handle merging tag categories
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s

This commit is contained in:
Garret Patti
2026-04-19 23:25:09 -04:00
parent fc9a7af7c3
commit 71a026f01e
3 changed files with 137 additions and 5 deletions

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' 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' import { requireAdmin } from '@/lib/auth'
export async function PATCH( export async function PATCH(
@@ -11,9 +11,30 @@ export async function PATCH(
try { try {
const { id } = await params const { id } = await params
const { name } = await request.json() const { name, merge } = await request.json()
const category = updateCategory(id, name)
return NextResponse.json(category) 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) { } catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 400 }) return NextResponse.json({ error: (err as Error).message }, { status: 400 })
} }

View File

@@ -85,11 +85,13 @@ function CategoryBlock({
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [mergeConflict, setMergeConflict] = useState<{ name: string } | null>(null)
const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null) const cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleRename = async (e: React.FormEvent) => { const handleRename = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError(null) setError(null)
setMergeConflict(null)
setSaving(true) setSaving(true)
try { try {
const res = await fetch(`/api/tags/categories/${encodeURIComponent(category.id)}`, { const res = await fetch(`/api/tags/categories/${encodeURIComponent(category.id)}`, {
@@ -98,8 +100,35 @@ function CategoryBlock({
body: JSON.stringify({ name: editName }), body: JSON.stringify({ name: editName }),
}) })
const data = await res.json() 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 } if (!res.ok) { setError(data.error); setSaving(false); return }
setEditing(false) setEditing(false)
setMergeConflict(null)
onChanged() onChanged()
} catch { } catch {
setError('Network error.') setError('Network error.')
@@ -158,7 +187,7 @@ function CategoryBlock({
</button> </button>
<button <button
type="button" type="button"
onClick={() => { setEditing(false); setEditName(category.name); setError(null) }} onClick={() => { setEditing(false); setEditName(category.name); setError(null); setMergeConflict(null) }}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors" className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }} style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
> >
@@ -230,6 +259,32 @@ function CategoryBlock({
</p> </p>
)} )}
{mergeConflict && (
<div className="mb-3 px-3 py-2 rounded-lg text-xs" style={{ backgroundColor: '#78350f33', color: '#fbbf24' }}>
<p className="mb-2">
A category named &ldquo;{mergeConflict.name}&rdquo; already exists. This will merge all tags from
&ldquo;{category.name}&rdquo; into it. Tags with the same name will be combined.
</p>
<div className="flex gap-2">
<button
onClick={handleMerge}
disabled={saving}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
style={{ backgroundColor: '#b45309', color: '#fff' }}
>
{saving ? 'Merging…' : 'Merge'}
</button>
<button
onClick={() => setMergeConflict(null)}
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
>
Cancel
</button>
</div>
</div>
)}
{/* Tags list */} {/* Tags list */}
<div className="flex flex-wrap gap-2 mb-3"> <div className="flex flex-wrap gap-2 mb-3">
{tags.map((tag) => ( {tags.map((tag) => (

View File

@@ -98,6 +98,62 @@ export function deleteCategoryForce(id: string): void {
if (result.changes === 0) throw new Error(`Category not found: ${id}`) 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 ───────────────────────────────────────────────────────────────────── // ─── Tags ─────────────────────────────────────────────────────────────────────
export function getTags(categoryId?: string): Tag[] { export function getTags(categoryId?: string): Tag[] {