Skip to content
Merged
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
41 changes: 39 additions & 2 deletions backend/app/services/node_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 23 additions & 4 deletions desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();

if (!existsSync(nodesDir)) {
console.warn(`[graph:loadData] Nodes directory does not exist: ${nodesDir}`);
Expand Down Expand Up @@ -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'
});
Expand Down Expand Up @@ -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<string, string>();
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) {
Expand Down
24 changes: 24 additions & 0 deletions docs/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,30 @@ title: Chapter 1
links: [node-abc, node-def]
task:
tracked: true

## 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; 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.
---
```

Expand Down
1 change: 1 addition & 0 deletions docs/graph-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions docs/unified-model-implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading