mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat: add else clause and value modifiers to file naming patterns (#2724)
This commit is contained in:
@@ -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(" ")
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user