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
11 changes: 11 additions & 0 deletions sharedUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,5 +286,16 @@ export const computeProgressPercents = (
};
};

/**
* Derives the target .codex file path from a .source file path.
* Normalizes path separators so the replacement works on both Windows and POSIX.
*/
export const deriveTargetPathFromSource = (sourcePath: string): string => {
const normalized = sourcePath.replace(/\\/g, "/");
return normalized
.replace(/\.source$/, ".codex")
.replace(/\/\.project\/sourceTexts\//, "/files/target/");
};

// Re-export corpus utilities
export * from "./corpusUtils";
138 changes: 78 additions & 60 deletions src/providers/NewSourceUploader/NewSourceUploaderProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { createStandardizedFilename, extractUsfmCodeFromFilename, getBookDisplay
import { formatJsonForNotebookFile } from "../../utils/notebookFileFormattingUtils";
import { CodexContentSerializer } from "../../serializer";
import { getCorpusMarkerForBook } from "../../../sharedUtils/corpusUtils";
import { deriveTargetPathFromSource } from "../../../sharedUtils";
import { getNotebookMetadataManager } from "../../utils/notebookMetadataManager";
import { SyncManager } from "../../projectManager/syncManager";
import { processNewlyImportedFiles } from "../../projectManager/utils/migrationUtils";
Expand Down Expand Up @@ -119,12 +120,17 @@ export class NewSourceUploaderProvider implements vscode.CustomTextEditorProvide
webviewPanel.webview.onDidReceiveMessage(async (message: any) => {
try {
if (message.command === "webviewReady") {
// Webview is ready, send current project inventory
const inventory = await this.fetchProjectInventory();

// Extract initial intent from URI query params (e.g. ?intent=source or ?intent=target)
const uriQuery = document.uri.query || "";
const intentMatch = uriQuery.match(/intent=(source|target)/);
const initialIntent = intentMatch ? intentMatch[1] : undefined;

webviewPanel.webview.postMessage({
command: "projectInventory",
inventory: inventory,
initialIntent,
});
} else if (message.command === "metadata.check") {
// Handle metadata check request
Expand Down Expand Up @@ -675,14 +681,10 @@ export class NewSourceUploaderProvider implements vscode.CustomTextEditorProvide
});
}
} else if (message.command === "fetchTargetFile") {
// Fetch target file content for translation imports
const { sourceFilePath } = message;

try {
const targetFilePath = sourceFilePath
.replace(/\.source$/, ".codex")
.replace(/\/\.project\/sourceTexts\//, "/files/target/");

const targetFilePath = deriveTargetPathFromSource(sourceFilePath);
const targetUri = vscode.Uri.file(targetFilePath);
const targetContent = await vscode.workspace.fs.readFile(targetUri);
const targetNotebook = JSON.parse(new TextDecoder().decode(targetContent));
Expand Down Expand Up @@ -1634,41 +1636,27 @@ export class NewSourceUploaderProvider implements vscode.CustomTextEditorProvide
token: vscode.CancellationToken
): Promise<void> {
try {
// The aligned content is already provided by the plugin's custom alignment algorithm
// We just need to merge it into the existing target notebook

// Load the existing target notebook
const targetFileUri = vscode.Uri.file(message.targetFilePath);
const existingContent = await vscode.workspace.fs.readFile(targetFileUri);
const existingNotebook = JSON.parse(new TextDecoder().decode(existingContent));

// Create a map of existing cells for quick lookup
const existingCellsMap = new Map<string, any>();
existingNotebook.cells.forEach((cell: any) => {
if (cell.metadata?.id) {
existingCellsMap.set(cell.metadata.id, cell);
}
});
// Build a map of aligned updates keyed by the TARGET cell's ID (not the imported content's ID)
const updatesMap = new Map<string, { alignedCell: any; updatedCell: any }>();
const paratextCells: Array<{ cell: any; parentId?: string }> = [];

// Track statistics
let insertedCount = 0;
let skippedCount = 0;
let paratextCount = 0;
let childCellCount = 0;

// Process aligned cells and update the notebook
const processedCells = new Map<string, any>();
const processedSourceCells = new Set<string>();

for (const alignedCell of message.alignedContent) {
if (alignedCell.isParatext) {
// Add paratext cells
const paratextId = alignedCell.importedContent.id;
const importedData = alignedCell.importedContent.data;
const paratextData =
typeof importedData === "object" && importedData !== null ? importedData : {};
const paratextCell = {
kind: 1, // vscode.NotebookCellKind.Code
kind: 1,
languageId: "html",
value: alignedCell.importedContent.content,
metadata: {
Expand All @@ -1682,35 +1670,54 @@ export class NewSourceUploaderProvider implements vscode.CustomTextEditorProvide
parentId: alignedCell.importedContent.parentId,
},
};
processedCells.set(paratextId, paratextCell);
paratextCells.push({
cell: paratextCell,
parentId: alignedCell.importedContent.parentId,
});
paratextCount++;
} else if (alignedCell.notebookCell) {
const targetId = alignedCell.importedContent.id;
const existingCell = existingCellsMap.get(targetId);
const targetId =
alignedCell.notebookCell?.metadata?.id ?? alignedCell.importedContent.id;

const existingCell = existingNotebook.cells.find(
(c: any) => c.metadata?.id === targetId
);

// Never overwrite milestone cells — they are structural markers
const isMilestone =
existingCell?.metadata?.type === CodexCellTypes.MILESTONE ||
alignedCell.notebookCell?.metadata?.type === CodexCellTypes.MILESTONE;
if (isMilestone) {
skippedCount++;
continue;
}

const existingValue = existingCell?.value ?? alignedCell.notebookCell.value ?? "";

if (existingValue && existingValue.trim() !== "") {
// Keep existing content if cell already has content
processedCells.set(targetId, existingCell || alignedCell.notebookCell);
updatesMap.set(targetId, {
alignedCell,
updatedCell: existingCell || alignedCell.notebookCell,
});
skippedCount++;
} else {
// Update empty cell with new content
const updatedCell = {
kind: 1, // vscode.NotebookCellKind.Code
kind: 1,
languageId: "html",
value: alignedCell.importedContent.content,
metadata: {
...alignedCell.notebookCell.metadata,
...(existingCell?.metadata ?? alignedCell.notebookCell.metadata),
type: CodexCellTypes.TEXT,
id: targetId,
data: {
...alignedCell.notebookCell.metadata.data,
...(existingCell?.metadata?.data ??
alignedCell.notebookCell.metadata?.data),
startTime: alignedCell.importedContent.startTime,
endTime: alignedCell.importedContent.endTime,
},
},
};
processedCells.set(targetId, updatedCell);
updatesMap.set(targetId, { alignedCell, updatedCell });

if (alignedCell.isAdditionalOverlap) {
childCellCount++;
Expand All @@ -1721,53 +1728,64 @@ export class NewSourceUploaderProvider implements vscode.CustomTextEditorProvide
}
}

// Build the final cell array, preserving the temporal order from alignedContent
// Preserve original notebook cell order: iterate existing cells, apply updates in-place
const newCells: any[] = [];
const usedExistingCellIds = new Set<string>();
const usedCellIds = new Set<string>();

// Process cells in the order they appear in alignedContent (temporal order)
for (const alignedCell of message.alignedContent) {
if (alignedCell.isParatext) {
// Add paratext cell
const paratextId = alignedCell.importedContent.id;
const paratextCell = processedCells.get(paratextId);
if (paratextCell) {
newCells.push(paratextCell);
for (const cell of existingNotebook.cells) {
const cellId = cell.metadata?.id;
if (cellId && updatesMap.has(cellId)) {
newCells.push(updatesMap.get(cellId)!.updatedCell);
usedCellIds.add(cellId);
} else {
newCells.push(cell);
if (cellId) {
usedCellIds.add(cellId);
}
} else if (alignedCell.notebookCell) {
const targetId = alignedCell.importedContent.id;
const processedCell = processedCells.get(targetId);
}

if (processedCell) {
newCells.push(processedCell);
usedExistingCellIds.add(targetId);
// Insert paratext cells that reference this cell as their parent
if (cellId) {
const childParatexts = paratextCells.filter((p) => p.parentId === cellId);
for (const pt of childParatexts) {
newCells.push(pt.cell);
}
}
}

// Add any existing cells that weren't in the aligned content (shouldn't happen normally)
for (const cell of existingNotebook.cells) {
const cellId = cell.metadata?.id;
if (!cellId || usedExistingCellIds.has(cellId)) {
continue;
// Append paratext cells without a parent (or whose parent wasn't found)
for (const pt of paratextCells) {
const alreadyInserted =
pt.parentId && newCells.some((c) => c.metadata?.id === pt.cell.metadata?.id);
if (!alreadyInserted) {
newCells.push(pt.cell);
}
console.warn(`Cell ${cellId} was not in aligned content, appending at end`);
newCells.push(cell);
}

// Update the notebook
const updatedNotebook = {
...existingNotebook,
cells: newCells,
metadata: {
...existingNotebook.metadata,
importerType: message.importerType || existingNotebook.metadata?.importerType,
importTimestamp: new Date().toISOString(),
importContext: {
...(existingNotebook.metadata?.importContext ?? {}),
lastTranslationImport: {
importerType: message.importerType,
timestamp: new Date().toISOString(),
sourceFilePath: message.sourceFilePath,
stats: { insertedCount, skippedCount, paratextCount, childCellCount },
},
},
},
};

// Write the updated notebook back to disk
await vscode.workspace.fs.writeFile(
targetFileUri,
Buffer.from(formatJsonForNotebookFile(updatedNotebook))
);

// Show success message with statistics
vscode.window.showInformationMessage(
`Translation imported: ${insertedCount} translations, ${paratextCount} paratext cells, ${childCellCount} child cells, ${skippedCount} skipped.`
);
Expand Down
3 changes: 2 additions & 1 deletion src/providers/navigationWebview/navigationWebviewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,8 @@ export class NavigationWebviewProvider extends BaseWebviewProvider {
}
case "openSourceUpload": {
try {
await vscode.commands.executeCommand("codex-project-manager.openSourceUpload");
const intent = message.intent as string | undefined;
await vscode.commands.executeCommand("codex-project-manager.openSourceUpload", intent);
} catch (error) {
console.error("Error opening source upload:", error);
vscode.window.showErrorMessage(`Failed to open source upload: ${error}`);
Expand Down
5 changes: 3 additions & 2 deletions src/providers/registerProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ export function registerProviders(context: vscode.ExtensionContext) {
);

disposables.push(
vscode.commands.registerCommand("codex-project-manager.openSourceUpload", () => {
vscode.commands.registerCommand("codex-project-manager.openSourceUpload", (intent?: string) => {
const workspaceFolder = getWorkSpaceFolder();
if (workspaceFolder) {
const uri = vscode.Uri.parse(`newSourceUploaderProvider-scheme:New Source Upload`);
const query = intent ? `?intent=${intent}` : "";
const uri = vscode.Uri.parse(`newSourceUploaderProvider-scheme:New Source Upload${query}`);
vscode.commands.executeCommand(
"vscode.openWith",
uri,
Expand Down
58 changes: 35 additions & 23 deletions webviews/codex-webviews/src/NavigationView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -426,9 +426,17 @@ function NavigationView() {
}));
};

const handleAddFiles = () => {
const handleAddSourceFile = () => {
vscode.postMessage({
command: "openSourceUpload",
intent: "source",
});
};

const handleImportTargetFile = () => {
vscode.postMessage({
command: "openSourceUpload",
intent: "target",
});
};

Expand Down Expand Up @@ -981,28 +989,32 @@ function NavigationView() {
</div>

<div className="mt-auto pt-4 flex flex-col gap-3 bg-vscode-sideBar-background relative">
{/* Action Buttons - Side by Side */}
<div className="flex min-[311px]:flex-row flex-col gap-2">
<Button
variant="default"
onClick={handleAddFiles}
title="Add files to translate"
className="flex-1 py-2.5 px-3 text-sm font-semibold shadow-sm hover:-translate-y-[1px] hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex items-center justify-center gap-2"
>
<i className="codicon codicon-add" />
Add Files
</Button>
<Button
variant="secondary"
onClick={handleOpenExport}
title="Export files"
className="flex-1 py-2.5 px-3 text-sm font-semibold shadow-sm hover:-translate-y-[1px] hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex items-center justify-center gap-2"
>
<i className="codicon codicon-cloud-upload" />
Export
</Button>
</div>

<Button
variant="default"
onClick={handleAddSourceFile}
title="Import a source file to translate"
className="w-full py-2.5 px-3 text-sm font-semibold shadow-sm hover:-translate-y-[1px] hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex items-center justify-center gap-2"
>
<i className="codicon codicon-add" />
Add Source File
</Button>
<Button
onClick={handleImportTargetFile}
title="Import a translation file into an existing source"
className="w-full py-2.5 px-3 text-sm font-semibold shadow-sm hover:-translate-y-[1px] hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex items-center justify-center gap-2 bg-green-600 hover:bg-green-700 text-white"
>
<i className="codicon codicon-add" />
Import Target File
</Button>
<Button
variant="secondary"
onClick={handleOpenExport}
title="Export files"
className="w-full py-2.5 px-3 text-sm font-semibold shadow-sm hover:-translate-y-[1px] hover:shadow-md active:translate-y-0 active:shadow-sm transition-all flex items-center justify-center gap-2"
>
<i className="codicon codicon-cloud-upload" />
Export
</Button>
</div>

{/* Corpus Marker Modal */}
Expand Down
Loading
Loading