- 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>
841 lines
32 KiB
Swift
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()
|
|
}
|
|
}
|