diff --git a/backend/app/services/node_service.py b/backend/app/services/node_service.py index 55823ec..5883873 100644 --- a/backend/app/services/node_service.py +++ b/backend/app/services/node_service.py @@ -264,14 +264,41 @@ async def delete_node(self, path: str) -> None: try: node_to_delete = await self.read_node(path) target_id = node_to_delete['metadata'].get('id') if node_to_delete else None - if target_id: + # Build a removal set including id and path variants + norm_path = str(path or '').replace('\\', '/') + with_md = norm_path if norm_path.endswith('.md') else f"{norm_path}.md" + without_md = norm_path[:-3] if norm_path.endswith('.md') else norm_path + remove_values = {v for v in [target_id, norm_path, with_md, without_md] if v} + + if remove_values: all_nodes = await self.list_nodes() for other in all_nodes: - if other['path'] == path: + if other.get('path') == path: + continue + raw_links = other.get('metadata', {}).get('links', []) or [] + if not isinstance(raw_links, list): continue - other_links = other['metadata'].get('links', []) - if target_id in other_links: - cleaned_links = [lid for lid in other_links if lid != target_id] + def should_keep(val: Any) -> bool: + try: + s = str(val or '') + if not s: + return False + if s in remove_values: + return False + s_norm = s.replace('\\', '/') + if s_norm in remove_values: + return False + s_with_md = s_norm if s_norm.endswith('.md') else f"{s_norm}.md" + if s_with_md in remove_values: + return False + s_without_md = s_norm[:-3] if s_norm.endswith('.md') else s_norm + if s_without_md in remove_values: + return False + return True + except Exception: + return True + cleaned_links = [v for v in raw_links if should_keep(v)] + if len(cleaned_links) != len(raw_links): await self.update_node(other['path'], {'links': cleaned_links}) except Exception: # Don't block deletion if cleanup fails diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 07c6877..76fdcdf 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -1961,9 +1961,19 @@ function setupIpcHandlers() { return out; }; - // If we know the deleted node's id, scan other nodes and remove backlinks - if (deletedNodeId) { + // Scan other nodes and remove backlinks by ID and path variants + { const nodesDir = path.join(projectPath, 'nodes'); + const normRel = normalizedRel; + const relWithMd = /\.md$/i.test(normRel) ? normRel : `${normRel}.md`; + const relWithoutMd = /\.md$/i.test(normRel) ? normRel.slice(0, -3) : normRel; + const removalSet = new Set([ + ...(deletedNodeId ? [deletedNodeId] : []), + normRel, + relWithMd, + relWithoutMd, + ].filter(Boolean) as string[]); + const walk = async (dir: string) => { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { @@ -1979,8 +1989,21 @@ function setupIpcHandlers() { const parsed = matter(fc); const fm = (parsed.data || {}) as any; const links: any[] = Array.isArray(fm.links) ? fm.links : []; - if (links.includes(deletedNodeId)) { - const newLinks = links.filter((l: any) => l !== deletedNodeId); + const newLinks = links.filter((l: any) => { + try { + const s = String(l || ''); + if (!s) return false; // drop empty + if (removalSet.has(s)) return false; + const sNorm = s.replace(/\\/g, '/'); + if (removalSet.has(sNorm)) return false; + const sWith = /\.md$/i.test(sNorm) ? sNorm : `${sNorm}.md`; + if (removalSet.has(sWith)) return false; + const sWithout = /\.md$/i.test(sNorm) ? sNorm.slice(0, -3) : sNorm; + if (removalSet.has(sWithout)) return false; + return true; + } catch { return true; } + }); + if (newLinks.length !== links.length) { const newFrontmatter = removeUndefined({ ...fm, links: newLinks }); const newContent = matter.stringify(parsed.content || '', newFrontmatter); await fs.writeFile(full, newContent, 'utf8'); diff --git a/frontend/src/components/tasks/TaskDetailModal.tsx b/frontend/src/components/tasks/TaskDetailModal.tsx index f3195c6..ec7019c 100644 --- a/frontend/src/components/tasks/TaskDetailModal.tsx +++ b/frontend/src/components/tasks/TaskDetailModal.tsx @@ -701,9 +701,13 @@ function TaskDetailModal({ node, onClose, onUpdate, availableStatuses, columns, const s = String(v || '').replace(/\\/g,'/') return s !== ln.metadata.id && s !== ln.path.replace(/\\/g,'/') }) + const updatedSoftLinks = Array.isArray(node.softLinks) + ? node.softLinks.filter((id: string) => id !== ln.metadata.id) + : [] const updatedNode = { ...node, - metadata: { ...node.metadata, links: updatedLinks } + metadata: { ...node.metadata, links: updatedLinks }, + softLinks: updatedSoftLinks } onUpdate(updatedNode) toast.success('Link removed') @@ -833,7 +837,29 @@ function TaskDetailModal({ node, onClose, onUpdate, availableStatuses, columns, onLinkCreated={(updatedLinks) => { setIsCreateLinkModalOpen(false) if (updatedLinks) { - const updatedNode = { ...node, metadata: { ...node.metadata, links: updatedLinks } } as any + // Resolve returned links (may be IDs or paths) into normalized softLinks (IDs) + const pathToId = new Map() + const idSet = new Set() + for (const other of nodesMap.values()) { + const p = String(other.path || '').replace(/\\/g, '/') + const nid = String(other.metadata?.id || '') + if (p) pathToId.set(p, nid) + if (nid) idSet.add(nid) + } + const resolvedSoftLinks: string[] = [] + for (const entry of (Array.isArray(updatedLinks) ? updatedLinks : [])) { + const raw = String(entry || '') + if (!raw) continue + if (idSet.has(raw)) { + if (!resolvedSoftLinks.includes(raw)) resolvedSoftLinks.push(raw) + continue + } + const norm = raw.replace(/\\/g, '/') + const withMd = /\.md$/i.test(norm) ? norm : `${norm}.md` + const tid = pathToId.get(norm) || pathToId.get(withMd) + if (tid && !resolvedSoftLinks.includes(tid)) resolvedSoftLinks.push(tid) + } + const updatedNode = { ...node, metadata: { ...node.metadata, links: updatedLinks }, softLinks: resolvedSoftLinks } as any onUpdate(updatedNode) } else { onUpdate({ ...(node as any) }) diff --git a/frontend/src/pages/Graph.tsx b/frontend/src/pages/Graph.tsx index 385367e..ddfcde6 100644 --- a/frontend/src/pages/Graph.tsx +++ b/frontend/src/pages/Graph.tsx @@ -124,6 +124,18 @@ function GraphView() { } }) + // Collapsible Options tray (Mind Map) + const OPTIONS_OPEN_LOCAL_KEY = 'verbweaver_graph_options_open' + const [optionsOpen, setOptionsOpen] = useState(() => { + try { + const raw = localStorage.getItem(OPTIONS_OPEN_LOCAL_KEY) + if (raw === null) return true + return raw === 'true' + } catch { + return true + } + }) + // Task columns config (to determine completed status per project) const [taskColumns, setTaskColumns] = useState([]) const [completedColumnId, setCompletedColumnId] = useState(null) @@ -1573,70 +1585,84 @@ function GraphView() { - {/* Left controls bar */} -
- - - - + {/* Left controls: collapsible Options tray */} +
+
+ + {optionsOpen && ( +
+ + + + +
+ )} +
{ diff --git a/frontend/src/store/nodeStore.ts b/frontend/src/store/nodeStore.ts index 57b8a2f..c32b907 100644 --- a/frontend/src/store/nodeStore.ts +++ b/frontend/src/store/nodeStore.ts @@ -115,25 +115,37 @@ function resolveLinksToIds(rawLinks: unknown, nodesMap: Map() if (!Array.isArray(rawLinks)) return [] // Precompute indexes - const idToId = new Set() + const idSet = new Set() const pathToId = new Map() const pathToIdLower = new Map() + // basename (lowercased, with .md ensured) -> unique id (empty string if ambiguous) + const basenameToUniqueIdLower = new Map() for (const n of nodesMap.values()) { const nid = String(n?.metadata?.id || '') - 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) + if (!nid) continue + idSet.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) + const base = (normPath.split('/')?.pop() || '') + if (base) { + const baseLower = base.toLowerCase() + const existing = basenameToUniqueIdLower.get(baseLower) + if (existing === undefined) { + basenameToUniqueIdLower.set(baseLower, nid) + } else if (existing && existing !== nid) { + // Mark ambiguous + basenameToUniqueIdLower.set(baseLower, '') + } } } 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 + if (idSet.has(val)) { result.add(val); continue } + // Try as normalized path (repo-relative); accept with or without .md const norm = val.replace(/\\/g,'/') const withMd = norm.endsWith('.md') ? norm : `${norm}.md` const normLower = norm.toLowerCase() @@ -143,14 +155,11 @@ 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 } + // Fallback: unique basename match (no path separators in source value) + if (!norm.includes('/')) { + const baseLower = (withMdLower.split('/')?.pop() || '') + const maybeId = basenameToUniqueIdLower.get(baseLower) + if (maybeId) { result.add(maybeId); continue } } } return Array.from(result)