Skip to content
Closed
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
351 changes: 304 additions & 47 deletions App.tsx

Large diffs are not rendered by default.

95 changes: 95 additions & 0 deletions components/NodeExportModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React, { useState } from 'react';

export interface NodeExportOptions {
includeHistory: boolean;
includePythonSettings: boolean;
}

interface NodeExportModalProps {
selectedCount: number;
isExporting: boolean;
onCancel: () => void;
onConfirm: (options: NodeExportOptions) => void;
}

const NodeExportModal: React.FC<NodeExportModalProps> = ({
selectedCount,
isExporting,
onCancel,
onConfirm,
}) => {
const [includeHistory, setIncludeHistory] = useState(false);
const [includePythonSettings, setIncludePythonSettings] = useState(true);

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onConfirm({ includeHistory, includePythonSettings });
};

return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded-lg bg-background p-6 shadow-xl">
<h2 className="text-lg font-semibold text-text-main">Export selection</h2>
<p className="mt-1 text-sm text-text-secondary">
{selectedCount === 1
? 'Export the selected node as a reusable JSON package.'
: `Export ${selectedCount} nodes as a reusable JSON package.`}
</p>

<form onSubmit={handleSubmit} className="mt-4 space-y-4">
<label className="flex items-start gap-3">
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-border-color text-primary focus:ring-primary"
checked={includeHistory}
onChange={(event) => setIncludeHistory(event.target.checked)}
/>
<span className="text-sm text-text-main">
<span className="font-medium">Include document history</span>
<br />
<span className="text-text-secondary">
Adds previous versions for each document so timelines are preserved when importing.
</span>
</span>
</label>

<label className="flex items-start gap-3">
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-border-color text-primary focus:ring-primary"
checked={includePythonSettings}
onChange={(event) => setIncludePythonSettings(event.target.checked)}
/>
<span className="text-sm text-text-main">
<span className="font-medium">Include Python settings</span>
<br />
<span className="text-text-secondary">
Bundles interpreter selections and auto-detect preferences for Python-enabled nodes.
</span>
</span>
</label>

<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onCancel}
className="rounded-md border border-border-color px-4 py-2 text-sm font-medium text-text-main hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-50"
disabled={isExporting}
>
Cancel
</button>
<button
type="submit"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-text hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-70"
disabled={isExporting}
>
{isExporting ? 'Exporting…' : 'Export'}
</button>
</div>
</form>
</div>
</div>
);
};

export default NodeExportModal;
90 changes: 16 additions & 74 deletions components/PromptList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import React, { useState, useMemo, useCallback } from 'react';
// Fix: Correctly import the DocumentOrFolder type.
import type { DocumentOrFolder, DraggedNodeTransfer, SerializedNodeForTransfer } from '../types';
import type { DocumentOrFolder, DraggedNodeTransfer } from '../types';
import DocumentTreeItem, { DocumentNode, DOCFORGE_DRAG_MIME } from './PromptTreeItem';
import {
buildDraggedNodePayload,
buildTransferContext,
type TransferableTreeNode,
} from '../services/nodeTransfer';

interface DocumentListProps {
tree: DocumentNode[];
Expand Down Expand Up @@ -59,81 +64,18 @@ const DocumentList: React.FC<DocumentListProps> = ({
// Fix: Corrected useState declaration syntax from `=>` to `=`. This resolves all subsequent "cannot find name" errors.
const [isRootDropping, setIsRootDropping] = useState(false);

const nodeLookup = useMemo(() => {
const map = new Map<string, DocumentNode>();
const traverse = (nodes: DocumentNode[]) => {
for (const node of nodes) {
map.set(node.id, node);
if (node.children.length > 0) {
traverse(node.children);
}
}
};
traverse(tree);
return map;
}, [tree]);

const parentLookup = useMemo(() => {
const map = new Map<string, string | null>();
const traverse = (nodes: DocumentNode[], parentId: string | null) => {
for (const node of nodes) {
map.set(node.id, parentId);
if (node.children.length > 0) {
traverse(node.children, node.id);
}
}
};
traverse(tree, null);
return map;
}, [tree]);

const serializeNode = useCallback(function serialize(node: DocumentNode): SerializedNodeForTransfer {
const children = node.children.length > 0 ? node.children.map(serialize) : undefined;
return {
type: node.type,
title: node.title,
content: node.content,
doc_type: node.doc_type,
language_hint: node.language_hint ?? null,
default_view_mode: node.default_view_mode ?? null,
children,
};
}, []);

const buildTransferPayload = useCallback((ids: string[]): DraggedNodeTransfer | null => {
if (!ids.length) {
return null;
}
const idSet = new Set(ids);
const rootIds = ids.filter(id => {
let current = parentLookup.get(id) ?? null;
while (current) {
if (idSet.has(current)) {
return false;
}
current = parentLookup.get(current) ?? null;
}
return true;
});

const nodes = rootIds
.map(id => nodeLookup.get(id))
.filter((node): node is DocumentNode => Boolean(node))
.map(serializeNode);

if (nodes.length === 0) {
return null;
}
const transferContext = useMemo(
() => buildTransferContext(tree as unknown as TransferableTreeNode[]),
[tree]
);

return {
schema: 'docforge/nodes',
version: 1,
exportedAt: new Date().toISOString(),
nodes,
};
}, [nodeLookup, parentLookup, serializeNode]);
const buildTransferPayload = useCallback(
(ids: string[]): DraggedNodeTransfer | null =>
buildDraggedNodePayload(ids, transferContext, { includePythonSettings: true }),
[transferContext]
);

const isKnownNodeId = useCallback((id: string) => nodeLookup.has(id), [nodeLookup]);
const isKnownNodeId = useCallback((id: string) => transferContext.nodeLookup.has(id), [transferContext]);

const handleRootDrop = (e: React.DragEvent) => {
e.preventDefault();
Expand Down
49 changes: 42 additions & 7 deletions electron/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -863,10 +863,24 @@ export const databaseService = {
);

const newDocumentId = Number(docResult.lastInsertRowid);
const effectiveContent = content;

if (effectiveContent && effectiveContent.length > 0) {
const sha = crypto.createHash('sha256').update(effectiveContent).digest('hex');
const normalizedVersions = Array.isArray(node.versions)
? node.versions
.map(version => ({
createdAt: typeof version.created_at === 'string' ? version.created_at : now,
content: typeof version.content === 'string' ? version.content : '',
}))
.filter(entry => typeof entry.createdAt === 'string')
: [];

if (normalizedVersions.length === 0 && content) {
normalizedVersions.push({ createdAt: now, content });
}

let latestVersionId: number | null = null;

for (const entry of normalizedVersions) {
const sha = crypto.createHash('sha256').update(entry.content).digest('hex');
const existingContent = db
Comment on lines +880 to 884

Choose a reason for hiding this comment

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

P1 Badge Electron import selects oldest history version as current

The same ordering issue occurs in the Electron-backed import: the loop iterates the serialized node.versions in the order provided and assigns latestVersionId on every iteration. Because exported histories are newest-first, the value left in latestVersionId after the loop is the oldest revision, so documents.current_version_id will reference stale content even though newer versions were inserted. Imports that include histories will therefore load the wrong document content. Choose the first entry or iterate oldest-to-newest before setting the current version.

Useful? React with 👍 / 👎.

.prepare('SELECT content_id FROM content_store WHERE sha256_hex = ?')
.get(sha) as { content_id: number } | undefined;
Expand All @@ -876,15 +890,36 @@ export const databaseService = {
: Number(
db
.prepare('INSERT INTO content_store (sha256_hex, text_content) VALUES (?, ?)')
.run(sha, effectiveContent).lastInsertRowid
.run(sha, entry.content).lastInsertRowid
);

const versionResult = db
.prepare('INSERT INTO doc_versions (document_id, created_at, content_id) VALUES (?, ?, ?)')
.run(newDocumentId, now, contentId);
.run(newDocumentId, entry.createdAt, contentId);

latestVersionId = Number(versionResult.lastInsertRowid);
}

if (latestVersionId !== null) {
db.prepare('UPDATE documents SET current_version_id = ? WHERE document_id = ?').run(latestVersionId, newDocumentId);
}

const versionId = Number(versionResult.lastInsertRowid);
db.prepare('UPDATE documents SET current_version_id = ? WHERE document_id = ?').run(versionId, newDocumentId);
if (node.python_settings) {
const envId = typeof node.python_settings.env_id === 'string' && node.python_settings.env_id.trim().length > 0
? node.python_settings.env_id
: null;
const autoDetect = node.python_settings.auto_detect_environment !== undefined
? Boolean(node.python_settings.auto_detect_environment)
: true;
const lastRunId = typeof node.python_settings.last_run_id === 'string'
? node.python_settings.last_run_id
: null;

db.prepare(
`INSERT INTO node_python_settings (node_id, env_id, auto_detect_env, last_run_id, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(node_id) DO UPDATE SET env_id = excluded.env_id, auto_detect_env = excluded.auto_detect_env, last_run_id = excluded.last_run_id, updated_at = excluded.updated_at`
).run(newNodeId, envId, autoDetect ? 1 : 0, lastRunId, now);
}
}

Expand Down
55 changes: 55 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,61 @@ ipcMain.handle('dialog:save', async (_, options, content) => {
}
});

ipcMain.handle('nodes:export', async (_, content: string, options: { defaultFileName?: string } = {}) => {
if (!mainWindow) {
return { success: false, error: 'Main window not available' };
}

const suggestedName = options.defaultFileName ?? `docforge-nodes-${new Date().toISOString().replace(/[:]/g, '-')}.dfnodes`;
const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, {
title: 'Export Nodes',
defaultPath: suggestedName,
filters: [
{ name: 'DocForge Node Export', extensions: ['dfnodes'] },
{ name: 'JSON Files', extensions: ['json'] },
{ name: 'All Files', extensions: ['*'] },
],
});

if (canceled || !filePath) {
return { success: false };
}

try {
await fs.writeFile(filePath, content, 'utf-8');
return { success: true, path: filePath };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Failed to save nodes export.' };
}
});

ipcMain.handle('nodes:import', async () => {
if (!mainWindow) {
return { success: false, error: 'Main window not available' };
}

const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
title: 'Import Nodes',
filters: [
{ name: 'DocForge Node Export', extensions: ['dfnodes'] },
{ name: 'JSON Files', extensions: ['json'] },
{ name: 'All Files', extensions: ['*'] },
],
properties: ['openFile'],
});

if (canceled || filePaths.length === 0) {
return { success: false };
}

try {
const content = await fs.readFile(filePaths[0], 'utf-8');
return { success: true, content, path: filePaths[0] };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Failed to read nodes export.' };
}
});

ipcMain.handle('dialog:open', async (_, options) => {
if (!mainWindow) return { success: false, error: 'Main window not available' };
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, options);
Expand Down
5 changes: 5 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
filters: [{ name: 'JSON Files', extensions: ['json'] }, { name: 'All Files', extensions: ['*'] }]
}, content),

nodesExport: (content: string, options?: { defaultFileName?: string }) =>
ipcRenderer.invoke('nodes:export', content, options ?? {}),

nodesImport: () => ipcRenderer.invoke('nodes:import'),

settingsImport: () => ipcRenderer.invoke('dialog:open', {
title: 'Import Settings',
filters: [{ name: 'JSON Files', extensions: ['json'] }],
Expand Down
Loading