Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/nperez0111/bookhive. Track your books, share your shelves, see what others are reading bookhive.buzz
atproto bluesky books bookshelf goodreads management-system
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480/** * List CRUD utilities: PDS writes + local DB mirroring. */import * as TID from "@atcute/tid";import { ids } from "../bsky/lexicon/ids.js";import type { SessionClient } from "../auth/client";import type { Database } from "../db";import type { HiveId } from "../types";import { loadGenresForHiveBook } from "./hiveBookGenres.js";import { sql } from "kysely";
type ListDeps = { agent: SessionClient; db: Database;};
export async function createList({ agent, db, name, description, ordered, tags,}: ListDeps & { name: string; description?: string; ordered?: boolean; tags?: string[];}): Promise<{ uri: string; cid: string }> { const now = new Date().toISOString(); const rkey = TID.now();
const record = { $type: ids.SocialPopfeedFeedList, name, description, ordered: ordered ?? false, tags, createdAt: now, };
const response = await agent.post("com.atproto.repo.applyWrites", { input: { repo: agent.did, writes: [ { $type: "com.atproto.repo.applyWrites#create", collection: ids.SocialPopfeedFeedList, rkey, value: record, }, ], }, });
if (!response.ok) { throw new Error("Failed to create list on PDS"); }
const applyData = response.data as { results: Array<{ $type: string; uri: string; cid: string }>; }; const result = applyData.results[0]; if (!result || result.$type !== "com.atproto.repo.applyWrites#createResult") { throw new Error("Failed to create list record"); }
await db .insertInto("book_list") .values({ uri: result.uri, cid: result.cid, userDid: agent.did, name, description: description ?? null, ordered: ordered ? 1 : 0, tags: tags ? JSON.stringify(tags) : null, createdAt: now, indexedAt: now, }) .execute();
return { uri: result.uri, cid: result.cid };}
export async function updateList({ agent, db, uri, name, description, ordered, tags,}: ListDeps & { uri: string; name?: string; description?: string; ordered?: boolean; tags?: string[];}): Promise<{ uri: string; cid: string }> { const existing = await db .selectFrom("book_list") .selectAll() .where("uri", "=", uri) .executeTakeFirst();
if (!existing) throw new Error("List not found"); if (existing.userDid !== agent.did) throw new Error("Not authorized to update this list");
const rkey = uri.split("/").at(-1)!;
// Fetch current record from PDS to get full value for update const getRes = await agent.get("com.atproto.repo.getRecord", { params: { repo: agent.did, collection: ids.SocialPopfeedFeedList, rkey, }, }); if (!getRes.ok) throw new Error("Failed to fetch list record from PDS"); const currentRecord = (getRes.data as { value: Record<string, unknown> }).value;
const updatedRecord = { ...currentRecord, name: name ?? existing.name, description: description !== undefined ? description : existing.description, ordered: ordered !== undefined ? ordered : Boolean(existing.ordered), tags: tags !== undefined ? tags : existing.tags ? JSON.parse(existing.tags) : undefined, };
const response = await agent.post("com.atproto.repo.applyWrites", { input: { repo: agent.did, writes: [ { $type: "com.atproto.repo.applyWrites#update", collection: ids.SocialPopfeedFeedList, rkey, value: updatedRecord, }, ], }, });
if (!response.ok) throw new Error("Failed to update list on PDS");
const applyData = response.data as { results: Array<{ $type: string; uri: string; cid: string }>; }; const result = applyData.results[0]; if (!result || result.$type !== "com.atproto.repo.applyWrites#updateResult") { throw new Error("Failed to update list record"); }
await db .updateTable("book_list") .set({ cid: result.cid, name: name ?? existing.name, description: description !== undefined ? description || null : existing.description, ordered: ordered !== undefined ? (ordered ? 1 : 0) : existing.ordered, tags: tags !== undefined ? (tags ? JSON.stringify(tags) : null) : existing.tags, indexedAt: new Date().toISOString(), }) .where("uri", "=", uri) .execute();
return { uri: result.uri, cid: result.cid };}
export async function deleteList({ agent, db, uri }: ListDeps & { uri: string }): Promise<void> { const existing = await db .selectFrom("book_list") .selectAll() .where("uri", "=", uri) .executeTakeFirst();
if (!existing) throw new Error("List not found"); if (existing.userDid !== agent.did) throw new Error("Not authorized to delete this list");
// Get all items for this list to delete from PDS const items = await db .selectFrom("book_list_item") .select("uri") .where("listUri", "=", uri) .execute();
const writes: Array<Record<string, unknown>> = [];
// Delete all items for (const item of items) { writes.push({ $type: "com.atproto.repo.applyWrites#delete", collection: ids.SocialPopfeedFeedListItem, rkey: item.uri.split("/").at(-1)!, }); }
// Delete the list itself writes.push({ $type: "com.atproto.repo.applyWrites#delete", collection: ids.SocialPopfeedFeedList, rkey: uri.split("/").at(-1)!, });
// Batch in groups of 200 (PDS limit) for (let i = 0; i < writes.length; i += 200) { const response = await agent.post("com.atproto.repo.applyWrites", { input: { repo: agent.did, writes: writes.slice(i, i + 200) }, }); if (!response.ok) throw new Error("Failed to delete list from PDS"); }
// Delete from local DB await db.deleteFrom("book_list_item").where("listUri", "=", uri).execute(); await db.deleteFrom("book_list").where("uri", "=", uri).execute();}
export async function addBookToList({ agent, db, listUri, hiveId, description, position,}: ListDeps & { listUri: string; hiveId: HiveId; description?: string; position?: number;}): Promise<{ uri: string; cid: string }> { const list = await db .selectFrom("book_list") .selectAll() .where("uri", "=", listUri) .executeTakeFirst();
if (!list) throw new Error("List not found"); if (list.userDid !== agent.did) throw new Error("Not authorized to modify this list");
// Get book metadata for the PDS record const book = await db .selectFrom("hive_book") .selectAll() .where("id", "=", hiveId) .executeTakeFirst();
if (!book) throw new Error("Book not found");
const listItemGenres = await loadGenresForHiveBook(db, hiveId);
// Get book identifiers for cross-app interop const idRow = await db .selectFrom("book_id_map") .selectAll() .where("hiveId", "=", hiveId) .executeTakeFirst();
const now = new Date().toISOString(); const rkey = TID.now();
const identifiers: Record<string, string> = { hiveId }; if (idRow?.isbn) identifiers["isbn10"] = idRow.isbn; if (idRow?.isbn13) identifiers["isbn13"] = idRow.isbn13;
const authors: string[] = book.authors.split("\t");
const record = { $type: ids.SocialPopfeedFeedListItem, creativeWorkType: "book", identifiers, title: book.title, mainCredit: authors[0], mainCreditRole: "author", posterUrl: book.cover ?? book.thumbnail, genres: listItemGenres.length > 0 ? listItemGenres : undefined, listUri, addedAt: now, description, position, };
const response = await agent.post("com.atproto.repo.applyWrites", { input: { repo: agent.did, writes: [ { $type: "com.atproto.repo.applyWrites#create", collection: ids.SocialPopfeedFeedListItem, rkey, value: record, }, ], }, });
if (!response.ok) throw new Error("Failed to add book to list on PDS");
const applyData = response.data as { results: Array<{ $type: string; uri: string; cid: string }>; }; const result = applyData.results[0]; if (!result || result.$type !== "com.atproto.repo.applyWrites#createResult") { throw new Error("Failed to create list item record"); }
await db .insertInto("book_list_item") .values({ uri: result.uri, cid: result.cid, userDid: agent.did, listUri, hiveId, description: description ?? null, position: position ?? null, addedAt: now, indexedAt: now, embeddedTitle: null, embeddedAuthor: null, embeddedCoverUrl: null, identifiers: JSON.stringify(identifiers), }) .execute();
return { uri: result.uri, cid: result.cid };}
export async function removeBookFromList({ agent, db, itemUri,}: ListDeps & { itemUri: string }): Promise<void> { const item = await db .selectFrom("book_list_item") .selectAll() .where("uri", "=", itemUri) .executeTakeFirst();
if (!item) throw new Error("List item not found"); if (item.userDid !== agent.did) throw new Error("Not authorized to modify this list");
const rkey = itemUri.split("/").at(-1)!;
const response = await agent.post("com.atproto.repo.applyWrites", { input: { repo: agent.did, writes: [ { $type: "com.atproto.repo.applyWrites#delete", collection: ids.SocialPopfeedFeedListItem, rkey, }, ], }, });
if (!response.ok) throw new Error("Failed to remove book from list on PDS");
await db.deleteFrom("book_list_item").where("uri", "=", itemUri).execute();}
export async function reorderListItems({ agent, db, listUri, itemUris,}: ListDeps & { listUri: string; itemUris: string[];}): Promise<void> { const list = await db .selectFrom("book_list") .selectAll() .where("uri", "=", listUri) .executeTakeFirst();
if (!list) throw new Error("List not found"); if (list.userDid !== agent.did) throw new Error("Not authorized to modify this list");
// Update the list record on PDS with new itemOrder const rkey = listUri.split("/").at(-1)!; const getRes = await agent.get("com.atproto.repo.getRecord", { params: { repo: agent.did, collection: ids.SocialPopfeedFeedList, rkey, }, }); if (!getRes.ok) throw new Error("Failed to fetch list record from PDS"); const currentRecord = (getRes.data as { value: Record<string, unknown> }).value;
const updatedRecord = { ...currentRecord, itemOrder: itemUris, };
const response = await agent.post("com.atproto.repo.applyWrites", { input: { repo: agent.did, writes: [ { $type: "com.atproto.repo.applyWrites#update", collection: ids.SocialPopfeedFeedList, rkey, value: updatedRecord, }, ], }, });
if (!response.ok) throw new Error("Failed to update list order on PDS");
// Update positions in local DB const caseFragments = itemUris.map((uri, idx) => sql`WHEN ${uri} THEN ${idx}`); if (caseFragments.length > 0) { await db .updateTable("book_list_item") .set({ position: sql`CASE uri ${sql.join(caseFragments, sql` `)} END`, indexedAt: new Date().toISOString(), }) .where("uri", "in", itemUris) .execute(); }}
export async function getUserLists({ db, userDid }: { db: Database; userDid: string }) { return db .selectFrom("book_list") .selectAll() .select((eb) => [ eb .selectFrom("book_list_item") .select(eb.fn.countAll<number>().as("count")) .whereRef("book_list_item.listUri", "=", "book_list.uri") .as("itemCount"), ]) .where("userDid", "=", userDid) .orderBy("createdAt", "desc") .execute();}
export async function getListWithItems({ db, listUri }: { db: Database; listUri: string }) { const list = await db .selectFrom("book_list") .selectAll() .where("uri", "=", listUri) .executeTakeFirst();
if (!list) return null;
const items = await db .selectFrom("book_list_item") .leftJoin("hive_book", "book_list_item.hiveId", "hive_book.id") .select([ "book_list_item.uri", "book_list_item.cid", "book_list_item.hiveId", "book_list_item.description", "book_list_item.position", "book_list_item.addedAt", "book_list_item.embeddedTitle", "book_list_item.embeddedAuthor", "book_list_item.embeddedCoverUrl", "book_list_item.identifiers", "hive_book.title", "hive_book.authors", "hive_book.thumbnail", "hive_book.cover", "hive_book.rating", ]) .where("book_list_item.listUri", "=", listUri) .orderBy("book_list_item.position", "asc") .orderBy("book_list_item.addedAt", "desc") .execute();
return { list, items };}