feat(books): rename tags to genres and add user tags

This commit is contained in:
neon
2026-02-19 05:08:21 -06:00
parent 1c97403379
commit 3eff3616fc
49 changed files with 583 additions and 792 deletions
@@ -101,7 +101,7 @@ export function useOidc() {
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})) const err = await res.json().catch(() => ({}))
throw new Error((err as Record<string, unknown>).message as string ?? 'OIDC login failed') throw new Error(((err as Record<string, unknown>).message as string) ?? 'OIDC login failed')
} }
return res.json() return res.json()
@@ -42,7 +42,7 @@ const metaLine = computed(() => {
return parts.length > 0 ? parts.join(' · ') : null return parts.length > 0 ? parts.join(' · ') : null
}) })
const visibleTags = computed(() => props.book.tags.slice(0, 2)) const visibleTags = computed(() => props.book.genres.slice(0, 2))
const coverLoaded = ref(false) const coverLoaded = ref(false)
const coverFailed = ref(false) const coverFailed = ref(false)
@@ -155,10 +155,10 @@ function editMetadata() {
{{ detail.publisher }} {{ detail.publisher }}
</p> </p>
<!-- Tags --> <!-- Genres -->
<div v-if="detail.tags.length" class="flex flex-wrap gap-1.5"> <div v-if="detail.genres.length" class="flex flex-wrap gap-1.5">
<span v-for="tag in detail.tags" :key="tag" class="text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary"> <span v-for="genre in detail.genres" :key="genre" class="text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{{ tag }} {{ genre }}
</span> </span>
</div> </div>
@@ -38,6 +38,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
seriesName: null, seriesName: null,
seriesIndex: null, seriesIndex: null,
authors: [], authors: [],
genres: [],
tags: [], tags: [],
files: [], files: [],
...overrides, ...overrides,
@@ -70,20 +70,14 @@ onUnmounted(() => clearTimeout(debounceTimer))
<!-- Lightbox --> <!-- Lightbox -->
<Teleport to="body"> <Teleport to="body">
<div <div v-if="lightboxOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" @click="lightboxOpen = false">
v-if="lightboxOpen" <button
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" class="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
@click="lightboxOpen = false" @click="lightboxOpen = false"
> >
<button class="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors" @click="lightboxOpen = false">
<X class="size-5" /> <X class="size-5" />
</button> </button>
<img <img :src="activeSrc" :alt="book.title ?? ''" class="max-h-[90vh] max-w-[90vw] rounded-xl shadow-2xl object-contain" @click.stop />
:src="activeSrc"
:alt="book.title ?? ''"
class="max-h-[90vh] max-w-[90vw] rounded-xl shadow-2xl object-contain"
@click.stop
/>
</div> </div>
</Teleport> </Teleport>
@@ -188,10 +188,10 @@ function downloadFile() {
</div> </div>
</dl> </dl>
<!-- Tags --> <!-- Genres -->
<div v-if="book.tags.length" class="flex flex-wrap gap-1.5 mt-5"> <div v-if="book.genres.length" class="flex flex-wrap gap-1.5 mt-5">
<span v-for="tag in book.tags" :key="tag" class="text-xs px-2.5 py-0.5 rounded-full border border-primary/30 text-primary/80"> <span v-for="genre in book.genres" :key="genre" class="text-xs px-2.5 py-0.5 rounded-full border border-primary/30 text-primary/80">
{{ tag }} {{ genre }}
</span> </span>
</div> </div>
@@ -8,14 +8,14 @@ import MetadataSearchDrawer from './MetadataSearchDrawer.vue'
import type { MetadataPatch } from '../../../composables/useMetadataDiff' import type { MetadataPatch } from '../../../composables/useMetadataDiff'
import { useMetadataEditor } from '../../../composables/useMetadataEditor' import { useMetadataEditor } from '../../../composables/useMetadataEditor'
import { useAuthorSearch } from '../../../composables/useAuthorSearch' import { useAuthorSearch } from '../../../composables/useAuthorSearch'
import { useTagSearch } from '../../../composables/useTagSearch' import { useGenreSearch } from '../../../composables/useTagSearch'
const props = defineProps<{ book: BookDetail }>() const props = defineProps<{ book: BookDetail }>()
const emit = defineEmits<{ saved: [BookDetail]; coverChanged: ['extracted' | 'custom' | null] }>() const emit = defineEmits<{ saved: [BookDetail]; coverChanged: ['extracted' | 'custom' | null] }>()
const { form, saving, error, isDirty, load, reset, save } = useMetadataEditor() const { form, saving, error, isDirty, load, reset, save } = useMetadataEditor()
const { search: searchAuthors } = useAuthorSearch() const { search: searchAuthors } = useAuthorSearch()
const { search: searchTags } = useTagSearch() const { search: searchGenres } = useGenreSearch()
const coverPanel = ref<InstanceType<typeof CoverEditorPanel> | null>(null) const coverPanel = ref<InstanceType<typeof CoverEditorPanel> | null>(null)
const searchOpen = ref(false) const searchOpen = ref(false)
@@ -30,20 +30,29 @@ const providerIdFields = [
function setIntField(field: 'publishedYear' | 'pageCount', e: Event) { function setIntField(field: 'publishedYear' | 'pageCount', e: Event) {
const val = (e.target as HTMLInputElement).value const val = (e.target as HTMLInputElement).value
if (val === '') { form[field] = null; return } if (val === '') {
form[field] = null
return
}
const n = parseInt(val, 10) const n = parseInt(val, 10)
form[field] = isNaN(n) ? null : n form[field] = isNaN(n) ? null : n
} }
function setFloatField(field: 'seriesIndex', e: Event) { function setFloatField(field: 'seriesIndex', e: Event) {
const val = (e.target as HTMLInputElement).value const val = (e.target as HTMLInputElement).value
if (val === '') { form[field] = null; return } if (val === '') {
form[field] = null
return
}
const n = parseFloat(val) const n = parseFloat(val)
form[field] = isNaN(n) ? null : n form[field] = isNaN(n) ? null : n
} }
onMounted(() => load(props.book)) onMounted(() => load(props.book))
watch(() => props.book.id, () => load(props.book)) watch(
() => props.book.id,
() => load(props.book),
)
async function submit() { async function submit() {
if (coverPanel.value?.hasPending) { if (coverPanel.value?.hasPending) {
@@ -125,15 +134,15 @@ function handleApply({ formPatch, coverUrl }: { formPatch: MetadataPatch; coverU
/> />
</div> </div>
<!-- Authors + Tags --> <!-- Authors + Genres -->
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div class="space-y-1.5"> <div class="space-y-1.5">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Authors</label> <label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Authors</label>
<ChipInput v-model="form.authors" placeholder="Add author..." :search-fn="searchAuthors" /> <ChipInput v-model="form.authors" placeholder="Add author..." :search-fn="searchAuthors" />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Tags</label> <label class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Genres</label>
<ChipInput v-model="form.tags" placeholder="Add tag..." :search-fn="searchTags" /> <ChipInput v-model="form.genres" placeholder="Add genre..." :search-fn="searchGenres" />
</div> </div>
</div> </div>
@@ -281,10 +290,5 @@ function handleApply({ formPatch, coverUrl }: { formPatch: MetadataPatch; coverU
</div> </div>
</div> </div>
<MetadataSearchDrawer <MetadataSearchDrawer v-if="searchOpen" :book="props.book" @close="searchOpen = false" @apply="handleApply" />
v-if="searchOpen"
:book="props.book"
@close="searchOpen = false"
@apply="handleApply"
/>
</template> </template>
@@ -19,10 +19,7 @@ const emit = defineEmits<{
const providerLabel = computed(() => getProviderLabel(props.candidate.provider, props.providers)) const providerLabel = computed(() => getProviderLabel(props.candidate.provider, props.providers))
const { fields, toggleField, copyAll, copyMissing, buildPatch, hasCopied } = useMetadataDiff( const { fields, toggleField, copyAll, copyMissing, buildPatch, hasCopied } = useMetadataDiff(props.book, props.candidate)
props.book,
props.candidate,
)
function apply() { function apply() {
emit('apply', buildPatch()) emit('apply', buildPatch())
@@ -43,10 +40,7 @@ function apply() {
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm font-semibold truncate leading-snug">{{ candidate.title }}</p> <p class="text-sm font-semibold truncate leading-snug">{{ candidate.title }}</p>
<div class="flex items-center gap-1.5 mt-0.5"> <div class="flex items-center gap-1.5 mt-0.5">
<span <span class="text-[10px] font-semibold px-2 py-0.5 rounded-full" :style="providerBadgeStyle(candidate.provider)">
class="text-[10px] font-semibold px-2 py-0.5 rounded-full"
:style="providerBadgeStyle(candidate.provider)"
>
{{ providerLabel }} {{ providerLabel }}
</span> </span>
</div> </div>
@@ -84,15 +78,8 @@ function apply() {
<!-- Diff rows --> <!-- Diff rows -->
<div class="flex-1 overflow-y-auto px-4"> <div class="flex-1 overflow-y-auto px-4">
<MetadataDiffRow <MetadataDiffRow v-for="field in fields" :key="field.key" :field="field" @toggle="toggleField" />
v-for="field in fields" <p v-if="fields.length === 0" class="py-8 text-center text-sm text-muted-foreground">No metadata available from this source.</p>
:key="field.key"
:field="field"
@toggle="toggleField"
/>
<p v-if="fields.length === 0" class="py-8 text-center text-sm text-muted-foreground">
No metadata available from this source.
</p>
</div> </div>
<!-- Footer --> <!-- Footer -->
@@ -15,17 +15,19 @@ const lightboxSrc = ref<string | null>(null)
<div v-if="field.isCover" class="py-3.5 border-b border-border/40"> <div v-if="field.isCover" class="py-3.5 border-b border-border/40">
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground mb-2.5">{{ field.label }}</p> <p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground mb-2.5">{{ field.label }}</p>
<div class="grid grid-cols-[1fr_44px_1fr] gap-2 items-center"> <div class="grid grid-cols-[1fr_44px_1fr] gap-2 items-center">
<!-- Current cover --> <!-- Current cover -->
<div <div
class="w-16 rounded-lg overflow-hidden bg-muted transition-all duration-300 shadow-sm ring-1 relative group" class="w-16 rounded-lg overflow-hidden bg-muted transition-all duration-300 shadow-sm ring-1 relative group"
:class="field.isCopied ? 'ring-primary ring-2 cursor-zoom-in' : 'ring-border opacity-50'" :class="field.isCopied ? 'ring-primary ring-2 cursor-zoom-in' : 'ring-border opacity-50'"
style="aspect-ratio: 2/3" style="aspect-ratio: 2/3"
@click="field.isCopied ? lightboxSrc = field.candidateDisplay : null" @click="field.isCopied ? (lightboxSrc = field.candidateDisplay) : null"
> >
<img v-if="field.isCopied" :src="field.candidateDisplay" alt="Preview" class="w-full h-full object-cover" @error="hideOnError" /> <img v-if="field.isCopied" :src="field.candidateDisplay" alt="Preview" class="w-full h-full object-cover" @error="hideOnError" />
<div v-else class="w-full h-full bg-linear-to-br from-muted to-muted-foreground/10" /> <div v-else class="w-full h-full bg-linear-to-br from-muted to-muted-foreground/10" />
<div v-if="field.isCopied" class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"> <div
v-if="field.isCopied"
class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<ZoomIn class="size-4 text-white" /> <ZoomIn class="size-4 text-white" />
</div> </div>
</div> </div>
@@ -34,7 +36,11 @@ const lightboxSrc = ref<string | null>(null)
<div class="flex justify-center"> <div class="flex justify-center">
<button <button
class="size-8 rounded-full flex items-center justify-center transition-all active:scale-90 shadow-sm" class="size-8 rounded-full flex items-center justify-center transition-all active:scale-90 shadow-sm"
:class="field.isCopied ? 'bg-primary text-primary-foreground shadow-primary/30' : 'bg-card border border-border text-muted-foreground hover:border-primary/40 hover:text-primary hover:bg-primary/5'" :class="
field.isCopied
? 'bg-primary text-primary-foreground shadow-primary/30'
: 'bg-card border border-border text-muted-foreground hover:border-primary/40 hover:text-primary hover:bg-primary/5'
"
@click="$emit('toggle', field.key)" @click="$emit('toggle', field.key)"
> >
<RotateCcw v-if="field.isCopied" class="size-3.5" /> <RotateCcw v-if="field.isCopied" class="size-3.5" />
@@ -62,23 +68,13 @@ const lightboxSrc = ref<string | null>(null)
<p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground mb-1.5">{{ field.label }}</p> <p class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground mb-1.5">{{ field.label }}</p>
<div class="flex flex-col gap-2 sm:grid sm:grid-cols-[1fr_44px_1fr] sm:gap-1.5 sm:items-stretch"> <div class="flex flex-col gap-2 sm:grid sm:grid-cols-[1fr_44px_1fr] sm:gap-1.5 sm:items-stretch">
<!-- Current value: framed card to signal it's the active record --> <!-- Current value: framed card to signal it's the active record -->
<div <div
class="min-w-0 rounded-lg px-3 py-2 transition-all duration-200" class="min-w-0 rounded-lg px-3 py-2 transition-all duration-200"
:class=" :class="!field.hasDiff ? 'bg-muted/30 opacity-50' : field.isCopied ? 'bg-muted/30 opacity-40' : 'bg-background ring-1 ring-border'"
!field.hasDiff
? 'bg-muted/30 opacity-50'
: field.isCopied
? 'bg-muted/30 opacity-40'
: 'bg-background ring-1 ring-border'
"
> >
<p class="text-[10px] font-medium text-muted-foreground mb-0.5 sm:hidden">Current</p> <p class="text-[10px] font-medium text-muted-foreground mb-0.5 sm:hidden">Current</p>
<p <p class="wrap-break-word leading-snug text-sm w-full" :class="!field.currentDisplay ? 'text-muted-foreground/40 italic' : 'text-foreground'">
class="wrap-break-word leading-snug text-sm w-full"
:class="!field.currentDisplay ? 'text-muted-foreground/40 italic' : 'text-foreground'"
>
{{ field.currentDisplay || 'empty' }} {{ field.currentDisplay || 'empty' }}
</p> </p>
</div> </div>
@@ -88,7 +84,11 @@ const lightboxSrc = ref<string | null>(null)
<button <button
v-if="field.hasDiff" v-if="field.hasDiff"
class="size-8 rounded-full flex items-center justify-center transition-all active:scale-90 shadow-sm shrink-0" class="size-8 rounded-full flex items-center justify-center transition-all active:scale-90 shadow-sm shrink-0"
:class="field.isCopied ? 'bg-primary text-primary-foreground shadow-primary/30' : 'bg-card border border-border text-muted-foreground hover:border-primary/40 hover:text-primary hover:bg-primary/5'" :class="
field.isCopied
? 'bg-primary text-primary-foreground shadow-primary/30'
: 'bg-card border border-border text-muted-foreground hover:border-primary/40 hover:text-primary hover:bg-primary/5'
"
@click="$emit('toggle', field.key)" @click="$emit('toggle', field.key)"
> >
<RotateCcw v-if="field.isCopied" class="size-3.5" /> <RotateCcw v-if="field.isCopied" class="size-3.5" />
@@ -102,33 +102,19 @@ const lightboxSrc = ref<string | null>(null)
<!-- New value: muted by default, highlights when selected --> <!-- New value: muted by default, highlights when selected -->
<div <div
class="min-w-0 rounded-lg px-3 py-2 transition-all duration-200" class="min-w-0 rounded-lg px-3 py-2 transition-all duration-200"
:class=" :class="!field.hasDiff ? 'bg-muted/30 opacity-50' : field.isCopied ? 'bg-primary/8 ring-1 ring-primary/20' : 'bg-muted/40'"
!field.hasDiff
? 'bg-muted/30 opacity-50'
: field.isCopied
? 'bg-primary/8 ring-1 ring-primary/20'
: 'bg-muted/40'
"
> >
<p class="text-[10px] font-medium text-muted-foreground mb-0.5 sm:hidden">New</p> <p class="text-[10px] font-medium text-muted-foreground mb-0.5 sm:hidden">New</p>
<p <p class="wrap-break-word leading-snug text-sm w-full" :class="field.isCopied ? 'text-primary font-medium' : 'text-muted-foreground'">
class="wrap-break-word leading-snug text-sm w-full"
:class="field.isCopied ? 'text-primary font-medium' : 'text-muted-foreground'"
>
{{ field.candidateDisplay }} {{ field.candidateDisplay }}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<!-- Lightbox --> <!-- Lightbox -->
<Teleport to="body"> <Teleport to="body">
<div <div v-if="lightboxSrc" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm" @click="lightboxSrc = null">
v-if="lightboxSrc"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm"
@click="lightboxSrc = null"
>
<button <button
class="absolute top-4 right-4 size-9 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center transition-colors" class="absolute top-4 right-4 size-9 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center transition-colors"
@click="lightboxSrc = null" @click="lightboxSrc = null"
@@ -40,10 +40,7 @@ const providerLabel = computed(() => getProviderLabel(props.candidate.provider,
{{ candidate.authors.join(', ') }} {{ candidate.authors.join(', ') }}
</span> </span>
<span class="flex items-center gap-1.5 flex-wrap"> <span class="flex items-center gap-1.5 flex-wrap">
<span <span class="inline-flex items-center text-[10px] font-bold px-1.5 py-0.5 rounded-md" :style="providerBadgeStyle(candidate.provider)">
class="inline-flex items-center text-[10px] font-bold px-1.5 py-0.5 rounded-md"
:style="providerBadgeStyle(candidate.provider)"
>
{{ providerLabel }} {{ providerLabel }}
</span> </span>
<span v-if="candidate.publishedYear" class="text-[10px] text-muted-foreground tabular-nums">{{ candidate.publishedYear }}</span> <span v-if="candidate.publishedYear" class="text-[10px] text-muted-foreground tabular-nums">{{ candidate.publishedYear }}</span>
@@ -3,7 +3,7 @@ import { computed, reactive } from 'vue'
import { Search, BookOpen, Loader2 } from 'lucide-vue-next' import { Search, BookOpen, Loader2 } from 'lucide-vue-next'
import type { BookDetail, MetadataCandidate, MetadataProviderInfo, MetadataProviderKey } from '@projectx/types' import type { BookDetail, MetadataCandidate, MetadataProviderInfo, MetadataProviderKey } from '@projectx/types'
import MetadataResultCard from './MetadataResultCard.vue' import MetadataResultCard from './MetadataResultCard.vue'
import { providerBadgeStyle, providerActivePillStyle } from '../../../lib/metadata-fetch' import { providerActivePillStyle } from '../../../lib/metadata-fetch'
const props = defineProps<{ const props = defineProps<{
book: BookDetail book: BookDetail
@@ -95,8 +95,12 @@ function runSearch() {
v-for="p in providers" v-for="p in providers"
:key="p.key" :key="p.key"
class="h-6 px-2.5 rounded-full text-xs font-medium transition-all flex items-center gap-1 active:scale-95" class="h-6 px-2.5 rounded-full text-xs font-medium transition-all flex items-center gap-1 active:scale-95"
:class="(selectedProviders.includes(p.key) || !selectedProviders.length) ? '' : 'bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/80'" :class="
:style="(selectedProviders.includes(p.key) || !selectedProviders.length) ? providerActivePillStyle(p.key) : {}" selectedProviders.includes(p.key) || !selectedProviders.length
? ''
: 'bg-muted text-muted-foreground hover:text-foreground hover:bg-muted/80'
"
:style="selectedProviders.includes(p.key) || !selectedProviders.length ? providerActivePillStyle(p.key) : {}"
@click="$emit('toggleProvider', p.key)" @click="$emit('toggleProvider', p.key)"
> >
{{ p.label }} {{ p.label }}
@@ -114,11 +118,7 @@ function runSearch() {
<div class="flex-1 overflow-y-auto px-4 pb-4"> <div class="flex-1 overflow-y-auto px-4 pb-4">
<!-- Skeleton grid while loading with no results yet --> <!-- Skeleton grid while loading with no results yet -->
<div v-if="isStreaming && !filteredResults.length" class="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div v-if="isStreaming && !filteredResults.length" class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div <div v-for="n in 8" :key="n" class="rounded-xl border border-border/40 bg-card overflow-hidden animate-pulse flex gap-3 p-2.5">
v-for="n in 8"
:key="n"
class="rounded-xl border border-border/40 bg-card overflow-hidden animate-pulse flex gap-3 p-2.5"
>
<div class="rounded-lg bg-muted shrink-0" style="width: 64px; aspect-ratio: 2/3" /> <div class="rounded-lg bg-muted shrink-0" style="width: 64px; aspect-ratio: 2/3" />
<div class="flex-1 flex flex-col justify-center gap-2 py-1"> <div class="flex-1 flex flex-col justify-center gap-2 py-1">
<div class="h-3 bg-muted rounded-md w-full" /> <div class="h-3 bg-muted rounded-md w-full" />
@@ -93,4 +93,3 @@ describe('onBookMissing', () => {
cleanup() cleanup()
}) })
}) })
@@ -14,7 +14,7 @@ export type DiffFieldKey =
| 'seriesIndex' | 'seriesIndex'
| 'isbn13' | 'isbn13'
| 'isbn10' | 'isbn10'
| 'tags' | 'genres'
| 'coverUrl' | 'coverUrl'
export interface DiffField { export interface DiffField {
@@ -41,7 +41,7 @@ export interface MetadataPatch {
isbn10?: string | null isbn10?: string | null
isbn13?: string | null isbn13?: string | null
authors?: string[] authors?: string[]
tags?: string[] genres?: string[]
googleBooksId?: string | null googleBooksId?: string | null
goodreadsId?: string | null goodreadsId?: string | null
amazonId?: string | null amazonId?: string | null
@@ -63,7 +63,7 @@ const FIELD_DEFS: { key: DiffFieldKey; label: string }[] = [
{ key: 'seriesIndex', label: 'Series Index' }, { key: 'seriesIndex', label: 'Series Index' },
{ key: 'isbn13', label: 'ISBN-13' }, { key: 'isbn13', label: 'ISBN-13' },
{ key: 'isbn10', label: 'ISBN-10' }, { key: 'isbn10', label: 'ISBN-10' },
{ key: 'tags', label: 'Tags' }, { key: 'genres', label: 'Genres' },
] ]
const PROVIDER_ID_FIELD: Record<MetadataProviderKey, keyof MetadataPatch | undefined> = { const PROVIDER_ID_FIELD: Record<MetadataProviderKey, keyof MetadataPatch | undefined> = {
@@ -79,7 +79,7 @@ export function useMetadataDiff(book: BookDetail, candidate: MetadataCandidate)
function getBookValue(key: DiffFieldKey): string { function getBookValue(key: DiffFieldKey): string {
if (key === 'authors') return book.authors.map((a) => a.name).join(', ') if (key === 'authors') return book.authors.map((a) => a.name).join(', ')
if (key === 'tags') return book.tags.join(', ') if (key === 'genres') return book.genres.join(', ')
if (key === 'coverUrl') return '' if (key === 'coverUrl') return ''
const val = book[key as keyof BookDetail] const val = book[key as keyof BookDetail]
return val != null ? String(val) : '' return val != null ? String(val) : ''
@@ -88,7 +88,7 @@ export function useMetadataDiff(book: BookDetail, candidate: MetadataCandidate)
function getCandidateValue(key: DiffFieldKey): string { function getCandidateValue(key: DiffFieldKey): string {
if (key === 'coverUrl') return candidate.coverUrl ?? '' if (key === 'coverUrl') return candidate.coverUrl ?? ''
if (key === 'authors') return (candidate.authors ?? []).join(', ') if (key === 'authors') return (candidate.authors ?? []).join(', ')
if (key === 'tags') return (candidate.tags ?? []).join(', ') if (key === 'genres') return (candidate.genres ?? []).join(', ')
const val = candidate[key as keyof MetadataCandidate] const val = candidate[key as keyof MetadataCandidate]
return val != null ? String(val) : '' return val != null ? String(val) : ''
} }
@@ -99,16 +99,18 @@ export function useMetadataDiff(book: BookDetail, candidate: MetadataCandidate)
if (!candidateVal) return [] if (!candidateVal) return []
const bookVal = getBookValue(def.key) const bookVal = getBookValue(def.key)
const isCopied = copiedFields.has(def.key) const isCopied = copiedFields.has(def.key)
return [{ return [
key: def.key, {
label: def.label, key: def.key,
bookValue: bookVal, label: def.label,
currentDisplay: isCopied ? candidateVal : bookVal, bookValue: bookVal,
candidateDisplay: candidateVal, currentDisplay: isCopied ? candidateVal : bookVal,
hasDiff: bookVal !== candidateVal, candidateDisplay: candidateVal,
isCopied, hasDiff: bookVal !== candidateVal,
isCover: def.key === 'coverUrl', isCopied,
}] isCover: def.key === 'coverUrl',
},
]
}), }),
) )
@@ -132,12 +134,30 @@ export function useMetadataDiff(book: BookDetail, candidate: MetadataCandidate)
let coverUrl: string | undefined let coverUrl: string | undefined
for (const key of copiedFields) { for (const key of copiedFields) {
if (key === 'coverUrl') { coverUrl = candidate.coverUrl; continue } if (key === 'coverUrl') {
if (key === 'authors') { formPatch.authors = candidate.authors ?? []; continue } coverUrl = candidate.coverUrl
if (key === 'tags') { formPatch.tags = candidate.tags ?? []; continue } continue
if (key === 'publishedYear') { formPatch.publishedYear = candidate.publishedYear ?? null; continue } }
if (key === 'pageCount') { formPatch.pageCount = candidate.pageCount ?? null; continue } if (key === 'authors') {
if (key === 'seriesIndex') { formPatch.seriesIndex = candidate.seriesIndex ?? null; continue } formPatch.authors = candidate.authors ?? []
continue
}
if (key === 'genres') {
formPatch.genres = candidate.genres ?? []
continue
}
if (key === 'publishedYear') {
formPatch.publishedYear = candidate.publishedYear ?? null
continue
}
if (key === 'pageCount') {
formPatch.pageCount = candidate.pageCount ?? null
continue
}
if (key === 'seriesIndex') {
formPatch.seriesIndex = candidate.seriesIndex ?? null
continue
}
const val = candidate[key as keyof MetadataCandidate] const val = candidate[key as keyof MetadataCandidate]
;(formPatch as Record<string, unknown>)[key] = val != null ? String(val) : null ;(formPatch as Record<string, unknown>)[key] = val != null ? String(val) : null
} }
@@ -20,6 +20,7 @@ export function useMetadataEditor() {
isbn13: null as string | null, isbn13: null as string | null,
rating: null as number | null, rating: null as number | null,
authors: [] as string[], authors: [] as string[],
genres: [] as string[],
tags: [] as string[], tags: [] as string[],
googleBooksId: null as string | null, googleBooksId: null as string | null,
goodreadsId: null as string | null, goodreadsId: null as string | null,
@@ -46,6 +47,7 @@ export function useMetadataEditor() {
form.isbn13 = book.isbn13 form.isbn13 = book.isbn13
form.rating = book.rating ?? null form.rating = book.rating ?? null
form.authors = book.authors.map((a) => a.name) form.authors = book.authors.map((a) => a.name)
form.genres = [...book.genres]
form.tags = [...book.tags] form.tags = [...book.tags]
form.googleBooksId = book.providerIds.google ?? null form.googleBooksId = book.providerIds.google ?? null
form.goodreadsId = book.providerIds.goodreads ?? null form.goodreadsId = book.providerIds.goodreads ?? null
@@ -83,31 +83,29 @@ export function useMetadataSearch() {
} }
} }
const PROVIDER_ORDER: MetadataProviderKey[] = ['amazon', 'goodreads', 'hardcover', 'google', 'openLibrary'] const PROVIDER_ORDER: MetadataProviderKey[] = ['amazon', 'goodreads', 'hardcover', 'google', 'openLibrary']
function sortResults(list: MetadataCandidate[]): MetadataCandidate[] { function sortResults(list: MetadataCandidate[]): MetadataCandidate[] {
const byProvider = new Map<string, MetadataCandidate[]>() const byProvider = new Map<string, MetadataCandidate[]>()
for (const r of list) { for (const r of list) {
const bucket = byProvider.get(r.provider) ?? [] const bucket = byProvider.get(r.provider) ?? []
bucket.push(r) bucket.push(r)
byProvider.set(r.provider, bucket) byProvider.set(r.provider, bucket)
}
const orderedKeys = [
...PROVIDER_ORDER.filter((p) => byProvider.has(p)),
...[...byProvider.keys()].filter((p) => !PROVIDER_ORDER.includes(p as MetadataProviderKey)),
]
const out: MetadataCandidate[] = []
for (const p of orderedKeys) out.push(...(byProvider.get(p)?.slice(0, 2) ?? []))
for (const p of orderedKeys) out.push(...(byProvider.get(p)?.slice(2) ?? []))
return out
} }
const orderedKeys = [
...PROVIDER_ORDER.filter((p) => byProvider.has(p)),
...[...byProvider.keys()].filter((p) => !PROVIDER_ORDER.includes(p as MetadataProviderKey)),
]
const out: MetadataCandidate[] = []
for (const p of orderedKeys) out.push(...(byProvider.get(p)?.slice(0, 2) ?? []))
for (const p of orderedKeys) out.push(...(byProvider.get(p)?.slice(2) ?? []))
return out
}
const filteredResults = computed(() => { const filteredResults = computed(() => {
const filtered = selectedProviders.value.length const filtered = selectedProviders.value.length ? results.value.filter((r) => selectedProviders.value.includes(r.provider)) : results.value
? results.value.filter((r) => selectedProviders.value.includes(r.provider))
: results.value
return sortResults(filtered) return sortResults(filtered)
}) })
@@ -1,13 +1,15 @@
import { api } from '@/lib/api' import { api } from '@/lib/api'
export function useTagSearch() { function useMetadataSearch(endpoint: string) {
async function search(q: string): Promise<string[]> { async function search(q: string): Promise<string[]> {
if (!q.trim()) return [] if (!q.trim()) return []
const res = await api(`/api/metadata/tags?q=${encodeURIComponent(q)}`) const res = await api(`/api/metadata/${endpoint}?q=${encodeURIComponent(q)}`)
if (!res.ok) return [] if (!res.ok) return []
const data: { name: string }[] = await res.json() const data: { name: string }[] = await res.json()
return data.map((t) => t.name) return data.map((item) => item.name)
} }
return { search } return { search }
} }
export const useGenreSearch = () => useMetadataSearch('genres')
export const useTagSearch = () => useMetadataSearch('tags')
@@ -19,7 +19,7 @@ export const FIELD_LABELS: Record<RuleField, string> = {
publishedYear: 'Published Year', publishedYear: 'Published Year',
pageCount: 'Page Count', pageCount: 'Page Count',
author: 'Author', author: 'Author',
tag: 'Tag', genre: 'Genre',
format: 'Format', format: 'Format',
addedAt: 'Added Date', addedAt: 'Added Date',
fileAvailability: 'File Availability', fileAvailability: 'File Availability',
@@ -5,7 +5,7 @@ export function getProviderLabel(provider: MetadataProviderKey, providers: Metad
} }
export function hideOnError(e: Event): void { export function hideOnError(e: Event): void {
(e.target as HTMLImageElement).style.visibility = 'hidden' ;(e.target as HTMLImageElement).style.visibility = 'hidden'
} }
/** Brand colors for each provider - used for pill/badge tinting. */ /** Brand colors for each provider - used for pill/badge tinting. */
@@ -23,8 +23,8 @@ const TEMPLATES: { label: string; build: () => GroupRule }[] = [
build: () => ({ type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'format', operator: 'includesAny', value: ['pdf'] } as Rule] }), build: () => ({ type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'format', operator: 'includesAny', value: ['pdf'] } as Rule] }),
}, },
{ {
label: 'No Tags', label: 'No Genres',
build: () => ({ type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'tag', operator: 'isEmpty' } as Rule] }), build: () => ({ type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'genre', operator: 'isEmpty' } as Rule] }),
}, },
{ {
label: 'EPUB Only', label: 'EPUB Only',
@@ -63,7 +63,6 @@ async function revoke(userId: number) {
await loadAccess() await loadAccess()
} }
onMounted(loadAccess) onMounted(loadAccess)
</script> </script>
+1 -7
View File
@@ -345,13 +345,7 @@ function navigateSearch(cfiTarget: string) {
@search="onSearchQuery" @search="onSearchQuery"
@clear="onSearchClear" @clear="onSearchClear"
@navigate="navigateSearch($event)" @navigate="navigateSearch($event)"
@close=" @close="onSearchClear(); searchInitialQuery = ''; showSearch = false"
() => {
onSearchClear()
searchInitialQuery = ''
showSearch = false
}
"
/> />
<ReaderSettingsPanel v-if="showSettings" :state="state" @update="applyUpdate" @close="showSettings = false" /> <ReaderSettingsPanel v-if="showSettings" :state="state" @update="applyUpdate" @close="showSettings = false" />
@@ -59,7 +59,7 @@ async function save() {
}) })
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})) const err = await res.json().catch(() => ({}))
throw new Error((err as Record<string, unknown>).message as string ?? 'Failed to save') throw new Error(((err as Record<string, unknown>).message as string) ?? 'Failed to save')
} }
saveSuccess.value = true saveSuccess.value = true
setTimeout(() => { setTimeout(() => {
+5 -1
View File
@@ -36,7 +36,11 @@ const { items: books, total, loading, hasMore, load } = useCollectionBooks(colle
const { selectionMode, selectedIds, selectedCount, enterSelectionMode, exitSelectionMode, toggleBook, rangeSelectTo, isSelected } = useBookSelection() const { selectionMode, selectedIds, selectedCount, enterSelectionMode, exitSelectionMode, toggleBook, rangeSelectTo, isSelected } = useBookSelection()
function handleSelect(id: number, event: MouseEvent) { function handleSelect(id: number, event: MouseEvent) {
if (event.shiftKey) rangeSelectTo(id, books.value.map((b) => b.id)) if (event.shiftKey)
rangeSelectTo(
id,
books.value.map((b) => b.id),
)
else toggleBook(id) else toggleBook(id)
} }
+2
View File
@@ -16,6 +16,7 @@ export type BookCard = {
files: BookFileRef[]; files: BookFileRef[];
publishedYear: number | null; publishedYear: number | null;
language: string | null; language: string | null;
genres: string[];
tags: string[]; tags: string[];
rating: number | null; rating: number | null;
readingProgress: number | null; readingProgress: number | null;
@@ -54,6 +55,7 @@ export type BookDetail = {
coverSource: 'extracted' | 'custom' | null; coverSource: 'extracted' | 'custom' | null;
providerIds: ProviderIds; providerIds: ProviderIds;
authors: { id: number; name: string; sortName: string | null }[]; authors: { id: number; name: string; sortName: string | null }[];
genres: string[];
tags: string[]; tags: string[];
files: BookDetailFile[]; files: BookDetailFile[];
}; };
+1 -1
View File
@@ -23,7 +23,7 @@ export interface MetadataCandidate {
isbn13?: string; isbn13?: string;
seriesName?: string; seriesName?: string;
seriesIndex?: number; seriesIndex?: number;
tags?: string[]; genres?: string[];
coverUrl?: string; coverUrl?: string;
sourceUrl?: string; sourceUrl?: string;
} }
+2 -2
View File
@@ -7,7 +7,7 @@ export type RuleField =
| 'publishedYear' | 'publishedYear'
| 'pageCount' | 'pageCount'
| 'author' | 'author'
| 'tag' | 'genre'
| 'format' | 'format'
| 'addedAt' | 'addedAt'
| 'fileAvailability' | 'fileAvailability'
@@ -48,7 +48,7 @@ export const FIELD_OPERATORS: Record<RuleField, RuleOperator[]> = {
language: ['eq', 'notEq', 'isEmpty', 'isNotEmpty'], language: ['eq', 'notEq', 'isEmpty', 'isNotEmpty'],
series: ['contains', 'notContains', 'isEmpty', 'isNotEmpty'], series: ['contains', 'notContains', 'isEmpty', 'isNotEmpty'],
author: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'], author: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
tag: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'], genre: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
format: ['includesAny', 'excludesAll'], format: ['includesAny', 'excludesAll'],
publishedYear: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'], publishedYear: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
seriesIndex: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'], seriesIndex: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
@@ -183,6 +183,12 @@ CREATE TABLE "book_authors" (
CONSTRAINT "book_authors_book_id_author_id_pk" PRIMARY KEY("book_id","author_id") CONSTRAINT "book_authors_book_id_author_id_pk" PRIMARY KEY("book_id","author_id")
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE "book_genres" (
"book_id" integer NOT NULL,
"genre_id" integer NOT NULL,
CONSTRAINT "book_genres_book_id_genre_id_pk" PRIMARY KEY("book_id","genre_id")
);
--> statement-breakpoint
CREATE TABLE "book_metadata" ( CREATE TABLE "book_metadata" (
"book_id" integer PRIMARY KEY NOT NULL, "book_id" integer PRIMARY KEY NOT NULL,
"title" varchar(1000), "title" varchar(1000),
@@ -212,6 +218,12 @@ CREATE TABLE "book_tags" (
CONSTRAINT "book_tags_book_id_tag_id_pk" PRIMARY KEY("book_id","tag_id") CONSTRAINT "book_tags_book_id_tag_id_pk" PRIMARY KEY("book_id","tag_id")
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE "genres" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(200) NOT NULL,
CONSTRAINT "genres_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "tags" ( CREATE TABLE "tags" (
"id" serial PRIMARY KEY NOT NULL, "id" serial PRIMARY KEY NOT NULL,
"name" varchar(200) NOT NULL, "name" varchar(200) NOT NULL,
@@ -389,6 +401,8 @@ ALTER TABLE "library_folders" ADD CONSTRAINT "library_folders_library_id_librari
ALTER TABLE "lenses" ADD CONSTRAINT "lenses_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "lenses" ADD CONSTRAINT "lenses_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_authors" ADD CONSTRAINT "book_authors_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "book_authors" ADD CONSTRAINT "book_authors_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_authors" ADD CONSTRAINT "book_authors_author_id_authors_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."authors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint ALTER TABLE "book_authors" ADD CONSTRAINT "book_authors_author_id_authors_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."authors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_genres" ADD CONSTRAINT "book_genres_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_genres" ADD CONSTRAINT "book_genres_genre_id_genres_id_fk" FOREIGN KEY ("genre_id") REFERENCES "public"."genres"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_metadata" ADD CONSTRAINT "book_metadata_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "book_metadata" ADD CONSTRAINT "book_metadata_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_tags" ADD CONSTRAINT "book_tags_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "book_tags" ADD CONSTRAINT "book_tags_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "book_tags" ADD CONSTRAINT "book_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "book_tags" ADD CONSTRAINT "book_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+191 -347
View File
@@ -1,5 +1,5 @@
{ {
"id": "f572f92e-eb3a-4b8c-8a8a-bde7de427787", "id": "0eeff5ae-6f70-4e72-b6f3-c06a1e702c9a",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -41,9 +41,7 @@
"app_settings_key_unique": { "app_settings_key_unique": {
"name": "app_settings_key_unique", "name": "app_settings_key_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["key"]
"key"
]
} }
}, },
"policies": {}, "policies": {},
@@ -114,12 +112,8 @@
"name": "password_reset_tokens_user_id_users_id_fk", "name": "password_reset_tokens_user_id_users_id_fk",
"tableFrom": "password_reset_tokens", "tableFrom": "password_reset_tokens",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -129,9 +123,7 @@
"password_reset_tokens_token_hash_unique": { "password_reset_tokens_token_hash_unique": {
"name": "password_reset_tokens_token_hash_unique", "name": "password_reset_tokens_token_hash_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["token_hash"]
"token_hash"
]
} }
}, },
"policies": {}, "policies": {},
@@ -182,9 +174,7 @@
"permissions_name_unique": { "permissions_name_unique": {
"name": "permissions_name_unique", "name": "permissions_name_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["name"]
"name"
]
} }
}, },
"policies": {}, "policies": {},
@@ -255,12 +245,8 @@
"name": "refresh_tokens_user_id_users_id_fk", "name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens", "tableFrom": "refresh_tokens",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -270,9 +256,7 @@
"refresh_tokens_token_hash_unique": { "refresh_tokens_token_hash_unique": {
"name": "refresh_tokens_token_hash_unique", "name": "refresh_tokens_token_hash_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["token_hash"]
"token_hash"
]
} }
}, },
"policies": {}, "policies": {},
@@ -302,12 +286,8 @@
"name": "role_permissions_role_id_roles_id_fk", "name": "role_permissions_role_id_roles_id_fk",
"tableFrom": "role_permissions", "tableFrom": "role_permissions",
"tableTo": "roles", "tableTo": "roles",
"columnsFrom": [ "columnsFrom": ["role_id"],
"role_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -315,12 +295,8 @@
"name": "role_permissions_permission_id_permissions_id_fk", "name": "role_permissions_permission_id_permissions_id_fk",
"tableFrom": "role_permissions", "tableFrom": "role_permissions",
"tableTo": "permissions", "tableTo": "permissions",
"columnsFrom": [ "columnsFrom": ["permission_id"],
"permission_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -328,10 +304,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"role_permissions_role_id_permission_id_pk": { "role_permissions_role_id_permission_id_pk": {
"name": "role_permissions_role_id_permission_id_pk", "name": "role_permissions_role_id_permission_id_pk",
"columns": [ "columns": ["role_id", "permission_id"]
"role_id",
"permission_id"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@@ -390,9 +363,7 @@
"roles_name_unique": { "roles_name_unique": {
"name": "roles_name_unique", "name": "roles_name_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["name"]
"name"
]
} }
}, },
"policies": {}, "policies": {},
@@ -430,12 +401,8 @@
"name": "user_library_access_user_id_users_id_fk", "name": "user_library_access_user_id_users_id_fk",
"tableFrom": "user_library_access", "tableFrom": "user_library_access",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -443,12 +410,8 @@
"name": "user_library_access_library_id_libraries_id_fk", "name": "user_library_access_library_id_libraries_id_fk",
"tableFrom": "user_library_access", "tableFrom": "user_library_access",
"tableTo": "libraries", "tableTo": "libraries",
"columnsFrom": [ "columnsFrom": ["library_id"],
"library_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -456,10 +419,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"user_library_access_user_id_library_id_pk": { "user_library_access_user_id_library_id_pk": {
"name": "user_library_access_user_id_library_id_pk", "name": "user_library_access_user_id_library_id_pk",
"columns": [ "columns": ["user_id", "library_id"]
"user_id",
"library_id"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@@ -490,12 +450,8 @@
"name": "user_roles_user_id_users_id_fk", "name": "user_roles_user_id_users_id_fk",
"tableFrom": "user_roles", "tableFrom": "user_roles",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -503,12 +459,8 @@
"name": "user_roles_role_id_roles_id_fk", "name": "user_roles_role_id_roles_id_fk",
"tableFrom": "user_roles", "tableFrom": "user_roles",
"tableTo": "roles", "tableTo": "roles",
"columnsFrom": [ "columnsFrom": ["role_id"],
"role_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -516,10 +468,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"user_roles_user_id_role_id_pk": { "user_roles_user_id_role_id_pk": {
"name": "user_roles_user_id_role_id_pk", "name": "user_roles_user_id_role_id_pk",
"columns": [ "columns": ["user_id", "role_id"]
"user_id",
"role_id"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@@ -636,16 +585,12 @@
"users_username_unique": { "users_username_unique": {
"name": "users_username_unique", "name": "users_username_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["username"]
"username"
]
}, },
"users_email_unique": { "users_email_unique": {
"name": "users_email_unique", "name": "users_email_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["email"]
"email"
]
} }
}, },
"policies": {}, "policies": {},
@@ -744,12 +689,8 @@
"name": "book_files_book_id_books_id_fk", "name": "book_files_book_id_books_id_fk",
"tableFrom": "book_files", "tableFrom": "book_files",
"tableTo": "books", "tableTo": "books",
"columnsFrom": [ "columnsFrom": ["book_id"],
"book_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -757,12 +698,8 @@
"name": "book_files_library_folder_id_library_folders_id_fk", "name": "book_files_library_folder_id_library_folders_id_fk",
"tableFrom": "book_files", "tableFrom": "book_files",
"tableTo": "library_folders", "tableTo": "library_folders",
"columnsFrom": [ "columnsFrom": ["library_folder_id"],
"library_folder_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -829,12 +766,8 @@
"name": "books_library_id_libraries_id_fk", "name": "books_library_id_libraries_id_fk",
"tableFrom": "books", "tableFrom": "books",
"tableTo": "libraries", "tableTo": "libraries",
"columnsFrom": [ "columnsFrom": ["library_id"],
"library_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -842,12 +775,8 @@
"name": "books_library_folder_id_library_folders_id_fk", "name": "books_library_folder_id_library_folders_id_fk",
"tableFrom": "books", "tableFrom": "books",
"tableTo": "library_folders", "tableTo": "library_folders",
"columnsFrom": [ "columnsFrom": ["library_folder_id"],
"library_folder_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -888,12 +817,8 @@
"name": "collection_books_collection_id_collections_id_fk", "name": "collection_books_collection_id_collections_id_fk",
"tableFrom": "collection_books", "tableFrom": "collection_books",
"tableTo": "collections", "tableTo": "collections",
"columnsFrom": [ "columnsFrom": ["collection_id"],
"collection_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -901,12 +826,8 @@
"name": "collection_books_book_id_books_id_fk", "name": "collection_books_book_id_books_id_fk",
"tableFrom": "collection_books", "tableFrom": "collection_books",
"tableTo": "books", "tableTo": "books",
"columnsFrom": [ "columnsFrom": ["book_id"],
"book_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -914,10 +835,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"collection_books_collection_id_book_id_pk": { "collection_books_collection_id_book_id_pk": {
"name": "collection_books_collection_id_book_id_pk", "name": "collection_books_collection_id_book_id_pk",
"columns": [ "columns": ["collection_id", "book_id"]
"collection_id",
"book_id"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@@ -987,12 +905,8 @@
"name": "collections_user_id_users_id_fk", "name": "collections_user_id_users_id_fk",
"tableFrom": "collections", "tableFrom": "collections",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1165,12 +1079,8 @@
"name": "library_folders_library_id_libraries_id_fk", "name": "library_folders_library_id_libraries_id_fk",
"tableFrom": "library_folders", "tableFrom": "library_folders",
"tableTo": "libraries", "tableTo": "libraries",
"columnsFrom": [ "columnsFrom": ["library_id"],
"library_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1250,12 +1160,8 @@
"name": "lenses_user_id_users_id_fk", "name": "lenses_user_id_users_id_fk",
"tableFrom": "lenses", "tableFrom": "lenses",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1265,10 +1171,7 @@
"lenses_user_id_name_unique": { "lenses_user_id_name_unique": {
"name": "lenses_user_id_name_unique", "name": "lenses_user_id_name_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["user_id", "name"]
"user_id",
"name"
]
} }
}, },
"policies": {}, "policies": {},
@@ -1336,12 +1239,8 @@
"name": "book_authors_book_id_books_id_fk", "name": "book_authors_book_id_books_id_fk",
"tableFrom": "book_authors", "tableFrom": "book_authors",
"tableTo": "books", "tableTo": "books",
"columnsFrom": [ "columnsFrom": ["book_id"],
"book_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1349,12 +1248,8 @@
"name": "book_authors_author_id_authors_id_fk", "name": "book_authors_author_id_authors_id_fk",
"tableFrom": "book_authors", "tableFrom": "book_authors",
"tableTo": "authors", "tableTo": "authors",
"columnsFrom": [ "columnsFrom": ["author_id"],
"author_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "no action", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1362,10 +1257,56 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"book_authors_book_id_author_id_pk": { "book_authors_book_id_author_id_pk": {
"name": "book_authors_book_id_author_id_pk", "name": "book_authors_book_id_author_id_pk",
"columns": [ "columns": ["book_id", "author_id"]
"book_id", }
"author_id" },
] "uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.book_genres": {
"name": "book_genres",
"schema": "",
"columns": {
"book_id": {
"name": "book_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"genre_id": {
"name": "genre_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"book_genres_book_id_books_id_fk": {
"name": "book_genres_book_id_books_id_fk",
"tableFrom": "book_genres",
"tableTo": "books",
"columnsFrom": ["book_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"book_genres_genre_id_genres_id_fk": {
"name": "book_genres_genre_id_genres_id_fk",
"tableFrom": "book_genres",
"tableTo": "genres",
"columnsFrom": ["genre_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"book_genres_book_id_genre_id_pk": {
"name": "book_genres_book_id_genre_id_pk",
"columns": ["book_id", "genre_id"]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@@ -1505,12 +1446,8 @@
"name": "book_metadata_book_id_books_id_fk", "name": "book_metadata_book_id_books_id_fk",
"tableFrom": "book_metadata", "tableFrom": "book_metadata",
"tableTo": "books", "tableTo": "books",
"columnsFrom": [ "columnsFrom": ["book_id"],
"book_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1544,12 +1481,8 @@
"name": "book_tags_book_id_books_id_fk", "name": "book_tags_book_id_books_id_fk",
"tableFrom": "book_tags", "tableFrom": "book_tags",
"tableTo": "books", "tableTo": "books",
"columnsFrom": [ "columnsFrom": ["book_id"],
"book_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1557,12 +1490,8 @@
"name": "book_tags_tag_id_tags_id_fk", "name": "book_tags_tag_id_tags_id_fk",
"tableFrom": "book_tags", "tableFrom": "book_tags",
"tableTo": "tags", "tableTo": "tags",
"columnsFrom": [ "columnsFrom": ["tag_id"],
"tag_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1570,10 +1499,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"book_tags_book_id_tag_id_pk": { "book_tags_book_id_tag_id_pk": {
"name": "book_tags_book_id_tag_id_pk", "name": "book_tags_book_id_tag_id_pk",
"columns": [ "columns": ["book_id", "tag_id"]
"book_id",
"tag_id"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@@ -1581,6 +1507,37 @@
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "isRLSEnabled": false
}, },
"public.genres": {
"name": "genres",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(200)",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"genres_name_unique": {
"name": "genres_name_unique",
"nullsNotDistinct": false,
"columns": ["name"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tags": { "public.tags": {
"name": "tags", "name": "tags",
"schema": "", "schema": "",
@@ -1605,9 +1562,7 @@
"tags_name_unique": { "tags_name_unique": {
"name": "tags_name_unique", "name": "tags_name_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["name"]
"name"
]
} }
}, },
"policies": {}, "policies": {},
@@ -1690,12 +1645,8 @@
"name": "scan_jobs_library_id_libraries_id_fk", "name": "scan_jobs_library_id_libraries_id_fk",
"tableFrom": "scan_jobs", "tableFrom": "scan_jobs",
"tableTo": "libraries", "tableTo": "libraries",
"columnsFrom": [ "columnsFrom": ["library_id"],
"library_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1803,12 +1754,8 @@
"name": "annotations_user_id_users_id_fk", "name": "annotations_user_id_users_id_fk",
"tableFrom": "annotations", "tableFrom": "annotations",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1816,12 +1763,8 @@
"name": "annotations_book_id_books_id_fk", "name": "annotations_book_id_books_id_fk",
"tableFrom": "annotations", "tableFrom": "annotations",
"tableTo": "books", "tableTo": "books",
"columnsFrom": [ "columnsFrom": ["book_id"],
"book_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1896,12 +1839,8 @@
"name": "bookmarks_user_id_users_id_fk", "name": "bookmarks_user_id_users_id_fk",
"tableFrom": "bookmarks", "tableFrom": "bookmarks",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -1909,12 +1848,8 @@
"name": "bookmarks_book_id_books_id_fk", "name": "bookmarks_book_id_books_id_fk",
"tableFrom": "bookmarks", "tableFrom": "bookmarks",
"tableTo": "books", "tableTo": "books",
"columnsFrom": [ "columnsFrom": ["book_id"],
"book_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -1989,12 +1924,8 @@
"name": "reader_default_preferences_user_id_users_id_fk", "name": "reader_default_preferences_user_id_users_id_fk",
"tableFrom": "reader_default_preferences", "tableFrom": "reader_default_preferences",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2069,12 +2000,8 @@
"name": "reader_preferences_user_id_users_id_fk", "name": "reader_preferences_user_id_users_id_fk",
"tableFrom": "reader_preferences", "tableFrom": "reader_preferences",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2082,12 +2009,8 @@
"name": "reader_preferences_book_file_id_book_files_id_fk", "name": "reader_preferences_book_file_id_book_files_id_fk",
"tableFrom": "reader_preferences", "tableFrom": "reader_preferences",
"tableTo": "book_files", "tableTo": "book_files",
"columnsFrom": [ "columnsFrom": ["book_file_id"],
"book_file_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2163,12 +2086,8 @@
"name": "reading_progress_book_file_id_book_files_id_fk", "name": "reading_progress_book_file_id_book_files_id_fk",
"tableFrom": "reading_progress", "tableFrom": "reading_progress",
"tableTo": "book_files", "tableTo": "book_files",
"columnsFrom": [ "columnsFrom": ["book_file_id"],
"book_file_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2176,12 +2095,8 @@
"name": "reading_progress_user_id_users_id_fk", "name": "reading_progress_user_id_users_id_fk",
"tableFrom": "reading_progress", "tableFrom": "reading_progress",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2189,10 +2104,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"reading_progress_book_file_id_user_id_pk": { "reading_progress_book_file_id_user_id_pk": {
"name": "reading_progress_book_file_id_user_id_pk", "name": "reading_progress_book_file_id_user_id_pk",
"columns": [ "columns": ["book_file_id", "user_id"]
"book_file_id",
"user_id"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@@ -2236,12 +2148,8 @@
"name": "oidc_group_mappings_role_id_roles_id_fk", "name": "oidc_group_mappings_role_id_roles_id_fk",
"tableFrom": "oidc_group_mappings", "tableFrom": "oidc_group_mappings",
"tableTo": "roles", "tableTo": "roles",
"columnsFrom": [ "columnsFrom": ["role_id"],
"role_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "set null", "onDelete": "set null",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2251,9 +2159,7 @@
"oidc_group_mappings_oidc_group_claim_unique": { "oidc_group_mappings_oidc_group_claim_unique": {
"name": "oidc_group_mappings_oidc_group_claim_unique", "name": "oidc_group_mappings_oidc_group_claim_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["oidc_group_claim"]
"oidc_group_claim"
]
} }
}, },
"policies": {}, "policies": {},
@@ -2373,12 +2279,8 @@
"name": "oidc_sessions_user_id_users_id_fk", "name": "oidc_sessions_user_id_users_id_fk",
"tableFrom": "oidc_sessions", "tableFrom": "oidc_sessions",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2446,12 +2348,8 @@
"name": "opds_users_user_id_users_id_fk", "name": "opds_users_user_id_users_id_fk",
"tableFrom": "opds_users", "tableFrom": "opds_users",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2461,9 +2359,7 @@
"opds_users_username_unique": { "opds_users_username_unique": {
"name": "opds_users_username_unique", "name": "opds_users_username_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["username"]
"username"
]
} }
}, },
"policies": {}, "policies": {},
@@ -2518,12 +2414,8 @@
"name": "kobo_devices_user_id_users_id_fk", "name": "kobo_devices_user_id_users_id_fk",
"tableFrom": "kobo_devices", "tableFrom": "kobo_devices",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2533,9 +2425,7 @@
"kobo_devices_token_unique": { "kobo_devices_token_unique": {
"name": "kobo_devices_token_unique", "name": "kobo_devices_token_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["token"]
"token"
]
} }
}, },
"policies": {}, "policies": {},
@@ -2579,12 +2469,8 @@
"name": "kobo_library_snapshots_user_id_users_id_fk", "name": "kobo_library_snapshots_user_id_users_id_fk",
"tableFrom": "kobo_library_snapshots", "tableFrom": "kobo_library_snapshots",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2594,9 +2480,7 @@
"kobo_library_snapshots_user_id_unique": { "kobo_library_snapshots_user_id_unique": {
"name": "kobo_library_snapshots_user_id_unique", "name": "kobo_library_snapshots_user_id_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["user_id"]
"user_id"
]
} }
}, },
"policies": {}, "policies": {},
@@ -2687,12 +2571,8 @@
"name": "kobo_reading_states_user_id_users_id_fk", "name": "kobo_reading_states_user_id_users_id_fk",
"tableFrom": "kobo_reading_states", "tableFrom": "kobo_reading_states",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2700,12 +2580,8 @@
"name": "kobo_reading_states_book_id_books_id_fk", "name": "kobo_reading_states_book_id_books_id_fk",
"tableFrom": "kobo_reading_states", "tableFrom": "kobo_reading_states",
"tableTo": "books", "tableTo": "books",
"columnsFrom": [ "columnsFrom": ["book_id"],
"book_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2715,10 +2591,7 @@
"kobo_reading_states_user_id_book_id_unique": { "kobo_reading_states_user_id_book_id_unique": {
"name": "kobo_reading_states_user_id_book_id_unique", "name": "kobo_reading_states_user_id_book_id_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["user_id", "book_id"]
"user_id",
"book_id"
]
} }
}, },
"policies": {}, "policies": {},
@@ -2788,12 +2661,8 @@
"name": "kobo_snapshot_books_snapshot_id_kobo_library_snapshots_id_fk", "name": "kobo_snapshot_books_snapshot_id_kobo_library_snapshots_id_fk",
"tableFrom": "kobo_snapshot_books", "tableFrom": "kobo_snapshot_books",
"tableTo": "kobo_library_snapshots", "tableTo": "kobo_library_snapshots",
"columnsFrom": [ "columnsFrom": ["snapshot_id"],
"snapshot_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -2801,12 +2670,8 @@
"name": "kobo_snapshot_books_book_id_books_id_fk", "name": "kobo_snapshot_books_book_id_books_id_fk",
"tableFrom": "kobo_snapshot_books", "tableFrom": "kobo_snapshot_books",
"tableTo": "books", "tableTo": "books",
"columnsFrom": [ "columnsFrom": ["book_id"],
"book_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2814,10 +2679,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"kobo_snapshot_books_snapshot_id_book_id_pk": { "kobo_snapshot_books_snapshot_id_book_id_pk": {
"name": "kobo_snapshot_books_snapshot_id_book_id_pk", "name": "kobo_snapshot_books_snapshot_id_book_id_pk",
"columns": [ "columns": ["snapshot_id", "book_id"]
"snapshot_id",
"book_id"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@@ -2904,12 +2766,8 @@
"name": "kobo_sync_settings_user_id_users_id_fk", "name": "kobo_sync_settings_user_id_users_id_fk",
"tableFrom": "kobo_sync_settings", "tableFrom": "kobo_sync_settings",
"tableTo": "users", "tableTo": "users",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -2919,9 +2777,7 @@
"kobo_sync_settings_user_id_unique": { "kobo_sync_settings_user_id_unique": {
"name": "kobo_sync_settings_user_id_unique", "name": "kobo_sync_settings_user_id_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["user_id"]
"user_id"
]
} }
}, },
"policies": {}, "policies": {},
@@ -2933,24 +2789,12 @@
"public.library_access_level": { "public.library_access_level": {
"name": "library_access_level", "name": "library_access_level",
"schema": "public", "schema": "public",
"values": [ "values": ["viewer", "editor", "owner"]
"viewer",
"editor",
"owner"
]
}, },
"public.opds_sort_order": { "public.opds_sort_order": {
"name": "opds_sort_order", "name": "opds_sort_order",
"schema": "public", "schema": "public",
"values": [ "values": ["recent", "title_asc", "title_desc", "author_asc", "author_desc", "series_asc", "series_desc"]
"recent",
"title_asc",
"title_desc",
"author_asc",
"author_desc",
"series_asc",
"series_desc"
]
} }
}, },
"schemas": {}, "schemas": {},
@@ -2963,4 +2807,4 @@
"schemas": {}, "schemas": {},
"tables": {} "tables": {}
} }
} }
+2 -2
View File
@@ -5,8 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1771540165274, "when": 1771545632263,
"tag": "0000_add_provider_ids", "tag": "0000_baseline",
"breakpoints": true "breakpoints": true
} }
] ]
+21
View File
@@ -47,6 +47,24 @@ export const bookAuthors = pgTable(
(t) => [primaryKey({ columns: [t.bookId, t.authorId] })], (t) => [primaryKey({ columns: [t.bookId, t.authorId] })],
); );
export const genres = pgTable('genres', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 200 }).notNull().unique(),
});
export const bookGenres = pgTable(
'book_genres',
{
bookId: integer('book_id')
.notNull()
.references(() => books.id, { onDelete: 'cascade' }),
genreId: integer('genre_id')
.notNull()
.references(() => genres.id, { onDelete: 'cascade' }),
},
(t) => [primaryKey({ columns: [t.bookId, t.genreId] })],
);
export const tags = pgTable('tags', { export const tags = pgTable('tags', {
id: serial('id').primaryKey(), id: serial('id').primaryKey(),
name: varchar('name', { length: 200 }).notNull().unique(), name: varchar('name', { length: 200 }).notNull().unique(),
@@ -71,5 +89,8 @@ export type NewBookMetadata = typeof bookMetadata.$inferInsert;
export type Author = typeof authors.$inferSelect; export type Author = typeof authors.$inferSelect;
export type NewAuthor = typeof authors.$inferInsert; export type NewAuthor = typeof authors.$inferInsert;
export type Genre = typeof genres.$inferSelect;
export type NewGenre = typeof genres.$inferInsert;
export type Tag = typeof tags.$inferSelect; export type Tag = typeof tags.$inferSelect;
export type NewTag = typeof tags.$inferInsert; export type NewTag = typeof tags.$inferInsert;
@@ -5,7 +5,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { GroupRule, Rule, SortField, SortSpec } from '@projectx/types'; import type { GroupRule, Rule, SortField, SortSpec } from '@projectx/types';
import { DB } from '../../db'; import { DB } from '../../db';
import * as schema from '../../db/schema'; import * as schema from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookMetadata, books, bookTags, readingProgress, tags } from '../../db/schema'; import { authors, bookAuthors, bookFiles, bookGenres, bookMetadata, books, readingProgress, genres } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>; type Db = NodePgDatabase<typeof schema>;
@@ -92,8 +92,8 @@ export class BookQueryBuilder {
return this.numericRuleToSql(bookMetadata.rating, operator, value as number, valueTo as number | undefined); return this.numericRuleToSql(bookMetadata.rating, operator, value as number, valueTo as number | undefined);
case 'author': case 'author':
return this.authorRuleToSql(operator, value as string[]); return this.authorRuleToSql(operator, value as string[]);
case 'tag': case 'genre':
return this.tagRuleToSql(operator, value as string[]); return this.genreRuleToSql(operator, value as string[]);
case 'format': case 'format':
return this.formatRuleToSql(operator, value as string[]); return this.formatRuleToSql(operator, value as string[]);
case 'addedAt': case 'addedAt':
@@ -186,26 +186,26 @@ export class BookQueryBuilder {
} }
} }
private tagRuleToSql(operator: string, values?: string[]): SQL { private genreRuleToSql(operator: string, values?: string[]): SQL {
const sq = (whereClause?: SQL) => const sq = (whereClause?: SQL) =>
this.db.select({ bookId: bookTags.bookId }).from(bookTags).innerJoin(tags, eq(bookTags.tagId, tags.id)).where(whereClause); this.db.select({ bookId: bookGenres.bookId }).from(bookGenres).innerJoin(genres, eq(bookGenres.genreId, genres.id)).where(whereClause);
switch (operator) { switch (operator) {
case 'includesAny': case 'includesAny':
if (!values?.length) return sql`1 = 0`; if (!values?.length) return sql`1 = 0`;
return inArray(books.id, sq(or(...values.map((v) => eq(tags.name, v))))); return inArray(books.id, sq(or(...values.map((v) => eq(genres.name, v)))));
case 'includesAll': case 'includesAll':
if (!values?.length) return sql`1 = 0`; if (!values?.length) return sql`1 = 0`;
return and(...values.map((v) => inArray(books.id, sq(eq(tags.name, v)))))!; return and(...values.map((v) => inArray(books.id, sq(eq(genres.name, v)))))!;
case 'excludesAll': case 'excludesAll':
if (!values?.length) return sql`1 = 1`; if (!values?.length) return sql`1 = 1`;
return not(inArray(books.id, sq(or(...values.map((v) => eq(tags.name, v)))))); return not(inArray(books.id, sq(or(...values.map((v) => eq(genres.name, v))))));
case 'isEmpty': case 'isEmpty':
return not(inArray(books.id, sq())); return not(inArray(books.id, sq()));
case 'isNotEmpty': case 'isNotEmpty':
return inArray(books.id, sq()); return inArray(books.id, sq());
default: default:
throw new BadRequestException(`Invalid operator '${operator}' for tag field`); throw new BadRequestException(`Invalid operator '${operator}' for genre field`);
} }
} }
+25 -5
View File
@@ -4,7 +4,19 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db'; import { DB } from '../../db';
import * as schema from '../../db/schema'; import * as schema from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookMetadata, books, bookTags, libraries, readingProgress, tags } from '../../db/schema'; import {
authors,
bookAuthors,
bookFiles,
bookGenres,
bookMetadata,
books,
bookTags,
genres,
libraries,
readingProgress,
tags,
} from '../../db/schema';
type Db = NodePgDatabase<typeof schema>; type Db = NodePgDatabase<typeof schema>;
@@ -40,7 +52,7 @@ export class BookRepository {
const bookIds = rows.map((r) => r.id); const bookIds = rows.map((r) => r.id);
const [authorRows, fileRows, tagRows] = await Promise.all([ const [authorRows, fileRows, genreRows, tagRows] = await Promise.all([
bookIds.length > 0 bookIds.length > 0
? this.db ? this.db
.select({ bookId: bookAuthors.bookId, name: authors.name }) .select({ bookId: bookAuthors.bookId, name: authors.name })
@@ -54,6 +66,13 @@ export class BookRepository {
.select({ bookId: bookFiles.bookId, id: bookFiles.id, format: bookFiles.format, role: bookFiles.role }) .select({ bookId: bookFiles.bookId, id: bookFiles.id, format: bookFiles.format, role: bookFiles.role })
.from(bookFiles) .from(bookFiles)
.where(inArray(bookFiles.bookId, bookIds)) .where(inArray(bookFiles.bookId, bookIds))
: ([] as { bookId: number; id: number; format: string | null; role: string }[]),
bookIds.length > 0
? this.db
.select({ bookId: bookGenres.bookId, name: genres.name })
.from(bookGenres)
.innerJoin(genres, eq(genres.id, bookGenres.genreId))
.where(inArray(bookGenres.bookId, bookIds))
: [], : [],
bookIds.length > 0 bookIds.length > 0
? this.db ? this.db
@@ -73,7 +92,7 @@ export class BookRepository {
.where(and(eq(readingProgress.userId, userId), inArray(readingProgress.bookFileId, primaryFileIds))) .where(and(eq(readingProgress.userId, userId), inArray(readingProgress.bookFileId, primaryFileIds)))
: []; : [];
return { rows, authorRows, fileRows, tagRows, progressRows, total: Number(total) }; return { rows, authorRows, fileRows, genreRows, tagRows, progressRows, total: Number(total) };
} }
async findById(id: number) { async findById(id: number) {
@@ -81,13 +100,14 @@ export class BookRepository {
if (!book) return null; if (!book) return null;
const [authorRows, tagRows, fileRows] = await Promise.all([ const [authorRows, genreRows, tagRows, fileRows] = await Promise.all([
this.db this.db
.select({ id: authors.id, name: authors.name, sortName: authors.sortName }) .select({ id: authors.id, name: authors.name, sortName: authors.sortName })
.from(bookAuthors) .from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId)) .innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(eq(bookAuthors.bookId, id)) .where(eq(bookAuthors.bookId, id))
.orderBy(bookAuthors.displayOrder), .orderBy(bookAuthors.displayOrder),
this.db.select({ name: genres.name }).from(bookGenres).innerJoin(genres, eq(genres.id, bookGenres.genreId)).where(eq(bookGenres.bookId, id)),
this.db.select({ name: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, id)), this.db.select({ name: tags.name }).from(bookTags).innerJoin(tags, eq(tags.id, bookTags.tagId)).where(eq(bookTags.bookId, id)),
this.db this.db
.select({ .select({
@@ -102,7 +122,7 @@ export class BookRepository {
.where(eq(bookFiles.bookId, id)), .where(eq(bookFiles.bookId, id)),
]); ]);
return { book, authorRows, tagRows, fileRows }; return { book, authorRows, genreRows, tagRows, fileRows };
} }
async findLibraryIdByBookId(bookId: number): Promise<number | null> { async findLibraryIdByBookId(bookId: number): Promise<number | null> {
+11 -73
View File
@@ -4,7 +4,8 @@ import { access, readdir, rm, stat } from 'fs/promises';
import { basename, join } from 'path'; import { basename, join } from 'path';
import { MetadataProviderKey } from '@projectx/types'; import { MetadataProviderKey } from '@projectx/types';
import type { BookCard, BookQuery, BooksPage } from '@projectx/types'; import type { BookQuery, BooksPage } from '@projectx/types';
import { assembleBookCards } from './utils/assemble-book-cards';
import type { RequestUser } from '../../common/types/request-user'; import type { RequestUser } from '../../common/types/request-user';
import { MetadataService } from '../metadata/metadata.service'; import { MetadataService } from '../metadata/metadata.service';
import { LibraryService } from '../library/library.service'; import { LibraryService } from '../library/library.service';
@@ -46,78 +47,11 @@ export class BookService {
return file; return file;
} }
private assembleBookCards(
rows: {
id: number;
status: string;
folderPath: string;
addedAt: Date;
title: string | null;
seriesName: string | null;
seriesIndex: number | null;
publishedYear: number | null;
language: string | null;
rating: number | null;
}[],
authorRows: { bookId: number; name: string }[],
fileRows: { bookId: number; id: number; format: string | null; role: string }[],
tagRows: { bookId: number; name: string }[],
progressRows: { bookFileId: number; percentage: number }[],
): BookCard[] {
const authorsByBook = new Map<number, string[]>();
for (const row of authorRows) {
const list = authorsByBook.get(row.bookId) ?? [];
list.push(row.name);
authorsByBook.set(row.bookId, list);
}
const filesByBook = new Map<number, { id: number; format: string | null; role: string }[]>();
for (const row of fileRows) {
const list = filesByBook.get(row.bookId) ?? [];
list.push({ id: row.id, format: row.format, role: row.role });
filesByBook.set(row.bookId, list);
}
const tagsByBook = new Map<number, string[]>();
for (const row of tagRows) {
const list = tagsByBook.get(row.bookId) ?? [];
list.push(row.name);
tagsByBook.set(row.bookId, list);
}
const progressByFileId = new Map<number, number>();
for (const row of progressRows) {
progressByFileId.set(row.bookFileId, row.percentage);
}
return rows.map((row) => {
const files = filesByBook.get(row.id) ?? [];
const primaryFile = files.find((f) => f.role === 'primary') ?? files[0] ?? null;
const readingProgress = primaryFile != null ? (progressByFileId.get(primaryFile.id) ?? null) : null;
return {
id: row.id,
status: row.status,
title: row.title ?? basename(row.folderPath),
seriesName: row.seriesName ?? null,
seriesIndex: row.seriesIndex ?? null,
authors: authorsByBook.get(row.id) ?? [],
files,
publishedYear: row.publishedYear ?? null,
language: row.language ?? null,
tags: tagsByBook.get(row.id) ?? [],
rating: row.rating ?? null,
readingProgress,
addedAt: row.addedAt.toISOString(),
};
});
}
async queryForLibrary(user: RequestUser, libraryId: number, query: BookQuery): Promise<BooksPage> { async queryForLibrary(user: RequestUser, libraryId: number, query: BookQuery): Promise<BooksPage> {
await this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user)); await this.libraryService.verifyUserAccess(user.id, libraryId, this.isSuperuser(user));
const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds: [libraryId], implicitLibraryId: libraryId, userId: user.id }); const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds: [libraryId], implicitLibraryId: libraryId, userId: user.id });
const orderBy = this.queryBuilder.buildOrderBy(query.sort); const orderBy = this.queryBuilder.buildOrderBy(query.sort);
const { rows, authorRows, fileRows, tagRows, progressRows, total } = await this.bookRepo.findCards({ const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, total } = await this.bookRepo.findCards({
where, where,
orderBy, orderBy,
limit: query.pagination.size, limit: query.pagination.size,
@@ -125,7 +59,7 @@ export class BookService {
userId: user.id, userId: user.id,
}); });
return { return {
items: this.assembleBookCards(rows, authorRows, fileRows, tagRows, progressRows), items: assembleBookCards(rows, authorRows, fileRows, genreRows, tagRows, progressRows),
total, total,
page: query.pagination.page, page: query.pagination.page,
size: query.pagination.size, size: query.pagination.size,
@@ -137,7 +71,7 @@ export class BookService {
const accessibleLibraryIds = libs.map((l) => l.id); const accessibleLibraryIds = libs.map((l) => l.id);
const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds, userId: user.id }); const where = this.queryBuilder.buildWhere(query.filter, { accessibleLibraryIds, userId: user.id });
const orderBy = this.queryBuilder.buildOrderBy(query.sort); const orderBy = this.queryBuilder.buildOrderBy(query.sort);
const { rows, authorRows, fileRows, tagRows, progressRows, total } = await this.bookRepo.findCards({ const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, total } = await this.bookRepo.findCards({
where, where,
orderBy, orderBy,
limit: query.pagination.size, limit: query.pagination.size,
@@ -145,7 +79,7 @@ export class BookService {
userId: user.id, userId: user.id,
}); });
return { return {
items: this.assembleBookCards(rows, authorRows, fileRows, tagRows, progressRows), items: assembleBookCards(rows, authorRows, fileRows, genreRows, tagRows, progressRows),
total, total,
page: query.pagination.page, page: query.pagination.page,
size: query.pagination.size, size: query.pagination.size,
@@ -232,6 +166,9 @@ export class BookService {
dto.authors.map((name) => ({ name, sortName: null })), dto.authors.map((name) => ({ name, sortName: null })),
); );
} }
if (dto.genres !== undefined) {
await this.metadataService.replaceGenres(id, dto.genres);
}
if (dto.tags !== undefined) { if (dto.tags !== undefined) {
await this.metadataService.replaceTags(id, dto.tags); await this.metadataService.replaceTags(id, dto.tags);
} }
@@ -254,7 +191,7 @@ export class BookService {
const result = await this.bookRepo.findById(id); const result = await this.bookRepo.findById(id);
if (!result) throw new NotFoundException(`Book ${id} not found`); if (!result) throw new NotFoundException(`Book ${id} not found`);
const { book, authorRows, tagRows, fileRows } = result; const { book, authorRows, genreRows, tagRows, fileRows } = result;
const meta = book.book_metadata; const meta = book.book_metadata;
return { return {
@@ -283,6 +220,7 @@ export class BookService {
[MetadataProviderKey.OPEN_LIBRARY]: meta?.openLibraryId ?? null, [MetadataProviderKey.OPEN_LIBRARY]: meta?.openLibraryId ?? null,
}, },
authors: authorRows, authors: authorRows,
genres: genreRows.map((g) => g.name),
tags: tagRows.map((t) => t.name), tags: tagRows.map((t) => t.name),
files: fileRows.map((f) => ({ files: fileRows.map((f) => ({
id: f.id, id: f.id,
@@ -14,6 +14,7 @@ export class BookCardDto {
files: BookFileRefDto[]; files: BookFileRefDto[];
publishedYear: number | null; publishedYear: number | null;
language: string | null; language: string | null;
genres: string[];
tags: string[]; tags: string[];
rating: number | null; rating: number | null;
readingProgress: number | null; readingProgress: number | null;
@@ -30,6 +30,7 @@ export class BookDetailDto {
coverSource: 'extracted' | 'custom' | null; coverSource: 'extracted' | 'custom' | null;
providerIds: ProviderIds; providerIds: ProviderIds;
authors: { id: number; name: string; sortName: string | null }[]; authors: { id: number; name: string; sortName: string | null }[];
genres: string[];
tags: string[]; tags: string[];
files: BookFileDto[]; files: BookFileDto[];
} }
@@ -14,6 +14,7 @@ export class UpdateBookMetadataDto {
@IsOptional() @IsString() @MaxLength(13) isbn13?: string | null; @IsOptional() @IsString() @MaxLength(13) isbn13?: string | null;
@IsOptional() @IsInt() @Min(1) @Max(5) rating?: number | null; @IsOptional() @IsInt() @Min(1) @Max(5) rating?: number | null;
@IsOptional() @IsArray() @IsString({ each: true }) authors?: string[]; @IsOptional() @IsArray() @IsString({ each: true }) authors?: string[];
@IsOptional() @IsArray() @IsString({ each: true }) genres?: string[];
@IsOptional() @IsArray() @IsString({ each: true }) tags?: string[]; @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[];
@IsOptional() @IsString() @MaxLength(50) googleBooksId?: string | null; @IsOptional() @IsString() @MaxLength(50) googleBooksId?: string | null;
@IsOptional() @IsString() @MaxLength(50) goodreadsId?: string | null; @IsOptional() @IsString() @MaxLength(50) goodreadsId?: string | null;
@@ -0,0 +1,85 @@
import { basename } from 'path';
import type { BookCard } from '@projectx/types';
type BookRow = {
id: number;
status: string;
folderPath: string;
addedAt: Date;
title: string | null;
seriesName: string | null;
seriesIndex: number | null;
publishedYear: number | null;
language: string | null;
rating: number | null;
};
type NameRow = { bookId: number; name: string };
type FileRow = { bookId: number; id: number; format: string | null; role: string };
type ProgressRow = { bookFileId: number; percentage: number | null };
export function assembleBookCards(
rows: BookRow[],
authorRows: NameRow[],
fileRows: FileRow[],
genreRows: NameRow[],
tagRows: NameRow[],
progressRows: ProgressRow[],
): BookCard[] {
const authorsByBook = new Map<number, string[]>();
for (const row of authorRows) {
const list = authorsByBook.get(row.bookId) ?? [];
list.push(row.name);
authorsByBook.set(row.bookId, list);
}
const filesByBook = new Map<number, { id: number; format: string | null; role: string }[]>();
for (const row of fileRows) {
const list = filesByBook.get(row.bookId) ?? [];
list.push({ id: row.id, format: row.format, role: row.role });
filesByBook.set(row.bookId, list);
}
const genresByBook = new Map<number, string[]>();
for (const row of genreRows) {
const list = genresByBook.get(row.bookId) ?? [];
list.push(row.name);
genresByBook.set(row.bookId, list);
}
const tagsByBook = new Map<number, string[]>();
for (const row of tagRows) {
const list = tagsByBook.get(row.bookId) ?? [];
list.push(row.name);
tagsByBook.set(row.bookId, list);
}
const progressByFileId = new Map<number, number | null>();
for (const row of progressRows) {
progressByFileId.set(row.bookFileId, row.percentage);
}
return rows.map((row) => {
const files = filesByBook.get(row.id) ?? [];
const primaryFile = files.find((f) => f.role === 'primary') ?? files[0] ?? null;
const readingProgress = primaryFile != null ? (progressByFileId.get(primaryFile.id) ?? null) : null;
return {
id: row.id,
status: row.status,
title: row.title ?? basename(row.folderPath),
seriesName: row.seriesName ?? null,
seriesIndex: row.seriesIndex ?? null,
authors: authorsByBook.get(row.id) ?? [],
files,
publishedYear: row.publishedYear ?? null,
language: row.language ?? null,
genres: genresByBook.get(row.id) ?? [],
tags: tagsByBook.get(row.id) ?? [],
rating: row.rating ?? null,
readingProgress,
addedAt: row.addedAt.toISOString(),
};
});
}
@@ -14,7 +14,7 @@ const RULE_FIELDS = [
'publishedYear', 'publishedYear',
'pageCount', 'pageCount',
'author', 'author',
'tag', 'genre',
'format', 'format',
'addedAt', 'addedAt',
'fileAvailability', 'fileAvailability',
@@ -56,7 +56,7 @@ const VALID_OPERATORS_BY_FIELD: Record<string, string[]> = {
language: ['eq', 'notEq', 'isEmpty', 'isNotEmpty'], language: ['eq', 'notEq', 'isEmpty', 'isNotEmpty'],
series: ['contains', 'notContains', 'isEmpty', 'isNotEmpty'], series: ['contains', 'notContains', 'isEmpty', 'isNotEmpty'],
author: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'], author: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
tag: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'], genre: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
format: ['includesAny', 'excludesAll'], format: ['includesAny', 'excludesAll'],
publishedYear: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'], publishedYear: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
seriesIndex: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'], seriesIndex: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
@@ -11,6 +11,11 @@ export class CatalogController {
return this.catalogService.searchAuthors(q); return this.catalogService.searchAuthors(q);
} }
@Get('genres')
searchGenres(@Query('q') q = '') {
return this.catalogService.searchGenres(q);
}
@Get('tags') @Get('tags')
searchTags(@Query('q') q = '') { searchTags(@Query('q') q = '') {
return this.catalogService.searchTags(q); return this.catalogService.searchTags(q);
+17 -15
View File
@@ -4,7 +4,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db'; import { DB } from '../../db';
import * as schema from '../../db/schema'; import * as schema from '../../db/schema';
import { authors, tags } from '../../db/schema'; import { authors, genres, tags } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>; type Db = NodePgDatabase<typeof schema>;
@@ -12,23 +12,25 @@ type Db = NodePgDatabase<typeof schema>;
export class CatalogService { export class CatalogService {
constructor(@Inject(DB) private readonly db: Db) {} constructor(@Inject(DB) private readonly db: Db) {}
async searchAuthors(q: string): Promise<{ id: number; name: string }[]> { searchAuthors(q: string) {
if (!q.trim()) return []; return this.searchByName(q, authors);
return this.db
.select({ id: authors.id, name: authors.name })
.from(authors)
.where(ilike(authors.name, `%${q}%`))
.orderBy(authors.name)
.limit(15);
} }
async searchTags(q: string): Promise<{ id: number; name: string }[]> { searchGenres(q: string) {
if (!q.trim()) return []; return this.searchByName(q, genres);
}
searchTags(q: string) {
return this.searchByName(q, tags);
}
private searchByName(q: string, table: typeof authors | typeof genres | typeof tags): Promise<{ id: number; name: string }[]> {
if (!q.trim()) return Promise.resolve([]);
return this.db return this.db
.select({ id: tags.id, name: tags.name }) .select({ id: table.id, name: table.name })
.from(tags) .from(table)
.where(ilike(tags.name, `%${q}%`)) .where(ilike(table.name, `%${q}%`))
.orderBy(tags.name) .orderBy(table.name)
.limit(15); .limit(15);
} }
} }
@@ -1,8 +1,8 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { basename } from 'path';
import { inArray } from 'drizzle-orm'; import { inArray } from 'drizzle-orm';
import type { BookCard, BooksPage } from '@projectx/types'; import type { BooksPage } from '@projectx/types';
import { assembleBookCards } from '../book/utils/assemble-book-cards';
import type { RequestUser } from '../../common/types/request-user'; import type { RequestUser } from '../../common/types/request-user';
import { books } from '../../db/schema'; import { books } from '../../db/schema';
import { BookRepository } from '../book/book.repository'; import { BookRepository } from '../book/book.repository';
@@ -28,71 +28,6 @@ export class CollectionService {
} }
} }
private assembleBookCards(
rows: {
id: number;
status: string;
folderPath: string;
addedAt: Date;
title: string | null;
seriesName: string | null;
seriesIndex: number | null;
publishedYear: number | null;
language: string | null;
rating: number | null;
}[],
authorRows: { bookId: number; name: string }[],
fileRows: { bookId: number; id: number; format: string | null; role: string }[],
tagRows: { bookId: number; name: string }[],
progressRows: { bookFileId: number; percentage: number | null }[],
): BookCard[] {
const authorsByBook = new Map<number, string[]>();
for (const row of authorRows) {
const list = authorsByBook.get(row.bookId) ?? [];
list.push(row.name);
authorsByBook.set(row.bookId, list);
}
const filesByBook = new Map<number, { id: number; format: string | null; role: string }[]>();
for (const row of fileRows) {
const list = filesByBook.get(row.bookId) ?? [];
list.push({ id: row.id, format: row.format, role: row.role });
filesByBook.set(row.bookId, list);
}
const tagsByBook = new Map<number, string[]>();
for (const row of tagRows) {
const list = tagsByBook.get(row.bookId) ?? [];
list.push(row.name);
tagsByBook.set(row.bookId, list);
}
const progressByFileId = new Map<number, number | null>();
for (const row of progressRows) {
progressByFileId.set(row.bookFileId, row.percentage);
}
return rows.map((row) => {
const primaryFile = (filesByBook.get(row.id) ?? []).find((f) => f.role === 'primary');
const readingProgress = primaryFile ? (progressByFileId.get(primaryFile.id) ?? null) : null;
return {
id: row.id,
status: row.status,
title: row.title ?? basename(row.folderPath),
seriesName: row.seriesName ?? null,
seriesIndex: row.seriesIndex ?? null,
publishedYear: row.publishedYear ?? null,
language: row.language ?? null,
rating: row.rating ?? null,
addedAt: row.addedAt.toISOString(),
authors: authorsByBook.get(row.id) ?? [],
files: filesByBook.get(row.id) ?? [],
tags: tagsByBook.get(row.id) ?? [],
readingProgress,
};
});
}
findAll(user: RequestUser, bookIds?: number[]) { findAll(user: RequestUser, bookIds?: number[]) {
if (bookIds && bookIds.length > 0) { if (bookIds && bookIds.length > 0) {
return this.collectionRepo.findAllForUserWithMembership(user.id, bookIds); return this.collectionRepo.findAllForUserWithMembership(user.id, bookIds);
@@ -170,7 +105,7 @@ export class CollectionService {
const bookIds = bookIdRows.map((r) => r.bookId); const bookIds = bookIdRows.map((r) => r.bookId);
const where = inArray(books.id, bookIds); const where = inArray(books.id, bookIds);
const { rows, authorRows, fileRows, tagRows, progressRows, total } = await this.bookRepo.findCards({ const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, total } = await this.bookRepo.findCards({
where, where,
orderBy: [], orderBy: [],
limit: size, limit: size,
@@ -178,6 +113,6 @@ export class CollectionService {
userId: user.id, userId: user.id,
}); });
return { items: this.assembleBookCards(rows, authorRows, fileRows, tagRows, progressRows), total, page, size }; return { items: assembleBookCards(rows, authorRows, fileRows, genreRows, tagRows, progressRows), total, page, size };
} }
} }
+4 -69
View File
@@ -1,7 +1,7 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { basename } from 'path';
import type { BookCard, BooksPage, GroupRule, SortSpec } from '@projectx/types'; import type { BooksPage, GroupRule, SortSpec } from '@projectx/types';
import { assembleBookCards } from '../book/utils/assemble-book-cards';
import type { RequestUser } from '../../common/types/request-user'; import type { RequestUser } from '../../common/types/request-user';
import { BookQueryBuilder } from '../book/book-query-builder.service'; import { BookQueryBuilder } from '../book/book-query-builder.service';
import { BookRepository } from '../book/book.repository'; import { BookRepository } from '../book/book.repository';
@@ -24,71 +24,6 @@ export class LensService {
return user.roles.some((r) => r.isSuperuser); return user.roles.some((r) => r.isSuperuser);
} }
private assembleBookCards(
rows: {
id: number;
status: string;
folderPath: string;
addedAt: Date;
title: string | null;
seriesName: string | null;
seriesIndex: number | null;
publishedYear: number | null;
language: string | null;
rating: number | null;
}[],
authorRows: { bookId: number; name: string }[],
fileRows: { bookId: number; id: number; format: string | null; role: string }[],
tagRows: { bookId: number; name: string }[],
progressRows: { bookFileId: number; percentage: number | null }[],
): BookCard[] {
const authorsByBook = new Map<number, string[]>();
for (const row of authorRows) {
const list = authorsByBook.get(row.bookId) ?? [];
list.push(row.name);
authorsByBook.set(row.bookId, list);
}
const filesByBook = new Map<number, { id: number; format: string | null; role: string }[]>();
for (const row of fileRows) {
const list = filesByBook.get(row.bookId) ?? [];
list.push({ id: row.id, format: row.format, role: row.role });
filesByBook.set(row.bookId, list);
}
const tagsByBook = new Map<number, string[]>();
for (const row of tagRows) {
const list = tagsByBook.get(row.bookId) ?? [];
list.push(row.name);
tagsByBook.set(row.bookId, list);
}
const progressByFileId = new Map<number, number | null>();
for (const row of progressRows) {
progressByFileId.set(row.bookFileId, row.percentage);
}
return rows.map((row) => {
const primaryFile = (filesByBook.get(row.id) ?? []).find((f) => f.role === 'primary');
const readingProgress = primaryFile ? (progressByFileId.get(primaryFile.id) ?? null) : null;
return {
id: row.id,
status: row.status,
title: row.title ?? basename(row.folderPath),
seriesName: row.seriesName ?? null,
seriesIndex: row.seriesIndex ?? null,
publishedYear: row.publishedYear ?? null,
language: row.language ?? null,
rating: row.rating ?? null,
addedAt: row.addedAt.toISOString(),
authors: authorsByBook.get(row.id) ?? [],
files: filesByBook.get(row.id) ?? [],
tags: tagsByBook.get(row.id) ?? [],
readingProgress,
};
});
}
async findAll(user: RequestUser) { async findAll(user: RequestUser) {
const lenses = await this.lensRepo.findAllForUser(user.id); const lenses = await this.lensRepo.findAllForUser(user.id);
const libs = await this.libraryService.findAll(user); const libs = await this.libraryService.findAll(user);
@@ -160,7 +95,7 @@ export class LensService {
const where = this.queryBuilder.buildWhere(lens.filter as GroupRule | null, { accessibleLibraryIds }); const where = this.queryBuilder.buildWhere(lens.filter as GroupRule | null, { accessibleLibraryIds });
const orderBy = this.queryBuilder.buildOrderBy((lens.defaultSort as SortSpec[]) ?? []); const orderBy = this.queryBuilder.buildOrderBy((lens.defaultSort as SortSpec[]) ?? []);
const { rows, authorRows, fileRows, tagRows, progressRows, total } = await this.bookRepo.findCards({ const { rows, authorRows, fileRows, genreRows, tagRows, progressRows, total } = await this.bookRepo.findCards({
where, where,
orderBy, orderBy,
limit: size, limit: size,
@@ -168,6 +103,6 @@ export class LensService {
userId: user.id, userId: user.id,
}); });
return { items: this.assembleBookCards(rows, authorRows, fileRows, tagRows, progressRows), total, page, size }; return { items: assembleBookCards(rows, authorRows, fileRows, genreRows, tagRows, progressRows), total, page, size };
} }
} }
@@ -6,8 +6,7 @@ import { MetadataSearchParams } from '../metadata-search-params';
import { extractAsins, parseBookPage } from './amazon.scraper'; import { extractAsins, parseBookPage } from './amazon.scraper';
const HEADERS: HeadersInit = { const HEADERS: HeadersInit = {
'user-agent': 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'accept-language': 'en-US,en;q=0.9', 'accept-language': 'en-US,en;q=0.9',
'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not_A Brand";v="24"', 'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not_A Brand";v="24"',
@@ -75,7 +74,7 @@ export class AmazonProvider implements IdentifiableProvider {
seriesName: data.seriesName, seriesName: data.seriesName,
seriesIndex: data.seriesIndex, seriesIndex: data.seriesIndex,
coverUrl: data.coverUrl, coverUrl: data.coverUrl,
tags: data.tags?.length ? data.tags : undefined, genres: data.tags?.length ? data.tags : undefined,
sourceUrl: `https://www.amazon.com/dp/${asin}`, sourceUrl: `https://www.amazon.com/dp/${asin}`,
}; };
} }
@@ -151,10 +151,7 @@ function extractPageCount($: CheerioAPI): number | undefined {
} }
function extractSeriesName($: CheerioAPI): string | undefined { function extractSeriesName($: CheerioAPI): string | undefined {
return ( return $('#rpi-attribute-book_details-series .rpi-attribute-value a span').first().text().trim() || undefined;
$('#rpi-attribute-book_details-series .rpi-attribute-value a span').first().text().trim() ||
undefined
);
} }
function extractSeriesIndex($: CheerioAPI): number | undefined { function extractSeriesIndex($: CheerioAPI): number | undefined {
@@ -177,7 +174,10 @@ function extractCoverUrl($: CheerioAPI): string | undefined {
function extractCategories($: CheerioAPI): string[] { function extractCategories($: CheerioAPI): string[] {
const cats: string[] = []; const cats: string[] = [];
$('#detailBullets_feature_div .zg_hrsr .a-list-item a').each((_, el) => { $('#detailBullets_feature_div .zg_hrsr .a-list-item a').each((_, el) => {
const name = $(el).text().trim().replace(/\s*\(Books\)\s*$/i, ''); const name = $(el)
.text()
.trim()
.replace(/\s*\(Books\)\s*$/i, '');
if (name) cats.push(name); if (name) cats.push(name);
}); });
return cats; return cats;
@@ -1,15 +1,8 @@
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types'; import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
import { import { GoodreadsApolloBook, GoodreadsApolloContributor, GoodreadsApolloSeries } from './goodreads.types';
GoodreadsApolloBook,
GoodreadsApolloContributor,
GoodreadsApolloSeries,
} from './goodreads.types';
export function mapGoodreadsApolloState( export function mapGoodreadsApolloState(state: Record<string, unknown>, bookId: string): MetadataCandidate | null {
state: Record<string, unknown>,
bookId: string,
): MetadataCandidate | null {
const book = findByKeyPrefix<GoodreadsApolloBook>(state, 'Book:kca:'); const book = findByKeyPrefix<GoodreadsApolloBook>(state, 'Book:kca:');
if (!book?.title) return null; if (!book?.title) return null;
@@ -19,9 +12,7 @@ export function mapGoodreadsApolloState(
const details = book.details; const details = book.details;
const firstSeries = book.bookSeries?.[0]; const firstSeries = book.bookSeries?.[0];
const tags = (book.bookGenres ?? []) const genres = (book.bookGenres ?? []).map((g) => g.genre?.name).filter((n): n is string => !!n);
.map((g) => g.genre?.name)
.filter((n): n is string => !!n);
const { title, subtitle } = splitTitle(book.title); const { title, subtitle } = splitTitle(book.title);
@@ -42,7 +33,7 @@ export function mapGoodreadsApolloState(
pageCount, pageCount,
isbn10: normalize(details?.isbn), isbn10: normalize(details?.isbn),
isbn13: normalize(details?.isbn13), isbn13: normalize(details?.isbn13),
tags: tags.length ? tags : undefined, genres: genres.length ? genres : undefined,
coverUrl: book.imageUrl, coverUrl: book.imageUrl,
sourceUrl: `https://www.goodreads.com/book/show/${bookId}`, sourceUrl: `https://www.goodreads.com/book/show/${bookId}`,
seriesName: normalize(series?.title), seriesName: normalize(series?.title),
@@ -7,8 +7,7 @@ import { mapGoodreadsApolloState } from './goodreads.mapper';
import { GoodreadsNextData } from './goodreads.types'; import { GoodreadsNextData } from './goodreads.types';
const HEADERS: HeadersInit = { const HEADERS: HeadersInit = {
'user-agent': 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'accept-language': 'en-US,en;q=0.9', 'accept-language': 'en-US,en;q=0.9',
}; };
@@ -25,9 +24,7 @@ export class GoodreadsProvider implements IdentifiableProvider {
private readonly logger = new Logger(GoodreadsProvider.name); private readonly logger = new Logger(GoodreadsProvider.name);
async search(params: MetadataSearchParams): Promise<MetadataCandidate[]> { async search(params: MetadataSearchParams): Promise<MetadataCandidate[]> {
const ids = params.isbn const ids = params.isbn ? await this.findIdByIsbn(params.isbn).then((id) => (id ? [id] : [])) : await this.searchIds(params);
? await this.findIdByIsbn(params.isbn).then((id) => (id ? [id] : []))
: await this.searchIds(params);
const results: MetadataCandidate[] = []; const results: MetadataCandidate[] = [];
for (const id of ids.slice(0, MAX_RESULTS)) { for (const id of ids.slice(0, MAX_RESULTS)) {
@@ -34,7 +34,7 @@ export function mapGoogleVolume(raw: GoogleVolumeItem): MetadataCandidate {
pageCount: info.pageCount, pageCount: info.pageCount,
isbn10, isbn10,
isbn13, isbn13,
tags: info.categories, genres: info.categories,
coverUrl, coverUrl,
sourceUrl: `https://books.google.com/books?id=${raw.id}`, sourceUrl: `https://books.google.com/books?id=${raw.id}`,
}; };
@@ -39,7 +39,7 @@ export function mapOpenLibraryDoc(raw: OpenLibraryDoc): MetadataCandidate {
publisher: raw.publisher?.[0], publisher: raw.publisher?.[0],
language: raw.language?.[0], language: raw.language?.[0],
pageCount: raw.number_of_pages_median, pageCount: raw.number_of_pages_median,
tags: raw.subject?.slice(0, 10), genres: raw.subject?.slice(0, 10),
coverUrl: coverUrl(raw.cover_i), coverUrl: coverUrl(raw.cover_i),
sourceUrl: `https://openlibrary.org${raw.key}`, sourceUrl: `https://openlibrary.org${raw.key}`,
}; };
@@ -55,7 +55,7 @@ export function mapOpenLibraryWork(raw: OpenLibraryWork): MetadataCandidate {
title: raw.title, title: raw.title,
description: extractDescription(raw.description), description: extractDescription(raw.description),
publishedYear: parseYear(raw.first_publish_date), publishedYear: parseYear(raw.first_publish_date),
tags: raw.subjects?.slice(0, 10), genres: raw.subjects?.slice(0, 10),
coverUrl: coverUrl(firstCover), coverUrl: coverUrl(firstCover),
sourceUrl: `https://openlibrary.org${raw.key}`, sourceUrl: `https://openlibrary.org${raw.key}`,
}; };
@@ -7,7 +7,7 @@ import { join } from 'path';
import { DB } from '../../db'; import { DB } from '../../db';
import * as schema from '../../db/schema'; import * as schema from '../../db/schema';
import { authors, bookAuthors, bookMetadata, bookTags, tags } from '../../db/schema'; import { authors, bookAuthors, bookGenres, bookMetadata, bookTags, genres, tags } from '../../db/schema';
import { extractCb7Metadata, extractCbrMetadata, extractCbzMetadata } from './lib/cbz-metadata'; import { extractCb7Metadata, extractCbrMetadata, extractCbzMetadata } from './lib/cbz-metadata';
import { extractAndSaveCover, generateThumbnail } from './lib/cover'; import { extractAndSaveCover, generateThumbnail } from './lib/cover';
import { extractEpubMetadata } from './lib/epub'; import { extractEpubMetadata } from './lib/epub';
@@ -167,7 +167,7 @@ export class MetadataService {
.where(eq(bookMetadata.bookId, bookId)); .where(eq(bookMetadata.bookId, bookId));
await this.replaceAuthors(bookId, parsed.authors); await this.replaceAuthors(bookId, parsed.authors);
await this.replaceTags(bookId, parsed.tags); await this.replaceGenres(bookId, parsed.tags);
this.logger.debug(`Metadata saved for book ${bookId}: "${parsed.title}"`); this.logger.debug(`Metadata saved for book ${bookId}: "${parsed.title}"`);
} }
@@ -231,21 +231,34 @@ export class MetadataService {
} }
} }
// ── Genres ───────────────────────────────────────────────────────────────────
async replaceGenres(bookId: number, parsedGenres: string[]) {
await this.db.delete(bookGenres).where(eq(bookGenres.bookId, bookId));
const unique = [...new Set(parsedGenres.map((g) => g.substring(0, 200)).filter(Boolean))];
if (unique.length === 0) return;
for (const name of unique) {
let [genre] = await this.db.select().from(genres).where(eq(genres.name, name)).limit(1);
if (!genre) {
[genre] = await this.db.insert(genres).values({ name }).returning();
}
await this.db.insert(bookGenres).values({ bookId, genreId: genre.id }).onConflictDoNothing();
}
}
// ── Tags ───────────────────────────────────────────────────────────────────── // ── Tags ─────────────────────────────────────────────────────────────────────
async replaceTags(bookId: number, parsedTags: string[]) { async replaceTags(bookId: number, userTags: string[]) {
await this.db.delete(bookTags).where(eq(bookTags.bookId, bookId)); await this.db.delete(bookTags).where(eq(bookTags.bookId, bookId));
const uniqueTags = [...new Set(parsedTags.map((t) => t.substring(0, 200)).filter(Boolean))]; const unique = [...new Set(userTags.map((t) => t.substring(0, 200)).filter(Boolean))];
if (uniqueTags.length === 0) return; if (unique.length === 0) return;
for (const rawName of uniqueTags) { for (const name of unique) {
const name = rawName;
let [tag] = await this.db.select().from(tags).where(eq(tags.name, name)).limit(1); let [tag] = await this.db.select().from(tags).where(eq(tags.name, name)).limit(1);
if (!tag) { if (!tag) {
[tag] = await this.db.insert(tags).values({ name }).returning(); [tag] = await this.db.insert(tags).values({ name }).returning();
} }
await this.db.insert(bookTags).values({ bookId, tagId: tag.id }).onConflictDoNothing(); await this.db.insert(bookTags).values({ bookId, tagId: tag.id }).onConflictDoNothing();
} }
} }