add tag imports
This commit is contained in:
203
src/lib/comic-metadata.ts
Normal file
203
src/lib/comic-metadata.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import type { Library, ImportedTag, TagMapping } from '@/types'
|
||||
import { getDb } from './db'
|
||||
import { resolveLibraryRoot } from './libraries'
|
||||
import { parseComicInfo } from './comic-info'
|
||||
|
||||
// ─── Metadata Import ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Import ComicInfo.xml metadata for all comic_issue items in a library.
|
||||
* - Populates media_items fields (title, year, genres, metadata JSON).
|
||||
* - For each tag: if a mapping exists, assigns the real tag; otherwise creates
|
||||
* an imported tag entry.
|
||||
*/
|
||||
export function importComicMetadata(library: Library): void {
|
||||
const db = getDb()
|
||||
const libraryRoot = resolveLibraryRoot(library)
|
||||
|
||||
const issues = db
|
||||
.prepare(
|
||||
`SELECT item_key, file_path, metadata FROM media_items
|
||||
WHERE library_id = ? AND item_type = 'comic_issue' AND file_path IS NOT NULL`
|
||||
)
|
||||
.all(library.id) as { item_key: string; file_path: string; metadata: string | null }[]
|
||||
|
||||
// Load existing mappings for this library
|
||||
const mappingRows = db
|
||||
.prepare('SELECT imported_tag_name, tag_id FROM tag_mappings WHERE library_id = ?')
|
||||
.all(library.id) as { imported_tag_name: string; tag_id: string }[]
|
||||
const mappings = new Map(mappingRows.map((r) => [r.imported_tag_name, r.tag_id]))
|
||||
|
||||
// Clear existing imported tag associations for this library (they'll be re-created)
|
||||
db.prepare(
|
||||
`DELETE FROM item_imported_tags WHERE imported_tag_id IN (
|
||||
SELECT id FROM imported_tags WHERE library_id = ?
|
||||
)`
|
||||
).run(library.id)
|
||||
db.prepare('DELETE FROM imported_tags WHERE library_id = ?').run(library.id)
|
||||
|
||||
const updateItem = db.prepare(`
|
||||
UPDATE media_items SET title = @title, year = @year, genres = @genres, metadata = @metadata
|
||||
WHERE item_key = @item_key
|
||||
`)
|
||||
const addMediaTag = db.prepare(
|
||||
'INSERT OR IGNORE INTO media_tags (item_key, tag_id) VALUES (?, ?)'
|
||||
)
|
||||
const upsertImportedTag = db.prepare(`
|
||||
INSERT INTO imported_tags (id, library_id, name) VALUES (@id, @library_id, @name)
|
||||
ON CONFLICT(library_id, name) DO UPDATE SET name = excluded.name
|
||||
RETURNING id
|
||||
`)
|
||||
const addItemImportedTag = db.prepare(
|
||||
'INSERT OR IGNORE INTO item_imported_tags (item_key, imported_tag_id) VALUES (?, ?)'
|
||||
)
|
||||
|
||||
let importedCount = 0
|
||||
|
||||
db.transaction(() => {
|
||||
for (const issue of issues) {
|
||||
const absPath = path.join(libraryRoot, issue.file_path)
|
||||
const info = parseComicInfo(absPath)
|
||||
if (!info) continue
|
||||
|
||||
// Merge with existing metadata JSON (preserve pageCount, coverUrl, etc.)
|
||||
const existingMeta = issue.metadata ? JSON.parse(issue.metadata) : {}
|
||||
const mergedMeta = {
|
||||
...existingMeta,
|
||||
writer: info.writer,
|
||||
publisher: info.publisher,
|
||||
translator: info.translator,
|
||||
web: info.web,
|
||||
month: info.month,
|
||||
day: info.day,
|
||||
}
|
||||
|
||||
updateItem.run({
|
||||
item_key: issue.item_key,
|
||||
title: info.title ?? existingMeta.title ?? null,
|
||||
year: info.year,
|
||||
genres: info.genre,
|
||||
metadata: JSON.stringify(mergedMeta),
|
||||
})
|
||||
|
||||
// Process tags
|
||||
for (const tagName of info.tags) {
|
||||
const mappedTagId = mappings.get(tagName)
|
||||
if (mappedTagId) {
|
||||
// Mapping exists — assign the real tag
|
||||
addMediaTag.run(issue.item_key, mappedTagId)
|
||||
} else {
|
||||
// No mapping — create imported tag
|
||||
const importedTagId = crypto.randomUUID()
|
||||
const row = upsertImportedTag.get({
|
||||
id: importedTagId,
|
||||
library_id: library.id,
|
||||
name: tagName,
|
||||
}) as { id: string }
|
||||
addItemImportedTag.run(issue.item_key, row.id)
|
||||
}
|
||||
}
|
||||
|
||||
importedCount++
|
||||
}
|
||||
})()
|
||||
|
||||
console.log(`[comic-metadata] Imported metadata for ${importedCount}/${issues.length} issues in "${library.name}"`)
|
||||
}
|
||||
|
||||
// ─── Imported Tags ────────────────────────────────────────────────────────────
|
||||
|
||||
export function getImportedTagsForLibrary(libraryId: string): ImportedTag[] {
|
||||
const db = getDb()
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT it.id, it.library_id as libraryId, it.name,
|
||||
COUNT(iit.item_key) as itemCount
|
||||
FROM imported_tags it
|
||||
LEFT JOIN item_imported_tags iit ON iit.imported_tag_id = it.id
|
||||
WHERE it.library_id = ?
|
||||
GROUP BY it.id
|
||||
ORDER BY it.name`
|
||||
)
|
||||
.all(libraryId) as ImportedTag[]
|
||||
}
|
||||
|
||||
// ─── Tag Mappings ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function getTagMappingsForLibrary(libraryId: string): TagMapping[] {
|
||||
const db = getDb()
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT tm.id, tm.library_id as libraryId, tm.imported_tag_name as importedTagName,
|
||||
tm.tag_id as tagId, t.name as tagName, tc.name as categoryName
|
||||
FROM tag_mappings tm
|
||||
JOIN tags t ON t.id = tm.tag_id
|
||||
JOIN tag_categories tc ON tc.id = t.category_id
|
||||
WHERE tm.library_id = ?
|
||||
ORDER BY tm.imported_tag_name`
|
||||
)
|
||||
.all(libraryId) as TagMapping[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tag mapping and apply it: assign the real tag to all items that
|
||||
* currently have the imported tag, then remove the imported tag entries.
|
||||
*/
|
||||
export function createTagMapping(libraryId: string, importedTagName: string, tagId: string): TagMapping {
|
||||
const db = getDb()
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
return db.transaction(() => {
|
||||
// Persist the mapping for future scans
|
||||
db.prepare(`
|
||||
INSERT INTO tag_mappings (id, library_id, imported_tag_name, tag_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(library_id, imported_tag_name) DO UPDATE SET tag_id = excluded.tag_id
|
||||
`).run(id, libraryId, importedTagName, tagId)
|
||||
|
||||
// Find all items that currently have this imported tag
|
||||
const importedTag = db
|
||||
.prepare('SELECT id FROM imported_tags WHERE library_id = ? AND name = ?')
|
||||
.get(libraryId, importedTagName) as { id: string } | undefined
|
||||
|
||||
if (importedTag) {
|
||||
const itemKeys = db
|
||||
.prepare('SELECT item_key FROM item_imported_tags WHERE imported_tag_id = ?')
|
||||
.all(importedTag.id) as { item_key: string }[]
|
||||
|
||||
// Assign the real tag to all affected items
|
||||
const addMediaTag = db.prepare(
|
||||
'INSERT OR IGNORE INTO media_tags (item_key, tag_id) VALUES (?, ?)'
|
||||
)
|
||||
for (const { item_key } of itemKeys) {
|
||||
addMediaTag.run(item_key, tagId)
|
||||
}
|
||||
|
||||
// Remove the imported tag (cascades to item_imported_tags)
|
||||
db.prepare('DELETE FROM imported_tags WHERE id = ?').run(importedTag.id)
|
||||
}
|
||||
|
||||
// Fetch the created mapping with joined names
|
||||
const mapping = db
|
||||
.prepare(
|
||||
`SELECT tm.id, tm.library_id as libraryId, tm.imported_tag_name as importedTagName,
|
||||
tm.tag_id as tagId, t.name as tagName, tc.name as categoryName
|
||||
FROM tag_mappings tm
|
||||
JOIN tags t ON t.id = tm.tag_id
|
||||
JOIN tag_categories tc ON tc.id = t.category_id
|
||||
WHERE tm.library_id = ? AND tm.imported_tag_name = ?`
|
||||
)
|
||||
.get(libraryId, importedTagName) as TagMapping
|
||||
|
||||
return mapping
|
||||
})()
|
||||
}
|
||||
|
||||
export function deleteTagMapping(id: string): void {
|
||||
const db = getDb()
|
||||
const result = db.prepare('DELETE FROM tag_mappings WHERE id = ?').run(id)
|
||||
if (result.changes === 0) throw new Error('Mapping not found')
|
||||
}
|
||||
Reference in New Issue
Block a user