mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 04:41:23 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
import Foundation
|
||||
|
||||
public struct BookSource: Codable, Hashable, Identifiable, Sendable {
|
||||
public var bookSourceUrl: String
|
||||
public var bookSourceName: String
|
||||
public var bookSourceGroup: String?
|
||||
public var bookSourceType: Int
|
||||
public var bookUrlPattern: String?
|
||||
public var customOrder: Int
|
||||
public var enabled: Bool
|
||||
public var enabledExplore: Bool
|
||||
public var jsLib: String?
|
||||
public var enabledCookieJar: Bool?
|
||||
public var concurrentRate: String?
|
||||
public var header: String?
|
||||
public var loginUrl: String?
|
||||
public var loginUi: String?
|
||||
public var loginCheckJs: String?
|
||||
public var coverDecodeJs: String?
|
||||
public var bookSourceComment: String?
|
||||
public var variableComment: String?
|
||||
public var lastUpdateTime: Int64
|
||||
public var respondTime: Int64
|
||||
public var weight: Int
|
||||
public var exploreUrl: String?
|
||||
public var exploreScreen: String?
|
||||
public var ruleExplore: ExploreRule?
|
||||
public var searchUrl: String?
|
||||
public var ruleSearch: SearchRule?
|
||||
public var ruleBookInfo: BookInfoRule?
|
||||
public var ruleToc: TocRule?
|
||||
public var ruleContent: ContentRule?
|
||||
public var ruleReview: ReviewRule?
|
||||
public var eventListener: Bool
|
||||
public var customButton: Bool
|
||||
|
||||
public var additionalFields: [String: JSONValue] = [:]
|
||||
|
||||
public var id: String { bookSourceUrl }
|
||||
|
||||
public init(bookSourceUrl: String, bookSourceName: String) {
|
||||
self.bookSourceUrl = bookSourceUrl
|
||||
self.bookSourceName = bookSourceName
|
||||
bookSourceGroup = nil
|
||||
bookSourceType = 0
|
||||
bookUrlPattern = nil
|
||||
customOrder = 0
|
||||
enabled = true
|
||||
enabledExplore = true
|
||||
jsLib = nil
|
||||
enabledCookieJar = true
|
||||
concurrentRate = nil
|
||||
header = nil
|
||||
loginUrl = nil
|
||||
loginUi = nil
|
||||
loginCheckJs = nil
|
||||
coverDecodeJs = nil
|
||||
bookSourceComment = nil
|
||||
variableComment = nil
|
||||
lastUpdateTime = 0
|
||||
respondTime = 180_000
|
||||
weight = 0
|
||||
exploreUrl = nil
|
||||
exploreScreen = nil
|
||||
ruleExplore = nil
|
||||
searchUrl = nil
|
||||
ruleSearch = nil
|
||||
ruleBookInfo = nil
|
||||
ruleToc = nil
|
||||
ruleContent = nil
|
||||
ruleReview = nil
|
||||
eventListener = false
|
||||
customButton = false
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey, CaseIterable {
|
||||
case bookSourceUrl, bookSourceName, bookSourceGroup, bookSourceType, bookUrlPattern, customOrder, enabled, enabledExplore, jsLib, enabledCookieJar, concurrentRate, header, loginUrl, loginUi, loginCheckJs, coverDecodeJs, bookSourceComment, variableComment, lastUpdateTime, respondTime, weight, exploreUrl, exploreScreen, ruleExplore, searchUrl, ruleSearch, ruleBookInfo, ruleToc, ruleContent, ruleReview, eventListener, customButton
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
bookSourceUrl = try values.decodeIfPresent(String.self, forKey: .bookSourceUrl) ?? ""
|
||||
bookSourceName = try values.decodeIfPresent(String.self, forKey: .bookSourceName) ?? ""
|
||||
bookSourceGroup = try values.decodeIfPresent(String.self, forKey: .bookSourceGroup)
|
||||
bookSourceType = try values.decodeLossyInt(forKey: .bookSourceType) ?? 0
|
||||
bookUrlPattern = try values.decodeIfPresent(String.self, forKey: .bookUrlPattern)
|
||||
customOrder = try values.decodeLossyInt(forKey: .customOrder) ?? 0
|
||||
enabled = try values.decodeLossyBool(forKey: .enabled) ?? true
|
||||
enabledExplore = try values.decodeLossyBool(forKey: .enabledExplore) ?? true
|
||||
jsLib = try values.decodeIfPresent(String.self, forKey: .jsLib)
|
||||
enabledCookieJar = values.contains(.enabledCookieJar)
|
||||
? try values.decodeLossyBool(forKey: .enabledCookieJar)
|
||||
: true
|
||||
concurrentRate = try values.decodeIfPresent(String.self, forKey: .concurrentRate)
|
||||
header = try values.decodeIfPresent(String.self, forKey: .header)
|
||||
loginUrl = try values.decodeIfPresent(String.self, forKey: .loginUrl)
|
||||
loginUi = try values.decodeIfPresent(String.self, forKey: .loginUi)
|
||||
loginCheckJs = try values.decodeIfPresent(String.self, forKey: .loginCheckJs)
|
||||
coverDecodeJs = try values.decodeIfPresent(String.self, forKey: .coverDecodeJs)
|
||||
bookSourceComment = try values.decodeIfPresent(String.self, forKey: .bookSourceComment)
|
||||
variableComment = try values.decodeIfPresent(String.self, forKey: .variableComment)
|
||||
lastUpdateTime = try values.decodeLossyInt64(forKey: .lastUpdateTime) ?? 0
|
||||
respondTime = try values.decodeLossyInt64(forKey: .respondTime) ?? 180_000
|
||||
weight = try values.decodeLossyInt(forKey: .weight) ?? 0
|
||||
exploreUrl = try values.decodeIfPresent(String.self, forKey: .exploreUrl)
|
||||
exploreScreen = try values.decodeIfPresent(String.self, forKey: .exploreScreen)
|
||||
ruleExplore = try values.decodeFlexible(ExploreRule.self, forKey: .ruleExplore)
|
||||
searchUrl = try values.decodeIfPresent(String.self, forKey: .searchUrl)
|
||||
ruleSearch = try values.decodeFlexible(SearchRule.self, forKey: .ruleSearch)
|
||||
ruleBookInfo = try values.decodeFlexible(BookInfoRule.self, forKey: .ruleBookInfo)
|
||||
ruleToc = try values.decodeFlexible(TocRule.self, forKey: .ruleToc)
|
||||
ruleContent = try values.decodeFlexible(ContentRule.self, forKey: .ruleContent)
|
||||
ruleReview = try values.decodeFlexible(ReviewRule.self, forKey: .ruleReview)
|
||||
eventListener = try values.decodeLossyBool(forKey: .eventListener) ?? false
|
||||
customButton = try values.decodeLossyBool(forKey: .customButton) ?? false
|
||||
if case .object(var raw) = try JSONValue(from: decoder) {
|
||||
for key in CodingKeys.allCases {
|
||||
if BookSourceEditorDocument.ruleKeys.contains(key.rawValue) {
|
||||
var nested: [String: JSONValue] = [:]
|
||||
var value = raw[key.rawValue]
|
||||
if case .string(let text) = value { value = try? JSONValue.decode(data: Data(text.utf8)) }
|
||||
if case .object(let object) = value { nested = object }
|
||||
for field in BookSourceEditorSchema.sections.flatMap(\.fields) where field.path.hasPrefix(key.rawValue + ".") {
|
||||
nested.removeValue(forKey: String(field.path.split(separator: ".")[1]))
|
||||
}
|
||||
raw[key.rawValue] = nested.isEmpty ? nil : .object(nested)
|
||||
} else { raw.removeValue(forKey: key.rawValue) }
|
||||
}
|
||||
additionalFields = raw
|
||||
}
|
||||
}
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var values = encoder.container(keyedBy: SourceCodingKey.self)
|
||||
for (key, value) in additionalFields { try values.encode(value, forKey: SourceCodingKey(key)) }
|
||||
try values.encode(bookSourceUrl, forKey: SourceCodingKey("bookSourceUrl"))
|
||||
try values.encode(bookSourceName, forKey: SourceCodingKey("bookSourceName"))
|
||||
try values.encodeIfPresent(bookSourceGroup, forKey: SourceCodingKey("bookSourceGroup"))
|
||||
try values.encode(bookSourceType, forKey: SourceCodingKey("bookSourceType"))
|
||||
try values.encodeIfPresent(bookUrlPattern, forKey: SourceCodingKey("bookUrlPattern"))
|
||||
try values.encode(customOrder, forKey: SourceCodingKey("customOrder"))
|
||||
try values.encode(enabled, forKey: SourceCodingKey("enabled"))
|
||||
try values.encode(enabledExplore, forKey: SourceCodingKey("enabledExplore"))
|
||||
try values.encodeIfPresent(jsLib, forKey: SourceCodingKey("jsLib"))
|
||||
try values.encode(enabledCookieJar, forKey: SourceCodingKey("enabledCookieJar"))
|
||||
try values.encodeIfPresent(concurrentRate, forKey: SourceCodingKey("concurrentRate"))
|
||||
try values.encodeIfPresent(header, forKey: SourceCodingKey("header"))
|
||||
try values.encodeIfPresent(loginUrl, forKey: SourceCodingKey("loginUrl"))
|
||||
try values.encodeIfPresent(loginUi, forKey: SourceCodingKey("loginUi"))
|
||||
try values.encodeIfPresent(loginCheckJs, forKey: SourceCodingKey("loginCheckJs"))
|
||||
try values.encodeIfPresent(coverDecodeJs, forKey: SourceCodingKey("coverDecodeJs"))
|
||||
try values.encodeIfPresent(bookSourceComment, forKey: SourceCodingKey("bookSourceComment"))
|
||||
try values.encodeIfPresent(variableComment, forKey: SourceCodingKey("variableComment"))
|
||||
try values.encode(lastUpdateTime, forKey: SourceCodingKey("lastUpdateTime"))
|
||||
try values.encode(respondTime, forKey: SourceCodingKey("respondTime"))
|
||||
try values.encode(weight, forKey: SourceCodingKey("weight"))
|
||||
try values.encodeIfPresent(exploreUrl, forKey: SourceCodingKey("exploreUrl"))
|
||||
try values.encodeIfPresent(exploreScreen, forKey: SourceCodingKey("exploreScreen"))
|
||||
try encodeRule(ruleExplore, key: "ruleExplore", into: &values)
|
||||
try values.encodeIfPresent(searchUrl, forKey: SourceCodingKey("searchUrl"))
|
||||
try encodeRule(ruleSearch, key: "ruleSearch", into: &values)
|
||||
try encodeRule(ruleBookInfo, key: "ruleBookInfo", into: &values)
|
||||
try encodeRule(ruleToc, key: "ruleToc", into: &values)
|
||||
try encodeRule(ruleContent, key: "ruleContent", into: &values)
|
||||
try encodeRule(ruleReview, key: "ruleReview", into: &values)
|
||||
try values.encode(eventListener, forKey: SourceCodingKey("eventListener"))
|
||||
try values.encode(customButton, forKey: SourceCodingKey("customButton"))
|
||||
}
|
||||
|
||||
private func encodeRule<T: Encodable>(_ rule: T?, key: String, into values: inout KeyedEncodingContainer<SourceCodingKey>) throws {
|
||||
var object: [String: JSONValue] = [:]
|
||||
if case .object(let extra) = additionalFields[key] { object = extra }
|
||||
if let rule, case .object(let known) = try JSONValue.decode(data: JSONEncoder().encode(rule)) {
|
||||
object.merge(known) { _, new in new }
|
||||
}
|
||||
if rule != nil || !object.isEmpty { try values.encode(object, forKey: SourceCodingKey(key)) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public extension BookSource {
|
||||
var groups: [String] {
|
||||
Self.parseGroups(bookSourceGroup)
|
||||
}
|
||||
|
||||
func hasGroup(_ group: String) -> Bool {
|
||||
groups.contains(group.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
mutating func addGroups(_ value: String) {
|
||||
bookSourceGroup = Self.joinGroups(groups + Self.parseGroups(value))
|
||||
}
|
||||
|
||||
mutating func removeGroups(_ value: String) {
|
||||
let removed = Set(Self.parseGroups(value))
|
||||
bookSourceGroup = Self.joinGroups(groups.filter { !removed.contains($0) })
|
||||
}
|
||||
|
||||
mutating func replaceGroup(_ oldValue: String, with newValue: String?) {
|
||||
let oldValue = oldValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var values = groups.filter { $0 != oldValue }
|
||||
if let newValue { values += Self.parseGroups(newValue) }
|
||||
bookSourceGroup = Self.joinGroups(values)
|
||||
}
|
||||
|
||||
static func parseGroups(_ value: String?) -> [String] {
|
||||
guard let value else { return [] }
|
||||
var seen = Set<String>()
|
||||
return value
|
||||
.components(separatedBy: CharacterSet(charactersIn: ",;,;"))
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty && seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
static func joinGroups(_ values: [String]) -> String? {
|
||||
var seen = Set<String>()
|
||||
let normalized = values
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty && seen.insert($0).inserted }
|
||||
return normalized.isEmpty ? nil : normalized.joined(separator: ",")
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func decodeFlexible<T: Decodable>(_ type: T.Type, forKey key: Key) throws -> T? {
|
||||
guard contains(key), try !decodeNil(forKey: key) else { return nil }
|
||||
if let value = try? decode(T.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
let json = try decode(String.self, forKey: key)
|
||||
guard !json.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let data = json.data(using: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
func decodeLossyBool(forKey key: Key) throws -> Bool? {
|
||||
guard contains(key), try !decodeNil(forKey: key) else { return nil }
|
||||
if let value = try? decode(Bool.self, forKey: key) { return value }
|
||||
if let value = try? decode(Int.self, forKey: key) { return value != 0 }
|
||||
let value = try decode(String.self, forKey: key).lowercased()
|
||||
if ["true", "1", "yes"].contains(value) { return true }
|
||||
if ["false", "0", "no"].contains(value) { return false }
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
guard contains(key), try !decodeNil(forKey: key) else { return nil }
|
||||
if let value = try? decode(Int.self, forKey: key) { return value }
|
||||
return Int(try decode(String.self, forKey: key))
|
||||
}
|
||||
|
||||
func decodeLossyInt64(forKey key: Key) throws -> Int64? {
|
||||
guard contains(key), try !decodeNil(forKey: key) else { return nil }
|
||||
if let value = try? decode(Int64.self, forKey: key) { return value }
|
||||
return Int64(try decode(String.self, forKey: key))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import Foundation
|
||||
|
||||
public struct BookSourceEditorField: Identifiable, Sendable {
|
||||
public let path: String
|
||||
public let title: String
|
||||
public var id: String { path }
|
||||
public var help: String {
|
||||
switch path {
|
||||
case "bookSourceUrl": return "填写书源站点地址,例如 https://example.com;它也是书源的唯一标识。"
|
||||
case "bookSourceName": return "显示在书源列表中的名称,不能为空。"
|
||||
case "bookSourceGroup": return "多个分组可用逗号或分号分隔。"
|
||||
case "bookUrlPattern": return "匹配该书源的书籍详情 URL 的正则表达式,用于从网址添加书籍。"
|
||||
case "concurrentRate": return "1000 表示请求间隔 1000 ms;1/1000 表示每 1000 ms 最多 1 次请求。"
|
||||
case "header": return "填写请求头 JSON,或用 @js: / <js> 动态生成。User-Agent 为 null 可禁用默认 UA。"
|
||||
case "jsLib": return "填写共享 JavaScript,或以 JSON 对象配置在线脚本库。"
|
||||
case "searchUrl": return "用 {{key}} 表示关键词、{{page}} 表示页码;URL 后可用逗号附加请求选项 JSON。"
|
||||
case "exploreUrl": return "填写 名称::地址,多项用换行或 && 分隔;也支持 JSON 分类数组和 JavaScript。"
|
||||
case "loginUi": return "填写登录界面 JSON。"
|
||||
case "ruleToc.formatJs": return "目录去重后处理标题,可使用 title、index(从 1 开始)和 gInt。"
|
||||
case "ruleContent.nextContentUrl": return "提取当前章的下一分页地址,而非下一章。"
|
||||
case "ruleContent.replaceRegex": return "在多页正文合并后执行的替换规则,使用 ## 分隔正则和替换内容。"
|
||||
case "ruleContent.imageStyle": return "FULL 表示图片铺满;留空使用默认样式。"
|
||||
default:
|
||||
if isJavaScript { return "使用 JavaScript 编写,可通过更多菜单调试当前书源草稿。" }
|
||||
if path.contains(".") { return "支持 CSS / JSoup、XPath、JSONPath、正则和 JavaScript;留空表示不配置此项。" }
|
||||
return "留空表示不配置此项。"
|
||||
}
|
||||
}
|
||||
public var isJavaScript: Bool {
|
||||
path.lowercased().contains("js") || path == "jsLib" || path.hasSuffix("imageDecode") || path.hasSuffix("payAction")
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookSourceEditorSection: Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let fields: [BookSourceEditorField]
|
||||
}
|
||||
|
||||
public enum BookSourceEditorSchema {
|
||||
public static let sections: [BookSourceEditorSection] = [
|
||||
.init(id: "basic", title: "基础", fields: [
|
||||
.init(path: "bookSourceName", title: "书源名称"),
|
||||
.init(path: "bookSourceUrl", title: "书源地址"),
|
||||
.init(path: "bookSourceGroup", title: "分组"),
|
||||
.init(path: "bookSourceComment", title: "备注"),
|
||||
.init(path: "bookUrlPattern", title: "书籍 URL 正则"),
|
||||
.init(path: "concurrentRate", title: "并发率"),
|
||||
.init(path: "header", title: "请求头"),
|
||||
.init(path: "jsLib", title: "JavaScript 库"),
|
||||
.init(path: "variableComment", title: "变量说明"),
|
||||
]),
|
||||
.init(id: "login", title: "登录", fields: [
|
||||
.init(path: "loginUrl", title: "登录地址 / 脚本"),
|
||||
.init(path: "loginUi", title: "自定义登录界面"),
|
||||
.init(path: "loginCheckJs", title: "登录检测脚本"),
|
||||
.init(path: "coverDecodeJs", title: "封面解密脚本"),
|
||||
]),
|
||||
.init(id: "search", title: "搜索", fields: [
|
||||
.init(path: "searchUrl", title: "搜索地址"),
|
||||
.init(path: "ruleSearch.checkKeyWord", title: "测试关键词"),
|
||||
.init(path: "ruleSearch.bookList", title: "书籍列表"),
|
||||
.init(path: "ruleSearch.name", title: "书名"),
|
||||
.init(path: "ruleSearch.author", title: "作者"),
|
||||
.init(path: "ruleSearch.intro", title: "简介"),
|
||||
.init(path: "ruleSearch.kind", title: "分类"),
|
||||
.init(path: "ruleSearch.lastChapter", title: "最新章节"),
|
||||
.init(path: "ruleSearch.updateTime", title: "更新时间"),
|
||||
.init(path: "ruleSearch.bookUrl", title: "详情地址"),
|
||||
.init(path: "ruleSearch.coverUrl", title: "封面地址"),
|
||||
.init(path: "ruleSearch.wordCount", title: "字数"),
|
||||
]),
|
||||
.init(id: "explore", title: "发现", fields: [
|
||||
.init(path: "exploreUrl", title: "发现分类地址"),
|
||||
.init(path: "exploreScreen", title: "发现筛选配置"),
|
||||
.init(path: "ruleExplore.bookList", title: "书籍列表"),
|
||||
.init(path: "ruleExplore.name", title: "书名"),
|
||||
.init(path: "ruleExplore.author", title: "作者"),
|
||||
.init(path: "ruleExplore.intro", title: "简介"),
|
||||
.init(path: "ruleExplore.kind", title: "分类"),
|
||||
.init(path: "ruleExplore.lastChapter", title: "最新章节"),
|
||||
.init(path: "ruleExplore.updateTime", title: "更新时间"),
|
||||
.init(path: "ruleExplore.bookUrl", title: "详情地址"),
|
||||
.init(path: "ruleExplore.coverUrl", title: "封面地址"),
|
||||
.init(path: "ruleExplore.wordCount", title: "字数"),
|
||||
]),
|
||||
.init(id: "info", title: "详情", fields: [
|
||||
.init(path: "ruleBookInfo.init", title: "预处理"),
|
||||
.init(path: "ruleBookInfo.name", title: "书名"),
|
||||
.init(path: "ruleBookInfo.author", title: "作者"),
|
||||
.init(path: "ruleBookInfo.intro", title: "简介"),
|
||||
.init(path: "ruleBookInfo.kind", title: "分类"),
|
||||
.init(path: "ruleBookInfo.lastChapter", title: "最新章节"),
|
||||
.init(path: "ruleBookInfo.updateTime", title: "更新时间"),
|
||||
.init(path: "ruleBookInfo.coverUrl", title: "封面地址"),
|
||||
.init(path: "ruleBookInfo.tocUrl", title: "目录地址"),
|
||||
.init(path: "ruleBookInfo.wordCount", title: "字数"),
|
||||
.init(path: "ruleBookInfo.canReName", title: "允许修改书名作者"),
|
||||
.init(path: "ruleBookInfo.downloadUrls", title: "文件下载地址"),
|
||||
]),
|
||||
.init(id: "toc", title: "目录", fields: [
|
||||
.init(path: "ruleToc.preUpdateJs", title: "更新前脚本"),
|
||||
.init(path: "ruleToc.chapterList", title: "章节列表"),
|
||||
.init(path: "ruleToc.chapterName", title: "章节名称"),
|
||||
.init(path: "ruleToc.chapterUrl", title: "章节地址"),
|
||||
.init(path: "ruleToc.formatJs", title: "标题处理脚本"),
|
||||
.init(path: "ruleToc.isVolume", title: "卷名标识"),
|
||||
.init(path: "ruleToc.isVip", title: "VIP 标识"),
|
||||
.init(path: "ruleToc.isPay", title: "已购买标识"),
|
||||
.init(path: "ruleToc.updateTime", title: "章节信息"),
|
||||
.init(path: "ruleToc.nextTocUrl", title: "目录下一页"),
|
||||
]),
|
||||
.init(id: "content", title: "正文", fields: [
|
||||
.init(path: "ruleContent.content", title: "正文"),
|
||||
.init(path: "ruleContent.subContent", title: "副正文"),
|
||||
.init(path: "ruleContent.title", title: "正文标题"),
|
||||
.init(path: "ruleContent.nextContentUrl", title: "正文下一页"),
|
||||
.init(path: "ruleContent.webJs", title: "WebView 注入脚本"),
|
||||
.init(path: "ruleContent.sourceRegex", title: "资源正则"),
|
||||
.init(path: "ruleContent.replaceRegex", title: "合并后替换"),
|
||||
.init(path: "ruleContent.imageStyle", title: "图片样式"),
|
||||
.init(path: "ruleContent.imageDecode", title: "图片解密脚本"),
|
||||
.init(path: "ruleContent.payAction", title: "购买脚本"),
|
||||
.init(path: "ruleContent.callBackJs", title: "事件回调脚本"),
|
||||
]),
|
||||
.init(id: "review", title: "段评", fields: [
|
||||
.init(path: "ruleReview.reviewUrl", title: "段评地址"),
|
||||
.init(path: "ruleReview.avatarRule", title: "头像"),
|
||||
.init(path: "ruleReview.contentRule", title: "内容"),
|
||||
.init(path: "ruleReview.postTimeRule", title: "发布时间"),
|
||||
.init(path: "ruleReview.reviewQuoteUrl", title: "回复地址"),
|
||||
.init(path: "ruleReview.voteUpUrl", title: "点赞地址"),
|
||||
.init(path: "ruleReview.voteDownUrl", title: "点踩地址"),
|
||||
.init(path: "ruleReview.postReviewUrl", title: "发布段评"),
|
||||
.init(path: "ruleReview.postQuoteUrl", title: "发布回复"),
|
||||
.init(path: "ruleReview.deleteUrl", title: "删除地址"),
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
public struct BookSourceEditorDocument: Sendable, Equatable {
|
||||
public private(set) var fields: [String: JSONValue]
|
||||
|
||||
public init(source: BookSource) throws {
|
||||
self = try Self(json: String(decoding: JSONEncoder().encode(source), as: UTF8.self))
|
||||
}
|
||||
|
||||
public init(json: String) throws {
|
||||
guard case .object(let object) = try JSONValue.decode(data: Data(json.utf8)) else {
|
||||
throw BookSourceImportError.invalidDocument("编辑器需要单个书源 JSON 对象")
|
||||
}
|
||||
fields = object
|
||||
for key in Self.ruleKeys {
|
||||
if case .string(let text) = fields[key], !text.isEmpty,
|
||||
case .object(let object) = try JSONValue.decode(data: Data(text.utf8)) {
|
||||
fields[key] = .object(object)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func value(at path: String) -> JSONValue? {
|
||||
let keys = path.split(separator: ".").map(String.init)
|
||||
guard let key = keys.first else { return nil }
|
||||
return keys.count == 1 ? fields[key] : fields[key]?[keys[1]]
|
||||
}
|
||||
|
||||
public mutating func set(_ value: JSONValue?, at path: String) {
|
||||
let keys = path.split(separator: ".").map(String.init)
|
||||
guard let key = keys.first else { return }
|
||||
if keys.count == 1 { fields[key] = value; return }
|
||||
var object: [String: JSONValue] = [:]
|
||||
if case .object(let existing) = fields[key] { object = existing }
|
||||
object[keys[1]] = value
|
||||
fields[key] = object.isEmpty ? nil : .object(object)
|
||||
}
|
||||
|
||||
public func json() throws -> String {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||||
return String(decoding: try encoder.encode(fields), as: UTF8.self)
|
||||
}
|
||||
|
||||
public func source() throws -> BookSource {
|
||||
let source = try JSONDecoder().decode(BookSource.self, from: JSONEncoder().encode(fields))
|
||||
let errors = BookSourceValidator.validate(source).filter { $0.severity == .error }
|
||||
guard errors.isEmpty else { throw BookSourceImportError.invalidSources(errors) }
|
||||
return source
|
||||
}
|
||||
|
||||
static let ruleKeys = ["ruleSearch", "ruleExplore", "ruleBookInfo", "ruleToc", "ruleContent", "ruleReview"]
|
||||
}
|
||||
|
||||
struct SourceCodingKey: CodingKey {
|
||||
let stringValue: String
|
||||
let intValue: Int? = nil
|
||||
init(_ value: String) { stringValue = value }
|
||||
init?(stringValue: String) { self.init(stringValue) }
|
||||
init?(intValue: Int) { return nil }
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import Foundation
|
||||
|
||||
public struct BookListRule: Codable, Hashable, Sendable {
|
||||
public var bookList: String?
|
||||
public var name: String?
|
||||
public var author: String?
|
||||
public var intro: String?
|
||||
public var kind: String?
|
||||
public var lastChapter: String?
|
||||
public var updateTime: String?
|
||||
public var bookUrl: String?
|
||||
public var coverUrl: String?
|
||||
public var wordCount: String?
|
||||
|
||||
public init(
|
||||
bookList: String? = nil,
|
||||
name: String? = nil,
|
||||
author: String? = nil,
|
||||
intro: String? = nil,
|
||||
kind: String? = nil,
|
||||
lastChapter: String? = nil,
|
||||
updateTime: String? = nil,
|
||||
bookUrl: String? = nil,
|
||||
coverUrl: String? = nil,
|
||||
wordCount: String? = nil
|
||||
) {
|
||||
self.bookList = bookList
|
||||
self.name = name
|
||||
self.author = author
|
||||
self.intro = intro
|
||||
self.kind = kind
|
||||
self.lastChapter = lastChapter
|
||||
self.updateTime = updateTime
|
||||
self.bookUrl = bookUrl
|
||||
self.coverUrl = coverUrl
|
||||
self.wordCount = wordCount
|
||||
}
|
||||
}
|
||||
|
||||
public struct SearchRule: Codable, Hashable, Sendable {
|
||||
public var checkKeyWord: String?
|
||||
public var bookList: String?
|
||||
public var name: String?
|
||||
public var author: String?
|
||||
public var intro: String?
|
||||
public var kind: String?
|
||||
public var lastChapter: String?
|
||||
public var updateTime: String?
|
||||
public var bookUrl: String?
|
||||
public var coverUrl: String?
|
||||
public var wordCount: String?
|
||||
|
||||
public init(
|
||||
checkKeyWord: String? = nil,
|
||||
bookList: String? = nil,
|
||||
name: String? = nil,
|
||||
author: String? = nil,
|
||||
intro: String? = nil,
|
||||
kind: String? = nil,
|
||||
lastChapter: String? = nil,
|
||||
updateTime: String? = nil,
|
||||
bookUrl: String? = nil,
|
||||
coverUrl: String? = nil,
|
||||
wordCount: String? = nil
|
||||
) {
|
||||
self.checkKeyWord = checkKeyWord
|
||||
self.bookList = bookList
|
||||
self.name = name
|
||||
self.author = author
|
||||
self.intro = intro
|
||||
self.kind = kind
|
||||
self.lastChapter = lastChapter
|
||||
self.updateTime = updateTime
|
||||
self.bookUrl = bookUrl
|
||||
self.coverUrl = coverUrl
|
||||
self.wordCount = wordCount
|
||||
}
|
||||
}
|
||||
|
||||
public typealias ExploreRule = BookListRule
|
||||
|
||||
public struct BookInfoRule: Codable, Hashable, Sendable {
|
||||
public var `init`: String?
|
||||
public var name: String?
|
||||
public var author: String?
|
||||
public var intro: String?
|
||||
public var kind: String?
|
||||
public var lastChapter: String?
|
||||
public var updateTime: String?
|
||||
public var coverUrl: String?
|
||||
public var tocUrl: String?
|
||||
public var wordCount: String?
|
||||
public var canReName: String?
|
||||
public var downloadUrls: String?
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
public struct TocRule: Codable, Hashable, Sendable {
|
||||
public var preUpdateJs: String?
|
||||
public var chapterList: String?
|
||||
public var chapterName: String?
|
||||
public var chapterUrl: String?
|
||||
public var formatJs: String?
|
||||
public var isVolume: String?
|
||||
public var isVip: String?
|
||||
public var isPay: String?
|
||||
public var updateTime: String?
|
||||
public var nextTocUrl: String?
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
public struct ContentRule: Codable, Hashable, Sendable {
|
||||
public var content: String?
|
||||
public var subContent: String?
|
||||
public var title: String?
|
||||
public var nextContentUrl: String?
|
||||
public var webJs: String?
|
||||
public var sourceRegex: String?
|
||||
public var replaceRegex: String?
|
||||
public var imageStyle: String?
|
||||
public var imageDecode: String?
|
||||
public var payAction: String?
|
||||
public var callBackJs: String?
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
public struct ReviewRule: Codable, Hashable, Sendable {
|
||||
public var reviewUrl: String?
|
||||
public var avatarRule: String?
|
||||
public var contentRule: String?
|
||||
public var postTimeRule: String?
|
||||
public var reviewQuoteUrl: String?
|
||||
public var voteUpUrl: String?
|
||||
public var voteDownUrl: String?
|
||||
public var postReviewUrl: String?
|
||||
public var postQuoteUrl: String?
|
||||
public var deleteUrl: String?
|
||||
|
||||
public init() {}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
|
||||
public struct ExploreKind: Codable, Hashable, Identifiable, Sendable {
|
||||
public var title: String
|
||||
public var url: String?
|
||||
public var type: String
|
||||
public var action: String?
|
||||
public var chars: [String?]?
|
||||
public var defaultValue: String?
|
||||
|
||||
public var id: String { "\(title)\u{0}\(url ?? "")" }
|
||||
|
||||
public var navigableURL: String? {
|
||||
guard type.caseInsensitiveCompare("url") == .orderedSame,
|
||||
let value = url?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!value.isEmpty else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case title, url, type, action, chars
|
||||
case defaultValue = "default"
|
||||
}
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
url: String? = nil,
|
||||
type: String = "url",
|
||||
action: String? = nil,
|
||||
chars: [String?]? = nil,
|
||||
defaultValue: String? = nil
|
||||
) {
|
||||
self.title = title
|
||||
self.url = url
|
||||
self.type = type
|
||||
self.action = action
|
||||
self.chars = chars
|
||||
self.defaultValue = defaultValue
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
title = values.lossyString(forKey: .title) ?? ""
|
||||
url = values.lossyString(forKey: .url)
|
||||
type = values.lossyString(forKey: .type) ?? "url"
|
||||
action = values.lossyString(forKey: .action)
|
||||
defaultValue = values.lossyString(forKey: .defaultValue)
|
||||
if let decoded = try? values.decodeIfPresent([JSONValue].self, forKey: .chars) {
|
||||
chars = decoded.map { value in
|
||||
if case .null = value { return nil }
|
||||
return value.stringValue
|
||||
}
|
||||
} else if let scalar = values.lossyString(forKey: .chars) {
|
||||
chars = [scalar]
|
||||
} else {
|
||||
chars = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer where Key == ExploreKind.CodingKeys {
|
||||
func lossyString(forKey key: Key) -> String? {
|
||||
guard contains(key), (try? decodeNil(forKey: key)) != true else { return nil }
|
||||
if let value = try? decode(String.self, forKey: key) { return value }
|
||||
if let value = try? decode(Bool.self, forKey: key) { return String(value) }
|
||||
if let value = try? decode(Int64.self, forKey: key) { return String(value) }
|
||||
if let value = try? decode(Double.self, forKey: key) {
|
||||
return value.rounded() == value ? String(format: "%.0f", value) : String(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public struct ExploreKindsReport: Sendable {
|
||||
public var kinds: [ExploreKind]
|
||||
public var diagnostics: [RuleDiagnostic]
|
||||
|
||||
public init(kinds: [ExploreKind] = [], diagnostics: [RuleDiagnostic] = []) {
|
||||
self.kinds = kinds
|
||||
self.diagnostics = diagnostics
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import Foundation
|
||||
|
||||
public struct Book: Codable, Hashable, Identifiable, Sendable {
|
||||
public var bookUrl: String
|
||||
public var tocUrl: String
|
||||
public var origin: String
|
||||
public var originName: String
|
||||
public var name: String
|
||||
public var author: String
|
||||
public var kind: String?
|
||||
public var customTag: String?
|
||||
public var coverUrl: String?
|
||||
public var customCoverUrl: String?
|
||||
public var intro: String?
|
||||
public var customIntro: String?
|
||||
public var charset: String?
|
||||
public var type: Int
|
||||
public var group: Int64
|
||||
public var latestChapterTitle: String?
|
||||
public var latestChapterTime: Int64
|
||||
public var lastCheckTime: Int64
|
||||
public var lastCheckCount: Int
|
||||
public var totalChapterNum: Int
|
||||
public var durChapterTitle: String?
|
||||
public var durChapterIndex: Int
|
||||
public var durVolumeIndex: Int
|
||||
public var chapterInVolumeIndex: Int
|
||||
public var durChapterPos: Int
|
||||
public var durChapterTime: Int64
|
||||
public var wordCount: String?
|
||||
public var canUpdate: Bool
|
||||
public var order: Int
|
||||
public var originOrder: Int
|
||||
public var variable: String?
|
||||
public var readConfig: BookReadConfig?
|
||||
public var syncTime: Int64
|
||||
|
||||
public var id: String { bookUrl }
|
||||
|
||||
public init(bookUrl: String, name: String, author: String = "", origin: String = "local") {
|
||||
self.bookUrl = bookUrl
|
||||
tocUrl = ""
|
||||
self.origin = origin
|
||||
originName = ""
|
||||
self.name = name
|
||||
self.author = author
|
||||
kind = nil
|
||||
customTag = nil
|
||||
coverUrl = nil
|
||||
customCoverUrl = nil
|
||||
intro = nil
|
||||
customIntro = nil
|
||||
charset = nil
|
||||
type = 0
|
||||
group = 0
|
||||
latestChapterTitle = nil
|
||||
latestChapterTime = 0
|
||||
lastCheckTime = 0
|
||||
lastCheckCount = 0
|
||||
totalChapterNum = 0
|
||||
durChapterTitle = nil
|
||||
durChapterIndex = 0
|
||||
durVolumeIndex = 0
|
||||
chapterInVolumeIndex = 0
|
||||
durChapterPos = 0
|
||||
durChapterTime = 0
|
||||
wordCount = nil
|
||||
canUpdate = true
|
||||
order = 0
|
||||
originOrder = 0
|
||||
variable = nil
|
||||
readConfig = nil
|
||||
syncTime = 0
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case bookUrl, tocUrl, origin, originName, name, author, kind, customTag, coverUrl, customCoverUrl
|
||||
case intro, customIntro, charset, type, group, latestChapterTitle, latestChapterTime, lastCheckTime
|
||||
case lastCheckCount, totalChapterNum, durChapterTitle, durChapterIndex, durVolumeIndex
|
||||
case chapterInVolumeIndex, durChapterPos, durChapterTime, wordCount, canUpdate, order, originOrder
|
||||
case variable, readConfig, syncTime
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let value = try decoder.container(keyedBy: CodingKeys.self)
|
||||
bookUrl = try value.decodeIfPresent(String.self, forKey: .bookUrl) ?? ""
|
||||
tocUrl = try value.decodeIfPresent(String.self, forKey: .tocUrl) ?? ""
|
||||
origin = try value.decodeIfPresent(String.self, forKey: .origin) ?? "local"
|
||||
originName = try value.decodeIfPresent(String.self, forKey: .originName) ?? ""
|
||||
name = try value.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
author = try value.decodeIfPresent(String.self, forKey: .author) ?? ""
|
||||
kind = try value.decodeIfPresent(String.self, forKey: .kind)
|
||||
customTag = try value.decodeIfPresent(String.self, forKey: .customTag)
|
||||
coverUrl = try value.decodeIfPresent(String.self, forKey: .coverUrl)
|
||||
customCoverUrl = try value.decodeIfPresent(String.self, forKey: .customCoverUrl)
|
||||
intro = try value.decodeIfPresent(String.self, forKey: .intro)
|
||||
customIntro = try value.decodeIfPresent(String.self, forKey: .customIntro)
|
||||
charset = try value.decodeIfPresent(String.self, forKey: .charset)
|
||||
type = try value.decodeIfPresent(Int.self, forKey: .type) ?? 0
|
||||
group = try value.decodeIfPresent(Int64.self, forKey: .group) ?? 0
|
||||
latestChapterTitle = try value.decodeIfPresent(String.self, forKey: .latestChapterTitle)
|
||||
latestChapterTime = try value.decodeIfPresent(Int64.self, forKey: .latestChapterTime) ?? 0
|
||||
lastCheckTime = try value.decodeIfPresent(Int64.self, forKey: .lastCheckTime) ?? 0
|
||||
lastCheckCount = try value.decodeIfPresent(Int.self, forKey: .lastCheckCount) ?? 0
|
||||
totalChapterNum = try value.decodeIfPresent(Int.self, forKey: .totalChapterNum) ?? 0
|
||||
durChapterTitle = try value.decodeIfPresent(String.self, forKey: .durChapterTitle)
|
||||
durChapterIndex = try value.decodeIfPresent(Int.self, forKey: .durChapterIndex) ?? 0
|
||||
durVolumeIndex = try value.decodeIfPresent(Int.self, forKey: .durVolumeIndex) ?? 0
|
||||
chapterInVolumeIndex = try value.decodeIfPresent(Int.self, forKey: .chapterInVolumeIndex) ?? 0
|
||||
durChapterPos = try value.decodeIfPresent(Int.self, forKey: .durChapterPos) ?? 0
|
||||
durChapterTime = try value.decodeIfPresent(Int64.self, forKey: .durChapterTime) ?? 0
|
||||
wordCount = try value.decodeIfPresent(String.self, forKey: .wordCount)
|
||||
canUpdate = try value.decodeIfPresent(Bool.self, forKey: .canUpdate) ?? true
|
||||
order = try value.decodeIfPresent(Int.self, forKey: .order) ?? 0
|
||||
originOrder = try value.decodeIfPresent(Int.self, forKey: .originOrder) ?? 0
|
||||
variable = try value.decodeIfPresent(String.self, forKey: .variable)
|
||||
readConfig = try value.decodeIfPresent(BookReadConfig.self, forKey: .readConfig)
|
||||
syncTime = try value.decodeIfPresent(Int64.self, forKey: .syncTime) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookReadConfig: Codable, Hashable, Sendable {
|
||||
public var reverseToc = false
|
||||
public var pageAnim: Int?
|
||||
public var reSegment = false
|
||||
public var imageStyle: String?
|
||||
public var useReplaceRule: Bool?
|
||||
public var delTag: Int64 = 0
|
||||
public var ttsEngine: String?
|
||||
public var splitLongChapter = true
|
||||
public var readSimulating = false
|
||||
public var startDate: String?
|
||||
public var startChapter: Int?
|
||||
public var dailyChapters = 3
|
||||
public var openCredits = 0
|
||||
public var closeCredits = 0
|
||||
public var playMode = 0
|
||||
public var playSpeed: Float = 1
|
||||
|
||||
public init() {}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case reverseToc, pageAnim, reSegment, imageStyle, useReplaceRule, delTag, ttsEngine
|
||||
case splitLongChapter, readSimulating, startDate, startChapter, dailyChapters
|
||||
case openCredits, closeCredits, playMode, playSpeed
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let value = try decoder.container(keyedBy: CodingKeys.self)
|
||||
reverseToc = try value.decodeIfPresent(Bool.self, forKey: .reverseToc) ?? false
|
||||
pageAnim = try value.decodeIfPresent(Int.self, forKey: .pageAnim)
|
||||
reSegment = try value.decodeIfPresent(Bool.self, forKey: .reSegment) ?? false
|
||||
imageStyle = try value.decodeIfPresent(String.self, forKey: .imageStyle)
|
||||
useReplaceRule = try value.decodeIfPresent(Bool.self, forKey: .useReplaceRule)
|
||||
delTag = try value.decodeIfPresent(Int64.self, forKey: .delTag) ?? 0
|
||||
ttsEngine = try value.decodeIfPresent(String.self, forKey: .ttsEngine)
|
||||
splitLongChapter = try value.decodeIfPresent(Bool.self, forKey: .splitLongChapter) ?? true
|
||||
readSimulating = try value.decodeIfPresent(Bool.self, forKey: .readSimulating) ?? false
|
||||
startDate = try value.decodeIfPresent(String.self, forKey: .startDate)
|
||||
startChapter = try value.decodeIfPresent(Int.self, forKey: .startChapter)
|
||||
dailyChapters = try value.decodeIfPresent(Int.self, forKey: .dailyChapters) ?? 3
|
||||
openCredits = try value.decodeIfPresent(Int.self, forKey: .openCredits) ?? 0
|
||||
closeCredits = try value.decodeIfPresent(Int.self, forKey: .closeCredits) ?? 0
|
||||
playMode = try value.decodeIfPresent(Int.self, forKey: .playMode) ?? 0
|
||||
playSpeed = try value.decodeIfPresent(Float.self, forKey: .playSpeed) ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookGroup: Codable, Hashable, Identifiable, Sendable {
|
||||
public static let idRoot: Int64 = -100
|
||||
public static let idAll: Int64 = -1
|
||||
public static let idLocal: Int64 = -2
|
||||
public static let idAudio: Int64 = -3
|
||||
public static let idNetNone: Int64 = -4
|
||||
public static let idLocalNone: Int64 = -5
|
||||
public static let idVideo: Int64 = -6
|
||||
public static let idError: Int64 = -11
|
||||
|
||||
public var groupId: Int64
|
||||
public var groupName: String
|
||||
public var cover: String?
|
||||
public var order: Int
|
||||
public var enableRefresh: Bool
|
||||
public var show: Bool
|
||||
public var bookSort: Int
|
||||
public var onlyUpdateRead: Bool
|
||||
|
||||
public var id: Int64 { groupId }
|
||||
|
||||
public init(
|
||||
groupId: Int64,
|
||||
groupName: String,
|
||||
cover: String? = nil,
|
||||
order: Int = 0,
|
||||
enableRefresh: Bool = true,
|
||||
show: Bool = true,
|
||||
bookSort: Int = -1,
|
||||
onlyUpdateRead: Bool = false
|
||||
) {
|
||||
self.groupId = groupId
|
||||
self.groupName = groupName
|
||||
self.cover = cover
|
||||
self.order = order
|
||||
self.enableRefresh = enableRefresh
|
||||
self.show = show
|
||||
self.bookSort = bookSort
|
||||
self.onlyUpdateRead = onlyUpdateRead
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookChapter: Codable, Hashable, Identifiable, Sendable {
|
||||
public var url: String
|
||||
public var title: String
|
||||
public var isVolume: Bool
|
||||
public var baseUrl: String
|
||||
public var bookUrl: String
|
||||
public var index: Int
|
||||
public var isVip: Bool
|
||||
public var isPay: Bool
|
||||
public var resourceUrl: String?
|
||||
public var tag: String?
|
||||
public var wordCount: String?
|
||||
public var start: Int64?
|
||||
public var end: Int64?
|
||||
public var startFragmentId: String?
|
||||
public var endFragmentId: String?
|
||||
public var variable: String?
|
||||
public var imageURL: String?
|
||||
|
||||
public var id: String { "\(bookUrl)\u{0}\(url)" }
|
||||
|
||||
public init(url: String, title: String, bookUrl: String, index: Int) {
|
||||
self.url = url
|
||||
self.title = title
|
||||
isVolume = false
|
||||
baseUrl = ""
|
||||
self.bookUrl = bookUrl
|
||||
self.index = index
|
||||
isVip = false
|
||||
isPay = false
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case url, title, isVolume, baseUrl, bookUrl, index, isVip, isPay, resourceUrl, tag, wordCount
|
||||
case start, end, startFragmentId, endFragmentId, variable, imgUrl, imageURL
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let value = try decoder.container(keyedBy: CodingKeys.self)
|
||||
url = try value.decodeIfPresent(String.self, forKey: .url) ?? ""
|
||||
title = try value.decodeIfPresent(String.self, forKey: .title) ?? ""
|
||||
isVolume = try value.decodeIfPresent(Bool.self, forKey: .isVolume) ?? false
|
||||
baseUrl = try value.decodeIfPresent(String.self, forKey: .baseUrl) ?? ""
|
||||
bookUrl = try value.decodeIfPresent(String.self, forKey: .bookUrl) ?? ""
|
||||
index = try value.decodeIfPresent(Int.self, forKey: .index) ?? 0
|
||||
isVip = try value.decodeIfPresent(Bool.self, forKey: .isVip) ?? false
|
||||
isPay = try value.decodeIfPresent(Bool.self, forKey: .isPay) ?? false
|
||||
resourceUrl = try value.decodeIfPresent(String.self, forKey: .resourceUrl)
|
||||
tag = try value.decodeIfPresent(String.self, forKey: .tag)
|
||||
wordCount = try value.decodeIfPresent(String.self, forKey: .wordCount)
|
||||
start = try value.decodeIfPresent(Int64.self, forKey: .start)
|
||||
end = try value.decodeIfPresent(Int64.self, forKey: .end)
|
||||
startFragmentId = try value.decodeIfPresent(String.self, forKey: .startFragmentId)
|
||||
endFragmentId = try value.decodeIfPresent(String.self, forKey: .endFragmentId)
|
||||
variable = try value.decodeIfPresent(String.self, forKey: .variable)
|
||||
imageURL = try value.decodeIfPresent(String.self, forKey: .imgUrl)
|
||||
?? value.decodeIfPresent(String.self, forKey: .imageURL)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var value = encoder.container(keyedBy: CodingKeys.self)
|
||||
try value.encode(url, forKey: .url)
|
||||
try value.encode(title, forKey: .title)
|
||||
try value.encode(isVolume, forKey: .isVolume)
|
||||
try value.encode(baseUrl, forKey: .baseUrl)
|
||||
try value.encode(bookUrl, forKey: .bookUrl)
|
||||
try value.encode(index, forKey: .index)
|
||||
try value.encode(isVip, forKey: .isVip)
|
||||
try value.encode(isPay, forKey: .isPay)
|
||||
try value.encodeIfPresent(resourceUrl, forKey: .resourceUrl)
|
||||
try value.encodeIfPresent(tag, forKey: .tag)
|
||||
try value.encodeIfPresent(wordCount, forKey: .wordCount)
|
||||
try value.encodeIfPresent(start, forKey: .start)
|
||||
try value.encodeIfPresent(end, forKey: .end)
|
||||
try value.encodeIfPresent(startFragmentId, forKey: .startFragmentId)
|
||||
try value.encodeIfPresent(endFragmentId, forKey: .endFragmentId)
|
||||
try value.encodeIfPresent(variable, forKey: .variable)
|
||||
try value.encodeIfPresent(imageURL, forKey: .imgUrl)
|
||||
}
|
||||
}
|
||||
|
||||
public struct Bookmark: Codable, Hashable, Identifiable, Sendable {
|
||||
public var time: Int64
|
||||
public var bookName: String
|
||||
public var bookAuthor: String
|
||||
public var chapterIndex: Int
|
||||
public var chapterPosition: Int
|
||||
public var chapterName: String
|
||||
public var bookText: String
|
||||
public var content: String
|
||||
|
||||
public var id: Int64 { time }
|
||||
|
||||
public init(time: Int64, bookName: String, bookAuthor: String = "") {
|
||||
self.time = time
|
||||
self.bookName = bookName
|
||||
self.bookAuthor = bookAuthor
|
||||
chapterIndex = 0
|
||||
chapterPosition = 0
|
||||
chapterName = ""
|
||||
bookText = ""
|
||||
content = ""
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case time, bookName, bookAuthor, chapterIndex, chapterPos, chapterPosition
|
||||
case chapterName, bookText, content
|
||||
}
|
||||
|
||||
public func resolvedChapterIndex(in chapters: [BookChapter]) -> Int? {
|
||||
if !chapterName.isEmpty,
|
||||
let index = chapters.firstIndex(where: { !$0.isVolume && $0.title == chapterName }) {
|
||||
return index
|
||||
}
|
||||
return chapters.firstIndex(where: { !$0.isVolume && $0.index == chapterIndex })
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
time = try values.decodeIfPresent(Int64.self, forKey: .time) ?? Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
bookName = try values.decodeIfPresent(String.self, forKey: .bookName) ?? ""
|
||||
bookAuthor = try values.decodeIfPresent(String.self, forKey: .bookAuthor) ?? ""
|
||||
chapterIndex = try values.decodeIfPresent(Int.self, forKey: .chapterIndex) ?? 0
|
||||
chapterPosition = try values.decodeIfPresent(Int.self, forKey: .chapterPos)
|
||||
?? values.decodeIfPresent(Int.self, forKey: .chapterPosition)
|
||||
?? 0
|
||||
chapterName = try values.decodeIfPresent(String.self, forKey: .chapterName) ?? ""
|
||||
bookText = try values.decodeIfPresent(String.self, forKey: .bookText) ?? ""
|
||||
content = try values.decodeIfPresent(String.self, forKey: .content) ?? ""
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var values = encoder.container(keyedBy: CodingKeys.self)
|
||||
try values.encode(time, forKey: .time)
|
||||
try values.encode(bookName, forKey: .bookName)
|
||||
try values.encode(bookAuthor, forKey: .bookAuthor)
|
||||
try values.encode(chapterIndex, forKey: .chapterIndex)
|
||||
try values.encode(chapterPosition, forKey: .chapterPos)
|
||||
try values.encode(chapterName, forKey: .chapterName)
|
||||
try values.encode(bookText, forKey: .bookText)
|
||||
try values.encode(content, forKey: .content)
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReadRecord: Codable, Hashable, Identifiable, Sendable {
|
||||
public var deviceId: String
|
||||
public var bookName: String
|
||||
public var readTime: Int64
|
||||
public var lastRead: Int64
|
||||
public var dailyReadTimes: [String: Int64]
|
||||
|
||||
public var id: String { "\(deviceId)\u{0}\(bookName)" }
|
||||
|
||||
public init(
|
||||
deviceId: String,
|
||||
bookName: String,
|
||||
readTime: Int64 = 0,
|
||||
lastRead: Int64? = nil,
|
||||
dailyReadTimes: [String: Int64] = [:]
|
||||
) {
|
||||
self.deviceId = deviceId
|
||||
self.bookName = bookName
|
||||
self.readTime = readTime
|
||||
self.lastRead = lastRead ?? Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
self.dailyReadTimes = dailyReadTimes
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case deviceId, bookName, readTime, lastRead, dailyReadTimes
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
deviceId = try values.decodeIfPresent(String.self, forKey: .deviceId) ?? ""
|
||||
bookName = try values.decodeIfPresent(String.self, forKey: .bookName) ?? ""
|
||||
readTime = try values.decodeIfPresent(Int64.self, forKey: .readTime) ?? 0
|
||||
lastRead = try values.decodeIfPresent(Int64.self, forKey: .lastRead) ?? 0
|
||||
dailyReadTimes = try values.decodeIfPresent([String: Int64].self, forKey: .dailyReadTimes) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReadRecordSummary: Hashable, Identifiable, Sendable {
|
||||
public var bookName: String
|
||||
public var readTime: Int64
|
||||
public var lastRead: Int64
|
||||
|
||||
public var id: String { bookName }
|
||||
|
||||
public init(bookName: String, readTime: Int64, lastRead: Int64) {
|
||||
self.bookName = bookName
|
||||
self.readTime = readTime
|
||||
self.lastRead = lastRead
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReplaceRule: Codable, Hashable, Identifiable, Sendable {
|
||||
public var id: Int64
|
||||
public var name: String
|
||||
public var group: String?
|
||||
public var pattern: String
|
||||
public var replacement: String
|
||||
public var scope: String?
|
||||
public var scopeTitle: Bool
|
||||
public var scopeContent: Bool
|
||||
public var excludeScope: String?
|
||||
public var isEnabled: Bool
|
||||
public var isRegex: Bool
|
||||
public var timeoutMillisecond: Int64
|
||||
public var sortOrder: Int
|
||||
|
||||
public init(id: Int64, name: String, pattern: String, replacement: String = "") {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.pattern = pattern
|
||||
self.replacement = replacement
|
||||
scopeTitle = false
|
||||
scopeContent = true
|
||||
isEnabled = true
|
||||
isRegex = true
|
||||
timeoutMillisecond = 3_000
|
||||
sortOrder = Int.min
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, name, group, pattern, replacement, scope, scopeTitle, scopeContent
|
||||
case excludeScope, isEnabled, isRegex, timeoutMillisecond, sortOrder, order
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decodeIfPresent(Int64.self, forKey: .id) ?? Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
name = try values.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
group = try values.decodeIfPresent(String.self, forKey: .group)
|
||||
pattern = try values.decodeIfPresent(String.self, forKey: .pattern) ?? ""
|
||||
replacement = try values.decodeIfPresent(String.self, forKey: .replacement) ?? ""
|
||||
scope = try values.decodeIfPresent(String.self, forKey: .scope)
|
||||
scopeTitle = try values.decodeIfPresent(Bool.self, forKey: .scopeTitle) ?? false
|
||||
scopeContent = try values.decodeIfPresent(Bool.self, forKey: .scopeContent) ?? true
|
||||
excludeScope = try values.decodeIfPresent(String.self, forKey: .excludeScope)
|
||||
isEnabled = try values.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
|
||||
isRegex = try values.decodeIfPresent(Bool.self, forKey: .isRegex) ?? true
|
||||
timeoutMillisecond = try values.decodeIfPresent(Int64.self, forKey: .timeoutMillisecond) ?? 3_000
|
||||
sortOrder = try values.decodeIfPresent(Int.self, forKey: .sortOrder)
|
||||
?? values.decodeIfPresent(Int.self, forKey: .order)
|
||||
?? Int.min
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var values = encoder.container(keyedBy: CodingKeys.self)
|
||||
try values.encode(id, forKey: .id)
|
||||
try values.encode(name, forKey: .name)
|
||||
try values.encodeIfPresent(group, forKey: .group)
|
||||
try values.encode(pattern, forKey: .pattern)
|
||||
try values.encode(replacement, forKey: .replacement)
|
||||
try values.encodeIfPresent(scope, forKey: .scope)
|
||||
try values.encode(scopeTitle, forKey: .scopeTitle)
|
||||
try values.encode(scopeContent, forKey: .scopeContent)
|
||||
try values.encodeIfPresent(excludeScope, forKey: .excludeScope)
|
||||
try values.encode(isEnabled, forKey: .isEnabled)
|
||||
try values.encode(isRegex, forKey: .isRegex)
|
||||
try values.encode(timeoutMillisecond, forKey: .timeoutMillisecond)
|
||||
try values.encode(sortOrder, forKey: .order)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import Foundation
|
||||
|
||||
public struct RssSource: Codable, Hashable, Identifiable, Sendable {
|
||||
public var sourceUrl = ""
|
||||
public var sourceName = ""
|
||||
public var sourceIcon = ""
|
||||
public var sourceGroup: String?
|
||||
public var sourceComment: String?
|
||||
public var enabled = true
|
||||
public var variableComment: String?
|
||||
public var jsLib: String?
|
||||
public var enabledCookieJar: Bool? = true
|
||||
public var concurrentRate: String?
|
||||
public var header: String?
|
||||
public var loginUrl: String?
|
||||
public var loginUi: String?
|
||||
public var loginCheckJs: String?
|
||||
public var coverDecodeJs: String?
|
||||
public var sortUrl: String?
|
||||
public var singleUrl = false
|
||||
public var articleStyle = 0
|
||||
public var ruleArticles: String?
|
||||
public var ruleNextPage: String?
|
||||
public var ruleTitle: String?
|
||||
public var rulePubDate: String?
|
||||
public var ruleDescription: String?
|
||||
public var ruleImage: String?
|
||||
public var ruleLink: String?
|
||||
public var ruleContent: String?
|
||||
public var contentWhitelist: String?
|
||||
public var contentBlacklist: String?
|
||||
public var shouldOverrideUrlLoading: String?
|
||||
public var style: String?
|
||||
public var enableJs = true
|
||||
public var loadWithBaseUrl = true
|
||||
public var injectJs: String?
|
||||
public var preloadJs: String?
|
||||
public var startHtml: String?
|
||||
public var startStyle: String?
|
||||
public var startJs: String?
|
||||
public var showWebLog = false
|
||||
public var lastUpdateTime: Int64 = 0
|
||||
public var customOrder = 0
|
||||
public var type = 0
|
||||
public var preload = false
|
||||
public var cacheFirst = false
|
||||
public var searchUrl: String?
|
||||
public var id: String { sourceUrl }
|
||||
|
||||
public init(sourceUrl: String = "", sourceName: String = "") {
|
||||
self.sourceUrl = sourceUrl
|
||||
self.sourceName = sourceName
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: RSSCodingKey.self)
|
||||
sourceUrl = values.string("sourceUrl") ?? ""
|
||||
sourceName = values.string("sourceName") ?? ""
|
||||
sourceIcon = values.string("sourceIcon") ?? ""
|
||||
sourceGroup = values.string("sourceGroup")
|
||||
sourceComment = values.string("sourceComment")
|
||||
enabled = values.bool("enabled") ?? true
|
||||
variableComment = values.string("variableComment")
|
||||
jsLib = values.string("jsLib")
|
||||
enabledCookieJar = values.contains("enabledCookieJar") ? values.bool("enabledCookieJar") : true
|
||||
concurrentRate = values.string("concurrentRate")
|
||||
header = values.string("header")
|
||||
loginUrl = values.string("loginUrl")
|
||||
loginUi = values.jsonString("loginUi")
|
||||
loginCheckJs = values.string("loginCheckJs")
|
||||
coverDecodeJs = values.string("coverDecodeJs")
|
||||
sortUrl = values.string("sortUrl")
|
||||
singleUrl = values.bool("singleUrl") ?? false
|
||||
articleStyle = values.int("articleStyle") ?? 0
|
||||
ruleArticles = values.string("ruleArticles")
|
||||
ruleNextPage = values.string("ruleNextPage")
|
||||
ruleTitle = values.string("ruleTitle")
|
||||
rulePubDate = values.string("rulePubDate")
|
||||
ruleDescription = values.string("ruleDescription")
|
||||
ruleImage = values.string("ruleImage")
|
||||
ruleLink = values.string("ruleLink")
|
||||
ruleContent = values.string("ruleContent")
|
||||
contentWhitelist = values.string("contentWhitelist")
|
||||
contentBlacklist = values.string("contentBlacklist")
|
||||
shouldOverrideUrlLoading = values.string("shouldOverrideUrlLoading")
|
||||
style = values.string("style")
|
||||
enableJs = values.bool("enableJs") ?? true
|
||||
loadWithBaseUrl = values.bool("loadWithBaseUrl") ?? true
|
||||
injectJs = values.string("injectJs")
|
||||
preloadJs = values.string("preloadJs")
|
||||
startHtml = values.string("startHtml")
|
||||
startStyle = values.string("startStyle")
|
||||
startJs = values.string("startJs")
|
||||
showWebLog = values.bool("showWebLog") ?? false
|
||||
lastUpdateTime = values.int64("lastUpdateTime") ?? 0
|
||||
customOrder = values.int("customOrder") ?? 0
|
||||
type = values.int("type") ?? 0
|
||||
preload = values.bool("preload") ?? false
|
||||
cacheFirst = values.bool("cacheFirst") ?? false
|
||||
searchUrl = values.string("searchUrl")
|
||||
}
|
||||
|
||||
public var groups: [String] { BookSource.parseGroups(sourceGroup) }
|
||||
public var displayNameGroup: String {
|
||||
groups.isEmpty ? sourceName : "\(sourceName) (\(sourceGroup ?? ""))"
|
||||
}
|
||||
public static func == (lhs: Self, rhs: Self) -> Bool { lhs.sourceUrl == rhs.sourceUrl }
|
||||
public func hash(into hasher: inout Hasher) { hasher.combine(sourceUrl) }
|
||||
|
||||
public mutating func addGroups(_ value: String) {
|
||||
var merged = Set(groups)
|
||||
merged.formUnion(BookSource.parseGroups(value))
|
||||
sourceGroup = merged.joined(separator: ",")
|
||||
}
|
||||
public mutating func removeGroups(_ value: String) {
|
||||
let removed = Set(BookSource.parseGroups(value))
|
||||
sourceGroup = groups.filter { !removed.contains($0) }.joined(separator: ",")
|
||||
}
|
||||
public func displayVariableComment(_ other: String) -> String {
|
||||
guard let variableComment, !variableComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return other }
|
||||
return "\(variableComment)\n\(other)"
|
||||
}
|
||||
}
|
||||
|
||||
public struct RssArticle: Codable, Hashable, Identifiable, Sendable {
|
||||
public var origin = ""
|
||||
public var sort = ""
|
||||
public var title = ""
|
||||
public var order: Int64 = 0
|
||||
public var link = ""
|
||||
public var pubDate: String?
|
||||
public var description: String?
|
||||
public var content: String?
|
||||
public var image: String?
|
||||
public var group = "默认分组"
|
||||
public var read = false
|
||||
public var variable: String?
|
||||
public var type = 0
|
||||
public var durPos = 0
|
||||
public var id: String { "\(origin)\u{0}\(link)\u{0}\(sort)" }
|
||||
|
||||
public init(origin: String = "", sort: String = "", title: String = "", link: String = "") {
|
||||
self.origin = origin; self.sort = sort; self.title = title; self.link = link
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: RSSCodingKey.self)
|
||||
origin = values.string("origin") ?? ""; sort = values.string("sort") ?? ""
|
||||
title = values.string("title") ?? ""; order = values.int64("order") ?? 0
|
||||
link = values.string("link") ?? ""; pubDate = values.string("pubDate")
|
||||
description = values.string("description"); content = values.string("content")
|
||||
image = values.string("image"); group = values.string("group") ?? "默认分组"
|
||||
read = values.bool("read") ?? false; variable = values.string("variable")
|
||||
type = values.int("type") ?? 0; durPos = values.int("durPos") ?? 0
|
||||
}
|
||||
|
||||
public func star(at time: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> RssStar {
|
||||
.init(article: self, starTime: time)
|
||||
}
|
||||
public func record(at time: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> RssReadRecord {
|
||||
.init(record: link, title: title, readTime: time, origin: origin, sort: sort, image: image, type: type, durPos: durPos, pubDate: pubDate)
|
||||
}
|
||||
public var variableMap: [String: String] { Self.decodeVariables(variable) }
|
||||
public static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.origin == rhs.origin && lhs.link == rhs.link && lhs.sort == rhs.sort
|
||||
}
|
||||
public func hash(into hasher: inout Hasher) { hasher.combine(link) }
|
||||
|
||||
fileprivate static func decodeVariables(_ value: String?) -> [String: String] {
|
||||
guard let value, let data = value.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
|
||||
return object.reduce(into: [:]) { result, pair in
|
||||
if let string = pair.value as? String { result[pair.key] = string }
|
||||
else if !(pair.value is NSNull) { result[pair.key] = String(describing: pair.value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct RssReadRecord: Codable, Hashable, Identifiable, Sendable {
|
||||
public var record: String
|
||||
public var title: String?
|
||||
public var readTime: Int64?
|
||||
public var read: Bool
|
||||
public var origin: String
|
||||
public var sort: String
|
||||
public var image: String?
|
||||
public var type: Int
|
||||
public var durPos: Int
|
||||
public var pubDate: String?
|
||||
public var id: String { record }
|
||||
public init(record: String, title: String? = nil, readTime: Int64? = nil, read: Bool = true, origin: String = "", sort: String = "", image: String? = nil, type: Int = 0, durPos: Int = 0, pubDate: String? = nil) {
|
||||
self.record = record; self.title = title; self.readTime = readTime; self.read = read
|
||||
self.origin = origin; self.sort = sort; self.image = image; self.type = type
|
||||
self.durPos = durPos; self.pubDate = pubDate
|
||||
}
|
||||
public var article: RssArticle {
|
||||
var value = RssArticle(origin: origin, sort: sort, title: title ?? "", link: record)
|
||||
value.image = image; value.type = type; value.durPos = durPos; value.pubDate = pubDate
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
public struct RssStar: Codable, Hashable, Identifiable, Sendable {
|
||||
public var origin = ""
|
||||
public var sort = ""
|
||||
public var title = ""
|
||||
public var starTime: Int64 = 0
|
||||
public var link = ""
|
||||
public var pubDate: String?
|
||||
public var description: String?
|
||||
public var content: String?
|
||||
public var image: String?
|
||||
public var group = "默认分组"
|
||||
public var variable: String?
|
||||
public var type = 0
|
||||
public var durPos = 0
|
||||
public var id: String { "\(origin)\u{0}\(link)" }
|
||||
public init(article: RssArticle = .init(), starTime: Int64 = 0) {
|
||||
origin = article.origin; sort = article.sort; title = article.title; self.starTime = starTime
|
||||
link = article.link; pubDate = article.pubDate; description = article.description
|
||||
content = article.content; image = article.image; group = article.group
|
||||
variable = article.variable; type = article.type; durPos = article.durPos
|
||||
}
|
||||
public init(from decoder: Decoder) throws {
|
||||
let article = try RssArticle(from: decoder)
|
||||
self.init(article: article, starTime: try decoder.container(keyedBy: RSSCodingKey.self).int64("starTime") ?? 0)
|
||||
}
|
||||
public var article: RssArticle {
|
||||
var value = RssArticle(origin: origin, sort: sort, title: title, link: link)
|
||||
value.pubDate = pubDate; value.description = description; value.content = content
|
||||
value.image = image; value.group = group; value.variable = variable; value.type = type; value.durPos = durPos
|
||||
return value
|
||||
}
|
||||
public var variableMap: [String: String] { RssArticle.decodeVariables(variable) }
|
||||
public func record(at time: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> RssReadRecord {
|
||||
.init(record: link, title: title, readTime: time, origin: origin, sort: sort, image: image, type: type, durPos: durPos, pubDate: pubDate)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RSSCodingKey: CodingKey {
|
||||
let stringValue: String
|
||||
var intValue: Int? { nil }
|
||||
init?(stringValue: String) { self.stringValue = stringValue }
|
||||
init?(intValue: Int) { return nil }
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer where Key == RSSCodingKey {
|
||||
func key(_ value: String) -> Key { Key(stringValue: value)! }
|
||||
func contains(_ value: String) -> Bool { contains(key(value)) }
|
||||
func string(_ name: String) -> String? {
|
||||
let key = key(name)
|
||||
if let value = try? decode(String.self, forKey: key) { return value }
|
||||
if let value = try? decode(Int64.self, forKey: key) { return String(value) }
|
||||
return nil
|
||||
}
|
||||
func bool(_ name: String) -> Bool? {
|
||||
let key = key(name)
|
||||
if let value = try? decode(Bool.self, forKey: key) { return value }
|
||||
if let value = try? decode(Int.self, forKey: key) { return value != 0 }
|
||||
if let value = string(name)?.lowercased() { return ["true", "1", "yes"].contains(value) ? true : (["false", "0", "no"].contains(value) ? false : nil) }
|
||||
return nil
|
||||
}
|
||||
func int(_ name: String) -> Int? {
|
||||
let key = key(name)
|
||||
if let value = try? decode(Int.self, forKey: key) { return value }
|
||||
return string(name).flatMap(Int.init)
|
||||
}
|
||||
func int64(_ name: String) -> Int64? {
|
||||
let key = key(name)
|
||||
if let value = try? decode(Int64.self, forKey: key) { return value }
|
||||
return string(name).flatMap(Int64.init)
|
||||
}
|
||||
func jsonString(_ name: String) -> String? {
|
||||
if let value = string(name) { return value }
|
||||
let key = key(name)
|
||||
guard let value = try? decode(JSONValue.self, forKey: key) else { return nil }
|
||||
return value.stringValue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
|
||||
public struct SearchBook: Codable, Hashable, Identifiable, Sendable {
|
||||
public var bookURL: String
|
||||
public var origin: String
|
||||
public var originName: String
|
||||
public var type: Int
|
||||
public var name: String
|
||||
public var author: String
|
||||
public var kind: String?
|
||||
public var coverURL: String?
|
||||
public var intro: String?
|
||||
public var wordCount: String?
|
||||
public var latestChapterTitle: String?
|
||||
public var tocURL: String
|
||||
public var time: Int64
|
||||
public var originOrder: Int
|
||||
public var chapterWordCountText: String?
|
||||
public var chapterWordCount: Int
|
||||
public var respondTime: Int
|
||||
public var origins: Set<String>
|
||||
public var variables: [String: String]
|
||||
|
||||
public var id: String { bookURL }
|
||||
public var variable: String? {
|
||||
get {
|
||||
guard !variables.isEmpty, let data = try? JSONEncoder().encode(variables) else { return nil }
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
set {
|
||||
guard let newValue, let data = newValue.data(using: .utf8) else { variables = [:]; return }
|
||||
variables = (try? JSONDecoder().decode([String: String].self, from: data)) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
public init(
|
||||
bookURL: String,
|
||||
origin: String,
|
||||
originName: String,
|
||||
type: Int,
|
||||
name: String,
|
||||
author: String = ""
|
||||
) {
|
||||
self.bookURL = bookURL
|
||||
self.origin = origin
|
||||
self.originName = originName
|
||||
self.type = type
|
||||
self.name = name
|
||||
self.author = author
|
||||
tocURL = ""
|
||||
time = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
originOrder = 0
|
||||
chapterWordCountText = nil
|
||||
chapterWordCount = -1
|
||||
respondTime = -1
|
||||
origins = [origin]
|
||||
variables = [:]
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case bookUrl, origin, originName, type, name, author, kind, coverUrl, intro, wordCount
|
||||
case latestChapterTitle, tocUrl, time, variable, originOrder, chapterWordCountText
|
||||
case chapterWordCount, respondTime
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let value = try decoder.container(keyedBy: CodingKeys.self)
|
||||
bookURL = try value.decodeIfPresent(String.self, forKey: .bookUrl) ?? ""
|
||||
origin = try value.decodeIfPresent(String.self, forKey: .origin) ?? ""
|
||||
originName = try value.decodeIfPresent(String.self, forKey: .originName) ?? ""
|
||||
type = try value.decodeIfPresent(Int.self, forKey: .type) ?? 0
|
||||
name = try value.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
author = try value.decodeIfPresent(String.self, forKey: .author) ?? ""
|
||||
kind = try value.decodeIfPresent(String.self, forKey: .kind)
|
||||
coverURL = try value.decodeIfPresent(String.self, forKey: .coverUrl)
|
||||
intro = try value.decodeIfPresent(String.self, forKey: .intro)
|
||||
wordCount = try value.decodeIfPresent(String.self, forKey: .wordCount)
|
||||
latestChapterTitle = try value.decodeIfPresent(String.self, forKey: .latestChapterTitle)
|
||||
tocURL = try value.decodeIfPresent(String.self, forKey: .tocUrl) ?? ""
|
||||
time = try value.decodeIfPresent(Int64.self, forKey: .time) ?? 0
|
||||
originOrder = try value.decodeIfPresent(Int.self, forKey: .originOrder) ?? 0
|
||||
chapterWordCountText = try value.decodeIfPresent(String.self, forKey: .chapterWordCountText)
|
||||
chapterWordCount = try value.decodeIfPresent(Int.self, forKey: .chapterWordCount) ?? -1
|
||||
respondTime = try value.decodeIfPresent(Int.self, forKey: .respondTime) ?? -1
|
||||
origins = [origin]
|
||||
let rawVariable = try value.decodeIfPresent(String.self, forKey: .variable)
|
||||
variables = rawVariable.flatMap { raw in
|
||||
try? JSONDecoder().decode([String: String].self, from: Data(raw.utf8))
|
||||
} ?? [:]
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var value = encoder.container(keyedBy: CodingKeys.self)
|
||||
try value.encode(bookURL, forKey: .bookUrl)
|
||||
try value.encode(origin, forKey: .origin)
|
||||
try value.encode(originName, forKey: .originName)
|
||||
try value.encode(type, forKey: .type)
|
||||
try value.encode(name, forKey: .name)
|
||||
try value.encode(author, forKey: .author)
|
||||
try value.encodeIfPresent(kind, forKey: .kind)
|
||||
try value.encodeIfPresent(coverURL, forKey: .coverUrl)
|
||||
try value.encodeIfPresent(intro, forKey: .intro)
|
||||
try value.encodeIfPresent(wordCount, forKey: .wordCount)
|
||||
try value.encodeIfPresent(latestChapterTitle, forKey: .latestChapterTitle)
|
||||
try value.encode(tocURL, forKey: .tocUrl)
|
||||
try value.encode(time, forKey: .time)
|
||||
try value.encodeIfPresent(variable, forKey: .variable)
|
||||
try value.encode(originOrder, forKey: .originOrder)
|
||||
try value.encodeIfPresent(chapterWordCountText, forKey: .chapterWordCountText)
|
||||
try value.encode(chapterWordCount, forKey: .chapterWordCount)
|
||||
try value.encode(respondTime, forKey: .respondTime)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user