implement bookmark search

This commit is contained in:
2025-05-29 00:11:17 +01:00
parent 347451dbbc
commit d5a3266870
16 changed files with 425 additions and 119 deletions

View File

@@ -17,6 +17,7 @@
"dependencies": {
"@mozilla/readability": "^0.6.0",
"arktype": "^2.1.20",
"flexsearch": "^0.8.204",
"jsdom": "^26.1.0",
"uid-safe": "^2.1.5",
"ulid": "^3.0.0"

View File

@@ -2,6 +2,7 @@ import { DEMO_USER, type User } from "@markone/core/user"
import { type } from "arktype"
import dayjs from "dayjs"
import { ulid } from "ulid"
import { initializeSearchIndexForUser } from "~/bookmark/search.js"
import { db } from "~/database.ts"
import { HttpError } from "~/error.ts"
import { httpHandler } from "~/http-handler.ts"
@@ -151,7 +152,8 @@ async function login(request: Bun.BunRequest<"/api/login">) {
const session = verifySession(request.cookies)
if (session) {
extendSession(session)
return Response.json(session, { status: 200 })
initializeSearchIndexForUser(session.user)
return Response.json(session.user, { status: 200 })
}
// then, check if there is a valid auth token
@@ -159,6 +161,7 @@ async function login(request: Bun.BunRequest<"/api/login">) {
const foundUser = await verifyAuthToken(request.cookies)
if (foundUser) {
await createSessionForUser(foundUser, request.cookies)
initializeSearchIndexForUser(foundUser)
return Response.json(foundUser, { status: 200 })
}
}
@@ -196,6 +199,8 @@ async function login(request: Bun.BunRequest<"/api/login">) {
rememberLoginForUser(user, request.cookies)
}
initializeSearchIndexForUser(user)
return Response.json(user, { status: 200 })
}

View File

@@ -2,6 +2,7 @@ import type { Bookmark, BookmarkId, Tag, TaggedBookmark } from "@markone/core"
import type { User } from "@markone/core/user"
import { Readability } from "@mozilla/readability"
import { JSDOM } from "jsdom"
import { addToSearchIndex, updateSearchIndex } from "~/bookmark/search.js"
import { db } from "~/database.ts"
import { findTagsByNames, insertTags } from "~/tag/tag.js"
import { DEMO_BOOKMARKS } from "./demo-bookmarks.ts"
@@ -42,6 +43,7 @@ VALUES ($id, $userId, $title, $url, $mimeType, $content)
mimeType: cachedContent?.mimeType || null,
content: cachedContent?.data ?? null,
})
addToSearchIndex(bookmark, user)
}
function findBookmarkCachedContent(id: string, user: User): Buffer | null {
@@ -52,7 +54,19 @@ function findBookmarkCachedContent(id: string, user: User): Buffer | null {
return row?.content ?? null
}
function findBookmark(id: string, user: User): Bookmark | null {
function findUserBookmarks({ limit = -1, skip = -1 }: { limit: number; skip: number }, user: User): Bookmark[] {
let query = "SELECT id, title, url FROM bookmarks WHERE user_id = $userId ORDER BY id DESC"
if (limit >= 0) {
query += ` LIMIT ${limit}`
}
if (skip >= 0) {
query += ` SKIP ${skip}`
}
return db.query<Bookmark, { userId: string }>(query).all({ userId: user.id })
}
function findBookmark(id: string, user: User): TaggedBookmark | null {
const bookmarkQuery = db.query<TaggedBookmark, { id: string; userId: string }>(
"SELECT id, title, url FROM bookmarks WHERE id = $id AND user_id = $userId",
)
@@ -66,13 +80,67 @@ function findBookmark(id: string, user: User): Bookmark | null {
INNER JOIN bookmark_tags
ON bookmark_tags.tag_id = tags.id AND bookmark_tags.bookmark_id = $bookmarkId
`)
const tags = tagsQuery.all({ bookmarkId: id })
bookmark.tags = tags
bookmark.tags = tagsQuery.all({ bookmarkId: id })
return bookmark
}
function findBookmarks(
{ ids, tagIds, limit, skip }: { ids: BookmarkId[]; tagIds: Tag["id"][]; limit?: number; skip?: number },
user: User,
) {
type ParamType = (string | number)[]
if (tagIds.length === 0) {
let query = `SELECT id, title, url FROM bookmarks WHERE user_id = ?${ids.length > 0 ? `AND id IN (${Array(ids.length).fill("?").join(",")})` : ""}`
if (limit) {
query += " LIMIT ?"
}
if (skip) {
query += " SKIP ?"
}
const params: (string | number)[] = [user.id, ...ids]
if (limit) {
params.push(limit)
}
if (skip) {
params.push(skip)
}
return db.query<Bookmark, ParamType>(query).all(...params)
}
const params: ParamType = [user.id, ...ids, ...tagIds]
if (limit) {
params.push(limit)
}
if (skip) {
params.push(skip)
}
return db
.query<Bookmark, ParamType>(`
SELECT bookmarks.id, bookmarks.title, bookmarks.url FROM bookmarks
INNER JOIN bookmark_tags
ON bookmark_tags.bookmark_id = bookmarks.id
WHERE bookmarks.user_id = ?
${ids.length > 0 ? `AND bookmarks.id IN (${Array(ids.length).fill("?").join(",")})` : ""}
AND bookmark_tags.tag_id IN (${Array(tagIds.length).fill("?").join(",")})
ORDER BY bookmarks.id DESC
${limit ? "LIMIT ?" : ""} ${skip ? "OFFSET ?" : ""}
`)
.all(...params)
}
function findBookmarksByIds(ids: BookmarkId[], user: User) {
return db
.query<Bookmark, string[]>(
`SELECT id, title, url FROM bookmarks WHERE user_id = ? AND id IN (${Array(ids.length).fill("?").join(",")})`,
)
.all(user.id, ...ids)
}
function updateBookmarkTitle(bookmark: Bookmark, newTitle: string, user: User) {
const query = db.query("UPDATE bookmarks SET title = $title WHERE id = $id AND user_id = $userId")
query.run({
@@ -80,6 +148,7 @@ function updateBookmarkTitle(bookmark: Bookmark, newTitle: string, user: User) {
id: bookmark.id,
userId: user.id,
})
updateSearchIndex({ ...bookmark, title: newTitle }, user)
}
function deleteBookmark(id: BookmarkId, user: User) {
@@ -207,7 +276,10 @@ export {
updateBookmarkTitle,
deleteBookmark,
findBookmark,
findBookmarks,
findUserBookmarks,
findBookmarkCachedContent,
findBookmarksByIds,
cacheContent,
assignTagsToBookmark,
findBookmarkTags,

View File

@@ -1,4 +1,4 @@
import type { Bookmark, Tag, TaggedBookmark } from "@markone/core"
import type { Bookmark, Tag, TagId, TaggedBookmark } from "@markone/core"
import { DEMO_USER } from "@markone/core/user"
import { type } from "arktype"
import { ulid } from "ulid"
@@ -14,11 +14,13 @@ import {
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
@@ -27,6 +29,7 @@ const ListUserBookmarksParams = type({
limit: ["number", "=", BOOKMARK_PAGINATION_LIMIT],
skip: ["number", "=", 0],
"tags?": "string",
"q?": "string",
})
const AddBookmarkRequestBody = type({
@@ -53,38 +56,23 @@ async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user
}
let results: Bookmark[]
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(",")})`,
)
const tagIds = tagIdsQuery.all(...tagNames).map(({ id }) => id)
const query = db.query(`
SELECT bookmarks.id, bookmarks.title, bookmarks.url FROM bookmarks
INNER JOIN bookmark_tags
ON bookmark_tags.bookmark_id = bookmarks.id
WHERE bookmarks.user_id = ? AND bookmark_tags.tag_id IN (${Array(tagIds.length).fill("?").join(",")})
ORDER BY bookmarks.id DESC
LIMIT ? OFFSET ?
`)
results = query.all(...[user.id, ...tagIds, queryParams.limit, queryParams.skip]) as 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 {
const query = db.query(`
SELECT bookmarks.id, bookmarks.title, bookmarks.url FROM bookmarks
WHERE bookmarks.user_id = $userId
ORDER BY bookmarks.id DESC
LIMIT $limit OFFSET $skip
`)
results = query.all({
userId: user.id,
limit: queryParams.limit,
skip: queryParams.skip,
}) as Bookmark[]
results = findBookmarks({ ids: [], tagIds: [], limit: queryParams.limit, skip: queryParams.skip }, user)
}
return Response.json(results, { status: 200 })

View File

@@ -0,0 +1,39 @@
import type { Bookmark, BookmarkId, TagId, User } from "@markone/core"
import { Index } from "flexsearch"
import { findBookmarks, findUserBookmarks } from "./bookmark.js"
const indices = new Map<string, Index>()
function initializeSearchIndexForUser(user: User) {
const bookmarks = findUserBookmarks({ limit: -1, skip: -1 }, user)
const index = new Index({ tokenize: "forward" })
for (const bookmark of bookmarks) {
index.add(bookmark.id, bookmark.title)
}
indices.set(user.id, index)
return index
}
function addToSearchIndex(bookmark: Bookmark, user: User) {
const index = indices.get(user.id)
index?.add(bookmark.id, bookmark.title)
}
function updateSearchIndex(updatedBookmark: Bookmark, user: User) {
const index = indices.get(user.id)
index?.update(updatedBookmark.id, updatedBookmark.title)
}
function fuzzySearchBookmarks({ searchTerm, tagIds }: { searchTerm: string; tagIds: TagId[] }, user: User): Bookmark[] {
let index = indices.get(user.id)
if (!index) {
index = initializeSearchIndexForUser(user)
}
const bookmarkIds = index.search(searchTerm) as BookmarkId[]
if (bookmarkIds.length === 0) {
return []
}
return findBookmarks({ ids: bookmarkIds, tagIds }, user)
}
export { initializeSearchIndexForUser, addToSearchIndex, updateSearchIndex, fuzzySearchBookmarks }