feat(scanner): regex metadata extraction (#1669)

feat(metadata): extract metadata from file paths with regex rules (#1669)

Let libraries define ordered regex rules that read title, authors, series,
series number and dates from each book's path relative to its library folder.
Run rules in a bounded worker pool, honour source precedence and field locks,
and apply them on scans, uploads and watcher moves. Also retry unprobed M4B
tag writes with the mp4 muxer.

Closes #1351

Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
Dennis
2026-10-17 00:24:11 +02:00
committed by GitHub
parent fd10f89586
commit 428aefe8d1
50 changed files with 26474 additions and 74 deletions
@@ -62,7 +62,6 @@ html[data-style='nostalgia'] {
--glow-texture: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 84 84' fill='%23000'%3E%3Crect x='1' y='1' width='19' height='19' fill-opacity='0.044'/%3E%3Crect x='22' y='1' width='19' height='19' fill-opacity='0.058'/%3E%3Crect x='43' y='1' width='19' height='19' fill-opacity='0.042'/%3E%3Crect x='64' y='1' width='19' height='19' fill-opacity='0.035'/%3E%3Crect x='1' y='22' width='19' height='19' fill-opacity='0.059'/%3E%3Crect x='22' y='22' width='19' height='19' fill-opacity='0.02'/%3E%3Crect x='43' y='22' width='19' height='19' fill-opacity='0.054'/%3E%3Crect x='64' y='22' width='19' height='19' fill-opacity='0.03'/%3E%3Crect x='1' y='43' width='19' height='19' fill-opacity='0.024'/%3E%3Crect x='22' y='43' width='19' height='19' fill-opacity='0.023'/%3E%3Crect x='43' y='43' width='19' height='19' fill-opacity='0.031'/%3E%3Crect x='64' y='43' width='19' height='19' fill-opacity='0.052'/%3E%3Crect x='1' y='64' width='19' height='19' fill-opacity='0.026'/%3E%3Crect x='22' y='64' width='19' height='19' fill-opacity='0.042'/%3E%3Crect x='43' y='64' width='19' height='19' fill-opacity='0.045'/%3E%3Crect x='64' y='64' width='19' height='19' fill-opacity='0.034'/%3E%3C/svg%3E");
--glow-texture-size: 107px 107px;
--cover-hover-transform: translateY(-3px);
--format-chip-fill: 18%;
@@ -99,7 +98,6 @@ html[data-style='nostalgia'].dark {
--sidebar-count-foreground: oklch(from var(--primary) max(l, 0.72) c h);
--glow-texture: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 84 84' fill='%23fff'%3E%3Crect x='1' y='1' width='19' height='19' fill-opacity='0.056'/%3E%3Crect x='22' y='1' width='19' height='19' fill-opacity='0.042'/%3E%3Crect x='43' y='1' width='19' height='19' fill-opacity='0.082'/%3E%3Crect x='64' y='1' width='19' height='19' fill-opacity='0.036'/%3E%3Crect x='1' y='22' width='19' height='19' fill-opacity='0.073'/%3E%3Crect x='22' y='22' width='19' height='19' fill-opacity='0.059'/%3E%3Crect x='43' y='22' width='19' height='19' fill-opacity='0.035'/%3E%3Crect x='64' y='22' width='19' height='19' fill-opacity='0.071'/%3E%3Crect x='1' y='43' width='19' height='19' fill-opacity='0.033'/%3E%3Crect x='22' y='43' width='19' height='19' fill-opacity='0.065'/%3E%3Crect x='43' y='43' width='19' height='19' fill-opacity='0.036'/%3E%3Crect x='64' y='43' width='19' height='19' fill-opacity='0.037'/%3E%3Crect x='1' y='64' width='19' height='19' fill-opacity='0.064'/%3E%3Crect x='22' y='64' width='19' height='19' fill-opacity='0.096'/%3E%3Crect x='43' y='64' width='19' height='19' fill-opacity='0.04'/%3E%3Crect x='64' y='64' width='19' height='19' fill-opacity='0.048'/%3E%3C/svg%3E");
/* Solid chips in a deep shade of each format's hue with white ink, as the classic look wore them; at this
lightness every format clears 5.4:1. */
--format-chip-fill: 100%;
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowDown, ArrowLeftRight, ArrowUp, FileCode, FileText, GripVertical, Headphones, RotateCcw } from '@lucide/vue'
import { ArrowDown, ArrowUp, FileCode, FileText, GripVertical, Headphones, RotateCcw } from '@lucide/vue'
import type { RegexMetadataConfig } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import RegexMetadataDialog from './RegexMetadataDialog.vue'
import { DEFAULT_FORMAT_PRIORITY, isReadAlongFormatKey, withReadAlongFormatPriority } from '@bookorbit/types'
import { formatNumber } from '@/i18n/formatters'
import { formatColorVar } from '@/features/book/lib/format-colors'
@@ -18,6 +21,8 @@ const PREVIEW_ROWS = 6
const props = withDefaults(
defineProps<{
metadataPrecedence: string[]
regexMetadata?: RegexMetadataConfig | null
libraryId?: number
formatPriority: string[]
allowedFormats?: string[]
formatCounts?: Record<string, number> | null
@@ -26,10 +31,13 @@ const props = withDefaults(
)
const emit = defineEmits<{
'update:regexMetadata': [value: RegexMetadataConfig | null]
'update:regexOpen': [value: boolean]
'update:metadataPrecedence': [value: string[]]
'update:formatPriority': [value: string[]]
}>()
const regexOpen = ref(false)
const showAll = ref(false)
const dragKey = ref<string | null>(null)
const overKey = ref<string | null>(null)
@@ -146,8 +154,47 @@ function onDragEnd() {
overKey.value = null
}
function swapSources() {
emit('update:metadataPrecedence', [...sources.value].reverse())
function moveSourceUp(key: string) {
const next = [...sources.value]
const index = next.indexOf(key)
if (index <= 0) return
next.splice(index, 1)
next.splice(index - 1, 0, key)
emit('update:metadataPrecedence', next)
}
function moveSourceDown(key: string) {
const next = [...sources.value]
const index = next.indexOf(key)
if (index < 0 || index === next.length - 1) return
next.splice(index, 1)
next.splice(index + 1, 0, key)
emit('update:metadataPrecedence', next)
}
function openRegex() {
regexOpen.value = true
emit('update:regexOpen', true)
}
function closeRegex() {
regexOpen.value = false
emit('update:regexOpen', false)
}
function applyRegex(config: RegexMetadataConfig | null) {
emit('update:regexMetadata', config)
closeRegex()
}
function sourceTitle(source: string) {
return t(
source === 'regex'
? 'library.creator.metadata.regex.sourceTitle'
: `library.creator.metadata.source.${source === 'embedded' ? 'embeddedTitle' : 'opfTitle'}`,
)
}
function sourceHint(source: string) {
if (source === 'regex') return t(props.regexMetadata ? 'library.creator.metadata.regex.enabledHint' : 'library.creator.metadata.regex.disabledHint')
return t(`library.creator.metadata.source.${source === 'embedded' ? 'embeddedHint' : 'opfHint'}`)
}
</script>
@@ -246,42 +293,43 @@ function swapSources() {
</LibraryCreatorCard>
<LibraryCreatorCard :label="t('library.creator.metadata.source.title')" label-id="metadata-source-title">
<ol aria-labelledby="metadata-source-title" class="flex flex-col items-stretch gap-2 @lg:flex-row @lg:items-center">
<template v-for="(source, index) in sources" :key="source">
<li
class="flex min-w-0 flex-1 items-center gap-3 rounded-xl border px-3 py-2.5"
:class="index === 0 ? 'border-primary/55 bg-primary/7' : 'border-border bg-background'"
>
<span
class="flex size-5.5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold"
:class="index === 0 ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'"
>
{{ formatNumber(index + 1) }}
</span>
<component :is="source === 'embedded' ? FileText : FileCode" :size="16" class="shrink-0 text-foreground" aria-hidden="true" />
<span class="min-w-0">
<span class="block text-[13px] font-semibold text-foreground">
{{ source === 'embedded' ? t('library.creator.metadata.source.embeddedTitle') : t('library.creator.metadata.source.opfTitle') }}
</span>
<span class="block text-xs text-muted-foreground">
{{ source === 'embedded' ? t('library.creator.metadata.source.embeddedHint') : t('library.creator.metadata.source.opfHint') }}
</span>
</span>
</li>
<li v-if="index === 0" class="flex justify-center">
<button
type="button"
class="flex size-8 items-center justify-center rounded-full border border-border bg-card text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="t('library.creator.metadata.source.swap')"
:title="t('library.creator.metadata.source.swap')"
@click="swapSources"
>
<ArrowLeftRight :size="14" class="rotate-90 @lg:rotate-0" aria-hidden="true" />
</button>
</li>
</template>
<ol aria-labelledby="metadata-source-title" class="flex flex-col gap-2">
<li
v-for="(source, index) in sources"
:key="source"
class="flex min-w-0 flex-wrap items-center gap-3 rounded-xl border border-border bg-background px-3 py-2.5"
>
<span class="text-xs font-bold text-muted-foreground">{{ formatNumber(index + 1) }}</span>
<component :is="source === 'embedded' ? FileText : FileCode" :size="16" class="shrink-0" aria-hidden="true" />
<span class="min-w-0 flex-1">
<span class="block text-sm font-semibold">{{ sourceTitle(source) }}</span>
<span class="block text-xs text-muted-foreground">{{ sourceHint(source) }}</span>
</span>
<Button v-if="source === 'regex'" variant="outline" size="sm" @click="openRegex">{{
t('library.creator.metadata.regex.configure')
}}</Button>
<div class="flex gap-1">
<Button
variant="ghost"
size="icon"
:disabled="index === 0"
:aria-label="t('library.creator.metadata.regex.moveSourceUp', { source: sourceTitle(source) })"
@click="moveSourceUp(source)"
><ArrowUp
/></Button>
<Button
variant="ghost"
size="icon"
:disabled="index === sources.length - 1"
:aria-label="t('library.creator.metadata.regex.moveSourceDown', { source: sourceTitle(source) })"
@click="moveSourceDown(source)"
><ArrowDown
/></Button>
</div>
</li>
</ol>
<p class="mt-2.5 text-xs text-muted-foreground">{{ t('library.creator.metadata.source.hint') }}</p>
<p class="mt-2.5 text-xs text-muted-foreground">{{ t('library.creator.metadata.regex.precedenceHint') }}</p>
</LibraryCreatorCard>
<RegexMetadataDialog v-if="regexOpen" :config="regexMetadata ?? null" :library-id="libraryId" @close="closeRegex" @apply="applyRegex" />
</div>
</template>
@@ -183,6 +183,8 @@ function summaryFor(id: LibraryCreatorSectionId): string {
? t('library.creator.summary.scannerAll', { mode: modeLabel.value })
: t('library.creator.summary.scannerSome', { mode: modeLabel.value, count: form.allowedFormats.length })
case 'metadata':
if (form.regexMetadata && form.metadataPrecedence[0] === 'regex')
return t('library.creator.metadata.regex.summary', { format: leadFormat.value })
return form.metadataPrecedence[0] === 'opfFile'
? t('library.creator.summary.metadataOpf', { format: leadFormat.value })
: t('library.creator.summary.metadataFile', { format: leadFormat.value })
@@ -285,6 +287,8 @@ const sectionProps = computed(() => ({
},
metadata: {
metadataPrecedence: form.metadataPrecedence,
regexMetadata: form.regexMetadata ?? null,
libraryId: creator.editingLibraryId.value ?? undefined,
formatPriority: form.formatPriority,
allowedFormats: form.allowedFormats,
formatCounts: stats.value?.formatCounts ?? null,
@@ -495,6 +499,7 @@ const sectionListeners = {
'update:addedAtSource': (value: Library['addedAtSource']) => (form.addedAtSource = value),
recompute: handleRecomputeAddedAt,
check: handleCheckFolders,
'update:regexMetadata': (value: Library['regexMetadata']) => (form.regexMetadata = value),
'update:metadataPrecedence': (value: string[]) => (form.metadataPrecedence = value),
'update:formatPriority': (value: string[]) => (form.formatPriority = value),
'update:allowedFormats': (value: string[]) => (form.allowedFormats = value),
@@ -522,6 +527,7 @@ const sectionListeners = {
'update:fileWriteReadAlongEnabled': (value: boolean) => (form.fileWriteReadAlongEnabled = value),
'update:fileWriteReadAlongMaxFileSizeMb': (value: number) => (form.fileWriteReadAlongMaxFileSizeMb = value),
'update:pickerOpen': handleNestedModalChange,
'update:regexOpen': handleNestedModalChange,
}
useModal({
@@ -0,0 +1,239 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { ArrowDown, ArrowUp, Plus, Trash2 } from '@lucide/vue'
import { REGEX_METADATA_GROUPS, REGEX_METADATA_LIMITS, type RegexMetadataConfig } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { formatNumber } from '@/i18n/formatters'
import { useRegexMetadataEditor } from '../composables/useRegexMetadataEditor'
const props = defineProps<{ config: RegexMetadataConfig | null; libraryId?: number }>()
const emit = defineEmits<{ close: []; apply: [config: RegexMetadataConfig | null] }>()
const { t } = useI18n()
const {
validation,
validating,
validationError,
ruleErrors,
patternInvalid,
flagsInvalid,
generalDiagnostics,
validRuleCount,
validateRules,
enabled,
rules,
relativePath,
result,
busy,
error,
canApply,
canPreview,
addRule,
removeRule,
moveUp,
moveDown,
value,
preview,
} = useRegexMetadataEditor(props.config, props.libraryId)
const examplePattern = String.raw`(?<author>[^\/]+)\/(?<series>[^\/]+)\/(?<seriesIndex>\d+) - (?<title>.+) - \k<author>`
const examplePath = 'Donna Leon/Commissario Brunetti/01 - Venezianisches Finale - Donna Leon.epub'
const FIELD_LABEL_KEYS: Record<string, string> = {
title: 'title',
author: 'authors',
authors: 'authors',
series: 'series',
seriesName: 'series',
seriesIndex: 'seriesIndex',
publishedYear: 'publishedYear',
publishedDate: 'publishedDate',
}
const diagnosticParams = {
count: formatNumber(REGEX_METADATA_LIMITS.patternLength),
min: formatNumber(1),
max: formatNumber(REGEX_METADATA_LIMITS.rules),
}
function fieldLabel(field: string) {
const key = FIELD_LABEL_KEYS[field]
return key ? t(`library.creator.metadata.regex.fields.${key}`) : field
}
function diagnosticMessage(code: string) {
return t(`library.creator.metadata.regex.diagnostics.${code}`, diagnosticParams)
}
function close() {
emit('close')
}
function handleOpenChange(open: boolean) {
if (!open) close()
}
function apply() {
if (canApply.value) emit('apply', value())
}
</script>
<template>
<DialogRoot :open="true" @update:open="handleOpenChange">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-[70] bg-scrim" />
<DialogContent
class="fixed left-1/2 top-1/2 z-[70] flex max-h-[90dvh] w-[calc(100%-2rem)] max-w-3xl -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto rounded-lg border border-border bg-card p-5 shadow-xl"
>
<DialogTitle class="text-lg font-semibold">{{ t('library.creator.metadata.regex.title') }}</DialogTitle>
<DialogDescription class="text-sm text-muted-foreground">{{ t('library.creator.metadata.regex.description') }}</DialogDescription>
<p class="text-xs text-muted-foreground">{{ t('library.creator.metadata.regex.primaryFileHint') }}</p>
<div class="flex items-center gap-2 text-sm font-medium">
<ToggleSwitch v-model="enabled" aria-labelledby="regex-enable-label" />
<span id="regex-enable-label">{{ t('library.creator.metadata.regex.enable') }}</span>
</div>
<p class="text-xs text-muted-foreground">
{{ t('library.creator.metadata.regex.groups') }} <code>{{ REGEX_METADATA_GROUPS.join(', ') }}</code>
</p>
<p class="text-xs text-muted-foreground">{{ t('library.creator.metadata.regex.valuesHint') }}</p>
<section class="flex min-w-0 flex-col gap-2 rounded-md border border-border bg-muted p-3 text-xs">
<h3 class="font-medium">{{ t('library.creator.metadata.regex.exampleTitle') }}</h3>
<dl class="flex min-w-0 flex-col gap-1">
<dt class="text-muted-foreground">{{ t('library.creator.metadata.regex.sourceTitle') }}</dt>
<dd class="select-text break-all font-mono">{{ examplePattern }}</dd>
<dt class="mt-1 text-muted-foreground">{{ t('library.creator.metadata.regex.exampleTestPath') }}</dt>
<dd class="select-text break-all font-mono">{{ examplePath }}</dd>
</dl>
</section>
<fieldset v-if="enabled" class="flex min-w-0 flex-col gap-3">
<legend class="sr-only">{{ t('library.creator.metadata.regex.rules') }}</legend>
<div v-for="(rule, index) in rules" :key="index" class="flex min-w-0 flex-col gap-2 rounded-md border border-border p-3">
<div class="flex items-center justify-between gap-2">
<label :for="`regex-pattern-${index}`" class="text-sm font-medium">{{
t('library.creator.metadata.regex.rule', { number: formatNumber(index + 1) })
}}</label>
<div class="flex gap-1">
<Button
variant="ghost"
size="icon"
:disabled="index === 0"
:aria-label="t('library.creator.metadata.regex.moveUp')"
@click="moveUp(index)"
><ArrowUp
/></Button>
<Button
variant="ghost"
size="icon"
:disabled="index === rules.length - 1"
:aria-label="t('library.creator.metadata.regex.moveDown')"
@click="moveDown(index)"
><ArrowDown
/></Button>
<Button variant="ghost" size="icon" :aria-label="t('library.creator.metadata.regex.remove')" @click="removeRule(index)"
><Trash2
/></Button>
</div>
</div>
<div
class="flex min-w-0 flex-wrap items-center rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring"
:class="patternInvalid[index] ? 'border-destructive' : 'border-input'"
>
<span class="px-2 py-2 text-xs text-muted-foreground">{{ t('library.creator.metadata.regex.basePath') }} /</span>
<input
:id="`regex-pattern-${index}`"
v-model="rule.pattern"
:maxlength="REGEX_METADATA_LIMITS.patternLength"
:aria-describedby="`regex-extension-${index} regex-errors-${index}`"
:aria-invalid="patternInvalid[index] || undefined"
autocomplete="off"
spellcheck="false"
class="min-w-24 flex-1 bg-transparent px-2 py-2 font-mono text-sm outline-none"
/>
<span
:id="`regex-extension-${index}`"
:title="t('library.creator.metadata.regex.extensionHint')"
class="px-2 py-2 font-mono text-xs text-muted-foreground"
>.ext<span class="sr-only">: {{ t('library.creator.metadata.regex.extensionHint') }}</span></span
>
</div>
<label class="flex items-center gap-2 text-xs text-muted-foreground">
{{ t('library.creator.metadata.regex.flags') }}
<Input
v-model="rule.flags"
:aria-describedby="`regex-errors-${index}`"
:aria-invalid="flagsInvalid[index] || undefined"
maxlength="2"
class="w-20 font-mono"
autocomplete="off"
spellcheck="false"
/>
</label>
<div :id="`regex-errors-${index}`" aria-live="polite">
<p v-for="code in ruleErrors[index]" :key="code" class="text-xs text-destructive">
{{ diagnosticMessage(code) }}
</p>
</div>
</div>
<Button variant="outline" :disabled="rules.length >= REGEX_METADATA_LIMITS.rules" class="self-start" @click="addRule"
><Plus />{{ t('library.creator.metadata.regex.add') }}</Button
>
<label class="flex flex-col gap-1 text-sm">
{{ t('library.creator.metadata.regex.examplePath') }}
<Input
v-model="relativePath"
:maxlength="REGEX_METADATA_LIMITS.pathLength"
:placeholder="t('library.creator.metadata.regex.examplePlaceholder')"
/>
</label>
<Button variant="outline" :disabled="!canPreview" class="self-start" @click="preview">{{
t(busy ? 'library.creator.metadata.regex.previewing' : 'library.creator.metadata.regex.preview')
}}</Button>
</fieldset>
<div v-if="enabled" aria-live="polite" class="flex flex-col gap-2 text-sm">
<p v-if="!validRuleCount" class="text-destructive">{{ t('library.creator.metadata.regex.validationRuleCount', diagnosticParams) }}</p>
<p v-else-if="validating" class="text-muted-foreground">{{ t('library.creator.metadata.regex.validating') }}</p>
<p v-else-if="validation?.valid" class="text-muted-foreground">{{ t('library.creator.metadata.regex.validationSuccess') }}</p>
<p v-if="validationError" role="alert" class="text-destructive">{{ t('library.creator.metadata.regex.validationError') }}</p>
<p v-for="diagnostic in generalDiagnostics" :key="diagnostic.code" class="text-destructive">
{{ diagnosticMessage(diagnostic.code) }}
</p>
<Button
v-if="validationError || generalDiagnostics.length"
variant="outline"
class="self-start"
:disabled="validating"
@click="validateRules"
>{{ t('library.creator.metadata.regex.validationRetry') }}</Button
>
</div>
<p v-if="error" role="alert" class="text-sm text-destructive">{{ t('library.creator.metadata.regex.previewError') }}</p>
<div v-if="result" aria-live="polite" class="flex flex-col gap-2 rounded-md border border-border p-3 text-sm">
<p>
{{ t('library.creator.metadata.regex.actualInput') }} <code class="break-all">{{ result.inputPath }}</code>
</p>
<p>
{{
result.matched
? t('library.creator.metadata.regex.matched', { number: formatNumber((result.ruleIndex ?? 0) + 1) })
: t('library.creator.metadata.regex.noMatch')
}}
</p>
<dl v-if="result.matched" class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<template v-for="(fieldValue, field) in result.metadata" :key="field"
><dt class="text-xs text-muted-foreground">{{ fieldLabel(field) }}</dt>
<dd class="break-all">{{ Array.isArray(fieldValue) ? fieldValue.join('; ') : fieldValue }}</dd></template
>
</dl>
<p v-for="(diagnostic, index) in result.diagnostics" :key="index" class="text-destructive">
{{ diagnosticMessage(diagnostic.code) }} <span v-if="diagnostic.field">({{ fieldLabel(diagnostic.field) }})</span>
</p>
</div>
<p class="text-xs text-muted-foreground">{{ t('library.creator.metadata.regex.scanHint') }}</p>
<div class="flex justify-end gap-2">
<Button variant="outline" @click="close">{{ t('common.cancel') }}</Button>
<Button :disabled="!canApply" @click="apply">{{ t('library.creator.metadata.regex.apply') }}</Button>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
@@ -79,20 +79,20 @@ describe('LibraryCreatorMetadata', () => {
expect(wrapper.findAll('ol[aria-labelledby="primary-file-title"] > li')[1]!.text()).toContain('Not imported')
})
it('swaps which source is read first', async () => {
it('moves a source earlier in the precedence list', async () => {
const wrapper = mountMetadata({ formatPriority: ['epub'] })
await wrapper.get('button[aria-label="Swap the order"]').trigger('click')
await wrapper.get('button[aria-label="Move metadata.opf file up"]').trigger('click')
expect(wrapper.emitted('update:metadataPrecedence')).toEqual([[['opfFile', 'embedded']]])
expect(wrapper.emitted('update:metadataPrecedence')).toEqual([[['opfFile', 'embedded', 'regex']]])
})
it('fills in a source missing from an older saved list before swapping', async () => {
it('fills in a source missing from an older saved list before reordering', async () => {
const wrapper = mountMetadata({ formatPriority: ['epub'], metadataPrecedence: ['embedded'] })
expect(wrapper.text()).toContain('metadata.opf file')
await wrapper.get('button[aria-label="Swap the order"]').trigger('click')
await wrapper.get('button[aria-label="Move metadata.opf file up"]').trigger('click')
expect(wrapper.emitted('update:metadataPrecedence')).toEqual([[['opfFile', 'embedded']]])
expect(wrapper.emitted('update:metadataPrecedence')).toEqual([[['opfFile', 'embedded', 'regex']]])
})
})
@@ -0,0 +1,158 @@
import { effectScope } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { useRegexMetadataEditor } from '../useRegexMetadataEditor'
vi.mock('@/lib/api', () => ({ api: vi.fn<typeof import('@/lib/api').api>() }))
describe('useRegexMetadataEditor', () => {
const scopes: ReturnType<typeof effectScope>[] = []
afterEach(() => {
scopes.splice(0).forEach((scope) => scope.stop())
vi.useRealTimers()
vi.resetAllMocks()
})
function editor(config = { rules: [{ pattern: '(?<title>.+)$', flags: '' }] }) {
const scope = effectScope()
scopes.push(scope)
return scope.run(() => useRegexMetadataEditor(config, 42))!
}
it('edits an isolated draft and preserves the ordering on apply', () => {
const config = { rules: [{ pattern: '(?<title>.+)$', flags: '' }] }
const state = editor(config)
state.rules.value[0]!.pattern = '(?<series>.+)'
state.addRule()
state.rules.value[1]!.pattern = '(?<title>.+)'
state.moveUp(1)
expect(config.rules[0]!.pattern).toBe('(?<title>.+)$')
expect(state.value()?.rules.map((rule) => rule.pattern)).toEqual(['(?<title>.+)', '(?<series>.+)'])
state.enabled.value = false
expect(state.value()).toBeNull()
})
it('sends the unchanged pattern and example path with its extension', async () => {
const state = editor()
state.relativePath.value = 'Author/My.Book.epub'
vi.mocked(api).mockResolvedValue(
new Response(
JSON.stringify({
inputPath: 'Author/My.Book',
matched: true,
ruleIndex: 0,
metadata: { title: 'Author/My.Book' },
groups: {},
diagnostics: [],
}),
),
)
await state.preview()
const request = vi.mocked(api).mock.calls[0]!
expect(request[0]).toBe('/api/v1/libraries/regex-metadata/preview')
expect(JSON.parse(request[1]!.body as string)).toEqual({
config: { rules: [{ pattern: '(?<title>.+)$', flags: '' }] },
relativePath: 'Author/My.Book.epub',
libraryId: 42,
})
expect(state.result.value?.inputPath).toBe('Author/My.Book')
})
it('ignores an old response after the input changes', async () => {
const state = editor()
state.relativePath.value = 'Old.epub'
let resolve!: (response: Response) => void
vi.mocked(api).mockReturnValue(
new Promise((done) => {
resolve = done
}),
)
const pending = state.preview()
state.relativePath.value = 'New.epub'
resolve(new Response(JSON.stringify({ matched: true, inputPath: 'Old' })))
await pending
expect(state.result.value).toBeNull()
expect(state.busy.value).toBe(false)
})
it('shows failed requests and clears them when the draft changes', async () => {
const state = editor()
state.relativePath.value = '../outside.epub'
vi.mocked(api).mockResolvedValue(new Response('', { status: 400 }))
await state.preview()
expect(state.error.value).toBe(true)
state.relativePath.value = 'Inside.epub'
expect(state.error.value).toBe(false)
})
it('debounces validation and only allows applying the current validated rules', async () => {
vi.useFakeTimers()
vi.mocked(api).mockResolvedValue(new Response(JSON.stringify({ valid: true, diagnostics: [] })))
const state = editor()
expect(state.canApply.value).toBe(false)
await vi.advanceTimersByTimeAsync(399)
expect(api).not.toHaveBeenCalled()
state.rules.value[0]!.pattern = '(?<author>.+)'
await vi.advanceTimersByTimeAsync(400)
expect(api).toHaveBeenCalledTimes(1)
expect(JSON.parse(vi.mocked(api).mock.calls[0]![1]!.body as string)).toEqual({
config: { rules: [{ pattern: '(?<author>.+)', flags: '' }] },
libraryId: 42,
})
expect(state.canApply.value).toBe(true)
state.relativePath.value = 'No match.epub'
expect(state.canApply.value).toBe(true)
state.rules.value[0]!.pattern = '['
expect(state.canApply.value).toBe(false)
state.enabled.value = false
expect(state.canApply.value).toBe(true)
await vi.advanceTimersByTimeAsync(400)
expect(api).toHaveBeenCalledTimes(1)
})
it('ignores stale validation and remaps errors after rules are reordered', async () => {
vi.useFakeTimers()
let resolve!: (response: Response) => void
vi.mocked(api).mockReturnValueOnce(
new Promise((done) => {
resolve = done
}),
)
const state = editor({
rules: [
{ pattern: '[', flags: '' },
{ pattern: '(?<title>.+)', flags: '' },
],
})
await vi.advanceTimersByTimeAsync(400)
state.moveDown(0)
resolve(new Response(JSON.stringify({ valid: true, diagnostics: [] })))
await Promise.resolve()
expect(state.canApply.value).toBe(false)
vi.mocked(api).mockResolvedValueOnce(new Response(JSON.stringify({ valid: false, diagnostics: [{ code: 'invalid_pattern', ruleIndex: 1 }] })))
await vi.advanceTimersByTimeAsync(400)
expect(state.ruleErrors.value).toEqual([[], ['invalid_pattern']])
expect(state.canApply.value).toBe(false)
state.removeRule(1)
expect(state.ruleErrors.value).toEqual([[]])
expect(state.canApply.value).toBe(false)
})
it('reports local bounds without sending requests and allows retry after network and worker failures', async () => {
vi.useFakeTimers()
const state = editor({ rules: [{ pattern: '', flags: 'gg' }] })
await vi.advanceTimersByTimeAsync(400)
expect(api).not.toHaveBeenCalled()
expect(state.ruleErrors.value[0]).toEqual(['pattern_required', 'invalid_flags'])
state.rules.value = [{ pattern: '(?<title>.+)', flags: '' }]
vi.mocked(api).mockRejectedValueOnce(new Error('offline'))
await vi.advanceTimersByTimeAsync(400)
expect(state.validationError.value).toBe(true)
expect(state.canApply.value).toBe(false)
vi.mocked(api).mockResolvedValueOnce(new Response(JSON.stringify({ valid: false, diagnostics: [{ code: 'worker_failed' }] })))
await state.validateRules()
expect(state.generalDiagnostics.value).toEqual([{ code: 'worker_failed' }])
expect(state.canApply.value).toBe(false)
vi.mocked(api).mockResolvedValueOnce(new Response(JSON.stringify({ valid: true, diagnostics: [] })))
await state.validateRules()
expect(state.canApply.value).toBe(true)
})
})
@@ -12,11 +12,12 @@ import { coveringFolderPath, normalizeFolderPath } from './folder-paths'
export { DEFAULT_FORMAT_PRIORITY }
export const DEFAULT_METADATA_PRECEDENCE = ['embedded', 'opfFile']
export const DEFAULT_METADATA_PRECEDENCE = ['embedded', 'opfFile', 'regex']
export const METADATA_LABELS: Record<string, string> = {
embedded: 'Embedded metadata',
opfFile: 'OPF files',
regex: 'Regex metadata',
}
export type LibraryCreatorSectionId = 'details' | 'folders' | 'scanner' | 'metadata' | 'reading' | 'schedule' | 'fileWrite' | 'access'
@@ -41,6 +42,7 @@ function blankForm() {
watchLocalFolders: true,
autoScanCronExpression: null as string | null,
metadataPrecedence: [...DEFAULT_METADATA_PRECEDENCE],
regexMetadata: null as Library['regexMetadata'],
formatPriority: withReadAlongFormatPriority(DEFAULT_FORMAT_PRIORITY),
allowedFormats: [] as string[],
organizationMode: 'book_per_folder' as OrganizationMode,
@@ -161,6 +163,7 @@ export function useLibraryCreator() {
form.watchLocalFolders = library.watchLocalFolders ?? true
form.autoScanCronExpression = library.autoScanCronExpression ?? null
form.metadataPrecedence = [...library.metadataPrecedence]
form.regexMetadata = library.regexMetadata ? { rules: library.regexMetadata.rules.map((rule) => ({ ...rule })) } : null
const missing = DEFAULT_FORMAT_PRIORITY.filter((f) => !library.formatPriority.includes(f))
form.formatPriority = withReadAlongFormatPriority([...library.formatPriority, ...missing])
form.allowedFormats = [...library.allowedFormats]
@@ -0,0 +1,178 @@
import { computed, onScopeDispose, ref, watch } from 'vue'
import { REGEX_METADATA_LIMITS, type RegexMetadataConfig, type RegexMetadataPreview, type RegexMetadataValidation } from '@bookorbit/types'
import { api } from '@/lib/api'
export function useRegexMetadataEditor(config: RegexMetadataConfig | null, libraryId?: number) {
const enabled = ref(config !== null)
const rules = ref(config?.rules.map((rule) => ({ ...rule })) ?? [{ pattern: '', flags: '' }])
const relativePath = ref('')
const result = ref<RegexMetadataPreview | null>(null)
const busy = ref(false)
const error = ref(false)
let sequence = 0
let controller: AbortController | undefined
function invalidate() {
sequence++
controller?.abort()
busy.value = false
result.value = null
error.value = false
}
watch([rules, relativePath, enabled], invalidate, { deep: true, flush: 'sync' })
onScopeDispose(invalidate)
const validation = ref<RegexMetadataValidation | null>(null)
const validating = ref(false)
const validationError = ref(false)
let validationSequence = 0
let validationController: AbortController | undefined
let validationTimer: ReturnType<typeof setTimeout> | undefined
const localRuleErrors = computed(() =>
rules.value.map((rule) => {
const errors: string[] = []
if (!rule.pattern.length) errors.push('pattern_required')
else if (rule.pattern.length > REGEX_METADATA_LIMITS.patternLength) errors.push('pattern_too_long')
if (!/^(?:i?u?|ui)$/.test(rule.flags)) errors.push('invalid_flags')
return errors
}),
)
const validRuleCount = computed(() => rules.value.length > 0 && rules.value.length <= REGEX_METADATA_LIMITS.rules)
const valid = computed(() => validRuleCount.value && localRuleErrors.value.every((errors) => !errors.length))
const ruleErrors = computed(() =>
localRuleErrors.value.map((errors, index) => [
...errors,
...(validation.value?.diagnostics.filter((diagnostic) => diagnostic.ruleIndex === index).map((diagnostic) => diagnostic.code) ?? []),
]),
)
const patternInvalid = computed(() => ruleErrors.value.map((errors) => errors.some((code) => code !== 'invalid_flags')))
const flagsInvalid = computed(() => ruleErrors.value.map((errors) => errors.includes('invalid_flags')))
const generalDiagnostics = computed(() => validation.value?.diagnostics.filter((diagnostic) => diagnostic.ruleIndex === undefined) ?? [])
const canApply = computed(() => !enabled.value || (valid.value && validation.value?.valid === true && !validating.value && !validationError.value))
const canPreview = computed(() => enabled.value && valid.value && !!relativePath.value && !busy.value)
function invalidateValidation() {
validationSequence++
clearTimeout(validationTimer)
validationController?.abort()
validation.value = null
validationError.value = false
validating.value = false
}
async function validateRules() {
invalidateValidation()
if (!enabled.value || !valid.value) return
const requestSequence = validationSequence
validationController = new AbortController()
validating.value = true
try {
const response = await api('/api/v1/libraries/regex-metadata/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: value(), libraryId }),
signal: validationController.signal,
})
if (!response.ok) {
if (requestSequence === validationSequence) validationError.value = true
return
}
const data = (await response.json()) as RegexMetadataValidation
if (requestSequence === validationSequence) validation.value = data
} catch {
if (requestSequence === validationSequence) validationError.value = true
} finally {
if (requestSequence === validationSequence) validating.value = false
}
}
watch(
[rules, enabled],
() => {
invalidateValidation()
if (!enabled.value || !valid.value) return
validating.value = true
validationTimer = setTimeout(validateRules, 400)
},
{ deep: true, flush: 'sync', immediate: true },
)
onScopeDispose(invalidateValidation)
function addRule() {
if (rules.value.length < REGEX_METADATA_LIMITS.rules) rules.value.push({ pattern: '', flags: '' })
}
function removeRule(index: number) {
rules.value.splice(index, 1)
}
function moveRule(index: number, direction: -1 | 1) {
const target = index + direction
if (target < 0 || target >= rules.value.length) return
const next = [...rules.value]
const [rule] = next.splice(index, 1)
next.splice(target, 0, rule!)
rules.value = next
}
function moveUp(index: number) {
moveRule(index, -1)
}
function moveDown(index: number) {
moveRule(index, 1)
}
function value(): RegexMetadataConfig | null {
return enabled.value ? { rules: rules.value.map((rule) => ({ ...rule })) } : null
}
async function preview() {
if (!canPreview.value) return
invalidate()
const requestSequence = sequence
controller = new AbortController()
busy.value = true
try {
const response = await api('/api/v1/libraries/regex-metadata/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: value(), relativePath: relativePath.value, libraryId }),
signal: controller.signal,
})
if (!response.ok) {
if (requestSequence === sequence) error.value = true
return
}
const data = (await response.json()) as RegexMetadataPreview
if (requestSequence === sequence) result.value = data
} catch {
if (requestSequence === sequence) error.value = true
} finally {
if (requestSequence === sequence) busy.value = false
}
}
return {
validation,
validating,
validationError,
ruleErrors,
patternInvalid,
flagsInvalid,
generalDiagnostics,
validRuleCount,
validateRules,
enabled,
rules,
relativePath,
result,
busy,
error,
canApply,
canPreview,
addRule,
removeRule,
moveUp,
moveDown,
value,
preview,
}
}
+62
View File
@@ -10535,6 +10535,68 @@
"opfHint": "Saved next to the book by Calibre",
"swap": "Swap the order",
"hint": "BookOrbit uses the first one a book has and ignores the other. Calibre libraries usually want metadata.opf first."
},
"regex": {
"title": "Configure regex metadata",
"sourceTitle": "Regex",
"description": "Extract metadata from the primary file path relative to its library folder. The file extension is removed before matching. The first rule with usable fields wins.",
"enable": "Enable regex metadata",
"primaryFileHint": "For a book made of several files, such as audiobook tracks, the rules read the primary file's path.",
"groups": "Named groups:",
"valuesHint": "Use author for one author or authors separated by semicolons. Dates use YYYY-MM-DD. Flags: i ignores case, u enables Unicode.",
"rules": "Ordered extraction rules",
"rule": "Rule {number}",
"moveUp": "Move rule up",
"moveDown": "Move rule down",
"remove": "Remove rule",
"basePath": "Library folder",
"extensionHint": "The file extension is automatically ignored. The end anchor matches the filename without its extension.",
"flags": "Flags",
"add": "Add rule",
"examplePath": "Example relative file path, including extension",
"examplePlaceholder": "Author/Series/08 - My.Book.epub",
"exampleTitle": "Example with an author backreference",
"exampleTestPath": "Test path (including extension)",
"validating": "Checking rules…",
"validationSuccess": "Rule syntax and groups are valid. Use the preview to check matches for a file path.",
"validationError": "Could not validate the rules. Try again before applying them.",
"validationRetry": "Check again",
"validationRuleCount": "Add between {min} and {max} rules.",
"preview": "Preview",
"previewing": "Testing rules…",
"previewError": "Could not preview the rules. Check the relative path and configuration, then try again.",
"actualInput": "Path tested:",
"matched": "Matched rule {number}",
"noMatch": "No usable match. File metadata will be used.",
"scanHint": "Changes apply to future metadata extraction for new or changed files. Saving does not start a scan or update unchanged books.",
"apply": "Apply to library form",
"configure": "Configure",
"enabledHint": "Extract fields from relative paths without file extensions.",
"disabledHint": "Disabled. Configure rules to enable.",
"moveSourceUp": "Move {source} up",
"moveSourceDown": "Move {source} down",
"diagnostics": {
"pattern_required": "Enter a regular expression.",
"pattern_too_long": "The expression must not exceed {count} characters.",
"invalid_flags": "Only i and u are allowed, each at most once.",
"invalid_pattern": "Invalid regular expression.",
"missing_group": "Include at least one supported named group.",
"conflicting_authors": "Use either author or authors, not both.",
"invalid_value": "An invalid field value was skipped.",
"timeout": "The expression took too long. Simplify the pattern.",
"busy": "All preview workers are busy. Try again shortly.",
"worker_failed": "The expression could not be evaluated. Try again."
},
"precedenceHint": "The first available file source supplies metadata. Regex fields follow this order and missing values are filled from the other source.",
"summary": "{format} first, regex metadata preferred",
"fields": {
"title": "Title",
"authors": "Authors",
"series": "Series",
"seriesIndex": "Series number",
"publishedYear": "Published year",
"publishedDate": "Published date"
}
}
},
"fileWrite": {