mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user