PrimaryTitle field and path template enhancements (#908)

- Added `PrimaryTitle` field for renaming templates
- Improved UI for creating a template, including live preview and custom
field picker

Fixes #905
This commit is contained in:
Alex
2026-04-23 20:56:10 +01:00
committed by GitHub
parent e35b4c47a7
commit 791ba184ee
11 changed files with 662 additions and 9 deletions
@@ -0,0 +1,161 @@
import { useMemo, useRef } from 'react';
import type { TextFieldConfig } from '../../../types/settings';
import {
buildNamingTemplatePreview,
NAMING_TEMPLATE_TOKENS,
type NamingTemplateContent,
type NamingTemplateMode,
type NamingTemplateToken,
} from '../../../utils/namingTemplatePreview';
import type { CustomSettingsFieldRendererProps } from './types';
const FIELD_MODES: Record<string, NamingTemplateMode> = {
TEMPLATE_RENAME: 'filename',
TEMPLATE_ORGANIZE: 'path',
TEMPLATE_AUDIOBOOK_RENAME: 'filename',
TEMPLATE_AUDIOBOOK_ORGANIZE: 'path',
};
const FIELD_CONTENT: Record<string, NamingTemplateContent> = {
TEMPLATE_RENAME: 'book',
TEMPLATE_ORGANIZE: 'book',
TEMPLATE_AUDIOBOOK_RENAME: 'audiobook',
TEMPLATE_AUDIOBOOK_ORGANIZE: 'audiobook',
};
const isTextField = (field: unknown): field is TextFieldConfig => {
return Boolean(
field && typeof field === 'object' && 'type' in field && field.type === 'TextField',
);
};
const groupTokens = (tokens: NamingTemplateToken[]) => {
const groups: Array<NamingTemplateToken['group']> = ['Core', 'Universal', 'Files'];
return groups
.map((group) => ({
group,
tokens: tokens.filter((token) => token.group === group),
}))
.filter((entry) => entry.tokens.length > 0);
};
export const NamingTemplateField = ({
field,
values,
onChange,
isDisabled,
}: CustomSettingsFieldRendererProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const boundField = useMemo(
() =>
field.boundFields?.find((candidate): candidate is TextFieldConfig => isTextField(candidate)),
[field.boundFields],
);
if (!boundField) {
return <p className="text-xs opacity-60">Naming template schema is unavailable.</p>;
}
const rawValue = values[boundField.key];
const value: string = typeof rawValue === 'string' ? rawValue : '';
const mode: NamingTemplateMode = FIELD_MODES[boundField.key] ?? 'filename';
const content: NamingTemplateContent = FIELD_CONTENT[boundField.key] ?? 'book';
const fieldDisabled = isDisabled || Boolean(boundField.fromEnv);
const availableTokens = NAMING_TEMPLATE_TOKENS.filter(
(token) => !token.audiobookOnly || content === 'audiobook',
);
const tokenGroups = groupTokens(availableTokens);
const preview = buildNamingTemplatePreview(value, mode, content);
const hasPathSeparatorInFilename = mode === 'filename' && /[\\/]/.test(value);
const insertToken = (token: string) => {
if (fieldDisabled) {
return;
}
const insertion = `{${token}}`;
const input = inputRef.current;
if (!input) {
onChange(boundField.key, `${value}${insertion}`);
return;
}
const start = input.selectionStart ?? value.length;
const end = input.selectionEnd ?? value.length;
const nextValue = `${value.slice(0, start)}${insertion}${value.slice(end)}`;
onChange(boundField.key, nextValue);
window.requestAnimationFrame(() => {
input.focus();
const cursor = start + insertion.length;
input.setSelectionRange(cursor, cursor);
});
};
return (
<div className="min-w-0 space-y-3">
<div className="space-y-1.5">
<input
ref={inputRef}
type="text"
value={value}
onChange={(event) => onChange(boundField.key, event.target.value)}
placeholder={boundField.placeholder}
maxLength={boundField.maxLength}
disabled={fieldDisabled}
className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-500/50 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60"
/>
{boundField.description && <p className="text-xs opacity-60">{boundField.description}</p>}
</div>
{(hasPathSeparatorInFilename || preview.unknownTokens.length > 0) && (
<div className="space-y-1 text-xs text-amber-600 dark:text-amber-400">
{hasPathSeparatorInFilename && (
<p>Rename templates cannot contain folder separators. Use Path Template instead.</p>
)}
{preview.unknownTokens.length > 0 && (
<p>
{preview.unknownTokens.length === 1 ? 'Unknown variable: ' : 'Unknown variables: '}
{preview.unknownTokens.map((token) => `{${token}}`).join(', ')}
</p>
)}
</div>
)}
<div className="w-full rounded-lg border border-(--border-muted) bg-(--bg-soft) px-3 py-2 text-sm leading-relaxed break-words">
<span className="opacity-60">Preview:</span>{' '}
<code className="font-mono text-(--text)">{preview.value}</code>
</div>
<details className="min-w-0">
<summary className="cursor-pointer text-xs font-semibold text-sky-500 select-none hover:text-sky-400 dark:text-sky-400 dark:hover:text-sky-300">
Insert variable
</summary>
<div className="mt-1.5 space-y-3 rounded-lg border border-(--border-muted) bg-(--bg-soft) p-3">
{tokenGroups.map((group) => (
<div key={group.group} className="min-w-0">
<div className="mb-1.5 text-[11px] font-medium tracking-wide text-zinc-500 uppercase dark:text-zinc-400">
{group.group}
</div>
<div className="flex min-w-0 flex-wrap gap-1.5">
{group.tokens.map((token) => (
<button
key={token.token}
type="button"
onClick={() => insertToken(token.token)}
disabled={fieldDisabled}
title={`${token.description}: ${token.value}`}
className="inline-flex min-h-8 max-w-full items-center rounded-md bg-zinc-500/15 px-2.5 py-1 font-mono text-xs transition-colors hover:bg-zinc-500/25 disabled:cursor-not-allowed disabled:opacity-50"
>
{`{${token.token}}`}
</button>
))}
</div>
</div>
))}
</div>
</details>
</div>
);
};
@@ -1,5 +1,6 @@
import type { ComponentType, ReactNode } from 'react';
import { NamingTemplateField } from './NamingTemplateField';
import { OidcAdminHint } from './OidcAdminHint';
import { OidcEnvInfo } from './OidcEnvInfo';
import { RequestPolicyGridField } from './RequestPolicyGridField';
@@ -47,6 +48,9 @@ const CUSTOM_FIELD_DEFINITIONS: Record<string, CustomFieldDefinition> = {
request_policy_grid: {
renderer: RequestPolicyGridField,
},
naming_template: {
renderer: NamingTemplateField,
},
settings_label: {
renderer: SettingsLabel,
},
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import {
buildNamingTemplatePreview,
renderNamingTemplate,
SAMPLE_NAMING_METADATA,
} from '../utils/namingTemplatePreview';
describe('namingTemplatePreview', () => {
it('renders primary title in path previews', () => {
const preview = buildNamingTemplatePreview(
'{Author}/{Series/}{SeriesPosition - }{PrimaryTitle} ({Year})',
'path',
'book',
);
expect(preview.value).toBe(
'Arthur Conan Doyle/Sherlock Holmes/5 - The Hound of the Baskervilles (1902).epub',
);
});
it('omits conditional text when a variable is empty', () => {
const preview = renderNamingTemplate(
'{Author}/{Series/}{PrimaryTitle}{ - Subtitle}',
{
...SAMPLE_NAMING_METADATA,
Series: '',
Subtitle: '',
},
{ allowPathSeparators: true },
);
expect(preview.value).toBe('Arthur Conan Doyle/The Hound of the Baskervilles');
});
it('reports unknown bare variables', () => {
const preview = renderNamingTemplate('{Author}/{NotAThing}', SAMPLE_NAMING_METADATA, {
allowPathSeparators: true,
});
expect(preview.unknownTokens).toEqual(['NotAThing']);
expect(preview.value).toBe('Arthur Conan Doyle');
});
});
@@ -0,0 +1,249 @@
export type NamingTemplateMode = 'filename' | 'path';
export type NamingTemplateContent = 'book' | 'audiobook';
export interface NamingTemplateToken {
token: string;
label: string;
description: string;
value: string;
group: 'Core' | 'Universal' | 'Files';
audiobookOnly?: boolean;
}
interface RenderOptions {
allowPathSeparators: boolean;
}
interface RenderResult {
value: string;
unknownTokens: string[];
}
export const NAMING_TEMPLATE_TOKENS: NamingTemplateToken[] = [
{
token: 'Author',
label: 'Author',
description: 'Primary author',
value: 'Arthur Conan Doyle',
group: 'Core',
},
{
token: 'Title',
label: 'Full title',
description: 'Title as provided by metadata',
value: 'The Hound of the Baskervilles: Another Adventure of Sherlock Holmes',
group: 'Core',
},
{
token: 'PrimaryTitle',
label: 'Primary title',
description: 'Title without the subtitle suffix',
value: 'The Hound of the Baskervilles',
group: 'Core',
},
{
token: 'Year',
label: 'Year',
description: 'Publication year',
value: '1902',
group: 'Core',
},
{
token: 'User',
label: 'User',
description: 'Requesting user',
value: 'alex',
group: 'Core',
},
{
token: 'Series',
label: 'Series',
description: 'Series name',
value: 'Sherlock Holmes',
group: 'Universal',
},
{
token: 'SeriesPosition',
label: 'Series position',
description: 'Book position in the series',
value: '5',
group: 'Universal',
},
{
token: 'Subtitle',
label: 'Subtitle',
description: 'Subtitle from metadata',
value: 'Another Adventure of Sherlock Holmes',
group: 'Universal',
},
{
token: 'OriginalName',
label: 'Original name',
description: 'Source filename without extension',
value: 'The Hound of the Baskervilles - Chapter 01',
group: 'Files',
},
{
token: 'PartNumber',
label: 'Part number',
description: 'Sequential part number for multi-file audiobooks',
value: '01',
group: 'Files',
audiobookOnly: true,
},
];
const KNOWN_TOKENS = [
'seriesposition',
'primarytitle',
'originalname',
'partnumber',
'subtitle',
'author',
'series',
'title',
'year',
'user',
];
const BRACE_PATTERN = /\{([^}]+)\}/g;
const INVALID_CHARS_PATTERN = /[\\/:*?"<>|]/g;
export const SAMPLE_NAMING_METADATA = NAMING_TEMPLATE_TOKENS.reduce<Record<string, string>>(
(metadata, token) => {
metadata[token.token] = token.value;
return metadata;
},
{},
);
const sanitizeFilename = (value: string): string => {
return value
.replace(INVALID_CHARS_PATTERN, '_')
.replace(/^[\s.]+|[\s.]+$/g, '')
.replace(/_+/g, '_')
.slice(0, 245);
};
const normalizeMetadata = (metadata: Record<string, string>): Record<string, string> => {
return Object.fromEntries(
Object.entries(metadata).map(([key, value]) => [key.toLowerCase(), value]),
);
};
const findPlaceholder = (content: string): { name: string | null; index: number } => {
const lowerContent = content.toLowerCase();
for (const tokenName of KNOWN_TOKENS) {
const index = lowerContent.indexOf(tokenName);
if (index !== -1) {
return { name: tokenName, index };
}
}
return { name: null, index: -1 };
};
export const renderNamingTemplate = (
template: string,
metadata: Record<string, string> = SAMPLE_NAMING_METADATA,
options: RenderOptions,
): RenderResult => {
if (!template) {
return { value: '', unknownTokens: [] };
}
const normalized = normalizeMetadata(metadata);
const unknownTokens: string[] = [];
const placeholderValue = (placeholderName: string): string => {
return (normalized[placeholderName] ?? '').trim();
};
const renderBlock = (content: string): string | null => {
const { name, index } = findPlaceholder(content);
if (!name) {
const unknown = content.trim();
if (unknown && !/\s/.test(unknown) && !unknownTokens.includes(unknown)) {
unknownTokens.push(unknown);
}
return null;
}
const prefix = content.slice(0, index);
const suffix = content.slice(index + name.length);
const rawValue = placeholderValue(name);
if (!rawValue) {
return '';
}
const value = sanitizeFilename(
options.allowPathSeparators ? rawValue : rawValue.replace(/\//g, '_'),
);
return `${prefix}${value}${suffix}`;
};
const matches = Array.from(template.matchAll(BRACE_PATTERN));
let result = '';
if (matches.length === 0) {
result = template;
} else {
let cursor = 0;
matches.forEach((match, index) => {
result += template.slice(cursor, match.index);
const content = match[1] ?? '';
const rendered = renderBlock(content);
if (rendered !== null) {
result += rendered;
} else {
const nextMatch = matches[index + 1];
const conditionalLiteral =
nextMatch !== undefined && match.index + match[0].length === nextMatch.index;
const nextContent = nextMatch?.[1] ?? '';
const nextPlaceholder = findPlaceholder(nextContent).name;
const includeLiteral =
conditionalLiteral && nextPlaceholder
? Boolean(placeholderValue(nextPlaceholder))
: false;
if (includeLiteral) {
result += content;
} else if (!conditionalLiteral && /\s/.test(content)) {
result += match[0];
}
}
cursor = match.index + match[0].length;
});
result += template.slice(cursor);
}
result = result.replace(/\/+/g, '/');
result = result.replace(/^\/+|\/+$/g, '');
result = result.replace(/^[\s\-_.]+/g, '');
result = result.replace(/[\s\-_.]+$/g, '');
result = result.replace(/(\s*-\s*){2,}/g, ' - ');
result = result.replace(/\(\s*\)/g, '');
result = result.replace(/\[\s*\]/g, '');
result = result.replace(/[\s\-_.]+$/g, '');
return { value: result, unknownTokens };
};
export const buildNamingTemplatePreview = (
template: string,
mode: NamingTemplateMode,
content: NamingTemplateContent,
): RenderResult => {
const rendered = renderNamingTemplate(template, SAMPLE_NAMING_METADATA, {
allowPathSeparators: mode === 'path',
});
const fallback = SAMPLE_NAMING_METADATA.PrimaryTitle;
const extension = content === 'audiobook' ? 'mp3' : 'epub';
const baseValue = rendered.value || fallback;
return {
value: `${baseValue}.${extension}`,
unknownTokens: rendered.unknownTokens,
};
};