diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingService.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingService.java index 6113639..4641da3 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingService.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingService.java @@ -97,6 +97,11 @@ public final class TargetFilenameBuildingService { * If any rule is violated, the state is treated as an * {@link InconsistentProposalState}. *
+ * Windows compatibility: The final filename is cleaned of Windows-incompatible characters + * (e.g., {@code < > : " / \ | ? *}) to ensure the resulting filename can be created on + * Windows systems. This is a defensive measure; the validated title is already expected + * to contain only letters, digits, and spaces. + *
* The 20-character limit applies exclusively to the base title. A duplicate-avoidance * suffix (e.g., {@code (1)}) may be appended by the target folder adapter after this * method returns and is not counted against the 20 characters. @@ -134,8 +139,16 @@ public final class TargetFilenameBuildingService { + title + "'"); } + // Defensive Windows compatibility: remove Windows-incompatible characters + String cleanedTitle = removeWindowsIncompatibleCharacters(title); + if (cleanedTitle.isBlank()) { + return new InconsistentProposalState( + "Title becomes empty after Windows-compatibility cleaning: '" + + title + "'"); + } + // Build: YYYY-MM-DD - Titel.pdf - String baseFilename = date + " - " + title + ".pdf"; + String baseFilename = date + " - " + cleanedTitle + ".pdf"; return new BaseFilenameReady(baseFilename); } @@ -156,4 +169,21 @@ public final class TargetFilenameBuildingService { } return true; } + + /** + * Removes characters that are incompatible with Windows filenames. + *
+ * Windows-incompatible characters are: {@code < > : " / \ | ? *} + *
+ * This is a defensive measure for ensuring Windows compatibility. The characters are + * simply removed; no replacement is performed. Unicode letters (including Umlauts and ß) + * and spaces are retained. + * + * @param title the title to clean; must not be null + * @return the cleaned title with Windows-incompatible characters removed + */ + private static String removeWindowsIncompatibleCharacters(String title) { + // Windows-incompatible characters: < > : " / \ | ? * + return title.replaceAll("[<>:\"/\\\\|?*]", ""); + } } diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingServiceTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingServiceTest.java index 8eaafbf..0b1e49e 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingServiceTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingServiceTest.java @@ -21,8 +21,9 @@ import static org.assertj.core.api.Assertions.assertThatNullPointerException; * Unit tests for {@link TargetFilenameBuildingService}. *
* Covers the verbindliches Zielformat {@code YYYY-MM-DD - Titel.pdf}, the 20-character - * base-title rule, the fachliche Titelregel (only letters, digits, and spaces), and the - * detection of inconsistent persistence states. + * base-title rule, the fachliche Titelregel (only letters, digits, and spaces), + * Windows-compatibility character removal, and the detection of inconsistent persistence + * states. */ class TargetFilenameBuildingServiceTest { @@ -218,6 +219,86 @@ class TargetFilenameBuildingServiceTest { assertThat(result).isInstanceOf(InconsistentProposalState.class); } + // ------------------------------------------------------------------------- + // Windows compatibility – removal of incompatible characters + // (defensive measure; characters should not appear in validated title) + // ------------------------------------------------------------------------- + + @Test + void buildBaseFilename_validTitleProducesWindowsCompatibleFilename() { + // Valid titles containing only letters, digits, and spaces should produce + // correct Windows-compatible filenames + ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 5, 20), "Versicherung"); + + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + + assertThat(result).isInstanceOf(BaseFilenameReady.class); + assertThat(((BaseFilenameReady) result).baseFilename()) + .isEqualTo("2026-05-20 - Versicherung.pdf"); + } + + @Test + void buildBaseFilename_germanUmlautsAreRetainedInFilename() { + // German Umlauts (ä, ö, ü) and ß are valid filename characters on Windows + // and must be retained in the output filename + ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 6, 15), "Überprüfung"); + + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + + assertThat(result).isInstanceOf(BaseFilenameReady.class); + assertThat(((BaseFilenameReady) result).baseFilename()) + .isEqualTo("2026-06-15 - Überprüfung.pdf"); + } + + @Test + void buildBaseFilename_germanSzligIsRetainedInFilename() { + // German ß is a valid filename character on Windows and must be retained + ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 3, 10), "Straße"); + + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + + assertThat(result).isInstanceOf(BaseFilenameReady.class); + assertThat(((BaseFilenameReady) result).baseFilename()) + .isEqualTo("2026-03-10 - Straße.pdf"); + } + + @Test + void buildBaseFilename_completeFormatIsWindowsCompatible() { + // The complete filename format (YYYY-MM-DD - Titel.pdf) is Windows-compatible + // The hyphen in the date and the dot in the extension are valid Windows characters + ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 12, 31), "Bericht"); + + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + + assertThat(result).isInstanceOf(BaseFilenameReady.class); + String filename = ((BaseFilenameReady) result).baseFilename(); + assertThat(filename).matches("\\d{4}-\\d{2}-\\d{2} - .+\\.pdf"); + // Verify no Windows-incompatible characters: < > : " / \ | ? * + assertThat(filename).doesNotContain("<"); + assertThat(filename).doesNotContain(">"); + assertThat(filename).doesNotContain(":"); + assertThat(filename).doesNotContain("\""); + assertThat(filename).doesNotContain("/"); + assertThat(filename).doesNotContain("\\"); + assertThat(filename).doesNotContain("|"); + assertThat(filename).doesNotContain("?"); + assertThat(filename).doesNotContain("*"); + } + + @Test + void buildBaseFilename_twentyCharacterRuleUnaffectedByWindowsCompatibility() { + // The 20-character rule applies to the base title only. + // Windows-compatibility cleaning does not change the length counting mechanism. + String title = "Stromabrechnung 2026"; // exactly 20 characters + ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 3, 31), title); + + BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); + + assertThat(result).isInstanceOf(BaseFilenameReady.class); + assertThat(((BaseFilenameReady) result).baseFilename()) + .startsWith("2026-03-31 - Stromabrechnung 2026"); + } + // ------------------------------------------------------------------------- // InconsistentProposalState reason field is non-null // -------------------------------------------------------------------------