mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 12:51:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
import Compression
|
||||
import CoreFoundation
|
||||
import Foundation
|
||||
|
||||
public enum DuplicatePolicy: Sendable {
|
||||
case replace
|
||||
case skip
|
||||
case fail
|
||||
}
|
||||
|
||||
public enum BookSourceImportError: Error, Equatable, LocalizedError {
|
||||
case emptyDocument
|
||||
case invalidDocument(String)
|
||||
case invalidSources([BookSourceIssue])
|
||||
case duplicate(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyDocument:
|
||||
return "导入内容为空"
|
||||
case .invalidDocument(let detail):
|
||||
return "无法解析书源 JSON:\(detail)"
|
||||
case .invalidSources(let issues):
|
||||
return issues.map(\.message).joined(separator: ";")
|
||||
case .duplicate(let url):
|
||||
return "书源已存在:\(url)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookSourceImportResult: Sendable {
|
||||
public var sources: [BookSource]
|
||||
public var inserted: Int
|
||||
public var replaced: Int
|
||||
public var skipped: Int
|
||||
public var warnings: [BookSourceIssue]
|
||||
|
||||
public init(
|
||||
sources: [BookSource],
|
||||
inserted: Int,
|
||||
replaced: Int,
|
||||
skipped: Int,
|
||||
warnings: [BookSourceIssue]
|
||||
) {
|
||||
self.sources = sources
|
||||
self.inserted = inserted
|
||||
self.replaced = replaced
|
||||
self.skipped = skipped
|
||||
self.warnings = warnings
|
||||
}
|
||||
}
|
||||
|
||||
public struct BookSourceImporter: Sendable {
|
||||
public static let maximumDocumentBytes = 50 * 1_024 * 1_024
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
public init() {
|
||||
decoder = JSONDecoder()
|
||||
}
|
||||
|
||||
public struct RejectedSource: Sendable {
|
||||
public var source: BookSource
|
||||
public var issues: [BookSourceIssue]
|
||||
}
|
||||
|
||||
public struct Inspection: Sendable {
|
||||
public var sources: [BookSource]
|
||||
public var rejected: [RejectedSource]
|
||||
}
|
||||
|
||||
public func inspect(_ data: Data) throws -> Inspection {
|
||||
let cleaned = try normalizedData(data)
|
||||
let root: JSONValue
|
||||
do { root = try JSONValue.decode(data: cleaned) }
|
||||
catch { throw BookSourceImportError.invalidDocument(error.localizedDescription) }
|
||||
let entries: [JSONValue]
|
||||
switch root {
|
||||
case .array(let array): entries = array
|
||||
case .object: entries = [root]
|
||||
default: throw BookSourceImportError.invalidDocument("需要书源对象或数组")
|
||||
}
|
||||
guard !entries.isEmpty else { throw BookSourceImportError.emptyDocument }
|
||||
var sources: [BookSource] = []
|
||||
var rejected: [RejectedSource] = []
|
||||
for (index, entry) in entries.enumerated() {
|
||||
do {
|
||||
guard case .object = entry else { throw BookSourceImportError.invalidDocument("第 \(index + 1) 项不是对象") }
|
||||
let source = try decoder.decode(BookSource.self, from: JSONEncoder().encode(entry))
|
||||
let errors = BookSourceValidator.validate(source).filter { $0.severity == .error }
|
||||
if errors.isEmpty { sources.append(source) }
|
||||
else { rejected.append(.init(source: source, issues: errors)) }
|
||||
} catch {
|
||||
let placeholder = BookSource(bookSourceUrl: entry["bookSourceUrl"]?.stringValue ?? "",
|
||||
bookSourceName: entry["bookSourceName"]?.stringValue ?? "第 \(index + 1) 项")
|
||||
rejected.append(.init(source: placeholder, issues: [.init(severity: .error, code: "source.decode",
|
||||
field: "[\(index)]", message: "第 \(index + 1) 项:\(error.localizedDescription)")]))
|
||||
}
|
||||
}
|
||||
return .init(sources: sources, rejected: rejected)
|
||||
}
|
||||
|
||||
public func decode(_ data: Data) throws -> [BookSource] {
|
||||
let result = try inspect(data)
|
||||
guard result.rejected.isEmpty else { throw BookSourceImportError.invalidSources(result.rejected.flatMap(\.issues)) }
|
||||
return result.sources
|
||||
}
|
||||
|
||||
public func normalizedData(_ data: Data) throws -> Data {
|
||||
guard data.count <= Self.maximumDocumentBytes else { throw BookSourceImportError.invalidDocument("书源文件超过 50 MB") }
|
||||
let cleaned = decodeText(stripUTF8BOM(decompressIfGzip(data)))
|
||||
guard cleaned.count <= Self.maximumDocumentBytes else { throw BookSourceImportError.invalidDocument("解压后书源超过 50 MB") }
|
||||
guard !String(decoding: cleaned, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw BookSourceImportError.emptyDocument
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
public func prepare(
|
||||
data: Data,
|
||||
existing: [BookSource],
|
||||
duplicatePolicy: DuplicatePolicy = .replace
|
||||
) throws -> BookSourceImportResult {
|
||||
let incoming = try decode(data)
|
||||
var fatalIssues: [BookSourceIssue] = []
|
||||
var warnings: [BookSourceIssue] = []
|
||||
for source in incoming {
|
||||
for issue in BookSourceValidator.validate(source) {
|
||||
if issue.severity == .error {
|
||||
fatalIssues.append(issue)
|
||||
} else {
|
||||
warnings.append(issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
guard fatalIssues.isEmpty else {
|
||||
throw BookSourceImportError.invalidSources(fatalIssues)
|
||||
}
|
||||
|
||||
var merged = existing
|
||||
var index = Dictionary(uniqueKeysWithValues: merged.enumerated().map { ($1.id, $0) })
|
||||
var inserted = 0
|
||||
var replaced = 0
|
||||
var skipped = 0
|
||||
|
||||
for source in incoming {
|
||||
if let existingIndex = index[source.id] {
|
||||
switch duplicatePolicy {
|
||||
case .replace:
|
||||
merged[existingIndex] = source
|
||||
replaced += 1
|
||||
case .skip:
|
||||
skipped += 1
|
||||
case .fail:
|
||||
throw BookSourceImportError.duplicate(source.id)
|
||||
}
|
||||
} else {
|
||||
index[source.id] = merged.count
|
||||
merged.append(source)
|
||||
inserted += 1
|
||||
}
|
||||
}
|
||||
return .init(
|
||||
sources: merged,
|
||||
inserted: inserted,
|
||||
replaced: replaced,
|
||||
skipped: skipped,
|
||||
warnings: warnings
|
||||
)
|
||||
}
|
||||
|
||||
private func stripUTF8BOM(_ data: Data) -> Data {
|
||||
let bom = Data([0xEF, 0xBB, 0xBF])
|
||||
return data.starts(with: bom) ? Data(data.dropFirst(3)) : data
|
||||
}
|
||||
|
||||
private func decodeText(_ data: Data) -> Data {
|
||||
if let value = String(data: data, encoding: .utf8) { return Data(value.utf8) }
|
||||
if data.starts(with: [0xFF, 0xFE]), let value = String(data: data, encoding: .utf16LittleEndian) {
|
||||
return Data(value.utf8)
|
||||
}
|
||||
if data.starts(with: [0xFE, 0xFF]), let value = String(data: data, encoding: .utf16BigEndian) {
|
||||
return Data(value.utf8)
|
||||
}
|
||||
if let value = String(data: data, encoding: Self.gb18030Encoding) {
|
||||
return Data(value.utf8)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private static var gb18030Encoding: String.Encoding {
|
||||
String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)))
|
||||
}
|
||||
|
||||
private func decompressIfGzip(_ data: Data) -> Data {
|
||||
guard data.count > 10, data[0] == 0x1F, data[1] == 0x8B, data[2] == 0x08 else { return data }
|
||||
var offset = 10
|
||||
let flags = Int(data[3])
|
||||
if flags & 0x04 != 0 {
|
||||
guard offset + 2 <= data.count - 8 else { return data }
|
||||
let extraLength = Int(data[offset]) | (Int(data[offset + 1]) << 8)
|
||||
offset += 2 + extraLength
|
||||
}
|
||||
if flags & 0x08 != 0 {
|
||||
while offset < data.count, data[offset] != 0 { offset += 1 }
|
||||
offset += 1
|
||||
}
|
||||
if flags & 0x10 != 0 {
|
||||
while offset < data.count, data[offset] != 0 { offset += 1 }
|
||||
offset += 1
|
||||
}
|
||||
if flags & 0x02 != 0 { offset += 2 }
|
||||
guard offset < data.count - 8 else { return data }
|
||||
let payload = Data(data[offset..<data.count - min(8, data.count - offset)])
|
||||
return inflate(payload) ?? data
|
||||
}
|
||||
|
||||
private func inflate(_ payload: Data) -> Data? {
|
||||
var capacity = max(payload.count * 4, 64 * 1_024)
|
||||
while capacity > 0, capacity <= Self.maximumDocumentBytes + 1 {
|
||||
let destination = UnsafeMutablePointer<UInt8>.allocate(capacity: capacity)
|
||||
let produced = payload.withUnsafeBytes { (source: UnsafeRawBufferPointer) -> Int in
|
||||
guard let base = source.bindMemory(to: UInt8.self).baseAddress else { return 0 }
|
||||
return compression_decode_buffer(
|
||||
destination, capacity,
|
||||
base, payload.count,
|
||||
nil,
|
||||
COMPRESSION_ZLIB
|
||||
)
|
||||
}
|
||||
if produced > 0, produced < capacity {
|
||||
let result = Data(bytes: destination, count: produced)
|
||||
destination.deallocate()
|
||||
return result
|
||||
}
|
||||
destination.deallocate()
|
||||
guard produced == capacity, capacity <= Int.max / 2 else { return nil }
|
||||
capacity = min(capacity * 2, Self.maximumDocumentBytes + 1)
|
||||
if capacity == produced { return nil }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
public struct BookSourceIssue: Codable, Hashable, Identifiable, Sendable {
|
||||
public enum Severity: String, Codable, Sendable {
|
||||
case warning
|
||||
case error
|
||||
}
|
||||
|
||||
public var severity: Severity
|
||||
public var code: String
|
||||
public var field: String
|
||||
public var message: String
|
||||
|
||||
public var id: String { "\(code):\(field):\(message)" }
|
||||
|
||||
public init(severity: Severity, code: String, field: String, message: String) {
|
||||
self.severity = severity
|
||||
self.code = code
|
||||
self.field = field
|
||||
self.message = message
|
||||
}
|
||||
}
|
||||
|
||||
public enum BookSourceValidator {
|
||||
public static func validate(_ source: BookSource) -> [BookSourceIssue] {
|
||||
var issues: [BookSourceIssue] = []
|
||||
if source.bookSourceUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
issues.append(.init(
|
||||
severity: .error,
|
||||
code: "source_url.empty",
|
||||
field: "bookSourceUrl",
|
||||
message: "书源地址不能为空"
|
||||
))
|
||||
}
|
||||
if source.bookSourceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
issues.append(.init(
|
||||
severity: .error,
|
||||
code: "source_name.empty",
|
||||
field: "bookSourceName",
|
||||
message: "书源名称不能为空"
|
||||
))
|
||||
}
|
||||
if !source.bookSourceUrl.isEmpty,
|
||||
let components = URLComponents(string: source.bookSourceUrl),
|
||||
components.scheme == nil {
|
||||
issues.append(.init(
|
||||
severity: .warning,
|
||||
code: "source_url.no_scheme",
|
||||
field: "bookSourceUrl",
|
||||
message: "书源地址没有 URL scheme,运行时可能无法解析相对地址"
|
||||
))
|
||||
}
|
||||
if source.searchUrl?.isEmpty == false, source.ruleSearch == nil {
|
||||
issues.append(.init(
|
||||
severity: .warning,
|
||||
code: "search.rule_missing",
|
||||
field: "ruleSearch",
|
||||
message: "配置了搜索地址但没有搜索解析规则"
|
||||
))
|
||||
}
|
||||
if source.exploreUrl?.isEmpty == false, source.ruleExplore == nil {
|
||||
issues.append(.init(
|
||||
severity: .warning,
|
||||
code: "explore.rule_missing",
|
||||
field: "ruleExplore",
|
||||
message: "配置了发现地址但没有发现解析规则"
|
||||
))
|
||||
}
|
||||
return issues
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
import Vision
|
||||
|
||||
public struct QRCodeDecoder: Sendable {
|
||||
public init() {}
|
||||
public func detect(in data: Data) throws -> [String] {
|
||||
let request = VNDetectBarcodesRequest()
|
||||
request.symbologies = [.qr]
|
||||
try VNImageRequestHandler(data: data).perform([request])
|
||||
var seen = Set<String>()
|
||||
return (request.results ?? []).compactMap(\.payloadStringValue).filter { seen.insert($0).inserted }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
public struct RSSSourceImportPayload: Sendable {
|
||||
public var sources: [RssSource]
|
||||
public var subscriptionURLs: [String]
|
||||
public init(sources: [RssSource] = [], subscriptionURLs: [String] = []) {
|
||||
self.sources = sources
|
||||
self.subscriptionURLs = subscriptionURLs
|
||||
}
|
||||
}
|
||||
|
||||
public enum RSSSourceImportError: Error, LocalizedError, Sendable {
|
||||
case emptyDocument
|
||||
case wrongFormat
|
||||
case invalidSource
|
||||
case duplicate(String)
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyDocument: "导入内容为空"
|
||||
case .wrongFormat: "RSS源格式错误"
|
||||
case .invalidSource: "不是订阅源"
|
||||
case .duplicate(let url): "RSS源已存在:\(url)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct RSSSourceImporter: Sendable {
|
||||
public init() {}
|
||||
|
||||
public func decode(_ data: Data) throws -> RSSSourceImportPayload {
|
||||
var data = data
|
||||
if data.starts(with: [0xEF, 0xBB, 0xBF]) { data = Data(data.dropFirst(3)) }
|
||||
guard !data.isEmpty else { throw RSSSourceImportError.emptyDocument }
|
||||
let decoder = JSONDecoder()
|
||||
if let manifest = try? decoder.decode(SubscriptionManifest.self, from: data), !manifest.sourceUrls.isEmpty {
|
||||
return .init(subscriptionURLs: manifest.sourceUrls)
|
||||
}
|
||||
let sources: [RssSource]
|
||||
if let values = try? decoder.decode([RssSource].self, from: data) { sources = values }
|
||||
else if let value = try? decoder.decode(RssSource.self, from: data) { sources = [value] }
|
||||
else { throw RSSSourceImportError.wrongFormat }
|
||||
guard sources.isEmpty || sources.first?.sourceUrl.isEmpty == false else { throw RSSSourceImportError.invalidSource }
|
||||
return .init(sources: sources)
|
||||
}
|
||||
|
||||
public func merge(
|
||||
_ incoming: [RssSource],
|
||||
into existing: [RssSource],
|
||||
duplicatePolicy: DuplicatePolicy = .replace,
|
||||
addGroup: String? = nil
|
||||
) throws -> [RssSource] {
|
||||
var result = existing
|
||||
var indexes = Dictionary(uniqueKeysWithValues: result.enumerated().map { ($0.element.sourceUrl, $0.offset) })
|
||||
for original in incoming where !original.sourceUrl.isEmpty {
|
||||
var source = original
|
||||
if let addGroup, !addGroup.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { source.addGroups(addGroup) }
|
||||
if let index = indexes[source.sourceUrl] {
|
||||
switch duplicatePolicy {
|
||||
case .replace: result[index] = source
|
||||
case .skip: continue
|
||||
case .fail: throw RSSSourceImportError.duplicate(source.sourceUrl)
|
||||
}
|
||||
} else {
|
||||
indexes[source.sourceUrl] = result.count
|
||||
result.append(source)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public func defaultSelection(incoming: [RssSource], existing: [RssSource]) -> [Bool] {
|
||||
let current = Dictionary(uniqueKeysWithValues: existing.map { ($0.sourceUrl, $0.lastUpdateTime) })
|
||||
return incoming.map { source in
|
||||
guard let oldTime = current[source.sourceUrl] else { return true }
|
||||
return oldTime < source.lastUpdateTime
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(_ sources: [RssSource], prettyPrinted: Bool = true) throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
if prettyPrinted { encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] }
|
||||
return try encoder.encode(sources)
|
||||
}
|
||||
|
||||
private struct SubscriptionManifest: Decodable {
|
||||
var sourceUrls: [String]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
|
||||
public enum RuleImportInput: Sendable, Equatable {
|
||||
case json(String)
|
||||
case remote(URL, withoutUserAgent: Bool)
|
||||
|
||||
public static func parse(_ input: String, kind: String = "bookSource") throws -> Self {
|
||||
var value = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.hasPrefix("{"), value.hasSuffix("}") { return .json(value) }
|
||||
if value.hasPrefix("["), value.hasSuffix("]"), (try? JSONValue.decode(data: Data(value.utf8))) != nil {
|
||||
return .json(value)
|
||||
}
|
||||
if let start = value.range(of: "]("), value.hasSuffix(")") {
|
||||
value = String(value[start.upperBound..<value.index(before: value.endIndex)])
|
||||
}
|
||||
value = value.trimmingCharacters(in: CharacterSet(charactersIn: "*`<> \n\r\t"))
|
||||
if let url = URL(string: value), url.scheme?.lowercased() == "legado" {
|
||||
guard url.host?.lowercased() == "import", url.path.lowercased() == "/" + kind.lowercased(),
|
||||
let src = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "src" })?.value,
|
||||
!src.lowercased().hasPrefix("legado:") else {
|
||||
throw BookSourceImportError.invalidDocument("二维码或深链类型不匹配,需要 \(kind)")
|
||||
}
|
||||
return try parse(src, kind: kind)
|
||||
}
|
||||
let marker = "#requestWithoutUA"
|
||||
let withoutUA = value.hasSuffix(marker)
|
||||
if withoutUA { value = String(value.dropLast(marker.count)) }
|
||||
guard let url = URL(string: value), let scheme = url.scheme?.lowercased(),
|
||||
["http", "https"].contains(scheme), url.host?.isEmpty == false else {
|
||||
throw BookSourceImportError.invalidDocument("请输入 JSON、HTTP(S) 地址或 Legado 导入二维码")
|
||||
}
|
||||
return .remote(url, withoutUserAgent: withoutUA)
|
||||
}
|
||||
|
||||
public func request() -> URLRequest? {
|
||||
guard case .remote(let url, let withoutUA) = self else { return nil }
|
||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 45)
|
||||
request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")
|
||||
if withoutUA { request.setValue("null", forHTTPHeaderField: "User-Agent") }
|
||||
return request
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user