Module:OnThisDay

Revision as of 00:12, 15 July 2026 by KWCBot (talk | contribs) (main(): accept month/day from direct #invoke frame args too (testing))
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

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

local p = {}

----------------------------------------------------------------
-- CONFIG: Week 1 anchor for King Warz Charts
-- Week 1 starts: 2020-12-25 (Friday)
----------------------------------------------------------------
local WEEK1_Y, WEEK1_M, WEEK1_D = 2020, 12, 25

----------------------------------------------------------------
-- Load data from Module:OnThisDay/Data
----------------------------------------------------------------
local ok, data = pcall(mw.loadData, 'Module:OnThisDay/Data')
if not ok or type(data) ~= 'table' then
	data = {}
end

-- Extra day-precise events (certs, first plays, debuts, first #1s),
-- generated by the Control Center's otd_events.py
local ok2, extraEvents = pcall(mw.loadData, 'Module:OnThisDay/Extra')
if not ok2 or type(extraEvents) ~= 'table' then
	extraEvents = {}
end

----------------------------------------------------------------
-- Date helpers (pure Lua date math; no os.time needed)
-- Uses "days from civil" algorithms (safe in Scribunto)
----------------------------------------------------------------
local function isLeap(y)
	return (y % 4 == 0 and y % 100 ~= 0) or (y % 400 == 0)
end

local function daysInMonth(y, m)
	local dim = {31,28,31,30,31,30,31,31,30,31,30,31}
	if m == 2 and isLeap(y) then
		return 29
	end
	return dim[m] or 30
end

-- Convert civil date -> serial day number (Rata Die-ish)
local function daysFromCivil(y, m, d)
	-- Howard Hinnant algorithm
	y = y - (m <= 2 and 1 or 0)
	local era = math.floor(y / 400)
	local yoe = y - era * 400
	local doy = math.floor((153 * (m + (m > 2 and -3 or 9)) + 2) / 5) + d - 1
	local doe = yoe * 365 + math.floor(yoe / 4) - math.floor(yoe / 100) + doy
	return era * 146097 + doe
end

-- Convert serial day number -> civil date
local function civilFromDays(z)
	-- Inverse Howard Hinnant algorithm
	local era = math.floor(z / 146097)
	local doe = z - era * 146097
	local yoe = math.floor((doe - math.floor(doe/1460) + math.floor(doe/36524) - math.floor(doe/146096)) / 365)
	local y = yoe + era * 400
	local doy = doe - (yoe * 365 + math.floor(yoe/4) - math.floor(yoe/100))
	local mp = math.floor((5 * doy + 2) / 153)
	local d = doy - math.floor((153 * mp + 2) / 5) + 1
	local m = mp + (mp < 10 and 3 or -9)
	y = y + (m <= 2 and 1 or 0)
	return y, m, d
end

local function pad2(n)
	if n < 10 then return "0" .. tostring(n) end
	return tostring(n)
end

local function toISO(y, m, d)
	return tostring(y) .. "-" .. pad2(m) .. "-" .. pad2(d)
end

local function parseISO(s)
	if type(s) ~= 'string' then return nil end
	local y, m, d = s:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
	if not y then return nil end
	y, m, d = tonumber(y), tonumber(m), tonumber(d)
	if not y or not m or not d then return nil end
	if m < 1 or m > 12 then return nil end
	local dim = daysInMonth(y, m)
	if d < 1 or d > dim then return nil end
	return y, m, d
end

-- Week number -> Week start ISO date (Friday)
local function weekToISO(weekNum)
	if not weekNum or weekNum < 1 then return nil end
	local base = daysFromCivil(WEEK1_Y, WEEK1_M, WEEK1_D)
	local z = base + (weekNum - 1) * 7
	local y, m, d = civilFromDays(z)
	return toISO(y, m, d)
end

----------------------------------------------------------------
-- Helpers
----------------------------------------------------------------
local u = mw.ustring

local TITLE_JOINERS = {
	['of'] = true, ['the'] = true, ['and'] = true, ['a'] = true,
	['an'] = true, ['in'] = true, ['on'] = true, ['to'] = true,
	['for'] = true, ['with'] = true, ['from'] = true, ['by'] = true,
	['at'] = true, ['or'] = true,
}

local function titlecase(str)
	if not str or str == '' then
		return ''
	end
	str = mw.text.trim(str)
	str = u.lower(str)

	local first = true
	local function up_word(word)
		local bare = u.match(word, '^%p*(.-)%p*$') or word
		if not first and TITLE_JOINERS[bare] then
			return word            -- joiners stay lowercase mid-title
		end
		first = false
		-- capitalize the first LETTER, skipping leading punctuation
		-- so '(rock' becomes '(Rock', not '(rock'
		local pre, ch, rest = u.match(word, '^(%A*)(%a)(.*)$')
		if ch then
			return pre .. u.upper(ch) .. rest
		end
		return word
	end

	str = u.gsub(str, '%S+', up_word)
	return str
end

local function normalizeArtistList(raw)
	if not raw or raw == '' then
		return ''
	end
	local parts = {}
	for part in mw.text.gsplit(raw, ",") do
		part = mw.text.trim(part)
		part = titlecase(part)
		table.insert(parts, part)
	end
	return table.concat(parts, ", ")
end

local function normalizeNo1Field(text, isAlbum)
	if not text or text == '' then
		return text
	end

	local linkInner, afterLink = text:match("%[%[([^%]]+)%]%](.*)")
	if not linkInner then
		return titlecase(text)
	end

	local target, label = linkInner:match("^(.-)|(.+)$")
	if not target then
		target = linkInner
		label  = linkInner
	end

	label = titlecase(label)

	if isAlbum then
		label = "''" .. label .. "''"
	end

	local changed = false

	local beforeSmall, insideParens, afterSmall =
		afterLink:match("^(.-)<small>%s*%((.-)%)%s*</small>(.*)$")

	if insideParens then
		local artistsText = normalizeArtistList(insideParens)
		afterLink = (beforeSmall or "") ..
			"<small>(" .. artistsText .. ")</small>" ..
			(afterSmall or "")
		changed = true
	else
		local b2, insidePlain, a2 =
			afterLink:match("^(.-)<small>%s*(.-)%s*</small>(.*)$")
		if insidePlain then
			local artistsText = normalizeArtistList(insidePlain)
			afterLink = (b2 or "") ..
				"<small>" .. artistsText .. "</small>" ..
				(a2 or "")
			changed = true
		else
			local b3, artistsPlain, a3 =
				afterLink:match("^(%s*)%(%s*(.-)%s*%)(.*)$")
			if artistsPlain then
				local artistsText = normalizeArtistList(artistsPlain)
				afterLink = (b3 or "") ..
					"(" .. artistsText .. ")" ..
					(a3 or "")
				changed = true
			end
		end
	end

	if not changed then
		local newAfter, n = afterLink:gsub("%(([^()]+)%)", function(inner)
			local artistsText = normalizeArtistList(inner)
			return "(" .. artistsText .. ")"
		end, 1)
		if n > 0 then
			afterLink = newAfter
		end
	end

	local link = "[[" .. target .. "|" .. label .. "]]"
	return link .. afterLink
end

local function getMonthDay(args)
	local month = tonumber(args.month)
	local day   = tonumber(args.day)

	if not month or not day then
		local lang = mw.getContentLanguage()
		month = tonumber(lang:formatDate('m'))
		day   = tonumber(lang:formatDate('d'))
	end

	return month, day
end

local function targetDateString(year, month, day)
	return string.format('%04d-%02d-%02d', year, month, day)
end

----------------------------------------------------------------
-- Normalize entry date:
-- - If entry.date is valid ISO, keep it
-- - Else if entry.week exists, compute ISO date from week anchor
-- - Else nil
----------------------------------------------------------------
local function getEntryISODate(entry)
	local datestr = tostring(entry.date or '')
	local y, m, d = parseISO(datestr)
	if y then
		return datestr
	end

	local w = tonumber(entry.week)
	if w then
		return weekToISO(w)
	end

	return nil
end

----------------------------------------------------------------
-- Build per-year index from the data module (using normalized dates)
----------------------------------------------------------------
local by_year = {}
local year_min, year_max = nil, nil

for md, entries in pairs(data) do
	if type(entries) == 'table' then
		for _, entry in ipairs(entries) do
			local iso = getEntryISODate(entry)
			if iso then
				local year = tonumber(iso:sub(1,4))
				if year then
					if not by_year[year] then
						by_year[year] = {}
					end

					-- store a lightweight copy with a normalized date
					table.insert(by_year[year], {
						date = iso,
						week = entry.week,
						no1song = entry.no1song,
						no1album = entry.no1album
					})

					if not year_min or year < year_min then year_min = year end
					if not year_max or year > year_max then year_max = year end
				end
			end
		end
	end
end

for year, list in pairs(by_year) do
	table.sort(list, function(a, b)
		return tostring(a.date or '') < tostring(b.date or '')
	end)
end

----------------------------------------------------------------
-- For a given year and target month/day:
-- 1) pick the last entry whose date <= target
-- 2) if none, pick the first entry whose date >= target
----------------------------------------------------------------
local function pickNearestInYear(year, month, day)
	local list = by_year[year]
	if not list then
		return nil
	end

	local target = targetDateString(year, month, day)

	local chosenBefore = nil
	local firstAfter = nil

	for _, entry in ipairs(list) do
		local d = tostring(entry.date or '')
		if d ~= '' then
			if d <= target then
				chosenBefore = entry
			elseif not firstAfter and d >= target then
				firstAfter = entry
			end
		end
	end

	return chosenBefore or firstAfter
end

local function formatEntryParts(entry)
	local datestr = tostring(entry.date or '')
	local year = datestr:sub(1, 4)
	local week = tonumber(entry.week)
	local weekPart = ''
	if week then
		weekPart = string.format('[[Week %d]] – ', week)
	end

	local songLine, albumLine

	if entry.no1song and entry.no1song ~= '' then
		local songText = normalizeNo1Field(entry.no1song, false)
		songLine = string.format(
			'* %s – %s%s is the #1 song on the charts',
			year,
			weekPart,
			songText
		)
	end

	if entry.no1album and entry.no1album ~= '' then
		local albumText = normalizeNo1Field(entry.no1album, true)
		albumLine = string.format(
			'* %s – %s%s is the #1 album on the charts',
			year,
			weekPart,
			albumText
		)
	end

	return songLine, albumLine
end

----------------------------------------------------------------
-- Main entry point
----------------------------------------------------------------
function p.main(frame)
	-- prefer whichever frame actually carries month/day: template calls
	-- put them in parent.args, direct {{#invoke:}} puts them in frame.args
	local parent = frame:getParent()
	local pargs = parent and parent.args or {}
	local fargs = frame.args or {}
	local args = pargs
	if not (pargs.month or pargs.day) and (fargs.month or fargs.day) then
		args = fargs
	end

	local month, day = getMonthDay(args)
	if not month or not day then
		return 'OnThisDay: could not determine date.'
	end

	local lang = mw.getContentLanguage()
	local currentYear = tonumber(lang:formatDate('Y')) or 2025

	-- Use actual min year found in data; fallback to 2020
	local startYear = year_min or 2020

	local picks = {}
	for year = startYear, currentYear do
		local entry = pickNearestInYear(year, month, day)
		if entry then
			table.insert(picks, entry)
		end
	end

	local songLines, albumLines = {}, {}

	for _, entry in ipairs(picks) do
		local sLine, aLine = formatEntryParts(entry)
		if sLine then table.insert(songLines, sLine) end
		if aLine then table.insert(albumLines, aLine) end
	end

	local out = {}
	table.insert(out, "'''On this day in King Warz Charts history:'''")
	table.insert(out, '')

	if #songLines > 0 then
		table.insert(out, "''Songs''")
		for _, line in ipairs(songLines) do
			table.insert(out, line)
		end
	end

	if #albumLines > 0 then
		table.insert(out, '')
		table.insert(out, "''Albums''")
		for _, line in ipairs(albumLines) do
			table.insert(out, line)
		end
	end

	-- day-precise anniversaries from Module:OnThisDay/Extra
	local mdKey = string.format('%02d-%02d', month, day)
	local ex = extraEvents[mdKey]
	if ex then
		local function addSection(title, rows, cap, fmt)
			if not rows then return end
			local total = 0
			for _ in ipairs(rows) do total = total + 1 end
			if total == 0 then return end
			table.insert(out, '')
			table.insert(out, "''" .. title .. "''")
			local n = 0
			for _, r in ipairs(rows) do
				n = n + 1
				if cap and n > cap then
					table.insert(out, string.format(
						'* <small>...and %d more</small>', total - cap))
					break
				end
				table.insert(out, fmt(r))
			end
		end
		addSection('Artist birthdays', ex.birthdays, nil, function(r)
			if r.dead then
				return string.format('* %d – [[%s]] was born on this day',
					r.year, r.artist)
			end
			return string.format(
				'* %d – [[%s]] was born on this day (turns %d)',
				r.year, r.artist, currentYear - r.year)
		end)
		addSection('Certifications', ex.certs, 6, function(r)
			return string.format('* %d – [[%s]] reached %s',
				r.year, r.page, r.label)
		end)
		addSection('First listens', ex.firstplays, 5, function(r)
			return string.format('* %d – First ever play of [[%s]]',
				r.year, r.page)
		end)
		addSection('Chart debuts', ex.debuts, 5, function(r)
			local nm = r.album and ("''[[" .. r.page .. "]]''") or ("[[" .. r.page .. "]]")
			local chart = r.album and 'album chart' or 'chart'
			return string.format('* %d – %s debuted on the %s at #%d',
				r.year, nm, chart, r.pos)
		end)
		addSection('First week at No. 1', ex.crownings, nil, function(r)
			local nm = r.album and ("''[[" .. r.page .. "]]''") or ("[[" .. r.page .. "]]")
			local chart = r.album and ' on the album chart' or ''
			return string.format(
				'* %d – %s hit #1 for the first time%s ([[Week %d]])',
				r.year, nm, chart, r.week)
		end)
		addSection('Play milestones', ex.playmilestones, 5, function(r)
			local nm = r.album and ("''[[" .. r.page .. "]]''") or ("[[" .. r.page .. "]]")
			return string.format('* %d – %s logged its %s play',
				r.year, nm, r.mlabel)
		end)
		addSection('Artist firsts', ex.artistfirsts, 4, function(r)
			return string.format('* %d – First ever play of a [[%s]] song',
				r.year, r.artist)
		end)
		addSection('Listening records', ex.recorddays, nil, function(r)
			if r.rank == 1 then
				return string.format(
					'* %d – Your busiest listening day on record (%s plays)',
					r.year, r.plays)
			end
			return string.format(
				'* %d – Your %s-busiest listening day on record (%s plays)',
				r.year, r.ranklabel, r.plays)
		end)
		addSection('Certification crown', ex.certcrowns, nil, function(r)
			if r.prev and r.prev ~= '' then
				return string.format(
					'* %d – [[%s]] overtook [[%s]] as the most certified song in chart history',
					r.year, r.page, r.prev)
			end
			return string.format(
				'* %d – [[%s]] became the most certified song in chart history',
				r.year, r.page)
		end)
	end

	if #out <= 2 then
		return string.format(
			'No chart history recorded for this date yet (month=%d, day=%d).',
			month, day)
	end

	return table.concat(out, '\n')
end

----------------------------------------------------------------
-- Debug helper
----------------------------------------------------------------
function p.debug(frame)
	local parent = frame:getParent()
	local args = parent and parent.args or frame.args or {}
	local month, day = getMonthDay(args)

	local out = {}
	table.insert(out, string.format('Debug OnThisDay: month=%d, day=%d', month or -1, day or -1))

	table.insert(out, string.format('\nData loaded ok: %s', tostring(ok)))
	table.insert(out, '\nTop-level data type: ' .. type(data))

	local years = {}
	for y, _ in pairs(by_year) do
		table.insert(years, y)
	end
	table.sort(years)

	table.insert(out, '\n\nYears indexed in by_year: ' .. (#years > 0 and table.concat(years, ', ') or '(none)'))
	table.insert(out, string.format('\nYear min/max: %s / %s', tostring(year_min), tostring(year_max)))

	table.insert(out, string.format(
		'\n\nWeek1 anchor used: %04d-%02d-%02d',
		WEEK1_Y, WEEK1_M, WEEK1_D
	))

	local currentYear = tonumber(mw.getContentLanguage():formatDate('Y')) or 2025
	local startYear = year_min or 2020

	table.insert(out, '\n\nPicks per year for this month/day:')
	for year = startYear, currentYear do
		local e = pickNearestInYear(year, month, day)
		if e then
			table.insert(out, string.format(
				'\n%d: week=%s, date=%s, song=%s, album=%s',
				year,
				tostring(e.week or ''),
				tostring(e.date or ''),
				tostring(e.no1song or ''),
				tostring(e.no1album or '')
			))
		else
			table.insert(out, string.format('\n%d: (no entry)', year))
		end
	end

	return table.concat(out, '\n')
end

return p