Module:Data Table

local p = {} local h = {}

local File = require("Module:File") local Franchise = require("Module:Franchise") local GalleryList = require("Module:Gallery List") local Sequences = require("Module:Sequences") local Term = require("Module:Term") local TermList = require("Module:Term List") local utilsArg = require("Module:UtilsArg") local utilsCargo = require("Module:UtilsCargo") 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") local SMALL_WIDTH_THRESHOLD = 40 -- any table whose width exceeds this value will have a "mobile-friendly mode"

-- "Responsive mode" requires loading two different tables for desktop and mobile and hiding one or the other -- Some tables are too large for this and exceed the max article size, so we disable responsive mode on those pages -- This can be removed once we increase the max article size local RESPONSIVE_MODE_DENYLIST = { ["Armor"] = true, ["Challenge"] = true, ["User:PhantomCaleb/Sandbox/Armor"] = true, }

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

function h.err(errMsg, warnMsg) local utilsError = require("Module:UtilsError") return utilsError.error(errMsg, warnMsg) end

-- Other modules can extend this one by using storeFn to store data in a custom table function p.Main(frame, storeFn, extensionArgsFn) local args, err = utilsArg.parse(frame:getParent.args, p.Templates["Data Table"]) local categories = err and err.categoryText or "" args.columnNames = h.extractColumnTags(args.columns)

local rows = h.parseRows(args.cells) local storeCategories = h.storeTable(args, storeFn, rows) categories = categories..storeCategories

local dataTable = p.printTable(rows, args) local extensionArgs = extensionArgsFn and extensionArgsFn(args) or {} if extensionArgs.requiredColumns then local missingColumns = utilsTable.difference(extensionArgs.requiredColumns, args.columnNames) if #missingColumns > 0 then missingColumns = utilsTable.map(missingColumns, utilsMarkup.code) h.warn(string.format("Columns %s are required.", mw.text.listToText(missingColumns))) categories = categories.."" end end

return dataTable, categories end

function p.Copy(frame) local args, err = utilsArg.parse(frame:getParent.args, p.Templates["Data Table Copy"]) local categories = err and err.categoryText or "" if not args.fromPage or not args.storedAs then return "", categories end local dataTable, copyCategories = p.copy(args) return dataTable, categories..copyCategories end

function p.ListExtensions(frame) local utilsPage = require("Module:UtilsPage") local subpages = utilsPage.getSubpages("Template:Data Table") local extensions = utilsTable.filter(subpages, function(subpage)		return not (string.find(subpage, "/Documentation$") or string.find(subpage, "/Styles.css") or string.find(subpage, "/Store"))	end) extensions = utilsTable.map(extensions, utilsMarkup.link) extensions = utilsMarkup.bulletList(extensions) return extensions end

-- postFormatHook allows other modules to make further changes to the table before it is printed, see Module:Armor for example function p.printTable(rows, args, postFormatHook) local tableArgs = h.formatTable(rows, args) if postFormatHook then tableArgs = postFormatHook(tableArgs) end

local styles = h.addStyles local responsiveModeEnabled = not tableArgs.isSmall and not RESPONSIVE_MODE_DENYLIST[mw.title.getCurrentTitle.fullText] local desktopTable = h.desktopTable(tableArgs, responsiveModeEnabled) local mobileTable = h.mobileTable(tableArgs, responsiveModeEnabled) return styles..desktopTable..mobileTable end

function p.copy(args, filterFn) local categories = "" local columnsQuery = utilsCargo.query("DataTables", "columns", {		where = utilsCargo.allOf({ _pageName = args.fromPage, tableName = args.storedAs, }, "columns HOLDS LIKE '%'"),		limit = 1	}) if #columnsQuery == 0 then local err = h.err("Data not found", string.format("No table  is stored on page %s", args.storedAs, args.fromPage)) return err, categories.."" end local columns = utilsString.split(columnsQuery[1].columns) columns = utilsTable.map(columns, mw.text.killMarkers) local columnsWithoutTags = h.extractColumnTags(columns) local columnLookup = utilsTable.invert(columnsWithoutTags) local includedColumnIndices = {} if not args.columns then includedColumnIndices = utilsTable.keys(columns) end for i, column in ipairs(args.columns or args.excludeColumns or {}) do		column = mw.text.killMarkers(column) local columnIndex = columnLookup[column] if not columnIndex then h.warn(string.format("Column  does not exist in table   stored on page %s. The columns defined for this table are as follows: %s", column, args.storedAs, args.fromPage, utilsMarkup.bulletList(columnsWithoutTags))) categories = categories.."" includedColumnIndices = utilsTable.keys(columns) break elseif args.columns then table.insert(includedColumnIndices, columnIndex) elseif args.excludeColumns then includedColumnIndices[columnIndex] = nil end end includedColumnIndices = utilsTable.compact(includedColumnIndices)

local whereExpressions = utilsTable.compact({		string.format("_pageName = '%s'", utilsCargo.escape(args.fromPage)),		args.storedAs and string.format("tableName = '%s'", utilsCargo.escape(args.storedAs)),		"columns HOLDS NOT LIKE '%'",	}) local whereClause = table.concat(whereExpressions, " AND ") local rowsQuery = utilsCargo.query("DataTables", "game, rowIndex, columnIndex, cell", {		where = whereClause,		limit = 5000,		orderBy= "rowIndex, columnIndex",	}) local rows = {} local currentRow = { cells = {} } local previousColumn = 0 for i, rowData in ipairs(rowsQuery) do		local columnIndex = tonumber(rowData.columnIndex) if columnIndex < previousColumn then table.insert(rows, currentRow) currentRow = { cells = {} } end table.insert(currentRow.cells, rowData.cell or " ") previousColumn = columnIndex end table.insert(rows, currentRow) if args.rowsWith or args.rowsExcluding then rows = utilsTable.filter(rows, function(row)			local includeRow = args.rowsWith == nil			local excludeRow = false			for i, cell in ipairs(row.cells) do				if args.rowsWith and string.find(cell, args.rowsWith) then					includeRow = true				end				if args.rowsExcluding and string.find(cell, args.rowsExcluding) then					excludeRow = true				end			end			return includeRow and not excludeRow		end) end if #rows == 0 then local withCriteria = args.rowsWith and string.format(" ", args.rowsWith) local excludingCriteria = args.rowsExcluding and string.format(" ", args.rowsExcluding) local critera = withCriteria or excludingCriteria if withCriteria and excludingCriteria then critera = withCritera.." and "..excludingCriteria end local criteriaMsg = criteria and " matching criteria "..criteria or "" local err = h.err("Data not found", string.format("No rows%s were found in table  on page %s", criteriaMsg, args.storedAs, args.fromPage)) return err, categories.."" end if filterFn then rows = utilsTable.filter(rows, filterFn) end local includedColumns = {} for i, columnIndex in ipairs(includedColumnIndices) do		local column = columns[columnIndex] table.insert(includedColumns, column) end for i, row in ipairs(rows) do		local rowCells = {} for j, columnIndex in ipairs(includedColumnIndices) do			table.insert(rowCells, row.cells[columnIndex]) end row.cells = rowCells end local dataTable = p.printTable(rows, {		game = rowsQuery[1].game,		columns = includedColumns,	}) return dataTable, categories end

function h.parseRows(cells) local rows = {} local currentRow = { cells = {} }	for i, cellText in ipairs(cells or {}) do		local isDivider = string.find(cellText, "^%-+$") -- is a divider if it contains only -'s		if isDivider and i ~= 1 then table.insert(rows, currentRow) currentRow = { cells = {}, }		elseif not isDivider then table.insert(currentRow.cells, cellText) end end if #currentRow.cells > 0 then table.insert(rows, currentRow) end return rows end

function h.extractColumnTags(columns) local columnHeaders = {} local columnTags = {} for i, column in ipairs(columns) do		columnHeaders[i] = column columnTags[i] = {} local matches repeat columnHeaders[i], matches = string.gsub(columnHeaders[i], "%s*%[([%w:%-%s%%]+)%]$", function(tag)				tag = utilsString.split(tag, ":")				table.insert(columnTags[i], { name = tag[1], args = utilsTable.tail(tag), })				return ""			end) until matches == 0 columnTags[i] = utilsTable.reverse(columnTags[i]) end return columnHeaders, columnTags end

function h.storeTable(args, storeFn, rows) local categories = "" if args.storeAs then h.store(args, rows) end if storeFn then local storeCategories = h.customStore(args, storeFn, rows) if storeCategories then categories = categories..storeCategories end end return categories end function h.store(args, rows) local frame = mw.getCurrentFrame local columns = utilsTable.map(args.columns, mw.text.killMarkers) columns = table.concat(columns, ", ") frame:expandTemplate({		title = "Data Table/Store",		args = {			game = args.game,			tableName = args.storeAs,			columns = columns		}	})

for i, row in ipairs(rows) do		for j, cell in ipairs(row.cells) do			frame:expandTemplate({				title = "Data Table/Store",				args = {					game = args.game,					tableName = args.storeAs,					rowIndex = i,					columnIndex = j,					cell = mw.text.unstrip(cell),				}			}) end end end function h.customStore(args, storeFn, rows) local tableData = {} for i, row in ipairs(rows) do		local rowData = {} for j, cell in ipairs(row.cells) do			local columnName = args.columnNames[j] rowData[columnName] = cell end table.insert(tableData, rowData) end return storeFn(args, tableData) end

function h.formatTable(rows, args) local categories = "" local columnHeaders, columnTags = h.extractColumnTags(args.columns) local unsortableColumns = {} local width = 0 for rowIndex, row in ipairs(rows) do		row.width = 0 for columnIndex, cell in ipairs(row.cells) do			if type(cell) == "string" then cell = { raw = cell }			end cell.class = "data-table__cell" cell.content = mw.html.create("div"):addClass("data-table__cell-content") cell.unsortable = false

local tags = columnTags[columnIndex] or {} local modifierTags, textTags = utilsTable.partition(tags, function(tag)				return utilsTable.includes({"ID", "Nowrap", "Rowspan", "Unsortable", "Width"}, tag.name)			end) for _, tag in ipairs(textTags) do				h.formatCell(cell, tag, args.game) end if #textTags == 0 then h.formatCell(cell, {}, args.game) end for _, tag in ipairs(modifierTags) do				h.modifyCell(cell, tag, rowIndex == #rows) end cell.content = tostring(cell.content) cell.columnHeader = columnHeaders[columnIndex] row.cells[columnIndex] = cell

if cell.unsortable then unsortableColumns[columnIndex] = true end if cell.rowId then row.id = cell.rowId end row.width = row.width + (cell.size or 0) end width = math.max(width, row.width) end local sortableColumns = {} for i in ipairs(columnHeaders) do		if not unsortableColumns[i] then table.insert(sortableColumns, i)		end end local sortable = args.sortable ~= false and #rows > 3 and sortableColumns local tableArgs = { game = args.game, storeAs = args.storeAs, headers = columnHeaders, rows = rows, caption = args.caption, sortable = sortable, stretch = args.stretch and args.stretch ~= "false", isSmall = width <= SMALL_WIDTH_THRESHOLD, }	return tableArgs end

function h.formatCell(cell, tag, game) local baseClass = "data-table__cell-content" local textNode = mw.html.create("div"):addClass("data-table__cell-content-text") local frame = mw.getCurrentFrame local cellSize = 1000 -- assume by default the cell is large to be on the safe side - better to overestimate than underestimate if cell.raw == "" then cellSize = 0 cell.content:addClass(baseClass.."--empty") elseif string.find(cell.raw, "^N/A") then cellSize = cell.raw:len local text = cell.raw local tooltip = utilsMarkup.tooltip("—", "Not Applicable") text = text:gsub("N/A", tooltip) textNode:wikitext(text) cell.content :addClass(baseClass.."--not-applicable") :node(textNode) elseif tag.name == "Amount" or tag.name == "Amounts" then cellSize = 0 cell.unsortable = true local amounts = utilsString.split(cell.raw, '%s*,[%D+|%s*]') for i, amount in ipairs(amounts) do			local item = string.gsub(amount, "$[%d,]+ ", "") local subject, info = TermList.separateAdditionalInfo(item) cellSize = math.max(cellSize, subject:len) end local text = frame:expandTemplate({			title = "Amounts",			args = {game, cell.raw}		}) textNode:wikitext(text) cell.content :addClass(baseClass.."--amounts") :node(textNode) elseif tag.name == "Description" then local text = string.gsub(cell.raw, "%[Player Name%]", h.playerName) text = "\n"..text.."\n" -- bullet lists don't work without the newlines cell.unsortable = true textNode:wikitext(text) cell.content :addClass(baseClass.."--description") :node(textNode) elseif tag.name == "HeartAmount" then cellSize = 12 cell.sortValue = cell.raw local heartAmount = frame:expandTemplate({			title = "HeartAmount",			args = {cell.raw, "true"}		}) textNode:wikitext(heartAmount) cell.content :addClass(baseClass.."--heart-amount") :node(textNode) elseif tag.name == "IconList" then cellSize = 0 local listItems = utilsString.split(cell.raw) for i, listItem in ipairs(listItems) do			cellSize = math.max(listItem:len, cellSize) end local iconList = frame:expandTemplate({			title = "Icon List",			args = {game, cell.raw}		}) textNode:wikitext(iconList) cell.content :addClass(baseClass.."--icon-list") :node(textNode) elseif tag.name == "Image" then cell.unsortable = true cellSize = 20 local text = string.gsub(cell.raw, "%[Player Name%]", "Link") local entries = GalleryList.parseEntries(nil, game, {text}, {			useTerms = false,			fileType = tag.args[1] or Franchise.graphics(game) == "2D" and "Sprite" or "Icon",		}) local image = File.image(entries[1].file, {			size = tag.args[2] or "64x64px",		}) textNode:wikitext(image) cell.content :addClass(baseClass.."--image") :node(textNode) elseif tag.name == "Name" then cellSize = cell.raw:len cell.unsortable = false local text = string.gsub(cell.raw, "%[Player Name%]", h.playerName) text = GalleryList.stripVariantSyntax(text) textNode:wikitext(text) cell.content :addClass(baseClass.."--name") :node(textNode) elseif tag.name == "List" then local listItems = utilsString.split(cell.raw, '%s*,[%D+|%s*]') cellSize = 0 for i, listItem in ipairs(listItems) do			cellSize = math.max(cellSize, listItem:len) end local list = h.list(listItems) textNode:wikitext(list) cell.content :addClass(baseClass.."--list") :node(textNode) elseif utilsTable.includes({"Defense", "Effect", "Mon", "Rupees"}, tag.name) then cellSize = cell.raw:len local amounts = utilsString.split(cell.raw, '%s*,[%D+|%s*]') if #amounts == 1 then cell.sortValue = amounts[1]:gsub(",", "") end local amountGame = game or tag.args[1] local format = tag.args[2] amounts = utilsTable.map(amounts, function(amount)			local amount, info = TermList.separateAdditionalInfo(amount)			if utilsTable.includes({"Mon", "Effect"}, tag.name) then				return frame:expandTemplate({ title = tag.name, args = {amount, format} })..info			else				local templateName = tag.name == "Rupees" and "Rupee" or tag.name				return frame:expandTemplate({ title = templateName, args = {amountGame, amount, format} })..info			end		end) if #amounts > 1 then cell.unsortable = true end local amountsList = h.list(amounts) textNode:wikitext(amountsList) cell.content :addClass(baseClass.."--amounts") :addClass(baseClass.."--"..utilsString.kebabCase(tag.name)) :node(textNode) elseif tag.name == "SortValue" then cellSize = cell.raw:len local text = string.gsub(cell.raw, "%[Player Name%]", "Link") cell.unsortable = false cell.sortValue = Sequences.sortValue(game, nil, text) local termLink = Term.link(text, game) textNode:wikitext(termLink) cell.content :addClass(baseClass.."--terms") :node(textNode) elseif utilsTable.includes({"Term", "Terms", "Plural", "TermList", "PluralList"}, tag.name) then local text = string.gsub(cell.raw, "%[Player Name%]", "Link") local pages if tag.name == "Term" or tag.name == "Plural" then pages = {text} else pages = utilsString.split(text) end pages = utilsTable.map(pages, GalleryList.stripVariantSyntax) local termLinks, terms = TermList.termList(tag.args[1] or game, pages, {			plural = tag.name == "Plural",			link = true,		}) cellSize = 0 for i in ipairs(termLinks) do			local term = terms[i] cellSize = math.max(term:len, cellSize) if term == "N/A" then local page, info = TermList.separateAdditionalInfo(pages[i]) termLinks[i] = utilsMarkup.tooltip("—", "Not Applicable")..info cell.content:addClass(baseClass.."--not-applicable") end end local isBulletList = tag.name == "TermList" or tag.name == "PluralList" local list = h.list(termLinks, isBulletList) cell.unsortable = #termLinks > 1 textNode:wikitext(list) cell.content :node(textNode) if isBulletList then cell.content:addClass(baseClass.."--term-list") else cell.content:addClass(baseClass.."--terms") end elseif tag.name then cellSize = cell.raw:len local text = string.gsub(cell.raw, "%[Player Name%]", h.playerName) text = GalleryList.stripVariantSyntax(text) h.warn(string.format("Unrecognized tag ", tag.name)) textNode:wikitext(text.."") cell.content:node(textNode) else cellSize = cell.raw:len local text = string.gsub(cell.raw, "%[Player Name%]", h.playerName) textNode:wikitext(text) cell.content:node(textNode) end cell.size = math.max(cell.size or 0, cellSize) return cell end

function h.modifyCell(cell, tag, isLastRow) if tag.name == "ID" then local id = string.gsub(cell.raw, "%[Player Name%]", "Link") cell.rowId = id	elseif tag.name == "Nowrap" then cell.content:addClass("data-table__cell-content--nowrap") elseif tag.name == "Rowspan" then h.computeRowspan(cell, isLastRow) elseif tag.name == "Unsortable" then cell.unsortable = true elseif tag.name == "Width" then cell.styles = cell.styles or {} cell.styles["width"] = tag.args[1] end end

local spanningCell local spanCount function h.computeRowspan(cell, isLastRow) if not spanningCell then spanningCell = cell spanCount = 1 elseif tostring(cell.content) == tostring(spanningCell.content) then cell.skip = true spanCount = spanCount + 1 else spanningCell.rowspan = spanCount spanningCell = cell spanCount = 1 end if isLastRow then spanningCell.rowspan = spanCount end end

local playerName function h.playerName return playerName or mw.getCurrentFrame:expandTemplate({		title = "Player Name"	}) end

function h.list(items, bulletList) if bulletList then return utilsMarkup.bulletList(items) elseif #items > 1 then return utilsMarkup.list(items) else return items[1] end end

-- With wide tables we have no choice but to show something completely different on mobile -- However, we don't need to do this for tables which we know are small enough to fit on mobile screens function h.desktopTable(args, responsiveModeEnabled) -- Exclude cells which are rowspanned over local tableArgs = mw.clone(args) for i, row in ipairs(tableArgs.rows) do		local cells = {} for j, cell in ipairs(row.cells) do			if not cell.skip then table.insert(cells, cell) end end tableArgs.rows[i].cells = cells end local wikitable = utilsLayout.table(tableArgs) local html = mw.html.create("div") :addClass("data-table data-table--desktop") :addClass(responsiveModeEnabled and "size-large-up nomobile" or nil) --nomobile prevents the desktop table HTML from being loaded on the mobile skin, saving data and load time :wikitext(wikitable) return tostring(html) end function h.mobileTable(args, responsiveModeEnabled) if not responsiveModeEnabled then return "" end local html = mw.html.create("div") :addClass("data-table data-table--mobile size-medium-down") :tag("div") :addClass("data-table__list") for i, row in ipairs(args.rows) do		local item = html:tag("table"):addClass("wikitable data-table__list-item") for j, cell in ipairs(row.cells) do			if cell.raw ~= "" then local header = args.headers[j] item:tag("tr") :tag("th") :addClass("data-table__row-header") :wikitext(header) :done :tag("td") :addClass(cell.class) :css(cell.styles or {}) :wikitext(cell.content) :done end end end return tostring(html:allDone) end

-- In addition to Data Table styles, we need to include the styles of all templates that are used in the Data Table -- because templatestyles can't be stored in Cargo function h.addStyles local stylesheets = { "Template:Data Table/Styles.css", "Template:Exp/Styles.css", "Template:Icon List/Styles.css", "Module:Value/Styles.css", }	local styles = "" for i, stylesheet in ipairs(stylesheets) do		styles = styles..mw.getCurrentFrame:extensionTag({			name = "templatestyles",			args = { src = stylesheet },		}) end return styles end

p.Templates = { ["Data Table/Store"] = { purpose = "Stores data into the DataTables Cargo table, for use by .", storesData = true, usage = "This template is transcluded by Module:Data Table.", },	["Data Table"] = { format = "block", purpose = " Displays tabular data in a way that is mobile-friendly. Minimizes the amount of boilerplate wikitext that most tables require. Automatically center-aligns columns and applies templates such as, , , etc.  The table data can stored in Cargo so that the table (or a subset of its rows) can be displayed on other relevant pages using .", boilerplate = { separateRequiredParams = false, },		paramOrder = {"game", "storeAs", "caption", "sortable", "stretch", "columns", "..."}, params = { game = { type = "string", suggested = true, enum = Franchise.enum, desc = "A game code. Used to automatically apply game-based templates (e.g., , ) to table cells.", },			storeAs = { type = "string", desc = " If present, the data is stored in the DataTables Cargo table under the given name. Other pages can retrieve the data by that name using . When a page has multiple data tables, each   value must be unique. ", trim = true, nilIfEmpty = true, },			caption = { type = "string", desc = "A table caption.", trim = true, nilIfEmpty = true, },			sortable = { type = "boolean", desc = "If set to, the data table will not be sortable.", trim = true, },			stretch = { type = "boolean", desc = "If present and set to anything other than, the data table will stretch to the full width of the page.", },			columns = { type = "content", required = true, desc = "Comma-separated list of column headers. One or more tag can be appended to each column to indicate how the template should handle data in that column. See below.", trim = true, nilIfEmpty= true, split = true, },			["..."] = {				name = "cells", placeholder = "cell", required = true, type = "content", desc = "Cell values. Type  to separate rows, as shown in the examples below.", trim = true, }		}	},	["Data Table Copy"] = { purpose = "Creates a copy of a located on another page.", format = "block", paramOrder = {"fromPage", "storedAs", "columns", "excludeColumns", "rowsWith", "rowsExcluding"}, params = { fromPage = { required = true, type = "wiki-page-name", desc = "The name of the wiki page containing the data table to be copied.", trim = true, nilIfEmpty = true, },			storedAs = { required = true, type = "string", desc = "The internal name of the table to be copied, as specified by the  parameter of Template:Data Table.", trim = true, nilIfEmpty = true, },			columns = { type = "string", desc = "A comma-separated list of columns to copy. If absent, all columns are copied.", trim = true, nilIfEmpty = true, split = true, },			excludeColumns = { type = "string", desc = "A comma-separated list of columns not to copy. If absent, all columns are copied. Ignored if  is present.", trim = true, nilIfEmpty = true, split = true, },			rowsWith = { type = "string", desc = "If specified, only rows containing the given string will be copied. If absent, all rows are copied.", trim = true, nilIfEmpty = true, },			rowsExcluding = { type = "string", desc = "If specified, only rows which do not contain the given string will be copied.", trim = true, nilIfEmpty = true, },		},	}, }

return p