Módulo:Date

De Hispanopedia

Nota: Éste módulo es una traducción de Module:Date lo que significa que cualquier módulo que dependa de Module:Date se puede utilizar con éste módulo sin ningún problema (siempre que el módulo en cuestión se adapte para entender fechas en español si fuese necesario). Es por eso que a pesar de que las fechas que devuelve al utilizar :text() son en español todos los nombres de los métodos internos y los parámetros se han mantenido en el idioma original.


Este módulo provee funciones relacionadas con el manejo de fechas para que puedan usarse en otros módulos. El módulo soporta fechas en el Calendario Gregoriano y en el calendario Juliano, desde el 9999 a. C. al 9999 d. C.. Los calendarios son prolépticos, es decir, se aplican incluso antes de su creación sin irregularidades.

Una fecha , con su hora opcional, se puede especificar en varios formatos diferentes y se puede formatear para su impresión usando otra variedad de formatos, por ejemplo '1 de abril de 2016' o el 'abril 1, 2016'. Las propiedades de la fecha incluyen su Fecha juliana y los días desde el 1 A.D. así como el día de la semana y el día del año.

También es posible comparar fechas (por ejemplo, fecha1 <= fecha2), y se les pueden aplicar operadores como la suma o la resta (por ejemplo, fecha + '3 meses' o fecha1 - fecha2). Estas operaciones funcionan con fechas de los calendarios Juliano y Gregoriano pero será nil si intentas operar con dos fechas de diferentes calendarios.

El módulo provee los siguientes objetos.

Export Descripción
_current Tabla con el año, mes, día, hora, minuto y segundo actuales.
_Date Función que devuelve una tabla para una fecha concreta.
_days_in_month Función que retorna el número de días en un mes.

En Módulo:Date/ejemplos se encuentran los ejemplos de uso del módulo y en Módulo discusión:Date/ejemplos está el resultado de su ejecución.

Formato de la salida

Es posible formatear la representación textual de la fecha.

local Date = require('Módulo:Date')._Date
local text = Date(2016, 7, 1):text()          -- devolvería '1 de julio de 2016'
local text = Date(2016, 7, 1):text('%-d de %B')  -- devolvería '1 de julio'
local text = Date('1 de julio de 2016'):text('mdy')  -- devolvería 'julio 1, 2016'

Los siguientes son los códigos de formato simplificados disponibles.

Código Resultado
hm horas:minutos, utilizando "am" o "pm" o una variante especificada (14:30 o 2:30 pm o lo especificado)
hms horas:minutos:segundos (14:30:45)
ymd año-mes-día (2016-07-01)
mdy mes día, año (julio 1, 2016)
dmy día de mes de año (1 de julio de 2016)

También están disponibles los siguientes códigos están disponibles (similar a los utilizados por strftime).

Código Resultado
%a Abreviación del día: Lu, Ma, ...
%A Nombre del día: lunes, martes, ...
%u Día de la semana: de 1 a 7 (Lunes a Domingo)
%w Día de la semana: de 0 a 6 (Domingo a Sábado)
%d Día del mes relleno con ceros: 01 a 31
%b Abreviación del mes: ene a dic
%B Nombre del mes: enero a diciembre
%m Mes relleno con ceros: 01 a 12
%Y Año relleno con ceros: 0012, 0120, 1200
%H Hora (reloj de 24 horas) rellena con ceros: 00 a 23
%I Hora (reloj de 12 horas) rellena con ceros: 01 a 12
%p AM o PM como con las opciones
%M Minutos rellenos con ceros: 00 a 59
%S Segundos rellenos con ceros: 00 a 59
%j Día del año relleno con ceros: 001 a 366
%-d Día del mes: 1 a 31
%-m Mes: 1 a 12
%-Y Año: 12, 120, 1200
%-H Hora: 0 a 23
%-M Minutos: 0 a 59
%-S Segundos: 0 a 59
%-j Día del año: 1 a 366
%-I Hora: 1 a 12
%% %

Además también se puede utilizar %{property} (donde property es una de las propiedades de la fecha).

Por ejemplo, una fecha como Date('1 de febrero de 2015 14:30:45 d. C.') tiene las siguientes propiedades.

Código Resultado
%{calendar} Gregorian
%{year} 2015
%{month} 2
%{day} 1
%{hour} 14
%{minute} 30
%{second} 45
%{dayabbr} do
%{dayname} Domingo
%{dayofweek} 0
%{dow} 0 (igual que 'dayofweek')
%{dayofweekiso} 7
%{dowiso} 7 (igual que 'dayofweekiso')
%{dayofyear} 32
%{era} d. C.
%{gsd} 735630 (números de días desde 1 de enero de 1 d. C.; el primero es el 1)
%{juliandate} 2457055.1046875 (Fecha juliana)
%{jd} 2457055.1046875 (igual que 'juliandate')
%{isleapyear} false
%{monthdays} 28
%{monthabbr} feb
%{monthname} febrero

También hay disponibles algunos atajos. Dada fecha = Date('1 feb 2015 14:30'), obtendremos los siguientes resultados.

Código Descripción Resultado del ejemplo Formato equivalente
fecha:text('%c') fecha y hora 2:30 pm 1 de febrero de 2015 %-I:%M %p %-d %B %-Y %{era}
fecha:text('%C') fecha y hora 1 de febrero de 2015, 14:30 %-d de %B de %-Y, %-H:%-M:%-S
fecha:text('%x') fecha 1 de febrero de 2015 %-d %B %-Y %{era}
fecha:text('%X') hora 2:30 pm %-I:%M %p

Fecha Juliana

El siguiente código contiene un ejemplo de la conversión de una Fecha juliana y la posterior obtención de información sobre esa fecha.

-- Código                                               -- Resultado
Date = require('Módulo:Date')._Date
fecha = Date('juliandate', 320)
número = fecha.gsd                                       -- -1721105
número = fecha.jd                                        -- 320
texto = fecha.dayname                                    -- sábado
texto = fecha:text()                                     -- 9 de octubre de 4713&nbsp;a. C.
texto = fecha:text('%Y-%m-%d')                           -- 4713-10-09
texto = fecha:text('%{era} %Y-%m-%d')                    -- a. C. 4713-10-09
texto = fecha:text('%Y-%m-%d %{era}')                    -- 4713-10-09&nbsp;BC
texto = fecha:text('%Y-%m-%d %{era}', 'era=B.C.E.')      -- 4713-10-09&nbsp;B.C.E.
texto = fecha:text('%Y-%m-%d', 'era=BCNEGATIVE')         -- -4712-10-09
texto = fecha:text('%Y-%m-%d', 'era=BCMINUS')            -- −4712-10-09 (utiliza el símbolo menos de Unicode U+2212)
texto = Date('juliandate',320):text('%{gsd} %{jd}')     -- -1721105 320
texto = Date('oct 9, 4713 B.C.E.'):text('%{gsd} %{jd}') -- -1721105 320
texto = Date(-4712,10,9):text('%{gsd} %{jd}')           -- -1721105 320

Diferencia de fechas

La diferencia entre dos fechas se puede determinar utilizando fecha1 - fecha2. El resultado es válido si las dos fechas utilizan el mismo calendario siendo 'nil' en otro caso. Es posible calcular una edad o una duración a partir de la diferencia entre dos fechas.

Por ejemplo:

-- Código                                    -- Resultado
Date = require('Módulo:Date')._Date
fecha1 = Date('21 mar 2015')
fecha2 = Date('4 dic 1999')
diff = fecha1 - fecha2
d = diff.age_days                            -- 5586
y, m, d = diff.years, diff.months, diff.days -- 15, 3, 17 (15 años + 3 meses + 17 días)
y, m, d = diff:age('ymd')                    -- 15, 3, 17
y, m, w, d = diff:age('ymwd')                -- 15, 3, 2, 3 (15 años + 3 meses + 2 semanas + 3 días)
y, m, w, d = diff:duration('ymwd')           -- 15, 3, 2, 4
d = diff:duration('d')                       -- 5587 (duración en días incluyendo el último día)

Una diferencia de fechas mantiene las fechas originales pero están cambiadas de tal forma que diff.date1 >= diff.date2 (siendo siempre diff.date1 la más reciente). Esto se muestra a continuación.

fecha1 = Date('21 mar 2015')
fecha2 = Date('4 dic 1999')
diff = fecha1 - fecha2
neg = diff.isnegative                        -- false
text = diff.date1:text()                     -- 21 de marzo de 2015
text = diff.date2:text()                     -- 4 de diciembre de 1999
diff = fecha2 - fecha1
neg = diff.isnegative                        -- true (se han cambiado las fechas de orden)
text = diff.date1:text()                     -- 21 de marzo de 2015
text = diff.date2:text()                     -- 4 de diciembre de 1999

Una diferencia de fechas también guarda la diferencia de tiempo:

fecha1 = Date('8 mar 2016 0:30:45')
fecha2 = Date('19 ene 2014 22:55')
diff = fecha1 - fecha2
y, m, d = diff.years, diff.months, diff.days      -- 2, 1, 17
H, M, S = diff.hours, diff.minutes, diff.seconds  -- 1, 35, 45

Una diferencia de fechas también se puede añadir o restar a una fecha.

fecha1 = Date('8 mar 2016 0:30:45')
fecha2 = Date('19 ene 2014 22:55')
diff = fecha1 - fecha2
fecha3 = fecha2 + diff
fecha4 = fecha1 - diff
texto = date3:text('ymd hms')        -- 2016-03-08 00:30:45
texto = date4:text('ymd hms')        -- 2014-01-19 22:55:00
igualdad = (fecha1 == fecha3)        -- true
igualdad = (fecha2 == fecha4)        -- true

Los métodos de edad 'age' y duración 'duration' aceptan un código que identifica los componentes a retornar. En el caso de la duración se incluye un día extra (el último).

Código Valores retornados
'ymwd' años, meses, semanas, días
'ymd' años, meses, días
'ym' años, meses
'y' años
'm' meses
'wd' semanas, días
'w' semanas
'd' días

Compatibilidad

Este módulo implementa las funciones de Módulo:Fecha y Módulo:Fechas, los tests de ambos módulos se encuentran funcionando contra este en Módulo:Date/tests y el resultado de su ejecución en Módulo discusión:Date/tests.


-- Date functions for use by other modules.
-- I18N and time zones are not supported.

local MINUS = '−' -- Unicode U+2212 MINUS SIGN
local floor = math.floor

local Date, DateDiff, diffmt -- forward declarations
local uniq = { 'unique identifier' }

local function is_date(t)
  -- The system used to make a date read-only means there is no unique
  -- metatable that is conveniently accessible to check.
  return type(t) == 'table' and t._id == uniq
end

local function is_diff(t)
  return type(t) == 'table' and getmetatable(t) == diffmt
end

local function _list_join(list, sep)
  return table.concat(list, sep)
end

local function collection()
  -- Return a table to hold items.
  return {
    n = 0,
    add = function (self, item)
      self.n = self.n + 1
      self[self.n] = item
    end,
    join = _list_join,
  }
end

local function strip_to_nil(text)
  -- If text is a string, return its trimmed content, or nil if empty.
  -- Otherwise return text (convenient when Date fields are provided from
  -- another module which may pass a string, a number, or another type).
  if type(text) == 'string' then
    text = text:match('(%S.-)%s*$')
  end
  return text
end

local function is_leap_year(year, calname)
  -- Return true if year is a leap year.
  if calname == 'Juliano' then
    return year % 4 == 0
  end
  return (year % 4 == 0 and year % 100 ~= 0) or year % 400 == 0
end

local function days_in_month(year, month, calname)
  -- Return number of days (1..31) in given month (1..12).
  if month == 2 and is_leap_year(year, calname) then
    return 29
  end
  return ({ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 })[month]
end

local function h_m_s(time)
  -- Return hour, minute, second extracted from fraction of a day.
  time = floor(time * 24 * 3600 + 0.5) -- number of seconds
  local second = time % 60
  time = floor(time / 60)
  return floor(time / 60), time % 60, second
end

local function hms(date)
  -- Return fraction of a day from date's time, where (0 <= fraction < 1)
  -- if the values are valid, but could be anything if outside range.
  return (date.hour + (date.minute + date.second / 60) / 60) / 24
end

local function julian_date(date)
  -- Return jd, jdz from a Julian or Gregorian calendar date where
  -- jd = Julian date and its fractional part is zero at noon
  -- jdz = same, but assume time is 00:00:00 if no time given
  -- http://www.tondering.dk/claus/cal/julperiod.php#formula
  -- Testing shows this works for all dates from year -9999 to 9999!
  -- JDN 0 is the 24-hour period starting at noon UTC on Monday
  -- 1 January 4713 BC = (-4712, 1, 1) Julian calendar
  -- 24 November 4714 BC = (-4713, 11, 24) Gregorian calendar
  local offset
  local a = floor((14 - date.month)/12)
  local y = date.year + 4800 - a
  if date.calendar == 'Juliano' then
    offset = floor(y/4) - 32083
  else
    offset = floor(y/4) - floor(y/100) + floor(y/400) - 32045
  end
  local m = date.month + 12*a - 3
  local jd = date.day + floor((153*m + 2)/5) + 365*y + offset
  if date.hastime then
    jd = jd + hms(date) - 0.5
    return jd, jd
  end
  return jd, jd - 0.5
end

local function set_date_from_jd(date)
  -- Set the fields of table date from its Julian date field.
  -- Return true if date is valid.
  -- http://www.tondering.dk/claus/cal/julperiod.php#formula
  -- This handles the proleptic Julian and Gregorian calendars.
  -- Negative Julian dates are not defined but they work.
  local calname = date.calendar
  local low, high -- min/max limits for date ranges −9999-01-01 to 9999-12-31
  if calname == 'Gregoriano' then
    low, high = -1930999.5, 5373484.49999
  elseif calname == 'Juliano' then
    low, high = -1931076.5, 5373557.49999
  else
    return
  end
  local jd = date.jd
  if not (type(jd) == 'number' and low <= jd and jd <= high) then
    return
  end
  local jdn = floor(jd)
  if date.hastime then
    local time = jd - jdn -- 0 <= time < 1
    if time >= 0.5 then -- if at or after midnight of next day
      jdn = jdn + 1
      time = time - 0.5
    else
      time = time + 0.5
    end
    date.hour, date.minute, date.second = h_m_s(time)
  else
    date.second = 0
    date.minute = 0
    date.hour = 0
  end
  local b, c
  if calname == 'Juliano' then
    b = 0
    c = jdn + 32082
  else -- Gregorian
    local a = jdn + 32044
    b = floor((4*a + 3)/146097)
    c = a - floor(146097*b/4)
  end
  local d = floor((4*c + 3)/1461)
  local e = c - floor(1461*d/4)
  local m = floor((5*e + 2)/153)
  date.day = e - floor((153*m + 2)/5) + 1
  date.month = m + 3 - 12*floor(m/10)
  date.year = 100*b + d - 4800 + floor(m/10)
  return true
end

local function fix_numbers(numbers, y, m, d, H, M, S, partial, hastime, calendar)
  -- Put the result of normalizing the given values in table numbers.
  -- The result will have valid m, d values if y is valid; caller checks y.
  -- The logic of PHP mktime is followed where m or d can be zero to mean
  -- the previous unit, and -1 is the one before that, etc.
  -- Positive values carry forward.
  local date
  if not (1 <= m and m <= 12) then
    date = Date(y, 1, 1)
    if not date then return end
    date = date + ((m - 1) .. 'm')
    y, m = date.year, date.month
  end
  local days_hms
  if not partial then
    if hastime and H and M and S then
      if not (0 <= H and H <= 23 and
          0 <= M and M <= 59 and
          0 <= S and S <= 59) then
        days_hms = hms({ hour = H, minute = M, second = S })
      end
    end
    if days_hms or not (1 <= d and d <= days_in_month(y, m, calendar)) then
      date = date or Date(y, m, 1)
      if not date then return end
      date = date + (d - 1 + (days_hms or 0))
      y, m, d = date.year, date.month, date.day
      if days_hms then
        H, M, S = date.hour, date.minute, date.second
      end
    end
  end
  numbers.year = y
  numbers.month = m
  numbers.day = d
  if days_hms then
    -- Don't set H unless it was valid because a valid H will set hastime.
    numbers.hour = H
    numbers.minute = M
    numbers.second = S
  end
end

local function set_date_from_numbers(date, numbers, options)
  -- Set the fields of table date from numeric values.
  -- Return true if date is valid.
  if type(numbers) ~= 'table' then
    return
  end
  local y = numbers.year or date.year
  local m = numbers.month or date.month
  local d = numbers.day or date.day
  local H = numbers.hour
  local M = numbers.minute or date.minute or 0
  local S = numbers.second or date.second or 0
  local need_fix
  if y and m and d then
    date.partial = nil
    if not (-9999 <= y and y <= 9999 and
      1 <= m and m <= 12 and
      1 <= d and d <= days_in_month(y, m, date.calendar)) then
        if not date.want_fix then
          return
        end
        need_fix = true
    end
  elseif y and date.partial then
    if d or not (-9999 <= y and y <= 9999) then
      return
    end
    if m and not (1 <= m and m <= 12) then
      if not date.want_fix then
        return
      end
      need_fix = true
    end
  else
    return
  end
  if date.partial then
    H = nil -- ignore any time
    M = nil
    S = nil
  else
    if H then
      -- It is not possible to set M or S without also setting H.
      date.hastime = true
    else
      H = 0
    end
    if not (0 <= H and H <= 23 and
        0 <= M and M <= 59 and
        0 <= S and S <= 59) then
      if date.want_fix then
        need_fix = true
      else
        return
      end
    end
  end
  date.want_fix = nil
  if need_fix then
    fix_numbers(numbers, y, m, d, H, M, S, date.partial, date.hastime, date.calendar)
    return set_date_from_numbers(date, numbers, options)
  end
  date.year = y -- -9999 to 9999 ('n BC' → year = 1 - n)
  date.month = m -- 1 to 12 (may be nil if partial)
  date.day = d -- 1 to 31 (* = nil if partial)
  date.hour = H -- 0 to 59 (*)
  date.minute = M -- 0 to 59 (*)
  date.second = S -- 0 to 59 (*)
  if type(options) == 'table' then
    for _, k in ipairs({ 'am', 'era', 'format' }) do
      if options[k] then
        date.options[k] = options[k]
      end
    end
  end
  return true
end

local function make_option_table(options1, options2)
  -- If options1 is a string, return a table with its settings, or
  -- if it is a table, use its settings.
  -- Missing options are set from table options2 or defaults.
  -- If a default is used, a flag is set so caller knows the value was not intentionally set.
  -- Valid option settings are:
  -- am: 'am', 'a.m.', 'AM', 'A.M.'
  -- 'pm', 'p.m.', 'PM', 'P.M.' (each has same meaning as corresponding item above)
  -- era: 'BCMINUS', 'BCNEGATIVE', 'BC', 'B.C.', 'BCE', 'B.C.E.', 'AD', 'A.D.', 'CE', 'C.E.'
  -- Option am = 'am' does not mean the hour is AM; it means 'am' or 'pm' is used, depending on the hour,
  -- and am = 'pm' has the same meaning.
  -- Similarly, era = 'BC' means 'BC' is used if year <= 0.
  -- BCMINUS displays a MINUS if year < 0 and the display format does not include %{era}.
  -- BCNEGATIVE is similar but displays a hyphen.
  local result = { bydefault = {} }
  if type(options1) == 'table' then
    result.am = options1.am
    result.era = options1.era
  elseif type(options1) == 'string' then
    -- Example: 'am:AM era:BC' or 'am=AM era=BC'.
    for item in options1:gmatch('%S+') do
      local lhs, rhs = item:match('^(%w+)[:=](.+)$')
      if lhs then
        result[lhs] = rhs
      end
    end
  end
  options2 = type(options2) == 'table' and options2 or {}
  local defaults = { am = 'am', era = 'BC' }
  for k, v in pairs(defaults) do
    if not result[k] then
      if options2[k] then
        result[k] = options2[k]
      else
        result[k] = v
        result.bydefault[k] = true
      end
    end
  end
  return result
end

local ampm_options = {
  -- lhs = input text accepted as an am/pm option
  -- rhs = code used internally
  ['am'] = 'am',
  ['AM'] = 'AM',
  ['a.m.'] = 'a.m.',
  ['A.M.'] = 'A.M.',
  ['pm'] = 'am', -- same as am
  ['PM'] = 'AM',
  ['p.m.'] = 'a.m.',
  ['P.M.'] = 'A.M.',
}

local era_text = {
  -- Text for displaying an era with a positive year (after adjusting
  -- by replacing year with 1 - year if date.year <= 0).
  -- options.era = { year<=0 , year>0 }
  ['BCMINUS'] = { 'BC' , '' , isbc = true, sign = MINUS },
  ['BCNEGATIVE'] = { 'BC' , '' , isbc = true, sign = '-' },
  ['BC'] = { 'BC' , '' , isbc = true },
  ['B.C.'] = { 'B.C.' , '' , isbc = true },
  ['BCE'] = { 'BCE' , '' , isbc = true },
  ['B.C.E.'] = { 'B.C.E.', '' , isbc = true },
  ['AD'] = { 'BC' , 'AD' },
  ['A.D.'] = { 'B.C.' , 'A.D.' },
  ['CE'] = { 'BCE' , 'CE' },
  ['C.E.'] = { 'B.C.E.', 'C.E.' },
}

local function get_era_for_year(era, year)
  return (era_text[era] or era_text['BC'])[year > 0 and 2 or 1] or ''
end

local function strftime(date, format, options)
  -- Return date formatted as a string using codes similar to those
  -- in the C strftime library function.
  local sformat = string.format
  local shortcuts = {
    ['%c'] = '%-I:%M %p %-d %B %-Y %{era}', -- date and time: 2:30 pm 1 April 2016
    ['%x'] = '%-d %B %-Y %{era}', -- date: 1 April 2016
    ['%X'] = '%-I:%M %p', -- time: 2:30 pm
  }
  if shortcuts[format] then
    format = shortcuts[format]
  end
  local codes = {
    a = { field = 'dayabbr' },
    A = { field = 'dayname' },
    b = { field = 'monthabbr' },
    B = { field = 'monthname' },
    u = { fmt = '%d' , field = 'dowiso' },
    w = { fmt = '%d' , field = 'dow' },
    d = { fmt = '%02d', fmt2 = '%d', field = 'day' },
    m = { fmt = '%02d', fmt2 = '%d', field = 'month' },
    Y = { fmt = '%04d', fmt2 = '%d', field = 'year' },
    H = { fmt = '%02d', fmt2 = '%d', field = 'hour' },
    M = { fmt = '%02d', fmt2 = '%d', field = 'minute' },
    S = { fmt = '%02d', fmt2 = '%d', field = 'second' },
    j = { fmt = '%03d', fmt2 = '%d', field = 'dayofyear' },
    I = { fmt = '%02d', fmt2 = '%d', field = 'hour', special = 'hour12' },
    p = { field = 'hour', special = 'am' },
  }
  options = make_option_table(options, date.options)
  local amopt = options.am
  local eraopt = options.era
  local function replace_code(spaces, modifier, id)
--print('spaces:' .. spaces .. ';')
--print('modifier:' .. modifier .. ';')
--print('id:' .. id .. ';')
    local code = codes[id]
    if code then
      local fmt = code.fmt
      if modifier == '-' and code.fmt2 then
        fmt = code.fmt2
      end
      local value = date[code.field]
      if not value then
        return nil -- an undefined field in a partial date
      end
      local special = code.special
      if special then
        if special == 'hour12' then
          value = value % 12
          value = value == 0 and 12 or value
        elseif special == 'am' then
          local ap = ({
            ['a.m.'] = { 'a.m.', 'p.m.' },
            ['AM'] = { 'AM', 'PM' },
            ['A.M.'] = { 'A.M.', 'P.M.' },
          })[ampm_options[amopt]] or { 'am', 'pm' }
          return (spaces == '' and '' or '&nbsp;') .. (value < 12 and ap[1] or ap[2])
        end
      end
      if code.field == 'year' then
        local sign = (era_text[eraopt] or {}).sign
        if not sign or format:find('%{era}', 1, true) then
          sign = ''
          if value <= 0 then
            value = 1 - value
          end
        else
          if value >= 0 then
            sign = ''
          else
            value = -value
          end
        end
        return spaces .. sign .. sformat(fmt, value)
      end
if code.field == 'monthname' then
return spaces .. 'de ' .. (fmt and sformat(fmt, value) or value) .. ' de'
end
      return spaces .. (fmt and sformat(fmt, value) or value)
    end
  end
  local function replace_property(spaces, id)
    if id == 'era' then
      -- Special case so can use local era option.
      local result = get_era_for_year(eraopt, date.year)
      if result == '' then
        return ''
      end
      return (spaces == '' and '' or '&nbsp;') .. result
    end
    local result = date[id]
    if type(result) == 'string' then
      return spaces .. result
    end
    if type(result) == 'number' then
      return spaces .. tostring(result)
    end
    if type(result) == 'boolean' then
      return spaces .. (result and '1' or '0')
    end
    -- This occurs if id is an undefined field in a partial date, or is the name of a function.
    return nil
  end
  local PERCENT = '\127PERCENT\127'

format = format
    :gsub('%%%%', PERCENT)
:gsub('(%s*)%%{(%w+)}', replace_property)

--print(format)
format = format
    :gsub('(%s*)%%(%-?)(%a)', replace_code)
--print(format)

format = format
    :gsub(PERCENT, '%%')

  return format
end

local function _date_text(date, fmt, options)
  -- Return a formatted string representing the given date.
  if not is_date(date) then
    error('date:text: need a date (use "date:text()" with a colon)', 2)
  end
  if type(fmt) == 'string' and fmt:match('%S') then
    if fmt:find('%', 1, true) then
      return strftime(date, fmt, options)
    end
  elseif date.partial then
    fmt = date.month and 'my' or 'y'
  else
    fmt = 'dmy'
    if date.hastime then
      fmt = (date.second > 0 and 'hms ' or 'hm ') .. fmt
    end
  end
  local function bad_format()
    -- For consistency with other format processing, return given format
    -- (or cleaned format if original was not a string) if invalid.
    return mw.text.nowiki(fmt)
  end
  if date.partial then
    -- Ignore days in standard formats like 'ymd'.
    if fmt == 'ym' or fmt == 'ymd' then
      fmt = date.month and '%Y-%m %{era}' or '%Y %{era}'
    elseif fmt == 'my' or fmt == 'dmy' or fmt == 'mdy' then
      fmt = date.month and '%B %-Y %{era}' or '%-Y %{era}'
    elseif fmt == 'y' then
      fmt = date.month and '%-Y %{era}' or '%-Y %{era}'
    else
      return bad_format()
    end
    return strftime(date, fmt, options)
  end
  local function hm_fmt()
    local plain = make_option_table(options, date.options).bydefault.am
    return plain and '%H:%M' or '%-I:%M %p'
  end
  local need_time = date.hastime
  local t = collection()
  for item in fmt:gmatch('%S+') do
    local f
    if item == 'hm' then
      f = hm_fmt()
      need_time = false
    elseif item == 'hms' then
      f = '%H:%M:%S'
      need_time = false
    elseif item == 'ymd' then
      f = '%Y-%m-%d %{era}'
    elseif item == 'mdy' then
      f = '%B %-d, %-Y %{era}'
    elseif item == 'dmy' then
      f = '%-d %B %-Y %{era}'
    else
      return bad_format()
    end
    t:add(f)
  end
  fmt = t:join(' ')
  if need_time then
    fmt = hm_fmt() .. ' ' .. fmt
  end
  return strftime(date, fmt, options)
end

local day_info = {
  -- 0=Sun to 6=Sat
  [0] = { 'do', 'domingo' },
  { 'lu', 'lunes' },
  { 'ma', 'martes' },
  { 'mi', 'miércoles' },
  { 'ju', 'jueves' },
  { 'vi', 'viernes' },
  { 'sa', 'sábado' },
}

local month_info = {
  -- 1=Jan to 12=Dec
  { 'ene', 'enero' },
  { 'feb', 'febrero' },
  { 'mar', 'marzo' },
  { 'abr', 'abril' },
  { 'may', 'mayo' },
  { 'jun', 'junio' },
  { 'jul', 'julio' },
  { 'ago', 'agosto' },
  { 'sep', 'septiembre' },
  { 'oct', 'octubre' },
  { 'nov', 'noviembre' },
  { 'dic', 'diciembre' },
}

local function name_to_number(text, translate)
  if type(text) == 'string' then
    return translate['xx' .. text:lower():gsub('é', 'e'):gsub('á', 'a')]
  end
end

local function day_number(text)
  return name_to_number(text, {
    xxdo = 0, xxdomingo = 0,
    xxlu = 1, xxlunes = 1,
    xxma = 2, xxmartes = 2,
    xxmi = 3, xxmiercoles = 3,
    xxju = 4, xxjueves = 4,
    xxvi = 5, xxviernes = 5,
    xxsat = 6, xxsabado = 6
  })
end

local function month_number(text)
  return name_to_number(text, {
    xxene = 1, xxenero = 1,
    xxfeb = 2, xxfebrero = 2,
    xxmar = 3, xxmarzo = 3,
    xxabr = 4, xxabril = 4,
    xxmay = 5, xxmayo = 5,
    xxjun = 6, xxjunio = 6,
    xxjul = 7, xxjulio = 7,
    xxago = 8, xxagosto = 8,
    xxsep = 9, xxseptiembre = 9, xxsept = 9,
    xxoct = 10, xxoctubre = 10,
    xxnov = 11, xxnoviembre = 11,
    xxdic = 12, xxdiciembre = 12,
  })
end

local function _list_text(list, fmt)
  -- Return a list of formatted strings from a list of dates.
  if not type(list) == 'table' then
    error('date:list:text: need "list:text()" with a colon', 2)
  end
  local result = { join = _list_join }
  for i, date in ipairs(list) do
    result[i] = date:text(fmt)
  end
  return result
end

local function _date_list(date, spec)
  -- Return a possibly empty numbered table of dates meeting the specification.
  -- Dates in the list are in ascending order (oldest date first).
  -- The spec should be a string of form "<count> <day> <op>"
  -- where each item is optional and
  -- count = number of items wanted in list
  -- day = abbreviation or name such as Mon or Monday
  -- op = >, >=, <, <= (default is > meaning after date)
  -- If no count is given, the list is for the specified days in date's month.
  -- The default day is date's day.
  -- The spec can also be a positive or negative number:
  -- -5 is equivalent to '5 <'
  -- 5 is equivalent to '5' which is '5 >'
  if not is_date(date) then
    error('date:list: need a date (use "date:list()" with a colon)', 2)
  end
  local list = { text = _list_text }
  if date.partial then
    return list
  end
  local count, offset, operation
  local ops = {
    ['>='] = { before = false, include = true },
    ['>'] = { before = false, include = false },
    ['<='] = { before = true , include = true },
    ['<'] = { before = true , include = false },
  }
  if spec then
    if type(spec) == 'number' then
      count = floor(spec + 0.5)
      if count < 0 then
        count = -count
        operation = ops['<']
      end
    elseif type(spec) == 'string' then
      local num, day, op = spec:match('^%s*(%d*)%s*(%a*)%s*([<>=]*)%s*$')
      if not num then
        return list
      end
      if num ~= '' then
        count = tonumber(num)
      end
      if day ~= '' then
        local dow = day_number(day:gsub('[sS]$', '')) -- accept plural days
        if not dow then
          return list
        end
        offset = dow - date.dow
      end
      operation = ops[op]
    else
      return list
    end
  end
  offset = offset or 0
  operation = operation or ops['>']
  local datefrom, dayfirst, daylast
  if operation.before then
    if offset > 0 or (offset == 0 and not operation.include) then
      offset = offset - 7
    end
    if count then
      if count > 1 then
        offset = offset - 7*(count - 1)
      end
      datefrom = date + offset
    else
      daylast = date.day + offset
      dayfirst = daylast % 7
      if dayfirst == 0 then
        dayfirst = 7
      end
    end
  else
    if offset < 0 or (offset == 0 and not operation.include) then
      offset = offset + 7
    end
    if count then
      datefrom = date + offset
    else
      dayfirst = date.day + offset
      daylast = date.monthdays
    end
  end
  if not count then
    if daylast < dayfirst then
      return list
    end
    count = floor((daylast - dayfirst)/7) + 1
    datefrom = Date(date, {day = dayfirst})
  end
  for i = 1, count do
    if not datefrom then break end -- exceeds date limits
    list[i] = datefrom
    datefrom = datefrom + 7
  end
  return list
end

-- A table to get the current date/time (UTC), but only if needed.
local current = setmetatable({}, {
  __index = function (self, key)
    local d = os.date('!*t')
    self.year = d.year
    self.month = d.month
    self.day = d.day
    self.hour = d.hour
    self.minute = d.min
    self.second = d.sec
    return rawget(self, key)
  end })

local function extract_date(newdate, text)
  -- Parse the date/time in text and return n, o where
  -- n = table of numbers with date/time fields
  -- o = table of options for AM/PM or AD/BC or format, if any
  -- or return nothing if date is known to be invalid.
  -- Caller determines if the values in n are valid.
  -- A year must be positive ('1' to '9999'); use 'BC' for BC.
  -- In a y-m-d string, the year must be four digits to avoid ambiguity
  -- ('0001' to '9999'). The only way to enter year <= 0 is by specifying
  -- the date as three numeric parameters like ymd Date(-1, 1, 1).
  -- Dates of form d/m/y, m/d/y, y/m/d are rejected as potentially ambiguous.
  local date, options = {}, {}
  if text:sub(-1) == 'Z' then
    -- Extract date/time from a Wikidata timestamp.
    -- The year can be 1 to 16 digits but this module handles 1 to 4 digits only.
    -- Examples: '+2016-06-21T14:30:00Z', '-0000000180-00-00T00:00:00Z'.
    local sign, y, m, d, H, M, S = text:match('^([+%-])(%d+)%-(%d%d)%-(%d%d)T(%d%d):(%d%d):(%d%d)Z$')
    if sign then
      y = tonumber(y)
      if sign == '-' and y > 0 then
        y = -y
      end
      if y <= 0 then
        options.era = 'BCE'
      end
      date.year = y
      m = tonumber(m)
      d = tonumber(d)
      H = tonumber(H)
      M = tonumber(M)
      S = tonumber(S)
      if m == 0 then
        newdate.partial = true
        return date, options
      end
      date.month = m
      if d == 0 then
        newdate.partial = true
        return date, options
      end
      date.day = d
      if H > 0 or M > 0 or S > 0 then
        date.hour = H
        date.minute = M
        date.second = S
      end
      return date, options
    end
    return
  end
  local function extract_ymd(item)
    -- Called when no day or month has been set.
    local y, m, d = item:match('^(%d%d%d%d)%-(%w+)%-(%d%d?)$')
    if y then
      if date.year then
        return
      end
      if m:match('^%d%d?$') then
        m = tonumber(m)
      else
        m = month_number(m)
      end
      if m then
        date.year = tonumber(y)
        date.month = m
        date.day = tonumber(d)
        return true
      end
    end
  end
  local function extract_day_or_year(item)
    -- Called when a day would be valid, or
    -- when a year would be valid if no year has been set and partial is set.
    local number, suffix = item:match('^(%d%d?%d?%d?)(.*)$')
    if number then
      local n = tonumber(number)
      if #number <= 2 and n <= 31 then
        suffix = suffix:lower()
        if suffix == '' or suffix == 'st' or suffix == 'nd' or suffix == 'rd' or suffix == 'th' then
          date.day = n
          return true
        end
      elseif suffix == '' and newdate.partial and not date.year then
        date.year = n
        return true
      end
    end
  end
  local function extract_month(item)
    -- A month must be given as a name or abbreviation; a number could be ambiguous.
    local m = month_number(item)
    if m then
      date.month = m
      return true
    end
  end
  local function extract_time(item)
    local h, m, s = item:match('^(%d%d?):(%d%d)(:?%d*)$')
    if date.hour or not h then
      return
    end
    if s ~= '' then
      s = s:match('^:(%d%d)$')
      if not s then
        return
      end
    end
    date.hour = tonumber(h)
    date.minute = tonumber(m)
    date.second = tonumber(s) -- nil if empty string
    return true
  end
  local item_count = 0
  local index_time
  local function set_ampm(item)
    local H = date.hour
    if H and not options.am and index_time + 1 == item_count then
      options.am = ampm_options[item] -- caller checked this is not nil
      if item:match('^[Aa]') then
        if not (1 <= H and H <= 12) then
          return
        end
        if H == 12 then
          date.hour = 0
        end
      else
        if not (1 <= H and H <= 23) then
          return
        end
        if H <= 11 then
          date.hour = H + 12
        end
      end
      return true
    end
  end
  for item in text:gsub(',', ' '):gsub('de', ' '):gsub('&nbsp;', ' '):gmatch('%S+') do
    item_count = item_count + 1
    if era_text[item] then
      -- Era is accepted in peculiar places.
      if options.era then
        return
      end
      options.era = item
    elseif ampm_options[item] then
      if not set_ampm(item) then
        return
      end
    elseif item:find(':', 1, true) then
      if not extract_time(item) then
        return
      end
      index_time = item_count
    elseif date.day and date.month then
      if date.year then
        return -- should be nothing more so item is invalid
      end
      if not item:match('^(%d%d?%d?%d?)$') then
        return
      end
      date.year = tonumber(item)
    elseif date.day then
      if not extract_month(item) then
        return
      end
    elseif date.month then
      if not extract_day_or_year(item) then
        return
      end
    elseif extract_month(item) then
      options.format = 'mdy'
    elseif extract_ymd(item) then
      options.format = 'ymd'
    elseif extract_day_or_year(item) then
      if date.day then
        options.format = 'dmy'
      end
    else
      return
    end
  end
  if not date.year or date.year == 0 then
    return
  end
  local era = era_text[options.era]
  if era and era.isbc then
    date.year = 1 - date.year
  end
  return date, options
end

local function autofill(date1, date2)
  -- Fill any missing month or day in each date using the
  -- corresponding component from the other date, if present,
  -- or with 1 if both dates are missing the month or day.
  -- This gives a good result for calculating the difference
  -- between two partial dates when no range is wanted.
  -- Return filled date1, date2 (two full dates).
  local function filled(a, b)
    local fillmonth, fillday
    if not a.month then
      fillmonth = b.month or 1
    end
    if not a.day then
      fillday = b.day or 1
    end
    if fillmonth or fillday then -- need to create a new date
      if (fillmonth or a.month) == 2 and (fillday or a.day) == 29 then
        -- Avoid invalid date, for example with {{age|2013|29 Feb 2016}} or {{age|Feb 2013|29 Jan 2015}}.
        if not is_leap_year(a.year, a.calendar) then
          fillday = 28
        end
      end
      a = Date(a, { month = fillmonth, day = fillday })
    end
    return a
  end
  return filled(date1, date2), filled(date2, date1)
end

local function date_add_sub(lhs, rhs, is_sub)
  -- Return a new date from calculating (lhs + rhs) or (lhs - rhs),
  -- or return nothing if invalid.
  -- The result is nil if the calculated date exceeds allowable limits.
  -- Caller ensures that lhs is a date; its properties are copied for the new date.
  if lhs.partial then
    -- Adding to a partial is not supported.
    -- Can subtract a date or partial from a partial, but this is not called for that.
    return
  end
  local function is_prefix(text, word, minlen)
    local n = #text
    return (minlen or 1) <= n and n <= #word and text == word:sub(1, n)
  end
  local function do_days(n)
    local forcetime, jd
    if floor(n) == n then
      jd = lhs.jd
    else
      forcetime = not lhs.hastime
      jd = lhs.jdz
    end
    jd = jd + (is_sub and -n or n)
    if forcetime then
      jd = tostring(jd)
      if not jd:find('.', 1, true) then
        jd = jd .. '.0'
      end
    end
    return Date(lhs, 'juliandate', jd)
  end
  if type(rhs) == 'number' then
    -- Add/subtract days, including fractional days.
    return do_days(rhs)
  end
  if type(rhs) == 'string' then
    -- rhs is a single component like '26m' or '26 months' (with optional sign).
    -- Fractions like '3.25d' are accepted for the units which are handled as days.
    local sign, numstr, id = rhs:match('^%s*([+-]?)([%d%.]+)%s*(%a+)$')
    if sign then
      if sign == '-' then
        is_sub = not (is_sub and true or false)
      end
      local y, m, days
      local num = tonumber(numstr)
      if not num then
        return
      end
      id = id:lower()
      if is_prefix(id, 'years') then
        y = num
        m = 0
      elseif is_prefix(id, 'months') then
        y = floor(num / 12)
        m = num % 12
      elseif is_prefix(id, 'weeks') then
        days = num * 7
      elseif is_prefix(id, 'days') then
        days = num
      elseif is_prefix(id, 'hours') then
        days = num / 24
      elseif is_prefix(id, 'minutes', 3) then
        days = num / (24 * 60)
      elseif is_prefix(id, 'seconds') then
        days = num / (24 * 3600)
      else
        return
      end
      if days then
        return do_days(days)
      end
      if numstr:find('.', 1, true) then
        return
      end
      if is_sub then
        y = -y
        m = -m
      end
      assert(-11 <= m and m <= 11)
      y = lhs.year + y
      m = lhs.month + m
      if m > 12 then
        y = y + 1
        m = m - 12
      elseif m < 1 then
        y = y - 1
        m = m + 12
      end
      local d = math.min(lhs.day, days_in_month(y, m, lhs.calendar))
      return Date(lhs, y, m, d)
    end
  end
  if is_diff(rhs) then
    local days = rhs.age_days
    if (is_sub or false) ~= (rhs.isnegative or false) then
      days = -days
    end
    return lhs + days
  end
end

local full_date_only = {
  dayabbr = true,
  dayname = true,
  dow = true,
  dayofweek = true,
  dowiso = true,
  dayofweekiso = true,
  dayofyear = true,
  gsd = true,
  juliandate = true,
  jd = true,
  jdz = true,
  jdnoon = true,
}

-- Metatable for a date's calculated fields.
local datemt = {
  __index = function (self, key)
    if rawget(self, 'partial') then
      if full_date_only[key] then return end
      if key == 'monthabbr' or key == 'monthdays' or key == 'monthname' then
        if not self.month then return end
      end
    end
    local value
    if key == 'dayabbr' then
      value = day_info[self.dow][1]
    elseif key == 'dayname' then
      value = day_info[self.dow][2]
    elseif key == 'dow' then
      value = (self.jdnoon + 1) % 7 -- day-of-week 0=Sun to 6=Sat
    elseif key == 'dayofweek' then
      value = self.dow
    elseif key == 'dowiso' then
      value = (self.jdnoon % 7) + 1 -- ISO day-of-week 1=Mon to 7=Sun
    elseif key == 'dayofweekiso' then
      value = self.dowiso
    elseif key == 'dayofyear' then
      local first = Date(self.year, 1, 1, self.calendar).jdnoon
      value = self.jdnoon - first + 1 -- day-of-year 1 to 366
    elseif key == 'era' then
      -- Era text (never a negative sign) from year and options.
      value = get_era_for_year(self.options.era, self.year)
    elseif key == 'format' then
      value = self.options.format or 'dmy'
    elseif key == 'gsd' then
      -- GSD = 1 from 00:00:00 to 23:59:59 on 1 January 1 AD Gregorian calendar,
      -- which is from jd 1721425.5 to 1721426.49999.
      value = floor(self.jd - 1721424.5)
    elseif key == 'juliandate' or key == 'jd' or key == 'jdz' then
      local jd, jdz = julian_date(self)
      rawset(self, 'juliandate', jd)
      rawset(self, 'jd', jd)
      rawset(self, 'jdz', jdz)
      return key == 'jdz' and jdz or jd
    elseif key == 'jdnoon' then
      -- Julian date at noon (an integer) on the calendar day when jd occurs.
      value = floor(self.jd + 0.5)
    elseif key == 'isleapyear' then
      value = is_leap_year(self.year, self.calendar)
    elseif key == 'monthabbr' then
      value = month_info[self.month][1]
    elseif key == 'monthdays' then
      value = days_in_month(self.year, self.month, self.calendar)
    elseif key == 'monthname' then
      value = month_info[self.month][2]
    end
    if value ~= nil then
      rawset(self, key, value)
      return value
    end
  end,
}

-- Date operators.
local function mt_date_add(lhs, rhs)
  if not is_date(lhs) then
    lhs, rhs = rhs, lhs -- put date on left (it must be a date for this to have been called)
  end
  return date_add_sub(lhs, rhs)
end

local function mt_date_sub(lhs, rhs)
  if is_date(lhs) then
    if is_date(rhs) then
      return DateDiff(lhs, rhs)
    end
    return date_add_sub(lhs, rhs, true)
  end
end

local function mt_date_concat(lhs, rhs)
  return tostring(lhs) .. tostring(rhs)
end

local function mt_date_tostring(self)
  return self:text()
end

local function mt_date_eq(lhs, rhs)
  -- Return true if dates identify same date/time where, for example,
  -- Date(-4712, 1, 1, 'Juliano') == Date(-4713, 11, 24, 'Gregoriano') is true.
  -- This is called only if lhs and rhs have the same type and the same metamethod.
  if lhs.partial or rhs.partial then
    -- One date is partial; the other is a partial or a full date.
    -- The months may both be nil, but must be the same.
    return lhs.year == rhs.year and lhs.month == rhs.month and lhs.calendar == rhs.calendar
  end
  return lhs.jdz == rhs.jdz
end

local function mt_date_lt(lhs, rhs)
  -- Return true if lhs < rhs, for example,
  -- Date('1 Jan 2016') < Date('06:00 1 Jan 2016') is true.
  -- This is called only if lhs and rhs have the same type and the same metamethod.
  if lhs.partial or rhs.partial then
    -- One date is partial; the other is a partial or a full date.
    if lhs.calendar ~= rhs.calendar then
      return lhs.calendar == 'Juliano'
    end
    if lhs.partial then
      lhs = lhs.partial.first
    end
    if rhs.partial then
      rhs = rhs.partial.first
    end
  end
  return lhs.jdz < rhs.jdz
end

--[[ Examples of syntax to construct a date:
Date(y, m, d, 'julian') default calendar is 'gregorian'
Date(y, m, d, H, M, S, 'julian')
Date('juliandate', jd, 'julian') if jd contains "." text output includes H:M:S
Date('currentdate')
Date('currentdatetime')
Date('1 April 1995', 'julian') parse date from text
Date('1 April 1995 AD', 'julian') using an era sets a flag to do the same for output
Date('04:30:59 1 April 1995', 'julian')
Date(date) copy of an existing date
Date(date, t) same, updated with y,m,d,H,M,S fields from table t
Date(t)     date with y,m,d,H,M,S fields from table t
]]
function Date(...) -- for forward declaration above
  -- Return a table holding a date assuming a uniform calendar always applies
  -- (proleptic Gregorian calendar or proleptic Julian calendar), or
  -- return nothing if date is invalid.
  -- A partial date has a valid year, however its month may be nil, and
  -- its day and time fields are nil.
  -- Field partial is set to false (if a full date) or a table (if a partial date).
  local calendars = { julian = 'Juliano', gregorian = 'Gregoriano' }
  local newdate = {
    _id = uniq,
    calendar = 'Gregoriano', -- default is Gregorian calendar
    hastime = false, -- true if input sets a time
    hour = 0, -- always set hour/minute/second so don't have to handle nil
    minute = 0,
    second = 0,
    options = {},
    list = _date_list,
    subtract = function (self, rhs, options)
      return DateDiff(self, rhs, options)
    end,
    text = _date_text,
  }
  local argtype, datetext, is_copy, jd_number, tnums
  local numindex = 0
  local numfields = { 'year', 'month', 'day', 'hour', 'minute', 'second' }
  local numbers = {}
  for _, v in ipairs({...}) do
    v = strip_to_nil(v)
    local vlower = type(v) == 'string' and v:lower() or nil
    if v == nil then
      -- Ignore empty arguments after stripping so modules can directly pass template parameters.
    elseif calendars[vlower] then
      newdate.calendar = calendars[vlower]
    elseif vlower == 'partial' then
      newdate.partial = true
    elseif vlower == 'fix' then
      newdate.want_fix = true
    elseif is_date(v) then
      -- Copy existing date (items can be overridden by other arguments).
      if is_copy or tnums then
        return
      end
      is_copy = true
      newdate.calendar = v.calendar
      newdate.partial = v.partial
      newdate.hastime = v.hastime
      newdate.options = v.options
      newdate.year = v.year
      newdate.month = v.month
      newdate.day = v.day
      newdate.hour = v.hour
      newdate.minute = v.minute
      newdate.second = v.second
    elseif type(v) == 'table' then
      if tnums then
        return
      end
      tnums = {}
      local tfields = { year=1, month=1, day=1, hour=2, minute=2, second=2 }
      for tk, tv in pairs(v) do
        if tfields[tk] then
          tnums[tk] = tonumber(tv)
        end
        if tfields[tk] == 2 then
          newdate.hastime = true
        end
      end
    else
      local num = tonumber(v)
      if not num and argtype == 'setdate' and numindex == 1 then
        num = month_number(v)
      end
      if num then
        if not argtype then
          argtype = 'setdate'
        end
        if argtype == 'setdate' and numindex < 6 then
          numindex = numindex + 1
          numbers[numfields[numindex]] = num
        elseif argtype == 'juliandate' and not jd_number then
          jd_number = num
          if type(v) == 'string' then
            if v:find('.', 1, true) then
              newdate.hastime = true
            end
          elseif num ~= floor(num) then
            -- The given value was a number. The time will be used
            -- if the fractional part is nonzero.
            newdate.hastime = true
          end
        else
          return
        end
      elseif argtype then
        return
      elseif type(v) == 'string' then
        if v == 'currentdate' or v == 'currentdatetime' or v == 'juliandate' then
          argtype = v
        else
          argtype = 'datetext'
          datetext = v
        end
      else
        return
      end
    end
  end
  if argtype == 'datetext' then
    if tnums or not set_date_from_numbers(newdate, extract_date(newdate, datetext)) then
      return
    end
  elseif argtype == 'juliandate' then
    newdate.partial = nil
    newdate.jd = jd_number
    if not set_date_from_jd(newdate) then
      return
    end
  elseif argtype == 'currentdate' or argtype == 'currentdatetime' then
    newdate.partial = nil
    newdate.year = current.year
    newdate.month = current.month
    newdate.day = current.day
    if argtype == 'currentdatetime' then
      newdate.hour = current.hour
      newdate.minute = current.minute
      newdate.second = current.second
      newdate.hastime = true
    end
    newdate.calendar = 'Gregoriano' -- ignore any given calendar name
  elseif argtype == 'setdate' then
    if tnums or not set_date_from_numbers(newdate, numbers) then
      return
    end
  elseif not (is_copy or tnums) then
    return
  end
  if tnums then
    newdate.jd = nil -- force recalculation in case jd was set before changes from tnums
    if not set_date_from_numbers(newdate, tnums) then
      return
    end
  end
  if newdate.partial then
    local year = newdate.year
    local month = newdate.month
    local first = Date(year, month or 1, 1, newdate.calendar)
    month = month or 12
    local last = Date(year, month, days_in_month(year, month), newdate.calendar)
    newdate.partial = { first = first, last = last }
  else
    newdate.partial = false -- avoid index lookup
  end
  setmetatable(newdate, datemt)
  local readonly = {}
  local mt = {
    __index = newdate,
    __newindex = function(t, k, v) error('date.' .. tostring(k) .. ' is read-only', 2) end,
    __add = mt_date_add,
    __sub = mt_date_sub,
    __concat = mt_date_concat,
    __tostring = mt_date_tostring,
    __eq = mt_date_eq,
    __lt = mt_date_lt,
  }
  return setmetatable(readonly, mt)
end

local function _diff_age(diff, code, options)
  -- Return a tuple of integer values from diff as specified by code, except that
  -- each integer may be a list of two integers for a diff with a partial date, or
  -- return nil if the code is not supported.
  -- If want round, the least significant unit is rounded to nearest whole unit.
  -- For a duration, an extra day is added.
  local wantround, wantduration, wantrange
  if type(options) == 'table' then
    wantround = options.round
    wantduration = options.duration
    wantrange = options.range
  else
    wantround = options
  end
  if not is_diff(diff) then
    local f = wantduration and 'duration' or 'age'
    error(f .. ': need a date difference (use "diff:' .. f .. '()" with a colon)', 2)
  end
  if diff.partial then
    -- Ignore wantround, wantduration.
    local function choose(v)
      if type(v) == 'table' then
        if not wantrange or v[1] == v[2] then
          -- Example: Date('partial', 2005) - Date('partial', 2001) gives
          -- diff.years = { 3, 4 } to show the range of possible results.
          -- If do not want a range, choose the second value as more expected.
          return v[2]
        end
      end
      return v
    end
    if code == 'ym' or code == 'ymd' then
      if not wantrange and diff.iszero then
        -- This avoids an unexpected result such as
        -- Date('partial', 2001) - Date('partial', 2001)
        -- giving diff = { years = 0, months = { 0, 11 } }
        -- which would be reported as 0 years and 11 months.
        return 0, 0
      end
      return choose(diff.partial.years), choose(diff.partial.months)
    end
    if code == 'y' then
      return choose(diff.partial.years)
    end
    if code == 'm' or code == 'w' or code == 'd' then
      return choose({ diff.partial.mindiff:age(code), diff.partial.maxdiff:age(code) })
    end
    return nil
  end
  local extra_days = wantduration and 1 or 0
  if code == 'wd' or code == 'w' or code == 'd' then
    local offset = wantround and 0.5 or 0
    local days = diff.age_days + extra_days
    if code == 'wd' or code == 'd' then
      days = floor(days + offset)
      if code == 'd' then
        return days
      end
      return floor(days/7), days % 7
    end
    return floor(days/7 + offset)
  end
  local H, M, S = diff.hours, diff.minutes, diff.seconds
  if code == 'dh' or code == 'dhm' or code == 'dhms' or code == 'h' or code == 'hm' or code == 'hms' then
    local days = floor(diff.age_days + extra_days)
    local inc_hour
    if wantround then
      if code == 'dh' or code == 'h' then
        if M >= 30 then
          inc_hour = true
        end
      elseif code == 'dhm' or code == 'hm' then
        if S >= 30 then
          M = M + 1
          if M >= 60 then
            M = 0
            inc_hour = true
          end
        end
      else
        -- Nothing needed because S is an integer.
      end
      if inc_hour then
        H = H + 1
        if H >= 24 then
          H = 0
          days = days + 1
        end
      end
    end
    if code == 'dh' or code == 'dhm' or code == 'dhms' then
      if code == 'dh' then
        return days, H
      elseif code == 'dhm' then
        return days, H, M
      else
        return days, H, M, S
      end
    end
    local hours = days * 24 + H
    if code == 'h' then
      return hours
    elseif code == 'hm' then
      return hours, M
    end
    return hours, M, S
  end
  if wantround then
    local inc_hour
    if code == 'ymdh' or code == 'ymwdh' then
      if M >= 30 then
        inc_hour = true
      end
    elseif code == 'ymdhm' or code == 'ymwdhm' then
      if S >= 30 then
        M = M + 1
        if M >= 60 then
          M = 0
          inc_hour = true
        end
      end
    elseif code == 'ymd' or code == 'ymwd' or code == 'yd' or code == 'md' then
      if H >= 12 then
        extra_days = extra_days + 1
      end
    end
    if inc_hour then
      H = H + 1
      if H >= 24 then
        H = 0
        extra_days = extra_days + 1
      end
    end
  end
  local y, m, d = diff.years, diff.months, diff.days
  if extra_days > 0 then
    d = d + extra_days
    if d > 28 or code == 'yd' then
      -- Recalculate in case have passed a month.
      diff = diff.date1 + extra_days - diff.date2
      y, m, d = diff.years, diff.months, diff.days
    end
  end
  if code == 'ymd' then
    return y, m, d
  elseif code == 'yd' then
    if y > 0 then
      -- It is known that diff.date1 > diff.date2.
      diff = diff.date1 - (diff.date2 + (y .. 'y'))
    end
    return y, floor(diff.age_days)
  elseif code == 'md' then
    return y * 12 + m, d
  elseif code == 'ym' or code == 'm' then
    if wantround then
      if d >= 16 then
        m = m + 1
        if m >= 12 then
          m = 0
          y = y + 1
        end
      end
    end
    if code == 'ym' then
      return y, m
    end
    return y * 12 + m
  elseif code == 'ymw' then
    local weeks = floor(d/7)
    if wantround then
      local days = d % 7
      if days > 3 or (days == 3 and H >= 12) then
        weeks = weeks + 1
      end
    end
    return y, m, weeks
  elseif code == 'ymwd' then
    return y, m, floor(d/7), d % 7
  elseif code == 'ymdh' then
    return y, m, d, H
  elseif code == 'ymwdh' then
    return y, m, floor(d/7), d % 7, H
  elseif code == 'ymdhm' then
    return y, m, d, H, M
  elseif code == 'ymwdhm' then
    return y, m, floor(d/7), d % 7, H, M
  end
  if code == 'y' then
    if wantround and m >= 6 then
      y = y + 1
    end
    return y
  end
  return nil
end

local function _diff_duration(diff, code, options)
  if type(options) ~= 'table' then
    options = { round = options }
  end
  options.duration = true
  return _diff_age(diff, code, options)
end

-- Metatable for some operations on date differences.
diffmt = { -- for forward declaration above
  __concat = function (lhs, rhs)
    return tostring(lhs) .. tostring(rhs)
  end,
  __tostring = function (self)
    return tostring(self.age_days)
  end,
  __index = function (self, key)
    local value
    if key == 'age_days' then
      if rawget(self, 'partial') then
        local function jdz(date)
          return (date.partial and date.partial.first or date).jdz
        end
        value = jdz(self.date1) - jdz(self.date2)
      else
        value = self.date1.jdz - self.date2.jdz
      end
    end
    if value ~= nil then
      rawset(self, key, value)
      return value
    end
  end,
}

function DateDiff(date1, date2, options) -- for forward declaration above
  -- Return a table with the difference between two dates (date1 - date2).
  -- The difference is negative if date1 is older than date2.
  -- Return nothing if invalid.
  -- If d = date1 - date2 then
  -- date1 = date2 + d
  -- If date1 >= date2 and the dates have no H:M:S time specified then
  -- date1 = date2 + (d.years..'y') + (d.months..'m') + d.days
  -- where the larger time units are added first.
  -- The result of Date(2015,1,x) + '1m' is Date(2015,2,28) for
  -- x = 28, 29, 30, 31. That means, for example,
  -- d = Date(2015,3,3) - Date(2015,1,31)
  -- gives d.years, d.months, d.days = 0, 1, 3 (excluding date1).
  if not (is_date(date1) and is_date(date2) and date1.calendar == date2.calendar) then
    return
  end
  local wantfill
  if type(options) == 'table' then
    wantfill = options.fill
  end
  local isnegative = false
  local iszero = false
  if date1 < date2 then
    isnegative = true
    date1, date2 = date2, date1
  elseif date1 == date2 then
    iszero = true
  end
  -- It is known that date1 >= date2 (period is from date2 to date1).
  if date1.partial or date2.partial then
    -- Two partial dates might have timelines:
    ---------------------A=================B--- date1 is from A to B inclusive
    --------C=======D-------------------------- date2 is from C to D inclusive
    -- date1 > date2 iff A > C (date1.partial.first > date2.partial.first)
    -- The periods can overlap ('April 2001' - '2001'):
    -------------A===B------------------------- A=2001-04-01 B=2001-04-30
    --------C=====================D------------ C=2001-01-01 D=2001-12-31
    if wantfill then
      date1, date2 = autofill(date1, date2)
    else
      local function zdiff(date1, date2)
        local diff = date1 - date2
        if diff.isnegative then
          return date1 - date1 -- a valid diff in case we call its methods
        end
        return diff
      end
      local function getdate(date, which)
        return date.partial and date.partial[which] or date
      end
      local maxdiff = zdiff(getdate(date1, 'last'), getdate(date2, 'first'))
      local mindiff = zdiff(getdate(date1, 'first'), getdate(date2, 'last'))
      local years, months
      if maxdiff.years == mindiff.years then
        years = maxdiff.years
        if maxdiff.months == mindiff.months then
          months = maxdiff.months
        else
          months = { mindiff.months, maxdiff.months }
        end
      else
        years = { mindiff.years, maxdiff.years }
      end
      return setmetatable({
        date1 = date1,
        date2 = date2,
        partial = {
          years = years,
          months = months,
          maxdiff = maxdiff,
          mindiff = mindiff,
        },
        isnegative = isnegative,
        iszero = iszero,
        age = _diff_age,
        duration = _diff_duration,
      }, diffmt)
    end
  end
  local y1, m1 = date1.year, date1.month
  local y2, m2 = date2.year, date2.month
  local years = y1 - y2
  local months = m1 - m2
  local d1 = date1.day + hms(date1)
  local d2 = date2.day + hms(date2)
  local days, time
  if d1 >= d2 then
    days = d1 - d2
  else
    months = months - 1
    -- Get days in previous month (before the "to" date) given December has 31 days.
    local dpm = m1 > 1 and days_in_month(y1, m1 - 1, date1.calendar) or 31
    if d2 >= dpm then
      days = d1 - hms(date2)
    else
      days = dpm - d2 + d1
    end
  end
  if months < 0 then
    years = years - 1
    months = months + 12
  end
  days, time = math.modf(days)
  local H, M, S = h_m_s(time)
  return setmetatable({
    date1 = date1,
    date2 = date2,
    partial = false, -- avoid index lookup
    years = years,
    

months = months,
    days = days,
    hours = H,
    minutes = M,
    seconds = S,
    isnegative = isnegative,
    iszero = iszero,
    age = _diff_age,
    duration = _diff_duration,
  }, diffmt)
end

return {
  _current = current,
  _Date = Date,
  _days_in_month = days_in_month,
}