import Foundation /// What ipx sends, as Swift types. The names are the server's, so a field here can be checked /// against src/web.rs without translating first. enum IPX {} extension IPX { /// One subscribed feed, as GET /api/feeds sends it. struct Feed: Decodable, Identifiable, Hashable { let id: String let url: String let title: String? let image: String? /// The OPML subscription it came from, if any. Feeds in one are drawn as a folder. let group: String? /// In a group, but the OPML no longer lists it. Kept because it has downloads. let orphaned: Bool let folder: String? let category: String? let feedCategory: String? let lastChecked: Int? let nextCheck: Int? let lastError: String? let entries: Int let downloaded: Int let unread: Int /// Including you. More than one means every file here is shared. let subscribers: Int let pinned: Bool var name: String { title?.isEmpty == false ? title! : id } enum CodingKeys: String, CodingKey { case id, url, title, image, group, orphaned, folder, category, entries, downloaded, unread, subscribers, pinned case feedCategory = "feed_category" case lastChecked = "last_checked" case nextCheck = "next_check" case lastError = "last_error" } } /// A file on an entry. `path` is the only thing that says it is downloaded. struct Enclosure: Decodable, Identifiable, Hashable { let id: Int let feedId: String let guid: String let url: String let mime: String? let length: Int? let path: String? let state: String let lastError: String? var isDownloaded: Bool { path?.isEmpty == false } /// Whether it is worth handing to a player. Having a file is not the same as being /// playable: blog feeds put article images in enclosures. var isPlayable: Bool { guard isDownloaded else { return false } let m = (mime ?? "").lowercased() if m.hasPrefix("audio/") || m.hasPrefix("video/") { return true } if !m.isEmpty { return false } let name = (path ?? url).split(separator: "?").first.map(String.init) ?? "" let ext = (name as NSString).pathExtension.lowercased() return ["mp3","m4a","m4b","aac","ogg","oga","opus","flac","wav", "mp4","m4v","mov","webm","mkv"].contains(ext) } var isVideo: Bool { (mime ?? "").lowercased().hasPrefix("video/") } enum CodingKeys: String, CodingKey { case id, guid, url, mime, length, path, state case feedId = "feed_id" case lastError = "last_error" } } /// One item. Read, pinned and position are this person's; the entry itself is shared. struct Entry: Decodable, Identifiable, Hashable { let guid: String let feedId: String let title: String? let link: String? let published: Int? /// Feed-supplied HTML, already sanitized with ammonia server-side. let description: String? let read: Bool let flagged: Bool let image: String? let duration: Int? let episode: Int? let season: Int? let position: Int let enclosures: [Enclosure] /// Unique across feeds; guid alone is not. var id: String { feedId + "\n" + guid } /// Memberwise, so a row can be rebuilt with one flag moved. Declaring it keeps the /// synthesised one, which a decoded type does not otherwise expose. init(guid: String, feedId: String, title: String?, link: String?, published: Int?, description: String?, read: Bool, flagged: Bool, image: String?, duration: Int?, episode: Int?, season: Int?, position: Int, enclosures: [Enclosure]) { self.guid = guid; self.feedId = feedId; self.title = title; self.link = link self.published = published; self.description = description; self.read = read self.flagged = flagged; self.image = image; self.duration = duration self.episode = episode; self.season = season; self.position = position self.enclosures = enclosures } var playable: Enclosure? { enclosures.first(where: \.isPlayable) } enum CodingKeys: String, CodingKey { case guid, title, link, published, description, read, flagged, image, duration, episode, season, position, enclosures case feedId = "feed_id" } } /// A page of items. `total` is the whole result, not the page. struct EntryPage: Decodable { let total: Int let entries: [Entry] } struct Me: Decodable { let name: String let admin: Bool /// Where to send someone the proxy signed in; signing out of ipx alone cannot stick. let signOut: String? let theme: String? let mode: String? enum CodingKeys: String, CodingKey { case name, admin, theme, mode case signOut = "sign_out" } } /// Which items to ask for. The server does the work; these go on the query, and the raw /// values are what `Filter::parse` in db.rs understands -- anything it does not know falls /// through to All, so a wrong name here is a filter that quietly does nothing. enum Filter: String, CaseIterable { case all case unread case downloaded /// `flagged` on the wire: the column is named for what it was before the interface /// called it pinned. case pinned = "flagged" /// Started past the first few seconds and short of the 90% the UI calls finished. Not /// the same as unread: opening an item marks it read. case inProgress = "in_progress" /// The tabs, which are not every filter: Currently Listening is a place of its own. static var tabs: [Filter] { [.all, .unread, .downloaded, .pinned] } var label: String { switch self { case .all: return "All" case .unread: return "Unread" case .downloaded: return "Downloaded" case .pinned: return "Pinned" case .inProgress: return "Listening" } } } enum Sort: String { case published, title, feed, kept, type, size } enum Direction: String { case asc, desc } }