pi-weave 0.3.12 → 0.3.13

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-weave",
3
- "version": "0.3.12",
3
+ "version": "0.3.13",
4
4
  "description": "Local-first Markdown memory and knowledge graph for Pi and OpenCode: preserve decisions, recall sessions, and explore notes with code.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
package/src/core/index.ts CHANGED
@@ -64,6 +64,9 @@ export {
64
64
  getNote,
65
65
  listNotes,
66
66
  moveNote,
67
+ moveHtmlArtifact,
68
+ renameHtmlArtifact,
69
+ deleteHtmlArtifact,
67
70
  parseHtmlArtifact,
68
71
  renameFolder,
69
72
  readVault,
package/src/core/vault.ts CHANGED
@@ -764,6 +764,80 @@ export async function moveNote(root: string, slug: string, folder: string | null
764
764
  });
765
765
  }
766
766
 
767
+ /**
768
+ * Move a standalone HTML artifact to an existing vault folder, or to the vault
769
+ * root with `null`. Notes that link to it by path follow it, as they do a note.
770
+ */
771
+ export async function moveHtmlArtifact(root: string, slug: string, folder: string | null): Promise<VaultMutationResult> {
772
+ const from = resolveHtmlPath(root, slug);
773
+ if (from === null) return { ok: false, reason: "invalid" };
774
+ const targetDir = folder === null ? join(root, NOTES_DIR) : resolveFolderPath(root, folder);
775
+ if (targetDir === null || !(await isDirectory(targetDir))) return { ok: false, reason: "missing" };
776
+ const target = folder === null ? basename(slug) : `${folder}/${basename(slug)}`;
777
+ const to = resolveHtmlPath(root, target)!;
778
+ return withVaultLock(root, async () => {
779
+ if (!(await exists(from))) return { ok: false, reason: "missing" };
780
+ if (from === to) return { ok: true, slug };
781
+ if (await exists(to)) return { ok: false, reason: "collision" };
782
+ await fs.rename(from, to);
783
+ await repointBacklinks(root, new Map([[slug, target]]));
784
+ return { ok: true, slug: target };
785
+ });
786
+ }
787
+
788
+ /**
789
+ * Rename a standalone HTML artifact in place. The new name sets the file name
790
+ * (slugified, extension kept) and the title the workspace shows, which lives
791
+ * in a leading `<!-- --- title: ... --- -->` comment or in `<title>`; a file
792
+ * with neither takes its title from the file name. Links follow the file.
793
+ */
794
+ export async function renameHtmlArtifact(root: string, slug: string, name: string): Promise<VaultMutationResult> {
795
+ const from = resolveHtmlPath(root, slug);
796
+ const title = name.trim().replace(/\.html?$/i, "").trim();
797
+ if (from === null || title === "") return { ok: false, reason: "invalid" };
798
+ const ext = /\.html?$/i.exec(slug)![0];
799
+ const parent = slug.split("/").slice(0, -1).join("/");
800
+ const target = [...(parent === "" ? [] : [parent]), `${slugify(title)}${ext}`].join("/");
801
+ const to = resolveHtmlPath(root, target)!;
802
+ return withVaultLock(root, async () => {
803
+ let text: string;
804
+ try {
805
+ text = await fs.readFile(from, "utf8");
806
+ } catch {
807
+ return { ok: false, reason: "missing" };
808
+ }
809
+ if (from !== to && await exists(to)) return { ok: false, reason: "collision" };
810
+ await fs.writeFile(from, retitleHtml(text, title), "utf8");
811
+ if (from === to) return { ok: true, slug };
812
+ await fs.rename(from, to);
813
+ await repointBacklinks(root, new Map([[slug, target]]));
814
+ return { ok: true, slug: target };
815
+ });
816
+ }
817
+
818
+ /** Set the title `parseHtmlArtifact` reads, leaving every other byte alone. */
819
+ function retitleHtml(text: string, title: string): string {
820
+ const comment = /<!--[\s\S]*?---\n([\s\S]*?)\n---[\s\S]*?-->/m.exec(text);
821
+ if (comment !== null && /^title:.*$/m.test(comment[1]!)) {
822
+ // `-->` would close the comment early.
823
+ const fields = comment[1]!.replace(/^title:.*$/m, () => `title: ${quoteField(title.replace(/-->/g, "->"))}`);
824
+ return text.slice(0, comment.index) + comment[0].replace(comment[1]!, () => fields) + text.slice(comment.index + comment[0].length);
825
+ }
826
+ const escaped = title.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
827
+ return text.replace(/(<title\b[^>]*>)[\s\S]*?(<\/title>)/i, (_, open: string, close: string) => `${open}${escaped}${close}`);
828
+ }
829
+
830
+ /** Permanently delete one standalone HTML artifact. */
831
+ export async function deleteHtmlArtifact(root: string, slug: string): Promise<VaultMutationResult> {
832
+ const path = resolveHtmlPath(root, slug);
833
+ if (path === null) return { ok: false, reason: "invalid" };
834
+ return withVaultLock(root, async () => {
835
+ if (!(await exists(path))) return { ok: false, reason: "missing" };
836
+ await fs.unlink(path);
837
+ return { ok: true };
838
+ });
839
+ }
840
+
767
841
  /** Permanently delete one note. */
768
842
  export async function deleteNote(root: string, slug: string): Promise<VaultMutationResult> {
769
843
  const path = resolveNotePath(root, slug);
@@ -789,12 +863,12 @@ export async function renameFolder(root: string, folder: string, name: string):
789
863
  if (!(await isDirectory(from))) return { ok: false, reason: "missing" };
790
864
  if (from !== to && await exists(to)) return { ok: false, reason: "collision" };
791
865
  if (from === to) return { ok: true, path: target };
792
- // Every note under the folder changes slug, so the whole subtree's
793
- // backlinks move with it — collected before the rename, while the old
794
- // paths still exist.
866
+ // Every note and HTML file under the folder changes slug, so the whole
867
+ // subtree's backlinks move with it — collected before the rename, while
868
+ // the old paths still exist. HTML slugs keep their extension.
795
869
  const moved = (await listNoteFiles(root))
796
- .filter((f) => isMarkdown(f) && f.startsWith(`${folder}/`))
797
- .map((f) => f.slice(0, -".md".length));
870
+ .filter((f) => f.startsWith(`${folder}/`))
871
+ .map((f) => isMarkdown(f) ? f.slice(0, -".md".length) : f);
798
872
  await fs.rename(from, to);
799
873
  await moveHistoryFolder(root, folder, target);
800
874
  await repointBacklinks(root, new Map(moved.map((s) => [s, `${target}/${s.slice(folder.length + 1)}`])));
@@ -273,8 +273,8 @@ export function treeRows(model: GraphModel, state: TreeState): TreeRow[] {
273
273
  export function treeEmptyHint(model: GraphModel): string | null {
274
274
  const vault = model.nodes.find((n) => n.kind === "vault");
275
275
  if (!vault) return null;
276
- // no notes and no repository
277
- const hasNotes = model.nodes.some((n) => n.kind === "note");
276
+ // nothing in the vault (no note, HTML file or folder) and no repository
277
+ const hasNotes = model.nodes.some((n) => n.kind === "note" || n.id.startsWith("artifact:") || n.id.startsWith("vfolder:"));
278
278
  const hasRepo = model.nodes.some((n) => n.kind === "repository");
279
279
  if (!hasNotes && !hasRepo) return "no notes yet — add one with the weave_note tool";
280
280
  return null;
@@ -411,6 +411,7 @@ function mutation(fetchImpl: FetchLike, url: string, method: string, body?: unkn
411
411
 
412
412
  const noteUrl = (slug: string, suffix = ""): string => `/api/note/${encodeURIComponent(slug)}${suffix}`;
413
413
  const folderUrl = (path: string, suffix = ""): string => `/api/folder/${path.split("/").map(encodeURIComponent).join("/")}${suffix}`;
414
+ const artifactUrl = (path: string, suffix = ""): string => `/api/artifact/${path.split("/").map(encodeURIComponent).join("/")}${suffix}`;
414
415
 
415
416
  /**
416
417
  * `POST /api/note/:slug` — save the note's Markdown body. No revision
@@ -421,6 +422,9 @@ export const saveNote = (fetchImpl: FetchLike, slug: string, body: string, raw =
421
422
  mutation(fetchImpl, noteUrl(slug), "POST", raw ? { body, raw: true } : { body });
422
423
  export const renameNote = (fetchImpl: FetchLike, slug: string, name: string) => mutation(fetchImpl, noteUrl(slug, "/rename"), "POST", { name });
423
424
  export const moveNote = (fetchImpl: FetchLike, slug: string, folder: string | null) => mutation(fetchImpl, noteUrl(slug, "/move"), "POST", { folder });
425
+ export const moveArtifact = (fetchImpl: FetchLike, slug: string, folder: string | null) => mutation(fetchImpl, artifactUrl(slug, "/move"), "POST", { folder });
426
+ export const renameArtifact = (fetchImpl: FetchLike, slug: string, name: string) => mutation(fetchImpl, artifactUrl(slug, "/rename"), "POST", { name });
427
+ export const deleteArtifact = (fetchImpl: FetchLike, slug: string) => mutation(fetchImpl, artifactUrl(slug), "DELETE");
424
428
  export const deleteNote = (fetchImpl: FetchLike, slug: string) => mutation(fetchImpl, noteUrl(slug), "DELETE");
425
429
  export const createFolder = (fetchImpl: FetchLike, path: string) => mutation(fetchImpl, folderUrl(path), "POST");
426
430
  export const renameFolder = (fetchImpl: FetchLike, path: string, name: string) => mutation(fetchImpl, folderUrl(path, "/rename"), "POST", { name });