Enhance naming templates with arbitrary prefix/suffix support (#560)

As described in https://github.com/calibrain/shelfmark/issues/559
I would like the option to use prefix/suffix text as part of my file
handling.

I appreciate every feedack
This commit is contained in:
Marcel Meier
2026-01-30 13:53:05 +00:00
committed by GitHub
parent 301b2e5456
commit 86082c999c
3 changed files with 189 additions and 32 deletions
+4 -4
View File
@@ -683,7 +683,7 @@ def download_settings():
TextField(
key="TEMPLATE_RENAME",
label="Naming Template",
description="Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Rename templates are filename-only (no '/' or '\\'); use Organize for folders.",
description="Variables: {Author}, {Title}, {Year}. 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.",
default="{Author} - {Title} ({Year})",
placeholder="{Author} - {Title} ({Year})",
show_when=[
@@ -695,7 +695,7 @@ def download_settings():
TextField(
key="TEMPLATE_ORGANIZE",
label="Path Template",
description="Use / to create folders. Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}",
description="Use / to create folders. Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.",
default="{Author}/{Title} ({Year})",
placeholder="{Author}/{Series/}{Title} ({Year})",
show_when=[
@@ -799,7 +799,7 @@ def download_settings():
TextField(
key="TEMPLATE_AUDIOBOOK_RENAME",
label="Naming Template",
description="Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Rename templates are filename-only (no '/' or '\\'); use Organize for folders.",
description="Variables: {Author}, {Title}, {Year}, {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.",
default="{Author} - {Title}",
placeholder="{Author} - {Title}{ - Part }{PartNumber}",
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"},
@@ -809,7 +809,7 @@ def download_settings():
TextField(
key="TEMPLATE_AUDIOBOOK_ORGANIZE",
label="Path Template",
description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}",
description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.",
default="{Author}/{Title}",
placeholder="{Author}/{Series/}{Title}{ - Part }{PartNumber}",
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "organize"},
+38 -28
View File
@@ -10,11 +10,12 @@ from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
TOKEN_PATTERN = re.compile(
r'\{([- ._/\[(]*)' # prefix: space, dash, dot, underscore, slash, brackets
r'([A-Za-z]+)' # token name
r'([- ._/\])]*)\}' # suffix: space, dash, dot, underscore, slash, brackets
)
# Known variable tokens, sorted longest-first to avoid partial matches
# e.g., "SeriesPosition" must match before "Series"
KNOWN_TOKENS = ['seriesposition', 'partnumber', 'subtitle', 'author', 'series', 'title', 'year']
# Match any {...} block for template parsing
BRACE_PATTERN = re.compile(r'\{([^}]+)\}')
# Characters that are invalid in filenames on various filesystems
INVALID_CHARS = re.compile(r'[\\/:*?"<>|]')
@@ -88,37 +89,46 @@ def parse_naming_template(
# Normalize metadata keys to lowercase for case-insensitive matching
normalized = {k.lower(): v for k, v in metadata.items()}
def replace_token(match: re.Match) -> str:
prefix = match.group(1)
token_name = match.group(2).lower()
suffix = match.group(3)
def replace_block(match: re.Match) -> str:
content = match.group(1)
content_lower = content.lower()
# Get the value for this token
value = normalized.get(token_name)
# Find which known token appears in this block (longest first)
for token in KNOWN_TOKENS:
idx = content_lower.find(token)
if idx != -1:
prefix = content[:idx]
suffix = content[idx + len(token):]
# Special handling for series position
if token_name == 'seriesposition':
value = format_series_position(value)
# Get the value for this token
value = normalized.get(token)
# Convert to string
if value is None:
value = ""
else:
value = str(value).strip()
# Special handling for series position
if token == 'seriesposition':
value = format_series_position(value)
# If value is empty, return empty string (no prefix/suffix)
if not value:
return ""
# Convert to string
if value is None:
value = ""
else:
value = str(value).strip()
if not allow_path_separators:
value = value.replace("/", "_")
# Sanitize the value
value = sanitize_filename(value)
# If value is empty, return empty string (no prefix/suffix)
if not value:
return ""
return f"{prefix}{value}{suffix}"
if not allow_path_separators:
value = value.replace("/", "_")
# Sanitize the value
value = sanitize_filename(value)
return f"{prefix}{value}{suffix}"
# No known token found → return original block unchanged
return match.group(0)
# Replace all tokens
result = TOKEN_PATTERN.sub(replace_token, template)
result = BRACE_PATTERN.sub(replace_block, template)
# Clean up any double slashes that might result from empty tokens
result = re.sub(r'/+', '/', result)
+147
View File
@@ -216,6 +216,153 @@ class TestParseNamingTemplate:
assert result == "Brandon Sanderson/The Way of Kings"
class TestArbitraryPrefixSuffix:
"""Tests for enhanced template syntax with arbitrary prefix/suffix text."""
def test_vol_prefix_with_value(self):
"""Test {Vol. SeriesPosition - } with a value."""
result = parse_naming_template(
"{Vol. SeriesPosition - }{Title}",
{"SeriesPosition": 2, "Title": "Book Title"}
)
assert result == "Vol. 2 - Book Title"
def test_vol_prefix_without_value(self):
"""Test {Vol. SeriesPosition - } without a value produces nothing."""
result = parse_naming_template(
"{Vol. SeriesPosition - }{Title}",
{"SeriesPosition": None, "Title": "Book Title"}
)
assert result == "Book Title"
def test_vol_prefix_empty_string(self):
"""Test {Vol. SeriesPosition - } with empty string produces nothing."""
result = parse_naming_template(
"{Vol. SeriesPosition - }{Title}",
{"SeriesPosition": "", "Title": "Book Title"}
)
assert result == "Book Title"
def test_book_x_of_series_pattern(self):
"""Test {Book SeriesPosition of the Series} pattern."""
result = parse_naming_template(
"{Book SeriesPosition of the Series}",
{"SeriesPosition": 2, "Series": "Stormlight"}
)
assert result == "Book 2 of the Series"
def test_case_insensitive_arbitrary_prefix(self):
"""Test case-insensitive token matching with arbitrary prefix."""
result = parse_naming_template(
"{vol. seriesposition - }{Title}",
{"SeriesPosition": 3, "Title": "Book"}
)
assert result == "vol. 3 - Book"
def test_arbitrary_prefix_with_part_number(self):
"""Test arbitrary prefix with PartNumber token."""
result = parse_naming_template(
"{Part PartNumber}",
{"PartNumber": "05"}
)
assert result == "Part 05"
def test_arbitrary_prefix_part_number_empty(self):
"""Test arbitrary prefix with empty PartNumber."""
result = parse_naming_template(
"{Title}{Part PartNumber}",
{"Title": "Book", "PartNumber": None}
)
assert result == "Book"
def test_no_variable_in_block_unchanged(self):
"""Test that blocks without known variables are left unchanged."""
result = parse_naming_template(
"{literal text}",
{"Title": "Book"}
)
assert result == "{literal text}"
def test_mixed_legacy_and_new_syntax(self):
"""Test mixed template with both legacy and new syntax."""
result = parse_naming_template(
"{Author}/{Vol. SeriesPosition - }{Title}",
{"Author": "Sanderson", "SeriesPosition": 1, "Title": "Mistborn"}
)
assert result == "Sanderson/Vol. 1 - Mistborn"
def test_mixed_with_empty_series_position(self):
"""Test mixed template when series position is empty."""
result = parse_naming_template(
"{Author}/{Vol. SeriesPosition - }{Title}",
{"Author": "Sanderson", "SeriesPosition": None, "Title": "Elantris"}
)
assert result == "Sanderson/Elantris"
def test_subtitle_with_arbitrary_prefix(self):
"""Test Subtitle token with arbitrary prefix text."""
result = parse_naming_template(
"{Title}{: Subtitle}",
{"Title": "Main", "Subtitle": "Secondary"}
)
assert result == "Main: Secondary"
def test_year_with_arbitrary_prefix_suffix(self):
"""Test Year with arbitrary text around it."""
result = parse_naming_template(
"{Title} {(Year)}",
{"Title": "Book", "Year": 2020}
)
assert result == "Book (2020)"
def test_year_empty_with_arbitrary_prefix_suffix(self):
"""Test Year with arbitrary text when Year is empty."""
result = parse_naming_template(
"{Title} {(Year)}",
{"Title": "Book", "Year": None}
)
# Note: trailing space gets cleaned up
assert result == "Book"
def test_series_position_longest_match(self):
"""Test that SeriesPosition matches before Series."""
result = parse_naming_template(
"{SeriesPosition - }{Series}",
{"SeriesPosition": 1, "Series": "Stormlight"}
)
assert result == "1 - Stormlight"
def test_arbitrary_prefix_float_position(self):
"""Test arbitrary prefix with float series position."""
result = parse_naming_template(
"{Vol. SeriesPosition - }{Title}",
{"SeriesPosition": 1.5, "Title": "Novella"}
)
assert result == "Vol. 1.5 - Novella"
def test_complex_template_with_arbitrary_prefixes(self):
"""Test complex template combining multiple arbitrary prefix patterns."""
template = "{Author}/{Series/}{Vol. SeriesPosition - }{Title}{: Subtitle} {(Year)}"
# All fields present
result = parse_naming_template(template, {
"Author": "Brandon Sanderson",
"Series": "Stormlight Archive",
"SeriesPosition": 1,
"Title": "The Way of Kings",
"Subtitle": "Epic Fantasy",
"Year": 2010
})
assert result == "Brandon Sanderson/Stormlight Archive/Vol. 1 - The Way of Kings: Epic Fantasy (2010)"
# Minimal fields
result = parse_naming_template(template, {
"Author": "Brandon Sanderson",
"Title": "Standalone Novel"
})
assert result == "Brandon Sanderson/Standalone Novel"
class TestBuildLibraryPath:
"""Tests for complete library path building."""