diff --git a/sharedUtils/milestoneIndexUtils.ts b/sharedUtils/milestoneIndexUtils.ts new file mode 100644 index 000000000..8add45daf --- /dev/null +++ b/sharedUtils/milestoneIndexUtils.ts @@ -0,0 +1,283 @@ +import type { MilestoneInfo } from "../types"; +import { CodexCellTypes } from "../types/enums"; + +type NotebookCell = { + value?: string; + metadata?: { + id?: string; + type?: string; + chapter?: number | string; + chapterNumber?: number | string; + data?: { + deleted?: boolean; + chapter?: number | string; + globalReferences?: string[]; + }; + }; +}; + +export type MilestoneIndexModel = { + milestones: MilestoneInfo[]; + /** 0-based milestone index for each notebook cell index */ + cellMilestoneIndices: number[]; +}; + +/** True when the notebook has at least one non-deleted milestone cell. */ +export function hasExplicitMilestonesInCells(cells: NotebookCell[]): boolean { + return cells.some( + (cell) => + cell.metadata?.type === CodexCellTypes.MILESTONE && + cell.metadata?.data?.deleted !== true + ); +} + +function isCountableContentCell(cell: NotebookCell): boolean { + const cellType = cell.metadata?.type; + if (cellType === CodexCellTypes.MILESTONE || cellType === "paratext") { + return false; + } + return cell.metadata?.data?.deleted !== true; +} + +function extractChapterFromCellId(cellId: string): string | null { + if (!cellId) { + return null; + } + const match = cellId.match(/\s+(\d+):(\d+)(?::|$)/); + return match ? match[1] : null; +} + +/** + * Unique chapter key for detection (e.g. "MAT-1"), aligned with milestone migration / import helpers. + */ +export function extractChapterKeyForDetection(cell: NotebookCell): string | null { + const meta = cell.metadata; + if (meta?.chapterNumber !== undefined && meta.chapterNumber !== null) { + return String(meta.chapterNumber); + } + if (meta?.chapter !== undefined && meta.chapter !== null) { + return String(meta.chapter); + } + if (meta?.data?.chapter !== undefined && meta.data.chapter !== null) { + return String(meta.data.chapter); + } + + const globalRefs = meta?.data?.globalReferences; + if (globalRefs && Array.isArray(globalRefs) && globalRefs.length > 0) { + const firstRef = globalRefs[0]; + const chapter = extractChapterFromCellId(firstRef); + if (chapter) { + const bookMatch = firstRef.match(/^([^\s]+)/); + return bookMatch ? `${bookMatch[1]}-${chapter}` : chapter; + } + } + + const cellId = meta?.id; + if (cellId) { + const chapter = extractChapterFromCellId(cellId); + if (chapter) { + const bookMatch = cellId.match(/^([^\s]+)/); + return bookMatch ? `${bookMatch[1]}-${chapter}` : chapter; + } + } + + return null; +} + +function milestoneLabelFromChapterKey(chapterKey: string, milestoneIndex: number): string { + const dash = chapterKey.lastIndexOf("-"); + if (dash > 0) { + return chapterKey.slice(dash + 1); + } + return chapterKey || String(milestoneIndex + 1); +} + +function buildFromExplicitMilestoneCells(cells: NotebookCell[]): MilestoneIndexModel | null { + if (!hasExplicitMilestonesInCells(cells)) { + return null; + } + + const milestones: MilestoneInfo[] = []; + const cellMilestoneIndices = new Array(cells.length).fill(0); + let totalContentCells = 0; + let currentMilestoneIndex = -1; + let currentMilestoneCellCount = 0; + + for (let i = 0; i < cells.length; i++) { + const cell = cells[i]; + const cellType = cell.metadata?.type; + + if (cellType === CodexCellTypes.MILESTONE) { + if (cell.metadata?.data?.deleted !== true) { + if (currentMilestoneIndex >= 0) { + milestones[currentMilestoneIndex].cellCount = currentMilestoneCellCount; + } + currentMilestoneIndex++; + currentMilestoneCellCount = 0; + milestones.push({ + index: currentMilestoneIndex, + cellIndex: i, + value: cell.value || String(currentMilestoneIndex + 1), + cellCount: 0, + }); + cellMilestoneIndices[i] = currentMilestoneIndex; + } + continue; + } + + if (isCountableContentCell(cell)) { + totalContentCells++; + const idx = currentMilestoneIndex >= 0 ? currentMilestoneIndex : 0; + cellMilestoneIndices[i] = idx; + if (currentMilestoneIndex >= 0) { + currentMilestoneCellCount++; + } + } + } + + if (currentMilestoneIndex >= 0) { + milestones[currentMilestoneIndex].cellCount = currentMilestoneCellCount; + } + + if (milestones.length === 0) { + return null; + } + + return { milestones, cellMilestoneIndices }; +} + +function buildFromChapterBoundaries(cells: NotebookCell[]): MilestoneIndexModel | null { + const milestones: MilestoneInfo[] = []; + const cellMilestoneIndices = new Array(cells.length).fill(0); + const seenChapters = new Set(); + let currentMilestoneIndex = -1; + let currentMilestoneCellCount = 0; + + for (let i = 0; i < cells.length; i++) { + const cell = cells[i]; + + if (!isCountableContentCell(cell)) { + cellMilestoneIndices[i] = currentMilestoneIndex >= 0 ? currentMilestoneIndex : 0; + continue; + } + + const chapterKey = extractChapterKeyForDetection(cell); + if (chapterKey && !seenChapters.has(chapterKey)) { + if (currentMilestoneIndex >= 0) { + milestones[currentMilestoneIndex].cellCount = currentMilestoneCellCount; + } + currentMilestoneIndex++; + currentMilestoneCellCount = 0; + seenChapters.add(chapterKey); + milestones.push({ + index: currentMilestoneIndex, + cellIndex: i, + value: milestoneLabelFromChapterKey(chapterKey, currentMilestoneIndex), + cellCount: 0, + }); + } + + const idx = currentMilestoneIndex >= 0 ? currentMilestoneIndex : 0; + cellMilestoneIndices[i] = idx; + if (currentMilestoneIndex >= 0) { + currentMilestoneCellCount++; + } + } + + if (currentMilestoneIndex >= 0) { + milestones[currentMilestoneIndex].cellCount = currentMilestoneCellCount; + } + + if (milestones.length <= 1) { + return null; + } + + return { milestones, cellMilestoneIndices }; +} + +function buildSyntheticMilestoneModel(cells: NotebookCell[]): MilestoneIndexModel { + let totalContentCells = 0; + const cellMilestoneIndices = new Array(cells.length).fill(0); + + for (let i = 0; i < cells.length; i++) { + if (isCountableContentCell(cells[i])) { + totalContentCells++; + } + } + + return { + milestones: [{ + index: 0, + cellIndex: 0, + value: "1", + cellCount: totalContentCells, + }], + cellMilestoneIndices, + }; +} + +/** + * Builds milestone list and per-cell indices using explicit milestone cells, then chapter + * boundaries in cell IDs (legacy NT/OT projects), then a single synthetic fallback. + */ +export function buildMilestoneIndexModel(cells: NotebookCell[]): MilestoneIndexModel { + const explicit = buildFromExplicitMilestoneCells(cells); + if (explicit && explicit.milestones.length > 1) { + return explicit; + } + + const inferred = buildFromChapterBoundaries(cells); + if (inferred) { + return inferred; + } + + if (explicit) { + return explicit; + } + + return buildSyntheticMilestoneModel(cells); +} + +/** + * True when the export UI should offer per-chapter milestone selection: explicit + * milestone cells (including a single chapter) or multiple inferred chapter + * boundaries. False for the synthetic single-chapter fallback only. + */ +export function hasSelectableMilestonesInCells(cells: NotebookCell[]): boolean { + if (hasExplicitMilestonesInCells(cells)) { + return true; + } + const inferred = buildFromChapterBoundaries(cells); + return inferred !== null && inferred.milestones.length > 0; +} + +/** + * Read-only milestone extraction from notebook cells (mirrors codexDocument.buildMilestoneIndex). + */ +export function extractMilestonesFromCells(cells: NotebookCell[]): MilestoneInfo[] { + return buildMilestoneIndexModel(cells).milestones; +} + +/** + * Returns the milestone index for a cell at the given position while iterating cells in order. + * Pass the current milestone index from the previous cell; returns updated index when a milestone cell is seen. + */ +export function advanceMilestoneIndexForCell( + cell: NotebookCell, + currentMilestoneIndex: number +): number { + if ( + cell.metadata?.type === CodexCellTypes.MILESTONE && + cell.metadata?.data?.deleted !== true + ) { + return currentMilestoneIndex + 1; + } + return currentMilestoneIndex; +} + +/** + * Effective milestone index for a content cell given the current milestone tracker (-1 if none yet). + */ +export function effectiveMilestoneIndex(currentMilestoneIndex: number): number { + return currentMilestoneIndex >= 0 ? currentMilestoneIndex : 0; +} diff --git a/src/exportHandler/audioExporter.ts b/src/exportHandler/audioExporter.ts index edf094593..183e3302d 100644 --- a/src/exportHandler/audioExporter.ts +++ b/src/exportHandler/audioExporter.ts @@ -13,6 +13,7 @@ import type { ExportProgressReporter, ExportMissingReason } from "./exportProgre import { pickAudioAttachment, isExportableCell, type AudioPick, type AudioPickOutcome } from "./audioAttachmentUtils"; import { formatCellDisplayLabel } from "./cellLabelUtils"; import { CodexCellTypes } from "../../types/enums"; +import { buildMilestoneIndexModel } from "../../sharedUtils/milestoneIndexUtils"; const execAsync = promisify(exec); @@ -32,6 +33,7 @@ function debug(...args: any[]) { type ExportAudioOptions = { includeTimestamps?: boolean; + selectedMilestonesByFile?: Record; }; type AudioCellData = { @@ -682,8 +684,17 @@ export async function exportAudioAttachments( }); const bookCode = basename(file.fsPath).split(".")[0] || "BOOK"; - const bookFolder = vscode.Uri.joinPath(exportDir, sanitizeFileComponent(bookCode)); - await vscode.workspace.fs.createDirectory(bookFolder); + const milestoneSelection = options?.selectedMilestonesByFile; + const milestoneFilter = milestoneSelection?.[file.fsPath]; + // Empty array means the user cleared every milestone for this file on step 3. + if ( + milestoneSelection && + Object.prototype.hasOwnProperty.call(milestoneSelection, file.fsPath) && + milestoneFilter && + milestoneFilter.length === 0 + ) { + continue; + } let notebook: CodexNotebookAsJSONData; try { @@ -700,6 +711,9 @@ export async function exportAudioAttachments( // Build milestone folder mapping: cellId -> milestone folder name const cellMilestoneFolder = buildCellMilestoneMap(notebook.cells); + const milestoneModel = buildMilestoneIndexModel(notebook.cells); + + const bookFolder = vscode.Uri.joinPath(exportDir, sanitizeFileComponent(bookCode)); // Count audio cells for per-book progress. Paratext and // milestone cells (e.g. chapter headers, intros) are not @@ -707,8 +721,18 @@ export async function exportAudioAttachments( // `isExportableCell` — they would otherwise show up under // "no audio recorded" purely as noise. const audioCells: Array<{ cell: any; cellId: string; pick: AudioPick; }> = []; - for (const cell of notebook.cells) { - if (!isExportableCell(cell)) continue; + for (let cellIndex = 0; cellIndex < notebook.cells.length; cellIndex++) { + const cell = notebook.cells[cellIndex]; + const milestoneIndex = milestoneModel.cellMilestoneIndices[cellIndex] ?? 0; + if ( + milestoneSelection && + Object.prototype.hasOwnProperty.call(milestoneSelection, file.fsPath) && + milestoneFilter && + !milestoneFilter.includes(milestoneIndex) + ) { + continue; + } + if (!isExportableCell(cell)) continue; const cellId: string | undefined = cell?.metadata?.id; if (!cellId) continue; const outcome = pickAudioAttachmentForCell(cell); @@ -812,11 +836,11 @@ export async function exportAudioAttachments( ? vscode.Uri.file(srcPath) : vscode.Uri.joinPath(workspaceFolder.uri, srcPath); - const timeFromCell = (cell?.metadata?.data || {}) as AudioCellData; - // Use ?? so a literal 0 for audioStartTime/audioEndTime is preferred - // over the cell timestamps, instead of falling through. - const start = timeFromCell.audioStartTime ?? timeFromCell.startTime; - const end = timeFromCell.audioEndTime ?? timeFromCell.endTime; + const timeFromCell = (cell?.metadata?.data || {}) as AudioCellData; + // Use ?? so a literal 0 for audioStartTime/audioEndTime is preferred + // over the cell timestamps, instead of falling through. + const start = timeFromCell.audioStartTime ?? timeFromCell.startTime; + const end = timeFromCell.audioEndTime ?? timeFromCell.endTime; const originalExt = extname(absoluteSrc.fsPath) || ".wav"; const labelRaw = cell?.metadata?.cellLabel || "unlabeled"; const label = sanitizeFileComponent(String(labelRaw).toLowerCase()); @@ -854,17 +878,17 @@ export async function exportAudioAttachments( const cellLabel = formatCellDisplayLabel(cell, cellId, bookCode); - tasks.push({ - cellId, - attachmentId: pick.id, - cellLabel, - absoluteSrc, - destUri, - targetFolder, - originalExt, - start, - end, - }); + tasks.push({ + cellId, + attachmentId: pick.id, + cellLabel, + absoluteSrc, + destUri, + targetFolder, + originalExt, + start, + end, + }); } // Pre-create all target directories in parallel diff --git a/src/exportHandler/exportHandler.ts b/src/exportHandler/exportHandler.ts index ac6dbd195..30884e550 100644 --- a/src/exportHandler/exportHandler.ts +++ b/src/exportHandler/exportHandler.ts @@ -255,6 +255,8 @@ export interface ExportOptions { removeIds?: boolean; includeAudio?: boolean; includeTimestamps?: boolean; + /** Per-file 0-based milestone indices to include when exporting audio. An empty array skips that file entirely. Files omitted from this map are exported in full (no milestone step). */ + selectedMilestonesByFile?: Record; } // IDML Round-trip export: Uses idmlExporter or biblicaExporter based on filename @@ -1793,7 +1795,10 @@ export async function exportCodexContent( break; case CodexExportFormat.AUDIO: { const { exportAudioAttachments } = await import("./audioExporter"); - exportPromises.push(exportAudioAttachments(wrapperPath, filesToExport, childReporter, { includeTimestamps: options?.includeTimestamps })); + exportPromises.push(exportAudioAttachments(wrapperPath, filesToExport, childReporter, { + includeTimestamps: options?.includeTimestamps, + selectedMilestonesByFile: options?.selectedMilestonesByFile, + })); break; } case CodexExportFormat.SUBTITLES_VTT_WITH_STYLES: @@ -1830,6 +1835,7 @@ export async function exportCodexContent( exportPromises.push( exportAudioAttachments(audioPath, filesToExport, childReporter, { includeTimestamps: options?.includeTimestamps, + selectedMilestonesByFile: options?.selectedMilestonesByFile, }) ); } diff --git a/src/projectManager/projectExportView.ts b/src/projectManager/projectExportView.ts index cbbddddd8..0e65c5d71 100644 --- a/src/projectManager/projectExportView.ts +++ b/src/projectManager/projectExportView.ts @@ -365,7 +365,6 @@ function getWebviewContent( const groupsJson = JSON.stringify(fileGroups); const exportOptionsConfigJson = JSON.stringify(EXPORT_OPTIONS_BY_FILE_TYPE); const initialExportFolderJson = JSON.stringify(initialExportFolder); - return ` @@ -480,6 +479,45 @@ function getWebviewContent( background-color: var(--vscode-editor-background); border-top: 1px solid var(--vscode-input-border); } + .milestone-file-group { + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + margin-bottom: 12px; + overflow: hidden; + } + .milestone-file-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + background-color: var(--vscode-editor-inactiveSelectionBackground); + } + .milestone-file-header h4 { + margin: 0; + flex: 1; + font-size: 0.95em; + } + .milestone-list { + padding: 8px 12px 12px 32px; + display: flex; + flex-direction: column; + gap: 6px; + background-color: var(--vscode-editor-background); + border-top: 1px solid var(--vscode-input-border); + } + .milestone-item { + display: flex; + align-items: center; + gap: 8px; + } + .milestone-item label { + cursor: pointer; + user-select: none; + } + .milestone-select-all { + font-size: 0.85em; + color: var(--vscode-descriptionForeground); + } .file-item { display: flex; align-items: center; @@ -1295,8 +1333,19 @@ function getWebviewContent( - +
+
+

Select Milestones

+

+ Choose which chapters (milestones) to include in the audio export. +

+
+
+
+ + +

Select Export Location

@@ -1313,8 +1362,8 @@ function getWebviewContent(

- -
+ +
@@ -1385,10 +1434,13 @@ function getWebviewContent(
2
3
+
+
4
+
@@ -1481,6 +1533,152 @@ function getWebviewContent( let exportPath = ${initialExportFolderJson}; let selectedFiles = new Set(); let selectedGroupKey = null; + /** @type {Record>} */ + let selectedMilestonesByFile = {}; + + function shouldShowMilestoneStep() { + if (!selectedAudioMode) return false; + for (const path of selectedFiles) { + const f = fileLookup[path]; + if (f && f.hasSelectableMilestones && f.milestones && f.milestones.length > 0) { + return true; + } + } + return false; + } + + function getTotalStepCount() { + return shouldShowMilestoneStep() ? 4 : 3; + } + + function getProgressDisplayStep(step) { + if (!shouldShowMilestoneStep() && step === 4) return 3; + return step; + } + + /** @type {string[]} Maps milestone UI file index to codex path */ + let milestoneFilePaths = []; + + function initMilestoneSelection() { + selectedMilestonesByFile = {}; + milestoneFilePaths = []; + for (const path of selectedFiles) { + const f = fileLookup[path]; + if (f && f.hasSelectableMilestones && f.milestones && f.milestones.length > 0) { + selectedMilestonesByFile[path] = new Set(f.milestones.map(m => m.index)); + milestoneFilePaths.push(path); + } + } + renderMilestoneSelection(); + updateStep3Button(); + } + + function renderMilestoneSelection() { + const container = document.getElementById('milestoneGroupsContainer'); + if (!container) return; + if (milestoneFilePaths.length === 0) { + container.innerHTML = '

No milestones found in the selected files.

'; + return; + } + container.innerHTML = milestoneFilePaths.map((filePath, fileIdx) => { + const f = fileLookup[filePath]; + const selectedSet = selectedMilestonesByFile[filePath] || new Set(); + const allSelected = f.milestones.every(m => selectedSet.has(m.index)); + const selectAllId = 'milestone-select-all-' + fileIdx; + const milestonesHtml = f.milestones.map((m, mIdx) => { + const cbId = 'milestone-' + fileIdx + '-' + mIdx; + const checked = selectedSet.has(m.index) ? 'checked' : ''; + const label = ((m.value || String(m.index + 1)).replace(/<[^>]*>/g, '').trim()) || String(m.index + 1); + return \` +
+ + +
+ \`; + }).join(''); + return \` +
+
+

\${f.displayName}

+ +
+
\${milestonesHtml}
+
+ \`; + }).join(''); + } + + function onMilestoneCheckboxChange(fileIdx, milestoneIndex) { + const filePath = milestoneFilePaths[fileIdx]; + if (!filePath) return; + if (!selectedMilestonesByFile[filePath]) { + selectedMilestonesByFile[filePath] = new Set(); + } + const cb = document.querySelector('input[data-file-idx="' + fileIdx + '"][data-milestone-index="' + milestoneIndex + '"]'); + if (cb && cb.checked) { + selectedMilestonesByFile[filePath].add(milestoneIndex); + } else { + selectedMilestonesByFile[filePath].delete(milestoneIndex); + } + syncMilestoneSelectAllCheckbox(fileIdx); + updateStep3Button(); + } + + function onMilestoneSelectAllChange(fileIdx) { + const filePath = milestoneFilePaths[fileIdx]; + const f = filePath ? fileLookup[filePath] : null; + if (!f || !f.milestones) return; + const selectAllCb = document.querySelector('.milestone-file-group[data-file-idx="' + fileIdx + '"] input[data-file-idx]:not([data-milestone-index])'); + const shouldSelectAll = selectAllCb && selectAllCb.checked; + if (!selectedMilestonesByFile[filePath]) { + selectedMilestonesByFile[filePath] = new Set(); + } + if (shouldSelectAll) { + f.milestones.forEach(m => selectedMilestonesByFile[filePath].add(m.index)); + } else { + selectedMilestonesByFile[filePath].clear(); + } + renderMilestoneSelection(); + updateStep3Button(); + } + + function syncMilestoneSelectAllCheckbox(fileIdx) { + const filePath = milestoneFilePaths[fileIdx]; + const f = filePath ? fileLookup[filePath] : null; + if (!f || !f.milestones) return; + const selectedSet = selectedMilestonesByFile[filePath] || new Set(); + const allSelected = f.milestones.every(m => selectedSet.has(m.index)); + const selectAllCb = document.querySelector('.milestone-file-group[data-file-idx="' + fileIdx + '"] input[data-file-idx]:not([data-milestone-index])'); + if (selectAllCb) selectAllCb.checked = allSelected; + } + + function updateStep3Button() { + const btn = document.getElementById('nextStep3'); + if (!btn) return; + let anySelected = false; + for (const path of selectedFiles) { + const set = selectedMilestonesByFile[path]; + if (set && set.size > 0) { + anySelected = true; + break; + } + } + btn.disabled = !anySelected; + } + + function buildSelectedMilestonesPayload() { + if (!shouldShowMilestoneStep()) return undefined; + const payload = {}; + for (const path of milestoneFilePaths) { + const set = selectedMilestonesByFile[path]; + payload[path] = set ? Array.from(set).sort((a, b) => a - b) : []; + } + return Object.keys(payload).length > 0 ? payload : undefined; + } // Build a path→file lookup so Step 2 can check audio-only status const fileLookup = {}; @@ -2165,6 +2363,7 @@ function getWebviewContent( const back = document.getElementById('btnBack'); const next1 = document.getElementById('nextStep1'); const next2 = document.getElementById('nextStep2'); + const next3 = document.getElementById('nextStep3'); const exportBtn = document.getElementById('exportButton'); if (currentStep === 1) { if (cancel) cancel.classList.add('visible'); @@ -2173,40 +2372,96 @@ function getWebviewContent( if (back) back.classList.add('visible'); if (next2) next2.classList.add('visible'); } else if (currentStep === 3) { + if (back) back.classList.add('visible'); + if (next3) next3.classList.add('visible'); + } else if (currentStep === 4) { if (back) back.classList.add('visible'); if (exportBtn) exportBtn.classList.add('visible'); } } + function updateProgressBarVisuals(activeStep) { + const showMilestones = shouldShowMilestoneStep(); + const circle3 = document.getElementById('progressCircle3'); + const circle4 = document.getElementById('progressCircle4'); + const line3 = document.getElementById('progressLine3'); + if (circle4) circle4.style.display = showMilestones ? '' : 'none'; + if (line3) line3.style.display = showMilestones ? '' : 'none'; + + const resetCircle = (circle, label) => { + if (!circle) return; + circle.classList.remove('active', 'completed'); + circle.textContent = String(label); + }; + + resetCircle(document.getElementById('progressCircle1'), 1); + resetCircle(document.getElementById('progressCircle2'), 2); + resetCircle(circle3, showMilestones ? 3 : 3); + resetCircle(circle4, 4); + + const setCircleState = (circleId, state) => { + const circle = document.getElementById(circleId); + if (!circle) return; + circle.classList.remove('active', 'completed'); + if (state === 'completed') { + circle.classList.add('completed'); + circle.innerHTML = ''; + } else if (state === 'active') { + circle.classList.add('active'); + } + }; + + if (showMilestones) { + for (let step = 1; step <= 4; step++) { + const state = step < activeStep ? 'completed' : (step === activeStep ? 'active' : 'idle'); + if (state !== 'idle') setCircleState('progressCircle' + step, state); + } + document.querySelectorAll('[id^="progressLine"]').forEach((line, i) => { + line.classList.remove('completed'); + if (i + 1 < activeStep) line.classList.add('completed'); + }); + } else { + const circleSteps = [ + { circle: 1, step: 1 }, + { circle: 2, step: 2 }, + { circle: 3, step: 4 }, + ]; + for (const { circle, step } of circleSteps) { + const state = step < activeStep ? 'completed' : (step === activeStep ? 'active' : 'idle'); + if (state !== 'idle') setCircleState('progressCircle' + circle, state); + } + const line1 = document.getElementById('progressLine1'); + const line2 = document.getElementById('progressLine2'); + if (line1) line1.classList.toggle('completed', activeStep > 1); + if (line2) line2.classList.toggle('completed', activeStep >= 4); + } + + const compact = document.getElementById('progressCompact'); + if (compact) { + compact.textContent = 'Step ' + getProgressDisplayStep(activeStep) + ' of ' + getTotalStepCount(); + } + } + function goBack() { - goToStep(currentStep - 1); + if (currentStep === 4) { + goToStep(shouldShowMilestoneStep() ? 3 : 2); + } else { + goToStep(currentStep - 1); + } } function goToStep(n) { const prevStep = currentStep; document.querySelectorAll('.step-panel').forEach(p => p.classList.remove('active')); document.getElementById('step' + n).classList.add('active'); - document.querySelectorAll('[id^="progressCircle"]').forEach((circle, i) => { - circle.classList.remove('active', 'completed'); - if (i + 1 < n) { - circle.classList.add('completed'); - circle.innerHTML = ''; - } else { - circle.textContent = String(i + 1); - if (i + 1 === n) circle.classList.add('active'); - } - }); - document.querySelectorAll('[id^="progressLine"]').forEach((line, i) => { - line.classList.remove('completed'); - if (i + 1 < n) line.classList.add('completed'); - }); - const compact = document.getElementById('progressCompact'); - if (compact) compact.textContent = 'Step ' + n + ' of 3'; + updateProgressBarVisuals(n); currentStep = n; updateButtonVisibility(); if (n === 2) { initStep2Options(prevStep === 1); - } else if (n === 3) { + } else if (n === 3 && shouldShowMilestoneStep()) { + initMilestoneSelection(); + } else if (n === 4) { updateExportButton(); } } @@ -2214,6 +2469,11 @@ function getWebviewContent( function goToStep1() { goToStep(1); } function goToStep2() { goToStep(2); } function goToStep3() { goToStep(3); } + function goToStep4() { goToStep(4); } + + function advanceToNextStepAfterFormat() { + goToStep(shouldShowMilestoneStep() ? 3 : 4); + } function updateStep2Button() { const btn = document.getElementById('nextStep2'); @@ -2425,9 +2685,8 @@ function getWebviewContent( exportState.started = true; document.body.classList.add('exporting'); document.querySelectorAll('.step-panel').forEach(p => p.classList.remove('active')); - const step4 = document.getElementById('step4'); - if (step4) step4.classList.add('active'); - currentStep = 4; + const stepExporting = document.getElementById('stepExporting'); + if (stepExporting) stepExporting.classList.add('active'); setStageState('preparing', 'active'); exportState.stageIndex = 0; } @@ -2464,9 +2723,12 @@ function getWebviewContent( }); return; } - goToStep(3); + advanceToNextStepAfterFormat(); } + window.onMilestoneCheckboxChange = onMilestoneCheckboxChange; + window.onMilestoneSelectAllChange = onMilestoneSelectAllChange; + window.addEventListener('message', event => { const message = event.data; if (message.command === 'updateExportPath') { @@ -2520,7 +2782,7 @@ function getWebviewContent( pendingSubtitleOverlapCheck = false; updateStep2Button(); if (message.proceed) { - goToStep(3); + advanceToNextStepAfterFormat(); } } }); @@ -2529,6 +2791,7 @@ function getWebviewContent( renderFileGroups(); setupCellListPopover(); updateStep1Button(); + updateProgressBarVisuals(1); if (exportPath) { const pathEl = document.getElementById('exportPath'); if (pathEl) pathEl.textContent = exportPath; @@ -2691,6 +2954,10 @@ function getWebviewContent( options.includeAudio = true; options.includeTimestamps = selectedAudioMode === 'audio-timestamps'; } + const selectedMilestones = buildSelectedMilestonesPayload(); + if (selectedMilestones) { + options.selectedMilestonesByFile = selectedMilestones; + } // Optimistically switch UI to the in-panel exporting screen so // the user does not see Cancel / Back / Export anymore. The host // also broadcasts exportStarted, which is idempotent. diff --git a/src/projectManager/utils/exportViewUtils.ts b/src/projectManager/utils/exportViewUtils.ts index 0cee77117..080708284 100644 --- a/src/projectManager/utils/exportViewUtils.ts +++ b/src/projectManager/utils/exportViewUtils.ts @@ -1,11 +1,15 @@ import * as vscode from "vscode"; -import { CodexNotebookAsJSONData } from "../../../types"; +import { CodexNotebookAsJSONData, MilestoneInfo } from "../../../types"; import { getCellAudioState, isExportableCell, isLabelableCell, } from "../../exportHandler/audioAttachmentUtils"; import { formatCellDisplayLabel } from "../../exportHandler/cellLabelUtils"; +import { + buildMilestoneIndexModel, + hasSelectableMilestonesInCells, +} from "../../../sharedUtils/milestoneIndexUtils"; export { EXPORT_OPTIONS_BY_FILE_TYPE, @@ -82,6 +86,9 @@ export interface FileGroupEntry { hasTranslations: boolean; hasAudio: boolean; audioStats?: NotebookAudioStats; + milestones: MilestoneInfo[]; + /** True when the notebook has real milestone cells (chapter boundaries), not only a synthetic fallback. */ + hasSelectableMilestones: boolean; } export interface FileGroup { @@ -340,6 +347,11 @@ function getGroupKeyFromMetadata(metadata: Record): string { return "usfm"; } + // Legacy scripture projects: NT/OT corpus without importerType (common in older projects) + if (corpusMarker === "NT" || corpusMarker === "OT") { + return "usfm"; + } + // Bible Stories (OBS) if (corpusMarker === "obs" || importerType === "obs") { return "obs"; @@ -389,6 +401,9 @@ export async function groupCodexFilesByImporterType( const audioStats = hasAudio ? analyzeNotebookAudioStats(notebook, bookCode) : undefined; + const milestoneModel = buildMilestoneIndexModel(notebook.cells); + const milestones = milestoneModel.milestones; + const hasSelectableMilestones = hasSelectableMilestonesInCells(notebook.cells); if (!groupsMap.has(groupKey)) { groupsMap.set(groupKey, []); @@ -400,6 +415,8 @@ export async function groupCodexFilesByImporterType( hasTranslations, hasAudio, audioStats, + milestones, + hasSelectableMilestones, }); } catch { const name = uri.fsPath.split(/[/\\]/).pop() || ""; @@ -412,6 +429,8 @@ export async function groupCodexFilesByImporterType( displayName: name.replace(/\.codex$/i, "") || name, hasTranslations: false, hasAudio: false, + milestones: [{ index: 0, cellIndex: 0, value: "1", cellCount: 0 }], + hasSelectableMilestones: false, }); } } diff --git a/webviews/codex-webviews/src/milestoneIndexUtils.test.ts b/webviews/codex-webviews/src/milestoneIndexUtils.test.ts new file mode 100644 index 000000000..af8ce960f --- /dev/null +++ b/webviews/codex-webviews/src/milestoneIndexUtils.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { + buildMilestoneIndexModel, + hasSelectableMilestonesInCells, +} from "../../../sharedUtils/milestoneIndexUtils"; +import { CodexCellTypes } from "../../../types/enums"; + +function milestoneCell(value: string) { + return { + kind: 2, + value, + metadata: { + type: CodexCellTypes.MILESTONE, + data: {}, + }, + }; +} + +function contentCell(id: string) { + return { + kind: 2, + value: "verse", + metadata: { + type: "text", + id, + data: { globalReferences: [id] }, + }, + }; +} + +describe("hasSelectableMilestonesInCells", () => { + it("returns true for a notebook with one explicit milestone chapter", () => { + const cells = [ + milestoneCell("Chapter 1"), + contentCell("MAT 1:1"), + contentCell("MAT 1:2"), + ]; + expect(buildMilestoneIndexModel(cells).milestones).toHaveLength(1); + expect(hasSelectableMilestonesInCells(cells)).toBe(true); + }); + + it("returns true for multiple explicit milestones", () => { + const cells = [ + milestoneCell("Chapter 1"), + contentCell("MAT 1:1"), + milestoneCell("Chapter 2"), + contentCell("MAT 2:1"), + ]; + expect(hasSelectableMilestonesInCells(cells)).toBe(true); + }); + + it("returns false when only the synthetic single-chapter fallback applies", () => { + const cells = [ + { + kind: 2, + value: "plain", + metadata: { type: "text", data: {} }, + }, + ]; + expect(buildMilestoneIndexModel(cells).milestones).toHaveLength(1); + expect(hasSelectableMilestonesInCells(cells)).toBe(false); + }); +});