diff --git a/Packages/ClementineKit/Sources/ClementineKit/Browse/Database.swift b/Packages/ClementineKit/Sources/ClementineKit/Browse/Database.swift index ec027ac..52b3ace 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Browse/Database.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Browse/Database.swift @@ -20,6 +20,20 @@ final class Database { handle = nil throw DatabaseError(message: message) } + // 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.compare(second, options: .caseInsensitive, locale: .current) { + case .orderedAscending: return -1 + case .orderedSame: return 0 + case .orderedDescending: return 1 + } + }, nil) } deinit { diff --git a/Packages/ClementineKit/Sources/ClementineKit/Browse/SongQuery.swift b/Packages/ClementineKit/Sources/ClementineKit/Browse/SongQuery.swift index f44f8fd..509ddfd 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Browse/SongQuery.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Browse/SongQuery.swift @@ -77,10 +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), 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, 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]) \(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 8e63215..0ca16ea 100644 --- a/Packages/ClementineKit/Tests/ClementineKitTests/LibraryTests.swift +++ b/Packages/ClementineKit/Tests/ClementineKitTests/LibraryTests.swift @@ -77,6 +77,44 @@ 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), + // 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 { + 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"]) + // 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"]) + + 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 { let library = try await SampleLibrary().store() let debussy = try await library.level(below: nil, grouping: .artistYear, sorting: .ascending).items[1]