Documentation icon دستاویز [تخلیق]
--------------------------------------------------------------------------------
-- Module:Archive
--
-- This module provides classes for easily working with archive pages.
--------------------------------------------------------------------------------

-- Load modules
require('Module:No globals')

-- Modules to be lazily loaded
local exponentialSearch -- [[Module:Exponential search]]

-- Constants
local DEFAULT_NUMBERED_SUBPAGE_FORMAT = 'Archive ${NUMBER}'
local DEFAULT_NUMBERED_ARCHIVE_FORMAT_STRING = '%01d'
local DEFAULT_NUMBERED_ARCHIVE_PATTERN = '(%d+)'
local DEFAULT_DATED_SUBPAGE_FORMAT = '${MONTH} ${YEAR}'
local DEFAULT_DATED_ARCHIVE_FORMAT_STRING = 'F Y'
local DEFAULT_DATED_ARCHIVE_PATTERN = '(%w+ %d%d%d%d)'

--------------------------------------------------------------------------------
-- Helper functions
--------------------------------------------------------------------------------

-- Make a class, with optional constructor and parent class.
local function makeClass(options)
	options = options or {}

	-- Make the class table. Classes inheriting from a parent class set the
	-- parent class table as their metatable.
	local class
	if options.inheritsFrom then
		class = setmetatable({}, options.inheritsFrom)
	else
		class = {}
	end

	-- Allow objects and child classes to inherit methods and static properties
	-- from the class table.
	class.__index = class

	-- Make the constructor
	function class.new(...)
		-- Make the object table. If this is a child class, the object is made
		-- from the constructor of the parent class.
		local self
		if options.inheritsFrom then
			self = options.inheritsFrom.new(...)
		else
			self = {}
		end

		-- Allow objects to inherit methods and static properties from the class
		-- table.
		setmetatable(self, class)

		-- Apply the constructor to the object.
		if options.constructor then
			options.constructor(self, ...)
		end

		return self
	end

	return class
end

local function validateNamedTitleArg(funcName, key, obj)
	local tp = type(obj)
	if tp == 'table' and type(obj.getContent) == 'function' then
		-- val is a mw.title object
		return obj
	elseif tp == 'string' then
		local title = mw.title.new(obj)
		if not title then
			error(string.format(
				"bad named argument %s to '%s' ('%s' is not a valid title)",
				key, funcName, obj
			), 3)
		end
		return title
	else
		error(string.format(
			"bad named argument %s to '%s' (table or string expected, got %s)",
			key, funcName, type(obj)
		), 3)
	end
end

--------------------------------------------------------------------------------
-- ArchivePage class
-- Represents an individual archive page
--------------------------------------------------------------------------------

local ArchivePage = makeClass{constructor = function (self, options)
	options = options or {}
	self._title = validateNamedTitleArg('ArchivePage.new', 'title', options.title)
end}

function ArchivePage:getTitle()
	return self._title
end

function ArchivePage:exists()
	return self._title.exists
end

function ArchivePage:__tostring()
	return tostring(self._title)
end

-- Not strictly a part of the class, but we keep the __eq metamethod code here
-- as it fits better here than anywhere else. We can't use the same function
-- object for both NumberedArchivePage:__eq and DatedArchivePage:__eq, otherwise
-- NumberedArchivePage objects and DatedArchivePage objects with the same title
-- would be equal to each other, which we don't want.
local function makeArchivePageEqualsMetamethod()
	return function (self, obj)
		return self._title == obj._title
	end
end

--------------------------------------------------------------------------------
-- NumberedArchivePage class
-- Represents an individual numbered archive page
--------------------------------------------------------------------------------

local NumberedArchivePage = makeClass{
	inheritsFrom = ArchivePage
}

function NumberedArchivePage:getArchiveNumber()
	local pattern = DEFAULT_NUMBERED_SUBPAGE_FORMAT
		:gsub('${NUMBER}', 'TEMPNUMBERMAGICWORD')
		:gsub('%p', '%%%0')
		:gsub(
			'TEMPNUMBERMAGICWORD',
			DEFAULT_NUMBERED_ARCHIVE_PATTERN:gsub('%%', '%%%%') -- escape % symbols in replacement string
		)
	pattern = '^' .. pattern .. '$'
	local number = self._title.subpageText:match(pattern)
	number = tonumber(number)
	if number then
		return number
	else
		error('error in getArchiveNumber: could not find number pattern in the subpage text')
	end
end

-- Define metamethods
NumberedArchivePage.__eq = makeArchivePageEqualsMetamethod()
NumberedArchivePage.__tostring = ArchivePage.__tostring

--------------------------------------------------------------------------------
-- DatedArchivePage class
-- Represents an individual archive page where the archives are organized by
-- date
--------------------------------------------------------------------------------

local DatedArchivePage = makeClass{
	inheritsFrom = ArchivePage
}

function DatedArchivePage:getBaseTitle()
	if not self._baseTitle then
		self._baseTitle = self._title.basePageTitle
	end
	return self._baseTitle
end

--[[
function DatedArchivePage:getArchiveCollection(options)
	if not self.archiveCollection then
		local baseTitle = self:getBaseTitle()
		local archiveFormat = self:getArchiveFormat()
		local date = self:getDate()
	end
	return self._archiveCollection
end
--]]

-- Define metamethods
DatedArchivePage.__eq = makeArchivePageEqualsMetamethod()
DatedArchivePage.__tostring = ArchivePage.__tostring

--------------------------------------------------------------------------------
-- ArchiveCollection
-- Represents a collection of archive pages. Should not be used directly, but
-- through one of its subclasses.
--------------------------------------------------------------------------------

local ArchiveCollection = makeClass{constructor = function (self, options)
	self._hasArchivePage = options.hasArchivePage
end}

ArchiveCollection._archivePageClass = ArchivePage

function ArchiveCollection:getBaseTitle()
	return self._baseTitle
end

function ArchiveCollection:getArchivePage(archive)
	local subpage = self._subpageFormat
	for variable, methodName in pairs(self._substitutions) do
		local pattern = '${' .. variable .. '(:?)(.-)}'
		subpage = subpage:gsub(pattern, function (colon, formatString)
			if colon == ':' and formatString ~= '' then
				return self[methodName](self, formatString, archive)
			else
				return self[methodName](self, nil, archive)
			end
		end)
	end
	return self._archivePageClass.new{title = self._baseTitle.prefixedText .. '/' .. subpage}
end

function ArchiveCollection:_exponentialArchiveSearch(archiveFunc)
	exponentialSearch = exponentialSearch or require('Module:Exponential search')
	local archiveNumber = exponentialSearch(function (i)
		return self:getArchivePage(archiveFunc(i)):exists()
	end)
	if not archiveNumber then
		return nil
	end
	return self:getArchivePage(archiveFunc(archiveNumber))
end

--------------------------------------------------------------------------------
-- NumberedArchiveCollection
-- Represents a collection of archive pages organized by number
--------------------------------------------------------------------------------

local NumberedArchiveCollection = makeClass{
	inheritsFrom = ArchiveCollection,
	constructor = function (self, options)
		options = options or {}
		self._subpageFormat = options.subpageFormat or DEFAULT_NUMBERED_SUBPAGE_FORMAT
		self._baseTitle = validateNamedTitleArg(
			'NumberedArchiveCollection.new',
			'baseTitle',
			options.baseTitle
		)
	end
}

NumberedArchiveCollection._archivePageClass = NumberedArchivePage

NumberedArchiveCollection._substitutions = {
	NUMBER = '_formatNumberString',
}

function NumberedArchiveCollection:_formatNumberString(formatString, archive)
	formatString = formatString or DEFAULT_NUMBERED_ARCHIVE_FORMAT_STRING
	return formatString:format(archive)
end

function NumberedArchiveCollection:getEarliestArchivePage()
	if self._hasArchivePage then
		local existingArchiveNumber = self._hasArchivePage:getArchiveNumber()
		return self:_exponentialArchiveSearch(function (i)
			return existingArchiveNumber - i + 1
		end)
	else
		local firstArchive = self:getArchivePage(1)
		if firstArchive:exists() then
			return firstArchive
		else
			return nil
		end
	end
end

function NumberedArchiveCollection:getLatestArchivePage()
	return self:_exponentialArchiveSearch(function (i)
		return i
	end)
end

-----------------------------------------------------------------------------
-- DatedArchiveCollection
-- Represents a collection of archive pages organized by date
--------------------------------------------------------------------------------

local DatedArchiveCollection = makeClass{
	inheritsFrom = ArchiveCollection,
	constructor = function (self, options)
		options = options or {}
		self._subpageFormat = options.subpageFormat or DEFAULT_DATED_SUBPAGE_FORMAT
		self._baseTitle = validateNamedTitleArg(
			'DatedArchiveCollection.new',
			'baseTitle',
			options.baseTitle
		)
		self._lang = mw.language.getContentLanguage()
	end
}

DatedArchiveCollection._archivePageClass = DatedArchivePage

DatedArchiveCollection._substitutions = {
	MONTH = '_formatMonthString',
	YEAR = '_formatYearString',
}

function DatedArchiveCollection:_formatDate(formatString, timestamp)
	return self._lang:formatDate(formatString, timestamp)
end

function DatedArchiveCollection:_formatDateFragment(formatString, archive, validFragments, default)
	local format
	if formatString then
		for _, s in ipairs(validFragments) do
			if formatString == s then
				format = formatString
				break
			end
		end
		if not format then
			format = default
		end
	else
		format = default
	end
	return self:_formatDate(format, archive)
end

function DatedArchiveCollection:_formatMonthString(formatString, archive)
	return self:_formatDateFragment(formatString, archive, {'M', 'F', 'm', 'n', 'xg'}, 'F')
end

function DatedArchiveCollection:_formatYearString(formatString, archive)
	return self:_formatDateFragment(formatString, archive, {'Y'}, 'Y')
end

function DatedArchiveCollection:_formatDateString(formatString, archive)
	formatString = formatString or DEFAULT_DATED_ARCHIVE_FORMAT_STRING
	return self:_formatDate(formatString, archive)
end

function DatedArchiveCollection:getEarliestArchivePage()
	-- TODO: use date instead of "now" to avoid bug where March 31st - 1 month is March 3rd
	return self:_exponentialArchiveSearch(function (i)
		return 'now - ' .. (i - 1) .. ' months'
	end)
end

function DatedArchiveCollection:getLatestArchivePage()
	return self:getArchivePage('February 2017')
end

--------------------------------------------------------------------------------
-- Exports
--------------------------------------------------------------------------------

return {
	NumberedArchivePage = NumberedArchivePage,
	NumberedArchiveCollection = NumberedArchiveCollection,
	DatedArchivePage = DatedArchivePage,
	DatedArchiveCollection = DatedArchiveCollection,
}