وحدة:Tabular data

This module provides basic functions for interacting with tabular data on Wikimedia Commons.

cell

Returns the value of the cell at the given row index and column name.

Usage: {{#invoke:Tabular data|cell|Page name.tab|output_row=Index of row to output|output_column=Name of column to output}}

A row index of 1 refers to the first row in the table. A row index of -1 refers to the last row in the table. It is an error to specify a row index of 0.

أمثلة

Latest death toll in c:Data:COVID-19 cases in Santa Clara County, California.tab (regardless of when the table was last updated):

lookup

Returns the value of the cell(s) in one or more output columns of the row matching the search key and column.

This function is reminiscent of LOOKUP() macros in popular spreadsheet applications, except that the search key must match exactly. (On the other hand, this means the table does not need to be sorted.)

Usage: {{#invoke:Tabular data|lookup|Page name.tab|search_value=Value to find in column|search_column=Name of column to search in|output_column=Name of column to output|output_column2=Name of another column to output|output_columnn=…|output_format=String format to format the output}}

If multiple columns are output without an explicit string format, this function formats the output as a human-readable list.

Some may find {{Tabular query}} (which uses this module) an intuitive way to obtain cell data as it resembles a simple SQL query.

Parameters

|1=
Page name on Commons with extension but no namespace
|search_value= or |search_pattern=
Value to find or pattern to match in column
|search_column=
Name of column to search in
|occurrence=
Index of the match to output in case of multiple matching rows. A row index of 1 refers to the first matching row. A row index of -1 refers to the last matching row. It is an error to specify a row index of 0.
|output_column= or |output_column1=, |output_column2=, ...
Names of columns to output
|output_format=
String format to format the output

wikitable

قالب:Tracked

Returns the entire data table as a (rather plain) table.

Usage: {{#invoke:Tabular data|wikitable|Page name.tab}}

Examples

COVID-19 statistics in Santa Clara County, California
{{#invoke:Tabular data|wikitable
|COVID-19 cases in Santa Clara County, California.tab}}
Tabular data page Data:COVID-19 cases in Santa Clara County California.tab does not exist or is not accessible.

Implementation notes

The implementation of this function incorporates {{n/a}} (to represent null values), {{yes}} (true), and {{no}} (false). The templates themselves cannot be reused because they are incompatible with the mw.html library, which builds the table using an HTML DOM instead of pure wikitext.

Internationalization

You can most likely port this template to a wiki in another language without making major modifications. The wikitable function automatically localizes the table's description, column titles, and license name into the wiki's content language. It also formats numbers according to the content language. However, you should localize the cells representing true, false, and null by changing the values in the messages, bgColors, and colors variables to match the wiki's own {{yes}}, {{no}}, and {{n/a}} templates, respectively.

See also


-- Module:Tabular data (self-contained, ASCII-only, Scottish-locality aware)
local p = {}

local navbar = require("Module:Navbar")
local lang   = mw.getContentLanguage()

-- Messages
local messages = { ["true"]="نعم", ["false"]="لا", null="غير متاح" }
local bgColors = { ["true"]="#9f9", ["false"]="#f99", null="#ececec" }
local colors   = { ["true"]="#2c2c2c", ["false"]="#2c2c2c", null="#2c2c2c" }

--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------

-- Convert localized digits to ASCII; strip Arabic/ASCII thousands; map Arabic decimal to dot;
-- strip Arabic comma and NBSP/NNBSP.
local function asciiDigits(s)
	if s == nil then return s end
	s = tostring(s)
	local map = {
		["٠"]="0",["١"]="1",["٢"]="2",["٣"]="3",["٤"]="4",["٥"]="5",["٦"]="6",["٧"]="7",["٨"]="8",["٩"]="9",
		["۰"]="0",["۱"]="1",["۲"]="2",["۳"]="3",["۴"]="4",["۵"]="5",["۶"]="6",["۷"]="7",["۸"]="8",["۹"]="9",
		["٬"]="",  -- U+066C Arabic thousands sep
		[","]="",  -- ASCII thousands sep
		["٫"]=".", -- U+066B Arabic decimal sep -> dot
		["،"]="",  -- U+060C Arabic comma
		["\194\160"]="", -- NBSP
		["\226\128\175"]="", -- NNBSP
	}
	s = mw.ustring.gsub(s, ".", map)
	s = mw.ustring.gsub(s, "%.%.+", ".")
	return s
end

-- Normalize Data: title; accept with/without "Data:" and ".tab"
local function normalizeDataTitle(name)
	if not name or name == "" then return nil end
	name = asciiDigits(mw.text.trim(name))
	if not mw.ustring.match(name, "^[Dd]ata:") then
		name = "Data:" .. name
	end
	-- If '...tab' without dot, fix to '.tab'
	name = mw.ustring.gsub(name, "([^.])tab$", "%1.tab")
	if not mw.ustring.match(name, "%.tab$") then
		name = name .. ".tab"
	end
	return name
end

-- Try fetching via JsonConfig
local function tryGetTabular(title)
	local ok, res = pcall(function() return mw.ext.data.get(title) end)
	if not ok then return nil, 'Failed to load tabular data from [['..title..']]: '..tostring(res) end
	if res == false or res == nil then return nil, 'Tabular data [['..title..']] could not be loaded (mw.ext.data.get returned false).' end
	if type(res) ~= 'table' then return nil, 'Tabular data [['..title..']] is not a table (got '..type(res)..').' end
	if type(res.schema) ~= 'table' or type(res.schema.fields) ~= 'table' then
		return nil, 'Unexpected tabular schema: missing schema.fields.'
	end
	if type(res.data) ~= 'table' then
		if type(res.rows) == 'table' then
			res.data = res.rows
		elseif type(res.entries) == 'table' then
			res.data = res.entries
		else
			return nil, 'Unexpected tabular schema: missing data/rows/entries table.'
		end
	end
	res.__title = title
	return res
end

-- Safe loader: fetch first (works even if local .exists=false due to Commons)
local function safeGetData(pageName)
	local title = normalizeDataTitle(pageName)
	if not title then return nil, 'No data page specified.' end
	local ds, err = tryGetTabular(title)
	if ds then return ds, nil end

	-- Optional existence hint
	local exists
	pcall(function()
		local t = mw.title.new(title)
		exists = t and t.exists
	end)
	if not exists then
		return nil, 'Tabular data page [[' .. title .. ']] does not exist or is not accessible.'
	end
	return nil, err or 'Unable to load tabular data.'
end

-- Truthy helper
local function truthy(v)
	if type(v) == 'boolean' then return v end
	if v == nil then return false end
	v = mw.ustring.lower(tostring(v))
	return v == '1' or v == 'yes' or v == 'y' or v == 'true' or v == 'on'
end

-- Fallback for silent returns
local function fallback(args) return args.default or "" end

-- List field names
local function joinFieldNames(dataset)
	local names = {}
	if dataset and dataset.schema and dataset.schema.fields then
		for _, f in ipairs(dataset.schema.fields) do table.insert(names, f.name) end
	end
	return table.concat(names, ", ")
end

-- Try a list of candidate page names; return first dataset that loads
local function tryCandidates(cands)
	for _, c in ipairs(cands) do
		local ds = select(1, safeGetData(c))
		if ds then return ds end
	end
	return nil
end

-- Resolve dataset from args (table / data=string / page=|src=|1=)
-- If nothing provided and POP= is present (Scottish template), try Scottish default series.
local function resolveDataset(args)
	if type(args.data) == 'table' then
		return args.data, nil
	end
	if type(args.data) == 'string' and args.data ~= '' then
		return safeGetData(args.data)
	end
	local raw = args.src or args.page or args[1]

	-- If caller provided something, use it
	if raw and raw ~= "" then
		return safeGetData(raw)
	end

	-- Heuristic: Scottish locality population template often omits page
	-- Try Data:Scottish localities/<year>.tab with fallbacks
	if args.POP or args.Pop or args.pop then
		local y = asciiDigits(lang:formatDate('Y'))
		local ds = tryCandidates({
			"Scottish localities/"..y,
			"Scottish localities/2022",
			"Scottish localities/2021",
			"Scottish localities/2020",
		})
		if ds then return ds, nil end
	end

	return nil, 'No dataset source was provided and no default matched.'
end

-- Write a cell's text (ASCII-normalized); or render null look
local function writeCellText(cell, value)
	if value == nil then
		cell
			:addClass("mw-tabular-value-null")
			:addClass("table-na")
			:css({ background=bgColors.null, color=colors.null, ["vertical-align"]="middle", ["text-align"]="center" })
			:wikitext(messages.null)
	else
		cell:wikitext(asciiDigits(value))
	end
end

--------------------------------------------------------------------------------
-- Core
--------------------------------------------------------------------------------

-- Internal: return value(s) from a specific row and column(s)
function p._cell(args)
	-- Special compatibility: {{Scottish locality population|name|POP=Dumbarton}}
	if (args[1] and mw.ustring.lower(args[1]) == "name") and (args.POP or args.Pop or args.pop) then
		local a = mw.clone(args)
		a.search_column = "Name"
		a.search_value  = args.POP or args.Pop or args.pop
		a.output_column = a.output_column or "Population"
		return p._lookup(a)
	end

	local dataset, err = resolveDataset(args)
	if not dataset then
		return fallback(args) -- silent
	end

	local rowIdx = tonumber(asciiDigits(args.output_row))
	if not rowIdx then
		return fallback(args)
	end

	local outputFormat = args.output_format

	-- Collect requested output columns
	local outputColumnNames = { args.output_column1 or args.output_column }
	while args["output_column" .. #outputColumnNames + 1] do
		table.insert(outputColumnNames, args["output_column" .. #outputColumnNames + 1])
	end
	if not outputColumnNames[1] or outputColumnNames[1] == "" then
		return fallback(args)
	end

	-- Map column name -> index
	local outputColumnIdxs, numFound = {}, 0
	for i, field in ipairs(dataset.schema.fields) do
		for _, wanted in ipairs(outputColumnNames) do
			if field.name == wanted and not outputColumnIdxs[wanted] then
				outputColumnIdxs[wanted] = i
				numFound = numFound + 1
			end
		end
		if numFound == #outputColumnNames then break end
	end
	if numFound < #outputColumnNames then
		return fallback(args)
	end

	-- Normalize row index (allow negative)
	local nrows = #dataset.data
	if nrows == 0 then return fallback(args) end
	if rowIdx > 0 then
		rowIdx = (rowIdx - 1) % nrows + 1
	elseif rowIdx < 0 then
		rowIdx = rowIdx % nrows + 1
	else
		return fallback(args)
	end

	local record = dataset.data[rowIdx]
	if not record then return fallback(args) end

	-- Output columns
	if outputFormat or #outputColumnNames > 1 then
		local values = {}
		for _, name in ipairs(outputColumnNames) do
			table.insert(values, record[ outputColumnIdxs[name] ])
		end
		return asciiDigits(outputFormat and mw.ustring.format(outputFormat, unpack(values))
			or mw.text.listToText(values))
	else
		return asciiDigits(record[ outputColumnIdxs[ outputColumnNames[1] ] ] or fallback(args))
	end
end

--- Public
function p.cell(frame) return p._cell(frame.args) end

-- Internal: lookup row by value/pattern in a column, then reuse _cell to pick columns
function p._lookup(args)
	-- If template-like call omitted search_column but gave POP=, map it
	if (args.POP or args.Pop or args.pop) and (not args.search_value) then
		args.search_value = args.POP or args.Pop or args.pop
	end
	if not args.search_column and args[1] and mw.ustring.lower(args[1]) == "name" then
		args.search_column = "Name"
	end
	if not args.output_column then
		args.output_column = "Population"
	end

	local dataset, err = resolveDataset(args)
	if not dataset then
		return fallback(args)
	end

	local searchColumn = args.search_column
	if not searchColumn or searchColumn == "" then
		return fallback(args)
	end

	-- Find search column index
	local searchColumnIdx
	for i, field in ipairs(dataset.schema.fields) do
		if field.name == searchColumn then searchColumnIdx = i; break end
	end
	if not searchColumnIdx then
		return fallback(args)
	end

	local occurrence = tonumber(asciiDigits(args.occurrence)) or 1
	local startIdx, endIdx, step = 1, #dataset.data, 1
	if occurrence < 0 then startIdx, endIdx, step = #dataset.data, 1, -1 end

	local searchValue, searchPattern = args.search_value, args.search_pattern
	local numMatches = 0
	for i = startIdx, endIdx, step do
		local rec = dataset.data[i]
		local cellVal = rec and rec[searchColumnIdx]
		if (searchValue and cellVal == searchValue)
			or (searchPattern and mw.ustring.match(tostring(cellVal), searchPattern)) then
			numMatches = numMatches + 1
			if numMatches == math.abs(occurrence) then
				local a = mw.clone(args)
				a.data, a.output_row = dataset, i
				return p._cell(a)
			end
		end
	end

	return fallback(args)
end

--- Public
function p.lookup(frame) return p._lookup(frame.args) end

-- Columns lister to help debugging/templates (optional to use)
function p.columns(frame)
	local dataset = select(1, resolveDataset(frame.args))
	if not dataset then return "" end
	return joinFieldNames(dataset)
end

-- Render a wikitable (keeps explicit error messages)
function p._wikitable(args)
	local pageName = args[1] or args.page or args.src or args.data
	local dataset, err = safeGetData(pageName)
	if not dataset then
		return string.format('<strong class="error">%s</strong>', err)
	end

	local datatypes = {}
	local htmlTable = mw.html.create("table"):addClass("wikitable sortable")

	local dataTitle = normalizeDataTitle(pageName) or ""
	htmlTable
		:tag("caption")
		:wikitext(navbar.navbar({ template=dataTitle, mini="y", style="float: right;", "view","edit" }))
		:wikitext(asciiDigits(dataset.description or ""))

	-- Header
	local headerRow = htmlTable:tag("tr")
	for i, field in ipairs(dataset.schema.fields) do
		datatypes[i] = field.type
		headerRow
			:tag("th")
			:attr("scope", "col")
			:attr("data-sort-type", (datatypes[i] == "text") and "string" or datatypes[i])
			:wikitext(asciiDigits(field.title or field.name or ("col"..i)))
	end

	-- Rows
	for _, record in ipairs(dataset.data) do
		local row = htmlTable:tag("tr")
		for j = 1, #dataset.schema.fields do
			local cell = row:tag("td")
			local val = record[j]
			if val ~= nil then
				local dtype = datatypes[j]
				if dtype == "number" and type(val) == "number" then
					cell:attr("align", "right"); writeCellText(cell, val)
				elseif dtype == "boolean" and type(val) == "boolean" then
					cell:addClass(val and "table-yes" or "table-no")
						:css({ background = val and bgColors["true"] or bgColors["false"],
								color = val and colors["true"] or colors["false"],
								["vertical-align"]="middle", ["text-align"]="center" })
						:wikitext(messages[val and "true" or "false"])
				else
					writeCellText(cell, val)
				end
			else
				writeCellText(cell, nil)
			end
		end
	end

	-- Footer
	local footer = htmlTable:tag("tr"):tag("td")
	footer:addClass("sortbottom"):attr("colspan", #dataset.schema.fields)
	if dataset.sources then footer:wikitext(asciiDigits(dataset.sources)):tag("br") end
	if dataset.license and dataset.license.url and dataset.license.text then
		local licenseText = mw.message.new("Jsonconfig-license",
			mw.ustring.format("[%s %s]", dataset.license.url, dataset.license.text))
		footer:tag("i"):wikitext(tostring(licenseText))
	end

	return tostring(htmlTable)
end

function p.wikitable(frame) return p._wikitable(frame.args) end

--------------------------------------------------------------------------------
return p