Module:Recipes

From Terraria Wiki
Jump to navigation Jump to search
Lua.svg Documentation The documentation below is transcluded from Module:Recipes/doc. (edit | history)

This module provides the functionality of Template:Recipes. See that template's page for documentation.


--------------------------------------------------------------------------------
--
-- =============================================================================
--
-- Module:Recipes
--
-- Storing and querying of crafting recipe data
--
-- =============================================================================
--
-- Code annotations:
-- This module is documented according to LuaCATS (Lua Comment and Type System).
-- LuaCATS comments are prefixed with three dashes (---) and use Markdown syntax.
-- For a full list of annotations, see the following link:
-- https://luals.github.io/wiki/annotations/
--
--------------------------------------------------------------------------------


---Holds the tables with the localization (l10n) information for the different
---languages, taken from the l10n submodule.
local l10n_info = mw.loadData('Module:Recipes/l10n')

---Holds the l10n information for the current language, as key-value pairs.
---@type table<string, string>
local l10n_table

---The current language. Determines which l10n table to use.
---@type string
local lang

local trim = mw.text.trim
local tag = mw.text.tag
local item_link = require('Module:Item').go
local getItemStat = require('Module:Iteminfo').getItemStat
local itemNameData = require('Module:ItemNames').getData
local moduleTr = require('Module:Tr')
local cache = require 'mw.ext.LuaCache'

---A cached version of the current frame, the interface to the parser.
local currentFrame

---Holds the arguments from the template call.
---@type table<string, string>
local inputArgs

---Counters for `p.query`: the function displays a table which by default consists
---of the columns 'Result', 'Ingredients', and 'Crafting station'. In `tableStart`,
---additional columns (extra columns, 'extCols') are potentially added, depending
---on template parameters. Those extCols are grouped by their position:
---1. col-A-1, col-A-2, ...
---2. 'Result' (default column, not extCol)
---3. col-B-1, col-B-2, ...
---4. 'Ingredients' (default column, not extCol)
---5. col-C-1, col-C-2, ...
---6. station-col-before-1, station-col-before-2, ...
---7. 'Crafting station' (default column, not extCol)
---8. station-col-after-1, station-col-after-2, ...
---9. col-D-1, col-D-2, ...
---How many extCols there are in each group is important information for the functions
---that produce table rows, so those counters are made available globally via this
---table. They are defined in `tableStart`.
local extColCounts = {
	A = 0,
	B = 0,
	C = 0,
	D = 0,
	stationBefore = 0,
	stationAfter = 0
}

---A record from the `Recipes` cargo table. All fields are strings or `nil`, even
---though in the cargo table they might be integers, Booleans, etc.
---@class (exact) CargoTableRecord
---@field result string? Item name
---@field resultid string? Item ID
---@field resultimage string? File name
---@field resulttext string? Note
---@field amount string?
---@field version string? `<platform1> <platform2>`
---@field station string?
---@field ingredients string? `¦itemname1¦^¦itemname2¦`
---@field ings string? `¦itemname1¦amount1^¦itemname2¦amount2`
---@field args string? `itemname1#i:...#n:...¦amount1^itemname2#i:...#n:...¦amount2`

---Return the l10n string associated with the `key`.
---@param key string
---@return string
local function l10n(key)
	return l10n_table[key] or l10n_info['en'][key]
end

---Return a trimmed version of the value of the template parameter with the specified `key`.
---Return `nil` if the parameter is empty or unset.
---@param key string|integer
---@return string?
local function getArg(key)
	local value = inputArgs[key]
	if not value then
		return nil
	end
	value = trim(value)
	if value == '' then
		return nil
	end
	return value
end

---Conversion table for the `getBoolArg` function.
---@type table<string, boolean>
local stringToBoolean = {
	['y'] = true, ['yes'] = true, ['1'] = true, ['on'] = true,
	['n'] = false, ['no'] = false, ['0'] = false, ['off'] = false,
}

---Convert the value of the template parameter with the specified `key` to a Boolean.
---Return `default` or `nil` if the value is not a recognized Boolean string.
---@param key string|integer
---@param default boolean?
---@return boolean?
local function getBoolArg(key, default)
	local value = getArg(key)
	if stringToBoolean[value] ~= nil then
		return stringToBoolean[value]
	end
	return default
end

---Split the `str` on each `div` in it and return the result as a table. Original
---version credit: http://richard.warburton.it. This version trims each substring.
---@param div string
---@param str string
---@return string[]? strExploded Is `nil` if `div` is an empty string
local function explode(div, str)
	if (div=='') then return nil end
	local pos,arr = 0,{}
	-- for each divider found
	for st,sp in function() return string.find(str,div,pos,true) end do
		arr[#arr+1] = trim(string.sub(str,pos,st-1)) -- Attach chars left of current divider
		pos = sp+1 -- Jump past current divider
	end
	arr[#arr+1] = trim(string.sub(str,pos)) -- Attach chars right of last divider
	return arr
end

---Escape all single quotes (normal and as HTML entities) with backslashes.
---@param stringToEscape string
---@return string
local function escape(stringToEscape)
	local s, _ = stringToEscape:gsub("'", "\\'"):gsub("&#39;", "\\'")
	return s  -- discard the number of replacements; irrelevant
end

---Add single quotes around the string and escape any quotes inside the string.
---@param stringToEnclose string
---@return string
local function enclose(stringToEnclose)
	return "'" .. escape(stringToEnclose) .. "'"
end

---Row order modes for the table output of `p.query`.
---@alias ResultOrderMode
---| 'result' # Order by English result item name
---| 'resultid' # Order by result item ID

---Modes of adding station name categories to the output of `p.query`.
---@enum ECateModes
local cateModes = {
	NEVER = 1,
	IF_RESULT_IS_PAGENAME = 2,
	ALWAYS = 3,
}

---Options that affect the table output of `p.query`.
local options = {
	---Whether the items in the result column should have an anchor.
	resultanchor = false,
	---Page name to transclude in place of each result cell.
	---@type string?
	resultTemplate = nil,
	---How to order the rows.
	---@type ResultOrderMode
	resultOrder = 'result',
	---Under what circumstances to add categorization (station name categories).
	---@type ECateModes
	cateMode = cateModes.IF_RESULT_IS_PAGENAME,
	---Whether to add links to the items in the result column.
	linkResult = true,
	---Whether to display the "Internal Item ID" text on each item in the result
	---column.
	showResultId = false,
	---Whether to group (i.e. "merge") identical, consecutive cells in the result
	---column.
	groupResults = true,
	---Whether to include a "Crafting station" column in the table.
	makeStationColumn = true,
	---Whether to group (i.e. "merge") identical, consecutive cells in the station
	---column.
	groupStations = true,
}

---Set values in the `options` table based on the `inputArgs`.
local function setOptions()
	options.resultanchor = getArg('resultanchor')
	options.resultTemplate = getArg('resulttemplate')
	options.groupResults = getBoolArg('grouping', options.groupResults)
	options.linkResult = getBoolArg('link', options.linkResult)
	options.showResultId = getBoolArg('showresultid', options.showResultId)

	if getBoolArg('orderbyid') then
		options.resultOrder = 'resultid'
	end

	options.makeStationColumn = not getBoolArg('nostation', not options.makeStationColumn)
	options.groupStations = options.makeStationColumn
	if options.makeStationColumn then
		options.groupStations = getBoolArg('stationgrouping', options.groupStations)
	end

	-- categorization modes:
	-- `|cate=force/all` => ALWAYS
	-- `|cate=n/no/0/off` => NEVER
	-- `|cate=y/yes/1/on/(anything else)` => IF_RESULT_IS_PAGENAME
	local cateArg = getArg('cate')
	if cateArg == 'force' or cateArg == 'all' then
		options.cateMode = cateModes.ALWAYS
	elseif not getBoolArg('cate', true) then
		options.cateMode = cateModes.NEVER
	end
end

---Cache for the `tr` function to increase speed on repeated translations.
---@type table<string, string>
local trCache = {}

---Translate an input text into the target language via `Module:Tr`.
---@param text string Text to translate
---@param lang string Target language
---@param link? boolean Whether to translate a page name
---@return string translatedText
local function tr(text, lang, link)
	-- assemble the identifier for the cache table
	local cacheKey = lang .. '|' .. text
	if link then
		cacheKey = cacheKey .. '|l'
	end
	-- look up in the cache table: was this input translated before?
	local result = trCache[cacheKey]
	if not result then
		-- no result in the cache table; this is the first time
		-- perform the translation
		result = link and moduleTr.translateLink(text, lang) or moduleTr.translate(text, lang)
		-- add the translated result to the cache table for the next time
		trCache[cacheKey] = result
	end
	return result
end

---Cache for the `itemLink` function to increase speed on repeated item links.
---@type table<string, string>
local itemLinkCache = {}

---Make an {{item}} link via `Module:Item`.
---@param name string Name of the item, may be suffixed with `#i:<image>` and/or `#n:<note>`
---@param args? table<string, string> Parameters for `Module:Item`
---@return string linkedItem
local function itemLink(name, args)
	-- assemble the identifier for the cache table
	local cacheKey = name .. '|'
	if args then
		for k, v in pairs(args) do
			cacheKey = cacheKey .. k .. '=' .. tostring(v) .. '|'
		end
	end
	-- look up in the cache table: was an item link made for this input before?
	local cachedItemLink = itemLinkCache[cacheKey]
	if cachedItemLink then
		-- cache exists; just return it
		return cachedItemLink
	end
	-- there was no result in the cache table; this is the first time

	---Arguments for the `item_link` call.
	---@type table<string, string>
	local ilArgs = {}
	if args then
		ilArgs = mw.clone(args)  -- deep copy
	end
	-- look for a '#' in the item name input
	local pos = name:find('#', 1, true)
	if pos then
		-- there is a '#', so the item name input has some extra data:
		-- `#i:<image>` and/or `#n:<note>`
		-- e.g.: `Wood#i:Acorn.png#n:After Moon Lord is defeated`
		local itemnameStripped = name:sub(1, pos-1)  -- actual item name without the extra data
		-- extract all the extra data
		for k, v in name:gmatch("#(%l):([^#]+)") do
			-- `k` is the lowercase letter `i` or `n`
			-- `v` is the value (`<image>` or `<note>`)
			if k == 'i' then
				-- extra data: image (`#i:<image>`)
				-- any `&` in the data value is replaced by the item name
				-- special shortcut: `#i:old` for `#i:& (old).png`
				if v == 'old' then
					ilArgs.image = itemnameStripped .. ' (old).png'
				else
					ilArgs.image = string.gsub(v, '&', itemnameStripped)
				end
			elseif k == 'n' then
				-- extra data: image (`#n:<note>`)
				ilArgs.note2 = v
			end
		end
		name = itemnameStripped
	end
	ilArgs[1] = name
	if not ilArgs[2] or ilArgs[2] == '' then
		ilArgs[2] = tr(name, lang, false)
	end
	ilArgs.small = 'y'
	ilArgs.lang = lang or 'en'
	ilArgs.nolink = ilArgs.nolink and 'y' or nil
	-- perform the item link expansion
	local result = item_link(currentFrame, ilArgs)
	-- add the result to the cache table for the next time
	itemLinkCache[cacheKey] = result
	return result
end

---Cache for the `versionIcons` function to increase speed on repeated icons.
---@type { [string]: string }
local versionIconsCache = {}

---Transform a string of versions into their respective icons via `{{version icons}}`.
---@param versionStr string Input, e.g. `desktop-console-mobile`
---@return string versionIconsStr
local function versionIcons(versionStr)
	-- assemble the identifier for the cache table
	local cacheKey = lang .. ':recipes:versionicon:' .. versionStr
	-- look up in the cache table: was this version input transformed before?
	local versionIconsStr = versionIconsCache[cacheKey] or cache.get(cacheKey)
	if not versionIconsStr then
		-- no result in the cache table; this is the first time
		-- perform the transformation
		versionIconsStr = currentFrame:expandTemplate{ title = 'version icons', args = {versionStr} }
		-- add the version icons to the cache table for the next time
		versionIconsCache[cacheKey] = versionIconsStr
		cache.set(cacheKey, versionIconsStr, 3600*24)  -- LuaCache: 24 hours
	end
	return versionIconsStr
end

---Cache for the `addCate` function so that no category is added more than once.
---@type { [string]: true }
local cateCache = {}

---Add the `stationName` to the list of stations for which a category will be
---output by `cateStr`.
---@param stationName string
local function addCate(stationName)
	cateCache[stationName] = true
end

---Return the wikitext for all categories in the `cateCache`. Add the generic
---"craftable" category if the `cateCache` is not empty.
---@return string
local function cateStr()
	local cateStrings = {}
	for station, _ in pairs(cateCache) do
		cateStrings[#cateStrings+1] = (
			'[[Category:'
			.. (l10n('station_cate')[station] or tr(station, lang, true))
			.. ']]'
		)
	end
	if #cateStrings > 0 then
		-- there is at least one category to add, so also add the generic "craftable"
		-- category; and add it before any of the others
		table.insert(cateStrings, 1, '[[Category:' .. l10n('cate_craftable') .. ']]')
	end
	return table.concat(cateStrings)
end

---Metals that should be displayed first have even values, and their alternatives
---have their value plus 1.
local metals = {
	Copper = 0, Tin = 1,
	Iron = 2, Lead = 3,
	Silver = 4, Tungsten = 5,
	Gold = 6, Platinum = 7,
	Cobalt = 8, Palladium = 9,
	Mythril = 10, Orichalcum = 11,
	Adamantite = 12, Titanium = 13,
}

---Explode a string of ingredients separated by `/` characters. Normalize "metal"
---ingredients like so:
---* `Iron/Lead Bar` => `{ 'Iron Bar', 'Lead Bar' }`
---* `Lead/Iron Bar` => `{ 'Iron Bar', 'Lead Bar' }`
---* `Lead Bar/Iron Bar` => `{ 'Iron Bar', 'Lead Bar' }`
---* `Titanium/Copper Helmet` => `{ 'Titanium Helmet', 'Copper Helmet' }`
---Only perform this normalization on the metals defined in `metals`. Otherwise:
---* `Acorn` => `{ 'Acorn' }`
---* `Grubby/Sluggy/Buggy` => `{ 'Grubby', 'Sluggy', 'Buggy' }`
---@param ingredient string
---@return string[]
local function splitIngredients(ingredient)
	-- count the number of `/` characters in `ingredient` (up to 2, we don't care about more)
	local _, count = ingredient:gsub('/', '/', 2)
	if count ~= 1 then
		-- there is only 1 item name or 3 or more item names in `ingredient`
		return explode('/', ingredient)  --[[@as string[] ]]
	end
	-- there are exactly 2 item names in `ingredient`
	local pattern = '^%s*(%S+)%s*(.-)/%s*(%S+)%s*(.-)$'
	-- * `Iron/Lead Bar` => `item1a=Iron`, `item2a=Lead`, `item2b=Bar`
	-- * `Silver Watch/Tungsten Watch` => `item1a=Silver`, `item1b=Watch`, `item2a=Tungsten`, `item2b=Watch`
	-- * `Ebonwood/Shadewood` => `item1a=Ebonwood`, `item2a=Shadewood`
	local item1a, item1b, item2a, item2b = ingredient:match(pattern)
	if metals[item1a] and metals[item2a] then
		if tostring(item1b) == '' then
			-- copy the common suffix to the first item, e.g.:
			-- `Iron/Lead Bar` => `Iron Bar/Lead Bar`
			item1b = item2b
		end
		-- check for incorrect order: if `item1a` has an odd value in `metals` and
		-- `item2a` has the even value before it (e.g. item1a=Lead, item2a=Iron),
		-- then the items are alternative metals but in the wrong order;
		-- the second part of this condition ensures that only alternative metals
		-- are considered and not something like item1a=Titanium, item2a=Copper
		if metals[item1a] % 2 == 1 and metals[item2a] == metals[item1a] - 1 then
			-- swap item1 and item2
			item1a, item1b, item2a, item2b = item2a, item2b, item1a, item1b
		end
	end
	return { trim(item1a .. ' ' .. item1b), trim(item2a .. ' ' .. item2b) }
end

---Normalize an ingredient input for storing it to the cargo table:
---* `Lead Bar` => `¦Lead Bar¦`
---* `Iron/Lead Bar` => `¦Iron Bar¦Lead Bar¦`
---* `Lead/Iron Bar` => `¦Iron Bar¦Lead Bar¦`
---* Strip extra data like `#i:` and `#n:`
---@param ingredient string Ingredient name input to normalize
---@return string
local function normalizeIngredient(ingredient)
	local items = splitIngredients(ingredient)
	for i, item in ipairs(items) do
		-- strip the "extdata" (the first '#' character and everything after it)
		items[i] = item:gsub('#.+', '')
	end
	return '¦' .. table.concat(items, '¦') .. '¦'
end

---Whether the `initItemGroups` function has already been called.
local itemGroupsInitialized = false
---Names of all item groups, indexed (e.g. `[1]='Any Wood'`).
---@type table<integer, string>
local itemGroupNames = {}
---Group membership of all items (e.g. `['Ebonwood']=1`).
---@type table<string, integer>
local itemGroupLookup = {}

---Fill the `itemGroupNames` and `itemGroupLookup` tables with data.
local function initItemGroups()
	itemGroupsInitialized = true

	---Initialize one item group.
	---@param groupname string Name of the group
	---@param groupcontents table List of item names in the group
	local function group(groupname, groupcontents)
		-- append the `groupName` to `itemGroupNames`
		local newIndex = #itemGroupNames + 1
		itemGroupNames[newIndex] = groupname
		-- store each item of `groupcontents` with the `itemGroupNames` index of this group
		for _, itemname in ipairs(groupcontents) do
			itemGroupLookup[itemname] = newIndex
		end
	end

	group('Any Wood', {
		'Wood', 'Ebonwood', 'Rich Mahogany', 'Pearlwood', 'Shadewood',
		'Spooky Wood', 'Boreal Wood', 'Palm Wood', 'Ash Wood',
	})
	group('Any Iron Bar', {
		'Iron Bar', 'Lead Bar',
	})
	group('Any Sand', {
		'Sand Block', 'Pearlsand Block', 'Crimsand Block', 'Ebonsand Block',
		'Hardened Sand Block', 'Hardened Ebonsand Block', 'Hardened Crimsand Block',
		'Hardened Pearlsand Block',
	})
	group('Any Pressure Plate', {
		'Red Pressure Plate', 'Green Pressure Plate', 'Gray Pressure Plate',
		'Brown Pressure Plate', 'Blue Pressure Plate', 'Yellow Pressure Plate',
		'Lihzahrd Pressure Plate',
	})
	group('Any Bird', {
		'Bird', 'Blue Jay', 'Cardinal',
	})
	group('Any Scorpion', {
		'Black Scorpion', 'Scorpion',
	})
	group('Any Squirrel', {
		'Squirrel', 'Red Squirrel',
	})
	group('Any Jungle Bug', {
		'Grubby', 'Sluggy', 'Buggy',
	})
	group('Any Duck', {
		'Mallard Duck', 'Duck',
	})
	group('Any Butterfly', {
		'Sulphur Butterfly', 'Julia Butterfly', 'Monarch Butterfly',
		'Purple Emperor Butterfly', 'Red Admiral Butterfly', 'Tree Nymph Butterfly',
		'Ulysses Butterfly', 'Zebra Swallowtail Butterfly',
	})
	group('Any Firefly', {
		'Firefly', 'Lightning Bug',
	})
	group('Any Snail', {
		'Snail', 'Glowing Snail',
	})
	group('Any Fruit', {
		'Apple', 'Apricot', 'Banana', 'Blackcurrant', 'Blood Orange', 'Cherry',
		'Coconut', 'Dragon Fruit', 'Elderberry', 'Grapefruit', 'Lemon', 'Mango',
		'Peach', 'Pineapple', 'Plum', 'Rambutan', 'Star Fruit', 'Spicy Pepper',
		'Pomegranate',
	})
	group('Any Dragonfly', {
		'Black Dragonfly', 'Blue Dragonfly', 'Green Dragonfly', 'Orange Dragonfly',
		'Red Dragonfly', 'Yellow Dragonfly',
	})
	group('Any Turtle', {
		'Turtle', 'Jungle Turtle',
	})
	group('Any Macaw', {
		'Scarlet Macaw', 'Blue Macaw',
	})
	group('Any Cockatiel', {
		'Yellow Cockatiel', 'Gray Cockatiel',
	})
	group('Any Cloud Balloon', {
		'Cloud in a Balloon', 'Blue Horseshoe Balloon',
	})
	group('Any Blizzard Balloon', {
		'Blizzard in a Balloon', 'White Horseshoe Balloon',
	})
	group('Any Sandstorm Balloon', {
		'Sandstorm in a Balloon', 'Yellow Horseshoe Balloon',
	})
	group('Any Balloon', {
		'Silly Green Balloon', 'Silly Pink Balloon', 'Silly Purple Balloon',
	})
	group('Any Guide to Critter Companionship', {
		'Guide to Critter Companionship', 'Guide to Critter Companionship (Inactive)',
	})
	group('Any Guide to Environmental Preservation', {
		'Guide to Environmental Preservation',
		'Guide to Environmental Preservation (Inactive)',
	})
end

---Return the name of the ingredient group that the `item` belongs to.
---@param item string Name of the ingredient (e.g. "Ebonwood")
---@return string? groupName Name of the group (e.g. "Any Wood")
local function getItemGroupName(item)
	if not itemGroupsInitialized then
		initItemGroups()
	end
	return itemGroupLookup[item] and itemGroupNames[itemGroupLookup[item]] or nil
end

---Normalize a crafting station name:
---* `Altar` => `Demon Altar`
---@param station string Crafting station name to normalize
---@return string
local function normalizeStation(station)
	if station == 'Altar' then
		station = 'Demon Altar'
	end
	return station
end

---Normalize a version string:
---* Discard invalid/unknown versions
---* Order, lower case, one space as separator
---* All versions => empty string
---@param version string Version string to normalize
---@return string
local function normalizeVersion(version)
	if not version or version == '' then
		return ''
	end
	version = trim(version):lower()
	-- search for all the valid versions and put the ones that are found in a table
	local normalizedVersion = {}
	if version:find('desktop', 1, true) then
		table.insert(normalizedVersion, 'desktop')
	end
	if version:find('console', 1, true) then
		table.insert(normalizedVersion, 'console')
	end
	if version:find('old-gen', 1, true) then
		table.insert(normalizedVersion, 'old-gen')
	end
	if version:find('japan', 1, true) then
		table.insert(normalizedVersion, 'japan')
	end
	if version:find('mobile', 1, true) then
		table.insert(normalizedVersion, 'mobile')
	end
	if version:find('3ds', 1, true) then
		table.insert(normalizedVersion, '3ds')
	end
	if #normalizedVersion == 0 then
		-- no valid versions were found in the `version` input
		return ''
	end
	local normalizedVersionStr = table.concat(normalizedVersion, ' ')
	if normalizedVersionStr == 'desktop console old-gen mobile 3ds' then
		-- all of the versions (except "japan") were found in the input
		return ''
	end
	return normalizedVersionStr
end

---Turn the `constraintArgs` into an SQL WHERE string.
---@param constraintArgs table<string, string>
---@return string whereStr
local function makeConstraints(constraintArgs)
	---@type string[]
	local constraints = {}

	-- station = '...'
	local stationArg = trim(constraintArgs['station'] or '')
	if stationArg ~= '' then
		-- convert `s1/s2/s3` to `station=s1 OR station=s2 OR station=s3`
		local pieces = explode('/', stationArg)
		---@cast pieces -?
		for i, v in ipairs(pieces) do
			pieces[i] = 'station = ' .. enclose(normalizeStation(v))
		end
		constraints[#constraints+1] = table.concat(pieces, ' OR ')
	end

	-- station != '...'
	local stationnotArg = trim(constraintArgs['stationnot'] or '')
	if stationnotArg ~= '' then
		-- convert `s1/s2/s3` to `station<>s1 AND station<>s2 AND station<>s3`
		local pieces = explode('/', stationnotArg)
		---@cast pieces -?
		for i, v in ipairs(pieces) do
			pieces[i] = 'station <> ' .. enclose(normalizeStation(v))
		end
		constraints[#constraints+1] = table.concat(pieces, ' AND ')
	end

	-- result = '...'
	local resultArg = trim(constraintArgs['result'] or '')
	if resultArg ~= '' then
		-- convert `r1/r2/LIKE r3` to `result=r1 OR result=r2 OR result LIKE r3`
		local pieces = explode('/', resultArg)
		---@cast pieces -?
		for i, v in ipairs(pieces) do
			if mw.ustring.sub(v, 1, 5) == 'LIKE ' then
				pieces[i] = 'result LIKE ' .. enclose(trim(mw.ustring.sub(v, 6)))
			else
				pieces[i] = 'result = ' .. enclose(v)
			end
		end
		constraints[#constraints+1] = table.concat(pieces, ' OR ')
	end

	-- result != '...'
	local resultnotArg = trim(constraintArgs['resultnot'] or '')
	if resultnotArg ~= '' then
		-- convert `r1/r2/LIKE r3` to `result<>r1 AND result<>r2 AND result NOT LIKE r3`
		local pieces = explode('/', resultnotArg)
		---@cast pieces -?
		for i, v in ipairs(pieces) do
			if mw.ustring.sub(v, 1, 5) == 'LIKE ' then
				pieces[i] = 'result NOT LIKE ' .. enclose(trim(mw.ustring.sub(v, 6)))
			else
				pieces[i] = 'result <> ' .. enclose(v)
			end
		end
		constraints[#constraints+1] = table.concat(pieces, ' AND ')
	end

	-- ingredient = '...'
	local ingredientArg = trim(constraintArgs['ingredient'] or '')
	if ingredientArg ~= '' then
		local pieces = explode('/', ingredientArg)
		---@cast pieces -?
		for i, v in ipairs(pieces) do
			-- add the "Any x" group of the item name (`v`) if it is in such a group,
			-- unless the prefix is "#" or "LIKE "
			if mw.ustring.sub(v, 1, 1) == '#' then
				pieces[i] = "ingredients HOLDS LIKE '%¦" .. escape(mw.ustring.sub(v, 2)) .. "¦%'"
			elseif mw.ustring.sub(v, 1, 5) == 'LIKE ' then
				pieces[i] = "ingredients HOLDS LIKE '%¦" .. escape(trim(mw.ustring.sub(v, 6))) .. "¦%'"
			else
				pieces[i] = "ingredients HOLDS LIKE '%¦" .. escape(v) .. "¦%'"
				local group = getItemGroupName(v)
				if group then
					pieces[i] = pieces[i] .. " OR ingredients HOLDS LIKE '%¦" .. escape(group) .. "¦%'"
				end
			end
		end
		constraints[#constraints+1] = table.concat(pieces, ' OR ')
	end

	-- versions
	local versionArg = constraintArgs['version'] or constraintArgs['versions'] or ''
	versionArg = normalizeVersion(versionArg)
	if versionArg ~= '' then
		constraints[#constraints+1] = 'version = ' .. enclose(versionArg)
	end

	local whereStr = ''
	if #constraints > 0 then
		whereStr = '(' .. table.concat(constraints, ') AND (') .. ')'
	end
	return whereStr
end

---Format a table cell in the "Result" column.
---@param record CargoTableRecord Record of the current row
---@return string cellContent
local function resultCell(record)
	---@type string[]
	local cellContents = {}

	local result = record.result  --[[@as string]]
	local version = record.version or ''

	---Arguments for the `{{item}}` call used for formatting the result item.
	local itemArgs = { nolink = not options.linkResult, class = 'multi-line' }
	if options.resultanchor then
		itemArgs.anchor = (
			'<div class="anchor" id="'
			.. currentFrame:callParserFunction('anchorencode', moduleTr.translate(result, lang))
			.. '"></div>'
		)
	end
	-- version text
	if version ~= '' then
		cellContents[#cellContents+1] = '<div class="version-note note-text small">'
		cellContents[#cellContents+1] = l10n('version_note_before')
		cellContents[#cellContents+1] = versionIcons(version)
		cellContents[#cellContents+1] = l10n('version_note_after')
		cellContents[#cellContents+1] = '</div>'
		-- if there is version-exclusive info, don't show eicons on the result {{item}}
		itemArgs.icons = 'no'
	end
	if options.showResultId then
		itemArgs.id = record.resultid
	end
	if record.resultimage then
		itemArgs.image = record.resultimage
	end
	cellContents[#cellContents+1] = itemLink(result, itemArgs)

	-- add amount text
	if record.amount ~= '1' then
		cellContents[#cellContents+1] = tag('span', { class = 'am' }, record.amount)
	end

	-- add note text
	local resultnote = record.resulttext or ''
	if resultnote ~= '' then
		local note_l10n = l10n('result_note') or {}
		resultnote = note_l10n[resultnote] or resultnote
		cellContents[#cellContents+1] = tag('div', { class = 'result-note note-text small' }, resultnote)
	end

	-- combine all the cell contents into one string
	local cellContentStr = table.concat(cellContents)

	if options.resultTemplate then
		local templateOutput = currentFrame:expandTemplate{
			title = options.resultTemplate, args = {
				link = options.linkResult, showid = options.showResultId,
				resultid = record.resultid, resultimage = record.resultimage,
				resultnote = resultnote, result = result, amount = record.amount,
				versions = version,
			}
		}
		-- "@@@@" is the placeholder for the regular result item output
		cellContentStr = templateOutput:gsub('@@@@', cellContentStr)
	end

	return cellContentStr
end

---Format a table cell in the "Ingredients" column.
---@param ingredientsArgs string The `args` field of the `CargoTableRecord` of the current row
---@param itemLinkArgs? table<string, string> Arguments for each ingredient's {{item}}
---@return string cellContent `<ul>` element
local function ingredientsCell(ingredientsArgs, itemLinkArgs)
	---List of ingredient strings, each in the format `itemname#i:...#n:...¦amount`.
	local ingredients = explode('^', ingredientsArgs)  --[[@as string[] ]]
	for i, ingredient in ipairs(ingredients) do
		local item, amount = ingredient:match('^(.-)¦(.-)$')
		-- some old-gen recipes that contain substitutable ingredients are not
		-- stored as separate recipes and so will contain ingredients like
		-- `Adamantite/Titanium Bar`
		-- (see [[Alternative crafting ingredients#Substitutable ingredients]])
		local items = splitIngredients(item)
		-- make an {{item}} for each one
		for j, subItem in ipairs(items) do
			items[j] = itemLink(subItem, itemLinkArgs)
		end
		local ingredientStr = table.concat(items, l10n('ingredients_sep'))
		-- add the amount string
		if amount ~= '1' then
			ingredientStr = ingredientStr .. tag('span', { class = 'am' }, amount)
		end
		ingredients[i] = tag('li', nil, ingredientStr)
	end
	return tag('ul', nil, table.concat(ingredients))
end

---Format the `station` string with `{{item}}` or a similar representation.
---* Replace occurrences of "and" with `andStr`.
---* Expand certain stations which have alternatives (e.g. Iron Anvil => "Iron
---  Anvil or Lead Anvil") and separate them with `orStr`.
---* Return the `station` unchanged if there is an unrecognized station in it
---  (see the three tables in `formatStationLinkSingle` for all recognized ones).
---@param station string
---@param itemLinkArgs table<string, string> Arguments to be passed to each `{{item}}` in this function
---@param andStr string String for "and"
---@param orStr string String for "or"
---@param orStrInner string String for "or" when inside an "and" (e.g. "Iron Anvil *orStrInner* Lead Anvil and Ecto Mist")
---@return string
local function stationLink(station, itemLinkArgs, andStr, orStr, orStrInner)

	-- split the `station` input on " and " and format each part, then concatenate
	-- the formatted parts back with `andStr`

	local compoundStationParts = explode(' and ', station)
	local hasCompounds = #compoundStationParts > 1

	-- Table that contains initial link arguments
	-- use `mw.clone` instead of `=`, otherwise the tables `oldLinkArgs` and `itemLinkArgs` would link to the same object
	local oldLinkArgs = mw.clone(itemLinkArgs)

	---Make an `itemLink` for `name` with the base `itemLinkArgs`.
	---@param name string
	---@param extraArgs? table<string, string> Additional arguments for `itemLink`; overrides `itemLinkArgs` if necessary
	---@return string
	local function il(name, extraArgs)
		itemLinkArgs = mw.clone(oldLinkArgs)
		if extraArgs then
			for k, v in pairs(extraArgs) do itemLinkArgs[k] = v end
		end
		return itemLink(name, itemLinkArgs)
	end

	---Format the `station` with `itemLink` or a similar representation, depending on the station.
	---@param singleStation string
	---@return string|false
	local function formatStationLinkSingle(singleStation)
		---Stations with completely custom formatting.
		local otherFormat = {
			['By Hand'] = l10n('station_by_hand'),
			['Placed Bottle only'] = il('Placed Bottle'),
			['Snow biome'] = il('Snow biome', {mode = 'text'}),
			['Snow Biome'] = il('Snow biome', {mode = 'text'}),
			['Ecto Mist'] = il('Ecto Mist', {mode = 'text'}),
			['Shimmer'] = il('Shimmer', {[2] = l10n('station_shimmer')}),
			['Chlorophyte Extractinator'] = il('Chlorophyte Extractinator', {[2] = l10n('station_chlorophyte_extractinator')})
		}
		---Stations with no special formatting, just a simple `itemLink`.
		local simpleItemLink = {
			['Furnace'] = true,
			['Work Bench'] = true,
			['Sawmill'] = true,
			["Tinkerer's Workshop"] = true,
			['Dye Vat'] = true,
			['Loom'] = true,
			['Keg'] = true,
			['Hellforge'] = true,
			['Bookcase'] = true,
			['Imbuing Station'] = true,
			['Lava'] = true,
			['Honey'] = true,
			['Glass Kiln'] = true,
			['Flesh Cloning Vat'] = true,
			['Autohammer'] = true,
			['Crystal Ball'] = true,
			['Ice Machine'] = true,
			['Meat Grinder'] = true,
			['Living Loom'] = true,
			['Heavy Work Bench'] = true,
			['Sky Mill'] = true,
			['Solidifier'] = true,
			['Honey Dispenser'] = true,
			['Bone Welder'] = true,
			['Blend-O-Matic'] = true,
			['Steampunk Boiler'] = true,
			['Ancient Manipulator'] = true,
			['Lihzahrd Furnace'] = true,
			['Living Wood'] = true,
			['Decay Chamber'] = true,
			['Teapot'] = true,
			['Campfire'] = true,
			['Chair'] = true,
		}
		---Stations that have one or more alternatives, concatenated with "or".
		local multipleItemLinks = {
			['Iron Anvil'] = { 'Lead Anvil', },
			['Adamantite Forge'] = { 'Titanium Forge', },
			['Mythril Anvil'] = { 'Orichalcum Anvil', },
			['Demon Altar'] = { 'Crimson Altar', },
			['Cooking Pot'] = { 'Cauldron', },
			['Placed Bottle'] = { 'Alchemy Table', },
			['Water'] = { 'Sink', },
		}

		if otherFormat[singleStation] then
			return otherFormat[singleStation]
		elseif simpleItemLink[singleStation] then
			return il(singleStation)
		elseif multipleItemLinks[singleStation] then
			-- e.g. "Iron Anvil" => "Iron Anvil or Lead Anvil"
			---List of `itemLinks` for each alternative of this station.
			local allAlternatives = { il(singleStation) }
			for _, altStation in ipairs(multipleItemLinks[singleStation]) do
				allAlternatives[#allAlternatives+1] = il(altStation)
			end
			-- if there are other stations in the full `station` string (e.g.
			-- "Iron Anvil and Ecto Mist"), then wrap this in an extra <span> tag
			-- and use the `orStrInner` as separator
			if hasCompounds then
				return tag('span', {class = 'ib'}, table.concat(allAlternatives, orStrInner))
			else
				return table.concat(allAlternatives, orStr)
			end
		end
		-- the `singleStation` was not in any of the three tables, so it's invalid
		return false
	end

	---@type string[]
	local formattedStations = {}

	for i, compoundStationPart in ipairs(compoundStationParts) do
		local formattedStation = formatStationLinkSingle(compoundStationPart)
		if formattedStation == false then
			-- the formatting failed because `compoundStationPart` was not recognized
			-- as a valid station, so abort all formatting now and return the
			-- input unchanged
			return station
		end
		formattedStations[i] = formattedStation
	end
	return table.concat(formattedStations, andStr)
end

---Format a table cell in the "Crafting station" column.
---@param station string The `station` field of the `CargoTableRecord` of the current row
---@param inline? boolean Whether not to set `wrap=y` on all `{{item}}`s, like it is done by default
---@return string cellContent
local function stationCell(station, inline)
	return stationLink(
		station,
		inline and {} or {wrap = 'y'},
		l10n('station_sep_and'),
		l10n('station_sep_or'),
		l10n('station_sep_or')
	)
end

---Format a crafting station in a "compact" way (for the `compact` mode of `p.extract`).
---@param station string The `station` field of the `CargoTableRecord` to format
---@param formatString string "@@@@" within this string will be replaced with the station
---@return string? formattedStation `nil` if the station is "By Hand" (which will never be formatted)
local function compactStation(station, formatString)
	if station == 'By Hand' then
		return
	end
	local s, _ = formatString:gsub('@@@@', stationLink(
		station,
		{mode = 'image'},
		l10n('compact_sep_stationAnd'),
		l10n('compact_sep_stationOr'),
		l10n('compact_sep_stationOrInner')
	))
	return s  -- discard the number of replacements from `gsub`; irrelevant
end

---Construct the HTML attributes for the `<table>` element.
---@return table<'class'|'id'|'style', string>
local function tableAttributes()
	local sortable = getBoolArg('sortable', true) and 'sortable ' or ''
	local customClasses = getArg('class') or ''
	return {
		class = 'terraria cellborder recipes ' .. sortable .. customClasses,
		id = getArg('id'),
		style = getArg('css') or getArg('style'),
	}
end

---Return the caption (if provided) and the header row of the table.
---Also fill the global `extColCounts` table.
---@param caption? string
---@return string tableStartElements `<caption>` element and `<tr>` element
local function tableStart(caption)
	---List of `<th>` elements that will constitute the first `<tr>` of the table.
	---@type string[]
	local headerCells = {}

	---Add a `<th>` for each of the template parameters that have the common
	---`argPrefix` and have a value set, then return the number of those parameters.
	---For example, with `argPrefix=foo-` and template parameters like so:
	---`{{recipes|foo-1=X|foo-2=Y|foo-3=Z|foo-8=Q}}`, the function will
	---add `<th>X</th> <th>Y</th> <th>Z</th>` to the `headerCells` list and return
	---`3`. The `foo-8` is disregarded because it is not consecutive.
	---@param argPrefix string Common prefix of the template parameter names
	---@param htmlClass? string Class to set on each `<th>` element
	---@return integer count Number of template parameters that have the `argPrefix`
	local function addColumnsForEach(argPrefix, htmlClass)
		-- start at parameter `argPrefix1`, then `argPrefix2`, etc., and go up
		-- until the parameter is not set or its value is blank
		local i = 1
		while true do
			local v = getArg(argPrefix .. i)
			if not v then
				break
			end
			headerCells[#headerCells+1] = tag('th', { class = htmlClass }, v)
			i = i + 1
		end
		return i - 1
	end

	-- fill the `headerCells` list:

	-- <th> for each `col-A-x` parameter
	extColCounts.A = addColumnsForEach('col-A-')

	-- <th> for the result column
	headerCells[#headerCells+1] = tag('th',
		{ class = 'result' },
		(getArg('header-result') or l10n('header_result'))
	)

	-- <th> for each `col-B-x` parameter
	extColCounts.B = addColumnsForEach('col-B-')

	-- <th> for the ingredients column
	headerCells[#headerCells+1] = tag('th',
		{ class = 'ingredients' },
		(getArg('header-ingredients') or l10n('header_ingredients'))
	)

	-- <th> for each `col-C-x` parameter
	extColCounts.C = addColumnsForEach('col-C-')

	if options.makeStationColumn then
		-- <th> for each `station-col-before-x` parameter
		extColCounts.stationBefore = addColumnsForEach('station-col-before-', 'station')

		-- <th> for the station column
		headerCells[#headerCells+1] = tag('th',
			{ class = 'station' },
			(getArg('header-station') or l10n('header_station'))
		)

		-- <th> for each `station-col-after-x` parameter
		extColCounts.stationAfter = addColumnsForEach('station-col-after-', 'station')
	end

	-- <th> for each `col-D-x` parameter
	extColCounts.D = addColumnsForEach('col-D-')

	-- put all the header cells into a <tr> tag
	local headerRow = tag('tr', nil, table.concat(headerCells))

	-- put the `caption` text (if present) into a <caption> element
	local captionElem = caption and tag('caption', nil, caption) or ''

	return captionElem .. headerRow
end

---Format the cargo record as a table row.
---@param record CargoTableRecord
---@param recordIndex integer Index of this record among all records (the full cargo query result)
---@param status table Data to track across multiple rows
---@return string rowElement `<tr>` element
local function tableRow(record, recordIndex, status)
	---Custom identifier of this record's crafting station, as defined in a template parameter.
	local stationIndex = getArg('station-index-' .. record.station)
	---Custom identifier of this record, as defined in a template parameter.
	local resultIndex = (
		getArg('result-index-#' .. recordIndex)
		or getArg('result-index-' .. record.result .. '-' .. (record.version or ''))
		or getArg('result-index-' .. record.result)
	)
	---Unique identifier for the result of this record, for comparing with
	---`status.result`.
	local result = table.concat(
		{
			record.result, (record.resultid or ''), (record.resultimage or ''),
			(record.resulttext or ''), record.amount, (record.version or '')
		},
		'|'
	)
	-- update the `status` table
	if status.station ~= record.station then
		-- the previous record has a different crafting station from this one
		status.station = record.station
		status.stationCounterPrev = status.stationCounter
		status.stationCounter = 1
	else
		-- the previous record has the same crafting station as this one
		status.stationCounter = status.stationCounter + 1
	end
	if status.result ~= result then
		-- the previous record has a different result identifier from this one
		status.result = result
		status.resultCounterPrev = status.resultCounter
		status.resultCounter = 1
	else
		-- the previous record has the same result identifier as this one
		status.resultCounter = status.resultCounter + 1
	end
	if status.resultIndex ~= resultIndex then
		-- the previous record has a different custom result identifier from this one
		status.resultIndex = resultIndex
		status.resultIndexCounterPrev = status.resultIndexCounter
		status.resultIndexCounter = 1
	else
		-- the previous record has the same custom result identifier as this one
		status.resultIndexCounter = status.resultIndexCounter + 1
	end

	---List of `<td>` elements.
	---@type string[]
	local cells = {}

	---Get the value of a `-row-` template parameter, put it in a `<td>`
	---element and add that element to the `cells` list.
	---@param columnName string Parameter name without index and prefix, e.g. `col-A-1`
	---@param htmlClass string Class to set on each `<td>` element
	---@param index? string Custom identifier of a crafting station or result item
	---@param counter integer
	---@param needGroup boolean
	---@param placeholder string Placeholder for the `rowspan` attribute
	local function extColCell(columnName, htmlClass, index, counter, needGroup, placeholder)
		local content = ''
		local attr = { class = htmlClass }
		if index then
			local cellName = index .. '-row-' .. columnName
			content = getArg(cellName) or ''
			attr.colspan = getArg(cellName .. '-colspan')
		end
		-- add `rowspan` attribute to this cell if needed
		if counter == 1 and needGroup then
			attr.rowspan = placeholder
		end
		cells[#cells+1] = tag('td', attr, content)
	end

	---Call `extColCell()` for each column in a group of columns, e.g. for
	---`col-A-1`, `col-A-2`, and `col-A-3`, if `columnPrefix=col-A-` and `count=3`.
	---@param count integer Number of cells in this group
	---@param columnPrefix string Common prefix of the group of columns
	---@param station? boolean
	local function extColCells(count, columnPrefix, station)
		if not station and (options.groupResults and status.resultIndexCounter > 1) then
			return
		end
		for i = 1, count do
			local columnName = columnPrefix .. i
			if station then
				extColCell(
					columnName, 'station ' .. columnName, stationIndex,
					status.stationCounter, options.groupStations, 'xxxrowspanxxx'
				)
			else
				extColCell(
					columnName, columnName, resultIndex,
					status.resultIndexCounter, options.groupResults, 'zzzrowspanzzz'
				)
			end
		end
	end

	-- cells for extCols A
	extColCells(extColCounts.A, 'col-A-')

	-- cell for result item
	if not options.groupResults or status.resultCounter == 1 then
		local attr = { class = 'result', ['data-sort-value'] = record.result }
		if status.resultCounter == 1 and options.groupResults then
			attr.rowspan = 'yyyrowspanyyy'
		end
		cells[#cells+1] = tag('td', attr, resultCell(record))
	end

	-- cells for extCols B
	extColCells(extColCounts.B, 'col-B-')

	-- cell for ingredient items
	cells[#cells+1] = tag('td', { class = 'ingredients' }, ingredientsCell(record.args))

	-- cell for extCols C
	extColCells(extColCounts.C, 'col-C-')

	if options.makeStationColumn and (not options.groupStations or status.stationCounter == 1) then
		-- cells for extCols stationBefore
		extColCells(extColCounts.stationBefore, 'station-col-before-', true)

		-- cell for crafting station
		cells[#cells+1] = tag('td',
			{ class = 'station', rowspan = options.groupStations and 'xxxrowspanxxx' or nil},
			stationCell(record.station)
		)

		-- cells for extCols stationAfter
		extColCells(extColCounts.stationAfter, 'station-col-after-', true)
	end

	-- cells for extCols D
	extColCells(extColCounts.D, 'col-D-')

	-- put all of the cells into a <tr> element
	return tag('tr', { ['data-rowid'] = recordIndex }, table.concat(cells))
end

---Format all cargo records as table rows.
---@param cargoQueryResult CargoTableRecord[] Records to format
---@param rootpagename string
---@return string[] rowElements `<tr>` elements
local function normalRows(cargoQueryResult, rootpagename)
	---When iterating over the cargo records, track this data. It is needed for
	---grouping the table rows with `rowspan`.
	local status = {
		---Crafting station of the current record. Multiple records can have the
		---same crafting station.
		---@type string?
		station = nil,
		---Current number of records that have the `status.station`.
		stationCounter = 0,
		---Total number of records that have the previous `status.station`.
		stationCounterPrev = 0,
		---Result identifier (item + image + amount etc.) of the current record.
		---Multiple records can have the same result identifier (e.g. an item
		---with different recipes).
		---@type string?
		result = nil,
		---Current number of records that have the `status.result`.
		resultCounter = 0,
		---Total number of records that have the previous `status.result`.
		resultCounterPrev = 0,
		---Custom result identifier (as defined in a template parameter) of the
		---current record.
		---@type string?
		resultIndex = nil,
		---Current number of records that have the `status.resultIndex`.
		resultIndexCounter = 0,
		---Total number of records that have the previous `status.resultIndex`.
		resultIndexCounterPrev = 0,
	}

	---List of `<tr>` elements.
	---@type string[]
	local rows = {}

	-- iterate over the rows (records) of the cargo query result
	for i, record in ipairs(cargoQueryResult) do
		-- make a <tr> element and update the `status` table for this record
		rows[i] = tableRow(record, i, status)

		-- grouping via `rowspan` attributes:
		-- only if we're past the first row, only if grouping is even enabled in
		-- the `options`, and only if the current row started a new group
		if i > 1 and options.groupStations and status.stationCounter == 1 then
			-- this row has a new station, so group all of the previous rows
			rows[i-status.stationCounterPrev] = rows[i-status.stationCounterPrev]:gsub('xxxrowspanxxx', status.stationCounterPrev)
		end
		if i > 1 and options.groupResults and status.resultCounter == 1 then
			-- this row has a new result identifier, so group all of the previous rows
			rows[i-status.resultCounterPrev] = rows[i-status.resultCounterPrev]:gsub('yyyrowspanyyy', status.resultCounterPrev)
		end
		if i > 1 and options.groupResults and status.resultIndexCounter == 1 then
			-- this row has a new custom result identifier, so group all of the previous rows
			rows[i-status.resultIndexCounterPrev] = rows[i-status.resultIndexCounterPrev]:gsub('zzzrowspanzzz', status.resultIndexCounterPrev)
		end

		-- categorization
		local needCate = options.cateMode ~= cateModes.NEVER
		if options.cateMode == cateModes.IF_RESULT_IS_PAGENAME then
			-- in this cateMode: only categorize if result item name equals pagename
			needCate = tr(record.result, lang) == rootpagename
		end
		if needCate then
			addCate(record.station)
		end
	end

	-- grouping for the last groups
	if rows[1] then  -- unnecessary if there aren't any rows
		if options.groupStations then
			rows[#rows-status.stationCounter+1] = rows[#rows-status.stationCounter+1]:gsub('xxxrowspanxxx', status.stationCounter)
		end
		if options.groupResults then
			rows[#rows-status.resultCounter+1] = rows[#rows-status.resultCounter+1]:gsub('yyyrowspanyyy', status.resultCounter)
			rows[#rows-status.resultIndexCounter+1] = rows[#rows-status.resultIndexCounter+1]:gsub('zzzrowspanzzz', status.resultIndexCounter)
		end
	end
	return rows
end

---Return all extra rows. These have completely manual contents, which are taken
---from the template parameters starting with `extrow-`.
---@param isTop? boolean Whether to consider the parameters starting with `topextrow-` instead
---@return string rowElements Zero, one, or more `<tr>` elements
local function extRows(isTop)
	-- the following three variables are row-specific and are reset for each row
	-- in the loop below

	---Prefix for the template parameter; includes the current row index.
	---@type string
	local argPrefix
	---List of `<td>` elements.
	---@type string[]
	local cellsInThisRow
	---Whether none of the cells in this row has any content.
	---@type boolean
	local rowIsEmpty

	---Get the value of an `extrow-` template parameter, put it in a `<td>`
	---element and add that element to the `cellsInThisRow` list.
	---@param columnName string Parameter name without prefix, e.g. `col-A-1`
	---@param htmlClass? string Class to set on each `<td>` element
	local function extColCell(columnName, htmlClass)
		local cellName = argPrefix .. columnName
		local content = getArg(cellName)
		if content then
			rowIsEmpty = false
		end
		cellsInThisRow[#cellsInThisRow+1] = tag('td',
			{
				class = htmlClass or columnName,
				colspan = getArg(cellName .. '-colspan'),
				rowspan = getArg(cellName .. '-rowspan')
			},
			content or ''
		)
	end

	---Call `extColCell()` for each column in a group of columns, e.g. for
	---`col-A-1`, `col-A-2`, and `col-A-3`, if `columnPrefix=col-A-` and `count=3`.
	---@param count integer Number of cells in this group
	---@param columnPrefix string Common prefix of the group of columns
	---@param station? boolean Whether to add the `station` class to each `<td>` element
	local function extColCells(count, columnPrefix, station)
		for i = 1, count do
			local columnName = columnPrefix .. i
			extColCell(columnName, station and ('station ' .. columnName) or nil)
		end
	end

	---String that all the template parameters start with.
	local commonArgPrefix = isTop and 'topextrow-' or 'extrow-'

	---List of `<tr>` elements.
	---@type string[]
	local rows = {}

	-- gather all of the template parameters that start with `(top)extrow-1` for
	-- all of the extcols, then `(top)extrow-2`, and so on, until there are no
	-- parameters for a row
	local rowIndex = 0
	while true do
		rowIndex = rowIndex + 1
		-- start new row: reset list of cells and emptiness flag
		cellsInThisRow = {}
		rowIsEmpty = true
		-- in this iteration, only consider template parameters with the current row index
		argPrefix = commonArgPrefix .. rowIndex .. '-'

		extColCells(extColCounts.A, 'col-A-')
		extColCell('col-result', 'result')
		extColCells(extColCounts.B, 'col-B-')
		extColCell('col-ingredients', 'ingredients')
		extColCells(extColCounts.C, 'col-C-')
		if options.makeStationColumn then
			extColCells(extColCounts.stationBefore, 'station-col-before-', true)
			extColCell('col-station', 'station')
			extColCells(extColCounts.stationAfter, 'station-col-after-', true)
		end
		extColCells(extColCounts.D, 'col-D-')

		if not rowIsEmpty then
			-- put all of the cells in this row into a <tr> element
			local attr = { ['data-' .. commonArgPrefix .. 'id'] = rowIndex }
			rows[#rows+1] = tag('tr', attr, table.concat(cellsInThisRow))
		else
			-- none of the template parameters (for none of the columns) were set
			-- or had a non-blank value, so we reached the last row and can return now
			break
		end
	end
	return table.concat(rows)
end

---Format the result of a cargo query as a recipes table.
---@param cargoQueryResult CargoTableRecord[]
---@param rootpagename string
---@param caption? string Caption of the whole table
---@param expectedrows? integer Number of expected table rows
---@return string
local function tableBody(cargoQueryResult, rootpagename, caption, expectedrows)
	local tableStartStr = tableStart(caption)
	local extRowsTopStr = extRows(true)
	local normalTableRows = normalRows(cargoQueryResult, rootpagename)
	local normalRowsStr = table.concat(normalTableRows)
	local extRowsStr = extRows()

	local tableBodyStr = (
		tableStartStr ..  -- <caption> and header <tr>
		extRowsTopStr ..  -- <tr>s with manual data
		normalRowsStr ..  -- <tr>s with cargo data
		extRowsStr  -- <tr>s with manual data
	)

	local rowsCount = #normalTableRows
	local attributes = tableAttributes()
	attributes['data-expectedRows'] = expectedrows
	attributes['data-totalRows'] = rowsCount

	local str = tag('table', attributes, tableBodyStr)

	if expectedrows and rowsCount ~= expectedrows then
		str = str .. '[[Category:'.. l10n('cate_unexpected_rows_count') .. ']]'
	end
	if not expectedrows and rowsCount == 0 then
		str = str .. '[[Category:'.. l10n('cate_no_row') .. ']]'
	end

	-- categorization
	if options.cateMode ~= cateModes.NEVER then
		str = str .. cateStr()
	end

	return str
end

--------------------------------------------------------------------------------
---Main return object
local p = {}

---For `{{recipes/register}}`: register a recipe to the cargo table.
---@param frame table Interface to the parser (`mw.frame`)
p.register = function(frame)
	local args = frame:getParent().args

	---List of ingredients (`itemname`), used for querying.
	local ingredients = {}
	---List of ingredients (`fulliteminput¦amount`), used for building ingredient cells.
	local ingArgs = {}
	---List of ingredients (`itemnameamount`), used in `GROUP BY` to eliminate duplicate rows.
	local ings = {}

	-- iterate over pairs of unnamed parameters
	local index = 1
	while args[index*2-1] do
		-- parameters 1, 3, 5, etc.: ingredient
		local itemStr = trim(args[index*2-1])
		local item = normalizeIngredient(itemStr)  -- e.g. ¦Iron Bar¦Lead Bar¦
		-- parameters 2, 4, 6, etc.: amount
		local amount = trim(args[index*2])
		-- append to each of the three lists
		ingredients[index] = item
		ingArgs[index] = itemStr .. '¦' .. amount
		ings[index] = item .. amount
		index = index + 1
	end
	table.sort(ings)

	-- store to cargo
	frame:callParserFunction('#cargo_store:_table=Recipes', {
		result = trim(args['result'] or ''),
		resultid = trim(args['resultid'] or ''),
		resultimage = trim(args['image'] or ''),
		resulttext = trim(args['note'] or ''),  -- reuse the legacy "resulttext" field for notes
		amount = trim(args['amount'] or ''),
		version = normalizeVersion(args['version']),
		station = normalizeStation(trim(args['station'] or '')),
		ingredients = table.concat(ingredients, '^'),
		ings = table.concat(ings, '^'),
		args = table.concat(ingArgs, '^'),
	})
end  -- p.register

---For `{{recipes}}`: query the cargo table and return a table of recipes.
---@param frame table Interface to the parser (`mw.frame`)
---@return string recipesTable Wikitext of the recipes table
p.query = function(frame)
	currentFrame = frame  -- global frame cache
	inputArgs = frame:getParent().args  -- global input args cache

	lang = frame.args['lang'] or 'en'
	l10n_table = l10n_info[lang] or l10n_info['en']
	setOptions()

	local where = trim(inputArgs['where'] or '')
	if where == '' then
		where = makeConstraints(inputArgs)
	end

	-- if there are no constraints, then there can never be any results
	if where == '' then
		return frame:expandTemplate{ title = 'error', args = {
			'Recipes: No constraints',
			from = 'Recipes',
			inline = 'y'
		}}
	end

	---@type integer?
	local expectedrows
	local expectedrowsArg = trim(inputArgs['expectedrows'] or '')
	if expectedrowsArg ~= '' then
		expectedrows = tonumber(expectedrowsArg)
	end

	local rootpagename = mw.title.getCurrentTitle().rootText

	-- prepare the cargo query

	local orderBy = options.resultOrder .. ', amount DESC, version'
	if options.makeStationColumn then
		-- if we should print a station column, then we must order by station first
		orderBy = 'station, ' .. orderBy
	end

	local fields = 'result, resultid, resultimage, resulttext, amount, version, station, args'
	local groupBy = 'resultid, result, amount, ings, version, station'

	-- perform the cargo query
	---@type CargoTableRecord[]
	local result = mw.ext.cargo.query('Recipes', fields, {
		where = where,
		groupBy = groupBy,
		orderBy = orderBy,
		limit = 2000,
	})

	-- format the query result as a table
	return tableBody(result, rootpagename, getArg('title'), expectedrows)
end  -- p.query

---For `{{recipes/extract}}`: query the cargo table and return a compact display.
---@param frame table Interface to the parser (`mw.frame`)
---@return string
p.extract = function(frame)
	currentFrame = frame  -- global frame cache
	inputArgs = frame:getParent().args  -- global input args cache

	lang = frame.args['lang'] or 'en'
	l10n_table = l10n_info[lang] or l10n_info['en']

	local where = trim(inputArgs['where'] or '')
	if where == '' then
		where = makeConstraints(inputArgs)
	end

	-- if there are no constraints, then there can never be any results
	if where == '' then
		return frame:expandTemplate{ title = 'error', args = {
			'Recipes/extract: No constraints',
			from = 'Recipes',
			inline = 'y'
		}}
	end

	-- prepare the cargo query

	local orderBy = getBoolArg('orderbyid') and 'resultid' or 'result'
	orderBy = orderBy .. ', amount DESC, version'  -- do not order by station

	local fields = 'result, resultid, resultimage, resulttext, amount, version, station, args, ings'
	local groupBy = 'resultid, result, amount, version, ings'

	-- perform the cargo query
	---@type CargoTableRecord[]
	local result = mw.ext.cargo.query('Recipes', fields, {
		where = where,
		groupBy = groupBy,
		orderBy = orderBy,
		limit = 20,  -- this is enough
	})

	---Functions for processing the result of the cargo query. They return the
	---output that is to be displayed, e.g. `handles.station` displays the
	---crafting station of the extracted recipe.
	---The template parameter `mode` determines which of these functions to use.
	---@type { [string]: fun(): string }
	local handles = {}

	---Format the recipes in a short, "compact" way.
	---This is the default output mode.
	---@return string
	handles.compact = function()
		---Whether to display the `record.version` on each record.
		local withVersion = not getBoolArg('noversion', false)
		---Whether to display the `record.resulttext` on each record.
		local withNote = not getBoolArg('nonote', false)
		---The amount text will normally not be displayed if it is '1'; this
		---parameter forces it even if it is '1'.
		local forceAmountText = getBoolArg('full', false)
		---Whether to display the `record.result` on each record.
		local withResult = getBoolArg('withresult', false)
		---Whether to display the `record.station` on each record.
		local withStation = not getBoolArg('nostation', false)

		---All of the recipes from the cargo query, each one formatted in a
		---compact way and wrapped in `<span class="recipe"></span>`.
		---@type string[]
		local compactedRecipes = {}

		-- iterate over the rows (records) of the cargo query result, format each
		-- one, and put it in the `compactedRecipes` list
		for index, record in ipairs(result) do
			---Result item, formatted with `{{item}}`.
			local resultStr = ''
			if withResult then
				---Arguments for the `{{item}}` call used for formatting the result item.
				local itemArgs = { mode = 'image' }
				if record.resultimage then
					itemArgs.image = record.resultimage
				end
				resultStr = itemLink(record.result, itemArgs)
				-- add amount text at the front
				if record.amount ~= '1' or forceAmountText then
					resultStr = record.amount .. ' ' .. resultStr
				end
				-- add note text
				local resultnote = record.resulttext or ''
				if withNote and resultnote ~= '' then
					local note_l10n = l10n('result_note') or {}
					resultnote = note_l10n[resultnote] or resultnote
					resultStr = resultStr .. tag('span', { class = 'result-note note-text small' }, resultnote)
				end
			end

			---All ingredient items, formatted with `{{item}}` and separated by '+'.
			local ingredientsStr = ''
			---List of ingredient strings, each in the format `itemname#i:...#n:...¦amount`.
			---Iterate over each one and replace it with its formatted output via
			---`{{item}}`.
			local ingList = explode('^', record.args)  --[[@as string[] ]]
			---Arguments for the `{{item}}` call used for formatting each ingredient item.
			local itemArgs = { mode = 'image' }
			for i, ingredient in ipairs(ingList) do
				local item, amount = ingredient:match('^(.-)¦(.-)$')
				-- some old-gen recipes that contain substitutable ingredients are not
				-- stored as separate recipes and so will contain ingredients like
				-- `Adamantite/Titanium Bar`
				-- (see [[Alternative crafting ingredients#Substitutable ingredients]])
				local items = splitIngredients(item)
				-- make an {{item}} for each one
				for j, subItem in ipairs(items) do
					items[j] = itemLink(subItem, itemArgs)
				end
				local ingredientStr = table.concat(items, '&hairsp;/&hairsp;')
				-- add amount text at the front
				if amount ~= '1' or forceAmountText then
					ingredientStr = amount .. '&thinsp;' .. ingredientStr
				end
				ingList[i] = ingredientStr
			end
			ingredientsStr = table.concat(ingList, ' + ')

			---Crafting station, formatted with `{{item}}`.
			local stationStr = ''
			if withStation then
				stationStr = compactStation(record.station, getArg('formatStation') or l10n('compact_format_station')) or ''
			end

			---The entire compacted recipe.
			local recipeStr = ingredientsStr
			-- (default: only the ingredients and nothing else)

			-- The three variable parts of the record (that are relevant for this
			-- output) have all been formatted with `{{item}}` above:
			-- * ingredient items  (`ingredientStr`)
			-- * result item  (`resultStr`)
			-- * crafting station  (`stationStr`)
			-- Now they need to be arranged for the output. This arrangement is
			-- localized, because some languages might require tweaks. For example,
			-- `<ingredients> = <result>` might not make much sense in some language,
			-- and `<result> ← <ingredients>` might be better.
			-- Furthermore, the arrangement can be customized entirely via a template
			-- parameter.
			-- The arrangement is provided by a `formatString`, in which there
			-- can be placeholders that will be replaced by the `{{item}}`s mentioned
			-- above.

			---String with placeholders like `@station@` which are to be replaced
			---with the respective `{{item}}`-formatted strings.
			local formatString = getArg('format')
			if not formatString then
				-- no template parameter provided; use the default
				if withStation and stationStr then
					if withResult then
						formatString = l10n('compact_format_recipeWithResultAndStation')
					else
						formatString = l10n('compact_format_recipeWithStation')
					end
				else
					if withResult then
						formatString = l10n('compact_format_recipeWithResult')
					else
						-- `withStation` is `false` (or `stationStr` is empty)
						-- and `withResult` is `false`... so only the `ingredientStr`
						-- remains, which needs no formatting
					end
				end
			end
			if formatString then
				local replacement = {
					['@station@'] = stationStr,
					['@ingredient@'] = ingredientsStr,
					['@result@'] = resultStr,
				}
				-- replace each occurrence of `@station@`, `@ingredient@`, and
				-- `@result@` as defined above; leave all other strings unchanged,
				-- like `@foo@`
				recipeStr = formatString:gsub('@.-@', function(s) return replacement[s] or s end)
			end

			-- Add the version icons to the recipe. How they are arranged in
			-- relation to the rest of the recipe (e.g. in front of the recipe,
			-- behind the recipe, with a certain separator, etc.) is determined
			-- by a localized formatting string (or an entirely custom one, via
			-- a template parameter), similar to the process above.
			if withVersion and (record.version or '') ~= '' then
				local versionFormatString = getArg('formatVersion') or l10n('compact_format_WithVersion')
				local replacement = {
					['@version@'] = versionIcons(record.version),
					['@recipe@'] = recipeStr,
				}
				-- replace each occurrence of `@version@` and `@recipe@` as defined
				-- above; leave all other strings unchanged, like `@foo@`
				recipeStr = versionFormatString:gsub('@.-@', function(s) return replacement[s] or s end)
			end

			-- wrap the compacted recipe in a CSS class
			compactedRecipes[index] = tag('span', {class = 'recipe'}, recipeStr)
		end

		local sep = getArg('sep') or getArg('seperator') or l10n('compact_default_sep')
		if sep == 'null' or sep == 'nil' then
			sep = ''
		end

		-- combine all the compacted recipes
		return tag('span', {class = 'recipes extract compact'}, table.concat(compactedRecipes, sep))
	end  -- handles.compact

	---Format the crafting ingredient items with `{{item}}`.
	---@return string
	handles.ingredients = function()
		---Whether to display the `record.version` on each record.
		local withVersion = not getBoolArg('noversion', false)
		---Whether to display exclusivity icons on each ingredient's `{{item}}`.
		local withVersionIcons = not getBoolArg('noversionicon', false)

		---Arguments for the `{{item}}` call used for formatting each ingredient item.
		local itemArgs = {}
		if not withVersionIcons then
			itemArgs.icons = 'n'
		end

		---All ingredient possibilities, with each one having all its ingredients
		---formatted with `{{item}}` each, and each one wrapped in
		---`<div class="ingredients"></div>`.
		local ingredientStrs = {}
		-- iterate over the rows (records) of the cargo query result, format each
		-- one, and put it in the `rows` list
		for i, record in ipairs(result) do
			local ingredientStr = ingredientsCell(record.args, itemArgs)
			-- add version icons
			if withVersion and (record.version or '') ~= '' then
				local formatString = getArg('formatVersion') or l10n('ingredients_format_WithVersion')
				local replacement = {
					['@version@'] = versionIcons(record.version),
					['@ingredient@'] = ingredientStr,
				}
				-- replace each occurrence of `@version@` and `@ingredient@` as
				-- defined above; leave all other strings unchanged, like `@foo@`
				ingredientStr = formatString:gsub('@.-@', function(s) return replacement[s] or s end)
			end
			ingredientStrs[i] = tag('div', {class = 'ingredients'}, ingredientStr)
		end

		local sep = getArg('sep') or getArg('seperator') or l10n('ingredients_default_sep')
		if sep == 'null' or sep == 'nil' then
			sep = ''
		end

		-- combine all the ingredient possibilities
		return tag('div', { class = 'recipes extract ingredients' }, table.concat(ingredientStrs, sep))
	end  -- handles.ingredients

	---Format the crafting station with `{{item}}`.
	---@return string
	handles.station = function()
		---The first row (record) of the cargo query result. All other rows are
		---ignored.
		local record = result[1]
		if not record then
			-- the cargo query had no result
			return ''
		end
		return tag('span', { class = 'recipes extract staion' }, stationCell(record.station, true))
	end  -- handles.station

	---Format the crafting result item with `{{item}}`.
	---@return string
	handles.result = function()
		---The first row (record) of the cargo query result. All other rows are
		---ignored.
		local record = result[1]
		if not record then
			-- the cargo query had no result
			return ''
		end

		---Whether to add the item ID to the result `{{item}}`.
		local showResultId = getBoolArg('showresultid', false)
		---Whether to link the result `{{item}}`.
		local withResultLink = getBoolArg('link', true)
		---The amount text will normally not be displayed if it is '1'; this
		---parameter forces it even if it is '1'.
		local forceAmountText = getBoolArg('full', false)
		---Whether to display the `record.resulttext` on each record.
		local withNote = not getBoolArg('nonote', false)
		---Whether to display the `record.version` on each record.
		local withVersion = not getBoolArg('noversion', false)

		---Arguments for the `{{item}}` call used for formatting the result item.
		local itemArgs = { nolink = not withResultLink, class = 'multi-line' }
		if showResultId then
			itemArgs.id = record.resultid
		end
		if record.resultimage then
			itemArgs.image = record.resultimage
		end
		local resultStr = itemLink(record.result, itemArgs)

		-- add amount text
		if record.amount ~= '1' or forceAmountText then
			---String with placeholders `@result@` and/or `@amount@` which are
			---to be replaced with the respective data.
			local formatString = getArg('format') or l10n('result_format_WithAmount')
			local replacement = {
				['@result@'] = resultStr,
				['@amount@'] = record.amount,
			}
			-- replace each occurrence of `@result@` and `@amount@` as defined
			-- above; leave all other strings unchanged, like `@foo@`
			resultStr = formatString:gsub('@.-@', function(s) return replacement[s] or s end)
		end

		-- add note text
		local resultnote = record.resulttext or ''
		if withNote and resultnote ~= '' then
			local note_l10n = l10n('result_note') or {}
			resultnote = note_l10n[resultnote] or resultnote
			resultStr = resultStr .. tag('span', { class = 'result-note note-text small' }, resultnote)
		end

		-- add version icons
		if withVersion and (record.version or '') ~= '' then
			---String with placeholders `@result@` and/or `@version@` which are
			---to be replaced with the respective data.
			local formatString = getArg('formatVersion') or l10n('result_format_WithVersion')
			local replacement = {
				['@result@'] = resultStr,
				['@version@'] = versionIcons(record.version),
			}
			-- replace each occurrence of `@result@` and `@version@` as defined
			-- above; leave all other strings unchanged, like `@foo@`
			resultStr = formatString:gsub('@.-@', function(s) return replacement[s] or s end)
		end

		return tag('span', { class = 'recipes extract result' }, resultStr)
	end  -- handles.result

	---Format the crafting result item amount.
	---@return string
	handles.amount = function()
		---The first row (record) of the cargo query result. All other rows are
		---ignored.
		local record = result[1]
		if not record then
			-- the cargo query had no result
			return ''
		end
		return tag('span', { class = 'recipes extract amount' }, record.amount)
	end  -- handles.amount

	---Add the buy prices of all ingredient items (multiplied by their amounts).
	---@return string
	handles['ingredients-buy'] = function()
		---The first row (record) of the cargo query result. All other rows are
		---ignored.
		local record = result[1]
		if not record then
			-- the cargo query had no result
			return ''
		end
		---Combined buy price of all ingredient items.
		local value = 0
		for _, v in ipairs(explode('^', record.ings)) do
			local item, amount = v:match('^¦(.-)¦(.-)$')
			local itemId = tonumber(itemNameData('itemIdFromName', item)) or 0
			local itemValue = getItemStat(itemId, 'value')
			value = value + itemValue * amount
		end
		return tostring(value)
	end  -- handles['ingredients-buy']

	---Add the sell values of all ingredient items (multiplied by their amounts).
	---@return string
	handles['ingredients-sell'] = function()
		---The first row (record) of the cargo query result. All other rows are
		---ignored.
		local record = result[1]
		if not record then
			-- the cargo query had no result
			return ''
		end
		---Combined sell value of all ingredient items.
		local value = 0
		for _, v in ipairs(explode('^', record.ings)) do
			local item, amount = v:match('^¦(.-)¦(.-)$')
			local itemId = tonumber(itemNameData('itemIdFromName', item)) or 0
			local itemValue = math.floor(getItemStat(itemId, 'value') / 5)
			value = value + itemValue * amount
		end
		return tostring(value)
	end  -- handles['ingredients-sell']

	-- function name aliases:
	handles.resultamount = handles.amount

	-- display the result using the specified `handles` function ("mode")
	local mode = getArg('mode') or 'compact'
	if handles[mode] then
		return (handles[mode])()
	else
		return currentFrame:expandTemplate{ title = 'error', args = {
			'Recipes/extract: Invalid mode',
			from = 'Recipes',
			inline = 'y'
		}}
	end
end  -- p.extract

---For `{{recipes/count}}`: query the cargo table and return the number of rows.
---@param frame table Interface to the parser (`mw.frame`)
---@return integer
p.count = function(frame)
	local args = frame:getParent().args
	local where = trim(args['where'] or '')
	if where == '' then
		where = makeConstraints(args)
	end

	-- if there are no constraints, then there can never be any results
	if where == '' then
		return 0
	end

	-- perform the cargo query
	-- since we must use `GROUP BY` to eliminate duplicates, we can not use
	-- `COUNT()` to get the number of rows directly; and `COUNT(DISTINCT ...)`
	-- will also not yield the correct result due to the `HOLDS` keyword
	local result = mw.ext.cargo.query('Recipes', 'resultid', {
		where = where,
		groupBy = 'resultid, result, amount, ings, version',
		limit = 2000,
	})

	return #result  -- number of rows
end  -- p.count

---For `{{recipes/exist}}`: query the cargo table and return whether it yields any rows at all.
---@param frame table Interface to the parser (`mw.frame`)
---@return ''|'yes' exists Blank if no row
p.exist = function(frame)
	local args = frame:getParent().args
	local where = trim(args['where'] or '')
	if where == '' then
		where = makeConstraints(args)
	end

	-- if there are no constraints, then there can never be any results
	if where == '' then
		return ''
	end

	-- perform the cargo query
	local result = mw.ext.cargo.query('Recipes', 'result', {
		where = where,
		limit = 1,  -- this is enough because we only want to see if there are any rows at all
	})

	return result[1] and 'yes' or ''
end  -- p.exist

return p