Add configurable word separator for naming templates (#1333)

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.
This commit is contained in:
Vinicius Gabriel
2026-09-17 15:52:21 -04:00
committed by GitHub
parent 8f608f2e64
commit c53545d9fe
10 changed files with 228 additions and 8 deletions
+10
View File
@@ -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**
+18
View File
@@ -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",
+20 -2
View File
@@ -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
+11
View File
@@ -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:
+25 -4
View File
@@ -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)
@@ -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) => {
@@ -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.
@@ -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<Record<string, string>>(
(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';
@@ -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() == " "
+55
View File
@@ -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."""