diff --git a/.gitignore b/.gitignore index 8e22f19d4..44c9fa075 100644 --- a/.gitignore +++ b/.gitignore @@ -5,13 +5,11 @@ node_modules /package-lock.json pnpm-lock.yaml __pycache__ -webviews/editable-react-table/node_modules /webviews/commentsWebview .DS_Store package-lock.json .project/complete_drafts.txt .project/indexes.sqlite -.project/dictionary.sqlite .wdio-vscode-service AGENTS.md # TypeScript build cache diff --git a/.vscodeignore b/.vscodeignore index 59f43a200..a01287586 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -2,22 +2,21 @@ * */** -# Allowlist what we need -!out/** -!out/sqldb/** +# Allowlist what we need (only extension output, NOT test bundles) +!out/*.js +!out/*.js.map +!out/node_modules/** !assets/** # !servers/** !webviews/codex-webviews/dist/** !webviews/codex-webviews/src/assets/** -!webviews/editable-react-table/dist/** !src/assets/reset.css # vscode.css was removed in favor of Tailwind CSS !src/assets/bible_data_with_vref_keys.json.gz !**/*.svg !node_modules/@vscode/codicons/dist/** -!node_modules/vscode-languageserver-textdocument !node_modules/vscode-uri !CHANGELOG.md diff --git a/README.md b/README.md index b8964b409..b3f17ee1a 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,6 @@ You can also use the "Create Codex Notebook" command to generate a new Codex Not - **Search Passages**: View search passages to compare translations. - **Comments**: Manage and view comments on your translations. - **Scripture Explorer**: Navigate through your scripture files easily. -- **Dictionary Table**: Access a comprehensive dictionary for translation help. ### Configuration @@ -85,16 +84,6 @@ codex-webviews % pnpm i codex-webviews % pnpm run build:all -## build the editable-react-table -#FIXME: if you get this error - -# you may have to do: -# `pnpm add @types/react` -# `pnpm add @types/react-dom` -# and then run build command again -dictionary-side-panel % cd ../editable-react-table -editable-react-table % pnpm i -editable-react-table % pnpm run build - # Now, let's go back to the root of the project and start the extension ChatSideBar % cd ../.. codex-editor % code . # this opens the project in VS Code, but you can also open it manually by opening VS Code and opening the extension folder you cloned diff --git a/docs/AB_TESTING.md b/docs/AB_TESTING.md index 2f0766eb7..f934b75d6 100644 --- a/docs/AB_TESTING.md +++ b/docs/AB_TESTING.md @@ -21,7 +21,6 @@ A/B testing in Codex shows two translation suggestions side‑by‑side once in Change these in VS Code Settings → Extensions → Codex Editor. ## Results & privacy -- Local log: Each choice is appended to `files/ab-test-results.jsonl` in your workspace (newline‑delimited JSON). - Win rates: The editor may compute simple win‑rates by variant label and show them in the chooser. - Network: If analytics posting is enabled in code, the extension may attempt to send anonymized A/B summaries to a configured endpoint. If your environment blocks network access, the extension continues without error. diff --git a/docs/merge-strategy.md b/docs/merge-strategy.md index 55154386f..e0cebe9e8 100644 --- a/docs/merge-strategy.md +++ b/docs/merge-strategy.md @@ -21,35 +21,19 @@ This document outlines the strategy for resolving merge conflicts in Codex proje - **Files**: - `metadata.json` - - `chat-threads.json` - - `files/chat_history.jsonl` - - `files/silver_path_memories.json` - - `files/smart_passages_memories.json` - - `.project/dictionary.sqlite` - **Strategy**: Keep newest version (timestamp-based override) ### 3. Mergeable JSON Arrays - **Files**: - `.project/comments.json` - - `files/project.dictionary` - **Strategy**: 1. Parse both versions as JSON arrays 2. Combine arrays 3. Deduplicate by thread ID and comment content 4. Preserve all unique threads and comments -### 4. Special JSON Merges - -- **Files**: - - `files/smart_edits.json` -- **Strategy**: - 1. Parse both versions - 2. Merge based on edit timestamps - 3. Preserve all unique edits - 4. Deduplicate identical edit operations - -### 5. Source Files (Read-only) +### 4. Source Files (Read-only) - **Location**: `.project/sourceTexts/*.source` - **Strategy**: Keep newest version (conflicts unlikely as these are typically read-only) diff --git a/package.json b/package.json index ccef3077b..6525f4757 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codex-editor-extension", "displayName": "Codex Translation Editor", - "description": "Support for `.codex` notebooks and translation source files. Includes Codex Language Server for translation drafting and checking diagnostics and features.", + "description": "Support for `.codex` notebooks and translation source files.", "publisher": "project-accelerate", "homepage": "https://codex-editor.gitbook.io/", "repository": { @@ -114,20 +114,8 @@ ] }, "menus": { - "view/title": [ - { - "command": "dictionaryTable.showDictionaryTable", - "when": "view == dictionaryTable", - "group": "navigation" - } - ], - "view/item/context": [ - { - "command": "dictionaryTable.showDictionaryTable", - "when": "view == dictionaryTable", - "group": "navigation" - } - ], + "view/title": [], + "view/item/context": [], "editor/title": [ { "command": "codex-editor-extension.scm.stageAndCommitAll", @@ -187,10 +175,6 @@ "title": "Migrate Audio Files (.x-m4a to .m4a)", "category": "Codex Editor" }, - { - "command": "codex-editor-extension.JOSH_COMMAND", - "title": "JOSH_COMMAND" - }, { "command": "codex-editor-extension.forceReindex", "title": "Force Reindex", @@ -262,10 +246,6 @@ "title": "Set Global Line Numbers", "category": "Codex Project" }, - { - "command": "dictionaryTable.showDictionaryTable", - "title": "Dictionary Table: Show" - }, { "command": "translationNotes.openTnEditor", "title": "Open Translation Notes" @@ -396,20 +376,6 @@ "command": "codex-editor-extension.getTargetCellByCellId", "title": "Get Target Cell by Cell ID" }, - { - "command": "extension.lookupWord", - "title": "Look Up Word", - "category": "Dictionary" - }, - { - "command": "extension.importWiktionaryJSONL", - "title": "Import Wiktionary" - }, - { - "command": "frontier.showWordsView", - "title": "Frontier: View All Words", - "category": "Frontier" - }, { "command": "codex-project-manager.openAISettings", "title": "Open AI Settings", @@ -627,11 +593,6 @@ "type": "string" } }, - "codex-project-manager.spellcheckIsEnabled": { - "type": "boolean", - "default": false, - "description": "Enable or disable spellcheck for the project" - }, "codex-project-manager.autoSyncEnabled": { "type": "boolean", "default": true, @@ -678,21 +639,6 @@ } } }, - { - "title": "Codex Extension Server", - "properties": { - "codex-editor-extension-server.enable": { - "type": "boolean", - "default": true, - "description": "Enable or disable the Codex Extension feature." - }, - "codex-editor-extension-server.primarySourceText": { - "type": "string", - "default": "", - "description": "The source text to use for the Codex Extension feature." - } - } - }, { "title": "Codex Extension", "properties": { @@ -885,12 +831,6 @@ "default": false, "description": "If activated, inline completion LLM prompts will be saved to the `copilot-messages.log` file in the project." }, - "codex-editor-extension.wordFrequencyThreshold": { - "title": "Word Frequency Threshold", - "type": "number", - "default": 50, - "description": "The minimum frequency of a word to be automatically added to the dictionary. Changing this value will trigger updates to the dictionary." - }, "codex-editor-extension.enableDoctrineGrading": { "title": "Enable Doctrine Grading", "type": "boolean", @@ -1007,16 +947,6 @@ } ], "priority": "default" - }, - { - "viewType": "codex.dictionaryEditor", - "displayName": "Dictionary Editor", - "selector": [ - { - "filenamePattern": "*.dictionary" - } - ], - "priority": "default" } ] }, @@ -1074,7 +1004,6 @@ "crypto-browserify": "^3.12.1", "encoding": "^0.1.13", "eslint": "^7.27.0", - "fts5-sql-bundle": "^1.0.2", "glob": "^7.1.4", "html-loader": "^5.1.0", "https-browserify": "^1.0.0", @@ -1099,15 +1028,12 @@ "typescript": "^5.4.2", "util": "^0.12.5", "vm-browserify": "^1.1.2", - "vscode-languageserver": "^9.0.1", - "vscode-languageserver-textdocument": "^1.0.12", "wdio-vscode-service": "^6.1.3", "webdriverio": "^8.45.0", "webpack": "^5.101.3", "webpack-cli": "^6.0.1" }, "dependencies": { - "@types/better-sqlite3": "^7.6.11", "@types/csv-parse": "^1.2.5", "@types/xml2js": "^0.4.14", "@vscode/codicons": "^0.0.36", @@ -1123,7 +1049,6 @@ "diff": "^7.0.0", "events": "^3.3.0", "fitty": "^2.4.2", - "fts5-sql-bundle": "^1.0.2", "hog-features": "^1.0.0", "i": "^0.3.7", "immutability-helper": "^3.1.1", @@ -1148,7 +1073,6 @@ "url": "^0.11.4", "usfm-grammar": "^2.3.1", "uuid": "^9.0.1", - "vscode-languageclient": "^9.0.1", "vscode-uri": "^3.0.8", "webvtt-parser": "^2.2.0", "xlsx": "^0.18.5", diff --git a/sharedUtils/index.ts b/sharedUtils/index.ts index f611787e2..ec2678f46 100644 --- a/sharedUtils/index.ts +++ b/sharedUtils/index.ts @@ -14,10 +14,6 @@ export const removeHtmlTags = (content: string) => { const footnotes = tempDiv.querySelectorAll('sup.footnote-marker, sup[data-footnote], sup'); footnotes.forEach(footnote => footnote.remove()); - // Remove spell check markup - const spellCheckElements = tempDiv.querySelectorAll('.spell-check-error, .spell-check-suggestion, [class*="spell-check"]'); - spellCheckElements.forEach(el => el.remove()); - // Replace paragraph end tags with spaces to preserve word boundaries tempDiv.innerHTML = tempDiv.innerHTML.replace(/<\/p>/gi, ' '); diff --git a/src/activationHelpers/contextAware/commands.ts b/src/activationHelpers/contextAware/commands.ts index ce23ecd6f..a12c95a3a 100644 --- a/src/activationHelpers/contextAware/commands.ts +++ b/src/activationHelpers/contextAware/commands.ts @@ -129,34 +129,6 @@ export async function registerCommands(context: vscode.ExtensionContext) { } ); - const openDictionaryCommand = vscode.commands.registerCommand( - "codex-editor-extension.openDictionaryFile", - async () => { - const workspaceUri = vscode.workspace.workspaceFolders?.[0]?.uri; - if (!workspaceUri) { - vscode.window.showErrorMessage( - "No workspace found. Please open a workspace first." - ); - return; - } - const dictionaryUri = vscode.Uri.joinPath(workspaceUri, "files", "project.dictionary"); - try { - // Ensure the files directory and dictionary file exist - const filesUri = vscode.Uri.joinPath(workspaceUri, "files"); - await vscode.workspace.fs.createDirectory(filesUri); - try { - await vscode.workspace.fs.stat(dictionaryUri); - } catch { - // Create the file if it doesn't exist - await vscode.workspace.fs.writeFile(dictionaryUri, new Uint8Array([])); - } - await vscode.commands.executeCommand("vscode.open", dictionaryUri); - } catch (error) { - vscode.window.showErrorMessage(`Failed to open dictionary: ${error}`); - } - } - ); - const createCodexNotebookCommand = vscode.commands.registerCommand( "codex-editor-extension.createCodexNotebook", async () => { @@ -374,7 +346,6 @@ export async function registerCommands(context: vscode.ExtensionContext) { codexKernel, openChapterCommand, openFileCommand, - openDictionaryCommand, createCodexNotebookCommand, setEditorFontCommand, exportCodexContentCommand, diff --git a/src/activationHelpers/contextAware/contentIndexes/fileSyncManager.ts b/src/activationHelpers/contextAware/contentIndexes/fileSyncManager.ts index 3e5b30c57..4d3d8ede6 100644 --- a/src/activationHelpers/contextAware/contentIndexes/fileSyncManager.ts +++ b/src/activationHelpers/contextAware/contentIndexes/fileSyncManager.ts @@ -3,11 +3,16 @@ import { createHash } from "crypto"; import { SQLiteIndexManager } from "./indexes/sqliteIndex"; import { FileData, readSourceAndTargetFiles } from "./indexes/fileReaders"; import { CodexCellTypes } from "../../../../types/enums"; +import { isDBShuttingDown } from "./indexes/sqliteIndexManager"; const DEBUG_MODE = false; const debug = (message: string, ...args: any[]) => { - DEBUG_MODE && debug(`[FileSyncManager] ${message}`, ...args); + DEBUG_MODE && console.log(`[FileSyncManager] ${message}`, ...args); }; +/** Maximum file size (bytes) that the sync manager will attempt to read into memory. */ +const MAX_SYNC_FILE_SIZE_MB = 50; +const MAX_SYNC_FILE_SIZE = MAX_SYNC_FILE_SIZE_MB * 1024 * 1024; + export interface FileSyncResult { totalFiles: number; syncedFiles: number; @@ -84,6 +89,12 @@ export class FileSyncManager { const syncStart = performance.now(); const { forceSync = false, progressCallback } = options; + // Bail early if the database is being torn down (project swap / deactivation) + if (isDBShuttingDown()) { + debug("[FileSyncManager] DB shutting down — aborting sync"); + return { totalFiles: 0, syncedFiles: 0, unchangedFiles: 0, errors: [], duration: 0, details: new Map() }; + } + debug(`[FileSyncManager] Starting optimized file sync (force: ${forceSync})...`); progressCallback?.("Initializing sync process...", 0); @@ -134,9 +145,13 @@ export class FileSyncManager { const fileMap = new Map(allFiles.map(f => [f.uri.fsPath, f])); const filesToProcess = filesToSync.map(path => fileMap.get(path)).filter(Boolean) as FileData[]; - // Process files in optimized batches - const BATCH_SIZE = 10; // Process 10 files at a time - const batches = []; + // Process files in optimized batches: + // 1. Read file contents from disk in parallel (I/O-bound) + // 2. Write all DB changes in a single transaction per batch (CPU-bound) + // This avoids the "cannot start a transaction within a transaction" + // error that occurred when each parallel file sync opened its own transaction. + const BATCH_SIZE = 10; + const batches: FileData[][] = []; for (let i = 0; i < filesToProcess.length; i += BATCH_SIZE) { batches.push(filesToProcess.slice(i, i + BATCH_SIZE)); } @@ -145,41 +160,102 @@ export class FileSyncManager { let processedCount = 0; for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) { + // Check shutdown between batches so a project swap isn't blocked + if (isDBShuttingDown()) { + debug("[FileSyncManager] DB shutting down mid-sync — aborting remaining batches"); + break; + } + const batch = batches[batchIndex]; const progress = 20 + (batchIndex / batches.length) * 60; // Reserve 20% for cleanup progressCallback?.(`Syncing batch ${batchIndex + 1}/${batches.length} (${batch.length} files)...`, progress); - // Process batch in parallel for I/O operations, then sync to database - const batchResults = await Promise.allSettled( - batch.map(async (fileData): Promise<{ success: true; file: string; } | { success: false; file: string; error: string; }> => { - try { - await this.syncSingleFileOptimized(fileData); - return { success: true, file: fileData.id }; - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error); - return { success: false, file: fileData.id, error: errorMsg }; + // Phase 1: Read all files in this batch from disk in parallel (I/O) + const readResults = await Promise.allSettled( + batch.map(async (fileData) => { + const filePath = fileData.uri.fsPath; + const fileStat = await vscode.workspace.fs.stat(fileData.uri); + + // Guard: skip oversized files to avoid OOM + if (fileStat.size > MAX_SYNC_FILE_SIZE) { + throw new Error(`File too large (${(fileStat.size / 1024 / 1024).toFixed(1)}MB > ${MAX_SYNC_FILE_SIZE_MB}MB limit) — skipped`); } + + const fileContent = await vscode.workspace.fs.readFile(fileData.uri); + const contentHash = createHash("sha256").update(fileContent).digest("hex"); + return { fileData, filePath, fileStat, contentHash }; }) ); - // Process batch results - for (const result of batchResults) { + // Collect successfully-read files; log failures with their actual file paths + const readyFiles: Array<{ + fileData: FileData; + filePath: string; + fileStat: vscode.FileStat; + contentHash: string; + }> = []; + + for (let i = 0; i < readResults.length; i++) { + const result = readResults[i]; if (result.status === 'fulfilled') { - if (result.value.success) { - syncedFiles++; - debug(`[FileSyncManager] Synced file: ${result.value.file}`); - } else { - errors.push({ - file: result.value.file, - error: result.value.error || 'Unknown error during sync' - }); - } + readyFiles.push(result.value); } else { - errors.push({ file: 'unknown', error: result.reason }); + errors.push({ file: batch[i].id, error: String(result.reason) }); + } + } + + // Phase 2: Write all read files to the database in a single transaction. + // Track a local counter so we only credit syncedFiles after the commit + // succeeds — a rollback means nothing was actually persisted. + // + // Per-file errors are caught (not rethrown) so the batch transaction still + // commits successfully for the files that did work. This is safe because + // writeSingleFileToDB calls updateSyncMetadata as its *last* step — if a + // file fails partway through, no sync_metadata row is written for it, so the + // next sync will re-process it (self-healing). The trade-off is that partial + // cell data for a failed file may briefly exist in the DB until the next sync + // overwrites it via INSERT OR REPLACE. + if (readyFiles.length > 0) { + let batchSynced = 0; + try { + // Use runInTransactionWithRetry for batch operations — + // if another connection or process holds a lock, we retry + // with exponential backoff instead of failing immediately. + await this.sqliteIndex.runInTransactionWithRetry(async () => { + for (const { fileData, filePath, fileStat, contentHash } of readyFiles) { + try { + await this.writeSingleFileToDB(fileData, filePath, fileStat, contentHash); + batchSynced++; + debug(`[FileSyncManager] Synced file: ${fileData.id}`); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + errors.push({ file: fileData.id, error: errorMsg }); + } + } + }); + // Transaction committed — count these files + syncedFiles += batchSynced; + } catch (txError) { + // Transaction rolled back — none of these files were committed + const errorMsg = txError instanceof Error ? txError.message : String(txError); + for (const { fileData } of readyFiles) { + errors.push({ file: fileData.id, error: errorMsg }); + } } } processedCount += batch.length; + + // Periodic WAL checkpoint every 5 batches during large syncs to keep + // the WAL file bounded and flush data to the main file. This ensures + // data survives a force-quit even if dispose() never runs. + if (batches.length >= 5 && (batchIndex + 1) % 5 === 0) { + try { + await this.sqliteIndex.walCheckpoint(); + } catch { + // Non-critical — WAL will be checkpointed eventually + } + } } // Cleanup sync metadata for files that no longer exist @@ -227,102 +303,79 @@ export class FileSyncManager { } /** - * Optimized single file sync with reduced I/O operations + * Write a single file's data to the database. + * Must be called within an existing transaction (no BEGIN/COMMIT here). */ - private async syncSingleFileOptimized(fileData: FileData): Promise { - const filePath = fileData.uri.fsPath; + private async writeSingleFileToDB( + fileData: FileData, + filePath: string, + fileStat: vscode.FileStat, + contentHash: string + ): Promise { const fileType = filePath.includes('.source') ? 'source' : 'codex'; - try { - // Batch file operations - const [fileStat, fileContent] = await Promise.all([ - vscode.workspace.fs.stat(fileData.uri), - vscode.workspace.fs.readFile(fileData.uri) - ]); - - const contentHash = createHash("sha256").update(fileContent).digest("hex"); - - // Use synchronous database operations within a transaction for speed - await this.sqliteIndex.runInTransaction(() => { - // Update/insert the file in the main files table - const fileId = this.sqliteIndex.upsertFileSync( - filePath, - fileType, - fileStat.mtime - ); - - // Calculate logical line positions for all non-paratext cells (1-indexed) - let logicalLinePosition = 1; - - // Process all cells in the file using sync operations - for (const cell of fileData.cells) { - const cellId = cell.metadata?.id || `${fileData.id}_${fileData.cells.indexOf(cell)}`; - const isParatext = cell.metadata?.type === "paratext"; - const isMilestone = cell.metadata?.type === CodexCellTypes.MILESTONE; - const hasContent = cell.value && cell.value.trim() !== ""; - - // Check if this is a child cell (has parentId in metadata) - const isChildCell = cell.metadata?.parentId !== undefined; - - // Calculate line number for database storage - let lineNumberForDB: number | null = null; - - if (!isParatext && !isMilestone && !isChildCell) { - if (fileType === 'source') { - // Source cells: always store line numbers (they should always have content) - lineNumberForDB = logicalLinePosition; - } else { - // Target cells: only store line number if cell has content - // But we still calculate the logical position for structural consistency - if (hasContent) { - lineNumberForDB = logicalLinePosition; - } - // If no content, lineNumberForDB stays null but logical position still increments - } - - // Always increment logical position for non-paratext, non-milestone, non-child cells - // This ensures stable line numbering even as cells get translated - logicalLinePosition++; + // Update/insert the file in the main files table (pass the real content hash) + const fileId = await this.sqliteIndex.upsertFileSync( + filePath, + fileType, + fileStat.mtime, + contentHash + ); + + // Calculate logical line positions for all non-paratext cells (1-indexed) + let logicalLinePosition = 1; + + // Process all cells in the file using sync operations + for (const cell of fileData.cells) { + const cellId = cell.metadata?.id || `${fileData.id}_${fileData.cells.indexOf(cell)}`; + const isParatext = cell.metadata?.type === "paratext"; + const isMilestone = cell.metadata?.type === CodexCellTypes.MILESTONE; + const hasContent = cell.value && cell.value.trim() !== ""; + + // Check if this is a child cell (has parentId in metadata) + const isChildCell = cell.metadata?.parentId !== undefined; + + // Calculate line number for database storage + let lineNumberForDB: number | null = null; + + if (!isParatext && !isMilestone && !isChildCell) { + if (fileType === 'source') { + // Source cells: always store line numbers (they should always have content) + lineNumberForDB = logicalLinePosition; + } else { + // Target cells: only store line number if cell has content + // But we still calculate the logical position for structural consistency + if (hasContent) { + lineNumberForDB = logicalLinePosition; } - // Paratext, milestone, and child cells: no line numbers, no position increment - - this.sqliteIndex.upsertCellSync( - cellId, - fileId, - fileType === 'source' ? 'source' : 'target', - cell.value, - lineNumberForDB ?? undefined, // Convert null to undefined for method signature compatibility - cell.metadata, - cell.value // raw content same as value for now - ); + // If no content, lineNumberForDB stays null but logical position still increments } - // Update sync metadata (this could be async but we'll keep it in transaction) - const stmt = this.sqliteIndex.database?.prepare(` - INSERT INTO sync_metadata (file_path, file_type, content_hash, file_size, last_modified_ms, last_synced_ms) - VALUES (?, ?, ?, ?, ?, strftime('%s', 'now') * 1000) - ON CONFLICT(file_path) DO UPDATE SET - content_hash = excluded.content_hash, - file_size = excluded.file_size, - last_modified_ms = excluded.last_modified_ms, - last_synced_ms = strftime('%s', 'now') * 1000, - updated_at = strftime('%s', 'now') * 1000 - `); - - if (stmt) { - try { - stmt.bind([filePath, fileType, contentHash, fileStat.size, fileStat.mtime]); - stmt.step(); - } finally { - stmt.free(); - } - } - }); - - } catch (error) { - console.error(`[FileSyncManager] Error in optimized sync for file ${filePath}:`, error); - throw error; + // Always increment logical position for non-paratext, non-milestone, non-child cells + // This ensures stable line numbering even as cells get translated + logicalLinePosition++; + } + // Paratext, milestone, and child cells: no line numbers, no position increment + + await this.sqliteIndex.upsertCellSync( + cellId, + fileId, + fileType === 'source' ? 'source' : 'target', + cell.value, + lineNumberForDB ?? undefined, // Convert null to undefined for method signature compatibility + cell.metadata, + cell.value // raw content same as value for now + ); } + + // Update sync metadata via the public API (not direct DB access) + await this.sqliteIndex.updateSyncMetadata( + filePath, + fileType, + contentHash, + fileStat.size, + fileStat.mtime + ); } /** @@ -449,14 +502,28 @@ export class FileSyncManager { const fileMap = new Map(requestedFiles.map(f => [f.uri.fsPath, f])); const filesToProcess = filesToSync.map(path => fileMap.get(path)).filter(Boolean) as FileData[]; - // Process files with progress tracking + // Process files with progress tracking (sequential — one transaction per file) for (let i = 0; i < filesToProcess.length; i++) { const fileData = filesToProcess[i]; + const filePath = fileData.uri.fsPath; const progress = 30 + (i / filesToProcess.length) * 60; // Reserve 30% start, 10% cleanup progressCallback?.(`Syncing ${i + 1}/${filesToProcess.length}: ${fileData.id}`, progress); try { - await this.syncSingleFileOptimized(fileData); + // Read file I/O — stat first so we can guard oversized files + const fileStat = await vscode.workspace.fs.stat(fileData.uri); + if (fileStat.size > MAX_SYNC_FILE_SIZE) { + console.warn(`[FileSyncManager] Skipping oversized file (${(fileStat.size / 1024 / 1024).toFixed(1)}MB): ${filePath}`); + errors.push({ file: fileData.id, error: `File too large (${(fileStat.size / 1024 / 1024).toFixed(1)}MB)` }); + continue; + } + const fileContent = await vscode.workspace.fs.readFile(fileData.uri); + const contentHash = createHash("sha256").update(fileContent).digest("hex"); + + // Write to DB in a transaction with retry for SQLITE_BUSY resilience + await this.sqliteIndex.runInTransactionWithRetry(async () => { + await this.writeSingleFileToDB(fileData, filePath, fileStat, contentHash); + }); syncedFiles++; debug(`[FileSyncManager] Synced targeted file: ${fileData.id}`); } catch (error) { @@ -466,7 +533,25 @@ export class FileSyncManager { } } - // Cleanup and finalize + // Cleanup sync metadata for files that were requested but no longer exist. + // Without this, deleted files leave orphaned metadata rows until the next + // full sync. Use the full project file list so we catch all deletions. + if (missingPaths.length > 0) { + progressCallback?.("Cleaning up obsolete metadata...", 90); + try { + const { sourceFiles: allSource, targetFiles: allTarget } = await readSourceAndTargetFiles(); + const allProjectPaths = [...allSource, ...allTarget].map(f => f.uri.fsPath); + const removedCount = await this.sqliteIndex.cleanupSyncMetadata(allProjectPaths); + if (removedCount > 0) { + debug(`[FileSyncManager] Cleaned up ${removedCount} obsolete sync records during targeted sync`); + } + } catch (cleanupError) { + console.warn("[FileSyncManager] Error cleaning up sync metadata during targeted sync:", cleanupError); + // Non-fatal — orphaned metadata will be cleaned up on next full sync + } + } + + // Finalize progressCallback?.("Finalizing targeted sync...", 95); await this.sqliteIndex.forceSave(); diff --git a/src/activationHelpers/contextAware/contentIndexes/indexes/index.ts b/src/activationHelpers/contextAware/contentIndexes/indexes/index.ts index 81ab8027a..0b9920df8 100644 --- a/src/activationHelpers/contextAware/contentIndexes/indexes/index.ts +++ b/src/activationHelpers/contextAware/contentIndexes/indexes/index.ts @@ -24,22 +24,14 @@ import { import { SQLiteIndexManager, CURRENT_SCHEMA_VERSION } from "./sqliteIndex"; import { SearchManager, SearchAlgorithmType } from "../searchAlgorithms"; -import { - initializeWordsIndex, - getWordFrequencies, - getWordsAboveThreshold, - WordOccurrence, -} from "./wordsIndex"; import { initializeFilesIndex, getFilePairs, getWordCountStats, FileInfo } from "./filesIndex"; import { updateCompleteDrafts } from "../indexingUtils"; import { readSourceAndTargetFiles } from "./fileReaders"; -import { debounce } from "lodash"; import { MinimalCellResult, TranslationPair } from "../../../../../types"; import { getNotebookMetadataManager } from "../../../../utils/notebookMetadataManager"; import { updateSplashScreenTimings } from "../../../../providers/SplashScreen/register"; import { FileSyncManager, FileSyncResult } from "../fileSyncManager"; - -type WordFrequencyMap = Map; +import { getSQLiteIndexManager } from "./sqliteIndexManager"; /** * Show AI learning progress notification - the core UX for index rebuilds @@ -133,8 +125,9 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { const savedRebuildState = context.globalState.get('indexRebuildState'); if (savedRebuildState) { rebuildState = { ...rebuildState, ...savedRebuildState }; - // Reset rebuild progress flag on startup - rebuildState.rebuildInProgress = false; + // Keep rebuildInProgress as-is for now — we use it below as a hint + // that the previous session was interrupted. It will be reset after + // the rebuild decision is made. } } @@ -153,7 +146,6 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { // since FileSyncManager indexes all content into the main database const sourceTextIndex = indexManager; - let wordsIndex: WordFrequencyMap = new Map(); let filesIndex: Map = new Map(); await metadataManager.initialize(); @@ -196,9 +188,11 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { /** * Update rebuild state and persist to storage */ - function updateRebuildState(updates: Partial) { + async function updateRebuildState(updates: Partial): Promise { + // In-memory update is synchronous — critical for the check-then-set invariant + // in smartRebuildIndexes (no await between isRebuildAllowed and this call). rebuildState = { ...rebuildState, ...updates }; - context.globalState.update('indexRebuildState', rebuildState); + await context.globalState.update('indexRebuildState', rebuildState); } /** @@ -245,14 +239,17 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { async function smartRebuildIndexes(reason: string, isForced: boolean = false): Promise { debug(`[Index] Starting smart rebuild: ${reason} (forced: ${isForced})`); - // Check consecutive rebuilds protection + // Check consecutive rebuilds protection. + // INVARIANT: No `await` between isRebuildAllowed and updateRebuildState below, + // so JS single-threaded execution guarantees no concurrent caller can slip between + // the check and the flag-set. const rebuildCheck = isRebuildAllowed(reason, isForced); if (!rebuildCheck.allowed) { console.warn(`[Index] Skipping rebuild: ${rebuildCheck.reason}`); return; } - updateRebuildState({ + await updateRebuildState({ lastRebuildTime: Date.now(), lastRebuildReason: reason, rebuildInProgress: true, @@ -270,7 +267,6 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { debug("[Index] Forced rebuild - clearing existing indexes..."); await translationPairsIndex.removeAll(); await sourceTextIndex.removeAll(); - wordsIndex.clear(); filesIndex.clear(); } @@ -298,12 +294,11 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { debug("[Index] Updating complementary indexes..."); try { - // Update words and files indexes - const { targetFiles } = await readSourceAndTargetFiles(); - wordsIndex = await initializeWordsIndex(wordsIndex, targetFiles); + // Update files index filesIndex = await initializeFilesIndex(); // Update complete drafts + const { targetFiles } = await readSourceAndTargetFiles(); await updateCompleteDrafts(targetFiles); debug("[Index] Complementary indexes updated successfully"); @@ -312,16 +307,17 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { // Don't fail the entire rebuild for complementary index errors } - const finalDocCount = translationPairsIndex.documentCount; + const finalDocCount = await translationPairsIndex.getDocumentCount(); debug(`[Index] Smart sync rebuild complete - indexed ${finalDocCount} documents`); + const sourceDocCount = await sourceTextIndex.getDocumentCount(); statusBarHandler.updateIndexCounts( finalDocCount, - sourceTextIndex.documentCount + sourceDocCount ); // Reset consecutive rebuilds on successful completion - updateRebuildState({ + await updateRebuildState({ rebuildInProgress: false, consecutiveRebuilds: 0 }); @@ -338,7 +334,7 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { } catch (error) { console.error("Error in smart sync rebuild:", error); - updateRebuildState({ + await updateRebuildState({ rebuildInProgress: false }); throw error; @@ -351,7 +347,7 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { * Conservative validation that only rebuilds for critical issues */ async function validateIndexHealthConservatively(): Promise<{ isHealthy: boolean; criticalIssue?: string; }> { - const documentCount = translationPairsIndex.documentCount; + const documentCount = await translationPairsIndex.getDocumentCount(); // Check if database is empty - regardless of schema, recreation is faster than sync if (documentCount === 0) { @@ -387,17 +383,31 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { } } catch (error) { console.warn("[Index] Error during health check:", error); - // Don't fail health check on file reading errors + // Treat health-check errors as unhealthy — hiding failures can mask real problems + return { isHealthy: false, criticalIssue: `health check failed: ${error}` }; } return { isHealthy: true }; } + // Detect if the previous session was interrupted mid-rebuild. + // If rebuildInProgress was still true when we loaded state, the previous + // sync never completed — skip the health check (which may pass for a + // partially-indexed DB) and go straight to the sync check. + const previousRebuildWasInterrupted = rebuildState.rebuildInProgress; + if (previousRebuildWasInterrupted) { + console.log("[Index] Previous rebuild was interrupted — will run sync check regardless of health"); + } + // Now reset the flag so a new rebuild can track its own progress. + // Must persist to globalState — otherwise next startup still sees true. + await updateRebuildState({ rebuildInProgress: false }); + // Check database health and determine if rebuild is needed - const currentDocCount = translationPairsIndex.documentCount; + const currentDocCount = await translationPairsIndex.getDocumentCount(); const healthCheck = await validateIndexHealthConservatively(); - debug(`[Index] Health check: ${healthCheck.isHealthy ? 'HEALTHY' : 'CRITICAL ISSUE'} - ${healthCheck.criticalIssue || 'OK'} (${currentDocCount} documents)`); + // Always log rebuild decisions to console so we can diagnose "always rebuilds" issues + console.log(`[Index] Health check: ${healthCheck.isHealthy ? 'HEALTHY' : 'CRITICAL ISSUE'} - ${healthCheck.criticalIssue || 'OK'} (${currentDocCount} documents)`); let needsRebuild = false; let rebuildReason = ''; @@ -405,6 +415,18 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { if (!healthCheck.isHealthy) { needsRebuild = true; rebuildReason = healthCheck.criticalIssue || 'health check failed'; + console.log(`[Index] Rebuild triggered by HEALTH CHECK: ${rebuildReason}`); + } else if (previousRebuildWasInterrupted) { + // Health check passed but previous session was interrupted. + // The DB may be partially indexed — always run the sync check. + const changeCheck = await checkIfRebuildNeeded(); + if (changeCheck.needsRebuild) { + needsRebuild = true; + rebuildReason = `interrupted rebuild recovery: ${changeCheck.reason}`; + console.log(`[Index] Rebuild triggered by INTERRUPTED REBUILD RECOVERY: ${rebuildReason}`); + } else { + console.log(`[Index] Previous rebuild was interrupted but DB appears complete (${currentDocCount} documents)`); + } } else { // Health check passed - database is structurally sound // Check for file changes to determine if sync is needed @@ -412,11 +434,14 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { if (changeCheck.needsRebuild) { needsRebuild = true; rebuildReason = changeCheck.reason; + console.log(`[Index] Rebuild triggered by SYNC CHECK: ${rebuildReason}`); + } else { + console.log(`[Index] No rebuild needed — database is up to date with ${currentDocCount} documents`); } } if (needsRebuild) { - debug(`[Index] Rebuild needed: ${rebuildReason}`); + console.log(`[Index] Starting rebuild: ${rebuildReason}`); // Check if this is a critical issue that should rebuild automatically const isCritical = !healthCheck.isHealthy; @@ -426,7 +451,7 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { try { await showAILearningProgress(async (progress) => { await smartRebuildIndexes(rebuildReason, isCritical); - const finalCount = translationPairsIndex.documentCount; + const finalCount = await translationPairsIndex.getDocumentCount(); debug(`[Index] Rebuild completed with ${finalCount} documents`); // No need for completion messages - the progress notification handles it @@ -442,13 +467,15 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { } else { // Database is healthy and up to date debug(`[Index] Index is healthy and up to date with ${currentDocCount} documents`); + const translationDocCount = await translationPairsIndex.getDocumentCount(); + const sourceDocCount = await sourceTextIndex.getDocumentCount(); statusBarHandler.updateIndexCounts( - translationPairsIndex.documentCount, - sourceTextIndex.documentCount + translationDocCount, + sourceDocCount ); // Reset consecutive rebuilds since we didn't need to rebuild - updateRebuildState({ + await updateRebuildState({ consecutiveRebuilds: 0 }); } @@ -502,21 +529,26 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { if (!query) return []; // User cancelled the input showInfo = true; } - const results = await getTranslationPairsFromSourceCellQuery( - translationPairsIndex, - query, - k, - onlyValidated - ); - if (showInfo) { - const resultsString = results - .map((r: TranslationPair) => `${r.cellId}: ${r.sourceCell.content}`) - .join("\n"); - vscode.window.showInformationMessage( - `Found ${results.length} ${onlyValidated ? 'validated' : 'all'} results for query: ${query}\n${resultsString}` + try { + const results = await getTranslationPairsFromSourceCellQuery( + translationPairsIndex, + query, + k, + onlyValidated ); + if (showInfo) { + const resultsString = results + .map((r: TranslationPair) => `${r.cellId}: ${r.sourceCell.content}`) + .join("\n"); + vscode.window.showInformationMessage( + `Found ${results.length} ${onlyValidated ? 'validated' : 'all'} results for query: ${query}\n${resultsString}` + ); + } + return results; + } catch (error) { + console.error("Error searching source cells:", error); + return []; } - return results; } ); @@ -538,23 +570,28 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { showInfo = true; } - const manager = new SearchManager(translationPairsIndex); - const results = await manager.getTranslationPairsFromSourceCellQueryWithAlgorithm( - algorithm, - query, - k, - onlyValidated - ); - - if (showInfo) { - const resultsString = results - .map((r: TranslationPair) => `${r.cellId}: ${r.sourceCell.content}`) - .join("\n"); - vscode.window.showInformationMessage( - `(${algorithm}) Found ${results.length} ${onlyValidated ? 'validated' : 'all'} results for query: ${query}\n${resultsString}` + try { + const manager = new SearchManager(translationPairsIndex); + const results = await manager.getTranslationPairsFromSourceCellQueryWithAlgorithm( + algorithm, + query, + k, + onlyValidated ); + + if (showInfo) { + const resultsString = results + .map((r: TranslationPair) => `${r.cellId}: ${r.sourceCell.content}`) + .join("\n"); + vscode.window.showInformationMessage( + `(${algorithm}) Found ${results.length} ${onlyValidated ? 'validated' : 'all'} results for query: ${query}\n${resultsString}` + ); + } + return results; + } catch (error) { + console.error(`Error searching with algorithm ${algorithm}:`, error); + return []; } - return results; } ); @@ -569,20 +606,25 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { if (!cellId) return null; // User cancelled the input showInfo = true; } - debug( - `Executing getSourceCellByCellIdFromAllSourceCells for cellId: ${cellId}` - ); - const results = await getSourceCellByCellIdFromAllSourceCells( - sourceTextIndex, - cellId - ); - debug("getSourceCellByCellIdFromAllSourceCells results:", results); - if (showInfo && results) { - vscode.window.showInformationMessage( - `Source cell for ${cellId}: ${results.content}` + try { + debug( + `Executing getSourceCellByCellIdFromAllSourceCells for cellId: ${cellId}` + ); + const results = await getSourceCellByCellIdFromAllSourceCells( + sourceTextIndex, + cellId ); + debug("getSourceCellByCellIdFromAllSourceCells results:", results); + if (showInfo && results) { + vscode.window.showInformationMessage( + `Source cell for ${cellId}: ${results.content}` + ); + } + return results; + } catch (error) { + console.error(`Error getting source cell for ${cellId}:`, error); + return null; } - return results; } ); @@ -597,66 +639,50 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { if (!cellId) return; // User cancelled the input showInfo = true; } - const results = await getTargetCellByCellId(translationPairsIndex, cellId); - if (showInfo && results) { - vscode.window.showInformationMessage( - `Target cell for ${cellId}: ${JSON.stringify(results)}` - ); + try { + const results = await getTargetCellByCellId(translationPairsIndex, cellId); + if (showInfo && results) { + vscode.window.showInformationMessage( + `Target cell for ${cellId}: ${JSON.stringify(results)}` + ); + } + return results; + } catch (error) { + console.error(`Error getting target cell for ${cellId}:`, error); + return null; } - return results; } ); const forceReindexCommand = vscode.commands.registerCommand( "codex-editor-extension.forceReindex", async () => { - await showAILearningProgress(async (progress) => { - await smartRebuildIndexes("manual force reindex command", true); - }); + try { + await showAILearningProgress(async (progress) => { + await smartRebuildIndexes("manual force reindex command", true); + }); + } catch (error) { + console.error("Error during force reindex:", error); + vscode.window.showErrorMessage("Reindex failed. Check the logs for details."); + } } ); const showIndexOptionsCommand = vscode.commands.registerCommand( "codex-editor-extension.showIndexOptions", async () => { - const option = await vscode.window.showQuickPick(["Force Reindex"], { - placeHolder: "Select an indexing option", - }); - - if (option === "Force Reindex") { - await smartRebuildIndexes("manual force reindex from options", true); - } - } - ); - - const getWordFrequenciesCommand = vscode.commands.registerCommand( - "codex-editor-extension.getWordFrequencies", - async (): Promise> => { - return getWordFrequencies(wordsIndex); - } - ); - - const refreshWordIndexCommand = vscode.commands.registerCommand( - "codex-editor-extension.refreshWordIndex", - async () => { - const { targetFiles } = await readSourceAndTargetFiles(); - wordsIndex = await initializeWordsIndex(new Map(), targetFiles); - debug("Word index refreshed"); - } - ); + try { + const option = await vscode.window.showQuickPick(["Force Reindex"], { + placeHolder: "Select an indexing option", + }); - const getWordsAboveThresholdCommand = vscode.commands.registerCommand( - "codex-editor-extension.getWordsAboveThreshold", - async () => { - const config = vscode.workspace.getConfiguration("codex-editor-extension"); - const threshold = config.get("wordFrequencyThreshold", 50); - if (wordsIndex.size === 0) { - const { targetFiles } = await readSourceAndTargetFiles(); - wordsIndex = await initializeWordsIndex(wordsIndex, targetFiles); + if (option === "Force Reindex") { + await smartRebuildIndexes("manual force reindex from options", true); + } + } catch (error) { + console.error("Error during reindex from options:", error); + vscode.window.showErrorMessage("Reindex failed. Check the logs for details."); } - const wordsAboveThreshold = await getWordsAboveThreshold(wordsIndex, threshold); - debug(`Words above threshold: ${wordsAboveThreshold}`); - return wordsAboveThreshold; } ); @@ -672,106 +698,116 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { showInfo = true; } - // Use the reliable database-direct search for complete translation pairs - let searchResults: any[] = []; - const replaceMode = options?.replaceMode || false; - - if (translationPairsIndex instanceof SQLiteIndexManager) { - if (replaceMode) { - // In replace mode, use the reliable searchCompleteTranslationPairsWithValidation - // Request a large limit since we'll filter to target-only matches - const searchLimit = Math.max(k * 10, 5000); - const allResults = await translationPairsIndex.searchCompleteTranslationPairsWithValidation( - query, - searchLimit, - options?.isParallelPassagesWebview || false, - false // onlyValidated - ); - - // Filter to only pairs where target content contains the query - const stripHtml = (html: string): string => { - let strippedText = html.replace(/<[^>]*>/g, ""); - strippedText = strippedText.replace(/  ?/g, " "); - strippedText = strippedText.replace(/&|<|>|"|'|"/g, ""); - strippedText = strippedText.replace(/&#\d+;/g, ""); - strippedText = strippedText.replace(/&[a-zA-Z]+;/g, ""); - return strippedText.toLowerCase(); - }; - - const queryLower = query.toLowerCase(); - const filteredResults: any[] = []; - for (const result of allResults) { - const targetContent = result.targetContent || ""; - if (!targetContent.trim()) continue; + try { + // Use the reliable database-direct search for complete translation pairs + let searchResults: any[] = []; + const replaceMode = options?.replaceMode || false; + + if (translationPairsIndex instanceof SQLiteIndexManager) { + if (replaceMode) { + // In replace mode, use the reliable searchCompleteTranslationPairsWithValidation + // Request a large limit since we'll filter to target-only matches + const searchLimit = Math.max(k * 10, 5000); + const allResults = await translationPairsIndex.searchCompleteTranslationPairsWithValidation( + query, + searchLimit, + options?.isParallelPassagesWebview || false, + false // onlyValidated + ); - const cleanTarget = stripHtml(targetContent); - if (cleanTarget.includes(queryLower)) { - filteredResults.push(result); - } + // Filter to only pairs where target content contains the query + const stripHtml = (html: string): string => { + let strippedText = html.replace(/<[^>]*>/g, ""); + strippedText = strippedText.replace(/  ?/g, " "); + strippedText = strippedText.replace(/&|<|>|"|'|"/g, ""); + strippedText = strippedText.replace(/&#\d+;/g, ""); + strippedText = strippedText.replace(/&[a-zA-Z]+;/g, ""); + return strippedText.toLowerCase(); + }; - if (filteredResults.length >= k) break; + const queryLower = query.toLowerCase(); + const filteredResults: any[] = []; + for (const result of allResults) { + const targetContent = result.targetContent || ""; + if (!targetContent.trim()) continue; + + const cleanTarget = stripHtml(targetContent); + if (cleanTarget.includes(queryLower)) { + filteredResults.push(result); + } + + if (filteredResults.length >= k) break; + } + searchResults = filteredResults; + } else { + // Use the new, reliable database-direct search with validation filtering + // For searchParallelCells, we always want only complete pairs (both source and target) + // and we can optionally filter by validation status if needed + searchResults = await translationPairsIndex.searchCompleteTranslationPairsWithValidation( + query, + k, + options?.isParallelPassagesWebview || false, // return raw content for webview display + false // onlyValidated - for now, show all complete pairs regardless of validation + ); } - searchResults = filteredResults; } else { - // Use the new, reliable database-direct search with validation filtering - // For searchParallelCells, we always want only complete pairs (both source and target) - // and we can optionally filter by validation status if needed - searchResults = await translationPairsIndex.searchCompleteTranslationPairsWithValidation( + console.warn("[searchParallelCells] Non-SQLite index detected, using fallback"); + // Fallback to old method for non-SQLite indexes + const results = await searchAllCells( + translationPairsIndex, + sourceTextIndex, query, k, - options?.isParallelPassagesWebview || false, // return raw content for webview display - false // onlyValidated - for now, show all complete pairs regardless of validation + false, + options ); + searchResults = results.slice(0, k); } - } else { - console.warn("[searchParallelCells] Non-SQLite index detected, using fallback"); - // Fallback to old method for non-SQLite indexes - const results = await searchAllCells( - translationPairsIndex, - sourceTextIndex, - query, - k, - false, - options - ); - searchResults = results.slice(0, k); - } - // Convert search results to TranslationPair format - const translationPairs: TranslationPair[] = searchResults.map((result) => ({ - cellId: result.cellId || result.cell_id, - sourceCell: { + // Convert search results to TranslationPair format + const translationPairs: TranslationPair[] = searchResults.map((result) => ({ cellId: result.cellId || result.cell_id, - content: result.sourceContent || result.content || "", - uri: result.uri || "", - line: result.line || 0, - }, - targetCell: { - cellId: result.cellId || result.cell_id, - content: result.targetContent || "", - uri: result.uri || "", - line: result.line || 0, - }, - })); - - if (showInfo) { - const resultsString = translationPairs - .map( - (r: TranslationPair) => - `${r.cellId}: Source: ${r.sourceCell.content}, Target: ${r.targetCell.content}` - ) - .join("\n"); - vscode.window.showInformationMessage( - `Found ${translationPairs.length} complete translation pairs for query: ${query}\n${resultsString}` - ); + sourceCell: { + cellId: result.cellId || result.cell_id, + content: result.sourceContent || result.content || "", + uri: result.uri || "", + line: result.line || 0, + }, + targetCell: { + cellId: result.cellId || result.cell_id, + content: result.targetContent || "", + uri: result.uri || "", + line: result.line || 0, + }, + })); + + if (showInfo) { + const resultsString = translationPairs + .map( + (r: TranslationPair) => + `${r.cellId}: Source: ${r.sourceCell.content}, Target: ${r.targetCell.content}` + ) + .join("\n"); + vscode.window.showInformationMessage( + `Found ${translationPairs.length} complete translation pairs for query: ${query}\n${resultsString}` + ); + } + return translationPairs; + } catch (error) { + console.error("Error searching parallel cells:", error); + return []; } - return translationPairs; } ); const searchSimilarCellIdsCommand = vscode.commands.registerCommand( "codex-editor-extension.searchSimilarCellIds", async (cellId: string) => { - return searchSimilarCellIds(translationPairsIndex, cellId); + try { + return await searchSimilarCellIds(translationPairsIndex, cellId); + } catch (error) { + console.error(`Error searching similar cell IDs for ${cellId}:`, error); + return []; + } } ); const getTranslationPairFromProjectCommand = vscode.commands.registerCommand( @@ -785,24 +821,29 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { if (!cellId) return; // User cancelled the input showInfo = true; } - const result = await getTranslationPairFromProject( - translationPairsIndex, - sourceTextIndex, - cellId, - options - ); - if (showInfo) { - if (result) { - vscode.window.showInformationMessage( - `Translation pair for ${cellId}: Source: ${result.sourceCell.content}, Target: ${result.targetCell.content}` - ); - } else { - vscode.window.showInformationMessage( - `No translation pair found for ${cellId}` - ); + try { + const result = await getTranslationPairFromProject( + translationPairsIndex, + sourceTextIndex, + cellId, + options + ); + if (showInfo) { + if (result) { + vscode.window.showInformationMessage( + `Translation pair for ${cellId}: Source: ${result.sourceCell.content}, Target: ${result.targetCell.content}` + ); + } else { + vscode.window.showInformationMessage( + `No translation pair found for ${cellId}` + ); + } } + return result; + } catch (error) { + console.error(`Error getting translation pair for ${cellId}:`, error); + return null; } - return result; } ); @@ -824,24 +865,29 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { }); if (!cellId) return null; // User cancelled the input } - const result = await findNextUntranslatedSourceCell( - sourceTextIndex, - translationPairsIndex, - query, - cellId - ); - if (showInfo) { - if (result) { - vscode.window.showInformationMessage( - `Next untranslated source cell: ${result.cellId}\nContent: ${result.content}` - ); - } else { - vscode.window.showInformationMessage( - "No untranslated source cell found matching the query." - ); + try { + const result = await findNextUntranslatedSourceCell( + sourceTextIndex, + translationPairsIndex, + query, + cellId + ); + if (showInfo) { + if (result) { + vscode.window.showInformationMessage( + `Next untranslated source cell: ${result.cellId}\nContent: ${result.content}` + ); + } else { + vscode.window.showInformationMessage( + "No untranslated source cell found matching the query." + ); + } } + return result; + } catch (error) { + console.error("Error finding next untranslated source cell:", error); + return null; } - return result; } ); @@ -863,117 +909,122 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { showInfo = true; } - const searchScope = options?.searchScope || "both"; - const selectedFiles = options?.selectedFiles || []; - const completeOnly = options?.completeOnly || false; - const isParallelPassagesWebview = options?.isParallelPassagesWebview || false; + try { + const searchScope = options?.searchScope || "both"; + const selectedFiles = options?.selectedFiles || []; + const completeOnly = options?.completeOnly || false; + const isParallelPassagesWebview = options?.isParallelPassagesWebview || false; - let results: TranslationPair[] = []; + let results: TranslationPair[] = []; - // When includeIncomplete=false, use optimized SQLite path for complete pairs only - // When includeIncomplete=true, use searchAllCells which adds source-only cells - if (!includeIncomplete && translationPairsIndex instanceof SQLiteIndexManager) { - // Determine search mode based on scope - // searchSourceOnly=true means only search source content - // searchSourceOnly=false means search both source and target - const searchSourceOnly = searchScope === "source"; + // When includeIncomplete=false, use optimized SQLite path for complete pairs only + // When includeIncomplete=true, use searchAllCells which adds source-only cells + if (!includeIncomplete && translationPairsIndex instanceof SQLiteIndexManager) { + // Determine search mode based on scope + // searchSourceOnly=true means only search source content + // searchSourceOnly=false means search both source and target + const searchSourceOnly = searchScope === "source"; - // Request extra results to account for post-filtering - const searchLimit = k * 2; + // Request extra results to account for post-filtering + const searchLimit = k * 2; - const searchResults = await translationPairsIndex.searchCompleteTranslationPairsWithValidation( - query, - searchLimit, - isParallelPassagesWebview, - completeOnly, // Pass through completeOnly for validation filtering - searchSourceOnly - ); + const searchResults = await translationPairsIndex.searchCompleteTranslationPairsWithValidation( + query, + searchLimit, + isParallelPassagesWebview, + completeOnly, // Pass through completeOnly for validation filtering + searchSourceOnly + ); - // Convert to TranslationPair format - let translationPairs: TranslationPair[] = searchResults.map((result) => ({ - cellId: result.cellId || result.cell_id, - sourceCell: { - cellId: result.cellId || result.cell_id, - content: result.sourceContent || result.content || "", - uri: result.uri || "", - line: result.line || 0, - }, - targetCell: { + // Convert to TranslationPair format + let translationPairs: TranslationPair[] = searchResults.map((result) => ({ cellId: result.cellId || result.cell_id, - content: result.targetContent || "", - uri: result.uri || "", - line: result.line || 0, - }, - })); - - // Filter out pairs with empty or minimal content - // Strip HTML and check for meaningful content (not just whitespace or very short text) - translationPairs = translationPairs.filter((pair) => { - const sourceText = stripHtml(pair.sourceCell.content || "").trim(); - const targetText = stripHtml(pair.targetCell.content || "").trim(); - // Require both source and target to have meaningful content (more than 3 chars) - return sourceText.length > 3 && targetText.length > 3; - }); - - // Apply searchScope content filtering - verify query actually appears in content - if (query.trim()) { - const queryLower = query.toLowerCase(); + sourceCell: { + cellId: result.cellId || result.cell_id, + content: result.sourceContent || result.content || "", + uri: result.uri || "", + line: result.line || 0, + }, + targetCell: { + cellId: result.cellId || result.cell_id, + content: result.targetContent || "", + uri: result.uri || "", + line: result.line || 0, + }, + })); + + // Filter out pairs with empty or minimal content + // Strip HTML and check for meaningful content (not just whitespace or very short text) translationPairs = translationPairs.filter((pair) => { - const cleanSource = stripHtml(pair.sourceCell.content || ""); - const cleanTarget = stripHtml(pair.targetCell.content || ""); - - if (searchScope === "source") { - return cleanSource.includes(queryLower); - } else if (searchScope === "target") { - return cleanTarget.includes(queryLower); - } else { - // "both" - query should appear in either source or target - return cleanSource.includes(queryLower) || cleanTarget.includes(queryLower); - } + const sourceText = stripHtml(pair.sourceCell.content || "").trim(); + const targetText = stripHtml(pair.targetCell.content || "").trim(); + // Require both source and target to have meaningful content (more than 3 chars) + return sourceText.length > 3 && targetText.length > 3; }); - } - // Apply selectedFiles filtering - if (selectedFiles.length > 0) { - translationPairs = translationPairs.filter((pair) => { - const sourceUri = pair.sourceCell?.uri || ""; - const targetUri = pair.targetCell?.uri || ""; - const normalizedSource = normalizeUri(sourceUri); - const normalizedTarget = normalizeUri(targetUri); - return selectedFiles.some((selectedUri: string) => { - const normalizedSelected = normalizeUri(selectedUri); - return normalizedSource === normalizedSelected || normalizedTarget === normalizedSelected; + // Apply searchScope content filtering - verify query actually appears in content + if (query.trim()) { + const queryLower = query.toLowerCase(); + translationPairs = translationPairs.filter((pair) => { + const cleanSource = stripHtml(pair.sourceCell.content || ""); + const cleanTarget = stripHtml(pair.targetCell.content || ""); + + if (searchScope === "source") { + return cleanSource.includes(queryLower); + } else if (searchScope === "target") { + return cleanTarget.includes(queryLower); + } else { + // "both" - query should appear in either source or target + return cleanSource.includes(queryLower) || cleanTarget.includes(queryLower); + } }); - }); + } + + // Apply selectedFiles filtering + if (selectedFiles.length > 0) { + translationPairs = translationPairs.filter((pair) => { + const sourceUri = pair.sourceCell?.uri || ""; + const targetUri = pair.targetCell?.uri || ""; + const normalizedSource = normalizeUri(sourceUri); + const normalizedTarget = normalizeUri(targetUri); + return selectedFiles.some((selectedUri: string) => { + const normalizedSelected = normalizeUri(selectedUri); + return normalizedSource === normalizedSelected || normalizedTarget === normalizedSelected; + }); + }); + } + + results = translationPairs.slice(0, k); + } else { + // Fallback for incomplete searches or non-SQLite indexes + results = await searchAllCells( + translationPairsIndex, + sourceTextIndex, + query, + k, + includeIncomplete, + options + ); } - results = translationPairs.slice(0, k); - } else { - // Fallback for incomplete searches or non-SQLite indexes - results = await searchAllCells( - translationPairsIndex, - sourceTextIndex, - query, - k, - includeIncomplete, - options - ); - } + debug(`Search results for "${query}":`, results); - debug(`Search results for "${query}":`, results); - - if (showInfo) { - const resultsString = results - .map((r) => { - const targetContent = r.targetCell.content || "(No target text)"; - return `${r.cellId}: Source: ${r.sourceCell.content}, Target: ${targetContent}`; - }) - .join("\n"); - vscode.window.showInformationMessage( - `Found ${results.length} cells for query: ${query}\n${resultsString}` - ); + if (showInfo) { + const resultsString = results + .map((r) => { + const targetContent = r.targetCell.content || "(No target text)"; + return `${r.cellId}: Source: ${r.sourceCell.content}, Target: ${targetContent}`; + }) + .join("\n"); + vscode.window.showInformationMessage( + `Found ${results.length} cells for query: ${query}\n${resultsString}` + ); + } + return results; + } catch (error) { + console.error("Error searching all cells:", error); + return []; } - return results; } ); @@ -1168,7 +1219,7 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { // Force a complete rebuild await smartRebuildIndexes("manual complete rebuild command", true); - const finalCount = translationPairsIndex.documentCount; + const finalCount = await translationPairsIndex.getDocumentCount(); debug(`[Index] Rebuild completed with ${finalCount} documents`); }); } @@ -1185,7 +1236,7 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { async () => { try { const { sourceFiles, targetFiles } = await readSourceAndTargetFiles(); - const currentDocCount = translationPairsIndex.documentCount; + const currentDocCount = await translationPairsIndex.getDocumentCount(); let statusMessage = `Index Status:\n`; statusMessage += `• Documents in index: ${currentDocCount}\n`; @@ -1209,7 +1260,7 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { statusMessage += `• Orphaned target cells: ${pairStats.orphanedTargetCells}\n`; // Run validation with fresh document count - const freshDocCount = translationPairsIndex.documentCount; + const freshDocCount = await translationPairsIndex.getDocumentCount(); const validationResult = await validateIndexHealthConservatively(); statusMessage += `\nValidation: ${validationResult.isHealthy ? '✅ COMPLETE' : '❌ INCOMPLETE'}`; if (!validationResult.isHealthy) { @@ -1422,7 +1473,8 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { debug("[Index] Manual refresh requested"); await showAILearningProgress(async (progress) => { await smartRebuildIndexes("manual refresh", true); - debug(`[Index] Refresh completed with ${translationPairsIndex.documentCount} documents`); + const docCount = await translationPairsIndex.getDocumentCount(); + debug(`[Index] Refresh completed with ${docCount} documents`); }); } catch (error) { console.error("Error refreshing index:", error); @@ -1473,6 +1525,12 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { return; } + // Don't run incremental indexing while a full rebuild is in progress + if (rebuildState.rebuildInProgress) { + debug("[Index] Skipping incremental index — full rebuild in progress"); + return; + } + debug(`[Index] Incremental indexing requested for ${filePaths.length} files`); try { @@ -1502,15 +1560,16 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { ); if (relevantTargetFiles.length > 0) { - wordsIndex = await initializeWordsIndex(wordsIndex, relevantTargetFiles); await updateCompleteDrafts(relevantTargetFiles); } debug(`[Index] Incremental sync completed: ${syncResult.syncedFiles} files processed`); + const translationDocCount = await translationPairsIndex.getDocumentCount(); + const sourceDocCount = await sourceTextIndex.getDocumentCount(); statusBarHandler.updateIndexCounts( - translationPairsIndex.documentCount, - sourceTextIndex.documentCount + translationDocCount, + sourceDocCount ); progress.report({ message: "AI learning complete!" }); @@ -1532,14 +1591,14 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { async () => { try { if (translationPairsIndex instanceof SQLiteIndexManager) { - const db = translationPairsIndex.database; - if (!db) { - vscode.window.showErrorMessage("Database not available"); - return; - } + const db = translationPairsIndex.database; // throws if closed/not init // Check target cell timestamp information - const timestampStmt = db.prepare(` + const timestampResult = await db.get<{ + total_target_cells: number; + cells_with_edit_timestamp: number; + cells_without_edit_timestamp: number; + }>(` SELECT COUNT(*) as total_target_cells, COUNT(t_current_edit_timestamp) as cells_with_edit_timestamp, @@ -1548,26 +1607,18 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { WHERE t_content IS NOT NULL AND t_content != '' `); - let timestampStats = { - totalTargetCells: 0, - cellsWithEditTimestamp: 0, - cellsWithoutEditTimestamp: 0 + const timestampStats = { + totalTargetCells: (timestampResult?.total_target_cells as number) || 0, + cellsWithEditTimestamp: (timestampResult?.cells_with_edit_timestamp as number) || 0, + cellsWithoutEditTimestamp: (timestampResult?.cells_without_edit_timestamp as number) || 0 }; - try { - timestampStmt.step(); - const result = timestampStmt.getAsObject(); - timestampStats = { - totalTargetCells: (result.total_target_cells as number) || 0, - cellsWithEditTimestamp: (result.cells_with_edit_timestamp as number) || 0, - cellsWithoutEditTimestamp: (result.cells_without_edit_timestamp as number) || 0 - }; - } finally { - timestampStmt.free(); - } - // Get sample target cells with timestamps - const sampleStmt = db.prepare(` + const sampleRows = await db.all<{ + cell_id: string; + t_current_edit_timestamp: number; + formatted_timestamp: string; + }>(` SELECT cell_id, t_current_edit_timestamp, @@ -1586,17 +1637,12 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { formattedTimestamp: string; }> = []; - try { - while (sampleStmt.step()) { - const row = sampleStmt.getAsObject(); - sampleCells.push({ - cellId: row.cell_id as string, - editTimestamp: row.t_current_edit_timestamp as number, - formattedTimestamp: row.formatted_timestamp as string - }); - } - } finally { - sampleStmt.free(); + for (const row of sampleRows) { + sampleCells.push({ + cellId: row.cell_id as string, + editTimestamp: row.t_current_edit_timestamp as number, + formattedTimestamp: row.formatted_timestamp as string + }); } let message = `Target Cell Timestamp Analysis (Optimized Schema v8):\\n`; @@ -1708,66 +1754,59 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { try { if (translationPairsIndex instanceof SQLiteIndexManager) { const schemaInfo = await translationPairsIndex.getDetailedSchemaInfo(); - const db = translationPairsIndex.database; - - if (db) { - // Check what columns actually exist in the cells table - const stmt = db.prepare("PRAGMA table_info(cells)"); - const actualColumns: Array<{ name: string; type: string; }> = []; - - try { - while (stmt.step()) { - const row = stmt.getAsObject(); - actualColumns.push({ - name: row.name as string, - type: row.type as string - }); - } - } finally { - stmt.free(); - } + const db = translationPairsIndex.database; // throws if closed/not init - let message = `Database Schema Diagnosis (Expected v8):\n\n`; - message += `📊 Schema Version: ${schemaInfo.currentVersion}\n`; - message += `📁 Tables Exist: ${schemaInfo.cellsTableExists ? 'Yes' : 'No'}\n`; - message += `🏗️ New Structure: ${schemaInfo.hasNewStructure ? 'Yes' : 'No'}\n\n`; - - message += `🔍 Cells Table Columns (${actualColumns.length}):\n`; - - // Check specifically for the timestamp columns - const hasTargetUpdatedAt = actualColumns.some(col => col.name === 't_updated_at'); - const hasTargetCurrentEdit = actualColumns.some(col => col.name === 't_current_edit_timestamp'); - const hasSourceUpdatedAt = actualColumns.some(col => col.name === 's_updated_at'); - - for (const col of actualColumns) { - let indicator = ''; - if (col.name === 't_updated_at') { - indicator = ' ❌ (SHOULD NOT EXIST)'; - } else if (col.name === 't_current_edit_timestamp') { - indicator = ' ✅ (CORRECT)'; - } else if (col.name === 's_updated_at') { - indicator = ' ✅ (CORRECT FOR SOURCE)'; - } - message += `• ${col.name} (${col.type})${indicator}\n`; - } + // Check what columns actually exist in the cells table + const pragmaRows = await db.all<{ name: string; type: string; }>("PRAGMA table_info(cells)"); + const actualColumns: Array<{ name: string; type: string; }> = []; - message += `\n🎯 Timestamp Column Analysis:\n`; - message += `• t_updated_at exists: ${hasTargetUpdatedAt ? '❌ YES (PROBLEM!)' : '✅ No'}\n`; - message += `• t_current_edit_timestamp exists: ${hasTargetCurrentEdit ? '✅ Yes' : '❌ NO (PROBLEM!)'}\n`; - message += `• s_updated_at exists: ${hasSourceUpdatedAt ? '✅ Yes' : '❌ NO (PROBLEM!)'}\n\n`; - - if (hasTargetUpdatedAt) { - message += `🚨 ISSUE FOUND: t_updated_at should NOT exist in schema v8!\n`; - message += `This suggests an old database that wasn't properly recreated.\n\n`; - message += `💡 SOLUTION: Force recreate the database:\n`; - message += `1. Run "Codex: Force Schema Reset" command\n`; - message += `2. Or delete .project/indexes.sqlite and restart extension\n`; - } else { - message += `✅ Timestamp columns are correct for schema v8!\n`; + for (const row of pragmaRows) { + actualColumns.push({ + name: row.name as string, + type: row.type as string + }); + } + + let message = `Database Schema Diagnosis (Expected v8):\n\n`; + message += `📊 Schema Version: ${schemaInfo.currentVersion}\n`; + message += `📁 Tables Exist: ${schemaInfo.cellsTableExists ? 'Yes' : 'No'}\n`; + message += `🏗️ New Structure: ${schemaInfo.hasNewStructure ? 'Yes' : 'No'}\n\n`; + + message += `🔍 Cells Table Columns (${actualColumns.length}):\n`; + + // Check specifically for the timestamp columns + const hasTargetUpdatedAt = actualColumns.some(col => col.name === 't_updated_at'); + const hasTargetCurrentEdit = actualColumns.some(col => col.name === 't_current_edit_timestamp'); + const hasSourceUpdatedAt = actualColumns.some(col => col.name === 's_updated_at'); + + for (const col of actualColumns) { + let indicator = ''; + if (col.name === 't_updated_at') { + indicator = ' ❌ (SHOULD NOT EXIST)'; + } else if (col.name === 't_current_edit_timestamp') { + indicator = ' ✅ (CORRECT)'; + } else if (col.name === 's_updated_at') { + indicator = ' ✅ (CORRECT FOR SOURCE)'; } + message += `• ${col.name} (${col.type})${indicator}\n`; + } - vscode.window.showInformationMessage(message); + message += `\n🎯 Timestamp Column Analysis:\n`; + message += `• t_updated_at exists: ${hasTargetUpdatedAt ? '❌ YES (PROBLEM!)' : '✅ No'}\n`; + message += `• t_current_edit_timestamp exists: ${hasTargetCurrentEdit ? '✅ Yes' : '❌ NO (PROBLEM!)'}\n`; + message += `• s_updated_at exists: ${hasSourceUpdatedAt ? '✅ Yes' : '❌ NO (PROBLEM!)'}\n\n`; + + if (hasTargetUpdatedAt) { + message += `🚨 ISSUE FOUND: t_updated_at should NOT exist in schema v8!\n`; + message += `This suggests an old database that wasn't properly recreated.\n\n`; + message += `💡 SOLUTION: Force recreate the database:\n`; + message += `1. Run "Codex: Force Schema Reset" command\n`; + message += `2. Or delete .project/indexes.sqlite and restart extension\n`; + } else { + message += `✅ Timestamp columns are correct for schema v8!\n`; } + + vscode.window.showInformationMessage(message); } else { vscode.window.showErrorMessage("SQLite index not available"); } @@ -1846,6 +1885,9 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { // Use setTimeout to avoid blocking the configuration change setTimeout(async () => { try { + // Skip if DB was closed during the delay (e.g. extension deactivating) + if (!getSQLiteIndexManager()) return; + const newThreshold = vscode.workspace.getConfiguration('codex-project-manager') .get('validationCount', 1); @@ -1866,6 +1908,9 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { // Use setTimeout to avoid blocking the configuration change setTimeout(async () => { try { + // Skip if DB was closed during the delay (e.g. extension deactivating) + if (!getSQLiteIndexManager()) return; + const newThreshold = vscode.workspace.getConfiguration('codex-project-manager') .get('validationCountAudio', 1); @@ -1931,11 +1976,18 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { debug("[SQLiteIndex] 🔍 Analyzing audio validation columns..."); progress.report({ message: "Checking audio validation data..." }); - const db = translationPairsIndex.database; - if (db) { + const db = translationPairsIndex.database; // throws if closed/not init + { try { // Get audio validation statistics (excluding milestone cells) - const audioValidationStmt = db.prepare(` + const audioValidationResult = await db.get<{ + total_target_cells: number; + cells_with_audio_validation_count: number; + cells_with_audio_validated_by: number; + fully_audio_validated_cells: number; + avg_audio_validation_count: number; + max_audio_validation_count: number; + }>(` SELECT COUNT(*) as total_target_cells, COUNT(t_audio_validation_count) as cells_with_audio_validation_count, @@ -1948,32 +2000,17 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { AND ((cell_type IS NULL AND (cell_id LIKE '%:%' OR cell_id LIKE '% %')) OR (cell_type IS NOT NULL AND cell_type != 'milestone')) `); - let audioStats = { - totalTargetCells: 0, - cellsWithAudioValidationCount: 0, - cellsWithAudioValidatedBy: 0, - fullyAudioValidatedCells: 0, - avgAudioValidationCount: 0, - maxAudioValidationCount: 0 + const audioStats = { + totalTargetCells: (audioValidationResult?.total_target_cells as number) || 0, + cellsWithAudioValidationCount: (audioValidationResult?.cells_with_audio_validation_count as number) || 0, + cellsWithAudioValidatedBy: (audioValidationResult?.cells_with_audio_validated_by as number) || 0, + fullyAudioValidatedCells: (audioValidationResult?.fully_audio_validated_cells as number) || 0, + avgAudioValidationCount: (audioValidationResult?.avg_audio_validation_count as number) || 0, + maxAudioValidationCount: (audioValidationResult?.max_audio_validation_count as number) || 0 }; - try { - audioValidationStmt.step(); - const result = audioValidationStmt.getAsObject(); - audioStats = { - totalTargetCells: (result.total_target_cells as number) || 0, - cellsWithAudioValidationCount: (result.cells_with_audio_validation_count as number) || 0, - cellsWithAudioValidatedBy: (result.cells_with_audio_validated_by as number) || 0, - fullyAudioValidatedCells: (result.fully_audio_validated_cells as number) || 0, - avgAudioValidationCount: (result.avg_audio_validation_count as number) || 0, - maxAudioValidationCount: (result.max_audio_validation_count as number) || 0 - }; - } finally { - audioValidationStmt.free(); - } - // Get sample audio validation data - const sampleAudioStmt = db.prepare(` + const sampleAudioRows = await db.all(` SELECT cell_id, t_audio_validation_count, @@ -1988,12 +2025,8 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { `); const sampleAudioCells: any[] = []; - try { - while (sampleAudioStmt.step()) { - sampleAudioCells.push(sampleAudioStmt.getAsObject()); - } - } finally { - sampleAudioStmt.free(); + for (const row of sampleAudioRows) { + sampleAudioCells.push(row); } // Get audio validation threshold @@ -2034,8 +2067,6 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { } catch (error) { vscode.window.showErrorMessage(`Audio validation analysis failed: ${error}`); } - } else { - vscode.window.showErrorMessage("Database not available"); } }); } else { @@ -2062,11 +2093,18 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { debug("[SQLiteIndex] 🔍 Analyzing validation columns..."); progress.report({ message: "Checking validation data..." }); - const db = translationPairsIndex.database; - if (db) { + const db = translationPairsIndex.database; // throws if closed/not init + { try { // Get validation statistics - const validationStmt = db.prepare(` + const validationResult = await db.get<{ + total_target_cells: number; + cells_with_validation_count: number; + cells_with_validated_by: number; + fully_validated_cells: number; + avg_validation_count: number; + max_validation_count: number; + }>(` SELECT COUNT(*) as total_target_cells, COUNT(t_validation_count) as cells_with_validation_count, @@ -2078,32 +2116,17 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { WHERE t_content IS NOT NULL AND t_content != '' `); - let stats = { - totalTargetCells: 0, - cellsWithValidationCount: 0, - cellsWithValidatedBy: 0, - fullyValidatedCells: 0, - avgValidationCount: 0, - maxValidationCount: 0 + const stats = { + totalTargetCells: (validationResult?.total_target_cells as number) || 0, + cellsWithValidationCount: (validationResult?.cells_with_validation_count as number) || 0, + cellsWithValidatedBy: (validationResult?.cells_with_validated_by as number) || 0, + fullyValidatedCells: (validationResult?.fully_validated_cells as number) || 0, + avgValidationCount: (validationResult?.avg_validation_count as number) || 0, + maxValidationCount: (validationResult?.max_validation_count as number) || 0 }; - try { - validationStmt.step(); - const result = validationStmt.getAsObject(); - stats = { - totalTargetCells: (result.total_target_cells as number) || 0, - cellsWithValidationCount: (result.cells_with_validation_count as number) || 0, - cellsWithValidatedBy: (result.cells_with_validated_by as number) || 0, - fullyValidatedCells: (result.fully_validated_cells as number) || 0, - avgValidationCount: (result.avg_validation_count as number) || 0, - maxValidationCount: (result.max_validation_count as number) || 0 - }; - } finally { - validationStmt.free(); - } - // Get sample validation data - const sampleStmt = db.prepare(` + const sampleRows = await db.all(` SELECT cell_id, t_validation_count, @@ -2118,12 +2141,8 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { `); const sampleCells: any[] = []; - try { - while (sampleStmt.step()) { - sampleCells.push(sampleStmt.getAsObject()); - } - } finally { - sampleStmt.free(); + for (const row of sampleRows) { + sampleCells.push(row); } // Get validation threshold @@ -2164,8 +2183,6 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { } catch (error) { vscode.window.showErrorMessage(`Validation analysis failed: ${error}`); } - } else { - vscode.window.showErrorMessage("Database not available"); } }); } else { @@ -2177,12 +2194,10 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { } ); - // Make sure to close the database when extension deactivates - context.subscriptions.push({ - dispose: async () => { - await indexManager.close(); - }, - }); + // Database close is handled by clearSQLiteIndexManager() in deactivate(), + // which is properly awaited. Do NOT add an async dispose subscription here — + // VS Code's Disposable.dispose() is synchronous and won't await it, causing + // a race with the deactivate path. // Update the subscriptions context.subscriptions.push( @@ -2195,9 +2210,6 @@ export async function createIndexWithContext(context: vscode.ExtensionContext) { getTranslationPairFromProjectCommand, forceReindexCommand, showIndexOptionsCommand, - getWordFrequenciesCommand, - refreshWordIndexCommand, - getWordsAboveThresholdCommand, searchParallelCellsCommand, searchSimilarCellIdsCommand, findNextUntranslatedSourceCellCommand, diff --git a/src/activationHelpers/contextAware/contentIndexes/indexes/schema.ts b/src/activationHelpers/contextAware/contentIndexes/indexes/schema.ts new file mode 100644 index 000000000..86eec70d9 --- /dev/null +++ b/src/activationHelpers/contextAware/contentIndexes/indexes/schema.ts @@ -0,0 +1,207 @@ +/** + * Shared schema constants for the SQLite database layer. + * + * This is the single source of truth for all DDL (CREATE TABLE, CREATE INDEX, + * CREATE TRIGGER) used by both the production SQLiteIndexManager and the test + * suite. Any schema change must be made here so the two stay in sync. + */ + +// Schema version — bump this whenever the schema changes. +// Using a full recreation strategy (no incremental migrations). +export const CURRENT_SCHEMA_VERSION = 13; // Added project_id/project_name to schema_info, fixed FTS triggers (DELETE+INSERT instead of INSERT OR REPLACE), added NULL-content cleanup triggers + +// ── Tables + FTS virtual table ────────────────────────────────────────────── + +export const CREATE_TABLES_SQL = ` + CREATE TABLE IF NOT EXISTS sync_metadata ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_path TEXT NOT NULL UNIQUE, + file_type TEXT NOT NULL CHECK(file_type IN ('source', 'codex')), + content_hash TEXT NOT NULL, + file_size INTEGER NOT NULL, + last_modified_ms INTEGER NOT NULL, + last_synced_ms INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000), + git_commit_hash TEXT, + created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000), + updated_at INTEGER DEFAULT (strftime('%s', 'now') * 1000) + ); + + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_path TEXT NOT NULL UNIQUE, + file_type TEXT NOT NULL CHECK(file_type IN ('source', 'codex')), + last_modified_ms INTEGER NOT NULL, + content_hash TEXT NOT NULL, + total_cells INTEGER DEFAULT 0, + total_words INTEGER DEFAULT 0, + created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000), + updated_at INTEGER DEFAULT (strftime('%s', 'now') * 1000) + ); + + CREATE TABLE IF NOT EXISTS cells ( + cell_id TEXT PRIMARY KEY, + cell_type TEXT, + s_file_id INTEGER, + s_content TEXT, + s_raw_content_hash TEXT, + s_line_number INTEGER, + s_word_count INTEGER DEFAULT 0, + s_raw_content TEXT, + s_created_at INTEGER, + s_updated_at INTEGER, + t_file_id INTEGER, + t_content TEXT, + t_raw_content_hash TEXT, + t_line_number INTEGER, + t_word_count INTEGER DEFAULT 0, + t_raw_content TEXT, + t_created_at INTEGER, + t_current_edit_timestamp INTEGER, + t_validation_count INTEGER DEFAULT 0, + t_validated_by TEXT, + t_is_fully_validated BOOLEAN DEFAULT FALSE, + t_audio_validation_count INTEGER DEFAULT 0, + t_audio_validated_by TEXT, + t_audio_is_fully_validated BOOLEAN DEFAULT FALSE, + milestone_index INTEGER, + FOREIGN KEY (s_file_id) REFERENCES files(id) ON DELETE SET NULL, + FOREIGN KEY (t_file_id) REFERENCES files(id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS words ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + word TEXT NOT NULL, + cell_id TEXT NOT NULL, + position INTEGER NOT NULL, + frequency INTEGER DEFAULT 1, + created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000), + FOREIGN KEY (cell_id) REFERENCES cells(cell_id) ON DELETE CASCADE + ); + + CREATE VIRTUAL TABLE IF NOT EXISTS cells_fts USING fts5( + cell_id, + content, + raw_content, + content_type, + tokenize='porter unicode61' + ); +`; + +// ── Initial indexes (created during schema setup) ─────────────────────────── + +export const CREATE_INDEXES_SQL = ` + CREATE INDEX IF NOT EXISTS idx_sync_metadata_path ON sync_metadata(file_path); + CREATE INDEX IF NOT EXISTS idx_files_path ON files(file_path); + CREATE INDEX IF NOT EXISTS idx_cells_s_file_id ON cells(s_file_id); + CREATE INDEX IF NOT EXISTS idx_cells_t_file_id ON cells(t_file_id); + CREATE INDEX IF NOT EXISTS idx_cells_milestone_index ON cells(milestone_index); +`; + +// ── Deferred indexes (created after initial data load for performance) ────── + +export const CREATE_DEFERRED_INDEXES_SQL = ` + CREATE INDEX IF NOT EXISTS idx_sync_metadata_hash ON sync_metadata(content_hash); + CREATE INDEX IF NOT EXISTS idx_sync_metadata_modified ON sync_metadata(last_modified_ms); + CREATE INDEX IF NOT EXISTS idx_cells_s_content_hash ON cells(s_raw_content_hash); + CREATE INDEX IF NOT EXISTS idx_cells_t_content_hash ON cells(t_raw_content_hash); + CREATE INDEX IF NOT EXISTS idx_cells_t_is_fully_validated ON cells(t_is_fully_validated); + CREATE INDEX IF NOT EXISTS idx_cells_t_current_edit_timestamp ON cells(t_current_edit_timestamp); + CREATE INDEX IF NOT EXISTS idx_cells_t_validation_count ON cells(t_validation_count); + CREATE INDEX IF NOT EXISTS idx_cells_t_audio_is_fully_validated ON cells(t_audio_is_fully_validated); + CREATE INDEX IF NOT EXISTS idx_cells_t_audio_validation_count ON cells(t_audio_validation_count); + CREATE INDEX IF NOT EXISTS idx_words_word ON words(word); + CREATE INDEX IF NOT EXISTS idx_words_cell_id ON words(cell_id); +`; + +// ── schema_info table (created separately because setSchemaVersion manages it) ─ + +export const CREATE_SCHEMA_INFO_SQL = ` + CREATE TABLE IF NOT EXISTS schema_info ( + id INTEGER PRIMARY KEY CHECK(id = 1), + version INTEGER NOT NULL, + project_id TEXT, + project_name TEXT + ) +`; + +// ── Triggers ──────────────────────────────────────────────────────────────── +// +// Each trigger must be executed as a separate statement because SQLite's exec() +// processes one statement at a time for triggers that contain BEGIN/END blocks. + +/** Timestamp auto-update triggers for metadata tables */ +export const TIMESTAMP_TRIGGERS = [ + `CREATE TRIGGER IF NOT EXISTS update_sync_metadata_timestamp + AFTER UPDATE ON sync_metadata + BEGIN + UPDATE sync_metadata SET updated_at = strftime('%s', 'now') * 1000 + WHERE id = NEW.id; + END`, + `CREATE TRIGGER IF NOT EXISTS update_files_timestamp + AFTER UPDATE ON files + BEGIN + UPDATE files SET updated_at = strftime('%s', 'now') * 1000 + WHERE id = NEW.id; + END`, + `CREATE TRIGGER IF NOT EXISTS update_cells_s_timestamp + AFTER UPDATE OF s_content, s_raw_content ON cells + BEGIN + UPDATE cells SET s_updated_at = strftime('%s', 'now') * 1000 + WHERE cell_id = NEW.cell_id; + END`, + // Target timestamp trigger removed - timestamps now handled in application logic +]; + +/** FTS5 triggers that keep cells_fts in sync with the cells table */ +export const FTS_TRIGGERS = [ + `CREATE TRIGGER IF NOT EXISTS cells_fts_source_insert + AFTER INSERT ON cells + WHEN NEW.s_content IS NOT NULL + BEGIN + INSERT INTO cells_fts(cell_id, content, raw_content, content_type) + VALUES (NEW.cell_id, NEW.s_content, COALESCE(NEW.s_raw_content, NEW.s_content), 'source'); + END`, + `CREATE TRIGGER IF NOT EXISTS cells_fts_target_insert + AFTER INSERT ON cells + WHEN NEW.t_content IS NOT NULL + BEGIN + INSERT INTO cells_fts(cell_id, content, raw_content, content_type) + VALUES (NEW.cell_id, NEW.t_content, COALESCE(NEW.t_raw_content, NEW.t_content), 'target'); + END`, + `CREATE TRIGGER IF NOT EXISTS cells_fts_source_update + AFTER UPDATE OF s_content, s_raw_content ON cells + WHEN NEW.s_content IS NOT NULL + BEGIN + DELETE FROM cells_fts WHERE cell_id = NEW.cell_id AND content_type = 'source'; + INSERT INTO cells_fts(cell_id, content, raw_content, content_type) + VALUES (NEW.cell_id, NEW.s_content, COALESCE(NEW.s_raw_content, NEW.s_content), 'source'); + END`, + `CREATE TRIGGER IF NOT EXISTS cells_fts_target_update + AFTER UPDATE OF t_content, t_raw_content ON cells + WHEN NEW.t_content IS NOT NULL + BEGIN + DELETE FROM cells_fts WHERE cell_id = NEW.cell_id AND content_type = 'target'; + INSERT INTO cells_fts(cell_id, content, raw_content, content_type) + VALUES (NEW.cell_id, NEW.t_content, COALESCE(NEW.t_raw_content, NEW.t_content), 'target'); + END`, + `CREATE TRIGGER IF NOT EXISTS cells_fts_source_clear + AFTER UPDATE OF s_content ON cells + WHEN NEW.s_content IS NULL AND OLD.s_content IS NOT NULL + BEGIN + DELETE FROM cells_fts WHERE cell_id = NEW.cell_id AND content_type = 'source'; + END`, + `CREATE TRIGGER IF NOT EXISTS cells_fts_target_clear + AFTER UPDATE OF t_content ON cells + WHEN NEW.t_content IS NULL AND OLD.t_content IS NOT NULL + BEGIN + DELETE FROM cells_fts WHERE cell_id = NEW.cell_id AND content_type = 'target'; + END`, + `CREATE TRIGGER IF NOT EXISTS cells_fts_delete + AFTER DELETE ON cells + BEGIN + DELETE FROM cells_fts WHERE cell_id = OLD.cell_id; + END`, +]; + +/** All triggers combined (timestamp + FTS), for convenience */ +export const ALL_TRIGGERS = [...TIMESTAMP_TRIGGERS, ...FTS_TRIGGERS]; diff --git a/src/activationHelpers/contextAware/contentIndexes/indexes/search.ts b/src/activationHelpers/contextAware/contentIndexes/indexes/search.ts index de402f675..bfbba9bb4 100644 --- a/src/activationHelpers/contextAware/contentIndexes/indexes/search.ts +++ b/src/activationHelpers/contextAware/contentIndexes/indexes/search.ts @@ -27,21 +27,20 @@ export function normalizeUri(uri: string): string { } } -export function searchTargetCellsByQuery( +export async function searchTargetCellsByQuery( translationPairsIndex: IndexType, query: string, k: number = 5, fuzziness: number = 0.2 ) { - return translationPairsIndex - .search(query, { - fields: ["targetContent"], - combineWith: "OR", - prefix: true, - fuzzy: fuzziness, - boost: { targetContent: 2, cellId: 1 }, - }) - .slice(0, k); + const results = await translationPairsIndex.search(query, { + fields: ["targetContent"], + combineWith: "OR", + prefix: true, + fuzzy: fuzziness, + boost: { targetContent: 2, cellId: 1 }, + }); + return results.slice(0, k); } export async function getSourceCellByCellIdFromAllSourceCells( @@ -55,8 +54,8 @@ export async function getSourceCellByCellIdFromAllSourceCells( return { cellId: result.cellId, content: result.content, - versions: result.versions || [], - notebookId: result.notebookId || "", + versions: result.versions ?? [], + notebookId: result.source_file_path ?? "", }; } } @@ -111,7 +110,7 @@ export async function getTranslationPairFromProject( const searchResults = await translationPairsIndex.search(cellId, { fields: ["cellId", "sourceContent", "targetContent"], combineWith: "OR", - filter: (result: any) => result.cellId === cellId, + filter: (result) => result.cellId === cellId, isParallelPassagesWebview, // Pass through for raw content handling }); @@ -165,7 +164,15 @@ export async function getTranslationPairFromProject( let sourceOnlyResult: SourceCellVersions | null = null; if (sourceTextIndex instanceof SQLiteIndexManager) { - sourceOnlyResult = await sourceTextIndex.getById(cellId); + const byIdResult = await sourceTextIndex.getById(cellId); + if (byIdResult) { + sourceOnlyResult = { + cellId: byIdResult.cellId, + content: byIdResult.content, + versions: byIdResult.versions ?? [], + notebookId: byIdResult.source_file_path ?? "", + }; + } } if (sourceOnlyResult) { @@ -301,12 +308,12 @@ export async function getTranslationPairsFromSourceCellQuery( return translationPairs; } -export function handleTextSelection(translationPairsIndex: IndexType, selectedText: string) { - return searchTargetCellsByQuery(translationPairsIndex, selectedText); +export async function handleTextSelection(translationPairsIndex: IndexType, selectedText: string) { + return await searchTargetCellsByQuery(translationPairsIndex, selectedText); } -export function searchSimilarCellIds( +export async function searchSimilarCellIds( translationPairsIndex: IndexType, cellId: string, k: number = 5, @@ -315,14 +322,14 @@ export function searchSimilarCellIds( // Parse the input cellId into book and chapter const match = cellId.match(/^(\w+)\s*(\d+)/); if (!match) { - return translationPairsIndex - .search(cellId, { - fields: ["cellId"], - combineWith: "OR", - prefix: true, - fuzzy: fuzziness, - boost: { cellId: 2 }, - }) + const results = await translationPairsIndex.search(cellId, { + fields: ["cellId"], + combineWith: "OR", + prefix: true, + fuzzy: fuzziness, + boost: { cellId: 2 }, + }); + return results .slice(0, k) .map((result: any) => ({ cellId: result.cellId, @@ -332,12 +339,12 @@ export function searchSimilarCellIds( // Search for exact book+chapter prefix (e.g., "GEN 2") const bookChapterPrefix = match[0]; - return translationPairsIndex - .search(bookChapterPrefix, { - fields: ["cellId"], - prefix: true, - combineWith: "AND", - }) + const results = await translationPairsIndex.search(bookChapterPrefix, { + fields: ["cellId"], + prefix: true, + combineWith: "AND", + }); + return results .slice(0, k) .map((result: any) => ({ cellId: result.cellId, @@ -369,7 +376,7 @@ export async function findNextUntranslatedSourceCell( if (!hasTranslation) { return { cellId: result.cellId, - content: result.content, + content: result.content ?? "", }; } } @@ -470,14 +477,14 @@ export async function searchAllCells( // Add incomplete pairs (source-only cells) if requested if (includeIncomplete) { const existingIds = new Set(results.map((r) => r.cellId)); - const sourceOnlyCells = sourceTextIndex - .search(query, { - fields: ["content"], - combineWith: "OR", - prefix: true, - fuzzy: 0.2, - boost: { content: 2 }, - }) + const sourceSearchResults = await sourceTextIndex.search(query, { + fields: ["content"], + combineWith: "OR", + prefix: true, + fuzzy: 0.2, + boost: { content: 2 }, + }); + const sourceOnlyCells = sourceSearchResults .filter((result: any) => { // Skip if already in results if (existingIds.has(result.cellId)) return false; diff --git a/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndex.ts b/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndex.ts index 0503d8080..01fa5c0fb 100644 --- a/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndex.ts +++ b/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndex.ts @@ -1,38 +1,324 @@ import * as vscode from "vscode"; -import initSqlJs, { Database, SqlJsStatic } from "fts5-sql-bundle"; +import { existsSync } from "fs"; +import { AsyncDatabase } from "../../../../utils/nativeSqlite"; import { createHash } from "crypto"; import { TranslationPair, MinimalCellResult } from "../../../../../types"; import { updateSplashScreenTimings } from "../../../../providers/SplashScreen/register"; import { ActivationTiming } from "../../../../extension"; -import { debounce } from "lodash"; import { EditMapUtils } from "../../../../utils/editMapUtils"; +import { MetadataManager } from "../../../../utils/metadataManager"; +import { + CURRENT_SCHEMA_VERSION, + CREATE_TABLES_SQL, + CREATE_INDEXES_SQL, + CREATE_DEFERRED_INDEXES_SQL, + CREATE_SCHEMA_INFO_SQL, + ALL_TRIGGERS, +} from "./schema"; + +// Re-export so existing consumers don't break +export { CURRENT_SCHEMA_VERSION } from "./schema"; const INDEX_DB_PATH = [".project", "indexes.sqlite"]; +// ── Typed result interfaces for public API ────────────────────────────────── + +/** Options accepted by the search() method. */ +export interface SearchOptions { + /** Maximum results to return (default: 50). */ + limit?: number; + /** Fuzzy matching threshold (default: 0.2). */ + fuzzy?: number; + /** Per-field boost weights (MiniSearch compat). */ + boost?: Record; + /** If true, return raw content with HTML; if false, return sanitized content (default: false). */ + returnRawContent?: boolean; + /** If true, this search is for the parallel passages webview display (default: false). */ + isParallelPassagesWebview?: boolean; + // Legacy MiniSearch compatibility options — accepted but ignored by the FTS5 engine. + /** @deprecated Legacy MiniSearch option — ignored by FTS5. */ + fields?: string[]; + /** @deprecated Legacy MiniSearch option — ignored by FTS5. */ + combineWith?: string; + /** @deprecated Legacy MiniSearch option — ignored by FTS5. */ + prefix?: boolean; + /** @deprecated Legacy MiniSearch option — ignored by FTS5. */ + filter?: (result: SearchResult) => boolean; +} + +/** A single result from the search() or searchSanitized() methods. */ +export interface SearchResult { + id: string; + cellId: string; + score: number; + /** MiniSearch compatibility stub — always `{}`. */ + match: Record; + uri: string; + line: number; + sourceContent?: string; + targetContent?: string; + content?: string; + sanitizedContent?: string; + rawContent?: string; + sanitizedTargetContent?: string; + rawTargetContent?: string; +} + +/** A single result from searchCells() or searchGreekText(). */ +export interface CellSearchResult { + cellId: string; + cell_id: string; + content: string; + rawContent: string; + sourceContent?: string; + targetContent?: string; + cell_type: "source" | "target"; + uri: string; + line: number; + score: number; + word_count: number; + file_type: string; +} + +/** Metadata attached to a cell retrieved by getById() or getCellById(). */ +export interface CellValidationMetadata { + currentEditTimestamp: number | null; + validationCount: number; + validatedBy: string[]; + isFullyValidated: boolean; + audioValidationCount: number; + audioValidatedBy: string[]; + audioIsFullyValidated: boolean; +} + +/** Result from getById(). */ +export interface CellByIdResult { + cellId: string; + content: string; + versions: string[]; + sourceContent: string; + targetContent: string; + sourceRawContent: string; + targetRawContent: string; + source_file_path: string; + target_file_path: string; + source_metadata: Record; + target_metadata: CellValidationMetadata; +} + +/** Result from getCellById(). */ +export interface CellDetailResult { + cellId: string; + content: string; + rawContent: string; + cell_type: "source" | "target"; + uri: string; + line: number; + [key: string]: unknown; +} + +/** Result from getTranslationPair(). */ +export interface TranslationPairResult { + cellId: string; + sourceContent: string; + targetContent: string; + rawSourceContent: string; + rawTargetContent: string; + uri: string | undefined; + line: number | undefined; +} + +/** A single entry from getFileStats(). */ +export interface FileStatEntry { + id: number; + file_path: string; + file_type: string; + cell_count: number; + total_words: number; +} + const DEBUG_MODE = false; const debug = (message: string, ...args: any[]) => { DEBUG_MODE && console.log(`[SQLiteIndex] ${message}`, ...args); }; -// Schema version for migrations -export const CURRENT_SCHEMA_VERSION = 12; // Added milestone_index column for O(1) milestone lookup - export class SQLiteIndexManager { - private sql: SqlJsStatic | null = null; - private db: Database | null = null; - private saveDebounceTimer: NodeJS.Timeout | null = null; - private readonly SAVE_DEBOUNCE_MS = 0; + private db: AsyncDatabase | null = null; + private dbPath: string | null = null; private progressTimings: ActivationTiming[] = []; private currentProgressTimer: NodeJS.Timeout | null = null; private currentProgressStartTime: number | null = null; private currentProgressName: string | null = null; private enableRealtimeProgress: boolean = true; + /** Mutex that serializes access to SQLite transactions (SQLite only allows one at a time). */ + private transactionLock: Promise = Promise.resolve(); + /** Set to true when close() is called — prevents new transactions and operations. */ + private closed = false; + /** Tracks whether deferred indexes have been created to skip redundant DDL on subsequent syncs. */ + private deferredIndexesCreated = false; + /** Consecutive WAL checkpoint failure count — used to escalate checkpoint mode. */ + private walCheckpointFailureCount = 0; + /** Maximum consecutive checkpoint failures before escalating to RESTART mode. */ + private static readonly MAX_CHECKPOINT_FAILURES = 5; + /** Handle for the periodic full integrity check timer (cleared on close). */ + private integrityCheckTimer: NodeJS.Timeout | null = null; + /** Timestamp of the last dbPath existence check (used by ensureOpen for periodic validation). */ + private lastDbPathCheckMs = 0; + /** Interval (ms) between dbPath existence checks in ensureOpen(). */ + private static readonly DB_PATH_CHECK_INTERVAL_MS = 30_000; + /** Track non-critical error frequencies for operational visibility. */ + private _nonCriticalErrorCounts: Map = new Map(); + /** Threshold at which non-critical errors are escalated to error level. */ + private static readonly NON_CRITICAL_ERROR_ESCALATION_THRESHOLD = 5; + /** Interval for periodic full integrity checks (default: 30 minutes). */ + private static readonly INTEGRITY_CHECK_INTERVAL_MS = 30 * 60 * 1000; + + /** + * Guard that checks both closed and db state. Call at the top of every + * public method that accesses the database. After this call returns, + * `this.db` is guaranteed non-null (use `this.db!` to assert). + * + * Additionally performs a periodic lightweight check that the database + * file still exists on disk. If the .project directory was deleted + * externally (e.g. `git clean -fdx`), this avoids cryptic "disk I/O error" + * messages by failing fast with a descriptive error. + * + * @param forceFileCheck If true, bypass the throttle and check the file + * immediately. Used by `runInTransaction` so writes + * always validate the DB file before starting. + */ + private ensureOpen(forceFileCheck = false): void { + if (this.closed) throw new Error("Database is closing or closed"); + if (!this.db) throw new Error("Database not initialized"); + + // Periodic dbPath existence check (lightweight, sync I/O, throttled). + // Skip for in-memory databases (":memory:") which have no file on disk. + if (this.dbPath && this.dbPath !== ":memory:") { + const now = Date.now(); + if (forceFileCheck || now - this.lastDbPathCheckMs >= SQLiteIndexManager.DB_PATH_CHECK_INTERVAL_MS) { + this.lastDbPathCheckMs = now; + if (!existsSync(this.dbPath)) { + this.closed = true; + console.error(`[SQLiteIndex] Database file no longer exists: ${this.dbPath}`); + throw new Error("Database file was deleted — the .project directory may have been removed"); + } + } + } + } + + /** + * Public read-only flag so callers (e.g. CodexCellDocument) can detect + * when this manager has been closed (e.g. after a project swap) and + * needs to be replaced with a fresh instance from the global singleton. + */ + get isClosed(): boolean { + return this.closed; + } + + /** + * Log a non-critical error with frequency tracking. + * First few occurrences are logged at warn level; repeated failures + * escalate to error level so they're visible in telemetry without + * flooding logs on every call. + */ + private logNonCriticalError(operation: string, err: unknown): void { + const count = (this._nonCriticalErrorCounts.get(operation) ?? 0) + 1; + this._nonCriticalErrorCounts.set(operation, count); + const msg = err instanceof Error ? err.message : String(err); + + if (count <= 3 || count % 10 === 0) { + console.warn(`[SQLiteIndex] ${operation} failed (${count}x): ${msg}`); + } + if (count === SQLiteIndexManager.NON_CRITICAL_ERROR_ESCALATION_THRESHOLD) { + console.error( + `[SQLiteIndex] ${operation} has failed ${count} consecutive times — may need investigation` + ); + } + } + + /** + * Reset the error counter for a specific operation (e.g., after a successful run). + */ + private resetNonCriticalErrorCount(operation: string): void { + this._nonCriticalErrorCounts.delete(operation); + } + + /** + * Open a database file with retry logic for transient SQLITE_BUSY / locked errors. + * Uses the same exponential-backoff pattern as runInTransactionWithRetry. + */ + private async openWithRetry( + path: string, + maxRetries = 3, + baseDelayMs = 100 + ): Promise { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await AsyncDatabase.open(path); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (!SQLiteIndexManager.isBusyError(msg) || attempt === maxRetries) throw error; + const delay = baseDelayMs * Math.pow(2, attempt); + debug(`[SQLiteIndex] DB open busy, retry ${attempt + 1}/${maxRetries} after ${delay}ms`); + await new Promise((r) => setTimeout(r, delay)); + } + } + throw new Error("Unreachable: openWithRetry exhausted retries"); + } + + /** + * Detect disk-full / out-of-space errors that should NOT be retried. + */ + private static isDiskFullError(msg: string): boolean { + return msg.includes("SQLITE_FULL") || msg.includes("ENOSPC") || msg.includes("no space left"); + } + + /** + * Detect transient SQLITE_BUSY / database-locked errors that can be retried. + */ + private static isBusyError(msg: string): boolean { + return msg.includes("SQLITE_BUSY") || msg.includes("database is locked"); + } + + /** + * Retry a standalone (non-transactional) database operation on SQLITE_BUSY. + * Use this for individual upserts that run outside of `runInTransaction`. + * Disk-full errors are never retried. + */ + private async withBusyRetry( + fn: () => Promise, + maxRetries = 2, + baseDelayMs = 50 + ): Promise { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (SQLiteIndexManager.isDiskFullError(msg)) throw error; + if (!SQLiteIndexManager.isBusyError(msg) || attempt === maxRetries) throw error; + const delay = baseDelayMs * Math.pow(2, attempt); + debug(`[SQLiteIndex] SQLITE_BUSY on standalone op, retry ${attempt + 1}/${maxRetries} after ${delay}ms`); + await new Promise((r) => setTimeout(r, delay)); + } + } + throw new Error("Unreachable: withBusyRetry exhausted retries"); + } + + /** Maximum number of progress timing entries to keep to prevent unbounded memory growth in long sessions. */ + private static readonly MAX_PROGRESS_ENTRIES = 200; private trackProgress(step: string, stepStartTime: number): number { const stepEndTime = globalThis.performance.now(); const duration = stepEndTime - stepStartTime; // Duration of THIS step only this.progressTimings.push({ step, duration, startTime: stepStartTime }); + + // Cap the array to prevent unbounded growth in long sessions with many rebuilds + if (this.progressTimings.length > SQLiteIndexManager.MAX_PROGRESS_ENTRIES) { + this.progressTimings = this.progressTimings.slice(-SQLiteIndexManager.MAX_PROGRESS_ENTRIES); + } + debug(`${step}: ${duration.toFixed(2)}ms`); // Stop any previous real-time timer @@ -126,26 +412,16 @@ export class SQLiteIndexManager { const initStart = globalThis.performance.now(); let stepStart = initStart; - // Initialize SQL.js + // No WASM initialization needed - native SQLite binary is downloaded on first run stepStart = this.trackProgress("AI initializing learning engine", stepStart); - const sqlWasmPath = vscode.Uri.joinPath( - context.extensionUri, - "out/node_modules/fts5-sql-bundle/dist/sql-wasm.wasm" - ); - - this.sql = await initSqlJs({ - locateFile: (file: string) => sqlWasmPath.fsPath, - }); - - if (!this.sql) { - throw new Error("Failed to initialize SQL.js"); - } - stepStart = this.trackProgress("AI learning engine ready", stepStart); // Load or create database await this.loadOrCreateDatabase(); + // Start background periodic integrity checks (every 30 min) + this.startPeriodicIntegrityCheck(); + this.trackProgress("AI learning capabilities ready", initStart); } @@ -158,288 +434,231 @@ export class SQLiteIndexManager { throw new Error("No workspace folder found"); } - const dbPath = vscode.Uri.joinPath(workspaceFolder.uri, ...INDEX_DB_PATH); + const dbUri = vscode.Uri.joinPath(workspaceFolder.uri, ...INDEX_DB_PATH); + this.dbPath = dbUri.fsPath; + + // Ensure the .project directory exists + const projectDir = vscode.Uri.joinPath(workspaceFolder.uri, ".project"); + try { + await vscode.workspace.fs.createDirectory(projectDir); + } catch (err) { + // "EntryExists" / "EEXIST" means the directory already exists — expected. + // Any other error (EACCES, ENOSPC, etc.) is a real problem. + const msg = err instanceof Error ? err.message : String(err); + const isAlreadyExists = msg.includes("EEXIST") || msg.includes("EntryExists") || msg.includes("FileExists"); + if (!isAlreadyExists) { + throw new Error(`Failed to create .project directory: ${msg}`); + } + } stepStart = this.trackProgress("Check for existing database", stepStart); + // Check if the database file exists and is valid + let dbExists = false; try { - const fileContent = await vscode.workspace.fs.readFile(dbPath); - stepStart = this.trackProgress("AI accessing previous learning", stepStart); + await vscode.workspace.fs.stat(dbUri); + dbExists = true; + } catch { + dbExists = false; + } - this.db = new this.sql!.Database(fileContent); - stepStart = this.trackProgress("Parse database structure", stepStart); + if (dbExists) { + try { + stepStart = this.trackProgress("AI accessing previous learning", stepStart); - debug("Loaded existing index database"); + // Open the existing file directly - no buffer loading needed + this.db = await this.openWithRetry(this.dbPath); - // Ensure schema is up to date - await this.ensureSchema(); - } catch (error) { - stepStart = this.trackProgress("Handle database error", stepStart); + // Apply production PRAGMAs on every open (not just schema creation). + // WAL is persisted in the file, but all other PRAGMAs are per-connection. + await this.applyProductionPragmas(); - // Check if this is a corruption error - const errorMessage = error instanceof Error ? error.message : String(error); - const isCorruption = errorMessage.includes("database disk image is malformed") || - errorMessage.includes("file is not a database") || - errorMessage.includes("database is locked") || - errorMessage.includes("database corruption"); + stepStart = this.trackProgress("Parse database structure", stepStart); - if (isCorruption) { - debug(`[SQLiteIndex] Database corruption detected: ${errorMessage}`); - debug("[SQLiteIndex] Deleting corrupt database and creating new one"); + debug("Loaded existing index database"); + + // Run a quick integrity check to catch corruption early + await this.quickIntegrityCheck(); + + // Ensure schema is up to date + await this.ensureSchema(); + } catch (error) { + stepStart = this.trackProgress("Handle database error", stepStart); + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`[SQLiteIndex] Database error during load: ${errorMessage}`); - // Delete the corrupted database file try { - await vscode.workspace.fs.delete(dbPath); - stepStart = this.trackProgress("Delete corrupted database", stepStart); - } catch (deleteError) { - debug("[SQLiteIndex] Could not delete corrupted database file:", deleteError); + await this.nukeDatabaseAndRecreate(`database error during load: ${errorMessage}`); + } catch (nukeError) { + // Recovery itself failed. Ensure we don't leave a half-open connection + // and log the fatal error so it's diagnosable. + console.error(`[SQLiteIndex] FATAL: Database recovery also failed: ${nukeError}`); + if (this.db) { + try { await this.db.close(); } catch { /* best-effort */ } + this.db = null; + } + throw nukeError; } - } else { - debug("Database file not found or other error, creating new database"); } - + } else { + // No existing database - create a new one stepStart = this.trackProgress("AI preparing fresh learning space", stepStart); debug("Creating new index database"); - this.db = new this.sql!.Database(); + this.db = await this.openWithRetry(this.dbPath); + try { + await this.applyProductionPragmas(); - await this.createSchema(); + await this.createSchema(); - // Validate schema before setting version to ensure reliability - if (!this.validateSchemaIntegrity()) { - throw new Error(`Schema validation failed after creation for version ${CURRENT_SCHEMA_VERSION} - database may be corrupted`); - } + if (!(await this.validateSchemaIntegrity())) { + throw new Error(`Schema validation failed after creation for version ${CURRENT_SCHEMA_VERSION} - database may be corrupted`); + } - this.setSchemaVersion(CURRENT_SCHEMA_VERSION); - await this.saveDatabase(); + await this.setSchemaVersion(CURRENT_SCHEMA_VERSION); + } catch (error) { + // Close the leaked connection before rethrowing + if (this.db) { + try { await this.db.close(); } catch (closeErr) { debug(`Error closing DB during error recovery: ${closeErr}`); } + this.db = null; + } + throw error; + } } this.trackProgress("AI learning space ready", loadStart); } - private async createSchema(): Promise { - if (!this.db) throw new Error("Database not initialized"); + /** + * Apply production-grade PRAGMAs to every database connection. + * WAL mode is persisted in the file, but all other PRAGMAs are per-connection + * and must be re-applied every time the database is opened. + */ + private async applyProductionPragmas(): Promise { + if (this.closed || !this.db) return; - const schemaStart = globalThis.performance.now(); + try { + // WAL mode — best for read-heavy workloads with occasional writes. + // Persisted in the file, but safe to re-issue (no-op if already WAL). + await this.db.exec("PRAGMA journal_mode = WAL"); - // Optimize database for faster creation (OUTSIDE of transaction) - debug("Optimizing database settings for fast creation..."); - this.db.run("PRAGMA synchronous = OFF"); // Disable fsync for speed - this.db.run("PRAGMA journal_mode = MEMORY"); // Use memory journal - this.db.run("PRAGMA temp_store = MEMORY"); // Store temp data in memory - this.db.run("PRAGMA cache_size = -64000"); // 64MB cache - this.db.run("PRAGMA foreign_keys = OFF"); // Disable FK checks during creation - - // Batch all schema creation in a single transaction for massive speedup - await this.runInTransaction(() => { - // Create all tables in batch - debug("Creating database tables..."); - - // Sync metadata table - this.db!.run(` - CREATE TABLE IF NOT EXISTS sync_metadata ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_path TEXT NOT NULL UNIQUE, - file_type TEXT NOT NULL CHECK(file_type IN ('source', 'codex')), - content_hash TEXT NOT NULL, - file_size INTEGER NOT NULL, - last_modified_ms INTEGER NOT NULL, - last_synced_ms INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000), - git_commit_hash TEXT, - created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000), - updated_at INTEGER DEFAULT (strftime('%s', 'now') * 1000) - ) - `); + // synchronous=NORMAL is safe with WAL (data survives process crashes; + // only an OS crash can lose the most recent transaction). + // Default is FULL which doubles fsync overhead for negligible safety gain with WAL. + await this.db.exec("PRAGMA synchronous = NORMAL"); - // Files table - this.db!.run(` - CREATE TABLE IF NOT EXISTS files ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_path TEXT NOT NULL UNIQUE, - file_type TEXT NOT NULL CHECK(file_type IN ('source', 'codex')), - last_modified_ms INTEGER NOT NULL, - content_hash TEXT NOT NULL, - total_cells INTEGER DEFAULT 0, - total_words INTEGER DEFAULT 0, - created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000), - updated_at INTEGER DEFAULT (strftime('%s', 'now') * 1000) - ) - `); + // 8 MB page cache — covers typical hot working set for 1500+ cell documents + await this.db.exec("PRAGMA cache_size = -8000"); - // Cells table - restructured to combine source and target in same row - this.db!.run(` - CREATE TABLE IF NOT EXISTS cells ( - cell_id TEXT PRIMARY KEY, - - -- Cell type (text, paratext, milestone, style) - cell_type TEXT, - - -- Source columns - s_file_id INTEGER, - s_content TEXT, - s_raw_content_hash TEXT, - s_line_number INTEGER, - s_word_count INTEGER DEFAULT 0, - s_raw_content TEXT, - s_created_at INTEGER, - s_updated_at INTEGER, - - -- Target columns - t_file_id INTEGER, - t_content TEXT, - t_raw_content_hash TEXT, - t_line_number INTEGER, - t_word_count INTEGER DEFAULT 0, - t_raw_content TEXT, - t_created_at INTEGER, - - -- Target metadata (optimized fields only) - t_current_edit_timestamp INTEGER, - t_validation_count INTEGER DEFAULT 0, - t_validated_by TEXT, - t_is_fully_validated BOOLEAN DEFAULT FALSE, - - -- Audio validation metadata (separate from text validation) - t_audio_validation_count INTEGER DEFAULT 0, - t_audio_validated_by TEXT, - t_audio_is_fully_validated BOOLEAN DEFAULT FALSE, - - -- Milestone index for O(1) lookup (0-based milestone index, NULL if no milestone) - milestone_index INTEGER, - - FOREIGN KEY (s_file_id) REFERENCES files(id) ON DELETE SET NULL, - FOREIGN KEY (t_file_id) REFERENCES files(id) ON DELETE SET NULL - ) - `); + // Store temp tables and indexes in memory (faster sorts / GROUP BY) + await this.db.exec("PRAGMA temp_store = MEMORY"); - // Translation pairs table removed in schema v8 - source/target are now in same row - - // Words table - this.db!.run(` - CREATE TABLE IF NOT EXISTS words ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - word TEXT NOT NULL, - cell_id TEXT NOT NULL, - position INTEGER NOT NULL, - frequency INTEGER DEFAULT 1, - created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000), - FOREIGN KEY (cell_id) REFERENCES cells(cell_id) ON DELETE CASCADE - ) - `); + // Enable foreign key enforcement + await this.db.exec("PRAGMA foreign_keys = ON"); - debug("Creating full-text search index..."); - // FTS5 virtual table - separate entries for source and target content - this.db!.run(` - CREATE VIRTUAL TABLE IF NOT EXISTS cells_fts USING fts5( - cell_id, - content, - raw_content, - content_type, - tokenize='porter unicode61' - ) - `); - }); + // Busy timeout: wait up to 5 seconds for locks instead of failing immediately. + // Prevents SQLITE_BUSY when another connection/process touches the file. + this.db.configure("busyTimeout", 5000); - debug("Creating database indexes (deferred)..."); - // Create indexes in a separate optimized transaction - await this.runInTransaction(() => { - // Create essential indexes only - defer others until after data insertion - this.db!.run("CREATE INDEX IF NOT EXISTS idx_sync_metadata_path ON sync_metadata(file_path)"); - this.db!.run("CREATE INDEX IF NOT EXISTS idx_files_path ON files(file_path)"); - this.db!.run("CREATE INDEX IF NOT EXISTS idx_cells_s_file_id ON cells(s_file_id)"); - this.db!.run("CREATE INDEX IF NOT EXISTS idx_cells_t_file_id ON cells(t_file_id)"); - this.db!.run("CREATE INDEX IF NOT EXISTS idx_cells_milestone_index ON cells(milestone_index)"); - }); + // Auto-checkpoint after 500 WAL pages (~2 MB) instead of the default 1000. + // Keeps the WAL file smaller in a VS Code extension where unbounded growth + // is undesirable. Manual checkpoints (PASSIVE/TRUNCATE) are still used + // after large batch operations and on close. + await this.db.exec("PRAGMA wal_autocheckpoint = 500"); - debug("Creating database triggers..."); - // Create triggers in batch - await this.runInTransaction(() => { - // Timestamp triggers - this.db!.run(` - CREATE TRIGGER IF NOT EXISTS update_sync_metadata_timestamp - AFTER UPDATE ON sync_metadata - BEGIN - UPDATE sync_metadata SET updated_at = strftime('%s', 'now') * 1000 - WHERE id = NEW.id; - END - `); + debug("[SQLiteIndex] Production PRAGMAs applied (WAL, sync=NORMAL, cache=8MB, busyTimeout=5s, autocheckpoint=500)"); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to apply production PRAGMAs: ${msg}`); + } + } - this.db!.run(` - CREATE TRIGGER IF NOT EXISTS update_files_timestamp - AFTER UPDATE ON files - BEGIN - UPDATE files SET updated_at = strftime('%s', 'now') * 1000 - WHERE id = NEW.id; - END - `); + /** + * Run a lightweight integrity check on startup. + * PRAGMA quick_check is much faster than full integrity_check (~100ms vs seconds) + * and catches the most common corruption patterns (page-level checksums, freelist). + * If corruption is detected, the database file is deleted so the caller can recreate it. + */ + private async quickIntegrityCheck(): Promise { + if (this.closed || !this.db) return; - this.db!.run(` - CREATE TRIGGER IF NOT EXISTS update_cells_s_timestamp - AFTER UPDATE OF s_content, s_raw_content ON cells - BEGIN - UPDATE cells SET s_updated_at = strftime('%s', 'now') * 1000 - WHERE cell_id = NEW.cell_id; - END - `); + try { + // PRAGMA quick_check returns a column named "quick_check" (not "integrity_check"). + const result = await this.db.get<{ quick_check: string; }>( + "PRAGMA quick_check(1)" + ); + // The first (and only) value in the result row is "ok" when healthy. + // Guard against unexpected column names by checking all values. + const value = result + ? (result.quick_check ?? Object.values(result)[0]) + : undefined; + + if (value && String(value) !== "ok") { + console.error(`[SQLiteIndex] Integrity check failed: ${value}`); + throw new Error(`database corruption detected by quick_check: ${value}`); + } + debug("[SQLiteIndex] Quick integrity check passed"); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + // If this is our own corruption error or any DB error, let the caller's + // corruption handler take over (it will delete + recreate) + throw new Error(`database corruption: ${msg}`); + } + } - // Target timestamp trigger removed - timestamps now handled in application logic - // to preserve actual edit timestamps from JSON metadata instead of database operation time - - // FTS synchronization triggers - handle source and target separately - this.db!.run(` - CREATE TRIGGER IF NOT EXISTS cells_fts_source_insert - AFTER INSERT ON cells - WHEN NEW.s_content IS NOT NULL - BEGIN - INSERT INTO cells_fts(cell_id, content, raw_content, content_type) - VALUES (NEW.cell_id, NEW.s_content, COALESCE(NEW.s_raw_content, NEW.s_content), 'source'); - END - `); + private async createSchema(): Promise { + this.ensureOpen(); - this.db!.run(` - CREATE TRIGGER IF NOT EXISTS cells_fts_target_insert - AFTER INSERT ON cells - WHEN NEW.t_content IS NOT NULL - BEGIN - INSERT INTO cells_fts(cell_id, content, raw_content, content_type) - VALUES (NEW.cell_id, NEW.t_content, COALESCE(NEW.t_raw_content, NEW.t_content), 'target'); - END - `); + const schemaStart = globalThis.performance.now(); - this.db!.run(` - CREATE TRIGGER IF NOT EXISTS cells_fts_source_update - AFTER UPDATE OF s_content, s_raw_content ON cells - WHEN NEW.s_content IS NOT NULL - BEGIN - INSERT OR REPLACE INTO cells_fts(cell_id, content, raw_content, content_type) - VALUES (NEW.cell_id, NEW.s_content, COALESCE(NEW.s_raw_content, NEW.s_content), 'source'); - END - `); + // Temporarily override PRAGMAs for fast bulk creation (OUTSIDE of transaction). + // We keep WAL mode active (set by applyProductionPragmas) so the database + // remains crash-safe even if the process dies during schema creation. + // NOTE: We keep synchronous=NORMAL (same as production) rather than OFF + // to avoid corruption risk if the process crashes mid-schema-creation. + // The performance difference for DDL is negligible. + debug("Optimizing database settings for fast creation..."); + await this.db!.exec("PRAGMA temp_store = MEMORY"); // Store temp data in memory + await this.db!.exec("PRAGMA cache_size = -64000"); // 64MB cache + await this.db!.exec("PRAGMA foreign_keys = OFF"); // Disable FK checks during creation - this.db!.run(` - CREATE TRIGGER IF NOT EXISTS cells_fts_target_update - AFTER UPDATE OF t_content, t_raw_content ON cells - WHEN NEW.t_content IS NOT NULL - BEGIN - INSERT OR REPLACE INTO cells_fts(cell_id, content, raw_content, content_type) - VALUES (NEW.cell_id, NEW.t_content, COALESCE(NEW.t_raw_content, NEW.t_content), 'target'); - END - `); + try { + // Batch all schema creation in a single transaction for massive speedup + await this.runInTransaction(async () => { + debug("Creating database tables..."); + await this.db!.exec(CREATE_TABLES_SQL); + }); - this.db!.run(` - CREATE TRIGGER IF NOT EXISTS cells_fts_delete - AFTER DELETE ON cells - BEGIN - DELETE FROM cells_fts WHERE cell_id = OLD.cell_id; - END - `); - }); + debug("Creating database indexes..."); + await this.runInTransaction(async () => { + await this.db!.exec(CREATE_INDEXES_SQL); + }); - // Restore normal database settings for production use (OUTSIDE of transaction) - debug("Restoring production database settings..."); - this.db.run("PRAGMA synchronous = NORMAL"); // Restore safe sync mode - this.db.run("PRAGMA journal_mode = WAL"); // Use WAL mode for better concurrency - this.db.run("PRAGMA foreign_keys = ON"); // Re-enable foreign key constraints - this.db.run("PRAGMA cache_size = -8000"); // Reasonable cache size (8MB) + debug("Creating database triggers..."); + // Each trigger must be a separate statement because SQLite's exec() + // processes one statement at a time for triggers with BEGIN/END blocks. + await this.runInTransaction(async () => { + for (const trigger of ALL_TRIGGERS) { + await this.db!.run(trigger); + } + }); + + } finally { + // Always restore production PRAGMAs, even if schema creation threw. + // foreign_keys=OFF and the large cache must be reverted; synchronous is + // already at NORMAL (we no longer set it to OFF during creation). + if (this.db) { + try { + debug("Restoring production database settings..."); + await this.db.exec("PRAGMA foreign_keys = ON"); + await this.db.exec("PRAGMA cache_size = -8000"); + } catch (pragmaErr) { + // Log but don't mask the original error from the try block. + // If PRAGMAs can't be restored, the DB is likely in a bad state + // and the original error (schema creation failure) is more important. + console.error(`[SQLiteIndex] CRITICAL: Failed to restore production PRAGMAs after schema creation: ${pragmaErr}`); + } + } + } const schemaEndTime = globalThis.performance.now(); const totalTime = schemaEndTime - schemaStart; @@ -453,42 +672,29 @@ export class SQLiteIndexManager { * Create remaining indexes after data insertion for better performance */ async createDeferredIndexes(): Promise { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); + + // Skip if already created this session — the DDL uses CREATE INDEX IF NOT EXISTS + // so it's idempotent, but running it on every sync adds unnecessary overhead. + if (this.deferredIndexesCreated) { + debug("Deferred indexes already created this session — skipping"); + return; + } debug("Creating deferred indexes for optimal performance..."); const indexStart = globalThis.performance.now(); - await this.runInTransaction(() => { - // Create remaining indexes that benefit from having data first - this.db!.run("CREATE INDEX IF NOT EXISTS idx_sync_metadata_hash ON sync_metadata(content_hash)"); - this.db!.run("CREATE INDEX IF NOT EXISTS idx_sync_metadata_modified ON sync_metadata(last_modified_ms)"); - - // Additional indexes for the new cell structure (main ones already created) - this.db!.run("CREATE INDEX IF NOT EXISTS idx_cells_s_content_hash ON cells(s_raw_content_hash)"); - this.db!.run("CREATE INDEX IF NOT EXISTS idx_cells_t_content_hash ON cells(t_raw_content_hash)"); - - // Performance indexes for extracted metadata - this.db!.run("CREATE INDEX IF NOT EXISTS idx_cells_t_is_fully_validated ON cells(t_is_fully_validated)"); - this.db!.run("CREATE INDEX IF NOT EXISTS idx_cells_t_current_edit_timestamp ON cells(t_current_edit_timestamp)"); - this.db!.run("CREATE INDEX IF NOT EXISTS idx_cells_t_validation_count ON cells(t_validation_count)"); - - // Performance indexes for audio validation metadata - this.db!.run("CREATE INDEX IF NOT EXISTS idx_cells_t_audio_is_fully_validated ON cells(t_audio_is_fully_validated)"); - this.db!.run("CREATE INDEX IF NOT EXISTS idx_cells_t_audio_validation_count ON cells(t_audio_validation_count)"); - - // Keep word index (will need updating for new structure) - this.db!.run("CREATE INDEX IF NOT EXISTS idx_words_word ON words(word)"); - this.db!.run("CREATE INDEX IF NOT EXISTS idx_words_cell_id ON words(cell_id)"); - - // Translation pairs indexes removed in schema v8 - table no longer exists + await this.runInTransaction(async () => { + await this.db!.exec(CREATE_DEFERRED_INDEXES_SQL); }); + this.deferredIndexesCreated = true; const indexEndTime = globalThis.performance.now(); debug(`Deferred indexes created in ${(indexEndTime - indexStart).toFixed(2)}ms`); } private async ensureSchema(): Promise { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); const ensureStart = globalThis.performance.now(); let stepStart = ensureStart; @@ -496,7 +702,7 @@ export class SQLiteIndexManager { try { // Check current schema version stepStart = this.trackProgress("Check database schema version", stepStart); - const currentVersion = this.getSchemaVersion(); + const currentVersion = await this.getSchemaVersion(); debug(`Current schema version: ${currentVersion}`); @@ -508,38 +714,42 @@ export class SQLiteIndexManager { await this.createSchema(); // Validate schema before setting version to ensure reliability - if (!this.validateSchemaIntegrity()) { + if (!(await this.validateSchemaIntegrity())) { throw new Error(`Schema validation failed after creation for version ${CURRENT_SCHEMA_VERSION} - database may be corrupted`); } - this.setSchemaVersion(CURRENT_SCHEMA_VERSION); + await this.setSchemaVersion(CURRENT_SCHEMA_VERSION); this.trackProgress("✨ AI learning structure organized", stepStart); debug(`New database created with schema version ${CURRENT_SCHEMA_VERSION}`); } else if (currentVersion !== CURRENT_SCHEMA_VERSION) { - // Scenario 2: ANY version mismatch (ahead, behind, or different) - ALWAYS recreate everything - // No partial migrations - we're senior database engineers who don't mess with bad/partial databases! + // Scenario 2: Version mismatch — try incremental migration first, + // fall back to full recreation if no migration path exists. stepStart = this.trackProgress("Handle schema version mismatch", stepStart); debug(`Database schema version ${currentVersion} does not match code version ${CURRENT_SCHEMA_VERSION}`); - debug("FULL RECREATION: No partial migrations - deleting and recreating database from scratch for maximum reliability"); - - // Log schema recreation to console instead of showing to user - debug(`[SQLiteIndex] 🔄 AI updating database schema (v${currentVersion} → v${CURRENT_SCHEMA_VERSION}). Recreating for reliability...`); - // CRITICAL: Delete the database file completely and recreate from scratch - // This handles ALL cases: old schemas, corrupted databases, future schema versions, etc. - await this.deleteDatabaseFile(); + // Only attempt incremental migration for forward upgrades + let migrated = false; + if (currentVersion > 0 && currentVersion < CURRENT_SCHEMA_VERSION) { + migrated = await this.tryIncrementalMigration(currentVersion, CURRENT_SCHEMA_VERSION); + } - // Recreate the database with the current schema (version 8) - await this.recreateDatabase(); + if (!migrated) { + await this.nukeDatabaseAndRecreate( + `schema version mismatch (v${currentVersion} → v${CURRENT_SCHEMA_VERSION})` + ); + } - this.trackProgress("Database complete recreation finished", stepStart); - debug(`Database completely recreated with schema version ${CURRENT_SCHEMA_VERSION} - no partial migrations used`); + this.trackProgress("Database schema upgrade finished", stepStart); } else { // Scenario 3: Correct version - load normally stepStart = this.trackProgress("Verify database schema", stepStart); debug(`Schema is up to date (version ${currentVersion})`); - // Schema is current - no additional checks needed + // Verify the database belongs to this project (detects copied/mismatched DBs) + const identityValid = await this.verifyProjectIdentity(); + if (!identityValid) { + await this.nukeDatabaseAndRecreate("project identity mismatch"); + } } this.trackProgress("Database Schema Setup Complete", ensureStart); @@ -552,22 +762,11 @@ export class SQLiteIndexManager { if (isCorruption) { console.error(`[SQLiteIndex] Database corruption detected during schema operations: ${errorMessage}`); - debug("[SQLiteIndex] Recreating corrupted database"); stepStart = this.trackProgress("Recreate corrupted database", stepStart); - // Force recreate the database - this.db = new this.sql!.Database(); - await this.createSchema(); - - // Validate schema before setting version to ensure reliability - if (!this.validateSchemaIntegrity()) { - throw new Error(`Schema validation failed after corruption recovery for version ${CURRENT_SCHEMA_VERSION} - database may be corrupted`); - } - - this.setSchemaVersion(CURRENT_SCHEMA_VERSION); + await this.nukeDatabaseAndRecreate(`corruption during schema ops: ${errorMessage}`); this.trackProgress("Database corruption recovery complete", stepStart); - debug("Successfully recreated database after corruption"); } else { // Re-throw non-corruption errors throw error; @@ -575,134 +774,207 @@ export class SQLiteIndexManager { } } - private async recreateDatabase(): Promise { - if (!this.db) throw new Error("Database not initialized"); - - debug("Dropping all existing tables..."); + /** + * Nuclear option: close the current connection, delete the database file + * (plus WAL/SHM), reopen a fresh connection, recreate the schema from + * scratch, validate it, and stamp the schema version. + * + * This is the single canonical path for "throw everything away and start + * over." All callers (schema mismatch, corruption, identity mismatch) + * should go through this method rather than inlining the sequence. + */ + private async nukeDatabaseAndRecreate(reason: string): Promise { + if (this.closed) throw new Error("Database is closing or closed"); - // Get all table names first - const tablesStmt = this.db.prepare(` - SELECT name FROM sqlite_master - WHERE type='table' AND name NOT LIKE 'sqlite_%' - `); + // Acquire the transaction lock so we don't yank the DB out from under + // a running transaction. + let releaseLock!: () => void; + const previousLock = this.transactionLock; + this.transactionLock = new Promise((resolve) => { releaseLock = resolve; }); + await previousLock; - const tableNames: string[] = []; try { - while (tablesStmt.step()) { - tableNames.push(tablesStmt.getAsObject().name as string); - } - } finally { - tablesStmt.free(); - } + debug(`[SQLiteIndex] Recreating database: ${reason}`); - // Drop all tables in a transaction - await this.runInTransaction(() => { - // Drop FTS table first if it exists - if (tableNames.includes('cells_fts')) { - this.db!.run("DROP TABLE IF EXISTS cells_fts"); + if (this.db) { + try { await this.db.close(); } catch (closeErr) { debug(`Error during cleanup close in nukeDatabaseAndRecreate: ${closeErr}`); } + this.db = null; } - // Drop other tables - for (const tableName of tableNames) { - if (tableName !== 'cells_fts') { - this.db!.run(`DROP TABLE IF EXISTS ${tableName}`); - } + // Best-effort backup: copy the database file before deletion so we + // have a forensic snapshot for diagnosing recurring corruption. + await this.backupDatabaseFile(); + + await this.deleteDatabaseFile(); + + if (!this.dbPath) { + throw new Error("Database path not set"); } - }); - debug("Creating fresh schema..."); - await this.createSchema(); + this.db = await this.openWithRetry(this.dbPath); + // Fresh connection is valid — reset state so operations can proceed + this.closed = false; + this.deferredIndexesCreated = false; - // Yield control after schema creation - await new Promise(resolve => setImmediate(resolve)); + try { + await this.applyProductionPragmas(); + await this.createSchema(); - // Validate schema before setting version to ensure reliability - if (!this.validateSchemaIntegrity()) { - throw new Error(`Schema validation failed after recreation for version ${CURRENT_SCHEMA_VERSION} - database may be corrupted`); - } + if (!(await this.validateSchemaIntegrity())) { + throw new Error(`Schema validation failed after recreation: ${reason}`); + } + + await this.setSchemaVersion(CURRENT_SCHEMA_VERSION); + } catch (schemaError) { + // Clean up the partially-created DB so the next attempt starts fresh + // instead of finding a zombie file with no/broken schema. + console.error(`[SQLiteIndex] Schema setup failed during recreation, cleaning up: ${schemaError}`); + if (this.db) { + try { await this.db.close(); } catch { /* best-effort */ } + this.db = null; + } + try { await this.deleteDatabaseFile(); } catch (cleanupErr) { debug(`Cleanup delete also failed: ${cleanupErr}`); } + throw schemaError; + } - this.setSchemaVersion(CURRENT_SCHEMA_VERSION); + debug(`[SQLiteIndex] Database recreated successfully: ${reason}`); + } finally { + releaseLock(); + } } - private getSchemaVersion(): number { - if (!this.db) return 0; + private async getSchemaVersion(): Promise { + if (!this.db) return -1; // No connection — treat as "unknown", triggers recreate try { - // Check if any tables exist at all (new database check) - const checkAnyTable = this.db.prepare(` - SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' - `); + const countRow = await this.db.get<{ count: number; }>( + "SELECT COUNT(*) as count FROM sqlite_master WHERE type='table'" + ); + if (!countRow || countRow.count === 0) return 0; - let tableCount = 0; - try { - checkAnyTable.step(); - tableCount = checkAnyTable.getAsObject().count as number; - } finally { - checkAnyTable.free(); - } + const tableRow = await this.db.get<{ name: string; }>( + "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_info'" + ); + if (!tableRow) return -1; - if (tableCount === 0) return 0; // Completely new database + const versionRow = await this.db.get<{ version: number; }>( + "SELECT version FROM schema_info WHERE id = 1 LIMIT 1" + ); + return versionRow?.version ?? -1; + } catch (error) { + console.warn("[SQLiteIndex] Failed to read schema version:", error); + return -1; + } + } - // Check if schema_info table exists - const checkTable = this.db.prepare(` - SELECT name FROM sqlite_master - WHERE type='table' AND name='schema_info' - `); - let hasTable = false; - try { - if (checkTable.step()) { - hasTable = checkTable.getAsObject().name === 'schema_info'; - } - } finally { - checkTable.free(); - } + async setSchemaVersion(version: number): Promise { + this.ensureOpen(); - if (!hasTable) return -1; // Unknown version if no schema_info table but other tables exist + await this.db!.run(CREATE_SCHEMA_INFO_SQL); - const stmt = this.db.prepare("SELECT version FROM schema_info WHERE id = 1 LIMIT 1"); - try { - if (stmt.step()) { - const result = stmt.getAsObject(); - return (result.version as number) || -1; - } - return -1; // No version found - } finally { - stmt.free(); + // Read the real project identity from metadata.json so we can detect + // when an indexes.sqlite is copied from a different project. + const identity = await this.getProjectIdentity(); + + await this.runInTransaction(async () => { + await this.db!.run("DELETE FROM schema_info"); + await this.db!.run( + "INSERT INTO schema_info (id, version, project_id, project_name) VALUES (1, ?, ?, ?)", + [version, identity?.projectId ?? null, identity?.projectName ?? null] + ); + debug(`Schema version updated to ${version}, project_id=${identity?.projectId}, project_name=${identity?.projectName}`); + }); + } + + /** + * Read the project identity (projectId and projectName) from metadata.json. + * This is the canonical project identity that persists across renames and moves. + */ + private async getProjectIdentity(): Promise<{ projectId: string; projectName: string | null; } | null> { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) return null; + try { + const result = await MetadataManager.safeReadMetadata<{ + projectId?: string; + projectName?: string; + }>(workspaceFolder.uri); + if (result.success && result.metadata?.projectId) { + return { + projectId: result.metadata.projectId, + projectName: result.metadata.projectName ?? null, + }; } } catch { - return -1; // Fallback to unknown version + // metadata.json may not exist yet — that's fine } + return null; } + /** + * Check if the database belongs to the current project. + * Compares the stored project_id against the projectId in metadata.json. + * + * Returns false (triggers re-index) when: + * - DB has a project_id that doesn't match metadata.json + * - DB has no project_id but metadata.json has one (can't prove DB belongs here) + * + * Returns true (safe to keep) when: + * - project_id matches metadata.json + * - Both DB and metadata have no projectId (can't verify either way) + * - metadata.json is unavailable (don't reject what we can't check) + */ + private async verifyProjectIdentity(): Promise { + if (!this.db) return false; - setSchemaVersion(version: number): void { - if (!this.db) return; + try { + const row = await this.db.get<{ project_id: string | null; }>( + "SELECT project_id FROM schema_info WHERE id = 1 LIMIT 1" + ); - // Create schema_info table if it doesn't exist - this.db.run(` - CREATE TABLE IF NOT EXISTS schema_info ( - id INTEGER PRIMARY KEY CHECK(id = 1), - version INTEGER NOT NULL - ) - `); + const storedId = row?.project_id ?? null; + const identity = await this.getProjectIdentity(); + + if (!storedId) { + // DB has no identity stamp. + if (identity) { + // metadata.json now has a projectId but DB doesn't — we can't + // prove this DB was created for this project (e.g., it could + // have been copied before identity stamps were added, or a + // swap occurred while the DB had no stamp). Re-index to be safe. + console.warn( + `[SQLiteIndex] DB has no project_id but metadata.json has ` + + `projectId="${identity.projectId}" — re-indexing for safety` + ); + return false; + } + // Both unknown — can't verify either way, treat as valid + return true; + } - // Clean up any duplicate rows and insert the new version - // Use a transaction to ensure atomicity - this.db.run("BEGIN TRANSACTION"); - try { - // Clean up any existing duplicate rows from old schema - this.db.run("DELETE FROM schema_info"); - this.db.run("INSERT INTO schema_info (id, version) VALUES (1, ?)", [version]); - this.db.run("COMMIT"); - debug(`Schema version updated to ${version}`); - } catch (error) { - this.db.run("ROLLBACK"); - console.error("Failed to set schema version:", error); - throw error; + // DB has a stored project_id + if (!identity) { + // Can't read metadata — don't reject + return true; + } + + if (storedId !== identity.projectId) { + console.warn( + `[SQLiteIndex] Project identity mismatch: DB has project_id="${storedId}", ` + + `but metadata.json has projectId="${identity.projectId}" (${identity.projectName}). ` + + `Database may have been copied from another project.` + ); + return false; + } + + return true; + } catch { + // Column may not exist in legacy schema — treat as valid + return true; } } + private computeContentHash(content: string): string { return createHash("sha256").update(content).digest("hex"); } @@ -716,44 +988,42 @@ export class SQLiteIndexManager { fileType: "source" | "codex", lastModifiedMs: number ): Promise { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); // Handle both URI strings and file paths const fileUri = filePath.startsWith('file:') ? vscode.Uri.parse(filePath) : vscode.Uri.file(filePath); const fileContent = await vscode.workspace.fs.readFile(fileUri); const contentHash = this.computeContentHash(fileContent.toString()); - const stmt = this.db.prepare(` - INSERT INTO files (file_path, file_type, last_modified_ms, content_hash) - VALUES (?, ?, ?, ?) - ON CONFLICT(file_path) DO UPDATE SET - last_modified_ms = excluded.last_modified_ms, - content_hash = excluded.content_hash, - updated_at = strftime('%s', 'now') * 1000 - RETURNING id - `); - - try { - stmt.bind([filePath, fileType, lastModifiedMs, contentHash]); - stmt.step(); - const result = stmt.getAsObject(); - return result.id as number; - } finally { - stmt.free(); - } + // Retry on SQLITE_BUSY since this runs outside a transaction + return this.withBusyRetry(async () => { + const result = await this.db!.get<{ id: number; }>(` + INSERT INTO files (file_path, file_type, last_modified_ms, content_hash) + VALUES (?, ?, ?, ?) + ON CONFLICT(file_path) DO UPDATE SET + last_modified_ms = excluded.last_modified_ms, + content_hash = excluded.content_hash, + updated_at = strftime('%s', 'now') * 1000 + RETURNING id + `, [filePath, fileType, lastModifiedMs, contentHash]); + return result?.id ?? 0; + }); } - // Synchronous version for use within transactions - upsertFileSync( + // Lightweight upsert for use within existing transactions (no file I/O). + // When a real content hash is available from the caller, pass it via + // contentHash; otherwise a synthetic hash is used as a fallback. + async upsertFileSync( filePath: string, fileType: "source" | "codex", - lastModifiedMs: number - ): number { - if (!this.db) throw new Error("Database not initialized"); + lastModifiedMs: number, + contentHash?: string + ): Promise { + this.ensureOpen(); - const contentHash = this.computeContentHash(filePath + lastModifiedMs); + const hash = contentHash ?? this.computeContentHash(filePath + lastModifiedMs); - const stmt = this.db.prepare(` + const result = await this.db!.get<{ id: number; }>(` INSERT INTO files (file_path, file_type, last_modified_ms, content_hash) VALUES (?, ?, ?, ?) ON CONFLICT(file_path) DO UPDATE SET @@ -761,16 +1031,8 @@ export class SQLiteIndexManager { content_hash = excluded.content_hash, updated_at = strftime('%s', 'now') * 1000 RETURNING id - `); - - try { - stmt.bind([filePath, fileType, lastModifiedMs, contentHash]); - stmt.step(); - const result = stmt.getAsObject(); - return result.id as number; - } finally { - stmt.free(); - } + `, [filePath, fileType, lastModifiedMs, hash]); + return result?.id ?? 0; } async upsertCell( @@ -783,7 +1045,7 @@ export class SQLiteIndexManager { rawContent?: string, milestoneIndex?: number | null ): Promise<{ id: string; isNew: boolean; contentChanged: boolean; }> { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); // Use rawContent if provided, otherwise fall back to content const actualRawContent = rawContent || content; @@ -796,21 +1058,11 @@ export class SQLiteIndexManager { const currentTimestamp = Date.now(); // Check if cell exists and if content changed - const checkStmt = this.db.prepare(` + const existingCell = await this.db!.get<{ cell_id: string; hash: string | null; }>(` SELECT cell_id, ${cellType === 'source' ? 's_raw_content_hash' : 't_raw_content_hash'} as hash FROM cells WHERE cell_id = ? - `); - - let existingCell: { cell_id: string; hash: string | null; } | null = null; - try { - checkStmt.bind([cellId]); - if (checkStmt.step()) { - existingCell = checkStmt.getAsObject() as any; - } - } finally { - checkStmt.free(); - } + `, [cellId]); const contentChanged = !existingCell || existingCell.hash !== rawContentHash; const isNew = !existingCell; @@ -890,19 +1142,10 @@ export class SQLiteIndexManager { // If no content, t_created_at remains NULL (will be set when first content is added) } else { // Existing target cell: check if t_created_at is NULL and we're adding first content - const checkCreatedStmt = this.db.prepare(` + const createdAtRow = await this.db!.get<{ t_created_at: number | null; }>(` SELECT t_created_at FROM cells WHERE cell_id = ? LIMIT 1 - `); - - let currentCreatedAt: number | null = null; - try { - checkCreatedStmt.bind([cellId]); - if (checkCreatedStmt.step()) { - currentCreatedAt = checkCreatedStmt.getAsObject().t_created_at as number | null; - } - } finally { - checkCreatedStmt.free(); - } + `, [cellId]); + const currentCreatedAt = createdAtRow?.t_created_at ?? null; // If t_created_at is NULL and we're adding content, set it to current edit timestamp if (currentCreatedAt === null && content && content.trim() !== '') { @@ -922,26 +1165,18 @@ export class SQLiteIndexManager { // Created_at logic is handled above based on cell type and content presence // Upsert the cell - const upsertStmt = this.db.prepare(` + await this.db!.run(` INSERT INTO cells (cell_id, ${columns.join(', ')}) VALUES (?, ${values.map(() => '?').join(', ')}) ON CONFLICT(cell_id) DO UPDATE SET ${columns.map(col => `${col} = excluded.${col}`).join(', ')} - `); - - try { - upsertStmt.bind([cellId, ...values]); - upsertStmt.step(); + `, [cellId, ...values]); - this.debouncedSave(); - return { id: cellId, isNew, contentChanged }; - } finally { - upsertStmt.free(); - } + return { id: cellId, isNew, contentChanged }; } // Synchronous version for use within transactions - upsertCellSync( + async upsertCellSync( cellId: string, fileId: number, cellType: "source" | "target", @@ -950,8 +1185,8 @@ export class SQLiteIndexManager { metadata?: any, rawContent?: string, milestoneIndex?: number | null - ): { id: string; isNew: boolean; contentChanged: boolean; } { - if (!this.db) throw new Error("Database not initialized"); + ): Promise<{ id: string; isNew: boolean; contentChanged: boolean; }> { + this.ensureOpen(); // Use rawContent if provided, otherwise fall back to content const actualRawContent = rawContent || content; @@ -964,21 +1199,11 @@ export class SQLiteIndexManager { const currentTimestamp = Date.now(); // Check if cell exists and if content changed - const checkStmt = this.db.prepare(` + const existingCell = await this.db!.get<{ cell_id: string; hash: string | null; }>(` SELECT cell_id, ${cellType === 'source' ? 's_raw_content_hash' : 't_raw_content_hash'} as hash FROM cells WHERE cell_id = ? - `); - - let existingCell: { cell_id: string; hash: string | null; } | null = null; - try { - checkStmt.bind([cellId]); - if (checkStmt.step()) { - existingCell = checkStmt.getAsObject() as any; - } - } finally { - checkStmt.free(); - } + `, [cellId]); const contentChanged = !existingCell || existingCell.hash !== rawContentHash; const isNew = !existingCell; @@ -1058,19 +1283,10 @@ export class SQLiteIndexManager { // If no content, t_created_at remains NULL (will be set when first content is added) } else { // Existing target cell: check if t_created_at is NULL and we're adding first content - const checkCreatedStmt = this.db.prepare(` + const createdAtRow = await this.db!.get<{ t_created_at: number | null; }>(` SELECT t_created_at FROM cells WHERE cell_id = ? LIMIT 1 - `); - - let currentCreatedAt: number | null = null; - try { - checkCreatedStmt.bind([cellId]); - if (checkCreatedStmt.step()) { - currentCreatedAt = checkCreatedStmt.getAsObject().t_created_at as number | null; - } - } finally { - checkCreatedStmt.free(); - } + `, [cellId]); + const currentCreatedAt = createdAtRow?.t_created_at ?? null; // If t_created_at is NULL and we're adding content, set it to current edit timestamp if (currentCreatedAt === null && content && content.trim() !== '') { @@ -1090,22 +1306,14 @@ export class SQLiteIndexManager { // Created_at logic is handled above based on cell type and content presence // Upsert the cell - const upsertStmt = this.db.prepare(` + await this.db!.run(` INSERT INTO cells (cell_id, ${columns.join(', ')}) VALUES (?, ${values.map(() => '?').join(', ')}) ON CONFLICT(cell_id) DO UPDATE SET ${columns.map(col => `${col} = excluded.${col}`).join(', ')} - `); + `, [cellId, ...values]); - try { - upsertStmt.bind([cellId, ...values]); - upsertStmt.step(); - - // Note: Don't call debouncedSave() in sync version as it's async - return { id: cellId, isNew, contentChanged }; - } finally { - upsertStmt.free(); - } + return { id: cellId, isNew, contentChanged }; } // Add a single document (DEPRECATED - use FileSyncManager instead) @@ -1132,45 +1340,45 @@ export class SQLiteIndexManager { return; } - // Remove all documents + // Remove all documents and sync metadata async removeAll(): Promise { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); // Use a transaction for better performance and make it non-blocking - await this.runInTransaction(() => { + await this.runInTransaction(async () => { // Delete in reverse dependency order to avoid foreign key issues - this.db!.run("DELETE FROM cells_fts"); - // translation_pairs table no longer exists in schema v8 - this.db!.run("DELETE FROM words"); - this.db!.run("DELETE FROM cells"); - this.db!.run("DELETE FROM files"); - }); - - // Use setImmediate to make the save operation non-blocking - setImmediate(() => { - this.debouncedSave(); + await this.db!.run("DELETE FROM cells_fts"); + await this.db!.run("DELETE FROM words"); + await this.db!.run("DELETE FROM cells"); + await this.db!.run("DELETE FROM files"); + // Also clear sync_metadata so checkFilesForSync doesn't think + // files are already synced after a removeAll + re-sync. + await this.db!.run("DELETE FROM sync_metadata"); }); } // Get document count get documentCount(): number { - if (!this.db) return 0; + // Deprecated: Use getDocumentCount() instead for async access + // This getter is kept for backward compatibility but will be removed + throw new Error("documentCount getter is deprecated. Use async getDocumentCount() instead."); + } - const stmt = this.db.prepare("SELECT COUNT(DISTINCT cell_id) as count FROM cells"); - try { - stmt.step(); - const result = stmt.getAsObject(); - return (result.count as number) || 0; - } finally { - stmt.free(); - } + async getDocumentCount(): Promise { + this.ensureOpen(); + + const row = await this.db!.get<{ count: number; }>("SELECT COUNT(DISTINCT cell_id) as count FROM cells"); + return (row?.count as number) || 0; } /** - * Get database instance for advanced operations (use with caution) + * Get database instance for advanced operations (use with caution). + * Throws if the manager has been closed or the database is not initialized, + * preventing use-after-close bugs. */ - get database(): Database | null { - return this.db; + get database(): AsyncDatabase { + this.ensureOpen(); + return this.db!; } // Search with MiniSearch-compatible interface (minisearch was deprecated–thankfully. We're now using SQLite3 and FTS5.) @@ -1186,8 +1394,8 @@ export class SQLiteIndexManager { * @param options.isParallelPassagesWebview - If true, this search is for the search passages webview display (default: false) * @returns Array of search results with raw or sanitized content based on options */ - search(query: string, options?: any): any[] { - if (!this.db) return []; + async search(query: string, options?: SearchOptions): Promise { + this.ensureOpen(); const limit = options?.limit || 50; const fuzzy = options?.fuzzy || 0.2; @@ -1249,7 +1457,22 @@ export class SQLiteIndexManager { return []; } - const stmt = this.db.prepare(` + // Always search using the sanitized content column for better matching + const ftsSearchQuery = `content: ${ftsQuery}`; + const rows = await this.db!.all<{ + cell_id: string; + content: string; + content_type: string; + s_content: string; + s_raw_content: string; + s_line_number: number; + t_content: string; + t_raw_content: string; + t_line_number: number; + s_file_path: string; + t_file_path: string; + score: number; + }>(` SELECT cells_fts.cell_id, cells_fts.content, @@ -1270,80 +1493,68 @@ export class SQLiteIndexManager { WHERE cells_fts MATCH ? ORDER BY score ASC LIMIT ? - `); + `, [ftsSearchQuery, limit]); const results = []; - try { - // Always search using the sanitized content column for better matching - const ftsSearchQuery = `content: ${ftsQuery}`; - stmt.bind([ftsSearchQuery, limit]); - while (stmt.step()) { - const row = stmt.getAsObject(); - - // Determine the content type from FTS entry - const contentType = row.content_type as string; // 'source' or 'target' - - // Get the appropriate content and metadata based on content type - let content, rawContent, line, uri, metadata; - - if (contentType === 'source') { - content = row.s_content; - rawContent = row.s_raw_content; - line = row.s_line_number; - uri = row.s_file_path; - metadata = {}; // Metadata now in dedicated columns - } else { - content = row.t_content; - rawContent = row.t_raw_content; - line = row.t_line_number; - uri = row.t_file_path; - metadata = {}; // Metadata now in dedicated columns - } - - // Verify both columns contain data - no fallbacks - if (!content || !rawContent) { - debug(`[SQLiteIndex] Cell ${row.cell_id} missing content data:`, { - content: !!content, - raw_content: !!rawContent, - content_type: contentType - }); - continue; // Skip this result - } - - // Choose which content to return based on use case - const contentToReturn = returnRawContent ? rawContent : content; - - // Format result to match MiniSearch output (minisearch was deprecated–thankfully. We're now using SQLite3 and FTS5.) - const result: any = { - id: row.cell_id, - cellId: row.cell_id, - score: row.score, - match: {}, // MiniSearch compatibility (minisearch was deprecated–thankfully. We're now using SQLite3 and FTS5.) - uri: uri, - line: line, - }; + for (const row of rows) { + // Determine the content type from FTS entry + const contentType = row.content_type as string; // 'source' or 'target' + + // Get the appropriate content and metadata based on content type + let content, rawContent, line, uri, metadata; + + if (contentType === 'source') { + content = row.s_content; + rawContent = row.s_raw_content; + line = row.s_line_number; + uri = row.s_file_path; + metadata = {}; // Metadata now in dedicated columns + } else { + content = row.t_content; + rawContent = row.t_raw_content; + line = row.t_line_number; + uri = row.t_file_path; + metadata = {}; // Metadata now in dedicated columns + } - // Add content based on cell type - always provide both versions for transparency - if (contentType === "source") { - result.sourceContent = contentToReturn; - result.content = contentToReturn; - // Always provide both versions for debugging/transparency - result.sanitizedContent = content; - result.rawContent = rawContent; - } else { - result.targetContent = contentToReturn; - // Always provide both versions for debugging/transparency - result.sanitizedTargetContent = content; - result.rawTargetContent = rawContent; - } + // Verify both columns contain data - no fallbacks + if (!content || !rawContent) { + debug(`[SQLiteIndex] Cell ${row.cell_id} missing content data:`, { + content: !!content, + raw_content: !!rawContent, + content_type: contentType + }); + continue; // Skip this result + } - // Add metadata fields - Object.assign(result, metadata); + // Choose which content to return based on use case + const contentToReturn = returnRawContent ? rawContent : content; + + // Format result to match MiniSearch output (minisearch was deprecated–thankfully. We're now using SQLite3 and FTS5.) + const result: SearchResult = { + id: row.cell_id, + cellId: row.cell_id, + score: row.score, + match: {}, // MiniSearch compatibility (minisearch was deprecated–thankfully. We're now using SQLite3 and FTS5.) + uri: uri, + line: line, + }; - results.push(result); + // Add content based on cell type - always provide both versions for transparency + if (contentType === "source") { + result.sourceContent = contentToReturn; + result.content = contentToReturn; + // Always provide both versions for debugging/transparency + result.sanitizedContent = content; + result.rawContent = rawContent; + } else { + result.targetContent = contentToReturn; + // Always provide both versions for debugging/transparency + result.sanitizedTargetContent = content; + result.rawTargetContent = rawContent; } - } finally { - stmt.free(); + + results.push(result); } return results; @@ -1358,15 +1569,30 @@ export class SQLiteIndexManager { * @param options - Search options (same as search method) * @returns Array of search results with sanitized content (no HTML tags) */ - searchSanitized(query: string, options?: any): any[] { - return this.search(query, { ...options, returnRawContent: false }); + async searchSanitized(query: string, options?: SearchOptions): Promise { + return await this.search(query, { ...options, returnRawContent: false }); } // Get document by ID (for source text index compatibility) - async getById(cellId: string): Promise { - if (!this.db) return null; - - const stmt = this.db.prepare(` + async getById(cellId: string): Promise { + this.ensureOpen(); + + const row = await this.db!.get<{ + cell_id: string; + s_content: string; + s_raw_content: string; + s_file_path: string; + t_content: string; + t_raw_content: string; + t_current_edit_timestamp: number | null; + t_validation_count: number; + t_validated_by: string | null; + t_is_fully_validated: boolean; + t_audio_validation_count: number; + t_audio_validated_by: string | null; + t_audio_is_fully_validated: boolean; + t_file_path: string; + }>(` SELECT c.cell_id, -- Source columns @@ -1388,41 +1614,34 @@ export class SQLiteIndexManager { LEFT JOIN files s_file ON c.s_file_id = s_file.id LEFT JOIN files t_file ON c.t_file_id = t_file.id WHERE c.cell_id = ? - `); - - try { - stmt.bind([cellId]); - if (stmt.step()) { - const row = stmt.getAsObject(); - - // Construct metadata from dedicated columns - const sourceMetadata = {}; - const targetMetadata = { - currentEditTimestamp: row.t_current_edit_timestamp || null, - validationCount: row.t_validation_count || 0, - validatedBy: row.t_validated_by ? row.t_validated_by.split(',') : [], - isFullyValidated: Boolean(row.t_is_fully_validated), - audioValidationCount: row.t_audio_validation_count || 0, - audioValidatedBy: row.t_audio_validated_by ? row.t_audio_validated_by.split(',') : [], - audioIsFullyValidated: Boolean(row.t_audio_is_fully_validated) - }; + `, [cellId]); + + if (row) { + // Construct metadata from dedicated columns + const sourceMetadata = {}; + const targetMetadata = { + currentEditTimestamp: row.t_current_edit_timestamp || null, + validationCount: row.t_validation_count || 0, + validatedBy: row.t_validated_by ? row.t_validated_by.split(',') : [], + isFullyValidated: Boolean(row.t_is_fully_validated), + audioValidationCount: row.t_audio_validation_count || 0, + audioValidatedBy: row.t_audio_validated_by ? row.t_audio_validated_by.split(',') : [], + audioIsFullyValidated: Boolean(row.t_audio_is_fully_validated) + }; - return { - cellId: cellId, - content: row.s_raw_content || row.s_content || "", // Prefer source raw content - versions: [], // Versions now tracked in dedicated columns - sourceContent: row.s_content, - targetContent: row.t_content, - sourceRawContent: row.s_raw_content, - targetRawContent: row.t_raw_content, - source_file_path: row.s_file_path, - target_file_path: row.t_file_path, - source_metadata: sourceMetadata, - target_metadata: targetMetadata, - }; - } - } finally { - stmt.free(); + return { + cellId: cellId, + content: row.s_raw_content || row.s_content || "", // Prefer source raw content + versions: [], // Versions now tracked in dedicated columns + sourceContent: row.s_content, + targetContent: row.t_content, + sourceRawContent: row.s_raw_content, + targetRawContent: row.t_raw_content, + source_file_path: row.s_file_path, + target_file_path: row.t_file_path, + source_metadata: sourceMetadata, + target_metadata: targetMetadata, + }; } return null; @@ -1435,9 +1654,9 @@ export class SQLiteIndexManager { public async getSourceCellsMapForFile( sourceFilePath?: string ): Promise<{ [k: string]: { content: string; versions: string[]; }; }> { - if (!this.db) return {}; + this.ensureOpen(); - const build = (pathA?: string, pathB?: string) => { + const build = async (pathA?: string, pathB?: string): Promise<{ [k: string]: { content: string; versions: string[]; }; }> => { const result: { [k: string]: { content: string; versions: string[]; }; } = {}; let sql = ` SELECT c.cell_id AS cell_id, @@ -1446,30 +1665,24 @@ export class SQLiteIndexManager { LEFT JOIN files s_file ON c.s_file_id = s_file.id WHERE c.s_content IS NOT NULL AND c.s_content != '' `; - const params: any[] = []; + const params: string[] = []; if (pathA || pathB) { if (pathA && pathB && pathA !== pathB) { sql += ` AND (s_file.file_path = ? OR s_file.file_path = ?)`; params.push(pathA, pathB); } else { - const only = pathA || pathB; + const only = pathA ?? pathB ?? ""; sql += ` AND s_file.file_path = ?`; params.push(only); } } - const stmt = this.db!.prepare(sql); - try { - stmt.bind(params); - while (stmt.step()) { - const row = stmt.getAsObject(); - const cellId = String(row.cell_id); - const content = String(row.content || ""); - result[cellId] = { content, versions: [] }; - } - } finally { - stmt.free(); + const rows = await this.db!.all<{ cell_id: string; content: string; }>(sql, params); + for (const row of rows) { + const cellId = String(row.cell_id); + const content = String(row.content || ""); + result[cellId] = { content, versions: [] }; } return result; }; @@ -1478,27 +1691,46 @@ export class SQLiteIndexManager { if (sourceFilePath) { const isUri = sourceFilePath.startsWith("file:"); const fsPathVariant = isUri ? vscode.Uri.parse(sourceFilePath).fsPath : undefined; - result = build(sourceFilePath, fsPathVariant); + result = await build(sourceFilePath, fsPathVariant); if (Object.keys(result).length === 0) { // Retry with swapped order just in case - result = build(fsPathVariant, sourceFilePath); + result = await build(fsPathVariant, sourceFilePath); } if (Object.keys(result).length === 0) { // Fallback to unfiltered - result = build(); + result = await build(); } } else { - result = build(); + result = await build(); } return result; } // Get cell by exact ID match (for translation pairs) - async getCellById(cellId: string, cellType?: "source" | "target"): Promise { - if (!this.db) return null; - - const stmt = this.db.prepare(` + async getCellById(cellId: string, cellType?: "source" | "target"): Promise { + this.ensureOpen(); + + const row = await this.db!.get<{ + cell_id: string; + s_content: string; + s_raw_content: string; + s_line_number: number; + s_file_path: string; + s_file_type: string; + t_content: string; + t_raw_content: string; + t_line_number: number; + t_current_edit_timestamp: number | null; + t_validation_count: number; + t_validated_by: string | null; + t_is_fully_validated: boolean; + t_audio_validation_count: number; + t_audio_validated_by: string | null; + t_audio_is_fully_validated: boolean; + t_file_path: string; + t_file_type: string; + }>(` SELECT c.cell_id, -- Source columns @@ -1524,27 +1756,45 @@ export class SQLiteIndexManager { LEFT JOIN files s_file ON c.s_file_id = s_file.id LEFT JOIN files t_file ON c.t_file_id = t_file.id WHERE c.cell_id = ? - `); + `, [cellId]); + + if (row) { + // Construct metadata from dedicated columns + const sourceMetadata = {}; + const targetMetadata = { + currentEditTimestamp: row.t_current_edit_timestamp || null, + validationCount: row.t_validation_count || 0, + validatedBy: row.t_validated_by ? row.t_validated_by.split(',') : [], + isFullyValidated: Boolean(row.t_is_fully_validated), + audioValidationCount: row.t_audio_validation_count || 0, + audioValidatedBy: row.t_audio_validated_by ? row.t_audio_validated_by.split(',') : [], + audioIsFullyValidated: Boolean(row.t_audio_is_fully_validated) + }; - try { - stmt.bind([cellId]); - if (stmt.step()) { - const row = stmt.getAsObject(); - - // Construct metadata from dedicated columns - const sourceMetadata = {}; - const targetMetadata = { - currentEditTimestamp: row.t_current_edit_timestamp || null, - validationCount: row.t_validation_count || 0, - validatedBy: row.t_validated_by ? row.t_validated_by.split(',') : [], - isFullyValidated: Boolean(row.t_is_fully_validated), - audioValidationCount: row.t_audio_validation_count || 0, - audioValidatedBy: row.t_audio_validated_by ? row.t_audio_validated_by.split(',') : [], - audioIsFullyValidated: Boolean(row.t_audio_is_fully_validated) + // Return data based on requested cell type + if (cellType === "source" && row.s_content) { + return { + cellId: row.cell_id, + content: row.s_content, + rawContent: row.s_raw_content, + cell_type: "source", + uri: row.s_file_path, + line: row.s_line_number, + ...sourceMetadata, }; - - // Return data based on requested cell type - if (cellType === "source" && row.s_content) { + } else if (cellType === "target" && row.t_content) { + return { + cellId: row.cell_id, + content: row.t_content, + rawContent: row.t_raw_content, + cell_type: "target", + uri: row.t_file_path, + line: row.t_line_number, + ...targetMetadata, + }; + } else if (!cellType) { + // Return source if available, otherwise target + if (row.s_content) { return { cellId: row.cell_id, content: row.s_content, @@ -1554,7 +1804,7 @@ export class SQLiteIndexManager { line: row.s_line_number, ...sourceMetadata, }; - } else if (cellType === "target" && row.t_content) { + } else if (row.t_content) { return { cellId: row.cell_id, content: row.t_content, @@ -1564,41 +1814,16 @@ export class SQLiteIndexManager { line: row.t_line_number, ...targetMetadata, }; - } else if (!cellType) { - // Return source if available, otherwise target - if (row.s_content) { - return { - cellId: row.cell_id, - content: row.s_content, - rawContent: row.s_raw_content, - cell_type: "source", - uri: row.s_file_path, - line: row.s_line_number, - ...sourceMetadata, - }; - } else if (row.t_content) { - return { - cellId: row.cell_id, - content: row.t_content, - rawContent: row.t_raw_content, - cell_type: "target", - uri: row.t_file_path, - line: row.t_line_number, - ...targetMetadata, - }; - } } } - } finally { - stmt.free(); } return null; } // Get translation pair by cell ID - async getTranslationPair(cellId: string): Promise { - if (!this.db) return null; + async getTranslationPair(cellId: string): Promise { + this.ensureOpen(); const sourceCell = await this.getCellById(cellId, "source"); const targetCell = await this.getCellById(cellId, "target"); @@ -1607,49 +1832,53 @@ export class SQLiteIndexManager { return { cellId, - sourceContent: sourceCell?.content || "", - targetContent: targetCell?.content || "", - rawSourceContent: sourceCell?.rawContent || "", - rawTargetContent: targetCell?.rawContent || "", - document: sourceCell?.document || targetCell?.document, - section: sourceCell?.section || targetCell?.section, - uri: sourceCell?.uri || targetCell?.uri, - line: sourceCell?.line || targetCell?.line, + sourceContent: sourceCell?.content ?? "", + targetContent: targetCell?.content ?? "", + rawSourceContent: sourceCell?.rawContent ?? "", + rawTargetContent: targetCell?.rawContent ?? "", + uri: sourceCell?.uri ?? targetCell?.uri, + line: sourceCell?.line ?? targetCell?.line, }; } - // Update word index for a cell + // Update word index for a cell — batched in a single transaction for performance. + // Uses chunked bulk INSERT to reduce round-trips (e.g., 200 words → 4 statements + // instead of 200). Uses runInTransactionWithRetry for SQLITE_BUSY resilience. async updateWordIndex(cellId: string, content: string): Promise { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); - // Clear existing words for this cell (cell_id is now TEXT) - this.db.run("DELETE FROM words WHERE cell_id = ?", [cellId]); - - // Add new words + // Tokenize and count const words = content .toLowerCase() .split(/\s+/) .filter((w) => w.length > 0); const wordCounts = new Map(); - - words.forEach((word, position) => { + for (const word of words) { wordCounts.set(word, (wordCounts.get(word) || 0) + 1); - }); + } - const stmt = this.db.prepare( - "INSERT INTO words (word, cell_id, position, frequency) VALUES (?, ?, ?, ?)" - ); + const entries = [...wordCounts.entries()]; + const CHUNK_SIZE = 50; - try { + await this.runInTransactionWithRetry(async () => { + // Clear existing words for this cell + await this.db!.run("DELETE FROM words WHERE cell_id = ?", [cellId]); + + // Bulk insert words in chunks of CHUNK_SIZE for fewer round-trips let position = 0; - for (const [word, frequency] of wordCounts) { - stmt.bind([word, cellId, position++, frequency]); - stmt.step(); - stmt.reset(); + for (let i = 0; i < entries.length; i += CHUNK_SIZE) { + const chunk = entries.slice(i, i + CHUNK_SIZE); + const placeholders = chunk.map(() => "(?, ?, ?, ?)").join(", "); + const params: (string | number)[] = []; + for (const [word, frequency] of chunk) { + params.push(word, cellId, position++, frequency); + } + await this.db!.run( + `INSERT INTO words (word, cell_id, position, frequency) VALUES ${placeholders}`, + params + ); } - } finally { - stmt.free(); - } + }); } /** @@ -1667,8 +1896,8 @@ export class SQLiteIndexManager { cellType?: "source" | "target", limit: number = 50, returnRawContent: boolean = false - ): Promise { - if (!this.db) throw new Error("Database not initialized"); + ): Promise { + this.ensureOpen(); // Reuse the same escaping logic from the search method const escapeForFTS5 = (text: string): string => { @@ -1728,47 +1957,50 @@ export class SQLiteIndexManager { WHERE cells_fts MATCH ? `; - const params: any[] = [`content: ${ftsQuery}`]; + const params: (string | number)[] = [`content: ${ftsQuery}`]; if (cellType) { sql += ` AND cells_fts.content_type = ?`; params.push(cellType); } - sql += ` ORDER BY score ASC LIMIT ?`; - params.push(limit); - - const stmt = this.db.prepare(sql); - const results = []; - - try { - stmt.bind(params); - while (stmt.step()) { - const row = stmt.getAsObject(); - - // Verify content exists - if (!row.content) { - debug(`[SQLiteIndex] Cell ${row.cell_id} missing content data`); - continue; - } - - results.push({ - cellId: row.cell_id, - cell_id: row.cell_id, - content: returnRawContent ? (row.raw_content || row.content) : row.content, - rawContent: row.raw_content, - sourceContent: row.cell_type === 'source' ? row.content : undefined, - targetContent: row.cell_type === 'target' ? row.content : undefined, - cell_type: row.cell_type, - uri: row.file_path, - line: row.line, - score: row.score, - word_count: row.word_count, - file_type: row.file_type - }); + sql += ` ORDER BY score ASC LIMIT ?`; + params.push(limit); + + const rows = await this.db!.all<{ + cell_id: string; + content: string; + raw_content: string; + word_count: number; + line: number; + file_path: string; + file_type: string; + cell_type: string; + score: number; + }>(sql, params); + + const results = []; + for (const row of rows) { + // Verify content exists + if (!row.content) { + debug(`[SQLiteIndex] Cell ${row.cell_id} missing content data`); + continue; } - } finally { - stmt.free(); + + results.push({ + cellId: row.cell_id, + cell_id: row.cell_id, + content: returnRawContent ? (row.raw_content || row.content) : row.content, + rawContent: row.raw_content, + sourceContent: row.cell_type === 'source' ? row.content : undefined, + targetContent: row.cell_type === 'target' ? row.content : undefined, + cell_type: row.cell_type as "source" | "target", + uri: row.file_path, + line: row.line, + score: row.score, + word_count: row.word_count, + file_type: row.file_type + }); } return results; @@ -1779,8 +2011,8 @@ export class SQLiteIndexManager { query: string, cellType?: "source" | "target", limit: number = 50 - ): Promise { - if (!this.db) throw new Error("Database not initialized"); + ): Promise { + this.ensureOpen(); let sql: string; let params: any[]; @@ -1916,46 +2148,55 @@ export class SQLiteIndexManager { params.push(limit); } - const stmt = this.db.prepare(sql); - const results = []; - - try { - stmt.bind(params); - while (stmt.step()) { - const row = stmt.getAsObject(); - - // Verify content exists - if (!row.content) { - debug(`[SQLiteIndex] Cell ${row.cell_id} missing content data`); - continue; - } + const rows = await this.db!.all<{ + cell_id: string; + content: string; + raw_content: string; + cell_type: string; + uri: string; + line: number; + score: number; + word_count: number; + file_type: string; + }>(sql, params); - results.push({ - cellId: row.cell_id, - cell_id: row.cell_id, - content: row.content, - rawContent: row.raw_content, - sourceContent: row.cell_type === 'source' ? row.content : undefined, - targetContent: row.cell_type === 'target' ? row.content : undefined, - cell_type: row.cell_type, - uri: row.uri, - line: row.line, - score: row.score, - word_count: row.word_count, - file_type: row.file_type - }); + const results = []; + for (const row of rows) { + // Verify content exists + if (!row.content) { + debug(`[SQLiteIndex] Cell ${row.cell_id} missing content data`); + continue; } - } finally { - stmt.free(); + + results.push({ + cellId: row.cell_id, + cell_id: row.cell_id, + content: row.content, + rawContent: row.raw_content, + sourceContent: row.cell_type === 'source' ? row.content : undefined, + targetContent: row.cell_type === 'target' ? row.content : undefined, + cell_type: row.cell_type as "source" | "target", + uri: row.uri, + line: row.line, + score: row.score, + word_count: row.word_count, + file_type: row.file_type + }); } return results; } - async getFileStats(): Promise> { - if (!this.db) throw new Error("Database not initialized"); + async getFileStats(): Promise> { + this.ensureOpen(); - const stmt = this.db.prepare(` + const rows = await this.db!.all<{ + id: number; + file_path: string; + file_type: string; + cell_count: number; + total_words: number; + }>(` SELECT f.id, f.file_path, f.file_type, COUNT(CASE WHEN c.s_file_id = f.id THEN 1 END) + @@ -1968,13 +2209,8 @@ export class SQLiteIndexManager { `); const stats = new Map(); - try { - while (stmt.step()) { - const row = stmt.getAsObject(); - stats.set(row.file_path as string, row); - } - } finally { - stmt.free(); + for (const row of rows) { + stats.set(row.file_path, row); } return stats; @@ -1989,9 +2225,17 @@ export class SQLiteIndexManager { cellsWithMissingContent: number; cellsWithMissingRawContent: number; }> { - if (!this.db) throw new Error("Database not initialized"); - - const stmt = this.db.prepare(` + this.ensureOpen(); + + const result = await this.db!.get<{ + total_cells: number; + cells_with_raw_content: number; + cells_with_different_content: number; + avg_content_length: number; + avg_raw_content_length: number; + cells_with_missing_content: number; + cells_with_missing_raw_content: number; + }>(` SELECT COUNT(*) as total_cells, (COUNT(s_raw_content) + COUNT(t_raw_content)) as cells_with_raw_content, @@ -2006,21 +2250,27 @@ export class SQLiteIndexManager { FROM cells `); - try { - stmt.step(); - const result = stmt.getAsObject(); + if (!result) { return { - totalCells: (result.total_cells as number) || 0, - cellsWithRawContent: (result.cells_with_raw_content as number) || 0, - cellsWithDifferentContent: (result.cells_with_different_content as number) || 0, - avgContentLength: (result.avg_content_length as number) || 0, - avgRawContentLength: (result.avg_raw_content_length as number) || 0, - cellsWithMissingContent: (result.cells_with_missing_content as number) || 0, - cellsWithMissingRawContent: (result.cells_with_missing_raw_content as number) || 0, + totalCells: 0, + cellsWithRawContent: 0, + cellsWithDifferentContent: 0, + avgContentLength: 0, + avgRawContentLength: 0, + cellsWithMissingContent: 0, + cellsWithMissingRawContent: 0, }; - } finally { - stmt.free(); } + + return { + totalCells: result.total_cells || 0, + cellsWithRawContent: result.cells_with_raw_content || 0, + cellsWithDifferentContent: result.cells_with_different_content || 0, + avgContentLength: result.avg_content_length || 0, + avgRawContentLength: result.avg_raw_content_length || 0, + cellsWithMissingContent: result.cells_with_missing_content || 0, + cellsWithMissingRawContent: result.cells_with_missing_raw_content || 0, + }; } // Get translation pair statistics for validation @@ -2031,10 +2281,14 @@ export class SQLiteIndexManager { orphanedSourceCells: number; orphanedTargetCells: number; }> { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); // Count translation pairs from combined source/target rows (schema v8+) - const pairsStmt = this.db.prepare(` + const pairsResult = await this.db!.get<{ + total_pairs: number; + complete_pairs: number; + incomplete_pairs: number; + }>(` SELECT COUNT(*) as total_pairs, SUM(CASE WHEN s_content IS NOT NULL AND s_content != '' AND t_content IS NOT NULL AND t_content != '' THEN 1 ELSE 0 END) as complete_pairs, @@ -2043,22 +2297,12 @@ export class SQLiteIndexManager { WHERE s_content IS NOT NULL OR t_content IS NOT NULL `); - let totalPairs = 0; - let completePairs = 0; - let incompletePairs = 0; - - try { - pairsStmt.step(); - const result = pairsStmt.getAsObject(); - totalPairs = (result.total_pairs as number) || 0; - completePairs = (result.complete_pairs as number) || 0; - incompletePairs = (result.incomplete_pairs as number) || 0; - } finally { - pairsStmt.free(); - } + const totalPairs = pairsResult?.total_pairs || 0; + const completePairs = pairsResult?.complete_pairs || 0; + const incompletePairs = pairsResult?.incomplete_pairs || 0; // Count orphaned source cells (source cells with no corresponding target) - const orphanedSourceStmt = this.db.prepare(` + const orphanedSourceResult = await this.db!.get<{ count: number; }>(` SELECT COUNT(*) as count FROM cells c WHERE c.s_content IS NOT NULL @@ -2066,16 +2310,10 @@ export class SQLiteIndexManager { AND (c.t_content IS NULL OR c.t_content = '') `); - let orphanedSourceCells = 0; - try { - orphanedSourceStmt.step(); - orphanedSourceCells = (orphanedSourceStmt.getAsObject().count as number) || 0; - } finally { - orphanedSourceStmt.free(); - } + const orphanedSourceCells = orphanedSourceResult?.count || 0; // Count orphaned target cells (target cells with no corresponding source) - const orphanedTargetStmt = this.db.prepare(` + const orphanedTargetResult = await this.db!.get<{ count: number; }>(` SELECT COUNT(*) as count FROM cells c WHERE c.t_content IS NOT NULL @@ -2083,13 +2321,7 @@ export class SQLiteIndexManager { AND (c.s_content IS NULL OR c.s_content = '') `); - let orphanedTargetCells = 0; - try { - orphanedTargetStmt.step(); - orphanedTargetCells = (orphanedTargetStmt.getAsObject().count as number) || 0; - } finally { - orphanedTargetStmt.free(); - } + const orphanedTargetCells = orphanedTargetResult?.count || 0; return { totalPairs, @@ -2107,13 +2339,19 @@ export class SQLiteIndexManager { totalCells: number; problematicCells: Array<{ cellId: string, issue: string; }>; }> { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); const issues: string[] = []; const problematicCells: Array<{ cellId: string, issue: string; }> = []; // Check for cells with missing source content (target content can be legitimately blank) - const checkStmt = this.db.prepare(` + const checkRows = await this.db!.all<{ + cell_id: string; + s_content: string | null; + s_raw_content: string | null; + t_content: string | null; + t_raw_content: string | null; + }>(` SELECT cell_id, s_content, s_raw_content, t_content, t_raw_content FROM cells WHERE (s_content IS NOT NULL AND s_content != '' AND (s_raw_content IS NULL OR s_raw_content = '')) @@ -2121,42 +2359,31 @@ export class SQLiteIndexManager { OR (s_content IS NULL OR s_content = '') `); - try { - while (checkStmt.step()) { - const row = checkStmt.getAsObject(); - const cellId = row.cell_id as string; - - // Check source content consistency - if (row.s_content && row.s_content !== '' && (!row.s_raw_content || row.s_raw_content === '')) { - issues.push(`Cell ${cellId} has source content but missing source raw_content`); - problematicCells.push({ cellId, issue: 'missing source raw_content' }); - } + for (const row of checkRows) { + const cellId = row.cell_id; - // Check target content consistency (only if target has content) - if (row.t_content && row.t_content !== '' && (!row.t_raw_content || row.t_raw_content === '')) { - issues.push(`Cell ${cellId} has target content but missing target raw_content`); - problematicCells.push({ cellId, issue: 'missing target raw_content' }); - } + // Check source content consistency + if (row.s_content && row.s_content !== '' && (!row.s_raw_content || row.s_raw_content === '')) { + issues.push(`Cell ${cellId} has source content but missing source raw_content`); + problematicCells.push({ cellId, issue: 'missing source raw_content' }); + } - // Check for missing source content (this is always problematic - source cells should have content) - if (!row.s_content || row.s_content === '') { - issues.push(`Cell ${cellId} has no source content (source cells must have content)`); - problematicCells.push({ cellId, issue: 'missing source content' }); - } + // Check target content consistency (only if target has content) + if (row.t_content && row.t_content !== '' && (!row.t_raw_content || row.t_raw_content === '')) { + issues.push(`Cell ${cellId} has target content but missing target raw_content`); + problematicCells.push({ cellId, issue: 'missing target raw_content' }); + } + + // Check for missing source content (this is always problematic - source cells should have content) + if (!row.s_content || row.s_content === '') { + issues.push(`Cell ${cellId} has no source content (source cells must have content)`); + problematicCells.push({ cellId, issue: 'missing source content' }); } - } finally { - checkStmt.free(); } // Get total cell count - const countStmt = this.db.prepare("SELECT COUNT(*) as total FROM cells"); - let totalCells = 0; - try { - countStmt.step(); - totalCells = countStmt.getAsObject().total as number; - } finally { - countStmt.free(); - } + const countResult = await this.db!.get<{ total: number; }>("SELECT COUNT(*) as total FROM cells"); + const totalCells = countResult?.total || 0; return { isValid: issues.length === 0, @@ -2173,41 +2400,29 @@ export class SQLiteIndexManager { cellsColumns: string[]; ftsColumns: string[]; }> { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); - const version = this.getSchemaVersion(); + const version = await this.getSchemaVersion(); // Get all tables - const tablesStmt = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table'"); + const tablesRows = await this.db!.all<{ name: string; }>("SELECT name FROM sqlite_master WHERE type='table'"); const tables: string[] = []; - try { - while (tablesStmt.step()) { - tables.push(tablesStmt.getAsObject().name as string); - } - } finally { - tablesStmt.free(); + for (const row of tablesRows) { + tables.push(row.name); } // Get cells table columns - const cellsColumnsStmt = this.db.prepare("PRAGMA table_info(cells)"); + const cellsColumnsRows = await this.db!.all<{ name: string; }>("PRAGMA table_info(cells)"); const cellsColumns: string[] = []; - try { - while (cellsColumnsStmt.step()) { - cellsColumns.push(cellsColumnsStmt.getAsObject().name as string); - } - } finally { - cellsColumnsStmt.free(); + for (const row of cellsColumnsRows) { + cellsColumns.push(row.name); } // Get FTS table columns - const ftsColumnsStmt = this.db.prepare("PRAGMA table_info(cells_fts)"); + const ftsColumnsRows = await this.db!.all<{ name: string; }>("PRAGMA table_info(cells_fts)"); const ftsColumns: string[] = []; - try { - while (ftsColumnsStmt.step()) { - ftsColumns.push(ftsColumnsStmt.getAsObject().name as string); - } - } finally { - ftsColumnsStmt.free(); + for (const row of ftsColumnsRows) { + ftsColumns.push(row.name); } return { version, tables, cellsColumns, ftsColumns }; @@ -2217,9 +2432,9 @@ export class SQLiteIndexManager { * Validate that the database schema was created correctly with all expected components * This validation is version-agnostic and works with whatever the current schema version is */ - private validateSchemaIntegrity(): boolean { - if (!this.db) { - debug("Schema validation failed: No database connection"); + private async validateSchemaIntegrity(): Promise { + if (this.closed || !this.db) { + debug("Schema validation failed: database is closed or not initialized"); return false; } @@ -2255,14 +2470,10 @@ export class SQLiteIndexManager { ]; // Check tables exist - const tablesStmt = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table'"); + const tablesRows = await this.db!.all<{ name: string; }>("SELECT name FROM sqlite_master WHERE type='table'"); const actualTables: string[] = []; - try { - while (tablesStmt.step()) { - actualTables.push(tablesStmt.getAsObject().name as string); - } - } finally { - tablesStmt.free(); + for (const row of tablesRows) { + actualTables.push(row.name); } for (const expectedTable of requiredCoreTables) { @@ -2272,15 +2483,11 @@ export class SQLiteIndexManager { } } - // Check cells table has correct v8 structure - const cellsColumnsStmt = this.db.prepare("PRAGMA table_info(cells)"); + // Check cells table has all expected columns (see CURRENT_SCHEMA_VERSION) + const cellsColumnsRows = await this.db!.all<{ name: string; }>("PRAGMA table_info(cells)"); const actualCellsColumns: string[] = []; - try { - while (cellsColumnsStmt.step()) { - actualCellsColumns.push(cellsColumnsStmt.getAsObject().name as string); - } - } finally { - cellsColumnsStmt.free(); + for (const row of cellsColumnsRows) { + actualCellsColumns.push(row.name); } for (const expectedColumn of expectedCellsColumns) { @@ -2291,14 +2498,10 @@ export class SQLiteIndexManager { } // Check essential indexes exist - const indexesStmt = this.db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'"); + const indexesRows = await this.db!.all<{ name: string; }>("SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'"); const actualIndexes: string[] = []; - try { - while (indexesStmt.step()) { - actualIndexes.push(indexesStmt.getAsObject().name as string); - } - } finally { - indexesStmt.free(); + for (const row of indexesRows) { + actualIndexes.push(row.name); } for (const expectedIndex of expectedIndexes) { @@ -2310,9 +2513,7 @@ export class SQLiteIndexManager { // Verify FTS table is properly set up try { - const ftsTestStmt = this.db.prepare("SELECT * FROM cells_fts LIMIT 0"); - ftsTestStmt.step(); // This will fail if FTS table is malformed - ftsTestStmt.free(); + await this.db!.all("SELECT * FROM cells_fts LIMIT 0"); } catch (error) { debug(`Schema validation failed: FTS table malformed - ${error}`); return false; @@ -2320,12 +2521,8 @@ export class SQLiteIndexManager { // Test that basic database operations work try { - this.db.run("BEGIN"); - // Test basic table functionality - const testStmt = this.db.prepare("SELECT COUNT(*) FROM files"); - testStmt.step(); - testStmt.free(); - this.db.run("ROLLBACK"); + await this.db!.get("SELECT COUNT(*) FROM files"); + await this.db!.get("SELECT COUNT(*) FROM cells"); } catch (error) { debug(`Schema validation failed: Basic table operations failed - ${error}`); return false; @@ -2340,56 +2537,41 @@ export class SQLiteIndexManager { } } - private debouncedSave = debounce(async () => { - try { - await this.saveDatabase(); - } catch (error) { - console.error("Error in debounced save:", error); - } - }, this.SAVE_DEBOUNCE_MS); - - // Force immediate save for critical updates (like during queued translations) + /** + * Flush WAL data to the main database file. + * Called after batch operations (sync, targeted sync) to ensure data + * is merged into the main file and survives a force-quit. + */ async forceSave(): Promise { - if (this.saveDebounceTimer) { - clearTimeout(this.saveDebounceTimer); - this.saveDebounceTimer = null; - } - - try { - await this.saveDatabase(); - } catch (error) { - console.error("Error in force save:", error); - throw error; - } + await this.walCheckpoint(); } // Force FTS index to rebuild/refresh for immediate search visibility async refreshFTSIndex(): Promise { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); try { // Force FTS5 to rebuild its index - this.db.run("INSERT INTO cells_fts(cells_fts) VALUES('rebuild')"); - } catch (error) { + await this.db!.run("INSERT INTO cells_fts(cells_fts) VALUES('rebuild')"); + this.resetNonCriticalErrorCount("refreshFTSIndex"); + } catch (rebuildError) { // If rebuild fails, try optimize instead try { - this.db.run("INSERT INTO cells_fts(cells_fts) VALUES('optimize')"); + await this.db!.run("INSERT INTO cells_fts(cells_fts) VALUES('optimize')"); + this.resetNonCriticalErrorCount("refreshFTSIndex"); } catch (optimizeError) { - // If both fail, silently continue - the triggers should handle synchronization + // Both rebuild and optimize failed — track for visibility + this.logNonCriticalError("refreshFTSIndex", optimizeError); } } } - // Ensure all pending writes are committed and visible for search + /** + * Flush WAL data to the main database file so writes are visible + * to other connections and survive a force-quit. + */ async flushPendingWrites(): Promise { - if (!this.db) throw new Error("Database not initialized"); - - // Execute a dummy query to ensure any autocommit transactions are flushed - try { - this.db.run("BEGIN IMMEDIATE; COMMIT;"); - } catch (error) { - // Database might already be in a transaction, that's fine - } + await this.walCheckpoint(); } // Immediate cell update with FTS synchronization @@ -2404,131 +2586,303 @@ export class SQLiteIndexManager { ): Promise<{ id: string; isNew: boolean; contentChanged: boolean; }> { const result = await this.upsertCell(cellId, fileId, cellType, content, lineNumber, metadata, rawContent); - // Force FTS synchronization for immediate search visibility + // Force FTS synchronization for immediate search visibility. + // Errors are NOT caught here — callers wrap this in runInTransaction(), + // so an FTS failure will roll back both the cell upsert and the FTS insert + // atomically, preventing cells/cells_fts divergence. if (result.contentChanged) { - try { - // Sanitize content for FTS search (same as upsertCell does) - const sanitizedContent = this.sanitizeContent(content); - const actualRawContent = rawContent || content; - - // Manually sync this specific cell to FTS if triggers didn't work - // Use sanitized content for the content field (for searching) and raw content for raw_content field - this.db!.run(` - INSERT OR REPLACE INTO cells_fts(cell_id, content, raw_content, content_type) - VALUES (?, ?, ?, ?) - `, [cellId, sanitizedContent, actualRawContent, cellType]); - } catch (error) { - console.error("Error syncing cell to FTS index:", error); - // Trigger should have handled it, but log the error for debugging - } + const sanitizedContent = this.sanitizeContent(content); + const actualRawContent = rawContent || content; + + await this.db!.run(` + INSERT OR REPLACE INTO cells_fts(cell_id, content, raw_content, content_type) + VALUES (?, ?, ?, ?) + `, [cellId, sanitizedContent, actualRawContent, cellType]); } return result; } + /** + * Update only the milestone_index column for a single cell. + * Intended to be called inside a transaction (e.g. from updateCellMilestoneIndices). + */ + async updateCellMilestoneIndex(cellId: string, milestoneIndex: number | null): Promise { + this.ensureOpen(); + await this.db!.run( + `UPDATE cells SET milestone_index = ? WHERE cell_id = ?`, + [milestoneIndex, cellId] + ); + } + // Debug method to check if a cell is in the FTS index async isCellInFTSIndex(cellId: string): Promise { - if (!this.db) return false; + this.ensureOpen(); - const stmt = this.db.prepare("SELECT cell_id FROM cells_fts WHERE cell_id = ? LIMIT 1"); - try { - stmt.bind([cellId]); - return stmt.step(); - } finally { - stmt.free(); - } + const row = await this.db!.get<{ cell_id: string; }>("SELECT cell_id FROM cells_fts WHERE cell_id = ? LIMIT 1", [cellId]); + return !!row; } // Debug method to get FTS index count vs regular table count async getFTSDebugInfo(): Promise<{ cellsCount: number; ftsCount: number; }> { - if (!this.db) return { cellsCount: 0, ftsCount: 0 }; + this.ensureOpen(); + + const cellsResult = await this.db!.get<{ count: number; }>("SELECT COUNT(*) as count FROM cells"); + const ftsResult = await this.db!.get<{ count: number; }>("SELECT COUNT(*) as count FROM cells_fts"); - const cellsStmt = this.db.prepare("SELECT COUNT(*) as count FROM cells"); - const ftsStmt = this.db.prepare("SELECT COUNT(*) as count FROM cells_fts"); + const cellsCount = cellsResult?.count || 0; + const ftsCount = ftsResult?.count || 0; - let cellsCount = 0; - let ftsCount = 0; + return { cellsCount, ftsCount }; + } + + async close(): Promise { + // Guard against double-close (e.g. deactivation racing with project deletion). + if (this.closed) return; + + // Mark as closed immediately so no new transactions or operations start. + this.closed = true; + + // Clean up all timers to prevent memory leaks + if (this.currentProgressTimer) { + clearInterval(this.currentProgressTimer); + this.currentProgressTimer = null; + } + this.stopPeriodicIntegrityCheck(); + + // Reset progress tracking state to prevent memory leaks + this.currentProgressName = null; + this.currentProgressStartTime = null; + this.progressTimings = []; + this._nonCriticalErrorCounts.clear(); + + // Wait for any in-flight transaction to complete before closing. + // We acquire the transaction lock so checkpoint + close cannot + // overlap with a running BEGIN/COMMIT. + let releaseLock!: () => void; + const previousLock = this.transactionLock; + this.transactionLock = new Promise((resolve) => { + releaseLock = resolve; + }); + await previousLock; try { - cellsStmt.step(); - cellsCount = cellsStmt.getAsObject().count as number; + if (this.db) { + // Let SQLite update its query planner statistics based on usage patterns. + // PRAGMA optimize is cheap (<1ms typically) and improves query performance + // for the next session by persisting better index statistics. + try { + await this.db.exec("PRAGMA optimize"); + } catch { + // Non-critical — next session will still work fine + } + + // Checkpoint WAL to merge it back into the main database file before closing. + // TRUNCATE mode resets the WAL file to zero bytes, keeping the directory tidy. + try { + await this.db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + } catch (checkpointError) { + // Non-critical — WAL will be checkpointed on next open + console.warn(`[SQLiteIndex] WAL checkpoint failed during close (non-critical):`, checkpointError); + } + } - ftsStmt.step(); - ftsCount = ftsStmt.getAsObject().count as number; + // Close database connection + if (this.db) { + try { + await this.db.close(); + debug("Database connection closed and resources cleaned up"); + } catch (error) { + console.error("[SQLiteIndex] Error during database close:", error); + } + this.db = null; + } } finally { - cellsStmt.free(); - ftsStmt.free(); + releaseLock(); } - - return { cellsCount, ftsCount }; } - async saveDatabase(): Promise { - if (!this.db) return; + /** + * Checkpoint the WAL file to keep it from growing unboundedly. + * Call after large batch operations (sync, rebuild, etc.). + * Logs a warning when the WAL file exceeds 50 MB so we can detect + * checkpoint failures early. + */ + async walCheckpoint(mode: "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE" = "PASSIVE"): Promise { + this.ensureOpen(); - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) return; + // Runtime validation to prevent SQL injection — mode is interpolated into the PRAGMA + const validModes = ["PASSIVE", "FULL", "RESTART", "TRUNCATE"]; + if (!validModes.includes(mode)) { + throw new Error(`Invalid WAL checkpoint mode: ${mode}`); + } - const dbPath = vscode.Uri.joinPath(workspaceFolder.uri, ...INDEX_DB_PATH); - const data = this.db.export(); + // If we've hit repeated failures, escalate to RESTART mode which forces + // WAL pages to be written even when readers hold snapshots. + const effectiveMode = + this.walCheckpointFailureCount >= SQLiteIndexManager.MAX_CHECKPOINT_FAILURES && mode === "PASSIVE" + ? "RESTART" + : mode; - // Ensure .project directory exists - const projectDir = vscode.Uri.joinPath(workspaceFolder.uri, ".project"); try { - await vscode.workspace.fs.createDirectory(projectDir); - } catch { - // Directory might already exist - } + // Log a warning when the WAL file is unusually large + if (this.dbPath) { + try { + const fs = await import("fs/promises"); + const walPath = this.dbPath + "-wal"; + const stats = await fs.stat(walPath).catch(() => null); + if (stats && stats.size > 50 * 1024 * 1024) { + console.warn( + `[SQLiteIndex] WAL file is large: ${(stats.size / 1024 / 1024).toFixed(1)}MB, running checkpoint(${effectiveMode})` + ); + } + } catch { + // WAL file may not exist (e.g. in-memory DB) + } + } - await vscode.workspace.fs.writeFile(dbPath, data); - } + await this.db!.exec(`PRAGMA wal_checkpoint(${effectiveMode})`); + debug(`WAL checkpoint(${effectiveMode}) completed`); - async close(): Promise { - // Clean up all timers to prevent memory leaks - if (this.currentProgressTimer) { - clearInterval(this.currentProgressTimer); - this.currentProgressTimer = null; + // Reset failure counters on success + this.walCheckpointFailureCount = 0; + this.resetNonCriticalErrorCount("walCheckpoint"); + } catch (error) { + this.walCheckpointFailureCount++; + this.logNonCriticalError("walCheckpoint", error); } + } - if (this.saveDebounceTimer) { - clearTimeout(this.saveDebounceTimer); - this.saveDebounceTimer = null; + /** + * Reclaim disk space by rebuilding the database file. + * VACUUM rewrites the entire DB into a compact form, eliminating free pages + * left by deleted rows. This is an expensive operation (~seconds for large DBs) + * and should only be called infrequently (e.g., after schema recreation, large + * deletions, or on explicit user request). + * + * NOTE: VACUUM cannot run inside a transaction and temporarily doubles disk usage. + */ + async vacuum(): Promise { + this.ensureOpen(); + try { + const start = globalThis.performance.now(); + await this.db!.exec("VACUUM"); + const elapsed = globalThis.performance.now() - start; + debug(`[SQLiteIndex] VACUUM completed in ${elapsed.toFixed(0)}ms`); + this.resetNonCriticalErrorCount("vacuum"); + } catch (error) { + this.logNonCriticalError("vacuum", error); } + } - // Reset progress tracking state to prevent memory leaks - this.currentProgressName = null; - this.currentProgressStartTime = null; - this.progressTimings = []; + /** + * Transaction helper for batch operations. + * Uses a promise-based mutex so that concurrent callers are serialized + * instead of hitting "cannot start a transaction within a transaction". + */ + async runInTransaction(callback: () => T | Promise): Promise { + // Force a file-existence check before every transaction to fail fast + // if the DB was deleted (e.g. git clean) instead of getting a cryptic + // "disk I/O error" mid-transaction. + this.ensureOpen(/* forceFileCheck */ true); + + // Queue behind any already-running transaction + let releaseLock!: () => void; + const previousLock = this.transactionLock; + this.transactionLock = new Promise((resolve) => { + releaseLock = resolve; + }); - // Save and close database - if (this.db) { + // Wait for the previous transaction to finish + await previousLock; + + try { + await this.db!.run("BEGIN TRANSACTION"); try { - await this.saveDatabase(); - this.db.close(); - this.db = null; - debug("Database connection closed and resources cleaned up"); + const result = await callback(); + await this.db!.run("COMMIT"); + return result; } catch (error) { - console.error("[SQLiteIndex] Error during database close:", error); - // Still close the database even if save fails - if (this.db) { - this.db.close(); - this.db = null; + try { + await this.db!.run("ROLLBACK"); + } catch (rollbackError) { + debug(`[SQLiteIndex] ROLLBACK failed: ${rollbackError}`); } + // Surface disk-full errors with a user-visible message + const errMsg = error instanceof Error ? error.message : String(error); + if (SQLiteIndexManager.isDiskFullError(errMsg)) { + vscode.window.showErrorMessage( + "Codex: Disk is full — database writes are failing. Please free up disk space and try again." + ); + } + throw error; } + } finally { + releaseLock(); } } - // Transaction helper for batch operations - async runInTransaction(callback: () => T): Promise { - if (!this.db) throw new Error("Database not initialized"); + /** + * Wrapper around runInTransaction that retries on transient SQLITE_BUSY + * errors with exponential backoff. Use this for non-interactive bulk + * operations (e.g. file sync) where a brief retry is acceptable. + */ + async runInTransactionWithRetry( + callback: () => T | Promise, + maxRetries = 3, + baseDelayMs = 100 + ): Promise { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await this.runInTransaction(callback); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + + // Disk-full errors will not self-resolve — don't retry + if (SQLiteIndexManager.isDiskFullError(msg)) throw error; + + if (!SQLiteIndexManager.isBusyError(msg) || attempt === maxRetries) throw error; + const delay = baseDelayMs * Math.pow(2, attempt); + debug(`SQLITE_BUSY, retry ${attempt + 1}/${maxRetries} after ${delay}ms`); + await new Promise((r) => setTimeout(r, delay)); + } + } + throw new Error("Unreachable: runInTransactionWithRetry exhausted retries"); + } - this.db.run("BEGIN TRANSACTION"); + /** + * Run a callback inside a named SAVEPOINT within an existing transaction. + * Unlike runInTransaction, this does NOT acquire the mutex or issue BEGIN — + * it is meant to be called *inside* a runInTransaction callback to get + * partial-rollback capability. + * + * If the callback throws, only the work since the SAVEPOINT is rolled back; + * the outer transaction remains intact. + * + * @param name Savepoint name (must be alphanumeric / underscore). + * @param callback The work to execute inside the savepoint. + */ + async runInSavepoint(name: string, callback: () => T | Promise): Promise { + this.ensureOpen(); + + // Validate savepoint name to prevent SQL injection + if (!/^[a-zA-Z_]\w*$/.test(name)) { + throw new Error(`Invalid savepoint name: ${name}`); + } + + await this.db!.run(`SAVEPOINT ${name}`); try { - const result = callback(); - this.db.run("COMMIT"); + const result = await callback(); + await this.db!.run(`RELEASE SAVEPOINT ${name}`); return result; } catch (error) { - this.db.run("ROLLBACK"); + try { + await this.db!.run(`ROLLBACK TO SAVEPOINT ${name}`); + // Release the savepoint even after rollback so it doesn't linger + await this.db!.run(`RELEASE SAVEPOINT ${name}`); + } catch (rollbackError) { + debug(`[SQLiteIndex] ROLLBACK TO SAVEPOINT ${name} failed: ${rollbackError}`); + } throw error; } } @@ -2554,7 +2908,8 @@ export class SQLiteIndexManager { .replace(/]*data-footnote[^>]*>[\s\S]*?<\/sup>/gi, '') .replace(/]*>[\s\S]*?<\/sup>/gi, ''); // Remove any remaining sup tags - // Step 2: Remove spell check markup and other unwanted elements + // Step 2: Remove suggestion markup and other unwanted elements + // (The spell-check regex strips legacy elements with "spell-check" CSS classes) cleanContent = cleanContent .replace(/<[^>]*class=["'][^"']*spell-check[^"']*["'][^>]*>[\s\S]*?<\/[^>]+>/gi, '') .replace(//gi, '') @@ -2583,25 +2938,76 @@ export class SQLiteIndexManager { return cleanContent; } - // Delete the database file from disk + /** + * Delete the database file AND its WAL/SHM auxiliary files from disk. + * All three must be removed; leaving orphaned WAL/SHM files can confuse + * SQLite when a new database is created at the same path. + */ private async deleteDatabaseFile(): Promise { - try { + // Use the stored dbPath when available — it was set by loadOrCreateDatabase + // and is the canonical path for this instance. Recomputing from + // workspaceFolders[0] can mismatch in multi-root workspaces. + let dbFsPath: string; + if (this.dbPath) { + dbFsPath = this.dbPath; + } else { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; if (!workspaceFolder) { - throw new Error("No workspace folder found"); + throw new Error("No workspace folder found for database deletion"); } + dbFsPath = vscode.Uri.joinPath(workspaceFolder.uri, ...INDEX_DB_PATH).fsPath; + } - const dbPath = vscode.Uri.joinPath(workspaceFolder.uri, ...INDEX_DB_PATH); + const dbUri = vscode.Uri.file(dbFsPath); + + // Delete the main database file first. This MUST succeed (or be "not found") + // for corruption recovery to work. If the file is locked or permissions fail, + // we rethrow so the caller knows the corrupted DB was NOT removed. + try { + await vscode.workspace.fs.delete(dbUri); + } catch (mainDeleteErr) { + // "FileNotFound" / "EntryNotFound" is fine — the file is already gone + const msg = mainDeleteErr instanceof Error ? mainDeleteErr.message : String(mainDeleteErr); + const isNotFound = msg.includes("ENOENT") || msg.includes("FileNotFound") || msg.includes("EntryNotFound (FileSystemError)"); + if (!isNotFound) { + console.error(`[SQLiteIndex] CRITICAL: Could not delete main DB file ${dbFsPath}: ${mainDeleteErr}`); + throw mainDeleteErr; + } + debug(`Main DB file already absent: ${dbFsPath}`); + } + // Auxiliary files (WAL, SHM) are best-effort — they may not exist + const auxiliaryFiles = [ + vscode.Uri.file(`${dbFsPath}-wal`), + vscode.Uri.file(`${dbFsPath}-shm`), + ]; + for (const fileUri of auxiliaryFiles) { try { - await vscode.workspace.fs.delete(dbPath); - debug("Database file deleted successfully"); - } catch (deleteError) { - debug("[SQLiteIndex] Could not delete database file:", deleteError); - // Don't throw here - we want to continue with reindex even if file deletion fails + await vscode.workspace.fs.delete(fileUri); + } catch (deleteErr) { + debug(`Could not delete ${fileUri.fsPath}: ${deleteErr}`); } - } catch (error) { - console.error("[SQLiteIndex] Error deleting database file:", error); + } + + debug("Database file and auxiliary files deleted successfully"); + } + + /** + * Best-effort backup: copy the main database file to a `.bak` sibling. + * This gives us a forensic snapshot when we're about to nuke-and-recreate + * so corruption patterns can be investigated after the fact. + * Failures are swallowed — a failed backup must never prevent recovery. + */ + private async backupDatabaseFile(): Promise { + try { + if (!this.dbPath) return; + const fs = await import("fs/promises"); + const backupPath = `${this.dbPath}.bak`; + await fs.copyFile(this.dbPath, backupPath); + debug(`[SQLiteIndex] Database backed up to ${backupPath}`); + } catch (backupErr) { + // Swallow — the original file may already be gone or inaccessible + this.logNonCriticalError("backupDatabaseFile", backupErr); } } @@ -2633,78 +3039,120 @@ export class SQLiteIndexManager { } /** - * Check which files need synchronization based on content hash and modification time + * Check which files need synchronization based on content hash and modification time. + * + * Optimized to batch-load all sync_metadata records in a single query (via temp table) + * instead of running one SELECT per file (N+1 problem). File I/O (stat/read) is still + * per-file since it depends on the metadata comparison result. */ async checkFilesForSync(filePaths: string[]): Promise<{ needsSync: string[]; unchanged: string[]; details: Map; }> { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); const needsSync: string[] = []; const unchanged: string[] = []; const details = new Map(); + // ── Phase 1: Batch-load all sync_metadata records in one query ── + // Use a temp table to avoid the 999-parameter limit on large projects. + const metadataMap = new Map(); + + if (filePaths.length > 0) { + await this.db!.exec("CREATE TEMP TABLE IF NOT EXISTS _check_paths (file_path TEXT PRIMARY KEY)"); + await this.db!.exec("DELETE FROM _check_paths"); + + const CHUNK = 500; + for (let i = 0; i < filePaths.length; i += CHUNK) { + const slice = filePaths.slice(i, i + CHUNK); + const placeholders = slice.map(() => "(?)").join(","); + await this.db!.run( + `INSERT OR IGNORE INTO _check_paths (file_path) VALUES ${placeholders}`, + slice + ); + } + + const rows = await this.db!.all<{ + file_path: string; + content_hash: string; + last_modified_ms: number; + file_size: number; + }>( + `SELECT sm.file_path, sm.content_hash, sm.last_modified_ms, sm.file_size + FROM sync_metadata sm + INNER JOIN _check_paths cp ON sm.file_path = cp.file_path` + ); + for (const row of rows) { + metadataMap.set(row.file_path, { + content_hash: row.content_hash, + last_modified_ms: row.last_modified_ms, + file_size: row.file_size, + }); + } + + // Clean up temp table + await this.db!.exec("DELETE FROM _check_paths"); + } + + // Collect mtime/size drift updates to batch them in a single transaction + const driftUpdates: Array<{ mtime: number; size: number; path: string; }> = []; + + // ── Phase 2: Compare each file against its cached metadata ── for (const filePath of filePaths) { try { - // Get file stats const fileUri = vscode.Uri.file(filePath); const fileStat = await vscode.workspace.fs.stat(fileUri); - const fileContent = await vscode.workspace.fs.readFile(fileUri); - const newContentHash = createHash("sha256").update(fileContent).digest("hex"); - - // Check sync metadata - const syncStmt = this.db.prepare(` - SELECT content_hash, last_modified_ms, file_size - FROM sync_metadata - WHERE file_path = ? - `); - - let existingRecord: { content_hash: string; last_modified_ms: number; file_size: number; } | null = null; - try { - syncStmt.bind([filePath]); - if (syncStmt.step()) { - existingRecord = syncStmt.getAsObject() as any; - } - } finally { - syncStmt.free(); - } + const existingRecord = metadataMap.get(filePath); if (!existingRecord) { + // New file — must sync. Read content for the detail hash. + const fileContent = await vscode.workspace.fs.readFile(fileUri); + const newContentHash = createHash("sha256").update(fileContent).digest("hex"); needsSync.push(filePath); details.set(filePath, { reason: "new file - not in sync metadata", newHash: newContentHash }); - } else if (existingRecord.content_hash !== newContentHash) { - needsSync.push(filePath); - details.set(filePath, { - reason: "content changed - hash mismatch", - oldHash: existingRecord.content_hash, - newHash: newContentHash - }); - } else if (existingRecord.last_modified_ms !== fileStat.mtime) { - needsSync.push(filePath); + continue; + } + + // Fast path: if mtime AND size both match stored values, the file + // almost certainly hasn't changed. Skip the expensive read+hash. + if (existingRecord.last_modified_ms === fileStat.mtime && + existingRecord.file_size === fileStat.size) { + unchanged.push(filePath); details.set(filePath, { - reason: "modification time changed - possible external edit", - oldHash: existingRecord.content_hash, - newHash: newContentHash + reason: "no changes detected (mtime+size match)", + oldHash: existingRecord.content_hash }); - } else if (existingRecord.file_size !== fileStat.size) { + continue; + } + + // Slow path: mtime or size changed — read content and hash + const fileContent = await vscode.workspace.fs.readFile(fileUri); + const newContentHash = createHash("sha256").update(fileContent).digest("hex"); + + if (existingRecord.content_hash !== newContentHash) { needsSync.push(filePath); details.set(filePath, { - reason: "file size changed", + reason: "content changed - hash mismatch", oldHash: existingRecord.content_hash, newHash: newContentHash }); } else { + // Content hash matches — file is byte-for-byte identical despite + // mtime/size drift (git operations, backups, etc.). unchanged.push(filePath); details.set(filePath, { - reason: "no changes detected", + reason: "no changes detected (hash verified after mtime/size drift)", oldHash: existingRecord.content_hash, newHash: newContentHash }); + + // Queue mtime/size update so the fast path works next time + driftUpdates.push({ mtime: fileStat.mtime, size: fileStat.size, path: filePath }); } } catch (error) { console.error(`[SQLiteIndex] Error checking file ${filePath}:`, error); @@ -2715,6 +3163,36 @@ export class SQLiteIndexManager { } } + // ── Phase 3: Batch-update mtime/size drift in a single transaction ── + if (driftUpdates.length > 0) { + try { + await this.runInTransaction(async () => { + for (const { mtime, size, path } of driftUpdates) { + await this.db!.run( + `UPDATE sync_metadata SET last_modified_ms = ?, file_size = ? WHERE file_path = ?`, + [mtime, size, path] + ); + } + }); + } catch (updateError) { + // Non-critical — just means next check will re-read content + debug(`Failed to batch-update sync_metadata mtime/size: ${updateError}`); + } + } + + // Log sync check summary so we can diagnose "always rebuilds" issues + if (needsSync.length > 0) { + console.log(`[SQLiteIndex] checkFilesForSync: ${needsSync.length} need sync, ${unchanged.length} unchanged`); + for (const fp of needsSync.slice(0, 5)) { + const detail = details.get(fp); + const shortPath = fp.split('/').slice(-2).join('/'); + console.log(`[SQLiteIndex] → ${shortPath}: ${detail?.reason}`); + } + if (needsSync.length > 5) { + console.log(`[SQLiteIndex] ... and ${needsSync.length - 5} more`); + } + } + return { needsSync, unchanged, details }; } @@ -2728,9 +3206,9 @@ export class SQLiteIndexManager { fileSize: number, lastModifiedMs: number ): Promise { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); - const stmt = this.db.prepare(` + await this.db!.run(` INSERT INTO sync_metadata (file_path, file_type, content_hash, file_size, last_modified_ms, last_synced_ms) VALUES (?, ?, ?, ?, ?, strftime('%s', 'now') * 1000) ON CONFLICT(file_path) DO UPDATE SET @@ -2739,14 +3217,7 @@ export class SQLiteIndexManager { last_modified_ms = excluded.last_modified_ms, last_synced_ms = strftime('%s', 'now') * 1000, updated_at = strftime('%s', 'now') * 1000 - `); - - try { - stmt.bind([filePath, fileType, contentHash, fileSize, lastModifiedMs]); - stmt.step(); - } finally { - stmt.free(); - } + `, [filePath, fileType, contentHash, fileSize, lastModifiedMs]); } /** @@ -2760,9 +3231,16 @@ export class SQLiteIndexManager { oldestSync: Date | null; newestSync: Date | null; }> { - if (!this.db) throw new Error("Database not initialized"); - - const stmt = this.db.prepare(` + this.ensureOpen(); + + const result = await this.db!.get<{ + total_files: number; + source_files: number; + codex_files: number; + avg_file_size: number; + oldest_sync_ms: number | null; + newest_sync_ms: number | null; + }>(` SELECT COUNT(*) as total_files, COUNT(CASE WHEN file_type = 'source' THEN 1 END) as source_files, @@ -2773,53 +3251,62 @@ export class SQLiteIndexManager { FROM sync_metadata `); - try { - stmt.step(); - const result = stmt.getAsObject(); + if (!result) { return { - totalFiles: (result.total_files as number) || 0, - sourceFiles: (result.source_files as number) || 0, - codexFiles: (result.codex_files as number) || 0, - avgFileSize: (result.avg_file_size as number) || 0, - oldestSync: result.oldest_sync_ms ? new Date(result.oldest_sync_ms as number) : null, - newestSync: result.newest_sync_ms ? new Date(result.newest_sync_ms as number) : null, + totalFiles: 0, + sourceFiles: 0, + codexFiles: 0, + avgFileSize: 0, + oldestSync: null, + newestSync: null, }; - } finally { - stmt.free(); } + + return { + totalFiles: result.total_files || 0, + sourceFiles: result.source_files || 0, + codexFiles: result.codex_files || 0, + avgFileSize: result.avg_file_size || 0, + oldestSync: result.oldest_sync_ms ? new Date(result.oldest_sync_ms) : null, + newestSync: result.newest_sync_ms ? new Date(result.newest_sync_ms) : null, + }; } /** - * Remove sync metadata for files that no longer exist + * Remove sync metadata for files that no longer exist. + * Uses a temp table to avoid SQLite's 999-parameter limit for large projects. */ async cleanupSyncMetadata(existingFilePaths: string[]): Promise { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); - if (existingFilePaths.length === 0) { - // If no files exist, clear all sync metadata - const stmt = this.db.prepare("DELETE FROM sync_metadata"); - try { - stmt.step(); - return this.db.getRowsModified(); - } finally { - stmt.free(); + return await this.runInTransaction(async () => { + if (existingFilePaths.length === 0) { + // If no files exist, clear all sync metadata + const result = await this.db!.run("DELETE FROM sync_metadata"); + return result.changes; } - } - // Create placeholders for IN clause - const placeholders = existingFilePaths.map(() => '?').join(','); - const stmt = this.db.prepare(` - DELETE FROM sync_metadata - WHERE file_path NOT IN (${placeholders}) - `); + // Use a temp table instead of IN (...) to stay under SQLite's 999-parameter limit + await this.db!.exec("CREATE TEMP TABLE IF NOT EXISTS _existing_paths (file_path TEXT PRIMARY KEY)"); + await this.db!.exec("DELETE FROM _existing_paths"); + + const CHUNK = 500; + for (let i = 0; i < existingFilePaths.length; i += CHUNK) { + const slice = existingFilePaths.slice(i, i + CHUNK); + const placeholders = slice.map(() => "(?)").join(","); + await this.db!.run( + `INSERT OR IGNORE INTO _existing_paths (file_path) VALUES ${placeholders}`, + slice + ); + } - try { - stmt.bind(existingFilePaths); - stmt.step(); - return this.db.getRowsModified(); - } finally { - stmt.free(); - } + const result = await this.db!.run(` + DELETE FROM sync_metadata + WHERE file_path NOT IN (SELECT file_path FROM _existing_paths) + `); + await this.db!.exec("DROP TABLE IF EXISTS _existing_paths"); + return result.changes; + }); } /** @@ -2831,24 +3318,15 @@ export class SQLiteIndexManager { cellsAffected: number; unknownFileRemoved: boolean; }> { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); debug("Starting source cell deduplication..."); // First, identify the "unknown" file ID - const unknownFileStmt = this.db.prepare(` + const unknownFileRow = await this.db!.get<{ id: number; }>(` SELECT id FROM files WHERE file_path = 'unknown' AND file_type = 'source' `); - - let unknownFileId: number | null = null; - try { - unknownFileStmt.bind([]); - if (unknownFileStmt.step()) { - unknownFileId = (unknownFileStmt.getAsObject() as any).id; - } - } finally { - unknownFileStmt.free(); - } + const unknownFileId: number | null = unknownFileRow?.id ?? null; if (!unknownFileId) { debug("No 'unknown' source file found - no deduplication needed"); @@ -2873,19 +3351,12 @@ export class SQLiteIndexManager { ) `; - const duplicateStmt = this.db.prepare(duplicateQuery); + const duplicateRows = await this.db!.all<{ cell_id: string; }>(duplicateQuery, [unknownFileId, unknownFileId]); const duplicatesToRemove: Array<{ cellId: string; }> = []; - - try { - duplicateStmt.bind([unknownFileId, unknownFileId]); - while (duplicateStmt.step()) { - const row = duplicateStmt.getAsObject() as any; - duplicatesToRemove.push({ - cellId: row.cell_id - }); - } - } finally { - duplicateStmt.free(); + for (const row of duplicateRows) { + duplicatesToRemove.push({ + cellId: row.cell_id + }); } debug(`Found ${duplicatesToRemove.length} duplicate cells to remove from 'unknown' file`); @@ -2894,70 +3365,57 @@ export class SQLiteIndexManager { return { duplicatesRemoved: 0, cellsAffected: 0, unknownFileRemoved: false }; } - // Remove duplicates from 'unknown' file in batches + // Remove duplicates from 'unknown' file in batches. + // FTS deletes and cells updates are in the same transaction so they + // are atomically committed or rolled back together — preventing FTS + // from getting out of sync with the cells table. let duplicatesRemoved = 0; - await this.runInTransaction(() => { - // Remove cells from FTS first (both source and target entries) + await this.runInTransaction(async () => { for (const duplicate of duplicatesToRemove) { - try { - this.db!.run("DELETE FROM cells_fts WHERE cell_id = ? AND content_type = 'source'", [duplicate.cellId]); - } catch (error) { - // Continue even if FTS delete fails - } + // Rethrow FTS errors so the entire transaction rolls back, + // keeping FTS and cells in sync. + await this.db!.run("DELETE FROM cells_fts WHERE cell_id = ? AND content_type = 'source'", [duplicate.cellId]); } - // Update cells to remove source data from 'unknown' file - const updateStmt = this.db!.prepare(` - UPDATE cells - SET s_file_id = NULL, - s_content = NULL, - s_raw_content = NULL, - s_line_number = NULL, - - s_word_count = NULL, - s_raw_content_hash = NULL, - s_content_hash = NULL, - - s_updated_at = datetime('now') - WHERE cell_id = ? AND s_file_id = ? - `); - try { - for (const duplicate of duplicatesToRemove) { - updateStmt.bind([duplicate.cellId, unknownFileId]); - updateStmt.step(); - duplicatesRemoved++; - updateStmt.reset(); - } - } finally { - updateStmt.free(); + for (const duplicate of duplicatesToRemove) { + await this.db!.run(` + UPDATE cells + SET s_file_id = NULL, + s_content = NULL, + s_raw_content = NULL, + s_line_number = NULL, + s_word_count = NULL, + s_raw_content_hash = NULL, + s_updated_at = datetime('now') + WHERE cell_id = ? AND s_file_id = ? + `, [duplicate.cellId, unknownFileId]); + duplicatesRemoved++; } }); // Check if 'unknown' file now has any remaining cells - const remainingCellsStmt = this.db.prepare(` - SELECT COUNT(*) as count FROM cells WHERE s_file_id = ? OR t_file_id = ? - `); - - let remainingCells = 0; - try { - remainingCellsStmt.bind([unknownFileId, unknownFileId]); - if (remainingCellsStmt.step()) { - remainingCells = (remainingCellsStmt.getAsObject() as any).count; - } - } finally { - remainingCellsStmt.free(); - } + const remainingRow = await this.db!.get<{ count: number; }>( + "SELECT COUNT(*) as count FROM cells WHERE s_file_id = ? OR t_file_id = ?", + [unknownFileId, unknownFileId] + ); + const remainingCells = remainingRow?.count ?? 0; // If no cells remain, remove the 'unknown' file entry let unknownFileRemoved = false; if (remainingCells === 0) { - this.db.run("DELETE FROM files WHERE id = ?", [unknownFileId]); + await this.db!.run("DELETE FROM files WHERE id = ?", [unknownFileId]); unknownFileRemoved = true; debug("Removed empty 'unknown' file entry"); } - // Refresh FTS index to ensure consistency - await this.refreshFTSIndex(); + // Refresh FTS index to ensure consistency after bulk deduplication. + // Non-fatal if it fails — triggers keep FTS in sync for ongoing operations + // and the next sync will rebuild as needed. + try { + await this.refreshFTSIndex(); + } catch (ftsError) { + console.warn(`[SQLiteIndex] FTS refresh after deduplication failed (non-critical):`, ftsError); + } debug(`Deduplication complete: removed ${duplicatesRemoved} duplicate cells`); debug(`Cells affected: ${duplicatesToRemove.length}`); @@ -2977,7 +3435,7 @@ export class SQLiteIndexManager { returnRawContent: boolean = false, searchSourceOnly: boolean = true // true for few-shot examples, false for UI search ): Promise { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); // Handle empty query by returning recent complete pairs if (!query || query.trim() === '') { @@ -3002,28 +3460,30 @@ export class SQLiteIndexManager { LIMIT ? `; - const stmt = this.db.prepare(sql); + const rows = await this.db!.all<{ + cell_id: string; + source_content: string; + raw_source_content: string | null; + target_content: string; + raw_target_content: string | null; + uri: string | null; + line: number | null; + score: number; + }>(sql, [limit]); const results = []; - try { - stmt.bind([limit]); - while (stmt.step()) { - const row = stmt.getAsObject(); - - results.push({ - cellId: row.cell_id, - cell_id: row.cell_id, - sourceContent: returnRawContent && row.raw_source_content ? row.raw_source_content : row.source_content, - targetContent: returnRawContent && row.raw_target_content ? row.raw_target_content : row.target_content, - content: returnRawContent && row.raw_source_content ? row.raw_source_content : row.source_content, - uri: row.uri, - line: row.line, - score: row.score, - cell_type: 'source' // For compatibility - }); - } - } finally { - stmt.free(); + for (const row of rows) { + results.push({ + cellId: row.cell_id, + cell_id: row.cell_id, + sourceContent: returnRawContent && row.raw_source_content ? row.raw_source_content : row.source_content, + targetContent: returnRawContent && row.raw_target_content ? row.raw_target_content : row.target_content, + content: returnRawContent && row.raw_source_content ? row.raw_source_content : row.source_content, + uri: row.uri, + line: row.line, + score: row.score, + cell_type: 'source' // For compatibility + }); } return results; @@ -3132,24 +3592,49 @@ export class SQLiteIndexManager { LIMIT ? `; - const stmt = this.db.prepare(sql); const results = []; try { // Use both FTS5 query and LIKE pattern for substring matching // Bind parameters depend on searchSourceOnly + let rows: Array<{ + cell_id: string; + source_content: string; + raw_source_content: string | null; + target_content: string; + raw_target_content: string | null; + uri: string | null; + line: number | null; + score: number; + }>; if (searchSourceOnly) { - stmt.bind([cleanQuery, likePattern, likePattern, limit]); + rows = await this.db!.all<{ + cell_id: string; + source_content: string; + raw_source_content: string | null; + target_content: string; + raw_target_content: string | null; + uri: string | null; + line: number | null; + score: number; + }>(sql, [cleanQuery, likePattern, likePattern, limit]); } else { - stmt.bind([cleanQuery, likePattern, likePattern, likePattern, likePattern, limit]); + rows = await this.db!.all<{ + cell_id: string; + source_content: string; + raw_source_content: string | null; + target_content: string; + raw_target_content: string | null; + uri: string | null; + line: number | null; + score: number; + }>(sql, [cleanQuery, likePattern, likePattern, likePattern, likePattern, limit]); } - while (stmt.step()) { - const row = stmt.getAsObject(); - + for (const row of rows) { // Target content is now directly available from the main query - const targetContent = row.target_content as string; - const rawTargetContent = row.raw_target_content as string; + const targetContent = row.target_content; + const rawTargetContent = row.raw_target_content; // Both source and target content are guaranteed to exist due to the WHERE clause results.push({ @@ -3167,8 +3652,6 @@ export class SQLiteIndexManager { } catch (error) { console.error(`[searchCompleteTranslationPairs] FTS5 query failed: ${error}`); return []; - } finally { - stmt.free(); } @@ -3196,7 +3679,7 @@ export class SQLiteIndexManager { return this.searchCompleteTranslationPairs(query, limit, returnRawContent, searchSourceOnly); } - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); // Handle empty query by returning recent complete validated pairs if (!query || query.trim() === '') { @@ -3222,36 +3705,32 @@ export class SQLiteIndexManager { LIMIT ? `; - const stmt = this.db.prepare(sql); + const rows = await this.db!.all<{ + cell_id: string; + source_content: string; + raw_source_content: string | null; + target_content: string; + raw_target_content: string | null; + uri: string | null; + line: number | null; + score: number; + }>(sql, [limit]); const results = []; - try { - stmt.bind([limit]); - while (stmt.step()) { - const row = stmt.getAsObject(); - - // Additional validation check if needed - let isFullyValidated = true; - if (onlyValidated) { - isFullyValidated = await this.isTargetCellFullyValidated(row.cell_id as string); - } - - if (isFullyValidated) { - results.push({ - cellId: row.cell_id, - cell_id: row.cell_id, - sourceContent: returnRawContent && row.raw_source_content ? row.raw_source_content : row.source_content, - targetContent: returnRawContent && row.raw_target_content ? row.raw_target_content : row.target_content, - content: returnRawContent && row.raw_source_content ? row.raw_source_content : row.source_content, - uri: row.uri, - line: row.line, - score: row.score, - cell_type: 'source' // For compatibility - }); - } - } - } finally { - stmt.free(); + // The SQL already filters with "AND c.t_is_fully_validated = 1" when + // onlyValidated is true, so no per-row isTargetCellFullyValidated check is needed. + for (const row of rows) { + results.push({ + cellId: row.cell_id, + cell_id: row.cell_id, + sourceContent: returnRawContent && row.raw_source_content ? row.raw_source_content : row.source_content, + targetContent: returnRawContent && row.raw_target_content ? row.raw_target_content : row.target_content, + content: returnRawContent && row.raw_source_content ? row.raw_source_content : row.source_content, + uri: row.uri, + line: row.line, + score: row.score, + cell_type: 'source' // For compatibility + }); } return results; @@ -3306,11 +3785,16 @@ export class SQLiteIndexManager { // FTS5 query with validation filtering // Use UNION to combine FTS5 MATCH results with LIKE substring matching // (FTS5 MATCH can't be combined with OR in WHERE clause) + // Include t_content/t_raw_content in SELECT to avoid per-row target lookups (N+1). + // Add validation filter directly in SQL instead of per-row isTargetCellFullyValidated. + const validationFilter = onlyValidated ? "AND c.t_is_fully_validated = 1" : ""; const sql = ` SELECT DISTINCT cell_id, source_content, raw_source_content, + target_content, + raw_target_content, line, uri, score @@ -3320,6 +3804,8 @@ export class SQLiteIndexManager { c.cell_id, c.s_content as source_content, c.s_raw_content as raw_source_content, + c.t_content as target_content, + c.t_raw_content as raw_target_content, c.s_line_number as line, COALESCE(s_file.file_path, t_file.file_path) as uri, bm25(cells_fts) as score @@ -3333,6 +3819,7 @@ export class SQLiteIndexManager { AND c.s_content != '' AND c.t_content IS NOT NULL AND c.t_content != '' + ${validationFilter} UNION @@ -3341,6 +3828,8 @@ export class SQLiteIndexManager { c.cell_id, c.s_content as source_content, c.s_raw_content as raw_source_content, + c.t_content as target_content, + c.t_raw_content as raw_target_content, c.s_line_number as line, COALESCE(s_file.file_path, t_file.file_path) as uri, 0.0 as score @@ -3352,77 +3841,58 @@ export class SQLiteIndexManager { AND c.s_content != '' AND c.t_content IS NOT NULL AND c.t_content != '' + ${validationFilter} ) ORDER BY score ASC LIMIT ? `; - const stmt = this.db.prepare(sql); const results = []; try { // Use both FTS5 query and LIKE pattern for substring matching // Bind parameters depend on searchSourceOnly + // Row type now includes target_content/raw_target_content from the SQL + type SearchRow = { + cell_id: string; + source_content: string; + raw_source_content: string | null; + target_content: string | null; + raw_target_content: string | null; + uri: string | null; + line: number | null; + score: number; + }; + let rows: SearchRow[]; if (searchSourceOnly) { - stmt.bind([cleanQuery, likePattern, likePattern, limit * 3]); // Get more results to account for validation filtering + rows = await this.db!.all(sql, [cleanQuery, likePattern, likePattern, limit]); } else { - stmt.bind([cleanQuery, likePattern, likePattern, likePattern, likePattern, limit * 3]); // Get more results to account for validation filtering + rows = await this.db!.all(sql, [cleanQuery, likePattern, likePattern, likePattern, likePattern, limit]); } - while (stmt.step()) { - const row = stmt.getAsObject(); - - // Check if target content is validated (only if onlyValidated is true) - let isFullyValidated = true; - if (onlyValidated) { - isFullyValidated = await this.isTargetCellFullyValidated(row.cell_id as string); - } - - if (isFullyValidated) { - // Get the target content for this cell - const targetStmt = this.db.prepare(` - SELECT t_content as content, t_raw_content as raw_content - FROM cells - WHERE cell_id = ? AND t_content IS NOT NULL AND t_content != '' - LIMIT 1 - `); - - let targetContent = ''; - let rawTargetContent = ''; - try { - targetStmt.bind([row.cell_id]); - if (targetStmt.step()) { - const targetRow = targetStmt.getAsObject(); - targetContent = targetRow.content as string; - rawTargetContent = targetRow.raw_content as string; - } - } finally { - targetStmt.free(); - } + // Validation and target content are now filtered/included at the SQL level — + // no per-row isTargetCellFullyValidated or target content lookup needed. + for (const row of rows) { + const targetContent = row.target_content ?? ''; + const rawTargetContent = row.raw_target_content ?? ''; - if (targetContent) { // Only include if we found target content - results.push({ - cellId: row.cell_id, - cell_id: row.cell_id, - sourceContent: returnRawContent && row.raw_source_content ? row.raw_source_content : row.source_content, - targetContent: returnRawContent && rawTargetContent ? rawTargetContent : targetContent, - content: returnRawContent && row.raw_source_content ? row.raw_source_content : row.source_content, - uri: row.uri, - line: row.line, - score: row.score, - cell_type: 'source' // For compatibility - }); - } - - // Stop when we have enough results - if (results.length >= limit) break; + if (targetContent) { + results.push({ + cellId: row.cell_id, + cell_id: row.cell_id, + sourceContent: returnRawContent && row.raw_source_content ? row.raw_source_content : row.source_content, + targetContent: returnRawContent && rawTargetContent ? rawTargetContent : targetContent, + content: returnRawContent && row.raw_source_content ? row.raw_source_content : row.source_content, + uri: row.uri, + line: row.line, + score: row.score, + cell_type: 'source' // For compatibility + }); } } } catch (error) { console.error(`[searchCompleteTranslationPairsWithValidation] FTS5 query failed: ${error}`); return []; - } finally { - stmt.free(); } return results; @@ -3434,25 +3904,20 @@ export class SQLiteIndexManager { * @returns True if the target cell has been validated, false otherwise */ private async isTargetCellFullyValidated(cellId: string): Promise { - if (!this.db) return false; + this.ensureOpen(); // Get the target cell's validation status from dedicated columns - const stmt = this.db.prepare(` - SELECT t_is_fully_validated FROM cells - WHERE cell_id = ? AND t_content IS NOT NULL - LIMIT 1 - `); - try { - stmt.bind([cellId]); - if (stmt.step()) { - const row = stmt.getAsObject(); + const row = await this.db!.get<{ t_is_fully_validated: number | null; }>(` + SELECT t_is_fully_validated FROM cells + WHERE cell_id = ? AND t_content IS NOT NULL + LIMIT 1 + `, [cellId]); + if (row) { return Boolean(row.t_is_fully_validated); } } catch (error) { console.error(`[isTargetCellFullyValidated] Error checking validation for ${cellId}:`, error); - } finally { - stmt.free(); } return false; @@ -3462,25 +3927,20 @@ export class SQLiteIndexManager { * Check if a target cell's audio is fully validated (for performance optimization) */ private async isTargetCellAudioFullyValidated(cellId: string): Promise { - if (!this.db) return false; + this.ensureOpen(); // Get the target cell's audio validation status from dedicated columns - const stmt = this.db.prepare(` - SELECT t_audio_is_fully_validated FROM cells - WHERE cell_id = ? AND t_content IS NOT NULL - LIMIT 1 - `); - try { - stmt.bind([cellId]); - if (stmt.step()) { - const row = stmt.getAsObject(); + const row = await this.db!.get<{ t_audio_is_fully_validated: number | null; }>(` + SELECT t_audio_is_fully_validated FROM cells + WHERE cell_id = ? AND t_content IS NOT NULL + LIMIT 1 + `, [cellId]); + if (row) { return Boolean(row.t_audio_is_fully_validated); } } catch (error) { console.error(`[isTargetCellAudioFullyValidated] Error checking audio validation for ${cellId}:`, error); - } finally { - stmt.free(); } return false; @@ -3511,32 +3971,22 @@ export class SQLiteIndexManager { * This should be called whenever the validation threshold setting changes */ async recalculateAllValidationStatus(): Promise<{ updatedCells: number; }> { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); const currentThreshold = this.getValidationThreshold(); // Update all target cells based on current validation count vs threshold - const updateStmt = this.db.prepare(` + const result = await this.db!.run(` UPDATE cells SET t_is_fully_validated = CASE WHEN t_validation_count >= ? THEN 1 ELSE 0 END WHERE t_content IS NOT NULL AND t_content != '' - `); - - try { - updateStmt.bind([currentThreshold]); - updateStmt.step(); - const updatedCells = this.db.getRowsModified(); - - // Save changes to disk - await this.saveDatabase(); + `, [currentThreshold]); + const updatedCells = result.changes; - return { updatedCells }; - } finally { - updateStmt.free(); - } + return { updatedCells }; } /** @@ -3544,31 +3994,21 @@ export class SQLiteIndexManager { * This should be called whenever the audio validation threshold setting changes */ async recalculateAllAudioValidationStatus(): Promise<{ updatedCells: number; }> { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); const currentThreshold = this.getAudioValidationThreshold(); // Update all target cells based on current audio validation count vs threshold - const updateStmt = this.db.prepare(` + const result = await this.db!.run(` UPDATE cells SET t_audio_is_fully_validated = CASE WHEN t_audio_validation_count >= ? THEN 1 ELSE 0 END - `); - - try { - updateStmt.bind([currentThreshold]); - updateStmt.step(); - const updatedCells = this.db.getRowsModified(); + `, [currentThreshold]); + const updatedCells = result.changes; - // Save changes to disk - await this.saveDatabase(); - - return { updatedCells }; - } finally { - updateStmt.free(); - } + return { updatedCells }; } /** @@ -3743,11 +4183,7 @@ export class SQLiteIndexManager { * Force database recreation for testing/debugging purposes */ async forceRecreateDatabase(): Promise { - if (!this.db) throw new Error("Database not initialized"); - - debug("[SQLiteIndex] Force recreating database..."); - await this.recreateDatabase(); - debug("[SQLiteIndex] Database recreation completed"); + await this.nukeDatabaseAndRecreate("forced recreation (debug/testing)"); } /** @@ -3760,38 +4196,29 @@ export class SQLiteIndexManager { cellsColumns: string[]; hasNewStructure: boolean; }> { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); - const currentVersion = this.getSchemaVersion(); + const currentVersion = await this.getSchemaVersion(); // Get all schema_info rows - const schemaInfoStmt = this.db.prepare("SELECT * FROM schema_info"); - const schemaInfoRows: any[] = []; + let schemaInfoRows: any[] = []; try { - while (schemaInfoStmt.step()) { - schemaInfoRows.push(schemaInfoStmt.getAsObject()); - } - } catch { - // Table might not exist - } finally { - schemaInfoStmt.free(); + schemaInfoRows = await this.db!.all("SELECT * FROM schema_info"); + } catch (err) { + debug(`schema_info table not readable: ${err}`); } // Check if cells table exists and its structure let cellsTableExists = false; const cellsColumns: string[] = []; try { - const cellsColumnsStmt = this.db.prepare("PRAGMA table_info(cells)"); - try { - while (cellsColumnsStmt.step()) { - cellsTableExists = true; - cellsColumns.push(cellsColumnsStmt.getAsObject().name as string); - } - } finally { - cellsColumnsStmt.free(); + const cellsColumnRows = await this.db!.all<{ name: string; }>("PRAGMA table_info(cells)"); + for (const row of cellsColumnRows) { + cellsTableExists = true; + cellsColumns.push(row.name); } - } catch { - // Table might not exist + } catch (err) { + debug(`cells table not readable: ${err}`); } const hasNewStructure = cellsColumns.includes('s_content') && cellsColumns.includes('t_content'); @@ -3827,10 +4254,18 @@ export class SQLiteIndexManager { hasTargetContent: boolean; }>; }> { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); // Get general stats - const statsStmt = this.db.prepare(` + const result = await this.db!.get<{ + total_cells: number; + cells_with_source_line_numbers: number; + cells_with_target_line_numbers: number; + cells_with_null_source_line_numbers: number; + cells_with_null_target_line_numbers: number; + target_cells_with_content: number; + target_cells_without_content: number; + }>(` SELECT COUNT(*) as total_cells, COUNT(c.s_line_number) as cells_with_source_line_numbers, @@ -3842,34 +4277,26 @@ export class SQLiteIndexManager { FROM cells c `); - let stats = { - totalCells: 0, - cellsWithSourceLineNumbers: 0, - cellsWithTargetLineNumbers: 0, - cellsWithNullSourceLineNumbers: 0, - cellsWithNullTargetLineNumbers: 0, - targetCellsWithContent: 0, - targetCellsWithoutContent: 0 + const stats = { + totalCells: result?.total_cells ?? 0, + cellsWithSourceLineNumbers: result?.cells_with_source_line_numbers ?? 0, + cellsWithTargetLineNumbers: result?.cells_with_target_line_numbers ?? 0, + cellsWithNullSourceLineNumbers: result?.cells_with_null_source_line_numbers ?? 0, + cellsWithNullTargetLineNumbers: result?.cells_with_null_target_line_numbers ?? 0, + targetCellsWithContent: result?.target_cells_with_content ?? 0, + targetCellsWithoutContent: result?.target_cells_without_content ?? 0 }; - try { - statsStmt.step(); - const result = statsStmt.getAsObject(); - stats = { - totalCells: (result.total_cells as number) || 0, - cellsWithSourceLineNumbers: (result.cells_with_source_line_numbers as number) || 0, - cellsWithTargetLineNumbers: (result.cells_with_target_line_numbers as number) || 0, - cellsWithNullSourceLineNumbers: (result.cells_with_null_source_line_numbers as number) || 0, - cellsWithNullTargetLineNumbers: (result.cells_with_null_target_line_numbers as number) || 0, - targetCellsWithContent: (result.target_cells_with_content as number) || 0, - targetCellsWithoutContent: (result.target_cells_without_content as number) || 0 - }; - } finally { - statsStmt.free(); - } - // Get sample cells with line numbers - const sampleStmt = this.db.prepare(` + const sampleRows = await this.db!.all<{ + cell_id: string; + source_line_number: number | null; + target_line_number: number | null; + source_file_path: string | null; + target_file_path: string | null; + has_source_content: number; + has_target_content: number; + }>(` SELECT c.cell_id, c.s_line_number as source_line_number, @@ -3896,21 +4323,16 @@ export class SQLiteIndexManager { hasTargetContent: boolean; }> = []; - try { - while (sampleStmt.step()) { - const row = sampleStmt.getAsObject(); - sampleCells.push({ - cellId: row.cell_id as string, - sourceLineNumber: row.source_line_number as number | null, - targetLineNumber: row.target_line_number as number | null, - sourceFilePath: row.source_file_path as string | null, - targetFilePath: row.target_file_path as string | null, - hasSourceContent: Boolean(row.has_source_content), - hasTargetContent: Boolean(row.has_target_content) - }); - } - } finally { - sampleStmt.free(); + for (const row of sampleRows) { + sampleCells.push({ + cellId: row.cell_id, + sourceLineNumber: row.source_line_number, + targetLineNumber: row.target_line_number, + sourceFilePath: row.source_file_path, + targetFilePath: row.target_file_path, + hasSourceContent: Boolean(row.has_source_content), + hasTargetContent: Boolean(row.has_target_content) + }); } return { @@ -3947,10 +4369,17 @@ export class SQLiteIndexManager { editTimestamp: number | null; }>; }> { - if (!this.db) throw new Error("Database not initialized"); + this.ensureOpen(); // Get general stats about target cell timestamps - const statsStmt = this.db.prepare(` + const result = await this.db!.get<{ + total_target_cells: number; + target_cells_with_content: number; + target_cells_with_created_at: number; + target_cells_with_edit_timestamp: number; + target_cells_with_content_but_no_created_at: number; + target_cells_without_content_but_with_created_at: number; + }>(` SELECT COUNT(*) as total_target_cells, SUM(CASE WHEN c.t_content IS NOT NULL AND c.t_content != '' THEN 1 ELSE 0 END) as target_cells_with_content, @@ -3962,32 +4391,22 @@ export class SQLiteIndexManager { WHERE c.t_file_id IS NOT NULL OR c.t_content IS NOT NULL `); - let stats = { - totalTargetCells: 0, - targetCellsWithContent: 0, - targetCellsWithCreatedAt: 0, - targetCellsWithEditTimestamp: 0, - targetCellsWithContentButNoCreatedAt: 0, - targetCellsWithoutContentButWithCreatedAt: 0 + const stats = { + totalTargetCells: result?.total_target_cells ?? 0, + targetCellsWithContent: result?.target_cells_with_content ?? 0, + targetCellsWithCreatedAt: result?.target_cells_with_created_at ?? 0, + targetCellsWithEditTimestamp: result?.target_cells_with_edit_timestamp ?? 0, + targetCellsWithContentButNoCreatedAt: result?.target_cells_with_content_but_no_created_at ?? 0, + targetCellsWithoutContentButWithCreatedAt: result?.target_cells_without_content_but_with_created_at ?? 0 }; - try { - statsStmt.step(); - const result = statsStmt.getAsObject(); - stats = { - totalTargetCells: (result.total_target_cells as number) || 0, - targetCellsWithContent: (result.target_cells_with_content as number) || 0, - targetCellsWithCreatedAt: (result.target_cells_with_created_at as number) || 0, - targetCellsWithEditTimestamp: (result.target_cells_with_edit_timestamp as number) || 0, - targetCellsWithContentButNoCreatedAt: (result.target_cells_with_content_but_no_created_at as number) || 0, - targetCellsWithoutContentButWithCreatedAt: (result.target_cells_without_content_but_with_created_at as number) || 0 - }; - } finally { - statsStmt.free(); - } - // Get sample target cells to inspect timestamps - const sampleStmt = this.db.prepare(` + const sampleRows = await this.db!.all<{ + cell_id: string; + has_content: number; + created_at: number | null; + edit_timestamp: number | null; + }>(` SELECT c.cell_id, CASE WHEN c.t_content IS NOT NULL AND c.t_content != '' THEN 1 ELSE 0 END as has_content, @@ -4008,24 +4427,24 @@ export class SQLiteIndexManager { editTimestampDate: string | null; }> = []; - try { - while (sampleStmt.step()) { - const row = sampleStmt.getAsObject(); - sampleCells.push({ - cellId: row.cell_id as string, - hasContent: Boolean(row.has_content), - createdAt: row.created_at as number | null, - editTimestamp: row.edit_timestamp as number | null, - createdAtDate: row.created_at ? new Date(row.created_at as number).toISOString() : null, - editTimestampDate: row.edit_timestamp ? new Date(row.edit_timestamp as number).toISOString() : null - }); - } - } finally { - sampleStmt.free(); + for (const row of sampleRows) { + sampleCells.push({ + cellId: row.cell_id, + hasContent: Boolean(row.has_content), + createdAt: row.created_at, + editTimestamp: row.edit_timestamp, + createdAtDate: row.created_at ? new Date(row.created_at).toISOString() : null, + editTimestampDate: row.edit_timestamp ? new Date(row.edit_timestamp).toISOString() : null + }); } // Find timestamp consistency issues - const issuesStmt = this.db.prepare(` + const issuesRows = await this.db!.all<{ + cell_id: string; + has_content: number; + created_at: number | null; + edit_timestamp: number | null; + }>(` SELECT c.cell_id, CASE WHEN c.t_content IS NOT NULL AND c.t_content != '' THEN 1 ELSE 0 END as has_content, @@ -4054,32 +4473,27 @@ export class SQLiteIndexManager { editTimestamp: number | null; }> = []; - try { - while (issuesStmt.step()) { - const row = issuesStmt.getAsObject(); - const hasContent = Boolean(row.has_content); - const createdAt = row.created_at as number | null; - const editTimestamp = row.edit_timestamp as number | null; - - let issue = ''; - if (hasContent && !createdAt) { - issue = 'Has content but missing t_created_at'; - } else if (!hasContent && createdAt) { - issue = 'No content but has t_created_at (should be NULL)'; - } else if (hasContent && !editTimestamp) { - issue = 'Has content but missing t_current_edit_timestamp'; - } - - timestampConsistencyIssues.push({ - cellId: row.cell_id as string, - issue, - hasContent, - createdAt, - editTimestamp - }); + for (const row of issuesRows) { + const hasContent = Boolean(row.has_content); + const createdAt = row.created_at; + const editTimestamp = row.edit_timestamp; + + let issue = ''; + if (hasContent && !createdAt) { + issue = 'Has content but missing t_created_at'; + } else if (!hasContent && createdAt) { + issue = 'No content but has t_created_at (should be NULL)'; + } else if (hasContent && !editTimestamp) { + issue = 'Has content but missing t_current_edit_timestamp'; } - } finally { - issuesStmt.free(); + + timestampConsistencyIssues.push({ + cellId: row.cell_id, + issue, + hasContent, + createdAt, + editTimestamp + }); } return { @@ -4088,4 +4502,183 @@ export class SQLiteIndexManager { timestampConsistencyIssues }; } + + // ── Periodic full integrity check ─────────────────────────────────────── + + /** + * Run a full `PRAGMA integrity_check` — validates B-tree structure, indexes, + * and foreign-key constraints. Much slower than quick_check (~seconds on + * large databases) but catches subtle corruption that quick_check misses. + * + * Returns `true` if the database is healthy, `false` otherwise. + * On failure the result string is logged at error level. + */ + async fullIntegrityCheck(): Promise { + this.ensureOpen(); + + try { + const start = globalThis.performance.now(); + const result = await this.db!.get<{ integrity_check: string; }>( + "PRAGMA integrity_check" + ); + const elapsed = globalThis.performance.now() - start; + const value = result + ? (result.integrity_check ?? Object.values(result)[0]) + : undefined; + + if (value && String(value) === "ok") { + debug(`[SQLiteIndex] Full integrity check passed in ${elapsed.toFixed(0)}ms`); + return true; + } + + console.error(`[SQLiteIndex] Full integrity check FAILED (${elapsed.toFixed(0)}ms): ${value}`); + return false; + } catch (error) { + console.error("[SQLiteIndex] Full integrity check threw:", error); + return false; + } + } + + /** + * Start a periodic background integrity check that runs every + * INTEGRITY_CHECK_INTERVAL_MS (30 min by default). The timer is + * automatically cleared when the database is closed. + * + * If corruption is detected the database is nuked and recreated. + */ + startPeriodicIntegrityCheck(): void { + // Clear any existing timer (idempotent) + this.stopPeriodicIntegrityCheck(); + + this.integrityCheckTimer = setInterval(async () => { + if (this.closed || !this.db) return; + + try { + const healthy = await this.fullIntegrityCheck(); + if (!healthy) { + console.error("[SQLiteIndex] Periodic integrity check detected corruption — recreating database"); + await this.nukeDatabaseAndRecreate("corruption detected by periodic integrity check"); + } + } catch (error) { + // Don't let the timer crash the extension + console.error("[SQLiteIndex] Periodic integrity check error:", error); + } + }, SQLiteIndexManager.INTEGRITY_CHECK_INTERVAL_MS); + + // Don't let the timer keep the Node process alive during shutdown + if (this.integrityCheckTimer.unref) { + this.integrityCheckTimer.unref(); + } + + debug("[SQLiteIndex] Periodic integrity check started (every 30 min)"); + } + + /** Stop the periodic integrity check timer. */ + stopPeriodicIntegrityCheck(): void { + if (this.integrityCheckTimer) { + clearInterval(this.integrityCheckTimer); + this.integrityCheckTimer = null; + } + } + + // ── FTS orphan cleanup ────────────────────────────────────────────────── + + /** + * Remove FTS entries whose cell_id no longer exists in the cells table. + * The cells_fts_delete trigger normally keeps them in sync, but edge cases + * (partial transaction failures, external DB edits) can leave orphans. + * + * This is safe to call at any time and is idempotent. + */ + async cleanupOrphanedFTSEntries(): Promise { + this.ensureOpen(); + + try { + // Find orphaned FTS entries (cell_id in FTS but not in cells) + const orphans = await this.db!.all<{ cell_id: string; }>( + `SELECT DISTINCT fts.cell_id + FROM cells_fts fts + LEFT JOIN cells c ON fts.cell_id = c.cell_id + WHERE c.cell_id IS NULL` + ); + + if (orphans.length === 0) { + debug("[SQLiteIndex] No orphaned FTS entries found"); + return 0; + } + + console.warn(`[SQLiteIndex] Found ${orphans.length} orphaned FTS entries — cleaning up`); + + // Batched DELETE instead of per-row deletes for better performance + const CHUNK_SIZE = 500; + await this.runInTransaction(async () => { + for (let i = 0; i < orphans.length; i += CHUNK_SIZE) { + const chunk = orphans.slice(i, i + CHUNK_SIZE); + const placeholders = chunk.map(() => "?").join(","); + await this.db!.run( + `DELETE FROM cells_fts WHERE cell_id IN (${placeholders})`, + chunk.map(o => o.cell_id) + ); + } + }); + + debug(`[SQLiteIndex] Cleaned up ${orphans.length} orphaned FTS entries`); + return orphans.length; + } catch (error) { + this.logNonCriticalError("cleanupOrphanedFTSEntries", error); + return 0; + } + } + + // ── Incremental schema migration framework ────────────────────────────── + + /** + * Registry of incremental migration functions keyed by target version. + * Each function receives the database and upgrades it from version N-1 to N. + * + * When a migration is available for the gap between the current DB version + * and CURRENT_SCHEMA_VERSION, it will be used instead of nuke-and-recreate. + * Add entries here as the schema evolves to avoid full rebuilds. + * + * Example: + * MIGRATIONS.set(14, async (db) => { + * await db.exec("ALTER TABLE cells ADD COLUMN new_col TEXT"); + * }); + */ + private static readonly MIGRATIONS = new Map Promise>(); + + /** + * Attempt to incrementally migrate from `fromVersion` to `toVersion`. + * Returns true if all intermediate migrations exist and succeeded, + * false if any migration is missing (caller should fall back to nuke). + */ + private async tryIncrementalMigration(fromVersion: number, toVersion: number): Promise { + // Check that we have a complete migration path + for (let v = fromVersion + 1; v <= toVersion; v++) { + if (!SQLiteIndexManager.MIGRATIONS.has(v)) { + debug(`[SQLiteIndex] No migration for v${v - 1} → v${v}, falling back to recreation`); + return false; + } + } + + debug(`[SQLiteIndex] Attempting incremental migration v${fromVersion} → v${toVersion}`); + + try { + await this.runInTransaction(async () => { + for (let v = fromVersion + 1; v <= toVersion; v++) { + const migrate = SQLiteIndexManager.MIGRATIONS.get(v)!; + debug(`[SQLiteIndex] Running migration v${v - 1} → v${v}`); + await migrate(this.db!); + } + }); + + // Stamp the new version + await this.setSchemaVersion(toVersion); + debug(`[SQLiteIndex] Incremental migration to v${toVersion} succeeded`); + return true; + } catch (error) { + console.error(`[SQLiteIndex] Incremental migration failed — will nuke and recreate:`, error); + return false; + } + } } diff --git a/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndexManager.ts b/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndexManager.ts index ebfcf2df7..4c80064ef 100644 --- a/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndexManager.ts +++ b/src/activationHelpers/contextAware/contentIndexes/indexes/sqliteIndexManager.ts @@ -3,6 +3,21 @@ import { SQLiteIndexManager } from "./sqliteIndex"; // Global instance to maintain state across the extension let globalIndexManager: SQLiteIndexManager | null = null; +/** + * Flag set during clearSQLiteIndexManager so in-flight DB operations + * (codexDocument sync, fileSyncManager) can bail early instead of + * hitting a closed/deleted database mid-operation. + */ +let shuttingDown = false; + +/** + * Returns true while the database is being torn down (project swap, deactivation). + * Long-running DB operations should check this and bail early. + */ +export function isDBShuttingDown(): boolean { + return shuttingDown; +} + /** * Get the global SQLite index manager instance * This allows other parts of the extension to access the same index manager @@ -17,24 +32,45 @@ export function getSQLiteIndexManager(): SQLiteIndexManager | null { */ export function setSQLiteIndexManager(manager: SQLiteIndexManager): void { globalIndexManager = manager; + shuttingDown = false; } /** - * Clear the global SQLite index manager instance - * This should be called during extension deactivation + * Clear the global SQLite index manager instance. + * Closes the underlying database connection before releasing the reference. + * This should be called during extension deactivation or project swap. + * + * The reference is cleared FIRST so new callers immediately get null, + * then close() is awaited. If close() throws, the stale reference is + * still gone — preventing a zombie manager from being returned. */ -export function clearSQLiteIndexManager(): void { - globalIndexManager = null; +export async function clearSQLiteIndexManager(): Promise { + shuttingDown = true; + const manager = globalIndexManager; + globalIndexManager = null; // Clear first to prevent new callers from getting it + if (manager) { + try { + await manager.close(); + } catch (e) { + console.error("[SQLiteIndexManager] Error closing index manager during cleanup:", e); + } + } + shuttingDown = false; } /** - * Force refresh the FTS index for immediate search visibility - * Call this when you need to ensure the latest data is searchable + * Force refresh the FTS index for immediate search visibility. + * Call this when you need to ensure the latest data is searchable. + * + * @returns `true` if the refresh was performed, `false` if the + * index manager is not available (e.g. during shutdown). */ -export async function refreshSearchIndex(): Promise { +export async function refreshSearchIndex(): Promise { if (globalIndexManager) { await globalIndexManager.refreshFTSIndex(); + return true; } + return false; } /** diff --git a/src/activationHelpers/contextAware/contentIndexes/indexes/wordsIndex.ts b/src/activationHelpers/contextAware/contentIndexes/indexes/wordsIndex.ts deleted file mode 100644 index 08d56a62a..000000000 --- a/src/activationHelpers/contextAware/contentIndexes/indexes/wordsIndex.ts +++ /dev/null @@ -1,248 +0,0 @@ -import * as vscode from "vscode"; -import * as path from "path"; -import { FileHandler } from "../../../../providers/dictionaryTable/utilities/FileHandler"; -import { cleanWord } from "../../../../utils/cleaningUtils"; -import { updateCompleteDrafts } from "../indexingUtils"; -import { getWorkSpaceUri } from "../../../../utils"; -import { tokenizeText } from "../../../../utils/nlpUtils"; -import { FileData } from "./fileReaders"; - -// HTML tag regex for stripping HTML -const HTML_TAG_REGEX = /<\/?[^>]+(>|$)/g; - -/** - * Strips HTML tags from text - */ -function stripHtml(text: string): string { - return text.replace(HTML_TAG_REGEX, ""); -} - -/** - * Maps positions between HTML-stripped text and original text - * Returns an array where index is position in stripped text and value is position in original text - */ -function createPositionMap(original: string): number[] { - const stripped = stripHtml(original); - const positionMap: number[] = new Array(stripped.length); - - let strippedPos = 0; - let inTag = false; - - for (let origPos = 0; origPos < original.length; origPos++) { - const char = original[origPos]; - - if (char === "<") { - inTag = true; - } - - if (!inTag) { - positionMap[strippedPos] = origPos; - strippedPos++; - } - - if (char === ">") { - inTag = false; - } - } - - return positionMap; -} - -export interface WordOccurrence { - word: string; - context: string; - leftContext: string; - rightContext: string; - fileUri: vscode.Uri; - fileName: string; - cellIndex: number; - lineNumber: number; - startPosition: number; - originalStartPosition?: number; // Position in original text (with HTML) - originalText?: string; // Original text with HTML -} - -export interface WordFrequency { - word: string; - frequency: number; - occurrences?: WordOccurrence[]; -} - -// FIXME: name says it all -const METHOD_SHOULD_BE_STORED_IN_CONFIG = "whitespace_and_punctuation"; -const DEFAULT_CONTEXT_SIZE = 30; - -export async function initializeWordsIndex( - initialWordIndex: any, - targetFiles: FileData[] -): Promise> { - // Create a new map for word occurrences - const result = new Map(); - let totalWords = 0; - - // Process each target file - for (const file of targetFiles) { - const fileName = path.basename(file.uri.fsPath); - - for (let cellIndex = 0; cellIndex < file.cells.length; cellIndex++) { - const cell = file.cells[cellIndex]; - if (cell.metadata?.type === "text" && cell.value?.trim() !== "") { - const originalText = cell.value; - const lines = originalText.split("\n"); - - for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) { - const originalLine = lines[lineNumber]; - const strippedLine = stripHtml(originalLine); - const positionMap = createPositionMap(originalLine); - - const words = tokenizeText({ - method: METHOD_SHOULD_BE_STORED_IN_CONFIG, - text: strippedLine, - }); - - words.forEach((word: string, idx: number) => { - const cleanedWord = cleanWord(word); - if (cleanedWord && cleanedWord.length > 1) { - // Find the position of this word in the line - let startPos = 0; - let tempWords = tokenizeText({ - method: METHOD_SHOULD_BE_STORED_IN_CONFIG, - text: strippedLine.substring(0, startPos + 1), - }); - - while (tempWords.length <= idx) { - startPos = strippedLine.indexOf(word, startPos + 1); - if (startPos === -1) break; - tempWords = tokenizeText({ - method: METHOD_SHOULD_BE_STORED_IN_CONFIG, - text: strippedLine.substring(0, startPos + 1), - }); - } - - if (startPos === -1) startPos = 0; - - // Get the original position in the text with HTML - const originalStartPos = - startPos < positionMap.length ? positionMap[startPos] : startPos; - - // Get context around the word - const leftContext = strippedLine.substring( - Math.max(0, startPos - DEFAULT_CONTEXT_SIZE), - startPos - ); - const rightContext = strippedLine.substring( - startPos + word.length, - Math.min( - strippedLine.length, - startPos + word.length + DEFAULT_CONTEXT_SIZE - ) - ); - - const occurrence: WordOccurrence = { - word: cleanedWord, - context: strippedLine, - leftContext, - rightContext, - fileUri: file.uri, - fileName, - cellIndex, - lineNumber, - startPosition: startPos, - originalStartPosition: originalStartPos, - originalText: originalLine, - }; - - if (!result.has(cleanedWord)) { - result.set(cleanedWord, []); - } - result.get(cleanedWord)!.push(occurrence); - totalWords++; - } - }); - } - } - } - } - - console.log(`Total word occurrences processed: ${totalWords}`); - console.log(`Unique words indexed: ${result.size}`); - - return result; -} - -export function getWordFrequency(wordIndex: Map, word: string): number { - const occurrences = wordIndex.get(word); - return occurrences ? occurrences.length : 0; -} - -export async function getWordsAboveThreshold( - wordIndex: Map, - threshold: number -): Promise { - const workspaceFolderUri = getWorkSpaceUri(); - if (!workspaceFolderUri) { - console.error("No workspace folder found"); - return []; - } - - const dictionaryUri = vscode.Uri.joinPath(workspaceFolderUri, "files", "project.dictionary"); - let dictionaryWords: string[] = []; - - try { - const fileContent = await vscode.workspace.fs.readFile(dictionaryUri); - const data = Buffer.from(fileContent).toString("utf-8"); - - if (data) { - dictionaryWords = parseDictionaryData(data); - } - } catch (error) { - console.error("Error reading dictionary file:", error); - } - - return Array.from(wordIndex.entries()) - .filter( - ([word, occurrences]) => - occurrences.length >= threshold && - !dictionaryWords.includes(word?.toLowerCase() || "") - ) - .map(([word, _]) => word); -} - -function parseDictionaryData(data: string): string[] { - try { - // Try parsing as JSONL first - const entries = data - .split("\n") - .filter((line) => line.trim().length > 0) - .map((line) => JSON.parse(line)); - return entries.map((entry: any) => entry.headWord?.toLowerCase() || ""); - } catch (jsonlError) { - try { - // If JSONL parsing fails, try parsing as a single JSON object - const dictionary = JSON.parse(data); - if (Array.isArray(dictionary.entries)) { - return dictionary.entries.map((entry: any) => entry.headWord?.toLowerCase() || ""); - } else { - throw new Error("Invalid JSON format: missing or invalid entries array."); - } - } catch (jsonError) { - console.error("Could not parse dictionary as JSONL or JSON:", jsonError); - return []; - } - } -} - -export function getWordFrequencies(wordIndex: Map): WordFrequency[] { - return Array.from(wordIndex.entries()).map(([word, occurrences]) => ({ - word, - frequency: occurrences.length, - occurrences, - })); -} - -export function getWordOccurrences( - wordIndex: Map, - word: string -): WordOccurrence[] { - return wordIndex.get(word) || []; -} diff --git a/src/activationHelpers/contextAware/webviewInitializers.ts b/src/activationHelpers/contextAware/webviewInitializers.ts deleted file mode 100644 index 9c0f824e0..000000000 --- a/src/activationHelpers/contextAware/webviewInitializers.ts +++ /dev/null @@ -1,16 +0,0 @@ -"use strict"; - -import * as vscode from "vscode"; -import { registerDictionaryTableProvider } from "../../providers/dictionaryTable/dictionaryTableProvider"; - -export async function initializeWebviews(context: vscode.ExtensionContext) { - // Register providers that are not yet handled by the centralized registration system - registerDictionaryTableProvider(context); - - // Note: The following providers are now registered in registerProviders.ts: - // - registerNavigationWebviewProvider (first so it appears first in activity bar) - // - registerMainMenuProvider - // - registerCommentsWebviewProvider - // - registerParallelViewWebviewProvider - // - registerChatProvider -} diff --git a/src/cellLabelImporter/cellLabelImporter.ts b/src/cellLabelImporter/cellLabelImporter.ts index c3dccfa28..f1abfe3fd 100644 --- a/src/cellLabelImporter/cellLabelImporter.ts +++ b/src/cellLabelImporter/cellLabelImporter.ts @@ -11,7 +11,7 @@ import { importLabelsFromVscodeUri } from "./fileHandler"; import { matchCellLabels } from "./matcher"; import { copyToTempStorage, getColumnHeaders } from "./utils"; import { updateCellLabels } from "./updater"; -import { getNonce } from "../providers/dictionaryTable/utilities/getNonce"; +import { getNonce } from "../utils/getNonce"; import { safePostMessageToPanel } from "../utils/webviewUtils"; const DEBUG_CELL_LABEL_IMPORTER = false; diff --git a/src/codexMigrationTool/codexMigrationTool.ts b/src/codexMigrationTool/codexMigrationTool.ts index a27f90696..c1866dad9 100644 --- a/src/codexMigrationTool/codexMigrationTool.ts +++ b/src/codexMigrationTool/codexMigrationTool.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; import * as path from "path"; -import { getNonce } from "../providers/dictionaryTable/utilities/getNonce"; +import { getNonce } from "../utils/getNonce"; import { safePostMessageToPanel } from "../utils/webviewUtils"; import { matchMigrationCells } from "./matcher"; import { applyMigrationToTargetFile } from "./updater"; diff --git a/src/extension.ts b/src/extension.ts index 956f3a82c..7c0c332e5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,12 +2,7 @@ import * as vscode from "vscode"; import { registerProviders } from "./providers/registerProviders"; import { GlobalProvider } from "./globalProvider"; import { registerCommands } from "./activationHelpers/contextAware/commands"; -import { initializeWebviews } from "./activationHelpers/contextAware/webviewInitializers"; -import { registerLanguageServer } from "./tsServer/registerLanguageServer"; -import { registerClientCommands } from "./tsServer/registerClientCommands"; -import registerClientOnRequests from "./tsServer/registerClientOnRequests"; -import { registerSmartEditCommands } from "./smartEdits/registerSmartEditCommands"; -import { LanguageClient } from "vscode-languageclient/node"; +import { registerBacktranslationCommands } from "./smartEdits/registerBacktranslationCommands"; import { registerProjectManager } from "./projectManager"; import { temporaryMigrationScript_checkMatthewNotebook, @@ -24,13 +19,8 @@ import { } from "./projectManager/utils/migrationUtils"; import { createIndexWithContext } from "./activationHelpers/contextAware/contentIndexes/indexes"; import { StatusBarItem } from "vscode"; -import { Database } from "fts5-sql-bundle"; -import { - importWiktionaryJSONL, - ingestJsonlDictionaryEntries, - initializeSqlJs, - registerLookupWordCommand, -} from "./sqldb"; +import { initNativeSqlite, isNativeSqliteReady } from "./utils/nativeSqlite"; +import { ensureSqliteNativeBinary } from "./utils/sqliteNativeBinaryManager"; import { registerStartupFlowCommands } from "./providers/StartupFlow/registerCommands"; import { registerPreflightCommand } from "./providers/StartupFlow/preflight"; import { NotebookMetadataManager } from "./utils/notebookMetadataManager"; @@ -67,6 +57,7 @@ import { } from "./projectManager/utils/migrationUtils"; import { initializeAudioProcessor } from "./utils/audioProcessor"; import { initializeAudioMerger } from "./utils/audioMerger"; +import { cleanupOrphanedProjectFiles } from "./utils/fileUtils"; // markUserAsUpdatedInRemoteList is now called in performProjectUpdate before window reload import * as fs from "fs"; import * as os from "os"; @@ -167,13 +158,6 @@ function finishRealtimeStep(): number { return globalThis.performance.now(); } -declare global { - // eslint-disable-next-line - var db: Database | undefined; -} - -let client: LanguageClient | undefined; -let clientCommandsDisposable: vscode.Disposable; let autoCompleteStatusBarItem: StatusBarItem; // let commitTimeout: any; // const COMMIT_DELAY = 5000; // Delay in milliseconds @@ -429,8 +413,24 @@ export async function activate(context: vscode.ExtensionContext) { stepStart = trackTiming("Configuring Startup Workflow", startupStart); - // Initialize SqlJs with real-time progress since it loads WASM files - // Only initialize database if we have a workspace (database is for project content) + // Download the native SQLite binary if not already present. + // This blocks with a progress notification on first run (~2 MB download). + const sqliteBinaryStart = globalThis.performance.now(); + try { + const binaryPath = await ensureSqliteNativeBinary(context); + console.log("[SQLite] Binary path resolved:", binaryPath); + initNativeSqlite(binaryPath); + console.log("[SQLite] Native module initialized successfully"); + } catch (error: any) { + console.error("[SQLite] Failed to set up native binary:", error?.message || error); + console.error("[SQLite] Stack:", error?.stack); + vscode.window.showWarningMessage( + "SQLite native module could not be loaded. Search features will be unavailable." + ); + // Database features (content index) will be unavailable + } + stepStart = trackTiming("Setting up search engine", sqliteBinaryStart); + const workspaceFolders = vscode.workspace.workspaceFolders; // Check for pending swap downloads (after workspace is ready) @@ -439,28 +439,6 @@ export async function activate(context: vscode.ExtensionContext) { console.error("[Extension] Error checking pending swap downloads:", err); }); } - if (workspaceFolders && workspaceFolders.length > 0) { - startRealtimeStep("AI preparing search capabilities"); - try { - global.db = await initializeSqlJs(context); - - } catch (error) { - console.error("Error initializing SqlJs:", error); - } - stepStart = finishRealtimeStep(); - if (global.db) { - const importCommand = vscode.commands.registerCommand( - "extension.importWiktionaryJSONL", - () => global.db && importWiktionaryJSONL(global.db) - ); - context.subscriptions.push(importCommand); - registerLookupWordCommand(global.db, context); - ingestJsonlDictionaryEntries(global.db); - } - } else { - // No workspace, skip database initialization - stepStart = trackTiming("AI search capabilities (skipped - no workspace)", globalThis.performance.now()); - } vscode.workspace.getConfiguration().update("workbench.startupEditor", "none", true); @@ -529,7 +507,6 @@ export async function activate(context: vscode.ExtensionContext) { trackTiming("Initializing Workspace", workspaceStart); - // Always initialize extension to ensure language server is available before webviews await initializeExtension(context, metadataExists); // Ensure local project settings exist when a Codex project is open @@ -564,10 +541,9 @@ export async function activate(context: vscode.ExtensionContext) { const coreComponentsStart = globalThis.performance.now(); await Promise.all([ - registerSmartEditCommands(context), + registerBacktranslationCommands(context), registerProviders(context), registerCommands(context), - initializeWebviews(context), ]); // Register metadata commands for frontier-authentication to call @@ -626,6 +602,9 @@ export async function activate(context: vscode.ExtensionContext) { await migration_cellIdsToUuid(context); await migration_recoverTempFilesAndMergeDuplicates(context); + // Remove leftover files from features that have been removed + await cleanupOrphanedProjectFiles(); + // After migrations complete, trigger sync directly // (All migrations have finished executing since they're awaited sequentially) try { @@ -816,58 +795,18 @@ async function initializeExtension(context: vscode.ExtensionContext, metadataExi debug("Initializing extension"); if (metadataExists) { - // Break down language server initialization - const totalLsStart = globalThis.performance.now(); - - startRealtimeStep("Initializing Language Server"); - const lsStart = globalThis.performance.now(); - client = await registerLanguageServer(context); - const lsDuration = globalThis.performance.now() - lsStart; - debug(`[Activation] Start Language Server: ${lsDuration.toFixed(2)}ms`); - - // Always register client commands to prevent "command not found" errors - // If language server failed, commands will return appropriate fallbacks - const regServicesStart = globalThis.performance.now(); - clientCommandsDisposable = registerClientCommands(context, client); - context.subscriptions.push(clientCommandsDisposable); - const regServicesDuration = globalThis.performance.now() - regServicesStart; - debug(`[Activation] Register Language Services: ${regServicesDuration.toFixed(2)}ms`); - - if (client && global.db) { - const optimizeStart = globalThis.performance.now(); - try { - await registerClientOnRequests(client, global.db); - await client.start(); - } catch (error) { - console.error("Error registering client requests:", error); - } - const optimizeDuration = globalThis.performance.now() - optimizeStart; - debug(`[Activation] Optimize Language Processing: ${optimizeDuration.toFixed(2)}ms`); - } else { - if (!client) { - console.warn("Language server failed to initialize - spellcheck and alert features will use fallback behavior"); - } - if (!global.db) { - console.info("[Database] Dictionary not available - dictionary features will be limited. This is normal during initial setup or if database initialization failed."); - } - } - finishRealtimeStep(); - const totalLsDuration = globalThis.performance.now() - totalLsStart; - debug(`[Activation] Language Server Ready: ${totalLsDuration.toFixed(2)}ms`); - - // Break down index creation + // Break down index creation const totalIndexStart = globalThis.performance.now(); - const verseRefsStart = globalThis.performance.now(); - // Index verse refs would go here, but it seems to be missing from this section - const verseRefsDuration = globalThis.performance.now() - verseRefsStart; - debug(`[Activation] Index Verse Refs: ${verseRefsDuration.toFixed(2)}ms`); - // Use real-time progress for context index setup since it can take a while // Note: SQLiteIndexManager handles its own detailed progress tracking - startRealtimeStep("AI learning your project structure"); - await createIndexWithContext(context); - finishRealtimeStep(); + if (isNativeSqliteReady()) { + startRealtimeStep("AI learning your project structure"); + await createIndexWithContext(context); + finishRealtimeStep(); + } else { + console.warn("[Extension] Skipping content index creation — SQLite native module not available"); + } // Don't track "Total Index Creation" since it would show cumulative time // The individual steps above already show the breakdown @@ -960,10 +899,6 @@ async function executeCommandsAfter( .getConfiguration() .update("files.autoSaveDelay", 1000, vscode.ConfigurationTarget.Global); - await vscode.workspace - .getConfiguration() - .update("codex-project-manager.spellcheckIsEnabled", false, vscode.ConfigurationTarget.Global); - // Final splash screen update and close updateSplashScreenSync(100, "Finalizing setup..."); @@ -1206,29 +1141,22 @@ async function cancelSwap(projectUri: vscode.Uri, _pendingState: any): Promise { - clearSQLiteIndexManager(); - } - ).catch(console.error); } export function getAutoCompleteStatusBarItem(): StatusBarItem { diff --git a/src/globalProvider.ts b/src/globalProvider.ts index 1d1c4aa47..e213a3359 100644 --- a/src/globalProvider.ts +++ b/src/globalProvider.ts @@ -2,7 +2,7 @@ import vscode from "vscode"; import { CodexCellEditorProvider } from "./providers/codexCellEditorProvider/codexCellEditorProvider"; import { CustomWebviewProvider } from "./providers/parallelPassagesWebview/customParallelPassagesWebviewProvider"; import { GlobalContentType, GlobalMessage } from "../types"; -import { getNonce } from "./providers/dictionaryTable/utilities/getNonce"; +import { getNonce } from "./utils/getNonce"; import { safePostMessageToView } from "./utils/webviewUtils"; diff --git a/src/projectManager/index.ts b/src/projectManager/index.ts index d837005bf..e10f04dd9 100644 --- a/src/projectManager/index.ts +++ b/src/projectManager/index.ts @@ -637,29 +637,6 @@ export async function registerProjectManager(context: vscode.ExtensionContext) { }) ); - const toggleSpellcheckCommand = vscode.commands.registerCommand( - "codex-project-manager.toggleSpellcheck", - executeWithRedirecting(async () => { - const config = vscode.workspace.getConfiguration("codex-project-manager"); - const currentSpellcheckIsEnabledValue = config.get("spellcheckIsEnabled", false); - - const newSpellcheckIsEnabledValue = !currentSpellcheckIsEnabledValue; - - console.log("currentSpellcheckIsEnabledValue", currentSpellcheckIsEnabledValue); - console.log("newSpellcheckIsEnabledValue", newSpellcheckIsEnabledValue); - - await config.update( - "spellcheckIsEnabled", - newSpellcheckIsEnabledValue, - vscode.ConfigurationTarget.Workspace - ); - vscode.commands.executeCommand("codex-project-manager.updateMetadataFile"); - vscode.window.showInformationMessage( - `Spellcheck is now ${newSpellcheckIsEnabledValue ? "enabled" : "disabled"}.` - ); - }) - ); - const updateMetadataFileCommand = vscode.commands.registerCommand( "codex-project-manager.updateMetadataFile", updateMetadataFile @@ -747,8 +724,7 @@ export async function registerProjectManager(context: vscode.ExtensionContext) { changeUserEmailCommand, validateProjectIdCommand, onDidChangeConfigurationListener, - onDidChangeExtensionsListener, - toggleSpellcheckCommand + onDidChangeExtensionsListener ); // Prompt user to install recommended extensions diff --git a/src/projectManager/projectInitializers.ts b/src/projectManager/projectInitializers.ts index faffc284e..540ea16a5 100644 --- a/src/projectManager/projectInitializers.ts +++ b/src/projectManager/projectInitializers.ts @@ -253,7 +253,7 @@ export async function initializeProject(shouldImportUSFM: boolean) { foldersWithUsfmToConvert, }); - // Ensure the files directory exists for dictionary and other project files + // Ensure the files directory exists for project files const filesDir = vscode.Uri.joinPath(workspaceFolder.uri, "files"); try { await vscode.workspace.fs.createDirectory(filesDir); diff --git a/src/projectManager/syncManager.ts b/src/projectManager/syncManager.ts index 3ac75bbd7..2278c0d28 100644 --- a/src/projectManager/syncManager.ts +++ b/src/projectManager/syncManager.ts @@ -12,6 +12,7 @@ import { getFrontierVersionStatus, checkVSCodeVersion } from "./utils/versionChe import { CommentsMigrator } from "../utils/commentsMigrationUtils"; import { checkRemoteUpdatingRequired } from "../utils/remoteUpdatingManager"; import { markPendingUpdateRequired } from "../utils/localProjectSettings"; +import { isNativeSqliteReady } from "../utils/nativeSqlite"; const DEBUG_SYNC_MANAGER = false; @@ -1145,11 +1146,16 @@ export class SyncManager { // Fallback method for basic index rebuild (original logic as backup) private async fallbackIndexRebuild(): Promise { + if (!isNativeSqliteReady()) { + console.warn("[SyncManager] Skipping index rebuild — SQLite not available"); + return; + } + const { getSQLiteIndexManager } = await import("../activationHelpers/contextAware/contentIndexes/indexes/sqliteIndexManager"); const indexManager = getSQLiteIndexManager(); if (indexManager) { - const currentDocCount = indexManager.documentCount; + const currentDocCount = await indexManager.getDocumentCount(); debug(`[FallbackSync] Current index has ${currentDocCount} documents`); if (currentDocCount > 0) { diff --git a/src/projectManager/utils/merge/resolvers.ts b/src/projectManager/utils/merge/resolvers.ts index 74b2e697d..3a11ac4f0 100644 --- a/src/projectManager/utils/merge/resolvers.ts +++ b/src/projectManager/utils/merge/resolvers.ts @@ -1,7 +1,7 @@ import { CodexCellDocument } from './../../../providers/codexCellEditorProvider/codexDocument'; import * as vscode from "vscode"; import * as path from "path"; -import { ConflictResolutionStrategy, ConflictFile, SmartEdit } from "./types"; +import { ConflictResolutionStrategy, ConflictFile } from "./types"; import { determineStrategy } from "./strategies"; import { getAuthApi } from "../../../extension"; import { checkProjectAdminPermissions } from "../../../utils/projectAdminPermissionChecker"; @@ -496,7 +496,6 @@ export async function resolveConflictFile( resolvedContent = conflict.ours; // Keep our version break; - case ConflictResolutionStrategy.SOURCE: case ConflictResolutionStrategy.OVERRIDE: { debugLog("Resolving conflict for:", conflict.filepath); // TODO: Compare content timestamps if embedded in the content @@ -505,18 +504,6 @@ export async function resolveConflictFile( break; } - case ConflictResolutionStrategy.JSONL: { - debugLog("Resolving JSONL conflict for:", conflict.filepath); - // Parse and merge JSONL content - const ourLines = conflict.ours.split("\n").filter(Boolean); - const theirLines = conflict.theirs.split("\n").filter(Boolean); - - // Combine and deduplicate - const allLines = new Set([...ourLines, ...theirLines]); - resolvedContent = Array.from(allLines).join("\n"); - break; - } - case ConflictResolutionStrategy.ARRAY: { debugLog("Resolving array conflict for:", conflict.filepath); // Special handling for notebook comment thread arrays @@ -530,11 +517,7 @@ export async function resolveConflictFile( // SPECIAL = "special", // Merge based on timestamps/rules case ConflictResolutionStrategy.SPECIAL: { debugLog("Resolving special conflict for:", conflict.filepath); - if (conflict.filepath === "metadata.json") { - resolvedContent = await resolveMetadataJsonConflict(conflict); - } else { - resolvedContent = await resolveSmartEditsConflict(conflict.ours, conflict.theirs); - } + resolvedContent = await resolveMetadataJsonConflict(conflict); break; } @@ -1722,67 +1705,6 @@ function isValidSelection(selectedId: string, attachments?: { [key: string]: any !attachment.isDeleted; } -/** - * Resolves conflicts in smart_edits.json files - */ -async function resolveSmartEditsConflict( - ourContent: string, - theirContent: string -): Promise { - // Handle empty content cases - if (!ourContent.trim()) { - return theirContent.trim() || "{}"; - } - if (!theirContent.trim()) { - return ourContent.trim() || "{}"; - } - - try { - const ourEdits = JSON.parse(ourContent); - const theirEdits = JSON.parse(theirContent); - - // Merge the edits, preferring newer versions for same cellIds - const mergedEdits: Record = {}; - - // Process our edits - Object.entries(ourEdits).forEach(([cellId, edit]) => { - mergedEdits[cellId] = edit as SmartEdit; - }); - - // Process their edits, comparing timestamps for conflicts - Object.entries(theirEdits).forEach(([cellId, theirEdit]) => { - if (!mergedEdits[cellId]) { - mergedEdits[cellId] = theirEdit as SmartEdit; - } else { - const ourDate = new Date(mergedEdits[cellId].lastUpdatedDate); - const theirDate = new Date((theirEdit as SmartEdit).lastUpdatedDate); - - if (theirDate > ourDate) { - mergedEdits[cellId] = theirEdit as SmartEdit; - } - - // Merge suggestions arrays and deduplicate - const allSuggestions = [ - ...mergedEdits[cellId].suggestions, - ...(theirEdit as SmartEdit).suggestions, - ]; - - // Deduplicate suggestions based on oldString+newString combination - mergedEdits[cellId].suggestions = Array.from( - new Map( - allSuggestions.map((sugg) => [`${sugg.oldString}:${sugg.newString}`, sugg]) - ).values() - ); - } - }); - - return JSON.stringify(mergedEdits, null, 2); - } catch (error) { - console.error("Error resolving smart_edits.json conflict:", error); - return "{}"; // Return empty object if parsing fails - } -} - /** * Resolves conflicts in metadata.json, specifically merging the remote updating list */ diff --git a/src/projectManager/utils/merge/strategies.ts b/src/projectManager/utils/merge/strategies.ts index 8cf1aa8bd..6dedd7103 100644 --- a/src/projectManager/utils/merge/strategies.ts +++ b/src/projectManager/utils/merge/strategies.ts @@ -9,29 +9,16 @@ export const filePatternsToResolve: Record ], // Simple JSON override files - keep newest version - [ConflictResolutionStrategy.OVERRIDE]: [ - "chat-threads.json", - "files/chat_history.jsonl", - "files/silver_path_memories.json", - "files/smart_passages_memories.json", - ".project/dictionary.sqlite", - ], + [ConflictResolutionStrategy.OVERRIDE]: [], // Mergeable Comment arrays on commentThread array - combine recursively and deduplicate [ConflictResolutionStrategy.ARRAY]: [".project/comments.json"], - // JSONL files - combine and deduplicate - [ConflictResolutionStrategy.JSONL]: ["files/project.dictionary"], - // Special JSON merges - merge based on timestamps [ConflictResolutionStrategy.SPECIAL]: [ - "files/smart_edits.json", "metadata.json" ], - // Source files - keep newest version (DEPRECATED: now using CODEX_CUSTOM_MERGE) - [ConflictResolutionStrategy.SOURCE]: [], - // Files to ignore [ConflictResolutionStrategy.IGNORE]: ["complete_drafts.txt"], diff --git a/src/projectManager/utils/merge/types.ts b/src/projectManager/utils/merge/types.ts index f3c0b7399..189eaed47 100644 --- a/src/projectManager/utils/merge/types.ts +++ b/src/projectManager/utils/merge/types.ts @@ -2,22 +2,13 @@ import { FrontierAPI } from "../../../../webviews/codex-webviews/src/StartupFlow export enum ConflictResolutionStrategy { OVERRIDE = "override", // Keep newest version (timestamp-based) - SOURCE = "source", // Keep newest version (read-only files) IGNORE = "ignore", // Always keep our version (HEAD) for auto-generated files ARRAY = "array", // Combine arrays and deduplicate SPECIAL = "special", // Merge based on timestamps/rules CODEX_CUSTOM_MERGE = "codex", // Special merge process for cell arrays - JSONL = "jsonl", // Combine and deduplicate JSONL files JSON_MERGE_3WAY = "json-merge-3way", // 3-way merge for JSON settings with chatSystemMessage tie-breaker } -export interface SmartEdit { - cellId: string; - lastCellValue: string; - suggestions: Array<{ oldString: string; newString: string; }>; - lastUpdatedDate: string; -} - export interface ConflictFile { filepath: string; ours: string; // The actual content, not a path diff --git a/src/projectManager/utils/projectUtils.ts b/src/projectManager/utils/projectUtils.ts index 24b970aa6..7b036a93d 100644 --- a/src/projectManager/utils/projectUtils.ts +++ b/src/projectManager/utils/projectUtils.ts @@ -592,7 +592,6 @@ export async function updateMetadataFile() { const originalGenerator = project.meta?.generator ? { ...project.meta.generator } : undefined; const originalAbbreviation = project.meta?.abbreviation; const originalLanguages = project.languages ? [...project.languages] : undefined; - const originalSpellcheckIsEnabled = project.spellcheckIsEnabled; const originalValidationCount = project.meta?.validationCount; const originalValidationCountAudio = project.meta?.validationCountAudio; @@ -636,9 +635,6 @@ export async function updateMetadataFile() { const newAbbreviation = projectSettings.get("abbreviation", ""); project.meta.abbreviation = newAbbreviation; - const newSpellcheckIsEnabled = projectSettings.get("spellcheckIsEnabled", false); - project.spellcheckIsEnabled = newSpellcheckIsEnabled; - // Track edits for changed user-editable fields // Ensure edits array exists if (!project.edits) { @@ -676,11 +672,6 @@ export async function updateMetadataFile() { addProjectMetadataEdit(project, EditMapUtils.languages(), newLanguages, author); } - // Track spellcheckIsEnabled changes - if (originalSpellcheckIsEnabled !== newSpellcheckIsEnabled) { - addProjectMetadataEdit(project, EditMapUtils.spellcheckIsEnabled(), newSpellcheckIsEnabled, author); - } - debug("Project settings loaded, preparing to write to metadata.json"); return project; }, @@ -848,7 +839,6 @@ export async function getProjectOverview(): Promise targetTexts, targetFont: metadata.targetFont || "Default Font", isAuthenticated, - spellcheckIsEnabled: metadata.spellcheckIsEnabled || false, }; } catch (error) { console.error("Failed to read project metadata:", error); diff --git a/src/providers/StartupFlow/StartupFlowProvider.ts b/src/providers/StartupFlow/StartupFlowProvider.ts index 0f0253b87..c5472f34c 100644 --- a/src/providers/StartupFlow/StartupFlowProvider.ts +++ b/src/providers/StartupFlow/StartupFlowProvider.ts @@ -3647,12 +3647,19 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { console.error("Failed to ensure attachments structure:", e); } - // Remove indexes.sqlite so it can be rebuilt - try { + // Remove indexes.sqlite (and WAL/SHM) so it can be rebuilt + { const indexDbPath = vscode.Uri.joinPath(newProjectUri, ".project", "indexes.sqlite"); - await vscode.workspace.fs.delete(indexDbPath, { recursive: false, useTrash: false }); - } catch { - // Missing index file is fine + for (const suffix of ["", "-wal", "-shm"]) { + try { + await vscode.workspace.fs.delete( + vscode.Uri.file(`${indexDbPath.fsPath}${suffix}`), + { recursive: false, useTrash: false } + ); + } catch { + // Missing file is fine + } + } } // 7. Initialize git repository (fresh .git) @@ -4312,6 +4319,18 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { * Perform project deletion */ private async performProjectDeletion(projectPath: string, projectName: string): Promise { + // Close the SQLite index database before deleting the project folder to prevent + // writing to an orphaned file descriptor (same guard as swap and update). + try { + const { clearSQLiteIndexManager } = await import( + "../../activationHelpers/contextAware/contentIndexes/indexes/sqliteIndexManager" + ); + await clearSQLiteIndexManager(); + debugLog("SQLite index manager closed before project deletion"); + } catch (e) { + debugLog("Warning: could not close SQLite index manager before deletion:", e); + } + try { // Use vscode.workspace.fs.delete with the recursive flag const projectUri = vscode.Uri.file(projectPath); @@ -4426,6 +4445,18 @@ export class StartupFlowProvider implements vscode.CustomTextEditorProvider { showSuccessMessage: boolean = true, currentUsername?: string ): Promise { + // Close the SQLite index database before any file operations to prevent + // writing to an orphaned file descriptor after the project folder is moved/deleted. + try { + const { clearSQLiteIndexManager } = await import( + "../../activationHelpers/contextAware/contentIndexes/indexes/sqliteIndexManager" + ); + await clearSQLiteIndexManager(); + debugLog("SQLite index manager closed before project update"); + } catch (e) { + debugLog("Warning: could not close SQLite index manager before update:", e); + } + const cleanedPath = await this.cleanupStaleUpdateState(projectPath, projectName); if (cleanedPath && cleanedPath !== projectPath) { projectPath = cleanedPath; diff --git a/src/providers/StartupFlow/performProjectSwap.ts b/src/providers/StartupFlow/performProjectSwap.ts index 47faf164c..71d59d507 100644 --- a/src/providers/StartupFlow/performProjectSwap.ts +++ b/src/providers/StartupFlow/performProjectSwap.ts @@ -441,6 +441,19 @@ export async function performProjectSwap( swapReason?: string ): Promise { debugLog("Starting project swap:", { projectName, oldProjectPath, newProjectUrl, swapUUID, swapInitiatedAt, swapInitiatedBy }); + + // Close the SQLite index database before any file operations to prevent + // writing to an orphaned file descriptor after the project folder is moved/deleted. + try { + const { clearSQLiteIndexManager } = await import( + "../../activationHelpers/contextAware/contentIndexes/indexes/sqliteIndexManager" + ); + await clearSQLiteIndexManager(); + debugLog("SQLite index manager closed before swap"); + } catch (e) { + debugLog("Warning: could not close SQLite index manager before swap:", e); + } + const targetFolderName = extractProjectNameFromUrl(newProjectUrl) || projectName; const targetProjectPath = path.join(path.dirname(oldProjectPath), targetFolderName); diff --git a/src/providers/WordsView/WordsViewProvider.ts b/src/providers/WordsView/WordsViewProvider.ts deleted file mode 100644 index 792c8bb01..000000000 --- a/src/providers/WordsView/WordsViewProvider.ts +++ /dev/null @@ -1,1324 +0,0 @@ -import * as vscode from "vscode"; -import { - getWordFrequencies, - initializeWordsIndex, - WordFrequency, - WordOccurrence, -} from "../../activationHelpers/contextAware/contentIndexes/indexes/wordsIndex"; -import { readSourceAndTargetFiles } from "../../activationHelpers/contextAware/contentIndexes/indexes/fileReaders"; -import { safePostMessageToPanel } from "../../utils/webviewUtils"; - -export class WordsViewProvider implements vscode.Disposable { - public static readonly viewType = "frontier.wordsView"; - private _panel?: vscode.WebviewPanel; - private _wordFrequencies: WordFrequency[] = []; - private _selectedOccurrences: Set = new Set(); - private _sortMode: "frequency" | "leftContext" | "rightContext" = "frequency"; - - // Pagination and view state - private _currentPage = 1; - private _pageSize = 50; - private _expandedWords: Set = new Set(); - private _totalPages = 1; - - constructor(private readonly _extensionUri: vscode.Uri) { } - - dispose() { - this._panel?.dispose(); - this._panel = undefined; - } - - public async show() { - if (this._panel) { - this._panel.reveal(); - return; - } - - this._panel = vscode.window.createWebviewPanel( - WordsViewProvider.viewType, - "KWIC View", - vscode.ViewColumn.One, - { - enableScripts: true, - retainContextWhenHidden: true, - } - ); - - this._panel.onDidDispose(() => { - this._panel = undefined; - }); - - this._panel.webview.onDidReceiveMessage(async (message) => { - let page: number; - switch (message.command) { - case "toggleSelection": - this._toggleOccurrenceSelection(message.id); - break; - case "replaceSelected": - await this._replaceSelectedOccurrences(message.replacement); - break; - case "sortBy": - this._sortMode = message.sortMode; - await this.updateContent(); - break; - case "nextPage": - if (this._currentPage < this._totalPages) { - this._currentPage++; - await this.updateContent(false); - } - break; - case "prevPage": - if (this._currentPage > 1) { - this._currentPage--; - await this.updateContent(false); - } - break; - case "goToPage": - page = parseInt(message.page); - if (!isNaN(page) && page >= 1 && page <= this._totalPages) { - this._currentPage = page; - await this.updateContent(false); - } - break; - case "toggleWordExpand": - this._toggleWordExpand(message.word); - await this.updateContent(false); - break; - } - }); - - await this.updateContent(); - } - - private _toggleWordExpand(word: string) { - if (this._expandedWords.has(word)) { - this._expandedWords.delete(word); - } else { - this._expandedWords.add(word); - } - } - - private _toggleOccurrenceSelection(id: string) { - if (this._selectedOccurrences.has(id)) { - this._selectedOccurrences.delete(id); - } else { - this._selectedOccurrences.add(id); - } - - // Update UI to reflect selection change - safePostMessageToPanel(this._panel, { - command: "updateSelection", - id, - selected: this._selectedOccurrences.has(id), - }); - } - - private async _replaceSelectedOccurrences(replacement: string) { - if (this._selectedOccurrences.size === 0) { - vscode.window.showInformationMessage("No occurrences selected for replacement"); - return; - } - - // Group the occurrences by file - const fileEdits = new Map(); - - for (const wordFreq of this._wordFrequencies) { - if (!wordFreq.occurrences) continue; - - for (const occurrence of wordFreq.occurrences) { - const id = this._getOccurrenceId(occurrence); - if (this._selectedOccurrences.has(id)) { - const key = occurrence.fileUri.toString(); - if (!fileEdits.has(key)) { - fileEdits.set(key, []); - } - fileEdits.get(key)?.push({ occurrence, replacement }); - } - } - } - - // Apply edits to each file - const workspaceEdit = new vscode.WorkspaceEdit(); - - for (const [fileUriStr, edits] of fileEdits.entries()) { - const fileUri = vscode.Uri.parse(fileUriStr); - - try { - const document = await vscode.workspace.openTextDocument(fileUri); - - // Sort edits from last to first to avoid position shifts - edits.sort((a, b) => { - if (a.occurrence.lineNumber !== b.occurrence.lineNumber) { - return b.occurrence.lineNumber - a.occurrence.lineNumber; - } - // Use original position if available - const aPos = - a.occurrence.originalStartPosition !== undefined - ? a.occurrence.originalStartPosition - : a.occurrence.startPosition; - const bPos = - b.occurrence.originalStartPosition !== undefined - ? b.occurrence.originalStartPosition - : b.occurrence.startPosition; - return bPos - aPos; - }); - - for (const edit of edits) { - const linePosition = document.lineAt(edit.occurrence.lineNumber).range.start; - // Use original position if available - const startPosition = - edit.occurrence.originalStartPosition !== undefined - ? edit.occurrence.originalStartPosition - : edit.occurrence.startPosition; - - const startPos = new vscode.Position( - edit.occurrence.lineNumber, - linePosition.character + startPosition - ); - const endPos = new vscode.Position( - edit.occurrence.lineNumber, - linePosition.character + startPosition + edit.occurrence.word.length - ); - - workspaceEdit.replace( - fileUri, - new vscode.Range(startPos, endPos), - edit.replacement - ); - } - } catch (error) { - console.error(`Error processing file ${fileUriStr}:`, error); - } - } - - // Apply all edits at once - const success = await vscode.workspace.applyEdit(workspaceEdit); - - if (success) { - vscode.window.showInformationMessage( - `Successfully replaced ${this._selectedOccurrences.size} occurrences` - ); - this._selectedOccurrences.clear(); - - // Re-index to update the view - await this.updateContent(); - } else { - vscode.window.showErrorMessage("Failed to apply replacements"); - } - } - - private _getOccurrenceId(occurrence: WordOccurrence): string { - return `${occurrence.fileUri.toString()}_${occurrence.cellIndex}_${occurrence.lineNumber}_${occurrence.startPosition}`; - } - - private async updateContent(reindex = true) { - if (!this._panel) { - return; - } - - if (reindex) { - // Initialize word index - const { targetFiles } = await readSourceAndTargetFiles(); - const wordsIndex = await initializeWordsIndex( - new Map(), - targetFiles - ); - this._wordFrequencies = getWordFrequencies(wordsIndex); - - // Sort according to current sort mode - this._sortFrequencies(); - - // Reset pagination - this._currentPage = 1; - this._totalPages = Math.ceil(this._wordFrequencies.length / this._pageSize); - } - - // Generate HTML with KWIC view - this._panel.webview.html = this._generateKwicHtml(); - } - - private _sortFrequencies() { - switch (this._sortMode) { - case "frequency": - this._wordFrequencies.sort((a, b) => b.frequency - a.frequency); - break; - case "leftContext": - // Sort by left context tokens in reverse order (tokens closest to the keyword first) - this._wordFrequencies.sort((a, b) => { - const aOcc = a.occurrences?.[0]; - const bOcc = b.occurrences?.[0]; - - if (!aOcc || !bOcc) { - return 0; - } - - // Split left context into tokens and reverse them - const aTokens = aOcc.leftContext.trim().split(/\s+/).filter(Boolean).reverse(); - const bTokens = bOcc.leftContext.trim().split(/\s+/).filter(Boolean).reverse(); - - // Compare tokens one by one, starting from tokens closest to the keyword - const minLength = Math.min(aTokens.length, bTokens.length); - - for (let i = 0; i < minLength; i++) { - const comparison = aTokens[i].localeCompare(bTokens[i]); - if (comparison !== 0) { - return comparison; - } - } - - // If all common tokens are the same, shorter context comes first - return aTokens.length - bTokens.length; - }); - - // Also sort occurrences within each word using token-based sorting - this._wordFrequencies.forEach((wf) => { - if (wf.occurrences) { - wf.occurrences.sort((a, b) => { - // Split left context into tokens and reverse them - const aTokens = a.leftContext - .trim() - .split(/\s+/) - .filter(Boolean) - .reverse(); - const bTokens = b.leftContext - .trim() - .split(/\s+/) - .filter(Boolean) - .reverse(); - - // Compare tokens one by one, starting from tokens closest to the keyword - const minLength = Math.min(aTokens.length, bTokens.length); - - for (let i = 0; i < minLength; i++) { - const comparison = aTokens[i].localeCompare(bTokens[i]); - if (comparison !== 0) { - return comparison; - } - } - - // If all common tokens are the same, shorter context comes first - return aTokens.length - bTokens.length; - }); - } - }); - break; - case "rightContext": - // Similarly update right context sorting to be token-based (closest to keyword first) - this._wordFrequencies.sort((a, b) => { - const aOcc = a.occurrences?.[0]; - const bOcc = b.occurrences?.[0]; - - if (!aOcc || !bOcc) { - return 0; - } - - // Split right context into tokens - const aTokens = aOcc.rightContext.trim().split(/\s+/).filter(Boolean); - const bTokens = bOcc.rightContext.trim().split(/\s+/).filter(Boolean); - - // Compare tokens one by one, starting from tokens closest to the keyword - const minLength = Math.min(aTokens.length, bTokens.length); - - for (let i = 0; i < minLength; i++) { - const comparison = aTokens[i].localeCompare(bTokens[i]); - if (comparison !== 0) { - return comparison; - } - } - - // If all common tokens are the same, shorter context comes first - return aTokens.length - bTokens.length; - }); - - // Also sort occurrences within each word using token-based sorting - this._wordFrequencies.forEach((wf) => { - if (wf.occurrences) { - wf.occurrences.sort((a, b) => { - // Split right context into tokens - const aTokens = a.rightContext.trim().split(/\s+/).filter(Boolean); - const bTokens = b.rightContext.trim().split(/\s+/).filter(Boolean); - - // Compare tokens one by one - const minLength = Math.min(aTokens.length, bTokens.length); - - for (let i = 0; i < minLength; i++) { - const comparison = aTokens[i].localeCompare(bTokens[i]); - if (comparison !== 0) { - return comparison; - } - } - - // If all common tokens are the same, shorter context comes first - return aTokens.length - bTokens.length; - }); - } - }); - break; - } - } - - private _generateKwicHtml(): string { - return ` - - - - - KWIC View - - - -
-
-
Total unique words: ${this._wordFrequencies.length}
-
Page ${this._currentPage} of ${this._totalPages}
-
- -
-
- - - - - -
- -
- -
-
- -
- - - -
- - - -
- ${this._generateWordsList()} -
- - -
- - -
- - - - - - -
- -
- Replace with: - - - -
- - - - `; - } - - private _generateWordsList(): string { - // Get paginated list of words - const startIdx = (this._currentPage - 1) * this._pageSize; - const endIdx = Math.min(startIdx + this._pageSize, this._wordFrequencies.length); - const pagedWords = this._wordFrequencies.slice(startIdx, endIdx); - - if (pagedWords.length === 0) { - return `
No words found
`; - } - - let html = ""; - - for (const wordFreq of pagedWords) { - const isExpanded = this._expandedWords.has(wordFreq.word); - - html += ` -
-
-
- ${wordFreq.word} - ${wordFreq.frequency} -
-
- - - -
-
- -
-
- `; - - if (isExpanded && wordFreq.occurrences) { - for (const occurrence of wordFreq.occurrences) { - const id = this._getOccurrenceId(occurrence); - const isSelected = this._selectedOccurrences.has(id); - - html += ` -
-
${this._escapeHtml(occurrence.leftContext)}
-
${this._escapeHtml(occurrence.word)}
-
${this._escapeHtml(occurrence.rightContext)}
-
${occurrence.fileName} (line ${occurrence.lineNumber + 1})
-
- `; - } - } - - html += ` -
-
-
`; - } - - return html; - } - - private _escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - } -} diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 57f674820..5e17734f7 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -8,8 +8,6 @@ import { EditMapUtils } from "../../utils/editMapUtils"; import { EditType, CodexCellTypes } from "../../../types/enums"; import { QuillCellContent, - SpellCheckResponse, - AlertCodesServerResponse, ValidationEntry, } from "../../../types"; import path from "path"; @@ -23,13 +21,6 @@ import { toPosixPath } from "../../utils/pathUtils"; import { revalidateCellMissingFlags } from "../../utils/audioMissingUtils"; import { mergeAudioFiles } from "../../utils/audioMerger"; import { getAttachmentDocumentSegmentFromUri } from "../../utils/attachmentFolderUtils"; -// Comment out problematic imports -// import { getAddWordToSpellcheckApi } from "../../extension"; -// import { getSimilarCellIds } from "@/utils/semanticSearch"; -// import { getSpellCheckResponseForText } from "../../extension"; -// import { ChapterGenerationManager } from "./chapterGenerationManager"; -// import { generateBackTranslation, editBacktranslation, getBacktranslation, setBacktranslation } from "../../backtranslation"; -// import { rejectEditSuggestion } from "../../actions/suggestions/rejectEditSuggestion"; // Enable debug logging if needed const DEBUG_MODE = false; @@ -537,14 +528,6 @@ const messageHandlers: Record Promise { - const typedEvent = event as Extract; - await vscode.commands.executeCommand("spellcheck.addWord", typedEvent.words); - safePostMessageToPanel(webviewPanel, { - type: "wordAdded", - content: typedEvent.words, - }); - }, getCommentsForCell: async ({ event, webviewPanel }) => { const typedEvent = event as Extract; @@ -663,75 +646,6 @@ const messageHandlers: Record Promise { - const typedEvent = event as Extract; - const config = vscode.workspace.getConfiguration("codex-project-manager"); - const spellcheckEnabled = config.get("spellcheckIsEnabled", false); - if (!spellcheckEnabled) { - return; - } - - const response = await vscode.commands.executeCommand( - "codex-editor-extension.spellCheckText", - typedEvent.content.cellContent - ); - provider.postMessageToWebview(webviewPanel, { - type: "providerSendsSpellCheckResponse", - content: response as SpellCheckResponse, - }); - }, - - getAlertCodes: async ({ event, webviewPanel, provider }) => { - const typedEvent = event as Extract; - - try { - const config = vscode.workspace.getConfiguration("codex-project-manager"); - const spellcheckEnabled = config.get("spellcheckIsEnabled", false); - - if (!spellcheckEnabled) { - debug("[Message Handler] Spellcheck is disabled, skipping alert codes"); - return; - } - - const result: AlertCodesServerResponse = await vscode.commands.executeCommand( - "codex-editor-extension.alertCodes", - typedEvent.content - ); - - const content: { [cellId: string]: number; } = {}; - result.forEach((item) => { - content[item.cellId] = item.code; - }); - - provider.postMessageToWebview(webviewPanel, { - type: "providerSendsgetAlertCodeResponse", - content, - }); - } catch (error) { - console.error("[Message Handler] Failed to get alert codes:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - requestedCells: typedEvent?.content?.length || 0, - cellIds: typedEvent?.content?.map(item => item.cellId) || [], - errorType: error instanceof Error ? error.constructor.name : typeof error - }); - - // Provide fallback response with empty codes for all requested cells - const content: { [cellId: string]: number; } = {}; - if (typedEvent?.content && Array.isArray(typedEvent.content)) { - typedEvent.content.forEach((item) => { - content[item.cellId] = 0; // 0 = no alerts - }); - } - - // Always send a response to prevent webview from waiting indefinitely - provider.postMessageToWebview(webviewPanel, { - type: "providerSendsgetAlertCodeResponse", - content, - }); - } - }, - saveHtml: async ({ event, document, provider, webviewPanel }) => { const typedEvent = event as Extract; const requestId = typedEvent.requestId; @@ -776,13 +690,6 @@ const messageHandlers: Record Promise Promise { - const typedEvent = event as Extract; - debug("supplyRecentEditHistory message received", { event }); - await vscode.commands.executeCommand( - "codex-smart-edits.supplyRecentEditHistory", - typedEvent.content.cellId, - typedEvent.content.editHistory - ); - }, - exportFile: async ({ event, document }) => { const typedEvent = event as Extract; const notebookName = path.parse(document.uri.fsPath).name; @@ -1326,16 +1223,6 @@ const messageHandlers: Record Promise { - const typedEvent = event as Extract; - debug("togglePinPrompt message received", { event }); - await vscode.commands.executeCommand( - "codex-smart-edits.togglePinPrompt", - typedEvent.content.cellId, - typedEvent.content.promptText - ); - }, - generateBacktranslation: async ({ event, webviewPanel, provider, document }) => { const typedEvent = event as Extract; const backtranslation = await vscode.commands.executeCommand( @@ -1412,14 +1299,6 @@ const messageHandlers: Record Promise { - const typedEvent = event as Extract; - await vscode.commands.executeCommand( - "codex-smart-edits.rejectEditSuggestion", - typedEvent.content - ); - }, - webviewFocused: ({ event, provider }) => { const typedEvent = event as Extract; if (provider.currentDocument && typedEvent.content?.uri) { @@ -3174,6 +3053,7 @@ const messageHandlers: Record Promise = new Set(); + + // Pending index operations that failed because the index manager was unavailable. + // These are replayed when the index manager becomes available again, ensuring + // no cell edits are silently dropped during project swap or initialization. + private _pendingIndexOps: Array<{ + cellId: string; + content: string; + editType: EditType; + }> = []; + + /** + * Maximum number of pending index operations to queue. Beyond this limit + * new operations are not queued (they are still tracked via _dirtyCellIds + * and will be picked up on the next full save/sync). + */ + private static readonly MAX_PENDING_INDEX_OPS = 500; + // Cache for milestone index to avoid rebuilding on every call private _cachedMilestoneIndex: MilestoneIndex | null = null; private _cachedMilestoneIndexCellsPerPage: number | null = null; @@ -169,9 +189,7 @@ export class CodexCellDocument implements vscode.CustomDocument { */ public async populateSourceCellMapFromIndex(sourceFilePath?: string): Promise { try { - if (!this._indexManager) { - this._indexManager = getSQLiteIndexManager(); - } + this.refreshIndexManager(); if (this._indexManager) { this._sourceCellMap = await this._indexManager.getSourceCellsMapForFile( @@ -344,6 +362,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // This avoids side effects (e.g., merge logic using edit history) from updating the stored value. // If the UI needs to reflect the preview, it should use a separate webview-only channel. this._isDirty = true; + this._dirtyCellIds.add(cellId); // Notify both VS Code and the webview that edits changed, so the provider can mark dirty and VS Code can autosave this._onDidChangeForVsCodeAndWebview.fire({ edits: [{ cellId, newContent, editType }], @@ -459,6 +478,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Set dirty flag and notify listeners about the change this._isDirty = true; + this._dirtyCellIds.add(cellId); this._onDidChangeForVsCodeAndWebview.fire({ edits: [{ cellId, newContent, editType }], }); @@ -486,7 +506,8 @@ export class CodexCellDocument implements vscode.CustomDocument { .replace(/]*data-footnote[^>]*>[\s\S]*?<\/sup>/gi, '') .replace(/]*>[\s\S]*?<\/sup>/gi, ''); // Remove any remaining sup tags - // Step 2: Remove spell check markup and other unwanted elements + // Step 2: Remove suggestion markup and other unwanted elements + // (The spell-check regex strips legacy elements with "spell-check" CSS classes) cleanContent = cleanContent .replace(/<[^>]*class=["'][^"']*spell-check[^"']*["'][^>]*>[\s\S]*?<\/[^>]+>/gi, '') .replace(//gi, '') @@ -517,10 +538,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Public method to ensure a cell is indexed (useful for waiting after transcription) public async ensureCellIndexed(cellId: string, timeoutMs: number = 5000): Promise { - if (!this._indexManager) { - this._indexManager = getSQLiteIndexManager(); - if (!this._indexManager) return false; - } + if (!this.refreshIndexManager()) return false; const start = Date.now(); while (Date.now() - start < timeoutMs) { @@ -542,35 +560,97 @@ export class CodexCellDocument implements vscode.CustomDocument { return false; } + /** + * Invalidate caches that are tied to a specific database instance. + * Must be called whenever the index manager is replaced (e.g. after a + * project swap) to prevent stale file IDs from the old database leaking + * into the new one. + */ + private invalidateIndexCaches(): void { + this._cachedFileId = null; + } + + /** + * Centralized helper: detect a closed/stale index manager and replace it + * with the current global instance. Returns the manager if available, or null. + * + * This eliminates the repeated "if closed → null → get → invalidate" pattern + * that was duplicated across populateSourceCellMapFromIndex, ensureCellIndexed, + * addCellToIndexImmediately, updateCellMilestoneIndices, and acquireIndexManagerAndFlush. + */ + private refreshIndexManager(): typeof this._indexManager { + if (this._indexManager?.isClosed) { + debug(`[CodexDocument] Detected closed index manager — refreshing`); + this._indexManager = null; + this.invalidateIndexCaches(); + } + if (!this._indexManager) { + this._indexManager = getSQLiteIndexManager(); + if (this._indexManager) { + this.invalidateIndexCaches(); + } + } + return this._indexManager; + } + + /** + * Try to acquire a valid (non-closed) index manager and flush any pending + * operations that were queued while the index manager was unavailable + * (e.g. during project swap or initialization). + * + * Detects stale managers that have been closed (e.g. after a project swap) + * and replaces them with the current global instance. + * + * Returns the index manager if available, or null. + */ + private async acquireIndexManagerAndFlush(): Promise { + this.refreshIndexManager(); + + if (this._indexManager && this._pendingIndexOps.length > 0) { + // Drain the queue — take a snapshot so new ops during flush are queued separately + const ops = [...this._pendingIndexOps]; + this._pendingIndexOps = []; + debug(`[CodexDocument] Replaying ${ops.length} pending index operations`); + for (const op of ops) { + try { + await this.addCellToIndexImmediately(op.cellId, op.content, op.editType); + } catch (err) { + console.warn(`[CodexDocument] Failed to replay pending index op for cell ${op.cellId}:`, err); + // Don't re-queue — the cell will be picked up on the next full save via _dirtyCellIds + } + } + } + return this._indexManager; + } + // TRUE IMMEDIATE INDEXING - No delays, immediate searchability private async addCellToIndexImmediately( cellId: string, content: string, editType: EditType ): Promise { + // Bail early if the database is being torn down (project swap / deactivation) + if (isDBShuttingDown()) { + this._dirtyCellIds.add(cellId); + return; + } + const hadCachedFileId = this._cachedFileId !== null; try { - // Refresh index manager reference if it's not available - if (!this._indexManager) { - this._indexManager = getSQLiteIndexManager(); - if (!this._indexManager) { - console.warn(`[CodexDocument] Index manager not available for immediate indexing of cell ${cellId}`); - return; + if (!this.refreshIndexManager()) { + // Queue the operation so it's replayed when the index manager becomes available, + // rather than silently dropping the update. Cap the queue to avoid unbounded + // memory growth — excess cells are still tracked via _dirtyCellIds. + this._dirtyCellIds.add(cellId); + if (this._pendingIndexOps.length < CodexCellDocument.MAX_PENDING_INDEX_OPS) { + this._pendingIndexOps.push({ cellId, content, editType }); + console.warn(`[CodexDocument] Index manager not available — queued indexing for cell ${cellId} (${this._pendingIndexOps.length} pending)`); + } else if (this._pendingIndexOps.length === CodexCellDocument.MAX_PENDING_INDEX_OPS) { + console.warn(`[CodexDocument] Pending index ops cap reached (${CodexCellDocument.MAX_PENDING_INDEX_OPS}) — further ops tracked via dirty cells only`); } + return; } - // Use cached file ID or get it once - let fileId = this._cachedFileId; - if (!fileId) { - const fileType = this.uri.toString().includes(".source") ? "source" : "codex"; - fileId = await this._indexManager.upsertFile( - this.uri.toString(), - fileType, - Date.now() - ); - this._cachedFileId = fileId; - } - - // Calculate logical line position based on cell structure + // Prepare data outside the transaction (no DB access needed) let logicalLinePosition: number | null = null; const cellIndex = this._documentData.cells.findIndex(cell => cell.metadata?.id === cellId); @@ -591,9 +671,6 @@ export class CodexCellDocument implements vscode.CustomDocument { } } logicalLinePosition = logicalPosition; - - // Since this method is only called when content exists, - // we always assign the logical position as the line number } // Paratext cells get lineNumber = null } @@ -607,20 +684,48 @@ export class CodexCellDocument implements vscode.CustomDocument { ? { ...currentCell.metadata, editType, lastUpdated: Date.now() } : { editType, lastUpdated: Date.now() }; - // IMMEDIATE AI KNOWLEDGE UPDATE with FTS synchronization - const result = await this._indexManager.upsertCellWithFTSSync( - cellId, - fileId, - this.getContentType(), - sanitizedContent, // Sanitized content for search - logicalLinePosition ?? undefined, // Convert null to undefined for method signature compatibility - fullMetadata, // Pass full cell metadata including type (e.g., MILESTONE) - content // Raw content with HTML tags - ); + // Wrap file upsert + cell upsert + FTS sync in a single transaction + // so a crash or concurrent write can't leave partial state. + // Use retry variant to handle SQLITE_BUSY if a background sync holds the lock. + // Non-null guaranteed by refreshIndexManager() guard above. + const indexManager = this._indexManager!; + await indexManager.runInTransactionWithRetry(async () => { + // Use cached file ID or get it once. + // Use upsertFileSync (not upsertFile) to avoid disk I/O while + // holding the transaction lock — upsertFile reads the file from + // disk, which would block all other DB operations. + let fileId = this._cachedFileId; + if (!fileId) { + const fileType = this.uri.toString().includes(".source") ? "source" : "codex"; + fileId = await indexManager.upsertFileSync( + this.uri.toString(), + fileType, + Date.now() + ); + this._cachedFileId = fileId; + } + + // IMMEDIATE AI KNOWLEDGE UPDATE with FTS synchronization + await indexManager.upsertCellWithFTSSync( + cellId, + fileId, + this.getContentType(), + sanitizedContent, // Sanitized content for search + logicalLinePosition ?? undefined, // Convert null to undefined for method signature compatibility + fullMetadata, // Pass full cell metadata including type (e.g., MILESTONE) + content // Raw content with HTML tags + ); + }); debug(`[CodexDocument] ✅ Cell ${cellId} immediately indexed and searchable at logical line ${logicalLinePosition}`); } catch (error) { + // If we set _cachedFileId inside the transaction and it rolled back, + // the ID is from an uncommitted INSERT — invalidate it so the next + // attempt gets a fresh one. + if (!hadCachedFileId) { + this.invalidateIndexCaches(); + } console.error(`[CodexDocument] Error indexing cell ${cellId}:`, error); throw error; } @@ -712,8 +817,8 @@ export class CodexCellDocument implements vscode.CustomDocument { // Record save timestamp to prevent file watcher from reverting our own save this._lastSaveTimestamp = Date.now(); - // IMMEDIATE AI LEARNING - Update all cells with content to ensure validation changes are persisted - await this.syncAllCellsToDatabase(); + // Sync only modified cells to the database (not all 1000+ cells) + await this.syncDirtyCellsToDatabase(); this._edits = []; // Clear edits after saving this._isDirty = false; // Reset dirty flag @@ -728,11 +833,19 @@ export class CodexCellDocument implements vscode.CustomDocument { const text = formatJsonForNotebookFile(this.getDocumentDataForSerialization()); await atomicWriteUriText(targetResource, text); - // IMMEDIATE AI LEARNING for non-backup saves + // Sync only modified cells for non-backup saves if (!backup) { + // If saving to a different path, invalidate the cached file ID so the + // index picks up the correct file URI on the next sync. The provider + // framework may or may not update this.uri after saveAs — by clearing + // the cache we ensure whichever URI is current gets used. + if (targetResource.toString() !== this.uri.toString()) { + this.invalidateIndexCaches(); + } + // Record save timestamp to prevent file watcher from reverting our own save this._lastSaveTimestamp = Date.now(); - await this.syncAllCellsToDatabase(); + await this.syncDirtyCellsToDatabase(); this._isDirty = false; // Reset dirty flag } } @@ -745,6 +858,7 @@ export class CodexCellDocument implements vscode.CustomDocument { this.invalidateMilestoneIndexCache(); this._edits = []; this._isDirty = false; // Reset dirty flag + this._dirtyCellIds.clear(); // Discard stale dirty IDs — document is back to saved state this._onDidChangeForWebview.fire({ content: this.getText(), edits: [], @@ -899,6 +1013,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Set dirty flag and notify listeners about the change this._isDirty = true; + this._dirtyCellIds.add(cellId); this._onDidChangeForVsCodeAndWebview.fire({ edits: [{ cellId, timestamps }], }); @@ -973,6 +1088,7 @@ export class CodexCellDocument implements vscode.CustomDocument { }); this._isDirty = true; + this._dirtyCellIds.add(cellId); this._onDidChangeForVsCodeAndWebview.fire({ edits: [{ cellId, deleted: true }], }); @@ -1038,6 +1154,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Set dirty flag and notify listeners about the change this._isDirty = true; + this._dirtyCellIds.add(newCellId); this._onDidChangeForVsCodeAndWebview.fire({ edits: [{ newCellId, referenceCellId, cellType, data }], }); @@ -1337,12 +1454,9 @@ export class CodexCellDocument implements vscode.CustomDocument { * This should be called after buildMilestoneIndex() to persist the milestone indices. */ public async updateCellMilestoneIndices(): Promise { - if (!this._indexManager) { - this._indexManager = getSQLiteIndexManager(); - if (!this._indexManager) { - console.warn(`[CodexDocument] Index manager not available for milestone index update`); - return; - } + if (!this.refreshIndexManager()) { + console.warn(`[CodexDocument] Index manager not available for milestone index update`); + return; } const cells = this._documentData.cells || []; @@ -1360,52 +1474,50 @@ export class CodexCellDocument implements vscode.CustomDocument { } const contentType = this.getContentType(); + // Non-null guaranteed by refreshIndexManager() guard above. + const indexManager = this._indexManager!; + const hadCachedFileId = this._cachedFileId !== null; - // Get file ID - let fileId = this._cachedFileId; - if (!fileId) { - fileId = await this._indexManager.upsertFile( - this.uri.toString(), - contentType === "source" ? "source" : "codex", - Date.now() - ); - this._cachedFileId = fileId; - } - - // Use targeted UPDATE statement instead of full upserts - const db = this._indexManager.database; - if (!db) { - console.warn(`[CodexDocument] Database not available for milestone index update`); - return; - } - - await this._indexManager.runInTransaction(() => { - // Prepare UPDATE statement once, reuse for all cells - const updateStatement = db.prepare(` - UPDATE cells - SET milestone_index = ? - WHERE cell_id = ? - `); + try { + // Use retry variant to handle SQLITE_BUSY if a background sync holds the lock. + await indexManager.runInTransactionWithRetry(async () => { + // Get file ID inside the transaction for atomicity. + // Use upsertFileSync (not upsertFile) to avoid disk I/O while + // holding the transaction lock — consistent with addCellToIndexImmediately + // and syncDirtyCellsToDatabase. + let fileId = this._cachedFileId; + if (!fileId) { + fileId = await indexManager.upsertFileSync( + this.uri.toString(), + contentType === "source" ? "source" : "codex", + Date.now() + ); + this._cachedFileId = fileId; + } - try { + // Update milestone_index for all cells using the index manager's + // updateCellMilestoneIndex method instead of raw db.run, so we go + // through ensureOpen() and stay consistent with the manager's API. for (const cell of cells) { const cellId = cell.metadata?.id; if (!cellId) continue; const milestoneIndex = cell.metadata?.data?.milestoneIndex; - - // Execute UPDATE statement - updateStatement.bind([ - milestoneIndex !== undefined ? milestoneIndex : null, - cellId - ]); - updateStatement.step(); - updateStatement.reset(); + await indexManager.updateCellMilestoneIndex( + cellId, + milestoneIndex !== undefined ? milestoneIndex : null + ); } - } finally { - updateStatement.free(); + }); + } catch (error) { + // If _cachedFileId was set inside the rolled-back transaction, + // invalidate it so the next attempt gets a fresh one. + if (!hadCachedFileId) { + this.invalidateIndexCaches(); } - }); + console.error(`[CodexDocument] Error updating milestone indices:`, error); + return; // Non-fatal — milestones will be retried on next call + } // Track that we've updated for this cell count this._lastUpdatedMilestoneIndexCellCount = currentCellCount; @@ -2094,6 +2206,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Set dirty flag and notify listeners about the change this._isDirty = true; + this._dirtyCellIds.add(cellId); this._onDidChangeForVsCodeAndWebview.fire({ edits: [{ cellId, newLabel }], }); @@ -2173,6 +2286,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Set dirty flag and notify listeners about the change this._isDirty = true; + this._dirtyCellIds.add(cellId); this._onDidChangeForVsCodeAndWebview.fire({ edits: [{ cellId, isLocked }], }); @@ -2315,6 +2429,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Mark document as dirty this._isDirty = true; + this._dirtyCellIds.add(cellId); // Notify listeners that the document has changed this._onDidChangeForVsCodeAndWebview.fire({ @@ -2424,6 +2539,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Mark document as dirty this._isDirty = true; + this._dirtyCellIds.add(cellId); // Notify listeners that the document has changed this._onDidChangeForVsCodeAndWebview.fire({ @@ -2518,6 +2634,9 @@ export class CodexCellDocument implements vscode.CustomDocument { ); edit.validatedBy = finalValidatedBy; changesDetected = true; + if (cell.metadata?.id) { + this._dirtyCellIds.add(cell.metadata.id); + } } } } @@ -2805,6 +2924,7 @@ export class CodexCellDocument implements vscode.CustomDocument { } this._isDirty = true; + this._dirtyCellIds.add(cellId); // Emit change events this._onDidChangeForVsCodeAndWebview.fire({ @@ -2876,6 +2996,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Mark as dirty and notify listeners this._isDirty = true; + this._dirtyCellIds.add(cellId); this._onDidChangeForVsCodeAndWebview.fire({ edits: this._edits, }); @@ -2923,6 +3044,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Mark as dirty and notify both VS Code and webview so the change is persisted this._isDirty = true; + this._dirtyCellIds.add(cellId); this._onDidChangeForVsCodeAndWebview.fire({ edits: this._edits, }); @@ -3049,6 +3171,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Mark as dirty and notify VS Code (so the file is persisted) and the webview this._isDirty = true; + this._dirtyCellIds.add(cellId); this._onDidChangeForVsCodeAndWebview.fire({ edits: this._edits, }); @@ -3099,6 +3222,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Mark as dirty and notify VS Code (so the file is persisted) and the webview this._isDirty = true; + this._dirtyCellIds.add(cellId); this._onDidChangeForVsCodeAndWebview.fire({ edits: this._edits, }); @@ -3136,6 +3260,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Mark as dirty and notify listeners this._isDirty = true; + this._dirtyCellIds.add(cellId); this._onDidChangeForVsCodeAndWebview.fire({ edits: this._edits, }); @@ -3177,6 +3302,9 @@ export class CodexCellDocument implements vscode.CustomDocument { delete cell.metadata.selectedAudioId; delete cell.metadata.selectionTimestamp; hasChanges = true; + if (cell.metadata?.id) { + this._dirtyCellIds.add(cell.metadata.id); + } } } @@ -3230,6 +3358,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Mark as dirty and notify listeners this._isDirty = true; + this._dirtyCellIds.add(cellId); this._onDidChangeForVsCodeAndWebview.fire({ edits: this._edits, }); @@ -3244,143 +3373,193 @@ export class CodexCellDocument implements vscode.CustomDocument { } - // Add method to sync all cells to database without modifying content - private async syncAllCellsToDatabase(): Promise { + // Sync only dirty (modified) cells to the database on save. + // Previously this synced ALL cells on every save, causing ~390MB of disk writes + // for a single character edit (1,596 cells × full FTS5 upsert each). + // Now it only syncs cells that were actually modified since the last save. + private async syncDirtyCellsToDatabase(): Promise { + // Bail early if the database is being torn down (project swap / deactivation). + // Dirty cells stay in the set and will be picked up after re-initialization. + if (isDBShuttingDown()) { + debug(`[CodexDocument] DB shutting down — skipping dirty cell sync`); + return; + } + + // Snapshot and clear the dirty set immediately so edits during sync + // are captured in the next save cycle. Declared outside try so the + // catch can re-add IDs on failure. + const dirtyIds = new Set(this._dirtyCellIds); + this._dirtyCellIds.clear(); + const hadCachedFileId = this._cachedFileId !== null; try { - if (!this._indexManager) { - this._indexManager = getSQLiteIndexManager(); - if (!this._indexManager) { - console.warn(`[CodexDocument] Index manager not available for AI learning`); - return; - } + + if (dirtyIds.size === 0) { + debug(`[CodexDocument] No dirty cells to sync — skipping database update`); + return; } - // Get file ID - let fileId = this._cachedFileId; - if (!fileId) { - fileId = await this._indexManager.upsertFile( - this.uri.toString(), - "codex", - Date.now() - ); - this._cachedFileId = fileId; + // Try to acquire index manager and flush any pending ops from earlier failures + const indexManager = await this.acquireIndexManagerAndFlush(); + if (!indexManager) { + // Re-add dirty IDs so they aren't lost — they'll be retried on the next save + for (const id of dirtyIds) { + this._dirtyCellIds.add(id); + } + console.warn(`[CodexDocument] Index manager not available for AI learning — ${dirtyIds.size} dirty cells re-queued`); + return; } let syncedCells = 0; let syncedValidations = 0; - // Process each cell, including those with audio-only validations - for (const cell of this._documentData.cells!) { - const cellId = cell.metadata?.id; - - if (!cellId || !this._documentData.cells) { - console.warn(`[CodexDocument] Skipping cell without valid ID or cells array`); - continue; + // Wrap all DB writes (file upsert + cell upserts + FTS syncs) + // in a single transaction so a crash or concurrent write can't + // leave partial state in the database. + // Use retry variant to handle SQLITE_BUSY if a background sync holds the lock. + await indexManager.runInTransactionWithRetry(async () => { + // Get file ID (inside the transaction so it's atomic with cell writes). + // Use upsertFileSync (not upsertFile) to avoid disk I/O while + // holding the transaction lock. + let fileId = this._cachedFileId; + if (!fileId) { + fileId = await indexManager.upsertFileSync( + this.uri.toString(), + "codex", + Date.now() + ); + this._cachedFileId = fileId; } - const hasContent = !!(cell.value && cell.value.trim() !== ''); - - const activeAudioValidators = (cell.metadata?.attachments && - Object.values(cell.metadata.attachments).flatMap((attachment: any) => { - if ( - !attachment || - attachment.type !== "audio" || - attachment.isDeleted || - !Array.isArray(attachment.validatedBy) - ) { - return []; - } - return attachment.validatedBy.filter((entry: any) => - entry && - !entry.isDeleted && - typeof entry.username === "string" && - entry.username.trim().length > 0 - ); - })) || []; + // Only process cells that were modified since the last save + for (const cell of this._documentData.cells!) { + const cellId = cell.metadata?.id; - const hasAudioValidation = activeAudioValidators.length > 0; + if (!cellId || !dirtyIds.has(cellId)) { + continue; + } - if (!hasContent && !hasAudioValidation) { - continue; - } + const hasContent = !!(cell.value && cell.value.trim() !== ''); + + const activeAudioValidators = (cell.metadata?.attachments && + Object.values(cell.metadata.attachments).flatMap((attachment: any) => { + if ( + !attachment || + attachment.type !== "audio" || + attachment.isDeleted || + !Array.isArray(attachment.validatedBy) + ) { + return []; + } + return attachment.validatedBy.filter((entry: any) => + entry && + !entry.isDeleted && + typeof entry.username === "string" && + entry.username.trim().length > 0 + ); + })) || []; + + const hasAudioValidation = activeAudioValidators.length > 0; + + if (!hasContent && !hasAudioValidation) { + continue; + } - try { - // Calculate logical line position only when we have textual content - let logicalLinePosition: number | null = null; - if (hasContent) { - const cellIndex = this._documentData.cells!.findIndex((c) => c.metadata?.id === cellId); - - if (cellIndex >= 0) { - const isCurrentCellParatext = cell.metadata?.type === "paratext"; - - if (!isCurrentCellParatext) { - // Count non-paratext cells before this cell - let logicalPosition = 1; - for (let i = 0; i < cellIndex; i++) { - const checkCell = this._documentData.cells![i]; - const isParatext = checkCell.metadata?.type === "paratext"; - if (!isParatext) { - logicalPosition++; + try { + // Calculate logical line position only when we have textual content + let logicalLinePosition: number | null = null; + if (hasContent) { + const cellIndex = this._documentData.cells!.findIndex((c) => c.metadata?.id === cellId); + + if (cellIndex >= 0) { + const isCurrentCellParatext = cell.metadata?.type === "paratext"; + + if (!isCurrentCellParatext) { + // Count non-paratext cells before this cell + let logicalPosition = 1; + for (let i = 0; i < cellIndex; i++) { + const checkCell = this._documentData.cells![i]; + const isParatext = checkCell.metadata?.type === "paratext"; + if (!isParatext) { + logicalPosition++; + } } + logicalLinePosition = logicalPosition; } - logicalLinePosition = logicalPosition; } } - } - // Prepare metadata for database - this will handle validation extraction - const cellMetadata = { - edits: cell.metadata?.edits || [], - attachments: cell.metadata?.attachments || {}, - selectedAudioId: cell.metadata?.selectedAudioId, - selectionTimestamp: cell.metadata?.selectionTimestamp, - type: cell.metadata?.type || null, - lastUpdated: Date.now(), - }; - - // Check if this cell has text validation data for logging - const edits = cell.metadata?.edits; - const lastEdit = edits && edits.length > 0 ? edits[edits.length - 1] : null; - const hasTextValidation = lastEdit?.validatedBy && lastEdit.validatedBy.length > 0; - - if (hasTextValidation && lastEdit?.validatedBy) { - syncedValidations++; - } + // Prepare metadata for database - this will handle validation extraction + const cellMetadata = { + edits: cell.metadata?.edits || [], + attachments: cell.metadata?.attachments || {}, + selectedAudioId: cell.metadata?.selectedAudioId, + selectionTimestamp: cell.metadata?.selectionTimestamp, + type: cell.metadata?.type || null, + lastUpdated: Date.now(), + }; - if (hasAudioValidation) { - syncedValidations++; - } + // Check if this cell has text validation data for logging + const edits = cell.metadata?.edits; + const lastEdit = edits && edits.length > 0 ? edits[edits.length - 1] : null; + const hasTextValidation = lastEdit?.validatedBy && lastEdit.validatedBy.length > 0; - // Sanitize content for search - const sanitizedContent = hasContent ? this.sanitizeContent(cell.value) : ""; + if (hasTextValidation && lastEdit?.validatedBy) { + syncedValidations++; + } - const rawContentForSync = hasContent - ? cell.value ?? "" - : JSON.stringify({ - audioOnlyValidation: true, - attachments: cell.metadata?.attachments ?? {}, - }); + if (hasAudioValidation) { + syncedValidations++; + } - await this._indexManager.upsertCellWithFTSSync( - cellId, - fileId, - this.getContentType(), - sanitizedContent, - hasContent ? logicalLinePosition ?? undefined : undefined, - cellMetadata, - rawContentForSync - ); + // Sanitize content for search + const sanitizedContent = hasContent ? this.sanitizeContent(cell.value) : ""; + + const rawContentForSync = hasContent + ? cell.value ?? "" + : JSON.stringify({ + audioOnlyValidation: true, + attachments: cell.metadata?.attachments ?? {}, + }); + + await indexManager.upsertCellWithFTSSync( + cellId, + fileId, + this.getContentType(), + sanitizedContent, + hasContent ? logicalLinePosition ?? undefined : undefined, + cellMetadata, + rawContentForSync + ); - syncedCells++; - } catch (error) { - console.error(`[CodexDocument] Error during AI learning for cell ${cellId}:`, error); + syncedCells++; + } catch (error) { + console.error(`[CodexDocument] Error during AI learning for cell ${cellId}:`, error); + // Re-add the failed cell so it is retried on the next save + this._dirtyCellIds.add(cellId); + } } - } + }); - debug(`[CodexDocument] ✅ AI knowledge updated: AI learned from ${syncedCells} cells, ${syncedValidations} cells with validation data`); + debug(`[CodexDocument] AI knowledge updated: synced ${syncedCells} dirty cells (of ${dirtyIds.size} marked), ${syncedValidations} with validation data`); } catch (error) { - console.error(`[CodexDocument] Error during AI learning:`, error); + // Transaction failed (SQLITE_BUSY, closed DB, etc.) — re-add dirty IDs + // so they aren't lost. They'll be retried on the next save cycle. + for (const id of dirtyIds) { + this._dirtyCellIds.add(id); + } + // If _cachedFileId was set inside the rolled-back transaction, the ID + // is from an uncommitted INSERT — always invalidate on any failure. + if (!hadCachedFileId) { + this.invalidateIndexCaches(); + } + // If the error was due to a closed DB, also clear the stale manager so + // the next attempt gets a fresh one. + const msg = error instanceof Error ? error.message : String(error); + if (msg.includes("closing or closed") || msg.includes("not initialized")) { + this._indexManager = null; + } + console.error(`[CodexDocument] Error during AI learning (${dirtyIds.size} dirty cells re-queued):`, error); } } } diff --git a/src/providers/codexCellEditorProvider/declarations.d.ts b/src/providers/codexCellEditorProvider/declarations.d.ts index e8e7c7a90..c7edc69c8 100644 --- a/src/providers/codexCellEditorProvider/declarations.d.ts +++ b/src/providers/codexCellEditorProvider/declarations.d.ts @@ -1,8 +1,6 @@ // Declaration file to define module types for TypeScript declare module '@/extension' { - export function getAddWordToSpellcheckApi(): any; - export function getSpellCheckResponseForText(): any; // In runtime, getAuthApi() returns a FrontierAPI | undefined synchronously. // Keep it as `any` here to avoid circular type deps. export function getAuthApi(): any; @@ -18,13 +16,3 @@ declare module './chapterGenerationManager' { } } -declare module '../../backtranslation' { - export function generateBackTranslation(): any; - export function editBacktranslation(): any; - export function getBacktranslation(): any; - export function setBacktranslation(): any; -} - -declare module '../../actions/suggestions/rejectEditSuggestion' { - export function rejectEditSuggestion(): any; -} diff --git a/src/providers/dictionaryTable/DictionaryEditorProvider.ts b/src/providers/dictionaryTable/DictionaryEditorProvider.ts deleted file mode 100644 index 980dd8f5d..000000000 --- a/src/providers/dictionaryTable/DictionaryEditorProvider.ts +++ /dev/null @@ -1,407 +0,0 @@ -import * as vscode from "vscode"; -import { getWebviewHtml } from "../../utils/webviewTemplate"; -import { safePostMessageToPanel } from "../../utils/webviewUtils"; -import { - DictionaryPostMessages, - DictionaryReceiveMessages, - Dictionary, - DictionaryEntry, -} from "../../../types"; -import { getWorkSpaceUri } from "../../utils"; -import { isEqual } from "lodash"; -import { Database } from "fts5-sql-bundle"; -import { - getWords, - getDefinitions, - getPagedWords, - addWord, - deleteWord, - updateWord, -} from "../../sqldb"; - -type FetchPageResult = { - entries: DictionaryEntry[]; - total: number; - page: number; - pageSize: number; -}; -interface DictionaryDocument extends vscode.CustomDocument { - content: Dictionary; -} - -type PartialDictionaryEntry = Partial; - -export class DictionaryEditorProvider implements vscode.CustomTextEditorProvider { - private page: number = 1; - private pageSize: number = 100; - private searchQuery: string | undefined = undefined; - - public static readonly viewType = "codex.dictionaryEditor"; - private document: FetchPageResult | undefined; - private readonly onDidChangeCustomDocument = new vscode.EventEmitter< - vscode.CustomDocumentEditEvent - >(); - private fileWatcher: vscode.FileSystemWatcher | undefined; - private lastSentData: Dictionary | null = null; - - constructor(private readonly context: vscode.ExtensionContext) { } - - public static register(context: vscode.ExtensionContext): vscode.Disposable { - const provider = new DictionaryEditorProvider(context); - const providerRegistration = vscode.window.registerCustomEditorProvider( - DictionaryEditorProvider.viewType, - provider - ); - return providerRegistration; - } - - public async resolveCustomTextEditor( - document: vscode.TextDocument, - webviewPanel: vscode.WebviewPanel, - _token: vscode.CancellationToken - ): Promise { - this.document = await this.handleFetchPage(this.page, this.pageSize, this.searchQuery); - webviewPanel.webview.options = { - enableScripts: true, - }; - webviewPanel.webview.html = this.getHtmlForWebview(webviewPanel.webview); - - const updateWebview = () => { - const dictionaryContent = this.document; - console.log("sending dictionaryContent to webview", dictionaryContent); - safePostMessageToPanel(webviewPanel, { - command: "providerTellsWebviewToUpdateData", - data: dictionaryContent, - } as DictionaryReceiveMessages); - }; - - // Watch for changes in the project.dictionary file - const workspaceFolderUri = getWorkSpaceUri(); - if (workspaceFolderUri) { - const dictionaryUri = vscode.Uri.joinPath( - workspaceFolderUri, - "files", - "project.dictionary" - ); - this.fileWatcher = vscode.workspace.createFileSystemWatcher(dictionaryUri.fsPath); - - this.fileWatcher.onDidChange(() => { - this.refreshEditor(webviewPanel); - updateWebview(); - }); - } - - const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument((e) => { - if (e.document.uri.toString() === document.uri.toString()) { - updateWebview(); - } - }); - - webviewPanel.onDidDispose(() => { - if (this.fileWatcher) { - this.fileWatcher.dispose(); - } - changeDocumentSubscription.dispose(); - }); - - webviewPanel.webview.onDidReceiveMessage(async (e: DictionaryPostMessages) => { - switch (e.command) { - case "webviewTellsProviderToUpdateData": { - if (e.operation === "fetchPage" && e.pagination) { - this.page = e.pagination.page; - this.pageSize = e.pagination.pageSize; - this.searchQuery = e.pagination.searchQuery; - const pageData = await this.handleFetchPage( - e.pagination.page, - e.pagination.pageSize, - e.pagination.searchQuery - ); - - safePostMessageToPanel(webviewPanel, { - command: "providerTellsWebviewToUpdateData", - data: { - dictionaryData: { - id: "", - label: "", - metadata: {}, - }, - entries: pageData.entries, - total: pageData.total, - page: pageData.page, - pageSize: pageData.pageSize, - }, - } as DictionaryReceiveMessages); - break; - } - const db = (global as any).db as Database; - if (!db) { - throw new Error("SQLite database not initialized"); - } - - switch (e.operation) { - case "update": - // TODO: this is not updating the database because the table is not sending updates here - await updateWord({ - db, - id: e.entry.id, - headWord: e.entry.headWord, - definition: e.entry.definition, - authorId: "", // TODO: get authorId - }); - vscode.window.showInformationMessage(`✏️ "${e.entry.headWord}"`); - break; - - case "delete": - try { - await deleteWord({ db, id: e.entry.id }); - vscode.window.showInformationMessage(`🗑️ ✅`); - } catch (error) { - vscode.window.showErrorMessage(`Error deleting word: ${error}`); - } - break; - - case "add": - if (e.entry.headWord) { - // Only add if headWord is not empty - await addWord({ - db, - headWord: e.entry.headWord, - definition: e.entry.definition, - authorId: "", // TODO: get authorId - }); - vscode.window.showInformationMessage(`"${e.entry.headWord}" → 📖`); - } - break; - } - - // Notify webview of successful update if needed - // FIXME: this is not updating the webview because it is stale - const pageData = await this.handleFetchPage( - this.page, - this.pageSize, - this.searchQuery - ); - this.document = pageData; - safePostMessageToPanel(webviewPanel, { - command: "providerTellsWebviewToUpdateData", - data: pageData, - } as DictionaryReceiveMessages); - break; - } - case "webviewAsksProviderToConfirmRemove": { - console.log("confirmRemove received in DictionaryEditorProvider", e.count); - const confirmed = await vscode.window.showInformationMessage( - `Are you sure you want to remove ${e.count} item${e.count > 1 ? "s" : ""}?`, - { modal: true }, - "Yes", - "No" - ); - if (confirmed === "Yes") { - await this.updateTextDocument(document, e.data, webviewPanel); - safePostMessageToPanel(webviewPanel, { - command: "providerTellsWebviewRemoveConfirmed", - } as DictionaryReceiveMessages); - } - break; - } - case "callCommand": { - await vscode.commands.executeCommand(e.vscodeCommandName, ...e.args); - break; - } - } - }); - - updateWebview(); - } - - private getHtmlForWebview(webview: vscode.Webview): string { - // Note: CSS URIs are now handled by the webview template - return getWebviewHtml(webview, this.context, { - title: "Dictionary Editor", - scriptPath: ["EditableReactTable", "index.js"], - csp: `default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-\${nonce}'; connect-src ${webview.cspSource}; img-src ${webview.cspSource} https:; font-src ${webview.cspSource};` - }); - } - - private async updateTextDocument( - document: vscode.TextDocument, - dictionary: Dictionary, - webviewPanel: vscode.WebviewPanel - ) { - const db = (global as any).db as Database; - if (!db) { - throw new Error("SQLite database not initialized"); - } - - // Update SQLite database instead of file - dictionary.entries.forEach((entry) => { - db.run( - ` - INSERT OR REPLACE INTO entries (word, definition) - VALUES (?, ?) - `, - [entry.headWord, entry.definition || ""] - ); - }); - - // Notify webview of updates - safePostMessageToPanel(webviewPanel, { - command: "providerTellsWebviewToUpdateData", - data: this.document, - } as DictionaryReceiveMessages); - } - - // private async repairDictionaryIfNeeded(dictionaryUri: vscode.Uri) { - // try { - // const dictionary = await readDictionaryClient(dictionaryUri); - // const newContent = serializeDictionaryEntries( - // dictionary.entries.map(ensureCompleteEntry) - // ); - // await saveDictionaryClient(dictionaryUri, { - // ...dictionary, - // entries: deserializeDictionaryEntries(newContent), - // }); - // console.log("Dictionary repaired and saved."); - // } catch (error) { - // console.error("Error repairing dictionary:", error); - // } - // } - - private isValidDictionaryEntry(entry: any): entry is DictionaryEntry { - return typeof entry === "object" && entry !== null && "headWord" in entry; - } - - private ensureCompleteEntry(entry: Partial): DictionaryEntry { - return { - id: entry.id || "", - headWord: entry.headWord || "", - definition: entry.definition || "", - isUserEntry: entry.isUserEntry || false, - authorId: entry.authorId || "", - }; - } - - private async refreshEditor(webviewPanel: vscode.WebviewPanel) { - if (this.document) { - try { - const updatedContent = this.document; - console.log("updatedContent", updatedContent); - - // Compare with current entries - if (!this.areEntriesEqual(this.document.entries, updatedContent.entries)) { - // Update only if there are changes - this.document = updatedContent; - - // Notify the webview of the updated content - safePostMessageToPanel(webviewPanel, { - command: "providerTellsWebviewToUpdateData", - data: updatedContent, - } as DictionaryReceiveMessages); - - // this.onDidChangeCustomDocument.fire({ - // document: this.document, - // undo: () => { - // // Implement undo logic if needed - // }, - // redo: () => { - // // Implement redo logic if needed - // }, - // }); - } - } catch (error) { - vscode.window.showErrorMessage(`Failed to refresh dictionary: ${error}`); - } - } - } - - // private parseEntriesFromJsonl(content: string): DictionaryEntry[] { - // return content - // .split("\n") - // .filter((line) => line.trim() !== "") - // .map((line) => ensureCompleteEntry(JSON.parse(line) as PartialDictionaryEntry)); - // } - - private areEntriesEqual(entries1: DictionaryEntry[], entries2: DictionaryEntry[]): boolean { - if (entries1.length !== entries2.length) return false; - return entries1.every( - (entry, index) => JSON.stringify(entry) === JSON.stringify(entries2[index]) - ); - } - - public saveCustomDocument( - document: Dictionary, - cancellation: vscode.CancellationToken - ): Thenable { - // Serialize the document content - const content = this.serializeDictionary(document); - - // Use vscode.workspace.fs to write the file - const encoder = new TextEncoder(); - const array = encoder.encode(content); - const workspaceFolderUri = getWorkSpaceUri(); - if (!workspaceFolderUri) { - console.error("Workspace folder not found. Aborting save of dictionary."); - return Promise.reject(new Error("Workspace folder not found")); - } - const metadataUri = vscode.Uri.joinPath(workspaceFolderUri, "metadata.json"); - const dictionaryUri = vscode.Uri.joinPath( - workspaceFolderUri, - "files", - "project.dictionary" - ); - - return vscode.workspace.fs.stat(metadataUri).then( - () => { - // metadata.json exists, proceed with writing the dictionary - return vscode.workspace.fs.writeFile(dictionaryUri, array); - }, - () => { - // metadata.json doesn't exist, abort the save - console.error("metadata.json not found. Aborting save of dictionary."); - return Promise.reject(new Error("metadata.json not found")); - } - ); - } - - private serializeDictionary(document: Dictionary): string { - // Convert the dictionary entries to JSON Lines - return document.entries.map((entry) => JSON.stringify(entry)).join("\n"); - } - - private hasDataChanged(newData: Dictionary): boolean { - if (!this.lastSentData) { - return true; - } - return !isEqual(this.lastSentData, newData); - } - - private async handleFetchPage( - page: number, - pageSize: number, - searchQuery?: string - ): Promise { - const db = (global as any).db as Database; - if (!db) { - throw new Error("SQLite database not initialized"); - } - - const { entries, total } = getPagedWords({ db, page, pageSize, searchQuery }); - // const entries: DictionaryEntry[] = words.map((word) => { - // const definitions = getDefinitions(db, word); - // return { - // id: word, - // headWord: word, - // definition: definitions.join("\n"), - // isUserEntry: false, - // authorId: "", - // }; - // }); - - return { - entries, - total, - page, - pageSize, - }; - } -} diff --git a/src/providers/dictionaryTable/dictionaryTableProvider.ts b/src/providers/dictionaryTable/dictionaryTableProvider.ts deleted file mode 100644 index 57e10cee6..000000000 --- a/src/providers/dictionaryTable/dictionaryTableProvider.ts +++ /dev/null @@ -1,55 +0,0 @@ -import * as vscode from "vscode"; -import { DictionaryEditorProvider } from "./DictionaryEditorProvider"; -import { getWorkSpaceUri } from "../../utils"; - -export function registerDictionaryTableProvider(context: vscode.ExtensionContext) { - // Register the DictionaryEditorProvider - const providerRegistration = vscode.window.registerCustomEditorProvider( - DictionaryEditorProvider.viewType, - new DictionaryEditorProvider(context), - { - webviewOptions: { enableFindWidget: true, retainContextWhenHidden: true }, - supportsMultipleEditorsPerDocument: false, - } - ); - - // Add the provider registration to the extension context - context.subscriptions.push(providerRegistration); - - // Register a command to open the dictionary editor - const openDictionaryEditorCommand = vscode.commands.registerCommand( - "dictionaryTable.showDictionaryTable", - async () => { - const workspaceUri = getWorkSpaceUri(); - if (!workspaceUri) { - vscode.window.showErrorMessage( - "No workspace found. Please open a workspace to access the dictionary." - ); - return; - } - const dictionaryUri = vscode.Uri.joinPath(workspaceUri, "files", "project.dictionary"); - - try { - await vscode.commands.executeCommand( - "vscode.openWith", - dictionaryUri, - DictionaryEditorProvider.viewType - ); - } catch (error) { - vscode.window.showErrorMessage(`Failed to open dictionary: ${error}`); - } - } - ); - - // Add the command to the extension context - context.subscriptions.push(openDictionaryEditorCommand); - - // Register the 'dictionaryTable.dictionaryUpdated' command so the LSP callback won't fail - const dictionaryUpdatedCommand = vscode.commands.registerCommand( - "dictionaryTable.dictionaryUpdated", - () => { - // No-op; the file system watcher on project.dictionary will handle refreshes - } - ); - context.subscriptions.push(dictionaryUpdatedCommand); -} diff --git a/src/providers/dictionaryTable/utilities/FileHandler.ts b/src/providers/dictionaryTable/utilities/FileHandler.ts deleted file mode 100644 index 21da9b3eb..000000000 --- a/src/providers/dictionaryTable/utilities/FileHandler.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as vscode from "vscode"; - -export class FileHandler { - // FIXME: I don't think this generic file handler should be here. We should probably refactor and look at other utils to come up with a standard way to do this, or else just rely on the vscode api to read files and handle errors. - static async readFile( - filePath: string - ): Promise<{ data: string | undefined; uri: vscode.Uri | undefined }> { - try { - if (!vscode.workspace.workspaceFolders) { - throw new Error("No workspace folder found"); - } - const workspaceFolder = vscode.workspace.workspaceFolders?.[0].uri; - const metadataPath = vscode.Uri.joinPath(workspaceFolder, "metadata.json"); - - if (await vscode.workspace.fs.stat(metadataPath)) { - const fileUri = vscode.Uri.joinPath(workspaceFolder, filePath); - let fileData; - try { - fileData = await vscode.workspace.fs.readFile(fileUri); - } catch (error) { - if ((error as Error).message.includes("ENOENT")) { - console.log("File does not exist, creating an empty file"); - await vscode.workspace.fs.writeFile(fileUri, new TextEncoder().encode("")); - fileData = new Uint8Array(); - } else { - console.error("Error message didn't include ENOENT"); - throw error; - } - } - const data = new TextDecoder().decode(fileData); - return { data, uri: fileUri }; - } else { - throw new Error("metadata.json file not found in the workspace root"); - } - } catch (error) { - // vscode.window.showErrorMessage(`Error reading file: ${filePath}`); - console.error("Error reading file in FileHandler:", { error }); - return { data: undefined, uri: undefined }; - } - } - - static async writeFile(filePath: string, data: string): Promise { - try { - if (!vscode.workspace.workspaceFolders) { - throw new Error("No workspace folder found"); - } - const workspaceFolder = vscode.workspace.workspaceFolders?.[0].uri; - const fileUri = vscode.Uri.joinPath(workspaceFolder, filePath); - const fileData = new TextEncoder().encode(data); - await vscode.workspace.fs.writeFile(fileUri, fileData); - } catch (error) { - console.error({ error }); - vscode.window.showErrorMessage(`Error writing to file: ${filePath}`); - } - } -} diff --git a/src/providers/dictionaryTable/utilities/getUri.ts b/src/providers/dictionaryTable/utilities/getUri.ts deleted file mode 100644 index c50e6230b..000000000 --- a/src/providers/dictionaryTable/utilities/getUri.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Uri, Webview } from "vscode"; - -/** - * A helper function which will get the webview URI of a given file or resource. - * - * @remarks This URI can be used within a webview's HTML as a link to the - * given file/resource. - * - * @param webview A reference to the extension webview - * @param extensionUri The URI of the directory containing the extension - * @param pathList An array of strings representing the path to a file/resource - * @returns A URI pointing to the file/resource - */ -export function getUri(webview: Webview, extensionUri: Uri, pathList: string[]) { - return webview.asWebviewUri(Uri.joinPath(extensionUri, ...pathList)); -} diff --git a/src/providers/mainMenu/mainMenuProvider.ts b/src/providers/mainMenu/mainMenuProvider.ts index 70fef2cb3..242b39f7e 100644 --- a/src/providers/mainMenu/mainMenuProvider.ts +++ b/src/providers/mainMenu/mainMenuProvider.ts @@ -22,13 +22,6 @@ import { manualUpdateCheck } from "../../utils/updateChecker"; import { CommentsMigrator } from "../../utils/commentsMigrationUtils"; import * as path from "path"; import { PublishProjectView } from "../publishProjectView/PublishProjectView"; -const DEBUG_MODE = false; // Set to true to enable debug logging - -function debugLog(...args: any[]): void { - if (DEBUG_MODE) { - console.log("[MainMenuProvider]", ...args); - } -} class ProjectManagerStore { private preflightState: ProjectManagerState = { @@ -603,7 +596,6 @@ export class MainMenuProvider extends BaseWebviewProvider { case "downloadSourceText": case "openAISettings": case "openSourceUpload": - case "toggleSpellcheck": case "openExportView": case "openLicenseSettings": await this.executeCommandAndNotify(message.command); diff --git a/src/providers/navigationWebview/navigationWebviewProvider.ts b/src/providers/navigationWebview/navigationWebviewProvider.ts index 0b08b225b..4376b89cf 100644 --- a/src/providers/navigationWebview/navigationWebviewProvider.ts +++ b/src/providers/navigationWebview/navigationWebviewProvider.ts @@ -41,7 +41,6 @@ interface BibleBookInfo { export class NavigationWebviewProvider extends BaseWebviewProvider { public static readonly viewType = "codex-editor.navigation"; private codexItems: CodexItem[] = []; - private dictionaryItems: CodexItem[] = []; private disposables: vscode.Disposable[] = []; private isBuilding = false; private pendingRebuild = false; @@ -88,7 +87,7 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { protected onWebviewResolved(webviewView: vscode.WebviewView): void { // Initial data load - if (this.codexItems.length === 0 && this.dictionaryItems.length === 0) { + if (this.codexItems.length === 0) { this.loadBibleBookMap(); this.buildInitialData(); } else { @@ -176,12 +175,6 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { { viewColumn: vscode.ViewColumn.Two } ); } - } else if (message.type === "dictionary") { - await vscode.commands.executeCommand( - "vscode.openWith", - uri, - "codex.dictionaryEditor" - ); } else { const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); @@ -333,24 +326,6 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { } break; } - case "toggleDictionary": { - try { - const config = vscode.workspace.getConfiguration("codex-project-manager"); - const currentState = config.get("spellcheckIsEnabled", false); - await config.update("spellcheckIsEnabled", !currentState, vscode.ConfigurationTarget.Workspace); - - // Refresh dictionary items to update the enabled state - await this.buildInitialData(); - - vscode.window.showInformationMessage( - `Spellcheck ${!currentState ? 'enabled' : 'disabled'}` - ); - } catch (error) { - console.error("Error toggling dictionary:", error); - vscode.window.showErrorMessage(`Failed to toggle dictionary: ${error}`); - } - break; - } case "openSourceUpload": { try { await vscode.commands.executeCommand("codex-project-manager.openSourceUpload"); @@ -418,7 +393,6 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { .item-icon { margin-right: 6px; color: var(--vscode-foreground); opacity: 0.7; } .folder-icon { color: var(--vscode-charts-yellow); } .file-icon { color: var(--vscode-charts-blue); } - .dictionary-icon { color: var(--vscode-charts-purple); } .search-container { padding: 8px; position: sticky; top: 0; background: var(--vscode-sideBar-background); z-index: 10; display: flex; align-items: center; } .search-input { flex: 1; height: 24px; border-radius: 4px; background: var(--vscode-input-background); border: 1px solid var(--vscode-input-border, transparent); color: var(--vscode-input-foreground); padding: 0 8px; outline: none; } .search-input:focus { border-color: var(--vscode-focusBorder); } @@ -443,7 +417,6 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { const workspaceFolders = vscode.workspace.workspaceFolders; if (!workspaceFolders?.length) { this.codexItems = []; - this.dictionaryItems = []; return; } @@ -452,12 +425,7 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { rootUri.fsPath, "files/target/**/*.codex" ); - const dictPattern = new vscode.RelativePattern(rootUri.fsPath, "files/**/*.dictionary"); - - const [codexUris, dictUris] = await Promise.all([ - vscode.workspace.findFiles(codexPattern), - vscode.workspace.findFiles(dictPattern), - ]); + const codexUris = await vscode.workspace.findFiles(codexPattern); // Process codex files with metadata const codexItemsWithMetadata = await Promise.all( @@ -468,11 +436,6 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { const groupedItems = this.groupByCorpus(codexItemsWithMetadata); this.codexItems = groupedItems; - // Process dictionary items - this.dictionaryItems = await Promise.all( - dictUris.map((uri) => this.makeDictionaryItem(uri)) - ); - this.sendItemsToWebview(); } catch (error) { console.error("Error building data:", error); @@ -737,59 +700,6 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { }; } - private async getDictionaryWordCount(uri: vscode.Uri): Promise { - try { - const content = await vscode.workspace.fs.readFile(uri); - const text = Buffer.from(content).toString('utf8'); - - // Parse the dictionary content and count entries - // Assuming dictionary is in a structured format (JSON or line-separated) - try { - const jsonData = JSON.parse(text); - if (Array.isArray(jsonData)) { - return jsonData.length; - } else if (typeof jsonData === 'object') { - return Object.keys(jsonData).length; - } - } catch { - // If not JSON, count lines (assuming one word per line) - const lines = text.split('\n').filter(line => line.trim().length > 0); - return lines.length; - } - - return 0; - } catch (error) { - console.warn(`Failed to count words in dictionary ${uri.fsPath}:`, error); - return 0; - } - } - - private async makeDictionaryItem(uri: vscode.Uri): Promise { - const fileName = path.basename(uri.fsPath, ".dictionary"); - const isProjectDictionary = fileName === "project"; - - let wordCount = 0; - let isEnabled = true; - - if (isProjectDictionary) { - // Get word count from dictionary file - wordCount = await this.getDictionaryWordCount(uri); - - // Get spellcheck enabled status from workspace configuration - const config = vscode.workspace.getConfiguration("codex-project-manager"); - isEnabled = config.get("spellcheckIsEnabled", true); - } - - return { - uri, - label: fileName, - type: "dictionary", - isProjectDictionary, - wordCount, - isEnabled, - }; - } - private registerWatchers(): void { const workspaceFolders = vscode.workspace.workspaceFolders; if (!workspaceFolders?.length) { @@ -801,23 +711,13 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { rootUri.fsPath, "files/target/**/*.codex" ); - const dictWatcherPattern = new vscode.RelativePattern( - rootUri.fsPath, - "files/**/*.dictionary" - ); - const codexWatcher = vscode.workspace.createFileSystemWatcher(codexWatcherPattern); - const dictWatcher = vscode.workspace.createFileSystemWatcher(dictWatcherPattern); this.disposables.push( codexWatcher, - dictWatcher, codexWatcher.onDidCreate(() => this.buildInitialData()), codexWatcher.onDidChange(() => this.buildInitialData()), codexWatcher.onDidDelete(() => this.buildInitialData()), - dictWatcher.onDidCreate(() => this.buildInitialData()), - dictWatcher.onDidChange(() => this.buildInitialData()), - dictWatcher.onDidDelete(() => this.buildInitialData()), vscode.workspace.onDidChangeConfiguration((e) => { if ( e.affectsConfiguration("codex-project-manager.validationCount") || @@ -832,14 +732,10 @@ export class NavigationWebviewProvider extends BaseWebviewProvider { private sendItemsToWebview(): void { if (this._view) { const serializedCodexItems = this.codexItems.map((item) => this.serializeItem(item)); - const serializedDictItems = this.dictionaryItems.map((item) => - this.serializeItem(item) - ); safePostMessageToView(this._view, { command: "updateItems", codexItems: serializedCodexItems, - dictionaryItems: serializedDictItems, }); if (this.bibleBookMap) { diff --git a/src/providers/registerProviders.ts b/src/providers/registerProviders.ts index ecb160872..1207caf7b 100644 --- a/src/providers/registerProviders.ts +++ b/src/providers/registerProviders.ts @@ -5,7 +5,7 @@ import { NavigationWebviewProvider } from "./navigationWebview/navigationWebview import { MainMenuProvider } from "./mainMenu/mainMenuProvider"; import { CustomWebviewProvider as CommentsProvider } from "./commentsWebview/customCommentsWebviewProvider"; import { CustomWebviewProvider as ParallelProvider } from "./parallelPassagesWebview/customParallelPassagesWebviewProvider"; -import { WordsViewProvider } from "./WordsView/WordsViewProvider"; + import { GlobalProvider } from "../globalProvider"; import { NewSourceUploaderProvider } from "./NewSourceUploader/NewSourceUploaderProvider"; import { getWorkSpaceFolder } from "../utils"; @@ -78,14 +78,6 @@ export function registerProviders(context: vscode.ExtensionContext) { }) ); - // Register Words View Provider - const wordsViewProvider = new WordsViewProvider(context.extensionUri); - - const showWordsViewCommand = vscode.commands.registerCommand("frontier.showWordsView", () => { - wordsViewProvider?.show(); - }); - - context.subscriptions.push(showWordsViewCommand); diff --git a/src/smartEdits/chat.ts b/src/smartEdits/chat.ts index a26e8021a..3b3015fd8 100644 --- a/src/smartEdits/chat.ts +++ b/src/smartEdits/chat.ts @@ -7,27 +7,12 @@ import { getAuthApi } from "../extension"; class Chatbot { private openai!: OpenAI; // Using definite assignment assertion private config: vscode.WorkspaceConfiguration; - public messages: ChatMessage[]; - private contextMessage: ChatMessage | null; - private maxBuffer: number; - private language: string; constructor(private systemMessage: string) { this.config = vscode.workspace.getConfiguration("codex-editor-extension"); - this.language = this.config.get("main_chat_language") || "en"; // Initialize OpenAI with proper configuration (will check Frontier API) this.initializeOpenAI(); - - this.messages = [ - { - role: "system", - content: - systemMessage + `\n\nTalk with the user in this language: ${this.language}.`, - }, - ]; - this.contextMessage = null; - this.maxBuffer = 30; } private async initializeOpenAI() { @@ -59,7 +44,7 @@ class Chatbot { // Warn if API key is not set and no Frontier API is available if (!frontierApiAvailable) { console.warn( - "Smart Edits LLM API key is not set (codex-editor-extension.api_key) and you are not logged into Frontier. LLM suggestions will be disabled." + "LLM API key is not set (codex-editor-extension.api_key) and you are not logged into Frontier. Backtranslation will be disabled." ); } } @@ -73,7 +58,7 @@ class Chatbot { } : undefined, }); - console.log("Called OpenAI from smart edits with", { + console.log("Initialized OpenAI for backtranslation with", { llmEndpoint: llmEndpoint || this.config.get("llmEndpoint") || "https://api.frontierrnd.com/api/v1", authBearerToken, @@ -141,7 +126,7 @@ class Chatbot { if (!apiKey && !isAuthenticated) { vscode.window.showErrorMessage( - "Authentication failed. Please add a valid API key or log in to Frontier to use the Smart Edits feature." + "Authentication failed. Please add a valid API key or log in to Frontier to use backtranslation." ); } else { vscode.window.showErrorMessage( @@ -158,61 +143,6 @@ class Chatbot { } } - private async *streamLLM(messages: ChatMessage[]): AsyncGenerator { - try { - // Ensure OpenAI is initialized with the latest configuration - if (!this.openai) { - await this.initializeOpenAI(); - } - - const model = "default"; - - const stream = await this.openai.chat.completions.create({ - model, - messages: messages.map((message) => ({ - role: this.mapMessageRole(message.role), - content: message.content, - })) as ChatCompletionMessageParam[], - ...(model.toLowerCase() === "default" ? {} : (model.toLowerCase() === "gpt-5" ? { temperature: 1 } : { temperature: this.config.get("temperature") || 0.8 })), - stream: true, - }); - - for await (const chunk of stream) { - const content = chunk.choices[0]?.delta?.content || ""; - if (content) { - yield content; - } - } - } catch (error: any) { - if (error.response && error.response.status === 401) { - // Try to reinitialize OpenAI in case authentication state changed - try { - await this.initializeOpenAI(); - - // If we still don't have valid authentication, show appropriate message - const apiKey = this.getApiKey(); - const frontierApi = getAuthApi(); - const isAuthenticated = frontierApi?.getAuthStatus().isAuthenticated; - - if (!apiKey && !isAuthenticated) { - vscode.window.showErrorMessage( - "Authentication failed. Please add a valid API key or log in to Frontier to use the Smart Edits feature." - ); - } else { - vscode.window.showErrorMessage( - "Authentication failed. Please check your API key or Frontier credentials." - ); - } - } catch (reinitError) { - console.error("Failed to reinitialize OpenAI client:", reinitError); - } - return; - } - console.error("Error streaming from LLM:", error); - throw error; - } - } - private getJson(content: string): any { try { const jsonMatch = content.match(/\{[\s\S]*\}/); @@ -225,73 +155,6 @@ class Chatbot { return null; } - async addMessage(role: "user" | "assistant", content: string): Promise { - this.messages.push({ role, content }); - } - - async setContext(context: string): Promise { - this.contextMessage = { - role: "user", - content: `Below is your current context, - this message may change and is not an error. - It simply means that the user has changed the verses they are looking at. - This means there may be conflicts between the context and what you previously thought the context was, - this is not your fault, it just means the context has changed.\nContext:\n${context}`, - }; - } - - private getMessagesWithContext(): ChatMessage[] { - if (this.contextMessage) { - return [ - { role: "system", content: this.systemMessage }, - this.contextMessage, - ...this.messages, - ]; - } - return this.messages; - } - - async sendMessage(message: string): Promise { - await this.addMessage("user", message); - const response = await this.callLLM(this.getMessagesWithContext()); - await this.addMessage("assistant", response); - if (this.messages.length > this.maxBuffer) { - this.messages.shift(); - } - return response; - } - - async editMessage(messageIndex: number, newContent: string): Promise { - if (messageIndex >= this.messages.length - 1) { - throw new Error("Invalid message index"); - } - - this.messages = this.messages.slice(0, messageIndex + 1); - } - - async sendMessageStream( - message: string, - onChunk: (chunk: { index: number; content: string; }, isLast: boolean) => void - ): Promise { - await this.addMessage("user", message); - let fullResponse = ""; - let chunkIndex = 0; - - for await (const chunk of this.streamLLM(this.getMessagesWithContext())) { - onChunk({ index: chunkIndex++, content: chunk }, false); - fullResponse += chunk; - } - - // Send a final empty chunk to indicate the end of the stream - onChunk({ index: chunkIndex, content: "" }, true); - - await this.addMessage("assistant", fullResponse); - if (this.messages.length > this.maxBuffer) { - this.messages.shift(); - } - return fullResponse; - } - async getCompletion(prompt: string): Promise { const response = await this.callLLM([ { role: "system", content: this.systemMessage }, @@ -300,25 +163,11 @@ class Chatbot { return response; } - async causeMemoryLoss() { - // TODO: Make the LLM beg to keep its memory before this happens. - this.messages = [{ role: "system", content: this.systemMessage }]; - } - async getJsonCompletion(prompt: string): Promise { const response = await this.getCompletion(prompt); return this.getJson(response); } - async getJsonCompletionWithHistory(prompt: string): Promise { - await this.addMessage("user", prompt); - const response = await this.callLLM(this.getMessagesWithContext()); - await this.addMessage("assistant", response); - if (this.messages.length > this.maxBuffer) { - this.messages.shift(); - } - return this.getJson(response); - } } export default Chatbot; \ No newline at end of file diff --git a/src/smartEdits/iceEdits.ts b/src/smartEdits/iceEdits.ts deleted file mode 100644 index 4ce8732a3..000000000 --- a/src/smartEdits/iceEdits.ts +++ /dev/null @@ -1,403 +0,0 @@ -import * as vscode from "vscode"; -import { diffWords } from "diff"; -import { tokenizeText } from "@/utils/nlpUtils"; - -const DEBUG = false; -const debug = DEBUG ? console.log : () => {}; - -interface ICEEditRecord { - original: string; - replacement: string; - leftToken: string; - rightToken: string; - frequency: number; - lastUpdated: number; - rejected?: boolean; -} - -interface ICECandidateSuggestion { - original: string; - replacement: string; - confidence: "high" | "low"; - frequency: number; - leftToken: string; - rightToken: string; -} - -export class ICEEdits { - private iceEditsPath: vscode.Uri; - private editRecords: Map = new Map(); - - constructor(workspaceFolder: string) { - this.iceEditsPath = vscode.Uri.joinPath( - vscode.Uri.file(workspaceFolder), - "files", - "ice_edits.json" - ); - this.ensureFileExists(); - } - - private stripHtml(text: string): string { - // Remove HTML tags - let strippedText = text.replace(/<[^>]*>/g, ""); - // Remove common HTML entities - strippedText = strippedText.replace(/  ?/g, " "); - // Keep apostrophe or typographic apostrophe - strippedText = strippedText.replace(/&|<|>|"|'|"/g, ""); - // Remove other numeric HTML entities - strippedText = strippedText.replace(/&#\d+;/g, ""); - // Remove any remaining & entities - strippedText = strippedText.replace(/&[a-zA-Z]+;/g, ""); - return strippedText; - } - - /** - * Performs a full diff-based recording of ICE edits. - * Strips HTML, diffs old/new text, and calls recordEdit(token, replacement, left, right). - */ - public async recordFullEdit(oldText: string, newText: string): Promise { - // 1) Strip HTML - const cleanOld = this.stripHtml(oldText); - const cleanNew = this.stripHtml(newText); - - // 2) Tokenize both texts for context - const oldTokens = tokenizeText({ method: "whitespace", text: cleanOld }); - const newTokens = tokenizeText({ method: "whitespace", text: cleanNew }); - - // 3) Diff them - const diff = diffWords(cleanOld, cleanNew); - - let oldIndex = 0; - let skipNextAdded = false; // Flag to skip processing added parts that were already handled as replacements - - for (let i = 0; i < diff.length; i++) { - const part = diff[i]; - - if (part.removed) { - // Handle removals (with potential replacements) - const removedTokens = part.value.split(/\s+/); - - for (let t = 0; t < removedTokens.length; t++) { - const token = removedTokens[t]; - if (!token) continue; // Skip empty tokens - - // Get the actual preceding and following tokens from the original text - const leftToken = oldIndex > 0 ? oldTokens[oldIndex - 1] : ""; - const rightToken = - oldIndex + 1 < oldTokens.length ? oldTokens[oldIndex + 1] : ""; - - // Look ahead for added part to mark this as a replacement - const nextPart = diff[i + 1]; - if (nextPart && nextPart.added) { - const addedTokens = nextPart.value.split(/\s+/).filter((t) => t); // Filter out empty tokens - if (t < addedTokens.length) { - await this.recordEdit(token, addedTokens[t], leftToken, rightToken); - } - skipNextAdded = true; // Skip processing this added part later - } else { - // Only record removal if we have some context - if (leftToken || rightToken) { - await this.recordEdit(token, "", leftToken, rightToken); - } - } - oldIndex++; - } - } else if (part.added && !skipNextAdded) { - // Handle pure additions (no preceding removal) - const addedTokens = part.value.split(/\s+/).filter((t) => t); // Filter out empty tokens - - for (let t = 0; t < addedTokens.length; t++) { - const token = addedTokens[t]; - if (!token) continue; // Skip empty tokens - - // For pure additions, get the actual surrounding context - const contextIndex = oldIndex > 0 ? oldIndex - 1 : 0; - const leftToken = contextIndex > 0 ? oldTokens[contextIndex - 1] : ""; - const rightToken = - contextIndex < oldTokens.length ? oldTokens[contextIndex] : ""; - - // Only record addition if we have some context - if (leftToken || rightToken) { - await this.recordEdit("", token, leftToken, rightToken); - } - } - } else { - // Skip unchanged tokens - const skipTokens = part.value.split(/\s+/).filter((t) => t).length; - oldIndex += skipTokens; - } - - // Reset the skip flag after processing an added part - if (part.added) { - skipNextAdded = false; - } - } - } - - private async ensureFileExists(): Promise { - try { - await vscode.workspace.fs.stat(this.iceEditsPath); - } catch (error) { - if ((error as any).code === "FileNotFound") { - await vscode.workspace.fs.writeFile(this.iceEditsPath, new Uint8Array()); - } else { - throw error; - } - } - } - - private async loadEditRecords(): Promise> { - try { - const fileContent = await vscode.workspace.fs.readFile(this.iceEditsPath); - const fileString = fileContent.toString(); - const records: Record = fileString ? JSON.parse(fileString) : {}; - - // Filter out rejected records when loading - const filteredRecords = Object.fromEntries( - Object.entries(records).filter(([_, record]) => !record.rejected) - ); - - // Update the in-memory records - this.editRecords = new Map(Object.entries(filteredRecords)); - - // Return all records (including rejected ones) for reference - return records; - } catch (error) { - console.error("Error loading ICE edit records:", error); - return {}; - } - } - - private async saveEditRecords(): Promise { - try { - const records = Object.fromEntries(this.editRecords); - await vscode.workspace.fs.writeFile( - this.iceEditsPath, - Buffer.from(JSON.stringify(records, null, 2)) - ); - } catch (error) { - console.error("Error saving ICE edit records:", error); - } - } - - private getRecordKey(original: string, leftToken: string, rightToken: string): string { - // Don't create records with all empty values - if (!original && !leftToken && !rightToken) { - return ""; // Return empty string instead of null - } - return `${leftToken}|${original}|${rightToken}`; - } - - async recordEdit( - original: string, - replacement: string, - leftToken: string, - rightToken: string - ): Promise { - // Skip if trying to record an empty edit - if (!original && !replacement) { - return; - } - - await this.loadEditRecords(); - - const key = this.getRecordKey(original, leftToken, rightToken); - // Skip if key is empty (all empty values) - if (!key) { - return; - } - - const existingRecord = this.editRecords.get(key); - - if (existingRecord && existingRecord.replacement === replacement) { - // Increment frequency for existing identical edit - this.editRecords.set(key, { - ...existingRecord, - frequency: existingRecord.frequency + 1, - lastUpdated: Date.now(), - }); - } else { - // Create new record - this.editRecords.set(key, { - original, - replacement, - leftToken, - rightToken, - frequency: 1, - lastUpdated: Date.now(), - }); - } - - await this.saveEditRecords(); - } - - async calculateSuggestions( - currentToken: string, - leftToken: string, - rightToken: string - ): Promise> { - debug("[RYDER] Calculating suggestions for:", { - currentToken, - leftToken, - rightToken, - }); - - const allRecords = await this.loadEditRecords(); - debug("[RYDER] allRecords details:", allRecords); - - const suggestions: Array<{ replacement: string; confidence: string; frequency: number }> = - []; - - for (const [key, record] of Object.entries(allRecords || {})) { - // Skip rejected records - if (record.rejected) { - debug("[RYDER] Skipping rejected record:", { key, record }); - continue; - } - - debug("[RYDER] Comparing record:", { - key, - recordOriginal: record.original, - recordReplacement: record.replacement, - expectedOriginal: currentToken, - expectedReplacement: record.replacement, - recordLeft: record.leftToken, - recordRight: record.rightToken, - currentLeft: leftToken, - currentRight: rightToken, - }); - - const { - original: recordOriginal, - replacement: recordReplacement, - leftToken: recordLeftToken, - rightToken: recordRightToken, - rejected: recordRejected, - } = record; - - // Only add suggestion if it matches exactly and isn't rejected - if ( - recordOriginal === currentToken && - recordLeftToken === leftToken && - recordRightToken === rightToken && - !recordRejected // Double-check rejection status - ) { - debug("[RYDER] found matching record", { key, record }); - suggestions.push({ - replacement: record.replacement, - confidence: "high", - frequency: record.frequency || 1, - }); - } - } - - debug("[RYDER] Final suggestions:", suggestions); - return suggestions; - } - - /** - * Mark an edit suggestion as rejected - */ - async rejectEdit( - original: string, - replacement: string, - leftToken: string, - rightToken: string - ): Promise { - debug("[RYDER] rejectEdit called from ICEEdits class", { - original, - replacement, - leftToken, - rightToken, - }); - await this.loadEditRecords(); - - // Load all records including rejected ones - const fileContent = await vscode.workspace.fs.readFile(this.iceEditsPath); - const fileString = fileContent.toString(); - const allRecords: Record = fileString ? JSON.parse(fileString) : {}; - - // Log all record keys to help debug - debug("[RYDER] allRecords details:", JSON.stringify(allRecords, null, 2)); - - // Find the record by matching fields directly - const entries = Object.entries(allRecords); - - // Debug each comparison - entries.forEach(([key, record]) => { - debug("[RYDER] Comparing record:", { - key, - recordOriginal: record.original, - recordReplacement: record.replacement, - expectedOriginal: original, - expectedReplacement: replacement, - originalMatches: record.original === original, - replacementMatches: record.replacement === replacement, - // Debug the actual string values - recordOriginalType: typeof record.original, - recordReplacementType: typeof record.replacement, - originalType: typeof original, - replacementType: typeof replacement, - // Debug string lengths - recordOriginalLength: record.original.length, - recordReplacementLength: record.replacement.length, - originalLength: original.length, - replacementLength: replacement.length, - // Debug character codes - recordOriginalCodes: [...record.original].map((c) => c.charCodeAt(0)), - recordReplacementCodes: [...record.replacement].map((c) => c.charCodeAt(0)), - originalCodes: [...original].map((c) => c.charCodeAt(0)), - replacementCodes: [...replacement].map((c) => c.charCodeAt(0)), - }); - }); - - const matchingEntry = entries.find(([_, record]) => { - const originalMatch = original === record.original; - const replacementMatch = replacement === record.replacement; - const leftTokenMatch = leftToken === record.leftToken; - const rightTokenMatch = rightToken === record.rightToken; - - return ( - originalMatch && - replacementMatch && - leftTokenMatch && - rightTokenMatch && - !record.rejected - ); - }); - - debug(matchingEntry); - - if (matchingEntry) { - const [key, record] = matchingEntry; - debug("[RYDER] found matching record", { key, record }); - - // Create the updated record and verify it has the rejected flag - const updatedRecord = { ...record, rejected: true }; - debug("[RYDER] updated record", { updatedRecord }); - - allRecords[key] = updatedRecord; - debug("[RYDER] allRecords after rejection", JSON.stringify(allRecords, null, 2)); - - await vscode.workspace.fs.writeFile( - this.iceEditsPath, - Buffer.from(JSON.stringify(allRecords, null, 2)) - ); - debug("[RYDER] Successfully wrote to file"); - - // Update in-memory records - this.editRecords.delete(key); - } else { - // Log why we didn't find a match - debug("[RYDER] Did not find matching record for:", { - original, - replacement, - availableRecords: entries.map(([key, record]) => ({ - key, - original: record.original, - replacement: record.replacement, - })), - }); - } - } -} diff --git a/src/smartEdits/registerBacktranslationCommands.ts b/src/smartEdits/registerBacktranslationCommands.ts new file mode 100644 index 000000000..ec4396130 --- /dev/null +++ b/src/smartEdits/registerBacktranslationCommands.ts @@ -0,0 +1,104 @@ +import * as vscode from "vscode"; +import { getWorkSpaceFolder } from "../utils"; +import { SmartBacktranslation, SavedBacktranslation } from "./smartBacktranslation"; + +export const registerBacktranslationCommands = (context: vscode.ExtensionContext) => { + const workspaceFolder = getWorkSpaceFolder(); + if (!workspaceFolder) { + console.warn("No workspace folder found, backtranslation will be disabled"); + return; + } + + const workspaceUri = vscode.Uri.file(workspaceFolder); + const smartBacktranslation = new SmartBacktranslation(workspaceUri); + + context.subscriptions.push( + vscode.commands.registerCommand( + "codex-smart-edits.generateBacktranslation", + async (text: string, cellId: string, filePath?: string): Promise => { + try { + return await smartBacktranslation.generateBacktranslation(text, cellId, filePath); + } catch (error) { + console.error("Error generating backtranslation:", error); + vscode.window.showErrorMessage( + "Failed to generate backtranslation. Please check the console for more details." + ); + return null; + } + } + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + "codex-smart-edits.editBacktranslation", + async ( + cellId: string, + newText: string, + existingBacktranslation: string, + filePath?: string + ): Promise => { + try { + return await smartBacktranslation.editBacktranslation( + cellId, + newText, + existingBacktranslation, + filePath + ); + } catch (error) { + console.error("Error editing backtranslation:", error); + vscode.window.showErrorMessage( + "Failed to edit backtranslation. Please check the console for more details." + ); + return null; + } + } + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + "codex-smart-edits.getBacktranslation", + async (cellId: string): Promise => { + try { + return await smartBacktranslation.getBacktranslation(cellId); + } catch (error) { + console.error("Error getting backtranslation:", error); + vscode.window.showErrorMessage( + "Failed to get backtranslation. Please check the console for more details." + ); + return null; + } + } + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + "codex-smart-edits.setBacktranslation", + async ( + cellId: string, + originalText: string, + userBacktranslation: string, + filePath?: string + ): Promise => { + try { + return await smartBacktranslation.setBacktranslation( + cellId, + originalText, + userBacktranslation, + filePath + ); + } catch (error) { + console.error("Error setting backtranslation:", error); + vscode.window.showErrorMessage( + "Failed to set backtranslation. Please check the console for more details." + ); + return null; + } + } + ) + ); + + console.log("Backtranslation commands registered"); +}; diff --git a/src/smartEdits/registerSmartEditCommands.ts b/src/smartEdits/registerSmartEditCommands.ts deleted file mode 100644 index 11d1ab179..000000000 --- a/src/smartEdits/registerSmartEditCommands.ts +++ /dev/null @@ -1,258 +0,0 @@ -import * as vscode from "vscode"; -import { SmartEdits } from "./smartEdits"; -import { getWorkSpaceFolder } from "../utils"; -import { SmartBacktranslation, SavedBacktranslation } from "./smartBacktranslation"; -import { ICEEdits } from "./iceEdits"; - -export const registerSmartEditCommands = (context: vscode.ExtensionContext) => { - const workspaceFolder = getWorkSpaceFolder(); - if (!workspaceFolder) { - console.warn("No workspace folder found, smart edits will be disabled"); - return; - } - - const workspaceUri = vscode.Uri.file(workspaceFolder); - const smartEdits = new SmartEdits(workspaceUri); - const smartBacktranslation = new SmartBacktranslation(workspaceUri); - const iceEdits = new ICEEdits(workspaceFolder); - - context.subscriptions.push( - vscode.commands.registerCommand( - "codex-smart-edits.getEdits", - async (text: string, cellId: string) => { - try { - const suggestions = await smartEdits.getEdits(text, cellId); - return suggestions; - } catch (error) { - console.error("Error getting smart edits:", error); - return []; - } - } - ) - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - "codex-smart-edits.getSavedSuggestions", - async (cellId: string) => { - try { - const suggestions = await smartEdits.loadSavedSuggestions(cellId); - return suggestions; - } catch (error) { - console.error("Error getting saved suggestions:", error); - return []; - } - } - ) - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - "codex-smart-edits.supplyRecentEditHistory", - async ({ cellId, editHistory }) => { - try { - await smartEdits.updateEditHistory(cellId, editHistory); - // TODO: Think about if below would be nice or not - // await promptedSmartEdits.updateEditHistory(cellId, editHistory); - return true; - } catch (error) { - console.error("Error updating edit history:", error); - return false; - } - } - ) - ); - - - // Add new commands for SmartBacktranslation - context.subscriptions.push( - vscode.commands.registerCommand( - "codex-smart-edits.generateBacktranslation", - async (text: string, cellId: string, filePath?: string): Promise => { - try { - return await smartBacktranslation.generateBacktranslation(text, cellId, filePath); - } catch (error) { - console.error("Error generating backtranslation:", error); - vscode.window.showErrorMessage( - "Failed to generate backtranslation. Please check the console for more details." - ); - return null; - } - } - ) - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - "codex-smart-edits.editBacktranslation", - async ( - cellId: string, - newText: string, - existingBacktranslation: string, - filePath?: string - ): Promise => { - try { - return await smartBacktranslation.editBacktranslation( - cellId, - newText, - existingBacktranslation, - filePath - ); - } catch (error) { - console.error("Error editing backtranslation:", error); - vscode.window.showErrorMessage( - "Failed to edit backtranslation. Please check the console for more details." - ); - return null; - } - } - ) - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - "codex-smart-edits.getBacktranslation", - async (cellId: string): Promise => { - try { - return await smartBacktranslation.getBacktranslation(cellId); - } catch (error) { - console.error("Error getting backtranslation:", error); - vscode.window.showErrorMessage( - "Failed to get backtranslation. Please check the console for more details." - ); - return null; - } - } - ) - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - "codex-smart-edits.setBacktranslation", - async ( - cellId: string, - originalText: string, - userBacktranslation: string, - filePath?: string - ): Promise => { - try { - return await smartBacktranslation.setBacktranslation( - cellId, - originalText, - userBacktranslation, - filePath - ); - } catch (error) { - console.error("Error setting backtranslation:", error); - vscode.window.showErrorMessage( - "Failed to set backtranslation. Please check the console for more details." - ); - return null; - } - } - ) - ); - - - // Test command for ICE edits - context.subscriptions.push( - vscode.commands.registerCommand("codex-smart-edits.testIceEdit", async () => { - try { - // Record a test edit - await iceEdits.recordEdit("hello", "hi", "", "there"); - vscode.window.showInformationMessage( - "Recorded test ICE edit: 'hello' -> 'hi' (with 'there' as right context)" - ); - } catch (error) { - console.error("Error recording ICE edit:", error); - vscode.window.showErrorMessage("Failed to record ICE edit"); - } - }) - ); - - // Test command to check ICE suggestions - context.subscriptions.push( - vscode.commands.registerCommand("codex-smart-edits.checkIceSuggestions", async () => { - try { - const suggestions = await iceEdits.calculateSuggestions("hello", "", "there"); - vscode.window.showInformationMessage(`Found ${suggestions.length} ICE suggestions`); - } catch (error) { - console.error("Error checking ICE suggestions:", error); - vscode.window.showErrorMessage("Failed to check ICE suggestions"); - } - }) - ); - - // Register the getIceEdits command - context.subscriptions.push( - vscode.commands.registerCommand("codex-smart-edits.getIceEdits", async (text: string) => { - const suggestions = await smartEdits.getIceEdits(text); - return suggestions; - }) - ); - - // Add new command for recording ICE edits - context.subscriptions.push( - vscode.commands.registerCommand( - "codex-smart-edits.recordIceEdit", - async (oldText: string, newText: string) => { - try { - // Use recordFullEdit which handles diffing and context extraction - await iceEdits.recordFullEdit(oldText, newText); - } catch (error) { - console.error("Error recording ICE edit:", error); - vscode.window.showErrorMessage( - "Failed to record ICE edit. Please check the console for more details." - ); - } - } - ) - ); - - // Add command to reject edit suggestions - context.subscriptions.push( - vscode.commands.registerCommand( - "codex-smart-edits.rejectEditSuggestion", - async ({ - source, - cellId, - oldString, - newString, - leftToken, - rightToken, - }: { - source: "ice" | "llm"; - cellId?: string; - oldString: string; - newString: string; - leftToken: string; - rightToken: string; - }) => { - try { - if (source === "ice") { - if (!leftToken && !rightToken) { - throw new Error( - "At least one of leftToken or rightToken is required for ICE edit rejections" - ); - } - await iceEdits.rejectEdit(oldString, newString, leftToken, rightToken); - } else { - if (!cellId) { - throw new Error("cellId is required for LLM edit rejections"); - } - await smartEdits.rejectSmartSuggestion(cellId, oldString, newString); - } - return true; - } catch (error) { - console.error("Error rejecting edit suggestion:", error); - vscode.window.showErrorMessage( - "Failed to reject edit suggestion. Please check the console for more details." - ); - return false; - } - } - ) - ); - - console.log("Smart Edit commands registered"); -}; diff --git a/src/smartEdits/smartEdits.ts b/src/smartEdits/smartEdits.ts deleted file mode 100644 index ef1820eeb..000000000 --- a/src/smartEdits/smartEdits.ts +++ /dev/null @@ -1,535 +0,0 @@ -import Chatbot from "./chat"; -import { - TranslationPair, - SmartEditContext, - SmartSuggestion, - SavedSuggestions, - EditHistoryEntry, -} from "../../types"; -import { EditMapUtils, isValueEdit, filterEditsByPath } from "../utils/editMapUtils"; -import * as vscode from "vscode"; -import { diffWords } from "diff"; -import { ICEEdits } from "./iceEdits"; -import { tokenizeText } from "@/utils/nlpUtils"; - -const DEBUG_ENABLED = false; -function debug(...args: any[]): void { - if (DEBUG_ENABLED) { - console.log(`[SmartEdits]`, ...args); - } -} - -interface IceSuggestion { - text: string; - replacement: string; - confidence: string; - frequency: number; - rejected?: boolean; - leftToken: string; - rightToken: string; -} - -const SYSTEM_MESSAGE = `You are a helpful assistant. Given similar edits across a corpus, you will suggest edits to a new text. -Your suggestions should follow this format: - { - "suggestions": [ - { - "oldString": "The old string to be replaced", - "newString": "The new string to replace the old string" - }, - { - "oldString": "The old string to be replaced", - "newString": "The new string to replace the old string" - } - ] - } - Rules: - 1. These will be in languages you may not be familiar with, so try your best anyways and use the context to infer the correct potential edits. - 2. Do not make edits based only on HTML. Preserve all HTML tags in the text. - 3. If no edits are needed, return this default response: - { - "suggestions": [] - } - 4. Focus on meaningful content changes, not just HTML structure modifications. - 5. Pay close attention to what commonly changes between revisions, and attempt to supply suggestions that implement these if it makes sense. - 6. The replacements should focus as few words as possible, break into multiple suggestions when needed. - `; - -export class SmartEdits { - private chatbot: Chatbot; - private smartEditsPath: vscode.Uri; - private teachFile: vscode.Uri; - private lastProcessedCellId: string | null = null; - private lastSuggestions: SmartSuggestion[] = []; - private editHistory: { [key: string]: EditHistoryEntry[]; } = {}; - private iceEdits: ICEEdits; - - constructor(workspaceUri: vscode.Uri) { - this.chatbot = new Chatbot(SYSTEM_MESSAGE); - this.smartEditsPath = vscode.Uri.joinPath(workspaceUri, "files", "smart_edits.json"); - this.teachFile = vscode.Uri.joinPath(workspaceUri, "files", "silver_path_memories.json"); - this.iceEdits = new ICEEdits(workspaceUri.fsPath); - - this.ensureFileExists(this.smartEditsPath); - this.ensureFileExists(this.teachFile); - } - - private async ensureFileExists(fileUri: vscode.Uri): Promise { - try { - await vscode.workspace.fs.stat(fileUri); - } catch (error) { - if ((error as any).code === "FileNotFound") { - await vscode.workspace.fs.writeFile(fileUri, new Uint8Array()); - } else { - throw error; - } - } - } - - async getEdits(text: string, cellId: string): Promise { - // Get ICE suggestions first - const iceSuggestions: SmartSuggestion[] = []; - const similarEntries = await this.findSimilarEntries(text); - const cellHistory = this.editHistory[cellId] || []; - - // Get ICE suggestions - const iceResults = await vscode.commands.executeCommand( - "codex-smart-edits.getIceEdits", - text - ); - - if (iceResults?.length) { - iceResults - .filter((suggestion) => suggestion.rejected !== true) - .forEach((suggestion) => { - iceSuggestions.push({ - oldString: suggestion.text, - newString: suggestion.replacement, - confidence: suggestion.confidence as "high" | "low", - source: "ice", - frequency: suggestion.frequency, - }); - }); - } - - // If we have high-confidence ICE suggestions, return them immediately - const highConfidenceIceSuggestions = iceSuggestions.filter((s) => s.confidence === "high"); - if (highConfidenceIceSuggestions.length > 0) { - debug("[SmartEdits] Exiting early: Found high-confidence ICE suggestions."); - this.lastProcessedCellId = cellId; - this.lastSuggestions = highConfidenceIceSuggestions; - return highConfidenceIceSuggestions; - } - - // Determine the reference cellId for saved suggestions and context (fallback to current if none found) - let firstResultCellId = cellId; - if (similarEntries.length > 0) { - firstResultCellId = similarEntries[0].cellId; - } - debug("[SmartEdits] Using firstResultCellId:", firstResultCellId); - - if (firstResultCellId === this.lastProcessedCellId) { - debug("[SmartEdits] Exiting early: firstResultCellId matches lastProcessedCellId. Returning cached suggestions:", this.lastSuggestions); - return this.lastSuggestions; - } - - const savedSuggestions = await this.loadSavedSuggestions(firstResultCellId); - - if (savedSuggestions && savedSuggestions.lastCellValue === text) { - debug("[SmartEdits] Exiting early: Found saved suggestions for unchanged text. Returning saved suggestions:", savedSuggestions); - // Filter out rejected suggestions - const filteredSuggestions = savedSuggestions.suggestions.filter( - (suggestion) => - !savedSuggestions.rejectedSuggestions?.some( - (rejected) => - rejected.oldString === suggestion.oldString && - rejected.newString === suggestion.newString - ) - ); - - this.lastProcessedCellId = firstResultCellId; - this.lastSuggestions = filteredSuggestions; - return filteredSuggestions; - } - - const similarTexts = await this.getSimilarTexts(similarEntries); - const similarTextsString = this.formatSimilarTexts(similarTexts); - const message = this.createEditMessage(similarTextsString, text, cellHistory); - - debug(`[SmartEdits] Sending LLM prompt for cell ${cellId}:`); - debug(message); - const jsonResponse = await this.chatbot.getJsonCompletion(message); - debug(`[SmartEdits] Received LLM response for cell ${cellId}:`, jsonResponse); - - let llmSuggestions: SmartSuggestion[] = []; - if (Array.isArray(jsonResponse.suggestions)) { - llmSuggestions = jsonResponse.suggestions.map((suggestion: any) => ({ - oldString: suggestion.oldString || "", - newString: suggestion.newString || "", - source: "llm", - })); - } - - // Combine LLM suggestions with ICE suggestions and filter out rejected ones - const allSuggestions = [...llmSuggestions, ...iceSuggestions]; - await this.saveSuggestions(firstResultCellId, text, allSuggestions); - - this.lastProcessedCellId = firstResultCellId; - this.lastSuggestions = allSuggestions; - return allSuggestions; - } - - async loadSavedSuggestions(cellId: string): Promise { - try { - const fileContent = await vscode.workspace.fs.readFile(this.smartEditsPath); - const fileString = fileContent.toString(); - const savedEdits: { [key: string]: SavedSuggestions; } = fileString - ? JSON.parse(fileString) - : {}; - const result = savedEdits[cellId] || null; - - if (result) { - // Filter out rejected suggestions - result.suggestions = result.suggestions.filter( - (suggestion) => - !result.rejectedSuggestions?.some( - (rejected) => - rejected.oldString === suggestion.oldString && - rejected.newString === suggestion.newString - ) - ); - } - - return result; - } catch (error) { - console.error("Error loading saved suggestions:", error); - return null; - } - } - - /** - * Mark a smart edit suggestion as rejected - */ - async rejectSmartSuggestion( - cellId: string, - oldString: string, - newString: string - ): Promise { - try { - const fileContent = await vscode.workspace.fs.readFile(this.smartEditsPath); - const fileString = fileContent.toString(); - const savedEdits: { [key: string]: SavedSuggestions; } = fileString - ? JSON.parse(fileString) - : {}; - - // Initialize the cell entry if it doesn't exist - if (!savedEdits[cellId]) { - savedEdits[cellId] = { - cellId, - lastCellValue: "", - suggestions: [], - rejectedSuggestions: [], - lastUpdatedDate: new Date().toISOString(), - }; - } - - const cellEdits = savedEdits[cellId]; - - debug("[RYDER] Rejecting smart suggestion for cellId:", cellId, { - oldString, - newString, - cellEdits, - savedEdits, - }); - - // Initialize rejectedSuggestions if it doesn't exist - if (!cellEdits.rejectedSuggestions) { - cellEdits.rejectedSuggestions = []; - } - - // Add to rejected suggestions if not already there - if ( - !cellEdits.rejectedSuggestions.some( - (s) => s.oldString === oldString && s.newString === newString - ) - ) { - cellEdits.rejectedSuggestions.push({ oldString, newString }); - } - - // Filter out the rejected suggestion from current suggestions - cellEdits.suggestions = cellEdits.suggestions.filter( - (s) => !(s.oldString === oldString && s.newString === newString) - ); - - // Update in-memory suggestions if this is for the last processed cell - if (this.lastProcessedCellId === cellId) { - this.lastSuggestions = this.lastSuggestions.filter( - (s) => !(s.oldString === oldString && s.newString === newString) - ); - debug("[RYDER] Updated in-memory suggestions:", this.lastSuggestions); - } - - debug("[RYDER] Rejected suggestion:", { oldString, newString }); - - savedEdits[cellId] = cellEdits; - await vscode.workspace.fs.writeFile( - this.smartEditsPath, - Buffer.from(JSON.stringify(savedEdits, null, 2)) - ); - } catch (error) { - console.error("Error rejecting smart suggestion:", error); - throw error; - } - } - - private async saveSuggestions( - cellId: string, - text: string, - suggestions: SmartSuggestion[] - ): Promise { - if (suggestions.length === 0) return; - try { - let savedEdits: { [key: string]: SavedSuggestions; } = {}; - - try { - const fileContent = await vscode.workspace.fs.readFile(this.smartEditsPath); - const fileString = fileContent.toString(); - savedEdits = fileString ? JSON.parse(fileString) : {}; - } catch (error) { - debug("No existing saved edits found, starting with empty object"); - } - - savedEdits[cellId] = { - cellId, - lastCellValue: text, - suggestions, - lastUpdatedDate: new Date().toISOString(), - }; - - await vscode.workspace.fs.writeFile( - this.smartEditsPath, - Buffer.from(JSON.stringify(savedEdits, null, 2)) - ); - } catch (error) { - console.error("Error saving suggestions:", error); - } - } - - private async findSimilarEntries(text: string): Promise { - try { - const results = await vscode.commands.executeCommand( - "codex-editor-extension.searchParallelCells", - text - ); - return results || []; - } catch (error) { - console.error("Error searching parallel cells:", error); - return []; - } - } - - private async getSimilarTexts(similarEntries: TranslationPair[]): Promise { - const similarTexts: SmartEditContext[] = []; - const allMemories = await this.readAllMemories(); - - for (const entry of similarEntries) { - if (entry.targetCell.uri) { - try { - const uri = vscode.Uri.parse(entry.targetCell.uri.toString()); - const pathSegments = uri.path.split("/").filter(Boolean); - - // Create new path segments array with modifications - const newPathSegments = pathSegments.map((segment) => { - if (segment === ".source") return ".codex"; - if (segment === "sourceTexts") return "target"; - return segment; - }); - - // Ensure we have the correct path structure - const workspaceUri = vscode.workspace.workspaceFolders?.[0]?.uri; - if (!workspaceUri) { - console.error("No workspace folder found"); - continue; - } - - // Create the target file URI by joining with workspace - const fileUri = vscode.Uri.joinPath( - workspaceUri, - "files", - "target", - pathSegments[pathSegments.length - 1].replace(".source", ".codex") - ); - - debug({ - lastPathSegments: pathSegments[pathSegments.length - 1], - fileUri, - pathSegments, - }); - - const fileContent = await vscode.workspace.fs.readFile(fileUri); - const fileString = fileContent.toString(); - const jsonContent = fileString ? JSON.parse(fileString) : { cells: [] }; - const cell = jsonContent.cells?.find( - (cell: any) => cell.metadata.id === entry.cellId - ); - if (cell) { - const context: SmartEditContext = { - cellId: entry.cellId, - currentCellValue: cell.value, - edits: cell.metadata.edits || [], - memory: allMemories[entry.cellId]?.content || "", - }; - similarTexts.push(context); - } else { - debug(`Cell not found for cellId: ${entry.cellId}`); - } - } catch (error) { - console.error(`Error reading file for cellId ${entry.cellId}:`, error); - } - } else { - debug(`No valid URI found for cellId: ${entry.cellId}`); - } - } - return similarTexts; - } - - private formatSimilarTexts(similarTexts: SmartEditContext[]): string { - const formattedTexts = similarTexts - .map((context) => { - // Alternative: Using the generic filter utility - const valueEdits = filterEditsByPath(context.edits, ["value"] as const); - if (valueEdits.length === 0) return ""; - - // TypeScript knows these are value edits with string values - const firstEdit = this.stripHtml(valueEdits[0].value as string); - const lastEdit = this.stripHtml(valueEdits[valueEdits.length - 1].value as string); - - if (valueEdits.length === 1 || firstEdit === lastEdit) return ""; - - const diff = this.generateDiff(firstEdit, lastEdit); - return `"${context.cellId}": { - revision 1: ${JSON.stringify(firstEdit)} - revision 2: ${JSON.stringify(lastEdit)} - diff: - ${diff} - memory: ${JSON.stringify(context.memory)} -}`; - }) - .filter((text) => text !== ""); - return `{\n${formattedTexts.join(",\n")}\n}`; - } - - private stripHtml(text: string): string { - // Remove HTML tags - let strippedText = text.replace(/<[^>]*>/g, ""); - // Remove common HTML entities - strippedText = strippedText.replace(/&|<|>|"|'/g, ""); - // remove and replace   entities - strippedText = strippedText.replace(/  ?/g, " "); - // Remove other numeric HTML entities - strippedText = strippedText.replace(/&#\d+;/g, ""); - // Remove any remaining & entities - strippedText = strippedText.replace(/&[a-zA-Z]+;/g, ""); - return strippedText; - } - - private generateDiff(oldText: string, newText: string): string { - const diff = diffWords(oldText, newText); - return diff - .map((part) => { - if (part.added) { - return ` + ${part.value}`; - } - if (part.removed) { - return ` - ${part.value}`; - } - return ` ${part.value}`; - }) - .join(""); - } - - private createEditMessage( - similarTextsString: string, - text: string, - history: EditHistoryEntry[] - ): string { - const historyString = - history.length > 0 - ? `\nRecent edit history for this cell:\n${history - .map( - (entry) => - `Before: ${entry.before}\nAfter: ${entry.after}\nTimestamp: ${new Date(entry.timestamp).toISOString()}` - ) - .join("\n\n")}` - : ""; - - return `Similar Texts:\n${similarTextsString}\n${historyString}\n\nEdit the following text based on the patterns you've seen in similar texts and recent edits, always return the json format specified. Do not suggest edits that are merely HTML changes. Focus on meaningful content modifications.\nText: ${text}`; - } - - async updateEditHistory(cellId: string, history: EditHistoryEntry[]): Promise { - this.editHistory[cellId] = history; - - // Record each edit in ICE edits using the new recordFullEdit method - for (const entry of history) { - await this.iceEdits.recordFullEdit(entry.before, entry.after); - } - } - - private async readAllMemories(): Promise<{ - [cellId: string]: { content: string; times_used: number; }; - }> { - try { - const fileContent = await vscode.workspace.fs.readFile(this.teachFile); - const fileString = fileContent.toString(); - return fileString ? JSON.parse(fileString) : {}; - } catch (error) { - console.error("Error reading memories:", error); - return {}; - } - } - - async getIceEdits(text: string): Promise { - debug("[ICE] Starting getIceEdits with text:", text); - // First tokenize by whitespace - const tokens = tokenizeText({ method: "whitespace", text }); - debug("[ICE] Initial tokens:", tokens); - - // Then clean each token of punctuation - const cleanTokens = tokens.map((token) => token.replace(/[.,!?;:]$/, "")); - debug("[ICE] Cleaned tokens:", cleanTokens); - - const iceSuggestions: IceSuggestion[] = []; - - // Process each word/token for ICE suggestions - for (let i = 0; i < cleanTokens.length; i++) { - const leftToken = i > 0 ? cleanTokens[i - 1] : ""; - const rightToken = i < cleanTokens.length - 1 ? cleanTokens[i + 1] : ""; - const currentToken = cleanTokens[i]; - const originalToken = tokens[i]; // Keep original for replacement - debug("[ICE] Processing token:", { currentToken, leftToken, rightToken }); - - const suggestions = await this.iceEdits.calculateSuggestions( - currentToken, - leftToken, - rightToken - ); - debug("[ICE] Got suggestions:", suggestions); - - suggestions.forEach((suggestion) => { - // Preserve the original punctuation when creating the suggestion - const punctuation = tokens[i].slice(currentToken.length); - iceSuggestions.push({ - text: originalToken, - replacement: suggestion.replacement + punctuation, - confidence: suggestion.confidence, - frequency: suggestion.frequency, - leftToken, - rightToken, - }); - }); - } - - debug("[ICE] Final suggestions:", iceSuggestions); - return iceSuggestions; - } -} diff --git a/src/smartEdits/types.ts b/src/smartEdits/types.ts deleted file mode 100644 index 468c0b147..000000000 --- a/src/smartEdits/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface TargetCell { - cellId: string; - targetContent: string; - id?: string; - score?: number; - sourceContent?: string; -} diff --git a/src/sqldb/index.ts b/src/sqldb/index.ts deleted file mode 100644 index c4ac38443..000000000 --- a/src/sqldb/index.ts +++ /dev/null @@ -1,601 +0,0 @@ -import * as vscode from "vscode"; -import { StatusBarItem } from "vscode"; -import initSqlJs, { Database, SqlJsStatic } from "fts5-sql-bundle"; -import path from "path"; -import { parseAndImportJSONL } from "./parseAndImportJSONL"; -import crypto from "crypto"; -import { DictionaryEntry } from "types"; - -export function getDefinitions(db: Database, headWord: string): string[] { - const stmt = db.prepare("SELECT definition FROM entries WHERE head_word = ?"); - stmt.bind([headWord]); - - const results: string[] = []; - while (stmt.step()) { - const row = stmt.getAsObject(); - if (row["definition"]) { - results.push(row["definition"] as string); - } - } - stmt.free(); - return results; -} - -const dictionaryDbPath = [".project", "dictionary.sqlite"]; - -export async function lookupWord(db: Database) { - try { - const word = await vscode.window.showInputBox({ prompt: "Enter a word to look up" }); - if (word) { - const definitions = getDefinitions(db, word); - if (definitions.length > 0) { - await vscode.window.showQuickPick(definitions, { - placeHolder: `Definitions for "${word}"`, - }); - } else { - vscode.window.showInformationMessage(`No definitions found for "${word}".`); - } - } - } catch (error) { - vscode.window.showErrorMessage(`An error occurred: ${(error as Error).message}`); - } -} - -export const initializeSqlJs = async (context: vscode.ExtensionContext) => { - // Initialize fts5-sql-bundle - let SQL: SqlJsStatic | undefined; - try { - const sqlWasmPath = vscode.Uri.joinPath(context.extensionUri, "out/node_modules/fts5-sql-bundle/dist/sql-wasm.wasm"); - - SQL = await initSqlJs({ - locateFile: (file: string) => { - - return sqlWasmPath.fsPath; - }, - }); - - if (!SQL) { - throw new Error("Failed to initialize fts5-sql-bundle"); - } - - - } catch (error) { - console.error("Error initializing fts5-sql-bundle:", error); - vscode.window.showErrorMessage(`Failed to initialize fts5-sql-bundle: ${error}`); - return; - } - - // Load or create the database file - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - return; - } - const dbPath = vscode.Uri.joinPath(workspaceFolder.uri, ...dictionaryDbPath); - - let fileBuffer: Uint8Array; - - try { - // NOTE: Use a stream to read the database file to avoid memory issues that can arise from large files and crashes the app - const fileContent = await vscode.workspace.fs.readFile(dbPath); - fileBuffer = fileContent; - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - const isFileNotFound = error instanceof vscode.FileSystemError && error.code === 'FileNotFound'; - const isCorruption = errorMessage.includes("database disk image is malformed") || - errorMessage.includes("file is not a database") || - errorMessage.includes("database is locked") || - errorMessage.includes("database corruption"); - - if (isFileNotFound) { - console.info("[Dictionary DB] Dictionary database file not found - creating new database"); - } else if (isCorruption) { - console.warn(`[Dictionary DB] Database corruption detected: ${errorMessage}`); - console.warn("[Dictionary DB] Deleting corrupt database and creating new one"); - - // Delete the corrupted database file - try { - await vscode.workspace.fs.delete(dbPath); - } catch (deleteError) { - console.warn("[Dictionary DB] Could not delete corrupted database file:", deleteError); - } - } else { - console.error("[Dictionary DB] Unexpected error reading dictionary file:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - dbPath: dbPath.fsPath, - errorType: error instanceof Error ? error.constructor.name : typeof error - }); - // For unexpected errors, still try to create a new database - console.info("[Dictionary DB] Attempting to create new database despite error"); - } - - // Create new database for all error cases (file not found, corruption, or unexpected errors) - const newDb = new SQL.Database(); - // Create your table structure - newDb.run(` - CREATE TABLE entries ( - id TEXT PRIMARY KEY, - head_word TEXT NOT NULL DEFAULT '', - definition TEXT, - is_user_entry INTEGER NOT NULL DEFAULT 0, - author_id TEXT, - createdAt TEXT NOT NULL DEFAULT (datetime('now')), - updatedAt TEXT NOT NULL DEFAULT (datetime('now')) - ); - - CREATE INDEX idx_entries_head_word ON entries(head_word); - `); - // Save the new database to file - fileBuffer = newDb.export(); - - // Ensure the .project directory exists - const projectDir = vscode.Uri.joinPath(workspaceFolder.uri, ".project"); - try { - await vscode.workspace.fs.createDirectory(projectDir); - } catch (dirError) { - // Directory might already exist, which is fine - console.debug("[Dictionary DB] .project directory already exists or could not be created:", dirError); - } - - // Write the new database file - try { - await vscode.workspace.fs.writeFile(dbPath, fileBuffer); - console.info("[Dictionary DB] New dictionary database created successfully"); - } catch (writeError) { - console.error("[Dictionary DB] Failed to write new database file:", { - error: writeError instanceof Error ? writeError.message : String(writeError), - stack: writeError instanceof Error ? writeError.stack : undefined, - dbPath: dbPath.fsPath - }); - // Don't return here - still try to use the in-memory database - } - } - - // Create/load the database - const db = new SQL.Database(fileBuffer); - - // After loading the database - try { - const columnCheckStmt = db.prepare("PRAGMA table_info(entries)"); - const columns = []; - while (columnCheckStmt.step()) { - const columnInfo = columnCheckStmt.getAsObject(); - columns.push(columnInfo.name); - } - columnCheckStmt.free(); - - if (!columns.includes("createdAt")) { - db.run("ALTER TABLE entries ADD COLUMN createdAt TEXT"); - db.run("UPDATE entries SET createdAt = datetime('now') WHERE createdAt IS NULL"); - } - if (!columns.includes("updatedAt")) { - db.run("ALTER TABLE entries ADD COLUMN updatedAt TEXT"); - db.run("UPDATE entries SET updatedAt = datetime('now') WHERE updatedAt IS NULL"); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - const isCorruption = errorMessage.includes("database disk image is malformed") || - errorMessage.includes("file is not a database") || - errorMessage.includes("database is locked") || - errorMessage.includes("database corruption"); - - if (isCorruption) { - console.error("[Dictionary DB] Database corruption detected during schema update:", errorMessage); - console.warn("[Dictionary DB] Recreating corrupted database"); - - // Recreate the database from scratch - const newDb = new SQL.Database(); - newDb.run(` - CREATE TABLE entries ( - id TEXT PRIMARY KEY, - head_word TEXT NOT NULL DEFAULT '', - definition TEXT, - is_user_entry INTEGER NOT NULL DEFAULT 0, - author_id TEXT, - createdAt TEXT NOT NULL DEFAULT (datetime('now')), - updatedAt TEXT NOT NULL DEFAULT (datetime('now')) - ); - - CREATE INDEX idx_entries_head_word ON entries(head_word); - `); - - // Save the new database to file - const newFileBuffer = newDb.export(); - await vscode.workspace.fs.writeFile(dbPath, newFileBuffer); - - - vscode.window.showWarningMessage("Dictionary database was corrupted and has been recreated. You may need to re-import your dictionary entries."); - - // Return the new database - return newDb; - } else { - console.error("Error checking/adding columns to entries table:", error); - vscode.window.showErrorMessage(`Failed to update database schema: ${error}`); - } - } - - return db; -}; - -export const registerLookupWordCommand = (db: Database, context: vscode.ExtensionContext) => { - const disposable = vscode.commands.registerCommand("extension.lookupWord", () => { - return lookupWord(db); - }); - context.subscriptions.push(disposable); -}; - -export const addWord = async ({ - db, - headWord, - definition, - authorId, - isUserEntry = true, -}: { - db: Database; - headWord: string; - definition: string; - authorId: string; - isUserEntry?: boolean; -}) => { - - const stmt = db.prepare( - `INSERT INTO entries (id, head_word, definition, is_user_entry, author_id, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now')) - ON CONFLICT(id) DO UPDATE SET - definition = excluded.definition, - is_user_entry = excluded.is_user_entry, - author_id = excluded.author_id, - updatedAt = datetime('now')` - ); - try { - const id = crypto.randomUUID(); - stmt.bind([id, headWord, definition, isUserEntry ? 1 : 0, authorId]); - stmt.step(); - - if (isUserEntry) { - await exportUserEntries(db); - } - } finally { - stmt.free(); - } -}; - -export const bulkAddWords = async (db: Database, entries: DictionaryEntry[]) => { - const stmt = db.prepare( - `INSERT INTO entries (id, head_word, definition, is_user_entry, author_id, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - definition = excluded.definition, - is_user_entry = excluded.is_user_entry, - author_id = excluded.author_id, - updatedAt = datetime('now')` - ); - try { - db.run("BEGIN TRANSACTION"); - entries.forEach((entry) => { - stmt.bind([ - entry.id, - entry.headWord, - entry.definition ?? "", - entry.isUserEntry ? 1 : 0, - entry.authorId ?? "", - entry.createdAt ?? "", - entry.updatedAt ?? "", - ]); - stmt.step(); - stmt.reset(); - }); - db.run("COMMIT"); - await saveDatabase(db); - } catch (error) { - db.run("ROLLBACK"); - throw error; - } finally { - stmt.free(); - } -}; - -export const getWords = (db: Database) => { - const stmt = db.prepare("SELECT head_word FROM entries"); - const words: string[] = []; - while (stmt.step()) { - words.push(stmt.getAsObject()["head_word"] as string); - } - stmt.free(); - return words; -}; - -export const getEntry = (db: Database, headWord: string, caseSensitive = false) => { - let query = "SELECT * FROM entries WHERE head_word = ?"; - if (!caseSensitive) { - query += " COLLATE NOCASE"; - } - const stmt = db.prepare(query); - stmt.bind([headWord]); - const entry = stmt.step(); - stmt.free(); - return entry; -}; - -export async function importWiktionaryJSONL(db: Database) { - try { - const options: vscode.OpenDialogOptions = { - canSelectMany: false, - openLabel: "Import", - filters: { - "JSONL files": ["jsonl"], - "All files": ["*"], - }, - }; - - const fileUri = await vscode.window.showOpenDialog(options); - - if (fileUri && fileUri[0]) { - const jsonlFilePath = fileUri[0].fsPath; - vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: "Importing Wiktionary JSONL", - cancellable: false, - }, - async (progress) => { - progress.report({ increment: 0, message: "Starting import..." }); - await parseAndImportJSONL(jsonlFilePath, db, (progressValue) => { - progress.report({ increment: progressValue * 100, message: "Importing..." }); - }); - progress.report({ increment: 100, message: "Import completed!" }); - const fileBuffer = db.export(); - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - return; - } - const dbPath = vscode.Uri.joinPath(workspaceFolder.uri, ...dictionaryDbPath); - await vscode.workspace.fs.writeFile(dbPath, fileBuffer); - vscode.window.showInformationMessage("Wiktionary JSONL import completed."); - } - ); - } else { - vscode.window.showWarningMessage("No file selected."); - } - } catch (error) { - vscode.window.showErrorMessage(`An error occurred: ${(error as Error).message}`); - } -} - -export const updateWord = async ({ - db, - id, - definition, - headWord, - authorId, - isUserEntry = true, -}: { - db: Database; - id: string; - definition: string; - headWord: string; - authorId: string; - isUserEntry?: boolean; -}) => { - - const stmt = db.prepare(` - UPDATE entries - SET head_word = ?, - definition = ?, - is_user_entry = ?, - author_id = ?, - updatedAt = datetime('now') - WHERE id = ? - `); - try { - stmt.bind([headWord, definition, isUserEntry ? 1 : 0, authorId, id]); - const result = stmt.step(); - const rowsModified = db.getRowsModified(); - if (rowsModified === 0) { - console.warn(`No rows were updated. Check if the id ${id} exists.`); - } - } catch (error) { - console.error("Error executing update statement:", error); - } finally { - stmt.free(); - } - if (isUserEntry) { - await exportUserEntries(db); - } -}; - -export const deleteWord = async ({ db, id }: { db: Database; id: string; }) => { - // First check if it's a user entry - const checkStmt = db.prepare("SELECT is_user_entry FROM entries WHERE id = ?"); - let isUserEntry = false; - try { - checkStmt.bind([id]); - if (checkStmt.step()) { - isUserEntry = !!checkStmt.get()[0]; - } - } finally { - checkStmt.free(); - } - - // Delete the entry - const deleteStmt = db.prepare("DELETE FROM entries WHERE id = ?"); - try { - deleteStmt.bind([id]); - deleteStmt.step(); - - // If it was a user entry, trigger export - if (isUserEntry) { - await exportUserEntries(db); - } - } finally { - deleteStmt.free(); - } -}; - -export const getPagedWords = ({ - db, - page, - pageSize, - searchQuery, -}: { - db: Database; - page: number; - pageSize: number; - searchQuery?: string; -}): { entries: DictionaryEntry[]; total: number; } => { - let total = 0; - const entries: DictionaryEntry[] = []; - - // Get total count - const countStmt = searchQuery - ? db.prepare("SELECT COUNT(*) as count FROM entries WHERE head_word LIKE ?") - : db.prepare("SELECT COUNT(*) as count FROM entries"); - - try { - if (searchQuery) { - countStmt.bind([`%${searchQuery}%`]); - } - countStmt.step(); - total = countStmt.getAsObject().count as number; - } finally { - countStmt.free(); - } - - // Get page of words - const offset = (page - 1) * pageSize; - const stmt = searchQuery - ? db.prepare(` - SELECT id, head_word, definition, is_user_entry, author_id - FROM entries - WHERE head_word LIKE ? - ORDER BY head_word - LIMIT ? OFFSET ?`) - : db.prepare(` - SELECT id, head_word, definition, is_user_entry, author_id - FROM entries - ORDER BY head_word - LIMIT ? OFFSET ?`); - - try { - if (searchQuery) { - stmt.bind([`%${searchQuery}%`, pageSize, offset]); - } else { - stmt.bind([pageSize, offset]); - } - - while (stmt.step()) { - const row = stmt.getAsObject(); - entries.push({ - id: row.id as string, - headWord: row.head_word as string, - definition: row.definition as string, - authorId: row.author_id as string, - isUserEntry: row.is_user_entry === 1, - }); - } - } finally { - stmt.free(); - } - - return { entries, total }; -}; - -export const exportUserEntries = async (db: Database) => { - const stmt = db.prepare( - "SELECT id, head_word, definition, author_id, is_user_entry, createdAt, updatedAt FROM entries WHERE is_user_entry = 1" - ); - const entries: DictionaryEntry[] = []; - - try { - while (stmt.step()) { - const row = stmt.getAsObject(); - entries.push({ - id: row.id as string, - headWord: row.head_word as string, - definition: row.definition as string, - authorId: row.author_id as string, - isUserEntry: row.is_user_entry === 1, - createdAt: row.createdAt as string, - updatedAt: row.updatedAt as string, - }); - } - } finally { - stmt.free(); - } - - // Convert entries to JSONL format - const jsonlContent = entries.map((entry) => JSON.stringify(entry)).join("\n"); - - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - return; - } - - // Ensure the files directory exists - const filesDir = vscode.Uri.joinPath(workspaceFolder.uri, "files"); - try { - await vscode.workspace.fs.createDirectory(filesDir); - } catch (error) { - // Directory might already exist, which is fine - - } - - const exportPath = vscode.Uri.joinPath(workspaceFolder.uri, "files", "project.dictionary"); - if (exportPath) { - // Export user entries to a file for persistence - await vscode.workspace.fs.writeFile(exportPath, Buffer.from(jsonlContent, "utf-8")); - } -}; - -export const ingestJsonlDictionaryEntries = async (db: Database) => { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - return; - } - const exportPath = vscode.Uri.joinPath(workspaceFolder.uri, "files", "project.dictionary"); - if (!exportPath) { - return; - } - - try { - // First check if the file exists - await vscode.workspace.fs.stat(exportPath); - - // If we get here, the file exists, so read it - const fileContent = await vscode.workspace.fs.readFile(exportPath); - const jsonlContent = new TextDecoder().decode(fileContent); - const entries = jsonlContent - .split("\n") - .filter((line: string) => line) - .map((line: string) => JSON.parse(line)); - - - await bulkAddWords(db, entries); - } catch (error) { - // Check if it's a file not found error - if (error instanceof vscode.FileSystemError && error.code === 'FileNotFound') { - - return; - } - console.error("Error reading dictionary file:", error); - } -}; - -// Function to save the database to file -export const saveDatabase = async (db: Database) => { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - console.error("Cannot save database: No workspace folder found."); - return; - } - const dbPath = vscode.Uri.joinPath(workspaceFolder.uri, ...dictionaryDbPath); - try { - const fileBuffer = db.export(); - await vscode.workspace.fs.writeFile(dbPath, fileBuffer); - - } catch (error) { - console.error("Error saving database:", error); - vscode.window.showErrorMessage(`Failed to save dictionary database: ${error}`); - } -}; diff --git a/src/sqldb/parseAndImportJSONL.ts b/src/sqldb/parseAndImportJSONL.ts deleted file mode 100644 index 1c6e834eb..000000000 --- a/src/sqldb/parseAndImportJSONL.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Database } from "fts5-sql-bundle"; -import * as vscode from "vscode"; -import { bulkAddWords } from "."; -import { DictionaryEntry } from "types"; -import crypto from "crypto"; -import { TextDecoder } from 'util'; - -interface WiktionaryEntry { - word: string; - senses: Array<{ - glosses: string[]; - }>; -} - -const generateId = () => { - return crypto.randomUUID(); -}; - -export async function parseAndImportJSONL( - filePath: string, - db: Database, - progressCallback?: (progress: number) => void -): Promise { - const wordsBuffer: DictionaryEntry[] = []; - const BATCH_SIZE = 1000; - let entryCount = 0; - - try { - // Read the entire file content - const fileUri = vscode.Uri.file(filePath); - const content = await vscode.workspace.fs.readFile(fileUri); - const text = new TextDecoder().decode(content); - - // Get file size for progress calculation - const stats = await vscode.workspace.fs.stat(fileUri); - const totalSize = stats.size; - let processedSize = 0; - - // Process the content line by line - const lines = text.split('\n'); - - for (const line of lines) { - if (!line.trim()) continue; - - try { - const entry: WiktionaryEntry = JSON.parse(line); - - // Combine all glosses into a single definition - const definitions = entry.senses - .flatMap((sense) => sense.glosses) - .filter((gloss) => gloss && !gloss.startsWith("Alternative form of")); - - if (definitions.length > 0) { - definitions.forEach((definition = "") => { - wordsBuffer.push({ - id: generateId(), - headWord: entry.word, - definition, - authorId: undefined, - isUserEntry: false, - }); - entryCount++; - }); - } - - // Insert in batches - if (wordsBuffer.length >= BATCH_SIZE) { - bulkAddWords(db, wordsBuffer); - wordsBuffer.length = 0; - } - - // Update progress - processedSize += line.length + 1; // +1 for newline - if (progressCallback) { - progressCallback(processedSize / totalSize); - } - } catch (err) { - console.error('Error processing line:', err); - continue; - } - } - - // Insert any remaining entries - if (wordsBuffer.length > 0) { - bulkAddWords(db, wordsBuffer); - } - } catch (err) { - console.error('Error reading file:', err); - throw err; - } -} diff --git a/src/stateStore.ts b/src/stateStore.ts index dc4a36ae2..d3a0c49c6 100644 --- a/src/stateStore.ts +++ b/src/stateStore.ts @@ -1,17 +1,7 @@ import * as vscode from "vscode"; -import { CellIdGlobalState, SelectedTextDataWithContext } from "../types"; +import { CellIdGlobalState } from "../types"; type StateStoreUpdate = - | { key: "cellId"; value: CellIdGlobalState } - | { key: "uri"; value: string | null } - | { key: "currentLineSelection"; value: SelectedTextDataWithContext } - | { key: "plainTextNotes"; value: string } - | { key: "apiKey"; value: string } - | { key: "verseRef"; value: { verseRef: string; uri: string } } - | { key: "cellId"; value: CellIdGlobalState } - | { - key: "sourceCellMap"; - value: { [k: string]: { content: string; versions: string[] } }; - }; + | { key: "cellId"; value: CellIdGlobalState }; type StateStoreKey = StateStoreUpdate["key"]; type StateStoreValue = Extract["value"]; diff --git a/src/test/suite/codexCellEditor.test.ts b/src/test/suite/codexCellEditor.test.ts index e10e3d8ce..b06b472a4 100644 --- a/src/test/suite/codexCellEditor.test.ts +++ b/src/test/suite/codexCellEditor.test.ts @@ -407,16 +407,6 @@ suite("CodexCellEditorProvider Test Suite", () => { const cellId = codexSubtitleContent.cells[0].metadata.id; const newContent = "Updated HTML content"; - // Stub command used by saveHtml handler so handler continues - const originalExecuteCommand_forSave = vscode.commands.executeCommand; - // @ts-expect-error test stub - vscode.commands.executeCommand = async (command: string, ...args: any[]) => { - if (command === "codex-smart-edits.recordIceEdit") { - return undefined; - } - return originalExecuteCommand_forSave(command, ...args); - }; - onDidReceiveMessageCallback!({ command: "saveHtml", content: { @@ -441,9 +431,6 @@ suite("CodexCellEditorProvider Test Suite", () => { "Document content should be updated after saveHtml message" ); - // Restore - vscode.commands.executeCommand = originalExecuteCommand_forSave; - // Test llmCompletion message — assert queueing behavior let queuedCellId: string | null = null; const originalAddCellToSingleCellQueue = (provider as any).addCellToSingleCellQueue; @@ -637,7 +624,7 @@ suite("CodexCellEditorProvider Test Suite", () => { ); }); - test("smart edit functionality updates cell content correctly", async () => { + test("saveHtml updates cell content correctly", async () => { const provider = new CodexCellEditorProvider(context); const document = await provider.openCustomDocument( tempUri, @@ -679,22 +666,14 @@ suite("CodexCellEditorProvider Test Suite", () => { // Mock cell content and edit history const cellId = codexSubtitleContent.cells[0].metadata.id; const originalDocCellValue = JSON.parse(document.getText()).cells.find((c: any) => c.metadata.id === cellId)?.value; - const smartEditResult = "This is the improved content after smart edit."; - - // Simulate saving the updated content (stub recordIceEdit) - const originalExecuteCommand2 = vscode.commands.executeCommand; - // @ts-expect-error test stub - vscode.commands.executeCommand = async (command: string, ...args: any[]) => { - if (command === "codex-smart-edits.recordIceEdit") { - return undefined; - } - return originalExecuteCommand2(command, ...args); - }; + const updatedContent = "This is the improved content after editing."; + + // Simulate saving the updated content onDidReceiveMessageCallback!({ command: "saveHtml", content: { cellMarkers: [cellId], - cellContent: smartEditResult, + cellContent: updatedContent, }, }); @@ -704,17 +683,14 @@ suite("CodexCellEditorProvider Test Suite", () => { let updatedValue: string | undefined; for (let i = 0; i < 5; i++) { await sleep(60); - const updatedContent = JSON.parse(document.getText()); - updatedValue = updatedContent.cells.find((c: any) => c.metadata.id === cellId)?.value; - if (updatedValue === smartEditResult) break; + const parsedContent = JSON.parse(document.getText()); + updatedValue = parsedContent.cells.find((c: any) => c.metadata.id === cellId)?.value; + if (updatedValue === updatedContent) break; } assert.ok( - updatedValue === smartEditResult || updatedValue === originalDocCellValue, + updatedValue === updatedContent || updatedValue === originalDocCellValue, "Cell content should eventually be updated or remain unchanged if async processing defers it" ); - - // Restore - vscode.commands.executeCommand = originalExecuteCommand2; }); test("validation button requires text even if audio exists", () => { diff --git a/src/test/suite/codexCellEditorProvider.test.ts b/src/test/suite/codexCellEditorProvider.test.ts index d422cbed3..a1b578b31 100644 --- a/src/test/suite/codexCellEditorProvider.test.ts +++ b/src/test/suite/codexCellEditorProvider.test.ts @@ -63,7 +63,7 @@ suite("CodexCellEditorProvider Test Suite", () => { // Stub background tasks to avoid side-effects and assert calls sinon.restore(); sinon.stub((CodexCellDocument as any).prototype, "addCellToIndexImmediately").callsFake(() => { }); - sinon.stub((CodexCellDocument as any).prototype, "syncAllCellsToDatabase").resolves(); + sinon.stub((CodexCellDocument as any).prototype, "syncDirtyCellsToDatabase").resolves(); sinon.stub((CodexCellDocument as any).prototype, "populateSourceCellMapFromIndex").resolves(); }); @@ -1088,16 +1088,6 @@ suite("CodexCellEditorProvider Test Suite", () => { const cellId = codexSubtitleContent.cells[0].metadata.id; const newContent = "Updated HTML content"; - // Stub command used by saveHtml handler - const originalExecuteCommand = vscode.commands.executeCommand; - // @ts-expect-error test stub - vscode.commands.executeCommand = async (command: string, ...args: any[]) => { - if (command === "codex-smart-edits.recordIceEdit") { - return undefined; - } - return originalExecuteCommand(command, ...args); - }; - onDidReceiveMessageCallback!({ command: "saveHtml", content: { @@ -1198,9 +1188,6 @@ suite("CodexCellEditorProvider Test Suite", () => { "providerUpdatesNotebookMetadataForWebview", ]; assert.ok(allowedAutoTypes.includes(postMessageCallback.type)); - - // Restore command stub - vscode.commands.executeCommand = originalExecuteCommand; }); test("text direction update should be reflected in the webview", async () => { @@ -1405,7 +1392,7 @@ suite("CodexCellEditorProvider Test Suite", () => { assert.strictEqual(lastEdit.value, 333); }); - test("smart edit functionality updates cell content correctly", async () => { + test("saveHtml updates cell content correctly", async () => { const provider = new CodexCellEditorProvider(context); const document = await provider.openCustomDocument( tempUri, @@ -1449,22 +1436,14 @@ suite("CodexCellEditorProvider Test Suite", () => { // Mock cell content and edit history const cellId = codexSubtitleContent.cells[0].metadata.id; const originalDocCellValue = JSON.parse(document.getText()).cells.find((c: any) => c.metadata.id === cellId)?.value; - const smartEditResult = "This is the improved content after smart edit."; - - // Simulate saving the updated content (stub recordIceEdit) - const originalExecuteCommand2 = vscode.commands.executeCommand; - // @ts-expect-error test stub - vscode.commands.executeCommand = async (command: string, ...args: any[]) => { - if (command === "codex-smart-edits.recordIceEdit") { - return undefined; - } - return originalExecuteCommand2(command, ...args); - }; + const updatedContent = "This is the improved content after editing."; + + // Simulate saving the updated content onDidReceiveMessageCallback!({ command: "saveHtml", content: { cellMarkers: [cellId], - cellContent: smartEditResult, + cellContent: updatedContent, }, }); @@ -1472,18 +1451,15 @@ suite("CodexCellEditorProvider Test Suite", () => { let updatedValue: string | undefined; for (let i = 0; i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 60)); - const updatedContent = JSON.parse(document.getText()); - updatedValue = updatedContent.cells.find((c: any) => c.metadata.id === cellId)?.value; - if (updatedValue === smartEditResult) break; + const parsedContent = JSON.parse(document.getText()); + updatedValue = parsedContent.cells.find((c: any) => c.metadata.id === cellId)?.value; + if (updatedValue === updatedContent) break; } // Accept either immediate update or unchanged value depending on async timing assert.ok( - updatedValue === smartEditResult || updatedValue === originalDocCellValue, + updatedValue === updatedContent || updatedValue === originalDocCellValue, "Cell content should eventually be updated or remain unchanged if async processing defers it" ); - - // Restore command stub - vscode.commands.executeCommand = originalExecuteCommand2; }); test("git commit is triggered on document save operations", async () => { @@ -2478,14 +2454,6 @@ suite("CodexCellEditorProvider Test Suite", () => { onDidChangeViewState: (_cb: any) => ({ dispose: () => { } }), } as any as vscode.WebviewPanel; - // Stub command used by saveHtml handler (recordIceEdit) - const originalExecuteCommand = vscode.commands.executeCommand; - // @ts-expect-error test stub - vscode.commands.executeCommand = async (command: string, ...args: any[]) => { - if (command === "codex-smart-edits.recordIceEdit") return undefined; - return originalExecuteCommand(command, ...args); - }; - // Gate saveCustomDocument so we can assert ack is only posted after it resolves const originalSaveCustomDocument = (provider as any).saveCustomDocument; let saveResolve!: () => void; @@ -2541,7 +2509,6 @@ suite("CodexCellEditorProvider Test Suite", () => { // Restore stubs (provider as any).saveCustomDocument = originalSaveCustomDocument; - vscode.commands.executeCommand = originalExecuteCommand; }); test("mergeMatchingCellsInTargetFile marks target current cell merged and logs merged edit", async function () { diff --git a/src/test/suite/editMapUtils.test.ts b/src/test/suite/editMapUtils.test.ts index 39a2e3388..4150b3d13 100644 --- a/src/test/suite/editMapUtils.test.ts +++ b/src/test/suite/editMapUtils.test.ts @@ -349,17 +349,6 @@ suite("editMapUtils Test Suite", () => { assert.deepStrictEqual(languagesEdit!.value, languagesValue, "Should have correct languages value"); }); - test("should use correct editMap for spellcheckIsEnabled", () => { - const metadata: { edits?: ProjectEditHistory<["spellcheckIsEnabled"]>[]; } = {}; - addProjectMetadataEdit(metadata, EditMapUtils.spellcheckIsEnabled(), true, "test-author"); - - const edits = metadata.edits!; - assert.ok(edits.some((e) => EditMapUtils.equals(e.editMap, EditMapUtils.spellcheckIsEnabled())), "Should have spellcheckIsEnabled editMap"); - const spellcheckEdit = edits.find((e) => EditMapUtils.equals(e.editMap, EditMapUtils.spellcheckIsEnabled())); - assert.ok(spellcheckEdit, "Should find spellcheckIsEnabled edit"); - assert.strictEqual(spellcheckEdit!.value, true, "Should have correct spellcheckIsEnabled value"); - }); - test("should deduplicate identical edits", () => { const metadata: { edits?: ProjectEditHistory<["projectName"]>[]; } = {}; const testProjectName = "Test Project"; @@ -382,7 +371,7 @@ suite("editMapUtils Test Suite", () => { assert.strictEqual(metadata.edits!.length, 2, "Should have two edits before deduplication"); // Add another edit which will trigger deduplication - addProjectMetadataEdit(metadata, EditMapUtils.spellcheckIsEnabled(), true, testAuthor); + addProjectMetadataEdit(metadata, EditMapUtils.languages(), ["en"], testAuthor); // Should have deduplicated the duplicate projectName edit const projectNameEdits = metadata.edits!.filter((e) => @@ -415,7 +404,7 @@ suite("editMapUtils Test Suite", () => { }); // Trigger deduplication - addProjectMetadataEdit(metadata, EditMapUtils.spellcheckIsEnabled(), true, testAuthor); + addProjectMetadataEdit(metadata, EditMapUtils.languages(), ["en"], testAuthor); const projectNameEdits = metadata.edits!.filter((e) => EditMapUtils.equals(e.editMap, EditMapUtils.projectName()) @@ -445,7 +434,7 @@ suite("editMapUtils Test Suite", () => { }); // Trigger deduplication - addProjectMetadataEdit(metadata, EditMapUtils.spellcheckIsEnabled(), true, testAuthor); + addProjectMetadataEdit(metadata, EditMapUtils.languages(), ["en"], testAuthor); const projectNameEdits = metadata.edits!.filter((e) => EditMapUtils.equals(e.editMap, EditMapUtils.projectName()) @@ -461,13 +450,13 @@ suite("editMapUtils Test Suite", () => { addProjectMetadataEdit(metadata, EditMapUtils.projectName(), "Test Project", testAuthor); addProjectMetadataEdit(metadata, EditMapUtils.languages(), ["en", "fr"], testAuthor); - addProjectMetadataEdit(metadata, EditMapUtils.spellcheckIsEnabled(), true, testAuthor); + addProjectMetadataEdit(metadata, EditMapUtils.metaField("validationCount"), 5, testAuthor); const edits = metadata.edits!; assert.strictEqual(edits.length, 3, "Should preserve edits with different editMaps"); assert.ok(edits.some((e) => EditMapUtils.equals(e.editMap, EditMapUtils.projectName())), "Should have projectName edit"); assert.ok(edits.some((e) => EditMapUtils.equals(e.editMap, EditMapUtils.languages())), "Should have languages edit"); - assert.ok(edits.some((e) => EditMapUtils.equals(e.editMap, EditMapUtils.spellcheckIsEnabled())), "Should have spellcheckIsEnabled edit"); + assert.ok(edits.some((e) => EditMapUtils.equals(e.editMap, EditMapUtils.metaField("validationCount"))), "Should have meta.validationCount edit"); }); }); diff --git a/src/test/suite/mergeStrategies.test.ts b/src/test/suite/mergeStrategies.test.ts index 09a8e8d34..1802eccd2 100644 --- a/src/test/suite/mergeStrategies.test.ts +++ b/src/test/suite/mergeStrategies.test.ts @@ -33,16 +33,6 @@ suite("Merge Strategies Test Suite", () => { const strategy2 = determineStrategy(commentsFile); assert.strictEqual(strategy2, ConflictResolutionStrategy.ARRAY); - // Test JSONL strategy - const dictionaryFile = "files/project.dictionary"; - const strategy3 = determineStrategy(dictionaryFile); - assert.strictEqual(strategy3, ConflictResolutionStrategy.JSONL); - - // Test SPECIAL strategy - const smartEditsFile = "files/smart_edits.json"; - const strategy4 = determineStrategy(smartEditsFile); - assert.strictEqual(strategy4, ConflictResolutionStrategy.SPECIAL); - // Test IGNORE strategy const completeDraftsFile = "complete_drafts.txt"; const strategy5 = determineStrategy(completeDraftsFile); diff --git a/src/test/suite/milestonePagination.test.ts b/src/test/suite/milestonePagination.test.ts index e68851355..07dd21b3d 100644 --- a/src/test/suite/milestonePagination.test.ts +++ b/src/test/suite/milestonePagination.test.ts @@ -34,7 +34,7 @@ suite("Milestone-Based Pagination Test Suite", () => { // Stub background tasks sinon.restore(); sinon.stub((CodexCellDocument as any).prototype, "addCellToIndexImmediately").callsFake(() => { }); - sinon.stub((CodexCellDocument as any).prototype, "syncAllCellsToDatabase").resolves(); + sinon.stub((CodexCellDocument as any).prototype, "syncDirtyCellsToDatabase").resolves(); sinon.stub((CodexCellDocument as any).prototype, "populateSourceCellMapFromIndex").resolves(); }); diff --git a/src/test/suite/nativeSqliteDatabase.test.ts b/src/test/suite/nativeSqliteDatabase.test.ts new file mode 100644 index 000000000..7b2f38213 --- /dev/null +++ b/src/test/suite/nativeSqliteDatabase.test.ts @@ -0,0 +1,2602 @@ +/** + * Comprehensive test suite for the native SQLite database layer. + * + * Tests the AsyncDatabase wrapper (nativeSqlite.ts) and the schema/operations + * used by SQLiteIndexManager (sqliteIndex.ts) to verify that the @vscode/sqlite3 + * native binary works correctly across creation, CRUD, FTS5 search, transactions, + * re-indexing, and edge cases. + * + * All tests use in-memory databases (":memory:") for speed and isolation. + * + * Bootstrap strategy: the native binary may not be initialised when the test + * runner starts (e.g. the test VS Code instance has a fresh user-data dir and + * the extension's download can fail). We search for the binary in the real + * VS Code / Codex global-storage directories and call initNativeSqlite() + * ourselves before any database tests run. + */ + +import * as assert from "assert"; +import { + AsyncDatabase, + initNativeSqlite, + isNativeSqliteReady, + RunResult, +} from "../../utils/nativeSqlite"; +import { + CURRENT_SCHEMA_VERSION, + CREATE_TABLES_SQL, + CREATE_INDEXES_SQL, + CREATE_DEFERRED_INDEXES_SQL, + CREATE_SCHEMA_INFO_SQL, + ALL_TRIGGERS, +} from "../../activationHelpers/contextAware/contentIndexes/indexes/schema"; +// ── Real Node.js builtins ─────────────────────────────────────────────────── +// +// Webpack replaces `fs`, `os`, `path`, and `crypto` with browser polyfills +// (memfs, os-browserify, crypto-browserify, etc.) in the test bundle. +// We need the *real* Node.js modules for filesystem access and hashing. +// The `eval("require")` trick bypasses webpack's module resolution — +// the same approach nativeSqlite.ts uses to load the .node addon. +// +// eslint-disable-next-line no-eval +const nodeRequire = eval("require") as NodeRequire; +const realFs: typeof import("fs") = nodeRequire("fs"); +const realOs: typeof import("os") = nodeRequire("os"); +const realPath: typeof import("path") = nodeRequire("path"); +const realCrypto: typeof import("crypto") = nodeRequire("crypto"); +// Webpack's ProvidePlugin replaces `process` with `process/browser` which +// reports platform as "browser" instead of "darwin"/"linux"/"win32". +// We need the real Node.js process object. +const realProcess: NodeJS.Process = nodeRequire("process"); + +// ── Native binary bootstrap ───────────────────────────────────────────────── + +const EXTENSION_ID = "project-accelerate.codex-editor-extension"; +const BINARY_NAME = "node_sqlite3.node"; + +/** + * Search for the node_sqlite3.node binary in the global-storage directories + * of every VS Code variant the developer might use. + */ +function findNativeBinary(): string | null { + const home = realOs.homedir(); + console.log(`[NativeSQLite Test] homedir = ${home}`); + + // App-data base directory differs per platform + const bases: string[] = []; + if (realProcess.platform === "darwin") { + bases.push(realPath.join(home, "Library", "Application Support")); + } else if (realProcess.platform === "linux") { + bases.push(realPath.join(home, ".config")); + } else if (realProcess.platform === "win32") { + bases.push(realProcess.env.APPDATA ?? realPath.join(home, "AppData", "Roaming")); + } + + // VS Code variant folder names (covers Codex fork, stable, insiders, OSS) + const variants = ["Codex", "Code", "Code - Insiders", "code-oss", "VSCodium"]; + + for (const base of bases) { + for (const variant of variants) { + const candidate = realPath.join( + base, + variant, + "User", + "globalStorage", + EXTENSION_ID, + "sqlite3-native", + BINARY_NAME + ); + try { + if (realFs.existsSync(candidate) && realFs.statSync(candidate).size > 500_000) { + console.log(`[NativeSQLite Test] Found binary: ${candidate}`); + return candidate; + } + } catch { + // Skip inaccessible paths + } + } + } + + // Also check the .vscode-test directory (test-electron may cache here) + try { + const vscodeTestDir = realPath.join(home, ".vscode-test"); + if (realFs.existsSync(vscodeTestDir)) { + const found = findFileRecursive(vscodeTestDir, BINARY_NAME, 3); + if (found) { + return found; + } + } + } catch { + // Non-critical + } + + console.warn(`[NativeSQLite Test] Binary not found in any searched location`); + return null; +} + +/** Shallow recursive file search (limited depth to keep it fast). */ +function findFileRecursive(dir: string, target: string, maxDepth: number): string | null { + if (maxDepth <= 0) { + return null; + } + try { + for (const entry of realFs.readdirSync(dir, { withFileTypes: true })) { + const full = realPath.join(dir, entry.name); + if (entry.isFile() && entry.name === target) { + if (realFs.statSync(full).size > 500_000) { + return full; + } + } else if (entry.isDirectory() && !entry.name.startsWith(".")) { + const found = findFileRecursive(full, target, maxDepth - 1); + if (found) { + return found; + } + } + } + } catch { + // Permission errors etc. + } + return null; +} + +/** + * Ensure the native SQLite binding is loaded before any tests run. + * Returns true if ready, false if the binary could not be found. + */ +function bootstrapNativeSqlite(): boolean { + if (isNativeSqliteReady()) { + return true; + } + + const binaryPath = findNativeBinary(); + if (!binaryPath) { + console.warn( + "[NativeSQLite Test] Could not find node_sqlite3.node binary. " + + "Run the extension once in normal mode to download it, then re-run tests." + ); + return false; + } + + console.log(`[NativeSQLite Test] Bootstrapping native binary from: ${binaryPath}`); + initNativeSqlite(binaryPath); + return isNativeSqliteReady(); +} + +// ── Schema helpers ────────────────────────────────────────────────────────── +// +// All schema SQL is imported from the shared schema.ts module (single source +// of truth for both production and tests). See: +// src/activationHelpers/contextAware/contentIndexes/indexes/schema.ts + +// ── Utility helpers ───────────────────────────────────────────────────────── + +const computeHash = (content: string): string => + realCrypto.createHash("sha256").update(content).digest("hex"); + +/** + * Open an in-memory database and apply the full production schema. + * Uses the shared schema constants so tests always run against the exact + * same DDL as production. + * Returns a ready-to-use AsyncDatabase instance. + */ +async function openTestDatabase(): Promise { + const db = await AsyncDatabase.open(":memory:"); + + // Apply PRAGMAs (simplified for in-memory) + await db.exec("PRAGMA journal_mode = MEMORY"); + await db.exec("PRAGMA synchronous = OFF"); + await db.exec("PRAGMA foreign_keys = ON"); + + // Create tables + indexes (shared with production) + await db.exec(CREATE_TABLES_SQL); + await db.exec(CREATE_INDEXES_SQL); + + // Create all triggers — timestamp + FTS (each is a separate statement) + for (const trigger of ALL_TRIGGERS) { + await db.run(trigger); + } + + // Schema info table (shared with production — includes project_id/project_name) + await db.run(CREATE_SCHEMA_INFO_SQL); + await db.run( + "INSERT INTO schema_info (id, version, project_id, project_name) VALUES (1, ?, NULL, NULL)", + [CURRENT_SCHEMA_VERSION] + ); + + return db; +} + +/** Insert a file record and return its auto-generated id */ +async function insertFile( + db: AsyncDatabase, + filePath: string, + fileType: "source" | "codex", + lastModifiedMs: number = Date.now() +): Promise { + const contentHash = computeHash(filePath + lastModifiedMs); + const result = await db.get<{ id: number }>( + `INSERT INTO files (file_path, file_type, last_modified_ms, content_hash) + VALUES (?, ?, ?, ?) + ON CONFLICT(file_path) DO UPDATE SET + last_modified_ms = excluded.last_modified_ms, + content_hash = excluded.content_hash + RETURNING id`, + [filePath, fileType, lastModifiedMs, contentHash] + ); + return result?.id ?? 0; +} + +/** Insert a cell with source or target content */ +async function insertCell( + db: AsyncDatabase, + cellId: string, + opts: { + cellType?: string; + sFileId?: number; + sContent?: string; + sRawContent?: string; + sLineNumber?: number; + tFileId?: number; + tContent?: string; + tRawContent?: string; + tLineNumber?: number; + milestoneIndex?: number; + } = {} +): Promise { + const sRawContent = opts.sRawContent ?? opts.sContent ?? null; + const tRawContent = opts.tRawContent ?? opts.tContent ?? null; + const sHash = sRawContent ? computeHash(sRawContent) : null; + const tHash = tRawContent ? computeHash(tRawContent) : null; + const sWordCount = opts.sContent + ? opts.sContent.split(/\s+/).filter((w) => w.length > 0).length + : 0; + const tWordCount = opts.tContent + ? opts.tContent.split(/\s+/).filter((w) => w.length > 0).length + : 0; + + return db.run( + `INSERT INTO cells ( + cell_id, cell_type, + s_file_id, s_content, s_raw_content, s_raw_content_hash, s_line_number, s_word_count, + t_file_id, t_content, t_raw_content, t_raw_content_hash, t_line_number, t_word_count, + milestone_index + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + cellId, + opts.cellType ?? null, + opts.sFileId ?? null, + opts.sContent ?? null, + sRawContent, + sHash, + opts.sLineNumber ?? null, + sWordCount, + opts.tFileId ?? null, + opts.tContent ?? null, + tRawContent, + tHash, + opts.tLineNumber ?? null, + tWordCount, + opts.milestoneIndex ?? null, + ] + ); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Test Suites +// ═══════════════════════════════════════════════════════════════════════════ + +suite("Native SQLite Database Tests", function () { + // Allow generous timeout for database operations + this.timeout(30_000); + + // ── Bootstrap ─────────────────────────────────────────────────────── + + let nativeReady = false; + + suiteSetup(function () { + nativeReady = bootstrapNativeSqlite(); + if (!nativeReady) { + console.warn("[NativeSQLite Test] Skipping all database tests — native binary not available."); + } + }); + + /** Guard that skips the current test when the native binary is unavailable. */ + function skipIfNotReady(ctx: Mocha.Context): void { + if (!nativeReady) { + ctx.skip(); + } + } + + // ── Pre-flight check ──────────────────────────────────────────────── + + test("native SQLite binding is initialized", function () { + if (!nativeReady) { + // In CI or environments without the pre-downloaded binary, skip + // gracefully instead of failing the entire test run. + this.skip(); + } + assert.strictEqual(isNativeSqliteReady(), true); + }); + + // ── 1. Database creation & schema ─────────────────────────────────── + + suite("Database Creation & Schema", () => { + let db: AsyncDatabase; + + setup(async function () { + skipIfNotReady(this); + db = await openTestDatabase(); + }); + + teardown(async () => { + if (db) { + await db.close(); + } + }); + + test("opens an in-memory database", async () => { + assert.ok(db, "AsyncDatabase.open(':memory:') should return a database"); + }); + + test("creates all required tables", async () => { + const tables = await db.all<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ); + const tableNames = tables.map((t) => t.name); + + const expected = ["cells", "cells_fts", "files", "schema_info", "sync_metadata", "words"]; + for (const table of expected) { + assert.ok(tableNames.includes(table), `Missing table: ${table}`); + } + }); + + test("creates cells table with all expected columns", async () => { + const columns = await db.all<{ name: string }>("PRAGMA table_info(cells)"); + const columnNames = columns.map((c) => c.name); + + const expected = [ + "cell_id", + "cell_type", + "s_file_id", + "s_content", + "s_raw_content_hash", + "s_line_number", + "s_word_count", + "s_raw_content", + "s_created_at", + "s_updated_at", + "t_file_id", + "t_content", + "t_raw_content_hash", + "t_line_number", + "t_word_count", + "t_raw_content", + "t_created_at", + "t_current_edit_timestamp", + "t_validation_count", + "t_validated_by", + "t_is_fully_validated", + "t_audio_validation_count", + "t_audio_validated_by", + "t_audio_is_fully_validated", + "milestone_index", + ]; + + for (const col of expected) { + assert.ok(columnNames.includes(col), `Missing column in cells: ${col}`); + } + }); + + test("creates all expected indexes", async () => { + const indexes = await db.all<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'" + ); + const indexNames = indexes.map((i) => i.name); + + const expected = [ + "idx_sync_metadata_path", + "idx_files_path", + "idx_cells_s_file_id", + "idx_cells_t_file_id", + "idx_cells_milestone_index", + ]; + + for (const idx of expected) { + assert.ok(indexNames.includes(idx), `Missing index: ${idx}`); + } + }); + + test("creates deferred indexes", async () => { + await db.exec(CREATE_DEFERRED_INDEXES_SQL); + + const indexes = await db.all<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'" + ); + const indexNames = indexes.map((i) => i.name); + + const deferredIndexes = [ + "idx_sync_metadata_hash", + "idx_sync_metadata_modified", + "idx_cells_s_content_hash", + "idx_cells_t_content_hash", + "idx_cells_t_is_fully_validated", + "idx_cells_t_current_edit_timestamp", + "idx_cells_t_validation_count", + "idx_cells_t_audio_is_fully_validated", + "idx_cells_t_audio_validation_count", + "idx_words_word", + "idx_words_cell_id", + ]; + + for (const idx of deferredIndexes) { + assert.ok(indexNames.includes(idx), `Missing deferred index: ${idx}`); + } + }); + + test("creates all triggers (timestamp + FTS)", async () => { + const triggers = await db.all<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type='trigger'" + ); + const triggerNames = triggers.map((t) => t.name); + + const expected = [ + // Timestamp auto-update triggers + "update_sync_metadata_timestamp", + "update_files_timestamp", + "update_cells_s_timestamp", + // FTS sync triggers + "cells_fts_source_insert", + "cells_fts_target_insert", + "cells_fts_source_update", + "cells_fts_target_update", + "cells_fts_delete", + ]; + + for (const trig of expected) { + assert.ok(triggerNames.includes(trig), `Missing trigger: ${trig}`); + } + }); + + test("FTS5 virtual table is queryable", async () => { + // Should not throw + const rows = await db.all("SELECT * FROM cells_fts LIMIT 0"); + assert.ok(Array.isArray(rows), "cells_fts should be queryable"); + }); + + test("schema_info table stores version correctly", async () => { + const row = await db.get<{ version: number }>( + "SELECT version FROM schema_info WHERE id = 1" + ); + assert.strictEqual(row?.version, CURRENT_SCHEMA_VERSION, `Schema version should be ${CURRENT_SCHEMA_VERSION}`); + }); + }); + + // ── 2. File CRUD ──────────────────────────────────────────────────── + + suite("File CRUD Operations", () => { + let db: AsyncDatabase; + + setup(async function () { + skipIfNotReady(this); + db = await openTestDatabase(); + }); + + teardown(async () => { + if (db) { await db.close(); } + }); + + test("inserts a new file and returns its id", async () => { + const id = await insertFile(db, "/project/GEN.source", "source"); + assert.ok(id > 0, "File id should be a positive integer"); + }); + + test("upsert on conflict updates and returns same id", async () => { + const id1 = await insertFile(db, "/project/GEN.source", "source", 1000); + const id2 = await insertFile(db, "/project/GEN.source", "source", 2000); + assert.strictEqual(id1, id2, "Upsert should return same id for same file_path"); + }); + + test("inserts multiple files with different types", async () => { + const sourceId = await insertFile(db, "/project/GEN.source", "source"); + const codexId = await insertFile(db, "/project/GEN.codex", "codex"); + assert.notStrictEqual(sourceId, codexId, "Different files should get different ids"); + }); + + test("rejects invalid file_type", async () => { + try { + await db.run( + "INSERT INTO files (file_path, file_type, last_modified_ms, content_hash) VALUES (?, ?, ?, ?)", + ["/bad.txt", "invalid", Date.now(), "abc"] + ); + assert.fail("Should have thrown for invalid file_type"); + } catch (err: any) { + assert.ok( + err.message.includes("CHECK") || err.message.includes("constraint"), + `Expected CHECK constraint error, got: ${err.message}` + ); + } + }); + + test("deletes a file", async () => { + const id = await insertFile(db, "/project/to-delete.source", "source"); + const result = await db.run("DELETE FROM files WHERE id = ?", [id]); + assert.strictEqual(result.changes, 1, "One row should be deleted"); + + const row = await db.get("SELECT * FROM files WHERE id = ?", [id]); + assert.strictEqual(row, undefined, "File should no longer exist"); + }); + }); + + // ── 3. Cell CRUD ──────────────────────────────────────────────────── + + suite("Cell CRUD Operations", () => { + let db: AsyncDatabase; + let sourceFileId: number; + let targetFileId: number; + + setup(async function () { + skipIfNotReady(this); + db = await openTestDatabase(); + sourceFileId = await insertFile(db, "/project/GEN.source", "source"); + targetFileId = await insertFile(db, "/project/GEN.codex", "codex"); + }); + + teardown(async () => { + if (db) { await db.close(); } + }); + + test("inserts a source cell", async () => { + await insertCell(db, "GEN 1:1", { + sFileId: sourceFileId, + sContent: "In the beginning God created the heavens and the earth.", + sLineNumber: 1, + }); + + const row = await db.get<{ cell_id: string; s_content: string }>( + "SELECT cell_id, s_content FROM cells WHERE cell_id = ?", + ["GEN 1:1"] + ); + assert.strictEqual(row?.cell_id, "GEN 1:1"); + assert.ok(row?.s_content?.includes("beginning")); + }); + + test("inserts a target cell", async () => { + await insertCell(db, "GEN 1:1", { + tFileId: targetFileId, + tContent: "En el principio Dios creó los cielos y la tierra.", + tLineNumber: 1, + }); + + const row = await db.get<{ t_content: string }>( + "SELECT t_content FROM cells WHERE cell_id = ?", + ["GEN 1:1"] + ); + assert.ok(row?.t_content?.includes("principio")); + }); + + test("updates existing cell via ON CONFLICT (upsert)", async () => { + await insertCell(db, "GEN 1:2", { + sFileId: sourceFileId, + sContent: "Original content", + }); + + // Upsert with updated content + await db.run( + `INSERT INTO cells (cell_id, s_content, s_raw_content) + VALUES (?, ?, ?) + ON CONFLICT(cell_id) DO UPDATE SET + s_content = excluded.s_content, + s_raw_content = excluded.s_raw_content`, + ["GEN 1:2", "Updated content", "Updated content"] + ); + + const row = await db.get<{ s_content: string }>( + "SELECT s_content FROM cells WHERE cell_id = ?", + ["GEN 1:2"] + ); + assert.strictEqual(row?.s_content, "Updated content"); + }); + + test("deletes a single cell", async () => { + await insertCell(db, "GEN 1:3", { + sFileId: sourceFileId, + sContent: "Test content to delete", + }); + + const result = await db.run("DELETE FROM cells WHERE cell_id = ?", ["GEN 1:3"]); + assert.strictEqual(result.changes, 1); + + const row = await db.get("SELECT * FROM cells WHERE cell_id = ?", ["GEN 1:3"]); + assert.strictEqual(row, undefined); + }); + + test("deletes all cells for a file", async () => { + // Insert multiple cells linked to source file + await insertCell(db, "GEN 1:1", { sFileId: sourceFileId, sContent: "Verse 1" }); + await insertCell(db, "GEN 1:2", { sFileId: sourceFileId, sContent: "Verse 2" }); + await insertCell(db, "GEN 1:3", { sFileId: sourceFileId, sContent: "Verse 3" }); + + const result = await db.run("DELETE FROM cells WHERE s_file_id = ?", [sourceFileId]); + assert.strictEqual(result.changes, 3); + }); + + test("word count is computed correctly", async () => { + const content = "In the beginning God created the heavens and the earth"; + const wordCount = content.split(/\s+/).filter((w) => w.length > 0).length; + + await insertCell(db, "GEN 1:1", { + sFileId: sourceFileId, + sContent: content, + }); + + const row = await db.get<{ s_word_count: number }>( + "SELECT s_word_count FROM cells WHERE cell_id = ?", + ["GEN 1:1"] + ); + assert.strictEqual(row?.s_word_count, wordCount); + }); + + test("milestone_index is stored and queryable", async () => { + await insertCell(db, "GEN 1:1", { + sFileId: sourceFileId, + sContent: "verse one", + milestoneIndex: 0, + }); + await insertCell(db, "GEN 1:2", { + sFileId: sourceFileId, + sContent: "verse two", + milestoneIndex: 1, + }); + await insertCell(db, "GEN 1:3", { + sFileId: sourceFileId, + sContent: "verse three", + milestoneIndex: 2, + }); + + const rows = await db.all<{ cell_id: string }>( + "SELECT cell_id FROM cells WHERE milestone_index = ?", + [1] + ); + assert.strictEqual(rows.length, 1); + assert.strictEqual(rows[0].cell_id, "GEN 1:2"); + }); + + test("validation fields are stored correctly", async () => { + await db.run( + `INSERT INTO cells (cell_id, t_content, t_raw_content, t_validation_count, t_validated_by, t_is_fully_validated) + VALUES (?, ?, ?, ?, ?, ?)`, + ["GEN 1:1", "translated", "translated", 2, "alice,bob", 1] + ); + + const row = await db.get<{ + t_validation_count: number; + t_validated_by: string; + t_is_fully_validated: number; + }>("SELECT t_validation_count, t_validated_by, t_is_fully_validated FROM cells WHERE cell_id = ?", [ + "GEN 1:1", + ]); + + assert.strictEqual(row?.t_validation_count, 2); + assert.strictEqual(row?.t_validated_by, "alice,bob"); + assert.strictEqual(row?.t_is_fully_validated, 1); + }); + + test("audio validation fields are stored correctly", async () => { + await db.run( + `INSERT INTO cells (cell_id, t_content, t_raw_content, + t_audio_validation_count, t_audio_validated_by, t_audio_is_fully_validated) + VALUES (?, ?, ?, ?, ?, ?)`, + ["GEN 1:1", "content", "content", 1, "charlie", 1] + ); + + const row = await db.get<{ + t_audio_validation_count: number; + t_audio_validated_by: string; + t_audio_is_fully_validated: number; + }>( + `SELECT t_audio_validation_count, t_audio_validated_by, t_audio_is_fully_validated + FROM cells WHERE cell_id = ?`, + ["GEN 1:1"] + ); + + assert.strictEqual(row?.t_audio_validation_count, 1); + assert.strictEqual(row?.t_audio_validated_by, "charlie"); + assert.strictEqual(row?.t_audio_is_fully_validated, 1); + }); + }); + + // ── 4. FTS5 Full-Text Search ──────────────────────────────────────── + + suite("FTS5 Full-Text Search", () => { + let db: AsyncDatabase; + let sourceFileId: number; + let targetFileId: number; + + setup(async function () { + skipIfNotReady(this); + db = await openTestDatabase(); + sourceFileId = await insertFile(db, "/project/GEN.source", "source"); + targetFileId = await insertFile(db, "/project/GEN.codex", "codex"); + }); + + teardown(async () => { + if (db) { await db.close(); } + }); + + test("trigger auto-indexes source content into FTS on INSERT", async () => { + await insertCell(db, "GEN 1:1", { + sFileId: sourceFileId, + sContent: "In the beginning God created the heavens and the earth.", + }); + + const ftsRows = await db.all<{ cell_id: string; content_type: string }>( + "SELECT cell_id, content_type FROM cells_fts WHERE cell_id = ?", + ["GEN 1:1"] + ); + assert.ok(ftsRows.length >= 1, "FTS should have an entry after insert"); + assert.ok( + ftsRows.some((r) => r.content_type === "source"), + "Should have a 'source' FTS entry" + ); + }); + + test("trigger auto-indexes target content into FTS on INSERT", async () => { + await insertCell(db, "GEN 1:1", { + tFileId: targetFileId, + tContent: "En el principio Dios creó los cielos y la tierra.", + }); + + const ftsRows = await db.all<{ content_type: string }>( + "SELECT content_type FROM cells_fts WHERE cell_id = ?", + ["GEN 1:1"] + ); + assert.ok( + ftsRows.some((r) => r.content_type === "target"), + "Should have a 'target' FTS entry" + ); + }); + + test("FTS MATCH query finds source content", async () => { + await insertCell(db, "GEN 1:1", { + sFileId: sourceFileId, + sContent: "In the beginning God created the heavens and the earth.", + }); + await insertCell(db, "GEN 1:2", { + sFileId: sourceFileId, + sContent: "And the earth was without form, and void.", + }); + + const results = await db.all<{ cell_id: string; content: string }>( + `SELECT cell_id, content FROM cells_fts WHERE cells_fts MATCH ? ORDER BY rank`, + ["beginning"] + ); + assert.ok(results.length >= 1, "Should find at least one result for 'beginning'"); + assert.ok( + results.some((r) => r.cell_id === "GEN 1:1"), + "GEN 1:1 should match 'beginning'" + ); + }); + + test("FTS MATCH with BM25 ranking", async () => { + await insertCell(db, "GEN 1:1", { + sFileId: sourceFileId, + sContent: "In the beginning God created the heavens and the earth.", + }); + await insertCell(db, "GEN 1:2", { + sFileId: sourceFileId, + sContent: "And the earth was without form, and void.", + }); + await insertCell(db, "GEN 1:3", { + sFileId: sourceFileId, + sContent: "And God said, Let there be light: and there was light.", + }); + + const results = await db.all<{ cell_id: string; score: number }>( + `SELECT cell_id, bm25(cells_fts) as score FROM cells_fts + WHERE cells_fts MATCH ? ORDER BY score ASC`, + ["earth"] + ); + assert.ok(results.length >= 2, "Should find at least 2 results for 'earth'"); + }); + + test("FTS wildcard search with prefix matching", async () => { + await insertCell(db, "GEN 1:1", { + sFileId: sourceFileId, + sContent: "In the beginning God created the heavens and the earth.", + }); + + const results = await db.all<{ cell_id: string }>( + `SELECT cell_id FROM cells_fts WHERE cells_fts MATCH ?`, + ["begin*"] + ); + assert.ok(results.length >= 1, "Wildcard 'begin*' should match 'beginning'"); + }); + + test("FTS column-scoped search", async () => { + await insertCell(db, "GEN 1:1", { + sFileId: sourceFileId, + sContent: "In the beginning God created the heavens and the earth.", + }); + + const results = await db.all<{ cell_id: string }>( + `SELECT cell_id FROM cells_fts WHERE cells_fts MATCH ?`, + ["content: beginning"] + ); + assert.ok(results.length >= 1, "Column-scoped search should work"); + }); + + test("FTS trigger removes entry on DELETE", async () => { + await insertCell(db, "GEN 1:1", { + sFileId: sourceFileId, + sContent: "Delete me from FTS", + }); + + // Verify FTS has the entry + let ftsCount = await db.get<{ count: number }>( + "SELECT COUNT(*) as count FROM cells_fts WHERE cell_id = ?", + ["GEN 1:1"] + ); + assert.ok((ftsCount?.count ?? 0) > 0, "FTS should have entry before delete"); + + // Delete the cell + await db.run("DELETE FROM cells WHERE cell_id = ?", ["GEN 1:1"]); + + // Verify FTS entry is removed + ftsCount = await db.get<{ count: number }>( + "SELECT COUNT(*) as count FROM cells_fts WHERE cell_id = ?", + ["GEN 1:1"] + ); + assert.strictEqual(ftsCount?.count, 0, "FTS entry should be removed after delete"); + }); + + test("FTS rebuild command works", async () => { + await insertCell(db, "GEN 1:1", { + sFileId: sourceFileId, + sContent: "Content for FTS rebuild test", + }); + + // Force FTS rebuild + await db.run("INSERT INTO cells_fts(cells_fts) VALUES('rebuild')"); + + // Verify it still works after rebuild + const results = await db.all<{ cell_id: string }>( + "SELECT cell_id FROM cells_fts WHERE cells_fts MATCH ?", + ["rebuild"] + ); + assert.ok(results.length >= 1, "FTS should still work after rebuild"); + }); + + test("FTS optimize command works", async () => { + await insertCell(db, "GEN 1:1", { + sFileId: sourceFileId, + sContent: "Content for FTS optimize test", + }); + + // Should not throw + await db.run("INSERT INTO cells_fts(cells_fts) VALUES('optimize')"); + }); + + test("manual FTS sync (INSERT OR REPLACE)", async () => { + // This mirrors upsertCellWithFTSSync's manual FTS sync + await insertCell(db, "GEN 1:1", { + sFileId: sourceFileId, + sContent: "Original source content", + }); + + // Manual FTS sync + await db.run( + `INSERT OR REPLACE INTO cells_fts(cell_id, content, raw_content, content_type) + VALUES (?, ?, ?, ?)`, + ["GEN 1:1", "Manually synced content", "Manually synced content", "source"] + ); + + const results = await db.all<{ content: string }>( + "SELECT content FROM cells_fts WHERE cell_id = ? AND content_type = 'source'", + ["GEN 1:1"] + ); + assert.ok(results.length >= 1, "Manual FTS sync should succeed"); + }); + }); + + // ── 5. Transactions ───────────────────────────────────────────────── + + suite("Transactions", () => { + let db: AsyncDatabase; + + setup(async function () { + skipIfNotReady(this); + db = await openTestDatabase(); + }); + + teardown(async () => { + if (db) { await db.close(); } + }); + + test("committed transaction persists data", async () => { + await db.run("BEGIN TRANSACTION"); + await db.run( + "INSERT INTO files (file_path, file_type, last_modified_ms, content_hash) VALUES (?, ?, ?, ?)", + ["/commit-test.source", "source", Date.now(), "hash1"] + ); + await db.run("COMMIT"); + + const row = await db.get<{ file_path: string }>( + "SELECT file_path FROM files WHERE file_path = ?", + ["/commit-test.source"] + ); + assert.strictEqual(row?.file_path, "/commit-test.source"); + }); + + test("rolled-back transaction discards data", async () => { + await db.run("BEGIN TRANSACTION"); + await db.run( + "INSERT INTO files (file_path, file_type, last_modified_ms, content_hash) VALUES (?, ?, ?, ?)", + ["/rollback-test.source", "source", Date.now(), "hash2"] + ); + await db.run("ROLLBACK"); + + const row = await db.get( + "SELECT file_path FROM files WHERE file_path = ?", + ["/rollback-test.source"] + ); + assert.strictEqual(row, undefined, "Rolled-back data should not persist"); + }); + + test("runInTransaction-style commit pattern", async () => { + // Mirrors the runInTransaction helper in sqliteIndex.ts + await db.run("BEGIN TRANSACTION"); + try { + await db.run( + "INSERT INTO files (file_path, file_type, last_modified_ms, content_hash) VALUES (?, ?, ?, ?)", + ["/txn-helper.source", "source", Date.now(), "hash3"] + ); + await db.run("COMMIT"); + } catch { + await db.run("ROLLBACK"); + throw new Error("Transaction should not have failed"); + } + + const row = await db.get<{ file_path: string }>( + "SELECT file_path FROM files WHERE file_path = ?", + ["/txn-helper.source"] + ); + assert.ok(row, "Transaction helper pattern should work"); + }); + + test("runInTransaction-style rollback on error", async () => { + await db.run("BEGIN TRANSACTION"); + try { + await db.run( + "INSERT INTO files (file_path, file_type, last_modified_ms, content_hash) VALUES (?, ?, ?, ?)", + ["/txn-error.source", "source", Date.now(), "hash4"] + ); + // Simulate an error + throw new Error("Simulated error"); + } catch { + await db.run("ROLLBACK"); + } + + const row = await db.get( + "SELECT file_path FROM files WHERE file_path = ?", + ["/txn-error.source"] + ); + assert.strictEqual(row, undefined, "Data should be rolled back after error"); + }); + + test("batch insert in transaction is faster than individual inserts", async () => { + const sourceFileId = await insertFile(db, "/perf-test.source", "source"); + + // Batch insert + const batchStart = Date.now(); + await db.run("BEGIN TRANSACTION"); + for (let i = 0; i < 100; i++) { + await db.run( + "INSERT INTO cells (cell_id, s_file_id, s_content, s_raw_content) VALUES (?, ?, ?, ?)", + [`PERF ${i}:1`, sourceFileId, `Content ${i}`, `Content ${i}`] + ); + } + await db.run("COMMIT"); + const batchDuration = Date.now() - batchStart; + + const count = await db.get<{ count: number }>( + "SELECT COUNT(*) as count FROM cells WHERE s_file_id = ?", + [sourceFileId] + ); + assert.strictEqual(count?.count, 100, "All 100 rows should be inserted"); + + // Batch should complete in reasonable time (well under 5 seconds for in-memory) + assert.ok(batchDuration < 5000, `Batch insert took ${batchDuration}ms, expected < 5000ms`); + }); + }); + + // ── 6. Schema Versioning & Re-indexing ────────────────────────────── + + suite("Schema Versioning & Re-indexing", () => { + let db: AsyncDatabase; + + setup(async function () { + skipIfNotReady(this); + db = await openTestDatabase(); + }); + + teardown(async () => { + if (db) { await db.close(); } + }); + + test("reads schema version", async () => { + const row = await db.get<{ version: number }>( + "SELECT version FROM schema_info WHERE id = 1 LIMIT 1" + ); + assert.strictEqual(row?.version, CURRENT_SCHEMA_VERSION); + }); + + test("updates schema version", async () => { + const newVersion = CURRENT_SCHEMA_VERSION + 1; + await db.run("BEGIN TRANSACTION"); + await db.run("DELETE FROM schema_info"); + await db.run("INSERT INTO schema_info (id, version) VALUES (1, ?)", [newVersion]); + await db.run("COMMIT"); + + const row = await db.get<{ version: number }>( + "SELECT version FROM schema_info WHERE id = 1" + ); + assert.strictEqual(row?.version, newVersion); + }); + + test("schema_info enforces single-row constraint", async () => { + try { + await db.run("INSERT INTO schema_info (id, version) VALUES (2, 99)"); + assert.fail("Should have thrown for id != 1"); + } catch (err: any) { + assert.ok( + err.message.includes("CHECK") || err.message.includes("constraint"), + `Expected CHECK constraint error, got: ${err.message}` + ); + } + }); + + test("removeAll clears all data (re-index prep)", async () => { + const fileId = await insertFile(db, "/project/GEN.source", "source"); + await insertCell(db, "GEN 1:1", { + sFileId: fileId, + sContent: "Verse content", + }); + await db.run("INSERT INTO words (word, cell_id, position) VALUES (?, ?, ?)", [ + "verse", + "GEN 1:1", + 0, + ]); + + // Clear all data (mirrors removeAll) + await db.run("BEGIN TRANSACTION"); + await db.run("DELETE FROM cells_fts"); + await db.run("DELETE FROM words"); + await db.run("DELETE FROM cells"); + await db.run("DELETE FROM files"); + await db.run("COMMIT"); + + const cellCount = await db.get<{ count: number }>("SELECT COUNT(*) as count FROM cells"); + const fileCount = await db.get<{ count: number }>("SELECT COUNT(*) as count FROM files"); + const wordCount = await db.get<{ count: number }>("SELECT COUNT(*) as count FROM words"); + const ftsCount = await db.get<{ count: number }>("SELECT COUNT(*) as count FROM cells_fts"); + + assert.strictEqual(cellCount?.count, 0); + assert.strictEqual(fileCount?.count, 0); + assert.strictEqual(wordCount?.count, 0); + assert.strictEqual(ftsCount?.count, 0); + }); + + test("FTS rebuild after full re-population", async () => { + const fileId = await insertFile(db, "/project/GEN.source", "source"); + + // Populate + for (let i = 1; i <= 10; i++) { + await insertCell(db, `GEN 1:${i}`, { + sFileId: fileId, + sContent: `Verse ${i} content with unique text verse${i}text`, + }); + } + + // Rebuild FTS + await db.run("INSERT INTO cells_fts(cells_fts) VALUES('rebuild')"); + + // Verify FTS works after rebuild + const results = await db.all<{ cell_id: string }>( + "SELECT cell_id FROM cells_fts WHERE cells_fts MATCH ?", + ["verse5text"] + ); + assert.ok(results.length >= 1, "Should find verse 5 after FTS rebuild"); + }); + + test("getDocumentCount matches actual cells", async () => { + const fileId = await insertFile(db, "/project/GEN.source", "source"); + await insertCell(db, "GEN 1:1", { sFileId: fileId, sContent: "A" }); + await insertCell(db, "GEN 1:2", { sFileId: fileId, sContent: "B" }); + await insertCell(db, "GEN 1:3", { sFileId: fileId, sContent: "C" }); + + const row = await db.get<{ count: number }>( + "SELECT COUNT(DISTINCT cell_id) as count FROM cells" + ); + assert.strictEqual(row?.count, 3); + }); + }); + + // ── 7. Sync Metadata ──────────────────────────────────────────────── + + suite("Sync Metadata", () => { + let db: AsyncDatabase; + + setup(async function () { + skipIfNotReady(this); + db = await openTestDatabase(); + }); + + teardown(async () => { + if (db) { await db.close(); } + }); + + test("inserts sync metadata", async () => { + await db.run( + `INSERT INTO sync_metadata (file_path, file_type, content_hash, file_size, last_modified_ms) + VALUES (?, ?, ?, ?, ?)`, + ["/project/GEN.source", "source", "abc123", 1024, Date.now()] + ); + + const row = await db.get<{ file_path: string; content_hash: string }>( + "SELECT file_path, content_hash FROM sync_metadata WHERE file_path = ?", + ["/project/GEN.source"] + ); + assert.strictEqual(row?.file_path, "/project/GEN.source"); + assert.strictEqual(row?.content_hash, "abc123"); + }); + + test("upserts sync metadata on conflict", async () => { + await db.run( + `INSERT INTO sync_metadata (file_path, file_type, content_hash, file_size, last_modified_ms) + VALUES (?, ?, ?, ?, ?)`, + ["/project/GEN.source", "source", "hash1", 100, 1000] + ); + + await db.run( + `INSERT INTO sync_metadata (file_path, file_type, content_hash, file_size, last_modified_ms) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(file_path) DO UPDATE SET + content_hash = excluded.content_hash, + file_size = excluded.file_size, + last_modified_ms = excluded.last_modified_ms`, + ["/project/GEN.source", "source", "hash2", 200, 2000] + ); + + const row = await db.get<{ content_hash: string; file_size: number }>( + "SELECT content_hash, file_size FROM sync_metadata WHERE file_path = ?", + ["/project/GEN.source"] + ); + assert.strictEqual(row?.content_hash, "hash2"); + assert.strictEqual(row?.file_size, 200); + }); + + test("deletes sync metadata", async () => { + await db.run( + `INSERT INTO sync_metadata (file_path, file_type, content_hash, file_size, last_modified_ms) + VALUES (?, ?, ?, ?, ?)`, + ["/project/to-delete.source", "source", "hash", 50, Date.now()] + ); + + const result = await db.run( + "DELETE FROM sync_metadata WHERE file_path = ?", + ["/project/to-delete.source"] + ); + assert.strictEqual(result.changes, 1); + }); + }); + + // ── 8. Words Index ────────────────────────────────────────────────── + + suite("Words Index", () => { + let db: AsyncDatabase; + let fileId: number; + + setup(async function () { + skipIfNotReady(this); + db = await openTestDatabase(); + fileId = await insertFile(db, "/project/GEN.source", "source"); + await insertCell(db, "GEN 1:1", { + sFileId: fileId, + sContent: "In the beginning God created", + }); + }); + + teardown(async () => { + if (db) { await db.close(); } + }); + + test("inserts words for a cell", async () => { + const words = ["in", "the", "beginning", "God", "created"]; + + await db.run("BEGIN TRANSACTION"); + for (let i = 0; i < words.length; i++) { + await db.run( + "INSERT INTO words (word, cell_id, position) VALUES (?, ?, ?)", + [words[i], "GEN 1:1", i] + ); + } + await db.run("COMMIT"); + + const count = await db.get<{ count: number }>( + "SELECT COUNT(*) as count FROM words WHERE cell_id = ?", + ["GEN 1:1"] + ); + assert.strictEqual(count?.count, words.length); + }); + + test("queries words by word text", async () => { + await db.run("INSERT INTO words (word, cell_id, position) VALUES (?, ?, ?)", [ + "beginning", + "GEN 1:1", + 2, + ]); + + // After creating deferred indexes + await db.exec(CREATE_DEFERRED_INDEXES_SQL); + + const rows = await db.all<{ cell_id: string }>( + "SELECT cell_id FROM words WHERE word = ?", + ["beginning"] + ); + assert.ok(rows.length >= 1); + assert.strictEqual(rows[0].cell_id, "GEN 1:1"); + }); + + test("CASCADE delete removes words when cell is deleted", async () => { + // Need to enable foreign keys (already done in setup via openTestDatabase) + await db.run("INSERT INTO words (word, cell_id, position) VALUES (?, ?, ?)", [ + "test", + "GEN 1:1", + 0, + ]); + + // Verify word exists + let wordCount = await db.get<{ count: number }>( + "SELECT COUNT(*) as count FROM words WHERE cell_id = ?", + ["GEN 1:1"] + ); + assert.ok((wordCount?.count ?? 0) > 0); + + // Delete the cell + await db.run("DELETE FROM cells WHERE cell_id = ?", ["GEN 1:1"]); + + // Words should be cascade-deleted + wordCount = await db.get<{ count: number }>( + "SELECT COUNT(*) as count FROM words WHERE cell_id = ?", + ["GEN 1:1"] + ); + assert.strictEqual(wordCount?.count, 0, "Words should be cascade-deleted with cell"); + }); + }); + + // ── 9. Edge Cases & Unicode ───────────────────────────────────────── + + suite("Edge Cases & Unicode", () => { + let db: AsyncDatabase; + let fileId: number; + + setup(async function () { + skipIfNotReady(this); + db = await openTestDatabase(); + fileId = await insertFile(db, "/project/MRK.source", "source"); + }); + + teardown(async () => { + if (db) { await db.close(); } + }); + + test("stores and retrieves Greek text", async () => { + const greekContent = + "φωνὴ βοῶντος ἐν τῇ ἐρήμῳ· Ἑτοιμάσατε τὴν ὁδὸν κυρίου, εὐθείας ποιεῖτε τὰς τρίβους αὐτοῦ,"; + + await insertCell(db, "MRK 1:3", { + sFileId: fileId, + sContent: greekContent, + }); + + const row = await db.get<{ s_content: string }>( + "SELECT s_content FROM cells WHERE cell_id = ?", + ["MRK 1:3"] + ); + assert.strictEqual(row?.s_content, greekContent); + }); + + test("FTS5 searches Greek text with unicode61 tokenizer", async () => { + await insertCell(db, "MRK 1:3", { + sFileId: fileId, + sContent: "φωνὴ βοῶντος ἐν τῇ ἐρήμῳ", + }); + + // The unicode61 tokenizer should handle Greek + const results = await db.all<{ cell_id: string }>( + "SELECT cell_id FROM cells_fts WHERE cells_fts MATCH ?", + ["φωνὴ"] + ); + assert.ok(results.length >= 1, "Should find Greek text via FTS"); + }); + + test("stores and retrieves Hebrew text", async () => { + const hebrewContent = "בְּרֵאשִׁית בָּרָא אֱלֹהִים אֵת הַשָּׁמַיִם וְאֵת הָאָרֶץ"; + + await insertCell(db, "GEN 1:1", { + sFileId: fileId, + sContent: hebrewContent, + }); + + const row = await db.get<{ s_content: string }>( + "SELECT s_content FROM cells WHERE cell_id = ?", + ["GEN 1:1"] + ); + assert.strictEqual(row?.s_content, hebrewContent); + }); + + test("stores and retrieves HTML content (raw_content)", async () => { + const htmlContent = + '

In the beginning God created

'; + const plainContent = "In the beginning God created"; + + await insertCell(db, "GEN 1:1", { + sFileId: fileId, + sContent: plainContent, + sRawContent: htmlContent, + }); + + const row = await db.get<{ s_content: string; s_raw_content: string }>( + "SELECT s_content, s_raw_content FROM cells WHERE cell_id = ?", + ["GEN 1:1"] + ); + assert.strictEqual(row?.s_content, plainContent); + assert.strictEqual(row?.s_raw_content, htmlContent); + }); + + test("handles empty content gracefully", async () => { + await insertCell(db, "GEN 1:1", { + sFileId: fileId, + sContent: "", + }); + + const row = await db.get<{ s_content: string; s_word_count: number }>( + "SELECT s_content, s_word_count FROM cells WHERE cell_id = ?", + ["GEN 1:1"] + ); + assert.strictEqual(row?.s_content, ""); + assert.strictEqual(row?.s_word_count, 0); + }); + + test("handles NULL content", async () => { + await db.run("INSERT INTO cells (cell_id) VALUES (?)", ["EMPTY-CELL"]); + + const row = await db.get<{ s_content: string | null; t_content: string | null }>( + "SELECT s_content, t_content FROM cells WHERE cell_id = ?", + ["EMPTY-CELL"] + ); + assert.strictEqual(row?.s_content, null); + assert.strictEqual(row?.t_content, null); + }); + + test("handles very long content", async () => { + const longContent = "word ".repeat(10_000).trim(); // ~50,000 characters + + await insertCell(db, "GEN 1:1", { + sFileId: fileId, + sContent: longContent, + }); + + const row = await db.get<{ s_content: string; s_word_count: number }>( + "SELECT s_content, s_word_count FROM cells WHERE cell_id = ?", + ["GEN 1:1"] + ); + assert.strictEqual(row?.s_word_count, 10_000); + assert.strictEqual(row?.s_content, longContent); + }); + + test("handles special characters in cell_id", async () => { + const specialId = "GEN 1:1 (alt)"; + await insertCell(db, specialId, { + sFileId: fileId, + sContent: "test content", + }); + + const row = await db.get<{ cell_id: string }>( + "SELECT cell_id FROM cells WHERE cell_id = ?", + [specialId] + ); + assert.strictEqual(row?.cell_id, specialId); + }); + + test("content hash is deterministic", async () => { + const content = "In the beginning God created the heavens and the earth."; + const hash1 = computeHash(content); + const hash2 = computeHash(content); + assert.strictEqual(hash1, hash2, "Same content should produce same hash"); + + const hash3 = computeHash(content + " "); + assert.notStrictEqual(hash1, hash3, "Different content should produce different hash"); + }); + }); + + // ── 10. AsyncDatabase API ─────────────────────────────────────────── + + suite("AsyncDatabase API", () => { + let db: AsyncDatabase; + + setup(async function () { + skipIfNotReady(this); + db = await AsyncDatabase.open(":memory:"); + }); + + teardown(async () => { + if (db) { await db.close(); } + }); + + test("exec() runs DDL statements", async () => { + await db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)"); + const tables = await db.all<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type='table' AND name='test'" + ); + assert.strictEqual(tables.length, 1); + }); + + test("run() returns lastID and changes", async () => { + await db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT)"); + + const result1 = await db.run("INSERT INTO test (value) VALUES (?)", ["first"]); + assert.strictEqual(result1.lastID, 1); + assert.strictEqual(result1.changes, 1); + + const result2 = await db.run("INSERT INTO test (value) VALUES (?)", ["second"]); + assert.strictEqual(result2.lastID, 2); + assert.strictEqual(result2.changes, 1); + }); + + test("get() returns single row or undefined", async () => { + await db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)"); + await db.run("INSERT INTO test VALUES (1, 'hello')"); + + const row = await db.get<{ id: number; value: string }>( + "SELECT * FROM test WHERE id = ?", + [1] + ); + assert.strictEqual(row?.id, 1); + assert.strictEqual(row?.value, "hello"); + + const missing = await db.get("SELECT * FROM test WHERE id = ?", [999]); + assert.strictEqual(missing, undefined); + }); + + test("all() returns array of rows", async () => { + await db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)"); + await db.run("INSERT INTO test VALUES (1, 'a')"); + await db.run("INSERT INTO test VALUES (2, 'b')"); + await db.run("INSERT INTO test VALUES (3, 'c')"); + + const rows = await db.all<{ id: number; value: string }>("SELECT * FROM test ORDER BY id"); + assert.strictEqual(rows.length, 3); + assert.strictEqual(rows[0].value, "a"); + assert.strictEqual(rows[2].value, "c"); + }); + + test("all() returns empty array for no matches", async () => { + await db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY)"); + const rows = await db.all("SELECT * FROM test"); + assert.ok(Array.isArray(rows)); + assert.strictEqual(rows.length, 0); + }); + + test("each() iterates over rows", async () => { + await db.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, value TEXT)"); + await db.run("INSERT INTO test VALUES (1, 'x')"); + await db.run("INSERT INTO test VALUES (2, 'y')"); + await db.run("INSERT INTO test VALUES (3, 'z')"); + + const collected: string[] = []; + const count = await db.each<{ value: string }>( + "SELECT value FROM test ORDER BY id", + [], + (row) => { + collected.push(row.value); + } + ); + + assert.strictEqual(count, 3); + assert.deepStrictEqual(collected, ["x", "y", "z"]); + }); + + test("run() rejects on SQL error", async () => { + try { + await db.run("INSERT INTO nonexistent_table VALUES (1)"); + assert.fail("Should have thrown"); + } catch (err: any) { + assert.ok(err.message.includes("no such table")); + } + }); + + test("get() rejects on SQL error", async () => { + try { + await db.get("SELECT * FROM nonexistent_table"); + assert.fail("Should have thrown"); + } catch (err: any) { + assert.ok(err.message.includes("no such table")); + } + }); + + test("exec() rejects on SQL error", async () => { + try { + await db.exec("INVALID SQL STATEMENT"); + assert.fail("Should have thrown"); + } catch (err: any) { + assert.ok(err instanceof Error); + } + }); + }); + + // ── 11. PRAGMAs & Configuration ───────────────────────────────────── + + suite("PRAGMAs & Database Configuration", () => { + let db: AsyncDatabase; + + setup(async function () { + skipIfNotReady(this); + db = await AsyncDatabase.open(":memory:"); + }); + + teardown(async () => { + if (db) { await db.close(); } + }); + + test("journal_mode can be set", async () => { + // In-memory DBs use MEMORY by default, but we can set it + await db.exec("PRAGMA journal_mode = MEMORY"); + const row = await db.get<{ journal_mode: string }>("PRAGMA journal_mode"); + assert.ok(row?.journal_mode === "memory", `Expected 'memory', got '${row?.journal_mode}'`); + }); + + test("foreign_keys can be enabled", async () => { + await db.exec("PRAGMA foreign_keys = ON"); + const row = await db.get<{ foreign_keys: number }>("PRAGMA foreign_keys"); + assert.strictEqual(row?.foreign_keys, 1); + }); + + test("cache_size can be configured", async () => { + await db.exec("PRAGMA cache_size = -8000"); + const row = await db.get<{ cache_size: number }>("PRAGMA cache_size"); + assert.strictEqual(row?.cache_size, -8000); + }); + + test("busyTimeout can be configured via configure()", async () => { + // Should not throw + db.configure("busyTimeout", 5000); + }); + + test("temp_store can be set to MEMORY", async () => { + await db.exec("PRAGMA temp_store = MEMORY"); + const row = await db.get<{ temp_store: number }>("PRAGMA temp_store"); + assert.strictEqual(row?.temp_store, 2); // 2 = MEMORY + }); + + test("integrity_check passes on fresh database", async () => { + const result = await db.get<{ quick_check: string }>( + "PRAGMA quick_check(1)" + ); + assert.strictEqual(result?.quick_check, "ok"); + }); + }); + + // ── 12. Joined Queries (mimicking search) ─────────────────────────── + + suite("Joined Queries (Search Pattern)", () => { + let db: AsyncDatabase; + let sourceFileId: number; + let targetFileId: number; + + setup(async function () { + skipIfNotReady(this); + db = await openTestDatabase(); + sourceFileId = await insertFile(db, "/project/GEN.source", "source"); + targetFileId = await insertFile(db, "/project/GEN.codex", "codex"); + + // Insert cells with both source and target content + // We need to insert source first, then update with target + await db.run( + `INSERT INTO cells (cell_id, s_file_id, s_content, s_raw_content, s_line_number, s_word_count) + VALUES (?, ?, ?, ?, ?, ?)`, + ["GEN 1:1", sourceFileId, "In the beginning God created", "In the beginning God created", 1, 5] + ); + await db.run( + `UPDATE cells SET t_file_id = ?, t_content = ?, t_raw_content = ?, t_line_number = ?, t_word_count = ? + WHERE cell_id = ?`, + [targetFileId, "En el principio Dios creo", "En el principio Dios creo", 1, 5, "GEN 1:1"] + ); + + await db.run( + `INSERT INTO cells (cell_id, s_file_id, s_content, s_raw_content, s_line_number, s_word_count) + VALUES (?, ?, ?, ?, ?, ?)`, + ["GEN 1:2", sourceFileId, "And the earth was without form", "And the earth was without form", 2, 6] + ); + await db.run( + `UPDATE cells SET t_file_id = ?, t_content = ?, t_raw_content = ?, t_line_number = ?, t_word_count = ? + WHERE cell_id = ?`, + [targetFileId, "Y la tierra estaba desordenada", "Y la tierra estaba desordenada", 2, 5, "GEN 1:2"] + ); + + // Manually sync to FTS (since UPDATE triggers handle source/target separately) + await db.run( + `INSERT OR REPLACE INTO cells_fts(cell_id, content, raw_content, content_type) + VALUES (?, ?, ?, ?)`, + ["GEN 1:1", "En el principio Dios creo", "En el principio Dios creo", "target"] + ); + await db.run( + `INSERT OR REPLACE INTO cells_fts(cell_id, content, raw_content, content_type) + VALUES (?, ?, ?, ?)`, + ["GEN 1:2", "Y la tierra estaba desordenada", "Y la tierra estaba desordenada", "target"] + ); + }); + + teardown(async () => { + if (db) { await db.close(); } + }); + + test("search with JOIN returns cell + file data", async () => { + const rows = await db.all<{ + cell_id: string; + content: string; + content_type: string; + s_content: string; + t_content: string; + s_file_path: string; + t_file_path: string; + score: number; + }>(` + SELECT + cells_fts.cell_id, + cells_fts.content, + cells_fts.content_type, + c.s_content, + c.t_content, + s_file.file_path as s_file_path, + t_file.file_path as t_file_path, + bm25(cells_fts) as score + FROM cells_fts + JOIN cells c ON cells_fts.cell_id = c.cell_id + LEFT JOIN files s_file ON c.s_file_id = s_file.id + LEFT JOIN files t_file ON c.t_file_id = t_file.id + WHERE cells_fts MATCH ? + ORDER BY score ASC + LIMIT 10 + `, ["content: beginning"]); + + assert.ok(rows.length >= 1, "Should find results for 'beginning'"); + const match = rows.find((r) => r.cell_id === "GEN 1:1"); + assert.ok(match, "Should find GEN 1:1"); + assert.ok(match?.s_content?.includes("beginning")); + assert.strictEqual(match?.s_file_path, "/project/GEN.source"); + assert.strictEqual(match?.t_file_path, "/project/GEN.codex"); + }); + + test("search target content via FTS", async () => { + const rows = await db.all<{ cell_id: string }>( + `SELECT cell_id FROM cells_fts WHERE cells_fts MATCH ?`, + ["principio"] + ); + assert.ok(rows.length >= 1); + assert.ok(rows.some((r) => r.cell_id === "GEN 1:1")); + }); + + test("search returns both source and target matches", async () => { + // "earth" is in GEN 1:2's source, "tierra" is in GEN 1:2's target + const sourceResults = await db.all<{ cell_id: string }>( + "SELECT cell_id FROM cells_fts WHERE cells_fts MATCH ?", + ["earth"] + ); + const targetResults = await db.all<{ cell_id: string }>( + "SELECT cell_id FROM cells_fts WHERE cells_fts MATCH ?", + ["tierra"] + ); + + assert.ok(sourceResults.some((r) => r.cell_id === "GEN 1:2"), "Source 'earth' should match"); + assert.ok(targetResults.some((r) => r.cell_id === "GEN 1:2"), "Target 'tierra' should match"); + }); + }); + + // ── 13. Concurrent operations ─────────────────────────────────────── + + suite("Concurrent Operations", () => { + let db: AsyncDatabase; + + setup(async function () { + skipIfNotReady(this); + db = await openTestDatabase(); + }); + + teardown(async () => { + if (db) { await db.close(); } + }); + + test("parallel reads do not interfere", async () => { + const fileId = await insertFile(db, "/project/GEN.source", "source"); + await insertCell(db, "GEN 1:1", { sFileId: fileId, sContent: "verse one" }); + await insertCell(db, "GEN 1:2", { sFileId: fileId, sContent: "verse two" }); + + // Run multiple reads in parallel + const [row1, row2, count] = await Promise.all([ + db.get<{ s_content: string }>("SELECT s_content FROM cells WHERE cell_id = ?", ["GEN 1:1"]), + db.get<{ s_content: string }>("SELECT s_content FROM cells WHERE cell_id = ?", ["GEN 1:2"]), + db.get<{ count: number }>("SELECT COUNT(*) as count FROM cells"), + ]); + + assert.ok(row1?.s_content?.includes("one")); + assert.ok(row2?.s_content?.includes("two")); + assert.strictEqual(count?.count, 2); + }); + + test("sequential writes maintain consistency", async () => { + const fileId = await insertFile(db, "/project/GEN.source", "source"); + + // Sequential writes + for (let i = 1; i <= 20; i++) { + await insertCell(db, `SEQ ${i}:1`, { + sFileId: fileId, + sContent: `Sequential content ${i}`, + }); + } + + const count = await db.get<{ count: number }>( + "SELECT COUNT(*) as count FROM cells WHERE s_file_id = ?", + [fileId] + ); + assert.strictEqual(count?.count, 20); + }); + }); + + // ── 14. Database close & cleanup ──────────────────────────────────── + + suite("Database Close & Cleanup", () => { + test("close() completes without error", async function () { + skipIfNotReady(this); + const db = await openTestDatabase(); + await db.close(); + // Should not throw + }); + + test("operations fail after close", async function () { + skipIfNotReady(this); + const db = await openTestDatabase(); + await db.close(); + + try { + await db.run("SELECT 1"); + assert.fail("Should have thrown after close"); + } catch (err: any) { + assert.ok(err instanceof Error); + } + }); + + test("VACUUM succeeds on fresh database", async function () { + skipIfNotReady(this); + const db = await openTestDatabase(); + await db.exec("VACUUM"); + await db.close(); + }); + + test("PRAGMA optimize runs without error before close", async function () { + skipIfNotReady(this); + const db = await openTestDatabase(); + // Insert some data so optimizer has something to analyze + const fileId = await insertFile(db, "/opt-test.source", "source"); + await insertCell(db, "OPT 1:1", { sFileId: fileId, sContent: "test content" }); + await db.exec("PRAGMA optimize"); + await db.close(); + }); + }); + + // ── 15. SQLITE_BUSY & busyTimeout behavior ────────────────────────── + + suite("SQLITE_BUSY & busyTimeout", () => { + test("busyTimeout prevents immediate SQLITE_BUSY on contention", async function () { + skipIfNotReady(this); + + // Use a file-based DB so two connections can contend for locks. + // In-memory DBs are single-connection and can't produce SQLITE_BUSY. + const tmpDir = realOs.tmpdir(); + const dbPath = realPath.join(tmpDir, `busy-test-${Date.now()}.sqlite`); + + let db1: AsyncDatabase | null = null; + let db2: AsyncDatabase | null = null; + + try { + db1 = await AsyncDatabase.open(dbPath); + db2 = await AsyncDatabase.open(dbPath); + + await db1.exec("PRAGMA journal_mode = WAL"); + await db2.exec("PRAGMA journal_mode = WAL"); + + // Set a short busy timeout on db2 + db2.configure("busyTimeout", 2000); + + // Create a simple table + await db1.exec("CREATE TABLE IF NOT EXISTS busy_test (id INTEGER PRIMARY KEY, val TEXT)"); + + // db1 starts a write transaction + await db1.run("BEGIN IMMEDIATE"); + await db1.run("INSERT INTO busy_test (val) VALUES ('from db1')"); + + // db2 tries to write — with WAL, readers don't block, but IMMEDIATE + // transactions will wait up to busyTimeout before failing + const start = Date.now(); + const db2WritePromise = db2.run("BEGIN IMMEDIATE").then(async () => { + await db2!.run("INSERT INTO busy_test (val) VALUES ('from db2')"); + await db2!.run("COMMIT"); + return "committed"; + }).catch(async (err: Error) => { + // Try to rollback if we got a transaction started + try { await db2!.run("ROLLBACK"); } catch { /* ignore */ } + return `error: ${err.message}`; + }); + + // Commit db1 after a short delay to release the lock + await new Promise(r => setTimeout(r, 200)); + await db1.run("COMMIT"); + + const result = await db2WritePromise; + const elapsed = Date.now() - start; + + // db2 should have succeeded (lock was released before timeout) or + // at minimum waited rather than failing instantly + assert.ok( + result === "committed" || elapsed >= 100, + `busyTimeout should cause wait, not instant failure. Result: ${result}, elapsed: ${elapsed}ms` + ); + } finally { + if (db1) { try { await db1.close(); } catch { /* */ } } + if (db2) { try { await db2.close(); } catch { /* */ } } + try { realFs.unlinkSync(dbPath); } catch { /* */ } + try { realFs.unlinkSync(dbPath + "-wal"); } catch { /* */ } + try { realFs.unlinkSync(dbPath + "-shm"); } catch { /* */ } + } + }); + + test("SQLITE_BUSY is returned when busyTimeout expires", async function () { + skipIfNotReady(this); + this.timeout(10_000); + + const tmpDir = realOs.tmpdir(); + const dbPath = realPath.join(tmpDir, `busy-expire-${Date.now()}.sqlite`); + + let db1: AsyncDatabase | null = null; + let db2: AsyncDatabase | null = null; + + try { + db1 = await AsyncDatabase.open(dbPath); + db2 = await AsyncDatabase.open(dbPath); + + // Use DELETE journal mode so write locks are exclusive + await db1.exec("PRAGMA journal_mode = DELETE"); + await db2.exec("PRAGMA journal_mode = DELETE"); + + // Very short timeout so the test doesn't hang + db2.configure("busyTimeout", 200); + + await db1.exec("CREATE TABLE IF NOT EXISTS busy_expire (id INTEGER PRIMARY KEY, val TEXT)"); + + // Hold an exclusive write lock on db1 (don't commit) + await db1.run("BEGIN EXCLUSIVE"); + await db1.run("INSERT INTO busy_expire (val) VALUES ('blocking')"); + + // db2 should fail with SQLITE_BUSY after timeout + try { + await db2.run("BEGIN EXCLUSIVE"); + assert.fail("Expected SQLITE_BUSY error"); + } catch (err: any) { + assert.ok( + err.message.includes("SQLITE_BUSY") || err.message.includes("database is locked"), + `Expected SQLITE_BUSY, got: ${err.message}` + ); + } + + await db1.run("ROLLBACK"); + } finally { + if (db1) { try { await db1.close(); } catch { /* */ } } + if (db2) { try { await db2.close(); } catch { /* */ } } + try { realFs.unlinkSync(dbPath); } catch { /* */ } + try { realFs.unlinkSync(dbPath + "-wal"); } catch { /* */ } + try { realFs.unlinkSync(dbPath + "-shm"); } catch { /* */ } + } + }); + }); + + // ── 16. Transaction retry with backoff ─────────────────────────────── + + suite("Transaction Retry with Backoff", () => { + test("runInTransaction-style retry succeeds after transient SQLITE_BUSY", async function () { + skipIfNotReady(this); + this.timeout(10_000); + + // Simulate the retry pattern from runInTransactionWithRetry + const db = await openTestDatabase(); + const fileId = await insertFile(db, "/retry-test.source", "source"); + + let attempt = 0; + const maxRetries = 3; + const baseDelayMs = 50; + + // Simulate a function that fails with SQLITE_BUSY on first attempt, succeeds after + const simulatedBusyThenSucceed = async (): Promise => { + for (let i = 0; i <= maxRetries; i++) { + try { + if (attempt === 0) { + attempt++; + throw new Error("SQLITE_BUSY: database is locked"); + } + // Succeeds on retry + await insertCell(db, "RETRY 1:1", { sFileId: fileId, sContent: "retried content" }); + return "success"; + } catch (error: any) { + const msg = error.message || String(error); + const isBusy = msg.includes("SQLITE_BUSY") || msg.includes("database is locked"); + if (!isBusy || i === maxRetries) throw error; + const delay = baseDelayMs * Math.pow(2, i); + await new Promise(r => setTimeout(r, delay)); + } + } + throw new Error("Unreachable"); + }; + + const result = await simulatedBusyThenSucceed(); + assert.strictEqual(result, "success"); + assert.strictEqual(attempt, 1, "Should have failed once then succeeded"); + + // Verify the data was written + const row = await db.get<{ s_content: string }>( + "SELECT s_content FROM cells WHERE cell_id = ?", + ["RETRY 1:1"] + ); + assert.ok(row?.s_content?.includes("retried"), "Retried write should persist"); + + await db.close(); + }); + + test("retry exhaustion throws the original error", async function () { + skipIfNotReady(this); + + const maxRetries = 2; + const baseDelayMs = 10; // Very short for fast test + let attempts = 0; + + const alwaysBusy = async (): Promise => { + for (let i = 0; i <= maxRetries; i++) { + try { + attempts++; + throw new Error("SQLITE_BUSY: database is locked"); + } catch (error: any) { + const msg = error.message || String(error); + const isBusy = msg.includes("SQLITE_BUSY") || msg.includes("database is locked"); + if (!isBusy || i === maxRetries) throw error; + const delay = baseDelayMs * Math.pow(2, i); + await new Promise(r => setTimeout(r, delay)); + } + } + }; + + try { + await alwaysBusy(); + assert.fail("Should have thrown after exhausting retries"); + } catch (err: any) { + assert.ok(err.message.includes("SQLITE_BUSY"), `Expected SQLITE_BUSY, got: ${err.message}`); + assert.strictEqual(attempts, maxRetries + 1, `Should have attempted ${maxRetries + 1} times`); + } + }); + + test("non-BUSY errors are not retried", async function () { + skipIfNotReady(this); + + const maxRetries = 3; + const baseDelayMs = 10; + let attempts = 0; + + const nonBusyError = async (): Promise => { + for (let i = 0; i <= maxRetries; i++) { + try { + attempts++; + throw new Error("SQLITE_CONSTRAINT: UNIQUE constraint failed"); + } catch (error: any) { + const msg = error.message || String(error); + const isBusy = msg.includes("SQLITE_BUSY") || msg.includes("database is locked"); + if (!isBusy || i === maxRetries) throw error; + const delay = baseDelayMs * Math.pow(2, i); + await new Promise(r => setTimeout(r, delay)); + } + } + }; + + try { + await nonBusyError(); + assert.fail("Should have thrown on first attempt"); + } catch (err: any) { + assert.ok(err.message.includes("SQLITE_CONSTRAINT"), `Expected SQLITE_CONSTRAINT, got: ${err.message}`); + assert.strictEqual(attempts, 1, "Non-BUSY errors should not be retried"); + } + }); + }); + + // ── 17. Corruption recovery patterns ───────────────────────────────── + + suite("Corruption Recovery Patterns", () => { + test("quick_check detects corruption (simulated via invalid data)", async function () { + skipIfNotReady(this); + + // Verify that quick_check returns 'ok' for a healthy database + const db = await openTestDatabase(); + const result = await db.get<{ quick_check: string }>("PRAGMA quick_check(1)"); + assert.strictEqual(result?.quick_check, "ok", "Healthy database should pass quick_check"); + await db.close(); + }); + + test("database can be recreated after schema mismatch", async function () { + skipIfNotReady(this); + + // Simulate a schema version mismatch by inserting wrong version + const db = await openTestDatabase(); + + // Set an invalid schema version + await db.run("UPDATE schema_info SET version = 999 WHERE id = 1"); + + // Verify it was set + const row = await db.get<{ version: number }>("SELECT version FROM schema_info WHERE id = 1"); + assert.strictEqual(row?.version, 999); + + // In production, this triggers nukeDatabaseAndRecreate(). + // Test that we can drop and recreate the schema from scratch. + await db.exec("DROP TABLE IF EXISTS cells_fts"); + await db.exec("DROP TABLE IF EXISTS cells"); + await db.exec("DROP TABLE IF EXISTS sync_metadata"); + await db.exec("DROP TABLE IF EXISTS words"); + await db.exec("DROP TABLE IF EXISTS files"); + await db.exec("DROP TABLE IF EXISTS schema_info"); + + // Recreate schema + await db.exec(CREATE_TABLES_SQL); + await db.exec(CREATE_INDEXES_SQL); + for (const trigger of ALL_TRIGGERS) { + await db.run(trigger); + } + await db.run(CREATE_SCHEMA_INFO_SQL); + await db.run( + "INSERT INTO schema_info (id, version, project_id, project_name) VALUES (1, ?, NULL, NULL)", + [CURRENT_SCHEMA_VERSION] + ); + + // Verify the recreated schema is healthy + const newVersion = await db.get<{ version: number }>("SELECT version FROM schema_info WHERE id = 1"); + assert.strictEqual(newVersion?.version, CURRENT_SCHEMA_VERSION); + + // Verify tables exist + const tables = await db.all<{ name: string }>( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ); + const tableNames = tables.map(t => t.name); + assert.ok(tableNames.includes("cells"), "cells table should exist after recreation"); + assert.ok(tableNames.includes("files"), "files table should exist after recreation"); + assert.ok(tableNames.includes("cells_fts"), "FTS table should exist after recreation"); + + // Verify we can insert data into the recreated schema + const fileId = await insertFile(db, "/recreated.source", "source"); + await insertCell(db, "RECREATED 1:1", { sFileId: fileId, sContent: "After recreation" }); + + const cell = await db.get<{ s_content: string }>( + "SELECT s_content FROM cells WHERE cell_id = ?", + ["RECREATED 1:1"] + ); + assert.ok(cell?.s_content?.includes("recreation"), "Data should be writable after recreation"); + + await db.close(); + }); + + test("project identity mismatch triggers re-index", async function () { + skipIfNotReady(this); + + const db = await openTestDatabase(); + + // Set a project identity + await db.run( + "UPDATE schema_info SET project_id = ?, project_name = ? WHERE id = 1", + ["project-A", "Test Project A"] + ); + + // Verify identity was set + const identity = await db.get<{ project_id: string; project_name: string }>( + "SELECT project_id, project_name FROM schema_info WHERE id = 1" + ); + assert.strictEqual(identity?.project_id, "project-A"); + assert.strictEqual(identity?.project_name, "Test Project A"); + + // Simulate a mismatch by checking against a different project + const currentProjectId: string = "project-B"; + const dbProjectId: string | undefined = identity?.project_id; + const mismatch = dbProjectId !== undefined && dbProjectId !== currentProjectId; + assert.ok(mismatch, "Should detect project identity mismatch"); + + await db.close(); + }); + }); + + // ── 18. WAL checkpoint behavior ────────────────────────────────────── + + suite("WAL Checkpoint Behavior", () => { + test("WAL checkpoint succeeds on file-based database", async function () { + skipIfNotReady(this); + + const tmpDir = realOs.tmpdir(); + const dbPath = realPath.join(tmpDir, `wal-ckpt-${Date.now()}.sqlite`); + + let db: AsyncDatabase | null = null; + try { + db = await AsyncDatabase.open(dbPath); + await db.exec("PRAGMA journal_mode = WAL"); + await db.exec("CREATE TABLE wal_test (id INTEGER PRIMARY KEY, val TEXT)"); + + // Insert data (writes to WAL) + for (let i = 0; i < 50; i++) { + await db.run("INSERT INTO wal_test (val) VALUES (?)", [`row ${i}`]); + } + + // Checkpoint should succeed + await db.exec("PRAGMA wal_checkpoint(PASSIVE)"); + await db.exec("PRAGMA wal_checkpoint(FULL)"); + await db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + + // Verify data is intact after checkpoint + const count = await db.get<{ count: number }>("SELECT COUNT(*) as count FROM wal_test"); + assert.strictEqual(count?.count, 50, "All rows should survive checkpoint"); + + } finally { + if (db) { try { await db.close(); } catch { /* */ } } + try { realFs.unlinkSync(dbPath); } catch { /* */ } + try { realFs.unlinkSync(dbPath + "-wal"); } catch { /* */ } + try { realFs.unlinkSync(dbPath + "-shm"); } catch { /* */ } + } + }); + + test("TRUNCATE checkpoint resets WAL file", async function () { + skipIfNotReady(this); + + const tmpDir = realOs.tmpdir(); + const dbPath = realPath.join(tmpDir, `wal-trunc-${Date.now()}.sqlite`); + const walPath = dbPath + "-wal"; + + let db: AsyncDatabase | null = null; + try { + db = await AsyncDatabase.open(dbPath); + await db.exec("PRAGMA journal_mode = WAL"); + await db.exec("CREATE TABLE trunc_test (id INTEGER PRIMARY KEY, val TEXT)"); + + // Write enough data to create a non-trivial WAL + for (let i = 0; i < 100; i++) { + await db.run("INSERT INTO trunc_test (val) VALUES (?)", [`data ${i}`]); + } + + // WAL file should exist and have content + const walExists = realFs.existsSync(walPath); + if (walExists) { + const walSizeBefore = realFs.statSync(walPath).size; + assert.ok(walSizeBefore > 0, "WAL should have content before checkpoint"); + + // TRUNCATE should reset it + await db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + + const walSizeAfter = realFs.statSync(walPath).size; + assert.strictEqual(walSizeAfter, 0, "WAL should be empty after TRUNCATE checkpoint"); + } + + // Data should still be intact + const count = await db.get<{ count: number }>("SELECT COUNT(*) as count FROM trunc_test"); + assert.strictEqual(count?.count, 100); + + } finally { + if (db) { try { await db.close(); } catch { /* */ } } + try { realFs.unlinkSync(dbPath); } catch { /* */ } + try { realFs.unlinkSync(walPath); } catch { /* */ } + try { realFs.unlinkSync(dbPath + "-shm"); } catch { /* */ } + } + }); + + test("checkpoint on in-memory database is a no-op", async function () { + skipIfNotReady(this); + + const db = await openTestDatabase(); + // Should not throw — just a no-op for in-memory DBs + await db.exec("PRAGMA wal_checkpoint(PASSIVE)"); + await db.close(); + }); + }); + + // ── AsyncDatabase.transaction() helper tests ──────────────────────────── + + suite("AsyncDatabase.transaction() helper", () => { + test("commits on success", async function () { + skipIfNotReady(this); + + const db = await openTestDatabase(); + await db.transaction(async (tx) => { + await tx.run( + "INSERT INTO files (file_path, file_type, last_modified_ms, content_hash) VALUES (?, ?, ?, ?)", + ["/tx/commit.source", "source", Date.now(), computeHash("commit")] + ); + }); + + const row = await db.get<{ file_path: string }>( + "SELECT file_path FROM files WHERE file_path = ?", + ["/tx/commit.source"] + ); + assert.strictEqual(row?.file_path, "/tx/commit.source"); + await db.close(); + }); + + test("rolls back on error and re-throws", async function () { + skipIfNotReady(this); + + const db = await openTestDatabase(); + await assert.rejects( + () => + db.transaction(async (tx) => { + await tx.run( + "INSERT INTO files (file_path, file_type, last_modified_ms, content_hash) VALUES (?, ?, ?, ?)", + ["/tx/rollback.source", "source", Date.now(), computeHash("rollback")] + ); + throw new Error("intentional failure"); + }), + /intentional failure/ + ); + + const row = await db.get<{ file_path: string }>( + "SELECT file_path FROM files WHERE file_path = ?", + ["/tx/rollback.source"] + ); + assert.strictEqual(row, undefined, "Row should not exist after rollback"); + await db.close(); + }); + + test("rejects if database is closed", async function () { + skipIfNotReady(this); + + const db = await openTestDatabase(); + await db.close(); + + await assert.rejects( + () => db.transaction(async () => { /* no-op */ }), + /closed/i + ); + }); + }); + + // ── Bulk word INSERT tests (chunked multi-row VALUES) ─────────────────── + + suite("Bulk word INSERT (chunked)", () => { + test("inserts many words in chunks and retrieves them", async function () { + skipIfNotReady(this); + + const db = await openTestDatabase(); + const fileId = await insertFile(db, "/bulk/words.source", "source"); + await insertCell(db, "BULK_WORDS_001", { sFileId: fileId, sContent: "all the words" }); + + // Simulate the optimized updateWordIndex bulk insert + const words = Array.from({ length: 120 }, (_, i) => [`word${i}`, 1] as [string, number]); + const CHUNK_SIZE = 50; + + await db.run("BEGIN TRANSACTION"); + await db.run("DELETE FROM words WHERE cell_id = ?", ["BULK_WORDS_001"]); + + let position = 0; + for (let i = 0; i < words.length; i += CHUNK_SIZE) { + const chunk = words.slice(i, i + CHUNK_SIZE); + const placeholders = chunk.map(() => "(?, ?, ?, ?)").join(", "); + const params: (string | number)[] = []; + for (const [word, frequency] of chunk) { + params.push(word, "BULK_WORDS_001", position++, frequency); + } + await db.run( + `INSERT INTO words (word, cell_id, position, frequency) VALUES ${placeholders}`, + params + ); + } + await db.run("COMMIT"); + + const count = await db.get<{ count: number }>( + "SELECT COUNT(*) as count FROM words WHERE cell_id = ?", + ["BULK_WORDS_001"] + ); + assert.strictEqual(count?.count, 120, "All 120 words should be inserted"); + + // Verify ordering + const first = await db.get<{ word: string; position: number }>( + "SELECT word, position FROM words WHERE cell_id = ? ORDER BY position LIMIT 1", + ["BULK_WORDS_001"] + ); + assert.strictEqual(first?.word, "word0"); + assert.strictEqual(first?.position, 0); + + const last = await db.get<{ word: string; position: number }>( + "SELECT word, position FROM words WHERE cell_id = ? ORDER BY position DESC LIMIT 1", + ["BULK_WORDS_001"] + ); + assert.strictEqual(last?.word, "word119"); + assert.strictEqual(last?.position, 119); + + await db.close(); + }); + }); + + // ── Batched FTS cleanup tests ─────────────────────────────────────────── + + suite("Batched FTS orphan cleanup", () => { + test("deletes orphaned FTS entries in a single batched DELETE", async function () { + skipIfNotReady(this); + + const db = await openTestDatabase(); + const fileId = await insertFile(db, "/fts/cleanup.source", "source"); + + // Insert cells that will have FTS entries via triggers + await insertCell(db, "FTS_KEEP_001", { sFileId: fileId, sContent: "keep this cell" }); + await insertCell(db, "FTS_ORPHAN_001", { sFileId: fileId, sContent: "orphan cell one" }); + await insertCell(db, "FTS_ORPHAN_002", { sFileId: fileId, sContent: "orphan cell two" }); + + // Verify FTS has entries for all three + const ftsBefore = await db.get<{ count: number }>( + "SELECT COUNT(DISTINCT cell_id) as count FROM cells_fts" + ); + assert.strictEqual(ftsBefore?.count, 3); + + // Delete cells directly (bypassing normal flow) to create orphans. + // First delete the FTS entries via the trigger by deleting the cells. + // Actually, the trigger should handle this. Let's delete cells manually + // and then re-insert orphaned FTS entries to simulate the edge case. + await db.run("DELETE FROM cells WHERE cell_id IN ('FTS_ORPHAN_001', 'FTS_ORPHAN_002')"); + + // After deletion, FTS should have been cleaned by the trigger. + // Manually insert orphan FTS entries to simulate a partial-failure edge case. + await db.run( + "INSERT INTO cells_fts(cell_id, content, raw_content, content_type) VALUES (?, ?, ?, ?)", + ["ORPHAN_MANUAL_001", "stale content", "stale content", "source"] + ); + await db.run( + "INSERT INTO cells_fts(cell_id, content, raw_content, content_type) VALUES (?, ?, ?, ?)", + ["ORPHAN_MANUAL_002", "stale content 2", "stale content 2", "target"] + ); + + // Batched cleanup: find orphans and delete in chunks + const orphans = await db.all<{ cell_id: string }>( + `SELECT DISTINCT fts.cell_id + FROM cells_fts fts + LEFT JOIN cells c ON fts.cell_id = c.cell_id + WHERE c.cell_id IS NULL` + ); + assert.ok(orphans.length >= 2, `Expected at least 2 orphans, got ${orphans.length}`); + + const CHUNK_SIZE = 500; + await db.run("BEGIN TRANSACTION"); + for (let i = 0; i < orphans.length; i += CHUNK_SIZE) { + const chunk = orphans.slice(i, i + CHUNK_SIZE); + const placeholders = chunk.map(() => "?").join(","); + await db.run( + `DELETE FROM cells_fts WHERE cell_id IN (${placeholders})`, + chunk.map(o => o.cell_id) + ); + } + await db.run("COMMIT"); + + // Verify only the kept cell remains in FTS + const ftsAfter = await db.all<{ cell_id: string }>( + "SELECT DISTINCT cell_id FROM cells_fts" + ); + const remainingIds = ftsAfter.map(r => r.cell_id); + assert.ok(remainingIds.includes("FTS_KEEP_001"), "Kept cell should remain in FTS"); + assert.ok(!remainingIds.includes("ORPHAN_MANUAL_001"), "Orphan 1 should be cleaned up"); + assert.ok(!remainingIds.includes("ORPHAN_MANUAL_002"), "Orphan 2 should be cleaned up"); + + await db.close(); + }); + }); + + // ── WAL auto-checkpoint configuration test ────────────────────────────── + + suite("WAL auto-checkpoint configuration", () => { + test("wal_autocheckpoint can be set and read back", async function () { + skipIfNotReady(this); + + const tmpDir = realOs.tmpdir(); + const dbPath = realPath.join(tmpDir, `wal-autocp-${Date.now()}.sqlite`); + + let db: AsyncDatabase | null = null; + try { + db = await AsyncDatabase.open(dbPath); + await db.exec("PRAGMA journal_mode = WAL"); + + // Set auto-checkpoint to 500 pages (matching our production config) + await db.exec("PRAGMA wal_autocheckpoint = 500"); + + const result = await db.get<{ wal_autocheckpoint: number }>( + "PRAGMA wal_autocheckpoint" + ); + assert.strictEqual(result?.wal_autocheckpoint, 500); + } finally { + if (db) { try { await db.close(); } catch { /* */ } } + try { realFs.unlinkSync(dbPath); } catch { /* */ } + try { realFs.unlinkSync(dbPath + "-wal"); } catch { /* */ } + try { realFs.unlinkSync(dbPath + "-shm"); } catch { /* */ } + } + }); + }); + + // ── Schema migration framework tests ──────────────────────────────────── + + suite("Schema versioning and migration patterns", () => { + test("schema version can be upgraded step-by-step", async function () { + skipIfNotReady(this); + + const db = await openTestDatabase(); + + // Verify current version + const v1 = await db.get<{ version: number }>( + "SELECT version FROM schema_info WHERE id = 1" + ); + assert.strictEqual(v1?.version, CURRENT_SCHEMA_VERSION); + + // Simulate a future migration: bump version + const nextVersion = CURRENT_SCHEMA_VERSION + 1; + await db.run( + "UPDATE schema_info SET version = ? WHERE id = 1", + [nextVersion] + ); + + const v2 = await db.get<{ version: number }>( + "SELECT version FROM schema_info WHERE id = 1" + ); + assert.strictEqual(v2?.version, nextVersion); + + await db.close(); + }); + + test("schema_info enforces single-row constraint", async function () { + skipIfNotReady(this); + + const db = await openTestDatabase(); + + // Attempt to insert a second row — should fail due to CHECK(id = 1) + await assert.rejects( + () => + db.run( + "INSERT INTO schema_info (id, version) VALUES (2, ?)", + [CURRENT_SCHEMA_VERSION] + ), + /CHECK constraint failed/i + ); + + await db.close(); + }); + + test("project identity can be stored and verified", async function () { + skipIfNotReady(this); + + const db = await openTestDatabase(); + const projectId = "test-project-" + Date.now(); + const projectName = "Test Project"; + + await db.run( + "UPDATE schema_info SET project_id = ?, project_name = ? WHERE id = 1", + [projectId, projectName] + ); + + const info = await db.get<{ + version: number; + project_id: string; + project_name: string; + }>("SELECT version, project_id, project_name FROM schema_info WHERE id = 1"); + + assert.strictEqual(info?.version, CURRENT_SCHEMA_VERSION); + assert.strictEqual(info?.project_id, projectId); + assert.strictEqual(info?.project_name, projectName); + + // Simulate project mismatch detection + const differentProjectId = "different-project"; + const matches = info?.project_id === differentProjectId; + assert.strictEqual(matches, false, "Should detect project ID mismatch"); + + await db.close(); + }); + + test("nuke-and-recreate pattern: drop all tables and recreate schema", async function () { + skipIfNotReady(this); + + const db = await openTestDatabase(); + + // Insert some data + const fileId = await insertFile(db, "/nuke/test.source", "source"); + await insertCell(db, "NUKE_001", { sFileId: fileId, sContent: "before nuke" }); + + // Verify data exists + const before = await db.get<{ count: number }>( + "SELECT COUNT(*) as count FROM cells" + ); + assert.ok((before?.count ?? 0) > 0); + + // Simulate nuke: drop all user tables (in correct order for FK) + await db.exec("DROP TABLE IF EXISTS words"); + await db.exec("DROP TABLE IF EXISTS cells_fts"); + await db.exec("DROP TABLE IF EXISTS cells"); + await db.exec("DROP TABLE IF EXISTS files"); + await db.exec("DROP TABLE IF EXISTS sync_metadata"); + await db.exec("DROP TABLE IF EXISTS schema_info"); + + // Recreate schema from shared constants + await db.exec(CREATE_TABLES_SQL); + await db.exec(CREATE_INDEXES_SQL); + for (const trigger of ALL_TRIGGERS) { + await db.run(trigger); + } + await db.run(CREATE_SCHEMA_INFO_SQL); + await db.run( + "INSERT INTO schema_info (id, version) VALUES (1, ?)", + [CURRENT_SCHEMA_VERSION] + ); + + // Verify tables are empty but schema is valid + const after = await db.get<{ count: number }>( + "SELECT COUNT(*) as count FROM cells" + ); + assert.strictEqual(after?.count, 0); + + const version = await db.get<{ version: number }>( + "SELECT version FROM schema_info WHERE id = 1" + ); + assert.strictEqual(version?.version, CURRENT_SCHEMA_VERSION); + + // Verify we can still insert data after recreation + const newFileId = await insertFile(db, "/nuke/after.source", "source"); + await insertCell(db, "NUKE_AFTER_001", { sFileId: newFileId, sContent: "after nuke" }); + + const afterInsert = await db.get<{ count: number }>( + "SELECT COUNT(*) as count FROM cells" + ); + assert.strictEqual(afterInsert?.count, 1); + + await db.close(); + }); + }); + + // ── ensureOpen file-check improvement test ────────────────────────────── + + suite("Database file existence validation", () => { + test("detects missing database file on disk", async function () { + skipIfNotReady(this); + + const tmpDir = realOs.tmpdir(); + const dbPath = realPath.join(tmpDir, `existence-check-${Date.now()}.sqlite`); + + let db: AsyncDatabase | null = null; + try { + db = await AsyncDatabase.open(dbPath); + await db.exec("PRAGMA journal_mode = WAL"); + await db.exec(CREATE_TABLES_SQL); + + // Verify file exists + assert.ok(realFs.existsSync(dbPath), "DB file should exist"); + + // Close and delete + await db.close(); + db = null; + realFs.unlinkSync(dbPath); + + assert.ok(!realFs.existsSync(dbPath), "DB file should be gone"); + } finally { + if (db) { try { await db.close(); } catch { /* */ } } + try { realFs.unlinkSync(dbPath); } catch { /* */ } + try { realFs.unlinkSync(dbPath + "-wal"); } catch { /* */ } + try { realFs.unlinkSync(dbPath + "-shm"); } catch { /* */ } + } + }); + }); +}); diff --git a/src/test/suite/projectUtils.test.ts b/src/test/suite/projectUtils.test.ts index 74fa8437b..4f3a7af24 100644 --- a/src/test/suite/projectUtils.test.ts +++ b/src/test/suite/projectUtils.test.ts @@ -32,7 +32,6 @@ suite("ProjectUtils - updateMetadataFile Tests", () => { const initialMetadata = { projectName: "Original Project Name", languages: ["en", "fr"], - spellcheckIsEnabled: false, meta: { version: "0.0.0", generator: { @@ -104,7 +103,6 @@ suite("ProjectUtils - updateMetadataFile Tests", () => { if (key === "sourceLanguage") return "en"; if (key === "targetLanguage") return "fr"; if (key === "abbreviation") return "ORIG"; - if (key === "spellcheckIsEnabled") return false; if (key === "validationCount") return 1; if (key === "validationCountAudio") return 1; return undefined; @@ -165,7 +163,6 @@ suite("ProjectUtils - updateMetadataFile Tests", () => { if (key === "sourceLanguage") return "en"; if (key === "targetLanguage") return "fr"; if (key === "abbreviation") return "ORIG"; - if (key === "spellcheckIsEnabled") return false; if (key === "validationCount") return 1; if (key === "validationCountAudio") return 1; return undefined; @@ -222,7 +219,6 @@ suite("ProjectUtils - updateMetadataFile Tests", () => { if (key === "sourceLanguage") return "en"; if (key === "targetLanguage") return "fr"; if (key === "abbreviation") return "NEW"; - if (key === "spellcheckIsEnabled") return false; if (key === "validationCount") return 5; if (key === "validationCountAudio") return 3; return undefined; @@ -290,7 +286,6 @@ suite("ProjectUtils - updateMetadataFile Tests", () => { if (key === "sourceLanguage") return "es"; if (key === "targetLanguage") return "pt"; if (key === "abbreviation") return "ORIG"; - if (key === "spellcheckIsEnabled") return false; if (key === "validationCount") return 1; if (key === "validationCountAudio") return 1; return undefined; @@ -332,61 +327,6 @@ suite("ProjectUtils - updateMetadataFile Tests", () => { assert.strictEqual(languagesEdit!.author, "test-author", "Edit should have correct author"); }); - test("updateMetadataFile creates edit entry for spellcheckIsEnabled changes", async () => { - // Mock configuration - const mockConfig: MockWorkspaceConfiguration = { - update: sandbox.stub().resolves(), - get: sandbox.stub().callsFake((key: string) => { - if (key === "projectName") return "Original Project Name"; - if (key === "userName") return "Original User"; - if (key === "userEmail") return "original@example.com"; - if (key === "sourceLanguage") return "en"; - if (key === "targetLanguage") return "fr"; - if (key === "abbreviation") return "ORIG"; - if (key === "spellcheckIsEnabled") return true; - if (key === "validationCount") return 1; - if (key === "validationCountAudio") return 1; - return undefined; - }), - }; - // Mock auth API for getCurrentUserName - const mockAuthApi = { - getUserInfo: sandbox.stub().resolves({ username: "test-author", email: "test@example.com" }), - getAuthStatus: sandbox.stub().returns({ isAuthenticated: true }), - // ... other methods as needed - }; - - // Stub getAuthApi to return mock auth API - const extensionModule = await import("../../extension"); - sandbox.stub(extensionModule, "getAuthApi").returns(mockAuthApi as any); - - // Stub getConfiguration to return different configs based on section - sandbox.stub(vscode.workspace, "getConfiguration").callsFake((section?: string) => { - if (section === "codex-project-manager") { - return mockConfig as vscode.WorkspaceConfiguration; - } - return mockConfig as vscode.WorkspaceConfiguration; - }); - - // Call updateMetadataFile - await updateMetadataFile(); - - // Read updated metadata - const afterContent = await vscode.workspace.fs.readFile(metadataPath); - const afterMetadata = JSON.parse(new TextDecoder().decode(afterContent)); - const edits: ProjectEditHistory[] = afterMetadata.edits || []; - - const spellcheckEdit = edits.find((e) => - EditMapUtils.equals(e.editMap, EditMapUtils.spellcheckIsEnabled()) - ); - - assert.ok(spellcheckEdit, "Should have spellcheckIsEnabled edit entry"); - assert.strictEqual(spellcheckEdit!.value, true, "SpellcheckIsEnabled edit should have correct value"); - assert.strictEqual(spellcheckEdit!.type, EditType.USER_EDIT, "Edit should be USER_EDIT type"); - assert.ok(typeof spellcheckEdit!.timestamp === "number", "Edit should have timestamp"); - assert.strictEqual(spellcheckEdit!.author, "test-author", "Edit should have correct author"); - }); - test("updateMetadataFile creates edit entries for all changed fields", async () => { // Mock configuration with multiple changes const mockConfig: MockWorkspaceConfiguration = { @@ -398,7 +338,6 @@ suite("ProjectUtils - updateMetadataFile Tests", () => { if (key === "sourceLanguage") return "de"; if (key === "targetLanguage") return "it"; if (key === "abbreviation") return "UPD"; - if (key === "spellcheckIsEnabled") return true; if (key === "validationCount") return 10; if (key === "validationCountAudio") return 7; return undefined; @@ -440,10 +379,6 @@ suite("ProjectUtils - updateMetadataFile Tests", () => { assert.ok(edits.some((e) => isEditPath(e, EditMapUtils.metaField("validationCountAudio"))), "Should have meta.validationCountAudio edit"); assert.ok(edits.some((e) => isEditPath(e, EditMapUtils.metaField("abbreviation"))), "Should have meta.abbreviation edit"); assert.ok(edits.some((e) => isEditPath(e, EditMapUtils.languages())), "Should have languages edit"); - assert.ok( - edits.some((e) => isEditPath(e, EditMapUtils.spellcheckIsEnabled())), - "Should have spellcheckIsEnabled edit" - ); // Verify values match const projectNameEdit = edits.find((e) => isEditPath(e, EditMapUtils.projectName())); @@ -468,8 +403,6 @@ suite("ProjectUtils - updateMetadataFile Tests", () => { const languagesEdit = edits.find((e) => isEditPath(e, EditMapUtils.languages())); assert.deepStrictEqual(languagesEdit!.value, ["de", "it"], "Languages edit should have correct value"); - const spellcheckEdit = edits.find((e) => isEditPath(e, EditMapUtils.spellcheckIsEnabled())); - assert.strictEqual(spellcheckEdit!.value, true, "SpellcheckIsEnabled edit should have correct value"); }); test("updateMetadataFile does not create edit if field unchanged", async () => { @@ -483,7 +416,6 @@ suite("ProjectUtils - updateMetadataFile Tests", () => { if (key === "sourceLanguage") return "en"; if (key === "targetLanguage") return "fr"; if (key === "abbreviation") return "ORIG"; - if (key === "spellcheckIsEnabled") return false; if (key === "validationCount") return 1; if (key === "validationCountAudio") return 1; return undefined; @@ -540,7 +472,6 @@ suite("ProjectUtils - updateMetadataFile Tests", () => { if (key === "sourceLanguage") return "en"; if (key === "targetLanguage") return "fr"; if (key === "abbreviation") return "ORIG"; - if (key === "spellcheckIsEnabled") return false; if (key === "validationCount") return 1; if (key === "validationCountAudio") return 1; return undefined; diff --git a/src/test/suite/validation/audioValidation.test.ts b/src/test/suite/validation/audioValidation.test.ts index cdeebdd4f..969b41d2e 100644 --- a/src/test/suite/validation/audioValidation.test.ts +++ b/src/test/suite/validation/audioValidation.test.ts @@ -42,7 +42,7 @@ suite("Audio Validation Test Suite", () => { // Stub background tasks to avoid side-effects and assert calls sinon.restore(); sinon.stub((CodexCellDocument as any).prototype, "addCellToIndexImmediately").callsFake(() => { }); - sinon.stub((CodexCellDocument as any).prototype, "syncAllCellsToDatabase").resolves(); + sinon.stub((CodexCellDocument as any).prototype, "syncDirtyCellsToDatabase").resolves(); sinon.stub((CodexCellDocument as any).prototype, "populateSourceCellMapFromIndex").resolves(); }); @@ -581,10 +581,10 @@ suite("Audio Validation Test Suite", () => { isDeleted: false, }); - // Note: syncAllCellsToDatabase is already stubbed in setup method + // Note: syncDirtyCellsToDatabase is already stubbed in setup method // We can verify the stub was called by checking if it exists - const syncStub = (CodexCellDocument as any).prototype.syncAllCellsToDatabase; - assert.ok(syncStub && typeof syncStub === 'function', "syncAllCellsToDatabase should be stubbed"); + const syncStub = (CodexCellDocument as any).prototype.syncDirtyCellsToDatabase; + assert.ok(syncStub && typeof syncStub === 'function', "syncDirtyCellsToDatabase should be stubbed"); // Act: Validate audio await document.validateCellAudio(cellId, true); diff --git a/src/test/suite/validation/audioValidationDatabase.test.ts b/src/test/suite/validation/audioValidationDatabase.test.ts index 1b7f76503..6e28027b9 100644 --- a/src/test/suite/validation/audioValidationDatabase.test.ts +++ b/src/test/suite/validation/audioValidationDatabase.test.ts @@ -71,7 +71,7 @@ suite("Audio Validation Database Integration Test Suite", () => { // Stub database sync to capture what would be saved let capturedAfterMerge: any = null; - const syncStub = sinon.stub((CodexCellDocument as any).prototype, "syncAllCellsToDatabase").callsFake(async function (this: any) { + const syncStub = sinon.stub((CodexCellDocument as any).prototype, "syncDirtyCellsToDatabase").callsFake(async function (this: any) { capturedAfterMerge = this._documentData.cells.find((c: any) => c.metadata?.id === cellId); return Promise.resolve(); }); @@ -164,7 +164,7 @@ suite("Audio Validation Database Integration Test Suite", () => { // Mock the database sync to capture the data that would be stored let capturedCellData: any = null; - const syncStub = sinon.stub((CodexCellDocument as any).prototype, "syncAllCellsToDatabase").callsFake(async function (this: any) { + const syncStub = sinon.stub((CodexCellDocument as any).prototype, "syncDirtyCellsToDatabase").callsFake(async function (this: any) { // Capture the cell data that would be synced to database capturedCellData = this._documentData.cells.find((c: any) => c.metadata?.id === cellId); return Promise.resolve(); @@ -247,7 +247,7 @@ suite("Audio Validation Database Integration Test Suite", () => { // Mock database sync to capture data let capturedCellData: any = null; - const syncStub = sinon.stub((CodexCellDocument as any).prototype, "syncAllCellsToDatabase").callsFake(async function (this: any) { + const syncStub = sinon.stub((CodexCellDocument as any).prototype, "syncDirtyCellsToDatabase").callsFake(async function (this: any) { capturedCellData = this._documentData.cells.find((c: any) => c.metadata?.id === cellId); return Promise.resolve(); }); @@ -333,7 +333,7 @@ suite("Audio Validation Database Integration Test Suite", () => { // Mock database sync to capture data - capture ALL syncs const allSyncedData: any[] = []; - const syncStub = sinon.stub((CodexCellDocument as any).prototype, "syncAllCellsToDatabase").callsFake(async function (this: any) { + const syncStub = sinon.stub((CodexCellDocument as any).prototype, "syncDirtyCellsToDatabase").callsFake(async function (this: any) { // Deep clone to avoid reference issues const cellData = JSON.parse(JSON.stringify(this._documentData.cells.find((c: any) => c.metadata?.id === cellId))); @@ -455,7 +455,7 @@ suite("Audio Validation Database Integration Test Suite", () => { // Mock database sync to capture data let capturedCellDataAfterRevalidate: any = null; - const syncStub = sinon.stub((CodexCellDocument as any).prototype, "syncAllCellsToDatabase").callsFake(async function (this: any) { + const syncStub = sinon.stub((CodexCellDocument as any).prototype, "syncDirtyCellsToDatabase").callsFake(async function (this: any) { // Deep clone to avoid reference issues capturedCellDataAfterRevalidate = JSON.parse(JSON.stringify(this._documentData.cells.find((c: any) => c.metadata?.id === cellId))); return Promise.resolve(); @@ -526,7 +526,7 @@ suite("Audio Validation Database Integration Test Suite", () => { // Mock database sync to capture data let capturedCellData: any = null; - const syncStub = sinon.stub((CodexCellDocument as any).prototype, "syncAllCellsToDatabase").callsFake(async function (this: any) { + const syncStub = sinon.stub((CodexCellDocument as any).prototype, "syncDirtyCellsToDatabase").callsFake(async function (this: any) { capturedCellData = this._documentData.cells.find((c: any) => c.metadata?.id === cellId); return Promise.resolve(); }); @@ -609,7 +609,7 @@ suite("Audio Validation Database Integration Test Suite", () => { // Mock database sync to capture data - use a fresh array for this test const capturedData: any[] = []; - const syncStub = sinon.stub(document as any, "syncAllCellsToDatabase").callsFake(async function (this: any) { + const syncStub = sinon.stub(document as any, "syncDirtyCellsToDatabase").callsFake(async function (this: any) { const cellData = this._documentData.cells.find((c: any) => c.metadata?.id === cellId); if (cellData) { // Only capture data for the specific cell we're testing diff --git a/src/tsServer/connection.ts b/src/tsServer/connection.ts deleted file mode 100644 index 14f325188..000000000 --- a/src/tsServer/connection.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - createConnection, - TextDocuments, - Diagnostic, - DiagnosticSeverity, - ProposedFeatures, - InitializeParams, - DidChangeConfigurationNotification, - CompletionItem, - TextDocumentPositionParams, - TextDocumentSyncKind, - InitializeResult, -} from "vscode-languageserver/node"; - -import { TextDocument } from "vscode-languageserver-textdocument"; -// import { SpellChecker } from '../server/spellCheck'; -// import { WordSuggestionProvider } from '../activationHelpers/contextAware/server/forecasting'; - -// Create a connection for the server, using Node's IPC as a transport. -// Also include all preview / proposed LSP features. -const connection = createConnection(ProposedFeatures.all); - -// Create a simple text document manager. -const documents: TextDocuments = new TextDocuments(TextDocument); - -let hasConfigurationCapability = false; -let hasWorkspaceFolderCapability = false; - -connection.onInitialize((params: InitializeParams) => { - const capabilities = params.capabilities; - - hasConfigurationCapability = !!( - capabilities.workspace && !!capabilities.workspace.configuration - ); - hasWorkspaceFolderCapability = !!( - capabilities.workspace && !!capabilities.workspace.workspaceFolders - ); - - const result: InitializeResult = { - capabilities: { - textDocumentSync: TextDocumentSyncKind.Incremental, - // Tell the client that this server supports code completion. - completionProvider: { - resolveProvider: true, - }, - }, - }; - if (hasWorkspaceFolderCapability) { - result.capabilities.workspace = { - workspaceFolders: { - supported: true, - }, - }; - } - return result; -}); - -connection.onInitialized(() => { - if (hasConfigurationCapability) { - // Register for all configuration changes. - connection.client.register(DidChangeConfigurationNotification.type, undefined); - } - if (hasWorkspaceFolderCapability) { - connection.workspace.onDidChangeWorkspaceFolders((_event) => { - connection.console.log("Workspace folder change event received."); - }); - } -}); - -// The content of a text document has changed. This event is emitted -// when the text document first opened or when its content has changed. -documents.onDidChangeContent((change) => { - validateTextDocument(change.document); -}); - -async function validateTextDocument(textDocument: TextDocument): Promise { - // In this function, you'll use your existing logic from SpellCheckDiagnosticsProvider - // to validate the document and send diagnostics. - // You'll need to adapt your existing code to work with the LSP types. -} - -connection.onDidChangeWatchedFiles((_change) => { - // Monitored files have change in VSCode - connection.console.log("We received a file change event"); -}); - -// This handler provides the initial list of the completion items. -connection.onCompletion((_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => { - // This is where you'll use your WordSuggestionProvider logic - // You'll need to adapt it to return CompletionItem[] instead of vscode.CompletionItem[] - return []; -}); - -// Make the text document manager listen on the connection -// for open, change and close text document events -documents.listen(connection); - -// Listen on the connection -connection.listen(); diff --git a/src/tsServer/forecasting.ts b/src/tsServer/forecasting.ts deleted file mode 100644 index 00f524b0c..000000000 --- a/src/tsServer/forecasting.ts +++ /dev/null @@ -1,65 +0,0 @@ -import * as fs from "fs"; -import * as path from "path"; - -class MarkovChain { - private forwardChain: Map>; - private backwardChain: Map>; - - constructor() { - this.forwardChain = new Map(); - this.backwardChain = new Map(); - } - - addPair(word1: string, word2: string, direction: "forward" | "backward") { - const chain = direction === "forward" ? this.forwardChain : this.backwardChain; - if (!chain.has(word1)) { - chain.set(word1, new Map()); - } - const nextWords = chain.get(word1)!; - nextWords.set(word2, (nextWords.get(word2) || 0) + 1); - } - - getNextWords(word: string, direction: "forward" | "backward"): string[] { - const chain = direction === "forward" ? this.forwardChain : this.backwardChain; - const nextWords = chain.get(word); - if (!nextWords) return []; - return Array.from(nextWords.entries()) - .sort((a, b) => b[1] - a[1]) - .map((entry) => entry[0]); - } - - getSimilarWords(word: string): string[] { - const leftNeighbors = this.getNextWords(word, "backward"); - const rightNeighbors = this.getNextWords(word, "forward"); - - const similarWords = new Map(); - - // Increase the number of neighbors considered - for (const left of leftNeighbors.slice(0, 5)) { - for (const right of rightNeighbors.slice(0, 5)) { - const middleWords = this.getNextWords(left, "forward").filter((w) => - this.getNextWords(w, "forward").includes(right) - ); - middleWords.forEach((w) => { - similarWords.set(w, (similarWords.get(w) || 0) + 1); - }); - } - } - - // Add words with similar context - this.getNextWords(word, "forward").forEach((w) => { - similarWords.set(w, (similarWords.get(w) || 0) + 2); - }); - this.getNextWords(word, "backward").forEach((w) => { - similarWords.set(w, (similarWords.get(w) || 0) + 2); - }); - - // Sort by frequency and similarity score - const result = Array.from(similarWords.entries()) - .sort((a, b) => b[1] - a[1]) - .map((entry) => entry[0]) - .filter((w) => w !== word); - - return result.slice(0, 10); - } -} diff --git a/src/tsServer/registerClientCommands.ts b/src/tsServer/registerClientCommands.ts deleted file mode 100644 index 11c7334b5..000000000 --- a/src/tsServer/registerClientCommands.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { GetAlertCodes, AlertCodesServerResponse } from "@types"; -import * as vscode from "vscode"; -import { LanguageClient } from "vscode-languageclient/node"; - -export function registerClientCommands( - context: vscode.ExtensionContext, - client: LanguageClient | undefined -): vscode.Disposable { - const disposables: vscode.Disposable[] = []; - - disposables.push( - vscode.commands.registerCommand( - "codex-editor-extension.spellCheckText", - async (text: string, cellId: string) => { - if (!client) { - console.error("[Language Server] spellCheckText failed: Language server client is not available - this indicates a language server initialization failure"); - // Return structure that consumers expect - return { corrections: [], matches: [] }; - } - - try { - return await client.sendRequest("spellcheck/check", { text, cellId }); - } catch (error) { - console.error("[Language Server] spellCheckText request failed:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - text: text.substring(0, 100), // Log first 100 chars for debugging - cellId, - clientState: client ? "initialized" : "not_initialized" - }); - // Return safe fallback - return { corrections: [], matches: [] }; - } - } - ) - ); - - disposables.push( - vscode.commands.registerCommand( - "codex-editor-extension.alertCodes", - async (args: GetAlertCodes): Promise => { - if (!client) { - console.error("[Language Server] alertCodes failed: Language server client is not available - this indicates a language server initialization failure", { - requestedCells: args.length, - cellIds: args.map(arg => arg.cellId) - }); - // Return safe fallback maintaining expected structure - return args.map((arg) => ({ - code: 0, // 0 = no alerts (safe fallback) - cellId: arg.cellId, - savedSuggestions: { suggestions: [] }, - })); - } - - try { - return await client.sendRequest( - "spellcheck/getAlertCodes", - args - ); - } catch (error) { - console.error("[Language Server] alertCodes request failed:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - requestedCells: args.length, - cellIds: args.map(arg => arg.cellId), - clientState: client ? "initialized" : "not_initialized" - }); - // Return safe fallback for all requested cells - return args.map((arg) => ({ - code: 0, - cellId: arg.cellId, - savedSuggestions: { suggestions: [] }, - })); - } - } - ) - ); - - disposables.push( - vscode.commands.registerCommand( - "codex-editor-extension.getSimilarWords", - async (word: string) => { - if (!client) { - console.error("[Language Server] getSimilarWords failed: Language server client is not available - this indicates a language server initialization failure", { - word - }); - return []; - } - - try { - return await client.sendRequest("server.getSimilarWords", [word]); - } catch (error) { - console.error("[Language Server] getSimilarWords request failed:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - word, - clientState: client ? "initialized" : "not_initialized" - }); - return []; - } - } - ) - ); - - disposables.push( - vscode.commands.registerCommand("spellcheck.addWord", async (words: string | string[]) => { - console.log("spellcheck.addWord command executed", { words }); - - if (!client) { - console.error("[Language Server] addWord failed: Language server client is not available - this indicates a language server initialization failure", { - words: Array.isArray(words) ? words : [words] - }); - vscode.window.showErrorMessage("Cannot add word to dictionary: Spellcheck service is not available due to language server issues"); - return; - } - - const wordsArray = Array.isArray(words) ? words : [words]; - console.log("sending request to language server"); - - try { - const response = await client.sendRequest("spellcheck/addWord", { - words: wordsArray, - }); - console.log("Add word response from language server:", response); - } catch (error) { - console.error("[Language Server] addWord request failed:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - words: wordsArray, - clientState: client ? "initialized" : "not_initialized" - }); - vscode.window.showErrorMessage(`Failed to add word to dictionary: ${error instanceof Error ? error.message : String(error)}`); - } - }) - ); - - context.subscriptions.push(...disposables); - - return { - dispose: () => { - disposables.forEach((d) => d.dispose()); - }, - }; -} diff --git a/src/tsServer/registerClientOnRequests.ts b/src/tsServer/registerClientOnRequests.ts deleted file mode 100644 index 9051c92ac..000000000 --- a/src/tsServer/registerClientOnRequests.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { LanguageClient } from "vscode-languageclient/node"; -import { Database } from "fts5-sql-bundle"; -import * as vscode from "vscode"; -import { getEntry, bulkAddWords } from "../sqldb"; - -const DEBUG_REGISTER_CLIENT_ON_REQUESTS = false; -function debug(message: string, ...args: any[]): void { - if (DEBUG_REGISTER_CLIENT_ON_REQUESTS) { - console.log(`[registerClientOnRequests] ${message}`, ...args); - } -} - -// Define message types -export const CustomRequests = { - CheckWord: "custom/checkWord", - GetSuggestions: "custom/getSuggestions", - AddWords: "custom/addWords", -} as const; - -function generateId(): string { - return Math.random().toString(36).substring(2, 15); -} - -export default async function registerClientOnRequests(client: LanguageClient, db: Database) { - try { - debug("[Language Server] Registering client request handlers with database..."); - - // Register handlers - await client.start(); // Make sure client is started first - - // Register the handlers - client.onRequest( - CustomRequests.CheckWord, - async ({ word, caseSensitive = false }: { word: string; caseSensitive: boolean; }) => { - try { - if (!db) { - console.error("[Language Server] Database not available for checkWord request:", { - word, - caseSensitive - }); - return { exists: false }; - } - - const entry = getEntry(db, word, caseSensitive); - return { exists: entry }; - } catch (error) { - console.error("[Language Server] CheckWord request failed:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - word, - caseSensitive, - databaseAvailable: !!db - }); - return { exists: false }; - } - } - ); - - client.onRequest( - "workspace/executeCommand", - async (params: { command: string; args: any[]; }) => { - try { - // Execute the command in the main extension context - const result = await vscode.commands.executeCommand(params.command, ...params.args); - return result; - } catch (error) { - console.error("[Language Server] Workspace command execution failed:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - command: params?.command, - argsCount: params?.args?.length || 0 - }); - throw error; // Re-throw to let caller handle command-specific errors - } - } - ); - - client.onRequest(CustomRequests.GetSuggestions, async (word: string) => { - try { - if (!db) { - console.error("[Language Server] Database not available for getSuggestions request:", { - word - }); - return []; - } - - // First, create the Levenshtein function if it doesn't exist - db.create_function("levenshtein", (a: string, b: string) => { - if (a.length === 0) return b.length; - if (b.length === 0) return a.length; - - const matrix = Array(b.length + 1) - .fill(null) - .map(() => Array(a.length + 1).fill(null)); - - for (let i = 0; i <= a.length; i++) matrix[0][i] = i; - for (let j = 0; j <= b.length; j++) matrix[j][0] = j; - - for (let j = 1; j <= b.length; j++) { - for (let i = 1; i <= a.length; i++) { - const substitutionCost = a[i - 1] === b[j - 1] ? 0 : 1; - matrix[j][i] = Math.min( - matrix[j][i - 1] + 1, - matrix[j - 1][i] + 1, - matrix[j - 1][i - 1] + substitutionCost - ); - } - } - return matrix[b.length][a.length]; - }); - - // Use Levenshtein distance to find similar words - const stmt = db.prepare(` - SELECT head_word - FROM entries - WHERE head_word LIKE '%' || ? || '%' COLLATE NOCASE - ORDER BY levenshtein(LOWER(head_word), LOWER(?)) - LIMIT 100 - `); - - const words: string[] = []; - stmt.bind([word[0], word]); - - while (stmt.step()) { - words.push(stmt.getAsObject()["head_word"] as string); - } - stmt.free(); - return words; - } catch (error) { - console.error("[Language Server] GetSuggestions request failed:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - word, - databaseAvailable: !!db - }); - return []; - } - }); - - client.onRequest(CustomRequests.AddWords, async (words: string[]) => { - try { - if (!db) { - console.error("[Language Server] Database not available for addWords request:", { - words, - wordsCount: words?.length || 0 - }); - return false; - } - - await bulkAddWords( - db, - words.map((word) => ({ - headWord: word, - definition: "", - authorId: "", - isUserEntry: true, - id: generateId(), - })) - ); - return true; - } catch (error) { - console.error("[Language Server] AddWords request failed:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - words, - wordsCount: words?.length || 0, - databaseAvailable: !!db - }); - return false; - } - }); - - debug("[Language Server] Client request handlers registered successfully"); - } catch (error) { - console.error("[Language Server] Critical failure registering client request handlers:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - clientAvailable: !!client, - databaseAvailable: !!db - }); - throw error; // Re-throw to let calling code handle the initialization failure - } -} diff --git a/src/tsServer/registerLanguageServer.ts b/src/tsServer/registerLanguageServer.ts deleted file mode 100644 index 23056b4e4..000000000 --- a/src/tsServer/registerLanguageServer.ts +++ /dev/null @@ -1,181 +0,0 @@ -import * as vscode from "vscode"; -import { - LanguageClient, - LanguageClientOptions, - ServerOptions, - TransportKind, -} from "vscode-languageclient/node"; -import { NOTEBOOK_TYPE } from "../utils/codexNotebookUtils"; - -const DEBUG_REGISTER_LANGUAGE_SERVER = false; -function debug(message: string, ...args: any[]): void { - if (DEBUG_REGISTER_LANGUAGE_SERVER) { - console.log(`[registerLanguageServer] ${message}`, ...args); - } -} - -export async function registerLanguageServer( - context: vscode.ExtensionContext -): Promise { - try { - const config = vscode.workspace.getConfiguration("codex-editor-extension-server"); - const isCopilotEnabled = config.get("enable", true); - - if (!isCopilotEnabled) { - debug("[Language Server] Language server is disabled by configuration"); - vscode.window.showInformationMessage( - "Codex Extension Server is disabled. Project was not indexed." - ); - return undefined; - } - - debug("[Language Server] Registering the Codex Copilot Language Server..."); - const serverModule = context.asAbsolutePath("out/server.js"); - - // Validate server module exists - try { - const fs = require('fs'); // eslint-disable-line @typescript-eslint/no-var-requires - if (!fs.existsSync(serverModule)) { - console.error("[Language Server] Server module file not found:", { - expectedPath: serverModule, - absolutePath: context.asAbsolutePath("out/server.js") - }); - vscode.window.showErrorMessage("Language server module not found. Please ensure the extension is properly compiled."); - return undefined; - } - } catch (fsError) { - console.error("[Language Server] Failed to validate server module existence:", { - error: fsError instanceof Error ? fsError.message : String(fsError), - serverModule - }); - // Continue anyway, let the language client handle the error - } - - const debugOptions = { execArgv: ["--nolazy", "--inspect=6009"] }; - - const serverOptions: ServerOptions = { - run: { module: serverModule, transport: TransportKind.ipc }, - debug: { - module: serverModule, - transport: TransportKind.ipc, - options: debugOptions, - }, - }; - - const clientOptions: LanguageClientOptions = { - documentSelector: [ - { notebook: NOTEBOOK_TYPE, language: "*" }, - { scheme: "file", pattern: "**/*.codex" }, - ], - synchronize: { - fileEvents: vscode.workspace.createFileSystemWatcher("**/*.{dictionary,codex,source}"), - }, - }; - - debug("[Language Server] Creating the Codex Copilot Language Server client..."); - const client = new LanguageClient( - "codexCopilotLanguageServer", - "Codex Copilot Language Server", - serverOptions, - clientOptions - ); - - debug("[Language Server] Attempting to start the Codex Copilot Language Server..."); - - try { - await client.start(); - context.subscriptions.push(client); - - // Set up notification handlers - try { - client.onNotification("custom/dictionaryUpdated", () => { - vscode.commands.executeCommand("dictionaryTable.dictionaryUpdated"); - }); - } catch (notificationError) { - console.error("[Language Server] Failed to register dictionary notification handler:", { - error: notificationError instanceof Error ? notificationError.message : String(notificationError), - stack: notificationError instanceof Error ? notificationError.stack : undefined - }); - // Continue - this is not critical for basic functionality - } - - debug("[Language Server] Codex Copilot Language Server started successfully."); - return client; - } catch (startError) { - console.error("[Language Server] Failed to start language server on first attempt:", { - error: startError instanceof Error ? startError.message : String(startError), - stack: startError instanceof Error ? startError.stack : undefined, - serverModule, - clientOptions: JSON.stringify(clientOptions, null, 2) - }); - - // Attempt to restart the server - debug("[Language Server] Attempting to restart the language server..."); - try { - await client.stop(); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await client.start(); - - debug("[Language Server] Codex Copilot Language Server restarted successfully."); - context.subscriptions.push(client); - - // Re-register notification handlers after restart - try { - client.onNotification("custom/dictionaryUpdated", () => { - vscode.commands.executeCommand("dictionaryTable.dictionaryUpdated"); - }); - } catch (notificationError) { - console.error("[Language Server] Failed to register dictionary notification handler after restart:", { - error: notificationError instanceof Error ? notificationError.message : String(notificationError) - }); - } - - return client; - } catch (restartError) { - console.error("[Language Server] Critical failure: Failed to restart the language server:", { - error: restartError instanceof Error ? restartError.message : String(restartError), - stack: restartError instanceof Error ? restartError.stack : undefined, - originalError: startError instanceof Error ? startError.message : String(startError), - serverModule - }); - - vscode.window.showErrorMessage( - "Language server failed to start. Spellcheck and smart edit features will not be available. " + - "Check the console for detailed error information." - ); - - return undefined; - } - } - } catch (error) { - console.error("[Language Server] Critical failure during language server registration:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - contextAvailable: !!context - }); - - vscode.window.showErrorMessage( - "Failed to initialize language server. Spellcheck and smart edit features will not be available." - ); - - return undefined; - } -} - -export function deactivate(client: LanguageClient): Thenable | undefined { - if (!client) { - debug("[Language Server] No Codex Copilot Language Server client to stop."); - return undefined; - } - - debug("[Language Server] Stopping Codex Copilot Language Server..."); - return client.stop().then( - () => debug("[Language Server] Codex Copilot Language Server stopped successfully."), - (error) => { - console.error("[Language Server] Error stopping Codex Copilot Language Server:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined - }); - } - ); -} diff --git a/src/tsServer/server.ts b/src/tsServer/server.ts deleted file mode 100644 index cb9f0f4c6..000000000 --- a/src/tsServer/server.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { - createConnection, - TextDocuments, - ProposedFeatures, - InitializeParams, - InitializeResult, -} from "vscode-languageserver/node"; -import { TextDocument } from "vscode-languageserver-textdocument"; -import { - SpellChecker, -} from "./spellCheck"; -import { - MatchesEntity, -} from "../../webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/types"; -import { RequestType } from "vscode-languageserver"; -import { tokenizeText } from "../utils/nlpUtils"; -import { GetAlertCodes, AlertCodesServerResponse } from "@types"; - -const DEBUG_MODE = false; // Flag for debug mode - -const connection = createConnection(ProposedFeatures.all); -const documents = new TextDocuments(TextDocument); -const ExecuteCommandRequest = new RequestType<{ command: string; args: any[]; }, any, void>( - "workspace/executeCommand" -); - -let spellChecker: SpellChecker; -let lastSmartEditsText: string | null = null; -let lastSmartEditResults: any[] | null = null; - -// Custom debug function -function debugLog(...args: any[]) { - if (DEBUG_MODE) { - console.log(new Date().toISOString(), ...args); - } -} - -// Define special phrases with their replacements and colors -let specialPhrases: { - phrase: string; - replacement: string; - color: string; - source: string; - leftToken: string; - rightToken: string; -}[] = [ - { - phrase: "hello world", - replacement: "hi", - color: "purple", - source: "llm", - leftToken: "", - rightToken: "", - }, - // Add more phrases as needed - ]; - -connection.onInitialize((params: InitializeParams) => { - const workspaceFolder = params.workspaceFolders?.[0].uri; - - debugLog(`Initializing with workspace folder: ${workspaceFolder}`); - - // Initialize services - debugLog("Initializing SpellChecker..."); - spellChecker = new SpellChecker(connection); - debugLog("SpellChecker initialized."); - - return { - capabilities: { - textDocumentSync: { - openClose: true, - change: 1, // Incremental - }, - // Add other capabilities as needed - }, - } as InitializeResult; -}); -connection.onRequest( - "spellcheck/getAlertCodes", - async (params: GetAlertCodes): Promise => { - try { - debugLog("SERVER: Received spellcheck/getAlertCodes request:", { params }); - - const results = await Promise.all( - params.map(async (param) => { - try { - const words = tokenizeText({ - method: "whitespace_and_punctuation", - text: param.text, - }); - - // spellcheck - for (const word of words) { - try { - const spellCheckResult = await spellChecker.spellCheck(word); - debugLog("SERVER: Spell check result:", { spellCheckResult }); - if (spellCheckResult?.corrections?.length > 0) { - return { - code: 1, - cellId: param.cellId, - savedSuggestions: { suggestions: [] }, - }; - } - } catch (wordError) { - console.error("[Language Server] Spell check failed for word:", { - word, - cellId: param.cellId, - error: wordError instanceof Error ? wordError.message : String(wordError), - stack: wordError instanceof Error ? wordError.stack : undefined - }); - // Continue processing other words - } - } - - // debugLog("No smart edits found, checking for applicable prompt"); - // If no spelling errors or smart edits, check for applicable prompt - - const code = 0; - - return { - code, - cellId: param.cellId, - savedSuggestions: { suggestions: [] }, - }; - } catch (cellError) { - console.error("[Language Server] Alert code processing failed for cell:", { - cellId: param.cellId, - textLength: param.text?.length || 0, - error: cellError instanceof Error ? cellError.message : String(cellError), - stack: cellError instanceof Error ? cellError.stack : undefined - }); - // Return safe fallback for this cell - return { - code: 0, - cellId: param.cellId, - savedSuggestions: { suggestions: [] }, - }; - } - }) - ); - - debugLog("SERVER: Returning results:", { results }); - return results; - } catch (error) { - console.error("[Language Server] Critical failure in getAlertCodes:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - requestCount: params?.length || 0, - cellIds: params?.map(p => p.cellId) || [] - }); - // Return safe fallback for all requested cells - return params.map((param) => ({ - code: 0, - cellId: param.cellId, - savedSuggestions: { suggestions: [] }, - })); - } - } -); - -connection.onRequest("spellcheck/check", async (params: { text: string; cellId: string; }) => { - try { - debugLog("SERVER: Received spellcheck/check request:", { params }); - - const text = params.text; - const matches: MatchesEntity[] = []; - - // Start parallel requests for both smart edits and ICE suggestions - let smartEditsPromise: Promise; - let iceEditsPromise: Promise; - - try { - smartEditsPromise = lastSmartEditsText !== text - ? connection.sendRequest(ExecuteCommandRequest, { - command: "codex-smart-edits.getEdits", - args: [text, params.cellId], - }) - : Promise.resolve(lastSmartEditResults); - - // New ICE edits request - iceEditsPromise = connection.sendRequest(ExecuteCommandRequest, { - command: "codex-smart-edits.getIceEdits", - args: [text], - }); - } catch (requestError) { - console.error("[Language Server] Failed to initiate smart edits requests:", { - error: requestError instanceof Error ? requestError.message : String(requestError), - stack: requestError instanceof Error ? requestError.stack : undefined, - cellId: params.cellId, - textLength: text?.length || 0 - }); - // Continue with spell checking even if smart edits fail - smartEditsPromise = Promise.resolve(null); - iceEditsPromise = Promise.resolve(null); - } - - // Process spell checking - try { - const words = tokenizeText({ - method: "whitespace_and_punctuation", - text: params.text, - }); - - // Process traditional spell checking - for (const word of words) { - if (!word) continue; - - try { - const spellCheckResult = await spellChecker.spellCheck(word); - if (!spellCheckResult) continue; - - const offset = text.indexOf(word, 0); - if (offset === -1) continue; - - if (spellCheckResult.wordIsFoundInDictionary === false) { - matches.push({ - id: `UNKNOWN_WORD_${matches.length}`, - text: word, - replacements: spellCheckResult.corrections - .filter((c) => c !== null && c !== undefined) - .map((correction) => ({ - value: correction, - source: "llm" as const, - })), - offset: offset, - length: word.length, - cellId: params.cellId, - }); - } - } catch (wordError) { - console.error("[Language Server] Spell check failed for individual word:", { - word, - cellId: params.cellId, - error: wordError instanceof Error ? wordError.message : String(wordError), - stack: wordError instanceof Error ? wordError.stack : undefined - }); - // Continue processing other words - } - } - } catch (spellError) { - console.error("[Language Server] Traditional spell checking failed:", { - error: spellError instanceof Error ? spellError.message : String(spellError), - stack: spellError instanceof Error ? spellError.stack : undefined, - cellId: params.cellId, - textLength: text?.length || 0 - }); - // Continue to smart edits processing - } - - // Wait for both smart edits and ICE suggestions - let smartEditResults = null; - let iceResults = null; - - try { - [smartEditResults, iceResults] = await Promise.all([smartEditsPromise, iceEditsPromise]); - } catch (smartEditsError) { - console.error("[Language Server] Smart edits processing failed:", { - error: smartEditsError instanceof Error ? smartEditsError.message : String(smartEditsError), - stack: smartEditsError instanceof Error ? smartEditsError.stack : undefined, - cellId: params.cellId - }); - // Continue with what we have - } - - // Update smart edits cache - if (lastSmartEditsText !== text) { - lastSmartEditsText = text; - lastSmartEditResults = smartEditResults; - } - - // Process smart edits - if (smartEditResults) { - try { - specialPhrases = []; - smartEditResults.forEach((suggestion: any, index: number) => { - const source = suggestion.source || "llm"; - const color = source === "ice" ? "blue" : "purple"; - - specialPhrases.push({ - phrase: suggestion.oldString, - replacement: suggestion.newString, - color: color, - source: source, - leftToken: suggestion.leftToken || "", - rightToken: suggestion.rightToken || "", - }); - }); - - specialPhrases.forEach(({ phrase, replacement, color, source }, index) => { - try { - let startIndex = 0; - const phraseToSearch = phrase?.trim(); // Trim whitespace before searching - if (!phraseToSearch) return; // Skip if phrase is empty after trimming - - const phraseLower = phraseToSearch.toLowerCase(); - - while ((startIndex = text?.toLowerCase()?.indexOf(phraseLower, startIndex)) !== -1) { - // Get context tokens for ICE suggestions - let leftToken = ""; - let rightToken = ""; - if (source === "ice") { - const words = text?.split(/\s+/); - const wordIndex = words?.findIndex( - (w, i) => - text?.indexOf( - w, - i === 0 ? 0 : text?.indexOf(words[i - 1]) + words[i - 1].length - ) === startIndex - ); - if (wordIndex !== -1) { - leftToken = wordIndex > 0 ? words[wordIndex - 1] : ""; - rightToken = wordIndex < words.length - 1 ? words[wordIndex + 1] : ""; - } - } - - matches.push({ - id: `SPECIAL_PHRASE_${index}_${matches.length}`, - text: phrase, // Display original phrase in popup - replacements: [ - { - value: replacement, - source: source as "llm" | "ice", - }, - ], - offset: startIndex, // Use the found index - length: phraseToSearch.length, // Use the *trimmed* length for the underline - color: color as "purple" | "blue", - leftToken: source === "ice" ? leftToken : "", - rightToken: source === "ice" ? rightToken : "", - cellId: params.cellId, - }); - startIndex += phraseToSearch.length; // Advance by the *trimmed* length - } - } catch (phraseError) { - console.error("[Language Server] Failed to process smart edit phrase:", { - phrase, - index, - cellId: params.cellId, - error: phraseError instanceof Error ? phraseError.message : String(phraseError), - stack: phraseError instanceof Error ? phraseError.stack : undefined - }); - // Continue processing other phrases - } - }); - } catch (smartEditProcessingError) { - console.error("[Language Server] Smart edit results processing failed:", { - error: smartEditProcessingError instanceof Error ? smartEditProcessingError.message : String(smartEditProcessingError), - stack: smartEditProcessingError instanceof Error ? smartEditProcessingError.stack : undefined, - cellId: params.cellId, - resultsCount: smartEditResults?.length || 0 - }); - // Continue to ICE processing - } - } - - // Process ICE results - if (iceResults && Array.isArray(iceResults)) { - try { - for (const suggestion of iceResults) { - try { - if (suggestion.rejected === true) continue; - - const wordOffset = text.indexOf(suggestion.oldString); - if (wordOffset !== -1) { - matches.push({ - id: `ICE_${matches.length}`, - text: suggestion.oldString, - replacements: [ - { - value: suggestion.newString, - confidence: suggestion.confidence, - source: "ice", - frequency: suggestion.frequency, - }, - ], - offset: wordOffset, - length: suggestion.oldString.length, - color: "blue", - leftToken: suggestion.leftToken || "", - rightToken: suggestion.rightToken || "", - cellId: params.cellId, - }); - } - } catch (iceItemError) { - console.error("[Language Server] Failed to process ICE suggestion:", { - suggestion, - cellId: params.cellId, - error: iceItemError instanceof Error ? iceItemError.message : String(iceItemError), - stack: iceItemError instanceof Error ? iceItemError.stack : undefined - }); - // Continue processing other ICE suggestions - } - } - } catch (iceProcessingError) { - console.error("[Language Server] ICE results processing failed:", { - error: iceProcessingError instanceof Error ? iceProcessingError.message : String(iceProcessingError), - stack: iceProcessingError instanceof Error ? iceProcessingError.stack : undefined, - cellId: params.cellId, - resultsCount: iceResults?.length || 0 - }); - // Continue with what we have - } - } - - debugLog(`Returning matches: ${JSON.stringify(matches)}`); - return matches; - } catch (error) { - console.error("[Language Server] Critical failure in spellcheck/check:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - cellId: params?.cellId, - textLength: params?.text?.length || 0 - }); - // Return empty matches array as safe fallback - return []; - } -}); - -connection.onRequest( - "spellcheck/applyPromptedEdit", - async (params: { text: string; prompt: string; cellId: string; }) => { - try { - debugLog("Received spellcheck/applyPromptedEdit request:", { params }); - - const modifiedText = await connection.sendRequest(ExecuteCommandRequest, { - command: "codex-smart-edits.applyPromptedEdit", - args: [params.text, params.prompt, params.cellId], - }); - - debugLog("Modified text from prompted edit:", modifiedText); - return modifiedText; - } catch (error) { - console.error("[Language Server] Prompted edit request failed:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - cellId: params?.cellId, - textLength: params?.text?.length || 0, - promptLength: params?.prompt?.length || 0 - }); - // Return original text as safe fallback - return params?.text || null; - } - } -); - -connection.onRequest("spellcheck/addWord", async (params: { words: string[]; }) => { - try { - debugLog("Received spellcheck/addWord request:", { params }); - - if (!spellChecker) { - console.error("[Language Server] SpellChecker not initialized for addWord request:", { - requestedWords: params?.words || [], - wordsCount: params?.words?.length || 0 - }); - throw new Error("SpellChecker is not initialized."); - } - - await spellChecker.addWords(params.words); - debugLog("Words successfully added to the dictionary."); - return { success: true }; - } catch (error) { - console.error("[Language Server] Add word request failed:", { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - requestedWords: params?.words || [], - wordsCount: params?.words?.length || 0, - spellCheckerAvailable: !!spellChecker - }); - return { - success: false, - error: error instanceof Error ? error.message : String(error) - }; - } -}); - -documents.listen(connection); -connection.listen(); diff --git a/src/tsServer/spellCheck.ts b/src/tsServer/spellCheck.ts deleted file mode 100644 index ca85da998..000000000 --- a/src/tsServer/spellCheck.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { - Connection, - RequestType, -} from "vscode-languageserver/node"; -import { verseRefRegex } from "./types"; -import { SpellCheckResult } from "./types"; -import { URI } from "vscode-uri"; -import { cleanWord } from "../utils/cleaningUtils"; - -let folderUri: URI | undefined; - -// Define request types -interface CheckWordResponse { - exists: boolean; -} - -const DEBUG_MODE = false; // Flag for debug mode - -// Custom debug function -function debugLog(...args: any[]) { - if (DEBUG_MODE) { - console.log(new Date().toISOString(), ...args); - } -} -const CheckWordRequest = new RequestType< - { word: string; caseSensitive: boolean }, - CheckWordResponse, - never ->("custom/checkWord"); -const GetSuggestionsRequest = new RequestType("custom/getSuggestions"); -const AddWordsRequest = new RequestType("custom/addWords"); - -export class SpellChecker { - private connection: Connection; - private wordCache: Map = new Map(); - - constructor(connection: Connection) { - this.connection = connection; - } - - async spellCheck(word: string): Promise { - if (this.wordCache.has(word)) { - const cachedResult = this.wordCache.get(word); - if (cachedResult) { - return cachedResult; - } - } - const originalWord = word; - const cleanedWord = cleanWord(word); - - try { - // Check if word exists in dictionary - const response = await this.connection.sendRequest(CheckWordRequest, { - word: cleanedWord, - caseSensitive: false, - }); - debugLog("SERVER: CheckWordRequest response:", { response }); - - if (response.exists) { - const result: SpellCheckResult = { - word: originalWord, - wordIsFoundInDictionary: true, - corrections: [], - }; - - this.wordCache.set(word, result); - return result; - } - - const suggestions = await this.getSuggestions(originalWord); - debugLog("SERVER: getSuggestions response:", { suggestions }); - const result: SpellCheckResult = { - word: originalWord, - wordIsFoundInDictionary: false, - corrections: suggestions, - }; - this.wordCache.set(word, result); - return result; - } catch (error) { - console.error("Error in spellCheck:", error); - const result: SpellCheckResult = { - word: originalWord, - wordIsFoundInDictionary: false, - corrections: [], - }; - this.wordCache.set(word, result); - return result; - } - } - - private async getSuggestions(word: string): Promise { - if (!word || word.trim().length === 0) { - return []; - } - - try { - const cleanedWord = cleanWord(word); - const leadingPunctuation = word.match(/^[^\p{L}\p{N}]+/u)?.[0] || ""; - const trailingPunctuation = word.match(/[^\p{L}\p{N}]+$/u)?.[0] || ""; - - // Get all words from the dictionary - const dictWords = await this.connection.sendRequest(GetSuggestionsRequest, cleanedWord); - debugLog("SERVER: GetSuggestionsRequest response:", { dictWords }); - - const suggestions = dictWords.map((dictWord) => ({ - word: dictWord, - distance: this.levenshteinDistance(cleanedWord, dictWord), - })); - debugLog("SERVER: suggestions:", { suggestions }); - return suggestions - .sort((a, b) => a.distance - b.distance) - .slice(0, 5) - .map((suggestion) => { - let result = suggestion.word; - - // Preserve original capitalization - if (word[0].toUpperCase() === word[0]) { - result = result.charAt(0).toUpperCase() + result.slice(1); - } - - // Preserve surrounding punctuation - return leadingPunctuation + result + trailingPunctuation; - }); - } catch (error) { - console.error("Error in getSuggestions:", error); - return []; - } - } - - async addWords(words: string[]): Promise { - try { - const success = await this.connection.sendRequest(AddWordsRequest, words); - - if (success) { - this.wordCache.clear(); - this.connection.sendNotification("custom/dictionaryUpdated"); - } - } catch (error) { - console.error("Error in addWords:", error); - } - } - - clearCache(): void { - this.wordCache.clear(); - } - - private levenshteinDistance(a: string, b: string): number { - const matrix: number[][] = []; - - for (let i = 0; i <= b.length; i++) { - matrix[i] = [i]; - } - for (let j = 0; j <= a.length; j++) { - matrix[0][j] = j; - } - - for (let i = 1; i <= b.length; i++) { - for (let j = 1; j <= a.length; j++) { - if (b.charAt(i - 1) === a.charAt(j - 1)) { - matrix[i][j] = matrix[i - 1][j - 1]; - } else { - matrix[i][j] = Math.min( - matrix[i - 1][j - 1] + 1, - matrix[i][j - 1] + 1, - matrix[i - 1][j] + 1 - ); - } - } - } - - return matrix[b.length][a.length]; - } -} \ No newline at end of file diff --git a/src/tsServer/types.ts b/src/tsServer/types.ts deleted file mode 100644 index 0f43b908b..000000000 --- a/src/tsServer/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const verseRefRegex = /(\b[A-Z, 1-9]{3}\s\d+:\d+\b)/; - -export type SpellCheckResult = { - word: string; - wordIsFoundInDictionary: boolean; - corrections: string[]; -}; - -export type SpellCheckFunction = (word: string) => SpellCheckResult; diff --git a/src/utils/chat_context.json b/src/utils/chat_context.json deleted file mode 100644 index 34f1e3f73..000000000 --- a/src/utils/chat_context.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "verses": {}, - "references": {} -} \ No newline at end of file diff --git a/src/utils/cleaningUtils.ts b/src/utils/cleaningUtils.ts deleted file mode 100644 index 60a1cf118..000000000 --- a/src/utils/cleaningUtils.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function cleanWord(word: string | undefined | null): string { - // this is for the spellchecker - if (word === undefined || word === null) { - return ""; - } - return ( - word - // Remove non-letter/number/mark characters from start and end - .replace(/^[^\p{L}\p{M}\p{N}']+|[^\p{L}\p{M}\p{N}']+$/gu, "") - // Replace multiple apostrophes with a single one - .replace(/''+/g, "'") - // Remove apostrophes at the start or end of words - .replace(/(? { - const books: BookPreview[] = []; - const lines = content.split("\n"); - - let currentBook: Partial = {}; - let verseCount = 0; - let chapterCount = 0; - - for (const line of lines) { - if (line.startsWith("\\id ")) { - if (currentBook.name) { - books.push({ - name: currentBook.name, - versesCount: verseCount, - chaptersCount: chapterCount, - previewContent: lines.slice(0, 5).join("\n"), - }); - } - currentBook = { name: line.substring(4).trim() }; - verseCount = 0; - chapterCount = 0; - } else if (line.startsWith("\\c ")) { - chapterCount++; - } else if (line.startsWith("\\v ")) { - verseCount++; - } - } - - // Add the last book - if (currentBook.name) { - books.push({ - name: currentBook.name, - versesCount: verseCount, - chaptersCount: chapterCount, - previewContent: lines.slice(0, 5).join("\n"), - }); - } - - return books; -} - -export async function analyzeUsxContent(content: string): Promise { - const books: BookPreview[] = []; - const lines = content.split("\n"); - - // Simple regex patterns for USX elements - const bookPattern = / = {}; - let verseCount = 0; - let chapterCount = 0; - - for (const line of lines) { - const bookMatch = line.match(bookPattern); - if (bookMatch) { - if (currentBook.name) { - books.push({ - name: currentBook.name, - versesCount: verseCount, - chaptersCount: chapterCount, - previewContent: lines.slice(0, 5).join("\n"), - }); - } - currentBook = { name: bookMatch[1] }; - verseCount = 0; - chapterCount = 0; - continue; - } - - if (line.match(chapterPattern)) { - chapterCount++; - } - - if (line.match(versePattern)) { - verseCount++; - } - } - - // Add the last book - if (currentBook.name) { - books.push({ - name: currentBook.name, - versesCount: verseCount, - chaptersCount: chapterCount, - previewContent: lines.slice(0, 5).join("\n"), - }); - } - - return books; -} - -export async function analyzeSubtitlesContent(content: string): Promise { - // Basic analysis for subtitles/VTT files - const lines = content.split("\n"); - const segments = content.split("\n\n").filter(Boolean); - - return [ - { - name: "Subtitles", - versesCount: segments.length, - chaptersCount: 1, - previewContent: lines.slice(0, 5).join("\n"), - }, - ]; -} - -export async function analyzePlainTextContent(content: string): Promise { - const lines = content.split("\n"); - - return [ - { - name: "Plain Text", - versesCount: lines.length, - chaptersCount: 1, - previewContent: content.slice(0, 200), - }, - ]; -} - -export async function analyzeSourceContent( - fileUri: vscode.Uri, - content: string -): Promise { - const fileType = getFileType(fileUri); - - switch (fileType) { - case "usfm": - return await analyzeUsfmContent(content); - case "subtitles": - return await analyzeSubtitlesContent(content); - case "plaintext": - return await analyzePlainTextContent(content); - default: - return []; - } -} diff --git a/src/utils/dictionaryUtils.ts b/src/utils/dictionaryUtils.ts deleted file mode 100644 index 2bf65b129..000000000 --- a/src/utils/dictionaryUtils.ts +++ /dev/null @@ -1,105 +0,0 @@ -import * as fs from "fs"; // Need to use fs because the server uses this too -import * as vscode from "vscode"; -import { Dictionary, DictionaryEntry } from "../../types"; -import { cleanWord } from "./cleaningUtils"; - -// Server version (using fs) -export async function readDictionaryServer(path: string): Promise { - try { - const content = await fs.promises.readFile(path, "utf-8"); - const entries = deserializeDictionaryEntries(content); - return { - id: "project", - label: "Project", - entries, - metadata: {}, - }; - } catch (error) { - console.error("Error reading dictionary:", error); - return { id: "project", label: "Project", entries: [], metadata: {} }; - } -} - -export async function saveDictionaryServer(path: string, dictionary: Dictionary): Promise { - const content = serializeDictionaryEntries(dictionary.entries); - await fs.promises.writeFile(path, content, "utf-8"); -} - -// Client version (using vscode.workspace.fs) -export async function readDictionaryClient(uri: vscode.Uri): Promise { - try { - const content = await vscode.workspace.fs.readFile(uri); - const entries = deserializeDictionaryEntries(new TextDecoder().decode(content)); - return { - id: "project", - label: "Project", - entries, - metadata: {}, - }; - } catch (error) { - console.error("Error reading dictionary:", error); - return { id: "project", label: "Project", entries: [], metadata: {} }; - } -} - -export async function saveDictionaryClient(uri: vscode.Uri, dictionary: Dictionary): Promise { - const content = serializeDictionaryEntries(dictionary.entries); - await vscode.workspace.fs.writeFile(uri, Buffer.from(content, "utf-8")); -} - -export async function addWordsToDictionary(path: string, words: string[]): Promise { - const dictionary = await readDictionaryServer(path); - const newEntries = words - .map(cleanWord) - .filter( - (word) => - word && - !dictionary.entries.some( - (entry) => entry.headWord?.toLowerCase() === word.toLowerCase() - ) - ) - .map((word) => createDictionaryEntry(word)); - - dictionary.entries.push(...newEntries); - await saveDictionaryServer(path, dictionary); -} - -export function serializeDictionaryEntries(entries: DictionaryEntry[]): string { - return entries.map((entry) => JSON.stringify(ensureCompleteEntry(entry))).join("\n") + "\n"; -} - -export function deserializeDictionaryEntries(content: string): DictionaryEntry[] { - return content - .split("\n") - .filter((line) => line.trim() !== "") - .map((line) => JSON.parse(line)) - .map(ensureCompleteEntry); -} - -export function repairDictionaryContent(content: string): string { - return content.replace(/}\s*{/g, "}\n{"); -} - -export function ensureCompleteEntry(entry: Partial): DictionaryEntry { - return { - id: entry.id || generateUniqueId(), - headWord: entry.headWord || "N/A", - definition: entry.definition || "", - isUserEntry: entry.isUserEntry || false, - authorId: entry.authorId || "", - }; -} - -function createDictionaryEntry(word: string): DictionaryEntry { - return { - id: generateUniqueId(), - headWord: word, - definition: "", - isUserEntry: false, - authorId: "", - }; -} - -function generateUniqueId(): string { - return Math.random().toString(36).substr(2, 9); -} diff --git a/src/utils/dictionaryUtils/client.ts b/src/utils/dictionaryUtils/client.ts deleted file mode 100644 index 6c13f71dd..000000000 --- a/src/utils/dictionaryUtils/client.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as vscode from "vscode"; -import { Dictionary } from "../../../types"; -import { serializeDictionaryEntries, deserializeDictionaryEntries } from "./common"; - -export async function readDictionaryClient(uri: vscode.Uri): Promise { - try { - const content = await vscode.workspace.fs.readFile(uri); - const entries = deserializeDictionaryEntries(new TextDecoder().decode(content)); - return { - id: "project", - label: "Project", - entries, - metadata: {}, - }; - } catch (error) { - console.error("Error reading dictionary:", error); - return { id: "project", label: "Project", entries: [], metadata: {} }; - } -} - -export async function saveDictionaryClient(uri: vscode.Uri, dictionary: Dictionary): Promise { - const content = serializeDictionaryEntries(dictionary.entries); - await vscode.workspace.fs.writeFile(uri, Buffer.from(content, "utf-8")); -} diff --git a/src/utils/dictionaryUtils/common.ts b/src/utils/dictionaryUtils/common.ts deleted file mode 100644 index 553bfaea5..000000000 --- a/src/utils/dictionaryUtils/common.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { DictionaryEntry } from "../../../types"; - -export function serializeDictionaryEntries(entries: DictionaryEntry[]): string { - return entries.map((entry) => JSON.stringify(entry)).join("\n") + "\n"; -} - -export function deserializeDictionaryEntries(content: string): DictionaryEntry[] { - return content - .split("\n") - .filter((line) => line.trim() !== "") - .map((line) => JSON.parse(line)); -} - -export function repairDictionaryContent(content: string): string { - return content.replace(/}\s*{/g, "}\n{"); -} - -// export function ensureCompleteEntry(entry: Partial): DictionaryEntry { -// let headWord = entry.headWord || ""; -// if (!headWord) { -// headWord = entry.id || "N/A"; -// } - -// return { -// id: entry.id || generateUniqueId(), -// headWord: headWord, -// definition: entry.definition || "", -// isUserEntry: entry.isUserEntry || false, -// authorId: entry.authorId || "", -// }; -// } - -export function createDictionaryEntry(word: string): DictionaryEntry { - return { - id: generateUniqueId(), - headWord: word, - definition: "", - isUserEntry: false, - authorId: "", - }; -} - -function generateUniqueId(): string { - return Math.random().toString(36).substr(2, 9); -} diff --git a/src/utils/dictionaryUtils/server.ts b/src/utils/dictionaryUtils/server.ts deleted file mode 100644 index 887f1ad0c..000000000 --- a/src/utils/dictionaryUtils/server.ts +++ /dev/null @@ -1,43 +0,0 @@ -import * as fs from "fs"; - -import { cleanWord } from "../cleaningUtils"; -import { - serializeDictionaryEntries, - deserializeDictionaryEntries, - createDictionaryEntry, -} from "./common"; -import { Dictionary } from "../../../types"; - -// todo: this is probably not needed anymore - -export async function readDictionaryServer(path: string): Promise { - try { - const content = await fs.promises.readFile(path, "utf-8"); - const entries = deserializeDictionaryEntries(content); - return { - id: "project", - label: "Project", - entries, - metadata: {}, - }; - } catch (error) { - console.error("Error reading dictionary:", error); - return { id: "project", label: "Project", entries: [], metadata: {} }; - } -} - -export async function saveDictionaryServer(path: string, dictionary: Dictionary): Promise { - const content = serializeDictionaryEntries(dictionary.entries); - await fs.promises.writeFile(path, content, "utf-8"); -} - -export async function addWordsToDictionary(path: string, words: string[]): Promise { - const dictionary = await readDictionaryServer(path); - const newEntries = words - .map(cleanWord) - .filter((word) => word && !dictionary.entries.some((entry) => entry.headWord === word)) - .map((word) => createDictionaryEntry(word)); - - dictionary.entries.push(...newEntries); - await saveDictionaryServer(path, dictionary); -} diff --git a/src/utils/editMapUtils.ts b/src/utils/editMapUtils.ts index fe0c00285..8bdda7bea 100644 --- a/src/utils/editMapUtils.ts +++ b/src/utils/editMapUtils.ts @@ -25,8 +25,6 @@ type MetaGeneratorEditMap = ["meta", "generator"]; type MetaEditMap = ["meta"]; type MetaFieldEditMap = ["meta", string]; type LanguagesEditMap = ["languages"]; -type SpellcheckIsEnabledEditMap = ["spellcheckIsEnabled"]; - import { EditType } from "../../types/enums"; // Utility functions for working with editMaps @@ -140,10 +138,6 @@ export const EditMapUtils = { return ["languages"]; }, - spellcheckIsEnabled(): SpellcheckIsEnabledEditMap { - return ["spellcheckIsEnabled"]; - }, - // Compare editMaps equals(editMap1: readonly string[], editMap2: readonly string[]): boolean { return JSON.stringify(editMap1) === JSON.stringify(editMap2); diff --git a/src/utils/editTypeExamples.ts b/src/utils/editTypeExamples.ts deleted file mode 100644 index 6e70173de..000000000 --- a/src/utils/editTypeExamples.ts +++ /dev/null @@ -1,112 +0,0 @@ -// Examples demonstrating the type-safe edit system -import { EditHistory, EditFor, CodexData } from "../../types"; -import { EditMapUtils } from "./editMapUtils"; -import { EditType } from "../../types/enums"; - -// Example 1: Value edit (HTML string) -const valueEdit: EditFor = { - editMap: EditMapUtils.value(), - value: "Hello World", // TypeScript infers: string - author: "user1", - timestamp: Date.now(), - type: EditType.USER_EDIT, - validatedBy: [] -}; - -// Example 2: Cell label edit (string) -const labelEdit: EditFor = { - editMap: EditMapUtils.cellLabel(), - value: "Genesis 1:1", // TypeScript infers: string - author: "user1", - timestamp: Date.now(), - type: EditType.USER_EDIT, - validatedBy: [] -}; - -// Example 3: Data edit (CodexData object) -const timestamps: CodexData = { - startTime: 0, - endTime: 1000, -}; - -const dataEdit: EditFor = { - editMap: EditMapUtils.data(), - value: timestamps, // ✅ TypeScript infers: CodexData - author: "user1", - timestamp: Date.now(), - type: EditType.USER_EDIT, - validatedBy: [] -}; - -// Example 4: Boolean field edit -const deleteEdit: EditFor = { - editMap: EditMapUtils.dataDeleted(), - value: true, // ✅ TypeScript infers: boolean - author: "user1", - timestamp: Date.now(), - type: EditType.USER_EDIT, - validatedBy: [] -}; - -// Example 5: Number field edit -const startTimeEdit: EditFor = { - editMap: EditMapUtils.dataStartTime(), - value: 500, // ✅ TypeScript infers: number - author: "user1", - timestamp: Date.now(), - type: EditType.USER_EDIT, - validatedBy: [] -}; - -// Example 6: Generic metadata field (fallback type) -const genericEdit: EditFor = { - editMap: EditMapUtils.metadata("customField"), - value: "some value", // ✅ TypeScript infers: string | number | boolean | object - author: "user1", - timestamp: Date.now(), - type: EditType.USER_EDIT, - validatedBy: [] -}; - -// Example 7: Array of mixed edit types -const mixedEdits: EditHistory[] = [ - { - editMap: EditMapUtils.value(), - value: "Content", // string - author: "user1", - timestamp: Date.now(), - type: "user-edit" as any - }, - { - editMap: EditMapUtils.cellLabel(), - value: "Chapter 1", // string - author: "user1", - timestamp: Date.now(), - type: "user-edit" as any - }, - { - editMap: EditMapUtils.dataDeleted(), - value: false, // boolean - author: "user1", - timestamp: Date.now(), - type: "user-edit" as any - } -]; - -// Type-safe filtering functions -function filterValueEdits(edits: EditHistory[]): EditFor<["value"]>[] { - return edits.filter(edit => EditMapUtils.isValue(edit.editMap)) as EditFor<["value"]>[]; -} - -function filterLabelEdits(edits: EditHistory[]): EditFor<["metadata", "cellLabel"]>[] { - return edits.filter(edit => EditMapUtils.equals(edit.editMap, ["metadata", "cellLabel"])) as EditFor<["metadata", "cellLabel"]>[]; -} - -// Usage example -const valueEdits = filterValueEdits(mixedEdits); -// TypeScript knows valueEdits[0].value is a string - -const labelEdits = filterLabelEdits(mixedEdits); -// TypeScript knows labelEdits[0].value is a string - -export { valueEdit, labelEdit, dataEdit, deleteEdit, startTimeEdit, genericEdit, mixedEdits }; diff --git a/src/utils/fileTypeUtils.ts b/src/utils/fileTypeUtils.ts index e8ed818e7..b6470e2ce 100644 --- a/src/utils/fileTypeUtils.ts +++ b/src/utils/fileTypeUtils.ts @@ -5,7 +5,6 @@ import { FileType } from '../../types'; export type FileTypeMap = { codex: string; source: string; - dictionary: string; tsv: string; }; @@ -42,10 +41,6 @@ export function isSourceFile(fileUri: vscode.Uri): boolean { return WebPathUtils.hasExtension(fileUri, 'source'); } -export function isDictionaryFile(fileUri: vscode.Uri): boolean { - return WebPathUtils.hasExtension(fileUri, 'dictionary'); -} - export function isTsvFile(fileUri: vscode.Uri): boolean { return WebPathUtils.hasExtension(fileUri, 'tsv'); } diff --git a/src/utils/fileUtils.ts b/src/utils/fileUtils.ts index d172adf08..c6431e226 100644 --- a/src/utils/fileUtils.ts +++ b/src/utils/fileUtils.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line @typescript-eslint/naming-convention import * as vscode from "vscode"; import * as path from "path"; -import { ChatMessageThread, NotebookCommentThread } from "../../types"; +import { NotebookCommentThread } from "../../types"; import { getWorkSpaceUri } from "./index"; function sanitizePath(workspaceUri: vscode.Uri, filepath: string): string { @@ -160,22 +160,6 @@ export const getCommentsFromFile = async (fileName: string): Promise => { - try { - const workspaceUri = getWorkSpaceUri(); - if (!workspaceUri) { - throw new Error("No workspace folder found."); - } - const uri = vscode.Uri.joinPath(workspaceUri, fileName); - const fileContentUint8Array = await vscode.workspace.fs.readFile(uri); - const fileContent = new TextDecoder().decode(fileContentUint8Array); - return JSON.parse(fileContent); - } catch (error) { - console.error(error); - throw new Error("Failed to parse notebook comments from file"); - } -}; - export const projectFileExists = async () => { const workspaceUri = getWorkSpaceUri(); if (!workspaceUri) { @@ -188,3 +172,48 @@ export const projectFileExists = async () => { ); return fileExists; }; + +/** + * Paths (relative to workspace root) of files from removed features + * that should be deleted when a project is opened. + * + * - dictionary.sqlite: old dictionary database (dictionary feature removed) + * - project.dictionary: old JSONL dictionary file (dictionary feature removed) + * - smart_passages_memories.json: never read or written by any code + * - chat-threads.json: old chat threads file, never read or written + * - chat_history.jsonl: old chat history file, never read or written + * - ab-test-results.jsonl: documented but never read or written by any code + */ +const ORPHANED_PROJECT_FILES = [ + ".project/dictionary.sqlite", + "files/project.dictionary", + "files/smart_passages_memories.json", + "chat-threads.json", + "files/chat_history.jsonl", + "files/ab-test-results.jsonl", + "files/smart_edits.json", + "files/ice_edits.json", + "files/silver_path_memories.json", +]; + +/** + * Delete leftover files from removed features so they don't accumulate as + * junk inside user projects. Runs once on extension activation; each + * deletion is best-effort (silently ignored if the file doesn't exist). + */ +export const cleanupOrphanedProjectFiles = async (): Promise => { + const workspaceUri = getWorkSpaceUri(); + if (!workspaceUri) { + return; + } + + for (const relativePath of ORPHANED_PROJECT_FILES) { + const fileUri = vscode.Uri.joinPath(workspaceUri, relativePath); + try { + await vscode.workspace.fs.delete(fileUri); + console.log(`[Cleanup] Deleted orphaned file: ${relativePath}`); + } catch { + // File doesn't exist or can't be deleted — that's fine + } + } +}; diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts deleted file mode 100644 index 38cddee67..000000000 --- a/src/utils/formatters.ts +++ /dev/null @@ -1,16 +0,0 @@ -export function formatFileSize(bytes: number | undefined | null): string { - if (bytes === undefined || bytes === null) { - return '0 B'; - } - - const units = ['B', 'KB', 'MB', 'GB']; - let size = Math.abs(bytes); // Handle negative numbers gracefully - let unitIndex = 0; - - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex++; - } - - return `${size.toFixed(1)} ${units[unitIndex]}`; -} diff --git a/src/providers/dictionaryTable/utilities/getNonce.ts b/src/utils/getNonce.ts similarity index 100% rename from src/providers/dictionaryTable/utilities/getNonce.ts rename to src/utils/getNonce.ts diff --git a/src/utils/index.ts b/src/utils/index.ts index f60ce846f..5ec6d2306 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -110,4 +110,3 @@ export const getFullListOfOrgVerseRefs = (): string[] => { // Re-export utilities export * from "./fileTypeUtils"; -export * from "./contentAnalyzers"; diff --git a/src/utils/llmUtils.ts b/src/utils/llmUtils.ts index 85fddca98..ff8c1f94e 100644 --- a/src/utils/llmUtils.ts +++ b/src/utils/llmUtils.ts @@ -181,151 +181,6 @@ export async function callLLM( } } -export async function performReflection( - text_to_refine: string, - text_context: string, - num_improvers: number, - number_of_loops: number, - chatReflectionConcern: string, - config: CompletionConfig, - cancellationToken?: vscode.CancellationToken -): Promise { - async function generateImprovement(text: string): Promise { - let systemContent = ""; - systemContent += - "You are an AI that is responsible for grading an answer according to a Christian perspective.\n"; - if (chatReflectionConcern) { - systemContent += "Specified Concern: " + chatReflectionConcern + "\n"; - } - systemContent += - "Provide a grade from 0 to 100 where 0 is the lowest grade and 100 is the highest grade and a grade comment.\n"; - - const response = await callLLM( - [ - { - role: "system", - content: systemContent, - }, - { - role: "user", - content: `Context: ${text_context}\nAnswer to grade: ${text}\nGrade:`, - }, - ], - config, - cancellationToken - ); - - return response; - } - - async function generateSummary(improvements: Promise[]): Promise { - const results = await Promise.all(improvements); - const summarizedContent = results.join("\n\n"); - const summary = await callLLM( - [ - { - role: "system", - //The comment about the original person is not available is to keep the reflection from fabricating a "personal" naritive to support a discussion. - content: - "You are an AI tasked with summarizing suggested improvements according to a Christian perspective. List each suggested improvement as a concise bullet point. Maintain a clear and distinct list format without losing any specifics from each suggested improvement. Drop any requests for personal testimony or stories, the original person is no longer available.", - }, - { - role: "user", - content: `Comments containing improvements: ${summarizedContent}\nSummary:`, - }, - ], - config, - cancellationToken - ); - return summary.trim(); - } - - async function implementImprovements( - text: string, - improvements: Promise | string - ): Promise { - try { - const improvedText = Promise.resolve(improvements).then((result) => { - // Apply the improvement logic here. For simplicity, let's assume we append the improvements. - return callLLM( - [ - { - role: "system", - content: `You are an AI tasked with implementing the requested changes to a text from a Christian perspective. Don't lengthen or change the text except as needed for implementing the listed improvements if any. Do not comply with adding first-person naratives even if requested. The improvements requested are: "${result}".`, - }, - { - role: "user", - content: text, - }, - ], - config, - cancellationToken - ); - }); - return await improvedText; - } catch (error) { - console.error("Error implementing improvements:", error); - throw new Error("Failed to implement improvements"); - } - } - - async function distillText(textToDistill: string): Promise { - return await callLLM( - [ - { - role: "system", - content: `You are an AI tasked with distilling text from a Christian perspective.`, - }, - { - role: "user", - content: `Text to distill: ${textToDistill}\nDistilled text: `, - }, - ], - config, - cancellationToken - ) - .then((distilledText) => { - // Some basic post-processing to remove any trailing whitespace - return distilledText.trim(); - }) - .catch((error) => { - console.error("Error implementing improvements:", error); - throw new Error("Failed to implement improvements"); - }); - } - - let text: string = text_to_refine; - - for (let i = 0; i < number_of_loops; i++) { - const improvements: Promise[] = []; - for (let j = 0; j < num_improvers; j++) { - //improvements.push(Promise.resolve(await generateImprovement(text))); - improvements.push(generateImprovement(text)); - } - - const summarized_improvements = - num_improvers == 1 - ? Promise.resolve(improvements[0]) - : await generateSummary(improvements); - - console.log( - "Reflection Iteration " + (i + 1) + ": summarized_improvements", - summarized_improvements - ); - - text = await implementImprovements(text, summarized_improvements); - - console.log("Reflection Iteration " + (i + 1) + ": improved_text", text); - } - - //now distill the text back down. - text = await distillText(text); - - console.log("Reflection Distilled text", text); - - return text; -} - export interface CompletionConfig { endpoint: string; apiKey: string; diff --git a/src/utils/nativeSqlite.ts b/src/utils/nativeSqlite.ts new file mode 100644 index 000000000..73436503c --- /dev/null +++ b/src/utils/nativeSqlite.ts @@ -0,0 +1,538 @@ +/** + * Native SQLite wrapper — dynamically loads the prebuilt node_sqlite3.node binary + * and applies the essential JS wrapper logic inline. + * + * Provides a Promise-based API that replaces the synchronous sql.js (fts5-sql-bundle) API. + * Key benefit: file-based SQLite with incremental page writes instead of + * serializing and rewriting the entire database on every save. + * + * Migration from sql.js patterns: + * - initSqlJs() + new SQL.Database() → AsyncDatabase.open(filepath) + * - db.run(sql) → await db.run(sql) + * - stmt = db.prepare(sql); stmt.bind(p); + * while(stmt.step()) stmt.getAsObject(); + * stmt.free(); → await db.all(sql, params) + * - stmt.bind(p); stmt.step(); + * stmt.getAsObject(); stmt.free(); → await db.get(sql, params) + * - db.export() + writeFile() → Not needed (writes to disk automatically) + * - db.getRowsModified() → result.changes from run() + * - db.create_function() → Not supported; compute in JS instead + */ + +import { EventEmitter } from "events"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +/** Result of an INSERT/UPDATE/DELETE operation */ +export interface RunResult { + /** Row ID of the last inserted row */ + lastID: number; + /** Number of rows affected by the statement */ + changes: number; +} + +/** + * The raw native binding exported by node_sqlite3.node. + * This is what `require('node_sqlite3.node')` returns. + */ +interface NativeBinding { + Database: new (filename: string, mode: number, callback: (err: Error | null) => void) => NativeDatabase; + Statement: new (db: NativeDatabase, sql: string, errBack?: (err: Error) => void) => NativeStatement; + OPEN_READONLY: number; + OPEN_READWRITE: number; + OPEN_CREATE: number; + BUSY: number; + LOCKED: number; +} + +/** Raw native Database — only has C++-level methods */ +interface NativeDatabase { + close(callback: (err: Error | null) => void): void; + exec(sql: string, callback: (err: Error | null) => void): void; + configure(option: string, value: any): void; + loadExtension(filepath: string, callback: (err: Error | null) => void): void; + // The following methods are ADDED by our wrapper below: + run?(sql: string, ...args: any[]): NativeDatabase; + get?(sql: string, ...args: any[]): NativeDatabase; + all?(sql: string, ...args: any[]): NativeDatabase; + each?(sql: string, ...args: any[]): NativeDatabase; + prepare?(sql: string, ...args: any[]): NativeStatement; +} + +/** Raw native Statement */ +interface NativeStatement { + bind(...args: any[]): NativeStatement; + run(...args: any[]): NativeStatement; + get(...args: any[]): NativeStatement; + all(...args: any[]): NativeStatement; + each(...args: any[]): NativeStatement; + reset(callback?: (err: Error | null) => void): NativeStatement; + finalize(callback?: (err: Error | null) => void): void; +} + +/** The wrapped Database type (with convenience methods added) */ +interface WrappedDatabase extends NativeDatabase { + run(sql: string, ...args: any[]): WrappedDatabase; + get(sql: string, ...args: any[]): WrappedDatabase; + all(sql: string, ...args: any[]): WrappedDatabase; + each(sql: string, ...args: any[]): WrappedDatabase; + prepare(sql: string, ...args: any[]): NativeStatement; +} + +// ── Module-level state ─────────────────────────────────────────────────────── + +/** The loaded native binding (set by initNativeSqlite) */ +let binding: NativeBinding | null = null; + +/** Whether the wrapper methods have been applied to Database/Statement prototypes */ +let wrapperApplied = false; + +// ── Wrapper logic (equivalent to sqlite3.js from node-sqlite3) ────────────── + +/** + * Create a convenience method on Database.prototype that: + * 1. Creates a Statement from the SQL + * 2. Calls the Statement method with params + * 3. Finalizes the Statement + * + * This replicates the `normalizeMethod` pattern from node-sqlite3's sqlite3.js. + */ +function normalizeMethod( + fn: (statement: NativeStatement, params: any[]) => any +): (this: any, sql: string, ...args: any[]) => any { + return function (this: any, sql: string, ...rest: any[]) { + if (!binding) { + throw new Error("SQLite native module not initialized. Call initNativeSqlite(binaryPath) first."); + } + + let errBack: ((err: Error) => void) | undefined; + const args = rest.slice(); + + if (typeof args[args.length - 1] === "function") { + const callback = args[args.length - 1]; + errBack = function (err: Error) { + if (err) { + callback(err); + } + }; + } + + const statement = new binding.Statement(this, sql, errBack); + return fn.call(this, statement, args); + }; +} + +/** + * Copy prototype properties from source to target (simple inheritance). + */ +function inherits(target: any, source: any): void { + for (const k in source.prototype) { + target.prototype[k] = source.prototype[k]; + } +} + +/** + * Apply the JS wrapper methods to the native Database and Statement prototypes. + * This must be called once after loading the binding. + */ +function applyWrapper(): void { + if (wrapperApplied || !binding) { + return; + } + + const Database = binding.Database; + const Statement = binding.Statement; + + // Add EventEmitter capabilities + inherits(Database, EventEmitter); + inherits(Statement, EventEmitter); + + // Database#prepare(sql, [bind1, bind2, ...], [callback]) + Database.prototype.prepare = normalizeMethod(function (statement: NativeStatement, params: any[]) { + return params.length + ? statement.bind(...params) + : statement; + }); + + // Database#run(sql, [bind1, bind2, ...], [callback]) + Database.prototype.run = normalizeMethod(function (this: any, statement: NativeStatement, params: any[]) { + statement.run(...params).finalize(); + return this; + }); + + // Database#get(sql, [bind1, bind2, ...], [callback]) + Database.prototype.get = normalizeMethod(function (this: any, statement: NativeStatement, params: any[]) { + statement.get(...params).finalize(); + return this; + }); + + // Database#all(sql, [bind1, bind2, ...], [callback]) + Database.prototype.all = normalizeMethod(function (this: any, statement: NativeStatement, params: any[]) { + statement.all(...params).finalize(); + return this; + }); + + // Database#each(sql, [bind1, bind2, ...], [callback], [complete]) + // Unlike run/get/all, each() delivers rows asynchronously via callbacks. + // We must defer finalize() to the completion callback so the statement + // stays alive while rows are being iterated. + Database.prototype.each = normalizeMethod(function (this: any, statement: NativeStatement, params: any[]) { + const args = [...params]; + // Find the completion callback (last function arg) and wrap it to finalize after + let foundCompletion = false; + for (let i = args.length - 1; i >= 0; i--) { + if (typeof args[i] === "function") { + // The last function is the completion callback (the one before it is the row callback) + const originalComplete = args[i]; + args[i] = function (this: any, ...cbArgs: any[]) { + statement.finalize(); + return originalComplete.apply(this, cbArgs); + }; + foundCompletion = true; + break; + } + } + if (!foundCompletion) { + // No callbacks at all — add a completion callback that just finalizes + args.push(function () { statement.finalize(); }); + } + statement.each(...args); + return this; + }); + + // Support event-based configure for trace/profile/change + const supportedEvents = ["trace", "profile", "change"]; + + Database.prototype.addListener = Database.prototype.on = function (type: string, ...args: any[]) { + const val = EventEmitter.prototype.addListener.apply(this, [type, ...args] as any); + if (supportedEvents.indexOf(type) >= 0) { + this.configure(type, true); + } + return val; + }; + + Database.prototype.removeListener = function (type: string, ...args: any[]) { + const val = EventEmitter.prototype.removeListener.apply(this, [type, ...args] as any); + if (!(this as any)._events[type]) { + if (supportedEvents.indexOf(type) >= 0) { + this.configure(type, false); + } + } + return val; + }; + + Database.prototype.removeAllListeners = function (type: string) { + const val = EventEmitter.prototype.removeAllListeners.apply(this, [type] as any); + if (supportedEvents.indexOf(type) >= 0) { + this.configure(type, false); + } + return val; + }; + + wrapperApplied = true; +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +/** + * Initialize the native SQLite module by loading the .node binary. + * Must be called once on startup BEFORE any AsyncDatabase operations. + * + * @param binaryPath - Absolute path to the node_sqlite3.node file + */ +export function initNativeSqlite(binaryPath: string): void { + if (binding) { + console.log("[SQLite] Native binding already loaded, skipping re-init"); + return; + } + + console.log(`[SQLite] Loading native binding from: ${binaryPath}`); + + let loadedBinding: NativeBinding; + try { + // Use the real Node.js require (not webpack's __webpack_require__) + // to dynamically load the .node native addon at runtime. + // eslint-disable-next-line @typescript-eslint/no-var-requires, no-eval + const nodeRequire = eval("require") as NodeRequire; + loadedBinding = nodeRequire(binaryPath) as NativeBinding; + } catch (loadError) { + const msg = loadError instanceof Error ? loadError.message : String(loadError); + throw new Error( + `Failed to load SQLite native binary at ${binaryPath}: ${msg}. ` + + `The binary may be corrupt, missing, or incompatible with this platform/architecture.` + ); + } + + if (!loadedBinding || !loadedBinding.Database) { + throw new Error( + `Failed to load SQLite native binding from ${binaryPath}: ` + + `binding.Database is ${typeof loadedBinding?.Database}` + ); + } + + binding = loadedBinding; + applyWrapper(); + + console.log("[SQLite] Native binding loaded and wrapper applied successfully"); +} + +/** + * Check whether the native SQLite module has been initialized. + */ +export function isNativeSqliteReady(): boolean { + return binding !== null && wrapperApplied; +} + +/** + * Promise-based wrapper around the native SQLite Database. + * All methods return Promises instead of using callbacks. + */ +export class AsyncDatabase { + private db: WrappedDatabase; + /** Set to true after close() — guards against use-after-close and double-close. */ + private closed = false; + + private constructor(db: WrappedDatabase) { + this.db = db; + } + + /** + * Open a database file. Creates the file if it doesn't exist. + * Use ":memory:" for an in-memory database (testing only). + * + * @throws Error if initNativeSqlite() hasn't been called yet + */ + static open( + filepath: string, + mode?: number + ): Promise { + if (!binding) { + throw new Error( + "SQLite native module not initialized. Call initNativeSqlite(binaryPath) first." + ); + } + + return new Promise((resolve, reject) => { + const effectiveMode = + mode ?? (binding!.OPEN_READWRITE | binding!.OPEN_CREATE); + const db = new binding!.Database( + filepath, + effectiveMode, + (err: Error | null) => { + if (err) { + reject(err); + } else { + resolve(new AsyncDatabase(db as unknown as WrappedDatabase)); + } + } + ); + }); + } + + /** + * Execute an INSERT, UPDATE, DELETE, or DDL statement. + * Returns { lastID, changes }. + */ + run(sql: string, params?: any[]): Promise { + if (this.closed) return Promise.reject(new Error("Database connection is closed")); + return new Promise((resolve, reject) => { + this.db.run( + sql, + params ?? [], + function (this: any, err: Error | null) { + if (err) { + reject(err); + } else { + resolve({ lastID: this.lastID, changes: this.changes }); + } + } + ); + }); + } + + /** + * Fetch a single row. Returns undefined if no rows match. + */ + get>( + sql: string, + params?: any[] + ): Promise { + if (this.closed) return Promise.reject(new Error("Database connection is closed")); + return new Promise((resolve, reject) => { + this.db.get( + sql, + params ?? [], + (err: Error | null, row: any) => { + if (err) { + reject(err); + } else { + resolve(row as T | undefined); + } + } + ); + }); + } + + /** + * Fetch all matching rows as an array. + */ + all>( + sql: string, + params?: any[] + ): Promise { + if (this.closed) return Promise.reject(new Error("Database connection is closed")); + return new Promise((resolve, reject) => { + this.db.all( + sql, + params ?? [], + (err: Error | null, rows: any[]) => { + if (err) { + reject(err); + } else { + resolve((rows || []) as T[]); + } + } + ); + }); + } + + /** + * Execute one or more SQL statements (no parameters, no return values). + * Useful for DDL, PRAGMA, or multi-statement scripts. + */ + exec(sql: string): Promise { + if (this.closed) return Promise.reject(new Error("Database connection is closed")); + return new Promise((resolve, reject) => { + this.db.exec(sql, (err: Error | null) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Close the database connection. + * Double-close is a safe no-op. + */ + close(): Promise { + if (this.closed) return Promise.resolve(); + this.closed = true; + return new Promise((resolve, reject) => { + this.db.close((err: Error | null) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Configure database options (synchronous). + * Commonly used: configure("busyTimeout", 5000) + * + * NOTE: Unlike the other methods on AsyncDatabase which return rejected + * Promises when the connection is closed, this method throws synchronously + * because the underlying native `configure()` is itself synchronous. + * Callers should handle this with a try/catch (not `.catch()`). + */ + configure(option: string, value: any): void { + if (this.closed) throw new Error("Database connection is closed"); + this.db.configure(option, value); + } + + /** + * Execute a callback inside a BEGIN/COMMIT transaction. + * If the callback throws, the transaction is rolled back and the error is re-thrown. + * + * This is a convenience wrapper around raw BEGIN/COMMIT/ROLLBACK SQL. + * For serialized (mutex-protected) transactions, use SQLiteIndexManager.runInTransaction() + * which adds a promise-based lock to prevent concurrent transactions. + */ + async transaction(fn: (db: AsyncDatabase) => Promise): Promise { + if (this.closed) return Promise.reject(new Error("Database connection is closed")); + await this.run("BEGIN TRANSACTION"); + try { + const result = await fn(this); + await this.run("COMMIT"); + return result; + } catch (err) { + try { + await this.run("ROLLBACK"); + } catch { + // Swallow ROLLBACK errors — the original error is more important + } + throw err; + } + } + + /** + * Iterate over rows one at a time (memory-efficient for large result sets). + * The callback is called for each row, and the promise resolves with the total count. + */ + each>( + sql: string, + params: any[], + rowCallback: (row: T) => void + ): Promise { + if (this.closed) return Promise.reject(new Error("Database connection is closed")); + return new Promise((resolve, reject) => { + let settled = false; + this.db.each( + sql, + params, + (err: Error | null, row: any) => { + if (settled) return; // already resolved/rejected — stop processing rows + if (err) { + settled = true; + reject(err); + } else { + try { + rowCallback(row as T); + } catch (callbackErr) { + settled = true; + reject(callbackErr instanceof Error ? callbackErr : new Error(String(callbackErr))); + } + } + }, + (err: Error | null, count: number) => { + if (settled) return; // already rejected by a row error + settled = true; + if (err) { + reject(err); + } else { + resolve(count); + } + } + ); + }); + } + + /** + * Load a SQLite extension from a shared library file. + */ + loadExtension(filepath: string): Promise { + if (this.closed) return Promise.reject(new Error("Database connection is closed")); + return new Promise((resolve, reject) => { + this.db.loadExtension(filepath, (err: Error | null) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + /** + * Access the underlying raw Database instance. + * Use only when the wrapper API is insufficient. + */ + get raw(): WrappedDatabase { + return this.db; + } +} diff --git a/src/utils/notebookMetadataManager.ts b/src/utils/notebookMetadataManager.ts index 29460e866..790ed5002 100644 --- a/src/utils/notebookMetadataManager.ts +++ b/src/utils/notebookMetadataManager.ts @@ -5,12 +5,6 @@ import { CodexContentSerializer } from "../serializer"; import { generateUniqueId, clearIdCache } from "./idGenerator"; import { NavigationCell, getCorrespondingSourceUri, getCorrespondingCodexUri } from "./codexNotebookUtils"; // import { API as GitAPI, Repository, Status } from "../providers/scm/git.d"; -import { - deserializeDictionaryEntries, - serializeDictionaryEntries, - repairDictionaryContent, -} from "./dictionaryUtils/common"; -import { readDictionaryClient, saveDictionaryClient } from "./dictionaryUtils/client"; import { CustomNotebookCellData, CustomNotebookMetadata } from "../../types"; import { getWorkSpaceUri } from "./index"; import { getCorpusMarkerForBook } from "../../sharedUtils/corpusUtils"; diff --git a/src/utils/sqliteNativeBinaryManager.ts b/src/utils/sqliteNativeBinaryManager.ts new file mode 100644 index 000000000..224c0bf7d --- /dev/null +++ b/src/utils/sqliteNativeBinaryManager.ts @@ -0,0 +1,407 @@ +/** + * SQLite Native Binary Manager + * + * Downloads the platform-specific SQLite native addon (.node binary) on first startup. + * Modeled after ffmpegManager.ts — detects the current platform/arch, downloads from + * TryGhost/node-sqlite3 GitHub releases, and caches in extension global storage. + * + * The binary only needs to be downloaded once; subsequent startups reuse the cached file. + */ + +import * as vscode from "vscode"; +import * as path from "path"; +import * as fs from "fs"; +import * as os from "os"; + +/** Version of the TryGhost sqlite3 prebuilt binary we download */ +const SQLITE3_VERSION = "5.1.7"; + +/** N-API version for the prebuilt binary (v6 is supported by all modern Node/Electron) */ +const NAPI_VERSION = 6; + +/** Base URL for prebuilt binary downloads */ +const RELEASES_BASE_URL = `https://github.com/TryGhost/node-sqlite3/releases/download/v${SQLITE3_VERSION}`; + +/** Subdirectory name inside extension global storage for the SQLite binary */ +const SQLITE_STORAGE_DIR = "sqlite3-native"; + +/** The expected filename inside the tarball: build/Release/node_sqlite3.node */ +const BINARY_NAME = "node_sqlite3.node"; + +/** Minimum expected binary size in bytes — real binaries are ~2 MB; anything below this is corrupt/truncated */ +const MIN_BINARY_SIZE_BYTES = 500_000; + +/** Maximum download attempts before giving up */ +const MAX_DOWNLOAD_ATTEMPTS = 3; + +/** Cached binary path (set after first successful resolution) */ +let cachedBinaryPath: string | null = null; + +/** In-flight download promise — prevents concurrent download races */ +let downloadInProgress: Promise | null = null; + +/** + * Determine the platform key used in the prebuilt binary filename. + * + * TryGhost builds for: + * darwin-arm64, darwin-x64, linux-arm64, linux-x64, + * linuxmusl-arm64, linuxmusl-x64, win32-ia32, win32-x64 + */ +function getPlatformKey(): string | null { + const platform = process.platform; // 'darwin', 'linux', 'win32' + const arch = process.arch; // 'arm64', 'x64', 'ia32' + + if (platform === "darwin") { + if (arch === "arm64" || arch === "x64") { + return `darwin-${arch}`; + } + } else if (platform === "linux") { + // Detect musl (Alpine Linux) vs glibc + const isMusl = detectMusl(); + const prefix = isMusl ? "linuxmusl" : "linux"; + if (arch === "arm64" || arch === "x64") { + return `${prefix}-${arch}`; + } + } else if (platform === "win32") { + if (arch === "x64" || arch === "ia32") { + return `win32-${arch}`; + } + } + + return null; +} + +/** + * Detect if we're running on a musl-based system (e.g., Alpine Linux). + */ +function detectMusl(): boolean { + try { + // Check for Alpine's /etc/alpine-release + if (fs.existsSync("/etc/alpine-release")) { + return true; + } + // Check if ldd reports musl + // eslint-disable-next-line @typescript-eslint/no-var-requires + const lddOutput = require("child_process") + .execSync("ldd --version 2>&1 || true", { encoding: "utf8" }); + return lddOutput.toLowerCase().includes("musl"); + } catch { + return false; + } +} + +/** + * Get the download URL for the current platform's prebuilt binary. + */ +function getBinaryDownloadUrl(platformKey: string): string { + const filename = `sqlite3-v${SQLITE3_VERSION}-napi-v${NAPI_VERSION}-${platformKey}.tar.gz`; + return `${RELEASES_BASE_URL}/${filename}`; +} + +/** + * Get the expected binary file path in extension global storage. + */ +function getBinaryStoragePath(context: vscode.ExtensionContext): string { + return path.join( + context.globalStorageUri.fsPath, + SQLITE_STORAGE_DIR, + BINARY_NAME + ); +} + +/** + * Get the path to the version marker file next to the binary. + */ +function getVersionFilePath(context: vscode.ExtensionContext): string { + return path.join( + context.globalStorageUri.fsPath, + SQLITE_STORAGE_DIR, + "version.txt" + ); +} + +/** + * Check whether a cached binary matches the expected version and is not corrupted. + * Returns true when the binary should be re-downloaded. + */ +function shouldRedownload(binaryPath: string, versionFilePath: string): boolean { + // Version mismatch → re-download + try { + const storedVersion = fs.readFileSync(versionFilePath, "utf8").trim(); + if (storedVersion !== SQLITE3_VERSION) { + console.log( + `[SQLite] Version mismatch (cached: ${storedVersion}, expected: ${SQLITE3_VERSION}) — will re-download` + ); + return true; + } + } catch { + // version.txt missing or unreadable → treat as outdated + console.log("[SQLite] version.txt missing or unreadable — will re-download"); + return true; + } + + // Size check → corruption guard + try { + const stat = fs.statSync(binaryPath); + if (stat.size < MIN_BINARY_SIZE_BYTES) { + console.log( + `[SQLite] Binary too small (${stat.size} bytes) — likely corrupt, will re-download` + ); + return true; + } + } catch { + return true; + } + + return false; +} + +/** + * Write the version marker after a successful download. + */ +function writeVersionFile(versionFilePath: string): void { + fs.writeFileSync(versionFilePath, SQLITE3_VERSION, "utf8"); +} + +/** Maximum number of HTTP redirects to follow before giving up */ +const MAX_REDIRECTS = 10; + +/** + * Resolve a potentially relative redirect URL against the original request URL. + */ +function resolveRedirectUrl(from: string, location: string): string { + try { + // URL constructor handles both absolute and relative URLs when given a base + return new URL(location, from).href; + } catch { + return location; + } +} + +/** + * Download a file from a URL, following redirects. Returns the data as a Buffer. + * + * @param url - Absolute URL to fetch + * @param redirects - Internal counter to prevent infinite redirect loops + */ +function downloadFile(url: string, redirects = 0): Promise { + return new Promise((resolve, reject) => { + if (redirects > MAX_REDIRECTS) { + reject(new Error(`Too many redirects (>${MAX_REDIRECTS}) — possible redirect loop`)); + return; + } + + const protocol = url.startsWith("https") ? require("https") : require("http"); + + protocol.get(url, { headers: { "User-Agent": "codex-editor" } }, (response: any) => { + // Follow redirects (301, 302, 307, 308) + if ([301, 302, 307, 308].includes(response.statusCode)) { + // Drain the redirect response body to free the socket + response.resume(); + + const location = response.headers.location; + if (!location) { + reject(new Error("Redirect without location header")); + return; + } + const redirectUrl = resolveRedirectUrl(url, location); + downloadFile(redirectUrl, redirects + 1).then(resolve).catch(reject); + return; + } + + if (response.statusCode !== 200) { + response.resume(); + reject(new Error(`Download failed: HTTP ${response.statusCode}`)); + return; + } + + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)); + response.on("end", () => resolve(Buffer.concat(chunks))); + response.on("error", reject); + }).on("error", reject); + }); +} + +/** + * Retry a function up to `MAX_DOWNLOAD_ATTEMPTS` times with exponential backoff + * (1 s, 2 s, 4 s, …). Rethrows the last error on exhaustion. + */ +async function withRetry(fn: () => Promise, label: string): Promise { + let lastError: Error | undefined; + for (let attempt = 1; attempt <= MAX_DOWNLOAD_ATTEMPTS; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (attempt < MAX_DOWNLOAD_ATTEMPTS) { + const delayMs = Math.pow(2, attempt - 1) * 1000; // 1 s, 2 s, 4 s + console.warn( + `[SQLite] ${label} attempt ${attempt}/${MAX_DOWNLOAD_ATTEMPTS} failed: ${lastError.message} — retrying in ${delayMs}ms…` + ); + await new Promise((r) => setTimeout(r, delayMs)); + } + } + } + throw lastError; +} + +/** + * Download and extract the SQLite native binary to extension storage. + */ +async function downloadBinary( + context: vscode.ExtensionContext, + platformKey: string +): Promise { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const tar = require("tar"); + const downloadUrl = getBinaryDownloadUrl(platformKey); + const storageDir = path.join(context.globalStorageUri.fsPath, SQLITE_STORAGE_DIR); + const binaryPath = path.join(storageDir, BINARY_NAME); + const versionFilePath = getVersionFilePath(context); + const tmpFile = path.join(os.tmpdir(), `sqlite3-native-${Date.now()}.tar.gz`); + + console.log(`[SQLite] Downloading native binary from: ${downloadUrl}`); + + try { + // Download the tarball with retry + backoff + const data = await withRetry(() => downloadFile(downloadUrl), "Download"); + + // Write to temp file + fs.writeFileSync(tmpFile, data); + + // Ensure storage directory exists + fs.mkdirSync(storageDir, { recursive: true }); + + // Extract: the tarball contains build/Release/node_sqlite3.node + // We extract with strip=2 to get just the .node file in storageDir + await tar.x({ + file: tmpFile, + cwd: storageDir, + strip: 2, // strip "build/Release/" to put node_sqlite3.node directly in storageDir + }); + + // Verify the binary was extracted + if (!fs.existsSync(binaryPath)) { + throw new Error( + `Binary extraction failed: ${BINARY_NAME} not found at ${binaryPath}` + ); + } + + // Verify extracted binary is not corrupt (size sanity check) + const stat = fs.statSync(binaryPath); + if (stat.size < MIN_BINARY_SIZE_BYTES) { + // Remove the corrupt file so next startup retries from scratch + try { fs.unlinkSync(binaryPath); } catch { /* best-effort */ } + throw new Error( + `Downloaded binary appears corrupt (${stat.size} bytes, expected ≥ ${MIN_BINARY_SIZE_BYTES})` + ); + } + + // Write version marker so future startups know which version is cached + writeVersionFile(versionFilePath); + + console.log(`[SQLite] Native binary v${SQLITE3_VERSION} installed at: ${binaryPath}`); + return binaryPath; + } finally { + // Always clean up the temp file, even on failure + try { fs.unlinkSync(tmpFile); } catch { /* file may not exist if download failed */ } + } +} + +/** + * Ensure the SQLite native binary is available, downloading it if necessary. + * This should be called on extension startup BEFORE any database operations. + * + * Shows a blocking progress dialog if download is needed. + * Returns the path to the .node binary file. + */ +export async function ensureSqliteNativeBinary( + context: vscode.ExtensionContext +): Promise { + const binaryPath = getBinaryStoragePath(context); + const versionFilePath = getVersionFilePath(context); + + // Fast path: in-memory cache still valid (same process, already verified) + if (cachedBinaryPath && cachedBinaryPath === binaryPath && fs.existsSync(cachedBinaryPath)) { + return cachedBinaryPath; + } + + // Check if binary already exists in storage AND matches expected version/integrity + if (fs.existsSync(binaryPath) && !shouldRedownload(binaryPath, versionFilePath)) { + cachedBinaryPath = binaryPath; + console.log(`[SQLite] Using cached native binary v${SQLITE3_VERSION}: ${binaryPath}`); + return binaryPath; + } + + // Determine platform + const platformKey = getPlatformKey(); + if (!platformKey) { + throw new Error( + `SQLite native binary not available for this platform: ` + + `${process.platform}-${process.arch}. ` + + `Supported: darwin-arm64, darwin-x64, linux-arm64, linux-x64, ` + + `linuxmusl-arm64, linuxmusl-x64, win32-ia32, win32-x64` + ); + } + + // If another call is already downloading, piggyback on that promise + // to avoid concurrent extractions into the same directory + if (downloadInProgress) { + console.log("[SQLite] Download already in progress — waiting for it to finish"); + return downloadInProgress; + } + + // Download with blocking progress dialog. + // Wrap in Promise.resolve() because vscode.window.withProgress returns Thenable, not Promise. + downloadInProgress = Promise.resolve( + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Setting up Codex Editor", + cancellable: false, + }, + async (progress) => { + progress.report({ + message: "Downloading search engine components... (one-time setup)", + }); + + try { + const downloadedPath = await downloadBinary(context, platformKey); + progress.report({ message: "Search engine ready!" }); + return downloadedPath; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage( + `Failed to download SQLite native binary: ${msg}. ` + + `Database features (search index) will be unavailable.` + ); + throw error; + } + } + ) + ); + + try { + const result = await downloadInProgress; + cachedBinaryPath = result; + return result; + } finally { + downloadInProgress = null; + } +} + +/** + * Get the cached binary path without triggering a download. + * Returns null if the binary hasn't been downloaded yet. + */ +export function getSqliteBinaryPath(): string | null { + return cachedBinaryPath; +} + +/** + * Reset the cached binary path (useful for testing). + */ +export function resetSqliteBinaryCache(): void { + cachedBinaryPath = null; +} diff --git a/src/utils/typeDemo.ts b/src/utils/typeDemo.ts deleted file mode 100644 index 5c17f81c8..000000000 --- a/src/utils/typeDemo.ts +++ /dev/null @@ -1,105 +0,0 @@ -// Type demonstration - this file shows how the type system works -// It will have TypeScript errors if uncommented lines are invalid - -import { EditHistory, CodexData } from "../../types"; -import { EditMapUtils } from "./editMapUtils"; -import { EditType } from "../../types/enums"; - -// ✅ Valid: String value for cell content -const validValueEdit: EditHistory<["value"]> = { - editMap: EditMapUtils.value(), - value: "Hello", // ✅ Correctly inferred as string - author: "test", - timestamp: 123, - type: EditType.USER_EDIT -}; - -// ✅ Valid: String value for cell label -const validLabelEdit: EditHistory<["metadata", "cellLabel"]> = { - editMap: EditMapUtils.cellLabel(), - value: "Genesis 1:1", // ✅ Correctly inferred as string - author: "test", - timestamp: 123, - type: EditType.USER_EDIT -}; - -// ❌ Invalid examples (commented out to avoid compilation errors): - -/* -// This would cause a TypeScript error because value should be string, not number: -const invalidValueEdit: EditHistory<["value"]> = { - editMap: EditMapUtils.value(), - value: 123, // ❌ TypeScript error: Type 'number' is not assignable to type 'string' - author: "test", - timestamp: 123, - type: EditType.USER_EDIT -}; - -// This would cause a TypeScript error because value should be boolean, not string: -const invalidBoolEdit: EditHistory<["metadata", "data", "deleted"]> = { - editMap: EditMapUtils.dataDeleted(), - value: "true", // ❌ TypeScript error: Type 'string' is not assignable to type 'boolean' - author: "test", - timestamp: 123, - type: EditType.USER_EDIT -}; - -// This would cause a TypeScript error because value should be CodexData, not partial object: -const invalidObjectEdit: EditHistory<["metadata", "data"]> = { - editMap: EditMapUtils.data(), - value: { startTime: 100 }, // ❌ TypeScript error: Missing required properties - author: "test", - timestamp: 123, - type: EditType.USER_EDIT -}; -*/ - -// ✅ Valid: Boolean value for deleted flag -const validBoolEdit: EditHistory<["metadata", "data", "deleted"]> = { - editMap: EditMapUtils.dataDeleted(), - value: true, // ✅ Correctly inferred as boolean - author: "test", - timestamp: 123, - type: EditType.USER_EDIT -}; - -// ✅ Valid: Number value for timestamp -const validNumberEdit: EditHistory<["metadata", "data", "startTime"]> = { - editMap: EditMapUtils.dataStartTime(), - value: 1000, // ✅ Correctly inferred as number - author: "test", - timestamp: 123, - type: EditType.USER_EDIT -}; - -// ✅ Valid: Object value for data -const validObjectEdit: EditHistory<["metadata", "data"]> = { - editMap: EditMapUtils.data(), - value: { // ✅ Correctly inferred as CodexData - startTime: 0, - endTime: 1000, - deleted: false - } as CodexData, - author: "test", - timestamp: 123, - type: EditType.USER_EDIT -}; - -// Type-safe usage demonstration -function processValueEdit(edit: EditHistory<["value"]>) { - // TypeScript knows edit.value is a string - const htmlContent: string = edit.value; // ✅ No type assertion needed - console.log("Processing HTML:", htmlContent.toUpperCase()); // ✅ String methods available -} - -function processBoolEdit(edit: EditHistory<["metadata", "data", "deleted"]>) { - // TypeScript knows edit.value is a boolean - const isDeleted: boolean = edit.value; // ✅ No type assertion needed - console.log("Deleted status:", isDeleted ? "Yes" : "No"); // ✅ Boolean methods available -} - -// Usage -processValueEdit(validValueEdit); -processBoolEdit(validBoolEdit); - -export { validValueEdit, validLabelEdit, validBoolEdit, validNumberEdit, validObjectEdit }; diff --git a/src/utils/typeSystemTest.ts b/src/utils/typeSystemTest.ts deleted file mode 100644 index b671cc2ff..000000000 --- a/src/utils/typeSystemTest.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Test file to demonstrate the type system is working correctly -import { EditHistory } from "../../types"; -import { EditMapUtils } from "./editMapUtils"; -import { EditType } from "../../types/enums"; - -// This file should compile without errors, demonstrating that the type system works - -// Test 1: Value edit should accept string -const valueEdit: EditHistory<["value"]> = { - editMap: EditMapUtils.value(), - value: "test", // Should be string - author: "test", - timestamp: 123, - type: EditType.USER_EDIT -}; - -// Test 2: Cell label edit should accept string -const labelEdit: EditHistory<["metadata", "cellLabel"]> = { - editMap: EditMapUtils.cellLabel(), - value: "Genesis 1:1", // Should be string - author: "test", - timestamp: 123, - type: EditType.USER_EDIT -}; - -// Test 3: Data edit should accept CodexData object -const dataEdit: EditHistory<["metadata", "data"]> = { - editMap: EditMapUtils.data(), - value: { // Should be CodexData object - startTime: 0, - endTime: 1000, - deleted: false - }, - author: "test", - timestamp: 123, - type: EditType.USER_EDIT -}; - -// Test 4: Boolean edit should accept boolean -const boolEdit: EditHistory<["metadata", "data", "deleted"]> = { - editMap: EditMapUtils.dataDeleted(), - value: true, // Should be boolean - author: "test", - timestamp: 123, - type: EditType.USER_EDIT -}; - -// Test 5: Number edit should accept number -const numberEdit: EditHistory<["metadata", "data", "startTime"]> = { - editMap: EditMapUtils.dataStartTime(), - value: 500, // Should be number - author: "test", - timestamp: 123, - type: EditType.USER_EDIT -}; - -// Type-safe usage demonstration -function processValueEdit(edit: EditHistory<["value"]>) { - // TypeScript knows edit.value is a string - const content: string = edit.value; - return content.toUpperCase(); // String methods work -} - -function processDataEdit(edit: EditHistory<["metadata", "data"]>) { - // TypeScript knows edit.value is a CodexData object - const data = edit.value; - return data.startTime; // Object properties work -} - -function processBoolEdit(edit: EditHistory<["metadata", "data", "deleted"]>) { - // TypeScript knows edit.value is a boolean - const isDeleted: boolean = edit.value; - return isDeleted ? "deleted" : "active"; -} - -// Test the functions -const content = processValueEdit(valueEdit); // Returns string -const startTime = processDataEdit(dataEdit); // Returns number -const status = processBoolEdit(boolEdit); // Returns string - -export { valueEdit, labelEdit, dataEdit, boolEdit, numberEdit, content, startTime, status }; diff --git a/src/utils/webviewTemplate.ts b/src/utils/webviewTemplate.ts index c16847032..551070556 100644 --- a/src/utils/webviewTemplate.ts +++ b/src/utils/webviewTemplate.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import { getNonce } from "../providers/dictionaryTable/utilities/getNonce"; +import { getNonce } from "./getNonce"; interface WebviewTemplateOptions { title?: string; diff --git a/types/index.d.ts b/types/index.d.ts index 24a3cad98..eb1d51517 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -10,39 +10,8 @@ interface ChatMessage { content: string; } -type Dictionary = { - id: string; - label: string; - entries: DictionaryEntry[]; - metadata: DictionaryMetadata; -}; - -interface ChatMessageWithContext extends ChatMessage { - context?: any; // FixMe: discuss what context could be. Cound it be a link to a note? - createdAt: string; - preReflection?: string; //If reflection has happened for a chat message, preReflection will be set to the original message. - grade?: number; - gradeComment?: string; -} - -interface FrontEndMessage { - command: { - name: string; // use enum - data?: any; // define based on enum - }; -} type CommentThread = vscode.CommentThread; -interface ChatMessageThread { - id: string; - messages: ChatMessageWithContext[]; - collapsibleState: number; - canReply: boolean; - threadTitle?: string; - deleted: boolean; - createdAt: string; -} - interface NotebookCommentThread { id: string; cellId: CellIdGlobalState; @@ -128,35 +97,6 @@ interface EditHistoryItem SpellCheckResult; - -type SpellCheckDiagnostic = { - range: vscode.Range; - message: string; - severity: vscode.DiagnosticSeverity; - source: string; -}; - type MiniSearchVerseResult = { book: string; chapter: string; @@ -567,14 +374,10 @@ export type EditorPostMessages = | { command: "updateCellIsLocked"; content: { cellId: string; isLocked: boolean; }; } | { command: "updateNotebookMetadata"; content: CustomNotebookMetadata; } | { command: "pickVideoFile"; } - | { command: "togglePinPrompt"; content: { cellId: string; promptText: string; }; } - | { command: "from-quill-spellcheck-getSpellCheckResponse"; content: EditorCellContent; } | { command: "getSourceText"; content: { cellId: string; }; } | { command: "searchSimilarCellIds"; content: { cellId: string; }; } | { command: "updateCellTimestamps"; content: { cellId: string; timestamps: Timestamps; }; } | { command: "deleteCell"; content: { cellId: string; }; } - | { command: "addWord"; words: string[]; } - | { command: "getAlertCodes"; content: GetAlertCodes; } | { command: "executeCommand"; content: { command: string; args: any[]; }; } | { command: "togglePrimarySidebar"; } | { command: "toggleSecondarySidebar"; } @@ -620,15 +423,6 @@ export type EditorPostMessages = | { command: "openSourceText"; content: { chapterNumber: number; }; } | { command: "updateCellLabel"; content: { cellId: string; cellLabel: string; }; } | { command: "pickVideoFile"; } - | { command: "applyPromptedEdit"; content: { text: string; prompt: string; cellId: string; }; } - | { command: "getTopPrompts"; content: { text: string; cellId: string; }; } - | { - command: "supplyRecentEditHistory"; - content: { - cellId: string; - editHistory: EditHistoryEntry[]; - }; - } | { command: "exportFile"; content: { subtitleData: string; format: string; includeStyles: boolean; }; @@ -652,17 +446,6 @@ export type EditorPostMessages = userBacktranslation: string; }; } - | { - command: "rejectEditSuggestion"; - content: { - source: "ice" | "llm"; - cellId?: string; - oldString: string; - newString: string; - leftToken: string; - rightToken: string; - }; - } | { command: "storeFootnote"; content: { @@ -783,7 +566,6 @@ export type EditorPostMessages = variants?: string[]; }; } - | { command: "adjustABTestingProbability"; content: { delta: number; buttonChoice?: "more" | "less"; testId?: string; cellId?: string; }; } | { command: "openLoginFlow"; } | { command: "requestCellsForMilestone"; @@ -812,14 +594,6 @@ export type EditorPostMessages = // (revalidateMissingForCell added above in EditorPostMessages union) -type AlertCodesServerResponse = { - code: number; - cellId: string; - savedSuggestions: { suggestions: string[]; }; -}[]; - -type GetAlertCodes = { text: string; cellId: string; }[]; - /** * Represents a validation entry by a user */ @@ -1140,16 +914,6 @@ interface Timestamps { format?: string; } -interface SpellCheckResponse { - id: string; - text: string; - replacements: Array<{ value: string; }>; - offset: number; - length: number; -} - -type SpellCheckResult = SpellCheckResponse[]; - /* This is the project overview that populates the project manager webview */ interface ProjectOverview extends Project { projectName: string; @@ -1172,7 +936,6 @@ interface ProjectOverview extends Project { validationCount?: number; validationCountAudio?: number; }; - spellcheckIsEnabled: boolean; } /* This is the project metadata that is saved in the metadata.json file */ @@ -1581,7 +1344,6 @@ type ProjectManagerMessageFromWebview = | { command: "publishProject"; } | { command: "syncProject"; } | { command: "openEditAnalysis"; } - | { command: "toggleSpellcheck"; } | { command: "getSyncSettings"; } | { command: "updateSyncSettings"; @@ -1980,7 +1742,7 @@ export type NewSourceUploaderPostMessages = any; // Placeholder - actual types a interface CodexItem { uri: vscode.Uri | string; label: string; - type: "corpus" | "codexDocument" | "dictionary"; + type: "corpus" | "codexDocument"; children?: CodexItem[]; corpusMarker?: string; progress?: { @@ -1995,9 +1757,6 @@ interface CodexItem { requiredAudioValidations?: number; }; sortOrder?: string; - isProjectDictionary?: boolean; - wordCount?: number; - isEnabled?: boolean; fileDisplayName?: string; } type EditorReceiveMessages = @@ -2158,15 +1917,9 @@ type EditorReceiveMessages = type: "autocompleteChapterComplete"; totalCells?: number; } - | { type: "providerSendsSpellCheckResponse"; content: SpellCheckResponse; } - | { - type: "providerSendsgetAlertCodeResponse"; - content: { [cellId: string]: number; }; - } | { type: "providerUpdatesTextDirection"; textDirection: "ltr" | "rtl"; } | { type: "providerSendsLLMCompletionResponse"; content: { completion: string; cellId: string; }; } | { type: "providerSendsABTestVariants"; content: { variants: string[]; cellId: string; testId: string; testName?: string; names?: string[]; abProbability?: number; }; } - | { type: "abTestingProbabilityUpdated"; content: { value: number; }; } | { type: "jumpToSection"; content: string; } | { type: "providerUpdatesNotebookMetadataForWebview"; content: CustomNotebookMetadata; } | { type: "updateVideoUrlInWebview"; content: string; } @@ -2193,9 +1946,7 @@ type EditorReceiveMessages = [cellId: string]: number; // cellId -> unresolvedCount }; } - | { type: "providerSendsPromptedEditResponse"; content: string; } | { type: "providerSendsSimilarCellIdsResponse"; content: { cellId: string; score: number; }[]; } - | { type: "providerSendsTopPrompts"; content: Array<{ prompt: string; isPinned: boolean; }>; } | { type: "providerSendsSourceText"; content: string; } | { type: "providerSendsBacktranslation"; diff --git a/webpack.config.js b/webpack.config.js index eac6a445b..b518ade8e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -6,7 +6,7 @@ const path = require("path"); const webpack = require("webpack"); -const CopyWebpackPlugin = require("copy-webpack-plugin"); + //@ts-check /** @typedef {import('webpack').Configuration} WebpackConfig **/ @@ -27,10 +27,10 @@ const extensionConfig = { externals: { vscode: "commonjs vscode", // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ // modules added here also need to be added in the .vscodeignore file - "fts5-sql-bundle": "commonjs fts5-sql-bundle", vm: "commonjs vm", encoding: "commonjs encoding", // Note: tar is NOT external - it's bundled so audio import can extract FFmpeg on-demand + // Note: sqlite3 native binary is downloaded on demand at runtime, not bundled }, resolve: { // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader @@ -44,7 +44,6 @@ const extensionConfig = { __dirname, "webviews/codex-webviews/src/NewSourceUploader/types.ts" ), - sqldb: path.resolve(__dirname, "src/sqldb"), }, fallback: { path: false, @@ -85,19 +84,12 @@ const extensionConfig = { include: /node_modules/, type: "javascript/auto", }, - { - test: /\.wasm$/, - type: "asset/resource", - }, ], }, devtool: "nosources-source-map", infrastructureLogging: { level: "log", // enables logging required for problem matchers }, - experiments: { - asyncWebAssembly: true, - }, plugins: [ new webpack.ProvidePlugin({ Buffer: ["buffer", "Buffer"], @@ -105,79 +97,17 @@ const extensionConfig = { new webpack.DefinePlugin({ "process.env.NODE_ENV": JSON.stringify("production"), }), - new CopyWebpackPlugin({ - patterns: [ - { - from: "node_modules/fts5-sql-bundle/dist/sql-wasm.wasm", - to: "node_modules/fts5-sql-bundle/dist/sql-wasm.wasm", - }, - { - from: "node_modules/fts5-sql-bundle/dist/sql-wasm.js", - to: "node_modules/fts5-sql-bundle/dist/sql-wasm.js", - }, - { - from: "node_modules/fts5-sql-bundle/dist/index.js", - to: "node_modules/fts5-sql-bundle/dist/index.js", - }, - { - from: "node_modules/fts5-sql-bundle/package.json", - to: "node_modules/fts5-sql-bundle/package.json", - }, - ], - }), ], optimization: { minimize: false, }, ignoreWarnings: [ - { - module: /node_modules\/vscode-languageserver-types/, - }, { module: /node_modules\/mocha/, }, ], }; -const serverConfig = { - name: "server", - target: "node", - mode: "none", - entry: "./src/tsServer/server.ts", - output: { - path: path.resolve(__dirname, "out"), - filename: "server.js", - libraryTarget: "commonjs2", - }, - node: { - __dirname: false, - __filename: false, - global: false, - }, - externals: { - vscode: "commonjs vscode", - }, - resolve: { - extensions: [".ts", ".js"], - alias: { - "@": path.resolve(__dirname, "src"), - }, - }, - module: { - rules: [ - { - test: /\.ts$/, - exclude: /node_modules/, - use: [ - { - loader: "ts-loader", - }, - ], - }, - ], - }, - devtool: "nosources-source-map", -}; const testConfig = { name: "test", @@ -332,4 +262,4 @@ const testRunnerConfig = { devtool: "nosources-source-map", }; -module.exports = [extensionConfig, serverConfig, testConfig, testRunnerConfig]; +module.exports = [extensionConfig, testConfig, testRunnerConfig]; diff --git a/webviews/codex-webviews/package.json b/webviews/codex-webviews/package.json index 03ab70a4a..2852db4ad 100644 --- a/webviews/codex-webviews/package.json +++ b/webviews/codex-webviews/package.json @@ -14,14 +14,13 @@ "build:CodexCellEditor": "cross-env APP_NAME=CodexCellEditor vite build", "build:CommentsView": "cross-env APP_NAME=CommentsView vite build", "build:NavigationView": "cross-env APP_NAME=NavigationView vite build", - "build:EditableReactTable": "cross-env APP_NAME=EditableReactTable vite build", "build:MainMenu": "cross-env APP_NAME=MainMenu vite build", "build:SplashScreen": "cross-env APP_NAME=SplashScreen vite build", "build:CellLabelImporterView": "cross-env APP_NAME=CellLabelImporterView vite build", "build:CodexMigrationToolView": "cross-env APP_NAME=CodexMigrationToolView vite build", "build:NewSourceUploader": "cross-env APP_NAME=NewSourceUploader vite build", "build:CopilotSettings": "cross-env APP_NAME=CopilotSettings vite build", - "build:all": "pnpm run build:StartupFlow && pnpm run build:ParallelView && pnpm run build:PublishProject && pnpm run build:CodexCellEditor && pnpm run build:CommentsView && pnpm run build:NavigationView && pnpm run build:EditableReactTable && pnpm run build:MainMenu && pnpm run build:SplashScreen && pnpm run build:CellLabelImporterView && pnpm run build:NewSourceUploader && pnpm run build:CopilotSettings", + "build:all": "pnpm run build:StartupFlow && pnpm run build:ParallelView && pnpm run build:PublishProject && pnpm run build:CodexCellEditor && pnpm run build:CommentsView && pnpm run build:NavigationView && pnpm run build:MainMenu && pnpm run build:SplashScreen && pnpm run build:CellLabelImporterView && pnpm run build:NewSourceUploader && pnpm run build:CopilotSettings", "type-check": "tsc --noEmit", "watch:all": "nodemon --watch src --ext ts,tsx,css --exec pnpm run build:all", "watch:CodexCellEditor": "nodemon --watch src --ext ts,tsx,css --exec pnpm run build:CodexCellEditor", @@ -29,7 +28,6 @@ "watch:CommentsView": "nodemon --watch src --ext ts,tsx,css --exec pnpm run build:CommentsView", "watch:ParallelView": "nodemon --watch src --ext ts,tsx,css --exec pnpm run build:ParallelView", "watch:StartupFlow": "nodemon --watch src --ext ts,tsx,css --exec pnpm run build:StartupFlow", - "watch:EditableReactTable": "nodemon --watch src --ext ts,tsx,css --exec pnpm run build:EditableReactTable", "watch:NavigationView": "nodemon --watch src --ext ts,tsx,css --exec pnpm run build:NavigationView", "watch:MainMenu": "nodemon --watch src --ext ts,tsx,css --exec pnpm run build:MainMenu", "watch:SplashScreen": "nodemon --watch src --ext ts,tsx,css --exec pnpm run build:SplashScreen", @@ -42,7 +40,6 @@ "preview": "vite preview" }, "dependencies": { - "@popperjs/core": "^2.0.0", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.14", @@ -85,7 +82,6 @@ "pnpm": "^10.6.2", "process": "^0.11.10", "quill": "^2.0.3", - "quill-delta": "^5.1.0", "quilljs-markdown": "^1.2.0", "react": "^18.2.0", "react-contenteditable": "^3.3.5", diff --git a/webviews/codex-webviews/src/CodexCellEditor/CellContentDisplay.tsx b/webviews/codex-webviews/src/CodexCellEditor/CellContentDisplay.tsx index 87fa88b2d..a0f46ddf3 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/CellContentDisplay.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/CellContentDisplay.tsx @@ -43,7 +43,6 @@ interface CellContentDisplayProps { textDirection: "ltr" | "rtl"; isSourceText: boolean; hasDuplicateId: boolean; - alertColorCode: number | undefined; highlightedCellId?: string | null; scrollSyncEnabled: boolean; lineNumber: string; @@ -445,7 +444,6 @@ const CellContentDisplay: React.FC = React.memo( textDirection, isSourceText, hasDuplicateId, - alertColorCode, highlightedCellId, scrollSyncEnabled, lineNumber, @@ -701,39 +699,6 @@ const CellContentDisplay: React.FC = React.memo( // Line numbers are always generated and shown at the beginning of each line // Labels are optional and shown after line numbers when present - // TODO: This was used for spell checking primarily. Will leave in for now but - // will not render it when it is undefined. - const AlertDot = ({ color }: { color: string }) => ( - - ); - - const getAlertDot = () => { - if (alertColorCode === -1 || alertColorCode === undefined) return null; - - const colors = { - "0": "transparent", - "1": "#FF6B6B", - "2": "purple", - "3": "white", - } as const; - return ( - - ); - }; - const getBackgroundColor = () => { if (checkShouldHighlight() && scrollSyncEnabled) { return "var(--vscode-editor-selectionBackground)"; @@ -1270,7 +1235,6 @@ const CellContentDisplay: React.FC = React.memo( )} - {getAlertDot()} )} diff --git a/webviews/codex-webviews/src/CodexCellEditor/CellList.tsx b/webviews/codex-webviews/src/CodexCellEditor/CellList.tsx index b3960bf3b..c00bb2f72 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/CellList.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/CellList.tsx @@ -2,7 +2,6 @@ import { EditorCellContent, EditorPostMessages, QuillCellContent, - SpellCheckResponse, MilestoneIndex, } from "../../../../types"; import React, { useMemo, useCallback, useState, useEffect, useRef, useContext } from "react"; @@ -23,7 +22,6 @@ import { useMessageHandler } from "./hooks/useCentralizedMessageDispatcher"; import { sanitizeQuillHtml } from "./utils"; export interface CellListProps { - spellCheckResponse: SpellCheckResponse | null; translationUnits: QuillCellContent[]; fullDocumentTranslationUnits: QuillCellContent[]; // Full document for global line numbering contentBeingUpdated: EditorCellContent; @@ -35,7 +33,6 @@ export interface CellListProps { isSourceText: boolean; windowHeight: number; headerHeight: number; - alertColorCodes: { [cellId: string]: number }; highlightedCellId?: string | null; scrollSyncEnabled: boolean; translationQueue?: string[]; // Queue of cells waiting for translation @@ -95,8 +92,6 @@ const CellList: React.FC = ({ isSourceText, windowHeight, headerHeight, - spellCheckResponse, - alertColorCodes, highlightedCellId, scrollSyncEnabled, translationQueue = [], @@ -817,7 +812,6 @@ const CellList: React.FC = ({ textDirection={textDirection} isSourceText={isSourceText} hasDuplicateId={hasDuplicateId} - alertColorCode={alertColorCodes[cellMarkers[0]]} highlightedCellId={highlightedCellId} scrollSyncEnabled={scrollSyncEnabled} isInTranslationProcess={isCellInTranslationProcess(cellMarkers[0])} @@ -853,7 +847,6 @@ const CellList: React.FC = ({ duplicateCellIds, highlightedCellId, scrollSyncEnabled, - alertColorCodes, generateCellLabel, isCellInTranslationProcess, getCellTranslationState, @@ -915,7 +908,6 @@ const CellList: React.FC = ({ = ({ textDirection={textDirection} isSourceText={isSourceText} hasDuplicateId={false} - alertColorCode={alertColorCodes[cellMarkers[0]]} highlightedCellId={highlightedCellId} scrollSyncEnabled={scrollSyncEnabled} isInTranslationProcess={isCellInTranslationProcess(cellMarkers[0])} @@ -1029,7 +1020,6 @@ const CellList: React.FC = ({ isCorrectionEditorMode, contentBeingUpdated, generateCellLabel, - spellCheckResponse, setContentBeingUpdated, handleCloseEditor, handleSaveHtml, @@ -1042,7 +1032,6 @@ const CellList: React.FC = ({ renderCellGroup, lineNumbersEnabled, vscode, - alertColorCodes, scrollSyncEnabled, isCellInTranslationProcess, getCellTranslationState, diff --git a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx index 6b6165e2a..5b13586db 100755 --- a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx @@ -5,7 +5,6 @@ import { QuillCellContent, EditorPostMessages, EditorCellContent, - SpellCheckResponse, CustomNotebookMetadata, EditorReceiveMessages, CellIdGlobalState, @@ -17,8 +16,6 @@ import CellList from "./CellList"; import { useVSCodeMessageHandler } from "./hooks/useVSCodeMessageHandler"; import { VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"; import VideoPlayer from "./VideoPlayer"; -import registerQuillSpellChecker from "./react-quill-spellcheck"; -import { getCleanedHtml } from "./react-quill-spellcheck/SuggestionBoxes"; import UnsavedChangesContext from "./contextProviders/UnsavedChangesContext"; import SourceCellContext from "./contextProviders/SourceCellContext"; import DuplicateCellResolver from "./DuplicateCellResolver"; @@ -135,9 +132,6 @@ const CodexCellEditor: React.FC = () => { const [allCellsInCurrentMilestone, setAllCellsInCurrentMilestone] = useState< QuillCellContent[] >([]); - const [alertColorCodes, setAlertColorCodes] = useState<{ - [cellId: string]: number; - }>({}); const [highlightedCellId, setHighlightedCellId] = useState(null); const [isWebviewReady, setIsWebviewReady] = useState(false); const [scrollSyncEnabled, setScrollSyncEnabled] = useState(true); @@ -224,7 +218,6 @@ const CodexCellEditor: React.FC = () => { const singleCellProgress = singleCellTranslationState.progress; // Required state variables that were removed - const [spellCheckResponse, setSpellCheckResponse] = useState(null); const [contentBeingUpdated, setContentBeingUpdated] = useState( {} as EditorCellContent ); @@ -1067,32 +1060,6 @@ const CodexCellEditor: React.FC = () => { setCurrentEditingCellId(content.cellMarkers?.[0] || null); }; - // Add the removeHtmlTags function - const removeHtmlTags = (text: string) => { - const temp = document.createElement("div"); - temp.innerHTML = text; - return temp.textContent || temp.innerText || ""; - }; - - // Function to check alert codes - const checkAlertCodes = () => { - const cellContentAndId = translationUnits.map((unit) => ({ - text: removeHtmlTags(unit.cellContent), - cellId: unit.cellMarkers[0], - })); - - debug("alerts", "Checking alert codes for cells:", { count: cellContentAndId.length }); - vscode.postMessage({ - command: "getAlertCodes", - content: cellContentAndId, - } as EditorPostMessages); - }; - - // useEffect(() => { - // // TODO: we are removing spell check for now until someone needs it - // checkAlertCodes(); - // }, [translationUnits]); - // Clear successful completions after a delay when all translations are complete useEffect(() => { const noActiveTranslations = @@ -1209,7 +1176,6 @@ const CodexCellEditor: React.FC = () => { setIsSourceText(isSourceText); setSourceCellMap(sourceCellMap); }, - setSpellCheckResponse: setSpellCheckResponse, jumpToCell: (cellId) => { const chapter = cellId?.split(" ")[1]?.split(":")[0]; const newChapterNumber = parseInt(chapter) || 1; @@ -1353,10 +1319,6 @@ const CodexCellEditor: React.FC = () => { updateVideoUrl: (url: string) => { setTempVideoUrl(url); }, - setAlertColorCodes: setAlertColorCodes, - recheckAlertCodes: () => { - // checkAlertCodes(); // TODO: we are removing spell check for now until someone needs it - }, // Use cellError handler instead of showErrorMessage cellError: (data) => { debug( @@ -1593,11 +1555,6 @@ const CodexCellEditor: React.FC = () => { return () => window.removeEventListener("focus", () => {}); }, []); - useEffect(() => { - // Initialize Quill and register SpellChecker and SmartEdits only once - registerQuillSpellChecker(Quill as any, vscode); - }, []); - const calculateTotalChapters = (units: QuillCellContent[]): number => { const sectionSet = new Set(); units.forEach((unit) => { @@ -2031,7 +1988,6 @@ const CodexCellEditor: React.FC = () => { requestId, content: content, } as EditorPostMessages); - checkAlertCodes(); }; // Provider ack: only mark the save as complete once the provider confirms the file write finished. @@ -3029,7 +2985,6 @@ const CodexCellEditor: React.FC = () => { >
0 @@ -3045,7 +3000,6 @@ const CodexCellEditor: React.FC = () => { isSourceText={isSourceText} windowHeight={windowHeight} headerHeight={headerHeight} - alertColorCodes={alertColorCodes} highlightedCellId={highlightedCellId} scrollSyncEnabled={scrollSyncEnabled} translationQueue={translationQueue} diff --git a/webviews/codex-webviews/src/CodexCellEditor/DuplicateCellResolver.tsx b/webviews/codex-webviews/src/CodexCellEditor/DuplicateCellResolver.tsx index e03539ad0..66fccc1ac 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/DuplicateCellResolver.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/DuplicateCellResolver.tsx @@ -116,7 +116,6 @@ const DuplicateCellResolver: React.FC<{ } label={cell.cellLabel} lineNumbersEnabled={lineNumbersEnabled} - alertColorCode={-1} hasDuplicateId={false} highlightedCellId={null} scrollSyncEnabled={false} diff --git a/webviews/codex-webviews/src/CodexCellEditor/Editor.tsx b/webviews/codex-webviews/src/CodexCellEditor/Editor.tsx index d95db9088..3b3b5cd2d 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/Editor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/Editor.tsx @@ -9,11 +9,8 @@ import React, { } from "react"; import Quill from "quill"; import "quill/dist/quill.snow.css"; -import registerQuillSpellChecker, { - getCleanedHtml, - QuillSpellChecker, -} from "./react-quill-spellcheck"; -import { EditHistory, EditorPostMessages, SpellCheckResponse } from "../../../../types"; +import { getCleanedHtml } from "./utils"; +import { EditHistory, EditorPostMessages } from "../../../../types"; import { EditMapUtils, isValueEdit } from "../../../../src/utils/editMapUtils"; import { EditType } from "../../../../types/enums"; @@ -29,10 +26,6 @@ const icons: any = Quill.import("ui/icons"); // Assuming you have access to the VSCode API here const vscode: any = (window as any).vscodeApi; -// Register the QuillSpellChecker with the VSCode API -registerQuillSpellChecker(Quill, vscode); -// Removed custom icon registrations for non-native buttons - // Define the shape of content change callback export interface EditorContentChanged { html: string; @@ -46,7 +39,6 @@ export interface EditorProps { editHistory: EditHistory[]; onChange?: (changes: EditorContentChanged) => void; onDirtyChange?: (dirty: boolean, rawHtml: string) => void; - spellCheckResponse?: SpellCheckResponse | null; textDirection: "ltr" | "rtl"; setIsEditingFootnoteInline: (isEditing: boolean) => void; isEditingFootnoteInline: boolean; @@ -62,10 +54,6 @@ class AutocompleteFormat extends Inline { static tagName = "span"; } -class OpenLibraryFormat extends Inline { - static blotName = "openLibrary"; - static tagName = "span"; -} // Define Footnote Format class FootnoteFormat extends Inline { @@ -89,7 +77,6 @@ class FootnoteFormat extends Inline { // Register formats Quill.register({ "formats/autocomplete": AutocompleteFormat, - "formats/openLibrary": OpenLibraryFormat, "formats/footnote": FootnoteFormat, }); @@ -103,7 +90,6 @@ function debug(message: string, ...args: any[]): void { // Export interface for imperative handle export interface EditorHandles { autocomplete: () => void; - openLibrary: () => void; showEditHistory: () => void; getSelectionText: () => string; addFootnote: () => void; @@ -261,8 +247,6 @@ const Editor = forwardRef((props, ref) => { const { setIsEditingFootnoteInline, isEditingFootnoteInline } = props; const [isToolbarExpanded, setIsToolbarExpanded] = useState(false); const [isToolbarVisible, setIsToolbarVisible] = useState(false); - const [showModal, setShowModal] = useState(false); - const [wordsToAdd, setWordsToAdd] = useState([]); const [isEditorEmpty, setIsEditorEmpty] = useState(true); const [editHistory, setEditHistory] = useState([]); const initialContentRef = useRef(""); @@ -339,7 +323,6 @@ const Editor = forwardRef((props, ref) => { }, }, }, - spellChecker: {}, }, }); // Apply minimal direct styles; rely on CSS file for look-and-feel @@ -691,14 +674,6 @@ const Editor = forwardRef((props, ref) => { footnote.classList.remove("footnote-selected"); }); - // Clean up spell checker - const spellChecker = quillRef.current.getModule( - "spellChecker" - ) as QuillSpellChecker; - if (spellChecker) { - spellChecker.dispose(); - } - // Clear the reference quillRef.current = null; } @@ -747,49 +722,13 @@ const Editor = forwardRef((props, ref) => { } as EditorPostMessages); }; - const handleAddWords = () => { - if (wordsToAdd.length > 0) { - window.vscodeApi.postMessage({ - command: "addWord", - words: wordsToAdd, - }); - } - setShowModal(false); - }; - - // Add message listener for prompt response + // Add message listener for LLM completion response useMessageHandler( "editor-promptResponse", (event: MessageEvent) => { if (quillRef.current) { const quill = quillRef.current; - if (event.data.type === "providerSendsPromptedEditResponse") { - const editedContent = event.data.content; - // Use Quill's API to set content with "api" source (not "user") - quill.clipboard.dangerouslyPasteHTML(editedContent, "api"); - - // Update baseline for dirty checking - LLM content is the new "initial" state - quillInitialContentRef.current = quill.root.innerHTML; - - // Mark as LLM content needing approval - isLLMContentNeedingApprovalRef.current = true; - - // Manually update all state for programmatic changes - const textContent = quill.getText(); - const charCount = textContent.trim().length; - setCharacterCount(charCount); - - // Call onChange with processed content - const contentIsEmpty = isQuillEmpty(quill); - const finalContent = contentIsEmpty - ? "" - : processQuillContentForSaving(getCleanedHtml(quill.root.innerHTML)); - props.onChange?.({ html: finalContent }); - - // Mark as dirty to ensure save button appears - props.onDirtyChange?.(true, quill.root.innerHTML); - setUnsavedChanges(true); - } else if (event.data.type === "providerSendsLLMCompletionResponse") { + if (event.data.type === "providerSendsLLMCompletionResponse") { const completionText = event.data.content.completion; const completionCellId = event.data.content.cellId; @@ -1172,16 +1111,6 @@ const Editor = forwardRef((props, ref) => { content: { currentLineId: props.currentLineId, addContentToValue: false }, }); }, - openLibrary: () => { - const quill = quillRef.current!; - const words = quill - .getText() - .split(/[\s\n.,!?]+/) - .filter((w) => w.length > 0) - .filter((w, i, self) => self.indexOf(w) === i); - setWordsToAdd(words); - setShowModal(true); - }, showEditHistory: () => { setEditHistoryForCell(props.editHistory); setShowHistoryModal(true); @@ -1700,41 +1629,6 @@ const Editor = forwardRef((props, ref) => {
)} - {showModal && ( -
-

Add Words to Dictionary

-

- {wordsToAdd.length > 0 - ? `Add all words to the dictionary?` - : "No words found in the content."} -

-
- - {wordsToAdd.length > 0 && ( - - )} -
-
- )} ); }); diff --git a/webviews/codex-webviews/src/CodexCellEditor/EditorWithABTesting.tsx b/webviews/codex-webviews/src/CodexCellEditor/EditorWithABTesting.tsx deleted file mode 100644 index 95cbf87e8..000000000 --- a/webviews/codex-webviews/src/CodexCellEditor/EditorWithABTesting.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import React, { - useRef, - useEffect, - useMemo, - useState, - useContext, - forwardRef, - useImperativeHandle, -} from "react"; -import Quill from "quill"; -import "quill/dist/quill.snow.css"; -import registerQuillSpellChecker, { - getCleanedHtml, - QuillSpellChecker, -} from "./react-quill-spellcheck"; -import { EditHistory, EditorPostMessages, SpellCheckResponse } from "../../../../types"; -import "./Editor.css"; -import UnsavedChangesContext from "./contextProviders/UnsavedChangesContext"; -import ReactPlayer from "react-player"; -import { diffWords } from "diff"; -import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; -import { processHtmlContent, updateFootnoteNumbering } from "./footnoteUtils"; -import { ABTestVariantSelector } from "./components/ABTestVariantSelector"; -import { useMessageHandler } from "./hooks/useCentralizedMessageDispatcher"; - -const icons: any = Quill.import("ui/icons"); -const vscode: any = (window as any).vscodeApi; - -registerQuillSpellChecker(Quill, vscode); - -interface ABTestState { - isActive: boolean; - variants: string[]; - cellId: string; - testId: string; - testName?: string; -} - -export interface EditorRef { - quillRef: React.RefObject; - printContents: () => void; - getSelection: () => { text: string; html: string } | null; - getCurrentLineId: () => string | undefined; - setSelectionToRange: (from: number, to: number) => void; - focus: () => void; - clearSelection: () => void; - addText: (text: string) => void; - isEnabled: () => boolean; - setIsEnabled: (isEnabled: boolean) => void; - showHistory: () => void; - setShowHistory: (showHistory: boolean) => void; - getCleanTextFromQuill: () => string; - getQuillContent: () => string; - deleteText: (from: number, to: number) => void; - insertText: (position: number, text: string) => void; -} - -interface EditorProps { - currentLineId: string; - currentLineIndex: number; - content: string; - readOnly: boolean; - fontFamily?: string; - fontSizeClass?: string; - isRTL?: boolean; - onReady?: () => void; - onChange?: (data: { - text: string; - html: string; - wordCount: number; - editHistory?: EditHistory; - }) => void; - onCursorChangeUpdated?: (cursorIndex: number, selectionLength: number) => void; - onEditorFocused?: () => void; - onEditorBlurred?: () => void; - showTranslationHelper?: boolean; - isSourceText?: boolean; - onSelectionChange?: (range: any) => void; - setSpellCheckResponse: React.Dispatch>; - hasUnsavedChanges?: boolean; -} - -const EditorWithABTesting = forwardRef((props, ref) => { - const quillRef = useRef(null); - const [quillContainer, setQuillContainer] = useState(null); - const { unsavedChanges, setUnsavedChanges } = useContext(UnsavedChangesContext); - const [spellChecker, setSpellChecker] = useState(null); - const [isEnabled, setIsEnabled] = useState(true); - const [showHistory, setShowHistory] = useState(false); - const [abTestState, setAbTestState] = useState({ - isActive: false, - variants: [], - cellId: "", - testId: "", - testName: "", - }); - - // A/B Testing handlers - const handleShowABTestVariants = (data: { - variants: string[]; - cellId: string; - testId: string; - }) => { - setAbTestState({ - isActive: true, - variants: data.variants, - cellId: data.cellId, - testId: data.testId, - }); - }; - - const handleVariantSelected = (selectedIndex: number, selectionTimeMs: number) => { - if (!abTestState.isActive) return; - - // Send selection to backend - backend handles all logic - vscode.postMessage({ - command: "selectABTestVariant", - content: { - cellId: abTestState.cellId, - selectedIndex, - testId: abTestState.testId, - testName: abTestState.testName, - selectionTimeMs, - totalVariants: abTestState.variants?.length ?? 0, - variants: abTestState.variants, - }, - } as EditorPostMessages); - - // Close the selector - backend will send new one if needed - handleDismissABTest(); - }; - - const handleDismissABTest = () => { - setAbTestState({ - isActive: false, - variants: [], - cellId: "", - testId: "", - testName: "", - }); - }; - - const updateHeaderLabel = () => { - // Implementation for updating header label - }; - - // Enhanced message handling for A/B testing - useMessageHandler( - "editorWithABTesting", - (event: MessageEvent) => { - if (quillRef.current) { - const quill = quillRef.current; - if (event.data.type === "providerSendsPromptedEditResponse") { - quill.root.innerHTML = event.data.content; - } else if (event.data.type === "providerSendsLLMCompletionResponse") { - const completionText = event.data.content.completion; - const completionCellId = event.data.content.cellId; - - // Validate that the completion is for the current cell - if (completionCellId === props.currentLineId) { - quill.root.innerHTML = completionText; - props.onChange?.({ - html: quill.root.innerHTML, - text: quill.getText(), - wordCount: quill.getText().trim().split(/\s+/).length, - }); - setUnsavedChanges(true); - } else { - console.warn( - `LLM completion received for cell ${completionCellId} but current cell is ${props.currentLineId}. Ignoring completion.` - ); - } - } else if (event.data.type === "providerSendsABTestVariants") { - // Handle A/B test variants: always show selector for user choice - const { variants, cellId, testId, testName } = event.data.content as { - variants: string[]; - cellId: string; - testId: string; - testName?: string; - }; - if ( - cellId === props.currentLineId && - Array.isArray(variants) && - variants.length > 1 - ) { - setAbTestState({ - isActive: true, - variants, - cellId, - testId, - testName, - }); - } - } - updateHeaderLabel(); - } - }, - [props.currentLineId, props.onChange, updateHeaderLabel] - ); - - // Rest of the Editor component logic would be here... - // For brevity, I'm including just the essential parts for A/B testing - // The full implementation would include all the Quill setup, formatting, etc. - - useImperativeHandle(ref, () => ({ - quillRef, - printContents: () => { - if (quillRef.current) { - console.log(quillRef.current.getContents()); - } - }, - getSelection: () => { - if (!quillRef.current) return null; - const selection = quillRef.current.getSelection(); - if (!selection) return null; - return { - text: quillRef.current.getText(selection.index, selection.length), - html: quillRef.current.getSemanticHTML(selection.index, selection.length), - }; - }, - getCurrentLineId: () => props.currentLineId, - setSelectionToRange: (from: number, to: number) => { - quillRef.current?.setSelection(from, to - from); - }, - focus: () => { - quillRef.current?.focus(); - }, - clearSelection: () => { - quillRef.current?.setSelection(null); - }, - addText: (text: string) => { - const selection = quillRef.current?.getSelection(); - if (selection && quillRef.current) { - quillRef.current.insertText(selection.index, text); - } - }, - isEnabled: () => isEnabled, - setIsEnabled, - showHistory: () => showHistory, - setShowHistory, - getCleanTextFromQuill: () => { - return quillRef.current ? getCleanedHtml(quillRef.current.getSemanticHTML()) : ""; - }, - getQuillContent: () => { - return quillRef.current?.root.innerHTML || ""; - }, - deleteText: (from: number, to: number) => { - quillRef.current?.deleteText(from, to - from); - }, - insertText: (position: number, text: string) => { - quillRef.current?.insertText(position, text); - }, - })); - - return ( -
- {/* Quill editor container */} -
- - {/* A/B Testing Overlay */} - {abTestState.isActive && ( - - )} -
- ); -}); - -EditorWithABTesting.displayName = "EditorWithABTesting"; - -export default EditorWithABTesting; diff --git a/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx index c87fb93bc..a4beb7fd2 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/TextCellEditor.tsx @@ -4,11 +4,10 @@ import { EditorPostMessages, QuillCellContent, EditHistory, - SpellCheckResponse, Timestamps, } from "../../../../types"; import Editor, { EditorHandles } from "./Editor"; -import { getCleanedHtml } from "./react-quill-spellcheck"; +import { getCleanedHtml } from "./utils"; import { CodexCellTypes } from "../../../../types/enums"; import { AddParatextButton } from "./AddParatextButton"; import ReactMarkdown from "react-markdown"; @@ -107,7 +106,6 @@ interface CellEditorProps { cellContent: string; cellIndex: number; cellType: CodexCellTypes; - spellCheckResponse: SpellCheckResponse | null; contentBeingUpdated: EditorCellContent; setContentBeingUpdated: (content: EditorCellContent) => void; handleCloseEditor: () => void; @@ -223,7 +221,6 @@ const CellEditor: React.FC = ({ editHistory, cellIndex, cellType, - spellCheckResponse, contentBeingUpdated, setContentBeingUpdated, handleCloseEditor, @@ -729,7 +726,7 @@ const CellEditor: React.FC = ({ getCleanedHtml(contentBeingUpdated.cellContent).replace(/\s/g, "") !== ""; const handleContentUpdate = (newContent: string) => { - // Clean spell check markup before updating content + // Clean suggestion markup before updating content const cleanedContent = getCleanedHtml(newContent); setContentBeingUpdated({ @@ -968,7 +965,7 @@ const CellEditor: React.FC = ({ try { const parser = new DOMParser(); - // Clean spell check markup before parsing + // Clean suggestion markup before parsing const cleanedContent = getCleanedHtml(editorContent); const doc = parser.parseFromString(cleanedContent, "text/html"); const footnoteElements = doc.querySelectorAll("sup.footnote-marker"); @@ -984,7 +981,7 @@ const CellEditor: React.FC = ({ footnoteElements.forEach((element) => { const id = element.textContent || ""; const rawContent = element.getAttribute("data-footnote") || ""; - // Clean spell check markup from footnote content as well + // Clean suggestion markup from footnote content as well const content = getCleanedHtml(rawContent); // Calculate the actual position of this element in the document @@ -2155,10 +2152,9 @@ const CellEditor: React.FC = ({ currentLineId={cellMarkers[0]} key={`${cellIndex}-quill`} initialValue={editorContent} - spellCheckResponse={spellCheckResponse} editHistory={editHistory} onChange={({ html }) => { - // Clean spell check markup before processing + // Clean suggestion markup before processing const cleanedHtml = getCleanedHtml(html); setEditorContent(cleanedHtml); @@ -2504,7 +2500,7 @@ const CellEditor: React.FC = ({ onConfirm={() => { // Create DOM parser to edit the HTML directly const parser = new DOMParser(); - // Clean spell check markup before parsing + // Clean suggestion markup before parsing const cleanedContent = getCleanedHtml(editorContent); const doc = parser.parseFromString( @@ -2562,7 +2558,7 @@ const CellEditor: React.FC = ({
diff --git a/webviews/codex-webviews/src/CodexCellEditor/__tests___/AttentionCheckRecovery.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/__tests___/AttentionCheckRecovery.test.tsx index 4d7261501..99bf0bb43 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/__tests___/AttentionCheckRecovery.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/__tests___/AttentionCheckRecovery.test.tsx @@ -294,9 +294,8 @@ describe("Attention Check Recovery Flow", () => { * - Initial selection: always tracked * - Recovery selection: NOT tracked * - * Note: The actual analytics logic is in EditorWithABTesting.tsx, - * which wraps ABTestVariantSelector. These tests verify the - * component's callback behavior that enables proper analytics handling. + * These tests verify the component's callback behavior + * that enables proper analytics handling. */ it("should call onVariantSelected callback for initial selection", async () => { diff --git a/webviews/codex-webviews/src/CodexCellEditor/__tests___/CellContentDisplay.buttonOrder.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/__tests___/CellContentDisplay.buttonOrder.test.tsx index 1de3062e1..e27aca80b 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/__tests___/CellContentDisplay.buttonOrder.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/__tests___/CellContentDisplay.buttonOrder.test.tsx @@ -122,7 +122,6 @@ describe("CellContentDisplay - Button Order Tests", () => { textDirection: "ltr" as const, isSourceText: false, // .codex file hasDuplicateId: false, - alertColorCode: undefined, highlightedCellId: null, scrollSyncEnabled: true, lineNumber: "1", @@ -192,7 +191,6 @@ describe("CellContentDisplay - Button Order Tests", () => { textDirection: "ltr" as const, isSourceText: false, // .codex file hasDuplicateId: false, - alertColorCode: undefined, highlightedCellId: null, scrollSyncEnabled: true, lineNumber: "2", @@ -255,7 +253,6 @@ describe("CellContentDisplay - Button Order Tests", () => { textDirection: "ltr" as const, isSourceText: true, // .source file hasDuplicateId: false, - alertColorCode: undefined, highlightedCellId: null, scrollSyncEnabled: true, lineNumber: "1", @@ -300,7 +297,6 @@ describe("CellContentDisplay - Button Order Tests", () => { textDirection: "ltr" as const, isSourceText: true, // .source file hasDuplicateId: false, - alertColorCode: undefined, highlightedCellId: null, scrollSyncEnabled: true, lineNumber: "2", @@ -351,7 +347,6 @@ describe("CellContentDisplay - Button Order Tests", () => { textDirection: "ltr" as const, isSourceText: true, // .source file hasDuplicateId: false, - alertColorCode: undefined, highlightedCellId: null, scrollSyncEnabled: true, lineNumber: "2", @@ -390,7 +385,6 @@ describe("CellContentDisplay - Button Order Tests", () => { textDirection: "ltr" as const, isSourceText: true, hasDuplicateId: false, - alertColorCode: undefined, highlightedCellId: null, scrollSyncEnabled: true, lineNumber: "2", @@ -428,7 +422,6 @@ describe("CellContentDisplay - Button Order Tests", () => { textDirection: "ltr" as const, isSourceText: true, hasDuplicateId: false, - alertColorCode: undefined, highlightedCellId: null, scrollSyncEnabled: true, lineNumber: "1", @@ -469,7 +462,6 @@ describe("CellContentDisplay - Button Order Tests", () => { textDirection: "ltr" as const, isSourceText: true, hasDuplicateId: false, - alertColorCode: undefined, highlightedCellId: null, scrollSyncEnabled: true, lineNumber: "2", @@ -510,7 +502,6 @@ describe("CellContentDisplay - Button Order Tests", () => { textDirection: "ltr" as const, isSourceText: false, hasDuplicateId: false, - alertColorCode: undefined, highlightedCellId: null, scrollSyncEnabled: true, lineNumber: "1", diff --git a/webviews/codex-webviews/src/CodexCellEditor/__tests___/CellContentDisplay.lockUnlock.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/__tests___/CellContentDisplay.lockUnlock.test.tsx index 89b2be737..74b525795 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/__tests___/CellContentDisplay.lockUnlock.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/__tests___/CellContentDisplay.lockUnlock.test.tsx @@ -132,7 +132,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -170,7 +169,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -215,7 +213,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -260,7 +257,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -302,7 +298,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -344,7 +339,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -375,7 +369,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="2" @@ -408,7 +401,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -438,7 +430,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="2" @@ -474,7 +465,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -521,7 +511,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -561,7 +550,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -602,7 +590,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -641,7 +628,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -685,7 +671,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -753,7 +738,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -809,7 +793,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -857,7 +840,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -904,7 +886,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -970,7 +951,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -1022,7 +1002,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -1068,7 +1047,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="2" @@ -1139,7 +1117,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" @@ -1191,7 +1168,6 @@ describe("CellContentDisplay - Lock/Unlock UI Behavior", () => { textDirection="ltr" isSourceText={false} hasDuplicateId={false} - alertColorCode={undefined} highlightedCellId={null} scrollSyncEnabled={true} lineNumber="1" diff --git a/webviews/codex-webviews/src/CodexCellEditor/__tests___/CellLineNumbers.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/__tests___/CellLineNumbers.test.tsx index 88ef7e24e..6bd5c2c3d 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/__tests___/CellLineNumbers.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/__tests___/CellLineNumbers.test.tsx @@ -171,7 +171,6 @@ describe("Cell Line Numbers and Labels", () => { ]; const props = { - spellCheckResponse: null, translationUnits, fullDocumentTranslationUnits: translationUnits, contentBeingUpdated: { @@ -187,7 +186,6 @@ describe("Cell Line Numbers and Labels", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -218,7 +216,6 @@ describe("Cell Line Numbers and Labels", () => { ]; const props = { - spellCheckResponse: null, translationUnits, fullDocumentTranslationUnits: translationUnits, contentBeingUpdated: { @@ -234,7 +231,6 @@ describe("Cell Line Numbers and Labels", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -267,7 +263,6 @@ describe("Cell Line Numbers and Labels", () => { ]; const props = { - spellCheckResponse: null, translationUnits, fullDocumentTranslationUnits: translationUnits, contentBeingUpdated: { @@ -283,7 +278,6 @@ describe("Cell Line Numbers and Labels", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -324,7 +318,6 @@ describe("Cell Line Numbers and Labels", () => { ]; const props = { - spellCheckResponse: null, translationUnits, fullDocumentTranslationUnits: translationUnits, contentBeingUpdated: { @@ -340,7 +333,6 @@ describe("Cell Line Numbers and Labels", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -385,7 +377,6 @@ describe("Cell Line Numbers and Labels", () => { ]; const props = { - spellCheckResponse: null, translationUnits, fullDocumentTranslationUnits: translationUnits, contentBeingUpdated: { @@ -401,7 +392,6 @@ describe("Cell Line Numbers and Labels", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -441,7 +431,6 @@ describe("Cell Line Numbers and Labels", () => { ]; const props = { - spellCheckResponse: null, translationUnits, fullDocumentTranslationUnits: translationUnits, contentBeingUpdated: { @@ -457,7 +446,6 @@ describe("Cell Line Numbers and Labels", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -496,7 +484,6 @@ describe("Cell Line Numbers and Labels", () => { ]; const props = { - spellCheckResponse: null, translationUnits, fullDocumentTranslationUnits: translationUnits, contentBeingUpdated: { @@ -512,7 +499,6 @@ describe("Cell Line Numbers and Labels", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -553,7 +539,6 @@ describe("Cell Line Numbers and Labels", () => { ]; const props = { - spellCheckResponse: null, translationUnits, fullDocumentTranslationUnits: translationUnits, contentBeingUpdated: { @@ -569,7 +554,6 @@ describe("Cell Line Numbers and Labels", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", diff --git a/webviews/codex-webviews/src/CodexCellEditor/__tests___/CellList.footnoteOffset.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/__tests___/CellList.footnoteOffset.test.tsx index c8917b2b4..06c1e209c 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/__tests___/CellList.footnoteOffset.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/__tests___/CellList.footnoteOffset.test.tsx @@ -203,7 +203,6 @@ describe("CellList - Footnote Offset Calculation", () => { }; const props = { - spellCheckResponse: null, translationUnits, fullDocumentTranslationUnits, contentBeingUpdated: { @@ -219,7 +218,6 @@ describe("CellList - Footnote Offset Calculation", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -272,7 +270,6 @@ describe("CellList - Footnote Offset Calculation", () => { }; const props = { - spellCheckResponse: null, translationUnits, fullDocumentTranslationUnits, contentBeingUpdated: { @@ -288,7 +285,6 @@ describe("CellList - Footnote Offset Calculation", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -348,7 +344,6 @@ describe("CellList - Footnote Offset Calculation", () => { }; const props = { - spellCheckResponse: null, translationUnits, fullDocumentTranslationUnits, contentBeingUpdated: { @@ -364,7 +359,6 @@ describe("CellList - Footnote Offset Calculation", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -421,7 +415,6 @@ describe("CellList - Footnote Offset Calculation", () => { // Test page 2 const props = { - spellCheckResponse: null, translationUnits: page2Cells, // Current page (page 2) fullDocumentTranslationUnits: allCellsInMilestone, // All cells in milestone contentBeingUpdated: { @@ -437,7 +430,6 @@ describe("CellList - Footnote Offset Calculation", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -497,7 +489,6 @@ describe("CellList - Footnote Offset Calculation", () => { }; const props = { - spellCheckResponse: null, translationUnits, fullDocumentTranslationUnits, contentBeingUpdated: { @@ -513,7 +504,6 @@ describe("CellList - Footnote Offset Calculation", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -597,7 +587,6 @@ describe("CellList - Footnote Offset Calculation", () => { }; const props = { - spellCheckResponse: null, translationUnits: legacyCells, fullDocumentTranslationUnits, contentBeingUpdated: { @@ -613,7 +602,6 @@ describe("CellList - Footnote Offset Calculation", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -661,7 +649,6 @@ describe("CellList - Footnote Offset Calculation", () => { const fullDocumentTranslationUnits: QuillCellContent[] = cellsWithOnlyUuid; const props = { - spellCheckResponse: null, translationUnits: cellsWithOnlyUuid, fullDocumentTranslationUnits, contentBeingUpdated: { @@ -677,7 +664,6 @@ describe("CellList - Footnote Offset Calculation", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -709,7 +695,6 @@ describe("CellList - Footnote Offset Calculation", () => { const fullDocumentTranslationUnits: QuillCellContent[] = [...translationUnits]; const props = { - spellCheckResponse: null, translationUnits, fullDocumentTranslationUnits, contentBeingUpdated: { @@ -725,7 +710,6 @@ describe("CellList - Footnote Offset Calculation", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -780,7 +764,6 @@ describe("CellList - Footnote Offset Calculation", () => { }; const props = { - spellCheckResponse: null, translationUnits, fullDocumentTranslationUnits, contentBeingUpdated: { @@ -796,7 +779,6 @@ describe("CellList - Footnote Offset Calculation", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -851,7 +833,6 @@ describe("CellList - Footnote Offset Calculation", () => { }; const props = { - spellCheckResponse: null, translationUnits, fullDocumentTranslationUnits, contentBeingUpdated: { @@ -867,7 +848,6 @@ describe("CellList - Footnote Offset Calculation", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", diff --git a/webviews/codex-webviews/src/CodexCellEditor/__tests___/CodexCellEditor.saveWorkflow.integration.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/__tests___/CodexCellEditor.saveWorkflow.integration.test.tsx index afb4fccf5..4db6e66db 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/__tests___/CodexCellEditor.saveWorkflow.integration.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/__tests___/CodexCellEditor.saveWorkflow.integration.test.tsx @@ -224,7 +224,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { it("should render CellList with real translation units", async () => { const mockProps = { - spellCheckResponse: null, translationUnits: mockTranslationUnits, fullDocumentTranslationUnits: mockTranslationUnits, contentBeingUpdated: { @@ -240,7 +239,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -284,7 +282,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: mockTranslationUnits[0].editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-1"], cellContent: "

Test content

", @@ -344,7 +341,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: mockTranslationUnits[0].editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-1"], cellContent: "

Test content

", @@ -431,7 +427,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { it("should render CellList with multiple cells", async () => { const mockProps = { - spellCheckResponse: null, translationUnits: mockTranslationUnits, fullDocumentTranslationUnits: mockTranslationUnits, contentBeingUpdated: { @@ -447,7 +442,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -488,7 +482,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: mockTranslationUnits[0].editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-1"], cellContent: "

Test content

", @@ -542,7 +535,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { // Test that CellList and CellEditor can work together with proper data flow const cellListProps = { - spellCheckResponse: null, translationUnits: mockTranslationUnits, fullDocumentTranslationUnits: mockTranslationUnits, contentBeingUpdated: { @@ -558,7 +550,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { isSourceText: false, windowHeight: 800, headerHeight: 100, - alertColorCodes: {}, highlightedCellId: null, scrollSyncEnabled: true, currentUsername: "test-user", @@ -626,7 +617,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: mockTranslationUnits[0].editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-1"], cellContent: "

Test content

", @@ -699,7 +689,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: mockTranslationUnits[0].editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-1"], cellContent: "

Test content

", @@ -759,7 +748,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: mockTranslationUnits[0].editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-2"], cellContent: "

Other cell

", @@ -814,7 +802,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: mockTranslationUnits[0].editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-1"], cellContent: "

Test content

", @@ -879,7 +866,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: mockTranslationUnits[0].editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-1"], cellContent: "

Test content

", @@ -994,7 +980,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: lockedCell.editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-1"], cellContent: "

Test content

", @@ -1050,7 +1035,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: lockedCell.editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-1"], cellContent: "

Test content

", @@ -1121,7 +1105,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: mockTranslationUnits[0].editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-2"], cellContent: "

Other content

", @@ -1202,7 +1185,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: mockTranslationUnits[0].editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-no-audio"], cellContent: "

Content without audio

", @@ -1261,7 +1243,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: mockTranslationUnits[0].editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-empty"], cellContent: "

Empty audio cell

", @@ -1318,7 +1299,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: mockTranslationUnits[0].editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-transition"], cellContent: "

Transition test

", @@ -1392,7 +1372,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: mockTranslationUnits[0].editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-deleted"], cellContent: "

Deleted audio cell

", @@ -1453,7 +1432,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: mockTranslationUnits[0].editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-available"], cellContent: "

Cell with audio

", @@ -1525,7 +1503,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: mockTranslationUnits[0].editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-null-response"], cellContent: "

Null response test

", @@ -1589,7 +1566,6 @@ describe("Real Cell Editor Save Workflow Integration Tests", () => { editHistory: mockTranslationUnits[0].editHistory, cellIndex: 0, cellType: CodexCellTypes.TEXT, - spellCheckResponse: null, contentBeingUpdated: { cellMarkers: ["cell-cached"], cellContent: "

Cached audio cell

", diff --git a/webviews/codex-webviews/src/CodexCellEditor/__tests___/initialContentStaleGuard.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/__tests___/initialContentStaleGuard.test.tsx index 67c2f3af9..dcb8be6c9 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/__tests___/initialContentStaleGuard.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/__tests___/initialContentStaleGuard.test.tsx @@ -1,7 +1,7 @@ -import React, { useRef, useState } from "react"; +import React, { useRef } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, cleanup, act } from "@testing-library/react"; -import type { QuillCellContent, SpellCheckResponse, MilestoneIndex } from "../../../../../types"; +import type { QuillCellContent, MilestoneIndex } from "../../../../../types"; import { useVSCodeMessageHandler } from "../hooks/useVSCodeMessageHandler"; /** @@ -54,9 +54,6 @@ function StaleGuardHarness(props: { /** Called when setContentPaginated rejects a stale message */ onRejected: (milestoneIdx: number, subsectionIdx: number) => void; }) { - const [spell, setSpell] = useState(null); - void spell; - // Mirrors CodexCellEditor.tsx refs const currentMilestoneIndexRef = useRef(0); const currentSubsectionIndexRef = useRef(0); @@ -94,15 +91,12 @@ function StaleGuardHarness(props: { useVSCodeMessageHandler({ setContent: () => {}, - setSpellCheckResponse: setSpell, jumpToCell: () => {}, updateCell: () => {}, autocompleteChapterComplete: () => {}, updateTextDirection: () => {}, updateNotebookMetadata: () => {}, updateVideoUrl: () => {}, - setAlertColorCodes: () => {}, - recheckAlertCodes: () => {}, setAudioAttachments: () => {}, setContentPaginated, handleCellPage: () => {}, diff --git a/webviews/codex-webviews/src/CodexCellEditor/__tests___/useVSCodeMessageHandler.rev.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/__tests___/useVSCodeMessageHandler.rev.test.tsx index 3925c6729..88713d2ce 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/__tests___/useVSCodeMessageHandler.rev.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/__tests___/useVSCodeMessageHandler.rev.test.tsx @@ -1,7 +1,7 @@ -import React, { useState } from "react"; +import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, cleanup } from "@testing-library/react"; -import type { QuillCellContent, SpellCheckResponse, MilestoneIndex } from "../../../../../types"; +import type { QuillCellContent, MilestoneIndex } from "../../../../../types"; import { useVSCodeMessageHandler } from "../hooks/useVSCodeMessageHandler"; type HandlerArgs = Parameters[0]; @@ -30,20 +30,14 @@ function Harness(props: { onSetContentPaginated: HandlerArgs["setContentPaginated"]; onHandleCellPage: HandlerArgs["handleCellPage"]; }) { - const [spell, setSpell] = useState(null); - void spell; - useVSCodeMessageHandler({ setContent: () => {}, - setSpellCheckResponse: setSpell, jumpToCell: () => {}, updateCell: () => {}, autocompleteChapterComplete: () => {}, updateTextDirection: () => {}, updateNotebookMetadata: () => {}, updateVideoUrl: () => {}, - setAlertColorCodes: () => {}, - recheckAlertCodes: () => {}, setAudioAttachments: () => {}, setContentPaginated: props.onSetContentPaginated, handleCellPage: props.onHandleCellPage, diff --git a/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts b/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts index d55860f12..b329079bb 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts +++ b/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; import { Dispatch, SetStateAction } from "react"; -import { QuillCellContent, SpellCheckResponse, MilestoneIndex } from "../../../../../types"; +import { QuillCellContent, MilestoneIndex } from "../../../../../types"; import { CustomNotebookMetadata } from "../../../../../types"; interface UseVSCodeMessageHandlerProps { @@ -9,15 +9,12 @@ interface UseVSCodeMessageHandlerProps { isSourceText: boolean, sourceCellMap: { [k: string]: { content: string; versions: string[]; }; } ) => void; - setSpellCheckResponse: Dispatch>; jumpToCell: (cellId: string) => void; updateCell: (data: { cellId: string; newContent: string; progress: number; }) => void; autocompleteChapterComplete: () => void; updateTextDirection: (direction: "ltr" | "rtl") => void; updateNotebookMetadata: (metadata: CustomNotebookMetadata) => void; updateVideoUrl: (url: string) => void; - setAlertColorCodes: Dispatch>; - recheckAlertCodes: () => void; // New handlers for provider-centric state management updateAutocompletionState?: (state: { @@ -82,15 +79,12 @@ interface UseVSCodeMessageHandlerProps { export const useVSCodeMessageHandler = ({ setContent, - setSpellCheckResponse, jumpToCell, updateCell, autocompleteChapterComplete, updateTextDirection, updateNotebookMetadata, updateVideoUrl, - setAlertColorCodes, - recheckAlertCodes, // New handlers updateAutocompletionState, @@ -182,9 +176,6 @@ export const useVSCodeMessageHandler = ({ // Swallow errors deriving attachments } break; - case "providerSendsSpellCheckResponse": - setSpellCheckResponse(message.content); - break; case "jumpToSection": jumpToCell(message.content); break; @@ -203,12 +194,6 @@ export const useVSCodeMessageHandler = ({ case "updateVideoUrlInWebview": updateVideoUrl(message.content); break; - case "providerSendsgetAlertCodeResponse": - setAlertColorCodes(message.content); - break; - case "wordAdded": - recheckAlertCodes(); - break; case "providerAutocompletionState": if (updateAutocompletionState) { updateAutocompletionState(message.state); @@ -403,15 +388,12 @@ export const useVSCodeMessageHandler = ({ }; }, [ setContent, - setSpellCheckResponse, jumpToCell, updateCell, autocompleteChapterComplete, updateTextDirection, updateNotebookMetadata, updateVideoUrl, - setAlertColorCodes, - recheckAlertCodes, updateAutocompletionState, updateSingleCellTranslationState, updateSingleCellQueueState, diff --git a/webviews/codex-webviews/src/CodexCellEditor/index.tsx b/webviews/codex-webviews/src/CodexCellEditor/index.tsx index f58dd4a60..7ba19b064 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/index.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/index.tsx @@ -7,6 +7,12 @@ import UnsavedChangesContext from "./contextProviders/UnsavedChangesContext"; import SourceCellContext from "./contextProviders/SourceCellContext"; import ScrollToContentContext from "./contextProviders/ScrollToContentContext"; import { TooltipProvider } from "./contextProviders/TooltipContext"; +import { getVSCodeAPI } from "../shared/vscodeApi"; + +// Acquire the VS Code API once at module scope and expose it on `window` so that +// all child components (TextCellEditor, Editor, AddParatextButton, etc.) that +// reference `window.vscodeApi` can find it. +(window as any).vscodeApi = getVSCodeAPI(); const Index: React.FC = () => { const [unsavedChanges, setUnsavedChanges] = useState(false); @@ -75,6 +81,5 @@ ReactDOM.createRoot(document.getElementById("root")!).render(); // Send webviewReady message when the webview is mounted window.addEventListener("load", () => { - const vscode = (window as any).vscodeApi; - vscode.postMessage({ command: "webviewReady" }); + (window as any).vscodeApi.postMessage({ command: "webviewReady" }); }); diff --git a/webviews/codex-webviews/src/CodexCellEditor/old_header_implementation.txt b/webviews/codex-webviews/src/CodexCellEditor/old_header_implementation.txt deleted file mode 100644 index 905a47632..000000000 --- a/webviews/codex-webviews/src/CodexCellEditor/old_header_implementation.txt +++ /dev/null @@ -1,1538 +0,0 @@ -import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; -import { Button } from "../components/ui/button"; -import { VSCodeBadge } from "@vscode/webview-ui-toolkit/react"; -import { CELL_DISPLAY_MODES } from "./CodexCellEditor"; -import NotebookMetadataModal from "./NotebookMetadataModal"; -import { AutocompleteModal } from "./modals/AutocompleteModal"; -import { ChapterSelectorModal } from "./modals/ChapterSelectorModal"; -import { MobileHeaderMenu } from "./components/MobileHeaderMenu"; -import { - type QuillCellContent, - type CustomNotebookMetadata, - type EditorPostMessages, -} from "../../../../types"; -import { EditMapUtils } from "../../../../src/utils/editMapUtils"; -import { WebviewApi } from "vscode-webview"; -import { type FileStatus, type EditorPosition, type Subsection } from "../lib/types"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../components/ui/dropdown-menu"; -import { Slider } from "../components/ui/slider"; -import { Alert, AlertDescription } from "../components/ui/alert"; - - -interface ChapterNavigationHeaderProps { - chapterNumber: number; - setChapterNumber: React.Dispatch>; - unsavedChanges: boolean; - onAutocompleteChapter: ( - numberOfCells: number, - includeEmptyCells: boolean, - includeNotValidatedByAnyUser: boolean, - includeNotValidatedByCurrentUser: boolean, - includeFullyValidatedByOthers: boolean - ) => void; - onStopAutocomplete: () => void; - isAutocompletingChapter: boolean; - onSetTextDirection: (direction: "ltr" | "rtl") => void; - textDirection: "ltr" | "rtl"; - onSetCellDisplayMode: (mode: CELL_DISPLAY_MODES) => void; - cellDisplayMode: CELL_DISPLAY_MODES; - isSourceText: boolean; - totalChapters: number; - totalUntranslatedCells: number; - totalCellsToAutocomplete: number; - totalCellsWithCurrentUserOption: number; - totalFullyValidatedCells: number; - openSourceText: (chapterNumber: number) => void; - shouldShowVideoPlayer: boolean; - setShouldShowVideoPlayer: React.Dispatch>; - documentHasVideoAvailable: boolean; - metadata: CustomNotebookMetadata | undefined; - onMetadataChange: (key: string, value: string) => void; - onSaveMetadata: () => void; - onPickFile: () => void; - onUpdateVideoUrl: (url: string) => void; - tempVideoUrl: string; - toggleScrollSync: () => void; - scrollSyncEnabled: boolean; - translationUnitsForSection: QuillCellContent[]; - isTranslatingCell?: boolean; - onStopSingleCellTranslation?: () => void; - currentSubsectionIndex: number; - setCurrentSubsectionIndex: React.Dispatch>; - getSubsectionsForChapter: (chapterNum: number) => Subsection[]; - bibleBookMap?: Map; - vscode: any; - fileStatus?: FileStatus; - editorPosition: EditorPosition; - onClose?: () => void; - onTriggerSync?: () => void; - isCorrectionEditorMode?: boolean; - chapterProgress?: Record< - number, - { percentTranslationsCompleted: number; percentFullyValidatedTranslations: number } - >; - allCellsForChapter?: QuillCellContent[]; - onTempFontSizeChange?: (fontSize: number) => void; - onFontSizeSave?: (fontSize: number) => void; -} - - -export function ChapterNavigationHeader({ - chapterNumber, - setChapterNumber, - unsavedChanges, - onAutocompleteChapter, - onStopAutocomplete, - isAutocompletingChapter, - onSetTextDirection, - textDirection, - onSetCellDisplayMode, - cellDisplayMode, - isSourceText, - totalChapters, - totalUntranslatedCells, - totalCellsToAutocomplete, - totalCellsWithCurrentUserOption, - totalFullyValidatedCells, - openSourceText, - shouldShowVideoPlayer, - setShouldShowVideoPlayer, - documentHasVideoAvailable, - metadata, - onMetadataChange, - onSaveMetadata, - onPickFile, - onUpdateVideoUrl, - tempVideoUrl, - toggleScrollSync, - scrollSyncEnabled, - translationUnitsForSection, - isTranslatingCell = false, - onStopSingleCellTranslation, - currentSubsectionIndex, - setCurrentSubsectionIndex, - getSubsectionsForChapter, - bibleBookMap, - vscode, - fileStatus = "none", - editorPosition, - onClose, - onTriggerSync, - isCorrectionEditorMode, - chapterProgress, - allCellsForChapter, - onTempFontSizeChange, - onFontSizeSave, -}: // Removed onToggleCorrectionEditor since it will be a VS Code command now -ChapterNavigationHeaderProps) { - const [showConfirm, setShowConfirm] = useState(false); - const [isMetadataModalOpen, setIsMetadataModalOpen] = useState(false); - const [showChapterSelector, setShowChapterSelector] = useState(false); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const chapterTitleRef = useRef(null); - const [truncatedBookName, setTruncatedBookName] = useState(null); - const [showUnsavedWarning, setShowUnsavedWarning] = useState(false); - - - // Responsive breakpoint state with hysteresis to prevent flickering - const [isMobileLayout, setIsMobileLayout] = useState(window.innerWidth < 640); - const [isVerySmallLayout, setIsVerySmallLayout] = useState(window.innerWidth < 400); - - - // Font size state - default to 14 if not set in metadata - const [fontSize, setFontSize] = useState(metadata?.fontSize || 14); - const [pendingFontSize, setPendingFontSize] = useState(null); - - - // Get subsections early so it's available for all hooks - const subsections = getSubsectionsForChapter(chapterNumber); - - - // Update font size when metadata changes - useEffect(() => { - if (metadata?.fontSize !== undefined) { - setFontSize(metadata.fontSize); - setPendingFontSize(null); // Clear any pending changes - } - }, [metadata?.fontSize]); - - - // Determine the display name using the map - const getDisplayTitle = useCallback(() => { - const firstMarker = translationUnitsForSection[0]?.cellMarkers?.[0]?.split(":")[0]; // e.g., "GEN 1" - if (!firstMarker) return "Chapter"; // Fallback title - - - const parts = firstMarker.split(" "); - const bookAbbr = parts[0]; // e.g., "GEN" - const chapterNum = parts[1] || ""; // e.g., "1" - - - // Look up the localized name - const localizedName = bibleBookMap?.get(bookAbbr)?.name; - - - // Use localized name if found, otherwise use the abbreviation - const displayBookName = localizedName || bookAbbr; - - - return `${displayBookName}\u00A0${chapterNum}`; - }, [translationUnitsForSection, bibleBookMap]); - - - // Debounced resize handler to prevent excessive re-renders - const debouncedResizeHandler = useMemo(() => { - let timeoutId: NodeJS.Timeout; - return (callback: () => void) => { - clearTimeout(timeoutId); - timeoutId = setTimeout(() => { - requestAnimationFrame(callback); - }, 150); - }; - }, []); - - - // Responsive breakpoint management with hysteresis - useEffect(() => { - const handleBreakpointResize = () => { - const width = window.innerWidth; - - - // Mobile layout breakpoint with 5px buffer zone - if (!isMobileLayout && width < 635) { - setIsMobileLayout(true); - } else if (isMobileLayout && width > 645) { - setIsMobileLayout(false); - } - - - // Very small layout breakpoint with 5px buffer zone - if (!isVerySmallLayout && width < 395) { - setIsVerySmallLayout(true); - } else if (isVerySmallLayout && width > 405) { - setIsVerySmallLayout(false); - } - }; - - - const debouncedBreakpointHandler = () => { - debouncedResizeHandler(handleBreakpointResize); - }; - - - window.addEventListener('resize', debouncedBreakpointHandler); - return () => window.removeEventListener('resize', debouncedBreakpointHandler); - }, [isMobileLayout, isVerySmallLayout, debouncedResizeHandler]); - - - // Dynamic title truncation based on available space - now universal (not screen-size dependent) - useEffect(() => { - const handleTitleResize = () => { - const container = chapterTitleRef.current; - if (!container) return; - - - const fullTitle = getDisplayTitle(); - const lastSpaceIndex = Math.max( - fullTitle.lastIndexOf('\u00A0'), - fullTitle.lastIndexOf(' ') - ); - const bookName = lastSpaceIndex > 0 ? fullTitle.substring(0, lastSpaceIndex) : fullTitle; - const chapterNum = lastSpaceIndex > 0 ? fullTitle.substring(lastSpaceIndex + 1) : ""; - - - // Use requestAnimationFrame to ensure DOM has updated - requestAnimationFrame(() => { - const containerRect = container.getBoundingClientRect(); - const parentRect = container.parentElement?.getBoundingClientRect(); - - - if (!parentRect) return; - - - // Calculate available width based on parent (not the title itself to avoid feedback) - // Use responsive states instead of direct window.innerWidth checks - const availableWidth = Math.min( - parentRect.width, - isVerySmallLayout ? window.innerWidth * 0.5 : // On very small screens, limit to 50% - isMobileLayout ? window.innerWidth * 0.6 : // On small screens, limit to 60% - window.innerWidth * 0.4 // On larger screens, limit to 40% - ); - - - // Create temporary element to measure text width including subsection label - const temp = document.createElement('span'); - temp.style.visibility = 'hidden'; - temp.style.position = 'absolute'; - temp.style.fontSize = window.getComputedStyle(container.querySelector('h1') || container).fontSize; - temp.style.fontFamily = window.getComputedStyle(container.querySelector('h1') || container).fontFamily; - - - // Account for subsection label like "(1-50)" only when it should be visible - const subsectionLabel = - !isMobileLayout && // Use responsive state instead of window.innerWidth >= 500 - subsections.length > 0 && - subsections[currentSubsectionIndex]?.label - ? ` (${subsections[currentSubsectionIndex].label})` - : ""; - temp.textContent = fullTitle + subsectionLabel; - document.body.appendChild(temp); - - - const fullWidth = temp.getBoundingClientRect().width; - document.body.removeChild(temp); - - - // If text is too wide, truncate the book name (now universal, not screen-size dependent) - if (fullWidth > availableWidth && bookName.length > 3) { - // Calculate how many characters we can fit - const totalTextLength = fullTitle.length + subsectionLabel.length; - const avgCharWidth = fullWidth / totalTextLength; - const chapterNumWidth = chapterNum.length * avgCharWidth; - const subsectionLabelWidth = subsectionLabel.length * avgCharWidth; - const ellipsisWidth = 3 * avgCharWidth; // "..." width - const availableForBookName = availableWidth - chapterNumWidth - subsectionLabelWidth - ellipsisWidth; - const maxBookNameChars = Math.floor(availableForBookName / avgCharWidth); - - - if (maxBookNameChars > 0) { - const truncated = bookName.substring(0, Math.max(1, maxBookNameChars - 1)); - setTruncatedBookName((prev) => (prev !== truncated ? truncated : prev)); - } - } else { - // Ensure we clear truncation only when needed to avoid extra renders - setTruncatedBookName((prev) => (prev !== null ? null : prev)); - } - }); - }; - - - const debouncedTitleHandler = () => { - debouncedResizeHandler(handleTitleResize); - }; - - - // Initial calculation - handleTitleResize(); - - - // Add resize observer for container changes - let resizeObserver: ResizeObserver | null = null; - if (window.ResizeObserver && chapterTitleRef.current) { - resizeObserver = new ResizeObserver(() => { - debouncedResizeHandler(handleTitleResize); - }); - resizeObserver.observe(chapterTitleRef.current); - } - - - // Add window resize listener as fallback - window.addEventListener('resize', debouncedTitleHandler); - - - return () => { - if (resizeObserver) { - resizeObserver.disconnect(); - } - window.removeEventListener('resize', debouncedTitleHandler); - }; - }, [getDisplayTitle, translationUnitsForSection, subsections, currentSubsectionIndex, isMobileLayout, isVerySmallLayout, debouncedResizeHandler]); - - - - - // Helper to determine if any translation is in progress - const isAnyTranslationInProgress = isAutocompletingChapter || isTranslatingCell; - - - // Common handler for stopping any kind of translation - const handleStopTranslation = () => { - if (isAutocompletingChapter) { - onStopAutocomplete(); - } else if (isTranslatingCell && onStopSingleCellTranslation) { - onStopSingleCellTranslation(); - } - }; - - - const handleAutocompleteClick = () => { - setShowConfirm(true); - }; - - - const handleConfirmAutocomplete = ( - numberOfCells: number, - includeEmptyCells: boolean, - includeNotValidatedByAnyUser: boolean, - includeNotValidatedByCurrentUser: boolean, - includeFullyValidatedByOthers = false - ) => { - onAutocompleteChapter( - numberOfCells, - includeEmptyCells, - includeNotValidatedByAnyUser, - includeNotValidatedByCurrentUser, - includeFullyValidatedByOthers - ); - setShowConfirm(false); - }; - - - const handleToggleVideoPlayer = () => { - setShouldShowVideoPlayer(!shouldShowVideoPlayer); - }; - - - const handleOpenMetadataModal = () => { - setIsMetadataModalOpen(true); - }; - - - const handleCloseMetadataModal = () => { - setIsMetadataModalOpen(false); - }; - - - const handleSaveMetadata = () => { - onSaveMetadata(); - setIsMetadataModalOpen(false); - if (metadata?.videoUrl) { - onUpdateVideoUrl(metadata.videoUrl); - } - }; - - - const handleFontSizeChange = (value: number[]) => { - const newFontSize = value[0]; - setFontSize(newFontSize); - setPendingFontSize(newFontSize); - - - // Update temporary font size for preview - if (onTempFontSizeChange) { - onTempFontSizeChange(newFontSize); - } - }; - - - const handleDropdownOpenChange = (open: boolean) => { - setIsDropdownOpen(open); - - - // If dropdown is closing and we have pending font size changes, save them - if (!open && pendingFontSize !== null) { - // Save the font size using the new handler - if (onFontSizeSave) { - onFontSizeSave(pendingFontSize); - } else { - // Fallback to old method if handler not provided - onMetadataChange("fontSize", pendingFontSize.toString()); - const updatedMetadata = { ...metadata, fontSize: pendingFontSize }; - (window as any).vscodeApi.postMessage({ - command: "updateNotebookMetadata", - content: updatedMetadata, - }); - } - - - setPendingFontSize(null); - } - }; - - - - - const handleTogglePrimarySidebar = () => { - if (vscode) { - vscode.postMessage({ command: "togglePrimarySidebar" }); - } - }; - - - const handleToggleSecondarySidebar = () => { - if (vscode) { - vscode.postMessage({ command: "toggleSecondarySidebar" }); - } - }; - - - // Function to get file status icon and color - const getFileStatusButton = () => { - if (fileStatus === "none") return null; - - - let icon: string; - let color: string; - let title: string; - let clickHandler: (() => void) | undefined = undefined; - - - switch (fileStatus) { - case "dirty": - icon = "codicon-cloud"; - color = "var(--vscode-editorWarning-foreground)"; // Yellow warning color - title = "Unsaved changes - Click to sync"; - clickHandler = onTriggerSync; - break; - case "syncing": - icon = "codicon-sync"; - color = "var(--vscode-descriptionForeground)"; // Gray for syncing - title = "Syncing changes"; - break; - case "synced": - icon = "codicon-check-all"; - color = "var(--vscode-terminal-ansiGreen)"; // Green for synced - title = "All changes saved"; - break; - default: - return null; - } - - - return ( - - ); - }; - - - // Add CSS for rotation animation - useEffect(() => { - if (!document.getElementById("codex-animation-styles")) { - const styleElement = document.createElement("style"); - styleElement.id = "codex-animation-styles"; - styleElement.textContent = ` - @keyframes rotate { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } - } - `; - document.head.appendChild(styleElement); - } - }, []); - - - // Update the jumpToChapter function to be reusable - const jumpToChapter = (newChapter: number) => { - if (!unsavedChanges && newChapter !== chapterNumber) { - (window as any).vscodeApi.postMessage({ - command: "jumpToChapter", - chapterNumber: newChapter, - }); - setChapterNumber(newChapter); - // Reset to first page when jumping to a different chapter through chapter selector - setCurrentSubsectionIndex(0); - } - }; - - - // Navigation functions for mobile menu - const handlePreviousChapter = () => { - if (!unsavedChanges) { - const newChapter = chapterNumber === 1 ? totalChapters : chapterNumber - 1; - jumpToChapter(newChapter); - } - }; - - - const handleNextChapter = () => { - if (!unsavedChanges) { - const newChapter = chapterNumber === totalChapters ? 1 : chapterNumber + 1; - jumpToChapter(newChapter); - } - }; - - - // Use responsive state variables instead of direct window.innerWidth checks - const shouldUseMobileLayout = isMobileLayout; - const shouldUseThreeRowLayout = isVerySmallLayout; - - - // Calculate progress for each subsection/page - const calculateSubsectionProgress = (subsection: Subsection) => { - // Use allCellsForChapter if available, otherwise fall back to translationUnitsForSection - const allChapterCells = allCellsForChapter || translationUnitsForSection; - - - // Get cells for this specific subsection from the full chapter data - const subsectionCells = allChapterCells.slice(subsection.startIndex, subsection.endIndex); - - - // Filter out paratext and merged cells for progress calculation - const validCells = subsectionCells.filter((cell) => { - const cellId = cell?.cellMarkers?.[0]; - return cellId && !cellId.startsWith("paratext-") && !cell.merged; - }); - - - if (validCells.length === 0) { - return { isFullyTranslated: false, isFullyValidated: false }; - } - - - // Check if all cells have content (translated) - const translatedCells = validCells.filter( - (cell) => - cell.cellContent && - cell.cellContent.trim().length > 0 && - cell.cellContent !== "" - ); - const isFullyTranslated = translatedCells.length === validCells.length; - - - // Check if all cells are validated - let isFullyValidated = false; - if (isFullyTranslated) { - const minimumValidationsRequired = 1; // Can be made configurable later - const validatedCells = validCells.filter((cell) => { - const validatedBy = - cell.editHistory - ?.slice() - .reverse() - .find( - (edit) => - EditMapUtils.isValue(edit.editMap) && - edit.value === cell.cellContent - )?.validatedBy || []; - return validatedBy.filter((v) => !v.isDeleted).length >= minimumValidationsRequired; - }); - isFullyValidated = validatedCells.length === validCells.length; - } - - - return { isFullyTranslated, isFullyValidated }; - }; - - - return ( -
- {/* Mobile hamburger menu - shows when layout is mobile */} -
- -
- - - {/* Desktop left controls */} -
- {isSourceText ? ( - <> - - - - {isCorrectionEditorMode ? ( - - Source Editing Mode - - ) : ( - Source Text - )} - - ) : ( - - )} -
- - - {/* Center navigation - flex centered */} -
- {/* Navigation arrows - hidden on very small screens */} - - - -
{ - // Always allow opening the chapter selector when there are no unsaved changes - if (!unsavedChanges) { - setShowChapterSelector(!showChapterSelector); - } else { - // Show warning when there are unsaved changes - setShowUnsavedWarning(true); - setTimeout(() => setShowUnsavedWarning(false), 3000); - } - }} - > -

- - {(() => { - if (truncatedBookName !== null) { - return truncatedBookName + "..."; - } - const fullTitle = getDisplayTitle(); - const lastSpaceIndex = Math.max( - fullTitle.lastIndexOf("\u00A0"), - fullTitle.lastIndexOf(" ") - ); - return lastSpaceIndex > 0 - ? fullTitle.substring(0, lastSpaceIndex) - : fullTitle; - })()} - - - {(() => { - const fullTitle = getDisplayTitle(); - const lastSpaceIndex = Math.max( - fullTitle.lastIndexOf("\u00A0"), - fullTitle.lastIndexOf(" ") - ); - return lastSpaceIndex > 0 - ? fullTitle.substring(lastSpaceIndex + 1) - : ""; - })()} - - {/* Show page info - hide on mobile to prevent collisions */} - {subsections.length > 0 && !shouldUseMobileLayout && ( - - ({subsections[currentSubsectionIndex]?.label || ""}) - - )} - -

-
- - - - - - {/* Page selector - shown on desktop layouts */} - {subsections.length > 0 && !shouldUseMobileLayout && ( -
- Page: - - - - - - {subsections.map((section, index) => { - const progress = calculateSubsectionProgress(section); - return ( - setCurrentSubsectionIndex(index)} - className="flex items-center justify-between cursor-pointer" - > - {section.label} -
- {progress.isFullyValidated && ( -
- )} - {currentSubsectionIndex === index && ( - - )} - {!progress.isFullyValidated && - progress.isFullyTranslated && ( -
- )} -
- - ); - })} - - -
- )} -
- - - {/* Desktop right controls */} -
- {/* {getFileStatusButton()} // FIXME: we want to show the file status, but it needs to load immediately, and it needs to be more reliable. - test this and also think through UX */} - {/* Show left sidebar toggle only when editor is not leftmost - - // FIXME: editorPosition is always 'unknown' - this is not the right way to check this - - */} - {/* - {(editorPosition === "rightmost" || - editorPosition === "center" || - editorPosition === "single") && ( - - )} - {/* Show right sidebar toggle only when editor is not rightmost */} - {/* {(editorPosition === "leftmost" || - editorPosition === "center" || - editorPosition === "single") && ( - - )} */} - {!isSourceText && ( - <> - {isAnyTranslationInProgress ? ( - - ) : ( - - )} - - )} - - - - - - - onSetTextDirection(textDirection === "ltr" ? "rtl" : "ltr") - } - disabled={unsavedChanges} - className="cursor-pointer" - > - - Text Direction ({textDirection.toUpperCase()}) - - - - { - const newMode = - cellDisplayMode === CELL_DISPLAY_MODES.INLINE - ? CELL_DISPLAY_MODES.ONE_LINE_PER_CELL - : CELL_DISPLAY_MODES.INLINE; - onSetCellDisplayMode(newMode); - (window as any).vscodeApi.postMessage({ - command: "updateCellDisplayMode", - mode: newMode, - }); - }} - disabled={unsavedChanges} - className="cursor-pointer" - > - - - Display Mode ( - {cellDisplayMode === CELL_DISPLAY_MODES.INLINE - ? "Inline" - : "One Line"} - ) - - - - - - { - const currentValue = metadata?.lineNumbersEnabled ?? true; - const newValue = !currentValue; - onMetadataChange("lineNumbersEnabled", newValue.toString()); - - - // Immediately save the metadata change - const updatedMetadata = { - ...metadata, - lineNumbersEnabled: newValue, - lineNumbersEnabledSource: "local" as const, - }; - vscode.postMessage({ - command: "updateNotebookMetadata", - content: updatedMetadata, - }); - }} - className="cursor-pointer" - > - - - {metadata?.lineNumbersEnabled ?? true - ? "Hide Line Numbers" - : "Show Line Numbers"} - - - - - {documentHasVideoAvailable && ( - <> - - - - - {shouldShowVideoPlayer ? "Hide Video" : "Show Video"} - - - - )} - - - {metadata && ( - <> - - - - Edit Metadata - - - )} - -
-
- {fontSize}px -
-
- A -
- -
- A -
-
-
-
-
- - - {/* Warning alert for unsaved changes */} - {showUnsavedWarning && ( -
- - - - Please close the editor or save your changes before navigating away from this section. - - -
- )} - - - {metadata && ( - - )} - - - setShowConfirm(false)} - onConfirm={handleConfirmAutocomplete} - totalUntranslatedCells={totalUntranslatedCells} - totalCellsToAutocomplete={totalCellsToAutocomplete} - totalCellsWithCurrentUserOption={totalCellsWithCurrentUserOption} - totalFullyValidatedByOthers={totalFullyValidatedCells} - defaultValue={Math.min(5, totalUntranslatedCells > 0 ? totalUntranslatedCells : 5)} - /> - - - setShowChapterSelector(false)} - onSelectChapter={jumpToChapter} - currentChapter={chapterNumber} - totalChapters={totalChapters} - bookTitle={getDisplayTitle().split("\u00A0")[0]} - unsavedChanges={unsavedChanges} - anchorRef={chapterTitleRef} - chapterProgress={chapterProgress} - /> -
- ); -} - - - -//FROM HERE ON IS WHAT USED TO BE THE MOBILE HEADER FILE - - -"use client"; - - -import React from "react"; -import { Button } from "../../components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../../components/ui/dropdown-menu"; -import { Slider } from "../../components/ui/slider"; -import { CELL_DISPLAY_MODES } from "../CodexCellEditor"; -import { type CustomNotebookMetadata } from "../../../../../types"; -import { type Subsection } from "../../lib/types"; - - -interface MobileHeaderMenuProps { - // Translation controls - isAutocompletingChapter: boolean; - isTranslatingCell: boolean; - onAutocompleteClick: () => void; - onStopTranslation: () => void; - unsavedChanges: boolean; - isSourceText: boolean; - - // Settings - textDirection: "ltr" | "rtl"; - onSetTextDirection: (direction: "ltr" | "rtl") => void; - cellDisplayMode: CELL_DISPLAY_MODES; - onSetCellDisplayMode: (mode: CELL_DISPLAY_MODES) => void; - - // Font size - fontSize: number; - onFontSizeChange: (value: number[]) => void; - - // Metadata and video - metadata: CustomNotebookMetadata | undefined; - onMetadataChange: (key: string, value: string) => void; - documentHasVideoAvailable: boolean; - shouldShowVideoPlayer: boolean; - onToggleVideoPlayer: () => void; - onOpenMetadataModal: () => void; - - // Page/subsection navigation - subsections: Subsection[]; - currentSubsectionIndex: number; - setCurrentSubsectionIndex: React.Dispatch>; - - // Chapter navigation (for ultra-small screens) - chapterNumber: number; - totalChapters: number; - jumpToChapter?: (chapterNumber: number) => void; - onPreviousChapter?: () => void; - onNextChapter?: () => void; - getDisplayTitle: () => string; - - // VS Code integration - vscode: any; -} - - -export function MobileHeaderMenu({ - isAutocompletingChapter, - isTranslatingCell, - onAutocompleteClick, - onStopTranslation, - unsavedChanges, - isSourceText, - textDirection, - onSetTextDirection, - cellDisplayMode, - onSetCellDisplayMode, - fontSize, - onFontSizeChange, - metadata, - onMetadataChange, - documentHasVideoAvailable, - shouldShowVideoPlayer, - onToggleVideoPlayer, - onOpenMetadataModal, - subsections, - currentSubsectionIndex, - setCurrentSubsectionIndex, - chapterNumber, - totalChapters, - jumpToChapter, - onPreviousChapter, - onNextChapter, - getDisplayTitle, - vscode, -}: MobileHeaderMenuProps) { - const isAnyTranslationInProgress = isAutocompletingChapter || isTranslatingCell; - - - return ( - - - - - - {/* Chapter Navigation */} -
- Chapter: {getDisplayTitle()} -
- {onPreviousChapter && ( - - - Previous Chapter - - )} - {onNextChapter && ( - - - Next Chapter - - )} - - - - {/* Translation Controls */} - {!isSourceText && ( - <> - {isAnyTranslationInProgress ? ( - - - - {isAutocompletingChapter ? "Stop Autocomplete" : "Stop Translation"} - - - ) : ( - - - Autocomplete Chapter - - )} - - - )} - - - {/* Text Direction */} - onSetTextDirection(textDirection === "ltr" ? "rtl" : "ltr")} - disabled={unsavedChanges} - className="cursor-pointer" - > - - Text Direction ({textDirection.toUpperCase()}) - - - - {/* Display Mode */} - { - const newMode = - cellDisplayMode === CELL_DISPLAY_MODES.INLINE - ? CELL_DISPLAY_MODES.ONE_LINE_PER_CELL - : CELL_DISPLAY_MODES.INLINE; - onSetCellDisplayMode(newMode); - (window as any).vscodeApi.postMessage({ - command: "updateCellDisplayMode", - mode: newMode, - }); - }} - disabled={unsavedChanges} - className="cursor-pointer" - > - - - Display Mode ({cellDisplayMode === CELL_DISPLAY_MODES.INLINE ? "Inline" : "One Line"}) - - - - - - - - {/* Page Selector - only show on mobile when pages exist */} - {subsections.length > 0 && ( - <> -
- Current Page: {subsections[currentSubsectionIndex]?.label || ""} -
- {subsections.map((section, index) => ( - setCurrentSubsectionIndex(index)} - className={`cursor-pointer ${currentSubsectionIndex === index ? 'bg-accent' : ''}`} - > - - Go to {section.label} - {currentSubsectionIndex === index && ( - - )} - - ))} - - - )} - - - {/* Line Numbers */} - { - const currentValue = metadata?.lineNumbersEnabled ?? true; - const newValue = !currentValue; - onMetadataChange("lineNumbersEnabled", newValue.toString()); - - - // Immediately save the metadata change - const updatedMetadata = { - ...metadata, - lineNumbersEnabled: newValue, - lineNumbersEnabledSource: "local" as const, - }; - vscode.postMessage({ - command: "updateNotebookMetadata", - content: updatedMetadata, - }); - }} - className="cursor-pointer" - > - - - {metadata?.lineNumbersEnabled ?? true ? "Hide Line Numbers" : "Show Line Numbers"} - - - - - {/* Video Player */} - {documentHasVideoAvailable && ( - <> - - - - {shouldShowVideoPlayer ? "Hide Video" : "Show Video"} - - - )} - - - {/* Metadata Editor */} - {metadata && ( - <> - - - - Edit Metadata - - - )} - - - {/* Font Size Slider */} - -
-
- Font Size - {fontSize}px -
-
- A -
- -
- A -
-
-
-
- ); -} - diff --git a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/LoadingIndicator.ts b/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/LoadingIndicator.ts deleted file mode 100644 index d7b9a9936..000000000 --- a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/LoadingIndicator.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { QuillSpellChecker } from "."; - -/** - * Manager for the loading indicator. - * - * This handles showing and hiding the loading indicator in the editor. - */ -export default class LoadingIndicator { - private currentLoader?: HTMLElement; - - constructor(private readonly parent: QuillSpellChecker) {} - - public startLoading() { - this.currentLoader?.remove(); - - if (this.parent.params.showLoadingIndicator) { - const loadingIndicator = this.createLoadingIndicator(); - this.currentLoader = loadingIndicator; - this.parent.quill.root.parentElement?.appendChild(loadingIndicator); - } - } - - public stopLoading() { - this.currentLoader?.remove(); - } - - private createLoadingIndicator(): HTMLElement { - const loadingIndicator = document.createElement("div"); - loadingIndicator.className = "quill-spck-loading-indicator"; - - const spinner = document.createElement("div"); - spinner.className = "quill-spck-loading-indicator-spinner"; - - loadingIndicator.appendChild(spinner); - return loadingIndicator; - } -} diff --git a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/PopupManager.ts b/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/PopupManager.ts deleted file mode 100644 index 7c5f75851..000000000 --- a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/PopupManager.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { createPopper } from "@popperjs/core"; -import { QuillSpellChecker } from "."; -import { MatchesEntity } from "./types"; -import { EditorPostMessages } from "../../../../../types"; - -/** - * Manager for popups. - * - * Handles opening and closing suggestion popups in the editor - * when a suggestion is selected. - */ -export default class PopupManager { - private openPopup?: HTMLElement; - private currentSuggestionElement?: HTMLElement; - private eventListenerAdded = false; - private activeButtons: HTMLElement[] = []; // Track active buttons for cleanup - - constructor(private readonly parent: QuillSpellChecker) { - this.closePopup = this.closePopup.bind(this); - } - - public initialize() { - if (!this.eventListenerAdded && this.parent.quill?.root) { - this.addEventHandler(); - this.eventListenerAdded = true; - } - } - - public dispose() { - // Clean up global event listeners - if (this.eventListenerAdded && this.parent.quill?.root) { - const root = this.findRoot(this.parent.quill.root); - root.removeEventListener("click", this.handleClick); - window.removeEventListener("resize", this.handleResize); - this.eventListenerAdded = false; - } - - // Clean up any open popup - this.closePopup(); - - // Clean up any remaining button listeners - this.activeButtons.forEach((button) => { - button.replaceWith(button.cloneNode(true)); - }); - this.activeButtons = []; - } - - private addEventHandler() { - const root = this.findRoot(this.parent.quill.root); - root.addEventListener("click", this.handleClick); - window.addEventListener("resize", this.handleResize); - } - - private handleClick = (e: MouseEvent) => { - const target = e.target as HTMLElement; - if (target.tagName === "QUILL-SPCK-MATCH") { - this.handleSuggestionClick(target); - } else if (this.openPopup && !this.openPopup.contains(target)) { - this.closePopup(); - } - }; - - private handleResize = () => { - if (this.currentSuggestionElement) { - this.handleSuggestionClick(this.currentSuggestionElement); - } - }; - - private closePopup() { - if (this.openPopup) { - // Clean up button event listeners before removing popup - const buttons = this.openPopup.querySelectorAll("button"); - buttons.forEach((button) => { - button.replaceWith(button.cloneNode(true)); - }); - - this.openPopup.remove(); - this.openPopup = undefined; - } - this.currentSuggestionElement = undefined; - this.activeButtons = []; // Clear the active buttons array - } - - private handleSuggestionClick(suggestion: HTMLElement) { - const offset = parseInt(suggestion.getAttribute("data-offset") || "0"); - const length = parseInt(suggestion.getAttribute("data-length") || "0"); - const id = suggestion?.id?.replace("match-", ""); - const rule = this.parent.matches.find( - (r) => r.offset === offset && r.length === length && r.id === id - ); - if (rule) { - this.createSuggestionPopup(rule, suggestion); - } - } - - private createSuggestionPopup(match: MatchesEntity, suggestion: HTMLElement) { - this.closePopup(); - this.currentSuggestionElement = suggestion; - - const popup = document.createElement("quill-spck-popup"); - popup.setAttribute("role", "tooltip"); - - const popupContent = document.createElement("div"); - popupContent.className = "quill-spck-match-popup"; - - // Create a scrollable container for suggestion buttons - const suggestionsDiv = document.createElement("div"); - suggestionsDiv.className = "quill-spck-match-popup-suggestions"; - - // Add up to 5 replacement suggestions into the scrollable list - match.replacements?.slice(0, 5).forEach((replacement, index) => { - const button = this.createActionButton( - this.formatReplacementLabel(replacement), - () => this.applySuggestion(match, replacement.value, index) - ); - suggestionsDiv.appendChild(button); - this.activeButtons.push(button); - }); - - // Create a footer for dictionary and reject actions - const footerDiv = document.createElement("div"); - footerDiv.className = "quill-spck-match-popup-footer"; - - // Add "Add to dictionary" button only if it's a dictionary error - if (match.color !== "purple" && match.color !== "blue") { - const addToDictionaryButton = this.createActionButton( - `${match.text} → 📖`, - () => this.addWordToDictionary(match.text) - ); - footerDiv.appendChild(addToDictionaryButton); - this.activeButtons.push(addToDictionaryButton); - } - - // Add reject button for LLM (purple) and ICE (blue) suggestions - if (match.color === "purple" || match.color === "blue") { - const rejectButton = document.createElement("button"); - rejectButton.className = "quill-spck-match-popup-action reject-action"; - rejectButton.innerHTML = ''; - rejectButton.title = "Reject this suggestion"; - rejectButton.addEventListener("click", () => { - this.rejectSuggestion({ match, suggestion }); - this.closePopup(); - }); - footerDiv.appendChild(rejectButton); - this.activeButtons.push(rejectButton); - } - - // Append the suggestions list and the footer - popupContent.appendChild(suggestionsDiv); - popupContent.appendChild(footerDiv); - - // Add source and confidence information - const reasonLabel = document.createElement("div"); - reasonLabel.className = "quill-spck-match-popup-reason"; - - if (match.color === "purple") { - reasonLabel.innerHTML = - ' AI suggestion based on similar texts'; - } else if (match.color === "blue") { - const firstReplacement = match.replacements?.[0]; - const confidence = firstReplacement?.confidence || "low"; - const frequency = firstReplacement?.frequency || 1; - reasonLabel.innerHTML = ` From your previous edits (${confidence} confidence) ${frequency}×`; - } - - popupContent.appendChild(reasonLabel); - - popup.appendChild(popupContent); - document.body.appendChild(popup); - - createPopper(suggestion, popup, { - placement: "top", - modifiers: [{ name: "offset", options: { offset: [0, 0] } }], - }); - - this.openPopup = popup; - } - - private formatReplacementLabel( - replacement: NonNullable[number] - ): string { - if (!replacement) return ""; - - let label = replacement.value; - - // Add confidence indicator for ICE suggestions - if (replacement.source === "ice") { - if (replacement.confidence === "high") { - label += " ✓✓"; // Double check for high confidence - } else { - label += " ✓"; // Single check for low confidence - } - } - - return label; - } - - private createActionButton(label: string, onClick: () => void): HTMLElement { - const button = document.createElement("button"); - button.className = "quill-spck-match-popup-action"; - button.textContent = label; - button.addEventListener("click", () => { - onClick(); - this.hideDiagnostic(); - }); - return button; - } - - private applySuggestion(match: MatchesEntity, replacement: string, index: number) { - this.parent.acceptMatch(match.id, index); - this.closePopup(); - } - - private addWordToDictionary(word: string) { - console.log(`Attempting to add word to dictionary: ${word}`); - window.vscodeApi?.postMessage({ - command: "addWord", - words: [word], - }); - this.closePopup(); - console.log(`Requested to add word: ${word}`); - } - - private hideDiagnostic() { - if (this.currentSuggestionElement) { - this.currentSuggestionElement.style.textDecoration = "none"; - this.currentSuggestionElement.style.borderBottom = "none"; - } - } - - private findRoot(element: HTMLElement): HTMLElement { - while (element.parentElement) { - element = element.parentElement; - } - return element; - } - - private rejectSuggestion({ - match, - suggestion, - }: { - match: MatchesEntity; - suggestion: HTMLElement; - }) { - const getCurrentEditingCellId = (window as any).getCurrentEditingCellId; - const currentCellId = getCurrentEditingCellId?.(); - - if (!currentCellId) { - console.error("No cell ID found for current edit"); - return; - } - - const content = { - source: match.replacements?.[0]?.source || "llm", - cellId: currentCellId, - oldString: match.text, - newString: match.replacements?.[0]?.value || "", - leftToken: match.leftToken || "", - rightToken: match.rightToken || "", - }; - - // FIXME: how did we lose the leftToken and rightToken? check ./index.ts - - const message: EditorPostMessages = { - command: "rejectEditSuggestion", - content: content, - }; - - window.vscodeApi?.postMessage(message); - - // Close popup and hide diagnostic - this.closePopup(); - this.hideDiagnostic(); - - // Force a new spell check immediately - this.parent.forceCheckSpelling(); - } -} diff --git a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/QuillSpellChecker.css b/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/QuillSpellChecker.css deleted file mode 100644 index f5a043e61..000000000 --- a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/QuillSpellChecker.css +++ /dev/null @@ -1,210 +0,0 @@ -/* In-text suggestion element */ -quill-spck-match:not(.purple):not(.blue) { - border-bottom: var(--vscode-editorError-foreground) solid 3px; -} -quill-spck-match:hover { - background-color: var(--vscode-editorError-background); - cursor: pointer; -} - -/* LLM suggestions (purple) */ -quill-spck-match.purple { - border-bottom: var(--vscode-editorInfo-foreground) solid 3px; -} -quill-spck-match.purple:hover { - background-color: var(--vscode-editorInfo-background); - cursor: pointer; -} - -/* ICE suggestions (blue) */ -quill-spck-match.blue { - border-bottom: var(--vscode-editorWarning-foreground) solid 3px; -} -quill-spck-match.blue:hover { - background-color: var(--vscode-editorWarning-background); - cursor: pointer; -} - -/* High confidence ICE suggestions */ -quill-spck-match.blue.high-confidence { - border-bottom-style: double; - border-bottom-width: 4px; -} - -/* Low confidence ICE suggestions */ -quill-spck-match.blue.low-confidence { - border-bottom-style: dotted; -} - -/* Popup */ -.quill-spck-match-popup { - isolation: isolate; - background-color: var(--vscode-editor-background); - border-radius: 7px; - border: 1px solid var(--vscode-editorWidget-border); - box-shadow: var(--vscode-widget-shadow); - z-index: 1; - max-width: 400px; - font-family: var(--vscode-font-family); - font-size: 1rem; - color: var(--vscode-editor-foreground); - overflow: hidden; -} - -.quill-spck-match-popup-suggestions { - display: flex; - flex-direction: column; - max-height: 200px; - overflow-y: auto; - gap: 4px; - padding: 0.5rem 16px; -} - -/* Separator between suggestion items */ -.quill-spck-match-popup-suggestions > .quill-spck-match-popup-action:not(:last-child) { - border-bottom: 1px solid var(--vscode-editorWidget-border); -} - -/* Custom scrollbar for suggestions list */ -.quill-spck-match-popup-suggestions::-webkit-scrollbar { - width: 8px; -} -.quill-spck-match-popup-suggestions::-webkit-scrollbar-track { - background: transparent; -} -.quill-spck-match-popup-suggestions::-webkit-scrollbar-thumb { - background-color: var(--vscode-scrollbarSlider-background); - border-radius: 4px; -} - -.quill-spck-match-popup-footer { - display: flex; - justify-content: space-between; - gap: 8px; - border-top: 1px solid var(--vscode-editorWidget-border); - padding: 0.5rem 16px 0 16px; -} - -.quill-spck-match-popup-action { - flex: none; - padding: 8px 16px; - border: none; - background: none; - cursor: pointer; - color: var(--vscode-editor-foreground); - font-size: 0.875rem; - text-align: left; - white-space: normal; - overflow: hidden; - text-overflow: ellipsis; - width: 100%; -} - -.quill-spck-match-popup-action:hover { - background-color: var(--vscode-list-hoverBackground); -} - -.quill-spck-match-popup-reason { - padding: 4px 16px; - font-size: 0.75rem; - color: var(--vscode-descriptionForeground); - background-color: var(--vscode-editor-background); - border-top: 1px solid var(--vscode-editorWidget-border); -} - -/* Frequency indicator for ICE suggestions */ -.quill-spck-match-popup-frequency { - padding: 2px 6px; - margin-left: 8px; - font-size: 0.75rem; - color: var(--vscode-badge-foreground); - background-color: var(--vscode-badge-background); - border-radius: 10px; -} - -/* Arrow */ -.quill-spck-popup-arrow, -.quill-spck-popup-arrow::before { - position: absolute; - width: 8px; - height: 8px; - background: inherit; -} - -.quill-spck-popup-arrow { - visibility: hidden; -} - -.quill-spck-popup-arrow::before { - visibility: visible; - content: ""; - transform: rotate(45deg); - background: var(--vscode-editor-background); - border: 1px solid var(--vscode-editorWidget-border); -} -quill-spck-popup[data-popper-placement^="top"] > .quill-spck-popup-arrow { - bottom: -4px; -} - -quill-spck-popup[data-popper-placement^="bottom"] > .quill-spck-popup-arrow { - top: -4px; -} - -quill-spck-popup[data-popper-placement^="left"] > .quill-spck-popup-arrow { - right: -4px; -} - -quill-spck-popup[data-popper-placement^="right"] > .quill-spck-popup-arrow { - left: -4px; -} - -/* Loading indicator in editor */ -.quill-spck-loading-indicator { - position: absolute; - bottom: 3px; - right: 3px; - z-index: 10; -} -.quill-spck-loading-indicator-spinner { - display: inline-block; - width: 1rem; - height: 1rem; - border-radius: 50%; - border: 2px solid var(--vscode-editorWidget-border); - border-top-color: var(--vscode-editor-foreground); - animation: quill-spck-loading-indicator-spin 1s linear infinite; -} -@keyframes quill-spck-loading-indicator-spin { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} - -/* Example for purple highlighting */ -quill-spck-match.purple { - border-bottom: var(--vscode-editorInfo-foreground) solid 3px; -} -quill-spck-match.purple:hover { - background-color: var(--vscode-editorInfo-background); - cursor: pointer; -} - -.quill-spck-match-popup-reason { - font-size: 12px; - padding: 0.5rem; - color: var(--vscode-editorInfo-foreground); -} - -.quill-spck-match-popup-action.reject-action { - color: var(--vscode-errorForeground); - padding: 4px 8px; - margin-left: 8px; - max-width: max-content; -} - -.quill-spck-match-popup-action.reject-action:hover { - background-color: var(--vscode-list-hoverBackground); -} diff --git a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/SuggestionBlot.ts b/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/SuggestionBlot.ts deleted file mode 100644 index 304968f31..000000000 --- a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/SuggestionBlot.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { MatchesEntity } from "./types"; - -const DEBUG = false; -const debug = DEBUG ? console.log.bind(console, "spell-checker-debug:") : () => {}; - -/** - * Creates a Quill editor blot representing a suggestion. - * @param Quill Quill static instance - * @returns Blot class for registering on the Quill instance - */ -export default function createSuggestionBlotForQuillInstance(Quill: any) { - const ParentBlot = Quill.import("formats/bold"); - - return class SuggestionBlot extends ParentBlot { - static blotName = "spck-match"; - static tagName = "quill-spck-match"; - - static create(match?: MatchesEntity) { - const node: HTMLElement = super.create(); - if (match) { - Object.entries({ - "data-offset": match.offset, - "data-length": match.length, - id: `match-${match.id}`, - }).forEach(([attr, value]) => node.setAttribute(attr, value?.toString() ?? "")); - - // Apply color class if specified - if (match.color) { - node.classList.add(match.color); - - // Add confidence class for ICE suggestions - if (match.color === "blue" && match.replacements?.[0]?.confidence) { - node.classList.add(`${match.replacements[0].confidence}-confidence`); - } - } - - debug("SuggestionBlot node created with attributes", { node }); - } - return node; - } - - optimize() { - debug("SuggestionBlot optimize called"); - } - - static value(node: HTMLElement) { - debug("SuggestionBlot value called", { node }); - return node.textContent; - } - }; -} diff --git a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/SuggestionBoxes.ts b/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/SuggestionBoxes.ts deleted file mode 100644 index b6415d281..000000000 --- a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/SuggestionBoxes.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type Quill from "quill"; -import Delta from "quill-delta"; -import { QuillSpellChecker } from "."; -import { MatchesEntity } from "./types"; - -/** - * Clean all suggestion boxes from an HTML string - */ -export const getCleanedHtml = (html: string) => - html.replace(/|<\/quill-spck-match>/g, ""); - -/** - * Remove all suggestion boxes from the editor. - */ -export const removeSuggestionBoxes = (quillEditor: Quill) => { - const initialSelection = quillEditor.getSelection(); - const deltas = quillEditor.getContents(); - - const cleanedDeltas = deltas.ops.map((delta) => ({ - ...delta, - attributes: delta.attributes && { - ...delta.attributes, - "spck-match": null, - }, - })); - - quillEditor.setContents(new Delta(cleanedDeltas), "silent"); - if (initialSelection) quillEditor.setSelection(initialSelection, "silent"); -}; - -/** - * Manager for the suggestion boxes. - * Handles inserting and removing suggestion box elements from the editor. - */ -export class SuggestionBoxes { - constructor(private readonly parent: QuillSpellChecker) {} - - public removeSuggestionBoxes() { - removeSuggestionBoxes(this.parent.quill); - } - - public addSuggestionBoxes() { - this.parent.matches.forEach((match) => { - const ops = new Delta() - .retain(match.offset) - .retain(match.length, { "spck-match": match }); - - this.parent.quill.updateContents(ops, "silent"); - }); - } - - public removeCurrentSuggestionBox(currentMatch: MatchesEntity, replacement: string) { - const start = currentMatch.offset + currentMatch.length; - const diff = replacement.length - currentMatch.length; - - this.parent.matches = this.parent.matches - .filter( - (match) => - match.replacements && - match.replacements.length > 0 && - match.offset !== currentMatch.offset - ) - .map((match) => ({ - ...match, - offset: match.offset >= start ? match.offset + diff : match.offset, - })); - - this.removeSuggestionBoxes(); - this.addSuggestionBoxes(); - } -} diff --git a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/debug.ts b/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/debug.ts deleted file mode 100644 index e6cf84d9c..000000000 --- a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/debug.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default function debug(...args: any[]) { - if (process.env.NODE_ENV !== "production") { - console.debug("QuillSpellChecker", ...args); - } -} diff --git a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/index.ts b/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/index.ts deleted file mode 100644 index 4c9723219..000000000 --- a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/index.ts +++ /dev/null @@ -1,339 +0,0 @@ -import Quill from "quill"; -import PopupManager from "./PopupManager"; -import "./QuillSpellChecker.css"; -import createSuggestionBlotForQuillInstance from "./SuggestionBlot"; -import { SuggestionBoxes } from "./SuggestionBoxes"; -import { MatchesEntity, SpellCheckerApi } from "./types"; - -const DEBUG_MODE = false; -const debug = (...args: any[]) => DEBUG_MODE && console.log("spell-checker-debug", ...args); - -export type QuillSpellCheckerParams = { - disableNativeSpellcheck: boolean; - cooldownTime: number; - showLoadingIndicator: boolean; - api: SpellCheckerApi; -}; - -export class QuillSpellChecker { - protected typingCooldown?: number; - protected loopPreventionCooldown?: number; - protected popups = new PopupManager(this); - public boxes = new SuggestionBoxes(this); - public matches: MatchesEntity[] = []; - protected onRequestComplete: () => void = () => null; - private typingTimer: number | undefined; - private typingDelay = 500; // Delay in milliseconds - private lastSpellCheckTime: number = 0; - private spellCheckCooldown: number = 1000; // Minimum time between spellchecks in ms - - constructor( - public quill: Quill, - public params: QuillSpellCheckerParams = { - disableNativeSpellcheck: false, - cooldownTime: 1000, - showLoadingIndicator: true, - api: {} as SpellCheckerApi, - } - ) { - debug("QuillSpellChecker constructor", { quill, params }); - if (!quill?.root) { - console.error("Quill instance or its root is not available"); - return; - } - - this.setupEventListeners(); - this.disableNativeSpellcheckIfSet(); - // setTimeout(() => { - // this.checkSpelling(); - // }, 100); - } - - // Add dispose method for cleanup - public dispose() { - debug("Disposing QuillSpellChecker"); - // Clean up window event listener - window.removeEventListener("message", this.handleVSCodeMessage); - - // Clean up Quill event listeners - this.quill.root.removeEventListener("copy", this.handleCopy); - this.quill.off("text-change", this.handleTextChange); - this.quill.off("editor-change", this.handleEditorChange); - - // Clear any pending timers - if (this.typingTimer) { - clearTimeout(this.typingTimer); - } - if (this.typingCooldown) { - clearTimeout(this.typingCooldown); - } - if (this.loopPreventionCooldown) { - clearTimeout(this.loopPreventionCooldown); - } - - // Clean up boxes and popups - this.boxes.removeSuggestionBoxes(); - this.matches = []; - - // Clean up popup manager - this.popups.dispose(); - } - - private setupEventListeners() { - this.quill.root.addEventListener("copy", this.handleCopy); - this.quill.on("text-change", this.handleTextChange); - this.quill.on("editor-change", this.handleEditorChange); - window.addEventListener("message", this.handleVSCodeMessage); - } - - private handleCopy = (event: ClipboardEvent) => { - const range = this.quill.getSelection(); - const text = this.quill.getText(range?.index, range?.length); - debug("copy event", { text }); - event.clipboardData?.setData("text/plain", text); - event.preventDefault(); - }; - - private handleTextChange = (delta: any, oldDelta: any, source: string) => { - debug("text-change event", { delta, oldDelta, source }); - if (source === "user") { - const content = this.quill.getText(); - debug("text-change content", { content }); - this.onTextChange(); - this.quill.emitter.emit("text-change"); - } else if (this.matches.length > 0 && this.quill.getText().trim()) { - this.boxes.addSuggestionBoxes(); - } - }; - - private handleEditorChange = (eventName: string, ...args: any[]) => { - debug("editor-change event", { eventName, args }); - this.popups.initialize(); - }; - - private handleVSCodeMessage = (event: MessageEvent) => { - const message = event.data; - debug("handleVSCodeMessage", { message }); - - // Add explicit handling for rejection updates - if (message.type === "wordAdded" || message.type === "suggestionRejected") { - debug("Forcing spell check after rejection/word addition"); - // this.forceCheckSpelling(); - } - }; - - public updateMatches(matches: MatchesEntity[]) { - debug("updateMatches", { matches }); - this.boxes.removeSuggestionBoxes(); - this.matches = matches; - this.boxes.addSuggestionBoxes(); - } - - public acceptMatch(id: MatchesEntity["id"], replacementIndex: number = 0) { - debug("acceptMatch", { id, replacementIndex }); - const match = this.matches.find((m) => m.id === id); - const mode = "silent"; - if (match?.replacements?.length && replacementIndex < match.replacements.length) { - // Remove just the specific underline by setting the text without the format - this.quill.formatText(match.offset, match.length, "spck-match", false, mode); - - // Replace the text - this.quill.deleteText(match.offset, match.length, mode); - this.quill.insertText(match.offset, match.replacements[replacementIndex].value, mode); - this.quill.setSelection( - match.offset + match.replacements[replacementIndex].value.length, - mode - ); - - // Remove just this match from the matches array - this.matches = this.matches.filter((m) => m.id !== id); - - // Remove only this suggestion box - this.boxes.removeCurrentSuggestionBox( - match, - match.replacements[replacementIndex].value - ); - - // Trigger a text-change event to update the editor state - this.quill.updateContents([{ retain: this.quill.getLength() }], "api"); - } - } - - public ignoreMatch(id: MatchesEntity["id"]) { - debug("ignoreMatch", { id }); - const match = this.matches.find((m) => m.id === id); - if (match) { - this.boxes.removeCurrentSuggestionBox(match, match.text); - } - } - - public showMatches(show: boolean = true) { - debug("showMatches", { show }); - show ? this.boxes.addSuggestionBoxes() : this.boxes.removeSuggestionBoxes(); - } - - private disableNativeSpellcheckIfSet() { - debug("disableNativeSpellcheckIfSet"); - if (this.params.disableNativeSpellcheck) { - this.quill.root.setAttribute("spellcheck", "false"); - } - } - - private onTextChange() { - debug("onTextChange"); - - // Clear the previous timer - if (this.typingTimer) { - clearTimeout(this.typingTimer); - } - - // Set a new timer - // this.typingTimer = window.setTimeout(() => { - // this.checkSpelling(); - // }, this.typingDelay); - } - - public setOnRequestComplete(callback: () => void) { - debug("setOnRequestComplete"); - this.onRequestComplete = callback; - } - - // public forceCheckSpelling() { - // debug("forceCheckSpelling called"); - // // Reset the last check time to ensure it runs - // this.lastSpellCheckTime = 200; - // return this.checkSpelling(true); - // } - - // public async checkSpelling(force: boolean = false) { - // debug("checkSpelling", { force }); - // const now = Date.now(); - // if (!force && now - this.lastSpellCheckTime < this.spellCheckCooldown) { - // debug("Skipping spell check due to cooldown"); - // return; - // } - - // this.lastSpellCheckTime = now; - - // if (document.querySelector("spck-toolbar")) { - // debug("Skipping spell check due to toolbar"); - // return; - // } - - // const text = this.quill.getText().trim(); - // debug("checkSpelling text", { text }); - - // if (!text) { - // debug("Skipping spell check due to empty text"); - // return; - // } - - // try { - // const results = await this.getSpellCheckerResults(text); - // this.boxes.removeSuggestionBoxes(); - // debug("checkSpelling results", { results }); - - // if (results?.length) { - // this.matches = results - // .filter((match) => match.replacements?.length) - // .map((match, index) => ({ ...match, id: index.toString() })); - // debug("checkSpelling matches", { matches: this.matches }); - // this.boxes.addSuggestionBoxes(); - // } else { - // this.matches = []; - // this.boxes.removeSuggestionBoxes(); - // } - - // this.onRequestComplete(); - // } catch (error) { - // console.error("Error during spell check:", error); - // this.matches = []; - // this.boxes.removeSuggestionBoxes(); - // this.onRequestComplete(); - // } - // } - - // private async getSpellCheckerResults(text: string): Promise { - // debug("getSpellCheckerResults", { text }); - // if (!(window as any).vscodeApi) return null; - - // try { - // return new Promise((resolve, reject) => { - // const messageListener = (event: MessageEvent) => { - // const message = event.data; - // if (message.type === "providerSendsSpellCheckResponse") { - // (window as any).removeEventListener("message", messageListener); - // debug("from-provider-getSpellCheckResponse", message.content); - // resolve(message.content); - // } - // }; - - // (window as any).addEventListener("message", messageListener); - - // (window as any).vscodeApi.postMessage({ - // command: "from-quill-spellcheck-getSpellCheckResponse", - // content: { cellContent: text }, - // }); - - // setTimeout(() => { - // (window as any).removeEventListener("message", messageListener); - // reject(new Error("Spell check request timed out")); - // }, 10000); - // }); - // } catch (e) { - // console.error("getSpellCheckerResults error", e); - // return null; - // } - // } - - public preventLoop() { - debug("preventLoop"); - if (this.loopPreventionCooldown) clearTimeout(this.loopPreventionCooldown); - this.loopPreventionCooldown = window.setTimeout(() => { - this.loopPreventionCooldown = undefined; - }, 100); - } -} - -// Global flag to track registration state -let isSpellCheckerRegistered = false; - -export default function registerQuillSpellChecker(Quill: any, vscodeApi: any) { - debug("spell-checker-debug: registerQuillSpellChecker", { - Quill, - vscodeApi, - isAlreadyRegistered: isSpellCheckerRegistered - }); - - // Store the VSCode API in the global variable - (window as any).vscodeApi = vscodeApi; - - // Check if we've already registered (more robust check) - if (isSpellCheckerRegistered || (Quill as any).imports?.["modules/spellChecker"]) { - debug("SpellChecker module already registered, skipping registration"); - return; - } - - try { - (Quill as any).register({ - "modules/spellChecker": QuillSpellChecker, - "formats/spck-match": createSuggestionBlotForQuillInstance(Quill), - }); - - // Mark as registered to prevent future registrations - isSpellCheckerRegistered = true; - debug("SpellChecker module registered successfully"); - } catch (error) { - console.error("[SpellChecker] Failed to register SpellChecker module:", error); - // Don't set the flag if registration failed - } -} - -export { getCleanedHtml, removeSuggestionBoxes } from "./SuggestionBoxes"; - -// Declare a global variable to store the VSCode API -declare global { - interface Window { - vscodeApi: any; - } -} diff --git a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/react-app-env.d.ts b/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/react-app-env.d.ts deleted file mode 100644 index 3e699bf85..000000000 --- a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/react-app-env.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -/// - -declare module "nanohtml/lib/browser"; -declare module "nanohtml/raw"; diff --git a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/types.ts b/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/types.ts deleted file mode 100644 index c1de81986..000000000 --- a/webviews/codex-webviews/src/CodexCellEditor/react-quill-spellcheck/types.ts +++ /dev/null @@ -1,43 +0,0 @@ -export interface SpellCheckerApi { - url: string; - body: SpellCheckerApiBody; - headers: HeadersInit; - method: string; - mode: RequestMode; - mapResponse: SpellCheckerResponseApi; - matches?: MatchesEntity[]; -} -export type SpellCheckerApiBody = (text: string) => BodyInit; -export type SpellCheckerResponseApi = (response: Response) => Promise<{ - language: Language; - matches?: MatchesEntity[] | null; -}>; -export interface Language { - name: string; - code: string; -} -export interface DetectedLanguage { - name: string; - code: string; - confidence: number; -} -export interface MatchesEntity { - id: string; - text: string; - replacements?: Array<{ - value: string; - confidence?: "high" | "low"; - source?: "ice" | "llm"; - frequency?: number; - }>; - offset: number; - length: number; - color?: "purple" | "blue"; - cellId?: string; - leftToken?: string; - rightToken?: string; -} - -export interface SpellCheckerApi { - check: (text: string) => Promise; -} diff --git a/webviews/codex-webviews/src/CodexCellEditor/utils.ts b/webviews/codex-webviews/src/CodexCellEditor/utils.ts index 0076aeeae..addd052df 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/utils.ts +++ b/webviews/codex-webviews/src/CodexCellEditor/utils.ts @@ -2,45 +2,12 @@ import type React from "react"; import type { QuillCellContent } from "../../../../types"; import type { ProgressPercentages } from "../lib/types"; -export const processVerseContent = (cellContent: string) => { - const verseRefRegex = /(?<=^|\s)(?=[A-Z, 1-9]{3} \d{1,3}:\d{1,3})/; - const lines = cellContent.split(verseRefRegex); - return lines - .map((line) => { - const verseMarker = line.match(/(\b[A-Z, 1-9]{3}\s\d+:\d+\b)/)?.[0]; - if (verseMarker) { - const lineWithoutVerseRefMarker = line - .replace(`${verseMarker} `, "") - .replace(`${verseMarker}\n`, "") - .replace(`${verseMarker}`, ""); - return { - verseMarkers: [verseMarker], - verseContent: lineWithoutVerseRefMarker, - }; - } - return null; - }) - .filter((line) => line !== null); -}; - /** - * @deprecated This function has been replaced with proper utilities in footnoteUtils.ts - * Use `processHtmlContent` from footnoteUtils.ts instead for better maintainability and type safety. - * - * This function is kept temporarily for backward compatibility but should not be used in new code. + * Strip suggestion-box markup (`` tags) from an HTML string. + * This is used to sanitize editor content before saving or processing. */ -export const HACKY_removeContiguousSpans = (html: string) => { - console.warn('HACKY_removeContiguousSpans is deprecated. Use processHtmlContent from footnoteUtils.ts instead.'); - - // Import the proper function dynamically to avoid circular dependencies - try { - // For now, provide basic functionality while migration is complete - return html.replace(/<\/span>/g, ""); - } catch (error) { - console.error('Error in deprecated HACKY_removeContiguousSpans:', error); - return html; - } -}; +export const getCleanedHtml = (html: string) => + html.replace(/|<\/quill-spck-match>/g, ""); export const sanitizeQuillHtml = (originalHTML: string) => { return originalHTML.replace(/
/g, "").replace(/<\/div>/g, ""); diff --git a/webviews/codex-webviews/src/EditableReactTable/AddWordForm.tsx b/webviews/codex-webviews/src/EditableReactTable/AddWordForm.tsx deleted file mode 100644 index eae99732c..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/AddWordForm.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, { useState } from "react"; -import { Modal, Form, Input, Button } from "antd"; -import { DictionaryPostMessages } from "../../../../types"; -import { vscode } from "./utilities/vscode"; - -interface AddWordProps { - visible: boolean; - onCancel: () => void; -} - -const AddWordForm: React.FC = ({ visible, onCancel }) => { - const [form] = Form.useForm(); - - const handleSubmit = () => { - form.validateFields().then((values) => { - // Send message to add new word - vscode.postMessage({ - command: "webviewTellsProviderToUpdateData", - operation: "add", - entry: { - headWord: values.headWord, - definition: values.definition || "", - }, - } as DictionaryPostMessages); - - form.resetFields(); - onCancel(); - }); - }; - - return ( - - Add New Word - - } - open={visible} - onCancel={onCancel} - footer={[ -
- - -
, - ]} - > -
- - - - - - - -
-
- ); -}; - -export default AddWordForm; diff --git a/webviews/codex-webviews/src/EditableReactTable/App.tsx b/webviews/codex-webviews/src/EditableReactTable/App.tsx deleted file mode 100644 index 9f7633dc0..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/App.tsx +++ /dev/null @@ -1,375 +0,0 @@ -import React, { useEffect, useState, useCallback, useRef } from "react"; -import { Table, Input, Button, Popconfirm, Tooltip, ConfigProvider, theme } from "antd"; -import type { ColumnsType } from "antd/es/table"; -import { vscode } from "./utilities/vscode"; -import { - DictionaryPostMessages, - DictionaryReceiveMessages, - Dictionary, - DictionaryEntry, -} from "../../../../types"; -import { useMeasure } from "@uidotdev/usehooks"; -import AddWordForm from "./AddWordForm"; -import EditWordForm from "./EditWordForm"; -import { ConfirmDeleteButton } from "./ConfirmDeleteButton"; - -interface DataType { - key: React.Key; - [key: string]: any; -} - -const App: React.FC = () => { - const [outerContainer, { height: outerContainerHeight }] = useMeasure(); - const [tableRef, { height: tableHeight }] = useMeasure(); - const [inputRef, { height: inputHeight }] = useMeasure(); - const [buttonRef, { height: buttonHeight }] = useMeasure(); - const [addWordVisible, setAddWordVisible] = useState(false); - const [entryToEdit, setEntryToEdit] = useState(null); - - const [dataSource, setDataSource] = useState([]); - const [columnNames, setColumnNames] = useState([]); - const [dictionary, setDictionary] = useState({ - id: "", - label: "", - entries: [], - metadata: {}, - }); - const [searchQuery, setSearchQuery] = useState(""); - const [vsCodeTheme, setVsCodeTheme] = useState({}); - const [pagination, setPagination] = useState({ - current: 1, - pageSize: 10, - total: 0, - }); - - const dataSourceRef = useRef(dataSource); - const dictionaryRef = useRef(dictionary); - const lastSentDataRef = useRef(null); - - useEffect(() => { - dataSourceRef.current = dataSource; - }, [dataSource]); - - useEffect(() => { - dictionaryRef.current = dictionary; - }, [dictionary]); - - useEffect(() => { - // Get the VS Code theme variables - const style = getComputedStyle(document.documentElement); - const themeColors = { - colorPrimary: style.getPropertyValue("--vscode-button-background").trim(), - colorPrimaryHover: style.getPropertyValue("--vscode-button-hoverBackground").trim(), - colorPrimaryActive: style.getPropertyValue("--vscode-button-background").trim(), - colorBgContainer: style.getPropertyValue("--vscode-editor-background").trim(), - colorBgElevated: style.getPropertyValue("--vscode-editor-background").trim(), - colorText: style.getPropertyValue("--vscode-editor-foreground").trim(), - colorTextSecondary: style.getPropertyValue("--vscode-descriptionForeground").trim(), - colorTextTertiary: style.getPropertyValue("--vscode-disabledForeground").trim(), - colorTextQuaternary: style.getPropertyValue("--vscode-disabledForeground").trim(), - colorBorder: style.getPropertyValue("--vscode-input-border").trim(), - colorBorderSecondary: style.getPropertyValue("--vscode-input-border").trim(), - colorFill: style.getPropertyValue("--vscode-input-background").trim(), - colorFillSecondary: style.getPropertyValue("--vscode-input-background").trim(), - colorFillTertiary: style.getPropertyValue("--vscode-input-background").trim(), - colorFillQuaternary: style.getPropertyValue("--vscode-input-background").trim(), - colorBgLayout: style.getPropertyValue("--vscode-editor-background").trim(), - colorWarning: style.getPropertyValue("--vscode-inputValidation-warningBorder").trim(), - colorError: style.getPropertyValue("--vscode-inputValidation-errorBorder").trim(), - colorInfo: style.getPropertyValue("--vscode-inputValidation-infoBorder").trim(), - colorSuccess: style.getPropertyValue("--vscode-inputValidation-infoBorder").trim(), - colorLink: style.getPropertyValue("--vscode-textLink-foreground").trim(), - colorLinkHover: style.getPropertyValue("--vscode-textLink-activeForeground").trim(), - colorLinkActive: style.getPropertyValue("--vscode-textLink-activeForeground").trim(), - // Table styles - colorTableBackground: style.getPropertyValue("--vscode-editor-background").trim(), - colorTableHeaderBackground: style.getPropertyValue("--vscode-editor-background").trim(), - colorTableHeaderText: style.getPropertyValue("--vscode-editor-foreground").trim(), - colorTableCellBackground: style.getPropertyValue("--vscode-editor-background").trim(), - colorTableCellText: style.getPropertyValue("--vscode-editor-foreground").trim(), - colorTableFixedCellBackground: style - .getPropertyValue("--vscode-editor-background") - .trim(), - }; - setVsCodeTheme(themeColors); - }, []); - - const handleDelete = useCallback((key: React.Key) => { - setDataSource((prevDataSource) => { - const itemToDelete = prevDataSource.find((item) => item.key === key); - if (itemToDelete) { - vscode.postMessage({ - command: "webviewTellsProviderToUpdateData", - operation: "delete", - entry: { - id: itemToDelete.id, - }, - } as DictionaryPostMessages); - } - return prevDataSource.filter((item) => item.key !== key); - }); - }, []); - - // const handleAdd = useCallback(() => { - // setDataSource((prevDataSource) => { - // const newKey = prevDataSource.length - // ? Math.max(...prevDataSource.map((item) => Number(item.key))) + 1 - // : 0; - // const newEntry: DataType = { - // key: newKey, - // headWord: "", - // definition: "", - // }; - - // vscode.postMessage({ - // command: "webviewTellsProviderToUpdateData", - // operation: "add", - // entry: { - // headWord: newEntry.headWord, - // definition: newEntry.definition, - // }, - // } as DictionaryPostMessages); - - // return [...prevDataSource, newEntry]; - // }); - // }, []); - - const getColumnIcon = useCallback((columnName: string): JSX.Element => { - const iconMap: { [key: string]: string } = { - headWord: "symbol-keyword", - headForm: "symbol-text", - variantForms: "symbol-array", - definition: "book", - translationEquivalents: "symbol-string", - links: "link", - linkedEntries: "references", - notes: "note", - metadata: "json", - hash: "symbol-key", - }; - const iconName = iconMap[columnName] || "symbol-field"; - return ; - }, []); - - const columns: ColumnsType = React.useMemo(() => { - if (columnNames.length === 0) { - return []; - } - - const dataColumns = columnNames - .filter((key) => key !== "id") - .filter((key) => key !== "isUserEntry") - .filter((key) => key !== "authorId") - .map((key) => ({ - title: ( - - - {getColumnIcon(key)} {key} - - - ), - dataIndex: key, - key: key, - render: (text: string) => {text}, - fixed: key === columnNames[0] ? ("left" as const) : undefined, - })); - - const actionColumn = { - title: ( - - - - ), - key: "action", - fixed: "right" as const, - width: 100, - render: (_: any, record: DataType) => ( -
- handleDelete(record.key)} /> -
- ), - }; - - return [...dataColumns, actionColumn]; - }, [columnNames, handleDelete, getColumnIcon]); - - // Function to fetch page data - const fetchPageData = useCallback((page: number, pageSize: number, search?: string) => { - vscode.postMessage({ - command: "webviewTellsProviderToUpdateData", - operation: "fetchPage", - pagination: { - page, - pageSize, - searchQuery: search, - }, - } as DictionaryPostMessages); - }, []); - - // Handle table pagination change - const handleTableChange = (newPagination: any) => { - setPagination((prev) => ({ - ...prev, - current: newPagination.current, - pageSize: newPagination.pageSize, - })); - fetchPageData(newPagination.current, newPagination.pageSize, searchQuery); - }; - - // Update the search handler to reset pagination - const handleSearchChange = (event: React.ChangeEvent) => { - const newQuery = event.target.value; - setSearchQuery(newQuery); - setPagination((prev) => ({ ...prev, current: 1 })); - fetchPageData(1, pagination.pageSize, newQuery); - }; - - // Update the message handler - useEffect(() => { - const handleReceiveMessage = (event: MessageEvent) => { - const message = event.data; - if (message.command === "providerTellsWebviewToUpdateData") { - const { entries, total, page, pageSize } = message.data; - const newDataSource = entries.map((entry, index) => ({ - key: (page - 1) * pageSize + index, - ...entry, - })); - - setDataSource(newDataSource); - setPagination((prev) => ({ - ...prev, - total, - current: page, - pageSize, - })); - - if (entries.length > 0) { - const newColumnNames = Object.keys(entries[0]).filter((key) => key !== "key"); - setColumnNames(newColumnNames); - } - } - }; - - window.addEventListener("message", handleReceiveMessage); - // Initial data fetch - fetchPageData(pagination.current, pagination.pageSize); - - return () => { - window.removeEventListener("message", handleReceiveMessage); - }; - }, []); - - return ( - -
-
- } - /> -
- -
- - -
- - setAddWordVisible(false)} /> - {entryToEdit && ( - setEntryToEdit(null)} - /> - )} -
- `Total ${total} items`, - }} - onChange={handleTableChange} - scroll={{ - x: "max-content", - y: `calc(${ - (outerContainerHeight || 0) - - (inputHeight || 0) - - (buttonHeight || 0) - }px - 180px)`, - }} - style={{ flexGrow: 1, overflow: "auto" }} - /> - - - - ); -}; - -export default App; diff --git a/webviews/codex-webviews/src/EditableReactTable/Badge.tsx b/webviews/codex-webviews/src/EditableReactTable/Badge.tsx deleted file mode 100644 index b387045a7..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/Badge.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from "react"; - -// Define an interface for the component's props -interface BadgeProps { - value: React.ReactNode; // This allows for strings, numbers, and other React nodes - backgroundColor: string; -} - -const Badge: React.FC = ({ value, backgroundColor }) => { - return ( - - {value} - - ); -}; - -export default Badge; diff --git a/webviews/codex-webviews/src/EditableReactTable/ConfirmDeleteButton.tsx b/webviews/codex-webviews/src/EditableReactTable/ConfirmDeleteButton.tsx deleted file mode 100644 index 38c053077..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/ConfirmDeleteButton.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Button } from "antd"; -import { useState } from "react"; -export const ConfirmDeleteButton: React.FC<{ onConfirm: () => void }> = ({ onConfirm }) => { - const [isDeleting, setIsDeleting] = useState(false); - if (isDeleting) { - return ( -
-
-
-
-
-
- ); - } - return ( - - - , - ]} - > -
- - - - - - - - - - ); -}; - -export default EditWordForm; diff --git a/webviews/codex-webviews/src/EditableReactTable/Table.tsx b/webviews/codex-webviews/src/EditableReactTable/Table.tsx deleted file mode 100644 index f525268df..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/Table.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React, { CSSProperties, useMemo } from "react"; -import { useTable, useFlexLayout, useResizeColumns, useSortBy, TableOptions } from "react-table"; -import Cell from "./cells/Cell"; -import Header from "./header/Header"; -import PlusIcon from "./img/Plus"; -import { ActionTypes } from "./utils"; -import { TableColumn, TableData, TableEntry } from "./tableTypes"; -interface CustomTableOptions extends TableOptions { - dataDispatch?: React.Dispatch; -} -const defaultColumn: TableColumn = { - minWidth: 50, - width: 150, - maxWidth: 400, - Cell: Cell, - Header: Header, - sortType: "alphanumericFalsyLast", -}; - -export default function Table({ columns, data, dispatch: dataDispatch, skipReset }: TableData) { - const sortTypes = useMemo( - () => ({ - alphanumericFalsyLast(rowA: any, rowB: any, columnId: string, desc?: boolean) { - if (!rowA.values[columnId] && !rowB.values[columnId]) { - return 0; - } - - if (!rowA.values[columnId]) { - return desc ? -1 : 1; - } - - if (!rowB.values[columnId]) { - return desc ? 1 : -1; - } - - return isNaN(rowA.values[columnId]) - ? rowA.values[columnId].localeCompare(rowB.values[columnId]) - : rowA.values[columnId] - rowB.values[columnId]; - }, - }), - [] - ); - - const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, totalColumnsWidth } = - useTable( - { - columns, - data, - defaultColumn, - dataDispatch, - autoResetSortBy: !skipReset, - autoResetFilters: !skipReset, - autoResetRowState: !skipReset, - sortTypes, - } as CustomTableOptions, - useFlexLayout /*Block 888*/, - useResizeColumns, - useSortBy - ); - - const RenderRow = React.useCallback( - ({ index, style }: { index: number; style: React.CSSProperties }) => { - const row = rows[index]; - prepareRow(row); - return ( -
- {row.cells.map((cell: any, cellIndex: number) => ( -
- {cell.render("Cell")} -
- ))} -
- ); - }, - [prepareRow, rows] - ); - - const Rows: React.FC = () => ( -
- {rows.map((row, index: number) => { - return RenderRow({ - index, - style: row?.getRowProps?.().style as CSSProperties, - }); - })} -
- ); - - return ( -
-
-
- {headerGroups.map((headerGroup: any, index: number) => ( -
- {headerGroup.headers.map((column: any, columnIndex: number) => ( -
- {column.render("Header")} -
- ))} -
- ))} -
-
-
-
-
- -
-
- -
dataDispatch && dataDispatch({ type: ActionTypes.ADD_ROW })} - style={{ - marginTop: 30, - width: "fit-content", - minWidth: "90px", - }} - > - - - - New -
-
-
- ); -} diff --git a/webviews/codex-webviews/src/EditableReactTable/cells/Cell.tsx b/webviews/codex-webviews/src/EditableReactTable/cells/Cell.tsx deleted file mode 100644 index 92fd84ffa..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/cells/Cell.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from "react"; -import { DataTypes } from "../utils"; -import TextCell from "./TextCell"; -import NumberCell from "./NumberCell"; -import SelectCell from "./SelectCell"; -import CheckboxCell from "./CheckboxCell"; -import { CellData } from "../tableTypes"; - -export default function Cell({ - value: initialValue, - row: { index }, - column: { id, dataType, options }, - dataDispatch, -}: CellData) { - function getCellElement() { - switch (dataType) { - case DataTypes.TEXT: - return ( - - ); - case DataTypes.NUMBER: - return ( - - ); - case DataTypes.SELECT: - return ( - - ); - case DataTypes.CHECKBOX: - return ( - - ); - default: - return ; - } - } - - return getCellElement(); -} diff --git a/webviews/codex-webviews/src/EditableReactTable/cells/CheckboxCell.tsx b/webviews/codex-webviews/src/EditableReactTable/cells/CheckboxCell.tsx deleted file mode 100644 index 5e8d24e2b..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/cells/CheckboxCell.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React, { useState } from "react"; -import { ActionTypes } from "../utils"; -import { CellTypeData } from "../tableTypes"; - -export default function CheckboxCell({ - initialValue, - columnId, - rowIndex, - dataDispatch, -}: CellTypeData) { - const [checked, setChecked] = useState(initialValue); - - const handleChange = (e: React.ChangeEvent) => { - setChecked(e.target.checked); - if (dataDispatch) - dataDispatch({ - type: ActionTypes.UPDATE_CELL, - columnId, - rowIndex, - value: e.target.checked, - }); - }; - - return ( -
- -
- ); -} diff --git a/webviews/codex-webviews/src/EditableReactTable/cells/NumberCell.tsx b/webviews/codex-webviews/src/EditableReactTable/cells/NumberCell.tsx deleted file mode 100644 index d30e21463..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/cells/NumberCell.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React, { useEffect, useState } from "react"; -import ContentEditable from "react-contenteditable"; -import { ActionTypes } from "../utils"; -import { CellTypeData, ValueState } from "../tableTypes"; - -export default function NumberCell({ - initialValue, - columnId, - rowIndex, - dataDispatch, -}: CellTypeData) { - const [value, setValue] = useState({ - value: initialValue, - update: false, - }); - - const onChange = (e: React.FormEvent) => { - const target = e.currentTarget; - setValue({ value: target.innerText, update: false }); - }; - - const onBlur = () => { - setValue((old) => ({ ...old, update: true })); - }; - - useEffect(() => { - setValue({ value: initialValue, update: false }); - }, [initialValue]); - - useEffect(() => { - if (value.update) { - if (dataDispatch) - dataDispatch({ - type: ActionTypes.UPDATE_CELL, - columnId, - rowIndex, - value: value.value, - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value.update, columnId, rowIndex]); - - return ( - - ); -} diff --git a/webviews/codex-webviews/src/EditableReactTable/cells/SelectCell.tsx b/webviews/codex-webviews/src/EditableReactTable/cells/SelectCell.tsx deleted file mode 100644 index a6640a88c..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/cells/SelectCell.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { createPortal } from "react-dom"; -import { usePopper } from "react-popper"; -import Badge from "../Badge"; -import { grey } from "../colors"; -import PlusIcon from "../img/Plus"; -import { ActionTypes, randomColor } from "../utils"; -import { CellTypeData } from "../tableTypes"; - -export default function SelectCell({ - initialValue, - options, - columnId, - rowIndex, - dataDispatch, -}: CellTypeData) { - const [selectRef, setSelectRef] = useState(null); - const [selectPop, setSelectPop] = useState(null); - const [showSelect, setShowSelect] = useState(false); - const [showAdd, setShowAdd] = useState(false); - const [addSelectRef, setAddSelectRef] = useState(null); - const { styles, attributes } = usePopper(selectRef, selectPop, { - placement: "bottom-start", - strategy: "fixed", - }); - const [value, setValue] = useState({ value: initialValue, update: false }); - - useEffect(() => { - setValue({ value: initialValue, update: false }); - }, [initialValue]); - - useEffect(() => { - if (value.update) { - if (dataDispatch) - dataDispatch({ - type: ActionTypes.UPDATE_CELL, - columnId, - rowIndex, - value: value.value, - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value, columnId, rowIndex]); - - useEffect(() => { - if (addSelectRef && showAdd) { - addSelectRef.focus(); - } - }, [addSelectRef, showAdd]); - - function getColor() { - let match = options?.find((option) => option.label === value.value); - return match?.backgroundColor ?? grey(200); - } - - function handleAddOption() { - setShowAdd(true); - } - - function handleOptionKeyDown(e: React.KeyboardEvent) { - if (e.key === "Enter") { - const target = e.target as HTMLInputElement; - if (target.value !== "") { - if (dataDispatch) - dataDispatch({ - type: ActionTypes.ADD_OPTION_TO_COLUMN, - option: target.value, - backgroundColor: randomColor(), - columnId, - }); - } - setShowAdd(false); - } - } - - function handleOptionBlur(e: React.FocusEvent) { - if (e.target.value !== "") { - if (dataDispatch) - dataDispatch({ - type: ActionTypes.ADD_OPTION_TO_COLUMN, - option: e.target.value, - backgroundColor: randomColor(), - columnId, - }); - } - setShowAdd(false); - } - - function handleOptionClick(option: any) { - setValue({ value: option.label, update: true }); - setShowSelect(false); - } - - useEffect(() => { - if (addSelectRef && showAdd) { - addSelectRef.focus(); - } - }, [addSelectRef, showAdd]); - - return ( - <> -
setShowSelect(true)} - > - {value.value && } -
- {showSelect &&
setShowSelect(false)} />} - {showSelect && - createPortal( -
-
- {options?.map((option) => ( -
handleOptionClick(option)} - > - -
- ))} - {showAdd && ( -
- -
- )} -
- - - - } - backgroundColor={grey(200)} - /> -
-
-
, - document.querySelector("#popper-portal") as Element - )} - - ); -} diff --git a/webviews/codex-webviews/src/EditableReactTable/cells/TextCell.tsx b/webviews/codex-webviews/src/EditableReactTable/cells/TextCell.tsx deleted file mode 100644 index f9790c313..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/cells/TextCell.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React, { useEffect, useState } from "react"; -import ContentEditable from "react-contenteditable"; -import { ActionTypes } from "../utils"; -import { CellTypeData, ValueState } from "../tableTypes"; - -export default function TextCell({ initialValue, columnId, rowIndex, dataDispatch }: CellTypeData) { - const [value, setValue] = useState({ - value: initialValue, - update: false, - }); - - function onChange(e: React.FormEvent) { - const target = e.currentTarget; - setValue({ value: target.innerText, update: false }); - } - - function onBlur() { - setValue((old: any) => ({ ...old, update: true })); - } - - useEffect(() => { - setValue({ value: initialValue, update: false }); - }, [initialValue]); - - useEffect(() => { - if (value.update) { - if (dataDispatch) - dataDispatch({ - type: ActionTypes.UPDATE_CELL, - columnId, - rowIndex, - value: value.value, - }); - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value.update, columnId, rowIndex]); - - return ( - - ); -} diff --git a/webviews/codex-webviews/src/EditableReactTable/colors.ts b/webviews/codex-webviews/src/EditableReactTable/colors.ts deleted file mode 100644 index 3825b8a75..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/colors.ts +++ /dev/null @@ -1,18 +0,0 @@ -type GreyScaleValue = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; - -export function grey(value: GreyScaleValue): string { - const reference: Record = { - 50: "#fafafa", - 100: "#f5f5f5", - 200: "#eeeeee", - 300: "#e0e0e0", - 400: "#bdbdbd", - 500: "#9e9e9e", - 600: "#757575", - 700: "#616161", - 800: "#424242", - 900: "#212121", - }; - - return reference[value]; -} diff --git a/webviews/codex-webviews/src/EditableReactTable/header/AddColumnHeader.tsx b/webviews/codex-webviews/src/EditableReactTable/header/AddColumnHeader.tsx deleted file mode 100644 index c6d3a57f8..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/header/AddColumnHeader.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import PlusIcon from "../img/Plus"; -import React from "react"; -import { ActionTypes, Constants } from "../utils"; - -export default function AddColumnHeader({ - getHeaderProps, - dataDispatch, -}: { - getHeaderProps: any; - dataDispatch: React.Dispatch; -}) { - return ( -
-
- dataDispatch({ - type: ActionTypes.ADD_COLUMN_TO_LEFT, - columnId: Constants.ADD_COLUMN_ID, - focus: true, - }) - } - > - - - -
-
- ); -} diff --git a/webviews/codex-webviews/src/EditableReactTable/header/DataTypeIcon.tsx b/webviews/codex-webviews/src/EditableReactTable/header/DataTypeIcon.tsx deleted file mode 100644 index 2609620fe..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/header/DataTypeIcon.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React, { ReactElement } from "react"; -import { DataTypes } from "../utils"; -import TextIcon from "../img/Text"; -import MultiIcon from "../img/Multi"; -import HashIcon from "../img/Hash"; - -interface DataTypeIconProps { - dataType: DataTypes; -} - -export default function DataTypeIcon({ dataType }: DataTypeIconProps): ReactElement | null { - function getPropertyIcon(dataType: DataTypes): ReactElement | null { - switch (dataType as DataTypes) { - case DataTypes.NUMBER: - return ; - case DataTypes.TEXT: - return ; - case DataTypes.SELECT: - return ; - default: - return null; - } - } - - return getPropertyIcon(dataType); -} diff --git a/webviews/codex-webviews/src/EditableReactTable/header/Header.tsx b/webviews/codex-webviews/src/EditableReactTable/header/Header.tsx deleted file mode 100644 index d0e0238ca..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/header/Header.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { usePopper } from "react-popper"; -import { Constants } from "../utils"; -import AddColumnHeader from "./AddColumnHeader"; -import DataTypeIcon from "./DataTypeIcon"; -import HeaderMenu from "./HeaderMenu"; -import { DataTypes } from "../utils"; //Unsure about importing this - -interface DataAction { - type: string; // More specific action types as string literals - payload?: any; // Be as specific as possible with the payload -} - -interface Column { - id: string | number; - created?: boolean; - label: string; - dataType: string; // You might want to use a specific union type or enum if you have a finite set of data types - getResizerProps: () => any; // Specify the correct return type if possible - getHeaderProps: () => any; // Specify the correct return type if possible -} - -interface HeaderProps { - column: Column; - setSortBy: (criteria: any) => void; // Specify the correct parameter type based on your sorting logic - dataDispatch: React.Dispatch; -} - -export default function Header({ - column: { id, created, label, dataType, getResizerProps, getHeaderProps }, - setSortBy, - dataDispatch, -}: HeaderProps) { - const [showHeaderMenu, setShowHeaderMenu] = useState(created || false); - const [headerMenuAnchorRef, setHeaderMenuAnchorRef] = useState(null); - const [headerMenuPopperRef, setHeaderMenuPopperRef] = useState(null); - const headerMenuPopper = usePopper(headerMenuAnchorRef, headerMenuPopperRef, { - placement: "bottom", - strategy: "absolute", - }); - - /* when the column is newly created, set it to open */ - useEffect(() => { - if (created) { - setShowHeaderMenu(true); - } - }, [created]); - - function getHeader() { - if (id === Constants.ADD_COLUMN_ID) { - return ; - } else if (id === Constants.CHECKBOX_COLUMN_ID) { - // Handle the checkbox column header specifically - // For example, return a simple header without the add column functionality - return ( -
-
{label}
-
- ); - } - - return ( - <> -
-
setShowHeaderMenu(true)} - ref={setHeaderMenuAnchorRef} - > - - - - {label} -
-
-
- {showHeaderMenu && ( -
setShowHeaderMenu(false)} /> - )} - {showHeaderMenu && ( - - )} - - ); - } - - return getHeader(); -} diff --git a/webviews/codex-webviews/src/EditableReactTable/header/HeaderMenu.tsx b/webviews/codex-webviews/src/EditableReactTable/header/HeaderMenu.tsx deleted file mode 100644 index fc94b6efb..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/header/HeaderMenu.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import React, { useEffect, useState } from "react"; -import ArrowUpIcon from "../img/ArrowUp"; -import ArrowDownIcon from "../img/ArrowDown"; -// import ArrowLeftIcon from '../img/ArrowLeft'; -// import ArrowRightIcon from '../img/ArrowRight'; -// import TrashIcon from '../img/Trash'; -import { grey } from "../colors"; -// import TypesMenu from './TypesMenu'; -// import { usePopper } from 'react-popper'; -import { ActionTypes, shortId } from "../utils"; -import { DataAction } from "../tableTypes"; -// import DataTypeIcon from './DataTypeIcon'; - -interface HeaderMenuProps { - label: string; - dataType: string; // Assuming dataType is used elsewhere, include it here for completeness - columnId: string; - setSortBy: (sortBy: { id: string; desc: boolean }[]) => void; - popper: any; - popperRef: React.Ref; - dataDispatch: React.Dispatch; - setShowHeaderMenu: (show: boolean) => void; -} - -export default function HeaderMenu({ - label, - dataType, - columnId, - setSortBy, - popper, - popperRef, - dataDispatch, - setShowHeaderMenu, -}: HeaderMenuProps) { - // const [inputRef] = useState(null); - - const [header, setHeader] = useState(label); - - useEffect(() => { - setHeader(label); - }, [label]); - - // useEffect(() => { - // if (inputRef) { - // inputRef.focus(); - // inputRef.select(); - // } - // }, [inputRef]); - - const buttons = [ - { - onClick: () => { - dataDispatch({ - type: ActionTypes.UPDATE_COLUMN_HEADER, - columnId, - label: header, - }); - setSortBy([{ id: columnId, desc: false }]); - setShowHeaderMenu(false); - }, - icon: , - label: "Sort ascending", - }, - { - onClick: () => { - dataDispatch({ - type: ActionTypes.UPDATE_COLUMN_HEADER, - columnId, - label: header, - }); - setSortBy([{ id: columnId, desc: true }]); - setShowHeaderMenu(false); - }, - icon: , - label: "Sort descending", - }, - ]; - - return ( -
-
-
-
-
- {buttons.map((button) => ( - - ))} -
-
-
- ); -} diff --git a/webviews/codex-webviews/src/EditableReactTable/header/TypesMenu.tsx b/webviews/codex-webviews/src/EditableReactTable/header/TypesMenu.tsx deleted file mode 100644 index ad1bd4845..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/header/TypesMenu.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from "react"; -import { ActionTypes, DataTypes, shortId } from "../utils"; -import DataTypeIcon from "./DataTypeIcon"; -import { DataAction } from "../tableTypes"; - -interface TypesMenuProps { - popper: any; - popperRef: React.Ref; - dataDispatch: React.Dispatch; - setShowTypeMenu: (show: boolean) => void; - onClose: () => void; - columnId: string; -} - -interface TypeOption { - type: DataTypes; - onClick: (event: React.MouseEvent) => void; - icon: JSX.Element; - label: string; -} - -function getLabel(type: DataTypes): string { - return type.charAt(0).toUpperCase() + type.slice(1); -} - -export default function TypesMenu({ - popper, - popperRef, - dataDispatch, - setShowTypeMenu, - onClose, - columnId, -}: TypesMenuProps) { - const types: TypeOption[] = [ - { - type: DataTypes.SELECT, - onClick: () => { - dataDispatch({ - type: ActionTypes.UPDATE_COLUMN_TYPE, - columnId, - dataType: DataTypes.SELECT, - }); - onClose(); - }, - icon: , - label: getLabel(DataTypes.SELECT), - }, - { - type: DataTypes.TEXT, - onClick: () => { - dataDispatch({ - type: ActionTypes.UPDATE_COLUMN_TYPE, - columnId, - dataType: DataTypes.TEXT, - }); - onClose(); - }, - icon: , - label: getLabel(DataTypes.TEXT), - }, - { - type: DataTypes.NUMBER, - onClick: () => { - dataDispatch({ - type: ActionTypes.UPDATE_COLUMN_TYPE, - columnId, - dataType: DataTypes.NUMBER, - }); - onClose(); - }, - icon: , - label: getLabel(DataTypes.NUMBER), - }, - ]; - - return ( -
setShowTypeMenu(true)} - onMouseLeave={() => setShowTypeMenu(false)} - {...popper.attributes.popper} - style={{ - ...popper.styles.popper, - width: 200, - backgroundColor: "white", - zIndex: 4, - }} - > - {types.map((type) => ( - - ))} -
- ); -} diff --git a/webviews/codex-webviews/src/EditableReactTable/img/ArrowDown.tsx b/webviews/codex-webviews/src/EditableReactTable/img/ArrowDown.tsx deleted file mode 100644 index 2feba363e..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/img/ArrowDown.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from "react"; - -const ArrowDown: React.FC = () => { - return ; -}; - -export default ArrowDown; diff --git a/webviews/codex-webviews/src/EditableReactTable/img/ArrowLeft.tsx b/webviews/codex-webviews/src/EditableReactTable/img/ArrowLeft.tsx deleted file mode 100644 index a626538ef..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/img/ArrowLeft.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from "react"; - -const ArrowLeft: React.FC = () => { - return ; -}; - -export default ArrowLeft; diff --git a/webviews/codex-webviews/src/EditableReactTable/img/ArrowRight.tsx b/webviews/codex-webviews/src/EditableReactTable/img/ArrowRight.tsx deleted file mode 100644 index 48734fdee..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/img/ArrowRight.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from "react"; - -const ArrowRight: React.FC = () => { - return ; -}; - -export default ArrowRight; diff --git a/webviews/codex-webviews/src/EditableReactTable/img/ArrowUp.tsx b/webviews/codex-webviews/src/EditableReactTable/img/ArrowUp.tsx deleted file mode 100644 index d76fb5b35..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/img/ArrowUp.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from "react"; - -const ArrowUp: React.FC = () => { - return ; -}; - -export default ArrowUp; diff --git a/webviews/codex-webviews/src/EditableReactTable/img/Hash.tsx b/webviews/codex-webviews/src/EditableReactTable/img/Hash.tsx deleted file mode 100644 index 80ae44353..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/img/Hash.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from "react"; - -const Hash: React.FC = () => { - return ; -}; - -export default Hash; diff --git a/webviews/codex-webviews/src/EditableReactTable/img/Multi.tsx b/webviews/codex-webviews/src/EditableReactTable/img/Multi.tsx deleted file mode 100644 index 4c827dedd..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/img/Multi.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from "react"; - -const Multi: React.FC = () => { - return ; -}; - -export default Multi; diff --git a/webviews/codex-webviews/src/EditableReactTable/img/Plus.tsx b/webviews/codex-webviews/src/EditableReactTable/img/Plus.tsx deleted file mode 100644 index 93ec92fad..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/img/Plus.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from "react"; - -const Plus: React.FC = () => { - return ; -}; - -export default Plus; diff --git a/webviews/codex-webviews/src/EditableReactTable/img/Text.tsx b/webviews/codex-webviews/src/EditableReactTable/img/Text.tsx deleted file mode 100644 index 63fe348ee..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/img/Text.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from "react"; - -const Text: React.FC = () => { - return ; -}; - -export default Text; diff --git a/webviews/codex-webviews/src/EditableReactTable/img/Trash.tsx b/webviews/codex-webviews/src/EditableReactTable/img/Trash.tsx deleted file mode 100644 index e1aa54888..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/img/Trash.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from "react"; - -const Trash: React.FC = () => { - return ; -}; - -export default Trash; diff --git a/webviews/codex-webviews/src/EditableReactTable/index.tsx b/webviews/codex-webviews/src/EditableReactTable/index.tsx deleted file mode 100644 index 99ad76999..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom"; -// import "antd/dist/reset.css"; - -import App from "./App"; - -ReactDOM.render( - - - , - document.getElementById("root") -); diff --git a/webviews/codex-webviews/src/EditableReactTable/scrollbarWidth.ts b/webviews/codex-webviews/src/EditableReactTable/scrollbarWidth.ts deleted file mode 100644 index 112bf4342..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/scrollbarWidth.ts +++ /dev/null @@ -1,13 +0,0 @@ -const scrollbarWidth = (): number => { - const scrollDiv = document.createElement("div"); - scrollDiv.setAttribute( - "style", - "width: 100px; height: 100px; overflow: scroll; position:absolute; top:-9999px;" - ); - document.body.appendChild(scrollDiv); - const scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth; - document.body.removeChild(scrollDiv); - return scrollbarWidth; -}; - -export default scrollbarWidth; diff --git a/webviews/codex-webviews/src/EditableReactTable/style.css b/webviews/codex-webviews/src/EditableReactTable/style.css deleted file mode 100644 index 1e7655f91..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/style.css +++ /dev/null @@ -1,514 +0,0 @@ -/* @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); */ - -html, body, #root { - height: 100%; /* Full height */ - margin: 0; - padding: 0; - overflow: hidden; -} - -/* html { - box-sizing: border-box; -} */ - -*, -*:before, -*:after { - box-sizing: inherit; -} - -* { - margin: 0; - padding: 0; - font-family: var(--vscode-font-family, 'Inter', sans-serif); - color: var(--vscode-editor-foreground); - background-color: var(--vscode-editor-background); -} - -/* #root { - margin: 0px; - padding: 0px; -} */ - -.transition-fade-enter { - opacity: 0; -} - -.transition-fade-enter-active { - opacity: 1; - transition: opacity 300ms; -} - -.transition-fade-exit { - opacity: 1; -} - -.transition-fade-exit-active { - opacity: 0; - transition: opacity 300ms; -} - -.svg-icon svg { - position: relative; - height: 1.5em; /*remove?*/ - width: 1.5em; /*remove?*/ - /* top: 0.125rem; */ - background-color: var(--vscode-button-background); - fill: var(--vscode-button-foreground); - display: flex; - align-items: center; -} - -.svg-text svg { - stroke: #424242; -} - -.svg-180 svg { - transform: rotate(180deg); -} - -.form-input { - padding: 0.375rem; - /* background-color: #eeeeee; */ - background-color: var(--vscode-input-background); - /* border: none; */ - border: 1px solid var(--vscode-input-border); - /* border-radius: 4px; */ - border-radius: var(--vscode-input-borderRadius); - font-size: 0.875rem; - /* color: #424242; */ - color: var(--vscode-input-foreground); -} - -.form-input:focus { - outline: none; - /* box-shadow: 0 0 1px 2px #8ecae6; */ - box-shadow: 0 0 1px 2px var(--vscode-focusBorder); -} - -.is-fullwidth { - width: 100%; -} - -.bg-white { - /* background-color: white; */ - background-color: var(--vscode-editor-background); -} - -.data-input { - white-space: pre-wrap; - border: none; - /* border: 1px solid var(--vscode-input-border); */ - padding: 0.5rem; - /* color: #424242; */ - color: var(--vscode-input-foreground); - font-size: 1rem; - /* border-radius: 4px; */ - border-radius: var(--vscode-input-borderRadius); - resize: none; - /* background-color: white; */ - background-color: var(--vscode-input-background); - box-sizing: border-box; - flex: 1 1 auto; -} - -.data-input:focus { - outline: none; - /* box-shadow: 0 0 0 2px var(--vscode-focusBorder); */ -} - -.shadow-5 { - /* box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.12), - 0 4px 6px rgba(0, 0, 0, 0.12), 0 8px 16px rgba(0, 0, 0, 0.12), - 0 16px 32px rgba(0, 0, 0, 0.12); */ - box-shadow: var(--vscode-shadow); -} - -.svg-icon-sm svg { - position: relative; - height: 1rem; - width: 1rem; - top: 0.125rem; -} - -.svg-gray svg { - stroke: #ffffff; -} - -.option-input { - width: 100%; - font-size: 1rem; - border: none; - /* border: 1px solid var(--vscode-input-border); */ - background-color: transparent; - /* background-color: var(--vscode-input-background); */ -} - -.option-input:focus { - outline: none; - /* box-shadow: 0 0 0 2px var(--vscode-focusBorder); */ -} - -.noselect { - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -.overlay { - position: fixed; - top: 0; - left: 0; - height: 100vh; - width: 100vw; - z-index: 2; - overflow: hidden; - background-color: rgba(0, 0, 0, 0.5); -} - -.sort-button { - padding: 0.25rem 0.75rem; - width: 100%; - background-color: transparent; - border: 0; - font-size: 0.875rem; - /* color: #757575; */ - color: var(--vscode-button-foreground); - cursor: pointer; - text-align: left; - display: flex; - align-items: center; -} - -.sort-button:hover { - /* background-color: #eeeeee; */ - background-color: var(--vscode-button-hoverBackground); -} - -.search-bar { - margin-bottom: 20px; - padding: 8px; - font-size: 16px; - border: 1px solid var(--vscode-input-border); - border-radius: 4px; - background-color: var(--vscode-input-background); - color: var(--vscode-input-foreground); - width: 100%; - box-sizing: border-box; -} - -.remove-button { - /* position: absolute; - top: 0; */ - /*right: 0; */ - /* bottom: 0; */ - /* left: 0; */ - background: none; - border: none; - cursor: pointer; - padding: 5px; /* Adjust padding as needed */ - display: flex; - justify-content: center; - align-items: center; - z-index: 10; /* Ensures the button is above the table */ -} - -.remove-button svg { - height: 36px; /* Adjust size as needed */ - width: 36px; /* Adjust size as needed */ - margin-right: 20px; - fill: var( - --vscode-button-foreground - ); /* Use VS Code theme color for the icon */ -} - -.remove-button:disabled { - cursor: not-allowed; - opacity: 0.5; -} - -.remove-button:hover:not(:disabled) { - background-color: var( - --vscode-button-hoverBackground - ); /* Use VS Code theme color for hover state */ -} - -.app-container { - display: flex; - flex-direction: column; - height: 100%; - /*888 below*/ - overflow-y: auto; - margin-bottom: 70px; - flex: 1; - width: 100%; -} - -.table { - display: flex; - flex-flow: column; - margin-top: 60px; -} - -.table-header { - display: flex; - flex-flow: column; - position: absolute; - z-index: 1001; -} - -.table-container { - /* overflow-y: auto; 888 */ - margin-bottom: 40px; - flex: 1; - width: 100%; -} - - -/* .remove-button-container { - /* position: absolute; */ - /* right: 0; Aligns the button to the right */ - /* top: -50px; Adjusts the vertical position to float above the table */ - /* z-index: 10; Ensures the button is above the table */ - -.checkbox-container { - display: flex; - align-items: center; - justify-content: center; /* Center horizontally if needed */ - height: 100%; /* Ensure the container fills the cell */ -} - -.checkbox-large { - transform: scale(2.5); /* Adjust scale as needed */ - margin: 5px; /* Optional: add some margin if needed */ -} - -.add-row { - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); - padding: 0.5rem; - display: flex; - align-items: center; - justify-content: center; - font-size: 0.875rem; - cursor: pointer; - height: 30px; - /* border: 1px solid #e0e0e0; */ - border: 1px solid var(--vscode-button-border); - border-radius: 4px; - transition: background-color 0.3s ease; - - - position: fixed; - bottom: 0; - left: 0; - right: 0; - z-index: 10; /* Ensures the button is above the table */ -} - -.add-row:hover { - /* background-color: #f5f5f5; */ - background-color: var(--vscode-button-hoverBackground); -} - - - -.resizer { - display: inline-block; - background: transparent; - width: 8px; - height: 100%; - position: absolute; - right: 0; - top: 0; - transform: translateX(50%); - z-index: 1; - cursor: col-resize; - touch-action: none; -} - -.resizer:hover { - /* background-color: #8ecae6; */ - background-color: var(--vscode-editor-selectionBackground); -} - -h1 { - font-size: 2rem; /* Adjust font size */ - color: var(--vscode-editor-foreground); /* Use VS Code theme color */ -} - -.tr { - display: flex; - align-items: center; - margin: 0; /* Remove margin between rows */ -} - -.td, .th { - padding: 1px; /* Minimal padding */ - margin: 0; /* Remove margin between cells */ - border-right: 1px solid var(--vscode-editor-lineHighlightBackground); /* Ensure consistent border */ - border-bottom: 1px solid var(--vscode-editor-lineHighlightBackground); /* Ensure consistent border */ - white-space: nowrap; - position: relative; - color: var(--vscode-editor-foreground); - background-color: var(--vscode-editorWidget-background); - font-weight: 500; - font-size: 1.0rem; /*0.875rem;*/ - cursor: pointer; -} - -.td:last-child, .th:last-child { - border-right: none; /* Remove right border for the last cell in each row */ -} - -.tr:last-child .td { - border-bottom: none; /* Remove bottom border for the last row */ -} - -.td-content, .th-content { - display: block; - padding: 1px; /* Minimal padding */ - overflow-x: hidden; - text-overflow: ellipsis; - display: flex; - align-items: center; - height: 50px; -} - -.th:hover { - background-color: var(--vscode-list-hoverBackground); -} - - - -.text-align-right { - text-align: right; -} - -.cell-padding { - padding: 0.5rem; -} - -.d-flex { - display: flex; -} - -.d-inline-block { - display: inline-block; -} - -.cursor-default { - cursor: default; -} - -.align-items-center { - align-items: center; -} - -.flex-wrap-wrap { - flex-wrap: wrap; -} - -.border-radius-md { - border-radius: 5px; -} - -.cursor-pointer { - cursor: pointer; -} - -.icon-margin { - margin-right: 4px; -} - -.font-weight-600 { - font-weight: 600; -} - -.font-weight-400 { - font-weight: 400; -} - -.font-size-75 { - font-size: 0.75rem; -} - -.flex-1 { - flex: 1; -} - -.mt-5 { - margin-top: 0.5rem; -} - -.mr-auto { - margin-right: auto; -} - -.ml-auto { - margin-left: auto; -} - -.mr-5 { - margin-right: 0.5rem; -} - -.justify-content-center { - justify-content: center; -} - -.flex-column { - flex-direction: column; -} - -.overflow-auto { - overflow: auto; -} - -.overflow-hidden { - overflow: hidden; -} - -.overflow-y-hidden { - overflow-y: hidden; -} - -.list-padding { - padding: 4px 0px; - border: 1px solid var(--vscode-editor-lineHighlightBackground); -} - -.bg-grey-200 { - /* background-color: #eeeeee; */ - background-color: var(--vscode-editor-background); -} - -.color-grey-800 { - /* color: #424242; */ - color: var(--vscode-editor-foreground); -} - -.color-grey-600 { - /* color: #757575; */ - color: var(--vscode-editorWidget-foreground); -} - -.color-grey-500 { - /* color: #9e9e9e; */ - color: var(--vscode-editor-inactiveSelectionBackground); -} - -.border-radius-sm { - border-radius: 4px; -} - -.text-transform-uppercase { - text-transform: uppercase; -} - -.text-transform-capitalize { - text-transform: capitalize; -} diff --git a/webviews/codex-webviews/src/EditableReactTable/tableTypes.d.ts b/webviews/codex-webviews/src/EditableReactTable/tableTypes.d.ts deleted file mode 100644 index 1974b121d..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/tableTypes.d.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { ActionTypes } from "./utils"; - -type TableColumn = { - id?: - | "headWord" - | "id" - | "hash" - | "definition" - | "translationEquivalents" - | "links" - | "linkedEntries" - | "metadata" - | "notes" - | "extra" - | "checkbox_column"; - label?: string; - accessor?: string; - minWidth?: number; - width?: number; - maxWidth?: number; - dataType?: string; // Could be more specific if there are only certain values allowed - options?: any[]; // Define this more specifically if possible - Cell?: any; - Header?: any; - sortType?: string; - visible?: boolean; -}; -// type DictionaryTableColumn = TableColumn & { -// id: -// | 'headWord' -// | 'id' -// | 'hash' -// | 'definition' -// | 'translationEquivalents' -// | 'links' -// | 'linkedEntries' -// | 'metadata' -// | 'notes' -// | 'extra' -// | 'checkbox_column'; -// }; - -type TableEntry = { - metadata: string | Record; // Assuming metadata can be string or object - dataDispatch?: React.Dispatch | undefined; - [key: string]: any; // For additional properties -}; - -type TableData = { - columns: TableColumn[]; - data: TableEntry[]; - dispatch?: React.Dispatch; - skipReset?: boolean; -}; - -type CellData = { - value: any; - row: RowData; - column: ColumnData; - dataDispatch: React.Dispatch; -}; - -type CellTypeData = { - initialValue: any; - options?: { label: string; backgroundColor: string }[]; // For SelectCell - rowIndex: number; - columnId: string; - dataDispatch?: React.Dispatch; - // dataDispatch: React.Dispatch; -}; - -interface DataAction { - type: ActionTypes; - columnId: string; - label?: string; - rowIndex?: number; - value?: any; - dataType?: any; - option?: any; - backgroundColor?: string; -} - -// enum DataTypes { -// NUMBER = 'number', -// TEXT = 'text', -// SELECT = 'select', -// CHECKBOX = 'checkbox', -// }; - -type RowData = { - index: number; -}; - -type ColumnData = { - id: string; - dataType: string; - options: any[]; -}; - -type ValueState = { - value: any; - update: boolean; -}; - -// declare module 'react-table' { -// export const useTable: any; -// export const useBlockLayout: any; -// export const useResizeColumns: any; -// export const useSortBy: any; -// // Add other exports as needed -// } - -// declare module 'react-window' { -// export const FixedSizeList: any; -// } diff --git a/webviews/codex-webviews/src/EditableReactTable/utilities/vscode.ts b/webviews/codex-webviews/src/EditableReactTable/utilities/vscode.ts deleted file mode 100644 index d8d9f969c..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/utilities/vscode.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { WebviewApi } from "vscode-webview"; - -/** - * A utility wrapper around the acquireVsCodeApi() function, which enables - * message passing and state management between the webview and extension - * contexts. - * - * This utility also enables webview code to be run in a web browser-based - * dev server by using native web browser features that mock the functionality - * enabled by acquireVsCodeApi. - */ -class VSCodeAPIWrapper { - private readonly vsCodeApi: WebviewApi | undefined; - - constructor() { - // Check if the acquireVsCodeApi function exists in the current development - // context (i.e. VS Code development window or web browser) - if (typeof acquireVsCodeApi === "function") { - this.vsCodeApi = acquireVsCodeApi(); - } - } - - /** - * Post a message (i.e. send arbitrary data) to the owner of the webview. - * - * @remarks When running webview code inside a web browser, postMessage will instead - * log the given message to the console. - * - * @param message Abitrary data (must be JSON serializable) to send to the extension context. - */ - public postMessage(message: unknown) { - if (this.vsCodeApi) { - this.vsCodeApi.postMessage(message); - } else { - console.log(message); - } - } - - /** - * Get the persistent state stored for this webview. - * - * @remarks When running webview source code inside a web browser, getState will retrieve state - * from local storage (https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). - * - * @return The current state or `undefined` if no state has been set. - */ - public getState(): unknown | undefined { - if (this.vsCodeApi) { - return this.vsCodeApi.getState(); - } else { - const state = localStorage.getItem("vscodeState"); - return state ? JSON.parse(state) : undefined; - } - } - - /** - * Set the persistent state stored for this webview. - * - * @remarks When running webview source code inside a web browser, setState will set the given - * state using local storage (https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). - * - * @param newState New persisted state. This must be a JSON serializable object. Can be retrieved - * using {@link getState}. - * - * @return The new state. - */ - public setState(newState: T): T { - if (this.vsCodeApi) { - return this.vsCodeApi.setState(newState); - } else { - localStorage.setItem("vscodeState", JSON.stringify(newState)); - return newState; - } - } -} - -// Exports class singleton to prevent multiple invocations of acquireVsCodeApi. -export const vscode = new VSCodeAPIWrapper(); diff --git a/webviews/codex-webviews/src/EditableReactTable/utils.ts b/webviews/codex-webviews/src/EditableReactTable/utils.ts deleted file mode 100644 index cd91165fb..000000000 --- a/webviews/codex-webviews/src/EditableReactTable/utils.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Dictionary, DictionaryEntry } from "codex-types"; -import { TableColumn, TableData, TableEntry } from "./tableTypes"; - -export enum ActionTypes { - ADD_OPTION_TO_COLUMN = "add_option_to_column", - ADD_ROW = "add_row", - UPDATE_COLUMN_TYPE = "update_column_type", - UPDATE_COLUMN_HEADER = "update_column_header", - UPDATE_CELL = "update_cell", - ADD_COLUMN_TO_LEFT = "add_column_to_left", - ADD_COLUMN_TO_RIGHT = "add_column_to_right", - DELETE_COLUMN = "delete_column", - ENABLE_RESET = "enable_reset", - LOAD_DATA = "loaddata", - REMOVE_CHECKED_ROWS = "remove_checked_rows", - RESIZE_COLUMN_WIDTHS = "resize_column_widths", - //test - // RESIZE_COLUMN = 'resize_column', - //endtest -} - -export enum DataTypes { - NUMBER = "number", - TEXT = "text", - SELECT = "select", - CHECKBOX = "checkbox", -} - -export const Constants = { - ADD_COLUMN_ID: 999999, - CHECKBOX_COLUMN_ID: "checkbox_column", -}; - -export function shortId(): string { - return "_" + Math.random().toString(36).substr(2, 9); -} - -export function randomColor(): string { - return `hsl(${Math.floor(Math.random() * 360)}, 95%, 90%)`; -} - -export function transformToTableData(dictionary: Dictionary): TableData { - // const data = dictionary.entries; - const data = dictionary.entries.map((entry) => ({ - ...entry, - metadata: - typeof entry.metadata === "string" ? entry.metadata : JSON.stringify(entry.metadata), // Only stringify if not already a string - })); - - let columns: TableColumn[] = []; - const checkboxColumn: TableColumn = { - // id: Constants.ADD_COLUMN_ID, - id: Constants.CHECKBOX_COLUMN_ID as TableColumn["id"], - label: " ", - accessor: "checkbox_column", - minWidth: 40, - width: 40, - // disableResizing: true, - dataType: DataTypes.CHECKBOX, - }; - - // Create columns in required format and according to the first entry in the data - if (data.length > 0) { - const firstEntry = data[0]; - columns = Object.keys(firstEntry).map((key) => ({ - id: key as TableColumn["id"], - label: - key - .replace(/([A-Z])/g, " $1") - .charAt(0) - .toUpperCase() + key.replace(/([A-Z])/g, " $1").slice(1), // Capitalize the first letter and insert space before each capital letter - accessor: key, - minWidth: 200, - dataType: DataTypes.TEXT, // Default to TEXT, adjust based on your needs - options: [], - })); - // Add the scroll column - columns.push(checkboxColumn); - } - - return { columns, data, skipReset: false }; -} - -export function transformToDictionaryFormat( - tableData: TableData, - dictionary: Dictionary -): Dictionary { - // Place row entries back into the dictionary - // dictionary.entries = tableData.data; - // Modify here to remove checkbox data from tableData.data - - dictionary.entries = tableData.data.map((row: TableEntry) => { - const newRow = { ...row }; - delete newRow[Constants.CHECKBOX_COLUMN_ID]; // Key for checkbox data - return newRow as unknown as DictionaryEntry; - }); - return dictionary; -} diff --git a/webviews/codex-webviews/src/NavigationView/__tests__/NavigationView.test.tsx b/webviews/codex-webviews/src/NavigationView/__tests__/NavigationView.test.tsx index b61504bfd..815b6eb03 100644 --- a/webviews/codex-webviews/src/NavigationView/__tests__/NavigationView.test.tsx +++ b/webviews/codex-webviews/src/NavigationView/__tests__/NavigationView.test.tsx @@ -111,29 +111,6 @@ describe("NavigationView Sort Order Toggle", () => { expect(sortedDesc[2].label).toBe("Alpha Codex"); }); - it("sorts both codex and dictionary items with the same sort order", () => { - const codexItems: CodexItem[] = [ - { label: "Zebra", uri: "file:///zebra", type: "codexDocument" }, - { label: "Alpha", uri: "file:///alpha", type: "codexDocument" }, - ]; - - const dictionaryItems: CodexItem[] = [ - { label: "Zebra Dict", uri: "file:///zebra-dict", type: "dictionary" }, - { label: "Alpha Dict", uri: "file:///alpha-dict", type: "dictionary" }, - ]; - - const sortOrder = "asc"; - const sortComparisonFn = (a: CodexItem, b: CodexItem) => sortComparison(a, b, sortOrder); - - const sortedCodex = [...codexItems].sort(sortComparisonFn); - const sortedDictionary = [...dictionaryItems].sort(sortComparisonFn); - - expect(sortedCodex[0].label).toBe("Alpha"); - expect(sortedCodex[1].label).toBe("Zebra"); - expect(sortedDictionary[0].label).toBe("Alpha Dict"); - expect(sortedDictionary[1].label).toBe("Zebra Dict"); - }); - it("maintains sort order when toggling multiple times", () => { const items = createMockCodexItems(); let sortOrder: "asc" | "desc" = "asc"; diff --git a/webviews/codex-webviews/src/NavigationView/index.tsx b/webviews/codex-webviews/src/NavigationView/index.tsx index d5a17cff6..cb63a5a66 100644 --- a/webviews/codex-webviews/src/NavigationView/index.tsx +++ b/webviews/codex-webviews/src/NavigationView/index.tsx @@ -26,7 +26,6 @@ interface BibleBookInfo { interface State { codexItems: CodexItem[]; - dictionaryItems: CodexItem[]; expandedGroups: Set; previousExpandedGroups: Set | null; searchQuery: string; @@ -170,7 +169,6 @@ const formatLabel = (label: string, bibleBookMap: Map): s function NavigationView() { const [state, setState] = useState({ codexItems: [], - dictionaryItems: [], expandedGroups: new Set(), previousExpandedGroups: null, searchQuery: "", @@ -267,7 +265,6 @@ function NavigationView() { return { ...prevState, codexItems: processedCodexItems, - dictionaryItems: message.dictionaryItems || [], hasReceivedInitialData: true, }; }); @@ -301,7 +298,6 @@ function NavigationView() { if (!state.searchQuery) return; // Only run when there's a search query const filteredCodexItems = filterItems(state.codexItems); - const filteredDictionaryItems = filterItems(state.dictionaryItems); // Calculate total number of visible items if all groups were expanded let totalItems = 0; @@ -313,13 +309,6 @@ function NavigationView() { } }); - filteredDictionaryItems.forEach((item) => { - totalItems += 1; - if (item.type === "corpus" && item.children) { - totalItems += item.children.length; - } - }); - // Estimate if items will fit without scrolling // Assuming each item takes about 50px height and container has about 400px usable height const estimatedHeight = totalItems * 50; @@ -337,20 +326,13 @@ function NavigationView() { } }); - // Expand dictionary groups with results - filteredDictionaryItems.forEach((item) => { - if (item.type === "corpus" && item.children && item.children.length > 0) { - newExpandedGroups.add(item.label); - } - }); - return { ...prev, expandedGroups: newExpandedGroups, }; }); } - }, [state.searchQuery, state.codexItems, state.dictionaryItems]); + }, [state.searchQuery, state.codexItems]); const toggleGroup = (label: string) => { setState((prevState) => { @@ -410,12 +392,6 @@ function NavigationView() { }); }; - const handleToggleDictionary = () => { - vscode.postMessage({ - command: "toggleDictionary", - }); - }; - const handleAddFiles = () => { vscode.postMessage({ command: "openSourceUpload", @@ -632,13 +608,11 @@ function NavigationView() { const renderItem = (item: CodexItem) => { const isGroup = item.type === "corpus"; const isExpanded = state.expandedGroups.has(item.label); - const icon = isGroup ? "library" : item.type === "dictionary" ? "book" : "file"; + const icon = isGroup ? "library" : "file"; const displayLabel = item.fileDisplayName || formatLabel(item.label || "", state.bibleBookMap || new Map()); const itemId = `${item.label || "unknown"}-${item.uri || ""}`; - const isProjectDict = item.isProjectDictionary; - // Handle click on the entire item container const handleItemClick = (e: React.MouseEvent) => { // Don't trigger if clicking on menu button @@ -653,58 +627,6 @@ function NavigationView() { } }; - // Special rendering for project dictionary - if (isProjectDict) { - return ( -
-
-
-
-
-
- - Dictionary -
-
- - {item.wordCount || 0} - - - {item.isEnabled ? "ON" : "OFF"} -
-
-
- - -
-
-
- ); - } - const progressValues = getProgressValues(item.progress); const hasProgress = item.progress && typeof item.progress === "object"; const hasAudio = progressValues.audioCompletion > 0 || progressValues.audioValidation > 0; @@ -838,13 +760,11 @@ function NavigationView() { }; const filteredCodexItems = filterItems(state.codexItems); - const filteredDictionaryItems = filterItems(state.dictionaryItems); const sortComparison = (a: CodexItem, b: CodexItem) => { const comparison = a.label.localeCompare(b.label); return sortOrder === "asc" ? comparison : -comparison; }; filteredCodexItems.sort(sortComparison); - filteredDictionaryItems.sort(sortComparison); const renameTestamentAbbreviations = (fileName: string, hasBibleBookMap: boolean): string => { if (hasBibleBookMap) { @@ -886,10 +806,6 @@ function NavigationView() { return !state.bookNameModal.newName.trim(); }, [state.bookNameModal.newName]); - // Separate project dictionary from other dictionaries - const projectDictionary = filteredDictionaryItems.find((item) => item.isProjectDictionary); - const otherDictionaries = filteredDictionaryItems.filter((item) => !item.isProjectDictionary); - return (
@@ -927,11 +843,10 @@ function NavigationView() {
{(() => { - if (filteredCodexItems.length > 0 || otherDictionaries.length > 0) { + if (filteredCodexItems.length > 0) { return ( <> {filteredCodexItems.map(renderItem)} - {otherDictionaries.map(renderItem)} ); } @@ -975,8 +890,6 @@ function NavigationView() {
- {/* Project Dictionary */} - {projectDictionary && renderItem(projectDictionary)}
{/* Corpus Marker Modal */} diff --git a/webviews/codex-webviews/src/components/ChatInputTextForm.tsx b/webviews/codex-webviews/src/components/ChatInputTextForm.tsx deleted file mode 100644 index 98ce2c606..000000000 --- a/webviews/codex-webviews/src/components/ChatInputTextForm.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React from "react"; -import { VSCodeButton, VSCodeTextArea, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"; -import { ContextItemList } from "./ContextItemList"; - -type CommentTextFormProps = { - handleSubmit: (comment: string) => void; - contextItems: string[]; - selectedText: string; - vscode: any; - sourceCellMap: { [k: string]: { content: string; versions: string[] } }; -}; - -export const ChatInputTextForm: React.FC = ({ - handleSubmit, - contextItems, - selectedText, - vscode, - sourceCellMap, -}) => { - return ( -
{ - e.preventDefault(); - const formData = new FormData(e.target as HTMLFormElement); - const formValue = formData.get("chatInput") as string; - handleSubmit(formValue); - (e.target as HTMLFormElement).reset(); - }} - > -
- - {selectedText && ( -
- - -
- )} -
-
- {/* console.log('Attach clicked')} - > - - */} - - - - - {/* console.log('Record clicked')} - > - - */} -
- - ); -}; diff --git a/webviews/codex-webviews/src/components/CloseButtonWithConfirmation.tsx b/webviews/codex-webviews/src/components/CloseButtonWithConfirmation.tsx deleted file mode 100644 index b0239757a..000000000 --- a/webviews/codex-webviews/src/components/CloseButtonWithConfirmation.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React, { useState } from "react"; -import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; - -const CloseButtonWithConfirmation: React.FC<{ - handleDeleteButtonClick: () => void; -}> = ({ handleDeleteButtonClick }) => { - const [showConfirmation, setShowConfirmation] = useState(false); - - const handleDelete = () => { - setShowConfirmation(true); - }; - - const confirmDelete = () => { - handleDeleteButtonClick(); - setShowConfirmation(false); - }; - - const cancelDelete = () => { - setShowConfirmation(false); - }; - - return ( -
- {!showConfirmation ? ( - - - - ) : ( -
- - - - - - - -
- )} -
- ); -}; - -export default CloseButtonWithConfirmation; diff --git a/webviews/codex-webviews/src/components/ContextItemList.tsx b/webviews/codex-webviews/src/components/ContextItemList.tsx deleted file mode 100644 index 6cef64ecd..000000000 --- a/webviews/codex-webviews/src/components/ContextItemList.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import React, { useState } from "react"; -import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"; - -type ContextItemListProps = { - contextItems: string[]; - vscode: any; - sourceCellMap: { [k: string]: { content: string; versions: string[] } }; -}; - -export const ContextItemList: React.FC = ({ - contextItems, - vscode, - sourceCellMap, -}) => { - const [isCollapsed, setIsCollapsed] = useState(true); - - const toggleCollapse = () => setIsCollapsed(isCollapsed); - - const openContextItem = (item: string) => { - vscode.postMessage({ - command: "openContextItem", - text: item, - // FIXME: I'm just going to check the string for 'Notes' or 'Questions' respectively, and open the webview for the appropriate one. - // This is a serious hack. I should be passing the context item type as well, and that should be passed in from a more sophisticated context object other than a string. - }); - }; - - const renderSourceCellContent = (cellId: string, cellContent: string) => { - if (cellContent) { - return ( -
- {cellId}: {cellContent.slice(0, 100)}... -
- ); - } - return null; - }; - - return ( - (contextItems.length > 0 || Object.keys(sourceCellMap).length > 0) && ( -
- - - {isCollapsed && contextItems.length > 0 - ? "Source Cell Content" - : "Hide Source Cell Content"} - - {!isCollapsed && ( -
- {contextItems.map((item, index) => ( -
- openContextItem(item)} - > - {item.length > 50 ? `${item.substring(0, 47)}...` : item} - -
- ))} - {Object.entries(sourceCellMap).map(([cellId, cellData]) => ( -
- openContextItem(cellId)}> - Source Cell: {cellId} - - {renderSourceCellContent(cellId, cellData.content)} -
- ))} -
- )} -
- ) - ); -}; diff --git a/webviews/codex-webviews/src/components/EditAndDeleteOptions.tsx b/webviews/codex-webviews/src/components/EditAndDeleteOptions.tsx deleted file mode 100644 index 6c43a47d1..000000000 --- a/webviews/codex-webviews/src/components/EditAndDeleteOptions.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from "react"; -import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; -import DeleteButtonWithConfirmation from "./DeleteButtonWithConfirmation"; - -const EditAndDeleteOptions: React.FC<{ - handleDeleteButtonClick: () => void; - handleEditButtonClick: (editModeIsEnabled: boolean) => void; -}> = ({ handleDeleteButtonClick: handleCommentDeletion }) => { - return ( -
- - - - handleCommentDeletion()} /> -
- ); -}; - -export default EditAndDeleteOptions; diff --git a/webviews/codex-webviews/src/components/MessageItem.tsx b/webviews/codex-webviews/src/components/MessageItem.tsx deleted file mode 100644 index cd522e39f..000000000 --- a/webviews/codex-webviews/src/components/MessageItem.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import React, { useState } from "react"; -import { ChatMessageWithContext } from "../../../../types"; -import { VSCodeTag } from "@vscode/webview-ui-toolkit/react"; -import { ChatRoleLabel } from "../common"; - -interface MessageItemProps { - messageItem: ChatMessageWithContext; - showSenderRoleLabels?: boolean; - onEditComplete?: (updatedMessage: ChatMessageWithContext) => void; // Callback for edit completion. -} - -const ALWAYS_SHOW = false; - -export const MessageItem: React.FC = ({ - messageItem, - showSenderRoleLabels = false, - onEditComplete, // Callback function to notify parent of edit completion -}) => { - const [isHovered, setIsHovered] = useState(false); - const [isDropdownVisible, setIsDropdownVisible] = useState(false); - const [isEditing, setIsEditing] = useState(false); - const [editedContent, setEditedContent] = useState(messageItem.content); // Edited content state - - const handleMouseEnter = () => setIsHovered(true); - const handleMouseLeave = () => { - setIsHovered(false); - setIsDropdownVisible(false); - }; - const toggleDropdown = () => setIsDropdownVisible(!isDropdownVisible); - - const handleEditClick = () => { - setIsEditing(true); - setIsDropdownVisible(false); // close dropdown - setEditedContent(messageItem.content); // Reset edited content - }; - - const handleChange = (event: React.ChangeEvent) => { - setEditedContent(event.target.value); // Update edited content - }; - - const handleSaveClick = () => { - // Notify parent component if exists - if (onEditComplete) { - onEditComplete({ ...messageItem, content: editedContent }); // Pass edited message back - } - setIsEditing(false); // Exit edit mode - }; - - return ( -
- {(messageItem.role === "user" || messageItem.role === "assistant") && ( -
- {new Date(messageItem.createdAt).toLocaleTimeString()}{" "} - {/* FIXME: add actual timestamps */} -
- )} -
- {showSenderRoleLabels && ( - - {ChatRoleLabel[messageItem.role as keyof typeof ChatRoleLabel]} - - )} - {/* Message Content */} - {isEditing ? ( -
- - {/* Check-Mark to Save */} - - ✔️ - -
- ) : ( -
{messageItem.content}
- )} -
- - {(isHovered || ALWAYS_SHOW) && ( -
- {/* Replace with your dropdown icon */} - {/* Placeholder icon */} -
- )} - - {/* Dropdown Menu */} - {isDropdownVisible && ( -
-
- Edit -
-
- )} -
- ); -}; diff --git a/webviews/codex-webviews/src/components/PasswordRequirementsChecker.tsx b/webviews/codex-webviews/src/components/PasswordRequirementsChecker.tsx deleted file mode 100644 index 8c075f0ec..000000000 --- a/webviews/codex-webviews/src/components/PasswordRequirementsChecker.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import React from 'react'; - -export interface PasswordRequirement { - id: string; - label: string; - validator: (password: string) => boolean; - description?: string; -} - -interface PasswordRequirementsCheckerProps { - password: string; - requirements: PasswordRequirement[]; - showRequirements?: boolean; -} - -export const PasswordRequirementsChecker: React.FC = ({ - password, - requirements, - showRequirements = true, -}) => { - if (!showRequirements) { - return null; - } - - const allRequirementsMet = requirements.every(req => req.validator(password)); - - return ( -
-
- Password Requirements: -
-
- {requirements.map((requirement) => { - const isMet = requirement.validator(password); - return ( -
- - {isMet ? '✓' : ''} - - - {requirement.label} - {requirement.description && ( - - {requirement.description} - - )} - -
- ); - })} -
- - {/* Overall strength indicator */} -
-
- - Password Strength - - 0 - ? 'var(--vscode-problemsWarningIcon-foreground)' - : 'var(--vscode-textBlockQuote-background)', - color: allRequirementsMet || password.length > 0 - ? 'var(--vscode-editor-background)' - : 'var(--vscode-descriptionForeground)', - }} - > - {allRequirementsMet ? "Strong" : password.length > 0 ? "Weak" : "Empty"} - -
-
-
req.validator(password)).length / requirements.length) * 100, 100)}%`, - height: '100%', - background: allRequirementsMet - ? 'var(--vscode-testing-iconPassed)' - : password.length > 0 - ? 'var(--vscode-problemsWarningIcon-foreground)' - : 'var(--vscode-textBlockQuote-background)', - transition: 'width 0.3s ease-in-out', - }} - /> -
-
-
- ); -}; - -// Standard GitLab-like password requirements -export const createStandardPasswordRequirements = (): PasswordRequirement[] => [ - { - id: 'length', - label: 'At least 15 characters', - validator: (password: string) => password.length >= 15, - }, - { - id: 'uppercase', - label: 'At least one uppercase letter', - validator: (password: string) => /[A-Z]/.test(password), - }, - { - id: 'lowercase', - label: 'At least one lowercase letter', - validator: (password: string) => /[a-z]/.test(password), - }, -]; - -// Utility function to validate password against requirements -export const validatePasswordRequirements = ( - password: string, - requirements: PasswordRequirement[] -): { isValid: boolean; unmetRequirements: string[] } => { - const unmetRequirements = requirements - .filter(req => !req.validator(password)) - .map(req => req.label); - - return { - isValid: unmetRequirements.length === 0, - unmetRequirements, - }; -}; \ No newline at end of file diff --git a/webviews/codex-webviews/src/components/VerseRefNavigation.tsx b/webviews/codex-webviews/src/components/VerseRefNavigation.tsx deleted file mode 100644 index e48707242..000000000 --- a/webviews/codex-webviews/src/components/VerseRefNavigation.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"; -import bibleBooksLookup from "../assets/bible-books-lookup.json"; - -interface VerseRefNavigationProps { - verseRef: string; // Expected format: "Book Chapter:Verse" - callback: (updatedVerseRef: string) => void; -} - -const VerseRefNavigation: React.FC = ({ verseRef, callback }) => { - // Split the verseRef into book, chapter, and verse - const [book, chapterAndVerse] = verseRef.split(" "); - const [chapter, verse] = chapterAndVerse.split(":"); - useEffect(() => { - setSelectedBook(book); - setSelectedChapter(chapter); - setSelectedVerse(verse); - }, [verseRef]); - - const [selectedBook, setSelectedBook] = useState(book); - const [selectedChapter, setSelectedChapter] = useState(chapter); - const [selectedVerse, setSelectedVerse] = useState(verse); - - const booksOfTheBible = bibleBooksLookup.map((book) => book.abbr); - const bookData = bibleBooksLookup.find((b) => b.abbr === selectedBook); - const chaptersBasedOnBook = bookData ? Object.keys(bookData.chapters).map(Number) : []; - - // @ts-expect-error Selected chapter will always match the chapter data - const verserInChapter = bookData.chapters[selectedChapter]; - const versesBasedOnChapter = - bookData && verserInChapter ? Array.from({ length: verserInChapter }, (_, i) => i + 1) : []; - - useEffect(() => { - if (selectedBook && selectedChapter && selectedVerse) { - const newVerseRef = `${selectedBook} ${selectedChapter}:${selectedVerse}`; - console.log({ newVerseRef }); - callback(newVerseRef); - } - }, [selectedVerse, selectedBook, selectedChapter, callback]); - - return ( -
- { - console.log({ e }); - console.log((e.target as HTMLSelectElement).value); - setSelectedBook((e.target as HTMLSelectElement).value); - setSelectedChapter("1"); - setSelectedVerse("1"); - }} - > - {booksOfTheBible.map((bibleBook: string) => ( - - {bibleBook} - - ))} - - { - console.log({ e }); - console.log((e.target as HTMLSelectElement).value); - setSelectedChapter((e.target as HTMLSelectElement).value); - setSelectedVerse("1"); - }} - > - {chaptersBasedOnBook.map((chapterNumber) => ( - - {chapterNumber} - - ))} - - { - console.log({ e }); - setSelectedVerse((e.target as HTMLSelectElement).value); - }} - > - {versesBasedOnChapter.map((verseNumber) => ( - - {verseNumber} - - ))} - -
- ); -}; - -export default VerseRefNavigation; diff --git a/webviews/codex-webviews/src/components/VisualPasswordIndicator.tsx b/webviews/codex-webviews/src/components/VisualPasswordIndicator.tsx deleted file mode 100644 index 8e8cc2d9a..000000000 --- a/webviews/codex-webviews/src/components/VisualPasswordIndicator.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import React from 'react'; - -interface VisualPasswordIndicatorProps { - password: string; - email: string; - minLength?: number; - showIndicator?: boolean; -} - -export const VisualPasswordIndicator: React.FC = ({ - password, - email, - minLength = 15, - showIndicator = true, -}) => { - if (!showIndicator) { - return null; - } - - // Check if password contains any part of the email (case-insensitive) - const getEmailMatches = (): { start: number; end: number }[] => { - if (!email || !password) return []; - - const matches: { start: number; end: number }[] = []; - const lowerPassword = password.toLowerCase(); - const emailParts = email.toLowerCase().split('@'); - - // Check for username part (before @) - if (emailParts[0] && emailParts[0].length >= 3) { - for (let i = 0; i <= lowerPassword.length - emailParts[0].length; i++) { - if (lowerPassword.substring(i, i + emailParts[0].length) === emailParts[0]) { - matches.push({ start: i, end: i + emailParts[0].length }); - } - } - } - - return matches; - }; - - const emailMatches = getEmailMatches(); - const hasEmailMatch = emailMatches.length > 0; - const isLengthValid = password.length >= minLength; - const allRequirementsMet = isLengthValid && !hasEmailMatch; - - // Render password with highlighting - const renderPasswordWithHighlighting = () => { - if (!password) return null; - - const result: JSX.Element[] = []; - let lastIndex = 0; - - // Add email match highlighting - emailMatches.forEach((match, idx) => { - // Add text before match - if (lastIndex < match.start) { - result.push( - - {password.substring(lastIndex, match.start)} - - ); - } - - // Add highlighted match - result.push( - - {password.substring(match.start, match.end)} - - ); - - lastIndex = match.end; - }); - - // Add remaining text after last match - if (lastIndex < password.length) { - result.push( - - {password.substring(lastIndex)} - - ); - } - - return result; - }; - - // Render length indicator dots - const renderLengthIndicator = () => { - const dots = []; - const currentLength = password.length; - - for (let i = 0; i < minLength; i++) { - const isFilled = i < currentLength; - dots.push( -
- ); - } - - return dots; - }; - - return ( -
- {/* Password visualization */} -
-
- Password: -
-
- {password ? renderPasswordWithHighlighting() : ( - - Type your password... - - )} -
-
- - {/* Length requirement indicator */} -
-
- Length: {password.length}/{minLength} characters -
-
- {renderLengthIndicator()} -
-
- - {/* Email match warning */} - {hasEmailMatch && ( -
- - ! - - Password should not contain parts of your email -
- )} - - {/* Success indicator */} - {allRequirementsMet && ( -
- - ✓ - - Password meets all requirements -
- )} -
- ); -}; - -// Updated password validation function -export const validateVisualPassword = ( - password: string, - email: string, - minLength: number = 15 -): { isValid: boolean; issues: string[] } => { - const issues: string[] = []; - - if (password.length < minLength) { - issues.push(`Password must be at least ${minLength} characters long`); - } - - if (email) { - const emailUsername = email.split('@')[0].toLowerCase(); - if (emailUsername.length >= 3 && password.toLowerCase().includes(emailUsername)) { - issues.push('Password should not contain parts of your email'); - } - } - - return { - isValid: issues.length === 0, - issues - }; -}; \ No newline at end of file diff --git a/webviews/codex-webviews/src/components/VisualPasswordInput.tsx b/webviews/codex-webviews/src/components/VisualPasswordInput.tsx deleted file mode 100644 index 8ffaf6e27..000000000 --- a/webviews/codex-webviews/src/components/VisualPasswordInput.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react'; - -interface VisualPasswordInputProps { - password: string; - email: string; - minLength?: number; - placeholder?: string; - onPasswordChange: (password: string) => void; - disabled?: boolean; - style?: React.CSSProperties; -} - -export const VisualPasswordInput: React.FC = ({ - password, - email, - minLength = 15, - placeholder = "Password", - onPasswordChange, - disabled = false, - style = {}, -}) => { - const [isFocused, setIsFocused] = useState(false); - const inputRef = useRef(null); - const overlayRef = useRef(null); - - // Enhanced email matching - checks for any 3+ character substring - const getEmailMatches = (): { start: number; end: number }[] => { - if (!email || !password || password.length < 3) return []; - - const matches: { start: number; end: number }[] = []; - const lowerPassword = password.toLowerCase(); - const lowerEmail = email.toLowerCase(); - - // Check for any 3+ character substring from the email - for (let emailStart = 0; emailStart <= lowerEmail.length - 3; emailStart++) { - for (let emailEnd = emailStart + 3; emailEnd <= lowerEmail.length; emailEnd++) { - const emailSubstring = lowerEmail.substring(emailStart, emailEnd); - - // Skip common short words and symbols that might cause false positives - if (emailSubstring.includes('@') || emailSubstring.includes('.') || - ['com', 'org', 'net', 'edu', 'gov'].includes(emailSubstring)) { - continue; - } - - // Look for this substring in the password - for (let passStart = 0; passStart <= lowerPassword.length - emailSubstring.length; passStart++) { - if (lowerPassword.substring(passStart, passStart + emailSubstring.length) === emailSubstring) { - matches.push({ start: passStart, end: passStart + emailSubstring.length }); - } - } - } - } - - // Remove overlapping matches, keeping the longest ones - return matches.sort((a, b) => (b.end - b.start) - (a.end - a.start)) - .filter((match, index, arr) => { - return !arr.slice(0, index).some(prev => - (match.start >= prev.start && match.start < prev.end) || - (match.end > prev.start && match.end <= prev.end) - ); - }); - }; - - const emailMatches = getEmailMatches(); - const hasEmailMatch = emailMatches.length > 0; - const isLengthValid = password.length >= minLength; - const allRequirementsMet = isLengthValid && !hasEmailMatch; - - // Render the visual overlay - const renderOverlay = () => { - if (!isFocused && !password) return null; - - const chars = password.split(''); - const elements: JSX.Element[] = []; - - // Create character elements with highlighting - chars.forEach((char, index) => { - const isInEmailMatch = emailMatches.some(match => index >= match.start && index < match.end); - const charColor = allRequirementsMet ? 'var(--vscode-testing-iconPassed)' : - isInEmailMatch ? 'var(--vscode-errorForeground)' : - 'var(--vscode-foreground)'; - - elements.push( - - • - - ); - }); - - // Add remaining dots for unfilled positions - for (let i = password.length; i < minLength; i++) { - elements.push( - - • - - ); - } - - return ( -
- {elements} -
- ); - }; - - const handleInputChange = (e: React.ChangeEvent) => { - onPasswordChange(e.target.value); - }; - - return ( -
- {/* Actual input field */} - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - placeholder={placeholder} - disabled={disabled} - style={{ - width: '100%', - padding: '8px', - border: `1px solid var(--vscode-input-border)`, - borderRadius: '4px', - backgroundColor: 'var(--vscode-input-background)', - color: 'transparent', // Hide the actual text - fontSize: '16px', - fontFamily: 'monospace', - outline: 'none', - boxSizing: 'border-box', - position: 'relative', - zIndex: 0, - }} - /> - - {/* Visual overlay */} - {renderOverlay()} - - {/* Status indicators below input */} -
- {/* Length indicator */} -
- Length: {password.length}/{minLength} characters -
- - {/* Email match warning */} - {hasEmailMatch && ( -
- - ! - - Password should not contain parts of your email -
- )} - - {/* Success indicator */} - {allRequirementsMet && ( -
- - ✓ - - Password meets all requirements -
- )} -
-
- ); -}; - -// Updated password validation function with enhanced email matching -export const validateVisualPassword = ( - password: string, - email: string, - minLength: number = 15 -): { isValid: boolean; issues: string[] } => { - const issues: string[] = []; - - if (password.length < minLength) { - issues.push(`Password must be at least ${minLength} characters long`); - } - - if (email && password.length >= 3) { - const lowerPassword = password.toLowerCase(); - const lowerEmail = email.toLowerCase(); - - // Check for any 3+ character substring from the email - let hasMatch = false; - for (let emailStart = 0; emailStart <= lowerEmail.length - 3 && !hasMatch; emailStart++) { - for (let emailEnd = emailStart + 3; emailEnd <= lowerEmail.length; emailEnd++) { - const emailSubstring = lowerEmail.substring(emailStart, emailEnd); - - // Skip common short words and symbols - if (emailSubstring.includes('@') || emailSubstring.includes('.') || - ['com', 'org', 'net', 'edu', 'gov'].includes(emailSubstring)) { - continue; - } - - if (lowerPassword.includes(emailSubstring)) { - hasMatch = true; - break; - } - } - } - - if (hasMatch) { - issues.push('Password should not contain parts of your email'); - } - } - - return { - isValid: issues.length === 0, - issues - }; -}; \ No newline at end of file diff --git a/webviews/dictionary-side-panel/dist/assets/index.css b/webviews/dictionary-side-panel/dist/assets/index.css deleted file mode 100644 index 57456c771..000000000 --- a/webviews/dictionary-side-panel/dist/assets/index.css +++ /dev/null @@ -1 +0,0 @@ -.app-container{color:var(--vscode-editor-foreground);padding:20px;background-color:var(--vscode-editor-background);height:100vh;box-sizing:border-box;display:flex;flex-direction:column;align-items:center}.title{color:var(--vscode-editor-foreground);font-size:24px;margin-bottom:20px}.card{background-color:var(--vscode-editorWidget-background);box-shadow:0 2px 4px #0000001a;padding:20px;border-radius:8px;width:100%;max-width:400px;text-align:center}.entry-count{font-size:16px;margin-bottom:20px}.show-table-btn{background-color:var(--vscode-button-background);color:var(--vscode-button-foreground);border:none;padding:10px 20px;border-radius:4px;cursor:pointer;font-size:14px;transition:background-color .3s ease}.show-table-btn:hover{background-color:var(--vscode-button-hoverBackground)} diff --git a/webviews/dictionary-side-panel/dist/assets/index.js b/webviews/dictionary-side-panel/dist/assets/index.js deleted file mode 100644 index a6b63b560..000000000 --- a/webviews/dictionary-side-panel/dist/assets/index.js +++ /dev/null @@ -1,40 +0,0 @@ -var lc=Object.defineProperty;var oc=(e,n,t)=>n in e?lc(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t;var Du=(e,n,t)=>oc(e,typeof n!="symbol"?n+"":n,t);(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const o of l)if(o.type==="childList")for(const u of o.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&r(u)}).observe(document,{childList:!0,subtree:!0});function t(l){const o={};return l.integrity&&(o.integrity=l.integrity),l.referrerPolicy&&(o.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?o.credentials="include":l.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function r(l){if(l.ep)return;l.ep=!0;const o=t(l);fetch(l.href,o)}})();function uc(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Qi={exports:{}},el={},Ki={exports:{}},L={};/** - * @license React - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Xt=Symbol.for("react.element"),ic=Symbol.for("react.portal"),sc=Symbol.for("react.fragment"),ac=Symbol.for("react.strict_mode"),cc=Symbol.for("react.profiler"),fc=Symbol.for("react.provider"),dc=Symbol.for("react.context"),pc=Symbol.for("react.forward_ref"),mc=Symbol.for("react.suspense"),vc=Symbol.for("react.memo"),hc=Symbol.for("react.lazy"),Iu=Symbol.iterator;function yc(e){return e===null||typeof e!="object"?null:(e=Iu&&e[Iu]||e["@@iterator"],typeof e=="function"?e:null)}var Yi={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Xi=Object.assign,Gi={};function lt(e,n,t){this.props=e,this.context=n,this.refs=Gi,this.updater=t||Yi}lt.prototype.isReactComponent={};lt.prototype.setState=function(e,n){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,n,"setState")};lt.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function Zi(){}Zi.prototype=lt.prototype;function $o(e,n,t){this.props=e,this.context=n,this.refs=Gi,this.updater=t||Yi}var Ao=$o.prototype=new Zi;Ao.constructor=$o;Xi(Ao,lt.prototype);Ao.isPureReactComponent=!0;var Fu=Array.isArray,Ji=Object.prototype.hasOwnProperty,Vo={current:null},qi={key:!0,ref:!0,__self:!0,__source:!0};function bi(e,n,t){var r,l={},o=null,u=null;if(n!=null)for(r in n.ref!==void 0&&(u=n.ref),n.key!==void 0&&(o=""+n.key),n)Ji.call(n,r)&&!qi.hasOwnProperty(r)&&(l[r]=n[r]);var i=arguments.length-2;if(i===1)l.children=t;else if(1>>1,X=C[H];if(0>>1;Hl(gl,z))hnl(er,gl)?(C[H]=er,C[hn]=z,H=hn):(C[H]=gl,C[vn]=z,H=vn);else if(hnl(er,z))C[H]=er,C[hn]=z,H=hn;else break e}}return P}function l(C,P){var z=C.sortIndex-P.sortIndex;return z!==0?z:C.id-P.id}if(typeof performance=="object"&&typeof performance.now=="function"){var o=performance;e.unstable_now=function(){return o.now()}}else{var u=Date,i=u.now();e.unstable_now=function(){return u.now()-i}}var s=[],c=[],v=1,m=null,p=3,g=!1,w=!1,S=!1,F=typeof setTimeout=="function"?setTimeout:null,f=typeof clearTimeout=="function"?clearTimeout:null,a=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function d(C){for(var P=t(c);P!==null;){if(P.callback===null)r(c);else if(P.startTime<=C)r(c),P.sortIndex=P.expirationTime,n(s,P);else break;P=t(c)}}function h(C){if(S=!1,d(C),!w)if(t(s)!==null)w=!0,hl(E);else{var P=t(c);P!==null&&yl(h,P.startTime-C)}}function E(C,P){w=!1,S&&(S=!1,f(N),N=-1),g=!0;var z=p;try{for(d(P),m=t(s);m!==null&&(!(m.expirationTime>P)||C&&!xe());){var H=m.callback;if(typeof H=="function"){m.callback=null,p=m.priorityLevel;var X=H(m.expirationTime<=P);P=e.unstable_now(),typeof X=="function"?m.callback=X:m===t(s)&&r(s),d(P)}else r(s);m=t(s)}if(m!==null)var bt=!0;else{var vn=t(c);vn!==null&&yl(h,vn.startTime-P),bt=!1}return bt}finally{m=null,p=z,g=!1}}var _=!1,x=null,N=-1,B=5,T=-1;function xe(){return!(e.unstable_now()-TC||125H?(C.sortIndex=z,n(c,C),t(s)===null&&C===t(c)&&(S?(f(N),N=-1):S=!0,yl(h,z-H))):(C.sortIndex=X,n(s,C),w||g||(w=!0,hl(E))),C},e.unstable_shouldYield=xe,e.unstable_wrapCallback=function(C){var P=p;return function(){var z=p;p=P;try{return C.apply(this,arguments)}finally{p=z}}}})(ls);rs.exports=ls;var Lc=rs.exports;/** - * @license React - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Tc=Tt,he=Lc;function y(e){for(var n="https://reactjs.org/docs/error-decoder.html?invariant="+e,t=1;t"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Kl=Object.prototype.hasOwnProperty,Rc=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Uu={},$u={};function Oc(e){return Kl.call($u,e)?!0:Kl.call(Uu,e)?!1:Rc.test(e)?$u[e]=!0:(Uu[e]=!0,!1)}function Mc(e,n,t,r){if(t!==null&&t.type===0)return!1;switch(typeof n){case"function":case"symbol":return!0;case"boolean":return r?!1:t!==null?!t.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Dc(e,n,t,r){if(n===null||typeof n>"u"||Mc(e,n,t,r))return!0;if(r)return!1;if(t!==null)switch(t.type){case 3:return!n;case 4:return n===!1;case 5:return isNaN(n);case 6:return isNaN(n)||1>n}return!1}function ie(e,n,t,r,l,o,u){this.acceptsBooleans=n===2||n===3||n===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=t,this.propertyName=e,this.type=n,this.sanitizeURL=o,this.removeEmptyString=u}var b={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){b[e]=new ie(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var n=e[0];b[n]=new ie(n,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){b[e]=new ie(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){b[e]=new ie(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){b[e]=new ie(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){b[e]=new ie(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){b[e]=new ie(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){b[e]=new ie(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){b[e]=new ie(e,5,!1,e.toLowerCase(),null,!1,!1)});var Ho=/[\-:]([a-z])/g;function Wo(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var n=e.replace(Ho,Wo);b[n]=new ie(n,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var n=e.replace(Ho,Wo);b[n]=new ie(n,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var n=e.replace(Ho,Wo);b[n]=new ie(n,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){b[e]=new ie(e,1,!1,e.toLowerCase(),null,!1,!1)});b.xlinkHref=new ie("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){b[e]=new ie(e,1,!1,e.toLowerCase(),null,!0,!0)});function Qo(e,n,t,r){var l=b.hasOwnProperty(n)?b[n]:null;(l!==null?l.type!==0:r||!(2i||l[u]!==o[i]){var s=` -`+l[u].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=u&&0<=i);break}}}finally{kl=!1,Error.prepareStackTrace=t}return(e=e?e.displayName||e.name:"")?yt(e):""}function Ic(e){switch(e.tag){case 5:return yt(e.type);case 16:return yt("Lazy");case 13:return yt("Suspense");case 19:return yt("SuspenseList");case 0:case 2:case 15:return e=El(e.type,!1),e;case 11:return e=El(e.type.render,!1),e;case 1:return e=El(e.type,!0),e;default:return""}}function Zl(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Dn:return"Fragment";case Mn:return"Portal";case Yl:return"Profiler";case Ko:return"StrictMode";case Xl:return"Suspense";case Gl:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case is:return(e.displayName||"Context")+".Consumer";case us:return(e._context.displayName||"Context")+".Provider";case Yo:var n=e.render;return e=e.displayName,e||(e=n.displayName||n.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Xo:return n=e.displayName||null,n!==null?n:Zl(e.type)||"Memo";case Ge:n=e._payload,e=e._init;try{return Zl(e(n))}catch{}}return null}function Fc(e){var n=e.type;switch(e.tag){case 24:return"Cache";case 9:return(n.displayName||"Context")+".Consumer";case 10:return(n._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=n.render,e=e.displayName||e.name||"",n.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return n;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return Zl(n);case 8:return n===Ko?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof n=="function")return n.displayName||n.name||null;if(typeof n=="string")return n}return null}function cn(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function as(e){var n=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(n==="checkbox"||n==="radio")}function jc(e){var n=as(e)?"checked":"value",t=Object.getOwnPropertyDescriptor(e.constructor.prototype,n),r=""+e[n];if(!e.hasOwnProperty(n)&&typeof t<"u"&&typeof t.get=="function"&&typeof t.set=="function"){var l=t.get,o=t.set;return Object.defineProperty(e,n,{configurable:!0,get:function(){return l.call(this)},set:function(u){r=""+u,o.call(this,u)}}),Object.defineProperty(e,n,{enumerable:t.enumerable}),{getValue:function(){return r},setValue:function(u){r=""+u},stopTracking:function(){e._valueTracker=null,delete e[n]}}}}function rr(e){e._valueTracker||(e._valueTracker=jc(e))}function cs(e){if(!e)return!1;var n=e._valueTracker;if(!n)return!0;var t=n.getValue(),r="";return e&&(r=as(e)?e.checked?"true":"false":e.value),e=r,e!==t?(n.setValue(e),!0):!1}function Tr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Jl(e,n){var t=n.checked;return A({},n,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:t??e._wrapperState.initialChecked})}function Vu(e,n){var t=n.defaultValue==null?"":n.defaultValue,r=n.checked!=null?n.checked:n.defaultChecked;t=cn(n.value!=null?n.value:t),e._wrapperState={initialChecked:r,initialValue:t,controlled:n.type==="checkbox"||n.type==="radio"?n.checked!=null:n.value!=null}}functionvscode.workspace.fs(e,n){n=n.checked,n!=null&&Qo(e,"checked",n,!1)}function ql(e,n){fs(e,n);var t=cn(n.value),r=n.type;if(t!=null)r==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+t):e.value!==""+t&&(e.value=""+t);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}n.hasOwnProperty("value")?bl(e,n.type,t):n.hasOwnProperty("defaultValue")&&bl(e,n.type,cn(n.defaultValue)),n.checked==null&&n.defaultChecked!=null&&(e.defaultChecked=!!n.defaultChecked)}function Bu(e,n,t){if(n.hasOwnProperty("value")||n.hasOwnProperty("defaultValue")){var r=n.type;if(!(r!=="submit"&&r!=="reset"||n.value!==void 0&&n.value!==null))return;n=""+e._wrapperState.initialValue,t||n===e.value||(e.value=n),e.defaultValue=n}t=e.name,t!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,t!==""&&(e.name=t)}function bl(e,n,t){(n!=="number"||Tr(e.ownerDocument)!==e)&&(t==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+t&&(e.defaultValue=""+t))}var gt=Array.isArray;function Qn(e,n,t,r){if(e=e.options,n){n={};for(var l=0;l"+n.valueOf().toString()+"",n=lr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;n.firstChild;)e.appendChild(n.firstChild)}});function Ot(e,n){if(n){var t=e.firstChild;if(t&&t===e.lastChild&&t.nodeType===3){t.nodeValue=n;return}}e.textContent=n}var kt={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Uc=["Webkit","ms","Moz","O"];Object.keys(kt).forEach(function(e){Uc.forEach(function(n){n=n+e.charAt(0).toUpperCase()+e.substring(1),kt[n]=kt[e]})});function vs(e,n,t){return n==null||typeof n=="boolean"||n===""?"":t||typeof n!="number"||n===0||kt.hasOwnProperty(e)&&kt[e]?(""+n).trim():n+"px"}function hs(e,n){e=e.style;for(var t in n)if(n.hasOwnProperty(t)){var r=t.indexOf("--")===0,l=vs(t,n[t],r);t==="float"&&(t="cssFloat"),r?e.setProperty(t,l):e[t]=l}}var $c=A({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function to(e,n){if(n){if($c[e]&&(n.children!=null||n.dangerouslySetInnerHTML!=null))throw Error(y(137,e));if(n.dangerouslySetInnerHTML!=null){if(n.children!=null)throw Error(y(60));if(typeof n.dangerouslySetInnerHTML!="object"||!("__html"in n.dangerouslySetInnerHTML))throw Error(y(61))}if(n.style!=null&&typeof n.style!="object")throw Error(y(62))}}function ro(e,n){if(e.indexOf("-")===-1)return typeof n.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var lo=null;function Go(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var oo=null,Kn=null,Yn=null;function Qu(e){if(e=Jt(e)){if(typeof oo!="function")throw Error(y(280));var n=e.stateNode;n&&(n=ol(n),oo(e.stateNode,e.type,n))}}function ys(e){Kn?Yn?Yn.push(e):Yn=[e]:Kn=e}function gs(){if(Kn){var e=Kn,n=Yn;if(Yn=Kn=null,Qu(e),n)for(e=0;e>>=0,e===0?32:31-(Zc(e)/Jc|0)|0}var or=64,ur=4194304;function wt(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Dr(e,n){var t=e.pendingLanes;if(t===0)return 0;var r=0,l=e.suspendedLanes,o=e.pingedLanes,u=t&268435455;if(u!==0){var i=u&~l;i!==0?r=wt(i):(o&=u,o!==0&&(r=wt(o)))}else u=t&~l,u!==0?r=wt(u):o!==0&&(r=wt(o));if(r===0)return 0;if(n!==0&&n!==r&&!(n&l)&&(l=r&-r,o=n&-n,l>=o||l===16&&(o&4194240)!==0))return n;if(r&4&&(r|=t&16),n=e.entangledLanes,n!==0)for(e=e.entanglements,n&=r;0t;t++)n.push(e);return n}function Gt(e,n,t){e.pendingLanes|=n,n!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,n=31-Te(n),e[n]=t}function nf(e,n){var t=e.pendingLanes&~n;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=n,e.mutableReadLanes&=n,e.entangledLanes&=n,n=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Ct),ei=" ",ni=!1;function Us(e,n){switch(e){case"keyup":return Tf.indexOf(n.keyCode)!==-1;case"keydown":return n.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function $s(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var In=!1;function Of(e,n){switch(e){case"compositionend":return $s(n);case"keypress":return n.which!==32?null:(ni=!0,ei);case"textInput":return e=n.data,e===ei&&ni?null:e;default:return null}}function Mf(e,n){if(In)return e==="compositionend"||!ru&&Us(e,n)?(e=Fs(),kr=eu=be=null,In=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(n.ctrlKey||n.altKey||n.metaKey)||n.ctrlKey&&n.altKey){if(n.char&&1=n)return{node:t,offset:n-e};e=r}e:{for(;t;){if(t.nextSibling){t=t.nextSibling;break e}t=t.parentNode}t=void 0}t=oi(t)}}function Hs(e,n){return e&&n?e===n?!0:e&&e.nodeType===3?!1:n&&n.nodeType===3?Hs(e,n.parentNode):"contains"in e?e.contains(n):e.compareDocumentPosition?!!(e.compareDocumentPosition(n)&16):!1:!1}function Ws(){for(var e=window,n=Tr();n instanceof e.HTMLIFrameElement;){try{var t=typeof n.contentWindow.location.href=="string"}catch{t=!1}if(t)e=n.contentWindow;else break;n=Tr(e.document)}return n}function lu(e){var n=e&&e.nodeName&&e.nodeName.toLowerCase();return n&&(n==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||n==="textarea"||e.contentEditable==="true")}function Bf(e){var n=Ws(),t=e.focusedElem,r=e.selectionRange;if(n!==t&&t&&t.ownerDocument&&Hs(t.ownerDocument.documentElement,t)){if(r!==null&&lu(t)){if(n=r.start,e=r.end,e===void 0&&(e=n),"selectionStart"in t)t.selectionStart=n,t.selectionEnd=Math.min(e,t.value.length);else if(e=(n=t.ownerDocument||document)&&n.defaultView||window,e.getSelection){e=e.getSelection();var l=t.textContent.length,o=Math.min(r.start,l);r=r.end===void 0?o:Math.min(r.end,l),!e.extend&&o>r&&(l=r,r=o,o=l),l=ui(t,o);var u=ui(t,r);l&&u&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==u.node||e.focusOffset!==u.offset)&&(n=n.createRange(),n.setStart(l.node,l.offset),e.removeAllRanges(),o>r?(e.addRange(n),e.extend(u.node,u.offset)):(n.setEnd(u.node,u.offset),e.addRange(n)))}}for(n=[],e=t;e=e.parentNode;)e.nodeType===1&&n.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof t.focus=="function"&&t.focus(),t=0;t=document.documentMode,Fn=null,fo=null,xt=null,po=!1;function ii(e,n,t){var r=t.window===t?t.document:t.nodeType===9?t:t.ownerDocument;po||Fn==null||Fn!==Tr(r)||(r=Fn,"selectionStart"in r&&lu(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),xt&&Ut(xt,r)||(xt=r,r=jr(fo,"onSelect"),0$n||(e.current=wo[$n],wo[$n]=null,$n--)}function M(e,n){$n++,wo[$n]=e.current,e.current=n}var fn={},re=pn(fn),ce=pn(!1),xn=fn;function qn(e,n){var t=e.type.contextTypes;if(!t)return fn;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===n)return r.__reactInternalMemoizedMaskedChildContext;var l={},o;for(o in t)l[o]=n[o];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=n,e.__reactInternalMemoizedMaskedChildContext=l),l}function fe(e){return e=e.childContextTypes,e!=null}function $r(){I(ce),I(re)}function mi(e,n,t){if(re.current!==fn)throw Error(y(168));M(re,n),M(ce,t)}function bs(e,n,t){var r=e.stateNode;if(n=n.childContextTypes,typeof r.getChildContext!="function")return t;r=r.getChildContext();for(var l in r)if(!(l in n))throw Error(y(108,Fc(e)||"Unknown",l));return A({},t,r)}function Ar(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||fn,xn=re.current,M(re,e),M(ce,ce.current),!0}function vi(e,n,t){var r=e.stateNode;if(!r)throw Error(y(169));t?(e=bs(e,n,xn),r.__reactInternalMemoizedMergedChildContext=e,I(ce),I(re),M(re,e)):I(ce),M(ce,t)}var $e=null,ul=!1,Fl=!1;function ea(e){$e===null?$e=[e]:$e.push(e)}function ed(e){ul=!0,ea(e)}function mn(){if(!Fl&&$e!==null){Fl=!0;var e=0,n=O;try{var t=$e;for(O=1;e>=u,l-=u,Ae=1<<32-Te(n)+l|t<N?(B=x,x=null):B=x.sibling;var T=p(f,x,d[N],h);if(T===null){x===null&&(x=B);break}e&&x&&T.alternate===null&&n(f,x),a=o(T,a,N),_===null?E=T:_.sibling=T,_=T,x=B}if(N===d.length)return t(f,x),j&&yn(f,N),E;if(x===null){for(;NN?(B=x,x=null):B=x.sibling;var xe=p(f,x,T.value,h);if(xe===null){x===null&&(x=B);break}e&&x&&xe.alternate===null&&n(f,x),a=o(xe,a,N),_===null?E=xe:_.sibling=xe,_=xe,x=B}if(T.done)return t(f,x),j&&yn(f,N),E;if(x===null){for(;!T.done;N++,T=d.next())T=m(f,T.value,h),T!==null&&(a=o(T,a,N),_===null?E=T:_.sibling=T,_=T);return j&&yn(f,N),E}for(x=r(f,x);!T.done;N++,T=d.next())T=g(x,f,N,T.value,h),T!==null&&(e&&T.alternate!==null&&x.delete(T.key===null?N:T.key),a=o(T,a,N),_===null?E=T:_.sibling=T,_=T);return e&&x.forEach(function(it){return n(f,it)}),j&&yn(f,N),E}function F(f,a,d,h){if(typeof d=="object"&&d!==null&&d.type===Dn&&d.key===null&&(d=d.props.children),typeof d=="object"&&d!==null){switch(d.$$typeof){case tr:e:{for(var E=d.key,_=a;_!==null;){if(_.key===E){if(E=d.type,E===Dn){if(_.tag===7){t(f,_.sibling),a=l(_,d.props.children),a.return=f,f=a;break e}}else if(_.elementType===E||typeof E=="object"&&E!==null&&E.$$typeof===Ge&&gi(E)===_.type){t(f,_.sibling),a=l(_,d.props),a.ref=mt(f,_,d),a.return=f,f=a;break e}t(f,_);break}else n(f,_);_=_.sibling}d.type===Dn?(a=_n(d.props.children,f.mode,h,d.key),a.return=f,f=a):(h=Lr(d.type,d.key,d.props,null,f.mode,h),h.ref=mt(f,a,d),h.return=f,f=h)}return u(f);case Mn:e:{for(_=d.key;a!==null;){if(a.key===_)if(a.tag===4&&a.stateNode.containerInfo===d.containerInfo&&a.stateNode.implementation===d.implementation){t(f,a.sibling),a=l(a,d.children||[]),a.return=f,f=a;break e}else{t(f,a);break}else n(f,a);a=a.sibling}a=Wl(d,f.mode,h),a.return=f,f=a}return u(f);case Ge:return _=d._init,F(f,a,_(d._payload),h)}if(gt(d))return w(f,a,d,h);if(at(d))return S(f,a,d,h);pr(f,d)}return typeof d=="string"&&d!==""||typeof d=="number"?(d=""+d,a!==null&&a.tag===6?(t(f,a.sibling),a=l(a,d),a.return=f,f=a):(t(f,a),a=Hl(d,f.mode,h),a.return=f,f=a),u(f)):t(f,a)}return F}var et=la(!0),oa=la(!1),Hr=pn(null),Wr=null,Bn=null,su=null;function au(){su=Bn=Wr=null}function cu(e){var n=Hr.current;I(Hr),e._currentValue=n}function Eo(e,n,t){for(;e!==null;){var r=e.alternate;if((e.childLanes&n)!==n?(e.childLanes|=n,r!==null&&(r.childLanes|=n)):r!==null&&(r.childLanes&n)!==n&&(r.childLanes|=n),e===t)break;e=e.return}}function Gn(e,n){Wr=e,su=Bn=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&n&&(ae=!0),e.firstContext=null)}function Ce(e){var n=e._currentValue;if(su!==e)if(e={context:e,memoizedValue:n,next:null},Bn===null){if(Wr===null)throw Error(y(308));Bn=e,Wr.dependencies={lanes:0,firstContext:e}}else Bn=Bn.next=e;return n}var kn=null;function fu(e){kn===null?kn=[e]:kn.push(e)}function ua(e,n,t,r){var l=n.interleaved;return l===null?(t.next=t,fu(n)):(t.next=l.next,l.next=t),n.interleaved=t,Qe(e,r)}function Qe(e,n){e.lanes|=n;var t=e.alternate;for(t!==null&&(t.lanes|=n),t=e,e=e.return;e!==null;)e.childLanes|=n,t=e.alternate,t!==null&&(t.childLanes|=n),t=e,e=e.return;return t.tag===3?t.stateNode:null}var Ze=!1;function du(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function ia(e,n){e=e.updateQueue,n.updateQueue===e&&(n.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Be(e,n){return{eventTime:e,lane:n,tag:0,payload:null,callback:null,next:null}}function on(e,n,t){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,R&2){var l=r.pending;return l===null?n.next=n:(n.next=l.next,l.next=n),r.pending=n,Qe(e,t)}return l=r.interleaved,l===null?(n.next=n,fu(r)):(n.next=l.next,l.next=n),r.interleaved=n,Qe(e,t)}function Cr(e,n,t){if(n=n.updateQueue,n!==null&&(n=n.shared,(t&4194240)!==0)){var r=n.lanes;r&=e.pendingLanes,t|=r,n.lanes=t,Jo(e,t)}}function wi(e,n){var t=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,t===r)){var l=null,o=null;if(t=t.firstBaseUpdate,t!==null){do{var u={eventTime:t.eventTime,lane:t.lane,tag:t.tag,payload:t.payload,callback:t.callback,next:null};o===null?l=o=u:o=o.next=u,t=t.next}while(t!==null);o===null?l=o=n:o=o.next=n}else l=o=n;t={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:o,shared:r.shared,effects:r.effects},e.updateQueue=t;return}e=t.lastBaseUpdate,e===null?t.firstBaseUpdate=n:e.next=n,t.lastBaseUpdate=n}function Qr(e,n,t,r){var l=e.updateQueue;Ze=!1;var o=l.firstBaseUpdate,u=l.lastBaseUpdate,i=l.shared.pending;if(i!==null){l.shared.pending=null;var s=i,c=s.next;s.next=null,u===null?o=c:u.next=c,u=s;var v=e.alternate;v!==null&&(v=v.updateQueue,i=v.lastBaseUpdate,i!==u&&(i===null?v.firstBaseUpdate=c:i.next=c,v.lastBaseUpdate=s))}if(o!==null){var m=l.baseState;u=0,v=c=s=null,i=o;do{var p=i.lane,g=i.eventTime;if((r&p)===p){v!==null&&(v=v.next={eventTime:g,lane:0,tag:i.tag,payload:i.payload,callback:i.callback,next:null});e:{var w=e,S=i;switch(p=n,g=t,S.tag){case 1:if(w=S.payload,typeof w=="function"){m=w.call(g,m,p);break e}m=w;break e;case 3:w.flags=w.flags&-65537|128;case 0:if(w=S.payload,p=typeof w=="function"?w.call(g,m,p):w,p==null)break e;m=A({},m,p);break e;case 2:Ze=!0}}i.callback!==null&&i.lane!==0&&(e.flags|=64,p=l.effects,p===null?l.effects=[i]:p.push(i))}else g={eventTime:g,lane:p,tag:i.tag,payload:i.payload,callback:i.callback,next:null},v===null?(c=v=g,s=m):v=v.next=g,u|=p;if(i=i.next,i===null){if(i=l.shared.pending,i===null)break;p=i,i=p.next,p.next=null,l.lastBaseUpdate=p,l.shared.pending=null}}while(!0);if(v===null&&(s=m),l.baseState=s,l.firstBaseUpdate=c,l.lastBaseUpdate=v,n=l.shared.interleaved,n!==null){l=n;do u|=l.lane,l=l.next;while(l!==n)}else o===null&&(l.shared.lanes=0);zn|=u,e.lanes=u,e.memoizedState=m}}function Si(e,n,t){if(e=n.effects,n.effects=null,e!==null)for(n=0;nt?t:4,e(!0);var r=Ul.transition;Ul.transition={};try{e(!1),n()}finally{O=t,Ul.transition=r}}function _a(){return _e().memoizedState}function ld(e,n,t){var r=sn(e);if(t={lane:r,action:t,hasEagerState:!1,eagerState:null,next:null},xa(e))Na(n,t);else if(t=ua(e,n,t,r),t!==null){var l=oe();Re(t,e,r,l),Pa(t,n,r)}}function od(e,n,t){var r=sn(e),l={lane:r,action:t,hasEagerState:!1,eagerState:null,next:null};if(xa(e))Na(n,l);else{var o=e.alternate;if(e.lanes===0&&(o===null||o.lanes===0)&&(o=n.lastRenderedReducer,o!==null))try{var u=n.lastRenderedState,i=o(u,t);if(l.hasEagerState=!0,l.eagerState=i,Oe(i,u)){var s=n.interleaved;s===null?(l.next=l,fu(n)):(l.next=s.next,s.next=l),n.interleaved=l;return}}catch{}finally{}t=ua(e,n,l,r),t!==null&&(l=oe(),Re(t,e,r,l),Pa(t,n,r))}}function xa(e){var n=e.alternate;return e===$||n!==null&&n===$}function Na(e,n){Nt=Yr=!0;var t=e.pending;t===null?n.next=n:(n.next=t.next,t.next=n),e.pending=n}function Pa(e,n,t){if(t&4194240){var r=n.lanes;r&=e.pendingLanes,t|=r,n.lanes=t,Jo(e,t)}}var Xr={readContext:Ce,useCallback:ee,useContext:ee,useEffect:ee,useImperativeHandle:ee,useInsertionEffect:ee,useLayoutEffect:ee,useMemo:ee,useReducer:ee,useRef:ee,useState:ee,useDebugValue:ee,useDeferredValue:ee,useTransition:ee,useMutableSource:ee,useSyncExternalStore:ee,useId:ee,unstable_isNewReconciler:!1},ud={readContext:Ce,useCallback:function(e,n){return De().memoizedState=[e,n===void 0?null:n],e},useContext:Ce,useEffect:Ei,useImperativeHandle:function(e,n,t){return t=t!=null?t.concat([e]):null,xr(4194308,4,wa.bind(null,n,e),t)},useLayoutEffect:function(e,n){return xr(4194308,4,e,n)},useInsertionEffect:function(e,n){return xr(4,2,e,n)},useMemo:function(e,n){var t=De();return n=n===void 0?null:n,e=e(),t.memoizedState=[e,n],e},useReducer:function(e,n,t){var r=De();return n=t!==void 0?t(n):n,r.memoizedState=r.baseState=n,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:n},r.queue=e,e=e.dispatch=ld.bind(null,$,e),[r.memoizedState,e]},useRef:function(e){var n=De();return e={current:e},n.memoizedState=e},useState:ki,useDebugValue:Su,useDeferredValue:function(e){return De().memoizedState=e},useTransition:function(){var e=ki(!1),n=e[0];return e=rd.bind(null,e[1]),De().memoizedState=e,[n,e]},useMutableSource:function(){},useSyncExternalStore:function(e,n,t){var r=$,l=De();if(j){if(t===void 0)throw Error(y(407));t=t()}else{if(t=n(),Z===null)throw Error(y(349));Pn&30||fa(r,n,t)}l.memoizedState=t;var o={value:t,getSnapshot:n};return l.queue=o,Ei(pa.bind(null,r,o,e),[e]),r.flags|=2048,Kt(9,da.bind(null,r,o,t,n),void 0,null),t},useId:function(){var e=De(),n=Z.identifierPrefix;if(j){var t=Ve,r=Ae;t=(r&~(1<<32-Te(r)-1)).toString(32)+t,n=":"+n+"R"+t,t=Wt++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=u.createElement(t,{is:r.is}):(e=u.createElement(t),t==="select"&&(u=e,r.multiple?u.multiple=!0:r.size&&(u.size=r.size))):e=u.createElementNS(e,t),e[Ie]=n,e[Vt]=r,ja(e,n,!1,!1),n.stateNode=e;e:{switch(u=ro(t,r),t){case"dialog":D("cancel",e),D("close",e),l=r;break;case"iframe":case"object":case"embed":D("load",e),l=r;break;case"video":case"audio":for(l=0;lrt&&(n.flags|=128,r=!0,vt(o,!1),n.lanes=4194304)}else{if(!r)if(e=Kr(u),e!==null){if(n.flags|=128,r=!0,t=e.updateQueue,t!==null&&(n.updateQueue=t,n.flags|=4),vt(o,!0),o.tail===null&&o.tailMode==="hidden"&&!u.alternate&&!j)return ne(n),null}else 2*W()-o.renderingStartTime>rt&&t!==1073741824&&(n.flags|=128,r=!0,vt(o,!1),n.lanes=4194304);o.isBackwards?(u.sibling=n.child,n.child=u):(t=o.last,t!==null?t.sibling=u:n.child=u,o.last=u)}return o.tail!==null?(n=o.tail,o.rendering=n,o.tail=n.sibling,o.renderingStartTime=W(),n.sibling=null,t=U.current,M(U,r?t&1|2:t&1),n):(ne(n),null);case 22:case 23:return Nu(),r=n.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(n.flags|=8192),r&&n.mode&1?pe&1073741824&&(ne(n),n.subtreeFlags&6&&(n.flags|=8192)):ne(n),null;case 24:return null;case 25:return null}throw Error(y(156,n.tag))}function md(e,n){switch(uu(n),n.tag){case 1:return fe(n.type)&&$r(),e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 3:return nt(),I(ce),I(re),vu(),e=n.flags,e&65536&&!(e&128)?(n.flags=e&-65537|128,n):null;case 5:return mu(n),null;case 13:if(I(U),e=n.memoizedState,e!==null&&e.dehydrated!==null){if(n.alternate===null)throw Error(y(340));bn()}return e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 19:return I(U),null;case 4:return nt(),null;case 10:return cu(n.type._context),null;case 22:case 23:return Nu(),null;case 24:return null;default:return null}}var vr=!1,te=!1,vd=typeof WeakSet=="function"?WeakSet:Set,k=null;function Hn(e,n){var t=e.ref;if(t!==null)if(typeof t=="function")try{t(null)}catch(r){V(e,n,r)}else t.current=null}function Ro(e,n,t){try{t()}catch(r){V(e,n,r)}}var Mi=!1;function hd(e,n){if(mo=Ir,e=Ws(),lu(e)){if("selectionStart"in e)var t={start:e.selectionStart,end:e.selectionEnd};else e:{t=(t=e.ownerDocument)&&t.defaultView||window;var r=t.getSelection&&t.getSelection();if(r&&r.rangeCount!==0){t=r.anchorNode;var l=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{t.nodeType,o.nodeType}catch{t=null;break e}var u=0,i=-1,s=-1,c=0,v=0,m=e,p=null;n:for(;;){for(var g;m!==t||l!==0&&m.nodeType!==3||(i=u+l),m!==o||r!==0&&m.nodeType!==3||(s=u+r),m.nodeType===3&&(u+=m.nodeValue.length),(g=m.firstChild)!==null;)p=m,m=g;for(;;){if(m===e)break n;if(p===t&&++c===l&&(i=u),p===o&&++v===r&&(s=u),(g=m.nextSibling)!==null)break;m=p,p=m.parentNode}m=g}t=i===-1||s===-1?null:{start:i,end:s}}else t=null}t=t||{start:0,end:0}}else t=null;for(vo={focusedElem:e,selectionRange:t},Ir=!1,k=n;k!==null;)if(n=k,e=n.child,(n.subtreeFlags&1028)!==0&&e!==null)e.return=n,k=e;else for(;k!==null;){n=k;try{var w=n.alternate;if(n.flags&1024)switch(n.tag){case 0:case 11:case 15:break;case 1:if(w!==null){var S=w.memoizedProps,F=w.memoizedState,f=n.stateNode,a=f.getSnapshotBeforeUpdate(n.elementType===n.type?S:Pe(n.type,S),F);f.__reactInternalSnapshotBeforeUpdate=a}break;case 3:var d=n.stateNode.containerInfo;d.nodeType===1?d.textContent="":d.nodeType===9&&d.documentElement&&d.removeChild(d.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(y(163))}}catch(h){V(n,n.return,h)}if(e=n.sibling,e!==null){e.return=n.return,k=e;break}k=n.return}return w=Mi,Mi=!1,w}function Pt(e,n,t){var r=n.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var o=l.destroy;l.destroy=void 0,o!==void 0&&Ro(n,t,o)}l=l.next}while(l!==r)}}function al(e,n){if(n=n.updateQueue,n=n!==null?n.lastEffect:null,n!==null){var t=n=n.next;do{if((t.tag&e)===e){var r=t.create;t.destroy=r()}t=t.next}while(t!==n)}}function Oo(e){var n=e.ref;if(n!==null){var t=e.stateNode;switch(e.tag){case 5:e=t;break;default:e=t}typeof n=="function"?n(e):n.current=e}}function Aa(e){var n=e.alternate;n!==null&&(e.alternate=null,Aa(n)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(n=e.stateNode,n!==null&&(delete n[Ie],delete n[Vt],delete n[go],delete n[qf],delete n[bf])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Va(e){return e.tag===5||e.tag===3||e.tag===4}function Di(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Va(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Mo(e,n,t){var r=e.tag;if(r===5||r===6)e=e.stateNode,n?t.nodeType===8?t.parentNode.insertBefore(e,n):t.insertBefore(e,n):(t.nodeType===8?(n=t.parentNode,n.insertBefore(e,t)):(n=t,n.appendChild(e)),t=t._reactRootContainer,t!=null||n.onclick!==null||(n.onclick=Ur));else if(r!==4&&(e=e.child,e!==null))for(Mo(e,n,t),e=e.sibling;e!==null;)Mo(e,n,t),e=e.sibling}function Do(e,n,t){var r=e.tag;if(r===5||r===6)e=e.stateNode,n?t.insertBefore(e,n):t.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Do(e,n,t),e=e.sibling;e!==null;)Do(e,n,t),e=e.sibling}var J=null,ze=!1;function Xe(e,n,t){for(t=t.child;t!==null;)Ba(e,n,t),t=t.sibling}function Ba(e,n,t){if(Fe&&typeof Fe.onCommitFiberUnmount=="function")try{Fe.onCommitFiberUnmount(nl,t)}catch{}switch(t.tag){case 5:te||Hn(t,n);case 6:var r=J,l=ze;J=null,Xe(e,n,t),J=r,ze=l,J!==null&&(ze?(e=J,t=t.stateNode,e.nodeType===8?e.parentNode.removeChild(t):e.removeChild(t)):J.removeChild(t.stateNode));break;case 18:J!==null&&(ze?(e=J,t=t.stateNode,e.nodeType===8?Il(e.parentNode,t):e.nodeType===1&&Il(e,t),Ft(e)):Il(J,t.stateNode));break;case 4:r=J,l=ze,J=t.stateNode.containerInfo,ze=!0,Xe(e,n,t),J=r,ze=l;break;case 0:case 11:case 14:case 15:if(!te&&(r=t.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var o=l,u=o.destroy;o=o.tag,u!==void 0&&(o&2||o&4)&&Ro(t,n,u),l=l.next}while(l!==r)}Xe(e,n,t);break;case 1:if(!te&&(Hn(t,n),r=t.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=t.memoizedProps,r.state=t.memoizedState,r.componentWillUnmount()}catch(i){V(t,n,i)}Xe(e,n,t);break;case 21:Xe(e,n,t);break;case 22:t.mode&1?(te=(r=te)||t.memoizedState!==null,Xe(e,n,t),te=r):Xe(e,n,t);break;default:Xe(e,n,t)}}function Ii(e){var n=e.updateQueue;if(n!==null){e.updateQueue=null;var t=e.stateNode;t===null&&(t=e.stateNode=new vd),n.forEach(function(r){var l=xd.bind(null,e,r);t.has(r)||(t.add(r),r.then(l,l))})}}function Ne(e,n){var t=n.deletions;if(t!==null)for(var r=0;rl&&(l=u),r&=~o}if(r=l,r=W()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*gd(r/1960))-r,10e?16:e,en===null)var r=!1;else{if(e=en,en=null,Jr=0,R&6)throw Error(y(331));var l=R;for(R|=4,k=e.current;k!==null;){var o=k,u=o.child;if(k.flags&16){var i=o.deletions;if(i!==null){for(var s=0;sW()-_u?Cn(e,0):Cu|=t),de(e,n)}function Za(e,n){n===0&&(e.mode&1?(n=ur,ur<<=1,!(ur&130023424)&&(ur=4194304)):n=1);var t=oe();e=Qe(e,n),e!==null&&(Gt(e,n,t),de(e,t))}function _d(e){var n=e.memoizedState,t=0;n!==null&&(t=n.retryLane),Za(e,t)}function xd(e,n){var t=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(t=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(y(314))}r!==null&&r.delete(n),Za(e,t)}var Ja;Ja=function(e,n,t){if(e!==null)if(e.memoizedProps!==n.pendingProps||ce.current)ae=!0;else{if(!(e.lanes&t)&&!(n.flags&128))return ae=!1,dd(e,n,t);ae=!!(e.flags&131072)}else ae=!1,j&&n.flags&1048576&&na(n,Br,n.index);switch(n.lanes=0,n.tag){case 2:var r=n.type;Nr(e,n),e=n.pendingProps;var l=qn(n,re.current);Gn(n,t),l=yu(null,n,r,e,l,t);var o=gu();return n.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(n.tag=1,n.memoizedState=null,n.updateQueue=null,fe(r)?(o=!0,Ar(n)):o=!1,n.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,du(n),l.updater=sl,n.stateNode=l,l._reactInternals=n,_o(n,r,e,t),n=Po(null,n,r,!0,o,t)):(n.tag=0,j&&o&&ou(n),le(null,n,l,t),n=n.child),n;case 16:r=n.elementType;e:{switch(Nr(e,n),e=n.pendingProps,l=r._init,r=l(r._payload),n.type=r,l=n.tag=Pd(r),e=Pe(r,e),l){case 0:n=No(null,n,r,e,t);break e;case 1:n=Ti(null,n,r,e,t);break e;case 11:n=zi(null,n,r,e,t);break e;case 14:n=Li(null,n,r,Pe(r.type,e),t);break e}throw Error(y(306,r,""))}return n;case 0:return r=n.type,l=n.pendingProps,l=n.elementType===r?l:Pe(r,l),No(e,n,r,l,t);case 1:return r=n.type,l=n.pendingProps,l=n.elementType===r?l:Pe(r,l),Ti(e,n,r,l,t);case 3:e:{if(Da(n),e===null)throw Error(y(387));r=n.pendingProps,o=n.memoizedState,l=o.element,ia(e,n),Qr(n,r,null,t);var u=n.memoizedState;if(r=u.element,o.isDehydrated)if(o={element:r,isDehydrated:!1,cache:u.cache,pendingSuspenseBoundaries:u.pendingSuspenseBoundaries,transitions:u.transitions},n.updateQueue.baseState=o,n.memoizedState=o,n.flags&256){l=tt(Error(y(423)),n),n=Ri(e,n,r,t,l);break e}else if(r!==l){l=tt(Error(y(424)),n),n=Ri(e,n,r,t,l);break e}else for(me=ln(n.stateNode.containerInfo.firstChild),ve=n,j=!0,Le=null,t=oa(n,null,r,t),n.child=t;t;)t.flags=t.flags&-3|4096,t=t.sibling;else{if(bn(),r===l){n=Ke(e,n,t);break e}le(e,n,r,t)}n=n.child}return n;case 5:return sa(n),e===null&&ko(n),r=n.type,l=n.pendingProps,o=e!==null?e.memoizedProps:null,u=l.children,ho(r,l)?u=null:o!==null&&ho(r,o)&&(n.flags|=32),Ma(e,n),le(e,n,u,t),n.child;case 6:return e===null&&ko(n),null;case 13:return Ia(e,n,t);case 4:return pu(n,n.stateNode.containerInfo),r=n.pendingProps,e===null?n.child=et(n,null,r,t):le(e,n,r,t),n.child;case 11:return r=n.type,l=n.pendingProps,l=n.elementType===r?l:Pe(r,l),zi(e,n,r,l,t);case 7:return le(e,n,n.pendingProps,t),n.child;case 8:return le(e,n,n.pendingProps.children,t),n.child;case 12:return le(e,n,n.pendingProps.children,t),n.child;case 10:e:{if(r=n.type._context,l=n.pendingProps,o=n.memoizedProps,u=l.value,M(Hr,r._currentValue),r._currentValue=u,o!==null)if(Oe(o.value,u)){if(o.children===l.children&&!ce.current){n=Ke(e,n,t);break e}}else for(o=n.child,o!==null&&(o.return=n);o!==null;){var i=o.dependencies;if(i!==null){u=o.child;for(var s=i.firstContext;s!==null;){if(s.context===r){if(o.tag===1){s=Be(-1,t&-t),s.tag=2;var c=o.updateQueue;if(c!==null){c=c.shared;var v=c.pending;v===null?s.next=s:(s.next=v.next,v.next=s),c.pending=s}}o.lanes|=t,s=o.alternate,s!==null&&(s.lanes|=t),Eo(o.return,t,n),i.lanes|=t;break}s=s.next}}else if(o.tag===10)u=o.type===n.type?null:o.child;else if(o.tag===18){if(u=o.return,u===null)throw Error(y(341));u.lanes|=t,i=u.alternate,i!==null&&(i.lanes|=t),Eo(u,t,n),u=o.sibling}else u=o.child;if(u!==null)u.return=o;else for(u=o;u!==null;){if(u===n){u=null;break}if(o=u.sibling,o!==null){o.return=u.return,u=o;break}u=u.return}o=u}le(e,n,l.children,t),n=n.child}return n;case 9:return l=n.type,r=n.pendingProps.children,Gn(n,t),l=Ce(l),r=r(l),n.flags|=1,le(e,n,r,t),n.child;case 14:return r=n.type,l=Pe(r,n.pendingProps),l=Pe(r.type,l),Li(e,n,r,l,t);case 15:return Ra(e,n,n.type,n.pendingProps,t);case 17:return r=n.type,l=n.pendingProps,l=n.elementType===r?l:Pe(r,l),Nr(e,n),n.tag=1,fe(r)?(e=!0,Ar(n)):e=!1,Gn(n,t),za(n,r,l),_o(n,r,l,t),Po(null,n,r,!0,e,t);case 19:return Fa(e,n,t);case 22:return Oa(e,n,t)}throw Error(y(156,n.tag))};function qa(e,n){return xs(e,n)}function Nd(e,n,t,r){this.tag=e,this.key=t,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=n,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function ke(e,n,t,r){return new Nd(e,n,t,r)}function zu(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Pd(e){if(typeof e=="function")return zu(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Yo)return 11;if(e===Xo)return 14}return 2}function an(e,n){var t=e.alternate;return t===null?(t=ke(e.tag,n,e.key,e.mode),t.elementType=e.elementType,t.type=e.type,t.stateNode=e.stateNode,t.alternate=e,e.alternate=t):(t.pendingProps=n,t.type=e.type,t.flags=0,t.subtreeFlags=0,t.deletions=null),t.flags=e.flags&14680064,t.childLanes=e.childLanes,t.lanes=e.lanes,t.child=e.child,t.memoizedProps=e.memoizedProps,t.memoizedState=e.memoizedState,t.updateQueue=e.updateQueue,n=e.dependencies,t.dependencies=n===null?null:{lanes:n.lanes,firstContext:n.firstContext},t.sibling=e.sibling,t.index=e.index,t.ref=e.ref,t}function Lr(e,n,t,r,l,o){var u=2;if(r=e,typeof e=="function")zu(e)&&(u=1);else if(typeof e=="string")u=5;else e:switch(e){case Dn:return _n(t.children,l,o,n);case Ko:u=8,l|=8;break;case Yl:return e=ke(12,t,n,l|2),e.elementType=Yl,e.lanes=o,e;case Xl:return e=ke(13,t,n,l),e.elementType=Xl,e.lanes=o,e;case Gl:return e=ke(19,t,n,l),e.elementType=Gl,e.lanes=o,e;case ss:return fl(t,l,o,n);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case us:u=10;break e;case is:u=9;break e;case Yo:u=11;break e;case Xo:u=14;break e;case Ge:u=16,r=null;break e}throw Error(y(130,e==null?e:typeof e,""))}return n=ke(u,t,n,l),n.elementType=e,n.type=r,n.lanes=o,n}function _n(e,n,t,r){return e=ke(7,e,r,n),e.lanes=t,e}function fl(e,n,t,r){return e=ke(22,e,r,n),e.elementType=ss,e.lanes=t,e.stateNode={isHidden:!1},e}function Hl(e,n,t){return e=ke(6,e,null,n),e.lanes=t,e}function Wl(e,n,t){return n=ke(4,e.children!==null?e.children:[],e.key,n),n.lanes=t,n.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},n}function zd(e,n,t,r,l){this.tag=n,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=_l(0),this.expirationTimes=_l(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=_l(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Lu(e,n,t,r,l,o,u,i,s){return e=new zd(e,n,t,i,s),n===1?(n=1,o===!0&&(n|=8)):n=0,o=ke(3,null,null,n),e.current=o,o.stateNode=e,o.memoizedState={element:r,isDehydrated:t,cache:null,transitions:null,pendingSuspenseBoundaries:null},du(o),e}function Ld(e,n,t){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(tc)}catch(e){console.error(e)}}tc(),ts.exports=ye;var Dd=ts.exports,Hi=Dd;Ql.createRoot=Hi.createRoot,Ql.hydrateRoot=Hi.hydrateRoot;class Id{constructor(){Du(this,"vsCodeApi");typeof acquireVsCodeApi=="function"&&(this.vsCodeApi=acquireVsCodeApi())}postMessage(n){this.vsCodeApi?this.vsCodeApi.postMessage(n):console.log(n)}getState(){if(this.vsCodeApi)return this.vsCodeApi.getState();{const n=localStorage.getItem("vscodeState");return n?JSON.parse(n):void 0}}setState(n){return this.vsCodeApi?this.vsCodeApi.setState(n):(localStorage.setItem("vscodeState",JSON.stringify(n)),n)}}const Wi=new Id;function Fd(e){return e.entries.length}function jd(){const[e,n]=Tt.useState(0);return Tt.useEffect(()=>{const t=r=>{const l=r.data;switch(l.command){case"sendData":{const o=l.data;n(Fd(o));break}case"updateEntryCount":{n(l.count),console.log("Entry count updated to:",l.count);break}}};return window.addEventListener("message",t),()=>{window.removeEventListener("message",t)}},[]),Wi.postMessage({command:"updateData"}),wn.jsxs("div",{className:"app-container",children:[wn.jsx("h1",{className:"title",children:"Dictionary Summary"}),wn.jsxs("div",{className:"card",children:[wn.jsxs("p",{className:"entry-count",children:["Entries in dictionary: ",e]}),wn.jsx("button",{className:"show-table-btn",onClick:()=>{Wi.postMessage({command:"showDictionaryTable"})},children:"Show Dictionary Table"})]})]})}Ql.createRoot(document.getElementById("root")).render(wn.jsx(Ec.StrictMode,{children:wn.jsx(jd,{})})); diff --git a/webviews/dictionary-side-panel/dist/index.html b/webviews/dictionary-side-panel/dist/index.html deleted file mode 100644 index 46d0d8fd3..000000000 --- a/webviews/dictionary-side-panel/dist/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - Vite + React + TS - - - - -
- - diff --git a/webviews/dictionary-side-panel/dist/vite.svg b/webviews/dictionary-side-panel/dist/vite.svg deleted file mode 100644 index e7b8dfb1b..000000000 --- a/webviews/dictionary-side-panel/dist/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/webviews/editable-react-table/dist/assets/index.css b/webviews/editable-react-table/dist/assets/index.css deleted file mode 100644 index 851197f53..000000000 --- a/webviews/editable-react-table/dist/assets/index.css +++ /dev/null @@ -1 +0,0 @@ -html,body,#root{height:100%;margin:0;padding:0;overflow:hidden}*,*:before,*:after{box-sizing:inherit}*{margin:0;padding:0;font-family:var(--vscode-font-family, "Inter", sans-serif);color:var(--vscode-editor-foreground);background-color:var(--vscode-editor-background)}.transition-fade-enter{opacity:0}.transition-fade-enter-active{opacity:1;transition:opacity .3s}.transition-fade-exit{opacity:1}.transition-fade-exit-active{opacity:0;transition:opacity .3s}.svg-icon svg{position:relative;height:1.5em;width:1.5em;background-color:var(--vscode-button-background);fill:var(--vscode-button-foreground);display:flex;align-items:center}.svg-text svg{stroke:#424242}.svg-180 svg{transform:rotate(180deg)}.form-input{padding:.375rem;background-color:var(--vscode-input-background);border:1px solid var(--vscode-input-border);border-radius:var(--vscode-input-borderRadius);font-size:.875rem;color:var(--vscode-input-foreground)}.form-input:focus{outline:none;box-shadow:0 0 1px 2px var(--vscode-focusBorder)}.is-fullwidth{width:100%}.bg-white{background-color:var(--vscode-editor-background)}.data-input{white-space:pre-wrap;border:none;padding:.5rem;color:var(--vscode-input-foreground);font-size:1rem;border-radius:var(--vscode-input-borderRadius);resize:none;background-color:var(--vscode-input-background);box-sizing:border-box;flex:1 1 auto}.data-input:focus{outline:none}.shadow-5{box-shadow:var(--vscode-shadow)}.svg-icon-sm svg{position:relative;height:1rem;width:1rem;top:.125rem}.svg-gray svg{stroke:#fff}.option-input{width:100%;font-size:1rem;border:none;background-color:transparent}.option-input:focus{outline:none}.noselect{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.overlay{position:fixed;top:0;left:0;height:100vh;width:100vw;z-index:2;overflow:hidden;background-color:#00000080}.sort-button{padding:.25rem .75rem;width:100%;background-color:transparent;border:0;font-size:.875rem;color:var(--vscode-button-foreground);cursor:pointer;text-align:left;display:flex;align-items:center}.sort-button:hover{background-color:var(--vscode-button-hoverBackground)}.search-bar{margin-bottom:20px;padding:8px;font-size:16px;border:1px solid var(--vscode-input-border);border-radius:4px;background-color:var(--vscode-input-background);color:var(--vscode-input-foreground);width:100%;box-sizing:border-box}.remove-button{background:none;border:none;cursor:pointer;padding:5px;display:flex;justify-content:center;align-items:center;z-index:10}.remove-button svg{height:36px;width:36px;margin-right:20px;fill:var( --vscode-button-foreground )}.remove-button:disabled{cursor:not-allowed;opacity:.5}.remove-button:hover:not(:disabled){background-color:var( --vscode-button-hoverBackground )}.app-container{display:flex;flex-direction:column;height:100%;overflow-y:auto;margin-bottom:70px;flex:1;width:100%}.table{display:flex;flex-flow:column;margin-top:60px}.table-header{display:flex;flex-flow:column;position:absolute;z-index:1001}.table-container{margin-bottom:40px;flex:1;width:100%}.checkbox-container{display:flex;align-items:center;justify-content:center;height:100%}.checkbox-large{transform:scale(2.5);margin:5px}.add-row{background-color:var(--vscode-button-background);color:var(--vscode-button-foreground);padding:.5rem;display:flex;align-items:center;justify-content:center;font-size:.875rem;cursor:pointer;height:30px;border:1px solid var(--vscode-button-border);border-radius:4px;transition:background-color .3s ease;position:fixed;bottom:0;left:0;right:0;z-index:10}.add-row:hover{background-color:var(--vscode-button-hoverBackground)}.resizer{display:inline-block;background:transparent;width:8px;height:100%;position:absolute;right:0;top:0;transform:translate(50%);z-index:1;cursor:col-resize;touch-action:none}.resizer:hover{background-color:var(--vscode-editor-selectionBackground)}h1{font-size:2rem;color:var(--vscode-editor-foreground)}.tr{display:flex;align-items:center;margin:0}.td,.th{padding:1px;margin:0;border-right:1px solid var(--vscode-editor-lineHighlightBackground);border-bottom:1px solid var(--vscode-editor-lineHighlightBackground);white-space:nowrap;position:relative;color:var(--vscode-editor-foreground);background-color:var(--vscode-editorWidget-background);font-weight:500;font-size:1rem;cursor:pointer}.td:last-child,.th:last-child{border-right:none}.tr:last-child .td{border-bottom:none}.td-content,.th-content{display:block;padding:1px;overflow-x:hidden;text-overflow:ellipsis;display:flex;align-items:center;height:50px}.th:hover{background-color:var(--vscode-list-hoverBackground)}.text-align-right{text-align:right}.cell-padding{padding:.5rem}.d-flex{display:flex}.d-inline-block{display:inline-block}.cursor-default{cursor:default}.align-items-center{align-items:center}.flex-wrap-wrap{flex-wrap:wrap}.border-radius-md{border-radius:5px}.cursor-pointer{cursor:pointer}.icon-margin{margin-right:4px}.font-weight-600{font-weight:600}.font-weight-400{font-weight:400}.font-size-75{font-size:.75rem}.flex-1{flex:1}.mt-5{margin-top:.5rem}.mr-auto{margin-right:auto}.ml-auto{margin-left:auto}.mr-5{margin-right:.5rem}.justify-content-center{justify-content:center}.flex-column{flex-direction:column}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-hidden{overflow-y:hidden}.list-padding{padding:4px 0;border:1px solid var(--vscode-editor-lineHighlightBackground)}.bg-grey-200{background-color:var(--vscode-editor-background)}.color-grey-800{color:var(--vscode-editor-foreground)}.color-grey-600{color:var(--vscode-editorWidget-foreground)}.color-grey-500{color:var(--vscode-editor-inactiveSelectionBackground)}.border-radius-sm{border-radius:4px}.text-transform-uppercase{text-transform:uppercase}.text-transform-capitalize{text-transform:capitalize} diff --git a/webviews/editable-react-table/dist/assets/index.js b/webviews/editable-react-table/dist/assets/index.js deleted file mode 100644 index 584efdff4..000000000 --- a/webviews/editable-react-table/dist/assets/index.js +++ /dev/null @@ -1,41 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const o of document.querySelectorAll('link[rel="modulepreload"]'))r(o);new MutationObserver(o=>{for(const l of o)if(l.type==="childList")for(const u of l.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&r(u)}).observe(document,{childList:!0,subtree:!0});function n(o){const l={};return o.integrity&&(l.integrity=o.integrity),o.referrerPolicy&&(l.referrerPolicy=o.referrerPolicy),o.crossOrigin==="use-credentials"?l.credentials="include":o.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function r(o){if(o.ep)return;o.ep=!0;const l=n(o);fetch(o.href,l)}})();var je=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function Co(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var df={exports:{}},xo={};/* -object-assign -(c) Sindre Sorhus -@license MIT -*/var Os=Object.getOwnPropertySymbols,Hv=Object.prototype.hasOwnProperty,$v=Object.prototype.propertyIsEnumerable;function Uv(e){if(e==null)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(e)}function bv(){try{if(!Object.assign)return!1;var e=new String("abc");if(e[5]="de",Object.getOwnPropertyNames(e)[0]==="5")return!1;for(var t={},n=0;n<10;n++)t["_"+String.fromCharCode(n)]=n;var r=Object.getOwnPropertyNames(t).map(function(l){return t[l]});if(r.join("")!=="0123456789")return!1;var o={};return"abcdefghijklmnopqrst".split("").forEach(function(l){o[l]=l}),Object.keys(Object.assign({},o)).join("")==="abcdefghijklmnopqrst"}catch{return!1}}var pf=bv()?Object.assign:function(e,t){for(var n,r=Uv(e),o,l=1;l"u"||typeof MessageChannel!="function"){var d=null,c=null,R=function(){if(d!==null)try{var b=e.unstable_now();d(!0,b),d=null}catch(ee){throw setTimeout(R,0),ee}};t=function(b){d!==null?setTimeout(t,0,b):(d=b,setTimeout(R,0))},n=function(b,ee){c=setTimeout(b,ee)},r=function(){clearTimeout(c)},e.unstable_shouldYield=function(){return!1},o=e.unstable_forceFrameRate=function(){}}else{var P=window.setTimeout,S=window.clearTimeout;if(typeof console<"u"){var k=window.cancelAnimationFrame;typeof window.requestAnimationFrame!="function"&&console.error("This browser doesn't support requestAnimationFrame. Make sure that you load a polyfill in older browsers. https://reactjs.org/link/react-polyfills"),typeof k!="function"&&console.error("This browser doesn't support cancelAnimationFrame. Make sure that you load a polyfill in older browsers. https://reactjs.org/link/react-polyfills")}var I=!1,O=null,h=-1,m=5,y=0;e.unstable_shouldYield=function(){return e.unstable_now()>=y},o=function(){},e.unstable_forceFrameRate=function(b){0>b||125>>1,Se=b[we];if(Se!==void 0&&0C(lt,fe))ht!==void 0&&0>C(ht,lt)?(b[we]=ht,b[et]=fe,we=et):(b[we]=lt,b[Je]=fe,we=Je);else if(ht!==void 0&&0>C(ht,fe))b[we]=ht,b[et]=fe,we=et;else break e}}return ee}return null}function C(b,ee){var fe=b.sortIndex-ee.sortIndex;return fe!==0?fe:b.id-ee.id}var B=[],X=[],J=1,ce=null,re=3,Re=!1,Ne=!1,Pe=!1;function He(b){for(var ee=T(X);ee!==null;){if(ee.callback===null)E(X);else if(ee.startTime<=b)E(X),ee.sortIndex=ee.expirationTime,D(B,ee);else break;ee=T(X)}}function it(b){if(Pe=!1,He(b),!Ne)if(T(B)!==null)Ne=!0,t(Ze);else{var ee=T(X);ee!==null&&n(it,ee.startTime-b)}}function Ze(b,ee){Ne=!1,Pe&&(Pe=!1,r()),Re=!0;var fe=re;try{for(He(ee),ce=T(B);ce!==null&&(!(ce.expirationTime>ee)||b&&!e.unstable_shouldYield());){var we=ce.callback;if(typeof we=="function"){ce.callback=null,re=ce.priorityLevel;var Se=we(ce.expirationTime<=ee);ee=e.unstable_now(),typeof Se=="function"?ce.callback=Se:ce===T(B)&&E(B),He(ee)}else E(B);ce=T(B)}if(ce!==null)var Je=!0;else{var lt=T(X);lt!==null&&n(it,lt.startTime-ee),Je=!1}return Je}finally{ce=null,re=fe,Re=!1}}var Ot=o;e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(b){b.callback=null},e.unstable_continueExecution=function(){Ne||Re||(Ne=!0,t(Ze))},e.unstable_getCurrentPriorityLevel=function(){return re},e.unstable_getFirstCallbackNode=function(){return T(B)},e.unstable_next=function(b){switch(re){case 1:case 2:case 3:var ee=3;break;default:ee=re}var fe=re;re=ee;try{return b()}finally{re=fe}},e.unstable_pauseExecution=function(){},e.unstable_requestPaint=Ot,e.unstable_runWithPriority=function(b,ee){switch(b){case 1:case 2:case 3:case 4:case 5:break;default:b=3}var fe=re;re=b;try{return ee()}finally{re=fe}},e.unstable_scheduleCallback=function(b,ee,fe){var we=e.unstable_now();switch(typeof fe=="object"&&fe!==null?(fe=fe.delay,fe=typeof fe=="number"&&0we?(b.sortIndex=fe,D(X,b),T(B)===null&&b===T(X)&&(Pe?r():Pe=!0,n(it,fe-we))):(b.sortIndex=Se,D(B,b),Ne||Re||(Ne=!0,t(Ze))),b},e.unstable_wrapCallback=function(b){var ee=re;return function(){var fe=re;re=ee;try{return b.apply(this,arguments)}finally{re=fe}}}})(Bf);Mf.exports=Bf;var em=Mf.exports;/** @license React v17.0.2 - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var $i=ae,Te=pf,be=em;function H(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),tm=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Ls=Object.prototype.hasOwnProperty,Ms={},Bs={};function nm(e){return Ls.call(Bs,e)?!0:Ls.call(Ms,e)?!1:tm.test(e)?Bs[e]=!0:(Ms[e]=!0,!1)}function rm(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function om(e,t,n,r){if(t===null||typeof t>"u"||rm(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function ot(e,t,n,r,o,l,u){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=o,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=l,this.removeEmptyString=u}var Xe={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){Xe[e]=new ot(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];Xe[t]=new ot(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){Xe[e]=new ot(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){Xe[e]=new ot(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){Xe[e]=new ot(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){Xe[e]=new ot(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){Xe[e]=new ot(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){Xe[e]=new ot(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){Xe[e]=new ot(e,5,!1,e.toLowerCase(),null,!1,!1)});var Uu=/[\-:]([a-z])/g;function bu(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Uu,bu);Xe[t]=new ot(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Uu,bu);Xe[t]=new ot(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Uu,bu);Xe[t]=new ot(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){Xe[e]=new ot(e,1,!1,e.toLowerCase(),null,!1,!1)});Xe.xlinkHref=new ot("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){Xe[e]=new ot(e,1,!1,e.toLowerCase(),null,!0,!0)});function Gu(e,t,n,r){var o=Xe.hasOwnProperty(t)?Xe[t]:null,l=o!==null?o.type===0:r?!1:!(!(2a||o[u]!==l[a])return` -`+o[u].replace(" at new "," at ");while(1<=u&&0<=a);break}}}finally{Sl=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?Hr(e):""}function im(e){switch(e.tag){case 5:return Hr(e.type);case 16:return Hr("Lazy");case 13:return Hr("Suspense");case 19:return Hr("SuspenseList");case 0:case 2:case 15:return e=bo(e.type,!1),e;case 11:return e=bo(e.type.render,!1),e;case 22:return e=bo(e.type._render,!1),e;case 1:return e=bo(e.type,!0),e;default:return""}}function Xn(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Jt:return"Fragment";case Cn:return"Portal";case Gr:return"Profiler";case Vu:return"StrictMode";case Vr:return"Suspense";case hi:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Qu:return(e.displayName||"Context")+".Consumer";case Xu:return(e._context.displayName||"Context")+".Provider";case Ui:var t=e.render;return t=t.displayName||t.name||"",e.displayName||(t!==""?"ForwardRef("+t+")":"ForwardRef");case bi:return Xn(e.type);case Yu:return Xn(e._render);case Ku:t=e._payload,e=e._init;try{return Xn(e(t))}catch{}}return null}function dn(e){switch(typeof e){case"boolean":case"number":case"object":case"string":case"undefined":return e;default:return""}}function Ff(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function lm(e){var t=Ff(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var o=n.get,l=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return o.call(this)},set:function(u){r=""+u,l.call(this,u)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(u){r=""+u},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Go(e){e._valueTracker||(e._valueTracker=lm(e))}function zf(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=Ff(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function gi(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Vl(e,t){var n=t.checked;return Te({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Ds(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=dn(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function jf(e,t){t=t.checked,t!=null&&Gu(e,"checked",t,!1)}function Xl(e,t){jf(e,t);var n=dn(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Ql(e,t.type,n):t.hasOwnProperty("defaultValue")&&Ql(e,t.type,dn(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}functionvscode.workspace.fs(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Ql(e,t,n){(t!=="number"||gi(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}function um(e){var t="";return $i.Children.forEach(e,function(n){n!=null&&(t+=n)}),t}function Kl(e,t){return e=Te({children:void 0},t),(t=um(t.children))&&(e.children=t),e}function Qn(e,t,n,r){if(e=e.options,t){t={};for(var o=0;o=n.length))throw Error(H(93));n=n[0]}t=n}t==null&&(t=""),n=t}e._wrapperState={initialValue:dn(n)}}function Wf(e,t){var n=dn(t.value),r=dn(t.defaultValue);n!=null&&(n=""+n,n!==e.value&&(e.value=n),t.defaultValue==null&&e.defaultValue!==n&&(e.defaultValue=n)),r!=null&&(e.defaultValue=""+r)}function js(e){var t=e.textContent;t===e._wrapperState.initialValue&&t!==""&&t!==null&&(e.value=t)}var ql={html:"http://www.w3.org/1999/xhtml",mathml:"http://www.w3.org/1998/Math/MathML",svg:"http://www.w3.org/2000/svg"};function Hf(e){switch(e){case"svg":return"http://www.w3.org/2000/svg";case"math":return"http://www.w3.org/1998/Math/MathML";default:return"http://www.w3.org/1999/xhtml"}}function Zl(e,t){return e==null||e==="http://www.w3.org/1999/xhtml"?Hf(t):e==="http://www.w3.org/2000/svg"&&t==="foreignObject"?"http://www.w3.org/1999/xhtml":e}var Vo,$f=function(e){return typeof MSApp<"u"&&MSApp.execUnsafeLocalFunction?function(t,n,r,o){MSApp.execUnsafeLocalFunction(function(){return e(t,n,r,o)})}:e}(function(e,t){if(e.namespaceURI!==ql.svg||"innerHTML"in e)e.innerHTML=t;else{for(Vo=Vo||document.createElement("div"),Vo.innerHTML=""+t.valueOf().toString()+"",t=Vo.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function lo(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Xr={animationIterationCount:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},am=["Webkit","ms","Moz","O"];Object.keys(Xr).forEach(function(e){am.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Xr[t]=Xr[e]})});function Uf(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Xr.hasOwnProperty(e)&&Xr[e]?(""+t).trim():t+"px"}function bf(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,o=Uf(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,o):e[n]=o}}var sm=Te({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Jl(e,t){if(t){if(sm[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(H(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(H(60));if(!(typeof t.dangerouslySetInnerHTML=="object"&&"__html"in t.dangerouslySetInnerHTML))throw Error(H(61))}if(t.style!=null&&typeof t.style!="object")throw Error(H(62))}}function eu(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}function Ju(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var tu=null,Kn=null,Yn=null;function Ws(e){if(e=_o(e)){if(typeof tu!="function")throw Error(H(280));var t=e.stateNode;t&&(t=Yi(t),tu(e.stateNode,e.type,t))}}function Gf(e){Kn?Yn?Yn.push(e):Yn=[e]:Kn=e}function Vf(){if(Kn){var e=Kn,t=Yn;if(Yn=Kn=null,Ws(e),t)for(e=0;er?0:1<n;n++)t.push(e);return t}function Vi(e,t,n){e.pendingLanes|=t;var r=t-1;e.suspendedLanes&=r,e.pingedLanes&=r,e=e.eventTimes,t=31-pn(t),e[t]=n}var pn=Math.clz32?Math.clz32:km,xm=Math.log,Rm=Math.LN2;function km(e){return e===0?32:31-(xm(e)/Rm|0)|0}var _m=be.unstable_UserBlockingPriority,Pm=be.unstable_runWithPriority,ii=!0;function Om(e,t,n,r){xn||ta();var o=la,l=xn;xn=!0;try{Xf(o,e,t,n,r)}finally{(xn=l)||na()}}function Tm(e,t,n,r){Pm(_m,la.bind(null,e,t,n,r))}function la(e,t,n,r){if(ii){var o;if((o=(t&4)===0)&&0=Kr),Ys=String.fromCharCode(32),qs=!1;function cd(e,t){switch(e){case"keyup":return Zm.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function fd(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var $n=!1;function eh(e,t){switch(e){case"compositionend":return fd(t);case"keypress":return t.which!==32?null:(qs=!0,Ys);case"textInput":return e=t.data,e===Ys&&qs?null:e;default:return null}}function th(e,t){if($n)return e==="compositionend"||!fa&&cd(e,t)?(e=ad(),li=aa=en=null,$n=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=tc(n)}}function md(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?md(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function rc(){for(var e=window,t=gi();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=gi(e.document)}return t}function lu(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}var fh=Qt&&"documentMode"in document&&11>=document.documentMode,Un=null,uu=null,qr=null,au=!1;function oc(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;au||Un==null||Un!==gi(r)||(r=Un,"selectionStart"in r&&lu(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),qr&&po(qr,r)||(qr=r,r=Ei(uu,"onSelect"),0Gn||(e.current=cu[Gn],cu[Gn]=null,Gn--)}function Me(e,t){Gn++,cu[Gn]=e.current,e.current=t}var vn={},qe=yn(vn),st=yn(!1),Tn=vn;function or(e,t){var n=e.type.contextTypes;if(!n)return vn;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var o={},l;for(l in n)o[l]=t[l];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=o),o}function ct(e){return e=e.childContextTypes,e!=null}function Ri(){_e(st),_e(qe)}function pc(e,t,n){if(qe.current!==vn)throw Error(H(168));Me(qe,t),Me(st,n)}function xd(e,t,n){var r=e.stateNode;if(e=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var o in r)if(!(o in e))throw Error(H(108,Xn(t)||"Unknown",o));return Te({},n,r)}function ai(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||vn,Tn=qe.current,Me(qe,e),Me(st,st.current),!0}function vc(e,t,n){var r=e.stateNode;if(!r)throw Error(H(169));n?(e=xd(e,t,Tn),r.__reactInternalMemoizedMergedChildContext=e,_e(st),_e(qe),Me(qe,e)):_e(st),Me(st,n)}var pa=null,Pn=null,vh=be.unstable_runWithPriority,va=be.unstable_scheduleCallback,fu=be.unstable_cancelCallback,mh=be.unstable_shouldYield,mc=be.unstable_requestPaint,du=be.unstable_now,hh=be.unstable_getCurrentPriorityLevel,qi=be.unstable_ImmediatePriority,Rd=be.unstable_UserBlockingPriority,kd=be.unstable_NormalPriority,_d=be.unstable_LowPriority,Pd=be.unstable_IdlePriority,Ml={},gh=mc!==void 0?mc:function(){},Ut=null,si=null,Bl=!1,hc=du(),Ke=1e4>hc?du:function(){return du()-hc};function ir(){switch(hh()){case qi:return 99;case Rd:return 98;case kd:return 97;case _d:return 96;case Pd:return 95;default:throw Error(H(332))}}function Od(e){switch(e){case 99:return qi;case 98:return Rd;case 97:return kd;case 96:return _d;case 95:return Pd;default:throw Error(H(332))}}function Nn(e,t){return e=Od(e),vh(e,t)}function mo(e,t,n){return e=Od(e),va(e,t,n)}function jt(){if(si!==null){var e=si;si=null,fu(e)}Td()}function Td(){if(!Bl&&Ut!==null){Bl=!0;var e=0;try{var t=Ut;Nn(99,function(){for(;eE?(C=T,T=null):C=T.sibling;var B=S(h,T,y[E],x);if(B===null){T===null&&(T=C);break}e&&T&&B.alternate===null&&t(h,T),m=l(B,m,E),D===null?_=B:D.sibling=B,D=B,T=C}if(E===y.length)return n(h,T),_;if(T===null){for(;EE?(C=T,T=null):C=T.sibling;var X=S(h,T,B.value,x);if(X===null){T===null&&(T=C);break}e&&T&&X.alternate===null&&t(h,T),m=l(X,m,E),D===null?_=X:D.sibling=X,D=X,T=C}if(B.done)return n(h,T),_;if(T===null){for(;!B.done;E++,B=y.next())B=P(h,B.value,x),B!==null&&(m=l(B,m,E),D===null?_=B:D.sibling=B,D=B);return _}for(T=r(h,T);!B.done;E++,B=y.next())B=k(T,h,E,B.value,x),B!==null&&(e&&B.alternate!==null&&T.delete(B.key===null?E:B.key),m=l(B,m,E),D===null?_=B:D.sibling=B,D=B);return e&&T.forEach(function(J){return t(h,J)}),_}return function(h,m,y,x){var _=typeof y=="object"&&y!==null&&y.type===Jt&&y.key===null;_&&(y=y.props.children);var D=typeof y=="object"&&y!==null;if(D)switch(y.$$typeof){case Wr:e:{for(D=y.key,_=m;_!==null;){if(_.key===D){switch(_.tag){case 7:if(y.type===Jt){n(h,_.sibling),m=o(_,y.props.children),m.return=h,h=m;break e}break;default:if(_.elementType===y.type){n(h,_.sibling),m=o(_,y.props),m.ref=Lr(h,_,y),m.return=h,h=m;break e}}n(h,_);break}else t(h,_);_=_.sibling}y.type===Jt?(m=nr(y.props.children,h.mode,x,y.key),m.return=h,h=m):(x=pi(y.type,y.key,y.props,null,h.mode,x),x.ref=Lr(h,m,y),x.return=h,h=x)}return u(h);case Cn:e:{for(_=y.key;m!==null;){if(m.key===_)if(m.tag===4&&m.stateNode.containerInfo===y.containerInfo&&m.stateNode.implementation===y.implementation){n(h,m.sibling),m=o(m,y.children||[]),m.return=h,h=m;break e}else{n(h,m);break}else t(h,m);m=m.sibling}m=Wl(y,h.mode,x),m.return=h,h=m}return u(h)}if(typeof y=="string"||typeof y=="number")return y=""+y,m!==null&&m.tag===6?(n(h,m.sibling),m=o(m,y),m.return=h,h=m):(n(h,m),m=jl(y,h.mode,x),m.return=h,h=m),u(h);if(Ko(y))return I(h,m,y,x);if(_r(y))return O(h,m,y,x);if(D&&Yo(h,y),typeof y>"u"&&!_)switch(h.tag){case 1:case 22:case 0:case 11:case 15:throw Error(H(152,Xn(h.type)||"Component"))}return n(h,m)}}var Ti=Bd(!0),Ad=Bd(!1),Po={},At=yn(Po),go=yn(Po),yo=yn(Po);function kn(e){if(e===Po)throw Error(H(174));return e}function vu(e,t){switch(Me(yo,t),Me(go,e),Me(At,Po),e=t.nodeType,e){case 9:case 11:t=(t=t.documentElement)?t.namespaceURI:Zl(null,"");break;default:e=e===8?t.parentNode:t,t=e.namespaceURI||null,e=e.tagName,t=Zl(t,e)}_e(At),Me(At,t)}function lr(){_e(At),_e(go),_e(yo)}function Ec(e){kn(yo.current);var t=kn(At.current),n=Zl(t,e.type);t!==n&&(Me(go,e),Me(At,n))}function ya(e){go.current===e&&(_e(At),_e(go))}var Le=yn(0);function Ni(e){for(var t=e;t!==null;){if(t.tag===13){var n=t.memoizedState;if(n!==null&&(n=n.dehydrated,n===null||n.data==="$?"||n.data==="$!"))return t}else if(t.tag===19&&t.memoizedProps.revealOrder!==void 0){if(t.flags&64)return t}else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===e)break;for(;t.sibling===null;){if(t.return===null||t.return===e)return null;t=t.return}t.sibling.return=t.return,t=t.sibling}return null}var Gt=null,nn=null,Dt=!1;function Dd(e,t){var n=St(5,null,null,0);n.elementType="DELETED",n.type="DELETED",n.stateNode=t,n.return=e,n.flags=8,e.lastEffect!==null?(e.lastEffect.nextEffect=n,e.lastEffect=n):e.firstEffect=e.lastEffect=n}function Cc(e,t){switch(e.tag){case 5:var n=e.type;return t=t.nodeType!==1||n.toLowerCase()!==t.nodeName.toLowerCase()?null:t,t!==null?(e.stateNode=t,!0):!1;case 6:return t=e.pendingProps===""||t.nodeType!==3?null:t,t!==null?(e.stateNode=t,!0):!1;case 13:return!1;default:return!1}}function mu(e){if(Dt){var t=nn;if(t){var n=t;if(!Cc(e,t)){if(t=qn(n.nextSibling),!t||!Cc(e,t)){e.flags=e.flags&-1025|2,Dt=!1,Gt=e;return}Dd(Gt,n)}Gt=e,nn=qn(t.firstChild)}else e.flags=e.flags&-1025|2,Dt=!1,Gt=e}}function xc(e){for(e=e.return;e!==null&&e.tag!==5&&e.tag!==3&&e.tag!==13;)e=e.return;Gt=e}function qo(e){if(e!==Gt)return!1;if(!Dt)return xc(e),Dt=!0,!1;var t=e.type;if(e.tag!==5||t!=="head"&&t!=="body"&&!su(t,e.memoizedProps))for(t=nn;t;)Dd(e,t),t=qn(t.nextSibling);if(xc(e),e.tag===13){if(e=e.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(H(317));e:{for(e=e.nextSibling,t=0;e;){if(e.nodeType===8){var n=e.data;if(n==="/$"){if(t===0){nn=qn(e.nextSibling);break e}t--}else n!=="$"&&n!=="$!"&&n!=="$?"||t++}e=e.nextSibling}nn=null}}else nn=Gt?qn(e.stateNode.nextSibling):null;return!0}function Al(){nn=Gt=null,Dt=!1}var Jn=[];function wa(){for(var e=0;el))throw Error(H(301));l+=1,Ge=Qe=null,t.updateQueue=null,Zr.current=Ch,e=n(r,o)}while(Jr)}if(Zr.current=Ai,t=Qe!==null&&Qe.next!==null,wo=0,Ge=Qe=Be=null,Ii=!1,t)throw Error(H(300));return e}function _n(){var e={memoizedState:null,baseState:null,baseQueue:null,queue:null,next:null};return Ge===null?Be.memoizedState=Ge=e:Ge=Ge.next=e,Ge}function Dn(){if(Qe===null){var e=Be.alternate;e=e!==null?e.memoizedState:null}else e=Qe.next;var t=Ge===null?Be.memoizedState:Ge.next;if(t!==null)Ge=t,Qe=e;else{if(e===null)throw Error(H(310));Qe=e,e={memoizedState:Qe.memoizedState,baseState:Qe.baseState,baseQueue:Qe.baseQueue,queue:Qe.queue,next:null},Ge===null?Be.memoizedState=Ge=e:Ge=Ge.next=e}return Ge}function Mt(e,t){return typeof t=="function"?t(e):t}function Mr(e){var t=Dn(),n=t.queue;if(n===null)throw Error(H(311));n.lastRenderedReducer=e;var r=Qe,o=r.baseQueue,l=n.pending;if(l!==null){if(o!==null){var u=o.next;o.next=l.next,l.next=u}r.baseQueue=o=l,n.pending=null}if(o!==null){o=o.next,r=r.baseState;var a=u=l=null,d=o;do{var c=d.lane;if((wo&c)===c)a!==null&&(a=a.next={lane:0,action:d.action,eagerReducer:d.eagerReducer,eagerState:d.eagerState,next:null}),r=d.eagerReducer===e?d.eagerState:e(r,d.action);else{var R={lane:c,action:d.action,eagerReducer:d.eagerReducer,eagerState:d.eagerState,next:null};a===null?(u=a=R,l=r):a=a.next=R,Be.lanes|=c,Oo|=c}d=d.next}while(d!==null&&d!==o);a===null?l=r:a.next=u,wt(r,t.memoizedState)||(It=!0),t.memoizedState=r,t.baseState=l,t.baseQueue=a,n.lastRenderedState=r}return[t.memoizedState,n.dispatch]}function Br(e){var t=Dn(),n=t.queue;if(n===null)throw Error(H(311));n.lastRenderedReducer=e;var r=n.dispatch,o=n.pending,l=t.memoizedState;if(o!==null){n.pending=null;var u=o=o.next;do l=e(l,u.action),u=u.next;while(u!==o);wt(l,t.memoizedState)||(It=!0),t.memoizedState=l,t.baseQueue===null&&(t.baseState=l),n.lastRenderedState=l}return[l,r]}function Rc(e,t,n){var r=t._getVersion;r=r(t._source);var o=t._workInProgressVersionPrimary;if(o!==null?e=o===r:(e=e.mutableReadLanes,(e=(wo&e)===e)&&(t._workInProgressVersionPrimary=r,Jn.push(t))),e)return n(t._source);throw Jn.push(t),Error(H(350))}function Fd(e,t,n,r){var o=rt;if(o===null)throw Error(H(349));var l=t._getVersion,u=l(t._source),a=Zr.current,d=a.useState(function(){return Rc(o,t,n)}),c=d[1],R=d[0];d=Ge;var P=e.memoizedState,S=P.refs,k=S.getSnapshot,I=P.source;P=P.subscribe;var O=Be;return e.memoizedState={refs:S,source:t,subscribe:r},a.useEffect(function(){S.getSnapshot=n,S.setSnapshot=c;var h=l(t._source);if(!wt(u,h)){h=n(t._source),wt(R,h)||(c(h),h=sn(O),o.mutableReadLanes|=h&o.pendingLanes),h=o.mutableReadLanes,o.entangledLanes|=h;for(var m=o.entanglements,y=h;0n?98:n,function(){e(!0)}),Nn(97<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=u.createElement(n,{is:r.is}):(e=u.createElement(n),n==="select"&&(u=e,r.multiple?u.multiple=!0:r.size&&(u.size=r.size))):e=u.createElementNS(e,n),e[tn]=t,e[xi]=r,Vd(e,t,!1,!1),t.stateNode=e,u=eu(n,r),n){case"dialog":ke("cancel",e),ke("close",e),o=r;break;case"iframe":case"object":case"embed":ke("load",e),o=r;break;case"video":case"audio":for(o=0;o<$r.length;o++)ke($r[o],e);o=r;break;case"source":ke("error",e),o=r;break;case"img":case"image":case"link":ke("error",e),ke("load",e),o=r;break;case"details":ke("toggle",e),o=r;break;case"input":Ds(e,r),o=Vl(e,r),ke("invalid",e);break;case"option":o=Kl(e,r);break;case"select":e._wrapperState={wasMultiple:!!r.multiple},o=Te({},r,{value:void 0}),ke("invalid",e);break;case"textarea":zs(e,r),o=Yl(e,r),ke("invalid",e);break;default:o=r}Jl(n,o);var a=o;for(l in a)if(a.hasOwnProperty(l)){var d=a[l];l==="style"?bf(e,d):l==="dangerouslySetInnerHTML"?(d=d?d.__html:void 0,d!=null&&$f(e,d)):l==="children"?typeof d=="string"?(n!=="textarea"||d!=="")&&lo(e,d):typeof d=="number"&&lo(e,""+d):l!=="suppressContentEditableWarning"&&l!=="suppressHydrationWarning"&&l!=="autoFocus"&&(io.hasOwnProperty(l)?d!=null&&l==="onScroll"&&ke("scroll",e):d!=null&&Gu(e,l,d,u))}switch(n){case"input":Go(e),Fs(e,r,!1);break;case"textarea":Go(e),js(e);break;case"option":r.value!=null&&e.setAttribute("value",""+dn(r.value));break;case"select":e.multiple=!!r.multiple,l=r.value,l!=null?Qn(e,!!r.multiple,l,!1):r.defaultValue!=null&&Qn(e,!!r.multiple,r.defaultValue,!0);break;default:typeof o.onClick=="function"&&(e.onclick=Ci)}Ed(n,r)&&(t.flags|=4)}t.ref!==null&&(t.flags|=128)}return null;case 6:if(e&&t.stateNode!=null)Qd(e,t,e.memoizedProps,r);else{if(typeof r!="string"&&t.stateNode===null)throw Error(H(166));n=kn(yo.current),kn(At.current),qo(t)?(r=t.stateNode,n=t.memoizedProps,r[tn]=t,r.nodeValue!==n&&(t.flags|=4)):(r=(n.nodeType===9?n:n.ownerDocument).createTextNode(r),r[tn]=t,t.stateNode=r)}return null;case 13:return _e(Le),r=t.memoizedState,t.flags&64?(t.lanes=n,t):(r=r!==null,n=!1,e===null?t.memoizedProps.fallback!==void 0&&qo(t):n=e.memoizedState!==null,r&&!n&&t.mode&2&&(e===null&&t.memoizedProps.unstable_avoidThisFallback!==!0||Le.current&1?Ve===0&&(Ve=3):((Ve===0||Ve===3)&&(Ve=4),rt===null||!(Oo&134217727)&&!(hr&134217727)||er(rt,Ye))),(r||n)&&(t.flags|=4),null);case 4:return lr(),wu(t),e===null&&yd(t.stateNode.containerInfo),null;case 10:return ha(t),null;case 17:return ct(t.type)&&Ri(),null;case 19:if(_e(Le),r=t.memoizedState,r===null)return null;if(l=(t.flags&64)!==0,u=r.rendering,u===null)if(l)Dr(r,!1);else{if(Ve!==0||e!==null&&e.flags&64)for(e=t.child;e!==null;){if(u=Ni(e),u!==null){for(t.flags|=64,Dr(r,!1),l=u.updateQueue,l!==null&&(t.updateQueue=l,t.flags|=4),r.lastEffect===null&&(t.firstEffect=null),t.lastEffect=r.lastEffect,r=n,n=t.child;n!==null;)l=n,e=r,l.flags&=2,l.nextEffect=null,l.firstEffect=null,l.lastEffect=null,u=l.alternate,u===null?(l.childLanes=0,l.lanes=e,l.child=null,l.memoizedProps=null,l.memoizedState=null,l.updateQueue=null,l.dependencies=null,l.stateNode=null):(l.childLanes=u.childLanes,l.lanes=u.lanes,l.child=u.child,l.memoizedProps=u.memoizedProps,l.memoizedState=u.memoizedState,l.updateQueue=u.updateQueue,l.type=u.type,e=u.dependencies,l.dependencies=e===null?null:{lanes:e.lanes,firstContext:e.firstContext}),n=n.sibling;return Me(Le,Le.current&1|2),t.child}e=e.sibling}r.tail!==null&&Ke()>ku&&(t.flags|=64,l=!0,Dr(r,!1),t.lanes=33554432)}else{if(!l)if(e=Ni(u),e!==null){if(t.flags|=64,l=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),Dr(r,!0),r.tail===null&&r.tailMode==="hidden"&&!u.alternate&&!Dt)return t=t.lastEffect=r.lastEffect,t!==null&&(t.nextEffect=null),null}else 2*Ke()-r.renderingStartTime>ku&&n!==1073741824&&(t.flags|=64,l=!0,Dr(r,!1),t.lanes=33554432);r.isBackwards?(u.sibling=t.child,t.child=u):(n=r.last,n!==null?n.sibling=u:t.child=u,r.last=u)}return r.tail!==null?(n=r.tail,r.rendering=n,r.tail=n.sibling,r.lastEffect=t.lastEffect,r.renderingStartTime=Ke(),n.sibling=null,t=Le.current,Me(Le,l?t&1|2:t&1),n):null;case 23:case 24:return Ta(),e!==null&&e.memoizedState!==null!=(t.memoizedState!==null)&&r.mode!=="unstable-defer-without-hiding"&&(t.flags|=4),null}throw Error(H(156,t.tag))}function kh(e){switch(e.tag){case 1:ct(e.type)&&Ri();var t=e.flags;return t&4096?(e.flags=t&-4097|64,e):null;case 3:if(lr(),_e(st),_e(qe),wa(),t=e.flags,t&64)throw Error(H(285));return e.flags=t&-4097|64,e;case 5:return ya(e),null;case 13:return _e(Le),t=e.flags,t&4096?(e.flags=t&-4097|64,e):null;case 19:return _e(Le),null;case 4:return lr(),null;case 10:return ha(e),null;case 23:case 24:return Ta(),null;default:return null}}function ka(e,t){try{var n="",r=t;do n+=im(r),r=r.return;while(r);var o=n}catch(l){o=` -Error generating stack: `+l.message+` -`+l.stack}return{value:e,source:t,stack:o}}function Su(e,t){try{console.error(t.value)}catch(n){setTimeout(function(){throw n})}}var _h=typeof WeakMap=="function"?WeakMap:Map;function Kd(e,t,n){n=un(-1,n),n.tag=3,n.payload={element:null};var r=t.value;return n.callback=function(){Fi||(Fi=!0,_u=r),Su(e,t)},n}function Yd(e,t,n){n=un(-1,n),n.tag=3;var r=e.type.getDerivedStateFromError;if(typeof r=="function"){var o=t.value;n.payload=function(){return Su(e,t),r(o)}}var l=e.stateNode;return l!==null&&typeof l.componentDidCatch=="function"&&(n.callback=function(){typeof r!="function"&&(Bt===null?Bt=new Set([this]):Bt.add(this),Su(e,t));var u=t.stack;this.componentDidCatch(t.value,{componentStack:u!==null?u:""})}),n}var Ph=typeof WeakSet=="function"?WeakSet:Set;function Fc(e){var t=e.ref;if(t!==null)if(typeof t=="function")try{t(null)}catch(n){fn(e,n)}else t.current=null}function Oh(e,t){switch(t.tag){case 0:case 11:case 15:case 22:return;case 1:if(t.flags&256&&e!==null){var n=e.memoizedProps,r=e.memoizedState;e=t.stateNode,t=e.getSnapshotBeforeUpdate(t.elementType===t.type?n:Nt(t.type,n),r),e.__reactInternalSnapshotBeforeUpdate=t}return;case 3:t.flags&256&&da(t.stateNode.containerInfo);return;case 5:case 6:case 4:case 17:return}throw Error(H(163))}function Th(e,t,n){switch(n.tag){case 0:case 11:case 15:case 22:if(t=n.updateQueue,t=t!==null?t.lastEffect:null,t!==null){e=t=t.next;do{if((e.tag&3)===3){var r=e.create;e.destroy=r()}e=e.next}while(e!==t)}if(t=n.updateQueue,t=t!==null?t.lastEffect:null,t!==null){e=t=t.next;do{var o=e;r=o.next,o=o.tag,o&4&&o&1&&(ip(n,e),Fh(n,e)),e=r}while(e!==t)}return;case 1:e=n.stateNode,n.flags&4&&(t===null?e.componentDidMount():(r=n.elementType===n.type?t.memoizedProps:Nt(n.type,t.memoizedProps),e.componentDidUpdate(r,t.memoizedState,e.__reactInternalSnapshotBeforeUpdate))),t=n.updateQueue,t!==null&&yc(n,t,e);return;case 3:if(t=n.updateQueue,t!==null){if(e=null,n.child!==null)switch(n.child.tag){case 5:e=n.child.stateNode;break;case 1:e=n.child.stateNode}yc(n,t,e)}return;case 5:e=n.stateNode,t===null&&n.flags&4&&Ed(n.type,n.memoizedProps)&&e.focus();return;case 6:return;case 4:return;case 12:return;case 13:n.memoizedState===null&&(n=n.alternate,n!==null&&(n=n.memoizedState,n!==null&&(n=n.dehydrated,n!==null&&ed(n))));return;case 19:case 17:case 20:case 21:case 23:case 24:return}throw Error(H(163))}function zc(e,t){for(var n=e;;){if(n.tag===5){var r=n.stateNode;if(t)r=r.style,typeof r.setProperty=="function"?r.setProperty("display","none","important"):r.display="none";else{r=n.stateNode;var o=n.memoizedProps.style;o=o!=null&&o.hasOwnProperty("display")?o.display:null,r.style.display=Uf("display",o)}}else if(n.tag===6)n.stateNode.nodeValue=t?"":n.memoizedProps;else if((n.tag!==23&&n.tag!==24||n.memoizedState===null||n===e)&&n.child!==null){n.child.return=n,n=n.child;continue}if(n===e)break;for(;n.sibling===null;){if(n.return===null||n.return===e)return;n=n.return}n.sibling.return=n.return,n=n.sibling}}function jc(e,t){if(Pn&&typeof Pn.onCommitFiberUnmount=="function")try{Pn.onCommitFiberUnmount(pa,t)}catch{}switch(t.tag){case 0:case 11:case 14:case 15:case 22:if(e=t.updateQueue,e!==null&&(e=e.lastEffect,e!==null)){var n=e=e.next;do{var r=n,o=r.destroy;if(r=r.tag,o!==void 0)if(r&4)ip(t,n);else{r=t;try{o()}catch(l){fn(r,l)}}n=n.next}while(n!==e)}break;case 1:if(Fc(t),e=t.stateNode,typeof e.componentWillUnmount=="function")try{e.props=t.memoizedProps,e.state=t.memoizedState,e.componentWillUnmount()}catch(l){fn(t,l)}break;case 5:Fc(t);break;case 4:qd(e,t)}}function Wc(e){e.alternate=null,e.child=null,e.dependencies=null,e.firstEffect=null,e.lastEffect=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.return=null,e.updateQueue=null}function Hc(e){return e.tag===5||e.tag===3||e.tag===4}function $c(e){e:{for(var t=e.return;t!==null;){if(Hc(t))break e;t=t.return}throw Error(H(160))}var n=t;switch(t=n.stateNode,n.tag){case 5:var r=!1;break;case 3:t=t.containerInfo,r=!0;break;case 4:t=t.containerInfo,r=!0;break;default:throw Error(H(161))}n.flags&16&&(lo(t,""),n.flags&=-17);e:t:for(n=e;;){for(;n.sibling===null;){if(n.return===null||Hc(n.return)){n=null;break e}n=n.return}for(n.sibling.return=n.return,n=n.sibling;n.tag!==5&&n.tag!==6&&n.tag!==18;){if(n.flags&2||n.child===null||n.tag===4)continue t;n.child.return=n,n=n.child}if(!(n.flags&2)){n=n.stateNode;break e}}r?Eu(e,n,t):Cu(e,n,t)}function Eu(e,t,n){var r=e.tag,o=r===5||r===6;if(o)e=o?e.stateNode:e.stateNode.instance,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Ci));else if(r!==4&&(e=e.child,e!==null))for(Eu(e,t,n),e=e.sibling;e!==null;)Eu(e,t,n),e=e.sibling}function Cu(e,t,n){var r=e.tag,o=r===5||r===6;if(o)e=o?e.stateNode:e.stateNode.instance,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Cu(e,t,n),e=e.sibling;e!==null;)Cu(e,t,n),e=e.sibling}function qd(e,t){for(var n=t,r=!1,o,l;;){if(!r){r=n.return;e:for(;;){if(r===null)throw Error(H(160));switch(o=r.stateNode,r.tag){case 5:l=!1;break e;case 3:o=o.containerInfo,l=!0;break e;case 4:o=o.containerInfo,l=!0;break e}r=r.return}r=!0}if(n.tag===5||n.tag===6){e:for(var u=e,a=n,d=a;;)if(jc(u,d),d.child!==null&&d.tag!==4)d.child.return=d,d=d.child;else{if(d===a)break e;for(;d.sibling===null;){if(d.return===null||d.return===a)break e;d=d.return}d.sibling.return=d.return,d=d.sibling}l?(u=o,a=n.stateNode,u.nodeType===8?u.parentNode.removeChild(a):u.removeChild(a)):o.removeChild(n.stateNode)}else if(n.tag===4){if(n.child!==null){o=n.stateNode.containerInfo,l=!0,n.child.return=n,n=n.child;continue}}else if(jc(e,n),n.child!==null){n.child.return=n,n=n.child;continue}if(n===t)break;for(;n.sibling===null;){if(n.return===null||n.return===t)return;n=n.return,n.tag===4&&(r=!1)}n.sibling.return=n.return,n=n.sibling}}function zl(e,t){switch(t.tag){case 0:case 11:case 14:case 15:case 22:var n=t.updateQueue;if(n=n!==null?n.lastEffect:null,n!==null){var r=n=n.next;do(r.tag&3)===3&&(e=r.destroy,r.destroy=void 0,e!==void 0&&e()),r=r.next;while(r!==n)}return;case 1:return;case 5:if(n=t.stateNode,n!=null){r=t.memoizedProps;var o=e!==null?e.memoizedProps:r;e=t.type;var l=t.updateQueue;if(t.updateQueue=null,l!==null){for(n[xi]=r,e==="input"&&r.type==="radio"&&r.name!=null&&jf(n,r),eu(e,o),t=eu(e,r),o=0;oo&&(o=u),n&=~l}if(n=o,n=Ke()-n,n=(120>n?120:480>n?480:1080>n?1080:1920>n?1920:3e3>n?3e3:4320>n?4320:1960*Ih(n/1960))-n,10 component higher in the tree to provide a loading indicator or placeholder to display.`)}Ve!==5&&(Ve=2),d=ka(d,a),S=u;do{switch(S.tag){case 3:l=d,S.flags|=4096,t&=-t,S.lanes|=t;var D=Kd(S,l,t);gc(S,D);break e;case 1:l=d;var T=S.type,E=S.stateNode;if(!(S.flags&64)&&(typeof T.getDerivedStateFromError=="function"||E!==null&&typeof E.componentDidCatch=="function"&&(Bt===null||!Bt.has(E)))){S.flags|=4096,t&=-t,S.lanes|=t;var C=Yd(S,l,t);gc(S,C);break e}}S=S.return}while(S!==null)}op(n)}catch(B){t=B,We===n&&n!==null&&(We=n=n.return);continue}break}while(1)}function np(){var e=Di.current;return Di.current=Ai,e===null?Ai:e}function br(e,t){var n=se;se|=16;var r=np();rt===e&&Ye===t||tr(e,t);do try{Mh();break}catch(o){tp(e,o)}while(1);if(ma(),se=n,Di.current=r,We!==null)throw Error(H(261));return rt=null,Ye=0,Ve}function Mh(){for(;We!==null;)rp(We)}function Bh(){for(;We!==null&&!mh();)rp(We)}function rp(e){var t=lp(e.alternate,e,In);e.memoizedProps=e.pendingProps,t===null?op(e):We=t,_a.current=null}function op(e){var t=e;do{var n=t.alternate;if(e=t.return,t.flags&2048){if(n=kh(t),n!==null){n.flags&=2047,We=n;return}e!==null&&(e.firstEffect=e.lastEffect=null,e.flags|=2048)}else{if(n=Rh(n,t,In),n!==null){We=n;return}if(n=t,n.tag!==24&&n.tag!==23||n.memoizedState===null||In&1073741824||!(n.mode&4)){for(var r=0,o=n.child;o!==null;)r|=o.lanes|o.childLanes,o=o.sibling;n.childLanes=r}e!==null&&!(e.flags&2048)&&(e.firstEffect===null&&(e.firstEffect=t.firstEffect),t.lastEffect!==null&&(e.lastEffect!==null&&(e.lastEffect.nextEffect=t.firstEffect),e.lastEffect=t.lastEffect),1u&&(a=u,u=D,D=a),a=nc(y,D),l=nc(y,u),a&&l&&(_.rangeCount!==1||_.anchorNode!==a.node||_.anchorOffset!==a.offset||_.focusNode!==l.node||_.focusOffset!==l.offset)&&(x=x.createRange(),x.setStart(a.node,a.offset),_.removeAllRanges(),D>u?(_.addRange(x),_.extend(l.node,l.offset)):(x.setEnd(l.node,l.offset),_.addRange(x)))))),x=[],_=y;_=_.parentNode;)_.nodeType===1&&x.push({element:_,left:_.scrollLeft,top:_.scrollTop});for(typeof y.focus=="function"&&y.focus(),y=0;yKe()-Oa?tr(e,0):Pa|=n),Rt(e,t)}function Wh(e,t){var n=e.stateNode;n!==null&&n.delete(t),t=0,t===0&&(t=e.mode,t&2?t&4?(bt===0&&(bt=mr),t=Wn(62914560&~bt),t===0&&(t=4194304)):t=ir()===99?1:2:t=1),n=pt(),e=el(e,t),e!==null&&(Vi(e,t,n),Rt(e,n))}var lp;lp=function(e,t,n){var r=t.lanes;if(e!==null)if(e.memoizedProps!==t.pendingProps||st.current)It=!0;else if(n&r)It=!!(e.flags&16384);else{switch(It=!1,t.tag){case 3:Nc(t),Al();break;case 5:Ec(t);break;case 1:ct(t.type)&&ai(t);break;case 4:vu(t,t.stateNode.containerInfo);break;case 10:r=t.memoizedProps.value;var o=t.type._context;Me(ki,o._currentValue),o._currentValue=r;break;case 13:if(t.memoizedState!==null)return n&t.child.childLanes?Ic(e,t,n):(Me(Le,Le.current&1),t=Vt(e,t,n),t!==null?t.sibling:null);Me(Le,Le.current&1);break;case 19:if(r=(n&t.childLanes)!==0,e.flags&64){if(r)return Dc(e,t,n);t.flags|=64}if(o=t.memoizedState,o!==null&&(o.rendering=null,o.tail=null,o.lastEffect=null),Me(Le,Le.current),r)break;return null;case 23:case 24:return t.lanes=0,Dl(e,t,n)}return Vt(e,t,n)}else It=!1;switch(t.lanes=0,t.tag){case 2:if(r=t.type,e!==null&&(e.alternate=null,t.alternate=null,t.flags|=2),e=t.pendingProps,o=or(t,qe.current),Zn(t,n),o=Ea(null,t,r,e,o,n),t.flags|=1,typeof o=="object"&&o!==null&&typeof o.render=="function"&&o.$$typeof===void 0){if(t.tag=1,t.memoizedState=null,t.updateQueue=null,ct(r)){var l=!0;ai(t)}else l=!1;t.memoizedState=o.state!==null&&o.state!==void 0?o.state:null,ga(t);var u=r.getDerivedStateFromProps;typeof u=="function"&&Oi(t,r,u,e),o.updater=Zi,t.stateNode=o,o._reactInternals=t,pu(t,r,e,n),t=yu(null,t,r,!0,l,n)}else t.tag=0,at(null,t,o,n),t=t.child;return t;case 16:o=t.elementType;e:{switch(e!==null&&(e.alternate=null,t.alternate=null,t.flags|=2),e=t.pendingProps,l=o._init,o=l(o._payload),t.type=o,l=t.tag=$h(o),e=Nt(o,e),l){case 0:t=gu(null,t,o,e,n);break e;case 1:t=Tc(null,t,o,e,n);break e;case 11:t=Pc(null,t,o,e,n);break e;case 14:t=Oc(null,t,o,Nt(o.type,e),r,n);break e}throw Error(H(306,o,""))}return t;case 0:return r=t.type,o=t.pendingProps,o=t.elementType===r?o:Nt(r,o),gu(e,t,r,o,n);case 1:return r=t.type,o=t.pendingProps,o=t.elementType===r?o:Nt(r,o),Tc(e,t,r,o,n);case 3:if(Nc(t),r=t.updateQueue,e===null||r===null)throw Error(H(282));if(r=t.pendingProps,o=t.memoizedState,o=o!==null?o.element:null,Id(e,t),ho(t,r,null,n),r=t.memoizedState.element,r===o)Al(),t=Vt(e,t,n);else{if(o=t.stateNode,(l=o.hydrate)&&(nn=qn(t.stateNode.containerInfo.firstChild),Gt=t,l=Dt=!0),l){if(e=o.mutableSourceEagerHydrationData,e!=null)for(o=0;o"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(ap)}catch(e){console.error(e)}}ap(),Lf.exports=Pt;var Ba=Lf.exports;const Kh=Co(Ba);class Yh{constructor(){typeof acquireVsCodeApi=="function"&&(this.vsCodeApi=acquireVsCodeApi())}postMessage(t){this.vsCodeApi?this.vsCodeApi.postMessage(t):console.log(t)}getState(){if(this.vsCodeApi)return this.vsCodeApi.getState();{const t=localStorage.getItem("vscodeState");return t?JSON.parse(t):void 0}}setState(t){return this.vsCodeApi?this.vsCodeApi.setState(t):(localStorage.setItem("vscodeState",JSON.stringify(t)),t)}}const Vc=new Yh;var sp={exports:{}},Iu={exports:{}};(function(e,t){(function(n,r){r(t,ae)})(je,function(n,r){function o(i,s,f,p,g,v,w){try{var N=i[v](w),L=N.value}catch(M){return void f(M)}N.done?s(L):Promise.resolve(L).then(p,g)}function l(i){return function(){var s=this,f=arguments;return new Promise(function(p,g){var v=i.apply(s,f);function w(L){o(v,p,g,w,N,"next",L)}function N(L){o(v,p,g,w,N,"throw",L)}w(void 0)})}}function u(){return(u=Object.assign||function(i){for(var s=1;s=0||(g[f]=i[f]);return g}function d(i){var s=function(f,p){if(typeof f!="object"||f===null)return f;var g=f[Symbol.toPrimitive];if(g!==void 0){var v=g.call(f,p||"default");if(typeof v!="object")return v;throw new TypeError("@@toPrimitive must return a primitive value.")}return(p==="string"?String:Number)(f)}(i,"string");return typeof s=="symbol"?s:String(s)}r=r&&Object.prototype.hasOwnProperty.call(r,"default")?r.default:r;var c={init:"init"},R=function(i){var s=i.value;return s===void 0?"":s},P=function(){return r.createElement(r.Fragment,null," ")},S={Cell:R,width:150,minWidth:0,maxWidth:Number.MAX_SAFE_INTEGER};function k(){for(var i=arguments.length,s=new Array(i),f=0;f(v=typeof v=="number"?v:1/0)){var w=g;g=v,v=w}return i.filter(function(N){return s.some(function(L){var M=N.values[L];return M>=g&&M<=v})})};Za.autoRemove=function(i){return!i||typeof i[0]!="number"&&typeof i[1]!="number"};var Rr=Object.freeze({__proto__:null,text:Ua,exactText:ba,exactTextCase:Ga,includes:Va,includesAll:Xa,includesSome:Qa,includesValue:Ka,exact:Ya,equals:qa,between:Za});c.resetFilters="resetFilters",c.setFilter="setFilter",c.setAllFilters="setAllFilters";var Ja=function(i){i.stateReducers.push(Ap),i.useInstance.push(Dp)};function Ap(i,s,f,p){if(s.type===c.init)return u({filters:[]},i);if(s.type===c.resetFilters)return u({},i,{filters:p.initialState.filters||[]});if(s.type===c.setFilter){var g=s.columnId,v=s.filterValue,w=p.allColumns,N=p.filterTypes,L=w.find(function(G){return G.id===g});if(!L)throw new Error("React-Table: Could not find a column with id: "+g);var M=it(L.filter,N||{},Rr),$=i.filters.find(function(G){return G.id===g}),z=y(v,$&&$.value);return Ze(M.autoRemove,z,L)?u({},i,{filters:i.filters.filter(function(G){return G.id!==g})}):u({},i,$?{filters:i.filters.map(function(G){return G.id===g?{id:g,value:z}:G})}:{filters:[].concat(i.filters,[{id:g,value:z}])})}if(s.type===c.setAllFilters){var j=s.filters,A=p.allColumns,W=p.filterTypes;return u({},i,{filters:y(j,i.filters).filter(function(G){var V=A.find(function(q){return q.id===G.id});return!Ze(it(V.filter,W||{},Rr).autoRemove,G.value,V)})})}}function Dp(i){var s=i.data,f=i.rows,p=i.flatRows,g=i.rowsById,v=i.allColumns,w=i.filterTypes,N=i.manualFilters,L=i.defaultCanFilter,M=L!==void 0&&L,$=i.disableFilters,z=i.state.filters,j=i.dispatch,A=i.autoResetFilters,W=A===void 0||A,G=r.useCallback(function(Q,ie){j({type:c.setFilter,columnId:Q,filterValue:ie})},[j]),V=r.useCallback(function(Q){j({type:c.setAllFilters,filters:Q})},[j]);v.forEach(function(Q){var ie=Q.id,pe=Q.accessor,te=Q.defaultCanFilter,oe=Q.disableFilters;Q.canFilter=pe?Re(oe!==!0&&void 0,$!==!0&&void 0,!0):Re(te,M,!1),Q.setFilter=function(le){return G(Q.id,le)};var ge=z.find(function(le){return le.id===ie});Q.filterValue=ge&&ge.value});var q=r.useMemo(function(){if(N||!z.length)return[f,p,g];var Q=[],ie={};return[function pe(te,oe){oe===void 0&&(oe=0);var ge=te;return(ge=z.reduce(function(le,he){var me=he.id,Ee=he.value,Z=v.find(function(Ie){return Ie.id===me});if(!Z)return le;oe===0&&(Z.preFilteredRows=le);var ve=it(Z.filter,w||{},Rr);return ve?(Z.filteredRows=ve(le,[me],Ee),Z.filteredRows):(console.warn("Could not find a valid 'column.filter' for column with the ID: "+Z.id+"."),le)},te)).forEach(function(le){Q.push(le),ie[le.id]=le,le.subRows&&(le.subRows=le.subRows&&le.subRows.length>0?pe(le.subRows,oe+1):le.subRows)}),ge}(f),Q,ie]},[N,z,f,p,g,v,w]),de=q[0],Y=q[1],U=q[2];r.useMemo(function(){v.filter(function(Q){return!z.find(function(ie){return ie.id===Q.id})}).forEach(function(Q){Q.preFilteredRows=de,Q.filteredRows=de})},[de,z,v]);var ue=x(W);D(function(){ue()&&j({type:c.resetFilters})},[j,N?null:s]),Object.assign(i,{preFilteredRows:f,preFilteredFlatRows:p,preFilteredRowsById:g,filteredRows:de,filteredFlatRows:Y,filteredRowsById:U,rows:de,flatRows:Y,rowsById:U,setFilter:G,setAllFilters:V})}Ja.pluginName="useFilters",c.resetGlobalFilter="resetGlobalFilter",c.setGlobalFilter="setGlobalFilter";var es=function(i){i.stateReducers.push(Fp),i.useInstance.push(zp)};function Fp(i,s,f,p){if(s.type===c.resetGlobalFilter)return u({},i,{globalFilter:p.initialState.globalFilter||void 0});if(s.type===c.setGlobalFilter){var g=s.filterValue,v=p.userFilterTypes,w=it(p.globalFilter,v||{},Rr),N=y(g,i.globalFilter);return Ze(w.autoRemove,N)?(i.globalFilter,a(i,["globalFilter"])):u({},i,{globalFilter:N})}}function zp(i){var s=i.data,f=i.rows,p=i.flatRows,g=i.rowsById,v=i.allColumns,w=i.filterTypes,N=i.globalFilter,L=i.manualGlobalFilter,M=i.state.globalFilter,$=i.dispatch,z=i.autoResetGlobalFilter,j=z===void 0||z,A=i.disableGlobalFilter,W=r.useCallback(function(U){$({type:c.setGlobalFilter,filterValue:U})},[$]),G=r.useMemo(function(){if(L||M===void 0)return[f,p,g];var U=[],ue={},Q=it(N,w||{},Rr);if(!Q)return console.warn("Could not find a valid 'globalFilter' option."),f;v.forEach(function(pe){var te=pe.disableGlobalFilter;pe.canFilter=Re(te!==!0&&void 0,A!==!0&&void 0,!0)});var ie=v.filter(function(pe){return pe.canFilter===!0});return[function pe(te){return(te=Q(te,ie.map(function(oe){return oe.id}),M)).forEach(function(oe){U.push(oe),ue[oe.id]=oe,oe.subRows=oe.subRows&&oe.subRows.length?pe(oe.subRows):oe.subRows}),te}(f),U,ue]},[L,M,N,w,v,f,p,g,A]),V=G[0],q=G[1],de=G[2],Y=x(j);D(function(){Y()&&$({type:c.resetGlobalFilter})},[$,L?null:s]),Object.assign(i,{preGlobalFilteredRows:f,preGlobalFilteredFlatRows:p,preGlobalFilteredRowsById:g,globalFilteredRows:V,globalFilteredFlatRows:q,globalFilteredRowsById:de,rows:V,flatRows:q,rowsById:de,setGlobalFilter:W,disableGlobalFilter:A})}function ts(i,s){return s.reduce(function(f,p){return f+(typeof p=="number"?p:0)},0)}es.pluginName="useGlobalFilter";var ns=Object.freeze({__proto__:null,sum:ts,min:function(i){var s=i[0]||0;return i.forEach(function(f){typeof f=="number"&&(s=Math.min(s,f))}),s},max:function(i){var s=i[0]||0;return i.forEach(function(f){typeof f=="number"&&(s=Math.max(s,f))}),s},minMax:function(i){var s=i[0]||0,f=i[0]||0;return i.forEach(function(p){typeof p=="number"&&(s=Math.min(s,p),f=Math.max(f,p))}),s+".."+f},average:function(i){return ts(0,i)/i.length},median:function(i){if(!i.length)return null;var s=Math.floor(i.length/2),f=[].concat(i).sort(function(p,g){return p-g});return i.length%2!=0?f[s]:(f[s-1]+f[s])/2},unique:function(i){return Array.from(new Set(i).values())},uniqueCount:function(i){return new Set(i).size},count:function(i){return i.length}}),jp=[],Wp={};c.resetGroupBy="resetGroupBy",c.setGroupBy="setGroupBy",c.toggleGroupBy="toggleGroupBy";var rs=function(i){i.getGroupByToggleProps=[Hp],i.stateReducers.push($p),i.visibleColumnsDeps.push(function(s,f){var p=f.instance;return[].concat(s,[p.state.groupBy])}),i.visibleColumns.push(Up),i.useInstance.push(Gp),i.prepareRow.push(Vp)};rs.pluginName="useGroupBy";var Hp=function(i,s){var f=s.header;return[i,{onClick:f.canGroupBy?function(p){p.persist(),f.toggleGroupBy()}:void 0,style:{cursor:f.canGroupBy?"pointer":void 0},title:"Toggle GroupBy"}]};function $p(i,s,f,p){if(s.type===c.init)return u({groupBy:[]},i);if(s.type===c.resetGroupBy)return u({},i,{groupBy:p.initialState.groupBy||[]});if(s.type===c.setGroupBy)return u({},i,{groupBy:s.value});if(s.type===c.toggleGroupBy){var g=s.columnId,v=s.value,w=v!==void 0?v:!i.groupBy.includes(g);return u({},i,w?{groupBy:[].concat(i.groupBy,[g])}:{groupBy:i.groupBy.filter(function(N){return N!==g})})}}function Up(i,s){var f=s.instance.state.groupBy,p=f.map(function(v){return i.find(function(w){return w.id===v})}).filter(Boolean),g=i.filter(function(v){return!f.includes(v.id)});return(i=[].concat(p,g)).forEach(function(v){v.isGrouped=f.includes(v.id),v.groupedIndex=f.indexOf(v.id)}),i}var bp={};function Gp(i){var s=i.data,f=i.rows,p=i.flatRows,g=i.rowsById,v=i.allColumns,w=i.flatHeaders,N=i.groupByFn,L=N===void 0?os:N,M=i.manualGroupBy,$=i.aggregations,z=$===void 0?bp:$,j=i.plugins,A=i.state.groupBy,W=i.dispatch,G=i.autoResetGroupBy,V=G===void 0||G,q=i.disableGroupBy,de=i.defaultCanGroupBy,Y=i.getHooks;m(j,["useColumnOrder","useFilters"],"useGroupBy");var U=x(i);v.forEach(function(Z){var ve=Z.accessor,Ie=Z.defaultGroupBy,tt=Z.disableGroupBy;Z.canGroupBy=ve?Re(Z.canGroupBy,tt!==!0&&void 0,q!==!0&&void 0,!0):Re(Z.canGroupBy,Ie,de,!1),Z.canGroupBy&&(Z.toggleGroupBy=function(){return i.toggleGroupBy(Z.id)}),Z.Aggregated=Z.Aggregated||Z.Cell});var ue=r.useCallback(function(Z,ve){W({type:c.toggleGroupBy,columnId:Z,value:ve})},[W]),Q=r.useCallback(function(Z){W({type:c.setGroupBy,value:Z})},[W]);w.forEach(function(Z){Z.getGroupByToggleProps=I(Y().getGroupByToggleProps,{instance:U(),header:Z})});var ie=r.useMemo(function(){if(M||!A.length)return[f,p,g,jp,Wp,p,g];var Z=A.filter(function($e){return v.find(function(qt){return qt.id===$e})}),ve=[],Ie={},tt=[],ne={},Oe=[],Ae={},nt=function $e(qt,Ht,Cs){if(Ht===void 0&&(Ht=0),Ht===Z.length)return qt.map(function(zo){return u({},zo,{depth:Ht})});var ml=Z[Ht],Bv=L(qt,ml);return Object.entries(Bv).map(function(zo,Av){var xs=zo[0],jo=zo[1],Wo=ml+":"+xs,Rs=$e(jo,Ht+1,Wo=Cs?Cs+">"+Wo:Wo),ks=Ht?Pe(jo,"leafRows"):jo,Dv=function(gt,hl,zv){var Ho={};return v.forEach(function(De){if(Z.includes(De.id))Ho[De.id]=hl[0]?hl[0].values[De.id]:null;else{var _s=typeof De.aggregate=="function"?De.aggregate:z[De.aggregate]||ns[De.aggregate];if(_s){var jv=hl.map(function($o){return $o.values[De.id]}),Wv=gt.map(function($o){var gl=$o.values[De.id];if(!zv&&De.aggregateValue){var Ps=typeof De.aggregateValue=="function"?De.aggregateValue:z[De.aggregateValue]||ns[De.aggregateValue];if(!Ps)throw console.info({column:De}),new Error("React Table: Invalid column.aggregateValue option for column listed above");gl=Ps(gl,$o,De)}return gl});Ho[De.id]=_s(Wv,jv)}else{if(De.aggregate)throw console.info({column:De}),new Error("React Table: Invalid column.aggregate option for column listed above");Ho[De.id]=null}}}),Ho}(ks,jo,Ht),Fv={id:Wo,isGrouped:!0,groupByID:ml,groupByVal:xs,values:Dv,subRows:Rs,leafRows:ks,depth:Ht,index:Av};return Rs.forEach(function(gt){ve.push(gt),Ie[gt.id]=gt,gt.isGrouped?(tt.push(gt),ne[gt.id]=gt):(Oe.push(gt),Ae[gt.id]=gt)}),Fv})}(f);return nt.forEach(function($e){ve.push($e),Ie[$e.id]=$e,$e.isGrouped?(tt.push($e),ne[$e.id]=$e):(Oe.push($e),Ae[$e.id]=$e)}),[nt,ve,Ie,tt,ne,Oe,Ae]},[M,A,f,p,g,v,z,L]),pe=ie[0],te=ie[1],oe=ie[2],ge=ie[3],le=ie[4],he=ie[5],me=ie[6],Ee=x(V);D(function(){Ee()&&W({type:c.resetGroupBy})},[W,M?null:s]),Object.assign(i,{preGroupedRows:f,preGroupedFlatRow:p,preGroupedRowsById:g,groupedRows:pe,groupedFlatRows:te,groupedRowsById:oe,onlyGroupedFlatRows:ge,onlyGroupedRowsById:le,nonGroupedFlatRows:he,nonGroupedRowsById:me,rows:pe,flatRows:te,rowsById:oe,toggleGroupBy:ue,setGroupBy:Q})}function Vp(i){i.allCells.forEach(function(s){var f;s.isGrouped=s.column.isGrouped&&s.column.id===i.groupByID,s.isPlaceholder=!s.isGrouped&&s.column.isGrouped,s.isAggregated=!s.isGrouped&&!s.isPlaceholder&&((f=i.subRows)==null?void 0:f.length)})}function os(i,s){return i.reduce(function(f,p,g){var v=""+p.values[s];return f[v]=Array.isArray(f[v])?f[v]:[],f[v].push(p),f},{})}var is=/([0-9]+)/gm;function sl(i,s){return i===s?0:i>s?1:-1}function kr(i,s,f){return[i.values[f],s.values[f]]}function ls(i){return typeof i=="number"?isNaN(i)||i===1/0||i===-1/0?"":String(i):typeof i=="string"?i:""}var Xp=Object.freeze({__proto__:null,alphanumeric:function(i,s,f){var p=kr(i,s,f),g=p[0],v=p[1];for(g=ls(g),v=ls(v),g=g.split(is).filter(Boolean),v=v.split(is).filter(Boolean);g.length&&v.length;){var w=g.shift(),N=v.shift(),L=parseInt(w,10),M=parseInt(N,10),$=[L,M].sort();if(isNaN($[0])){if(w>N)return 1;if(N>w)return-1}else{if(isNaN($[1]))return isNaN(L)?-1:1;if(L>M)return 1;if(M>L)return-1}}return g.length-v.length},datetime:function(i,s,f){var p=kr(i,s,f),g=p[0],v=p[1];return sl(g=g.getTime(),v=v.getTime())},basic:function(i,s,f){var p=kr(i,s,f);return sl(p[0],p[1])},string:function(i,s,f){var p=kr(i,s,f),g=p[0],v=p[1];for(g=g.split("").filter(Boolean),v=v.split("").filter(Boolean);g.length&&v.length;){var w=g.shift(),N=v.shift(),L=w.toLowerCase(),M=N.toLowerCase();if(L>M)return 1;if(M>L)return-1;if(w>N)return 1;if(N>w)return-1}return g.length-v.length},number:function(i,s,f){var p=kr(i,s,f),g=p[0],v=p[1],w=/[^0-9.]/gi;return sl(g=Number(String(g).replace(w,"")),v=Number(String(v).replace(w,"")))}});c.resetSortBy="resetSortBy",c.setSortBy="setSortBy",c.toggleSortBy="toggleSortBy",c.clearSortBy="clearSortBy",S.sortType="alphanumeric",S.sortDescFirst=!1;var us=function(i){i.getSortByToggleProps=[Qp],i.stateReducers.push(Kp),i.useInstance.push(Yp)};us.pluginName="useSortBy";var Qp=function(i,s){var f=s.instance,p=s.column,g=f.isMultiSortEvent,v=g===void 0?function(w){return w.shiftKey}:g;return[i,{onClick:p.canSort?function(w){w.persist(),p.toggleSortBy(void 0,!f.disableMultiSort&&v(w))}:void 0,style:{cursor:p.canSort?"pointer":void 0},title:p.canSort?"Toggle SortBy":void 0}]};function Kp(i,s,f,p){if(s.type===c.init)return u({sortBy:[]},i);if(s.type===c.resetSortBy)return u({},i,{sortBy:p.initialState.sortBy||[]});if(s.type===c.clearSortBy)return u({},i,{sortBy:i.sortBy.filter(function(U){return U.id!==s.columnId})});if(s.type===c.setSortBy)return u({},i,{sortBy:s.sortBy});if(s.type===c.toggleSortBy){var g,v=s.columnId,w=s.desc,N=s.multi,L=p.allColumns,M=p.disableMultiSort,$=p.disableSortRemove,z=p.disableMultiRemove,j=p.maxMultiSortColCount,A=j===void 0?Number.MAX_SAFE_INTEGER:j,W=i.sortBy,G=L.find(function(U){return U.id===v}).sortDescFirst,V=W.find(function(U){return U.id===v}),q=W.findIndex(function(U){return U.id===v}),de=w!=null,Y=[];return(g=!M&&N?V?"toggle":"add":q!==W.length-1||W.length!==1?"replace":V?"toggle":"replace")!="toggle"||$||de||N&&z||!(V&&V.desc&&!G||!V.desc&&G)||(g="remove"),g==="replace"?Y=[{id:v,desc:de?w:G}]:g==="add"?(Y=[].concat(W,[{id:v,desc:de?w:G}])).splice(0,Y.length-A):g==="toggle"?Y=W.map(function(U){return U.id===v?u({},U,{desc:de?w:!V.desc}):U}):g==="remove"&&(Y=W.filter(function(U){return U.id!==v})),u({},i,{sortBy:Y})}}function Yp(i){var s=i.data,f=i.rows,p=i.flatRows,g=i.allColumns,v=i.orderByFn,w=v===void 0?as:v,N=i.sortTypes,L=i.manualSortBy,M=i.defaultCanSort,$=i.disableSortBy,z=i.flatHeaders,j=i.state.sortBy,A=i.dispatch,W=i.plugins,G=i.getHooks,V=i.autoResetSortBy,q=V===void 0||V;m(W,["useFilters","useGlobalFilter","useGroupBy","usePivotColumns"],"useSortBy");var de=r.useCallback(function(te){A({type:c.setSortBy,sortBy:te})},[A]),Y=r.useCallback(function(te,oe,ge){A({type:c.toggleSortBy,columnId:te,desc:oe,multi:ge})},[A]),U=x(i);z.forEach(function(te){var oe=te.accessor,ge=te.canSort,le=te.disableSortBy,he=te.id,me=oe?Re(le!==!0&&void 0,$!==!0&&void 0,!0):Re(M,ge,!1);te.canSort=me,te.canSort&&(te.toggleSortBy=function(Z,ve){return Y(te.id,Z,ve)},te.clearSortBy=function(){A({type:c.clearSortBy,columnId:te.id})}),te.getSortByToggleProps=I(G().getSortByToggleProps,{instance:U(),column:te});var Ee=j.find(function(Z){return Z.id===he});te.isSorted=!!Ee,te.sortedIndex=j.findIndex(function(Z){return Z.id===he}),te.isSortedDesc=te.isSorted?Ee.desc:void 0});var ue=r.useMemo(function(){if(L||!j.length)return[f,p];var te=[],oe=j.filter(function(ge){return g.find(function(le){return le.id===ge.id})});return[function ge(le){var he=w(le,oe.map(function(me){var Ee=g.find(function(Ie){return Ie.id===me.id});if(!Ee)throw new Error("React-Table: Could not find a column with id: "+me.id+" while sorting");var Z=Ee.sortType,ve=Ne(Z)||(N||{})[Z]||Xp[Z];if(!ve)throw new Error("React-Table: Could not find a valid sortType of '"+Z+"' for column '"+me.id+"'.");return function(Ie,tt){return ve(Ie,tt,me.id,me.desc)}}),oe.map(function(me){var Ee=g.find(function(Z){return Z.id===me.id});return Ee&&Ee.sortInverted?me.desc:!me.desc}));return he.forEach(function(me){te.push(me),me.subRows&&me.subRows.length!==0&&(me.subRows=ge(me.subRows))}),he}(f),te]},[L,j,f,p,g,w,N]),Q=ue[0],ie=ue[1],pe=x(q);D(function(){pe()&&A({type:c.resetSortBy})},[L?null:s]),Object.assign(i,{preSortedRows:f,preSortedFlatRows:p,sortedRows:Q,sortedFlatRows:ie,rows:Q,flatRows:ie,setSortBy:de,toggleSortBy:Y})}function as(i,s,f){return[].concat(i).sort(function(p,g){for(var v=0;vi.pageIndex?N=g===-1?v.length>=i.pageSize:w-1),N?u({},i,{pageIndex:w}):i}if(s.type===c.setPageSize){var L=s.pageSize,M=i.pageSize*i.pageIndex;return u({},i,{pageIndex:Math.floor(M/L),pageSize:L})}}function Zp(i){var s=i.rows,f=i.autoResetPage,p=f===void 0||f,g=i.manualExpandedKey,v=g===void 0?"expanded":g,w=i.plugins,N=i.pageCount,L=i.paginateExpandedRows,M=L===void 0||L,$=i.expandSubRows,z=$===void 0||$,j=i.state,A=j.pageSize,W=j.pageIndex,G=j.expanded,V=j.globalFilter,q=j.filters,de=j.groupBy,Y=j.sortBy,U=i.dispatch,ue=i.data,Q=i.manualPagination;m(w,["useGlobalFilter","useFilters","useGroupBy","useSortBy","useExpanded"],"usePagination");var ie=x(p);D(function(){ie()&&U({type:c.resetPage})},[U,Q?null:ue,V,q,de,Y]);var pe=Q?N:Math.ceil(s.length/A),te=r.useMemo(function(){return pe>0?[].concat(new Array(pe)).fill(null).map(function(ve,Ie){return Ie}):[]},[pe]),oe=r.useMemo(function(){var ve;if(Q)ve=s;else{var Ie=A*W,tt=Ie+A;ve=s.slice(Ie,tt)}return M?ve:He(ve,{manualExpandedKey:v,expanded:G,expandSubRows:z})},[z,G,v,Q,W,A,M,s]),ge=W>0,le=pe===-1?oe.length>=A:W-1&&v.push(g.splice(L,1)[0])};g.length&&p.length;)w();return[].concat(v,g)}function Ev(i){var s=i.dispatch;i.setColumnOrder=r.useCallback(function(f){return s({type:c.setColumnOrder,columnOrder:f})},[s])}hs.pluginName="useColumnOrder",S.canResize=!0,c.columnStartResizing="columnStartResizing",c.columnResizing="columnResizing",c.columnDoneResizing="columnDoneResizing",c.resetResize="resetResize";var gs=function(i){i.getResizerProps=[Cv],i.getHeaderProps.push({style:{position:"relative"}}),i.stateReducers.push(xv),i.useInstance.push(kv),i.useInstanceBeforeDimensions.push(Rv)},Cv=function(i,s){var f=s.instance,p=s.header,g=f.dispatch,v=function(w,N){var L=!1;if(w.type==="touchstart"){if(w.touches&&w.touches.length>1)return;L=!0}var M,$,z=function(Y){var U=[];return function ue(Q){Q.columns&&Q.columns.length&&Q.columns.map(ue),U.push(Q)}(Y),U}(N).map(function(Y){return[Y.id,Y.totalWidth]}),j=L?Math.round(w.touches[0].clientX):w.clientX,A=function(){window.cancelAnimationFrame(M),M=null,g({type:c.columnDoneResizing})},W=function(){window.cancelAnimationFrame(M),M=null,g({type:c.columnResizing,clientX:$})},G=function(Y){$=Y,M||(M=window.requestAnimationFrame(W))},V={mouse:{moveEvent:"mousemove",moveHandler:function(Y){return G(Y.clientX)},upEvent:"mouseup",upHandler:function(Y){document.removeEventListener("mousemove",V.mouse.moveHandler),document.removeEventListener("mouseup",V.mouse.upHandler),A()}},touch:{moveEvent:"touchmove",moveHandler:function(Y){return Y.cancelable&&(Y.preventDefault(),Y.stopPropagation()),G(Y.touches[0].clientX),!1},upEvent:"touchend",upHandler:function(Y){document.removeEventListener(V.touch.moveEvent,V.touch.moveHandler),document.removeEventListener(V.touch.upEvent,V.touch.moveHandler),A()}}},q=L?V.touch:V.mouse,de=!!function(){if(typeof b=="boolean")return b;var Y=!1;try{var U={get passive(){return Y=!0,!1}};window.addEventListener("test",null,U),window.removeEventListener("test",null,U)}catch{Y=!1}return b=Y}()&&{passive:!1};document.addEventListener(q.moveEvent,q.moveHandler,de),document.addEventListener(q.upEvent,q.upHandler,de),g({type:c.columnStartResizing,columnId:N.id,columnWidth:N.totalWidth,headerIdWidths:z,clientX:j})};return[i,{onMouseDown:function(w){return w.persist()||v(w,p)},onTouchStart:function(w){return w.persist()||v(w,p)},style:{cursor:"col-resize"},draggable:!1,role:"separator"}]};function xv(i,s){if(s.type===c.init)return u({columnResizing:{columnWidths:{}}},i);if(s.type===c.resetResize)return u({},i,{columnResizing:{columnWidths:{}}});if(s.type===c.columnStartResizing){var f=s.clientX,p=s.columnId,g=s.columnWidth,v=s.headerIdWidths;return u({},i,{columnResizing:u({},i.columnResizing,{startX:f,headerIdWidths:v,columnWidth:g,isResizingColumn:p})})}if(s.type===c.columnResizing){var w=s.clientX,N=i.columnResizing,L=N.startX,M=N.columnWidth,$=N.headerIdWidths,z=(w-L)/M,j={};return($===void 0?[]:$).forEach(function(A){var W=A[0],G=A[1];j[W]=Math.max(G+G*z,0)}),u({},i,{columnResizing:u({},i.columnResizing,{columnWidths:u({},i.columnResizing.columnWidths,{},j)})})}return s.type===c.columnDoneResizing?u({},i,{columnResizing:u({},i.columnResizing,{startX:null,isResizingColumn:null})}):void 0}gs.pluginName="useResizeColumns";var Rv=function(i){var s=i.flatHeaders,f=i.disableResizing,p=i.getHooks,g=i.state.columnResizing,v=x(i);s.forEach(function(w){var N=Re(w.disableResizing!==!0&&void 0,f!==!0&&void 0,!0);w.canResize=N,w.width=g.columnWidths[w.id]||w.originalWidth||w.width,w.isResizing=g.isResizingColumn===w.id,N&&(w.getResizerProps=I(p().getResizerProps,{instance:v(),header:w}))})};function kv(i){var s=i.plugins,f=i.dispatch,p=i.autoResetResize,g=p===void 0||p,v=i.columns;m(s,["useAbsoluteLayout"],"useResizeColumns");var w=x(g);D(function(){w()&&f({type:c.resetResize})},[v]);var N=r.useCallback(function(){return f({type:c.resetResize})},[f]);Object.assign(i,{resetResizing:N})}var cl={position:"absolute",top:0},ys=function(i){i.getTableBodyProps.push(Fo),i.getRowProps.push(Fo),i.getHeaderGroupProps.push(Fo),i.getFooterGroupProps.push(Fo),i.getHeaderProps.push(function(s,f){var p=f.column;return[s,{style:u({},cl,{left:p.totalLeft+"px",width:p.totalWidth+"px"})}]}),i.getCellProps.push(function(s,f){var p=f.cell;return[s,{style:u({},cl,{left:p.column.totalLeft+"px",width:p.column.totalWidth+"px"})}]}),i.getFooterProps.push(function(s,f){var p=f.column;return[s,{style:u({},cl,{left:p.totalLeft+"px",width:p.totalWidth+"px"})}]})};ys.pluginName="useAbsoluteLayout";var Fo=function(i,s){return[i,{style:{position:"relative",width:s.instance.totalColumnsWidth+"px"}}]},fl={display:"inline-block",boxSizing:"border-box"},dl=function(i,s){return[i,{style:{display:"flex",width:s.instance.totalColumnsWidth+"px"}}]},ws=function(i){i.getRowProps.push(dl),i.getHeaderGroupProps.push(dl),i.getFooterGroupProps.push(dl),i.getHeaderProps.push(function(s,f){var p=f.column;return[s,{style:u({},fl,{width:p.totalWidth+"px"})}]}),i.getCellProps.push(function(s,f){var p=f.cell;return[s,{style:u({},fl,{width:p.column.totalWidth+"px"})}]}),i.getFooterProps.push(function(s,f){var p=f.column;return[s,{style:u({},fl,{width:p.totalWidth+"px"})}]})};function Ss(i){i.getTableProps.push(_v),i.getRowProps.push(pl),i.getHeaderGroupProps.push(pl),i.getFooterGroupProps.push(pl),i.getHeaderProps.push(Pv),i.getCellProps.push(Ov),i.getFooterProps.push(Tv)}ws.pluginName="useBlockLayout",Ss.pluginName="useFlexLayout";var _v=function(i,s){return[i,{style:{minWidth:s.instance.totalColumnsMinWidth+"px"}}]},pl=function(i,s){return[i,{style:{display:"flex",flex:"1 0 auto",minWidth:s.instance.totalColumnsMinWidth+"px"}}]},Pv=function(i,s){var f=s.column;return[i,{style:{boxSizing:"border-box",flex:f.totalFlexWidth?f.totalFlexWidth+" 0 auto":void 0,minWidth:f.totalMinWidth+"px",width:f.totalWidth+"px"}}]},Ov=function(i,s){var f=s.cell;return[i,{style:{boxSizing:"border-box",flex:f.column.totalFlexWidth+" 0 auto",minWidth:f.column.totalMinWidth+"px",width:f.column.totalWidth+"px"}}]},Tv=function(i,s){var f=s.column;return[i,{style:{boxSizing:"border-box",flex:f.totalFlexWidth?f.totalFlexWidth+" 0 auto":void 0,minWidth:f.totalMinWidth+"px",width:f.totalWidth+"px"}}]};function Es(i){i.stateReducers.push(Mv),i.getTableProps.push(Nv),i.getHeaderProps.push(Iv),i.getRowProps.push(Lv)}c.columnStartResizing="columnStartResizing",c.columnResizing="columnResizing",c.columnDoneResizing="columnDoneResizing",c.resetResize="resetResize",Es.pluginName="useGridLayout";var Nv=function(i,s){var f=s.instance;return[i,{style:{display:"grid",gridTemplateColumns:f.visibleColumns.map(function(p){var g;return f.state.gridLayout.columnWidths[p.id]?f.state.gridLayout.columnWidths[p.id]+"px":(g=f.state.columnResizing)!=null&&g.isResizingColumn?f.state.gridLayout.startWidths[p.id]+"px":typeof p.width=="number"?p.width+"px":p.width}).join(" ")}}]},Iv=function(i,s){var f=s.column;return[i,{id:"header-cell-"+f.id,style:{position:"sticky",gridColumn:"span "+f.totalVisibleHeaderCount}}]},Lv=function(i,s){var f=s.row;return f.isExpanded?[i,{style:{gridColumn:"1 / "+(f.cells.length+1)}}]:[i,{}]};function Mv(i,s,f,p){if(s.type===c.init)return u({gridLayout:{columnWidths:{}}},i);if(s.type===c.resetResize)return u({},i,{gridLayout:{columnWidths:{}}});if(s.type===c.columnStartResizing){var g=s.columnId,v=s.headerIdWidths,w=vl(g);if(w!==void 0){var N=p.visibleColumns.reduce(function(U,ue){var Q;return u({},U,((Q={})[ue.id]=vl(ue.id),Q))},{}),L=p.visibleColumns.reduce(function(U,ue){var Q;return u({},U,((Q={})[ue.id]=ue.minWidth,Q))},{}),M=p.visibleColumns.reduce(function(U,ue){var Q;return u({},U,((Q={})[ue.id]=ue.maxWidth,Q))},{}),$=v.map(function(U){var ue=U[0];return[ue,vl(ue)]});return u({},i,{gridLayout:u({},i.gridLayout,{startWidths:N,minWidths:L,maxWidths:M,headerIdGridWidths:$,columnWidth:w})})}return i}if(s.type===c.columnResizing){var z=s.clientX,j=i.columnResizing.startX,A=i.gridLayout,W=A.columnWidth,G=A.minWidths,V=A.maxWidths,q=A.headerIdGridWidths,de=(z-j)/W,Y={};return(q===void 0?[]:q).forEach(function(U){var ue=U[0],Q=U[1];Y[ue]=Math.min(Math.max(G[ue],Q+Q*de),V[ue])}),u({},i,{gridLayout:u({},i.gridLayout,{columnWidths:u({},i.gridLayout.columnWidths,{},Y)})})}return s.type===c.columnDoneResizing?u({},i,{gridLayout:u({},i.gridLayout,{startWidths:{},minWidths:{},maxWidths:{}})}):void 0}function vl(i){var s,f=(s=document.getElementById("header-cell-"+i))==null?void 0:s.offsetWidth;if(f!==void 0)return f}n._UNSTABLE_usePivotColumns=cs,n.actions=c,n.defaultColumn=S,n.defaultGroupByFn=os,n.defaultOrderByFn=as,n.defaultRenderer=R,n.emptyRenderer=P,n.ensurePluginOrder=m,n.flexRender=E,n.functionalUpdate=y,n.loopHooks=h,n.makePropGetter=I,n.makeRenderer=T,n.reduceHooks=O,n.safeUseLayoutEffect=_,n.useAbsoluteLayout=ys,n.useAsyncDebounce=function(i,s){s===void 0&&(s=0);var f=r.useRef({}),p=x(i),g=x(s);return r.useCallback(function(){var v=l(regeneratorRuntime.mark(function w(){var N,L,M,$=arguments;return regeneratorRuntime.wrap(function(z){for(;;)switch(z.prev=z.next){case 0:for(N=$.length,L=new Array(N),M=0;M1?s-1:0),p=1;p(e.ADD_OPTION_TO_COLUMN="add_option_to_column",e.ADD_ROW="add_row",e.UPDATE_COLUMN_TYPE="update_column_type",e.UPDATE_COLUMN_HEADER="update_column_header",e.UPDATE_CELL="update_cell",e.ADD_COLUMN_TO_LEFT="add_column_to_left",e.ADD_COLUMN_TO_RIGHT="add_column_to_right",e.DELETE_COLUMN="delete_column",e.ENABLE_RESET="enable_reset",e.LOAD_DATA="loaddata",e.REMOVE_CHECKED_ROWS="remove_checked_rows",e.RESIZE_COLUMN_WIDTHS="resize_column_widths",e))(Ce||{}),ze=(e=>(e.NUMBER="number",e.TEXT="text",e.SELECT="select",e.CHECKBOX="checkbox",e))(ze||{});const gn={ADD_COLUMN_ID:999999,CHECKBOX_COLUMN_ID:"checkbox_column"};function ji(){return"_"+Math.random().toString(36).substr(2,9)}function Lu(){return`hsl(${Math.floor(Math.random()*360)}, 95%, 90%)`}function Zh(e){let t=e.entries.map(o=>({...o,metadata:typeof o.metadata=="string"?o.metadata:JSON.stringify(o.metadata)})),n=[],r={id:gn.CHECKBOX_COLUMN_ID,label:" ",accessor:"checkbox_column",minWidth:40,width:40,dataType:"checkbox"};if(t.length>0){const o=t[0];n=Object.keys(o).map(l=>({id:l,label:l.replace(/([A-Z])/g," $1").charAt(0).toUpperCase()+l.replace(/([A-Z])/g," $1").slice(1),accessor:l,minWidth:200,dataType:"text",options:[]})),n.push(r)}return{columns:n,data:t,skipReset:!1}}function Jh(e,t){return t.entries=e.data.map(n=>{const r={...n};return delete r[gn.CHECKBOX_COLUMN_ID],r}),t}var cp={},eg=function e(t,n){if(t===n)return!0;if(t&&n&&typeof t=="object"&&typeof n=="object"){if(t.constructor!==n.constructor)return!1;var r,o,l;if(Array.isArray(t)){if(r=t.length,r!=n.length)return!1;for(o=r;o--!==0;)if(!e(t[o],n[o]))return!1;return!0}if(t.constructor===RegExp)return t.source===n.source&&t.flags===n.flags;if(t.valueOf!==Object.prototype.valueOf)return t.valueOf()===n.valueOf();if(t.toString!==Object.prototype.toString)return t.toString()===n.toString();if(l=Object.keys(t),r=l.length,r!==Object.keys(n).length)return!1;for(o=r;o--!==0;)if(!Object.prototype.hasOwnProperty.call(n,l[o]))return!1;for(o=r;o--!==0;){var u=l[o];if(!e(t[u],n[u]))return!1}return!0}return t!==t&&n!==n},fp={exports:{}},tg="SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED",ng=tg,rg=ng;function dp(){}function pp(){}pp.resetWarningCache=dp;var og=function(){function e(r,o,l,u,a,d){if(d!==rg){var c=new Error("Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types");throw c.name="Invariant Violation",c}}e.isRequired=e;function t(){return e}var n={array:e,bigint:e,bool:e,func:e,number:e,object:e,string:e,symbol:e,any:e,arrayOf:t,element:e,elementType:e,instanceOf:t,node:e,objectOf:t,oneOf:t,oneOfType:t,shape:t,exact:t,checkPropTypes:pp,resetWarningCache:dp};return n.PropTypes=n,n};fp.exports=og();var ig=fp.exports,lg=je&&je.__extends||function(){var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(r,o){r.__proto__=o}||function(r,o){for(var l in o)Object.prototype.hasOwnProperty.call(o,l)&&(r[l]=o[l])},e(t,n)};return function(t,n){if(typeof n!="function"&&n!==null)throw new TypeError("Class extends value "+String(n)+" is not a constructor or null");e(t,n);function r(){this.constructor=t}t.prototype=n===null?Object.create(n):(r.prototype=n.prototype,new r)}}(),Wi=je&&je.__assign||function(){return Wi=Object.assign||function(e){for(var t,n=1,r=arguments.length;n/g,"
")}function dg(e){var t=document.createTextNode("");e.appendChild(t);var n=document.activeElement===e;if(t!==null&&t.nodeValue!==null&&n){var r=window.getSelection();if(r!==null){var o=document.createRange();o.setStart(t,t.nodeValue.length),o.collapse(!0),r.removeAllRanges(),r.addRange(o)}e instanceof HTMLElement&&e.focus()}}var pg=function(e){lg(t,e);function t(){var n=e!==null&&e.apply(this,arguments)||this;return n.lastHtml=n.props.html,n.el=typeof n.props.innerRef=="function"?{current:null}:$l.createRef(),n.getEl=function(){return(n.props.innerRef&&typeof n.props.innerRef!="function"?n.props.innerRef:n.el).current},n.emitChange=function(r){var o=n.getEl();if(o){var l=o.innerHTML;if(n.props.onChange&&l!==n.lastHtml){var u=Object.assign({},r,{target:{value:l}});n.props.onChange(u)}n.lastHtml=l}},n}return t.prototype.render=function(){var n=this,r=this.props,o=r.tagName,l=r.html,u=r.innerRef,a=sg(r,["tagName","html","innerRef"]);return $l.createElement(o||"div",Wi(Wi({},a),{ref:typeof u=="function"?function(d){u(d),n.el.current=d}:u||this.el,onInput:this.emitChange,onBlur:this.props.onBlur||this.emitChange,onKeyUp:this.props.onKeyUp||this.emitChange,onKeyDown:this.props.onKeyDown||this.emitChange,contentEditable:!this.props.disabled,dangerouslySetInnerHTML:{__html:l}}),this.props.children)},t.prototype.shouldComponentUpdate=function(n){var r=this.props,o=this.getEl();return!o||Xc(n.html)!==Xc(o.innerHTML)?!0:r.disabled!==n.disabled||r.tagName!==n.tagName||r.className!==n.className||r.innerRef!==n.innerRef||r.placeholder!==n.placeholder||!(0,fg.default)(r.style,n.style)},t.prototype.componentDidUpdate=function(){var n=this.getEl();n&&(this.props.html!==n.innerHTML&&(n.innerHTML=this.props.html),this.lastHtml=this.props.html,dg(n))},t.propTypes={html:$t.string.isRequired,onChange:$t.func,disabled:$t.bool,tagName:$t.string,className:$t.string,style:$t.object,innerRef:$t.oneOfType([$t.object,$t.func])},t}($l.Component),mp=cp.default=pg;function vg({initialValue:e,columnId:t,rowIndex:n,dataDispatch:r}){const[o,l]=ae.useState({value:e,update:!1});function u(d){const c=d.currentTarget;l({value:c.innerText,update:!1})}function a(){l(d=>({...d,update:!0}))}return ae.useEffect(()=>{l({value:e,update:!1})},[e]),ae.useEffect(()=>{o.update&&r&&r({type:Ce.UPDATE_CELL,columnId:t,rowIndex:n,value:o.value})},[o.update,t,n]),F(mp,{html:o.value&&o.value.toString()||"",onChange:u,onBlur:a,className:"data-input"})}function mg({initialValue:e,columnId:t,rowIndex:n,dataDispatch:r}){const[o,l]=ae.useState({value:e,update:!1}),u=d=>{const c=d.currentTarget;l({value:c.innerText,update:!1})},a=()=>{l(d=>({...d,update:!0}))};return ae.useEffect(()=>{l({value:e,update:!1})},[e]),ae.useEffect(()=>{o.update&&r&&r({type:Ce.UPDATE_CELL,columnId:t,rowIndex:n,value:o.value})},[o.update,t,n]),F(mp,{html:o.value&&o.value.toString()||"",onChange:u,onBlur:a,className:"data-input text-align-right"})}var Qc=function(t){return t.reduce(function(n,r){var o=r[0],l=r[1];return n[o]=l,n},{})},Kc=typeof window<"u"&&window.document&&window.document.createElement?ae.useLayoutEffect:ae.useEffect,ft="top",kt="bottom",_t="right",dt="left",Aa="auto",No=[ft,kt,_t,dt],ur="start",So="end",hg="clippingParents",hp="viewport",zr="popper",gg="reference",Yc=No.reduce(function(e,t){return e.concat([t+"-"+ur,t+"-"+So])},[]),gp=[].concat(No,[Aa]).reduce(function(e,t){return e.concat([t,t+"-"+ur,t+"-"+So])},[]),yg="beforeRead",wg="read",Sg="afterRead",Eg="beforeMain",Cg="main",xg="afterMain",Rg="beforeWrite",kg="write",_g="afterWrite",Pg=[yg,wg,Sg,Eg,Cg,xg,Rg,kg,_g];function zt(e){return e?(e.nodeName||"").toLowerCase():null}function vt(e){if(e==null)return window;if(e.toString()!=="[object Window]"){var t=e.ownerDocument;return t&&t.defaultView||window}return e}function Ln(e){var t=vt(e).Element;return e instanceof t||e instanceof Element}function Ct(e){var t=vt(e).HTMLElement;return e instanceof t||e instanceof HTMLElement}function Da(e){if(typeof ShadowRoot>"u")return!1;var t=vt(e).ShadowRoot;return e instanceof t||e instanceof ShadowRoot}function Og(e){var t=e.state;Object.keys(t.elements).forEach(function(n){var r=t.styles[n]||{},o=t.attributes[n]||{},l=t.elements[n];!Ct(l)||!zt(l)||(Object.assign(l.style,r),Object.keys(o).forEach(function(u){var a=o[u];a===!1?l.removeAttribute(u):l.setAttribute(u,a===!0?"":a)}))})}function Tg(e){var t=e.state,n={popper:{position:t.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(t.elements.popper.style,n.popper),t.styles=n,t.elements.arrow&&Object.assign(t.elements.arrow.style,n.arrow),function(){Object.keys(t.elements).forEach(function(r){var o=t.elements[r],l=t.attributes[r]||{},u=Object.keys(t.styles.hasOwnProperty(r)?t.styles[r]:n[r]),a=u.reduce(function(d,c){return d[c]="",d},{});!Ct(o)||!zt(o)||(Object.assign(o.style,a),Object.keys(l).forEach(function(d){o.removeAttribute(d)}))})}}const Ng={name:"applyStyles",enabled:!0,phase:"write",fn:Og,effect:Tg,requires:["computeStyles"]};function Ft(e){return e.split("-")[0]}var On=Math.max,Hi=Math.min,ar=Math.round;function Mu(){var e=navigator.userAgentData;return e!=null&&e.brands&&Array.isArray(e.brands)?e.brands.map(function(t){return t.brand+"/"+t.version}).join(" "):navigator.userAgent}function yp(){return!/^((?!chrome|android).)*safari/i.test(Mu())}function sr(e,t,n){t===void 0&&(t=!1),n===void 0&&(n=!1);var r=e.getBoundingClientRect(),o=1,l=1;t&&Ct(e)&&(o=e.offsetWidth>0&&ar(r.width)/e.offsetWidth||1,l=e.offsetHeight>0&&ar(r.height)/e.offsetHeight||1);var u=Ln(e)?vt(e):window,a=u.visualViewport,d=!yp()&&n,c=(r.left+(d&&a?a.offsetLeft:0))/o,R=(r.top+(d&&a?a.offsetTop:0))/l,P=r.width/o,S=r.height/l;return{width:P,height:S,top:R,right:c+P,bottom:R+S,left:c,x:c,y:R}}function Fa(e){var t=sr(e),n=e.offsetWidth,r=e.offsetHeight;return Math.abs(t.width-n)<=1&&(n=t.width),Math.abs(t.height-r)<=1&&(r=t.height),{x:e.offsetLeft,y:e.offsetTop,width:n,height:r}}function wp(e,t){var n=t.getRootNode&&t.getRootNode();if(e.contains(t))return!0;if(n&&Da(n)){var r=t;do{if(r&&e.isSameNode(r))return!0;r=r.parentNode||r.host}while(r)}return!1}function Kt(e){return vt(e).getComputedStyle(e)}function Ig(e){return["table","td","th"].indexOf(zt(e))>=0}function Sn(e){return((Ln(e)?e.ownerDocument:e.document)||window.document).documentElement}function nl(e){return zt(e)==="html"?e:e.assignedSlot||e.parentNode||(Da(e)?e.host:null)||Sn(e)}function qc(e){return!Ct(e)||Kt(e).position==="fixed"?null:e.offsetParent}function Lg(e){var t=/firefox/i.test(Mu()),n=/Trident/i.test(Mu());if(n&&Ct(e)){var r=Kt(e);if(r.position==="fixed")return null}var o=nl(e);for(Da(o)&&(o=o.host);Ct(o)&&["html","body"].indexOf(zt(o))<0;){var l=Kt(o);if(l.transform!=="none"||l.perspective!=="none"||l.contain==="paint"||["transform","perspective"].indexOf(l.willChange)!==-1||t&&l.willChange==="filter"||t&&l.filter&&l.filter!=="none")return o;o=o.parentNode}return null}function Io(e){for(var t=vt(e),n=qc(e);n&&Ig(n)&&Kt(n).position==="static";)n=qc(n);return n&&(zt(n)==="html"||zt(n)==="body"&&Kt(n).position==="static")?t:n||Lg(e)||t}function za(e){return["top","bottom"].indexOf(e)>=0?"x":"y"}function ro(e,t,n){return On(e,Hi(t,n))}function Mg(e,t,n){var r=ro(e,t,n);return r>n?n:r}function Sp(){return{top:0,right:0,bottom:0,left:0}}function Ep(e){return Object.assign({},Sp(),e)}function Cp(e,t){return t.reduce(function(n,r){return n[r]=e,n},{})}var Bg=function(t,n){return t=typeof t=="function"?t(Object.assign({},n.rects,{placement:n.placement})):t,Ep(typeof t!="number"?t:Cp(t,No))};function Ag(e){var t,n=e.state,r=e.name,o=e.options,l=n.elements.arrow,u=n.modifiersData.popperOffsets,a=Ft(n.placement),d=za(a),c=[dt,_t].indexOf(a)>=0,R=c?"height":"width";if(!(!l||!u)){var P=Bg(o.padding,n),S=Fa(l),k=d==="y"?ft:dt,I=d==="y"?kt:_t,O=n.rects.reference[R]+n.rects.reference[d]-u[d]-n.rects.popper[R],h=u[d]-n.rects.reference[d],m=Io(l),y=m?d==="y"?m.clientHeight||0:m.clientWidth||0:0,x=O/2-h/2,_=P[k],D=y-S[R]-P[I],T=y/2-S[R]/2+x,E=ro(_,T,D),C=d;n.modifiersData[r]=(t={},t[C]=E,t.centerOffset=E-T,t)}}function Dg(e){var t=e.state,n=e.options,r=n.element,o=r===void 0?"[data-popper-arrow]":r;o!=null&&(typeof o=="string"&&(o=t.elements.popper.querySelector(o),!o)||wp(t.elements.popper,o)&&(t.elements.arrow=o))}const Fg={name:"arrow",enabled:!0,phase:"main",fn:Ag,effect:Dg,requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function cr(e){return e.split("-")[1]}var zg={top:"auto",right:"auto",bottom:"auto",left:"auto"};function jg(e,t){var n=e.x,r=e.y,o=t.devicePixelRatio||1;return{x:ar(n*o)/o||0,y:ar(r*o)/o||0}}function Zc(e){var t,n=e.popper,r=e.popperRect,o=e.placement,l=e.variation,u=e.offsets,a=e.position,d=e.gpuAcceleration,c=e.adaptive,R=e.roundOffsets,P=e.isFixed,S=u.x,k=S===void 0?0:S,I=u.y,O=I===void 0?0:I,h=typeof R=="function"?R({x:k,y:O}):{x:k,y:O};k=h.x,O=h.y;var m=u.hasOwnProperty("x"),y=u.hasOwnProperty("y"),x=dt,_=ft,D=window;if(c){var T=Io(n),E="clientHeight",C="clientWidth";if(T===vt(n)&&(T=Sn(n),Kt(T).position!=="static"&&a==="absolute"&&(E="scrollHeight",C="scrollWidth")),T=T,o===ft||(o===dt||o===_t)&&l===So){_=kt;var B=P&&T===D&&D.visualViewport?D.visualViewport.height:T[E];O-=B-r.height,O*=d?1:-1}if(o===dt||(o===ft||o===kt)&&l===So){x=_t;var X=P&&T===D&&D.visualViewport?D.visualViewport.width:T[C];k-=X-r.width,k*=d?1:-1}}var J=Object.assign({position:a},c&&zg),ce=R===!0?jg({x:k,y:O},vt(n)):{x:k,y:O};if(k=ce.x,O=ce.y,d){var re;return Object.assign({},J,(re={},re[_]=y?"0":"",re[x]=m?"0":"",re.transform=(D.devicePixelRatio||1)<=1?"translate("+k+"px, "+O+"px)":"translate3d("+k+"px, "+O+"px, 0)",re))}return Object.assign({},J,(t={},t[_]=y?O+"px":"",t[x]=m?k+"px":"",t.transform="",t))}function Wg(e){var t=e.state,n=e.options,r=n.gpuAcceleration,o=r===void 0?!0:r,l=n.adaptive,u=l===void 0?!0:l,a=n.roundOffsets,d=a===void 0?!0:a,c={placement:Ft(t.placement),variation:cr(t.placement),popper:t.elements.popper,popperRect:t.rects.popper,gpuAcceleration:o,isFixed:t.options.strategy==="fixed"};t.modifiersData.popperOffsets!=null&&(t.styles.popper=Object.assign({},t.styles.popper,Zc(Object.assign({},c,{offsets:t.modifiersData.popperOffsets,position:t.options.strategy,adaptive:u,roundOffsets:d})))),t.modifiersData.arrow!=null&&(t.styles.arrow=Object.assign({},t.styles.arrow,Zc(Object.assign({},c,{offsets:t.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:d})))),t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-placement":t.placement})}const Hg={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:Wg,data:{}};var ni={passive:!0};function $g(e){var t=e.state,n=e.instance,r=e.options,o=r.scroll,l=o===void 0?!0:o,u=r.resize,a=u===void 0?!0:u,d=vt(t.elements.popper),c=[].concat(t.scrollParents.reference,t.scrollParents.popper);return l&&c.forEach(function(R){R.addEventListener("scroll",n.update,ni)}),a&&d.addEventListener("resize",n.update,ni),function(){l&&c.forEach(function(R){R.removeEventListener("scroll",n.update,ni)}),a&&d.removeEventListener("resize",n.update,ni)}}const Ug={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:$g,data:{}};var bg={left:"right",right:"left",bottom:"top",top:"bottom"};function vi(e){return e.replace(/left|right|bottom|top/g,function(t){return bg[t]})}var Gg={start:"end",end:"start"};function Jc(e){return e.replace(/start|end/g,function(t){return Gg[t]})}function ja(e){var t=vt(e),n=t.pageXOffset,r=t.pageYOffset;return{scrollLeft:n,scrollTop:r}}function Wa(e){return sr(Sn(e)).left+ja(e).scrollLeft}function Vg(e,t){var n=vt(e),r=Sn(e),o=n.visualViewport,l=r.clientWidth,u=r.clientHeight,a=0,d=0;if(o){l=o.width,u=o.height;var c=yp();(c||!c&&t==="fixed")&&(a=o.offsetLeft,d=o.offsetTop)}return{width:l,height:u,x:a+Wa(e),y:d}}function Xg(e){var t,n=Sn(e),r=ja(e),o=(t=e.ownerDocument)==null?void 0:t.body,l=On(n.scrollWidth,n.clientWidth,o?o.scrollWidth:0,o?o.clientWidth:0),u=On(n.scrollHeight,n.clientHeight,o?o.scrollHeight:0,o?o.clientHeight:0),a=-r.scrollLeft+Wa(e),d=-r.scrollTop;return Kt(o||n).direction==="rtl"&&(a+=On(n.clientWidth,o?o.clientWidth:0)-l),{width:l,height:u,x:a,y:d}}function Ha(e){var t=Kt(e),n=t.overflow,r=t.overflowX,o=t.overflowY;return/auto|scroll|overlay|hidden/.test(n+o+r)}function xp(e){return["html","body","#document"].indexOf(zt(e))>=0?e.ownerDocument.body:Ct(e)&&Ha(e)?e:xp(nl(e))}function oo(e,t){var n;t===void 0&&(t=[]);var r=xp(e),o=r===((n=e.ownerDocument)==null?void 0:n.body),l=vt(r),u=o?[l].concat(l.visualViewport||[],Ha(r)?r:[]):r,a=t.concat(u);return o?a:a.concat(oo(nl(u)))}function Bu(e){return Object.assign({},e,{left:e.x,top:e.y,right:e.x+e.width,bottom:e.y+e.height})}function Qg(e,t){var n=sr(e,!1,t==="fixed");return n.top=n.top+e.clientTop,n.left=n.left+e.clientLeft,n.bottom=n.top+e.clientHeight,n.right=n.left+e.clientWidth,n.width=e.clientWidth,n.height=e.clientHeight,n.x=n.left,n.y=n.top,n}function ef(e,t,n){return t===hp?Bu(Vg(e,n)):Ln(t)?Qg(t,n):Bu(Xg(Sn(e)))}function Kg(e){var t=oo(nl(e)),n=["absolute","fixed"].indexOf(Kt(e).position)>=0,r=n&&Ct(e)?Io(e):e;return Ln(r)?t.filter(function(o){return Ln(o)&&wp(o,r)&&zt(o)!=="body"}):[]}function Yg(e,t,n,r){var o=t==="clippingParents"?Kg(e):[].concat(t),l=[].concat(o,[n]),u=l[0],a=l.reduce(function(d,c){var R=ef(e,c,r);return d.top=On(R.top,d.top),d.right=Hi(R.right,d.right),d.bottom=Hi(R.bottom,d.bottom),d.left=On(R.left,d.left),d},ef(e,u,r));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}function Rp(e){var t=e.reference,n=e.element,r=e.placement,o=r?Ft(r):null,l=r?cr(r):null,u=t.x+t.width/2-n.width/2,a=t.y+t.height/2-n.height/2,d;switch(o){case ft:d={x:u,y:t.y-n.height};break;case kt:d={x:u,y:t.y+t.height};break;case _t:d={x:t.x+t.width,y:a};break;case dt:d={x:t.x-n.width,y:a};break;default:d={x:t.x,y:t.y}}var c=o?za(o):null;if(c!=null){var R=c==="y"?"height":"width";switch(l){case ur:d[c]=d[c]-(t[R]/2-n[R]/2);break;case So:d[c]=d[c]+(t[R]/2-n[R]/2);break}}return d}function Eo(e,t){t===void 0&&(t={});var n=t,r=n.placement,o=r===void 0?e.placement:r,l=n.strategy,u=l===void 0?e.strategy:l,a=n.boundary,d=a===void 0?hg:a,c=n.rootBoundary,R=c===void 0?hp:c,P=n.elementContext,S=P===void 0?zr:P,k=n.altBoundary,I=k===void 0?!1:k,O=n.padding,h=O===void 0?0:O,m=Ep(typeof h!="number"?h:Cp(h,No)),y=S===zr?gg:zr,x=e.rects.popper,_=e.elements[I?y:S],D=Yg(Ln(_)?_:_.contextElement||Sn(e.elements.popper),d,R,u),T=sr(e.elements.reference),E=Rp({reference:T,element:x,strategy:"absolute",placement:o}),C=Bu(Object.assign({},x,E)),B=S===zr?C:T,X={top:D.top-B.top+m.top,bottom:B.bottom-D.bottom+m.bottom,left:D.left-B.left+m.left,right:B.right-D.right+m.right},J=e.modifiersData.offset;if(S===zr&&J){var ce=J[o];Object.keys(X).forEach(function(re){var Re=[_t,kt].indexOf(re)>=0?1:-1,Ne=[ft,kt].indexOf(re)>=0?"y":"x";X[re]+=ce[Ne]*Re})}return X}function qg(e,t){t===void 0&&(t={});var n=t,r=n.placement,o=n.boundary,l=n.rootBoundary,u=n.padding,a=n.flipVariations,d=n.allowedAutoPlacements,c=d===void 0?gp:d,R=cr(r),P=R?a?Yc:Yc.filter(function(I){return cr(I)===R}):No,S=P.filter(function(I){return c.indexOf(I)>=0});S.length===0&&(S=P);var k=S.reduce(function(I,O){return I[O]=Eo(e,{placement:O,boundary:o,rootBoundary:l,padding:u})[Ft(O)],I},{});return Object.keys(k).sort(function(I,O){return k[I]-k[O]})}function Zg(e){if(Ft(e)===Aa)return[];var t=vi(e);return[Jc(e),t,Jc(t)]}function Jg(e){var t=e.state,n=e.options,r=e.name;if(!t.modifiersData[r]._skip){for(var o=n.mainAxis,l=o===void 0?!0:o,u=n.altAxis,a=u===void 0?!0:u,d=n.fallbackPlacements,c=n.padding,R=n.boundary,P=n.rootBoundary,S=n.altBoundary,k=n.flipVariations,I=k===void 0?!0:k,O=n.allowedAutoPlacements,h=t.options.placement,m=Ft(h),y=m===h,x=d||(y||!I?[vi(h)]:Zg(h)),_=[h].concat(x).reduce(function(we,Se){return we.concat(Ft(Se)===Aa?qg(t,{placement:Se,boundary:R,rootBoundary:P,padding:c,flipVariations:I,allowedAutoPlacements:O}):Se)},[]),D=t.rects.reference,T=t.rects.popper,E=new Map,C=!0,B=_[0],X=0;X<_.length;X++){var J=_[X],ce=Ft(J),re=cr(J)===ur,Re=[ft,kt].indexOf(ce)>=0,Ne=Re?"width":"height",Pe=Eo(t,{placement:J,boundary:R,rootBoundary:P,altBoundary:S,padding:c}),He=Re?re?_t:dt:re?kt:ft;D[Ne]>T[Ne]&&(He=vi(He));var it=vi(He),Ze=[];if(l&&Ze.push(Pe[ce]<=0),a&&Ze.push(Pe[He]<=0,Pe[it]<=0),Ze.every(function(we){return we})){B=J,C=!1;break}E.set(J,Ze)}if(C)for(var Ot=I?3:1,b=function(Se){var Je=_.find(function(lt){var et=E.get(lt);if(et)return et.slice(0,Se).every(function(ht){return ht})});if(Je)return B=Je,"break"},ee=Ot;ee>0;ee--){var fe=b(ee);if(fe==="break")break}t.placement!==B&&(t.modifiersData[r]._skip=!0,t.placement=B,t.reset=!0)}}const ey={name:"flip",enabled:!0,phase:"main",fn:Jg,requiresIfExists:["offset"],data:{_skip:!1}};function tf(e,t,n){return n===void 0&&(n={x:0,y:0}),{top:e.top-t.height-n.y,right:e.right-t.width+n.x,bottom:e.bottom-t.height+n.y,left:e.left-t.width-n.x}}function nf(e){return[ft,_t,kt,dt].some(function(t){return e[t]>=0})}function ty(e){var t=e.state,n=e.name,r=t.rects.reference,o=t.rects.popper,l=t.modifiersData.preventOverflow,u=Eo(t,{elementContext:"reference"}),a=Eo(t,{altBoundary:!0}),d=tf(u,r),c=tf(a,o,l),R=nf(d),P=nf(c);t.modifiersData[n]={referenceClippingOffsets:d,popperEscapeOffsets:c,isReferenceHidden:R,hasPopperEscaped:P},t.attributes.popper=Object.assign({},t.attributes.popper,{"data-popper-reference-hidden":R,"data-popper-escaped":P})}const ny={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:ty};function ry(e,t,n){var r=Ft(e),o=[dt,ft].indexOf(r)>=0?-1:1,l=typeof n=="function"?n(Object.assign({},t,{placement:e})):n,u=l[0],a=l[1];return u=u||0,a=(a||0)*o,[dt,_t].indexOf(r)>=0?{x:a,y:u}:{x:u,y:a}}function oy(e){var t=e.state,n=e.options,r=e.name,o=n.offset,l=o===void 0?[0,0]:o,u=gp.reduce(function(R,P){return R[P]=ry(P,t.rects,l),R},{}),a=u[t.placement],d=a.x,c=a.y;t.modifiersData.popperOffsets!=null&&(t.modifiersData.popperOffsets.x+=d,t.modifiersData.popperOffsets.y+=c),t.modifiersData[r]=u}const iy={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:oy};function ly(e){var t=e.state,n=e.name;t.modifiersData[n]=Rp({reference:t.rects.reference,element:t.rects.popper,strategy:"absolute",placement:t.placement})}const uy={name:"popperOffsets",enabled:!0,phase:"read",fn:ly,data:{}};function ay(e){return e==="x"?"y":"x"}function sy(e){var t=e.state,n=e.options,r=e.name,o=n.mainAxis,l=o===void 0?!0:o,u=n.altAxis,a=u===void 0?!1:u,d=n.boundary,c=n.rootBoundary,R=n.altBoundary,P=n.padding,S=n.tether,k=S===void 0?!0:S,I=n.tetherOffset,O=I===void 0?0:I,h=Eo(t,{boundary:d,rootBoundary:c,padding:P,altBoundary:R}),m=Ft(t.placement),y=cr(t.placement),x=!y,_=za(m),D=ay(_),T=t.modifiersData.popperOffsets,E=t.rects.reference,C=t.rects.popper,B=typeof O=="function"?O(Object.assign({},t.rects,{placement:t.placement})):O,X=typeof B=="number"?{mainAxis:B,altAxis:B}:Object.assign({mainAxis:0,altAxis:0},B),J=t.modifiersData.offset?t.modifiersData.offset[t.placement]:null,ce={x:0,y:0};if(T){if(l){var re,Re=_==="y"?ft:dt,Ne=_==="y"?kt:_t,Pe=_==="y"?"height":"width",He=T[_],it=He+h[Re],Ze=He-h[Ne],Ot=k?-C[Pe]/2:0,b=y===ur?E[Pe]:C[Pe],ee=y===ur?-C[Pe]:-E[Pe],fe=t.elements.arrow,we=k&&fe?Fa(fe):{width:0,height:0},Se=t.modifiersData["arrow#persistent"]?t.modifiersData["arrow#persistent"].padding:Sp(),Je=Se[Re],lt=Se[Ne],et=ro(0,E[Pe],we[Pe]),ht=x?E[Pe]/2-Ot-et-Je-X.mainAxis:b-et-Je-X.mainAxis,rl=x?-E[Pe]/2+Ot+et+lt+X.mainAxis:ee+et+lt+X.mainAxis,yr=t.elements.arrow&&Io(t.elements.arrow),ol=yr?_==="y"?yr.clientTop||0:yr.clientLeft||0:0,wr=(re=J==null?void 0:J[_])!=null?re:0,il=He+ht-wr-ol,ll=He+rl-wr,Lo=ro(k?Hi(it,il):it,He,k?On(Ze,ll):Ze);T[_]=Lo,ce[_]=Lo-He}if(a){var Mo,ul=_==="x"?ft:dt,al=_==="x"?kt:_t,Wt=T[D],Fn=D==="y"?"height":"width",Bo=Wt+h[ul],Ao=Wt-h[al],Sr=[ft,dt].indexOf(m)!==-1,Er=(Mo=J==null?void 0:J[D])!=null?Mo:0,Cr=Sr?Bo:Wt-E[Fn]-C[Fn]-Er+X.altAxis,Do=Sr?Wt+E[Fn]+C[Fn]-Er-X.altAxis:Ao,xr=k&&Sr?Mg(Cr,Wt,Do):ro(k?Cr:Bo,Wt,k?Do:Ao);T[D]=xr,ce[D]=xr-Wt}t.modifiersData[r]=ce}}const cy={name:"preventOverflow",enabled:!0,phase:"main",fn:sy,requiresIfExists:["offset"]};function fy(e){return{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}}function dy(e){return e===vt(e)||!Ct(e)?ja(e):fy(e)}function py(e){var t=e.getBoundingClientRect(),n=ar(t.width)/e.offsetWidth||1,r=ar(t.height)/e.offsetHeight||1;return n!==1||r!==1}function vy(e,t,n){n===void 0&&(n=!1);var r=Ct(t),o=Ct(t)&&py(t),l=Sn(t),u=sr(e,o,n),a={scrollLeft:0,scrollTop:0},d={x:0,y:0};return(r||!r&&!n)&&((zt(t)!=="body"||Ha(l))&&(a=dy(t)),Ct(t)?(d=sr(t,!0),d.x+=t.clientLeft,d.y+=t.clientTop):l&&(d.x=Wa(l))),{x:u.left+a.scrollLeft-d.x,y:u.top+a.scrollTop-d.y,width:u.width,height:u.height}}function my(e){var t=new Map,n=new Set,r=[];e.forEach(function(l){t.set(l.name,l)});function o(l){n.add(l.name);var u=[].concat(l.requires||[],l.requiresIfExists||[]);u.forEach(function(a){if(!n.has(a)){var d=t.get(a);d&&o(d)}}),r.push(l)}return e.forEach(function(l){n.has(l.name)||o(l)}),r}function hy(e){var t=my(e);return Pg.reduce(function(n,r){return n.concat(t.filter(function(o){return o.phase===r}))},[])}function gy(e){var t;return function(){return t||(t=new Promise(function(n){Promise.resolve().then(function(){t=void 0,n(e())})})),t}}function yy(e){var t=e.reduce(function(n,r){var o=n[r.name];return n[r.name]=o?Object.assign({},o,r,{options:Object.assign({},o.options,r.options),data:Object.assign({},o.data,r.data)}):r,n},{});return Object.keys(t).map(function(n){return t[n]})}var rf={placement:"bottom",modifiers:[],strategy:"absolute"};function of(){for(var e=arguments.length,t=new Array(e),n=0;nF("span",{className:"font-weight-400 d-inline-block color-grey-800 border-radius-sm text-transform-capitalize",style:{backgroundColor:t,padding:"2px 6px"},children:e});function Au(e){return{50:"#fafafa",100:"#f5f5f5",200:"#eeeeee",300:"#e0e0e0",400:"#bdbdbd",500:"#9e9e9e",600:"#757575",700:"#616161",800:"#424242",900:"#212121"}[e]}const $a=()=>Fe("svg",{xmlns:"http://www.w3.org/2000/svg",className:"icon icon-tabler icon-tabler-plus",width:"44",height:"44",viewBox:"0 0 24 24",strokeWidth:"1.5",stroke:"#2c3e50",fill:"none",strokeLinecap:"round",strokeLinejoin:"round",children:[F("path",{stroke:"none",d:"M0 0h24v24H0z",fill:"none"}),F("line",{x1:"12",y1:"5",x2:"12",y2:"19"}),F("line",{x1:"5",y1:"12",x2:"19",y2:"12"})]});function Ty({initialValue:e,options:t,columnId:n,rowIndex:r,dataDispatch:o}){const[l,u]=ae.useState(null),[a,d]=ae.useState(null),[c,R]=ae.useState(!1),[P,S]=ae.useState(!1),[k,I]=ae.useState(null),{styles:O,attributes:h}=kp(l,a,{placement:"bottom-start",strategy:"fixed"}),[m,y]=ae.useState({value:e,update:!1});ae.useEffect(()=>{y({value:e,update:!1})},[e]),ae.useEffect(()=>{m.update&&o&&o({type:Ce.UPDATE_CELL,columnId:n,rowIndex:r,value:m.value})},[m,n,r]),ae.useEffect(()=>{k&&P&&k.focus()},[k,P]);function x(){let C=t==null?void 0:t.find(B=>B.label===m.value);return(C==null?void 0:C.backgroundColor)??Au(200)}function _(){S(!0)}function D(C){if(C.key==="Enter"){const B=C.target;B.value!==""&&o&&o({type:Ce.ADD_OPTION_TO_COLUMN,option:B.value,backgroundColor:Lu(),columnId:n}),S(!1)}}function T(C){C.target.value!==""&&o&&o({type:Ce.ADD_OPTION_TO_COLUMN,option:C.target.value,backgroundColor:Lu(),columnId:n}),S(!1)}function E(C){y({value:C.label,update:!0}),R(!1)}return ae.useEffect(()=>{k&&P&&k.focus()},[k,P]),Fe(If,{children:[F("div",{ref:u,className:"cell-padding d-flex cursor-default align-items-center flex-1",onClick:()=>R(!0),children:m.value&&F(Ul,{value:m.value,backgroundColor:x()})}),c&&F("div",{className:"overlay",onClick:()=>R(!1)}),c&&Ba.createPortal(F("div",{className:"shadow-5 bg-white border-radius-md",ref:d,...h.popper,style:{...O.popper,zIndex:4,minWidth:200,maxWidth:320,maxHeight:400,padding:"0.75rem",overflow:"auto"},children:Fe("div",{className:"d-flex flex-wrap-wrap",style:{marginTop:"-0.5rem"},children:[t==null?void 0:t.map(C=>F("div",{className:"cursor-pointer mr-5 mt-5",onClick:()=>E(C),children:F(Ul,{value:C.label,backgroundColor:C.backgroundColor})})),P&&F("div",{className:"mr-5 mt-5 bg-grey-200 border-radius-sm",style:{width:120,padding:"2px 4px"},children:F("input",{type:"text",className:"option-input",onBlur:T,ref:I,onKeyDown:D})}),F("div",{className:"cursor-pointer mr-5 mt-5",onClick:_,children:F(Ul,{value:F("span",{className:"svg-icon-sm svg-text",children:F($a,{})}),backgroundColor:Au(200)})})]})}),document.querySelector("#popper-portal"))]})}function Ny({initialValue:e,columnId:t,rowIndex:n,dataDispatch:r}){const[o,l]=ae.useState(e);return F("div",{className:"checkbox-container",children:F("input",{type:"checkbox",checked:o,onChange:a=>{l(a.target.checked),r&&r({type:Ce.UPDATE_CELL,columnId:t,rowIndex:n,value:a.target.checked})},className:"checkbox-large"})})}function Iy({value:e,row:{index:t},column:{id:n,dataType:r,options:o},dataDispatch:l}){function u(){switch(r){case ze.TEXT:return F(vg,{initialValue:e,rowIndex:t,columnId:n,dataDispatch:l});case ze.NUMBER:return F(mg,{initialValue:e,rowIndex:t,columnId:n,dataDispatch:l});case ze.SELECT:return F(Ty,{initialValue:e,options:o,rowIndex:t,columnId:n,dataDispatch:l});case ze.CHECKBOX:return F(Ny,{initialValue:e,rowIndex:t,columnId:n,dataDispatch:l});default:return F("span",{})}}return u()}function Ly({getHeaderProps:e,dataDispatch:t}){return F("div",{...e(),className:"th noselect d-inline-block",children:F("div",{className:"th-content d-flex justify-content-center",onClick:n=>t({type:Ce.ADD_COLUMN_TO_LEFT,columnId:gn.ADD_COLUMN_ID,focus:!0}),children:F("span",{className:"svg-icon-sm svg-gray",children:F($a,{})})})})}const My=()=>Fe("svg",{width:"44",height:"44",viewBox:"0 0 24 24",strokeWidth:"1.5",stroke:"#2c3e50",fill:"none",strokeLinecap:"round",strokeLinejoin:"round",children:[F("path",{stroke:"none",d:"M0 0h24v24H0z",fill:"none"}),F("line",{x1:"4",y1:"6",x2:"20",y2:"6"}),F("line",{x1:"4",y1:"12",x2:"14",y2:"12"}),F("line",{x1:"4",y1:"18",x2:"18",y2:"18"})]}),By=()=>Fe("svg",{width:"44",height:"44",viewBox:"0 0 24 24",strokeWidth:"1.5",stroke:"#2c3e50",fill:"none",strokeLinecap:"round",strokeLinejoin:"round",children:[F("path",{stroke:"none",d:"M0 0h24v24H0z",fill:"none"}),F("rect",{x:"7",y:"3",width:"14",height:"14",rx:"2"}),F("path",{d:"M17 17v2a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h2"})]}),Ay=()=>Fe("svg",{width:"44",height:"44",viewBox:"0 0 24 24",strokeWidth:"1.5",stroke:"#2c3e50",fill:"none",strokeLinecap:"round",strokeLinejoin:"round",children:[F("path",{stroke:"none",d:"M0 0h24v24H0z",fill:"none"}),F("line",{x1:"5",y1:"9",x2:"19",y2:"9"}),F("line",{x1:"5",y1:"15",x2:"19",y2:"15"}),F("line",{x1:"11",y1:"4",x2:"7",y2:"20"}),F("line",{x1:"17",y1:"4",x2:"13",y2:"20"})]});function Dy({dataType:e}){function t(n){switch(n){case ze.NUMBER:return F(Ay,{});case ze.TEXT:return F(My,{});case ze.SELECT:return F(By,{});default:return null}}return t(e)}const Fy=()=>Fe("svg",{width:"48",height:"48",viewBox:"0 0 24 24",strokeWidth:"1.5",stroke:"#2c3e50",fill:"none",strokeLinecap:"round",strokeLinejoin:"round",children:[F("path",{stroke:"none",d:"M0 0h24v24H0z",fill:"none"}),F("line",{x1:"12",y1:"5",x2:"12",y2:"19"}),F("line",{x1:"18",y1:"11",x2:"12",y2:"5"}),F("line",{x1:"6",y1:"11",x2:"12",y2:"5"})]}),zy=()=>Fe("svg",{width:"44",height:"44",viewBox:"0 0 24 24",strokeWidth:"1.5",stroke:"#2c3e50",fill:"none",strokeLinecap:"round",strokeLinejoin:"round",children:[F("path",{stroke:"none",d:"M0 0h24v24H0z",fill:"none"}),F("line",{x1:"12",y1:"5",x2:"12",y2:"19"}),F("line",{x1:"18",y1:"13",x2:"12",y2:"19"}),F("line",{x1:"6",y1:"13",x2:"12",y2:"19"})]});function jy({label:e,dataType:t,columnId:n,setSortBy:r,popper:o,popperRef:l,dataDispatch:u,setShowHeaderMenu:a}){const[d,c]=ae.useState(e);ae.useEffect(()=>{c(e)},[e]);const R=[{onClick:()=>{u({type:Ce.UPDATE_COLUMN_HEADER,columnId:n,label:d}),r([{id:n,desc:!1}]),a(!1)},icon:F(Fy,{}),label:"Sort ascending"},{onClick:()=>{u({type:Ce.UPDATE_COLUMN_HEADER,columnId:n,label:d}),r([{id:n,desc:!0}]),a(!1)},icon:F(zy,{}),label:"Sort descending"}];return F("div",{ref:l,style:{...o.styles.popper,zIndex:3},...o.attributes.popper,children:Fe("div",{className:"bg-white shadow-5 border-radius-md",style:{width:240},children:[F("div",{style:{paddingTop:"0.75rem",paddingLeft:"0.75rem",paddingRight:"0.75rem"}}),F("div",{style:{borderTop:`2px solid ${Au(200)}`}}),F("div",{className:"list-padding",children:R.map(P=>Fe("button",{type:"button",className:"sort-button",onMouseDown:P.onClick,children:[F("span",{className:"svg-icon svg-text icon-margin",children:P.icon}),P.label]},ji()))})]})})}function Wy({column:{id:e,created:t,label:n,dataType:r,getResizerProps:o,getHeaderProps:l},setSortBy:u,dataDispatch:a}){const[d,c]=ae.useState(t||!1),[R,P]=ae.useState(null),[S,k]=ae.useState(null),I=kp(R,S,{placement:"bottom",strategy:"absolute"});ae.useEffect(()=>{t&&c(!0)},[t]);function O(){return e===gn.ADD_COLUMN_ID?F(Ly,{dataDispatch:a,getHeaderProps:l}):e===gn.CHECKBOX_COLUMN_ID?F("div",{...l(),className:"th noselect d-inline-block",children:F("div",{className:"th-content",children:n})}):Fe(If,{children:[Fe("div",{...l(),className:"th noselect d-inline-block",children:[Fe("div",{className:"th-content",onClick:()=>c(!0),ref:P,children:[F("span",{className:"svg-icon svg-gray icon-margin",children:F(Dy,{dataType:ze[r]})}),n]}),F("div",{...o(),className:"resizer"})]}),d&&F("div",{className:"overlay",onClick:()=>c(!1)}),d&&F(jy,{label:n,dataType:r,popper:I,popperRef:k,dataDispatch:a,setSortBy:u,columnId:e.toString(),setShowHeaderMenu:c})]})}return O()}const Hy={minWidth:50,width:150,maxWidth:400,Cell:Iy,Header:Wy,sortType:"alphanumericFalsyLast"};function $y({columns:e,data:t,dispatch:n,skipReset:r}){const o=ae.useMemo(()=>({alphanumericFalsyLast(k,I,O,h){return!k.values[O]&&!I.values[O]?0:k.values[O]?I.values[O]?isNaN(k.values[O])?k.values[O].localeCompare(I.values[O]):k.values[O]-I.values[O]:h?1:-1:h?-1:1}}),[]),{getTableProps:l,getTableBodyProps:u,headerGroups:a,rows:d,prepareRow:c,totalColumnsWidth:R}=ti.useTable({columns:e,data:t,defaultColumn:Hy,dataDispatch:n,autoResetSortBy:!r,autoResetFilters:!r,autoResetRowState:!r,sortTypes:o},ti.useFlexLayout,ti.useResizeColumns,ti.useSortBy),P=Of.useCallback(({index:k,style:I})=>{var h;const O=d[k];return c(O),F("div",{...(h=O==null?void 0:O.getRowProps)==null?void 0:h.call(O,{style:I}),className:"tr",children:O.cells.map((m,y)=>ae.createElement("div",{...m.getCellProps(),key:y,className:"td",style:{width:`${m.column.width}px`}},m.render("Cell")))})},[c,d]),S=()=>F("div",{children:d.map((k,I)=>{var O;return P({index:I,style:(O=k==null?void 0:k.getRowProps)==null?void 0:O.call(k).style})})});return Fe("div",{style:{maxWidth:"100vw",overflow:"auto"},children:[F("div",{className:"table-header",children:F("div",{children:a.map((k,I)=>ae.createElement("div",{...k.getHeaderGroupProps(),key:I,className:"tr"},k.headers.map((O,h)=>ae.createElement("div",{...O.getHeaderProps(),key:h,className:"th"},O.render("Header")))))})}),Fe("div",{className:"table",children:[F("div",{children:F("div",{...u(),children:F(S,{})})}),Fe("div",{className:"tr add-row",onClick:()=>n&&n({type:Ce.ADD_ROW}),style:{marginTop:30,width:"fit-content",minWidth:"90px"},children:[F("span",{className:"svg-icon svg-gray icon-margin",children:F($a,{})}),"New"]})]})]})}var Du={exports:{}};(function(e,t){Object.defineProperty(t,"__esModule",{value:!0});function n(E){return typeof E=="object"&&!("toString"in E)?Object.prototype.toString.call(E).slice(8,-1):E}var r=typeof process=="object"&&!0;function o(E,C){if(!E)throw r?new Error("Invariant failed"):new Error(C())}t.invariant=o;var l=Object.prototype.hasOwnProperty,u=Array.prototype.splice,a=Object.prototype.toString;function d(E){return a.call(E).slice(8,-1)}var c=Object.assign||function(E,C){return R(C).forEach(function(B){l.call(C,B)&&(E[B]=C[B])}),E},R=typeof Object.getOwnPropertySymbols=="function"?function(E){return Object.keys(E).concat(Object.getOwnPropertySymbols(E))}:function(E){return Object.keys(E)};function P(E){return Array.isArray(E)?c(E.constructor(E.length),E):d(E)==="Map"?new Map(E):d(E)==="Set"?new Set(E):E&&typeof E=="object"?c(Object.create(Object.getPrototypeOf(E)),E):E}var S=function(){function E(){this.commands=c({},k),this.update=this.update.bind(this),this.update.extend=this.extend=this.extend.bind(this),this.update.isEquals=function(C,B){return C===B},this.update.newContext=function(){return new E().update}}return Object.defineProperty(E.prototype,"isEquals",{get:function(){return this.update.isEquals},set:function(C){this.update.isEquals=C},enumerable:!0,configurable:!0}),E.prototype.extend=function(C,B){this.commands[C]=B},E.prototype.update=function(C,B){var X=this,J=typeof B=="function"?{$apply:B}:B;Array.isArray(C)&&Array.isArray(J)||o(!Array.isArray(J),function(){return"update(): You provided an invalid spec to update(). The spec may not contain an array except as the value of $set, $push, $unshift, $splice or any custom command allowing an array value."}),o(typeof J=="object"&&J!==null,function(){return"update(): You provided an invalid spec to update(). The spec and every included key path must be plain objects containing one of the "+("following commands: "+Object.keys(X.commands).join(", ")+".")});var ce=C;return R(J).forEach(function(re){if(l.call(X.commands,re)){var Re=C===ce;ce=X.commands[re](J[re],ce,J,C),Re&&X.isEquals(ce,C)&&(ce=C)}else{var Ne=d(C)==="Map"?X.update(C.get(re),J[re]):X.update(C[re],J[re]),Pe=d(ce)==="Map"?ce.get(re):ce[re];(!X.isEquals(Ne,Pe)||typeof Ne>"u"&&!l.call(C,re))&&(ce===C&&(ce=P(C)),d(ce)==="Map"?ce.set(re,Ne):ce[re]=Ne)}}),ce},E}();t.Context=S;var k={$push:function(E,C,B){return O(C,B,"$push"),E.length?C.concat(E):C},$unshift:function(E,C,B){return O(C,B,"$unshift"),E.length?E.concat(C):C},$splice:function(E,C,B,X){return m(C,B),E.forEach(function(J){y(J),C===X&&J.length&&(C=P(X)),u.apply(C,J)}),C},$set:function(E,C,B){return _(B),E},$toggle:function(E,C){h(E,"$toggle");var B=E.length?P(C):C;return E.forEach(function(X){B[X]=!C[X]}),B},$unset:function(E,C,B,X){return h(E,"$unset"),E.forEach(function(J){Object.hasOwnProperty.call(C,J)&&(C===X&&(C=P(X)),delete C[J])}),C},$add:function(E,C,B,X){return T(C,"$add"),h(E,"$add"),d(C)==="Map"?E.forEach(function(J){var ce=J[0],re=J[1];C===X&&C.get(ce)!==re&&(C=P(X)),C.set(ce,re)}):E.forEach(function(J){C===X&&!C.has(J)&&(C=P(X)),C.add(J)}),C},$remove:function(E,C,B,X){return T(C,"$remove"),h(E,"$remove"),E.forEach(function(J){C===X&&C.has(J)&&(C=P(X)),C.delete(J)}),C},$merge:function(E,C,B,X){return D(C,E),R(E).forEach(function(J){E[J]!==C[J]&&(C===X&&(C=P(X)),C[J]=E[J])}),C},$apply:function(E,C){return x(E),E(C)}},I=new S;t.isEquals=I.update.isEquals,t.extend=I.extend,t.default=I.update,t.default.default=e.exports=c(t.default,t);function O(E,C,B){o(Array.isArray(E),function(){return"update(): expected target of "+n(B)+" to be an array; got "+n(E)+"."}),h(C[B],B)}function h(E,C){o(Array.isArray(E),function(){return"update(): expected spec of "+n(C)+" to be an array; got "+n(E)+". Did you forget to wrap your parameter in an array?"})}function m(E,C){o(Array.isArray(E),function(){return"Expected $splice target to be an array; got "+n(E)}),y(C.$splice)}function y(E){o(Array.isArray(E),function(){return"update(): expected spec of $splice to be an array of arrays; got "+n(E)+". Did you forget to wrap your parameters in an array?"})}function x(E){o(typeof E=="function",function(){return"update(): expected spec of $apply to be a function; got "+n(E)+"."})}function _(E){o(Object.keys(E).length===1,function(){return"Cannot have more than one key in an object with $set"})}function D(E,C){o(C&&typeof C=="object",function(){return"update(): $merge expects a spec of type 'object'; got "+n(C)}),o(E&&typeof E=="object",function(){return"update(): $merge expects a target of type 'object'; got "+n(E)})}function T(E,C){var B=d(E);o(B==="Map"||B==="Set",function(){return"update(): "+n(C)+" expects a target of type Set or Map; got "+n(B)})}})(Du,Du.exports);var Uy=Du.exports;const yt=Co(Uy),by=()=>Fe("svg",{width:"44",height:"44",viewBox:"0 0 24 24",strokeWidth:"1.5",stroke:"#2c3e50",fill:"none",strokeLinecap:"round",strokeLinejoin:"round",children:[F("path",{stroke:"none",d:"M0 0h24v24H0z",fill:"none"}),F("line",{x1:"4",y1:"7",x2:"20",y2:"7"}),F("line",{x1:"10",y1:"11",x2:"10",y2:"17"}),F("line",{x1:"14",y1:"11",x2:"14",y2:"17"}),F("path",{d:"M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"}),F("path",{d:"M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"})]});function Gy(e){var t=typeof e;return e!=null&&(t=="object"||t=="function")}var _p=Gy,Vy=typeof je=="object"&&je&&je.Object===Object&&je,Xy=Vy,Qy=Xy,Ky=typeof self=="object"&&self&&self.Object===Object&&self,Yy=Qy||Ky||Function("return this")(),Pp=Yy,qy=Pp,Zy=function(){return qy.Date.now()},Jy=Zy,e0=/\s/;function t0(e){for(var t=e.length;t--&&e0.test(e.charAt(t)););return t}var n0=t0,r0=n0,o0=/^\s+/;function i0(e){return e&&e.slice(0,r0(e)+1).replace(o0,"")}var l0=i0,u0=Pp,a0=u0.Symbol,Op=a0,lf=Op,Tp=Object.prototype,s0=Tp.hasOwnProperty,c0=Tp.toString,jr=lf?lf.toStringTag:void 0;function f0(e){var t=s0.call(e,jr),n=e[jr];try{e[jr]=void 0;var r=!0}catch{}var o=c0.call(e);return r&&(t?e[jr]=n:delete e[jr]),o}var d0=f0,p0=Object.prototype,v0=p0.toString;function m0(e){return v0.call(e)}var h0=m0,uf=Op,g0=d0,y0=h0,w0="[object Null]",S0="[object Undefined]",af=uf?uf.toStringTag:void 0;function E0(e){return e==null?e===void 0?S0:w0:af&&af in Object(e)?g0(e):y0(e)}var C0=E0;function x0(e){return e!=null&&typeof e=="object"}var R0=x0,k0=C0,_0=R0,P0="[object Symbol]";function O0(e){return typeof e=="symbol"||_0(e)&&k0(e)==P0}var T0=O0,N0=l0,sf=_p,I0=T0,cf=0/0,L0=/^[-+]0x[0-9a-f]+$/i,M0=/^0b[01]+$/i,B0=/^0o[0-7]+$/i,A0=parseInt;function D0(e){if(typeof e=="number")return e;if(I0(e))return cf;if(sf(e)){var t=typeof e.valueOf=="function"?e.valueOf():e;e=sf(t)?t+"":t}if(typeof e!="string")return e===0?e:+e;e=N0(e);var n=M0.test(e);return n||B0.test(e)?A0(e.slice(2),n?2:8):L0.test(e)?cf:+e}var F0=D0,z0=_p,bl=Jy,ff=F0,j0="Expected a function",W0=Math.max,H0=Math.min;function $0(e,t,n){var r,o,l,u,a,d,c=0,R=!1,P=!1,S=!0;if(typeof e!="function")throw new TypeError(j0);t=ff(t)||0,z0(n)&&(R=!!n.leading,P="maxWait"in n,l=P?W0(ff(n.maxWait)||0,t):l,S="trailing"in n?!!n.trailing:S);function k(T){var E=r,C=o;return r=o=void 0,c=T,u=e.apply(C,E),u}function I(T){return c=T,a=setTimeout(m,t),R?k(T):u}function O(T){var E=T-d,C=T-c,B=t-E;return P?H0(B,l-C):B}function h(T){var E=T-d,C=T-c;return d===void 0||E>=t||E<0||P&&C>=l}function m(){var T=bl();if(h(T))return y(T);a=setTimeout(m,O(T))}function y(T){return a=void 0,S&&r?k(T):(r=o=void 0,u)}function x(){a!==void 0&&clearTimeout(a),c=0,r=d=o=a=void 0}function _(){return a===void 0?u:y(bl())}function D(){var T=bl(),E=h(T);if(r=arguments,o=this,d=T,E){if(a===void 0)return I(d);if(P)return clearTimeout(a),a=setTimeout(m,t),k(d)}return a===void 0&&(a=setTimeout(m,t)),u}return D.cancel=x,D.flush=_,D}var U0=$0;const b0=Co(U0);function G0(e,t){switch(console.log("Reducer action:",t),t.type){case Ce.ADD_OPTION_TO_COLUMN:const n=e.columns.findIndex(S=>S.id===t.columnId);return yt(e,{skipReset:{$set:!0},columns:{[n]:{options:{$push:[{label:t.option,backgroundColor:t.backgroundColor}]}}}});case Ce.ADD_ROW:const r=V0(e.data);return console.log("New state after ADD_ROW:",e),yt(e,{skipReset:{$set:!0},data:{$push:[{id:r}]}});case Ce.UPDATE_COLUMN_TYPE:const o=e.columns.findIndex(S=>S.id===t.columnId);switch(t.dataType){case ze.NUMBER:return e.columns[o].dataType===ze.NUMBER?e:yt(e,{skipReset:{$set:!0},columns:{[o]:{dataType:{$set:t.dataType}}},data:{$apply:S=>S.map(k=>({...k,[t.columnId]:isNaN(k[t.columnId])?"":Number.parseInt(k[t.columnId])}))}});case ze.SELECT:if(e.columns[o].dataType===ze.SELECT)return e;{let S=[];return e.data.forEach(k=>{k[t.columnId]&&S.push({label:k[t.columnId],backgroundColor:Lu()})}),yt(e,{skipReset:{$set:!0},columns:{[o]:{dataType:{$set:t.dataType},options:{$push:S}}}})}case ze.TEXT:return e.columns[o].dataType===ze.TEXT?e:e.columns[o].dataType===ze.SELECT?yt(e,{skipReset:{$set:!0},columns:{[o]:{dataType:{$set:t.dataType}}}}):yt(e,{skipReset:{$set:!0},columns:{[o]:{dataType:{$set:t.dataType}}},data:{$apply:S=>S.map(k=>({...k,[t.columnId]:k[t.columnId]+""}))}});default:return e}case Ce.UPDATE_COLUMN_HEADER:const l=e.columns.findIndex(S=>S.id===t.columnId);return yt(e,{skipReset:{$set:!0},columns:{[l]:{label:{$set:t.label}}}});case Ce.UPDATE_CELL:return yt(e,{skipReset:{$set:!0},data:{[t.rowIndex]:{[t.columnId]:{$set:t.value}}}});case Ce.ADD_COLUMN_TO_LEFT:const u=e.columns.findIndex(S=>S.id===t.columnId);let a=ji();return yt(e,{skipReset:{$set:!0},columns:{$splice:[[u,0,{id:a,label:"Column",accessor:a,dataType:ze.TEXT,created:t.focus&&!0,options:[]}]]}});case Ce.ADD_COLUMN_TO_RIGHT:const d=e.columns.findIndex(S=>S.id===t.columnId),c=ji();return yt(e,{skipReset:{$set:!0},columns:{$splice:[[d+1,0,{id:c,label:"Column",accessor:c,dataType:ze.TEXT,created:t.focus&&!0,options:[]}]]}});case Ce.DELETE_COLUMN:const R=e.columns.findIndex(S=>S.id===t.columnId);return yt(e,{skipReset:{$set:!0},columns:{$splice:[[R,1]]}});case Ce.ENABLE_RESET:return yt(e,{skipReset:{$set:!0}});case Ce.LOAD_DATA:let P=t.columns.map(S=>S.id&&["headWord","definition","translationEquivalents","checkbox_column","notes"].includes(S.id)?{...S,visible:!0}:{...S,visible:!1});return{...e,data:t.data,columns:P,dictionary:t.dictionary};case Ce.REMOVE_CHECKED_ROWS:return{...e,data:e.data.filter(S=>!S[gn.CHECKBOX_COLUMN_ID])};case Ce.RESIZE_COLUMN_WIDTHS:return console.log("Resizing columns to",t.minWidth),{...e,columns:e.columns.map(S=>S.dataType!==ze.CHECKBOX?{...S,width:t.minWidth}:S)};default:return e}}function V0(e){let t;do t=ji();while(e.some(n=>n.id===t));return t}function X0(){const e={columns:[],data:[],skipReset:!1,dictionary:{id:"",label:"",entries:[],metadata:{}}},[t,n]=ae.useReducer(G0,e),[r,o]=ae.useState(window.innerWidth-20);ae.useEffect(()=>{n({type:Ce.ENABLE_RESET}),console.log("Data changed")},[t.data,t.columns]),ae.useEffect(()=>{if(t.data.length>0&&t.columns.length>0){const P={data:t.data,columns:t.columns},S=Jh(P,t.dictionary);Vc.postMessage({command:"updateData",data:S}),console.log("Something in data, columns, or dict changed. New count:",t.data.length)}},[t.data,t.columns,t.dictionary]),ae.useEffect(()=>{const P=k=>{const I=t.columns.filter(O=>O.visible).length;return(k-60)/(I-1)},S=b0(()=>{const k=P(window.innerWidth);n({type:Ce.RESIZE_COLUMN_WIDTHS,minWidth:k}),o(window.innerWidth-20)},100);return o(window.innerWidth-20),window.addEventListener("resize",S),()=>{S.cancel(),window.removeEventListener("resize",S)}},[n,t.columns.length]),ae.useEffect(()=>{const P=S=>{console.log("Received event:"),console.log({event:S});const k=S.data;switch(k.command){case"sendData":{let I=k.data;I.entries||(I={...I,entries:[]}),console.log("Dictionary before transformation:"),console.log({dictionary:I});const O=Zh(I);n({type:Ce.LOAD_DATA,data:O.data,columns:O.columns,dictionary:I}),window.dispatchEvent(new Event("resize"));break}case"removeConfirmed":n({type:Ce.REMOVE_CHECKED_ROWS});break}};return window.addEventListener("message",P),()=>{window.removeEventListener("message",P)}},[]);const l=()=>{const P=t.data.filter(S=>S[gn.CHECKBOX_COLUMN_ID]).length;Vc.postMessage({command:"confirmRemove",count:P})},u=t.data.some(P=>P[gn.CHECKBOX_COLUMN_ID]),[a,d]=ae.useState(""),c=P=>{d(P.target.value)},R=t.data.filter(P=>Object.values(P).some(S=>typeof S=="string"&&S.toLowerCase().includes(a.toLowerCase())));return Fe("div",{style:{width:"100%",height:"100%",padding:10,display:"flex",flexDirection:"column"},children:[Fe("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:40,marginTop:40,minHeight:"60px"},children:[F("h1",{children:"Dictionary"}),u&&F("button",{onClick:l,className:"remove-button",title:"Remove selected rows",children:F(by,{})})]}),F("div",{style:{width:"100%",maxWidth:"100%",boxSizing:"border-box"},children:F("input",{type:"text",placeholder:"Search...",value:a,onChange:c,className:"search-bar",style:{width:r}})}),Fe("div",{className:"app-container",children:[F("div",{className:"table-container",children:F($y,{columns:t.columns.filter(P=>P.visible),data:R,dispatch:n,skipReset:t.skipReset})}),F("div",{id:"popper-portal"})]})]})}Kh.render(F(Of.StrictMode,{children:F(X0,{})}),document.getElementById("root")); diff --git a/webviews/editable-react-table/dist/index.html b/webviews/editable-react-table/dist/index.html deleted file mode 100644 index de4065c53..000000000 --- a/webviews/editable-react-table/dist/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - Dictionary Table - - - - -
- - -