Module:File

local p = {} local h = {}

local Franchise = require("Module:Franchise") local Term = require("Module:Term") local utilsArg = require("Module:UtilsArg") local utilsCargo = require("Module:UtilsCargo") local utilsError = require("Module:UtilsError") local utilsLayout = require("Module:UtilsLayout") local utilsMarkup = require("Module:UtilsMarkup") local utilsPage = require("Module:UtilsPage") local utilsString = require("Module:UtilsString") local utilsTable = require("Module:UtilsTable")

local data = mw.loadData("Module:File/Data")

local CARGO_TABLE = "Files" local CATEGORY_INVALID_ARGS = ""

-- Template:File Redirect function p.FileRedirect(frame) -- We should consider making the types uppercase in /Data so that we don't have to do this local args = utilsTable.clone(frame:getParent.args) args[2] = args[2] and string.lower(args[2])

local args, err = utilsArg.parse(args, p.Templates["File Redirect"]) local categories = "" if err then categories = categories..utilsMarkup.categories(err.categories) end if not args.game and not args.type then categories = categories.."" end if args.game and not args.type then utilsError.warn(" must be set when   is set.") categories = categories..CATEGORY_INVALID_ARGS end local gameName = args.game and Franchise.shortName(args.game) if gameName then categories = categories.."" end local type = args.type local typeCategory = data.typesByKey[type] and data.typesByKey[type].cat if gameName and typeCategory then categories = categories..string.format("", gameName, typeCategory) end return categories end

-- Various templates function p.Icon(frame) local args = frame.args local img = p.icon(args[1], args[2], {		size = args.size,	}) return img end

-- Template:FileInfo function p.StoreWidth(frame) return mw.title.getCurrentTitle.file.width end function p.StoreHeight(frame) return mw.title.getCurrentTitle.file.height end function p.FileInfo(frame) local args, err = utilsArg.parse(frame:getParent.args, p.Templates.FileInfo) local result = p.printFileInfo(args) if err then return result .. utilsMarkup.categories(err.categories) else return result end end function p.printFileInfo(args) return h.printFileInfoTable(args) .. h.categories(args.type, args.game, args.subject) end function h.printFileInfoTable(args) local gameDisplay if args.game then local gameLogo = Franchise.logo(args.game) local gameImage = gameLogo and utilsPage.exists(gameLogo) and utilsMarkup.file(gameLogo, { size = "130x130px" }) local gameLink = Franchise.link(args.game) local gameText = gameLink and string.format("This is a file pertaining to %s.", gameLink) if gameImage and gameText then gameDisplay = gameImage .. " " .. gameText elseif gameText then gameDisplay = gameText else gameDisplay = "" end end local type = args.type and data.typesByKey[args.type] type = type and type.cat

local license if args.licensing and utilsTable.includes(data.licenses, args.licensing) then license = mw.getCurrentFrame:expandTemplate({			title = "FileInfo/" .. args.licensing,			args = {				trademark = args.trademark			}		}) else license = mw.getCurrentFrame:expandTemplate({ title = "FileInfo/Unsure" }) end

local html = mw.html.create("table"):addClass("wikitable fileinfo") h.row(html, "Summary", args.summary) h.row(html, "Type", type) h.row(html, "Source", args.source) h.row(html, "Game", gameDisplay) h.row(html, "Licensing", license, {		rowspan = args.trademark and "2" or "1" 	}) h.row(html, "Trademark", args.trademark and mw.getCurrentFrame:expandTemplate({ title = "FileInfo/Trademark" })) return tostring(html) end function h.row(html, field, value, attributes) if value then return html :tag("tr") :tag("th") :wikitext(field) :done :tag("td") :wikitext(value) :done :done end end function h.categories(type, game, subjects) local gameName = game and Franchise.shortName(game) local typeCat = type and data.typesByKey[type] and data.typesByKey[type].cat local categories = {} if typeCat and not typeCat.nogame and gameName and game ~= "Series" then table.insert(categories, gameName .. " " .. typeCat) elseif typeCat then table.insert(categories, typeCat) elseif gameCat then table.insert(categories, gameName .. " Files") end if subjects then categories = utilsTable.concat(categories, h.subjectCategories(subjects, game)) end if type == "sprite" and utilsString.endsWith(mw.title.getCurrentTitle.text, ".gif") then table.insert(categories, "GIF Sprites") end if not game then -- not necessarily invalid but worth tracking table.insert(categories, "Files without Game") end return utilsMarkup.categories(categories) end function h.subjectCategories(subjects) local categories = utilsTable.flatMap(subjects, function(subject)		local term, errCategories = Term.fetchTerm(subject, "Series")		if not term then			utilsError.warn(string.format("subject  is not a valid term", subject))			return errCategories -- add term-related maintenance categories, if any		end		term = string.gsub(term, "#", "") -- strip # from term because categories can't have them in their name		local category = "Images of "..term		 -- only add subject-based categories if they already exist, to avoid spamming Special:WantedCategories		if utilsPage.exists("Category:" .. category) then			return {category}		else			return {}		end	end) return categories end

-- Module:File/Data function p.Data(frame) local result = "" result = result .. utilsMarkup.heading(2, "Types") result = result .. utilsLayout.table({		sortable = true,		headers = {"Type", "Category"},		rows = utilsTable.map(data.types, function(type) local key = utilsMarkup.code(type.key) local cat = "Category:"..type.cat.."" return {key, cat} end)	}) result = result .. utilsMarkup.heading(2, "Licenses") result = result .. utilsLayout.table({		sortable = true,		headers = {"License", "Template", "Output"},		rows = utilsTable.map(data.licenses, function(license) local template = "FileInfo/"..license local templateLink = "Template:"..template.."" local templateOutput = mw.getCurrentFrame:expandTemplate({title = template}) return {utilsMarkup.code(license), templateLink, templateOutput} end)	})

return result end

-- Queries Cargo for the 100 most-used subjects, uses DPL to determine which ones don't exist as categories yet. -- Repeat for the next 100 subjects, and so on until there are ~100 table rows or no more subjects to process. function p.WantedSubjectCategories(frame) local BATCH_SIZE = 100 local MAX_ROWS = 100 local offset = 0 local rows = {} local listedCategories = {} local cargoResults repeat cargoResults = utilsCargo.query("Files, Files__subject", "Files._pageName=page, Files__subject._value=subject, COUNT(*)=count", {			groupBy = "_value",			orderBy = "COUNT(*) DESC",			join = "Files._ID = Files__subject._rowID",			where = " Files__subject._value != ''",			limit = BATCH_SIZE,			offset = offset		}) offset = offset + BATCH_SIZE local dplArgs = utilsTable.map(cargoResults, function(result)			return {				param = "titlematch",				value = "Images of "..result.subject			}		end) dplArgs.namespace = "Category" dplArgs.redirects = "include" -- some "Images of X" categories are redirects to synonymous terms (see Category:Synonymous Files) local existingCategories = utilsPage.dpl(dplArgs) local existingCategoryLookup = utilsTable.invert(existingCategories) for _, result in ipairs(cargoResults) do			local cat = "Category:Images of "..result.subject if not listedCategories[cat] and not existingCategoryLookup[cat] then table.insert(rows, { utilsMarkup.link(cat), result.count, utilsMarkup.link(result.page)}) listedCategories[cat] = true -- because of the way we batch the cargo queries, we need keep track of results that were already added end end until #rows >= MAX_ROWS or #cargoResults == 0 return utilsLayout.table({		sortable = true,		headers = {"Category", "Count", "Sample File"},		rows = rows	}) end

-- Utilities function p.image(filename, options) filename = utilsPage.stripNamespace(filename) options = options or {} local sizeWidth, sizeHeight = p.dimensions(options.size) local checkExists = options.checkExists ~= false -- If the file is a redirect and we are doing an existence check or getting width/height from Cargo, then we need to get the redirectTarget. -- This is a somewhat expensive operation to do at scale so we only do it when needed local originalFilename = filename if checkExists or options.scale then local redirectTarget = mw.title.new("File:"..filename).redirectTarget redirectTarget = redirectTarget and redirectTarget.text if redirectTarget then filename = redirectTarget end end if checkExists and not utilsPage.exists("File:" .. filename, true) then -- We check again with mw.title To work around a bug where moved files are marked non-existant until null-edited -- This happens because of how we use Cargo to query for existence to save on expensive parser functions local title = mw.title.new(filename, "File") if not title.fileExists then return h.noimage(filename, sizeWidth, sizeHeight, options), false end end if options.scale then local file if options.scaleUsingCargo then results = utilsCargo.query(CARGO_TABLE, "width, height", {				where = utilsCargo.allOf({ ["_pageName"] = "File:"..filename, }),				limit = 1,			}) file = results[1] end if not file or utilsString.isEmpty(file.width) or utilsString.isEmpty(file.height) then -- if scaleUsingCargo = false or data does not return from Cargo query for some reason, then use the title object (an expensive parser function) file = mw.title.new("File:"..filename).file end

local width = math.floor(tonumber(file.width) * options.scale) local height = math.floor(tonumber(file.height) * options.scale) if (sizeWidth and sizeWidth < width) or (sizeHeight and sizeHeight < height) then width = sizeWidth height = sizeHeight end size = "" if width then size = width end if height then size = size .. "x" .. height end size = size .. "px" options = utilsTable.merge({}, options, {			size = size,		}) end local classes = utilsTable.compact({options.notPageImage and "notpageimage", options.isPixelArt and "pixel-art"}) if #classes > 0 then options.class = table.concat(classes, " ") end return utilsMarkup.file(originalFilename, options), checkExists and true or nil end function p.dimensions(size) if not size then return nil end local s, e	s, e = size:find("^[0-9]+") local width = s and size:sub(s, e) or "" s, e = size:find("x[0-9]+") local height = s and size:sub(s+1, e) or "" return tonumber(width), tonumber(height) end function h.noimage(filename, sizeWidth, sizeHeight, options) local uploadUrl = mw.uri.fullUrl("Special:Upload") uploadUrl:extend({		wpDestFile = filename	}) local options = utilsTable.merge({}, options, {		link = tostring(uploadUrl),		class = "notpageimage",	}) -- Make sure thumbnail for 'no image' is no less than 100x100px if (sizeWidth and sizeWidth < 100) or (sizeHeight and sizeHeight < 100) then options.size = "100px" end return utilsMarkup.file("File:No Image Upload.png", options) end

function p.gameImage(game, subject, type, options) options = options or {} local parts = utilsTable._filter(utilsString.notEmpty)({game, subject, type}) local filename = table.concat(parts, " ") .. ".png" if Franchise.graphics(game) == "2D" then options.isPixelArt = true end return p.image(filename, options) end

function p.icon(game, subject, options) local type = "Icon" if Franchise.graphics(game) == "2D" then type = "Sprite" end options = options or {} options.isPixelArt = true return p.gameImage(game, subject, type, options) end

function p.logo(code, options) local filename = Franchise.logo(code) return p.image(filename, options) end

p.Templates = { FileInfo = { purpose = "Displays, categorizes, and stores file information. See Guidelines:Files for further guidance.", format = "block", paramOrder = {"summary", "subject", "type", "source", "game", "licensing", "trademark"}, params = { summary = { --required = true, type = "content", desc = "A short description of the file.", trim = true, nilIfEmpty = true, },			type = { required = "Category:Files Lacking Type", type = "string", desc = "The type of file, which determines how it is categorized.", enum = data.typesEnum, trim = true, nilIfEmpty = true, },			source = { required = "Category:Files Lacking Sources", type = "string", desc = "The original source of the file. It may be in the form of a URL or author recognition. Template:Source exists for this purpose.", trim = true, nilIfEmpty = true, },			subject = { type = "string", desc = "Wiki article names of all the subjects depicted in the file. A comma-separated list.", split = true, trim = true, nilIfEmpty = true, },			game = { --required = true, type = "string", desc = "A valid code for a game, book, comic, manga, or TV show (or ).", enum = Franchise.enum({ 					includeSeries = true,					includeGroups = true,				}), trim = true, nilIfEmpty = true, },			licensing = { required = "Category:Unlicensed Files", type = "string", desc = "The copyright licensing for the file. For the vast majority of files,  is the correct value here.", enum = data.licenses, trim = true, nilIfEmpty = true, },			trademark = { type = "boolean", desc = "Enter any text to add a trademark notice to the licensing. Use on all trademarks (usually denoted by an ® or ™ symbol).", trim = true, nilIfEmpty = true, }		},		examples = { {				summary = "LADX:", subject = "Animal Village, Rabbit", type = "map", source = "", game = "LADX", licensing = "Copyright", },			{				summary = "The Timeline", source = "", type = "print", game = "Series", licensing = "Copyright" },			{				summary = "Nintendo's current logo.", type = "logo", source = "", licensing = "PD-Simple", trademark = "yes", },			{				summary = "File missing required info" },		},	},	["File Redirect"] = { purpose = "This template is added to file redirects to properly categorize them.", params = { [1] = {				name = "game", desc = "A game code.", type = "string", enum = Franchise.enum({ includeSeries = true }), trim = true, nilIfEmpty = true, },			[2] = {				name = "type", desc = "The file type. This should be set if  is set.", type = "string", enum = data.typesEnum, trim = true, nilIfEmpty = true, },		},		examples = { {"MM", "Model"}, {},			{				desc = "Error handling", args = {"not a game", "Model"}, },			{"MM", "not a type"}, {"MM"}, },	} }

function p.Schemas local optionsSchema = { type = "record", properties = { {				name = "size", type = "string", desc = "Image size in pixels.", },			{				name = "scale", type = "number", desc = "Image scaling factor — the original image size is multitplied by . If both   and   are present, the value which results in the smaller image will be used. By default this uses an .", },			{				name = "scaleUsingCargo", type = "boolean", desc = "If set to true, then a Cargo query is used to determine the original image size for the  option above. You can use this to avoid hitting expensive parser function limit. This option has an additional performance cost of roughly 2-5 milliseconds per image.", },			{				name = "link", type = "string", desc = "Name of a page on the wiki or an external URL for the image thumbnail to link to.", },			{				name = "caption", type = "string", desc = "Alt text for the image.", },			{				name = "notPageImage", type = "boolean", desc = "If true, the image will never appear as the page's representative image in page previews.", },			{				name = "checkExists", type = "boolean", default = "true", desc = "If set to  then the function skips the file existence check. A red link is returned instead of the 'please upload' placeholder.", },		}	}	local franchiseCode = { required = true, type = "string", desc = "A franchise code." }	return { image = { filename = { required = true, type = "string", desc = "Filename of the image, with or without the namespace prefix.", },			options = { type = "record", properties = utilsTable.concat(optionsSchema.properties, {					{						name = "isPixelArt",						type = "boolean",						desc = "If  the image will be rendered using nearest-neighbor interpolation, which prevents pixel art (sprites, 2D game screenshots) from appearing blurry in thumbnails.",					},				}) },		},		gameImage = { game = franchiseCode, subject = { type = "string", required = true, },			type = { type = "string", required = true, enum = {"", "Artwork", "Icon", "Model", "Render", "Screenshot", "Sprite", "Texture"}, },			options = optionsSchema, },		icon = { game = franchiseCode, subject = { type = "string", required = true, },			options = optionsSchema, },		logo = { code = franchiseCode, optons = optionsSchema, },	} end

function p.Documentation return { image = { desc = "A higher-level version of utilsMarkup.file with awareness of whether the file exists or not.", params = {"filename", "options"}, returns = { "Wikitext rendering an image thumbnail.", "A boolean — true if the image exists, false otherwise.", },			cases = { {					args = {"File:TWW Great Fairy Figurine Model.png", { link = "Great Fairy", size = "100px" }},					expect = {"", true} },				{					desc = "If file does not exist, show 'click to upload' thumbnail which links to Special:Upload.", args = {"File:TWWHD Great Fairy Figurine Model.png", { link = "Great Fairy", size = "150px", }},					expect = {"", false} },				{					desc = "'No image' thumbnail has minimum 100px width, because it is illegible at smaller sizes.", args = {"File:TWWHD Great Fairy Figurine Model.png", { size = "64px", }},					expect = {"", false}, },				{					desc = " skips the existence check and simply render a red link", args = {"File:TWWHD Great Fairy Figurine Model.png", { link = "Great Fairy", size = "100px", checkExists = false, }},					expect = {"", nil} },				{					desc = "Scaling factor.", args = {"File:TMC Vaati Sprite.png", { scale = 2 }}, expect = {"", true} },				{					desc = "The  options ensures that sprites do not appear blurry.", args = {"File:TMC Vaati Sprite.png", { scale = 2, isPixelArt = true }}, expect = {"", true}, },				{					desc = "Scaling using Cargo instead of an expensive parser function.", args = {"File:TMC Vaati Sprite.png", { scale = 2, scaleUsingCargo = true, isPixelArt = true }}, expect = {"", true}, },				{					desc = "If both  and   are specified, the one resulting in the smaller image is used.", args = {"File:TMC Vaati Sprite.png", { scale = 2, size = "80px", isPixelArt = true }}, expect = {"", true}, },				{					args = {"File:TMC Vaati Sprite.png", { scale = 10, size = "80px", isPixelArt = true }}, expect = {"", true}, },				{					desc = "Applying .", args = {"File:TMC Vaati Sprite.png", { notPageImage=true, isPixelArt = true }}, expect = {"", true}, },			},		},		gameImage = { desc = "A specialized version of image that infers the filename from game, subject, and type.", params = {"game", "subject", "type", "options"}, returns = { "A  of wikitext that renders a thumbnail.", "A boolean — true if the image exists, false otherwise.", },			cases = { {					args = {"TWW", "Great Fairy Figurine", "Model", { link = "Great Fairy", size = "100px" }},					expect = {"", true} },			}		},		icon = { params = {"game", "subject", "options"}, returns = "An icon thumbnail for the subject in the given game.", cases = { {					args = {"LANS", "Pineapple"}, expect = "" },				{					args = {"LADX", "Pineapple"}, expect = "" },			}		},		logo = { params = {"code", "options"}, returns = { "Given a valid franchise code, returns a logo thumbnail.", "A boolean indicating whether a logo exists for the game yet.", },			cases = { {					args = {"TWW", { size = "200px" }}, expect = {"", true} },				{					args = {"SSB4", { size = "200px" }}, expect = {"", true}, },				{					args = {"SS (Himekawa)", { size = "200px" }}, expect = {"", true} },				{					args = {"TLoZ (Mishouzaki)", { size = "200px" }}, expect = {"", true}, },				{					args = {"TAoL (Mishouzaki)", { size = "200px" }}, expect = {"", false}, },				{					args = {"E", { size = "200px" }}, expect = {"", true}, },				{					args = {"TMoL", { size = "200px" }}, expect = {"", true}, },			}		}	} end

return p