Module:Cronos/Sandbox

From Meta, a Wikimedia project coordination wiki
Module documentation
---
-- This is the module that auto-generates the [[Meta:Cronos]] monthly calendar
--
-- Thanks to this module we do not need a bot to keep the calendar
-- updated. Note that this module needs to request some MediaWiki titles.
-- This increase the "Expensive parser function count" by ~40. Anyway, the
-- global limit actually is 500.
--
-- Happy hacking!
--
-- @author [[User:Valerio Bozzolan]]
-- @creation 2019-04-13
---

local DEFAULT_CONFIG = {
	-- Namespace name for the event pages
	--  e.g. 'Meta' for [[Meta:Cronos/Events/2000-12-31]
	['event_ns'] = 'Meta',

	-- Expected prefix for every Cronos event page
	--  e.g. 'Cronos/Event/' for [[Meta:Cronos/Events/2000-12-31]
	['event_prefix'] = 'Cronos/Events/',

	-- Timezone
	--  '0': use local wiki timezone
	--  '1': use UTC timezone
	['utc'] = '0',

	-- Week
	--  '1': week start from Monday
	--  '0': week start from Sunday
	['start_from_monday'] = '1',

	-- How much days to be displayed in the calendar
	['days'] = '28',

	-- How much months to shift in the past or in the future
	--   '0': Display the current month
	--   '1': Display the next month
	--  '-1': Display the previous month
	--   etc.
	['month_shift'] = '0',

	-- Maximum length of an Event title in bytes before truncating
	['max_title_len' ] = '20',
}

-- list of all 'MediaWiki:Sunday' etc.
local MW_WEEK_NAMES = {
	'Sunday',
	'Monday',
	'Thursday',
	'Wednesday',
	'Thursday',
	'Friday',
	'Saturday',
}

-- list of all 'MediaWiki:January' etc.
local MW_MONTH_NAMES = {
	'January',
	'February',
	'March',
	'April',
	'May',
	'June',
	'July',
	'August',
	'September',
	'October',
	'November',
	'December',
}

local p = {}

-- constants
local SECONDS_IN_DAY = 86400;
local SECONDS_IN_MONTH = SECONDS_IN_DAY * 28 -- Not really :^) it's a lazy hack

local HEADING_TEMPLATE = 'Cronos month/Head'

-- this Lua pattern distinguish single events, and the known template arguments
local EVENT_PATTERN = '{{ *[Cc]ronos event'
local EVENT_ARG_TITLE = 'title'
local EVENT_ARG_WHEN  = 'when'

--- Parse a single line of a template argument
-- @param string s Wikitext
-- @param string|nil arg Argument name
local function parse_arg( s, arg )
	local pattern = '|%s*' .. arg .. '%s*=(.-)\n'
	local capture = mw.ustring.match( s, pattern )
	if capture ~= nil then
		capture = mw.text.trim( capture )
	end
	return capture
end

--- Parse a single title
--
-- @param string s Wikitext
local function parse_title( s )
	local pattern = '(.+)=%s*\n'
	local capture = mw.ustring.match( s, pattern )
	if capture ~= nil then
		capture = mw.text.trim( capture, " =" )
	end
	return capture
end

---
-- Simple class describing a single CronosEvent
---
local CronosEvent = {}
CronosEvent.__index = CronosEvent

---
-- Create a CronosEvent
--
-- @param string title
-- @param string when
function CronosEvent:create( day, title, when )
	local event = {}
	setmetatable( event, CronosEvent )
	event.day   = day
	event.title = title
	event.when  = when
	return event
end

--- Create a CronosEvent parsing a block of wikitext
--
-- @param table  day           The day this event belongs to
-- @param string wikitext      Wikitext that contains part of the arguments
-- @param string section_title Title of the current section
-- @return table|nil
function CronosEvent:createParsing( day, wikitext, section_title )
	local title = parse_arg( wikitext, EVENT_ARG_TITLE ) or section_title
	if title ~= nil then
		local when = parse_arg( wikitext, EVENT_ARG_WHEN  )
		if when ~= nil then
			return CronosEvent:create( day, title, when )
		end
	end
	return nil
end

---
-- Generate a brief abstract of this specific event
--
function CronosEvent:renderBrief( frame )
	frame = frame or mw.getCurrentFrame()

	-- eventually truncate (but put the complete title inside a tooltip
	local title = self.title
	local title_is_pure_text = mw.ustring.find( title, '[', 1, true ) == nil
	local maxlen = tonumber( self.day.config.max_title_len )
	if mw.ustring.len( title ) > maxlen and title_is_pure_text then
		local truncated = mw.ustring.sub( title, 1, maxlen ) .. '…'
		title =	tostring( mw.html.create( 'span' )
			:attr( 'title', title )
			:wikitext( truncated ) )
	end

	return frame:expandTemplate{
		title = 'Cronos event brief',
		args = {
			self.day.yyyy,
			self.day.mm,
			self.day.dd,
			title,
			self.when,
		}
	}	
end

---
-- Simple class collecting events
--
local CronosDay = {}
CronosDay.__index = CronosDay

---
-- Create a CronosDay table
--
-- @param string date Full date formatted in 'yyyy-mm-dd'
-- @param string y Year
-- @param string m Month 0-12
-- @param string d Day   0-31
-- @param table config Configuration
function CronosDay:create( date, config )
	local day = {}
	setmetatable( day, CronosDay )
	local dmy  = mw.text.split( date, '-', true )
	day.yyyy   = dmy[ 1 ]
	day.mm     = dmy[ 2 ]
	day.dd     = dmy[ 3 ]
	day.date   = date
	day.m      = tonumber( day.mm )

	local timestamp = os.time{ 
		year  = day.yyyy,
		month = day.m,
		day   = day.dd,
	}

	day.week = os.date( '%w', timestamp ) -- sunday is 0

	day.config = config
	day.title  = config.event_prefix .. date
	day.events = nil
	return day
end

---
-- Get the wikitext of this event page
--
-- @return string|nil
function CronosDay:wikitext()
	return mw.title.makeTitle( self.config.event_ns, self.title )
		:getContent()
end

---
-- Parse this day page looking for events
--
function CronosDay:parse()
	self.events = {}
	local wikitext = self:wikitext()
	local ok = wikitext ~= nil
	if ok then
		local sections = mw.text.split( wikitext, "\n=" )
		for _, section in pairs( sections ) do
			local section_title = parse_title( section )
			local blocks = mw.text.split( section, EVENT_PATTERN )
			for i, block in pairs( blocks ) do
				local event = CronosEvent:createParsing( self, block, section_title )
				if event ~= nil then
					self.events[ #self.events + 1 ] = event
				end
			end
		end
	end
	return ok
end

---
-- Get the CronosEvent(s) related to this day (if any)
--
-- @return table
--
function CronosDay:getEvents()
	if self.events == nil then
		self:parse()
	end
	return self.events
end

---
-- Generate a brief header of this day
--
function CronosDay:renderBrief( frame )
	frame = frame or mw.getCurrentFrame()
	return frame:expandTemplate{
		title = 'Cronos day brief',
		args = { self.yyyy, self.mm, self.dd }
	}
end

---
-- Generate a list of all the events in this day
--
function CronosDay:renderEvents( frame )
	local s = ''
	frame = frame or mw.getCurrentFrame()
	for _, event in pairs( self:getEvents() ) do
		s = s .. "\n" .. event:renderBrief( frame )
	end
	return  s
end

---
-- Generate a list of all the events in this day
--
function CronosDay:render( frame )
	frame = frame or mw.getCurrentFrame()
	return self:renderBrief(  frame )
	    .. self:renderEvents( frame )
end

---
-- Get a complete configuration inheriting default options
--
-- @param table|nil config
--
function getConfig( config )
	config = config or {}
	for k, default in pairs( DEFAULT_CONFIG ) do
		local v = config[ k ]
		if v ~= nil then
			v = mw.text.trim( tostring( config[ k ] ) )
			if v == '' then
				v = nil
			end
		end
		config[ k ] = v or default
	end
	return config
end

---
-- Generate the monthly calendar (Lua API)
--
-- @param object config
--
function p._main( config )

	-- localized week names
	local weekNames = {}
	for k, v in pairs( MW_WEEK_NAMES ) do
		weekNames[ k ] = mw.message.new( v ):plain()
	end

	function fill_empty_week_days( week )
		s = ''
		while week < 7 do
			s = s .. '\n|'
			week = week + 1
		end
		return s
	end

	local frame = mw.getCurrentFrame()

	-- inherit the default configs
	config = getConfig( config )

	local days = tonumber( config.days )
	local start_from_monday = config.start_from_monday == '1'

	-- today in unix time
	local time = os.time()

	-- shift the current time month forward or backward by some months
	local month_shift = tonumber( config.month_shift )
	time = time + month_shift * SECONDS_IN_MONTH

	-- see the documentation of os.date() about UTC or local time
	local format_prefix = ''
	if config.utc == '1' then
		format_prefix = '!'
	end

	-- os.date() formats to obtain a date table or a date in Y-m-d
	local format_date = format_prefix .. '*t'
	local format_ymd  = format_prefix .. '%F'

	-- today's date
	local date = os.date( format_date, time )

	-- the week day starts from sunday = 1
	local week_day = date.wday

	-- how much days since the start of this week
	local days_since_start_week = week_day - 1
	if start_from_monday then
		days_since_start_week = days_since_start_week - 1
		if days_since_start_week == 0 then
			days_since_start_week = 6
		end
	end

	-- calculate the date when this week started
	local seconds_since_start_week = days_since_start_week * SECONDS_IN_DAY
	local time_start_of_week = time - seconds_since_start_week
	local date_start_of_week = os.date( format_date, time_start_of_week )

	-- generate a list of days for this calendar
	local daylist = {}
	local i = 0
	while i < days do
		local event_time = time_start_of_week + i * SECONDS_IN_DAY
		local event_ymd  = os.date( format_ymd, event_time )
		i = i + 1
		daylist[ i ] = CronosDay:create( event_ymd, config	)
	end

	-- heading
	local s = frame:expandTemplate{ title = HEADING_TEMPLATE }

	-- prepare week indexes
	local week_start = 0
	local week_end   = 7
	if start_from_monday then
		week_end   = week_end   + 1
		week_start = week_start + 1
	end

	-- start of the table
	s = s .. '\n{| class="wikitable cronos-month-table"'

	-- generate the table body
	local i = 0
	local lastMonth = -1
	while i < days do

		-- print a newline?
		local newline = false

		local week = i % 7
		local day  = daylist[ i + 1 ]

		-- print week labels
		if day.m ~= lastMonth then
			lastMonth = day.m

			local pre_days = 7 - week
			if pre_days > 0 then
				s = s .. fill_empty_week_days( 7 - week )
				s = s .. "\n|-\n"
			end

			-- add row with month name
			local monthName = mw.message.new( MW_MONTH_NAMES[ lastMonth ] ):plain()
			s = s .. "\n|-\n"
			s = s .. '\n!colspan="7"|' .. monthName
			s = s .. "\n|-\n"

			-- add row with week names
			for k, v in pairs( weekNames ) do
				s = s .. "\n!" .. v
			end

			if week > 0 then
				s = s .. fill_empty_week_days( week )
				s = s .. "\n|-"
			end
		end

		-- new row
		if week == 0 then
			s = s .. "\n|-"
		end

		-- render day box
		s = s .. "\n|" .. day:render( frame )

		-- next day
		i = i + 1
	end

	return s .. "\n|}"
end

---
-- Generate the monthly calendar (Wikitext API)
--
function p.main( frame )
	local args = frame:getParent().args or frame.args
	return p._main( args )
end

---
-- Get a single CronosDay object (Lua API)
--
-- You can use this in your Lua console e.g.:
--    =mw.logObject( p._test( '2019-03-25' ) )
--
-- @param string date Date formatted as 'yyyy-mm-dd'
-- @param table config Configuration
--
function p._day( date, config )
	local config = getConfig( config )
	local day = CronosDay:create( date, config )
	day:parse()
	return day
end

---
-- Print the events of a single CronosDay (Wikitext API)
--
-- You can use this in your Lua console to do tests e.g.:
--    =mw.logObject( p._day( '2019-03-25' ):getEvents() )
--
-- @param object config
--
function p.day( frame )
	local args = frame:getParent().args or frame.args
	local date = frame.args[ 1 ]
	local day = p._day( date, frame.args )
	return day:renderEvents( frame )
end

return p