add tagging system for media items
Introduces user-defined tag categories and tags with a many-to-many relationship to media items. Tags are stored in a SQLite database (medialore.db via better-sqlite3) with ON DELETE CASCADE for automatic cleanup. Users can manage categories and tags at /manage/tags, assign tags to games in the detail modal, and tag mixed media files via a hover button on each tile. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
229
src/lib/tags.ts
Normal file
229
src/lib/tags.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import path from 'path'
|
||||
import Database from 'better-sqlite3'
|
||||
import type { Tag, TagCategory } from '@/types'
|
||||
|
||||
const DB_PATH = path.resolve(process.cwd(), 'medialore.db')
|
||||
|
||||
let _db: Database.Database | null = null
|
||||
|
||||
function getDb(): Database.Database {
|
||||
if (_db) return _db
|
||||
_db = new Database(DB_PATH)
|
||||
_db.pragma('journal_mode = WAL')
|
||||
_db.pragma('foreign_keys = ON')
|
||||
initDb(_db)
|
||||
return _db
|
||||
}
|
||||
|
||||
function initDb(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tag_categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
category_id TEXT NOT NULL REFERENCES tag_categories(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS tags_name_category ON tags(name, category_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media_tags (
|
||||
media_key TEXT NOT NULL,
|
||||
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (media_key, tag_id)
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
// ─── Categories ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function getCategories(): TagCategory[] {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT id, name FROM tag_categories ORDER BY name').all() as TagCategory[]
|
||||
}
|
||||
|
||||
export function addCategory(name: string): TagCategory {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) throw new Error('Category name is required.')
|
||||
|
||||
const db = getDb()
|
||||
const baseId = slugify(trimmed) || 'category'
|
||||
let id = baseId
|
||||
let suffix = 2
|
||||
while (db.prepare('SELECT 1 FROM tag_categories WHERE id = ?').get(id)) {
|
||||
id = `${baseId}-${suffix++}`
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare('INSERT INTO tag_categories (id, name) VALUES (?, ?)').run(id, trimmed)
|
||||
} catch {
|
||||
throw new Error(`A category named "${trimmed}" already exists.`)
|
||||
}
|
||||
|
||||
return { id, name: trimmed }
|
||||
}
|
||||
|
||||
export function updateCategory(id: string, name: string): TagCategory {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) throw new Error('Category name is required.')
|
||||
|
||||
const db = getDb()
|
||||
try {
|
||||
const result = db.prepare('UPDATE tag_categories SET name = ? WHERE id = ?').run(trimmed, id)
|
||||
if (result.changes === 0) throw new Error(`Category not found: ${id}`)
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message
|
||||
if (msg.includes('UNIQUE')) throw new Error(`A category named "${trimmed}" already exists.`)
|
||||
throw err
|
||||
}
|
||||
|
||||
return { id, name: trimmed }
|
||||
}
|
||||
|
||||
export function deleteCategory(id: string): void {
|
||||
const db = getDb()
|
||||
const tagCount = (
|
||||
db.prepare('SELECT COUNT(*) as count FROM tags WHERE category_id = ?').get(id) as { count: number }
|
||||
).count
|
||||
|
||||
if (tagCount > 0) {
|
||||
throw new Error(
|
||||
`Category has ${tagCount} tag${tagCount === 1 ? '' : 's'}. Delete all tags first or use force delete.`
|
||||
)
|
||||
}
|
||||
|
||||
const result = db.prepare('DELETE FROM tag_categories WHERE id = ?').run(id)
|
||||
if (result.changes === 0) throw new Error(`Category not found: ${id}`)
|
||||
}
|
||||
|
||||
export function deleteCategoryForce(id: string): void {
|
||||
const db = getDb()
|
||||
// CASCADE on tags will also cascade to media_tags
|
||||
const result = db.prepare('DELETE FROM tag_categories WHERE id = ?').run(id)
|
||||
if (result.changes === 0) throw new Error(`Category not found: ${id}`)
|
||||
}
|
||||
|
||||
// ─── Tags ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getTags(categoryId?: string): Tag[] {
|
||||
const db = getDb()
|
||||
if (categoryId) {
|
||||
return db
|
||||
.prepare('SELECT id, name, category_id as categoryId FROM tags WHERE category_id = ? ORDER BY name')
|
||||
.all(categoryId) as Tag[]
|
||||
}
|
||||
return db
|
||||
.prepare('SELECT id, name, category_id as categoryId FROM tags ORDER BY name')
|
||||
.all() as Tag[]
|
||||
}
|
||||
|
||||
export function addTag(name: string, categoryId: string): Tag {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) throw new Error('Tag name is required.')
|
||||
|
||||
const db = getDb()
|
||||
|
||||
const category = db.prepare('SELECT 1 FROM tag_categories WHERE id = ?').get(categoryId)
|
||||
if (!category) throw new Error(`Category not found: ${categoryId}`)
|
||||
|
||||
const baseId = `tag-${slugify(trimmed) || 'tag'}`
|
||||
let id = baseId
|
||||
let suffix = 2
|
||||
while (db.prepare('SELECT 1 FROM tags WHERE id = ?').get(id)) {
|
||||
id = `${baseId}-${suffix++}`
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare('INSERT INTO tags (id, name, category_id) VALUES (?, ?, ?)').run(id, trimmed, categoryId)
|
||||
} catch {
|
||||
throw new Error(`A tag named "${trimmed}" already exists in this category.`)
|
||||
}
|
||||
|
||||
return { id, name: trimmed, categoryId }
|
||||
}
|
||||
|
||||
export function updateTag(id: string, name: string): Tag {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) throw new Error('Tag name is required.')
|
||||
|
||||
const db = getDb()
|
||||
|
||||
const existing = db
|
||||
.prepare('SELECT id, name, category_id as categoryId FROM tags WHERE id = ?')
|
||||
.get(id) as Tag | undefined
|
||||
if (!existing) throw new Error(`Tag not found: ${id}`)
|
||||
|
||||
try {
|
||||
db.prepare('UPDATE tags SET name = ? WHERE id = ?').run(trimmed, id)
|
||||
} catch {
|
||||
throw new Error(`A tag named "${trimmed}" already exists in this category.`)
|
||||
}
|
||||
|
||||
return { id, name: trimmed, categoryId: existing.categoryId }
|
||||
}
|
||||
|
||||
export function deleteTag(id: string): void {
|
||||
const db = getDb()
|
||||
// CASCADE removes media_tags rows automatically
|
||||
const result = db.prepare('DELETE FROM tags WHERE id = ?').run(id)
|
||||
if (result.changes === 0) throw new Error(`Tag not found: ${id}`)
|
||||
}
|
||||
|
||||
// ─── Assignments ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function addTagToItem(mediaKey: string, tagId: string): void {
|
||||
const db = getDb()
|
||||
const tag = db.prepare('SELECT 1 FROM tags WHERE id = ?').get(tagId)
|
||||
if (!tag) throw new Error(`Tag not found: ${tagId}`)
|
||||
// INSERT OR IGNORE handles duplicate gracefully
|
||||
db.prepare('INSERT OR IGNORE INTO media_tags (media_key, tag_id) VALUES (?, ?)').run(mediaKey, tagId)
|
||||
}
|
||||
|
||||
export function removeTagFromItem(mediaKey: string, tagId: string): void {
|
||||
const db = getDb()
|
||||
db.prepare('DELETE FROM media_tags WHERE media_key = ? AND tag_id = ?').run(mediaKey, tagId)
|
||||
}
|
||||
|
||||
export function getResolvedTagsForItem(mediaKey: string): { tags: Tag[]; categories: TagCategory[] } {
|
||||
const db = getDb()
|
||||
|
||||
const tags = db
|
||||
.prepare(
|
||||
`SELECT t.id, t.name, t.category_id as categoryId
|
||||
FROM tags t
|
||||
JOIN media_tags mt ON mt.tag_id = t.id
|
||||
WHERE mt.media_key = ?
|
||||
ORDER BY t.name`
|
||||
)
|
||||
.all(mediaKey) as Tag[]
|
||||
|
||||
const categoryIds = [...new Set(tags.map((t) => t.categoryId))]
|
||||
const categories: TagCategory[] =
|
||||
categoryIds.length > 0
|
||||
? (db
|
||||
.prepare(
|
||||
`SELECT id, name FROM tag_categories WHERE id IN (${categoryIds.map(() => '?').join(',')}) ORDER BY name`
|
||||
)
|
||||
.all(...categoryIds) as TagCategory[])
|
||||
: []
|
||||
|
||||
return { tags, categories }
|
||||
}
|
||||
|
||||
export function removeAllAssignmentsForLibrary(libraryId: string): void {
|
||||
const db = getDb()
|
||||
db.prepare("DELETE FROM media_tags WHERE media_key LIKE ?").run(`${libraryId}:%`)
|
||||
}
|
||||
Reference in New Issue
Block a user