Jump to content

Module:Reference

From MartinusWiki

Documentation for this module may be created at Module:Reference/doc

-- English Martinus Wiki reference renderer.
--
-- Stage 1 source artifact.  This module deliberately contains the routing and
-- validation policy in one small, data-driven module so it can be copied to
-- Module:Reference after Scribunto is enabled.  It does not call ParserFunctions.
-- The public entry points are p.ref (the template wrapper) and p.render (a
-- direct module entry point); all state is local to an invocation.

local p = {}

local EN_TTT = "https://www.martinus.dk/en/online-library/ttt/index.php"
local DA_TTT = "https://www.martinus.dk/da/onlinebibliotek/dtt/index.php"
local EN_SYMBOLS = "https://www.martinus.dk/en/online-library/symbols/index.php"
local DA_LECTURES = "https://www.martinus.dk/da/onlinebibliotek/foredrag/index.php"
local ERROR_CATEGORY = "[[Category:Pages with Martinus reference errors]]"

-- The section ranges are the ranges shown by the official online library.  LB6
-- and LB7 are retained for compatibility, but their English flag is false until
-- an English text is confirmed by the Institute.
local BOOKS = {
    LB1 = { id = 51, title = "Livets Bog 1", first = 1, last = 288, english = true },
    LB2 = { id = 52, title = "Livets Bog 2", first = 289, last = 638, english = true },
    LB3 = { id = 53, title = "Livets Bog 3", first = 639, last = 1052, english = true },
    LB4 = { id = 54, title = "Livets Bog 4", first = 1053, last = 1590, english = true },
    LB5 = { id = 55, title = "Livets Bog 5", first = 1591, last = 1938, english = true },
    LB6 = { id = 56, title = "Livets Bog 6", first = 1939, last = 2395, english = false },
    LB7 = { id = 57, title = "Livets Bog 7", first = 2396, last = 2664, english = false },
}

local TEWP = {
    [1] = { id = 61, title = "The Eternal World Picture 1", english = true },
    [2] = { id = 62, title = "The Eternal World Picture 2", english = true },
    [3] = { id = 63, title = "The Eternal World Picture 3", english = true },
    [4] = { id = 64, title = "The Eternal World Picture 4", english = true },
    [5] = { id = 65, title = "The Eternal World Picture 5", english = false },
    [6] = { id = 66, title = "The Eternal World Picture 6", english = false },
}

local ALLOWED = {
    ["1"] = true, ["2"] = true, ["3"] = true, ["4"] = true,
    ["5"] = true, ["6"] = true, ["7"] = true, ["8"] = true,
    ["9"] = true, ["10"] = true,
    work = true, source = true, section = true, subsection = true,
    kind = true,
}

local function trim(value)
    if value == nil then
        return nil
    end
    value = tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
    if value == "" then
        return nil
    end
    return value
end

local function text(value)
    -- All values that reach this helper have already passed a strict grammar,
    -- except Kosmos text.  mw.text.encode prevents that free text becoming HTML.
    return mw.text.encode(tostring(value))
end

local function error_message(code, detail)
    local suffix = detail and (" (" .. text(detail) .. ")") or ""
    return '<span class="error reference-error">Reference error: ' .. code .. suffix .. "</span>" .. ERROR_CATEGORY
end

local function link(url, label)
    return "[" .. url .. " " .. text(label) .. "]"
end

local function is_integer(value)
    return value ~= nil and value:match("^[1-9]%d*$") ~= nil
end

local function is_decimal_section(value)
    return value ~= nil and value:match("^[1-9]%d*%.[1-9]%d*$") ~= nil
end

local function source_suffix(book)
    if book.english then
        return "English online text"
    end
    return "Danish original; no English translation confirmed"
end

local function book_url(book, section, subsection)
    local base = book.english and EN_TTT or DA_TTT
    local mode = section and (book.english and "opslag" or "forside") or "forside"
    local url = base .. "?bog=" .. book.id .. "&mode=" .. mode
    if section then
        url = url .. "&stk=" .. section
    end
    if subsection then
        url = url .. "&pkt=" .. subsection
    end
    return url
end

local function book_reference(book, section, subsection)
    if not book then
        return nil
    end
    if section and not is_integer(section) then
        return nil, "INVALID_SECTION"
    end
    if subsection and not is_integer(subsection) then
        return nil, "INVALID_SUBSECTION"
    end
    if subsection and not section then
        return nil, "MISSING_SECTION"
    end
    if section then
        local number = tonumber(section)
        if number < book.first or number > book.last then
            return nil, "SECTION_OUT_OF_RANGE"
        end
    end
    local label = book.title
    if section then
        label = label .. ", section " .. section
        if subsection then
            label = label .. ", subsection " .. subsection
        end
    else
        label = label .. " (book)"
    end
    local url = book_url(book, section, subsection)
    return link(url, label .. " — " .. source_suffix(book))
end

local function find_book_for_section(section)
    local number = tonumber(section)
    for _, key in ipairs({ "LB1", "LB2", "LB3", "LB4", "LB5", "LB6", "LB7" }) do
        local book = BOOKS[key]
        if number >= book.first and number <= book.last then
            return book
        end
    end
    return nil
end

local function normalise_work(value)
    local upper = value:upper():gsub("%s+", " ")
    local lb = upper:match("^(LB[1-7])$")
    if lb then
        return "LB", tonumber(lb:sub(3))
    end
    local tewp = upper:match("^TEWP%s+([1-6])$")
        or upper:match("^DEV%s+([1-6])$")
        or upper:match("^THE ETERNAL WORLD PICTURE%s+([1-6])$")
    if tewp then
        return "TEWP", tonumber(tewp)
    end
    local short = upper:match("^SHORT BOOK%s+([1-9]%d*)$")
        or upper:match("^SMÅBOG%s+([1-9]%d*)$")
        or upper:match("^SMABOG%s+([1-9]%d*)$")
        or upper:match("^PAMPHLET%s+([1-9]%d*)$")
    if short then
        return "SHORT", tonumber(short)
    end
    return upper
end

local function collect_args(argument_frame)
    local values = {}
    -- p.ref passes the Template:Ref frame explicitly. This is important:
    -- putting #invoke inside literal <ref> markup creates another parser frame
    -- and loses the template arguments. Reading this exact frame also keeps
    -- every argument visible to validation, including unknown names.
    if not argument_frame then
        return nil, "MISSING_PRIMARY_ARGUMENT"
    end
    for key, raw in pairs(argument_frame.args or {}) do
        local name = tostring(key)
        local value = trim(raw)
        if value ~= nil then
            if not ALLOWED[name] then
                return nil, "UNSUPPORTED_ARGUMENT", name
            end
            values[name] = value
        end
    end
    return values
end

local function positional(values)
    local result = {}
    for index = 1, 10 do
        if values[tostring(index)] ~= nil then
            result[index] = values[tostring(index)]
        end
    end
    for index = 2, 10 do
        if result[index] ~= nil and result[index - 1] == nil then
            return nil, "MISSING_PRIMARY_ARGUMENT"
        end
    end
    for index = 4, 10 do
        if result[index] ~= nil then
            return nil, "TOO_MANY_POSITIONAL_ARGUMENTS"
        end
    end
    return result
end

local function render_book(work, section, subsection)
    local family, volume = normalise_work(work)
    if family == "LB" then
        local book = BOOKS["LB" .. volume]
        if not book then
            return nil, "UNKNOWN_VOLUME"
        end
        return book_reference(book, section, subsection)
    end
    if family == "TEWP" then
        local book = TEWP[volume]
        if not book then
            return nil, "UNKNOWN_VOLUME"
        end
        if subsection then
            return nil, "TOO_MANY_SOURCE_ARGUMENTS"
        end
        if not section or not is_decimal_section(section) then
            return nil, "INVALID_SYMBOL_SECTION"
        end
        local base = book.english and EN_TTT or DA_TTT
        local url = base .. "?bog=" .. book.id .. "&mode=opslag&pkt=" .. section:match("%d+$") .. "&stk=" .. section:match("^%d+")
        return link(url, book.title .. ", section " .. section .. " — " .. source_suffix(book))
    end
    if family == "SHORT" then
        if subsection then
            return nil, "TOO_MANY_SOURCE_ARGUMENTS"
        end
        if not section or not is_decimal_section(section) then
            return nil, "INVALID_SHORT_BOOK_SECTION"
        end
        local chapter, passage = section:match("^(%d+)%.(%d+)$")
        local url = DA_TTT .. "?bog=" .. volume .. "&art=" .. chapter .. "&stk=" .. passage
        return link(url, "Short book " .. volume .. ", section " .. section .. " — Danish original; English translation not confirmed")
    end
    return nil, "UNKNOWN_WORK"
end

local function render_special(kind, value)
    local lowered = kind:lower()
    if lowered == "symbol" or lowered == "symbols" then
        if not is_integer(value) or tonumber(value) > 100 then
            return nil, "INVALID_SYMBOL_ID"
        end
        return link(EN_SYMBOLS .. "?symbol=" .. value, "Symbol " .. value .. " — English symbol page")
    end
    if lowered == "article" or lowered == "articles" then
        if not value or value:match("^%d%d%d%d$") == nil then
            return nil, "INVALID_ARTICLE_ID"
        end
        return link(EN_TTT .. "?art=" .. value .. "&mode=artikelopslag", "Article " .. value .. " — English online text if available")
    end
    if lowered == "lecture" or lowered == "lectures" or lowered == "foredrag" then
        if not is_integer(value) then
            return nil, "INVALID_LECTURE_ID"
        end
        return link(DA_LECTURES .. "?nr=" .. value, "Lecture " .. value .. " — Danish original; English translation not confirmed")
    end
    if lowered == "kosmos" then
        if not value then
            return nil, "MISSING_KOSMOS_DESCRIPTION"
        end
        return text("Kosmos: " .. value .. " — Danish original; no direct online-library link confirmed")
    end
    return nil, "UNKNOWN_SOURCE_KIND"
end

local function render_reference(argument_frame)
    local values, collect_error, collect_detail = collect_args(argument_frame)
    if not values then
        return error_message(collect_error, collect_detail)
    end
    local pos, positional_error = positional(values)
    if not pos then
        return error_message(positional_error)
    end

    local work = values.work or values.source
    local section = values.section
    local subsection = values.subsection
    local kind = values.kind

    if values.work and values.source then
        return error_message("DUPLICATE_WORK_ARGUMENTS")
    end
    if values.section and pos[2] then
        return error_message("DUPLICATE_SECTION_ARGUMENT")
    end
    if values.subsection and pos[3] then
        return error_message("DUPLICATE_SUBSECTION_ARGUMENT")
    end

    if kind then
        if pos[2] or pos[3] or work or section or subsection then
            return error_message("MIXED_REFERENCE_FORMS")
        end
        local special_result, special_error = render_special(kind, pos[1] or values["1"])
        if not special_result then
            return error_message(special_error)
        end
        return special_result
    end

    if work and pos[1] then
        return error_message("MIXED_REFERENCE_FORMS")
    end

    if work then
        section = section or pos[2]
        subsection = subsection or pos[3]
    else
        work = pos[1]
        section = section or pos[2]
        subsection = subsection or pos[3]
    end

    if not work then
        return error_message("MISSING_PRIMARY_ARGUMENT")
    end

    if is_integer(work) then
        if section and subsection then
            return error_message("DUPLICATE_SUBSECTION_ARGUMENT")
        end
        if section and not is_integer(section) then
            return error_message("INVALID_SUBSECTION")
        end
        local book = find_book_for_section(work)
        if not book then
            return error_message("SECTION_OUT_OF_RANGE")
        end
        -- For numeric shorthand the second value is the subsection.  Accept
        -- either positional 2 or the named subsection form, but never discard
        -- a third positional value.
        local numeric_subsection = subsection or section
        local result, render_error = book_reference(book, work, numeric_subsection)
        if not result then
            return error_message(render_error)
        end
        return result
    end

    if work:lower() == "e" or work:lower() == "epilogue" then
        if section or subsection then
            return error_message("UNEXPECTED_EPILOGUE_ARGUMENT")
        end
        local book = BOOKS.LB7
        return link(book_url(book, "2665"), "Livets Bog 7, epilogue (section 2665) — Danish original; no English translation confirmed")
    end

    local special_kind = work:lower()
    if special_kind == "symbol" or special_kind == "symbols"
        or special_kind == "article" or special_kind == "articles"
        or special_kind == "lecture" or special_kind == "lectures"
        or special_kind == "foredrag" or special_kind == "kosmos" then
        if subsection then
            return error_message("TOO_MANY_SOURCE_ARGUMENTS")
        end
        local special_result, special_error = render_special(work, section)
        if not special_result then
            return error_message(special_error)
        end
        return special_result
    end

    local result, render_error = render_book(work, section, subsection)
    if not result then
        return error_message(render_error)
    end
    return result
end

function p.render(frame)
    return render_reference(frame)
end

function p.ref(frame)
    local parent = frame and frame:getParent()
    local content = render_reference(parent)
    local has_error_category = content:sub(-#ERROR_CATEGORY) == ERROR_CATEGORY
    if has_error_category then
        -- Category links inside a Cite extension tag are not reliably attached
        -- to the page. Keep the error text in the note, but emit the marker
        -- outside the tag so the maintenance category is authoritative.
        content = content:sub(1, -#ERROR_CATEGORY - 1)
    end
    local reference = frame:extensionTag("ref", content)
    if has_error_category then
        reference = reference .. ERROR_CATEGORY
    end
    return reference
end

return p