mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
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:
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user