refactor(题目): 新建 / 复制题目收成 validateProblem、insertProblem、copyProblem
admin/problem.ts 里四处 insert(schema.problem) 其实是两对重复: - 新建公开题 / 新建比赛题:公共校验 + SQL 展示数据 + 插入 + 写标签,逐行一样, 只差 contestId。编辑题目那条也抄了同一段校验。收成 validateProblem(校验 + 展示数据)和 insertProblem(插入 + 标签,一个事务)。比赛题当初就是抄漏了 sqlDisplay,现在三条路径只有一份 - 比赛题转公开 / 从公开题库拉进比赛:复制整行 + 统计清零 + 复制标签,收成 copyProblem 顺带 db/index.ts 加 DbOrTx 类型,四处 `tx as unknown as typeof db` 去掉。 唯一的行为差异:编辑题目时 SQL 展示数据的生成挪到了题号查重之前(和新建一致), 两项同时出错时报的是前一个。 实跑核过:新建公开题、编辑、重复题号 409、新建比赛题、比赛题转公开(不可见、 标签跟过去)、从公开题库拉进比赛(可见、标签跟过去),SQL 题测试点缺失时照样拦下。 测试数据已删。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f2ca34fd4b
commit
925cecb389
3 files changed
+139
-153
No files matched your search
@@ -10,6 +10,14 @@ const client = postgres(config.databaseUrl)
|
|||||||
|
|
||||||
export const db = drizzle(client, { schema })
|
export const db = drizzle(client, { schema })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「库或事务」都能接的参数类型。辅助函数既要能单独调、又要能在事务里调时用它,
|
||||||
|
* 别再写 `tx as unknown as typeof db` —— 那个断言把两者的差异(事务上没有
|
||||||
|
* `$client`、`batch` 等)一并抹掉了。
|
||||||
|
*/
|
||||||
|
export type DbOrTx =
|
||||||
|
typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读出来的时刻统一成 ISO 8601 UTC,和写侧的 `new Date().toISOString()` 同形状。
|
* 读出来的时刻统一成 ISO 8601 UTC,和写侧的 `new Date().toISOString()` 同形状。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { Hono } from "hono"
|
|||||||
|
|
||||||
import { requireProblemPermission, type AppEnv } from "../../auth/middleware"
|
import { requireProblemPermission, type AppEnv } from "../../auth/middleware"
|
||||||
import type { AuthUser } from "../../auth/session"
|
import type { AuthUser } from "../../auth/session"
|
||||||
import { db, schema } from "../../db"
|
import { db, schema, type DbOrTx } from "../../db"
|
||||||
import { failure, parseBody, success } from "../../http"
|
import { failure, parseBody, success } from "../../http"
|
||||||
import { astRulesError, pickAstRules } from "../../judge/ast"
|
import { astRulesError, pickAstRules } from "../../judge/ast"
|
||||||
import { buildSqlDisplay } from "../../judge/sql"
|
import { buildSqlDisplay } from "../../judge/sql"
|
||||||
@@ -120,7 +120,7 @@ export function normalizeTagNames(names: string[]) {
|
|||||||
* 一条 `lower(name) in (...)` 把已有标签全查回来,按小写名建 Map。
|
* 一条 `lower(name) in (...)` 把已有标签全查回来,按小写名建 Map。
|
||||||
* 以前是每个名字一条 SELECT,一次改十个标签就是十次往返。
|
* 以前是每个名字一条 SELECT,一次改十个标签就是十次往返。
|
||||||
*/
|
*/
|
||||||
export async function findTagsByName(tx: typeof db, names: string[]) {
|
export async function findTagsByName(tx: DbOrTx, names: string[]) {
|
||||||
const map = new Map<string, number>()
|
const map = new Map<string, number>()
|
||||||
if (names.length === 0) return map
|
if (names.length === 0) return map
|
||||||
const rows = await tx
|
const rows = await tx
|
||||||
@@ -137,7 +137,7 @@ export async function findTagsByName(tx: typeof db, names: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 把标签名解析成 id:去空格、大小写不敏感复用已有标签,没有才新建。对齐旧 resolve_tags */
|
/** 把标签名解析成 id:去空格、大小写不敏感复用已有标签,没有才新建。对齐旧 resolve_tags */
|
||||||
async function resolveTags(tx: typeof db, names: string[]) {
|
async function resolveTags(tx: DbOrTx, names: string[]) {
|
||||||
const wanted = normalizeTagNames(names)
|
const wanted = normalizeTagNames(names)
|
||||||
if (wanted.length === 0) return []
|
if (wanted.length === 0) return []
|
||||||
const existing = await findTagsByName(tx, wanted)
|
const existing = await findTagsByName(tx, wanted)
|
||||||
@@ -154,7 +154,7 @@ async function resolveTags(tx: typeof db, names: string[]) {
|
|||||||
.filter((id) => id !== undefined)
|
.filter((id) => id !== undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setTags(tx: typeof db, problemId: number, names: string[]) {
|
async function setTags(tx: DbOrTx, problemId: number, names: string[]) {
|
||||||
const ids = await resolveTags(tx, names)
|
const ids = await resolveTags(tx, names)
|
||||||
await tx
|
await tx
|
||||||
.delete(schema.problemTags)
|
.delete(schema.problemTags)
|
||||||
@@ -332,6 +332,99 @@ function problemValues(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProblemInput = ReturnType<typeof createProblemRequestSchema.parse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存前的全部校验:公共校验 + SQL 题的展示数据。新建公开题、新建比赛题、编辑题目
|
||||||
|
* 三条路径共用 —— 原来各抄一份,比赛题那份还把算好的 sqlDisplay 丢了(见 insertProblem)。
|
||||||
|
*
|
||||||
|
* SQL 题每次保存都重算展示数据:测试点或标准答案可能刚改过,留着旧的就会和判题结果对不上。
|
||||||
|
*/
|
||||||
|
async function validateProblem(
|
||||||
|
data: ProblemInput,
|
||||||
|
): Promise<
|
||||||
|
{ error: string } | { sql: boolean; sqlDisplay: SqlDisplay | null }
|
||||||
|
> {
|
||||||
|
const checked = commonChecks(data)
|
||||||
|
if ("error" in checked) return checked
|
||||||
|
if (!checked.sql) return { sql: false, sqlDisplay: null }
|
||||||
|
const built = await generateSqlDisplay(
|
||||||
|
data.testCaseId,
|
||||||
|
data.answers,
|
||||||
|
data.sqlConfig!,
|
||||||
|
)
|
||||||
|
if ("error" in built) return built
|
||||||
|
return { sql: true, sqlDisplay: built.display }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建一道题(公开题 contestId 为 null)连同标签,一个事务 */
|
||||||
|
async function insertProblem(
|
||||||
|
data: ProblemInput,
|
||||||
|
validated: { sql: boolean; sqlDisplay: SqlDisplay | null },
|
||||||
|
owner: { contestId: number | null; createdById: number },
|
||||||
|
) {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
const [row] = await tx
|
||||||
|
.insert(schema.problem)
|
||||||
|
.values({
|
||||||
|
...problemValues(data, validated.sql),
|
||||||
|
...owner,
|
||||||
|
createTime: now,
|
||||||
|
lastUpdateTime: now,
|
||||||
|
submissionNumber: 0,
|
||||||
|
acceptedNumber: 0,
|
||||||
|
statisticInfo: {},
|
||||||
|
isPublic: false,
|
||||||
|
// 比赛题原来这里写死 null,结果比赛里的 SQL 题打开后看不到示例数据表和期望结果
|
||||||
|
sqlDisplay: validated.sqlDisplay,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
await setTags(tx, row!.id, data.tags)
|
||||||
|
return row!
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一道已有的题复制一份(连同标签),统计清零。比赛题转公开、从公开题库拉进比赛
|
||||||
|
* 都是这个动作,只是落到哪、可见性怎么设不同,由 `overrides` 给。
|
||||||
|
*/
|
||||||
|
async function copyProblem(
|
||||||
|
tx: DbOrTx,
|
||||||
|
source: ProblemRow,
|
||||||
|
overrides: Pick<
|
||||||
|
typeof schema.problem.$inferInsert,
|
||||||
|
"contestId" | "displayId" | "visible" | "isPublic"
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const { id: _old, ...rest } = source
|
||||||
|
const [copy] = await tx
|
||||||
|
.insert(schema.problem)
|
||||||
|
.values({
|
||||||
|
...rest,
|
||||||
|
...overrides,
|
||||||
|
submissionNumber: 0,
|
||||||
|
acceptedNumber: 0,
|
||||||
|
statisticInfo: {},
|
||||||
|
createTime: now,
|
||||||
|
lastUpdateTime: now,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
const tags = await tx
|
||||||
|
.select({ tagId: schema.problemTags.problemtagId })
|
||||||
|
.from(schema.problemTags)
|
||||||
|
.where(eq(schema.problemTags.problemId, source.id))
|
||||||
|
if (tags.length) {
|
||||||
|
await tx
|
||||||
|
.insert(schema.problemTags)
|
||||||
|
.values(
|
||||||
|
tags.map((tag) => ({ problemId: copy!.id, problemtagId: tag.tagId })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return copy!
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 公开题目
|
// ---------------------------------------------------------------- 公开题目
|
||||||
|
|
||||||
adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
|
adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
|
||||||
@@ -434,19 +527,9 @@ adminProblemRoutes.get("/problems/:id", requireProblemPermission, async (c) => {
|
|||||||
adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
|
adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
|
||||||
const parsed = await parseBody(c, createProblemRequestSchema)
|
const parsed = await parseBody(c, createProblemRequestSchema)
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const checked = commonChecks(parsed.data)
|
const validated = await validateProblem(parsed.data)
|
||||||
if ("error" in checked)
|
if ("error" in validated)
|
||||||
return failure(c, 400, "invalid-problem", checked.error)
|
return failure(c, 400, "invalid-problem", validated.error)
|
||||||
let sqlDisplay: SqlDisplay | null = null
|
|
||||||
if (checked.sql) {
|
|
||||||
const built = await generateSqlDisplay(
|
|
||||||
parsed.data.testCaseId,
|
|
||||||
parsed.data.answers,
|
|
||||||
parsed.data.sqlConfig!,
|
|
||||||
)
|
|
||||||
if ("error" in built) return failure(c, 400, "invalid-problem", built.error)
|
|
||||||
sqlDisplay = built.display
|
|
||||||
}
|
|
||||||
|
|
||||||
const [duplicate] = await db
|
const [duplicate] = await db
|
||||||
.select({ id: schema.problem.id })
|
.select({ id: schema.problem.id })
|
||||||
@@ -461,25 +544,9 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
|
|||||||
if (duplicate)
|
if (duplicate)
|
||||||
return failure(c, 409, "display-id-exists", "Display ID already exists")
|
return failure(c, 409, "display-id-exists", "Display ID already exists")
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
const created = await insertProblem(parsed.data, validated, {
|
||||||
const created = await db.transaction(async (tx) => {
|
contestId: null,
|
||||||
const [row] = await tx
|
createdById: c.get("user")!.id,
|
||||||
.insert(schema.problem)
|
|
||||||
.values({
|
|
||||||
...problemValues(parsed.data, checked.sql),
|
|
||||||
contestId: null,
|
|
||||||
createdById: c.get("user")!.id,
|
|
||||||
createTime: now,
|
|
||||||
lastUpdateTime: now,
|
|
||||||
submissionNumber: 0,
|
|
||||||
acceptedNumber: 0,
|
|
||||||
statisticInfo: {},
|
|
||||||
isPublic: false,
|
|
||||||
sqlDisplay,
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
await setTags(tx as unknown as typeof db, row!.id, parsed.data.tags)
|
|
||||||
return row!
|
|
||||||
})
|
})
|
||||||
return success(c, await serialize(created), 201)
|
return success(c, await serialize(created), 201)
|
||||||
})
|
})
|
||||||
@@ -498,9 +565,9 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
|
|||||||
if (!(await canEdit(c.get("user")!, existing))) {
|
if (!(await canEdit(c.get("user")!, existing))) {
|
||||||
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||||
}
|
}
|
||||||
const checked = commonChecks(parsed.data)
|
const validated = await validateProblem(parsed.data)
|
||||||
if ("error" in checked)
|
if ("error" in validated)
|
||||||
return failure(c, 400, "invalid-problem", checked.error)
|
return failure(c, 400, "invalid-problem", validated.error)
|
||||||
|
|
||||||
// 题号唯一性的作用域跟着题目走:公开题在全部公开题里唯一,比赛题在本场比赛内唯一
|
// 题号唯一性的作用域跟着题目走:公开题在全部公开题里唯一,比赛题在本场比赛内唯一
|
||||||
const [duplicate] = await db
|
const [duplicate] = await db
|
||||||
@@ -519,29 +586,17 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
|
|||||||
if (duplicate)
|
if (duplicate)
|
||||||
return failure(c, 409, "display-id-exists", "Display ID already exists")
|
return failure(c, 409, "display-id-exists", "Display ID already exists")
|
||||||
|
|
||||||
// SQL 题每次保存都重算展示数据:测试点或标准答案可能刚改过,留着旧的就会和判题结果对不上
|
|
||||||
let sqlDisplay: SqlDisplay | null = null
|
|
||||||
if (checked.sql) {
|
|
||||||
const built = await generateSqlDisplay(
|
|
||||||
parsed.data.testCaseId,
|
|
||||||
parsed.data.answers,
|
|
||||||
parsed.data.sqlConfig!,
|
|
||||||
)
|
|
||||||
if ("error" in built) return failure(c, 400, "invalid-problem", built.error)
|
|
||||||
sqlDisplay = built.display
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await db.transaction(async (tx) => {
|
const updated = await db.transaction(async (tx) => {
|
||||||
const [row] = await tx
|
const [row] = await tx
|
||||||
.update(schema.problem)
|
.update(schema.problem)
|
||||||
.set({
|
.set({
|
||||||
...problemValues(parsed.data, checked.sql),
|
...problemValues(parsed.data, validated.sql),
|
||||||
sqlDisplay,
|
sqlDisplay: validated.sqlDisplay,
|
||||||
lastUpdateTime: new Date().toISOString(),
|
lastUpdateTime: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.where(eq(schema.problem.id, id))
|
.where(eq(schema.problem.id, id))
|
||||||
.returning()
|
.returning()
|
||||||
await setTags(tx as unknown as typeof db, id, parsed.data.tags)
|
await setTags(tx, id, parsed.data.tags)
|
||||||
return row!
|
return row!
|
||||||
})
|
})
|
||||||
return success(c, await serialize(updated))
|
return success(c, await serialize(updated))
|
||||||
@@ -686,20 +741,9 @@ adminProblemRoutes.post(
|
|||||||
}
|
}
|
||||||
const parsed = await parseBody(c, createProblemRequestSchema)
|
const parsed = await parseBody(c, createProblemRequestSchema)
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const checked = commonChecks(parsed.data)
|
const validated = await validateProblem(parsed.data)
|
||||||
if ("error" in checked)
|
if ("error" in validated)
|
||||||
return failure(c, 400, "invalid-problem", checked.error)
|
return failure(c, 400, "invalid-problem", validated.error)
|
||||||
let sqlDisplay: SqlDisplay | null = null
|
|
||||||
if (checked.sql) {
|
|
||||||
const built = await generateSqlDisplay(
|
|
||||||
parsed.data.testCaseId,
|
|
||||||
parsed.data.answers,
|
|
||||||
parsed.data.sqlConfig!,
|
|
||||||
)
|
|
||||||
if ("error" in built)
|
|
||||||
return failure(c, 400, "invalid-problem", built.error)
|
|
||||||
sqlDisplay = built.display
|
|
||||||
}
|
|
||||||
|
|
||||||
const [duplicate] = await db
|
const [duplicate] = await db
|
||||||
.select({ id: schema.problem.id })
|
.select({ id: schema.problem.id })
|
||||||
@@ -714,27 +758,9 @@ adminProblemRoutes.post(
|
|||||||
if (duplicate)
|
if (duplicate)
|
||||||
return failure(c, 409, "display-id-exists", "Duplicate Display id")
|
return failure(c, 409, "display-id-exists", "Duplicate Display id")
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
const created = await insertProblem(parsed.data, validated, {
|
||||||
const created = await db.transaction(async (tx) => {
|
contestId,
|
||||||
const [row] = await tx
|
createdById: user.id,
|
||||||
.insert(schema.problem)
|
|
||||||
.values({
|
|
||||||
...problemValues(parsed.data, checked.sql),
|
|
||||||
contestId,
|
|
||||||
createdById: user.id,
|
|
||||||
createTime: now,
|
|
||||||
lastUpdateTime: now,
|
|
||||||
submissionNumber: 0,
|
|
||||||
acceptedNumber: 0,
|
|
||||||
statisticInfo: {},
|
|
||||||
isPublic: false,
|
|
||||||
// 上面 generateSqlDisplay 已经把展示数据算好了,之前这里写死 null,
|
|
||||||
// 结果比赛里的 SQL 题打开后看不到示例数据表和期望结果(公开题那两条路径都是对的)
|
|
||||||
sqlDisplay,
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
await setTags(tx as unknown as typeof db, row!.id, parsed.data.tags)
|
|
||||||
return row!
|
|
||||||
})
|
})
|
||||||
return success(c, await serialize(created), 201)
|
return success(c, await serialize(created), 201)
|
||||||
},
|
},
|
||||||
@@ -782,43 +808,19 @@ adminProblemRoutes.post(
|
|||||||
if (duplicate)
|
if (duplicate)
|
||||||
return failure(c, 409, "display-id-exists", "Duplicate display ID")
|
return failure(c, 409, "display-id-exists", "Duplicate display ID")
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
const created = await db.transaction(async (tx) => {
|
const created = await db.transaction(async (tx) => {
|
||||||
// 原比赛题标记成「已转公开」,避免同一道题被转两次
|
// 原比赛题标记成「已转公开」,避免同一道题被转两次
|
||||||
await tx
|
await tx
|
||||||
.update(schema.problem)
|
.update(schema.problem)
|
||||||
.set({ isPublic: true })
|
.set({ isPublic: true })
|
||||||
.where(eq(schema.problem.id, id))
|
.where(eq(schema.problem.id, id))
|
||||||
const { id: _old, ...rest } = problem
|
// 转出来的公开题默认不可见:题面往往还要按公开场景改一遍
|
||||||
const [copy] = await tx
|
return copyProblem(tx, problem, {
|
||||||
.insert(schema.problem)
|
contestId: null,
|
||||||
.values({
|
displayId: parsed.data.displayId,
|
||||||
...rest,
|
visible: false,
|
||||||
contestId: null,
|
isPublic: true,
|
||||||
displayId: parsed.data.displayId,
|
})
|
||||||
// 转出来的公开题默认不可见:题面往往还要按公开场景改一遍
|
|
||||||
visible: false,
|
|
||||||
isPublic: true,
|
|
||||||
submissionNumber: 0,
|
|
||||||
acceptedNumber: 0,
|
|
||||||
statisticInfo: {},
|
|
||||||
createTime: now,
|
|
||||||
lastUpdateTime: now,
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
const tags = await tx
|
|
||||||
.select({ tagId: schema.problemTags.problemtagId })
|
|
||||||
.from(schema.problemTags)
|
|
||||||
.where(eq(schema.problemTags.problemId, id))
|
|
||||||
if (tags.length) {
|
|
||||||
await tx.insert(schema.problemTags).values(
|
|
||||||
tags.map((tag) => ({
|
|
||||||
problemId: copy!.id,
|
|
||||||
problemtagId: tag.tagId,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return copy!
|
|
||||||
})
|
})
|
||||||
return success(c, await serialize(created), 201)
|
return success(c, await serialize(created), 201)
|
||||||
},
|
},
|
||||||
@@ -881,38 +883,14 @@ adminProblemRoutes.post(
|
|||||||
"Duplicate display id in this contest",
|
"Duplicate display id in this contest",
|
||||||
)
|
)
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
const created = await db.transaction((tx) =>
|
||||||
const created = await db.transaction(async (tx) => {
|
copyProblem(tx, problem, {
|
||||||
const { id: _old, ...rest } = problem
|
contestId,
|
||||||
const [copy] = await tx
|
displayId: parsed.data.displayId,
|
||||||
.insert(schema.problem)
|
visible: true,
|
||||||
.values({
|
isPublic: true,
|
||||||
...rest,
|
}),
|
||||||
contestId,
|
)
|
||||||
isPublic: true,
|
|
||||||
visible: true,
|
|
||||||
displayId: parsed.data.displayId,
|
|
||||||
submissionNumber: 0,
|
|
||||||
acceptedNumber: 0,
|
|
||||||
statisticInfo: {},
|
|
||||||
createTime: now,
|
|
||||||
lastUpdateTime: now,
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
const tags = await tx
|
|
||||||
.select({ tagId: schema.problemTags.problemtagId })
|
|
||||||
.from(schema.problemTags)
|
|
||||||
.where(eq(schema.problemTags.problemId, problem.id))
|
|
||||||
if (tags.length) {
|
|
||||||
await tx.insert(schema.problemTags).values(
|
|
||||||
tags.map((tag) => ({
|
|
||||||
problemId: copy!.id,
|
|
||||||
problemtagId: tag.tagId,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return copy!
|
|
||||||
})
|
|
||||||
return success(c, await serialize(created), 201)
|
return success(c, await serialize(created), 201)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ adminTagRoutes.post(
|
|||||||
const wanted = normalizeTagNames(parsed.data.tagNames)
|
const wanted = normalizeTagNames(parsed.data.tagNames)
|
||||||
|
|
||||||
const tagIds = await db.transaction(async (tx) => {
|
const tagIds = await db.transaction(async (tx) => {
|
||||||
const existing = await findTagsByName(tx as unknown as typeof db, wanted)
|
const existing = await findTagsByName(tx, wanted)
|
||||||
// 添加时按需新建标签,移除时只认已有标签 —— 否则「移除」会顺手造出一堆空标签
|
// 添加时按需新建标签,移除时只认已有标签 —— 否则「移除」会顺手造出一堆空标签
|
||||||
if (parsed.data.action === "add") {
|
if (parsed.data.action === "add") {
|
||||||
const missing = wanted.filter(
|
const missing = wanted.filter(
|
||||||
|
|||||||
Reference in new issue
Block a user