From 1890026bbe51970bef3dc22ab243db8a3b434c6a Mon Sep 17 00:00:00 2001 From: wulujia Date: Fri, 4 Sep 2026 21:18:51 +0800 Subject: [PATCH] feat(tag): vocabulary ownership, multi-tag filter, tag management routes - sr_user_tag gains source ("auto" | "mine") and last_used_at; sr_user_bookmark_tag gains source ("user" | "ai"). Migration backfills last_used_at from live links. - Vocabulary (listUserTags) now filters display=true and orders mine-first by recency; the AI picker (pickTagsForBookmark) keeps "mine" ahead and caps at 3. - Prompt lists "my tags" before fallback tags. - AI attachments write source=ai without touching last_used_at; user attachments write source=user and bump last_used_at. updateUserTagsDisplay no longer revives hidden tags on the AI path, so a deleted word stays deleted. - New routes: /v1/tag/promote, /v1/tag/demote, /v1/tag/delete (the web already called delete; it was a 404). /v1/tag/update and the new routes accept tag_uuid for local-first clients. /v1/tag/list?within=a,b returns candidate tags with counts. - /v1/bookmark/list accepts topic_ids=a,b (intersection via metadata.tags containment) and filter=untagged; list rows now carry tags[] with added_by. - add_tag / add_tags accept bookmark_uid. - deleteBookmarkTag is a soft delete, matching the PowerSync path. Orphaned auto tags are hidden; "mine" tags stay at zero links. - Sync batch: created tags are "mine"; added links are "user" and bump recency; auto-hide only applies to auto tags and is scoped by user_id. - Regenerated router and DI (the DI file also picks up ContentOrchestrator, which was missing from the generated file). CI test step runs vitest once instead of watch. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011QiL9KgdsseJDNAhSqwnCG --- prisma/hyperdrive.prisma | 9 + .../migration.sql | 15 ++ src/const/prompt.ts | 16 +- src/di/generated/dependency.ts | 8 +- src/di/generated/readerRouter.ts | 12 + src/domain/aigc.ts | 9 +- src/domain/bookmark.ts | 71 +++--- src/domain/orchestrator/urlParser.ts | 9 +- src/domain/tag.ts | 225 ++++++++++++++---- src/domain/telegram.ts | 2 +- src/handler/http/bookmarkController.ts | 30 +-- src/handler/http/tagController.ts | 62 ++++- src/infra/repository/dbBookmark.ts | 196 ++++++++++++--- src/infra/repository/dbSyncBatch.ts | 21 +- src/utils/tags.ts | 53 +++++ test/const/prompt.test.ts | 31 +++ test/utils/tags.test.ts | 60 +++++ 17 files changed, 674 insertions(+), 155 deletions(-) create mode 100644 prisma/migrations/20260905000000_add_tag_source_and_last_used/migration.sql create mode 100644 src/utils/tags.ts create mode 100644 test/const/prompt.test.ts create mode 100644 test/utils/tags.test.ts diff --git a/prisma/hyperdrive.prisma b/prisma/hyperdrive.prisma index 16b7fae..b102815 100644 --- a/prisma/hyperdrive.prisma +++ b/prisma/hyperdrive.prisma @@ -140,6 +140,8 @@ model sr_user_bookmark_tag { tag_id Int @default(0) created_at DateTime @default(now()) is_deleted Boolean @default(false) + // who attached this tag: "user" | "ai"; "" for rows created before the column existed + source String @default("") bookmark sr_bookmark? @relation(fields: [bookmark_id], references: [id]) user_bookmark sr_user_bookmark? @relation(fields: [user_id, bookmark_id], references: [user_id, bookmark_id]) @@ -169,7 +171,14 @@ model sr_user_tag { display Boolean @default(true) created_at DateTime @default(now()) + // "auto": in the vocabulary but never confirmed by the user + // "mine": confirmed by the user, AI picks these first + source String @default("auto") + // last time the user (not AI) attached this tag to a bookmark + last_used_at DateTime? + @@unique([user_id, tag_name]) + @@index([user_id, display, source]) } model sr_bookmark_comment { diff --git a/prisma/migrations/20260905000000_add_tag_source_and_last_used/migration.sql b/prisma/migrations/20260905000000_add_tag_source_and_last_used/migration.sql new file mode 100644 index 0000000..ee2fb14 --- /dev/null +++ b/prisma/migrations/20260905000000_add_tag_source_and_last_used/migration.sql @@ -0,0 +1,15 @@ +-- sr_user_tag: vocabulary ownership + recency +ALTER TABLE "sr_user_tag" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'auto'; +ALTER TABLE "sr_user_tag" ADD COLUMN "last_used_at" TIMESTAMP(3); + +-- sr_user_bookmark_tag: who attached the tag ("user" | "ai"), "" for history +ALTER TABLE "sr_user_bookmark_tag" ADD COLUMN "source" TEXT NOT NULL DEFAULT ''; + +CREATE INDEX "sr_user_tag_user_id_display_source_idx" ON "sr_user_tag"("user_id", "display", "source"); + +-- Backfill last_used_at from the newest live link. History cannot tell user from AI, +-- so every existing link counts once. +UPDATE "sr_user_tag" t SET "last_used_at" = ( + SELECT MAX(bt."created_at") FROM "sr_user_bookmark_tag" bt + WHERE bt."tag_id" = t."id" AND bt."user_id" = t."user_id" AND bt."is_deleted" = false +); diff --git a/src/const/prompt.ts b/src/const/prompt.ts index f0c2021..381cbd4 100644 --- a/src/const/prompt.ts +++ b/src/const/prompt.ts @@ -130,7 +130,15 @@ ${byline} ${content}` } -export const generateOverviewTagsUserPrompt = function (userLang: string, tags: string[]) { +export interface TagVocabularyPrompt { + /** confirmed by the user: pick these first */ + mine: string[] + /** in the vocabulary but never confirmed */ + auto: string[] +} + +export const generateOverviewTagsUserPrompt = function (userLang: string, tags: string[] | TagVocabularyPrompt) { + const vocabulary: TagVocabularyPrompt = Array.isArray(tags) ? { mine: [], auto: tags } : tags return `## 你需要输出tags - 从提供的标签列表中选择最符合文章内容的标签,数量可以是0~3个 - 宁缺毋滥:如果列表中没有与文章核心内容真正匹配的标签,就一个都不选,输出空数组 []。勉强选择一个沾边的标签,比不选择更糟糕 @@ -141,8 +149,10 @@ export const generateOverviewTagsUserPrompt = function (userLang: string, tags: - 标签选择要基于文章实际内容,避免主观臆测 - 不要因为标签描述的是读者可能的兴趣而选择它,标签必须描述文章本身的内容 - 生成标签列表时,语言则只能跟随用户的标签列表,不可以擅自翻译 -- 标签的列表: -${tags.join(',')} +- 我的标签(能对上就必须优先用): +${vocabulary.mine.join(',')} +- 备选标签(我的标签都对不上时才用): +${vocabulary.auto.join(',')} ## 你需要输出overview - 概述文章的核心主题和主要内容,overview的内容包括 diff --git a/src/di/generated/dependency.ts b/src/di/generated/dependency.ts index 84fa918..a4c2b27 100644 --- a/src/di/generated/dependency.ts +++ b/src/di/generated/dependency.ts @@ -23,11 +23,11 @@ import { ReportRepo } from '../../infra/repository/dbReport' import { BookmarkService } from '../../domain/bookmark' import { TagService } from '../../domain/tag' import { MarkService } from '../../domain/mark' +import { UserService } from '../../domain/user' import { ImportService } from '../../domain/import' import { UrlParserHandler } from '../../domain/orchestrator/urlParser' import { NotificationService } from '../../domain/notification' import { ShareService } from '../../domain/share' -import { UserService } from '../../domain/user' import { DBSyncBatchOperation } from '../../infra/repository/dbSyncBatch' import { QueueClient } from '../../infra/queue/queueClient' import { AigcService } from '../../domain/aigc' @@ -39,6 +39,7 @@ import { ShareOrchestrator } from '../../domain/orchestrator/share' import { SyncOrchestrator } from '../../domain/orchestrator/sync' import { ImportOrchestrator } from '../../domain/orchestrator/import' import { EmailService } from '../../domain/email' +import { ContentOrchestrator } from '../../domain/orchestrator/content' import { BookmarkJob } from '../../handler/cron/bookmarkJob' import { BookmarkConsumer } from '../../handler/queue/bookmarkConsumer' import { BucketClient } from '../../infra/repository/bucketClient' @@ -124,6 +125,11 @@ container.register(BookmarkOrchestrator, { useFactory: container => new BookmarkOrchestrator(container.resolve(BookmarkService), container.resolve(TagService), container.resolve(MarkService)) }) +container.register(ContentOrchestrator, { + useFactory: container => + new ContentOrchestrator(container.resolve(BookmarkService), container.resolve(UserService), container.resolve(TagService), container.resolve(MarkService)) +}) + container.register(ImportOrchestrator, { useFactory: container => new ImportOrchestrator( diff --git a/src/di/generated/readerRouter.ts b/src/di/generated/readerRouter.ts index d5d4815..2275211 100644 --- a/src/di/generated/readerRouter.ts +++ b/src/di/generated/readerRouter.ts @@ -197,6 +197,18 @@ export function getRouter(container: Container) { const controller = container.resolve(TagController) return await controller.handleCreateTagRequest(ctx, req) }) + router.post('/v1/tag/promote', async (req: Request, ctx: ContextManager) => { + const controller = container.resolve(TagController) + return await controller.handlePromoteTagRequest(ctx, req) + }) + router.post('/v1/tag/demote', async (req: Request, ctx: ContextManager) => { + const controller = container.resolve(TagController) + return await controller.handleDemoteTagRequest(ctx, req) + }) + router.post('/v1/tag/delete', async (req: Request, ctx: ContextManager) => { + const controller = container.resolve(TagController) + return await controller.handleDeleteTagRequest(ctx, req) + }) router.post('/v1/user/login', async (req: Request, ctx: ContextManager) => { const controller = container.resolve(UserController) return await controller.handleUserLoginRequest(ctx, req) diff --git a/src/domain/aigc.ts b/src/domain/aigc.ts index 23471ac..98d6807 100644 --- a/src/domain/aigc.ts +++ b/src/domain/aigc.ts @@ -9,6 +9,7 @@ import { buildChatSystemInstruction, buildChatUserMessage } from '../const/prompt' +import type { TagVocabularyPrompt } from '../const/prompt' import { ContextManager } from '../utils/context' import { ContentParser } from '../utils/parser' import { inject, injectable } from '../decorators/di' @@ -388,7 +389,13 @@ export class AigcService { } // Generate tags from user tags - public async generateOverviewTags(ctx: ContextManager, bmTitle: string, bmContent: string, byline: string, userTags: string[]): Promise { + public async generateOverviewTags( + ctx: ContextManager, + bmTitle: string, + bmContent: string, + byline: string, + userTags: string[] | TagVocabularyPrompt + ): Promise { const userLang = ctx.get('ai_lang') || 'EN' const contents: Content[] = [ diff --git a/src/domain/bookmark.ts b/src/domain/bookmark.ts index 8dc3f23..e2d5e13 100644 --- a/src/domain/bookmark.ts +++ b/src/domain/bookmark.ts @@ -15,6 +15,9 @@ import { VectorizeRepo } from '../infra/repository/dbVectorize' import { MarkRepo } from '../infra/repository/dbMark' import { UserRepo } from '../infra/repository/dbUser' import type { bookmarkParsePO, bookmarkPO } from '../infra/repository/dbBookmark' +import type { Prisma } from '@prisma/hyperdrive-client' + +type UserBookmarkListRow = Prisma.sr_user_bookmarkGetPayload<{ include: { bookmark: true; sr_user_bookmark_tag: true } }> import { MultiLangError } from '../utils/multiLangError' import { authToken } from '../middleware/auth' import { randomUUID } from 'crypto' @@ -264,12 +267,12 @@ export class BookmarkService { return null } - // 创建标签 + // 创建标签:导入来的词进自动标签,用户在标签页确认后才算我的标签 for (const tag of item.tags) { - const tagRes = await this.bookmarkRepo.createUserTag(ctx.getUserId(), tag) + const tagRes = await this.bookmarkRepo.createUserTag(ctx.getUserId(), tag, 'auto') console.log(`create tag: ${JSON.stringify(tagRes)}`) if (!tagRes) continue - await this.bookmarkRepo.createBookmarkTag(bmInfo.id, ctx.getUserId(), tagRes.id, tag) + await this.bookmarkRepo.createBookmarkTag(bmInfo.id, ctx.getUserId(), tagRes.id, tag, 'user') } return { @@ -498,11 +501,11 @@ export class BookmarkService { } } - /** 获取收藏列表 */ - public async bookmarkList(ctx: ContextManager, page: number, size: number, filter: string) { - return (await this.bookmarkRepo.listUserBookmarks(ctx.getUserId(), (page - 1) * size, size, filter)) + /** one list row for the client: bookmark fields + user state + live tag chips */ + private mapUserBookmarkRows(ctx: ContextManager, rows: UserBookmarkListRow[]) { + return rows .filter(({ bookmark }) => bookmark !== null) - .map(({ uuid, bookmark, alias_title, archive_status, is_starred, deleted_at, type, created_at, updated_at }) => { + .map(({ uuid, bookmark, alias_title, archive_status, is_starred, deleted_at, type, created_at, updated_at, sr_user_bookmark_tag }) => { const { private_user, content_md_key, content_key, ...bookmarkWithout } = bookmark! return { ...bookmarkWithout, @@ -514,28 +517,30 @@ export class BookmarkService { trashed_at: !!deleted_at ? deleted_at : undefined, type: type === 1 ? 'shortcut' : 'article', created_at, - updated_at + updated_at, + tags: (sr_user_bookmark_tag || []).map(t => ({ + id: ctx.hashIds.encodeId(t.tag_id), + name: t.tag_name, + show_name: t.tag_name, + added_by: t.source + })) } }) } - /** 根据标签ID获取收藏列表 */ + /** 获取收藏列表 */ + public async bookmarkList(ctx: ContextManager, page: number, size: number, filter: string) { + return this.mapUserBookmarkRows(ctx, await this.bookmarkRepo.listUserBookmarks(ctx.getUserId(), (page - 1) * size, size, filter)) + } + + /** 按标签交集获取收藏列表 */ + public async bookmarkListByTopics(ctx: ContextManager, page: number, size: number, tagIds: number[]): Promise { + return this.mapUserBookmarkRows(ctx, await this.bookmarkRepo.listUserBookmarksByTagIds(ctx.getUserId(), tagIds, (page - 1) * size, size)) + } + + /** 根据标签ID获取收藏列表(单标签,保留给旧调用方) */ public async bookmarkListByTopic(ctx: ContextManager, page: number, size: number, tagId: number): Promise { - return (await this.bookmarkRepo.listUserBookmarksByTagId(ctx.getUserId(), tagId, (page - 1) * size, size)) - .filter(({ bookmark }) => bookmark !== null) - .map(({ user_bookmark, bookmark }) => { - const { private_user, content_md_key, content_key, ...bookmarkWithout } = bookmark! - return { - ...bookmarkWithout!, - bookmark_user_uuid: user_bookmark!.uuid, - alias_title: user_bookmark!.alias_title, - id: ctx.hashIds.encodeId(user_bookmark!.bookmark_id), - archived: user_bookmark!.archive_status === 1 ? 'archive' : user_bookmark!.archive_status === 2 ? 'later' : 'inbox', - starred: user_bookmark!.is_starred ? 'star' : 'unstar', - created_at: user_bookmark!.created_at, - updated_at: user_bookmark!.updated_at - } - }) + return this.bookmarkListByTopics(ctx, page, size, [tagId]) } public async getBookmarkContent(bmKey: string) { @@ -635,15 +640,17 @@ export class BookmarkService { return 0 } - /** 书签添加标签 */ + /** + * AI attaches vocabulary words to a bookmark. Names are resolved without touching + * ownership or display, links are written with source "ai", last_used_at stays put. + */ public async tagBookmark(ctx: ContextManager, userId: number, bmId: number, tags: string[]) { - const bookmarkRepo = this.bookmarkRepo - - for (const tag of tags) { - const repoTag = await bookmarkRepo.createUserTag(userId, tag) - if (!repoTag) continue - await bookmarkRepo.createBookmarkTag(bmId, userId, repoTag.id, repoTag.tag_name) - } + if (tags.length < 1) return + // a plain lookup: the bookmark may belong to several users and a name that is not in + // this user's live vocabulary must be dropped, never created + const rows = await this.bookmarkRepo.getUserTagsByNames(userId, tags) + if (rows.length < 1) return + await this.bookmarkRepo.upsertBookmarkTags(bmId, userId, rows, 'ai') } /** 创建书签概述 */ diff --git a/src/domain/orchestrator/urlParser.ts b/src/domain/orchestrator/urlParser.ts index 11ce68e..aa2b744 100644 --- a/src/domain/orchestrator/urlParser.ts +++ b/src/domain/orchestrator/urlParser.ts @@ -1,3 +1,4 @@ +import { groupVocabulary, pickTagsForBookmark } from '../../utils/tags' import { inject, injectable } from '../../decorators/di' import { ContextManager } from '../../utils/context' import { BookmarkService } from '../bookmark' @@ -166,21 +167,21 @@ export class UrlParserHandler { console.log(`bookmark ${info.bookmarkId} url is prohibited content, skip tags and overview generation`) return } - // get user setting tags list - const userTags = (await this.tagService.listUserTags(ctx)).map(item => item.name) + // the live vocabulary, split so the prompt can prefer the user's own words + const vocabulary = await this.tagService.listUserTags(ctx) const { overview, key_takeaways, tags } = await this.aigcService.generateOverviewTags( ctx, meta.parseRes.title || '', meta.parseRes.textContent, meta.parseRes.byline || '', - userTags + groupVocabulary(vocabulary) ) if (overview.length > 0) { await Promise.all(info.userIds.map(userId => this.bookmarkService.createBookmarkOverview(userId, info.bookmarkId, '', JSON.stringify({ overview, key_takeaways })))) } - const filteredTags = tags.filter(tag => userTags.includes(tag)) + const filteredTags = pickTagsForBookmark(tags, vocabulary).map(t => t.name) await Promise.all(info.userIds.map(userId => this.bookmarkService.tagBookmark(ctx, userId, info.bookmarkId, filteredTags))) } diff --git a/src/domain/tag.ts b/src/domain/tag.ts index 75e62b7..c9ae8e8 100644 --- a/src/domain/tag.ts +++ b/src/domain/tag.ts @@ -1,50 +1,94 @@ import { inject, injectable } from '../decorators/di' import { BookmarkNotFoundError, ErrorParam, ServerError } from '../const/err' import { ContextManager } from '../utils/context' -import { BookmarkRepo } from '../infra/repository/dbBookmark' +import { BookmarkRepo, BookmarkTagSource, UserTagSource } from '../infra/repository/dbBookmark' +import { normalizeTagName } from '../utils/tags' export interface BookmarkTag { id: number name: string show_name: string display?: boolean + /** vocabulary ownership: "mine" = confirmed by the user, "auto" = never confirmed */ + source?: UserTagSource + last_used_at?: Date | null + /** on a bookmark: who attached it. "" for history */ + added_by?: BookmarkTagSource + /** only on the "+" picker of the multi-tag filter page */ + count?: number +} + +/** a tag addressed either by HTTP hashid or by sync uuid */ +export interface TagRef { + tag_id?: number + tag_uuid?: string +} + +interface UserTagRow { + id: number + tag_name: string + display: boolean + source: string + last_used_at: Date | null } @injectable() export class TagService { constructor(@inject(BookmarkRepo) private bookmarkRepo: BookmarkRepo) {} + /** hashid or uuid to DB id; 0 when neither resolves. Never feed a uuid to decodeId */ + public async resolveTagId(ctx: ContextManager, ref: TagRef): Promise { + if (ref.tag_uuid) { + const tag = await this.bookmarkRepo.getUserTagByUuid(ctx.getUserId(), ref.tag_uuid) + return tag?.id || 0 + } + if (ref.tag_id) return ctx.hashIds.decodeId(ref.tag_id) || 0 + return 0 + } + + private toTag(ctx: ContextManager, row: UserTagRow): BookmarkTag { + return { + id: ctx.hashIds.encodeId(row.id), + name: row.tag_name, + show_name: row.tag_name, + display: row.display, + source: row.source === 'mine' ? 'mine' : 'auto', + last_used_at: row.last_used_at + } + } + public async addBookmarkTag(ctx: ContextManager, bmId: number, tagName?: string, tagId?: number): Promise { const bmRepo = this.bookmarkRepo + const userId = ctx.getUserId() - const res = await bmRepo.getUserBookmark(bmId, ctx.getUserId()) + const res = await bmRepo.getUserBookmark(bmId, userId) if (!res) throw BookmarkNotFoundError() - // 创建标签逻辑 - // 如果是传递的ID,则代表是user tag中曾经存在的,此时更新一下display并且插入到bookmark_tag中 - // 如果是传递的tagName,则代表是新创建的tag,此时插入user_tag和bookmark_tag + // 传 tagName:用户打的词,建成(或认领为)我的标签再贴上 + // 传 tagId:词表里已有的词,贴上并恢复显示 + let tag: UserTagRow | null = null if (tagName) { - const res = await bmRepo.createUserTag(ctx.getUserId(), tagName) - if (!res) throw ServerError() - await bmRepo.createBookmarkTag(bmId, ctx.getUserId(), res.id, tagName) - tagId = res.id + const created = await bmRepo.createUserTag(userId, tagName) + if (!created) throw ServerError() + tag = created } else if (tagId) { tagId = ctx.hashIds.decodeId(tagId) - const tag = await bmRepo.getUserTagById(ctx.getUserId(), tagId) - if (!tag) throw ErrorParam() - await Promise.allSettled([bmRepo.createBookmarkTag(bmId, ctx.getUserId(), tagId, tag.tag_name), bmRepo.updateUserTagDisplay(ctx.getUserId(), tagId, true)]) - tagName = tag.tag_name - } - if (!tagName || !tagId) throw ServerError() - return { - id: ctx.hashIds.encodeId(tagId), - name: tagName, - show_name: tagName + const found = await bmRepo.getUserTagById(userId, tagId) + if (!found) throw ErrorParam() + if (!found.display) await bmRepo.updateUserTagDisplay(userId, tagId, true) + tag = { ...found, display: true } } + if (!tag) throw ServerError() + + await bmRepo.createBookmarkTag(bmId, userId, tag.id, tag.tag_name, 'user') + await bmRepo.touchUserTagsLastUsed(userId, [tag.id]) + + return { ...this.toTag(ctx, tag), added_by: 'user' } } public async addBookmarkTags(ctx: ContextManager, bmId: number, tags: { name: string; id?: number }[]): Promise { const bmRepo = this.bookmarkRepo + const userId = ctx.getUserId() const needInsert = tags.filter(t => !t.id) const needUpdate = tags.filter(t => t.id).map(t => ({ ...t, id: ctx.hashIds.decodeId(t.id!) })) @@ -58,64 +102,121 @@ export class TagService { insertRes = ( await bmRepo.createUserTags( - ctx.getUserId(), + userId, needInsert.map(t => t.name) ) )?.map(item => ({ - id: ctx.hashIds.encodeId(item.id), + id: item.id, name: item.tag_name })) || [] - - if (!insertRes.length) { - throw ServerError() - } + // createUserTags skips names that already exist; they are picked up by name below + const inserted = new Set(insertRes.map(t => t.name)) + insertRes.push(...needInsert.filter(t => !inserted.has(t.name)).map(t => ({ id: 0, name: t.name }))) } const needUpsert = [...insertRes, ...needUpdate] const updateRes = await bmRepo.updateUserTagsDisplay( - ctx.getUserId(), - needUpsert.map(t => t.name) + userId, + needUpsert.map(t => t.name), + true ) if (updateRes.length > 0) { - const res = await bmRepo.upsertBookmarkTags(bmId, ctx.getUserId(), updateRes) - if (!res) throw ServerError() + // ON CONFLICT DO NOTHING legitimately reports 0 rows when every tag was already attached + await bmRepo.upsertBookmarkTags(bmId, userId, updateRes, 'user') + await bmRepo.touchUserTagsLastUsed( + userId, + updateRes.map(t => t.id) + ) } return updateRes.map(res => ({ id: ctx.hashIds.encodeId(res.id), display: true, name: res.tag_name, - show_name: res.tag_name + show_name: res.tag_name, + source: res.source === 'mine' ? 'mine' : 'auto', + added_by: 'user' })) } + /** remove from this one bookmark. The word stays in the vocabulary unless it is an orphaned auto tag */ public async deleteBookmarkTag(ctx: ContextManager, bmId: number, tagId: number) { const bmRepo = this.bookmarkRepo - const res = await bmRepo.getUserBookmark(bmId, ctx.getUserId()) + const userId = ctx.getUserId() + const res = await bmRepo.getUserBookmark(bmId, userId) if (!res) throw BookmarkNotFoundError() - await bmRepo.deleteBookmarkTag(bmId, ctx.getUserId(), tagId).then(async () => { - const hasRecord = await bmRepo.countBookmarksByTag(ctx.getUserId(), tagId) - if (!hasRecord) await bmRepo.deleteUserTag(ctx.getUserId(), tagId) - }) + await bmRepo.deleteBookmarkTag(bmId, userId, tagId) + + const hasRecord = await bmRepo.countBookmarksByTag(userId, tagId) + if (!hasRecord) { + const tag = await bmRepo.getUserTagById(userId, tagId) + if (tag && tag.source !== 'mine') await bmRepo.deleteUserTag(userId, tagId) + } return null } + /** the tags page "new tag" is a claim: an existing name (any case / width) becomes mine, else a new mine tag */ public async createTag(ctx: ContextManager, tagName: string): Promise { + return this.promoteTag(ctx, { tag_name: tagName }) + } + + public async promoteTag(ctx: ContextManager, ref: TagRef & { tag_name?: string }): Promise { const bmRepo = this.bookmarkRepo + const userId = ctx.getUserId() - const res = await bmRepo.createUserTag(ctx.getUserId(), tagName) - if (!res) throw ErrorParam() + if (ref.tag_name) { + const name = normalizeTagName(ref.tag_name) + if (!name) throw ErrorParam() - return { - id: ctx.hashIds.encodeId(res.id), - name: res.tag_name, - show_name: res.tag_name, - display: true + const existing = await bmRepo.findUserTagByName(userId, name) + if (existing) { + if (existing.source !== 'mine') await bmRepo.updateUserTagSource(userId, existing.id, 'mine') + if (!existing.display) await bmRepo.updateUserTagDisplay(userId, existing.id, true) + return this.toTag(ctx, { ...existing, source: 'mine', display: true }) + } + + const created = await bmRepo.createUserTag(userId, name) + if (!created) throw ErrorParam() + return this.toTag(ctx, created) } + + const tagId = await this.resolveTagId(ctx, ref) + if (tagId < 1) throw ErrorParam() + const tag = await bmRepo.getUserTagById(userId, tagId) + if (!tag) throw ErrorParam() + + if (tag.source !== 'mine') await bmRepo.updateUserTagSource(userId, tagId, 'mine') + return this.toTag(ctx, { ...tag, source: 'mine' }) + } + + /** back to auto. Links untouched */ + public async demoteTag(ctx: ContextManager, ref: TagRef): Promise { + const bmRepo = this.bookmarkRepo + const tagId = await this.resolveTagId(ctx, ref) + if (tagId < 1) throw ErrorParam() + const tag = await bmRepo.getUserTagById(ctx.getUserId(), tagId) + if (!tag) throw ErrorParam() + + if (tag.source === 'mine') await bmRepo.updateUserTagSource(ctx.getUserId(), tagId, 'auto') + return this.toTag(ctx, { ...tag, source: 'auto' }) + } + + /** the tags page delete: detach everywhere and hide the word */ + public async deleteTag(ctx: ContextManager, ref: TagRef) { + const bmRepo = this.bookmarkRepo + const userId = ctx.getUserId() + const tagId = await this.resolveTagId(ctx, ref) + if (tagId < 1) throw ErrorParam() + const tag = await bmRepo.getUserTagById(userId, tagId) + if (!tag) throw ErrorParam() + + await bmRepo.softDeleteBookmarkTagsByTag(userId, tagId) + await bmRepo.deleteUserTag(userId, tagId) + return null } public async editTag(ctx: ContextManager, tagId: number, tagName: string) { @@ -127,23 +228,45 @@ export class TagService { return null } + /** live vocabulary, mine first by recency (ordering done in SQL) */ public async listUserTags(ctx: ContextManager): Promise { + const res = await this.bookmarkRepo.getUserTags(ctx.getUserId()) + return res.map(item => this.toTag(ctx, item)) + } + + /** + * For the "+" picker on the multi-tag filter page: tags still present inside the + * intersection of `tagIds`, each with the number of bookmarks it would leave. + */ + public async listCandidateTags(ctx: ContextManager, tagIds: number[]): Promise { const bmRepo = this.bookmarkRepo - const res = await bmRepo.getUserTags(ctx.getUserId()) - res.sort((a, b) => b.created_at.getTime() - a.created_at.getTime()) - return res.map(item => ({ - show_name: item.tag_name, - name: item.tag_name, - id: ctx.hashIds.encodeId(item.id), - display: item.display - })) + const userId = ctx.getUserId() + + const selected = await bmRepo.getUserTagsByIds(userId, tagIds) + if (selected.length !== tagIds.length) throw ErrorParam() + + const counts = await bmRepo.countTagsWithinBookmarks( + userId, + selected.map(t => t.uuid), + tagIds + ) + if (counts.length < 1) return [] + + const countById = new Map(counts.map(c => [c.tag_id, Number(c.count)])) + const candidates = await bmRepo.getUserTagsByIds(userId, [...countById.keys()]) + + return candidates + .filter(t => t.display) + .map(t => ({ ...this.toTag(ctx, t), count: countById.get(t.id) || 0 })) + .sort((a, b) => (b.count || 0) - (a.count || 0) || a.name.localeCompare(b.name)) } public async getBookmarkTags(ctx: ContextManager, userId: number, bmId: number): Promise { return (await this.bookmarkRepo.getBookmarkTags(userId, bmId)).map(t => ({ show_name: t.tag_name, name: t.tag_name, - id: ctx.hashIds.encodeId(t.tag_id) + id: ctx.hashIds.encodeId(t.tag_id), + added_by: (t.source === 'ai' ? 'ai' : t.source === 'user' ? 'user' : '') as BookmarkTagSource })) } } diff --git a/src/domain/telegram.ts b/src/domain/telegram.ts index 434b37b..d795d0a 100644 --- a/src/domain/telegram.ts +++ b/src/domain/telegram.ts @@ -229,7 +229,7 @@ export class TelegramBotService { const tag = await this.bookmarkRepo.getUserTagById(userId, tagId) if (tag instanceof Error) return { data: [], tagName: '' } if (!tag) return { data: [], tagName: '' } - resp = await this.bookmarkRepo.listUserBookmarksByTagId(userId, tagId, (page - 1) * size, size) + resp = await this.bookmarkRepo.listUserBookmarksByTagIds(userId, [tagId], (page - 1) * size, size) tagName = tag.tag_name } else { resp = await this.bookmarkRepo.listUserBookmarks(userId, (page - 1) * size, size, '') diff --git a/src/handler/http/bookmarkController.ts b/src/handler/http/bookmarkController.ts index 9535a51..76bc3c4 100644 --- a/src/handler/http/bookmarkController.ts +++ b/src/handler/http/bookmarkController.ts @@ -1,3 +1,4 @@ +import { decodeIdList } from './tagController' import { Failed, Successed } from '../../utils/responseUtils' import { ContextManager } from '../../utils/context' import { BookmarkChangesSyncTooOldError, ErrorConnectionParam, ErrorParam } from '../../const/err' @@ -114,17 +115,18 @@ export class BookmarkController { */ @Get('/list') public async handleUserGetBookmarksRequest(ctx: ContextManager, request: Request) { - const params = await RequestUtils.query<{ page: number; size: number; filter?: string; topic_id?: number; collection_id?: number }>(request) + const params = await RequestUtils.query<{ page: number; size: number; filter?: string; topic_id?: number; topic_ids?: string; collection_id?: number }>(request) if (params.page < 1 || params.size < 1 || params.page === undefined || params.size === undefined) { return Failed(ErrorParam()) } let res: bookmarkPO[] = [] if (params.filter === 'topics') { - params.topic_id = ctx.hashIds.decodeId(params.topic_id || 0) - if (params.topic_id < 1) return Failed(ErrorParam()) + // topic_ids=a,b filters by intersection; topic_id kept for older clients + const topicIds = decodeIdList(ctx, params.topic_ids || params.topic_id) + if (topicIds.length < 1) return Failed(ErrorParam()) - res = await this.bookmarkService.bookmarkListByTopic(ctx, Number(params.page), Number(params.size), params.topic_id) + res = await this.bookmarkService.bookmarkListByTopics(ctx, Number(params.page), Number(params.size), topicIds) } else { res = await this.bookmarkService.bookmarkList(ctx, Number(params.page), Number(params.size), params.filter || 'all') } @@ -267,13 +269,13 @@ export class BookmarkController { */ @Post('/add_tag') public async handleUserBookmarkAddTagRequest(ctx: ContextManager, request: Request) { - const req = await RequestUtils.json<{ bookmark_id: number; tag_name?: string; tag_id?: number }>(request) - if (!req || !req.bookmark_id) return Failed(ErrorParam()) + const req = await RequestUtils.json<{ bookmark_id?: number; bookmark_uid?: string; tag_name?: string; tag_id?: number }>(request) + if (!req || (!req.bookmark_id && !req.bookmark_uid)) return Failed(ErrorParam()) - req.bookmark_id = ctx.hashIds.decodeId(req.bookmark_id) - if (!req.bookmark_id) return Failed(ErrorParam()) + const bookmarkId = await this.bookmarkService.getBookmarkId(ctx, { bmId: req.bookmark_id, bmUId: req.bookmark_uid }) + if (!bookmarkId) return Failed(ErrorParam()) - const res = await this.tagService.addBookmarkTag(ctx, req.bookmark_id, req.tag_name, req.tag_id) + const res = await this.tagService.addBookmarkTag(ctx, bookmarkId, req.tag_name, req.tag_id) return Successed(res) } @@ -282,13 +284,13 @@ export class BookmarkController { */ @Post('/add_tags') public async handleUserBookmarkAddTagsRequest(ctx: ContextManager, request: Request) { - const req = await RequestUtils.json<{ bookmark_id: number; tags: { name: string; id: number }[] }>(request) - if (!req || !req.bookmark_id || !req.tags || !req.tags.length) return Failed(ErrorParam()) + const req = await RequestUtils.json<{ bookmark_id?: number; bookmark_uid?: string; tags: { name: string; id?: number }[] }>(request) + if (!req || (!req.bookmark_id && !req.bookmark_uid) || !req.tags || !req.tags.length) return Failed(ErrorParam()) - req.bookmark_id = ctx.hashIds.decodeId(req.bookmark_id) - if (!req.bookmark_id) return Failed(ErrorParam()) + const bookmarkId = await this.bookmarkService.getBookmarkId(ctx, { bmId: req.bookmark_id, bmUId: req.bookmark_uid }) + if (!bookmarkId) return Failed(ErrorParam()) - const res = await this.tagService.addBookmarkTags(ctx, req.bookmark_id, req.tags) + const res = await this.tagService.addBookmarkTags(ctx, bookmarkId, req.tags) return Successed(res) } diff --git a/src/handler/http/tagController.ts b/src/handler/http/tagController.ts index b602c81..6edfaac 100644 --- a/src/handler/http/tagController.ts +++ b/src/handler/http/tagController.ts @@ -4,28 +4,49 @@ import { RequestUtils } from '../../utils/requestUtils' import { Failed, Successed } from '../../utils/responseUtils' import { Controller } from '../../decorators/controller' import { inject } from '../../decorators/di' -import { TagService } from '../../domain/tag' +import { TagService, TagRef } from '../../domain/tag' import { Get, Post } from '../../decorators/route' +/** "a,b,c" of hashids to DB ids; empty array when any part fails to decode */ +export function decodeIdList(ctx: ContextManager, raw: string | number | undefined): number[] { + if (raw === undefined || raw === null || raw === '') return [] + const parts = String(raw) + .split(',') + .map(p => p.trim()) + .filter(p => p.length > 0) + const ids = parts.map(p => ctx.hashIds.decodeId(p as unknown as number) || 0) + if (ids.length < 1 || ids.some(id => id < 1)) return [] + return [...new Set(ids)] +} + @Controller('/v1/tag') export class TagController { constructor(@inject(TagService) private tagService: TagService) {} + /** + * 词表。带 within=a,b 时返回这批标签交集里还出现的候选词(含篇数),不含已选 + */ @Get('/list') public async handleListTagsRequest(ctx: ContextManager, request: Request): Promise { + const params = await RequestUtils.query<{ within?: string }>(request) + if (params.within) { + const ids = decodeIdList(ctx, params.within) + if (ids.length < 1) return Failed(ErrorParam()) + return Successed(await this.tagService.listCandidateTags(ctx, ids)) + } const tags = await this.tagService.listUserTags(ctx) return Successed(tags) } @Post('/update') public async handleUpdateTagRequest(ctx: ContextManager, request: Request) { - const req = await RequestUtils.json<{ tag_id: number; tag_name: string }>(request) + const req = await RequestUtils.json(request) if (!req || !req.tag_name || req.tag_name.length > 30) return Failed(ErrorParam()) - req.tag_id = ctx.hashIds.decodeId(req.tag_id) - if (!req.tag_id) return Failed(ErrorParam()) + const tagId = await this.tagService.resolveTagId(ctx, req) + if (tagId < 1) return Failed(ErrorParam()) - await this.tagService.editTag(ctx, req.tag_id, req.tag_name) + await this.tagService.editTag(ctx, tagId, req.tag_name) return Successed() } @@ -37,4 +58,35 @@ export class TagController { const res = await this.tagService.createTag(ctx, req.tag_name) return Successed(res) } + + /** 认领为我的标签。传 tag_name 时按归一化名字匹配,命中即认领,未命中新建 */ + @Post('/promote') + public async handlePromoteTagRequest(ctx: ContextManager, request: Request) { + const req = await RequestUtils.json(request) + if (!req || (!req.tag_id && !req.tag_uuid && !req.tag_name)) return Failed(ErrorParam()) + if (req.tag_name && req.tag_name.length > 30) return Failed(ErrorParam()) + + const res = await this.tagService.promoteTag(ctx, req) + return Successed(res) + } + + /** 移出我的标签,关联不变 */ + @Post('/demote') + public async handleDemoteTagRequest(ctx: ContextManager, request: Request) { + const req = await RequestUtils.json(request) + if (!req || (!req.tag_id && !req.tag_uuid)) return Failed(ErrorParam()) + + const res = await this.tagService.demoteTag(ctx, req) + return Successed(res) + } + + /** 标签页的删除:从所有文章上移除,词表里隐藏 */ + @Post('/delete') + public async handleDeleteTagRequest(ctx: ContextManager, request: Request) { + const req = await RequestUtils.json(request) + if (!req || (!req.tag_id && !req.tag_uuid)) return Failed(ErrorParam()) + + await this.tagService.deleteTag(ctx, req) + return Successed() + } } diff --git a/src/infra/repository/dbBookmark.ts b/src/infra/repository/dbBookmark.ts index ec88fd0..4e859a0 100644 --- a/src/infra/repository/dbBookmark.ts +++ b/src/infra/repository/dbBookmark.ts @@ -32,6 +32,9 @@ export enum bookmarkFetchRetryStatus { SUCCESS = 'success' } +export type UserTagSource = 'auto' | 'mine' +export type BookmarkTagSource = 'user' | 'ai' | '' + export interface bookmarkPO { bookmark_id?: number title: string @@ -296,35 +299,86 @@ export class BookmarkRepo { } else if (filter === 'trashed') { where.deleted_at = { not: null } orderBy = { deleted_at: 'desc' } + } else if (filter === 'untagged') { + return await this.listUntaggedUserBookmarks(userId, offset, limit) } return await this.prismaPg().sr_user_bookmark.findMany({ where, skip: offset, take: limit, - include: { - bookmark: true - }, + include: this.userBookmarkListInclude(), orderBy }) } - public async listUserBookmarksByTagId(userId: number, tagId: number, offset: number, limit: number) { - return await this.prismaPg().sr_user_bookmark_tag.findMany({ + /** list rows carry the live tag links so the list UI can draw chips without a second round trip */ + private userBookmarkListInclude() { + return { + bookmark: true, + sr_user_bookmark_tag: { where: { is_deleted: false }, orderBy: { created_at: 'asc' as const } } + } + } + + /** + * Bookmarks that carry every tag in tagIds (intersection). + * Uses metadata.tags (uuid array kept by trigger_tag_uuid_update) with jsonb containment, + * so one query serves n = 1 and n > 1 alike. + */ + public async listUserBookmarksByTagIds(userId: number, tagIds: number[], offset: number, limit: number) { + if (tagIds.length < 1) return [] + const tags = await this.prismaPg().sr_user_tag.findMany({ where: { id: { in: tagIds }, user_id: userId }, select: { uuid: true } }) + if (tags.length !== tagIds.length) return [] + + return await this.prismaPg().sr_user_bookmark.findMany({ where: { user_id: userId, - tag_id: tagId, - is_deleted: false + deleted_at: null, + metadata: { path: ['tags'], array_contains: tags.map(t => t.uuid) } }, skip: offset, take: limit, - include: { - user_bookmark: true, - bookmark: true - } + include: this.userBookmarkListInclude(), + orderBy: { created_at: 'desc' } + }) + } + + /** Bookmarks with no live tag. Raw SQL so rows whose metadata lacks a tags array still count as untagged. */ + public async listUntaggedUserBookmarks(userId: number, offset: number, limit: number) { + const rows = await this.prismaPg().$queryRaw<{ id: number }[]>` + SELECT id FROM sr_user_bookmark + WHERE user_id = ${userId} AND deleted_at IS NULL + AND (jsonb_typeof(metadata->'tags') IS DISTINCT FROM 'array' OR metadata->'tags' = '[]'::jsonb) + ORDER BY created_at DESC + LIMIT ${limit} OFFSET ${offset}` + if (rows.length < 1) return [] + + return await this.prismaPg().sr_user_bookmark.findMany({ + where: { id: { in: rows.map(r => r.id) } }, + include: this.userBookmarkListInclude(), + orderBy: { created_at: 'desc' } }) } + /** + * For the "+" picker on the multi-tag filter page: tags that still appear inside the + * intersection of `uuids`, with how many bookmarks each one would leave. + */ + public async countTagsWithinBookmarks(userId: number, uuids: string[], excludeTagIds: number[]) { + if (uuids.length < 1) return [] + const exclude = excludeTagIds.length > 0 ? Prisma.sql`AND bt.tag_id NOT IN (${Prisma.join(excludeTagIds)})` : Prisma.empty + return await this.prismaPg().$queryRaw<{ tag_id: number; count: number }[]>` + SELECT bt.tag_id, COUNT(DISTINCT bt.bookmark_id)::int AS count + FROM sr_user_bookmark_tag bt + JOIN sr_user_bookmark ub ON ub.bookmark_id = bt.bookmark_id AND ub.user_id = bt.user_id + WHERE bt.user_id = ${userId} + AND bt.is_deleted = false + AND ub.deleted_at IS NULL + AND ub.metadata->'tags' @> ${JSON.stringify(uuids)}::jsonb + ${exclude} + GROUP BY bt.tag_id` + } + public async updateBookmark(bmId: number, info: bookmarkParsePO) { return await this.prismaPg().sr_bookmark.update({ where: { id: bmId }, data: { updated_at: new Date(), ...info } }) } @@ -438,7 +492,11 @@ export class BookmarkRepo { }) } - public async createUserTag(userId: number, tag: string) { + /** + * A tag the user typed is "mine". If the name already exists as an auto tag, + * the user has just claimed it: same row, same links, source flips to mine. + */ + public async createUserTag(userId: number, tag: string, source: UserTagSource = 'mine') { if (!tag) return return this.prismaPg().sr_user_tag.upsert({ where: { @@ -451,11 +509,11 @@ export class BookmarkRepo { user_id: userId, tag_name: tag, created_at: new Date(), - display: true + display: true, + source }, - update: { - display: true - } + // an auto write (e.g. import) never demotes a tag the user already claimed + update: source === 'mine' ? { display: true, source } : { display: true } }) } @@ -466,29 +524,58 @@ export class BookmarkRepo { user_id: userId, tag_name: tag, created_at: new Date(), - display: true - })) + display: true, + source: 'mine' + })), + skipDuplicates: true }) } public async updateUserTagDisplay(userId: number, tagId: number, display: boolean) { return await this.prismaPg().sr_user_tag.update({ where: { id: tagId, user_id: userId }, - data: { display: true } + data: { display } }) } - public async createBookmarkTag(bmId: number, userId: number, tagId: number, tagName: string) { + public async updateUserTagSource(userId: number, tagId: number, source: UserTagSource) { + return await this.prismaPg().sr_user_tag.update({ + where: { id: tagId, user_id: userId }, + data: { source } + }) + } + + /** the user just attached these tags by hand; AI attachments never call this */ + public async touchUserTagsLastUsed(userId: number, tagIds: number[]) { + if (tagIds.length < 1) return 0 + return await this.prismaPg().sr_user_tag.updateMany({ + where: { id: { in: tagIds }, user_id: userId }, + data: { last_used_at: new Date() } + }) + } + + public async createBookmarkTag(bmId: number, userId: number, tagId: number, tagName: string, source: BookmarkTagSource) { return await this.prismaPg().sr_user_bookmark_tag.upsert({ where: { bookmark_id_user_id_tag_id: { bookmark_id: bmId, user_id: userId, tag_id: tagId } }, - create: { user_id: userId, bookmark_id: bmId, tag_id: tagId, tag_name: tagName, created_at: new Date() }, - update: {} + create: { user_id: userId, bookmark_id: bmId, tag_id: tagId, tag_name: tagName, created_at: new Date(), source }, + // a link soft-deleted through PowerSync comes back alive when re-added over HTTP + update: { is_deleted: false, source } }) } + /** soft delete, same tombstone the PowerSync path writes; the metadata trigger handles both */ public async deleteBookmarkTag(bookmarkId: number, userId: number, tagId: number) { - return await this.prismaPg().sr_user_bookmark_tag.delete({ - where: { bookmark_id_user_id_tag_id: { bookmark_id: bookmarkId, user_id: userId, tag_id: tagId } } + return await this.prismaPg().sr_user_bookmark_tag.updateMany({ + where: { bookmark_id: bookmarkId, user_id: userId, tag_id: tagId }, + data: { is_deleted: true } + }) + } + + /** the tags page "delete": detach from every bookmark */ + public async softDeleteBookmarkTagsByTag(userId: number, tagId: number) { + return await this.prismaPg().sr_user_bookmark_tag.updateMany({ + where: { tag_id: tagId, user_id: userId, is_deleted: false }, + data: { is_deleted: true } }) } @@ -510,14 +597,32 @@ export class BookmarkRepo { return await this.prismaPg().sr_user_bookmark_tag.findMany({ where: { bookmark_id: bookmarkId, user_id: userId, is_deleted: false } }) } + /** the live vocabulary, mine first by recency. Serves both the tags page and the AI picker */ public async getUserTags(userId: number) { - return await this.prismaPg().sr_user_tag.findMany({ where: { user_id: userId } }) + return await this.prismaPg().sr_user_tag.findMany({ + where: { user_id: userId, display: true }, + orderBy: [{ last_used_at: { sort: 'desc', nulls: 'last' } }, { created_at: 'desc' }] + }) } public async getUserTagById(userId: number, tagId: number) { return await this.prismaPg().sr_user_tag.findFirst({ where: { id: tagId, user_id: userId } }) } + public async getUserTagByUuid(userId: number, uuid: string) { + return await this.prismaPg().sr_user_tag.findFirst({ where: { uuid, user_id: userId } }) + } + + public async getUserTagsByIds(userId: number, tagIds: number[]) { + if (tagIds.length < 1) return [] + return await this.prismaPg().sr_user_tag.findMany({ where: { id: { in: tagIds }, user_id: userId } }) + } + + /** case-insensitive exact match; caller normalizes whitespace and width first */ + public async findUserTagByName(userId: number, tagName: string) { + return await this.prismaPg().sr_user_tag.findFirst({ where: { user_id: userId, tag_name: { equals: tagName, mode: 'insensitive' } } }) + } + public async updateUserTag(userId: number, tagId: number, tagName: string) { return await this.prismaPg().sr_user_tag.update({ where: { id: tagId, user_id: userId }, data: { tag_name: tagName } }) } @@ -749,28 +854,45 @@ export class BookmarkRepo { return res as bookmarkActionChangePO[] } - // 批量upsert - public async upsertBookmarkTags(bmId: number, userId: number, tags: { id: number; tag_name: string }[]) { + /** + * 批量贴标签。删除是软删,所以用户再贴要把 is_deleted 翻回来; + * AI 贴的遇到已有行(含用户删过的)一律不动,尊重用户的移除。 + */ + public async upsertBookmarkTags(bmId: number, userId: number, tags: { id: number; tag_name: string }[], source: BookmarkTagSource = 'ai') { + if (tags.length < 1) return 0 const tagIds = tags.map(t => t.id) const tagNames = tags.map(t => t.tag_name) + const onConflict = source === 'user' ? Prisma.sql`DO UPDATE SET is_deleted = false, source = 'user'` : Prisma.sql`DO NOTHING` return await this.prismaPg().$executeRaw` - INSERT INTO sr_user_bookmark_tag(user_id, bookmark_id, tag_id, tag_name, created_at) - SELECT ${userId}, ${bmId}, tag_id, tag_name, NOW() + INSERT INTO sr_user_bookmark_tag(user_id, bookmark_id, tag_id, tag_name, created_at, source) + SELECT ${userId}, ${bmId}, tag_id, tag_name, NOW(), ${source} FROM UNNEST(${tagIds}::int[], ${tagNames}::text[]) AS t(tag_id, tag_name) - ON CONFLICT(user_id, bookmark_id, tag_id) DO NOTHING; + ON CONFLICT(user_id, bookmark_id, tag_id) ${onConflict}; ` } - // 批量插入且更新display接口 - public async updateUserTagsDisplay(userId: number, names: string[]) { - return await this.prismaPg().$queryRaw<{ id: number; tag_name: string }[]>` - INSERT INTO sr_user_tag(user_id, tag_name, display) - SELECT ${userId}, tag_name, true + /** the live vocabulary rows for these names; misses and hidden words are dropped. AI paths use this, never an upsert */ + public async getUserTagsByNames(userId: number, names: string[]) { + if (names.length < 1) return [] + return await this.prismaPg().sr_user_tag.findMany({ where: { user_id: userId, display: true, tag_name: { in: names } } }) + } + + /** + * User path: resolve names to ids in one round trip, creating missing words and showing + * hidden ones again. `createSource` says what a brand-new word is: a word the user typed is + * "mine", an imported word is "auto". With revive=false the conflict branch leaves display alone + * (kept for callers that only need ids; AI paths should use getUserTagsByNames instead). + */ + public async updateUserTagsDisplay(userId: number, names: string[], revive = false, createSource: UserTagSource = 'mine') { + if (names.length < 1) return [] + return await this.prismaPg().$queryRaw<{ id: number; tag_name: string; source: string }[]>` + INSERT INTO sr_user_tag(user_id, tag_name, display, source) + SELECT ${userId}, tag_name, true, ${createSource} FROM UNNEST(${names}::text[]) AS tag_name ON CONFLICT(user_id, tag_name) - DO UPDATE SET display = true - RETURNING id, tag_name; + DO UPDATE SET display = CASE WHEN ${revive} THEN true ELSE sr_user_tag.display END + RETURNING id, tag_name, source; ` } } diff --git a/src/infra/repository/dbSyncBatch.ts b/src/infra/repository/dbSyncBatch.ts index 98bdd17..767a48c 100644 --- a/src/infra/repository/dbSyncBatch.ts +++ b/src/infra/repository/dbSyncBatch.ts @@ -62,10 +62,13 @@ export class DBSyncBatchOperation { tag_name: tagName, display: true, created_at: new Date(), - uuid: tagUuid + uuid: tagUuid, + // only users create tags through sync, so the word is theirs + source: 'mine' }, update: { - display: true + display: true, + source: 'mine' } }) } @@ -179,38 +182,44 @@ export class DBSyncBatchOperation { WHERE bookmark_id = (SELECT bookmark_id from sr_user_bookmark where uuid = ${bookmarkUuid}) AND tag_id IN (SELECT id FROM sr_user_tag WHERE uuid in (${Prisma.join(tagsToDelete.map(uuid => Prisma.sql`${uuid}`))})) AND user_id = ${userId}` + // an auto tag with no live link left is hidden; a "mine" tag stays even at zero await tx.$executeRaw` UPDATE sr_user_tag t SET display = false WHERE t.uuid IN (${Prisma.join(tagsToDelete.map(uuid => Prisma.sql`${uuid}`))}) + AND t.user_id = ${userId} + AND t.source = 'auto' AND NOT EXISTS ( SELECT 1 FROM sr_user_bookmark_tag bt WHERE bt.tag_id = t.id + AND bt.user_id = t.user_id AND bt.is_deleted = false );` } if (tagsToAdd.length > 0) { await tx.$executeRaw` - INSERT INTO sr_user_bookmark_tag(user_id, bookmark_id, tag_id, tag_name, is_deleted, created_at) + INSERT INTO sr_user_bookmark_tag(user_id, bookmark_id, tag_id, tag_name, is_deleted, created_at, source) SELECT ${userId}, (SELECT bookmark_id FROM sr_user_bookmark WHERE uuid = ${bookmarkUuid} AND user_id = ${userId}), ut.id, ut.tag_name, false, - ${new Date()} + ${new Date()}, + 'user' FROM sr_user_tag ut WHERE ut.user_id = ${userId} AND ut.uuid IN (${Prisma.join(tagsToAdd.map(uuid => Prisma.sql`${uuid}`))}) ON CONFLICT(user_id, bookmark_id, tag_id) - DO UPDATE SET is_deleted = false + DO UPDATE SET is_deleted = false, source = 'user' ` + // sync writes are user actions: show the word again and bump recency await tx.$executeRaw` UPDATE sr_user_tag - SET display = true + SET display = true, last_used_at = NOW() WHERE uuid IN (${Prisma.join(tagsToAdd.map(uuid => Prisma.sql`${uuid}`))}) AND user_id = ${userId} ` diff --git a/src/utils/tags.ts b/src/utils/tags.ts new file mode 100644 index 0000000..a32d7c2 --- /dev/null +++ b/src/utils/tags.ts @@ -0,0 +1,53 @@ +/** + * Tag name normalization and AI tag picking. Pure functions, shared by the + * public and private backends. + */ + +export interface VocabularyTag { + id?: number + name: string + source?: 'auto' | 'mine' +} + +/** trim, collapse inner whitespace, fold full-width ASCII to half-width */ +export function normalizeTagName(name: string): string { + if (!name) return '' + let out = '' + for (const ch of name) { + const code = ch.charCodeAt(0) + if (code === 0x3000) out += ' ' + else if (code >= 0xff01 && code <= 0xff5e) out += String.fromCharCode(code - 0xfee0) + else out += ch + } + return out.replace(/\s+/g, ' ').trim() +} + +/** names the AI may choose from, split the way the prompt wants them */ +export function groupVocabulary(vocabulary: VocabularyTag[]): { mine: string[]; auto: string[] } { + const mine: string[] = [] + const auto: string[] = [] + for (const tag of vocabulary) (tag.source === 'mine' ? mine : auto).push(tag.name) + return { mine, auto } +} + +/** + * Keep only candidates that exist in the vocabulary, dedupe, put "mine" first + * (stable within each group), cap at `limit`. + */ +export function pickTagsForBookmark(candidates: string[], vocabulary: T[], limit = 3): T[] { + const byName = new Map() + for (const tag of vocabulary) if (!byName.has(tag.name)) byName.set(tag.name, tag) + + const seen = new Set() + const picked: T[] = [] + for (const name of candidates) { + const tag = byName.get(name) + if (!tag || seen.has(name)) continue + seen.add(name) + picked.push(tag) + } + + const mine = picked.filter(t => t.source === 'mine') + const auto = picked.filter(t => t.source !== 'mine') + return [...mine, ...auto].slice(0, limit) +} diff --git a/test/const/prompt.test.ts b/test/const/prompt.test.ts new file mode 100644 index 0000000..60ccd82 --- /dev/null +++ b/test/const/prompt.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { generateOverviewTagsUserPrompt } from '../../src/const/prompt' + +describe('generateOverviewTagsUserPrompt', () => { + it('lists my tags before the fallback tags', () => { + const prompt = generateOverviewTagsUserPrompt('zh', { mine: ['创业', '育儿'], auto: ['技术'] }) + const mineAt = prompt.indexOf('我的标签(能对上就必须优先用):\n创业,育儿') + const autoAt = prompt.indexOf('备选标签(我的标签都对不上时才用):\n技术') + expect(mineAt).toBeGreaterThan(-1) + expect(autoAt).toBeGreaterThan(mineAt) + }) + + it('still emits both sections when my tags are empty', () => { + const prompt = generateOverviewTagsUserPrompt('en', { mine: [], auto: ['tech'] }) + expect(prompt).toContain('我的标签(能对上就必须优先用):\n\n') + expect(prompt).toContain('备选标签(我的标签都对不上时才用):\ntech') + }) + + it('treats a plain string array as fallback tags only', () => { + const prompt = generateOverviewTagsUserPrompt('en', ['a', 'b']) + expect(prompt).toContain('备选标签(我的标签都对不上时才用):\na,b') + }) + + it('keeps the wording other tests pin', () => { + const prompt = generateOverviewTagsUserPrompt('zh', []) + expect(prompt).toContain('数量可以是0~3个') + expect(prompt).toContain('输出空数组 []') + expect(prompt).toContain('标签必须描述文章本身的内容') + expect(prompt).toContain('tags: [标签1, 标签2, 标签3, 标签4, ...]') + }) +}) diff --git a/test/utils/tags.test.ts b/test/utils/tags.test.ts new file mode 100644 index 0000000..fc4ab73 --- /dev/null +++ b/test/utils/tags.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { groupVocabulary, normalizeTagName, pickTagsForBookmark } from '../../src/utils/tags' + +describe('normalizeTagName', () => { + it('trims and collapses whitespace', () => { + expect(normalizeTagName(' AI 编程 ')).toBe('AI 编程') + }) + + it('folds full-width ASCII and ideographic space to half-width', () => { + expect(normalizeTagName('AI coding')).toBe('AI coding') + }) + + it('returns empty string for empty or blank input', () => { + expect(normalizeTagName('')).toBe('') + expect(normalizeTagName(' ')).toBe('') + }) +}) + +describe('groupVocabulary', () => { + it('splits by source, keeping order inside each group', () => { + const grouped = groupVocabulary([ + { name: 'a', source: 'auto' }, + { name: 'm1', source: 'mine' }, + { name: 'b', source: 'auto' }, + { name: 'm2', source: 'mine' }, + { name: 'c' } + ]) + expect(grouped).toEqual({ mine: ['m1', 'm2'], auto: ['a', 'b', 'c'] }) + }) +}) + +describe('pickTagsForBookmark', () => { + const vocabulary = [ + { id: 1, name: '创业', source: 'mine' as const }, + { id: 2, name: '人工智能', source: 'auto' as const }, + { id: 3, name: '技术', source: 'auto' as const }, + { id: 4, name: '育儿', source: 'mine' as const } + ] + + it('drops candidates outside the vocabulary', () => { + expect(pickTagsForBookmark(['无关', '技术'], vocabulary).map(t => t.name)).toEqual(['技术']) + }) + + it('puts mine first, keeps AI order within each group, caps at 3', () => { + const picked = pickTagsForBookmark(['技术', '创业', '人工智能', '育儿'], vocabulary) + expect(picked.map(t => t.name)).toEqual(['创业', '育儿', '技术']) + }) + + it('dedupes repeated candidates', () => { + expect(pickTagsForBookmark(['技术', '技术', '创业'], vocabulary).map(t => t.name)).toEqual(['创业', '技术']) + }) + + it('returns vocabulary entries, not bare names', () => { + expect(pickTagsForBookmark(['创业'], vocabulary)[0]).toEqual({ id: 1, name: '创业', source: 'mine' }) + }) + + it('honours a custom limit', () => { + expect(pickTagsForBookmark(['技术', '创业', '人工智能', '育儿'], vocabulary, 2).map(t => t.name)).toEqual(['创业', '育儿']) + }) +})