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
+41 -9
View File
@@ -19,6 +19,7 @@ from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import (
ActionButton,
CheckboxField,
CustomComponentField,
HeadingField,
MultiSelectField,
NumberField,
@@ -844,6 +845,37 @@ def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]:
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"},
+20
View File
@@ -16,6 +16,7 @@ logger = setup_logger(__name__)
# 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<primary>.+?)(?:\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+")
+3
View File
@@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Any
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 "",
@@ -11,6 +11,7 @@ from shelfmark.core.logger import setup_logger
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,
@@ -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,
};
};
+47
View File
@@ -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
+33
View File
@@ -11,6 +11,7 @@ import pytest
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 @@ class TestParseNamingTemplate:
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 @@ class TestArbitraryPrefixSuffix:
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."""
@@ -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"