mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 15:31:28 +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:
@@ -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"},
|
||||
|
||||
@@ -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+")
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user