Module:PsalmTable

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

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

local vc = require('Module:VerseCalculations')
local pf = require('Module:PsalmFunctions')
local p = {}
local chapter=-1
local DEBUG
local debugLog = {}
local includeColors = true
local groupEnglish = true
local clusterCBC = true
local sectionInfo, subsectionInfo, speakerInfo, addresseeInfo, emotionInfo = {}, {}, {}, {}, {}
local includeSectionBreaks = false
local lastSectionLineSet = {}
local lastSubSectionLineSet = {}
local annotationList = {}
local colorMap = {}
local currentSectionColor
local colorSections = false

-- Fixes \n from SMW being passed as literal text
local function unescapeNewlines(s)
	return s:gsub("\\n", "\n")
	
end

local function normalizeV(verse)
    -- Ensure the input is a string
    if type(verse) ~= "string" or verse == "" then
        return -1
    end

    -- Extract base number and optional letter suffix
    local baseNumber, letters = verse:match("^(%d+)([a-z]*)$")
    if not baseNumber then
        return -1
    end

    -- Convert base number
    baseNumber = tonumber(baseNumber) or 0

    -- Add scaled value for letter suffix (e.g., "a" = 0.1, "b" = 0.2, ...)
    if letters ~= "" then
        local letterValue = 0
        for i = 1, #letters do
            local char = letters:sub(i, i)
            if char:match("[a-z]") then
                letterValue = letterValue + (char:byte() - string.byte("a") + 1) * (0.1 ^ i)
            else
                return "Error: Invalid letter in verse suffix"
            end
        end
        baseNumber = baseNumber + letterValue
    end

    return baseNumber
end
function p.normalizeVerse(frame)
	local verse = frame.args[1]

	return normalizeV(verse)
end

local function parseLineID(line)
	if type(line) ~= "string" then return nil end
	local number, letter = line:match("^(%d+)([a-z]?)$")
	if not number then return nil end
	return tonumber(number), letter or ""
end

--[[
local function compareLines(anum, aletter, bnum, bletter)
	if not anum or not bnum then return false end  -- guard against nil

	if anum ~= bnum then
		return anum < bnum
	end
	return aletter < bletter
end
]]
-- return a numeric 
local function compareLines(anum, aletter, bnum, bletter)
	if not anum or not bnum then return 0 end
	if anum ~= bnum then
		return anum < bnum and -1 or 1
	end
	if aletter == bletter then
		return 0
	end
	return aletter < bletter and -1 or 1
end

--[[
local function isLineInRange(lineID, firstLine, lastLine)
	local linenum, lineletter = parseLineID(lineID)
	local firstnum, firstletter = parseLineID(firstLine)
	local lastnum, lastletter = parseLineID(lastLine)

	if not linenum or not firstnum or not lastnum then
		return false
	end

	return compareLines(firstnum, firstletter, linenum, lineletter)
	       and compareLines(linenum, lineletter, lastnum, lastletter)
end
]]
local function isLineInRange(lineID, firstLine, lastLine)
	local linenum, lineletter = parseLineID(lineID)
	local firstnum, firstletter = parseLineID(firstLine)
	local lastnum, lastletter = parseLineID(lastLine)

	if not linenum or not firstnum or not lastnum then
		return false
	end

	-- Check: firstLine ≤ lineID ≤ lastLine
	return compareLines(firstnum, firstletter, linenum, lineletter) <= 0 and
	       compareLines(linenum, lineletter, lastnum, lastletter) <= 0
end


local function makeSubobjectString(data, lineID)
	local wordID = data.WordID or ""
	local parts = mw.text.split(wordID, "-")
	local writtenID = parts[2] or ""
	local lexemeID = parts[3] or ""
	local num, letter = parseLineID(data.LineID)
	local normalizedVerse = normalizeV(lineID) * 10

	-- Normalize Split: ensure it's a string ("true" or "")
	local isSplit = (data.Split == true or data.Split == "true") and "true" or ""

	if DEBUG and isSplit then 
		table.insert(debugLog, "Word " .. wordID .. " has Split:" .. tostring(isSplit)) 
	end


	return string.format(
		'{{#subobject:|IsCategory=TextualWord|WordID=%s|Split=%s|WrittenWordID=%s|LexemeID=%s|LineID=%s|Verse=%s|NormalizedVerse=%s|Language=%s|Word=%s|Index=%s}}',
		wordID,
		isSplit,
		writtenID,
		lexemeID,
		data.LineID or "",
		num or "",
		normalizedVerse or "",
		data.Language or "",
		data.Word or "",
		data.Index or ""
	)
end


local function adjustRowspansForBreaks()
	-- Clear previous sets
	lastSectionLineSet = {}
	lastSubSectionLineSet = {}

	-- Track section last lines
	for _, sec in ipairs(sectionInfo) do
		if sec.LastLine then
			lastSectionLineSet[sec.LastLine] = true
		end
	end

	-- Track subsection last lines only if not already in section
	for _, sec in ipairs(subsectionInfo) do
		if sec.LastLine and not lastSectionLineSet[sec.LastLine] then
			lastSubSectionLineSet[sec.LastLine] = true
		end
	end

	-- Combine both into one list for breaks
	local breakLineIDs = {}
	for lineID in pairs(lastSectionLineSet) do
		table.insert(breakLineIDs, lineID)
	end
	for lineID in pairs(lastSubSectionLineSet) do
		table.insert(breakLineIDs, lineID)
	end

	-- Sort: numeric prefix, alpha suffix
	table.sort(breakLineIDs, function(a, b)
		local numA, suffixA = a:match("^(%d+)(%a*)$")
		local numB, suffixB = b:match("^(%d+)(%a*)$")

		numA = tonumber(numA) or 0
		numB = tonumber(numB) or 0

		if numA == numB then
			return suffixA < suffixB
		else
			return numA < numB
		end
	end)

	-- Count how many breaks fall within a given section range
	local function countBreaksInRange(firstLine, lastLine)
		local count = 0
		for _, lineID in ipairs(breakLineIDs) do
			if isLineInRange(lineID, firstLine, lastLine) then
				count = count + 1
			end
		end
		return count
	end

	-- Adjust speaker sections
	for _, sec in ipairs(speakerInfo) do
		local base = tonumber(sec.LineCount) or 1
		local add = countBreaksInRange(sec.FirstLine, sec.LastLine)
		sec.LineCount = base + add
	end

	-- Adjust addressee sections
	for _, sec in ipairs(addresseeInfo) do
		local base = tonumber(sec.LineCount) or 1
		local add = countBreaksInRange(sec.FirstLine, sec.LastLine)
		sec.LineCount = base + add
	end

	return nil
end


local function renderWordSpan(w)
	--table.insert(debugLog, "ℹ️ Raw WordID: [" .. tostring(w.WordID) .. "]")
	local wordID = mw.text.trim(w.WordID or "")
	local wordIDs = mw.text.split(wordID, " ")
	
	local firstWordID = wordIDs[1]
	local fullID = "id-" .. firstWordID
	
	local allIDs = {}
	for _, id in ipairs(wordIDs) do
		if id ~= "" then
			table.insert(allIDs, "id-" .. id)
		end
	end

	--local fullID = "id-" .. mw.text.trim(w.WordID or "")
	local participant = annotationList[fullID]

	local color = participant and colorMap[participant] or nil
	local styleAttr = ""
	--if includeColors then 
		styleAttr = color and (' style="background-color:' .. color .. ';"') or ""
	--end
	local class = (w.Language or "unknown"):lower()
	if (w.Split) then 
		class = class .. " split-word" 
	end
	
	if w.Word and w.Word:find("<span") then
		table.insert(debugLog, "⚠️ Word already contains span: " .. w.Word)
	end
	
	return string.format('<span class="%s %s"%s>%s</span>', class, table.concat(allIDs, " "), styleAttr, w.Word or "")
end
local function sortLineIDsFromSections(sections)
	local lineIDSet = {}
	for _, sec in ipairs(sections) do
		if sec.FirstLine and sec.LastLine then
			lineIDSet[sec.FirstLine] = true
			lineIDSet[sec.LastLine] = true
		end
	end
	return sortLineIDs(lineIDSet)
end

local function getSubsections(sectionData)
	local subs = {}
	for _, s in ipairs(sectionData) do
		if s.Column == "Subsection" then
			table.insert(subs, s)
		end
	end
	-- sort by FirstLine
	table.sort(subs, function(a, b)
		local anum, aletter = parseLineID(a.FirstLine)
		local bnum, bletter = parseLineID(b.FirstLine)
		if anum ~= bnum then
			return anum < bnum
		else
			return aletter < bletter
		end
	end)
	return subs
end

local function findSubsectionForLine(lineID, subsections)
	for _, sub in ipairs(subsections) do
		local sFirstNum, sFirstLet = parseLineID(sub.FirstLine)
		local sLastNum, sLastLet = parseLineID(sub.LastLine)
		local lNum, lLet = parseLineID(lineID)

		-- lineID is within subsection range if:
		--   lineID >= FirstLine  ⇨  not (lineID < FirstLine)
		--   lineID <= LastLine   ⇨  not (lineID > LastLine)
		local lineIsAfterOrAtStart = not compareLines(lNum, lLet, sFirstNum, sFirstLet)
		local lineIsBeforeOrAtEnd  = not compareLines(sLastNum, sLastLet, lNum, lLet)

		if lineIsAfterOrAtStart and lineIsBeforeOrAtEnd then
			return sub
		end
	end
	return nil
end
local function buildGenericSectionCell(lineID, column, entries)
	if not entries or #entries == 0 then
		return nil -- no sections at all
	end

	for _, sec in ipairs(entries) do
		if lineID == sec.FirstLine then
			local rowspan = tonumber(sec.LineCount) or 1
			local value = sec.Heading or ""
			local styleAttr = ""
			if includeColors and sec.Color then
				styleAttr = string.format(' style="background-color: %s;"', tostring(sec.Color))
			end

			local classList = { tostring(column:lower()) .. "-bar" }
			if sec.Annotation then
				table.insert(classList, tostring(column:lower() .. '-' .. sec.Annotation:lower()))
			end
			local classAttr = table.concat(classList, " ")

			return string.format(
				'|%s class="%s" data-line="%s" section-type="%s" rowspan=%d | <div>%s</div>',
				styleAttr,
				classAttr,
				tostring(lineID),
				tostring(column),
				rowspan,
				tostring(value)
			)
		elseif isLineInRange(lineID, sec.FirstLine, sec.LastLine) then
			return nil -- skip line, part of a rowspan
		end
	end

	-- If there are entries, but no match for this lineID, return an empty cell
	return '| '
end


local readyForSubsection = nil
local oneRowSkipped = nil

local function buildSubsectionCell(lineID)
	for _, sec in ipairs(subsectionInfo) do
		if lineID == sec.FirstLine then
			-- first is the heading, then prepare the content for next time
			local rowspan = tonumber(sec.LineCount) or 1

			local subHeading = sec.Heading or ""
			local sectionHeading = ""
			local roomToBreathe = tonumber(rowspan) > 4

			if mw.ustring.match(subHeading, "^File:.+%.%a%a%a$") then
				subHeading = string.format('[[%s|50px|frameless|center|class=aag-icon]]', subHeading)
			end
			subHeading = '<div class="subsection-content" data-rowcount="' .. rowspan .. '">' .. 
			subHeading .. "</div>"

			for _, parent in ipairs(sectionInfo or {}) do
				--if isLineInRange(sec.FirstLine, parent.FirstLine, parent.LastLine) then
				if sec.FirstLine == parent.FirstLine then
					sectionHeading = '<div class="section-heading">' .. (parent.Heading or "") .. "</div>"
					break
				end
			end

			local styleAttr = ""
			if includeColors and sec.Color then
				styleAttr = string.format(' style="background-color: %s;"', tostring(sec.Color))
			end

			if sectionHeading ~= "" then
				-- need to handle section before subsection
				
				if roomToBreathe then
					readyForSubsection = {
						style = styleAttr,
						content = subHeading,
						rowspan = rowspan - 2
					}
		
					return string.format(
						'|%s class="subsection-bar" data-line="%s" section-type="Subsection" rowspan=2 | %s',
						styleAttr,
						tostring(lineID),
						sectionHeading
					)
				else				
					readyForSubsection = {
						style = styleAttr,
						content = subHeading,
						rowspan = rowspan - 1
					}
					readyForSubsection = nil
		
					return string.format(
						'|%s class="subsection-bar" data-line="%s" section-type="Subsection" | %s',
						styleAttr,
						tostring(lineID),
						sectionHeading
					)
				end
			else
				-- just the subsection, right now
					readyForSubsection = {
						style = styleAttr,
						content = subHeading,
						rowspan = rowspan
					}
				-- handle subsection content
				local subsectionContent = string.format(
					'|%s class="subsection-bar" data-line="%s" section-type="Subsection" rowspan=%d | %s',
					readyForSubsection.style or '',
					tostring(lineID),
					readyForSubsection.rowspan or 1,
					readyForSubsection.content or ''
				)
				readyForSubsection = nil
				return subsectionContent
			end
				
		elseif readyForSubsection then
			if not oneRowSkipped then
				-- must skip one row
				oneRowSkipped = true
				return nil
			else
				-- reset this for the future
				oneRowSkipped = nil
				
				-- handle subsection content
				local subsectionContent = string.format(
					'|%s class="subsection-bar" data-line="%s" section-type="Subsection" rowspan=%d | %s',
					readyForSubsection.style or '',
					tostring(lineID),
					readyForSubsection.rowspan or 1,
					readyForSubsection.content or ''
				)
				readyForSubsection = nil
				return subsectionContent
			end

		elseif isLineInRange(lineID, sec.FirstLine, sec.LastLine) then
			-- no cell at all, since rowspan covers this
			return nil
		end
	end

	return '| '
end


local function buildEmotionCell(lineID)
	local sources = { subsectionInfo, sectionInfo }

	for _, source in ipairs(sources) do
		for _, sec in ipairs(source) do
			if lineID == sec.FirstLine and sec.Emotion then
				local rowspan = tonumber(sec.LineCount) or 1
				local value = sec.Emotion or "none specified"
				local styleAttr = ""
				if includeColors and sec.Color then
					styleAttr = string.format(' style="background-color: %s;"', tostring(sec.Color))
				end
				
				return string.format(
					'|%s class="emotion-bar" data-line="%s" section-type="Emotion" rowspan=%d | <div>%s</div>',
					styleAttr,
					tostring(lineID),
					rowspan,
					tostring(value)
				)


			elseif isLineInRange(lineID, sec.FirstLine, sec.LastLine) and sec.Emotion then
				-- emotion already entered early; leave blank
				return nil
			end
		end
	end


		-- If there are entries, but no match for this lineID, return an empty cell
	return '| '

end


local function buildSectionCell(lineID, column)
	
	-- General-purpose columns
	local infoMap = {
		Speaker = speakerInfo,
		Section = sectionInfo,
		Addressee = addresseeInfo,
	}
	local entries = infoMap[column]
	if not entries then return nil end
	return buildGenericSectionCell(lineID, column, entries)

end

local function sortSectionListByFirstLine(sections)
	table.sort(sections, function(a, b)
		local anum, aletter = parseLineID(a.FirstLine)
		local bnum, bletter = parseLineID(b.FirstLine)
		return compareLines(anum, aletter, bnum, bletter) < 0
	end)
end
local function getSectionAnnotationForLine(lineID, sectionInfo)
	local lNum, lLet = parseLineID(lineID)
	for _, sec in ipairs(sectionInfo) do
		local fNum, fLet = parseLineID(sec.FirstLine)
		local tNum, tLet = parseLineID(sec.LastLine)

		if compareLines(fNum, fLet, lNum, lLet) <= 0 and
		   compareLines(lNum, lLet, tNum, tLet) <= 0 then
			return sec.Annotation or nil
		end
	end
	if DEBUG then table.insert(debugLog, "⚠️ No section annotation match for lineID: " .. tostring(lineID)) end
	return nil
end


local function getSectionNumberForLine(lineID, info)
	local lNum, lLet = parseLineID(lineID)
	for i, sec in ipairs(info) do
		local fNum, fLet = parseLineID(sec.FirstLine)
		local tNum, tLet = parseLineID(sec.LastLine)

		if compareLines(fNum, fLet, lNum, lLet) <= 0 and
		   compareLines(lNum, lLet, tNum, tLet) <= 0 then
			return i
		end
	end
	-- table.insert(debugLog, "⚠️ No section match for lineID: " .. tostring(lineID)) end
		-- If there are entries, but no match for this lineID, return an empty cell
	return nil

end
local function groupAdjacentWordsByWordID(wordList)
	if not groupEnglish then return end
	local result = {}

	-- Step 1: Sort by Index
	table.sort(wordList, function(a, b)
		return (tonumber(a.Index) or 0) < (tonumber(b.Index) or 0)
	end)

	-- Step 2: Group only consecutive words that share the same WordID
	local currentGroup = nil
	for _, w in ipairs(wordList) do
		if currentGroup and currentGroup.WordID == w.WordID then
			if not w.Split then
				currentGroup.Word = currentGroup.Word .. " "
			end
			currentGroup.Word = currentGroup.Word .. (w.Word or "")
		else
			if currentGroup then
				table.insert(result, currentGroup)
			end
			currentGroup = {
				WordID = w.WordID,
				Language = w.Language,
				Word = w.Word or ""
			}
		end
	end

	if currentGroup then
		table.insert(result, currentGroup)
	end

	return result
end


local function buildHebrewCell(lineID, hebrewWords)
	local hebrewSpans = {}
	local prevMid = nil

	for _, h in ipairs(hebrewWords or {}) do
		local parts = mw.text.split(h.WordID or "", "-")
		local mid = parts[2]
		if prevMid and mid ~= prevMid then
			table.insert(hebrewSpans, " ")
		end
		prevMid = mid
		table.insert(hebrewSpans, renderWordSpan(h))
	end

	local result = string.format(
		'<div class="line" data-line="%s">%s</div>',
		lineID,
		table.concat(hebrewSpans, ""))
	
	table.insert(debugLog, "ℹ️ Hebrew for line " .. lineID .. ": " .. result)

	return result
	
end


local function restoreInsertions(englishWords, insertions, currentLineID)
	local englishSpans = {}
	local insertQueue = {}

	-- Filter insertions for this line
	for _, ins in ipairs(insertions or {}) do
		if ins.lineID == currentLineID then
			table.insert(insertQueue, ins)
		end
	end

	-- Sort insertions by index
	table.sort(insertQueue, function(a, b)
		return (tonumber(a.index or 0) or 0) < (tonumber(b.index or 0) or 0)
	end)

	table.insert(debugLog, 'ℹ️️ Debug: insertions for line ' .. currentLineID .. ' = <div class=\"mw-collapsible mw-collapsed\"><pre>' .. mw.text.nowiki(mw.dumpObject(insertQueue)) .. '</pre></div>')

	local insertIndex = 1
	local ins = insertQueue[insertIndex]
	local accumulatedText = ""
	local wordCount = 0

	for _, word in ipairs(englishWords or {}) do
		local plain = word.Word or ""
		local rendered = renderWordSpan(word)
		local inserted = false

		while ins do
			local targetText = ins.textBefore or ""

			if targetText == "" then
				-- We'll insert this at the beginning after loop
				break
			end

			local candidate = (accumulatedText .. " " .. plain):gsub("^%s*", ""):gsub("%s*$", ""):gsub("%s+", " ")

			if candidate == targetText then
				-- Perfect match: insert after this word
				table.insert(englishSpans, rendered)
				table.insert(englishSpans,
					string.format('<span class="insertion" data-index="%d">%s</span>',
						wordCount,
						mw.text.nowiki(ins.text or "⟪insert⟫")
					)
				)
				accumulatedText = candidate
				inserted = true
				insertIndex = insertIndex + 1
				ins = insertQueue[insertIndex]
				table.insert(debugLog, 'ℹ️ Debug: Found a perfect match for <pre>' .. targetText .. '</pre>')
				break

			elseif targetText == candidate:sub(1, #targetText) and #targetText > #accumulatedText then
				
				table.insert(debugLog, 'ℹ️ Debug: need to split spans because <pre>' .. targetText .. '</pre> demands it')

				
				-- Partial match: split current span
				local matchLength = #targetText - #accumulatedText
				local beforePart = plain:sub(1, matchLength)
				local afterPart = plain:sub(matchLength + 1)

				local spanBase = string.format('class="english" data-wordid="%s"', mw.text.nowiki(word.WordID or ""))

				table.insert(englishSpans, string.format('<span %s>%s</span>', spanBase, mw.text.nowiki(beforePart)))
				table.insert(englishSpans,
					string.format('<span class="insertion" data-index="%d">%s</span>',
						wordCount,
						mw.text.nowiki(ins.text or "⟪insert⟫")
					)
				)
				table.insert(englishSpans, string.format('<span %s>%s</span>', spanBase, mw.text.nowiki(afterPart)))

				accumulatedText = accumulatedText .. plain
				inserted = true
				insertIndex = insertIndex + 1
				ins = insertQueue[insertIndex]
				break
			else
				table.insert(debugLog, '⚠️ Debug: No match possible for <pre>' .. targetText .. '</pre> and <pre>' .. candidate .. '</pre>')

				-- No match
				break
			end
		end

		if not inserted then
			table.insert(englishSpans, rendered)
			accumulatedText = accumulatedText .. " " .. plain
		end

		wordCount = wordCount + 1
	end

	-- Handle any remaining insertions
	while ins do
		if (ins.textBefore or "") == "" then
			-- Insert at beginning
			table.insert(englishSpans, 1,
				string.format('<span class="insertion" data-index="0">%s</span>',
					mw.text.nowiki(ins.text or "⟪insert⟫")
				)
			)
			table.insert(debugLog, 'ℹ️️ Debug: Beginning insertion <pre>' .. ins.text .. '</pre>')
		else
			-- Insert at end
			table.insert(englishSpans,
				string.format('<span class="insertion" data-index="%d">%s</span>',
					wordCount,
					mw.text.nowiki(ins.text or "⟪insert⟫")
				)
			)
			table.insert(debugLog, 'ℹ️️ Debug: Ending insertion <pre>' .. ins.text .. '</pre>')
		end
		insertIndex = insertIndex + 1
		ins = insertQueue[insertIndex]
	end

	return englishSpans
end





local function buildLineRow(lineID, group, insertions, lastLineID, groupBy, buildSubobjects, subobjects, putVerseFirst)
	local hebrewSpans, englishSpans = {}, {}
	local prevMid = nil

	for _, h in ipairs(group.hebrew or {}) do
		local parts = mw.text.split(h.WordID or "", "-")
		local mid = parts[2]
		if prevMid and mid ~= prevMid then
			table.insert(hebrewSpans, " ")
		end
		prevMid = mid
		table.insert(hebrewSpans, renderWordSpan(h))
	end
	
	if DEBUG then
		table.insert(debugLog, 'ℹ️ Debug: hebrew = <div class="mw-collapsible mw-collapsed"><pre>' .. mw.text.nowiki(mw.dumpObject(hebrewSpans)) .. '</pre></div>')
	end


	if DEBUG then table.insert(debugLog, "ℹ️️ Debug: buildLineRow: " .. lineID) end
	
	local groupedEnglish = group.english
	if clusterCBC and #insertions == 0 then
		groupedEnglish = groupAdjacentWordsByWordID(group.english)
	end

	if DEBUG then
		table.insert(debugLog, 'ℹ️ Debug: cluster CBC = ' .. tostring(clusterCBC))
		table.insert(debugLog, 'ℹ️️ Debug: english = <div class="mw-collapsible mw-collapsed"><pre>' .. mw.text.nowiki(mw.dumpObject(groupedEnglish)) .. '</pre></div>')
	end

	englishSpans = restoreInsertions(groupedEnglish, insertions, lineID)


	
	local hasSubsections = type(subsectionInfo) == "table" and #subsectionInfo > 0
	local row = {}
	local subobjectRow = {}

	local sectionNum = getSectionNumberForLine(lineID, sectionInfo)
	if groupBy=="sections" and sectionNum then
		local sectionClass = "section-" .. (sectionNum or "unknown")
		
		if sectionClass == "section-unknown" and DEBUG then
			table.insert(debugLog, "No section class for line " .. tostring(lineID))
		end
		
		-- Look for a background color
		for _, sec in ipairs(subsectionInfo or {}) do
			if lineID == sec.FirstLine then
				if includeColors and sec.Color then
					currentSectionColor = string.format(' style="background-color: %s;"', tostring(sec.Color))
					break
				end
			end
		end
		
		if colorSections and currentSectionColor then
			-- start new row with background color
			table.insert(row, '|- class="' .. sectionClass .. '"' .. currentSectionColor)
		else
			-- start new row without background color
			table.insert(row, '|- class="' .. sectionClass .. '"')
		end

		
	elseif groupBy=="speakers" then
		local speaker = getSectionAnnotationForLine(lineID, speakerInfo)
		local speakerClass = "speaker-" .. (speaker or "unknown")
	
		if speakerClass == "" and DEBUG then table.insert(debugLog, "No speaker class for line " .. lineID) end

		-- start new row
		table.insert(row, '|- class="' .. speakerClass .. '"')		
	else
		-- start new row with no class
		table.insert(row, '|- ')
	end
	
	-- speaker
	if #speakerInfo > 0 then
		table.insert(row, buildSectionCell(lineID, "Speaker"))
	end
	
	if hasSubsections and #subsectionInfo > 0 then
			table.insert(row, buildSubsectionCell(lineID))
	elseif #sectionInfo > 0 then
		table.insert(row, buildSectionCell(lineID, "Section"))
	end 

	if putVerseFirst then
		table.insert(row, string.format('| class="verse-cell" | %s', lineID))
	end
	
	local num, letter = parseLineID(lineID)

	
	table.insert(row, '| dir="rtl" class="hebrew-cell" | ' .. buildHebrewCell(lineID, group.hebrew, buildSubobjects, subobjects))
	table.insert(subobjectRow, string.format(
			'{{#subobject:|IsCategory=RenderedHebrew|Chapter=%s|Verse=%s|LineID=%s|IsRenderedHebrewFor=%s|NormalizedVerse=%s|LineData=%s}}',
				chapter or "999",
				num or "999",
				lineID or "999",	
				"Data/" .. chapter .. "." .. lineID,
				normalizeV(lineID or "999") * 10,
				buildHebrewCell(lineID, group.hebrew, buildSubobjects, subobjects):gsub("|", "{{!}}")
			))

	if putVerseFirst == false then
		table.insert(row, string.format('| class="verse-cell" | %s', lineID))
	end
	
	--table.insert(row, string.format('| class="cbc-cell" | <div class="line" data-line="%s">%s</div>', 
	--	lineID, table.concat(englishSpans, " ")))
	
	local englishLine = ""
	for i, span in ipairs(englishSpans) do
		if i > 1 then
			local isSplit = string.find(span, 'class="[^"]*split%-word')
			if not isSplit then
				englishLine = englishLine .. " "
			end
		end
		englishLine = englishLine .. span
	end
	
	table.insert(row, string.format('| class="cbc-cell" | <div class="line" data-line="%s">%s</div>', 
		lineID, englishLine))
	
	
	
	table.insert(subobjectRow, string.format(
			'{{#subobject:|IsCategory=RenderedCBC|Chapter=%s|Verse=%s|LineID=%s|IsRenderedCBCFor=%s|NormalizedVerse=%s|LineData=%s}}',
				chapter or "999",
				num or "999",
				lineID or "999",	
				"Data/" .. chapter .. "." .. lineID,
				normalizeV(lineID or "999") * 10,
				table.concat(englishSpans, " "):gsub("|", "{{!}}")
			))

	if #sectionInfo > 0 then
		table.insert(row, buildEmotionCell(lineID))
	end 
	if #addresseeInfo > 0 then
		table.insert(row, buildSectionCell(lineID, "Addressee"))
	end 

	if includeSectionBreaks and lastSectionLineSet and lastSectionLineSet[lineID] and lineID ~= lastLineID then
		-- new row with one cell of colspan=4
		table.insert(row, '|- class="section-break"')
		table.insert(row, '| colspan="4" |')
	elseif includeSectionBreaks and lastSubSectionLineSet and lastSubSectionLineSet[lineID] and lineID ~= lastLineID then
		-- new row with one cell of colspan=4
		table.insert(row, '|- class="subsection-break"')
		table.insert(row, '| colspan="4" |')
	end


	return table.concat(row, "\n"), table.concat(subobjectRow, "\n")
	--return table.concat(row, "\n"):gsub("\n+$", "")

end

local function parseOverlayLines(content, buildSubobjects)
	local lines, subobjects = {}, {}
	local decoded

	-- Try to parse the whole content as JSON
	local isJSON = content:match("^%s*[%[{]")
	if isJSON then
		local ok, result = pcall(mw.text.jsonDecode, content)
		if ok and type(result) == "table" then
			decoded = result
		end
	end

	-- If whole-content JSON parsed successfully, treat it as a JSON array
	if decoded then
		for _, data in ipairs(decoded) do
			local lang = data.Language and data.Language:lower()
			local lineID = data.LineID
			if lang and lineID then
				lines[lineID] = lines[lineID] or {hebrew = {}, english = {}}
				table.insert(lines[lineID][lang], data)

				if buildSubobjects then
					table.insert(subobjects, makeSubobjectString(data, lineID))
				end
			end
		end
	else
		-- Otherwise, fall back to line-by-line parsing
		for line in content:gmatch("[^\r\n]+") do
			local ok, data = pcall(mw.text.jsonDecode, line)
			if ok and data then
				local lang = data.Language and data.Language:lower()
				local lineID = data.LineID
				if lang and lineID then
					lines[lineID] = lines[lineID] or {hebrew = {}, english = {}}
					table.insert(lines[lineID][lang], data)

					if buildSubobjects then
						table.insert(subobjects, makeSubobjectString(data, lineID))
					end
				end
			end
		end
	end

	return lines, subobjects
end


local function sortLineIDs(lines)
	local ids = {}
	for lineID in pairs(lines) do
		table.insert(ids, lineID)
	end
	table.sort(ids, function(a, b)
		local anum, aletter = parseLineID(a)
		local bnum, bletter = parseLineID(b)
		if not anum or not bnum then return a < b end -- fallback to string compare

		if anum ~= bnum then
			return anum < bnum
		else
			return aletter < bletter
		end
	end)
	return ids
end


local function sortWordGroupsByIndex(lines)
	for _, group in pairs(lines) do
		table.sort(group.hebrew, function(a, b) return a.Index < b.Index end)
		table.sort(group.english, function(a, b) return a.Index < b.Index end)
	end
end

local function cleanSMWInput(raw)
	raw = unescapeNewlines(raw or "")
	raw = raw:gsub("%[%[SMW::off%]%]", ""):gsub("%[%[SMW::on%]%]", "")
	return raw
end

local function parseJSONArg(frame, key)
	local raw = frame.args[key] or "{}"
	raw = raw:gsub("\\n", "")
	local ok, result = pcall(mw.text.jsonDecode, raw)
	if ok and type(result) == "table" then
		return result
	else
		return {}
	end
end

local function extractSectionColumns(sections)
	--local speakerInfo, sectionInfo, subsectionInfo, addresseeInfo, emotionInfo = {}, {}, {}, {}, {}

	for _, sec in ipairs(sections) do
		local col = sec.Column
		if col == "Speaker" then
			table.insert(speakerInfo, sec)
		elseif col == "Section" then
			table.insert(sectionInfo, sec)
		elseif col == "Subsection" then
			table.insert(subsectionInfo, sec)
		elseif col == "Addressee" then
			table.insert(addresseeInfo, sec)
		elseif col == "Emotion" then
			table.insert(emotionInfo, sec)
		end
	end
	if DEBUG and #speakerInfo > 0 then
		
		table.insert(debugLog, 'ℹ️ Debug: section counts - Speaker=' .. #speakerInfo ..
			', Section=' .. #sectionInfo ..
			', Subsection=' .. #subsectionInfo ..
			', Addressee=' .. #addresseeInfo ..
			', Emotion=' .. #emotionInfo)

		table.insert(debugLog, 'ℹ️ Debug: speaker = <div class=\"mw-collapsible mw-collapsed\"><pre>' .. mw.text.nowiki(mw.dumpObject(speakerInfo)) .. '</pre></div>')
		table.insert(debugLog, 'ℹ️ Debug: sections = <div class=\"mw-collapsible mw-collapsed\"><pre>' .. mw.text.nowiki(mw.dumpObject(sectionInfo)) .. '</pre></div>')
		table.insert(debugLog, 'ℹ️ Debug: subsections = <div class=\"mw-collapsible mw-collapsed\"><pre>' .. mw.text.nowiki(mw.dumpObject(subsectionInfo)) .. '</pre></div>')

	end
	return nil -- speakerInfo, sectionInfo, subsectionInfo, addresseeInfo, emotionInfo
end



function p.renderEnglishCellOLD(lineID, group)
	local englishSpans = {}
	local groupedEnglish = groupAdjacentWordsByWordID(group.english)
	for _, e in ipairs(groupedEnglish) do
		table.insert(englishSpans, renderWordSpan(e))
	end
	return string.format('| class="cbc-cell" | <div class="line" data-line="%s">%s</div>', 
		lineID, table.concat(englishSpans, " "))
end

function p.renderEnglishCell(lineID, group)
	local englishSpans = {}
	local groupedEnglish = groupAdjacentWordsByWordID(group.english)

	for i, e in ipairs(groupedEnglish) do
		local span = renderWordSpan(e)
		
		if i > 1 then
			local isSplit = string.find(span, 'class="[^"]*split%-word')
			if not isSplit then
				table.insert(englishSpans, " ") -- insert space
			end
		end

		table.insert(englishSpans, span)
	end

	return string.format(
		'| class="cbc-cell" | <div class="line" data-line="%s">%s</div>', 
		lineID, table.concat(englishSpans)
	)
end

local function cleanForJSON(str)
	-- Trim leading/trailing whitespace
	str = mw.text.trim(str or "")

	-- Remove raw control characters (ASCII 0–31 except \t (9) and \n (10) if needed)
	str = str:gsub("[%z\1-\8\11\12\14-\31]", "")

	-- Replace literal newlines inside quoted strings with \n
	str = str:gsub('"(.-)\n(.-)"', function(a, b)
		return '"' .. a .. '\\n' .. b .. '"'
	end)

	return str
end


function p.renderOverlayTableSection(frame)
	local out = {}
	local args = frame.args

	chapter = pf.getPsalmNumber(frame)

	-- Debug mode
	DEBUG = args["debug"] == "true"
	if DEBUG then table.insert(debugLog, "|}") end


	-- Extract and validate input text
	local content = cleanSMWInput(args["text"] or args["tokens"])
	if content == "" then 
		return "" --No content found." 
	end

	-- Parse section JSON
	local sectionsJSON = cleanSMWInput(args["sections"] or "")
    sectionsJSON = cleanForJSON(sectionsJSON)
	
	local ok, sectionData = pcall(mw.text.jsonDecode, sectionsJSON)
	local sections = (ok and type(sectionData) == "table") and sectionData or {}
	table.insert(debugLog, 'ℹ️ sectionsJSON = <div class=\"mw-collapsible mw-collapsed\"><pre>' .. mw.text.nowiki(mw.dumpObject(sectionsJSON)) .. '</pre></div>') 
	table.insert(debugLog, 'ℹ️ sectionData = <div class=\"mw-collapsible mw-collapsed\"><pre>' .. mw.text.nowiki(mw.dumpObject(sectionData)) .. '</pre></div>') 

	-- Parse insertions JSON
	local insertionsJSON = cleanSMWInput(args["insertions"] or "")
	local ok, insertionsData = pcall(mw.text.jsonDecode, insertionsJSON)
	local insertions = (ok and type(insertionsData) == "table") and insertionsData or {}
	if #insertions > 0 then
		table.insert(debugLog, "Parsed insertions: " .. tostring(#insertions) .. " items")
		table.insert(debugLog, 'ℹ️ Insertions = <div class=\"mw-collapsible mw-collapsed\"><pre>' .. mw.text.nowiki(mw.dumpObject(insertions)) .. '</pre></div>') 
	 end

	-- Settings
	local groupBy = args["groupby"] or "sections"
	if groupBy then table.insert(debugLog, 'ℹ️ groupby=' .. tostring(groupBy)) end

	-- Section info
	--local speakerInfo, sectionInfo, subsectionInfo, addresseeInfo, emotionInfo = 
	extractSectionColumns(sections)
	sortSectionListByFirstLine(sectionInfo)
	includeSectionBreaks = #sectionInfo > 0
	table.insert(debugLog, 'ℹ️ Debug: sections = <div class=\"mw-collapsible mw-collapsed\"><pre>' .. mw.text.nowiki(mw.dumpObject(sections)) .. '</pre></div>') 

	-- Line break tracking (optional)
	if includeSectionBreaks then
		adjustRowspansForBreaks()
	end
	
	-- Annotation and styling info
	annotationList = parseJSONArg(frame, "annotations") or {}
	table.insert(debugLog, 'ℹ️ Debug: annotations = <div class=\"mw-collapsible mw-collapsed\"><pre>' .. mw.text.nowiki(mw.dumpObject(annotationList)) .. '</pre></div>') 

	colorMap = parseJSONArg(frame, "colormap")
	table.insert(debugLog, 'ℹ️ Debug: colors = <div class=\"mw-collapsible mw-collapsed\"><pre>' .. mw.text.nowiki(mw.dumpObject(colorMap)) .. '</pre></div>') 
	local putVerseFirst = not (tostring(args["versefirst"] or ""):lower() == "false" or tostring(args["versefirst"]):lower() == "no")
	table.insert(debugLog, 'ℹ️ Debug: putVerseFirst=' .. tostring(putVerseFirst)) 

	clusterCBC = not (tostring(args["clusterCBC"] or ""):lower() == "false" or tostring(args["clusterCBC"]):lower() == "no")


	includeColors = not (tostring(args["colors"] or ""):lower() == "false" or tostring(args["colors"]):lower() == "no")
	-- if DEBUG then table.insert(debugLog, 'ℹ️ Debug: colors=' .. tostring(includeColors)) end

	groupEnglish = not (tostring(args["groupingEnglish"] or ""):lower() == "false" or tostring(args["groupingEnglish"]):lower() == "no")
	colorSections = not (tostring(args["colorsections"] or ""):lower() == "false" or tostring(args["colorsections"]):lower() == "no")

	-- Parse lines and optional SMW subobjects
	local buildSubobjects = args["subobjects"] or false
	local lines, subobjects = parseOverlayLines(content, buildSubobjects)
	sortWordGroupsByIndex(lines)
	table.insert(debugLog, 'ℹ️ Debug: lines = <div class=\"mw-collapsible mw-collapsed\"><pre>' .. mw.text.nowiki(mw.dumpObject(lines)) .. '</pre></div>') 

	-- Sort and iterate lines
	local sortedLineIDs = sortLineIDs(lines)
	local lastLineID = sortedLineIDs[#sortedLineIDs]
	local lineCount = 0

	for _, lineID in ipairs(sortedLineIDs) do
		local group = lines[lineID]
		lineCount = lineCount + 1
		if maxLines and lineCount > maxLines then break end

		local tableRow, subobjectRow = buildLineRow(
			lineID, group, insertions,
			lastLineID, groupBy, buildSubobjects, subobjects, putVerseFirst
		)

		table.insert(out, tableRow)
		if buildSubobjects then
			table.insert(subobjects, subobjectRow)
		end
	end

	-- Optional subobject output
	if buildSubobjects then
		frame:preprocess(table.concat(subobjects, "\n"))
	end

	if DEBUG then
		return table.concat(out, "\n") .. table.concat(debugLog, "\n")
	else
		return table.concat(out, "\n")
	end

end

function p.buildTableSubobjects(frame)
	local out = {}
	local args = frame.args

	chapter = pf.getPsalmNumber(frame)

	-- Debug mode
	DEBUG = args["debug"] == "true"
	if DEBUG then table.insert(debugLog, "|}") end


	-- Extract and validate input text
	local content = cleanSMWInput(args["text"] or args["tokens"])
	if content == "" then return "No content found." end

	-- Parse section JSON
	local sectionsJSON = cleanSMWInput(args["sections"] or "")
	local ok, sectionData = pcall(mw.text.jsonDecode, sectionsJSON)
	local sections = (ok and type(sectionData) == "table") and sectionData or {}
	table.insert(debugLog, 'ℹ️ Debug: sections = <div class=\"mw-collapsible mw-collapsed\"><pre>' .. mw.text.nowiki(mw.dumpObject(sections)) .. '</pre></div>') 

	-- Parse insertions JSON
	local insertionsJSON = cleanSMWInput(args["insertions"] or "")
	local ok, insertionsData = pcall(mw.text.jsonDecode, insertionsJSON)
	local insertions = (ok and type(insertionsData) == "table") and insertionsData or {}
	if #insertions > 0 then
		table.insert(debugLog, "Parsed insertions: " .. tostring(#insertions) .. " items")
		table.insert(debugLog, 'ℹ️ Insertions = <div class=\"mw-collapsible mw-collapsed\"><pre>' .. mw.text.nowiki(mw.dumpObject(insertions)) .. '</pre></div>') 
	 end

	-- Settings
	local groupBy = args["groupby"] or "sections"
	if groupBy then table.insert(debugLog, 'ℹ️ groupby=' .. tostring(groupBy)) end

	-- Section info
	--local speakerInfo, sectionInfo, subsectionInfo, addresseeInfo, emotionInfo = 
	extractSectionColumns(sections)
	sortSectionListByFirstLine(sectionInfo)
	includeSectionBreaks = #sectionInfo > 0
	table.insert(debugLog, 'ℹ️ Debug: sections = <div class=\"mw-collapsible mw-collapsed\"><pre>' .. mw.text.nowiki(mw.dumpObject(sections)) .. '</pre></div>') 

	-- Line break tracking (optional)
	if includeSectionBreaks then
		adjustRowspansForBreaks()
	end
	
	-- Annotation and styling info
	annotationList = parseJSONArg(frame, "annotations") or {}
	table.insert(debugLog, 'ℹ️ Debug: annotations = <div class=\"mw-collapsible mw-collapsed\"><pre>' .. mw.text.nowiki(mw.dumpObject(annotationList)) .. '</pre></div>') 

	colorMap = parseJSONArg(frame, "colormap")
	table.insert(debugLog, 'ℹ️ Debug: colors = <div class=\"mw-collapsible mw-collapsed\"><pre>' .. mw.text.nowiki(mw.dumpObject(colorMap)) .. '</pre></div>') 
	local putVerseFirst = not (tostring(args["versefirst"] or ""):lower() == "false" or tostring(args["versefirst"]):lower() == "no")
	table.insert(debugLog, 'ℹ️ Debug: putVerseFirst=' .. tostring(putVerseFirst)) 

	clusterCBC = not (tostring(args["clusterCBC"] or ""):lower() == "false" or tostring(args["clusterCBC"]):lower() == "no")


	includeColors = not (tostring(args["colors"] or ""):lower() == "false" or tostring(args["colors"]):lower() == "no")
	-- if DEBUG then table.insert(debugLog, 'ℹ️ Debug: colors=' .. tostring(includeColors)) end

	groupEnglish = not (tostring(args["groupingEnglish"] or ""):lower() == "false" or tostring(args["groupingEnglish"]):lower() == "no")

	-- Parse lines and optional SMW subobjects
	local buildSubobjects = args["subobjects"] or false
	local lines, subobjects = parseOverlayLines(content, buildSubobjects)
	sortWordGroupsByIndex(lines)

	-- Sort and iterate lines
	local sortedLineIDs = sortLineIDs(lines)
	local lastLineID = sortedLineIDs[#sortedLineIDs]
	local lineCount = 0

	for _, lineID in ipairs(sortedLineIDs) do
		local group = lines[lineID]
		lineCount = lineCount + 1
		if maxLines and lineCount > maxLines then break end

		local tableRow, subobjectRow = buildLineRow(
			lineID, group, insertions,
			lastLineID, groupBy, buildSubobjects, subobjects, putVerseFirst
		)

			table.insert(subobjects, subobjectRow)
	end

	frame:preprocess(table.concat(subobjects, "\n"))

	if DEBUG then
		return table.concat(subobjects, "\n") .. table.concat(debugLog, "\n")
	else
		return table.concat(subobjects, "\n")
	end

end

function p.renderPsalmTableEscaped(frame)
  -- Call the actual rendering function with passed arguments
  local result = p.renderOverlayTableSection(frame)
  
  -- Replace all pipes with {{!}} to escape in wikitext
  local escaped = result:gsub("|", "{{!}}")

  return escaped
end
return p