+ 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.
+
+
+
+
+
+
+ )}
+
{/* 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[] {