વિભાગ:Citation/CS1/Date validation

local p = {}

--[[--------------------------< F O R W A R D D E C L A R A T I O N S >--------------------------------------
]]

local is\_set, in\_array; -- imported functions from selected Module:Citation/CS1/Utilities
local cfg; -- table of tables imported from slected Module:Citation/CS1/Configuration

--[=[-------------------------< I S \_ V A L I D \_ A C C E S S D A T E >----------------------------------------

returns true if:
 Wikipedia start date <= accessdate < today + 2 days

Wikipedia start date is 2001-01-15T00:00:00 UTC which is 979516800 seconds after 1970-01-01T00:00:00 UTC (the start of Unix time)
accessdate is the date provided in |accessdate= at time 00:00:00 UTC
today is the current date at time 00:00:00 UTC plus 48 hours
 if today is 2015-01-01T00:00:00 then
 adding 24 hours gives 2015-01-02T00:00:00 – one second more than today
 adding 24 hours gives 2015-01-03T00:00:00 – one second more than tomorrow

This function does not work if it is fed month names for languages other than English. Wikimedia #time: parser
apparently doesn't understand non-Engish date month names. This function will always return false when the date
contains a non-English month name because good1 is false after the call to lang.formatDate(). To get around that
call this function with YYYY-MM-DD format dates.

]=]

local function is\_valid\_accessdate (accessdate)
 local lang = mw.getContentLanguage();
 local good1, good2;
 local access\_ts, tomorrow\_ts; -- to hold unix time stamps representing the dates

 good1, access\_ts = pcall( lang.formatDate, lang, 'U', accessdate ); -- convert accessdate value to unix timesatmp
 good2, tomorrow\_ts = pcall( lang.formatDate, lang, 'U', 'today + 2 days' ); -- today midnight + 2 days is one second more than all day tomorrow

 if good1 and good2 then -- lang.formatDate() returns a timestamp in the local script which which tonumber() may not understand
 access\_ts = tonumber (access\_ts) or lang:parseFormattedNumber (access\_ts); -- convert to numbers for the comparison;
 tomorrow\_ts = tonumber (tomorrow\_ts) or lang:parseFormattedNumber (tomorrow\_ts);
 else
 return false; -- one or both failed to convert to unix time stamp
 end

 if 979516800 <= access\_ts and access\_ts < tomorrow\_ts then -- Wikipedia start date <= accessdate < tomorrow's date
 return true;
 else
 return false; -- accessdate out of range
 end
end

--[[--------------------------< G E T \_ M O N T H \_ N U M B E R >----------------------------------------------

returns a number according to the month in a date: 1 for January, etc. Capitalization and spelling must be correct. If not a valid month, returns 0

]]

local function get\_month\_number (month)
 return cfg.date\_names['local'].long[month] or cfg.date\_names['local'].short[month] or -- look for local names first
 cfg.date\_names['en'].long[month] or cfg.date\_names['en'].short[month] or -- failing that, look for English names
 0; -- not a recognized month name
end

--[[--------------------------< I S \_ V A L I D \_ E M B A R G O \_ D A T E >------------------------------------

returns true and date value if that value has proper dmy, mdy, ymd format.

returns false and 9999 (embargoed forever) when date value is not proper format; assumes that when |embargo= is
set, the editor intended to embargo a pmc but |embargo= does not hold a single date.

]]

local function is\_valid\_embargo\_date (v)
 if v:match ('^%d%d%d%d%-%d%d%-%d%d$') or -- ymd
 v:match ('^%d%d?%s+%a+%s+%d%d%d%d$') or -- dmy
 v:match ('^%a+%s+%d%d?%s\*,%s\*%d%d%d%d$') then -- mdy
 return true, v;
 end
 return false, '9999'; -- if here not good date so return false and set embargo date to long time in future
end

--[[--------------------------< G E T \_ S E A S O N \_ N U M B E R >--------------------------------------------

returns a number according to the sequence of seasons in a year: 1 for Winter, etc. Capitalization and spelling must be correct. If not a valid season, returns 0

Uses ISO DIS 8601 2016 part 2 §4.7 Divisions of a year for hemishpere-independent seasons:
 21-24 = Spring, Summer, Autumn, Winter, independent of “Hemisphere”

These additional divisions not currently supported:
 25-28 = Spring - Northern Hemisphere, Summer- Northern Hemisphere, Autumn - Northern Hemisphere, Winter - Northern Hemisphere
 29-32 = Spring – Southern Hemisphere, Summer– Southern Hemisphere, Autumn – Southern Hemisphere, Winter - Southern Hemisphere
 33-36 = Quarter 1, Quarter 2, Quarter 3, Quarter 4 (3 months each)
 37-39 = Quadrimester 1, Quadrimester 2, Quadrimester 3 (4 months each)
 40-41 = Semestral 1, Semestral-2 (6 months each)

]]

local function get\_season\_number (season)
 return cfg.date\_names['local'].season[season] or -- look for local names first
 cfg.date\_names['en'].season[season] or -- failing that, look for English names
 0; -- not a recognized season name
end

--[[--------------------------< I S \_ P R O P E R \_ N A M E >--------------------------------------------------

returns a non-zero number if date contains a recognized proper name. Capitalization and spelling must be correct.

]]

local function is\_proper\_name (name)
 return cfg.date\_names['local'].named[name] or -- look for local names dates first
 cfg.date\_names['en'].named[name] or -- failing that, look for English names
 0; -- not a recognized named date
end

--[[--------------------------< I S \_ V A L I D \_ M O N T H \_ O R \_ S E A S O N >------------------------------

--returns true if month or season is valid (properly spelled, capitalized, abbreviated)

]]

local function is\_valid\_month\_or\_season (month\_season)
 if 0 == get\_month\_number (month\_season) then -- if month text isn't one of the twelve months, might be a season
 if 0 == get\_season\_number (month\_season) then -- not a month, is it a season?
 return false; -- return false not a month or one of the five seasons
 end
 end
 return true;
end

--[[--------------------------< I S \_ V A L I D \_ Y E A R >----------------------------------------------------

Function gets current year from the server and compares it to year from a citation parameter. Years more than one year in the future are not acceptable.

]]
local year\_limit;
local function is\_valid\_year(year)
 if not is\_set(year\_limit) then
 year\_limit = tonumber(os.date("%Y"))+1; -- global variable so we only have to fetch it once
 end
 return tonumber(year) <= year\_limit; -- false if year is in the future more than one year
end

--[[--------------------------< I S \_ V A L I D \_ D A T E >----------------------------------------------------
Returns true if day is less than or equal to the number of days in month and year is no farther into the future
than next year; else returns false.

Assumes Julian calendar prior to year 1582 and Gregorian calendar thereafter. Accounts for Julian calendar leap
years before 1582 and Gregorian leap years after 1582. Where the two calendars overlap (1582 to approximately
1923) dates are assumed to be Gregorian.

]]

local function is\_valid\_date (year, month, day)
local days\_in\_month = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
local month\_length;
 if not is\_valid\_year(year) then -- no farther into the future than next year
 return false;
 end

 month = tonumber(month); -- required for YYYY-MM-DD dates

 if (2==month) then -- if February
 month\_length = 28; -- then 28 days unless
 if 1582 > tonumber(year) then -- Julian calendar
 if 0==(year%4) then
 month\_length = 29;
 end
 else -- Gregorian calendar
 if (0==(year%4) and (0~=(year%100) or 0==(year%400))) then -- is a leap year?
 month\_length = 29; -- if leap year then 29 days in February
 end
 end
 else
 month\_length=days\_in\_month[month];
 end

 if tonumber (day) > month\_length then
 return false;
 end
 return true;
end

--[[--------------------------< I S \_ V A L I D \_ M O N T H \_ R A N G E \_ S T Y L E >--------------------------

Months in a range are expected to have the same style: Jan–Mar or October–December but not February–Mar or Jul–August.
There is a special test for May because it can be either short or long form.

Returns true when style for both months is the same

]]

local function is\_valid\_month\_range\_style (month1, month2)
local len1 = month1:len();
local len2 = month2:len();
 if len1 == len2 then
 return true; -- both months are short form so return true
 elseif 'May' == month1 or 'May'== month2 then
 return true; -- both months are long form so return true
 elseif 3 == len1 or 3 == len2 then
 return false; -- months are mixed form so return false
 else
 return true; -- both months are long form so return true
 end
end

--[[--------------------------< I S \_ V A L I D \_ M O N T H \_ S E A S O N \_ R A N G E >------------------------

Check a pair of months or seasons to see if both are valid members of a month or season pair.

Month pairs are expected to be left to right, earliest to latest in time.

All season ranges are accepted as valid because there are publishers out there who have published a Summer–Spring YYYY issue so ... ok

]]

local function is\_valid\_month\_season\_range(range\_start, range\_end)
 local range\_start\_number = get\_month\_number (range\_start);
 local range\_end\_number;

 if 0 == range\_start\_number then -- is this a month range?
 range\_start\_number = get\_season\_number (range\_start); -- not a month; is it a season? get start season number
 range\_end\_number = get\_season\_number (range\_end); -- get end season number

 if (0 ~= range\_start\_number) and (0 ~= range\_end\_number) then
 return true; -- any season pairing is accepted
 end
 return false; -- range\_start and/or range\_end is not a season
 end
 -- here when range\_start is a month
 range\_end\_number = get\_month\_number (range\_end); -- get end month number
 if range\_start\_number < range\_end\_number then -- range\_start is a month; does range\_start precede range\_end?
 if is\_valid\_month\_range\_style (range\_start, range\_end) then -- do months have the same style?
 return true; -- proper order and same style
 end
 end
 return false; -- range\_start month number is greater than or equal to range end number; or range end isn't a month
end

--[[--------------------------< M A K E \_ C O I N S \_ D A T E >------------------------------------------------

This function receives a table of date parts for one or two dates and an empty table reference declared in
Module:Citation/CS1. The function is called only for |date= parameters and only if the |date=<value> is
determined to be a valid date format. The question of what to do with invalid date formats is not answered here.

The date parts in the input table are converted to an ISO 8601 conforming date string:
 single whole dates: yyyy-mm-dd
 month and year dates: yyyy-mm
 year dates: yyyy
 ranges: yyyy-mm-dd/yyyy-mm-dd
 yyyy-mm/yyyy-mm
 yyyy/yyyy

Dates in the Julian calendar are reduced to year or year/year so that we don't have to do calendar conversion from
Julian to Proleptic Gregorian.

The input table has:
 year, year2 – always present; if before 1582, ignore months and days if present
 month, month2 – 0 if not provided, 1-12 for months, 21-24 for seasons; 99 Christmas
 day, day2 – 0 if not provided, 1-31 for days

the output table receives:
 rftdate: an IS8601 formatted date
 rftchron: a free-form version of the date, usually without year which is in rftdate (season ranges and propername dates)
 rftssn: one of four season keywords: winter, spring, summer, fall (lowercase)

]]

local function make\_COinS\_date (input, tCOinS\_date)
 local date; -- one date or first date in a range
 local date2 = *; -- end of range date*
-- start temporary Julian / Gregorian calendar uncertainty detection
 local year = tonumber(input.year); -- this temporary code to determine the extent of sources dated to the Julian/Gregorian
 local month = tonumber(input.month); -- interstice 1 October 1582 – 1 January 1926
 local day = tonumber (input.day);
 if (0 ~= day) and -- day must have a value for this to be a whole date
 (((1582 == year) and (10 <= month) and (12 >= month)) or -- any whole 1582 date from 1 october to 31 December or
 ((1926 == year) and (1 == month) and (1 == input.day)) or -- 1 January 1926 or
 ((1582 < year) and (1925 >= year))) then -- any date 1 January 1583 – 31 December 1925
 tCOinS\_date.inter\_cal\_cat = true; -- set category flag true
 end
-- end temporary Julian / Gergorian calendar uncertainty detection

 if 1582 > tonumber(input.year) or 20 < tonumber(input.month) then -- Julian calendar or season so &rft.date gets year only
 date = input.year;
 if 0 ~= input.year2 and input.year ~= input.year2 then -- if a range, only the second year portion when not the same as range start year
 date = string.format ('%.4d/%.4d', tonumber(input.year), tonumber(input.year2)) -- assemble the date range
 end
 if 20 < tonumber(input.month) then -- if season or propername date
 local season = {[24]='winter', [21]='spring', [22]='summer', [23]='fall', [99]='Christmas'}; -- seasons lowercase, no autumn; proper names use title case
 if 0 == input.month2 then -- single season date
 if 30 <tonumber(input.month) then
 tCOinS\_date.rftchron = season[input.month]; -- proper name dates
 else
 tCOinS\_date.rftssn = season[input.month]; -- seasons
 end
 else -- season range with a second season specified
 if input.year ~= input.year2 then -- season year – season year range or season year–year
 tCOinS\_date.rftssn = season[input.month]; -- start of range season; keep this?
 if 0~= input.month2 then
 tCOinS\_date.rftchron = string.format ('%s %s – %s %s', season[input.month], input.year, season[input.month2], input.year2);
 end
 else -- season–season year range
 tCOinS\_date.rftssn = season[input.month]; -- start of range season; keep this?
 tCOinS\_date.rftchron = season[input.month] .. '–' .. season[input.month2]; -- season–season year range
 end
 end
 end
 tCOinS\_date.rftdate = date;
 return; -- done
 end

 if 0 ~= input.day then
 date = string.format ('%s-%.2d-%.2d', input.year, tonumber(input.month), tonumber(input.day)); -- whole date
 elseif 0 ~= input.month then
 date = string.format ('%s-%.2d', input.year, tonumber(input.month)); -- year and month
 else
 date = string.format ('%s', input.year); -- just year
 end

 if 0 ~= input.year2 then
 if 0 ~= input.day2 then
 date2 = string.format ('/%s-%.2d-%.2d', input.year2, tonumber(input.month2), tonumber(input.day2)); -- whole date
 elseif 0 ~= input.month2 then
 date2 = string.format ('/%s-%.2d', input.year2, tonumber(input.month2)); -- year and month
 else
 date2 = string.format ('/%s', input.year2); -- just year
 end
 end

 tCOinS\_date.rftdate = date .. date2; -- date2 has the '/' separator
 return;
end

--[[--------------------------< C H E C K \_ D A T E >----------------------------------------------------------

Check date format to see that it is one of the formats approved by WP:DATESNO or WP:DATERANGE. Exception: only
allowed range separator is endash. Additionally, check the date to see that it is a real date: no 31 in 30-day
months; no 29 February when not a leap year. Months, both long-form and three character abbreviations, and seasons
must be spelled correctly. Future years beyond next year are not allowed.

If the date fails the format tests, this function returns false and does not return values for anchor\_year and
COinS\_date. When this happens, the date parameter is used in the COinS metadata and the CITEREF identifier gets
its year from the year parameter if present otherwise CITEREF does not get a date value.

Inputs:
 date\_string - date string from date-holding parameters (date, year, accessdate, embargo, archivedate, etc.)

Returns:
 false if date string is not a real date; else
 true, anchor\_year, COinS\_date
 anchor\_year can be used in CITEREF anchors
 COinS\_date is ISO 8601 format date; see make\_COInS\_date()

]]

local function check\_date (date\_string, tCOinS\_date, test\_accessdate)
 local year; -- assume that year2, months, and days are not used;
 local year2=0; -- second year in a year range
 local month=0;
 local month2=0; -- second month in a month range
 local day=0;
 local day2=0; -- second day in a day range
 local anchor\_year;
 local coins\_date;

 if date\_string:match("^%d%d%d%d%-%d%d%-%d%d$") then -- year-initial numerical year month day format
 year, month, day=string.match(date\_string, "(%d%d%d%d)%-(%d%d)%-(%d%d)");
 if 12 < tonumber(month) or 1 > tonumber(month) or 1582 > tonumber(year) or 0 == tonumber(day) then return false; end -- month or day number not valid or not Gregorian calendar
 anchor\_year = year;
 elseif mw.ustring.match(date\_string, "^%D- +[1-9]%d?, +[1-9]%d%d%d%a?$") then -- month-initial: month day, year
 month, day, anchor\_year, year=mw.ustring.match(date\_string, "(%D-) +(%d%d?),%s\*((%d%d%d%d?)%a?)");
 month = get\_month\_number (month);
 if 0 == month then return false; end -- return false if month text isn't one of the twelve months

 elseif mw.ustring.match(date\_string, "^%D- +[1-9]%d?[%-–][1-9]%d?, +[1-9]%d%d%d%a?$") then -- month-initial day range: month day–day, year; days are separated by endash
 month, day, day2, anchor\_year, year=mw.ustring.match(date\_string, "(%D-) +(%d%d?)[%-–](%d%d?), +((%d%d%d%d)%a?)");
 if tonumber(day) >= tonumber(day2) then return false; end -- date range order is left to right: earlier to later; dates may not be the same;
 month = get\_month\_number (month);
 if 0 == month then return false; end -- return false if month text isn't one of the twelve months
 month2=month; -- for metadata
 year2=year;

 elseif mw.ustring.match(date\_string, "^[1-9]%d? +%D- +[1-9]%d%d%d%a?$") then -- day-initial: day month year
 day, month, anchor\_year, year=mw.ustring.match(date\_string, "(%d%d\*)%s\*(%D-) +((%d%d%d%d?)%a?)");
 month = get\_month\_number (month);
 if 0 == month then return false; end -- return false if month text isn't one of the twelve months

 elseif mw.ustring.match(date\_string, "^[1-9]%d?[%-–][1-9]%d? +%D- +[1-9]%d%d%d%a?$") then -- day-range-initial: day–day month year; days are separated by endash
 day, day2, month, anchor\_year, year=mw.ustring.match(date\_string, "(%d%d?)[%-–](%d%d?) +(%D-) +((%d%d%d%d)%a?)");
 if tonumber(day) >= tonumber(day2) then return false; end -- date range order is left to right: earlier to later; dates may not be the same;
 month = get\_month\_number (month);
 if 0 == month then return false; end -- return false if month text isn't one of the twelve months
 month2=month; -- for metadata
 year2=year;

 elseif mw.ustring.match(date\_string, "^[1-9]%d? +%D- +[%-–] +[1-9]%d? +%D- +[1-9]%d%d%d%a?$") then -- day initial month-day-range: day month - day month year; uses spaced endash
 day, month, day2, month2, anchor\_year, year=mw.ustring.match(date\_string, "(%d%d?) +(%D-) +[%-–] +(%d%d?) +(%D-) +((%d%d%d%d)%a?)");
 if (not is\_valid\_month\_season\_range(month, month2)) or not is\_valid\_year(year) then return false; end -- date range order is left to right: earlier to later;
 month = get\_month\_number (month); -- for metadata
 month2 = get\_month\_number (month2);
 year2=year;

 elseif mw.ustring.match(date\_string, "^%D- +[1-9]%d? +[%-–] +%D- +[1-9]%d?, +[1-9]%d%d%d%a?$") then -- month initial month-day-range: month day – month day, year; uses spaced endash
 month, day, month2, day2, anchor\_year, year=mw.ustring.match(date\_string, "(%D-) +(%d%d?) +[%-–] +(%D-) +(%d%d?), +((%d%d%d%d)%a?)");
 if (not is\_valid\_month\_season\_range(month, month2)) or not is\_valid\_year(year) then return false; end
 month = get\_month\_number (month); -- for metadata
 month2 = get\_month\_number (month2);
 year2=year;

 elseif mw.ustring.match(date\_string, "^[1-9]%d? +%D- +[1-9]%d%d%d +[%-–] +[1-9]%d? +%D- +[1-9]%d%d%d%a?$") then -- day initial month-day-year-range: day month year - day month year; uses spaced endash
 day, month, year, day2, month2, anchor\_year, year2=mw.ustring.match(date\_string, "(%d%d?) +(%D-) +(%d%d%d%d) +[%-–] +(%d%d?) +(%D-) +((%d%d%d%d)%a?)");
 if tonumber(year2) <= tonumber(year) then return false; end -- must be sequential years, left to right, earlier to later
 if not is\_valid\_year(year2) or not is\_valid\_month\_range\_style(month, month2) then return false; end -- year2 no more than one year in the future; months same style
 month = get\_month\_number (month); -- for metadata
 month2 = get\_month\_number (month2);

 elseif mw.ustring.match(date\_string, "^%D- +[1-9]%d?, +[1-9]%d%d%d +[%-–] +%D- +[1-9]%d?, +[1-9]%d%d%d%a?$") then -- month initial month-day-year-range: month day, year – month day, year; uses spaced endash
 month, day, year, month2, day2, anchor\_year, year2=mw.ustring.match(date\_string, "(%D-) +(%d%d?), +(%d%d%d%d) +[%-–] +(%D-) +(%d%d?), +((%d%d%d%d)%a?)");
 if tonumber(year2) <= tonumber(year) then return false; end -- must be sequential years, left to right, earlier to later
 if not is\_valid\_year(year2) or not is\_valid\_month\_range\_style(month, month2) then return false; end -- year2 no more than one year in the future; months same style
 month = get\_month\_number (month); -- for metadata
 month2 = get\_month\_number (month2);

 elseif mw.ustring.match(date\_string, "^%D- +[1-9]%d%d%d[%-–]%d%d%a?$") then -- special case Winter/Summer year-year (YYYY-YY); year separated with unspaced endash
 local century;
 month, year, century, anchor\_year, year2=mw.ustring.match(date\_string, "(%D-) +((%d%d)%d%d)[%-–]((%d%d)%a?)");
 if 'Winter' ~= month and 'Summer' ~= month then return false end; -- 'month' can only be Winter or Summer
 anchor\_year=year..'–'..anchor\_year; -- assemble anchor\_year from both years
 year2 = century..year2; -- add the century to year2 for comparisons
 if 1 ~= tonumber(year2) - tonumber(year) then return false; end -- must be sequential years, left to right, earlier to later
 if not is\_valid\_year(year2) then return false; end -- no year farther in the future than next year
 month = get\_season\_number (month);

 elseif mw.ustring.match(date\_string, "^%D- +[1-9]%d%d%d[%-–][1-9]%d%d%d%a?$") then -- special case Winter/Summer year-year; year separated with unspaced endash
 month, year, anchor\_year, year2=mw.ustring.match(date\_string, "(%D-) +(%d%d%d%d)[%-–]((%d%d%d%d)%a?)");
 if 'Winter' ~= month and 'Summer' ~= month then return false end; -- 'month' can only be Winter or Summer
 anchor\_year=year..'–'..anchor\_year; -- assemble anchor\_year from both years
 if 1 ~= tonumber(year2) - tonumber(year) then return false; end -- must be sequential years, left to right, earlier to later
 if not is\_valid\_year(year2) then return false; end -- no year farther in the future than next year
 month = get\_season\_number (month); -- for metadata

 elseif mw.ustring.match(date\_string, "^%D- +[1-9]%d%d%d +[%-–] +%D- +[1-9]%d%d%d%a?$") then -- month/season year - month/season year; separated by spaced endash
 month, year, month2, anchor\_year, year2=mw.ustring.match(date\_string, "(%D-) +(%d%d%d%d) +[%-–] +(%D-) +((%d%d%d%d)%a?)");
 anchor\_year=year..'–'..anchor\_year; -- assemble anchor\_year from both years
 if tonumber(year) >= tonumber(year2) then return false; end -- left to right, earlier to later, not the same
 if not is\_valid\_year(year2) then return false; end -- no year farther in the future than next year
 if 0 ~= get\_month\_number(month) and 0 ~= get\_month\_number(month2) and is\_valid\_month\_range\_style(month, month2) then -- both must be month year, same month style
 month = get\_month\_number(month);
 month2 = get\_month\_number(month2);
 elseif 0 ~= get\_season\_number(month) and 0 ~= get\_season\_number(month2) then -- both must be or season year, not mixed
 month = get\_season\_number(month);
 month2 = get\_season\_number(month2);
 else
 return false;
 end

 elseif mw.ustring.match(date\_string, "^%D-[%-–]%D- +[1-9]%d%d%d%a?$") then -- month/season range year; months separated by endash
 month, month2, anchor\_year, year=mw.ustring.match(date\_string, "(%D-)[%-–](%D-)%s\*((%d%d%d%d)%a?)");
 if (not is\_valid\_month\_season\_range(month, month2)) or (not is\_valid\_year(year)) then return false; end
 if 0 ~= get\_month\_number(month) then -- determined to be a valid range so just check this one to know if month or season
 month = get\_month\_number(month);
 month2 = get\_month\_number(month2);
 else
 month = get\_season\_number(month);
 month2 = get\_season\_number(month2);
 end
 year2=year;

 elseif mw.ustring.match(date\_string, "^%D- +%d%d%d%d%a?$") then -- month/season year or proper-name year
 month, anchor\_year, year=mw.ustring.match(date\_string, "(%D-)%s\*((%d%d%d%d)%a?)");
 if not is\_valid\_year(year) then return false; end
 if not is\_valid\_month\_or\_season (month) and 0 == is\_proper\_name (month) then return false; end
 if 0 ~= get\_month\_number(month) then -- determined to be a valid range so just check this one to know if month or season
 month = get\_month\_number(month);
 elseif 0 ~= get\_season\_number(month) then
 month = get\_season\_number(month);
 else
 month = is\_proper\_name (month); -- must be proper name; not supported in COinS
 end

 elseif mw.ustring.match(date\_string, "^[1-9]%d%d%d?[%-–][1-9]%d%d%d?%a?$") then -- Year range: YYY-YYY or YYY-YYYY or YYYY–YYYY; separated by unspaced endash; 100-9999
 year, anchor\_year, year2=mw.ustring.match(date\_string, "(%d%d%d%d?)[%-–]((%d%d%d%d?)%a?)");
 anchor\_year=year..'–'..anchor\_year; -- assemble anchor year from both years
 if tonumber(year) >= tonumber(year2) then return false; end -- left to right, earlier to later, not the same
 if not is\_valid\_year(year2) then return false; end -- no year farther in the future than next year

 elseif mw.ustring.match(date\_string, "^[1-9]%d%d%d[%-–]%d%d%a?$") then -- Year range: YYYY–YY; separated by unspaced endash
 local century;
 year, century, anchor\_year, year2=mw.ustring.match(date\_string, "((%d%d)%d%d)[%-–]((%d%d)%a?)");
 anchor\_year=year..'–'..anchor\_year; -- assemble anchor year from both years
 if 13 > tonumber(year2) then return false; end -- don't allow 2003-05 which might be May 2003
 year2 = century..year2; -- add the century to year2 for comparisons
 if tonumber(year) >= tonumber(year2) then return false; end -- left to right, earlier to later, not the same
 if not is\_valid\_year(year2) then return false; end -- no year farther in the future than next year

 elseif date\_string:match("^[1-9]%d%d%d?%a?$") then -- year; here accept either YYY or YYYY
 anchor\_year, year=date\_string:match("((%d%d%d%d?)%a?)");
 if false == is\_valid\_year(year) then
 return false;
 end

 else
 return false; -- date format not one of the MOS:DATE approved formats
 end

 if test\_accessdate then -- test accessdate here because we have numerical date parts
 if 0 ~= year and 0 ~= month and 0 ~= day and -- all parts of a single date required
 0 == year2 and 0 == month2 and 0 == day2 then -- none of these; accessdate must not be a range
 if not is\_valid\_accessdate (year..'-'..month..'-'..day) then
 return false; -- return false when accessdate out of bounds
 end
 else
 return false; -- return false when accessdate is a range of two dates
 end
 end

 local result=true; -- check whole dates for validity; assume true because not all dates will go through this test
 if 0 ~= year and 0 ~= month and 0 ~= day and 0 == year2 and 0 == month2 and 0 == day2 then -- YMD (simple whole date)
 result=is\_valid\_date(year,month,day);

 elseif 0 ~= year and 0 ~= month and 0 ~= day and 0 == year2 and 0 == month2 and 0 ~= day2 then -- YMD-d (day range)
 result=is\_valid\_date(year,month,day);
 result=result and is\_valid\_date(year,month,day2);

 elseif 0 ~= year and 0 ~= month and 0 ~= day and 0 == year2 and 0 ~= month2 and 0 ~= day2 then -- YMD-md (day month range)
 result=is\_valid\_date(year,month,day);
 result=result and is\_valid\_date(year,month2,day2);

 elseif 0 ~= year and 0 ~= month and 0 ~= day and 0 ~= year2 and 0 ~= month2 and 0 ~= day2 then -- YMD-ymd (day month year range)
 result=is\_valid\_date(year,month,day);
 result=result and is\_valid\_date(year2,month2,day2);
 end

 if false == result then return false; end

 if nil ~= tCOinS\_date then -- this table only passed into this function when testing |date= parameter values
 make\_COinS\_date ({year=year, month=month, day=day, year2=year2, month2=month2, day2=day2}, tCOinS\_date); -- make an ISO 8601 date string for COinS
 end

 return true, anchor\_year; -- format is good and date string represents a real date
end

--[[--------------------------< D A T E S >--------------------------------------------------------------------

Cycle the date-holding parameters in passed table date\_parameters\_list through check\_date() to check compliance with MOS:DATE. For all valid dates, check\_date() returns
true. The |date= parameter test is unique, it is the only date holding parameter from which values for anchor\_year (used in CITEREF identifiers) and COinS\_date (used in
the COinS metadata) are derived. The |date= parameter is the only date-holding parameter that is allowed to contain the no-date keywords "n.d." or "nd" (without quotes).

Unlike most error messages created in this module, only one error message is created by this function. Because all of the date holding parameters are processed serially,
a single error message is created as the dates are tested.

]]

local function dates(date\_parameters\_list, tCOinS\_date)
 local anchor\_year; -- will return as nil if the date being tested is not |date=
 local COinS\_date; -- will return as nil if the date being tested is not |date=
 local embargo\_date; -- if embargo date is a good dmy, mdy, ymd date then holds original value else reset to 9999
 local error\_message = "";
 local good\_date = false;

 for k, v in pairs(date\_parameters\_list) do -- for each date-holding parameter in the list
 if is\_set(v.val) then -- if the parameter has a value
 v.val = mw.ustring.gsub (v.val, '%d', cfg.date\_names.local\_digits); -- translate 'local' digits to Western 0-9
 if v.val:match("^c%. [1-9]%d%d%d?%a?$") then -- special case for c. year or with or without CITEREF disambiguator - only |date= and |year=
 local year = v.val:match("c%. ([1-9]%d%d%d?)%a?"); -- get the year portion so it can be tested
 if 'date'==k then
 anchor\_year, COinS\_date = v.val:match("((c%. [1-9]%d%d%d?)%a?)"); -- anchor year and COinS\_date only from |date= parameter
 good\_date = is\_valid\_year(year);
 elseif 'year'==k then
 good\_date = is\_valid\_year(year);
 end
 elseif 'date'==k then -- if the parameter is |date=
 if v.val:match("^n%.d%.%a?$") then -- if |date=n.d. with or without a CITEREF disambiguator
 good\_date, anchor\_year, COinS\_date = true, v.val:match("((n%.d%.)%a?)"); --"n.d."; no error when date parameter is set to no date
 elseif v.val:match("^nd%a?$") then -- if |date=nd with or without a CITEREF disambiguator
 good\_date, anchor\_year, COinS\_date = true, v.val:match("((nd)%a?)"); --"nd"; no error when date parameter is set to no date
 else
 good\_date, anchor\_year, COinS\_date = check\_date (v.val, tCOinS\_date); -- go test the date
 end
 elseif 'year'==k then -- if the parameter is |year= it should hold only a year value
 if v.val:match("^[1-9]%d%d%d?%a?$") then -- if |year= 3 or 4 digits only with or without a CITEREF disambiguator
 good\_date, anchor\_year, COinS\_date = true, v.val:match("((%d+)%a?)");
 end
 elseif 'access-date'==k then -- if the parameter is |date=
 good\_date = check\_date (v.val, nil, true); -- go test the date; nil is a placeholder; true is the test\_accessdate flag
 elseif 'embargo'==k then -- if the parameter is |embargo=
 good\_date = check\_date (v.val); -- go test the date
 if true == good\_date then -- if the date is a valid date
 good\_date, embargo\_date = is\_valid\_embargo\_date (v.val); -- is |embargo= date a single dmy, mdy, or ymd formatted date? yes:returns embargo; no: returns 9999
 end
 else -- any other date-holding parameter
 good\_date = check\_date (v.val); -- go test the date
 end
 if false==good\_date then -- assemble one error message so we don't add the tracking category multiple times
 if is\_set(error\_message) then -- once we've added the first portion of the error message ...
 error\_message=error\_message .. ", "; -- ... add a comma space separator
 end
 error\_message=error\_message .. "|" .. v.name .. "="; -- add the failed parameter
 end
 end
 end
 return anchor\_year, embargo\_date, error\_message; -- and done
end

--[[--------------------------< Y E A R \_ D A T E \_ C H E C K >------------------------------------------------

Compare the value provided in |year= with the year value(s) provided in |date=. This function returns a numeric value:
 0 - year value does not match the year value in date
 1 - (default) year value matches the year value in date or one of the year values when date contains two years
 2 - year value matches the year value in date when date is in the form YYYY-MM-DD and year is disambiguated (|year=YYYYx)

]]

local function year\_date\_check (year\_string, date\_string)
 local year;
 local date1;
 local date2;
 local result = 1; -- result of the test; assume that the test passes

 year = year\_string:match ('(%d%d%d%d?)');

 if date\_string:match ('%d%d%d%d%-%d%d%-%d%d') and year\_string:match ('%d%d%d%d%a') then --special case where both date and year are required YYYY-MM-DD and YYYYx
 date1 = date\_string:match ('(%d%d%d%d)');
 year = year\_string:match ('(%d%d%d%d)');
 if year ~= date1 then
 result = 0; -- years don't match
 else
 result = 2; -- years match; but because disambiguated, don't add to maint cat
 end

 elseif date\_string:match ("%d%d%d%d?.-%d%d%d%d?") then -- any of the standard range formats of date with two three- or four-digit years
 date1, date2 = date\_string:match ("(%d%d%d%d?).-(%d%d%d%d?)");
 if year ~= date1 and year ~= date2 then
 result = 0;
 end

 elseif mw.ustring.match(date\_string, "%d%d%d%d[%-–]%d%d") then -- YYYY-YY date ranges
 local century;
 date1, century, date2 = mw.ustring.match(date\_string, "((%d%d)%d%d)[%-–]+(%d%d)");
 date2 = century..date2; -- convert YY to YYYY
 if year ~= date1 and year ~= date2 then
 result = 0;
 end

 elseif date\_string:match ("%d%d%d%d?") then -- any of the standard formats of date with one year
 date1 = date\_string:match ("(%d%d%d%d?)");
 if year ~= date1 then
 result = 0;
 end
 else
 result = 0; -- no recognizable year in date
 end
 return result;
end

--[[-------------------------< R E F O R M A T T A B L E S >------------------------------------------------

These table are used exclusively for reformatting dates

]]

local source\_patterns = { -- this table holds patterns that match allowed date formats used to extract date components
 ['dmy'] = '^(%d%d?)%s+(%a+)%s+(%d%d%d%d)$',
 ['mdy'] = '^(%a+)%s+(%d%d?),%s+(%d%d%d%d)$',
 ['ymd'] = '^(%d%d%d%d)%-(%d%d)-(%d%d)$',
 }

local short\_formats = { -- this table holds format strings used by os.date() for short month names
 ['dmy'] = '%e %b %Y',
 ['mdy'] = '%b %e, %Y',
 ['ymd'] = '%F',
 }

local long\_formats = { -- this table holds format strings used by os.date() for long month names
 ['dmy'] = '%e %B %Y',
 ['mdy'] = '%B %e, %Y',
 ['ymd'] = '%F',
 }

--[[-------------------------< G E T \_ D M Y \_ D A T E \_ P A R T S >------------------------------------------

extracts year, month and day from DMY formatted date, places them in the source\_date table, and returns.

]]

local function get\_dmy\_date\_parts (date, source\_date)
 source\_date.day, source\_date.month, source\_date.year = date:match (source\_patterns['dmy']); -- get date components as strings
 source\_date.month = get\_month\_number (source\_date.month); -- get month number
end

--[[-------------------------< G E T \_ M D Y \_ D A T E \_ P A R T S >------------------------------------------

extracts year, month and day from MDY formatted date, places them in the source\_date table, and returns.

]]

local function get\_mdy\_date\_parts (date, source\_date)
 source\_date.month, source\_date.day, source\_date.year = date:match (source\_patterns['mdy']); -- get date components as strings
 source\_date.month = get\_month\_number (source\_date.month); -- get month number
end

--[[-------------------------< G E T \_ Y M D \_ D A T E \_ P A R T S >------------------------------------------

extracts year, month and day from YMD formatted date, places them in the source\_date table, and returns.

]]

local function get\_ymd\_date\_parts (date, source\_date)
 source\_date.year, source\_date.month, source\_date.day = date:match (source\_patterns['ymd']); -- get date components as strings
end

--[[-------------------------< R E F O R M A T \_ D A T E S >--------------------------------------------------

Reformats existing dates into the format specified by format and short.

format is one of several keywords: dmy, dmy-all, mdy, mdy-all, ymd, ymd-all. The all version includes access- and
archive-dates; otherwise these dates are not reformatted

Date ranges, season dates, proper name dates are not currently supported.

For i18n: This code works only at en.wiki because os.date() doesn't support any languages other than English.
mw.getContentLanguage():formatDate() will work at non-English wikis only when the date format is yyyy-mm-dd. This is
the same issue that plagues is\_valid\_accessdate()

It is possible that a solution like that written for ht:Module:Citation/CS1/Date\_validation date\_name\_xlate() could be applied to this problem

]]

local function reformat\_dates (date\_parameters\_list, format, short)
 local all = false; -- set to false to skip access- and archive-dates
 local result = false;
 local format\_str;
 local source\_date = {};

 if format:match('%a+%-all') then
 format = format:match('(%a+)%-all'); -- extract the format
 all = true; -- set to true to format access- and archive-dates
 end

 for param\_name, param\_val in pairs (date\_parameters\_list) do -- for each date-holding parameter in the list
 if is\_set (param\_val.val) then -- if the parameter has a value
 if not (not all and in\_array (param\_name, {'access-date', 'archive-date'})) then -- skip access- or archive-date unless format is xxx-all; yeah, ugly; TODO: find a better way
 for source, pattern in pairs (source\_patterns) do
 if param\_val.val:match (pattern) then
 if 'ymd' == source then
 get\_ymd\_date\_parts (param\_val.val, source\_date); -- get the date parts into the source\_date table
 elseif 'dmy' == source then
 get\_dmy\_date\_parts (param\_val.val, source\_date); -- get the date parts into the source\_date table
 elseif 'mdy' == source then
 get\_mdy\_date\_parts (param\_val.val, source\_date); -- get the date parts into the source\_date table
 end

 if 'ymd' == format and 1582 > tonumber(source\_date.year) then -- ymd format dates not allowed before 1582
 return false; -- abandon reformatting
 end

 if short then
 format\_str = short\_formats[format];
 else
 format\_str = long\_formats[format];
 end
 -- convert date and save;
 date\_parameters\_list[param\_name].val = mw.text.trim (os.date (format\_str, os.time(source\_date))); -- strip leading space when single digit day and %e is first format
 result = true;
 end -- if
 end -- for
 end -- if
 end -- if
 end -- for
return result; -- declare result and done
end

--[[--------------------------< D A T E \_ H Y P H E N \_ T O \_ D A S H >----------------------------------------

Loops through the list of date-holding parameters and converts any hyphen to an ndash. Not called if the cs1|2
template has any date errors.

Modifies the date\_parameters\_list and returns true if hyphens are replaced, else returns false.

]]

local function date\_hyphen\_to\_dash (date\_parameters\_list)
 local result = false;
 local n;
 for param\_name, param\_val in pairs(date\_parameters\_list) do -- for each date-holding parameter in the list
 if is\_set (param\_val.val) then
 if not mw.ustring.match (param\_val.val, '%d%d%d%d%-%d%d%-%d%d') then -- for those that are not ymd dates (ustring because here digits may not be western)
 param\_val.val, n = param\_val.val:gsub ('%-', '–'); -- replace any hyphen with ndash
 if 0 ~= n then
 date\_parameters\_list[param\_name].val = param\_val.val; -- update the list
 result = true;
 end
 end
 end
 end
 return result; -- so we know if any hyphens were replaced
end

--[[-------------------------< D A T E \_ N A M E \_ X L A T E >------------------------------------------------

Attempts to translate English month names to local-language month names using names supplied by MediaWiki's
date parser function. This is simple name-for-name replacement and may not work for all languages.

if xlat\_dig is true, this function will also translate western (English) digits to the local language's digits.
This will also translate ymd dates.

]]

local function date\_name\_xlate (date\_parameters\_list, xlt\_dig)
 local xlate;
 local mode; -- long or short month names
 local modified = false;
 local date;

 for param\_name, param\_val in pairs(date\_parameters\_list) do -- for each date-holding parameter in the list
 if is\_set(param\_val.val) then -- if the parameter has a value
 date = param\_val.val;
 for month in mw.ustring.gmatch (date, '%a+') do -- iterate through all dates in the date (single date or date range)
 if cfg.date\_names.en.long[month] then
 mode = 'F'; -- English name is long so use long local name
 elseif cfg.date\_names.en.short[month] then
 mode = 'M'; -- English name is short so use short local name
 else
 mode = nil; -- not an English month name; could be local language month name or an English season name
 end

 if mode then -- might be a season
 xlate = mw.getContentLanguage():formatDate(mode, '1' .. month); -- translate the month name to this local language
 date = mw.ustring.gsub (date, month, xlate); -- replace the English with the translation
 date\_parameters\_list[param\_name].val = date; -- save the translated date
 modified = true;
 end
 end

 if xlt\_dig then -- shall we also translate digits?
 date = date:gsub ('%d', cfg.date\_names.xlate\_digits); -- translate digits from western to 'local digits'
 date\_parameters\_list[param\_name].val = date; -- save the translated date
 modified = true;
 end
 end
 end

 return modified;
 end

--[[--------------------------< S E T \_ S E L E C T E D \_ M O D U L E S >--------------------------------------

Sets local imported functions table to same (live or sandbox) as that used by the other modules.

]]

local function set\_selected\_modules (cfg\_table\_ptr, utilities\_page\_ptr)
 is\_set = utilities\_page\_ptr.is\_set; -- import functions from selected Module:Citation/CS1/Utilities module
 in\_array = utilities\_page\_ptr.in\_array; -- import functions from selected Module:Citation/CS1/Utilities module
 cfg = cfg\_table\_ptr; -- import tables from selected Module:Citation/CS1/Configuration
end

return { -- return exported functions
 dates = dates,
 year\_date\_check = year\_date\_check,
 reformat\_dates = reformat\_dates,
 date\_hyphen\_to\_dash = date\_hyphen\_to\_dash,
 date\_name\_xlate = date\_name\_xlate,
 set\_selected\_modules = set\_selected\_modules
 }