import type { Bookmark, Tag, TagId, TaggedBookmark } from "@markone/core" import { DEMO_USER } from "@markone/core/user" 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 { LinkUnreachable, UnsupportedLink, assignTagsToBookmark, cacheContent, deleteBookmark, findBookmark, findBookmarkCachedContent, findBookmarkTags, findBookmarks, insertBookmark, updateBookmarkTags, updateBookmarkTitle, } from "./bookmark.ts" import { fuzzySearchBookmarks } from "~/bookmark/search.js" import { insertTags } from "~/tag/tag.js" const BOOKMARK_PAGINATION_LIMIT = 100 const ListUserBookmarksParams = type({ limit: ["number", "=", BOOKMARK_PAGINATION_LIMIT], skip: ["number", "=", 0], "tags?": "string", "q?": "string", }) const AddBookmarkRequestBody = type({ "title?": "string", url: "string", tags: "string[]", "force?": "boolean", }) const UpdateBookmarkRequestBody = type({ "title?": "string", "tags?": "string[]", }) const AddTagRequestBody = type({ name: "string", }) async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user: User) { const { searchParams } = new URL(request.url) const queryParams = ListUserBookmarksParams(Object.fromEntries(searchParams)) if (queryParams instanceof type.errors) { throw new HttpError(400, "", queryParams.summary) } let results: Bookmark[] if (queryParams.q || queryParams.tags) { let tagIds: TagId[] = [] if (queryParams.tags) { const tagNames = queryParams.tags.split(",") const tagIdsQuery = db.query<{ id: string }, string[]>( `SELECT id FROM tags WHERE name IN (${Array(tagNames.length).fill("?").join(",")})`, ) tagIds = tagIdsQuery.all(...tagNames).map(({ id }) => id) } if (queryParams.q) { results = fuzzySearchBookmarks({ searchTerm: queryParams.q, tagIds }, user) } else { results = findBookmarks({ ids: [], tagIds }, user) } } else { results = findBookmarks({ ids: [], tagIds: [], limit: queryParams.limit, skip: queryParams.skip }, user) } return Response.json(results, { status: 200 }) } async function deleteUserBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user: User) { if (user.id !== DEMO_USER.id) { deleteBookmark(request.params.id, user) } 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 cachedContent = await cacheContent(body.url).catch((error) => { if (error instanceof LinkUnreachable) { if (body.force) { return null } throw new HttpError(400, "LinkUnreachable") } if (error instanceof UnsupportedLink) { throw new HttpError(400, "UnsupportedLink") } console.error(error) throw new HttpError(500) }) const tagNames = new Set(body.tags) for (const tag of tagNames) { if (tag.length === 0 || /[\s#]/g.test(tag)) { throw new HttpError(400, "InvalidTag", "Tags cannot contain '#' or whitespaces") } } const bookmark: TaggedBookmark = { id: ulid(), title: "", url: body.url, tags: [], } if (body.title) { bookmark.title = body.title } else if (cachedContent?.title) { bookmark.title = cachedContent.title } insertBookmark(bookmark, cachedContent, user) if (tagNames.size > 0) { const tagQuery = db.query, string[]>(` SELECT id, name FROM tags WHERE user_id = ? AND name IN (${Array(tagNames.size).fill("?").join(",")}) `) const tags = tagQuery.all(user.id, ...tagNames) for (const tag of tags) { if (tag.id && tag.name) { bookmark.tags.push(tag as Tag) tagNames.delete(tag.name) } } if (tagNames.size > 0) { const createdTags = insertTags([...tagNames], user) bookmark.tags.push(...createdTags) } assignTagsToBookmark(bookmark.tags, bookmark) } return Response.json(bookmark, { status: 200 }) } return Response.json(undefined, { status: 204 }) } async function fetchBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user: User) { switch (request.headers.get("Accept")) { case "text/html": { const html = findBookmarkCachedContent(request.params.id, user) if (html === null) { throw new HttpError(404) } return new Response(html, { status: 200, headers: { "Content-Type": "text/html", }, }) } case "application/json": { const bookmark = findBookmark(request.params.id, user) if (bookmark === null) { throw new HttpError(404) } return Response.json(bookmark, { status: 200 }) } default: throw new HttpError(400, "UnsupportedAcceptHeader") } } async function listUserTags(request: Bun.BunRequest<"/api/tags">, user: User) { const query = db.query("SELECT id, name FROM tags WHERE user_id = $id") const tags = query.all({ id: user.id }) return Response.json(tags, { status: 200 }) } async function createUserTag(request: Bun.BunRequest<"/api/tags">, user: User) { if (user.id !== DEMO_USER.id) { const query = db.query( "INSERT INTO tags (id, name, user_id) VALUES ($id, $name, $userId)", ) const json = await request.json().catch(() => { throw new HttpError(400) }) const body = AddTagRequestBody(json) if (body instanceof type.errors) { throw new HttpError(400) } const tag: Tag = { id: ulid(), name: body.name, } query.run({ id: tag.id, name: tag.name, userId: user.id }) return Response.json(tag, { status: 200 }) } return Response.json(undefined, { status: 204 }) } async function listBookmarkTags(request: Bun.BunRequest<"/api/bookmarks/:id/tags">, user: User) { const bookmark = findBookmark(request.params.id, user) if (!bookmark) { throw new HttpError(404) } const tags = findBookmarkTags(bookmark) return Response.json(tags, { status: 200 }) } async function updateBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user: User) { const bodyJson = await request.json().catch(() => { throw new HttpError(400) }) const body = UpdateBookmarkRequestBody(bodyJson) if (body instanceof type.errors) { throw new HttpError(400) } if (!body.title || !body.tags) { return Response.json(undefined, { status: 204 }) } const bookmark = findBookmark(request.params.id, user) if (!bookmark) { throw new HttpError(404) } if (body.title) { updateBookmarkTitle(bookmark, body.title, user) bookmark.title = body.title } if (body.tags) { const taggedBookmark = bookmark as TaggedBookmark for (const tag of body.tags) { if (tag.length === 0 || /[\s#]/g.test(tag)) { throw new HttpError(400, "InvalidTag", "Tags cannot contain '#' or whitespaces") } } taggedBookmark.tags = updateBookmarkTags(bookmark, body.tags, user) return Response.json(taggedBookmark, { status: 200 }) } return Response.json(bookmark, { status: 200 }) } export { addBookmark, fetchBookmark, listUserBookmarks, deleteUserBookmark, listUserTags, createUserTag, listBookmarkTags, updateBookmark, }