diff --git a/package.json b/package.json index 1b3cb923..5112f572 100644 --- a/package.json +++ b/package.json @@ -560,6 +560,11 @@ "default": false, "description": "Internal flag indicating the edit history format migration has completed." }, + "codex-project-manager.verseRangeLabelsAndPositionsMigrationCompleted": { + "type": "boolean", + "default": false, + "description": "Internal flag indicating the verse range labels and positions migration has completed." + }, "codex-project-manager.documentContextHoistMigrationCompleted": { "type": "boolean", "default": false, diff --git a/src/extension.ts b/src/extension.ts index 2baf9308..5c1ce762 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,6 +19,7 @@ import { migration_addMilestoneCells, migration_reorderMisplacedParatextCells, migration_addGlobalReferences, + migration_verseRangeLabelsAndPositions, migration_cellIdsToUuid, migration_recoverTempFilesAndMergeDuplicates, } from "./projectManager/utils/migrationUtils"; @@ -623,6 +624,7 @@ export async function activate(context: vscode.ExtensionContext) { await migration_addMilestoneCells(context); await migration_reorderMisplacedParatextCells(context); await migration_addGlobalReferences(context); + await migration_verseRangeLabelsAndPositions(context); await migration_cellIdsToUuid(context); await migration_recoverTempFilesAndMergeDuplicates(context); diff --git a/src/projectManager/utils/migrationUtils.ts b/src/projectManager/utils/migrationUtils.ts index 3beb532e..5e9cddbd 100644 --- a/src/projectManager/utils/migrationUtils.ts +++ b/src/projectManager/utils/migrationUtils.ts @@ -12,6 +12,11 @@ import { getAuthApi } from "../../extension"; import { extractParentCellIdFromParatext } from "../../providers/codexCellEditorProvider/utils/cellUtils"; import { generateCellIdFromHash, isUuidFormat } from "../../utils/uuidUtils"; import { getCorrespondingSourceUri, getCorrespondingCodexUri } from "../../utils/codexNotebookUtils"; +import { + parseVerseRef, + getSortKeyFromParsedRef, + type ParsedVerseRef, +} from "../../utils/verseRefUtils"; import bibleData from "../../../webviews/codex-webviews/src/assets/bible-books-lookup.json"; import { resolveCodexCustomMerge, mergeDuplicateCellsUsingResolverLogic } from "./merge/resolvers"; import { atomicWriteUriText } from "../../utils/notebookSafeSaveUtils"; @@ -2080,6 +2085,21 @@ function getLocalizedBookName(bookAbbr: string): string { return bookInfo?.name || bookAbbr; } +/** + * Extracts chapter number from a milestone value (e.g. "John 4", "4", "GEN 2"). + * Used for verse-range migration to associate milestones with content chapters. + */ +function extractChapterNumberFromMilestoneValue(value: string | undefined): number | null { + if (!value) return null; + const matches = value.match(/(\d+)(?!.*\d)/); + if (matches?.[1]) { + const n = parseInt(matches[1], 10); + return !isNaN(n) && n > 0 ? n : null; + } + const parsed = parseInt(value, 10); + return !isNaN(parsed) && parsed > 0 ? parsed : null; +} + /** * Creates a milestone cell with book name and chapter number derived from the cell below it. * Format: "BookName ChapterNumber" (e.g., "Isaiah 1") @@ -2939,6 +2959,257 @@ export async function migrateGlobalReferencesForFile(fileUri: vscode.Uri): Promi } } +/** + * Processes a single file: reorders content cells so verse-range cells appear after the correct + * chapter milestone and in verse order, and sets cellLabel (and optional chapterNumber) for + * verse-range refs. Combines reorder and labelling in one pass. Idempotent. + * Returns true if the file was modified, false otherwise. + */ +export async function migrateVerseRangeLabelsAndPositionsForFile( + fileUri: vscode.Uri +): Promise { + try { + const fileContent = await vscode.workspace.fs.readFile(fileUri); + const serializer = new CodexContentSerializer(); + const notebookData: any = await serializer.deserializeNotebook( + fileContent, + new vscode.CancellationTokenSource().token + ); + + const cells: any[] = notebookData.cells || []; + if (cells.length === 0) return false; + + // Classify cells: milestones (with chapter from value), content with ref, content without ref, paratext, style/other + const milestones: Array<{ cell: any; chapter: number | null }> = []; + const contentWithRef: Array<{ cell: any; parsed: ParsedVerseRef; sortKey: { book: string; chapter: number; verse: number } }> = []; + const contentWithoutRef: any[] = []; + const paratextByParentId = new Map(); + const styleOrOther: any[] = []; + + for (const cell of cells) { + const md = cell.metadata || {}; + const cellType = md.type; + const cellId = md.id; + + if (cellType === CodexCellTypes.MILESTONE) { + const chapter = extractChapterNumberFromMilestoneValue(cell.value); + milestones.push({ cell, chapter }); + continue; + } + + if (cellType === CodexCellTypes.PARATEXT && cellId) { + const parentId = extractParentCellIdFromParatext(cellId, md); + if (parentId) { + if (!paratextByParentId.has(parentId)) paratextByParentId.set(parentId, []); + paratextByParentId.get(parentId)!.push(cell); + } else { + styleOrOther.push(cell); + } + continue; + } + + if (cellType === CodexCellTypes.STYLE) { + styleOrOther.push(cell); + continue; + } + + // Content cell (TEXT or similar) + const ref = md.data?.globalReferences?.[0]; + const parsed = typeof ref === "string" ? parseVerseRef(ref) : null; + if (parsed) { + const sortKey = getSortKeyFromParsedRef(parsed); + contentWithRef.push({ cell, parsed, sortKey }); + } else { + contentWithoutRef.push(cell); + } + } + + // Partition content-with-ref by (book, chapter), sort each by verse + const contentByChapter = new Map(); + for (const item of contentWithRef) { + const key = `${item.sortKey.book}\t${item.sortKey.chapter}`; + if (!contentByChapter.has(key)) contentByChapter.set(key, []); + contentByChapter.get(key)!.push(item); + } + for (const arr of contentByChapter.values()) { + arr.sort((a, b) => a.sortKey.verse - b.sortKey.verse); + } + + // Build new cell order: for each milestone, emit milestone then content for that chapter; after each content cell emit its paratext + const newCells: any[] = []; + let hasChanges = false; + + const emitContentCell = (item: (typeof contentWithRef)[0]) => { + const { cell, parsed } = item; + const md = cell.metadata || {}; + if (parsed.kind === "range") { + if (md.cellLabel !== parsed.cellLabel) { + md.cellLabel = parsed.cellLabel; + hasChanges = true; + } + if (md.chapterNumber === undefined || md.chapterNumber === null) { + md.chapterNumber = String(parsed.chapter); + hasChanges = true; + } + cell.metadata = md; + } + newCells.push(cell); + const parentId = md.id; + if (parentId) { + const paratextCells = paratextByParentId.get(parentId); + if (paratextCells) { + for (const pt of paratextCells) newCells.push(pt); + } + } + }; + + for (const { cell, chapter } of milestones) { + newCells.push(cell); + if (chapter != null) { + // Emit all content cells whose sortKey chapter matches this milestone's chapter + const keysToDelete: string[] = []; + for (const [key, items] of contentByChapter.entries()) { + const [, chapStr] = key.split("\t"); + if (parseInt(chapStr, 10) === chapter) { + for (const item of items) emitContentCell(item); + keysToDelete.push(key); + } + } + for (const k of keysToDelete) contentByChapter.delete(k); + } + } + + // Emit remaining content-with-ref (chapter not matched to any milestone) in deterministic order + const remaining: typeof contentWithRef = []; + for (const items of contentByChapter.values()) remaining.push(...items); + remaining.sort( + (a, b) => + a.sortKey.book.localeCompare(b.sortKey.book) || + a.sortKey.chapter - b.sortKey.chapter || + a.sortKey.verse - b.sortKey.verse + ); + for (const item of remaining) emitContentCell(item); + + // Emit content without ref in original order, then style/other + for (const cell of contentWithoutRef) { + newCells.push(cell); + const parentId = cell.metadata?.id; + if (parentId) { + const paratextCells = paratextByParentId.get(parentId); + if (paratextCells) { + for (const pt of paratextCells) newCells.push(pt); + } + } + } + for (const cell of styleOrOther) newCells.push(cell); + + // Check if order or content changed + const oldIds = cells.map((c) => c.metadata?.id ?? "").join(","); + const newIds = newCells.map((c) => c.metadata?.id ?? "").join(","); + const orderChanged = oldIds !== newIds; + if (orderChanged || hasChanges) { + notebookData.cells = newCells; + const updatedContent = await serializer.serializeNotebook( + notebookData, + new vscode.CancellationTokenSource().token + ); + await vscode.workspace.fs.writeFile(fileUri, updatedContent); + return true; + } + return false; + } catch (error) { + console.error(`Error migrating verse range labels/positions for ${fileUri.fsPath}:`, error); + return false; + } +} + +/** + * Migration: Fix verse-range cell positions (after chapter milestone, in verse order) and set + * cellLabel for verse-range refs. Runs on both .codex and .source files. Idempotent. + */ +export const migration_verseRangeLabelsAndPositions = async ( + context?: vscode.ExtensionContext +): Promise => { + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) return; + + const migrationKey = "verseRangeLabelsAndPositionsMigrationCompleted"; + const config = vscode.workspace.getConfiguration("codex-project-manager"); + let hasMigrationRun = false; + try { + hasMigrationRun = config.get(migrationKey, false); + } catch { + hasMigrationRun = !!context?.workspaceState.get(migrationKey); + } + if (hasMigrationRun) { + debug("Verse range labels/positions migration already completed, skipping"); + return; + } + + debug("Running verse range labels/positions migration..."); + const workspaceFolder = workspaceFolders[0]; + const codexFiles = await vscode.workspace.findFiles( + new vscode.RelativePattern(workspaceFolder, "**/*.codex") + ); + const sourceFiles = await vscode.workspace.findFiles( + new vscode.RelativePattern(workspaceFolder, "**/*.source") + ); + const allFiles = [...codexFiles, ...sourceFiles]; + + if (allFiles.length === 0) { + try { + await config.update(migrationKey, true, vscode.ConfigurationTarget.Workspace); + } catch { + await context?.workspaceState.update(migrationKey, true); + } + return; + } + + let processedFiles = 0; + let migratedFiles = 0; + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Fixing verse range labels and positions", + cancellable: false, + }, + async (progress) => { + for (let i = 0; i < allFiles.length; i++) { + const file = allFiles[i]; + progress.report({ + message: path.basename(file.fsPath), + increment: 100 / allFiles.length, + }); + try { + const wasMigrated = await migrateVerseRangeLabelsAndPositionsForFile(file); + processedFiles++; + if (wasMigrated) migratedFiles++; + } catch (error) { + console.error(`Error processing ${file.fsPath}:`, error); + } + } + } + ); + + try { + await config.update(migrationKey, true, vscode.ConfigurationTarget.Workspace); + } catch { + await context?.workspaceState.update(migrationKey, true); + } + debug( + `Verse range labels/positions migration completed: ${processedFiles} files processed, ${migratedFiles} migrated` + ); + if (migratedFiles > 0) { + vscode.window.showInformationMessage( + `Verse range migration complete: ${migratedFiles} files updated` + ); + } + } catch (error) { + console.error("Error running verse range labels/positions migration:", error); + } +}; + /** * Migration: Convert all cell IDs to UUID format using SHA-256 hash of original ID. * For child cells (those with IDs containing ':' separators), adds metadata.parentId field. diff --git a/src/test/suite/migration_verseRangeLabelsAndPositions.test.ts b/src/test/suite/migration_verseRangeLabelsAndPositions.test.ts new file mode 100644 index 00000000..4d99beaf --- /dev/null +++ b/src/test/suite/migration_verseRangeLabelsAndPositions.test.ts @@ -0,0 +1,283 @@ +import * as assert from "assert"; +import * as vscode from "vscode"; +import * as os from "os"; +import * as path from "path"; +import { randomUUID } from "crypto"; +import { CodexContentSerializer } from "../../serializer"; +import { migrateVerseRangeLabelsAndPositionsForFile } from "../../projectManager/utils/migrationUtils"; +import { CodexCellTypes } from "../../../types/enums"; + +async function createTempNotebookFile( + ext: ".codex" | ".source", + cells: Array<{ kind?: number; languageId?: string; value: string; metadata: any }> +): Promise { + const serializer = new CodexContentSerializer(); + const tmpDir = os.tmpdir(); + const fileName = `verse-range-migration-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`; + const uri = vscode.Uri.file(path.join(tmpDir, fileName)); + + const notebook: any = { + cells: cells.map((cell) => ({ + kind: cell.kind ?? 2, + languageId: cell.languageId ?? "scripture", + value: cell.value, + metadata: cell.metadata, + })), + metadata: {}, + }; + + const bytes = await serializer.serializeNotebook( + notebook, + new vscode.CancellationTokenSource().token + ); + await vscode.workspace.fs.writeFile(uri, bytes); + return uri; +} + +async function readNotebookFile(uri: vscode.Uri): Promise { + const fileBytes = await vscode.workspace.fs.readFile(uri); + const serializer = new CodexContentSerializer(); + return await serializer.deserializeNotebook( + fileBytes, + new vscode.CancellationTokenSource().token + ); +} + +function ref(ref: string) { + return { data: { globalReferences: [ref] } }; +} + +suite("migrateVerseRangeLabelsAndPositionsForFile", () => { + let testFiles: vscode.Uri[] = []; + + teardown(async () => { + for (const uri of testFiles) { + try { + await vscode.workspace.fs.delete(uri); + } catch { + // ignore + } + } + testFiles = []; + }); + + test("should move verse-range cell (4:1-3) after milestone and set cellLabel", async () => { + const id1 = randomUUID(); + const id3 = randomUUID(); + const milestoneId = randomUUID(); + + const uri = await createTempNotebookFile(".codex", [ + { + value: "Jesus knew the Pharisees heard...", + metadata: { + id: id1, + type: CodexCellTypes.TEXT, + ...ref("JHN 4:1-3"), + edits: [], + }, + }, + { + value: "John 4", + languageId: "html", + metadata: { id: milestoneId, type: CodexCellTypes.MILESTONE, edits: [] }, + }, + { + value: "He had to pass through Samaria.", + metadata: { + id: id3, + type: CodexCellTypes.TEXT, + ...ref("JHN 4:4"), + cellLabel: "4", + edits: [], + }, + }, + ]); + testFiles.push(uri); + + const wasMigrated = await migrateVerseRangeLabelsAndPositionsForFile(uri); + assert.strictEqual(wasMigrated, true, "Migration should have occurred"); + + const data = await readNotebookFile(uri); + assert.strictEqual(data.cells.length, 3, "Should have same number of cells"); + + const first = data.cells[0]; + const second = data.cells[1]; + const third = data.cells[2]; + + assert.strictEqual(first.metadata?.type, CodexCellTypes.MILESTONE, "First cell should be milestone"); + assert.strictEqual(first.value, "John 4"); + + assert.strictEqual(second.metadata?.type, CodexCellTypes.TEXT, "Second cell should be content 4:1-3"); + assert.deepStrictEqual(second.metadata?.data?.globalReferences, ["JHN 4:1-3"]); + assert.strictEqual(second.metadata?.cellLabel, "1-3", "Verse range cell should have cellLabel 1-3"); + + assert.strictEqual(third.metadata?.type, CodexCellTypes.TEXT, "Third cell should be content 4:4"); + assert.strictEqual(third.metadata?.cellLabel, "4"); + }); + + test("should set cellLabel for mid-chapter verse range (4:7-8)", async () => { + const id6 = randomUUID(); + const id78 = randomUUID(); + const id9 = randomUUID(); + const milestoneId = randomUUID(); + + const uri = await createTempNotebookFile(".codex", [ + { + value: "John 4", + languageId: "html", + metadata: { id: milestoneId, type: CodexCellTypes.MILESTONE, edits: [] }, + }, + { + value: "Jacob's well was there.", + metadata: { + id: id6, + type: CodexCellTypes.TEXT, + ...ref("JHN 4:6"), + cellLabel: "6", + edits: [], + }, + }, + { + value: "A Samaritan woman came to draw water.", + metadata: { + id: id78, + type: CodexCellTypes.TEXT, + ...ref("JHN 4:7-8"), + edits: [], + }, + }, + { + value: "The Samaritan woman said...", + metadata: { + id: id9, + type: CodexCellTypes.TEXT, + ...ref("JHN 4:9"), + cellLabel: "9", + edits: [], + }, + }, + ]); + testFiles.push(uri); + + const wasMigrated = await migrateVerseRangeLabelsAndPositionsForFile(uri); + assert.strictEqual(wasMigrated, true, "Migration should have occurred (labelling)"); + + const data = await readNotebookFile(uri); + const cell78 = data.cells.find( + (c: any) => c.metadata?.data?.globalReferences?.[0] === "JHN 4:7-8" + ); + assert.ok(cell78, "Should find cell with JHN 4:7-8"); + assert.strictEqual(cell78.metadata?.cellLabel, "7-8", "Verse range 4:7-8 should have cellLabel 7-8"); + + const order = data.cells + .filter((c: any) => c.metadata?.data?.globalReferences?.[0]) + .map((c: any) => c.metadata.data.globalReferences[0]); + assert.deepStrictEqual( + order, + ["JHN 4:6", "JHN 4:7-8", "JHN 4:9"], + "Order should remain 6, 7-8, 9" + ); + }); + + test("should be idempotent - running twice should not change result", async () => { + const milestoneId = randomUUID(); + const id1 = randomUUID(); + const id2 = randomUUID(); + + const uri = await createTempNotebookFile(".codex", [ + { + value: "John 4", + languageId: "html", + metadata: { id: milestoneId, type: CodexCellTypes.MILESTONE, edits: [] }, + }, + { + value: "Content 4:1-3", + metadata: { + id: id1, + type: CodexCellTypes.TEXT, + ...ref("JHN 4:1-3"), + edits: [], + }, + }, + { + value: "Content 4:4", + metadata: { + id: id2, + type: CodexCellTypes.TEXT, + ...ref("JHN 4:4"), + cellLabel: "4", + edits: [], + }, + }, + ]); + testFiles.push(uri); + + await migrateVerseRangeLabelsAndPositionsForFile(uri); + const data1 = await readNotebookFile(uri); + const ids1 = data1.cells.map((c: any) => c.metadata?.id).join(","); + + const second = await migrateVerseRangeLabelsAndPositionsForFile(uri); + const data2 = await readNotebookFile(uri); + const ids2 = data2.cells.map((c: any) => c.metadata?.id).join(","); + + assert.strictEqual(second, false, "Second run should report no changes"); + assert.strictEqual(ids1, ids2, "Order should be unchanged"); + const cell13 = data2.cells.find( + (c: any) => c.metadata?.data?.globalReferences?.[0] === "JHN 4:1-3" + ); + assert.strictEqual(cell13?.metadata?.cellLabel, "1-3"); + }); + + test("should handle empty file gracefully", async () => { + const uri = await createTempNotebookFile(".codex", []); + testFiles.push(uri); + + const wasMigrated = await migrateVerseRangeLabelsAndPositionsForFile(uri); + assert.strictEqual(wasMigrated, false); + const data = await readNotebookFile(uri); + assert.strictEqual(data.cells.length, 0); + }); + + test("should process .source file same as .codex", async () => { + const id1 = randomUUID(); + const id2 = randomUUID(); + const milestoneId = randomUUID(); + + const uri = await createTempNotebookFile(".source", [ + { + value: "Verse range content", + metadata: { + id: id1, + type: CodexCellTypes.TEXT, + ...ref("JHN 4:1-3"), + edits: [], + }, + }, + { + value: "John 4", + languageId: "html", + metadata: { id: milestoneId, type: CodexCellTypes.MILESTONE, edits: [] }, + }, + { + value: "Single verse content", + metadata: { + id: id2, + type: CodexCellTypes.TEXT, + ...ref("JHN 4:4"), + cellLabel: "4", + edits: [], + }, + }, + ]); + testFiles.push(uri); + + const wasMigrated = await migrateVerseRangeLabelsAndPositionsForFile(uri); + assert.strictEqual(wasMigrated, true); + + const data = await readNotebookFile(uri); + assert.strictEqual(data.cells[0].metadata?.type, CodexCellTypes.MILESTONE); + assert.strictEqual(data.cells[1].metadata?.data?.globalReferences?.[0], "JHN 4:1-3"); + assert.strictEqual(data.cells[1].metadata?.cellLabel, "1-3"); + assert.strictEqual(data.cells[2].metadata?.data?.globalReferences?.[0], "JHN 4:4"); + }); +}); diff --git a/src/test/suite/verseRefUtils.test.ts b/src/test/suite/verseRefUtils.test.ts index dc53cbd2..af40c6e8 100644 --- a/src/test/suite/verseRefUtils.test.ts +++ b/src/test/suite/verseRefUtils.test.ts @@ -3,6 +3,8 @@ import { extractVerseRefFromLine, getVerseRefFromCellMetadata, verseRefRegex, + parseVerseRef, + getSortKeyFromParsedRef, } from "../../utils/verseRefUtils"; suite("verseRefUtils Test Suite", () => { @@ -121,6 +123,23 @@ suite("verseRefUtils Test Suite", () => { ); }); + test("returns verse-range ref from globalReferences (JHN 4:1-3)", () => { + assert.strictEqual( + getVerseRefFromCellMetadata({ + id: "uuid", + data: { globalReferences: ["JHN 4:1-3"] }, + }), + "JHN 4:1-3" + ); + assert.strictEqual( + getVerseRefFromCellMetadata({ + id: "uuid", + data: { globalReferences: ["JHN 4:7-8"] }, + }), + "JHN 4:7-8" + ); + }); + test("trims bookCode when building from bookCode/chapter/verse", () => { assert.strictEqual( getVerseRefFromCellMetadata({ @@ -139,4 +158,50 @@ suite("verseRefUtils Test Suite", () => { assert.ok(verseRefRegex.test("GEN 2:3")); }); }); + + suite("parseVerseRef", () => { + test("parses single verse (BOOK C:V)", () => { + const r = parseVerseRef("JHN 4:4"); + assert.ok(r && r.kind === "single"); + assert.strictEqual(r.book, "JHN"); + assert.strictEqual(r.chapter, 4); + assert.strictEqual(r.verse, 4); + assert.strictEqual(r.cellLabel, "4"); + }); + + test("parses verse range (BOOK C:V1-V2)", () => { + const r = parseVerseRef("JHN 4:1-3"); + assert.ok(r && r.kind === "range"); + assert.strictEqual(r.book, "JHN"); + assert.strictEqual(r.chapter, 4); + assert.strictEqual(r.verseStart, 1); + assert.strictEqual(r.verseEnd, 3); + assert.strictEqual(r.cellLabel, "1-3"); + }); + + test("parses verse range 4:7-8", () => { + const r = parseVerseRef("JHN 4:7-8"); + assert.ok(r && r.kind === "range"); + assert.strictEqual(r.cellLabel, "7-8"); + }); + + test("returns null for invalid or empty", () => { + assert.strictEqual(parseVerseRef(""), null); + assert.strictEqual(parseVerseRef("not a ref"), null); + }); + }); + + suite("getSortKeyFromParsedRef", () => { + test("single verse returns book, chapter, verse", () => { + const r = parseVerseRef("JHN 4:4")!; + const key = getSortKeyFromParsedRef(r); + assert.deepStrictEqual(key, { book: "JHN", chapter: 4, verse: 4 }); + }); + + test("verse range uses verseStart as verse", () => { + const r = parseVerseRef("JHN 4:1-3")!; + const key = getSortKeyFromParsedRef(r); + assert.deepStrictEqual(key, { book: "JHN", chapter: 4, verse: 1 }); + }); + }); }); diff --git a/src/utils/verseRefUtils/index.ts b/src/utils/verseRefUtils/index.ts index c5b6a194..7761085a 100644 --- a/src/utils/verseRefUtils/index.ts +++ b/src/utils/verseRefUtils/index.ts @@ -80,10 +80,83 @@ export function extractVerseRefFromLine(line: string): string | null { /** Pattern for "BOOK 1:1" style at end of string (used for metadata.id or globalReferences) */ const verseRefAtEndRegex = /\s\d+:\d+$/; +/** Single verse ref: "BOOK C:V" */ +export type ParsedSingleVerseRef = { + kind: "single"; + book: string; + chapter: number; + verse: number; + cellLabel: string; +}; + +/** Verse range ref: "BOOK C:V1-V2" */ +export type ParsedVerseRangeRef = { + kind: "range"; + book: string; + chapter: number; + verseStart: number; + verseEnd: number; + cellLabel: string; +}; + +export type ParsedVerseRef = ParsedSingleVerseRef | ParsedVerseRangeRef; + +/** Match "BOOK C:V1-V2" (verse range) */ +const verseRangeRefRegex = /^\s*([^\s]+)\s+(\d+):(\d+)-(\d+)\s*$/; +/** Match "BOOK C:V" (single verse) */ +const singleVerseRefRegex = /^\s*([^\s]+)\s+(\d+):(\d+)\s*$/; + +/** + * Parse a ref string into a single-verse or verse-range result. + * Examples: "JHN 4:4" -> single; "JHN 4:1-3" -> range with cellLabel "1-3". + */ +export function parseVerseRef(ref: string): ParsedVerseRef | null { + if (typeof ref !== "string" || !ref.trim()) return null; + const rangeMatch = ref.match(verseRangeRefRegex); + if (rangeMatch) { + const [, book, chapter, verseStart, verseEnd] = rangeMatch; + return { + kind: "range", + book: book!, + chapter: parseInt(chapter!, 10), + verseStart: parseInt(verseStart!, 10), + verseEnd: parseInt(verseEnd!, 10), + cellLabel: `${verseStart}-${verseEnd}`, + }; + } + const singleMatch = ref.match(singleVerseRefRegex); + if (singleMatch) { + const [, book, chapter, verse] = singleMatch; + return { + kind: "single", + book: book!, + chapter: parseInt(chapter!, 10), + verse: parseInt(verse!, 10), + cellLabel: verse!, + }; + } + return null; +} + +/** + * Sort key (book, chapter, verse) for ordering content cells. + * For verse ranges, verse is the start of the range. + */ +export function getSortKeyFromParsedRef(parsed: ParsedVerseRef): { book: string; chapter: number; verse: number } { + if (parsed.kind === "single") { + return { book: parsed.book, chapter: parsed.chapter, verse: parsed.verse }; + } + return { book: parsed.book, chapter: parsed.chapter, verse: parsed.verseStart }; +} + +/** Pattern that matches either single verse or verse range at end (for backward compatibility) */ +const verseRefOrRangeAtEndRegex = /\s\d+:\d+(-\d+)?$/; + /** - * Get verse reference string (e.g. "MAT 1:1") from cell metadata. + * Get verse reference string (e.g. "MAT 1:1" or "JHN 4:1-3") from cell metadata. * Supports legacy format (metadata.id = "BOOK 1:1") and New Source Uploader USFM * (metadata.id = UUID, reference in data.globalReferences or bookCode/chapter/verse). + * Also recognizes verse-range refs in globalReferences (e.g. "JHN 4:1-3"). */ export function getVerseRefFromCellMetadata(metadata: { id?: string; @@ -94,9 +167,9 @@ export function getVerseRefFromCellMetadata(metadata: { }): string | null { if (!metadata) return null; const id = metadata.id; - if (typeof id === "string" && verseRefAtEndRegex.test(id)) return id; + if (typeof id === "string" && verseRefOrRangeAtEndRegex.test(id)) return id; const ref = metadata.data?.globalReferences?.[0]; - if (typeof ref === "string" && verseRefAtEndRegex.test(ref)) return ref; + if (typeof ref === "string" && verseRefOrRangeAtEndRegex.test(ref)) return ref; const { bookCode, chapter, verse } = metadata; if (bookCode != null && chapter != null && verse != null) return `${String(bookCode).trim()} ${chapter}:${verse}`;