Module:Age: Difference between revisions

    From Nonbinary Wiki
    (Change order of calculation of is_leap_year for tine efficiency tweak (tested with some manual code returns), and some if/else's on mutually exclusive combinations of neg year/month/day (tested with :Australian_Labor_Party))
    m (clarify)
    Line 4: Line 4:
             {{Gregorian serial date}}      gsd_ymd
             {{Gregorian serial date}}      gsd_ymd
    Calendar functions will be needed in many areas, so this may be superseded
    Calendar functions will be needed in many areas, so this may be superseded
    by some other system, perhaps using PHP functions accessed via mw.
    by some other system, perhaps using PHP functions accessed via mediawiki.
    ]]
    ]]



    Revision as of 04:59, 3 December 2013

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

    --[[ Code for some date functions, including implementations of:
            {{Age in days}}                 age_days
            {{Age in years and months}}     age_ym
            {{Gregorian serial date}}       gsd_ymd
    Calendar functions will be needed in many areas, so this may be superseded
    by some other system, perhaps using PHP functions accessed via mediawiki.
    ]]
    
    local MINUS = '−'  -- Unicode U+2212 MINUS SIGN
    
    local function number_name(number, singular, plural, sep)
        -- Return the given number, converted to a string, with the
        -- separator (default space) and singular or plural name appended.
        plural = plural or (singular .. 's')
        sep = sep or ' '
        return tostring(number) .. sep .. ((number == 1) and singular or plural)
        -- this uses an interesting trick of Lua:
        --  * and reurns false if the first argument is false, and the second otherwise, so (number==1) and singular returns singular if its 1, returns false if it is only 1
        --  * or returns the first argument if it is not false, and the second argument if the first is false
        --  * so, if number is 1, and evaluates (true and singular) returning (singular); or evaluates (singular or plural), finds singular non-false, and returns singular
        --  * but, if number is not 1, and evaluates (false and singular) returning (false); or evaluates (false or plural), and is forced to return plural
    end
    
    local function strip_to_nil(text)
        -- If text is a non-blank string, return its content with no leading
        -- or trailing whitespace.
        -- Otherwise return nil (a nil or empty string argument gives a nil
        -- result, as does a string argument of only whitespace).
        if type(text) == 'string' then
            local result = text:match("^%s*(.-)%s*$")
            if result ~= '' then
                return result
            end
        end
        return nil
    end
    
    local function is_leap_year(year)
        -- Return true if year is a leap year, assuming Gregorian calendar.
        return year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0)
    end
    
    local function days_in_month(year, month)
        -- Return number of days (1..31) in given month (1..12).
        local month_days = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }
        if month == 2 and is_leap_year(year) then
            return 29
        end
        return month_days[month]
    end
    
    -- A table to get current year/month/day (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
                return rawget(self, key)
            end
        })
    
    local function date_component(named, positional, component)
        -- Return the first of the two arguments that is not nil and is not empty.
        -- If both are nil, return the current date component, if specified.
        -- The returned value is nil or is a number.
        -- This translates empty arguments passed to the template to nil, and
        -- optionally replaces a nil argument with a value from the current date.
        named = strip_to_nil(named)
        if named then
            return tonumber(named)
        end
        positional = strip_to_nil(positional)
        if positional then
            return tonumber(positional)
        end
        if component then
            return current[component]
        end
        return nil
    end
    
    local function gsd(year, month, day)
        -- Return the Gregorian serial day (an integer >= 1) for the given date,
        -- or return nil if the date is invalid (only check that year >= 1).
        -- This is the number of days from the start of 1 AD (there is no year 0).
        -- This code implements the logic in [[Template:Gregorian serial date]].
        if year < 1 then
            return nil
        end
        local floor = math.floor
        local days_this_year = (month - 1) * 30.5 + day
        if month > 2 then
            if is_leap_year(year) then
                days_this_year = days_this_year - 1
            else
                days_this_year = days_this_year - 2
            end
            if month > 8 then
                days_this_year = days_this_year + 0.9
            end
        end
        days_this_year = floor(days_this_year + 0.5)
        year = year - 1
        local days_from_past_years = year * 365
            + floor(year / 4)
            - floor(year / 100)
            + floor(year / 400)
        return days_from_past_years + days_this_year
    end
    
    local Date = {
        -- A naive date that assumes the Gregorian calendar always applied.
        year = 0,   -- 1 to 9999 (0 if never set)
        month = 1,  -- 1 to 12
        day = 1,    -- 1 to 31
        isvalid = false,
        new = function (self, o)
            o = o or {}
            setmetatable(o, self)
            self.__index = self
            return o
        end
    }
    
    function Date:__lt(rhs)
        -- Return true if self < rhs.
        if self.year < rhs.year then
            return true
        elseif self.year == rhs.year then
            if self.month < rhs.month then
                return true
            elseif self.month == rhs.month then
                return self.day < rhs.day
            end
        end
        return false
        -- probably simplify to return (self.year < rhs.year) or ((self.year == rhs.year) and ((self.month < rhs.month) or ((self.month == rhs.month) and (self.day < rhs.day))))
        -- would be just as efficient, as lua does not evaluate second argument of (true or second_argument)
        -- or similarly return self.year < rhs.year ? true : self.year > rhs.year ? false : self.month < rhs.month ? true : self.month > rhs.month ? false : self.day < rhs.day
    end
    
    function Date:set_current()
        -- Set date from current time (UTC) and return self.
        self.year = current.year
        self.month = current.month
        self.day = current.day
        self.isvalid = true
        return self
    end
    
    function Date:set_ymd(y, m, d)
        -- Set date from year, month, day (strings or numbers) and return self.
        -- LATER: If m is a name like "March" or "mar", translate it to a month.
        y = tonumber(y)
        m = tonumber(m)
        d = tonumber(d)
        if type(y) == 'number' and type(m) == 'number' and type(d) == 'number' then
            self.year = y
            self.month = m
            self.day = d
            self.isvalid = (1 <= y and y <= 9999 and 1 <= m and m <= 12 and
                            1 <= d and d <= days_in_month(y, m))
        end
        return self
    end
    
    local DateDiff = {
        -- Simple difference between two dates, assuming Gregorian calendar.
        isnegative = false,  -- true if second date is before first
        years = 0,
        months = 0,
        days = 0,
        new = function (self, o)
            o = o or {}
            setmetatable(o, self)
            self.__index = self
            return o
        end
    }
    
    function DateDiff:set(date1, date2)
        -- Set difference between the two dates, and return self.
        -- Difference is negative if the second date is older than the first.
        local isnegative
        if date2 < date1 then
            isnegative = true
            date1, date2 = date2, date1
        else
            isnegative = false
        end
        -- It is known that date1 <= date2.
        local y1, m1, d1 = date1.year, date1.month, date1.day
        local y2, m2, d2 = date2.year, date2.month, date2.day
        local years, months, days = y2 - y1, m2 - m1, d2 - d1
        if days < 0 then
            days = days + days_in_month(y1, m1)
            months = months - 1
        end
        if months < 0 then
            months = months + 12
            years = years - 1
        end
        self.years, self.months, self.days, self.isnegative = years, months, days, isnegative
        return self
    end
    
    function DateDiff:age_ym()
        -- Return text specifying difference in years, months.
        local sign = self.isnegative and MINUS or ''
        local mtext = number_name(self.months, 'month')
        local result
        if self.years > 0 then
            local ytext = number_name(self.years, 'year')
            if self.months == 0 then
                result = ytext
            else
                result = ytext .. ',&nbsp;' .. mtext
            end
        else
            if self.months == 0 then
                sign = ''
            end
            result = mtext
        end
        return sign .. result
    end
    
    local function error_wikitext(text)
        -- Return message for display when template parameters are invalid.
        local prefix = '[[Module talk:Age|Module error]]:'
        local cat = '[[Category:Age error]]'
        return '<span style="color:black; background-color:pink;">' ..
                prefix .. ' ' .. text .. cat .. '</span>'
    end
    
    local function age_days(frame)
        -- Return age in days between two given dates, or
        -- between given date and current date.
        -- This code implements the logic in [[Template:Age in days]].
        -- Like {{Age in days}}, a missing argument is replaced from the current
        -- date, so can get a bizarre mixture of specified/current y/m/d.
        local args = frame:getParent().args
        local year1  = date_component(args.year1 , args[1], 'year' )
        local month1 = date_component(args.month1, args[2], 'month')
        local day1   = date_component(args.day1  , args[3], 'day'  )
        local year2  = date_component(args.year2 , args[4], 'year' )
        local month2 = date_component(args.month2, args[5], 'month')
        local day2   = date_component(args.day2  , args[6], 'day'  )
        local gsd1 = gsd(year1, month1, day1)
        local gsd2 = gsd(year2, month2, day2)
        if gsd1 and gsd2 then
            local sign = ''
            local result = gsd2 - gsd1
            if result < 0 then
                sign = MINUS
                result = -result
            end
            return sign .. tostring(result)
        end
        return error_wikitext('Cannot handle dates before the year 1 AD')
    end
    
    local function age_ym(frame)
        -- Return age in years and months between two given dates, or
        -- between given date and current date.
        local args = frame:getParent().args
        local fields = {}
        for i = 1, 6 do
            fields[i] = strip_to_nil(args[i])
        end
        local date1, date2
        if fields[1] and fields[2] and fields[3] then
            date1 = Date:new():set_ymd(fields[1], fields[2], fields[3])
        end
        if not (date1 and date1.isvalid) then
            return error_wikitext('Need date: year, month, day')
        end
        if fields[4] and fields[5] and fields[6] then
            date2 = Date:new():set_ymd(fields[4], fields[5], fields[6])
            if not date2.isvalid then
                return error_wikitext('Second date should be year, month, day')
            end
        else
            date2 = Date:new():set_current()
        end
        return DateDiff:new():set(date1, date2):age_ym()
    end
    
    local function gsd_ymd(frame)
        -- Return Gregorian serial day of the given date, or the current date.
        -- Like {{Gregorian serial date}}, a missing argument is replaced from the
        -- current date, so can get a bizarre mixture of specified/current y/m/d.
        -- This accepts positional arguments, although the original template does not.
        local args = frame:getParent().args
        local year  = date_component(args.year , args[1], 'year' )
        local month = date_component(args.month, args[2], 'month')
        local day   = date_component(args.day  , args[3], 'day'  )
        local result = gsd(year, month, day)
        if result then
            return tostring(result)
        end
        return error_wikitext('Cannot handle dates before the year 1 AD')
    end
    
    return { age_days = age_days, age_ym = age_ym, gsd = gsd_ymd }