mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(server): match entity names that carry non-collapsed whitespace
Author, narrator, genre and tag names written before whitespace normalization existed can hold a non-breaking space, tab, newline or doubled space. HTML collapses all of those, so the name renders correctly in the UI while the ILIKE search predicate never matches it; unaccent() does not touch whitespace either. Re-entering an identical name by hand fixed a single book because the write path canonicalizes the row it reuses, but nothing ever ran for untouched books. Collapse stored names to the form normalizeMetadataTextSql() produces, merging rows that collide once collapsed and remapping every junction and content-filter reference before a duplicate is dropped. Case variants are left alone, since search is already case-insensitive. Close the two write paths that still produced un-collapsed names, and normalize the typed search term so a pasted non-breaking space still matches. Also carries staged client appearance work: the surface brightness picker and related controls. Closes #1014
This commit is contained in:
@@ -5,14 +5,35 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { t } = useI18n()
|
||||
const props = withDefaults(defineProps<{ touch?: boolean }>(), {
|
||||
touch: false,
|
||||
})
|
||||
const accentOptions = ACCENT_ROWS.flat()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<div v-if="props.touch" class="grid grid-cols-10 gap-0.5">
|
||||
<Tooltip v-for="opt in accentOptions" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex aspect-square w-full items-center justify-center rounded-md transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="themeStore.accent === opt.id ? 'bg-muted ring-2 ring-primary' : 'hover:bg-muted/60'"
|
||||
:aria-label="t(opt.labelKey)"
|
||||
@click="themeStore.setAccent(opt.id)"
|
||||
>
|
||||
<span class="block size-5 rounded-full" :class="opt.swatchClass" :style="{ backgroundColor: opt.color }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t(opt.labelKey) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div v-else class="space-y-1.5">
|
||||
<div v-for="(row, rowIndex) in ACCENT_ROWS" :key="rowIndex" class="flex items-center gap-0.5">
|
||||
<Tooltip v-for="opt in row" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="w-3.5 h-3.5 rounded-full transition-transform hover:scale-125 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card shrink-0"
|
||||
:class="opt.swatchClass"
|
||||
:aria-label="t(opt.labelKey)"
|
||||
|
||||
@@ -32,17 +32,15 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import AccentPicker from '@/components/AccentPicker.vue'
|
||||
import LanguagePicker from '@/components/LanguagePicker.vue'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import RadiusPicker from '@/components/RadiusPicker.vue'
|
||||
import BackgroundPicker from '@/components/BackgroundPicker.vue'
|
||||
import ThemePicker from '@/components/ThemePicker.vue'
|
||||
import SurfaceBrightnessPicker from '@/components/SurfaceBrightnessPicker.vue'
|
||||
import SurfacePicker from '@/components/SurfacePicker.vue'
|
||||
import { useGlobalSearch, type GlobalSearchResult } from '@/features/book/composables/useGlobalSearch'
|
||||
import BookCoverImage from '@/features/book/components/BookCoverImage.vue'
|
||||
@@ -123,6 +121,7 @@ function navigateToSettings() {
|
||||
router.push({ name: 'settings-libraries' })
|
||||
}
|
||||
|
||||
const appearanceSheetOpen = ref(false)
|
||||
const languageSheetOpen = ref(false)
|
||||
const languagePopoverOpen = ref(false)
|
||||
|
||||
@@ -141,6 +140,10 @@ function openLanguageSheet() {
|
||||
languageSheetOpen.value = true
|
||||
}
|
||||
|
||||
function openAppearanceSheet() {
|
||||
appearanceSheetOpen.value = true
|
||||
}
|
||||
|
||||
function navigateToWhatsNew() {
|
||||
router.push({ name: 'whats-new' })
|
||||
}
|
||||
@@ -636,36 +639,10 @@ function formatBadgeStyle(fmt: string) {
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Palette :size="15" class="mr-2 text-muted-foreground" />
|
||||
{{ t('components.appHeader.appearance') }}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent class="w-72 p-4">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.theme') }}</span>
|
||||
<ThemePicker />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.accent') }}</span>
|
||||
<AccentPicker />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.radius') }}</span>
|
||||
<RadiusPicker />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.surfaceOpacity') }}</span>
|
||||
<SurfacePicker />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.background') }}</span>
|
||||
<BackgroundPicker />
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem @click="openAppearanceSheet">
|
||||
<Palette :size="15" class="mr-2 text-muted-foreground" />
|
||||
{{ t('components.appHeader.appearance') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem @click="openLanguageSheet">
|
||||
<Languages :size="15" class="mr-2 text-muted-foreground" />
|
||||
@@ -798,6 +775,10 @@ function formatBadgeStyle(fmt: string) {
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.surfaceOpacity') }}</span>
|
||||
<SurfacePicker />
|
||||
</div>
|
||||
<div v-if="themeStore.resolvedTheme === 'dark'" class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('settings.appearance.theme.surfaceBrightness.label') }}</span>
|
||||
<SurfaceBrightnessPicker />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[13px] text-muted-foreground">{{ t('components.appHeader.background') }}</span>
|
||||
<BackgroundPicker />
|
||||
@@ -883,10 +864,52 @@ function formatBadgeStyle(fmt: string) {
|
||||
|
||||
<BookUploadModal v-if="uploadOpen" @close="uploadOpen = false" @uploaded="uploadOpen = false" />
|
||||
|
||||
<Sheet v-model:open="appearanceSheetOpen">
|
||||
<SheetContent side="bottom" class="h-[85dvh] rounded-t-xl p-0">
|
||||
<SheetHeader class="shrink-0 border-b border-border px-4 py-3 text-start">
|
||||
<SheetTitle class="text-base">{{ t('components.appHeader.appearance') }}</SheetTitle>
|
||||
<SheetDescription class="sr-only">{{ t('components.appHeader.appearanceDescription') }}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div class="min-h-0 flex-1 space-y-2 overflow-y-auto px-3 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||||
<section class="space-y-2 rounded-lg border border-border bg-card p-3">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('components.appHeader.theme') }}</h3>
|
||||
<ThemePicker touch />
|
||||
</section>
|
||||
|
||||
<section class="space-y-2 rounded-lg border border-border bg-card p-3">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('components.appHeader.accent') }}</h3>
|
||||
<AccentPicker touch />
|
||||
</section>
|
||||
|
||||
<section class="space-y-2 rounded-lg border border-border bg-card p-3">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('components.appHeader.radius') }}</h3>
|
||||
<RadiusPicker touch />
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 rounded-lg border border-border bg-card p-3">
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('components.appHeader.surfaceOpacity') }}</h3>
|
||||
<SurfacePicker />
|
||||
</div>
|
||||
<div v-if="themeStore.resolvedTheme === 'dark'" class="space-y-2 border-t border-border pt-3">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('settings.appearance.theme.surfaceBrightness.label') }}</h3>
|
||||
<SurfaceBrightnessPicker />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2 rounded-lg border border-border bg-card p-3">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t('components.appHeader.background') }}</h3>
|
||||
<BackgroundPicker touch />
|
||||
</section>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Sheet v-model:open="languageSheetOpen">
|
||||
<SheetContent side="bottom" class="h-[85dvh] rounded-t-xl px-2 pb-2">
|
||||
<SheetHeader class="pb-1">
|
||||
<SheetTitle>{{ t('settings.appearance.language.label') }}</SheetTitle>
|
||||
<SheetDescription class="sr-only">{{ t('settings.appearance.language.description') }}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<LanguagePicker :autofocus="false" class="min-h-0 flex-1" @select="selectLanguage" />
|
||||
</SheetContent>
|
||||
|
||||
@@ -3,16 +3,27 @@ import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const props = withDefaults(defineProps<{ touch?: boolean }>(), {
|
||||
touch: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
<div class="grid" :class="props.touch ? 'grid-cols-4 gap-2' : 'grid-cols-5 gap-2'">
|
||||
<Tooltip v-for="opt in BACKGROUND_OPTIONS" :key="opt.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button type="button" class="flex flex-col items-center cursor-pointer focus:outline-none" @click="themeStore.setBackground(opt.id)">
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center cursor-pointer rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-label="opt.label"
|
||||
@click="themeStore.setBackground(opt.id)"
|
||||
>
|
||||
<div
|
||||
class="w-full h-8 rounded overflow-hidden ring-2 transition-all"
|
||||
:class="themeStore.background === opt.id ? 'ring-primary' : 'ring-border hover:ring-muted-foreground/40'"
|
||||
class="w-full overflow-hidden ring-2 transition-all"
|
||||
:class="[
|
||||
props.touch ? 'h-10 rounded-md' : 'h-8 rounded',
|
||||
themeStore.background === opt.id ? 'ring-primary' : 'ring-border hover:ring-muted-foreground/40',
|
||||
]"
|
||||
>
|
||||
<div class="w-full h-full bg-background pattern-preview" :class="opt.cssClass" />
|
||||
</div>
|
||||
|
||||
@@ -1,32 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { t } = useI18n()
|
||||
const props = withDefaults(defineProps<{ touch?: boolean }>(), {
|
||||
touch: false,
|
||||
})
|
||||
|
||||
const shapes = [
|
||||
{ id: 'sharp' as const, rx: '0' },
|
||||
{ id: 'default' as const, rx: '3' },
|
||||
{ id: 'rounded' as const, rx: '8' },
|
||||
{ id: 'pill' as const, rx: '99' },
|
||||
{ id: 'sharp' as const, rx: '0', labelKey: 'components.radiusPicker.sharp' },
|
||||
{ id: 'default' as const, rx: '3', labelKey: 'components.radiusPicker.default' },
|
||||
{ id: 'rounded' as const, rx: '8', labelKey: 'components.radiusPicker.rounded' },
|
||||
{ id: 'pill' as const, rx: '99', labelKey: 'components.radiusPicker.pill' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div v-if="props.touch" class="grid grid-cols-4 gap-1.5">
|
||||
<Tooltip v-for="s in shapes" :key="s.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 items-center justify-center rounded-md border transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="themeStore.radius === s.id ? 'border-primary bg-primary/8 text-primary' : 'border-border bg-muted/30 text-muted-foreground'"
|
||||
:aria-label="t(s.labelKey)"
|
||||
@click="themeStore.setRadius(s.id)"
|
||||
>
|
||||
<span class="block h-4 w-8 border-2 border-current" :style="{ borderRadius: `${s.rx}px` }" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ t(s.labelKey) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1.5">
|
||||
<Tooltip v-for="s in shapes" :key="s.id">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-5 border-2 transition-colors focus:outline-none"
|
||||
:style="{
|
||||
borderRadius: `${s.rx}px`,
|
||||
borderColor: themeStore.radius === s.id ? 'var(--primary)' : 'var(--muted-foreground)',
|
||||
opacity: themeStore.radius === s.id ? '1' : '0.5',
|
||||
}"
|
||||
:aria-label="t(s.labelKey)"
|
||||
@click="themeStore.setRadius(s.id)"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ s.id }}</TooltipContent>
|
||||
<TooltipContent>{{ t(s.labelKey) }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const { t } = useI18n()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
function handleSurfaceBrightnessInput(event: Event) {
|
||||
themeStore.setBrightness(Number((event.target as HTMLInputElement).value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
:value="themeStore.brightness"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
class="w-full accent-primary cursor-pointer"
|
||||
:aria-label="t('settings.appearance.theme.surfaceBrightness.label')"
|
||||
:aria-valuetext="t('components.appHeader.surfaceBrightnessValue', { value: formatNumber(themeStore.brightness) })"
|
||||
@input="handleSurfaceBrightnessInput"
|
||||
/>
|
||||
<span class="w-10 shrink-0 text-right text-[13px] tabular-nums text-muted-foreground">
|
||||
{{ t('components.appHeader.surfaceBrightnessValue', { value: formatNumber(themeStore.brightness) }) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,6 +5,9 @@ import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const { t } = useI18n()
|
||||
const themeStore = useThemeStore()
|
||||
const props = withDefaults(defineProps<{ touch?: boolean }>(), {
|
||||
touch: false,
|
||||
})
|
||||
|
||||
function handleLightTheme() {
|
||||
themeStore.setTheme('light')
|
||||
@@ -20,24 +23,36 @@ function handleSystemTheme() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1 p-1 rounded-md border border-border bg-muted/50 w-fit">
|
||||
<div class="flex items-center rounded-md border border-border bg-muted/50 p-1" :class="props.touch ? 'w-full gap-0.5' : 'w-fit gap-1'">
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-[10px] font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="themeStore.theme === 'light' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="[
|
||||
props.touch ? 'h-9 min-w-0 flex-1 justify-center px-1.5 text-xs' : 'px-2.5 py-1 text-[10px]',
|
||||
themeStore.theme === 'light' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
]"
|
||||
@click="handleLightTheme"
|
||||
>
|
||||
<Sun :size="12" /> {{ t('components.themePicker.light') }}
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-[10px] font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="themeStore.theme === 'dark' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="[
|
||||
props.touch ? 'h-9 min-w-0 flex-1 justify-center px-1.5 text-xs' : 'px-2.5 py-1 text-[10px]',
|
||||
themeStore.theme === 'dark' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
]"
|
||||
@click="handleDarkTheme"
|
||||
>
|
||||
<Moon :size="12" /> {{ t('components.themePicker.dark') }}
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-[10px] font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="themeStore.theme === 'system' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:class="[
|
||||
props.touch ? 'h-9 min-w-0 flex-1 justify-center px-1.5 text-xs' : 'px-2.5 py-1 text-[10px]',
|
||||
themeStore.theme === 'system' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
]"
|
||||
@click="handleSystemTheme"
|
||||
>
|
||||
<Monitor :size="12" /> {{ t('components.themePicker.system') }}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { reactive } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SurfaceBrightnessPicker from '../SurfaceBrightnessPicker.vue'
|
||||
|
||||
const themeStore = reactive({
|
||||
brightness: 35,
|
||||
setBrightness: vi.fn<(brightness: number) => void>((brightness: number) => {
|
||||
themeStore.brightness = brightness
|
||||
}),
|
||||
})
|
||||
|
||||
vi.mock('@/stores/theme', () => ({
|
||||
useThemeStore: () => themeStore,
|
||||
}))
|
||||
|
||||
describe('SurfaceBrightnessPicker', () => {
|
||||
beforeEach(() => {
|
||||
themeStore.brightness = 35
|
||||
themeStore.setBrightness.mockClear()
|
||||
})
|
||||
|
||||
it('renders the current brightness as an accessible percentage', () => {
|
||||
const wrapper = mount(SurfaceBrightnessPicker)
|
||||
const input = wrapper.get('input[type="range"]')
|
||||
|
||||
expect(input.attributes('min')).toBe('0')
|
||||
expect(input.attributes('max')).toBe('100')
|
||||
expect(input.attributes('step')).toBe('5')
|
||||
expect(input.attributes('aria-label')).toBe('Surface brightness')
|
||||
expect(input.attributes('aria-valuetext')).toBe('35%')
|
||||
expect(wrapper.text()).toBe('35%')
|
||||
})
|
||||
|
||||
it('updates the shared theme brightness preference', async () => {
|
||||
const wrapper = mount(SurfaceBrightnessPicker)
|
||||
|
||||
await wrapper.get('input[type="range"]').setValue(60)
|
||||
|
||||
expect(themeStore.setBrightness).toHaveBeenCalledWith(60)
|
||||
expect(wrapper.text()).toBe('60%')
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Bell, BellOff, CheckCheck, Trash2 } from '@lucide/vue'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useNotifications } from '../composables/useNotifications'
|
||||
import NotificationItemVue from './NotificationItem.vue'
|
||||
@@ -63,7 +63,10 @@ function handleLoadMore() {
|
||||
|
||||
<SheetContent side="right" class="w-[90vw] sm:max-w-md p-0 flex flex-col gap-0">
|
||||
<SheetHeader class="flex flex-row items-center justify-between border-b px-4 py-3 h-14 shrink-0 space-y-0">
|
||||
<SheetTitle class="text-sm font-semibold text-foreground">{{ t('notifications.title') }}</SheetTitle>
|
||||
<div>
|
||||
<SheetTitle class="text-sm font-semibold text-foreground">{{ t('notifications.title') }}</SheetTitle>
|
||||
<SheetDescription class="sr-only">{{ t('notifications.description') }}</SheetDescription>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 pr-8">
|
||||
<Button v-if="unreadCount > 0" variant="ghost" size="sm" class="h-7 gap-1.5 text-xs text-muted-foreground" @click="handleMarkAllRead">
|
||||
<CheckCheck :size="14" />
|
||||
|
||||
@@ -4754,6 +4754,7 @@
|
||||
"annotations": "Annotations",
|
||||
"uploadBooks": "Upload books",
|
||||
"appearance": "Appearance",
|
||||
"appearanceDescription": "Choose theme, accent, corner radius, surface, and background options.",
|
||||
"theme": "Theme",
|
||||
"accent": "Accent",
|
||||
"radius": "Radius",
|
||||
@@ -4774,7 +4775,8 @@
|
||||
"cta": "Star BookOrbit on GitHub"
|
||||
},
|
||||
"surfaceOpacity": "Surface opacity",
|
||||
"surfaceOpacityValue": "{value}%"
|
||||
"surfaceOpacityValue": "{value}%",
|
||||
"surfaceBrightnessValue": "{value}%"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -4912,6 +4914,12 @@
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
},
|
||||
"radiusPicker": {
|
||||
"sharp": "Sharp corners",
|
||||
"default": "Default corners",
|
||||
"rounded": "Rounded corners",
|
||||
"pill": "Pill-shaped corners"
|
||||
},
|
||||
"languagePicker": {
|
||||
"searchPlaceholder": "Search languages",
|
||||
"suggested": "Suggested",
|
||||
@@ -6145,6 +6153,7 @@
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Review and manage your notifications.",
|
||||
"markAllRead": "Mark all read",
|
||||
"clear": "Clear",
|
||||
"empty": "No notifications yet",
|
||||
|
||||
@@ -166,7 +166,7 @@ export const routes: RouteRecordRaw[] = [
|
||||
path: 'account/privacy',
|
||||
name: 'settings-account-privacy',
|
||||
component: () => import('@/features/settings/PrivacySharingSettings.vue'),
|
||||
meta: { title: () => t('titles.account.privacy') },
|
||||
meta: { maxWidth: 'max-w-4xl', title: () => t('titles.account.privacy') },
|
||||
},
|
||||
{
|
||||
path: 'account/notifications',
|
||||
@@ -285,7 +285,7 @@ export const routes: RouteRecordRaw[] = [
|
||||
path: 'metadata/providers',
|
||||
name: 'settings-metadata-providers',
|
||||
component: () => import('@/features/settings/metadata-preferences/MetadataPreferencesSettings.vue'),
|
||||
meta: { maxWidth: 'max-w-3xl', title: () => t('titles.metadata.providers') },
|
||||
meta: { maxWidth: 'max-w-4xl', title: () => t('titles.metadata.providers') },
|
||||
},
|
||||
{
|
||||
path: 'metadata/field-rules',
|
||||
|
||||
@@ -18,6 +18,17 @@ const SEARCH_ENTRY_POINTS = [
|
||||
'modules/series/series.repository.ts',
|
||||
] as const;
|
||||
|
||||
// Entry points that turn a free-text term typed by a user into a LIKE pattern. Stored entity
|
||||
// names are whitespace-normalized, and unaccent() does not touch whitespace, so these have to
|
||||
// collapse the term the same way or a pasted non-breaking space matches nothing.
|
||||
const FREE_TEXT_SEARCH_ENTRY_POINTS = [
|
||||
'modules/authors/authors.repository.ts',
|
||||
'modules/book/book-query-builder.service.ts',
|
||||
'modules/catalog/catalog.service.ts',
|
||||
'modules/opds/opds-book.service.ts',
|
||||
'modules/series/series.repository.ts',
|
||||
] as const;
|
||||
|
||||
describe('accent-insensitive search entry points', () => {
|
||||
it.each(SEARCH_ENTRY_POINTS)('%s uses the shared accent-insensitive predicate', (relativePath) => {
|
||||
const source = readFileSync(join(__dirname, '..', '..', relativePath), 'utf8');
|
||||
@@ -26,4 +37,12 @@ describe('accent-insensitive search entry points', () => {
|
||||
expect(source).not.toMatch(/\bilike\s*\(/i);
|
||||
expect(source).not.toMatch(/\bILIKE\b/);
|
||||
});
|
||||
|
||||
it.each(FREE_TEXT_SEARCH_ENTRY_POINTS)('%s builds its search pattern through the shared helper', (relativePath) => {
|
||||
const source = readFileSync(join(__dirname, '..', '..', relativePath), 'utf8');
|
||||
|
||||
expect(source).toContain('buildSearchPattern');
|
||||
expect(source).not.toMatch(/replace\(\s*\/\[[%_\\\\]/);
|
||||
expect(source).not.toMatch(/function escapeLikePattern/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PgDialect, pgTable, text } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { accentInsensitiveIlike } from './accent-insensitive-search.utils';
|
||||
import { accentInsensitiveIlike, buildSearchPattern, escapeLikePattern } from './accent-insensitive-search.utils';
|
||||
|
||||
const records = pgTable('records', {
|
||||
name: text('name'),
|
||||
@@ -24,3 +24,40 @@ describe('accentInsensitiveIlike', () => {
|
||||
expect(query.params).toEqual(['%100\\%\\_%']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeLikePattern', () => {
|
||||
it('escapes every LIKE metacharacter', () => {
|
||||
expect(escapeLikePattern('100%_off\\now')).toBe('100\\%\\_off\\\\now');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSearchPattern', () => {
|
||||
it('wraps the term in wildcards', () => {
|
||||
expect(buildSearchPattern('Dan Brown')).toBe('%Dan Brown%');
|
||||
});
|
||||
|
||||
// unaccent() leaves whitespace alone, so a term that is not collapsed to the stored
|
||||
// normalized form matches nothing. See the 0068 migration.
|
||||
it.each([
|
||||
['non-breaking space', 'Dan\u00A0Brown'],
|
||||
['doubled space', 'Dan Brown'],
|
||||
['tab', 'Dan\tBrown'],
|
||||
['newline', 'Dan\nBrown'],
|
||||
['ideographic space', 'Dan\u3000Brown'],
|
||||
['surrounding whitespace', ' Dan Brown '],
|
||||
])('collapses %s to a single space', (_label, term) => {
|
||||
expect(buildSearchPattern(term)).toBe('%Dan Brown%');
|
||||
});
|
||||
|
||||
it('escapes LIKE metacharacters in the term', () => {
|
||||
expect(buildSearchPattern('50%_x')).toBe('%50\\%\\_x%');
|
||||
});
|
||||
|
||||
it('escapes a backslash before it can consume the next character', () => {
|
||||
expect(buildSearchPattern('a\\b')).toBe('%a\\\\b%');
|
||||
});
|
||||
|
||||
it('returns a bare wildcard for a whitespace-only term', () => {
|
||||
expect(buildSearchPattern(' ')).toBe('%%');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { SQL, SQLWrapper, sql } from 'drizzle-orm';
|
||||
|
||||
import { normalizeMetadataText } from './metadata-text-normalize.utils';
|
||||
|
||||
export function accentInsensitiveIlike(value: SQLWrapper, pattern: string): SQL {
|
||||
return sql`public.bookorbit_unaccent(${value}) ILIKE public.bookorbit_unaccent(${pattern})`;
|
||||
}
|
||||
|
||||
export function escapeLikePattern(value: string): string {
|
||||
return value.replace(/[\\%_]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Stored entity names are whitespace-normalized, so the term has to be too. A pasted
|
||||
// non-breaking space or a stray double space would otherwise match nothing at all.
|
||||
export function buildSearchPattern(term: string): string {
|
||||
return `%${escapeLikePattern(normalizeMetadataText(term) ?? '')}%`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
-- Entity names written before whitespace normalization existed can hold a non-breaking space,
|
||||
-- a tab, a newline or a doubled space. HTML collapses all of those, so the name looks correct in
|
||||
-- the UI while `name ILIKE '%typed term%'` never matches it. Collapse the stored names to the
|
||||
-- form normalizeMetadataTextSql() produces, merging rows that collide once collapsed.
|
||||
--
|
||||
-- Grouping is on the whitespace-collapsed name only, not lowercased: search is already
|
||||
-- case-insensitive, so case variants are not part of this defect and are left alone.
|
||||
|
||||
-- ── authors ──────────────────────────────────────────────────────────────────
|
||||
WITH normalized AS (
|
||||
SELECT id, name, btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')) AS norm
|
||||
FROM authors
|
||||
), canonical AS (
|
||||
SELECT id, first_value(id) OVER (PARTITION BY norm ORDER BY (name = norm) DESC, id ASC) AS canonical_id
|
||||
FROM normalized WHERE norm <> ''
|
||||
), duplicates AS (
|
||||
SELECT id AS duplicate_id, canonical_id FROM canonical WHERE id <> canonical_id
|
||||
)
|
||||
INSERT INTO book_authors (book_id, author_id, display_order)
|
||||
SELECT DISTINCT ON (ba.book_id, d.canonical_id) ba.book_id, d.canonical_id, ba.display_order
|
||||
FROM book_authors ba
|
||||
JOIN duplicates d ON d.duplicate_id = ba.author_id
|
||||
ORDER BY ba.book_id, d.canonical_id, ba.display_order ASC
|
||||
ON CONFLICT (book_id, author_id) DO NOTHING;--> statement-breakpoint
|
||||
|
||||
WITH normalized AS (
|
||||
SELECT id, name, btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')) AS norm
|
||||
FROM authors
|
||||
), canonical AS (
|
||||
SELECT id, first_value(id) OVER (PARTITION BY norm ORDER BY (name = norm) DESC, id ASC) AS canonical_id
|
||||
FROM normalized WHERE norm <> ''
|
||||
), duplicates AS (
|
||||
SELECT id AS duplicate_id FROM canonical WHERE id <> canonical_id
|
||||
)
|
||||
DELETE FROM book_authors ba USING duplicates d WHERE ba.author_id = d.duplicate_id;--> statement-breakpoint
|
||||
|
||||
-- author_enrichment_queue cascades; a merged-away author simply loses its queued entry.
|
||||
WITH normalized AS (
|
||||
SELECT id, name, btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')) AS norm
|
||||
FROM authors
|
||||
), canonical AS (
|
||||
SELECT id, first_value(id) OVER (PARTITION BY norm ORDER BY (name = norm) DESC, id ASC) AS canonical_id
|
||||
FROM normalized WHERE norm <> ''
|
||||
), duplicates AS (
|
||||
SELECT id AS duplicate_id FROM canonical WHERE id <> canonical_id
|
||||
)
|
||||
DELETE FROM authors a USING duplicates d WHERE a.id = d.duplicate_id;--> statement-breakpoint
|
||||
|
||||
UPDATE authors SET
|
||||
name = btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')),
|
||||
sort_name = NULLIF(btrim(regexp_replace(replace(COALESCE(sort_name, ''), chr(160), ' '), '[[:space:]]+', ' ', 'g')), '')
|
||||
WHERE btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')) <> ''
|
||||
AND (
|
||||
name <> btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g'))
|
||||
OR sort_name IS DISTINCT FROM NULLIF(btrim(regexp_replace(replace(COALESCE(sort_name, ''), chr(160), ' '), '[[:space:]]+', ' ', 'g')), '')
|
||||
);--> statement-breakpoint
|
||||
|
||||
-- ── narrators ────────────────────────────────────────────────────────────────
|
||||
WITH normalized AS (
|
||||
SELECT id, name, btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')) AS norm
|
||||
FROM narrators
|
||||
), canonical AS (
|
||||
SELECT id, first_value(id) OVER (PARTITION BY norm ORDER BY (name = norm) DESC, id ASC) AS canonical_id
|
||||
FROM normalized WHERE norm <> ''
|
||||
), duplicates AS (
|
||||
SELECT id AS duplicate_id, canonical_id FROM canonical WHERE id <> canonical_id
|
||||
)
|
||||
INSERT INTO book_narrators (book_id, narrator_id, display_order)
|
||||
SELECT DISTINCT ON (bn.book_id, d.canonical_id) bn.book_id, d.canonical_id, bn.display_order
|
||||
FROM book_narrators bn
|
||||
JOIN duplicates d ON d.duplicate_id = bn.narrator_id
|
||||
ORDER BY bn.book_id, d.canonical_id, bn.display_order ASC
|
||||
ON CONFLICT (book_id, narrator_id) DO NOTHING;--> statement-breakpoint
|
||||
|
||||
WITH normalized AS (
|
||||
SELECT id, name, btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')) AS norm
|
||||
FROM narrators
|
||||
), canonical AS (
|
||||
SELECT id, first_value(id) OVER (PARTITION BY norm ORDER BY (name = norm) DESC, id ASC) AS canonical_id
|
||||
FROM normalized WHERE norm <> ''
|
||||
), duplicates AS (
|
||||
SELECT id AS duplicate_id FROM canonical WHERE id <> canonical_id
|
||||
)
|
||||
DELETE FROM book_narrators bn USING duplicates d WHERE bn.narrator_id = d.duplicate_id;--> statement-breakpoint
|
||||
|
||||
WITH normalized AS (
|
||||
SELECT id, name, btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')) AS norm
|
||||
FROM narrators
|
||||
), canonical AS (
|
||||
SELECT id, first_value(id) OVER (PARTITION BY norm ORDER BY (name = norm) DESC, id ASC) AS canonical_id
|
||||
FROM normalized WHERE norm <> ''
|
||||
), duplicates AS (
|
||||
SELECT id AS duplicate_id FROM canonical WHERE id <> canonical_id
|
||||
)
|
||||
DELETE FROM narrators n USING duplicates d WHERE n.id = d.duplicate_id;--> statement-breakpoint
|
||||
|
||||
UPDATE narrators SET
|
||||
name = btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')),
|
||||
sort_name = NULLIF(btrim(regexp_replace(replace(COALESCE(sort_name, ''), chr(160), ' '), '[[:space:]]+', ' ', 'g')), '')
|
||||
WHERE btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')) <> ''
|
||||
AND (
|
||||
name <> btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g'))
|
||||
OR sort_name IS DISTINCT FROM NULLIF(btrim(regexp_replace(replace(COALESCE(sort_name, ''), chr(160), ' '), '[[:space:]]+', ' ', 'g')), '')
|
||||
);--> statement-breakpoint
|
||||
|
||||
-- ── genres ───────────────────────────────────────────────────────────────────
|
||||
WITH normalized AS (
|
||||
SELECT id, name, btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')) AS norm
|
||||
FROM genres
|
||||
), canonical AS (
|
||||
SELECT id, first_value(id) OVER (PARTITION BY norm ORDER BY (name = norm) DESC, id ASC) AS canonical_id
|
||||
FROM normalized WHERE norm <> ''
|
||||
), duplicates AS (
|
||||
SELECT id AS duplicate_id, canonical_id FROM canonical WHERE id <> canonical_id
|
||||
)
|
||||
INSERT INTO book_genres (book_id, genre_id)
|
||||
SELECT DISTINCT bg.book_id, d.canonical_id
|
||||
FROM book_genres bg
|
||||
JOIN duplicates d ON d.duplicate_id = bg.genre_id
|
||||
ON CONFLICT (book_id, genre_id) DO NOTHING;--> statement-breakpoint
|
||||
|
||||
WITH normalized AS (
|
||||
SELECT id, name, btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')) AS norm
|
||||
FROM genres
|
||||
), canonical AS (
|
||||
SELECT id, first_value(id) OVER (PARTITION BY norm ORDER BY (name = norm) DESC, id ASC) AS canonical_id
|
||||
FROM normalized WHERE norm <> ''
|
||||
), duplicates AS (
|
||||
SELECT id AS duplicate_id, canonical_id FROM canonical WHERE id <> canonical_id
|
||||
)
|
||||
INSERT INTO user_content_filter_genres (user_id, filter_type, genre_id)
|
||||
SELECT DISTINCT ucf.user_id, ucf.filter_type, d.canonical_id
|
||||
FROM user_content_filter_genres ucf
|
||||
JOIN duplicates d ON d.duplicate_id = ucf.genre_id
|
||||
ON CONFLICT (user_id, filter_type, genre_id) DO NOTHING;--> statement-breakpoint
|
||||
|
||||
WITH normalized AS (
|
||||
SELECT id, name, btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')) AS norm
|
||||
FROM genres
|
||||
), canonical AS (
|
||||
SELECT id, first_value(id) OVER (PARTITION BY norm ORDER BY (name = norm) DESC, id ASC) AS canonical_id
|
||||
FROM normalized WHERE norm <> ''
|
||||
), duplicates AS (
|
||||
SELECT id AS duplicate_id FROM canonical WHERE id <> canonical_id
|
||||
)
|
||||
DELETE FROM genres g USING duplicates d WHERE g.id = d.duplicate_id;--> statement-breakpoint
|
||||
|
||||
UPDATE genres SET name = btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g'))
|
||||
WHERE name <> btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g'))
|
||||
AND btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')) <> '';--> statement-breakpoint
|
||||
|
||||
-- ── tags ─────────────────────────────────────────────────────────────────────
|
||||
WITH normalized AS (
|
||||
SELECT id, name, btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')) AS norm
|
||||
FROM tags
|
||||
), canonical AS (
|
||||
SELECT id, first_value(id) OVER (PARTITION BY norm ORDER BY (name = norm) DESC, id ASC) AS canonical_id
|
||||
FROM normalized WHERE norm <> ''
|
||||
), duplicates AS (
|
||||
SELECT id AS duplicate_id, canonical_id FROM canonical WHERE id <> canonical_id
|
||||
)
|
||||
INSERT INTO book_tags (book_id, tag_id)
|
||||
SELECT DISTINCT bt.book_id, d.canonical_id
|
||||
FROM book_tags bt
|
||||
JOIN duplicates d ON d.duplicate_id = bt.tag_id
|
||||
ON CONFLICT (book_id, tag_id) DO NOTHING;--> statement-breakpoint
|
||||
|
||||
WITH normalized AS (
|
||||
SELECT id, name, btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')) AS norm
|
||||
FROM tags
|
||||
), canonical AS (
|
||||
SELECT id, first_value(id) OVER (PARTITION BY norm ORDER BY (name = norm) DESC, id ASC) AS canonical_id
|
||||
FROM normalized WHERE norm <> ''
|
||||
), duplicates AS (
|
||||
SELECT id AS duplicate_id, canonical_id FROM canonical WHERE id <> canonical_id
|
||||
)
|
||||
INSERT INTO user_content_filter_tags (user_id, filter_type, tag_id)
|
||||
SELECT DISTINCT ucf.user_id, ucf.filter_type, d.canonical_id
|
||||
FROM user_content_filter_tags ucf
|
||||
JOIN duplicates d ON d.duplicate_id = ucf.tag_id
|
||||
ON CONFLICT (user_id, filter_type, tag_id) DO NOTHING;--> statement-breakpoint
|
||||
|
||||
WITH normalized AS (
|
||||
SELECT id, name, btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')) AS norm
|
||||
FROM tags
|
||||
), canonical AS (
|
||||
SELECT id, first_value(id) OVER (PARTITION BY norm ORDER BY (name = norm) DESC, id ASC) AS canonical_id
|
||||
FROM normalized WHERE norm <> ''
|
||||
), duplicates AS (
|
||||
SELECT id AS duplicate_id FROM canonical WHERE id <> canonical_id
|
||||
)
|
||||
DELETE FROM tags t USING duplicates d WHERE t.id = d.duplicate_id;--> statement-breakpoint
|
||||
|
||||
UPDATE tags SET name = btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g'))
|
||||
WHERE name <> btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g'))
|
||||
AND btrim(regexp_replace(replace(name, chr(160), ' '), '[[:space:]]+', ' ', 'g')) <> '';--> statement-breakpoint
|
||||
|
||||
-- Merging author rows and rewriting their names invalidates the denormalized sort key on books.
|
||||
UPDATE books b SET primary_author_sort_name = (
|
||||
SELECT NULLIF(btrim(COALESCE(a.sort_name, a.name)), '')
|
||||
FROM book_authors ba INNER JOIN authors a ON a.id = ba.author_id
|
||||
WHERE ba.book_id = b.id
|
||||
ORDER BY ba.display_order ASC, ba.author_id ASC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE b.primary_author_sort_name IS DISTINCT FROM (
|
||||
SELECT NULLIF(btrim(COALESCE(a.sort_name, a.name)), '')
|
||||
FROM book_authors ba INNER JOIN authors a ON a.id = ba.author_id
|
||||
WHERE ba.book_id = b.id
|
||||
ORDER BY ba.display_order ASC, ba.author_id ASC
|
||||
LIMIT 1
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -477,6 +477,13 @@
|
||||
"when": 1786840894954,
|
||||
"tag": "0067_add_reading_progress_last_read_at",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 68,
|
||||
"version": "7",
|
||||
"when": 1786843782210,
|
||||
"tag": "0068_normalize_entity_name_whitespace",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import type { ContentFilterRules } from '@bookorbit/types';
|
||||
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
|
||||
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
|
||||
import { accentInsensitiveIlike, buildSearchPattern } from '../../common/utils/accent-insensitive-search.utils';
|
||||
import { DB } from '../../db';
|
||||
import { refreshPrimaryAuthorSortNamesForAuthors, refreshPrimaryAuthorSortNamesForBooks } from '../../db/book-author-sort-key';
|
||||
import * as schema from '../../db/schema';
|
||||
@@ -337,7 +337,7 @@ export class AuthorsRepository {
|
||||
}
|
||||
const query = params.q?.trim();
|
||||
if (query) {
|
||||
clauses.push(accentInsensitiveIlike(authors.name, `%${escapeLikePattern(query)}%`));
|
||||
clauses.push(accentInsensitiveIlike(authors.name, buildSearchPattern(query)));
|
||||
}
|
||||
if (params.hasPhoto !== undefined) {
|
||||
clauses.push(eq(authors.hasPhoto, params.hasPhoto));
|
||||
@@ -353,7 +353,3 @@ export class AuthorsRepository {
|
||||
return order === 'asc' ? asc(expression) : desc(expression);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeLikePattern(s: string): string {
|
||||
return s.replace(/[\\%_]/g, '\\$&');
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ vi.mock('drizzle-orm', () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../common/utils/accent-insensitive-search.utils', () => ({
|
||||
vi.mock('../../common/utils/accent-insensitive-search.utils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../common/utils/accent-insensitive-search.utils')>()),
|
||||
accentInsensitiveIlike: vi.fn((left: unknown, pattern: string) => ({ type: 'accentInsensitiveIlike', left, pattern })),
|
||||
}));
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
import { DB } from '../../db';
|
||||
import { isDateKey, resolveTimeZone, toDateKeyInTimeZone } from '../../common/utils/timezone.utils';
|
||||
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
|
||||
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
|
||||
import { accentInsensitiveIlike, buildSearchPattern, escapeLikePattern } from '../../common/utils/accent-insensitive-search.utils';
|
||||
import * as schema from '../../db/schema';
|
||||
import { BookSortBuilder, customMetadataValueColumn, type BookSortContext } from './book-sort-builder.service';
|
||||
import {
|
||||
@@ -91,7 +91,7 @@ export class BookQueryBuilder {
|
||||
}
|
||||
|
||||
buildQuickSearch(q: string): SQL {
|
||||
const pattern = `%${q.replace(/[%_\\]/g, '\\$&')}%`;
|
||||
const pattern = buildSearchPattern(q);
|
||||
|
||||
const existsAuthor = (() => {
|
||||
const sq = this.db
|
||||
@@ -1157,7 +1157,3 @@ export class BookQueryBuilder {
|
||||
return parts.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeLikePattern(s: string): string {
|
||||
return s.replace(/[\\%_]/g, '\\$&');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ vi.mock('drizzle-orm', () => ({
|
||||
sql: vi.fn((parts: TemplateStringsArray, ...values: unknown[]) => ({ type: 'sql', parts, values })),
|
||||
}));
|
||||
|
||||
vi.mock('../../common/utils/accent-insensitive-search.utils', () => ({
|
||||
vi.mock('../../common/utils/accent-insensitive-search.utils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../common/utils/accent-insensitive-search.utils')>()),
|
||||
accentInsensitiveIlike: vi.fn((left: unknown, pattern: string) => ({ type: 'accentInsensitiveIlike', left, pattern })),
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { and, eq, isNotNull } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
|
||||
import { accentInsensitiveIlike, buildSearchPattern } from '../../common/utils/accent-insensitive-search.utils';
|
||||
import * as schema from '../../db/schema';
|
||||
import { authors, bookMetadata, bookSeries, collections, genres, narrators, tags } from '../../db/schema';
|
||||
|
||||
@@ -16,7 +16,6 @@ type MetadataTextColumn = typeof bookMetadata.publisher | typeof bookMetadata.la
|
||||
|
||||
const DEFAULT_SEARCH_LIMIT = 15;
|
||||
const COLLECTION_SEARCH_LIMIT = 20;
|
||||
const LIKE_SPECIAL_CHARS = /[%_\\]/g;
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
@@ -104,7 +103,6 @@ export class CatalogService {
|
||||
const term = q.trim();
|
||||
if (!term) return null;
|
||||
|
||||
const escaped = term.replace(LIKE_SPECIAL_CHARS, '\\$&');
|
||||
return `%${escaped}%`;
|
||||
return buildSearchPattern(term);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1064,14 +1064,17 @@ describe('MetadataService', () => {
|
||||
throw new Error('unexpected table in insert');
|
||||
});
|
||||
|
||||
await service.replaceGenres(12, [' Fantasy ', 'Fantasy', '', 'X'.repeat(250)]);
|
||||
// The non-breaking space and the doubled space collapse onto the plain 'Fantasy' entry;
|
||||
// stored un-collapsed they would each become a separate row that no search can reach.
|
||||
await service.replaceGenres(12, [' Fantasy ', 'Fantasy', 'Fantasy\u00A0', 'Epic Fantasy', 'Epic\tFantasy', '', 'X'.repeat(250)]);
|
||||
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(db.delete).toHaveBeenCalledWith(bookGenres);
|
||||
expect(insertedGenres).toEqual(['Fantasy', 'X'.repeat(200)]);
|
||||
expect(insertedGenres).toEqual(['Fantasy', 'Epic Fantasy', 'X'.repeat(200)]);
|
||||
expect(insertedBookGenres).toEqual([
|
||||
{ bookId: 12, genreId: 77 },
|
||||
{ bookId: 12, genreId: 78 },
|
||||
{ bookId: 12, genreId: 79 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -781,7 +781,12 @@ export class MetadataService {
|
||||
}
|
||||
|
||||
private normalizeUniqueRelationNames(values: string[]): string[] {
|
||||
return [...new Set(values.map((value) => value.trim().substring(0, MAX_RELATION_NAME_LENGTH)).filter(Boolean))];
|
||||
const names = values.map((value) => {
|
||||
const truncated = normalizeMetadataText(value)?.substring(0, MAX_RELATION_NAME_LENGTH);
|
||||
// Truncating can strand a trailing space, so normalize again after the cut.
|
||||
return normalizeMetadataText(truncated);
|
||||
});
|
||||
return [...new Set(names.filter((name): name is string => name !== null))];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
emptyCounters,
|
||||
getSourceContributors,
|
||||
hasErrorCode,
|
||||
normalizeEntityName,
|
||||
normalizeReadStatus,
|
||||
pruneUndefined,
|
||||
toDate,
|
||||
@@ -147,6 +148,34 @@ describe('migration executor utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeEntityName', () => {
|
||||
// Search matches these names with ILIKE, and unaccent() does not touch whitespace, so an
|
||||
// imported name that keeps a non-breaking space is invisible to every search for it.
|
||||
it.each([
|
||||
['non-breaking space', 'Dan\u00A0Brown'],
|
||||
['doubled space', 'Dan Brown'],
|
||||
['tab', 'Dan\tBrown'],
|
||||
['newline between parts', 'Dan\nBrown'],
|
||||
['leading and trailing whitespace', ' Dan Brown\t'],
|
||||
])('collapses %s', (_label, name) => {
|
||||
expect(normalizeEntityName(name, 500)).toBe('Dan Brown');
|
||||
});
|
||||
|
||||
it('returns null for names that are empty once normalized', () => {
|
||||
expect(normalizeEntityName(' ', 500)).toBeNull();
|
||||
expect(normalizeEntityName(null, 500)).toBeNull();
|
||||
expect(normalizeEntityName(undefined, 500)).toBeNull();
|
||||
});
|
||||
|
||||
it('truncates to the column limit', () => {
|
||||
expect(normalizeEntityName('X'.repeat(250), 200)).toBe('X'.repeat(200));
|
||||
});
|
||||
|
||||
it('does not leave a trailing space when the cut lands on one', () => {
|
||||
expect(normalizeEntityName('abcd efgh', 5)).toBe('abcd');
|
||||
});
|
||||
});
|
||||
|
||||
it('derives source contributors from structured data then falls back to parsed legacy names', () => {
|
||||
const structured = getSourceContributors(
|
||||
[
|
||||
|
||||
@@ -138,11 +138,11 @@ export function buildContributorValues(contributor: {
|
||||
sortName?: string | null;
|
||||
description?: string | null;
|
||||
}): typeof schema.authors.$inferInsert {
|
||||
const name = normalizeMetadataText(contributor.name) ?? '';
|
||||
const sortName = normalizeMetadataText(contributor.sortName ?? contributor.name) ?? name;
|
||||
const name = normalizeEntityName(contributor.name, 500) ?? '';
|
||||
const sortName = normalizeEntityName(contributor.sortName ?? contributor.name, 500) ?? name;
|
||||
return pruneUndefined({
|
||||
name: truncateText(name, 500),
|
||||
sortName: truncateText(sortName, 500),
|
||||
name,
|
||||
sortName,
|
||||
description: contributor.description ?? undefined,
|
||||
});
|
||||
}
|
||||
@@ -242,6 +242,13 @@ export function truncateText(value: string, maxLength: number): string {
|
||||
return value.length <= maxLength ? value : value.slice(0, maxLength);
|
||||
}
|
||||
|
||||
// Entity names are matched by exact string in search, so an imported name has to land in the
|
||||
// same normalized form the rest of the app writes. Normalized twice because the truncation
|
||||
// between the two can strand a trailing space.
|
||||
export function normalizeEntityName(value: string | null | undefined, maxLength: number): string | null {
|
||||
return normalizeMetadataText(normalizeMetadataText(value)?.slice(0, maxLength));
|
||||
}
|
||||
|
||||
export function truncateNullableText(value: string | null | undefined, maxLength: number): string | null | undefined {
|
||||
if (value == null) return value;
|
||||
return truncateText(value, maxLength);
|
||||
|
||||
@@ -4,7 +4,14 @@ import type * as schema from '../../../db/schema';
|
||||
import { MigrationRepository } from '../migration.repository';
|
||||
import { MigrationImportRepository } from './migration-import.repository';
|
||||
import type { PlannerResult } from '../planner/planner.types';
|
||||
import { type RunStateCheck, buildContributorValues, buildMetadataPatch, emptyCounters, getSourceContributors, truncateText } from './executor-utils';
|
||||
import {
|
||||
type RunStateCheck,
|
||||
buildContributorValues,
|
||||
buildMetadataPatch,
|
||||
emptyCounters,
|
||||
getSourceContributors,
|
||||
normalizeEntityName,
|
||||
} from './executor-utils';
|
||||
|
||||
function isDomainAvailable(planned: PlannerResult, domain: keyof NonNullable<PlannerResult['execution']['sourceData']['availableDomains']>): boolean {
|
||||
return planned.execution.sourceData.availableDomains?.[domain] ?? true;
|
||||
@@ -143,10 +150,9 @@ export class SharedOverlaysImporter {
|
||||
}
|
||||
|
||||
for (const [index, contributor] of narrators.entries()) {
|
||||
const values = {
|
||||
name: truncateText(contributor.name, 500),
|
||||
sortName: truncateText(contributor.sortName ?? contributor.name, 500),
|
||||
};
|
||||
const name = normalizeEntityName(contributor.name, 500);
|
||||
if (!name) continue;
|
||||
const values = { name, sortName: normalizeEntityName(contributor.sortName ?? contributor.name, 500) ?? name };
|
||||
allNarrators.set(values.name, values);
|
||||
bookAssignments.push({ bookId: match.targetBookId, narratorName: values.name, displayOrder: index });
|
||||
}
|
||||
@@ -195,7 +201,8 @@ export class SharedOverlaysImporter {
|
||||
}
|
||||
|
||||
for (const name of sourceBook.genres) {
|
||||
const genreName = truncateText(name, 200);
|
||||
const genreName = normalizeEntityName(name, 200);
|
||||
if (!genreName) continue;
|
||||
allGenreNames.add(genreName);
|
||||
bookAssignments.push({ bookId: match.targetBookId, genreName });
|
||||
}
|
||||
@@ -242,7 +249,8 @@ export class SharedOverlaysImporter {
|
||||
}
|
||||
|
||||
for (const name of sourceBook.tags) {
|
||||
const tagName = truncateText(name, 200);
|
||||
const tagName = normalizeEntityName(name, 200);
|
||||
if (!tagName) continue;
|
||||
allTagNames.add(tagName);
|
||||
bookAssignments.push({ bookId: match.targetBookId, tagName });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SQL, and, count, eq, gt, inArray, or, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
|
||||
import { accentInsensitiveIlike, buildSearchPattern } from '../../common/utils/accent-insensitive-search.utils';
|
||||
import * as schema from '../../db/schema';
|
||||
import {
|
||||
authors,
|
||||
@@ -88,8 +88,6 @@ const READ_STATUS_BUCKETS = {
|
||||
|
||||
const ACTIVE_READ_STATUSES = [...READ_STATUS_BUCKETS.reading, ...READ_STATUS_BUCKETS.finished];
|
||||
|
||||
const LIKE_SPECIAL_CHARS = /[%_\\]/g;
|
||||
|
||||
export interface OpdsBookEntry {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -397,7 +395,7 @@ export class OpdsBookService {
|
||||
const term = q.trim();
|
||||
if (!term) return undefined;
|
||||
|
||||
const pattern = `%${term.replace(LIKE_SPECIAL_CHARS, '\\$&')}%`;
|
||||
const pattern = buildSearchPattern(term);
|
||||
const existsAuthor = (() => {
|
||||
const sq = this.db
|
||||
.select({ one: sql`1` })
|
||||
@@ -524,7 +522,7 @@ export class OpdsBookService {
|
||||
const where: SQL[] = [inArray(books.libraryId, accessibleIds)];
|
||||
const term = opts.q?.trim();
|
||||
if (term) {
|
||||
where.push(accentInsensitiveIlike(authors.name, `%${term.replace(LIKE_SPECIAL_CHARS, '\\$&')}%`));
|
||||
where.push(accentInsensitiveIlike(authors.name, buildSearchPattern(term)));
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
@@ -558,7 +556,7 @@ export class OpdsBookService {
|
||||
const where: SQL[] = [inArray(books.libraryId, accessibleIds)];
|
||||
const term = opts.q?.trim();
|
||||
if (term) {
|
||||
where.push(accentInsensitiveIlike(bookSeries.name, `%${term.replace(LIKE_SPECIAL_CHARS, '\\$&')}%`));
|
||||
where.push(accentInsensitiveIlike(bookSeries.name, buildSearchPattern(term)));
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import type { ContentFilterRules } from '@bookorbit/types';
|
||||
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
|
||||
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
|
||||
import { accentInsensitiveIlike, buildSearchPattern } from '../../common/utils/accent-insensitive-search.utils';
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { authors, bookAuthors, bookMetadata, books, bookSeries, bookSeriesMemberships, userBookStatus } from '../../db/schema';
|
||||
@@ -41,10 +41,6 @@ export class SeriesRepository {
|
||||
return inArray(books.libraryId, libraryIds);
|
||||
}
|
||||
|
||||
private escapeLikePattern(s: string): string {
|
||||
return s.replace(/[\\%_]/g, '\\$&');
|
||||
}
|
||||
|
||||
private buildAuthorNameMatchCondition(pattern: string): SQL {
|
||||
return sql`${books.id} IN (
|
||||
SELECT ${bookAuthors.bookId} FROM ${bookAuthors}
|
||||
@@ -71,13 +67,13 @@ export class SeriesRepository {
|
||||
const conditions: SQL[] = [libraryFilter, ...filterClauses];
|
||||
|
||||
if (params.q) {
|
||||
const qPattern = `%${this.escapeLikePattern(params.q)}%`;
|
||||
const qPattern = buildSearchPattern(params.q);
|
||||
const authorNameMatch = this.buildAuthorNameMatchCondition(qPattern);
|
||||
conditions.push(sql`(${accentInsensitiveIlike(bookSeries.name, qPattern)} OR ${authorNameMatch})`);
|
||||
}
|
||||
|
||||
if (params.author) {
|
||||
const authorPattern = `%${this.escapeLikePattern(params.author)}%`;
|
||||
const authorPattern = buildSearchPattern(params.author);
|
||||
conditions.push(this.buildAuthorNameMatchCondition(authorPattern));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user