The lists are native now, and the page is a button in the toolbar: it is still where signing in happens, and it is still the whole of settings, the admin page, the directory and OPML, which were never going to be rewritten. LibraryStore holds a page of items and asks for the next, because the sorting, filtering and searching are the server's work and ten thousand items have no business being in memory to be sorted here. Read and pinned are set locally and sent after, as the page's readWrites map does and for the same reason: a list asked for before the write lands answers with the old state, which put the dot back on an item just read. The interface follows the account's light or dark rather than the phone's, and defaults to dark when nobody has chosen, because that is what ipx's own theme.ts does. Following the system instead put a light list in front of a dark page. preferredColorScheme was not enough on its own -- inside a hosting controller it did not reach the hierarchy -- so the style is overridden on the controller, which also carries to the page presented over it. The sidebar had to be broken into sub-views: the whole list in one expression was more than the type checker would work through, and it said so rather than compiling it. Tested against the real library, 135 feeds and eleven thousand items, and the playback test now drives the native row rather than the page's button. Thirteen pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
5.5 KiB
Swift
153 lines
5.5 KiB
Swift
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.
|
|
enum Filter: String, CaseIterable {
|
|
case all, unread, downloaded, pinned
|
|
}
|
|
|
|
enum Sort: String {
|
|
case published, title, feed, kept, type, size
|
|
}
|
|
|
|
enum Direction: String {
|
|
case asc, desc
|
|
}
|
|
}
|