Module:MM Schedule

local p = {} local h = {} local Data = require("Module:MM Schedule/Data")

local DataTable = require("Module:Data Table") local Term = require("Module:Term") local utilsArg = require("Module:UtilsArg") local utilsLayout = require("Module:UtilsLayout") local utilsMarkup = require("Module:UtilsMarkup") local utilsString = require("Module:UtilsString") local utilsTable = require("Module:UtilsTable")

local CATEGORY_INVALID_ARGS = require("Module:Constants/category/invalidArgs")

function h.warn(msg, ...) local utilsError = require("Module:UtilsError") msg = string.format(msg, ...) utilsError.warn(msg) end

-- Template:MM Schedule/Header function p.Header(frame) local header = " " return footer end

-- Template:MM Schedule 2 (WIP) function p.Main2(frame) local args, err = utilsArg.parse(frame:getParent.args, p.Templates["MM Schedule"]) local categories = err and err.categoryText or "" local rows = DataTable.parseRows(args.schedule) local scheduleEntries = utilsTable.map(rows, "cells") local schedule, categories2 = h.parseSchedule(scheduleEntries) local defaultBranch = h.parseDefaultBranch(args.defaultBranch) local scheduleOutput = h.printSchedule(schedule, defaultBranch) categories = categories..categories2

return scheduleOutput, categories end

function h.parseSchedule(entries) local categories = "" local schedule = {} local events = {} for i, entryInput in ipairs(entries) do		local entry, entryEvents, errCategories = h.parseEntry(entryInput) categories = categories..errCategories if errCategories == "" then table.insert(schedule, entry) h.addForkEvents(events, entryEvents) end end events = utilsTable.uniqueBy(events, function(event)		return event.forkEvent.name	end)

schedule = utilsTable.concat(events, schedule) schedule = utilsTable.sortBy(schedule, {"epochMinutes", function(entry)		if entry.forkEvent then			return 1		elseif entry.dayStateChange then			return 2		elseif entry.scheduleEntry then			return 3		end	end}) return schedule, categories end

function h.parseDefaultBranch(input) local preconditions = h.parsePreconditions(input) return h.preconditions(preconditions) end

local currentDayState function h.parseEntry(entryInput) local entry = {} local events = {} local categories = "" local preconditions if #entryInput == 4 then local preconditionsInput = table.remove(entryInput, 1) preconditions, events, categories = h.parsePreconditions(preconditionsInput) end if #entryInput == 3 then local time = entryInput[1] local epochMinutes = h.epochMinutes(currentDayState.. " "..time) if not epochMinutes then h.warn("Invalid time entry: %s. See Template:MM Schedule for correct time format.", time) categories = "" end local location = entryInput[2] local actions = entryInput[3] entry.epochMinutes = epochMinutes entry.preconditions = preconditions entry.scheduleEntry = { time = time, location = location, actions = actions, }	elseif #entryInput == 1 then local dayState = entryInput[1] local dayStateCode = Data.dayStates[dayState] if dayStateCode then currentDayState = dayStateCode else local dayStates = utilsTable.keys(Data.dayStates) local dayStateList = utilsMarkup.bulletList(dayStates) h.warn("Invalid day state . Accepted day states are: %s", dayState, dayStateList) categories = "" end entry.dayStateChange = dayState entry.epochMinutes = h.dayStateToMinutes(dayStateCode) else h.warn("Invalid entry: %s", utilsTable.print(entryInput)) entry = nil categories = "" end return entry, events, categories end

local seenEvents = {} function h.addForkEvents(entries, events) for i, event in ipairs(events) do		if not seenEvents[event.name] then local forkEventEntry = { epochMinutes = h.epochMinutes(event.sortTime), preconditions = h.preconditions(event.preconditions), forkEvent = { name = event.name, description = event.description, notes = event.notes, }			}			table.insert(entries, forkEventEntry) seenEvents[event.name] = event end end end

function h.parsePreconditions(conditions) local events = {} local preconditions local categories = "" for condition in string.gmatch(conditions, "%[([^%]]+)%]") do		local operator = string.match(condition, "^([^:]+):") local eventName = string.gsub(condition, "^([^:]+):", "") local event = Data.events[eventName] local eventState = h.eventState(operator) if eventState == nil then h.warn("Invalid event condition . Recognized operators are   and  .", condition) categories = categories.."" elseif event == nil then h.warn("Unrecognized event name . See Module:MM Schedule/Data for supported events.", eventName) categories = categories.."" else preconditions = preconditions or {} preconditions[eventName] = eventState event = utilsTable.merge({}, event, { name = eventName }) table.insert(events, event) end end preconditions = h.preconditions(preconditions) return preconditions, events, categories end function h.eventState(operator) if operator == nil then return true elseif operator == "Not" then return false elseif operator == "Late" then return "late" else return nil end end

-- Computes transitive preconditions and adds them to the table -- For example, Anju's Anguish (1) is a precondition for Anju's Angust (2) which is a precondition for Anju's Anguish (3) -- so Anju's Anguish (1) is a transivite precondition for Anju's Anguish (3) function h.preconditions(preconditions) preconditions = preconditions or {} for eventName in pairs(preconditions or {}) do		if preconditions[eventName] == true then local transitivePreconditions = Data.events[eventName].preconditions transitivePreconditions = h.preconditions(transitivePreconditions) preconditions = utilsTable.merge({}, preconditions, transitivePreconditions) end end return preconditions end

-- @param timeStr a string such as "D2 12:00 PM" -- @return an integer representation of the time in minutes since the Dawn of the First Day, or nil if timeStr is invalid function h.epochMinutes(timeStr) local categories = "" local isValid = string.match(timeStr, "^[DN][1-3] 1?[0-9]:[0-5][0-9] [AP]M") if not isValid then return nil end local timeParts = utilsString.split(timeStr, " ") local dayState = timeParts[1] local dayPeriod = timeParts[3] local hours, minutes = unpack(utilsString.split(timeParts[2], ":")) hours = tonumber(hours) minutes = tonumber(minutes)

local totalMinutes = h.dayStateToMinutes(dayState) if hours < 6 then totalMinutes = totalMinutes + 6*60 + hours*60 elseif hours > 6 then totalMinutes = totalMinutes + (hours-6)*60 end totalMinutes = totalMinutes + minutes return totalMinutes end

function h.dayStateToMinutes(dayState) dayState = utilsString.split(dayState, "") local dayNum = tonumber(dayState[2]) local minutes = (dayNum-1)*24*60 -- offset by 24 hours for each day if dayState[1] == "N" then minutes = minutes + 12*60 -- offset by an additional 12 hours for nights end return minutes end

function h.printSchedule(schedule, defaultBranch, isBranch) local entries = utilsTable.takeWhile(schedule, function(entry)		return not entry.forkEvent	end)

local forkEvents = {} for i = #entries + 1, #schedule do		if not schedule[i].forkEvent then break else table.insert(forkEvents, schedule[i].forkEvent) end end

local branches if #forkEvents > 0 then local forkEntries = utilsTable.slice(schedule, #entries + #forkEvents + 1) branches = h.scheduleBranches(forkEntries, forkEvents, defaultBranch) -- Don't show branch tabs when there is only one or the tabs are the same if #branches < 2 then entries = utilsTable.concat(entries, forkEntries) branches = nil end end local html = mw.html.create("div") local tabl = html:tag("table"):addClass("wikitable") if not isBranch then html:addClass("mm-schedule") end tabl:tag("tr") :tag("th") :wikitext("Time") :done :tag("th") :wikitext("Location") :done :tag("th") :wikitext("Action(s)") :done for i, entry in ipairs(entries) do		local trow = tabl:tag("tr") if entry.dayStateChange then trow:tag("th") :attr("colspan", 3) :wikitext(entry.dayStateChange) :done elseif entry.scheduleEntry then local scheduleEntry = entry.scheduleEntry local locationInput = scheduleEntry.location local location if utilsMarkup.containsLink(locationInput) then location = locationInput else local locationSubject, locationNotes = utilsMarkup.separateMarkup(locationInput) local locationLink = Term.link(locationSubject, "MM3D") location = locationLink..locationNotes end trow:tag("td") :wikitext(scheduleEntry.time) :addClass("mm-schedule__time") :done :tag("td") :wikitext(location) :addClass("mm-schedule__location") :done :tag("td") :addClass("mm-schedule__actions") :wikitext(scheduleEntry.actions) :done end end if branches then local tabData = {} for i, branch in ipairs(branches) do			local tabLabel = h.branchLabel(branch.conditions) local tabContent = h.printSchedule(branch.schedule, defaultBranch, true) table.insert(tabData, {				label = tabLabel,				content = tabContent,			}) end local tabs = utilsLayout.tabs(tabData) html:wikitext(tabs) end return tostring(html) end function h.branchLabel(conditions) local listItems = {} for i, condition in ipairs(conditions) do		local eventName, eventState = unpack(condition) local file = ({			[true] = "File:Green check.svg",			[false] = "File:X mark.png",			["late"] = "File:MM3D Schedule Icon.png",		})[eventState] table.insert(listItems, "15px|link= "..eventName) end if #listItems == 1 then return listItems[1] else return utilsMarkup.list(listItems) end end

-- Generally, each "fork" in a character's schedule is determined by one event being completed or not. -- However, there are a few events happen at around the same time, so we want to show these two events in the same tab group. function h.scheduleBranches(schedule, events, defaultBranch) local conditionCombinations = h.conditionCombinations(schedule, events) local branches = {} for i, conditions in ipairs(conditionCombinations) do		local branch = { conditions = conditions, schedule = h.filterByPreconditions(schedule, conditions), }		table.insert(branches, branch) end -- Sort descending by schedule length branches = utilsTable.sortBy(branches, function(branch)		return -(#branch.schedule)	end) -- Merge identical branches local i = 1 while i < #branches do		local branchA = table.remove(branches, i)		local branchB = table.remove(branches, i)		if h.schedulesEqual(branchA.schedule, branchB.schedule) then local conditions = utilsTable.intersection(branchA.conditions, branchB.conditions) if #conditions > 0 then local mergedBranch = { conditions = conditions, schedule = branchA.schedule }				table.insert(branches, i, mergedBranch) end else table.insert(branches, i, branchA) table.insert(branches, i+1, branchB) end i = i + 1 end -- Set default branch local defaultBranchIndex = utilsTable.findIndex(branches, function(branch)		for i, condition in ipairs(branch.conditions) do			local eventName, eventState = unpack(condition)			if eventState ~= defaultBranch[eventName] then				return false			end		end		return true	end) if defaultBranchIndex then local branch = table.remove(branches, defaultBranchIndex) table.insert(branches, 1, branch) end return branches end function h.schedulesEqual(scheduleA, scheduleB) local scheduleA = utilsTable.filter(scheduleA, "scheduleEntry") local scheduleB = utilsTable.filter(scheduleB, "scheduleEntry") return utilsTable.isEqual(scheduleA, scheduleB) end

function h.conditionCombinations(schedule, events) local conditions = {} for i, event in ipairs(events) do		local hasLateCondition = utilsTable.find(schedule, function(entry) 			return entry.preconditions and entry.preconditions[eventName] == "late"		end) local eventConditions = {} table.insert(eventConditions, {event.name, true}) table.insert(eventConditions, {event.name, false}) if hasLateCondition then table.insert(eventConditions, {event.name, "late"}) end table.insert(conditions, eventConditions) end local conditionCombinations = h.cartesianProduct(conditions) return conditionCombinations end function h.cartesianProduct(sets) local setA = table.remove(sets, 1) local setB = table.remove(sets, 1) local product = {} for _, a in ipairs(setA) do		if setB then for _, b in ipairs(setB) do				table.insert(product, {a, b}) end else table.insert(product, {a}) end end if #sets > 0 then table.insert(sets, product) return h.cartesianProduct(sets) else return product end end

function h.filterByPreconditions(schedule, preconditions) return utilsTable.filter(schedule, function(entry)		for i, condition in ipairs(preconditions) do			local eventName, conditionState = unpack(condition)			local eventState = entry.preconditions and entry.preconditions[eventName]			if eventState ~= nil and eventState ~= conditionState then				return false			end		end		return true	end) end

p.Templates = { ["MM Schedule"] = { params = { ["defaultBranch"] = { trim = true, type = "string", },			["..."] = {				name = "schedule", type = "content", trim = true, },		},	} }

return p