feat: add else clause and value modifiers to file naming patterns (#2724)

This commit is contained in:
ACX
2026-02-12 22:17:55 -07:00
committed by GitHub
parent facfc2843c
commit 6b0cc27d1b
10 changed files with 1088 additions and 166 deletions

View File

@@ -32,6 +32,7 @@ public class PathPatternResolver {
private final Pattern CONTROL_CHARACTER_PATTERN = Pattern.compile("\\p{Cntrl}");
private final Pattern INVALID_CHARS_PATTERN = Pattern.compile("[\\\\/:*?\"<>|]");
private final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{(.*?)}");
private final Pattern MODIFIER_PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}:]+)(?::([^}]+))?}");
private final Pattern COMMA_SPACE_PATTERN = Pattern.compile(", ");
private final Pattern SLASH_PATTERN = Pattern.compile("/");
@@ -154,32 +155,27 @@ public class PathPatternResolver {
values.put("extension", extension);
// Handle optional blocks enclosed in <...>
// Handle optional blocks enclosed in <...>, supporting else clause via pipe: <left|right>
Pattern optionalBlockPattern = Pattern.compile("<([^<>]*)>");
Matcher matcher = optionalBlockPattern.matcher(pattern);
StringBuilder resolved = new StringBuilder(1024);
while (matcher.find()) {
String block = matcher.group(1);
Matcher placeholderMatcher = PLACEHOLDER_PATTERN.matcher(block);
boolean allHaveValues = true;
String blockContent = matcher.group(1);
// Check if all placeholders inside optional block have non-blank values
while (placeholderMatcher.find()) {
String key = placeholderMatcher.group(1);
String value = values.getOrDefault(key, "");
if (value.isBlank()) {
allHaveValues = false;
break;
}
}
// Split on first unescaped pipe for else clause
int pipeIndex = blockContent.indexOf('|');
String primaryBlock = pipeIndex >= 0 ? blockContent.substring(0, pipeIndex) : blockContent;
String fallbackBlock = pipeIndex >= 0 ? blockContent.substring(pipeIndex + 1) : null;
boolean allHaveValues = checkAllPlaceholdersHaveValues(primaryBlock, values);
if (allHaveValues) {
String resolvedBlock = block;
for (Map.Entry<String, String> entry : values.entrySet()) {
resolvedBlock = resolvedBlock.replace("{" + entry.getKey() + "}", entry.getValue());
}
String resolvedBlock = resolveBlockPlaceholders(primaryBlock, values);
matcher.appendReplacement(resolved, Matcher.quoteReplacement(resolvedBlock));
} else if (fallbackBlock != null) {
String resolvedFallback = resolveBlockPlaceholders(fallbackBlock, values);
matcher.appendReplacement(resolved, Matcher.quoteReplacement(resolvedFallback));
} else {
matcher.appendReplacement(resolved, "");
}
@@ -188,18 +184,22 @@ public class PathPatternResolver {
String result = resolved.toString();
// Replace known placeholders with values, preserve unknown ones
Matcher placeholderMatcher = PLACEHOLDER_PATTERN.matcher(result);
// Replace known placeholders (with optional modifiers) with values, preserve unknown ones
Matcher placeholderMatcher = MODIFIER_PLACEHOLDER_PATTERN.matcher(result);
StringBuilder finalResult = new StringBuilder(1024);
while (placeholderMatcher.find()) {
String key = placeholderMatcher.group(1);
if (values.containsKey(key)) {
String replacement = values.get(key);
String fieldName = placeholderMatcher.group(1);
String modifier = placeholderMatcher.group(2);
if (values.containsKey(fieldName)) {
String replacement = values.get(fieldName);
if (modifier != null && !modifier.isEmpty()) {
replacement = applyModifier(replacement, modifier, fieldName);
}
placeholderMatcher.appendReplacement(finalResult, Matcher.quoteReplacement(replacement));
} else {
// Preserve unknown placeholders (e.g., {foo})
placeholderMatcher.appendReplacement(finalResult, Matcher.quoteReplacement("{" + key + "}"));
placeholderMatcher.appendReplacement(finalResult, Matcher.quoteReplacement(placeholderMatcher.group()));
}
}
placeholderMatcher.appendTail(finalResult);
@@ -223,6 +223,67 @@ public class PathPatternResolver {
return validateFinalPath(result, folderBased);
}
private boolean checkAllPlaceholdersHaveValues(String block, Map<String, String> values) {
Matcher placeholderMatcher = MODIFIER_PLACEHOLDER_PATTERN.matcher(block);
while (placeholderMatcher.find()) {
String fieldName = placeholderMatcher.group(1);
String value = values.getOrDefault(fieldName, "");
if (value.isBlank()) {
return false;
}
}
return true;
}
private String resolveBlockPlaceholders(String block, Map<String, String> values) {
Matcher m = MODIFIER_PLACEHOLDER_PATTERN.matcher(block);
StringBuilder sb = new StringBuilder(256);
while (m.find()) {
String fieldName = m.group(1);
String modifier = m.group(2);
String replacement = values.getOrDefault(fieldName, "");
if (modifier != null && !modifier.isEmpty()) {
replacement = applyModifier(replacement, modifier, fieldName);
}
m.appendReplacement(sb, Matcher.quoteReplacement(replacement));
}
m.appendTail(sb);
return sb.toString();
}
private String applyModifier(String value, String modifier, String fieldName) {
if (value == null || value.isEmpty()) {
return value;
}
return switch (modifier) {
case "first" -> {
String[] parts = COMMA_SPACE_PATTERN.split(value);
yield parts[0].trim();
}
case "sort" -> {
String firstItem = COMMA_SPACE_PATTERN.split(value)[0].trim();
int lastSpace = firstItem.lastIndexOf(' ');
if (lastSpace > 0) {
yield firstItem.substring(lastSpace + 1) + ", " + firstItem.substring(0, lastSpace);
}
yield firstItem;
}
case "initial" -> {
String target = value;
if ("authors".equals(fieldName)) {
// For authors, use the first letter of the last name of the first author
String firstAuthor = COMMA_SPACE_PATTERN.split(value)[0].trim();
int lastSpace = firstAuthor.lastIndexOf(' ');
target = lastSpace > 0 ? firstAuthor.substring(lastSpace + 1) : firstAuthor;
}
yield target.substring(0, 1).toUpperCase();
}
case "upper" -> value.toUpperCase();
case "lower" -> value.toLowerCase();
default -> value;
};
}
private String sanitize(String input) {
if (input == null) return "";
return WHITESPACE_PATTERN.matcher(CONTROL_CHARACTER_PATTERN.matcher(INVALID_CHARS_PATTERN.matcher(input).replaceAll("")).replaceAll("")).replaceAll(" ")

View File

@@ -279,4 +279,146 @@ class PathPatternResolverTest {
assertThat(PathPatternResolver.resolvePattern(book1, pattern)).isEqualTo("Title - Subtitle.epub");
assertThat(PathPatternResolver.resolvePattern(book2, pattern)).isEqualTo("file.epub");
}
// ===== Else Clause Tests =====
@Test void elseClause_leftSideWhenPresent() {
var book = createBook("Title", List.of("Author"), null, "Series", 1f, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "<{series}|Standalone>/{title}")).isEqualTo("Series/Title.epub");
}
@Test void elseClause_fallbackWhenMissing() {
var book = createBook("Title", List.of("Author"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "<{series}|Standalone>/{title}")).isEqualTo("Standalone/Title.epub");
}
@Test void elseClause_fallbackWithPlaceholders() {
var book = createBook("Title", List.of("Author"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "<{series}/{seriesIndex} - {title}|{title}>")).isEqualTo("Title.epub");
}
@Test void elseClause_backwardCompatibleNoPipe() {
var book = createBook("Title", List.of("Author"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "<{series}/>{title}")).isEqualTo("Title.epub");
}
@Test void elseClause_mixedBlocksWithAndWithoutElse() {
var book = createBook("Title", List.of("Author"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "<{series}|Standalone>/<{year} - >{title}")).isEqualTo("Standalone/Title.epub");
}
// ===== Modifier Tests =====
@Test void modifier_firstMultipleAuthors() {
var book = createBook("Title", List.of("Patrick Rothfuss", "Brandon Sanderson"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "{authors:first}/{title}")).isEqualTo("Patrick Rothfuss/Title.epub");
}
@Test void modifier_sortAuthor() {
var book = createBook("Title", List.of("Patrick Rothfuss"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "{authors:sort}/{title}")).isEqualTo("Rothfuss, Patrick/Title.epub");
}
@Test void modifier_initialTitle() {
var book = createBook("The Name of the Wind", List.of("Author"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "{title:initial}/{title}")).isEqualTo("T/The Name of the Wind.epub");
}
@Test void modifier_initialAuthorsUsesLastName() {
var book = createBook("Title", List.of("Patrick Rothfuss"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "{authors:initial}/{authors:sort}/{title}")).isEqualTo("R/Rothfuss, Patrick/Title.epub");
}
@Test void modifier_upper() {
var book = createBook("Title", List.of("Author"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "{title:upper}")).isEqualTo("TITLE.epub");
}
@Test void modifier_lower() {
var book = createBook("Title", List.of("Author"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "{title:lower}")).isEqualTo("title.epub");
}
@Test void modifier_insideElseClause() {
var book = createBook("Title", List.of("Patrick Rothfuss"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "<{series}|{authors:sort}>/{title}")).isEqualTo("Rothfuss, Patrick/Title.epub");
}
@Test void modifier_inOptionalBlock() {
var book = createBook("Title", List.of("Patrick Rothfuss"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "<{authors:sort}/>{title}")).isEqualTo("Rothfuss, Patrick/Title.epub");
}
// ===== Edge Case Tests =====
@Test void modifier_sortThreeWordName() {
var book = createBook("T", List.of("Mary Jane Watson"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "{authors:sort}/{title}")).isEqualTo("Watson, Mary Jane/T.epub");
}
@Test void modifier_initialSingleWordAuthor() {
var book = createBook("T", List.of("Plato"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "{authors:initial}/{title}")).isEqualTo("P/T.epub");
}
@Test void elseClause_multipleBlocks() {
var book = createBook("Title", List.of("Author"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "<{series}|Standalone>/<{year}|Unknown> - {title}"))
.isEqualTo("Standalone/Unknown - Title.epub");
}
@Test void elseClause_emptyFallback() {
var book = createBook("Title", List.of("Author"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "<{series}|>{title}")).isEqualTo("Title.epub");
}
@Test void elseClause_primaryPartiallyMissing() {
var book = createBook("Title", List.of("Author"), null, "Series", null, null, null, null, null, "f.epub");
// series present but seriesIndex missing → fallback
assertThat(PathPatternResolver.resolvePattern(book, "<{series} #{seriesIndex}|{title}>")).isEqualTo("Title.epub");
}
@Test void modifier_inPrimarySideOfElseClause() {
var book = createBook("Title", List.of("Patrick Rothfuss"), null, "Series", null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "<{series} by {authors:sort}|{title}>"))
.isEqualTo("Series by Rothfuss, Patrick.epub");
}
@Test void modifier_chainedDifferentFields() {
var book = createBook("Title", List.of("Patrick Rothfuss"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "{title:initial}/{authors:first}/{title:lower}"))
.isEqualTo("T/Patrick Rothfuss/title.epub");
}
@Test void modifier_onMissingFieldInOptionalBlock() {
var book = createBook("Title", null, null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "<{authors:sort}/>{title}")).isEqualTo("Title.epub");
}
@Test void modifier_firstWithManyAuthors() {
var book = createBook("Title", List.of("Alice", "Bob", "Carol", "Dave"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "{authors:first}/{title}")).isEqualTo("Alice/Title.epub");
}
@Test void modifier_withElseClauseAndExtensionPlaceholder() {
var book = createBook("Title", List.of("Jane Doe"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "<{series:upper}|{authors:sort}>/{title}.{extension}"))
.isEqualTo("Doe, Jane/Title.epub");
}
@Test void modifier_initialLowercaseTitle() {
var book = createBook("lowercase title", List.of("Author"), null, null, null, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "{title:initial}/{title}")).isEqualTo("L/lowercase title.epub");
}
@Test void elseClause_primaryCompleteIgnoresFallback() {
var book = createBook("Title", List.of("Author"), null, "Series", 5f, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "<{series} #{seriesIndex}|{title}>")).isEqualTo("Series #05.epub");
}
@Test void elseClause_existingPatternsUnchanged() {
var book = createBook("Title", List.of("Author"), LocalDate.of(2023, 1, 1), "Series", 1f, null, null, null, null, "f.epub");
assertThat(PathPatternResolver.resolvePattern(book, "{authors}/<{series}/><{seriesIndex}. >{title}< ({year})>"))
.isEqualTo("Author/Series/01. Title (2023).epub");
}
}

View File

@@ -890,6 +890,434 @@ class PathPatternResolverTest {
"Result bytes should be <= " + MAX_FILENAME_BYTES);
}
// ===== Else Clause Tests =====
@Test
@DisplayName("Else clause: should use left side when values are present")
void testElseClause_leftSideUsedWhenPresent() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.seriesName("My Series")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "<{series}|Standalone>/{title}", "original.pdf");
assertEquals("My Series/Book Title.pdf", result);
}
@Test
@DisplayName("Else clause: should use fallback when values are missing")
void testElseClause_fallbackUsedWhenMissing() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "<{series}|Standalone>/{title}", "original.pdf");
assertEquals("Standalone/Book Title.pdf", result);
}
@Test
@DisplayName("Else clause: fallback can contain placeholders")
void testElseClause_fallbackWithPlaceholders() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(Set.of("John Doe"))
.build();
String result = PathPatternResolver.resolvePattern(metadata, "<{series}/{seriesIndex} - {title}|{title}>", "original.pdf");
assertEquals("Book Title.pdf", result);
}
@Test
@DisplayName("Else clause: backward compatible without pipe")
void testElseClause_backwardCompatibleNoPipe() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "<{series}/>{title}", "original.pdf");
assertEquals("Book Title.pdf", result);
}
@Test
@DisplayName("Else clause: literal text in fallback")
void testElseClause_literalFallback() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "<{series}|No Series>/{title}", "original.pdf");
assertEquals("No Series/Book Title.pdf", result);
}
@Test
@DisplayName("Else clause: mixed blocks with and without else")
void testElseClause_mixedBlocks() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(Set.of("Author"))
.build();
String result = PathPatternResolver.resolvePattern(metadata, "<{series}|Standalone>/<{year} - >{title}", "original.pdf");
assertEquals("Standalone/Book Title.pdf", result);
}
// ===== Modifier Tests =====
@Test
@DisplayName("Modifier: first with multiple authors")
void testModifier_firstMultipleAuthors() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(new LinkedHashSet<>(List.of("Patrick Rothfuss", "Brandon Sanderson")))
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{authors:first}/{title}", "original.pdf");
assertEquals("Patrick Rothfuss/Book Title.pdf", result);
}
@Test
@DisplayName("Modifier: first with single author")
void testModifier_firstSingleAuthor() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(Set.of("Patrick Rothfuss"))
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{authors:first}/{title}", "original.pdf");
assertEquals("Patrick Rothfuss/Book Title.pdf", result);
}
@Test
@DisplayName("Modifier: sort transforms 'First Last' to 'Last, First'")
void testModifier_sort() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(Set.of("Patrick Rothfuss"))
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{authors:sort}/{title}", "original.pdf");
assertEquals("Rothfuss, Patrick/Book Title.pdf", result);
}
@Test
@DisplayName("Modifier: sort with single-word author name")
void testModifier_sortSingleWord() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(Set.of("Plato"))
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{authors:sort}/{title}", "original.pdf");
assertEquals("Plato/Book Title.pdf", result);
}
@Test
@DisplayName("Modifier: initial on title")
void testModifier_initialTitle() {
BookMetadata metadata = BookMetadata.builder()
.title("The Name of the Wind")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title:initial}/{title}", "original.pdf");
assertEquals("T/The Name of the Wind.pdf", result);
}
@Test
@DisplayName("Modifier: initial on authors uses first letter of last name")
void testModifier_initialAuthors() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(Set.of("Patrick Rothfuss"))
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{authors:initial}/{authors:sort}/{title}", "original.pdf");
assertEquals("R/Rothfuss, Patrick/Book Title.pdf", result);
}
@Test
@DisplayName("Modifier: upper")
void testModifier_upper() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title:upper}", "original.pdf");
assertEquals("BOOK TITLE.pdf", result);
}
@Test
@DisplayName("Modifier: lower")
void testModifier_lower() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title:lower}", "original.pdf");
assertEquals("book title.pdf", result);
}
@Test
@DisplayName("Modifier: unknown modifier passes through unchanged")
void testModifier_unknownPassesThrough() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title:reverse}", "original.pdf");
assertEquals("Book Title.pdf", result);
}
@Test
@DisplayName("Modifier inside else clause fallback")
void testModifier_insideElseClause() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(Set.of("Patrick Rothfuss"))
.build();
String result = PathPatternResolver.resolvePattern(metadata, "<{series}|{authors:sort}>/{title}", "original.pdf");
assertEquals("Rothfuss, Patrick/Book Title.pdf", result);
}
@Test
@DisplayName("Modifier in optional block with present values")
void testModifier_inOptionalBlock() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(Set.of("Patrick Rothfuss"))
.build();
String result = PathPatternResolver.resolvePattern(metadata, "<{authors:sort}/>{title}", "original.pdf");
assertEquals("Rothfuss, Patrick/Book Title.pdf", result);
}
// ===== Edge Case Tests =====
@Test
@DisplayName("Modifier: sort with three-word author name uses last word as last name")
void testModifier_sortThreeWordName() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(Set.of("Mary Jane Watson"))
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{authors:sort}/{title}", "original.pdf");
assertEquals("Watson, Mary Jane/Book Title.pdf", result);
}
@Test
@DisplayName("Modifier: initial on single-word author name uses that name")
void testModifier_initialSingleWordAuthor() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(Set.of("Plato"))
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{authors:initial}/{title}", "original.pdf");
assertEquals("P/Book Title.pdf", result);
}
@Test
@DisplayName("Modifier: first on non-authors field takes first comma-separated value")
void testModifier_firstOnNonAuthorsField() {
BookMetadata metadata = BookMetadata.builder()
.title("Intro, Outro, Epilogue")
.build();
// The title contains commas, but they are sanitized first (commas are allowed chars).
// However, sanitize doesn't remove commas, so :first should split on ", "
String result = PathPatternResolver.resolvePattern(metadata, "{title:first}", "original.pdf");
// The title value after sanitize is "Intro, Outro, Epilogue" and first splits on ", "
assertEquals("Intro.pdf", result);
}
@Test
@DisplayName("Modifier: sort on non-authors field still works (splits on last space)")
void testModifier_sortOnTitle() {
BookMetadata metadata = BookMetadata.builder()
.title("The Wind")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title:sort}", "original.pdf");
assertEquals("Wind, The.pdf", result);
}
@Test
@DisplayName("Multiple else clause blocks in same pattern")
void testElseClause_multipleBlocks() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(Set.of("Author"))
.build();
String result = PathPatternResolver.resolvePattern(metadata,
"<{series}|Standalone>/<{year}|Unknown Year> - {title}", "original.pdf");
assertEquals("Standalone/Unknown Year - Book Title.pdf", result);
}
@Test
@DisplayName("Else clause with empty fallback produces empty string")
void testElseClause_emptyFallback() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "<{series}|>{title}", "original.pdf");
assertEquals("Book Title.pdf", result);
}
@Test
@DisplayName("Else clause: primary side partially missing with multiple placeholders triggers fallback")
void testElseClause_primaryPartiallyMissing() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.seriesName("My Series")
// seriesIndex is null
.build();
String result = PathPatternResolver.resolvePattern(metadata,
"<{series} #{seriesIndex}|{title}>", "original.pdf");
// series present but seriesIndex missing → fallback to {title}
assertEquals("Book Title.pdf", result);
}
@Test
@DisplayName("Modifier in primary side of else clause with all values present")
void testModifier_inPrimarySideOfElseClause() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(Set.of("Patrick Rothfuss"))
.seriesName("My Series")
.build();
String result = PathPatternResolver.resolvePattern(metadata,
"<{series} by {authors:sort}|{title}>", "original.pdf");
assertEquals("My Series by Rothfuss, Patrick.pdf", result);
}
@Test
@DisplayName("Chained modifiers on different fields in same pattern")
void testModifier_chainedDifferentFields() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(Set.of("Patrick Rothfuss"))
.build();
String result = PathPatternResolver.resolvePattern(metadata,
"{title:initial}/{authors:first}/{title:lower}", "original.pdf");
assertEquals("B/Patrick Rothfuss/book title.pdf", result);
}
@Test
@DisplayName("Modifier on field used in optional block where field is missing - block should be removed")
void testModifier_onMissingFieldInOptionalBlock() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.build();
String result = PathPatternResolver.resolvePattern(metadata,
"<{authors:sort}/>{title}", "original.pdf");
assertEquals("Book Title.pdf", result);
}
@Test
@DisplayName("Modifier: first with truncated author list preserves first author")
void testModifier_firstWithManyAuthors() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(new LinkedHashSet<>(List.of("Alice", "Bob", "Carol", "Dave")))
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{authors:first}/{title}", "original.pdf");
assertEquals("Alice/Book Title.pdf", result);
}
@Test
@DisplayName("Modifier combined with else clause and extension check")
void testModifier_withElseClauseAndExtension() {
BookMetadata metadata = BookMetadata.builder()
.title("My Book")
.authors(Set.of("Jane Doe"))
.build();
String result = PathPatternResolver.resolvePattern(metadata,
"<{series:upper}|{authors:sort}>/{title}.{extension}", "original.epub");
assertEquals("Doe, Jane/My Book.epub", result);
}
@Test
@DisplayName("Modifier: initial on lowercase title produces uppercase letter")
void testModifier_initialLowercaseTitle() {
BookMetadata metadata = BookMetadata.builder()
.title("lowercase title")
.build();
String result = PathPatternResolver.resolvePattern(metadata, "{title:initial}/{title}", "original.pdf");
assertEquals("L/lowercase title.pdf", result);
}
@Test
@DisplayName("Else clause with all primary values present ignores fallback")
void testElseClause_primaryCompleteIgnoresFallback() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.seriesName("My Series")
.seriesNumber(5f)
.build();
String result = PathPatternResolver.resolvePattern(metadata,
"<{series} #{seriesIndex}|{title}>", "original.pdf");
assertEquals("My Series #05.pdf", result);
}
@Test
@DisplayName("Else clause preserves backward compatibility with existing patterns")
void testElseClause_existingPatternsUnchanged() {
BookMetadata metadata = BookMetadata.builder()
.title("Book Title")
.authors(Set.of("Author"))
.seriesName("Series")
.seriesNumber(1f)
.publishedDate(LocalDate.of(2023, 1, 1))
.build();
String result = PathPatternResolver.resolvePattern(metadata,
"{authors}/<{series}/><{seriesIndex}. >{title}< ({year})>", "original.epub");
assertEquals("Author/Series/01. Book Title (2023).epub", result);
}
@Test
@DisplayName("Should remove leading slash from resolved pattern if first component is empty")
void testResolvePattern_removesLeadingSlash_whenFirstComponentIsEmpty() {