From a8dc3f58a4fc3f52eb9973b5c7ff5a6b9a605ff1 Mon Sep 17 00:00:00 2001 From: dadukhankevin Date: Tue, 3 Mar 2026 13:34:40 -0600 Subject: [PATCH 1/3] Edit ID system & cell value object refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add optional `id` field to all edit history types (EditHistoryBase, EditFor, etc.) - Add `CellValueOnDisk` type: `{ selectedEdit, updatedAt }` for on-disk cell values - Add `activeEditId` to cell metadata and QuillCellContent for O(1) edit lookup - Add `generateEditId()` using SHA-256 via existing uuidUtils - Add `resolveCellValue()` helper for reading raw .codex JSON safely - Serializer bridge: deserialize resolves object→string, serialize writes string→object - Merge dedup uses edit `id` as primary key (composite fallback for legacy) - `applyEditToCell` saves value as `{ selectedEdit, updatedAt }` when edit has id - CodexDocument: all new edits get `id: randomUUID()`, sets `activeEditId` - New `selectEdit(cellId, editId)` method for rollbacks preserving validations - `getCellValueData` does O(1 lookup by activeEditId with scan fallback - Migration `migration_editValueObject`: backfills IDs, converts cell.value to object - Updated raw-JSON touchpoints (matcher, updater, cellUtils, sharedUtils) Co-Authored-By: Claude Opus 4.6 --- package.json | 5 + sharedUtils/index.ts | 23 ++- src/cellLabelImporter/updater.ts | 2 + src/codexMigrationTool/matcher.ts | 3 +- src/codexMigrationTool/updater.ts | 5 +- src/extension.ts | 2 + src/projectManager/utils/merge/resolvers.ts | 32 ++- .../utils/migrationCompletionUtils.ts | 1 + src/projectManager/utils/migrationUtils.ts | 188 +++++++++++++++++- .../codexCellEditorMessagehandling.ts | 2 + .../codexCellEditorProvider/codexDocument.ts | 57 +++++- .../utils/cellUtils.ts | 1 + src/serializer.ts | 25 ++- src/utils/cellValueResolver.ts | 51 +++++ src/utils/editMapUtils.ts | 45 ++++- types/index.d.ts | 10 + 16 files changed, 425 insertions(+), 27 deletions(-) create mode 100644 src/utils/cellValueResolver.ts diff --git a/package.json b/package.json index d6af0a40e..82d422c0f 100644 --- a/package.json +++ b/package.json @@ -516,6 +516,11 @@ "default": false, "description": "Internal flag indicating the global references migration has completed." }, + "codex-project-manager.editValueObjectMigrationCompleted": { + "type": "boolean", + "default": false, + "description": "Internal flag indicating the edit value object migration has completed." + }, "codex-project-manager.chatSystemMessageToMetadataMigrationCompleted": { "type": "boolean", "default": false, diff --git a/sharedUtils/index.ts b/sharedUtils/index.ts index 6de95786e..46242e3c6 100644 --- a/sharedUtils/index.ts +++ b/sharedUtils/index.ts @@ -47,13 +47,22 @@ export const getCellValueData = (cell: QuillCellContent) => { // Ensure editHistory exists and is an array const editHistory = cell.editHistory || []; - // Find the latest edit that matches the current cell content (strict match). - // Falls back to the latest value edit if the strict match fails, which can happen - // when the merge step during save subtly normalizes the stored value. - const reversed = editHistory.slice().reverse(); - const latestEditThatMatchesCellValue = - reversed.find((edit) => EditMapUtils.isValue(edit.editMap) && edit.value === cell.cellContent) ?? - reversed.find((edit) => EditMapUtils.isValue(edit.editMap) && !edit.preview); + // O(1) lookup by activeEditId when available + let latestEditThatMatchesCellValue: (typeof editHistory)[number] | undefined; + if (cell.activeEditId) { + latestEditThatMatchesCellValue = editHistory.find( + (edit) => (edit as any).id === cell.activeEditId && EditMapUtils.isValue(edit.editMap) + ); + } + + // Fallback: find the latest edit that matches the current cell content (strict match). + // Falls back to the latest value edit if the strict match fails. + if (!latestEditThatMatchesCellValue) { + const reversed = editHistory.slice().reverse(); + latestEditThatMatchesCellValue = + reversed.find((edit) => EditMapUtils.isValue(edit.editMap) && edit.value === cell.cellContent) ?? + reversed.find((edit) => EditMapUtils.isValue(edit.editMap) && !edit.preview); + } // Get audio validation from attachments instead of edits let audioValidatedBy: ValidationEntry[] = []; diff --git a/src/cellLabelImporter/updater.ts b/src/cellLabelImporter/updater.ts index 59914cbd0..35cbf9c69 100644 --- a/src/cellLabelImporter/updater.ts +++ b/src/cellLabelImporter/updater.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode"; +import { randomUUID } from "crypto"; import { CellLabelData, CellMetadata, FileData } from "./types"; import { CodexContentSerializer } from "../serializer"; import { getNotebookMetadataManager } from "../utils/notebookMetadataManager"; @@ -131,6 +132,7 @@ async function saveNotebookFileWithLabels( // Create edit history entry for the label change cell.metadata.edits.push({ + id: randomUUID(), editMap: EditMapUtils.cellLabel(), value: newLabel, timestamp: currentTimestamp, diff --git a/src/codexMigrationTool/matcher.ts b/src/codexMigrationTool/matcher.ts index 4831ca460..d49c05c8d 100644 --- a/src/codexMigrationTool/matcher.ts +++ b/src/codexMigrationTool/matcher.ts @@ -7,6 +7,7 @@ import type { import { removeHtmlTags } from "../exportHandler/subtitleUtils"; import { CodexCellTypes } from "../../types/enums"; import { isContentCell } from "../utils/cellTypeUtils"; +import { resolveCellValue } from "../utils/cellValueResolver"; type SourceLine = { cellId: string; @@ -61,7 +62,7 @@ const buildSourceLinesFromFile = (file: FileData): SourceLine[] => { } const cellId = getCellId(cell); if (!cellId) return; - const value = typeof cell.value === "string" ? cell.value : ""; + const value = resolveCellValue(cell as any); const normalized = normalizeText(value); if (!normalized) return; lines.push({ cellId, sourceValue: normalized }); diff --git a/src/codexMigrationTool/updater.ts b/src/codexMigrationTool/updater.ts index e2a618f0b..d2d2148fd 100644 --- a/src/codexMigrationTool/updater.ts +++ b/src/codexMigrationTool/updater.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode"; +import { randomUUID } from "crypto"; import { CodexContentSerializer } from "../serializer"; import { mergeDuplicateCellsUsingResolverLogic } from "../projectManager/utils/merge/resolvers"; import { EditMapUtils } from "../utils/editMapUtils"; @@ -6,6 +7,7 @@ import { EditType } from "../../types/enums"; import { getAuthApi } from "../extension"; import type { CustomNotebookCellData } from "../../types"; import type { MigrationMatchResult } from "./types"; +import { resolveCellValue } from "../utils/cellValueResolver"; const cloneCell = (cell: CustomNotebookCellData): CustomNotebookCellData => { return JSON.parse(JSON.stringify(cell)) as CustomNotebookCellData; @@ -28,8 +30,9 @@ const addMigrationEdit = ( cell.metadata.edits = []; } cell.metadata.edits.push({ + id: randomUUID(), editMap: EditMapUtils.value(), - value: cell.value, + value: resolveCellValue(cell as any), timestamp, type: EditType.MIGRATION, author, diff --git a/src/extension.ts b/src/extension.ts index 6523ae8bb..98a5ff16a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -17,6 +17,7 @@ import { migration_verseRangeLabelsAndPositions, migration_cellIdsToUuid, migration_recoverTempFilesAndMergeDuplicates, + migration_editValueObject, } from "./projectManager/utils/migrationUtils"; import { createIndexWithContext } from "./activationHelpers/contextAware/contentIndexes/indexes"; import { StatusBarItem } from "vscode"; @@ -671,6 +672,7 @@ export async function activate(context: vscode.ExtensionContext) { await migration_addGlobalReferences(context); await migration_cellIdsToUuid(context); await migration_recoverTempFilesAndMergeDuplicates(context); + await migration_editValueObject(context); } // Remove leftover files from features that have been removed diff --git a/src/projectManager/utils/merge/resolvers.ts b/src/projectManager/utils/merge/resolvers.ts index 89df804a8..0b58cf791 100644 --- a/src/projectManager/utils/merge/resolvers.ts +++ b/src/projectManager/utils/merge/resolvers.ts @@ -921,8 +921,13 @@ function applyEditToCell(cell: CustomNotebookCellData, edit: EditHistory): void try { if (path.length === 1 && path[0] === 'value') { - // Direct cell value edit - cell.value = value as string; + // Direct cell value edit — store as object if edit has an id + if (edit.id) { + (cell as any).value = { selectedEdit: edit.id, updatedAt: edit.timestamp }; + cell.metadata.activeEditId = edit.id; + } else { + cell.value = value as string; + } } else if (path.length >= 2 && path[0] === 'metadata') { // Metadata field edit if (path.length === 2) { @@ -1014,12 +1019,20 @@ function mergeTwoCellsUsingResolverLogic( ...(theirCell.metadata?.edits || []) ].sort((a, b) => a.timestamp - b.timestamp); - // Remove duplicates based on timestamp, editMap and value, while merging validatedBy entries + // Remove duplicates: prefer edit id as primary key, fall back to composite key for legacy const editMap = new Map(); allEdits.forEach((edit) => { if (edit.editMap && Array.isArray(edit.editMap)) { - const editMapKey = edit.editMap.join('.'); - const key = `${edit.timestamp}:${editMapKey}:${edit.value}`; + let key: string; + if (edit.id) { + key = edit.id; + } else { + const editMapKey = edit.editMap.join('.'); + const valueKey = typeof edit.value === 'object' && edit.value !== null + ? JSON.stringify(edit.value) + : String(edit.value); + key = `${edit.timestamp}:${editMapKey}:${valueKey}`; + } if (!editMap.has(key)) { editMap.set(key, edit); } else { @@ -1040,6 +1053,15 @@ function mergeTwoCellsUsingResolverLogic( } mergedCell.metadata.edits = uniqueEdits; + // After merge, resolve activeEditId to the latest non-preview value edit + const latestValueEdit = [...uniqueEdits].reverse().find( + (e) => e.editMap && Array.isArray(e.editMap) && e.editMap.length === 1 && e.editMap[0] === 'value' && !e.preview + ); + if (latestValueEdit?.id) { + mergedCell.metadata.activeEditId = latestValueEdit.id; + (mergedCell as any).value = { selectedEdit: latestValueEdit.id, updatedAt: latestValueEdit.timestamp }; + } + // Merge attachments intelligently const mergedAttachments = mergeAttachments( ourCell.metadata?.attachments, diff --git a/src/projectManager/utils/migrationCompletionUtils.ts b/src/projectManager/utils/migrationCompletionUtils.ts index 2a71107ba..fad538852 100644 --- a/src/projectManager/utils/migrationCompletionUtils.ts +++ b/src/projectManager/utils/migrationCompletionUtils.ts @@ -22,6 +22,7 @@ export const CODEX_PROJECT_MIGRATION_FLAG_KEYS = [ "globalReferencesMigrationCompleted", "cellIdsToUuidMigrationCompleted", "tempFilesRecoveryAndDuplicateMergeCompleted", + "editValueObjectMigrationCompleted", ] as const; export type CodexProjectMigrationFlagKey = (typeof CODEX_PROJECT_MIGRATION_FLAG_KEYS)[number]; diff --git a/src/projectManager/utils/migrationUtils.ts b/src/projectManager/utils/migrationUtils.ts index 85b188a0d..d6574c2b4 100644 --- a/src/projectManager/utils/migrationUtils.ts +++ b/src/projectManager/utils/migrationUtils.ts @@ -4,7 +4,7 @@ import { randomUUID } from "crypto"; import * as dugiteGit from "../../utils/dugiteGit"; import { CodexContentSerializer } from "@/serializer"; import { vrefData } from "@/utils/verseRefUtils/verseData"; -import { EditMapUtils } from "@/utils/editMapUtils"; +import { EditMapUtils, generateEditId } from "@/utils/editMapUtils"; import { EditType, CodexCellTypes } from "../../../types/enums"; import type { ValidationEntry } from "../../../types"; import { getAuthApi } from "../../extension"; @@ -4021,3 +4021,189 @@ export const migration_recoverTempFilesAndMergeDuplicates = async (context?: vsc console.error("Error running temp files recovery and duplicate merge migration:", error); } }; + +/** + * Migration: Backfill edit IDs and convert cell.value to CellValueOnDisk object format. + * + * 1. Backfill `id` on all edits (cell-level and file-level) using SHA-256 deterministic hash. + * Skips edits that already have UUID-format IDs (from randomUUID). + * 2. Find matching edit for current cell.value string → set as activeEditId. + * 3. Convert cell.value from string to { selectedEdit: id, updatedAt: timestamp }. + * 4. Create INITIAL_IMPORT edit if cell has content but no matching edit. + * 5. Also backfill IDs on file-level metadata.edits. + */ +export const migration_editValueObject = async (context?: vscode.ExtensionContext) => { + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + return; + } + + const migrationKey = "editValueObjectMigrationCompleted"; + const config = vscode.workspace.getConfiguration("codex-project-manager"); + let hasMigrationRun = false; + try { + hasMigrationRun = config.get(migrationKey, false); + } catch (e) { + hasMigrationRun = !!context?.workspaceState.get(migrationKey); + } + if (hasMigrationRun) { + return; + } + + debug("Running edit value object migration..."); + + const codexFiles = await vscode.workspace.findFiles("**/*.codex"); + if (codexFiles.length === 0) { + // No codex files — mark migration as done + try { + await config.update(migrationKey, true, vscode.ConfigurationTarget.Workspace); + } catch (e) { + await context?.workspaceState.update(migrationKey, true); + } + return; + } + + let migratedFiles = 0; + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Migrating edit IDs and cell value objects...", + cancellable: false, + }, + async (progress) => { + for (let i = 0; i < codexFiles.length; i++) { + const file = codexFiles[i]; + progress.report({ + message: `Processing file ${i + 1}/${codexFiles.length}`, + increment: (100 / codexFiles.length), + }); + + try { + const fileData = await vscode.workspace.fs.readFile(file); + const content = new TextDecoder().decode(fileData); + const notebook = JSON.parse(content); + + let fileModified = false; + + // Get author for deterministic IDs + let author = "anonymous"; + try { + const authApi = await getAuthApi(); + const userInfo = await authApi?.getUserInfo(); + if (userInfo?.username) { + author = userInfo.username; + } + } catch (_) { /* ignore */ } + + // Process each cell + for (const cell of (notebook.cells || [])) { + if (!cell.metadata?.edits) { + cell.metadata = cell.metadata || { id: randomUUID(), type: "text", edits: [] }; + if (!cell.metadata.edits) { + cell.metadata.edits = []; + } + } + + // 1. Backfill IDs on all cell-level edits + for (const edit of cell.metadata.edits) { + if (!edit.id || !isUuidFormat(edit.id)) { + const valueStr = typeof edit.value === 'object' && edit.value !== null + ? JSON.stringify(edit.value) + : String(edit.value ?? ''); + const authorStr = edit.author || author; + edit.id = await generateEditId(valueStr, edit.timestamp, authorStr); + fileModified = true; + } + } + + // 2. Find matching edit for current cell.value + const currentValue = typeof cell.value === "string" ? cell.value : ""; + if (currentValue) { + const valueEdits = cell.metadata.edits.filter( + (e: any) => e.editMap && Array.isArray(e.editMap) && e.editMap.length === 1 && e.editMap[0] === "value" && !e.preview + ); + + // Try to find exact match first + let matchingEdit = valueEdits.find((e: any) => e.value === currentValue); + + // If no match, find latest non-preview value edit + if (!matchingEdit && valueEdits.length > 0) { + matchingEdit = valueEdits[valueEdits.length - 1]; + } + + // 4. Create INITIAL_IMPORT edit if cell has content but no matching value edit + if (!matchingEdit && currentValue.trim()) { + const importTimestamp = cell.metadata.edits.length > 0 + ? Math.min(...cell.metadata.edits.map((e: any) => e.timestamp || Date.now())) - 1000 + : Date.now() - 1000; + const importId = await generateEditId(currentValue, importTimestamp, author); + const importEdit = { + id: importId, + editMap: ["value"], + value: currentValue, + timestamp: importTimestamp, + type: EditType.INITIAL_IMPORT, + author: author, + validatedBy: [], + }; + cell.metadata.edits.unshift(importEdit); + matchingEdit = importEdit; + fileModified = true; + } + + if (matchingEdit) { + // Set activeEditId + cell.metadata.activeEditId = matchingEdit.id; + + // 3. Convert cell.value to object format + cell.value = { + selectedEdit: matchingEdit.id, + updatedAt: matchingEdit.timestamp, + }; + fileModified = true; + } + } + } + + // 5. Backfill IDs on file-level metadata.edits + if (notebook.metadata?.edits) { + for (const edit of notebook.metadata.edits) { + if (!edit.id || !isUuidFormat(edit.id)) { + const valueStr = typeof edit.value === 'object' && edit.value !== null + ? JSON.stringify(edit.value) + : String(edit.value ?? ''); + const authorStr = edit.author || author; + edit.id = await generateEditId(valueStr, edit.timestamp, authorStr); + fileModified = true; + } + } + } + + if (fileModified) { + const newContent = formatJsonForNotebookFile(notebook); + await atomicWriteUriText(file, newContent); + migratedFiles++; + } + } catch (error) { + console.error(`Error migrating file ${file.fsPath}:`, error); + } + } + } + ); + + // Mark migration as complete + try { + await config.update(migrationKey, true, vscode.ConfigurationTarget.Workspace); + } catch (e) { + await context?.workspaceState.update(migrationKey, true); + } + + if (migratedFiles > 0) { + debug(`Edit value object migration completed: ${migratedFiles} files migrated.`); + } + } catch (error) { + console.error("Error running edit value object migration:", error); + } +}; diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 83f25a126..b58491036 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode"; +import { randomUUID } from "crypto"; import { CodexCellDocument } from "./codexDocument"; import { safePostMessageToPanel } from "../../utils/webviewUtils"; // Use type-only import to break circular dependency @@ -2692,6 +2693,7 @@ const messageHandlers: Record Promise " + currentContent; const mergeEdit: EditHistory = { + id: randomUUID(), editMap: EditMapUtils.value(), value: mergedContent, timestamp: timestamp + 1, diff --git a/src/providers/codexCellEditorProvider/codexDocument.ts b/src/providers/codexCellEditorProvider/codexDocument.ts index 4a9026d34..1fe659181 100644 --- a/src/providers/codexCellEditorProvider/codexDocument.ts +++ b/src/providers/codexCellEditorProvider/codexDocument.ts @@ -346,6 +346,7 @@ export class CodexCellDocument implements vscode.CustomDocument { const currentTimestamp = Date.now(); const previewEdit = { + id: randomUUID(), editMap: EditMapUtils.value(), value: newContent, timestamp: currentTimestamp, @@ -384,6 +385,7 @@ export class CodexCellDocument implements vscode.CustomDocument { if (cellToUpdate.metadata.edits.length === 0 && !!previousValue) { cellToUpdate.metadata.edits.push({ + id: randomUUID(), editMap: EditMapUtils.value(), value: previousValue, timestamp: currentTimestamp - 1000, @@ -458,7 +460,9 @@ export class CodexCellDocument implements vscode.CustomDocument { } } + const newEditId = randomUUID(); cellToUpdate.metadata.edits.push({ + id: newEditId, editMap: EditMapUtils.value(), value: newContent, // TypeScript infers: string timestamp: currentTimestamp, @@ -467,7 +471,12 @@ export class CodexCellDocument implements vscode.CustomDocument { validatedBy, }); - // Record the edit + // Track which edit the current cell value points to + if (shouldUpdateValue) { + cellToUpdate.metadata.activeEditId = newEditId; + } + + // Record the edit // not being used ??? this._edits.push({ type: "updateCellContent", @@ -890,6 +899,7 @@ export class CodexCellDocument implements vscode.CustomDocument { cellContent: cell.value, cellType: cell.metadata.type, editHistory: cell.metadata.edits || [], + activeEditId: cell.metadata.activeEditId, timestamps: cell.metadata.data, cellLabel: cell.metadata.cellLabel, data: cell.metadata.data, @@ -900,6 +910,36 @@ export class CodexCellDocument implements vscode.CustomDocument { }; } + /** + * Selects an existing edit by ID for a cell, changing the cell's value and activeEditId. + * Does NOT create a new edit entry — this preserves existing validations on the target edit. + */ + public selectEdit(cellId: string, editId: string): boolean { + const cell = this._documentData.cells.find((c) => c.metadata?.id === cellId); + if (!cell) { + console.warn("selectEdit: Could not find cell", cellId); + return false; + } + + const edit = cell.metadata.edits.find( + (e) => e.id === editId && EditMapUtils.isValue(e.editMap) + ); + if (!edit || typeof edit.value !== "string") { + console.warn("selectEdit: Could not find value edit with id", editId); + return false; + } + + cell.value = edit.value; + cell.metadata.activeEditId = editId; + + this._isDirty = true; + this._onDidChangeForVsCodeAndWebview.fire({ + edits: [{ cellId, newContent: edit.value, editType: edit.type }], + }); + + return true; + } + // Additional methods for other edit operations... // For example, updating cell timestamps @@ -938,6 +978,7 @@ export class CodexCellDocument implements vscode.CustomDocument { ); if (!hasInitialStart && previousStartTime !== undefined) { cellToUpdate.metadata.edits.push({ + id: randomUUID(), editMap: EditMapUtils.dataStartTime(), value: previousStartTime, timestamp: currentTimestamp - 1000, @@ -948,6 +989,7 @@ export class CodexCellDocument implements vscode.CustomDocument { } const startTimeEditMap = EditMapUtils.dataStartTime(); cellToUpdate.metadata.edits.push({ + id: randomUUID(), editMap: startTimeEditMap, value: timestamps.startTime, timestamp: currentTimestamp, @@ -972,6 +1014,7 @@ export class CodexCellDocument implements vscode.CustomDocument { ); if (!hasInitialEnd && previousEndTime !== undefined) { cellToUpdate.metadata.edits.push({ + id: randomUUID(), editMap: EditMapUtils.dataEndTime(), value: previousEndTime, timestamp: currentTimestamp - 1000, @@ -982,6 +1025,7 @@ export class CodexCellDocument implements vscode.CustomDocument { } const endTimeEditMap = EditMapUtils.dataEndTime(); cellToUpdate.metadata.edits.push({ + id: randomUUID(), editMap: endTimeEditMap, value: timestamps.endTime, timestamp: currentTimestamp, @@ -1066,6 +1110,7 @@ export class CodexCellDocument implements vscode.CustomDocument { } const currentTimestamp = Date.now(); cellToSoftDelete.metadata.edits.push({ + id: randomUUID(), editMap: EditMapUtils.dataDeleted(), value: true, timestamp: currentTimestamp, @@ -1232,6 +1277,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Add edit history entry with new structure this._documentData.metadata.edits.push({ + id: randomUUID(), editMap, value: newValue, timestamp: currentTimestamp, @@ -2204,6 +2250,7 @@ export class CodexCellDocument implements vscode.CustomDocument { } const currentTimestamp = Date.now(); cellToUpdate.metadata.edits.push({ + id: randomUUID(), editMap: EditMapUtils.cellLabel(), value: newLabel, // TypeScript infers: string timestamp: currentTimestamp, @@ -2284,6 +2331,7 @@ export class CodexCellDocument implements vscode.CustomDocument { } const currentTimestamp = Date.now(); cellToUpdate.metadata.edits.push({ + id: randomUUID(), editMap: lockEditMap, value: isLocked, // TypeScript infers: boolean timestamp: currentTimestamp, @@ -2341,8 +2389,10 @@ export class CodexCellDocument implements vscode.CustomDocument { console.warn("No edits found for cell to validate"); // repair the edit history by adding an llm generation with author unknown, and then a user edit with validation const currentTimestamp = Date.now(); + const repairEditId = randomUUID(); cellToUpdate.metadata.edits = [ { + id: randomUUID(), editMap: EditMapUtils.value(), value: cellToUpdate.value, timestamp: currentTimestamp, @@ -2351,6 +2401,7 @@ export class CodexCellDocument implements vscode.CustomDocument { validatedBy: [], }, { + id: repairEditId, editMap: EditMapUtils.value(), value: cellToUpdate.value, timestamp: currentTimestamp, @@ -2359,6 +2410,7 @@ export class CodexCellDocument implements vscode.CustomDocument { validatedBy: [], }, ]; + cellToUpdate.metadata.activeEditId = repairEditId; } // Find the correct edit corresponding to the CURRENT VALUE of the cell @@ -2380,7 +2432,9 @@ export class CodexCellDocument implements vscode.CustomDocument { // If we didn't find a value edit that matches current value, create one so validation history is consistent if (targetEditIndex === -1) { const currentTimestamp = Date.now(); + const fallbackEditId = randomUUID(); cellToUpdate.metadata.edits.push({ + id: fallbackEditId, editMap: EditMapUtils.value(), value: cellToUpdate.value, timestamp: currentTimestamp, @@ -2389,6 +2443,7 @@ export class CodexCellDocument implements vscode.CustomDocument { validatedBy: [], } as any); targetEditIndex = cellToUpdate.metadata.edits.length - 1; + cellToUpdate.metadata.activeEditId = fallbackEditId; } const latestEdit = cellToUpdate.metadata.edits[targetEditIndex]; diff --git a/src/providers/codexCellEditorProvider/utils/cellUtils.ts b/src/providers/codexCellEditorProvider/utils/cellUtils.ts index 8e0ac14be..e9d084325 100644 --- a/src/providers/codexCellEditorProvider/utils/cellUtils.ts +++ b/src/providers/codexCellEditorProvider/utils/cellUtils.ts @@ -65,6 +65,7 @@ export function convertCellToQuillContent(cell: CustomNotebookCellData): QuillCe cellContent: cell.value || "", cellType: cell.metadata?.type || CodexCellTypes.TEXT, editHistory: cell.metadata?.edits || [], + activeEditId: cell.metadata?.activeEditId, timestamps: cell.metadata?.data, cellLabel: cell.metadata?.cellLabel, merged: cell.metadata?.data?.merged, diff --git a/src/serializer.ts b/src/serializer.ts index e76406c03..60815dc19 100644 --- a/src/serializer.ts +++ b/src/serializer.ts @@ -2,8 +2,9 @@ import * as vscode from "vscode"; import { TextDecoder, TextEncoder } from "util"; -import { CodexNotebookAsJSONData, CustomNotebookCellData } from "../types"; +import { CodexNotebookAsJSONData, CustomNotebookCellData, CellValueOnDisk } from "../types"; import { formatJsonForNotebookFile } from "./utils/notebookFileFormattingUtils"; +import { resolveCellValue, isCellValueObject } from "./utils/cellValueResolver"; export interface CodexNotebookDocument extends vscode.NotebookDocument { cells: CustomNotebookCellData[]; @@ -41,6 +42,16 @@ export class CodexContentSerializer implements vscode.NotebookSerializer { try { raw = JSON.parse(contents); debug("Successfully parsed notebook contents", { cellCount: raw.cells.length }); + // Resolve CellValueOnDisk objects to strings at load time + for (const cell of raw.cells) { + if (isCellValueObject(cell.value)) { + const objValue = cell.value as unknown as CellValueOnDisk; + if (cell.metadata) { + cell.metadata.activeEditId = objValue.selectedEdit; + } + (cell as any).value = resolveCellValue(cell as any); + } + } return raw as CodexNotebookAsJSONData; } catch { debug("Failed to parse notebook contents, creating empty notebook"); @@ -87,10 +98,20 @@ export class CodexContentSerializer implements vscode.NotebookSerializer { // Preserve full metadata; only ensure id is kept const md: any = { ...(cell.metadata || {}) }; if (cell.metadata?.id) md.id = cell.metadata.id; + + // If activeEditId exists, save value as CellValueOnDisk object + let valueForDisk: string | CellValueOnDisk = cell.value; + if (md.activeEditId) { + valueForDisk = { + selectedEdit: md.activeEditId, + updatedAt: Date.now(), + }; + } + contents.cells.push({ kind: cell.kind, languageId: cell.languageId, - value: cell.value, + value: valueForDisk as any, metadata: md, }); } diff --git a/src/utils/cellValueResolver.ts b/src/utils/cellValueResolver.ts new file mode 100644 index 000000000..974b1d994 --- /dev/null +++ b/src/utils/cellValueResolver.ts @@ -0,0 +1,51 @@ +import { EditMapUtils } from "./editMapUtils"; + +/** + * Resolves a cell's value from raw .codex JSON data. + * Handles both legacy string format and new { selectedEdit, updatedAt } object format. + * + * @param cell A raw cell object from parsed .codex JSON + * @returns The resolved string value of the cell + */ +export function resolveCellValue(cell: { value: unknown; metadata?: { edits?: Array<{ id?: string; editMap: readonly string[]; value: unknown; preview?: boolean }>; activeEditId?: string } }): string { + const value = cell.value; + + // Legacy format: value is already a string + if (typeof value === "string") { + return value; + } + + // New object format: { selectedEdit: string, updatedAt: number } + if (value && typeof value === "object" && "selectedEdit" in value) { + const selectedEditId = (value as { selectedEdit: string }).selectedEdit; + const edits = cell.metadata?.edits; + if (edits && selectedEditId) { + const matchingEdit = edits.find( + (e) => e.id === selectedEditId && EditMapUtils.isValue(e.editMap) + ); + if (matchingEdit && typeof matchingEdit.value === "string") { + return matchingEdit.value; + } + } + } + + // Fallback: find the latest non-preview value edit + const edits = cell.metadata?.edits; + if (edits) { + for (let i = edits.length - 1; i >= 0; i--) { + const edit = edits[i]; + if (EditMapUtils.isValue(edit.editMap) && !edit.preview && typeof edit.value === "string") { + return edit.value; + } + } + } + + return ""; +} + +/** + * Checks if a cell value is in the new object format (CellValueOnDisk). + */ +export function isCellValueObject(value: unknown): value is { selectedEdit: string; updatedAt: number } { + return value !== null && typeof value === "object" && "selectedEdit" in value; +} diff --git a/src/utils/editMapUtils.ts b/src/utils/editMapUtils.ts index ca0ab749a..a4bec355d 100644 --- a/src/utils/editMapUtils.ts +++ b/src/utils/editMapUtils.ts @@ -29,6 +29,7 @@ type DeletedCorpusMarkerEditMap = ["deletedCorpusMarker"]; type DeletedFileEditMap = ["deletedFile"]; import { EditType } from "../../types/enums"; +import { generateCellIdFromHash } from "./uuidUtils"; // Utility functions for working with editMaps export const EditMapUtils = { @@ -213,21 +214,35 @@ export function deduplicateFileMetadataEdits( return []; } - // Create a Map to track unique edits by key: timestamp:editMap:value + // Dedup: prefer edit id as primary key, fall back to composite key for legacy const editMap = new Map(); edits.forEach((edit) => { if (edit.editMap && Array.isArray(edit.editMap)) { - const editMapKey = edit.editMap.join('.'); - // Properly serialize object values to avoid [object Object] issue - const valueKey = typeof edit.value === 'object' && edit.value !== null - ? JSON.stringify(edit.value) - : String(edit.value); - const key = `${edit.timestamp}:${editMapKey}:${valueKey}`; - - // Keep the first occurrence of a duplicate (file-level edits don't have validatedBy) + let key: string; + if (edit.id) { + key = edit.id; + } else { + const editMapKey = edit.editMap.join('.'); + const valueKey = typeof edit.value === 'object' && edit.value !== null + ? JSON.stringify(edit.value) + : String(edit.value); + key = `${edit.timestamp}:${editMapKey}:${valueKey}`; + } + if (!editMap.has(key)) { editMap.set(key, edit); + } else if (edit.validatedBy) { + // Merge validatedBy arrays on collision + const existing = editMap.get(key)!; + if (!existing.validatedBy) { + existing.validatedBy = []; + } + for (const v of edit.validatedBy) { + if (v && !existing.validatedBy.some((ev: any) => ev?.username === v.username)) { + existing.validatedBy.push(v); + } + } } } }); @@ -289,6 +304,7 @@ export function addMetadataEdit( // Create the new edit entry const newEdit = { + id: globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, editMap, value, timestamp: currentTimestamp, @@ -321,6 +337,7 @@ export function addProjectMetadataEdit( // Create the new edit entry const newEdit = { + id: globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, editMap, value, timestamp: currentTimestamp, @@ -332,3 +349,13 @@ export function addProjectMetadataEdit( metadata.edits.push(newEdit); metadata.edits = deduplicateFileMetadataEdits(metadata.edits); } + +/** + * Generates a deterministic edit ID from edit content using SHA-256. + * Input: `${value}:${timestamp}:${author}` → SHA-256 → UUID format. + * Used for backfilling IDs on existing edits during migration. + */ +export async function generateEditId(value: string, timestamp: number, author: string): Promise { + const input = `${value}:${timestamp}:${author}`; + return generateCellIdFromHash(input); +} diff --git a/types/index.d.ts b/types/index.d.ts index 63290cad0..238d1afbc 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -90,6 +90,7 @@ interface TranslationPair { // Generic EditHistoryItem that infers value type from editMap interface EditHistoryItem { + id?: string; editMap: TEditMap; value: EditMapValueType; timestamp: number; @@ -647,12 +648,16 @@ type EditMapValueType = // Conditional type for EditHistory that infers value type based on editMap type EditHistoryBase = { + id?: string; author: string; timestamp: number; type: import("./enums").EditType; validatedBy?: ValidationEntry[]; }; +/** On-disk representation of cell.value when using the edit-value-object format */ +type CellValueOnDisk = { selectedEdit: string; updatedAt: number }; + export type EditHistory = EditHistoryBase & { editMap: TEditMap; value: EditMapValueType; @@ -665,6 +670,7 @@ export type EditHistoryMutable = EditHistory; // Utility type for creating type-safe edits export type EditFor = { + id?: string; editMap: TEditMap; value: EditMapValueType; author: string; @@ -675,6 +681,7 @@ export type EditFor = { // File-level edit type for metadata edits (separate from EditHistory) export type FileEditHistory = { + id?: string; editMap: TEditMap; value: EditMapValueType; timestamp: number; @@ -684,6 +691,7 @@ export type FileEditHistory = { + id?: string; editMap: TEditMap; value: EditMapValueType; timestamp: number; @@ -710,6 +718,7 @@ type BaseCustomCellMetaData = { id: string; type: CodexCellTypes; edits: EditHistory[]; + activeEditId?: string; parentId?: string; // UUID of parent cell (for child cells like cues, paratext, etc.) isLocked?: boolean; }; @@ -914,6 +923,7 @@ interface QuillCellContent { cellContent: string; cellType: CodexCellTypes; editHistory: Array; + activeEditId?: string; timestamps?: Timestamps; cellLabel?: string; merged?: boolean; From 2f14411592b8744bfe93eac7910aafd60dd0f1af Mon Sep 17 00:00:00 2001 From: dadukhankevin Date: Tue, 3 Mar 2026 13:46:19 -0600 Subject: [PATCH 2/3] fix tests for cell value object format Update test assertions to handle CellValueOnDisk object format on disk: - codexCellEditor.test.ts: check edit value via selectedEdit lookup - codexCellEditorProvider.test.ts: same pattern for disk persistence - editMapUtils.test.ts: duplicate edit needs same id for dedup - providerMergeResolve.test.ts: merged cell.value is now an object Co-Authored-By: Claude Opus 4.6 --- src/test/suite/codexCellEditor.test.ts | 8 +++++++- src/test/suite/codexCellEditorProvider.test.ts | 8 +++++++- src/test/suite/editMapUtils.test.ts | 5 +++-- src/test/suite/providerMergeResolve.test.ts | 8 +++++++- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/test/suite/codexCellEditor.test.ts b/src/test/suite/codexCellEditor.test.ts index b06b472a4..f2d676a50 100644 --- a/src/test/suite/codexCellEditor.test.ts +++ b/src/test/suite/codexCellEditor.test.ts @@ -247,7 +247,13 @@ suite("CodexCellEditorProvider Test Suite", () => { assert.ok(cellAfter, "Cell should still exist after save"); // Assert value reflects the USER_EDIT (not the INITIAL_IMPORT) - assert.strictEqual(cellAfter.value, newValue, "Cell value should be updated to the user edit value"); + // On disk, cell.value is now a CellValueOnDisk object when activeEditId is set + if (cellAfter.metadata.activeEditId) { + const matchingEdit = (cellAfter.metadata.edits || []).find((e: any) => e.id === cellAfter.value.selectedEdit); + assert.strictEqual(matchingEdit?.value, newValue, "Selected edit value should be the user edit value"); + } else { + assert.strictEqual(cellAfter.value, newValue, "Cell value should be updated to the user edit value"); + } // Assert edits include INITIAL_IMPORT followed by USER_EDIT, with timestamp ordering const edits = cellAfter.metadata.edits || []; diff --git a/src/test/suite/codexCellEditorProvider.test.ts b/src/test/suite/codexCellEditorProvider.test.ts index c1eee0c4f..aea0553fb 100644 --- a/src/test/suite/codexCellEditorProvider.test.ts +++ b/src/test/suite/codexCellEditorProvider.test.ts @@ -244,11 +244,17 @@ suite("CodexCellEditorProvider Test Suite", () => { const diskBuf = await vscode.workspace.fs.readFile(document.uri); const diskJson = JSON.parse(new TextDecoder().decode(diskBuf)); const diskCell = diskJson.cells.find((c: any) => c.metadata.id === cellId); - assert.strictEqual(diskCell.value, newValue, "On disk: user edit value should persist"); + // On disk, cell.value is now a CellValueOnDisk object when activeEditId is set const editsOnDisk = diskCell.metadata.edits || []; const lastValueEdit = [...editsOnDisk].reverse().find((e: any) => JSON.stringify(e.editMap) === JSON.stringify(["value"])); assert.ok(lastValueEdit, "On disk: should have a value edit entry"); assert.strictEqual(lastValueEdit?.type, "user-edit", "On disk: latest value edit should be user-edit"); + if (diskCell.metadata.activeEditId) { + assert.strictEqual(diskCell.value.selectedEdit, diskCell.metadata.activeEditId, "On disk: value.selectedEdit should match activeEditId"); + assert.strictEqual(lastValueEdit.value, newValue, "On disk: edit value should contain the user edit text"); + } else { + assert.strictEqual(diskCell.value, newValue, "On disk: user edit value should persist"); + } }); test("resolveCustomEditor sets up message passing", async () => { diff --git a/src/test/suite/editMapUtils.test.ts b/src/test/suite/editMapUtils.test.ts index 4150b3d13..fabc6a029 100644 --- a/src/test/suite/editMapUtils.test.ts +++ b/src/test/suite/editMapUtils.test.ts @@ -359,14 +359,15 @@ suite("editMapUtils Test Suite", () => { const firstEdit = metadata.edits![0]; const firstTimestamp = firstEdit.timestamp; - // Manually add a duplicate with same timestamp + // Manually add a duplicate with same timestamp and same id metadata.edits!.push({ + id: firstEdit.id, editMap: EditMapUtils.projectName(), value: testProjectName, timestamp: firstTimestamp, type: EditType.USER_EDIT, author: testAuthor, - }); + } as any); assert.strictEqual(metadata.edits!.length, 2, "Should have two edits before deduplication"); diff --git a/src/test/suite/providerMergeResolve.test.ts b/src/test/suite/providerMergeResolve.test.ts index a2b60b98c..7dbe978ec 100644 --- a/src/test/suite/providerMergeResolve.test.ts +++ b/src/test/suite/providerMergeResolve.test.ts @@ -125,7 +125,13 @@ suite("Provider + Merge Integration - multi-user multi-field edits", () => { assert.strictEqual(shared.metadata.cellLabel, latestLabel); // Value should be from the latest value edit (ours v2) - assert.strictEqual(shared.value, ourLatestValue); + // After merge, value may be a CellValueOnDisk object or a string + if (typeof shared.value === "object" && shared.value?.selectedEdit) { + const matchingEdit = (shared.metadata.edits || []).find((e: any) => e.id === shared.value.selectedEdit); + assert.strictEqual(matchingEdit?.value, ourLatestValue, "Selected edit value should match latest"); + } else { + assert.strictEqual(shared.value, ourLatestValue); + } // Timestamps checks skipped: only relevant for timestamped content types From 960b6a4eb41e6cbc19107aa3206f5f3eeffd9a1a Mon Sep 17 00:00:00 2001 From: dadukhankevin Date: Tue, 3 Mar 2026 14:32:57 -0600 Subject: [PATCH 3/3] fix: resolve CellValueOnDisk in CodexCellDocument constructor CodexCellDocument reads .codex files directly via JSON.parse, bypassing the serializer. When cell.value is saved as { selectedEdit, updatedAt } on disk, the constructor needs to resolve it to a string just like the serializer does, otherwise downstream code breaks. Co-Authored-By: Claude Opus 4.6 --- .../codexCellEditorProvider/codexDocument.ts | 13 +++++++++++++ src/serializer.ts | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexDocument.ts b/src/providers/codexCellEditorProvider/codexDocument.ts index 1fe659181..766f401ab 100644 --- a/src/providers/codexCellEditorProvider/codexDocument.ts +++ b/src/providers/codexCellEditorProvider/codexDocument.ts @@ -15,8 +15,10 @@ import { MilestoneIndex, MilestoneInfo, CustomCellMetaData, + CellValueOnDisk, } from "../../../types"; import { EditMapUtils, deduplicateFileMetadataEdits } from "../../utils/editMapUtils"; +import { resolveCellValue, isCellValueObject } from "../../utils/cellValueResolver"; import { CodexCellTypes, EditType } from "../../../types/enums"; import { getAuthApi } from "@/extension"; import { randomUUID } from "crypto"; @@ -122,6 +124,17 @@ export class CodexCellDocument implements vscode.CustomDocument { this._documentData.cells.length ); + // Resolve CellValueOnDisk objects to strings at load time + for (const cell of this._documentData.cells) { + if (isCellValueObject(cell.value)) { + const objValue = cell.value as unknown as CellValueOnDisk; + if (cell.metadata) { + cell.metadata.activeEditId = objValue.selectedEdit; + } + (cell as any).value = resolveCellValue(cell as any); + } + } + // Initialize validatedBy arrays to ensure proper format this.initializeValidatedByArrays(); diff --git a/src/serializer.ts b/src/serializer.ts index 60815dc19..9205664ee 100644 --- a/src/serializer.ts +++ b/src/serializer.ts @@ -53,8 +53,8 @@ export class CodexContentSerializer implements vscode.NotebookSerializer { } } return raw as CodexNotebookAsJSONData; - } catch { - debug("Failed to parse notebook contents, creating empty notebook"); + } catch (e) { + console.error("[CodexSerializer] Failed to parse/resolve notebook contents:", e); raw = { cells: [], metadata: {} }; } // Create array of Notebook cells for the VS Code API from file contents