Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
c44c04c
Add subdivision-aware milestone pagination resolver
Luke-Bilhorn Apr 22, 2026
86e2659
Handle milestone subdivision writes with source→target mirroring
Luke-Bilhorn Apr 22, 2026
b2c6457
Thread resolver subdivisions through the editor webview
Luke-Bilhorn Apr 22, 2026
a515058
Allow renaming milestone subdivisions from the accordion
Luke-Bilhorn Apr 23, 2026
b120e92
Add per-subsection delete and per-milestone reset controls (source only)
Luke-Bilhorn Apr 23, 2026
f1af005
Add addMilestoneSubdivisionAnchor handler + shared commit pipeline
Luke-Bilhorn Apr 23, 2026
2800c2f
Add inline "Add break at cell…" form to milestone accordion (source o…
Luke-Bilhorn Apr 23, 2026
9e88bae
Add useSubdivisionNumberLabels setting to force numeric subsection la…
Luke-Bilhorn Apr 23, 2026
6e0d734
Surface cellsPerPage and useSubdivisionNumberLabels in Interface Sett…
Luke-Bilhorn Apr 23, 2026
8d902ba
Refresh paired target webview after source-side subdivision placement…
Luke-Bilhorn Apr 24, 2026
66a43fd
Move subdivision edit affordances behind a gear/settings toggle
Luke-Bilhorn Apr 24, 2026
fdfb9cd
Adaptive chunking, maxSubdivisionLength setting, and named-break pers…
Luke-Bilhorn Apr 24, 2026
675967c
Surface maxSubdivisionLength in Interface Settings; polish copy
Luke-Bilhorn Apr 24, 2026
60357f5
Accordion: red trash-can icons and phantom spacer for vertical alignment
Luke-Bilhorn Apr 24, 2026
7435ba0
Use disabled icon button for non-deletable trash icon.
Luke-Bilhorn Apr 24, 2026
b76b8fe
Tweak milestone row icon spacing.
Luke-Bilhorn Apr 24, 2026
cc2ff47
MilestoneAccordion: fix rename click behavior
Luke-Bilhorn Apr 24, 2026
99869c8
Fix tests on milestone subdivisions
Luke-Bilhorn Apr 30, 2026
8ba8cc6
Fix MilestoneAccordion tests for new per-row rename UI
Luke-Bilhorn May 5, 2026
7789727
Extract milestone cell builder into shared milestoneCellUtils
Luke-Bilhorn May 7, 2026
2b4d371
Add subdivision split/merge helpers and milestone-cell insertion
Luke-Bilhorn May 7, 2026
a335d9d
Add milestone placement editing on source files
Luke-Bilhorn May 7, 2026
2604972
Test milestone placement edits and the new UI gating
Luke-Bilhorn May 7, 2026
c397664
Inline the milestone rename input on the milestone row
Luke-Bilhorn May 8, 2026
dedd2b1
Commit demote on a single click
Luke-Bilhorn May 8, 2026
21f2973
Default new milestones to a 'New milestone' placeholder
Luke-Bilhorn May 8, 2026
a0d02c9
Refresh source + target webviews before persisting structural edits
Luke-Bilhorn May 8, 2026
6cd2a10
Mirror milestone renames from source to target
Luke-Bilhorn May 8, 2026
9dfea29
Refresh subdivision-mirror paths before disk save
Luke-Bilhorn May 8, 2026
070d942
Force-apply server-pushed milestone refreshes in the webview
Luke-Bilhorn May 16, 2026
1e2e997
Capture pre-mutation source roots so the merge mirror reaches the target
Luke-Bilhorn May 16, 2026
71ce954
Preserve target subdivision names when mirroring promote/demote
Luke-Bilhorn May 16, 2026
7a7e09b
test(milestone): align demote mirror expectations with subdivisions[]…
Luke-Bilhorn May 16, 2026
6609de2
Fix subdivision rename input focus loss on parent re-renders
Luke-Bilhorn May 16, 2026
b845bed
Merge branch 'main' into milestone-editing
LeviXIII May 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 32 additions & 22 deletions package-lock.json

Large diffs are not rendered by default.

23 changes: 22 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -941,11 +941,32 @@
"description": "Text direction for the editor (left-to-right or right-to-left)"
},
"codex-editor-extension.cellsPerPage": {
"title": "Cells Per Page",
"type": "number",
"default": 50,
"minimum": 5,
"maximum": 200,
"description": "Number of cells to display per page when a chapter has many cells. Helps with performance for large chapters."
"description": "Default page size for milestones without custom subdivision breaks. Applies only as a fallback when a milestone has no user-defined subdivisions."
},
"codex-editor-extension.useSubdivisionNumberLabels": {
"title": "Always Show Subdivision Number Ranges",
"type": "boolean",
"default": false,
"description": "When enabled, milestone subdivisions always display their numeric cell range (e.g. '6-15') even if a user-assigned name exists. When disabled, names take precedence and the range is shown only as a muted suffix."
},
"codex-editor-extension.maxSubdivisionLength": {
"title": "Maximum Subdivision Length",
"type": "number",
"default": 0,
"minimum": 0,
"maximum": 5000,
"description": "Maximum number of cells the editor will leave inside a single subdivision. Stretches between user-defined breaks that exceed this length are sub-chunked using 'Cells Per Page'. Set to 0 to disable (in which case 'Cells Per Page' itself acts as the threshold). Useful when you want a higher 'Cells Per Page' default but still allow short logical pages between custom breaks to remain unbroken."
},
"codex-editor-extension.enableMilestonePlacementEditing": {
"title": "Enable Milestone Placement Editing",
"type": "boolean",
"default": false,
"description": "When enabled, the milestone accordion's settings mode reveals controls for editing where milestones sit in the source document — adding, removing, promoting a subdivision break to a milestone, or demoting a milestone to a subdivision break. Edits made on the source mirror to the paired target by UUID."
},
"codex-editor-extension.useOnlyValidatedExamples": {
"title": "Use Only Validated Examples",
Expand Down
87 changes: 87 additions & 0 deletions src/interfaceSettings/interfaceSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,25 @@ export async function openInterfaceSettings() {
const sendInit = () => {
const config = vscode.workspace.getConfiguration("codex-editor-extension");
const highlightSearchResults = config.get<boolean>("highlightSearchResults", true);
const cellsPerPage = config.get<number>("cellsPerPage", 50);
const useSubdivisionNumberLabels = config.get<boolean>(
"useSubdivisionNumberLabels",
false
);
const maxSubdivisionLength = config.get<number>("maxSubdivisionLength", 0);
const enableMilestonePlacementEditing = config.get<boolean>(
"enableMilestonePlacementEditing",
false
);

panel.webview.postMessage({
command: "init",
data: {
highlightSearchResults,
cellsPerPage,
useSubdivisionNumberLabels,
maxSubdivisionLength,
enableMilestonePlacementEditing,
},
});
};
Expand Down Expand Up @@ -99,6 +113,79 @@ export async function openInterfaceSettings() {
);
break;
}

case "updateCellsPerPage": {
// Clamp to the range declared in package.json so invalid input
// cannot corrupt pagination. Pull bounds from the schema-defined
// minimum/maximum rather than hardcoding them in multiple places.
const raw = Number(message.value);
if (!Number.isFinite(raw)) break;
const clamped = Math.max(5, Math.min(200, Math.round(raw)));
const config = vscode.workspace.getConfiguration("codex-editor-extension");
await config.update(
"cellsPerPage",
clamped,
vscode.ConfigurationTarget.Workspace
);
break;
}

case "updateUseSubdivisionNumberLabels": {
const config = vscode.workspace.getConfiguration("codex-editor-extension");
await config.update(
"useSubdivisionNumberLabels",
Boolean(message.value),
vscode.ConfigurationTarget.Workspace
);
break;
}

case "updateMaxSubdivisionLength": {
// 0 means "off" — the resolver falls back to using `cellsPerPage`
// as the threshold. Anything else is clamped to the package.json
// bounds so corrupted input can't sneak through.
const raw = Number(message.value);
if (!Number.isFinite(raw)) break;
const rounded = Math.round(raw);
const clamped = rounded <= 0 ? 0 : Math.max(0, Math.min(5000, rounded));
const config = vscode.workspace.getConfiguration("codex-editor-extension");
await config.update(
"maxSubdivisionLength",
clamped,
vscode.ConfigurationTarget.Workspace
);
break;
}

case "updateEnableMilestonePlacementEditing": {
const config = vscode.workspace.getConfiguration("codex-editor-extension");
await config.update(
"enableMilestonePlacementEditing",
Boolean(message.value),
vscode.ConfigurationTarget.Workspace
);
break;
}
}
});

// Keep the panel in sync when settings change from elsewhere (e.g. the
// VS Code Settings UI). Disposed together with the panel below.
const configListener = vscode.workspace.onDidChangeConfiguration((e) => {
if (
e.affectsConfiguration("codex-editor-extension.highlightSearchResults") ||
e.affectsConfiguration("codex-editor-extension.cellsPerPage") ||
e.affectsConfiguration("codex-editor-extension.useSubdivisionNumberLabels") ||
e.affectsConfiguration("codex-editor-extension.maxSubdivisionLength") ||
e.affectsConfiguration(
"codex-editor-extension.enableMilestonePlacementEditing"
)
) {
sendInit();
}
});

panel.onDidDispose(() => {
configListener.dispose();
});
}
154 changes: 14 additions & 140 deletions src/projectManager/utils/migrationUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as vscode from "vscode";
import * as path from "path";
import { randomUUID } from "crypto";
import * as dugiteGit from "../../utils/dugiteGit";
import { CodexContentSerializer } from "@/serializer";
import { vrefData } from "@/utils/verseRefUtils/verseData";
Expand All @@ -11,13 +10,16 @@ import { getAuthApi } from "../../extension";
import { extractParentCellIdFromParatext } from "../../providers/codexCellEditorProvider/utils/cellUtils";
import { generateCellIdFromHash, isUuidFormat } from "../../utils/uuidUtils";
import { getCorrespondingSourceUri, getCorrespondingCodexUri } from "../../utils/codexNotebookUtils";
import {
buildMilestoneCellPayload,
extractChapterFromCellId,
} from "../../utils/milestoneCellUtils";
import {
parseVerseRef,
getSortKeyFromParsedRef,
stripCellIdSuffix,
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";
import { normalizeNotebookFileText, formatJsonForNotebookFile } from "../../utils/notebookFileFormattingUtils";
Expand Down Expand Up @@ -2010,112 +2012,6 @@ async function getCurrentUserName(): Promise<string> {
return "unknown";
}

/**
* Extracts the chapter/section number from a cellId.
* Handles formats like:
* - "GEN 1:1" -> "1"
* - "Book Name 2:5" -> "2"
* - "filename 1:1" -> "1"
* Returns null if the pattern doesn't match.
*/
function extractChapterFromCellId(cellId: string): string | null {
if (!cellId) return null;

// Pattern: anything followed by space, then number, colon, number
// e.g., "GEN 1:1", "Book Name 2:5", "filename 1:1"
const match = cellId.match(/\s+(\d+):(\d+)(?::|$)/);
if (match) {
return match[1]; // Return the chapter number (first number)
}
return null;
}

/**
* Extracts chapter number from a cell using priority order:
* 1. metadata.chapterNumber (Biblica)
* 2. metadata.chapter (USFM)
* 3. metadata.data?.chapter (legacy)
* 4. extractChapterFromCellId (from cellId)
* 5. milestoneIndex (final fallback, 1-indexed)
*/
function extractChapterFromCell(cell: any, milestoneIndex: number): string {
// Priority 1: metadata.chapterNumber (Biblica)
if (cell?.metadata?.chapterNumber !== undefined && cell.metadata.chapterNumber !== null) {
return String(cell.metadata.chapterNumber);
}

// Priority 2: metadata.chapter (USFM)
if (cell?.metadata?.chapter !== undefined && cell.metadata.chapter !== null) {
return String(cell.metadata.chapter);
}

// Priority 3: metadata.data?.chapter (legacy)
if (cell?.metadata?.data?.chapter !== undefined && cell.metadata.data.chapter !== null) {
return String(cell.metadata.data.chapter);
}

// Priority 4: Extract from cellId
const cellId = cell?.metadata?.id || cell?.id;
if (cellId) {
const chapterFromId = extractChapterFromCellId(cellId);
if (chapterFromId) {
return chapterFromId;
}
}

// Priority 5: Use milestone index (1-indexed)
return milestoneIndex.toString();
}

/**
* Extracts book abbreviation from a cell's globalReferences or cellMarkers.
* Returns null if no book abbreviation can be found.
*/
function extractBookNameFromCell(cell: any): string | null {
// Priority 1: Extract from globalReferences array (preferred method)
const globalRefs = cell?.data?.globalReferences || cell?.metadata?.data?.globalReferences;
if (globalRefs && Array.isArray(globalRefs) && globalRefs.length > 0) {
const firstRef = globalRefs[0];
// Extract book name: "GEN 1:1" -> "GEN" or "TheChosen-201-en-SingleSpeaker 1:jkflds" -> "TheChosen-201-en-SingleSpeaker"
const bookMatch = firstRef.match(/^([^\s]+)/);
if (bookMatch) {
return bookMatch[1];
}
}

// Priority 2: Fallback to cellMarkers (legacy support during migration)
if (cell?.cellMarkers?.[0]) {
const firstMarker = cell.cellMarkers[0].split(":")[0];
if (firstMarker) {
const parts = firstMarker.split(" ");
return parts[0];
}
}

// Priority 3: Extract from cellId
const cellId = cell?.metadata?.id || cell?.id;
if (cellId) {
// Extract book name from cellId: "GEN 1:1" -> "GEN"
const bookMatch = cellId.match(/^([^\s]+)/);
if (bookMatch) {
return bookMatch[1];
}
}

return null;
}

/**
* Gets the localized book name from a book abbreviation.
* Returns the abbreviation itself if no localized name is found.
*/
function getLocalizedBookName(bookAbbr: string): string {
if (!bookAbbr) return bookAbbr;

const bookInfo = (bibleData as any[]).find((book) => book.abbr === bookAbbr);
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.
Expand All @@ -2133,44 +2029,22 @@ function extractChapterNumberFromMilestoneValue(value: string | undefined): numb

/**
* Creates a milestone cell with book name and chapter number derived from the cell below it.
* Format: "BookName ChapterNumber" (e.g., "Isaiah 1")
* Format: "BookName ChapterNumber" (e.g., "Isaiah 1"). Thin wrapper around the
* shared `buildMilestoneCellPayload` helper that resolves the current user's
* author name through the auth API; the in-editor structural-edit handlers
* call the shared helper directly with `document._author`.
* @param cell - The cell to derive chapter information from
* @param milestoneIndex - The index of the milestone (1-indexed)
* @param uuid - Optional UUID to use for the milestone cell. If not provided, generates a new one.
*/
async function createMilestoneCell(cell: any, milestoneIndex: number, uuid?: string): Promise<any> {
const cellUuid = uuid || randomUUID();
const chapterNumber = extractChapterFromCell(cell, milestoneIndex);
const currentTimestamp = Date.now();
const author = await getCurrentUserName();

// Extract book name from cell
const bookAbbr = extractBookNameFromCell(cell);
const bookName = bookAbbr ? getLocalizedBookName(bookAbbr) : null;

// Combine book name and chapter number, or use just chapter number if no book name found
const milestoneValue = bookName ? `${bookName} ${chapterNumber}` : chapterNumber;

// Create initial edit entry similar to source file cells
const initialEdit = {
editMap: EditMapUtils.value(),
value: milestoneValue,
timestamp: currentTimestamp - 1000, // Ensure it's before any user edits
type: EditType.INITIAL_IMPORT,
author: author,
validatedBy: []
};

return {
kind: 2, // vscode.NotebookCellKind.Code
languageId: "html",
value: milestoneValue,
metadata: {
id: cellUuid,
type: CodexCellTypes.MILESTONE,
edits: [initialEdit]
}
};
return buildMilestoneCellPayload({
referenceCell: cell,
milestoneOrdinal: milestoneIndex,
author,
uuid,
});
}


Expand Down
Loading
Loading