Модуль:Wikidata/positions
Материал из вики «Вече»
Дополнительные действия
Для документации этого модуля может быть создана страница Модуль:Wikidata/positions/doc
local p = {}
local config = require('Module:Wikidata/positions/config')
local linkFallbackProperties = config.linkFallbackProperties
local topPriorityClasses = config.topPriorityClasses
local highPriorityClasses = config.highPriorityClasses
local lowerPrioritySubclasses = config.lowerPrioritySubclasses
local lowPrioritySubclasses = config.lowPrioritySubclasses
local ignoredPositionProperties = config.ignoredPositionProperties
local ignoredEntities = config.ignoredEntities
local maleGender = config.maleGender
local femaleGender = config.femaleGender
local actingRoleIds = config.actingRoleIds
local entityCache = {}
local sitelinkCache = {}
local priorityCache = {}
local function isUsableStatement(statement)
return statement and statement.rank ~= 'deprecated'
end
local function getEntityId(snak)
if not snak or snak.snaktype ~= 'value' or not snak.datavalue then
return nil
end
local value = snak.datavalue.value
return type(value) == 'table' and value.id or nil
end
local function hasIgnoredPositionProperty(entity)
if not entity or not entity.claims then
return false
end
for _, propertyId in ipairs(ignoredPositionProperties) do
if entity.claims[propertyId] then
return true
end
end
return false
end
local function getEntity(id)
if not id then
return nil
end
if entityCache[id] ~= nil then
return entityCache[id] or nil
end
local entity = mw.wikibase.getEntity(id)
entityCache[id] = entity or false
return entity
end
local function getSitelink(id)
if not id then
return nil
end
if sitelinkCache[id] ~= nil then
return sitelinkCache[id] or nil
end
-- Current wiki is selected automatically.
local sitelink = mw.wikibase.getSitelink(id)
sitelinkCache[id] = sitelink or false
return sitelink
end
local function getConfiguredEntityId(options)
if type(options.entity) == 'string' then
return options.entity
end
if type(options.entityId) == 'string' then
return options.entityId
end
if type(options.from) == 'string' then
return options.from
end
return nil
end
local function getSourceEntity(options)
if type(options.entity) == 'table' then
return options.entity
end
local id = getConfiguredEntityId(options)
if id then
return getEntity(id)
end
return mw.wikibase.getEntity()
end
local function getQualifierSnak(statement, property)
local qualifiers = statement.qualifiers
local snaks = qualifiers and qualifiers[property]
return snaks and snaks[1] or nil
end
local function isActingStatement(statement)
local qualifiers = statement.qualifiers or {}
for _, property in ipairs({ 'P5102', 'P3831' }) do
for _, snak in ipairs(qualifiers[property] or {}) do
if actingRoleIds[getEntityId(snak)] then
return true
end
end
end
return false
end
local function parseTimeString(time)
if not time then
return nil
end
local year, month, day = time:match('^([%+%-]?%d+)%-(%d%d)%-(%d%d)')
if not year then
return nil
end
return tonumber(year) * 10000 + tonumber(month) * 100 + tonumber(day)
end
local function getDateFromSnak(snak)
return parseTimeString(
snak
and snak.datavalue
and snak.datavalue.value
and snak.datavalue.value.time
)
end
local function getClaimText(claim)
local value = claim.mainsnak
and claim.mainsnak.datavalue
and claim.mainsnak.datavalue.value
if claim.rank ~= 'deprecated'
and value
and value.language == 'ru'
and value.text
then
return value.text
end
return nil
end
-- Uses the name valid at the beginning of the tenure.
-- Берёт последнее название, которое пересекается со сроком должности.
local function selectDatedName(entity, property, termStart, termEnd)
if not entity or not entity.claims then
return nil
end
local selectedName = nil
local selectedStart = -math.huge
local undatedName = nil
for _, claim in ipairs(entity.claims[property] or {}) do
local text = getClaimText(claim)
if text then
local nameStart = getDateFromSnak(getQualifierSnak(claim, 'P580'))
local nameEnd = getDateFromSnak(getQualifierSnak(claim, 'P582'))
if nameStart or nameEnd then
local overlaps =
(not nameEnd or not termStart or nameEnd >= termStart)
and (not nameStart or not termEnd or nameStart <= termEnd)
if overlaps
and (not selectedName or (nameStart or -math.huge) > selectedStart)
then
selectedName = text
selectedStart = nameStart or -math.huge
end
elseif not undatedName then
undatedName = text
end
end
end
return selectedName or undatedName
end
local function getSubjectGender(entity)
if not entity or not entity.claims then
return nil
end
for _, property in ipairs({ 'P21', 'P31' }) do
for _, statement in ipairs(entity.claims[property] or {}) do
if isUsableStatement(statement) then
local id = getEntityId(statement.mainsnak)
if id == maleGender or id == femaleGender then
return id
end
end
end
end
return nil
end
local function isSubjectDead(entity)
if not entity or not entity.claims then
return false
end
for _, statement in ipairs(entity.claims.P570 or {}) do
if isUsableStatement(statement)
and getDateFromSnak(statement.mainsnak)
then
return true
end
end
return false
end
local function getFallbackSitelink(entity)
if not entity or not entity.claims then
return nil
end
for _, property in ipairs(linkFallbackProperties) do
for _, claim in ipairs(entity.claims[property] or {}) do
if isUsableStatement(claim) then
local fallbackId = getEntityId(claim.mainsnak)
if fallbackId then
local sitelink = getSitelink(fallbackId)
if sitelink then
return sitelink
end
end
end
end
end
return nil
end
local function uppercaseFirst(text)
if not text or text == '' then
return text
end
return mw.ustring.upper(mw.ustring.sub(text, 1, 1))
.. mw.ustring.sub(text, 2)
end
local function getPositionPriority(positionId, entity)
if priorityCache[positionId] then
return priorityCache[positionId]
end
local priority = {
top = false,
high = false,
lower = false,
low = false,
}
if entity and entity.claims then
for _, statement in ipairs(entity.claims.P279 or {}) do
if isUsableStatement(statement) then
local classId = getEntityId(statement.mainsnak)
if topPriorityClasses[classId] then
priority.top = true
end
if highPriorityClasses[classId] then
priority.high = true
end
if lowerPrioritySubclasses[classId] then
priority.lower = true
end
if lowPrioritySubclasses[classId] then
priority.low = true
end
end
end
end
-- При пересечении списков остаётся только более высокий уровень.
if priority.top then
priority.high = false
priority.lower = false
priority.low = false
elseif priority.high then
priority.lower = false
priority.low = false
elseif priority.lower then
priority.low = false
end
priorityCache[positionId] = priority
return priority
end
local function newState(context, options)
local subjectEntity = getSourceEntity(options)
return {
context = context,
options = options,
subjectEntity = subjectEntity,
subjectId = (subjectEntity and subjectEntity.id)
or getConfiguredEntityId(options),
subjectGender = getSubjectGender(subjectEntity),
subjectDead = isSubjectDead(subjectEntity),
descriptorCache = {},
}
end
local function getPositionDescriptor(state, statement)
if state.descriptorCache[statement] ~= nil then
return state.descriptorCache[statement] or nil
end
local positionId = getEntityId(statement.mainsnak)
if not positionId then
state.descriptorCache[statement] = false
return nil
end
-- Если в P39 указан общий класс, а не конкретная должность, не выводим его.
if ignoredEntities[positionId]
or topPriorityClasses[positionId]
or highPriorityClasses[positionId]
or lowerPrioritySubclasses[positionId]
or lowPrioritySubclasses[positionId]
then
state.descriptorCache[statement] = false
return nil
end
local positionEntity = getEntity(positionId)
if not positionEntity or hasIgnoredPositionProperty(positionEntity) then
state.descriptorCache[statement] = false
return nil
end
local defaultLabel = state.context.formatSnak(
state.options,
statement.mainsnak
)
local termStart = getDateFromSnak(getQualifierSnak(statement, 'P580'))
local termEnd = getDateFromSnak(getQualifierSnak(statement, 'P582'))
if not termEnd then
termEnd = tonumber(os.date('!%Y%m%d'))
end
local label = selectDatedName(positionEntity, 'P1813', termStart, termEnd)
if not label then
label = selectDatedName(positionEntity, 'P1448', termStart, termEnd)
end
if not label and state.subjectGender == maleGender then
label = selectDatedName(positionEntity, 'P3321', termStart, termEnd)
end
if not label and state.subjectGender == femaleGender then
label = selectDatedName(positionEntity, 'P2521', termStart, termEnd)
end
-- Automatic local-wiki label selection.
if not label then
label = mw.wikibase.getLabel(positionId)
end
local formattedLabel = defaultLabel
local groupKey = positionId .. '\30' .. positionId
if label then
local labelOptions = state.context.cloneOptions(state.options)
labelOptions.text = label
labelOptions.qualifiers = statement.qualifiers
formattedLabel = state.context.formatSnak(
labelOptions,
statement.mainsnak
)
groupKey = positionId .. '\30' .. label
if not getSitelink(positionId) then
local fallbackSitelink = getFallbackSitelink(positionEntity)
if fallbackSitelink then
formattedLabel =
'[['
.. fallbackSitelink
.. '|'
.. uppercaseFirst(label)
.. ']]'
end
end
end
local priority = getPositionPriority(positionId, positionEntity)
local descriptor = {
id = positionId,
groupKey = groupKey,
formattedLabel = formattedLabel,
topPriority = priority.top,
highPriority = priority.high,
lowerPriority = priority.lower,
lowPriority = priority.low,
}
state.descriptorCache[statement] = descriptor
return descriptor
end
local function getAllPositionClaims(state)
local entity = state.subjectEntity
local statements = entity
and entity.claims
and entity.claims.P39
or {}
local claims = {}
for _, statement in ipairs(statements) do
if isUsableStatement(statement) then
table.insert(claims, statement)
end
end
return claims
end
local function makePositionRecord(state, statement, sourceIndex)
local startSnak = getQualifierSnak(statement, 'P580')
local endSnak = getQualifierSnak(statement, 'P582')
-- Достаточно одной границы срока.
if not startSnak and not endSnak then
return nil
end
local position = getPositionDescriptor(state, statement)
if not position then
return nil
end
return {
statement = statement,
sourceIndex = sourceIndex,
position = position,
isActing = isActingStatement(statement),
startSnak = startSnak,
endSnak = endSnak,
predecessorSnak = getQualifierSnak(statement, 'P1365'),
successorSnak = getQualifierSnak(statement, 'P1366'),
startDate = getDateFromSnak(startSnak),
endDate = getDateFromSnak(endSnak),
}
end
local function collectPositionRecords(state)
local records = {}
for sourceIndex, statement in ipairs(getAllPositionClaims(state)) do
local record = makePositionRecord(state, statement, sourceIndex)
if record then
table.insert(records, record)
end
end
return records
end
local function hasPreviousTerm(records, record)
if not record.startDate then
return false
end
for _, candidate in ipairs(records) do
if candidate ~= record
and candidate.position.groupKey == record.position.groupKey
and candidate.isActing == record.isActing
and candidate.endDate == record.startDate
then
return true
end
end
return false
end
local function findNextTerm(records, record)
if not record.endDate then
return nil
end
for _, candidate in ipairs(records) do
if candidate ~= record
and candidate.position.groupKey == record.position.groupKey
and candidate.isActing == record.isActing
and candidate.startDate == record.endDate
then
return candidate
end
end
return nil
end
local function buildMergedEntry(records, firstRecord)
local lastRecord = firstRecord
local used = {
[firstRecord] = true,
}
while true do
local nextRecord = findNextTerm(records, lastRecord)
if not nextRecord or used[nextRecord] then
break
end
used[nextRecord] = true
lastRecord = nextRecord
end
return {
statement = firstRecord.statement,
sourceIndex = firstRecord.sourceIndex,
position = firstRecord.position,
isActing = firstRecord.isActing,
startSnak = firstRecord.startSnak,
endSnak = lastRecord.endSnak,
predecessorSnak = firstRecord.predecessorSnak,
successorSnak = lastRecord.successorSnak,
startDate = firstRecord.startDate,
endDate = lastRecord.endDate,
}
end
local function buildMergedEntries(records)
local entries = {}
for _, record in ipairs(records) do
if not hasPreviousTerm(records, record) then
table.insert(entries, buildMergedEntry(records, record))
end
end
return entries
end
-- Same QID + same selected title become one group.
local function buildPositionGroups(entries)
local groupsByKey = {}
local groups = {}
for _, entry in ipairs(entries) do
local key = entry.position.groupKey
local group = groupsByKey[key]
if not group then
group = {
key = key,
title = entry.position.formattedLabel,
firstIndex = entry.sourceIndex,
topPriority = false,
highPriority = false,
lowerPriority = false,
lowPriority = false,
hasCurrent = false,
latestEnd = nil,
entries = {},
}
groupsByKey[key] = group
table.insert(groups, group)
end
table.insert(group.entries, entry)
group.topPriority = group.topPriority or entry.position.topPriority
group.highPriority = group.highPriority or entry.position.highPriority
group.lowerPriority = group.lowerPriority or entry.position.lowerPriority
group.lowPriority = group.lowPriority or entry.position.lowPriority
if not entry.endSnak then
group.hasCurrent = true
end
if entry.endDate
and (not group.latestEnd or entry.endDate > group.latestEnd)
then
group.latestEnd = entry.endDate
end
end
for _, group in ipairs(groups) do
table.sort(group.entries, function(a, b)
-- Действующий срок всегда идёт первым.
local aCurrent = not a.endSnak
local bCurrent = not b.endSnak
if aCurrent ~= bCurrent then
return aCurrent
end
-- Остальные сроки — от более позднего начала к более раннему.
if a.startDate ~= b.startDate then
if not a.startDate then
return false
end
if not b.startDate then
return true
end
return a.startDate > b.startDate
end
-- Если даты начала совпали, выше идёт более позднее окончание.
if a.endDate ~= b.endDate then
if not a.endDate then
return true
end
if not b.endDate then
return false
end
return a.endDate > b.endDate
end
return a.sourceIndex < b.sourceIndex
end)
end
return groups
end
-- Current → top → normal → low → newest completed position.
-- Claims inside one group are never separated.
local function orderPositionGroups(state, groups)
table.sort(groups, function(a, b)
local aCurrent = not state.subjectDead and a.hasCurrent
local bCurrent = not state.subjectDead and b.hasCurrent
if aCurrent ~= bCurrent then
return aCurrent
end
if a.topPriority ~= b.topPriority then
return a.topPriority
end
if a.highPriority ~= b.highPriority then
return a.highPriority
end
-- «Low» сразу отправляем в конец.
if a.lowPriority ~= b.lowPriority then
return not a.lowPriority
end
-- Среди остальных «lower» идут после обычных.
if a.lowerPriority ~= b.lowerPriority then
return not a.lowerPriority
end
if a.latestEnd ~= b.latestEnd then
if not a.latestEnd then
return false
end
if not b.latestEnd then
return true
end
return a.latestEnd > b.latestEnd
end
return a.firstIndex < b.firstIndex
end)
end
local function formatOrganizations(state, statement)
local qualifiers = statement.qualifiers
and statement.qualifiers.P642
if not qualifiers then
return nil
end
local organizations = {}
for _, snak in ipairs(qualifiers) do
local formatted = state.context.formatSnak(state.options, snak)
if formatted and formatted ~= '' then
table.insert(organizations, formatted)
end
end
if #organizations == 0 then
return nil
end
return ' (' .. mw.text.listToText(organizations) .. ')'
end
local function formatSuccessionSnak(state, snak, noValueText)
if not snak then
return nil
end
if snak.snaktype == 'novalue' then
return '<i>' .. noValueText .. '</i>'
end
if getEntityId(snak) == state.subjectId then
if state.subjectGender == maleGender then
return '<i>он сам</i>'
end
if state.subjectGender == femaleGender then
return '<i>она сама</i>'
end
end
return state.context.formatSnak(state.options, snak)
end
-- Форматируем дату обычным способом, затем убираем только ссылки.
local function formatPlainDate(state, snak)
local text = state.context.formatSnak(state.options, snak)
text = text:gsub('%[%[([^%[%]|]+)|([^%[%]]+)%]%]', '%2')
text = text:gsub('%[%[([^%[%]]+)%]%]', '%1')
return text
end
local function getFullDateParts(date)
if not date then
return nil
end
local plain = date:gsub('<[^>]->', '')
plain = plain:gsub(' ', ' ')
plain = plain:gsub(' ', ' ')
return mw.ustring.match(
mw.text.trim(plain),
'^(%d%d?)%s+([^%s]+)%s+(%d%d%d%d)$'
)
end
-- Сокращаем только диапазон из двух обычных полных дат.
local function formatDateRange(startDate, endDate)
local startDay, startMonth, startYear = getFullDateParts(startDate)
local endDay, endMonth, endYear = getFullDateParts(endDate)
-- Неполные, старостильные и другие нестандартные даты не трогаем.
if not startDay or not endDay or startYear ~= endYear then
return startDate .. ' — ' .. endDate
end
if startMonth == endMonth and startDay ~= endDay then
return '<span class="nowrap">'
.. startDay .. '—' .. endDay
.. ' ' .. endMonth .. ' ' .. endYear
.. '</span>'
end
if startMonth ~= endMonth then
return '<span class="nowrap">'
.. startDay .. ' ' .. startMonth
.. ' — ' .. endDay .. ' ' .. endMonth .. ' ' .. endYear
.. '</span>'
end
return startDate .. ' — ' .. endDate
end
local function formatPeriod(state, entry)
local startDate = entry.startSnak
and formatPlainDate(state, entry.startSnak)
local endDate = entry.endSnak
and formatPlainDate(state, entry.endSnak)
if not startDate and endDate then
startDate = state.context.formatSnak(
state.options,
{ snaktype = 'somevalue' }
)
end
if not startDate then
return nil
end
if endDate then
return formatDateRange(startDate, endDate)
end
return 'с ' .. startDate
end
local function formatActingNote(state, entry)
local period = formatPeriod(state, entry)
if not period then
return nil
end
local abbreviation = state.options.frame:expandTemplate{
title = 'и.о.',
}
return '<br><small>(' .. abbreviation .. ' ' .. period .. ')</small>'
end
local function getActingTitle(state)
if state.subjectGender == femaleGender then
return 'исполняющая обязанности'
end
return 'исполняющий обязанности'
end
-- Компактные сроки и. о. добавляются к обычной должности.
local function prepareActingEntries(state, group)
local regularEntries = {}
for _, entry in ipairs(group.entries) do
entry.hidden = nil
entry.actingNotes = nil
if not entry.isActing then
table.insert(regularEntries, entry)
end
end
if #regularEntries == 0 then
return false
end
for _, entry in ipairs(group.entries) do
if entry.isActing then
local note = formatActingNote(state, entry)
local target = nil
local actingBefore = false
-- И. о. закончился в день начала обычного срока.
if note and entry.endDate then
for _, regular in ipairs(regularEntries) do
if regular.startDate == entry.endDate then
target = regular
actingBefore = true
break
end
end
end
-- И. о. начался в день окончания обычного срока.
if note and not target and entry.startDate then
for _, regular in ipairs(regularEntries) do
if regular.endDate == entry.startDate then
target = regular
break
end
end
end
if target then
target.actingNotes = (target.actingNotes or '') .. note
-- Берём предшественника самого раннего срока.
if actingBefore and entry.predecessorSnak then
target.predecessorSnak = entry.predecessorSnak
end
-- Берём преемника самого позднего срока.
if not actingBefore and entry.successorSnak then
target.successorSnak = entry.successorSnak
end
entry.hidden = true
-- Несвязанный срок и. о. без преемника и предшественника
-- можно показать компактно у первой обычной должности.
elseif not entry.predecessorSnak and not entry.successorSnak then
target = regularEntries[1]
target.actingNotes = (target.actingNotes or '') .. note
entry.hidden = true
end
end
end
return true
end
local function formatPositionEntry(state, entry, heading, actingOnly)
local args = {}
if heading then
heading = state.context.wrapStatement(
heading,
state.options.property,
entry.statement.id
)
args['должность_'] =
'<span class="ts-wikidata-positions-uppercasefirst">'
.. heading
.. '</span>'
if actingOnly and entry.isActing then
args['должность_'] = args['должность_']
.. '<br><small>('
.. getActingTitle(state)
.. ')</small>'
end
local organizations = formatOrganizations(state, entry.statement)
if organizations then
args['должность*'] = organizations
end
else
args['должность_'] =
'<span class="ts-wikidata-positions-hidden-header">​</span>'
end
local period = formatPeriod(state, entry)
if entry.isActing and not actingOnly and period then
local abbreviation = state.options.frame:expandTemplate{
title = 'и.о.',
}
period = abbreviation .. ' ' .. period
end
if period and entry.actingNotes then
period = period .. entry.actingNotes
end
if period then
args['период правления_'] = period
end
local predecessor = formatSuccessionSnak(
state,
entry.predecessorSnak,
'должность учреждена'
)
if predecessor then
args['предшественник_'] = predecessor
end
local successor = formatSuccessionSnak(
state,
entry.successorSnak,
'должность упразднена'
)
if successor then
args['преемник_'] = successor
end
local result = state.options.frame:expandTemplate{
title = 'Государственный деятель/Должность',
args = args,
}
if not heading then
result = result:gsub(
'<tr[^>]*>.-ts%-wikidata%-positions%-hidden%-header.-</tr>%s*',
'',
1
)
end
return result
end
local function getStyles(options)
return options.frame:extensionTag(
'templatestyles',
'',
{ src = 'Шаблон:Wikidata/positions/styles.css' }
)
end
function p.formatPositionClaims(context, options)
if not context then error('context is not specified') end
if not options then error('options is not specified') end
local state = newState(context, options)
local records = collectPositionRecords(state)
local entries = buildMergedEntries(records)
if #entries == 0 then
return ''
end
local groups = buildPositionGroups(entries)
orderPositionGroups(state, groups)
local results = {
getStyles(options),
}
for _, group in ipairs(groups) do
local hasRegularEntry = prepareActingEntries(state, group)
local headingShown = false
for _, entry in ipairs(group.entries) do
if not entry.hidden then
local heading = nil
if not headingShown then
heading = group.title
if heading and mw.text.trim(heading) == '' then
heading = nil
end
end
local result = formatPositionEntry(
state,
entry,
heading,
not hasRegularEntry
)
if result ~= '' then
table.insert(results, result)
headingShown = true
end
end
end
end
return table.concat(results, '\n')
end
-- Compatibility with the old claim-function call.
function p.formatPositionClaim(context, options, statement)
if not context then error('context is not specified') end
if not options then error('options is not specified') end
if not statement then error('statement is not specified') end
if not isUsableStatement(statement) then
return ''
end
local state = newState(context, options)
local record = makePositionRecord(state, statement, 1)
if not record then
return ''
end
local entry = {
statement = statement,
sourceIndex = 1,
position = record.position,
isActing = record.isActing,
startSnak = record.startSnak,
endSnak = record.endSnak,
predecessorSnak = record.predecessorSnak,
successorSnak = record.successorSnak,
startDate = record.startDate,
endDate = record.endDate,
}
return getStyles(options)
.. formatPositionEntry(
state,
entry,
entry.position.formattedLabel,
entry.isActing
)
end
return p