diff --git a/Music/Models/SmartPlaylistCondition.swift b/Music/Models/SmartPlaylistCondition.swift new file mode 100644 index 0000000..62b24f2 --- /dev/null +++ b/Music/Models/SmartPlaylistCondition.swift @@ -0,0 +1,152 @@ +import Foundation + +// Classifies a track field for operator and UI purposes. +enum FieldType { + case string, int, double, date +} + +// Represents a track column that can be filtered on. +// Raw value matches the SQLite column name in the "tracks" table. +enum TrackField: String, Codable, CaseIterable, Identifiable, Sendable { + case title, artist, albumArtist, album, genre, composer, fileFormat + case year, bpm, rating, playCount, trackNumber, discNumber, bitrate, sampleRate + case fileSize, duration + case dateAdded, dateModified, lastPlayedAt + + var id: String { rawValue } + + var displayName: String { + switch self { + case .title: return "Title" + case .artist: return "Artist" + case .albumArtist: return "Album Artist" + case .album: return "Album" + case .genre: return "Genre" + case .composer: return "Composer" + case .fileFormat: return "File Format" + case .year: return "Year" + case .bpm: return "BPM" + case .rating: return "Rating" + case .playCount: return "Play Count" + case .trackNumber: return "Track Number" + case .discNumber: return "Disc Number" + case .bitrate: return "Bitrate" + case .sampleRate: return "Sample Rate" + case .fileSize: return "File Size" + case .duration: return "Duration" + case .dateAdded: return "Date Added" + case .dateModified: return "Date Modified" + case .lastPlayedAt: return "Last Played" + } + } + + var fieldType: FieldType { + switch self { + case .title, .artist, .albumArtist, .album, .genre, .composer, .fileFormat: + return .string + case .year, .bpm, .rating, .playCount, .trackNumber, .discNumber, .bitrate, .sampleRate, .fileSize: + return .int + case .duration: + return .double + case .dateAdded, .dateModified, .lastPlayedAt: + return .date + } + } + + var validOperators: [ConditionOperator] { + switch fieldType { + case .string: return [.equals, .startsWith] + case .int, .double, .date: return [.equals, .greaterThan, .lessThan] + } + } + + var defaultValue: ConditionValue { + switch fieldType { + case .string: return .string("") + case .int: return .int(0) + case .double: return .double(0) + case .date: return .date(Date()) + } + } +} + +enum ConditionOperator: String, Codable, Identifiable, Sendable { + case equals + case startsWith + case greaterThan + case lessThan + + var id: String { rawValue } + + var displayName: String { + switch self { + case .equals: return "is" + case .startsWith: return "starts with" + case .greaterThan: return "is greater than" + case .lessThan: return "is less than" + } + } +} + +// Tagged union storing the actual filter value with its type. +// Uses custom Codable to survive JSON round-trips cleanly. +enum ConditionValue: Equatable, Sendable { + case string(String) + case int(Int) + case double(Double) + case date(Date) + + var isEmpty: Bool { + if case .string(let s) = self { + return s.trimmingCharacters(in: .whitespaces).isEmpty + } + return false + } +} + +extension ConditionValue: Codable { + private enum CodingKeys: String, CodingKey { case type, value } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .string(let s): + try container.encode("string", forKey: .type) + try container.encode(s, forKey: .value) + case .int(let i): + try container.encode("int", forKey: .type) + try container.encode(i, forKey: .value) + case .double(let d): + try container.encode("double", forKey: .type) + try container.encode(d, forKey: .value) + case .date(let date): + try container.encode("date", forKey: .type) + try container.encode(date.timeIntervalSince1970, forKey: .value) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "string": + self = .string(try container.decode(String.self, forKey: .value)) + case "int": + self = .int(try container.decode(Int.self, forKey: .value)) + case "double": + self = .double(try container.decode(Double.self, forKey: .value)) + case "date": + self = .date(Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .value))) + default: + throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown type: \(type)") + } + } +} + +nonisolated struct SmartPlaylistCondition: Codable, Equatable, Sendable { + var field: TrackField + var op: ConditionOperator + var value: ConditionValue + + var isEmpty: Bool { value.isEmpty } +} diff --git a/MusicTests/SmartPlaylistTests.swift b/MusicTests/SmartPlaylistTests.swift index b099238..f89f135 100644 --- a/MusicTests/SmartPlaylistTests.swift +++ b/MusicTests/SmartPlaylistTests.swift @@ -76,4 +76,39 @@ struct SmartPlaylistTests { #expect(results[0].title == "Bitches Brew") #expect(results[1].title == "Kind of Blue") } + + // Encodes and decodes a SmartPlaylistCondition to/from JSON, + // verifying that all fields survive the round-trip. + @Test func conditionCodableRoundTrip() throws { + let condition = SmartPlaylistCondition( + field: .artist, + op: .equals, + value: .string("Miles Davis") + ) + let data = try JSONEncoder().encode(condition) + let decoded = try JSONDecoder().decode(SmartPlaylistCondition.self, from: data) + #expect(decoded.field == .artist) + #expect(decoded.op == .equals) + if case .string(let s) = decoded.value { + #expect(s == "Miles Davis") + } else { + Issue.record("Expected string value") + } + } + + // Encodes and decodes an array of conditions with mixed value types. + @Test func conditionsArrayCodableRoundTrip() throws { + let conditions: [SmartPlaylistCondition] = [ + SmartPlaylistCondition(field: .artist, op: .startsWith, value: .string("Miles")), + SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960)), + SmartPlaylistCondition(field: .dateAdded, op: .lessThan, value: .date(Date(timeIntervalSince1970: 0))) + ] + let data = try JSONEncoder().encode(conditions) + let decoded = try JSONDecoder().decode([SmartPlaylistCondition].self, from: data) + #expect(decoded.count == 3) + #expect(decoded[0].field == .artist) + #expect(decoded[1].op == .greaterThan) + if case .int(let y) = decoded[1].value { #expect(y == 1960) } else { Issue.record("Expected int") } + if case .date(let d) = decoded[2].value { #expect(d == Date(timeIntervalSince1970: 0)) } else { Issue.record("Expected date") } + } }