From c86a17cecf3d63a3aa10fdb2103404c217ecfbfd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:51:14 +0000 Subject: [PATCH 1/3] Sort the library ignoring case and accents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library ordered names by their bytes, as SQLite does, so "abba" came after "ZZ Top", and "Émile" after both. The database now has a LOCALIZED collation that orders text as the phone's language does, as search results are, and the library's levels are ordered with it. It's named as the one Android's SQLite has, so both clients use the same SQL. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012foWs5vBJrYTDtfyFhJ3ky --- .../ClementineKit/Browse/Database.swift | 13 ++++++++ .../ClementineKit/Browse/SongQuery.swift | 5 +-- .../ClementineKitTests/LibraryTests.swift | 33 +++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/Packages/ClementineKit/Sources/ClementineKit/Browse/Database.swift b/Packages/ClementineKit/Sources/ClementineKit/Browse/Database.swift index ec027ac..82df375 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Browse/Database.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Browse/Database.swift @@ -20,6 +20,19 @@ final class Database { handle = nil throw DatabaseError(message: message) } + // Text in order for the phone's language, whatever its case or accents, as search results + // are: SQLite's own collation orders text by its bytes, putting "abba" after "ZZ Top", and + // "Émile" after both. Named as the one Android's SQLite has, so both clients' SQL is the + // same. Names equal but for case keep an order of their own, so the order is always the same. + sqlite3_create_collation_v2(handle, "LOCALIZED", SQLITE_UTF8, nil, { _, length1, text1, length2, text2 in + let first = String(decoding: UnsafeRawBufferPointer(start: text1, count: Int(length1)), as: UTF8.self) + let second = String(decoding: UnsafeRawBufferPointer(start: text2, count: Int(length2)), as: UTF8.self) + switch first.localizedStandardCompare(second) { + case .orderedAscending: return -1 + case .orderedDescending: return 1 + case .orderedSame: return first == second ? 0 : (first < second ? -1 : 1) + } + }, nil) } deinit { diff --git a/Packages/ClementineKit/Sources/ClementineKit/Browse/SongQuery.swift b/Packages/ClementineKit/Sources/ClementineKit/Browse/SongQuery.swift index f44f8fd..926ebc5 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Browse/SongQuery.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Browse/SongQuery.swift @@ -77,10 +77,11 @@ struct SongQuery { } sql += ", CAST(filename AS TEXT), artist, album FROM \(table)" sql += whereClause(selection) + // Names in order for the phone's language, whatever their case or accents (Database). if isSongLevel { - sql += " ORDER BY album, disc, track \(sorting.rawValue)" + sql += " ORDER BY album COLLATE LOCALIZED, disc, track \(sorting.rawValue)" } else { - sql += " GROUP BY \(fields[level]) ORDER BY \(fields[level]) \(sorting.rawValue)" + sql += " GROUP BY \(fields[level]) ORDER BY \(fields[level]) COLLATE LOCALIZED \(sorting.rawValue)" } return try database.query(sql, selection).map { row in diff --git a/Packages/ClementineKit/Tests/ClementineKitTests/LibraryTests.swift b/Packages/ClementineKit/Tests/ClementineKitTests/LibraryTests.swift index 8e63215..6158aab 100644 --- a/Packages/ClementineKit/Tests/ClementineKitTests/LibraryTests.swift +++ b/Packages/ClementineKit/Tests/ClementineKitTests/LibraryTests.swift @@ -77,6 +77,39 @@ struct LibraryTests { #expect(albums.items.map(\.value) == ["Suite bergamasque", "Gymnopédies", ""]) } + /// Names whose case and accents differ, and an album's songs whose names aren't in track order. + private func unsortedLibrary() throws -> Database { + let database = try Database(path: nil) + try database.execute("CREATE TABLE songs (artist TEXT, album TEXT, title TEXT, filename TEXT, disc INTEGER, track INTEGER)") + let songs: [(String, String, String, Int64)] = [ + ("ZZ Top", "Eliminator", "Song", 1), ("abba", "arrival", "Song", 1), + ("Érik Satie", "Gymnopédies", "Song", 1), ("Blondie", "Parallel Lines", "Song", 1), + ("Various", "zebra", "Song", 1), ("Various", "Apple", "Song", 1), ("Various", "apple", "Song", 1), + ("Various", "Été", "Song", 3), ("Various", "Été", "b second", 2), ("Various", "Été", "A first", 1), + ] + for song in songs { + try database.run("INSERT INTO songs VALUES (?, ?, ?, 'file:///song', 1, ?)", + [.text(song.0), .text(song.1), .text(song.2), .integer(song.3)]) + } + return database + } + + @Test func sortsIgnoringCaseAndAccents() throws { + let database = try unsortedLibrary() + let query = SongQuery(fields: ["artist", "album", "title"], sorting: .ascending, table: "songs") + #expect(try query.items(in: database, level: 0, selection: []).map(\.value) + == ["abba", "Blondie", "Érik Satie", "Various", "ZZ Top"]) + #expect(try query.items(in: database, level: 1, selection: ["Various"]).map(\.value) + == ["Apple", "apple", "Été", "zebra"]) + // An album's songs stay in track order. + #expect(try query.items(in: database, level: 2, selection: ["Various", "Été"]).map(\.value) + == ["A first", "b second", "Song"]) + + let descending = SongQuery(fields: ["artist", "album", "title"], sorting: .descending, table: "songs") + #expect(try descending.items(in: database, level: 0, selection: []).map(\.value) + == ["ZZ Top", "Various", "Érik Satie", "Blondie", "abba"]) + } + @Test func groupsByYear() async throws { let library = try await SampleLibrary().store() let debussy = try await library.level(below: nil, grouping: .artistYear, sorting: .ascending).items[1] From e8bc53b3f9d0fe820a2ed003209e977a42ce6e36 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:54:03 +0000 Subject: [PATCH 2/3] Don't expect case variants of a name in a particular order The library test expected "Apple" before "apple", but the order of names differing only in case is the language's to choose, and on iOS it isn't that one. It now checks they're side by side, before the rest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012foWs5vBJrYTDtfyFhJ3ky --- .../Tests/ClementineKitTests/LibraryTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Packages/ClementineKit/Tests/ClementineKitTests/LibraryTests.swift b/Packages/ClementineKit/Tests/ClementineKitTests/LibraryTests.swift index 6158aab..3cc1134 100644 --- a/Packages/ClementineKit/Tests/ClementineKitTests/LibraryTests.swift +++ b/Packages/ClementineKit/Tests/ClementineKitTests/LibraryTests.swift @@ -99,8 +99,10 @@ struct LibraryTests { let query = SongQuery(fields: ["artist", "album", "title"], sorting: .ascending, table: "songs") #expect(try query.items(in: database, level: 0, selection: []).map(\.value) == ["abba", "Blondie", "Érik Satie", "Various", "ZZ Top"]) - #expect(try query.items(in: database, level: 1, selection: ["Various"]).map(\.value) - == ["Apple", "apple", "Été", "zebra"]) + // Names differing only in case are side by side, in whichever order the language puts them. + let albums = try query.items(in: database, level: 1, selection: ["Various"]).map(\.value) + #expect(Set(albums.prefix(2)) == ["Apple", "apple"]) + #expect(albums.dropFirst(2) == ["Été", "zebra"]) // An album's songs stay in track order. #expect(try query.items(in: database, level: 2, selection: ["Various", "Été"]).map(\.value) == ["A first", "b second", "Song"]) From bad5a2dba6019440ac9eaca764c5d9a0a67dc2a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:56:58 +0000 Subject: [PATCH 3/3] Order names differing only in case the same way every time localizedStandardCompare told names differing only in case apart in its own order, not Android's. The LOCALIZED collation now compares ignoring case only, and the library then orders equal names by their bytes, as the Android client does, so "Apple" always comes before "apple". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012foWs5vBJrYTDtfyFhJ3ky --- .../Sources/ClementineKit/Browse/Database.swift | 13 +++++++------ .../Sources/ClementineKit/Browse/SongQuery.swift | 8 +++++--- .../Tests/ClementineKitTests/LibraryTests.swift | 13 ++++++++----- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/Packages/ClementineKit/Sources/ClementineKit/Browse/Database.swift b/Packages/ClementineKit/Sources/ClementineKit/Browse/Database.swift index 82df375..52b3ace 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Browse/Database.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Browse/Database.swift @@ -20,17 +20,18 @@ final class Database { handle = nil throw DatabaseError(message: message) } - // Text in order for the phone's language, whatever its case or accents, as search results - // are: SQLite's own collation orders text by its bytes, putting "abba" after "ZZ Top", and - // "Émile" after both. Named as the one Android's SQLite has, so both clients' SQL is the - // same. Names equal but for case keep an order of their own, so the order is always the same. + // Text in order for the phone's language, whatever its case or accents: SQLite's own + // collation orders text by its bytes, putting "abba" after "ZZ Top", and "Émile" after + // both. Named as the one Android's SQLite has, so both clients' SQL is the same. Names + // differing only in case are equal here: the query then orders them by their bytes, so + // "Apple" comes before "apple" on both clients. sqlite3_create_collation_v2(handle, "LOCALIZED", SQLITE_UTF8, nil, { _, length1, text1, length2, text2 in let first = String(decoding: UnsafeRawBufferPointer(start: text1, count: Int(length1)), as: UTF8.self) let second = String(decoding: UnsafeRawBufferPointer(start: text2, count: Int(length2)), as: UTF8.self) - switch first.localizedStandardCompare(second) { + switch first.compare(second, options: .caseInsensitive, locale: .current) { case .orderedAscending: return -1 + case .orderedSame: return 0 case .orderedDescending: return 1 - case .orderedSame: return first == second ? 0 : (first < second ? -1 : 1) } }, nil) } diff --git a/Packages/ClementineKit/Sources/ClementineKit/Browse/SongQuery.swift b/Packages/ClementineKit/Sources/ClementineKit/Browse/SongQuery.swift index 926ebc5..509ddfd 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Browse/SongQuery.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Browse/SongQuery.swift @@ -77,11 +77,13 @@ struct SongQuery { } sql += ", CAST(filename AS TEXT), artist, album FROM \(table)" sql += whereClause(selection) - // Names in order for the phone's language, whatever their case or accents (Database). + // Names in order for the phone's language, whatever their case or accents (Database), then + // by their bytes, so names differing only in case are always in the same order. + let field = fields[level] if isSongLevel { - sql += " ORDER BY album COLLATE LOCALIZED, disc, track \(sorting.rawValue)" + sql += " ORDER BY album COLLATE LOCALIZED, album, disc, track \(sorting.rawValue)" } else { - sql += " GROUP BY \(fields[level]) ORDER BY \(fields[level]) COLLATE LOCALIZED \(sorting.rawValue)" + sql += " GROUP BY \(field) ORDER BY \(field) COLLATE LOCALIZED \(sorting.rawValue), \(field) \(sorting.rawValue)" } return try database.query(sql, selection).map { row in diff --git a/Packages/ClementineKit/Tests/ClementineKitTests/LibraryTests.swift b/Packages/ClementineKit/Tests/ClementineKitTests/LibraryTests.swift index 3cc1134..0ca16ea 100644 --- a/Packages/ClementineKit/Tests/ClementineKitTests/LibraryTests.swift +++ b/Packages/ClementineKit/Tests/ClementineKitTests/LibraryTests.swift @@ -84,7 +84,9 @@ struct LibraryTests { let songs: [(String, String, String, Int64)] = [ ("ZZ Top", "Eliminator", "Song", 1), ("abba", "arrival", "Song", 1), ("Érik Satie", "Gymnopédies", "Song", 1), ("Blondie", "Parallel Lines", "Song", 1), - ("Various", "zebra", "Song", 1), ("Various", "Apple", "Song", 1), ("Various", "apple", "Song", 1), + // Names differing only in case, the lower-case one first, so their order doesn't come + // from the rows'. + ("Various", "zebra", "Song", 1), ("Various", "apple", "Song", 1), ("Various", "Apple", "Song", 1), ("Various", "Été", "Song", 3), ("Various", "Été", "b second", 2), ("Various", "Été", "A first", 1), ] for song in songs { @@ -99,10 +101,9 @@ struct LibraryTests { let query = SongQuery(fields: ["artist", "album", "title"], sorting: .ascending, table: "songs") #expect(try query.items(in: database, level: 0, selection: []).map(\.value) == ["abba", "Blondie", "Érik Satie", "Various", "ZZ Top"]) - // Names differing only in case are side by side, in whichever order the language puts them. - let albums = try query.items(in: database, level: 1, selection: ["Various"]).map(\.value) - #expect(Set(albums.prefix(2)) == ["Apple", "apple"]) - #expect(albums.dropFirst(2) == ["Été", "zebra"]) + // Names differing only in case in a fixed order, the same as on Android. + #expect(try query.items(in: database, level: 1, selection: ["Various"]).map(\.value) + == ["Apple", "apple", "Été", "zebra"]) // An album's songs stay in track order. #expect(try query.items(in: database, level: 2, selection: ["Various", "Été"]).map(\.value) == ["A first", "b second", "Song"]) @@ -110,6 +111,8 @@ struct LibraryTests { let descending = SongQuery(fields: ["artist", "album", "title"], sorting: .descending, table: "songs") #expect(try descending.items(in: database, level: 0, selection: []).map(\.value) == ["ZZ Top", "Various", "Érik Satie", "Blondie", "abba"]) + #expect(try descending.items(in: database, level: 1, selection: ["Various"]).map(\.value) + == ["zebra", "Été", "apple", "Apple"]) } @Test func groupsByYear() async throws {