mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(books): rename tags to genres and add user tags
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
+14
@@ -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
|
||||||
@@ -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": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user