Module:PsalmFunctions

From Psalms: Layer by Layer
Jump to: navigation, search

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