implement bookmark tagging

This commit is contained in:
2025-05-21 13:18:16 +01:00
parent f048dee6e2
commit b0d458e5ca
20 changed files with 826 additions and 362 deletions

View File

@@ -1,11 +1,21 @@
import type { Bookmark, BookmarkTag } from "@markone/core/bookmark"
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 { LinkUnreachable, UnsupportedLink, findBookmarkHtml, cacheWebsite, findBookmark } from "./bookmark.ts"
import {
LinkUnreachable,
UnsupportedLink,
assignTagsToBookmark,
cacheContent,
findBookmark,
findBookmarkCachedContent,
findBookmarkTags,
insertBookmark,
} from "./bookmark.ts"
import { insertTags } from "./tag.ts"
const BOOKMARK_PAGINATION_LIMIT = 100
@@ -16,12 +26,15 @@ const ListUserBookmarksParams = type({
const AddBookmarkRequestBody = type({
"title?": "string",
kind: "string",
url: "string",
tags: "string[]",
"force?": "boolean",
})
const AddTagRequestBody = type({
name: "string",
})
async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user: User) {
const queryParams = ListUserBookmarksParams(request.params)
if (queryParams instanceof type.errors) {
@@ -30,7 +43,7 @@ async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user
const listBookmarksQuery = db.query(
`
SELECT bookmarks.id, bookmarks.kind, bookmarks.title, bookmarks.url FROM bookmarks
SELECT bookmarks.id, bookmarks.title, bookmarks.url FROM bookmarks
WHERE bookmarks.user_id = $userId
ORDER BY bookmarks.id DESC
LIMIT $limit OFFSET $skip
@@ -61,10 +74,11 @@ 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) {
console.log(body)
throw new HttpError(400)
}
const cachedWebsite = await cacheWebsite(body.url).catch((error) => {
const cachedContent = await cacheContent(body.url).catch((error) => {
if (error instanceof LinkUnreachable) {
if (body.force) {
return null
@@ -78,48 +92,45 @@ async function addBookmark(request: Bun.BunRequest<"/api/bookmarks">, user: User
throw new HttpError(500)
})
const bookmark: LinkBookmark = {
kind: "link",
const tagNames = new Set(body.tags)
for (const tag of tagNames) {
if (/[\s#]/g.test(tag)) {
throw new HttpError(400, "InvalidTag", "Tags cannot contain '#' or whitespaces")
}
}
const bookmark: Bookmark = {
id: ulid(),
title: "",
url: body.url,
tags: body.tags.map((tag) => ({ id: ulid(), name: tag })),
tags: [],
}
if (body.title) {
bookmark.title = body.title
} else if (cachedWebsite?.title) {
bookmark.title = cachedWebsite.title
} else if (cachedContent?.title) {
bookmark.title = cachedContent.title
}
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: cachedWebsite?.readableHtml ?? "",
})
insertBookmark(bookmark, cachedContent, user)
if (tagNames.size > 0) {
const tagQuery = db.query<Partial<BookmarkTag>, string[]>(`
SELECT id, name FROM tags
WHERE user_id = ? AND name IN (${Array(tagNames.size).fill("?").join(",")})
`)
const tags = tagQuery.all(user.id, ...tagNames)
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,
})
if (tag.id && tag.name) {
bookmark.tags.push(tag as BookmarkTag)
tagNames.delete(tag.name)
}
}
})
insertTags(bookmark.tags)
const createdTags = insertTags([...tagNames], user)
assignTagsToBookmark(createdTags, bookmark)
}
return Response.json(bookmark, { status: 200 })
}
@@ -130,7 +141,7 @@ VALUES ($id, $bookmarkId, $name)
async function fetchBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user: User) {
switch (request.headers.get("Accept")) {
case "text/html": {
const html = findBookmarkHtml(request.params.id, user)
const html = findBookmarkCachedContent(request.params.id, user)
if (html === null) {
throw new HttpError(404)
}
@@ -155,4 +166,53 @@ async function fetchBookmark(request: Bun.BunRequest<"/api/bookmarks/:id">, user
}
}
export { addBookmark, fetchBookmark, listUserBookmarks, deleteUserBookmark }
async function listUserTags(request: Bun.BunRequest<"/api/tags">, user: User) {
const query = db.query<BookmarkTag, { id: string }>("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<void, { id: string; name: string; userId: string }>(
"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: BookmarkTag = {
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 })
}
export {
addBookmark,
fetchBookmark,
listUserBookmarks,
deleteUserBookmark,
listUserTags,
createUserTag,
listBookmarkTags,
}