pi-weave 0.1.16 → 0.1.17
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/index.ts +5 -0
- package/src/core/vault.ts +105 -14
- package/src/web/client/api.ts +21 -1
- package/src/web/client/dist/app.js +48 -42
- package/src/web/client/graph/column.model.ts +5 -3
- package/src/web/client/note/note.model.ts +22 -3
- package/src/web/client/shell/Columns.tsx +2 -1
- package/src/web/client/shell/Shell.tsx +1 -0
- package/src/web/client/shell/theme.ts +6 -0
- package/src/web/client/tree/Tree.tsx +45 -4
- package/src/web/client/tree/tree.model.ts +18 -0
- package/src/web/server/routes.ts +63 -2
- package/src/web/shared/wire.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-weave",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
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,
|
package/src/core/index.ts
CHANGED
|
@@ -17,6 +17,8 @@ export { runDeepScan, type DeepScanOptions, type DeepScanResult, type SummarizeF
|
|
|
17
17
|
export {
|
|
18
18
|
addNote,
|
|
19
19
|
appendToNote,
|
|
20
|
+
deleteFolder,
|
|
21
|
+
deleteNote,
|
|
20
22
|
extractRawTail,
|
|
21
23
|
finalizeNote,
|
|
22
24
|
formatNote,
|
|
@@ -24,7 +26,10 @@ export {
|
|
|
24
26
|
getHtmlArtifact,
|
|
25
27
|
getNote,
|
|
26
28
|
listNotes,
|
|
29
|
+
moveNote,
|
|
27
30
|
parseHtmlArtifact,
|
|
31
|
+
renameFolder,
|
|
32
|
+
renameNote,
|
|
28
33
|
resolveHtmlPath,
|
|
29
34
|
resolveNotePath,
|
|
30
35
|
searchNotes,
|
package/src/core/vault.ts
CHANGED
|
@@ -99,7 +99,7 @@ export function resolveNotePath(root: string, slug: string): string | null {
|
|
|
99
99
|
*/
|
|
100
100
|
export async function addNote(root: string, input: AddNoteInput): Promise<Note> {
|
|
101
101
|
await ensureVault(root);
|
|
102
|
-
return
|
|
102
|
+
return withVaultLock(root, async () => {
|
|
103
103
|
const now = (input.now ?? new Date()).toISOString();
|
|
104
104
|
const base = slugify(input.title);
|
|
105
105
|
const slug = uniqueSlug(base, (candidate) => existsSync(notePath(root, candidate)));
|
|
@@ -183,18 +183,12 @@ async function writeNote(
|
|
|
183
183
|
const LOCK_NS = "vault:note:";
|
|
184
184
|
|
|
185
185
|
/**
|
|
186
|
-
* Run `task` with exclusive access to
|
|
187
|
-
*
|
|
188
|
-
* Paths are locked in a fixed order so callers that need multiple locks cannot
|
|
189
|
-
* deadlock. Every mutation in this module goes through here, keeping appends
|
|
190
|
-
* and finalization serialized with one another.
|
|
186
|
+
* Run `task` with exclusive mutation access to this vault.
|
|
191
187
|
*/
|
|
192
|
-
function
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
task,
|
|
197
|
-
)();
|
|
188
|
+
function withVaultLock<T>(root: string, task: () => Promise<T>): Promise<T> {
|
|
189
|
+
// ponytail: one vault-wide lock is enough for a local notepad; use
|
|
190
|
+
// hierarchical locks only if independent-note write throughput matters.
|
|
191
|
+
return withMutationQueue(LOCK_NS + join(root, NOTES_DIR), task);
|
|
198
192
|
}
|
|
199
193
|
|
|
200
194
|
/** Options for {@link appendToNote}. */
|
|
@@ -219,7 +213,7 @@ export async function appendToNote(
|
|
|
219
213
|
): Promise<Note | null> {
|
|
220
214
|
const path = resolveNotePath(root, slug);
|
|
221
215
|
if (!path) return null;
|
|
222
|
-
return
|
|
216
|
+
return withVaultLock(root, async () => {
|
|
223
217
|
const note = await getNote(root, slug);
|
|
224
218
|
if (!note) return null;
|
|
225
219
|
const tail = extractRawTail(note.body);
|
|
@@ -326,7 +320,7 @@ export async function finalizeNote(
|
|
|
326
320
|
): Promise<Note | null> {
|
|
327
321
|
const path = resolveNotePath(root, slug);
|
|
328
322
|
if (!path) return null;
|
|
329
|
-
return
|
|
323
|
+
return withVaultLock(root, async () => {
|
|
330
324
|
const note = await getNote(root, slug);
|
|
331
325
|
if (!note) return null;
|
|
332
326
|
const rawTail = extractRawTail(note.body);
|
|
@@ -347,6 +341,103 @@ export async function finalizeNote(
|
|
|
347
341
|
});
|
|
348
342
|
}
|
|
349
343
|
|
|
344
|
+
export type VaultMutationResult =
|
|
345
|
+
| { ok: true; slug?: string; path?: string }
|
|
346
|
+
| { ok: false; reason: "missing" | "collision" | "invalid" };
|
|
347
|
+
|
|
348
|
+
function resolveFolderPath(root: string, folder: string): string | null {
|
|
349
|
+
const parts = folder.split("/");
|
|
350
|
+
if (parts.length === 0 || parts.some((part) => part === "" || part === "." || part === "..")) return null;
|
|
351
|
+
const notesDir = join(root, NOTES_DIR);
|
|
352
|
+
const candidate = join(notesDir, ...parts);
|
|
353
|
+
const rel = relative(notesDir, candidate);
|
|
354
|
+
return rel.startsWith("..") || isAbsolute(rel) || rel.length === 0 ? null : candidate;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
358
|
+
try {
|
|
359
|
+
return (await fs.stat(path)).isDirectory();
|
|
360
|
+
} catch {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Rename a note in place and keep its front-matter title in sync. */
|
|
366
|
+
export async function renameNote(root: string, slug: string, name: string, now = new Date()): Promise<VaultMutationResult> {
|
|
367
|
+
const from = resolveNotePath(root, slug);
|
|
368
|
+
const title = name.trim();
|
|
369
|
+
if (from === null || title === "") return { ok: false, reason: "invalid" };
|
|
370
|
+
const parent = slug.split("/").slice(0, -1).join("/");
|
|
371
|
+
const target = [...(parent === "" ? [] : [parent]), slugify(title)].join("/");
|
|
372
|
+
const to = resolveNotePath(root, target);
|
|
373
|
+
if (to === null) return { ok: false, reason: "invalid" };
|
|
374
|
+
return withVaultLock(root, async () => {
|
|
375
|
+
const note = await getNote(root, slug);
|
|
376
|
+
if (note === null) return { ok: false, reason: "missing" };
|
|
377
|
+
if (from !== to && await exists(to)) return { ok: false, reason: "collision" };
|
|
378
|
+
if (from !== to) await fs.rename(from, to);
|
|
379
|
+
await writeNote(to, target, { ...note, title, updated: now.toISOString() }, note.body, note.frontMatter);
|
|
380
|
+
return { ok: true, slug: target };
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Move a note to an existing vault folder, or to the vault root with `null`. */
|
|
385
|
+
export async function moveNote(root: string, slug: string, folder: string | null): Promise<VaultMutationResult> {
|
|
386
|
+
const from = resolveNotePath(root, slug);
|
|
387
|
+
if (from === null) return { ok: false, reason: "invalid" };
|
|
388
|
+
const targetDir = folder === null ? join(root, NOTES_DIR) : resolveFolderPath(root, folder);
|
|
389
|
+
if (targetDir === null || !(await isDirectory(targetDir))) return { ok: false, reason: "missing" };
|
|
390
|
+
const target = folder === null ? basename(slug) : `${folder}/${basename(slug)}`;
|
|
391
|
+
const to = resolveNotePath(root, target);
|
|
392
|
+
if (to === null) return { ok: false, reason: "invalid" };
|
|
393
|
+
return withVaultLock(root, async () => {
|
|
394
|
+
if (!(await exists(from))) return { ok: false, reason: "missing" };
|
|
395
|
+
if (from === to) return { ok: true, slug };
|
|
396
|
+
if (await exists(to)) return { ok: false, reason: "collision" };
|
|
397
|
+
await fs.rename(from, to);
|
|
398
|
+
return { ok: true, slug: target };
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Permanently delete one note. */
|
|
403
|
+
export async function deleteNote(root: string, slug: string): Promise<VaultMutationResult> {
|
|
404
|
+
const path = resolveNotePath(root, slug);
|
|
405
|
+
if (path === null) return { ok: false, reason: "invalid" };
|
|
406
|
+
return withVaultLock(root, async () => {
|
|
407
|
+
if (!(await exists(path))) return { ok: false, reason: "missing" };
|
|
408
|
+
await fs.unlink(path);
|
|
409
|
+
return { ok: true };
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Rename a vault folder without changing its parent. */
|
|
414
|
+
export async function renameFolder(root: string, folder: string, name: string): Promise<VaultMutationResult> {
|
|
415
|
+
const from = resolveFolderPath(root, folder);
|
|
416
|
+
const title = name.trim();
|
|
417
|
+
if (from === null || title === "") return { ok: false, reason: "invalid" };
|
|
418
|
+
const parent = folder.split("/").slice(0, -1).join("/");
|
|
419
|
+
const target = [...(parent === "" ? [] : [parent]), slugify(title)].join("/");
|
|
420
|
+
const to = resolveFolderPath(root, target);
|
|
421
|
+
if (to === null) return { ok: false, reason: "invalid" };
|
|
422
|
+
return withVaultLock(root, async () => {
|
|
423
|
+
if (!(await isDirectory(from))) return { ok: false, reason: "missing" };
|
|
424
|
+
if (from !== to && await exists(to)) return { ok: false, reason: "collision" };
|
|
425
|
+
if (from !== to) await fs.rename(from, to);
|
|
426
|
+
return { ok: true, path: target };
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Permanently delete a vault folder and its contents. */
|
|
431
|
+
export async function deleteFolder(root: string, folder: string): Promise<VaultMutationResult> {
|
|
432
|
+
const path = resolveFolderPath(root, folder);
|
|
433
|
+
if (path === null) return { ok: false, reason: "invalid" };
|
|
434
|
+
return withVaultLock(root, async () => {
|
|
435
|
+
if (!(await isDirectory(path))) return { ok: false, reason: "missing" };
|
|
436
|
+
await fs.rm(path, { recursive: true });
|
|
437
|
+
return { ok: true };
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
350
441
|
async function listVaultEntries(root: string): Promise<{ files: string[]; folders: string[] }> {
|
|
351
442
|
const dir = join(root, NOTES_DIR);
|
|
352
443
|
const files: string[] = [];
|
package/src/web/client/api.ts
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* fortnight.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
import type { GraphPayload, NotePayload, OpenResult, SearchPayload, ViewNote } from "../shared/wire";
|
|
39
|
+
import type { GraphPayload, MutationResult, NotePayload, OpenResult, SearchPayload, ViewNote } from "../shared/wire";
|
|
40
40
|
|
|
41
41
|
// --- the injected HTTP port ------------------------------------------------------
|
|
42
42
|
|
|
@@ -229,6 +229,10 @@ export function isOpenResult(value: unknown): value is OpenResult {
|
|
|
229
229
|
return isObject(value) && typeof value["opened"] === "boolean";
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
export function isMutationResult(value: unknown): value is MutationResult {
|
|
233
|
+
return isObject(value) && value["ok"] === true && (value["id"] === undefined || typeof value["id"] === "string");
|
|
234
|
+
}
|
|
235
|
+
|
|
232
236
|
// --- routes --------------------------------------------------------------------------
|
|
233
237
|
|
|
234
238
|
/**
|
|
@@ -308,3 +312,19 @@ export function openNote(fetchImpl: FetchLike, slug: string): Promise<ApiResult<
|
|
|
308
312
|
body: JSON.stringify({ slug }),
|
|
309
313
|
});
|
|
310
314
|
}
|
|
315
|
+
|
|
316
|
+
function mutation(fetchImpl: FetchLike, url: string, method: string, body?: unknown): Promise<ApiResult<MutationResult>> {
|
|
317
|
+
return request(fetchImpl, url, isMutationResult, {
|
|
318
|
+
method,
|
|
319
|
+
...(body === undefined ? {} : { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const noteUrl = (slug: string, suffix = ""): string => `/api/note/${encodeURIComponent(slug)}${suffix}`;
|
|
324
|
+
const folderUrl = (path: string, suffix = ""): string => `/api/folder/${path.split("/").map(encodeURIComponent).join("/")}${suffix}`;
|
|
325
|
+
|
|
326
|
+
export const renameNote = (fetchImpl: FetchLike, slug: string, name: string) => mutation(fetchImpl, noteUrl(slug, "/rename"), "POST", { name });
|
|
327
|
+
export const moveNote = (fetchImpl: FetchLike, slug: string, folder: string | null) => mutation(fetchImpl, noteUrl(slug, "/move"), "POST", { folder });
|
|
328
|
+
export const deleteNote = (fetchImpl: FetchLike, slug: string) => mutation(fetchImpl, noteUrl(slug), "DELETE");
|
|
329
|
+
export const renameFolder = (fetchImpl: FetchLike, path: string, name: string) => mutation(fetchImpl, folderUrl(path, "/rename"), "POST", { name });
|
|
330
|
+
export const deleteFolder = (fetchImpl: FetchLike, path: string) => mutation(fetchImpl, folderUrl(path), "DELETE");
|