From 517420fb263f649d1bc157dbd2579939d1941462 Mon Sep 17 00:00:00 2001 From: TheWover <17090738+TheWover@users.noreply.github.com> Date: Sat, 4 Oct 2025 07:54:43 -0600 Subject: [PATCH 1/2] Node soft links now support paths, not just node ID. --- backend/app/services/node_service.py | 41 ++++++++++++++- desktop/package.json | 2 +- desktop/src/main.ts | 27 ++++++++-- docs/editor.md | 24 +++++++++ docs/graph-view.md | 1 + docs/unified-model-implementation.md | 2 + frontend/src/pages/Editor.tsx | 2 +- frontend/src/store/nodeStore.ts | 77 +++++++++++++++++++++++----- package-lock.json | 8 +-- 9 files changed, 160 insertions(+), 24 deletions(-) diff --git a/backend/app/services/node_service.py b/backend/app/services/node_service.py index ed4b98b..55823ec 100644 --- a/backend/app/services/node_service.py +++ b/backend/app/services/node_service.py @@ -148,6 +148,7 @@ def normalize_name(n: str) -> str: 'parent': parent, 'children': children }, + # Preserve raw metadata.links; softLinks will be normalized where nodes are aggregated 'softLinks': metadata.get('links', []), 'hasTask': 'task' in metadata, 'taskStatus': metadata.get('task', {}).get('status') if 'task' in metadata else None @@ -356,7 +357,7 @@ async def list_nodes(self, directory: Optional[str] = None, exclude_templates: b if directory is None: directory = "nodes" - nodes = [] + nodes: List[Dict[str, Any]] = [] start_path = os.path.join(self.project_path, directory) for root, dirs, files in os.walk(start_path): @@ -395,7 +396,43 @@ async def list_nodes(self, directory: Optional[str] = None, exclude_templates: b node = await self.read_node(file_path) if node: nodes.append(node) - + + # Normalize softLinks: allow entries to be either node IDs or repository-relative paths + try: + # Build path->id index + path_to_id = {} + id_set = set() + for n in nodes: + nid = str(n.get('metadata', {}).get('id') or '') + if nid: + id_set.add(nid) + p = str(n.get('path') or '').replace('\\', '/') + if p: + path_to_id[p] = nid + for n in nodes: + raw = n.get('metadata', {}).get('links', []) or [] + resolved: List[str] = [] + for entry in raw if isinstance(raw, list) else []: + try: + s = str(entry or '') + if not s: + continue + if s in id_set: + if s not in resolved: + resolved.append(s) + continue + norm = s.replace('\\', '/') + with_md = norm if norm.endswith('.md') else f"{norm}.md" + tid = path_to_id.get(norm) or path_to_id.get(with_md) + if tid and tid not in resolved: + resolved.append(tid) + except Exception: + continue + n['softLinks'] = resolved + except Exception: + # On any failure, leave softLinks as-is + pass + return nodes async def search_nodes(self, query: str, node_type: Optional[str] = None, diff --git a/desktop/package.json b/desktop/package.json index 0c988b6..1b2c164 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -33,7 +33,7 @@ "devDependencies": { "@types/node": "^20.10.4", "cross-env": "^7.0.3", - "electron": "^38.2.0", + "electron": "^38.2.1", "electron-builder": "^26.0.12", "electron-vite": "^3.1.0", "typescript": "^5.3.3" diff --git a/desktop/src/main.ts b/desktop/src/main.ts index fd8e938..07c6877 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -1562,6 +1562,7 @@ function setupIpcHandlers() { const nodesDir = path.join(projectPath, 'nodes'); const graphNodes: any[] = []; // Type later with Shared GraphNode const graphEdges: any[] = []; // Type later with Shared GraphEdge + const pathToId = new Map(); if (!existsSync(nodesDir)) { console.warn(`[graph:loadData] Nodes directory does not exist: ${nodesDir}`); @@ -1619,17 +1620,25 @@ function setupIpcHandlers() { tags: frontmatter.tags || [], status: frontmatter.status }); + if (frontmatter.id) { + pathToId.set(relativeEntryPath, String(frontmatter.id)); + } - // Edge extraction from frontmatter.links + // Edge extraction from frontmatter.links (IDs or paths) if (frontmatter.links && Array.isArray(frontmatter.links)) { frontmatter.links.forEach((linkTarget: string) => { if (linkTarget && typeof linkTarget === 'string') { - const targetNodeId = linkTarget.replace(/\\/g, '/'); - const edgeId = `fm-${relativeEntryPath}-${targetNodeId}`.replace(/[^a-zA-Z0-9-_]/g, '-'); + const raw = linkTarget.replace(/\\/g, '/'); + const isIdLike = /^node-/.test(raw); + let targetRef = raw; + if (!isIdLike && !/\.md$/i.test(targetRef)) { + targetRef += '.md'; + } + const edgeId = `fm-${relativeEntryPath}-${targetRef}`.replace(/[^a-zA-Z0-9-_]/g, '-'); graphEdges.push({ id: edgeId, source: relativeEntryPath, - target: targetNodeId, + target: isIdLike ? `__ID__:${raw}` : targetRef, type: 'soft', label: frontmatter.linkLabel || 'links to' }); @@ -1670,6 +1679,16 @@ function setupIpcHandlers() { try { // Start processing from the nodes directory with empty relative base await processDirectory(nodesDir, ''); + // Resolve any edges that carry ID placeholders to actual paths + const idToPath = new Map(); + pathToId.forEach((id, p) => idToPath.set(id, p)); + for (const e of graphEdges) { + if (typeof e.target === 'string' && e.target.startsWith('__ID__:')) { + const id = e.target.slice('__ID__:'.length); + const p = idToPath.get(id); + if (p) e.target = p; + } + } console.log(`[graph:loadData] Loaded ${graphNodes.length} nodes and ${graphEdges.length} edges.`); return { nodes: graphNodes, edges: graphEdges }; } catch (error) { diff --git a/docs/editor.md b/docs/editor.md index beba41c..1ef9c37 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -162,6 +162,30 @@ title: Chapter 1 links: [node-abc, node-def] task: tracked: true + +## Linking to other nodes via path (new) + +In addition to linking by node IDs, you can now link to other nodes by their repository-relative path. This is optional and additive; the app still writes IDs when creating links. + +Examples (all valid): + +```yaml +--- +id: node-123 +title: Chapter 1 +links: + # By ID (preferred by system) + - node-abc + # By path (with extension) + - nodes/Characters/Alice.md + # By path (extension optional; .md assumed) + - nodes/Places/Village +--- +``` + +Notes: +- Path values are resolved project‑relative and normalized; `.md` is assumed if omitted. +- At load time, Verbweaver resolves any path entries to their target node IDs. Your existing ID-based links continue to work. --- ``` diff --git a/docs/graph-view.md b/docs/graph-view.md index efe7a50..187a353 100644 --- a/docs/graph-view.md +++ b/docs/graph-view.md @@ -17,6 +17,7 @@ A sub-view switcher is available within each sub-view for quick navigation (Mind The Mind Map shows nodes as draggable items connected by links: - Drag nodes to rearrange; enable Rigid Mode to persist per-project folder positions. +- Soft links can be authored by node ID or by repository-relative path in a node's YAML frontmatter. The graph resolves any path-based links to the appropriate targets at load time. - Use the left-side controls to hide uploads and toggle rigid layout. - The MiniMap in the corner provides an overview for large graphs. diff --git a/docs/unified-model-implementation.md b/docs/unified-model-implementation.md index 63127c8..8e38e7f 100644 --- a/docs/unified-model-implementation.md +++ b/docs/unified-model-implementation.md @@ -86,6 +86,8 @@ position: y: 150 links: - node-0987654321-xyz789ghi + # You may also link by repository-relative path (extension optional) + - nodes/Appendix/Glossary task: status: in-progress priority: high diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 28ad941..d70deb3 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -123,7 +123,7 @@ function EditorView() { if (!resolvedNodePath) return [] as Array<{ path: string; name: string; title: string; id: string }> const node = s.nodes.get(resolvedNodePath) if (!node) return [] - const linkIds: string[] = Array.isArray(node.metadata?.links) ? node.metadata.links : [] + const linkIds: string[] = Array.isArray(node.softLinks) ? node.softLinks : [] const results: Array<{ path: string; name: string; title: string; id: string }> = [] if (linkIds.length === 0) return results for (const other of s.nodes.values()) { diff --git a/frontend/src/store/nodeStore.ts b/frontend/src/store/nodeStore.ts index 5a1153b..1543449 100644 --- a/frontend/src/store/nodeStore.ts +++ b/frontend/src/store/nodeStore.ts @@ -110,6 +110,32 @@ function sanitizeFilename(name: string): string { return name.replace(/[<>:"/\\|?*]/g, '-').trim(); } +// Normalize a list of link entries (IDs or paths) to a unique array of target IDs +function resolveLinksToIds(rawLinks: unknown, nodesMap: Map): string[] { + const result = new Set() + if (!Array.isArray(rawLinks)) return [] + // Precompute indexes + const idToId = new Set() + const pathToId = new Map() + for (const n of nodesMap.values()) { + const nid = String(n?.metadata?.id || '') + if (nid) idToId.add(nid) + pathToId.set(n.path.replace(/\\/g,'/'), nid) + } + for (const entry of rawLinks) { + const val = String(entry || '') + if (!val) continue + // Prefer exact ID match + if (idToId.has(val)) { result.add(val); continue } + // Try as normalized path + const norm = val.replace(/\\/g,'/') + const withMd = norm.endsWith('.md') ? norm : `${norm}.md` + if (pathToId.has(norm)) { result.add(pathToId.get(norm)!); continue } + if (pathToId.has(withMd)) { result.add(pathToId.get(withMd)!); continue } + } + return Array.from(result) +} + // Helper to load a node from file (Electron only) async function loadNodeFromFile(filePath: string, isDirectory: boolean): Promise { if (!isElectron || !window.electronAPI) return null; @@ -261,8 +287,19 @@ export const useNodeStore = create((set, get) => ({ } } - console.log('[NodeStore] Loaded nodes:', Array.from(nodes.keys())); - set({ nodes, isLoading: false }); + // After initial load, normalize link entries to IDs for softLinks + try { + const normalized = new Map() + nodes.forEach((n, p) => { + const softIds = resolveLinksToIds((n.metadata as any)?.links || [], nodes) + normalized.set(p, { ...n, softLinks: softIds }) + }) + console.log('[NodeStore] Loaded nodes:', Array.from(normalized.keys())) + set({ nodes: normalized, isLoading: false }) + } catch { + console.log('[NodeStore] Loaded nodes:', Array.from(nodes.keys())) + set({ nodes, isLoading: false }) + } } else if (currentProject) { // Web: Fetch from API const response = await apiClient.get(`/projects/${currentProject.id}/nodes`); @@ -272,8 +309,17 @@ export const useNodeStore = create((set, get) => ({ for (const node of data.nodes) { nodes.set(node.path, node); } - - set({ nodes, isLoading: false }); + // Normalize softLinks from backend too, just in case older projects contain path links + try { + const normalized = new Map() + nodes.forEach((n, p) => { + const softIds = resolveLinksToIds((n.metadata as any)?.links || n.softLinks || [], nodes) + normalized.set(p, { ...n, softLinks: softIds }) + }) + set({ nodes: normalized, isLoading: false }) + } catch { + set({ nodes, isLoading: false }) + } } } catch (error) { console.error('[NodeStore] Failed to load nodes:', error); @@ -403,7 +449,7 @@ export const useNodeStore = create((set, get) => ({ ...node, metadata: updatedMetadata, content: updatedContent, - softLinks: updatedMetadata.links || [], + softLinks: resolveLinksToIds((updatedMetadata as any)?.links || [], get().nodes), hasTask: !node.isDirectory && ((updatedMetadata as any)?.task?.tracked !== false), taskStatus: (updatedMetadata as any).task?.status }; @@ -455,7 +501,7 @@ export const useNodeStore = create((set, get) => ({ const updatedNode: VerbweaverNode = { ...node, metadata: updatedMetadata, - softLinks: updatedMetadata.links || [], + softLinks: resolveLinksToIds((updatedMetadata as any)?.links || [], get().nodes), hasTask: !node.isDirectory && ((updatedMetadata as any)?.task?.tracked !== false), taskStatus: (updatedMetadata as any).task?.status }; @@ -493,7 +539,7 @@ export const useNodeStore = create((set, get) => ({ const updatedNode: VerbweaverNode = { ...node, metadata: updatedMetadata, - softLinks: updatedMetadata.links || [], + softLinks: resolveLinksToIds((updatedMetadata as any)?.links || [], get().nodes), hasTask: !node.isDirectory && ((updatedMetadata as any)?.task?.tracked !== false), taskStatus: (updatedMetadata as any).task?.status }; @@ -530,15 +576,16 @@ export const useNodeStore = create((set, get) => ({ if (deletedId) { for (const [nodePath, node] of newNodes) { const links = Array.isArray(node.metadata?.links) ? node.metadata.links : []; - if (links.includes(deletedId)) { - const filtered = links.filter((id: string) => id !== deletedId); + const removeBy = new Set([deletedId, path.replace(/\\/g,'/')]) + const filtered = links.filter((v: string) => !removeBy.has(String(v)) && !removeBy.has(String(v).replace(/\\/g,'/'))) + if (filtered.length !== links.length) { const updated = { ...node, metadata: { ...node.metadata, links: filtered, }, - softLinks: filtered, + softLinks: resolveLinksToIds(filtered, newNodes as any), } as any; newNodes.set(nodePath, updated); } @@ -626,13 +673,19 @@ export const useNodeStore = create((set, get) => ({ const targetId = targetNode.metadata.id; // Remove link from source node - const updatedSourceLinks = (sourceNode.metadata.links || []).filter(id => id !== targetId); + const updatedSourceLinks = (sourceNode.metadata.links || []).filter(v => { + const s = String(v).replace(/\\/g,'/') + return s !== targetId && s !== targetPath.replace(/\\/g,'/') + }); await get().updateNode(sourcePath, { metadata: { links: updatedSourceLinks } }); // Remove link from target node - const updatedTargetLinks = (targetNode.metadata.links || []).filter(id => id !== sourceId); + const updatedTargetLinks = (targetNode.metadata.links || []).filter(v => { + const s = String(v).replace(/\\/g,'/') + return s !== sourceId && s !== sourcePath.replace(/\\/g,'/') + }); await get().updateNode(targetPath, { metadata: { links: updatedTargetLinks } }); diff --git a/package-lock.json b/package-lock.json index 4d77b98..6a4d9b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,7 @@ "devDependencies": { "@types/node": "^20.10.4", "cross-env": "^7.0.3", - "electron": "^38.2.0", + "electron": "^38.2.1", "electron-builder": "^26.0.12", "electron-vite": "^3.1.0", "typescript": "^5.3.3" @@ -6153,9 +6153,9 @@ } }, "node_modules/electron": { - "version": "38.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-38.2.0.tgz", - "integrity": "sha512-Cw5Mb+N5NxsG0Hc1qr8I65Kt5APRrbgTtEEn3zTod30UNJRnAE1xbGk/1NOaDn3ODzI/MYn6BzT9T9zreP7xWA==", + "version": "38.2.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-38.2.1.tgz", + "integrity": "sha512-P4pE2RpRg3kM8IeOK+heg6iAxR5wcXnNHrbVchn7M3GBnYAhjfJRkROusdOro5PlKzdtfKjesbbqaG4MqQXccg==", "dev": true, "hasInstallScript": true, "license": "MIT", From 14af7d3d94b6644f7bbb4af3dcbce8d05ed06fb4 Mon Sep 17 00:00:00 2001 From: TheWover <17090738+TheWover@users.noreply.github.com> Date: Sat, 4 Oct 2025 09:29:40 -0600 Subject: [PATCH 2/2] Fixed Task details dialog links for links made via path instead of node ID --- docs/editor.md | 4 +- .../src/components/tasks/TaskDetailModal.tsx | 203 +++++++++--------- frontend/src/store/nodeStore.ts | 28 ++- frontend/src/views/TasksView.tsx | 16 +- 4 files changed, 144 insertions(+), 107 deletions(-) diff --git a/docs/editor.md b/docs/editor.md index 1ef9c37..b29daaf 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -163,9 +163,9 @@ links: [node-abc, node-def] task: tracked: true -## Linking to other nodes via path (new) +## Linking to other nodes via path -In addition to linking by node IDs, you can now link to other nodes by their repository-relative path. This is optional and additive; the app still writes IDs when creating links. +In addition to linking by node IDs, you can now link to other nodes by their repository-relative path. This is optional; the app still writes IDs when creating links. Examples (all valid): diff --git a/frontend/src/components/tasks/TaskDetailModal.tsx b/frontend/src/components/tasks/TaskDetailModal.tsx index 34a9cd2..f3195c6 100644 --- a/frontend/src/components/tasks/TaskDetailModal.tsx +++ b/frontend/src/components/tasks/TaskDetailModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useMemo } from 'react' import { X, Send, Paperclip, Link, User, Calendar, Tag, MessageSquare, Edit3, Download, Trash2, FileText, Eye, ChevronDown, ChevronRight } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { useNodeStore } from '../../store/nodeStore' @@ -47,6 +47,7 @@ interface TaskDetailModalProps { function TaskDetailModal({ node, onClose, onUpdate, availableStatuses, columns, onDelete }: TaskDetailModalProps) { const { updateNode, getNode, loadNodes } = useNodeStore() + const nodesMap = useNodeStore((s) => s.nodes) const { addEditorTab } = useTabStore() const navigate = useNavigate() const [isEditing, setIsEditing] = useState(false) @@ -74,6 +75,24 @@ function TaskDetailModal({ node, onClose, onUpdate, availableStatuses, columns, const [previewError, setPreviewError] = useState('') const { currentProjectPath } = useProjectStore() + // Resolve linked nodes (normalized to IDs in node.softLinks) + const linkedNodes = useMemo(() => { + if (!node) return [] as VerbweaverNode[] + const linkIds: string[] = Array.isArray(node.softLinks) ? node.softLinks : [] + if (linkIds.length === 0) return [] + const results: VerbweaverNode[] = [] + for (const other of nodesMap.values()) { + if (!other.isDirectory && linkIds.includes(other.metadata?.id)) { + results.push(other as any) + } + } + const uniq = new Map() + results.forEach(r => uniq.set(r.path, r)) + return Array.from(uniq.values()).sort((a, b) => ( + (a.metadata?.title || a.name).localeCompare(b.metadata?.title || b.name) + )) + }, [node?.path, nodesMap]) + useEffect(() => { if (node) { const task = node.metadata.task || {} @@ -621,101 +640,93 @@ function TaskDetailModal({ node, onClose, onUpdate, availableStatuses, columns, )} - {(node.metadata.links || []).length > 0 ? ( -
- {(node.metadata.links || []).map((linkId: string, index: number) => { - // Find the linked node by ID - const linkedNode = Array.from(useNodeStore.getState().nodes.values()) - .find(n => n.metadata.id === linkId) - - const handleCopy = async () => { - const core = String(linkId || '').replace(/^node-/, '') - const tag = `node-${core}` - try { - if ((navigator as any)?.clipboard?.writeText) { - await navigator.clipboard.writeText(tag) - } else { - const ta = document.createElement('textarea') - ta.value = tag - ta.style.position = 'fixed' - ta.style.opacity = '0' - document.body.appendChild(ta) - ta.focus() - ta.select() - try { document.execCommand('copy') } catch {} - document.body.removeChild(ta) - } - toast.success('Copied node ID tag') - } catch { - toast.error('Copy failed') - } - } - - return ( -
-
- - -
- {isEditing && ( - - )} -
- ) - })} -
- ) : ( -

- No related content. {isEditing && "Click 'Create Link' to add connections to other nodes."} -

- )} + {linkedNodes.length > 0 ? ( +
+ {linkedNodes.map((ln) => { + const handleCopy = async () => { + const idToCopy = ln.metadata?.id + if (!idToCopy) return + const core = String(idToCopy || '').replace(/^node-/, '') + const tag = `node-${core}` + try { + if ((navigator as any)?.clipboard?.writeText) { + await (navigator as any).clipboard.writeText(tag) + } else { + const ta = document.createElement('textarea') + ta.value = tag + ta.style.position = 'fixed' + ta.style.opacity = '0' + document.body.appendChild(ta) + ta.focus(); ta.select(); try { document.execCommand('copy') } catch {} + document.body.removeChild(ta) + } + toast.success('Copied node ID tag') + } catch { + toast.error('Copy failed') + } + } + + return ( +
+
+ + {ln.metadata?.id && ( + + )} +
+ {isEditing && ( + + )} +
+ ) + })} +
+ ) : ( +

+ No related content. {isEditing && "Click 'Create Link' to add connections to other nodes."} +

+ )} {/* Save Button */} diff --git a/frontend/src/store/nodeStore.ts b/frontend/src/store/nodeStore.ts index 1543449..57b8a2f 100644 --- a/frontend/src/store/nodeStore.ts +++ b/frontend/src/store/nodeStore.ts @@ -117,10 +117,16 @@ function resolveLinksToIds(rawLinks: unknown, nodesMap: Map() const pathToId = new Map() + const pathToIdLower = new Map() for (const n of nodesMap.values()) { const nid = String(n?.metadata?.id || '') - if (nid) idToId.add(nid) - pathToId.set(n.path.replace(/\\/g,'/'), nid) + if (nid) { + idToId.add(nid) + // Only index paths that have valid IDs to avoid capturing folders without IDs + const normPath = n.path.replace(/\\/g,'/') + pathToId.set(normPath, nid) + pathToIdLower.set(normPath.toLowerCase(), nid) + } } for (const entry of rawLinks) { const val = String(entry || '') @@ -130,8 +136,22 @@ function resolveLinksToIds(rawLinks: unknown, nodesMap: Map k.toLowerCase()) + const suffixMatches: number[] = [] + keysLower.forEach((k, idx) => { if (k.endsWith(withMdLower) || k.endsWith(normLower)) suffixMatches.push(idx) }) + if (suffixMatches.length === 1) { + const id = pathToId.get(keys[suffixMatches[0]]) + if (id) { result.add(id); continue } + } } return Array.from(result) } diff --git a/frontend/src/views/TasksView.tsx b/frontend/src/views/TasksView.tsx index f864d2c..165a619 100644 --- a/frontend/src/views/TasksView.tsx +++ b/frontend/src/views/TasksView.tsx @@ -297,14 +297,20 @@ function TasksView() { } } - // Handle opening task from URL parameter + // Handle opening task from URL parameter (robustly resolve path variants) useEffect(() => { if (taskPath && nodes.size > 0) { - const decodedTaskPath = decodeURIComponent(taskPath) - const taskNode = nodes.get(decodedTaskPath) - + const decoded = decodeURIComponent(taskPath) + const norm = decoded.replace(/\\/g, '/').replace(/^\/+/, '') + const withMd = norm.endsWith('.md') ? norm : `${norm}.md` + // Try direct variants + let taskNode = nodes.get(norm) || nodes.get(withMd) || nodes.get(norm.replace(/^nodes\//, 'nodes/')) + // Fallback: unique suffix match + if (!taskNode) { + const matches = Array.from(nodes.values()).filter(n => n.path.replace(/\\/g,'/').endsWith(withMd) || n.path.replace(/\\/g,'/').endsWith(norm)) + if (matches.length === 1) taskNode = matches[0] + } if (taskNode && taskNode.isMarkdown) { - console.log('Opening task from URL:', decodedTaskPath, taskNode) setSelectedTask(taskNode) setIsDetailModalOpen(true) // Clear the URL parameter after opening the task