claude-memory-admin 1.0.1 → 1.2.0
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/README.md +94 -15
- package/package.json +8 -2
- package/public/app.mjs +849 -408
- package/public/graph.mjs +32 -76
- package/public/index.html +34 -29
- package/public/markdown.mjs +74 -0
- package/public/styles.css +2 -439
- package/public/ui.mjs +309 -0
- package/server.mjs +82 -5
- package/src/instructions.mjs +123 -21
- package/src/model.mjs +4 -1
- package/src/mutate.mjs +355 -1
- package/src/parse.mjs +176 -0
- package/src/settings.mjs +120 -19
- package/src/stores.mjs +34 -4
package/public/graph.mjs
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
// Hand-rolled rather than pulled from a library: the largest project has ~60
|
|
4
|
-
// nodes, which a plain spring/repulsion relaxation handles fine, and it keeps
|
|
5
|
-
// the app at two dependencies with no bundler.
|
|
1
|
+
import * as ui from '/ui.mjs';
|
|
2
|
+
|
|
6
3
|
|
|
7
4
|
const TYPE_COLORS = {
|
|
8
|
-
project: '
|
|
9
|
-
feedback: '
|
|
10
|
-
user: '
|
|
11
|
-
reference: '
|
|
12
|
-
unknown: '
|
|
5
|
+
project: 'var(--ui-type-project)',
|
|
6
|
+
feedback: 'var(--ui-type-feedback)',
|
|
7
|
+
user: 'var(--ui-type-user)',
|
|
8
|
+
reference: 'var(--ui-type-reference)',
|
|
9
|
+
unknown: 'var(--ui-fg-subtle)',
|
|
13
10
|
};
|
|
14
11
|
|
|
15
12
|
function colorFor(type) {
|
|
@@ -21,7 +18,6 @@ function simulate(nodes, edges, { width, height, iterations = 320, repulsion = 1
|
|
|
21
18
|
const k = Math.sqrt(area / Math.max(nodes.length, 1)) * 0.72;
|
|
22
19
|
const index = new Map(nodes.map((n, i) => [n.id, i]));
|
|
23
20
|
|
|
24
|
-
// Deterministic ring start: same project always lays out the same way.
|
|
25
21
|
nodes.forEach((node, i) => {
|
|
26
22
|
const angle = (i / nodes.length) * Math.PI * 2;
|
|
27
23
|
const radius = Math.min(width, height) * 0.32 * (0.55 + ((i * 37) % 100) / 220);
|
|
@@ -66,8 +62,6 @@ function simulate(nodes, edges, { width, height, iterations = 320, repulsion = 1
|
|
|
66
62
|
b.dx += fx; b.dy += fy;
|
|
67
63
|
}
|
|
68
64
|
|
|
69
|
-
// Pull toward the centre, proportional to distance, so disconnected
|
|
70
|
-
// components stay in frame instead of drifting outward forever.
|
|
71
65
|
for (const node of nodes) {
|
|
72
66
|
node.dx += (width / 2 - node.x) * 0.006;
|
|
73
67
|
node.dy += (height / 2 - node.y) * 0.006;
|
|
@@ -81,14 +75,10 @@ function simulate(nodes, edges, { width, height, iterations = 320, repulsion = 1
|
|
|
81
75
|
temperature *= 0.99;
|
|
82
76
|
}
|
|
83
77
|
|
|
84
|
-
// Rescale the settled layout to fill the viewport. Clamping to the walls
|
|
85
|
-
// during the simulation would instead flatten everything onto the border,
|
|
86
|
-
// which is what makes hand-rolled force layouts look broken.
|
|
87
78
|
fitToBox(nodes, width, height, 46);
|
|
88
79
|
return nodes;
|
|
89
80
|
}
|
|
90
81
|
|
|
91
|
-
/** Map the settled positions onto the drawing area, preserving aspect ratio. */
|
|
92
82
|
function fitToBox(nodes, width, height, pad) {
|
|
93
83
|
const xs = nodes.map((n) => n.x);
|
|
94
84
|
const ys = nodes.map((n) => n.y);
|
|
@@ -98,9 +88,6 @@ function fitToBox(nodes, width, height, pad) {
|
|
|
98
88
|
const maxY = Math.max(...ys);
|
|
99
89
|
const spanX = maxX - minX || 1;
|
|
100
90
|
const spanY = maxY - minY || 1;
|
|
101
|
-
// Scale each axis to the box, but cap how far they may differ: a uniform
|
|
102
|
-
// scale wastes half the canvas on a wide panel, and an uncapped one stretches
|
|
103
|
-
// the graph into something misleading.
|
|
104
91
|
let scaleX = (width - pad * 2) / spanX;
|
|
105
92
|
let scaleY = (height - pad * 2) / spanY;
|
|
106
93
|
const MAX_ANISOTROPY = 1.45;
|
|
@@ -115,7 +102,6 @@ function fitToBox(nodes, width, height, pad) {
|
|
|
115
102
|
}
|
|
116
103
|
}
|
|
117
104
|
|
|
118
|
-
/** Connected components, so each cluster can be laid out on its own. */
|
|
119
105
|
function findComponents(nodes, edges) {
|
|
120
106
|
const adjacency = new Map(nodes.map((n) => [n.id, []]));
|
|
121
107
|
for (const edge of edges) {
|
|
@@ -151,16 +137,6 @@ function boundsOf(nodes) {
|
|
|
151
137
|
return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
|
|
152
138
|
}
|
|
153
139
|
|
|
154
|
-
/**
|
|
155
|
-
* Lay out each connected component on its own, then pack the components into
|
|
156
|
-
* the canvas.
|
|
157
|
-
*
|
|
158
|
-
* Simulating everything in one field looks wrong as soon as a project has more
|
|
159
|
-
* than one cluster: unconnected components drift apart, and scaling the result
|
|
160
|
-
* to fit then squashes the largest cluster into an unreadable knot. Packing
|
|
161
|
-
* gives every cluster the same generous internal spacing regardless of how many
|
|
162
|
-
* there are.
|
|
163
|
-
*/
|
|
164
140
|
function layoutComponents(nodes, edges, { width, height, repulsion }) {
|
|
165
141
|
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
166
142
|
const groups = findComponents(nodes, edges)
|
|
@@ -171,7 +147,6 @@ function layoutComponents(nodes, edges, { width, height, repulsion }) {
|
|
|
171
147
|
for (const group of groups) {
|
|
172
148
|
const ids = new Set(group.map((n) => n.id));
|
|
173
149
|
const inner = edges.filter((e) => ids.has(e.from) && ids.has(e.to));
|
|
174
|
-
// A box roughly proportional to the node count keeps density even.
|
|
175
150
|
const side = Math.max(180, Math.sqrt(group.length) * 190);
|
|
176
151
|
simulate(group, inner, { width: side, height: side, iterations: 420, repulsion });
|
|
177
152
|
const bounds = boundsOf(group);
|
|
@@ -183,7 +158,6 @@ function layoutComponents(nodes, edges, { width, height, repulsion }) {
|
|
|
183
158
|
});
|
|
184
159
|
}
|
|
185
160
|
|
|
186
|
-
// Shelf packing: fill a row until it would overflow, then start a new one.
|
|
187
161
|
const gap = 26;
|
|
188
162
|
let cursorX = 0;
|
|
189
163
|
let cursorY = 0;
|
|
@@ -206,8 +180,6 @@ function layoutComponents(nodes, edges, { width, height, repulsion }) {
|
|
|
206
180
|
}
|
|
207
181
|
}
|
|
208
182
|
|
|
209
|
-
// Reuse the same fit the single-component path uses, so one big cluster still
|
|
210
|
-
// fills the canvas instead of being shrunk to fit a square working box.
|
|
211
183
|
fitToBox(nodes, width, height, 40);
|
|
212
184
|
}
|
|
213
185
|
|
|
@@ -218,10 +190,6 @@ function svgEl(tag, attrs = {}) {
|
|
|
218
190
|
return el;
|
|
219
191
|
}
|
|
220
192
|
|
|
221
|
-
/**
|
|
222
|
-
* Render the graph into `container`. Dangling wikilink targets are drawn as
|
|
223
|
-
* hollow nodes so a broken link is visible rather than simply absent.
|
|
224
|
-
*/
|
|
225
193
|
export function renderGraph(container, graph, { onSelect, onSpreadChange, selected, spread = 1.6 } = {}) {
|
|
226
194
|
container.textContent = '';
|
|
227
195
|
|
|
@@ -234,8 +202,7 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
|
|
|
234
202
|
|
|
235
203
|
if (nodes.length === 0) {
|
|
236
204
|
const note = document.createElement('p');
|
|
237
|
-
note.className =
|
|
238
|
-
note.style.padding = '40px';
|
|
205
|
+
note.className = ui.graphEmpty;
|
|
239
206
|
note.textContent = 'No memories to graph yet.';
|
|
240
207
|
container.append(note);
|
|
241
208
|
return;
|
|
@@ -250,9 +217,6 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
|
|
|
250
217
|
const width = Math.max(container.clientWidth || 900, 480);
|
|
251
218
|
const baseHeight = 620;
|
|
252
219
|
|
|
253
|
-
// Nodes with no links at all are laid out in their own strip rather than
|
|
254
|
-
// left in the simulation: a single unlinked node drifts to the far edge and
|
|
255
|
-
// squashes everything else when the layout is scaled to fit.
|
|
256
220
|
const degree = new Map(nodes.map((n) => [n.id, 0]));
|
|
257
221
|
for (const edge of edges) {
|
|
258
222
|
degree.set(edge.from, (degree.get(edge.from) || 0) + 1);
|
|
@@ -261,9 +225,6 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
|
|
|
261
225
|
const linked = nodes.filter((n) => degree.get(n.id) > 0);
|
|
262
226
|
const unlinked = nodes.filter((n) => !degree.get(n.id));
|
|
263
227
|
|
|
264
|
-
// The strip grows with the number of rows it has to hold. A fixed height
|
|
265
|
-
// overflowed the canvas as soon as a project had more than one row of
|
|
266
|
-
// unlinked notes, dropping them behind the legend.
|
|
267
228
|
const perRow = Math.max(1, Math.floor((width - 120) / 160));
|
|
268
229
|
const stripRows = unlinked.length ? Math.ceil(unlinked.length / perRow) : 0;
|
|
269
230
|
const strip = stripRows ? 34 + stripRows * 46 : 0;
|
|
@@ -302,15 +263,13 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
|
|
|
302
263
|
|
|
303
264
|
const position = new Map(nodes.map((n) => [n.id, n]));
|
|
304
265
|
const edgeGroup = svgEl('g', { stroke: 'currentColor', 'stroke-opacity': '.28', 'marker-end': 'url(#arrow)' });
|
|
305
|
-
// Kept as pairs rather than relying on child order: edges whose endpoints are
|
|
306
|
-
// missing are skipped, so the indices would not line up.
|
|
307
266
|
const drawnEdges = [];
|
|
308
267
|
for (const edge of edges) {
|
|
309
268
|
const a = position.get(edge.from);
|
|
310
269
|
const b = position.get(edge.to);
|
|
311
270
|
if (!a || !b) continue;
|
|
312
271
|
const line = svgEl('line', {
|
|
313
|
-
class:
|
|
272
|
+
class: ui.graphEdge,
|
|
314
273
|
x1: a.x, y1: a.y, x2: b.x, y2: b.y,
|
|
315
274
|
'stroke-dasharray': String(edge.to).startsWith('ghost:') ? '4 3' : '',
|
|
316
275
|
});
|
|
@@ -338,28 +297,22 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
|
|
|
338
297
|
}
|
|
339
298
|
|
|
340
299
|
for (const node of nodes) {
|
|
341
|
-
const group = svgEl('g', { class:
|
|
300
|
+
const group = svgEl('g', { class: ui.graphNode(node.id === selected), transform: `translate(${node.x},${node.y})` });
|
|
342
301
|
const radius = node.ghost ? 6 : 7 + Math.min(node.degree, 8) * 1.1;
|
|
343
302
|
|
|
344
|
-
|
|
345
|
-
// circles are only a few pixels across and are painfully hard to hit.
|
|
346
|
-
// Handlers go on the group, not on this disc - the visible circle paints
|
|
347
|
-
// over its centre, so a disc-level listener would only catch the ring
|
|
348
|
-
// around the dot and clicks on the node itself would do nothing.
|
|
349
|
-
const hit = svgEl('circle', { class: 'hit', r: Math.max(radius + 9, 15) });
|
|
303
|
+
const hit = svgEl('circle', { class: ui.graphHit, r: Math.max(radius + 9, 15) });
|
|
350
304
|
group.append(hit);
|
|
351
305
|
|
|
352
306
|
const circle = svgEl('circle', {
|
|
353
307
|
r: radius,
|
|
354
308
|
fill: node.ghost ? 'transparent' : colorFor(node.type),
|
|
355
|
-
stroke: node.ghost ? '
|
|
309
|
+
stroke: node.ghost ? 'var(--ui-danger)' : node.status === 'orphan' ? 'var(--ui-warn)' : 'var(--ui-line-strong)',
|
|
356
310
|
'stroke-dasharray': node.ghost ? '3 2' : node.status === 'orphan' ? '3 2' : '',
|
|
357
311
|
});
|
|
358
312
|
const label = svgEl('text', {
|
|
359
313
|
y: radius + 10,
|
|
360
314
|
'text-anchor': 'middle',
|
|
361
|
-
|
|
362
|
-
stroke: 'var(--panel)',
|
|
315
|
+
stroke: 'var(--ui-surface)',
|
|
363
316
|
'stroke-width': '2.5',
|
|
364
317
|
'paint-order': 'stroke',
|
|
365
318
|
'stroke-linejoin': 'round',
|
|
@@ -378,8 +331,6 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
|
|
|
378
331
|
group.addEventListener('click', (event) => { event.stopPropagation(); onSelect(node.id); });
|
|
379
332
|
}
|
|
380
333
|
|
|
381
|
-
// Hovering focuses the node and its neighbours: the only practical way to
|
|
382
|
-
// read a dense cluster without zooming into it first.
|
|
383
334
|
group.addEventListener('pointerenter', () => focusOn(node.id));
|
|
384
335
|
group.addEventListener('pointerleave', () => focusOn(null));
|
|
385
336
|
|
|
@@ -400,7 +351,6 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
|
|
|
400
351
|
}
|
|
401
352
|
}
|
|
402
353
|
|
|
403
|
-
// Pan and zoom.
|
|
404
354
|
let scale = 1;
|
|
405
355
|
let panX = 0;
|
|
406
356
|
let panY = 0;
|
|
@@ -419,9 +369,6 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
|
|
|
419
369
|
apply();
|
|
420
370
|
}, { passive: false });
|
|
421
371
|
|
|
422
|
-
// Panning only engages once the pointer has actually moved. Capturing on
|
|
423
|
-
// pointerdown instead would retarget the pointer to the <svg>, and the click
|
|
424
|
-
// on a node would never reach it - which is why nodes were unclickable.
|
|
425
372
|
const DRAG_THRESHOLD = 4;
|
|
426
373
|
let pending = null;
|
|
427
374
|
let panning = false;
|
|
@@ -458,7 +405,6 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
|
|
|
458
405
|
svg.addEventListener('pointerup', endDrag);
|
|
459
406
|
svg.addEventListener('pointercancel', endDrag);
|
|
460
407
|
|
|
461
|
-
// Zoom helpers used by the toolbar.
|
|
462
408
|
const zoomBy = (factor) => {
|
|
463
409
|
const next = Math.max(0.3, Math.min(6, scale * factor));
|
|
464
410
|
const cx = width / 2;
|
|
@@ -473,10 +419,11 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
|
|
|
473
419
|
container.append(svg);
|
|
474
420
|
|
|
475
421
|
const toolbar = document.createElement('div');
|
|
476
|
-
toolbar.className =
|
|
422
|
+
toolbar.className = ui.graphToolbar;
|
|
477
423
|
|
|
478
424
|
const makeButton = (label, title, onClick) => {
|
|
479
425
|
const button = document.createElement('button');
|
|
426
|
+
button.className = ui.graphButton;
|
|
480
427
|
button.textContent = label;
|
|
481
428
|
button.title = title;
|
|
482
429
|
button.onclick = onClick;
|
|
@@ -489,14 +436,15 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
|
|
|
489
436
|
);
|
|
490
437
|
|
|
491
438
|
const spreadLabel = document.createElement('label');
|
|
439
|
+
spreadLabel.className = ui.graphLabel;
|
|
492
440
|
spreadLabel.title = 'How far apart the layout pushes nodes';
|
|
493
441
|
const slider = document.createElement('input');
|
|
442
|
+
slider.className = ui.graphRange;
|
|
494
443
|
slider.type = 'range';
|
|
495
444
|
slider.min = '1';
|
|
496
445
|
slider.max = '4';
|
|
497
446
|
slider.step = '0.2';
|
|
498
447
|
slider.value = String(spread);
|
|
499
|
-
// Re-laying out is cheap here (<70 nodes) so it can happen on release.
|
|
500
448
|
slider.addEventListener('change', () => {
|
|
501
449
|
if (onSpreadChange) onSpreadChange(Number(slider.value));
|
|
502
450
|
});
|
|
@@ -505,13 +453,21 @@ export function renderGraph(container, graph, { onSelect, onSpreadChange, select
|
|
|
505
453
|
container.append(toolbar);
|
|
506
454
|
|
|
507
455
|
const legend = document.createElement('div');
|
|
508
|
-
legend.className =
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if (ghosts.length)
|
|
514
|
-
|
|
456
|
+
legend.className = ui.graphLegend;
|
|
457
|
+
const entries = Object.entries(TYPE_COLORS)
|
|
458
|
+
.filter(([type]) => real.some((n) => n.type === type))
|
|
459
|
+
.map(([type, color]) => [type, `background:${color}`]);
|
|
460
|
+
entries.push(['orphan', 'border:1.5px dashed var(--ui-warn)']);
|
|
461
|
+
if (ghosts.length) entries.push(['missing target', 'border:1.5px dashed var(--ui-danger)']);
|
|
462
|
+
for (const [label, swatchStyle] of entries) {
|
|
463
|
+
const item = document.createElement('span');
|
|
464
|
+
item.className = ui.graphLegendItem;
|
|
465
|
+
const swatch = document.createElement('i');
|
|
466
|
+
swatch.className = ui.graphLegendSwatch;
|
|
467
|
+
swatch.style.cssText = swatchStyle;
|
|
468
|
+
item.append(swatch, document.createTextNode(label));
|
|
469
|
+
legend.append(item);
|
|
470
|
+
}
|
|
515
471
|
container.append(legend);
|
|
516
472
|
|
|
517
473
|
return byName;
|
package/public/index.html
CHANGED
|
@@ -4,62 +4,67 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>Claude Memory Admin</title>
|
|
7
|
+
<script>
|
|
8
|
+
document.documentElement.dataset.theme =
|
|
9
|
+
localStorage.getItem('theme')
|
|
10
|
+
|| (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
11
|
+
</script>
|
|
7
12
|
<link rel="stylesheet" href="/styles.css">
|
|
8
13
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>🧠</text></svg>">
|
|
9
14
|
</head>
|
|
10
15
|
<body>
|
|
11
16
|
<div id="app">
|
|
12
|
-
<aside id="sidebar">
|
|
13
|
-
<header
|
|
14
|
-
<button id="collapse"
|
|
15
|
-
<h1>
|
|
16
|
-
<p id="root-path"
|
|
17
|
-
<input id="search" type="search" placeholder="Search all memories ( / )" autocomplete="off" spellcheck="false">
|
|
18
|
-
<label
|
|
19
|
-
<input type="checkbox" id="show-all"> show projects without memory
|
|
17
|
+
<aside id="sidebar" data-ui="sidebar">
|
|
18
|
+
<header data-ui="sidebarHead">
|
|
19
|
+
<button id="collapse" data-ui="iconButton" title="Hide the project list" aria-label="Hide the project list">◄</button>
|
|
20
|
+
<h1 data-ui="sidebarTitle">memory<span data-ui="promptCaret">/</span>admin</h1>
|
|
21
|
+
<p id="root-path" data-ui="sidebarRoot"></p>
|
|
22
|
+
<input id="search" type="search" data-ui="searchInput" placeholder="Search all memories ( / )" autocomplete="off" spellcheck="false">
|
|
23
|
+
<label data-ui="toggleLabel">
|
|
24
|
+
<input type="checkbox" id="show-all" data-ui="checkbox"> show projects without memory
|
|
20
25
|
</label>
|
|
21
26
|
</header>
|
|
22
|
-
<nav id="project-list" aria-label="Projects"></nav>
|
|
23
|
-
<footer
|
|
24
|
-
<a href="https://bitspire.ch/" target="_blank" rel="noopener noreferrer">
|
|
27
|
+
<nav id="project-list" data-ui="sidebarList" aria-label="Projects"></nav>
|
|
28
|
+
<footer data-ui="sidebarFoot">
|
|
29
|
+
<a href="https://bitspire.ch/" target="_blank" rel="noopener noreferrer" data-ui="sidebarCredit">
|
|
25
30
|
<span>Brought to you by</span>
|
|
26
|
-
<img src="/assets/bitspire-logo.webp" alt="BitSpire" width="96">
|
|
31
|
+
<img src="/assets/bitspire-logo.webp" alt="BitSpire" width="96" data-ui="sidebarLogo">
|
|
27
32
|
</a>
|
|
28
33
|
</footer>
|
|
29
34
|
</aside>
|
|
30
35
|
|
|
31
|
-
<button id="expand"
|
|
36
|
+
<button id="expand" data-ui="expandHandle" title="Show the project list" aria-label="Show the project list" hidden>►</button>
|
|
32
37
|
|
|
33
|
-
<main id="main">
|
|
34
|
-
<div id="empty-state"
|
|
35
|
-
<h2>Pick a project</h2>
|
|
36
|
-
<p>Each project keeps its memories in <code>~/.claude/projects/<slug>/memory/</code>, indexed by <code>MEMORY.md</code>.</p>
|
|
38
|
+
<main id="main" data-ui="main">
|
|
39
|
+
<div id="empty-state" data-ui="emptyState">
|
|
40
|
+
<h2 data-ui="emptyTitle">Pick a project</h2>
|
|
41
|
+
<p>Each project keeps its memories in <code data-ui="inlineCode">~/.claude/projects/<slug>/memory/</code>, indexed by <code data-ui="inlineCode">MEMORY.md</code>.</p>
|
|
37
42
|
</div>
|
|
38
43
|
|
|
39
44
|
<div id="search-view" hidden>
|
|
40
|
-
<header id="search-head">
|
|
41
|
-
<h2 id="search-title"></h2>
|
|
42
|
-
<button id="search-clear"
|
|
45
|
+
<header id="search-head" data-ui="searchHead">
|
|
46
|
+
<h2 id="search-title" data-ui="searchTitle"></h2>
|
|
47
|
+
<button id="search-clear" data-ui="buttonSmall">Clear</button>
|
|
43
48
|
</header>
|
|
44
|
-
<section id="search-results"></section>
|
|
49
|
+
<section id="search-results" data-ui="searchResults"></section>
|
|
45
50
|
</div>
|
|
46
51
|
|
|
47
52
|
<div id="project-view" hidden>
|
|
48
|
-
<header id="project-head">
|
|
49
|
-
<div
|
|
50
|
-
<h2 id="project-title"></h2>
|
|
51
|
-
<button id="delete-project"
|
|
53
|
+
<header id="project-head" data-ui="projectHead">
|
|
54
|
+
<div data-ui="projectHeadRow">
|
|
55
|
+
<h2 id="project-title" data-ui="projectTitle"></h2>
|
|
56
|
+
<button id="delete-project" data-ui="buttonDangerSmall">Delete all memory</button>
|
|
52
57
|
</div>
|
|
53
|
-
<p id="project-sub"
|
|
54
|
-
<div id="tabs" role="tablist"></div>
|
|
58
|
+
<p id="project-sub" data-ui="projectSub"></p>
|
|
59
|
+
<div id="tabs" data-ui="tabBar" role="tablist"></div>
|
|
55
60
|
</header>
|
|
56
|
-
<section id="tab-content"></section>
|
|
61
|
+
<section id="tab-content" data-ui="tabContent"></section>
|
|
57
62
|
</div>
|
|
58
63
|
</main>
|
|
59
64
|
</div>
|
|
60
65
|
|
|
61
66
|
<div id="modal-root"></div>
|
|
62
|
-
<div id="toast-root" aria-live="polite"></div>
|
|
67
|
+
<div id="toast-root" data-ui="toastRoot" aria-live="polite"></div>
|
|
63
68
|
|
|
64
69
|
<script type="module" src="/app.mjs"></script>
|
|
65
70
|
</body>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { marked } from '/vendor/marked.js';
|
|
2
|
+
import DOMPurify from '/vendor/purify.js';
|
|
3
|
+
import * as ui from '/ui.mjs';
|
|
4
|
+
|
|
5
|
+
marked.setOptions({ gfm: true, breaks: false });
|
|
6
|
+
|
|
7
|
+
const WIKILINK = /\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g;
|
|
8
|
+
const PLACEHOLDER = /%%WIKI(\d+)%%/;
|
|
9
|
+
const PLACEHOLDER_EXACT = /^%%WIKI(\d+)%%$/;
|
|
10
|
+
const PLACEHOLDER_SPLIT = /(%%WIKI\d+%%)/;
|
|
11
|
+
const LOCAL_MARKDOWN = /^[^/#?][^:]*\.md$/i;
|
|
12
|
+
|
|
13
|
+
function extractWikilinks(text) {
|
|
14
|
+
const tokens = [];
|
|
15
|
+
const masked = text.replace(WIKILINK, (_, target, alias) => {
|
|
16
|
+
const key = `%%WIKI${tokens.length}%%`;
|
|
17
|
+
tokens.push({ target: target.trim(), alias: (alias || '').trim() });
|
|
18
|
+
return key;
|
|
19
|
+
});
|
|
20
|
+
return { tokens, masked };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function linkButton(token, resolved) {
|
|
24
|
+
const button = document.createElement('button');
|
|
25
|
+
button.className = ui.wikilink(Boolean(resolved));
|
|
26
|
+
button.textContent = token.alias || token.target;
|
|
27
|
+
button.title = resolved ? `Go to ${token.target}` : `No memory named "${token.target}"`;
|
|
28
|
+
if (resolved) button.onclick = resolved.open;
|
|
29
|
+
return button;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rehydrateWikilinks(container, tokens, resolveWikilink) {
|
|
33
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
|
34
|
+
const texts = [];
|
|
35
|
+
while (walker.nextNode()) texts.push(walker.currentNode);
|
|
36
|
+
|
|
37
|
+
for (const textNode of texts) {
|
|
38
|
+
if (!PLACEHOLDER.test(textNode.nodeValue)) continue;
|
|
39
|
+
const fragment = document.createDocumentFragment();
|
|
40
|
+
for (const part of textNode.nodeValue.split(PLACEHOLDER_SPLIT)) {
|
|
41
|
+
const match = part.match(PLACEHOLDER_EXACT);
|
|
42
|
+
if (!match) {
|
|
43
|
+
if (part) fragment.append(document.createTextNode(part));
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const token = tokens[Number(match[1])];
|
|
47
|
+
fragment.append(linkButton(token, resolveWikilink(token.target)));
|
|
48
|
+
}
|
|
49
|
+
textNode.replaceWith(fragment);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function rewireAnchors(container, resolveHref) {
|
|
54
|
+
for (const anchor of container.querySelectorAll('a[href]')) {
|
|
55
|
+
const href = anchor.getAttribute('href');
|
|
56
|
+
if (!LOCAL_MARKDOWN.test(href)) {
|
|
57
|
+
anchor.target = '_blank';
|
|
58
|
+
anchor.rel = 'noopener noreferrer';
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const resolved = resolveHref(href);
|
|
62
|
+
anchor.className = ui.indexLink(Boolean(resolved));
|
|
63
|
+
anchor.removeAttribute('href');
|
|
64
|
+
anchor.title = resolved ? `Go to ${href}` : `${href} does not exist`;
|
|
65
|
+
if (resolved) anchor.onclick = resolved.open;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function renderMarkdown(container, text, { resolveWikilink, resolveHref } = {}) {
|
|
70
|
+
const { tokens, masked } = extractWikilinks(text);
|
|
71
|
+
container.innerHTML = DOMPurify.sanitize(marked.parse(masked));
|
|
72
|
+
if (tokens.length && resolveWikilink) rehydrateWikilinks(container, tokens, resolveWikilink);
|
|
73
|
+
if (resolveHref) rewireAnchors(container, resolveHref);
|
|
74
|
+
}
|