restructure
Some checks failed
CI - Multi-Platform Native / Build iOS (RSSuper) (push) Has been cancelled
CI - Multi-Platform Native / Build macOS (push) Has been cancelled
CI - Multi-Platform Native / Build Android (push) Has been cancelled
CI - Multi-Platform Native / Build Linux (push) Has been cancelled
CI - Multi-Platform Native / Build Summary (push) Has been cancelled

This commit is contained in:
2026-03-30 16:39:18 -04:00
parent a8e07d52f0
commit c2e1622bd8
252 changed files with 4803 additions and 17165 deletions

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,61 @@
//
// ContentView.swift
// RSSuper
//
// Created by Mike Freno on 3/29/26.
//
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
var body: some View {
NavigationSplitView {
List {
ForEach(items) { item in
NavigationLink {
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
} label: {
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
}
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
} detail: {
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(timestamp: Date())
modelContext.insert(newItem)
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(items[index])
}
}
}
}
#Preview {
ContentView()
.modelContainer(for: Item.self, inMemory: true)
}

View File

@@ -0,0 +1,764 @@
//
// DatabaseManager.swift
// RSSuper
//
// Created on 3/29/26.
//
import Foundation
import SQLite3
enum DatabaseError: LocalizedError {
case objectNotFound
case saveFailed(Error)
case fetchFailed(Error)
case migrationFailed(Error)
var errorDescription: String? {
switch self {
case .objectNotFound:
return "Object not found"
case .saveFailed(let error):
return "Failed to save: \(error.localizedDescription)"
case .fetchFailed(let error):
return "Failed to fetch: \(error.localizedDescription)"
case .migrationFailed(let error):
return "Migration failed: \(error.localizedDescription)"
}
}
}
final class DatabaseManager {
static let shared = DatabaseManager()
private var db: OpaquePointer?
private let fileManager = FileManager.default
private init() {
let dbPath = databasePath()
ensureDatabaseDirectoryExists()
if sqlite3_open(dbPath, &db) != SQLITE_OK {
fatalError("Failed to open database")
}
enableForeignKeys()
try? migrateDatabase()
}
deinit {
if db != nil {
sqlite3_close(db)
}
}
private func databasePath() -> String {
let urls = fileManager.urls(for: .documentDirectory, in: .userDomainMask)
let documentsDirectory = urls[0]
return documentsDirectory.appendingPathComponent("RSSuper.sqlite").path
}
private func ensureDatabaseDirectoryExists() {
let urls = fileManager.urls(for: .documentDirectory, in: .userDomainMask)
let documentsDirectory = urls[0]
try? fileManager.createDirectory(at: documentsDirectory, withIntermediateDirectories: true)
}
private func enableForeignKeys() {
var errorMessage: UnsafeMutablePointer<CChar>?
defer { sqlite3_free(errorMessage) }
sqlite3_exec(db, "PRAGMA foreign_keys = ON", nil, nil, &errorMessage)
}
private func migrateDatabase() throws {
createSubscriptionsTable()
createFeedItemsTable()
createSearchHistoryTable()
createFTSIndex()
createIndexes()
}
private func createSubscriptionsTable() {
let createTableSQL = """
CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
title TEXT NOT NULL,
category TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
fetch_interval INTEGER NOT NULL DEFAULT 3600,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_fetched_at TEXT,
next_fetch_at TEXT,
error TEXT,
http_auth BLOB
)
"""
var errorMessage: UnsafeMutablePointer<CChar>?
defer { sqlite3_free(errorMessage) }
if sqlite3_exec(db, createTableSQL, nil, nil, &errorMessage) != SQLITE_OK {
let error = String(cString: errorMessage!)
fatalError("Failed to create subscriptions table: \(error)")
}
let uniqueIndexSQL = "CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_url ON subscriptions(url)"
var error: UnsafeMutablePointer<CChar>?
defer { sqlite3_free(error) }
sqlite3_exec(db, uniqueIndexSQL, nil, nil, &error)
}
private func createFeedItemsTable() {
let createTableSQL = """
CREATE TABLE IF NOT EXISTS feed_items (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
link TEXT,
description TEXT,
content TEXT,
author TEXT,
published TEXT,
updated TEXT,
categories TEXT,
enclosure BLOB,
guid TEXT,
subscription_id TEXT NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
subscription_title TEXT,
read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
"""
var errorMessage: UnsafeMutablePointer<CChar>?
defer { sqlite3_free(errorMessage) }
if sqlite3_exec(db, createTableSQL, nil, nil, &errorMessage) != SQLITE_OK {
let error = String(cString: errorMessage!)
fatalError("Failed to create feed_items table: \(error)")
}
}
private func createSearchHistoryTable() {
let createTableSQL = """
CREATE TABLE IF NOT EXISTS search_history (
id TEXT PRIMARY KEY,
query TEXT NOT NULL,
timestamp TEXT NOT NULL
)
"""
var errorMessage: UnsafeMutablePointer<CChar>?
defer { sqlite3_free(errorMessage) }
if sqlite3_exec(db, createTableSQL, nil, nil, &errorMessage) != SQLITE_OK {
let error = String(cString: errorMessage!)
fatalError("Failed to create search_history table: \(error)")
}
}
private func createFTSIndex() {
let createFTSQL = """
CREATE VIRTUAL TABLE IF NOT EXISTS feed_items_fts USING fts5(
title,
description,
content,
author,
content='feed_items',
content_rowid='rowid'
)
"""
var errorMessage: UnsafeMutablePointer<CChar>?
defer { sqlite3_free(errorMessage) }
sqlite3_exec(db, createFTSQL, nil, nil, &errorMessage)
let triggerInsertSQL = """
CREATE TRIGGER IF NOT EXISTS feed_items_ai AFTER INSERT ON feed_items BEGIN
INSERT INTO feed_items_fts(rowid, title, description, content, author)
VALUES (new.rowid, new.title, new.description, new.content, new.author);
END
"""
var error: UnsafeMutablePointer<CChar>?
defer { sqlite3_free(error) }
sqlite3_exec(db, triggerInsertSQL, nil, nil, &error)
let triggerDeleteSQL = """
CREATE TRIGGER IF NOT EXISTS feed_items_ad AFTER DELETE ON feed_items BEGIN
DELETE FROM feed_items_fts WHERE rowid = old.rowid;
END
"""
defer { sqlite3_free(error) }
sqlite3_exec(db, triggerDeleteSQL, nil, nil, &error)
let triggerUpdateSQL = """
CREATE TRIGGER IF NOT EXISTS feed_items_au AFTER UPDATE ON feed_items BEGIN
DELETE FROM feed_items_fts WHERE rowid = old.rowid;
INSERT INTO feed_items_fts(rowid, title, description, content, author)
VALUES (new.rowid, new.title, new.description, new.content, new.author);
END
"""
defer { sqlite3_free(error) }
sqlite3_exec(db, triggerUpdateSQL, nil, nil, &error)
}
private func createIndexes() {
let indexes = [
"CREATE INDEX IF NOT EXISTS idx_feed_items_subscription_id ON feed_items(subscription_id)",
"CREATE INDEX IF NOT EXISTS idx_feed_items_published ON feed_items(published)",
"CREATE INDEX IF NOT EXISTS idx_feed_items_read ON feed_items(read)",
"CREATE INDEX IF NOT EXISTS idx_search_history_timestamp ON search_history(timestamp)"
]
for indexSQL in indexes {
var errorMessage: UnsafeMutablePointer<CChar>?
defer { sqlite3_free(errorMessage) }
sqlite3_exec(db, indexSQL, nil, nil, &errorMessage)
}
}
}
// MARK: - Subscription CRUD
extension DatabaseManager {
func createSubscription(id: String, url: String, title: String, category: String? = nil, enabled: Bool = true, fetchInterval: Int = 3600) throws -> FeedSubscription {
let now = ISO8601DateFormatter().string(from: Date())
let subscription = FeedSubscription(
id: id,
url: url,
title: title,
category: category,
enabled: enabled,
fetchInterval: fetchInterval,
createdAt: Date(),
updatedAt: Date(),
lastFetchedAt: nil,
nextFetchAt: nil,
error: nil,
httpAuth: nil
)
let insertSQL = """
INSERT INTO subscriptions (id, url, title, category, enabled, fetch_interval, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
"""
guard let statement = prepareStatement(sql: insertSQL) else {
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
}
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, (id as NSString).utf8String, -1, nil)
sqlite3_bind_text(statement, 2, (url as NSString).utf8String, -1, nil)
sqlite3_bind_text(statement, 3, (title as NSString).utf8String, -1, nil)
sqlite3_bind_text(statement, 4, (category as NSString?)?.utf8String, -1, nil)
sqlite3_bind_int(statement, 5, enabled ? 1 : 0)
sqlite3_bind_int(statement, 6, Int32(fetchInterval))
sqlite3_bind_text(statement, 7, (now as NSString).utf8String, -1, nil)
sqlite3_bind_text(statement, 8, (now as NSString).utf8String, -1, nil)
if sqlite3_step(statement) != SQLITE_DONE {
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
}
return subscription
}
func fetchSubscription(id: String) throws -> FeedSubscription? {
let selectSQL = "SELECT * FROM subscriptions WHERE id = ? LIMIT 1"
guard let statement = prepareStatement(sql: selectSQL) else {
return nil
}
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, (id as NSString).utf8String, -1, nil)
if sqlite3_step(statement) == SQLITE_ROW {
return rowToSubscription(statement)
}
return nil
}
func fetchAllSubscriptions() throws -> [FeedSubscription] {
let selectSQL = "SELECT * FROM subscriptions ORDER BY created_at DESC"
return executeQuery(sql: selectSQL, bindParams: [], rowMapper: rowToSubscription)
}
func fetchEnabledSubscriptions() throws -> [FeedSubscription] {
let selectSQL = "SELECT * FROM subscriptions WHERE enabled = 1 ORDER BY title"
return executeQuery(sql: selectSQL, bindParams: [], rowMapper: rowToSubscription)
}
func updateSubscription(_ subscription: FeedSubscription, title: String? = nil, category: String? = nil, enabled: Bool? = nil, fetchInterval: Int? = nil) throws -> FeedSubscription {
var updated = subscription
if let title = title { updated.title = title }
if let category = category { updated.category = category }
if let enabled = enabled { updated.enabled = enabled }
if let fetchInterval = fetchInterval { updated.fetchInterval = fetchInterval }
updated.updatedAt = Date()
let updateSQL = """
UPDATE subscriptions SET
title = ?, category = ?, enabled = ?, fetch_interval = ?, updated_at = ?
WHERE id = ?
"""
guard let statement = prepareStatement(sql: updateSQL) else {
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
}
defer { sqlite3_finalize(statement) }
let now = ISO8601DateFormatter().string(from: Date())
sqlite3_bind_text(statement, 1, (updated.title as NSString).utf8String, -1, nil)
sqlite3_bind_text(statement, 2, (updated.category as NSString?)?.utf8String, -1, nil)
sqlite3_bind_int(statement, 3, updated.enabled ? 1 : 0)
sqlite3_bind_int(statement, 4, Int32(updated.fetchInterval))
sqlite3_bind_text(statement, 5, (now as NSString).utf8String, -1, nil)
sqlite3_bind_text(statement, 6, (updated.id as NSString).utf8String, -1, nil)
if sqlite3_step(statement) != SQLITE_DONE {
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
}
return updated
}
func deleteSubscription(id: String) throws {
let deleteSQL = "DELETE FROM subscriptions WHERE id = ?"
guard let statement = prepareStatement(sql: deleteSQL) else {
return
}
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, (id as NSString).utf8String, -1, nil)
if sqlite3_step(statement) != SQLITE_DONE {
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
}
}
private func rowToSubscription(_ statement: OpaquePointer) -> FeedSubscription {
let id = String(cString: sqlite3_column_text(statement, 0))
let url = String(cString: sqlite3_column_text(statement, 1))
let title = String(cString: sqlite3_column_text(statement, 2))
let category = sqlite3_column_type(statement, 3) == SQLITE_NULL ? nil : String(cString: sqlite3_column_text(statement, 3))
let enabled = sqlite3_column_int(statement, 4) == 1
let fetchInterval = Int(sqlite3_column_int(statement, 5))
let createdAt = parseDate(String(cString: sqlite3_column_text(statement, 6)))
let updatedAt = parseDate(String(cString: sqlite3_column_text(statement, 7)))
let lastFetchedAt = sqlite3_column_type(statement, 8) == SQLITE_NULL ? nil : parseDate(String(cString: sqlite3_column_text(statement, 8)))
let nextFetchAt = sqlite3_column_type(statement, 9) == SQLITE_NULL ? nil : parseDate(String(cString: sqlite3_column_text(statement, 9)))
let error = sqlite3_column_type(statement, 10) == SQLITE_NULL ? nil : String(cString: sqlite3_column_text(statement, 10))
let httpAuthData = sqlite3_column_type(statement, 11) == SQLITE_NULL ? nil : Data(bytes: sqlite3_column_blob(statement, 11), count: Int(sqlite3_column_bytes(statement, 11)))
let httpAuth = httpAuthData.flatMap { try? JSONDecoder().decode(HttpAuth.self, from: $0) }
return FeedSubscription(
id: id,
url: url,
title: title,
category: category,
enabled: enabled,
fetchInterval: fetchInterval,
createdAt: createdAt,
updatedAt: updatedAt,
lastFetchedAt: lastFetchedAt,
nextFetchAt: nextFetchAt,
error: error,
httpAuth: httpAuth
)
}
}
// MARK: - FeedItem CRUD
extension DatabaseManager {
func createFeedItem(_ item: FeedItem) throws -> FeedItem {
let now = ISO8601DateFormatter().string(from: Date())
let insertSQL = """
INSERT INTO feed_items (id, title, link, description, content, author, published, updated, categories, enclosure, guid, subscription_id, subscription_title, read, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
guard let statement = prepareStatement(sql: insertSQL) else {
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
}
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, (item.id as NSString).utf8String, -1, nil)
sqlite3_bind_text(statement, 2, (item.title as NSString).utf8String, -1, nil)
sqlite3_bind_text(statement, 3, (item.link as NSString?)?.utf8String, -1, nil)
sqlite3_bind_text(statement, 4, (item.description as NSString?)?.utf8String, -1, nil)
sqlite3_bind_text(statement, 5, (item.content as NSString?)?.utf8String, -1, nil)
sqlite3_bind_text(statement, 6, (item.author as NSString?)?.utf8String, -1, nil)
if let published = item.published {
sqlite3_bind_text(statement, 7, (ISO8601DateFormatter().string(from: published) as NSString).utf8String, -1, nil)
} else {
sqlite3_bind_null(statement, 7)
}
if let updated = item.updated {
sqlite3_bind_text(statement, 8, (ISO8601DateFormatter().string(from: updated) as NSString).utf8String, -1, nil)
} else {
sqlite3_bind_null(statement, 8)
}
if let categories = item.categories, let data = try? JSONEncoder().encode(categories) {
sqlite3_bind_blob(statement, 9, (data as NSData).bytes, Int32(data.count), nil)
} else {
sqlite3_bind_null(statement, 9)
}
if let enclosure = item.enclosure, let data = try? JSONEncoder().encode(enclosure) {
sqlite3_bind_blob(statement, 10, (data as NSData).bytes, Int32(data.count), nil)
} else {
sqlite3_bind_null(statement, 10)
}
sqlite3_bind_text(statement, 11, (item.guid as NSString?)?.utf8String, -1, nil)
sqlite3_bind_text(statement, 12, (item.subscriptionId as NSString).utf8String, -1, nil)
sqlite3_bind_text(statement, 13, (item.subscriptionTitle as NSString?)?.utf8String, -1, nil)
sqlite3_bind_int(statement, 14, item.read ? 1 : 0)
sqlite3_bind_text(statement, 15, (now as NSString).utf8String, -1, nil)
if sqlite3_step(statement) != SQLITE_DONE {
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
}
return item
}
func fetchFeedItem(id: String) throws -> FeedItem? {
let selectSQL = "SELECT * FROM feed_items WHERE id = ? LIMIT 1"
guard let statement = prepareStatement(sql: selectSQL) else {
return nil
}
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, (id as NSString).utf8String, -1, nil)
if sqlite3_step(statement) == SQLITE_ROW {
return rowToFeedItem(statement)
}
return nil
}
func fetchFeedItems(for subscriptionId: String) throws -> [FeedItem] {
let selectSQL = "SELECT * FROM feed_items WHERE subscription_id = ? ORDER BY published DESC"
return executeQuery(sql: selectSQL, bindParams: [subscriptionId], rowMapper: rowToFeedItem)
}
func fetchFeedItems(limit: Int = 50) throws -> [FeedItem] {
let selectSQL = "SELECT * FROM feed_items ORDER BY published DESC LIMIT ?"
return executeQuery(sql: selectSQL, bindParams: [limit], rowMapper: rowToFeedItem)
}
func fetchUnreadFeedItems(limit: Int = 50) throws -> [FeedItem] {
let selectSQL = "SELECT * FROM feed_items WHERE read = 0 ORDER BY published DESC LIMIT ?"
return executeQuery(sql: selectSQL, bindParams: [limit], rowMapper: rowToFeedItem)
}
func updateFeedItem(_ item: FeedItem, read: Bool? = nil) throws -> FeedItem {
guard let read = read else { return item }
let updateSQL = "UPDATE feed_items SET read = ? WHERE id = ?"
guard let statement = prepareStatement(sql: updateSQL) else {
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
}
defer { sqlite3_finalize(statement) }
sqlite3_bind_int(statement, 1, read ? 1 : 0)
sqlite3_bind_text(statement, 2, (item.id as NSString).utf8String, -1, nil)
if sqlite3_step(statement) != SQLITE_DONE {
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
}
var updatedItem = item
updatedItem.read = read
return updatedItem
}
func markAsRead(ids: [String]) throws {
guard !ids.isEmpty else { return }
let placeholders = ids.map { _ in "?" }.joined(separator: ",")
let updateSQL = "UPDATE feed_items SET read = 1 WHERE id IN (\(placeholders))"
guard let statement = prepareStatement(sql: updateSQL) else {
return
}
defer { sqlite3_finalize(statement) }
for (index, id) in ids.enumerated() {
sqlite3_bind_text(statement, Int32(index + 1), (id as NSString).utf8String, -1, nil)
}
sqlite3_step(statement)
}
func deleteFeedItem(id: String) throws {
let deleteSQL = "DELETE FROM feed_items WHERE id = ?"
guard let statement = prepareStatement(sql: deleteSQL) else {
return
}
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, (id as NSString).utf8String, -1, nil)
sqlite3_step(statement)
}
func deleteFeedItems(for subscriptionId: String) throws {
let deleteSQL = "DELETE FROM feed_items WHERE subscription_id = ?"
guard let statement = prepareStatement(sql: deleteSQL) else {
return
}
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, (subscriptionId as NSString).utf8String, -1, nil)
sqlite3_step(statement)
}
private func rowToFeedItem(_ statement: OpaquePointer) -> FeedItem {
let id = String(cString: sqlite3_column_text(statement, 0))
let title = String(cString: sqlite3_column_text(statement, 1))
let link = sqlite3_column_type(statement, 2) == SQLITE_NULL ? nil : String(cString: sqlite3_column_text(statement, 2))
let description = sqlite3_column_type(statement, 3) == SQLITE_NULL ? nil : String(cString: sqlite3_column_text(statement, 3))
let content = sqlite3_column_type(statement, 4) == SQLITE_NULL ? nil : String(cString: sqlite3_column_text(statement, 4))
let author = sqlite3_column_type(statement, 5) == SQLITE_NULL ? nil : String(cString: sqlite3_column_text(statement, 5))
let published = sqlite3_column_type(statement, 6) == SQLITE_NULL ? nil : parseDate(String(cString: sqlite3_column_text(statement, 6)))
let updated = sqlite3_column_type(statement, 7) == SQLITE_NULL ? nil : parseDate(String(cString: sqlite3_column_text(statement, 7)))
let categoriesData = sqlite3_column_type(statement, 8) == SQLITE_NULL ? nil : Data(bytes: sqlite3_column_blob(statement, 8), count: Int(sqlite3_column_bytes(statement, 8)))
let categories = categoriesData.flatMap { try? JSONDecoder().decode([String].self, from: $0) }
let enclosureData = sqlite3_column_type(statement, 9) == SQLITE_NULL ? nil : Data(bytes: sqlite3_column_blob(statement, 9), count: Int(sqlite3_column_bytes(statement, 9)))
let enclosure = enclosureData.flatMap { try? JSONDecoder().decode(Enclosure.self, from: $0) }
let guid = sqlite3_column_type(statement, 10) == SQLITE_NULL ? nil : String(cString: sqlite3_column_text(statement, 10))
let subscriptionId = String(cString: sqlite3_column_text(statement, 11))
let subscriptionTitle = sqlite3_column_type(statement, 12) == SQLITE_NULL ? nil : String(cString: sqlite3_column_text(statement, 12))
let read = sqlite3_column_int(statement, 13) == 1
return FeedItem(
id: id,
title: title,
link: link,
description: description,
content: content,
author: author,
published: published,
updated: updated,
categories: categories,
enclosure: enclosure,
guid: guid,
subscriptionId: subscriptionId,
subscriptionTitle: subscriptionTitle,
read: read
)
}
}
// MARK: - SearchHistory CRUD
extension DatabaseManager {
func addToSearchHistory(query: String) throws -> SearchHistoryItem {
let id = UUID().uuidString
let timestamp = ISO8601DateFormatter().string(from: Date())
let item = SearchHistoryItem(id: id, query: query, timestamp: Date())
let insertSQL = "INSERT INTO search_history (id, query, timestamp) VALUES (?, ?, ?)"
guard let statement = prepareStatement(sql: insertSQL) else {
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
}
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, (id as NSString).utf8String, -1, nil)
sqlite3_bind_text(statement, 2, (query as NSString).utf8String, -1, nil)
sqlite3_bind_text(statement, 3, (timestamp as NSString).utf8String, -1, nil)
if sqlite3_step(statement) != SQLITE_DONE {
throw DatabaseError.saveFailed(DatabaseError.objectNotFound)
}
return item
}
func fetchSearchHistory(limit: Int = 20) throws -> [SearchHistoryItem] {
let selectSQL = "SELECT * FROM search_history ORDER BY timestamp DESC LIMIT ?"
return executeQuery(sql: selectSQL, bindParams: [limit], rowMapper: rowToSearchHistoryItem)
}
func clearSearchHistory() throws {
let deleteSQL = "DELETE FROM search_history"
var errorMessage: UnsafeMutablePointer<CChar>?
defer { sqlite3_free(errorMessage) }
sqlite3_exec(db, deleteSQL, nil, nil, &errorMessage)
}
func deleteSearchHistoryItem(id: String) throws {
let deleteSQL = "DELETE FROM search_history WHERE id = ?"
guard let statement = prepareStatement(sql: deleteSQL) else {
return
}
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, (id as NSString).utf8String, -1, nil)
sqlite3_step(statement)
}
private func rowToSearchHistoryItem(_ statement: OpaquePointer) -> SearchHistoryItem {
let id = String(cString: sqlite3_column_text(statement, 0))
let query = String(cString: sqlite3_column_text(statement, 1))
let timestamp = parseDate(String(cString: sqlite3_column_text(statement, 2)))
return SearchHistoryItem(id: id, query: query, timestamp: timestamp)
}
}
// MARK: - FTS Search
extension DatabaseManager {
func fullTextSearch(query: String, limit: Int = 50) throws -> [FeedItem] {
let selectSQL = """
SELECT * FROM feed_items
WHERE title LIKE ? OR description LIKE ? OR content LIKE ? OR author LIKE ?
ORDER BY published DESC
LIMIT ?
"""
let pattern = "%\(query)%"
return executeQuery(sql: selectSQL, bindParams: [pattern, pattern, pattern, pattern, limit], rowMapper: rowToFeedItem)
}
func advancedSearch(title: String? = nil, author: String? = nil, subscriptionId: String? = nil, startDate: Date? = nil, endDate: Date? = nil, limit: Int = 50) throws -> [FeedItem] {
var conditions: [String] = []
var parameters: [String] = []
if let title = title {
conditions.append("title LIKE ?")
parameters.append("%\(title)%")
}
if let author = author {
conditions.append("author LIKE ?")
parameters.append("%\(author)%")
}
if let subscriptionId = subscriptionId {
conditions.append("subscription_id = ?")
parameters.append(subscriptionId)
}
if let startDate = startDate {
conditions.append("published >= ?")
parameters.append(ISO8601DateFormatter().string(from: startDate))
}
if let endDate = endDate {
conditions.append("published <= ?")
parameters.append(ISO8601DateFormatter().string(from: endDate))
}
var sql = "SELECT * FROM feed_items"
if !conditions.isEmpty {
sql += " WHERE " + conditions.joined(separator: " AND ")
}
sql += " ORDER BY published DESC LIMIT ?"
parameters.append(String(limit))
return executeQuery(sql: sql, bindParams: parameters, rowMapper: rowToFeedItem)
}
}
// MARK: - Batch Operations
extension DatabaseManager {
func markAllAsRead(for subscriptionId: String) throws {
let updateSQL = "UPDATE feed_items SET read = 1 WHERE subscription_id = ?"
guard let statement = prepareStatement(sql: updateSQL) else {
return
}
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, (subscriptionId as NSString).utf8String, -1, nil)
sqlite3_step(statement)
}
func cleanupOldItems(olderThan days: Int, for subscriptionId: String? = nil) throws -> Int {
let cutoffDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
let cutoffString = ISO8601DateFormatter().string(from: cutoffDate)
var sql = "DELETE FROM feed_items WHERE published < ?"
var params: [String] = [cutoffString]
if let subscriptionId = subscriptionId {
sql += " AND subscription_id = ?"
params.append(subscriptionId)
}
guard let statement = prepareStatement(sql: sql) else {
return 0
}
defer { sqlite3_finalize(statement) }
for (index, param) in params.enumerated() {
sqlite3_bind_text(statement, Int32(index + 1), (param as NSString).utf8String, -1, nil)
}
sqlite3_step(statement)
return Int(sqlite3_changes(db))
}
}
// MARK: - Helper Methods
extension DatabaseManager {
private func prepareStatement(sql: String) -> OpaquePointer? {
var statement: OpaquePointer?
if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK {
return statement
}
return nil
}
private func executeQuery<T>(sql: String, bindParams: [Any], rowMapper: (OpaquePointer) -> T) -> [T] {
var results: [T] = []
guard let statement = prepareStatement(sql: sql) else {
return results
}
defer { sqlite3_finalize(statement) }
for (index, param) in bindParams.enumerated() {
let paramIndex = Int32(index + 1)
if let stringParam = param as? String {
sqlite3_bind_text(statement, paramIndex, (stringParam as NSString).utf8String, -1, nil)
} else if let intParam = param as? Int {
sqlite3_bind_int(statement, paramIndex, Int32(intParam))
}
}
while sqlite3_step(statement) == SQLITE_ROW {
results.append(rowMapper(statement))
}
return results
}
private func parseDate(_ string: String) -> Date {
let formatter = ISO8601DateFormatter()
return formatter.date(from: string) ?? Date()
}
}

18
iOS/RSSuper/Item.swift Normal file
View File

@@ -0,0 +1,18 @@
//
// Item.swift
// RSSuper
//
// Created by Mike Freno on 3/29/26.
//
import Foundation
import SwiftData
@Model
final class Item {
var timestamp: Date
init(timestamp: Date) {
self.timestamp = timestamp
}
}

View File

@@ -0,0 +1,66 @@
//
// ContentType.swift
// RSSuper
//
// Created by Mike Freno on 3/29/26.
//
import Foundation
enum ContentType: String, Codable, CaseIterable {
case article
case audio
case video
var localizedDescription: String {
switch self {
case .article: return "Article"
case .audio: return "Audio"
case .video: return "Video"
}
}
}
enum Theme: String, Codable, CaseIterable {
case system
case light
case dark
var localizedDescription: String {
switch self {
case .system: return "System"
case .light: return "Light"
case .dark: return "Dark"
}
}
}
enum Privacy: String, Codable, CaseIterable {
case `public`
case `private`
case anonymous
var localizedDescription: String {
switch self {
case .public: return "Public"
case .private: return "Private"
case .anonymous: return "Anonymous"
}
}
}
enum NotificationType: String, Codable, CaseIterable {
case newArticle = "NEW_ARTICLE"
case episodeRelease = "EPISODE_RELEASE"
case customAlert = "CUSTOM_ALERT"
case upgradePromo = "UPGRADE_PROMO"
var localizedDescription: String {
switch self {
case .newArticle: return "New Article"
case .episodeRelease: return "Episode Release"
case .customAlert: return "Custom Alert"
case .upgradePromo: return "Upgrade Promo"
}
}
}

View File

@@ -0,0 +1,48 @@
//
// Date+Extensions.swift
// RSSuper
//
// Created by Mike Freno on 3/29/26.
//
import Foundation
extension Date {
/// Creates a Date from Unix timestamp in milliseconds
init?(millisecondsSince1970: Int64) {
let seconds = TimeInterval(millisecondsSince1970) / 1000.0
self.init(timeIntervalSince1970: seconds)
}
/// Returns the Unix timestamp in milliseconds
var millisecondsSince1970: Int64 {
Int64((timeIntervalSince1970 * 1000).rounded())
}
/// Creates a Date from Unix timestamp in seconds
init?(secondsSince1970: Int64) {
self.init(timeIntervalSince1970: TimeInterval(secondsSince1970))
}
/// Returns the Unix timestamp in seconds
var secondsSince1970: Int64 {
Int64(timeIntervalSince1970.rounded())
}
/// ISO8601 formatted string
var iso8601String: String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: self)
}
/// Creates a Date from ISO8601 string
init?(iso8601String: String) {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let date = formatter.date(from: iso8601String) else {
return nil
}
self = date
}
}

View File

@@ -0,0 +1,85 @@
//
// Feed.swift
// RSSuper
//
// Created by Mike Freno on 3/29/26.
//
import Foundation
struct Feed: Identifiable, Codable, Equatable {
let id: String
let title: String
var link: String?
var description: String?
var subtitle: String?
var language: String?
var lastBuildDate: Date?
var updated: Date?
var generator: String?
var ttl: Int?
var items: [FeedItem]
var rawUrl: String
var lastFetchedAt: Date?
var nextFetchAt: Date?
enum CodingKeys: String, CodingKey {
case id
case title
case link
case description
case subtitle
case language
case lastBuildDate = "last_build_date"
case updated
case generator
case ttl
case items
case rawUrl = "raw_url"
case lastFetchedAt = "last_fetched_at"
case nextFetchAt = "next_fetch_at"
}
init(
id: String,
title: String,
link: String? = nil,
description: String? = nil,
subtitle: String? = nil,
language: String? = nil,
lastBuildDate: Date? = nil,
updated: Date? = nil,
generator: String? = nil,
ttl: Int? = nil,
items: [FeedItem] = [],
rawUrl: String,
lastFetchedAt: Date? = nil,
nextFetchAt: Date? = nil
) {
self.id = id
self.title = title
self.link = link
self.description = description
self.subtitle = subtitle
self.language = language
self.lastBuildDate = lastBuildDate
self.updated = updated
self.generator = generator
self.ttl = ttl
self.items = items
self.rawUrl = rawUrl
self.lastFetchedAt = lastFetchedAt
self.nextFetchAt = nextFetchAt
}
var debugDescription: String {
"""
Feed(
id: \(id),
title: \(title),
items: \(items.count),
lastFetched: \(lastFetchedAt?.formatted(date: .abbreviated, time: .shortened) ?? "N/A")
)
"""
}
}

View File

@@ -0,0 +1,109 @@
//
// FeedItem.swift
// RSSuper
//
// Created by Mike Freno on 3/29/26.
//
import Foundation
struct FeedItem: Identifiable, Codable, Equatable {
let id: String
let title: String
var link: String?
var description: String?
var content: String?
var author: String?
var published: Date?
var updated: Date?
var categories: [String]?
var enclosure: Enclosure?
var guid: String?
var subscriptionId: String
var subscriptionTitle: String?
var read: Bool = false
enum CodingKeys: String, CodingKey {
case id
case title
case link
case description
case content
case author
case published
case updated
case categories
case enclosure
case guid
case subscriptionId = "subscription_id"
case subscriptionTitle = "subscription_title"
case read
}
init(
id: String,
title: String,
link: String? = nil,
description: String? = nil,
content: String? = nil,
author: String? = nil,
published: Date? = nil,
updated: Date? = nil,
categories: [String]? = nil,
enclosure: Enclosure? = nil,
guid: String? = nil,
subscriptionId: String,
subscriptionTitle: String? = nil,
read: Bool = false
) {
self.id = id
self.title = title
self.link = link
self.description = description
self.content = content
self.author = author
self.published = published
self.updated = updated
self.categories = categories
self.enclosure = enclosure
self.guid = guid
self.subscriptionId = subscriptionId
self.subscriptionTitle = subscriptionTitle
self.read = read
}
var debugDescription: String {
"""
FeedItem(
id: \(id),
title: \(title),
author: \(author ?? "N/A"),
published: \(published?.formatted(date: .abbreviated, time: .shortened) ?? "N/A"),
subscription: \(subscriptionTitle ?? "N/A")
)
"""
}
}
struct Enclosure: Codable, Equatable {
let url: String
let type: String
var length: Int64?
var mimeType: ContentType {
if type.contains("audio") {
return .audio
} else if type.contains("video") {
return .video
} else {
return .article
}
}
var fileSizeDescription: String {
guard let length = length else { return "Unknown" }
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: length)
}
}

View File

@@ -0,0 +1,89 @@
//
// FeedSubscription.swift
// RSSuper
//
// Created by Mike Freno on 3/29/26.
//
import Foundation
struct FeedSubscription: Identifiable, Codable, Equatable {
let id: String
let url: String
var title: String
var category: String?
var enabled: Bool
var fetchInterval: Int
let createdAt: Date
var updatedAt: Date
var lastFetchedAt: Date?
var nextFetchAt: Date?
var error: String?
var httpAuth: HttpAuth?
enum CodingKeys: String, CodingKey {
case id
case url
case title
case category
case enabled
case fetchInterval = "fetch_interval"
case createdAt = "created_at"
case updatedAt = "updated_at"
case lastFetchedAt = "last_fetched_at"
case nextFetchAt = "next_fetch_at"
case error
case httpAuth = "http_auth"
}
init(
id: String,
url: String,
title: String,
category: String? = nil,
enabled: Bool = true,
fetchInterval: Int = 60,
createdAt: Date = Date(),
updatedAt: Date = Date(),
lastFetchedAt: Date? = nil,
nextFetchAt: Date? = nil,
error: String? = nil,
httpAuth: HttpAuth? = nil
) {
self.id = id
self.url = url
self.title = title
self.category = category
self.enabled = enabled
self.fetchInterval = fetchInterval
self.createdAt = createdAt
self.updatedAt = updatedAt
self.lastFetchedAt = lastFetchedAt
self.nextFetchAt = nextFetchAt
self.error = error
self.httpAuth = httpAuth
}
var debugDescription: String {
"""
FeedSubscription(
id: \(id),
title: \(title),
url: \(url),
enabled: \(enabled),
fetchInterval: \(fetchInterval)min,
error: \(error ?? "None")
)
"""
}
}
struct HttpAuth: Codable, Equatable {
let username: String
let password: String
init(username: String, password: String) {
self.username = username
self.password = password
}
}

View File

@@ -0,0 +1,54 @@
//
// NotificationPreferences.swift
// RSSuper
//
// Created by Mike Freno on 3/29/26.
//
import Foundation
struct NotificationPreferences: Codable, Equatable {
var newArticles: Bool
var episodeReleases: Bool
var customAlerts: Bool
var badgeCount: Bool
var sound: Bool
var vibration: Bool
init(
newArticles: Bool = true,
episodeReleases: Bool = true,
customAlerts: Bool = false,
badgeCount: Bool = true,
sound: Bool = true,
vibration: Bool = true
) {
self.newArticles = newArticles
self.episodeReleases = episodeReleases
self.customAlerts = customAlerts
self.badgeCount = badgeCount
self.sound = sound
self.vibration = vibration
}
var allEnabled: Bool {
newArticles && episodeReleases && customAlerts && badgeCount && sound && vibration
}
var anyEnabled: Bool {
newArticles || episodeReleases || customAlerts || badgeCount || sound || vibration
}
var debugDescription: String {
"""
NotificationPreferences(
newArticles: \(newArticles),
episodeReleases: \(episodeReleases),
customAlerts: \(customAlerts),
badgeCount: \(badgeCount),
sound: \(sound),
vibration: \(vibration)
)
"""
}
}

View File

@@ -0,0 +1,76 @@
//
// ReadingPreferences.swift
// RSSuper
//
// Created by Mike Freno on 3/29/26.
//
import Foundation
struct ReadingPreferences: Codable, Equatable {
var fontSize: FontSize
var lineHeight: LineHeight
var showTableOfContents: Bool
var showReadingTime: Bool
var showAuthor: Bool
var showDate: Bool
enum FontSize: String, Codable, CaseIterable {
case small
case medium
case large
case xlarge
var pointValue: CGFloat {
switch self {
case .small: return 14
case .medium: return 16
case .large: return 18
case .xlarge: return 20
}
}
}
enum LineHeight: String, Codable, CaseIterable {
case normal
case relaxed
case loose
var multiplier: CGFloat {
switch self {
case .normal: return 1.2
case .relaxed: return 1.5
case .loose: return 1.8
}
}
}
init(
fontSize: FontSize = .medium,
lineHeight: LineHeight = .relaxed,
showTableOfContents: Bool = false,
showReadingTime: Bool = true,
showAuthor: Bool = true,
showDate: Bool = true
) {
self.fontSize = fontSize
self.lineHeight = lineHeight
self.showTableOfContents = showTableOfContents
self.showReadingTime = showReadingTime
self.showAuthor = showAuthor
self.showDate = showDate
}
var debugDescription: String {
"""
ReadingPreferences(
fontSize: \(fontSize.rawValue),
lineHeight: \(lineHeight.rawValue),
showTableOfContents: \(showTableOfContents),
showReadingTime: \(showReadingTime),
showAuthor: \(showAuthor),
showDate: \(showDate)
)
"""
}
}

View File

@@ -0,0 +1,72 @@
//
// SearchFilters.swift
// RSSuper
//
// Created by Mike Freno on 3/29/26.
//
import Foundation
struct SearchFilters: Codable, Equatable {
var dateFrom: Date?
var dateTo: Date?
var feedIds: [String]?
var authors: [String]?
var contentType: ContentType?
enum CodingKeys: String, CodingKey {
case dateFrom = "date_from"
case dateTo = "date_to"
case feedIds = "feed_ids"
case authors
case contentType = "content_type"
}
init(
dateFrom: Date? = nil,
dateTo: Date? = nil,
feedIds: [String]? = nil,
authors: [String]? = nil,
contentType: ContentType? = nil
) {
self.dateFrom = dateFrom
self.dateTo = dateTo
self.feedIds = feedIds
self.authors = authors
self.contentType = contentType
}
var debugDescription: String {
"""
SearchFilters(
dateFrom: \(dateFrom?.formatted(date: .abbreviated, time: .shortened) ?? "N/A"),
dateTo: \(dateTo?.formatted(date: .abbreviated, time: .shortened) ?? "N/A"),
feedIds: \(feedIds.map { "\($0.count) feeds" } ?? "All"),
authors: \(authors.map { "\($0.count) authors" } ?? "All"),
contentType: \(contentType?.localizedDescription ?? "All")
)
"""
}
}
enum SearchSortOption: String, Codable, CaseIterable {
case relevance = "relevance"
case dateDesc = "date_desc"
case dateAsc = "date_asc"
case titleAsc = "title_asc"
case titleDesc = "title_desc"
case feedAsc = "feed_asc"
case feedDesc = "feed_desc"
var localizedDescription: String {
switch self {
case .relevance: return "Relevance"
case .dateDesc: return "Date (Newest First)"
case .dateAsc: return "Date (Oldest First)"
case .titleAsc: return "Title (A-Z)"
case .titleDesc: return "Title (Z-A)"
case .feedAsc: return "Feed (A-Z)"
case .feedDesc: return "Feed (Z-A)"
}
}
}

View File

@@ -0,0 +1,30 @@
//
// SearchHistoryItem.swift
// RSSuper
//
// Created by Mike Freno on 3/29/26.
//
import Foundation
struct SearchHistoryItem: Identifiable, Codable, Equatable {
let id: String
let query: String
let timestamp: Date
init(id: String, query: String, timestamp: Date = Date()) {
self.id = id
self.query = query
self.timestamp = timestamp
}
var debugDescription: String {
"""
SearchHistoryItem(
id: \(id),
query: \(query),
timestamp: \(timestamp.formatted(date: .abbreviated, time: .shortened))
)
"""
}
}

View File

@@ -0,0 +1,73 @@
//
// SearchResult.swift
// RSSuper
//
// Created by Mike Freno on 3/29/26.
//
import Foundation
struct SearchResult: Identifiable, Codable, Equatable {
let id: String
let type: SearchResultType
let title: String
var snippet: String?
var link: String?
var feedTitle: String?
var published: Date?
var score: Double?
enum CodingKeys: String, CodingKey {
case id
case type
case title
case snippet
case link
case feedTitle = "feed_title"
case published
case score
}
init(
id: String,
type: SearchResultType,
title: String,
snippet: String? = nil,
link: String? = nil,
feedTitle: String? = nil,
published: Date? = nil,
score: Double? = nil
) {
self.id = id
self.type = type
self.title = title
self.snippet = snippet
self.link = link
self.feedTitle = feedTitle
self.published = published
self.score = score
}
var debugDescription: String {
"""
SearchResult(
id: \(id),
type: \(type.localizedDescription),
title: \(title),
score: \(score.map { String($0) } ?? "N/A")
)
"""
}
}
enum SearchResultType: String, Codable, CaseIterable {
case article
case feed
var localizedDescription: String {
switch self {
case .article: return "Article"
case .feed: return "Feed"
}
}
}

View File

@@ -0,0 +1,15 @@
import Foundation
struct FetchResult {
let feedData: Data
let headers: [String: String]
let url: URL
let cached: Bool
init(feedData: Data, headers: [String: String], url: URL, cached: Bool = false) {
self.feedData = feedData
self.headers = headers
self.url = url
self.cached = cached
}
}

View File

@@ -0,0 +1,172 @@
import Foundation
final class FeedFetcher {
private let session: URLSession
private let cacheManager = CacheManager.shared
private let timeout: TimeInterval = 15.0
private let maxRetries = 3
private let maxBackoffDelay: TimeInterval = 60.0
nonisolated init(session: URLSession = URLSession(configuration: .default)) {
self.session = session
}
func fetchFeed(url: URL, credentials: HTTPAuthCredentials? = nil) async throws -> FetchResult {
if let cached = cacheManager.getCachedResult(for: url), !isCacheExpired(for: url) {
return cached
}
return try await withRetries { retryNumber in
try await self.fetchFeedInternal(url: url, credentials: credentials, retryNumber: retryNumber)
}
}
private func fetchFeedInternal(url: URL, credentials: HTTPAuthCredentials?, retryNumber: Int) async throws -> FetchResult {
var request = URLRequest(url: url)
request.timeoutInterval = timeout
if let credentials {
request.setValue(credentials.authorizationHeader(), forHTTPHeaderField: "Authorization")
}
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.noData
}
switch httpResponse.statusCode {
case 200...299:
let headers = httpResponse.allHeaderFields.reduce(into: [String: String]()) { result, pair in
if let key = pair.key as? String, let value = pair.value as? String {
result[key] = value
}
}
let result = FetchResult(
feedData: data,
headers: headers,
url: url,
cached: false
)
if shouldCacheResponse(httpResponse: httpResponse) {
cacheManager.cacheResult(result, for: url)
}
return result
case 401:
throw NetworkError.authenticationError
case 404:
throw NetworkError.httpError(statusCode: 404)
case 408:
throw NetworkError.timeout
case 500...599:
throw NetworkError.httpError(statusCode: httpResponse.statusCode)
default:
throw NetworkError.httpError(statusCode: httpResponse.statusCode)
}
}
private func withRetries<T>(_ operation: @escaping (Int) async throws -> T) async throws -> T {
var lastError: Error?
for attempt in 0...maxRetries {
if attempt > 0 {
let delay = calculateBackoffDelay(attempt: attempt)
try await Task.sleep(for: .seconds(delay))
}
do {
return try await operation(attempt)
} catch {
lastError = error
let networkError = error as? NetworkError
if !shouldRetry(error: networkError) {
throw error
}
}
}
throw lastError ?? NetworkError.noData
}
private func shouldRetry(error: NetworkError?) -> Bool {
guard let error else { return false }
switch error {
case .timeout, .httpError(_), .noData:
return true
case .invalidURL, .decodingError, .authenticationError, .cacheError:
return false
}
}
private func calculateBackoffDelay(attempt: Int) -> TimeInterval {
let baseDelay: TimeInterval = 0.5
let delay = baseDelay * pow(2, Double(attempt))
return min(delay, maxBackoffDelay)
}
private func isCacheExpired(for url: URL) -> Bool {
guard let cached = cacheManager.getCachedResult(for: url) else { return true }
if let cacheControl = cached.headers["Cache-Control"] ?? cached.headers["cache-control"] {
if cacheControl.contains("no-store") {
return true
}
if cacheControl.contains("no-cache") {
return true
}
if let maxAge = extractMaxAge(from: cacheControl) {
let age = Date().timeIntervalSince(cached.url.lastPathComponent.isEmpty ? Date() : Date())
return age > maxAge
}
}
if let lastModified = cached.headers["Last-Modified"] ?? cached.headers["last-modified"],
let cacheUntil = parseHTTPDate(lastModified) {
return Date() > cacheUntil
}
return false
}
private func shouldCacheResponse(httpResponse: HTTPURLResponse) -> Bool {
guard let cacheControl = httpResponse.allHeaderFields["Cache-Control"] as? String else {
return httpResponse.statusCode == 200
}
if cacheControl.contains("no-store") {
return false
}
return true
}
private func extractMaxAge(from cacheControl: String) -> TimeInterval? {
let parts = cacheControl.split(separator: ",")
for part in parts {
let trimmed = part.trimmingCharacters(in: .whitespaces)
if trimmed.starts(with: "max-age=") {
let valueString = trimmed.dropFirst(8)
return Double(String(valueString))
}
}
return nil
}
private func parseHTTPDate(_ dateString: String) -> Date? {
let formatter = HTTPDateFormatter.formatter
return formatter.date(from: dateString)
}
}
private class HTTPDateFormatter {
static let formatter: DateFormatter = {
let df = DateFormatter()
df.locale = Locale(identifier: "en_US_POSIX")
df.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
return df
}()
}

View File

@@ -0,0 +1,37 @@
import Foundation
final class CacheManager {
static let shared = CacheManager()
private var cache = [String: FetchResult]()
private let lock = NSLock()
private init() {}
func getCachedResult(for url: URL) -> FetchResult? {
let key = url.absoluteString
lock.lock()
defer { lock.unlock() }
return cache[key]
}
func cacheResult(_ result: FetchResult, for url: URL) {
let key = url.absoluteString
lock.lock()
defer { lock.unlock() }
cache[key] = result
}
func removeCachedResult(for url: URL) {
let key = url.absoluteString
lock.lock()
defer { lock.unlock() }
cache.removeValue(forKey: key)
}
func clearAll() {
lock.lock()
defer { lock.unlock() }
cache.removeAll()
}
}

View File

@@ -0,0 +1,20 @@
import Foundation
nonisolated(unsafe) struct HTTPAuthCredentials {
let username: String
let password: String
nonisolated init(username: String, password: String) {
self.username = username
self.password = password
}
nonisolated func authorizationHeader() -> String {
let credentials = "\(username):\(password)"
guard let data = credentials.data(using: .utf8) else {
return ""
}
let base64 = data.base64EncodedString()
return "Basic \(base64)"
}
}

View File

@@ -0,0 +1,32 @@
import Foundation
enum NetworkError: Error {
case invalidURL
case timeout
case httpError(statusCode: Int)
case noData
case decodingError(Error)
case authenticationError
case cacheError(Error)
}
extension NetworkError: LocalizedError {
var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid URL"
case .timeout:
return "Request timeout"
case .httpError(let statusCode):
return "HTTP error: \(statusCode)"
case .noData:
return "No data received"
case .decodingError(let error):
return "Decoding error: \(error.localizedDescription)"
case .authenticationError:
return "Authentication failed"
case .cacheError(let error):
return "Cache error: \(error.localizedDescription)"
}
}
}

View File

@@ -0,0 +1,100 @@
import Foundation
final class AtomParser {
nonisolated init() {}
nonisolated func parse(xml: String, sourceURL: String) throws -> Feed {
let lowered = xml.lowercased()
guard lowered.contains("<feed"), lowered.contains("</feed>") else {
throw FeedParsingError.malformedXML
}
guard let feedBlock = xmlFirstBlock("feed", in: xml) else {
throw FeedParsingError.malformedXML
}
let entryBlocks = xmlAllBlocks("entry", in: feedBlock)
let feedWithoutEntries = feedBlock.replacingOccurrences(
of: "(?is)<(?:\\w+:)?entry\\b[^>]*>.*?</(?:\\w+:)?entry>",
with: "",
options: .regularExpression
)
let feedTitle = xmlFirstTagValue("title", in: feedWithoutEntries)?.xmlNilIfEmpty ?? sourceURL
let feedLink = selectAlternateLink(in: feedWithoutEntries)
let items = entryBlocks.map { parseEntry(xml: $0, subscriptionId: sourceURL, subscriptionTitle: feedTitle) }
return Feed(
id: sourceURL,
title: feedTitle,
link: feedLink,
description: xmlFirstTagValue("summary", in: feedWithoutEntries)?.xmlNilIfEmpty,
subtitle: xmlFirstTagValue("subtitle", in: feedWithoutEntries)?.xmlNilIfEmpty,
updated: XMLDateParser.parse(xmlFirstTagValue("updated", in: feedWithoutEntries)),
generator: xmlFirstTagValue("generator", in: feedWithoutEntries)?.xmlNilIfEmpty,
items: items,
rawUrl: sourceURL
)
}
nonisolated private func parseEntry(xml: String, subscriptionId: String, subscriptionTitle: String) -> FeedItem {
let title = xmlFirstTagValue("title", in: xml)?.xmlNilIfEmpty
let link = selectAlternateLink(in: xml)
let guid = xmlFirstTagValue("id", in: xml)?.xmlNilIfEmpty
let resolvedId = guid ?? link ?? UUID().uuidString
return FeedItem(
id: resolvedId,
title: title ?? link ?? "(Untitled)",
link: link,
description: xmlFirstTagValue("summary", in: xml)?.xmlNilIfEmpty,
content: xmlFirstTagValue("content", in: xml)?.xmlNilIfEmpty,
author: xmlFirstTagValue("name", in: xml)?.xmlNilIfEmpty
?? xmlFirstTagValue("author", in: xml)?.xmlNilIfEmpty,
published: XMLDateParser.parse(xmlFirstTagValue("published", in: xml)),
updated: XMLDateParser.parse(xmlFirstTagValue("updated", in: xml)),
categories: parseCategories(in: xml),
enclosure: parseEnclosure(in: xml),
guid: guid,
subscriptionId: subscriptionId,
subscriptionTitle: subscriptionTitle
)
}
nonisolated private func parseCategories(in xml: String) -> [String]? {
var values = xmlAllTagValues("category", in: xml)
let attributes = xmlAllTagAttributes("category", in: xml)
for attribute in attributes {
if let term = attribute["term"]?.xmlNilIfEmpty {
values.append(term)
}
}
return values.isEmpty ? nil : values
}
nonisolated private func parseEnclosure(in xml: String) -> Enclosure? {
let links = xmlAllTagAttributes("link", in: xml)
guard let enclosure = links.first(where: { $0["rel"]?.lowercased() == "enclosure" }) else {
return nil
}
guard let href = enclosure["href"]?.xmlNilIfEmpty else { return nil }
let type = enclosure["type"]?.xmlNilIfEmpty ?? "application/octet-stream"
return Enclosure(url: href, type: type, length: xmlInt64(enclosure["length"]))
}
nonisolated private func selectAlternateLink(in xml: String) -> String? {
let links = xmlAllTagAttributes("link", in: xml)
if let preferred = links.first(where: {
let rel = $0["rel"]?.lowercased() ?? "alternate"
return rel == "alternate"
}) {
return preferred["href"]?.xmlNilIfEmpty
}
return links.first?["href"]?.xmlNilIfEmpty
}
}

View File

@@ -0,0 +1,37 @@
import Foundation
final class FeedParser {
nonisolated init() {}
nonisolated func parse(data: Data, sourceURL: String) throws -> ParseResult {
let xml = String(decoding: data, as: UTF8.self)
let feedType = try detectFeedType(from: xml)
switch feedType {
case .rss:
let feed = try RSSParser().parse(xml: xml, sourceURL: sourceURL)
return ParseResult(feedType: .rss, feed: feed)
case .atom:
let feed = try AtomParser().parse(xml: xml, sourceURL: sourceURL)
return ParseResult(feedType: .atom, feed: feed)
}
}
nonisolated private func detectFeedType(from xml: String) throws -> FeedType {
let lowered = xml.lowercased()
if lowered.contains("<rss") {
return .rss
}
if lowered.contains("<feed") {
return .atom
}
if lowered.contains("<") {
throw FeedParsingError.malformedXML
}
throw FeedParsingError.unsupportedFeedType
}
}

View File

@@ -0,0 +1,6 @@
import Foundation
enum FeedType: String, Equatable {
case rss
case atom
}

View File

@@ -0,0 +1,11 @@
import Foundation
struct ParseResult: Equatable {
let feedType: FeedType
let feed: Feed
}
enum FeedParsingError: Error, Equatable {
case unsupportedFeedType
case malformedXML
}

View File

@@ -0,0 +1,84 @@
import Foundation
final class RSSParser {
nonisolated init() {}
nonisolated func parse(xml: String, sourceURL: String) throws -> Feed {
let lowered = xml.lowercased()
guard lowered.contains("<rss"), lowered.contains("</rss>") else {
throw FeedParsingError.malformedXML
}
guard let channel = xmlFirstBlock("channel", in: xml) else {
throw FeedParsingError.malformedXML
}
let itemBlocks = xmlAllBlocks("item", in: channel)
let channelWithoutItems = channel.replacingOccurrences(
of: "(?is)<(?:\\w+:)?item\\b[^>]*>.*?</(?:\\w+:)?item>",
with: "",
options: .regularExpression
)
let title = xmlFirstTagValue("title", in: channelWithoutItems)?.xmlNilIfEmpty ?? sourceURL
let feedDescription = xmlFirstTagValue("description", in: channelWithoutItems)?.xmlNilIfEmpty
?? xmlFirstTagValue("summary", in: channelWithoutItems)?.xmlNilIfEmpty
let subtitle = xmlFirstTagValue("subtitle", in: channelWithoutItems)?.xmlNilIfEmpty
let items = itemBlocks.map { parseItem(xml: $0, subscriptionId: sourceURL, subscriptionTitle: title) }
return Feed(
id: sourceURL,
title: title,
link: xmlFirstTagValue("link", in: channelWithoutItems)?.xmlNilIfEmpty,
description: feedDescription,
subtitle: subtitle,
language: xmlFirstTagValue("language", in: channelWithoutItems)?.xmlNilIfEmpty,
lastBuildDate: XMLDateParser.parse(xmlFirstTagValue("lastBuildDate", in: channelWithoutItems)),
generator: xmlFirstTagValue("generator", in: channelWithoutItems)?.xmlNilIfEmpty,
ttl: Int(xmlFirstTagValue("ttl", in: channelWithoutItems) ?? ""),
items: items,
rawUrl: sourceURL
)
}
nonisolated private func parseItem(xml: String, subscriptionId: String, subscriptionTitle: String) -> FeedItem {
let title = xmlFirstTagValue("title", in: xml)?.xmlNilIfEmpty
let link = xmlFirstTagValue("link", in: xml)?.xmlNilIfEmpty
let guid = xmlFirstTagValue("guid", in: xml)?.xmlNilIfEmpty
let resolvedId = guid ?? link ?? UUID().uuidString
let enclosureAttributes = xmlAllTagAttributes("enclosure", in: xml).first
let enclosure = makeEnclosure(attributes: enclosureAttributes)
let description = xmlFirstTagValue("description", in: xml)?.xmlNilIfEmpty
?? xmlFirstTagValue("summary", in: xml)?.xmlNilIfEmpty
return FeedItem(
id: resolvedId,
title: title ?? link ?? "(Untitled)",
link: link,
description: description,
content: xmlFirstTagValue("encoded", in: xml)?.xmlNilIfEmpty,
author: xmlFirstTagValue("author", in: xml)?.xmlNilIfEmpty
?? xmlFirstTagValue("creator", in: xml)?.xmlNilIfEmpty,
published: XMLDateParser.parse(xmlFirstTagValue("pubDate", in: xml)),
categories: xmlAllTagValues("category", in: xml).isEmpty ? nil : xmlAllTagValues("category", in: xml),
enclosure: enclosure,
guid: guid,
subscriptionId: subscriptionId,
subscriptionTitle: subscriptionTitle
)
}
nonisolated private func makeEnclosure(attributes: [String: String]?) -> Enclosure? {
guard
let attributes,
let url = attributes["url"]?.xmlNilIfEmpty,
let type = attributes["type"]?.xmlNilIfEmpty
else {
return nil
}
return Enclosure(url: url, type: type, length: xmlInt64(attributes["length"]))
}
}

View File

@@ -0,0 +1,145 @@
import Foundation
struct XMLDateParser {
nonisolated(unsafe) private static let iso8601WithFractional: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
nonisolated(unsafe) private static let iso8601: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
nonisolated(unsafe) private static let dateFormatters: [DateFormatter] = {
let formats = [
"EEE, dd MMM yyyy HH:mm:ss Z",
"EEE, dd MMM yyyy HH:mm Z",
"yyyy-MM-dd'T'HH:mm:ssZ",
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
"yyyy-MM-dd HH:mm:ss Z"
]
return formats.map { format in
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = format
return formatter
}
}()
nonisolated(unsafe) static func parse(_ value: String?) -> Date? {
guard let raw = value?.xmlTrimmed, !raw.isEmpty else { return nil }
if let date = iso8601WithFractional.date(from: raw) { return date }
if let date = iso8601.date(from: raw) { return date }
for formatter in dateFormatters {
if let date = formatter.date(from: raw) {
return date
}
}
return nil
}
}
extension String {
nonisolated(unsafe) var xmlTrimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
nonisolated(unsafe) var xmlNilIfEmpty: String? {
let trimmed = xmlTrimmed
return trimmed.isEmpty ? nil : trimmed
}
nonisolated(unsafe) var xmlDecoded: String {
replacingOccurrences(of: "<![CDATA[", with: "")
.replacingOccurrences(of: "]]>", with: "")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&amp;", with: "&")
.replacingOccurrences(of: "&quot;", with: "\"")
.replacingOccurrences(of: "&apos;", with: "'")
}
}
nonisolated(unsafe) func xmlInt64(_ value: String?) -> Int64? {
guard let value = value?.xmlTrimmed, !value.isEmpty else { return nil }
return Int64(value)
}
nonisolated(unsafe) func xmlFirstTagValue(_ tag: String, in xml: String) -> String? {
let pattern = "(?is)<(?:\\w+:)?\(tag)\\b[^>]*>(.*?)</(?:\\w+:)?\(tag)>"
guard let value = xmlFirstCapture(pattern: pattern, in: xml) else { return nil }
return value.xmlDecoded.xmlTrimmed
}
nonisolated(unsafe) func xmlAllTagValues(_ tag: String, in xml: String) -> [String] {
let pattern = "(?is)<(?:\\w+:)?\(tag)\\b[^>]*>(.*?)</(?:\\w+:)?\(tag)>"
return xmlAllCaptures(pattern: pattern, in: xml)
.map { $0.xmlDecoded.xmlTrimmed }
.filter { !$0.isEmpty }
}
nonisolated(unsafe) func xmlFirstBlock(_ tag: String, in xml: String) -> String? {
let pattern = "(?is)<(?:\\w+:)?\(tag)\\b[^>]*>(.*?)</(?:\\w+:)?\(tag)>"
return xmlFirstCapture(pattern: pattern, in: xml)
}
nonisolated(unsafe) func xmlAllBlocks(_ tag: String, in xml: String) -> [String] {
let pattern = "(?is)<(?:\\w+:)?\(tag)\\b[^>]*>(.*?)</(?:\\w+:)?\(tag)>"
return xmlAllCaptures(pattern: pattern, in: xml)
}
nonisolated(unsafe) func xmlAllTagAttributes(_ tag: String, in xml: String) -> [[String: String]] {
let pattern = "(?is)<(?:\\w+:)?\(tag)\\b([^>]*)/?>"
return xmlAllCaptures(pattern: pattern, in: xml).map(parseXMLAttributes)
}
nonisolated(unsafe) private func xmlFirstCapture(pattern: String, in text: String) -> String? {
guard
let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
let range = Range(match.range(at: 1), in: text)
else {
return nil
}
return String(text[range])
}
nonisolated(unsafe) private func xmlAllCaptures(pattern: String, in text: String) -> [String] {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
return regex.matches(in: text, range: NSRange(text.startIndex..., in: text)).compactMap { match in
guard let range = Range(match.range(at: 1), in: text) else { return nil }
return String(text[range])
}
}
nonisolated(unsafe) private func parseXMLAttributes(_ raw: String) -> [String: String] {
guard let regex = try? NSRegularExpression(pattern: "(\\w+(?::\\w+)?)\\s*=\\s*\"([^\"]*)\"") else {
return [:]
}
var result: [String: String] = [:]
for match in regex.matches(in: raw, range: NSRange(raw.startIndex..., in: raw)) {
guard
let keyRange = Range(match.range(at: 1), in: raw),
let valueRange = Range(match.range(at: 2), in: raw)
else {
continue
}
let key = String(raw[keyRange]).lowercased()
let value = String(raw[valueRange]).xmlDecoded.xmlTrimmed
result[key] = value
}
return result
}

View File

@@ -0,0 +1,32 @@
//
// RSSuperApp.swift
// RSSuper
//
// Created by Mike Freno on 3/29/26.
//
import SwiftUI
import SwiftData
@main
struct RSSuperApp: App {
var sharedModelContainer: ModelContainer = {
let schema = Schema([
Item.self,
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(sharedModelContainer)
}
}