Module:Resource
Appearance
Documentation for this module may be created at Module:Resource/doc
-- ================================================================================
-- Module dependencies
-- ================================================================================
local getArgs = require('Module:Arguments').getArgs
local cargo = mw.ext.cargo -- for cargo queries if needed
local tbl = require("Module:Table utilities")
local cfmt = require("Module:Cargo format utilities")
local out = require("Module:Wiki output utilities")
local html = mw.html.create()
local ibf = require("Module:Infobox_functions")
-- JSON
local json = require("Module:JSON_pipeline")
local lineage_utils = require("Module:Lineage map utilities")
-- ================================================================================
-- Main table
-- ================================================================================
local p = {}
-- ========================================
-- Get subresource links for a parent resource
-- ========================================
function p.get_links(frame)
local args = getArgs(frame)
-- ---------------------------------------------------------------------
-- Resolve the lookup title.
-- Priority: explicit filter_value arg, else the current page's title.
-- ---------------------------------------------------------------------
local raw_title = mw.title.getCurrentTitle().text
if args.filter_value then
raw_title = args.filter_value
end
-- Trim stray whitespace. filter_value is frequently hand-typed or template-
-- concatenated, so leading/trailing spaces sneak in and silently break "=".
raw_title = mw.text.trim(raw_title or "")
if raw_title == "" then
return "== Resource links ==\nError: empty page title.\n"
end
-- ---------------------------------------------------------------------
-- Typographic-quote handling — the reason exact matches were failing.
-- MediaWiki keeps the exact glyph a page was created with, so titles often
-- carry a curly apostrophe ’ (U+2019, the comma-shaped one) while a typed
-- filter_value uses the straight ' (U+0027). Byte-for-byte they differ, so
-- `Name = '...'` returns zero rows.
--
-- We CAN'T fix this in SQL here: Cargo's function allowlist blocks REPLACE(),
-- so we can't fold the stored column. Instead we build BOTH glyph forms of
-- our title and match either one. Covers whichever form landed in Cargo.
-- Lua 5.1 (Scribunto) has no \u escapes, so build the chars from codepoints.
-- ---------------------------------------------------------------------
local RSQUO = mw.ustring.char(0x2019) -- ’ right single quote
local LSQUO = mw.ustring.char(0x2018) -- ‘ left single quote
-- Fold any curly single quotes to straight -> canonical base form.
local function to_straight(s)
s = mw.ustring.gsub(s, RSQUO, "'")
s = mw.ustring.gsub(s, LSQUO, "'")
return s
end
-- Escape single quotes for a SQL string literal (once — the old code did it
-- twice, quadrupling quotes on real matches).
local function sql_escape(s)
return (s:gsub("'", "''"))
end
local display_title = raw_title -- original glyphs, for user-facing errors
local base_straight = to_straight(raw_title) -- all single quotes -> '
local base_curly = mw.ustring.gsub(base_straight, "'", RSQUO) -- all -> ’
local esc_straight = sql_escape(base_straight)
local esc_curly = sql_escape(base_curly) -- no straight quotes remain, but escape defensively
local esc_original = sql_escape(raw_title) -- verbatim glyphs, untouched
-- Reusable OR-clause that matches the given column against any of three forms:
-- all-straight, all-right-curly, or the title exactly as typed/stored. The
-- first two assume a *repeated* quote glyph (like an apostrophe: O'Brien /
-- O’Brien). Titles that use a genuine open/close quote PAIR (e.g. scare quotes
-- like ‘horrific’) have mismatched glyphs that neither folded form can
-- reconstruct, so the verbatim original is needed to catch those.
-- Parenthesized so it stays correct if you ever AND another condition on.
local function name_clause(col)
return string.format("(%s = '%s' OR %s = '%s' OR %s = '%s')",
col, esc_straight, col, esc_curly, col, esc_original)
end
-- Guard: Cargo extension must be present, or every query below silently nils.
if not (mw.ext and mw.ext.cargo and mw.ext.cargo.query) then
return "== Resource links ==\nError: Cargo extension unavailable.\n"
end
-- ---------------------------------------------------------------------
-- Parent row.
-- ---------------------------------------------------------------------
local res = mw.ext.cargo.query(
"Resource",
"Name, Resource_link",
{
where = name_clause("Name"),
limit = 1
}
)
if not res or not res[1] then
return "== Resource links ==\nError: No resource row for '" .. display_title .. "'.\n"
end
local parent_link = res[1].Resource_link or ""
-- ---------------------------------------------------------------------
-- All subresources via LIMIT/OFFSET pagination.
-- ---------------------------------------------------------------------
local limit_per_page = 1000 -- stay safely under common non-bot query caps
local max_pages = 100 -- hard stop (=100k rows): kills a runaway loop
-- if the backend ever keeps returning full pages
local offset = 0
local pages = 0
local sub = {}
while pages < max_pages do
local batch = mw.ext.cargo.query(
"Subresource",
"Name, Parent_resource, Subresource_link, File_name",
{
where = name_clause("Parent_resource"),
orderBy = "Name ASC, _pageID ASC", -- stable ordering for pagination
limit = limit_per_page,
offset = offset
}
)
if not batch or #batch == 0 then
break
end
for i = 1, #batch do
if type(batch[i]) == "table" then -- skip anything malformed
sub[#sub + 1] = batch[i]
end
end
if #batch < limit_per_page then
break -- got the last (partial) page
end
offset = offset + limit_per_page
pages = pages + 1
end
-- Dedup after all pages are collected, before processing. Guarded so a missing
-- util doesn't blow up the whole render.
if tbl and type(tbl.deduplicate_rows) == "function" then
sub = tbl.deduplicate_rows(sub, {"Name"})
end
-- ---------------------------------------------------------------------
-- Build output.
-- ---------------------------------------------------------------------
local lines = {}
if #sub > 0 then
-- collect + sort: numeric by trailing digits, then case-insensitive title
local rows = {}
for _, row in ipairs(sub) do
local sub_name = row.Name or ""
local sub_link = row.Subresource_link or ""
if sub_name ~= "" and sub_link ~= "" then
local num = tonumber(sub_name:match("(%d+)$")) or math.huge
table.insert(rows, { name = sub_name, link = sub_link, num = num })
end
end
table.sort(rows, function(a, b)
if a.num ~= b.num then
return a.num < b.num
end
return mw.ustring.lower(a.name) < mw.ustring.lower(b.name)
end)
for _, r in ipairs(rows) do
table.insert(lines, string.format("* [%s %s]", r.link, r.name))
end
else
-- No subresources: fall back to a single link to the parent resource.
if parent_link ~= "" then
table.insert(lines, string.format("[%s %s]", parent_link, "Link to resource"))
end
end
local header = "== Resource links ==\n"
if #lines == 0 then
return header .. "No links found.\n"
end
return header .. table.concat(lines, "\n") .. "\n"
end
-- ========================================
-- Get resources for species
-- ========================================
--[[
This function takes advantage of the new `Species resource lookup` JSON data set to massively speed up querying resources for species on the wiki.
--]]
function p.get_resource_for_species(frame)
local args = getArgs(frame)
-- ========================================
-- Resolve the focal taxon
-- ========================================
local otu_id, suborder, page_title, err = json.resolve_focal_taxon(frame)
if err then
local msg =
"Could not resolve focal taxon.\n" ..
"Check that the OTU ID is correct for this species in the database."
return out.warning_box("⚠️ Infobox error", msg)
end
----------------------------------------------------------------
-- Pull raw JSON data (Cargo-like rows) for this species
----------------------------------------------------------------
local base_page = "JSON:Species resource lookup"
local results = json.fetch_by_otu_id("JSON:Species resource lookup", otu_id)
if not results or #results == 0 then
return "" -- or "''No resources found.''"
end
----------------------------------------------------------------
-- 3. Schema-driven formatting (values → final wikitext strings)
-- After this step, Descriptive_keyword is a nicely formatted
-- string like "Management, Species identification, Agriculturist guide"
----------------------------------------------------------------
local formatted = cfmt.format_results_with_schema(results, base_page)
----------------------------------------------------------------
-- 4. Apply filters on the *formatted* rows
----------------------------------------------------------------
local function normalize(s)
return mw.ustring.lower(mw.text.trim(s))
end
-- Category is a list_of_string field (Airtable is mid-transition between an old and new
-- vocabulary, so a single resource can carry multiple category terms, e.g. "Guide~~Reference
-- works"). `formatted` has already joined those into a display string like "Guide and
-- Reference works", which isn't safe to match against — so pull the *raw*, un-joined value
-- from `results` (same row order as `formatted`) and check membership instead of equality.
local function raw_value_list(raw_value)
local list = {}
if not raw_value then return list end
if type(raw_value) == "table" then
for _, v in ipairs(raw_value) do
table.insert(list, normalize(tostring(v)))
end
else
for _, v in ipairs(mw.text.split(tostring(raw_value), "~~")) do
local t = mw.text.trim(v)
if t ~= "" then table.insert(list, normalize(t)) end
end
end
return list
end
local function list_contains(list, target)
for _, v in ipairs(list) do
if v == target then return true end
end
return false
end
local cat = args.Category or args.category
local kw = args.Descriptive_keyword or args.keyword
local not_cat = args.not_Category or args.not_category
local not_kw = args.not_Descriptive_keyword or args.not_keyword
local cat_norm = cat and normalize(cat) or nil
local kw_norm = kw and normalize(kw) or nil
local not_cat_norm = not_cat and normalize(not_cat) or nil
private_not_kw_norm = not_kw and normalize(not_kw) or nil
local filtered = {}
for i, rec in ipairs(formatted) do
local ok = true
-- Pull & normalize values from the record
local raw_rec = results[i]
local rec_cat_list = raw_value_list(raw_rec and raw_rec.Category)
local rec_kw = rec.Descriptive_keyword and normalize(rec.Descriptive_keyword) or ""
------------------------------------------------------------
-- INCLUDE FILTERS
------------------------------------------------------------
-- Category must match (any one of its terms) if category filter is given
if ok and cat_norm then
if not list_contains(rec_cat_list, cat_norm) then
ok = false
end
end
-- Keyword string must contain the filter substring if given
if ok and kw_norm then
if not rec_kw:find(kw_norm, 1, true) then
ok = false
end
end
------------------------------------------------------------
-- EXCLUDE FILTERS (NOT)
------------------------------------------------------------
-- Category must NOT match if a not-category filter is given
if ok and not_cat_norm then
if list_contains(rec_cat_list, not_cat_norm) then
ok = false
end
end
-- Keyword string must NOT contain filter substring
if ok and not_kw_norm then
if rec_kw:find(not_kw_norm, 1, true) then
ok = false
end
end
if ok then
table.insert(filtered, rec)
end
end
local final_results
if cat_norm or kw_norm or not_cat_norm or not_kw_norm then
final_results = filtered
else
final_results = formatted -- no filters at all
end
if not final_results or #final_results == 0 then
return ""
end
----------------------------------------------------------------
-- 5. Choose which fields to display and their labels
----------------------------------------------------------------
local display_fields = {
"Title",
"Author",
"Year_published",
"Geographic_purview",
"Resource_link",
}
local column_label_map = {
Author = "Author(s)",
Year_published = "Year",
Geographic_purview = "Geographic purview",
Resource_link = "URL",
}
----------------------------------------------------------------
-- 6. Collapse + cutoffs
----------------------------------------------------------------
local row_cutoff = tonumber(args.row_cutoff or 10)
local character_cutoff = tonumber(args.character_cutoff or 500)
local collapse = (#final_results > row_cutoff)
----------------------------------------------------------------
-- 7. Render table
----------------------------------------------------------------
local table_wikitext = out.generate_wiki_table_simple(
final_results,
display_fields,
column_label_map,
collapse,
character_cutoff,
row_cutoff
)
return table_wikitext
end
-- ========================================
-- Get resource author attribution and resource descriptions
-- ========================================
-- Despite the name, this function creates an author attribution and resource description section
function p.get_description(frame)
local args = getArgs(frame)
local title = mw.title.getCurrentTitle().text
-- Escape single quotes by replacing them with two single quotes.
-- Double quotes might not need escaping depending on the database's handling of string literals.
title = title:gsub("'", "''")
-- Run cargo query on resources so it can be accessed from functions below
local tables = "Resource"
local fields = "Name, Author, Resource_description, Resource_link, Subresource, File_name"
local where_clause = string.format("Name = '%s'", title)
local cargo_args = {
where = where_clause
}
local cargo_results = cargo.query(tables, fields, cargo_args)
local output = ""
if cargo_results and #cargo_results > 0 then
local description = cargo_results[1].Resource_description
local author = cfmt.parse_arguments(cargo_results[1].Author, "page")
if description ~= nil and description ~= "" then
local header_author = "== Source author(s) ==\n"
local header_desc = "== Resource description ==\n"
output = header_author .. author .. "\n" .. header_desc .. description .. "\n"
else
output = "== Resource description ==\nNo description found.\n"
end
else
output = "== Resource description ==\nError: No results returned for the given title.\n"
end
return output
end
return p