Module:Data Table

local p = {} local h = {} local Tags = require("Module:Data Table/Tags") -- edit this page to add new tags

local Franchise = require("Module:Franchise") local TransclusionArguments = require("Module:Transclusion Arguments") 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 = p.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 "" categories = categories..""

if not args.fromPage or not args.storedAs then return "", categories end

local dataTable, copyCategories = h.copy(args) TransclusionArguments.store({		module = "Module:Data Table",		isValid = copyCategories == "",		args = 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

function p.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 p.printTable(rows, args, postFormatHook) local tableArgs, categories = h.formatTable(rows, args) categories = categories.."" if postFormatHook then tableArgs = postFormatHook(tableArgs) end local title = mw.title.getCurrentTitle local isMainspace = title.nsText == "" or utilsTable.includes({"Data Table", "Data Table Copy", "Wares"}, title.rootText) -- These templates are treated as mainspace for testing purposes

local styles = h.styles({ "Template:Data Table/Styles.css" }) local incompleteNotice = isMainspace and h.incomplete(tableArgs) or "" local tableNotes = mw.getCurrentFrame:expandTemplate({ title = "Table Notes" })

-- We sometimes use Data Tables for documenting Category:Module Data -- Since doc pages are editor-facing and not user-facing, speed is more important than mobile-friendliness local responsiveModeEnabled = not tableArgs.isSmall and isMainspace and not RESPONSIVE_MODE_DENYLIST[title.fullText]

local desktopTable = h.desktopTable(tableArgs, responsiveModeEnabled) local mobileTable = h.mobileTable(tableArgs, responsiveModeEnabled) return styles..incompleteNotice..desktopTable..mobileTable..tableNotes..categories end

function h.extractColumnTags(columns) local columnHeaders = {} local columnTagsByColumn = {} local colspans for i, column in ipairs(columns) do		local columnTags = {} local matches repeat column, matches = string.gsub(column, "%s*%[([^%]]+)%]$", function(tag)				tag = utilsString.split(tag, ":")				if tag[1] == "Colspan" then					colspans = colspans or {}					colspans[i] = {						colspan = tonumber(tag[2]),					}				else					table.insert(columnTags, { name = tag[1], args = utilsTable.tail(tag), })				end				return ""			end) until matches == 0 columnTags = utilsTable.reverse(columnTags) if not (colspans and colspans[i]) then table.insert(columnTagsByColumn, columnTags) table.insert(columnHeaders, column) elseif colspans then colspans[i].header = column end end return columnHeaders, columnTagsByColumn, colspans 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 if args.storeAs or storeFn then categories = categories..utilsCargo.categoryStoring end return categories end function h.store(args, rows) local frame = mw.getCurrentFrame local columns = utilsTable.map(args.columns, function(column) 		column = mw.text.killMarkers(column)		 -- remove tags that shouldn't be applied to Data Table Copies		for tagName, tag in ipairs(Tags.attributeTags) do			if tag.noCopy then				column = string.gsub(column, "%["..tagName.."[^%]]+]", "")			end		end		return column	end) 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.copy(args, filterFn) local columnsQuery, categories = 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 headers = utilsString.split(columnsQuery[1].columns) headers = utilsTable.map(headers, mw.text.killMarkers) -- Here we use "columns" to refer to actual data columns (excluding colspans) -- We use "headers" to refer to any table header (including colspans) local columnNames, tags, colspans = h.extractColumnTags(headers) local headerNames = utilsTable.clone(columnNames) for k, colspan in pairs(colspans or {}) do		table.insert(headerNames, k, colspan.header) end local columnLookup = utilsTable.invert(columnNames) local headerLookup = utilsTable.invert(headerNames) local includedColumnIndices = {} local includedHeaderIndices = {} if not args.columns then includedColumnIndices = utilsTable.keys(columnNames) includedHeaderIndices = utilsTable.keys(headerNames) end local copyHeaderNames, copyHeaderTags = h.extractColumnTags(args.columns or args.excludeColumns or {}) for i, copyHeader in ipairs(copyHeaderNames) do		copyHeader = mw.text.killMarkers(copyHeader) local columnIndex = columnLookup[copyHeader] local headerIndex = headerLookup[copyHeader] if not headerIndex 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", copyHeader, args.storedAs, args.fromPage, utilsMarkup.bulletList(headerNames))) categories = categories.."" includedColumnIndices = utilsTable.keys(columnNames) includedHeaderIndices = utilsTable.keys(headerNames) break elseif args.columns then table.insert(includedHeaderIndices, headerIndex) if columnIndex then table.insert(includedColumnIndices, columnIndex) end elseif args.excludeColumns then includedHeaderIndices[headerIndex] = nil if columnIndex then includedColumnIndices[columnIndex] = nil end end end includedColumnIndices = utilsTable.compact(includedColumnIndices) includedHeaderIndices = utilsTable.compact(includedHeaderIndices)

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 includedHeaders = {} for i, headerIndex in ipairs(includedHeaderIndices) do		local header = headers[headerIndex] local copyTags = copyHeaderTags[i] local headerAlias = copyTags and copyTags[1] and copyTags[1].name == "As" and copyTags[1].args[1] if headerAlias then local headerName = headerNames[headerIndex] header = string.gsub(header, "^"..headerName, headerAlias) end table.insert(includedHeaders, header) 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 = includedHeaders,	}) return dataTable, categories end

function h.formatTable(rows, args) local categories = "" local columnHeaders, columnTags, colspans = h.extractColumnTags(args.columns) -- Data rows local unsortableColumns = {} local hasEmptyCells = false local width = 0 for rowIndex, row in ipairs(rows) do		row.width = 0 row.cells = row.cells or row for columnIndex, cellText in ipairs(row.cells) do			local tags = columnTags[columnIndex] or {} local isLastRow = #rows == rowIndex -- needed for Rowspan tag local cell, errCategories = h.formatCell(cellText, args, tags, isLastRow, columnIndex) categories = categories..errCategories cell.content = tostring(cell.content) cell.columnHeader = columnHeaders[columnIndex] row.cells[columnIndex] = cell

if cell.sortValue == false then unsortableColumns[columnIndex] = true end if cell.rowId then row.id = cell.rowId end if cell.isEmpty then hasEmptyCells = true end row.width = row.width + (cell.size or 0) end width = math.max(width, row.width) end -- Header rows local mainHeaders = {} local subHeaders = {} local i = 1 while i <= #columnHeaders do		local colspan = colspans and colspans[i] if colspan then table.insert(mainHeaders, { 				content = colspan.header,				colspan = colspan.colspan,				unsortable = true,			}) for j = 0, (colspan.colspan - 1) do				table.insert(subHeaders, {					content = columnHeaders[i],					unsortable = unsortableColumns[i],				}) i = i + 1 end else table.insert(mainHeaders, {				content = columnHeaders[i],				rowspan = colspans and 2 or nil,				unsortable = unsortableColumns[i],			}) i = i + 1 end end local tableArgs = { game = args.game, storeAs = args.storeAs, mainHeaders = mainHeaders, subHeaders = subHeaders, rows = rows, caption = args.caption, sortable = args.sortable ~= false and #rows > 3, stretch = args.stretch and args.stretch ~= "false", isSmall = width <= SMALL_WIDTH_THRESHOLD, hasEmptyCells = hasEmptyCells, isMissingRows = #rows == 0, }	return tableArgs, categories end function h.formatCell(cellText, args, tags, isLastRow, columnIndex) local cell = { raw = cellText, class = "data-table__cell", content = mw.html.create("div"), isLastRow = isLastRow, columnIndex = columnIndex, }	if type(cellText) == "table" then -- allows modules like Module:Wares to pass in cells as objects for things like custom sort values cell = utilsTable.merge(cell, cellText) cell.raw = cellText.content cell.content = mw.html.create("div") end local categories = ""

local contentTags = utilsTable.filter(tags, function(tag)		return Tags.contentTags[tag.name]	end) local attributeTags = utilsTable.filter(tags, function(tag)		return Tags.attributeTags[tag.name]	end) local unrecognizedTags = utilsTable.filter(tags, function(tag)		return not Tags.contentTags[tag.name] and not Tags.attributeTags[tag.name]	end)

for _, tag in ipairs(contentTags) do		h.applyContentTag(cell, tag, args) end if #contentTags == 0 then h.applyContentTag(cell, {}, args) end

for _, tag in ipairs(attributeTags) do		cell.rowIndex = rowIndex h.applyAttributeTag(cell, tag, args) end for i, tag in ipairs(unrecognizedTags) do		h.warn(string.format("Unrecognized tag ", tag.name)) categories = categories.."" end

return cell, categories end function h.applyContentTag(cell, tag, args) local tagName = tag.name or "" if cell.raw == "" then tagName = "EmptyCell" cell.isEmpty = true elseif string.find(cell.raw, "^N/A") then tagName = "NotApplicable" end local formatter = Tags.contentTags[tagName] and Tags.contentTags[tagName].formatter if type(formatter) ~= "function" then return end local cellData = formatter(cell.raw, args, tag.args, args) cell.content :addClass("data-table__cell-content") :tag("div") :addClass("data-table__cell-content-text") :wikitext(cellData.text) :done if tagName ~= "" then cell.content:addClass("data-table__cell-content--"..utilsString.kebabCase(tagName)) end if cell.raw == "" then cell.content:addClass("data-table__cell-content--empty") end if cellData.sortValue ~= nil then cell.sortValue = cellData.sortValue end cell.size = math.max(cell.size or 0, cellData.size or 1000) -- assume by default the cell is large to be on the safe side - better to overestimate than underestimate end function h.applyAttributeTag(cell, tag, args) local formatter = Tags.attributeTags[tag.name] and Tags.attributeTags[tag.name].formatter if type(formatter) ~= "function" then return end formatter(cell, args, tag.args) 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 table.insert(tableArgs.rows, 1, { cells = args.mainHeaders, header = true}) if #args.subHeaders > 0 then table.insert(tableArgs.rows, 2, { cells = args.subHeaders, header = true}) 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("table") :addClass("wikitable") for i, row in ipairs(args.rows) do		local subHeaderIndex = 1 if i ~= 1 then html:tag("tr") :addClass("data-table__separator") end local hasSubheaders = #args.subHeaders > 0 local subHeaderIndex = 1 local columnIndex = 1 for mainHeaderIndex = 1, #args.mainHeaders do			local header = args.mainHeaders[mainHeaderIndex] or {} local colspan = header.colspan == nil and hasSubheaders and 2 or nil local tableRow = html:tag("tr") :tag("th") :addClass("data-table__row-header") :attr("rowspan", header.colspan) :attr("colspan", colspan) :wikitext(header.content) :done if header.colspan then for i = 1, header.colspan do					if i ~= 1 then tableRow = tableRow:tag("tr") end local cell = row.cells[columnIndex] or {} local subheader = args.subHeaders[subHeaderIndex] or {} tableRow:tag("th") :addClass("data-table__row-header") :wikitext(subheader.content) :done :tag("td") :addClass(cell.class) :css(cell.styles or {}) :wikitext(cell.content) :done subHeaderIndex = subHeaderIndex + 1 columnIndex = columnIndex + 1 end else local cell = row.cells[columnIndex] or {} tableRow:tag("td") :addClass(cell.class) :css(cell.styles or {}) :wikitext(cell.content) :done columnIndex = columnIndex + 1 end end end return tostring(html:allDone) end

function h.incomplete(args) local addMissingRows = args.isMissingRows and "add the missing rows" or nil local fillEmptyCells = args.hasEmptyCells and "fill in the empty cells" or nil local addDataAction = addMissingRows or fillEmptyCells if addMissingRows and fillEmptyCells then addDataAction = addMissingRows.." and "..fillEmptyCells end if not addDataAction then return "" end local editPage = ' [ editing the page] ' local message = string.format(" The following data table is incomplete. You can help by %s to %s. ", editPage, addDataAction) message = mw.getCurrentFrame:preprocess(message) message = message.."" local game = args.game game = game and Franchise.baseGame(game) game = game and Franchise.shortName(game) if game then message = message..string.format("", game) end return message end

function h.styles(stylesheets) local styles = "" for i, stylesheet in ipairs(stylesheets) do		styles = styles..mw.getCurrentFrame:extensionTag({			name = "templatestyles",			args = { src = stylesheet },		}) end return styles end

function p.Schemas(frame) return { printTable = { args = { type = "record", required = true, desc = "Template:Data Table arguments.", properties = { {						name = "game", type = "string" },					{						name = "columns", required = true, type = "array", items = { type = "string" }, },					{						name = "caption", type = "string", },					{						name = "sortable", type = "boolean", },					{						name = "stretch", type = "boolean", },				}			},			rows = { required = true, type = "array", items = { type = "record", properties = { {							name = "cells", type = "array", items = { type = "string" }, },					},				},			},			postFormatHook = { type = "function", desc = "Advanced usage - allows other modules to further customize a data table before printing it. Used by Module:Armor, for example.", },		},	} end

function p.Documentation(frame) return { parseRows = { desc = "Allows other templates to use Template:Data Table's table-like syntax. Used by Module:Wares, for example.", params = {"cells"}, returns = { "A list of rows." },			cases = { outputOnly = true, {					args = { {"-", "cell1", "cell2", "-", "cell3", "cell4", "-"} },				},			},		},		printTable = { params = {"rows", "args", "postFormatHook"}, returns = { "A data table.", "A string of error categories, or an empty string if there are none.", },			cases = { resultOnly = true, {					args = { {							{								cells = {"Blue Chu Jelly", "Blue Potion"} },							{								cells = {"Red Chu Jelly", "Red Potion"}, },						},						{							game = "TWWHD", columns = {"Chu Jelly [Term]", "Potion [Term]"} },					},				},			},		},	} 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