Modulo:ChronoSpine/date
Vai alla navigazione
Vai alla ricerca
La documentazione per questo modulo può essere creata in Modulo:ChronoSpine/date/man
--[[
Module:ChronoSpine/date
=========================
Purpose
-------
Parses the raw wikitext string an editor types into a `data`/`date`
parameter into the normalised date fields used by every other part of
ChronoSpine (see Module:ChronoSpine/entry for the shape those fields
fit into).
This module works identically for both time modes (`reale` and
`narrativo`) and knows nothing about where the string came from
(manual parameter, SMW query result, Wikibase claim): it is pure
string-to-data logic, per the "pure core" design rule.
Accepted formats
-----------------
Full date 2022-05-05
Year + month 2022-05
Year only 2257
Approximate c. 2150 (any of the above, prefixed)
Range 2256-2257 (en dash; "--" also accepted)
Uncertain ?
See the project's design discussion for why each of these was chosen
(in particular: why the range separator is an en dash / "--" rather
than a plain hyphen, and why a range's endpoints may not themselves be
ranges or "?").
Return value convention
------------------------
`M.parse` never raises a Lua error for bad user input -- a single
mistyped date must never break the rest of the page. Instead it always
returns a table:
On success:
{ ok = true, anno = ..., mese = ..., giorno = ...,
circa = true|false, incerta = true|false, fine = {...}|nil }
On failure:
{ ok = false, raw = "<original string>", code = "<error code>" }
`code` is a fixed, internal, English vocabulary (not shown to
readers as-is): "empty", "bad-format", "bad-month", "bad-day",
"bad-range", "range-order". Turning a code into a localized message
and a tracking category is deliberately left to /i18n and /config,
consumed by each adapter -- this module does not know about either.
--]]
local M = {}
-- U+2013 EN DASH, the preferred range separator (matches the example
-- in the project briefing: "2256-2257"). Expressed as explicit UTF-8
-- bytes (0xE2 0x80 0x93) rather than typed directly into the source,
-- so the character survives byte-for-byte regardless of the encoding
-- used when this file is edited, copied, or pasted into a wiki page.
local RANGE_SEP_ENDASH = "\226\128\147"
-- ASCII fallback for editors who cannot easily type an en dash. A
-- single hyphen is deliberately NOT accepted as a range separator,
-- since it is already part of the ISO date syntax (2022-05-05).
local RANGE_SEP_DOUBLE_HYPHEN = "--"
-- Ordinary (non-leap-year) length, in days, of each calendar month.
local DAYS_IN_MONTH = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
--[[
is_leap_year(year)
-------------------
Private helper. Standard Gregorian leap-year rule: divisible by 4,
except centuries, unless also divisible by 400.
--]]
local function is_leap_year(year)
return (year % 4 == 0 and year % 100 ~= 0) or (year % 400 == 0)
end
--[[
max_day(year, month)
----------------------
Private helper. Returns how many days `month` has in `year`, taking
the leap-year rule into account for February.
--]]
local function max_day(year, month)
if month == 2 and is_leap_year(year) then
return 29
end
return DAYS_IN_MONTH[month]
end
--[[
trim(text)
-----------
Private helper. Strips leading and trailing whitespace. Used
throughout this module so editors can freely add spaces around dates,
range separators, etc. without breaking parsing.
--]]
local function trim(text)
return text:match("^%s*(.-)%s*$")
end
--[[
compare_dates_only(a, b)
--------------------------
Private helper, identical in spirit to the one in
Module:ChronoSpine/entry, but intentionally a separate copy (see the
note in that module for why). Used here only to check that a range's
end is not earlier than its start.
Returns -1 / 0 / 1, as in Module:ChronoSpine/entry.
--]]
local function compare_dates_only(a, b)
local a_anno, b_anno = a.anno or 0, b.anno or 0
if a_anno ~= b_anno then
return (a_anno < b_anno) and -1 or 1
end
local a_mese, b_mese = a.mese or 0, b.mese or 0
if a_mese ~= b_mese then
return (a_mese < b_mese) and -1 or 1
end
local a_giorno, b_giorno = a.giorno or 0, b.giorno or 0
if a_giorno ~= b_giorno then
return (a_giorno < b_giorno) and -1 or 1
end
return 0
end
--[[
strip_circa(text)
-------------------
Private helper. If `text` starts with the approximate-date marker
"c." -- case-insensitive, with or without a following space, e.g.
"c.2150", "c. 2150", "C. 2150" -- removes it and returns the
remainder plus `true`. Otherwise returns `text` unchanged plus `false`.
--]]
local function strip_circa(text)
local remainder = text:match("^[Cc]%.%s*(.+)$")
if remainder then
return remainder, true
end
return text, false
end
--[[
parse_atomic(text)
--------------------
Private helper. Parses a single date with NO range and NO leading
"c." marker (M.parse handles both of those before calling this),
covering the three "full and partial date" shapes:
2022-05-05 -- full date
2022-05 -- year + month
2257 -- year only
Returns on success:
(table) { anno = ..., mese = ...|nil, giorno = ...|nil }
Returns on failure:
(nil, code) -- code is "bad-format", "bad-month", or "bad-day"
--]]
local function parse_atomic(text)
-- Full date: YYYY-MM-DD
local y, m, d = text:match("^(%d+)-(%d%d)-(%d%d)$")
if y then
y, m, d = tonumber(y), tonumber(m), tonumber(d)
if m < 1 or m > 12 then
return nil, "bad-month"
end
if d < 1 or d > max_day(y, m) then
return nil, "bad-day"
end
return { anno = y, mese = m, giorno = d }
end
-- Year + month: YYYY-MM
y, m = text:match("^(%d+)-(%d%d)$")
if y then
y, m = tonumber(y), tonumber(m)
if m < 1 or m > 12 then
return nil, "bad-month"
end
return { anno = y, mese = m }
end
-- Year only: YYYY
y = text:match("^(%d+)$")
if y then
return { anno = tonumber(y) }
end
return nil, "bad-format"
end
--[[
M.parse(raw)
-------------
Parses one raw date string (the full content of a `data`/`date`
parameter) into normalised fields. See the module header above for
the full return-value convention.
Parameters:
raw (string|nil) -- the raw parameter value, exactly as the editor
typed it (leading/trailing whitespace is fine; it is trimmed here).
Returns:
(table) -- either a success table (`ok = true`, plus date fields) or
a failure table (`ok = false`, `raw`, `code`).
--]]
function M.parse(raw)
if raw ~= nil and type(raw) ~= "string" then
-- Defensive: callers should only ever pass strings (or nil)
-- taken from wikitext parameters, but guard against a stray
-- number or other value rather than letting a pattern-match
-- call below crash outright.
raw = tostring(raw)
end
if raw == nil then
return { ok = false, raw = raw, code = "empty" }
end
local text = trim(raw)
if text == "" then
return { ok = false, raw = raw, code = "empty" }
end
-- Uncertain date: "?" and nothing else.
if text == "?" then
return { ok = true, incerta = true, circa = false }
end
-- Range: en dash preferred, "--" accepted as a typing-friendly
-- alias. Only the FIRST occurrence is treated as the separator;
-- a second one would leave a dash inside one side, which will
-- simply fail to parse as an atomic date and be reported as
-- "bad-range" below.
local sep_start, sep_end = text:find(RANGE_SEP_ENDASH, 1, true)
if not sep_start then
sep_start, sep_end = text:find(RANGE_SEP_DOUBLE_HYPHEN, 1, true)
end
if sep_start then
local left = trim(text:sub(1, sep_start - 1))
local right = trim(text:sub(sep_end + 1))
if left == "" or right == "" then
return { ok = false, raw = raw, code = "bad-range" }
end
-- Recurse: each side is parsed with the exact same rules as
-- a standalone date (including its own optional "c."),
-- which is what lets "c. 2150-2200" work.
local start_result = M.parse(left)
local end_result = M.parse(right)
-- Neither side may be uncertain ("?") or itself a range --
-- both would make "the end of the range" meaningless.
if not start_result.ok or not end_result.ok
or start_result.incerta or end_result.incerta
or start_result.fine or end_result.fine then
return { ok = false, raw = raw, code = "bad-range" }
end
if compare_dates_only(start_result, end_result) > 0 then
return { ok = false, raw = raw, code = "range-order" }
end
start_result.fine = {
anno = end_result.anno,
mese = end_result.mese,
giorno = end_result.giorno,
circa = end_result.circa,
}
return start_result
end
-- Approximate date: strip an optional "c." marker, then parse the
-- remainder as a plain (or partial) date.
local remainder, circa = strip_circa(text)
local parsed, code = parse_atomic(remainder)
if not parsed then
return { ok = false, raw = raw, code = code }
end
parsed.ok = true
parsed.circa = circa
parsed.incerta = false
return parsed
end
return M