Zelda Wiki

Want to contribute to this wiki?
Sign up for an account, and get started!

Come join the Zelda Wiki community Discord server!

READ MORE

Zelda Wiki
(converting to external links, effectively unclogging the whatlinkshere pages)
m ((For some reason my edit never saved.) Standardizing: Places -> Locations)
(46 intermediate revisions by 5 users not shown)
Line 1: Line 1:
 
local p = {}
 
local p = {}
 
local h = {}
 
local h = {}
  +
local cargo = mw.ext.cargo
 
local utilsCode = require("Module:UtilsCode")
+
local Franchise = require("Module:Franchise")
local utilsGame = require("Module:UtilsGame")
+
local utilsArg = require("Module:UtilsArg")
local utilsNavbox = require("Module:UtilsNavbox")
+
local utilsLayout = require("Module:UtilsLayout")
  +
local utilsMarkup = require("Module:UtilsMarkup")
  +
local utilsPage = require("Module:UtilsPage")
 
local utilsTable = require("Module:UtilsTable")
 
local utilsTable = require("Module:UtilsTable")
   
 
-- See the module for documentation about subcategories.
 
-- See the module for documentation about subcategories.
local SUBCATEGORIES = require("Module:Categories/Subcategories")
+
local data = mw.loadData("Module:Categories/Data")
  +
local PER_GAME_CATEGORIES = {
  +
{
  +
parameter = "bosses",
  +
category = "Bosses",
  +
},
  +
{
  +
parameter = "characters",
  +
category = "Characters",
  +
},
  +
{
  +
parameter = "challenges",
  +
category = "Challenges",
  +
},
  +
{
  +
parameter = "dungeons",
  +
category = "Dungeons",
  +
},
  +
{
  +
parameter = "enemies",
  +
category = "Enemies",
  +
},
  +
{
  +
parameter = "items",
  +
category = "Items",
  +
},
  +
{
  +
parameter = "levels",
  +
category = "Levels",
  +
},
  +
{
  +
parameter = "locations",
  +
category = "Locations",
  +
},
  +
{
  +
parameter = "mini-games",
  +
cargoField = "miniGames",
  +
category = "Mini-Games",
  +
},
  +
{
  +
parameter = "objects",
  +
category = "Objects",
  +
},
  +
{
  +
parameter = "playable",
  +
category = "Playable Characters",
  +
},
  +
{
  +
parameter = "quests",
  +
category = "Quests",
  +
},
  +
{
  +
parameter = "side-quests",
  +
cargoField = "sideQuests",
  +
category = "Side Quests",
  +
},
  +
{
  +
parameter = "songs",
  +
category = "Songs",
  +
},
  +
{
  +
parameter = "stages",
  +
category = "Stages",
  +
},
  +
{
  +
parameter = "sub-bosses",
  +
cargoField = "subBosses",
  +
category = "Sub-Bosses",
  +
},
  +
}
  +
local APPEARANCES_TABLE = "Appearances"
   
function p._CategorizeEntries(frame)
+
function p.CargoDeclare(frame)
local args = frame:getParent().args
+
local fields = {}
  +
for _, category in ipairs(PER_GAME_CATEGORIES) do
return p.CategorizeEntries(frame.args["plain"], args["bosses"], args["characters"], args["dungeons"], args["enemies"], args["items"], args["objects"], args["places"], args["songs"], args["stages"], args["sub-bosses"])
 
  +
fields[category.cargoField or category.parameter] = "Integer"
  +
end
  +
fields["mainAppearances"] = "Integer"
  +
fields["totalAppearances"] = "Integer"
  +
return frame:callParserFunction("#cargo_declare:_table=" .. APPEARANCES_TABLE, fields)
 
end
 
end
   
  +
function p.Main(frame)
function p.CategorizeEntries(plain, bosses, characters, dungeons, enemies, items, objects, places, songs, stages, subbosses)
 
  +
local args, err = utilsArg.parse(frame:getParent().args, p.Templates.Categories)
local result = ""
 
  +
local appearances = p.appearances(args)
result = result .. p.PlainToNavboxes(plain)
 
  +
local result = p.printNavboxes(args.categories, args, appearances)
result = result .. p.PlainToCategories(plain)
 
  +
if utilsPage.inNamespace("") then
result = result .. p.GamesToCategories(bosses, characters, dungeons, enemies, items, objects, places, songs, stages, subbosses)
 
  +
p.storeAppearances(frame, appearances)
  +
end
  +
if not utilsPage.inNamespace("User") then
  +
result = result .. p.printCategories(args.categories, args)
  +
end
  +
if err then
  +
result = result .. utilsMarkup.categories(err.categories)
  +
end
 
return result
 
return result
 
end
 
end
   
function p.PlainToNavboxes(plain)
+
function p.appearances(perGameCategories)
  +
local appearances = {}
result = ""
 
  +
local mainAppearances = {}
local plaintable = mw.text.split(plain, '%s*,%s*')
 
  +
local totalAppearances = {}
 
  +
for _, category in ipairs(PER_GAME_CATEGORIES) do
local navboxTitle = ""
 
  +
local games = perGameCategories[category.parameter]
local rows = {}
 
  +
if games then
local notCategories = ""
 
  +
local baseGames = utilsTable.map(games, Franchise.baseGame)
local dplQuery = ""
 
  +
local uniqueAppearances = utilsTable.unique(baseGames)
local dplParameters = '|namespace=|includesubpages=false|skipthispage=no|mode=userformat|format=,%PAGE%,;,|ordermethod=titlewithoutnamespace|noresultsheader= }}'
 
  +
local uniqueAppearancesByType = utilsTable.groupBy(uniqueAppearances, Franchise.type)
 
  +
local uniqueMainAppearances = uniqueAppearancesByType["main"] or {}
-- For every category entered in the template
 
  +
appearances[category.parameter] = uniqueMainAppearances
for key, category in ipairs(plaintable) do
 
  +
table.insert(mainAppearances, uniqueMainAppearances)
rows = {}
 
  +
table.insert(totalAppearances, uniqueAppearances)
if not utilsCode.IsEmpty(category) then
 
+
else
  +
appearances[category.parameter] = {}
-- Sets the title, which is "[[:Category:X|X]] in {{TLoZ|Series}}"
 
navboxTitle = "[[:Category:" .. category .. "|" .. category .. "]] in " .. mw.getCurrentFrame():expandTemplate{ title = "TLoZ", args = { "Series" } }
 
 
-- If subcategories exist, handles that
 
if not utilsCode.IsEmpty(SUBCATEGORIES[category]) then
 
notCategories = ""
 
for key2, subCategory in ipairs(SUBCATEGORIES[category]) do
 
notCategories = notCategories .. "|notcategory=" .. subCategory
 
dplQuery = h.dplToExternalLinks(mw.getCurrentFrame():preprocess("{{#dpl:|category=" .. category .. "|category=" .. subCategory .. dplParameters))
 
 
if not (dplQuery == " ") then
 
table.insert(rows, {title = subCategory, content = dplQuery})
 
end
 
end
 
 
-- Other(s) row
 
dplQuery = h.dplToExternalLinks(mw.getCurrentFrame():preprocess("{{#dpl:|category=" .. category .. notCategories .. dplParameters))
 
if not (dplQuery == " ") then
 
table.insert(rows, {title = "Other(s)", content = dplQuery})
 
end
 
result = result .. utilsNavbox.CreateRowNavbox(rows, navboxTitle)
 
 
-- else just outputs everything
 
else
 
dplQuery = h.dplToExternalLinks(mw.getCurrentFrame():preprocess("{{#dpl:|category=" .. category .. dplParameters))
 
if not (dplQuery == " ") then
 
result = result .. utilsNavbox.CreateNavbox(dplQuery, navboxTitle)
 
end
 
end
 
 
 
end
 
end
 
end
 
end
  +
mainAppearances = utilsTable.union(mainAppearances)
return tostring(result)
 
  +
totalAppearances = utilsTable.union(totalAppearances)
  +
appearances["mainAppearances"] = #mainAppearances
  +
appearances["totalAppearances"] = #totalAppearances
  +
return appearances
 
end
 
end
   
function h.dplToExternalLinks(dplResult)
+
function p.storeAppearances(frame, appearances)
local result = ""
+
local fields = {}
  +
for _, category in ipairs(PER_GAME_CATEGORIES) do
local firstLink = true
 
  +
fields[category.cargoField or category.parameter] = #appearances[category.parameter]
dplResult = mw.text.split(dplResult, '%s*;%s*')
 
-- Removing the last entry in the table since it's "empty" due to how DPL
 
-- adds a ";" at the end of each entry instead of just in-between entries
 
table.remove(dplResult)
 
for key, link in ipairs(dplResult) do
 
if firstLink == true then
 
firstLink = false
 
else
 
result = result .. ' <b>·</b> '
 
end
 
result = result .. '<span class="plainlinks">[https://zelda.gamepedia.com/' .. mw.getCurrentFrame():callParserFunction{name = 'urlencode', args = link} .. ' ' .. link .. ']</span>'
 
 
end
 
end
  +
fields["mainAppearances"] = appearances["mainAppearances"]
return result
 
  +
fields["totalAppearances"] = appearances["totalAppearances"]
  +
frame:callParserFunction("#cargo_store:_table=" .. APPEARANCES_TABLE, fields)
 
end
 
end
   
function p.PlainToCategories(plain)
+
function p.printCategories(plainCategories, perGameCategories)
  +
local categories = plainCategories or {}
result = ""
 
  +
local gameCategoryMap = {} -- keeps track of which games are specified for which categories
local plaintable = mw.text.split(plain, '%s*,%s*')
 
  +
for key, category in ipairs(plaintable) do
 
  +
for _, category in ipairs(PER_GAME_CATEGORIES) do
if not utilsCode.IsEmpty(category) then
 
  +
local gamesInCategory = perGameCategories[category.parameter]
result = result .. "[[Category:" .. category .. "]]"
 
  +
if gamesInCategory then
  +
table.insert(categories, category.category)
  +
gameCategoryMap[category.parameter] = utilsTable.invert(gamesInCategory)
 
end
 
end
 
end
 
end
  +
return result
 
  +
for _, game in ipairs(Franchise.enum()) do
  +
for _, category in ipairs(PER_GAME_CATEGORIES) do
  +
if gameCategoryMap[category.parameter] and gameCategoryMap[category.parameter][game] then
  +
local categoryName = category.category .. " in " .. Franchise.shortName(game)
  +
table.insert(categories, categoryName)
  +
end
  +
end
  +
end
  +
  +
return utilsMarkup.categories(categories)
 
end
 
end
   
  +
function p.printNavboxes(categories, perGameCategories, appearances)
function p.GamesToCategories(bosses, characters, dungeons, enemies, items, objects, places, songs, stages, subbosses)
 
 
local result = ""
 
local result = ""
local categories = {}
+
if categories then
  +
local collapse = #categories > 1
local sortOrder = utilsGame.GetSortOrder("canon")
 
  +
for _, category in ipairs(categories) do
 
  +
local navbox = p.printNavbox(category, collapse)
if not (utilsCode.IsEmpty(bosses)) then
 
  +
result = result .. navbox
bosses = mw.text.split(bosses, '%s*,%s*')
 
for _, value in ipairs(bosses) do
 
table.insert(categories, {"Bosses", value})
 
 
end
 
end
 
end
 
end
  +
-- TODO: Print navs for per-game categories.
if not (utilsCode.IsEmpty(characters)) then
 
  +
-- TODO: Print navs for recurring entities (see "appearances" object)
characters = mw.text.split(characters, '%s*,%s*')
 
  +
return result
for _, value in ipairs(characters) do
 
  +
end
table.insert(categories, {"Characters", value})
 
  +
end
 
  +
function p.printNavbox(category, collapse)
  +
local pagesInCategory = tonumber(mw.getCurrentFrame():callParserFunction("PAGESINCATEGORY", category, "R", "pages"))
  +
if pagesInCategory == 0 or pagesInCategory > data.maxPagesPerNav then
  +
return "", pagesInCategory
 
end
 
end
  +
if not (utilsCode.IsEmpty(dungeons)) then
 
  +
local categoryLink = string.format("[[:Category:%s|%s]]", category, category)
dungeons = mw.text.split(dungeons, '%s*,%s*')
 
  +
local seriesLink = Franchise.link("Series")
for _, value in ipairs(dungeons) do
 
  +
local navboxTitle = string.format("%s in %s", categoryLink, seriesLink)
table.insert(categories, {"Dungeons", value})
 
  +
end
 
  +
local dplArgs = {
  +
namespace = "",
  +
includeSubpages = false,
  +
skipthispage = "no",
  +
orderMethod = "title",
  +
category = category,
  +
}
  +
  +
local subcategories = data.subcategories[category]
  +
if not subcategories then
  +
local results = utilsPage.dpl(dplArgs)
  +
local rows = {
  +
{ title = "All", content = h.rowContent(results) }
  +
}
  +
return utilsLayout.CreateRowNavbox(rows, navboxTitle), pagesInCategory
 
end
 
end
  +
if not utilsCode.IsEmpty(enemies) then
 
  +
local rows = {}
enemies = mw.text.split(enemies, '%s*,%s*')
 
  +
local pagesInRows = 0
for _, value in ipairs(enemies) do
 
  +
for _, subcategory in ipairs(subcategories) do
table.insert(categories, {"Enemies", value})
 
  +
local rowDplArgs = utilsTable.merge({}, dplArgs, {
  +
category = category .. "&" .. subcategory.category
  +
})
  +
for _, parentCategory in ipairs(subcategory.parents or {}) do
  +
table.insert(rowDplArgs, {
  +
param = "notcategory",
  +
value = parentCategory
  +
})
 
end
 
end
  +
local results = utilsPage.dpl(rowDplArgs)
end
 
if not utilsCode.IsEmpty(items) then
+
if #results > 0 then
  +
table.insert(rows, {
items = mw.text.split(items, '%s*,%s*')
 
  +
title = subcategory.display or subcategory.category,
for _, value in ipairs(items) do
 
  +
content = h.rowContent(results)
table.insert(categories, {"Items", value})
 
  +
})
 
end
 
end
 
end
 
end
  +
if not (utilsCode.IsEmpty(objects)) then
 
  +
-- "Other" row
objects = mw.text.split(objects, '%s*,%s*')
 
for _, value in ipairs(objects) do
+
for _, subcategory in ipairs(subcategories) do
table.insert(categories, {"Objects", value})
+
table.insert(dplArgs, {
  +
param = "notcategory",
end
 
  +
value = subcategory.category
  +
})
 
end
 
end
  +
local results = utilsPage.dpl(dplArgs)
if not (utilsCode.IsEmpty(places)) then
 
  +
if #results > 0 then
places = mw.text.split(places, '%s*,%s*')
 
  +
table.insert(rows, {
for _, value in ipairs(places) do
 
  +
title = "Other",
table.insert(categories, {"Places", value})
 
  +
content = h.rowContent(results)
end
 
  +
})
 
end
 
end
  +
return utilsLayout.CreateRowNavbox(rows, navboxTitle, {
if not (utilsCode.IsEmpty(songs)) then
 
  +
collapsed = collapse
songs = mw.text.split(songs, '%s*,%s*')
 
  +
}), pagesInCategory
for _, value in ipairs(songs) do
 
  +
end
table.insert(categories, {"Songs", value})
 
  +
function h.rowContent(pages)
  +
-- creates link to the pages that do not register on [[Special:WhatLinksHere]], to avoid spamming it
  +
local links = utilsTable.map(pages, function(page)
  +
if page == mw.title.getCurrentTitle().fullText then
  +
return utilsMarkup.bold(page)
  +
else
  +
return utilsMarkup.link(page, page, true)
 
end
 
end
  +
end)
  +
return table.concat(links, " <b>·</b> ")
  +
end
  +
  +
function p.Data(frame)
  +
local result = ""
  +
result = result .. "'''Max navbox size:''' " .. data.maxPagesPerNav .." items\n"
  +
local tableRows = {}
  +
for k in pairs(data.subcategories) do
  +
table.insert(tableRows, {utilsMarkup.link("Category:" .. k), p.printNavbox(k)})
 
end
 
end
  +
tableRows = utilsTable.sortBy(tableRows, 1)
if not (utilsCode.IsEmpty(stages)) then
 
  +
result = result .. utilsLayout.table({
stages = mw.text.split(stages, '%s*,%s*')
 
  +
sortable = true,
for _, value in ipairs(stages) do
 
  +
headers = {"Category", "Navbox", "# Pages"},
table.insert(categories, {"Stages", value})
 
  +
rows = tableRows,
end
 
end
+
})
if not (utilsCode.IsEmpty(subbosses)) then
 
subbosses = mw.text.split(subbosses, '%s*,%s*')
 
for _, value in ipairs(subbosses) do
 
table.insert(categories, {"Sub-Bosses", value})
 
end
 
end
 
 
for _, game in ipairs(sortOrder) do
 
for _2, category in ipairs(categories) do
 
if game == category[2] then
 
result = result .. "[[Category:" .. category[1] .. " in " .. utilsGame.AbbToGame(category[2], "true") .. "]]"
 
end
 
end
 
end
 
 
 
 
return result
 
return result
 
end
 
end
  +
  +
local params = {}
  +
local paramOrder = {}
  +
for _, perGameCategory in ipairs(PER_GAME_CATEGORIES) do
  +
params[perGameCategory.parameter] = {
  +
type = "string",
  +
enum = Franchise.enum(),
  +
desc = string.format("Comma-separated list of [[Data:Franchise|codes]] representing the games or other titles in which the given article subject is one of the [[:Category:%s|%s]].", perGameCategory.category, perGameCategory.category),
  +
split = true,
  +
trim = true,
  +
nilIfEmpty = true,
  +
}
  +
table.insert(paramOrder, perGameCategory.parameter)
  +
end
  +
  +
p.Schemas = {
  +
Data = {
  +
required = true,
  +
type = "record",
  +
properties = {
  +
{
  +
name = "maxPagesPerNav",
  +
required = true,
  +
type = "number",
  +
desc = "Defines the maximum number of links in any given navbox. Categories with a number of pages exceeding this limit will not have navboxes generated for them.",
  +
},
  +
{
  +
name = "subcategories",
  +
required = true,
  +
type = "map",
  +
desc = 'Maps a category to a list of "subcategories". Allows for sub-categorization in the navbox. For each "subcategory", a row is created in the navbox listing the members that exist in both the "subcategory" and the "main" category. An "Other" row is created for any remaining pages that are in the "main" category but none of the "subcategories" listed.',
  +
keys = { type = "string" },
  +
values = {
  +
type = "array",
  +
items = {
  +
type = "record",
  +
properties = {
  +
{
  +
name = "category",
  +
required = true,
  +
type = "string",
  +
desc = 'Name of a category that intersects with the "main" category.',
  +
},
  +
{
  +
name = "parents",
  +
type = "array",
  +
items = { type = "string" },
  +
desc = "Used to exclude members of one or more parent categories. Not needed for most cases."
  +
},
  +
{
  +
name = "display",
  +
type = "string",
  +
default = "category",
  +
desc = "Text displayed for the navbox row. Defaults to the category name, without the namespace prefix.",
  +
},
  +
},
  +
}
  +
},
  +
},
  +
},
  +
},
  +
}
  +
  +
p.Templates = {
  +
Categories = {
  +
purpose = "Adding categories to pages. For each category, a navbox is generated with links between the pages in the category.",
  +
usesModuleData = true,
  +
format = "block",
  +
indent = 1,
  +
paramOrder = utilsTable.concat({1}, paramOrder),
  +
params = utilsTable.merge(params, {
  +
[1] = {
  +
name = "categories",
  +
type = "string",
  +
desc = "Comma separated list of categories which are not subcategorized by game. Examples of these include [[:Category:Animals|Animals]], [[:Category:Forests|Forests]], [[:Category:Fire-Related Enemies|Fire-Related Enemies]], and so on.",
  +
split = true,
  +
trim = true,
  +
nilIfEmpty = true,
  +
}
  +
}),
  +
examples = {
  +
vertical = true,
  +
{
  +
desc = "[[Ice Keese]]",
  +
args = {
  +
[1] = "Keese, Ice-Related Enemies",
  +
["enemies"] = "OoT, OoT3D, MM, MM3D, TP, TPHD, PH, ST, TFH, BotW",
  +
["sub-bosses"] = "ST",
  +
}
  +
},
  +
{
  +
desc = "[[Yuga]]",
  +
args = {
  +
[1] = "Demons, Loruleans, Sorcerers",
  +
["bosses"] = "TLoZ, ALttP, OoT, OoT3D, OoS, OoA, FSA, TP, TPHD, TFoE, TWoG, ZA, BSTLoZ, AST, HW, HWL, HWDE",
  +
["characters"] = "ALBW, HW",
  +
["playable"] = "HW",
  +
},
  +
},
  +
{
  +
desc = "[[Blue Fire]]",
  +
args = {
  +
[1] = "Ancient Technology",
  +
["items"] = "OoT, OoT3D",
  +
["objects"] = "BotW"
  +
}
  +
},
  +
{
  +
desc = "[[Desert Temple]]",
  +
args = {
  +
["dungeons"] = "OoT, OoT3D",
  +
["levels"] = "TFH",
  +
["places"] = "OoT, OoT3D",
  +
},
  +
},
  +
{
  +
desc = "[[Eldin Caves]]",
  +
args = {
  +
["stages"] = "HW, HWL, HWDE",
  +
},
  +
},
  +
{
  +
desc = "[[Song of Healing]]",
  +
args = {
  +
["songs"] = "MM, MM3D, TP, TPHD, ST",
  +
},
  +
},
  +
{
  +
desc = string.format("Navboxes are not generated for categories with %s+ links.", data.maxPagesPerNav),
  +
args = {
  +
[1] = "Hylians, Swords",
  +
},
  +
},
  +
{
  +
desc = "Invalid codes, duplicate cods, and improperly ordered codes are handled appropriately.",
  +
args = {
  +
characters = "TP, TP, fakeGame, OoT, OoT",
  +
},
  +
},
  +
},
  +
},
  +
}
   
 
return p
 
return p

Revision as of 02:29, 13 October 2021

This is the main module for the following templates:
local p = {}
local h = {}

local Franchise = require("Module:Franchise")
local utilsArg = require("Module:UtilsArg")
local utilsLayout = require("Module:UtilsLayout")
local utilsMarkup = require("Module:UtilsMarkup")
local utilsPage = require("Module:UtilsPage")
local utilsTable = require("Module:UtilsTable")

-- See the module for documentation about subcategories.
local data = mw.loadData("Module:Categories/Data")
local PER_GAME_CATEGORIES = {
	{
		parameter = "bosses",
		category = "Bosses",
	},
	{
		parameter = "characters",
		category = "Characters",
	},
	{
		parameter = "challenges",
		category = "Challenges",
	},
	{
		parameter = "dungeons",
		category = "Dungeons",
	},
	{
		parameter = "enemies", 
		category = "Enemies",
	},
	{
		parameter = "items",
		category = "Items",
	},
	{
		parameter = "levels",
		category = "Levels",
	},
	{
		parameter = "locations",
		category = "Locations",
	},
	{
		parameter = "mini-games",
		cargoField = "miniGames",
		category = "Mini-Games",
	},
	{
		parameter = "objects",
		category = "Objects",
	},
	{
		parameter = "playable",
		category = "Playable Characters",
	},
	{
		parameter = "quests", 
		category = "Quests",
	},
	{
		parameter = "side-quests", 
		cargoField = "sideQuests",
		category = "Side Quests",
	},
	{
		parameter = "songs", 
		category = "Songs",
	},
	{
		parameter = "stages", 
		category = "Stages",
	},
	{
		parameter = "sub-bosses",
		cargoField = "subBosses",
		category = "Sub-Bosses",
	},
}
local APPEARANCES_TABLE = "Appearances"

function p.CargoDeclare(frame)
	local fields = {}
	for _, category in ipairs(PER_GAME_CATEGORIES) do
		fields[category.cargoField or category.parameter] = "Integer"
	end 
	fields["mainAppearances"] = "Integer"
	fields["totalAppearances"] = "Integer"
	return frame:callParserFunction("#cargo_declare:_table=" .. APPEARANCES_TABLE, fields)
end

function p.Main(frame)
	local args, err = utilsArg.parse(frame:getParent().args, p.Templates.Categories)
	local appearances = p.appearances(args)
	local result = p.printNavboxes(args.categories, args, appearances)
	if utilsPage.inNamespace("") then
		p.storeAppearances(frame, appearances)
	end
	if not utilsPage.inNamespace("User") then
		result = result .. p.printCategories(args.categories, args)
	end
	if err then
		result = result .. utilsMarkup.categories(err.categories)
	end
	return result
end

function p.appearances(perGameCategories)
	local appearances = {}
	local mainAppearances = {}
	local totalAppearances = {}
	for _, category in ipairs(PER_GAME_CATEGORIES) do
		local games = perGameCategories[category.parameter]
		if games then
			local baseGames = utilsTable.map(games, Franchise.baseGame)
			local uniqueAppearances = utilsTable.unique(baseGames)
			local uniqueAppearancesByType = utilsTable.groupBy(uniqueAppearances, Franchise.type)
			local uniqueMainAppearances = uniqueAppearancesByType["main"] or {}
			appearances[category.parameter] = uniqueMainAppearances
			table.insert(mainAppearances, uniqueMainAppearances)
			table.insert(totalAppearances, uniqueAppearances)
		else
			appearances[category.parameter] = {}
		end
	end
	mainAppearances = utilsTable.union(mainAppearances)
	totalAppearances = utilsTable.union(totalAppearances)
	appearances["mainAppearances"] = #mainAppearances
	appearances["totalAppearances"] = #totalAppearances
	return appearances
end

function p.storeAppearances(frame, appearances)
	local fields = {}
	for _, category in ipairs(PER_GAME_CATEGORIES) do
		fields[category.cargoField or category.parameter] = #appearances[category.parameter]
	end
	fields["mainAppearances"] = appearances["mainAppearances"]
	fields["totalAppearances"] = appearances["totalAppearances"]
	frame:callParserFunction("#cargo_store:_table=" .. APPEARANCES_TABLE, fields)
end

function p.printCategories(plainCategories, perGameCategories)
	local categories = plainCategories or {}
	local gameCategoryMap = {} -- keeps track of which games are specified for which categories 
	
	for _, category in ipairs(PER_GAME_CATEGORIES) do
		local gamesInCategory = perGameCategories[category.parameter]
		if gamesInCategory then
			table.insert(categories, category.category)
			gameCategoryMap[category.parameter] = utilsTable.invert(gamesInCategory)
		end
	end
	
	for _, game in ipairs(Franchise.enum()) do
		for _, category in ipairs(PER_GAME_CATEGORIES) do
			if gameCategoryMap[category.parameter] and gameCategoryMap[category.parameter][game] then
				local categoryName = category.category .. " in " .. Franchise.shortName(game)
				table.insert(categories, categoryName)
			end
		end
	end
	
	return utilsMarkup.categories(categories)
end

function p.printNavboxes(categories, perGameCategories, appearances)
	local result = ""
	if categories then
		local collapse = #categories > 1
		for _, category in ipairs(categories) do
			local navbox = p.printNavbox(category, collapse)
			result = result .. navbox
		end
	end
	-- TODO: Print navs for per-game categories.
	-- TODO: Print navs for recurring entities (see "appearances" object)
	return result
end

function p.printNavbox(category, collapse)
	local pagesInCategory = tonumber(mw.getCurrentFrame():callParserFunction("PAGESINCATEGORY", category, "R", "pages"))
	if pagesInCategory == 0 or pagesInCategory > data.maxPagesPerNav then
		return "", pagesInCategory
	end
	
	local categoryLink = string.format("[[:Category:%s|%s]]", category, category)
	local seriesLink = Franchise.link("Series")
	local navboxTitle = string.format("%s in %s", categoryLink, seriesLink)
	
	local dplArgs = {
		namespace = "",
		includeSubpages = false,
		skipthispage = "no",
		orderMethod = "title",
		category = category,
	}
	
	local subcategories = data.subcategories[category]
	if not subcategories then
		local results = utilsPage.dpl(dplArgs)
		local rows = { 
			{ title = "All", content = h.rowContent(results) }
		}
		return utilsLayout.CreateRowNavbox(rows, navboxTitle), pagesInCategory
	end
	
	local rows = {}
	local pagesInRows = 0
	for _, subcategory in ipairs(subcategories) do
		local rowDplArgs = utilsTable.merge({}, dplArgs, {
			category = category .. "&" .. subcategory.category
		})
		for _, parentCategory in ipairs(subcategory.parents or {}) do
			table.insert(rowDplArgs, {
				param = "notcategory",
				value = parentCategory
			})
		end
		local results = utilsPage.dpl(rowDplArgs)
		if #results > 0 then
			table.insert(rows, { 
				title = subcategory.display or subcategory.category, 
				content = h.rowContent(results)
			})
		end
	end
	
	-- "Other" row
	for _, subcategory in ipairs(subcategories) do
		table.insert(dplArgs, {
			param = "notcategory",
			value = subcategory.category
		})
	end
	local results = utilsPage.dpl(dplArgs)
	if #results > 0 then
		table.insert(rows, {
			title = "Other",
			content = h.rowContent(results)
		})
	end
	return utilsLayout.CreateRowNavbox(rows, navboxTitle, {
		collapsed = collapse
	}), pagesInCategory
end
function h.rowContent(pages)
	-- creates link to the pages that do not register on [[Special:WhatLinksHere]], to avoid spamming it
	local links = utilsTable.map(pages, function(page)
		if page == mw.title.getCurrentTitle().fullText then
			return utilsMarkup.bold(page)
		else
			return utilsMarkup.link(page, page, true)
		end
	end)
	return table.concat(links, " <b>·</b> ")
end

function p.Data(frame)
	local result = ""
	result = result .. "'''Max navbox size:''' " .. data.maxPagesPerNav .." items\n"
	local tableRows = {}
	for k in pairs(data.subcategories) do
		table.insert(tableRows, {utilsMarkup.link("Category:" .. k), p.printNavbox(k)})
	end
	tableRows = utilsTable.sortBy(tableRows, 1)
	result = result .. utilsLayout.table({
		sortable = true,
		headers = {"Category", "Navbox", "# Pages"},
		rows = tableRows,
	})
	return result
end

local params = {}
local paramOrder = {}
for _, perGameCategory in ipairs(PER_GAME_CATEGORIES) do
	params[perGameCategory.parameter] = {
		type = "string",
		enum = Franchise.enum(),
		desc = string.format("Comma-separated list of [[Data:Franchise|codes]] representing the games or other titles in which the given article subject is one of the [[:Category:%s|%s]].", perGameCategory.category, perGameCategory.category),
		split = true,
		trim = true,
		nilIfEmpty = true,
	}
	table.insert(paramOrder, perGameCategory.parameter)
end

p.Schemas = {
	Data = {
		required = true,
		type = "record",
		properties = {
			{
				name = "maxPagesPerNav",
				required = true,
				type = "number",
				desc = "Defines the maximum number of links in any given navbox. Categories with a number of pages exceeding this limit will not have navboxes generated for them.",
			},
			{
				name = "subcategories",
				required = true,
				type = "map",
				desc = 'Maps a category to a list of "subcategories". Allows for sub-categorization in the navbox. For each "subcategory", a row is created in the navbox listing the members that exist in both the "subcategory" and the "main" category. An "Other" row is created for any remaining pages that are in the "main" category but none of the "subcategories" listed.',
				keys = { type = "string" },
				values = {
					type = "array",
					items = {
						type = "record",
						properties = {
							{
								name = "category",
								required = true,
								type = "string",
								desc = 'Name of a category that intersects with the "main" category.',
							},
							{
								name = "parents",
								type = "array",
								items = { type = "string" },
								desc = "Used to exclude members of one or more parent categories. Not needed for most cases."
							},
							{
								name = "display",
								type = "string",
								default = "category",
								desc = "Text displayed for the navbox row. Defaults to the category name, without the namespace prefix.",
							},
						},
					}
				},
			},
		},
	},
}

p.Templates = {
	Categories = {
		purpose = "Adding categories to pages. For each category, a navbox is generated with links between the pages in the category.",
		usesModuleData = true,
		format = "block",
		indent = 1,
		paramOrder = utilsTable.concat({1}, paramOrder),
		params = utilsTable.merge(params, {
			[1] = {
				name = "categories",
				type = "string",
				desc = "Comma separated list of categories which are not subcategorized by game. Examples of these include [[:Category:Animals|Animals]], [[:Category:Forests|Forests]], [[:Category:Fire-Related Enemies|Fire-Related Enemies]], and so on.",
				split = true,
				trim = true,
				nilIfEmpty = true,
			}
		}),
		examples = {
			vertical = true,
			{
				desc = "[[Ice Keese]]",
				args = {
					[1] = "Keese, Ice-Related Enemies",
					["enemies"] = "OoT, OoT3D, MM, MM3D, TP, TPHD, PH, ST, TFH, BotW",
					["sub-bosses"] = "ST",
				}
			},
			{
				desc = "[[Yuga]]",
				args = {
					[1] = "Demons, Loruleans, Sorcerers",
					["bosses"] = "TLoZ, ALttP, OoT, OoT3D, OoS, OoA, FSA, TP, TPHD, TFoE, TWoG, ZA, BSTLoZ, AST, HW, HWL, HWDE",
					["characters"] = "ALBW, HW",
					["playable"] = "HW",
				},
			},
			{
				desc = "[[Blue Fire]]",
				args = {
					[1] = "Ancient Technology",
					["items"] = "OoT, OoT3D",
					["objects"] = "BotW"
				}
			},
			{
				desc = "[[Desert Temple]]",
				args = {
					["dungeons"] = "OoT, OoT3D",
					["levels"] = "TFH",
					["places"] = "OoT, OoT3D",
				},
			},
			{
				desc = "[[Eldin Caves]]",
				args = {
					["stages"] = "HW, HWL, HWDE",
				},
			},
			{
				desc = "[[Song of Healing]]",
				args = {
					["songs"] = "MM, MM3D, TP, TPHD, ST",
				},
			},
			{
				desc = string.format("Navboxes are not generated for categories with %s+ links.", data.maxPagesPerNav),
				args = {
					[1] = "Hylians, Swords",
				},
			},
			{
				desc = "Invalid codes, duplicate cods, and improperly ordered codes are handled appropriately.",
				args = {
					characters = "TP, TP, fakeGame, OoT, OoT",
				},
			},
		},
	},
}

return p