feat(settings): redesign file naming as a two-column workbench

The three-column layout left the editor 270px wide at 1280 and broke resolved paths mid-word.
Draw the pattern one folder per line, preview it against long-name books, and list only what
missing metadata changes. Add resolvePlaceholders to the shared resolver to report fallbacks.
This commit is contained in:
neon
2026-10-01 02:46:15 +00:00
parent 2855dbb8a3
commit 4701412ef2
30 changed files with 2544 additions and 758 deletions
@@ -1,27 +1,38 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Info, Loader2 } from '@lucide/vue'
import { Loader2 } from '@lucide/vue'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import SettingsPageHeader from './SettingsPageHeader.vue'
import NamingRuleRail, { type RailItem } from './file-naming/components/NamingRuleRail.vue'
import NamingRuleEditor from './file-naming/components/NamingRuleEditor.vue'
import NamingResultPanel from './file-naming/components/NamingResultPanel.vue'
import NamingRuleRail, { type RailGroup, type RailItem } from './file-naming/components/NamingRuleRail.vue'
import NamingRuleEditor, { type RuleLink } from './file-naming/components/NamingRuleEditor.vue'
import FileNamingSaveBar from './file-naming/components/FileNamingSaveBar.vue'
import PatternExamplesSheet from './file-naming/components/PatternExamplesSheet.vue'
import { useDebouncedPatternPreview } from './composables/useDebouncedPatternPreview'
import { useFileNamingRules } from './file-naming/composables/useFileNamingRules'
import { GLOBAL_RULE_ICONS, globalKeyForMode, librariesGovernedBy, type NamingRule, type NamingRuleId } from './file-naming/lib/naming-rules'
import {
GLOBAL_RULE_ICONS,
globalKeyForMode,
globalRule,
librariesGovernedBy,
type NamingRule,
type NamingRuleId,
} from './file-naming/lib/naming-rules'
import type { PreviewBookId } from './file-naming/lib/preview-books'
import type { PreviewCaseId } from './file-naming/lib/pattern-preview'
import { findUnbalancedDelimiter } from './file-naming/lib/pattern-highlight'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
const { t } = useI18n()
const { hasPermission } = usePermissions()
const {
libraries,
visibleRules,
rules,
selectedRuleId,
selectedRule,
query,
loading,
saving,
crossPlatformSanitizationEnabled,
@@ -45,8 +56,11 @@ const {
} = useFileNamingRules()
const helpOpen = ref(false)
/** Below md the rail and the editor are separate screens, so one of them is showing. */
/** Below the two-column width the rail and the editor are separate screens, so one of them is showing. */
const mobileView = ref<'rail' | 'editor'>('rail')
/** Kept across rules, so a long title chosen to test one pattern can test the next one too. */
const previewBookId = ref<PreviewBookId>('sample')
const caseId = ref<PreviewCaseId>('complete')
const EDITOR_ID = 'file-naming-editor'
@@ -54,13 +68,9 @@ onMounted(() => {
void load()
})
// A filter that hides the selected rule would leave the editor showing something the
// rail no longer lists, so follow the filter down to the first surviving rule.
watch(visibleRules, (rules) => {
if (rules.length === 0) return
if (rules.some((rule) => rule.id === selectedRuleId.value)) return
const first = rules[0]
if (first) selectedRuleId.value = first.id
// A missing-metadata case belongs to the rule it was opened on.
watch(selectedRuleId, () => {
caseId.value = 'complete'
})
function ruleIcon(rule: NamingRule): string {
@@ -68,76 +78,95 @@ function ruleIcon(rule: NamingRule): string {
return rule.library?.icon || 'FolderOpen'
}
// A library's organization mode decides which global default it falls back to, which is
// the fact that matters on this page; a book count would not tell the reader anything here.
function railDetail(rule: NamingRule): string {
if (rule.kind === 'library') {
return rule.organizationMode === 'book_per_folder'
? t('settings.reader.fileNaming.orgFolderAsBook')
: t('settings.reader.fileNaming.orgFileAsBook')
}
if (rule.globalKey === 'download') return t('settings.reader.fileNaming.downloadScope')
const governed = librariesGovernedBy(rule, libraries.value, overriddenLibraryIds.value).length
return t('settings.reader.fileNaming.appliesToLibraries', { count: governed })
}
function toRailItem(rule: NamingRule): RailItem {
return {
id: rule.id,
name: ruleName(rule),
detail: railDetail(rule),
icon: ruleIcon(rule),
custom: rule.kind === 'library' && !isInherited(rule),
dirty: isDirty(rule),
}
}
const railGlobals = computed(() => visibleRules.value.filter((rule) => rule.kind === 'global').map(toRailItem))
const railLibraries = computed(() => visibleRules.value.filter((rule) => rule.kind === 'library').map(toRailItem))
/** Each library sits under the default for its organization mode, whether or not it overrides it. */
const railGroups = computed<RailGroup[]>(() =>
rules.value
.filter((rule) => rule.kind === 'global')
.map((rule) => ({
rule: toRailItem(rule),
libraries: rules.value
.filter((entry) => entry.kind === 'library' && rule.target === 'upload' && entry.organizationMode === rule.organizationMode)
.map(toRailItem),
separate: rule.target === 'download',
})),
)
const activePattern = computed(() => (selectedRule.value ? effectivePattern(selectedRule.value) : ''))
const activeName = computed(() => (selectedRule.value ? ruleName(selectedRule.value) : ''))
const activeInherited = computed(() => (selectedRule.value ? isInherited(selectedRule.value) : false))
const activeError = computed(() => (selectedRule.value ? errorFor(selectedRule.value) : ''))
/** Set while the field is completing a token, whose open brace is unfinished rather than wrong. */
const completingToken = ref(false)
const completingOnly = computed(() => completingToken.value && findUnbalancedDelimiter(activePattern.value) === '{')
const activeError = computed(() => (selectedRule.value && !completingOnly.value ? errorFor(selectedRule.value) : ''))
/** The save bar stays calm only when the token being typed is the sole thing blocking Save. */
const quietSaveBar = computed(
() => completingOnly.value && !dirtyRules.value.some((rule) => rule.id !== selectedRuleId.value && errorFor(rule) !== ''),
)
// The result panel resolves the pattern four times over. Phones cannot do that per
// The result panel resolves the pattern several times over. Phones cannot do that per
// keystroke without the on-screen keyboard stuttering, so the preview trails the field
// there and stays in lockstep on desktop.
const previewPattern = useDebouncedPatternPreview(activePattern)
/** The global rule a library falls back to, named so the copy can point at it. */
const inheritedFromName = computed(() => {
const usedBy = computed<RuleLink[]>(() => {
const rule = selectedRule.value
if (!rule || rule.kind !== 'library') return ''
return t(
`settings.reader.fileNaming.rule.${globalKeyForMode(rule.organizationMode ?? 'book_per_file')}` as 'settings.reader.fileNaming.rule.fileAsBook',
)
if (!rule || rule.kind !== 'global') return []
return librariesGovernedBy(rule, libraries.value, overriddenLibraryIds.value).map((library) => ({
id: `library:${library.id}` as const,
name: library.name,
}))
})
/** One line under the field saying exactly what this pattern governs today. */
const scopeSummary = computed(() => {
const base = computed<RuleLink | null>(() => {
const rule = selectedRule.value
if (!rule) return ''
if (rule.kind === 'library') return t('settings.reader.fileNaming.scopeLibrary', { name: rule.library?.name ?? '' })
if (rule.globalKey === 'download') return t('settings.reader.fileNaming.scopeDownload')
const governed = librariesGovernedBy(rule, libraries.value, overriddenLibraryIds.value).length
if (governed === 0) return t('settings.reader.fileNaming.scopeGlobalNone')
return t('settings.reader.fileNaming.scopeGlobal', { count: governed })
if (!rule || rule.kind !== 'library') return null
const key = globalKeyForMode(rule.organizationMode ?? 'book_per_file')
return { id: `global:${key}`, name: ruleName(globalRule(key)) }
})
/** Libraries whose files this pattern renames on the next save of a book's details. */
const renamingLibraries = computed<string[]>(() => {
const rule = selectedRule.value
if (!rule || rule.target !== 'upload') return []
if (rule.kind === 'library') return rule.library?.fileRenameEnabled ? [rule.library.name] : []
return librariesGovernedBy(rule, libraries.value, overriddenLibraryIds.value)
.filter((library) => library.fileRenameEnabled)
.map((library) => library.name)
})
const canBulkRename = computed(() => hasPermission('manage_libraries'))
function handleCompleting(value: boolean) {
completingToken.value = value
}
function handleSelect(id: NamingRuleId) {
selectedRuleId.value = id
mobileView.value = 'editor'
}
function handleQuery(value: string) {
query.value = value
}
function handlePattern(value: string) {
if (selectedRule.value) setDraft(selectedRule.value, value)
}
function handlePreviewBook(value: PreviewBookId) {
previewBookId.value = value
}
function handleCase(value: PreviewCaseId) {
caseId.value = value
}
function handleAddOverride() {
if (selectedRule.value) addOverride(selectedRule.value)
}
@@ -176,7 +205,7 @@ function handleSanitize(value: boolean) {
</script>
<template>
<div class="space-y-4 pb-16">
<div class="@container/naming space-y-4 pb-16">
<SettingsPageHeader
v-if="!props.embedded"
class="hidden md:flex"
@@ -194,71 +223,71 @@ function handleSanitize(value: boolean) {
</div>
<template v-else>
<div class="overflow-hidden rounded-lg border border-border bg-card shadow-xs">
<div class="md:grid md:grid-cols-[17.5rem_minmax(0,1fr)] md:items-stretch">
<div class="rounded-lg border border-border bg-card shadow-xs">
<div class="overflow-clip rounded-lg @2xl/naming:grid @2xl/naming:grid-cols-[15.5rem_minmax(0,1fr)] @2xl/naming:items-stretch">
<NamingRuleRail
:class="mobileView === 'editor' ? 'hidden md:flex' : 'flex'"
:globals="railGlobals"
:libraries="railLibraries"
:libraries-total="libraries.length"
:custom-count="overriddenLibraryIds.size"
:class="mobileView === 'editor' ? 'hidden @2xl/naming:flex' : 'flex'"
:groups="railGroups"
:selected-id="selectedRuleId"
:query="query"
:editor-id="EDITOR_ID"
@select="handleSelect"
@update:query="handleQuery"
/>
<div :id="EDITOR_ID" :class="mobileView === 'rail' ? 'hidden md:block' : 'block'">
<div v-if="selectedRule" class="xl:grid xl:grid-cols-[minmax(0,1fr)_23rem] xl:items-start">
<NamingRuleEditor
:rule="selectedRule"
:name="activeName"
:icon="ruleIcon(selectedRule)"
:pattern="activePattern"
:inherited="activeInherited"
:dirty="isDirty(selectedRule)"
:error="activeError"
:scope-summary="scopeSummary"
:inherited-from-name="inheritedFromName"
:sanitize="crossPlatformSanitizationEnabled"
@update:pattern="handlePattern"
@add-override="handleAddOverride"
@remove-override="handleRemoveOverride"
@reset-to-shipped="handleResetToShipped"
@open-help="openHelp"
@back="handleBack"
/>
<div class="px-3.5 pb-4 md:px-5 xl:sticky xl:top-4 xl:self-start xl:border-l xl:border-border xl:px-4 xl:pt-4">
<NamingResultPanel
:pattern="previewPattern"
:target="selectedRule.target"
:rule-name="activeName"
:sanitize="crossPlatformSanitizationEnabled"
:sanitize-busy="savingCrossPlatformSanitization"
@update:sanitize="handleSanitize"
/>
</div>
</div>
<div :id="EDITOR_ID" :class="mobileView === 'rail' ? 'hidden @2xl/naming:block' : 'block'">
<NamingRuleEditor
v-if="selectedRule"
:rule="selectedRule"
:name="activeName"
:icon="ruleIcon(selectedRule)"
:pattern="activePattern"
:preview-pattern="previewPattern"
:inherited="activeInherited"
:dirty="isDirty(selectedRule)"
:error="activeError"
:sanitize="crossPlatformSanitizationEnabled"
:preview-book-id="previewBookId"
:case-id="caseId"
:used-by="usedBy"
:base="base"
:renaming-libraries="renamingLibraries"
:can-bulk-rename="canBulkRename"
@update:pattern="handlePattern"
@update:preview-book-id="handlePreviewBook"
@update:case-id="handleCase"
@add-override="handleAddOverride"
@remove-override="handleRemoveOverride"
@reset-to-shipped="handleResetToShipped"
@open-examples="openHelp"
@select="handleSelect"
@back="handleBack"
@completing="handleCompleting"
/>
</div>
</div>
<FileNamingSaveBar
v-if="dirtyRules.length > 0"
class="rounded-b-lg"
:unsaved-count="dirtyRules.length"
:saving="saving"
:blocked="blockedByError"
:quiet="quietSaveBar"
@save="handleSave"
@discard="handleDiscard"
/>
</div>
<div class="flex items-start gap-3 rounded-lg border border-primary/15 bg-primary/5 px-4 py-3 shadow-xs">
<Info :size="16" class="mt-0.5 shrink-0 text-primary" aria-hidden="true" />
<p class="text-xs leading-relaxed text-muted-foreground">
<span class="font-medium text-foreground">{{ t('settings.reader.fileNaming.howItWorks.title') }}</span>
{{ ' ' }}{{ t('settings.reader.fileNaming.howItWorks.summary') }}
</p>
<div class="flex items-center gap-4 rounded-lg border border-border bg-card px-4 py-3.5 shadow-xs md:px-5">
<div class="min-w-0 flex-1">
<p class="settings-label">{{ t('settings.reader.fileNaming.crossPlatform') }}</p>
<p class="settings-hint">{{ t('settings.reader.fileNaming.crossPlatformHint') }}</p>
</div>
<ToggleSwitch
:model-value="crossPlatformSanitizationEnabled"
:disabled="savingCrossPlatformSanitization"
:aria-label="t('settings.reader.fileNaming.crossPlatform')"
@update:model-value="handleSanitize"
/>
</div>
</template>
@@ -5,10 +5,11 @@ import type { Library } from '@bookorbit/types'
import { i18n } from '@/i18n'
import FileNamingSettings from '../FileNamingSettings.vue'
const { apiMock, toastSuccess, toastError } = vi.hoisted(() => ({
const { apiMock, toastSuccess, toastError, hasPermission } = vi.hoisted(() => ({
apiMock: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
toastSuccess: vi.fn<(message: string) => void>(),
toastError: vi.fn<(message: string) => void>(),
hasPermission: vi.fn<(permission: string) => boolean>(),
}))
const libraries = ref<Library[]>([])
@@ -18,11 +19,25 @@ vi.mock('vue-sonner', () => ({ toast: { success: toastSuccess, error: toastError
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({ libraries, fetchLibraries: vi.fn<() => Promise<void>>().mockResolvedValue(undefined) }),
}))
vi.mock('@/features/auth/composables/usePermissions', () => ({ usePermissions: () => ({ hasPermission }) }))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div><slot /></div>' },
TooltipTrigger: { template: '<div><slot /></div>' },
}))
// Menus render their items inline so a test can reach them without driving the popover.
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div data-testid="menu"><slot /></div>' },
DropdownMenuLabel: { template: '<div><slot /></div>' },
DropdownMenuSeparator: { template: '<hr />' },
DropdownMenuItem: {
props: { disabled: { type: Boolean, default: false } },
emits: ['select'],
template: '<button type="button" role="menuitem" :disabled="disabled" @click="$emit(\'select\', $event)"><slot /></button>',
},
}))
vi.mock('../file-naming/components/PatternExamplesSheet.vue', () => ({
default: {
props: { open: { type: Boolean, default: false } },
@@ -31,14 +46,17 @@ vi.mock('../file-naming/components/PatternExamplesSheet.vue', () => ({
}))
function makeLibrary(overrides: Partial<Library> = {}): Library {
return { id: 7, name: 'Fiction', organizationMode: 'book_per_file', fileNamingPattern: null, ...overrides } as Library
return { id: 7, name: 'Fiction', organizationMode: 'book_per_file', fileNamingPattern: null, fileRenameEnabled: false, ...overrides } as Library
}
const ok = (body: object): Response =>
({ ok: true, status: 200, json: vi.fn<() => Promise<unknown>>().mockResolvedValue(body) }) as unknown as Response
async function mountPage() {
const wrapper = mount(FileNamingSettings, { props: { embedded: true } })
const wrapper = mount(FileNamingSettings, {
props: { embedded: true },
global: { stubs: { RouterLink: { template: '<a data-testid="bulk-rename"><slot /></a>' } } },
})
await flushPromises()
await flushPromises()
return wrapper
@@ -46,72 +64,94 @@ async function mountPage() {
type Wrapper = Awaited<ReturnType<typeof mountPage>>
const railButton = (wrapper: Wrapper, text: string) =>
wrapper
.find('nav')
.findAll('button')
.find((button) => button.text().includes(text))
const buttonWith = (wrapper: Wrapper, text: string) => wrapper.findAll('button').find((button) => button.text().includes(text))
const patternField = (wrapper: Wrapper) => wrapper.find<HTMLTextAreaElement>('textarea#file-naming-pattern')
async function editPattern(wrapper: Wrapper, value: string) {
const field = patternField(wrapper)
field.element.value = value
field.element.setSelectionRange(value.length, value.length)
await field.trigger('input')
await flushPromises()
}
beforeEach(() => {
vi.clearAllMocks()
i18n.global.locale.value = 'en'
hasPermission.mockReturnValue(true)
libraries.value = [makeLibrary()]
apiMock.mockImplementation(() => Promise.resolve(ok({ pattern: '{authors}/{title}', enabled: true })))
})
describe('FileNamingSettings', () => {
it('gives the pattern field a programmatic label and describes it with its hint', async () => {
it('gives the pattern field a programmatic label and describes it with its scope and hint', async () => {
const wrapper = await mountPage()
expect(wrapper.find('label[for="file-naming-pattern"]').exists()).toBe(true)
expect(patternField(wrapper).attributes('aria-describedby')).toContain('file-naming-pattern-hint')
expect(patternField(wrapper).attributes('aria-describedby')).toBe('file-naming-pattern-scope file-naming-pattern-hint')
})
it('lists every global default and every library as a selectable rule', async () => {
it('nests each library under the default for its organization mode', async () => {
libraries.value = [makeLibrary(), makeLibrary({ id: 8, name: 'Comics', organizationMode: 'book_per_folder' })]
const wrapper = await mountPage()
for (const name of ['File as Book default', 'Folder as Book default', 'Download filename', 'Fiction', 'Comics']) {
expect(buttonWith(wrapper, name)).toBeDefined()
}
const groups = wrapper.find('nav').findAll(':scope > ul > li')
expect(groups.map((group) => group.find('button').text())).toEqual(['File as Book default', 'Folder as Book default', 'Download filename'])
expect(groups[0]!.find('ul').text()).toContain('Fiction')
expect(groups[1]!.find('ul').text()).toContain('Comics')
expect(groups[2]!.find('ul').exists()).toBe(false)
})
it('opens on the File as Book default', async () => {
it('opens on the File as Book default, one folder level per line', async () => {
const wrapper = await mountPage()
expect(wrapper.find('h2').text()).toContain('File as Book default')
expect(patternField(wrapper).element.value).toBe('{authors}/{title}')
expect(patternField(wrapper).element.value).toBe('{authors}/\n{title}')
})
it('renders the pattern as coloured pieces rather than one undifferentiated string', async () => {
it('names the libraries a default governs and selects one from its link', async () => {
libraries.value = [makeLibrary(), makeLibrary({ id: 9, name: 'PDFs' })]
const wrapper = await mountPage()
const layer = wrapper.find('pre[aria-hidden="true"]')
expect(layer.findAll('span.text-pattern-token').length).toBeGreaterThan(0)
expect(layer.text()).toBe('{authors}/{title}')
expect(wrapper.find('#file-naming-pattern-scope').text()).toBe('Used by Fiction and PDFs')
await wrapper
.find('#file-naming-pattern-scope')
.findAll('button')
.find((button) => button.text() === 'PDFs')
?.trigger('click')
expect(wrapper.find('h2').text()).toContain('PDFs')
})
it('shows a library that has no pattern as inheriting, with the field read-only', async () => {
it('shows a library that has no pattern as following its default, with the field read-only', async () => {
const wrapper = await mountPage()
await buttonWith(wrapper, 'Fiction')?.trigger('click')
await railButton(wrapper, 'Fiction')?.trigger('click')
expect(wrapper.find('#file-naming-pattern-scope').text()).toBe('Follows the File as Book default.')
expect(wrapper.text()).toContain('This library follows the File as Book default')
expect(patternField(wrapper).attributes('readonly')).toBeDefined()
expect(buttonWith(wrapper, 'Add an override')).toBeDefined()
})
it('makes the field editable once an override is added', async () => {
it('makes the field editable once an override is added, and says what it overrides', async () => {
const wrapper = await mountPage()
await buttonWith(wrapper, 'Fiction')?.trigger('click')
await railButton(wrapper, 'Fiction')?.trigger('click')
await buttonWith(wrapper, 'Add an override')?.trigger('click')
expect(patternField(wrapper).attributes('readonly')).toBeUndefined()
expect(wrapper.text()).toContain('Use the global default')
expect(wrapper.find('#file-naming-pattern-scope').text()).toBe('Overrides the File as Book default for this library only.')
expect(buttonWith(wrapper, 'Use the global default')).toBeDefined()
})
it('counts unsaved rules in the save bar and enables saving', async () => {
it('shows the save bar only once something is unsaved', async () => {
const wrapper = await mountPage()
expect(wrapper.text()).toContain('No unsaved changes')
expect(buttonWith(wrapper, 'Save changes')?.attributes('disabled')).toBeDefined()
expect(buttonWith(wrapper, 'Save changes')).toBeUndefined()
await patternField(wrapper).setValue('{title}')
await editPattern(wrapper, '{title}')
expect(wrapper.text()).toContain('1 unsaved change')
expect(buttonWith(wrapper, 'Save changes')?.attributes('disabled')).toBeUndefined()
@@ -119,46 +159,125 @@ describe('FileNamingSettings', () => {
it('blocks saving and says why when the edited pattern is invalid', async () => {
const wrapper = await mountPage()
await patternField(wrapper).setValue('{title}?')
await editPattern(wrapper, '{title}?')
const error = wrapper.find('#file-naming-pattern-error')
expect(error.text()).toBe('Pattern contains invalid characters')
expect(wrapper.find('#file-naming-pattern-error').text()).toBe('Pattern contains invalid characters')
expect(patternField(wrapper).attributes('aria-invalid')).toBe('true')
expect(buttonWith(wrapper, 'Save changes')?.attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('Fix the invalid pattern before saving')
})
it('restores the saved pattern when the edit is discarded', async () => {
it('does not treat a token still being typed as an error', async () => {
const wrapper = await mountPage()
await patternField(wrapper).setValue('{title}')
await editPattern(wrapper, '{authors}/{ti')
expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
expect(wrapper.find('#file-naming-pattern-error').exists()).toBe(false)
expect(wrapper.text()).not.toContain('Fix the invalid pattern before saving')
expect(buttonWith(wrapper, 'Save changes')?.attributes('disabled')).toBeDefined()
})
it('restores the saved pattern and hides the save bar when the edit is discarded', async () => {
const wrapper = await mountPage()
await editPattern(wrapper, '{title}')
await buttonWith(wrapper, 'Discard')?.trigger('click')
expect(patternField(wrapper).element.value).toBe('{authors}/{title}')
expect(wrapper.text()).toContain('No unsaved changes')
expect(patternField(wrapper).element.value).toBe('{authors}/\n{title}')
expect(buttonWith(wrapper, 'Save changes')).toBeUndefined()
})
it('previews the resolved path and what happens when metadata is missing', async () => {
it('previews the result and lists only what each missing field changes', async () => {
const wrapper = await mountPage()
const result = wrapper.find('[aria-label="Resolved result"]')
expect(wrapper.text()).toContain('Neuromancer')
expect(wrapper.text()).toContain('If metadata is missing')
for (const label of ['No series', 'No year', 'No author']) {
expect(wrapper.text()).toContain(label)
}
expect(result.text()).toContain('William Gibson')
expect(result.text()).toContain('Neuromancer.epub')
expect(result.text()).toContain('If metadata is missing')
// The mocked pattern has no fallback, so a book without an author gets a nameless folder.
const author = result.findAll('button[aria-pressed]').find((row) => row.text().includes('No author'))
expect(author?.text()).toContain('empty name')
expect(
result
.findAll('button[aria-pressed]')
.find((row) => row.text().includes('No year'))
?.text(),
).toContain('No change')
})
it('offers the readaloud token and previews read-aloud and plain EPUB outcomes', async () => {
it('previews a missing-metadata case in the tree when its row is chosen', async () => {
const wrapper = await mountPage()
await editPattern(wrapper, '<{authors}|Unknown Author>/{title}')
const row = () => wrapper.findAll('button[aria-pressed]').find((button) => button.text().includes('No author'))!
expect(buttonWith(wrapper, '{readaloud}')).toBeDefined()
await patternField(wrapper).setValue('{title}< ({readaloud})>')
await row().trigger('click')
expect(row().attributes('aria-pressed')).toBe('true')
expect(wrapper.text()).toContain('Previewing: No author')
expect(wrapper.find('[aria-label="Resolved result"] ol').text()).toContain('Unknown Author')
})
it('previews with another book from the picker', async () => {
const wrapper = await mountPage()
await buttonWith(wrapper, 'Robinson Crusoe')?.trigger('click')
expect(wrapper.find('[aria-label="Resolved result"] ol').text()).toContain('Daniel Defoe')
})
it('compares read-aloud and plain EPUB results when the pattern uses the readaloud token', async () => {
const wrapper = await mountPage()
await editPattern(wrapper, '{title}< ({readaloud})>')
expect(wrapper.text()).toContain('Read-aloud comparison')
expect(wrapper.text()).toContain('Read-aloud EPUB')
expect(wrapper.text()).toContain('Plain EPUB')
expect(wrapper.text()).toContain('Neuromancer (readaloud)')
})
it('inserts a token from the insert menu', async () => {
const wrapper = await mountPage()
const field = patternField(wrapper).element
field.setSelectionRange(field.value.length, field.value.length)
await wrapper
.findAll('[role="menuitem"]')
.find((item) => item.text().startsWith('{year}'))
?.trigger('click')
await flushPromises()
expect(patternField(wrapper).element.value).toBe('{authors}/\n{title}{year}')
})
it('applies a recipe to the pattern field', async () => {
const wrapper = await mountPage()
await buttonWith(wrapper, 'Calibre style')?.trigger('click')
expect(patternField(wrapper).element.value).toContain('<{authors}|Unknown Author>/')
expect(wrapper.text()).toContain('1 unsaved change')
})
it('opens the examples sheet from the recipes menu', async () => {
const wrapper = await mountPage()
expect(wrapper.find('[data-testid="pattern-examples-sheet"]').attributes('data-open')).toBe('false')
await wrapper
.findAll('[role="menuitem"]')
.find((item) => item.text() === 'Examples')
?.trigger('click')
expect(wrapper.find('[data-testid="pattern-examples-sheet"]').attributes('data-open')).toBe('true')
})
it('names the libraries that rename files to this pattern, and links to Bulk Rename for those allowed to run it', async () => {
libraries.value = [makeLibrary({ fileRenameEnabled: true }), makeLibrary({ id: 9, name: 'PDFs' })]
const wrapper = await mountPage()
expect(wrapper.text()).toContain("the next time a book's details are saved: Fiction.")
expect(wrapper.find('[data-testid="bulk-rename"]').exists()).toBe(true)
hasPermission.mockReturnValue(false)
const restricted = await mountPage()
expect(restricted.find('[data-testid="bulk-rename"]').exists()).toBe(false)
})
it('saves the cross-platform toggle immediately without a separate save button', async () => {
const wrapper = await mountPage()
@@ -171,38 +290,11 @@ describe('FileNamingSettings', () => {
)
})
it('filters the rule list down to a matching name', async () => {
libraries.value = [makeLibrary(), makeLibrary({ id: 8, name: 'Comics' })]
const wrapper = await mountPage()
await wrapper.find('input#file-naming-rule-filter').setValue('comics')
expect(buttonWith(wrapper, 'Comics')).toBeDefined()
expect(buttonWith(wrapper, 'Fiction')).toBeUndefined()
})
it('opens the examples sheet from its trigger', async () => {
const wrapper = await mountPage()
expect(wrapper.find('[data-testid="pattern-examples-sheet"]').attributes('data-open')).toBe('false')
await buttonWith(wrapper, 'Examples')?.trigger('click')
expect(wrapper.find('[data-testid="pattern-examples-sheet"]').attributes('data-open')).toBe('true')
})
it('applies a recipe to the pattern field', async () => {
const wrapper = await mountPage()
await buttonWith(wrapper, 'Calibre style')?.trigger('click')
expect(patternField(wrapper).element.value).toContain('{authors}')
expect(wrapper.text()).toContain('1 unsaved change')
})
it('still lists the global defaults when no libraries are configured', async () => {
libraries.value = []
const wrapper = await mountPage()
expect(buttonWith(wrapper, 'File as Book default')).toBeDefined()
expect(wrapper.text()).toContain('No libraries use this default')
expect(railButton(wrapper, 'File as Book default')).toBeDefined()
expect(wrapper.text()).toContain('No library uses this default right now')
})
})
@@ -9,6 +9,8 @@ const props = defineProps<{
saving: boolean
/** Set while a dirty rule has a validation error, so Save cannot send a bad pattern. */
blocked: boolean
/** Blocked only by a token still being typed: Save stays off, but nothing is wrong yet. */
quiet?: boolean
}>()
const emit = defineEmits<{ save: []; discard: [] }>()
@@ -31,7 +33,7 @@ function handleDiscard() {
<div
class="sticky bottom-0 z-10 flex flex-wrap items-center gap-3 border-t border-border bg-card/90 px-3.5 py-2.5 backdrop-blur supports-[backdrop-filter]:bg-card/75 md:px-5"
>
<p v-if="blocked && dirty" class="flex items-center gap-2 text-sm text-destructive" role="status">
<p v-if="blocked && dirty && !quiet" class="flex items-center gap-2 text-sm text-destructive" role="status">
<span aria-hidden="true" class="size-2 shrink-0 rounded-full bg-destructive" />
{{ t('settings.reader.fileNaming.save.blocked') }}
</p>
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { ChevronRight, File, Folder, FolderOpen } from '@lucide/vue'
import { splitExtension, type LevelChange } from '../lib/pattern-resolution'
/**
* One row per variation of the preview book, listing only the levels that come out differently.
* Printing the whole path for every row would repeat the unchanged folders on every line, which
* is most of the text and none of the information.
*/
export interface ChangeRow {
id: string
label: string
changes: LevelChange[]
}
const props = defineProps<{
rows: ChangeRow[]
extension: string
/** Rows become buttons that choose what the tree above shows. */
selectable?: boolean
selectedId?: string
}>()
const emit = defineEmits<{ select: [id: string] }>()
const { t } = useI18n()
function iconFor(change: LevelChange) {
if (change.kind === 'file') return File
return change.bookFolder ? FolderOpen : Folder
}
function parts(change: LevelChange): [string, string] {
return change.kind === 'file' ? splitExtension(change.to, props.extension) : [change.to, '']
}
function handleSelect(id: string) {
if (props.selectable) emit('select', id)
}
</script>
<template>
<ul class="list-none divide-y divide-border overflow-hidden rounded-md border border-border p-0">
<li v-for="row in rows" :key="row.id">
<component
:is="selectable ? 'button' : 'div'"
:type="selectable ? 'button' : undefined"
:aria-pressed="selectable ? selectedId === row.id : undefined"
class="group grid w-full grid-cols-1 items-start gap-x-3 gap-y-1 px-3 py-2 text-start text-[13px] leading-5 sm:grid-cols-[5.5rem_minmax(0,1fr)_0.875rem]"
:class="
!selectable
? ''
: selectedId === row.id
? 'bg-primary/8 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary'
: 'transition-colors hover:bg-muted/50 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary'
"
@click="handleSelect(row.id)"
>
<span class="text-xs font-semibold" :class="selectable && selectedId === row.id ? 'text-primary' : 'text-muted-foreground'">{{
row.label
}}</span>
<span class="flex min-w-0 flex-wrap gap-x-3 gap-y-0.5">
<span v-if="row.changes.length === 0" class="text-muted-foreground">{{ t('settings.reader.fileNaming.noChange') }}</span>
<span v-for="(change, index) in row.changes" :key="index" class="inline-flex min-w-0 max-w-full items-start gap-1.5">
<component
:is="iconFor(change)"
:size="12"
class="mt-1 shrink-0"
:class="[
change.emptyName ? 'text-destructive' : change.kind === 'file' || change.bookFolder ? 'text-primary' : 'text-muted-foreground',
change.skipped ? 'folder-dashed' : '',
]"
aria-hidden="true"
/>
<span v-if="change.emptyName" class="min-w-0 break-words text-destructive"
><s>{{ change.from }}</s>
<span class="ms-1 text-[10px] font-bold uppercase tracking-wider">{{ t('settings.reader.fileNaming.tagEmptyName') }}</span></span
>
<span v-else-if="change.skipped" class="min-w-0 break-words text-muted-foreground"
><s>{{ change.from }}</s>
<span class="ms-1 text-[10px] font-bold uppercase tracking-wider">{{ t('settings.reader.fileNaming.tagSkipped') }}</span></span
>
<span v-else class="min-w-0 break-words" :class="change.usedFallback ? 'text-pattern-fallback' : 'text-foreground'"
>{{ parts(change)[0] }}<span v-if="parts(change)[1]" class="text-primary">{{ parts(change)[1] }}</span></span
>
</span>
</span>
<ChevronRight
v-if="selectable"
:size="14"
class="mt-0.5 hidden text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 sm:block"
:class="selectedId === row.id ? 'opacity-100' : ''"
aria-hidden="true"
/>
</component>
</li>
</ul>
</template>
<style scoped>
.folder-dashed :deep(path) {
stroke-dasharray: 2.6 2.4;
}
</style>
@@ -1,147 +1,179 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ClipboardCopy, Eye, TriangleAlert } from '@lucide/vue'
import { BookOpen, Check, ChevronDown, ClipboardCopy, Eye } from '@lucide/vue'
import { toast } from 'vue-sonner'
import { EXAMPLE_PATTERN_METADATA } from '@bookorbit/types'
import type { OrganizationMode } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { copyToClipboard } from '@/lib/clipboard'
import PathTreePreview from './PathTreePreview.vue'
import { MISSING_METADATA_CASES, previewDownloadName, previewUploadPath } from '../lib/pattern-preview'
import ResolvedPathTree from './ResolvedPathTree.vue'
import LevelChangeList, { type ChangeRow } from './LevelChangeList.vue'
import { levelChanges, resolvePreview, type ResolveOptions } from '../lib/pattern-resolution'
import { MISSING_METADATA_CASES, PREVIEW_CASES, type PreviewCaseId } from '../lib/pattern-preview'
import { PREVIEW_BOOK_IDS, PREVIEW_BOOK_NOTE_KEYS, PREVIEW_BOOKS, withoutFields, type PreviewBookId } from '../lib/preview-books'
import type { NamingTarget } from '../lib/naming-rules'
const props = defineProps<{
pattern: string
target: NamingTarget
ruleName: string
mode: OrganizationMode | null
sanitize: boolean
sanitizeBusy?: boolean
previewBookId: PreviewBookId
caseId: PreviewCaseId
/** Set while the pattern is invalid: the preview shows what it last resolved to, dimmed. */
stale?: boolean
}>()
const emit = defineEmits<{ 'update:sanitize': [value: boolean] }>()
const emit = defineEmits<{ 'update:previewBookId': [value: PreviewBookId]; 'update:caseId': [value: PreviewCaseId] }>()
const { t } = useI18n()
const CASE_LABELS = {
const CASE_LABELS: Record<PreviewCaseId, string> = {
complete: 'settings.reader.fileNaming.exampleCase.complete',
noSeries: 'settings.reader.fileNaming.caseNoSeriesLabel',
noYear: 'settings.reader.fileNaming.caseNoYearLabel',
noAuthor: 'settings.reader.fileNaming.caseNoAuthorLabel',
} as const
function resolve(metadata: Record<string, string>): string {
const options = { sanitizeForCrossPlatform: props.sanitize }
return props.target === 'download' ? previewDownloadName(props.pattern, options, metadata) : previewUploadPath(props.pattern, options, metadata)
}
const resolved = computed(() => resolve(EXAMPLE_PATTERN_METADATA))
const usesReadaloudToken = computed(() => props.pattern.includes('{readaloud'))
const plainEpubResult = computed(() => resolve({ ...EXAMPLE_PATTERN_METADATA, readaloud: '' }))
const book = computed(() => PREVIEW_BOOKS[props.previewBookId])
const options = computed<ResolveOptions>(() => ({
target: props.target,
mode: props.mode,
extension: book.value.extension,
sanitize: props.sanitize,
}))
const isUpload = computed(() => props.target === 'upload')
const fallbackCases = computed(() =>
MISSING_METADATA_CASES.map((previewCase) => ({
id: previewCase.id,
label: t(CASE_LABELS[previewCase.id as keyof typeof CASE_LABELS]),
path: resolve(previewCase.metadata),
function resolveFor(omit: readonly string[]) {
return resolvePreview(props.pattern, withoutFields(book.value.metadata, omit), options.value)
}
const complete = computed(() => resolveFor([]))
const activeCase = computed(() => PREVIEW_CASES.find((entry) => entry.id === props.caseId) ?? PREVIEW_CASES[0]!)
const shown = computed(() => (props.caseId === 'complete' ? complete.value : resolveFor(activeCase.value.omit)))
const missingRows = computed<ChangeRow[]>(() =>
MISSING_METADATA_CASES.map((entry) => ({
id: entry.id,
label: t(CASE_LABELS[entry.id]),
changes: levelChanges(complete.value, resolveFor(entry.omit)),
})),
)
async function handleCopy() {
if (!resolved.value) return
const copied = await copyToClipboard(resolved.value)
if (copied) toast.success(t('settings.reader.fileNaming.resultCopied'))
else toast.error(t('settings.reader.fileNaming.resultCopyFailed'))
/** Only a book that is a read-aloud EPUB has a plain counterpart worth comparing against. */
const showsReadaloud = computed(() => props.pattern.includes('{readaloud') && !!book.value.metadata.readaloud)
const readaloudRows = computed<ChangeRow[]>(() => [
{ id: 'plain', label: t('settings.reader.fileNaming.plainEpub'), changes: levelChanges(complete.value, resolveFor(['readaloud'])) },
])
const bookNote = (id: PreviewBookId) => t(PREVIEW_BOOK_NOTE_KEYS[id])
function chooseBook(id: PreviewBookId) {
emit('update:previewBookId', id)
}
function handleSanitize(value: boolean) {
emit('update:sanitize', value)
/** Choosing the case already shown goes back to the complete book. */
function chooseCase(id: string) {
emit('update:caseId', id === props.caseId ? 'complete' : (id as PreviewCaseId))
}
function showComplete() {
emit('update:caseId', 'complete')
}
async function handleCopy() {
if (!shown.value.path) return
const copied = await copyToClipboard(shown.value.path)
if (copied) toast.success(t('settings.reader.fileNaming.resultCopied'))
else toast.error(t('settings.reader.fileNaming.resultCopyFailed'))
}
</script>
<template>
<section class="overflow-hidden rounded-lg border border-border bg-card shadow-xs" :aria-label="t('settings.reader.fileNaming.resultPanel')">
<header class="flex items-center justify-between gap-2 border-b border-border px-3.5 py-2.5">
<h3 class="flex min-w-0 items-center gap-1.5 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
<Eye :size="12" class="shrink-0" aria-hidden="true" />
<span class="truncate">{{ t('settings.reader.fileNaming.resultFor', { rule: ruleName }) }}</span>
</h3>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon-sm"
type="button"
:disabled="!resolved"
:aria-label="t('settings.reader.fileNaming.copyResult')"
@click="handleCopy"
>
<ClipboardCopy :size="14" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('settings.reader.fileNaming.copyResult') }}</TooltipContent>
</Tooltip>
</header>
<section :aria-label="t('settings.reader.fileNaming.resultPanel')">
<p v-if="stale" class="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground" role="status">
<Eye :size="13" class="shrink-0" aria-hidden="true" />
{{ t('settings.reader.fileNaming.previewStale') }}
</p>
<div class="flex gap-3 border-b border-border px-3.5 py-3">
<span aria-hidden="true" class="h-16 w-11 shrink-0 rounded bg-gradient-to-br from-primary to-primary/25 shadow-sm" />
<div class="grid gap-5 @4xl:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)]" :class="stale ? 'pointer-events-none opacity-45' : ''">
<div class="min-w-0">
<p class="font-serif text-[15px] font-semibold leading-tight text-foreground">{{ EXAMPLE_PATTERN_METADATA.title }}</p>
<p class="mt-0.5 text-xs text-muted-foreground">{{ EXAMPLE_PATTERN_METADATA.authors }} &middot; {{ EXAMPLE_PATTERN_METADATA.year }}</p>
<p class="text-xs text-muted-foreground">{{ EXAMPLE_PATTERN_METADATA.series }} #{{ EXAMPLE_PATTERN_METADATA.seriesIndex }}</p>
<p class="mt-1 text-[11px] text-muted-foreground">{{ t('settings.reader.fileNaming.sampleBookNote') }}</p>
<div class="mb-2 flex min-h-8 flex-wrap items-center gap-2">
<h3 class="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{{ t('settings.reader.fileNaming.result') }}</h3>
<span class="grow" />
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="outline"
size="sm"
type="button"
class="h-7 max-w-60 gap-1.5 px-2 text-xs"
:aria-label="t('settings.reader.fileNaming.previewWithAria', { title: book.metadata.title })"
>
<BookOpen :size="13" class="shrink-0" aria-hidden="true" />
<span class="truncate">{{ book.metadata.title }}</span>
<ChevronDown :size="13" class="shrink-0 text-muted-foreground" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-72 max-w-[calc(100vw-2rem)]">
<DropdownMenuLabel class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{{
t('settings.reader.fileNaming.previewWith')
}}</DropdownMenuLabel>
<DropdownMenuItem v-for="id in PREVIEW_BOOK_IDS" :key="id" class="items-start gap-2.5" @select="chooseBook(id)">
<Check :size="14" class="mt-0.5" :class="id === previewBookId ? 'text-primary' : 'invisible'" aria-hidden="true" />
<span class="min-w-0">
<span class="block truncate text-[13px]">{{ PREVIEW_BOOKS[id].metadata.title }}</span>
<span class="block text-xs text-muted-foreground">{{ bookNote(id) }}</span>
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon-sm"
type="button"
class="size-7"
:disabled="!shown.path"
:aria-label="t('settings.reader.fileNaming.copyResult')"
@click="handleCopy"
>
<ClipboardCopy :size="14" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('settings.reader.fileNaming.copyResult') }}</TooltipContent>
</Tooltip>
</div>
<div class="rounded-md border border-border bg-background/40 px-3.5 py-2.5">
<p v-if="caseId !== 'complete'" class="mb-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs font-semibold text-primary">
<Eye :size="13" class="shrink-0" aria-hidden="true" />
{{ t('settings.reader.fileNaming.previewingCase', { case: t(CASE_LABELS[caseId]) }) }}
<button type="button" class="ms-auto font-medium text-foreground underline underline-offset-2 hover:text-primary" @click="showComplete">
{{ t('settings.reader.fileNaming.showComplete') }}
</button>
</p>
<ResolvedPathTree :preview="shown" :extension="book.extension" :label="t(CASE_LABELS[caseId])" />
</div>
</div>
</div>
<div class="px-3.5 py-3">
<PathTreePreview :path="resolved" />
</div>
<div class="border-t border-border px-3.5 py-3">
<h4 class="mb-1 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
<TriangleAlert :size="11" class="shrink-0" aria-hidden="true" />
{{ t('settings.reader.fileNaming.missingMetadata') }}
</h4>
<dl class="divide-y divide-dashed divide-border">
<div v-for="item in fallbackCases" :key="item.id" class="py-1.5">
<dt class="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground">
<span aria-hidden="true" class="size-1.5 shrink-0 rounded-full bg-warning" />
{{ item.label }}
</dt>
<dd class="mt-0.5 break-words font-mono text-[11px] text-foreground">
{{ item.path || t('settings.reader.fileNaming.previewEmptyShort') }}
</dd>
<div v-if="isUpload" class="min-w-0 space-y-4">
<div>
<h3 class="mb-2 flex min-h-8 items-center text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
{{ t('settings.reader.fileNaming.missingMetadata') }}
</h3>
<LevelChangeList :rows="missingRows" :extension="book.extension" selectable :selected-id="caseId" @select="chooseCase" />
</div>
</dl>
</div>
<div v-if="usesReadaloudToken" class="border-t border-border px-3.5 py-3">
<h4 class="mb-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
{{ t('settings.reader.fileNaming.readaloudComparison') }}
</h4>
<dl class="divide-y divide-dashed divide-border">
<div class="py-1.5">
<dt class="text-[11px] font-semibold text-muted-foreground">{{ t('settings.reader.fileNaming.readaloudEpub') }}</dt>
<dd class="mt-0.5 break-words font-mono text-[11px] text-foreground">{{ resolved }}</dd>
<div v-if="showsReadaloud">
<h3 class="mb-2 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
{{ t('settings.reader.fileNaming.readaloudComparison') }}
</h3>
<LevelChangeList :rows="readaloudRows" :extension="book.extension" />
</div>
<div class="py-1.5">
<dt class="text-[11px] font-semibold text-muted-foreground">{{ t('settings.reader.fileNaming.plainEpub') }}</dt>
<dd class="mt-0.5 break-words font-mono text-[11px] text-foreground">{{ plainEpubResult }}</dd>
</div>
</dl>
</div>
<div class="flex items-center gap-3 border-t border-border bg-muted/30 px-3.5 py-3">
<div class="min-w-0 flex-1">
<p class="settings-label">{{ t('settings.reader.fileNaming.crossPlatform') }}</p>
<p class="settings-hint">{{ t('settings.reader.fileNaming.crossPlatformShortHint') }}</p>
</div>
<ToggleSwitch
:model-value="sanitize"
:disabled="sanitizeBusy"
:aria-label="t('settings.reader.fileNaming.crossPlatform')"
@update:model-value="handleSanitize"
/>
</div>
</section>
</template>
@@ -1,35 +1,61 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { BookOpen, ChevronLeft, CornerDownRight, File, FolderOpen, Plus, RotateCcw } from '@lucide/vue'
import { ChevronLeft, CornerDownRight, Copy, File, FolderOpen, Info, MoreHorizontal, Plus, RotateCcw, Undo2 } from '@lucide/vue'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import AppIcon from '@/components/AppIcon.vue'
import { copyToClipboard } from '@/lib/clipboard'
import { formatList, formatListParts } from '@/i18n/formatters'
import PatternInput from './PatternInput.vue'
import PatternTokenPalette from './PatternTokenPalette.vue'
import PatternRecipeStrip from './PatternRecipeStrip.vue'
import type { NamingRule } from '../lib/naming-rules'
import PatternInsertBar from './PatternInsertBar.vue'
import PatternRecipeMenu from './PatternRecipeMenu.vue'
import NamingResultPanel from './NamingResultPanel.vue'
import { PREVIEW_BOOKS, type PreviewBookId } from '../lib/preview-books'
import type { PreviewCaseId } from '../lib/pattern-preview'
import type { NamingRule, NamingRuleId } from '../lib/naming-rules'
/** Another rule the header names, rendered as a link that selects it. */
export interface RuleLink {
id: NamingRuleId
name: string
}
const props = defineProps<{
rule: NamingRule
name: string
icon: string
/** The pattern being edited, or a preview pattern that trails it while typing on phones. */
pattern: string
previewPattern: string
inherited: boolean
dirty: boolean
error: string
scopeSummary: string
inheritedFromName: string
sanitize: boolean
previewBookId: PreviewBookId
caseId: PreviewCaseId
/** For a global upload default: the libraries that follow it. */
usedBy: RuleLink[]
/** For a library: the global default it follows or overrides. */
base: RuleLink | null
/** Libraries this pattern governs that rename files when a book's details are saved. */
renamingLibraries: string[]
canBulkRename: boolean
}>()
const emit = defineEmits<{
'update:pattern': [value: string]
'update:previewBookId': [value: PreviewBookId]
'update:caseId': [value: PreviewCaseId]
addOverride: []
removeOverride: []
resetToShipped: []
openHelp: []
openExamples: []
select: [id: NamingRuleId]
back: []
completing: [value: boolean]
}>()
const { t } = useI18n()
@@ -37,12 +63,17 @@ const { t } = useI18n()
const field = ref<InstanceType<typeof PatternInput> | null>(null)
const fieldId = 'file-naming-pattern'
const scopeId = `${fieldId}-scope`
const hintId = `${fieldId}-hint`
const errorId = `${fieldId}-error`
const describedBy = computed(() => (props.error ? `${hintId} ${errorId}` : hintId))
const isLibrary = computed(() => props.rule.kind === 'library')
const isFolderMode = computed(() => props.rule.organizationMode === 'book_per_folder')
const canEdit = computed(() => !props.inherited)
const isCustom = computed(() => isLibrary.value && !props.inherited)
const isDownload = computed(() => props.rule.target === 'download')
const describedBy = computed(() => [scopeId, props.error ? errorId : props.inherited ? '' : hintId].filter(Boolean).join(' '))
const sampleValues = computed(() => PREVIEW_BOOKS[props.previewBookId].metadata)
const usedByParts = computed(() => formatListParts(props.usedBy.map((link) => link.name)))
// Mirrors the pill tokens the rest of the app uses for organization mode.
const organizationBadgeClass = computed(() =>
@@ -51,11 +82,11 @@ const organizationBadgeClass = computed(() =>
: 'border-[var(--pill-file-as-book)]/40 bg-[var(--pill-file-as-book)]/10 text-[var(--pill-file-as-book)]',
)
const ruleHint = computed(() => {
if (props.rule.kind === 'library') return t('settings.reader.fileNaming.libraryRuleHint', { base: props.inheritedFromName })
if (props.rule.globalKey === 'download') return t('settings.reader.fileNaming.downloadPatternHint')
return isFolderMode.value ? t('settings.reader.fileNaming.folderAsBookDefaultHint') : t('settings.reader.fileNaming.fileAsBookDefaultHint')
})
/** Index into `usedBy` for an element part of the formatted list. */
function linkAt(partIndex: number): RuleLink | undefined {
const elementIndex = usedByParts.value.slice(0, partIndex + 1).filter((part) => part.type === 'element').length - 1
return props.usedBy[elementIndex]
}
function handlePattern(value: string) {
emit('update:pattern', value)
@@ -65,6 +96,18 @@ function handleInsert(text: string, caretOffset?: number) {
field.value?.insertAtCaret(text, caretOffset)
}
function handleCompleting(value: boolean) {
emit('completing', value)
}
function handleBook(value: PreviewBookId) {
emit('update:previewBookId', value)
}
function handleCase(value: PreviewCaseId) {
emit('update:caseId', value)
}
function handleAddOverride() {
emit('addOverride')
}
@@ -77,127 +120,221 @@ function handleReset() {
emit('resetToShipped')
}
function handleHelp() {
emit('openHelp')
function handleExamples() {
emit('openExamples')
}
function handleBack() {
emit('back')
}
function handleSelectBase() {
if (props.base) emit('select', props.base.id)
}
function handleSelectLink(partIndex: number) {
const link = linkAt(partIndex)
if (link) emit('select', link.id)
}
async function handleCopyPattern() {
const copied = await copyToClipboard(props.pattern)
if (copied) toast.success(t('settings.reader.fileNaming.patternCopied'))
else toast.error(t('settings.reader.fileNaming.copyPatternFailed'))
}
</script>
<template>
<div class="flex min-w-0 flex-col">
<header class="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border px-3.5 py-3 md:flex-nowrap md:items-start md:px-5 md:py-4">
<header class="flex items-start gap-2.5 border-b border-border px-3.5 py-3 @2xl/naming:gap-3 @2xl/naming:px-5 @2xl/naming:py-4">
<Button
variant="ghost"
size="icon-sm"
type="button"
class="md:hidden"
class="-ms-1.5 @2xl/naming:hidden"
:aria-label="t('settings.reader.fileNaming.backToRules')"
@click="handleBack"
>
<ChevronLeft :size="18" aria-hidden="true" />
</Button>
<span class="grid size-9 shrink-0 place-items-center rounded-lg bg-primary/12 text-primary">
<span class="hidden size-9 shrink-0 place-items-center rounded-lg bg-primary/12 text-primary @2xl/naming:grid">
<AppIcon :icon="icon" fallback="File" :size="17" aria-hidden="true" />
</span>
<div class="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-1">
<h2 class="flex flex-wrap items-center gap-2 font-serif text-base font-semibold tracking-tight text-foreground md:text-[17px]">
{{ name }}
<Badge v-if="rule.kind === 'library'" variant="outline" class="gap-1" :class="organizationBadgeClass">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<h2 class="font-serif text-base font-semibold tracking-tight text-foreground @2xl/naming:text-[17px]">{{ name }}</h2>
<Badge v-if="isLibrary" variant="outline" class="gap-1" :class="organizationBadgeClass">
<FolderOpen v-if="isFolderMode" :size="11" aria-hidden="true" />
<File v-else :size="11" aria-hidden="true" />
{{ isFolderMode ? t('settings.reader.fileNaming.orgFolderAsBook') : t('settings.reader.fileNaming.orgFileAsBook') }}
</Badge>
<Badge v-if="rule.kind === 'library'" :variant="inherited ? 'outline' : 'secondary'">
{{ inherited ? t('settings.reader.fileNaming.badgeInherits') : t('settings.reader.fileNaming.badgeCustom') }}
</Badge>
<Badge v-if="dirty" variant="secondary" class="gap-1">
<Badge v-if="isCustom" variant="secondary">{{ t('settings.reader.fileNaming.badgeCustom') }}</Badge>
<Badge v-if="dirty" variant="secondary" class="gap-1.5">
<span aria-hidden="true" class="size-1.5 rounded-full bg-warning" />
{{ t('settings.reader.fileNaming.badgeUnsaved') }}
</Badge>
</h2>
<p :id="hintId" class="w-full text-xs leading-relaxed text-muted-foreground">{{ ruleHint }}</p>
</div>
<p :id="scopeId" class="mt-0.5 text-xs leading-relaxed text-muted-foreground">
<template v-if="isDownload">{{ t('settings.reader.fileNaming.scopeDownload') }}</template>
<i18n-t
v-else-if="isLibrary && base"
:keypath="inherited ? 'settings.reader.fileNaming.followsBase' : 'settings.reader.fileNaming.libraryRuleHint'"
scope="global"
>
<template #base>
<button
type="button"
class="text-foreground underline decoration-foreground/30 underline-offset-2 hover:text-primary hover:decoration-current"
@click="handleSelectBase"
>
{{ base.name }}
</button>
</template>
</i18n-t>
<i18n-t v-else-if="usedBy.length" keypath="settings.reader.fileNaming.usedBy" scope="global">
<template #libraries>
<template v-for="(part, index) in usedByParts" :key="index"
><button
v-if="part.type === 'element'"
type="button"
class="text-foreground underline decoration-foreground/30 underline-offset-2 hover:text-primary hover:decoration-current"
@click="handleSelectLink(index)"
>
{{ part.value }}</button
><template v-else>{{ part.value }}</template></template
>
</template>
</i18n-t>
<template v-else>{{ t('settings.reader.fileNaming.scopeGlobalNone') }}</template>
</p>
</div>
<Button variant="secondary" size="sm" type="button" class="hidden shrink-0 md:inline-flex" @click="handleHelp">
<BookOpen :size="13" aria-hidden="true" />
{{ t('settings.reader.fileNaming.examples') }}
</Button>
<div class="flex shrink-0 items-center gap-1.5">
<PatternRecipeMenu
v-if="!inherited"
:target="rule.target"
:organization-mode="rule.organizationMode"
:current-pattern="pattern"
:sanitize="sanitize"
:preview-book-id="previewBookId"
@apply="handlePattern"
@open-examples="handleExamples"
/>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="icon-sm" type="button" :aria-label="t('settings.editor.moreActions')">
<MoreHorizontal :size="16" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="min-w-56">
<DropdownMenuItem v-if="rule.kind === 'global'" :disabled="pattern === rule.shippedDefault" @select="handleReset">
<RotateCcw aria-hidden="true" />
{{ t('settings.reader.fileNaming.resetToShipped') }}
</DropdownMenuItem>
<DropdownMenuItem v-else-if="isCustom" @select="handleRemoveOverride">
<Undo2 aria-hidden="true" />
{{ t('settings.reader.fileNaming.useGlobalDefault') }}
</DropdownMenuItem>
<DropdownMenuItem v-else @select="handleAddOverride">
<Plus aria-hidden="true" />
{{ t('settings.reader.fileNaming.addOverride') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @select="handleCopyPattern">
<Copy aria-hidden="true" />
{{ t('settings.reader.fileNaming.copyPattern') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<div class="@container flex flex-col gap-4 px-3.5 py-4 md:px-5">
<div class="@container flex flex-col gap-6 px-3.5 py-4 @2xl/naming:px-5 @2xl/naming:py-5">
<div
v-if="inherited"
class="flex flex-col gap-2.5 rounded-md border border-dashed border-border bg-muted/40 px-3 py-2.5 sm:flex-row sm:items-center sm:gap-3"
class="flex flex-col gap-2.5 rounded-md border border-dashed border-border bg-muted/40 px-3 py-2.5 @md:flex-row @md:items-center @md:gap-3"
>
<CornerDownRight :size="15" class="hidden shrink-0 text-muted-foreground sm:block" aria-hidden="true" />
<CornerDownRight :size="15" class="hidden shrink-0 text-muted-foreground @md:block" aria-hidden="true" />
<p class="min-w-0 flex-1 text-xs leading-relaxed text-muted-foreground">
{{ t('settings.reader.fileNaming.inheritsNotice', { base: inheritedFromName }) }}
{{ t('settings.reader.fileNaming.inheritsNotice', { base: base?.name ?? '' }) }}
</p>
<Button variant="outline" size="sm" type="button" class="shrink-0 self-start sm:self-auto" @click="handleAddOverride">
<Button variant="outline" size="sm" type="button" class="shrink-0 self-start @md:self-auto" @click="handleAddOverride">
<Plus :size="12" aria-hidden="true" />
{{ t('settings.reader.fileNaming.addOverride') }}
</Button>
</div>
<div>
<div class="mb-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
<div class="mb-1.5 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1">
<label :for="fieldId" class="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
{{ t('settings.reader.fileNaming.patternLabel') }}
</label>
<Badge v-if="inherited" variant="outline">{{ t('settings.reader.fileNaming.readOnlyFromGlobal') }}</Badge>
<span class="grow" />
<Button v-if="rule.kind === 'library' && !inherited" variant="ghost" size="sm" type="button" @click="handleRemoveOverride">
<RotateCcw :size="12" aria-hidden="true" />
{{ t('settings.reader.fileNaming.useGlobalDefault') }}
</Button>
<Button
v-else-if="rule.kind === 'global'"
variant="ghost"
size="sm"
type="button"
:disabled="pattern === rule.shippedDefault"
@click="handleReset"
>
<RotateCcw :size="12" aria-hidden="true" />
{{ t('settings.reader.fileNaming.resetToShipped') }}
</Button>
<PatternInsertBar v-if="!inherited" :target="rule.target" :sample-values="sampleValues" @insert="handleInsert" />
</div>
<PatternInput
:id="fieldId"
ref="field"
:model-value="pattern"
lines
:mode="rule.organizationMode"
:target="rule.target"
:readonly="inherited"
:invalid="!!error"
:described-by="describedBy"
:placeholder="rule.shippedDefault"
:sample-values="sampleValues"
@update:model-value="handlePattern"
@completing="handleCompleting"
/>
<p v-if="error" :id="errorId" role="alert" class="mt-1.5 text-xs font-medium text-destructive">{{ error }}</p>
<p v-else class="mt-1.5 text-[11px] text-muted-foreground">{{ scopeSummary }}</p>
<p v-else-if="!inherited && isDownload" :id="hintId" class="mt-1.5 text-[11px] text-muted-foreground">
{{ t('settings.reader.fileNaming.downloadLineHint') }}
</p>
<i18n-t
v-else-if="!inherited"
:id="hintId"
keypath="settings.reader.fileNaming.lineHint"
tag="p"
class="mt-1.5 text-[11px] leading-relaxed text-muted-foreground"
scope="global"
>
<template #enter><kbd class="rounded border border-border px-1 font-mono text-[10.5px]">Enter</kbd></template>
<template #brace><kbd class="rounded border border-border px-1 font-mono text-[10.5px]">{</kbd></template>
</i18n-t>
</div>
<PatternTokenPalette v-if="canEdit" @insert="handleInsert" />
<PatternRecipeStrip
v-if="canEdit"
<NamingResultPanel
:pattern="previewPattern"
:target="rule.target"
:organization-mode="rule.organizationMode"
:current-pattern="pattern"
:mode="rule.organizationMode"
:sanitize="sanitize"
@apply="handlePattern"
:preview-book-id="previewBookId"
:case-id="caseId"
:stale="!!error"
@update:preview-book-id="handleBook"
@update:case-id="handleCase"
/>
<Button variant="secondary" size="sm" type="button" class="self-start md:hidden" @click="handleHelp">
<BookOpen :size="13" aria-hidden="true" />
{{ t('settings.reader.fileNaming.examples') }}
</Button>
<p v-if="renamingLibraries.length" class="flex items-start gap-2 text-xs leading-relaxed text-muted-foreground">
<Info :size="14" class="mt-0.5 shrink-0" aria-hidden="true" />
<span>
{{ t('settings.reader.fileNaming.renameNote', { libraries: formatList(renamingLibraries) }) }}
<RouterLink
v-if="canBulkRename"
:to="{ name: 'tools-bulk-rename' }"
class="ms-1 whitespace-nowrap text-foreground underline decoration-foreground/30 underline-offset-2 hover:text-primary hover:decoration-current"
>
{{ t('tools.header.bulkRename') }}
</RouterLink>
</span>
</p>
</div>
</div>
</template>
@@ -1,29 +1,32 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Globe, Library, Search } from '@lucide/vue'
import { ChevronRight } from '@lucide/vue'
import AppIcon from '@/components/AppIcon.vue'
import type { NamingRuleId } from '../lib/naming-rules'
export interface RailItem {
id: NamingRuleId
name: string
detail: string
icon: string
custom: boolean
dirty: boolean
}
const props = defineProps<{
globals: RailItem[]
/** A global default and the libraries that sit under it: inheritance is drawn, not described. */
export interface RailGroup {
rule: RailItem
libraries: RailItem[]
librariesTotal: number
customCount: number
/** Drawn apart from the groups above it, for a rule that governs something other than uploads. */
separate?: boolean
}
const props = defineProps<{
groups: RailGroup[]
selectedId: NamingRuleId
query: string
editorId: string
}>()
const emit = defineEmits<{ select: [id: NamingRuleId]; 'update:query': [value: string] }>()
const emit = defineEmits<{ select: [id: NamingRuleId] }>()
const { t } = useI18n()
@@ -31,107 +34,87 @@ function handleSelect(id: NamingRuleId) {
emit('select', id)
}
function handleQuery(event: Event) {
emit('update:query', (event.target as HTMLInputElement).value)
function isCurrent(id: NamingRuleId): boolean {
return id === props.selectedId
}
</script>
<template>
<div class="flex min-w-0 flex-col border-border bg-muted/25 md:border-r">
<div class="p-2.5">
<label class="sr-only" for="file-naming-rule-filter">{{ t('settings.reader.fileNaming.filterRules') }}</label>
<div
class="flex items-center gap-2 rounded-md border border-input bg-background px-2 py-1.5 focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/45"
>
<Search :size="13" class="shrink-0 text-muted-foreground" aria-hidden="true" />
<input
id="file-naming-rule-filter"
type="search"
:value="query"
:placeholder="t('settings.reader.fileNaming.filterRules')"
class="w-full min-w-0 border-0 bg-transparent text-[13px] text-foreground outline-none placeholder:text-muted-foreground"
@input="handleQuery"
/>
</div>
</div>
<nav class="flex min-w-0 flex-col gap-1 border-border bg-muted/25 p-2 md:border-e" :aria-label="t('settings.reader.fileNaming.rulesNav')">
<ul class="flex list-none flex-col gap-0.5 p-0">
<li v-for="group in groups" :key="group.rule.id" :class="group.separate ? 'mt-1.5 border-t border-border pt-2' : ''">
<button
type="button"
:aria-current="isCurrent(group.rule.id) ? 'true' : undefined"
:aria-controls="editorId"
class="relative flex min-h-11 w-full items-center gap-2.5 rounded-md px-2 text-start @2xl/naming:min-h-9 transition-colors hover:bg-primary/8 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
:class="isCurrent(group.rule.id) ? 'bg-primary/12 shadow-[inset_2px_0_0_var(--primary)]' : ''"
@click="handleSelect(group.rule.id)"
>
<span
class="grid size-6 shrink-0 place-items-center rounded-[5px]"
:class="isCurrent(group.rule.id) ? 'bg-primary/20 text-primary' : 'bg-surface-3 text-muted-foreground'"
>
<AppIcon :icon="group.rule.icon" fallback="File" :size="13" aria-hidden="true" />
</span>
<span
class="min-w-0 flex-1 truncate text-sm font-medium @2xl/naming:text-[13px]"
:class="isCurrent(group.rule.id) ? 'font-semibold text-primary' : 'text-foreground'"
>
{{ group.rule.name }}
</span>
<span
v-if="group.rule.dirty"
class="size-1.5 shrink-0 rounded-full bg-warning"
role="img"
:aria-label="t('settings.reader.fileNaming.unsavedMarker')"
/>
<ChevronRight :size="16" class="shrink-0 text-muted-foreground @2xl/naming:hidden" aria-hidden="true" />
</button>
<div class="min-h-0 flex-1 overflow-y-auto px-1.5 pb-2.5">
<template v-if="props.globals.length">
<p class="flex items-center gap-1.5 px-2 pb-1 pt-2.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<Globe :size="11" class="shrink-0" aria-hidden="true" />
{{ t('settings.reader.fileNaming.globalDefaults') }}
</p>
<ul class="list-none space-y-px p-0">
<li v-for="item in props.globals" :key="item.id">
<ul v-if="group.libraries.length" class="mb-1.5 ms-5 mt-px flex list-none flex-col gap-px border-s border-border p-0">
<li v-for="item in group.libraries" :key="item.id">
<button
type="button"
:aria-current="item.id === selectedId ? 'true' : undefined"
:aria-current="isCurrent(item.id) ? 'true' : undefined"
:aria-controls="editorId"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-primary/8 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
:class="item.id === selectedId ? 'bg-primary/12 shadow-[inset_2px_0_0_var(--primary)]' : ''"
class="relative flex min-h-10 w-full items-center gap-2 rounded-e-md ps-4 pe-2 text-start @2xl/naming:min-h-7.5 transition-colors before:absolute before:start-0 before:top-1/2 before:h-px before:w-2.5 before:bg-border hover:bg-primary/8 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
:class="isCurrent(item.id) ? 'bg-primary/12 shadow-[inset_2px_0_0_var(--primary)]' : ''"
@click="handleSelect(item.id)"
>
<AppIcon
:icon="item.icon"
fallback="FolderOpen"
:size="14"
class="shrink-0"
:class="item.custom || isCurrent(item.id) ? 'text-primary' : 'text-muted-foreground'"
aria-hidden="true"
/>
<span
class="grid size-6 shrink-0 place-items-center rounded-[5px]"
:class="item.id === selectedId ? 'bg-primary/20 text-primary' : 'bg-surface-3 text-muted-foreground'"
class="min-w-0 flex-1 truncate text-sm @2xl/naming:text-[13px]"
:class="isCurrent(item.id) ? 'font-semibold text-primary' : 'text-foreground'"
>
<AppIcon :icon="item.icon" fallback="File" :size="13" aria-hidden="true" />
{{ item.name }}
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-[13px]" :class="item.id === selectedId ? 'font-semibold text-primary' : 'text-foreground'">{{
item.name
}}</span>
<span class="block truncate text-[11px] text-muted-foreground">{{ item.detail }}</span>
</span>
<span v-if="item.dirty" class="size-1.5 shrink-0 rounded-full bg-warning" :aria-label="t('settings.reader.fileNaming.unsavedMarker')" />
</button>
</li>
</ul>
</template>
<template v-if="props.libraries.length">
<p class="flex items-center gap-1.5 px-2 pb-1 pt-2.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<Library :size="11" class="shrink-0" aria-hidden="true" />
{{ t('settings.reader.fileNaming.librariesGroup') }}
<span class="ml-auto tabular-nums">{{ t('settings.reader.fileNaming.customCount', { custom: customCount, total: librariesTotal }) }}</span>
</p>
<ul class="list-none space-y-px p-0">
<li v-for="item in props.libraries" :key="item.id">
<button
type="button"
:aria-current="item.id === selectedId ? 'true' : undefined"
:aria-controls="editorId"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-primary/8 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
:class="item.id === selectedId ? 'bg-primary/12 shadow-[inset_2px_0_0_var(--primary)]' : ''"
@click="handleSelect(item.id)"
>
<span
class="grid size-6 shrink-0 place-items-center rounded-[5px]"
:class="item.id === selectedId ? 'bg-primary/20 text-primary' : 'bg-surface-3 text-muted-foreground'"
>
<AppIcon :icon="item.icon" fallback="FolderOpen" :size="13" aria-hidden="true" />
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-[13px]" :class="item.id === selectedId ? 'font-semibold text-primary' : 'text-foreground'">{{
item.name
}}</span>
<span class="block truncate text-[11px] text-muted-foreground">{{ item.detail }}</span>
</span>
<span v-if="item.dirty" class="size-1.5 shrink-0 rounded-full bg-warning" :aria-label="t('settings.reader.fileNaming.unsavedMarker')" />
v-if="item.dirty"
class="size-1.5 shrink-0 rounded-full bg-warning"
role="img"
:aria-label="t('settings.reader.fileNaming.unsavedMarker')"
/>
<span
v-else-if="item.custom"
class="shrink-0 rounded bg-primary/15 px-1 py-px text-[9px] font-bold uppercase tracking-wide text-primary"
class="shrink-0 rounded bg-primary/15 px-1 py-px text-[9.5px] font-bold uppercase tracking-wide text-primary"
>
{{ t('settings.reader.fileNaming.badgeCustom') }}
</span>
<ChevronRight :size="16" class="shrink-0 text-muted-foreground @2xl/naming:hidden" aria-hidden="true" />
</button>
</li>
</ul>
</template>
</li>
</ul>
<p v-if="!props.globals.length && !props.libraries.length" class="px-2 py-8 text-center text-xs text-muted-foreground">
{{ t('settings.reader.fileNaming.noRuleMatches', { query }) }}
</p>
</div>
</div>
<p class="mt-auto px-2 pb-1 pt-3 text-[11.5px] leading-normal text-muted-foreground">{{ t('settings.reader.fileNaming.railFoot') }}</p>
</nav>
</template>
@@ -1,35 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Folder, FileText, TriangleAlert } from '@lucide/vue'
import { splitResolvedPath } from '../lib/pattern-preview'
const props = defineProps<{ path: string; indent?: number }>()
const { t } = useI18n()
const parts = computed(() => splitResolvedPath(props.path))
const step = computed(() => props.indent ?? 14)
</script>
<template>
<div v-if="!path" class="flex items-center gap-2 text-xs text-muted-foreground">
<TriangleAlert :size="14" class="shrink-0 text-warning" aria-hidden="true" />
{{ t('settings.reader.fileNaming.previewEmpty') }}
</div>
<div v-else class="font-mono text-[13px] leading-tight">
<div v-for="(folder, index) in parts.folders" :key="`${index}-${folder}`" class="flex min-w-0 items-start gap-1.5 py-0.5 text-muted-foreground">
<span aria-hidden="true" class="shrink-0" :style="{ width: `${index * step}px` }" />
<Folder :size="14" class="mt-0.5 shrink-0" aria-hidden="true" />
<span class="min-w-0 break-all">{{ folder }}</span>
</div>
<div class="flex min-w-0 items-start gap-1.5 py-0.5 font-semibold text-foreground">
<span aria-hidden="true" class="shrink-0" :style="{ width: `${parts.folders.length * step}px` }" />
<FileText :size="14" class="mt-0.5 shrink-0 text-primary" aria-hidden="true" />
<span class="min-w-0 break-all"
>{{ parts.stem }}<span class="font-medium text-primary">{{ parts.extension }}</span></span
>
</div>
</div>
</template>
@@ -1,6 +1,23 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { File, Folder, FolderOpen } from '@lucide/vue'
import type { OrganizationMode, PatternToken } from '@bookorbit/types'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import PatternText from './PatternText.vue'
import {
canToggleOptional,
displayOffset,
displayText,
joinWithPreviousLevel,
patternLevels,
patternOffset,
stripDisplayNewlines,
toggleOptionalLevel,
type PatternLevel,
} from '../lib/pattern-levels'
import { completeToken, matchTokens, partialTokenAt, TOKEN_DESCRIPTION_KEYS } from '../lib/pattern-tokens'
import type { NamingTarget } from '../lib/naming-rules'
const props = withDefaults(
defineProps<{
@@ -12,41 +29,182 @@ const props = withDefaults(
readonly?: boolean
disabled?: boolean
placeholder?: string
/** Draws one folder level per line with a gutter of level icons. */
lines?: boolean
mode?: OrganizationMode | null
target?: NamingTarget
/** Values shown beside each suggestion, so a token is recognised by what it produces. */
sampleValues?: Record<string, string>
}>(),
{ readonly: false, disabled: false, invalid: false },
{ readonly: false, disabled: false, invalid: false, lines: false, mode: null, target: 'upload', sampleValues: () => ({}) },
)
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const emit = defineEmits<{
'update:modelValue': [value: string]
/** True while a token is being typed through the suggestions, so its open brace is not an error yet. */
completing: [value: boolean]
}>()
const { t } = useI18n()
const entry = ref<HTMLTextAreaElement | null>(null)
const levels = computed<PatternLevel[]>(() =>
props.lines ? patternLevels(props.modelValue, props.mode, props.target) : [{ text: props.modelValue, kind: 'file', bookFolder: false }],
)
const display = computed(() => (props.lines ? displayText(levels.value) : props.modelValue))
const isEmpty = computed(() => props.modelValue.length === 0)
const editable = computed(() => !props.readonly && !props.disabled)
const splitsIntoFolders = computed(() => props.lines && props.target === 'upload')
/** Folder numbers count folders only, so the file line never shifts them. */
function levelLabel(index: number): string {
const level = levels.value[index]
if (!level || level.kind === 'file') return t('settings.reader.fileNaming.level.file')
if (level.bookFolder) return t('settings.reader.fileNaming.level.bookFolder')
const number = levels.value.slice(0, index + 1).filter((entry) => entry.kind !== 'file').length
return t('settings.reader.fileNaming.level.folder', { n: number })
}
function levelIcon(level: PatternLevel) {
if (level.kind === 'file') return File
return level.bookFolder ? FolderOpen : Folder
}
function isToggle(level: PatternLevel): boolean {
return editable.value && splitsIntoFolders.value && canToggleOptional(level)
}
function toggleTooltip(level: PatternLevel): string {
return level.kind === 'optional' ? t('settings.reader.fileNaming.level.skippedWhenEmpty') : t('settings.reader.fileNaming.level.alwaysCreated')
}
/* ───────────── caret plumbing ───────────── */
function caretInPattern(): number {
const field = entry.value
if (!field) return props.modelValue.length
return props.lines ? patternOffset(field.value, field.selectionStart ?? 0) : (field.selectionStart ?? 0)
}
/** Emits the new pattern, then puts the caret back once the display has been redrawn around it. */
function commit(value: string, caret: number) {
emit('update:modelValue', value)
void nextTick(() => {
const field = entry.value
if (!field) return
const offset = props.lines ? displayOffset(patternLevels(value, props.mode, props.target), caret) : caret
field.setSelectionRange(offset, offset)
})
}
/* ───────────── token suggestions after "{" ───────────── */
const suggestion = ref<{ start: number; query: string } | null>(null)
const activeIndex = ref(0)
const listboxId = computed(() => `${props.id}-tokens`)
const suggestions = computed<PatternToken[]>(() => (suggestion.value ? matchTokens(suggestion.value.query) : []))
const suggestOpen = computed(() => suggestion.value !== null)
const optionId = (index: number) => `${props.id}-token-${index}`
watch(suggestOpen, (open) => {
emit('completing', open)
})
const tokenText = (token: string) => `{${token}}`
function refreshSuggestion(value: string, caret: number) {
const next = partialTokenAt(value, caret)
if (next && (!suggestion.value || suggestion.value.start !== next.start)) activeIndex.value = 0
suggestion.value = next
}
function closeSuggestions() {
suggestion.value = null
}
function chooseSuggestion(token: PatternToken) {
const current = suggestion.value
if (!current) return
const completed = completeToken(props.modelValue, current.start, caretInPattern(), token)
closeSuggestions()
commit(completed.pattern, completed.caret)
}
function handleOptionDown(event: MouseEvent, token: PatternToken) {
// Keeps focus, and so the caret, in the field while the option is picked.
event.preventDefault()
chooseSuggestion(token)
}
/* ───────────── events ───────────── */
function handleInput(event: Event) {
const target = event.target as HTMLTextAreaElement
// Patterns are a single path expression; a pasted newline would silently corrupt one.
const cleaned = target.value.replace(/[\r\n]+/g, '')
if (cleaned !== target.value) target.value = cleaned
emit('update:modelValue', cleaned)
const field = event.target as HTMLTextAreaElement
const caret = props.lines ? patternOffset(field.value, field.selectionStart ?? 0) : (field.selectionStart ?? 0)
// Patterns are a single path expression; a pasted or typed newline would silently corrupt one.
const value = stripDisplayNewlines(field.value)
refreshSuggestion(value, caret)
if (!props.lines && value !== field.value) field.value = value
commit(value, caret)
}
function handleEnter(event: KeyboardEvent) {
event.preventDefault()
function handleKeydown(event: KeyboardEvent) {
if (suggestOpen.value && suggestions.value.length > 0) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
const step = event.key === 'ArrowDown' ? 1 : -1
activeIndex.value = (activeIndex.value + step + suggestions.value.length) % suggestions.value.length
return
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
const token = suggestions.value[activeIndex.value]
if (token) chooseSuggestion(token)
return
}
}
if (event.key === 'Escape' && suggestOpen.value) {
event.preventDefault()
event.stopPropagation()
closeSuggestions()
return
}
if (event.key === 'Enter') {
event.preventDefault()
// Enter means "start a new folder", which is a slash; the line break follows from it.
if (splitsIntoFolders.value && editable.value) insertAtCaret('/')
return
}
if (event.key === 'Backspace' && props.lines && editable.value) {
const field = event.target as HTMLTextAreaElement
const at = field.selectionStart ?? 0
if (at !== field.selectionEnd || field.value.charAt(at - 1) !== '\n') return
// Deleting the line break alone would be undone by the next redraw; remove the slash instead.
const joined = joinWithPreviousLevel(props.modelValue, patternOffset(field.value, at))
if (!joined) return
event.preventDefault()
commit(joined.pattern, joined.offset)
}
}
/** Lets the token palette drop text at the caret instead of appending it. */
function handleBlur() {
closeSuggestions()
}
function handleToggle(index: number) {
const next = toggleOptionalLevel(levels.value, index)
if (next !== null) emit('update:modelValue', next)
}
/** Lets the insert bar drop text at the caret instead of appending it. */
function insertAtCaret(text: string, caretOffset = text.length) {
const field = entry.value
if (!field || props.readonly || props.disabled) return
const start = field.selectionStart ?? props.modelValue.length
const end = field.selectionEnd ?? start
if (!field || !editable.value) return
const start = caretInPattern()
const end = props.lines ? patternOffset(field.value, field.selectionEnd ?? start) : (field.selectionEnd ?? start)
const next = props.modelValue.slice(0, start) + text + props.modelValue.slice(end)
emit('update:modelValue', next)
requestAnimationFrame(() => {
field.focus()
const caret = start + caretOffset
field.setSelectionRange(caret, caret)
})
field.focus()
commit(next, start + caretOffset)
}
function focus() {
@@ -61,19 +219,14 @@ defineExpose({ insertAtCaret, focus })
class="relative rounded-md border bg-background transition-colors focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/45"
:class="[
invalid ? 'border-destructive focus-within:border-destructive focus-within:ring-destructive/40' : 'border-input',
readonly ? 'border-dashed bg-muted/30' : '',
disabled ? 'opacity-50' : '',
]"
>
<!-- The painted layer sits in normal flow so it, not JavaScript, sets the height. -->
<pre
aria-hidden="true"
class="m-0 min-h-9 whitespace-pre-wrap break-words px-2.5 py-1.5 font-mono text-[13px] leading-6"
><span v-if="isEmpty" class="text-muted-foreground">{{ placeholder }}</span><PatternText v-else :pattern="modelValue" /></pre>
<textarea
:id="id"
ref="entry"
:value="modelValue"
:value="display"
rows="1"
spellcheck="false"
autocomplete="off"
@@ -84,18 +237,100 @@ defineExpose({ insertAtCaret, focus })
:aria-label="ariaLabel"
:aria-describedby="describedBy"
:aria-invalid="invalid ? 'true' : undefined"
class="absolute inset-0 size-full resize-none overflow-hidden whitespace-pre-wrap break-words border-0 bg-transparent px-2.5 py-1.5 font-mono text-[13px] leading-6 text-transparent caret-primary outline-none selection:bg-primary/30"
:aria-autocomplete="editable ? 'list' : undefined"
:aria-controls="suggestOpen ? listboxId : undefined"
:aria-activedescendant="suggestOpen && suggestions.length ? optionId(activeIndex) : undefined"
class="pattern-text absolute inset-y-0 end-0 z-10 resize-none overflow-hidden border-0 bg-transparent py-1.5 pe-2.5 font-mono text-[13px] leading-6 text-transparent caret-primary outline-none selection:bg-primary/30"
:class="lines ? 'start-9 ps-2.5' : 'start-0 ps-2.5'"
@input="handleInput"
@keydown.enter="handleEnter"
@keydown="handleKeydown"
@blur="handleBlur"
/>
<span v-if="lines" aria-hidden="true" class="pointer-events-none absolute inset-y-0 start-9 w-px bg-border/70" />
<!-- The painted layer sits in normal flow so it, not JavaScript, sets the height. -->
<div class="pattern-text min-h-9 py-1.5 pe-2.5 font-mono text-[13px] leading-6" :class="lines ? 'ps-[2.875rem]' : 'ps-2.5'">
<div
v-for="(level, index) in levels"
:key="index"
class="relative min-h-6"
:class="lines && index > 0 ? 'shadow-[inset_0_1px_0_color-mix(in_oklch,var(--border)_70%,transparent)]' : ''"
>
<template v-if="lines">
<Tooltip v-if="isToggle(level)">
<TooltipTrigger as-child>
<button
type="button"
class="absolute -start-[2.875rem] top-0 z-20 grid h-6 w-9 place-items-center rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
:aria-pressed="level.kind === 'optional'"
:aria-label="t('settings.reader.fileNaming.level.skipWhenEmpty', { level: levelLabel(index) })"
@click="handleToggle(index)"
>
<Folder :size="13" :class="level.kind === 'optional' ? 'folder-dashed text-pattern-optional' : ''" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>{{ toggleTooltip(level) }}</TooltipContent>
</Tooltip>
<span
v-else
class="absolute -start-[2.875rem] top-0 grid h-6 w-9 place-items-center"
:class="level.kind === 'file' || level.bookFolder ? 'text-primary' : 'text-muted-foreground'"
:title="levelLabel(index)"
aria-hidden="true"
>
<component :is="levelIcon(level)" :size="13" :class="level.kind === 'optional' ? 'folder-dashed text-pattern-optional' : ''" />
</span>
</template>
<span aria-hidden="true"
><span v-if="isEmpty && index === 0" class="text-muted-foreground">{{ placeholder }}</span
><PatternText v-else :pattern="level.text" /><span
v-if="lines && splitsIntoFolders && level.kind === 'file' && !level.text && !isEmpty"
class="font-sans text-xs text-muted-foreground"
>{{ t('settings.reader.fileNaming.uploadedNameKept') }}</span
>&#8203;</span
>
</div>
</div>
<ul
v-if="suggestOpen"
:id="listboxId"
role="listbox"
:aria-label="t('settings.reader.fileNaming.tokenSuggestions')"
class="absolute inset-x-0 top-full z-30 mt-1 max-h-72 list-none overflow-y-auto rounded-md border border-border bg-popover p-1 shadow-md"
>
<li
v-for="(token, index) in suggestions"
:id="optionId(index)"
:key="token"
role="option"
:aria-selected="index === activeIndex"
class="grid cursor-default grid-cols-[8.5rem_minmax(0,1fr)_auto] items-center gap-3 rounded-sm px-2 py-1.5 text-xs"
:class="index === activeIndex ? 'bg-accent text-accent-foreground' : ''"
@mousedown="handleOptionDown($event, token)"
>
<span class="font-mono font-semibold text-pattern-token">{{ tokenText(token) }}</span>
<span class="truncate text-foreground">{{ t(TOKEN_DESCRIPTION_KEYS[token]) }}</span>
<span class="max-w-40 truncate text-muted-foreground">{{ sampleValues[token] || '' }}</span>
</li>
<li v-if="suggestions.length === 0" class="px-2 py-1.5 text-xs text-muted-foreground">
{{ t('settings.reader.fileNaming.noTokenMatch', { query: suggestion?.query ?? '' }) }}
</li>
</ul>
</div>
</template>
<style scoped>
pre,
textarea {
tab-size: 2;
.pattern-text {
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: normal;
tab-size: 2;
font-variant-ligatures: none;
}
.folder-dashed :deep(path) {
stroke-dasharray: 2.6 2.4;
}
</style>
@@ -0,0 +1,188 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronDown, Plus } from '@lucide/vue'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { MODIFIER_DESCRIPTION_KEYS, MODIFIER_NAMES, TOKEN_DESCRIPTION_KEYS, TOKEN_NAMES } from '../lib/pattern-tokens'
import type { NamingTarget } from '../lib/naming-rules'
const props = defineProps<{
target: NamingTarget
/** Values for the preview book, so each token is recognised by what it produces. */
sampleValues: Record<string, string>
}>()
const emit = defineEmits<{ insert: [text: string, caretOffset?: number] }>()
const { t } = useI18n()
const tokens = computed(() =>
TOKEN_NAMES.map((token) => ({ text: `{${token}}`, description: t(TOKEN_DESCRIPTION_KEYS[token]), sample: props.sampleValues[token] ?? '' })),
)
const modifiers = computed(() => MODIFIER_NAMES.map((modifier) => ({ text: `:${modifier}`, description: t(MODIFIER_DESCRIPTION_KEYS[modifier]) })))
const canAddFolder = computed(() => props.target === 'upload')
function insertText(text: string) {
emit('insert', text)
}
function insertOptional() {
// Caret lands inside the brackets, which is where the next keystroke belongs.
emit('insert', '<>', 1)
}
function insertFallback() {
emit('insert', '|')
}
function insertFolder() {
emit('insert', '/')
}
/** Focus stays in the pattern field the insert just went into, rather than returning to the trigger. */
function keepFieldFocus(event: Event) {
event.preventDefault()
}
</script>
<template>
<div class="flex items-center" role="toolbar" :aria-label="t('settings.reader.fileNaming.insertToolbar')">
<!-- Wide editors get one control per kind of thing to insert. -->
<div class="hidden flex-wrap items-center gap-0.5 @xl:flex">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" type="button" class="h-7 gap-1.5 px-2 text-xs">
<span class="font-mono font-bold text-pattern-token" aria-hidden="true">{ }</span>
{{ t('settings.reader.fileNaming.tokens') }}
<ChevronDown :size="12" class="text-muted-foreground" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-[28rem] max-w-[calc(100vw-2rem)]" @close-auto-focus="keepFieldFocus">
<DropdownMenuItem
v-for="token in tokens"
:key="token.text"
class="grid grid-cols-[8.5rem_minmax(0,1fr)_auto] gap-3 text-xs"
@select="insertText(token.text)"
>
<span class="font-mono font-semibold text-pattern-token">{{ token.text }}</span>
<span class="truncate">{{ token.description }}</span>
<span class="max-w-36 truncate text-muted-foreground">{{ token.sample }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" type="button" class="h-7 gap-1.5 px-2 text-xs">
<span class="font-mono font-bold text-pattern-modifier" aria-hidden="true">:</span>
{{ t('settings.reader.fileNaming.modifiers') }}
<ChevronDown :size="12" class="text-muted-foreground" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-80 max-w-[calc(100vw-2rem)]" @close-auto-focus="keepFieldFocus">
<DropdownMenuLabel class="text-xs font-normal text-muted-foreground">{{
t('settings.reader.fileNaming.modifiersExplain')
}}</DropdownMenuLabel>
<DropdownMenuItem
v-for="modifier in modifiers"
:key="modifier.text"
class="grid grid-cols-[5rem_minmax(0,1fr)] gap-3 text-xs"
@select="insertText(modifier.text)"
>
<span class="font-mono font-semibold text-pattern-modifier">{{ modifier.text }}</span>
<span class="truncate">{{ modifier.description }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" type="button" class="h-7 gap-1.5 px-2 text-xs" @click="insertOptional">
<span class="font-mono font-bold text-pattern-optional" aria-hidden="true">&lt; &gt;</span>
{{ t('settings.reader.fileNaming.insertOptional') }}
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('settings.reader.fileNaming.paletteOptionalHint') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" type="button" class="h-7 gap-1.5 px-2 text-xs" @click="insertFallback">
<span class="font-mono font-bold text-pattern-fallback" aria-hidden="true">|</span>
{{ t('settings.reader.fileNaming.insertFallback') }}
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('settings.reader.fileNaming.paletteFallbackHint') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="canAddFolder">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" type="button" class="h-7 gap-1.5 px-2 text-xs" @click="insertFolder">
<span class="font-mono font-bold text-muted-foreground" aria-hidden="true">/</span>
{{ t('settings.reader.fileNaming.insertFolder') }}
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('settings.reader.fileNaming.paletteSeparatorHint') }}</TooltipContent>
</Tooltip>
</div>
<!-- Narrow editors fold the same choices into one menu. -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" type="button" class="h-7 gap-1 px-2 text-xs @xl:hidden">
<Plus :size="13" aria-hidden="true" />
{{ t('settings.reader.fileNaming.insert') }}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-[min(22rem,calc(100vw-2rem))]" @close-auto-focus="keepFieldFocus">
<DropdownMenuLabel class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{{
t('settings.reader.fileNaming.structure')
}}</DropdownMenuLabel>
<DropdownMenuItem class="gap-3 text-xs" @select="insertOptional">
<span class="w-8 font-mono font-bold text-pattern-optional">&lt; &gt;</span>{{ t('settings.reader.fileNaming.insertOptional') }}
</DropdownMenuItem>
<DropdownMenuItem class="gap-3 text-xs" @select="insertFallback">
<span class="w-8 font-mono font-bold text-pattern-fallback">|</span>{{ t('settings.reader.fileNaming.insertFallback') }}
</DropdownMenuItem>
<DropdownMenuItem v-if="canAddFolder" class="gap-3 text-xs" @select="insertFolder">
<span class="w-8 font-mono font-bold text-muted-foreground">/</span>{{ t('settings.reader.fileNaming.insertFolder') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{{
t('settings.reader.fileNaming.tokens')
}}</DropdownMenuLabel>
<DropdownMenuItem
v-for="token in tokens"
:key="token.text"
class="grid grid-cols-[8rem_minmax(0,1fr)] gap-3 text-xs"
@select="insertText(token.text)"
>
<span class="font-mono font-semibold text-pattern-token">{{ token.text }}</span>
<span class="truncate">{{ token.description }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{{
t('settings.reader.fileNaming.modifiers')
}}</DropdownMenuLabel>
<DropdownMenuItem
v-for="modifier in modifiers"
:key="modifier.text"
class="grid grid-cols-[5rem_minmax(0,1fr)] gap-3 text-xs"
@select="insertText(modifier.text)"
>
<span class="font-mono font-semibold text-pattern-modifier">{{ modifier.text }}</span>
<span class="truncate">{{ modifier.description }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { BookOpen, Check, ChevronDown, WandSparkles } from '@lucide/vue'
import type { OrganizationMode } from '@bookorbit/types'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import AppIcon from '@/components/AppIcon.vue'
import { recipePattern, recipesFor, type PatternRecipe } from '../lib/pattern-recipes'
import { resolvePreview } from '../lib/pattern-resolution'
import { PREVIEW_BOOKS, type PreviewBookId } from '../lib/preview-books'
import type { NamingTarget } from '../lib/naming-rules'
const props = defineProps<{
target: NamingTarget
organizationMode: OrganizationMode | null
currentPattern: string
sanitize: boolean
previewBookId: PreviewBookId
}>()
const emit = defineEmits<{ apply: [pattern: string]; openExamples: [] }>()
const { t } = useI18n()
interface RecipeEntry {
id: string
icon: string
name: string
pattern: string
path: string
}
// Resolved only while the menu is rendered, so typing in the field does not re-resolve every recipe.
const entries = computed<RecipeEntry[]>(() => {
const book = PREVIEW_BOOKS[props.previewBookId]
return recipesFor(props.target).map((recipe: PatternRecipe) => {
const pattern = recipePattern(recipe, props.organizationMode)
const preview = resolvePreview(pattern, book.metadata, {
target: props.target,
mode: props.organizationMode,
extension: book.extension,
sanitize: props.sanitize,
})
return {
id: recipe.id,
icon: recipe.icon,
name: t(`settings.reader.fileNaming.recipe.${recipe.id}` as 'settings.reader.fileNaming.recipe.seriesShelf'),
pattern,
path: preview.path.split('/').join(' / '),
}
})
})
function applyRecipe(pattern: string) {
emit('apply', pattern)
}
function openExamples() {
emit('openExamples')
}
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" type="button" :aria-label="t('settings.reader.fileNaming.recipes')">
<WandSparkles :size="14" aria-hidden="true" />
<span class="hidden @lg:inline">{{ t('settings.reader.fileNaming.recipes') }}</span>
<ChevronDown :size="14" class="text-muted-foreground" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-[26rem] max-w-[calc(100vw-2rem)]">
<DropdownMenuLabel class="flex items-baseline gap-2">
<span class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{{ t('settings.reader.fileNaming.recipesTitle') }}</span>
<span class="text-xs font-normal text-muted-foreground">{{ t('settings.reader.fileNaming.recipesReplace') }}</span>
</DropdownMenuLabel>
<DropdownMenuItem v-for="entry in entries" :key="entry.id" class="flex-col items-stretch gap-1 py-2" @select="applyRecipe(entry.pattern)">
<span class="flex items-center gap-2 text-[13px] font-semibold">
<AppIcon :icon="entry.icon" fallback="BookOpen" :size="14" aria-hidden="true" />
{{ entry.name }}
<span v-if="entry.pattern === currentPattern" class="ms-auto inline-flex items-center gap-1 text-xs font-medium text-primary">
<Check :size="13" class="text-primary" aria-hidden="true" />
{{ t('settings.reader.fileNaming.recipeInUse') }}
</span>
</span>
<span class="break-words ps-[1.375rem] text-xs text-muted-foreground">{{ entry.path }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="gap-2 text-[13px]" @select="openExamples">
<BookOpen :size="14" aria-hidden="true" />
{{ t('settings.reader.fileNaming.examples') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
@@ -1,87 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check } from '@lucide/vue'
import type { OrganizationMode } from '@bookorbit/types'
import AppIcon from '@/components/AppIcon.vue'
import { recipePattern, recipesFor, type PatternRecipe } from '../lib/pattern-recipes'
import { previewDownloadName, previewUploadPath, splitResolvedPath } from '../lib/pattern-preview'
import type { NamingTarget } from '../lib/naming-rules'
const props = defineProps<{
target: NamingTarget
organizationMode: OrganizationMode | null
currentPattern: string
sanitize: boolean
disabled?: boolean
}>()
const emit = defineEmits<{ apply: [pattern: string] }>()
const { t } = useI18n()
interface RecipeCard {
id: string
icon: string
name: string
pattern: string
folders: string[]
filename: string
}
const cards = computed<RecipeCard[]>(() =>
recipesFor(props.target).map((recipe: PatternRecipe) => {
const pattern = recipePattern(recipe, props.organizationMode)
const resolved =
props.target === 'download'
? previewDownloadName(pattern, { sanitizeForCrossPlatform: props.sanitize })
: previewUploadPath(pattern, { sanitizeForCrossPlatform: props.sanitize })
const parts = splitResolvedPath(resolved)
return {
id: recipe.id,
icon: recipe.icon,
name: t(`settings.reader.fileNaming.recipe.${recipe.id}` as 'settings.reader.fileNaming.recipe.seriesShelf'),
pattern,
folders: parts.folders,
filename: `${parts.stem}${parts.extension}`,
}
}),
)
// Kept out of `cards` so typing in the field does not re-resolve every recipe.
function isActive(pattern: string): boolean {
return pattern === props.currentPattern
}
function applyRecipe(pattern: string) {
emit('apply', pattern)
}
</script>
<template>
<div>
<p class="mb-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{{ t('settings.reader.fileNaming.recipesLabel') }}</p>
<div class="grid gap-2 @md:grid-cols-2">
<button
v-for="card in cards"
:key="card.id"
type="button"
:aria-pressed="isActive(card.pattern)"
:disabled="disabled"
class="flex flex-col gap-1.5 rounded-md border border-border bg-card p-3 text-left transition-all hover:border-primary/50 hover:shadow-md disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:class="isActive(card.pattern) ? 'border-primary ring-1 ring-primary' : ''"
@click="applyRecipe(card.pattern)"
>
<span class="flex items-center gap-1.5 text-[13px] font-semibold text-foreground">
<AppIcon :icon="card.icon" fallback="BookOpen" :size="14" aria-hidden="true" />
{{ card.name }}
<Check v-if="isActive(card.pattern)" :size="14" class="ml-auto shrink-0 text-primary" aria-hidden="true" />
</span>
<span class="break-words font-mono text-[11px] leading-snug text-muted-foreground">
<span v-for="folder in card.folders" :key="folder">{{ folder }}/</span>
<span class="font-semibold text-foreground">{{ card.filename }}</span>
</span>
</button>
</div>
</div>
</template>
@@ -1,136 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { PATTERN_TOKENS, type PatternToken } from '@bookorbit/types'
import { PATTERN_MODIFIERS, type PatternModifier } from '../lib/pattern-highlight'
const emit = defineEmits<{ insert: [text: string, caretOffset?: number] }>()
const { t } = useI18n()
// Written out rather than derived from the token name so a renamed message key fails
// the locale check instead of silently rendering the raw key.
const TOKEN_DESCRIPTION_KEYS: Record<PatternToken, string> = {
title: 'settings.reader.fileNaming.tokenTitle',
subtitle: 'settings.reader.fileNaming.tokenSubtitle',
authors: 'settings.reader.fileNaming.tokenAuthors',
narrators: 'settings.reader.fileNaming.tokenNarrators',
year: 'settings.reader.fileNaming.tokenYear',
series: 'settings.reader.fileNaming.tokenSeries',
seriesIndex: 'settings.reader.fileNaming.tokenSeriesIndex',
publisher: 'settings.reader.fileNaming.tokenPublisher',
isbn: 'settings.reader.fileNaming.tokenIsbn',
language: 'settings.reader.fileNaming.tokenLanguage',
library: 'settings.reader.fileNaming.tokenLibrary',
originalFilename: 'settings.reader.fileNaming.tokenOriginalFilename',
extension: 'settings.reader.fileNaming.tokenExtension',
readaloud: 'settings.reader.fileNaming.tokenReadaloud',
}
const MODIFIER_DESCRIPTION_KEYS: Record<PatternModifier, string> = {
first: 'settings.reader.fileNaming.modFirst',
sort: 'settings.reader.fileNaming.modSort',
initial: 'settings.reader.fileNaming.modInitial',
fixed2: 'settings.reader.fileNaming.modFixed2',
max3: 'settings.reader.fileNaming.modMax3',
upper: 'settings.reader.fileNaming.modUpper',
lower: 'settings.reader.fileNaming.modLower',
}
const tokens = computed(() => PATTERN_TOKENS.map((entry) => ({ text: `{${entry.token}}`, description: t(TOKEN_DESCRIPTION_KEYS[entry.token]) })))
const modifiers = computed(() => PATTERN_MODIFIERS.map((modifier) => ({ text: `:${modifier}`, description: t(MODIFIER_DESCRIPTION_KEYS[modifier]) })))
function insertToken(text: string) {
emit('insert', text)
}
function insertModifier(text: string) {
emit('insert', text)
}
function insertOptional() {
// Caret lands inside the brackets, which is where the next keystroke belongs.
emit('insert', '<>', 1)
}
function insertFallback() {
emit('insert', '|')
}
function insertSeparator() {
emit('insert', '/')
}
</script>
<template>
<div class="rounded-md border border-border bg-muted/30">
<div class="flex flex-col gap-1 px-3 py-2.5 sm:flex-row sm:gap-3">
<span class="w-20 shrink-0 pt-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
{{ t('settings.reader.fileNaming.tokens') }}
</span>
<div class="flex flex-wrap gap-1">
<button
v-for="token in tokens"
:key="token.text"
type="button"
:title="token.description"
class="inline-flex h-6 items-center rounded-[5px] border border-pattern-token/30 bg-pattern-token/10 px-1.5 font-mono text-[11px] font-semibold text-pattern-token transition-colors hover:bg-pattern-token/20 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
@click="insertToken(token.text)"
>
{{ token.text }}
</button>
</div>
</div>
<div class="flex flex-col gap-1 border-t border-border/70 px-3 py-2.5 sm:flex-row sm:gap-3">
<span class="w-20 shrink-0 pt-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
{{ t('settings.reader.fileNaming.modifiers') }}
</span>
<div class="flex flex-wrap gap-1">
<button
v-for="modifier in modifiers"
:key="modifier.text"
type="button"
:title="modifier.description"
class="inline-flex h-6 items-center rounded-[5px] border border-pattern-modifier/30 bg-pattern-modifier/10 px-1.5 font-mono text-[11px] font-semibold text-pattern-modifier transition-colors hover:bg-pattern-modifier/20 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
@click="insertModifier(modifier.text)"
>
{{ modifier.text }}
</button>
</div>
</div>
<div class="flex flex-col gap-1 border-t border-border/70 px-3 py-2.5 sm:flex-row sm:gap-3">
<span class="w-20 shrink-0 pt-1 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
{{ t('settings.reader.fileNaming.structure') }}
</span>
<div class="flex flex-wrap gap-1">
<button
type="button"
:title="t('settings.reader.fileNaming.paletteOptionalHint')"
class="inline-flex h-6 items-center rounded-[5px] border border-pattern-optional/35 bg-pattern-optional/10 px-1.5 font-mono text-[11px] font-semibold text-pattern-optional transition-colors hover:bg-pattern-optional/20 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
@click="insertOptional"
>
{{ t('settings.reader.fileNaming.paletteOptional') }}
</button>
<button
type="button"
:title="t('settings.reader.fileNaming.paletteFallbackHint')"
class="inline-flex h-6 items-center rounded-[5px] border border-pattern-fallback/35 bg-pattern-fallback/10 px-1.5 font-mono text-[11px] font-semibold text-pattern-fallback transition-colors hover:bg-pattern-fallback/20 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
@click="insertFallback"
>
{{ t('settings.reader.fileNaming.paletteFallback') }}
</button>
<button
type="button"
:title="t('settings.reader.fileNaming.paletteSeparatorHint')"
class="inline-flex h-6 items-center rounded-[5px] border border-border bg-background px-1.5 font-mono text-[11px] font-semibold text-foreground transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
@click="insertSeparator"
>
{{ t('settings.reader.fileNaming.paletteSeparator') }}
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { File, Folder, FolderOpen, TriangleAlert } from '@lucide/vue'
import { firstToken, splitExtension, type ResolvedLevel, type ResolvedPreview } from '../lib/pattern-resolution'
const props = defineProps<{
preview: ResolvedPreview
extension: string
label: string
}>()
const { t } = useI18n()
interface Row {
level: ResolvedLevel
depth: number
stem: string
suffix: string
token: string
}
// Depth stops growing past five folders so a deep scheme cannot push names off a phone screen.
const MAX_DEPTH = 5
const rows = computed<Row[]>(() => {
let depth = 0
return props.preview.levels.map((level) => {
const row: Row = {
level,
depth: Math.min(depth, MAX_DEPTH),
stem: level.name,
suffix: '',
token: firstToken(level.text) ?? '',
}
if (level.kind === 'file') [row.stem, row.suffix] = splitExtension(level.name, props.extension)
if (level.kind !== 'file' && (!level.skipped || level.emptyName)) depth += 1
return row
})
})
const tokenText = (token: string) => `{${token}}`
function iconFor(level: ResolvedLevel) {
if (level.kind === 'file') return File
return level.bookFolder ? FolderOpen : Folder
}
</script>
<template>
<div v-if="!preview.path" class="flex items-center gap-2 text-xs text-muted-foreground">
<TriangleAlert :size="14" class="shrink-0 text-warning" aria-hidden="true" />
{{ t('settings.reader.fileNaming.previewEmpty') }}
</div>
<ol v-else class="list-none p-0" :aria-label="label">
<li
v-for="(row, index) in rows"
:key="index"
class="grid grid-cols-[1rem_minmax(0,1fr)] items-start gap-2.5 py-0.5 text-sm leading-[1.375rem]"
:style="{ paddingInlineStart: `${row.depth * 1.125}rem` }"
>
<template v-if="row.level.emptyName">
<TriangleAlert :size="14" class="mt-1 text-destructive" aria-hidden="true" />
<span class="text-[13px] text-destructive">{{ t('settings.reader.fileNaming.levelEmptyName') }}</span>
</template>
<template v-else-if="row.level.skipped">
<Folder :size="14" class="folder-dashed mt-1 text-muted-foreground" aria-hidden="true" />
<i18n-t keypath="settings.reader.fileNaming.levelSkipped" tag="span" class="text-[13px] text-muted-foreground" scope="global">
<template #token
><span class="font-mono text-xs">{{ tokenText(row.token) }}</span></template
>
</i18n-t>
</template>
<template v-else>
<component
:is="iconFor(row.level)"
:size="14"
class="mt-1"
:class="[
row.level.kind === 'file' || row.level.bookFolder ? 'text-primary' : 'text-muted-foreground',
row.level.kind === 'optional' ? 'folder-dashed' : '',
]"
aria-hidden="true"
/>
<span
class="min-w-0 break-words"
:class="[row.level.kind === 'file' ? 'font-semibold' : '', row.level.usedFallback ? 'text-pattern-fallback' : 'text-foreground']"
>{{ row.stem }}<span v-if="row.suffix" class="font-medium text-primary">{{ row.suffix }}</span>
<span v-if="row.level.usedFallback" class="tag tag-fallback">{{ t('settings.reader.fileNaming.tagFallback') }}</span>
<span v-if="row.level.bookFolder" class="tag">{{ t('settings.reader.fileNaming.tagBookFolder') }}</span>
<span v-if="row.level.keepsUploadName" class="tag">{{ t('settings.reader.fileNaming.tagUploadedName') }}</span>
<span v-if="row.level.truncated" class="tag tag-warning">{{ t('settings.reader.fileNaming.tagShortened') }}</span>
</span>
</template>
</li>
</ol>
</template>
<style scoped>
.folder-dashed :deep(path) {
stroke-dasharray: 2.6 2.4;
}
.tag {
display: inline-flex;
margin-inline-start: 0.375rem;
vertical-align: 1px;
border-radius: 0.25rem;
padding: 0 0.3125rem;
font-family: var(--font-sans);
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.05em;
line-height: 1rem;
text-transform: uppercase;
white-space: nowrap;
color: var(--muted-foreground);
background: color-mix(in oklch, var(--foreground) 8%, transparent);
}
.tag-fallback {
color: var(--pattern-fallback);
background: color-mix(in oklch, var(--pattern-fallback) 14%, transparent);
}
.tag-warning {
color: var(--warning);
background: color-mix(in oklch, var(--warning) 14%, transparent);
}
</style>
@@ -0,0 +1,131 @@
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { i18n } from '@/i18n'
import PatternInput from '../PatternInput.vue'
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div class="tooltip-content"><slot /></div>' },
TooltipTrigger: { template: '<div><slot /></div>' },
}))
type Props = InstanceType<typeof PatternInput>['$props']
function mountInput(props: Partial<Props> = {}) {
const wrapper = mount(PatternInput, {
attachTo: document.body,
props: {
id: 'pattern',
modelValue: '{authors}/{title}',
lines: true,
mode: 'book_per_file' as const,
target: 'upload' as const,
...props,
'onUpdate:modelValue': (value: string) => wrapper.setProps({ modelValue: value }),
},
})
return wrapper
}
type Wrapper = ReturnType<typeof mountInput>
const field = (wrapper: Wrapper) => wrapper.find<HTMLTextAreaElement>('textarea')
const lastEmitted = (wrapper: Wrapper) => wrapper.emitted('update:modelValue')?.at(-1)?.[0]
async function typeAtEnd(wrapper: Wrapper, text: string) {
const element = field(wrapper).element
element.value += text
element.setSelectionRange(element.value.length, element.value.length)
await field(wrapper).trigger('input')
await nextTick()
}
beforeEach(() => {
i18n.global.locale.value = 'en'
document.body.innerHTML = ''
})
describe('PatternInput', () => {
it('draws each folder level on its own line and keeps the saved pattern free of line breaks', async () => {
const wrapper = mountInput()
expect(field(wrapper).element.value).toBe('{authors}/\n{title}')
await typeAtEnd(wrapper, ' ({year})')
expect(lastEmitted(wrapper)).toBe('{authors}/{title} ({year})')
})
it('starts a new folder on Enter by inserting a slash at the caret', async () => {
const wrapper = mountInput()
const element = field(wrapper).element
element.setSelectionRange(element.value.length, element.value.length)
await field(wrapper).trigger('keydown', { key: 'Enter' })
expect(lastEmitted(wrapper)).toBe('{authors}/{title}/')
})
it('joins a line to the one above on Backspace by removing the slash between them', async () => {
const wrapper = mountInput()
const element = field(wrapper).element
const lineStart = element.value.indexOf('\n') + 1
element.setSelectionRange(lineStart, lineStart)
await field(wrapper).trigger('keydown', { key: 'Backspace' })
expect(lastEmitted(wrapper)).toBe('{authors}{title}')
})
it('suggests tokens after a brace and completes the highlighted one on Enter', async () => {
const wrapper = mountInput()
await typeAtEnd(wrapper, ' - {sub')
const listbox = wrapper.find('[role="listbox"]')
expect(listbox.exists()).toBe(true)
expect(field(wrapper).attributes('aria-controls')).toBe(listbox.attributes('id'))
expect(field(wrapper).attributes('aria-activedescendant')).toBe('pattern-token-0')
expect(wrapper.emitted('completing')?.at(-1)).toEqual([true])
await field(wrapper).trigger('keydown', { key: 'Enter' })
expect(lastEmitted(wrapper)).toBe('{authors}/{title} - {subtitle}')
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
expect(wrapper.emitted('completing')?.at(-1)).toEqual([false])
})
it('closes the suggestions on Escape and leaves the typed text alone', async () => {
const wrapper = mountInput()
await typeAtEnd(wrapper, '{ti')
await field(wrapper).trigger('keydown', { key: 'Escape' })
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
expect(lastEmitted(wrapper)).toBe('{authors}/{title}{ti')
})
it('toggles a folder between always created and skipped when empty from its gutter icon', async () => {
const wrapper = mountInput({ modelValue: '{authors}/{series}/{title}' })
const toggle = wrapper.find('button[aria-label="Skip Folder 2 when its value is missing"]')
expect(toggle.attributes('aria-pressed')).toBe('false')
await toggle.trigger('click')
expect(lastEmitted(wrapper)).toBe('{authors}/<{series}/>{title}')
expect(wrapper.find('button[aria-label="Skip Folder 2 when its value is missing"]').attributes('aria-pressed')).toBe('true')
})
it('offers no toggle for a folder with a fallback, the book folder or a read-only field', () => {
expect(mountInput({ modelValue: '<{authors}|Unknown>/{title}/{title}', mode: 'book_per_folder' }).findAll('button[aria-pressed]')).toHaveLength(0)
expect(mountInput({ modelValue: '{authors}/{title}', readonly: true }).findAll('button[aria-pressed]')).toHaveLength(0)
})
it('keeps a single unsplit line for a download pattern and ignores Enter', async () => {
const wrapper = mountInput({ modelValue: '{authors}/{title}', mode: null, target: 'download' })
expect(field(wrapper).element.value).toBe('{authors}/{title}')
await field(wrapper).trigger('keydown', { key: 'Enter' })
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
@@ -315,23 +315,6 @@ describe('saving', () => {
})
})
describe('filtering', () => {
it('narrows the rule list to names matching the query', async () => {
libraries.value = [makeLibrary(), makeLibrary({ id: 2, name: 'Comics' })]
const rules = await loaded()
rules.query.value = 'comic'
expect(rules.visibleRules.value.map((rule) => rule.id)).toEqual(['library:2'])
})
it('matches global rules by their translated name', async () => {
const rules = await loaded()
rules.query.value = 'download'
expect(rules.visibleRules.value.map((rule) => rule.id)).toEqual(['global:download'])
})
})
describe('cross-platform sanitization', () => {
it('reads the stored value on load', async () => {
apiMock.mockImplementation((input, init) => {
@@ -51,7 +51,6 @@ export function useFileNamingRules() {
const savingCrossPlatformSanitization = ref(false)
const selectedRuleId = ref<NamingRuleId>('global:fileAsBook')
const query = ref('')
const globalRules = computed<NamingRule[]>(() => GLOBAL_RULE_KEYS.map(globalRule))
const libraryRules = computed<NamingRule[]>(() => libraries.value.map(libraryRule))
@@ -111,12 +110,6 @@ export function useFileNamingRules() {
const hasUnsavedChanges = computed(() => dirtyRules.value.length > 0)
const blockedByError = computed(() => dirtyRules.value.some((rule) => errorFor(rule) !== ''))
const visibleRules = computed(() => {
const needle = query.value.trim().toLowerCase()
if (!needle) return rules.value
return rules.value.filter((rule) => ruleName(rule).toLowerCase().includes(needle))
})
function ruleName(rule: NamingRule): string {
return rule.kind === 'global' ? t(`settings.reader.fileNaming.rule.${rule.globalKey}`) : (rule.library?.name ?? '')
}
@@ -272,10 +265,8 @@ export function useFileNamingRules() {
rules,
globalRules,
libraryRules,
visibleRules,
selectedRuleId,
selectedRule,
query,
loading,
saving,
crossPlatformSanitizationEnabled,
@@ -0,0 +1,122 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { DEFAULT_KOREADER_DEVICE_PATTERN, DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE, DEFAULT_UPLOAD_PATTERN_BOOK_PER_FOLDER } from '@bookorbit/types'
import {
canToggleOptional,
displayOffset,
displayText,
joinWithPreviousLevel,
patternLevels,
patternOffset,
splitPatternLevels,
stripDisplayNewlines,
toggleOptionalLevel,
} from '../pattern-levels'
import { DOWNLOAD_RECIPES, UPLOAD_RECIPES } from '../pattern-recipes'
import { PATTERN_EXAMPLES } from '../pattern-examples'
const everyShippedPattern = [
DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE,
DEFAULT_UPLOAD_PATTERN_BOOK_PER_FOLDER,
DEFAULT_KOREADER_DEVICE_PATTERN,
...UPLOAD_RECIPES.flatMap((recipe) => Object.values(recipe.patterns ?? {})),
...DOWNLOAD_RECIPES.map((recipe) => recipe.downloadPattern ?? ''),
...PATTERN_EXAMPLES.flatMap((example) => Object.values(example.patterns).filter((pattern): pattern is string => pattern !== null)),
]
describe('splitPatternLevels', () => {
it('reads the File as Book default as an author folder, an optional series folder and a file', () => {
expect(splitPatternLevels(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE)).toEqual([
{ text: '<{authors:first}|Unknown Author>/', kind: 'folder' },
{ text: '<{series}/>', kind: 'optional' },
{ text: '<{seriesIndex}. ><{title}|{originalFilename}>< ({year})>', kind: 'file' },
])
})
it('never splits inside an optional group that is more than one folder', () => {
expect(splitPatternLevels('<Series/{series}/|Standalone/>{title}')).toEqual([{ text: '<Series/{series}/|Standalone/>{title}', kind: 'file' }])
})
it('leaves an empty file level for a pattern ending in a slash', () => {
expect(splitPatternLevels('{authors}/').at(-1)).toEqual({ text: '', kind: 'file' })
})
it.each(everyShippedPattern)('joins back to exactly %s', (pattern) => {
expect(
splitPatternLevels(pattern)
.map((level) => level.text)
.join(''),
).toBe(pattern)
})
})
describe('patternLevels', () => {
it('marks the last folder of a Folder as Book pattern as the book folder', () => {
const levels = patternLevels(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FOLDER, 'book_per_folder', 'upload')
expect(levels.map((level) => level.bookFolder)).toEqual([false, false, true, false])
})
it('marks no book folder for File as Book', () => {
expect(patternLevels(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE, 'book_per_file', 'upload').some((level) => level.bookFolder)).toBe(false)
})
it('keeps a download pattern as one level even when it contains a slash', () => {
expect(patternLevels('{authors}/{title}', null, 'download')).toEqual([{ text: '{authors}/{title}', kind: 'file', bookFolder: false }])
})
})
describe('line-per-level display', () => {
const pattern = DEFAULT_UPLOAD_PATTERN_BOOK_PER_FOLDER
const levels = patternLevels(pattern, 'book_per_folder', 'upload')
const display = displayText(levels)
it('puts each level on its own line and strips back to the saved pattern', () => {
expect(display.split('\n')).toHaveLength(4)
expect(stripDisplayNewlines(display)).toBe(pattern)
})
it('maps every pattern offset to the display and back', () => {
for (let offset = 0; offset <= pattern.length; offset += 1) {
expect(patternOffset(display, displayOffset(levels, offset))).toBe(offset)
}
})
it('puts a caret that sits right after a slash at the start of the next line', () => {
const afterFirstSlash = levels[0]!.text.length
expect(display.charAt(displayOffset(levels, afterFirstSlash) - 1)).toBe('\n')
})
})
describe('joinWithPreviousLevel', () => {
it('removes the slash that ended the previous folder', () => {
expect(joinWithPreviousLevel('{authors}/{title}', 10)).toEqual({ pattern: '{authors}{title}', offset: 9 })
})
it('removes the slash inside an optional folder, keeping its brackets', () => {
expect(joinWithPreviousLevel('<{series}/>{title}', 11)).toEqual({ pattern: '<{series}>{title}', offset: 10 })
})
it('does nothing where no level boundary precedes the caret', () => {
expect(joinWithPreviousLevel('{title}', 0)).toBeNull()
expect(joinWithPreviousLevel('{title}', 7)).toBeNull()
})
})
describe('toggleOptionalLevel', () => {
it('wraps a plain folder so it is skipped when empty, and unwraps it again', () => {
const levels = patternLevels('{authors}/{series}/{title}', 'book_per_file', 'upload')
const wrapped = toggleOptionalLevel(levels, 1)
expect(wrapped).toBe('{authors}/<{series}/>{title}')
expect(toggleOptionalLevel(patternLevels(wrapped!, 'book_per_file', 'upload'), 1)).toBe('{authors}/{series}/{title}')
})
it('refuses a folder that already has a fallback, since it can never be empty', () => {
const [author] = patternLevels(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE, 'book_per_file', 'upload')
expect(canToggleOptional(author!)).toBe(false)
})
it('refuses the Folder as Book book folder and the file name', () => {
const levels = patternLevels('{authors}/{title}/{title}', 'book_per_folder', 'upload')
expect(levels.map(canToggleOptional)).toEqual([true, false, false])
})
})
@@ -0,0 +1,123 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE,
DEFAULT_UPLOAD_PATTERN_BOOK_PER_FOLDER,
EXAMPLE_PATTERN_METADATA,
resolveUploadPath,
} from '@bookorbit/types'
import { firstToken, levelChanges, resolvePreview, splitExtension, type ResolveOptions } from '../pattern-resolution'
import { PREVIEW_BOOKS, withoutFields } from '../preview-books'
const FILE: ResolveOptions = { target: 'upload', mode: 'book_per_file', extension: 'epub', sanitize: true }
const FOLDER: ResolveOptions = { ...FILE, mode: 'book_per_folder' }
describe('resolvePreview', () => {
it('names every level from the real resolver', () => {
const preview = resolvePreview(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FOLDER, EXAMPLE_PATTERN_METADATA, FOLDER)
expect(preview.path).toBe(
resolveUploadPath(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FOLDER, EXAMPLE_PATTERN_METADATA, 'epub', { sanitizeForCrossPlatform: true }),
)
expect(preview.levels.map((level) => level.name)).toEqual(['William Gibson', 'Sprawl', '01. Neuromancer (1984)', '01. Neuromancer (1984).epub'])
expect(preview.levels.map((level) => level.bookFolder)).toEqual([false, false, true, false])
})
it('marks an optional folder that dropped out as skipped, and keeps the rest aligned', () => {
const preview = resolvePreview(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE, withoutFields(EXAMPLE_PATTERN_METADATA, ['series', 'seriesIndex']), FILE)
expect(preview.levels.map((level) => [level.name, level.skipped])).toEqual([
['William Gibson', false],
['', true],
['Neuromancer (1984).epub', false],
])
})
it('reports a level that fell back', () => {
const preview = resolvePreview(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE, withoutFields(EXAMPLE_PATTERN_METADATA, ['authors']), FILE)
expect(preview.levels[0]).toMatchObject({ name: 'Unknown Author', usedFallback: true })
})
it('flags an unguarded folder that resolves to nothing as an empty name', () => {
const preview = resolvePreview('{series}/{title}', withoutFields(EXAMPLE_PATTERN_METADATA, ['series']), FILE)
expect(preview.levels[0]).toMatchObject({ skipped: true, emptyName: true })
expect(preview.levels[1]).toMatchObject({ name: 'Neuromancer.epub' })
})
it('keeps the uploaded name for a pattern ending in a slash', () => {
const preview = resolvePreview('{authors}/', EXAMPLE_PATTERN_METADATA, FILE)
expect(preview.levels.at(-1)).toMatchObject({ name: 'neuromancer.epub', keepsUploadName: true, skipped: false })
})
it('reports a name the 255-byte limit shortened', () => {
const longTitle = { ...EXAMPLE_PATTERN_METADATA, title: 'Neuromancer '.repeat(30) }
const preview = resolvePreview('{title}', longTitle, FILE)
expect(preview.levels[0]?.truncated).toBe(true)
expect(resolvePreview('{title}', EXAMPLE_PATTERN_METADATA, FILE).levels[0]?.truncated).toBe(false)
})
it('resolves a download pattern to a single file name', () => {
const preview = resolvePreview('<{authors:first} - >{title}', EXAMPLE_PATTERN_METADATA, { ...FILE, target: 'download', mode: null })
expect(preview.path).toBe('William Gibson - Neuromancer.epub')
expect(preview.levels).toHaveLength(1)
})
it('resolves every preview book under the shipped Folder as Book default', () => {
for (const book of Object.values(PREVIEW_BOOKS)) {
const preview = resolvePreview(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FOLDER, book.metadata, { ...FOLDER, extension: book.extension })
expect(preview.path.endsWith(`.${book.extension}`)).toBe(true)
}
})
})
describe('levelChanges', () => {
it('lists only the levels a missing field changes', () => {
const complete = resolvePreview(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE, EXAMPLE_PATTERN_METADATA, FILE)
const noYear = resolvePreview(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE, withoutFields(EXAMPLE_PATTERN_METADATA, ['year']), FILE)
expect(levelChanges(complete, noYear)).toEqual([
{
kind: 'file',
bookFolder: false,
from: '01. Neuromancer (1984).epub',
to: '01. Neuromancer.epub',
skipped: false,
emptyName: false,
usedFallback: false,
},
])
})
it('reports a folder that dropped out as skipped', () => {
const complete = resolvePreview(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE, EXAMPLE_PATTERN_METADATA, FILE)
const noSeries = resolvePreview(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE, withoutFields(EXAMPLE_PATTERN_METADATA, ['series', 'seriesIndex']), FILE)
expect(levelChanges(complete, noSeries)[0]).toMatchObject({ kind: 'optional', from: 'Sprawl', skipped: true })
})
it('reports an unguarded folder that loses its name as an empty name, not a skipped folder', () => {
const complete = resolvePreview('{authors}/{title}', EXAMPLE_PATTERN_METADATA, FILE)
const noAuthor = resolvePreview('{authors}/{title}', withoutFields(EXAMPLE_PATTERN_METADATA, ['authors']), FILE)
expect(levelChanges(complete, noAuthor)).toEqual([
{ kind: 'folder', bookFolder: false, from: 'William Gibson', to: '', skipped: false, emptyName: true, usedFallback: false },
])
})
})
describe('helpers', () => {
it('splits the extension off a file name only when it matches', () => {
expect(splitExtension('Neuromancer.epub', 'epub')).toEqual(['Neuromancer', '.epub'])
expect(splitExtension('Neuromancer', 'epub')).toEqual(['Neuromancer', ''])
})
it('finds the first token a level reads', () => {
expect(firstToken('<{series}/>')).toBe('series')
expect(firstToken('Books/')).toBeNull()
})
})
@@ -0,0 +1,36 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { completeToken, matchTokens, partialTokenAt } from '../pattern-tokens'
describe('matchTokens', () => {
it('offers tokens that start with the query before those that only contain it', () => {
expect(matchTokens('ser')).toEqual(['series', 'seriesIndex'])
expect(matchTokens('index')).toEqual(['seriesIndex'])
})
it('offers every token for an empty query and none for a stranger', () => {
expect(matchTokens('')).toHaveLength(14)
expect(matchTokens('zzz')).toEqual([])
})
})
describe('partialTokenAt', () => {
it('finds the open brace and the letters typed after it', () => {
expect(partialTokenAt('{authors}/{ti', 13)).toEqual({ start: 10, query: 'ti' })
})
it('ignores a finished token and a modifier', () => {
expect(partialTokenAt('{title}', 7)).toBeNull()
expect(partialTokenAt('{authors:fi', 11)).toBeNull()
})
})
describe('completeToken', () => {
it('replaces the partial token and places the caret after it', () => {
expect(completeToken('{authors}/{ti', 10, 13, 'title')).toEqual({ pattern: '{authors}/{title}', caret: 17 })
})
it('swallows letters and a closing brace already after the caret', () => {
expect(completeToken('{ti}/x', 0, 3, 'title')).toEqual({ pattern: '{title}/x', caret: 7 })
})
})
@@ -0,0 +1,147 @@
import type { OrganizationMode } from '@bookorbit/types'
import type { NamingTarget } from './naming-rules'
/**
* A pattern read as the path it builds: one entry per folder, then the file name.
*
* `folder` ends in a top-level slash. `optional` is a whole folder wrapped in an optional group,
* such as `<{series}/>`, which the resolver drops when a value inside it is missing. The pieces
* concatenate back to the exact pattern, so the field can draw one level per line without
* changing a single character of what is saved.
*/
export type PatternLevelKind = 'folder' | 'optional' | 'file'
export interface PatternLevel {
text: string
kind: PatternLevelKind
/** Folder as Book keeps each book in a folder of its own: the last folder before the file. */
bookFolder: boolean
}
export function splitPatternLevels(pattern: string): Omit<PatternLevel, 'bookFolder'>[] {
const levels: Omit<PatternLevel, 'bookFolder'>[] = []
let depth = 0
let start = 0
let index = 0
while (index < pattern.length) {
const char = pattern.charAt(index)
if (char === '<' && depth === 0 && start === index) {
const close = pattern.indexOf('>', index)
if (close !== -1) {
const inner = pattern.slice(index + 1, close)
if (isWholeOptionalFolder(inner)) {
levels.push({ text: pattern.slice(index, close + 1), kind: 'optional' })
index = close + 1
start = index
continue
}
}
}
if (char === '<') depth += 1
else if (char === '>' && depth > 0) depth -= 1
else if (char === '/' && depth === 0) {
levels.push({ text: pattern.slice(start, index + 1), kind: 'folder' })
start = index + 1
}
index += 1
}
levels.push({ text: pattern.slice(start), kind: 'file' })
return levels
}
/** `{series}/` inside a group, with nothing else that would make the group more than one folder. */
function isWholeOptionalFolder(inner: string): boolean {
if (!inner.endsWith('/')) return false
const body = inner.slice(0, -1)
return body.length > 0 && !/[/|<]/.test(body)
}
/** Downloads have no folders, so their pattern is always a single level. */
export function patternLevels(pattern: string, mode: OrganizationMode | null, target: NamingTarget): PatternLevel[] {
if (target === 'download') return [{ text: pattern, kind: 'file', bookFolder: false }]
const levels = splitPatternLevels(pattern).map((level) => ({ ...level, bookFolder: false }))
if (mode === 'book_per_folder') {
for (let index = levels.length - 2; index >= 0; index -= 1) {
if (levels[index]?.kind === 'folder') {
levels[index]!.bookFolder = true
break
}
}
}
return levels
}
/* ───────────── line-per-level display ─────────────
* The field shows the pattern with a newline after every level. Those newlines exist only on
* screen: the saved value is the pattern with them removed, so offsets have to be translated
* between the two whenever the caret is read or restored.
*/
export function displayText(levels: PatternLevel[]): string {
return levels.map((level) => level.text).join('\n')
}
export function stripDisplayNewlines(display: string): string {
return display.replace(/[\r\n]/g, '')
}
/** A display offset becomes a pattern offset by dropping the newlines before it. */
export function patternOffset(display: string, displayOffset: number): number {
const before = display.slice(0, displayOffset)
return displayOffset - (before.match(/\n/g)?.length ?? 0)
}
/**
* A pattern offset sitting exactly on a level boundary lands at the start of the next line, which
* is where typing continues after a slash that just opened a new folder.
*/
export function displayOffset(levels: PatternLevel[], patternOffsetValue: number): number {
let boundary = 0
let newlines = 0
for (let index = 0; index < levels.length - 1; index += 1) {
boundary += levels[index]!.text.length
if (boundary <= patternOffsetValue) newlines += 1
}
return patternOffsetValue + newlines
}
/**
* Backspace at the start of a line joins it to the line above, which in the pattern means removing
* the separator that ended that level: the slash of `{a}/`, or the slash inside `<{a}/>`.
*/
export function joinWithPreviousLevel(pattern: string, patternOffsetValue: number): { pattern: string; offset: number } | null {
if (patternOffsetValue <= 0) return null
const before = pattern.charAt(patternOffsetValue - 1)
if (before === '/') {
return { pattern: pattern.slice(0, patternOffsetValue - 1) + pattern.slice(patternOffsetValue), offset: patternOffsetValue - 1 }
}
if (before === '>' && pattern.charAt(patternOffsetValue - 2) === '/') {
const cut = patternOffsetValue - 2
return { pattern: pattern.slice(0, cut) + pattern.slice(cut + 1), offset: patternOffsetValue - 1 }
}
return null
}
/* ───────────── skip a folder when it would be empty ───────────── */
/**
* Only a folder that is a plain run of tokens and text can be wrapped: the resolver does not nest
* optional groups, and a folder that already has a fallback can never be empty.
*/
export function canToggleOptional(level: PatternLevel): boolean {
if (level.kind === 'optional') return true
if (level.kind !== 'folder' || level.bookFolder) return false
const body = level.text.slice(0, -1)
return body.length > 0 && !/[<>|]/.test(body)
}
/** Wraps a folder as `<…/>` or unwraps it again, leaving every other level untouched. */
export function toggleOptionalLevel(levels: PatternLevel[], index: number): string | null {
const level = levels[index]
if (!level || !canToggleOptional(level)) return null
const next = level.kind === 'optional' ? `${level.text.slice(1, -2)}/` : `<${level.text}>`
return levels.map((entry, position) => (position === index ? next : entry.text)).join('')
}
@@ -15,6 +15,8 @@ export type PreviewCaseId = 'complete' | 'noSeries' | 'noYear' | 'noAuthor'
export interface PreviewCase {
id: PreviewCaseId
/** The fields this case blanks, so it can be applied to any preview book. */
omit: readonly string[]
metadata: Record<string, string>
}
@@ -26,10 +28,10 @@ export const metadataWithout = (...fields: string[]): Record<string, string> =>
}
export const PREVIEW_CASES: PreviewCase[] = [
{ id: 'complete', metadata: { ...EXAMPLE_PATTERN_METADATA } },
{ id: 'noSeries', metadata: metadataWithout('series', 'seriesIndex') },
{ id: 'noYear', metadata: metadataWithout('year') },
{ id: 'noAuthor', metadata: metadataWithout('authors') },
{ id: 'complete', omit: [], metadata: { ...EXAMPLE_PATTERN_METADATA } },
{ id: 'noSeries', omit: ['series', 'seriesIndex'], metadata: metadataWithout('series', 'seriesIndex') },
{ id: 'noYear', omit: ['year'], metadata: metadataWithout('year') },
{ id: 'noAuthor', omit: ['authors'], metadata: metadataWithout('authors') },
]
/** Cases other than the complete one, which the panels render as the headline result. */
@@ -0,0 +1,144 @@
import { MAX_PATH_SEGMENT_BYTES, resolveDownloadFilename, resolvePlaceholders, resolveUploadPath, type OrganizationMode } from '@bookorbit/types'
import { patternLevels, type PatternLevelKind } from './pattern-levels'
import type { NamingTarget } from './naming-rules'
/**
* One row of the resolved path. The names come from the real resolver so sanitization, the
* extension and the 255-byte limit are exactly what an upload gets; each level is also resolved
* on its own only to learn whether it dropped out or fell back.
*/
export interface ResolvedLevel {
kind: PatternLevelKind
text: string
name: string
bookFolder: boolean
/** The level resolved to nothing and is left out of the path. */
skipped: boolean
/** A required folder that resolved to nothing, leaving an empty name in the path. */
emptyName: boolean
usedFallback: boolean
/** A pattern ending in a slash keeps the uploaded file's own name. */
keepsUploadName: boolean
truncated: boolean
}
export interface ResolvedPreview {
path: string
levels: ResolvedLevel[]
}
export interface ResolveOptions {
target: NamingTarget
mode: OrganizationMode | null
extension: string
sanitize: boolean
}
const TRUNCATION_MARK = /~[0-9a-f]{8}(\.[^.]+)?$/
function byteLength(value: string): number {
return new TextEncoder().encode(value).length
}
function wasTruncated(name: string): boolean {
return byteLength(name) >= MAX_PATH_SEGMENT_BYTES - 16 && TRUNCATION_MARK.test(name)
}
export function resolvePreview(pattern: string, metadata: Record<string, string>, options: ResolveOptions): ResolvedPreview {
const resolverOptions = { sanitizeForCrossPlatform: options.sanitize }
if (options.target === 'download') {
const name = resolveDownloadFilename(pattern, metadata, options.extension, resolverOptions) ?? ''
const { usedFallback } = resolvePlaceholders(pattern || '{originalFilename}', metadata)
return {
path: name,
levels: [
{
kind: 'file',
text: pattern,
name,
bookFolder: false,
skipped: !name,
emptyName: false,
usedFallback,
keepsUploadName: false,
truncated: wasTruncated(name),
},
],
}
}
const path = pattern.trim() ? (resolveUploadPath(pattern, metadata, options.extension, resolverOptions) ?? '') : ''
const segments = path ? path.split('/') : []
let cursor = 0
const levels = patternLevels(pattern, options.mode, options.target).map((level): ResolvedLevel => {
const own = resolvePlaceholders(level.text, metadata)
const keepsUploadName = level.kind === 'file' && !level.text.trim()
const empty = level.kind === 'file' ? !own.text : !own.text.replace(/\/$/, '').trim()
const skipped = empty && !keepsUploadName
// An unguarded required folder still leaves its slash, and so an empty segment, behind.
const emptyName = skipped && level.kind === 'folder'
if (emptyName) cursor += 1
const name = skipped ? '' : (segments[cursor++] ?? '')
return {
kind: level.kind,
text: level.text,
name,
bookFolder: level.bookFolder,
skipped,
emptyName,
usedFallback: own.usedFallback,
keepsUploadName,
truncated: wasTruncated(name),
}
})
return { path, levels }
}
export interface LevelChange {
kind: PatternLevelKind
bookFolder: boolean
from: string
to: string
skipped: boolean
/** The folder is still created, but with no name: an unguarded token came out empty. */
emptyName: boolean
usedFallback: boolean
}
/** What a missing field changes, level by level, against the complete result. */
export function levelChanges(base: ResolvedPreview, other: ResolvedPreview): LevelChange[] {
const changes: LevelChange[] = []
base.levels.forEach((level, index) => {
const next = other.levels[index]
if (!next) return
const nowEmpty = !level.emptyName && next.emptyName
const nowSkipped = !level.skipped && next.skipped && !nowEmpty
if (!nowSkipped && !nowEmpty && level.name === next.name) return
changes.push({
kind: level.kind,
bookFolder: level.bookFolder,
from: level.name,
to: next.name,
skipped: nowSkipped,
emptyName: nowEmpty,
usedFallback: next.usedFallback && !level.usedFallback,
})
})
return changes
}
/** Splits "01. Neuromancer (1984).epub" so the extension can be drawn apart from the name. */
export function splitExtension(name: string, extension: string): [string, string] {
const suffix = extension ? `.${extension}` : ''
if (!suffix || !name.toLowerCase().endsWith(suffix.toLowerCase())) return [name, '']
return [name.slice(0, -suffix.length), name.slice(-suffix.length)]
}
/** The first token a level reads, so a skipped folder can say which value was missing. */
export function firstToken(text: string): string | null {
const match = text.match(/\{([A-Za-z]+)/)
return match?.[1] ?? null
}
@@ -0,0 +1,66 @@
import { PATTERN_TOKENS, type PatternToken } from '@bookorbit/types'
import { PATTERN_MODIFIERS, type PatternModifier } from './pattern-highlight'
// Written out rather than derived from the token name so a renamed message key fails
// the locale check instead of silently rendering the raw key.
export const TOKEN_DESCRIPTION_KEYS: Record<PatternToken, string> = {
title: 'settings.reader.fileNaming.tokenTitle',
subtitle: 'settings.reader.fileNaming.tokenSubtitle',
authors: 'settings.reader.fileNaming.tokenAuthors',
narrators: 'settings.reader.fileNaming.tokenNarrators',
year: 'settings.reader.fileNaming.tokenYear',
series: 'settings.reader.fileNaming.tokenSeries',
seriesIndex: 'settings.reader.fileNaming.tokenSeriesIndex',
publisher: 'settings.reader.fileNaming.tokenPublisher',
isbn: 'settings.reader.fileNaming.tokenIsbn',
language: 'settings.reader.fileNaming.tokenLanguage',
library: 'settings.reader.fileNaming.tokenLibrary',
originalFilename: 'settings.reader.fileNaming.tokenOriginalFilename',
extension: 'settings.reader.fileNaming.tokenExtension',
readaloud: 'settings.reader.fileNaming.tokenReadaloud',
}
export const MODIFIER_DESCRIPTION_KEYS: Record<PatternModifier, string> = {
first: 'settings.reader.fileNaming.modFirst',
sort: 'settings.reader.fileNaming.modSort',
initial: 'settings.reader.fileNaming.modInitial',
fixed2: 'settings.reader.fileNaming.modFixed2',
max3: 'settings.reader.fileNaming.modMax3',
upper: 'settings.reader.fileNaming.modUpper',
lower: 'settings.reader.fileNaming.modLower',
}
export const TOKEN_NAMES: PatternToken[] = PATTERN_TOKENS.map((entry) => entry.token)
export const MODIFIER_NAMES: readonly PatternModifier[] = PATTERN_MODIFIERS
/**
* Tokens matching what has been typed after a `{`: those starting with it first, then those
* containing it, so `{ser` offers series before seriesIndex and `{index` still finds seriesIndex.
*/
export function matchTokens(query: string): PatternToken[] {
const needle = query.toLowerCase()
if (!needle) return [...TOKEN_NAMES]
const starts = TOKEN_NAMES.filter((token) => token.toLowerCase().startsWith(needle))
const contains = TOKEN_NAMES.filter((token) => !token.toLowerCase().startsWith(needle) && token.toLowerCase().includes(needle))
return [...starts, ...contains]
}
/**
* The partial token the caret sits in: `{` followed only by letters, with no closing brace yet.
* Returns where the brace is and what follows it, or null when the caret is not in one.
*/
export function partialTokenAt(pattern: string, caret: number): { start: number; query: string } | null {
const before = pattern.slice(0, caret)
const open = before.lastIndexOf('{')
if (open === -1) return null
const typed = before.slice(open + 1)
if (!/^[A-Za-z]*$/.test(typed)) return null
return { start: open, query: typed }
}
/** Replaces the partial token with a whole one, swallowing letters and a brace already after the caret. */
export function completeToken(pattern: string, start: number, caret: number, token: string): { pattern: string; caret: number } {
const rest = pattern.slice(caret).replace(/^[A-Za-z]*\}?/, '')
const inserted = `{${token}}`
return { pattern: pattern.slice(0, start) + inserted + rest, caret: start + inserted.length }
}
@@ -0,0 +1,126 @@
import { EXAMPLE_PATTERN_METADATA } from '@bookorbit/types'
/**
* Books the result can be previewed with. The shipped sample stays the default; the others are
* the shapes that actually break a naming scheme: a long title inside a long series, a very long
* subtitle, more than three authors, an audiobook, and a book that belongs to no series.
*
* Titles and names are bibliographic data and are never translated; only the note is.
*/
export const PREVIEW_BOOK_IDS = ['sample', 'longTitle', 'longSubtitle', 'manyAuthors', 'audiobook', 'standalone'] as const
export type PreviewBookId = (typeof PREVIEW_BOOK_IDS)[number]
export interface PreviewBook {
id: PreviewBookId
extension: string
metadata: Record<string, string>
}
const book = (fields: Partial<Record<string, string>>): Record<string, string> => ({
title: '',
subtitle: '',
authors: '',
narrators: '',
year: '',
series: '',
seriesIndex: '',
language: 'English',
publisher: '',
isbn: '',
library: 'Books',
originalFilename: '',
extension: '',
readaloud: '',
...fields,
})
export const PREVIEW_BOOKS: Record<PreviewBookId, PreviewBook> = {
sample: { id: 'sample', extension: 'epub', metadata: { ...EXAMPLE_PATTERN_METADATA } },
longTitle: {
id: 'longTitle',
extension: 'epub',
metadata: book({
title: 'Through the Looking-Glass, and What Alice Found There',
authors: 'Lewis Carroll',
year: '1871',
series: "Alice's Adventures in Wonderland",
seriesIndex: '02',
publisher: 'Macmillan',
originalFilename: 'through-the-looking-glass',
extension: 'epub',
}),
},
longSubtitle: {
id: 'longSubtitle',
extension: 'epub',
metadata: book({
title: 'Robinson Crusoe',
subtitle:
'The Life and Strange Surprizing Adventures of Robinson Crusoe, of York, Mariner, Who Lived Eight and Twenty Years, All Alone in an Un-inhabited Island on the Coast of America',
authors: 'Daniel Defoe',
year: '1719',
publisher: 'W. Taylor',
originalFilename: 'robinson-crusoe',
extension: 'epub',
}),
},
manyAuthors: {
id: 'manyAuthors',
extension: 'cbz',
metadata: book({
title: 'Preludes & Nocturnes',
authors: 'Neil Gaiman, Sam Kieth, Mike Dringenberg, Malcolm Jones III, Todd Klein',
year: '1991',
series: 'The Sandman',
seriesIndex: '01',
publisher: 'DC Comics',
originalFilename: 'sandman-v01',
extension: 'cbz',
}),
},
audiobook: {
id: 'audiobook',
extension: 'm4b',
metadata: book({
title: 'Artificial Condition',
authors: 'Martha Wells',
narrators: 'Kevin R. Free',
year: '2018',
series: 'The Murderbot Diaries',
seriesIndex: '02',
publisher: 'Tor.com',
originalFilename: 'artificial-condition',
extension: 'm4b',
}),
},
standalone: {
id: 'standalone',
extension: 'epub',
metadata: book({
title: 'The Ocean at the End of the Lane',
authors: 'Neil Gaiman',
narrators: 'Neil Gaiman',
year: '2013',
publisher: 'William Morrow',
originalFilename: 'the-ocean-at-the-end-of-the-lane',
extension: 'epub',
}),
},
}
/** Written out so a renamed message key fails the locale check instead of rendering raw. */
export const PREVIEW_BOOK_NOTE_KEYS: Record<PreviewBookId, string> = {
sample: 'settings.reader.fileNaming.previewBook.sample',
longTitle: 'settings.reader.fileNaming.previewBook.longTitle',
longSubtitle: 'settings.reader.fileNaming.previewBook.longSubtitle',
manyAuthors: 'settings.reader.fileNaming.previewBook.manyAuthors',
audiobook: 'settings.reader.fileNaming.previewBook.audiobook',
standalone: 'settings.reader.fileNaming.previewBook.standalone',
}
/** The book with some fields blanked, for previewing what a pattern does without them. */
export function withoutFields(metadata: Record<string, string>, fields: readonly string[]): Record<string, string> {
const copy = { ...metadata }
for (const field of fields) copy[field] = ''
return copy
}
+19 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it } from 'vitest'
import { setI18nLocale } from '@/i18n'
import { formatBytes } from '@/lib/formatting'
import { formatCompactNumber, formatDate, formatLanguageName, formatNumber } from './formatters'
import { formatCompactNumber, formatDate, formatLanguageName, formatListParts, formatNumber } from './formatters'
describe('locale formatters', () => {
afterEach(async () => {
@@ -54,4 +54,22 @@ describe('locale formatters', () => {
await setI18nLocale('nl')
expect(formatBytes(1536)).toBe('1,5 kB')
})
it('splits a sentence list into items and the locale joining words', async () => {
await setI18nLocale('en')
expect(formatListParts(['Comics', 'Novels', 'PDFs'])).toEqual([
{ type: 'element', value: 'Comics' },
{ type: 'literal', value: ', ' },
{ type: 'element', value: 'Novels' },
{ type: 'literal', value: ', and ' },
{ type: 'element', value: 'PDFs' },
])
await setI18nLocale('nl')
expect(
formatListParts(['Comics', 'Novels'])
.map((part) => part.value)
.join(''),
).toBe('Comics en Novels')
})
})
+19 -3
View File
@@ -49,16 +49,32 @@ export function formatCompactNumber(value: number): string {
}
/** Joins values into a locale-aware list: "Audiobooks, Comics, Novels" in English. */
export function formatList(values: string[]): string {
function listFormatter(options: Intl.ListFormatOptions): Intl.ListFormat {
const locale = activeLocale()
const options: Intl.ListFormatOptions = { style: 'short', type: 'unit' }
const key = formatterKey(locale, options)
let formatter = listFormatters.get(key)
if (!formatter) {
formatter = new Intl.ListFormat(locale, options)
listFormatters.set(key, formatter)
}
return formatter.format(values)
return formatter
}
export function formatList(values: string[]): string {
return listFormatter({ style: 'short', type: 'unit' }).format(values)
}
export interface ListPart {
type: 'element' | 'literal'
value: string
}
/**
* A sentence-style list ("A, B and C") split into its items and the locale's joining words,
* so a template can render each item as a control without hardcoding English separators.
*/
export function formatListParts(values: string[]): ListPart[] {
return listFormatter({ style: 'long', type: 'conjunction' }).formatToParts(values)
}
export function formatDate(value: Date | number, options: Intl.DateTimeFormatOptions = {}): string {
+52 -1
View File
@@ -2474,7 +2474,58 @@
"noLanguage": "No language",
"noPublisher": "No publisher"
},
"unknownModifier": "Unknown modifier \"{modifier}\". It will be ignored, leaving the value unchanged."
"unknownModifier": "Unknown modifier \"{modifier}\". It will be ignored, leaving the value unchanged.",
"rulesNav": "Naming rules",
"railFoot": "A library without a pattern of its own follows the default it sits under.",
"usedBy": "Used by {libraries}",
"followsBase": "Follows the {base}.",
"copyPattern": "Copy pattern",
"recipes": "Recipes",
"recipesTitle": "Start from a recipe",
"recipesReplace": "Replaces the whole pattern",
"recipeInUse": "In use",
"insert": "Insert",
"insertToolbar": "Insert into the pattern",
"insertOptional": "Optional",
"insertFallback": "Fallback",
"insertFolder": "Folder",
"lineHint": "Press {enter} to start a new folder or type {brace} to pick a token. Click a folder icon to skip that folder when its value is missing.",
"downloadLineHint": "Downloads have no folders, so only the last part of the pattern is used.",
"tokenSuggestions": "Matching tokens",
"noTokenMatch": "No token matches \"{query}\".",
"uploadedNameKept": "Uploaded filename kept",
"level": {
"folder": "Folder {n}",
"bookFolder": "Book folder",
"file": "File name",
"skipWhenEmpty": "Skip {level} when its value is missing",
"skippedWhenEmpty": "Skipped when its value is missing. Click to always create this folder.",
"alwaysCreated": "Always created. Click to skip this folder when its value is missing."
},
"result": "Result",
"previewWith": "Preview with",
"previewWithAria": "Preview with {title}",
"previewBook": {
"sample": "Sample book",
"longTitle": "Long title and series",
"longSubtitle": "Long subtitle",
"manyAuthors": "Five authors",
"audiobook": "Audiobook",
"standalone": "No series"
},
"previewingCase": "Previewing: {case}",
"showComplete": "Show complete",
"previewStale": "The preview catches up once the pattern is fixed.",
"noChange": "No change",
"tagSkipped": "skipped",
"tagFallback": "fallback",
"tagBookFolder": "book folder",
"tagUploadedName": "uploaded name",
"tagShortened": "shortened to 255 bytes",
"tagEmptyName": "empty name",
"levelSkipped": "Skipped because {token} is empty",
"levelEmptyName": "Empty folder name. Give this folder a fallback so it always has one.",
"renameNote": "Books already on disk keep their paths. Libraries that rename files apply this pattern the next time a book's details are saved: {libraries}."
},
"kobo": {
"title": "Kobo Sync",
+24 -4
View File
@@ -131,9 +131,20 @@ function checkAllPlaceholdersPresent(block: string, values: Record<string, strin
});
}
export function replacePlaceholders(pattern: string, values: Record<string, string>): string {
export interface PlaceholderResolution {
text: string;
/** An optional group could not fill its primary side and used its `|fallback`. */
usedFallback: boolean;
/** An optional group with no fallback could not be filled and resolved to nothing. */
droppedOptional: boolean;
}
/** replacePlaceholders, also reporting which optional-group branch each group took. */
export function resolvePlaceholders(pattern: string, values: Record<string, string>): PlaceholderResolution {
let usedFallback = false;
let droppedOptional = false;
// Handle optional blocks with else clause: <primary|fallback>
pattern = pattern.replace(/<([^<>]+)>/g, (_, blockContent: string) => {
const resolved = pattern.replace(/<([^<>]+)>/g, (_, blockContent: string) => {
const pipeIndex = blockContent.indexOf("|");
const primary = pipeIndex >= 0 ? blockContent.substring(0, pipeIndex) : blockContent;
const fallback = pipeIndex >= 0 ? blockContent.substring(pipeIndex + 1) : null;
@@ -141,10 +152,19 @@ export function replacePlaceholders(pattern: string, values: Record<string, stri
if (checkAllPlaceholdersPresent(primary, values)) {
return resolveModifierPlaceholders(primary, values);
}
return fallback != null ? resolveModifierPlaceholders(fallback, values) : "";
if (fallback != null) {
usedFallback = true;
return resolveModifierPlaceholders(fallback, values);
}
droppedOptional = true;
return "";
});
return resolveModifierPlaceholders(pattern, values).trim();
return { text: resolveModifierPlaceholders(resolved, values).trim(), usedFallback, droppedOptional };
}
export function replacePlaceholders(pattern: string, values: Record<string, string>): string {
return resolvePlaceholders(pattern, values).text;
}
export function validatePattern(pattern: string): boolean {
@@ -7,6 +7,7 @@ import {
MAX_PATH_SEGMENT_BYTES,
replacePlaceholders,
resolveDownloadFilename,
resolvePlaceholders,
resolveUploadPath,
sanitizePathSegment,
validatePattern,
@@ -343,6 +344,34 @@ describe('replacePlaceholders', () => {
});
});
describe('resolvePlaceholders', () => {
it('resolves to the same text as replacePlaceholders', () => {
for (const pattern of [DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE, DEFAULT_UPLOAD_PATTERN_BOOK_PER_FOLDER, '{title}< - {subtitle}>']) {
expect(resolvePlaceholders(pattern, PARTIAL).text).toBe(replacePlaceholders(pattern, PARTIAL));
}
});
it('reports neither branch when every group fills its primary side', () => {
expect(resolvePlaceholders('<{authors}|Unknown>/<{series}/>{title}', FULL)).toEqual({
text: 'William Gibson/Sprawl/Neuromancer',
usedFallback: false,
droppedOptional: false,
});
});
it('reports a group that fell back', () => {
const result = resolvePlaceholders('<{publisher}|Unknown Publisher>/{title}', PARTIAL);
expect(result).toEqual({ text: 'Unknown Publisher/Project Hail Mary', usedFallback: true, droppedOptional: false });
});
it('reports a group without a fallback that dropped out', () => {
const result = resolvePlaceholders('<{series}/>{title}', PARTIAL);
expect(result).toEqual({ text: 'Project Hail Mary', usedFallback: false, droppedOptional: true });
});
});
// ── resolveUploadPath ─────────────────────────────────────────────────────────
describe('KOReader device pattern', () => {