Module:SMWTable

From Final Fantasy XIV Online Wiki
Jump to navigation Jump to search
Documentation for Module:SMWTable [view] [edit] [history] [purge] (How does this work?)

This helper module is used when authoring other modules that render tables from SMW queries.

local SMWTable = require("Module:SMWTable")

Usage

To create a table, first create a new SMWTable instance:

local myTable = return SMWTable.new({
  selection = "Gunbreaker's Arm",
  queryOptions = {
    sort = "Has level requirement,Has item level,Has canonical name",
    limit = 999,
  },
  attributes = {
    class = "table sortable",
  },
})

Next, call the column method to add columns to the table, providing a definition for each column via helper functions:

myTable
  :column(SMWTable.nameAndIconColumn({label = "Item"}))
  :column(SMWTable.textColumn({property = "Has level requirement", label = "Level"}))
  :column(SMWTable.textColumn({property = "Has item level", label = "Item level"}))
  :column(SMWTable.classJobRequirementColumn())
  :column(SMWTable.weaponDamageColumn())
  :column(SMWTable.materiaSlotsColumn())
  :column(SMWTable.attributeBonusesColumn())

Or by providing a column definition directly:

myTable:column({
  label = "My column",
  printouts = {
    ["Has some property"] = "",
  },
  render = function(row)
    return "Value is " .. row["Has some property"]
  end
})

Finally, render the table by returning it from your module's entrypoint function:

local p = {}
function p.main()
  return myTable
end
return p

Class: SMWTable

Constructor

local mySMWTable = SMWTable.new({
  selection: string,
  queryOptions?: table,
  attributes?: table,
  caption?: string,
})

Creates a new table builder instance. Takes a single argument with named options. Returns a single value, the new SMWTable instance.

selection
The query selection string, passed through directly to {{#ask:}}.
queryOptions
Optional. Additional named parameters to be passed through to {{#ask:}}, particularly useful for setting sort/order.
attributes
Optional. Attributes attached to the table when it is rendered. Most commonly used to set class and/or style to customize the table's appearance, make it sortable, etc.
Default: { class = "table" }
caption
Optional. Wikitext for the table caption, if provided.

Method: column

mySMWTable:column({
  label: string,
  printouts: table,
  render: function,
})

Adds a column to the table. Columns are rendered in the order they are added to the builder, from left to right. Takes a single column definition table which defines what data the column will display and how it will be displayed. Column definitions must have all of the following keys:

label
The header text displayed for this column.
printouts
A table describing the SMW properties that this column will display. Keys of this table are SMW property names, and the value for each key is SMW printout customization data for that property, or otherwise an empty string.
render
A function which takes a single argument, a single row of SMW data as a table, and renders the value of this column for the given row.

The printouts and render keys work together to define the properties necessary to render the column, and then render each row individually based on the requested data. For example, a simple column definition which displays an item's canonical name wrapped in quotation marks might look like:

mySMWTable:column({
  label = "Canonical name",
  printouts = {
    ["Has canonical name"] = "",
  },
  render = function(row)
    return '"' .. row["Has canonical name"] .. '"'
  end
})

This column definition requests one property as part of the query: Has canonical name. No special formatting is required, so the corresponding value in the printouts table is simply the empty string. Then, to render the value for each row, the render function concatenates quotation marks around the current row's property value.

More complicated columns may require multiple properties, chained properties, and/or printout formatting directives in order to achieve their function. For example, consider a table of crafting recipes with a column that displays the recipe's ingredients:

local myTable = SMWTable.new({
  selection = "[[Has context::Recipe]]",
  queryOptions = {limit = 1},
})
myTable:column({
  label = "Ingredients",

  printouts = {
    -- "Has ingredient" is a Record property, so we can query its subproperties
    -- "Has ingredient name" uses # to prevent automatic linking of the item
    ["Has ingredient.Has ingredient name"] = "#",
    ["Has ingredient.Has ingredient quantity"] = "",
  },

  render = function(row)
    -- "Has ingredient" can also have more than one value, for recipes with more than one ingredient
    -- Ensure the values we're working with are arrays so we can handle multiple ingredients
    local names = row["Has ingredient name"]
    local quantities = row["Has ingredient quantity"]
    if type(names) == "string" then
      names = {names}
      quantities = {quantities}
    end

    -- Assemble the column value
    local out = ""
    for i, name in ipairs(names) do  -- Loop over all ingredient names
      if out ~= "" then
        
        out = out .. "<br>"  -- Separate ingredients with a line break
      end
      -- Display the ingredient name and the corresponding quantity
      out = out .. name .. " x" .. quantities[i]
    end
  end
})

This table would render the crafting recipe for Raisins like so:

Ingredients
Lowland Grapes x1
Fire Shard x1

Method: __tostring

tostring(mySMWTable)

Renders the table to wikitext. This is a metamethod that is invoked when the SMWTable instance is cast to a string (via tostring(), the .. operator, ...)

Static method: textColumn

mySMWTable:column(SMWTable.textColumn({
  property: string,
  label?: string,
  default?: string,
  valuesep?: string,
}))

Returns a column definition that displays the value of a string property.

property
Property name, e.g. "Has canonical name".
label
Optional. Heading text for the column.
Default: Property name.
default
Optional. Default value to render when a row does not have a value for the property.
valuesep
Optional. Separator used when a row has multiple values for the property.
Default: ", "

Static method: nameAndIconColumn

Static method: classJobRequirementColumn

Static method: weaponDamageColumn

Static method: materiaSlotsColumn

TODO



local Icon = require("Module:Icon")
local yesno = require("Module:Yesno")

---@class SMWTableColumn A column that displays property values of Semantic Mediawiki entities.
---@field label string Header label for the column
---@field printouts {[string]: string} Map of property names to printout options used for the property; pagename property is keyed by empty string
---@field render fun(entity: table): string Function that renders the column's value for each queried entity

---@class SMWTable A table of Semantic Mediawiki entities, composed of multiple columns.
---@field selection string SMW query selection string
---@field queryOptions table Additional SMW query options for e.g. sorting etc
---@field attributes table Table attribute customization - default is {class = "wikitable"}
---@field caption string | nil Caption content, if any
---@field groupBy string | nil Property name to group results by
---@field groupEvery integer | nil If groupBy is an integer property, groupEvery allows you to generate groups for a range of values (e.g. every 10) instead of every individual value
---@field groupHeader string
---@field groupDefault string
---@field printouts {[string]: string} Map of property names to printout options used for the property; pagename property is keyed by empty string
---@field columnLabels string[] Array of column header labels
---@field columnRenderers (fun(entity: table): string)[] Array of column render functions in the order they will appear
local SMWTable = {}
SMWTable.__index = SMWTable

---@param args {
---  selection: string,
---  queryOptions?: table,
---  attributes?: table,
---  caption?: string,
---  group?: {
---    property: string,
---    range?: number,
---    headerFormat?: string,
---    defaultContent?: string,
---  },
---  groupEvery?: integer,
---}
---@return SMWTable
function SMWTable.new(args)
	---@type SMWTable
	local instance = {
		-- from constructor args
		selection = args.selection,
		queryOptions = args.queryOptions or {},
		attributes = args.attributes or {class = "wikitable"},
		caption = args.caption,
		groupBy = args.group and args.group.property,
		groupEvery = args.group and args.group.range,
		groupHeader = args.group and args.group.headerFormat or "Group: %s",
		groupDefault = args.group and args.group.defaultContent or "No results",
		-- populated by :column()
		printouts = {},
		columnLabels = {},
		columnRenderers = {},
	}

	-- Option validation
	-- Always sort by the thing we're grouping by, to avoid sorting results in Lua
	if instance.groupBy then
		if instance.queryOptions.sort then
			instance.queryOptions.sort = instance.groupBy .. "," .. instance.queryOptions.sort
		else
			instance.queryOptions.sort = instance.groupBy
		end
	end

	setmetatable(instance, SMWTable)
	return instance
end

-- Methods

---@param column SMWTableColumn Column definition
---@return self
function SMWTable:column(column)
	-- apply printouts required for column
	for propertyName, formatString in pairs(column.printouts) do
		local existingFormat = self.printouts[propertyName]
		if existingFormat then
			if existingFormat ~= formatString then
				error("Two columns requested different formats for property " .. propertyName .. ": \"" .. existingFormat .. "\", \"" .. formatString .. "\"")
			end
		else
			self.printouts[propertyName] = formatString
		end
	end

	table.insert(self.columnLabels, column.label)
	table.insert(self.columnRenderers, column.render)

	return self
end

---@param self SMWTable
---@return string
function SMWTable:__tostring()
	local query = {self.selection}
	for propertyName, formatString in pairs(self.printouts) do
		table.insert(query, "?" .. propertyName .. formatString)
	end

	for key, value in pairs(self.queryOptions) do
		query[key] = value
	end

	-- Execute query
	local results = mw.smw.ask(query)
	if not results or #results == 0 then
		return "No results"
	end

	-- Render table

	local function createTable()
		local t = mw.html.create("table"):attr(self.attributes)
		if self.caption then
			t:node(mw.html.create("caption"):wikitext(self.caption))
		end
		local tr = mw.html.create("tr")
		t:node(tr)
		for _, label in ipairs(self.columnLabels) do
			tr:node(mw.html.create("th"):wikitext(label))
		end
		return t
	end

	local groupBy = self.groupBy
	local groupEvery = self.groupEvery
	local currentGroup = nil

	-- render rows
	local out = ""
	local t
	for _, row in ipairs(results) do
		-- Check if we've moved to a new group
		if groupBy then
			if groupEvery and (not currentGroup or row[groupBy] > currentGroup) then
				if not currentGroup then
					currentGroup = groupEvery
				else
					currentGroup = currentGroup + groupEvery
				end

				-- finalize and dispose the current table
				out = out .. tostring(t or "")
				t = nil

				-- add a heading for the next group
				out = out .. "\n" .. self.groupHeader:format(currentGroup - 9 .. "-" .. currentGroup) .. "\n"

				-- add more headings if necessary to get up to the new value
				while row[groupBy] > currentGroup do
					currentGroup = currentGroup + groupEvery
					out = out
						.. self.groupDefault
						.. "\n" .. self.groupHeader:format(currentGroup - 9 .. "-" .. currentGroup) .. "\n"
				end
			elseif not groupEvery and row[groupBy] ~= currentGroup then
				currentGroup = row[groupBy]
				-- finalize and dispose the current table
				out = out .. tostring(t or "")
				t = nil

				-- add a heading for the next group
				out = out .. "\n" .. self.groupHeader:format(row[groupBy]) .. "\n"
			end
		end

		-- render values for this row
		if not t then
			t = createTable()
		end
		local tr = mw.html.create("tr")
		t:node(tr)
		for _, render in ipairs(self.columnRenderers) do
			local renderedValue = tostring(render(row))
			local td = mw.html.create("td")
				:wikitext(renderedValue)
			tr:node(td)
		end
	end

	-- finalize the last table
	out = out .. tostring(t or "")

	return out
end

-- Static methods - column helpers

---@param options string | {property: string, label?: string, default?: string, valuesep?: string}
---@return SMWTableColumn
function SMWTable.textColumn(options)
	if type(options) == "string" then
		options = {property = options}
	end
	return {
		label = options.label or options.property,
		printouts = {
			[options.property] = "",
		},
		render = function (item)
			local value = item[options.property]
			if not value then
				return options.default or ""
			end
			if type(value) == "table" then
				return table.concat(value, options.valuesep or ", ")
			end
			return tostring(item[options.property])
		end
	}
end

---@param options {name?: string, iconSize?: ("small" | "mid" | "big"), allowWrap?: boolean?}
---@return SMWTableColumn
function SMWTable.nameAndIconColumn(options)
	options = options or {}
	return {
		label = "Name",
		printouts = {
			[""] = "=Pagename#",
			["Has context"] = "",
			["Has game icon"] = "#",
			["Has canonical name"] = "",
			["Has dye channels"] = "",
			["Has game icon frame type"] = "",
		},
		render = function(item)
			return Icon.gameIconWithLabel({
				type = item['Has context'],
				frameType = item['Has game icon frame type'],
				icon = item['Has game icon'],
				link = item['Pagename'],
				size = options.iconSize or "big",
				dyeCount = item['Has dye channels'],
				text = item['Has canonical name'] or item['Pagename'],
				allowWrap = options.allowWrap or false
			})
		end
	}
end

local classJobAbbreviations = {
	-- DoH/DoL
	alchemist = "ALC",
	armorer = "ARM",
	blacksmith = "BSM",
	botanist = "BTN",
	carpenter = "CRP",
	culinarian = "CUL",
	fisher = "FSH",
	goldsmith = "GSM",
	leatherworker = "LTW",
	miner = "MIN",
	weaver = "WVR",
	-- Combat classes
	marauder = "MRD",
	gladiator = "GLA",
	pugilist = "PGL",
	lancer = "LNC",
	archer = "ARC",
	conjurer = "CNJ",
	thaumaturge = "THM",
	arcanist = "ACN",
	rogue = "ROG",
	-- Combat jobs
	["dark knight"] = "DRK",
	["red mage"] = "RDM",
	["black mage"] = "BLM",
	["white mage"] = "WHM",
	["blue mage"] = "BLM",
	astrologian = "AST",
	bard = "BRD",
	dragoon = "DRG",
	monk = "MNK",
	machinist = "MCH",
	ninja = "NIN",
	samurai = "SAM",
	scholar = "SCH",
	summoner = "SMN",
	paladin = "PLD",
	warrior = "WAR",
	gunbreaker = "GNB",
	dancer = "DNC",
	reaper = "RPR",
	sage = "SGE",
	viper = "VPR",
	pictomancer = "PCT",
}

function SMWTable.classJobRequirementColumn()
	return {
		label = "Requirement",
		printouts = {
			["Is for class"] = "#",
			["Is for job"] = "#",
		},
		render = function(item)
			local out = ""

			local classes = item["Is for class"] or {}
			if type(classes) == "string" then
				classes = {classes}
			end
			local jobs = item["Is for job"] or {}
			if type(jobs) == "string" then
				jobs = {jobs}
			end

			for i, className in ipairs(classes) do
				if i ~= 1 then
					out = out .. "&nbsp;"
				end
				out = out .. "[[" .. className .. "|" .. classJobAbbreviations[className:lower()] .. "]]"
			end

			out = out .. " "

			for i, jobName in ipairs(jobs) do
				if i ~= 1 then
					out = out .. "&nbsp;"
				end
				out = out .. "[[" .. jobName .. "|" .. classJobAbbreviations[jobName:lower()] .. "]]"
			end

			return out
		end
	}
end

function SMWTable.weaponDamageColumn()
	return {
		label = "Damage (Type)",
		printouts = {
			["Is for job"] = "#",
			["Has weapon physical damage"] = "",
			["Has weapon magic damage"] = "",
			["Has hq weapon physical damage"] = "",
			["Has hq weapon magic damage"] = "",
		},
		render = function(item)
			-- only render damage if this weapon is for a job
			local job = item["Is for job"]
			if not job or job == "" then return "" end


			job = job:lower()
			local damageType = "magic"
			if job == "warrior"
			or job == "paladin"
			or job == "monk"
			or job == "dragoon"
			or job == "bard"
			or job == "ninja"
			or job == "dark knight"
			or job == "machinist"
			or job == "samurai"
			or job == "gunbreaker"
			or job == "dancer"
			or job == "reaper"
			or job == "viper"
			then
				damageType = "physical"
			end

			local out = tostring(item["Has weapon " .. damageType .. " damage"])
			local hqDamage = item["Has hq weapon " .. damageType .. " damage"]
			if hqDamage and hqDamage ~= "" then
				out = out .. "&nbsp;<sup style=\"font-size: 0.7em; font-weight: bold;\">" .. mw.getCurrentFrame():expandTemplate({title = "HQ"}) .. item["Has hq weapon physical damage"] .. "</sup>"
			end
			if damageType == "physical" then
				out = out .. "[[File:Physical Damage.png|24px|link=Damage Types#Physical]]"
			else
				out = out .. "[[File:Magical Damage.png|24px|link=Damage Types#Magical]]"
			end
			return out
		end
	}
end

function SMWTable.materiaSlotsColumn()
	return {
		label = "Materia slots",
		printouts = {
			["Has materia slots"] = "",
			["Has advanced melding"] = "",
		},
		render = function(item)
			local out = item["Has materia slots"]
			if yesno(item["Has advanced melding"]) then
				out = out .. " <span style=\"color:red\" title=\"This item can undergo advanced melding\">(5)</span>"
			end
			return out
		end
	}
end

local attributes = {
	"Strength",
	"Dexterity",
	"Intelligence",
	"Mind",
	"Vitality",
	"Critical Hit",
	"Determination",
	"Direct Hit Rate",
	"Skill Speed",
	"Spell Speed",
	"Tenacity",
	"Piety",
}

local attributePrintouts = {}
for _, v in ipairs(attributes) do
	attributePrintouts["Has " .. v:lower() .. " bonus"] = ""
	attributePrintouts["Has hq " .. v:lower() .. " bonus"] = ""
end
attributePrintouts["Has customizable substats"] = ""
attributePrintouts["Has random substats"] = ""

function SMWTable.attributeBonusesColumn()
	return {
		label = "Stats and Attributes",
		printouts = attributePrintouts,
		render = function(item)
			local out = ""

			for _, attributeName in ipairs(attributes) do
				local bonus = item["Has " .. attributeName:lower() .. " bonus"]
				if bonus and bonus ~= "" then
					out = out .. "[[" .. attributeName .. "]]&nbsp;+" .. bonus
					local hqBonus = item["Has hq " .. attributeName:lower() .. " bonus"]
					if hqBonus and hqBonus ~= "" then
						out = out .. "<sup style=\"font-size: 0.7em; font-weight: bold;\">" .. mw.getCurrentFrame():expandTemplate({title = "HQ"}) .. item["Has hq " .. attributeName:lower() .. " bonus"] .. "</sup>"
					end
					out = out .. "&emsp;"
				end
			end

			if yesno(item["Has customizable substats"]) then
				out = out .. "[[File:Item settings icon.png|24px|link=]] ''Customizable [[Attributes#Secondary_Attributes|secondary attributes]]''"
			elseif yesno(item["Has random substats"]) then
				out = out .. "''Random [[Attributes#Secondary_Attributes|secondary attributes]]''"
			end

			return out
		end
	}
end



return SMWTable