Modulo:ChronoSpine/date

Da Wikitrek.
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