From e1fe1c854462eea911bba6cbe6442a3dc8ced113 Mon Sep 17 00:00:00 2001 From: MaxDac <25101798+MaxDac@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:56:30 +0000 Subject: [PATCH] Expose the file helpers an app that syncs needs; align foldCase with the sync library MarkNotes Plus now hosts the sync engine that used to live in :core. Its base-copy store and its crash-recovery tests call three :core helpers that were `internal`, which they cannot do from another module: - `renameCaseFile`: the no-replace rename that keeps the exact case and the file identity. - `writeNewFile` and `NewFileStage`: the create-only write and its crash checkpoints. They become public, with documentation. No behaviour change. `foldCase` also differed from the sync library's case folding for three characters whose simple foldings were added in Unicode 15.1 (U+1FD3, U+1FE3, U+FB05): the JVM's upper-then-lower mapping puts them in a different equivalence class than the library, so a rename between such names was "case only" for one and not for the other. They are folded explicitly now and covered by `CaseFoldingTest`. Only the classes must agree, not the keys (the library folds Cherokee to upper case, the JVM to lower case); a test in Plus compares the classes over the whole Unicode range. Co-Authored-By: Claude Sonnet 5.5 --- .../marknotes/core/notes/CaseFolding.kt | 20 +++++++++++++++++-- .../maxdac/marknotes/core/notes/CaseRename.kt | 8 ++++++-- .../maxdac/marknotes/core/notes/NewFile.kt | 5 +++-- .../marknotes/core/notes/CaseFoldingTest.kt | 8 ++++++++ 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/core/src/main/kotlin/io/github/maxdac/marknotes/core/notes/CaseFolding.kt b/core/src/main/kotlin/io/github/maxdac/marknotes/core/notes/CaseFolding.kt index 0228ff3..8da4f49 100644 --- a/core/src/main/kotlin/io/github/maxdac/marknotes/core/notes/CaseFolding.kt +++ b/core/src/main/kotlin/io/github/maxdac/marknotes/core/notes/CaseFolding.kt @@ -7,8 +7,10 @@ package io.github.maxdac.marknotes.core.notes * The apps that sync notes compute the same key in the shared sync library (`foldCase`); both must agree, because * a rename is "case only" exactly when the keys are equal. This version has no dependency on that library and * folds through the JVM's simple case mappings (upper, then lower), which gives the same equivalence classes as the - * `C` and `S` mappings of CaseFolding.txt except for the dotted and dotless I, kept apart below. The sync - * library's own test vectors are repeated in `CaseFoldingTest`. + * `C` and `S` mappings of CaseFolding.txt except for the dotted and dotless I, kept apart below, and the three + * `S` mappings of Unicode 15.1 that older JVMs lack, added below. The keys themselves may differ from the library's + * (it folds Cherokee to upper case): only the classes must agree. The sync library's own test vectors are repeated + * in `CaseFoldingTest`. */ fun foldCase(path: String): String { val folded = StringBuilder(path.length) @@ -18,6 +20,14 @@ fun foldCase(path: String): String { index += Character.charCount(codePoint) val key = when (codePoint) { DOTTED_CAPITAL_I, DOTLESS_SMALL_I -> codePoint + + // Simple foldings added in Unicode 15.1 that the JVM's upper-then-lower mapping lacks. + GREEK_SMALL_IOTA_DIALYTIKA_PERISPOMENI -> GREEK_SMALL_IOTA_DIALYTIKA_TONOS + + GREEK_SMALL_UPSILON_DIALYTIKA_PERISPOMENI -> GREEK_SMALL_UPSILON_DIALYTIKA_TONOS + + LONG_S_T_LIGATURE -> ST_LIGATURE + else -> Character.toLowerCase(Character.toUpperCase(codePoint)) } folded.appendCodePoint(key) @@ -27,3 +37,9 @@ fun foldCase(path: String): String { private const val DOTTED_CAPITAL_I = 0x0130 private const val DOTLESS_SMALL_I = 0x0131 +private const val GREEK_SMALL_IOTA_DIALYTIKA_PERISPOMENI = 0x1FD3 +private const val GREEK_SMALL_IOTA_DIALYTIKA_TONOS = 0x0390 +private const val GREEK_SMALL_UPSILON_DIALYTIKA_PERISPOMENI = 0x1FE3 +private const val GREEK_SMALL_UPSILON_DIALYTIKA_TONOS = 0x03B0 +private const val LONG_S_T_LIGATURE = 0xFB05 +private const val ST_LIGATURE = 0xFB06 diff --git a/core/src/main/kotlin/io/github/maxdac/marknotes/core/notes/CaseRename.kt b/core/src/main/kotlin/io/github/maxdac/marknotes/core/notes/CaseRename.kt index e06640f..1683408 100644 --- a/core/src/main/kotlin/io/github/maxdac/marknotes/core/notes/CaseRename.kt +++ b/core/src/main/kotlin/io/github/maxdac/marknotes/core/notes/CaseRename.kt @@ -18,8 +18,12 @@ internal fun fileIdentity(file: File): String = Files.readAttributes(file.toPath(), BasicFileAttributes::class.java).fileKey()?.toString() ?: if (windows) WindowsFiles.identity(file) else throw IOException("Cannot verify file identity: $file") -/** Never use Java moves here: Unix implementations may check existence then perform a replacing rename. */ -internal fun renameCaseFile(source: File, target: File) { +/** + * Renames [source] to [target] without ever replacing an existing file, then checks that the exact case of the new + * name and the file identity survived. Never use Java moves here: Unix implementations may check existence then + * perform a replacing rename. Public for apps that keep their own files next to the notes (the sync base copies). + */ +fun renameCaseFile(source: File, target: File) { val identity = fileIdentity(source) moveFileNoReplace(source, target) val names = target.parentFile.list().orEmpty().toList() diff --git a/core/src/main/kotlin/io/github/maxdac/marknotes/core/notes/NewFile.kt b/core/src/main/kotlin/io/github/maxdac/marknotes/core/notes/NewFile.kt index 4b16f44..7013eaf 100644 --- a/core/src/main/kotlin/io/github/maxdac/marknotes/core/notes/NewFile.kt +++ b/core/src/main/kotlin/io/github/maxdac/marknotes/core/notes/NewFile.kt @@ -7,10 +7,11 @@ import java.nio.file.Files import java.nio.file.StandardOpenOption import java.nio.file.attribute.FileTime -internal enum class NewFileStage { BYTES_WRITTEN, MTIME_SET, FLUSHED, PUBLISHED } +/** The steps of [writeNewFile], reported to its `checkpoint` so tests can interrupt it after each one. */ +enum class NewFileStage { BYTES_WRITTEN, MTIME_SET, FLUSHED, PUBLISHED } /** Only complete, timestamped, flushed bytes may become visible at the create-only destination. */ -internal fun writeNewFile( +fun writeNewFile( target: File, bytes: ByteArray, modifiedMillis: Long, diff --git a/core/src/test/kotlin/io/github/maxdac/marknotes/core/notes/CaseFoldingTest.kt b/core/src/test/kotlin/io/github/maxdac/marknotes/core/notes/CaseFoldingTest.kt index f1db5df..650b423 100644 --- a/core/src/test/kotlin/io/github/maxdac/marknotes/core/notes/CaseFoldingTest.kt +++ b/core/src/test/kotlin/io/github/maxdac/marknotes/core/notes/CaseFoldingTest.kt @@ -11,6 +11,14 @@ class CaseFoldingTest { assertEquals(foldCase("ſ/Σ.md"), foldCase("S/ς.md")) } + @Test + fun foldingsAddedInUnicode151JoinTheirTargets() { + // The JVM's upper-then-lower mapping lacks these; the shared sync library folds them. + assertEquals(foldCase("\u0390.md"), foldCase("\u1FD3.md")) + assertEquals(foldCase("\u03B0.md"), foldCase("\u1FE3.md")) + assertEquals(foldCase("\uFB06.md"), foldCase("\uFB05.md")) + } + @Test fun dottedAndDotlessIAreNotFoldedToI() { assertNotEquals(foldCase("İ.md"), foldCase("i.md"))