From f546655e0ef2c69d027f4b3bb4fb6712e90392aa Mon Sep 17 00:00:00 2001 From: LeviXIII Date: Wed, 4 Mar 2026 15:56:19 -0500 Subject: [PATCH 01/12] - Ensure that missing audio icon is displayed in both the CellContentDisplay and in the MilestoneAccordion. - Code is set for both front and backend calculations for audio. --- sharedUtils/index.ts | 21 ++++ .../codexCellEditorMessagehandling.ts | 2 + .../codexCellEditorProvider.ts | 2 +- .../codexCellEditorProvider/codexDocument.ts | 26 ++++- types/index.d.ts | 3 + .../ChapterNavigationHeader.tsx | 7 ++ .../src/CodexCellEditor/CodexCellEditor.tsx | 7 ++ .../components/MilestoneAccordion.tsx | 105 ++++++++++++++---- webviews/codex-webviews/src/lib/types.ts | 1 + 9 files changed, 147 insertions(+), 27 deletions(-) diff --git a/sharedUtils/index.ts b/sharedUtils/index.ts index 07c4b6eca..8cb92708c 100644 --- a/sharedUtils/index.ts +++ b/sharedUtils/index.ts @@ -140,6 +140,27 @@ export const shouldDisableValidation = ( return !hasTextContent(htmlContent); }; +/** + * Returns true if the cell has an audio attachment whose file is missing from disk + * (isMissing === true on the selected or any non-deleted audio attachment). + */ +export const cellHasMissingAudio = ( + attachments: Record | undefined, + selectedAudioId?: string +): boolean => { + const atts = attachments; + if (!atts || Object.keys(atts).length === 0) return false; + + if (selectedAudioId && atts[selectedAudioId]) { + const att = atts[selectedAudioId]; + return att && att.type === "audio" && !att.isDeleted && att.isMissing === true; + } + + return Object.values(atts).some( + (att: any) => att && att.type === "audio" && !att.isDeleted && att.isMissing === true + ); +}; + // Progress helpers shared across provider and webviews export const cellHasAudioUsingAttachments = ( attachments: Record | undefined, diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 6bd5465aa..7e221e7fa 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -2451,6 +2451,8 @@ const messageHandlers: Record Promise { const progress: Record = {}; const cells = this._documentData.cells || []; @@ -1597,6 +1599,13 @@ export class CodexCellDocument implements vscode.CustomDocument { ) ).length; + const missingAudioCount = progressCells.filter((cell) => + cellHasMissingAudio( + cell.attachments, + cell.metadata?.selectedAudioId + ) + ).length; + // Calculate validation data (only from root content cells) const cellWithValidatedData = progressCells.map((cell) => getCellValueData(cell)); @@ -1618,7 +1627,10 @@ export class CodexCellDocument implements vscode.CustomDocument { ); // Milestone number is 1-based (i + 1) - progress[i + 1] = progressPercentages; + progress[i + 1] = { + ...progressPercentages, + cellsWithMissingAudio: missingAudioCount, + }; } return progress; @@ -1649,6 +1661,7 @@ export class CodexCellDocument implements vscode.CustomDocument { audioValidationLevels?: number[]; requiredTextValidations?: number; requiredAudioValidations?: number; + cellsWithMissingAudio?: number; }> { const progress: Record = {}; const cells = this._documentData.cells || []; @@ -1763,6 +1777,13 @@ export class CodexCellDocument implements vscode.CustomDocument { ) ).length; + const missingAudioCount = progressCells.filter((cell) => + cellHasMissingAudio( + cell.attachments, + cell.metadata?.selectedAudioId + ) + ).length; + // Calculate validation data (only from root content cells) const cellWithValidatedData = progressCells.map((cell) => getCellValueData(cell)); @@ -1810,6 +1831,7 @@ export class CodexCellDocument implements vscode.CustomDocument { audioValidationLevels, requiredTextValidations: minimumValidationsRequired, requiredAudioValidations: minimumAudioValidationsRequired, + cellsWithMissingAudio: missingAudioCount, }; } diff --git a/types/index.d.ts b/types/index.d.ts index 3b39faefc..dadbb8b39 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1128,6 +1128,7 @@ export interface MilestoneIndex { percentFullyValidatedTranslations: number; percentAudioValidatedTranslations: number; percentTextValidatedTranslations: number; + cellsWithMissingAudio?: number; }>; } @@ -2097,6 +2098,7 @@ type EditorReceiveMessages = audioValidationLevels?: number[]; requiredTextValidations?: number; requiredAudioValidations?: number; + cellsWithMissingAudio?: number; }>; } | { @@ -2210,6 +2212,7 @@ type EditorReceiveMessages = percentFullyValidatedTranslations: number; percentAudioValidatedTranslations: number; percentTextValidatedTranslations: number; + cellsWithMissingAudio?: number; }>; } | { diff --git a/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx b/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx index 963a49676..21aec1e79 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx @@ -90,6 +90,8 @@ interface ChapterNavigationHeaderProps { subsectionProgress?: Record; allSubsectionProgress?: Record>; requestSubsectionProgress?: (milestoneIdx: number) => void; + // Audio state for syncing missing icon with CellContentDisplay + audioAttachments?: { [cellId: string]: "available" | "available-local" | "available-pointer" | "missing" | "deletedOnly" | "none" }; } export function ChapterNavigationHeader({ @@ -149,6 +151,7 @@ export function ChapterNavigationHeader({ subsectionProgress, allSubsectionProgress, requestSubsectionProgress, + audioAttachments, }: // Removed onToggleCorrectionEditor since it will be a VS Code command now ChapterNavigationHeaderProps) { const [showConfirm, setShowConfirm] = useState(false); @@ -482,6 +485,7 @@ ChapterNavigationHeaderProps) { audioValidationLevels: backendProgress.audioValidationLevels, requiredTextValidations: backendProgress.requiredTextValidations, requiredAudioValidations: backendProgress.requiredAudioValidations, + cellsWithMissingAudio: backendProgress.cellsWithMissingAudio, }; } @@ -498,6 +502,7 @@ ChapterNavigationHeaderProps) { audioValidationLevels: undefined, requiredTextValidations: undefined, requiredAudioValidations: undefined, + cellsWithMissingAudio: 0, }; }; @@ -1078,6 +1083,8 @@ ChapterNavigationHeaderProps) { calculateSubsectionProgress={calculateSubsectionProgress} requestSubsectionProgress={requestSubsectionProgress} vscode={vscode} + audioAttachments={audioAttachments} + translationUnitsForSection={translationUnitsForSection} /> ); diff --git a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx index 4d3d11a8f..bd76126da 100755 --- a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx @@ -976,6 +976,12 @@ const CodexCellEditor: React.FC = () => { ...milestoneIndex, milestoneProgress: message.milestoneProgress, }); + + // Invalidate subsection progress cache so the MilestoneAccordion + // re-fetches with updated data (e.g., cellsWithMissingAudio) + progressCacheRef.current.clear(); + pendingProgressRequestsRef.current.clear(); + setSubsectionProgress({}); } }, [milestoneIndex] @@ -3272,6 +3278,7 @@ const CodexCellEditor: React.FC = () => { subsectionProgress={subsectionProgress[currentMilestoneIndex]} allSubsectionProgress={subsectionProgress} requestSubsectionProgress={requestSubsectionProgressForMilestone} + audioAttachments={audioAttachments} /> diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx index 5bd7c604f..1f81fecc4 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx @@ -10,7 +10,7 @@ import { import { ProgressDots } from "./ProgressDots"; import { deriveSubsectionPercentages, getProgressDisplay } from "../utils/progressUtils"; import MicrophoneIcon from "../../components/ui/icons/MicrophoneIcon"; -import { Languages, Check, RotateCcw } from "lucide-react"; +import { Languages, Check, RotateCcw, TriangleAlert } from "lucide-react"; import type { Subsection, ProgressPercentages } from "../../lib/types"; import type { MilestoneIndex, MilestoneInfo } from "../../../../../types"; import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; @@ -43,9 +43,22 @@ interface MilestoneAccordionProps { audioValidationLevels?: number[]; requiredTextValidations?: number; requiredAudioValidations?: number; + cellsWithMissingAudio?: number; }; requestSubsectionProgress?: (milestoneIdx: number) => void; vscode: any; + /** Audio state per cell - used to sync missing icon with CellContentDisplay */ + audioAttachments?: { + [cellId: string]: + | "available" + | "available-local" + | "available-pointer" + | "missing" + | "deletedOnly" + | "none"; + }; + /** Cells in the current subsection - used with audioAttachments to derive missing count */ + translationUnitsForSection?: Array<{ cellMarkers?: string[] }>; } export function MilestoneAccordion({ @@ -63,6 +76,8 @@ export function MilestoneAccordion({ calculateSubsectionProgress, requestSubsectionProgress, vscode, + audioAttachments, + translationUnitsForSection, }: MilestoneAccordionProps) { // Layout constants const DROPDOWN_MAX_HEIGHT_VIEWPORT_PERCENT = 60; // 60vh @@ -298,6 +313,7 @@ export function MilestoneAccordion({ audioValidationLevels: backendProgress.audioValidationLevels, requiredTextValidations: backendProgress.requiredTextValidations, requiredAudioValidations: backendProgress.requiredAudioValidations, + cellsWithMissingAudio: backendProgress.cellsWithMissingAudio, }; } @@ -319,6 +335,7 @@ export function MilestoneAccordion({ audioValidationLevels: undefined, requiredTextValidations: undefined, requiredAudioValidations: undefined, + cellsWithMissingAudio: 0, }; }; @@ -355,6 +372,14 @@ export function MilestoneAccordion({ if (!isOpen || !milestoneIndex) return null; + // Derive missing audio count from audioAttachments for current subsection (syncs with CellContentDisplay) + const currentSubsectionMissingFromAudio = + audioAttachments && translationUnitsForSection + ? translationUnitsForSection.filter( + (unit) => audioAttachments[unit.cellMarkers?.[0] ?? ""] === "missing" + ).length + : 0; + // Get milestone progress const getMilestoneProgress = (milestoneIdx: number) => { // milestoneProgress uses 1-based keys @@ -764,6 +789,16 @@ export function MilestoneAccordion({ const isTextFullyTranslated = milestoneProgress.textCompletedPercent >= 100; + const rawMilestoneProgress = + milestoneIndex.milestoneProgress?.[milestoneIdx + 1]; + const backendMissing = + rawMilestoneProgress?.cellsWithMissingAudio ?? 0; + // Use audioAttachments-derived count when viewing this milestone so it stays in sync with CellContentDisplay + const hasMissingAudio = + milestoneIdx === currentMilestoneIndex + ? backendMissing > 0 || currentSubsectionMissingFromAudio > 0 + : backendMissing > 0; + return (
+ {hasMissingAudio && ( +
+ +
+ )}
0 + : (progress.cellsWithMissingAudio ?? 0) > 0; return (
{subsection.label} - +
+ {subsectionHasMissingAudio && ( +
+ +
+ )} + +
); })} diff --git a/webviews/codex-webviews/src/lib/types.ts b/webviews/codex-webviews/src/lib/types.ts index 1fb1ae5d4..72b661b9e 100644 --- a/webviews/codex-webviews/src/lib/types.ts +++ b/webviews/codex-webviews/src/lib/types.ts @@ -55,6 +55,7 @@ export interface ProgressPercentages { audioValidationLevels?: number[]; requiredTextValidations?: number; requiredAudioValidations?: number; + cellsWithMissingAudio?: number; } export interface Subsection { From 181dbee250be60b2957d6ba81faaa00d0a7a4931 Mon Sep 17 00:00:00 2001 From: LeviXIII Date: Thu, 5 Mar 2026 09:34:07 -0500 Subject: [PATCH 02/12] - Refactor hasMissing audio code across codebase to make it more streamlined and efficient. - Have missing audio icon appear in Navigation view as well when there is missing audio in the folder and files. --- .../codexCellEditorMessagehandling.ts | 268 +++--------------- .../codexCellEditorProvider.ts | 217 ++------------ .../navigationWebviewProvider.ts | 33 ++- src/utils/audioAvailabilityUtils.ts | 188 ++++++++++++ types/index.d.ts | 9 +- .../src/NavigationView/index.tsx | 44 ++- 6 files changed, 310 insertions(+), 449 deletions(-) create mode 100644 src/utils/audioAvailabilityUtils.ts diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 7e221e7fa..25d64f1ed 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -21,6 +21,7 @@ import { getCommentsFromFile } from "../../utils/fileUtils"; import { getUnresolvedCommentsCountForCell } from "../../utils/commentsUtils"; import { toPosixPath } from "../../utils/pathUtils"; import { revalidateCellMissingFlags } from "../../utils/audioMissingUtils"; +import { computeCellAudioStateWithVersionGate, type AudioAvailabilityState } from "../../utils/audioAvailabilityUtils"; import { mergeAudioFiles } from "../../utils/audioMerger"; import { getAttachmentDocumentSegmentFromUri } from "../../utils/attachmentFolderUtils"; // Comment out problematic imports @@ -445,67 +446,16 @@ const messageHandlers: Record Promise = {}; if (Array.isArray(notebookData?.cells) && workspaceFolder) { for (const cell of notebookData.cells) { const id = cell?.metadata?.id; if (!id) continue; - let hasAvailable = false; - let hasAvailablePointer = false; - let hasMissing = false; - let hasDeleted = false; - const atts = cell?.metadata?.attachments || {}; - for (const key of Object.keys(atts)) { - const att: any = (atts as any)[key]; - if (att && att.type === "audio") { - if (att.isDeleted) { - hasDeleted = true; - } else if (att.isMissing) { - hasMissing = true; - } else { - try { - const url = String(att.url || ""); - if (url) { - const filesRel = url.startsWith(".project/") ? url : url.replace(/^\.?\/?/, ""); - const abs = path.join(workspaceFolder.uri.fsPath, filesRel); - const { isPointerFile } = await import("../../utils/lfsHelpers"); - const isPtr = await isPointerFile(abs).catch(() => false); - if (isPtr) hasAvailablePointer = true; else hasAvailable = true; - } else { - hasAvailable = true; - } - } catch { hasAvailable = true; } - } - } - } - - // If the user's selected audio is missing, show missing icon regardless of other attachments. - const selectedId = cell?.metadata?.selectedAudioId; - const selectedAtt = selectedId ? (atts as any)[selectedId] : undefined; - const selectedIsMissing = selectedAtt?.type === "audio" && selectedAtt?.isMissing === true; - - let state: "available" | "available-local" | "available-pointer" | "missing" | "deletedOnly" | "none"; - if (selectedIsMissing) state = "missing"; - else if (hasAvailable) state = "available-local"; - else if (hasAvailablePointer) state = "available-pointer"; - else if (hasMissing) state = "missing"; - else if (hasDeleted) state = "deletedOnly"; - else state = "none"; - - // Apply installed-version gate (no modal) to avoid showing Play when blocked - if (state !== "available-local") { - try { - const { getFrontierVersionStatus } = await import("../../projectManager/utils/versionChecks"); - const status = await getFrontierVersionStatus(); - if (!status.ok) { - if (state !== "missing" && state !== "deletedOnly" && state !== "none") { - state = "available-pointer"; - } - } - } catch { /* ignore */ } - } - - availability[id] = state as any; + availability[id] = await computeCellAudioStateWithVersionGate( + cell?.metadata?.attachments, + cell?.metadata?.selectedAudioId, + workspaceFolder + ); } } provider.postMessageToWebview(webviewPanel, { @@ -2335,78 +2285,16 @@ const messageHandlers: Record Promise = {}; for (const cell of cells) { const cellId = cell?.metadata?.id; if (!cellId) continue; - let hasAvailable = false; - let hasAvailablePointer = false; - let hasMissing = false; - let hasDeleted = false; - const atts = cell?.metadata?.attachments || {}; - for (const key of Object.keys(atts)) { - const att: any = (atts as any)[key]; - if (att && att.type === "audio") { - if (att.isDeleted) { - hasDeleted = true; - } else if (att.isMissing) { - hasMissing = true; - } else { - try { - const url = String(att.url || ""); - if (url) { - const filesRel = url.startsWith(".project/") ? url : url.replace(/^\.?\/?/, ""); - const filesAbs = path.join(workspaceFolder.uri.fsPath, filesRel); - try { - await vscode.workspace.fs.stat(vscode.Uri.file(filesAbs)); - const { isPointerFile } = await import("../../utils/lfsHelpers"); - const isPtr = await isPointerFile(filesAbs).catch(() => false); - if (isPtr) hasAvailablePointer = true; else hasAvailable = true; - } catch { - const pointerAbs = filesAbs.includes("/.project/attachments/files/") - ? filesAbs.replace("/.project/attachments/files/", "/.project/attachments/pointers/") - : filesAbs.replace(".project/attachments/files/", ".project/attachments/pointers/"); - try { - await vscode.workspace.fs.stat(vscode.Uri.file(pointerAbs)); - hasAvailablePointer = true; - } catch { - hasMissing = true; - } - } - } else { - hasMissing = true; - } - } catch { hasMissing = true; } - } - } - } - const selectedId = cell?.metadata?.selectedAudioId; - const selectedAtt = selectedId ? (atts as any)[selectedId] : undefined; - const selectedIsMissing = selectedAtt?.type === "audio" && selectedAtt?.isMissing === true; - - // Provisional state — prefer showing available when a valid file exists, - // even if the user's explicit selection points to a missing file. - let state: "available" | "available-local" | "available-pointer" | "missing" | "deletedOnly" | "none"; - if (hasAvailable) state = "available-local"; - else if (hasAvailablePointer) state = "available-pointer"; - else if (selectedIsMissing || hasMissing) state = "missing"; - else if (hasDeleted) state = "deletedOnly"; - else state = "none"; - - if (state !== "available-local") { - try { - const { getFrontierVersionStatus } = await import("../../projectManager/utils/versionChecks"); - const status = await getFrontierVersionStatus(); - if (!status.ok) { - if (state !== "missing" && state !== "deletedOnly" && state !== "none") { - state = "available-pointer"; - } - } - } catch { /* ignore */ } - } - - availability[cellId] = state as any; + availability[cellId] = await computeCellAudioStateWithVersionGate( + cell?.metadata?.attachments, + cell?.metadata?.selectedAudioId, + workspaceFolder + ); } provider.postMessageToWebview(webviewPanel, { @@ -2575,68 +2463,32 @@ const messageHandlers: Record Promise = {}; let validatedByArray: ValidationEntry[] = []; + const ws = vscode.workspace.getWorkspaceFolder(document.uri); for (const cell of cells) { const cellId = cell?.metadata?.id; if (!cellId) continue; - let hasAvailable = false; - let hasAvailablePointer = false; - let hasMissing = false; - let hasDeleted = false; const atts = cell?.metadata?.attachments || {}; - for (const key of Object.keys(atts)) { - const att: any = (atts as any)[key]; - if (att && att.type === "audio") { - if (att.isDeleted) { - hasDeleted = true; - } else if (att.isMissing) { - hasMissing = true; - } else { - // Differentiate pointer vs real file by inspecting attachments/files path - try { - const ws = vscode.workspace.getWorkspaceFolder(document.uri); - const url = String(att.url || ""); - if (ws && url) { - const filesPath = url.startsWith(".project/") ? url : url.replace(/^\.?\/?/, ""); - const abs = path.join(ws.uri.fsPath, filesPath); - const { isPointerFile } = await import("../../utils/lfsHelpers"); - const isPtr = await isPointerFile(abs).catch(() => false); - if (isPtr) hasAvailablePointer = true; else hasAvailable = true; - } else { - hasAvailable = true; - } - } catch { - hasAvailable = true; - } - } - } + if (ws) { + availability[cellId] = await computeCellAudioStateWithVersionGate( + atts, + cell?.metadata?.selectedAudioId, + ws + ); + } - if (cellId === typedEvent.content.cellId && key === cell?.metadata?.selectedAudioId) { - const validatedBy = Array.isArray(att?.validatedBy) ? att.validatedBy : []; - validatedByArray = [...validatedBy]; + // Extract validatedBy for the target cell's selected audio + if (cellId === typedEvent.content.cellId && cell?.metadata?.selectedAudioId) { + const selectedAtt = (atts as any)[cell.metadata.selectedAudioId]; + if (selectedAtt) { + validatedByArray = Array.isArray(selectedAtt.validatedBy) + ? [...selectedAtt.validatedBy] + : []; } } - // If the user's selected audio is missing, show missing icon regardless of other attachments. - const selectedId = cell?.metadata?.selectedAudioId; - const selectedAtt = selectedId ? (atts as any)[selectedId] : undefined; - const selectedIsMissing = selectedAtt?.type === "audio" && selectedAtt?.isMissing === true; - - if (selectedIsMissing) { - availability[cellId] = "missing"; - } else if (hasAvailable) { - availability[cellId] = "available-local"; - } else if (hasAvailablePointer) { - availability[cellId] = "available-pointer"; - } else if (hasMissing) { - availability[cellId] = "missing"; - } else if (hasDeleted) { - availability[cellId] = "deletedOnly"; - } else { - availability[cellId] = "none"; - } } provider.postMessageToWebview(webviewPanel, { @@ -3271,61 +3123,17 @@ const messageHandlers: Record Promise c?.metadata?.id === cellId); if (cell) { - let hasAvailable = false; let hasAvailablePointer = false; let hasMissing = false; let hasDeleted = false; - const atts = cell?.metadata?.attachments || {}; - for (const key of Object.keys(atts)) { - const att: any = atts[key]; - if (att && att.type === "audio") { - if (att.isDeleted) hasDeleted = true; - else if (att.isMissing) hasMissing = true; - else { - try { - const url = String(att.url || ""); - if (url) { - const filesRel = url.startsWith(".project/") ? url : url.replace(/^\.?\/?/, ""); - const abs = path.join(workspaceFolder.uri.fsPath, filesRel); - const { isPointerFile } = await import("../../utils/lfsHelpers"); - const isPtr = await isPointerFile(abs).catch(() => false); - if (isPtr) hasAvailablePointer = true; else hasAvailable = true; - } else { - hasAvailable = true; - } - } catch { hasAvailable = true; } - } - } - } - // If the user's selected audio is missing, show missing icon regardless of other attachments. - const selectedId = cell?.metadata?.selectedAudioId; - const selectedAtt = selectedId ? (atts as any)[selectedId] : undefined; - const selectedIsMissing = selectedAtt?.type === "audio" && selectedAtt?.isMissing === true; - - // Provisional state - let state: "available" | "available-local" | "available-pointer" | "missing" | "deletedOnly" | "none"; - if (selectedIsMissing) state = "missing"; - else if (hasAvailable) state = "available-local"; - else if (hasAvailablePointer) state = "available-pointer"; - else if (hasMissing) state = "missing"; - else if (hasDeleted) state = "deletedOnly"; - else state = "none"; - - // Apply installed-version gate to avoid Play icon when blocked - if (state !== "available-local") { - try { - const { getFrontierVersionStatus } = await import("../../projectManager/utils/versionChecks"); - const status = await getFrontierVersionStatus(); - if (!status.ok) { - if (state !== "missing" && state !== "deletedOnly" && state !== "none") { - state = "available-pointer"; - } - } - } catch { /* ignore */ } - } - - availability[cellId] = state as any; - safePostMessageToPanel(webviewPanel, { type: "providerSendsAudioAttachments", attachments: availability }); + const state = await computeCellAudioStateWithVersionGate( + cell?.metadata?.attachments, + cell?.metadata?.selectedAudioId, + workspaceFolder + ); + safePostMessageToPanel(webviewPanel, { + type: "providerSendsAudioAttachments", + attachments: { [cellId]: state }, + }); } } catch { /* ignore */ } } diff --git a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts index b6681b997..c19faf777 100755 --- a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts @@ -32,6 +32,7 @@ import { safePostMessageToPanel } from "../../utils/webviewUtils"; import path from "path"; import * as fs from "fs"; import { getAuthApi } from "@/extension"; +import { computeCellAudioStateWithVersionGate, type AudioAvailabilityState } from "../../utils/audioAvailabilityUtils"; import { getCachedChapter as getCachedChapterUtil, updateCachedChapter as updateCachedChapterUtil, @@ -907,76 +908,15 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider = {}; for (const cell of notebookData.cells as any[]) { const cellId = cell?.metadata?.id; if (!cellId) continue; - let hasAvailable = false; let hasAvailablePointer = false; let hasMissing = false; let hasDeleted = false; - const atts = cell?.metadata?.attachments || {}; - for (const key of Object.keys(atts)) { - const att: any = atts[key]; - if (att && att.type === "audio") { - if (att.isDeleted) hasDeleted = true; - else if (att.isMissing) hasMissing = true; - else { - try { - const url = String(att.url || ""); - if (ws && url) { - const filesRel = url.startsWith(".project/") ? url : url.replace(/^\.?\/?/, ""); - const filesAbs = path.join(ws.uri.fsPath, filesRel); - try { - await vscode.workspace.fs.stat(vscode.Uri.file(filesAbs)); - const { isPointerFile } = await import("../../utils/lfsHelpers"); - const isPtr = await isPointerFile(filesAbs).catch(() => false); - if (isPtr) hasAvailablePointer = true; else hasAvailable = true; - } catch { - const pointerAbs = filesAbs.includes("/.project/attachments/files/") - ? filesAbs.replace("/.project/attachments/files/", "/.project/attachments/pointers/") - : filesAbs.replace(".project/attachments/files/", ".project/attachments/pointers/"); - try { - await vscode.workspace.fs.stat(vscode.Uri.file(pointerAbs)); - hasAvailablePointer = true; - } catch { - hasMissing = true; - } - } - } else { - hasMissing = true; - } - } catch { hasMissing = true; } - } - } - } - const selectedId = cell?.metadata?.selectedAudioId; - const selectedAtt = selectedId ? (atts as any)[selectedId] : undefined; - const selectedIsMissing = selectedAtt?.type === "audio" && selectedAtt?.isMissing === true; - - // Prefer showing available when a valid file exists, - // even if the user's explicit selection points to a missing file. - let state: "available" | "available-local" | "available-pointer" | "missing" | "deletedOnly" | "none"; - if (hasAvailable) state = "available-local"; - else if (hasAvailablePointer) state = "available-pointer"; - else if (selectedIsMissing || hasMissing) state = "missing"; - else if (hasDeleted) state = "deletedOnly"; - else state = "none"; - - // If Frontier installed version is below minimum, any non-local availability - // should present as "available-pointer" (cloud/download) to avoid Play UI. - if (state !== "available-local") { - try { - const { getFrontierVersionStatus } = await import("../../projectManager/utils/versionChecks"); - const status = await getFrontierVersionStatus(); - if (!status.ok) { - if (state !== "missing" && state !== "deletedOnly" && state !== "none") { - state = "available-pointer"; // normalize to non-playable - } - } - } catch { - // On failure to check, leave state unchanged - } - } - - availability[cellId] = state as any; + availability[cellId] = await computeCellAudioStateWithVersionGate( + cell?.metadata?.attachments, + cell?.metadata?.selectedAudioId, + ws + ); } if (Object.keys(availability).length > 0) { this.postMessageToWebview(webviewPanel, { @@ -1002,68 +942,19 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider = {}; - // Only update the specific cell that changed - const cell = cells.find((cell: any) => cell?.metadata?.id === cellId); + const cell = cells.find((c: any) => c?.metadata?.id === cellId); if (cell) { - let hasAvailable = false; - let hasAvailablePointer = false; - let hasMissing = false; - let hasDeleted = false; - const atts = cell?.metadata?.attachments || {}; - for (const key of Object.keys(atts)) { - const att: any = atts[key]; - if (att && att.type === "audio") { - if (att.isDeleted) hasDeleted = true; - else if (att.isMissing) hasMissing = true; - else { - try { - const ws = vscode.workspace.getWorkspaceFolder(document.uri); - const url = String(att.url || ""); - if (ws && url) { - const filesRel = url.startsWith(".project/") ? url : url.replace(/^\.?\/?/, ""); - const abs = path.join(ws.uri.fsPath, filesRel); - await vscode.workspace.fs.stat(vscode.Uri.file(abs)); - const { isPointerFile } = await import("../../utils/lfsHelpers"); - const isPtr = await isPointerFile(abs).catch(() => false); - if (isPtr) hasAvailablePointer = true; else hasAvailable = true; - } else { - hasMissing = true; - } - } catch { hasMissing = true; } - } - } - } - - const selectedId = cell?.metadata?.selectedAudioId; - const selectedAtt = selectedId ? (atts as any)[selectedId] : undefined; - const selectedIsMissing = selectedAtt?.type === "audio" && selectedAtt?.isMissing === true; - - // Prefer showing available when a valid file exists, - // even if the user's explicit selection points to a missing file. - let state: "available" | "available-local" | "available-pointer" | "missing" | "deletedOnly" | "none"; - if (hasAvailable) state = "available-local"; - else if (hasAvailablePointer) state = "available-pointer"; - else if (selectedIsMissing || hasMissing) state = "missing"; - else if (hasDeleted) state = "deletedOnly"; - else state = "none"; - - if (state !== "available-local") { - try { - const { getFrontierVersionStatus } = await import("../../projectManager/utils/versionChecks"); - const status = await getFrontierVersionStatus(); - if (!status.ok) { - if (state !== "missing" && state !== "deletedOnly" && state !== "none") { - state = "available-pointer"; - } - } - } catch { /* ignore */ } + const ws = vscode.workspace.getWorkspaceFolder(document.uri); + if (ws) { + availability[cellId] = await computeCellAudioStateWithVersionGate( + cell?.metadata?.attachments, + cell?.metadata?.selectedAudioId, + ws + ); } - availability[cellId] = state as any; - - // Send targeted update for this specific cell safePostMessageToPanel(webviewPanel, { type: "providerSendsAudioAttachments", attachments: availability @@ -4304,49 +4195,6 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider { - if (attachment.isDeleted) { - return "deletedOnly"; - } - if (attachment.isMissing) { - return "missing"; - } - - const url = String(attachment.url || ""); - if (!url) { - return "missing"; - } - - try { - const filesRel = url.startsWith(".project/") ? url : url.replace(/^\.?\/?/, ""); - const abs = path.join(workspaceFolder.uri.fsPath, filesRel); - await vscode.workspace.fs.stat(vscode.Uri.file(abs)); - const { isPointerFile } = await import("../../utils/lfsHelpers"); - const isPtr = await isPointerFile(abs).catch(() => false); - return isPtr ? "available-pointer" : "available-local"; - } catch { - // File doesn't exist at files/ path, check for pointer - try { - const filesRel = url.startsWith(".project/") ? url : url.replace(/^\.?\/?/, ""); - const filesAbs = path.join(workspaceFolder.uri.fsPath, filesRel); - const pointerAbs = filesAbs.includes("/.project/attachments/files/") - ? filesAbs.replace("/.project/attachments/files/", "/.project/attachments/pointers/") - : filesAbs.replace(".project/attachments/files/", ".project/attachments/pointers/"); - await vscode.workspace.fs.stat(vscode.Uri.file(pointerAbs)); - return "available-pointer"; - } catch { - return "missing"; - } - } - } - /** * Refreshes audio attachments for all open webviews after sync operations. * This ensures that audio availability is updated even if file watchers didn't trigger during sync. @@ -4357,57 +4205,36 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider = {}; - // Check audio availability for all cells using getCurrentAttachment() const cellIds = document.getAllCellIds(); for (const cellId of cellIds) { - // Get the current attachment (respects selectedAudioId) const currentAttachment = document.getCurrentAttachment(cellId, "audio"); - if (!currentAttachment) { availability[cellId] = "none"; continue; } - // Check availability only for the current attachment - let state = await this.checkAttachmentAvailability(currentAttachment.attachment, ws); - - // Apply version gate if needed - if (state !== "available-local") { - try { - const { getFrontierVersionStatus } = await import("../../projectManager/utils/versionChecks"); - const status = await getFrontierVersionStatus(); - if (!status.ok) { - if (state !== "missing" && state !== "deletedOnly") { - state = "available-pointer"; - } - } - } catch { - // On failure to check, leave state unchanged - } - } - - availability[cellId] = state; + availability[cellId] = await computeCellAudioStateWithVersionGate( + { [currentAttachment.attachmentId]: currentAttachment.attachment }, + currentAttachment.attachmentId, + ws + ); } - // Send updated audio attachments to webview if (Object.keys(availability).length > 0) { safePostMessageToPanel(webviewPanel, { type: "providerSendsAudioAttachments", attachments: availability }); - debug(`Refreshed audio attachments for ${Object.keys(availability).length} cells in ${documentUri}`); } - } catch (error) { console.error("Error refreshing audio attachments for document:", documentUri, error); } diff --git a/src/providers/navigationWebview/navigationWebviewProvider.ts b/src/providers/navigationWebview/navigationWebviewProvider.ts index 22bf021bb..2966b976f 100644 --- a/src/providers/navigationWebview/navigationWebviewProvider.ts +++ b/src/providers/navigationWebview/navigationWebviewProvider.ts @@ -1,6 +1,5 @@ import * as vscode from "vscode"; import * as path from "path"; -import * as fs from "fs"; import { CodexContentSerializer } from "../../serializer"; import bibleData from "../../../webviews/codex-webviews/src/assets/bible-books-lookup.json"; import { BaseWebviewProvider } from "../../globalProvider"; @@ -9,6 +8,7 @@ import { safePostMessageToView } from "../../utils/webviewUtils"; import { CodexItem } from "types"; import { getCellValueData, cellHasAudioUsingAttachments, computeValidationStats, computeProgressPercents, shouldExcludeCellFromProgress, shouldExcludeQuillCellFromProgress, countActiveValidations, hasTextContent } from "../../../sharedUtils"; import { normalizeCorpusMarker } from "../../utils/corpusMarkerUtils"; +import { isSelectedAudioMissing } from "../../utils/audioAvailabilityUtils"; import { addMetadataEdit, addProjectMetadataEdit, EditMapUtils } from "../../utils/editMapUtils"; import { MetadataManager } from "../../utils/metadataManager"; import { getAuthApi } from "../../extension"; @@ -592,6 +592,21 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { cellHasAudioUsingAttachments(cell?.metadata?.attachments, cell?.metadata?.selectedAudioId) ).length; + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + let cellsWithMissingAudio = 0; + if (workspaceFolder) { + const missingChecks = await Promise.all( + progressCells.map((cell) => + isSelectedAudioMissing( + cell?.metadata?.attachments, + cell?.metadata?.selectedAudioId, + workspaceFolder + ) + ) + ); + cellsWithMissingAudio = missingChecks.filter(Boolean).length; + } + // Use project settings for required validation counts const config = vscode.workspace.getConfiguration("codex-project-manager"); const minimumValidationsRequired = config.get("validationCount", 1); @@ -660,6 +675,7 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { audioValidationLevels, requiredTextValidations: minimumValidationsRequired, requiredAudioValidations: minimumAudioValidationsRequired, + cellsWithMissingAudio, }, sortOrder, fileDisplayName: metadata?.fileDisplayName, @@ -736,6 +752,8 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { const averageTextValidationLevels = avgArray('textValidationLevels', textLen); const averageAudioValidationLevels = avgArray('audioValidationLevels', audioLen); + const totalMissingAudio = itemsInGroup.reduce((sum, item) => sum + (item.progress?.cellsWithMissingAudio || 0), 0); + const sortedItems = itemsInGroup.sort((a, b) => { if (a.sortOrder && b.sortOrder) { return a.sortOrder.localeCompare(b.sortOrder); @@ -760,6 +778,7 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { audioValidationLevels: averageAudioValidationLevels, requiredTextValidations: vscode.workspace.getConfiguration("codex-project-manager").get("validationCount", 1) || 1, requiredAudioValidations: vscode.workspace.getConfiguration("codex-project-manager").get("validationCountAudio", 1) || 1, + cellsWithMissingAudio: totalMissingAudio, }, }); }); @@ -1280,7 +1299,7 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { const author = await this.getCurrentUser(); await MetadataManager.safeUpdateMetadata( workspaceFolder, - (metadata: { edits?: unknown[] }) => { + (metadata: { edits?: unknown[]; }) => { if (!metadata.edits) metadata.edits = []; addProjectMetadataEdit( metadata, @@ -1299,7 +1318,7 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { private async recordCorpusDeletionToEditHistory( corpusMarker: string, - deletedFiles: Array<{ filePath: string; label: string }> + deletedFiles: Array<{ filePath: string; label: string; }> ): Promise { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri; if (!workspaceFolder) return; @@ -1308,7 +1327,7 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { const author = await this.getCurrentUser(); await MetadataManager.safeUpdateMetadata( workspaceFolder, - (metadata: { edits?: unknown[] }) => { + (metadata: { edits?: unknown[]; }) => { if (!metadata.edits) metadata.edits = []; addProjectMetadataEdit( metadata, @@ -1338,7 +1357,7 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { private async deleteCorpusMarker( corpusLabel: string, displayName: string, - children: Array<{ uri: string; label: string; type: string }> + children: Array<{ uri: string; label: string; type: string; }> ): Promise { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; if (!workspaceFolder) { @@ -1363,7 +1382,7 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { if (panelToClose) panelToClose.dispose(); }; - const allDeletedFiles: Array<{ filePath: string; label: string }> = []; + const allDeletedFiles: Array<{ filePath: string; label: string; }> = []; const errors: string[] = []; await vscode.window.withProgress( @@ -1430,7 +1449,7 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { try { await vscode.workspace.fs.delete(sourceUri); } catch (deleteError: unknown) { - const err = deleteError as { code?: string }; + const err = deleteError as { code?: string; }; if (err.code !== "FileNotFound" && err.code !== "ENOENT") { errors.push(`Failed to delete source for ${child.label}`); } diff --git a/src/utils/audioAvailabilityUtils.ts b/src/utils/audioAvailabilityUtils.ts new file mode 100644 index 000000000..f3eae15ca --- /dev/null +++ b/src/utils/audioAvailabilityUtils.ts @@ -0,0 +1,188 @@ +import * as vscode from "vscode"; +import path from "path"; +import { toPosixPath } from "./pathUtils"; + +export type AudioAvailabilityState = + | "available-local" + | "available-pointer" + | "missing" + | "deletedOnly" + | "none"; + +interface AttachmentLike { + url?: string; + type?: string; + isDeleted?: boolean; + isMissing?: boolean; +} + +/** + * Check the on-disk availability of a single audio attachment. + * + * Resolution order: + * 1. isDeleted flag -> "deletedOnly" + * 2. isMissing flag (set when editor opens) -> "missing" + * 3. stat the files/ path; if it exists, inspect whether it's an LFS pointer + * 4. stat the pointers/ path as a fallback -> "available-pointer" + * 5. Otherwise -> "missing" + */ +export async function checkAttachmentAvailability( + attachment: AttachmentLike, + workspaceFolder: vscode.WorkspaceFolder +): Promise> { + if (attachment.isDeleted) { + return "deletedOnly"; + } + if (attachment.isMissing) { + return "missing"; + } + + const url = String(attachment.url || ""); + if (!url) { + return "missing"; + } + + const filesRel = url.startsWith(".project/") ? url : url.replace(/^\.?\/?/, ""); + const filesAbs = path.join(workspaceFolder.uri.fsPath, filesRel); + + try { + await vscode.workspace.fs.stat(vscode.Uri.file(filesAbs)); + const { isPointerFile } = await import("./lfsHelpers"); + const isPtr = await isPointerFile(filesAbs).catch(() => false); + return isPtr ? "available-pointer" : "available-local"; + } catch { + // files/ path doesn't exist — try the pointers/ equivalent + } + + try { + const normalizedPosix = toPosixPath(filesAbs); + const pointerAbs = normalizedPosix.includes("/attachments/files/") + ? filesAbs.replace( + path.join(".project", "attachments", "files"), + path.join(".project", "attachments", "pointers") + ) + : filesAbs; + + if (pointerAbs !== filesAbs) { + await vscode.workspace.fs.stat(vscode.Uri.file(pointerAbs)); + return "available-pointer"; + } + } catch { + // pointer path doesn't exist either + } + + return "missing"; +} + +/** + * Compute the overall audio availability state for a cell by inspecting + * all of its audio attachments and the selectedAudioId. + * + * Priority: available-local > available-pointer > missing > deletedOnly > none + * This means if *any* attachment is locally available the cell reports as such, + * even if the user's explicit selection points to a missing file. + */ +export async function computeCellAudioState( + attachments: Record | undefined, + selectedAudioId: string | undefined, + workspaceFolder: vscode.WorkspaceFolder +): Promise { + if (!attachments || Object.keys(attachments).length === 0) { + return "none"; + } + + let hasAvailable = false; + let hasAvailablePointer = false; + let hasMissing = false; + let hasDeleted = false; + + for (const att of Object.values(attachments)) { + if (!att || att.type !== "audio") continue; + + const state = await checkAttachmentAvailability(att, workspaceFolder); + switch (state) { + case "available-local": + hasAvailable = true; + break; + case "available-pointer": + hasAvailablePointer = true; + break; + case "missing": + hasMissing = true; + break; + case "deletedOnly": + hasDeleted = true; + break; + } + } + + const selectedAtt = selectedAudioId ? attachments[selectedAudioId] : undefined; + const selectedIsMissing = + selectedAtt?.type === "audio" && selectedAtt?.isMissing === true; + + if (hasAvailable) return "available-local"; + if (hasAvailablePointer) return "available-pointer"; + if (selectedIsMissing || hasMissing) return "missing"; + if (hasDeleted) return "deletedOnly"; + return "none"; +} + +/** + * Apply the Frontier installed-version gate. + * When Frontier is below the minimum version, non-local availability + * is normalised to "available-pointer" so the Play UI is hidden. + */ +export async function applyFrontierVersionGate( + state: AudioAvailabilityState +): Promise { + if (state === "available-local") return state; + + try { + const { getFrontierVersionStatus } = await import( + "../projectManager/utils/versionChecks" + ); + const status = await getFrontierVersionStatus(); + if ( + !status.ok && + state !== "missing" && + state !== "deletedOnly" && + state !== "none" + ) { + return "available-pointer"; + } + } catch { + // leave state unchanged on check failure + } + return state; +} + +/** + * Convenience: compute cell audio state with the Frontier version gate applied. + */ +export async function computeCellAudioStateWithVersionGate( + attachments: Record | undefined, + selectedAudioId: string | undefined, + workspaceFolder: vscode.WorkspaceFolder +): Promise { + const state = await computeCellAudioState(attachments, selectedAudioId, workspaceFolder); + return applyFrontierVersionGate(state); +} + +/** + * Check whether a cell's selected audio is missing from disk. + * Lighter-weight helper for progress tracking where we only need a boolean + * (e.g. the navigation sidebar). + */ +export async function isSelectedAudioMissing( + attachments: Record | undefined, + selectedAudioId: string | undefined, + workspaceFolder: vscode.WorkspaceFolder +): Promise { + if (!selectedAudioId || !attachments) return false; + + const att = attachments[selectedAudioId]; + if (!att || att.type !== "audio" || att.isDeleted) return false; + + const state = await checkAttachmentAvailability(att, workspaceFolder); + return state === "missing"; +} diff --git a/types/index.d.ts b/types/index.d.ts index dadbb8b39..e37b1076e 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1200,8 +1200,8 @@ type ProjectMetadata = { /** Registry of original imported files (hash, fileName, referencedBy) - stored in metadata.json for sync/merge */ originalFilesHashes?: { version: number; - files: { [hash: string]: { hash: string; fileName: string; originalNames: string[]; referencedBy: string[]; addedAt: string } }; - fileNameToHash: { [fileName: string]: string }; + files: { [hash: string]: { hash: string; fileName: string; originalNames: string[]; referencedBy: string[]; addedAt: string; }; }; + fileNameToHash: { [fileName: string]: string; }; }; edits?: ProjectEditHistory[]; meta: { @@ -1618,7 +1618,7 @@ type ProjectManagerMessageFromWebview = content: { corpusLabel: string; displayName: string; - children: Array<{ uri: string; label: string; type: string }>; + children: Array<{ uri: string; label: string; type: string; }>; }; } | { command: "openCellLabelImporter"; } @@ -2026,6 +2026,7 @@ interface CodexItem { audioValidationLevels?: number[]; requiredTextValidations?: number; requiredAudioValidations?: number; + cellsWithMissingAudio?: number; }; sortOrder?: string; isProjectDictionary?: boolean; @@ -2473,7 +2474,7 @@ type EditorReceiveMessages = | { type: "searchMatchCounts"; query: string; - milestoneMatchCounts: { [milestoneIdx: number]: number }; + milestoneMatchCounts: { [milestoneIdx: number]: number; }; totalMatches: number; error?: string; }; diff --git a/webviews/codex-webviews/src/NavigationView/index.tsx b/webviews/codex-webviews/src/NavigationView/index.tsx index b0f61ced5..587dea733 100644 --- a/webviews/codex-webviews/src/NavigationView/index.tsx +++ b/webviews/codex-webviews/src/NavigationView/index.tsx @@ -12,7 +12,7 @@ import { } from "../components/ui/dropdown-menu"; import "../tailwind.css"; import { CodexItem } from "types"; -import { Languages, Mic } from "lucide-react"; +import { Languages, Mic, TriangleAlert } from "lucide-react"; import { RenameModal } from "../components/RenameModal"; import { Dialog, @@ -690,6 +690,7 @@ function NavigationView() { audioValidationLevels?: number[]; requiredTextValidations?: number; requiredAudioValidations?: number; + cellsWithMissingAudio?: number; }) => { if (typeof progress !== "object") { return { @@ -701,6 +702,7 @@ function NavigationView() { audioValidationLevels: [] as number[], requiredTextValidations: undefined as number | undefined, requiredAudioValidations: undefined as number | undefined, + cellsWithMissingAudio: 0, }; } const textValidation = Math.max( @@ -728,6 +730,7 @@ function NavigationView() { audioValidationLevels: progress.audioValidationLevels ?? [audioValidation], requiredTextValidations: progress.requiredTextValidations, requiredAudioValidations: progress.requiredAudioValidations, + cellsWithMissingAudio: progress.cellsWithMissingAudio ?? 0, }; }; @@ -810,6 +813,7 @@ function NavigationView() { const progressValues = getProgressValues(item.progress); const hasProgress = item.progress && typeof item.progress === "object"; const hasAudio = progressValues.audioCompletion > 0 || progressValues.audioValidation > 0; + const hasMissingAudio = progressValues.cellsWithMissingAudio > 0; return (
@@ -922,19 +926,33 @@ function NavigationView() { showTooltips />
- {/* Audio progress - only show if there's audio data */} - {hasAudio && ( + {/* Audio progress - show if there's audio data or missing audio */} + {(hasAudio || hasMissingAudio) && (
- - +
+ {hasMissingAudio && ( +
+ +
+ )} + +
+ {hasAudio && ( + + )}
)}
From 94ecfd9385d0794ca9807b66eae152fc3ac65cbb Mon Sep 17 00:00:00 2001 From: LeviXIII Date: Thu, 5 Mar 2026 12:56:50 -0500 Subject: [PATCH 03/12] - Refactor audio attachment handling to use audioAvailability instead of isMissing across the codebase. - Update related functions and tests to ensure consistent behavior and improve clarity in audio state management. --- ...vailability_on_attachment_5d7cef57.plan.md | 152 ++++++++++++++++++ sharedUtils/index.ts | 17 +- src/exportHandler/audioExporter.ts | 6 +- src/projectManager/utils/merge/resolvers.ts | 2 +- .../codexCellEditorMessagehandling.ts | 44 +++-- .../codexCellEditorProvider.ts | 14 +- .../codexCellEditorProvider/codexDocument.ts | 15 +- .../navigationWebviewProvider.ts | 22 +-- .../suite/audioAttachmentsRestoration.test.ts | 14 +- .../suite/codexCellEditorProvider.test.ts | 10 +- src/test/suite/providerMergeResolve.test.ts | 20 +-- src/utils/audioAttachmentsMigrationUtils.ts | 40 ++--- src/utils/audioAvailabilityUtils.ts | 80 ++++++--- src/utils/audioMissingUtils.ts | 40 +++-- types/index.d.ts | 8 +- .../CodexCellEditor/AudioHistoryViewer.tsx | 11 +- .../hooks/useVSCodeMessageHandler.ts | 11 +- .../importers/audio/cellMetadata.ts | 1 + .../importers/audio2/AudioImporter2Form.tsx | 1 + .../SpreadsheetImporterForm.tsx | 2 + 20 files changed, 353 insertions(+), 157 deletions(-) create mode 100644 .cursor/plans/audio_availability_on_attachment_5d7cef57.plan.md diff --git a/.cursor/plans/audio_availability_on_attachment_5d7cef57.plan.md b/.cursor/plans/audio_availability_on_attachment_5d7cef57.plan.md new file mode 100644 index 000000000..56afbe0ed --- /dev/null +++ b/.cursor/plans/audio_availability_on_attachment_5d7cef57.plan.md @@ -0,0 +1,152 @@ +--- +name: Audio availability on attachment +overview: Replace filesystem stat calls at read time with a persisted `audioAvailability` field on each attachment, set at write time. This eliminates all I/O from availability checks, making them pure metadata reads. +todos: + - id: types + content: Add AttachmentAvailability type and audioAvailability field to types/index.d.ts + status: completed + - id: availability-read + content: Simplify checkAttachmentAvailability to pure metadata read, extract filesystem logic to determineAttachmentAvailability + status: completed + - id: availability-write + content: "Update audioMissingUtils.ts: rename setter, update revalidation to set full state" + status: completed + - id: migration + content: Update audioAttachmentsMigrationUtils.ts to convert isMissing to audioAvailability + status: completed + - id: recording-handler + content: "Set audioAvailability: available-local in saveAudioAttachment, merge, and stream-and-save handlers" + status: completed + - id: document-model + content: "Update codexDocument.ts: parameter type, getCurrentAttachment, auto-selection logic" + status: completed + - id: shared-utils + content: Update cellHasMissingAudio and cellHasAudioUsingAttachments in sharedUtils + status: completed + - id: webview-code + content: Update deriveAudioAvailability in useVSCodeMessageHandler.ts and AudioHistoryViewer.tsx + status: completed + - id: importers + content: "Add audioAvailability: available-local to all import form attachment constructors" + status: completed + - id: merge-export + content: Update resolvers.ts isValidSelection and audioExporter.ts filter + status: completed + - id: tests + content: Update all test fixtures and assertions for the new field + status: completed +isProject: false +--- + +# Replace Filesystem Checks with Persisted `audioAvailability` Field + +## Context + +Currently, `checkAttachmentAvailability()` in `[src/utils/audioAvailabilityUtils.ts](src/utils/audioAvailabilityUtils.ts)` does filesystem `stat` calls and LFS pointer detection **every time** availability is queried. This happens at document open, after recording, after sync, and in the navigation sidebar — often looping over every cell in a chapter. + +The `isMissing: boolean` field already exists but only captures 2 of 3 states. We will introduce `audioAvailability: "available-local" | "available-pointer" | "missing"` on each attachment and make all read-time checks pure metadata lookups. + +## New Type + +Define a new type `AttachmentAvailability` and add the field to all attachment shapes: + +```typescript +type AttachmentAvailability = "available-local" | "available-pointer" | "missing"; +``` + +This is the **attachment-level** state. The existing cell-level `AudioAvailabilityState` (which adds `"none"` and `"deletedOnly"`) remains as a rollup derived from individual attachments. + +## Files to Change + +### 1. Types — `[types/index.d.ts](types/index.d.ts)` + +- Export `AttachmentAvailability` type +- Add `audioAvailability?: AttachmentAvailability` to all 3 attachment shapes (lines ~950, ~1145, ~2431) +- Keep `isMissing?: boolean` temporarily (marked deprecated) for backward compat during migration window + +### 2. Core availability functions — `[src/utils/audioAvailabilityUtils.ts](src/utils/audioAvailabilityUtils.ts)` + +- `checkAttachmentAvailability` becomes a **pure metadata read**: reads `audioAvailability` field, falls back to `isMissing` for legacy data, returns the state without any filesystem I/O +- `computeCellAudioState` stays structurally the same but now calls the simplified `checkAttachmentAvailability` +- `isSelectedAudioMissing` simplifies to a direct field read +- `applyFrontierVersionGate` unchanged (operates on the state enum, not filesystem) +- Update `AttachmentLike` interface to include `audioAvailability?` +- **New export**: `determineAttachmentAvailability(workspaceFolder, attachmentUrl)` — the extracted filesystem logic (stat + LFS detection) for use at write time + +### 3. Write-time availability setter — `[src/utils/audioMissingUtils.ts](src/utils/audioMissingUtils.ts)` + +- Rename `setMissingFlagOnAttachmentObject` to `setAttachmentAvailability` — sets `audioAvailability` (and deprecated `isMissing` for compat), bumps `updatedAt` +- Update `revalidateCellMissingFlags` to call `determineAttachmentAvailability` and set the full state +- Update `attachmentPointerExists` / `ensurePointerFromFiles` as needed + +### 4. Migration — `[src/utils/audioAttachmentsMigrationUtils.ts](src/utils/audioAttachmentsMigrationUtils.ts)` + +- `updateMissingFlagsForCodexDocuments`: set `audioAvailability` based on filesystem check (same logic as today, but writes the richer field) +- Convert legacy `isMissing` to `audioAvailability` for old documents + +### 5. Recording handler — `[src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts](src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts)` + +- `saveAudioAttachment` (~line 2251): add `audioAvailability: "available-local"` to the attachment object (file is written to both `files/` and `pointers/`) +- `updateCellAfterTranscription` (~line 441): preserve existing `audioAvailability` from the current attachment +- Cell merge handler (~line 3014): set `audioAvailability: "available-local"` on merged attachment +- Stream-and-save paths (~lines 1878, 2000): after writing file to disk, also update the attachment's `audioAvailability` to `"available-local"` in the document (not just via webview message) + +### 6. Document model — `[src/providers/codexCellEditorProvider/codexDocument.ts](src/providers/codexCellEditorProvider/codexDocument.ts)` + +- Update `updateCellAttachment` parameter type to include `audioAvailability?` +- Update `getCurrentAttachment` logic: replace `!att.isMissing` checks with `att.audioAvailability !== "missing"` +- Update auto-selection fallback logic (~line 3224): use `audioAvailability` instead of `isMissing` + +### 7. Provider — `[src/providers/codexCellEditorProvider/codexCellEditorProvider.ts](src/providers/codexCellEditorProvider/codexCellEditorProvider.ts)` + +- All `computeCellAudioStateWithVersionGate` call sites remain, but they become much faster (no filesystem I/O underneath) +- No structural changes needed + +### 8. Navigation sidebar — `[src/providers/navigationWebview/navigationWebviewProvider.ts](src/providers/navigationWebview/navigationWebviewProvider.ts)` + +- `isSelectedAudioMissing` call site benefits automatically from the simplified implementation + +### 9. Shared utils — `[sharedUtils/index.ts](sharedUtils/index.ts)` + +- `cellHasMissingAudio`: replace `att.isMissing === true` with `att.audioAvailability === "missing"` +- `cellHasAudioUsingAttachments`: replace `att.isMissing !== true` with `att.audioAvailability !== "missing"` + +### 10. Webview code + +- `[useVSCodeMessageHandler.ts](webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts)`: update `deriveAudioAvailability` to read `audioAvailability` instead of `isMissing` +- `[AudioHistoryViewer.tsx](webviews/codex-webviews/src/CodexCellEditor/AudioHistoryViewer.tsx)`: update local type and `isMissing` references + +### 11. Import forms (set `audioAvailability: "available-local"` on new attachments) + +- `[cellMetadata.ts](webviews/codex-webviews/src/NewSourceUploader/importers/audio/cellMetadata.ts)` ~line 64 +- `[AudioImporter2Form.tsx](webviews/codex-webviews/src/NewSourceUploader/importers/audio2/AudioImporter2Form.tsx)` ~line 637 +- `[SpreadsheetImporterForm.tsx](webviews/codex-webviews/src/NewSourceUploader/importers/bibleSpredSheet/SpreadsheetImporterForm.tsx)` ~lines 716, 764 + +### 12. Merge resolver — `[src/projectManager/utils/merge/resolvers.ts](src/projectManager/utils/merge/resolvers.ts)` + +- `isValidSelection` (~line 1773): replace `!attachment.isMissing` with `attachment.audioAvailability !== "missing"` +- `mergeAttachments`: ensure `audioAvailability` is preserved from the winning side + +### 13. Audio exporter — `[src/exportHandler/audioExporter.ts](src/exportHandler/audioExporter.ts)` + +- Replace `attVal.isMissing` filter (~line 454) with `attVal.audioAvailability === "missing"` + +### 14. Tests + +- `[codexCellEditorProvider.test.ts](src/test/suite/codexCellEditorProvider.test.ts)`: update attachment fixtures +- `[audioAttachmentsRestoration.test.ts](src/test/suite/audioAttachmentsRestoration.test.ts)`: update fixtures and assertions +- `[providerMergeResolve.test.ts](src/test/suite/providerMergeResolve.test.ts)`: update merge test + +## Backward Compatibility + +- Keep `isMissing?: boolean` on the type (deprecated) for one release cycle +- `checkAttachmentAvailability` reads `audioAvailability` first, falls back to `isMissing` if absent +- Migration converts `isMissing` to `audioAvailability` on project open +- Write-time setters write both fields during the transition + +## What This Eliminates + +- All `vscode.workspace.fs.stat()` calls in `checkAttachmentAvailability` +- All `isPointerFile()` calls at read time +- The `lfsHelpers` dynamic import in the read path +- N filesystem round-trips per chapter on document open, save, sync refresh, and nav sidebar progress computation diff --git a/sharedUtils/index.ts b/sharedUtils/index.ts index 8cb92708c..64a81460f 100644 --- a/sharedUtils/index.ts +++ b/sharedUtils/index.ts @@ -142,7 +142,7 @@ export const shouldDisableValidation = ( /** * Returns true if the cell has an audio attachment whose file is missing from disk - * (isMissing === true on the selected or any non-deleted audio attachment). + * (audioAvailability === "missing" on the selected or any non-deleted audio attachment). */ export const cellHasMissingAudio = ( attachments: Record | undefined, @@ -151,17 +151,19 @@ export const cellHasMissingAudio = ( const atts = attachments; if (!atts || Object.keys(atts).length === 0) return false; + const isAttMissing = (att: any): boolean => + att.audioAvailability === "missing" || (att.audioAvailability === undefined && att.isMissing === true); + if (selectedAudioId && atts[selectedAudioId]) { const att = atts[selectedAudioId]; - return att && att.type === "audio" && !att.isDeleted && att.isMissing === true; + return att && att.type === "audio" && !att.isDeleted && isAttMissing(att); } return Object.values(atts).some( - (att: any) => att && att.type === "audio" && !att.isDeleted && att.isMissing === true + (att: any) => att && att.type === "audio" && !att.isDeleted && isAttMissing(att) ); }; -// Progress helpers shared across provider and webviews export const cellHasAudioUsingAttachments = ( attachments: Record | undefined, selectedAudioId?: string @@ -169,13 +171,16 @@ export const cellHasAudioUsingAttachments = ( const atts = attachments; if (!atts || Object.keys(atts).length === 0) return false; + const isAttAvailable = (att: any): boolean => + att.audioAvailability !== "missing" && (att.audioAvailability !== undefined || att.isMissing !== true); + if (selectedAudioId && atts[selectedAudioId]) { const att = atts[selectedAudioId]; - return att && att.type === "audio" && !att.isDeleted && att.isMissing !== true; + return att && att.type === "audio" && !att.isDeleted && isAttAvailable(att); } return Object.values(atts).some( - (att: any) => att && att.type === "audio" && !att.isDeleted && att.isMissing !== true + (att: any) => att && att.type === "audio" && !att.isDeleted && isAttAvailable(att) ); }; diff --git a/src/exportHandler/audioExporter.ts b/src/exportHandler/audioExporter.ts index ef23e101b..6f543d175 100644 --- a/src/exportHandler/audioExporter.ts +++ b/src/exportHandler/audioExporter.ts @@ -445,13 +445,13 @@ function pickAudioAttachmentForCell(cell: any): { id: string; url: string; start if (!attachments || typeof attachments !== "object") return null; const selectedId: string | undefined = cell?.metadata?.selectedAudioId; - const candidates: Array<{ id: string; url: string; updatedAt?: number; start?: number; end?: number; isDeleted?: boolean; isMissing?: boolean; }> + const candidates: Array<{ id: string; url: string; updatedAt?: number; start?: number; end?: number; isDeleted?: boolean; audioAvailability?: string; }> = []; for (const [attId, attVal] of Object.entries(attachments)) { if (!attVal || typeof attVal !== "object") continue; if (attVal.type !== "audio") continue; if (attVal.isDeleted) continue; - if (attVal.isMissing) continue; + if (attVal.audioAvailability === "missing") continue; if (!attVal.url || typeof attVal.url !== "string") continue; candidates.push({ id: attId, url: attVal.url, updatedAt: attVal.updatedAt, start: attVal.startTime, end: attVal.endTime }); } @@ -563,7 +563,7 @@ export async function exportAudioAttachments( id: k, type: attachments[k]?.type, isDeleted: attachments[k]?.isDeleted, - isMissing: attachments[k]?.isMissing, + audioAvailability: attachments[k]?.audioAvailability, hasUrl: !!attachments[k]?.url })) ); diff --git a/src/projectManager/utils/merge/resolvers.ts b/src/projectManager/utils/merge/resolvers.ts index d366bb2d2..29176ea51 100644 --- a/src/projectManager/utils/merge/resolvers.ts +++ b/src/projectManager/utils/merge/resolvers.ts @@ -1770,7 +1770,7 @@ function isValidSelection(selectedId: string, attachments?: { [key: string]: any return attachment && attachment.type === "audio" && !attachment.isDeleted && - !attachment.isMissing; + attachment.audioAvailability !== "missing"; } /** diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 25d64f1ed..4a99d2e34 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -447,14 +447,13 @@ const messageHandlers: Record Promise = {}; - if (Array.isArray(notebookData?.cells) && workspaceFolder) { + if (Array.isArray(notebookData?.cells)) { for (const cell of notebookData.cells) { const id = cell?.metadata?.id; if (!id) continue; availability[id] = await computeCellAudioStateWithVersionGate( cell?.metadata?.attachments, cell?.metadata?.selectedAudioId, - workspaceFolder ); } } @@ -1875,7 +1874,16 @@ const messageHandlers: Record Promise Promise Promise Promise Promise Promise Promise Promise 0) { @@ -946,14 +945,10 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider c?.metadata?.id === cellId); if (cell) { - const ws = vscode.workspace.getWorkspaceFolder(document.uri); - if (ws) { - availability[cellId] = await computeCellAudioStateWithVersionGate( - cell?.metadata?.attachments, - cell?.metadata?.selectedAudioId, - ws - ); - } + availability[cellId] = await computeCellAudioStateWithVersionGate( + cell?.metadata?.attachments, + cell?.metadata?.selectedAudioId, + ); safePostMessageToPanel(webviewPanel, { type: "providerSendsAudioAttachments", @@ -4224,7 +4219,6 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider; createdBy?: string; } + attachmentData: { url: string; type: string; createdAt: number; updatedAt: number; isDeleted: boolean; audioAvailability?: import("../../../types").AttachmentAvailability; metadata?: Record; createdBy?: string; } ): void { const indexOfCellToUpdate = this._documentData.cells.findIndex( (cell) => cell.metadata?.id === cellId @@ -2997,7 +2997,7 @@ export class CodexCellDocument implements vscode.CustomDocument { if (selectedAttachment && selectedAttachment.type === attachmentType && !selectedAttachment.isDeleted && - !selectedAttachment.isMissing) { + selectedAttachment.audioAvailability !== "missing") { return { attachmentId: cell.metadata.selectedAudioId, attachment: selectedAttachment @@ -3015,8 +3015,8 @@ export class CodexCellDocument implements vscode.CustomDocument { !attachment.isDeleted ) .sort(([_, a]: [string, any], [__, b]: [string, any]) => { - const aMissing = a.isMissing ? 1 : 0; - const bMissing = b.isMissing ? 1 : 0; + const aMissing = a.audioAvailability === "missing" ? 1 : 0; + const bMissing = b.audioAvailability === "missing" ? 1 : 0; if (aMissing !== bMissing) return aMissing - bMissing; return (b.updatedAt || 0) - (a.updatedAt || 0); }); @@ -3223,13 +3223,12 @@ export class CodexCellDocument implements vscode.CustomDocument { selectedAttachment.type !== "audio" || selectedAttachment.isDeleted; - const isMissing = !isInvalid && selectedAttachment.isMissing === true; + const isMissingAudio = !isInvalid && selectedAttachment.audioAvailability === "missing"; - if (isInvalid || isMissing) { - // Look for a valid non-missing audio attachment to auto-select + if (isInvalid || isMissingAudio) { const validAlternative = Object.entries(cell.metadata.attachments) .filter(([_, att]: [string, any]) => - att?.type === "audio" && !att.isDeleted && !att.isMissing + att?.type === "audio" && !att.isDeleted && att.audioAvailability !== "missing" ) .sort(([_, a]: [string, any], [__, b]: [string, any]) => (b.updatedAt || 0) - (a.updatedAt || 0) diff --git a/src/providers/navigationWebview/navigationWebviewProvider.ts b/src/providers/navigationWebview/navigationWebviewProvider.ts index 2966b976f..ce3de626b 100644 --- a/src/providers/navigationWebview/navigationWebviewProvider.ts +++ b/src/providers/navigationWebview/navigationWebviewProvider.ts @@ -8,7 +8,7 @@ import { safePostMessageToView } from "../../utils/webviewUtils"; import { CodexItem } from "types"; import { getCellValueData, cellHasAudioUsingAttachments, computeValidationStats, computeProgressPercents, shouldExcludeCellFromProgress, shouldExcludeQuillCellFromProgress, countActiveValidations, hasTextContent } from "../../../sharedUtils"; import { normalizeCorpusMarker } from "../../utils/corpusMarkerUtils"; -import { isSelectedAudioMissing } from "../../utils/audioAvailabilityUtils"; +import { computeCellAudioState } from "../../utils/audioAvailabilityUtils"; import { addMetadataEdit, addProjectMetadataEdit, EditMapUtils } from "../../utils/editMapUtils"; import { MetadataManager } from "../../utils/metadataManager"; import { getAuthApi } from "../../extension"; @@ -592,20 +592,12 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { cellHasAudioUsingAttachments(cell?.metadata?.attachments, cell?.metadata?.selectedAudioId) ).length; - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - let cellsWithMissingAudio = 0; - if (workspaceFolder) { - const missingChecks = await Promise.all( - progressCells.map((cell) => - isSelectedAudioMissing( - cell?.metadata?.attachments, - cell?.metadata?.selectedAudioId, - workspaceFolder - ) - ) - ); - cellsWithMissingAudio = missingChecks.filter(Boolean).length; - } + const cellsWithMissingAudio = progressCells.filter((cell) => + computeCellAudioState( + cell?.metadata?.attachments, + cell?.metadata?.selectedAudioId, + ) === "missing" + ).length; // Use project settings for required validation counts const config = vscode.workspace.getConfiguration("codex-project-manager"); diff --git a/src/test/suite/audioAttachmentsRestoration.test.ts b/src/test/suite/audioAttachmentsRestoration.test.ts index e11c6785a..b91e72246 100644 --- a/src/test/suite/audioAttachmentsRestoration.test.ts +++ b/src/test/suite/audioAttachmentsRestoration.test.ts @@ -63,7 +63,7 @@ suite('Audio Attachments Restoration', () => { try { await vscode.workspace.fs.delete(tmpProjectRoot, { recursive: true }); } catch { /* ignore */ } }); - test('updates isMissing flags in .codex after restoration', async () => { + test('updates audioAvailability in .codex after restoration', async () => { const { tmpProjectRoot, filesRoot, pointersRoot, wsFolder } = makeWorkspace(); await vscode.workspace.fs.createDirectory(filesRoot); await vscode.workspace.fs.createDirectory(pointersRoot); @@ -87,6 +87,7 @@ suite('Audio Attachments Restoration', () => { [audioId.replace('.webm', '')]: { type: 'audio', url: path.posix.join('.project/attachments/files', bookFolder, audioId), + audioAvailability: 'missing', isMissing: true, }, }, @@ -109,18 +110,18 @@ suite('Audio Attachments Restoration', () => { const dstStat = await vscode.workspace.fs.stat(dstFile); assert.ok(dstStat.size >= 3); - // Codex should have isMissing=false after update and updatedAt bumped + // Codex should have audioAvailability != "missing" after update and updatedAt bumped const updated = new TextDecoder().decode(await vscode.workspace.fs.readFile(codexFile)); const parsed = JSON.parse(updated); const att = parsed.cells[0]?.metadata?.attachments?.[audioId.replace('.webm', '')]; - assert.strictEqual(att?.isMissing, false, 'Attachment should be marked not missing after pointer restoration'); + assert.notStrictEqual(att?.audioAvailability, 'missing', 'Attachment should not be missing after pointer restoration'); assert.ok(typeof att?.updatedAt === 'number' && att.updatedAt > 0, 'Attachment should have updatedAt set'); // Cleanup try { await vscode.workspace.fs.delete(tmpProjectRoot, { recursive: true }); } catch { /* ignore */ } }); - test('keeps isMissing=true when no file exists to restore', async () => { + test('keeps audioAvailability=missing when no file exists to restore', async () => { const { tmpProjectRoot, filesRoot, pointersRoot, wsFolder } = makeWorkspace(); await vscode.workspace.fs.createDirectory(filesRoot); await vscode.workspace.fs.createDirectory(pointersRoot); @@ -140,6 +141,7 @@ suite('Audio Attachments Restoration', () => { [audioId.replace('.webm', '')]: { type: 'audio', url: path.posix.join('.project/attachments/files', bookFolder, audioId), + audioAvailability: 'missing', isMissing: true, }, }, @@ -166,11 +168,11 @@ suite('Audio Attachments Restoration', () => { try { await vscode.workspace.fs.stat(dstFile); } catch { pointerExists = false; } assert.strictEqual(pointerExists, false, 'Pointer should remain missing when there is nothing to restore from files/'); - // Codex should still have isMissing=true + // Codex should still have audioAvailability=missing const updated = new TextDecoder().decode(await vscode.workspace.fs.readFile(codexFile)); const parsed = JSON.parse(updated); const att = parsed.cells[0]?.metadata?.attachments?.[audioId.replace('.webm', '')]; - assert.strictEqual(att?.isMissing, true, 'Attachment should remain missing if no file bytes exist'); + assert.strictEqual(att?.audioAvailability, 'missing', 'Attachment should remain missing if no file bytes exist'); // Cleanup try { await vscode.workspace.fs.delete(tmpProjectRoot, { recursive: true }); } catch { /* ignore */ } diff --git a/src/test/suite/codexCellEditorProvider.test.ts b/src/test/suite/codexCellEditorProvider.test.ts index 29a2a5fcf..21cf429b1 100644 --- a/src/test/suite/codexCellEditorProvider.test.ts +++ b/src/test/suite/codexCellEditorProvider.test.ts @@ -2301,6 +2301,7 @@ suite("CodexCellEditorProvider Test Suite", () => { createdAt: Date.now(), updatedAt: Date.now(), isDeleted: false, + audioAvailability: "missing", isMissing: true, createdBy: "test-user", }); @@ -2339,7 +2340,7 @@ suite("CodexCellEditorProvider Test Suite", () => { ); }); - test("revalidateMissingForCell restores pointer, clears isMissing, bumps updatedAt, and posts updates", async function () { + test("revalidateMissingForCell restores pointer, clears audioAvailability missing, bumps updatedAt, and posts updates", async function () { this.timeout(12000); const provider = new CodexCellEditorProvider(context); const document = await provider.openCustomDocument( @@ -2381,6 +2382,7 @@ suite("CodexCellEditorProvider Test Suite", () => { createdAt: initialUpdatedAt, updatedAt: initialUpdatedAt, isDeleted: false, + audioAvailability: "missing", isMissing: true, }); @@ -2420,13 +2422,13 @@ suite("CodexCellEditorProvider Test Suite", () => { } catch { /* retry */ } await new Promise((r) => setTimeout(r, 60)); } - // Do not hard-fail if pointer check races; the isMissing flip below is the contract we require + // Do not hard-fail if pointer check races; the audioAvailability flip below is the contract we require assert.ok(ptrOk || true, "Pointer creation may race; continuing to validate flags and messages"); - // Assert attachment updated: isMissing=false and updatedAt bumped + // Assert attachment updated: audioAvailability not "missing" and updatedAt bumped const after = JSON.parse(document.getText()); const att = after.cells[0].metadata.attachments[audioId]; - assert.strictEqual(att.isMissing, false, "isMissing should be cleared after revalidation"); + assert.notStrictEqual(att.audioAvailability, "missing", "audioAvailability should not be 'missing' after revalidation"); assert.ok(att.updatedAt > initialUpdatedAt, "updatedAt should increase"); // Assert messages were posted: history refresh and availability map diff --git a/src/test/suite/providerMergeResolve.test.ts b/src/test/suite/providerMergeResolve.test.ts index a2b60b98c..181cf7489 100644 --- a/src/test/suite/providerMergeResolve.test.ts +++ b/src/test/suite/providerMergeResolve.test.ts @@ -251,7 +251,7 @@ suite("Provider + Merge Integration - multi-user multi-field edits", () => { await deleteIfExists(localTheirsUri); }); - test("last edit to isMissing wins during merge for audio attachments", async () => { + test("last edit to audioAvailability wins during merge for audio attachments", async () => { const localOursUri = await createTempCodexFile( `merge-audio-missing-ours-${Date.now()}-${Math.random().toString(36).slice(2)}.codex`, JSON.parse(JSON.stringify(codexSubtitleContent)) @@ -277,7 +277,7 @@ suite("Provider + Merge Integration - multi-user multi-field edits", () => { const baseUrl = ".project/attachments/files/BOOK/audio-shared.webm"; const t0 = Date.now(); - // Both users have the same attachment, start with missing=false + // Both users have the same attachment, start with available-local (oursDoc as any).updateCellAttachment(sharedCellId, audioId, { url: baseUrl, type: "audio", @@ -285,7 +285,7 @@ suite("Provider + Merge Integration - multi-user multi-field edits", () => { createdAt: t0, updatedAt: t0, isDeleted: false, - isMissing: false, + audioAvailability: "available-local", }); (theirsDoc as any).updateCellAttachment(sharedCellId, audioId, { url: baseUrl, @@ -294,24 +294,24 @@ suite("Provider + Merge Integration - multi-user multi-field edits", () => { createdAt: t0, updatedAt: t0, isDeleted: false, - isMissing: false, + audioAvailability: "available-local", }); - // Ours flips isMissing to true at t1 + // Ours flips to missing at t1 const t1 = t0 + 50; const oursParsed1 = JSON.parse((oursDoc as any).getText()); const ourCellIdx = (oursParsed1.cells as any[]).findIndex((c: any) => c.metadata?.id === sharedCellId); const ourAtt = oursParsed1.cells[ourCellIdx].metadata.attachments[audioId]; - ourAtt.isMissing = true; + ourAtt.audioAvailability = "missing"; ourAtt.updatedAt = t1; await vscode.workspace.fs.writeFile(localOursUri, Buffer.from(JSON.stringify(oursParsed1, null, 2))); - // Theirs flips isMissing back to false at t2 (later) + // Theirs flips back to available-local at t2 (later) const t2 = t1 + 50; const theirsParsed1 = JSON.parse((theirsDoc as any).getText()); const theirCellIdx = (theirsParsed1.cells as any[]).findIndex((c: any) => c.metadata?.id === sharedCellId); const theirAtt = theirsParsed1.cells[theirCellIdx].metadata.attachments[audioId]; - theirAtt.isMissing = false; + theirAtt.audioAvailability = "available-local"; theirAtt.updatedAt = t2; await vscode.workspace.fs.writeFile(localTheirsUri, Buffer.from(JSON.stringify(theirsParsed1, null, 2))); @@ -319,13 +319,13 @@ suite("Provider + Merge Integration - multi-user multi-field edits", () => { const oursReloaded = await provider.openCustomDocument(localOursUri, { backupId: undefined }, new vscode.CancellationTokenSource().token); const theirsReloaded = await provider.openCustomDocument(localTheirsUri, { backupId: undefined }, new vscode.CancellationTokenSource().token); - // Merge and assert that the later edit (theirs) wins: isMissing should be false + // Merge and assert that the later edit (theirs) wins: audioAvailability should be available-local const merged = await resolveCodexCustomMerge((oursReloaded as any).getText(), (theirsReloaded as any).getText()); const notebook = JSON.parse(merged); const shared = (notebook.cells || []).find((c: any) => c.metadata?.id === sharedCellId)!; const mergedAtt = shared.metadata?.attachments?.[audioId]; assert.ok(mergedAtt, "Merged notebook should contain the shared audio attachment"); - assert.strictEqual(mergedAtt.isMissing, false, "Later edit to isMissing should win during merge"); + assert.strictEqual(mergedAtt.audioAvailability, "available-local", "Later edit to audioAvailability should win during merge"); await deleteIfExists(localOursUri); await deleteIfExists(localTheirsUri); diff --git a/src/utils/audioAttachmentsMigrationUtils.ts b/src/utils/audioAttachmentsMigrationUtils.ts index 5042ec910..9211eca4c 100644 --- a/src/utils/audioAttachmentsMigrationUtils.ts +++ b/src/utils/audioAttachmentsMigrationUtils.ts @@ -1,7 +1,8 @@ import * as vscode from 'vscode'; import * as crypto from 'crypto'; import { toPosixPath, normalizeAttachmentUrl } from './pathUtils'; -import { setMissingFlagOnAttachmentObject } from './audioMissingUtils'; +import { setAttachmentAvailability } from './audioMissingUtils'; +import { determineAttachmentAvailability } from './audioAvailabilityUtils'; const DEBUG_MODE = false; const debug = (message: string) => { @@ -423,12 +424,11 @@ export class AudioAttachmentsMigrator { } /** - * Scans all .codex documents and sets/unsets attachment.isMissing based on pointer existence. - * If a referenced file is not present in pointers/, sets isMissing=true. If present, sets isMissing=false. + * Scans all .codex documents and sets audioAvailability based on filesystem checks. + * Also migrates legacy isMissing fields to audioAvailability. */ private async updateMissingFlagsForCodexDocuments(): Promise { try { - // Find all codex documents const codexPattern = new vscode.RelativePattern( this.workspaceFolder.uri, "files/target/**/*.codex" @@ -452,31 +452,15 @@ export class AudioAttachmentsMigrator { if (!attachments || typeof attachments !== 'object') continue; for (const [attId, attVal] of Object.entries(attachments) as [string, any][]) { - // Only process object-style attachments (skip legacy string forms) if (!attVal || typeof attVal !== 'object') continue; const url: string | undefined = attVal.url; if (!url || typeof url !== 'string') continue; - // Normalize URL to files/ path then derive pointers/ path - const normalizedUrl = normalizeAttachmentUrl(url) || url; - const posixUrl = toPosixPath(normalizedUrl); - const pointerPosix = posixUrl.includes('/attachments/files/') - ? posixUrl.replace('/attachments/files/', '/attachments/pointers/') - : posixUrl; - - const pointerSegments = pointerPosix.split('/').filter(Boolean); - const pointerUri = vscode.Uri.joinPath(this.workspaceFolder.uri, ...pointerSegments); - - let existsInPointers = false; - try { - await vscode.workspace.fs.stat(pointerUri); - existsInPointers = true; - } catch { - existsInPointers = false; - } - - const desiredMissing = !existsInPointers; - if (setMissingFlagOnAttachmentObject(attVal, desiredMissing)) { + const availability = await determineAttachmentAvailability( + this.workspaceFolder, + url + ); + if (setAttachmentAvailability(attVal, availability)) { changed = true; } } @@ -485,14 +469,14 @@ export class AudioAttachmentsMigrator { if (changed) { const updated = JSON.stringify(data, null, 2); await vscode.workspace.fs.writeFile(codexUri, new TextEncoder().encode(updated)); - debug(`Updated missing flags for attachments in ${codexUri.fsPath}`); + debug(`Updated audioAvailability for attachments in ${codexUri.fsPath}`); } } catch (err) { - console.error(`[AudioAttachmentsMigration] Failed to update missing flags for ${codexUri.fsPath}:`, err); + console.error(`[AudioAttachmentsMigration] Failed to update availability for ${codexUri.fsPath}:`, err); } } } catch (error) { - console.error('[AudioAttachmentsMigration] Error while updating missing flags in codex documents:', error); + console.error('[AudioAttachmentsMigration] Error while updating availability in codex documents:', error); } } diff --git a/src/utils/audioAvailabilityUtils.ts b/src/utils/audioAvailabilityUtils.ts index f3eae15ca..c27172a99 100644 --- a/src/utils/audioAvailabilityUtils.ts +++ b/src/utils/audioAvailabilityUtils.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import path from "path"; import { toPosixPath } from "./pathUtils"; +import type { AttachmentAvailability } from "../../types"; export type AudioAvailabilityState = | "available-local" @@ -9,39 +10,69 @@ export type AudioAvailabilityState = | "deletedOnly" | "none"; -interface AttachmentLike { +export interface AttachmentLike { url?: string; type?: string; isDeleted?: boolean; + audioAvailability?: AttachmentAvailability; + /** @deprecated Use audioAvailability instead */ isMissing?: boolean; } /** - * Check the on-disk availability of a single audio attachment. + * Read the persisted availability state of a single audio attachment. + * Pure metadata read — no filesystem I/O. * * Resolution order: * 1. isDeleted flag -> "deletedOnly" - * 2. isMissing flag (set when editor opens) -> "missing" - * 3. stat the files/ path; if it exists, inspect whether it's an LFS pointer - * 4. stat the pointers/ path as a fallback -> "available-pointer" - * 5. Otherwise -> "missing" + * 2. audioAvailability field (set at write time) -> that value + * 3. Legacy isMissing fallback -> "missing" if true, "available-local" if false + * 4. No field set and no URL -> "missing" + * 5. No field set but URL present -> "available-local" (optimistic default) */ -export async function checkAttachmentAvailability( +export function checkAttachmentAvailability( attachment: AttachmentLike, - workspaceFolder: vscode.WorkspaceFolder -): Promise> { +): Exclude { if (attachment.isDeleted) { return "deletedOnly"; } - if (attachment.isMissing) { + + if (attachment.audioAvailability) { + return attachment.audioAvailability; + } + + // Legacy fallback: isMissing boolean + if (attachment.isMissing === true) { return "missing"; } + if (attachment.isMissing === false) { + return "available-local"; + } + // No availability metadata at all const url = String(attachment.url || ""); if (!url) { return "missing"; } + return "available-local"; +} + +/** + * Determine the on-disk availability of an attachment by performing filesystem + * stat calls and LFS pointer detection. Intended for **write-time** use only — + * call this when recording, importing, migrating, or revalidating, then persist + * the result as `audioAvailability` on the attachment. + */ +export async function determineAttachmentAvailability( + workspaceFolder: vscode.WorkspaceFolder, + attachmentUrl: string +): Promise> { + const url = String(attachmentUrl || ""); + if (!url) { + return "missing"; + } + const filesRel = url.startsWith(".project/") ? url : url.replace(/^\.?\/?/, ""); const filesAbs = path.join(workspaceFolder.uri.fsPath, filesRel); @@ -78,15 +109,14 @@ export async function checkAttachmentAvailability( * Compute the overall audio availability state for a cell by inspecting * all of its audio attachments and the selectedAudioId. * + * Pure metadata read — no filesystem I/O. + * * Priority: available-local > available-pointer > missing > deletedOnly > none - * This means if *any* attachment is locally available the cell reports as such, - * even if the user's explicit selection points to a missing file. */ -export async function computeCellAudioState( +export function computeCellAudioState( attachments: Record | undefined, selectedAudioId: string | undefined, - workspaceFolder: vscode.WorkspaceFolder -): Promise { +): AudioAvailabilityState { if (!attachments || Object.keys(attachments).length === 0) { return "none"; } @@ -99,7 +129,7 @@ export async function computeCellAudioState( for (const att of Object.values(attachments)) { if (!att || att.type !== "audio") continue; - const state = await checkAttachmentAvailability(att, workspaceFolder); + const state = checkAttachmentAvailability(att); switch (state) { case "available-local": hasAvailable = true; @@ -118,7 +148,8 @@ export async function computeCellAudioState( const selectedAtt = selectedAudioId ? attachments[selectedAudioId] : undefined; const selectedIsMissing = - selectedAtt?.type === "audio" && selectedAtt?.isMissing === true; + selectedAtt?.type === "audio" && + (selectedAtt?.audioAvailability === "missing" || selectedAtt?.isMissing === true); if (hasAvailable) return "available-local"; if (hasAvailablePointer) return "available-pointer"; @@ -162,27 +193,24 @@ export async function applyFrontierVersionGate( export async function computeCellAudioStateWithVersionGate( attachments: Record | undefined, selectedAudioId: string | undefined, - workspaceFolder: vscode.WorkspaceFolder ): Promise { - const state = await computeCellAudioState(attachments, selectedAudioId, workspaceFolder); + const state = computeCellAudioState(attachments, selectedAudioId); return applyFrontierVersionGate(state); } /** - * Check whether a cell's selected audio is missing from disk. - * Lighter-weight helper for progress tracking where we only need a boolean - * (e.g. the navigation sidebar). + * Check whether a cell's selected audio is missing. + * Pure metadata read — no filesystem I/O. */ -export async function isSelectedAudioMissing( +export function isSelectedAudioMissing( attachments: Record | undefined, selectedAudioId: string | undefined, - workspaceFolder: vscode.WorkspaceFolder -): Promise { +): boolean { if (!selectedAudioId || !attachments) return false; const att = attachments[selectedAudioId]; if (!att || att.type !== "audio" || att.isDeleted) return false; - const state = await checkAttachmentAvailability(att, workspaceFolder); + const state = checkAttachmentAvailability(att); return state === "missing"; } diff --git a/src/utils/audioMissingUtils.ts b/src/utils/audioMissingUtils.ts index f7c01cc71..db4686361 100644 --- a/src/utils/audioMissingUtils.ts +++ b/src/utils/audioMissingUtils.ts @@ -2,6 +2,8 @@ import * as vscode from "vscode"; import path from "path"; import { toPosixPath, normalizeAttachmentUrl } from "./pathUtils"; import type { CodexCellDocument } from "../providers/codexCellEditorProvider/codexDocument"; +import type { AttachmentAvailability } from "../../types"; +import { determineAttachmentAvailability } from "./audioAvailabilityUtils"; /** * Checks whether a pointer file exists for a given attachment URL. @@ -48,7 +50,6 @@ async function ensurePointerFromFiles( ...pointersPosix.split("/").filter(Boolean) ); - // Check files exists let fileExists = false; try { await vscode.workspace.fs.stat(fileUri); @@ -58,7 +59,6 @@ async function ensurePointerFromFiles( } if (!fileExists) return false; - // If pointer already exists, done try { await vscode.workspace.fs.stat(pointerUri); return true; @@ -66,7 +66,6 @@ async function ensurePointerFromFiles( try { const bytes = await vscode.workspace.fs.readFile(fileUri); - // Ensure parent directory exists const pointerDir = vscode.Uri.file(path.posix.dirname(pointerUri.fsPath)); try { await vscode.workspace.fs.createDirectory(pointerDir); } catch { /* ignore */ } await vscode.workspace.fs.writeFile(pointerUri, bytes); @@ -77,7 +76,8 @@ async function ensurePointerFromFiles( } /** - * Revalidates and updates isMissing flags for all audio attachments on a specific cell. + * Revalidates and updates audioAvailability for all audio attachments on a specific cell. + * Performs filesystem checks at revalidation time and persists the result. * Returns true if any flag changed. */ export async function revalidateCellMissingFlags( @@ -103,30 +103,35 @@ export async function revalidateCellMissingFlags( if (!existsInPointers) { existsInPointers = await ensurePointerFromFiles(workspaceFolder, url); } - const desiredMissing = !existsInPointers; - // Use shared util to set flag and bump updatedAt only when changed + + const availability = existsInPointers + ? await determineAttachmentAvailability(workspaceFolder, url) + : "missing" as AttachmentAvailability; + const updated = { ...attVal }; - if (setMissingFlagOnAttachmentObject(updated, desiredMissing)) { + if (setAttachmentAvailability(updated, availability)) { document.updateCellAttachment(cellId, attId, updated); changed = true; } } return changed; } catch (err) { - console.error("Failed to revalidate missing flags for cell", { cellId, err }); + console.error("Failed to revalidate availability for cell", { cellId, err }); return false; } } /** - * Sets the isMissing flag on an attachment object and updates updatedAt when changed. + * Sets the audioAvailability field on an attachment object and updates updatedAt when changed. + * Also sets the deprecated isMissing field for backward compatibility. * Returns true if the object was modified. */ -export function setMissingFlagOnAttachmentObject(att: any, desiredMissing: boolean): boolean { +export function setAttachmentAvailability(att: any, availability: AttachmentAvailability): boolean { try { - const current = att?.isMissing ?? false; - if (current !== desiredMissing) { - att.isMissing = desiredMissing; + const current = att?.audioAvailability; + if (current !== availability) { + att.audioAvailability = availability; + att.isMissing = availability === "missing"; att.updatedAt = Date.now(); return true; } @@ -136,4 +141,11 @@ export function setMissingFlagOnAttachmentObject(att: any, desiredMissing: boole } } - +/** + * @deprecated Use setAttachmentAvailability instead. + * Kept for backward compatibility during migration window. + */ +export function setMissingFlagOnAttachmentObject(att: any, desiredMissing: boolean): boolean { + const availability: AttachmentAvailability = desiredMissing ? "missing" : "available-local"; + return setAttachmentAvailability(att, availability); +} diff --git a/types/index.d.ts b/types/index.d.ts index e37b1076e..4568e1762 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -5,6 +5,8 @@ import { CodexCell } from "src/utils/codexNotebookUtils"; import { SavedBacktranslation } from "../smartEdits/smartBacktranslation"; import { CodexCellTypes } from "./enums"; +type AttachmentAvailability = "available-local" | "available-pointer" | "missing"; + interface ChatMessage { role: "system" | "user" | "assistant" | "context"; content: string; @@ -949,6 +951,8 @@ type CustomCellMetaData = BaseCustomCellMetaData & { createdAt: number; updatedAt: number; isDeleted: boolean; + audioAvailability?: AttachmentAvailability; + /** @deprecated Use audioAvailability instead */ isMissing?: boolean; validatedBy?: ValidationEntry[]; createdBy?: string; @@ -1142,7 +1146,7 @@ interface QuillCellContent { merged?: boolean; deleted?: boolean; data?: { [key: string]: any; footnotes?: Footnote[]; }; - attachments?: { [attachmentId: string]: { type: string; isDeleted?: boolean; isMissing?: boolean; url?: string; validatedBy?: ValidationEntry[]; }; }; + attachments?: { [attachmentId: string]: { type: string; isDeleted?: boolean; audioAvailability?: AttachmentAvailability; /** @deprecated Use audioAvailability instead */ isMissing?: boolean; url?: string; validatedBy?: ValidationEntry[]; }; }; metadata?: { selectedAudioId?: string; selectionTimestamp?: number; @@ -2430,6 +2434,8 @@ type EditorReceiveMessages = createdAt: number; updatedAt: number; isDeleted: boolean; + audioAvailability?: AttachmentAvailability; + /** @deprecated Use audioAvailability instead */ isMissing?: boolean; validatedBy?: ValidationEntry[]; }; diff --git a/webviews/codex-webviews/src/CodexCellEditor/AudioHistoryViewer.tsx b/webviews/codex-webviews/src/CodexCellEditor/AudioHistoryViewer.tsx index f12421b89..c3bb7c044 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/AudioHistoryViewer.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/AudioHistoryViewer.tsx @@ -28,7 +28,9 @@ interface AudioHistoryEntry { createdAt: number; updatedAt: number; isDeleted: boolean; - isMissing?: boolean; // Added for missing audio + audioAvailability?: "available-local" | "available-pointer" | "missing"; + /** @deprecated Use audioAvailability instead */ + isMissing?: boolean; validatedBy?: ValidationEntry[]; }; } @@ -120,7 +122,7 @@ export const AudioHistoryViewer: React.FC = ({ // Pre-mark entries that are known missing try { const missingIds = (message.content.audioHistory as any[]) - .filter((e: any) => e?.attachment?.isMissing === true) + .filter((e: any) => e?.attachment?.audioAvailability === "missing" || (e?.attachment?.audioAvailability === undefined && e?.attachment?.isMissing === true)) .map((e: any) => e.attachmentId); if (missingIds.length > 0) { setErrorIds((prev) => { @@ -563,7 +565,8 @@ export const AudioHistoryViewer: React.FC = ({ const isLoading = delayedLoadingIds.has(entry.attachmentId); const hasError = errorIds.has(entry.attachmentId) || - entry.attachment?.isMissing === true; + entry.attachment?.audioAvailability === "missing" || + (entry.attachment?.audioAvailability === undefined && entry.attachment?.isMissing === true); const isValidating = validatingIds.has(entry.attachmentId); // Compute validation status from attachment.validatedBy @@ -682,7 +685,7 @@ export const AudioHistoryViewer: React.FC = ({ DELETED )} - {entry.attachment.isMissing && + {(entry.attachment.audioAvailability === "missing" || (entry.attachment.audioAvailability === undefined && entry.attachment.isMissing)) && !entry.attachment.isDeleted && ( + att.audioAvailability === "missing" || (att.audioAvailability === undefined && att.isMissing === true); + const deriveAudioAvailability = (unit: QuillCellContent): AudioAvailability => { const atts = (unit?.attachments || {}) as Record; let hasAvailable = false; @@ -21,18 +24,16 @@ const deriveAudioAvailability = (unit: QuillCellContent): AudioAvailability => { const att = atts[key]; if (att?.type === "audio") { if (att.isDeleted) hasDeleted = true; - else if (att.isMissing) hasMissing = true; + else if (isAttMissing(att)) hasMissing = true; else hasAvailable = true; } } - // Prefer showing available when a valid file exists, - // even if the user's explicit selection points to a missing file. if (hasAvailable) return "available"; const selectedId = unit?.metadata?.selectedAudioId; const selectedAtt = selectedId ? atts[selectedId] : undefined; - if (selectedAtt?.type === "audio" && selectedAtt?.isMissing === true) { + if (selectedAtt?.type === "audio" && isAttMissing(selectedAtt)) { return "missing"; } @@ -40,7 +41,7 @@ const deriveAudioAvailability = (unit: QuillCellContent): AudioAvailability => { const nonDeleted = Object.entries(atts) .filter(([, att]) => att?.type === "audio" && !att.isDeleted) .sort(([, a], [, b]) => (b.updatedAt || 0) - (a.updatedAt || 0)); - if (nonDeleted.length > 0 && nonDeleted[0][1].isMissing) { + if (nonDeleted.length > 0 && isAttMissing(nonDeleted[0][1])) { return "missing"; } } diff --git a/webviews/codex-webviews/src/NewSourceUploader/importers/audio/cellMetadata.ts b/webviews/codex-webviews/src/NewSourceUploader/importers/audio/cellMetadata.ts index bab4bfa8c..74cc4e195 100644 --- a/webviews/codex-webviews/src/NewSourceUploader/importers/audio/cellMetadata.ts +++ b/webviews/codex-webviews/src/NewSourceUploader/importers/audio/cellMetadata.ts @@ -68,6 +68,7 @@ export function createAudioCellMetadata(params: AudioCellMetadataParams): { meta createdAt: Date.now(), updatedAt: Date.now(), isDeleted: false, + audioAvailability: "available-local" as const, }, }, selectedAudioId: params.attachmentId, diff --git a/webviews/codex-webviews/src/NewSourceUploader/importers/audio2/AudioImporter2Form.tsx b/webviews/codex-webviews/src/NewSourceUploader/importers/audio2/AudioImporter2Form.tsx index 1ebbe0c8b..c2de7296f 100644 --- a/webviews/codex-webviews/src/NewSourceUploader/importers/audio2/AudioImporter2Form.tsx +++ b/webviews/codex-webviews/src/NewSourceUploader/importers/audio2/AudioImporter2Form.tsx @@ -639,6 +639,7 @@ export const AudioImporterForm: React.FC = ({ createdAt: Date.now(), updatedAt: Date.now(), isDeleted: false, + audioAvailability: "available-local" as const, }, }, selectedAudioId: attachmentId, diff --git a/webviews/codex-webviews/src/NewSourceUploader/importers/bibleSpredSheet/SpreadsheetImporterForm.tsx b/webviews/codex-webviews/src/NewSourceUploader/importers/bibleSpredSheet/SpreadsheetImporterForm.tsx index ffb32e524..fb4f0455e 100644 --- a/webviews/codex-webviews/src/NewSourceUploader/importers/bibleSpredSheet/SpreadsheetImporterForm.tsx +++ b/webviews/codex-webviews/src/NewSourceUploader/importers/bibleSpredSheet/SpreadsheetImporterForm.tsx @@ -721,6 +721,7 @@ export const SpreadsheetImporterForm: React.FC = (props) createdAt: Date.now(), updatedAt: Date.now(), isDeleted: false, + audioAvailability: "available-local" as const, startTime: 0, endTime: Number.NaN, }, @@ -769,6 +770,7 @@ export const SpreadsheetImporterForm: React.FC = (props) createdAt: Date.now(), updatedAt: Date.now(), isDeleted: false, + audioAvailability: "available-local" as const, startTime: 0, endTime: Number.NaN, }, From e306a32a1dd9fb509ccefac4adf081caedddce8b Mon Sep 17 00:00:00 2001 From: LeviXIII Date: Thu, 5 Mar 2026 13:39:01 -0500 Subject: [PATCH 04/12] - Added a new function to revalidate audio attachment flags across all cells in a document, ensuring accurate audio state management. - Updated CodexCellEditorProvider to utilize the new revalidation function, improving the accuracy of audio availability on initial load and during refresh. - Enhanced error handling and metadata persistence during the revalidation process. --- .../codexCellEditorProvider.ts | 79 +++++++++++++++---- src/utils/audioMissingUtils.ts | 37 +++++++++ 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts index 2b150f28a..71cd0496c 100755 --- a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts @@ -33,6 +33,7 @@ import path from "path"; import * as fs from "fs"; import { getAuthApi } from "@/extension"; import { computeCellAudioStateWithVersionGate, type AudioAvailabilityState } from "../../utils/audioAvailabilityUtils"; +import { revalidateDocumentAudioFlags } from "../../utils/audioMissingUtils"; import { getCachedChapter as getCachedChapterUtil, updateCachedChapter as updateCachedChapterUtil, @@ -904,24 +905,37 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider = {}; - for (const cell of notebookData.cells as any[]) { - const cellId = cell?.metadata?.id; - if (!cellId) continue; - availability[cellId] = await computeCellAudioStateWithVersionGate( - cell?.metadata?.attachments, - cell?.metadata?.selectedAudioId, - ); + if (ws) { + const flagsChanged = await revalidateDocumentAudioFlags(document, ws); + + // Re-read the document after revalidation to get fresh metadata + const freshData = this.getDocumentAsJson(document); + if (Array.isArray(freshData?.cells)) { + const availability: Record = {}; + for (const cell of freshData.cells as any[]) { + const cellId = cell?.metadata?.id; + if (!cellId) continue; + availability[cellId] = await computeCellAudioStateWithVersionGate( + cell?.metadata?.attachments, + cell?.metadata?.selectedAudioId, + ); + } + if (Object.keys(availability).length > 0) { + this.postMessageToWebview(webviewPanel, { + type: "providerSendsAudioAttachments", + attachments: availability as any, + }); + } } - if (Object.keys(availability).length > 0) { - this.postMessageToWebview(webviewPanel, { - type: "providerSendsAudioAttachments", - attachments: availability as any, - }); + + // If any flags changed, refresh milestone progress so MilestoneAccordion + // shows corrected missing-audio icons for all milestones. + if (flagsChanged) { + await sendMilestoneRefreshToWebview(document, webviewPanel, this); } } } catch (e) { @@ -2433,7 +2447,40 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider { + try { + const ws = vscode.workspace.getWorkspaceFolder(document.uri); + if (ws) { + const flagsChanged = await revalidateDocumentAudioFlags(document, ws); + + const freshData = this.getDocumentAsJson(document); + if (Array.isArray(freshData?.cells)) { + const availability: Record = {}; + for (const cell of freshData.cells as any[]) { + const cellId = cell?.metadata?.id; + if (!cellId) continue; + availability[cellId] = await computeCellAudioStateWithVersionGate( + cell?.metadata?.attachments, + cell?.metadata?.selectedAudioId, + ); + } + if (Object.keys(availability).length > 0) { + safePostMessageToPanel(webviewPanel, { + type: "providerSendsAudioAttachments", + attachments: availability as any, + }); + } + } + + if (flagsChanged) { + await sendMilestoneRefreshToWebview(document, webviewPanel, this); + } + } + } catch (e) { + debug("Failed to revalidate audio availability during refresh", e); + } + })(); if (videoUrl) { this.postMessageToWebview(webviewPanel, { diff --git a/src/utils/audioMissingUtils.ts b/src/utils/audioMissingUtils.ts index db4686361..207d53eef 100644 --- a/src/utils/audioMissingUtils.ts +++ b/src/utils/audioMissingUtils.ts @@ -141,6 +141,43 @@ export function setAttachmentAvailability(att: any, availability: AttachmentAvai } } +/** + * Revalidates audioAvailability for ALL audio attachments across every cell in a document. + * Performs filesystem checks and persists updated metadata. + * Returns true if any flag changed. + */ +export async function revalidateDocumentAudioFlags( + document: CodexCellDocument, + workspaceFolder: vscode.WorkspaceFolder +): Promise { + try { + const cells = (document as any)._documentData?.cells || []; + let anyChanged = false; + + for (const cell of cells) { + const cellId = cell?.metadata?.id; + if (!cellId || !cell?.metadata?.attachments) continue; + + const hasAudio = Object.values(cell.metadata.attachments).some( + (att: any) => att?.type === "audio" + ); + if (!hasAudio) continue; + + const changed = await revalidateCellMissingFlags(document, workspaceFolder, cellId); + if (changed) anyChanged = true; + } + + if (anyChanged) { + await document.save(new vscode.CancellationTokenSource().token); + } + + return anyChanged; + } catch (err) { + console.error("Failed to revalidate document audio flags", err); + return false; + } +} + /** * @deprecated Use setAttachmentAvailability instead. * Kept for backward compatibility during migration window. From fd6d599de9cee978546798146447de585d6674b0 Mon Sep 17 00:00:00 2001 From: LeviXIII Date: Wed, 11 Mar 2026 13:44:50 -0400 Subject: [PATCH 05/12] Refactor audio availability handling to compute from disk without document mutation - Replaced revalidation logic with new methods to compute audio availability for cells and attachments directly from the filesystem. - Introduced `computeCellAudioAvailabilityFromDisk` and `computeCellIdsAudioAvailability` functions to assess audio state without altering the document. - Updated message handling to send computed audio availability and history to the webview, ensuring accurate UI updates. - Enhanced tests to reflect changes in availability computation and document immutability. --- .../codexCellEditorMessagehandling.ts | 78 +++---- .../codexCellEditorProvider.ts | 174 ++++++++++----- .../suite/codexCellEditorProvider.test.ts | 8 +- src/utils/audioMissingUtils.ts | 205 +++++++++++++----- 4 files changed, 312 insertions(+), 153 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 4a99d2e34..7d9af0f48 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -20,7 +20,7 @@ import { getAuthApi } from "@/extension"; import { getCommentsFromFile } from "../../utils/fileUtils"; import { getUnresolvedCommentsCountForCell } from "../../utils/commentsUtils"; import { toPosixPath } from "../../utils/pathUtils"; -import { revalidateCellMissingFlags } from "../../utils/audioMissingUtils"; +import { computeCellAudioAvailabilityFromDisk, computeCellIdsAudioAvailability } from "../../utils/audioMissingUtils"; import { computeCellAudioStateWithVersionGate, type AudioAvailabilityState } from "../../utils/audioAvailabilityUtils"; import { mergeAudioFiles } from "../../utils/audioMerger"; import { getAttachmentDocumentSegmentFromUri } from "../../utils/attachmentFolderUtils"; @@ -3111,46 +3111,30 @@ const messageHandlers: Record Promise c?.metadata?.id === cellId); - if (cell) { - const state = await computeCellAudioStateWithVersionGate( - cell?.metadata?.attachments, - cell?.metadata?.selectedAudioId, - ); - safePostMessageToPanel(webviewPanel, { - type: "providerSendsAudioAttachments", - attachments: { [cellId]: state }, - }); - } - } catch { /* ignore */ } - } + // Send current history (read-only) + const audioHistory = document.getAttachmentHistory(cellId, "audio") || []; + const currentAttachment = document.getCurrentAttachment(cellId, "audio"); + const explicitSelection = document.getExplicitAudioSelection(cellId); + provider.postMessageToWebview(webviewPanel, { + type: "audioHistoryReceived", + content: { + cellId, + audioHistory, + currentAttachmentId: currentAttachment?.attachmentId ?? null, + hasExplicitSelection: explicitSelection !== null, + }, + }); } catch (err) { - console.error("Failed to revalidate missing for cell", { cellId, err }); + console.error("Failed to compute availability for cell", { cellId, err }); } }, @@ -3211,6 +3195,26 @@ const messageHandlers: Record Promise c.cellMarkers?.[0]) + .filter(Boolean) as string[]; + if (pageCellIds.length > 0) { + const availability = await computeCellIdsAudioAvailability( + document, ws, pageCellIds, + ); + if (Object.keys(availability).length > 0) { + safePostMessageToPanel(webviewPanel, { + type: "providerSendsAudioAttachments", + attachments: availability as any, + }); + } + } + } + debug(`Sent cells for milestone ${milestoneIndex}, subsection ${subsectionIndex}: ${processedCells.length} cells`); } catch (error) { console.error("Error fetching cells for milestone:", error); diff --git a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts index 71cd0496c..67f9b4f16 100755 --- a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts @@ -33,7 +33,7 @@ import path from "path"; import * as fs from "fs"; import { getAuthApi } from "@/extension"; import { computeCellAudioStateWithVersionGate, type AudioAvailabilityState } from "../../utils/audioAvailabilityUtils"; -import { revalidateDocumentAudioFlags } from "../../utils/audioMissingUtils"; +import { computeCellIdsAudioAvailability, computeDocumentAudioAvailability } from "../../utils/audioMissingUtils"; import { getCachedChapter as getCachedChapterUtil, updateCachedChapter as updateCachedChapterUtil, @@ -85,6 +85,50 @@ function extractChapterNumberFromMilestoneValue(value: string | undefined): numb return null; } +/** + * Builds a corrected milestone progress map with accurate cellsWithMissingAudio counts + * derived from filesystem-based audio availability, without mutating the document. + */ +function buildCorrectedMilestoneProgress( + originalProgress: Record, + milestoneIndex: MilestoneIndex, + documentCells: any[], + availability: Record, +): typeof originalProgress { + const corrected: typeof originalProgress = {}; + for (const [key, value] of Object.entries(originalProgress)) { + corrected[Number(key)] = { ...value }; + } + + for (let i = 0; i < milestoneIndex.milestones.length; i++) { + const milestone = milestoneIndex.milestones[i]; + const startIdx = milestone.cellIndex; + const endIdx = startIdx + milestone.cellCount; + + let missingCount = 0; + for (let j = startIdx; j < endIdx && j < documentCells.length; j++) { + const cellId = documentCells[j]?.metadata?.id; + if (cellId && availability[cellId] === "missing") { + missingCount++; + } + } + + const milestoneKey = i + 1; // milestoneProgress uses 1-based keys + if (corrected[milestoneKey]) { + corrected[milestoneKey].cellsWithMissingAudio = missingCount; + } + } + + return corrected; +} + // StateStore interface matching what's provided by initializeStateStore interface StateStore { storeListener: ( @@ -887,6 +931,49 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider c.cellMarkers?.[0]) + .filter(Boolean) as string[]; + + if (visibleCellIds.length > 0) { + const visibleAvailability = await computeCellIdsAudioAvailability( + document, ws, visibleCellIds, + ); + if (Object.keys(visibleAvailability).length > 0) { + this.postMessageToWebview(webviewPanel, { + type: "providerSendsAudioAttachments", + attachments: visibleAvailability as any, + }); + } + } + + const allAvailability = await computeDocumentAudioAvailability(document, ws); + if (Object.keys(allAvailability).length > 0) { + this.postMessageToWebview(webviewPanel, { + type: "providerSendsAudioAttachments", + attachments: allAvailability as any, + }); + + const documentCells = (document as any)._documentData?.cells || []; + const correctedProgress = buildCorrectedMilestoneProgress( + milestoneProgress, milestoneIndex, documentCells, allAvailability, + ); + this.postMessageToWebview(webviewPanel, { + type: "milestoneProgressUpdate", + milestoneProgress: correctedProgress, + }); + } + } + } catch (e) { + debug("Failed to compute refined audio availability", e); + } } // Also send updated metadata plus the autoDownloadAudioOnOpen flag for the project @@ -904,43 +991,6 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider = {}; - for (const cell of freshData.cells as any[]) { - const cellId = cell?.metadata?.id; - if (!cellId) continue; - availability[cellId] = await computeCellAudioStateWithVersionGate( - cell?.metadata?.attachments, - cell?.metadata?.selectedAudioId, - ); - } - if (Object.keys(availability).length > 0) { - this.postMessageToWebview(webviewPanel, { - type: "providerSendsAudioAttachments", - attachments: availability as any, - }); - } - } - - // If any flags changed, refresh milestone progress so MilestoneAccordion - // shows corrected missing-audio icons for all milestones. - if (flagsChanged) { - await sendMilestoneRefreshToWebview(document, webviewPanel, this); - } - } - } catch (e) { - debug("Failed to compute refined audio availability", e); - } }; // Function to update audio attachments for a specific cell when files change externally @@ -2447,38 +2497,48 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider { try { const ws = vscode.workspace.getWorkspaceFolder(document.uri); if (ws) { - const flagsChanged = await revalidateDocumentAudioFlags(document, ws); - - const freshData = this.getDocumentAsJson(document); - if (Array.isArray(freshData?.cells)) { - const availability: Record = {}; - for (const cell of freshData.cells as any[]) { - const cellId = cell?.metadata?.id; - if (!cellId) continue; - availability[cellId] = await computeCellAudioStateWithVersionGate( - cell?.metadata?.attachments, - cell?.metadata?.selectedAudioId, - ); - } - if (Object.keys(availability).length > 0) { + const visibleCellIds = initialCells + .map((c: any) => c.cellMarkers?.[0]) + .filter(Boolean) as string[]; + + if (visibleCellIds.length > 0) { + const visibleAvailability = await computeCellIdsAudioAvailability( + document, ws, visibleCellIds, + ); + if (Object.keys(visibleAvailability).length > 0) { safePostMessageToPanel(webviewPanel, { type: "providerSendsAudioAttachments", - attachments: availability as any, + attachments: visibleAvailability as any, }); } } - if (flagsChanged) { - await sendMilestoneRefreshToWebview(document, webviewPanel, this); + const allAvailability = await computeDocumentAudioAvailability(document, ws); + if (Object.keys(allAvailability).length > 0) { + safePostMessageToPanel(webviewPanel, { + type: "providerSendsAudioAttachments", + attachments: allAvailability as any, + }); + + const documentCells = (document as any)._documentData?.cells || []; + const correctedProgress = buildCorrectedMilestoneProgress( + milestoneProgress, milestoneIndex, documentCells, allAvailability, + ); + safePostMessageToPanel(webviewPanel, { + type: "milestoneProgressUpdate", + milestoneProgress: correctedProgress, + }); } } } catch (e) { - debug("Failed to revalidate audio availability during refresh", e); + debug("Failed to compute audio availability during refresh", e); } })(); diff --git a/src/test/suite/codexCellEditorProvider.test.ts b/src/test/suite/codexCellEditorProvider.test.ts index 3ec5b39c1..7cd911fb6 100644 --- a/src/test/suite/codexCellEditorProvider.test.ts +++ b/src/test/suite/codexCellEditorProvider.test.ts @@ -2340,7 +2340,7 @@ suite("CodexCellEditorProvider Test Suite", () => { ); }); - test("revalidateMissingForCell restores pointer, clears audioAvailability missing, bumps updatedAt, and posts updates", async function () { + test("revalidateMissingForCell restores pointer, computes availability from disk, and posts updates without mutating document", async function () { this.timeout(12000); const provider = new CodexCellEditorProvider(context); const document = await provider.openCustomDocument( @@ -2425,11 +2425,11 @@ suite("CodexCellEditorProvider Test Suite", () => { // Do not hard-fail if pointer check races; the audioAvailability flip below is the contract we require assert.ok(ptrOk || true, "Pointer creation may race; continuing to validate flags and messages"); - // Assert attachment updated: audioAvailability not "missing" and updatedAt bumped + // Document should NOT be mutated — revalidation is now compute-only const after = JSON.parse(document.getText()); const att = after.cells[0].metadata.attachments[audioId]; - assert.notStrictEqual(att.audioAvailability, "missing", "audioAvailability should not be 'missing' after revalidation"); - assert.ok(att.updatedAt > initialUpdatedAt, "updatedAt should increase"); + assert.strictEqual(att.audioAvailability, "missing", "audioAvailability should remain unchanged on the document (compute-only)"); + assert.strictEqual(att.updatedAt, initialUpdatedAt, "updatedAt should not change (compute-only)"); // Assert messages were posted: history refresh and availability map const historyMsg = posted.find((m) => m?.type === "audioHistoryReceived"); diff --git a/src/utils/audioMissingUtils.ts b/src/utils/audioMissingUtils.ts index 207d53eef..16eea7d86 100644 --- a/src/utils/audioMissingUtils.ts +++ b/src/utils/audioMissingUtils.ts @@ -3,7 +3,11 @@ import path from "path"; import { toPosixPath, normalizeAttachmentUrl } from "./pathUtils"; import type { CodexCellDocument } from "../providers/codexCellEditorProvider/codexDocument"; import type { AttachmentAvailability } from "../../types"; -import { determineAttachmentAvailability } from "./audioAvailabilityUtils"; +import { + determineAttachmentAvailability, + applyFrontierVersionGate, + type AudioAvailabilityState, +} from "./audioAvailabilityUtils"; /** * Checks whether a pointer file exists for a given attachment URL. @@ -76,83 +80,158 @@ async function ensurePointerFromFiles( } /** - * Revalidates and updates audioAvailability for all audio attachments on a specific cell. - * Performs filesystem checks at revalidation time and persists the result. - * Returns true if any flag changed. + * Compute the on-disk audio availability for a single attachment URL. + * Performs filesystem stat + LFS pointer checks but does NOT mutate the document. */ -export async function revalidateCellMissingFlags( +async function computeAttachmentAvailabilityFromDisk( + workspaceFolder: vscode.WorkspaceFolder, + url: string, +): Promise { + let existsInPointers = await attachmentPointerExists(workspaceFolder, url); + if (!existsInPointers) { + existsInPointers = await ensurePointerFromFiles(workspaceFolder, url); + } + + return existsInPointers + ? await determineAttachmentAvailability(workspaceFolder, url) + : "missing" as AttachmentAvailability; +} + +/** + * Compute the overall audio availability state for a single cell by checking the + * filesystem. Returns an AudioAvailabilityState without mutating the document. + * + * Priority: available-local > available-pointer > missing > deletedOnly > none + */ +export async function computeCellAudioAvailabilityFromDisk( document: CodexCellDocument, workspaceFolder: vscode.WorkspaceFolder, - cellId: string -): Promise { + cellId: string, +): Promise { try { const cell = (document as any)._documentData?.cells?.find( (c: any) => c?.metadata?.id === cellId ); - if (!cell?.metadata?.attachments) return false; + if (!cell?.metadata?.attachments) return "none"; + + let hasAvailableLocal = false; + let hasAvailablePointer = false; + let hasMissing = false; + let hasDeleted = false; - let changed = false; - for (const [attId, attVal] of Object.entries(cell.metadata.attachments) as [string, any][]) { + for (const attVal of Object.values(cell.metadata.attachments) as any[]) { if (!attVal || typeof attVal !== "object") continue; if (attVal.type !== "audio") continue; - const url: string | undefined = attVal.url; - if (!url || typeof url !== "string") continue; - - // If the file exists but pointer is missing, try to restore the pointer now - let existsInPointers = await attachmentPointerExists(workspaceFolder, url); - if (!existsInPointers) { - existsInPointers = await ensurePointerFromFiles(workspaceFolder, url); + if (attVal.isDeleted) { + hasDeleted = true; + continue; } - const availability = existsInPointers - ? await determineAttachmentAvailability(workspaceFolder, url) - : "missing" as AttachmentAvailability; + const url: string | undefined = attVal.url; + if (!url || typeof url !== "string") { + hasMissing = true; + continue; + } - const updated = { ...attVal }; - if (setAttachmentAvailability(updated, availability)) { - document.updateCellAttachment(cellId, attId, updated); - changed = true; + const availability = await computeAttachmentAvailabilityFromDisk(workspaceFolder, url); + switch (availability) { + case "available-local": + hasAvailableLocal = true; + break; + case "available-pointer": + hasAvailablePointer = true; + break; + case "missing": + hasMissing = true; + break; } } - return changed; + + if (hasAvailableLocal) return "available-local"; + if (hasAvailablePointer) return "available-pointer"; + if (hasMissing) return "missing"; + if (hasDeleted) return "deletedOnly"; + return "none"; } catch (err) { - console.error("Failed to revalidate availability for cell", { cellId, err }); - return false; + console.error("Failed to compute audio availability for cell", { cellId, err }); + return "none"; } } /** - * Sets the audioAvailability field on an attachment object and updates updatedAt when changed. - * Also sets the deprecated isMissing field for backward compatibility. - * Returns true if the object was modified. + * Resolve the Frontier version gate once and return a function that applies it. + * Avoids repeated dynamic imports and async calls when processing many cells. */ -export function setAttachmentAvailability(att: any, availability: AttachmentAvailability): boolean { +async function resolveVersionGate(): Promise<(state: AudioAvailabilityState) => AudioAvailabilityState> { try { - const current = att?.audioAvailability; - if (current !== availability) { - att.audioAvailability = availability; - att.isMissing = availability === "missing"; - att.updatedAt = Date.now(); - return true; + const { getFrontierVersionStatus } = await import( + "../projectManager/utils/versionChecks" + ); + const status = await getFrontierVersionStatus(); + if (!status.ok) { + return (state) => { + if ( + state === "available-local" || + state === "missing" || + state === "deletedOnly" || + state === "none" + ) { + return state; + } + return "available-pointer"; + }; } - return false; } catch { - return false; + // Version check unavailable — pass through unchanged + } + return (state) => state; +} + +/** + * Compute audio availability for a specific set of cell IDs by checking the filesystem. + * Applies the Frontier version gate once for all cells. + * Returns a map of cellId → AudioAvailabilityState. + * Does NOT mutate or save the document. + */ +export async function computeCellIdsAudioAvailability( + document: CodexCellDocument, + workspaceFolder: vscode.WorkspaceFolder, + cellIds: string[], +): Promise> { + const result: Record = {}; + if (cellIds.length === 0) return result; + + try { + const gate = await resolveVersionGate(); + + for (const cellId of cellIds) { + const state = await computeCellAudioAvailabilityFromDisk( + document, + workspaceFolder, + cellId, + ); + result[cellId] = gate(state); + } + } catch (err) { + console.error("Failed to compute audio availability for cells", err); } + + return result; } /** - * Revalidates audioAvailability for ALL audio attachments across every cell in a document. - * Performs filesystem checks and persists updated metadata. - * Returns true if any flag changed. + * Compute audio availability for ALL cells with audio in a document. + * Applies the Frontier version gate once for all cells. + * Returns a map of cellId → AudioAvailabilityState. + * Does NOT mutate or save the document. */ -export async function revalidateDocumentAudioFlags( +export async function computeDocumentAudioAvailability( document: CodexCellDocument, workspaceFolder: vscode.WorkspaceFolder -): Promise { +): Promise> { try { const cells = (document as any)._documentData?.cells || []; - let anyChanged = false; + const audioCellIds: string[] = []; for (const cell of cells) { const cellId = cell?.metadata?.id; @@ -161,19 +240,35 @@ export async function revalidateDocumentAudioFlags( const hasAudio = Object.values(cell.metadata.attachments).some( (att: any) => att?.type === "audio" ); - if (!hasAudio) continue; - - const changed = await revalidateCellMissingFlags(document, workspaceFolder, cellId); - if (changed) anyChanged = true; - } - - if (anyChanged) { - await document.save(new vscode.CancellationTokenSource().token); + if (hasAudio) { + audioCellIds.push(cellId); + } } - return anyChanged; + return computeCellIdsAudioAvailability(document, workspaceFolder, audioCellIds); } catch (err) { - console.error("Failed to revalidate document audio flags", err); + console.error("Failed to compute document audio availability", err); + return {}; + } +} + +/** + * Sets the audioAvailability field on an attachment object and updates updatedAt when changed. + * Also sets the deprecated isMissing field for backward compatibility. + * Intended for genuine write events only (recording, importing, deleting). + * Returns true if the object was modified. + */ +export function setAttachmentAvailability(att: any, availability: AttachmentAvailability): boolean { + try { + const current = att?.audioAvailability; + if (current !== availability) { + att.audioAvailability = availability; + att.isMissing = availability === "missing"; + att.updatedAt = Date.now(); + return true; + } + return false; + } catch { return false; } } From a91c1ca7b03829dd37c3882b1c03535cde9cde50 Mon Sep 17 00:00:00 2001 From: LeviXIII Date: Wed, 11 Mar 2026 13:58:08 -0400 Subject: [PATCH 06/12] - Adjust test for changes --- src/test/suite/milestonePagination.test.ts | 32 ++++++++++++---------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/test/suite/milestonePagination.test.ts b/src/test/suite/milestonePagination.test.ts index e68851355..e220dbc87 100644 --- a/src/test/suite/milestonePagination.test.ts +++ b/src/test/suite/milestonePagination.test.ts @@ -643,7 +643,14 @@ suite("Milestone-Based Pagination Test Suite", () => { ]; const document = await createDocumentWithCells(cells); - const { panel, onDidReceiveMessageRef, lastPostedMessageRef } = createMockWebviewPanel(); + const { panel, onDidReceiveMessageRef } = createMockWebviewPanel(); + + const postedMessages: any[] = []; + const origPostMessage = panel.webview.postMessage.bind(panel.webview); + (panel.webview as any).postMessage = async (message: any) => { + postedMessages.push(message); + return origPostMessage(message); + }; await provider.resolveCustomEditor( document, @@ -654,8 +661,8 @@ suite("Milestone-Based Pagination Test Suite", () => { // Wait for initial setup await sleep(100); - // Clear any initial messages - lastPostedMessageRef.current = null; + // Clear messages collected during setup + postedMessages.length = 0; // Send requestCellsForMilestone message const messageCallback = onDidReceiveMessageRef.current; @@ -672,29 +679,26 @@ suite("Milestone-Based Pagination Test Suite", () => { // Wait for message processing await sleep(100); - // Verify message was sent - assert.ok(lastPostedMessageRef.current, "Should have posted a message"); - assert.strictEqual( - lastPostedMessageRef.current.type, - "providerSendsCellPage", - "Should send providerSendsCellPage message" - ); + // Find the providerSendsCellPage message among all posted messages + // (the handler may also send follow-up messages like providerSendsAudioAttachments) + const cellPageMsg = postedMessages.find((m) => m?.type === "providerSendsCellPage"); + assert.ok(cellPageMsg, "Should send providerSendsCellPage message"); assert.strictEqual( - lastPostedMessageRef.current.milestoneIndex, + cellPageMsg.milestoneIndex, 0, "Should include correct milestone index" ); assert.strictEqual( - lastPostedMessageRef.current.subsectionIndex, + cellPageMsg.subsectionIndex, 0, "Should include correct subsection index" ); assert.ok( - Array.isArray(lastPostedMessageRef.current.cells), + Array.isArray(cellPageMsg.cells), "Should include cells array" ); assert.strictEqual( - lastPostedMessageRef.current.cells.length, + cellPageMsg.cells.length, 2, "Should include 2 cells" ); From 4c0ecd9ff1faedae7fe5c39ae002edd0e88671f6 Mon Sep 17 00:00:00 2001 From: LeviXIII Date: Mon, 16 Mar 2026 07:45:09 -0400 Subject: [PATCH 07/12] - Fix missing audio icon not showing up after syncing. --- .../codexCellEditorProvider.ts | 27 ++++---- .../codexCellEditorProvider/codexDocument.ts | 21 ++++-- .../hooks/useVSCodeMessageHandler.ts | 65 +++++++++++-------- 3 files changed, 65 insertions(+), 48 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts index 91a72d18a..5ccde02f4 100755 --- a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts @@ -4254,7 +4254,10 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider { debug("Refreshing audio attachments after sync for all webviews"); @@ -4267,26 +4270,20 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider = {}; - - const cellIds = document.getAllCellIds(); - for (const cellId of cellIds) { - const currentAttachment = document.getCurrentAttachment(cellId, "audio"); - if (!currentAttachment) { - availability[cellId] = "none"; - continue; + // Revert non-dirty documents first so we read the post-merge cell data + if (!document.isDirty) { + try { + await document.revert(); + } catch { + // Revert failure is non-fatal; proceed with current in-memory data } - - availability[cellId] = await computeCellAudioStateWithVersionGate( - { [currentAttachment.attachmentId]: currentAttachment.attachment }, - currentAttachment.attachmentId, - ); } + const availability = await computeDocumentAudioAvailability(document, ws); if (Object.keys(availability).length > 0) { safePostMessageToPanel(webviewPanel, { type: "providerSendsAudioAttachments", - attachments: availability + attachments: availability as any, }); debug(`Refreshed audio attachments for ${Object.keys(availability).length} cells in ${documentUri}`); } diff --git a/src/providers/codexCellEditorProvider/codexDocument.ts b/src/providers/codexCellEditorProvider/codexDocument.ts index 666b4b64d..ecb0c968c 100644 --- a/src/providers/codexCellEditorProvider/codexDocument.ts +++ b/src/providers/codexCellEditorProvider/codexDocument.ts @@ -2989,15 +2989,18 @@ export class CodexCellDocument implements vscode.CustomDocument { return null; } + const isAttachmentMissing = (att: any): boolean => + att.audioAvailability === "missing" || + (att.audioAvailability === undefined && att.isMissing === true); + // STEP 1: Check for explicit selection first if (cell.metadata?.selectedAudioId && attachmentType === "audio") { const selectedAttachment = cell.metadata.attachments?.[cell.metadata.selectedAudioId]; - // Validate selection is still valid and the file isn't missing if (selectedAttachment && selectedAttachment.type === attachmentType && !selectedAttachment.isDeleted && - selectedAttachment.audioAvailability !== "missing") { + !isAttachmentMissing(selectedAttachment)) { return { attachmentId: cell.metadata.selectedAudioId, attachment: selectedAttachment @@ -3007,7 +3010,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Selection is invalid or missing — fall through to automatic resolution } - // STEP 2: Fall back to latest non-deleted, non-missing attachment (prefer available files) + // STEP 2: Fall back to latest non-deleted attachment (prefer non-missing files) const attachments = Object.entries(cell.metadata.attachments) .filter(([_, attachment]: [string, any]) => attachment && @@ -3015,8 +3018,8 @@ export class CodexCellDocument implements vscode.CustomDocument { !attachment.isDeleted ) .sort(([_, a]: [string, any], [__, b]: [string, any]) => { - const aMissing = a.audioAvailability === "missing" ? 1 : 0; - const bMissing = b.audioAvailability === "missing" ? 1 : 0; + const aMissing = isAttachmentMissing(a) ? 1 : 0; + const bMissing = isAttachmentMissing(b) ? 1 : 0; if (aMissing !== bMissing) return aMissing - bMissing; return (b.updatedAt || 0) - (a.updatedAt || 0); }); @@ -3223,12 +3226,16 @@ export class CodexCellDocument implements vscode.CustomDocument { selectedAttachment.type !== "audio" || selectedAttachment.isDeleted; - const isMissingAudio = !isInvalid && selectedAttachment.audioAvailability === "missing"; + const isAttMissing = (att: any): boolean => + att.audioAvailability === "missing" || + (att.audioAvailability === undefined && att.isMissing === true); + + const isMissingAudio = !isInvalid && isAttMissing(selectedAttachment); if (isInvalid || isMissingAudio) { const validAlternative = Object.entries(cell.metadata.attachments) .filter(([_, att]: [string, any]) => - att?.type === "audio" && !att.isDeleted && att.audioAvailability !== "missing" + att?.type === "audio" && !att.isDeleted && !isAttMissing(att) ) .sort(([_, a]: [string, any], [__, b]: [string, any]) => (b.updatedAt || 0) - (a.updatedAt || 0) diff --git a/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts b/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts index 469130cf8..dc8f8f444 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts +++ b/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts @@ -7,46 +7,59 @@ type AudioAvailability = "available" | "available-local" | "available-pointer" | /** * Derives the audio availability state for a cell based on its attachments and selection. - * When an explicit selectedAudioId is missing, returns "missing" regardless of other entries. - * When no explicit selection exists, checks whether the implicit current audio - * (latest non-deleted by updatedAt) is missing—this is the one the provider would serve on play. + * + * Uses `audioAvailability` as the primary field, then falls back to the legacy + * `isMissing` boolean. When neither field is set the attachment is treated as + * "unknown" (not counted as available) so that the filesystem-based check from + * the provider can supply the definitive answer. This prevents a sync merge + * that drops the legacy `isMissing` flag from incorrectly showing a play icon. */ -const isAttMissing = (att: any): boolean => - att.audioAvailability === "missing" || (att.audioAvailability === undefined && att.isMissing === true); +type AttAvailability = "available-local" | "available-pointer" | "missing" | "deletedOnly" | "unknown"; + +const classifyAttachment = (att: any): AttAvailability => { + if (att.isDeleted) return "deletedOnly"; + if (att.audioAvailability) return att.audioAvailability as AttAvailability; + if (att.isMissing === true) return "missing"; + if (att.isMissing === false) return "available-local"; + return "unknown"; +}; const deriveAudioAvailability = (unit: QuillCellContent): AudioAvailability => { const atts = (unit?.attachments || {}) as Record; - let hasAvailable = false; + let hasAvailableLocal = false; + let hasAvailablePointer = false; let hasMissing = false; let hasDeleted = false; + let hasUnknown = false; for (const key of Object.keys(atts)) { const att = atts[key]; - if (att?.type === "audio") { - if (att.isDeleted) hasDeleted = true; - else if (isAttMissing(att)) hasMissing = true; - else hasAvailable = true; - } - } - - if (hasAvailable) return "available"; - - const selectedId = unit?.metadata?.selectedAudioId; - const selectedAtt = selectedId ? atts[selectedId] : undefined; - if (selectedAtt?.type === "audio" && isAttMissing(selectedAtt)) { - return "missing"; - } + if (att?.type !== "audio") continue; - if (!selectedId && hasMissing) { - const nonDeleted = Object.entries(atts) - .filter(([, att]) => att?.type === "audio" && !att.isDeleted) - .sort(([, a], [, b]) => (b.updatedAt || 0) - (a.updatedAt || 0)); - if (nonDeleted.length > 0 && isAttMissing(nonDeleted[0][1])) { - return "missing"; + const state = classifyAttachment(att); + switch (state) { + case "available-local": + hasAvailableLocal = true; + break; + case "available-pointer": + hasAvailablePointer = true; + break; + case "missing": + hasMissing = true; + break; + case "deletedOnly": + hasDeleted = true; + break; + default: + hasUnknown = true; + break; } } + if (hasAvailableLocal) return "available-local"; + if (hasAvailablePointer) return "available-pointer"; if (hasMissing) return "missing"; + if (hasUnknown) return "none"; if (hasDeleted) return "deletedOnly"; return "none"; }; From 59142c7b5853c1d945e62552e96ea45cf2852d70 Mon Sep 17 00:00:00 2001 From: LeviXIII Date: Mon, 16 Mar 2026 14:41:53 -0400 Subject: [PATCH 08/12] - Remove unused variable --- .../codexCellEditorProvider/codexCellEditorMessagehandling.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 9d2956a5d..8acda391b 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -2484,7 +2484,6 @@ const messageHandlers: Record Promise = {}; let validatedByArray: ValidationEntry[] = []; - const ws = vscode.workspace.getWorkspaceFolder(document.uri); for (const cell of cells) { const cellId = cell?.metadata?.id; From e5150f9b92f7d6abe8c445ee91c9e50b6db3c66f Mon Sep 17 00:00:00 2001 From: LeviXIII Date: Mon, 16 Mar 2026 14:54:40 -0400 Subject: [PATCH 09/12] Fixed missing version gate in revalidateMissingForCell handler - Applied applyFrontierVersionGate to the computed state before sending to webview, ensuring consistency with all other filesystem-based audio availability code paths. --- .../codexCellEditorMessagehandling.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 8acda391b..36ec26556 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -20,7 +20,7 @@ import { getAuthApi } from "@/extension"; import { getCommentsFromFile } from "../../utils/fileUtils"; import { getUnresolvedCommentsCountForCell } from "../../utils/commentsUtils"; import { toPosixPath } from "../../utils/pathUtils"; -import { computeCellAudioAvailabilityFromDisk, computeCellIdsAudioAvailability } from "../../utils/audioMissingUtils"; +import { computeCellIdsAudioAvailability } from "../../utils/audioMissingUtils"; import { computeCellAudioStateWithVersionGate, type AudioAvailabilityState } from "../../utils/audioAvailabilityUtils"; import { mergeAudioFiles } from "../../utils/audioMerger"; import { getAttachmentDocumentSegmentFromUri } from "../../utils/attachmentFolderUtils"; @@ -3115,8 +3115,13 @@ const messageHandlers: Record Promise Date: Mon, 16 Mar 2026 15:16:10 -0400 Subject: [PATCH 10/12] - update buildCorrectedMilestoneProgress in src/providers/codexCellEditorProvider/codexCellEditorProvider.ts to use the same missing-audio semantics as initial progress, while still honoring filesystem-derived correction --- .../codexCellEditorProvider.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts index 5ccde02f4..b1300e56f 100755 --- a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts @@ -49,6 +49,7 @@ import { isMatchingFilePair as isMatchingFilePairUtil, } from "../../utils/fileTypeUtils"; import { getCorrespondingSourceUri } from "../../utils/codexNotebookUtils"; +import { cellHasMissingAudio } from "../../../sharedUtils"; // Enable debug logging if needed const DEBUG_MODE = false; @@ -114,8 +115,14 @@ function buildCorrectedMilestoneProgress( let missingCount = 0; for (let j = startIdx; j < endIdx && j < documentCells.length; j++) { - const cellId = documentCells[j]?.metadata?.id; - if (cellId && availability[cellId] === "missing") { + const cell = documentCells[j]; + const cellId = cell?.metadata?.id; + const hasMissingAudioByMetadata = cellHasMissingAudio( + cell?.metadata?.attachments, + cell?.metadata?.selectedAudioId, + ); + const hasMissingAudioByDisk = cellId ? availability[cellId] === "missing" : false; + if (hasMissingAudioByMetadata || hasMissingAudioByDisk) { missingCount++; } } From 052d75f168d9cf6355d264b193fc22b3e743efc1 Mon Sep 17 00:00:00 2001 From: LeviXIII Date: Tue, 17 Mar 2026 15:12:16 -0400 Subject: [PATCH 11/12] Fixes missing state erroneously marking cells that have audio restored: - Enhance attachment metadata handling by adding revalidation functions. - Implemented revalidateCellAttachmentAvailability and revalidateDocumentAttachmentAvailability to ensure audio attachment availability is accurately reflected from the filesystem. - Updated document saving logic to trigger when metadata changes occur, improving consistency in audio history and availability states. --- .../codexCellEditorMessagehandling.ts | 17 +++- .../codexCellEditorProvider.ts | 30 ++++++- .../codexCellEditorProvider/codexDocument.ts | 23 ++++++ src/utils/audioMissingUtils.ts | 81 +++++++++++++++++++ 4 files changed, 147 insertions(+), 4 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 36ec26556..78c3a3d44 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -20,7 +20,7 @@ import { getAuthApi } from "@/extension"; import { getCommentsFromFile } from "../../utils/fileUtils"; import { getUnresolvedCommentsCountForCell } from "../../utils/commentsUtils"; import { toPosixPath } from "../../utils/pathUtils"; -import { computeCellIdsAudioAvailability } from "../../utils/audioMissingUtils"; +import { computeCellIdsAudioAvailability, revalidateCellAttachmentAvailability } from "../../utils/audioMissingUtils"; import { computeCellAudioStateWithVersionGate, type AudioAvailabilityState } from "../../utils/audioAvailabilityUtils"; import { mergeAudioFiles } from "../../utils/audioMerger"; import { getAttachmentDocumentSegmentFromUri } from "../../utils/attachmentFolderUtils"; @@ -3115,7 +3115,18 @@ const messageHandlers: Record Promise Promise 0) { this.postMessageToWebview(webviewPanel, { @@ -4407,6 +4415,16 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider c.metadata?.id === cellId); + const att = cell?.metadata?.attachments?.[attachmentId]; + if (!att || typeof att !== "object") return false; + + const prev = (att as any).audioAvailability; + if (prev === availability) return false; + + (att as any).audioAvailability = availability; + (att as any).isMissing = availability === "missing"; + this._isDirty = true; + return true; + } + /** * Soft deletes an attachment by setting isDeleted to true * @param cellId The ID of the cell to update diff --git a/src/utils/audioMissingUtils.ts b/src/utils/audioMissingUtils.ts index 16eea7d86..ca39ce2ef 100644 --- a/src/utils/audioMissingUtils.ts +++ b/src/utils/audioMissingUtils.ts @@ -281,3 +281,84 @@ export function setMissingFlagOnAttachmentObject(att: any, desiredMissing: boole const availability: AttachmentAvailability = desiredMissing ? "missing" : "available-local"; return setAttachmentAvailability(att, availability); } + +/** + * Revalidates the audioAvailability field on each audio attachment for a cell + * by checking the filesystem. Uses patchAttachmentAvailability to update only + * availability metadata without triggering auto-selection or edit history entries. + * + * Returns true if any attachment was updated (caller should save the document). + */ +export async function revalidateCellAttachmentAvailability( + document: CodexCellDocument, + workspaceFolder: vscode.WorkspaceFolder, + cellId: string, +): Promise { + try { + const cell = (document as any)._documentData?.cells?.find( + (c: any) => c?.metadata?.id === cellId + ); + if (!cell?.metadata?.attachments) return false; + + let changed = false; + const attachments = cell.metadata.attachments as Record; + + for (const [attId, attVal] of Object.entries(attachments)) { + if (!attVal || typeof attVal !== "object") continue; + if (attVal.type !== "audio") continue; + if (attVal.isDeleted) continue; + + const url: string | undefined = attVal.url; + if (!url || typeof url !== "string") continue; + + const diskAvailability = await computeAttachmentAvailabilityFromDisk(workspaceFolder, url); + if (document.patchAttachmentAvailability(cellId, attId, diskAvailability)) { + changed = true; + } + } + + return changed; + } catch (err) { + console.error("Failed to revalidate attachment availability for cell", { cellId, err }); + return false; + } +} + +/** + * Revalidates audioAvailability on all audio attachments in a document by checking + * the filesystem. Intended to run once after document open so that subsequent + * metadata reads (e.g. deriveAudioAvailability in the webview) return correct values. + * + * Returns true if any attachment was updated (caller should save the document). + */ +export async function revalidateDocumentAttachmentAvailability( + document: CodexCellDocument, + workspaceFolder: vscode.WorkspaceFolder, +): Promise { + try { + const cells = (document as any)._documentData?.cells || []; + let changed = false; + + for (const cell of cells) { + const cellId = cell?.metadata?.id; + if (!cellId || !cell?.metadata?.attachments) continue; + + const hasAudio = Object.values(cell.metadata.attachments).some( + (att: any) => att?.type === "audio" + ); + if (!hasAudio) continue; + + const cellChanged = await revalidateCellAttachmentAvailability( + document, + workspaceFolder, + cellId, + ); + if (cellChanged) changed = true; + } + + return changed; + } catch (err) { + console.error("Failed to revalidate document attachment availability", err); + return false; + } +} From f05e45aa1dc2e342d9511f851fcdbc7346a839c7 Mon Sep 17 00:00:00 2001 From: LeviXIII Date: Tue, 17 Mar 2026 15:19:39 -0400 Subject: [PATCH 12/12] - Update test to reflect code changes. --- src/test/suite/codexCellEditorProvider.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/suite/codexCellEditorProvider.test.ts b/src/test/suite/codexCellEditorProvider.test.ts index 7cd911fb6..68a7e5c5e 100644 --- a/src/test/suite/codexCellEditorProvider.test.ts +++ b/src/test/suite/codexCellEditorProvider.test.ts @@ -2340,7 +2340,7 @@ suite("CodexCellEditorProvider Test Suite", () => { ); }); - test("revalidateMissingForCell restores pointer, computes availability from disk, and posts updates without mutating document", async function () { + test("revalidateMissingForCell restores pointer, updates document availability from disk, and posts updates", async function () { this.timeout(12000); const provider = new CodexCellEditorProvider(context); const document = await provider.openCustomDocument( @@ -2425,11 +2425,11 @@ suite("CodexCellEditorProvider Test Suite", () => { // Do not hard-fail if pointer check races; the audioAvailability flip below is the contract we require assert.ok(ptrOk || true, "Pointer creation may race; continuing to validate flags and messages"); - // Document should NOT be mutated — revalidation is now compute-only + // Document SHOULD be mutated — revalidation updates audioAvailability from disk state const after = JSON.parse(document.getText()); const att = after.cells[0].metadata.attachments[audioId]; - assert.strictEqual(att.audioAvailability, "missing", "audioAvailability should remain unchanged on the document (compute-only)"); - assert.strictEqual(att.updatedAt, initialUpdatedAt, "updatedAt should not change (compute-only)"); + assert.strictEqual(att.audioAvailability, "available-local", "audioAvailability should be updated to available-local since file exists on disk"); + assert.ok(att.updatedAt >= initialUpdatedAt, "updatedAt should be bumped when availability changes"); // Assert messages were posted: history refresh and availability map const historyMsg = posted.find((m) => m?.type === "audioHistoryReceived");