276 lines
7.1 KiB
TypeScript
276 lines
7.1 KiB
TypeScript
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<Partial<Tag>, 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<Tag, { 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: 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,
|
|
}
|