Files
RSSuper/iOS/RSSuper/Database/DatabaseManager.swift
Michael Freno 14efe072fa feat: implement cross-platform features and UI integration
- iOS: Add BackgroundSyncService, SyncScheduler, SyncWorker, BookmarkViewModel, FeedViewModel
- iOS: Add BackgroundSyncService, SyncScheduler, SyncWorker services
- Linux: Add settings-store.vala, State.vala signals, view widgets (FeedList, FeedDetail, AddFeed, Search, Settings, Bookmark)
- Linux: Add bookmark-store.vala, bookmark vala model, search-service.vala
- Android: Add NotificationService, NotificationManager, NotificationPreferencesStore
- Android: Add BookmarkDao, BookmarkRepository, SettingsStore
- Add unit tests for iOS, Android, Linux
- Add integration tests
- Add performance benchmarks
- Update tasks and documentation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-30 23:06:12 -04:00

841 lines
32 KiB
Swift

//
// 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: - Business Logic Methods
func saveFeed(_ feed: Feed) throws {
try createSubscription(
id: feed.id ?? UUID().uuidString,
url: feed.link,
title: feed.title,
category: feed.category,
enabled: true,
fetchInterval: feed.ttl ?? 3600
)
for item in feed.items {
try createFeedItem(item)
}
}
func getFeedItems(subscriptionId: String) throws -> [FeedItem] {
try fetchFeedItems(for: subscriptionId)
}
func markItemAsRead(itemId: String) throws {
guard let item = try fetchFeedItem(id: itemId) else {
throw DatabaseError.objectNotFound
}
_ = try updateFeedItem(item, read: true)
}
func markItemAsStarred(itemId: String) throws {
guard let item = try fetchFeedItem(id: itemId) else {
throw DatabaseError.objectNotFound
}
var updatedItem = item
updatedItem.starred = true
_ = try updateFeedItem(updatedItem, read: nil)
}
func unstarItem(itemId: String) throws {
guard let item = try fetchFeedItem(id: itemId) else {
throw DatabaseError.objectNotFound
}
var updatedItem = item
updatedItem.starred = false
_ = try updateFeedItem(updatedItem, read: nil)
}
func getStarredItems() throws -> [FeedItem] {
let stmt = "SELECT * FROM feed_items WHERE starred = 1 ORDER BY published DESC"
guard let statement = prepareStatement(sql: stmt) else {
return []
}
defer { sqlite3_finalize(statement) }
var items: [FeedItem] = []
while sqlite3_step(statement) == SQLITE_ROW {
items.append(rowToFeedItem(statement))
}
return items
}
func getUnreadItems() throws -> [FeedItem] {
let stmt = "SELECT * FROM feed_items WHERE read = 0 ORDER BY published DESC"
guard let statement = prepareStatement(sql: stmt) else {
return []
}
defer { sqlite3_finalize(statement) }
var items: [FeedItem] = []
while sqlite3_step(statement) == SQLITE_ROW {
items.append(rowToFeedItem(statement))
}
return items
}
}
// 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()
}
}