Loading Packages/Core/Sources/StorageKit/LibraryStore+Browse.swift 0 → 100644 +58 −0 Original line number Diff line number Diff line import Foundation import GRDB import PodcastModel extension LibraryStore { /// Most recent episodes across all subscriptions (for the "Nouveautés" inbox). public func recentEpisodes(limit: Int = 100) async throws -> [Episode] { try await dbQueue.read { db in try EpisodeRecord .order(Column("publicationDate").desc) .limit(limit) .fetchAll(db) .map { $0.toModel() } } } /// Episodes whose title matches `query` (case-insensitive), newest first. public func searchEpisodes(_ query: String, limit: Int = 50) async throws -> [Episode] { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return [] } let pattern = "%\(trimmed)%" return try await dbQueue.read { db in try EpisodeRecord .filter(Column("title").like(pattern)) .order(Column("publicationDate").desc) .limit(limit) .fetchAll(db) .map { $0.toModel() } } } // MARK: Subscription tags public func setTags(_ tags: [String], forPodcastID id: UUID) async throws { let podcastId = id.uuidString let clean = Set(tags.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }) try await dbQueue.write { db in try PodcastTagRecord.filter(Column("podcastId") == podcastId).deleteAll(db) for tag in clean { try PodcastTagRecord(podcastId: podcastId, tag: tag).insert(db) } } } /// All podcast→tags, for grouping/filtering the library. public func allPodcastTags() async throws -> [UUID: [String]] { try await dbQueue.read { db in let records = try PodcastTagRecord.order(Column("tag")).fetchAll(db) var result: [UUID: [String]] = [:] for record in records { if let id = UUID(uuidString: record.podcastId) { result[id, default: []].append(record.tag) } } return result } } } Packages/Core/Sources/StorageKit/LibraryStore.swift +9 −0 Original line number Diff line number Diff line Loading @@ -98,6 +98,14 @@ public final class LibraryStore: Sendable { .references("episode", onDelete: .cascade) } } migrator.registerMigration("v5") { db in try db.create(table: "podcast_tag") { t in t.column("podcastId", .text).notNull() .references("podcast", onDelete: .cascade) t.column("tag", .text).notNull() t.primaryKey(["podcastId", "tag"]) } } return migrator }() Loading Loading @@ -175,6 +183,7 @@ public final class LibraryStore: Sendable { /// play state). Used by the in-app reset. public func deleteAllData() async throws { _ = try await dbQueue.write { db in try PodcastTagRecord.deleteAll(db) try FavoriteRecord.deleteAll(db) try PlayStateRecord.deleteAll(db) try QueueRecord.deleteAll(db) Loading Packages/Core/Sources/StorageKit/Records.swift +7 −0 Original line number Diff line number Diff line Loading @@ -153,6 +153,13 @@ struct FavoriteRecord: Codable, FetchableRecord, PersistableRecord { var episodeId: String } /// A tag/folder assigned to a subscription (many-to-many). struct PodcastTagRecord: Codable, FetchableRecord, PersistableRecord { static let databaseTableName = "podcast_tag" var podcastId: String var tag: String } enum CategoryCoding { static func encode(_ categories: [String]) -> String { guard let data = try? JSONEncoder().encode(categories) else { return "[]" } Loading Packages/Core/Tests/StorageKitTests/BrowseTests.swift 0 → 100644 +40 −0 Original line number Diff line number Diff line import Foundation import Testing import StorageKit import PodcastModel @Suite struct BrowseTests { private func seed() async throws -> LibraryStore { let store = try LibraryStore.inMemory() let p = Podcast(feedURL: URL(string: "https://e.test/f.xml")!, title: "Tech Cast") _ = try await store.save(podcast: p, episodes: [ Episode(guid: "g0", title: "Swift et concurrence", publicationDate: Date(timeIntervalSince1970: 300), enclosureURL: URL(string: "https://e.test/0.mp3")), Episode(guid: "g1", title: "Réseaux et sécurité", publicationDate: Date(timeIntervalSince1970: 200), enclosureURL: URL(string: "https://e.test/1.mp3")), Episode(guid: "g2", title: "SwiftUI avancé", publicationDate: Date(timeIntervalSince1970: 100), enclosureURL: URL(string: "https://e.test/2.mp3")), ]) return store } @Test func recentEpisodesNewestFirst() async throws { let store = try await seed() let recent = try await store.recentEpisodes(limit: 2) #expect(recent.map(\.title) == ["Swift et concurrence", "Réseaux et sécurité"]) } @Test func searchMatchesTitleCaseInsensitive() async throws { let store = try await seed() let hits = try await store.searchEpisodes("swift") #expect(hits.map(\.title) == ["Swift et concurrence", "SwiftUI avancé"]) #expect(try await store.searchEpisodes(" ").isEmpty) } @Test func tagsRoundTrip() async throws { let store = try await seed() let podcast = try await store.allPodcasts()[0] try await store.setTags(["Actu", "Tech"], forPodcastID: podcast.id) let map = try await store.allPodcastTags() #expect(map[podcast.id]?.sorted() == ["Actu", "Tech"]) try await store.setTags([], forPodcastID: podcast.id) #expect(try await store.allPodcastTags()[podcast.id] == nil) } } Loading
Packages/Core/Sources/StorageKit/LibraryStore+Browse.swift 0 → 100644 +58 −0 Original line number Diff line number Diff line import Foundation import GRDB import PodcastModel extension LibraryStore { /// Most recent episodes across all subscriptions (for the "Nouveautés" inbox). public func recentEpisodes(limit: Int = 100) async throws -> [Episode] { try await dbQueue.read { db in try EpisodeRecord .order(Column("publicationDate").desc) .limit(limit) .fetchAll(db) .map { $0.toModel() } } } /// Episodes whose title matches `query` (case-insensitive), newest first. public func searchEpisodes(_ query: String, limit: Int = 50) async throws -> [Episode] { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return [] } let pattern = "%\(trimmed)%" return try await dbQueue.read { db in try EpisodeRecord .filter(Column("title").like(pattern)) .order(Column("publicationDate").desc) .limit(limit) .fetchAll(db) .map { $0.toModel() } } } // MARK: Subscription tags public func setTags(_ tags: [String], forPodcastID id: UUID) async throws { let podcastId = id.uuidString let clean = Set(tags.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }) try await dbQueue.write { db in try PodcastTagRecord.filter(Column("podcastId") == podcastId).deleteAll(db) for tag in clean { try PodcastTagRecord(podcastId: podcastId, tag: tag).insert(db) } } } /// All podcast→tags, for grouping/filtering the library. public func allPodcastTags() async throws -> [UUID: [String]] { try await dbQueue.read { db in let records = try PodcastTagRecord.order(Column("tag")).fetchAll(db) var result: [UUID: [String]] = [:] for record in records { if let id = UUID(uuidString: record.podcastId) { result[id, default: []].append(record.tag) } } return result } } }
Packages/Core/Sources/StorageKit/LibraryStore.swift +9 −0 Original line number Diff line number Diff line Loading @@ -98,6 +98,14 @@ public final class LibraryStore: Sendable { .references("episode", onDelete: .cascade) } } migrator.registerMigration("v5") { db in try db.create(table: "podcast_tag") { t in t.column("podcastId", .text).notNull() .references("podcast", onDelete: .cascade) t.column("tag", .text).notNull() t.primaryKey(["podcastId", "tag"]) } } return migrator }() Loading Loading @@ -175,6 +183,7 @@ public final class LibraryStore: Sendable { /// play state). Used by the in-app reset. public func deleteAllData() async throws { _ = try await dbQueue.write { db in try PodcastTagRecord.deleteAll(db) try FavoriteRecord.deleteAll(db) try PlayStateRecord.deleteAll(db) try QueueRecord.deleteAll(db) Loading
Packages/Core/Sources/StorageKit/Records.swift +7 −0 Original line number Diff line number Diff line Loading @@ -153,6 +153,13 @@ struct FavoriteRecord: Codable, FetchableRecord, PersistableRecord { var episodeId: String } /// A tag/folder assigned to a subscription (many-to-many). struct PodcastTagRecord: Codable, FetchableRecord, PersistableRecord { static let databaseTableName = "podcast_tag" var podcastId: String var tag: String } enum CategoryCoding { static func encode(_ categories: [String]) -> String { guard let data = try? JSONEncoder().encode(categories) else { return "[]" } Loading
Packages/Core/Tests/StorageKitTests/BrowseTests.swift 0 → 100644 +40 −0 Original line number Diff line number Diff line import Foundation import Testing import StorageKit import PodcastModel @Suite struct BrowseTests { private func seed() async throws -> LibraryStore { let store = try LibraryStore.inMemory() let p = Podcast(feedURL: URL(string: "https://e.test/f.xml")!, title: "Tech Cast") _ = try await store.save(podcast: p, episodes: [ Episode(guid: "g0", title: "Swift et concurrence", publicationDate: Date(timeIntervalSince1970: 300), enclosureURL: URL(string: "https://e.test/0.mp3")), Episode(guid: "g1", title: "Réseaux et sécurité", publicationDate: Date(timeIntervalSince1970: 200), enclosureURL: URL(string: "https://e.test/1.mp3")), Episode(guid: "g2", title: "SwiftUI avancé", publicationDate: Date(timeIntervalSince1970: 100), enclosureURL: URL(string: "https://e.test/2.mp3")), ]) return store } @Test func recentEpisodesNewestFirst() async throws { let store = try await seed() let recent = try await store.recentEpisodes(limit: 2) #expect(recent.map(\.title) == ["Swift et concurrence", "Réseaux et sécurité"]) } @Test func searchMatchesTitleCaseInsensitive() async throws { let store = try await seed() let hits = try await store.searchEpisodes("swift") #expect(hits.map(\.title) == ["Swift et concurrence", "SwiftUI avancé"]) #expect(try await store.searchEpisodes(" ").isEmpty) } @Test func tagsRoundTrip() async throws { let store = try await seed() let podcast = try await store.allPodcasts()[0] try await store.setTags(["Actu", "Tech"], forPodcastID: podcast.id) let map = try await store.allPodcastTags() #expect(map[podcast.id]?.sorted() == ["Actu", "Tech"]) try await store.setTags([], forPodcastID: podcast.id) #expect(try await store.allPodcastTags()[podcast.id] == nil) } }