Модуль:MetaCat/Core
Перейти к навигации
Перейти к поиску
Описание
Ядро системы категоризации MetaCat. Содержит утилиты, фабрики навигации и работу с географическими данными.
Подмодули
- Core — утилиты, фабрики
- Year — общая логика, утилиты, фабрики
- Decade — обработчик десятилетий
- Century — обработчик веков
- Millennium — обработчик тысячелетий
- Geo — географический резолвер
Данные
- geo-country.json — страны
- geo-city.json — города
- geo-region.json — регионы
- geo-continent.json — части света
-- ◆ MetaCat/Core
-- Унифицированная система обработки временных категорий
local Core = {}
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 1: КОНСТАНТЫ И КОНФИГУРАЦИЯ
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ JSON файлы данных (консолидированные geo-* файлы)
Core.JSON_PATHS = {
-- Консолидированные географические данные
GEO_COUNTRY = 'Модуль:MetaCat/data/geo-country.json',
GEO_CITY = 'Модуль:MetaCat/data/geo-city.json',
GEO_REGION = 'Модуль:MetaCat/data/geo-region.json',
GEO_CONTINENT = 'Модуль:MetaCat/data/geo-continent.json'
}
-- ▼ Константы префиксов
local PREFIX_QUESTION, PREFIX_FALLBACK = '?', '~'
local PREFIX_PATTERN = '[%?~]'
-- ▼ Кеш загруженных JSON файлов (ленивая загрузка)
local json_cache = {}
-- ▼ Единый кеш данных о странах (падежи, предлог, succession)
local country_unified_cache = {}
-- ▼ Конфигурация навигации
local NAVBOX_CONFIG = {
BATCH_SIZE = 25,
MAX_BATCHES = 4
}
-- ▼ Стили для блока разветвления
local FORK_STYLES = {
base = 'display:inline-block;vertical-align:middle;text-align:center;line-height:1.4',
border_left = ';border-left:2px solid #a2a9b1;padding-left:0.5em;margin-left:0.3em',
border_right = ';border-right:2px solid #a2a9b1;padding-right:0.5em;margin-right:0.3em',
border_both = ';border-left:2px solid #a2a9b1;border-right:2px solid #a2a9b1;padding:0 0.5em;margin:0 0.3em'
}
-- ▼ Дефолтные настройки режимов
Core.DEFAULTS = {
SKIP_1CENTURY = false,
SKIP_GAPS = false,
SPLIT = false
}
-- ▼ Базовая конфигурация
Core.base_config = {
errors = {
country_not_found = 6,
continent_not_found = 7,
city_not_found = 8,
region_not_found = 9
},
range_codes = { [2]=true, [3]=true, [4]=true, [5]=true }
}
-- ▼ Словарь типов времени
local TIME_TYPE_NAMES = {
year = {
nom = 'год',
gen = 'года',
placeholder = '<год>',
root_category = 'Годы',
detail_level = 3
},
decade = {
nom = 'десятилетие',
gen = 'десятилетия',
placeholder = '<десятилетие>',
root_category = 'Десятилетия',
detail_level = 2
},
century = {
nom = 'век',
gen = 'века',
placeholder = '<век>',
root_category = 'Века',
detail_level = 1
},
millennium = {
nom = 'тысячелетие',
gen = 'тысячелетия',
placeholder = '<тысячелетие>',
root_category = 'Тысячелетия',
detail_level = 0
}
}
-- ▼ Шаблоны сообщений для ошибки валидации типов
local TYPE_VALIDATION_MSG_MISMATCH = 'В категорию для %s вызывается шаблон для %s'
-- ▼ Конфигурация путей для геообъектов
local GEO_BOUNDS_CONFIG = {
country = {
json_path = Core.JSON_PATHS.GEO_COUNTRY,
array_key = 'country',
historical_key = 'historical'
},
region = {
json_path = Core.JSON_PATHS.GEO_REGION,
array_key = 'region',
parent_key = 'country'
},
city = {
json_path = Core.JSON_PATHS.GEO_CITY,
array_key = 'city'
}
}
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 2: ЗАВИСИМОСТИ
-- ══════════════════════════════════════════════════════════════════════════════
local getArgs = require('Модуль:Arguments').getArgs
local sparseIpairs = require('Модуль:TableTools').sparseIpairs
local getStyles = require('Модуль:Индекс категории').getStyles
local gsub = mw.ustring.gsub
-- Forward declaration (expand_all используется до определения)
local expand_all
-- ▼ Общие зависимости для экспорта в дочерние модули
Core.deps = {
toroman = require('Module:Roman').convert,
gsub = gsub
}
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 3: БАЗОВЫЕ УТИЛИТЫ
-- ══════════════════════════════════════════════════════════════════════════════
Core.utils = {}
-- ▼ Проверка типа таблицы
local function is_table(value) return type(value) == 'table' end
Core.utils.is_table = is_table
-- ▼ Получение первого символа строки
local function first_char(s) return s and s:sub(1, 1) or '' end
-- ▼ Поверхностное копирование таблицы
local function table_shallow_copy(t)
local r = {}
for k, v in pairs(t or {}) do r[k] = v end
return r
end
-- ▼ Возврат первого непустого значения
Core.utils.first_non_empty = function(a, b, c)
return (a and a ~= '' and a) or (b and b ~= '' and b) or c
end
-- ▼ Аккумулятор результатов с лимитом
Core.utils.new_result_accumulator = function(limit)
local acc = { _seen = {}, _list = {}, _limit = limit or 2 }
acc.add = function(self, value)
if not value or value == '' or self._seen[value] or #self._list >= self._limit then return false end
table.insert(self._list, value)
self._seen[value] = true
return true
end
acc.list = function(self) return self._list end
return acc
end
-- ▼ Применение замен плейсхолдеров
Core.utils.apply_replacements = function(text, ...)
local merged = {}
for i = 1, select('#', ...) do
local t = select(i, ...)
if is_table(t) then for k, v in pairs(t) do merged[k] = v end end
end
for placeholder, value in pairs(merged) do text = gsub(text, placeholder, value) end
return text
end
-- ▼ Проверка попадания года в период
Core.utils.is_year_in_period = function(start_year, end_year, type_name, time)
local year = tonumber(time)
if not year then return true end
local s, e
if type_name == 'century' then
s, e = (year - 1) * 100 + 1, year * 100
elseif type_name == 'decade' then
s = math.floor(year / 10) * 10
e = s + 9
elseif type_name == 'millennium' then
-- Формат навигации: 1=1-е н.э., 0=1-е до н.э., -1=2-е до н.э.
-- Диапазоны: 1-е н.э.=[1,1000], 1-е до н.э.=[-1000,-1], 2-е до н.э.=[-2000,-1001]
if year > 0 then
s, e = (year - 1) * 1000 + 1, year * 1000
else
s, e = (year - 1) * 1000, year * 1000 - 1
end
else
s, e = year, year
end
return (not end_year and start_year <= e) or (start_year <= e and end_year and end_year >= s)
end
-- ▼ Нормализация текста категории
Core.utils.normalize_category_text = function(text, had_exclamation_space)
if type(text) ~= 'string' then return '' end
local result = gsub(gsub(text, ' !', '!'), '[%!%s]+$', '')
if had_exclamation_space and not result:find('!') then result = result .. '! ' end
return result
end
-- ▼ Парсинг булевого параметра с дефолтом
function Core.parse_bool_param(param, default)
if param == 'yes' or param == '1' then return true end
if param == 'no' or param == '0' then return false end
return default
end
-- ▼ Грамматические исправления для века (общая функция)
-- placeholder: '<век>' или '<1век>'
-- century: номер века (число)
-- bc: флаг до н.э. (boolean)
-- Возвращает текст с исправлениями и подстановкой римской цифры
function Core.expand_century_placeholder(text, placeholder, century, bc)
if not text or not placeholder or not century then return text end
local toroman = Core.deps.toroman
local roman = toroman(century)
local result = text
-- Экранирование спецсимволов Lua-паттернов (< и > не являются спецсимволами)
local esc_ph = placeholder:gsub('([%(%)%.%%%+%-%*%?%[%]%^%$])', '%%%1')
-- Грамматические исправления предлогов
if century == 2 then
result = gsub(result, 'в ' .. esc_ph .. ' веке', 'во ' .. placeholder .. ' веке')
end
if century == 11 then
result = gsub(result, 'о ' .. esc_ph .. ' веке', 'об ' .. placeholder .. ' веке')
end
-- Подстановка римской цифры с учётом до н.э.
if bc then
result = gsub(result, esc_ph .. ' (век[еа]?)', roman .. ' %1 до н. э.')
end
result = gsub(result, esc_ph, roman)
return result
end
-- ▼ Конвертация года в век
-- Возвращает номер века (положительный для н.э., отрицательный для до н.э.)
function Core.year_to_century(year)
if not year then return nil end
if year <= 0 then
return -(math.floor((-year) / 100) + 1)
else
return math.floor((year - 1) / 100) + 1
end
end
-- ▼ Информация о веке из года
-- Возвращает: { century = число (всегда положительное), bc = boolean }
function Core.year_to_century_info(year)
if not year then return nil end
if year <= 0 then
return { century = math.floor((-year) / 100) + 1, bc = true }
else
return { century = math.floor((year - 1) / 100) + 1, bc = false }
end
end
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 4: ЛЕНИВАЯ ЗАГРУЗКА МОДУЛЕЙ
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ Кеш загруженных модулей (единый для всех)
local modules_cache = {}
-- ▼ Фабрика ленивой загрузки модулей
local function create_lazy_loader(module_path, on_load)
return function()
if modules_cache[module_path] ~= nil then
return modules_cache[module_path]
end
local ok, mod = pcall(require, module_path)
if ok and is_table(mod) then
modules_cache[module_path] = mod
if on_load then on_load(mod) end
else
modules_cache[module_path] = false
end
return modules_cache[module_path] or nil
end
end
-- ▼ Lazy loaders для основных модулей
Core.utils.get_geo_module = create_lazy_loader('Модуль:MetaCat/Geo')
Core.utils.get_findtopic_module = create_lazy_loader('Модуль:Find topic')
-- ▼ Загрузка дочерних модулей MetaCat
local function get_module(module_name)
local path = 'Модуль:MetaCat/' .. module_name
return create_lazy_loader(path)()
end
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 5: ПЛЕЙСХОЛДЕРЫ
-- ══════════════════════════════════════════════════════════════════════════════
Core.placeholders = {}
-- ▼ Определения плейсхолдеров
Core.placeholders.PH = {
country = { '<страна>', '<страны>', '<в стране>' },
state = { '<государство>', '<государства>', '<в государстве>' },
continent_simple = { '<часть света>', '<части света>', '<в части света>' },
continent_parent = { '<континент>', '<континента>', '<на континенте>' },
city = { '<город>', '<города>', '<в городе>' },
region = { '<регион>', '<региона>', '<в регионе>' }
}
-- ▼ Проверка наличия любого плейсхолдера из списка
local function has_any(text, list)
for _, ph in ipairs(list) do if text:find(ph, 1, true) then return true end end
return false
end
-- ▼ Детекция плейсхолдеров в тексте
function Core.detect_placeholders(text)
local PH = Core.placeholders.PH
return {
state = has_any(text, PH.state),
country = has_any(text, PH.country),
continent = has_any(text, PH.continent_simple) or has_any(text, PH.continent_parent),
city = has_any(text, PH.city),
region = has_any(text, PH.region)
}
end
-- ▼ Замена плейсхолдеров с двоеточием
function Core.placeholders.replace_colon(text, entity, placeholder_patterns)
if not entity or not entity.cases then return text end
local function matches_required(entity_name, required_name)
entity_name = gsub(entity_name or '', '%s+', ' ')
required_name = gsub(required_name or '', '%s+', ' ')
if required_name:match('^%-') then
local excluded = {}
for ex in required_name:gmatch('%-([^%-]+)') do excluded[gsub(ex, '%s+', ' ')] = true end
return not excluded[entity_name]
end
return entity_name == required_name
end
for pattern, case_key in pairs(placeholder_patterns) do
local has_v = case_key == 'предложный' and pattern:match('^<[^>]*в ')
local has_na = case_key == 'предложный' and pattern:match('^<[^>]*на ')
local case_val = entity.cases[case_key] or ''
local prefix = ''
if case_key == 'предложный' then
if has_v then prefix = 'в ' elseif has_na then prefix = 'в ' end
end
text = text:gsub(pattern, function(required)
if matches_required(entity.name or '', required, pattern) then
if case_val ~= '' then return prefix .. case_val end
return ''
end
return ''
end)
end
return text
end
-- ▼ Проверка наличия плейсхолдеров месяца
local function has_month_placeholder(text)
return type(text) == 'string' and (text:find('<месяц', 1, true) or text:find('<в месяце>', 1, true))
end
Core.has_month_placeholder = has_month_placeholder
-- ▼ Проверка географических плейсхолдеров
local function has_country_placeholders(s)
if type(s) ~= 'string' then return false end
if s:find('^%|?%[%+', 1) or s:find('^%|?%[%-', 1) then return true end
local PH = Core.placeholders.PH
for _, list in pairs(PH) do
for _, ph in ipairs(list) do
if s:find(ph, 1, true) then return true end
end
end
return false
end
Core.has_country_placeholders = has_country_placeholders
-- ▼ Определение нужных типов геообъектов по плейсхолдерам
local function detect_needed_geo_types(args)
if not args then return nil, false end
local needed_types, has_geo_placeholders = nil, false
local PH = Core.placeholders.PH
local type_map = {
city = 'city', region = 'region',
country = 'country', state = 'country',
continent_simple = 'country', continent_parent = 'country'
}
for _, arg in pairs(args) do
if type(arg) == 'string' and has_country_placeholders(arg) then
has_geo_placeholders = true
needed_types = needed_types or {}
for ph_type, list in pairs(PH) do
for _, ph in ipairs(list) do
if arg:find(ph, 1, true) then
needed_types[type_map[ph_type]] = true
break
end
end
end
end
end
return needed_types, has_geo_placeholders
end
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 6: СУЩНОСТИ (ENTITIES)
-- ══════════════════════════════════════════════════════════════════════════════
Core.entities = {}
local entity_cache = {}
-- ▼ Безопасное получение падежа
function Core.entities.get_safe_case(name, case_name, dataKey)
if not name or name == '' then return '' end
local key = table.concat({name, case_name or '', dataKey or ''}, '|')
local cached = entity_cache[key]
if cached ~= nil then return cached end
local findTopic = Core.utils.get_findtopic_module()
if not findTopic then return '' end
local result = findTopic.findtopicinstring(name, case_name, dataKey)
if not result or tostring(result):match('^Ошибка') then result = '' end
entity_cache[key] = result
return result
end
-- ▼ Построение падежей для сущности
-- Возвращает таблицу падежей и флаг mismatch если именительный падеж не совпадает с исходным именем
function Core.entities.build_cases(name, dataKey)
if not name or name == '' then
return { ['именительный']='', ['родительный']='', ['предложный']='', ['предлог']='' }
end
local nom = Core.entities.get_safe_case(name, 'именительный', dataKey) or ''
local prepositional = Core.entities.get_safe_case(name, 'предложный', dataKey) or ''
local pred_full = Core.entities.get_safe_case(name, 'предлог', dataKey) or ''
local cases = {
['именительный'] = nom,
['родительный'] = Core.entities.get_safe_case(name, 'родительный', dataKey) or '',
['предложный'] = prepositional,
['предлог'] = pred_full
}
-- Проверка точного соответствия: если Find topic вернул другое название — несоответствие
if nom ~= '' and nom ~= name then
cases.mismatch = true
cases.expected = name
cases.found = nom
end
return cases
end
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 7: ЗАГРУЗКА ДАННЫХ
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ Ленивая загрузка JSON с кешированием
local function load_json_cached(path)
if json_cache[path] then return json_cache[path] end
local ok, data = pcall(mw.loadJsonData, path)
if ok and type(data) == 'table' then
json_cache[path] = data
return data
end
return nil
end
Core.utils.load_json = load_json_cached
-- ▼ Хелпер: поиск элемента по имени или алиасу в массиве
local function find_item_by_name(items, entity_name)
if type(items) ~= 'table' then return nil end
for _, item in ipairs(items) do
if item.name == entity_name then return item end
if type(item.aliases) == 'table' then
for _, alias in ipairs(item.aliases) do
if alias == entity_name then return item end
end
end
end
return nil
end
-- ▼ Хелпер: итерация по geo-группам с callback
-- callback(items, arr_key, grp) → возвращает результат или nil для продолжения
local function iterate_geo_groups(data, array_keys, callback)
if not data or type(data.group) ~= 'table' then return nil end
for _, grp in ipairs(data.group) do
for _, arr_key in ipairs(array_keys) do
if is_table(grp[arr_key]) then
local result = callback(grp[arr_key], arr_key, grp)
if result then return result end
end
end
end
return nil
end
-- ▼ Универсальная загрузка данных о границах геообъектов
local function load_geo_bounds(entity_name, entity_type)
if not entity_name or entity_name == '' or not entity_type then return nil end
local config = GEO_BOUNDS_CONFIG[entity_type]
if not config then return nil end
local data = load_json_cached(config.json_path)
if not data then return nil end
-- Для регионов: group.country[].region[]
if config.parent_key then
return iterate_geo_groups(data, {config.parent_key}, function(parents)
for _, parent_obj in ipairs(parents) do
if is_table(parent_obj) and is_table(parent_obj[config.array_key]) then
local result = find_item_by_name(parent_obj[config.array_key], entity_name)
if result then return result end
end
end
return nil
end)
end
-- Для стран и городов: group.country[] / group.historical[] / group.city[]
local keys = {config.array_key}
if config.historical_key then table.insert(keys, config.historical_key) end
return iterate_geo_groups(data, keys, function(items)
return find_item_by_name(items, entity_name)
end)
end
-- ▼ Алиасы для загрузки границ геообъектов
local load_country_bounds = function(n) return load_geo_bounds(n, 'country') end
local load_region_bounds = function(n) return load_geo_bounds(n, 'region') end
local load_city_bounds = function(n) return load_geo_bounds(n, 'city') end
-- ▼ Универсальный поиск геообъекта в заголовке
local function find_geo_bounds_for_title(title, needed_types)
if not title or title == '' then return nil end
local findTopic = Core.utils.get_findtopic_module()
if not findTopic then return nil end
local check_city = needed_types and needed_types.city
local check_region = needed_types and needed_types.region
local check_country = not needed_types or needed_types.country
-- Приоритет 1: Город
if check_city then
local city_name = findTopic.findtopicinstring(title, 'именительный', 'city')
if city_name and city_name ~= '' then
local city_data = load_city_bounds(city_name)
if city_data then
return { data = city_data, name = city_name, type = 'city' }
end
return nil
end
end
-- Приоритет 2: Регион
if check_region then
local region_name = findTopic.findtopicinstring(title, 'именительный', 'ate1')
if region_name and region_name ~= '' then
local region_data = load_region_bounds(region_name)
if region_data then
return { data = region_data, name = region_name, type = 'region' }
end
return nil
end
end
-- Приоритет 3: Страна
if check_country then
local country_name = findTopic.findtopicinstring(title, 'именительный', 'country')
if country_name and country_name ~= '' then
local country_data = load_country_bounds(country_name)
if country_data then
return { data = country_data, name = country_name, type = 'country' }
end
end
end
return nil
end
-- ▼ Умная загрузка geo_data с проверкой плейсхолдеров
function Core.load_geo_data_if_needed(ctx, args)
local force_geo = args and (args['force'] == 'geo')
local needed_types, has_geo_placeholders = detect_needed_geo_types(args)
-- Сохранение флагов плейсхолдеров всего шаблона
if needed_types then
ctx.template_has_city = needed_types.city or false
ctx.template_has_region = needed_types.region or false
end
-- Сохранение флага force_geo для передачи в Geo.resolve
ctx.force_geo = force_geo
local geo_info = nil
if has_geo_placeholders or force_geo then
geo_info = find_geo_bounds_for_title(ctx.title, needed_types)
end
local geo_data = geo_info and geo_info.data or nil
local geo_name = geo_info and geo_info.name or nil
ctx.geo_data = geo_data
if geo_info and geo_name then
if geo_info.type == 'country' then
ctx.country_name = geo_name
elseif geo_info.type == 'city' then
ctx.city_name = geo_name
elseif geo_info.type == 'region' then
ctx.region_name = geo_name
end
end
return geo_data, geo_name
end
-- ▼ Конвертация JSON-массива в Lua-массив
local function json_array_to_lua(arr)
if type(arr) ~= 'table' then return nil end
local result = {}
for k, v in pairs(arr) do
local idx = tonumber(k)
if idx then
result[idx] = v
end
end
return #result > 0 and result or nil
end
-- ▼ Парсинг дат из state-записи
-- Для split: "always" означает использование fallback дат (min/max страны)
local function parse_state_years(years, fallback_min, fallback_max)
if years == 'always' then
return fallback_min, fallback_max
elseif is_table(years) then
return tonumber(years[1]), years[2] == 'now' and nil or tonumber(years[2])
end
return nil, nil
end
-- ▼ Получение данных о связях страны (с кешированием)
local succession_cache = {}
local function get_country_succession(country_name)
if not country_name or country_name == '' then return nil end
if succession_cache[country_name] then return succession_cache[country_name] end
local country_data = load_country_bounds(country_name)
local states = {}
if country_data and is_table(country_data.state) then
for state_name, years in pairs(country_data.state) do
-- Для "always" — min/max текущей страны
local start_year, end_year = parse_state_years(years, country_data.min, country_data.max)
if start_year then
table.insert(states, { name = state_name, start_year = start_year, end_year = end_year })
end
end
end
local result = {
name = country_name,
min = country_data and country_data.min or nil,
max = country_data and country_data.max or nil,
past = country_data and json_array_to_lua(country_data.past) or nil,
next = country_data and json_array_to_lua(country_data.next) or nil,
state = country_data and country_data.state or nil,
states = states
}
succession_cache[country_name] = result
return result
end
-- ▼ Поиск стран которые входили в данное государство (для СССР, РИ и т.д.)
local function find_member_countries(state_name)
local data = load_json_cached(Core.JSON_PATHS.GEO_COUNTRY)
if not data then return {}, nil, nil end
local members = {}
local state_min, state_max
iterate_geo_groups(data, {'country', 'historical'}, function(items)
for _, c in ipairs(items) do
if is_table(c) and c.name then
if c.name == state_name then
state_min, state_max = c.min, c.max
elseif is_table(c.state) and c.state[state_name] then
-- Для "always" — min/max страны-члена
local their_start, their_end = parse_state_years(c.state[state_name], c.min, c.max)
table.insert(members, { name = c.name, min = c.min, max = c.max, state_start = their_start, state_end = their_end })
end
end
end
return nil -- продолжить итерацию
end)
return members, state_min, state_max
end
-- ▼ Получение данных о стране из единого кеша (падежи + предлог)
local function get_country_data(country_name)
if not country_name or country_name == '' then return nil end
if country_unified_cache[country_name] then return country_unified_cache[country_name] end
local findTopic = Core.utils.get_findtopic_module()
local data = {
cases = { imen = country_name, rod = country_name, pred = country_name },
pred_full = 'в ' .. country_name
}
if findTopic then
data.cases.rod = findTopic.findtopicinstring(country_name, 'родительный', 'country') or country_name
if data.cases.rod ~= country_name then
data.cases.pred = findTopic.findtopicinstring(country_name, 'предложный', 'country') or country_name
end
local pred_full = findTopic.findtopicinstring(country_name, 'предлог', 'country')
if pred_full and pred_full ~= '' then
data.pred_full = pred_full
end
end
country_unified_cache[country_name] = data
return data
end
-- ▼ Получение падежей страны для навбоксов
local function get_country_cases(country_name)
local data = get_country_data(country_name)
return data and data.cases or nil
end
-- ▼ Получение предложного падежа страны с предлогом
function Core.get_country_pred(country_name)
local data = get_country_data(country_name)
return data and data.pred_full or ''
end
-- ▼ Получение короткого названия страны для навигации
function Core.get_country_short(country_name)
if not country_name or country_name == '' then return country_name end
local country_data = load_country_bounds(country_name)
return country_data and country_data.short or country_name
end
-- ▼ Экранирование спецсимволов Lua-паттернов для безопасной замены
local function escape_pattern(s)
return (s:gsub('([%(%)%.%%%+%-%*%?%[%]%^%$])', '%%%1'))
end
-- ▼ Замена страны в заголовке категории
-- Логика: предлог перед → предложный с предлогом, pos=1 → именительный, иначе → родительный
function Core.replace_country_in_title(cat_title, country_name, country_pred, ctx)
local old = get_country_cases(ctx.country_name)
local new = get_country_cases(country_name)
if not old or not new then return cat_title end
-- Порядок поиска: сначала длинные формы (pred), потом короткие (imen)
-- Это предотвращает частичную замену "Узбекистан" в "Узбекистане"
local forms = { {old.pred, 'pred'}, {old.rod, 'rod'}, {old.imen, 'imen'} }
for _, pair in ipairs(forms) do
local form = pair[1]
local case_type = pair[2]
local pos = mw.ustring.find(cat_title, form, 1, true)
if pos then
-- Экранирование спецсимволов (например дефис в "Аль-Андалус")
local form_escaped = escape_pattern(form)
-- 1. Предлог перед страной (в, во, на) → предложный с предлогом
local before = mw.ustring.sub(cat_title, math.max(1, pos - 3), pos - 1)
local prep = before:match('(во) $') or before:match('(на) $') or before:match('(в) $')
if prep then
return mw.ustring.gsub(cat_title, prep .. ' ' .. form_escaped, country_pred, 1)
end
-- 2. Начало строки → именительный
if pos == 1 then
return mw.ustring.gsub(cat_title, form_escaped, new.imen, 1)
end
-- 3. Иначе → родительный
return mw.ustring.gsub(cat_title, form_escaped, new.rod, 1)
end
end
return cat_title
end
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 8: НАВИГАЦИЯ
-- ══════════════════════════════════════════════════════════════════════════════
-- ────────────────────────────────────────────────────────────────────────────────
-- 8.1 Базовые хелперы
-- ────────────────────────────────────────────────────────────────────────────────
-- Конвертация года в период (век, десятилетие и т.д.)
local function year_to_period(time_configs, year)
return time_configs.year_to_period and time_configs.year_to_period(year) or year
end
-- ────────────────────────────────────────────────────────────────────────────────
-- 8.2 Skip-gaps режим (пропуск несуществующих категорий)
-- ────────────────────────────────────────────────────────────────────────────────
-- Проверка существования категорий батчами
local function check_categories_exist(category_titles)
if not category_titles or #category_titles == 0 then return {} end
local batch = mw.title.newBatch(category_titles, 14)
local titles = batch:lookupExistence():getTitles()
local exists = {}
for i, title_obj in ipairs(titles) do
if title_obj and title_obj.exists then
exists[category_titles[i]] = true
end
end
return exists
end
-- Сбор синих ссылок с пропуском пробелов
local function collect_links_skip_gaps(ctx, time_configs, bounds, current_time, direction)
local links = {}
local step = time_configs.step * direction
local batch_size = NAVBOX_CONFIG.BATCH_SIZE
local max_batches = NAVBOX_CONFIG.MAX_BATCHES
local batches_used = 0
local needed = ctx.range
local function check_batch(start_time)
if #links >= needed or batches_used >= max_batches then return false end
local category_names = {}
local time_to_name = {}
for offset = 0, batch_size - 1 do
local t = start_time + offset * step
if (direction > 0 and t > bounds.max) or (direction < 0 and t < bounds.min) then
break
end
-- Пропуск текущего и прошедших периодов
local dominated = (direction > 0 and t <= current_time) or (direction < 0 and t >= current_time)
if not dominated then
local link_text = time_configs.format_link(t, ctx)
local cat_name = link_text:match('%[%[:К:([^|%]]+)')
if cat_name then
table.insert(category_names, cat_name)
time_to_name[cat_name] = {time = t, link = link_text}
end
end
end
if #category_names == 0 then return false end
batches_used = batches_used + 1
local exists = check_categories_exist(category_names)
local found_any = false
for _, cat_name in ipairs(category_names) do
if #links >= needed then break end
if exists[cat_name] then
local info = time_to_name[cat_name]
table.insert(links, {time = info.time, link = info.link})
found_any = true
end
end
return found_any
end
local start_time = current_time + step
while #links < needed and batches_used < max_batches do
local has_more = check_batch(start_time)
if not has_more then
local next_check = start_time + batch_size * step
if (direction > 0 and next_check > bounds.max) or (direction < 0 and next_check < bounds.min) then
break
end
end
start_time = start_time + batch_size * step
end
return links
end
-- ▼ Добивание недостающих ссылок красными
local function fill_with_red_links(links, ctx, time_configs, bounds, current_time, direction)
if #links >= ctx.range then return end
local needed = ctx.range - #links
table.sort(links, function(a, b) return a.time < b.time end)
local start_time
if direction < 0 then
start_time = (#links > 0) and (links[1].time - time_configs.step) or (current_time - time_configs.step)
else
start_time = (#links > 0) and (links[#links].time + time_configs.step) or (current_time + time_configs.step)
end
local added = 0
local t = start_time
while added < needed do
if direction < 0 and t < bounds.min then break end
if direction > 0 and t > bounds.max then break end
local link_text = time_configs.format_link(t, ctx)
table.insert(links, {time = t, link = link_text})
added = added + 1
t = t + direction * time_configs.step
end
table.sort(links, function(a, b) return a.time < b.time end)
end
-- ────────────────────────────────────────────────────────────────────────────────
-- 8.3 Fork-система (разветвления для стран-преемников)
-- ────────────────────────────────────────────────────────────────────────────────
-- Типы связей для fork-системы
local FORK_TYPES = {
STATE_JOIN = 'state_join', -- Страна вошла в состав (+ для контейнера, ↑ для члена)
STATE_LEAVE = 'state_leave', -- Страна вышла из состава (− для контейнера, ↓ для члена)
STATE_BOTH = 'state_both', -- Вход и выход в одном периоде (↑↓ или ±)
PAST = 'past', -- Предшественник (← после названия)
NEXT = 'next' -- Преемник (→ перед названием)
}
-- Добавление страны в fork с типом связи
-- is_member: true если текущая страна — член (показывать ↑↓), false если контейнер (показывать +−)
-- event_year: год события (для сортировки внутри периода)
local function add_country_to_fork(forks, period, name, link_type, is_member, event_year)
forks[period] = forks[period] or { countries = {} }
-- Поиск существующей записи
for _, existing in ipairs(forks[period].countries) do
if existing.name == name and existing.is_member == is_member then
-- Объединение типов: join + leave = both
if (existing.link_type == FORK_TYPES.STATE_JOIN and link_type == FORK_TYPES.STATE_LEAVE) or
(existing.link_type == FORK_TYPES.STATE_LEAVE and link_type == FORK_TYPES.STATE_JOIN) then
existing.link_type = FORK_TYPES.STATE_BOTH
-- Сохранение минимального года для сортировки
if event_year and (not existing.event_year or event_year < existing.event_year) then
existing.event_year = event_year
end
end
return
end
end
-- Новая запись
local c_data = get_country_succession(name)
table.insert(forks[period].countries, {
name = name,
min = c_data and c_data.min or nil,
max = c_data and c_data.max or nil,
link_type = link_type,
is_member = is_member,
event_year = event_year
})
end
-- Расчёт разветвлений (state-based + past/next)
local function calculate_forks(succession, nav_start, nav_end, current_time, ctx, time_configs)
local forks = {}
if not succession then return forks, nav_end end
-- Хелпер: добавление fork для входа/выхода
local function add_state_fork(name, start_year, end_year, is_member)
local events = {
{year = start_year, fork_type = FORK_TYPES.STATE_JOIN},
{year = end_year, fork_type = FORK_TYPES.STATE_LEAVE}
}
for _, ev in ipairs(events) do
if ev.year then
local period = year_to_period(time_configs, ev.year)
if period >= nav_start and period <= nav_end then
add_country_to_fork(forks, period, name, ev.fork_type, is_member, ev.year)
end
end
end
end
-- 1. Государства в которые входила текущая страна (is_member=true → ↑↓)
for _, s in ipairs(succession.states or {}) do
add_state_fork(s.name, s.start_year, s.end_year, true)
end
-- 2. Страны которые входили в текущее государство (is_member=false → +−)
for _, m in ipairs(find_member_countries(succession.name)) do
add_state_fork(m.name, m.state_start, m.state_end, false)
end
-- 3. Past forks (предшественники на виртуальном периоде min-1)
-- Past воспринимается как соседний год слева от текущей страны
if succession.min and succession.past then
local virtual_period = year_to_period(time_configs, succession.min) - time_configs.step
-- Проверка видимости: виртуальный период в пределах range от текущего времени
local range_start = current_time - ctx.range * time_configs.step
if virtual_period >= range_start then
for _, past_name in ipairs(succession.past) do
local past_data = get_country_succession(past_name)
if past_data and past_data.max then
add_country_to_fork(forks, virtual_period, past_name, FORK_TYPES.PAST, nil, past_data.max)
end
end
end
end
-- 4. Next forks (преемники на виртуальном периоде max+1)
-- Next воспринимается как соседний год справа от текущей страны
if succession.max and succession.next then
local virtual_period = year_to_period(time_configs, succession.max) + time_configs.step
-- Проверка видимости: виртуальный период в пределах range от текущего времени
local range_end = current_time + ctx.range * time_configs.step
if virtual_period <= range_end then
for _, next_name in ipairs(succession.next) do
local next_data = get_country_succession(next_name)
if next_data and next_data.min then
add_country_to_fork(forks, virtual_period, next_name, FORK_TYPES.NEXT, nil, next_data.min)
end
end
end
end
return forks, nav_end
end
-- ────────────────────────────────────────────────────────────────────────────────
-- 8.4 Рендеринг навигации
-- ────────────────────────────────────────────────────────────────────────────────
-- Символы для типов связей
local FORK_SYMBOLS = {
[FORK_TYPES.STATE_JOIN] = { member = '↑', container = '+' },
[FORK_TYPES.STATE_LEAVE] = { member = '↓', container = '−' },
[FORK_TYPES.STATE_BOTH] = { member = '↑↓', container = '±' },
[FORK_TYPES.PAST] = '←',
[FORK_TYPES.NEXT] = '→'
}
-- Приоритет типа связи для сортировки (меньше = выше приоритет)
local function get_fork_type_priority(c)
if c.link_type == FORK_TYPES.PAST or c.link_type == FORK_TYPES.NEXT then return 1 end
-- Сначала выход, потом оба, потом вход
if c.link_type == FORK_TYPES.STATE_LEAVE then return 2 end
if c.link_type == FORK_TYPES.STATE_BOTH then return 3 end
if c.link_type == FORK_TYPES.STATE_JOIN then return 4 end
return 5
end
-- Сортировка связанных стран: дата → тип → алфавит
local function sort_fork_countries(countries)
table.sort(countries, function(a, b)
local a_year, b_year = a.event_year or 0, b.event_year or 0
if a_year ~= b_year then return a_year < b_year end
local a_prio, b_prio = get_fork_type_priority(a), get_fork_type_priority(b)
if a_prio ~= b_prio then return a_prio < b_prio end
return a.name < b.name
end)
end
-- ▼ Хелпер: формирование country_part по типу связи
local function format_country_part(country_short, link_type)
if link_type == 'next' then
return '(→ ' .. country_short .. ')'
elseif link_type == 'past' then
return '(' .. country_short .. ' ←)'
else
return '(' .. country_short .. ')'
end
end
-- ▼ Хелпер: обёртка ссылки (90% для state, обычный для past/next с датой)
local function wrap_secondary_link(cat_title, display, full_size)
local link = '[[:К:' .. cat_title .. '|' .. display .. ']]'
if full_size then
return link
end
return '<span style="font-size:90%">' .. link .. '</span>'
end
-- Экспорт хелперов для временных модулей
Core.format_country_part = format_country_part
Core.wrap_secondary_link = wrap_secondary_link
-- Создание ссылки на связанную страну
-- link_type: тип связи (state_join, state_leave, state_both, past, next)
-- is_same_period: true если past/next на том же периоде что и текущая страна (без даты)
local function make_country_link(time_configs, period, country_data, ctx, link_type, is_same_period)
local country_name = type(country_data) == 'table' and country_data.name or country_data
local country_short = Core.get_country_short(country_name)
local is_member = type(country_data) == 'table' and country_data.is_member
-- Определение символа
local symbol = ''
if link_type == FORK_TYPES.STATE_JOIN or link_type == FORK_TYPES.STATE_LEAVE or link_type == FORK_TYPES.STATE_BOTH then
local sym_data = FORK_SYMBOLS[link_type]
-- is_member=true → стрелки (↑↓), is_member=false → плюс/минус (+−)
symbol = is_member and sym_data.member or sym_data.container
elseif link_type == FORK_TYPES.PAST or link_type == FORK_TYPES.NEXT then
symbol = FORK_SYMBOLS[link_type]
end
-- Формирование ссылки
if link_type == FORK_TYPES.PAST or link_type == FORK_TYPES.NEXT then
-- Для past/next: реальный год из event_year, конвертированный в период
local real_year = (type(country_data) == 'table' and country_data.event_year) or period
local real_period = time_configs.year_to_period and time_configs.year_to_period(real_year) or real_year
if is_same_period then
-- Past/Next на текущем периоде: без даты
local display_text = link_type == FORK_TYPES.NEXT
and (symbol .. ' ' .. country_short)
or (country_short .. ' ' .. symbol)
if time_configs.format_country_only then
return time_configs.format_country_only(real_period, country_name, ctx, display_text)
end
return '<span style="font-size:90%">(' .. display_text .. ')</span>'
else
-- Past/Next на соседнем периоде: с датой
if time_configs.format_link_with_country then
return time_configs.format_link_with_country(real_period, country_name, ctx, false, link_type)
end
local display = link_type == FORK_TYPES.NEXT
and (symbol .. ' ' .. country_short)
or (country_short .. ' ' .. symbol)
return tostring(real_period) .. ' (' .. display .. ')'
end
else
-- State: без даты, только символ и название
local display_text = symbol .. ' ' .. country_short
if time_configs.format_country_only then
return time_configs.format_country_only(period, country_name, ctx, display_text)
end
return '<span style="font-size:90%">(' .. display_text .. ')</span>'
end
end
-- Определение позиции блока разветвления
local function get_fork_position(has_left, has_right)
if has_left and has_right then return 'center'
elseif has_left then return 'right'
elseif has_right then return 'left'
end
return 'none'
end
-- Создание HTML-блока разветвления
local function create_fork_block(items, position)
if #items == 1 then
return items[1]
end
local style = FORK_STYLES.base
if position == 'left' then style = style .. FORK_STYLES.border_right
elseif position == 'right' then style = style .. FORK_STYLES.border_left
elseif position == 'center' then style = style .. FORK_STYLES.border_both
end
return '<div style="' .. style .. '">' .. table.concat(items, '<br>') .. '</div>'
end
-- ────────────────────────────────────────────────────────────────────────────────
-- 8.5 Главная функция навигации
-- ────────────────────────────────────────────────────────────────────────────────
-- Создание навигационного блока
local function make_navbox(ctx, time_configs)
local wt = mw.html.create('div'):addClass('ts-module-Индекс_категории hlist')
local row = wt:tag('ul')
local bounds = time_configs.calculate_bounds(ctx, ctx.geo_data)
local current_time = time_configs.adjust_current(ctx)
-- Пропуск несуществующих категорий (режим skip-gaps)
if ctx.skip_gaps then
local left_links = collect_links_skip_gaps(ctx, time_configs, bounds, current_time, -1)
local right_links = collect_links_skip_gaps(ctx, time_configs, bounds, current_time, 1)
fill_with_red_links(left_links, ctx, time_configs, bounds, current_time, -1)
fill_with_red_links(right_links, ctx, time_configs, bounds, current_time, 1)
table.sort(left_links, function(a, b) return a.time < b.time end)
local start_index = math.max(1, #left_links - ctx.range + 1)
for i = start_index, #left_links do
row:tag('li'):wikitext(left_links[i].link)
end
local current_link = time_configs.format_link(current_time, ctx)
local current_text = current_link:match('%|([^%]]+)%]%]') or ''
row:tag('li'):tag('strong'):wikitext(current_text)
for i = 1, math.min(#right_links, ctx.range) do
row:tag('li'):wikitext(right_links[i].link)
end
return getStyles() .. tostring(wt)
end
-- Обычный режим (с поддержкой разветвлений)
local country_name = ctx.country_name
local enable_split = not ctx.skip_split
local succession = enable_split and country_name and get_country_succession(country_name) or nil
if succession and (succession.past or succession.next or (succession.states and #succession.states > 0)) and not ctx.country_pred then
ctx.country_pred = Core.get_country_pred(country_name)
end
-- Границы страны (если нет ручных параметров)
local country_bounds = nil
if succession and (succession.min or succession.max) and not ctx.has_manual_min and not ctx.has_manual_max then
country_bounds = {
min = succession.min and year_to_period(time_configs, succession.min) or nil,
max = succession.max and year_to_period(time_configs, succession.max) or nil
}
end
-- Базовая линейка и обрезка
local nav_start = current_time - ctx.range * time_configs.step
local nav_end = current_time + ctx.range * time_configs.step
local effective_min = (country_bounds and country_bounds.min) or bounds.min
local effective_max = (country_bounds and country_bounds.max) or bounds.max
nav_start = math.max(nav_start, effective_min)
nav_end = math.min(nav_end, effective_max)
if nav_end < nav_start then return '' end
-- Расчёт разветвлений (только если включены)
local forks = {}
if enable_split and succession then
forks, nav_end = calculate_forks(succession, nav_start, nav_end, current_time, ctx, time_configs)
end
-- Сбор всех периодов для навигации (включая fork-периоды вне основного диапазона)
local all_periods = {}
local periods_set = {}
-- Основной диапазон
for i = nav_start, nav_end, time_configs.step do
if not periods_set[i] then
table.insert(all_periods, i)
periods_set[i] = true
end
end
-- Добавление fork-периодов, которые могут быть вне диапазона
for period, _ in pairs(forks) do
if not periods_set[period] then
table.insert(all_periods, period)
periods_set[period] = true
end
end
table.sort(all_periods)
-- Генерация навигации
for idx, i in ipairs(all_periods) do
local has_left = idx > 1
local has_right = idx < #all_periods
local fork_data = forks[i]
if fork_data and fork_data.countries and #fork_data.countries > 0 then
-- Есть fork на этом периоде
local items = {}
-- Проверка существования текущей страны в этом периоде
local current_exists = true
if succession then
local c_min = succession.min and year_to_period(time_configs, succession.min) or nil
local c_max = succession.max and year_to_period(time_configs, succession.max) or nil
current_exists = (not c_min or c_min <= i) and (not c_max or c_max >= i)
end
-- Сначала текущая страна (если существует) с датой
if current_exists then
table.insert(items, time_configs.format_link(i, ctx))
end
-- Сортировка и добавление связанных стран
sort_fork_countries(fork_data.countries)
for _, c in ipairs(fork_data.countries) do
local is_past_next = (c.link_type == FORK_TYPES.PAST or c.link_type == FORK_TYPES.NEXT)
table.insert(items, make_country_link(time_configs, i, c, ctx, c.link_type, is_past_next and current_exists))
end
-- Если есть хотя бы одна ссылка
if #items > 0 then
row:tag('li'):wikitext(create_fork_block(items, get_fork_position(has_left, has_right)))
end
else
-- Обычная ссылка
row:tag('li'):wikitext(time_configs.format_link(i, ctx))
end
end
return getStyles() .. tostring(wt)
end
Core.make_navbox = make_navbox
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 9: КАТЕГОРИИ
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ Стандартное добавление категории
local function add_category_standard(_, ret, added, processed)
local categories = mw.text.split(processed, '|')
local cat_name = categories[1]
if not added[cat_name] then
ret = ret .. string.format('[[Категория:%s%s]]',
cat_name,
categories[2] and ('|' .. categories[2]) or ''
)
added[cat_name] = true
return ret, true
end
return ret, false
end
Core.add_category_standard = add_category_standard
-- ▼ Добавление категории с проверкой диапазона
local function add_category_with_range(ctx, ret, added, processed)
local categories = mw.text.split(processed, '|')
local cat_name = categories[1]
local sort_key = categories[2] or ''
local cmin = tonumber(categories[3])
local cmax = tonumber(categories[4])
local current_time = (ctx.BC == 1) and -ctx.time or ctx.time
if (not cmin or current_time >= cmin) and (not cmax or current_time <= cmax) then
if not added[cat_name] then
ret = ret .. string.format('[[Категория:%s%s]]',
cat_name,
sort_key ~= '' and ('|' .. sort_key) or ''
)
added[cat_name] = true
return ret, true
end
end
return ret, false
end
Core.add_category_with_range = add_category_with_range
-- ▼ Проверка существования категории
local function category_exists(category_name)
if not category_name or category_name == '' then return false end
category_name = mw.ustring.match(category_name, "^" .. PREFIX_PATTERN .. "*(.-)!") or category_name
local title = mw.title.new('Категория:' .. category_name)
return title and title.exists
end
Core.category_exists = category_exists
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 10: РАСШИРЕНИЕ ПЛЕЙСХОЛДЕРОВ
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ Расширение плейсхолдеров месяца
local month_case_cache = {}
local function expand_month_placeholders(text, title)
if type(text) ~= 'string' or text == '' then return text end
if not has_month_placeholder(text) then
return text
end
local findTopic = Core.utils.get_findtopic_module()
if not findTopic then return text end
local starts_with_month = mw.ustring.find(text, '^%s*<месяц>') ~= nil
local starts_with_month_gen = mw.ustring.find(text, '^%s*<месяца>') ~= nil
local starts_with_pred = mw.ustring.find(text, '^%s*<в месяце>') ~= nil
local function get_month_cases(mode)
local key = (title or '') .. '|' .. (mode or '-')
local c = month_case_cache[key]
if not c then
c = {
imen = findTopic.findtopicinstring(title or '', 'именительный', 'month', mode) or '',
rod = findTopic.findtopicinstring(title or '', 'родительный', 'month', mode) or '',
pred = findTopic.findtopicinstring(title or '', 'предлог', 'month', mode) or ''
}
month_case_cache[key] = c
end
return c
end
local upper = get_month_cases('upper')
local lower = get_month_cases('lower')
if starts_with_pred then
text = mw.ustring.gsub(text, '^(%s*)<в месяце>', function(spaces)
local pred = lower.pred or ''
pred = mw.ustring.gsub(pred, '^в', 'В', 1)
return (spaces or '') .. pred
end, 1)
elseif starts_with_month then
text = mw.ustring.gsub(text, '^(%s*)<месяц>', function(spaces)
return (spaces or '') .. (upper.imen or '')
end, 1)
elseif starts_with_month_gen then
text = mw.ustring.gsub(text, '^(%s*)<месяца>', function(spaces)
return (spaces or '') .. (upper.rod or '')
end, 1)
end
text = gsub(text, '<месяц>', lower.imen)
text = gsub(text, '<месяца>', lower.rod)
text = gsub(text, '<в месяце>', lower.pred)
return text
end
-- ▼ Поддержка <ключ> для месяцев в сортировочном ключе
local MONTHS_MAP = {
['январь'] = 1, ['февраль'] = 2, ['март'] = 3, ['апрель'] = 4,
['май'] = 5, ['июнь'] = 6, ['июль'] = 7, ['август'] = 8,
['сентябрь'] = 9, ['октябрь'] = 10, ['ноябрь'] = 11, ['декабрь'] = 12
}
local function get_month_key_from_title(title)
local findTopic = Core.utils.get_findtopic_module()
if not findTopic then return nil end
local month_nom = findTopic.findtopicinstring(title or '', 'именительный', 'month', 'lower') or ''
month_nom = mw.ustring.lower(month_nom or '')
local idx = MONTHS_MAP[month_nom]
if idx then
return string.format('%02d', idx)
end
return nil
end
local function apply_month_key_placeholder(text, title)
if type(text) ~= 'string' or text == '' then return text end
if not mw.ustring.find(text, '<ключ>', 1, true) then return text end
local mm = get_month_key_from_title(title)
if not mm then return text end
local result = text
if mw.ustring.find(result, '|', 1, true) then
result = mw.ustring.gsub(result, '^(.-)|(.*)$', function(cat, sort)
sort = mw.ustring.gsub(sort, '<ключ>', mm)
return (cat or '') .. '|' .. (sort or '')
end, 1)
return result
end
if mw.ustring.find(result, '!', 1, true) then
result = mw.ustring.gsub(result, '(![^|]*)<ключ>', function(before)
return (before or '') .. mm
end)
return result
end
return result
end
-- ▼ Унифицированное раскрытие всех плейсхолдеров
expand_all = function(text, ctx, conf, opts)
opts = opts or {}
local separator = opts.separator or '!'
local result = conf.do_expand(ctx, separator == '!' and text:gsub('!', '|') or text)
result = expand_month_placeholders(result, ctx.title)
result = apply_month_key_placeholder(result, ctx.title)
if not opts.skip_strip and ctx.skip_1century and ctx.is_single_century then
local Geo = Core.utils.get_geo_module()
if Geo and Geo.strip_century_references then
result = Geo.strip_century_references(result)
end
end
return result
end
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 11: ОБРАБОТКА ОШИБОК
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ Создание трекера ошибок
local function make_error_tracker(conf)
local error_list = {}
local unique_errors = {}
local range_aggregate = nil
local function add_error(error_code, additional_info)
local msg = conf.error_messages[error_code]
if not msg then return end
msg = msg:gsub('%%INFO%%', additional_info or "")
if conf.range_codes and conf.range_codes[error_code] then
if not range_aggregate then
range_aggregate = {message = conf.range_prefix, details = {}}
table.insert(error_list, range_aggregate)
end
table.insert(range_aggregate.details, msg)
else
local error_message = '<span class="error">' .. msg .. '</span>'
if not unique_errors[error_message] then
unique_errors[error_message] = true
table.insert(error_list, {message = error_message})
end
end
end
local function publish_errors()
if #error_list == 0 then return '' end
local result = '<div class="error-list">'
for i, err in ipairs(error_list) do
if err.details then
result = result .. '<span class="error">' .. err.message
for _, detail in ipairs(err.details) do
result = result .. ' ' .. detail
end
result = result .. '</span>'
else
result = result .. err.message
end
if i < #error_list then
result = result .. '<br>'
end
end
result = result .. '</div>'
result = result .. string.format('[[Категория:Википедия:Страницы с некорректным использованием модуля %s]]', conf.module_name)
return result
end
return add_error, publish_errors
end
Core.make_error_tracker = make_error_tracker
-- ▼ Общие сообщения об ошибках (не зависят от типа времени)
Core.common_error_messages = {
[6] = 'Ошибка: страна не найдена.',
[7] = 'Ошибка: часть света не найдена.',
[8] = 'Ошибка: город не найден.',
[9] = 'Ошибка: регион не найден.',
[11] = 'Ошибка: часть света не найдена для страны %INFO%.',
[12] = 'Ошибка: обнаружены неразвернутые плейсхолдеры в категории: %INFO%.',
[13] = 'Ошибка: не удалось определить тип категории.',
[14] = 'Ошибка: не удалось загрузить модуль MetaCat/%INFO%.',
[15] = 'Ошибка: %INFO%.',
[16] = 'Ошибка: регион для города %INFO% не найден.',
[17] = 'Ошибка: государство "%INFO%" не найдено в справочнике Find topic.',
[18] = 'Ошибка: фильтр "%INFO%" требует плейсхолдер <часть света> в строке.'
}
-- ▼ Формы рода для типов времени
local function gender_forms_for_time(time_type)
local is_neuter = (time_type == 'decade' or time_type == 'millennium')
return {
not_found = is_neuter and 'не найдено' or 'не найден',
adj_min = is_neuter and 'Минимальное' or 'Минимальный',
adj_max = is_neuter and 'Максимальное' or 'Максимальный',
part_limited = is_neuter and 'ограниченное' or 'ограниченный'
}
end
-- ▼ Фабрика генераторов сообщений об ошибках
function Core.create_error_messages(time_type)
local time_name = TIME_TYPE_NAMES[time_type] and TIME_TYPE_NAMES[time_type].nom or time_type
local forms = gender_forms_for_time(time_type)
local time_name_not_found = time_name .. ' ' .. forms.not_found
local time_name_two = 'два ' .. (
time_type == 'century' and 'века' or
time_type == 'decade' and 'десятилетия' or
time_type == 'millennium' and 'тысячелетия' or
'года'
)
local messages = {
[1] = 'Ошибка: ' .. time_name_not_found .. '.',
[2] = forms.adj_min .. ' ' .. time_name .. ', ' .. forms.part_limited .. ' шаблоном: %INFO%.',
[3] = forms.adj_max .. ' ' .. time_name .. ', ' .. forms.part_limited .. ' шаблоном: %INFO%.',
[4] = forms.adj_min .. ' ' .. time_name .. ' для %INFO%.',
[5] = forms.adj_max .. ' ' .. time_name .. ' для %INFO%.',
[10] = 'Ошибка: обнаружено ' .. time_name_two .. '.'
}
for code, msg in pairs(Core.common_error_messages) do
messages[code] = msg
end
return messages
end
-- ▼ Фабрика заголовка агрегированной ошибки диапазона
function Core.create_range_prefix(time_type)
local time_name = TIME_TYPE_NAMES[time_type] and TIME_TYPE_NAMES[time_type].nom or time_type
return 'Ошибка: ' .. time_name .. ' не попадает в заданный диапазон.'
end
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 12: ДИСПЕТЧЕРИЗАЦИЯ МОДУЛЕЙ
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ Диспетчеризация модулей (вызывается из диспетчера)
function Core.dispatch_to_module(frame, category_type, module_map, dispatcher_name)
local error_config = {
module_name = dispatcher_name or 'MetaCat',
error_messages = Core.common_error_messages,
range_codes = {}
}
local add_error, publish_errors = make_error_tracker(error_config)
local module_name = module_map[category_type]
if not module_name then
add_error(13)
return publish_errors()
end
local target_module = get_module(module_name)
if not target_module or not target_module.main then
add_error(14, module_name)
return publish_errors()
end
return target_module.main(frame)
end
-- ▼ Expand через модуль (вызывается из диспетчера)
function Core.expand_with_module(frame, category_type, module_map)
local module_name = module_map[category_type]
if not module_name then
return ''
end
local target_module = get_module(module_name)
if not target_module or not target_module.expand then
return ''
end
return target_module.expand(frame)
end
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 13: ДЕТЕКЦИЯ ТИПОВ
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ Плейсхолдеры временных типов (для детекции)
local TIME_PLACEHOLDERS = {
year = {'<год>'},
decade = {'<десятилетие>'},
century = {'<век>'},
millennium = {'<тысячелетие>', '<тысячелетия>', '<в тысячелетии>'}
}
-- ▼ Проверка наличия временных плейсхолдеров в строке (включая вложенные в функции)
local function detect_time_placeholders_in_string(str)
local found = {}
for time_type, placeholders in pairs(TIME_PLACEHOLDERS) do
for _, ph in ipairs(placeholders) do
if str:find(ph, 1, true) then
found[time_type] = true
break
end
end
end
return found
end
-- ▼ Сканирование сырого викитекста шаблона (общая логика для детекции плейсхолдеров)
-- Возвращает найденные временные типы из сырого викитекста страницы шаблона
local function scan_template_raw_content()
local current_title = mw.title.getCurrentTitle()
if current_title.namespace ~= 10 then return {} end
local raw_content = current_title:getContent()
if not raw_content then return {} end
return detect_time_placeholders_in_string(raw_content)
end
-- ▼ Детектор типов плейсхолдеров для категоризации шаблонов
local function detect_placeholder_types(args)
if not args then return {} end
local types = { time = nil, geo = nil, has_month = false }
-- Приоритет force над плейсхолдерами
local force_type = args['force'] and mw.ustring.lower(args['force'])
if force_type and (force_type == 'year' or force_type == 'decade' or force_type == 'century' or force_type == 'millennium') then
types.time = force_type
end
local PH = Core.placeholders.PH
local geo_map = {
city = 'city', region = 'region', country = 'country', state = 'country',
continent_simple = 'continent', continent_parent = 'continent'
}
-- Приоритет geo-типов: более специфичные (city, region) > общие (country, continent)
-- Используется упорядоченный список для детерминированного поведения
local geo_priority = {'city', 'region', 'country', 'state', 'continent_simple', 'continent_parent'}
-- Сбор всех найденных временных типов из всех аргументов
local found_time = { year = false, decade = false, century = false, millennium = false }
local found_geo = {}
for _, arg in pairs(args) do
if type(arg) == 'string' then
-- Накопление временных типов из всех аргументов
local found = detect_time_placeholders_in_string(arg)
for t, v in pairs(found) do
found_time[t] = found_time[t] or v
end
-- Детекция месяца
if not types.has_month and has_month_placeholder(arg) then
types.has_month = true
end
-- Накопление гео-типов из всех аргументов
for ph_type, list in pairs(PH) do
for _, ph in ipairs(list) do
if arg:find(ph, 1, true) then
found_geo[ph_type] = true
break
end
end
end
end
end
-- Для шаблонов: анализ сырого викитекста (плейсхолдеры могут быть внутри #ifexpr)
local raw_found = scan_template_raw_content()
for t, v in pairs(raw_found) do
found_time[t] = found_time[t] or v
end
-- Выбор geo-типа по приоритету (более специфичные первыми)
for _, ph_type in ipairs(geo_priority) do
if found_geo[ph_type] then
types.geo = geo_map[ph_type]
break
end
end
-- Выбор временного типа по приоритету (если не задан через force)
if not types.time then
for _, t in ipairs({'year', 'decade', 'century', 'millennium'}) do
if found_time[t] then
types.time = t
break
end
end
end
return types
end
-- ▼ Определение типа категории по её названию
local function detect_category_time_type(title, args)
if mw.ustring.match(title, '%d+%s*год[ау]?') then return 'year' end
if mw.ustring.match(title, '%d*0%-[ех] год[ыоа][вх]?') then return 'decade' end
if mw.ustring.match(title, '[IVXLCDM]+ век[еа]?') then return 'century' end
if mw.ustring.match(title, '%d+%-[ем] тысячелети[ией]') or mw.ustring.match(title, '%d+%-го тысячелетия') then
return 'millennium'
end
if args and args['force'] == 'year' and mw.ustring.match(title, '%d%d%d%d') then
return 'year'
end
return nil
end
Core.detect_category_time_type = detect_category_time_type
-- ▼ Определение типа шаблона по плейсхолдерам в аргументах
-- Для шаблонов также анализируется сырой викитекст страницы (для поиска плейсхолдеров внутри #ifexpr и т.д.)
local function detect_template_time_type(args)
if not args then return nil, false, false, false, false end
local has = { year = false, decade = false, century = false, millennium = false }
-- Поиск плейсхолдеров в аргументах
for _, arg in pairs(args) do
if type(arg) == 'string' then
local found = detect_time_placeholders_in_string(arg)
for t, v in pairs(found) do has[t] = has[t] or v end
end
end
-- Для шаблонов: анализ сырого викитекста (общая функция)
local raw_found = scan_template_raw_content()
for t, v in pairs(raw_found) do has[t] = has[t] or v end
-- Приоритет: year > decade > century > millennium
local template_type = nil
for _, t in ipairs({'year', 'decade', 'century', 'millennium'}) do
if has[t] then template_type = t; break end
end
return template_type, has.year, has.decade, has.century, has.millennium
end
Core.detect_template_time_type = detect_template_time_type
-- ▼ Категории шаблонов (централизация)
function Core.template_module_categories(module_name, args)
local cats = {}
table.insert(cats, string.format('[[Категория:Шаблоны, использующие модуль %s]]', module_name))
local types = detect_placeholder_types(args)
local geo_names = {
country = 'странам',
city = 'городам',
continent = 'частям света',
region = 'административным единицам первого уровня'
}
local time_names = {
year = 'годам',
decade = 'десятилетиям',
century = 'векам',
millennium = 'тысячелетиям'
}
-- Месяцы добавляются как отдельная категория
if types.has_month then
table.insert(cats, '[[Категория:Шаблоны для категорий по месяцам]]')
end
-- Основная категория: география + время (без месяцев)
local components = {}
if types.geo then
table.insert(components, geo_names[types.geo])
end
if types.time then
table.insert(components, time_names[types.time])
end
if #components > 0 then
local category_text = table.concat(components, ' и ')
table.insert(cats, string.format('[[Категория:Шаблоны для категорий по %s]]', category_text))
end
table.insert(cats, '[[Категория:Шаблоны, использующие индекс категории (автоматический)]]')
-- Отслеживающие категории на основе аргументов шаблона
local tracking_cats = Core.check_template_tracking_rules(args, types.time, types.geo)
for cat_name, sort_key in pairs(tracking_cats) do
if sort_key and sort_key ~= '' and sort_key ~= true then
table.insert(cats, string.format('[[Категория:%s|%s]]', cat_name, sort_key))
else
table.insert(cats, string.format('[[Категория:%s]]', cat_name))
end
end
return table.concat(cats, '')
end
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 14: ВАЛИДАЦИЯ
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ Проверка соответствия типа шаблона и категории
local function validate_template_category_match(ctx, args, add_error)
if args['skip-type-check'] == 'yes' or args['skip-type-check'] == '1' then
return
end
-- При явном указании force пропуск проверки плейсхолдеров
local force_type = args['force'] and mw.ustring.lower(args['force'])
if force_type and TIME_TYPE_NAMES[force_type] then
return
end
for _, type_info in pairs(TIME_TYPE_NAMES) do
if ctx.title == type_info.root_category then
return
end
end
local category_type = detect_category_time_type(ctx.title, args)
local template_type, has_year, has_decade, has_century, has_millennium = detect_template_time_type(args)
local has_placeholder = {
year = has_year,
decade = has_decade,
century = has_century,
millennium = has_millennium
}
-- Проверка 1: Наличие соответствующего плейсхолдера для типа категории
if category_type and not has_placeholder[category_type] then
local type_info = TIME_TYPE_NAMES[category_type]
local has_root_category = false
if type_info and type_info.root_category then
for _, arg in pairs(args) do
if type(arg) == 'string' and arg == type_info.root_category then
has_root_category = true
break
end
end
end
if not has_root_category then
local error_info = string.format(
'В категории %s используется шаблон, в котором не передаётся переменная %s',
type_info and type_info.gen or category_type,
type_info and type_info.placeholder or category_type
)
add_error(15, error_info)
return
end
end
-- Проверка 2: Запрет более детальных плейсхолдеров
if category_type and template_type and category_type ~= template_type then
local cat_info = TIME_TYPE_NAMES[category_type]
local tpl_info = TIME_TYPE_NAMES[template_type]
local cat_level = cat_info and cat_info.detail_level or 0
local tpl_level = tpl_info and tpl_info.detail_level or 0
if tpl_level > cat_level then
local error_info = string.format(
TYPE_VALIDATION_MSG_MISMATCH,
cat_info and cat_info.gen or category_type,
tpl_info and tpl_info.gen or template_type
)
add_error(15, error_info)
end
end
end
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 15: ОБРАБОТКА КАТЕГОРИЙ
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ Зарезервированные ключи аргументов (не обрабатываются как категории)
local RESERVED_ARG_KEYS = {
title = true, time = true, type = true, bc = true,
min = true, max = true, range = true,
noindex = true, nonav = true,
['skip-gaps'] = true, skip_gaps = true,
['skip-1century'] = true, skip_1century = true,
['show-question'] = true, show_question = true,
['skip-type-check'] = true,
split = true, force = true
}
-- ▼ Получение префикса строки (? или ~)
local function get_line_prefix(line)
-- Приоритет: original (надёжнее, т.к. result может быть skip с пустым text)
if line.original then
local after_cond = line.original:match('^%|?%[.-%](' .. PREFIX_PATTERN .. ')')
if after_cond then return after_cond end
return first_char(line.original)
end
if line.results and line.results[1] and line.results[1].text then
return first_char(line.results[1].text)
end
return ''
end
-- ▼ Проверка, является ли строка fallback-строкой (~)
local function is_fallback_line(ln)
return get_line_prefix(ln) == PREFIX_FALLBACK
end
-- ▼ Извлечение блоков условий из строки (возвращает строку условий или nil)
local function extract_conditions_string(text)
if not text or type(text) ~= 'string' then return nil end
local rest = text:match('^%|?(.*)') or text
local conditions_str = ''
while true do
local block, after = rest:match('^(%[[^%]]+%])(.*)')
if not block then break end
conditions_str = conditions_str .. block
rest = after
end
return conditions_str ~= '' and conditions_str or nil
end
-- ▼ Добавление унаследованных условий к fallback-строке
local function prepend_inherited_conditions(fallback_text, inherited_conditions)
if not inherited_conditions then return fallback_text end
if extract_conditions_string(fallback_text) then return fallback_text end
local prefix, rest = fallback_text:match('^(%|?)(.*)')
return prefix .. inherited_conditions .. rest
end
-- ▼ Обработка географических плейсхолдеров
local function process_country_placeholders(s, ctx, conf, add_error)
if type(s) ~= 'string' then return {} end
if not has_country_placeholders(s) then
return {{text = expand_all(s, ctx, conf, {separator = '', skip_strip = true}), type = 'main'}}
end
local result_lines = {}
local adjusted_time = conf.adjust_time_for_country and conf.adjust_time_for_country(ctx.time, ctx.BC) or ctx.time
local Geo = Core.utils.get_geo_module()
local country_result = nil
if Geo and type(Geo.resolve) == 'function' then
country_result = Geo.resolve({
[1] = s,
title = ctx.title,
type = conf.type,
time = adjusted_time,
skip_1century = ctx.skip_1century,
country_name = ctx.country_name,
city_name = ctx.city_name,
region_name = ctx.region_name,
template_has_city = ctx.template_has_city,
template_has_region = ctx.template_has_region,
force_geo = ctx.force_geo
})
end
if country_result then
-- Обработка массива результатов
if country_result.results then
if #country_result.results == 0 and country_result.error == 0 then
table.insert(result_lines, {text = '', type = 'skip'})
else
for idx, res_text in ipairs(country_result.results) do
if res_text and res_text ~= '' then
local res_type = (idx == 1) and 'main' or 'extra'
table.insert(result_lines, {text = res_text, type = res_type})
end
end
end
end
if country_result.error and country_result.error > 0 then
local info
if country_result.error == 11 or country_result.error == Core.base_config.errors.continent_not_found then
info = ctx.country_name or ''
if info == '' then
local findTopic = Core.utils.get_findtopic_module()
info = findTopic and findTopic.findtopicinstring(ctx.title, 'именительный', 'country') or ''
end
end
add_error(country_result.error, info)
ctx.country_error_flag = true
end
-- Несоответствия государств (ошибка 17)
if country_result.state_mismatches and #country_result.state_mismatches > 0 then
for _, state_name in ipairs(country_result.state_mismatches) do
add_error(17, state_name)
end
end
-- Фильтры без требуемого плейсхолдера (ошибка 18)
if country_result.filter_errors and #country_result.filter_errors > 0 then
for _, filter_name in ipairs(country_result.filter_errors) do
add_error(18, filter_name)
end
end
end
return result_lines
end
-- ▼ Обработка строк категорий
local function cats(args, ctx, conf, add_error)
local ret = ''
local added_categories = {}
local lines = {}
-- Установка флагов плейсхолдеров шаблона для оптимизации Geo fallback
local needed_types, _ = detect_needed_geo_types(args)
if needed_types then
ctx.template_has_city = needed_types.city or false
ctx.template_has_region = needed_types.region or false
end
local need_country = ctx.skip_1century or not ctx.skip_split
if need_country and not ctx.country_name then
local findTopic = Core.utils.get_findtopic_module()
if findTopic then
ctx.country_name = findTopic.findtopicinstring(ctx.title, 'именительный', 'country') or nil
if ctx.country_name == '' then ctx.country_name = nil end
end
end
if ctx.skip_1century and ctx.country_name then
local Geo = Core.utils.get_geo_module()
if Geo and Geo.is_single_century_country then
ctx.is_single_century = Geo.is_single_century_country(ctx.country_name)
end
end
local function add_category(text)
local processed = expand_all(text, ctx, conf)
if not processed or processed:match('^%s*$') then return false end
local unresolved = processed:match('<([^>]+)>')
if unresolved then add_error(12, processed) end
local new_ret, added = conf.add_category(ctx, ret, added_categories, processed)
if new_ret ~= nil then ret = new_ret end
return added
end
local max_line_idx = 0
-- Обработка позиционных аргументов
for i, arg in sparseIpairs(args) do
if type(arg) == 'string' and arg ~= '' then
local result = process_country_placeholders(arg, ctx, conf, add_error)
lines[i] = {
original = arg,
results = result or {}
}
if i > max_line_idx then max_line_idx = i end
end
end
-- Обработка именованных аргументов
local line_idx = max_line_idx + 1
for key, value in pairs(args) do
if type(key) == 'string' and not RESERVED_ARG_KEYS[key] then
if has_country_placeholders(key) or key:match('^' .. PREFIX_PATTERN .. '%|%[]') then
local arg
if type(value) == 'string' and value ~= '' then
if key:match('[%+%^][^:]+$') and not key:match(':$') then
arg = key .. ':' .. value
else
arg = key .. value
end
else
arg = key
end
local result = process_country_placeholders(arg, ctx, conf, add_error)
lines[line_idx] = {
original = arg,
results = result or {}
}
line_idx = line_idx + 1
end
end
end
max_line_idx = line_idx - 1
local i = 1
while i <= max_line_idx do
local line = lines[i]
if line then
local prefix = get_line_prefix(line)
local is_question = (prefix == PREFIX_QUESTION)
local is_fallback_prefix = (prefix == PREFIX_FALLBACK)
if is_question then
-- Обработка условных категорий (?)
local exists = {main = false, extra = false}
local has_geo = Core.has_country_placeholders(line.original)
for _, result in ipairs(line.results) do
if result.type ~= 'skip' and result.text and first_char(result.text) == PREFIX_QUESTION then
local text = result.text:sub(2):gsub('^%s+', '')
local cat_name = expand_all(text:match('^(.-)!') or text, ctx, conf, {separator = ''})
if category_exists(cat_name) then
exists[result.type] = true
add_category(text)
elseif ctx.show_question then
add_category(text)
end
end
end
-- Обработка fallback-строк (~) после условной
local j = i + 1
local produced_any = (exists.main or exists.extra)
-- Извлечение условий из ?-строки для наследования
local inherited_conditions = extract_conditions_string(line.original)
while lines[j] and is_fallback_line(lines[j]) do
local fallback_line = lines[j]
local fallback_results = fallback_line.results
-- Наследование условий: если у ~-строки нет своих условий и есть гео-плейсхолдеры
if inherited_conditions and has_geo and not extract_conditions_string(fallback_line.original) then
local modified_text = prepend_inherited_conditions(fallback_line.original, inherited_conditions)
fallback_results = process_country_placeholders(modified_text, ctx, conf, add_error)
end
for _, result in ipairs(fallback_results) do
if result.type ~= 'skip' and result.text and first_char(result.text) == PREFIX_FALLBACK then
-- Fallback публикуется если:
-- 1. Ни одна ?-категория не существует, ИЛИ
-- 2. Есть гео-плейсхолдеры И для данного типа ?-категория не существует
local should_publish = not produced_any or (has_geo and not exists[result.type])
if should_publish then add_category(result.text:sub(2)) end
end
end
j = j + 1
end
i = j - 1
elseif not is_fallback_prefix then
-- Обычные категории (без префикса)
for _, result in ipairs(line.results) do
if result.type ~= 'skip' and result.text and result.text ~= '' then
add_category(result.text)
end
end
end
end
i = i + 1
end
return ret
end
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 16: ОСНОВНОЙ ДВИЖОК
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ Основной раннер MetaCat
function Core.run(frame, conf)
local args = getArgs(frame)
local ctx = {
title = args['title'] or mw.title.getCurrentTitle().text,
range = tonumber(args['range'] or conf.defaults.range),
min = tonumber(args['min'] or conf.defaults.min),
max = tonumber(args['max'] or conf.defaults.max),
has_manual_min = args['min'] ~= nil,
has_manual_max = args['max'] ~= nil,
BC = 0,
templ = nil,
time = nil,
country_error_flag = false,
skip_gaps = Core.parse_bool_param(args['skip-gaps'], Core.DEFAULTS.SKIP_GAPS),
skip_1century = Core.parse_bool_param(args['skip-1century'], Core.DEFAULTS.SKIP_1CENTURY),
skip_split = not Core.parse_bool_param(args['split'], Core.DEFAULTS.SPLIT),
show_question = Core.parse_bool_param(args['show-question'], false),
country_name = nil,
country_pred = nil
}
local add_error, publish_errors = make_error_tracker(conf)
if mw.title.getCurrentTitle().namespace == 10 then
return Core.template_module_categories(conf.module_name, args)
end
local ok = conf.parse(ctx, add_error, args)
if not ok then
return publish_errors()
end
validate_template_category_match(ctx, args, add_error)
local categories
if type(conf.custom_cats) == 'function' then
categories = conf.custom_cats(ctx, args, add_error)
else
categories = cats(args, ctx, conf, add_error)
end
if conf.check_in_bounds then
conf.check_in_bounds(ctx, args, add_error)
end
local output = ''
if args['nonav'] ~= '1' then
output = output .. conf.navbox(ctx)
end
if args['noindex'] ~= '1' then
output = output .. mw.getCurrentFrame():preprocess('{{индекс категории (автоматический)}}\n')
end
if is_table(categories) then
local flat_categories = {}
for _, value in ipairs(categories) do
table.insert(flat_categories, value.text)
end
categories = table.concat(flat_categories, '')
end
output = output .. publish_errors()
return output .. (categories or '')
end
-- ▼ Раскрытие плейсхолдеров
function Core.expand(frame, conf)
local args = getArgs(frame)
local ctx = {
title = args['title'] or mw.title.getCurrentTitle().text,
BC = 0, time = nil, templ = nil,
skip_1century = false, is_single_century = false,
force_geo = args['force'] == 'geo'
}
local ok = conf.parse(ctx, function() end, args)
if not ok then return '' end
local text = args[1] or ''
if conf.expand_pre then text = conf.expand_pre(ctx, text) end
local result = expand_all(text, ctx, conf, {separator = ''})
-- Обработка geo-плейсхолдеров через Geo.resolve
if has_country_placeholders(result) then
local Geo = Core.utils.get_geo_module()
if Geo and Geo.resolve then
local adjusted_time = conf.adjust_time_for_country
and conf.adjust_time_for_country(ctx.time, ctx.BC)
or ctx.time
local geo_result = Geo.resolve({
[1] = result,
title = ctx.title,
type = conf.type,
time = adjusted_time,
force_geo = ctx.force_geo
})
if geo_result and geo_result.results and geo_result.results[1] and geo_result.results[1] ~= '' then
result = geo_result.results[1]
else
-- Очистка нераскрытых geo-плейсхолдеров
local PH = Core.placeholders.PH
for _, list in pairs(PH) do
for _, ph in ipairs(list) do
result = result:gsub(ph, '')
end
end
end
end
end
return result
end
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 17: ФАБРИКИ КОНФИГУРАЦИЙ
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ Создание конфигурации с наследованием
function Core.create_config(time_type, custom_config)
local config = table_shallow_copy(Core.base_config)
config.error_messages = Core.create_error_messages(time_type)
config.adjust_time_for_country = Core.create_time_adjuster(time_type)
config.add_category = Core.add_category_standard
config.range_prefix = Core.create_range_prefix(time_type)
for k, v in pairs(custom_config or {}) do
config[k] = v
end
return config
end
-- ▼ Фабрика корректировщика времени для страны
function Core.create_time_adjuster(time_type)
if time_type == 'year' or time_type == 'century' or time_type == 'millennium' then
return function(time, BC)
if BC == 1 then return 1 - time end
return time
end
elseif time_type == 'decade' then
return function(time, BC)
if BC == 1 then return -time - 10 end
return time
end
end
-- Fallback для неизвестных типов
return function(time) return time end
end
-- ▼ Создание стандартной функции main
function Core.create_main(conf)
return function(frame)
return Core.run(frame, conf)
end
end
-- ▼ Создание стандартной функции expand
function Core.create_expand(conf)
return function(frame)
return Core.expand(frame, conf)
end
end
-- ▼ Создание стандартных полей конфигурации для временного типа
function Core.create_time_config_fields(time_type)
return {
error_messages = Core.create_error_messages(time_type),
range_codes = { [2]=true, [3]=true, [4]=true, [5]=true },
range_prefix = Core.create_range_prefix(time_type),
adjust_time_for_country = Core.create_time_adjuster(time_type)
}
end
-- ▼ Объединение базовой конфигурации с пользовательской
function Core.merge_time_config(time_type, custom_config)
local base = Core.create_time_config_fields(time_type)
base.type = time_type
for k, v in pairs(custom_config or {}) do
base[k] = v
end
return base
end
-- ══════════════════════════════════════════════════════════════════════════════
-- ▼ РАЗДЕЛ 18: ОТСЛЕЖИВАЮЩИЕ КАТЕГОРИИ
-- ══════════════════════════════════════════════════════════════════════════════
-- Проверка наличия фильтрации геообъектов в квадратных скобках
local function has_geo_filtering(text)
if type(text) ~= 'string' then return false end
-- Паттерн: |[+...] или |[-...] или [+...] или [-...] в начале строки
return text:match('^%|?%[[%+%-]') ~= nil
end
-- Ключи сортировки по типам модулей: Y=Year, D=Decade, C=Century, M=Millennium, G=Geo
local MODULE_SORT_KEYS = {
year = 'Y',
decade = 'D',
century = 'C',
millennium = 'M',
country = 'G',
city = 'G',
continent = 'G',
region = 'G'
}
-- Определение ключа сортировки по типу времени и географии
local function get_module_sort_key(time_type, geo_type)
-- Приоритет: время > география
if time_type then
return MODULE_SORT_KEYS[time_type] or ''
elseif geo_type then
return MODULE_SORT_KEYS[geo_type] or 'G'
end
return ''
end
-- Проверка аргументов шаблона на использование различных параметров
function Core.check_template_tracking_rules(args, time_type, geo_type)
local tracking_cats = {}
if not args then return tracking_cats end
local sort_key = get_module_sort_key(time_type, geo_type)
-- Проверка именованных параметров
if args['force'] then
tracking_cats['Шаблоны, использующие модуль MetaCat с параметром force'] = sort_key
end
if args['min'] or args['max'] then
tracking_cats['Шаблоны, использующие модуль MetaCat с параметрами min/max'] = sort_key
end
if args['range'] then
tracking_cats['Шаблоны, использующие модуль MetaCat с параметром range'] = sort_key
end
if args['skip-gaps'] then
tracking_cats['Шаблоны, использующие модуль MetaCat с параметром skip-gaps'] = sort_key
end
if args['skip-1century'] then
tracking_cats['Шаблоны, использующие модуль MetaCat с параметром skip-1century'] = sort_key
end
if args['split'] then
tracking_cats['Шаблоны, использующие модуль MetaCat с параметром split'] = sort_key
end
if args['show-question'] then
tracking_cats['Шаблоны, использующие модуль MetaCat с параметром show-question'] = sort_key
end
-- Проверка позиционных и именованных аргументов на содержимое
local has_geo_filter = false
for key, arg in pairs(args) do
if type(arg) == 'string' and arg ~= '' then
-- Проверка фильтрации геообъектов
if not has_geo_filter and has_geo_filtering(arg) then
has_geo_filter = true
end
end
-- Проверка ключей на фильтрацию (для именованных аргументов вида |[+страна:Россия]=...)
if type(key) == 'string' and has_geo_filtering(key) then
has_geo_filter = true
end
end
if has_geo_filter then
tracking_cats['Шаблоны, использующие модуль MetaCat с фильтрацией геообъектов'] = sort_key
end
return tracking_cats
end
return Core