Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 41 additions & 9 deletions shelfmark/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from shelfmark.core.settings_registry import (
ActionButton,
CheckboxField,
CustomComponentField,
HeadingField,
MultiSelectField,
NumberField,
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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=[
Expand All @@ -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=[
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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"},
Expand Down
20 changes: 20 additions & 0 deletions shelfmark/core/naming.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
# e.g., "SeriesPosition" must match before "Series"
KNOWN_TOKENS = [
"seriesposition",
"primarytitle",
"originalname",
"partnumber",
"subtitle",
Expand Down Expand Up @@ -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+")

Expand Down
3 changes: 3 additions & 0 deletions shelfmark/download/outputs/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 "",
Expand Down
3 changes: 3 additions & 0 deletions shelfmark/download/postprocess/transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
};
4 changes: 4 additions & 0 deletions src/frontend/src/components/settings/customFields/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -47,6 +48,9 @@ const CUSTOM_FIELD_DEFINITIONS: Record<string, CustomFieldDefinition> = {
request_policy_grid: {
renderer: RequestPolicyGridField,
},
naming_template: {
renderer: NamingTemplateField,
},
settings_label: {
renderer: SettingsLabel,
},
Expand Down
Loading
Loading