import Foundation import SQLite3 public enum LegadoDatabaseError: Error, LocalizedError, Sendable { case open(String) case execute(String) case prepare(String) case bind(String) case decode(String) public var errorDescription: String? { switch self { case .open(let message), .execute(let message), .prepare(let message), .bind(let message), .decode(let message): message } } } public actor LegadoDatabase { public static let schemaVersion: Int32 = 7 // SQLite is opened with FULLMUTEX and every operational access remains // actor-isolated. `nonisolated(unsafe)` only permits closing the handle // from Swift 6's nonisolated actor deinitializer. nonisolated(unsafe) private let handle: OpaquePointer private let encoder = JSONEncoder() private let decoder = JSONDecoder() public init(url: URL) throws { try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true ) var database: OpaquePointer? let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX guard sqlite3_open_v2(url.path, &database, flags, nil) == SQLITE_OK, let database else { let message = database.map { String(cString: sqlite3_errmsg($0)) } ?? "无法打开数据库" if let database { sqlite3_close(database) } throw LegadoDatabaseError.open(message) } handle = database sqlite3_busy_timeout(database, 5_000) do { try Self.execute(database, "PRAGMA foreign_keys = ON") try Self.execute(database, "PRAGMA journal_mode = WAL") try Self.migrate(database) } catch { sqlite3_close(database) throw error } } deinit { sqlite3_close(handle) } public static func defaultURL() -> URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("Legado/legado.sqlite3") } public func compact() throws { try Self.execute(handle, "PRAGMA wal_checkpoint(TRUNCATE)") try Self.execute(handle, "VACUUM") } public func loadBookSources() throws -> [BookSource] { try load("SELECT payload FROM book_sources ORDER BY position, book_source_url", as: BookSource.self) } public func replaceBookSources(_ values: [BookSource]) throws { try transaction { if values.isEmpty { try Self.execute(handle, "DELETE FROM book_sources") } else { let placeholders = Array(repeating: "?", count: values.count).joined(separator: ",") let prune = try prepare("DELETE FROM book_sources WHERE book_source_url NOT IN (\(placeholders))") defer { sqlite3_finalize(prune) } for (offset, value) in values.enumerated() { try bind(value.bookSourceUrl, to: prune, at: Int32(offset + 1)) } try stepDone(prune) } let statement = try prepare(""" INSERT INTO book_sources( book_source_url, book_source_group, custom_order, enabled, position, payload ) VALUES(?, ?, ?, ?, ?, ?) ON CONFLICT(book_source_url) DO UPDATE SET book_source_group = excluded.book_source_group, custom_order = excluded.custom_order, enabled = excluded.enabled, position = excluded.position, payload = excluded.payload """) defer { sqlite3_finalize(statement) } for (position, value) in values.enumerated() { sqlite3_reset(statement) sqlite3_clear_bindings(statement) try bind(value.bookSourceUrl, to: statement, at: 1) if let group = value.bookSourceGroup { try bind(group, to: statement, at: 2) } else { sqlite3_bind_null(statement, 2) } sqlite3_bind_int64(statement, 3, Int64(value.customOrder)) sqlite3_bind_int(statement, 4, value.enabled ? 1 : 0) sqlite3_bind_int64(statement, 5, Int64(position)) try bind(try encoder.encode(value), to: statement, at: 6) try stepDone(statement) } } } public func loadBooks() throws -> [Book] { try load("SELECT payload FROM books ORDER BY position, book_url", as: Book.self) } public func replaceBooks(_ values: [Book]) throws { try transaction { if values.isEmpty { try Self.execute(handle, "DELETE FROM books") } else { let placeholders = Array(repeating: "?", count: values.count).joined(separator: ",") let prune = try prepare("DELETE FROM books WHERE book_url NOT IN (\(placeholders))") defer { sqlite3_finalize(prune) } for (offset, value) in values.enumerated() { try bind(value.bookUrl, to: prune, at: Int32(offset + 1)) } try stepDone(prune) } let insert = try prepare(""" INSERT INTO books(book_url, name, author, position, payload) VALUES(?, ?, ?, ?, ?) ON CONFLICT(book_url) DO UPDATE SET name = excluded.name, author = excluded.author, position = excluded.position, payload = excluded.payload """) defer { sqlite3_finalize(insert) } for (position, value) in values.enumerated() { sqlite3_reset(insert) sqlite3_clear_bindings(insert) try bind(value.bookUrl, to: insert, at: 1) try bind(value.name, to: insert, at: 2) try bind(value.author, to: insert, at: 3) sqlite3_bind_int64(insert, 4, Int64(position)) try bind(try encoder.encode(value), to: insert, at: 5) try stepDone(insert) } } } public func loadChapters(bookURL: String) throws -> [BookChapter] { let statement = try prepare("SELECT payload FROM chapters WHERE book_url = ? ORDER BY chapter_index") defer { sqlite3_finalize(statement) } try bind(bookURL, to: statement, at: 1) return try decodeRows(statement, as: BookChapter.self) } public func replaceChapters(_ chapters: [BookChapter], bookURL: String) throws { try transaction { let delete = try prepare("DELETE FROM chapters WHERE book_url = ?") defer { sqlite3_finalize(delete) } try bind(bookURL, to: delete, at: 1) try stepDone(delete) let insert = try prepare( "INSERT INTO chapters(url, book_url, chapter_index, payload) VALUES(?, ?, ?, ?)" ) defer { sqlite3_finalize(insert) } for (position, original) in chapters.enumerated() { var chapter = original chapter.bookUrl = bookURL chapter.index = position sqlite3_reset(insert) sqlite3_clear_bindings(insert) try bind(chapter.url, to: insert, at: 1) try bind(bookURL, to: insert, at: 2) sqlite3_bind_int64(insert, 3, Int64(position)) try bind(try encoder.encode(chapter), to: insert, at: 4) try stepDone(insert) } } } public func removeChapters(bookURL: String) throws { let statement = try prepare("DELETE FROM chapters WHERE book_url = ?") defer { sqlite3_finalize(statement) } try bind(bookURL, to: statement, at: 1) try stepDone(statement) } public func loadBookmarks() throws -> [Bookmark] { try load("SELECT payload FROM bookmarks ORDER BY time", as: Bookmark.self) } public func loadBookGroups() throws -> [BookGroup] { try load("SELECT payload FROM book_groups ORDER BY position, group_id", as: BookGroup.self) } public func replaceBookGroups(_ values: [BookGroup]) throws { try replaceAll( deleteSQL: "DELETE FROM book_groups", insertSQL: "INSERT INTO book_groups(group_id, position, payload) VALUES(?, ?, ?)", values: values ) { statement, value, position, data in sqlite3_bind_int64(statement, 1, value.groupId) sqlite3_bind_int64(statement, 2, Int64(position)) try self.bind(data, to: statement, at: 3) } } public func replaceBookmarks(_ values: [Bookmark]) throws { try replaceAll( deleteSQL: "DELETE FROM bookmarks", insertSQL: "INSERT INTO bookmarks(time, payload) VALUES(?, ?)", values: values ) { statement, value, _, data in sqlite3_bind_int64(statement, 1, value.time) try self.bind(data, to: statement, at: 2) } } public func loadReadRecords() throws -> [ReadRecord] { try load("SELECT payload FROM read_records ORDER BY last_read DESC", as: ReadRecord.self) } public func replaceReadRecords(_ values: [ReadRecord]) throws { try replaceAll( deleteSQL: "DELETE FROM read_records", insertSQL: "INSERT INTO read_records(device_id, book_name, last_read, payload) VALUES(?, ?, ?, ?)", values: values ) { statement, value, _, data in try self.bind(value.deviceId, to: statement, at: 1) try self.bind(value.bookName, to: statement, at: 2) sqlite3_bind_int64(statement, 3, value.lastRead) try self.bind(data, to: statement, at: 4) } } public func loadSearchKeywords(order: SearchKeywordOrder) throws -> [SearchKeyword] { let orderSQL = switch order { case .usage: "usage DESC, last_use_time DESC" case .lastUseTime: "last_use_time DESC, usage DESC" } let statement = try prepare("SELECT word, usage, last_use_time FROM search_keywords ORDER BY \(orderSQL)") defer { sqlite3_finalize(statement) } var result: [SearchKeyword] = [] while true { let status = sqlite3_step(statement) if status == SQLITE_DONE { return result } guard status == SQLITE_ROW else { throw databaseError(.execute) } result.append(.init( word: columnString(statement, at: 0), usage: Int(sqlite3_column_int64(statement, 1)), lastUseTime: sqlite3_column_int64(statement, 2) )) } } public func recordSearchKeyword(_ rawWord: String, at time: Int64) throws -> SearchKeyword { let word = rawWord.trimmingCharacters(in: .whitespacesAndNewlines) guard !word.isEmpty else { throw LegadoDatabaseError.bind("搜索词不能为空") } try transaction { let statement = try prepare(""" INSERT INTO search_keywords(word, usage, last_use_time) VALUES(?, 1, ?) ON CONFLICT(word) DO UPDATE SET usage = search_keywords.usage + 1, last_use_time = excluded.last_use_time """) defer { sqlite3_finalize(statement) } try bind(word, to: statement, at: 1) sqlite3_bind_int64(statement, 2, time) try stepDone(statement) } let statement = try prepare("SELECT usage, last_use_time FROM search_keywords WHERE word = ?") defer { sqlite3_finalize(statement) } try bind(word, to: statement, at: 1) guard sqlite3_step(statement) == SQLITE_ROW else { throw databaseError(.execute) } return .init( word: word, usage: Int(sqlite3_column_int64(statement, 0)), lastUseTime: sqlite3_column_int64(statement, 1) ) } public func deleteSearchKeyword(_ word: String) throws { try executeBound("DELETE FROM search_keywords WHERE word = ?", text: word) } public func deleteAllSearchKeywords() throws { try Self.execute(handle, "DELETE FROM search_keywords") } public func importLegacySearchKeywords(_ words: [String]) throws { guard try count("search_keywords") == 0, !words.isEmpty else { return } let now: Int64 = 0 try transaction { let statement = try prepare( "INSERT OR IGNORE INTO search_keywords(word, usage, last_use_time) VALUES(?, 1, ?)" ) defer { sqlite3_finalize(statement) } for (offset, rawWord) in words.enumerated() { let word = rawWord.trimmingCharacters(in: .whitespacesAndNewlines) guard !word.isEmpty else { continue } sqlite3_reset(statement) sqlite3_clear_bindings(statement) try bind(word, to: statement, at: 1) sqlite3_bind_int64(statement, 2, now - Int64(offset)) try stepDone(statement) } } } public func cacheValue(for key: String, now: Int64) throws -> String? { let statement = try prepare( "SELECT value FROM caches WHERE `key` = ? AND (deadline = 0 OR deadline > ?)" ) defer { sqlite3_finalize(statement) } try bind(key, to: statement, at: 1) sqlite3_bind_int64(statement, 2, now) guard sqlite3_step(statement) == SQLITE_ROW else { return nil } return sqlite3_column_type(statement, 0) == SQLITE_NULL ? nil : columnString(statement, at: 0) } public func putCache(_ cache: LegadoCache) throws { let statement = try prepare(""" INSERT INTO caches(`key`, value, deadline) VALUES(?, ?, ?) ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, deadline = excluded.deadline """) defer { sqlite3_finalize(statement) } try bind(cache.key, to: statement, at: 1) if let value = cache.value { try bind(value, to: statement, at: 2) } else { sqlite3_bind_null(statement, 2) } sqlite3_bind_int64(statement, 3, cache.deadline) try stepDone(statement) } public func deleteCache(_ key: String) throws { try executeBound("DELETE FROM caches WHERE `key` = ?", text: key) } public func deleteSourceVariables(_ sourceKey: String) throws { let statement = try prepare(""" DELETE FROM caches WHERE `key` LIKE 'v_' || ? || '_%' OR `key` = 'userInfo_' || ? OR `key` = 'loginHeader_' || ? OR `key` = 'sourceVariable_' || ? OR `key` = 'infoMap_' || ? """) defer { sqlite3_finalize(statement) } for index in 1...5 { try bind(sourceKey, to: statement, at: Int32(index)) } try stepDone(statement) } public func clearExpiredCaches(now: Int64) throws { let statement = try prepare("DELETE FROM caches WHERE deadline > 0 AND deadline < ?") defer { sqlite3_finalize(statement) } sqlite3_bind_int64(statement, 1, now) try stepDone(statement) } public func cookie(for url: String) throws -> LegadoCookie? { let statement = try prepare("SELECT cookie FROM cookies WHERE url = ?") defer { sqlite3_finalize(statement) } try bind(url, to: statement, at: 1) guard sqlite3_step(statement) == SQLITE_ROW else { return nil } return .init(url: url, cookie: columnString(statement, at: 0)) } public func loadOkHTTPCookies() throws -> [LegadoCookie] { let statement = try prepare("SELECT url, cookie FROM cookies WHERE url LIKE '%|%'") defer { sqlite3_finalize(statement) } var result: [LegadoCookie] = [] while true { let status = sqlite3_step(statement) if status == SQLITE_DONE { return result } guard status == SQLITE_ROW else { throw databaseError(.execute) } result.append(.init(url: columnString(statement, at: 0), cookie: columnString(statement, at: 1))) } } public func putCookie(_ cookie: LegadoCookie) throws { let statement = try prepare(""" INSERT INTO cookies(url, cookie) VALUES(?, ?) ON CONFLICT(url) DO UPDATE SET cookie = excluded.cookie """) defer { sqlite3_finalize(statement) } try bind(cookie.url, to: statement, at: 1) try bind(cookie.cookie, to: statement, at: 2) try stepDone(statement) } public func deleteCookie(_ url: String) throws { try executeBound("DELETE FROM cookies WHERE url = ?", text: url) } public func deleteOkHTTPCookies() throws { try Self.execute(handle, "DELETE FROM cookies WHERE url LIKE '%|%'") } public func loadSearchBooks() throws -> [SearchBook] { try load("SELECT payload FROM searchBooks ORDER BY time DESC", as: SearchBook.self) } public func cachedSearchMatches(keyword: String) throws -> [SearchBook] { guard !keyword.isEmpty else { return [] } return try load(""" SELECT payload FROM searchBooks WHERE instr(lower(name), lower(?1)) > 0 OR instr(lower(author), lower(?1)) > 0 ORDER BY CASE WHEN name = ?1 OR author = ?1 THEN 0 ELSE 1 END, time DESC LIMIT 200 """, as: SearchBook.self, bindings: [keyword]) } public func putSearchBooks(_ values: [SearchBook]) throws { try transaction { let statement = try prepare(""" INSERT INTO searchBooks(book_url, origin, name, author, time, payload) VALUES(?, ?, ?, ?, ?, ?) ON CONFLICT(book_url) DO UPDATE SET origin = excluded.origin, name = excluded.name, author = excluded.author, time = excluded.time, payload = excluded.payload """) defer { sqlite3_finalize(statement) } for value in values { sqlite3_reset(statement) sqlite3_clear_bindings(statement) try bind(value.bookURL, to: statement, at: 1) try bind(value.origin, to: statement, at: 2) try bind(value.name, to: statement, at: 3) try bind(value.author, to: statement, at: 4) sqlite3_bind_int64(statement, 5, value.time) try bind(try encoder.encode(value), to: statement, at: 6) try stepDone(statement) } } } public func deleteSearchBooks(name: String, author: String) throws { let statement = try prepare("DELETE FROM searchBooks WHERE name = ? AND author = ?") defer { sqlite3_finalize(statement) } try bind(name, to: statement, at: 1) try bind(author, to: statement, at: 2) try stepDone(statement) } public func clearExpiredSearchBooks(before time: Int64) throws { let statement = try prepare("DELETE FROM searchBooks WHERE time < ?") defer { sqlite3_finalize(statement) } sqlite3_bind_int64(statement, 1, time) try stepDone(statement) } public func loadTxtTocRules() throws -> [TxtTocRule] { try load("SELECT payload FROM txtTocRules ORDER BY serial_number, id", as: TxtTocRule.self) } public func replaceTxtTocRules(_ values: [TxtTocRule]) throws { try replaceAll( deleteSQL: "DELETE FROM txtTocRules", insertSQL: "INSERT INTO txtTocRules(id, serial_number, enabled, payload) VALUES(?, ?, ?, ?)", values: values ) { statement, value, _, data in sqlite3_bind_int64(statement, 1, value.id) sqlite3_bind_int64(statement, 2, Int64(value.serialNumber)) sqlite3_bind_int(statement, 3, value.enable ? 1 : 0) try self.bind(data, to: statement, at: 4) } } public func loadHttpTTS() throws -> [HttpTTS] { try load("SELECT payload FROM httpTTS ORDER BY name", as: HttpTTS.self) } public func replaceHttpTTS(_ values: [HttpTTS]) throws { try replaceAll( deleteSQL: "DELETE FROM httpTTS", insertSQL: "INSERT INTO httpTTS(id, name, payload) VALUES(?, ?, ?)", values: values ) { statement, value, _, data in sqlite3_bind_int64(statement, 1, value.id) try self.bind(value.name, to: statement, at: 2) try self.bind(data, to: statement, at: 3) } } public func loadRuleSubs() throws -> [RuleSub] { try load("SELECT payload FROM ruleSubs ORDER BY custom_order, id", as: RuleSub.self) } public func replaceRuleSubs(_ values: [RuleSub]) throws { try replaceAll( deleteSQL: "DELETE FROM ruleSubs", insertSQL: "INSERT INTO ruleSubs(id, type, custom_order, payload) VALUES(?, ?, ?, ?)", values: values ) { statement, value, _, data in sqlite3_bind_int64(statement, 1, value.id) sqlite3_bind_int64(statement, 2, Int64(value.type)) sqlite3_bind_int64(statement, 3, Int64(value.customOrder)) try self.bind(data, to: statement, at: 4) } } public func loadKeyboardAssists() throws -> [KeyboardAssist] { try load("SELECT payload FROM keyboardAssists ORDER BY serial_no", as: KeyboardAssist.self) } public func replaceKeyboardAssists(_ values: [KeyboardAssist]) throws { try replaceAll( deleteSQL: "DELETE FROM keyboardAssists", insertSQL: "INSERT INTO keyboardAssists(type, key, serial_no, payload) VALUES(?, ?, ?, ?)", values: values ) { statement, value, _, data in sqlite3_bind_int64(statement, 1, Int64(value.type)) try self.bind(value.key, to: statement, at: 2) sqlite3_bind_int64(statement, 3, Int64(value.serialNo)) try self.bind(data, to: statement, at: 4) } } public func loadServers() throws -> [Server] { try load("SELECT payload FROM servers ORDER BY sort_number, id", as: Server.self) } public func replaceServers(_ values: [Server]) throws { try replaceAll( deleteSQL: "DELETE FROM servers", insertSQL: "INSERT INTO servers(id, sort_number, payload) VALUES(?, ?, ?)", values: values ) { statement, value, _, data in sqlite3_bind_int64(statement, 1, value.id) sqlite3_bind_int64(statement, 2, Int64(value.sortNumber)) try self.bind(data, to: statement, at: 3) } } public func loadRssSources() throws -> [RssSource] { try load("SELECT payload FROM rssSources ORDER BY custom_order", as: RssSource.self) } public func replaceRssSources(_ values: [RssSource]) throws { try replaceAll( deleteSQL: "DELETE FROM rssSources", insertSQL: "INSERT INTO rssSources(source_url, source_name, source_group, source_comment, enabled, custom_order, login_url, payload) VALUES(?, ?, ?, ?, ?, ?, ?, ?)", values: values ) { statement, value, _, data in try self.bind(value.sourceUrl, to: statement, at: 1) try self.bind(value.sourceName, to: statement, at: 2) if let group = value.sourceGroup { try self.bind(group, to: statement, at: 3) } else { sqlite3_bind_null(statement, 3) } if let comment = value.sourceComment { try self.bind(comment, to: statement, at: 4) } else { sqlite3_bind_null(statement, 4) } sqlite3_bind_int(statement, 5, value.enabled ? 1 : 0) sqlite3_bind_int64(statement, 6, Int64(value.customOrder)) if let loginURL = value.loginUrl { try self.bind(loginURL, to: statement, at: 7) } else { sqlite3_bind_null(statement, 7) } try self.bind(data, to: statement, at: 8) } } public func loadRssArticles(origin: String, sort: String) throws -> [RssArticle] { let statement = try prepare("SELECT a.payload, EXISTS(SELECT 1 FROM rssReadRecords r WHERE r.record = a.link) FROM rssArticles a WHERE a.origin = ? AND a.sort = ? ORDER BY a.article_order DESC") defer { sqlite3_finalize(statement) } try bind(origin, to: statement, at: 1) try bind(sort, to: statement, at: 2) var result: [RssArticle] = [] while true { let status = sqlite3_step(statement) if status == SQLITE_DONE { return result } guard status == SQLITE_ROW else { throw databaseError(.execute) } guard let bytes = sqlite3_column_blob(statement, 0) else { throw LegadoDatabaseError.decode("RSS文章记录缺少payload") } var article = try decoder.decode(RssArticle.self, from: Data(bytes: bytes, count: Int(sqlite3_column_bytes(statement, 0)))) article.read = sqlite3_column_int(statement, 1) != 0 result.append(article) } } public func findRssArticle(origin: String, link: String, sort: String? = nil) throws -> RssArticle? { let sql = sort == nil ? "SELECT payload FROM rssArticles WHERE origin = ? AND link = ? LIMIT 1" : "SELECT payload FROM rssArticles WHERE origin = ? AND link = ? AND sort = ? LIMIT 1" let statement = try prepare(sql) defer { sqlite3_finalize(statement) } try bind(origin, to: statement, at: 1); try bind(link, to: statement, at: 2) if let sort { try bind(sort, to: statement, at: 3) } return try decodeFirst(statement, as: RssArticle.self) } public func replaceRssArticles(_ values: [RssArticle], origin: String, sort: String) throws { try transaction { let delete = try prepare("DELETE FROM rssArticles WHERE origin = ? AND sort = ?") defer { sqlite3_finalize(delete) } try bind(origin, to: delete, at: 1); try bind(sort, to: delete, at: 2); try stepDone(delete) try insertRssArticles(values, conflict: "REPLACE") } } public func appendRssArticles(_ values: [RssArticle]) throws { try transaction { try insertRssArticles(values, conflict: "IGNORE") } } public func putRssArticles(_ values: [RssArticle]) throws { try transaction { try insertRssArticles(values, conflict: "REPLACE") } } public func updateRssArticles(_ values: [RssArticle]) throws { try transaction { let statement = try prepare("UPDATE rssArticles SET article_order = ?, payload = ? WHERE origin = ? AND link = ? AND sort = ?") defer { sqlite3_finalize(statement) } for value in values { sqlite3_reset(statement); sqlite3_clear_bindings(statement) sqlite3_bind_int64(statement, 1, value.order); try bind(try encoder.encode(value), to: statement, at: 2) try bind(value.origin, to: statement, at: 3); try bind(value.link, to: statement, at: 4) try bind(value.sort, to: statement, at: 5); try stepDone(statement) } } } public func updateRssArticleOrigin(from oldOrigin: String, to origin: String) throws { let statement = try prepare("SELECT payload FROM rssArticles WHERE origin = ?") defer { sqlite3_finalize(statement) } try bind(oldOrigin, to: statement, at: 1) var values = try decodeRows(statement, as: RssArticle.self) values.indices.forEach { values[$0].origin = origin } try transaction { let update = try prepare("UPDATE rssArticles SET origin = ?, payload = ? WHERE origin = ? AND link = ? AND sort = ?") defer { sqlite3_finalize(update) } for value in values { sqlite3_reset(update); sqlite3_clear_bindings(update) try bind(origin, to: update, at: 1); try bind(try encoder.encode(value), to: update, at: 2) try bind(oldOrigin, to: update, at: 3); try bind(value.link, to: update, at: 4) try bind(value.sort, to: update, at: 5); try stepDone(update) } } } public func clearOldRssArticles(origin: String, sort: String, before order: Int64) throws { let statement = try prepare("DELETE FROM rssArticles WHERE origin = ? AND sort = ? AND article_order < ?") defer { sqlite3_finalize(statement) } try bind(origin, to: statement, at: 1); try bind(sort, to: statement, at: 2) sqlite3_bind_int64(statement, 3, order); try stepDone(statement) } public func removeRssArticles(origin: String) throws { try executeBound("DELETE FROM rssArticles WHERE origin = ?", text: origin) } public func loadRssReadRecords() throws -> [RssReadRecord] { try load("SELECT payload FROM rssReadRecords ORDER BY read_time DESC", as: RssReadRecord.self) } public func appendRssReadRecords(_ values: [RssReadRecord]) throws { try transaction { let statement = try prepare("INSERT OR IGNORE INTO rssReadRecords(record, origin, read_time, payload) VALUES(?, ?, ?, ?)") defer { sqlite3_finalize(statement) } for value in values { sqlite3_reset(statement); sqlite3_clear_bindings(statement) try bind(value.record, to: statement, at: 1); try bind(value.origin, to: statement, at: 2) if let time = value.readTime { sqlite3_bind_int64(statement, 3, time) } else { sqlite3_bind_null(statement, 3) } try bind(try encoder.encode(value), to: statement, at: 4); try stepDone(statement) } } } public func updateRssReadRecords(_ values: [RssReadRecord]) throws { try transaction { let statement = try prepare("UPDATE rssReadRecords SET origin = ?, read_time = ?, payload = ? WHERE record = ?") defer { sqlite3_finalize(statement) } for value in values { sqlite3_reset(statement); sqlite3_clear_bindings(statement) try bind(value.origin, to: statement, at: 1) if let time = value.readTime { sqlite3_bind_int64(statement, 2, time) } else { sqlite3_bind_null(statement, 2) } try bind(try encoder.encode(value), to: statement, at: 3); try bind(value.record, to: statement, at: 4) try stepDone(statement) } } } public func findRssReadRecord(record: String, origin: String) throws -> RssReadRecord? { let statement = try prepare("SELECT payload FROM rssReadRecords WHERE record = ? AND origin = ? LIMIT 1") defer { sqlite3_finalize(statement) } try bind(record, to: statement, at: 1); try bind(origin, to: statement, at: 2) return try decodeFirst(statement, as: RssReadRecord.self) } public func clearRssReadRecords() throws { try Self.execute(handle, "DELETE FROM rssReadRecords") } public func removeRssReadRecords(origin: String) throws { try executeBound("DELETE FROM rssReadRecords WHERE origin = ?", text: origin) } public func loadRssStars() throws -> [RssStar] { try load("SELECT payload FROM rssStars ORDER BY star_time DESC", as: RssStar.self) } public func replaceRssStars(_ values: [RssStar]) throws { try replaceAll( deleteSQL: "DELETE FROM rssStars", insertSQL: "INSERT INTO rssStars(origin, link, star_time, group_name, payload) VALUES(?, ?, ?, ?, ?)", values: values ) { statement, value, _, data in try self.bind(value.origin, to: statement, at: 1); try self.bind(value.link, to: statement, at: 2) sqlite3_bind_int64(statement, 3, value.starTime); try self.bind(value.group, to: statement, at: 4) try self.bind(data, to: statement, at: 5) } } public func updateRssStars(_ values: [RssStar]) throws { try transaction { let statement = try prepare("INSERT OR REPLACE INTO rssStars(origin, link, star_time, group_name, payload) VALUES(?, ?, ?, ?, ?)") defer { sqlite3_finalize(statement) } for value in values { sqlite3_reset(statement); sqlite3_clear_bindings(statement) try bind(value.origin, to: statement, at: 1); try bind(value.link, to: statement, at: 2) sqlite3_bind_int64(statement, 3, value.starTime); try bind(value.group, to: statement, at: 4) try bind(try encoder.encode(value), to: statement, at: 5); try stepDone(statement) } } } public func findRssStar(origin: String, link: String) throws -> RssStar? { let statement = try prepare("SELECT payload FROM rssStars WHERE origin = ? AND link = ? LIMIT 1") defer { sqlite3_finalize(statement) } try bind(origin, to: statement, at: 1); try bind(link, to: statement, at: 2) return try decodeFirst(statement, as: RssStar.self) } public func removeRssStars(origin: String, link: String? = nil) throws { let sql = link == nil ? "DELETE FROM rssStars WHERE origin = ?" : "DELETE FROM rssStars WHERE origin = ? AND link = ?" let statement = try prepare(sql) defer { sqlite3_finalize(statement) } try bind(origin, to: statement, at: 1); if let link { try bind(link, to: statement, at: 2) } try stepDone(statement) } public func removeRssStars(group: String) throws { try executeBound("DELETE FROM rssStars WHERE group_name = ?", text: group) } public func clearRssStars() throws { try Self.execute(handle, "DELETE FROM rssStars") } public func updateRssStarOrigin(from oldOrigin: String, to origin: String) throws { let statement = try prepare("SELECT payload FROM rssStars WHERE origin = ?") defer { sqlite3_finalize(statement) } try bind(oldOrigin, to: statement, at: 1) var values = try decodeRows(statement, as: RssStar.self) values.indices.forEach { values[$0].origin = origin } try transaction { let update = try prepare("UPDATE rssStars SET origin = ?, payload = ? WHERE origin = ? AND link = ?") defer { sqlite3_finalize(update) } for value in values { sqlite3_reset(update); sqlite3_clear_bindings(update) try bind(origin, to: update, at: 1); try bind(try encoder.encode(value), to: update, at: 2) try bind(oldOrigin, to: update, at: 3); try bind(value.link, to: update, at: 4) try stepDone(update) } } } private func insertRssArticles(_ values: [RssArticle], conflict: String) throws { let statement = try prepare("INSERT OR \(conflict) INTO rssArticles(origin, link, sort, article_order, payload) VALUES(?, ?, ?, ?, ?)") defer { sqlite3_finalize(statement) } for value in values { sqlite3_reset(statement); sqlite3_clear_bindings(statement) try bind(value.origin, to: statement, at: 1); try bind(value.link, to: statement, at: 2) try bind(value.sort, to: statement, at: 3); sqlite3_bind_int64(statement, 4, value.order) try bind(try encoder.encode(value), to: statement, at: 5); try stepDone(statement) } } public func importLegacyJSON( bookSourcesURL: URL?, booksURL: URL?, chaptersDirectory: URL? = nil, bookGroupsURL: URL? = nil, bookmarksURL: URL? = nil, readRecordsURL: URL? = nil ) throws { if try count("book_sources") == 0, let bookSourcesURL, FileManager.default.fileExists(atPath: bookSourcesURL.path) { try replaceBookSources(decoder.decode([BookSource].self, from: Data(contentsOf: bookSourcesURL))) } if try count("books") == 0, let booksURL, FileManager.default.fileExists(atPath: booksURL.path) { try replaceBooks(decoder.decode([Book].self, from: Data(contentsOf: booksURL))) } if let chaptersDirectory, !legacyChaptersImported() { if try count("chapters") == 0, FileManager.default.fileExists(atPath: chaptersDirectory.path) { for url in try FileManager.default.contentsOfDirectory(at: chaptersDirectory, includingPropertiesForKeys: nil) where url.pathExtension.lowercased() == "json" { let chapters = try decoder.decode([BookChapter].self, from: Data(contentsOf: url)) if let bookURL = chapters.first?.bookUrl, !bookURL.isEmpty { try replaceChapters(chapters, bookURL: bookURL) } } } try Self.execute(handle, "INSERT OR IGNORE INTO legacy_imports(key) VALUES('chapters')") } if try count("bookmarks") == 0, let bookmarksURL, FileManager.default.fileExists(atPath: bookmarksURL.path) { try replaceBookmarks(decoder.decode([Bookmark].self, from: Data(contentsOf: bookmarksURL))) } if try count("book_groups") == 0, let bookGroupsURL, FileManager.default.fileExists(atPath: bookGroupsURL.path) { try replaceBookGroups(decoder.decode([BookGroup].self, from: Data(contentsOf: bookGroupsURL))) } if try count("read_records") == 0, let readRecordsURL, FileManager.default.fileExists(atPath: readRecordsURL.path) { try replaceReadRecords(decoder.decode([ReadRecord].self, from: Data(contentsOf: readRecordsURL))) } } private static func migrate(_ database: OpaquePointer) throws { let version = try scalarInt32(database, "PRAGMA user_version") guard version <= schemaVersion else { throw LegadoDatabaseError.open("数据库版本 \(version) 高于当前支持版本 \(schemaVersion)") } if version < 1 { try execute(database, "BEGIN IMMEDIATE") do { try execute(database, """ CREATE TABLE book_sources( book_source_url TEXT PRIMARY KEY NOT NULL, position INTEGER NOT NULL, payload BLOB NOT NULL ); CREATE INDEX book_sources_position ON book_sources(position); CREATE TABLE books( book_url TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL, author TEXT NOT NULL, position INTEGER NOT NULL, payload BLOB NOT NULL ); CREATE UNIQUE INDEX books_name_author ON books(name, author); CREATE INDEX books_position ON books(position); CREATE TABLE chapters( url TEXT NOT NULL, book_url TEXT NOT NULL, chapter_index INTEGER NOT NULL, payload BLOB NOT NULL, PRIMARY KEY(url, book_url), UNIQUE(book_url, chapter_index), FOREIGN KEY(book_url) REFERENCES books(book_url) ON UPDATE CASCADE ON DELETE CASCADE ); CREATE INDEX chapters_book_url ON chapters(book_url); PRAGMA user_version = 1; """) try execute(database, "COMMIT") } catch { try? execute(database, "ROLLBACK") throw error } } if version < 2 { try execute(database, "BEGIN IMMEDIATE") do { try execute(database, """ CREATE TABLE book_groups( group_id INTEGER PRIMARY KEY NOT NULL, position INTEGER NOT NULL, payload BLOB NOT NULL ); CREATE TABLE bookmarks( time INTEGER PRIMARY KEY NOT NULL, payload BLOB NOT NULL ); CREATE TABLE read_records( device_id TEXT NOT NULL, book_name TEXT NOT NULL, last_read INTEGER NOT NULL, payload BLOB NOT NULL, PRIMARY KEY(device_id, book_name) ); CREATE INDEX read_records_last_read ON read_records(last_read DESC); PRAGMA user_version = 2; """) try execute(database, "COMMIT") } catch { try? execute(database, "ROLLBACK") throw error } } if version < 3 { try execute(database, "BEGIN IMMEDIATE") do { try execute(database, """ CREATE TABLE search_keywords( word TEXT PRIMARY KEY NOT NULL, usage INTEGER NOT NULL DEFAULT 1, last_use_time INTEGER NOT NULL ); CREATE TABLE caches( `key` TEXT PRIMARY KEY NOT NULL, value TEXT, deadline INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE cookies( url TEXT PRIMARY KEY NOT NULL, cookie TEXT NOT NULL DEFAULT '' ); CREATE TABLE searchBooks( book_url TEXT PRIMARY KEY NOT NULL, origin TEXT NOT NULL, payload BLOB NOT NULL, FOREIGN KEY(origin) REFERENCES book_sources(book_source_url) ON DELETE CASCADE ); CREATE INDEX searchBooks_origin ON searchBooks(origin); PRAGMA user_version = 3; """) try execute(database, "COMMIT") } catch { try? execute(database, "ROLLBACK") throw error } } if version < 4 { try execute(database, "BEGIN IMMEDIATE") do { try execute(database, """ ALTER TABLE book_sources ADD COLUMN book_source_group TEXT; ALTER TABLE book_sources ADD COLUMN custom_order INTEGER NOT NULL DEFAULT 0; ALTER TABLE book_sources ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1; CREATE INDEX book_sources_group ON book_sources(book_source_group); CREATE INDEX book_sources_enabled_order ON book_sources(enabled, custom_order); ALTER TABLE searchBooks ADD COLUMN name TEXT NOT NULL DEFAULT ''; ALTER TABLE searchBooks ADD COLUMN author TEXT NOT NULL DEFAULT ''; ALTER TABLE searchBooks ADD COLUMN time INTEGER NOT NULL DEFAULT 0; CREATE INDEX searchBooks_name_author ON searchBooks(name, author); CREATE INDEX searchBooks_time ON searchBooks(time); PRAGMA user_version = 4; """) try execute(database, "COMMIT") } catch { try? execute(database, "ROLLBACK") throw error } } if version < 5 { try execute(database, "BEGIN IMMEDIATE") do { try execute(database, """ CREATE TABLE txtTocRules( id INTEGER PRIMARY KEY NOT NULL, serial_number INTEGER NOT NULL DEFAULT -1, enabled INTEGER NOT NULL DEFAULT 1, payload BLOB NOT NULL ); -- Retain the historical v5 schema for existing databases; no dictionary APIs remain. CREATE TABLE dictRules( name TEXT PRIMARY KEY NOT NULL, sort_number INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, payload BLOB NOT NULL ); CREATE TABLE httpTTS( id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, payload BLOB NOT NULL ); CREATE TABLE ruleSubs( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, type INTEGER NOT NULL DEFAULT 0, custom_order INTEGER NOT NULL DEFAULT 0, payload BLOB NOT NULL ); CREATE TABLE keyboardAssists( type INTEGER NOT NULL DEFAULT 0, key TEXT NOT NULL, serial_no INTEGER NOT NULL DEFAULT 0, payload BLOB NOT NULL, PRIMARY KEY(type, key) ); CREATE TABLE servers( id INTEGER PRIMARY KEY NOT NULL, sort_number INTEGER NOT NULL DEFAULT 0, payload BLOB NOT NULL ); PRAGMA user_version = 5; """) try execute(database, "COMMIT") } catch { try? execute(database, "ROLLBACK") throw error } } if version < 6 { try execute(database, "BEGIN IMMEDIATE") do { try execute(database, """ CREATE TABLE rssSources( source_url TEXT PRIMARY KEY NOT NULL, source_name TEXT NOT NULL, source_group TEXT, source_comment TEXT, enabled INTEGER NOT NULL DEFAULT 1, custom_order INTEGER NOT NULL DEFAULT 0, login_url TEXT, payload BLOB NOT NULL ); CREATE INDEX rssSources_order ON rssSources(custom_order, source_url); CREATE TABLE rssArticles( origin TEXT NOT NULL, link TEXT NOT NULL, sort TEXT NOT NULL, article_order INTEGER NOT NULL DEFAULT 0, payload BLOB NOT NULL, PRIMARY KEY(origin, link, sort) ); CREATE INDEX rssArticles_origin_sort_order ON rssArticles(origin, sort, article_order DESC); CREATE TABLE rssReadRecords( record TEXT PRIMARY KEY NOT NULL, origin TEXT NOT NULL, read_time INTEGER, payload BLOB NOT NULL ); CREATE INDEX rssReadRecords_time ON rssReadRecords(read_time DESC); CREATE INDEX rssReadRecords_origin ON rssReadRecords(origin); CREATE TABLE rssStars( origin TEXT NOT NULL, link TEXT NOT NULL, star_time INTEGER NOT NULL DEFAULT 0, group_name TEXT NOT NULL DEFAULT '默认分组', payload BLOB NOT NULL, PRIMARY KEY(origin, link) ); CREATE INDEX rssStars_time ON rssStars(star_time DESC); PRAGMA user_version = 6; """) try execute(database, "COMMIT") } catch { try? execute(database, "ROLLBACK") throw error } } if version < 7 { try execute(database, "BEGIN IMMEDIATE") do { try execute(database, """ CREATE TABLE legacy_imports(key TEXT PRIMARY KEY NOT NULL); PRAGMA user_version = 7; """) if version > 0, try scalarInt32(database, "SELECT COUNT(*) FROM chapters") > 0 { try execute(database, "INSERT INTO legacy_imports(key) VALUES('chapters')") } try execute(database, "COMMIT") } catch { try? execute(database, "ROLLBACK") throw error } } } private func legacyChaptersImported() -> Bool { (try? Self.scalarInt32(handle, "SELECT COUNT(*) FROM legacy_imports WHERE key = 'chapters'")) == 1 } private func load(_ sql: String, as type: T.Type, bindings: [String] = []) throws -> [T] { let statement = try prepare(sql) defer { sqlite3_finalize(statement) } for (index, value) in bindings.enumerated() { try bind(value, to: statement, at: Int32(index + 1)) } return try decodeRows(statement, as: type) } private func decodeRows(_ statement: OpaquePointer, as type: T.Type) throws -> [T] { var result: [T] = [] while true { let status = sqlite3_step(statement) if status == SQLITE_DONE { return result } guard status == SQLITE_ROW else { throw databaseError(.execute) } guard let bytes = sqlite3_column_blob(statement, 0) else { throw LegadoDatabaseError.decode("数据库记录缺少payload") } let count = Int(sqlite3_column_bytes(statement, 0)) do { result.append(try decoder.decode(T.self, from: Data(bytes: bytes, count: count))) } catch { throw LegadoDatabaseError.decode(error.localizedDescription) } } } private func decodeFirst(_ statement: OpaquePointer, as type: T.Type) throws -> T? { let status = sqlite3_step(statement) if status == SQLITE_DONE { return nil } guard status == SQLITE_ROW, let bytes = sqlite3_column_blob(statement, 0) else { throw databaseError(.execute) } do { return try decoder.decode(T.self, from: Data(bytes: bytes, count: Int(sqlite3_column_bytes(statement, 0)))) } catch { throw LegadoDatabaseError.decode(error.localizedDescription) } } private func replaceAll( deleteSQL: String, insertSQL: String, values: [T], binder: (OpaquePointer, T, Int, Data) throws -> Void ) throws { try transaction { try Self.execute(handle, deleteSQL) let statement = try prepare(insertSQL) defer { sqlite3_finalize(statement) } for (position, value) in values.enumerated() { sqlite3_reset(statement) sqlite3_clear_bindings(statement) try binder(statement, value, position, try encoder.encode(value)) try stepDone(statement) } } } private func transaction(_ operation: () throws -> Void) throws { try Self.execute(handle, "BEGIN IMMEDIATE") do { try operation() try Self.execute(handle, "COMMIT") } catch { try? Self.execute(handle, "ROLLBACK") throw error } } private func count(_ table: String) throws -> Int64 { let allowed = [ "book_sources", "books", "chapters", "book_groups", "bookmarks", "read_records", "search_keywords", "caches", "cookies", "searchBooks" , "txtTocRules", "httpTTS", "ruleSubs", "keyboardAssists", "servers", "rssSources", "rssArticles", "rssReadRecords", "rssStars" ] guard allowed.contains(table) else { throw LegadoDatabaseError.execute("非法表名") } let statement = try prepare("SELECT COUNT(*) FROM \(table)") defer { sqlite3_finalize(statement) } guard sqlite3_step(statement) == SQLITE_ROW else { throw databaseError(.execute) } return sqlite3_column_int64(statement, 0) } private func prepare(_ sql: String) throws -> OpaquePointer { var statement: OpaquePointer? guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else { throw databaseError(.prepare) } return statement } private func bind(_ value: String, to statement: OpaquePointer, at index: Int32) throws { guard sqlite3_bind_text(statement, index, value, -1, Self.transient) == SQLITE_OK else { throw databaseError(.bind) } } private func bind(_ value: Data, to statement: OpaquePointer, at index: Int32) throws { let status = value.withUnsafeBytes { sqlite3_bind_blob(statement, index, $0.baseAddress, Int32($0.count), Self.transient) } guard status == SQLITE_OK else { throw databaseError(.bind) } } private func stepDone(_ statement: OpaquePointer) throws { guard sqlite3_step(statement) == SQLITE_DONE else { throw databaseError(.execute) } } private func executeBound(_ sql: String, text: String) throws { let statement = try prepare(sql) defer { sqlite3_finalize(statement) } try bind(text, to: statement, at: 1) try stepDone(statement) } private func columnString(_ statement: OpaquePointer, at index: Int32) -> String { guard let value = sqlite3_column_text(statement, index) else { return "" } return String(cString: value) } private enum ErrorKind { case execute, prepare, bind } private func databaseError(_ kind: ErrorKind) -> LegadoDatabaseError { let message = String(cString: sqlite3_errmsg(handle)) switch kind { case .execute: return LegadoDatabaseError.execute(message) case .prepare: return LegadoDatabaseError.prepare(message) case .bind: return LegadoDatabaseError.bind(message) } } private static func execute(_ database: OpaquePointer, _ sql: String) throws { var error: UnsafeMutablePointer? guard sqlite3_exec(database, sql, nil, nil, &error) == SQLITE_OK else { let message = error.map { String(cString: $0) } ?? String(cString: sqlite3_errmsg(database)) sqlite3_free(error) throw LegadoDatabaseError.execute(message) } } private static func scalarInt32(_ database: OpaquePointer, _ sql: String) throws -> Int32 { var statement: OpaquePointer? guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { throw LegadoDatabaseError.prepare(String(cString: sqlite3_errmsg(database))) } defer { sqlite3_finalize(statement) } guard sqlite3_step(statement) == SQLITE_ROW else { throw LegadoDatabaseError.execute(String(cString: sqlite3_errmsg(database))) } return sqlite3_column_int(statement, 0) } private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) } public actor SQLiteBookSourceRepository: BookSourceRepository { private let database: LegadoDatabase private let legacyFileURL: URL? public init(database: LegadoDatabase, legacyFileURL: URL? = nil) { self.database = database self.legacyFileURL = legacyFileURL } public func load() async throws -> [BookSource] { try await database.importLegacyJSON(bookSourcesURL: legacyFileURL, booksURL: nil) return try await database.loadBookSources() } public func save(_ sources: [BookSource]) async throws { try await database.replaceBookSources(sources) } } public actor SQLiteBookRepository: BookRepository { private let database: LegadoDatabase private let legacyFileURL: URL? public init(database: LegadoDatabase, legacyFileURL: URL? = nil) { self.database = database self.legacyFileURL = legacyFileURL } public func load() async throws -> [Book] { try await database.importLegacyJSON(bookSourcesURL: nil, booksURL: legacyFileURL) return try await database.loadBooks() } public func save(_ books: [Book]) async throws { try await database.replaceBooks(books) } } public actor SQLiteBookChapterRepository: BookChapterRepository { private let database: LegadoDatabase private let legacyDirectory: URL? public init(database: LegadoDatabase, legacyDirectory: URL? = nil) { self.database = database self.legacyDirectory = legacyDirectory } public func load(bookURL: String) async throws -> [BookChapter] { try await database.importLegacyJSON( bookSourcesURL: nil, booksURL: nil, chaptersDirectory: legacyDirectory ) return try await database.loadChapters(bookURL: bookURL) } public func save(_ chapters: [BookChapter], bookURL: String) async throws { try await database.replaceChapters(chapters, bookURL: bookURL) } public func remove(bookURL: String) async throws { try await database.removeChapters(bookURL: bookURL) } } public actor SQLiteReadingActivityRepository: ReadingActivityRepository { private let database: LegadoDatabase private let legacyBookmarksURL: URL? private let legacyRecordsURL: URL? public init( database: LegadoDatabase, legacyBookmarksURL: URL? = nil, legacyRecordsURL: URL? = nil ) { self.database = database self.legacyBookmarksURL = legacyBookmarksURL self.legacyRecordsURL = legacyRecordsURL } public func loadBookmarks() async throws -> [Bookmark] { try await migrate() return try await database.loadBookmarks() } public func saveBookmarks(_ bookmarks: [Bookmark]) async throws { try await database.replaceBookmarks(bookmarks) } public func loadReadRecords() async throws -> [ReadRecord] { try await migrate() return try await database.loadReadRecords() } public func saveReadRecords(_ records: [ReadRecord]) async throws { try await database.replaceReadRecords(records) } private func migrate() async throws { try await database.importLegacyJSON( bookSourcesURL: nil, booksURL: nil, bookmarksURL: legacyBookmarksURL, readRecordsURL: legacyRecordsURL ) } } public actor SQLiteBookGroupRepository: BookGroupRepository { private let database: LegadoDatabase private let legacyFileURL: URL? public init(database: LegadoDatabase, legacyFileURL: URL? = nil) { self.database = database self.legacyFileURL = legacyFileURL } public func load() async throws -> [BookGroup] { try await database.importLegacyJSON( bookSourcesURL: nil, booksURL: nil, bookGroupsURL: legacyFileURL ) return try await database.loadBookGroups() } public func save(_ groups: [BookGroup]) async throws { try await database.replaceBookGroups(groups) } }