A note-taking app inspired by nb
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}