Module:PsalmFunctions
From Psalms: Layer by Layer
Documentation for this module may be created at Module:PsalmFunctions/doc
local p = {}
function p.pageExists(frame)
local title = frame.args[1]
local page = mw.title.new(title)
return page and page.exists
end
function p.highlightAttributesByColorOrder(frame)
local jsonText = frame.args[1]
local fieldsArg = frame.args[2] or ""
local highlightCount = tonumber(frame.args[3]) or 100
-- Parse comma-delimited list into a Lua table
local fields = {}
for field in mw.text.gsplit(fieldsArg, "%s*,%s*") do
table.insert(fields, field)
end
if #fields == 0 then
return "⚠️ No fields provided"
end
-- Decode the JSON safely
local ok, parsed = pcall(mw.text.jsonDecode, jsonText)
if not ok or type(parsed) ~= "table" or #parsed == 0 then
return "⚠️ Invalid or empty JSON: " .. tostring(jsonText)
end
-- Color map for the first 3 fields
local colors = { "red", "orange", "gold" }
-- Build a replacement map: value → colored span
local replacements = {}
for i = 1, math.min(#fields, #colors) do
local field = fields[i]
local color = colors[i]
for _, item in ipairs(parsed) do
local value = item[field]
if type(value) == "string" and value ~= "" then
local coloredSpan = '<span style="color:' .. color .. ';">' .. mw.text.nowiki(value) .. '</span>'
replacements[value] = coloredSpan
end
end
end
-- Apply replacements globally to original jsonText
for search, replacement in pairs(replacements) do
local escaped = mw.ustring.gsub(mw.text.nowiki(search), "([%%%]%^%$%(%)%.%[%]%*%+%-%?])", "%%%1")
jsonText = mw.ustring.gsub(jsonText, escaped, replacement, highlightCount)
end
-- Wrap entire result in light gray
return '<span style="color:lightgray;">' .. jsonText .. '</span>'
end
function p.replaceTextInPageContent(frame)
local pageName = frame.args[1]
local targetText = frame.args[2] or ""
local replacementText = frame.args[3] or ""
if not pageName or pageName == "" then
return "⚠️ No page name provided"
end
local title = mw.title.new(pageName)
if not title then
return "⚠️ Invalid page name: " .. pageName
end
local content = title:getContent()
if not content then
return "⚠️ Could not retrieve content of page: " .. pageName
end
-- Perform the text replacement
local modified = string.gsub(content, targetText, replacementText, 1) -- only replace first occurrence
return modified
end
function p.deduplicate(frame)
local title = frame.args[1]
if not title then
return "❌ No page title provided"
end
-- Try to get and decode the JSON content
local jsonText = mw.title.new(title):getContent()
if not jsonText then
return "❌ Could not load content from " .. title
end
local status, data = pcall(mw.text.jsonDecode, jsonText)
if not status or type(data) ~= "table" then
return "❌ Failed to parse JSON from " .. title
end
-- Scan for duplicates
local seen = {}
local duplicates = {}
for i, item in ipairs(data) do
local wordID = item.WordID
if wordID then
if seen[wordID] then
table.insert(duplicates, wordID)
else
seen[wordID] = true
end
end
end
if #duplicates == 0 then
return "✅ No duplicate WordIDs found."
else
return "⚠️ Duplicate WordIDs: " .. table.concat(duplicates, ", ")
end
end
function p.generateVerseWordIDs(frame)
local n = tonumber(frame.args[1] or "0")
if not n or n < 1 then
return "⚠️ Please provide a positive number of verses."
end
local result = {}
for i = 1, n do
table.insert(result, {
label = tostring(i),
WordID = i .. "-1-1"
})
end
return mw.text.jsonEncode(result)
end
function p.concatJSONFromList(frame)
local list = frame.args[1] or ""
local result = {}
local warnings = {}
-- Split the comma-separated list and trim each item
for title in mw.text.gsplit(list, ",", true) do
title = mw.text.trim(title)
if title ~= "" then
local page = mw.title.new(title)
if page and page.exists then
local content = page:getContent()
local success, parsed = pcall(mw.text.jsonDecode, content or "[]")
if success and type(parsed) == "table" then
for _, item in ipairs(parsed) do
table.insert(result, item)
end
else
table.insert(warnings, "⚠️ Invalid JSON on page: " .. title)
end
else
table.insert(warnings, "⚠️ Page not found: " .. title)
end
end
end
local encoded = mw.text.jsonEncode(result)
if #warnings > 0 then
return encoded .. "\n\n" .. table.concat(warnings, "\n")
else
return encoded
end
end
function p.pageVerseRange(frame)
local fullTitle = mw.title.getCurrentTitle().fullText
if fullTitle:find("%-") then
-- Extract the last part after the last "/"
local parts = mw.text.split(fullTitle, "/")
local last = parts[#parts]
-- Remove any file extension (like ".json")
local range = last:gsub("%.json$", "")
-- Return full verse range (e.g., "1-3")
return range
else
return ""
end
end
function p.fixJsonObjects(frame)
local input = frame.args[1]
-- Insert commas between } {
local fixed = input:gsub("}%s*{", "},\n{")
-- Wrap in square brackets to make it a proper JSON array
local jsonArray = "[" .. fixed .. "]"
-- Optionally decode it into a Lua table:
local success, decoded = pcall(mw.text.jsonDecode, jsonArray)
if success then
return mw.text.jsonEncode(decoded) -- Re-encode prettified, or return decoded
else
return "JSON parse error: " .. tostring(decoded)
end
end
function p.getFirstNumber(frame)
return string.match(frame.args[1] or "", "^(%d+)")
end
function p.getLastNumber(frame)
local input = mw.text.trim(frame.args[1] or "")
local a, b = string.match(input, "^(%d+)%s*%-%s*(%d+)$")
if b then
return b
else
return string.match(input, "(%d+)$")
end
end
local function pad2(n)
n = tonumber(n) or 0
return n < 10 and "0" .. n or tostring(n)
end
function p.getHeatmapJSON(frame)
local raw = frame.args[1] or ""
local allIDs = {}
local counts = {}
-- Step 1: Collect all full word IDs from the input
for wordID in raw:gmatch('"([^"]+)":"[^"]+"') do
if wordID:match("^id%-") then
allIDs[wordID] = true
end
end
-- Step 2: Apply label counts
for key, label in raw:gmatch('"([^"]+)":"([^"]+)"') do
if key:match("^id%-") then
-- Exact word ID
counts[key] = (counts[key] or 0) + 1
else
-- Line-level shorthand like "3c"
local prefix = "id%-" .. key .. "%-"
local lineKey = "id-" .. key .. "-*"
counts[lineKey] = (counts[lineKey] or 0) + 1 -- always increment once per label
for fullID in pairs(allIDs) do
if fullID:match("^" .. prefix) then
counts[fullID] = (counts[fullID] or 0) + 1
end
end
end
end
-- Step 3: Build result
local result = {}
for wordID, count in pairs(counts) do
result[wordID] = "heatmap-" .. pad2(count)
end
return mw.text.jsonEncode(result)
end
function p.last(frame)
local str = frame.args[1] or ""
if str == "" then
return "" -- or return a default like "0"
end
local delimiter = frame.args[2] or ","
local parts = mw.text.split(str, delimiter)
return mw.text.trim(parts[#parts] or "")
end
function p.getPsalmNumberOld(frame)
-- Get the current page name
local pageName = mw.title.getCurrentTitle().text
-- Check if "Psalm" exists in the page name
if not pageName:find("Psalm") then
return "19" -- Return 1 if "Psalm" is not found
end
-- Extract the part of the page name after "Psalm "
local psalmNumber = pageName:match("Psalm%s*(%d+)")
return psalmNumber or "19"
end
function p.getPsalmNumber(frame)
local page = mw.title.getCurrentTitle()
local pageName = page.text
-- Try extracting from page name
local psalmNumber = nil
if pageName:find("Psalm") then
psalmNumber = pageName:match("Psalm%s*(%d+)")
end
-- If nothing found, look up semantic property "Chapter"
if not psalmNumber or psalmNumber == "" then
local query = string.format("{{#show: %s | ?Chapter }}", page.fullText)
psalmNumber = frame:preprocess(query)
end
-- Default fallback
if not psalmNumber or psalmNumber == "" then
psalmNumber = "19"
end
return psalmNumber
end
function p.encode(frame)
local text = frame.args[1] or ""
text = mw.text.encode(text) -- Converts <, >, &, etc. to HTML entities
return text
end
function p.encode_brackets_pipes(frame)
local text = frame.args[1] or ""
-- Replace only brackets and pipes with URL encoding
text = text:gsub("%[", "%%5B") -- Encode '[' as '%5B'
:gsub("%]", "%%5D") -- Encode ']' as '%5D'
:gsub("%|", "%%7C") -- Encode '|' as '%7C'
:gsub("%=", "%%3D") -- Encode '=' as '%3D'
return text
end
function p.decode_brackets_pipes(input)
local text
if type(input) == "table" and input.args then
-- called from #invoke
text = input.args[1]
elseif type(input) == "table" and input[1] then
-- called directly with { "some text" }
text = input[1]
elseif type(input) == "string" then
-- called directly with a string
text = input
else
text = ""
end
-- Replace only brackets and pipes with URL encoding
text = text:gsub("%%5B", "[")
:gsub("%%5D", "]")
:gsub("%%7C", "|")
:gsub("%%3C", "<")
:gsub("%%3E", ">")
:gsub("%%3D", "=")
return text
end
function p.escape_equals(frame)
local text = frame.args[1] or ""
-- Replace only brackets and pipes with URL encoding
text = text:gsub("=", "{{=}}}}")
return text
end
function p.escapePipes(frame)
local text = frame.args[1] or ""
-- Replace only pipes with URL encoding
text = text:gsub("%|", "{{!}}")
return text
end
function p.url_encode(frame)
local text = frame.args[1] or ""
text = text:gsub("\n", "\r\n") -- Convert newlines to CRLF
text = text:gsub("([^%w%-%.%_%~])", function(c)
return string.format("%%%02X", string.byte(c)) -- Convert special characters to %XX format
end)
return text
end
function p.url_decode(frame)
local text = frame.args[1] or ""
text = text:gsub("%%(%x%x)", function(hex)
return string.char(tonumber(hex, 16)) -- Convert hex code to character
end)
return text
end
function p.makeID(frame)
local str = frame.args[1] or ""
-- Ensure we only return the modified string, avoiding unintended second return values
str = str:gsub("%s+", "-") -- Replace spaces with hyphens
:gsub("%.", "") -- Remove periods
:gsub(",", "-") -- Replace commas with hyphens
return str
end
function p.validPageName(frame)
local str = frame.args[1] or ""
-- Ensure we only return the modified string, avoiding unintended second return values
str = str:gsub("%s+", "_")
return str
end
function p.listSubpages(frame)
local prefix = frame.args[1] or mw.title.getCurrentTitle().prefixedText
local namespace = mw.title.getCurrentTitle().namespace
local limit = tonumber(frame.args[2]) or 50 -- Set a reasonable limit (default: 50)
-- Call the MediaWiki API to get subpages
local api = mw.ext.data and mw.ext.data.getAllPageNames
if not api then
return "Error: API function not available. Check your MediaWiki configuration."
end
local pages = api({ namespace = namespace, prefix = prefix .. "/", limit = limit })
if not pages or #pages == 0 then
return "No subpages found for: " .. prefix
end
-- Format the list of subpages
local result = {}
for _, page in ipairs(pages) do
table.insert(result, "[[" .. page .. "]]")
end
return table.concat(result, "<br>")
end
-- used for VxV Notes tables
local function splitLines(text)
local lines = {}
for line in mw.text.gsplit(text, "<br%s*/?>") do
table.insert(lines, mw.text.trim(line))
end
return lines
end
function p.buildVerseTable(frame)
local args = frame.args
local verse = mw.text.trim(args[1] or "?")
local hebrewText = args[2]
local englishText = args[3]
local hebrewLines = splitLines(hebrewText or "")
local englishLines = splitLines(englishText or "")
local output = {}
table.insert(output, '{| class="wikitable"\n|-\n! v. !! Hebrew !! Close-but-clear')
for i, heb in ipairs(hebrewLines) do
local partLetter = string.char(96 + i) -- 1 = a, 2 = b, etc.
local eng = englishLines[i] or ''
table.insert(output, string.format('|-\n|%s%s ||%s ||%s', verse, partLetter, heb, eng))
end
table.insert(output, '|}')
return table.concat(output, '\n')
end
function p.random(frame)
local min = tonumber(frame.args[1]) or 1
local max = tonumber(frame.args[2]) or 1000
return math.random(min, max)
end
function p.TabbedContentHeaders(frame)
local args = frame.args
local names = mw.text.split(args[1] or "", ",")
local parentID = args[2] or "parentID"
local result = {}
for i, name in ipairs(names) do
table.insert(result, frame:expandTemplate{
title = "TabbedContent/Header",
args = {
ParentID = parentID,
ID = tostring(i),
Title = mw.text.trim(name),
Style = "header"
}
})
end
return table.concat(result, "\n")
end
function p.TabbedContentSubPages(frame)
local args = frame.args
local names = args.PageNames
if type(names) == "string" then
names = mw.text.split(names, ",")
else
names = mw.text.split(args[1] or "", ",")
end
local parentID = args.ParentID or (args[2] or "parentID"):gsub("_$", "")
local currentPage = args.ContentRoot or mw.title.getCurrentTitle().fullText
local result = {}
for i, name in ipairs(names) do
local subpageName = mw.text.trim(name)
local fullPageName = currentPage .. "/" .. subpageName
local transclusion = frame:preprocess('{{:' .. fullPageName .. '}}')
local divEnd = '</div>'
table.insert(result, frame:expandTemplate{
title = "TabbedContent/Header",
args = {
ParentID = parentID,
ID = tostring(i),
Title = mw.text.trim(name),
Style = "body"
}
})
table.insert(result, transclusion)
table.insert(result, divEnd)
end
return table.concat(result, "\n")
end
function p.TabbedContentAnnotations(frame)
local args = frame.args
local names = mw.text.split(args[1] or "", ",")
local parentID = mw.text.trim(args[2] or "parentID"):gsub("_$", "")
local chapter = mw.text.trim(args[3] or "19")
local addNewLink = args[4] or "" -- Optional: override the add-new page target
local result = {}
for i, name in ipairs(names) do
local subpageName = mw.text.trim(name)
local divID = parentID .. "-" .. tostring(i)
local divClass = "tab-pane fade" .. (i == 1 and " show active" or "")
local labelFor = divID .. "Label"
local renderedTitle = string.format("Psalm %s/Overlays/%s/Rendered", chapter, subpageName)
local titleObj = mw.title.new(renderedTitle)
local bodyContent = ""
if titleObj and titleObj.exists then
bodyContent = string.format("{{:%s}}", renderedTitle)
else
-- bodyContent = string.format("{{Psalm/Table |Chapter=%s|annotations=%s|speakerbars=no|sections=no}}", chapter, subpageName)
bodyContent = string.format("{{#ifexist: Psalm %s/Text/Table/Default " ..
"|{{:Psalm %s/Text/Table/Default}} " ..
"| {{Psalm/Table " ..
"|sections=no|speakerbars=no|Chapter={{%s}} }} }}",
chapter, chapter,chapter)
end
table.insert(result, string.format(
'<div id="%s" class="%s" role="tabpanel" aria-labelledby="%s">%s</div>',
divID, divClass, labelFor, bodyContent
))
end
return frame:preprocess(table.concat(result, "\n"))
end
function p.TabbedContentOverlays(frame)
local args = frame.args
local names = mw.text.split(args[1] or "", ",")
local parentID = mw.text.trim(args[2] or "parentID"):gsub("_$", "")
local chapter = mw.text.trim(args[3] or "19")
local addNewLink = args[4] or "" -- Optional: override the add-new page target
local result = {}
for i, name in ipairs(names) do
local subpageName = mw.text.trim(name)
local divID = parentID .. "-" .. tostring(i)
local divClass = "tab-pane fade" .. (i == 1 and " show active" or "")
local labelFor = divID .. "Label"
local approvedTitle = string.format("Approved/%s/%s", chapter, subpageName)
local titleObj = mw.title.new(approvedTitle)
local bodyContent = ""
if titleObj and titleObj.exists then
bodyContent = string.format("{{:%s}}", approvedTitle)
else
-- Instead of falling back to Psalm/Table,
-- embed the annotations + widget block
bodyContent = string.format([[
{{#invoke:PsalmTable/Aligned
|buildAlignedHebrewEnglishTable
|%s
}}
]], chapter)
end
table.insert(result, string.format(
'<div id="%s" class="%s" role="tabpanel" aria-labelledby="%s">%s</div>',
divID, divClass, labelFor, bodyContent
))
end
return frame:preprocess(table.concat(result, "\n"))
end
function p.setPropertiesFromArgs(frame)
-- Merge parent (template) args first, then override with #invoke args
local merged = {}
local parent = frame:getParent()
if parent and parent.args then
for k, v in pairs(parent.args) do merged[k] = v end
end
for k, v in pairs(frame.args or {}) do merged[k] = v end
-- booleanize quiet
local quiet = frame.args.quiet and tostring(frame.args.quiet) == "true"
local out = {}
for name, value in pairs(merged) do
if type(name) == "string" and name ~= "quiet" then
name = mw.text.trim(name)
value = mw.text.trim(tostring(value or ""))
if name ~= "" and value ~= "" then
if quiet then
table.insert(out, string.format('{{#set: %s=%s}}', name, value))
else
table.insert(out, string.format('%s: [[%s::%s]]<br/>', name, name, value))
end
end
end
end
if quiet then
return frame:preprocess(table.concat(out, "\n"))
else
return table.concat(out, "\n")
end
end
function p.setSubobjectFromArgs(frame)
local args = frame:getParent().args
local category = frame.args[1] or args[1] or frame.args.category or "subobject"
local out = {}
local subobject = {}
table.insert(subobject, '{{#subobject:')
table.insert(subobject, string.format('|IsCategory=%s', category))
table.insert(subobject, string.format('|Chapter=%s', p.getPsalmNumber(frame)))
for name, value in pairs(args) do
name = mw.text.trim(name)
value = mw.text.trim(value)
if name ~= "" and value ~= "" then
table.insert(subobject, string.format('|%s=%s', name, value))
end
end
table.insert(subobject, "}}")
table.insert(out, frame:preprocess(table.concat(subobject, "\n")))
return ""
end
function p.setVersePropertiesFromArgs(frame)
local args = frame:getParent().args
local category = frame.args[1] or "HebrewText"
local out = {}
for name, value in pairs(args) do
name = mw.text.trim(name)
value = mw.text.trim(value)
local subobject = {}
if name ~= "" and value ~= "" then
local number, part = string.match(name, "^(%d+)([a-z]?)$")
table.insert(subobject, '{{#subobject:')
table.insert(subobject, string.format('|IsCategory=%s', category))
table.insert(subobject, "|Chapter={{CurrentChapter}}")
table.insert(subobject, string.format('|Reference=Psalm {{CurrentChapter}}/%s', name))
table.insert(subobject, string.format('|VersePortion=%s', name))
if number then
table.insert(subobject, string.format('|Verse=%s', number))
end
if part and part ~= "" then
table.insert(subobject, string.format('|VersePart=%s', part))
end
table.insert(subobject, string.format('|Text=%s', value))
table.insert(subobject, "}}")
table.insert(out, frame:preprocess(table.concat(subobject, "\n")))
end
end
return ""
end
function p.psalmImages(frame)
local output = {}
local total = tonumber(frame.args[1]) or 150 -- default to 150 Psalms
local perRow = tonumber(frame.args[2]) or 5
for i = 1, total do
table.insert(output, frame:preprocess(string.format("{{PsalmImage|%d|%d}}", i, perRow)))
end
return table.concat(output, " ")
end
function p.GenerateDiagramID(frame)
local str = frame.args[1] or ""
-- Ensure we only return the modified string, avoiding unintended second return values
str = str:gsub("%s+", "-") -- Replace spaces with hyphens
:gsub("%.", "") -- Remove periods
:gsub(",", "-") -- Replace commas with hyphens
:gsub("[%s/\\]+", "-") -- turn spaces/slashes into dashes
:gsub("[^%w%-]+", "") -- remove non-word characters except dashes
:gsub("%-+", "-") -- collapse multiple dashes into one
:gsub("^%-+", "") -- trim leading dashes
:gsub("%-+$", "") -- trim trailing dashes
return str
end
function p.renderWikitext(frame)
local input = frame.args[1] or ''
return frame:preprocess(input)
end
function p.getHeatmapJSONFromFiles(frame)
local chapter = mw.text.trim(frame.args[1] or ""):gsub("%s+", "")
local fileList = frame.args[2] or "" -- e.g. "participants,repeated-roots"
if chapter == "" or fileList == "" then
return "{}"
end
local counts = {}
-- Helper: safely process one JSON file
local function processFile(fname)
local clean = mw.text.trim(fname):gsub("%s+", "")
if clean == "" then
return
end
local title = string.format("Data/%s/annotations/%s.json", chapter, clean)
local titleObj = mw.title.new(title)
if not titleObj then
mw.log("⚠️ Could not find title: " .. title)
return
end
local raw = titleObj:getContent()
if not raw then
mw.log("⚠️ Could not get content for: " .. title)
return
end
local ok, data = pcall(mw.text.jsonDecode, raw)
if not ok or type(data) ~= "table" then
mw.log("⚠️ JSON decode failed for: " .. title)
return
end
-- Count hbIDs (and also expand shorthand if present)
for _, item in ipairs(data) do
if item.hbID then
counts[item.hbID] = (counts[item.hbID] or 0) + 1
end
if item.id and not item.hbID then
-- If shorthand like "id-3c", apply to all matching IDs seen so far
local prefix = "^" .. mw.ustring.gsub(item.id, "%-", "%%-")
for hb in pairs(counts) do
if hb:match(prefix) then
counts[hb] = (counts[hb] or 0) + 1
end
end
end
end
end
-- Iterate over file list
for fname in mw.text.gsplit(fileList, ",", true) do
processFile(fname)
end
-- Build result heatmap
local result = {}
for wordID, count in pairs(counts) do
result[wordID] = "heatmap-" .. pad2(count)
end
return mw.text.jsonEncode(result)
end
return p