pi-weave 0.1.13 → 0.1.14
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/package.json +1 -1
- package/src/core/cache/workspace.ts +70 -16
- package/src/core/graph/build.ts +33 -2
- package/src/core/graph/current.ts +9 -2
- package/src/core/graph/wikilinks.ts +5 -1
- package/src/core/index.ts +4 -0
- package/src/core/types.ts +13 -0
- package/src/core/vault.ts +98 -8
- package/src/core/view/detail.ts +4 -0
- package/src/web/client/dist/app.js +61 -60
- package/src/web/client/main.tsx +1 -1
- package/src/web/client/note/Note.tsx +24 -0
- package/src/web/client/note/note.model.ts +20 -0
- package/src/web/client/shell/Columns.tsx +29 -12
- package/src/web/client/shell/Divider.tsx +18 -0
- package/src/web/client/shell/Shell.tsx +27 -4
- package/src/web/client/shell/cssvars.ts +8 -0
- package/src/web/client/shell/drag.model.ts +43 -0
- package/src/web/client/shell/layout.model.ts +138 -0
- package/src/web/client/shell/shell.model.ts +19 -5
- package/src/web/client/shell/theme.ts +14 -13
- package/src/web/client/shell/viewport.ts +11 -0
- package/src/web/server/page.ts +1 -0
- package/src/web/server/routes.ts +36 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-weave",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "An agent-native knowledge workspace for your life and your code. Smart notepad + repository exploration, readable by humans and agents alike.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -33,8 +33,8 @@ import { buildGraph, DEFAULT_MAX_NOTES, type BuildGraphInput } from "../graph/bu
|
|
|
33
33
|
import type { GraphModel } from "../graph/model";
|
|
34
34
|
import { readRepositorySide } from "../graph/current";
|
|
35
35
|
import { withMutationQueue } from "../mutex";
|
|
36
|
-
import { getNote, listNoteFolders, statNotes } from "../vault";
|
|
37
|
-
import type { Note } from "../types";
|
|
36
|
+
import { getHtmlArtifact, getNote, listNoteFolders, statNotes } from "../vault";
|
|
37
|
+
import type { HtmlArtifact, Note } from "../types";
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* One build's outputs: the graph, and the notes it was built from.
|
|
@@ -53,6 +53,8 @@ export interface WorkspaceSnapshot {
|
|
|
53
53
|
* caller mutating it would corrupt the next build.
|
|
54
54
|
*/
|
|
55
55
|
notes: readonly Note[];
|
|
56
|
+
/** Exactly the HTML artifacts represented by the graph. */
|
|
57
|
+
artifacts: readonly HtmlArtifact[];
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/** Cumulative counters, from construction. Callers take deltas. */
|
|
@@ -74,6 +76,12 @@ interface CachedNote {
|
|
|
74
76
|
note: Note;
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
interface CachedArtifact {
|
|
80
|
+
mtimeMs: number;
|
|
81
|
+
size: number;
|
|
82
|
+
artifact: HtmlArtifact;
|
|
83
|
+
}
|
|
84
|
+
|
|
77
85
|
/** The repository half, held behind a TTL because assessing it spawns git. */
|
|
78
86
|
interface CachedRepo {
|
|
79
87
|
/** Wall-clock ms (from the injected clock) when this was captured. */
|
|
@@ -107,8 +115,8 @@ export type InvalidationScope = "vault" | "repo" | "none";
|
|
|
107
115
|
* whether an event is worth forwarding at all, and one implementation means
|
|
108
116
|
* the two can never disagree.
|
|
109
117
|
*
|
|
110
|
-
* - `vault`: a `*.md` under `<vaultRoot>/notes/`. Only
|
|
111
|
-
* the vault manifest does not participate in
|
|
118
|
+
* - `vault`: a `*.md`, `*.html`, or `*.htm` under `<vaultRoot>/notes/`. Only
|
|
119
|
+
* vault artifacts count — the vault manifest does not participate in graph.
|
|
112
120
|
* - `repo`: anything under `<cwd>/.okf/` (the derived index and its summary
|
|
113
121
|
* sidecars) or under `<cwd>/.git/` (HEAD moves, staged changes), plus any
|
|
114
122
|
* tracked file in the repo, since editing one makes the index stale.
|
|
@@ -141,6 +149,9 @@ function classify(
|
|
|
141
149
|
const slug = rel.slice(0, -".md".length).split(sep).join("/");
|
|
142
150
|
return { scope: "vault", slug };
|
|
143
151
|
}
|
|
152
|
+
if (rel.toLowerCase().endsWith(".html") || rel.toLowerCase().endsWith(".htm")) {
|
|
153
|
+
return { scope: "vault", slug: rel.split(sep).join("/") };
|
|
154
|
+
}
|
|
144
155
|
const base = rel.split(sep).pop() ?? rel;
|
|
145
156
|
if (base.startsWith(".") || (base.includes(".") && !base.endsWith(".md"))) {
|
|
146
157
|
return { scope: "none", slug: null };
|
|
@@ -178,12 +189,14 @@ export class WorkspaceCache {
|
|
|
178
189
|
private readonly stalenessTtlMs: number;
|
|
179
190
|
|
|
180
191
|
private notes = new Map<string, CachedNote>();
|
|
192
|
+
private artifacts = new Map<string, CachedArtifact>();
|
|
181
193
|
/**
|
|
182
194
|
* `.md` files present at the last refresh, including ones too malformed to
|
|
183
195
|
* parse — mirrors `readVault().fileCount` so the vault node's note count
|
|
184
|
-
* matches the uncached build exactly.
|
|
196
|
+
* matches the uncached build exactly. HTML artifacts have a separate count.
|
|
185
197
|
*/
|
|
186
198
|
private fileCount = 0;
|
|
199
|
+
private artifactCount = 0;
|
|
187
200
|
private folders: string[] = [];
|
|
188
201
|
private repo: CachedRepo | null = null;
|
|
189
202
|
/**
|
|
@@ -229,8 +242,8 @@ export class WorkspaceCache {
|
|
|
229
242
|
private repoDirtiedDuringBuild = false;
|
|
230
243
|
private building = false;
|
|
231
244
|
/**
|
|
232
|
-
* Whether the last {@link refreshNotes} observed any
|
|
233
|
-
* a file read, a
|
|
245
|
+
* Whether the last {@link refreshNotes} observed any vault-side movement:
|
|
246
|
+
* a file read, a file that disappeared, or a change in raw file counts.
|
|
234
247
|
* Read by {@link build} to decide whether {@link lastSnapshot} is reusable.
|
|
235
248
|
*/
|
|
236
249
|
private notesChanged = true;
|
|
@@ -299,7 +312,8 @@ export class WorkspaceCache {
|
|
|
299
312
|
invalidate(absPath: string): void {
|
|
300
313
|
const { scope, slug } = classify(absPath, { cwd: this.cwd, vaultRoot: this.vaultRoot });
|
|
301
314
|
if (scope === "vault" && slug !== null) {
|
|
302
|
-
this.
|
|
315
|
+
if (slug.toLowerCase().endsWith(".html") || slug.toLowerCase().endsWith(".htm")) this.artifacts.delete(slug);
|
|
316
|
+
else this.notes.delete(slug);
|
|
303
317
|
if (this.building) this.evictedDuringBuild.add(slug);
|
|
304
318
|
} else if (scope === "repo") {
|
|
305
319
|
this.repo = null;
|
|
@@ -310,6 +324,7 @@ export class WorkspaceCache {
|
|
|
310
324
|
/** Drop everything: a repo scan landed, or the vault root moved. */
|
|
311
325
|
invalidateAll(): void {
|
|
312
326
|
this.notes.clear();
|
|
327
|
+
this.artifacts.clear();
|
|
313
328
|
this.repo = null;
|
|
314
329
|
if (this.building) {
|
|
315
330
|
// Whatever the in-flight build writes back was read before this call,
|
|
@@ -336,7 +351,8 @@ export class WorkspaceCache {
|
|
|
336
351
|
this.allEvictedDuringBuild = false;
|
|
337
352
|
this.repoDirtiedDuringBuild = false;
|
|
338
353
|
try {
|
|
339
|
-
const
|
|
354
|
+
const refreshed = await this.refreshNotes();
|
|
355
|
+
const notes = refreshed.notes;
|
|
340
356
|
const repoFresh = this.repoNeedsRefresh();
|
|
341
357
|
const repo = await this.refreshRepo();
|
|
342
358
|
|
|
@@ -379,15 +395,21 @@ export class WorkspaceCache {
|
|
|
379
395
|
exists: true,
|
|
380
396
|
noteCount: this.fileCount,
|
|
381
397
|
...(this.folders.length > 0 ? { folders: this.folders } : {}),
|
|
398
|
+
...(this.artifactCount > 0 ? { artifactCount: this.artifactCount } : {}),
|
|
382
399
|
},
|
|
383
400
|
notes: kept,
|
|
401
|
+
artifacts: refreshed.artifacts,
|
|
384
402
|
repository: repo?.repository ?? null,
|
|
385
403
|
};
|
|
386
404
|
if (repo?.summaries !== undefined) input.summaries = repo.summaries;
|
|
387
405
|
|
|
388
406
|
this.gitCalls += gitSpawnCount() - spawnsBefore;
|
|
389
407
|
this.builtAt = this.now().toISOString();
|
|
390
|
-
const snapshot: WorkspaceSnapshot = {
|
|
408
|
+
const snapshot: WorkspaceSnapshot = {
|
|
409
|
+
model: buildGraph(input),
|
|
410
|
+
notes: Object.freeze(kept),
|
|
411
|
+
artifacts: Object.freeze(input.artifacts ?? []),
|
|
412
|
+
};
|
|
391
413
|
this.lastSnapshot = snapshot;
|
|
392
414
|
return snapshot;
|
|
393
415
|
} finally {
|
|
@@ -416,8 +438,15 @@ export class WorkspaceCache {
|
|
|
416
438
|
* something else touches it.
|
|
417
439
|
*/
|
|
418
440
|
private applyDeferredInvalidations(): void {
|
|
419
|
-
if (this.allEvictedDuringBuild)
|
|
420
|
-
|
|
441
|
+
if (this.allEvictedDuringBuild) {
|
|
442
|
+
this.notes.clear();
|
|
443
|
+
this.artifacts.clear();
|
|
444
|
+
} else {
|
|
445
|
+
for (const slug of this.evictedDuringBuild) {
|
|
446
|
+
if (/\.html?$/i.test(slug)) this.artifacts.delete(slug);
|
|
447
|
+
else this.notes.delete(slug);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
421
450
|
if (this.repoDirtiedDuringBuild) this.repo = null;
|
|
422
451
|
this.evictedDuringBuild.clear();
|
|
423
452
|
this.allEvictedDuringBuild = false;
|
|
@@ -428,18 +457,40 @@ export class WorkspaceCache {
|
|
|
428
457
|
* Stat every note; re-read only the ones whose mtime or size moved. Notes
|
|
429
458
|
* that disappeared are evicted, so the map never outgrows the vault.
|
|
430
459
|
*/
|
|
431
|
-
private async refreshNotes(): Promise<Note[]> {
|
|
460
|
+
private async refreshNotes(): Promise<{ notes: Note[]; artifacts: HtmlArtifact[] }> {
|
|
432
461
|
const previousFolders = this.folders;
|
|
433
462
|
const [stats, folders] = await Promise.all([statNotes(this.vaultRoot), listNoteFolders(this.vaultRoot)]);
|
|
434
463
|
this.folders = folders;
|
|
435
464
|
const previousCount = this.notes.size;
|
|
465
|
+
const previousArtifactCount = this.artifacts.size;
|
|
436
466
|
const previousFileCount = this.fileCount;
|
|
437
|
-
|
|
467
|
+
const previousRawArtifactCount = this.artifactCount;
|
|
468
|
+
this.fileCount = stats.filter((st) => st.path.toLowerCase().endsWith(".md")).length;
|
|
469
|
+
this.artifactCount = stats.filter((st) => /\.html?$/i.test(st.path)).length;
|
|
438
470
|
let read = 0;
|
|
439
471
|
|
|
440
472
|
const next = new Map<string, CachedNote>();
|
|
473
|
+
const nextArtifacts = new Map<string, CachedArtifact>();
|
|
441
474
|
const out: Note[] = [];
|
|
475
|
+
const artifactOut: HtmlArtifact[] = [];
|
|
442
476
|
for (const st of stats) {
|
|
477
|
+
if (/\.html?$/i.test(st.path)) {
|
|
478
|
+
const hit = this.artifacts.get(st.slug);
|
|
479
|
+
if (hit !== undefined && hit.mtimeMs === st.mtimeMs && hit.size === st.size) {
|
|
480
|
+
this.notesCached += 1;
|
|
481
|
+
nextArtifacts.set(st.slug, hit);
|
|
482
|
+
artifactOut.push(hit.artifact);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
this.notesRead += 1;
|
|
486
|
+
read += 1;
|
|
487
|
+
const artifact = await getHtmlArtifact(this.vaultRoot, st.slug);
|
|
488
|
+
if (artifact === null) continue;
|
|
489
|
+
const cached = { mtimeMs: st.mtimeMs, size: st.size, artifact };
|
|
490
|
+
nextArtifacts.set(st.slug, cached);
|
|
491
|
+
artifactOut.push(artifact);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
443
494
|
const hit = this.notes.get(st.slug);
|
|
444
495
|
if (hit !== undefined && hit.mtimeMs === st.mtimeMs && hit.size === st.size) {
|
|
445
496
|
this.notesCached += 1;
|
|
@@ -463,11 +514,14 @@ export class WorkspaceCache {
|
|
|
463
514
|
folders.length !== previousFolders.length ||
|
|
464
515
|
folders.some((f, i) => f !== previousFolders[i]);
|
|
465
516
|
this.notesChanged =
|
|
466
|
-
read > 0 || next.size !== previousCount ||
|
|
517
|
+
read > 0 || next.size !== previousCount || nextArtifacts.size !== previousArtifactCount ||
|
|
518
|
+
this.fileCount !== previousFileCount || this.artifactCount !== previousRawArtifactCount || foldersChanged;
|
|
467
519
|
this.notes = next;
|
|
520
|
+
this.artifacts = nextArtifacts;
|
|
521
|
+
const artifacts = artifactOut.sort((a, b) => b.updated.localeCompare(a.updated));
|
|
468
522
|
// `statNotes` yields readdir (slug-ascending) order and sort is stable,
|
|
469
523
|
// so ties break by slug — identical to `readVault`.
|
|
470
|
-
return out.sort((a, b) => b.updated.localeCompare(a.updated));
|
|
524
|
+
return { notes: out.sort((a, b) => b.updated.localeCompare(a.updated)), artifacts };
|
|
471
525
|
}
|
|
472
526
|
|
|
473
527
|
/** The repository half, re-assessed only when the TTL has expired. */
|
package/src/core/graph/build.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* makes the page's refresh-polling cheap.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { Note, RepoIndex, StalenessReport, VaultStatus } from "../types";
|
|
11
|
+
import type { HtmlArtifact, Note, RepoIndex, StalenessReport, VaultStatus } from "../types";
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
13
13
|
import type { SummaryRecord } from "../summaries";
|
|
14
14
|
import type { EdgeKind, GraphEdge, GraphModel, GraphNode } from "./model";
|
|
@@ -22,6 +22,8 @@ export interface BuildGraphInput {
|
|
|
22
22
|
vault: VaultStatus;
|
|
23
23
|
/** Full notes including bodies (for wiki-link extraction). */
|
|
24
24
|
notes: Note[];
|
|
25
|
+
/** Standalone HTML/HTM artifacts under the vault's notes directory. */
|
|
26
|
+
artifacts?: HtmlArtifact[];
|
|
25
27
|
/** Repository half; null when cwd is not an indexed git repository. */
|
|
26
28
|
repository: { index: RepoIndex; staleness: StalenessReport } | null;
|
|
27
29
|
/** Deep-scan summaries keyed by repo-relative path (docs/scan-modes.md). */
|
|
@@ -83,6 +85,9 @@ export function dataTimestamp(input: BuildGraphInput): string {
|
|
|
83
85
|
for (const note of input.notes) {
|
|
84
86
|
if (note.updated > max) max = note.updated;
|
|
85
87
|
}
|
|
88
|
+
for (const artifact of input.artifacts ?? []) {
|
|
89
|
+
if (artifact.updated > max) max = artifact.updated;
|
|
90
|
+
}
|
|
86
91
|
const repoStamp = input.repository?.index.updated ?? "";
|
|
87
92
|
if (repoStamp > max) max = repoStamp;
|
|
88
93
|
if (input.summaries) {
|
|
@@ -126,12 +131,18 @@ function buildVaultSide(
|
|
|
126
131
|
root: input.vault.root,
|
|
127
132
|
notes: String(input.vault.noteCount),
|
|
128
133
|
};
|
|
134
|
+
const artifacts = input.artifacts ?? [];
|
|
135
|
+
if ((input.vault.artifactCount ?? artifacts.length) > 0) {
|
|
136
|
+
vaultDetail.artifacts = String(input.vault.artifactCount ?? artifacts.length);
|
|
137
|
+
}
|
|
129
138
|
if (truncated) {
|
|
130
139
|
vaultDetail.warning = `Graph shows the ${maxNotes} most recent notes — the vault holds ${input.vault.noteCount}. Wiki-links to older notes are omitted.`;
|
|
131
140
|
}
|
|
132
141
|
nodes.push({ id: "vault", kind: "vault", label: "Vault", provenance: null, detail: vaultDetail });
|
|
133
142
|
|
|
134
143
|
const keptSlugs = new Set(kept.map((n) => n.slug));
|
|
144
|
+
const artifactSlugs = new Set(artifacts.map((a) => a.slug));
|
|
145
|
+
const artifactLinks = new Map<string, number>();
|
|
135
146
|
|
|
136
147
|
// Nested notes nest under synthesized folder nodes so the vault tree groups
|
|
137
148
|
// them the way the repository tree groups directories. Ids are prefixed
|
|
@@ -143,6 +154,7 @@ function buildVaultSide(
|
|
|
143
154
|
...new Set([
|
|
144
155
|
...(input.vault.folders ?? []),
|
|
145
156
|
...kept.map((n) => n.slug.split("/").slice(0, -1).join("/")),
|
|
157
|
+
...artifacts.map((a) => a.slug.split("/").slice(0, -1).join("/")),
|
|
146
158
|
]),
|
|
147
159
|
]
|
|
148
160
|
.filter((d) => d.length > 0)
|
|
@@ -179,7 +191,7 @@ function buildVaultSide(
|
|
|
179
191
|
// The names, not just the count (§4.2). `detail` keeps carrying the count
|
|
180
192
|
// because it is what the TUI's side panel prints; the structured targets
|
|
181
193
|
// go on the model, where a UI can turn them into ghost nodes.
|
|
182
|
-
const dangling = links.filter((slug) => !keptSlugs.has(slug));
|
|
194
|
+
const dangling = links.filter((slug) => !keptSlugs.has(slug) && !artifactSlugs.has(slug));
|
|
183
195
|
if (dangling.length > 0) {
|
|
184
196
|
detail["dangling links"] = String(dangling.length);
|
|
185
197
|
danglingLinks[note.slug] = dangling;
|
|
@@ -191,6 +203,10 @@ function buildVaultSide(
|
|
|
191
203
|
for (const target of resolved) {
|
|
192
204
|
edges.push({ source: `note:${note.slug}`, target: `note:${target}`, kind: "links-to" });
|
|
193
205
|
}
|
|
206
|
+
for (const target of links.filter((slug) => artifactSlugs.has(slug))) {
|
|
207
|
+
edges.push({ source: `note:${note.slug}`, target: `artifact:${target}`, kind: "links-to" });
|
|
208
|
+
artifactLinks.set(target, (artifactLinks.get(target) ?? 0) + 1);
|
|
209
|
+
}
|
|
194
210
|
// A note body naming a repo path → `mentions` (§4.4). Emitted after the
|
|
195
211
|
// wiki-links so a note's edges read vault-ward first, then code-ward, and
|
|
196
212
|
// only for paths that are already nodes — `paths` is built from the repo
|
|
@@ -200,6 +216,21 @@ function buildVaultSide(
|
|
|
200
216
|
edges.push({ source: `note:${note.slug}`, target, kind: "mentions" });
|
|
201
217
|
}
|
|
202
218
|
}
|
|
219
|
+
for (const artifact of artifacts) {
|
|
220
|
+
const dir = artifact.slug.split("/").slice(0, -1).join("/");
|
|
221
|
+
const parent = (dir.length > 0 && folderIds.get(dir)) || "vault";
|
|
222
|
+
const detail: Record<string, string> = {
|
|
223
|
+
path: artifact.slug,
|
|
224
|
+
title: artifact.title,
|
|
225
|
+
updated: artifact.updated,
|
|
226
|
+
size: `${artifact.size} bytes`,
|
|
227
|
+
};
|
|
228
|
+
if (artifact.description) detail.description = artifact.description;
|
|
229
|
+
const links = artifactLinks.get(artifact.slug) ?? 0;
|
|
230
|
+
if (links > 0) detail["link references"] = String(links);
|
|
231
|
+
nodes.push({ id: `artifact:${artifact.slug}`, kind: "file", label: artifact.title, provenance: null, detail });
|
|
232
|
+
edges.push({ source: parent, target: `artifact:${artifact.slug}`, kind: "contains" });
|
|
233
|
+
}
|
|
203
234
|
return [...keptSlugs];
|
|
204
235
|
}
|
|
205
236
|
|
|
@@ -101,11 +101,18 @@ export async function readRepositorySide(
|
|
|
101
101
|
* a third readdir) is now N reads and one readdir (weave-workspace §4.1).
|
|
102
102
|
*/
|
|
103
103
|
export async function buildCurrentGraph(cwd: string, vaultRoot: string = resolveVaultRoot()): Promise<GraphModel> {
|
|
104
|
-
const { notes, fileCount, folders } = await readVault(vaultRoot);
|
|
104
|
+
const { notes, fileCount, folders, artifacts, artifactCount } = await readVault(vaultRoot);
|
|
105
105
|
|
|
106
106
|
const input: BuildGraphInput = {
|
|
107
|
-
vault: {
|
|
107
|
+
vault: {
|
|
108
|
+
root: vaultRoot,
|
|
109
|
+
exists: true,
|
|
110
|
+
noteCount: fileCount,
|
|
111
|
+
...(folders ? { folders } : {}),
|
|
112
|
+
...(artifactCount ? { artifactCount } : {}),
|
|
113
|
+
},
|
|
108
114
|
notes: notes.slice(0, DEFAULT_MAX_NOTES),
|
|
115
|
+
...(artifacts ? { artifacts } : {}),
|
|
109
116
|
repository: null,
|
|
110
117
|
};
|
|
111
118
|
|
|
@@ -23,7 +23,11 @@ export function extractWikilinks(body: string): string[] {
|
|
|
23
23
|
for (const match of body.matchAll(WIKILINK_RE)) {
|
|
24
24
|
const raw = (match[1] ?? "").trim();
|
|
25
25
|
if (raw.length === 0) continue;
|
|
26
|
-
|
|
26
|
+
// HTML artifacts are addressed by their vault-relative filename; unlike a
|
|
27
|
+
// Markdown note, the extension is part of the stable identity.
|
|
28
|
+
const slug = /\.html?$/i.test(raw)
|
|
29
|
+
? raw.replace(/\\/g, "/").replace(/^\.\//, "")
|
|
30
|
+
: raw.split("/").map((part) => slugify(part)).join("/");
|
|
27
31
|
if (seen.has(slug)) continue;
|
|
28
32
|
seen.add(slug);
|
|
29
33
|
out.push(slug);
|
package/src/core/index.ts
CHANGED
|
@@ -21,11 +21,15 @@ export {
|
|
|
21
21
|
finalizeNote,
|
|
22
22
|
formatNote,
|
|
23
23
|
formatRawAppend,
|
|
24
|
+
getHtmlArtifact,
|
|
24
25
|
getNote,
|
|
25
26
|
listNotes,
|
|
27
|
+
parseHtmlArtifact,
|
|
28
|
+
resolveHtmlPath,
|
|
26
29
|
resolveNotePath,
|
|
27
30
|
searchNotes,
|
|
28
31
|
} from "./vault";
|
|
32
|
+
export type { HtmlArtifact } from "./types";
|
|
29
33
|
export { withMutationQueue } from "./mutex";
|
|
30
34
|
export { formatDashboard, formatStatusLine, getWorkspaceStatus } from "./workspace";
|
|
31
35
|
export { WorkspaceCache } from "./cache/workspace";
|
package/src/core/types.ts
CHANGED
|
@@ -69,6 +69,17 @@ export interface Note extends NoteMeta {
|
|
|
69
69
|
frontMatter?: NoteFrontMatter;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** A standalone HTML artifact discovered under the vault's notes directory. */
|
|
73
|
+
export interface HtmlArtifact {
|
|
74
|
+
/** Vault-relative path, including the `.html`/`.htm` extension. */
|
|
75
|
+
slug: string;
|
|
76
|
+
title: string;
|
|
77
|
+
description: string;
|
|
78
|
+
/** ISO mtime and byte size, used for graph display and cache invalidation. */
|
|
79
|
+
updated: string;
|
|
80
|
+
size: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
72
83
|
/** Summary of one note for list/search output. */
|
|
73
84
|
export interface NoteSummary extends NoteMeta {
|
|
74
85
|
slug: string;
|
|
@@ -175,6 +186,8 @@ export interface VaultStatus {
|
|
|
175
186
|
exists: boolean;
|
|
176
187
|
noteCount: number;
|
|
177
188
|
folders?: string[];
|
|
189
|
+
/** Number of discovered HTML/HTM artifacts. */
|
|
190
|
+
artifactCount?: number;
|
|
178
191
|
}
|
|
179
192
|
|
|
180
193
|
/** Status of the repository half of the workspace. */
|
package/src/core/vault.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
|
-
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative } from "node:path";
|
|
4
4
|
import {
|
|
5
5
|
parseFrontMatter,
|
|
6
6
|
parseNoteFile,
|
|
7
7
|
serializeNote,
|
|
8
|
+
unquoteField,
|
|
8
9
|
} from "./frontmatter";
|
|
9
10
|
import { withMutationQueue } from "./mutex";
|
|
10
11
|
import { NOTES_DIR, OKF_MANIFEST } from "./paths";
|
|
11
12
|
import { slugify, uniqueSlug } from "./slug";
|
|
12
13
|
import type {
|
|
13
14
|
Note,
|
|
15
|
+
HtmlArtifact,
|
|
14
16
|
NoteFrontMatter,
|
|
15
17
|
NoteMeta,
|
|
16
18
|
NoteSearchHit,
|
|
@@ -358,7 +360,7 @@ async function listNoteFiles(root: string): Promise<string[]> {
|
|
|
358
360
|
for (const entry of entries) {
|
|
359
361
|
if (entry.isDirectory()) {
|
|
360
362
|
await walk(prefix.length > 0 ? `${prefix}/${entry.name}` : entry.name);
|
|
361
|
-
} else if (entry.isFile() && entry.name
|
|
363
|
+
} else if (entry.isFile() && isVaultArtifact(entry.name)) {
|
|
362
364
|
out.push(prefix.length > 0 ? `${prefix}/${entry.name}` : entry.name);
|
|
363
365
|
}
|
|
364
366
|
}
|
|
@@ -367,6 +369,82 @@ async function listNoteFiles(root: string): Promise<string[]> {
|
|
|
367
369
|
return out.sort();
|
|
368
370
|
}
|
|
369
371
|
|
|
372
|
+
function isMarkdown(path: string): boolean {
|
|
373
|
+
return path.endsWith(".md");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isHtml(path: string): boolean {
|
|
377
|
+
return path.toLowerCase().endsWith(".html") || path.toLowerCase().endsWith(".htm");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function isVaultArtifact(path: string): boolean {
|
|
381
|
+
return isMarkdown(path) || isHtml(path);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Resolve a vault-relative HTML path without allowing traversal. */
|
|
385
|
+
export function resolveHtmlPath(root: string, slug: string): string | null {
|
|
386
|
+
if (slug.trim().length === 0 || !isHtml(slug)) return null;
|
|
387
|
+
const notesDir = join(root, NOTES_DIR);
|
|
388
|
+
const candidate = join(notesDir, slug);
|
|
389
|
+
const rel = relative(notesDir, candidate);
|
|
390
|
+
return rel.startsWith("..") || isAbsolute(rel) || rel.length === 0 ? null : candidate;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function decodeHtml(value: string): string {
|
|
394
|
+
return value
|
|
395
|
+
.replace(/&/gi, "&")
|
|
396
|
+
.replace(/</gi, "<")
|
|
397
|
+
.replace(/>/gi, ">")
|
|
398
|
+
.replace(/"/gi, '"')
|
|
399
|
+
.replace(/'|'/gi, "'")
|
|
400
|
+
.replace(/&#(\d+);/g, (_, n: string) => String.fromCodePoint(Number(n)));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function htmlTagValue(text: string, tag: string): string {
|
|
404
|
+
const match = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i").exec(text);
|
|
405
|
+
return match?.[1] === undefined ? "" : decodeHtml(match[1].replace(/<[^>]+>/g, "").trim());
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function htmlMeta(text: string, name: string): string {
|
|
409
|
+
for (const match of text.matchAll(/<meta\b([^>]*)>/gi)) {
|
|
410
|
+
const attrs = match[1] ?? "";
|
|
411
|
+
const get = (key: string): string => {
|
|
412
|
+
const value = new RegExp(`\\b${key}\\s*=\\s*([\\\"'])(.*?)\\1`, "i").exec(attrs);
|
|
413
|
+
return value?.[2] ? decodeHtml(value[2].trim()) : "";
|
|
414
|
+
};
|
|
415
|
+
if (get("name").toLowerCase() === name.toLowerCase()) return get("content");
|
|
416
|
+
}
|
|
417
|
+
return "";
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Parse metadata from an HTML file without attempting to interpret its body. */
|
|
421
|
+
export function parseHtmlArtifact(slug: string, text: string, updated = "", size = Buffer.byteLength(text)): HtmlArtifact {
|
|
422
|
+
const comment = /<!--[\s\S]*?---\n([\s\S]*?)\n---[\s\S]*?-->/m.exec(text);
|
|
423
|
+
const fields = comment ? parseFrontMatter(`---\n${comment[1]}\n---\n`)?.fields : undefined;
|
|
424
|
+
const fallback = basename(slug).replace(/\.html?$/i, "").replace(/[-_]+/g, " ");
|
|
425
|
+
return {
|
|
426
|
+
slug,
|
|
427
|
+
title: fields?.get("title") ? unquoteField(fields.get("title")!) : htmlTagValue(text, "title") || fallback,
|
|
428
|
+
description: fields?.get("description")
|
|
429
|
+
? unquoteField(fields.get("description")!)
|
|
430
|
+
: htmlMeta(text, "description"),
|
|
431
|
+
updated,
|
|
432
|
+
size,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Read one HTML artifact by its vault-relative path. */
|
|
437
|
+
export async function getHtmlArtifact(root: string, slug: string): Promise<HtmlArtifact | null> {
|
|
438
|
+
const path = resolveHtmlPath(root, slug);
|
|
439
|
+
if (!path) return null;
|
|
440
|
+
try {
|
|
441
|
+
const [text, st] = await Promise.all([fs.readFile(path, "utf8"), fs.stat(path)]);
|
|
442
|
+
return parseHtmlArtifact(slug, text, st.mtime.toISOString(), st.size);
|
|
443
|
+
} catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
370
448
|
export function summarizeNote(note: Note): NoteSummary {
|
|
371
449
|
const { body, frontMatter, ...rest } = note;
|
|
372
450
|
void frontMatter;
|
|
@@ -422,21 +500,33 @@ export interface VaultSnapshot {
|
|
|
422
500
|
fileCount: number;
|
|
423
501
|
/** Subdirectories present in <vault>/notes/, including empty ones. */
|
|
424
502
|
folders?: string[];
|
|
503
|
+
/** Readable standalone HTML/HTM artifacts. */
|
|
504
|
+
artifacts?: HtmlArtifact[];
|
|
505
|
+
/** Number of HTML/HTM files present, including malformed files. */
|
|
506
|
+
artifactCount?: number;
|
|
425
507
|
}
|
|
426
508
|
|
|
427
509
|
/** Read the whole vault in one pass: one readdir, one read per note. */
|
|
428
510
|
export async function readVault(root: string): Promise<VaultSnapshot> {
|
|
429
511
|
const [files, folders] = await Promise.all([listNoteFiles(root), listNoteFolders(root)]);
|
|
430
512
|
const notes: Note[] = [];
|
|
513
|
+
const artifacts: HtmlArtifact[] = [];
|
|
431
514
|
for (const file of files) {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
515
|
+
if (isMarkdown(file)) {
|
|
516
|
+
const note = await getNote(root, file.slice(0, -".md".length));
|
|
517
|
+
if (note) notes.push(note);
|
|
518
|
+
} else if (isHtml(file)) {
|
|
519
|
+
const artifact = await getHtmlArtifact(root, file);
|
|
520
|
+
if (artifact) artifacts.push(artifact);
|
|
521
|
+
}
|
|
435
522
|
}
|
|
523
|
+
const artifactCount = files.filter(isHtml).length;
|
|
436
524
|
return {
|
|
437
525
|
notes: notes.sort(byUpdatedDesc),
|
|
438
|
-
fileCount: files.length,
|
|
526
|
+
fileCount: files.filter(isMarkdown).length,
|
|
439
527
|
...(folders.length > 0 ? { folders } : {}),
|
|
528
|
+
...(artifacts.length > 0 ? { artifacts: artifacts.sort(byUpdatedDesc) } : {}),
|
|
529
|
+
...(artifactCount > 0 ? { artifactCount } : {}),
|
|
440
530
|
};
|
|
441
531
|
}
|
|
442
532
|
|
|
@@ -465,7 +555,7 @@ export async function statNotes(root: string): Promise<NoteStat[]> {
|
|
|
465
555
|
const path = join(dir, file);
|
|
466
556
|
try {
|
|
467
557
|
const st = await fs.stat(path);
|
|
468
|
-
return { slug: file.slice(0, -".md".length), path, mtimeMs: st.mtimeMs, size: st.size };
|
|
558
|
+
return { slug: isMarkdown(file) ? file.slice(0, -".md".length) : file, path, mtimeMs: st.mtimeMs, size: st.size };
|
|
469
559
|
} catch {
|
|
470
560
|
return null; // raced a delete
|
|
471
561
|
}
|
|
@@ -480,7 +570,7 @@ export async function listNotes(root: string): Promise<NoteSummary[]> {
|
|
|
480
570
|
}
|
|
481
571
|
|
|
482
572
|
export async function noteCount(root: string): Promise<number> {
|
|
483
|
-
return (await listNoteFiles(root)).length;
|
|
573
|
+
return (await listNoteFiles(root)).filter(isMarkdown).length;
|
|
484
574
|
}
|
|
485
575
|
|
|
486
576
|
/**
|
package/src/core/view/detail.ts
CHANGED
|
@@ -35,7 +35,10 @@ export interface DetailModel {
|
|
|
35
35
|
|
|
36
36
|
/** Ordered meta keys shown in the detail header. */
|
|
37
37
|
const META_ORDER = [
|
|
38
|
+
"title",
|
|
39
|
+
"description",
|
|
38
40
|
"path",
|
|
41
|
+
"size",
|
|
39
42
|
"slug",
|
|
40
43
|
"source",
|
|
41
44
|
"updated",
|
|
@@ -55,6 +58,7 @@ const META_ORDER = [
|
|
|
55
58
|
"summarized at",
|
|
56
59
|
"summary",
|
|
57
60
|
"dangling links",
|
|
61
|
+
"link references",
|
|
58
62
|
"warning",
|
|
59
63
|
"stale",
|
|
60
64
|
"preview",
|