Module:Categories

From Zelda Wiki, the Zelda encyclopedia
Jump to navigation Jump to search
This is the main module for the following templates:
local p = {}
local h = {}
local Data = mw.loadData("Module:Categories/Data")

local Franchise = require("Module:Franchise")
local utilsArg = require("Module:UtilsArg")
local utilsCargo = require("Module:UtilsCargo")
local utilsPage = require("Module:UtilsPage")
local utilsTable = require("Module:UtilsTable")

local MIN_PAGES_PER_NAV = 2
local CATEGORY_INVALID_ARGS = "[[Category:"..require("Module:Constants/category/invalidArgs").."]]"
local CATEGORY_NAVBOXES_ATTENTION = "Navigation templates needing attention"
local CATEGORY_NAVBOXES_OTHER = "Navboxes with other"
local CATEGORY_CAT_NAVBOX_TEMPLATES = "Category navbox templates"
local CATEGORY_TEMPLATES_DELETION = "[[Category:Templates needing deletion]]"

local title = mw.title.getCurrentTitle()
local isTemplatePage = title.nsText == "Template"

-- Template:Categories
function p.Main(frame)
	local args, err = utilsArg.parse(frame:getParent().args, p.Templates.Categories)
	local categories = err and err.categoryText or ""

	h.storeCategories(args)
	
	for i in ipairs(args.categories or {}) do
		args.categories[i] = string.gsub(args.categories[i], "^Category:", "") -- strip namespace prefix in case it was added by mistake
	end

	local navboxes = ""
	if not utilsPage.inNamespace("Category") then
		navboxes = h.printNavboxes(args)
	end
	if not utilsPage.inNamespace("User") then
		categories = h.printCategories(args.categories, args)..categories
	end
	return navboxes, categories
end

-- Template:Categories/Navbox
function p.Navbox(frame)
	local categories = string.format("[[Category:%s|%s]]", CATEGORY_CAT_NAVBOX_TEMPLATES, title.subpageText)

	local args, err = utilsArg.parse(frame:getParent().args, p.Templates["Categories/Navbox"])
	if err then
		categories = categories..err.categoryText
	end
	if err and isTemplatePage then
		return "", categories
	elseif err then
		return ""
	end
	
	local groups = {}
	for i, group in ipairs(args.groups) do
		local groupCategories = {}
		for j, category in ipairs(group.category) do
			category = category and string.gsub(category, "^Category:", "") -- category may or may not have prefix - strip it in case it does
			if category and not utilsPage.exists("Category:"..category) then
				local utilsError = require("Module:UtilsError")
				local err = utilsError.error(string.format("Category <code>%s</code> does not exist", category), true)
				categories = categories..err..CATEGORY_INVALID_ARGS
			else
				table.insert(groupCategories, category)
			end
		end
		if #groupCategories > 0 then
			group.category = groupCategories
			table.insert(groups, group)
		end
	end
	local navbox, pageCount = h.printNavbox(args.navboxCategory, groups)
	
	if pageCount > Data.maxPagesPerNav then
		local utilsError = require("Module:UtilsError")
		local err = utilsError.error(string.format("No navbox is shown for category <code>%s</code> as it has %d entries, which exceeds the %d-page maximum", args.navboxCategory, pageCount, Data.maxPagesPerNav), true)
		categories = categories..err..CATEGORY_TEMPLATES_DELETION
	end
	
	if isTemplatePage then
		return navbox or "", categories
	else
		return navbox or ""
	end
end

function h.storeCategories(args)
	for i, category in ipairs(Data.gameCategories) do
		local games = args[category.parameter]
		if games then
			-- We only store the base game to avoid skewing appearance counts towards games with remakes
			games = utilsTable.map(games, Franchise.baseGame)
			games = utilsTable.unique(games)
			local canon, nonCanon = utilsTable.partition(games, Franchise.isCanon)
			mw.getCurrentFrame():expandTemplate({
				title = "Categories/Store",
				args = {
					category = category.category,
					canon = table.concat(canon, ", "),
					nonCanon = table.concat(nonCanon, ", "),
				}
			})
		end
	end
	for i, category in ipairs(args.categories or {}) do
		mw.getCurrentFrame():expandTemplate({
			title = "Categories/Store",
			args = {
				category = category,
			}
		})
	end
end

function h.printCategories(plainCategories, perGameCategories)
	local categories = plainCategories or {}
	local gameCategoryMap = {} -- keeps track of which games are specified for which categories 
	
	for _, category in ipairs(Data.gameCategories) 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(Data.gameCategories) 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
	
	local categoryLinks = ""
	for i, category in ipairs(categories) do
		categoryLinks = categoryLinks..string.format("[[Category:%s]]", category)
	end
	
	return categoryLinks
end

function h.printNavboxes(args)
	local html = mw.html.create("div")
		:addClass("zw-categories")
		:tag("div")
			:addClass("zw-categories__navboxes")
		
	local commonNavs = {}
	for i, gameCategory in ipairs(Data.gameCategories) do
		if args[gameCategory.parameter] and gameCategory.common then
			local navbox = h.printCommonNav(gameCategory)
			if navbox then
				table.insert(commonNavs, navbox)
			end
		end
	end
	if #commonNavs > 0 then
		html:tag("div")
			:addClass("zw-categories__navboxes-common")
			:wikitext(unpack(commonNavs))
	end
	
	if args.categories then
		table.sort(args.categories)
		local autoNavs = html:tag("div")
			:addClass("zw-categories__navboxes-category-list")
		for i, category in ipairs(args.categories) do
			local templateName = "Template:Categories/Navbox/"..category
			
			local navbox
			if utilsPage.exists(templateName) then
				navbox = mw.getCurrentFrame():expandTemplate({ title = templateName })
			else
				navbox = h.printNavbox(category)
			end
			autoNavs:wikitext(navbox)
		end
	end
	
	return tostring(html:allDone())
end

function h.printCommonNav(gameCategory)
	local category, tiers = gameCategory.category, gameCategory.common
	
	-- Display nothing if the current subject does not appear in tiers[1] or more games
	local results = utilsCargo.query("Categories, Categories__canon", "Categories._pageName=page", {
		join = "Categories._ID = Categories__canon._rowID",
		orderBy = "page",
		groupBy = "page",
		having = "COUNT(*) >= "..tiers[1],
		where = utilsCargo.allOf({
			_pageName = mw.title.getCurrentTitle().text,
			category = category,
		})
	})
	if #results == 0 then
		return nil
	else
		return h._printCommonNav(category, tiers)
	end
end
function h._printCommonNav(category, tiers)	
	local navboxArgs = {
		id = "Common "..category,
		title = "Recurring "..category
	}
	local numTiers = utilsTable.size(tiers)
	for i in ipairs(tiers) do
		local min = tiers[i]
		local max = tiers[i+1]
		
		local countRange = "COUNT(*) >= "..min
		if max then
			countRange = countRange.." AND COUNT(*) < "..max
		end
		local results = utilsCargo.query("Categories, Categories__canon", "Categories._pageName=page", {
			join = "Categories._ID = Categories__canon._rowID",
			orderBy = "page",
			groupBy = "page",
			having = countRange,
			where = utilsCargo.allOf({
				category = category
			})
		})
		local pages = utilsTable.map(results, "page")
		local index = numTiers - i + 1 -- we want to show the highest tiers first
		navboxArgs["group"..index] = string.format("%d+ Appearances", min)
		navboxArgs["links"..index] = h.rowContent(pages)
	end
	return mw.getCurrentFrame():expandTemplate({
		title = "Navbox",
		args = navboxArgs,
	})
end

function h.printNavbox(category, groups)
	local dplArgs = {
		namespace = {"", "Community"},
		includeSubpages = false,
		skipthispage = "no",
		orderMethod = "title",
		category = category,
	}
	local navboxArgs = {
		id = "category-"..category,
		title = category,
		footer = string.format("[[:Category:%s]]", category),
	}
	
	local pagesInCategory = utilsPage.dpl(dplArgs)

	if #pagesInCategory < MIN_PAGES_PER_NAV or #pagesInCategory > Data.maxPagesPerNav then
		return nil, #pagesInCategory
	end
	
	if not groups then
		navboxArgs["links1"] = h.rowContent(pagesInCategory)
		local navbox = mw.getCurrentFrame():expandTemplate({
			title = "Navbox",
			args = navboxArgs
		})
		return navbox, #pagesInCategory
	end
	
	local errCategories = ""
	for i, group in ipairs(groups) do
		local groupPages = {}
		for j, groupCategory in ipairs(group.category) do
			local rowDplArgs = utilsTable.merge({}, dplArgs, {
				category = category.."&"..groupCategory
			})
			local pages = utilsPage.dpl(rowDplArgs)
			groupPages = utilsTable.concat(groupPages, pages)
		end
		groupPages = utilsTable.unique(groupPages)
		table.sort(groupPages)
		if #groupPages > 0 then
			navboxArgs["group"..i] = group.display or group.category[1]
			navboxArgs["links"..i] = h.rowContent(groupPages)
			navboxArgs["maxGroupSize"..i] = group.maxGroupSize
		elseif isTemplatePage then
			local utilsError = require("Module:UtilsError")
			utilsError.warn(string.format("Group %d has 0 entries.", i))
			errCategories = errCategories.."[[Category:"..CATEGORY_NAVBOXES_ATTENTION.."]]"
		end
	end
	-- "Other" row
	local categoriesToExclude = {}
	for i, group in ipairs(groups) do
		categoriesToExclude = utilsTable.concat(categoriesToExclude, group.category)
	end
	local otherDplArgs = utilsTable.merge({}, dplArgs, {
		notcategory = categoriesToExclude
	})
	local results = utilsPage.dpl(otherDplArgs)
	if #results > 0 then
		local i = utilsTable.size(groups) + 1
		navboxArgs["group"..i] = "Other"
		navboxArgs["links"..i] = h.rowContent(results)
	end
	local navbox = mw.getCurrentFrame():expandTemplate({
		title = "Navbox",
		args = navboxArgs,
	})
	return navbox..errCategories, #pagesInCategory
end

function h.rowContent(pages)
	local links = utilsTable.map(pages, function(page)
		page = string.gsub(page, ",", "&#44;") -- fixes bug where pages with commas in them are rendered incorrectly
		local display = page
		display = string.gsub(display, "^Community:", "")
		return string.format("[[%s|%s]]", page, display)
	end)
	return table.concat(links, ", ")
end

function p.Data(frame)
	local utilsLayout = require("Module:UtilsLayout")
	local utilsMarkup = require("Module:UtilsMarkup")
	local utilsString = require("Module:UtilsString")
	
	local commonSubjectNavboxes = ""
	for i, gameCategory in ipairs(Data.gameCategories) do
		if gameCategory.common then
			commonSubjectNavboxes = commonSubjectNavboxes .. h._printCommonNav(gameCategory.category, gameCategory.common)
		end
	end
	local desc = 'The following navbox templates are automatically generated by [[Template:Categories]]. The commonality tiers are controlled by the <code>common</code> fields defined on this /Data page.'
	commonSubjectNavboxes = desc..commonSubjectNavboxes
	
	local subpages = utilsPage.getSubpages("Template:Categories/Navbox")
	subpages = utilsTable.filter(subpages, function(subpage)
		return not string.find(subpage, "/Documentation$") and not string.find(subpage, "/Preload$")
	end)
	local needingAttention = utilsPage.dpl({
		category = CATEGORY_NAVBOXES_ATTENTION,
	})
	local withOther = utilsPage.dpl({
		category = CATEGORY_NAVBOXES_OTHER,
	})
	needingAttention = utilsTable.invert(needingAttention)
	withOther = utilsTable.invert(withOther)
	local dataRows = utilsTable.map(subpages, function(subpage)
		local display = string.match(subpage, "Template:Categories/Navbox/(.*)")
		local navbox = string.format("[[%s|%s]]", subpage, display)
		
		local needsAttention = needingAttention[subpage] and "yes" or ""
		local hasOther = withOther[subpage] and "yes" or ""

		return {navbox, needsAttention, hasOther}
	end)
	local customizedNavboxes = utilsLayout.table({
		sortable = true,
		headers = {
			"[[:Category:"..CATEGORY_CAT_NAVBOX_TEMPLATES.."|Navbox]]",
			"[[:Category:"..CATEGORY_NAVBOXES_ATTENTION.."|Needs Attention]]",
			"[[:Category:"..CATEGORY_NAVBOXES_OTHER.."|Has Other]]",
		},
		rows = dataRows,
	})
	local desc = "These customized [[:Category:"..CATEGORY_CAT_NAVBOX_TEMPLATES.."|category navbox templates]] are added by [[Template:Categories]] instead of the ones it generates automatically (see previous tab)."
	customizedNavboxes = desc..customizedNavboxes

	local queryResults = utilsCargo.query("Categories, _pageData", "category, COUNT(*)=pageCount, Categories._pageName=sample, _isRedirect", {
		join = "Categories._pageName=_pageData._pageName",
		where = "canon HOLDS NOT LIKE '%' AND nonCanon HOLDS NOT LIKE '%' AND category IS NOT NULL AND _isRedirect = '0'",
		groupBy = "category",
		limit = 1000,
	})
	local autoNavCategories, fatCats = utilsTable.partition(queryResults, function(cat)
		return tonumber(cat.pageCount) <= Data.maxPagesPerNav
	end)
	local autoNavCategories, tinyCats = utilsTable.partition(autoNavCategories, function(cat)
		return tonumber(cat.pageCount) >= MIN_PAGES_PER_NAV
	end)

	local tableRows = {}
	for i, cat in ipairs(autoNavCategories) do
		local category = cat.category
		local pageCount = tonumber(cat.pageCount)
		local sample = cat.sample
		local customNavboxTemplate = "Template:Categories/Navbox/"..category
		if not utilsPage.exists(customNavboxTemplate) then
			local categoryLink = "[[:Category:"..category.."]]"
			local navboxId = "navbox-category-"..utilsString.kebabCase(category)
			local sampleLink = string.format("[[%s#%s|%s]]", sample, navboxId, sample)
			
			local customizeLink = mw.title.new(customNavboxTemplate):fullUrl({
				action = "edit",
				preload = "Template:Categories/Navbox/Preload",
				["preloadparams[]"] = category,
			})
			customizeLink = string.format("[%s Customize %s]", customizeLink, category)

			table.insert(tableRows, {pageCount, categoryLink, sampleLink, customizeLink})
		end
	end
	-- Sort descending by number of pages
	table.sort(tableRows, function(a, b)
		return a[1] > b[1]
	end)
	local autoNavboxes = utilsLayout.table({
		sortable = true,
		headers = {"{{Exp|Number of pages in category|#}}", "Category", "Sample", "Customization"},
		rows = tableRows,
	})
	local desc = ""
	.."<p>The following categories have navboxes automatically generated by [[Template:Categories]]. As there are too many such navboxes to display them here, click the sample link to view the navbox on one of the pages that uses it.</p>"
	.."<p>To customize these navboxes, click the associated link in the table below. See [[Template:Categories/Navbox]] (and the next tab) for more information.</p>"
	.."<p>Navboxes with more than a dozen or so links should usually be partitioned using [[Template:Categories/Navbox]]. Navboxes with too many links in a single list may be less effective as navigation tools.</p>"
	desc = frame:preprocess(desc)
	autoNavboxes = desc .. autoNavboxes
	
	local tableRows = {}
	for i, cat in ipairs(fatCats) do
		local catLink = string.format("[[:Category:%s]]", cat.category)
		table.insert(tableRows, {catLink, cat.pageCount})
	end
	fatCats = utilsLayout.table({
		sortable = true,
		headers = {"Category", "{{Exp|Number of pages in category|#}}"},
		rows = tableRows,
	})
	local desc = string.format("No navbox is generated for the following categories as they have more than %d pages.", Data.maxPagesPerNav)
	fatCats = desc..fatCats
	
	local tableRows = {}
	for i, cat in ipairs(tinyCats) do
		local catLink = string.format("[[:Category:%s]]", cat.category)
		table.insert(tableRows, {catLink, cat.pageCount})
	end
	tinyCats = utilsLayout.table({
		sortable = true,
		headers = {"Category", "{{Exp|Number of pages in category|#}}"},
		rows = tableRows,
	})
	local desc = string.format("<p>No navbox is generated for the following categories as they contain less than %d pages.</p>", MIN_PAGES_PER_NAV)
	desc = desc.."<p>These categories should be added to more pages or removed entirely.</p>"
	tinyCats = desc..tinyCats
	
	local tabs = utilsLayout.tabs({
		{
			label = "Common Subject Navboxes",
			content = commonSubjectNavboxes,
		},
		{
			label = "Category Navboxes (Automatic)",
			content = autoNavboxes,
		},
		{
			label = "Category Navboxes (Customized)",
			content = customizedNavboxes,
		},
		{
			label = "Big Categories",
			content = fatCats,
		},
		{
			label = "Small Categories",
			content = tinyCats
		},
	})

	return tabs
end

local params = {}
local paramOrder = {}
for _, perGameCategory in ipairs(Data.gameCategories) 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

function p.Schemas()
	return {
		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 = "gameCategories",
					required = true,
					type = "array",
					desc = "Defines the categories which are subcategorized by [[Data:Franchise]] entry.",
					items = {
						type = "record",
						properties = {
							{
								name = "parameter",
								required = true,
								type = "string",
								desc = "The name of the parameter for [[Template:Categories]].",
							},
							{
								name = "category",
								required = true,
								type = "string",
								desc = "The parent category name.",
							},
							{
								name = "common",
								type = "array",
								items = { type = "number" },
								desc = "If present, [[Module:Categories]] will generate a navbox for subjects with more than N appearances in the given category.",
							},
						},
					},
				},
			},
		},
	}
end

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",
					["locations"] = "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 codes, and improperly ordered codes are handled appropriately.",
				args = {
					characters = "TP, TP, fakeGame, OoT, OoT",
				},
			},
		},
	},
	["Categories/Navbox"] = {
		format = "block",
		purpose = "For creating a [[:Category:Category navbox templates|category-based navbox]] to override the default navbox generated by [[Template:Categories]].",
		boilerplate = {
			hiddenParams = {"maxGroupSize"},
		},
		repeatedGroup = {
			name = "groups",
			params = {"category", "display", "maxGroupSize"}, 
			counts = {2, 3, 4, 5},
		},
		params = {
			[1] = {
				name = "navboxCategory",
				required = true,
				inline = true,
				type = "string",
				desc = "The name of the category that this navbox is for.",
				trim = true,
				nilIfEmpty = true,
			},
			display = {
				type = "string",
				desc = "A label for the navbox partition. Corresponds to the <code>group</code> parameter of [[Template:Navbox]]. Defaults to <code>categoryN</code>.",
				trim = true,
				nilIfEmpty = true,
			},
			category = {
				type = "string",
				required = true,
				desc = "<p>A category name, without the <code>Category:</code> prefix. The navbox group will contain all pages that are in both this category and the navbox category.</p><p>It is also possible to specify a comma-separated list of category names. In this case, the navbox group will contain all pages that are in the navbox category and ''any'' of the specified categories. The first category in the list is the default <code>display</code> name.</p>",
				trim = true,
				nilIfEmpty = true,
				split = true,
			},
			maxGroupSize = {
				type = "number",
				desc = "Sets the <code>maxGroupSize</code> parameter of [[Template:Navbox]].",
			},
		},
		examples = {
			{
				"Insects",
				category1= "Bees",
				category2= "Butterflies",
				category3= "Dragonflies",
			},
			{
				"Treasures",
				category1= "Items in Phantom Hourglass",
				display1= "{{PH|-}}",
				
				category2= "Items in Spirit Tracks",
				display2= "{{ST|-}}",
				
				category3= "Items in Skyward Sword",
				display3= "{{SS|-}}",
				
				category4= "Items in A Link Between Worlds",
				display4= "{{ALBW|-}}",
			},
			{
				"Twilight Enemies",
				category1= "Enemies",
				category2= "Bosses, Sub-Bosses",
			},
			{
				desc = "<code>navboxCategory</code> can refer to a category that doesn't exist yet, but the subgroup categories must exist.",
				args = {
					"New Category",
					category1 = "Not a Category",
					category2 = "",
				},
			},
			{
				"Hylians"
			},
			{
				args = {""},
			},
			{
				desc = "Non-existent grouping categories are ignored.",
				args = {
					"Medals",
					category1= "Items in Skyward Sword",
					category2= "Not a Category",
				},
			},
		},
	},
}

return p