From 2312240d107f53e42c2dfb528b75b5615d73cdaf Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 8 Feb 2026 23:26:52 +0100 Subject: [PATCH] feat: init db --- deno.json | 9 ++ deno.lock | 17 ++++ main.ts | 21 ++++- notes.db | Bin 0 -> 12288 bytes publish.test.ts | 85 ------------------- publish.ts | 181 ----------------------------------------- src/data/db.ts | 43 ++++++++++ src/data/note.ts | 8 ++ src/migrations/init.ts | 15 ++++ 9 files changed, 109 insertions(+), 270 deletions(-) create mode 100644 deno.json create mode 100644 notes.db delete mode 100644 publish.test.ts delete mode 100644 publish.ts create mode 100644 src/data/db.ts create mode 100644 src/data/note.ts create mode 100644 src/migrations/init.ts diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..f6a1f31 --- /dev/null +++ b/deno.json @@ -0,0 +1,9 @@ +{ + "tasks": { + "dev": "deno run --watch --allow-net --allow-read --allow-write main.ts", + "migrate": "deno run src/migrations/init.ts" + }, + "imports": { + "@std/assert": "jsr:@std/assert@1" + } +} diff --git a/deno.lock b/deno.lock index e3e45cd..1c4ccc2 100644 --- a/deno.lock +++ b/deno.lock @@ -149,7 +149,24 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" } }, + "redirects": { + "https://deno.land/x/sqlite/mod.ts": "https://deno.land/x/sqlite@v3.9.1/mod.ts" + }, + "remote": { + "https://deno.land/x/sqlite@v3.9.1/build/sqlite.js": "2afc7875c7b9c85d89730c4a311ab3a304e5d1bf761fbadd8c07bbdf130f5f9b", + "https://deno.land/x/sqlite@v3.9.1/build/vfs.js": "7f7778a9fe499cd10738d6e43867340b50b67d3e39142b0065acd51a84cd2e03", + "https://deno.land/x/sqlite@v3.9.1/mod.ts": "e09fc79d8065fe222578114b109b1fd60077bff1bb75448532077f784f4d6a83", + "https://deno.land/x/sqlite@v3.9.1/src/constants.ts": "90f3be047ec0a89bcb5d6fc30db121685fc82cb00b1c476124ff47a4b0472aa9", + "https://deno.land/x/sqlite@v3.9.1/src/db.ts": "03d0c860957496eadedd86e51a6e650670764630e64f56df0092e86c90752401", + "https://deno.land/x/sqlite@v3.9.1/src/error.ts": "f7a15cb00d7c3797da1aefee3cf86d23e0ae92e73f0ba3165496c3816ab9503a", + "https://deno.land/x/sqlite@v3.9.1/src/function.ts": "bc778cab7a6d771f690afa27264c524d22fcb96f1bb61959ade7922c15a4ab8d", + "https://deno.land/x/sqlite@v3.9.1/src/query.ts": "d58abda928f6582d77bad685ecf551b1be8a15e8e38403e293ec38522e030cad", + "https://deno.land/x/sqlite@v3.9.1/src/wasm.ts": "e79d0baa6e42423257fb3c7cc98091c54399254867e0f34a09b5bdef37bd9487" + }, "workspace": { + "dependencies": [ + "jsr:@std/assert@1" + ], "packageJson": { "dependencies": [ "npm:@atproto/lexicon@~0.6.1", diff --git a/main.ts b/main.ts index 74bd878..cd8270b 100644 --- a/main.ts +++ b/main.ts @@ -1,17 +1,30 @@ import { Jetstream } from "@skyware/jetstream"; +import { createNote, updateNote } from "./src/data/db" const jetstream = new Jetstream({ wantedCollections: ["space.litenote.note"], }); jetstream.onCreate("space.litenote.note", (event) => { - const { did, commit } = event; - console.log("create", did, commit); + console.log("create", event); + const {did, commit: {rkey, record}} = event + + createNote({ + did, + rkey, + ...record + } as any) }); jetstream.onUpdate("space.litenote.note", (event) => { - const { did, commit } = event; - console.log("update", did, commit); + console.log("update", event); + const {did, commit: {rkey, record}} = event + + updateNote({ + did, + rkey, + ...record + } as any) }); jetstream.on("close", () => { diff --git a/notes.db b/notes.db new file mode 100644 index 0000000000000000000000000000000000000000..add99b2c697c6107b4476c3252cbfbadbdd0b75b GIT binary patch literal 12288 zcmeI1QBM;=5XbK-QM?53h44Ct2WZmk9j%I~fmpRB#8$D^AR1D(y)8Yuy*+QYv^BmM zjo*dO!Mpk?_ze2y?1jcaYKXpRX0y|FXZ|z0_shLZ_r>#Ss5x|G(q|fq)(y+Ht@{9$ zWo6K=piPF2KV~Ki{5I>$M#jn?ew$nPZe`|At@+c1ua_lIoCpvBB0vO)01+SpM1Tko z0U|&Ih``?y_|UPhEiEqEA3e=lf=5!PmU&pOlp7UjlpjZEV#lXGsZX`kenl)gTO}RZMz(Jk7sd->Q}C zyYQs43#*u$1Eb910kbQat4sG5Z7YldK2mQ5_V!IS&@x>&%@a1)%%|z=XARFeG<)uc zg_8ymAOb{y2oM1xKm>>Y5g-CYfC&7L1U}8!v+JL4Zfw+6%5kiq7Ye}x&|MB9jCyJr zrn((xe>;&{wxxj0RI(2&3Sj?nh#m7LTnsu+TE<9X!L+$&h9ScadMpQ^B`D3t=JuH% z@dUyMLY;>l-cs1Os&hgIXgB5sZ>q1|QCfr;TAw%ID-2MmjuZat9# zwasSJah8{%esbJ7IZk-g=E`wgcvA)}W_B}W0m&K@FenbYVRR&l zea=UR{n63*cA@9syKOIgv2fBL0z`la5CI}U1c(3;AOb{y2oM1xKm`5?0&6q1mFs`h G|NjI&A02rB literal 0 HcmV?d00001 diff --git a/publish.test.ts b/publish.test.ts deleted file mode 100644 index 38088aa..0000000 --- a/publish.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { test, expect, describe } from "bun:test"; -import { stripUnpublishedLinks, extractLocalImages } from "./publish"; - -describe("stripUnpublishedLinks", () => { - const isUnpublished = (href: string) => - !href.startsWith("http://") && !href.startsWith("https://"); - - test("strips links to unpublished notes", () => { - const md = "Check out [my draft](draft-note) for details."; - expect(stripUnpublishedLinks(md, isUnpublished)).toBe( - "Check out my draft for details.", - ); - }); - - test("keeps links to published URLs", () => { - const md = "Visit [Google](https://google.com) today."; - expect(stripUnpublishedLinks(md, isUnpublished)).toBe(md); - }); - - test("strips multiple unpublished links", () => { - const md = "See [note A](note-a) and [note B](note-b)."; - expect(stripUnpublishedLinks(md, isUnpublished)).toBe( - "See note A and note B.", - ); - }); - - test("handles mixed links", () => { - const md = - "Read [docs](https://docs.example.com) and [my note](local-note)."; - expect(stripUnpublishedLinks(md, isUnpublished)).toBe( - "Read [docs](https://docs.example.com) and my note.", - ); - }); - - test("does not strip image references", () => { - const md = "![alt text](./images/photo.png)"; - expect(stripUnpublishedLinks(md, isUnpublished)).toBe(md); - }); - - test("handles empty link text", () => { - const md = "[](unpublished-ref)"; - expect(stripUnpublishedLinks(md, isUnpublished)).toBe(""); - }); -}); - -describe("extractLocalImages", () => { - test("extracts local image paths", () => { - const md = "![photo](./images/photo.png)"; - const result = extractLocalImages(md); - expect(result).toEqual([ - { full: "![photo](./images/photo.png)", alt: "photo", path: "./images/photo.png" }, - ]); - }); - - test("skips URL images", () => { - const md = "![logo](https://example.com/logo.png)"; - expect(extractLocalImages(md)).toEqual([]); - }); - - test("extracts multiple local images", () => { - const md = ` -![a](./a.png) -Some text -![b](../assets/b.jpg) -![c](https://cdn.example.com/c.webp) -`; - const result = extractLocalImages(md); - expect(result).toHaveLength(2); - expect(result[0].path).toBe("./a.png"); - expect(result[1].path).toBe("../assets/b.jpg"); - }); - - test("handles images with empty alt", () => { - const md = "![](./no-alt.png)"; - const result = extractLocalImages(md); - expect(result).toEqual([ - { full: "![](./no-alt.png)", alt: "", path: "./no-alt.png" }, - ]); - }); - - test("handles http:// URLs (case insensitive)", () => { - const md = "![img](HTTP://example.com/img.png)"; - expect(extractLocalImages(md)).toEqual([]); - }); -}); diff --git a/publish.ts b/publish.ts deleted file mode 100644 index ee163d2..0000000 --- a/publish.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Publish a markdown note as an AT Protocol record. - * - * - Strips links to unpublished notes (keeps just the link text) - * - Uploads local images as blobs and replaces paths with CIDs - * - Creates the space.litenote.note record - */ - -import { resolve, dirname } from "path"; - -interface BlobRef { - $type: "blob"; - ref: { $link: string }; - mimeType: string; - size: number; -} - -interface ImageEntry { - image: BlobRef; - alt: string; -} - -interface PublishOptions { - /** PDS host, e.g. "https://bsky.social" */ - pdsHost: string; - /** Author DID */ - did: string; - /** Auth token for XRPC calls */ - accessJwt: string; - /** Note title */ - title: string; - /** Raw markdown content */ - content: string; - /** Base directory for resolving relative image paths */ - baseDir: string; - /** Predicate that returns true if a link target is an unpublished note */ - isUnpublishedLink?: (href: string) => boolean; -} - -const MIME_TYPES: Record = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".svg": "image/svg+xml", - ".avif": "image/avif", -}; - -/** - * Strip markdown links where the href points to an unpublished note. - * `[text](unpublished-ref)` → `text` - */ -export function stripUnpublishedLinks( - markdown: string, - isUnpublished: (href: string) => boolean, -): string { - // Match [text](href) but not ![alt](src) (images start with !) - return markdown.replace( - /(? { - if (isUnpublished(href)) { - return text; - } - return match; - }, - ); -} - -/** - * Extract local image references from markdown. - * Returns matches for `![alt](path)` where path is not a URL. - */ -export function extractLocalImages( - markdown: string, -): Array<{ full: string; alt: string; path: string }> { - const results: Array<{ full: string; alt: string; path: string }> = []; - const regex = /!\[([^\]]*)\]\(([^)]+)\)/g; - let m: RegExpExecArray | null; - while ((m = regex.exec(markdown)) !== null) { - const [full, alt, path] = m; - // Skip URLs - if (/^https?:\/\//i.test(path)) continue; - results.push({ full, alt, path }); - } - return results; -} - -function getMimeType(filePath: string): string { - const ext = filePath.toLowerCase().match(/\.[^.]+$/)?.[0] ?? ""; - return MIME_TYPES[ext] ?? "application/octet-stream"; -} - -async function uploadBlob( - pdsHost: string, - accessJwt: string, - filePath: string, -): Promise { - const file = Bun.file(filePath); - const bytes = await file.arrayBuffer(); - const mimeType = getMimeType(filePath); - - const res = await fetch(`${pdsHost}/xrpc/com.atproto.repo.uploadBlob`, { - method: "POST", - headers: { - "Content-Type": mimeType, - Authorization: `Bearer ${accessJwt}`, - }, - body: bytes, - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`uploadBlob failed (${res.status}): ${text}`); - } - - const data = (await res.json()) as { blob: BlobRef }; - return data.blob; -} - -export async function publish(opts: PublishOptions) { - const { pdsHost, did, accessJwt, title, baseDir } = opts; - let content = opts.content; - - // 1. Strip links to unpublished notes - if (opts.isUnpublishedLink) { - content = stripUnpublishedLinks(content, opts.isUnpublishedLink); - } - - // 2. Extract & upload local images - const localImages = extractLocalImages(content); - const images: ImageEntry[] = []; - - for (const img of localImages) { - const absPath = resolve(baseDir, img.path); - const blob = await uploadBlob(pdsHost, accessJwt, absPath); - const cid = blob.ref.$link; - - // Replace the local path with the CID in markdown - content = content.replace(img.full, `![${img.alt}](${cid})`); - - images.push({ image: blob, alt: img.alt }); - } - - // 3. Create the record - const now = new Date().toISOString(); - const record: Record = { - $type: "space.litenote.note", - title, - content, - publishedAt: now, - createdAt: now, - }; - - if (images.length > 0) { - record.images = images; - } - - const res = await fetch( - `${pdsHost}/xrpc/com.atproto.repo.createRecord`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessJwt}`, - }, - body: JSON.stringify({ - repo: did, - collection: "space.litenote.note", - record, - }), - }, - ); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`createRecord failed (${res.status}): ${text}`); - } - - return (await res.json()) as { uri: string; cid: string }; -} diff --git a/src/data/db.ts b/src/data/db.ts new file mode 100644 index 0000000..27fa156 --- /dev/null +++ b/src/data/db.ts @@ -0,0 +1,43 @@ +import { DB } from "https://deno.land/x/sqlite/mod.ts"; +import type { Note } from "./note" + +export const db = new DB("notes.db"); + +export const createNote = async (note: Note) => { + return db.query( + ` + INSERT INTO note ( + title, content, publishedAt, createdAt, did, rkey + ) VALUES (?, ?, ?, ?, ?, ?) + `, + [ + note.title, + note.content, + new Date(note.publishedAt).toISOString(), // publishedAt + new Date(note.createdAt).toISOString(), // createdAt + note.did, + note.rkey, + ], + ); +} + +export const updateNote = async (note: Note) => { + db.query( + ` + UPDATE note + SET + title = ?, + content = ?, + publishedAt = ? + WHERE did = ? + AND rkey = ? + `, + [ + note.title, + note.content, + note.publishedAt, // publishedAt + note.did, + note.rkey, + ], + ); +} \ No newline at end of file diff --git a/src/data/note.ts b/src/data/note.ts new file mode 100644 index 0000000..bd4346b --- /dev/null +++ b/src/data/note.ts @@ -0,0 +1,8 @@ +export type Note = { + did: string + rkey: string + title: string + content: string + publishedAt: Date + createdAt: Date +} \ No newline at end of file diff --git a/src/migrations/init.ts b/src/migrations/init.ts new file mode 100644 index 0000000..149a4b2 --- /dev/null +++ b/src/migrations/init.ts @@ -0,0 +1,15 @@ +import { db } from "../data/db" + +db.execute(` + CREATE TABLE IF NOT EXISTS note ( + title TEXT NOT NULL, + content TEXT NOT NULL, + publishedAt DATETIME, + createdAt DATETIME NOT NULL, + did TEXT NOT NULL, + rkey TEXT NOT NULL, + PRIMARY KEY (did, rkey) + ); +`); + +db.close(); \ No newline at end of file