import * as frontMatter from "@std/front-matter"; import * as yaml from "@std/yaml"; import { join, SEPARATOR } from "@std/path"; import { Entry, getTitleFor, inferredType, NoteContents } from "./entry.ts"; import { UserError } from "./errors.ts"; export class Notebook { root: string; constructor(root: string) { this.root = root; } name(): string { return this.root.split(SEPARATOR).at(-1)!; } getEntryPath(path: RelativePath): string { return join( this.root, ...path.folders, ...(path.entry != null ? [path.entry] : []), ); } newPath(folders: string[] = []): NotebookPath { return new NotebookPath(this, folders); } pin(path: RelativePath) { let p: RelativePath; let name: string; if (path.entry) { p = path; name = path.entry; } else { p = { ...path, folders: path.folders.slice(0, -1) }; name = path.folders.at(-1)!; } const s = new Set(this.getPinned(p)); s.add(name); this.writePinned(p, [...s]); } unpin(path: RelativePath) { let p: RelativePath; let name: string; if (path.entry) { p = path; name = path.entry; } else { p = { ...path, folders: path.folders.slice(0, -1) }; name = path.folders.at(-1)!; } this.writePinned(p, this.getPinned(p).filter((p) => p !== name)); } writePinned(path: RelativePath, pinned: string[]) { if (pinned.length === 0) { Deno.removeSync(this.pindexForPath(path)); } else { Deno.writeTextFileSync( this.pindexForPath(path), pinned.join("\n") + "\n", ); } } private getPinned(path: RelativePath) { try { return Deno.readTextFileSync(this.pindexForPath(path)).split("\n"); } catch { // It's fine; just nothing is pinned return []; } } private pindexForPath(path: RelativePath): string { return join(this.root, ...path.folders, ".pindex"); } getEntries(path: RelativePath): Entry[] { let retried = false; while (true) { try { const entries: Entry[] = []; const pinned = this.getPinned(path); const now = new Date(); for (const de of Deno.readDirSync(this.getEntryPath(path))) { if (de.name.startsWith(".")) continue; const p = this.getEntryPath({ ...path, entry: de.name }); const stat = Deno.statSync(p); let title = de.name; const entryType = de.isDirectory ? "directory" : inferredType(de.name); if (de.isFile && entryType !== "image") { title = getTitleFor(de.name, Deno.readTextFileSync(p)); } const baseEntry = { notebook: this, folders: path.folders.filter((e) => e !== "."), filename: de.name, title, lastModified: stat.mtime ?? now, pinned: pinned.includes(de.name), }; let entry: Entry; if (entryType === "todo") { entry = { ...baseEntry, type: "todo", status: title.includes("[x]") ? "closed" : "open", }; } else { entry = { ...baseEntry, type: entryType, }; } entries.push(entry); } return entries; } catch (e) { if (retried) throw e; retried = true; } } } } export interface RawPath { notebookName?: string; folders: string[]; entry?: string; } export class NotebookPath implements RelativePath { notebook: Notebook; folders: string[]; entry?: string; constructor(notebook: Notebook, folders: string[], entry?: string) { this.notebook = notebook; this.folders = folders; this.entry = entry; } withEntry(entry?: string): NotebookPath { return new NotebookPath(this.notebook, this.folders, entry); } toString(): string { return `${this.folders.join("/")}${ this.folders.length > 0 && this.entry ? "/" : "" }${this.entry ?? ""}`; } getAbsolutePath(): string { return this.notebook.getEntryPath(this); } getEntries(): Entry[] { return this.notebook.getEntries(this); } write(contents: string, attrs?: object): void { if (this.entry == null) { throw new Error("cannot write note with no file name"); } let c = contents; if (attrs != null) { c = `---\n${yaml.stringify(attrs)}---\n\n${contents}`; } Deno.writeTextFileSync(this.getAbsolutePath(), c); } read(): NoteContents { if (this.entry == null) { throw new UserError(`"${this}" is a folder, not an entry`); } const body = Deno.readTextFileSync(this.getAbsolutePath()); if (frontMatter.test(body)) { return frontMatter.extractYaml(body); } return { body }; } getTitle(): string { if (this.entry == null) { return this.folders.at(-1) ?? ""; } const body = Deno.readTextFileSync(this.getAbsolutePath()); return getTitleFor(this.entry, body); } } export interface RelativePath { folders: string[]; entry?: string; } export function parseRaw( path: string, ): RawPath { let notebookName: string | undefined; if (path.includes(":")) { [notebookName, path] = path.split(":", 2); if (!path) path = ""; } let folders; let entry: string | undefined; const components = path.split("/").filter((e) => e != ""); if (path.endsWith("/")) { folders = components; } else { entry = components.pop(); folders = components; } return { notebookName, folders, entry }; }