clawvault 1.7.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/clawvault.js +29 -0
- package/dashboard/lib/graph-diff.js +102 -0
- package/dashboard/lib/graph-diff.test.js +56 -0
- package/dashboard/lib/vault-parser.js +252 -0
- package/dashboard/lib/vault-parser.test.js +74 -0
- package/dashboard/public/app.js +783 -0
- package/dashboard/public/graph.js +376 -0
- package/dashboard/public/index.html +52 -0
- package/dashboard/public/style.css +154 -0
- package/dashboard/public/styles.css +221 -0
- package/dashboard/server.js +374 -0
- package/dashboard/test-crash.mjs +37 -0
- package/dashboard/test-screenshot.png +0 -0
- package/dist/{chunk-XQUQIW6E.js → chunk-CBYLZH4X.js} +1 -1
- package/dist/{chunk-AZRV2I5U.js → chunk-YBJDNBWV.js} +13 -1
- package/dist/commands/repair-session.js +1 -1
- package/dist/commands/session-recap.js +2 -2
- package/dist/index.js +2 -2
- package/dist/lib/session-utils.d.ts +6 -2
- package/dist/lib/session-utils.js +3 -1
- package/package.json +9 -4
package/bin/clawvault.js
CHANGED
|
@@ -1186,5 +1186,34 @@ program
|
|
|
1186
1186
|
}
|
|
1187
1187
|
});
|
|
1188
1188
|
|
|
1189
|
+
// === DASHBOARD ===
|
|
1190
|
+
program
|
|
1191
|
+
.command('dashboard')
|
|
1192
|
+
.description('Run local vault graph dashboard')
|
|
1193
|
+
.option('-p, --port <port>', 'Dashboard port', '3377')
|
|
1194
|
+
.option('-v, --vault <path>', 'Vault path')
|
|
1195
|
+
.action(async (options) => {
|
|
1196
|
+
try {
|
|
1197
|
+
const parsedPort = Number.parseInt(options.port, 10);
|
|
1198
|
+
if (Number.isNaN(parsedPort)) {
|
|
1199
|
+
console.error(chalk.red(`Error: Invalid port: ${options.port}`));
|
|
1200
|
+
process.exit(1);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
const vaultPath = options.vault
|
|
1204
|
+
? path.resolve(options.vault)
|
|
1205
|
+
: resolveVaultPath(undefined);
|
|
1206
|
+
|
|
1207
|
+
const { startDashboard } = await import('../dashboard/server.js');
|
|
1208
|
+
await startDashboard({
|
|
1209
|
+
port: parsedPort,
|
|
1210
|
+
vaultPath
|
|
1211
|
+
});
|
|
1212
|
+
} catch (err) {
|
|
1213
|
+
console.error(chalk.red(`Error: ${err.message}`));
|
|
1214
|
+
process.exit(1);
|
|
1215
|
+
}
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1189
1218
|
// Parse and run
|
|
1190
1219
|
program.parse();
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
function toNodeSignature(node) {
|
|
2
|
+
return JSON.stringify({
|
|
3
|
+
title: node.title,
|
|
4
|
+
category: node.category,
|
|
5
|
+
tags: Array.isArray(node.tags) ? [...node.tags].sort() : [],
|
|
6
|
+
path: node.path,
|
|
7
|
+
missing: Boolean(node.missing),
|
|
8
|
+
degree: Number(node.degree ?? 0)
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function toEdgeKey(edge) {
|
|
13
|
+
return `${edge.source}=>${edge.target}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compute an efficient patch between graph snapshots.
|
|
18
|
+
* @param {{nodes: Array<object>, edges: Array<object>, stats?: object}} previousGraph
|
|
19
|
+
* @param {{nodes: Array<object>, edges: Array<object>, stats?: object}} nextGraph
|
|
20
|
+
*/
|
|
21
|
+
export function diffGraphs(previousGraph, nextGraph) {
|
|
22
|
+
const previousNodes = previousGraph?.nodes ?? [];
|
|
23
|
+
const nextNodes = nextGraph?.nodes ?? [];
|
|
24
|
+
const previousEdges = previousGraph?.edges ?? [];
|
|
25
|
+
const nextEdges = nextGraph?.edges ?? [];
|
|
26
|
+
|
|
27
|
+
const previousNodeById = new Map(previousNodes.map((node) => [node.id, node]));
|
|
28
|
+
const nextNodeById = new Map(nextNodes.map((node) => [node.id, node]));
|
|
29
|
+
|
|
30
|
+
const addedNodes = [];
|
|
31
|
+
const updatedNodes = [];
|
|
32
|
+
const removedNodeIds = [];
|
|
33
|
+
|
|
34
|
+
for (const [nodeId, nextNode] of nextNodeById.entries()) {
|
|
35
|
+
const previousNode = previousNodeById.get(nodeId);
|
|
36
|
+
if (!previousNode) {
|
|
37
|
+
addedNodes.push(nextNode);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (toNodeSignature(previousNode) !== toNodeSignature(nextNode)) {
|
|
41
|
+
updatedNodes.push(nextNode);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const nodeId of previousNodeById.keys()) {
|
|
46
|
+
if (!nextNodeById.has(nodeId)) {
|
|
47
|
+
removedNodeIds.push(nodeId);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const previousEdgeByKey = new Map(previousEdges.map((edge) => [toEdgeKey(edge), edge]));
|
|
52
|
+
const nextEdgeByKey = new Map(nextEdges.map((edge) => [toEdgeKey(edge), edge]));
|
|
53
|
+
const addedEdges = [];
|
|
54
|
+
const removedEdges = [];
|
|
55
|
+
|
|
56
|
+
for (const [edgeKey, edge] of nextEdgeByKey.entries()) {
|
|
57
|
+
if (!previousEdgeByKey.has(edgeKey)) {
|
|
58
|
+
addedEdges.push(edge);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const [edgeKey, edge] of previousEdgeByKey.entries()) {
|
|
63
|
+
if (!nextEdgeByKey.has(edgeKey)) {
|
|
64
|
+
removedEdges.push(edge);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const touchedNodeIds = new Set();
|
|
69
|
+
for (const node of addedNodes) {
|
|
70
|
+
touchedNodeIds.add(node.id);
|
|
71
|
+
}
|
|
72
|
+
for (const node of updatedNodes) {
|
|
73
|
+
touchedNodeIds.add(node.id);
|
|
74
|
+
}
|
|
75
|
+
for (const nodeId of removedNodeIds) {
|
|
76
|
+
touchedNodeIds.add(nodeId);
|
|
77
|
+
}
|
|
78
|
+
for (const edge of addedEdges) {
|
|
79
|
+
touchedNodeIds.add(edge.source);
|
|
80
|
+
touchedNodeIds.add(edge.target);
|
|
81
|
+
}
|
|
82
|
+
for (const edge of removedEdges) {
|
|
83
|
+
touchedNodeIds.add(edge.source);
|
|
84
|
+
touchedNodeIds.add(edge.target);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
addedNodes,
|
|
89
|
+
updatedNodes,
|
|
90
|
+
removedNodeIds,
|
|
91
|
+
addedEdges,
|
|
92
|
+
removedEdges,
|
|
93
|
+
changedNodeIds: Array.from(touchedNodeIds).sort((a, b) => a.localeCompare(b)),
|
|
94
|
+
stats: nextGraph?.stats ?? null,
|
|
95
|
+
hasChanges:
|
|
96
|
+
addedNodes.length > 0 ||
|
|
97
|
+
updatedNodes.length > 0 ||
|
|
98
|
+
removedNodeIds.length > 0 ||
|
|
99
|
+
addedEdges.length > 0 ||
|
|
100
|
+
removedEdges.length > 0
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { diffGraphs } from './graph-diff.js';
|
|
3
|
+
|
|
4
|
+
describe('diffGraphs', () => {
|
|
5
|
+
it('detects node and edge additions, updates, and removals', () => {
|
|
6
|
+
const previous = {
|
|
7
|
+
nodes: [
|
|
8
|
+
{ id: 'a', title: 'A', category: 'root', tags: [], path: 'a.md', missing: false, degree: 1 },
|
|
9
|
+
{ id: 'b', title: 'B', category: 'root', tags: ['x'], path: 'b.md', missing: false, degree: 1 },
|
|
10
|
+
{ id: 'c', title: 'C', category: 'root', tags: [], path: null, missing: true, degree: 0 }
|
|
11
|
+
],
|
|
12
|
+
edges: [{ source: 'a', target: 'b' }],
|
|
13
|
+
stats: { nodeCount: 3, edgeCount: 1 }
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const next = {
|
|
17
|
+
nodes: [
|
|
18
|
+
{ id: 'a', title: 'A Updated', category: 'root', tags: [], path: 'a.md', missing: false, degree: 1 },
|
|
19
|
+
{ id: 'b', title: 'B', category: 'root', tags: ['x'], path: 'b.md', missing: false, degree: 2 },
|
|
20
|
+
{ id: 'd', title: 'D', category: 'projects', tags: [], path: 'd.md', missing: false, degree: 1 }
|
|
21
|
+
],
|
|
22
|
+
edges: [
|
|
23
|
+
{ source: 'a', target: 'b' },
|
|
24
|
+
{ source: 'b', target: 'd' }
|
|
25
|
+
],
|
|
26
|
+
stats: { nodeCount: 3, edgeCount: 2 }
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const patch = diffGraphs(previous, next);
|
|
30
|
+
|
|
31
|
+
expect(patch.addedNodes).toEqual([next.nodes[2]]);
|
|
32
|
+
expect(patch.updatedNodes).toEqual(expect.arrayContaining([next.nodes[0], next.nodes[1]]));
|
|
33
|
+
expect(patch.removedNodeIds).toEqual(['c']);
|
|
34
|
+
expect(patch.addedEdges).toEqual([{ source: 'b', target: 'd' }]);
|
|
35
|
+
expect(patch.removedEdges).toEqual([]);
|
|
36
|
+
expect(patch.changedNodeIds).toEqual(['a', 'b', 'c', 'd']);
|
|
37
|
+
expect(patch.hasChanges).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns hasChanges=false for equivalent graphs', () => {
|
|
41
|
+
const graph = {
|
|
42
|
+
nodes: [{ id: 'a', title: 'A', category: 'root', tags: ['t'], path: 'a.md', missing: false, degree: 0 }],
|
|
43
|
+
edges: [],
|
|
44
|
+
stats: { nodeCount: 1, edgeCount: 0 }
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const patch = diffGraphs(graph, structuredClone(graph));
|
|
48
|
+
|
|
49
|
+
expect(patch.hasChanges).toBe(false);
|
|
50
|
+
expect(patch.addedNodes).toEqual([]);
|
|
51
|
+
expect(patch.updatedNodes).toEqual([]);
|
|
52
|
+
expect(patch.removedNodeIds).toEqual([]);
|
|
53
|
+
expect(patch.addedEdges).toEqual([]);
|
|
54
|
+
expect(patch.removedEdges).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
|
|
5
|
+
export const WIKI_LINK_REGEX = /\[\[([^\]|]+)(\|[^\]]+)?\]\]/g;
|
|
6
|
+
|
|
7
|
+
const MARKDOWN_EXT = '.md';
|
|
8
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
9
|
+
'.git',
|
|
10
|
+
'.obsidian',
|
|
11
|
+
'.trash',
|
|
12
|
+
'node_modules'
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build a graph from markdown notes in a vault.
|
|
17
|
+
* @param {string} vaultPath
|
|
18
|
+
* @param {{ includeDangling?: boolean }} [options]
|
|
19
|
+
*/
|
|
20
|
+
export async function buildVaultGraph(vaultPath, options = {}) {
|
|
21
|
+
const includeDangling = options.includeDangling !== false;
|
|
22
|
+
const root = path.resolve(vaultPath);
|
|
23
|
+
const markdownFiles = await collectMarkdownFiles(root);
|
|
24
|
+
|
|
25
|
+
const nodesById = new Map();
|
|
26
|
+
const edges = [];
|
|
27
|
+
const edgeSet = new Set();
|
|
28
|
+
|
|
29
|
+
for (const absoluteFilePath of markdownFiles) {
|
|
30
|
+
const raw = await fs.readFile(absoluteFilePath, 'utf8');
|
|
31
|
+
const parsed = matter(raw);
|
|
32
|
+
const relativePath = path.relative(root, absoluteFilePath);
|
|
33
|
+
const id = toNodeId(relativePath);
|
|
34
|
+
const frontmatter = parsed.data ?? {};
|
|
35
|
+
|
|
36
|
+
nodesById.set(id, {
|
|
37
|
+
id,
|
|
38
|
+
title: normalizeString(frontmatter.title) || toDisplayTitle(id),
|
|
39
|
+
category: normalizeString(frontmatter.category) || inferCategory(id),
|
|
40
|
+
tags: normalizeTags(frontmatter.tags),
|
|
41
|
+
path: toPosixPath(relativePath),
|
|
42
|
+
missing: false,
|
|
43
|
+
_outboundTargets: extractWikiLinks(parsed.content)
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const idsByLowercase = new Map();
|
|
48
|
+
const idsByBaseName = new Map();
|
|
49
|
+
for (const id of nodesById.keys()) {
|
|
50
|
+
idsByLowercase.set(id.toLowerCase(), id);
|
|
51
|
+
const baseName = path.posix.basename(id).toLowerCase();
|
|
52
|
+
const existing = idsByBaseName.get(baseName) ?? new Set();
|
|
53
|
+
existing.add(id);
|
|
54
|
+
idsByBaseName.set(baseName, existing);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const node of nodesById.values()) {
|
|
58
|
+
for (const rawTarget of node._outboundTargets) {
|
|
59
|
+
const targetId = resolveTargetId(rawTarget, {
|
|
60
|
+
idsByLowercase,
|
|
61
|
+
idsByBaseName,
|
|
62
|
+
includeDangling
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (!targetId) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!nodesById.has(targetId) && includeDangling) {
|
|
70
|
+
nodesById.set(targetId, {
|
|
71
|
+
id: targetId,
|
|
72
|
+
title: toDisplayTitle(targetId),
|
|
73
|
+
category: 'unresolved',
|
|
74
|
+
tags: [],
|
|
75
|
+
path: null,
|
|
76
|
+
missing: true,
|
|
77
|
+
_outboundTargets: []
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const edgeKey = `${node.id}=>${targetId}`;
|
|
82
|
+
if (edgeSet.has(edgeKey)) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
edgeSet.add(edgeKey);
|
|
86
|
+
edges.push({ source: node.id, target: targetId });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const degreeByNodeId = new Map();
|
|
91
|
+
for (const edge of edges) {
|
|
92
|
+
degreeByNodeId.set(edge.source, (degreeByNodeId.get(edge.source) ?? 0) + 1);
|
|
93
|
+
degreeByNodeId.set(edge.target, (degreeByNodeId.get(edge.target) ?? 0) + 1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const nodes = Array.from(nodesById.values())
|
|
97
|
+
.map(({ _outboundTargets, ...node }) => ({
|
|
98
|
+
...node,
|
|
99
|
+
degree: degreeByNodeId.get(node.id) ?? 0
|
|
100
|
+
}))
|
|
101
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
102
|
+
|
|
103
|
+
edges.sort((a, b) => {
|
|
104
|
+
const sourceSort = a.source.localeCompare(b.source);
|
|
105
|
+
return sourceSort !== 0 ? sourceSort : a.target.localeCompare(b.target);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
nodes,
|
|
110
|
+
edges,
|
|
111
|
+
stats: {
|
|
112
|
+
generatedAt: new Date().toISOString(),
|
|
113
|
+
nodeCount: nodes.length,
|
|
114
|
+
edgeCount: edges.length,
|
|
115
|
+
fileCount: markdownFiles.length
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function collectMarkdownFiles(root) {
|
|
121
|
+
const pending = [root];
|
|
122
|
+
const files = [];
|
|
123
|
+
|
|
124
|
+
while (pending.length > 0) {
|
|
125
|
+
const currentDir = pending.pop();
|
|
126
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
127
|
+
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
const absolutePath = path.join(currentDir, entry.name);
|
|
130
|
+
if (entry.isDirectory()) {
|
|
131
|
+
if (!IGNORED_DIRECTORIES.has(entry.name)) {
|
|
132
|
+
pending.push(absolutePath);
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (entry.isFile() && entry.name.toLowerCase().endsWith(MARKDOWN_EXT)) {
|
|
138
|
+
files.push(absolutePath);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return files;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function extractWikiLinks(markdown) {
|
|
147
|
+
const links = [];
|
|
148
|
+
const regex = new RegExp(WIKI_LINK_REGEX.source, 'g');
|
|
149
|
+
let match = regex.exec(markdown);
|
|
150
|
+
|
|
151
|
+
while (match) {
|
|
152
|
+
const rawTarget = match[1]?.trim();
|
|
153
|
+
if (rawTarget) {
|
|
154
|
+
links.push(rawTarget);
|
|
155
|
+
}
|
|
156
|
+
match = regex.exec(markdown);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return links;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function resolveTargetId(target, context) {
|
|
163
|
+
const normalized = normalizeWikiTarget(target);
|
|
164
|
+
if (!normalized) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const lower = normalized.toLowerCase();
|
|
169
|
+
const exact = context.idsByLowercase.get(lower);
|
|
170
|
+
if (exact) {
|
|
171
|
+
return exact;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!normalized.includes('/')) {
|
|
175
|
+
const maybeMatches = context.idsByBaseName.get(lower);
|
|
176
|
+
if (maybeMatches?.size === 1) {
|
|
177
|
+
return Array.from(maybeMatches)[0];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return context.includeDangling ? normalized : null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function normalizeWikiTarget(target) {
|
|
185
|
+
let value = normalizeString(target);
|
|
186
|
+
if (!value) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const hashIndex = value.indexOf('#');
|
|
191
|
+
if (hashIndex >= 0) {
|
|
192
|
+
value = value.slice(0, hashIndex);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const caretIndex = value.indexOf('^');
|
|
196
|
+
if (caretIndex >= 0) {
|
|
197
|
+
value = value.slice(0, caretIndex);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
value = value.replace(/\\/g, '/');
|
|
201
|
+
value = value.replace(/^\.\//, '');
|
|
202
|
+
value = value.replace(/^\/+/, '');
|
|
203
|
+
value = value.replace(/\/+/g, '/');
|
|
204
|
+
|
|
205
|
+
if (value.toLowerCase().endsWith(MARKDOWN_EXT)) {
|
|
206
|
+
value = value.slice(0, -MARKDOWN_EXT.length);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return normalizeString(value);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function toNodeId(relativePath) {
|
|
213
|
+
const normalized = toPosixPath(relativePath);
|
|
214
|
+
return normalized.toLowerCase().endsWith(MARKDOWN_EXT)
|
|
215
|
+
? normalized.slice(0, -MARKDOWN_EXT.length)
|
|
216
|
+
: normalized;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function inferCategory(id) {
|
|
220
|
+
const category = id.split('/')[0];
|
|
221
|
+
return normalizeString(category) || 'root';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function normalizeTags(tags) {
|
|
225
|
+
if (Array.isArray(tags)) {
|
|
226
|
+
return tags.map(normalizeString).filter(Boolean);
|
|
227
|
+
}
|
|
228
|
+
if (typeof tags === 'string') {
|
|
229
|
+
return tags
|
|
230
|
+
.split(',')
|
|
231
|
+
.map((tag) => normalizeString(tag))
|
|
232
|
+
.filter(Boolean);
|
|
233
|
+
}
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function normalizeString(value) {
|
|
238
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function toDisplayTitle(id) {
|
|
242
|
+
const base = path.posix.basename(id);
|
|
243
|
+
return base
|
|
244
|
+
.replace(/[-_]+/g, ' ')
|
|
245
|
+
.replace(/\s+/g, ' ')
|
|
246
|
+
.trim()
|
|
247
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function toPosixPath(filePath) {
|
|
251
|
+
return filePath.split(path.sep).join('/');
|
|
252
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { buildVaultGraph } from './vault-parser.js';
|
|
6
|
+
|
|
7
|
+
function makeTempVault() {
|
|
8
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'clawvault-dashboard-'));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function writeVaultFile(root, relativePath, content) {
|
|
12
|
+
const fullPath = path.join(root, relativePath);
|
|
13
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
14
|
+
fs.writeFileSync(fullPath, content, 'utf8');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('buildVaultGraph', () => {
|
|
18
|
+
it('builds nodes and edges from markdown wiki-links', async () => {
|
|
19
|
+
const vaultPath = makeTempVault();
|
|
20
|
+
try {
|
|
21
|
+
writeVaultFile(
|
|
22
|
+
vaultPath,
|
|
23
|
+
'decisions/use-clawvault.md',
|
|
24
|
+
`---
|
|
25
|
+
title: Use ClawVault
|
|
26
|
+
tags: [architecture, memory]
|
|
27
|
+
---
|
|
28
|
+
Linked to [[projects/clawvault|ClawVault Project]] and [[missing-note]].
|
|
29
|
+
`
|
|
30
|
+
);
|
|
31
|
+
writeVaultFile(vaultPath, 'projects/clawvault.md', '# ClawVault');
|
|
32
|
+
|
|
33
|
+
const graph = await buildVaultGraph(vaultPath);
|
|
34
|
+
const decisionNode = graph.nodes.find((node) => node.id === 'decisions/use-clawvault');
|
|
35
|
+
const unresolvedNode = graph.nodes.find((node) => node.id === 'missing-note');
|
|
36
|
+
|
|
37
|
+
expect(decisionNode).toMatchObject({
|
|
38
|
+
title: 'Use ClawVault',
|
|
39
|
+
category: 'decisions',
|
|
40
|
+
tags: ['architecture', 'memory']
|
|
41
|
+
});
|
|
42
|
+
expect(graph.edges).toEqual(
|
|
43
|
+
expect.arrayContaining([
|
|
44
|
+
{ source: 'decisions/use-clawvault', target: 'projects/clawvault' },
|
|
45
|
+
{ source: 'decisions/use-clawvault', target: 'missing-note' }
|
|
46
|
+
])
|
|
47
|
+
);
|
|
48
|
+
expect(unresolvedNode).toMatchObject({
|
|
49
|
+
missing: true,
|
|
50
|
+
category: 'unresolved'
|
|
51
|
+
});
|
|
52
|
+
} finally {
|
|
53
|
+
fs.rmSync(vaultPath, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('resolves basename links when there is a unique match', async () => {
|
|
58
|
+
const vaultPath = makeTempVault();
|
|
59
|
+
try {
|
|
60
|
+
writeVaultFile(vaultPath, 'research/notes.md', 'See [[clawvault]].');
|
|
61
|
+
writeVaultFile(vaultPath, 'projects/clawvault.md', '# ClawVault');
|
|
62
|
+
|
|
63
|
+
const graph = await buildVaultGraph(vaultPath);
|
|
64
|
+
|
|
65
|
+
expect(graph.edges).toEqual(
|
|
66
|
+
expect.arrayContaining([
|
|
67
|
+
{ source: 'research/notes', target: 'projects/clawvault' }
|
|
68
|
+
])
|
|
69
|
+
);
|
|
70
|
+
} finally {
|
|
71
|
+
fs.rmSync(vaultPath, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|