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, }

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, extensionArgs) extensionArgs = extensionArgs or {} local args, err = utilsArg.parse(frame:getParent.args, p.Templates["Data Table"]) local categories = err and err.categoryText or "" local rows = h.parseRows(args.cells) local tableArgs = p.formatTable(rows, args) local storeCategories = h.storeTable(args.storeAs, storeFn, tableArgs) local dataTable = p.printTable(tableArgs) categories = categories..storeCategories if extensionArgs.requiredColumns then local missingColumns = utilsTable.difference(extensionArgs.requiredColumns, tableArgs.headers) 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 errCategories = err and err.categoryText or "" if not args.fromPage or not args.storedAs then return "", errCategories end local dataTable, categories = p.copy(args) return dataTable, categories..errCategories 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

function p.copy(args, filterFn) local categories = ""

local queryResults = utilsCargo.query("DataTables", "name, rowIndex, columnIndex, columnHeader, columnSortable, cellContent, cellSortValue, cellRaw, cellRowspan", {		where = utilsCargo.allOf({ _pageName = args.fromPage, name = args.storedAs, game = args.game, }),		orderBy= "game, name, rowIndex, columnIndex",		limit= 5000	}) if #queryResults == 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 dataRows = {} local currentRow = {} for i, result in ipairs(queryResults) do		if i ~= 1 and result.columnIndex == "1" then table.insert(dataRows, currentRow) currentRow = {} end table.insert(currentRow, result) end table.insert(dataRows, currentRow)

dataRows = utilsTable.map(dataRows, function(row)		return {			cells = utilsTable.map(row, function(cell) return { columnHeader = cell.columnHeader, columnSortable = cell.columnSortable, content = cell.cellContent, sortValue = cell.cellSortValue, raw = cell.cellRaw, rowspan = cell.rowspan, }			end)		}	end) if args.rowsWith or args.rowsExcluding then dataRows = utilsTable.filter(dataRows, 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.raw, args.rowsWith) then					includeRow = true				end				if args.rowsExcluding and string.find(cell.raw, args.rowsExcluding) then					excludeRow = true				end			end			return includeRow and not excludeRow		end) if #dataRows == 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 err = h.err("Data not found", string.format("No rows matching criteria %s were found in table  on page %s", critera, args.storedAs, args.fromPage)) return err, categories.."" end end if filterFn then dataRows = utilsTable.filter(dataRows, filterFn) end local columns = utilsTable.uniqueBy(dataRows[1] and dataRows[1].cells or {}, function(cell)		return cell.columnHeader	end) local columnHeaders = {} local columnLookup = {} for i, column in ipairs(columns) do		columnHeaders[i] = column.columnHeader columnLookup[column.columnHeader] =column end local includedColumns, columnTags = h.extractColumnTags(args.columns or {}) if #includedColumns == 0 then includedColumns = nil end local hasInvalidColumns = false local columnsToCopy if includedColumns or args.excludeColumns then if includedColumns then columnsToCopy = {} else columnsToCopy = utilsTable.invert(columnHeaders) end local validColumnsList = utilsMarkup.bulletList(columnHeaders) for i, column in ipairs(includedColumns or args.excludeColumns) do			column = mw.text.killMarkers(column) if not columnLookup[column] then hasInvalidColumns = true 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, validColumnsList)) categories = categories.."" elseif includedColumns then columnsToCopy[column] = true columnLookup[column].tags = columnTags[i] elseif args.excludeColumns then columnsToCopy[column] = false end end end

if columnsToCopy and not hasInvalidColumns then for i, row in ipairs(dataRows) do			local cells = {} for j, cell in ipairs(row.cells) do				if columnsToCopy[cell.columnHeader] then table.insert(cells, cell) end end row.cells = cells end columnHeaders = utilsTable.filter(columnHeaders, function(header)			return columnsToCopy[header]		end) columnHeaders = utilsTable.map(columnHeaders, function(header)			local tags = columnLookup[header] and columnLookup[header].tags or {}			if tags[1] and tags[1].name == "As" then				return tags[1].args[1]			else				return header			end		end) end local sortableColumns = {} for i, column in ipairs(columnHeaders) do		if not columnLookup[column] or columnLookup[column].columnSortable == "1" then table.insert(sortableColumns, i)		end end local dataTable = p.printTable({		headers = columnHeaders, 		rows = dataRows, 		sortable = sortableColumns,		stretch = args.stretch and args.stretch ~= "false"	}) return dataTable, categories end

function p.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) end cell.content = tostring(cell.content) cell.storedContent = cell.storedContent and tostring(cell.storedContent) or cell.content cell.storedContent = mw.text.unstrip(cell.storedContent) 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 p.printTable(args) -- We re-apply the data-table__cell class as other modules may re-use this function but not add that class for i, row in ipairs(args.rows) do		for j, cell in ipairs(row.cells) do local currentClass = cell.class and (cell.class.." ") or "" cell.class = currentClass.."data-table__cell" end end local styles = h.addStyles local responsiveModeEnabled = not args.isSmall and not RESPONSIVE_MODE_DENYLIST[mw.title.getCurrentTitle.fullText] local desktopTable = h.desktopTable(args, responsiveModeEnabled) local mobileTable = h.mobileTable(args, responsiveModeEnabled) return styles..desktopTable..mobileTable 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.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 cellSize = 20 local text = string.gsub(cell.raw, "%[Player Name%]", "Link") local entries, subjects = GalleryList.parseEntries(nil, game, {text}, {			useTerms = false,			fileType = tag.args[1] or Franchise.graphics(game) == "2D" and "Sprite" or "Icon",		}) cell.sortValue = subjects[1] 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 == "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 == "SectionLink" then cellSize = cell.raw:len cell.rowId = string.gsub(cell.raw, "%[Player Name%]", "Link") local text = string.gsub(cell.raw, "%[Player Name%]", h.playerName) text = string.gsub(text, "%s*%[([%s:%.%w]+)]$", "") -- strip variant regex; see Module:Gallery List text = mw.text.killMarkers(text) cell.rowId = string.gsub(cell.rowId, "[%[%]]", "") local page = mw.title.getCurrentTitle.fullText local link = string.format("%s", page, cell.rowId, text) -- We show the link on table copies but only text on the original page textNode:wikitext(text) cell.storedContent = mw.clone(cell.content) :addClass(baseClass.."--section-link") :tag("div") :addClass("data-table__cell-content-text") :wikitext(link) :done cell.content :addClass(baseClass.."--section-link") :node(textNode) elseif tag.name == "SortValue" then cellSize = cell.raw:len local text = string.gsub(cell.raw, "%[Player Name%]", "Link") 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 = utilsString.split(text) local entries, rawTerms = GalleryList.parseEntries(nil, tag.args[1] or game, pages, {			plural = tag.name == "Plural"		}) local termLinks = utilsTable.map(entries, "link") cellSize = 0 for i in ipairs(termLinks) do			cellSize = math.max(rawTerms[i]:len, cellSize) if rawTerms[i] == "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) if #termLinks > 1 then cell.unsortable = true else cell.size = rawTerms[1]:len end 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) 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, rowIndex) 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 local rowspan = tonumber(tag.args[1]) local isSpanningRow = rowIndex % rowspan == 1 if isSpanningRow then cell.rowspan = rowspan else cell.skip = true end 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 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

function h.storeTable(storeAs, storeFn, tableArgs) local categories = "" if storeAs then h.store(tableArgs) end if storeFn then local storeCategories = storeFn(tableArgs) if storeCategories then categories = categories..storeCategories end end return categories end function h.store(args) local frame = mw.getCurrentFrame for i, row in ipairs(args.rows) do		for j, cell in ipairs(row.cells) do			local columnSortable = type(args.sortable) == "table" and utilsTable.includes(args.sortable, j) and "1" or "0" frame:expandTemplate({				title = "Data Table/Store",				args = {					game = args.game,					name = args.storeAs,					rowIndex = i,					columnIndex = j,					columnHeader = mw.text.killMarkers(cell.columnHeader),					columnSortable = columnSortable,					cellContent= cell.storedContent,					cellRaw = cell.raw,					cellRowspan = cell.rowspan,					cellSortValue = cell.sortValue,				}			}) end 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