From 6c6a35433cfeb9186afca4e543271c560fe9c9a8 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:00:10 -0400 Subject: [PATCH] tag mapping improvements --- .../api/libraries/[id]/bulk-rename/route.ts | 70 ++++ src/app/manage/page.tsx | 215 ++++++++++++ src/app/manage/tags/mappings/[id]/page.tsx | 314 +++++++++++++++++- src/components/comics/ComicIssueView.tsx | 50 ++- 4 files changed, 642 insertions(+), 7 deletions(-) create mode 100644 src/app/api/libraries/[id]/bulk-rename/route.ts 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/manage/page.tsx b/src/app/manage/page.tsx index fa2508a..ae8b5b4 100644 --- a/src/app/manage/page.tsx +++ b/src/app/manage/page.tsx @@ -108,6 +108,7 @@ function LibraryRow({ 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) @@ -211,6 +212,7 @@ 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 index d66a462..a266800 100644 --- a/src/app/manage/tags/mappings/[id]/page.tsx +++ b/src/app/manage/tags/mappings/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState, useRef } from 'react' +import { useEffect, useState, useRef, useCallback } from 'react' import { useParams } from 'next/navigation' import type { Tag, TagCategory, ImportedTag, TagMapping, Library } from '@/types' @@ -14,6 +14,34 @@ export default function TagMappingsPage() { 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([ @@ -44,6 +72,9 @@ export default function TagMappingsPage() { 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 (
@@ -70,27 +101,49 @@ export default function TagMappingsPage() { ) : ( <> + +
- {importedTags.length === 0 ? ( + {visibleTags.length === 0 ? (

- No unmapped imported tags. All tags have been mapped or no ComicInfo.xml tags were found. + {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.'}

) : (
- {importedTags.map((it) => ( + {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 ? (

@@ -110,6 +163,159 @@ export default function TagMappingsPage() { ) } +// ─── 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({ @@ -117,13 +323,17 @@ function ImportedTagRow({ libraryId, tagsByCategory, categories, + prefixMappings, onMapped, + onIgnore, }: { importedTag: ImportedTag libraryId: string tagsByCategory: { category: TagCategory; tags: Tag[] }[] categories: TagCategory[] + prefixMappings: Record onMapped: () => void + onIgnore: () => void }) { const [selectedTagId, setSelectedTagId] = useState('') const [saving, setSaving] = useState(false) @@ -133,6 +343,24 @@ function ImportedTagRow({ const [newTagCategoryId, setNewTagCategoryId] = useState('') const [creatingTag, setCreatingTag] = useState(false) + 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) @@ -252,7 +480,7 @@ function ImportedTagRow({ + + ) : ( <> @@ -335,6 +578,67 @@ function ImportedTagRow({ ) } +// ─── 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 }) { 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} /> )}