import { DEMO_USER } from "@markone/core/user" import type { LinkBookmark, BookmarkTag } from "@markone/core/bookmark" import { type } from "arktype" import { ulid } from "ulid" import { db } from "~/database.ts" import { HttpError } from "~/error.ts" import type { User } from "~/user/user.ts" import { JSDOM } from "jsdom" import { Readability } from "@mozilla/readability" import { findBookmarkHtml } from "./bookmark.ts" const BOOKMARK_PAGINATION_LIMIT = 100 const ListUserBookmarksParams = type({ limit: ["number", "=", BOOKMARK_PAGINATION_LIMIT], skip: ["number", "=", 0], }) const AddBookmarkRequestBody = type({ "title?": "string", kind: "string", url: "string", tags: "string[]", "force?": "boolean", }) async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user: User) { const queryParams = ListUserBookmarksParams(request.params) if (queryParams instanceof type.errors) { throw new HttpError(400, "", queryParams.summary) } const listBookmarksQuery = db.query( ` SELECT bookmarks.id, bookmarks.kind, bookmarks.title, bookmarks.url FROM bookmarks WHERE bookmarks.user_id = $userId ORDER BY bookmarks.id DESC LIMIT $limit OFFSET $skip `, ) const results = listBookmarksQuery.all({ userId: user.id, limit: queryParams.limit, skip: queryParams.skip, }) return Response.json(results, { status: 200 }) } async function deleteUserBookmark(request: Bun.BunRequest<"/api/bookmark/:id">, user: User) { if (user.id !== DEMO_USER.id) { const deleteBookmarkQuery = db.query("DELETE FROM bookmarks WHERE user_id = $userId AND id = $id") const tx = db.transaction(() => { deleteBookmarkQuery.run({ userId: user.id, id: request.params.id }) }) tx() } return Response.json(undefined, { status: 204 }) } async function addBookmark(request: Bun.BunRequest<"/api/bookmarks">, user: User) { if (user.id !== DEMO_USER.id) { const body = AddBookmarkRequestBody(await request.json()) if (body instanceof type.errors) { throw new HttpError(400) } const websiteResponse = await fetch(body.url).catch(() => { if (body.force) { return null } throw new HttpError(400, "WebsiteUnreachable") }) const websiteText = websiteResponse ? await websiteResponse.text().catch(() => { throw new HttpError(400, "UnsupportedWebsite") }) : null const bookmark: LinkBookmark = { kind: "link", id: ulid(), title: "", url: body.url, tags: body.tags.map((tag) => ({ id: ulid(), name: tag })), } if (body.title) { bookmark.title = body.title } let contentHtml: string if (websiteText) { const dom = new JSDOM(websiteText, { url: body.url, }) const reader = new Readability(dom.window.document) const article = reader.parse() if (!bookmark.title) { bookmark.title = article?.title || "Untitled" } contentHtml = article?.content || "" } else { contentHtml = "" if (!bookmark.title) { bookmark.title = "Untitled" } } const query = db.query(` INSERT INTO bookmarks (id, user_id, kind, title, url, content_html) VALUES ($id, $userId, $kind, $title, $url, $html) `) query.run({ id: bookmark.id, userId: user.id, kind: bookmark.kind, title: bookmark.title, url: bookmark.url, html: contentHtml, }) const insertTagQuery = db.query(` INSERT INTO tags(id, bookmark_id, name) VALUES ($id, $bookmarkId, $name) `) const insertTags = db.transaction((tags: BookmarkTag[]) => { for (const tag of tags) { insertTagQuery.run({ bookmarkId: bookmark.id, id: tag.id, name: tag.name, }) } }) insertTags(bookmark.tags) return Response.json(bookmark, { status: 200 }) } return Response.json(undefined, { status: 204 }) } async function fetchBookmark(request: Bun.BunRequest<"/api/bookmark/:id">, user: User) { switch (request.headers.get("Accept")) { case "text/html": { const html = findBookmarkHtml(request.params.id, user) if (html === null) { throw new HttpError(404) } return new Response(html, { status: 200, headers: { "Content-Type": "text/html", }, }) } default: throw new HttpError(400, "UnsupportedAcceptHeader") } } export { addBookmark, fetchBookmark, listUserBookmarks, deleteUserBookmark }