Difference between revisions of "Module:Age/sandbox"

From blackwiki
Jump to navigation Jump to search
blackwiki>Johnuniq
(for {{time interval}}: replace abbr=letter with abbr=on per talk; for {{age in days}}: show_unit=abbr/full; format=commas; templates can invoke age_generic so remove redundant age_xxx functions)
m (52 revisions imported)
 
(42 intermediate revisions by 4 users not shown)
Line 1: Line 1:
 
-- Implement various "age of" and other date-related templates.
 
-- Implement various "age of" and other date-related templates.
--[[ TODO
 
Check the "This implements" functions to see what parameters are accepted by the
 
existing template. The code here might not be emulating what the template does.
 
Some functions have not been tested yet.
 
These features have not yet been implemented:
 
* Some templates accept an ambiguous date with a missing component.
 
* Some templates provide a hidden sort key.
 
* Some templates have extra parameters not yet implemented.
 
* {{age in years}}  Age as a range (month/day unknown).
 
* {{age in months}}
 
* {{age in weeks and days}}
 
* {{age in weeks}}
 
]]
 
  
local _Date, _current_date
+
local mtext = {
local function get_exports(frame)
+
-- Message and other text that should be localized.
 +
['mt-bad-param1'] =            'Invalid parameter $1',
 +
['mt-bad-param2'] =            'Parameter $1=$2 is invalid',
 +
['mt-bad-show'] =              'Parameter show=$1 is not supported here',
 +
['mt-cannot-add'] =            'Cannot add "$1"',
 +
['mt-conflicting-show'] =      'Parameter show=$1 conflicts with round=$2',
 +
['mt-date-wrong-order'] =      'The second date must be later in time than the first date',
 +
['mt-dd-future'] =              'Death date (first date) must not be in the future',
 +
['mt-dd-wrong-order'] =        'Death date (first date) must be later in time than the birth date (second date)',
 +
['mt-invalid-bd-age'] =        'Invalid birth date for calculating age',
 +
['mt-invalid-dates-age'] =      'Invalid dates for calculating age',
 +
['mt-invalid-end'] =            'Invalid end date in second parameter',
 +
['mt-invalid-start'] =          'Invalid start date in first parameter',
 +
['mt-need-jdn'] =              'Need valid Julian date number',
 +
['mt-need-valid-bd'] =          'Need valid birth date: year, month, day',
 +
['mt-need-valid-bd2'] =        'Need valid birth date (second date): year, month, day',
 +
['mt-need-valid-date'] =        'Need valid date',
 +
['mt-need-valid-dd'] =          'Need valid death date (first date): year, month, day',
 +
['mt-need-valid-ymd'] =        'Need valid year, month, day',
 +
['mt-need-valid-ymd-current'] = 'Need valid year|month|day or "currentdate"',
 +
['mt-need-valid-ymd2'] =        'Second date should be year, month, day',
 +
['mt-template-bad-name'] =      'The specified template name is not valid',
 +
['mt-template-x'] =            'The template invoking this must have "|template=x" where x is the wanted operation',
 +
['txt-age'] =                  '(age ',
 +
['txt-aged'] =                  ' (aged ',
 +
['txt-and'] =                  ' and ',
 +
['txt-comma-and'] =            ', and ',
 +
['txt-error'] =                'Error: ',
 +
['txt-or'] =                    ' or ',
 +
}
 +
 
 +
local translate, from_en, to_en, isZero
 +
if translate then
 +
-- Functions to translate from en to local language and reverse go here.
 +
-- See example at [[:bn:Module:বয়স]].
 +
else
 +
from_en = function (text)
 +
return text
 +
end
 +
isZero = function (text)
 +
return tonumber(text) == 0
 +
end
 +
end
 +
 
 +
local _Date, _currentDate
 +
local function getExports(frame)
 
-- Return objects exported from the date module or its sandbox.
 
-- Return objects exported from the date module or its sandbox.
 
if not _Date then
 
if not _Date then
 
local sandbox = frame:getTitle():find('sandbox', 1, true) and '/sandbox' or ''
 
local sandbox = frame:getTitle():find('sandbox', 1, true) and '/sandbox' or ''
 
local datemod = require('Module:Date' .. sandbox)
 
local datemod = require('Module:Date' .. sandbox)
_Date = datemod._Date
+
local realDate = datemod._Date
_current_date = datemod._current
+
_currentDate = datemod._current
 +
if to_en then
 +
_Date = function (...)
 +
local args = {}
 +
for i, v in ipairs({...}) do
 +
args[i] = to_en(v)
 +
end
 +
return realDate(unpack(args))
 +
end
 +
else
 +
_Date = realDate
 +
end
 
end
 
end
return _Date, _current_date
+
return _Date, _currentDate
 
end
 
end
  
local function collection()
+
local Collection  -- a table to hold items
-- Return a table to hold items.
+
Collection = {
return {
+
add = function (self, item)
n = 0,
+
if item ~= nil then
add = function (self, item)
 
 
self.n = self.n + 1
 
self.n = self.n + 1
 
self[self.n] = item
 
self[self.n] = item
end,
+
end
join = function (self, sep)
+
end,
return table.concat(self, sep)
+
join = function (self, sep)
end,
+
return table.concat(self, sep)
}
+
end,
end
+
remove = function (self, pos)
 +
if self.n > 0 and (pos == nil or (0 < pos and pos <= self.n)) then
 +
self.n = self.n - 1
 +
return table.remove(self, pos)
 +
end
 +
end,
 +
sort = function (self, comp)
 +
table.sort(self, comp)
 +
end,
 +
new = function ()
 +
return setmetatable({n = 0}, Collection)
 +
end
 +
}
 +
Collection.__index = Collection
  
local function strip_to_nil(text)
+
local function stripToNil(text)
 
-- If text is a string, return its trimmed content, or nil if empty.
 
-- If text is a string, return its trimmed content, or nil if empty.
 
-- Otherwise return text (which may, for example, be nil).
 
-- Otherwise return text (which may, for example, be nil).
Line 56: Line 111:
 
end
 
end
  
local function message(msg, nocat)
+
local function message(msg, id)
-- Return formatted message text for an error.
+
-- Return formatted message text for an error or warning.
-- Can append "#FormattingError" to URL of a page with a problem to find it.
+
local function getText(msg)
local anchor = '<span id="FormattingError" />'
+
return mtext[msg] or error('Bug: message "' .. tostring(msg) .. '" not defined')
local category
+
end
if not nocat and mw.title.getCurrentTitle():inNamespaces(0, 10) then
+
local text
-- Category only in namespaces: 0=article, 10=template.
+
if type(msg) == 'table' then
category = '[[Category:Age error]]'
+
text = getText(msg[1])
 +
local rep = {}
 +
for i, v in ipairs(msg) do
 +
if i > 1 then
 +
rep['$' .. (i - 1)] = v
 +
end
 +
end
 +
text = text:gsub('$%d+', rep)
 +
else
 +
text = getText(msg)
 +
end
 +
local categories = {
 +
error = '[[Category:Age error]]',
 +
warning = '[[Category:Age error]]',  -- same as error until determine whether 'Age warning' would be worthwhile
 +
}
 +
local a, b, category
 +
if id == 'warning' then
 +
a = '<sup>[<i>'
 +
b = '</i>]</sup>'
 
else
 
else
category = ''
+
a = '<strong class="error">' .. getText('txt-error')
 +
b = '</strong>'
 +
end
 +
if mw.title.getCurrentTitle():inNamespaces(0) then
 +
-- Category only in namespaces: 0=article.
 +
category = categories[id or 'error']
 
end
 
end
return anchor ..
+
return
'<strong class="error">Error: ' ..
+
a ..
mw.text.nowiki(msg) ..
+
mw.text.nowiki(text) ..
'</strong>' ..
+
b ..
category
+
(category or '')
 
end
 
end
  
local function formatnumber(numstr)
+
local function formatNumber(number)
 
-- Return the given number formatted with commas as group separators,
 
-- Return the given number formatted with commas as group separators,
-- given that numstr consists of en digits only (no decimal mark).
+
-- given that the number is an integer.
 +
local numstr = tostring(number)
 
local length = #numstr
 
local length = #numstr
local places = collection()
+
local places = Collection.new()
 
local pos = 0
 
local pos = 0
 
repeat
 
repeat
Line 85: Line 164:
 
until pos >= length
 
until pos >= length
 
places:add(length)
 
places:add(length)
local groups = collection()
+
local groups = Collection.new()
 
for i = places.n, 2, -1 do
 
for i = places.n, 2, -1 do
 
local p1 = length - places[i] + 1
 
local p1 = length - places[i] + 1
Line 94: Line 173:
 
end
 
end
  
local function make_text(values, components, names, options)
+
local function spellNumber(number, options, i)
-- Return wikitext representing an age or duration.
+
-- Return result of spelling number, or
local text = collection()
+
-- return number (as a string) if cannot spell it.
local count = #values
+
-- i == 1 for the first number which can optionally start with an uppercase letter.
local plural = names.plural or ''
+
number = tostring(number)
local sep = names.sep or ''
+
return require('Module:ConvertNumeric').spell_number(
for i, v in ipairs(values) do
+
number,
if v > 0 or (text.n == 0 and i == count) or (text.n > 0 and components.keepzero) then
+
nil,                      -- fraction numerator
local vstr = tostring(v)
+
nil,                      -- fraction denominator
if i == 1 and options.format == 'format_commas' then
+
i == 1 and options.upper,  -- true: 'One' instead of 'one'
-- Numbers after the first should be small and not need formatting.
+
not options.us,            -- true: use 'and' between tens/ones etc
vstr = formatnumber(vstr)
+
options.adj,              -- true: hyphenated
end
+
options.ordinal            -- true: 'first' instead of 'one'
local name = names[components[i]]
+
) or number
if name then
+
end
text:add(vstr .. sep .. name .. (v == 1 and '' or plural))
+
 
else
+
local function makeExtra(args, flagCurrent)
text:add(vstr)
+
-- Return extra text that will be inserted before the visible result
end
+
-- but after any sort key.
 +
local extra = args.prefix or ''
 +
if mw.ustring.len(extra) > 1 then
 +
-- Parameter "~" gives "~3" whereas "over" gives "over 3".
 +
if extra:sub(-6, -1) ~= '&nbsp;' then
 +
extra = extra .. ' '
 
end
 
end
 
end
 
end
local first, last
+
if flagCurrent then
if options.join == 'sep_space' then
+
extra = '<span class="currentage"></span>' .. extra
first = ' '
 
last = ' '
 
elseif options.join == 'sep_comma' then
 
first = ', '
 
last = ', '
 
elseif options.join == 'sep_serialcomma' and text.n > 2 then
 
first = ', '
 
last = ', and '
 
else
 
first = ', '
 
last = ' and '
 
 
end
 
end
for i, v in ipairs(text) do
+
return extra
if i < text.n then
+
end
text[i] = v .. (i + 1 < text.n and first or last)
+
 
end
+
local function makeSort(value, sortable)
end
+
-- Return a sort key if requested.
local sign = ''
+
-- Assume value is a valid number which has not overflowed.
if options.isnegative then
+
if sortable == 'sortable_table' or sortable == 'sortable_on' or sortable == 'sortable_debug' then
-- Do not display negative zero.
+
local sortKey
if text.n > 1 or (text.n == 1 and text[1]:sub(1, 1) ~= '0' ) then
+
if value == 0 then
if options.format == 'format_raw' then
+
sortKey = '5000000000000000000'
sign = '-'  -- plain hyphen so result can be used in a calculation
+
else
 +
local mag = math.floor(math.log10(math.abs(value)) + 1e-14)
 +
if value > 0 then
 +
sortKey = 7000 + mag
 
else
 
else
sign = '−'  -- Unicode U+2212 MINUS SIGN
+
sortKey = 2999 - mag
 +
value = value + 10^(mag+1)
 
end
 
end
 +
sortKey = string.format('%d', sortKey) .. string.format('%015.0f', math.floor(value * 10^(14-mag)))
 +
end
 +
local result
 +
if sortable == 'sortable_table' then
 +
result = 'data-sort-value="_SORTKEY_"|'
 +
elseif sortable == 'sortable_debug' then
 +
result = '<span data-sort-value="_SORTKEY_♠"><span style="border:1px solid">_SORTKEY_♠</span></span>'
 +
else
 +
result = '<span data-sort-value="_SORTKEY_♠"></span>'
 
end
 
end
 +
return result:gsub('_SORTKEY_', sortKey)
 
end
 
end
return sign .. text:join() .. (options.suffix or '')
 
 
end
 
end
  
local translate_parameters = {
+
local translateParameters = {
 
abbr = {
 
abbr = {
 
off = 'abbr_off',
 
off = 'abbr_off',
Line 176: Line 262:
 
},
 
},
 
show = {
 
show = {
 +
hide = { id = 'hide' },
 
y = { 'y', id = 'y' },
 
y = { 'y', id = 'y' },
 
ym = { 'y', 'm', id = 'ym' },
 
ym = { 'y', 'm', id = 'ym' },
Line 181: Line 268:
 
ymw = { 'y', 'm', 'w', id = 'ymw' },
 
ymw = { 'y', 'm', 'w', id = 'ymw' },
 
ymwd = { 'y', 'm', 'w', 'd', id = 'ymwd' },
 
ymwd = { 'y', 'm', 'w', 'd', id = 'ymwd' },
yd = { 'y', 'd', id = 'yd', keepzero = true },
+
yd = { 'y', 'd', id = 'yd', keepZero = true },
 
m = { 'm', id = 'm' },
 
m = { 'm', id = 'm' },
 
md = { 'm', 'd', id = 'md' },
 
md = { 'm', 'd', id = 'md' },
 
w = { 'w', id = 'w' },
 
w = { 'w', id = 'w' },
 
wd = { 'w', 'd', id = 'wd' },
 
wd = { 'w', 'd', id = 'wd' },
 +
h = { 'H', id = 'h' },
 +
hm = { 'H', 'M', id = 'hm' },
 +
hms = { 'H', 'M', 'S', id = 'hms' },
 +
M = { 'M', id = 'M' },
 +
s = { 'S', id = 's' },
 
d = { 'd', id = 'd' },
 
d = { 'd', id = 'd' },
 
dh = { 'd', 'H', id = 'dh' },
 
dh = { 'd', 'H', id = 'dh' },
 
dhm = { 'd', 'H', 'M', id = 'dhm' },
 
dhm = { 'd', 'H', 'M', id = 'dhm' },
 +
dhms = { 'd', 'H', 'M', 'S', id = 'dhms' },
 
ymdh = { 'y', 'm', 'd', 'H', id = 'ymdh' },
 
ymdh = { 'y', 'm', 'd', 'H', id = 'ymdh' },
 
ymdhm = { 'y', 'm', 'd', 'H', 'M', id = 'ymdhm' },
 
ymdhm = { 'y', 'm', 'd', 'H', 'M', id = 'ymdhm' },
 
ymwdh = { 'y', 'm', 'w', 'd', 'H', id = 'ymwdh' },
 
ymwdh = { 'y', 'm', 'w', 'd', 'H', id = 'ymwdh' },
 
ymwdhm = { 'y', 'm', 'w', 'd', 'H', 'M', id = 'ymwdhm' },
 
ymwdhm = { 'y', 'm', 'w', 'd', 'H', 'M', id = 'ymwdhm' },
 +
},
 +
sortable = {
 +
off = false,
 +
on = 'sortable_on',
 +
table = 'sortable_table',
 +
debug = 'sortable_debug',
 
},
 
},
 
}
 
}
  
local function date_difference(parms)
+
local spellOptions = {
 +
cardinal = {},
 +
Cardinal = { upper = true },
 +
cardinal_us = { us = true },
 +
Cardinal_us = { us = true, upper = true },
 +
ordinal = { ordinal = true },
 +
Ordinal = { ordinal = true, upper = true },
 +
ordinal_us = { ordinal = true, us = true },
 +
Ordinal_us = { ordinal = true, us = true, upper = true },
 +
}
 +
 
 +
local function dateExtract(frame)
 +
-- Return part of a date after performing an optional operation.
 +
local Date = getExports(frame)
 +
local args = frame:getParent().args
 +
local parms = {}
 +
for i, v in ipairs(args) do
 +
parms[i] = v
 +
end
 +
if yes(args.fix) then
 +
table.insert(parms, 'fix')
 +
end
 +
if yes(args.partial) then
 +
table.insert(parms, 'partial')
 +
end
 +
local show = stripToNil(args.show) or 'dmy'
 +
local date = Date(unpack(parms))
 +
if not date then
 +
if show == 'format' then
 +
return 'error'
 +
end
 +
return message('mt-need-valid-date')
 +
end
 +
local add = stripToNil(args.add)
 +
if add then
 +
for item in add:gmatch('%S+') do
 +
date = date + item
 +
if not date then
 +
return message({ 'mt-cannot-add', item })
 +
end
 +
end
 +
end
 +
local sortKey, result
 +
local sortable = translateParameters.sortable[args.sortable]
 +
if sortable then
 +
local value = (date.partial and date.partial.first or date).jdz
 +
sortKey = makeSort(value, sortable)
 +
end
 +
if show ~= 'hide' then
 +
result = date[show]
 +
if result == nil then
 +
result = from_en(date:text(show))
 +
elseif type(result) == 'boolean' then
 +
result = result and '1' or '0'
 +
else
 +
result = from_en(tostring(result))
 +
end
 +
end
 +
return (sortKey or '') .. makeExtra(args) .. (result or '')
 +
end
 +
 
 +
local function rangeJoin(range)
 +
-- Return text to be used between a range of ages.
 +
return range == 'dash' and '–' or mtext['txt-or']
 +
end
 +
 
 +
local function makeText(values, components, names, options, noUpper)
 +
-- Return wikitext representing an age or duration.
 +
local text = Collection.new()
 +
local count = #values
 +
local sep = names.sep or ''
 +
for i, v in ipairs(values) do
 +
-- v is a number (say 4 for 4 years), or a table ({4,5} for 4 or 5 years).
 +
local islist = type(v) == 'table'
 +
if (islist or v > 0) or (text.n == 0 and i == count) or (text.n > 0 and components.keepZero) then
 +
local fmt, vstr
 +
if options.spell then
 +
fmt = function(number)
 +
return spellNumber(number, options.spell, noUpper or i)
 +
end
 +
elseif i == 1 and options.format == 'format_commas' then
 +
-- Numbers after the first should be small and not need formatting.
 +
fmt = formatNumber
 +
else
 +
fmt = tostring
 +
end
 +
if islist then
 +
vstr = fmt(v[1]) .. rangeJoin(options.range)
 +
noUpper = true
 +
vstr = vstr .. fmt(v[2])
 +
else
 +
vstr = fmt(v)
 +
end
 +
local name = names[components[i]]
 +
if name then
 +
local plural = names.plural
 +
if not plural or (islist and v[2] or v) == 1 then
 +
plural = ''
 +
end
 +
text:add(vstr .. sep .. name .. plural)
 +
else
 +
text:add(vstr)
 +
end
 +
end
 +
end
 +
local first, last
 +
if options.join == 'sep_space' then
 +
first = ' '
 +
last = ' '
 +
elseif options.join == 'sep_comma' then
 +
first = ', '
 +
last = ', '
 +
elseif options.join == 'sep_serialcomma' and text.n > 2 then
 +
first = ', '
 +
last = mtext['txt-comma-and']
 +
else
 +
first = ', '
 +
last = mtext['txt-and']
 +
end
 +
for i, v in ipairs(text) do
 +
if i < text.n then
 +
text[i] = v .. (i + 1 < text.n and first or last)
 +
end
 +
end
 +
local sign = ''
 +
if options.isnegative then
 +
-- Do not display negative zero.
 +
if text.n > 1 or (text.n == 1 and text[1]:sub(1, 1) ~= '0' ) then
 +
if options.format == 'format_raw' then
 +
sign = '-'  -- plain hyphen so result can be used in a calculation
 +
else
 +
sign = '−'  -- Unicode U+2212 MINUS SIGN
 +
end
 +
end
 +
end
 +
return
 +
(options.sortKey or '') ..
 +
(options.extra or '') ..
 +
sign ..
 +
text:join() ..
 +
(options.suffix or '')
 +
end
 +
 
 +
local function dateDifference(parms)
 
-- Return a formatted date difference using the given parameters
 
-- Return a formatted date difference using the given parameters
 
-- which have been validated.
 
-- which have been validated.
-- TODO Handle 'partial' dates, for example, a year only.
 
 
local names = {
 
local names = {
 
abbr_off = {
 
abbr_off = {
Line 210: Line 451:
 
H = 'hour',
 
H = 'hour',
 
M = 'minute',
 
M = 'minute',
 +
S = 'second',
 
},
 
},
 
abbr_on = {
 
abbr_on = {
Line 218: Line 460:
 
H = 'h',
 
H = 'h',
 
M = 'm',
 
M = 'm',
 +
S = 's',
 
},
 
},
 
abbr_infant = {      -- for {{age for infant}}
 
abbr_infant = {      -- for {{age for infant}}
Line 228: Line 471:
 
H = 'hr',
 
H = 'hr',
 
M = 'min',
 
M = 'min',
 +
S = 'sec',
 
},
 
},
 
abbr_raw = {},
 
abbr_raw = {},
Line 234: Line 478:
 
local show = parms.show  -- may be nil; default is set below
 
local show = parms.show  -- may be nil; default is set below
 
local abbr = parms.abbr or 'abbr_off'
 
local abbr = parms.abbr or 'abbr_off'
local suffix, default_join
+
local defaultJoin
 
if abbr ~= 'abbr_off' then
 
if abbr ~= 'abbr_off' then
default_join = 'sep_space'
+
defaultJoin = 'sep_space'
 
end
 
end
 
if not show then
 
if not show then
Line 242: Line 486:
 
if parms.disp == 'disp_age' then
 
if parms.disp == 'disp_age' then
 
if diff.years < 3 then
 
if diff.years < 3 then
default_join = 'sep_space'
+
defaultJoin = 'sep_space'
 
if diff.years >= 1 then
 
if diff.years >= 1 then
 
show = 'ym'
 
show = 'ym'
Line 254: Line 498:
 
end
 
end
 
if type(show) ~= 'table' then
 
if type(show) ~= 'table' then
show = translate_parameters.show[show]
+
show = translateParameters.show[show]
 
end
 
end
 
if parms.disp == 'disp_raw' then
 
if parms.disp == 'disp_raw' then
default_join = 'sep_space'
+
defaultJoin = 'sep_space'
 
abbr = 'abbr_raw'
 
abbr = 'abbr_raw'
elseif parms.want_sc then
+
elseif parms.wantSc then
default_join = 'sep_serialcomma'
+
defaultJoin = 'sep_serialcomma'
 
end
 
end
local options = {
+
local diffOptions = {
suffix = suffix,  -- not currently used
+
round = parms.round,
 +
duration = parms.wantDuration,
 +
range = parms.range and true or nil,
 +
}
 +
local sortKey
 +
if parms.sortable then
 +
local value = diff.age_days + (parms.wantDuration and 1 or 0) -- days and fraction of a day
 +
if diff.isnegative then
 +
value = -value
 +
end
 +
sortKey = makeSort(value, parms.sortable)
 +
end
 +
local textOptions = {
 +
extra = parms.extra,
 
format = parms.format,
 
format = parms.format,
join = parms.sep or default_join,
+
join = parms.sep or defaultJoin,
 
isnegative = diff.isnegative,
 
isnegative = diff.isnegative,
 +
range = parms.range,
 +
sortKey = sortKey,
 +
spell = parms.spell,
 +
suffix = parms.suffix,  -- not currently used
 
}
 
}
local values = { diff:age(show.id, parms.round, parms.want_duration) }
+
if show.id == 'hide' then
return make_text(values, show, names[abbr], options)
+
return sortKey or ''
 +
end
 +
local values = { diff:age(show.id, diffOptions) }
 +
if values[1] then
 +
return makeText(values, show, names[abbr], textOptions)
 +
end
 +
if diff.partial then
 +
-- Handle a more complex range such as
 +
-- {{age_yd|20 Dec 2001|2003|range=yes}} → 1 year, 12 days or 2 years, 11 days
 +
local opt = {
 +
format = textOptions.format,
 +
join = textOptions.join,
 +
isnegative = textOptions.isnegative,
 +
spell = textOptions.spell,
 +
}
 +
return
 +
(textOptions.sortKey or '') ..
 +
makeText({ diff.partial.mindiff:age(show.id, diffOptions) }, show, names[abbr], opt) ..
 +
rangeJoin(textOptions.range) ..
 +
makeText({ diff.partial.maxdiff:age(show.id, diffOptions) }, show, names[abbr], opt, true) ..
 +
(textOptions.suffix or '')
 +
end
 +
return message({ 'mt-bad-show', show.id })
 
end
 
end
  
local function get_dates(frame, getopt)
+
local function getDates(frame, getopt)
 
-- Parse template parameters and return one of:
 
-- Parse template parameters and return one of:
 
-- * date        (a date table, if single)
 
-- * date        (a date table, if single)
 
-- * date1, date2 (two date tables, if not single)
 
-- * date1, date2 (two date tables, if not single)
 
-- * text        (a string error message)
 
-- * text        (a string error message)
-- A missing date is replaced with the current date.
+
-- A missing date is optionally replaced with the current date.
-- If want_mixture is true, a missing date component is replaced
+
-- If wantMixture is true, a missing date component is replaced
 
-- from the current date, so can get a bizarre mixture of
 
-- from the current date, so can get a bizarre mixture of
 
-- specified/current y/m/d as has been done by some "age" templates.
 
-- specified/current y/m/d as has been done by some "age" templates.
 
-- Some results may be placed in table getopt.
 
-- Some results may be placed in table getopt.
local Date, current_date = get_exports(frame)
+
local Date, currentDate = getExports(frame)
 +
getopt = getopt or {}
 +
local function flagCurrent(text)
 +
-- This allows the calling template to detect if the current date has been used,
 +
-- that is, whether both dates have been entered in a template expecting two.
 +
-- For example, an infobox may want the age when an event occurred, not the current age.
 +
-- Don't bother detecting if wantMixture is used because not needed and it is a poor option.
 +
if not text then
 +
if getopt.noMissing then
 +
return nil  -- this gives a nil date which gives an error
 +
end
 +
text = 'currentdate'
 +
if getopt.flag == 'usesCurrent' then
 +
getopt.usesCurrent = true
 +
end
 +
end
 +
return text
 +
end
 
local args = frame:getParent().args
 
local args = frame:getParent().args
 
local fields = {}
 
local fields = {}
local is_named = args.year or args.year1 or args.year2
+
local isNamed = args.year or args.year1 or args.year2 or
if is_named then
+
args.month or args.month1 or args.month2 or
 +
args.day or args.day1 or args.day2
 +
if isNamed then
 
fields[1] = args.year1 or args.year
 
fields[1] = args.year1 or args.year
 
fields[2] = args.month1 or args.month
 
fields[2] = args.month1 or args.month
Line 300: Line 602:
 
local imax = 0
 
local imax = 0
 
for i = 1, 6 do
 
for i = 1, 6 do
fields[i] = strip_to_nil(fields[i])
+
fields[i] = stripToNil(fields[i])
 
if fields[i] then
 
if fields[i] then
 
imax = i
 
imax = i
 +
end
 +
if getopt.omitZero and i % 3 ~= 1 then  -- omit zero months and days as unknown values but keep year 0 which is 1 BCE
 +
if isZero(fields[i]) then
 +
fields[i] = nil
 +
getopt.partial = true
 +
end
 
end
 
end
 
end
 
end
getopt = getopt or {}
+
local fix = getopt.fix and 'fix' or ''
local single = getopt.single
+
local partialText = getopt.partial and 'partial' or ''
 
local dates = {}
 
local dates = {}
if is_named or imax > 2 then
+
if isNamed or imax >= 3 then
local nr_dates = single and 1 or 2
+
local nrDates = getopt.single and 1 or 2
if getopt.want_mixture then
+
if getopt.wantMixture then
 +
-- Cannot be partial since empty fields are set from current.
 
local components = { 'year', 'month', 'day' }
 
local components = { 'year', 'month', 'day' }
for i = 1, nr_dates * 3 do
+
for i = 1, nrDates * 3 do
fields[i] = fields[i] or current_date[components[i > 3 and i - 3 or i]]
+
fields[i] = fields[i] or currentDate[components[i > 3 and i - 3 or i]]
 
end
 
end
for i = 1, nr_dates do
+
for i = 1, nrDates do
 
local index = i == 1 and 1 or 4
 
local index = i == 1 and 1 or 4
dates[i] = Date(fields[index], fields[index+1], fields[index+2])
+
local y, m, d = fields[index], fields[index+1], fields[index+2]
 +
if (m == 2 or m == '2') and (d == 29 or d == '29') then
 +
-- Workaround error with following which attempt to use invalid date 2001-02-29.
 +
-- {{age_ymwd|year1=2001|year2=2004|month2=2|day2=29}}
 +
-- {{age_ymwd|year1=2001|month1=2|year2=2004|month2=1|day2=29}}
 +
-- TODO Get rid of wantMixture because even this ugly code does not handle
 +
-- 'Feb' or 'February' or 'feb' or 'february'.
 +
if not ((y % 4 == 0 and y % 100 ~= 0) or y % 400 == 0) then
 +
d = 28
 +
end
 +
end
 +
dates[i] = Date(y, m, d)
 
end
 
end
 
else
 
else
for i = 1, nr_dates do
+
-- If partial dates are allowed, accept
 +
--    year only, or
 +
--    year and month only
 +
-- Do not accept year and day without a month because that makes no sense
 +
-- (and because, for example, Date('partial', 2001, nil, 12) sets day = nil, not 12).
 +
for i = 1, nrDates do
 
local index = i == 1 and 1 or 4
 
local index = i == 1 and 1 or 4
 
local y, m, d = fields[index], fields[index+1], fields[index+2]
 
local y, m, d = fields[index], fields[index+1], fields[index+2]
if y and m and d then
+
if (getopt.partial and y and (m or not d)) or (y and m and d) then
dates[i] = Date(y, m, d)
+
dates[i] = Date(fix, partialText, y, m, d)
elseif not (y or m or d) then
+
elseif not y and not m and not d then
dates[i] = Date('currentdate')
+
dates[i] = Date(flagCurrent())
 
end
 
end
 
end
 
end
 
end
 
end
 
else
 
else
getopt.textdates = true
+
getopt.textdates = true -- have parsed each date from a single text field
dates[1] = Date(fields[1] or 'currentdate')
+
dates[1] = Date(fix, partialText, flagCurrent(fields[1]))
if not single then
+
if not getopt.single then
dates[2] = Date(fields[2] or 'currentdate')
+
dates[2] = Date(fix, partialText, flagCurrent(fields[2]))
 
end
 
end
 
end
 
end
 
if not dates[1] then
 
if not dates[1] then
return message('Need valid year, month, day')
+
return message(getopt.missing1 or 'mt-need-valid-ymd')
 
end
 
end
if single then
+
if getopt.single then
 
return dates[1]
 
return dates[1]
 
end
 
end
 
if not dates[2] then
 
if not dates[2] then
return message('Second date should be year, month, day')
+
return message(getopt.missing2 or 'mt-need-valid-ymd2')
 
end
 
end
 
return dates[1], dates[2]
 
return dates[1], dates[2]
 
end
 
end
  
local function age_generic(frame, name)
+
local function ageGeneric(frame)
 
-- Return the result required by the specified template.
 
-- Return the result required by the specified template.
name = name or frame.args['template']
+
-- Can use sortable=x where x = on/table/off/debug in any supported template.
 +
-- Some templates default to sortable=on but can be overridden.
 +
local name = frame.args.template
 
if not name then
 
if not name then
return message('The template invoking this must have "|tamplate=x" where x is the wanted operation')
+
return message('mt-template-x')
 
end
 
end
 
local args = frame:getParent().args
 
local args = frame:getParent().args
 
local specs = {
 
local specs = {
age_days = {
+
age_days = {               -- {{age in days}}
 
show = 'd',
 
show = 'd',
 
disp = 'disp_raw',
 
disp = 'disp_raw',
 
},
 
},
age_full_years = {
+
age_days_nts = {            -- {{age in days nts}}
 +
show = 'd',
 +
disp = 'disp_raw',
 +
format = 'format_commas',
 +
sortable = 'on',
 +
},
 +
duration_days = {          -- {{duration in days}}
 +
show = 'd',
 +
disp = 'disp_raw',
 +
duration = true,
 +
},
 +
duration_days_nts = {      -- {{duration in days nts}}
 +
show = 'd',
 +
disp = 'disp_raw',
 +
format = 'format_commas',
 +
sortable = 'on',
 +
duration = true,
 +
},
 +
age_full_years = {         -- {{age}}
 
show = 'y',
 
show = 'y',
 
abbr = 'abbr_raw',
 
abbr = 'abbr_raw',
 +
flag = 'usesCurrent',
 +
omitZero = true,
 +
range = 'no',
 
},
 
},
age_infant = {
+
age_full_years_nts = {      -- {{age nts}}
 +
show = 'y',
 +
abbr = 'abbr_raw',
 +
format = 'format_commas',
 +
sortable = 'on',
 +
},
 +
age_in_years = {            -- {{age in years}}
 +
show = 'y',
 +
abbr = 'abbr_raw',
 +
negative = 'error',
 +
range = 'dash',
 +
},
 +
age_in_years_nts = {        -- {{age in years nts}}
 +
show = 'y',
 +
abbr = 'abbr_raw',
 +
negative = 'error',
 +
range = 'dash',
 +
format = 'format_commas',
 +
sortable = 'on',
 +
},
 +
age_infant = {             -- {{age for infant}}
 
-- Do not set show because special processing is done later.
 
-- Do not set show because special processing is done later.
 
abbr = yes(args.abbr) and 'abbr_infant' or 'abbr_off',
 
abbr = yes(args.abbr) and 'abbr_infant' or 'abbr_off',
 
disp = 'disp_age',
 
disp = 'disp_age',
 
sep = 'sep_space',
 
sep = 'sep_space',
 +
sortable = 'on',
 
},
 
},
age_m = {
+
age_m = {                   -- {{age in months}}
 
show = 'm',
 
show = 'm',
 
disp = 'disp_raw',
 
disp = 'disp_raw',
 
},
 
},
age_w = {
+
age_w = {                   -- {{age in weeks}}
 
show = 'w',
 
show = 'w',
 
disp = 'disp_raw',
 
disp = 'disp_raw',
 
},
 
},
age_wd = {
+
age_wd = {                 -- {{age in weeks and days}}
 
show = 'wd',
 
show = 'wd',
 
},
 
},
age_yd = {
+
age_yd = {                 -- {{age in years and days}}
 
show = 'yd',
 
show = 'yd',
 +
format = 'format_commas',
 
sep = args.sep ~= 'and' and 'sep_comma' or nil,
 
sep = args.sep ~= 'and' and 'sep_comma' or nil,
 
},
 
},
age_ym = {
+
age_yd_nts = {              -- {{age in years and days nts}}
 +
show = 'yd',
 +
format = 'format_commas',
 +
sep = args.sep ~= 'and' and 'sep_comma' or nil,
 +
sortable = 'on',
 +
},
 +
age_ym = {                 -- {{age in years and months}}
 
show = 'ym',
 
show = 'ym',
 
sep = 'sep_comma',
 
sep = 'sep_comma',
 
},
 
},
age_ymd = {
+
age_ymd = {                 -- {{age in years, months and days}}
 
show = 'ymd',
 
show = 'ymd',
want_mixture = true,
+
range = true,
 
},
 
},
age_ymwd = {
+
age_ymwd = {               -- {{age in years, months, weeks and days}}
 
show = 'ymwd',
 
show = 'ymwd',
 +
wantMixture = true,
 
},
 
},
 
}
 
}
 
local spec = specs[name]
 
local spec = specs[name]
 
if not spec then
 
if not spec then
return message('The specified template name is not valid')
+
return message('mt-template-bad-name')
 
end
 
end
 
if name == 'age_days' then
 
if name == 'age_days' then
local su = strip_to_nil(args['show unit'])
+
local su = stripToNil(args['show unit'])
 
if su then
 
if su then
 
if su == 'abbr' or su == 'full' then
 
if su == 'abbr' or su == 'full' then
Line 411: Line 788:
 
end
 
end
 
end
 
end
local getopt = { want_mixture = spec.want_mixture }
+
local partial, autofill
local date1, date2 = get_dates(frame, getopt)
+
local range = stripToNil(args.range) or spec.range
 +
if range then
 +
-- Suppose partial dates are used and age could be 11 or 12 years.
 +
-- "|range=" (empty value) has no effect (spec is used).
 +
-- "|range=yes" or spec.range == true sets range = true (gives "11 or 12")
 +
-- "|range=dash" or spec.range == 'dash' sets range = 'dash' (gives "11–12").
 +
-- "|range=no" or spec.range == 'no' sets range = nil and fills each date in the diff (gives "12").
 +
--    ("on" is equivalent to "yes", and "off" is equivalent to "no").
 +
-- "|range=OTHER" sets range = nil and rejects partial dates.
 +
range = ({ dash = 'dash', off = 'no', no = 'no', [true] = true })[range] or yes(range)
 +
if range then
 +
partial = true  -- accept partial dates with a possible age range for the result
 +
if range == 'no' then
 +
autofill = true  -- missing month/day in first or second date are filled from other date or 1
 +
range = nil
 +
end
 +
end
 +
end
 +
local getopt = {
 +
fix = yes(args.fix),
 +
flag = stripToNil(args.flag) or spec.flag,
 +
omitZero = spec.omitZero,
 +
partial = partial,
 +
wantMixture = spec.wantMixture,
 +
}
 +
local date1, date2 = getDates(frame, getopt)
 
if type(date1) == 'string' then
 
if type(date1) == 'string' then
 
return date1
 
return date1
 
end
 
end
local format = strip_to_nil(args.format)
+
local format = stripToNil(args.format)
 +
local spell = spellOptions[format]
 
if format then
 
if format then
 
format = 'format_' .. format
 
format = 'format_' .. format
Line 423: Line 826:
 
end
 
end
 
local parms = {
 
local parms = {
diff = date2 - date1,
+
diff = date2:subtract(date1, { fill = autofill }),
want_duration = yes(args.duration),
+
wantDuration = spec.duration or yes(args.duration),
want_sc = yes(args.sc),
+
range = range,
show = spec.show,
+
wantSc = yes(args.sc),
 +
show = args.show == 'hide' and 'hide' or spec.show,
 
abbr = spec.abbr,
 
abbr = spec.abbr,
 
disp = spec.disp,
 
disp = spec.disp,
format = format,
+
extra = makeExtra(args, getopt.usesCurrent and format ~= 'format_raw'),
 +
format = format or spec.format,
 
round = yes(args.round),
 
round = yes(args.round),
 
sep = spec.sep,
 
sep = spec.sep,
 +
sortable = translateParameters.sortable[args.sortable or spec.sortable],
 +
spell = spell,
 +
}
 +
if (spec.negative or frame.args.negative) == 'error' and parms.diff.isnegative then
 +
return message('mt-date-wrong-order')
 +
end
 +
return from_en(dateDifference(parms))
 +
end
 +
 +
local function bda(frame)
 +
-- Implement [[Template:Birth date and age]].
 +
local args = frame:getParent().args
 +
local options = {
 +
missing1 = 'mt-need-valid-bd',
 +
noMissing = true,
 +
single = true,
 +
}
 +
local date = getDates(frame, options)
 +
if type(date) == 'string' then
 +
return date  -- error text
 +
end
 +
local Date = getExports(frame)
 +
local diff = Date('currentdate') - date
 +
if diff.isnegative or diff.years > 150 then
 +
return message('mt-invalid-bd-age')
 +
end
 +
local disp, show = 'disp_raw', 'y'
 +
if diff.years < 2 then
 +
disp = 'disp_age'
 +
if diff.years == 0 and diff.months == 0 then
 +
show = 'd'
 +
else
 +
show = 'm'
 +
end
 +
end
 +
local df = stripToNil(args.df)  -- day first (dmy); default is month first (mdy)
 +
local result = '(<span class="bday">%-Y-%m-%d</span>) </span>' ..
 +
(df and '%-d %B %-Y' or '%B %-d, %-Y')
 +
result = from_en('<span style="display:none"> ' ..
 +
date:text(result) ..
 +
'<span class="noprint ForceAgeToShow"> ' ..
 +
mtext['txt-age'] ..
 +
dateDifference({
 +
diff = diff,
 +
show = show,
 +
abbr = 'abbr_off',
 +
disp = disp,
 +
sep = 'sep_space',
 +
}) ..
 +
')</span>')
 +
local warnings = tonumber(frame.args.warnings)
 +
if warnings and warnings > 0 then
 +
local good = {
 +
df = true,
 +
mf = true,
 +
day = true,
 +
day1 = true,
 +
month = true,
 +
month1 = true,
 +
year = true,
 +
year1 = true,
 +
}
 +
local invalid
 +
local imax = options.textdates and 1 or 3
 +
for k, _ in pairs(args) do
 +
if type(k) == 'number' then
 +
if k > imax then
 +
invalid = tostring(k)
 +
break
 +
end
 +
else
 +
if not good[k] then
 +
invalid = k
 +
break
 +
end
 +
end
 +
end
 +
if invalid then
 +
result = result .. message({ 'mt-bad-param1', invalid }, 'warning')
 +
end
 +
end
 +
return result
 +
end
 +
 +
local function dda(frame)
 +
-- Implement [[Template:Death date and age]].
 +
local args = frame:getParent().args
 +
local options = {
 +
missing1 = 'mt-need-valid-dd',
 +
missing2 = 'mt-need-valid-bd2',
 +
noMissing = true,
 +
partial = true,
 
}
 
}
return date_difference(parms)
+
local date1, date2 = getDates(frame, options)
 +
if type(date1) == 'string' then
 +
return date1
 +
end
 +
local diff = date1 - date2
 +
if diff.isnegative then
 +
return message('mt-dd-wrong-order')
 +
end
 +
local Date = getExports(frame)
 +
local today = Date('currentdate') + 1  -- one day in future allows for timezones
 +
if date1 > today then
 +
return message('mt-dd-future')
 +
end
 +
local years
 +
if diff.partial then
 +
years = diff.partial.years
 +
years = type(years) == 'table' and years[2] or years
 +
else
 +
years = diff.years
 +
end
 +
if years > 150 then
 +
return message('mt-invalid-dates-age')
 +
end
 +
local df = stripToNil(args.df)  -- day first (dmy); default is month first (mdy)
 +
local result
 +
if date1.day then  -- y, m, d known
 +
result = (df and
 +
'%-d %B %-Y' or
 +
'%B %-d, %-Y') ..
 +
'<span style="display:none">(%-Y-%m-%d)</span>'
 +
elseif date1.month then  -- y, m known; d unknown
 +
result =
 +
'%B %-Y' ..
 +
'<span style="display:none">(%-Y-%m-00)</span>'
 +
else  -- y known; m, d unknown
 +
result =
 +
'%-Y' ..
 +
'<span style="display:none">(%-Y-00-00)</span>'
 +
end
 +
result = from_en(date1:text(result) ..
 +
mtext['txt-aged'] ..
 +
dateDifference({
 +
diff = diff,
 +
show = 'y',
 +
abbr = 'abbr_off',
 +
disp = 'disp_raw',
 +
range = 'dash',
 +
sep = 'sep_space',
 +
}) ..
 +
')')
 +
local warnings = tonumber(frame.args.warnings)
 +
if warnings and warnings > 0 then
 +
local good = {
 +
df = true,
 +
mf = true,
 +
}
 +
local invalid
 +
local imax = options.textdates and 2 or 6
 +
for k, _ in pairs(args) do
 +
if type(k) == 'number' then
 +
if k > imax then
 +
invalid = tostring(k)
 +
break
 +
end
 +
else
 +
if not good[k] then
 +
invalid = k
 +
break
 +
end
 +
end
 +
end
 +
if invalid then
 +
result = result .. message({ 'mt-bad-param1', invalid }, 'warning')
 +
end
 +
end
 +
return result
 
end
 
end
  
local function ymd_to_gsd(frame)
+
local function dateToGsd(frame)
-- This implements {{gregorian serial date}}.
+
-- Implement [[Template:Gregorian serial date]].
 
-- Return Gregorian serial date of the given date, or the current date.
 
-- Return Gregorian serial date of the given date, or the current date.
 
-- The returned value is negative for dates before 1 January 1 AD
 
-- The returned value is negative for dates before 1 January 1 AD
 
-- despite the fact that GSD is not defined for such dates.
 
-- despite the fact that GSD is not defined for such dates.
local date = get_dates(frame, { want_mixture=true, single=true })
+
local date = getDates(frame, { wantMixture=true, single=true })
 
if type(date) == 'string' then
 
if type(date) == 'string' then
 
return date
 
return date
Line 448: Line 1,020:
 
end
 
end
  
local function ymd_from_jd(frame)
+
local function jdToDate(frame)
 
-- Return formatted date from a Julian date.
 
-- Return formatted date from a Julian date.
-- The result is y-m-d or y-m-d H:M:S if input includes a fraction.
+
-- The result includes a time if the input includes a fraction.
 
-- The word 'Julian' is accepted for the Julian calendar.
 
-- The word 'Julian' is accepted for the Julian calendar.
local Date = get_exports(frame)
+
local Date = getExports(frame)
 
local args = frame:getParent().args
 
local args = frame:getParent().args
 
local date = Date('juliandate', args[1], args[2])
 
local date = Date('juliandate', args[1], args[2])
 
if date then
 
if date then
return date:text()
+
return from_en(date:text())
 
end
 
end
return message('Need valid Julian date number')
+
return message('mt-need-jdn')
 
end
 
end
  
local function ymd_to_jd(frame)
+
local function dateToJd(frame)
-- Return Julian date (a number) from a date (y-m-d), or datetime (y-m-d H:M:S),
+
-- Return Julian date (a number) from a date which may include a time,
 
-- or the current date ('currentdate') or current date and time ('currentdatetime').
 
-- or the current date ('currentdate') or current date and time ('currentdatetime').
 
-- The word 'Julian' is accepted for the Julian calendar.
 
-- The word 'Julian' is accepted for the Julian calendar.
local Date = get_exports(frame)
+
local Date = getExports(frame)
 
local args = frame:getParent().args
 
local args = frame:getParent().args
 
local date = Date(args[1], args[2], args[3], args[4], args[5], args[6], args[7])
 
local date = Date(args[1], args[2], args[3], args[4], args[5], args[6], args[7])
Line 471: Line 1,043:
 
return tostring(date.jd)
 
return tostring(date.jd)
 
end
 
end
return message('Need valid year/month/day or "currentdate"')
+
return message('mt-need-valid-ymd-current')
 
end
 
end
  
local function time_interval(frame)
+
local function timeInterval(frame)
-- This implements {{time interval}}.
+
-- Implement [[Template:Time interval]].
 
-- There are two positional arguments: date1, date2.
 
-- There are two positional arguments: date1, date2.
 
-- The default for each is the current date and time.
 
-- The default for each is the current date and time.
 
-- Result is date2 - date1 formatted.
 
-- Result is date2 - date1 formatted.
local Date = get_exports(frame)
+
local Date = getExports(frame)
 
local args = frame:getParent().args
 
local args = frame:getParent().args
 
local parms = {
 
local parms = {
want_duration = yes(args.duration),
+
extra = makeExtra(args),
want_sc = yes(args.sc),
+
wantDuration = yes(args.duration),
 +
range = yes(args.range) or (args.range == 'dash' and 'dash' or nil),
 +
wantSc = yes(args.sc),
 
}
 
}
local date1 = Date(strip_to_nil(args[1]) or 'currentdatetime')
+
local fix = yes(args.fix) and 'fix' or ''
 +
local date1 = Date(fix, 'partial', stripToNil(args[1]) or 'currentdatetime')
 
if not date1 then
 
if not date1 then
return message('Invalid start date in first parameter')
+
return message('mt-invalid-start')
 
end
 
end
local date2 = Date(strip_to_nil(args[2]) or 'currentdatetime')
+
local date2 = Date(fix, 'partial', stripToNil(args[2]) or 'currentdatetime')
 
if not date2 then
 
if not date2 then
return message('Invalid end date in second parameter')
+
return message('mt-invalid-end')
 
end
 
end
 
parms.diff = date2 - date1
 
parms.diff = date2 - date1
for argname, translate in pairs(translate_parameters) do
+
for argname, translate in pairs(translateParameters) do
local parm = strip_to_nil(args[argname])
+
local parm = stripToNil(args[argname])
 
if parm then
 
if parm then
 
parm = translate[parm]
 
parm = translate[parm]
if not parm then
+
if parm == nil then -- test for nil because false is a valid setting
return message('Parameter ' .. argname .. '=' .. args[argname] .. ' is invalid')
+
return message({ 'mt-bad-param2', argname, args[argname] })
 
end
 
end
 
parms[argname] = parm
 
parms[argname] = parm
Line 510: Line 1,085:
 
if show then
 
if show then
 
if show.id ~= round then
 
if show.id ~= round then
return message('Parameter show=' .. args.show .. ' conflicts with round=' .. args.round)
+
return message({ 'mt-conflicting-show', args.show, args.round })
 
end
 
end
 
else
 
else
parms.show = translate_parameters.show[round]
+
parms.show = translateParameters.show[round]
 
end
 
end
 
end
 
end
 
parms.round = true
 
parms.round = true
 
end
 
end
return date_difference(parms)
+
return from_en(dateDifference(parms))
 
end
 
end
  
 
return {
 
return {
age_generic = age_generic,         -- can emulate several age templates
+
age_generic = ageGeneric,           -- can emulate several age templates
gsd = ymd_to_gsd,                   -- Template:Gregorian_serial_date
+
birth_date_and_age = bda,          -- Template:Birth_date_and_age
JULIANDAY = ymd_to_jd,              -- Template:JULIANDAY
+
death_date_and_age = dda,          -- Template:Death_date_and_age
time_interval = time_interval,     -- Template:Time_interval
+
gsd = dateToGsd,                   -- Template:Gregorian_serial_date
ymd_from_jd = ymd_from_jd,         -- Template:?
+
extract = dateExtract,              -- Template:Extract
ymd_to_jd = ymd_to_jd,             -- Template:JULIANDAY (alias)
+
jd_to_date = jdToDate,             -- Template:?
 +
JULIANDAY = dateToJd,               -- Template:JULIANDAY
 +
time_interval = timeInterval,       -- Template:Time_interval
 
}
 
}

Latest revision as of 13:07, 26 September 2020

Documentation for this module may be created at Module:Age/sandbox/doc

-- Implement various "age of" and other date-related templates.

local mtext = {
	-- Message and other text that should be localized.
	['mt-bad-param1'] =             'Invalid parameter $1',
	['mt-bad-param2'] =             'Parameter $1=$2 is invalid',
	['mt-bad-show'] =               'Parameter show=$1 is not supported here',
	['mt-cannot-add'] =             'Cannot add "$1"',
	['mt-conflicting-show'] =       'Parameter show=$1 conflicts with round=$2',
	['mt-date-wrong-order'] =       'The second date must be later in time than the first date',
	['mt-dd-future'] =              'Death date (first date) must not be in the future',
	['mt-dd-wrong-order'] =         'Death date (first date) must be later in time than the birth date (second date)',
	['mt-invalid-bd-age'] =         'Invalid birth date for calculating age',
	['mt-invalid-dates-age'] =      'Invalid dates for calculating age',
	['mt-invalid-end'] =            'Invalid end date in second parameter',
	['mt-invalid-start'] =          'Invalid start date in first parameter',
	['mt-need-jdn'] =               'Need valid Julian date number',
	['mt-need-valid-bd'] =          'Need valid birth date: year, month, day',
	['mt-need-valid-bd2'] =         'Need valid birth date (second date): year, month, day',
	['mt-need-valid-date'] =        'Need valid date',
	['mt-need-valid-dd'] =          'Need valid death date (first date): year, month, day',
	['mt-need-valid-ymd'] =         'Need valid year, month, day',
	['mt-need-valid-ymd-current'] = 'Need valid year|month|day or "currentdate"',
	['mt-need-valid-ymd2'] =        'Second date should be year, month, day',
	['mt-template-bad-name'] =      'The specified template name is not valid',
	['mt-template-x'] =             'The template invoking this must have "|template=x" where x is the wanted operation',
	['txt-age'] =                   '(age&nbsp;',
	['txt-aged'] =                  ' (aged&nbsp;',
	['txt-and'] =                   ' and ',
	['txt-comma-and'] =             ', and ',
	['txt-error'] =                 'Error: ',
	['txt-or'] =                    '&nbsp;or ',
}

local translate, from_en, to_en, isZero
if translate then
	-- Functions to translate from en to local language and reverse go here.
	-- See example at [[:bn:Module:বয়স]].
else
	from_en = function (text)
		return text
	end
	isZero = function (text)
		return tonumber(text) == 0
	end
end

local _Date, _currentDate
local function getExports(frame)
	-- Return objects exported from the date module or its sandbox.
	if not _Date then
		local sandbox = frame:getTitle():find('sandbox', 1, true) and '/sandbox' or ''
		local datemod = require('Module:Date' .. sandbox)
		local realDate = datemod._Date
		_currentDate = datemod._current
		if to_en then
			_Date = function (...)
				local args = {}
				for i, v in ipairs({...}) do
					args[i] = to_en(v)
				end
				return realDate(unpack(args))
			end
		else
			_Date = realDate
		end
	end
	return _Date, _currentDate
end

local Collection  -- a table to hold items
Collection = {
	add = function (self, item)
		if item ~= nil then
			self.n = self.n + 1
			self[self.n] = item
		end
	end,
	join = function (self, sep)
		return table.concat(self, sep)
	end,
	remove = function (self, pos)
		if self.n > 0 and (pos == nil or (0 < pos and pos <= self.n)) then
			self.n = self.n - 1
			return table.remove(self, pos)
		end
	end,
	sort = function (self, comp)
		table.sort(self, comp)
	end,
	new = function ()
		return setmetatable({n = 0}, Collection)
	end
}
Collection.__index = Collection

local function stripToNil(text)
	-- If text is a string, return its trimmed content, or nil if empty.
	-- Otherwise return text (which may, for example, be nil).
	if type(text) == 'string' then
		text = text:match('(%S.-)%s*$')
	end
	return text
end

local function yes(parameter)
	-- Return true if parameter should be interpreted as "yes".
	-- Do not want to accept mixed upper/lowercase unless done by current templates.
	-- Need to accept "on" because "round=on" is wanted.
	return ({ y = true, yes = true, on = true })[parameter]
end

local function message(msg, id)
	-- Return formatted message text for an error or warning.
	local function getText(msg)
		return mtext[msg] or error('Bug: message "' .. tostring(msg) .. '" not defined')
	end
	local text
	if type(msg) == 'table' then
		text = getText(msg[1])
		local rep = {}
		for i, v in ipairs(msg) do
			if i > 1 then
				rep['$' .. (i - 1)] = v
			end
		end
		text = text:gsub('$%d+', rep)
	else
		text = getText(msg)
	end
	local categories = {
		error = '[[Category:Age error]]',
		warning = '[[Category:Age error]]',  -- same as error until determine whether 'Age warning' would be worthwhile
	}
	local a, b, category
	if id == 'warning' then
		a = '<sup>[<i>'
		b = '</i>]</sup>'
	else
		a = '<strong class="error">' .. getText('txt-error')
		b = '</strong>'
	end
	if mw.title.getCurrentTitle():inNamespaces(0) then
		-- Category only in namespaces: 0=article.
		category = categories[id or 'error']
	end
	return
		a ..
		mw.text.nowiki(text) ..
		b ..
		(category or '')
end

local function formatNumber(number)
	-- Return the given number formatted with commas as group separators,
	-- given that the number is an integer.
	local numstr = tostring(number)
	local length = #numstr
	local places = Collection.new()
	local pos = 0
	repeat
		places:add(pos)
		pos = pos + 3
	until pos >= length
	places:add(length)
	local groups = Collection.new()
	for i = places.n, 2, -1 do
		local p1 = length - places[i] + 1
		local p2 = length - places[i - 1]
		groups:add(numstr:sub(p1, p2))
	end
	return groups:join(',')
end

local function spellNumber(number, options, i)
	-- Return result of spelling number, or
	-- return number (as a string) if cannot spell it.
	-- i == 1 for the first number which can optionally start with an uppercase letter.
	number = tostring(number)
	return require('Module:ConvertNumeric').spell_number(
		number,
		nil,                       -- fraction numerator
		nil,                       -- fraction denominator
		i == 1 and options.upper,  -- true: 'One' instead of 'one'
		not options.us,            -- true: use 'and' between tens/ones etc
		options.adj,               -- true: hyphenated
		options.ordinal            -- true: 'first' instead of 'one'
	) or number
end

local function makeExtra(args, flagCurrent)
	-- Return extra text that will be inserted before the visible result
	-- but after any sort key.
	local extra = args.prefix or ''
	if mw.ustring.len(extra) > 1 then
		-- Parameter "~" gives "~3" whereas "over" gives "over 3".
		if extra:sub(-6, -1) ~= '&nbsp;' then
			extra = extra .. ' '
		end
	end
	if flagCurrent then
		extra = '<span class="currentage"></span>' .. extra
	end
	return extra
end

local function makeSort(value, sortable)
	-- Return a sort key if requested.
	-- Assume value is a valid number which has not overflowed.
	if sortable == 'sortable_table' or sortable == 'sortable_on' or sortable == 'sortable_debug' then
		local sortKey
		if value == 0 then
			sortKey = '5000000000000000000'
		else
			local mag = math.floor(math.log10(math.abs(value)) + 1e-14)
			if value > 0 then
				sortKey = 7000 + mag
			else
				sortKey = 2999 - mag
				value = value + 10^(mag+1)
			end
			sortKey = string.format('%d', sortKey) .. string.format('%015.0f', math.floor(value * 10^(14-mag)))
		end
		local result
		if sortable == 'sortable_table' then
			result = 'data-sort-value="_SORTKEY_"|'
		elseif sortable == 'sortable_debug' then
			result = '<span data-sort-value="_SORTKEY_♠"><span style="border:1px solid">_SORTKEY_♠</span></span>'
		else
			result = '<span data-sort-value="_SORTKEY_♠"></span>'
		end
		return result:gsub('_SORTKEY_', sortKey)
	end
end

local translateParameters = {
	abbr = {
		off = 'abbr_off',
		on = 'abbr_on',
	},
	disp = {
		age = 'disp_age',
		raw = 'disp_raw',
	},
	format = {
		raw = 'format_raw',
		commas = 'format_commas',
	},
	round = {
		on = 'on',
		yes = 'on',
		months = 'ym',
		weeks = 'ymw',
		days = 'ymd',
		hours = 'ymdh',
	},
	sep = {
		comma = 'sep_comma',
		[','] = 'sep_comma',
		serialcomma = 'sep_serialcomma',
		space = 'sep_space',
	},
	show = {
		hide = { id = 'hide' },
		y = { 'y', id = 'y' },
		ym = { 'y', 'm', id = 'ym' },
		ymd = { 'y', 'm', 'd', id = 'ymd' },
		ymw = { 'y', 'm', 'w', id = 'ymw' },
		ymwd = { 'y', 'm', 'w', 'd', id = 'ymwd' },
		yd = { 'y', 'd', id = 'yd', keepZero = true },
		m = { 'm', id = 'm' },
		md = { 'm', 'd', id = 'md' },
		w = { 'w', id = 'w' },
		wd = { 'w', 'd', id = 'wd' },
		h = { 'H', id = 'h' },
		hm = { 'H', 'M', id = 'hm' },
		hms = { 'H', 'M', 'S', id = 'hms' },
		M = { 'M', id = 'M' },
		s = { 'S', id = 's' },
		d = { 'd', id = 'd' },
		dh = { 'd', 'H', id = 'dh' },
		dhm = { 'd', 'H', 'M', id = 'dhm' },
		dhms = { 'd', 'H', 'M', 'S', id = 'dhms' },
		ymdh = { 'y', 'm', 'd', 'H', id = 'ymdh' },
		ymdhm = { 'y', 'm', 'd', 'H', 'M', id = 'ymdhm' },
		ymwdh = { 'y', 'm', 'w', 'd', 'H', id = 'ymwdh' },
		ymwdhm = { 'y', 'm', 'w', 'd', 'H', 'M', id = 'ymwdhm' },
	},
	sortable = {
		off = false,
		on = 'sortable_on',
		table = 'sortable_table',
		debug = 'sortable_debug',
	},
}

local spellOptions = {
	cardinal = {},
	Cardinal = { upper = true },
	cardinal_us = { us = true },
	Cardinal_us = { us = true, upper = true },
	ordinal = { ordinal = true },
	Ordinal = { ordinal = true, upper = true },
	ordinal_us = { ordinal = true, us = true },
	Ordinal_us = { ordinal = true, us = true, upper = true },
}

local function dateExtract(frame)
	-- Return part of a date after performing an optional operation.
	local Date = getExports(frame)
	local args = frame:getParent().args
	local parms = {}
	for i, v in ipairs(args) do
		parms[i] = v
	end
	if yes(args.fix) then
		table.insert(parms, 'fix')
	end
	if yes(args.partial) then
		table.insert(parms, 'partial')
	end
	local show = stripToNil(args.show) or 'dmy'
	local date = Date(unpack(parms))
	if not date then
		if show == 'format' then
			return 'error'
		end
		return message('mt-need-valid-date')
	end
	local add = stripToNil(args.add)
	if add then
		for item in add:gmatch('%S+') do
			date = date + item
			if not date then
				return message({ 'mt-cannot-add', item })
			end
		end
	end
	local sortKey, result
	local sortable = translateParameters.sortable[args.sortable]
	if sortable then
		local value = (date.partial and date.partial.first or date).jdz
		sortKey = makeSort(value, sortable)
	end
	if show ~= 'hide' then
		result = date[show]
		if result == nil then
			result = from_en(date:text(show))
		elseif type(result) == 'boolean' then
			result = result and '1' or '0'
		else
			result = from_en(tostring(result))
		end
	end
	return (sortKey or '') .. makeExtra(args) .. (result or '')
end

local function rangeJoin(range)
	-- Return text to be used between a range of ages.
	return range == 'dash' and '–' or mtext['txt-or']
end

local function makeText(values, components, names, options, noUpper)
	-- Return wikitext representing an age or duration.
	local text = Collection.new()
	local count = #values
	local sep = names.sep or ''
	for i, v in ipairs(values) do
		-- v is a number (say 4 for 4 years), or a table ({4,5} for 4 or 5 years).
		local islist = type(v) == 'table'
		if (islist or v > 0) or (text.n == 0 and i == count) or (text.n > 0 and components.keepZero) then
			local fmt, vstr
			if options.spell then
				fmt = function(number)
					return spellNumber(number, options.spell, noUpper or i)
				end
			elseif i == 1 and options.format == 'format_commas' then
				-- Numbers after the first should be small and not need formatting.
				fmt = formatNumber
			else
				fmt = tostring
			end
			if islist then
				vstr = fmt(v[1]) .. rangeJoin(options.range)
				noUpper = true
				vstr = vstr .. fmt(v[2])
			else
				vstr = fmt(v)
			end
			local name = names[components[i]]
			if name then
				local plural = names.plural
				if not plural or (islist and v[2] or v) == 1 then
					plural = ''
				end
				text:add(vstr .. sep .. name .. plural)
			else
				text:add(vstr)
			end
		end
	end
	local first, last
	if options.join == 'sep_space' then
		first = ' '
		last = ' '
	elseif options.join == 'sep_comma' then
		first = ', '
		last = ', '
	elseif options.join == 'sep_serialcomma' and text.n > 2 then
		first = ', '
		last = mtext['txt-comma-and']
	else
		first = ', '
		last = mtext['txt-and']
	end
	for i, v in ipairs(text) do
		if i < text.n then
			text[i] = v .. (i + 1 < text.n and first or last)
		end
	end
	local sign = ''
	if options.isnegative then
		-- Do not display negative zero.
		if text.n > 1 or (text.n == 1 and text[1]:sub(1, 1) ~= '0' ) then
			if options.format == 'format_raw' then
				sign = '-'  -- plain hyphen so result can be used in a calculation
			else
				sign = '−'  -- Unicode U+2212 MINUS SIGN
			end
		end
	end
	return
		(options.sortKey or '') ..
		(options.extra or '') ..
		sign ..
		text:join() ..
		(options.suffix or '')
end

local function dateDifference(parms)
	-- Return a formatted date difference using the given parameters
	-- which have been validated.
	local names = {
		abbr_off = {
			plural = 's',
			sep = '&nbsp;',
			y = 'year',
			m = 'month',
			w = 'week',
			d = 'day',
			H = 'hour',
			M = 'minute',
			S = 'second',
		},
		abbr_on = {
			y = 'y',
			m = 'm',
			w = 'w',
			d = 'd',
			H = 'h',
			M = 'm',
			S = 's',
		},
		abbr_infant = {      -- for {{age for infant}}
			plural = 's',
			sep = '&nbsp;',
			y = 'yr',
			m = 'mo',
			w = 'wk',
			d = 'day',
			H = 'hr',
			M = 'min',
			S = 'sec',
		},
		abbr_raw = {},
	}
	local diff = parms.diff  -- must be a valid date difference
	local show = parms.show  -- may be nil; default is set below
	local abbr = parms.abbr or 'abbr_off'
	local defaultJoin
	if abbr ~= 'abbr_off' then
		defaultJoin = 'sep_space'
	end
	if not show then
		show = 'ymd'
		if parms.disp == 'disp_age' then
			if diff.years < 3 then
				defaultJoin = 'sep_space'
				if diff.years >= 1 then
					show = 'ym'
				else
					show = 'md'
				end
			else
				show = 'y'
			end
		end
	end
	if type(show) ~= 'table' then
		show = translateParameters.show[show]
	end
	if parms.disp == 'disp_raw' then
		defaultJoin = 'sep_space'
		abbr = 'abbr_raw'
	elseif parms.wantSc then
		defaultJoin = 'sep_serialcomma'
	end
	local diffOptions = {
		round = parms.round,
		duration = parms.wantDuration,
		range = parms.range and true or nil,
	}
	local sortKey
	if parms.sortable then
		local value = diff.age_days + (parms.wantDuration and 1 or 0)  -- days and fraction of a day
		if diff.isnegative then
			value = -value
		end
		sortKey = makeSort(value, parms.sortable)
	end
	local textOptions = {
		extra = parms.extra,
		format = parms.format,
		join = parms.sep or defaultJoin,
		isnegative = diff.isnegative,
		range = parms.range,
		sortKey = sortKey,
		spell = parms.spell,
		suffix = parms.suffix,  -- not currently used
	}
	if show.id == 'hide' then
		return sortKey or ''
	end
	local values = { diff:age(show.id, diffOptions) }
	if values[1] then
		return makeText(values, show, names[abbr], textOptions)
	end
	if diff.partial then
		-- Handle a more complex range such as
		-- {{age_yd|20 Dec 2001|2003|range=yes}} → 1 year, 12 days or 2 years, 11 days
		local opt = {
			format = textOptions.format,
			join = textOptions.join,
			isnegative = textOptions.isnegative,
			spell = textOptions.spell,
		}
		return
			(textOptions.sortKey or '') ..
			makeText({ diff.partial.mindiff:age(show.id, diffOptions) }, show, names[abbr], opt) ..
			rangeJoin(textOptions.range) ..
			makeText({ diff.partial.maxdiff:age(show.id, diffOptions) }, show, names[abbr], opt, true) ..
			(textOptions.suffix or '')
	end
	return message({ 'mt-bad-show', show.id })
end

local function getDates(frame, getopt)
	-- Parse template parameters and return one of:
	-- * date         (a date table, if single)
	-- * date1, date2 (two date tables, if not single)
	-- * text         (a string error message)
	-- A missing date is optionally replaced with the current date.
	-- If wantMixture is true, a missing date component is replaced
	-- from the current date, so can get a bizarre mixture of
	-- specified/current y/m/d as has been done by some "age" templates.
	-- Some results may be placed in table getopt.
	local Date, currentDate = getExports(frame)
	getopt = getopt or {}
	local function flagCurrent(text)
		-- This allows the calling template to detect if the current date has been used,
		-- that is, whether both dates have been entered in a template expecting two.
		-- For example, an infobox may want the age when an event occurred, not the current age.
		-- Don't bother detecting if wantMixture is used because not needed and it is a poor option.
		if not text then
			if getopt.noMissing then
				return nil  -- this gives a nil date which gives an error
			end
			text = 'currentdate'
			if getopt.flag == 'usesCurrent' then
				getopt.usesCurrent = true
			end
		end
		return text
	end
	local args = frame:getParent().args
	local fields = {}
	local isNamed = args.year or args.year1 or args.year2 or
		args.month or args.month1 or args.month2 or
		args.day or args.day1 or args.day2
	if isNamed then
		fields[1] = args.year1 or args.year
		fields[2] = args.month1 or args.month
		fields[3] = args.day1 or args.day
		fields[4] = args.year2
		fields[5] = args.month2
		fields[6] = args.day2
	else
		for i = 1, 6 do
			fields[i] = args[i]
		end
	end
	local imax = 0
	for i = 1, 6 do
		fields[i] = stripToNil(fields[i])
		if fields[i] then
			imax = i
		end
		if getopt.omitZero and i % 3 ~= 1 then  -- omit zero months and days as unknown values but keep year 0 which is 1 BCE
			if isZero(fields[i]) then
				fields[i] = nil
				getopt.partial = true
			end
		end
	end
	local fix = getopt.fix and 'fix' or ''
	local partialText = getopt.partial and 'partial' or ''
	local dates = {}
	if isNamed or imax >= 3 then
		local nrDates = getopt.single and 1 or 2
		if getopt.wantMixture then
			-- Cannot be partial since empty fields are set from current.
			local components = { 'year', 'month', 'day' }
			for i = 1, nrDates * 3 do
				fields[i] = fields[i] or currentDate[components[i > 3 and i - 3 or i]]
			end
			for i = 1, nrDates do
				local index = i == 1 and 1 or 4
				local y, m, d = fields[index], fields[index+1], fields[index+2]
				if (m == 2 or m == '2') and (d == 29 or d == '29') then
					-- Workaround error with following which attempt to use invalid date 2001-02-29.
					-- {{age_ymwd|year1=2001|year2=2004|month2=2|day2=29}}
					-- {{age_ymwd|year1=2001|month1=2|year2=2004|month2=1|day2=29}}
					-- TODO Get rid of wantMixture because even this ugly code does not handle
					-- 'Feb' or 'February' or 'feb' or 'february'.
					if not ((y % 4 == 0 and y % 100 ~= 0) or y % 400 == 0) then
						d = 28
					end
				end
				dates[i] = Date(y, m, d)
			end
		else
			-- If partial dates are allowed, accept
			--     year only, or
			--     year and month only
			-- Do not accept year and day without a month because that makes no sense
			-- (and because, for example, Date('partial', 2001, nil, 12) sets day = nil, not 12).
			for i = 1, nrDates do
				local index = i == 1 and 1 or 4
				local y, m, d = fields[index], fields[index+1], fields[index+2]
				if (getopt.partial and y and (m or not d)) or (y and m and d) then
					dates[i] = Date(fix, partialText, y, m, d)
				elseif not y and not m and not d then
					dates[i] = Date(flagCurrent())
				end
			end
		end
	else
		getopt.textdates = true  -- have parsed each date from a single text field
		dates[1] = Date(fix, partialText, flagCurrent(fields[1]))
		if not getopt.single then
			dates[2] = Date(fix, partialText, flagCurrent(fields[2]))
		end
	end
	if not dates[1] then
		return message(getopt.missing1 or 'mt-need-valid-ymd')
	end
	if getopt.single then
		return dates[1]
	end
	if not dates[2] then
		return message(getopt.missing2 or 'mt-need-valid-ymd2')
	end
	return dates[1], dates[2]
end

local function ageGeneric(frame)
	-- Return the result required by the specified template.
	-- Can use sortable=x where x = on/table/off/debug in any supported template.
	-- Some templates default to sortable=on but can be overridden.
	local name = frame.args.template
	if not name then
		return message('mt-template-x')
	end
	local args = frame:getParent().args
	local specs = {
		age_days = {                -- {{age in days}}
			show = 'd',
			disp = 'disp_raw',
		},
		age_days_nts = {            -- {{age in days nts}}
			show = 'd',
			disp = 'disp_raw',
			format = 'format_commas',
			sortable = 'on',
		},
		duration_days = {           -- {{duration in days}}
			show = 'd',
			disp = 'disp_raw',
			duration = true,
		},
		duration_days_nts = {       -- {{duration in days nts}}
			show = 'd',
			disp = 'disp_raw',
			format = 'format_commas',
			sortable = 'on',
			duration = true,
		},
		age_full_years = {          -- {{age}}
			show = 'y',
			abbr = 'abbr_raw',
			flag = 'usesCurrent',
			omitZero = true,
			range = 'no',
		},
		age_full_years_nts = {      -- {{age nts}}
			show = 'y',
			abbr = 'abbr_raw',
			format = 'format_commas',
			sortable = 'on',
		},
		age_in_years = {            -- {{age in years}}
			show = 'y',
			abbr = 'abbr_raw',
			negative = 'error',
			range = 'dash',
		},
		age_in_years_nts = {        -- {{age in years nts}}
			show = 'y',
			abbr = 'abbr_raw',
			negative = 'error',
			range = 'dash',
			format = 'format_commas',
			sortable = 'on',
		},
		age_infant = {              -- {{age for infant}}
			-- Do not set show because special processing is done later.
			abbr = yes(args.abbr) and 'abbr_infant' or 'abbr_off',
			disp = 'disp_age',
			sep = 'sep_space',
			sortable = 'on',
		},
		age_m = {                   -- {{age in months}}
			show = 'm',
			disp = 'disp_raw',
		},
		age_w = {                   -- {{age in weeks}}
			show = 'w',
			disp = 'disp_raw',
		},
		age_wd = {                  -- {{age in weeks and days}}
			show = 'wd',
		},
		age_yd = {                  -- {{age in years and days}}
			show = 'yd',
			format = 'format_commas',
			sep = args.sep ~= 'and' and 'sep_comma' or nil,
		},
		age_yd_nts = {              -- {{age in years and days nts}}
			show = 'yd',
			format = 'format_commas',
			sep = args.sep ~= 'and' and 'sep_comma' or nil,
			sortable = 'on',
		},
		age_ym = {                  -- {{age in years and months}}
			show = 'ym',
			sep = 'sep_comma',
		},
		age_ymd = {                 -- {{age in years, months and days}}
			show = 'ymd',
			range = true,
		},
		age_ymwd = {                -- {{age in years, months, weeks and days}}
			show = 'ymwd',
			wantMixture = true,
		},
	}
	local spec = specs[name]
	if not spec then
		return message('mt-template-bad-name')
	end
	if name == 'age_days' then
		local su = stripToNil(args['show unit'])
		if su then
			if su == 'abbr' or su == 'full' then
				spec.disp = nil
				spec.abbr = su == 'abbr' and 'abbr_on' or nil
			end
		end
	end
	local partial, autofill
	local range = stripToNil(args.range) or spec.range
	if range then
		-- Suppose partial dates are used and age could be 11 or 12 years.
		-- "|range=" (empty value) has no effect (spec is used).
		-- "|range=yes" or spec.range == true sets range = true (gives "11 or 12")
		-- "|range=dash" or spec.range == 'dash' sets range = 'dash' (gives "11–12").
		-- "|range=no" or spec.range == 'no' sets range = nil and fills each date in the diff (gives "12").
		--     ("on" is equivalent to "yes", and "off" is equivalent to "no").
		-- "|range=OTHER" sets range = nil and rejects partial dates.
		range = ({ dash = 'dash', off = 'no', no = 'no', [true] = true })[range] or yes(range)
		if range then
			partial = true  -- accept partial dates with a possible age range for the result
			if range == 'no' then
				autofill = true  -- missing month/day in first or second date are filled from other date or 1
				range = nil
			end
		end
	end
	local getopt = {
		fix = yes(args.fix),
		flag = stripToNil(args.flag) or spec.flag,
		omitZero = spec.omitZero,
		partial = partial,
		wantMixture = spec.wantMixture,
	}
	local date1, date2 = getDates(frame, getopt)
	if type(date1) == 'string' then
		return date1
	end
	local format = stripToNil(args.format)
	local spell = spellOptions[format]
	if format then
		format = 'format_' .. format
	elseif name == 'age_days' and getopt.textdates then
		format = 'format_commas'
	end
	local parms = {
		diff = date2:subtract(date1, { fill = autofill }),
		wantDuration = spec.duration or yes(args.duration),
		range = range,
		wantSc = yes(args.sc),
		show = args.show == 'hide' and 'hide' or spec.show,
		abbr = spec.abbr,
		disp = spec.disp,
		extra = makeExtra(args, getopt.usesCurrent and format ~= 'format_raw'),
		format = format or spec.format,
		round = yes(args.round),
		sep = spec.sep,
		sortable = translateParameters.sortable[args.sortable or spec.sortable],
		spell = spell,
	}
	if (spec.negative or frame.args.negative) == 'error' and parms.diff.isnegative then
		return message('mt-date-wrong-order')
	end
	return from_en(dateDifference(parms))
end

local function bda(frame)
	-- Implement [[Template:Birth date and age]].
	local args = frame:getParent().args
	local options = {
		missing1 = 'mt-need-valid-bd',
		noMissing = true,
		single = true,
	}
	local date = getDates(frame, options)
	if type(date) == 'string' then
		return date  -- error text
	end
	local Date = getExports(frame)
	local diff = Date('currentdate') - date
	if diff.isnegative or diff.years > 150 then
		return message('mt-invalid-bd-age')
	end
	local disp, show = 'disp_raw', 'y'
	if diff.years < 2 then
		disp = 'disp_age'
		if diff.years == 0 and diff.months == 0 then
			show = 'd'
		else
			show = 'm'
		end
	end
	local df = stripToNil(args.df)  -- day first (dmy); default is month first (mdy)
	local result = '(<span class="bday">%-Y-%m-%d</span>) </span>' ..
					(df and '%-d %B %-Y' or '%B %-d, %-Y')
	result = from_en('<span style="display:none"> ' ..
		date:text(result) ..
		'<span class="noprint ForceAgeToShow"> ' ..
		mtext['txt-age'] ..
		dateDifference({
			diff = diff,
			show = show,
			abbr = 'abbr_off',
			disp = disp,
			sep = 'sep_space',
		}) ..
		')</span>')
	local warnings = tonumber(frame.args.warnings)
	if warnings and warnings > 0 then
		local good = {
			df = true,
			mf = true,
			day = true,
			day1 = true,
			month = true,
			month1 = true,
			year = true,
			year1 = true,
		}
		local invalid
		local imax = options.textdates and 1 or 3
		for k, _ in pairs(args) do
			if type(k) == 'number' then
				if k > imax then
					invalid = tostring(k)
					break
				end
			else
				if not good[k] then
					invalid = k
					break
				end
			end
		end
		if invalid then
			result = result .. message({ 'mt-bad-param1', invalid }, 'warning')
		end
	end
	return result
end

local function dda(frame)
	-- Implement [[Template:Death date and age]].
	local args = frame:getParent().args
	local options = {
		missing1 = 'mt-need-valid-dd',
		missing2 = 'mt-need-valid-bd2',
		noMissing = true,
		partial = true,
	}
	local date1, date2 = getDates(frame, options)
	if type(date1) == 'string' then
		return date1
	end
	local diff = date1 - date2
	if diff.isnegative then
		return message('mt-dd-wrong-order')
	end
	local Date = getExports(frame)
	local today = Date('currentdate') + 1  -- one day in future allows for timezones
	if date1 > today then
		return message('mt-dd-future')
	end
	local years
	if diff.partial then
		years = diff.partial.years
		years = type(years) == 'table' and years[2] or years
	else
		years = diff.years
	end
	if years > 150 then
		return message('mt-invalid-dates-age')
	end
	local df = stripToNil(args.df)  -- day first (dmy); default is month first (mdy)
	local result
	if date1.day then  -- y, m, d known
		result = (df and
			'%-d %B %-Y' or
			'%B %-d, %-Y') ..
			'<span style="display:none">(%-Y-%m-%d)</span>'
	elseif date1.month then  -- y, m known; d unknown
		result =
			'%B %-Y' ..
			'<span style="display:none">(%-Y-%m-00)</span>'
	else  -- y known; m, d unknown
		result =
			'%-Y' ..
			'<span style="display:none">(%-Y-00-00)</span>'
	end
	result = from_en(date1:text(result) ..
		mtext['txt-aged'] ..
		dateDifference({
			diff = diff,
			show = 'y',
			abbr = 'abbr_off',
			disp = 'disp_raw',
			range = 'dash',
			sep = 'sep_space',
		}) ..
		')')
	local warnings = tonumber(frame.args.warnings)
	if warnings and warnings > 0 then
		local good = {
			df = true,
			mf = true,
		}
		local invalid
		local imax = options.textdates and 2 or 6
		for k, _ in pairs(args) do
			if type(k) == 'number' then
				if k > imax then
					invalid = tostring(k)
					break
				end
			else
				if not good[k] then
					invalid = k
					break
				end
			end
		end
		if invalid then
			result = result .. message({ 'mt-bad-param1', invalid }, 'warning')
		end
	end
	return result
end

local function dateToGsd(frame)
	-- Implement [[Template:Gregorian serial date]].
	-- Return Gregorian serial date of the given date, or the current date.
	-- The returned value is negative for dates before 1 January 1 AD
	-- despite the fact that GSD is not defined for such dates.
	local date = getDates(frame, { wantMixture=true, single=true })
	if type(date) == 'string' then
		return date
	end
	return tostring(date.gsd)
end

local function jdToDate(frame)
	-- Return formatted date from a Julian date.
	-- The result includes a time if the input includes a fraction.
	-- The word 'Julian' is accepted for the Julian calendar.
	local Date = getExports(frame)
	local args = frame:getParent().args
	local date = Date('juliandate', args[1], args[2])
	if date then
		return from_en(date:text())
	end
	return message('mt-need-jdn')
end

local function dateToJd(frame)
	-- Return Julian date (a number) from a date which may include a time,
	-- or the current date ('currentdate') or current date and time ('currentdatetime').
	-- The word 'Julian' is accepted for the Julian calendar.
	local Date = getExports(frame)
	local args = frame:getParent().args
	local date = Date(args[1], args[2], args[3], args[4], args[5], args[6], args[7])
	if date then
		return tostring(date.jd)
	end
	return message('mt-need-valid-ymd-current')
end

local function timeInterval(frame)
	-- Implement [[Template:Time interval]].
	-- There are two positional arguments: date1, date2.
	-- The default for each is the current date and time.
	-- Result is date2 - date1 formatted.
	local Date = getExports(frame)
	local args = frame:getParent().args
	local parms = {
		extra = makeExtra(args),
		wantDuration = yes(args.duration),
		range = yes(args.range) or (args.range == 'dash' and 'dash' or nil),
		wantSc = yes(args.sc),
	}
	local fix = yes(args.fix) and 'fix' or ''
	local date1 = Date(fix, 'partial', stripToNil(args[1]) or 'currentdatetime')
	if not date1 then
		return message('mt-invalid-start')
	end
	local date2 = Date(fix, 'partial', stripToNil(args[2]) or 'currentdatetime')
	if not date2 then
		return message('mt-invalid-end')
	end
	parms.diff = date2 - date1
	for argname, translate in pairs(translateParameters) do
		local parm = stripToNil(args[argname])
		if parm then
			parm = translate[parm]
			if parm == nil then  -- test for nil because false is a valid setting
				return message({ 'mt-bad-param2', argname, args[argname] })
			end
			parms[argname] = parm
		end
	end
	if parms.round then
		local round = parms.round
		local show = parms.show
		if round ~= 'on' then
			if show then
				if show.id ~= round then
					return message({ 'mt-conflicting-show', args.show, args.round })
				end
			else
				parms.show = translateParameters.show[round]
			end
		end
		parms.round = true
	end
	return from_en(dateDifference(parms))
end

return {
	age_generic = ageGeneric,           -- can emulate several age templates
	birth_date_and_age = bda,           -- Template:Birth_date_and_age
	death_date_and_age = dda,           -- Template:Death_date_and_age
	gsd = dateToGsd,                    -- Template:Gregorian_serial_date
	extract = dateExtract,              -- Template:Extract
	jd_to_date = jdToDate,              -- Template:?
	JULIANDAY = dateToJd,               -- Template:JULIANDAY
	time_interval = timeInterval,       -- Template:Time_interval
}