mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
567 lines
24 KiB
Swift
567 lines
24 KiB
Swift
import Foundation
|
|
import SQLite3
|
|
|
|
public struct SearchKeyword: Codable, Hashable, Identifiable, Sendable {
|
|
public var word: String
|
|
public var usage: Int
|
|
public var lastUseTime: Int64
|
|
public var id: String { word }
|
|
|
|
public init(word: String, usage: Int = 1, lastUseTime: Int64? = nil) {
|
|
self.word = word
|
|
self.usage = usage
|
|
self.lastUseTime = lastUseTime ?? Int64(Date().timeIntervalSince1970 * 1_000)
|
|
}
|
|
}
|
|
|
|
public struct LegadoCache: Codable, Hashable, Identifiable, Sendable {
|
|
public var key: String
|
|
public var value: String?
|
|
public var deadline: Int64
|
|
public var id: String { key }
|
|
|
|
public init(key: String, value: String? = nil, deadline: Int64 = 0) {
|
|
self.key = key
|
|
self.value = value
|
|
self.deadline = deadline
|
|
}
|
|
}
|
|
|
|
public struct LegadoCookie: Codable, Hashable, Identifiable, Sendable {
|
|
public var url: String
|
|
public var cookie: String
|
|
public var id: String { url }
|
|
|
|
public init(url: String, cookie: String = "") {
|
|
self.url = url
|
|
self.cookie = cookie
|
|
}
|
|
}
|
|
|
|
public struct TxtTocRule: Codable, Hashable, Identifiable, Sendable {
|
|
public var id: Int64
|
|
public var name: String
|
|
public var rule: String
|
|
public var replacement: String
|
|
public var example: String?
|
|
public var serialNumber: Int
|
|
public var enable: Bool
|
|
public init(
|
|
id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000),
|
|
name: String = "",
|
|
rule: String = "",
|
|
replacement: String = "",
|
|
example: String? = nil,
|
|
serialNumber: Int = -1,
|
|
enable: Bool = true
|
|
) {
|
|
self.id = id; self.name = name; self.rule = rule; self.replacement = replacement
|
|
self.example = example; self.serialNumber = serialNumber; self.enable = enable
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id, name, rule, replacement, example, serialNumber, enable
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let value = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try value.decodeIfPresent(Int64.self, forKey: .id) ?? Int64(Date().timeIntervalSince1970 * 1_000)
|
|
name = try value.decodeIfPresent(String.self, forKey: .name) ?? ""
|
|
rule = try value.decodeIfPresent(String.self, forKey: .rule) ?? ""
|
|
replacement = try value.decodeIfPresent(String.self, forKey: .replacement) ?? ""
|
|
example = try value.decodeIfPresent(String.self, forKey: .example)
|
|
serialNumber = try value.decodeIfPresent(Int.self, forKey: .serialNumber) ?? -1
|
|
enable = try value.decodeIfPresent(Bool.self, forKey: .enable) ?? true
|
|
}
|
|
}
|
|
|
|
public struct HttpTTS: Codable, Hashable, Identifiable, Sendable {
|
|
public var id: Int64
|
|
public var name: String
|
|
public var url: String
|
|
public var contentType: String?
|
|
public var concurrentRate: String?
|
|
public var loginUrl: String?
|
|
public var loginUi: String?
|
|
public var header: String?
|
|
public var jsLib: String?
|
|
public var enabledCookieJar: Bool?
|
|
public var loginCheckJs: String?
|
|
public var lastUpdateTime: Int64
|
|
public init(id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000), name: String = "", url: String = "") {
|
|
self.id = id; self.name = name; self.url = url; contentType = nil; concurrentRate = "0"
|
|
loginUrl = nil; loginUi = nil; header = nil; jsLib = nil; enabledCookieJar = false
|
|
loginCheckJs = nil; lastUpdateTime = id
|
|
}
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
|
let now = Int64(Date().timeIntervalSince1970 * 1_000)
|
|
id = (try? values.decode(Int64.self, forKey: .id))
|
|
?? Int64((try? values.decode(String.self, forKey: .id)) ?? "")
|
|
?? now
|
|
name = try values.decodeIfPresent(String.self, forKey: .name) ?? ""
|
|
url = try values.decodeIfPresent(String.self, forKey: .url) ?? ""
|
|
contentType = try values.decodeIfPresent(String.self, forKey: .contentType)
|
|
concurrentRate = try values.decodeIfPresent(String.self, forKey: .concurrentRate)
|
|
loginUrl = try values.decodeIfPresent(String.self, forKey: .loginUrl)
|
|
if let text = try? values.decodeIfPresent(String.self, forKey: .loginUi) {
|
|
loginUi = text
|
|
} else if let json = try? values.decodeIfPresent(JSONValue.self, forKey: .loginUi) {
|
|
loginUi = (try? JSONEncoder().encode(json)).flatMap { String(data: $0, encoding: .utf8) }
|
|
} else {
|
|
loginUi = nil
|
|
}
|
|
header = try values.decodeIfPresent(String.self, forKey: .header)
|
|
jsLib = try values.decodeIfPresent(String.self, forKey: .jsLib)
|
|
enabledCookieJar = try values.decodeIfPresent(Bool.self, forKey: .enabledCookieJar) ?? false
|
|
loginCheckJs = try values.decodeIfPresent(String.self, forKey: .loginCheckJs)
|
|
lastUpdateTime = (try? values.decode(Int64.self, forKey: .lastUpdateTime))
|
|
?? Int64((try? values.decode(String.self, forKey: .lastUpdateTime)) ?? "")
|
|
?? now
|
|
}
|
|
}
|
|
|
|
public struct RuleSub: Codable, Hashable, Identifiable, Sendable {
|
|
public var id: Int64
|
|
public var name: String
|
|
public var url: String
|
|
public var type: Int
|
|
public var customOrder: Int
|
|
public var autoUpdate: Bool
|
|
public var update: Int64
|
|
public var updateInterval: Int
|
|
public var silentUpdate: Bool
|
|
public var js: String?
|
|
public var showRule: String?
|
|
public var sourceUrl: String?
|
|
public init(id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000), name: String = "", url: String = "") {
|
|
self.id = id; self.name = name; self.url = url; type = 0; customOrder = 0
|
|
autoUpdate = false; update = id; updateInterval = 0; silentUpdate = false
|
|
js = nil; showRule = nil; sourceUrl = nil
|
|
}
|
|
}
|
|
|
|
public struct KeyboardAssist: Codable, Hashable, Identifiable, Sendable {
|
|
public var type: Int
|
|
public var key: String
|
|
public var value: String
|
|
public var serialNo: Int
|
|
public var id: String { "\(type)\u{0}\(key)" }
|
|
public init(type: Int = 0, key: String, value: String, serialNo: Int = 0) {
|
|
self.type = type; self.key = key; self.value = value; self.serialNo = serialNo
|
|
}
|
|
}
|
|
|
|
public struct Server: Codable, Hashable, Identifiable, Sendable {
|
|
public enum ServerType: String, Codable, Sendable { case webDAV = "WEBDAV" }
|
|
public struct WebDAVConfig: Codable, Hashable, Sendable {
|
|
public var url: String
|
|
public var username: String
|
|
public var password: String
|
|
public init(url: String, username: String, password: String) {
|
|
self.url = url; self.username = username; self.password = password
|
|
}
|
|
}
|
|
public var id: Int64
|
|
public var name: String
|
|
public var type: ServerType
|
|
public var config: String?
|
|
public var sortNumber: Int
|
|
public init(id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000), name: String = "", type: ServerType = .webDAV) {
|
|
self.id = id; self.name = name; self.type = type; config = nil; sortNumber = 0
|
|
}
|
|
public var webDAVConfig: WebDAVConfig? {
|
|
guard type == .webDAV, let config else { return nil }
|
|
return try? JSONDecoder().decode(WebDAVConfig.self, from: Data(config.utf8))
|
|
}
|
|
}
|
|
|
|
public enum SearchKeywordOrder: Sendable { case usage, lastUseTime }
|
|
|
|
public protocol SearchKeywordRepository: Sendable {
|
|
func load(order: SearchKeywordOrder) async throws -> [SearchKeyword]
|
|
@discardableResult func record(_ word: String, at time: Int64) async throws -> SearchKeyword
|
|
func delete(_ word: String) async throws
|
|
func deleteAll() async throws
|
|
}
|
|
|
|
public actor SQLiteSearchKeywordRepository: SearchKeywordRepository {
|
|
private let database: LegadoDatabase
|
|
private let legacyWords: [String]
|
|
private var migratedLegacy = false
|
|
|
|
public init(database: LegadoDatabase, legacyWords: [String] = []) {
|
|
self.database = database
|
|
self.legacyWords = legacyWords
|
|
}
|
|
|
|
public func load(order: SearchKeywordOrder) async throws -> [SearchKeyword] {
|
|
try await migrateLegacyIfNeeded()
|
|
return try await database.loadSearchKeywords(order: order)
|
|
}
|
|
|
|
public func record(_ word: String, at time: Int64) async throws -> SearchKeyword {
|
|
try await migrateLegacyIfNeeded()
|
|
return try await database.recordSearchKeyword(word, at: time)
|
|
}
|
|
|
|
public func delete(_ word: String) async throws { try await database.deleteSearchKeyword(word) }
|
|
public func deleteAll() async throws { try await database.deleteAllSearchKeywords() }
|
|
|
|
private func migrateLegacyIfNeeded() async throws {
|
|
guard !migratedLegacy else { return }
|
|
migratedLegacy = true
|
|
try await database.importLegacySearchKeywords(legacyWords)
|
|
}
|
|
}
|
|
|
|
public actor InMemorySearchKeywordRepository: SearchKeywordRepository {
|
|
private var values: [String: SearchKeyword]
|
|
public init(values: [SearchKeyword] = []) {
|
|
self.values = Dictionary(uniqueKeysWithValues: values.map { ($0.word, $0) })
|
|
}
|
|
public func load(order: SearchKeywordOrder) -> [SearchKeyword] {
|
|
values.values.sorted {
|
|
switch order {
|
|
case .usage:
|
|
$0.usage == $1.usage ? $0.lastUseTime > $1.lastUseTime : $0.usage > $1.usage
|
|
case .lastUseTime:
|
|
$0.lastUseTime == $1.lastUseTime ? $0.usage > $1.usage : $0.lastUseTime > $1.lastUseTime
|
|
}
|
|
}
|
|
}
|
|
public func record(_ word: String, at time: Int64) -> SearchKeyword {
|
|
var value = values[word] ?? .init(word: word, usage: 0, lastUseTime: time)
|
|
value.usage += 1
|
|
value.lastUseTime = time
|
|
values[word] = value
|
|
return value
|
|
}
|
|
public func delete(_ word: String) { values[word] = nil }
|
|
public func deleteAll() { values.removeAll() }
|
|
}
|
|
|
|
public protocol LegadoCacheRepository: Sendable {
|
|
func value(for key: String, now: Int64) async throws -> String?
|
|
func put(_ cache: LegadoCache) async throws
|
|
func delete(_ key: String) async throws
|
|
func deleteSourceVariables(_ sourceKey: String) async throws
|
|
func clearExpired(now: Int64) async throws
|
|
}
|
|
|
|
public actor SQLiteLegadoCacheRepository: LegadoCacheRepository {
|
|
private let database: LegadoDatabase
|
|
public init(database: LegadoDatabase) { self.database = database }
|
|
public func value(for key: String, now: Int64) async throws -> String? {
|
|
try await database.cacheValue(for: key, now: now)
|
|
}
|
|
public func put(_ cache: LegadoCache) async throws { try await database.putCache(cache) }
|
|
public func delete(_ key: String) async throws { try await database.deleteCache(key) }
|
|
public func deleteSourceVariables(_ sourceKey: String) async throws {
|
|
try await database.deleteSourceVariables(sourceKey)
|
|
}
|
|
public func clearExpired(now: Int64) async throws { try await database.clearExpiredCaches(now: now) }
|
|
}
|
|
|
|
public protocol LegadoCookieRepository: Sendable {
|
|
func get(_ url: String) async throws -> LegadoCookie?
|
|
func getOkHTTP() async throws -> [LegadoCookie]
|
|
func put(_ cookie: LegadoCookie) async throws
|
|
func delete(_ url: String) async throws
|
|
func deleteOkHTTP() async throws
|
|
}
|
|
|
|
public protocol SearchBookRepository: Sendable {
|
|
func cachedMatches(keyword: String) async throws -> [SearchBook]
|
|
func get(bookURL: String) async throws -> SearchBook?
|
|
func first(name: String, author: String) async throws -> SearchBook?
|
|
func changeSources(name: String, author: String, sourceGroup: String, key: String?) async throws -> [SearchBook]
|
|
func enabledWithCover(name: String, author: String) async throws -> [SearchBook]
|
|
func put(_ books: [SearchBook]) async throws
|
|
func clear(name: String, author: String) async throws
|
|
func clearExpired(before time: Int64) async throws
|
|
}
|
|
|
|
extension SearchBookRepository {
|
|
public func cachedMatches(keyword: String) async throws -> [SearchBook] {
|
|
try await changeSources(name: keyword, author: "", sourceGroup: "", key: nil)
|
|
}
|
|
}
|
|
|
|
public protocol ConfigurationRepository: Sendable {
|
|
func loadTxtTocRules() async throws -> [TxtTocRule]
|
|
func saveTxtTocRules(_ values: [TxtTocRule]) async throws
|
|
func loadHttpTTS() async throws -> [HttpTTS]
|
|
func saveHttpTTS(_ values: [HttpTTS]) async throws
|
|
func loadRuleSubs() async throws -> [RuleSub]
|
|
func saveRuleSubs(_ values: [RuleSub]) async throws
|
|
func loadKeyboardAssists() async throws -> [KeyboardAssist]
|
|
func saveKeyboardAssists(_ values: [KeyboardAssist]) async throws
|
|
func loadServers() async throws -> [Server]
|
|
func saveServers(_ values: [Server]) async throws
|
|
}
|
|
|
|
public actor SQLiteConfigurationRepository: ConfigurationRepository {
|
|
private let database: LegadoDatabase
|
|
public init(database: LegadoDatabase) { self.database = database }
|
|
public func loadTxtTocRules() async throws -> [TxtTocRule] { try await database.loadTxtTocRules() }
|
|
public func saveTxtTocRules(_ values: [TxtTocRule]) async throws { try await database.replaceTxtTocRules(values) }
|
|
public func loadHttpTTS() async throws -> [HttpTTS] { try await database.loadHttpTTS() }
|
|
public func saveHttpTTS(_ values: [HttpTTS]) async throws { try await database.replaceHttpTTS(values) }
|
|
public func loadRuleSubs() async throws -> [RuleSub] { try await database.loadRuleSubs() }
|
|
public func saveRuleSubs(_ values: [RuleSub]) async throws { try await database.replaceRuleSubs(values) }
|
|
public func loadKeyboardAssists() async throws -> [KeyboardAssist] { try await database.loadKeyboardAssists() }
|
|
public func saveKeyboardAssists(_ values: [KeyboardAssist]) async throws { try await database.replaceKeyboardAssists(values) }
|
|
public func loadServers() async throws -> [Server] { try await database.loadServers() }
|
|
public func saveServers(_ values: [Server]) async throws { try await database.replaceServers(values) }
|
|
}
|
|
|
|
public actor SQLiteSearchBookRepository: SearchBookRepository {
|
|
private let database: LegadoDatabase
|
|
public init(database: LegadoDatabase) { self.database = database }
|
|
|
|
public func cachedMatches(keyword: String) async throws -> [SearchBook] {
|
|
try await database.cachedSearchMatches(keyword: keyword)
|
|
}
|
|
|
|
public func get(bookURL: String) async throws -> SearchBook? {
|
|
let books = try await database.loadSearchBooks()
|
|
return books.first { $0.bookURL == bookURL }
|
|
}
|
|
|
|
public func first(name: String, author: String) async throws -> SearchBook? {
|
|
let books = try await database.loadSearchBooks()
|
|
let sources = try await database.loadBookSources()
|
|
let sourceIDs = Set(sources.map(\.bookSourceUrl))
|
|
return books
|
|
.filter { $0.name == name && $0.author == author }
|
|
.filter { sourceIDs.contains($0.origin) }
|
|
.sorted { $0.originOrder < $1.originOrder }
|
|
.first
|
|
}
|
|
|
|
public func changeSources(
|
|
name: String,
|
|
author: String,
|
|
sourceGroup: String,
|
|
key: String?
|
|
) async throws -> [SearchBook] {
|
|
let sources = try await database.loadBookSources()
|
|
let sourceMap = Dictionary(uniqueKeysWithValues: sources.map { ($0.bookSourceUrl, $0) })
|
|
let books = try await database.loadSearchBooks()
|
|
return books.compactMap { book in
|
|
guard book.name == name,
|
|
book.author.localizedCaseInsensitiveContains(author),
|
|
let source = sourceMap[book.origin], source.enabled,
|
|
sourceGroup.isEmpty || (source.bookSourceGroup ?? "").contains(sourceGroup) else { return nil }
|
|
if let key, !key.isEmpty,
|
|
!book.originName.localizedCaseInsensitiveContains(key),
|
|
!(book.latestChapterTitle ?? "").localizedCaseInsensitiveContains(key) { return nil }
|
|
var value = book
|
|
value.originOrder = source.customOrder
|
|
return value
|
|
}.sorted { $0.originOrder < $1.originOrder }
|
|
}
|
|
|
|
public func enabledWithCover(name: String, author: String) async throws -> [SearchBook] {
|
|
let sources = try await database.loadBookSources()
|
|
let sourceMap = Dictionary(uniqueKeysWithValues: sources.map { ($0.bookSourceUrl, $0) })
|
|
let books = try await database.loadSearchBooks()
|
|
return books.compactMap { book in
|
|
guard book.name == name, book.author == author,
|
|
!(book.coverURL ?? "").isEmpty,
|
|
let source = sourceMap[book.origin], source.enabled else { return nil }
|
|
var value = book
|
|
value.originOrder = source.customOrder
|
|
return value
|
|
}.sorted { $0.originOrder < $1.originOrder }
|
|
}
|
|
|
|
public func put(_ books: [SearchBook]) async throws { try await database.putSearchBooks(books) }
|
|
public func clear(name: String, author: String) async throws {
|
|
try await database.deleteSearchBooks(name: name, author: author)
|
|
}
|
|
public func clearExpired(before time: Int64) async throws {
|
|
try await database.clearExpiredSearchBooks(before: time)
|
|
}
|
|
}
|
|
|
|
public actor SQLiteLegadoCookieRepository: LegadoCookieRepository {
|
|
private let database: LegadoDatabase
|
|
public init(database: LegadoDatabase) { self.database = database }
|
|
public func get(_ url: String) async throws -> LegadoCookie? { try await database.cookie(for: url) }
|
|
public func getOkHTTP() async throws -> [LegadoCookie] { try await database.loadOkHTTPCookies() }
|
|
public func put(_ cookie: LegadoCookie) async throws { try await database.putCookie(cookie) }
|
|
public func delete(_ url: String) async throws { try await database.deleteCookie(url) }
|
|
public func deleteOkHTTP() async throws { try await database.deleteOkHTTPCookies() }
|
|
}
|
|
|
|
public enum SearchKeywordRepositoryFactory {
|
|
public static func live(defaults: UserDefaults = .standard) -> any SearchKeywordRepository {
|
|
guard let database = try? LegadoDatabase(url: LegadoDatabase.defaultURL()) else {
|
|
return InMemorySearchKeywordRepository()
|
|
}
|
|
return SQLiteSearchKeywordRepository(
|
|
database: database,
|
|
legacyWords: defaults.stringArray(forKey: "bookSearchHistory") ?? []
|
|
)
|
|
}
|
|
}
|
|
|
|
public enum SearchBookRepositoryFactory {
|
|
public static func live() -> any SearchBookRepository {
|
|
guard let database = try? LegadoDatabase(url: LegadoDatabase.defaultURL()) else {
|
|
return InMemorySearchBookRepository()
|
|
}
|
|
return SQLiteSearchBookRepository(database: database)
|
|
}
|
|
}
|
|
|
|
public final class PersistentRuntimeCache: @unchecked Sendable {
|
|
public static let shared = PersistentRuntimeCache()
|
|
private let lock = NSLock()
|
|
private var handle: OpaquePointer?
|
|
|
|
public init(url: URL = LegadoDatabase.defaultURL()) {
|
|
let migrator = try? LegadoDatabase(url: url)
|
|
_ = migrator
|
|
sqlite3_open_v2(
|
|
url.path,
|
|
&handle,
|
|
SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX,
|
|
nil
|
|
)
|
|
if let handle { sqlite3_busy_timeout(handle, 5_000) }
|
|
}
|
|
|
|
deinit { if let handle { sqlite3_close(handle) } }
|
|
|
|
public func get(_ key: String, now: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> String? {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
guard let handle else { return nil }
|
|
var statement: OpaquePointer?
|
|
guard sqlite3_prepare_v2(
|
|
handle,
|
|
"SELECT value FROM caches WHERE `key` = ? AND (deadline = 0 OR deadline > ?)",
|
|
-1,
|
|
&statement,
|
|
nil
|
|
) == SQLITE_OK, let statement else { return nil }
|
|
defer { sqlite3_finalize(statement) }
|
|
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
|
|
sqlite3_bind_int64(statement, 2, now)
|
|
guard sqlite3_step(statement) == SQLITE_ROW,
|
|
let value = sqlite3_column_text(statement, 0) else { return nil }
|
|
return String(cString: value)
|
|
}
|
|
|
|
public func put(_ key: String, value: String, deadline: Int64 = 0) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
guard let handle else { return }
|
|
var statement: OpaquePointer?
|
|
guard sqlite3_prepare_v2(
|
|
handle,
|
|
"""
|
|
INSERT INTO caches(`key`, value, deadline) VALUES(?, ?, ?)
|
|
ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, deadline = excluded.deadline
|
|
""",
|
|
-1,
|
|
&statement,
|
|
nil
|
|
) == SQLITE_OK, let statement else { return }
|
|
defer { sqlite3_finalize(statement) }
|
|
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
|
|
sqlite3_bind_text(statement, 2, value, -1, Self.transient)
|
|
sqlite3_bind_int64(statement, 3, deadline)
|
|
sqlite3_step(statement)
|
|
}
|
|
|
|
public func cookie(for key: String) -> String? {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
guard let handle else { return nil }
|
|
var statement: OpaquePointer?
|
|
guard sqlite3_prepare_v2(handle, "SELECT cookie FROM cookies WHERE url = ?", -1, &statement, nil) == SQLITE_OK,
|
|
let statement else { return nil }
|
|
defer { sqlite3_finalize(statement) }
|
|
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
|
|
guard sqlite3_step(statement) == SQLITE_ROW, let value = sqlite3_column_text(statement, 0) else { return nil }
|
|
return String(cString: value)
|
|
}
|
|
|
|
public func putCookie(key: String, value: String) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
guard let handle else { return }
|
|
var statement: OpaquePointer?
|
|
guard sqlite3_prepare_v2(
|
|
handle,
|
|
"INSERT INTO cookies(url, cookie) VALUES(?, ?) ON CONFLICT(url) DO UPDATE SET cookie = excluded.cookie",
|
|
-1,
|
|
&statement,
|
|
nil
|
|
) == SQLITE_OK, let statement else { return }
|
|
defer { sqlite3_finalize(statement) }
|
|
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
|
|
sqlite3_bind_text(statement, 2, value, -1, Self.transient)
|
|
sqlite3_step(statement)
|
|
}
|
|
|
|
public func deleteCookie(key: String) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
guard let handle else { return }
|
|
var statement: OpaquePointer?
|
|
guard sqlite3_prepare_v2(handle, "DELETE FROM cookies WHERE url = ?", -1, &statement, nil) == SQLITE_OK,
|
|
let statement else { return }
|
|
defer { sqlite3_finalize(statement) }
|
|
sqlite3_bind_text(statement, 1, key, -1, Self.transient)
|
|
sqlite3_step(statement)
|
|
}
|
|
|
|
private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
|
}
|
|
|
|
public actor InMemorySearchBookRepository: SearchBookRepository {
|
|
private var values: [String: SearchBook] = [:]
|
|
public init(values: [SearchBook] = []) {
|
|
self.values = Dictionary(uniqueKeysWithValues: values.map { ($0.bookURL, $0) })
|
|
}
|
|
public func get(bookURL: String) -> SearchBook? { values[bookURL] }
|
|
public func cachedMatches(keyword: String) async throws -> [SearchBook] {
|
|
guard !keyword.isEmpty else { return [] }
|
|
return Array(values.values.filter {
|
|
$0.name.localizedCaseInsensitiveContains(keyword) || $0.author.localizedCaseInsensitiveContains(keyword)
|
|
}.sorted {
|
|
let lhsExact = $0.name == keyword || $0.author == keyword
|
|
let rhsExact = $1.name == keyword || $1.author == keyword
|
|
if lhsExact != rhsExact { return lhsExact }
|
|
return $0.time > $1.time
|
|
}.prefix(200))
|
|
}
|
|
public func first(name: String, author: String) -> SearchBook? {
|
|
values.values.filter { $0.name == name && $0.author == author }
|
|
.sorted { $0.originOrder < $1.originOrder }.first
|
|
}
|
|
public func changeSources(name: String, author: String, sourceGroup: String, key: String?) -> [SearchBook] {
|
|
values.values.filter {
|
|
$0.name == name && $0.author.localizedCaseInsensitiveContains(author)
|
|
&& (key?.isEmpty != false
|
|
|| $0.originName.localizedCaseInsensitiveContains(key ?? "")
|
|
|| ($0.latestChapterTitle ?? "").localizedCaseInsensitiveContains(key ?? ""))
|
|
}.sorted { $0.originOrder < $1.originOrder }
|
|
}
|
|
public func enabledWithCover(name: String, author: String) -> [SearchBook] {
|
|
values.values.filter { $0.name == name && $0.author == author && !($0.coverURL ?? "").isEmpty }
|
|
.sorted { $0.originOrder < $1.originOrder }
|
|
}
|
|
public func put(_ books: [SearchBook]) { for book in books { values[book.bookURL] = book } }
|
|
public func clear(name: String, author: String) {
|
|
values = values.filter { $0.value.name != name || $0.value.author != author }
|
|
}
|
|
public func clearExpired(before time: Int64) { values = values.filter { $0.value.time >= time } }
|
|
}
|