Commit 78131f4a authored by Kourser's avatar Kourser
Browse files

StorageKit: recent episodes (inbox), episode search, subscription tags



recentEpisodes / searchEpisodes queries + a podcast_tag table (migration v5)
with setTags/allPodcastTags. + tests (59 green).

Co-Authored-By: default avatarClaude <claude@anthropic.com>
parent eeab1af0
Loading
Loading
Loading
Loading
+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
        }
    }
}
+9 −0
Original line number Diff line number Diff line
@@ -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
    }()

@@ -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)
+7 −0
Original line number Diff line number Diff line
@@ -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 "[]" }
+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)
    }
}