Перейти к содержанию

Модуль:Персона

Материал из PERSONO.WIKI

Для документации этого модуля может быть создана страница Модуль:Персона/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