Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 229 additions & 0 deletions src/exportHandler/exportHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,206 @@ async function exportCodexContentAsObsRoundtrip(
);
}

/**
* USFM (Unified Standard Format Marker) Round-trip export
* Rebuilds original USFM file with translated content
*/
async function exportCodexContentAsUsfmRoundtrip(
userSelectedPath: string,
filesToExport: string[],
_options?: ExportOptions
) {
const workspaceFolders = vscode.workspace.workspaceFolders;
if (!workspaceFolders) {
vscode.window.showErrorMessage("No workspace folder found.");
return;
}

const exportFolder = vscode.Uri.file(userSelectedPath);

Comment on lines +871 to +872
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing directory creation for export folder.

Unlike other export functions in this file (e.g., exportCodexContentAsIdmlRoundtrip, exportCodexContentAsDocxRoundtrip), this function does not call vscode.workspace.fs.createDirectory(exportFolder). This could cause the export to fail if the directory doesn't exist.

     const exportFolder = vscode.Uri.file(userSelectedPath);
+    await vscode.workspace.fs.createDirectory(exportFolder);
🤖 Prompt for AI Agents
In src/exportHandler/exportHandler.ts around lines 871 to 872, the exportFolder
Uri is created but the directory is not ensured to exist; add a call to await
vscode.workspace.fs.createDirectory(exportFolder) (using the same pattern as
exportCodexContentAsIdmlRoundtrip/exportCodexContentAsDocxRoundtrip) immediately
after constructing exportFolder so the folder is created before any write
operations, and propagate or handle any errors as the surrounding function does
for other export flows.

return vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Exporting USFM Round-trip",
cancellable: false,
},
async (progress) => {
const increment = filesToExport.length > 0 ? 100 / filesToExport.length : 100;

// Import USFM exporter from experimental (now standalone implementation)
const experimentalExporter = await import("../../webviews/codex-webviews/src/NewSourceUploader/importers/usfm/experimental/usfmExporter");
const exportUsfmRoundtrip = experimentalExporter.exportUsfmRoundtrip;

// For each selected codex file, reconstruct the USFM with translations
for (const [index, filePath] of filesToExport.entries()) {
progress.report({ message: `Processing ${index + 1}/${filesToExport.length}`, increment });
try {
const file = vscode.Uri.file(filePath);
const fileName = basename(file.fsPath);
const bookCode = fileName.split(".")[0] || "";

console.log(`[USFM Export] Processing ${fileName} using USFM round-trip exporter`);

// Read codex notebook
const codexNotebook = await readCodexNotebookFromUri(file);

// Check if this is a USFM file (experimental or standalone)
const importerType = (codexNotebook.metadata as any)?.importerType;
const corpusMarker = (codexNotebook.metadata as any)?.corpusMarker;

if (importerType !== 'usfm-experimental' && corpusMarker !== 'usfm') {
console.warn(`[USFM Export] Skipping ${fileName} - not imported with USFM importer (importerType: ${importerType}, corpusMarker: ${corpusMarker})`);
vscode.window.showWarningMessage(`Skipping ${fileName} - not imported with USFM importer`);
continue;
}

// Get original file name from metadata with fallback
// Try multiple sources: codex metadata, source notebook metadata, or construct from bookCode
let metadataOriginalFileName = (codexNotebook.metadata as any)?.originalFileName;
const metadataBookCode = (codexNotebook.metadata as any)?.bookCode;
const finalBookCode = metadataBookCode || bookCode;

// If not found in codex metadata, try source notebook metadata
if (!metadataOriginalFileName) {
try {
const sourceFileName = fileName.replace('.codex', '.source');
const sourceFileUri = vscode.Uri.joinPath(
workspaceFolders[0].uri,
".project",
"sourceTexts",
sourceFileName
);
const sourceNotebook = await readCodexNotebookFromUri(sourceFileUri);
metadataOriginalFileName = (sourceNotebook.metadata as any)?.originalFileName;
if (metadataOriginalFileName) {
console.log(`[USFM Export] Found originalFileName in source notebook: ${metadataOriginalFileName}`);
}
} catch (error) {
// Source notebook not found or error reading it, continue with fallbacks
console.log(`[USFM Export] Could not read source notebook for originalFileName`);
}
}

// Try common USFM file extensions
const possibleExtensions = ['.usfm', '.sfm', '.USFM', '.SFM'];
let originalFileName = metadataOriginalFileName;

// If no originalFileName, try to find it in originals folder
if (!originalFileName && finalBookCode) {
const originalsDir = vscode.Uri.joinPath(
workspaceFolders[0].uri,
".project",
"attachments",
"originals"
);

// Try each extension
for (const ext of possibleExtensions) {
const testFileName = `${finalBookCode}${ext}`;
const testUri = vscode.Uri.joinPath(originalsDir, testFileName);
try {
await vscode.workspace.fs.stat(testUri);
originalFileName = testFileName;
console.log(`[USFM Export] Found original file: ${testFileName}`);
break;
} catch {
// File doesn't exist, try next extension
}
}
}

// Final fallback: construct filename from bookCode
if (!originalFileName) {
originalFileName = `${finalBookCode}.usfm`;
console.log(`[USFM Export] Using fallback filename: ${originalFileName}`);
}

// Load original USFM file from attachments/originals
const originalsDir = vscode.Uri.joinPath(
workspaceFolders[0].uri,
".project",
"attachments",
"originals"
);
const originalFileUri = vscode.Uri.joinPath(originalsDir, originalFileName);

let originalUsfmContent: string;
try {
const originalFileData = await vscode.workspace.fs.readFile(originalFileUri);
originalUsfmContent = new TextDecoder('utf-8').decode(originalFileData);
console.log(`[USFM Export] Loaded original USFM file: ${originalFileName}`);
} catch (error) {
// Fallback: try to get from structureMetadata if available
const structureMetadata = (codexNotebook.metadata as any)?.structureMetadata;
if (structureMetadata?.originalUsfmContent) {
originalUsfmContent = structureMetadata.originalUsfmContent;
console.log(`[USFM Export] Using original USFM content from metadata (file not found at ${originalFileUri.fsPath})`);
} else {
throw new Error(`Original USFM file not found at ${originalFileUri.fsPath} and no original content in metadata`);
}
}

// Build codex cells array
// Include id property if it exists (some cells have id at top level, others in metadata.id)
const codexCells = codexNotebook.cells.map(cell => {
const cellData: any = {
kind: cell.kind,
value: cell.value,
metadata: cell.metadata,
};
// Include id if it exists at top level (for ProcessedCell structure)
if ((cell as any).id) {
cellData.id = (cell as any).id;
}
return cellData;
});

// Get lineMappings from structureMetadata if available (for standalone exporter)
const structureMetadata = (codexNotebook.metadata as any)?.structureMetadata;
const lineMappings = structureMetadata?.lineMappings;

// Debug: Log structureMetadata and lineMappings
if (lineMappings) {
console.log(`[USFM Export] Found lineMappings: ${lineMappings.length} entries`);
const sampleMapping = lineMappings.find((m: any) => m.cellId);
console.log(`[USFM Export] Sample mapping with cellId:`, sampleMapping);
console.log(`[USFM Export] Mappings with cellId count:`, lineMappings.filter((m: any) => m.cellId && m.cellId !== '').length);
} else {
console.warn(`[USFM Export] No lineMappings found in structureMetadata`);
console.log(`[USFM Export] structureMetadata keys:`, structureMetadata ? Object.keys(structureMetadata) : 'null');
}

// Export USFM with translations
// If we have lineMappings, use them for precise round-trip export
let updatedUsfmContent: string;
if (lineMappings) {
updatedUsfmContent = await exportUsfmRoundtrip(originalUsfmContent, lineMappings, codexCells);
} else {
// Use backward-compatible signature (no lineMappings - fallback mode)
updatedUsfmContent = await exportUsfmRoundtrip(originalUsfmContent, codexCells);
}

// Save to export folder
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const exportedName = originalFileName.replace(/\.(usfm|sfm|USFM|SFM)$/i, `_${timestamp}_translated.$1`);
const exportedUri = vscode.Uri.joinPath(exportFolder, exportedName);

const encoder = new TextEncoder();
await vscode.workspace.fs.writeFile(exportedUri, encoder.encode(updatedUsfmContent));

console.log(`[USFM Export] ✓ Exported ${exportedName}`);

} catch (error) {
console.error(`[USFM Export] Error exporting ${filePath}:`, error);
vscode.window.showErrorMessage(`Failed to export ${basename(filePath)}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}

vscode.window.showInformationMessage(`USFM round-trip export completed to ${userSelectedPath}`);
}
);
}

/**
* TMS (Translation Memory System) Round-trip export
* Supports both TMX and XLIFF formats
Expand Down Expand Up @@ -1074,6 +1274,19 @@ async function exportCodexContentAsRebuild(
// TMS (Translation Memory System) files use the TMS exporter
filesByType['tms'] = filesByType['tms'] || [];
filesByType['tms'].push(filePath);
} else if (
corpusMarker === 'usfm' ||
importerType === 'usfm-experimental' ||
importerType === 'usfm' ||
// Also check for NT/OT corpus markers with USFM file extensions (Bible books imported as USFM)
((corpusMarker === 'NT' || corpusMarker === 'OT') &&
originalFileName &&
(originalFileName.endsWith('.usfm') || originalFileName.endsWith('.sfm') || originalFileName.endsWith('.USFM') || originalFileName.endsWith('.SFM'))) ||
(originalFileName && (originalFileName.endsWith('.usfm') || originalFileName.endsWith('.sfm') || originalFileName.endsWith('.USFM') || originalFileName.endsWith('.SFM')))
) {
// USFM files use the USFM round-trip exporter
filesByType['usfm'] = filesByType['usfm'] || [];
filesByType['usfm'].push(filePath);
Comment on lines +1277 to +1289
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's check the directory structure and find importer files
fd -t f "importer" webviews/codex-webviews/src/NewSourceUploader/ --type f

Repository: genesis-ai-dev/codex-editor

Length of output: 1666


🏁 Script executed:

# Search for .usfm/.sfm references in importers
rg -n "\.usfm|\.sfm" webviews/codex-webviews/src/NewSourceUploader/ -t ts

Repository: genesis-ai-dev/codex-editor

Length of output: 2302


🏁 Script executed:

# Get the broader context around lines 1277-1289 in exportHandler.ts
cat -n src/exportHandler/exportHandler.ts | sed -n '1260,1310p'

Repository: genesis-ai-dev/codex-editor

Length of output: 3876


🏁 Script executed:

# Check how corpusMarker and importerType are set in metadata
rg -n "corpusMarker\s*=" src/exportHandler/ --type ts -B 2 -A 2

Repository: genesis-ai-dev/codex-editor

Length of output: 10483


🏁 Script executed:

# Check Paratext importer to see how it handles USFM files and metadata
head -100 webviews/codex-webviews/src/NewSourceUploader/importers/paratext/index.ts

Repository: genesis-ai-dev/codex-editor

Length of output: 203


🏁 Script executed:

# Look for where file metadata is created/stored
rg -n "importerType|corpusMarker" src/exportHandler/exportHandler.ts --type ts -B 1 -A 1 | head -80

Repository: genesis-ai-dev/codex-editor

Length of output: 5654


🏁 Script executed:

# Find Paratext importer files
find webviews/codex-webviews/src/NewSourceUploader/importers/paratext -type f -name "*.ts" -o -name "*.tsx"

Repository: genesis-ai-dev/codex-editor

Length of output: 310


🏁 Script executed:

# Search for where corpusMarker is assigned (set, not just read)
rg -n "corpusMarker\s*:" --type ts -B 2 -A 2 | head -150

Repository: genesis-ai-dev/codex-editor

Length of output: 13353


🏁 Script executed:

# Check for importerType assignment in metadata
rg -n "importerType\s*:" --type ts -B 2 -A 2 | head -150

Repository: genesis-ai-dev/codex-editor

Length of output: 15770


🏁 Script executed:

# Check Paratext importer file
cat webviews/codex-webviews/src/NewSourceUploader/importers/paratext/index.tsx

Repository: genesis-ai-dev/codex-editor

Length of output: 861


🏁 Script executed:

# Get the full Paratext parser to see metadata assignments
cat webviews/codex-webviews/src/NewSourceUploader/importers/paratext/parser.ts | head -300

Repository: genesis-ai-dev/codex-editor

Length of output: 11910


🏁 Script executed:

# Search for corpusMarker and importerType assignments in paratext parser
rg -n "corpusMarker|importerType" webviews/codex-webviews/src/NewSourceUploader/importers/paratext/parser.ts -B 2 -A 2

Repository: genesis-ai-dev/codex-editor

Length of output: 53


🏁 Script executed:

# Search for the paratextImporter function and its return/metadata setup
rg -n "paratextImporter|notebookPair|createNotebookPair" webviews/codex-webviews/src/NewSourceUploader/importers/paratext/parser.ts -B 2 -A 5

Repository: genesis-ai-dev/codex-editor

Length of output: 2849


🏁 Script executed:

# Get the end of the parser file where the main export function likely is
tail -200 webviews/codex-webviews/src/NewSourceUploader/importers/paratext/parser.ts

Repository: genesis-ai-dev/codex-editor

Length of output: 9081


🏁 Script executed:

# Find and examine createNotebookPair function
rg -n "export.*createNotebookPair|function createNotebookPair" webviews/codex-webviews/src/NewSourceUploader/importers/common/usfmUtils.ts -A 50 | head -100

Repository: genesis-ai-dev/codex-editor

Length of output: 1725


🏁 Script executed:

# Check what originalFileName is set to in Paratext importer
rg -n "originalFileName" webviews/codex-webviews/src/NewSourceUploader/importers/paratext/parser.ts -B 2 -A 2

Repository: genesis-ai-dev/codex-editor

Length of output: 53


🏁 Script executed:

# Also check usfmUtils to see what getCorpusMarkerForBook returns
rg -n "getCorpusMarkerForBook" webviews/codex-webviews/src/NewSourceUploader/importers/common/usfmUtils.ts -A 15 | head -50

Repository: genesis-ai-dev/codex-editor

Length of output: 1372


🏁 Script executed:

# Find getCorpusMarkerForBook in corpusUtils
rg -n "export.*getCorpusMarkerForBook|function getCorpusMarkerForBook" webviews/codex-webviews/src/utils/corpusUtils.ts -A 20

Repository: genesis-ai-dev/codex-editor

Length of output: 159


🏁 Script executed:

# Also check what originalFileName is set to in createNotebookPair - it's baseName
# Let's verify baseName for Paratext is the book code by looking at the Paratext parser call again
cat webviews/codex-webviews/src/NewSourceUploader/importers/paratext/parser.ts | sed -n '437,465p'

Repository: genesis-ai-dev/codex-editor

Length of output: 1613


🏁 Script executed:

# Search for getCorpusMarkerForBook across the entire codebase
rg -n "getCorpusMarkerForBook" webviews/ src/ --type ts -A 10 | head -100

Repository: genesis-ai-dev/codex-editor

Length of output: 10410


🏁 Script executed:

# Find and examine getCorpusMarkerForBook in sharedUtils
cat sharedUtils/corpusUtils.ts | head -200

Repository: genesis-ai-dev/codex-editor

Length of output: 3202


🏁 Script executed:

# Check if there's a Paratext exporter in exportHandler
rg -n "paratext|Paratext" src/exportHandler/exportHandler.ts -B 2 -A 2

Repository: genesis-ai-dev/codex-editor

Length of output: 1544


🏁 Script executed:

# Check what file types are actually supported in rebuild export
rg -n "filesByType\[" src/exportHandler/exportHandler.ts | grep -A 1 -B 1 "1277\|1289\|1290"

Repository: genesis-ai-dev/codex-editor

Length of output: 365


🏁 Script executed:

# Check USFM importer to see what originalFileName it sets
rg -n "originalFileName|createNotebookPair" webviews/codex-webviews/src/NewSourceUploader/importers/usfm/index.ts -B 2 -A 2 | head -60

Repository: genesis-ai-dev/codex-editor

Length of output: 547


🏁 Script executed:

# Also check if there are any other places where files get .usfm extension in originalFileName
rg -n "originalFileName.*usfm|\.usfm.*originalFileName" webviews/ src/ --type ts

Repository: genesis-ai-dev/codex-editor

Length of output: 980


🏁 Script executed:

# Check all places where originalFileName is set or modified
rg -n "originalFileName\s*=" webviews/codex-webviews/src/NewSourceUploader/importers/ --type ts -B 2 -A 2 | head -150

Repository: genesis-ai-dev/codex-editor

Length of output: 1010


Line 1285 serves as a valid fallback but relies on filename extension as its sole criterion.

The standalone originalFileName check at line 1285 is intentionally broad to catch edge cases where files lack corpusMarker or importerType metadata. However, it assumes that any file with a .usfm/.sfm extension in originalFileName should route to the USFM exporter, regardless of which importer created it.

While this works for USFM-imported files (which strip extensions from originalFileName in createNotebookPair), and Paratext files won't match since they use book codes without extensions, the condition could theoretically misroute files from importers that preserve extensions in originalFileName (e.g., Biblica). If such an importer ever produces a file with .usfm in its originalFileName, it would incorrectly route to the USFM exporter instead of its proper handler.

Consider whether this fallback should require additional validation (e.g., checking importerType is not set to a non-USFM value) to prevent unintended file routing.

🤖 Prompt for AI Agents
In src/exportHandler/exportHandler.ts around lines 1277 to 1289, the fallback
condition at line 1285 routes any file whose originalFileName ends with
.usfm/.sfm to the USFM exporter even when importerType might indicate a
different importer; narrow this fallback by adding an extra check so it only
applies when importerType is absent/empty or explicitly one of the USFM
importers (e.g., 'usfm' or 'usfm-experimental') — alternatively, implement a
negative check that excludes known non-USFM importers (blacklist) before routing
to the USFM exporter; update the conditional accordingly and add a short comment
explaining the reason for the stricter fallback.

} else {
unsupportedFiles.push({ file: basename(filePath), marker: corpusMarker || importerType || 'unknown' });
}
Expand Down Expand Up @@ -1193,6 +1406,22 @@ async function exportCodexContentAsRebuild(
}
}

// Export USFM files
if (filesByType['usfm']?.length > 0) {
console.log(`[Rebuild Export] Exporting ${filesByType['usfm'].length} USFM file(s)...`);
progress.report({
message: `Exporting ${filesByType['usfm'].length} USFM file(s)...`,
increment: 20
});
try {
await exportCodexContentAsUsfmRoundtrip(userSelectedPath, filesByType['usfm'], options);
processedCount += filesByType['usfm'].length;
} catch (error) {
console.error('[Rebuild Export] USFM export failed:', error);
vscode.window.showErrorMessage(`USFM export failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}

progress.report({ message: "Complete", increment: 30 });

// Show summary
Expand Down
3 changes: 2 additions & 1 deletion src/projectManager/projectExportView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,13 +334,14 @@ function getWebviewContent(
<i class="codicon codicon-refresh"></i>
<div>
<strong>Rebuild Export</strong>
<p>Intelligently detects file type and exports back to original format (DOCX, IDML, Biblica, OBS, TMS)</p>
<p>Intelligently detects file type and exports back to original format (DOCX, IDML, Biblica, OBS, TMS, USFM)</p>
<div style="display: flex; gap: 0.5rem; margin-top: 0.25rem; flex-wrap: wrap;">
<span class="format-tag" style="background-color: var(--vscode-charts-blue);">DOCX</span>
<span class="format-tag" style="background-color: var(--vscode-charts-blue);">IDML</span>
<span class="format-tag" style="background-color: var(--vscode-charts-blue);">Biblica</span>
<span class="format-tag" style="background-color: var(--vscode-charts-blue);">OBS</span>
<span class="format-tag" style="background-color: var(--vscode-charts-blue);">TMS</span>
<span class="format-tag" style="background-color: var(--vscode-charts-blue);">USFM</span>
</div>
</div>
</div>
Expand Down
78 changes: 76 additions & 2 deletions src/providers/NewSourceUploader/NewSourceUploaderProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { promisify } from "util";
import { exec } from "child_process";
import { getWebviewHtml } from "../../utils/webviewTemplate";
import { createNoteBookPair } from "./codexFIleCreateUtils";
import { WriteNotebooksMessage, WriteTranslationMessage, OverwriteResponseMessage, WriteNotebooksWithAttachmentsMessage, SelectAudioFileMessage, ReprocessAudioFileMessage, RequestAudioSegmentMessage, FinalizeAudioImportMessage, UpdateAudioSegmentsMessage } from "../../../webviews/codex-webviews/src/NewSourceUploader/types/plugin";
import { WriteNotebooksMessage, WriteTranslationMessage, OverwriteResponseMessage, WriteNotebooksWithAttachmentsMessage, SelectAudioFileMessage, ReprocessAudioFileMessage, RequestAudioSegmentMessage, FinalizeAudioImportMessage, UpdateAudioSegmentsMessage, SaveFileMessage } from "../../../webviews/codex-webviews/src/NewSourceUploader/types/plugin";
import {
handleSelectAudioFile,
handleReprocessAudioFile,
Expand Down Expand Up @@ -317,8 +317,12 @@ export class NewSourceUploaderProvider implements vscode.CustomTextEditorProvide
message as FinalizeAudioImportMessage,
token,
webviewPanel,
(msg, tok, pan) => this.handleWriteNotebooks(msg as WriteNotebooksMessage, tok, pan)
async (msg, tok, pan) => {
await this.handleWriteNotebooks(msg as WriteNotebooksMessage, tok, pan);
}
);
} else if (message.command === "saveFile") {
await this.handleSaveFile(message as SaveFileMessage, webviewPanel);
}
} catch (error) {
console.error("Error handling message:", error);
Expand Down Expand Up @@ -1328,6 +1332,76 @@ export class NewSourceUploaderProvider implements vscode.CustomTextEditorProvide
}
}

/**
* Handle saveFile command from webview - saves a file using VS Code's save dialog
*/
private async handleSaveFile(message: SaveFileMessage, webviewPanel: vscode.WebviewPanel): Promise<void> {
try {
const { fileName, dataBase64, mime } = message;

// Extract base64 data (handle data: URL format)
let base64Data = dataBase64;
if (base64Data.includes(',')) {
// Remove data: URL prefix if present
base64Data = base64Data.split(',')[1];
}

// Convert base64 to Buffer
const buffer = Buffer.from(base64Data, 'base64');

if (buffer.length === 0) {
throw new Error('File data is empty');
}

// Show save dialog
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const defaultUri = workspaceFolder
? vscode.Uri.joinPath(workspaceFolder.uri, fileName)
: undefined;
Comment on lines +1356 to +1360
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Sanitize fileName to prevent path traversal in default URI.

The fileName from the webview message is used directly in constructing defaultUri. While the VS Code save dialog ultimately controls where the file is saved, a malicious or malformed fileName containing path components (e.g., ../../../etc/passwd) could set an unexpected default location, potentially confusing users or exposing directory structure.

+            // Sanitize fileName to prevent path traversal
+            const sanitizedFileName = path.basename(fileName);
+
             // Show save dialog
             const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
             const defaultUri = workspaceFolder
-                ? vscode.Uri.joinPath(workspaceFolder.uri, fileName)
+                ? vscode.Uri.joinPath(workspaceFolder.uri, sanitizedFileName)
                 : undefined;

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/providers/NewSourceUploader/NewSourceUploaderProvider.ts around lines
1356 to 1360, the fileName from the webview is used directly to build defaultUri
which allows path traversal; sanitize the incoming fileName before joining it to
the workspace path by extracting only a safe basename (strip any path separators
and parent references like "../" or backslashes), reject or replace unsafe
characters, enforce a reasonable max length, and fallback to a fixed safe
defaultName if validation fails; then pass that sanitized name to
vscode.Uri.joinPath so the save dialog default cannot point outside the intended
workspace.


const saveUri = await vscode.window.showSaveDialog({
defaultUri,
saveLabel: 'Save',
filters: mime
? {
'All Files': ['*'],
[mime]: [fileName.split('.').pop() || '*']
}
: undefined
});

if (!saveUri) {
// User cancelled
webviewPanel.webview.postMessage({
command: "notification",
type: "info",
message: "File save cancelled"
});
return;
}

// Write file
await vscode.workspace.fs.writeFile(saveUri, buffer);

// Send success notification
webviewPanel.webview.postMessage({
command: "notification",
type: "success",
message: `File saved successfully: ${path.basename(saveUri.fsPath)}`
});

console.log(`[NEW SOURCE UPLOADER] File saved: ${saveUri.fsPath} (${buffer.length} bytes)`);

} catch (error) {
console.error("[NEW SOURCE UPLOADER] Error saving file:", error);
webviewPanel.webview.postMessage({
command: "notification",
type: "error",
message: error instanceof Error ? error.message : "Failed to save file"
});
}
}

private checkIfBibleContent(document: vscode.NotebookDocument): boolean {
// Check first few cells to see if they contain Bible verse references
const cellsToCheck = Math.min(5, document.cellCount);
Expand Down
Loading