Module:Data Table

local p = {} local h = {}

local File = require("Module:File") local Franchise = require("Module:Franchise") 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"

-- 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 columnHeaders, columnTags = h.extractColumnTags(args.columns) local name = args.storeAs local unsortableColumns = {} local dataRows = {} local currentRow = { cells = {}, width = 0, }	local width = 0 local columnIndex = 1 local rowIndex = 1 for i, cellText in ipairs(args.cells or {}) do		local rowWidth = 0 local isDivider = cellText == "-" or cellText == "--" if isDivider and i ~= 1 then table.insert(dataRows, currentRow) width = math.max(currentRow.width, width) currentRow = { cells = {}, width = 0 }			rowIndex = rowIndex + 1 columnIndex = 1 elseif not isDivider then local cell = { class = "data-table__cell", content = mw.html.create("div"):addClass("data-table__cell-content"), raw = cellText, 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] table.insert(currentRow.cells, cell)

if cell.unsortable then unsortableColumns[columnIndex] = true end if cell.rowId then currentRow.id = cell.rowId end columnIndex = columnIndex + 1 currentRow.width = currentRow.width + (cell.size or 0) end end if #currentRow.cells > 0 then rowIndex = rowIndex + 1 table.insert(dataRows, currentRow) width = math.max(currentRow.width, 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 #dataRows > 1 and sortableColumns local tableArgs = { game = args.game, storeAs = args.storeAs, headers = columnHeaders, rows = dataRows, caption = args.caption, sortable = sortable, stretch = args.stretch and args.stretch ~= "false", isSmall = width <= SMALL_WIDTH_THRESHOLD, }

if args.storeAs then h.store(tableArgs) end if storeFn then local storeCategories = storeFn(tableArgs) if storeCategories then categories = categories..storeCategories end end 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

local dataTable = p.printTable(tableArgs) 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 h.warn(string.format("No table  is stored on page %s", args.storedAs, args.fromPage)) return "", 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 then dataRows = utilsTable.filter(dataRows, function(row)			local includeRow = false			for i, cell in ipairs(row.cells) do				if string.find(cell.raw, args.rowsWith) then					includeRow = true				end			end			return includeRow		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 hasInvalidColumns = false local columnsToCopy if args.columns or args.excludeColumns then if args.columns then columnsToCopy = {} else columnsToCopy = utilsTable.invert(columnHeaders) end local validColumnsList = utilsMarkup.bulletList(columnHeaders) for i, column in ipairs(args.columns 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 args.columns then columnsToCopy[column] = true 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) 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.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 desktopTable = h.desktopTable(args) local mobileTable = h.mobileTable(args) return styles..desktopTable..mobileTable 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", "Template:Item Amount/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

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

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

local NUMBER_VARIANT_REGEX = "%s*%[([%d]+)%]%s*$" 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*]') -- %D ensures that we don't split large numbers (e.g. 1,500) amounts = utilsTable.map(amounts, function(itemAmount)			local amountStart, amountEnd, amount = string.find(itemAmount, "^(%d+)%s")			if not amount then				h.warn(string.format("Invalid entry . Amount columns must start with a number.", itemAmount))				textNode:wikitext("")				return itemAmount			else				local item = string.sub(itemAmount, amountEnd+1)				local subject, info = TermList.separateAdditionalInfo(item)				local amount = frame:expandTemplate({ title = "Item Amount", args = {game, amount, subject} })				cellSize = math.max(cellSize, subject:len + info:len)				return amount..info			end		end) textNode:wikitext(h.list(amounts)) 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 fileType = tag.args[1] or Franchise.graphics(game) == "2D" and "Sprite" or "Icon" local text = string.gsub(cell.raw, "%[Player Name%]", "Link") local number = string.match(text, NUMBER_VARIANT_REGEX) if number then text = string.gsub(text, NUMBER_VARIANT_REGEX, "") fileType = fileType.." "..number end local subject = TermList.separateAdditionalInfo(text) cell.sortValue = subject local image = File.gameImage(game, subject, fileType, {			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)			if utilsTable.includes({"Mon", "Effect"}, tag.name) then				return frame:expandTemplate({ title = tag.name, args = {amount, format} })			else				local templateName = tag.name == "Rupees" and "Rupee" or tag.name				return frame:expandTemplate({ title = templateName, args = {amountGame, amount, format} })			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, NUMBER_VARIANT_REGEX, "") 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 tag.name == "Term" or tag.name == "Terms" then local text = string.gsub(cell.raw, "%[Player Name%]", "Link") text = string.gsub(text, NUMBER_VARIANT_REGEX, "") local pages = utilsString.split(text) local termLinks, rawTerms = TermList.termList(tag.args[1] or game, pages, { link = true }) 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 list = h.list(termLinks) if #termLinks > 1 then cell.unsortable = true else cell.size = rawTerms[1]:len end textNode:wikitext(list) cell.content :addClass(baseClass.."--terms") :node(textNode) 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

function h.list(items) return #items > 1 and utilsMarkup.list(items) or items[1] end

local frame = mw.getCurrentFrame function h.store(args) 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) -- 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(not tableArgs.isSmall and "size-large-up" or nil) :wikitext(wikitable) return tostring(html) end function h.mobileTable(args) if args.isSmall 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

function h.warn(msg) local utilsError = require("Module:UtilsError") utilsError.warn(msg) 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"}, 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, },		},	}, }

return p