authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-06-27 19:40:19-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:45-07:00
logbba4a6d0d133e1513a1a4acbba1d6f1dd5fdd627
tree356cf1637de8b49e425918047eff931c7215e2d5
parente258365f5bba27170707594f3548c6a9753532a5
signature Commit is signed but in an unrecognized format.

stuff for file view


19 files changed, 1087 insertions(+), 241 deletions(-)

framework/bundle.ts+1-1
......@@ -250,7 +250,7 @@ export async function finalizeServerJavaScript(
250250 // Replace the magic word
251251 let text = files[fileWithMagicWord].toString("utf-8");
252252 text = text.replace(
253 new RegExp(magicWord + "\\[(-?\\d)\\]", "gs"),
253 new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"),
254254 (_, i) => {
255255 i = Number(i);
256256 // Inline the styling data
framework/lib/async.ts+22-4
......@@ -34,6 +34,12 @@ export class Queue<T, R> {
3434 this.#passive = options.passive ?? false;
3535 }
3636
37 cancel() {
38 const bar = this.#cachedProgress;
39 bar?.stop();
40 this.#queue = [];
41 }
42
3743 get bar() {
3844 const cached = this.#cachedProgress;
3945 if (!cached) {
......@@ -65,7 +71,7 @@ export class Queue<T, R> {
6571 return cached;
6672 }
6773
68 add(args: T) {
74 addReturn(args: T) {
6975 this.#total += 1;
7076 this.updateTotal();
7177 if (this.#active.length > this.#maxJobs) {
......@@ -76,6 +82,10 @@ export class Queue<T, R> {
7682 return this.#run(args);
7783 }
7884
85 add(args: T) {
86 return this.addReturn(args).then(() => {}, () => {});
87 }
88
7989 addMany(items: T[]) {
8090 this.#total += items.length;
8191 this.updateTotal();
......@@ -92,10 +102,12 @@ export class Queue<T, R> {
92102 const itemText = this.#getItemText(args);
93103 const spinner = new Spinner(itemText);
94104 spinner.stop();
105 (spinner as any).redraw = () => (bar as any).redraw();
95106 const active = this.#active;
96107 try {
97108 active.unshift(spinner);
98109 bar.props = { active };
110 console.log(this.#name + ": " + itemText);
99111 const result = await this.#fn(args, spinner);
100112 this.#done++;
101113 return result;
......@@ -139,7 +151,7 @@ export class Queue<T, R> {
139151 }
140152 }
141153
142 async done(o: { method: "success" | "stop" }) {
154 async done(o?: { method: "success" | "stop" }) {
143155 if (this.#active.length === 0) {
144156 this.#end(o);
145157 return;
......@@ -153,8 +165,8 @@ export class Queue<T, R> {
153165
154166 #end(
155167 { method = this.#passive ? "stop" : "success" }: {
156 method: "success" | "stop";
157 },
168 method?: "success" | "stop";
169 } = {},
158170 ) {
159171 const bar = this.#cachedProgress;
160172 if (this.#errors.length > 0) {
......@@ -171,6 +183,12 @@ export class Queue<T, R> {
171183 get active(): boolean {
172184 return this.#active.length !== 0;
173185 }
186
187 [Symbol.dispose]() {
188 if (this.active) {
189 this.cancel();
190 }
191 }
174192}
175193
176194const cwd = process.cwd();
framework/lib/fs.ts+7
......@@ -1,7 +1,10 @@
11// File System APIs. Some custom APIs, but mostly a re-export a mix of built-in
22// Node.js sync+promise fs methods. For convenince.
33export {
4 createReadStream,
5 createWriteStream,
46 existsSync,
7 open,
58 readdir,
69 readdirSync,
710 readFile,
......@@ -57,6 +60,8 @@ export function readJsonSync<T>(file: string) {
5760
5861import * as path from "node:path";
5962import {
63 createReadStream,
64 createWriteStream,
6065 existsSync,
6166 mkdirSync as nodeMkdirSync,
6267 readdirSync,
......@@ -67,9 +72,11 @@ import {
6772} from "node:fs";
6873import {
6974 mkdir as nodeMkdir,
75 open,
7076 readdir,
7177 readFile,
7278 rm,
7379 stat,
7480 writeFile,
7581} from "node:fs/promises";
82export { Stats } from "node:fs";
framework/lib/sqlite.ts+43-12
......@@ -48,6 +48,15 @@ export class WrappedDatabase {
4848 prepare<Args extends unknown[] = [], Result = unknown>(
4949 query: string,
5050 ): Stmt<Args, Result> {
51 query = query.trim();
52 const lines = query.split("\n");
53 const trim = Math.min(
54 ...lines.map((line) =>
55 line.trim().length === 0 ? Infinity : line.match(/^\s*/)![0].length
56 ),
57 );
58 query = lines.map((x) => x.slice(trim)).join("\n");
59
5160 let prepared;
5261 try {
5362 prepared = this.node.prepare(query);
......@@ -62,42 +71,64 @@ export class WrappedDatabase {
6271export class Stmt<Args extends unknown[] = unknown[], Row = unknown> {
6372 #node: StatementSync;
6473 #class: any | null = null;
74 query: string;
75
6576 constructor(node: StatementSync) {
6677 this.#node = node;
78 this.query = node.sourceSQL;
6779 }
6880
6981 /** Get one row */
7082 get(...args: Args): Row | null {
71 const item = this.#node.get(...args as any) as Row;
72 if (!item) return null;
73 const C = this.#class;
74 if (C) Object.setPrototypeOf(item, C.prototype);
75 return item;
83 return this.#wrap(args, () => {
84 const item = this.#node.get(...args as any) as Row;
85 if (!item) return null;
86 const C = this.#class;
87 if (C) Object.setPrototypeOf(item, C.prototype);
88 return item;
89 });
7690 }
7791 getNonNull(...args: Args) {
7892 const item = this.get(...args);
79 if (!item) throw new Error("Query returned no result");
93 if (!item) {
94 throw this.#wrap(args, () => new Error("Query returned no result"));
95 }
8096 return item;
8197 }
8298 iter(...args: Args): Iterator<Row> {
83 return this.array(...args)[Symbol.iterator]();
99 return this.#wrap(args, () => this.array(...args)[Symbol.iterator]());
84100 }
85101 /** Get all rows */
86102 array(...args: Args): Row[] {
87 const array = this.#node.all(...args as any) as Row[];
88 const C = this.#class;
89 if (C) array.forEach((item) => Object.setPrototypeOf(item, C.prototype));
90 return array;
103 return this.#wrap(args, () => {
104 const array = this.#node.all(...args as any) as Row[];
105 const C = this.#class;
106 if (C) array.forEach((item) => Object.setPrototypeOf(item, C.prototype));
107 return array;
108 });
91109 }
92110 /** Return the number of changes / row ID */
93111 run(...args: Args) {
94 return this.#node.run(...args as any);
112 return this.#wrap(args, () => this.#node.run(...args as any));
95113 }
96114
97115 as<R>(Class: { new (): R }): Stmt<Args, R> {
98116 this.#class = Class;
99117 return this as any;
100118 }
119
120 #wrap<T>(args: unknown[], fn: () => T) {
121 try {
122 return fn();
123 } catch (err: any) {
124 if (err && typeof err === "object") {
125 err.query = this.query;
126 args = args.flat(Infinity);
127 err.queryArgs = args.length === 1 ? args[0] : args;
128 }
129 throw err;
130 }
131 }
101132}
102133
103134import { DatabaseSync, StatementSync } from "node:sqlite";
package-lock.json+15-1
......@@ -17,7 +17,9 @@
1717 "marko": "^6.0.20",
1818 "puppeteer": "^24.10.1",
1919 "sharp": "^0.34.2",
20 "unique-names-generator": "^4.7.1"
20 "unique-names-generator": "^4.7.1",
21 "vscode-oniguruma": "^2.0.1",
22 "vscode-textmate": "^9.2.0"
2123 },
2224 "devDependencies": {
2325 "@types/node": "^22.15.29",
......@@ -4680,6 +4682,18 @@
46804682 "url": "https://opencollective.com/unified"
46814683 }
46824684 },
4685 "node_modules/vscode-oniguruma": {
4686 "version": "2.0.1",
4687 "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz",
4688 "integrity": "sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==",
4689 "license": "MIT"
4690 },
4691 "node_modules/vscode-textmate": {
4692 "version": "9.2.0",
4693 "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.2.0.tgz",
4694 "integrity": "sha512-rkvG4SraZQaPSN/5XjwKswdU0OP9MF28QjrYzUBbhb8QyG3ljB1Ky996m++jiI7KdiAP2CkBiQZd9pqEDTClqA==",
4695 "license": "MIT"
4696 },
46834697 "node_modules/w3c-keyname": {
46844698 "version": "2.2.8",
46854699 "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
package.json+3-1
......@@ -13,7 +13,9 @@
1313 "marko": "^6.0.20",
1414 "puppeteer": "^24.10.1",
1515 "sharp": "^0.34.2",
16 "unique-names-generator": "^4.7.1"
16 "unique-names-generator": "^4.7.1",
17 "vscode-oniguruma": "^2.0.1",
18 "vscode-textmate": "^9.2.0"
1719 },
1820 "devDependencies": {
1921 "@types/node": "^22.15.29",
repl.js+1-1
......@@ -18,7 +18,7 @@ const repl = hot.load("node:repl").start({
1818 .catch((err) => {
1919 // TODO: improve @paperclover/console's ability to print AggregateError
2020 // and errors with extra random properties
21 console.error(util.inspect(err));
21 console.error(util.inspect(err, false, 10, true));
2222 })
2323 .then((result) => done(null, result));
2424 },
src/backend.ts+7-4
......@@ -1,19 +1,22 @@
1const logHttp = scoped("http", { color: "magenta" });
2
1// This is the main file for the backend
32const app = new Hono();
3const logHttp = scoped("http", { color: "magenta" });
44
5app.notFound(assets.notFound);
6
5// Middleware
76app.use(trimTrailingSlash());
87app.use(removeDuplicateSlashes);
98app.use(logger((msg) => msg.startsWith("-->") && logHttp(msg.slice(4))));
109app.use(admin.middleware);
1110
11// Backends
1212app.route("", require("./q+a/backend.ts").app);
1313app.route("", require("./file-viewer/backend.tsx").app);
1414
15// Asset middleware has least precedence
1516app.use(assets.middleware);
1617
18// Handlers
19app.notFound(assets.notFound);
1720if (process.argv.includes("--development")) {
1821 app.onError((err, c) => {
1922 if (err instanceof HTTPException) {
src/file-viewer/bin/extension-stats.ts deleted-83
......@@ -1,83 +0,0 @@
1import * as path from "node:path";
2import { cache, MediaFile } from "../db";
3
4// Function to get file extension statistics
5function getExtensionStats() {
6 // Get all files (not directories) from the database
7 const query = `
8 SELECT path FROM media_files
9 WHERE kind = ${MediaFile.Kind.file}
10 `;
11
12 // Use raw query to get all file paths
13 const rows = cache.query(query).all() as { path: string }[];
14
15 // Count extensions
16 const extensionCounts: Record<string, number> = {};
17
18 for (const row of rows) {
19 const extension = path.extname(row.path).toLowerCase();
20 extensionCounts[extension] = (extensionCounts[extension] || 0) + 1;
21 }
22
23 // Sort extensions by count (descending)
24 const sortedExtensions = Object.entries(extensionCounts)
25 .sort((a, b) => b[1] - a[1]);
26
27 return {
28 totalFiles: rows.length,
29 extensions: sortedExtensions,
30 };
31}
32
33// Function to print a visual table
34function printExtensionTable() {
35 const stats = getExtensionStats();
36
37 // Calculate column widths
38 const extensionColWidth = Math.max(
39 ...stats.extensions.map(([ext]) => ext.length),
40 "Extension".length,
41 ) + 2;
42
43 const countColWidth = Math.max(
44 ...stats.extensions.map(([_, count]) => count.toString().length),
45 "Count".length,
46 ) + 2;
47
48 const percentColWidth = "Percentage".length + 2;
49
50 // Print header
51 console.log("MediaFile Extension Statistics");
52 console.log(`Total files: ${stats.totalFiles}`);
53 console.log();
54
55 // Print table header
56 console.log(
57 "Extension".padEnd(extensionColWidth) +
58 "Count".padEnd(countColWidth) +
59 "Percentage".padEnd(percentColWidth),
60 );
61
62 // Print separator
63 console.log(
64 "-".repeat(extensionColWidth) +
65 "-".repeat(countColWidth) +
66 "-".repeat(percentColWidth),
67 );
68
69 // Print rows
70 for (const [extension, count] of stats.extensions) {
71 const percentage = ((count / stats.totalFiles) * 100).toFixed(2);
72 const ext = extension || "(no extension)";
73
74 console.log(
75 ext.padEnd(extensionColWidth) +
76 count.toString().padEnd(countColWidth) +
77 `${percentage}%`.padEnd(percentColWidth),
78 );
79 }
80}
81
82// Run the program
83printExtensionTable();
src/file-viewer/bin/list.ts created+8
......@@ -0,0 +1,8 @@
1export function main() {
2 const meows = MediaFile.db.prepare(`
3 select * from media_files;
4 `).as(MediaFile).array();
5 console.log(meows);
6}
7
8import { MediaFile } from "@/file-viewer/models/MediaFile.ts";
src/file-viewer/bin/scan3.ts+589-2
......@@ -1,2 +1,589 @@
1import "@/file-viewer/models/MediaFile.ts";
2import "@/file-viewer/models/BlobAsset.ts";
1const root = path.resolve("C:/media");
2const workDir = path.resolve(".clover/file-assets");
3
4export async function main() {
5 const start = performance.now();
6 const timerSpinner = new Spinner({
7 text: () =>
8 `paper clover's scan3 [${
9 ((performance.now() - start) / 1000).toFixed(1)
10 }s]`,
11 fps: 10,
12 });
13 using _endTimerSpinner = { [Symbol.dispose]: () => timerSpinner.stop() };
14
15 // Read a directory or file stat and queue up changed files.
16 using qList = new async.Queue({
17 name: "Discover Tree",
18 async fn(absPath: string, spin) {
19 const stat = await fs.stat(absPath);
20
21 const publicPath = toPublicPath(absPath);
22 const mediaFile = MediaFile.getByPath(publicPath);
23
24 if (stat.isDirectory()) {
25 const items = await fs.readdir(absPath);
26 qList.addMany(items.map((subPath) => path.join(absPath, subPath)));
27
28 if (mediaFile) {
29 const deleted = mediaFile.getChildren()
30 .filter((child) => !items.includes(child.basename))
31 .flatMap((child) =>
32 child.kind === MediaFileKind.directory
33 ? child.getRecursiveFileChildren()
34 : child
35 );
36
37 qMeta.addMany(deleted.map((mediaFile) => ({
38 absPath: path.join(root, mediaFile.path),
39 publicPath: mediaFile.path,
40 stat: null,
41 mediaFile,
42 })));
43 }
44
45 return;
46 }
47
48 // All processes must be performed again if there is no file.
49 if (
50 !mediaFile ||
51 stat.size !== mediaFile.size ||
52 stat.mtime.getTime() !== mediaFile.date.getTime()
53 ) {
54 qMeta.add({ absPath, publicPath, stat, mediaFile });
55 return;
56 }
57
58 // If the scanners changed, it may mean more processes should be run.
59 queueProcessors({ absPath, stat, mediaFile });
60 },
61 maxJobs: 24,
62 });
63 using qMeta = new async.Queue({
64 name: "Update Metadata",
65 async fn({ absPath, publicPath, stat, mediaFile }: UpdateMetadataJob) {
66 if (!stat) {
67 // File was deleted.
68 await runUndoProcessors(UNWRAP(mediaFile));
69 return;
70 }
71 // TODO: run scrubLocationMetadata first
72
73 const hash = await new Promise<string>((resolve, reject) => {
74 const reader = fs.createReadStream(absPath);
75 reader.on("error", reject);
76
77 const hasher = crypto.createHash("sha1").setEncoding("hex");
78 hasher.on("error", reject);
79 hasher.on("readable", () => resolve(hasher.read()));
80
81 reader.pipe(hasher);
82 });
83 let date = stat.mtime;
84 if (
85 mediaFile &&
86 mediaFile.date.getTime() < stat.mtime.getTime() &&
87 (Date.now() - stat.mtime.getTime()) < monthMilliseconds
88 ) {
89 date = mediaFile.date;
90 console.warn(
91 `M-time on ${publicPath} was likely corrupted. ${
92 formatDate(mediaFile.date)
93 } -> ${formatDate(stat.mtime)}`,
94 );
95 }
96 mediaFile = MediaFile.createFile({
97 path: publicPath,
98 date,
99 hash,
100 size: stat.size,
101 duration: mediaFile?.duration ?? 0,
102 dimensions: mediaFile?.dimensions ?? "",
103 contents: mediaFile?.contents ?? "",
104 });
105 await queueProcessors({ absPath, stat, mediaFile });
106 },
107 getItemText: (job) =>
108 job.publicPath.slice(1) + (job.stat ? "" : " (deleted)"),
109 maxJobs: 2,
110 });
111 using qProcess = new async.Queue({
112 name: "Process Contents",
113 async fn(
114 { absPath, stat, mediaFile, processor, index, after }: ProcessJob,
115 spin,
116 ) {
117 await processor.run({ absPath, stat, mediaFile, spin });
118 mediaFile.setProcessed(mediaFile.processed | (1 << (16 + index)));
119 for (const dependantJob of after) {
120 ASSERT(dependantJob.needs > 0);
121 dependantJob.needs -= 1;
122 if (dependantJob.needs == 0) qProcess.add(dependantJob);
123 }
124 },
125 getItemText: ({ mediaFile, processor }) =>
126 `${mediaFile.path.slice(1)} - ${processor.name}`,
127 maxJobs: 2,
128 });
129
130 function decodeProcessors(input: string) {
131 return input
132 .split(";")
133 .filter(Boolean)
134 .map(([a, b, c]) => ({
135 id: a,
136 hash: (b.charCodeAt(0) << 8) + c.charCodeAt(0),
137 }));
138 }
139
140 async function queueProcessors(
141 { absPath, stat, mediaFile }: Omit<ProcessFileArgs, "spin">,
142 ) {
143 const ext = mediaFile.extension.toLowerCase();
144 let possible = processors.filter((p) => p.include.has(ext));
145 if (possible.length === 0) return;
146
147 const hash = possible.reduce((a, b) => a ^ b.hash, 0) | 1;
148 ASSERT(hash <= 0xFFFF);
149 let processed = mediaFile.processed;
150
151 // If the hash has changed, migrate the bitfield over.
152 // This also runs when the processor hash is in it's initial 0 state.
153 const order = decodeProcessors(mediaFile.processors);
154 if ((processed & 0xFFFF) !== hash) {
155 const previous = order.filter((_, i) =>
156 (processed & (1 << (16 + i))) !== 0
157 );
158 processed = hash;
159 for (const { id, hash } of previous) {
160 const p = processors.find((p) => p.id === id);
161 if (!p) continue;
162 const index = possible.indexOf(p);
163 if (index !== -1 && p.hash === hash) {
164 processed |= 1 << (16 + index);
165 } else {
166 if (p.undo) await p.undo(mediaFile);
167 }
168 }
169 mediaFile.setProcessors(
170 processed,
171 possible.map((p) =>
172 p.id + String.fromCharCode(p.hash >> 8, p.hash & 0xFF)
173 ).join(";"),
174 );
175 } else {
176 possible = order.map(({ id }) =>
177 UNWRAP(possible.find((p) => p.id === id))
178 );
179 }
180
181 // Queue needed processors.
182 const jobs: ProcessJob[] = [];
183 for (let i = 0, { length } = possible; i < length; i += 1) {
184 if ((processed & (1 << (16 + i))) === 0) {
185 const job: ProcessJob = {
186 absPath,
187 stat,
188 mediaFile,
189 processor: possible[i],
190 index: i,
191 after: [],
192 needs: possible[i].depends.length,
193 };
194 jobs.push(job);
195 if (job.needs === 0) qProcess.add(job);
196 }
197 }
198 for (const job of jobs) {
199 for (const dependId of job.processor.depends) {
200 const dependJob = jobs.find((j) => j.processor.id === dependId);
201 if (dependJob) {
202 dependJob.after.push(job);
203 } else {
204 ASSERT(job.needs > 0);
205 job.needs -= 1;
206 if (job.needs === 0) qProcess.add(job);
207 }
208 }
209 }
210 }
211
212 async function runUndoProcessors(mediaFile: MediaFile) {
213 const { processed } = mediaFile;
214 const previous = decodeProcessors(mediaFile.processors)
215 .filter((_, i) => (processed & (1 << (16 + i))) !== 0);
216 for (const { id } of previous) {
217 const p = processors.find((p) => p.id === id);
218 if (!p) continue;
219 if (p.undo) {
220 await p.undo(mediaFile);
221 }
222 }
223 mediaFile.delete();
224 }
225
226 // Add the root & recursively iterate!
227 qList.add(root);
228 await qList.done();
229 await qMeta.done();
230 await qProcess.done();
231
232 console.info(
233 "Updated file viewer index in " +
234 ((performance.now() - start) / 1000).toFixed(1) + "s",
235 );
236}
237
238interface Process {
239 name: string;
240 enable?: boolean;
241 include: Set<string>;
242 depends?: string[];
243 /* Perform an action. */
244 run(args: ProcessFileArgs): Promise<void>;
245 /* Should detect if `run` was never even run before before undoing state */
246 undo?(mediaFile: MediaFile): Promise<void>;
247}
248
249const execFileRaw = util.promisify(child_process.execFile);
250const execFile: typeof execFileRaw = ((
251 ...args: Parameters<typeof execFileRaw>
252) =>
253 execFileRaw(...args).catch((e: any) => {
254 if (e?.message?.startsWith?.("Command failed")) {
255 if (e.code > (2 ** 31)) e.code |= 0;
256 const code = e.signal ? `signal ${e.signal}` : `code ${e.code}`;
257 e.message = `${e.cmd.split(" ")[0]} failed with ${code}`;
258 }
259 throw e;
260 })) as any;
261const ffprobe = testProgram("ffprobe", "--help");
262const ffmpeg = testProgram("ffmpeg", "--help");
263
264const ffmpegOptions = [
265 "-hide_banner",
266 "-loglevel",
267 "warning",
268];
269
270const imageSizes = [64, 128, 256, 512, 1024, 2048];
271
272const procDuration: Process = {
273 name: "calculate duration",
274 enable: ffprobe !== null,
275 include: rules.extsDuration,
276 async run({ absPath, mediaFile }) {
277 const { stdout } = await execFile(ffprobe!, [
278 "-v",
279 "error",
280 "-show_entries",
281 "format=duration",
282 "-of",
283 "default=noprint_wrappers=1:nokey=1",
284 absPath,
285 ]);
286
287 const duration = parseFloat(stdout.trim());
288 if (Number.isNaN(duration)) {
289 throw new Error("Could not extract duration from " + stdout);
290 }
291 mediaFile.setDuration(Math.ceil(duration));
292 },
293};
294
295// NOTE: Never re-order the processors. Add new ones at the end.
296const procDimensions: Process = {
297 name: "calculate dimensions",
298 enable: ffprobe != null,
299 include: rules.extsDimensions,
300 async run({ absPath, mediaFile }) {
301 const ext = path.extname(absPath);
302
303 let dimensions;
304
305 if (ext === ".svg") {
306 // Parse out of text data
307 const content = await fs.readFile(absPath, "utf8");
308 const widthMatch = content.match(/width="(\d+)"/);
309 const heightMatch = content.match(/height="(\d+)"/);
310
311 if (widthMatch && heightMatch) {
312 dimensions = `${widthMatch[1]}x${heightMatch[1]}`;
313 }
314 } else {
315 // Use ffprobe to observe streams
316 const { stdout } = await execFile("ffprobe", [
317 "-v",
318 "error",
319 "-select_streams",
320 "v:0",
321 "-show_entries",
322 "stream=width,height",
323 "-of",
324 "csv=s=x:p=0",
325 absPath,
326 ]);
327 if (stdout.includes("x")) {
328 dimensions = stdout.trim();
329 }
330 }
331
332 mediaFile.setDimensions(dimensions ?? "");
333 },
334};
335
336const procLoadTextContents: Process = {
337 name: "load text content",
338 include: rules.extsReadContents,
339 async run({ absPath, mediaFile, stat }) {
340 if (stat.size > 1_000_000) return;
341 const text = await fs.readFile(absPath, "utf-8");
342 mediaFile.setContents(text);
343 },
344};
345
346const procHighlightCode: Process = {
347 name: "highlight source code",
348 include: new Set(rules.extsCode.keys()),
349 async run({ absPath, mediaFile, stat }) {
350 const language = UNWRAP(
351 rules.extsCode.get(path.extname(absPath).toLowerCase()),
352 );
353 // An issue is that .ts is an overloaded extension, shared between
354 // 'transport stream' and 'typescript'.
355 //
356 // Filter used here is:
357 // - more than 1mb
358 // - invalid UTF-8
359 if (stat.size > 1_000_000) return;
360 let code;
361 const buf = await fs.readFile(absPath);
362 try {
363 code = new TextDecoder("utf-8", { fatal: true }).decode(buf);
364 } catch (error) {
365 mediaFile.setContents("");
366 return;
367 }
368 const content = await highlight.highlightCode(code, language);
369 mediaFile.setContents(content);
370 },
371};
372
373const imageSubsets = [
374 {
375 ext: ".webp",
376 // deno-fmt-disable-line
377 args: [
378 "-lossless",
379 "0",
380 "-compression_level",
381 "6",
382 "-quality",
383 "95",
384 "-method",
385 "6",
386 ],
387 },
388 {
389 ext: ".jxl",
390 args: ["-c:v", "libjxl", "-distance", "0.8", "-effort", "9"],
391 },
392];
393
394const procImageSubsets: Process = {
395 name: "encode image subsets",
396 include: rules.extsImage,
397 enable: false,
398 depends: ["calculate dimensions"],
399 async run({ absPath, mediaFile, stat, spin }) {
400 const { width, height } = UNWRAP(mediaFile.parseDimensions());
401 const targetSizes = imageSizes.filter((w) => w < width);
402 const baseStatus = spin.text;
403
404 using stack = new DisposableStack();
405 for (const size of targetSizes) {
406 const { w, h } = resizeDimensions(width, height, size);
407 for (const { ext, args } of imageSubsets) {
408 spin.text = baseStatus +
409 ` (${w}x${h}, ${ext.slice(1).toUpperCase()})`;
410
411 stack.use(
412 await produceAsset(
413 `${mediaFile.hash}/${size}${ext}`,
414 async (out) => {
415 await fs.mkdir(path.dirname(out));
416 await fs.rm(out, { force: true });
417 await execFile(ffmpeg!, [
418 ...ffmpegOptions,
419 "-i",
420 absPath,
421 "-vf",
422 `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`,
423 ...args,
424 out,
425 ]);
426 return [out];
427 },
428 ),
429 );
430 }
431 }
432
433 stack.move();
434 },
435 async undo(mediaFile) {
436 const { width } = UNWRAP(mediaFile.parseDimensions());
437 const targetSizes = imageSizes.filter((w) => w < width);
438 for (const size of targetSizes) {
439 for (const { ext } of imageSubsets) {
440 unproduceAsset(`${mediaFile.hash}/${size}${ext}`);
441 }
442 }
443 },
444};
445
446const videoFormats = [
447 {
448 name: "webm",
449 },
450];
451
452const processors = [
453 procDimensions,
454 procDuration,
455 procLoadTextContents,
456 procHighlightCode,
457 procImageSubsets,
458]
459 .map((process, id, all) => {
460 const strIndex = (id: number) =>
461 String.fromCharCode("a".charCodeAt(0) + id);
462 return {
463 ...process as Process,
464 id: strIndex(id),
465 // Create a unique key.
466 hash: new Uint16Array(
467 crypto.createHash("sha1")
468 .update(process.run.toString())
469 .digest().buffer,
470 ).reduce((a, b) => a ^ b),
471 depends: (process.depends ?? []).map((depend) => {
472 const index = all.findIndex((p) => p.name === depend);
473 if (index === -1) throw new Error(`Cannot find depend '${depend}'`);
474 if (index === id) throw new Error(`Cannot depend on self: '${depend}'`);
475 return strIndex(index);
476 }),
477 };
478 });
479
480function resizeDimensions(w: number, h: number, desiredWidth: number) {
481 ASSERT(desiredWidth < w, `${desiredWidth} < ${w}`);
482 return { w: desiredWidth, h: Math.floor((h / w) * desiredWidth) };
483}
484
485async function produceAsset(
486 key: string,
487 builder: (prefix: string) => Promise<string[]>,
488) {
489 const asset = AssetRef.putOrIncrement(key);
490 try {
491 if (asset.refs === 1) {
492 const paths = await builder(path.join(workDir, key));
493 asset.addFiles(
494 paths.map((file) =>
495 path.relative(workDir, file)
496 .replaceAll("\\", "/")
497 ),
498 );
499 }
500 return {
501 [Symbol.dispose]: () => asset.unref(),
502 };
503 } catch (err: any) {
504 if (err && typeof err === "object") err.assetKey = key;
505 asset.unref();
506 throw err;
507 }
508}
509
510async function unproduceAsset(key: string) {
511 const ref = AssetRef.get(key);
512 if (ref) {
513 ref.unref();
514 console.log(`unref ${key}`);
515 // TODO: remove associated files from target
516 }
517}
518
519interface UpdateMetadataJob {
520 absPath: string;
521 publicPath: string;
522 stat: fs.Stats | null;
523 mediaFile: MediaFile | null;
524}
525
526interface ProcessFileArgs {
527 absPath: string;
528 stat: fs.Stats;
529 mediaFile: MediaFile;
530 spin: Spinner;
531}
532
533interface ProcessJob {
534 absPath: string;
535 stat: fs.Stats;
536 mediaFile: MediaFile;
537 processor: typeof processors[0];
538 index: number;
539 after: ProcessJob[];
540 needs: number;
541}
542
543export function skipBasename(basename: string): boolean {
544 // dot files must be incrementally tracked
545 if (basename === ".dirsort") return true;
546 if (basename === ".friends") return true;
547
548 return (
549 basename.startsWith(".") ||
550 basename.startsWith("._") ||
551 basename.startsWith(".tmp") ||
552 basename === ".DS_Store" ||
553 basename.toLowerCase() === "thumbs.db" ||
554 basename.toLowerCase() === "desktop.ini"
555 );
556}
557
558export function toPublicPath(absPath: string) {
559 ASSERT(path.isAbsolute(absPath));
560 if (absPath === root) return "/";
561 return "/" + path.relative(root, absPath).replaceAll("\\", "/");
562}
563
564export function testProgram(name: string, helpArgument: string) {
565 try {
566 child_process.spawnSync(name, [helpArgument]);
567 return name;
568 } catch (err) {
569 console.warn(`Missing or corrupt executable '${name}'`);
570 }
571 return null;
572}
573
574const monthMilliseconds = 30 * 24 * 60 * 60 * 1000;
575
576import { Spinner } from "@paperclover/console/Spinner";
577import * as async from "#sitegen/async";
578import * as fs from "#sitegen/fs";
579
580import * as path from "node:path";
581import * as child_process from "node:child_process";
582import * as util from "node:util";
583import * as crypto from "node:crypto";
584
585import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
586import { AssetRef } from "@/file-viewer/models/AssetRef.ts";
587import { formatDate } from "@/file-viewer/format.ts";
588import * as rules from "@/file-viewer/rules.ts";
589import * as highlight from "@/file-viewer/highlight.ts";
src/file-viewer/cotyledon.tsx+47
......@@ -1,3 +1,48 @@
1// WARNING
2// -------
3// This file contains spoilers for COTYLEDON
4// Consider reading through the entire archive before picking apart this
5// code, as this contains the beginning AND the ending sections, which
6// contains very percise storytelling. You've been warned...
7//
8// --> https://paperclover.net/file/cotyledon <--
9//
10//
11//
12//
13//
14//
15//
16//
17//
18//
19//
20//
21//
22//
23//
24//
25//
26//
27//
28//
29//
30//
31//
32//
33//
34//
35//
36//
37//
38//
39//
40//
41//
42//
43//
44
45// SPEEDBUMP
146export function Speedbump() {
247 return (
348 <div class="panel last">
......@@ -80,6 +125,7 @@ export function Speedbump() {
80125 );
81126}
82127
128// OPENING
83129export function Readme() {
84130 return (
85131 <div class="panel last">
......@@ -136,6 +182,7 @@ export function Readme() {
136182 );
137183}
138184
185// TRUE ENDING. Written in Apple Notes.
139186export function ForEveryone() {
140187 return (
141188 <>
src/file-viewer/highlight.ts+15-16
......@@ -1,10 +1,3 @@
1import { onceAsync } from "../lib.ts";
2import * as fs from "node:fs/promises";
3import * as path from "node:path";
4import * as oniguruma from "vscode-oniguruma";
5import * as textmate from "vscode-textmate";
6import { escapeHTML } from "../framework/bun-polyfill.ts";
7
81const languages = [
92 "ts",
103 "tsx",
......@@ -88,7 +81,6 @@ interface HighlightLinesOptions {
8881}
8982
9083export function getStyle(scopesToCheck: string[], langugage: Language) {
91 if (import.meta.main) console.log(scopesToCheck);
9284 for (const scope of scopes) {
9385 if (scope[2] && scope[2] !== langugage) continue;
9486 const find = scopesToCheck.find((s) => s.startsWith(scope[0]));
......@@ -98,6 +90,7 @@ export function getStyle(scopesToCheck: string[], langugage: Language) {
9890 }
9991 return null;
10092}
93
10194function highlightLines({
10295 lines,
10396 grammar,
......@@ -120,7 +113,7 @@ function highlightLines({
120113 const str = lines[i].slice(token.startIndex, token.endIndex);
121114 if (str.trim().length === 0) {
122115 // Emit but do not consider scope changes
123 html += escapeHTML(str);
116 html += ssr.escapeHtml(str);
124117 continue;
125118 }
126119
......@@ -129,7 +122,7 @@ function highlightLines({
129122 if (lastHtmlStyle) html += "</span>";
130123 if (style) html += `<span class='${style}'>`;
131124 }
132 html += escapeHTML(str);
125 html += ssr.escapeHtml(str);
133126 lastHtmlStyle = style;
134127 }
135128 html += "\n";
......@@ -140,7 +133,7 @@ function highlightLines({
140133 return { state, html };
141134}
142135
143export const getRegistry = onceAsync(async () => {
136export const getRegistry = async.once(async () => {
144137 const wasmBin = await fs.readFile(
145138 path.join(
146139 import.meta.dirname,
......@@ -187,18 +180,24 @@ export async function highlightCode(code: string, language: Language) {
187180 return html;
188181}
189182
190import { existsSync } from "node:fs";
191if (import.meta.main) {
183export async function main() {
192184 // validate exts
193185 for (const ext of languages) {
194186 if (
195 !existsSync(
187 !fs.existsSync(
196188 path.join(import.meta.dirname, `highlight-grammar/${ext}.plist`),
197189 )
198190 ) {
199191 console.error(`Missing grammar for ${ext}`);
200192 }
201 const html = await highlightCode("wwwwwwwwwwwaaaaaaaaaaaaaaaa", ext);
193 // Sanity check
194 await highlightCode("wwwwwwwwwwwaaaaaaaaaaaaaaaa", ext);
202195 }
203 console.log(await highlightCode(`{"maps":"damn"`, "json"));
204196}
197
198import * as async from "#sitegen/async";
199import * as fs from "#sitegen/fs";
200import * as path from "node:path";
201import * as oniguruma from "vscode-oniguruma";
202import * as textmate from "vscode-textmate";
203import * as ssr from "#ssr";
src/file-viewer/models/AssetRef.ts created+73
......@@ -0,0 +1,73 @@
1const db = getDb("cache.sqlite");
2db.table(
3 "asset_refs",
4 /* SQL */ `
5 create table if not exists asset_refs (
6 id integer primary key autoincrement,
7 key text not null UNIQUE,
8 refs integer not null
9 );
10 create table if not exists asset_ref_files (
11 file text not null,
12 id integer not null,
13 foreign key (id) references asset_refs(id)
14 );
15 create index asset_ref_files_id on asset_ref_files(id);
16`,
17);
18
19/**
20 * Uncompressed files are read directly from the media store root. Derivied
21 * assets like compressed files, optimized images, and streamable video are
22 * stored in the `derived` folder. After scanning, the derived assets are
23 * uploaded into the store (storage1/clofi-derived dataset on NAS). Since
24 * multiple files can share the same hash, the number of references is
25 * tracked, and the derived content is only produced once. This means if a
26 * file is deleted, it should only decrement a reference count; deleting it
27 * once all references are removed.
28 */
29export class AssetRef {
30 /** Key which aws referenced */
31 id!: number;
32 key!: string;
33 refs!: number;
34
35 unref() {
36 decrementQuery.run(this.key);
37 deleteUnreferencedQuery.run().changes > 0;
38 }
39
40 addFiles(files: string[]) {
41 for (const file of files) {
42 addFileQuery.run({ id: this.id, file });
43 }
44 }
45
46 static get(key: string) {
47 return getQuery.get(key);
48 }
49
50 static putOrIncrement(key: string) {
51 putOrIncrementQuery.get(key);
52 return UNWRAP(AssetRef.get(key));
53 }
54}
55
56const getQuery = db.prepare<[key: string]>(/* SQL */ `
57 select * from asset_refs where key = ?;
58`).as(AssetRef);
59const putOrIncrementQuery = db.prepare<[key: string]>(/* SQL */ `
60 insert into asset_refs (key, refs) values (?, 1)
61 on conflict(key) do update set refs = refs + 1;
62`);
63const decrementQuery = db.prepare<[key: string]>(/* SQL */ `
64 update asset_refs set refs = refs - 1 where key = ? and refs > 0;
65`);
66const deleteUnreferencedQuery = db.prepare(/* SQL */ `
67 delete from asset_refs where refs <= 0;
68`);
69const addFileQuery = db.prepare<[{ id: number; file: string }]>(/* SQL */ `
70 insert into asset_ref_files (id, file) values ($id, $file);
71`);
72
73import { getDb } from "#sitegen/sqlite";
src/file-viewer/models/BlobAsset.ts deleted-57
......@@ -1,57 +0,0 @@
1const db = getDb("cache.sqlite");
2db.table(
3 "blob_assets",
4 /* SQL */ `
5 CREATE TABLE IF NOT EXISTS blob_assets (
6 hash TEXT PRIMARY KEY,
7 refs INTEGER NOT NULL DEFAULT 0
8 );
9`,
10);
11
12/**
13 * Uncompressed files are read directly from the media store root. Compressed
14 * files are stored as `<compress store>/<first 2 chars of hash>/<hash>` Since
15 * multiple files can share the same hash, the number of references is tracked
16 * so that when a file is deleted, the compressed data is only removed when all
17 * references are gone.
18 */
19export class BlobAsset {
20 /** sha1 of the contents */
21 hash!: string;
22 refs!: number;
23
24 decrementOrDelete() {
25 BlobAsset.decrementOrDelete(this.hash);
26 }
27
28 static get(hash: string) {
29 return getQuery.get(hash);
30 }
31 static putOrIncrement(hash: string) {
32 ASSERT(hash.length === 40);
33 putOrIncrementQuery.get(hash);
34 return BlobAsset.get(hash)!;
35 }
36 static decrementOrDelete(hash: string) {
37 ASSERT(hash.length === 40);
38 decrementQuery.run(hash);
39 return deleteQuery.run(hash).changes > 0;
40 }
41}
42
43const getQuery = db.prepare<[hash: string]>(/* SQL */ `
44 SELECT * FROM blob_assets WHERE hash = ?;
45`).as(BlobAsset);
46const putOrIncrementQuery = db.prepare<[hash: string]>(/* SQL */ `
47 INSERT INTO blob_assets (hash, refs) VALUES (?, 1)
48 ON CONFLICT(hash) DO UPDATE SET refs = refs + 1;
49`);
50const decrementQuery = db.prepare<[hash: string]>(/* SQL */ `
51 UPDATE blob_assets SET refs = refs - 1 WHERE hash = ? AND refs > 0;
52`);
53const deleteQuery = db.prepare<[hash: string]>(/* SQL */ `
54 DELETE FROM blob_assets WHERE hash = ? AND refs <= 0;
55`);
56
57import { getDb } from "#sitegen/sqlite";
src/file-viewer/models/MediaFile.ts+153-53
......@@ -2,26 +2,31 @@ const db = getDb("cache.sqlite");
22db.table(
33 "media_files",
44 /* SQL */ `
5 CREATE TABLE IF NOT EXISTS media_files (
6 id INTEGER PRIMARY KEY AUTOINCREMENT,
7 parent_id INTEGER,
8 path TEXT UNIQUE,
9 kind INTEGER NOT NULL,
10 timestamp INTEGER NOT NULL,
11 timestamp_updated INTEGER NOT NULL DEFAULT NOW,
12 hash TEXT NOT NULL,
13 size INTEGER NOT NULL,
14 duration INTEGER NOT NULL DEFAULT 0,
15 dimensions TEXT NOT NULL DEFAULT "",
16 contents TEXT NOT NULL,
17 dirsort TEXT,
18 processed INTEGER NOT NULL,
19 FOREIGN KEY (parent_id) REFERENCES media_files(id)
5 create table media_files (
6 id integer primary key autoincrement,
7 parent_id integer,
8 path text unique,
9 kind integer not null,
10 timestamp integer not null,
11 timestamp_updated integer not null default current_timestamp,
12 hash text not null,
13 size integer not null,
14 duration integer not null default 0,
15 dimensions text not null default "",
16 contents text not null,
17 dirsort text,
18 processed integer not null,
19 processors text not null default "",
20 foreign key (parent_id) references media_files(id)
2021 );
21 -- Index for quickly looking up files by path
22 CREATE INDEX IF NOT EXISTS media_files_path ON media_files (path);
23 -- Index for finding directories that need to be processed
24 CREATE INDEX IF NOT EXISTS media_files_directory_processed ON media_files (kind, processed);
22 -- index for quickly looking up files by path
23 create index media_files_path on media_files (path);
24 -- index for quickly looking up children
25 create index media_files_parent_id on media_files (parent_id);
26 -- index for quickly looking up recursive file children
27 create index media_files_file_children on media_files (kind, path);
28 -- index for finding directories that need to be processed
29 create index media_files_directory_processed on media_files (kind, processed);
2530`,
2631);
2732
......@@ -31,7 +36,7 @@ export enum MediaFileKind {
3136}
3237export class MediaFile {
3338 id!: number;
34 parent_id!: number;
39 parent_id!: number | null;
3540 /**
3641 * Has leading slash, does not have `/file` prefix.
3742 * @example "/2025/waterfalls/waterfalls.mp3"
......@@ -69,12 +74,13 @@ export class MediaFile {
6974 size!: number;
7075 /**
7176 * 0 - not processed
72 * 1 - processed
77 * non-zero - processed
7378 *
74 * file: this is for compression
79 * file: a bit-field of the processors.
7580 * directory: this is for re-indexing contents
7681 */
7782 processed!: number;
83 processors!: string;
7884
7985 // -- instance ops --
8086 get date() {
......@@ -119,8 +125,33 @@ export class MediaFile {
119125 ASSERT(result.kind === MediaFileKind.directory);
120126 return result;
121127 }
122 setCompressed(compressed: boolean) {
123 MediaFile.markCompressed(this.id, compressed);
128 setProcessed(processed: number) {
129 setProcessedQuery.run({ id: this.id, processed });
130 this.processed = processed;
131 }
132 setProcessors(processed: number, processors: string) {
133 setProcessorsQuery.run({ id: this.id, processed, processors });
134 this.processed = processed;
135 this.processors = processors;
136 }
137 setDuration(duration: number) {
138 setDurationQuery.run({ id: this.id, duration });
139 this.duration = duration;
140 }
141 setDimensions(dimensions: string) {
142 setDimensionsQuery.run({ id: this.id, dimensions });
143 this.dimensions = dimensions;
144 }
145 setContents(contents: string) {
146 setContentsQuery.run({ id: this.id, contents });
147 this.contents = contents;
148 }
149 getRecursiveFileChildren() {
150 if (this.kind !== MediaFileKind.directory) return [];
151 return getChildrenFilesRecursiveQuery.array(this.path + "/");
152 }
153 delete() {
154 deleteCascadeQuery.run({ id: this.id });
124155 }
125156
126157 // -- static ops --
......@@ -130,7 +161,7 @@ export class MediaFile {
130161 if (filePath === "/") {
131162 return Object.assign(new MediaFile(), {
132163 id: 0,
133 parent_id: 0,
164 parent_id: null,
134165 path: "/",
135166 kind: MediaFileKind.directory,
136167 timestamp: 0,
......@@ -149,11 +180,15 @@ export class MediaFile {
149180 date,
150181 hash,
151182 size,
152 duration = 0,
153 dimensions = "",
154 content = "",
183 duration,
184 dimensions,
185 contents,
155186 }: CreateFile) {
156 createFileQuery.get({
187 ASSERT(
188 !filePath.includes("\\") && filePath.startsWith("/"),
189 `Invalid path: ${filePath}`,
190 );
191 return createFileQuery.getNonNull({
157192 path: filePath,
158193 parentId: MediaFile.getOrPutDirectoryId(path.dirname(filePath)),
159194 timestamp: date.getTime(),
......@@ -162,37 +197,46 @@ export class MediaFile {
162197 size,
163198 duration,
164199 dimensions,
165 contents: content,
200 contents,
166201 });
167202 }
168203 static getOrPutDirectoryId(filePath: string) {
169 filePath = path.posix.normalize(filePath);
170 const row = getDirectoryIdQuery.get(filePath) as { id: number };
204 ASSERT(
205 !filePath.includes("\\") && filePath.startsWith("/"),
206 `Invalid path: ${filePath}`,
207 );
208 filePath = path.normalize(filePath);
209 const row = getDirectoryIdQuery.get(filePath);
171210 if (row) return row.id;
172211 let current = filePath;
173212 let parts = [];
174 let parentId: null | number = 0;
213 let parentId: null | number = null;
175214 if (filePath === "/") {
176 return createDirectoryQuery.run(filePath, 0).lastInsertRowid as number;
215 return createDirectoryQuery.getNonNull({
216 path: filePath,
217 parentId,
218 }).id;
177219 }
178 // walk down the path until we find a directory that exists
220 // walk up the path until we find a directory that exists
179221 do {
180222 parts.unshift(path.basename(current));
181223 current = path.dirname(current);
182 parentId = (getDirectoryIdQuery.get(current) as { id: number })?.id;
224 parentId = getDirectoryIdQuery.get(current)?.id ?? null;
183225 } while (parentId == undefined && current !== "/");
184226 if (parentId == undefined) {
185 parentId = createDirectoryQuery.run({
227 parentId = createDirectoryQuery.getNonNull({
186228 path: current,
187 parentId: 0,
188 }).lastInsertRowid as number;
229 parentId,
230 }).id;
189231 }
190 // walk back up the path, creating directories as needed
232 // walk back down the path, creating directories as needed
191233 for (const part of parts) {
192234 current = path.join(current, part);
193235 ASSERT(parentId != undefined);
194 parentId = createDirectoryQuery.run({ path: current, parentId })
195 .lastInsertRowid as number;
236 parentId = createDirectoryQuery.getNonNull({
237 path: current,
238 parentId,
239 }).id;
196240 }
197241 return parentId;
198242 }
......@@ -213,8 +257,8 @@ export class MediaFile {
213257 size,
214258 });
215259 }
216 static markCompressed(id: number, compressed: boolean) {
217 markCompressedQuery.run({ id, processed: compressed ? 2 : 1 });
260 static setProcessed(id: number, processed: number) {
261 setProcessedQuery.run({ id, processed });
218262 }
219263 static createOrUpdateDirectory(dirPath: string) {
220264 const id = MediaFile.getOrPutDirectoryId(dirPath);
......@@ -223,6 +267,7 @@ export class MediaFile {
223267 static getChildren(id: number) {
224268 return getChildrenQuery.array(id);
225269 }
270 static db = db;
226271}
227272
228273// Create a `file` entry with a given path, date, file hash, size, and duration
......@@ -233,9 +278,9 @@ interface CreateFile {
233278 date: Date;
234279 hash: string;
235280 size: number;
236 duration?: number;
237 dimensions?: string;
238 content?: string;
281 duration: number;
282 dimensions: string;
283 contents: string;
239284}
240285
241286// Set the `processed` flag true and update the metadata for a directory
......@@ -256,14 +301,18 @@ export interface DirConfig {
256301// -- queries --
257302
258303// Get a directory ID by path, creating it if it doesn't exist
259const createDirectoryQuery = db.prepare<[{ path: string; parentId: number }]>(
304const createDirectoryQuery = db.prepare<
305 [{ path: string; parentId: number | null }],
306 { id: number }
307>(
260308 /* SQL */ `
261309 insert into media_files (
262310 path, parent_id, kind, timestamp, hash, size,
263311 duration, dimensions, contents, dirsort, processed)
264312 values (
265313 $path, $parentId, ${MediaFileKind.directory}, 0, '', 0,
266 0, '', '', '', 0);
314 0, '', '', '', 0)
315 returning id;
267316`,
268317);
269318const getDirectoryIdQuery = db.prepare<[string], { id: number }>(/* SQL */ `
......@@ -295,14 +344,43 @@ const createFileQuery = db.prepare<[{
295344 processed = case
296345 when media_files.hash != excluded.hash then 0
297346 else media_files.processed
298 end;
299`);
300const markCompressedQuery = db.prepare<[{
347 end
348 returning *;
349`).as(MediaFile);
350const setProcessedQuery = db.prepare<[{
301351 id: number;
302352 processed: number;
303353}]>(/* SQL */ `
304354 update media_files set processed = $processed where id = $id;
305355`);
356const setProcessorsQuery = db.prepare<[{
357 id: number;
358 processed: number;
359 processors: string;
360}]>(/* SQL */ `
361 update media_files set
362 processed = $processed,
363 processors = $processors
364 where id = $id;
365`);
366const setDurationQuery = db.prepare<[{
367 id: number;
368 duration: number;
369}]>(/* SQL */ `
370 update media_files set duration = $duration where id = $id;
371`);
372const setDimensionsQuery = db.prepare<[{
373 id: number;
374 dimensions: string;
375}]>(/* SQL */ `
376 update media_files set dimensions = $dimensions where id = $id;
377`);
378const setContentsQuery = db.prepare<[{
379 id: number;
380 contents: string;
381}]>(/* SQL */ `
382 update media_files set contents = $contents where id = $id;
383`);
306384const getByPathQuery = db.prepare<[string]>(/* SQL */ `
307385 select * from media_files where path = ?;
308386`).as(MediaFile);
......@@ -330,7 +408,29 @@ const updateDirectoryQuery = db.prepare<[id: number]>(/* SQL */ `
330408const getChildrenQuery = db.prepare<[id: number]>(/* SQL */ `
331409 select * from media_files where parent_id = ?;
332410`).as(MediaFile);
411const getChildrenFilesRecursiveQuery = db.prepare<[dir: string]>(/* SQL */ `
412 select * from media_files
413 where path like ? || '%'
414 and kind = ${MediaFileKind.file}
415`).as(MediaFile);
416const deleteCascadeQuery = db.prepare<[{ id: number }]>(/* SQL */ `
417 with recursive items as (
418 select id, parent_id from media_files where id = $id
419 union all
420 select p.id, p.parent_id
421 from media_files p
422 join items c on p.id = c.parent_id
423 where p.parent_id is not null
424 and not exists (
425 select 1 from media_files child
426 where child.parent_id = p.id
427 and child.id <> c.id
428 )
429 )
430 delete from media_files
431 where id in (select id from items)
432`);
333433
334434import { getDb } from "#sitegen/sqlite";
335import * as path from "node:path";
435import * as path from "node:path/posix";
336436import { FilePermissions } from "./FilePermissions.ts";
src/file-viewer/rules.ts+6-6
......@@ -1,7 +1,7 @@
11// -- file extension rules --
22
33/** Extensions that must have EXIF/etc data stripped */
4const extScrubExif = new Set([
4export const extScrubExif = new Set([
55 ".jpg",
66 ".jpeg",
77 ".png",
......@@ -10,7 +10,7 @@ const extScrubExif = new Set([
1010 ".m4a",
1111]);
1212/** Extensions that rendered syntax-highlighted code */
13const extsCode = new Map<string, highlight.Language>(Object.entries({
13export const extsCode = new Map<string, highlight.Language>(Object.entries({
1414 ".json": "json",
1515 ".toml": "toml",
1616 ".ts": "ts",
......@@ -36,7 +36,7 @@ const extsCode = new Map<string, highlight.Language>(Object.entries({
3636 ".diff": "diff",
3737}));
3838/** These files show an audio embed. */
39const extsAudio = new Set([
39export const extsAudio = new Set([
4040 ".mp3",
4141 ".flac",
4242 ".wav",
......@@ -44,7 +44,7 @@ const extsAudio = new Set([
4444 ".m4a",
4545]);
4646/** These files show a video embed. */
47const extsVideo = new Set([
47export const extsVideo = new Set([
4848 ".mp4",
4949 ".mkv",
5050 ".webm",
......@@ -52,7 +52,7 @@ const extsVideo = new Set([
5252 ".mov",
5353]);
5454/** These files show an image embed */
55const extsImage = new Set([
55export const extsImage = new Set([
5656 ".jpg",
5757 ".jpeg",
5858 ".png",
......@@ -85,7 +85,7 @@ export const extsArchive = new Set([
8585 * Formats which are already compression formats, meaning a pass
8686 * through zstd would offer little to negative benefits
8787 */
88export const extsHaveCompression = new Set([
88export const extsPreCompressed = new Set([
8989 ...extsAudio,
9090 ...extsVideo,
9191 ...extsImage,
src/pages/resume.css created+47
......@@ -0,0 +1,47 @@
1body,html {
2 overflow: hidden;
3}
4h1 {
5 color: #f09;
6 margin-bottom: 0;
7}
8.job {
9 padding: 18px;
10 margin: 1em -18px;
11 border: 1px solid black;
12}
13.job *, footer * {
14 margin: 0;
15 padding: 0;
16}
17.job ul {
18 margin-left: 1em;
19}
20.job li {
21 line-height: 1.5em;
22}
23.job header, footer {
24 display: grid;
25 grid-template-columns: auto max-content;
26 grid-template-rows: 1fr 1fr;
27}
28footer {
29 margin-top: 1.5em;
30}
31footer h2 {
32 font-size: 1em;
33 margin-bottom: 0.5em;
34}
35
36.job header > em, footer > em {
37 margin-top: 2px;
38 font-size: 1.25em;
39}
40
41header h2, header em, footer h2, footer em {
42 display: inline-block;
43}
44 header em, footer em {
45 margin-left: 16px!important;
46 text-align: right;
47}
src/pages/resume.marko created+50
......@@ -0,0 +1,50 @@
1import "./resume.css";
2
3export const meta = { title: 'clover\'s resume' };
4
5<main>
6<h1>clover's resume</h1>
7<div>last updated: 2025</>
8
9<article.job>
10 <header>
11 <h2>web/backend engineer</h2>
12 <em>2025-now</em>
13 </>
14 <ul>
15 <i>(more details added as time goes on...)</i>
16 </ul>
17</>
18
19<article.job>
20 <header>
21 <h2>runtime/systems engineer</h2>
22 <em>2023-2025</em>
23 <p>developer tools company</p>
24 </>
25 <ul>
26 <li>hardcore engineering, elegant solutions</>
27 <li>platform compatibility & stability</>
28 <li>debugging and profiling across platforms</>
29 </ul>
30</>
31
32<article.job>
33 <header>
34 <h2>technician</h2>
35 <em>2023; part time</em>
36 <p>automotive maintainance company</p>
37 </>
38 <ul>
39 <li>pressed buttons on a computer</>
40 </ul>
41</>
42
43<footer>
44 <h2>eduation</h2> <em>2004-now</em>
45 <p>
46 my life on earth has taught me more than i expected. i <br/>
47 continue to learn new things daily, as if it was magic.
48 </p>
49</footer>
50</main>