diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 871077d..84d3d05 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -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"}, diff --git a/shelfmark/core/naming.py b/shelfmark/core/naming.py index 7c5baa7..883c1a5 100644 --- a/shelfmark/core/naming.py +++ b/shelfmark/core/naming.py @@ -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) diff --git a/tests/core/test_naming.py b/tests/core/test_naming.py index 3cb4561..63cf1a7 100644 --- a/tests/core/test_naming.py +++ b/tests/core/test_naming.py @@ -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."""