--
-- Copyright (c) 2021-2025 Zeping Lee
-- Released under the MIT license.
-- Repository:
https://github.com/zepinglee/citeproc-lua
--
local date_module = {}
local element
local ir_node
local output
local util
local using_luatex, kpse = pcall(require, "kpse")
if using_luatex then
element = require("citeproc-element")
ir_node = require("citeproc-ir-node")
output = require("citeproc-output")
util = require("citeproc-util")
else
element = require("citeproc.element")
ir_node = require("citeproc.ir-node")
output = require("citeproc.output")
util = require("citeproc.util")
end
local Element = element.Element
local Rendered = ir_node.Rendered
local SeqIr = ir_node.SeqIr
local GroupVar = ir_node.GroupVar
local PlainText = output.PlainText
-- [Date](
https://docs.citationstyles.org/en/stable/specification.html#date)
---@class Date: Element
---@field children DatePart[]?
---@field variable string
---@field form string?
---@field date_parts string?
---@field delimiter string?
---@field prefix string?
---@field suffix string?
---@field text_case string?
local Date = Element:derive("date")
function Date:from_node(node)
local o = Date:new()
o:set_attribute(node, "variable")
o:set_attribute(node, "form")
o:set_attribute(node, "date-parts")
if o.form and not o.date_parts then
o.date_parts = "year-month-day"
end
o:get_delimiter_attribute(node)
o:set_formatting_attributes(node)
o:set_affixes_attributes(node)
o:set_display_attribute(node)
o:set_text_case_attribute(node)
o.children = {}
o:process_children_nodes(node)
for _, date_part in ipairs(o.children) do
o[date_part.name] = date_part
end
return o
end
function Date:build_ir(engine, state, context)
local variable
if not state.suppressed[self.variable] then
---@type DateVariable?
variable = context:get_variable(self.variable)
end
if not variable then
local ir = Rendered:new({}, self)
ir.group_var = GroupVar.Missing
return ir
end
local ir
if variable["date-parts"] and #variable["date-parts"] > 0 then
-- TODO: move input normlization in one place
for i = 1, 2 do
if variable["date-parts"][i] then
for j = 1, 3 do
local variabel_part = variable["date-parts"][i][j]
if variabel_part == 0 or variabel_part == "" then
variable["date-parts"][i][j] = nil
else
variable["date-parts"][i][j] = tonumber(variabel_part)
end
end
end
end
if variable["season"] and not variable["date-parts"][1][2] then
variable["date-parts"][1][2] = 20 + tonumber(variable["season"])
end
variable = variable["date-parts"]
if self.form then
ir = self:build_localized_date_ir(variable, engine, state, context)
else
ir = self:build_independent_date_ir(variable, engine, state, context)
end
ir.affixes = self.affixes
elseif variable["literal"] then
local inlines = self:render_text_inlines(variable["literal"], context)
ir = Rendered:new(inlines, self)
ir.group_var = GroupVar.Important
elseif variable["raw"] then
local inlines = self:render_text_inlines(variable["raw"], context)
ir = Rendered:new(inlines, self)
ir.group_var = GroupVar.Important
end
if not ir then
-- date_LiteralFailGracefullyIfNoValue.txt
ir = Rendered:new({}, self)
if context.sort_key then
ir.sort_key = false
end
ir.group_var = GroupVar.Missing
return ir
end
if ir.group_var == GroupVar.Important then
-- Suppress substituted name variable
if state.name_override and not context.sort_key then
state.suppressed[self.variable] = true
end
end
if context.sort_key then
ir.sort_key = self:render_sort_key(engine, state, context)
end
return ir
end
function Date:build_independent_date_ir(variable, engine, state, context)
-- else
-- local literal = variable["literal"]
-- if literal then
-- res = literal
-- else
-- local raw = variable["raw"]
-- if raw then
-- res = raw
-- end
-- end
return self:build_date_parts(self.children, variable, self.delimiter, engine, state, context)
end
function Date:build_localized_date_ir(variable, engine, state, context)
local date_part_mask = {}
for _, part in ipairs(util.split(self.date_parts or "year-month-day", "%-")) do
date_part_mask[part] = true
end
-- local date_parts = {}
-- for _, date_part in ipairs(self.children) do
-- date_parts[date_part.name] = date_part
-- end
local localized_date = context:get_localized_date(self.form)
local date_parts = {}
for _, date_part in ipairs(localized_date.children) do
if date_part_mask[date_part.name] then
date_part = date_part:copy()
local local_date_part = self[date_part.name]
if local_date_part then
local_date_part:override(date_part)
end
table.insert(date_parts, date_part)
end
end
return self:build_date_parts(date_parts, variable, localized_date.delimiter, engine, state, context)
end
function Date:build_date_parts(date_parts, variable, delimiter, engine, state, context)
if #variable >= 2 then
return self:build_date_range(date_parts, variable, delimiter, engine, state, context)
elseif #variable == 1 then
return self:build_single_date(date_parts, variable[1], delimiter, engine, state, context)
end
end
function Date:build_single_date(date_parts, single_date, delimiter, engine, state, context)
local irs = {}
for _, date_part in ipairs(date_parts) do
local part_ir = date_part:build_ir(single_date, engine, state, context)
table.insert(irs, part_ir)
end
local ir = SeqIr:new(irs, self)
ir.delimiter = self.delimiter
-- return Rendered:new(inlines, self)
return ir
end
local date_part_index = {
year = 1,
month = 2,
day = 3,
}
function Date:build_date_range(date_parts, variable, delimiter, engine, state, context)
local first, second = variable[1], variable[2]
local diff_level = 4
for _, date_part in ipairs(date_parts) do
local part_index = date_part_index[date_part.name]
if first[part_index] and first[part_index] ~= second[part_index] then
if part_index < diff_level then
diff_level = part_index
end
end
end
local irs = {}
local range_part_queue = {}
local range_delimiter
for i, date_part in ipairs(date_parts) do
local part_index = date_part_index[date_part.name]
if part_index == diff_level then
range_delimiter = date_part.range_delimiter or util.unicode["en dash"]
end
if first[part_index] then
if part_index >= diff_level then
table.insert(range_part_queue, date_part)
else
if #range_part_queue > 0 then
table.insert(irs, self:build_date_range_parts(range_part_queue, variable,
delimiter, engine, state, context, range_delimiter))
range_part_queue = {}
end
table.insert(irs, date_part:build_ir(first, engine, state, context))
end
end
end
if #range_part_queue > 0 then
table.insert(irs, self:build_date_range_parts(range_part_queue, variable,
delimiter, engine, state, context, range_delimiter))
end
local ir = SeqIr:new(irs, self)
ir.delimiter = delimiter
return ir
end
function Date:build_date_range_parts(range_part_queue, variable, delimiter, engine, state, context, range_delimiter)
local irs = {}
local first, second = variable[1], variable[2]
local date_part_irs = {}
for i, diff_part in ipairs(range_part_queue) do
-- if delimiter and i > 1 then
-- table.insert(date_part_irs, PlainText:new(delimiter))
-- end
if i == #range_part_queue then
table.insert(date_part_irs, diff_part:build_ir(first, engine, state, context, "suffix"))
else
table.insert(date_part_irs, diff_part:build_ir(first, engine, state, context))
end
end
local range_part_ir = SeqIr:new(date_part_irs, self)
range_part_ir.delimiter = delimiter
table.insert(irs, range_part_ir)
table.insert(irs, Rendered:new({PlainText:new(range_delimiter)}, self))
date_part_irs = {}
for i, diff_part in ipairs(range_part_queue) do
if i == 1 then
table.insert(date_part_irs, diff_part:build_ir(second, engine, state, context, "prefix"))
else
table.insert(date_part_irs, diff_part:build_ir(second, engine, state, context))
end
end
range_part_ir = SeqIr:new(date_part_irs, self)
range_part_ir.delimiter = delimiter
table.insert(irs, range_part_ir)
local ir = SeqIr:new(irs, self)
return ir
end
function Date:render_sort_key(engine, state, context)
local date = context:get_variable(self.variable)
if not date then
return false
end
if not date["date-parts"] then
if date.literal then
return "1" .. date.literal
else
return false
end
end
local show_parts = {
year = false,
month = false,
day = false,
}
if self.form then
for _, dp_name in ipairs(util.split(self.date_parts, "%-")) do
show_parts[dp_name] = true
end
else
for _, date_part in ipairs(self.children) do
show_parts[date_part.name] = true
end
end
local res = ""
for _, range_part in ipairs(date["date-parts"]) do
if res ~= "" then
res = res .. "/"
end
for i, dp_name in ipairs({"year", "month", "day"}) do
local value = 0
if show_parts[dp_name] and range_part[i] then
value = range_part[i]
end
if i == 1 then
res = res .. string.format("%05d", value + 10000)
else
res = res .. "-" .. string.format("%02d", value)
end
end
end
return res
end
-- [Date-part](
https://docs.citationstyles.org/en/stable/specification.html#date-part)
---@class DatePart: Element
---@field name string
---@field form string?
---@field text_case string?
---@field range_delimiter string?
local DatePart = Element:derive("date-part")
function DatePart:from_node(node)
local o = DatePart:new()
o:set_attribute(node, "name")
o:set_attribute(node, "form")
if o.name == "month" then
o:set_strip_periods_attribute(node)
end
o:set_formatting_attributes(node)
o:set_text_case_attribute(node)
o:set_attribute(node, "range-delimiter")
o:set_affixes_attributes(node)
return o
end
function DatePart:build_ir(single_date, engine, state, context, suppressed_affix)
local text
if self.name == "year" then
text = self:render_year(single_date[1], engine, state, context)
elseif self.name == "month" then
text = self:render_month(single_date[2], engine, state, context)
elseif self.name == "day" then
text = self:render_day(single_date[3], single_date[2], engine, state, context)
end
if not text then
local ir = Rendered:new({}, self)
ir.group_var = GroupVar.Missing
return ir
end
local inlines = {PlainText:new(text)}
local output_format = context.format
-- if not context.is_english then
-- print(debug.traceback())
-- end
local is_english = context:is_english()
output_format:apply_text_case(inlines, self.text_case, is_english)
inlines = output_format:with_format(inlines, self.formatting)
local ir = Rendered:new(inlines, self)
ir.group_var = GroupVar.Important
if self.name == "year" then
ir = SeqIr:new({ir}, self)
ir.is_year = true
end
ir.affixes = util.clone(self.affixes)
if ir.affixes and suppressed_affix then
ir.affixes[suppressed_affix] = nil
end
return ir
end
function DatePart:render_day(day, month, engine, state, context)
if not day or day == "" then
return nil
end
day = tonumber(day)
if day < 1 or day > 31 then
return nil
end
local form = self.form or "numeric"
if form == "ordinal" then
local limit_day_1 = context.locale.style_options.limit_day_ordinals_to_day_1
if limit_day_1 and day > 1 then
form = "numeric"
end
end
if form == "numeric-leading-zeros" then
return string.format("%02d", day)
elseif form == "ordinal" then
-- When the “day” date-part is rendered in the “ordinal” form, the ordinal
-- gender is matched against that of the month term.
local gender = context.locale:get_number_gender(string.format("month-%02d", month))
local suffix = context.locale:get_ordinal_term(day, gender)
return tostring(day) .. suffix
else -- numeric
return tostring(day)
end
end
function DatePart:render_month(month, engine, state, context)
if not month or month == "" then
return nil
end
month = tonumber(month)
if not month or month < 1 or month > 24 then
return nil
end
local form = self.form or "long"
local res
if form == "long" or form == "short" then
if month >= 1 and month <= 12 then
res = context:get_simple_term(string.format("month-%02d", month), form)
else
local season = month % 4
if season == 0 then
season = 4
end
res = context:get_simple_term(string.format("season-%02d", season))
end
elseif form == "numeric-leading-zeros" then
res = string.format("%02d", month)
else
res = tostring(month)
end
return self:apply_strip_periods(res)
end
function DatePart:render_year(year, engine, state, context)
if not year or year == "" then
return nil
end
year = tonumber(year)
if year == 0 then
return nil
end
local form = self.form or "long"
if form == "long" then
if year < 0 then
return tostring(-year) .. context:get_simple_term("bc")
elseif year < 1000 then
return tostring(year) .. context:get_simple_term("ad")
else
return tostring(year)
end
elseif form == "short" then
return string.sub(tostring(year), -2)
end
end
function DatePart:copy()
local o = {}
for key, value in pairs(self) do
if type(value) == "table" then
o[key] = {}
for k, v in pairs(value) do
o[key][k] = v
end
else
o[key] = value
end
end
setmetatable(o, DatePart)
return o
end
function DatePart:override(localized_date_part)
for key, value in pairs(self) do
if type(value) == "table" and localized_date_part[key] then
for k, v in pairs(value) do
localized_date_part[key][k] = v
end
else
localized_date_part[key] = value
end
end
end
date_module.Date = Date
date_module.DatePart = DatePart
return date_module