Jump to content

Module:Resource

From HopperWiki

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
Cookies help us deliver our services. By using our services, you agree to our use of cookies.