mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 19:00:21 +01:00
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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user