diff --git a/src/app/api/imported-tags/route.ts b/src/app/api/imported-tags/route.ts new file mode 100644 index 0000000..87b833d --- /dev/null +++ b/src/app/api/imported-tags/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getImportedTagsForLibrary } from '@/lib/comic-metadata' +import { requireAdmin } from '@/lib/auth' + +export async function GET(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const libraryId = request.nextUrl.searchParams.get('libraryId') + if (!libraryId) { + return NextResponse.json({ error: 'libraryId is required' }, { status: 400 }) + } + + const tags = getImportedTagsForLibrary(libraryId) + return NextResponse.json(tags) +} diff --git a/src/app/api/libraries/[id]/bulk-rename/route.ts b/src/app/api/libraries/[id]/bulk-rename/route.ts new file mode 100644 index 0000000..08f7f42 --- /dev/null +++ b/src/app/api/libraries/[id]/bulk-rename/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAdmin } from '@/lib/auth' +import { getLibrary } from '@/lib/libraries' +import { getDb } from '@/lib/db' + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const { id: libraryId } = await params + const library = getLibrary(libraryId) + if (!library) { + return NextResponse.json({ error: 'Library not found' }, { status: 404 }) + } + if (library.type !== 'comics') { + return NextResponse.json({ error: 'Only comics libraries support bulk rename' }, { status: 400 }) + } + + const body = await request.json() + const { pattern, preview } = body as { pattern: string; preview?: boolean } + + if (!pattern || typeof pattern !== 'string') { + return NextResponse.json({ error: 'Pattern is required' }, { status: 400 }) + } + + // Validate regex + let regex: RegExp + try { + regex = new RegExp(pattern, 'g') + } catch { + return NextResponse.json({ error: 'Invalid regex pattern' }, { status: 400 }) + } + + const db = getDb() + + const rows = db + .prepare( + `SELECT item_key, title FROM media_items + WHERE library_id = ? AND item_type IN ('comic_series', 'comic_issue')` + ) + .all(libraryId) as { item_key: string; title: string }[] + + const changes: { itemKey: string; oldTitle: string; newTitle: string }[] = [] + + for (const row of rows) { + // Reset lastIndex since we reuse the regex with 'g' flag + regex.lastIndex = 0 + const newTitle = row.title.replace(regex, '').trim() + if (newTitle && newTitle !== row.title) { + changes.push({ itemKey: row.item_key, oldTitle: row.title, newTitle }) + } + } + + if (preview) { + return NextResponse.json({ changes }) + } + + // Apply + const stmt = db.prepare('UPDATE media_items SET title = ? WHERE item_key = ?') + db.transaction(() => { + for (const c of changes) { + stmt.run(c.newTitle, c.itemKey) + } + })() + + return NextResponse.json({ updated: changes.length }) +} diff --git a/src/app/api/libraries/[id]/import-metadata/route.ts b/src/app/api/libraries/[id]/import-metadata/route.ts new file mode 100644 index 0000000..47d8784 --- /dev/null +++ b/src/app/api/libraries/[id]/import-metadata/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getLibrary } from '@/lib/libraries' +import { importComicMetadata } from '@/lib/comic-metadata' +import { requireAdmin } from '@/lib/auth' + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const { id } = await params + + const library = getLibrary(id) + if (!library) { + return NextResponse.json({ error: 'Library not found' }, { status: 404 }) + } + + if (library.type !== 'comics') { + return NextResponse.json({ error: 'Metadata import is only supported for comic libraries' }, { status: 400 }) + } + + // Fire-and-forget + void Promise.resolve().then(() => { + try { + importComicMetadata(library) + } catch (err) { + console.error(`[import-metadata] Error importing metadata for "${library.name}":`, err) + } + }) + + return new NextResponse(null, { status: 202 }) +} diff --git a/src/app/api/tag-mappings/[id]/route.ts b/src/app/api/tag-mappings/[id]/route.ts new file mode 100644 index 0000000..230e699 --- /dev/null +++ b/src/app/api/tag-mappings/[id]/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server' +import { deleteTagMapping } from '@/lib/comic-metadata' +import { requireAdmin } from '@/lib/auth' + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const { id } = await params + + try { + deleteTagMapping(id) + return new NextResponse(null, { status: 204 }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete mapping' + return NextResponse.json({ error: message }, { status: 404 }) + } +} diff --git a/src/app/api/tag-mappings/route.ts b/src/app/api/tag-mappings/route.ts new file mode 100644 index 0000000..38b37cd --- /dev/null +++ b/src/app/api/tag-mappings/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getTagMappingsForLibrary, createTagMapping } from '@/lib/comic-metadata' +import { requireAdmin } from '@/lib/auth' + +export async function GET(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const libraryId = request.nextUrl.searchParams.get('libraryId') + if (!libraryId) { + return NextResponse.json({ error: 'libraryId is required' }, { status: 400 }) + } + + const mappings = getTagMappingsForLibrary(libraryId) + return NextResponse.json(mappings) +} + +export async function POST(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + let body: { libraryId?: string; importedTagName?: string; tagId?: string } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const { libraryId, importedTagName, tagId } = body + if (!libraryId || !importedTagName || !tagId) { + return NextResponse.json( + { error: 'libraryId, importedTagName, and tagId are required' }, + { status: 400 } + ) + } + + try { + const mapping = createTagMapping(libraryId, importedTagName, tagId) + return NextResponse.json(mapping, { status: 201 }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create mapping' + return NextResponse.json({ error: message }, { status: 400 }) + } +} diff --git a/src/app/manage/page.tsx b/src/app/manage/page.tsx index f6d26ba..ae8b5b4 100644 --- a/src/app/manage/page.tsx +++ b/src/app/manage/page.tsx @@ -107,6 +107,8 @@ function LibraryRow({ const [confirming, setConfirming] = useState(false) const [removing, setRemoving] = useState(false) const [uploadingCover, setUploadingCover] = useState(false) + const [importing, setImporting] = useState<'idle' | 'running' | 'done'>('idle') + const [showBulkRename, setShowBulkRename] = useState(false) const cancelRef = useRef | null>(null) const fileInputRef = useRef(null) @@ -209,6 +211,41 @@ function LibraryRow({ {/* Actions */}
+ {library.type === 'comics' && ( + <> + + + + )} {library.coverExt && (
+ + {showBulkRename && ( + setShowBulkRename(false)} /> + )} + + ) +} + +// ─── Bulk Rename Modal ──────────────────────────────────────────────────────── + +function BulkRenameModal({ libraryId, onClose }: { libraryId: string; onClose: () => void }) { + const [pattern, setPattern] = useState('') + const [preview, setPreview] = useState<{ itemKey: string; oldTitle: string; newTitle: string }[] | null>(null) + const [loading, setLoading] = useState(false) + const [applying, setApplying] = useState(false) + const [error, setError] = useState(null) + const [result, setResult] = useState(null) + + const handlePreview = async () => { + if (!pattern.trim()) return + setError(null) + setPreview(null) + setResult(null) + setLoading(true) + try { + const res = await fetch(`/api/libraries/${encodeURIComponent(libraryId)}/bulk-rename`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pattern: pattern.trim(), preview: true }), + }) + const data = await res.json() + if (!res.ok) { + setError(data.error ?? 'Failed to preview') + } else { + setPreview(data.changes ?? []) + } + } catch { + setError('Network error') + } finally { + setLoading(false) + } + } + + const handleApply = async () => { + if (!pattern.trim()) return + setError(null) + setApplying(true) + try { + const res = await fetch(`/api/libraries/${encodeURIComponent(libraryId)}/bulk-rename`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pattern: pattern.trim() }), + }) + const data = await res.json() + if (!res.ok) { + setError(data.error ?? 'Failed to apply') + } else { + setResult(`Updated ${data.updated} title${data.updated === 1 ? '' : 's'}`) + setPreview(null) + } + } catch { + setError('Network error') + } finally { + setApplying(false) + } + } + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+

Bulk Rename

+

+ Enter a regex pattern to remove from comic titles +

+
+ +
+ + {/* Body */} +
+ {/* Pattern input */} +
+ setPattern(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handlePreview() }} + placeholder="e.g. \[English\]|\{doujin-moe\.us\}" + className="flex-1 rounded-lg px-3 py-2 text-sm outline-none font-mono" + style={{ + backgroundColor: 'var(--background)', + border: '1px solid var(--border)', + color: 'var(--text-primary)', + }} + autoFocus + /> + +
+ + {error && ( +

+ {error} +

+ )} + + {result && ( +

+ {result} +

+ )} + + {/* Preview list */} + {preview !== null && ( + preview.length === 0 ? ( +

+ No titles match this pattern. +

+ ) : ( +
+

+ {preview.length} title{preview.length === 1 ? '' : 's'} will be updated: +

+
+ {preview.map((c) => ( +
+

+ {c.oldTitle} +

+

+ {c.newTitle} +

+
+ ))} +
+
+ ) + )} +
+ + {/* Footer */} + {preview && preview.length > 0 && ( +
+ + +
+ )} +
) } diff --git a/src/app/manage/tags/mappings/[id]/page.tsx b/src/app/manage/tags/mappings/[id]/page.tsx new file mode 100644 index 0000000..aaf0f4c --- /dev/null +++ b/src/app/manage/tags/mappings/[id]/page.tsx @@ -0,0 +1,769 @@ +'use client' + +import { useEffect, useMemo, useState, useRef, useCallback } from 'react' +import { useParams } from 'next/navigation' +import type { Tag, TagCategory, ImportedTag, TagMapping, Library } from '@/types' + +export default function TagMappingsPage() { + const params = useParams() + const libraryId = params.id as string + + const [library, setLibrary] = useState(null) + const [importedTags, setImportedTags] = useState([]) + const [mappings, setMappings] = useState([]) + const [tags, setTags] = useState([]) + const [categories, setCategories] = useState([]) + const [loading, setLoading] = useState(true) + const [prefixMappings, setPrefixMappings] = useState>({}) + const [ignoredTags, setIgnoredTags] = useState>(new Set()) + + // Load prefix mappings and ignored tags from localStorage on mount + useEffect(() => { + try { + const stored = localStorage.getItem(`prefix-mappings-${libraryId}`) + if (stored) setPrefixMappings(JSON.parse(stored)) + } catch { /* ignore */ } + try { + const stored = localStorage.getItem(`ignored-tags-${libraryId}`) + if (stored) setIgnoredTags(new Set(JSON.parse(stored))) + } catch { /* ignore */ } + }, [libraryId]) + + const updatePrefixMappings = useCallback((next: Record) => { + setPrefixMappings(next) + try { + localStorage.setItem(`prefix-mappings-${libraryId}`, JSON.stringify(next)) + } catch { /* ignore */ } + }, [libraryId]) + + const updateIgnoredTags = useCallback((next: Set) => { + setIgnoredTags(next) + try { + localStorage.setItem(`ignored-tags-${libraryId}`, JSON.stringify([...next])) + } catch { /* ignore */ } + }, [libraryId]) + + const refresh = () => { + Promise.all([ + fetch(`/api/imported-tags?libraryId=${encodeURIComponent(libraryId)}`).then((r) => r.json()), + fetch(`/api/tag-mappings?libraryId=${encodeURIComponent(libraryId)}`).then((r) => r.json()), + fetch('/api/tags/items').then((r) => r.json()), + fetch('/api/tags/categories').then((r) => r.json()), + fetch('/api/libraries').then((r) => r.json()), + ]) + .then(([imported, maps, tgs, cats, libs]: [ImportedTag[], TagMapping[], Tag[], TagCategory[], Library[]]) => { + setImportedTags(imported) + setMappings(maps) + setTags(tgs) + setCategories(cats) + setLibrary(libs.find((l) => l.id === libraryId) ?? null) + setLoading(false) + }) + .catch(() => setLoading(false)) + } + + useEffect(() => { + refresh() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [libraryId]) + + const tagsByCategory = categories.map((cat) => ({ + category: cat, + tags: tags.filter((t) => t.categoryId === cat.id), + })).filter((g) => g.tags.length > 0) + + const visibleTags = importedTags.filter((t) => !ignoredTags.has(t.name)) + const hiddenTags = importedTags.filter((t) => ignoredTags.has(t.name)) + + return ( +
+ +

+ Tag Mappings{library ? ` — ${library.name}` : ''} +

+

+ Map imported tags from ComicInfo.xml files to your tag categories. +

+ + {loading ? ( +
+ +
+ ) : ( + <> + + +
+ {visibleTags.length === 0 ? ( +

+ {importedTags.length === 0 + ? 'No unmapped imported tags. All tags have been mapped or no ComicInfo.xml tags were found.' + : 'All unmapped tags are hidden. Check the ignored tags section below.'} +

+ ) : ( +
+ {visibleTags.map((it) => ( + updateIgnoredTags(new Set([...ignoredTags, it.name]))} + /> + ))} +
+ )} +
+ + {hiddenTags.length > 0 && ( + { + const next = new Set(ignoredTags) + next.delete(name) + updateIgnoredTags(next) + }} + /> + )} + +
+ {mappings.length === 0 ? ( +

+ No saved mappings yet. Map imported tags above to create persistent mappings. +

+ ) : ( +
+ {mappings.map((m) => ( + + ))} +
+ )} +
+ + )} +
+ ) +} + +// ─── Prefix Mappings Section ────────────────────────────────────────────────── + +function PrefixMappingsSection({ + categories, + importedTags, + prefixMappings, + onUpdate, +}: { + categories: TagCategory[] + importedTags: ImportedTag[] + prefixMappings: Record + onUpdate: (next: Record) => void +}) { + const [newPrefix, setNewPrefix] = useState('') + const [newCategoryId, setNewCategoryId] = useState('') + + // Detect prefixes from imported tags that aren't yet mapped + const detectedPrefixes = Array.from( + new Set( + importedTags + .map((t) => { + const idx = t.name.indexOf(': ') + return idx > 0 ? t.name.slice(0, idx).trim().toLowerCase() : null + }) + .filter((p): p is string => p !== null) + ) + ).filter((p) => !(p in prefixMappings)).sort() + + const catMap = new Map(categories.map((c) => [c.id, c.name])) + const entries = Object.entries(prefixMappings) + + const handleAdd = () => { + const key = newPrefix.trim().toLowerCase() + if (!key || !newCategoryId) return + onUpdate({ ...prefixMappings, [key]: newCategoryId }) + setNewPrefix('') + setNewCategoryId('') + } + + const handleRemove = (key: string) => { + const next = { ...prefixMappings } + delete next[key] + onUpdate(next) + } + + return ( +
+

+ Map tag prefixes (e.g. "language" in "language: english") to categories. + When creating a new tag, the category and name will auto-fill. +

+ + {/* Existing mappings */} + {entries.length > 0 && ( +
+ {entries.map(([prefix, catId]) => ( +
+ + {prefix}: + + + + {catMap.get(catId) ?? catId} + +
+ +
+ ))} +
+ )} + + {/* Add row */} +
+ setNewPrefix(e.target.value)} + placeholder="prefix" + className="rounded-lg px-2 py-1.5 text-xs font-mono outline-none" + style={{ + backgroundColor: 'var(--background)', + border: '1px solid var(--border)', + color: 'var(--text-primary)', + width: 100, + }} + onKeyDown={(e) => { if (e.key === 'Enter') handleAdd() }} + /> + + + +
+ + {/* Suggestions */} + {detectedPrefixes.length > 0 && ( +
+ Detected: + {detectedPrefixes.map((p) => ( + + ))} +
+ )} +
+ ) +} + +// ─── Imported Tag Row ───────────────────────────────────────────────────────── + +function ImportedTagRow({ + importedTag, + libraryId, + tagsByCategory, + categories, + prefixMappings, + onMapped, + onIgnore, +}: { + importedTag: ImportedTag + libraryId: string + tagsByCategory: { category: TagCategory; tags: Tag[] }[] + categories: TagCategory[] + prefixMappings: Record + onMapped: () => void + onIgnore: () => void +}) { + // Auto-match: if prefix mapping exists, find a tag in that category matching the stripped name + const autoMatchedTagId = useMemo(() => { + const colonIdx = importedTag.name.indexOf(': ') + if (colonIdx <= 0) return '' + const prefix = importedTag.name.slice(0, colonIdx).trim().toLowerCase() + const mappedCategoryId = prefixMappings[prefix] + if (!mappedCategoryId) return '' + const strippedName = importedTag.name.slice(colonIdx + 2).trim().toLowerCase() + const group = tagsByCategory.find((g) => g.category.id === mappedCategoryId) + const match = group?.tags.find((t) => t.name.toLowerCase() === strippedName) + return match?.id ?? '' + }, [importedTag.name, prefixMappings, tagsByCategory]) + + const [selectedTagId, setSelectedTagId] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [creating, setCreating] = useState(false) + const [newTagName, setNewTagName] = useState(importedTag.name) + const [newTagCategoryId, setNewTagCategoryId] = useState('') + const [creatingTag, setCreatingTag] = useState(false) + + // Apply auto-match when it changes (e.g. prefix mappings updated) + useEffect(() => { + if (autoMatchedTagId) setSelectedTagId(autoMatchedTagId) + }, [autoMatchedTagId]) + + const startCreating = () => { + // Apply prefix mapping defaults if the imported tag has a colon prefix + const colonIdx = importedTag.name.indexOf(': ') + if (colonIdx > 0) { + const prefix = importedTag.name.slice(0, colonIdx).trim().toLowerCase() + const mappedCategoryId = prefixMappings[prefix] + if (mappedCategoryId) { + setNewTagCategoryId(mappedCategoryId) + setNewTagName(importedTag.name.slice(colonIdx + 2).trim()) + setCreating(true) + return + } + } + setNewTagName(importedTag.name) + setNewTagCategoryId('') + setCreating(true) + } + + const handleMap = async () => { + if (!selectedTagId) return + setError(null) + setSaving(true) + try { + const res = await fetch('/api/tag-mappings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + libraryId, + importedTagName: importedTag.name, + tagId: selectedTagId, + }), + }) + if (!res.ok) { + const data = await res.json() + setError(data.error ?? 'Failed to save mapping') + setSaving(false) + return + } + onMapped() + } catch { + setError('Network error') + setSaving(false) + } + } + + const handleCreateAndMap = async () => { + if (!newTagName.trim() || !newTagCategoryId) return + setError(null) + setCreatingTag(true) + try { + const createRes = await fetch('/api/tags/items', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newTagName.trim(), categoryId: newTagCategoryId }), + }) + if (!createRes.ok) { + const data = await createRes.json() + setError(data.error ?? 'Failed to create tag') + setCreatingTag(false) + return + } + const newTag = await createRes.json() + + const mapRes = await fetch('/api/tag-mappings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + libraryId, + importedTagName: importedTag.name, + tagId: newTag.id, + }), + }) + if (!mapRes.ok) { + const data = await mapRes.json() + setError(data.error ?? 'Failed to save mapping') + setCreatingTag(false) + return + } + onMapped() + } catch { + setError('Network error') + setCreatingTag(false) + } + } + + return ( +
+
+ {/* Left: imported tag name + item count */} +
+ + {importedTag.name} + + ({importedTag.itemCount}) + + +
+ + {!creating ? ( + <> + {/* Right: tag picker + map button + new button */} + + + + + + + + + ) : ( + <> + {/* Inline create: category picker + name input + create & map button */} + + + setNewTagName(e.target.value)} + placeholder="Tag name" + className="rounded-lg px-2 py-1.5 text-xs outline-none" + style={{ + backgroundColor: 'var(--background)', + border: '1px solid var(--border)', + color: 'var(--text-primary)', + width: 120, + }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreateAndMap() + if (e.key === 'Escape') setCreating(false) + }} + autoFocus + /> + + + + + + )} +
+ {error && ( +

+ {error} +

+ )} +
+ ) +} + +// ─── Ignored Tags Section ───────────────────────────────────────────────────── + +function IgnoredTagsSection({ + tags, + onUnignore, +}: { + tags: ImportedTag[] + onUnignore: (name: string) => void +}) { + const [expanded, setExpanded] = useState(false) + + return ( +
+ + {expanded && ( +
+
+
+ {tags.map((t) => ( +
+ + {t.name} + ({t.itemCount}) + +
+ +
+ ))} +
+
+
+ )} +
+ ) +} + +// ─── Mapping Row ────────────────────────────────────────────────────────────── + +function MappingRow({ mapping, onDeleted }: { mapping: TagMapping; onDeleted: () => void }) { + const [confirming, setConfirming] = useState(false) + const [deleting, setDeleting] = useState(false) + const cancelRef = useRef | null>(null) + + const handleDeleteClick = () => { + if (!confirming) { + setConfirming(true) + cancelRef.current = setTimeout(() => setConfirming(false), 4000) + return + } + if (cancelRef.current) clearTimeout(cancelRef.current) + setDeleting(true) + fetch(`/api/tag-mappings/${encodeURIComponent(mapping.id)}`, { method: 'DELETE' }) + .then(() => onDeleted()) + .catch(() => setDeleting(false)) + } + + return ( +
+ + {mapping.importedTagName} + + + + {mapping.categoryName}: {mapping.tagName} + +
+ {confirming && ( + + )} + +
+ ) +} + +// ─── Shared helpers ─────────────────────────────────────────────────────────── + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+
+
{children}
+
+
+ ) +} + +function LoadingRows() { + return ( +
+ {[70, 50, 85].map((w) => ( +
+
+
+ ))} +
+ ) +} diff --git a/src/app/manage/tags/page.tsx b/src/app/manage/tags/page.tsx index 2f23f7b..875a34a 100644 --- a/src/app/manage/tags/page.tsx +++ b/src/app/manage/tags/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useState, useRef } from 'react' -import type { Tag, TagCategory } from '@/types' +import type { Tag, TagCategory, Library, ImportedTag } from '@/types' // ─── Main Page ──────────────────────────────────────────────────────────────── @@ -62,6 +62,8 @@ export default function ManageTagsPage() {
+ +
) } @@ -480,6 +482,80 @@ function AddCategoryForm({ onAdded }: { onAdded: () => void }) { ) } +// ─── Imported Tag Mappings Section ──────────────────────────────────────────── + +function ImportedTagMappingsSection() { + const [libraries, setLibraries] = useState([]) + const [tagCounts, setTagCounts] = useState>({}) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetch('/api/libraries') + .then((r) => r.json()) + .then(async (libs: Library[]) => { + const comicLibs = libs.filter((l) => l.type === 'comics') + setLibraries(comicLibs) + + const counts: Record = {} + await Promise.all( + comicLibs.map(async (lib) => { + const tags: ImportedTag[] = await fetch( + `/api/imported-tags?libraryId=${encodeURIComponent(lib.id)}` + ).then((r) => r.json()) + counts[lib.id] = tags.length + }) + ) + setTagCounts(counts) + setLoading(false) + }) + .catch(() => setLoading(false)) + }, []) + + if (loading) { + return ( +
+ +
+ ) + } + + if (libraries.length === 0) { + return ( +
+

+ No comic libraries configured. Add a comic library to import tags from ComicInfo.xml files. +

+
+ ) + } + + return ( +
+
+ {libraries.map((lib) => ( + + ))} +
+
+ ) +} + // ─── Shared helpers ─────────────────────────────────────────────────────────── function Section({ title, children }: { title: string; children: React.ReactNode }) { diff --git a/src/components/comics/ComicIssueView.tsx b/src/components/comics/ComicIssueView.tsx index 4232ded..9e6dadf 100644 --- a/src/components/comics/ComicIssueView.tsx +++ b/src/components/comics/ComicIssueView.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react' import type { ComicIssue } from '@/types' import ImageLightbox from '@/components/mixed/ImageLightbox' import MediaTagPanel from '@/components/tags/MediaTagPanel' +import AssignedTagBadges from '@/components/tags/AssignedTagBadges' function fileApiUrl(libraryId: string, relativePath: string): string { return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}` @@ -24,6 +25,7 @@ function pageUrl(libraryId: string, issueKey: string, pageIndex: number): string export default function ComicIssueView({ libraryId, issue, onClose, onTagsChanged, readOnly }: Props) { const [lightboxPage, setLightboxPage] = useState(null) const [showTagPanel, setShowTagPanel] = useState(false) + const [tagRefreshKey, setTagRefreshKey] = useState(0) const issueKey = issue.item_key ?? `${libraryId}:comic_issue:${issue.id}` // Close on Escape @@ -110,6 +112,50 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange
+ {/* Cover + tags */} +
+
+ {issue.coverUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {issue.title} + ) : pageCount > 0 ? ( + // eslint-disable-next-line @next/next/no-img-element + {issue.title} + ) : ( +
+ 📖 +
+ )} +
+
+

+ Tags +

+ {issue.item_key ? ( + + ) : ( +

No tags

+ )} +
+
+ {/* Page grid */}
{pageCount === 0 ? ( @@ -155,7 +201,7 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange itemKey={issueKey} onHide={() => setShowTagPanel(false)} onClose={onClose} - onTagsChanged={onTagsChanged} + onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }} readOnly={readOnly} /> )} @@ -170,7 +216,7 @@ export default function ComicIssueView({ libraryId, issue, onClose, onTagsChange onPrev={lightboxPage > 0 ? () => setLightboxPage((p) => (p ?? 1) - 1) : undefined} onNext={lightboxPage < pageCount - 1 ? () => setLightboxPage((p) => (p ?? 0) + 1) : undefined} itemKey={issueKey} - onTagsChanged={onTagsChanged} + onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }} readOnly={readOnly} /> )} diff --git a/src/lib/comic-info.ts b/src/lib/comic-info.ts new file mode 100644 index 0000000..e2f358f --- /dev/null +++ b/src/lib/comic-info.ts @@ -0,0 +1,72 @@ +import AdmZip from 'adm-zip' +import { XMLParser } from 'fast-xml-parser' +import type { ComicInfoData } from '@/types' + +const parser = new XMLParser() + +function toNumber(val: unknown): number | null { + if (val === undefined || val === null || val === '') return null + const n = Number(val) + return isNaN(n) ? null : n +} + +function toString(val: unknown): string | null { + if (val === undefined || val === null || val === '') return null + return String(val) +} + +/** + * Parse ComicInfo.xml from inside a CBZ archive. + * Returns null if the archive doesn't contain ComicInfo.xml or parsing fails. + */ +export function parseComicInfo(absoluteCbzPath: string): ComicInfoData | null { + let zip: AdmZip + try { + zip = new AdmZip(absoluteCbzPath) + } catch { + return null + } + + // Find ComicInfo.xml (case-insensitive) + const entry = zip.getEntries().find( + (e) => !e.isDirectory && e.entryName.toLowerCase() === 'comicinfo.xml' + ) + if (!entry) return null + + let xml: string + try { + xml = entry.getData().toString('utf-8') + } catch { + return null + } + + let doc: Record + try { + doc = parser.parse(xml) as Record + } catch { + return null + } + + // The root element can be ComicInfo or ComicInfoXml (varies by source) + const info = (doc.ComicInfo ?? doc.ComicInfoXml ?? doc.comicinfo) as Record | undefined + if (!info) return null + + // Parse tags: comma-separated string + const rawTags = toString(info.Tags) + const tags: string[] = rawTags + ? rawTags.split(',').map((t) => t.trim()).filter(Boolean) + : [] + + return { + title: toString(info.Title), + year: toNumber(info.Year), + month: toNumber(info.Month), + day: toNumber(info.Day), + writer: toString(info.Writer), + translator: toString(info.Translator), + publisher: toString(info.Publisher), + genre: toString(info.Genre), + tags, + web: toString(info.Web), + } +} diff --git a/src/lib/comic-metadata.ts b/src/lib/comic-metadata.ts new file mode 100644 index 0000000..8aa3f1a --- /dev/null +++ b/src/lib/comic-metadata.ts @@ -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') +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 195b0f8..ca4f516 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -109,6 +109,7 @@ function initDb(db: Database.Database): void { migrateLibraryPermissionsAccessLevel(db) migrateLibrariesAddComics(db) migrateComicItemTypes(db) + migrateImportedTags(db) seedAppSettings(db) } @@ -421,3 +422,28 @@ function migrateAiJobs(db: Database.Database): void { db.exec('ALTER TABLE ai_jobs ADD COLUMN payload TEXT') } } + +function migrateImportedTags(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS imported_tags ( + id TEXT PRIMARY KEY, + library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, + name TEXT NOT NULL, + UNIQUE(library_id, name) + ); + + CREATE TABLE IF NOT EXISTS item_imported_tags ( + item_key TEXT NOT NULL, + imported_tag_id TEXT NOT NULL REFERENCES imported_tags(id) ON DELETE CASCADE, + PRIMARY KEY (item_key, imported_tag_id) + ); + + CREATE TABLE IF NOT EXISTS tag_mappings ( + id TEXT PRIMARY KEY, + library_id TEXT NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, + imported_tag_name TEXT NOT NULL, + tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + UNIQUE(library_id, imported_tag_name) + ); + `) +} diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index 810742b..71c411c 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -12,6 +12,7 @@ import { getThumbnailPath, getCbzThumbnailPath } from './thumbnails' import { computeFingerprint } from './fingerprint' import { reKeyMediaItem } from './tags' import { runAiTagging } from './ai-tagger' +import { importComicMetadata } from './comic-metadata' let scanRunning = false @@ -651,6 +652,13 @@ async function scanComics(library: Library, libraryRoot: string): Promise } console.log(`[scanner] comics: indexed ${items.filter((i) => 'issues' in i).length} series, ${issueCount} issues`) + + // Import ComicInfo.xml metadata (title, year, genres, tags) + try { + importComicMetadata(library) + } catch (err) { + console.error(`[scanner] Error importing comic metadata for "${library.name}":`, err) + } } // --------------------------------------------------------------------------- diff --git a/src/types/index.ts b/src/types/index.ts index 3b1a9b5..7ea3bb4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -146,3 +146,32 @@ export interface UserSettings { tvLoop: boolean tvMuted: boolean } + +export interface ComicInfoData { + title: string | null + year: number | null + month: number | null + day: number | null + writer: string | null + translator: string | null + publisher: string | null + genre: string | null + tags: string[] + web: string | null +} + +export interface ImportedTag { + id: string + libraryId: string + name: string + itemCount: number +} + +export interface TagMapping { + id: string + libraryId: string + importedTagName: string + tagId: string + tagName?: string + categoryName?: string +}