From c53545d9fec9e63d3066d774adf9103f824f9032 Mon Sep 17 00:00:00 2001 From: Vinicius Gabriel Date: Thu, 17 Sep 2026 16:52:21 -0300 Subject: [PATCH] Add configurable word separator for naming templates (#1333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1230 ## What Adds a "Word Separator" setting (Space / Dot / Underscore / Hyphen / Custom) that replaces internal whitespace in each naming-template placeholder's rendered value — e.g. `{Author}` renders "Arthur.Conan.Doyle" instead of "Arthur Conan Doyle" when Dot is selected. This follows option 2 from the issue rather than inventing new dotted-keyword template syntax (`{Author.}`), since it's a smaller surface: one setting applies uniformly across all four templates (books/audiobooks × rename/organize) instead of needing a parallel token for every existing one. ## How it works - Literal characters typed into the template itself (e.g. the `.` in `{Author}.-.{Title}`) are never touched — only whitespace *inside* a placeholder's resolved value is affected. - Default is "Space", which is a no-op: existing templates produce byte-identical output after this change (verified via the existing test suite, unmodified, still passing). ## Where - `shelfmark/core/naming.py` — `word_separator` param on `parse_naming_template` / `build_library_path`. - `shelfmark/download/postprocess/policy.py` — `get_word_separator()`, mirroring the existing `get_file_organization()` accessor. - `shelfmark/download/postprocess/transfer.py` — wires the resolved separator through the four existing template-rendering call sites. - `shelfmark/config/settings.py` — new `Word Separator` / `Custom Word Separator` fields next to the existing naming-template fields. - `src/frontend/.../namingTemplatePreview.ts` + `NamingTemplateField.tsx` — the settings UI has its own TS mirror of the Python renderer for the live preview; updated it in lockstep so the preview doesn't lie about what the separator will actually do. - Tests added on both sides (pytest + vitest). ## Testing - `uv run pytest tests/core/test_naming.py tests/core/test_destination_file_organization.py` — all pass, including new cases. - `uv run pytest` (full suite) — same pre-existing failures as on `main` before this change (browser/network-dependent bypass & e2e tests unrelated to this diff), everything else green. - `uv run ruff check` / `ruff format --check` / `basedpyright` — clean. - `npm run lint` / `format:check` / `typecheck` / `test:unit` (196 tests) — clean. --- docs/environment-variables.md | 10 ++++ shelfmark/config/settings.py | 18 ++++++ shelfmark/core/naming.py | 22 +++++++- shelfmark/download/postprocess/policy.py | 11 ++++ shelfmark/download/postprocess/transfer.py | 29 ++++++++-- .../customFields/NamingTemplateField.tsx | 4 +- .../src/tests/namingTemplatePreview.test.ts | 36 ++++++++++++ .../src/utils/namingTemplatePreview.ts | 17 +++++- .../test_destination_file_organization.py | 34 ++++++++++++ tests/core/test_naming.py | 55 +++++++++++++++++++ 10 files changed, 228 insertions(+), 8 deletions(-) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 5ac035f..6cae56a 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -434,6 +434,7 @@ The release source tab to open by default in the release modal for audiobooks. U | `BOOKS_OUTPUT_MODE` | Choose where completed book files are sent. | string (choice) | `folder` | | `INGEST_DIR` | Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}). | string | `/books` | | `FILE_ORGANIZATION` | Choose how downloaded book files are named and organized. | string (choice) | `rename` | +| `NAMING_WORD_SEPARATOR` | Replaces spaces inside naming template values (e.g. 'Conan Doyle' -> 'Conan.Doyle' with '.'). Applies to books and audiobooks, rename and organize templates alike. Literal characters typed into a template (like the '-' in '{Author} - {Title}') are left as-is. Leave empty to keep spaces as-is. | string | _empty string_ | | `TEMPLATE_RENAME` | Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. 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. | string | `{Author} - {Title} ({Year})` | | `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` | | `HARDLINK_TORRENTS` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `false` | @@ -497,6 +498,15 @@ Choose how downloaded book files are named and organized. - **Default:** `rename` - **Options:** `none` (None), `rename` (Rename Only), `organize` (Rename and Organize) +#### `NAMING_WORD_SEPARATOR` + +**Word Separator** + +Replaces spaces inside naming template values (e.g. 'Conan Doyle' -> 'Conan.Doyle' with '.'). Applies to books and audiobooks, rename and organize templates alike. Literal characters typed into a template (like the '-' in '{Author} - {Title}') are left as-is. Leave empty to keep spaces as-is. + +- **Type:** string +- **Default:** _empty string_ + #### `TEMPLATE_RENAME` **Naming Template** diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 543f185..f6004db 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -1023,6 +1023,24 @@ def download_settings() -> list[SettingsField]: "value": "folder", }, ), + TextField( + key="NAMING_WORD_SEPARATOR", + label="Word Separator", + description=( + "Replaces spaces inside naming template values (e.g. 'Conan Doyle' -> " + "'Conan.Doyle' with '.'). Applies to books and audiobooks, rename and " + "organize templates alike. Literal characters typed into a template " + "(like the '-' in '{Author} - {Title}') are left as-is. Leave empty to " + "keep spaces as-is." + ), + default="", + placeholder=".", + max_length=5, + show_when={ + "field": "BOOKS_OUTPUT_MODE", + "value": "folder", + }, + ), # Rename mode template - filename only _naming_template_field( key="TEMPLATE_RENAME", diff --git a/shelfmark/core/naming.py b/shelfmark/core/naming.py index 2c69cf3..ed313b1 100644 --- a/shelfmark/core/naming.py +++ b/shelfmark/core/naming.py @@ -42,6 +42,10 @@ BRACE_PATTERN = re.compile(r"\{([^}]+)\}") # Characters that are invalid in filenames on various filesystems INVALID_CHARS = re.compile(r'[\\/:*?"<>|]') +# Runs of whitespace inside a single placeholder's rendered value, e.g. "Conan Doyle" +# -- collapsed to the configured word separator (see `parse_naming_template`). +WHITESPACE_RUN = re.compile(r"\s+") + def _sanitize(name: str | None, max_length: int = 245) -> str: """Sanitize a string for filesystem use.""" @@ -158,8 +162,16 @@ def parse_naming_template( metadata: Mapping[str, str | int | float | None], *, allow_path_separators: bool = True, + word_separator: str = " ", ) -> str: - """Render a naming template with Shelfmark metadata placeholders.""" + """Render a naming template with Shelfmark metadata placeholders. + + `word_separator` replaces whitespace *inside* each placeholder's rendered + value (e.g. "Conan Doyle" -> "Conan.Doyle" for a "." separator). It never + touches literal characters typed into the template itself, so a template + like "{Author}.-.{Title}" keeps its own dots regardless of this setting. + The default (" ") leaves values untouched, matching prior behavior. + """ if not template: return "" @@ -195,6 +207,8 @@ def parse_naming_template( if not value: return "" + if word_separator != " ": + value = WHITESPACE_RUN.sub(word_separator, value) if not allow_path_separators: value = value.replace("/", "_") value = sanitize_filename(value) @@ -260,9 +274,13 @@ def build_library_path( template: str, metadata: Mapping[str, str | int | float | None], extension: str | None = None, + *, + word_separator: str = " ", ) -> Path: """Build a final library path from a template and metadata.""" - relative = parse_naming_template(template, metadata, allow_path_separators=True) + relative = parse_naming_template( + template, metadata, allow_path_separators=True, word_separator=word_separator + ) if not relative: # Fallback to title if template produces empty result diff --git a/shelfmark/download/postprocess/policy.py b/shelfmark/download/postprocess/policy.py index 0aa6b8f..5f903a2 100644 --- a/shelfmark/download/postprocess/policy.py +++ b/shelfmark/download/postprocess/policy.py @@ -55,6 +55,17 @@ def get_file_organization(*, is_audiobook: bool) -> str: return mode if mode in ("none", "rename", "rename_and_group", "organize") else "rename" +def get_word_separator() -> str: + """Get the configured word separator for naming template values. + + Replaces whitespace inside each placeholder's rendered value (e.g. "Conan + Doyle" -> "Conan.Doyle"). The setting holds the separator character + directly (e.g. "." or "_"); empty means a plain space, which leaves + values unchanged. + """ + return _config_text(core_config.config.get("NAMING_WORD_SEPARATOR", "")) or " " + + def get_template(*, is_audiobook: bool, organization_mode: str) -> str: """Get the template for the content type and organization mode.""" if is_audiobook: diff --git a/shelfmark/download/postprocess/transfer.py b/shelfmark/download/postprocess/transfer.py index efdc054..4049016 100644 --- a/shelfmark/download/postprocess/transfer.py +++ b/shelfmark/download/postprocess/transfer.py @@ -25,7 +25,11 @@ from shelfmark.download.fs import ( atomic_move, run_blocking_io, ) -from shelfmark.download.postprocess.policy import get_file_organization, get_template +from shelfmark.download.postprocess.policy import ( + get_file_organization, + get_template, + get_word_separator, +) from .packs import BookGroup, PackBook, group_files_into_books, match_plan_to_files from .scan import collect_directory_files, scan_directory_tree @@ -198,6 +202,7 @@ def transfer_book_files( is_audiobook = check_audiobook(task.content_type) organization_mode = organization_mode or get_file_organization(is_audiobook=is_audiobook) + word_separator = get_word_separator() groups = resolve_book_groups(task, book_files, organization_mode=organization_mode) if groups is not None: @@ -229,6 +234,7 @@ def transfer_book_files( template, file_metadata, extension=ext or None, + word_separator=word_separator, ) run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True) @@ -256,6 +262,7 @@ def transfer_book_files( template, file_metadata, extension=ext or None, + word_separator=word_separator, ) run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True) @@ -289,7 +296,9 @@ def transfer_book_files( metadata = build_file_metadata(task, book_file) extension = book_file.suffix.lstrip(".") or task.format or "" - filename = parse_naming_template(template, metadata, allow_path_separators=False) + filename = parse_naming_template( + template, metadata, allow_path_separators=False, word_separator=word_separator + ) filename = Path(filename).name if filename else "" if filename and extension: filename = f"{sanitize_filename(filename)}.{extension}" @@ -483,7 +492,12 @@ def transfer_file_to_library( template_metadata = dict(metadata) template_metadata.setdefault("OriginalName", source_path.stem) dest_path = run_blocking_io( - build_library_path, library_base, template, template_metadata, extension + build_library_path, + library_base, + template, + template_metadata, + extension, + word_separator=get_word_separator(), ) run_blocking_io(dest_path.parent.mkdir, parents=True, exist_ok=True) @@ -538,12 +552,14 @@ def transfer_directory_to_library( safe_cleanup_path(temp_file, task) return None + word_separator = get_word_separator() base_library_path = run_blocking_io( build_library_path, library_base, template, metadata, extension=None, + word_separator=word_separator, ) run_blocking_io(base_library_path.parent.mkdir, parents=True, exist_ok=True) @@ -574,7 +590,12 @@ def transfer_directory_to_library( ext = source_file.suffix.lstrip(".") file_metadata = {**metadata, "PartNumber": part_number} file_path = run_blocking_io( - build_library_path, library_base, template, file_metadata, extension=ext + build_library_path, + library_base, + template, + file_metadata, + extension=ext, + word_separator=word_separator, ) run_blocking_io(file_path.parent.mkdir, parents=True, exist_ok=True) diff --git a/src/frontend/src/components/settings/customFields/NamingTemplateField.tsx b/src/frontend/src/components/settings/customFields/NamingTemplateField.tsx index f851129..4feca70 100644 --- a/src/frontend/src/components/settings/customFields/NamingTemplateField.tsx +++ b/src/frontend/src/components/settings/customFields/NamingTemplateField.tsx @@ -4,6 +4,7 @@ import type { TextFieldConfig } from '../../../types/settings'; import { buildNamingTemplatePreview, NAMING_TEMPLATE_TOKENS, + resolveWordSeparator, type NamingTemplateContent, type NamingTemplateMode, type NamingTemplateToken, @@ -66,7 +67,8 @@ export const NamingTemplateField = ({ (token) => !token.audiobookOnly || content === 'audiobook', ); const tokenGroups = groupTokens(availableTokens); - const preview = buildNamingTemplatePreview(value, mode, content); + const wordSeparator = resolveWordSeparator(values.NAMING_WORD_SEPARATOR); + const preview = buildNamingTemplatePreview(value, mode, content, wordSeparator); const hasPathSeparatorInFilename = mode === 'filename' && /[\\/]/.test(value); const insertToken = (token: string) => { diff --git a/src/frontend/src/tests/namingTemplatePreview.test.ts b/src/frontend/src/tests/namingTemplatePreview.test.ts index 0da2c38..c866df0 100644 --- a/src/frontend/src/tests/namingTemplatePreview.test.ts +++ b/src/frontend/src/tests/namingTemplatePreview.test.ts @@ -4,6 +4,7 @@ import { buildNamingTemplatePreview, NAMING_TEMPLATE_TOKENS, renderNamingTemplate, + resolveWordSeparator, SAMPLE_NAMING_METADATA, } from '../utils/namingTemplatePreview'; @@ -97,6 +98,41 @@ describe('namingTemplatePreview', () => { expect(swedish.value).not.toBe(english.value); }); + it('replaces internal whitespace with the configured word separator', () => { + const preview = renderNamingTemplate('{Author}/{PrimaryTitle}', SAMPLE_NAMING_METADATA, { + allowPathSeparators: true, + wordSeparator: '.', + }); + + expect(preview.value).toBe('Arthur.Conan.Doyle/The.Hound.of.the.Baskervilles'); + }); + + it('leaves values unchanged for the default space separator', () => { + const preview = renderNamingTemplate('{Author}', SAMPLE_NAMING_METADATA, { + allowPathSeparators: true, + }); + + expect(preview.value).toBe('Arthur Conan Doyle'); + }); + + it('never touches literal template characters, only placeholder values', () => { + const preview = renderNamingTemplate('{Author}.-.{PrimaryTitle}', SAMPLE_NAMING_METADATA, { + allowPathSeparators: true, + wordSeparator: '.', + }); + + expect(preview.value).toBe('Arthur.Conan.Doyle.-.The.Hound.of.the.Baskervilles'); + }); + + it('resolves the word separator setting like the backend policy module', () => { + expect(resolveWordSeparator('.')).toBe('.'); + expect(resolveWordSeparator('_')).toBe('_'); + expect(resolveWordSeparator('-')).toBe('-'); + expect(resolveWordSeparator('~')).toBe('~'); + expect(resolveWordSeparator('')).toBe(' '); + expect(resolveWordSeparator(undefined)).toBe(' '); + }); + it('keeps the picker and the known-token list in lockstep', () => { // KNOWN_TOKENS is a hand-maintained duplicate of the Python list. A token // added to the picker but not to it would render as an unknown variable. diff --git a/src/frontend/src/utils/namingTemplatePreview.ts b/src/frontend/src/utils/namingTemplatePreview.ts index 1dca04a..bade551 100644 --- a/src/frontend/src/utils/namingTemplatePreview.ts +++ b/src/frontend/src/utils/namingTemplatePreview.ts @@ -12,6 +12,8 @@ export interface NamingTemplateToken { interface RenderOptions { allowPathSeparators: boolean; + /** Mirrors `word_separator` in shelfmark/core/naming.py. Defaults to ' ' (no change). */ + wordSeparator?: string; } interface RenderResult { @@ -128,6 +130,7 @@ const firstAuthor = (value: string): string => value.split(/\s*[,;]\s*/)[0]?.tri const BRACE_PATTERN = /\{([^}]+)\}/g; const INVALID_CHARS_PATTERN = /[\\/:*?"<>|]/g; +const WHITESPACE_RUN_PATTERN = /\s+/g; export const SAMPLE_NAMING_METADATA = NAMING_TEMPLATE_TOKENS.reduce>( (metadata, token) => { @@ -193,11 +196,16 @@ export const renderNamingTemplate = ( const prefix = content.slice(0, index); const suffix = content.slice(index + name.length); - const rawValue = placeholderValue(name); + let rawValue = placeholderValue(name); if (!rawValue) { return ''; } + const wordSeparator = options.wordSeparator ?? ' '; + if (wordSeparator !== ' ') { + rawValue = rawValue.replace(WHITESPACE_RUN_PATTERN, wordSeparator); + } + const value = sanitizeFilename( options.allowPathSeparators ? rawValue : rawValue.replace(/\//g, '_'), ); @@ -253,13 +261,20 @@ export const renderNamingTemplate = ( return { value: result, unknownTokens }; }; +// Mirrors get_word_separator() in shelfmark/download/postprocess/policy.py. +export const resolveWordSeparator = (value: unknown): string => { + return (typeof value === 'string' ? value : '') || ' '; +}; + export const buildNamingTemplatePreview = ( template: string, mode: NamingTemplateMode, content: NamingTemplateContent, + wordSeparator = ' ', ): RenderResult => { const rendered = renderNamingTemplate(template, SAMPLE_NAMING_METADATA, { allowPathSeparators: mode === 'path', + wordSeparator, }); const fallback = SAMPLE_NAMING_METADATA.PrimaryTitle; const extension = content === 'audiobook' ? 'mp3' : 'epub'; diff --git a/tests/core/test_destination_file_organization.py b/tests/core/test_destination_file_organization.py index a8b382a..03ff0c7 100644 --- a/tests/core/test_destination_file_organization.py +++ b/tests/core/test_destination_file_organization.py @@ -143,3 +143,37 @@ def test_get_template_defaults_when_missing_and_ignores_pre_release_library_temp policy.get_template(is_audiobook=True, organization_mode="rename") == "{Author} - {Title} ({Year})" ) + + +def test_get_word_separator_defaults_to_space(monkeypatch): + import shelfmark.download.postprocess.policy as policy + + monkeypatch.setattr(policy.core_config.config, "get", lambda key, default=None: default) + + assert policy.get_word_separator() == " " + + +def test_get_word_separator_uses_configured_character(monkeypatch): + import shelfmark.download.postprocess.policy as policy + + for value, expected in [(".", "."), ("_", "_"), ("-", "-"), ("~", "~")]: + monkeypatch.setattr( + policy.core_config.config, + "get", + lambda key, default=None, value=value: {"NAMING_WORD_SEPARATOR": value}.get( + key, default + ), + ) + assert policy.get_word_separator() == expected + + +def test_get_word_separator_falls_back_to_space_for_blank_value(monkeypatch): + import shelfmark.download.postprocess.policy as policy + + monkeypatch.setattr( + policy.core_config.config, + "get", + lambda key, default=None: {"NAMING_WORD_SEPARATOR": ""}.get(key, default), + ) + + assert policy.get_word_separator() == " " diff --git a/tests/core/test_naming.py b/tests/core/test_naming.py index da396ce..82d19d5 100644 --- a/tests/core/test_naming.py +++ b/tests/core/test_naming.py @@ -220,6 +220,61 @@ class TestParseNamingTemplate: assert result == "Brandon Sanderson/The Way of Kings" +class TestWordSeparator: + """Tests for the `word_separator` option (#1230).""" + + def test_default_space_matches_prior_behavior(self): + """The default separator (' ') leaves placeholder values untouched.""" + result = parse_naming_template( + "{Author}/{Title}", {"Author": "Arthur Conan Doyle", "Title": "The Hound"} + ) + assert result == "Arthur Conan Doyle/The Hound" + + def test_dot_separator_replaces_internal_spaces(self): + result = parse_naming_template( + "{Author}/{Title}", + {"Author": "Arthur Conan Doyle", "Title": "The Hound of the Baskervilles"}, + word_separator=".", + ) + assert result == "Arthur.Conan.Doyle/The.Hound.of.the.Baskervilles" + + def test_underscore_and_hyphen_separators(self): + metadata = {"Author": "Arthur Conan Doyle", "Title": "The Hound"} + assert ( + parse_naming_template("{Author} - {Title}", metadata, word_separator="_") + == "Arthur_Conan_Doyle - The_Hound" + ) + assert ( + parse_naming_template("{Author} - {Title}", metadata, word_separator="-") + == "Arthur-Conan-Doyle - The-Hound" + ) + + def test_literal_template_characters_are_untouched(self): + """A dot typed into the template itself is not affected by the setting.""" + result = parse_naming_template( + "{Author}/{Author}.-.{Title}.({Year})", + {"Author": "Arthur Conan Doyle", "Title": "The Hound", "Year": 1902}, + word_separator=".", + ) + assert result == "Arthur.Conan.Doyle/Arthur.Conan.Doyle.-.The.Hound.(1902)" + + def test_custom_separator(self): + result = parse_naming_template( + "{Author}", {"Author": "Arthur Conan Doyle"}, word_separator="~" + ) + assert result == "Arthur~Conan~Doyle" + + def test_build_library_path_applies_separator(self): + path = build_library_path( + "/books", + "{Author}/{Title}", + {"Author": "Arthur Conan Doyle", "Title": "The Hound"}, + extension="epub", + word_separator=".", + ) + assert path == Path("/books/Arthur.Conan.Doyle/The.Hound.epub") + + class TestArbitraryPrefixSuffix: """Tests for enhanced template syntax with arbitrary prefix/suffix text."""