add tag imports
This commit is contained in:
16
src/app/api/imported-tags/route.ts
Normal file
16
src/app/api/imported-tags/route.ts
Normal file
@@ -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)
|
||||
}
|
||||
34
src/app/api/libraries/[id]/import-metadata/route.ts
Normal file
34
src/app/api/libraries/[id]/import-metadata/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
21
src/app/api/tag-mappings/[id]/route.ts
Normal file
21
src/app/api/tag-mappings/[id]/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
}
|
||||
44
src/app/api/tag-mappings/route.ts
Normal file
44
src/app/api/tag-mappings/route.ts
Normal file
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,7 @@ 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 cancelRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -209,6 +210,30 @@ function LibraryRow({
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{library.type === 'comics' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setImporting('running')
|
||||
fetch(`/api/libraries/${encodeURIComponent(library.id)}/import-metadata`, { method: 'POST' })
|
||||
.then(() => {
|
||||
setImporting('done')
|
||||
setTimeout(() => setImporting('idle'), 3000)
|
||||
})
|
||||
.catch(() => setImporting('idle'))
|
||||
}}
|
||||
disabled={importing === 'running'}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (importing === 'idle') (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (importing === 'idle') (e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
{importing === 'running' ? 'Importing…' : importing === 'done' ? 'Imported ✓' : 'Import Metadata'}
|
||||
</button>
|
||||
)}
|
||||
{library.coverExt && (
|
||||
<button
|
||||
onClick={handleRemoveCover}
|
||||
|
||||
447
src/app/manage/tags/mappings/[id]/page.tsx
Normal file
447
src/app/manage/tags/mappings/[id]/page.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useRef } 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<Library | null>(null)
|
||||
const [importedTags, setImportedTags] = useState<ImportedTag[]>([])
|
||||
const [mappings, setMappings] = useState<TagMapping[]>([])
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
const [categories, setCategories] = useState<TagCategory[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
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)
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<a
|
||||
href="/manage/tags"
|
||||
className="text-sm no-underline transition-colors"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||||
>
|
||||
← Tags
|
||||
</a>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
Tag Mappings{library ? ` — ${library.name}` : ''}
|
||||
</h1>
|
||||
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
|
||||
Map imported tags from ComicInfo.xml files to your tag categories.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<Section title="Unmapped Tags">
|
||||
<LoadingRows />
|
||||
</Section>
|
||||
) : (
|
||||
<>
|
||||
<Section title="Unmapped Tags">
|
||||
{importedTags.length === 0 ? (
|
||||
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
|
||||
No unmapped imported tags. All tags have been mapped or no ComicInfo.xml tags were found.
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||
{importedTags.map((it) => (
|
||||
<ImportedTagRow
|
||||
key={it.id}
|
||||
importedTag={it}
|
||||
libraryId={libraryId}
|
||||
tagsByCategory={tagsByCategory}
|
||||
categories={categories}
|
||||
onMapped={refresh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Saved Mappings">
|
||||
{mappings.length === 0 ? (
|
||||
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
|
||||
No saved mappings yet. Map imported tags above to create persistent mappings.
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||
{mappings.map((m) => (
|
||||
<MappingRow key={m.id} mapping={m} onDeleted={refresh} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Imported Tag Row ─────────────────────────────────────────────────────────
|
||||
|
||||
function ImportedTagRow({
|
||||
importedTag,
|
||||
libraryId,
|
||||
tagsByCategory,
|
||||
categories,
|
||||
onMapped,
|
||||
}: {
|
||||
importedTag: ImportedTag
|
||||
libraryId: string
|
||||
tagsByCategory: { category: TagCategory; tags: Tag[] }[]
|
||||
categories: TagCategory[]
|
||||
onMapped: () => void
|
||||
}) {
|
||||
const [selectedTagId, setSelectedTagId] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newTagName, setNewTagName] = useState(importedTag.name)
|
||||
const [newTagCategoryId, setNewTagCategoryId] = useState('')
|
||||
const [creatingTag, setCreatingTag] = useState(false)
|
||||
|
||||
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 (
|
||||
<div className="py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Left: imported tag name + item count */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{importedTag.name}
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
({importedTag.itemCount})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!creating ? (
|
||||
<>
|
||||
{/* Right: tag picker + map button + new button */}
|
||||
<select
|
||||
value={selectedTagId}
|
||||
onChange={(e) => setSelectedTagId(e.target.value)}
|
||||
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)',
|
||||
minWidth: 160,
|
||||
}}
|
||||
>
|
||||
<option value="">Select tag…</option>
|
||||
{tagsByCategory.map((group) => (
|
||||
<optgroup key={group.category.id} label={group.category.name}>
|
||||
{group.tags.map((tag) => (
|
||||
<option key={tag.id} value={tag.id}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={handleMap}
|
||||
disabled={!selectedTagId || saving}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
>
|
||||
{saving ? 'Mapping…' : 'Map'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={(e) => {
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
}}
|
||||
title="Create a new tag and map it"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Inline create: category picker + name input + create & map button */}
|
||||
<select
|
||||
value={newTagCategoryId}
|
||||
onChange={(e) => setNewTagCategoryId(e.target.value)}
|
||||
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)',
|
||||
minWidth: 120,
|
||||
}}
|
||||
>
|
||||
<option value="">Category…</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={newTagName}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleCreateAndMap}
|
||||
disabled={!newTagName.trim() || !newTagCategoryId || creatingTag}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
>
|
||||
{creatingTag ? 'Creating…' : 'Create & Map'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setCreating(false)}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs mt-1.5 px-3 py-1 rounded-lg" style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Mapping Row ──────────────────────────────────────────────────────────────
|
||||
|
||||
function MappingRow({ mapping, onDeleted }: { mapping: TagMapping; onDeleted: () => void }) {
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const cancelRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<div className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<span
|
||||
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{mapping.importedTagName}
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>→</span>
|
||||
<span
|
||||
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
>
|
||||
{mapping.categoryName}: {mapping.tagName}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{confirming && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (cancelRef.current) clearTimeout(cancelRef.current)
|
||||
setConfirming(false)
|
||||
}}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
disabled={deleting}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: confirming ? '#7f1d1d' : 'var(--border)',
|
||||
color: confirming ? '#fca5a5' : 'var(--text-secondary)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!confirming) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d'
|
||||
;(e.currentTarget as HTMLElement).style.color = '#fca5a5'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!confirming) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deleting ? 'Deleting…' : confirming ? 'Confirm?' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<h2
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div
|
||||
className="rounded-xl border"
|
||||
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||
>
|
||||
<div className="px-5 py-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingRows() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{[70, 50, 85].map((w) => (
|
||||
<div key={w} className="flex items-center gap-3">
|
||||
<div
|
||||
className="h-4 rounded animate-pulse"
|
||||
style={{ width: `${w}%`, backgroundColor: 'var(--border)' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
<Section title="Add a Category">
|
||||
<AddCategoryForm onAdded={refresh} />
|
||||
</Section>
|
||||
|
||||
<ImportedTagMappingsSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -480,6 +482,80 @@ function AddCategoryForm({ onAdded }: { onAdded: () => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Imported Tag Mappings Section ────────────────────────────────────────────
|
||||
|
||||
function ImportedTagMappingsSection() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [tagCounts, setTagCounts] = useState<Record<string, number>>({})
|
||||
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<string, number> = {}
|
||||
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 (
|
||||
<Section title="Imported Tag Mappings">
|
||||
<LoadingRows />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
if (libraries.length === 0) {
|
||||
return (
|
||||
<Section title="Imported Tag Mappings">
|
||||
<p className="text-sm py-4" style={{ color: 'var(--text-secondary)' }}>
|
||||
No comic libraries configured. Add a comic library to import tags from ComicInfo.xml files.
|
||||
</p>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Imported Tag Mappings">
|
||||
<div className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||
{libraries.map((lib) => (
|
||||
<div key={lib.id} className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
|
||||
<span className="flex-1 font-medium text-sm" style={{ color: 'var(--text-primary)' }}>
|
||||
{lib.name}
|
||||
<span className="ml-2 font-normal text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{tagCounts[lib.id] ?? 0} imported tag{(tagCounts[lib.id] ?? 0) === 1 ? '' : 's'}
|
||||
</span>
|
||||
</span>
|
||||
<a
|
||||
href={`/manage/tags/mappings/${encodeURIComponent(lib.id)}`}
|
||||
className="text-xs px-2.5 py-1.5 rounded-lg transition-colors no-underline"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
|
||||
>
|
||||
Manage Mappings
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
|
||||
72
src/lib/comic-info.ts
Normal file
72
src/lib/comic-info.ts
Normal file
@@ -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<string, unknown>
|
||||
try {
|
||||
doc = parser.parse(xml) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
// The root element can be ComicInfo or ComicInfoXml (varies by source)
|
||||
const info = (doc.ComicInfo ?? doc.ComicInfoXml ?? doc.comicinfo) as Record<string, unknown> | 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),
|
||||
}
|
||||
}
|
||||
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')
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user