Модуль:Персона
Внешний вид
Для документации этого модуля может быть создана страница Модуль:Персона/doc
--[[
Модуль:Персона — вся логика карточки персоны.
Почему Lua, а не ParserFunctions: карточке нужно разбирать частичные даты
(«1769-09-14», «1769-09», «1769», «-0106»), обрезать длинные списки до пяти
значений и не выводить пустые строки и пустые разделы. На #if/#switch это
превращается в нечитаемую простыню, которую невозможно проверить.
Шаблон:Персона остаётся тонкой обёрткой: он объявляет Cargo-таблицу,
записывает в неё строку и зовёт отсюда три функции —
`карточка`, `категории` и `часть_даты`.
Разделитель списков — точка с запятой, как требует templates/CONTRACT.md.
Запятая внутри значения («Ростов-на-Дону, Россия») не ломает разбор.
]]
local p = {}
--- Сколько значений списка показываем в карточке; остальное — «и ещё N».
local LIMIT = 5
local MONTHS = {
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
}
local MONTHS_NOM = {
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
}
--- Куда ведёт «и ещё N»: раздел статьи из CONTRACT.md, где список раскрыт
--- полностью. Поля, которых здесь нет, обрезаются без ссылки.
local ANCHOR = {
['профессии'] = 'Биография',
['организации'] = 'Карьера',
['должности'] = 'Карьера',
['награды'] = 'Награды',
['известные_работы'] = 'Биография',
}
-- ─── Мелкие помощники ───────────────────────────────────────────────────────
local function trim( s )
return mw.text.trim( s or '' )
end
local function blank( s )
return trim( s ) == ''
end
--- Разбивает значение-список по точке с запятой, выбрасывая пустые куски.
local function split( s )
local out = {}
for _, part in ipairs( mw.text.split( s or '', ';' ) ) do
part = mw.text.trim( part )
if part ~= '' then
out[#out + 1] = part
end
end
return out
end
--- Экранирует то, что уйдёт в HTML-атрибут или в текст без вики-разметки.
local function esc( s )
return mw.text.nowiki( s )
end
-- ─── Даты ───────────────────────────────────────────────────────────────────
--- Разбирает дату по контракту. Возвращает nil, если формат не опознан:
--- точность не додумывается.
local function parseDate( raw )
local s = trim( raw )
if s == '' then
return nil
end
local bce = false
if s:sub( 1, 1 ) == '-' then
bce = true
s = s:sub( 2 )
end
local y, m, d = s:match( '^(%d%d%d%d?)%-(%d%d)%-(%d%d)$' )
if not y then
y, m = s:match( '^(%d%d%d%d?)%-(%d%d)$' )
end
if not y then
y = s:match( '^(%d%d%d%d?)$' )
end
if not y then
return nil
end
local month = tonumber( m )
if month and ( month < 1 or month > 12 ) then
month = nil
d = nil
end
local day = tonumber( d )
if day and ( day < 1 or day > 31 ) then
day = nil
end
return { year = tonumber( y ), month = month, day = day, bce = bce }
end
--- «14 сентября 1769», «сентябрь 1769», «1769», «106 до н. э.»
local function formatDate( raw )
local t = parseDate( raw )
if not t then
-- Дата в неизвестном формате выводится как есть: терять её хуже,
-- чем показать необработанной.
return blank( raw ) and nil or trim( raw )
end
local year = tostring( t.year ) .. ( t.bce and ' до н. э.' or '' )
if t.month and t.day then
return string.format( '%d %s %s', t.day, MONTHS[t.month], year )
elseif t.month then
return string.format( '%s %s', MONTHS_NOM[t.month], year )
end
return year
end
local ROMAN = {
{ 1000, 'M' }, { 900, 'CM' }, { 500, 'D' }, { 400, 'CD' },
{ 100, 'C' }, { 90, 'XC' }, { 50, 'L' }, { 40, 'XL' },
{ 10, 'X' }, { 9, 'IX' }, { 5, 'V' }, { 4, 'IV' }, { 1, 'I' },
}
local function roman( n )
local out = {}
for _, pair in ipairs( ROMAN ) do
while n >= pair[1] do
out[#out + 1] = pair[2]
n = n - pair[1]
end
end
return table.concat( out )
end
-- ─── Списки со срезкой ──────────────────────────────────────────────────────
--- Возвращает вики-текст списка: первые LIMIT значений через запятую плюс
--- «и ещё N» со ссылкой на раздел статьи, где список раскрыт полностью.
local function renderList( field, raw, decorate )
local items = split( raw )
if #items == 0 then
return nil
end
local shown = {}
-- Если хоть в одном значении есть запятая («Ясная Поляна, Тульская
-- губерния»), запятая как разделитель перестаёт читаться — переходим на
-- точку с запятой.
local sep = ', '
for i = 1, math.min( #items, LIMIT ) do
if items[i]:find( ',', 1, true ) then
sep = '; '
end
shown[i] = decorate and decorate( items[i] ) or items[i]
end
local text = table.concat( shown, sep )
local hidden = #items - LIMIT
if hidden > 0 then
local anchor = ANCHOR[field]
local more = 'и ещё ' .. hidden
if anchor then
more = '[[#' .. anchor .. '|' .. more .. ']]'
end
text = text .. sep .. '<span class="pw-list__meta">' .. more .. '</span>'
end
return text
end
local function linkCategory( name )
return '[[:Категория:' .. name .. '|' .. name .. ']]'
end
local function linkPage( name )
return '[[' .. name .. ']]'
end
-- ─── Сборка таблицы ─────────────────────────────────────────────────────────
local Rows = {}
Rows.__index = Rows
local function newRows()
return setmetatable( { sections = {} }, Rows )
end
function Rows:section( title )
self.sections[#self.sections + 1] = { title = title, rows = {} }
return self
end
function Rows:add( label, value )
if value == nil or trim( tostring( value ) ) == '' then
return self
end
local s = self.sections[#self.sections]
s.rows[#s.rows + 1] = { label = label, value = value }
return self
end
function Rows:render( node )
local any = false
for _, section in ipairs( self.sections ) do
if #section.rows > 0 then
any = true
if section.title then
node:tag( 'tr' )
:tag( 'td' )
:addClass( 'pw-infobox__section' )
:attr( 'colspan', '2' )
:wikitext( section.title )
end
for _, row in ipairs( section.rows ) do
local tr = node:tag( 'tr' )
tr:tag( 'th' ):attr( 'scope', 'row' ):wikitext( row.label )
tr:tag( 'td' ):wikitext( row.value )
end
end
end
return any
end
-- ─── Изображение ────────────────────────────────────────────────────────────
--- Изображение появляется, только если подтверждена лицензия. Так решено в
--- CONTRACT.md: нет лицензии — нет картинки, даже если файл известен.
local function renderImage( args, box )
local file = trim( args['изображение'] )
local license = trim( args['лицензия_изображения'] )
if file == '' or license == '' then
return false
end
box:tag( 'div' )
:addClass( 'pw-infobox__image' )
:wikitext( '[[Файл:' .. file .. '|280x360px|alt=' ..
esc( trim( args['имя'] ) ) .. ']]' )
local bits = {}
local caption = trim( args['подпись'] )
if caption ~= '' then
bits[#bits + 1] = caption
end
local credit = {}
local author = trim( args['автор_изображения'] )
if author ~= '' then
credit[#credit + 1] = 'Автор: ' .. author
end
-- Ссылка на текст лицензии, если она указана: «CC BY-SA 4.0» само по себе
-- ничего не говорит читателю о том, что ему можно делать со снимком.
local licenseUrl = trim( args['ссылка_на_лицензию'] )
if licenseUrl ~= '' then
credit[#credit + 1] = 'лицензия: [' .. licenseUrl .. ' ' .. license .. ']'
else
credit[#credit + 1] = 'лицензия: ' .. license
end
-- Требуемая атрибуция — это условие лицензии, а не украшение. Если она
-- задана, показываем дословно.
local attribution = trim( args['атрибуция'] )
if attribution ~= '' then
credit[#credit + 1] = attribution
end
local page = trim( args['страница_файла'] )
if page ~= '' then
credit[#credit + 1] = '[' .. page .. ' страница файла]'
else
credit[#credit + 1] = '[[:Файл:' .. file .. '|страница файла]]'
end
bits[#bits + 1] = table.concat( credit, ' · ' )
box:tag( 'div' )
:addClass( 'pw-infobox__caption' )
:wikitext( table.concat( bits, '<br />' ) )
return true
end
-- ─── Публичные функции ──────────────────────────────────────────────────────
--- Аргументы шаблона. Модуль всегда зовут из Шаблон:Персона, но если кто-то
--- вызовет #invoke прямо со страницы, разбор не должен падать.
local function templateArgs( frame )
local parent = frame:getParent()
return parent and parent.args or frame.args
end
--- {{#invoke:Персона|карточка}} — вызывается из Шаблон:Персона.
---
--- Имена функций записаны как p['…'], а не через `function p.имя()`:
--- идентификаторы Lua — только ASCII, а вызывать из шаблонов хочется
--- по-русски, на языке, на котором написана вся остальная вики.
p['карточка'] = function ( frame )
local args = templateArgs( frame )
local name = trim( args['имя'] )
if name == '' then
name = mw.title.getCurrentTitle().text
end
local box = mw.html.create( 'div' ):addClass( 'pw-infobox' )
box:tag( 'div' ):addClass( 'pw-infobox__title' ):wikitext( name )
renderImage( args, box )
local rows = newRows()
rows:section( nil )
local full = trim( args['полное_имя'] )
if full ~= '' and full ~= name then
rows:add( 'Полное имя', full )
end
rows:add( 'В оригинале', trim( args['имя_оригинал'] ) )
rows:add( 'Варианты имени', renderList( 'варианты_имени', args['варианты_имени'] ) )
rows:section( 'Даты и места' )
rows:add( 'Дата рождения', formatDate( args['дата_рождения'] ) )
rows:add( 'Место рождения', trim( args['место_рождения'] ) )
rows:add( 'Дата смерти', formatDate( args['дата_смерти'] ) )
rows:add( 'Место смерти', trim( args['место_смерти'] ) )
rows:add( 'Гражданство', renderList( 'гражданство', args['гражданство'] ) )
rows:section( 'Деятельность' )
rows:add( 'Сфера', renderList( 'сферы', args['сферы'], linkCategory ) )
rows:add( 'Род занятий', renderList( 'профессии', args['профессии'] ) )
rows:add( 'Должности', renderList( 'должности', args['должности'] ) )
rows:add( 'Организации', renderList( 'организации', args['организации'] ) )
rows:add( 'Образование', renderList( 'образование', args['образование'] ) )
rows:section( 'Признание' )
rows:add( 'Награды', renderList( 'награды', args['награды'] ) )
rows:add( 'Известные работы', renderList( 'известные_работы', args['известные_работы'] ) )
rows:section( 'Связи' )
rows:add( 'Связанные люди', renderList( 'связанные_люди', args['связанные_люди'], linkPage ) )
local site = trim( args['сайт'] )
if site ~= '' then
rows:add( 'Сайт', '[' .. site .. ' официальный сайт]' )
end
rows:section( 'Идентификаторы' )
local wd = trim( args['wikidata'] )
if wd ~= '' then
rows:add( 'Wikidata', '[https://www.wikidata.org/wiki/' .. wd .. ' ' .. wd .. ']' )
end
local viaf = trim( args['viaf'] )
if viaf ~= '' then
rows:add( 'VIAF', '[https://viaf.org/viaf/' .. viaf .. ' ' .. viaf .. ']' )
end
local isni = trim( args['isni'] )
if isni ~= '' then
rows:add( 'ISNI', '[https://isni.org/isni/' .. isni .. ' ' .. isni .. ']' )
end
local orcid = trim( args['orcid'] )
if orcid ~= '' then
rows:add( 'ORCID', '[https://orcid.org/' .. orcid .. ' ' .. orcid .. ']' )
end
rows:section( 'Редакционные сведения' )
-- Статус вычисляет расширение по пространству страницы и числу
-- источников, заверенных человеком ({{#статус_публикации:}}). Свободное
-- поле `статус` из текста статьи сюда не идёт: у 48 статей массового
-- выпуска оно осталось «черновиком», и опубликованная страница называла
-- себя черновиком. Если шаблон аргумент не передал, строки нет вовсе —
-- лучше промолчать, чем сказать неправду.
rows:add( 'Статус', trim( frame.args['статус_публикации'] or '' ) )
-- Оба числа приходят из данных, а не из поля, которое заполняет человек.
-- Считает их расширение PersonoWiki по таблице Sources, то есть только те
-- источники, что оформлены шаблоном {{Источник}}. Обычные сноски <ref> в
-- эту таблицу не попадают и здесь не считаются — поэтому строка названа
-- «структурированных», а не «источников в статье»: статья с двумя сносками
-- и без шаблонов показывала «Источников в статье: 0», и это читалось как
-- утверждение, что источников нет вовсе.
-- «Проверено редакцией» — только источники типа independent_secondary,
-- подтверждённые другим редактором и подписанные его именем.
local total = trim( frame.args['источников_всего'] or '' )
local independent = trim( frame.args['источников_независимых'] or '' )
if total ~= '' then
rows:add( 'Структурированных источников', total )
end
if independent ~= '' then
rows:add( 'Проверено редакцией', independent )
end
-- Разметку сюда передаёт шаблон именованным аргументом самого #invoke:
-- {{#проверено:}} — парсер-функция расширения PersonoWiki, и разворачивать
-- её должен парсер, а не Lua.
rows:add( 'Проверено', trim( frame.args['проверено'] or args['проверено'] ) )
local tbl = mw.html.create( 'table' )
if rows:render( tbl ) then
box:node( tbl )
end
return tostring( box )
end
--- {{#invoke:Персона|категории}} — автоматические категории статьи.
p['категории'] = function ( frame )
local args = templateArgs( frame )
local out = { '[[Категория:Персоналии]]' }
for _, sphere in ipairs( split( args['сферы'] ) ) do
out[#out + 1] = '[[Категория:' .. sphere .. ']]'
end
local born = parseDate( args['дата_рождения'] )
if born and not born.bce and born.year then
local century = math.floor( ( born.year - 1 ) / 100 ) + 1
out[#out + 1] = '[[Категория:Родившиеся в ' .. roman( century ) .. ' веке]]'
end
-- Лицензия, которую никто не открывал и не подтверждал, — это заявление, а
-- не проверка. Отметка не убирает изображение, но показывает редакции, где
-- проверку ещё предстоит сделать.
if not blank( args['изображение'] ) and not blank( args['лицензия_изображения'] )
and ( blank( args['изображение_проверил'] ) or blank( args['изображение_проверено'] ) ) then
out[#out + 1] = '[[Категория:Изображения без подтверждённой лицензии]]'
end
if blank( args['изображение'] ) or blank( args['лицензия_изображения'] ) then
out[#out + 1] = '[[Категория:Персоналии без изображения]]'
end
return table.concat( out )
end
--- {{#invoke:Персона|часть_даты|1769-09-14|год}} — для записи в Cargo.
--- Пустая строка, если такой точности в дате нет: Cargo запишет NULL.
p['часть_даты'] = function ( frame )
local t = parseDate( frame.args[1] )
if not t then
return ''
end
local what = trim( frame.args[2] )
if what == 'год' then
return tostring( t.bce and -t.year or t.year )
elseif what == 'месяц' then
return t.month and tostring( t.month ) or ''
elseif what == 'день' then
return t.day and tostring( t.day ) or ''
end
return ''
end
--- Адрес файла без чужих меток учёта.
---
--- `{{filepath:}}` для файла с Викисклада возвращает адрес с хвостом
--- `?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=original`.
--- Это метки учёта чужого сайта, и они попадают ровно туда, где им не место:
--- в `og:image` и в поле `image` разметки JSON-LD, то есть в то, что видят
--- поисковые системы. Хвост ещё и длинный: на кириллическом имени файла, где
--- каждая буква занимает шесть знаков процентной записи, эти семьдесят шесть
--- знаков переполняют колонку `imageUrl`, и пересборка всей таблицы Persons
--- падает на одной строке, оставив таблицу пустой.
p['адрес_изображения'] = function ( frame )
local url = trim( frame.args[1] )
-- Скобки обязательны: gsub возвращает ещё и число замен.
return ( url:gsub( '%?.*$', '' ) )
end
--- {{#invoke:Персона|дата|1769-09-14}} — человекочитаемая дата для текста
--- статьи и для панелей заглавной страницы.
p['дата'] = function ( frame )
return formatDate( frame.args[1] ) or ''
end
--- То же, но для вызова из другого модуля, а не из разметки.
---
--- Понадобилось карточке сущности: организация, событие и произведение
--- показывали дату основания сырой записью «1809-08-16». Заводить второй
--- разбор дат в Модуль:Карточка нельзя — правила проекта о неполных датах и
--- о годах до нашей эры живут здесь, и две реализации разошлись бы на первом
--- же исправлении. Имя латиницей: Lua допускает в идентификаторах только
--- ASCII, а точку входа с русским именем через скобки из другого модуля
--- вызывать неудобно.
p.formatDate = formatDate
return p