feat: init db
This commit is contained in:
9
deno.json
Normal file
9
deno.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
deno.lock
generated
17
deno.lock
generated
@@ -149,7 +149,24 @@
|
|||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="
|
"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": {
|
"workspace": {
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/assert@1"
|
||||||
|
],
|
||||||
"packageJson": {
|
"packageJson": {
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"npm:@atproto/lexicon@~0.6.1",
|
"npm:@atproto/lexicon@~0.6.1",
|
||||||
|
|||||||
21
main.ts
21
main.ts
@@ -1,17 +1,30 @@
|
|||||||
import { Jetstream } from "@skyware/jetstream";
|
import { Jetstream } from "@skyware/jetstream";
|
||||||
|
import { createNote, updateNote } from "./src/data/db"
|
||||||
|
|
||||||
const jetstream = new Jetstream({
|
const jetstream = new Jetstream({
|
||||||
wantedCollections: ["space.litenote.note"],
|
wantedCollections: ["space.litenote.note"],
|
||||||
});
|
});
|
||||||
|
|
||||||
jetstream.onCreate("space.litenote.note", (event) => {
|
jetstream.onCreate("space.litenote.note", (event) => {
|
||||||
const { did, commit } = event;
|
console.log("create", event);
|
||||||
console.log("create", did, commit);
|
const {did, commit: {rkey, record}} = event
|
||||||
|
|
||||||
|
createNote({
|
||||||
|
did,
|
||||||
|
rkey,
|
||||||
|
...record
|
||||||
|
} as any)
|
||||||
});
|
});
|
||||||
|
|
||||||
jetstream.onUpdate("space.litenote.note", (event) => {
|
jetstream.onUpdate("space.litenote.note", (event) => {
|
||||||
const { did, commit } = event;
|
console.log("update", event);
|
||||||
console.log("update", did, commit);
|
const {did, commit: {rkey, record}} = event
|
||||||
|
|
||||||
|
updateNote({
|
||||||
|
did,
|
||||||
|
rkey,
|
||||||
|
...record
|
||||||
|
} as any)
|
||||||
});
|
});
|
||||||
|
|
||||||
jetstream.on("close", () => {
|
jetstream.on("close", () => {
|
||||||
|
|||||||
@@ -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 = "";
|
|
||||||
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 = "";
|
|
||||||
const result = extractLocalImages(md);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ full: "", alt: "photo", path: "./images/photo.png" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("skips URL images", () => {
|
|
||||||
const md = "";
|
|
||||||
expect(extractLocalImages(md)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extracts multiple local images", () => {
|
|
||||||
const md = `
|
|
||||||

|
|
||||||
Some text
|
|
||||||

|
|
||||||

|
|
||||||
`;
|
|
||||||
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 = "";
|
|
||||||
const result = extractLocalImages(md);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ full: "", alt: "", path: "./no-alt.png" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles http:// URLs (case insensitive)", () => {
|
|
||||||
const md = "";
|
|
||||||
expect(extractLocalImages(md)).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
181
publish.ts
181
publish.ts
@@ -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<string, string> = {
|
|
||||||
".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  (images start with !)
|
|
||||||
return markdown.replace(
|
|
||||||
/(?<!!)\[([^\]]*)\]\(([^)]+)\)/g,
|
|
||||||
(match, text, href) => {
|
|
||||||
if (isUnpublished(href)) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
return match;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract local image references from markdown.
|
|
||||||
* Returns matches for `` 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<BlobRef> {
|
|
||||||
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, ``);
|
|
||||||
|
|
||||||
images.push({ image: blob, alt: img.alt });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Create the record
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const record: Record<string, unknown> = {
|
|
||||||
$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 };
|
|
||||||
}
|
|
||||||
43
src/data/db.ts
Normal file
43
src/data/db.ts
Normal file
@@ -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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/data/note.ts
Normal file
8
src/data/note.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export type Note = {
|
||||||
|
did: string
|
||||||
|
rkey: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
publishedAt: Date
|
||||||
|
createdAt: Date
|
||||||
|
}
|
||||||
15
src/migrations/init.ts
Normal file
15
src/migrations/init.ts
Normal file
@@ -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();
|
||||||
Reference in New Issue
Block a user