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