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
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:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
35
iOS/RSSuper/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
35
iOS/RSSuper/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
6
iOS/RSSuper/Assets.xcassets/Contents.json
Normal file
6
iOS/RSSuper/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
61
iOS/RSSuper/ContentView.swift
Normal file
61
iOS/RSSuper/ContentView.swift
Normal 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)
|
||||
}
|
||||
764
iOS/RSSuper/Database/DatabaseManager.swift
Normal file
764
iOS/RSSuper/Database/DatabaseManager.swift
Normal 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
18
iOS/RSSuper/Item.swift
Normal 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
|
||||
}
|
||||
}
|
||||
66
iOS/RSSuper/Models/ContentType.swift
Normal file
66
iOS/RSSuper/Models/ContentType.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
48
iOS/RSSuper/Models/Date+Extensions.swift
Normal file
48
iOS/RSSuper/Models/Date+Extensions.swift
Normal 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
|
||||
}
|
||||
}
|
||||
85
iOS/RSSuper/Models/Feed.swift
Normal file
85
iOS/RSSuper/Models/Feed.swift
Normal 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")
|
||||
)
|
||||
"""
|
||||
}
|
||||
}
|
||||
109
iOS/RSSuper/Models/FeedItem.swift
Normal file
109
iOS/RSSuper/Models/FeedItem.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
89
iOS/RSSuper/Models/FeedSubscription.swift
Normal file
89
iOS/RSSuper/Models/FeedSubscription.swift
Normal 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
|
||||
}
|
||||
}
|
||||
54
iOS/RSSuper/Models/NotificationPreferences.swift
Normal file
54
iOS/RSSuper/Models/NotificationPreferences.swift
Normal 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)
|
||||
)
|
||||
"""
|
||||
}
|
||||
}
|
||||
76
iOS/RSSuper/Models/ReadingPreferences.swift
Normal file
76
iOS/RSSuper/Models/ReadingPreferences.swift
Normal 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)
|
||||
)
|
||||
"""
|
||||
}
|
||||
}
|
||||
72
iOS/RSSuper/Models/SearchFilters.swift
Normal file
72
iOS/RSSuper/Models/SearchFilters.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
30
iOS/RSSuper/Models/SearchHistoryItem.swift
Normal file
30
iOS/RSSuper/Models/SearchHistoryItem.swift
Normal 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))
|
||||
)
|
||||
"""
|
||||
}
|
||||
}
|
||||
73
iOS/RSSuper/Models/SearchResult.swift
Normal file
73
iOS/RSSuper/Models/SearchResult.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
iOS/RSSuper/Networking/01_FetchResult.swift
Normal file
15
iOS/RSSuper/Networking/01_FetchResult.swift
Normal 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
|
||||
}
|
||||
}
|
||||
172
iOS/RSSuper/Networking/03_FeedFetcher.swift
Normal file
172
iOS/RSSuper/Networking/03_FeedFetcher.swift
Normal 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
|
||||
}()
|
||||
}
|
||||
37
iOS/RSSuper/Networking/CacheManager.swift
Normal file
37
iOS/RSSuper/Networking/CacheManager.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
20
iOS/RSSuper/Networking/HTTPAuthCredentials.swift
Normal file
20
iOS/RSSuper/Networking/HTTPAuthCredentials.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
32
iOS/RSSuper/Networking/NetworkError.swift
Normal file
32
iOS/RSSuper/Networking/NetworkError.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
100
iOS/RSSuper/Parsing/AtomParser.swift
Normal file
100
iOS/RSSuper/Parsing/AtomParser.swift
Normal 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
|
||||
}
|
||||
}
|
||||
37
iOS/RSSuper/Parsing/FeedParser.swift
Normal file
37
iOS/RSSuper/Parsing/FeedParser.swift
Normal 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
|
||||
}
|
||||
}
|
||||
6
iOS/RSSuper/Parsing/FeedType.swift
Normal file
6
iOS/RSSuper/Parsing/FeedType.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
enum FeedType: String, Equatable {
|
||||
case rss
|
||||
case atom
|
||||
}
|
||||
11
iOS/RSSuper/Parsing/ParseResult.swift
Normal file
11
iOS/RSSuper/Parsing/ParseResult.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
struct ParseResult: Equatable {
|
||||
let feedType: FeedType
|
||||
let feed: Feed
|
||||
}
|
||||
|
||||
enum FeedParsingError: Error, Equatable {
|
||||
case unsupportedFeedType
|
||||
case malformedXML
|
||||
}
|
||||
84
iOS/RSSuper/Parsing/RSSParser.swift
Normal file
84
iOS/RSSuper/Parsing/RSSParser.swift
Normal 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"]))
|
||||
}
|
||||
}
|
||||
145
iOS/RSSuper/Parsing/XMLParsingUtilities.swift
Normal file
145
iOS/RSSuper/Parsing/XMLParsingUtilities.swift
Normal 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: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: """, with: "\"")
|
||||
.replacingOccurrences(of: "'", 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
|
||||
}
|
||||
32
iOS/RSSuper/RSSuperApp.swift
Normal file
32
iOS/RSSuper/RSSuperApp.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user