diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 5a2049b0..3ebd38ee 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -19,6 +19,7 @@ from shelfmark.core.settings_registry import ( ActionButton, CheckboxField, + CustomComponentField, HeadingField, MultiSelectField, NumberField, @@ -844,6 +845,37 @@ def _is_plain_email_address(addr: str) -> bool: return {"error": False, "values": values} +def _naming_template_field( + *, + key: str, + label: str, + description: str, + default: str, + placeholder: str, + show_when: dict[str, Any] | list[dict[str, Any]], + universal_only: bool = False, +) -> CustomComponentField: + return CustomComponentField( + key=f"{key.lower()}_editor", + label=label, + component="naming_template", + show_when=show_when, + universal_only=universal_only, + wrap_in_field_wrapper=True, + value_fields=[ + TextField( + key=key, + label=label, + description=description, + default=default, + placeholder=placeholder, + show_when=show_when, + universal_only=universal_only, + ) + ], + ) + + @register_settings("downloads", "Downloads", icon="folder", order=5) def download_settings() -> list[SettingsField]: """Configure download behavior and file locations.""" @@ -931,10 +963,10 @@ def download_settings() -> list[SettingsField]: }, ), # Rename mode template - filename only - TextField( + _naming_template_field( key="TEMPLATE_RENAME", label="Naming Template", - description="Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\\'); use Organize for folders. Applies to single-file downloads.", + description="Filename template for single-file book downloads.", default="{Author} - {Title} ({Year})", placeholder="{Author} - {Title} ({Year})", show_when=[ @@ -943,10 +975,10 @@ def download_settings() -> list[SettingsField]: ], ), # Organize mode template - folders allowed - TextField( + _naming_template_field( key="TEMPLATE_ORGANIZE", label="Path Template", - description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.", + description="Folder and filename template for book downloads.", default="{Author}/{Title} ({Year})", placeholder="{Author}/{Series/}{Title} ({Year})", show_when=[ @@ -1124,7 +1156,7 @@ def download_settings() -> list[SettingsField]: TextField( key="EMAIL_SUBJECT_TEMPLATE", label="Subject Template", - description="Email subject. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}.", + description="Email subject. Variables: {Author}, {Title}, {PrimaryTitle}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {Format}.", default="{Title}", placeholder="{Title}", show_when={"field": "BOOKS_OUTPUT_MODE", "value": "email"}, @@ -1201,20 +1233,20 @@ def download_settings() -> list[SettingsField]: universal_only=True, ), # Rename mode template - filename only - TextField( + _naming_template_field( key="TEMPLATE_AUDIOBOOK_RENAME", label="Naming Template", - description="Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\\'); use Organize for folders. Applies to single-file downloads.", + description="Filename template for single-file audiobook downloads.", default="{Author} - {Title}", placeholder="{Author} - {Title}{ - Part }{PartNumber}", show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"}, universal_only=True, ), # Organize mode template - folders allowed - TextField( + _naming_template_field( key="TEMPLATE_AUDIOBOOK_ORGANIZE", label="Path Template", - description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.", + description="Folder and filename template for audiobook downloads.", default="{Author}/{Title}", placeholder="{Author}/{Series/}{Title}{ - Part }{PartNumber}", show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "organize"}, diff --git a/shelfmark/core/naming.py b/shelfmark/core/naming.py index 23018cef..bf21ee89 100644 --- a/shelfmark/core/naming.py +++ b/shelfmark/core/naming.py @@ -16,6 +16,7 @@ # e.g., "SeriesPosition" must match before "Series" KNOWN_TOKENS = [ "seriesposition", + "primarytitle", "originalname", "partnumber", "subtitle", @@ -65,6 +66,25 @@ def format_series_position(position: str | float | None) -> str: return str(position) +def derive_primary_title(title: str | None, subtitle: str | None) -> str: + """Return the title without an explicit subtitle suffix when possible.""" + title_value = " ".join(str(title or "").split()).strip() + if not title_value: + return "" + + subtitle_value = " ".join(str(subtitle or "").split()).strip() + if not subtitle_value: + return title_value + + pattern = rf"^(?P.+?)(?:\s*:\s*|\s+-\s+){re.escape(subtitle_value)}$" + match = re.match(pattern, title_value, flags=re.IGNORECASE) + if not match: + return title_value + + primary = match.group("primary").strip() + return primary or title_value + + # Pads numbers to 9 digits for natural sorting (e.g., "Part 2" -> "Part 000000002") PAD_NUMBERS_PATTERN = re.compile(r"\d+") diff --git a/shelfmark/download/outputs/email.py b/shelfmark/download/outputs/email.py index 4f97118e..2cdc5ee7 100644 --- a/shelfmark/download/outputs/email.py +++ b/shelfmark/download/outputs/email.py @@ -13,6 +13,7 @@ import shelfmark.core.config as core_config from shelfmark.core.logger import setup_logger +from shelfmark.core.naming import derive_primary_title from shelfmark.core.utils import is_audiobook as check_audiobook from shelfmark.download.outputs import register_output from shelfmark.download.staging import ( @@ -150,9 +151,11 @@ def _parse_attachment_limit_mb(value: object) -> int: def _render_subject(template: str, task: DownloadTask) -> str: + primary_title = derive_primary_title(task.title, task.subtitle) mapping = { "Author": task.author or "", "Title": task.title or "", + "PrimaryTitle": primary_title, "Year": task.year or "", "Series": task.series_name or "", "SeriesPosition": task.series_position or "", diff --git a/shelfmark/download/postprocess/transfer.py b/shelfmark/download/postprocess/transfer.py index dbc04dd6..c78fd9ac 100644 --- a/shelfmark/download/postprocess/transfer.py +++ b/shelfmark/download/postprocess/transfer.py @@ -11,6 +11,7 @@ from shelfmark.core.naming import ( assign_part_numbers, build_library_path, + derive_primary_title, parse_naming_template, same_filesystem, sanitize_filename, @@ -57,9 +58,11 @@ def should_hardlink(task: DownloadTask) -> bool: def build_metadata_dict(task: DownloadTask) -> dict: """Build template metadata from a download task.""" + primary_title = derive_primary_title(task.title, task.subtitle) return { "Author": task.author, "Title": task.title, + "PrimaryTitle": primary_title, "Subtitle": task.subtitle, "Year": task.year, "Series": task.series_name, diff --git a/src/frontend/src/components/settings/customFields/NamingTemplateField.tsx b/src/frontend/src/components/settings/customFields/NamingTemplateField.tsx new file mode 100644 index 00000000..5db74853 --- /dev/null +++ b/src/frontend/src/components/settings/customFields/NamingTemplateField.tsx @@ -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 = { + TEMPLATE_RENAME: 'filename', + TEMPLATE_ORGANIZE: 'path', + TEMPLATE_AUDIOBOOK_RENAME: 'filename', + TEMPLATE_AUDIOBOOK_ORGANIZE: 'path', +}; + +const FIELD_CONTENT: Record = { + 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 = ['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(null); + const boundField = useMemo( + () => + field.boundFields?.find((candidate): candidate is TextFieldConfig => isTextField(candidate)), + [field.boundFields], + ); + + if (!boundField) { + return

Naming template schema is unavailable.

; + } + + 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 ( +
+
+ 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 &&

{boundField.description}

} +
+ + {(hasPathSeparatorInFilename || preview.unknownTokens.length > 0) && ( +
+ {hasPathSeparatorInFilename && ( +

Rename templates cannot contain folder separators. Use Path Template instead.

+ )} + {preview.unknownTokens.length > 0 && ( +

+ {preview.unknownTokens.length === 1 ? 'Unknown variable: ' : 'Unknown variables: '} + {preview.unknownTokens.map((token) => `{${token}}`).join(', ')} +

+ )} +
+ )} + +
+ Preview:{' '} + {preview.value} +
+ +
+ + Insert variable + +
+ {tokenGroups.map((group) => ( +
+
+ {group.group} +
+
+ {group.tokens.map((token) => ( + + ))} +
+
+ ))} +
+
+
+ ); +}; diff --git a/src/frontend/src/components/settings/customFields/index.tsx b/src/frontend/src/components/settings/customFields/index.tsx index ef5cd20f..15209a05 100644 --- a/src/frontend/src/components/settings/customFields/index.tsx +++ b/src/frontend/src/components/settings/customFields/index.tsx @@ -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 = { request_policy_grid: { renderer: RequestPolicyGridField, }, + naming_template: { + renderer: NamingTemplateField, + }, settings_label: { renderer: SettingsLabel, }, diff --git a/src/frontend/src/tests/namingTemplatePreview.test.ts b/src/frontend/src/tests/namingTemplatePreview.test.ts new file mode 100644 index 00000000..94a469c3 --- /dev/null +++ b/src/frontend/src/tests/namingTemplatePreview.test.ts @@ -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'); + }); +}); diff --git a/src/frontend/src/utils/namingTemplatePreview.ts b/src/frontend/src/utils/namingTemplatePreview.ts new file mode 100644 index 00000000..63c41b46 --- /dev/null +++ b/src/frontend/src/utils/namingTemplatePreview.ts @@ -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>( + (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): Record => { + 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 = 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, + }; +}; diff --git a/tests/config/test_download_settings.py b/tests/config/test_download_settings.py index 4fd67fc7..3f45aefd 100644 --- a/tests/config/test_download_settings.py +++ b/tests/config/test_download_settings.py @@ -129,6 +129,53 @@ def test_download_settings_destination_test_buttons_exist(): assert audiobook_button.universal_only is True +def test_download_settings_naming_templates_use_wrapped_custom_component(): + from shelfmark.config.settings import download_settings + + fields = download_settings() + fields_by_key = {getattr(field, "key", None): field for field in fields} + + expected = { + "template_rename_editor": "TEMPLATE_RENAME", + "template_organize_editor": "TEMPLATE_ORGANIZE", + "template_audiobook_rename_editor": "TEMPLATE_AUDIOBOOK_RENAME", + "template_audiobook_organize_editor": "TEMPLATE_AUDIOBOOK_ORGANIZE", + } + + for editor_key, value_key in expected.items(): + editor = fields_by_key[editor_key] + + assert editor.get_field_type() == "CustomComponentField" + assert editor.component == "naming_template" + assert editor.wrap_in_field_wrapper is True + assert editor.get_bind_keys() == [value_key] + assert [field.key for field in editor.value_fields] == [value_key] + assert editor.label == editor.value_fields[0].label + + for value_key in expected.values(): + assert value_key not in fields_by_key + + +def test_download_settings_naming_template_serialization_keeps_value_fields_hidden(): + from shelfmark.config.settings import download_settings + from shelfmark.core import settings_registry + from shelfmark.core.settings_registry import SettingsTab + + tab = SettingsTab(name="downloads", display_name="Downloads", fields=download_settings()) + serialized_tab = settings_registry.serialize_tab(tab) + serialized_fields = {field["key"]: field for field in serialized_tab["fields"]} + + editor = serialized_fields["template_organize_editor"] + bound_fields = editor.get("boundFields", []) + + assert editor["component"] == "naming_template" + assert editor["wrapInFieldWrapper"] is True + assert editor["bindKeys"] == ["TEMPLATE_ORGANIZE"] + assert [field["key"] for field in bound_fields] == ["TEMPLATE_ORGANIZE"] + assert bound_fields[0]["hiddenInUi"] is True + assert bound_fields[0]["placeholder"] == "{Author}/{Series/}{Title} ({Year})" + + def test_test_books_destination_uses_current_values(tmp_path): from shelfmark.config.download_settings_handlers import check_books_destination diff --git a/tests/core/test_naming.py b/tests/core/test_naming.py index b5b7a814..da396ceb 100644 --- a/tests/core/test_naming.py +++ b/tests/core/test_naming.py @@ -11,6 +11,7 @@ from shelfmark.core.naming import ( assign_part_numbers, build_library_path, + derive_primary_title, format_series_position, natural_sort_key, parse_naming_template, @@ -117,6 +118,18 @@ def test_subtitle_token(self): result = parse_naming_template("{Author}/{Title}{ - Subtitle}", metadata) assert result == "Brandon Sanderson/The Way of Kings - Book One of the Stormlight Archive" + def test_primary_title_token(self): + """Test subtitle-less title tokens.""" + metadata = { + "Author": "Samin Nosrat", + "Title": "Salt, Fat, Acid, Heat: Mastering the Elements of Good Cooking", + "PrimaryTitle": "Salt, Fat, Acid, Heat", + } + + assert parse_naming_template("{Author}/{PrimaryTitle}", metadata) == ( + "Samin Nosrat/Salt, Fat, Acid, Heat" + ) + def test_part_number_token(self): """Test PartNumber in templates.""" metadata = {"Author": "Brandon Sanderson", "Title": "The Way of Kings", "PartNumber": "01"} @@ -338,6 +351,26 @@ def test_complex_template_with_arbitrary_prefixes(self): assert result == "Brandon Sanderson/Standalone Novel" +class TestDerivePrimaryTitle: + def test_removes_matching_colon_subtitle(self): + assert ( + derive_primary_title( + "Salt, Fat, Acid, Heat: Mastering the Elements of Good Cooking", + "Mastering the Elements of Good Cooking", + ) + == "Salt, Fat, Acid, Heat" + ) + + def test_removes_matching_dash_subtitle(self): + assert derive_primary_title("Book Title - A Novel", "A Novel") == "Book Title" + + def test_falls_back_to_title_without_subtitle(self): + assert derive_primary_title("Dune", None) == "Dune" + + def test_falls_back_when_subtitle_does_not_match_suffix(self): + assert derive_primary_title("The Final Empire", "Mistborn") == "The Final Empire" + + class TestBuildLibraryPath: """Tests for complete library path building.""" diff --git a/tests/core/test_primary_title_template_variable.py b/tests/core/test_primary_title_template_variable.py new file mode 100644 index 00000000..5076dcfb --- /dev/null +++ b/tests/core/test_primary_title_template_variable.py @@ -0,0 +1,57 @@ +"""Tests for subtitle-less title template variables.""" + +from pathlib import Path + +from shelfmark.core.models import DownloadTask +from shelfmark.download.postprocess.transfer import build_metadata_dict, transfer_book_files + + +def test_transfer_metadata_includes_primary_title(): + task = DownloadTask( + task_id="primary-title", + source="prowlarr", + title="Salt, Fat, Acid, Heat: Mastering the Elements of Good Cooking", + subtitle="Mastering the Elements of Good Cooking", + ) + + metadata = build_metadata_dict(task) + + assert metadata["PrimaryTitle"] == "Salt, Fat, Acid, Heat" + + +def test_single_file_rename_can_use_primary_title(tmp_path: Path, monkeypatch): + source_dir = tmp_path / "source" + destination = tmp_path / "destination" + source_dir.mkdir() + destination.mkdir() + + source_file = source_dir / "download.epub" + source_file.write_text("book") + + monkeypatch.setattr( + "shelfmark.download.postprocess.transfer.get_template", + lambda *, is_audiobook, organization_mode: "{Author} - {PrimaryTitle}", + ) + + task = DownloadTask( + task_id="primary-title-rename", + source="prowlarr", + title="Salt, Fat, Acid, Heat: Mastering the Elements of Good Cooking", + author="Samin Nosrat", + subtitle="Mastering the Elements of Good Cooking", + format="epub", + content_type="ebook", + ) + + final_paths, error, _op_counts = transfer_book_files( + [source_file], + destination=destination, + task=task, + use_hardlink=False, + is_torrent=False, + organization_mode="rename", + ) + + assert error is None + assert len(final_paths) == 1 + assert final_paths[0].name == "Samin Nosrat - Salt, Fat, Acid, Heat.epub"