Модуль:Math/tonumber
Материал из вики «Вече»
Дополнительные действия
Для документации этого модуля может быть создана страница Модуль:Math/tonumber/doc
--[[
Модуль для преобразования строк в числа и формирования категорий
Поддерживает: годы, десятилетия, века, тысячелетия (включая «до н. э.»)
]]
local p = {}
local U, T = mw.ustring, mw.text
local floor, ceil, abs = math.floor, math.ceil, math.abs
-------------------------------------------------------------------------------
-- КОНСТАНТЫ
-------------------------------------------------------------------------------
local ERR_DATE = 'Категория:Википедия:Ошибки распознавания даты в Math/tonumber'
local ROMAN = {
toArabic = {I = 1, V = 5, X = 10, L = 50, C = 100, D = 500, M = 1000},
fromArabic = {
{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 BC_PATTERN = 'до%s*н%.?%s*э'
-------------------------------------------------------------------------------
-- СЛОВА О ВРЕМЕНИ
-------------------------------------------------------------------------------
local TIME_WORDS = {
{'год[%a]*'}, {'гг?', dot = true}, {'лет'}, {'десятилет[%a]*'},
{'век[%a]*'}, {'вв?', dot = true},
{'тысячелет[%a]*'}, {'тыс', dot = true},
{'до%s*н%.?%s*э%.?'}, {'н%.?%s*э%.?'},
}
local TIME_AFTER = {}
for _, w in ipairs(TIME_WORDS) do
TIME_AFTER[#TIME_AFTER + 1] = '^%s*' .. w[1] .. '%.?'
end
for _, pat in ipairs({'^%s*или[%s%p]', '^%s*and[%s%p]', '^%s*or[%s%p]'}) do
TIME_AFTER[#TIME_AFTER + 1] = pat
end
-------------------------------------------------------------------------------
-- МЕСЯЦЫ И КЛЮЧЕВЫЕ СЛОВА
-------------------------------------------------------------------------------
local MONTHS = {
'январ[яье]', 'феврал[яье]', 'март[ае]?', 'апрел[яье]',
'ма[йяе]', 'июн[яье]', 'июл[яье]', 'август[ае]?',
'сентябр[яье]', 'октябр[яье]', 'ноябр[яье]', 'декабр[яье]'
}
local CANCEL_KEYWORDS = {
'не%s*состоял', 'не%s*выпу', 'отменён', 'отменен',
'невышедш', 'неиздан', 'заморожен', 'законсервир',
'не%s*начат', 'остановлен', 'не%s*был',
'не%s*достроен', 'не%s*завершён', 'не%s*завершен'
}
local PLANNED_KEYWORDS = {
'^tba$', '^%{%{%s*н%.?%s*в%.?%s*%}%}$',
'^н%.?%s*в%.?$', '^наст%.?%s*время$',
'запланирован', 'строится'
}
local MILLENNIA_AGO_PATTERN = 'тыс%.?%s*лет%s*назад'
-------------------------------------------------------------------------------
-- ПАТТЕРНЫ ДЛЯ УДАЛЕНИЯ (шум)
-------------------------------------------------------------------------------
local NOISE = {
-- HTML
{pat = '<sup.-</sup>', use = 'gsub'},
{pat = '<ref.-</ref>', use = 'gsub'},
{pat = '<ref[^/]*/?>', use = 'gsub'},
{pat = '<br.->', repl = '\n', use = 'gsub'},
{pat = '<span[^>]-display%s*:%s*none[^>]*>.-</span>', use = 'gsub'},
{pat = '<[^<>]+>', use = 'gsub'},
-- Категории
{pat = '%[%[[Cc]ategory:.-%]%]', use = 'gsub'},
{pat = '%[%[[Кк]атегория:.-%]%]', use = 'ugsub'},
-- Возрасты
{pat = '[;,]%s*%d+%s+[гл][ое][дт][%a]* назад', use = 'ugsub'},
{pat = '%(%d+[^%)]*[гл][ое][дт][^%)]*%)', use = 'ugsub'},
-- Идентификаторы в скобках
{pat = '%([^%)]*[A-Za-zА-Яа-яЁё][^%)]*[%-%/][^%)]*%d+[^%)]*%)', use = 'ugsub'},
{pat = '%(%d+%-й[^%)]*%)', use = 'ugsub'},
{pat = '%(as [^%)]+%)', use = 'ugsub'},
{pat = '%(как [^%)]+%)', use = 'ugsub'},
-- Временные уточнения
{pat = '[нН]ачал[оа]%s+', use = 'ugsub'},
{pat = '[сС]ередин[ауые]%s+', use = 'ugsub'},
{pat = '[кК]он[ец][цчае]?%s+', use = 'ugsub'},
-- Номера
{pat = '№%s*%d+', use = 'ugsub'},
}
-------------------------------------------------------------------------------
-- ПАТТЕРНЫ ДЛЯ СКАНИРОВАНИЯ
-------------------------------------------------------------------------------
local MILL_BASE = '(%d+)%s*%-?%s*[%aА-Яа-яЁё]*%s*тыс%.?'
local SCAN = {
-- Тысячелетия
{t = 'millennium', p = '(%d+)%-?[его]*%s+тысячелет'},
{t = 'millennium', p = MILL_BASE .. '%s*до%s*н%.?%s*э%.?', bc = true},
{t = 'millennium', p = MILL_BASE .. '%s*н%.?%s*э%.?', bc = false},
{t = 'millennium', p = MILL_BASE .. '%s*$'},
-- Века (римские)
{t = 'century', p = '([IVXLCDM]+)%s*вв?%.?е?к?[ауе]?', f = 'roman', ctx = true},
{t = 'century', p = '([IVXLCDM]+)%s*[%-%–—]%s*[IVXLCDM]+', f = 'roman'},
{t = 'century', p = '[%s%[(]([IVXLCDM]+)[%s%])]', f = 'roman'},
{t = 'century', p = '^%s*([IVXLCDM]+)%s*$', f = 'roman'},
{t = 'century', p = '%d%-[яй]%s+пол[ао]?в?и?н?[аa]?%.?%s*([IVXLCDM]+)', f = 'roman'},
-- Века (арабские)
{t = 'century', p = '(%d+)%s*век[ауе]?%s*до%s*н%.?%s*э', bc = true, ctx = true},
{t = 'century', p = '(%d+)%s*вв?%.%s*до%s*н%.?%s*э', bc = true, ctx = true},
{t = 'century', p = '(%d+)%s*век[ауе]?', ctx = true},
{t = 'century', p = '(%d+)%s*вв?%.', ctx = true},
-- Десятилетия
{t = 'decade', p = '(%d+0)%-?[ехxeЕХE]'},
{t = 'decade', p = '[%s%[(](0)%-?[ехxeЕХE]'},
{t = 'decade', p = '^(0)%-?[ехxeЕХE]'},
-- Годы с эпохой
{t = 'year', p = '(%d+)%s+лет%s+до%s*н%.?%s*э%.?', bc = true},
{t = 'year', p = '(%d+)%s+г?%.?%s*до%sн%.%s?э', bc = true},
{t = 'year', p = '(%d+)%s*до%s*н%s+э', bc = true},
{t = 'year', p = '(%d+)%s*донэ', bc = true},
{t = 'year', p = '(%d+)%s+г?%.?%s*н%.%s?э', bc = false},
{t = 'year', p = '(%d+)%s+н%s*э', bc = false},
{t = 'year', p = '(%d+)%s*нэ', bc = false},
-- Годы с контекстом
{t = 'year', p = '(%d+) лет назад', rel = true},
{t = 'year', p = '(%d+)%-?[ймго]%.?%s*[гл][ое][дт]'},
{t = 'year', p = '(%d+)%s+год[ауеом]?'},
{t = 'year', p = '(%d+)%s+г%.'},
-- Форматы дат
{t = 'year', p = '%d%d?%.%d%d?%.(%d%d%d%d)'},
{t = 'year', p = '%d%d?%.%d%d?%.(%d%d%d)'},
{t = 'year', p = '%d%d?%.%d%d?%.(%d%d)[^%d]'},
{t = 'year', p = '(%d%d%d%d)%.%d%d?%.%d%d?'},
{t = 'year', p = '%d%d?%.(%d%d%d%d)'},
{t = 'year', p = '(%d%d%d%d)%-%d%d%-%d%d'},
{t = 'year', p = '(%d%d%d%d?)%-[01]%d[^%d]'},
{t = 'year', p = '(%d%d%d%d?)%-[01]%d$'},
-- Числа в контексте
{t = 'year', p = '[%s%(%+%-;%[/~,](%d%d%d%d)%D'},
{t = 'year', p = '[%s%(%+%-;%[/~,](%d%d%d)%D'},
{t = 'year', p = '[%s%(%+%-;%[/~,](%d%d%d%d)$'},
{t = 'year', p = '[%s%(%+%-;%[/~,](%d%d%d)$'},
{t = 'year', p = '^(%d%d%d%d)%D'},
{t = 'year', p = '^(%d%d%d)%D'},
{t = 'year', p = '%(%s*(%d%d%d%d)%s*%)'},
{t = 'year', p = '[%s;,](%d%d)%s*%('},
-- 2-значные годы с явным контекстом
{t = 'year', p = '%]%]%s*(%d%d)%s'},
{t = 'year', p = '%]%]%s*(%d%d)%)'},
{t = 'year', p = '%]%]%s*(%d%d)$'},
{t = 'year', p = '[%s;,](%d%d)%s+год[ауеом]?'},
{t = 'year', p = '[%s;,](%d%d)%s+г%.'},
{t = 'year', p = '[%s;,](%d%d)%s*%)'},
{t = 'year', p = '[%s;,](%d%d) $'},
{t = 'year', p = '^ (%d%d)[%;%s]'},
{t = 'year', p = '^ (%d%d) $'},
}
-------------------------------------------------------------------------------
-- РИМСКИЕ ЦИФРЫ
-------------------------------------------------------------------------------
local function romanToArabic(s)
if not s or s == '' then return nil end
s = s:upper()
local result, prev = 0, 0
for i = #s, 1, -1 do
local v = ROMAN.toArabic[s:sub(i, i)]
if not v then return nil end
result = result + (v < prev and -v or v)
prev = v
end
return result
end
local function arabicToRoman(n)
if not n or n <= 0 then return nil end
local result = ''
for _, pair in ipairs(ROMAN.fromArabic) do
while n >= pair[1] do
result = result .. pair[2]
n = n - pair[1]
end
end
return result
end
-------------------------------------------------------------------------------
-- УТИЛИТЫ
-------------------------------------------------------------------------------
local function normDash(s)
return U.gsub(s, '[—–‑−]', '-')
end
local function stripWiki(s)
s = U.gsub(s, '%[%[[^%]|]*|([^%]]*)%]%]', '%1')
return U.gsub(s, '%[%[([^%]]*)%]%]', '%1')
end
local function stripHtml(s)
return U.gsub(s or '', '<[^<>]+>', '')
end
local function hasBC(s)
return U.match(U.lower(s), BC_PATTERN) ~= nil
end
local function isTruthy(v)
if not v then return false end
local s = T.trim(v):lower()
return s == '1' or s == 'yes'
end
local function matchesAny(s, patterns)
for _, pattern in ipairs(patterns) do
if U.match(s, pattern) then return true end
end
return false
end
local function dateErrCat(t)
return (t and t ~= '') and (ERR_DATE .. ' (' .. t .. ')') or ERR_DATE
end
local function signedVal(r)
if r.bc then return r.value == 0 and '-0' or -r.value end
return r.value
end
local function adjustPrep(title, n)
if n == 2 and U.match(title, '%sв$') then
return U.gsub(title, '%sв$', ' во')
end
return title
end
local function isTimeWord(after)
local chunk = U.sub(U.lower(after), 1, 15) or ''
for _, pat in ipairs(TIME_AFTER) do
if U.match(chunk, pat) then return true end
end
return false
end
local function hasMonthContext(s)
local lower = U.lower(s or '')
for _, m in ipairs(MONTHS) do
if U.match(lower, m .. '%s*$') or U.match(lower, m .. '%s+%d%d?[,;]%s*$') then
return true
end
end
return false
end
-------------------------------------------------------------------------------
-- ФОРМАТИРОВАНИЕ
-------------------------------------------------------------------------------
local FMT = {
year = {
cat = {
function(v, bc) return v .. ' году' .. (bc and ' до н. э.' or '') end,
function(v, bc) return v .. ' года' .. (bc and ' до н. э.' or '') end,
},
str = {
short = function(v, bc) return tostring(v) .. (bc and ' до н. э.' or '') end,
i = function(v, bc) return tostring(v) .. ' год' .. (bc and ' до н. э.' or '') end,
r = function(v, bc) return tostring(v) .. ' года' .. (bc and ' до н. э.' or '') end,
p = function(v, bc) return 'в ' .. tostring(v) .. ' году' .. (bc and ' до н. э.' or '') end,
},
},
decade = {
cat = {
function(v, bc) return v .. '-е годы' .. (bc and ' до н. э.' or '') end,
function(v, bc) return v .. '-х годов' .. (bc and ' до н. э.' or '') end,
},
str = {
short = function(v, bc) return v .. '-е' .. (bc and ' до н. э.' or '') end,
i = function(v, bc) return v .. '-е годы' .. (bc and ' до н. э.' or '') end,
r = function(v, bc) return v .. '-х годов' .. (bc and ' до н. э.' or '') end,
p = function(v, bc) return 'в ' .. v .. '-х годах' .. (bc and ' до н. э.' or '') end,
},
},
century = {
cat = {
function(v, bc) return arabicToRoman(v) .. ' веке' .. (bc and ' до н. э.' or '') end,
function(v, bc) return arabicToRoman(v) .. ' века' .. (bc and ' до н. э.' or '') end,
},
str = {
short = function(v, bc) return arabicToRoman(v) .. (bc and ' до н. э.' or '') end,
i = function(v, bc) return arabicToRoman(v) .. ' век' .. (bc and ' до н. э.' or '') end,
r = function(v, bc) return arabicToRoman(v) .. ' века' .. (bc and ' до н. э.' or '') end,
p = function(v, bc)
local prep = v == 2 and 'во ' or 'в '
return prep .. arabicToRoman(v) .. ' веке' .. (bc and ' до н. э.' or '')
end,
},
},
millennium = {
cat = {
function(v, bc) return v .. '-м тысячелетии' .. (bc and ' до н. э.' or '') end,
function(v, bc) return v .. '-го тысячелетия' .. (bc and ' до н. э.' or '') end,
},
str = {
short = function(v, bc) return v .. '-е' .. (bc and ' до н. э.' or '') end,
i = function(v, bc) return v .. '-е тысячелетие' .. (bc and ' до н. э.' or '') end,
r = function(v, bc) return v .. '-го тысячелетия' .. (bc and ' до н. э.' or '') end,
p = function(v, bc)
local prep = v == 2 and 'во ' or 'в '
return prep .. v .. '-м тысячелетии' .. (bc and ' до н. э.' or '')
end,
},
},
}
local YEAR_FMT = {
['годы'] = function(y) return floor(y / 10) .. '0-е' end,
['годов'] = function(y) return floor(y / 10) .. '0-х' end,
['века'] = function(y) return arabicToRoman(ceil(y / 100)) end,
['тысячелетия'] = function(y) return ceil(y / 1000) .. '-го' end,
}
-------------------------------------------------------------------------------
-- ОЧИСТКА ВХОДА
-------------------------------------------------------------------------------
local function cleanInput(s)
if not s then return nil, false end
s = T.unstrip(s)
-- Нормализация
s = U.gsub(s, '&#(%d+);', function(n) return U.char(tonumber(n)) end)
s = U.gsub(s, ' ', ' '):gsub(' ', ' ')
:gsub('<', '<'):gsub('>', '>'):gsub('&', '&')
s = U.gsub(s, ' ', ' ')
s = normDash(s)
-- Удаление шума
for _, n in ipairs(NOISE) do
if n.use == 'gsub' then s = s:gsub(n.pat, n.repl or '')
else s = U.gsub(s, n.pat, n.repl or '') end
end
-- Проверка месяца до удаления ссылок
local lower = U.lower(s)
local hasMonth = false
for _, m in ipairs(MONTHS) do
if U.match(lower, m) then hasMonth = true break end
end
s = stripWiki(s)
s = s:gsub('(%d) (%d%d%d)', '%1%2')
local trimmed = T.trim(s)
if trimmed == '' or trimmed == '-' then return nil, false end
return trimmed, hasMonth
end
-------------------------------------------------------------------------------
-- ПАРСЕР: СКАНИРОВАНИЕ
-------------------------------------------------------------------------------
local function scan(padded, out, spec, bcGlobal)
local init = 1
local valFunc = spec.f == 'roman' and romanToArabic or nil
while true do
local si, ei, c1 = U.find(padded, spec.p, init)
if not si then break end
local val = valFunc and valFunc(c1) or tonumber(c1)
if val then
local valStr = c1 or tostring(abs(val))
local numPos = U.find(padded, valStr, si, true)
local numEnd = numPos and (numPos + U.len(valStr) - 1) or ei
local bc = spec.bc
if bc == nil then bc = bcGlobal and U.match(padded, '%s*до%s*н%.?%s*э', ei) ~= nil or false end
out[#out + 1] = {
type = spec.t, value = abs(val), pos = numPos or si, endPos = numEnd,
matchEnd = ei, bc = bc, rel = spec.rel, short = spec.short,
rangeSecond = spec.rangeSecond, ctx = spec.ctx
}
end
init = si + 1
end
end
-------------------------------------------------------------------------------
-- ПАРСЕР: КЛАССИФИКАЦИЯ ГОДА
-------------------------------------------------------------------------------
local function classifyYear(c, padded, hasMonth)
local before = U.sub(padded, c.pos > 10 and c.pos - 10 or 1, c.pos - 1) or ''
local after = U.sub(padded, c.endPos + 1, c.endPos + 20) or ''
local afterL = U.lower(after)
-- Явный год
if c.bc or U.match(before, '[%.%-]%d+$') or U.match(after, '^[%.%-]%d')
or U.match(afterL, '^%s*гг?%.')
or U.match(afterL, '^%s*г[оа]?[дуеом]?[%s%p]') or U.match(afterL, '^%s*г[оа]?[дуеом]?$')
or U.match(afterL, '^%s*н%.%s*э') or U.match(afterL, '^%s*до%s*н%.%s*э')
or (c.value < 100 and U.match(before, '%]%]%s*$'))
or hasMonthContext(before) then
return true, false, false
end
-- Исключения
if U.match(after, '^[,.]%d') then return false, false, true end
if U.match(afterL, '^%s*тыс') then return false, false, true end
if U.match(afterL, '^%s*вв?%.') or U.match(afterL, '^%s*век') then return false, false, true end
if hasMonth and c.value < 1000 and U.match(before, '%(%s*$') then return false, false, true end
if c.value < 100 and U.match(before, '[A-Za-zА-Яа-яЁё][%-/]$') then return false, false, true end
if U.match(after, '^%s*:') and U.match(before, '[A-Za-zА-Яа-яЁё]%s*$') then return false, false, true end
if U.match(afterL, '^%-[ойыегмх][йуюа]?%s+[^гвд]') then return false, false, true end
if hasMonth and c.value <= 31 and not U.match(afterL, '^%s*г[оа]?[дуеом]?') then return false, false, true end
if c.value % 10 == 0 and U.match(afterL, '^%s*[-‑–—]?[ехx]') and not U.match(afterL, '^%s*[-‑–—]?[ivxlcdm]+%s*в') then
return false, false, true
end
-- Слабый кандидат
if not U.find(after, '\n', 1, true) and not isTimeWord(after) then
-- короткое слово (1–2 буквы) после числа — вероятно единица, не год
-- исключение: за ним сразу число (диапазон типа "по 2000")
local word = U.match(afterL, '^%s*([%aА-Яа-яЁё]+)')
if word and U.len(word) <= 2 and not U.match(afterL, '^%s*[%aА-Яа-яЁё]+%.?%s+%d') then
return false, true, false
end
if U.match(afterL, '^%s*[%aА-Яа-яЁё][%aА-Яа-яЁё][%aА-Яа-яЁё]') then return false, true, false end
end
return false, false, false
end
-------------------------------------------------------------------------------
-- ПАРСЕР: ФИЛЬТРАЦИЯ
-------------------------------------------------------------------------------
local function filterCandidates(candidates, hasMonth, padded)
table.sort(candidates, function(a, b)
return a.pos < b.pos or (a.pos == b.pos and a.endPos > b.endPos)
end)
local filtered, hasStrong = {}, false
for _, c in ipairs(candidates) do
if c.type == 'year' then c.explicit, c.weak, c.invalid = classifyYear(c, padded, hasMonth) end
if not (c.type == 'year' and c.invalid) then
local dominated = false
for _, a in ipairs(filtered) do
if c.pos >= a.pos and c.endPos <= a.endPos then dominated = true break end
end
if not dominated then
filtered[#filtered + 1] = c
if c.type ~= 'year' or not c.weak then hasStrong = true end
end
end
end
-- Убираем слабые если есть сильные
if hasStrong then
local strong = {}
for _, c in ipairs(filtered) do
if c.type ~= 'year' or not c.weak then strong[#strong + 1] = c end
end
filtered = strong
end
-- Если есть век с явным контекстом, отбрасываем прочие римские числа
local hasCtx = false
for _, c in ipairs(filtered) do
if c.type == 'century' and c.ctx then hasCtx = true break end
end
if hasCtx then
local tmp = {}
for _, c in ipairs(filtered) do
if c.type ~= 'century' or c.ctx then tmp[#tmp + 1] = c end
end
filtered = tmp
end
return filtered
end
local function applyPrefer(filtered, prefer)
if not prefer or #filtered == 0 then return filtered end
local pref = {}
for _, c in ipairs(filtered) do
if prefer == 'year_or_decade' then
if c.type == 'year' or c.type == 'decade' then pref[#pref + 1] = c end
elseif c.type == prefer then
pref[#pref + 1] = c
end
end
return #pref > 0 and pref or filtered
end
local function fixBCRanges(filtered, padded)
for i = 1, #filtered - 1 do
local c1, c2 = filtered[i], filtered[i + 1]
if c1.type == 'year' and c2.type == 'year' and c2.bc and not c1.bc then
local sep = U.sub(padded, c1.endPos, c2.pos - 1)
if U.match(sep, '^%s*[—–%-]%s*$') then c1.bc = true end
end
end
end
-------------------------------------------------------------------------------
-- ПРОВЕРКИ КАЧЕСТВА
-------------------------------------------------------------------------------
local function findLastPos(s, pattern, plain)
local pos, start = nil, 1
while true do
local f = U.find(s, pattern, start, plain)
if not f then break end
pos, start = f, f + 1
end
return pos
end
local function getSuspicionLevel(orig, searchStr)
if not searchStr then return false end
local s = T.unstrip(orig)
local origStr, yearStr = tostring(searchStr), U.lower(tostring(searchStr))
local clean = s:gsub('<ref.-</ref>', ''):gsub('<ref[^>]*/>', '')
:gsub('{{efn.-}}', ''):gsub('{{sfn.-}}', ''):gsub('{{примечание.-}}', '')
:gsub('{{note.-}}', ''):gsub('{{комментарий.-}}', ''):gsub('<sup.-</sup>', ''):gsub('%b()', '')
clean = stripHtml(clean)
local norm = normDash(U.lower(clean))
local pos = findLastPos(norm, yearStr, true)
if not pos then return false end
local after = U.sub(norm, pos + #yearStr, pos + #yearStr + 20)
if not after or after == '' then return false end
local rawPos = findLastPos(orig, origStr, true)
if rawPos then
local ch = U.match(U.sub(orig, rawPos + #origStr, rawPos + #origStr + 10), '^%s*(.)')
if ch == '{' or ch == '<' or ch == '\127' then return false end
end
local trimmed = U.match(after, '^%s*(.*)') or ''
if trimmed == '' or isTimeWord(after) then return false end
if U.match(trimmed, '^<') or U.match(after, '^%-[ехx]') or U.match(after, '^[,%)%]%-/<>%[|{}]') then return false end
if U.match(trimmed, '^%d') or U.match(U.sub(trimmed, 1, 10), '%d') then return false end
local word = U.match(trimmed, '^([%a]+)')
if not word or word == '' then return false end
return U.len(word) <= 2 and 'soft' or 'hard'
end
local function getDateIssues(s)
local norm = normDash(stripWiki(stripHtml(T.unstrip(s or ''))))
local lower = U.lower(norm)
local isBC = U.match(lower, BC_PATTERN) or U.match(lower, 'до%s*нашей%s*эры')
if not isBC then
local y, yy, after = U.match(norm, '(%d%d%d%d?)%-([1-9]%d)(%D)')
if not y then y, yy = U.match(norm, '(%d%d%d%d?)%-([1-9]%d)$') end
if y and yy and tonumber(yy) >= 13 and tonumber(yy) <= 99 and (after or '') ~= '-' then return true end
local y1, y2 = U.match(norm, '(%d%d%d%d?)%-(%d%d%d%d?)')
if y1 and y2 and tonumber(y2) < tonumber(y1) then return true end
end
return U.match(lower, 'до%s*н%s+э') or U.match(lower, 'донэ')
or (U.match(lower, '%d%s*нэ') and not U.match(lower, 'донэ'))
or U.match(lower, '%d%s+н%s+э') or U.match(norm, '%d%d%d%d%.%d%d?%.%d%d?')
or U.match(norm, '%d%d%d%d?,%d%d%d%d?')
end
local function isDecadeNoHyphen(s)
return U.match(s, '%d0[ехxeЕХE]') and not U.match(s, '%d0[-‑–—][ехxeЕХE]')
end
local function hasAbbrevNoDot(s)
local l = U.lower(stripHtml(T.unstrip(s or '')))
return U.match(l, '[%divxlcdm]%s*гг?[%s,)%]]') or U.match(l, '%d%s*тыс[%s,)%]]')
or U.match(l, '%d%s*тыс$') or U.match(l, '[ivxlcdm]%s*вв?[%s,)%]]')
or U.match(l, '%d%s*в[%s,)%]]')
end
local function needsVerification(r, s)
if r.type == 'decade' and isDecadeNoHyphen(s) then return true end
if r.short or r.rel then return true end
if r.type == 'year' and getSuspicionLevel(s, abs(r.value)) then return true end
if r.type == 'century' and getSuspicionLevel(s, arabicToRoman(r.value)) then return true end
return getDateIssues(s) or hasAbbrevNoDot(s)
end
local function getShortRangeYear(s)
local norm = normDash(s)
local lower = U.lower(norm)
if not (U.match(lower, '%d%d%d%d%-[1-9]%d?%s*год') or U.match(lower, '%d%d%d%d%-[1-9]%d?%s*гг')) then return nil end
return tonumber(U.match(norm, '(%d%d%d%d)%-[1-9]%d?'))
end
-------------------------------------------------------------------------------
-- ГЛАВНЫЙ ПАРСЕР
-------------------------------------------------------------------------------
-- Паттерны для сокращённых диапазонов (1927-28), исключая месяцы (01-12)
local SHORT_RANGE = {
'%d%d%d%d%-(1[3-9])%D', '%d%d%d%d%-(1[3-9])$',
'%d%d%d%d%-([2-9]%d)%D', '%d%d%d%d%-([2-9]%d)$',
}
local function parseTime(s, useFirst, prefer)
local str, hasMonth = cleanInput(s)
if str == nil then return {type = 'skip'} end
if str == '' then return {type = 'error', errorType = 'неизвестное значение'} end
local trimmed = T.trim(str)
if trimmed == '' then return {type = 'skip'} end
local bcGlobal = hasBC(str)
-- Специальные значения
local clean = U.lower(T.trim(str:gsub("['\"]+" , '')))
if clean == 'значение отсутствует' then return {type = 'skip'} end
if clean == '?' or clean == 'неизвестно' or clean == 'значение неизвестно' then
return {type = 'error', errorType = 'неизвестное значение'}
end
if U.match(clean, MILLENNIA_AGO_PATTERN) then
return {type = 'error', errorType = 'тысячелетия назад'}
end
if matchesAny(clean, CANCEL_KEYWORDS) then return {type = 'error', errorType = 'отменено'} end
if matchesAny(clean, PLANNED_KEYWORDS) then return {type = 'error', errorType = 'планируется'} end
-- Чистое число
local numOnly = U.match(trimmed, '^%-?%d+$')
if numOnly and not hasMonth then
local val = tonumber(numOnly)
if val then return {type = 'year', value = abs(val), bc = trimmed:sub(1, 1) == '-'} end
end
-- Число в скобках
local parenOnly = U.match(trimmed, '^%(%s*(%d+)%s*%)$')
if parenOnly then return {type = 'year', value = tonumber(parenOnly), bc = false} end
-- Диапазон до н. э.
local y1, y2 = U.match(str, '(%d+)%s*%-%s*(%d+)%s+до%s*н')
if y1 and y2 then
return {type = 'year', value = tonumber(useFirst and y1 or y2), bc = true, rangeSecond = not useFirst}
end
local function fallback()
local hasYearWord = U.match(U.lower(str), '%d+%s*год')
local hasYearNum = str:match('%d%d%d%d') or str:match('%d%d%d$') or str:match('%d%d%d[^%d]')
if hasMonth and not hasYearWord and not hasYearNum then
return {type = 'error', errorType = 'дата без года'}
end
if U.match(str, 'век') or U.match(str, 'тысяч') then return {type = 'skip'} end
return {type = 'notfound'}
end
local padded = ' ' .. str .. ' '
local candidates = {}
-- Особый случай: "18 до н. э. / 234"
if U.match(str, 'до%s*н%.?%s*э%.?.-/') then
local n = U.match(str, 'до%s*н%.?%s*э%.?.-/%s*(%d+)')
if n then candidates[#candidates + 1] = {type = 'year', value = tonumber(n), pos = 9999, endPos = 9999, bc = false} end
end
-- Сканирование
for _, spec in ipairs(SCAN) do scan(padded, candidates, spec, bcGlobal) end
if bcGlobal and not useFirst then
scan(padded, candidates, {t = 'year', p = '%d+%-%s*(%d+)%s+до%s*н', bc = true, rangeSecond = true}, false)
end
if not useFirst then
for _, pat in ipairs(SHORT_RANGE) do
scan(padded, candidates, {t = 'year', p = pat, short = true}, bcGlobal)
end
end
-- Целая строка = число
local fullNum = U.match(trimmed, '^%-?(%d+)$')
if fullNum then
candidates[#candidates + 1] = {type = 'year', value = tonumber(fullNum), pos = 1, endPos = #str, bc = trimmed:match('^%-') ~= nil}
end
if #candidates == 0 then return fallback() end
local filtered = filterCandidates(candidates, hasMonth, padded)
if #filtered == 0 then return fallback() end
filtered = applyPrefer(filtered, prefer)
if #filtered == 0 then return fallback() end
fixBCRanges(filtered, padded)
-- Приоритет: 4-значные над короткими, явные над слабыми
if not useFirst and #filtered > 1 then
local last = filtered[#filtered]
if last.type == 'year' then
local dominated = (last.value < 100 and not last.rangeSecond and not last.short) or last.weak
if dominated then
for i = #filtered - 1, 1, -1 do
local c = filtered[i]
if c.type == 'year' and ((last.value < 100 and c.value >= 100) or (last.weak and (c.explicit or c.value >= 100))) then
filtered[#filtered] = c
break
end
end
end
end
end
-- Сокращённые диапазоны
local shortYear = getShortRangeYear(str)
if shortYear and not useFirst then
for _, c in ipairs(filtered) do
if c.type == 'year' and c.value == shortYear then
return {type = c.type, value = c.value, bc = c.bc, short = true}
end
end
end
local res = useFirst and filtered[1] or filtered[#filtered]
return {type = res.type, value = res.value, bc = res.bc, rel = res.rel, short = res.short}
end
-------------------------------------------------------------------------------
-- ФОРМАТИРОВАНИЕ КАТЕГОРИЙ
-------------------------------------------------------------------------------
local function formatCat(result, title, form)
if not result or result.value == nil then return dateErrCat() end
local t, v, bc = result.type, result.value, result.bc
if t ~= 'decade' then title = adjustPrep(title, v) end
local fmts = FMT[t] and FMT[t].cat
local fmt = fmts and (fmts[form or 1] or fmts[1])
if not fmt then return dateErrCat() end
return 'Категория:' .. title .. ' ' .. fmt(v, bc)
end
-------------------------------------------------------------------------------
-- ПУБЛИЧНОЕ API
-------------------------------------------------------------------------------
function p.integer(frame)
local s = frame.args[1]
if not s then return nil end
s = s:gsub('<[^<>]+>', ''):gsub('[^0-9]', ' ')
return tonumber(T.split(T.trim(s), ' ')[1])
end
function p.quantity(frame)
local s = frame.args[1]
if not s then return nil end
s = s:gsub('<[^<>]+>', ''):gsub(' ', '')
:gsub('тыс', '000'):gsub('млн', '000000'):gsub('млрд', '000000000')
:gsub(',(%d%d%d)000', '%1'):gsub(',(%d%d)000', '%10'):gsub(',(%d)000', '%100')
:gsub('[±].*$', ''):gsub('[^0-9]', ' ')
return tonumber(T.split(T.trim(s), ' ')[1])
end
function p.yearCategory(frame)
local year = tonumber(T.trim(frame.args[1] or ''))
if not year then return dateErrCat() end
local title = T.trim(frame.args[2] or '')
local form = T.trim(frame.args[3] or '')
if form == '' then form = 'году' end
local absY = abs(year)
title = adjustPrep(title, absY)
if YEAR_FMT[form] then absY = YEAR_FMT[form](absY) end
return 'Категория:' .. title .. ' ' .. absY .. ' ' .. form .. (year < 0 and ' до н. э.' or '')
end
function p.decadeCategory(frame)
local raw = T.trim(frame.args[1] or '')
local dec = tonumber(raw)
if dec == nil and raw ~= '-0' then return dateErrCat() end
local bc = raw == '-0' or (dec and dec < 0)
local form = T.trim(frame.args[3] or '') == 'годов' and 2 or 1
return formatCat({type = 'decade', value = dec and abs(dec) or 0, bc = bc}, T.trim(frame.args[2] or ''), form)
end
function p.centuryCategory(frame)
local c = tonumber(T.trim(frame.args[1] or ''))
if not c then return dateErrCat() end
local form = T.trim(frame.args[3] or '') == 'века' and 2 or 1
return formatCat({type = 'century', value = abs(c), bc = c < 0}, T.trim(frame.args[2] or ''), form)
end
function p.millenniumCategory(frame)
local m = tonumber(T.trim(frame.args[1] or ''))
if not m then return dateErrCat() end
local form = T.trim(frame.args[3] or '') == 'тысячелетия' and 2 or 1
return formatCat({type = 'millennium', value = abs(m), bc = m < 0}, T.trim(frame.args[2] or ''), form)
end
local function extract(frame, expected)
local s = frame.args[1]
if not s then return frame.args['default'] end
local r = parseTime(s, false)
if r.type ~= expected then return frame.args['default'] or '' end
return signedVal(r)
end
function p.decade(frame) return extract(frame, 'decade') end
function p.century(frame) return extract(frame, 'century') end
function p.millennium(frame) return extract(frame, 'millennium') end
function p.datefromstring(frame)
local s = frame.args[1]
local default = frame.args['default']
if not s or T.trim(s) == '' then s = default end
if not s then return '' end
local r = parseTime(s, isTruthy(frame.args['first']))
if (r.type == 'skip' or r.type == 'error' or r.type == 'notfound') and default and s ~= default then
r = parseTime(default, isTruthy(frame.args['first']))
end
if r.type == 'skip' or r.type == 'error' or r.type == 'notfound' then
return default or ''
end
local fmts = FMT[r.type] and FMT[r.type].str
local case = frame.args['case']
local fmt = fmts and fmts[(case == 'i' or case == 'r' or case == 'p') and case or 'short']
return fmt and fmt(r.value, r.bc) or default or ''
end
function p.year(frame)
local s = frame.args[1]
if not s then return frame.args['default'] end
local r = parseTime(s, isTruthy(frame.args['first']), 'year_or_decade')
if r.type == 'skip' then return frame.args['default'] or '' end
if r.type == 'error' then return '' end
if r.type == 'notfound' then return frame.args['default'] or '' end
if r.type == 'year' or r.type == 'decade' then return signedVal(r) end
return ''
end
function p.datecat(frame)
local s = frame.args[1]
if not s or T.trim(s) == '' then return '' end
local raw = T.trim(s)
if raw == '-' then return '' end
if raw == '—' or raw == '–' then return '[[' .. dateErrCat('неизвестное значение') .. ']]' end
local cat = frame.args['cat'] or ''
local form = tonumber(frame.args['form']) or 1
local cutD, cutC, cutM = tonumber(frame.args['cut-decade']), tonumber(frame.args['cut-century']), tonumber(frame.args['cut-mill'])
local suffix = frame.args['suffix']
suffix = suffix and T.trim(suffix) ~= '' and (' ' .. T.trim(suffix)) or ''
local r = parseTime(s, isTruthy(frame.args['first']))
if r.type == 'skip' then return '' end
if r.type == 'error' then return '[[' .. dateErrCat(r.errorType) .. ']]' end
if r.type == 'notfound' then return '[[' .. dateErrCat() .. ']]' end
-- Автоукрупнение
local function getCompareValue(tp, val, bc)
local v
if tp == 'year' then v = val
elseif tp == 'decade' then v = val + 1
elseif tp == 'century' then v = val * 100
elseif tp == 'millennium' then v = val * 1000
else return nil end
return bc and -v or v
end
local sv = getCompareValue(r.type, r.value, r.bc)
if sv then
if cutM and sv < cutM and r.type ~= 'millennium' then
if r.type == 'year' or r.type == 'decade' then
r.value = ceil(r.value / 1000)
elseif r.type == 'century' then
r.value = ceil(r.value / 10)
end
r.type = 'millennium'
elseif cutC and sv < cutC and (r.type == 'year' or r.type == 'decade') then
r.value = ceil(r.value / 100)
r.type = 'century'
elseif cutD and sv < cutD and r.type == 'year' then
r.value = floor(r.value / 10) * 10
r.type = 'decade'
end
end
local result = '[[' .. formatCat(r, cat, form) .. suffix .. ']]'
if needsVerification(r, s) then
result = result .. '[[' .. dateErrCat('требует проверки') .. ']]'
end
return result
end
return p