diff --git a/src/app/api/tags/categories/[id]/route.ts b/src/app/api/tags/categories/[id]/route.ts index ab7e9a7..2e5a93e 100644 --- a/src/app/api/tags/categories/[id]/route.ts +++ b/src/app/api/tags/categories/[id]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { updateCategory, deleteCategory, deleteCategoryForce, getTags } from '@/lib/tags' +import { updateCategory, deleteCategory, deleteCategoryForce, getTags, getCategories, mergeCategories } from '@/lib/tags' import { requireAdmin } from '@/lib/auth' export async function PATCH( @@ -11,9 +11,30 @@ export async function PATCH( try { const { id } = await params - const { name } = await request.json() - const category = updateCategory(id, name) - return NextResponse.json(category) + const { name, merge } = await request.json() + + try { + const category = updateCategory(id, name) + return NextResponse.json(category) + } catch (err) { + const msg = (err as Error).message + if (!msg.includes('already exists')) throw err + + // A category with this name already exists — find it + const trimmed = (name as string).trim() + const target = getCategories().find((c) => c.name.toLowerCase() === trimmed.toLowerCase()) + if (!target) throw err + + if (merge) { + mergeCategories(id, target.id) + return NextResponse.json(target) + } + + return NextResponse.json( + { error: msg, conflict: true, targetCategoryId: target.id }, + { status: 409 } + ) + } } catch (err) { return NextResponse.json({ error: (err as Error).message }, { status: 400 }) } diff --git a/src/app/manage/tags/page.tsx b/src/app/manage/tags/page.tsx index 875a34a..edfd931 100644 --- a/src/app/manage/tags/page.tsx +++ b/src/app/manage/tags/page.tsx @@ -85,11 +85,13 @@ function CategoryBlock({ const [confirming, setConfirming] = useState(false) const [deleting, setDeleting] = useState(false) const [error, setError] = useState(null) + const [mergeConflict, setMergeConflict] = useState<{ name: string } | null>(null) const cancelRef = useRef | null>(null) const handleRename = async (e: React.FormEvent) => { e.preventDefault() setError(null) + setMergeConflict(null) setSaving(true) try { const res = await fetch(`/api/tags/categories/${encodeURIComponent(category.id)}`, { @@ -98,8 +100,35 @@ function CategoryBlock({ body: JSON.stringify({ name: editName }), }) const data = await res.json() + if (!res.ok) { + if (res.status === 409 && data.conflict) { + setMergeConflict({ name: editName.trim() }) + setSaving(false) + return + } + setError(data.error); setSaving(false); return + } + setEditing(false) + onChanged() + } catch { + setError('Network error.') + } + setSaving(false) + } + + const handleMerge = async () => { + setError(null) + setSaving(true) + try { + const res = await fetch(`/api/tags/categories/${encodeURIComponent(category.id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: editName, merge: true }), + }) + const data = await res.json() if (!res.ok) { setError(data.error); setSaving(false); return } setEditing(false) + setMergeConflict(null) onChanged() } catch { setError('Network error.') @@ -158,7 +187,7 @@ function CategoryBlock({ + + + + )} + {/* Tags list */}
{tags.map((tag) => ( diff --git a/src/lib/tags.ts b/src/lib/tags.ts index 13b2109..00d9bdb 100644 --- a/src/lib/tags.ts +++ b/src/lib/tags.ts @@ -98,6 +98,62 @@ export function deleteCategoryForce(id: string): void { if (result.changes === 0) throw new Error(`Category not found: ${id}`) } +/** + * Merge all tags from `sourceId` category into `targetId` category, then + * delete the source category. Tags with conflicting names (case-insensitive) + * are combined: their media_tags and tag_mappings rows are re-pointed to the + * target tag, and the source tag is deleted. + */ +export function mergeCategories(sourceId: string, targetId: string): void { + const db = getDb() + + const source = db.prepare('SELECT id, name FROM tag_categories WHERE id = ?').get(sourceId) as TagCategory | undefined + if (!source) throw new Error(`Source category not found: ${sourceId}`) + const target = db.prepare('SELECT id, name FROM tag_categories WHERE id = ?').get(targetId) as TagCategory | undefined + if (!target) throw new Error(`Target category not found: ${targetId}`) + + const sourceTags = db + .prepare('SELECT id, name, category_id as categoryId FROM tags WHERE category_id = ?') + .all(sourceId) as Tag[] + const targetTags = db + .prepare('SELECT id, name, category_id as categoryId FROM tags WHERE category_id = ?') + .all(targetId) as Tag[] + + const targetTagsByNameLower = new Map(targetTags.map((t) => [t.name.toLowerCase(), t])) + + const txn = db.transaction(() => { + for (const srcTag of sourceTags) { + const conflict = targetTagsByNameLower.get(srcTag.name.toLowerCase()) + if (conflict) { + // Re-point media_tags from source tag to target tag (ignore duplicates) + db.prepare( + `INSERT OR IGNORE INTO media_tags (item_key, tag_id) + SELECT item_key, ? FROM media_tags WHERE tag_id = ?` + ).run(conflict.id, srcTag.id) + db.prepare('DELETE FROM media_tags WHERE tag_id = ?').run(srcTag.id) + + // Re-point tag_mappings from source tag to target tag (ignore duplicates) + db.prepare( + `UPDATE OR IGNORE tag_mappings SET tag_id = ? WHERE tag_id = ?` + ).run(conflict.id, srcTag.id) + // Delete any remaining (were duplicates that couldn't be updated) + db.prepare('DELETE FROM tag_mappings WHERE tag_id = ?').run(srcTag.id) + + // Delete the source tag + db.prepare('DELETE FROM tags WHERE id = ?').run(srcTag.id) + } else { + // No conflict — just move the tag to the target category + db.prepare('UPDATE tags SET category_id = ? WHERE id = ?').run(targetId, srcTag.id) + } + } + + // Delete the now-empty source category + db.prepare('DELETE FROM tag_categories WHERE id = ?').run(sourceId) + }) + + txn() +} + // ─── Tags ───────────────────────────────────────────────────────────────────── export function getTags(categoryId?: string): Tag[] {