A note-taking app inspired by nb
at main 231 lines 5.6 kB view raw
1import * as frontMatter from "@std/front-matter"; 2import * as yaml from "@std/yaml"; 3 4import { join, SEPARATOR } from "@std/path"; 5import { Entry, getTitleFor, inferredType, NoteContents } from "./entry.ts"; 6import { UserError } from "./errors.ts"; 7 8export class Notebook { 9 root: string; 10 11 constructor(root: string) { 12 this.root = root; 13 } 14 15 name(): string { 16 return this.root.split(SEPARATOR).at(-1)!; 17 } 18 19 getEntryPath(path: RelativePath): string { 20 return join( 21 this.root, 22 ...path.folders, 23 ...(path.entry != null ? [path.entry] : []), 24 ); 25 } 26 27 newPath(folders: string[] = []): NotebookPath { 28 return new NotebookPath(this, folders); 29 } 30 31 pin(path: RelativePath) { 32 let p: RelativePath; 33 let name: string; 34 if (path.entry) { 35 p = path; 36 name = path.entry; 37 } else { 38 p = { ...path, folders: path.folders.slice(0, -1) }; 39 name = path.folders.at(-1)!; 40 } 41 const s = new Set(this.getPinned(p)); 42 s.add(name); 43 this.writePinned(p, [...s]); 44 } 45 46 unpin(path: RelativePath) { 47 let p: RelativePath; 48 let name: string; 49 if (path.entry) { 50 p = path; 51 name = path.entry; 52 } else { 53 p = { ...path, folders: path.folders.slice(0, -1) }; 54 name = path.folders.at(-1)!; 55 } 56 this.writePinned(p, this.getPinned(p).filter((p) => p !== name)); 57 } 58 59 writePinned(path: RelativePath, pinned: string[]) { 60 if (pinned.length === 0) { 61 Deno.removeSync(this.pindexForPath(path)); 62 } else { 63 Deno.writeTextFileSync( 64 this.pindexForPath(path), 65 pinned.join("\n") + "\n", 66 ); 67 } 68 } 69 70 private getPinned(path: RelativePath) { 71 try { 72 return Deno.readTextFileSync(this.pindexForPath(path)).split("\n"); 73 } catch { 74 // It's fine; just nothing is pinned 75 return []; 76 } 77 } 78 79 private pindexForPath(path: RelativePath): string { 80 return join(this.root, ...path.folders, ".pindex"); 81 } 82 83 getEntries(path: RelativePath): Entry[] { 84 let retried = false; 85 while (true) { 86 try { 87 const entries: Entry[] = []; 88 const pinned = this.getPinned(path); 89 90 const now = new Date(); 91 for (const de of Deno.readDirSync(this.getEntryPath(path))) { 92 if (de.name.startsWith(".")) continue; 93 const p = this.getEntryPath({ ...path, entry: de.name }); 94 const stat = Deno.statSync(p); 95 let title = de.name; 96 const entryType = de.isDirectory 97 ? "directory" 98 : inferredType(de.name); 99 100 if (de.isFile && entryType !== "image") { 101 title = getTitleFor(de.name, Deno.readTextFileSync(p)); 102 } 103 104 const baseEntry = { 105 notebook: this, 106 folders: path.folders.filter((e) => e !== "."), 107 filename: de.name, 108 title, 109 lastModified: stat.mtime ?? now, 110 pinned: pinned.includes(de.name), 111 }; 112 113 let entry: Entry; 114 if (entryType === "todo") { 115 entry = { 116 ...baseEntry, 117 type: "todo", 118 status: title.includes("[x]") ? "closed" : "open", 119 }; 120 } else { 121 entry = { 122 ...baseEntry, 123 type: entryType, 124 }; 125 } 126 127 entries.push(entry); 128 } 129 130 return entries; 131 } catch (e) { 132 if (retried) throw e; 133 retried = true; 134 } 135 } 136 } 137} 138 139export interface RawPath { 140 notebookName?: string; 141 folders: string[]; 142 entry?: string; 143} 144 145export class NotebookPath implements RelativePath { 146 notebook: Notebook; 147 folders: string[]; 148 entry?: string; 149 150 constructor(notebook: Notebook, folders: string[], entry?: string) { 151 this.notebook = notebook; 152 this.folders = folders; 153 this.entry = entry; 154 } 155 156 withEntry(entry?: string): NotebookPath { 157 return new NotebookPath(this.notebook, this.folders, entry); 158 } 159 160 toString(): string { 161 return `${this.folders.join("/")}${ 162 this.folders.length > 0 && this.entry ? "/" : "" 163 }${this.entry ?? ""}`; 164 } 165 166 getAbsolutePath(): string { 167 return this.notebook.getEntryPath(this); 168 } 169 170 getEntries(): Entry[] { 171 return this.notebook.getEntries(this); 172 } 173 174 write(contents: string, attrs?: object): void { 175 if (this.entry == null) { 176 throw new Error("cannot write note with no file name"); 177 } 178 let c = contents; 179 if (attrs != null) { 180 c = `---\n${yaml.stringify(attrs)}---\n\n${contents}`; 181 } 182 Deno.writeTextFileSync(this.getAbsolutePath(), c); 183 } 184 185 read(): NoteContents { 186 if (this.entry == null) { 187 throw new UserError(`"${this}" is a folder, not an entry`); 188 } 189 const body = Deno.readTextFileSync(this.getAbsolutePath()); 190 if (frontMatter.test(body)) { 191 return frontMatter.extractYaml(body); 192 } 193 194 return { body }; 195 } 196 197 getTitle(): string { 198 if (this.entry == null) { 199 return this.folders.at(-1) ?? ""; 200 } 201 const body = Deno.readTextFileSync(this.getAbsolutePath()); 202 return getTitleFor(this.entry, body); 203 } 204} 205 206export interface RelativePath { 207 folders: string[]; 208 entry?: string; 209} 210 211export function parseRaw( 212 path: string, 213): RawPath { 214 let notebookName: string | undefined; 215 216 if (path.includes(":")) { 217 [notebookName, path] = path.split(":", 2); 218 if (!path) path = ""; 219 } 220 let folders; 221 let entry: string | undefined; 222 const components = path.split("/").filter((e) => e != ""); 223 if (path.endsWith("/")) { 224 folders = components; 225 } else { 226 entry = components.pop(); 227 folders = components; 228 } 229 230 return { notebookName, folders, entry }; 231}