@polderlabs/bizar 4.5.0 → 4.5.2
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/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
- package/bizar-dash/dist/assets/main-B4OfGAwz.js +361 -0
- package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +1 -0
- package/bizar-dash/dist/assets/main-DAlLdW8I.css +1 -0
- package/bizar-dash/dist/assets/{mobile--0FBIKX3.js → mobile-BRhoDOUz.js} +1 -2
- package/bizar-dash/dist/assets/{mobile--0FBIKX3.js.map → mobile-BRhoDOUz.js.map} +1 -1
- package/bizar-dash/dist/assets/{mobile-OgRp8VIb.js → mobile-lbH6szyX.js} +2 -3
- package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +1 -0
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/skills/bizar/SKILL.md +20 -0
- package/bizar-dash/skills/headroom/SKILL.md +94 -0
- package/bizar-dash/skills/lightrag/SKILL.md +11 -0
- package/bizar-dash/skills/obsidian/SKILL.md +13 -0
- package/bizar-dash/src/server/api.mjs +6 -0
- package/bizar-dash/src/server/headroom.mjs +647 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +281 -6
- package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
- package/bizar-dash/src/server/memory-store.mjs +151 -0
- package/bizar-dash/src/server/mods-loader.mjs +13 -5
- package/bizar-dash/src/server/providers-store.mjs +73 -1
- package/bizar-dash/src/server/routes/_shared.mjs +17 -0
- package/bizar-dash/src/server/routes/chat.mjs +38 -0
- package/bizar-dash/src/server/routes/headroom.mjs +126 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +6 -2
- package/bizar-dash/src/server/routes/memory.mjs +439 -4
- package/bizar-dash/src/server/schedules-runner.mjs +4 -4
- package/bizar-dash/src/server/server.mjs +55 -37
- package/bizar-dash/src/server/watcher.mjs +2 -2
- package/bizar-dash/src/web/App.tsx +42 -6
- package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
- package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
- package/bizar-dash/src/web/components/Toast.tsx +1 -1
- package/bizar-dash/src/web/components/Topbar.tsx +3 -1
- package/bizar-dash/src/web/lib/api.ts +12 -11
- package/bizar-dash/src/web/lib/types.ts +16 -0
- package/bizar-dash/src/web/main.tsx +1 -0
- package/bizar-dash/src/web/styles/chat.css +2 -0
- package/bizar-dash/src/web/styles/main.css +52 -23
- package/bizar-dash/src/web/styles/memory.css +955 -0
- package/bizar-dash/src/web/styles/tasks.css +2 -0
- package/bizar-dash/src/web/views/Memory.tsx +141 -0
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +3 -2
- package/bizar-dash/src/web/views/Overview.tsx +7 -2
- package/bizar-dash/src/web/views/Settings.tsx +32 -1
- package/bizar-dash/src/web/views/Skills.tsx +3 -2
- package/bizar-dash/src/web/views/Tasks.tsx +4 -3
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
- package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
- package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
- package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +356 -0
- package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
- package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
- package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
- package/bizar-dash/tests/cli-bugfixes.test.mjs +151 -0
- package/bizar-dash/tests/frontend-bugfixes.test.mjs +151 -0
- package/bizar-dash/tests/headroom-install.test.mjs +173 -0
- package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
- package/bizar-dash/tests/headroom-status.test.mjs +117 -0
- package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
- package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
- package/bizar-dash/tests/memory-tab.test.mjs +322 -0
- package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
- package/bizar-dash/tests/server-bugfixes.test.mjs +318 -0
- package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
- package/cli/artifact.mjs +11 -4
- package/cli/bin.mjs +211 -12
- package/cli/doctor.mjs +5 -3
- package/cli/memory.mjs +65 -21
- package/cli/provision.mjs +8 -2
- package/install.sh +1 -2
- package/package.json +3 -3
- package/bizar-dash/dist/assets/main-CDFKHzBg.css +0 -1
- package/bizar-dash/dist/assets/main-NYFpS2wY.js +0 -312
- package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +0 -1
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
// src/web/views/memory/ObsidianPanel.tsx — vault browser + note CRUD + backlinks.
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
ChevronDown,
|
|
5
|
+
ChevronRight,
|
|
6
|
+
FilePlus,
|
|
7
|
+
FileText,
|
|
8
|
+
Folder,
|
|
9
|
+
Loader2,
|
|
10
|
+
Pencil,
|
|
11
|
+
RefreshCw,
|
|
12
|
+
Search as SearchIcon,
|
|
13
|
+
Trash2,
|
|
14
|
+
X,
|
|
15
|
+
} from 'lucide-react';
|
|
16
|
+
import { Button } from '../../components/Button';
|
|
17
|
+
import { Card, CardMeta, CardTitle } from '../../components/Card';
|
|
18
|
+
import { EmptyState } from '../../components/EmptyState';
|
|
19
|
+
import { Spinner } from '../../components/Spinner';
|
|
20
|
+
import { useToast } from '../../components/Toast';
|
|
21
|
+
import { useModal } from '../../components/Modal';
|
|
22
|
+
import { api } from '../../lib/api';
|
|
23
|
+
import { formatTime, formatRelative, cn } from '../../lib/utils';
|
|
24
|
+
|
|
25
|
+
type Note = {
|
|
26
|
+
relPath: string;
|
|
27
|
+
frontmatter: Record<string, unknown>;
|
|
28
|
+
body: string;
|
|
29
|
+
raw: string;
|
|
30
|
+
mtime: number;
|
|
31
|
+
size: number;
|
|
32
|
+
schemaValid: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type TreeNode = {
|
|
36
|
+
name: string;
|
|
37
|
+
path: string;
|
|
38
|
+
type: 'folder' | 'note';
|
|
39
|
+
size?: number;
|
|
40
|
+
mtime?: number;
|
|
41
|
+
children?: TreeNode[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type Backlink = {
|
|
45
|
+
fromRelPath: string;
|
|
46
|
+
fromTitle: string;
|
|
47
|
+
snippet: string;
|
|
48
|
+
mtime: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type Props = { refreshKey: number };
|
|
52
|
+
|
|
53
|
+
export function ObsidianPanel({ refreshKey }: Props) {
|
|
54
|
+
const toast = useToast();
|
|
55
|
+
const modal = useModal();
|
|
56
|
+
const [notes, setNotes] = useState<Note[]>([]);
|
|
57
|
+
const [tree, setTree] = useState<TreeNode | null>(null);
|
|
58
|
+
const [loading, setLoading] = useState(true);
|
|
59
|
+
const [searchQ, setSearchQ] = useState('');
|
|
60
|
+
const [searchResults, setSearchResults] = useState<Note[] | null>(null);
|
|
61
|
+
const [searching, setSearching] = useState(false);
|
|
62
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
63
|
+
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
|
|
64
|
+
|
|
65
|
+
const reload = async () => {
|
|
66
|
+
setLoading(true);
|
|
67
|
+
try {
|
|
68
|
+
const [n, t] = await Promise.all([
|
|
69
|
+
api.get<{ notes: Note[] }>('/memory/notes').catch(() => ({ notes: [] })),
|
|
70
|
+
api.get<{ tree: TreeNode | null }>('/memory/obsidian/tree').catch(() => ({ tree: null })),
|
|
71
|
+
]);
|
|
72
|
+
setNotes(n.notes || []);
|
|
73
|
+
setTree(t.tree);
|
|
74
|
+
if (selected && !n.notes?.find((x) => x.relPath === selected)) {
|
|
75
|
+
setSelected(null);
|
|
76
|
+
setBacklinks([]);
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
toast.error(`Vault load failed: ${(err as Error).message}`);
|
|
80
|
+
} finally {
|
|
81
|
+
setLoading(false);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
reload();
|
|
87
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
88
|
+
}, [refreshKey]);
|
|
89
|
+
|
|
90
|
+
// Debounced search
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!searchQ.trim()) {
|
|
93
|
+
setSearchResults(null);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
setSearching(true);
|
|
97
|
+
const t = setTimeout(async () => {
|
|
98
|
+
try {
|
|
99
|
+
const r = await api.get<{ results: Array<{ relPath: string; snippet: string; score: number }> }>(
|
|
100
|
+
`/memory/search?q=${encodeURIComponent(searchQ)}&limit=50`,
|
|
101
|
+
);
|
|
102
|
+
const pathSet = new Set((r.results || []).map((x) => x.relPath));
|
|
103
|
+
setSearchResults(notes.filter((n) => pathSet.has(n.relPath)));
|
|
104
|
+
} catch (err) {
|
|
105
|
+
toast.error(`Search failed: ${(err as Error).message}`);
|
|
106
|
+
} finally {
|
|
107
|
+
setSearching(false);
|
|
108
|
+
}
|
|
109
|
+
}, 240);
|
|
110
|
+
return () => clearTimeout(t);
|
|
111
|
+
}, [searchQ, notes, toast]);
|
|
112
|
+
|
|
113
|
+
const onSelect = async (relPath: string) => {
|
|
114
|
+
setSelected(relPath);
|
|
115
|
+
try {
|
|
116
|
+
const r = await api.get<{ backlinks: Backlink[] }>(
|
|
117
|
+
`/memory/obsidian/backlinks?note=${encodeURIComponent(relPath)}`,
|
|
118
|
+
);
|
|
119
|
+
setBacklinks(r.backlinks || []);
|
|
120
|
+
} catch {
|
|
121
|
+
setBacklinks([]);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const onCreate = () => {
|
|
126
|
+
modal.open({
|
|
127
|
+
title: 'New note',
|
|
128
|
+
width: 720,
|
|
129
|
+
children: (
|
|
130
|
+
<NoteEditor
|
|
131
|
+
mode="create"
|
|
132
|
+
onSave={async (relPath, frontmatter, body) => {
|
|
133
|
+
try {
|
|
134
|
+
await api.post('/memory/notes', { path: relPath, frontmatter, body });
|
|
135
|
+
toast.success(`Created ${relPath}.`);
|
|
136
|
+
modal.close();
|
|
137
|
+
await reload();
|
|
138
|
+
onSelect(relPath);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
toast.error(`Create failed: ${(err as Error).message}`);
|
|
141
|
+
}
|
|
142
|
+
}}
|
|
143
|
+
onCancel={() => modal.close()}
|
|
144
|
+
/>
|
|
145
|
+
),
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const onEdit = async (relPath: string) => {
|
|
150
|
+
try {
|
|
151
|
+
const note = await api.get<Note>(`/memory/notes/${relPath.split('/').map(encodeURIComponent).join('/')}`);
|
|
152
|
+
modal.open({
|
|
153
|
+
title: `Edit ${relPath}`,
|
|
154
|
+
width: 720,
|
|
155
|
+
children: (
|
|
156
|
+
<NoteEditor
|
|
157
|
+
mode="edit"
|
|
158
|
+
initial={{ relPath: note.relPath, frontmatter: note.frontmatter, body: note.body }}
|
|
159
|
+
onSave={async (rp, frontmatter, body) => {
|
|
160
|
+
try {
|
|
161
|
+
await api.put(`/memory/notes/${rp.split('/').map(encodeURIComponent).join('/')}`, {
|
|
162
|
+
frontmatter, body,
|
|
163
|
+
});
|
|
164
|
+
toast.success(`Saved ${rp}.`);
|
|
165
|
+
modal.close();
|
|
166
|
+
await reload();
|
|
167
|
+
onSelect(rp);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
toast.error(`Save failed: ${(err as Error).message}`);
|
|
170
|
+
}
|
|
171
|
+
}}
|
|
172
|
+
onCancel={() => modal.close()}
|
|
173
|
+
/>
|
|
174
|
+
),
|
|
175
|
+
});
|
|
176
|
+
} catch (err) {
|
|
177
|
+
toast.error(`Load failed: ${(err as Error).message}`);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const onDelete = async (relPath: string) => {
|
|
182
|
+
if (!confirm(`Delete ${relPath}? This cannot be undone.`)) return;
|
|
183
|
+
try {
|
|
184
|
+
await api.del(`/memory/notes/${relPath.split('/').map(encodeURIComponent).join('/')}`);
|
|
185
|
+
toast.success(`Deleted ${relPath}.`);
|
|
186
|
+
if (selected === relPath) setSelected(null);
|
|
187
|
+
await reload();
|
|
188
|
+
} catch (err) {
|
|
189
|
+
toast.error(`Delete failed: ${(err as Error).message}`);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const sortedNotes = useMemo(() => {
|
|
194
|
+
return [...notes].sort((a, b) => b.mtime - a.mtime);
|
|
195
|
+
}, [notes]);
|
|
196
|
+
|
|
197
|
+
const displayed = searchResults ?? sortedNotes;
|
|
198
|
+
|
|
199
|
+
if (loading && !notes.length) {
|
|
200
|
+
return (
|
|
201
|
+
<div className="view-loading">
|
|
202
|
+
<Spinner size="lg" />
|
|
203
|
+
<p>Loading vault…</p>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<div className="memory-panel-content memory-obsidian-grid">
|
|
210
|
+
{/* ── Folder tree (left) ─────────────────────────────────────── */}
|
|
211
|
+
<Card variant="outlined" className="memory-tree-card">
|
|
212
|
+
<CardTitle>
|
|
213
|
+
<Folder size={14} /> Folders
|
|
214
|
+
</CardTitle>
|
|
215
|
+
<CardMeta>{tree ? `${countFolders(tree)} folder(s)` : 'no vault'}</CardMeta>
|
|
216
|
+
<div className="memory-tree-body">
|
|
217
|
+
{tree ? <TreeView node={tree} depth={0} onSelect={onSelect} selected={selected} /> : null}
|
|
218
|
+
</div>
|
|
219
|
+
</Card>
|
|
220
|
+
|
|
221
|
+
{/* ── Note list (center) ─────────────────────────────────────── */}
|
|
222
|
+
<div className="memory-obsidian-list-col">
|
|
223
|
+
<div className="memory-list-toolbar">
|
|
224
|
+
<div className="search-input">
|
|
225
|
+
<SearchIcon size={14} />
|
|
226
|
+
<input
|
|
227
|
+
type="text"
|
|
228
|
+
className="input"
|
|
229
|
+
placeholder="Search notes…"
|
|
230
|
+
value={searchQ}
|
|
231
|
+
onChange={(e) => setSearchQ(e.target.value)}
|
|
232
|
+
/>
|
|
233
|
+
{searchQ && (
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
className="icon-btn"
|
|
237
|
+
onClick={() => setSearchQ('')}
|
|
238
|
+
aria-label="Clear search"
|
|
239
|
+
>
|
|
240
|
+
<X size={12} />
|
|
241
|
+
</button>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
<Button variant="primary" size="sm" onClick={onCreate}>
|
|
245
|
+
<FilePlus size={12} /> New note
|
|
246
|
+
</Button>
|
|
247
|
+
<Button variant="ghost" size="sm" onClick={reload} title="Refresh">
|
|
248
|
+
<RefreshCw size={12} />
|
|
249
|
+
</Button>
|
|
250
|
+
</div>
|
|
251
|
+
{searching && <div className="muted text-sm">Searching…</div>}
|
|
252
|
+
{displayed.length === 0 ? (
|
|
253
|
+
<EmptyState
|
|
254
|
+
icon={<FileText size={28} />}
|
|
255
|
+
title="No notes"
|
|
256
|
+
message={
|
|
257
|
+
notes.length === 0
|
|
258
|
+
? 'No notes in this vault yet. Click "New note" to create one.'
|
|
259
|
+
: `No notes match "${searchQ}".`
|
|
260
|
+
}
|
|
261
|
+
/>
|
|
262
|
+
) : (
|
|
263
|
+
<ul className="memory-note-list">
|
|
264
|
+
{displayed.map((n) => (
|
|
265
|
+
<li
|
|
266
|
+
key={n.relPath}
|
|
267
|
+
className={cn('memory-note-card', selected === n.relPath && 'memory-note-card-active')}
|
|
268
|
+
>
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
className="memory-note-card-main"
|
|
272
|
+
onClick={() => onSelect(n.relPath)}
|
|
273
|
+
>
|
|
274
|
+
<div className="memory-note-card-title">
|
|
275
|
+
<FileText size={12} />
|
|
276
|
+
<span>{titleOf(n)}</span>
|
|
277
|
+
</div>
|
|
278
|
+
<div className="memory-note-card-meta">
|
|
279
|
+
<code className="muted">{n.relPath}</code>
|
|
280
|
+
<span className="muted">· {formatRelative(n.mtime)}</span>
|
|
281
|
+
{!n.schemaValid && <span className="memory-pill-warn">schema</span>}
|
|
282
|
+
</div>
|
|
283
|
+
</button>
|
|
284
|
+
<div className="memory-note-card-actions">
|
|
285
|
+
<button
|
|
286
|
+
type="button"
|
|
287
|
+
className="icon-btn"
|
|
288
|
+
onClick={() => onEdit(n.relPath)}
|
|
289
|
+
aria-label={`Edit ${n.relPath}`}
|
|
290
|
+
title="Edit"
|
|
291
|
+
>
|
|
292
|
+
<Pencil size={12} />
|
|
293
|
+
</button>
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
className="icon-btn"
|
|
297
|
+
onClick={() => onDelete(n.relPath)}
|
|
298
|
+
aria-label={`Delete ${n.relPath}`}
|
|
299
|
+
title="Delete"
|
|
300
|
+
>
|
|
301
|
+
<Trash2 size={12} />
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
</li>
|
|
305
|
+
))}
|
|
306
|
+
</ul>
|
|
307
|
+
)}
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
{/* ── Detail pane (right) ────────────────────────────────────── */}
|
|
311
|
+
<div className="memory-obsidian-detail-col">
|
|
312
|
+
{selected ? (
|
|
313
|
+
<NoteDetail relPath={selected} onClose={() => setSelected(null)} />
|
|
314
|
+
) : (
|
|
315
|
+
<EmptyState
|
|
316
|
+
icon={<FileText size={28} />}
|
|
317
|
+
title="Pick a note"
|
|
318
|
+
message="Select a note from the list to view its content, frontmatter, and backlinks."
|
|
319
|
+
/>
|
|
320
|
+
)}
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Tree view ─────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Coerce a note's title to a renderable string. frontmatter.title is
|
|
330
|
+
* `unknown` per the schema, so this guards against non-string values.
|
|
331
|
+
*/
|
|
332
|
+
function titleOf(n: Note): string {
|
|
333
|
+
const t = n.frontmatter?.title;
|
|
334
|
+
if (typeof t === 'string' && t.trim()) return t;
|
|
335
|
+
return n.relPath.replace(/\.md$/i, '');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function TreeView({
|
|
339
|
+
node,
|
|
340
|
+
depth,
|
|
341
|
+
onSelect,
|
|
342
|
+
selected,
|
|
343
|
+
}: {
|
|
344
|
+
node: TreeNode;
|
|
345
|
+
depth: number;
|
|
346
|
+
onSelect: (path: string) => void;
|
|
347
|
+
selected: string | null;
|
|
348
|
+
}) {
|
|
349
|
+
const [open, setOpen] = useState(depth < 2);
|
|
350
|
+
if (node.type === 'note') {
|
|
351
|
+
return (
|
|
352
|
+
<button
|
|
353
|
+
type="button"
|
|
354
|
+
className={cn('memory-tree-node memory-tree-node-note', selected === node.path && 'memory-tree-node-active')}
|
|
355
|
+
style={{ paddingLeft: 6 + depth * 12 }}
|
|
356
|
+
onClick={() => onSelect(node.path)}
|
|
357
|
+
>
|
|
358
|
+
<FileText size={11} />
|
|
359
|
+
<span>{node.name}</span>
|
|
360
|
+
</button>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
return (
|
|
364
|
+
<div className="memory-tree-folder">
|
|
365
|
+
<button
|
|
366
|
+
type="button"
|
|
367
|
+
className="memory-tree-node memory-tree-node-folder"
|
|
368
|
+
style={{ paddingLeft: 6 + depth * 12 }}
|
|
369
|
+
onClick={() => setOpen((v) => !v)}
|
|
370
|
+
>
|
|
371
|
+
{open ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
|
|
372
|
+
<Folder size={11} />
|
|
373
|
+
<span>{node.name || 'vault'}</span>
|
|
374
|
+
{node.children && node.children.length > 0 && (
|
|
375
|
+
<span className="muted text-xs">{node.children.length}</span>
|
|
376
|
+
)}
|
|
377
|
+
</button>
|
|
378
|
+
{open && node.children && (
|
|
379
|
+
<div className="memory-tree-children">
|
|
380
|
+
{node.children.map((c, i) => (
|
|
381
|
+
<TreeView key={`${c.path}-${i}`} node={c} depth={depth + 1} onSelect={onSelect} selected={selected} />
|
|
382
|
+
))}
|
|
383
|
+
</div>
|
|
384
|
+
)}
|
|
385
|
+
</div>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function countFolders(node: TreeNode): number {
|
|
390
|
+
if (node.type === 'note') return 0;
|
|
391
|
+
return 1 + (node.children || []).reduce((acc, c) => acc + countFolders(c), 0);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ── Note detail ───────────────────────────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
function NoteDetail({ relPath, onClose }: { relPath: string; onClose: () => void }) {
|
|
397
|
+
const [note, setNote] = useState<Note | null>(null);
|
|
398
|
+
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
|
|
399
|
+
const [loading, setLoading] = useState(true);
|
|
400
|
+
|
|
401
|
+
useEffect(() => {
|
|
402
|
+
let cancelled = false;
|
|
403
|
+
setLoading(true);
|
|
404
|
+
(async () => {
|
|
405
|
+
try {
|
|
406
|
+
const [n, b] = await Promise.all([
|
|
407
|
+
api.get<Note>(`/memory/notes/${relPath.split('/').map(encodeURIComponent).join('/')}`),
|
|
408
|
+
api.get<{ backlinks: Backlink[] }>(`/memory/obsidian/backlinks?note=${encodeURIComponent(relPath)}`),
|
|
409
|
+
]);
|
|
410
|
+
if (cancelled) return;
|
|
411
|
+
setNote(n);
|
|
412
|
+
setBacklinks(b.backlinks || []);
|
|
413
|
+
} catch {
|
|
414
|
+
if (!cancelled) setNote(null);
|
|
415
|
+
} finally {
|
|
416
|
+
if (!cancelled) setLoading(false);
|
|
417
|
+
}
|
|
418
|
+
})();
|
|
419
|
+
return () => { cancelled = true; };
|
|
420
|
+
}, [relPath]);
|
|
421
|
+
|
|
422
|
+
if (loading) {
|
|
423
|
+
return (
|
|
424
|
+
<div className="view-loading"><Spinner size="sm" /></div>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
if (!note) return <div className="muted">Note not found.</div>;
|
|
428
|
+
|
|
429
|
+
const tags = Array.isArray(note.frontmatter?.tags) ? note.frontmatter.tags : [];
|
|
430
|
+
const links = Array.isArray(note.frontmatter?.links) ? note.frontmatter.links : [];
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
<div className="memory-note-detail">
|
|
434
|
+
<div className="memory-note-detail-head">
|
|
435
|
+
<h3>{titleOf(note)}</h3>
|
|
436
|
+
<button type="button" className="icon-btn" onClick={onClose} aria-label="Close">
|
|
437
|
+
<X size={12} />
|
|
438
|
+
</button>
|
|
439
|
+
</div>
|
|
440
|
+
<div className="memory-note-detail-meta">
|
|
441
|
+
<code>{relPath}</code>
|
|
442
|
+
<span className="muted">· modified {formatTime(note.mtime)}</span>
|
|
443
|
+
</div>
|
|
444
|
+
{tags.length > 0 && (
|
|
445
|
+
<div className="memory-tag-row">
|
|
446
|
+
{tags.map((t, i) => (
|
|
447
|
+
<span key={i} className="memory-tag">{String(t)}</span>
|
|
448
|
+
))}
|
|
449
|
+
</div>
|
|
450
|
+
)}
|
|
451
|
+
<pre className="memory-note-body mono text-sm">{note.body}</pre>
|
|
452
|
+
{backlinks.length > 0 && (
|
|
453
|
+
<div className="memory-backlinks">
|
|
454
|
+
<h4>Backlinks</h4>
|
|
455
|
+
<ul>
|
|
456
|
+
{backlinks.map((b) => (
|
|
457
|
+
<li key={b.fromRelPath}>
|
|
458
|
+
<code>{b.fromRelPath}</code>
|
|
459
|
+
<p className="muted text-sm">{b.snippet}</p>
|
|
460
|
+
</li>
|
|
461
|
+
))}
|
|
462
|
+
</ul>
|
|
463
|
+
</div>
|
|
464
|
+
)}
|
|
465
|
+
{links.length > 0 && (
|
|
466
|
+
<div className="memory-backlinks">
|
|
467
|
+
<h4>Forward links</h4>
|
|
468
|
+
<ul>
|
|
469
|
+
{links.map((l, i) => (
|
|
470
|
+
<li key={i}><code>{String(l)}</code></li>
|
|
471
|
+
))}
|
|
472
|
+
</ul>
|
|
473
|
+
</div>
|
|
474
|
+
)}
|
|
475
|
+
</div>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ── Note editor modal body ────────────────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
function NoteEditor({
|
|
482
|
+
mode,
|
|
483
|
+
initial,
|
|
484
|
+
onSave,
|
|
485
|
+
onCancel,
|
|
486
|
+
}: {
|
|
487
|
+
mode: 'create' | 'edit';
|
|
488
|
+
initial?: { relPath: string; frontmatter: Record<string, unknown>; body: string };
|
|
489
|
+
onSave: (relPath: string, frontmatter: Record<string, unknown>, body: string) => void | Promise<void>;
|
|
490
|
+
onCancel: () => void;
|
|
491
|
+
}) {
|
|
492
|
+
const [relPath, setRelPath] = useState(initial?.relPath || 'untitled.md');
|
|
493
|
+
const [frontmatterYaml, setFrontmatterYaml] = useState(
|
|
494
|
+
initial?.frontmatter ? yamlStringify(initial.frontmatter) : 'title: ""\ntags: []\n',
|
|
495
|
+
);
|
|
496
|
+
const [body, setBody] = useState(initial?.body || '');
|
|
497
|
+
const [busy, setBusy] = useState(false);
|
|
498
|
+
const toast = useToast();
|
|
499
|
+
|
|
500
|
+
const onSubmit = async () => {
|
|
501
|
+
if (!relPath.endsWith('.md')) {
|
|
502
|
+
toast.error('Path must end in .md');
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
let fm: Record<string, unknown>;
|
|
506
|
+
try {
|
|
507
|
+
fm = yamlParse(frontmatterYaml) || {};
|
|
508
|
+
} catch (err) {
|
|
509
|
+
toast.error(`Frontmatter parse failed: ${(err as Error).message}`);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
setBusy(true);
|
|
513
|
+
try {
|
|
514
|
+
await onSave(relPath, fm, body);
|
|
515
|
+
} finally {
|
|
516
|
+
setBusy(false);
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
return (
|
|
521
|
+
<div className="memory-editor-modal">
|
|
522
|
+
<label className="field-label">Path</label>
|
|
523
|
+
<input
|
|
524
|
+
type="text"
|
|
525
|
+
className="input mono"
|
|
526
|
+
value={relPath}
|
|
527
|
+
onChange={(e) => setRelPath(e.target.value)}
|
|
528
|
+
disabled={mode === 'edit'}
|
|
529
|
+
placeholder="notes/example.md"
|
|
530
|
+
/>
|
|
531
|
+
<label className="field-label" style={{ marginTop: 12 }}>Frontmatter (YAML)</label>
|
|
532
|
+
<textarea
|
|
533
|
+
className="memory-editor-textarea mono text-sm"
|
|
534
|
+
rows={6}
|
|
535
|
+
value={frontmatterYaml}
|
|
536
|
+
onChange={(e) => setFrontmatterYaml(e.target.value)}
|
|
537
|
+
/>
|
|
538
|
+
<label className="field-label" style={{ marginTop: 12 }}>Body (Markdown)</label>
|
|
539
|
+
<textarea
|
|
540
|
+
className="memory-editor-textarea mono text-sm"
|
|
541
|
+
rows={14}
|
|
542
|
+
value={body}
|
|
543
|
+
onChange={(e) => setBody(e.target.value)}
|
|
544
|
+
/>
|
|
545
|
+
<div className="modal-footer-actions" style={{ marginTop: 12 }}>
|
|
546
|
+
<Button variant="ghost" onClick={onCancel} disabled={busy}>Cancel</Button>
|
|
547
|
+
<Button variant="primary" onClick={onSubmit} disabled={busy}>
|
|
548
|
+
{busy && <Loader2 size={12} className="memory-spin" />}
|
|
549
|
+
{mode === 'create' ? 'Create' : 'Save'}
|
|
550
|
+
</Button>
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ── Tiny YAML helpers (avoid pulling a 100kB lib into the bundle) ────────
|
|
557
|
+
|
|
558
|
+
function yamlStringify(obj: Record<string, unknown>): string {
|
|
559
|
+
// Best-effort: dump top-level scalar / list / nested-map fields.
|
|
560
|
+
// Good enough for the note editor — the server re-validates anyway.
|
|
561
|
+
const lines: string[] = [];
|
|
562
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
563
|
+
if (v === null || v === undefined) continue;
|
|
564
|
+
if (Array.isArray(v)) {
|
|
565
|
+
if (v.length === 0) { lines.push(`${k}: []`); continue; }
|
|
566
|
+
lines.push(`${k}:`);
|
|
567
|
+
for (const item of v) lines.push(` - ${yamlScalar(item)}`);
|
|
568
|
+
} else if (typeof v === 'object') {
|
|
569
|
+
lines.push(`${k}:`);
|
|
570
|
+
for (const [kk, vv] of Object.entries(v as Record<string, unknown>)) {
|
|
571
|
+
lines.push(` ${kk}: ${yamlScalar(vv)}`);
|
|
572
|
+
}
|
|
573
|
+
} else {
|
|
574
|
+
lines.push(`${k}: ${yamlScalar(v)}`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return lines.join('\n');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function yamlScalar(v: unknown): string {
|
|
581
|
+
if (typeof v === 'string') {
|
|
582
|
+
if (/[:#\n"']/.test(v) || v === '') return JSON.stringify(v);
|
|
583
|
+
return v;
|
|
584
|
+
}
|
|
585
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
586
|
+
return JSON.stringify(v);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function yamlParse(s: string): Record<string, unknown> {
|
|
590
|
+
const out: Record<string, unknown> = {};
|
|
591
|
+
const lines = s.split(/\r?\n/);
|
|
592
|
+
let i = 0;
|
|
593
|
+
while (i < lines.length) {
|
|
594
|
+
const line = lines[i];
|
|
595
|
+
if (!line.trim() || line.trim().startsWith('#')) { i++; continue; }
|
|
596
|
+
const m = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
|
|
597
|
+
if (!m) { i++; continue; }
|
|
598
|
+
const [, key, rest] = m;
|
|
599
|
+
if (rest === '') {
|
|
600
|
+
// Nested mapping or list follows.
|
|
601
|
+
const block: Record<string, unknown> = {};
|
|
602
|
+
const list: unknown[] = [];
|
|
603
|
+
let isList = false;
|
|
604
|
+
let isMap = false;
|
|
605
|
+
while (i + 1 < lines.length) {
|
|
606
|
+
const next = lines[i + 1];
|
|
607
|
+
if (!next.startsWith(' ')) break;
|
|
608
|
+
if (next.startsWith(' - ')) {
|
|
609
|
+
isList = true;
|
|
610
|
+
list.push(parseScalar(next.slice(4)));
|
|
611
|
+
i++;
|
|
612
|
+
} else {
|
|
613
|
+
isMap = true;
|
|
614
|
+
const nm = next.match(/^ ([A-Za-z_][\w-]*):\s*(.*)$/);
|
|
615
|
+
if (nm) {
|
|
616
|
+
block[nm[1]] = parseScalar(nm[2]);
|
|
617
|
+
}
|
|
618
|
+
i++;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
out[key] = isList && !isMap ? list : (isMap ? block : {});
|
|
622
|
+
} else {
|
|
623
|
+
out[key] = parseScalar(rest);
|
|
624
|
+
}
|
|
625
|
+
i++;
|
|
626
|
+
}
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function parseScalar(v: string): unknown {
|
|
631
|
+
const t = v.trim();
|
|
632
|
+
if (t === '') return '';
|
|
633
|
+
if (t === 'true') return true;
|
|
634
|
+
if (t === 'false') return false;
|
|
635
|
+
if (t === 'null' || t === '~') return null;
|
|
636
|
+
if (/^-?\d+$/.test(t)) return parseInt(t, 10);
|
|
637
|
+
if (/^-?\d+\.\d+$/.test(t)) return parseFloat(t);
|
|
638
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
639
|
+
return t.slice(1, -1);
|
|
640
|
+
}
|
|
641
|
+
return t;
|
|
642
|
+
}
|