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 { 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()
|
||||||
|
|
||||||
|
try {
|
||||||
const category = updateCategory(id, name)
|
const category = updateCategory(id, name)
|
||||||
return NextResponse.json(category)
|
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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 “{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 */}
|
{/* 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) => (
|
||||||
|
|||||||
@@ -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[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user