handle merging tag categories
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
This commit is contained in:
@@ -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 { 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 })
|
||||
}
|
||||
|
||||
@@ -85,11 +85,13 @@ function CategoryBlock({
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mergeConflict, setMergeConflict] = useState<{ name: string } | null>(null)
|
||||
const cancelRef = useRef<ReturnType<typeof setTimeout> | 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({
|
||||
</button>
|
||||
<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"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
@@ -230,6 +259,32 @@ function CategoryBlock({
|
||||
</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 “{mergeConflict.name}” already exists. This will merge all tags from
|
||||
“{category.name}” 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 */}
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{tags.map((tag) => (
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user