Files
ipodderx-app/ios/Sources/APIModels.swift
Ray Slakinski 01d8bd936e A native feed list and item list (#3)
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>
2026-09-20 09:27:03 -04:00

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
}
}