mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
435 lines
18 KiB
Swift
435 lines
18 KiB
Swift
#if SWIFT_PACKAGE
|
||
import LegadoCore
|
||
#endif
|
||
import Foundation
|
||
import SwiftUI
|
||
|
||
@MainActor
|
||
final class SourceLibraryModel: ObservableObject {
|
||
@Published private(set) var sources: [BookSource] = []
|
||
@Published private(set) var isWorking = false
|
||
@Published var notice: Notice?
|
||
@Published var pendingImport: PendingImport?
|
||
|
||
struct PendingImport: Identifiable, Sendable {
|
||
struct Item: Identifiable, Sendable {
|
||
let source: BookSource
|
||
let existing: BookSource?
|
||
var id: String { source.id }
|
||
|
||
var state: String {
|
||
guard let existing else { return "新增" }
|
||
return source.lastUpdateTime > existing.lastUpdateTime ? "更新" : "已有"
|
||
}
|
||
}
|
||
|
||
struct InvalidItem: Identifiable, Sendable {
|
||
let id = UUID()
|
||
let source: BookSource
|
||
let issues: [BookSourceIssue]
|
||
}
|
||
|
||
let id = UUID()
|
||
let items: [Item]
|
||
let invalidItems: [InvalidItem]
|
||
|
||
var validCount: Int { items.count }
|
||
var invalidCount: Int { invalidItems.count }
|
||
var totalCount: Int { validCount + invalidCount }
|
||
}
|
||
|
||
struct Notice: Identifiable {
|
||
enum Kind { case success, error }
|
||
|
||
let id = UUID()
|
||
let kind: Kind
|
||
let title: String
|
||
let detail: String
|
||
}
|
||
|
||
private let repository: any BookSourceRepository
|
||
private let importer = BookSourceImporter()
|
||
private let importTransport: any HTTPTransport
|
||
|
||
var groups: [String] {
|
||
Array(Set(sources.flatMap(\.groups))).sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||
}
|
||
|
||
init(repository: any BookSourceRepository, importTransport: any HTTPTransport = URLSessionHTTPTransport.shared) {
|
||
self.repository = repository
|
||
self.importTransport = importTransport
|
||
}
|
||
|
||
static func live() -> SourceLibraryModel {
|
||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||
.appendingPathComponent("Legado", isDirectory: true)
|
||
let legacy = base.appendingPathComponent("book-sources.json")
|
||
guard let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) else {
|
||
return SourceLibraryModel(repository: FileBookSourceRepository(fileURL: legacy))
|
||
}
|
||
return SourceLibraryModel(repository: SQLiteBookSourceRepository(database: database, legacyFileURL: legacy))
|
||
}
|
||
|
||
static func demo(sources: [BookSource]) -> SourceLibraryModel {
|
||
SourceLibraryModel(repository: InMemoryBookSourceRepository(sources: sources))
|
||
}
|
||
|
||
func load() async {
|
||
guard sources.isEmpty else { return }
|
||
isWorking = true
|
||
defer { isWorking = false }
|
||
do {
|
||
sources = try await repository.load()
|
||
} catch {
|
||
notice = .init(kind: .error, title: "无法读取书源", detail: error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
func importFiles(_ urls: [URL]) async {
|
||
isWorking = true
|
||
defer { isWorking = false }
|
||
var combined = sources
|
||
var inserted = 0
|
||
var replaced = 0
|
||
var warnings = 0
|
||
do {
|
||
for url in urls {
|
||
let granted = url.startAccessingSecurityScopedResource()
|
||
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
||
let result = try await Task.detached(priority: .userInitiated) { [importer, existing = combined] in
|
||
try importer.prepare(data: Data(contentsOf: url), existing: existing, duplicatePolicy: .replace)
|
||
}.value
|
||
combined = result.sources
|
||
inserted += result.inserted
|
||
replaced += result.replaced
|
||
warnings += result.warnings.count
|
||
}
|
||
try await repository.save(combined)
|
||
sources = combined
|
||
notice = .init(
|
||
kind: .success,
|
||
title: "书源导入完成",
|
||
detail: "新增 \(inserted) 个,覆盖 \(replaced) 个,警告 \(warnings) 条"
|
||
)
|
||
} catch {
|
||
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
func importText(_ text: String) async { await importRemoteURL(text) }
|
||
|
||
func importRemoteURL(_ value: String) async {
|
||
isWorking = true
|
||
defer { isWorking = false }
|
||
do {
|
||
var budget = 100
|
||
let batch = try await resolveImport(value, depth: 0, visited: [], budget: &budget)
|
||
guard batch.invalidItems.isEmpty else {
|
||
throw BookSourceImportError.invalidSources(batch.invalidItems.flatMap(\.issues))
|
||
}
|
||
try await applyImport(JSONEncoder().encode(batch.sources), successTitle: "书源导入完成")
|
||
} catch {
|
||
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
func remove(at offsets: IndexSet) async {
|
||
var updated = sources
|
||
updated.remove(atOffsets: offsets)
|
||
await persist(updated, successTitle: "已删除书源")
|
||
}
|
||
|
||
func remove(sourceIDs: Set<String>) async {
|
||
guard !sourceIDs.isEmpty else { return }
|
||
await persist(sources.filter { !sourceIDs.contains($0.id) }, successTitle: "已删除 \(sourceIDs.count) 个书源")
|
||
}
|
||
|
||
func setEnabled(_ enabled: Bool, for sourceID: String) async {
|
||
var updated = sources
|
||
guard let index = updated.firstIndex(where: { $0.id == sourceID }) else { return }
|
||
updated[index].enabled = enabled
|
||
await persist(updated, successTitle: nil)
|
||
}
|
||
|
||
func applySilentSubscription(_ data: Data) async throws {
|
||
let incoming = try await Task.detached(priority: .userInitiated) { [importer] in
|
||
try importer.decode(data)
|
||
}.value
|
||
var updated = sources
|
||
for var source in incoming {
|
||
if let index = updated.firstIndex(where: { $0.bookSourceUrl == source.bookSourceUrl }) {
|
||
guard updated[index].lastUpdateTime < source.lastUpdateTime else { continue }
|
||
source.bookSourceGroup = updated[index].bookSourceGroup
|
||
updated[index] = source
|
||
} else { updated.append(source) }
|
||
}
|
||
try await repository.save(updated)
|
||
sources = updated
|
||
}
|
||
|
||
func previewFiles(_ urls: [URL]) async {
|
||
isWorking = true
|
||
defer { isWorking = false }
|
||
do {
|
||
var incoming: [String: BookSource] = [:]
|
||
var invalidItems: [PendingImport.InvalidItem] = []
|
||
for url in urls {
|
||
let granted = url.startAccessingSecurityScopedResource()
|
||
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
||
let data = try await Task.detached(priority: .userInitiated) { [importer] in
|
||
try importer.normalizedData(Data(contentsOf: url))
|
||
}.value
|
||
var budget = 100
|
||
let batch = try await resolveImport(String(decoding: data, as: UTF8.self), depth: 0, visited: [], budget: &budget)
|
||
invalidItems.append(contentsOf: batch.invalidItems)
|
||
for source in batch.sources {
|
||
incoming[source.id] = source
|
||
}
|
||
}
|
||
// UIDocumentPicker is still dismissing when its completion runs.
|
||
// Presenting the review sheet in that same frame is ignored by
|
||
// SwiftUI, so stage it after the picker has left the hierarchy.
|
||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||
stage(Array(incoming.values), invalidItems: invalidItems)
|
||
} catch {
|
||
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
func previewText(_ text: String) async { await previewInput(text) }
|
||
|
||
func previewRemoteURL(_ value: String) async { await previewInput(value) }
|
||
|
||
func previewInput(_ value: String) async {
|
||
isWorking = true
|
||
defer { isWorking = false }
|
||
do {
|
||
var budget = 100
|
||
let batch = try await resolveImport(value, depth: 0, visited: [], budget: &budget)
|
||
stage(batch.sources, invalidItems: batch.invalidItems)
|
||
} catch {
|
||
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
private nonisolated func resolveImport(_ value: String, depth: Int, visited: Set<String>, budget: inout Int) async throws -> (
|
||
sources: [BookSource], invalidItems: [PendingImport.InvalidItem]
|
||
) {
|
||
guard depth < 8 else { throw BookSourceImportError.invalidDocument("订阅清单嵌套过深") }
|
||
let input = try RuleImportInput.parse(value)
|
||
let data: Data
|
||
var visited = visited
|
||
switch input {
|
||
case .json(let json): data = Data(json.utf8)
|
||
case .remote(let url, _):
|
||
guard budget > 0 else { throw BookSourceImportError.invalidDocument("订阅清单的总链接数超过 100") }
|
||
budget -= 1
|
||
guard visited.insert(url.absoluteString).inserted else {
|
||
throw BookSourceImportError.invalidDocument("订阅清单出现循环链接")
|
||
}
|
||
let response = try await importTransport.data(for: input.request()!)
|
||
guard (200...299).contains(response.statusCode) else {
|
||
throw BookSourceImportError.invalidDocument("服务器返回 HTTP \(response.statusCode)")
|
||
}
|
||
data = response.data
|
||
}
|
||
let normalized = try importer.normalizedData(data)
|
||
if case .array(let urls) = (try JSONValue.decode(data: normalized))["sourceUrls"] {
|
||
guard !urls.isEmpty, urls.count <= 100 else { throw BookSourceImportError.invalidDocument("订阅清单需包含 1–100 个链接") }
|
||
var sources: [BookSource] = []
|
||
var invalid: [PendingImport.InvalidItem] = []
|
||
for item in urls {
|
||
guard case .string(let url) = item else { throw BookSourceImportError.invalidDocument("sourceUrls 必须是链接数组") }
|
||
let batch = try await resolveImport(url, depth: depth + 1, visited: visited, budget: &budget)
|
||
sources += batch.sources
|
||
invalid += batch.invalidItems
|
||
}
|
||
return (sources, invalid)
|
||
}
|
||
return try validatedSources(from: normalized)
|
||
}
|
||
|
||
func cancelPendingImport() { pendingImport = nil }
|
||
|
||
func commitPendingImport(
|
||
selectedIDs: Set<String>,
|
||
keepOriginalName: Bool,
|
||
keepOriginalGroup: Bool,
|
||
keepOriginalEnabled: Bool,
|
||
customGroup: String?,
|
||
addCustomGroup: Bool
|
||
) async {
|
||
guard let pendingImport else { return }
|
||
var updated = sources
|
||
var inserted = 0
|
||
var replaced = 0
|
||
for item in pendingImport.items where selectedIDs.contains(item.id) {
|
||
var source = item.source
|
||
if let existing = item.existing {
|
||
if keepOriginalName { source.bookSourceName = existing.bookSourceName }
|
||
if keepOriginalGroup { source.bookSourceGroup = existing.bookSourceGroup }
|
||
if keepOriginalEnabled {
|
||
source.enabled = existing.enabled
|
||
source.enabledExplore = existing.enabledExplore
|
||
}
|
||
source.customOrder = existing.customOrder
|
||
}
|
||
if let customGroup, !BookSource.parseGroups(customGroup).isEmpty {
|
||
if addCustomGroup { source.addGroups(customGroup) }
|
||
else { source.bookSourceGroup = BookSource.joinGroups(BookSource.parseGroups(customGroup)) }
|
||
}
|
||
if let index = updated.firstIndex(where: { $0.id == source.id }) {
|
||
updated[index] = source
|
||
replaced += 1
|
||
} else {
|
||
updated.append(source)
|
||
inserted += 1
|
||
}
|
||
}
|
||
do {
|
||
try await repository.save(updated)
|
||
sources = updated
|
||
self.pendingImport = nil
|
||
notice = .init(
|
||
kind: .success,
|
||
title: "书源导入完成",
|
||
detail: "新增 \(inserted) 个,更新 \(replaced) 个"
|
||
)
|
||
} catch {
|
||
notice = .init(kind: .error, title: "保存失败", detail: error.localizedDescription)
|
||
return
|
||
}
|
||
}
|
||
|
||
func setExploreEnabled(_ enabled: Bool, for sourceID: String) async {
|
||
var updated = sources
|
||
guard let index = updated.firstIndex(where: { $0.id == sourceID }) else { return }
|
||
updated[index].enabledExplore = enabled
|
||
await persist(updated, successTitle: nil)
|
||
}
|
||
|
||
func saveSource(_ source: BookSource, replacing oldSourceID: String? = nil) async -> Bool {
|
||
let errors = BookSourceValidator.validate(source).filter { $0.severity == .error }
|
||
guard errors.isEmpty else {
|
||
notice = .init(kind: .error, title: "书源无效", detail: errors.map(\.message).joined(separator: ";"))
|
||
return false
|
||
}
|
||
let oldSourceID = oldSourceID ?? source.id
|
||
var updated = sources
|
||
if updated.contains(where: { $0.id == oldSourceID }) {
|
||
if source.id != oldSourceID, updated.contains(where: { $0.id == source.id }) {
|
||
notice = .init(kind: .error, title: "地址冲突", detail: "该地址已有书源,请使用导入预览选择更新。")
|
||
return false
|
||
}
|
||
guard let replacement = updated.firstIndex(where: { $0.id == oldSourceID }) else { return false }
|
||
updated[replacement] = source
|
||
} else if let index = updated.firstIndex(where: { $0.id == source.id }) {
|
||
updated[index] = source
|
||
} else {
|
||
updated.append(source)
|
||
}
|
||
return await persist(updated, successTitle: "书源已保存")
|
||
}
|
||
|
||
func setEnabled(_ enabled: Bool, for sourceIDs: Set<String>) async {
|
||
guard !sourceIDs.isEmpty else { return }
|
||
var updated = sources
|
||
for index in updated.indices where sourceIDs.contains(updated[index].id) {
|
||
updated[index].enabled = enabled
|
||
}
|
||
await persist(updated, successTitle: enabled ? "已批量启用" : "已批量停用")
|
||
}
|
||
|
||
func addGroup(_ group: String, to sourceIDs: Set<String>) async {
|
||
let values = BookSource.parseGroups(group)
|
||
guard !values.isEmpty, !sourceIDs.isEmpty else { return }
|
||
var updated = sources
|
||
for index in updated.indices where sourceIDs.contains(updated[index].id) {
|
||
updated[index].addGroups(values.joined(separator: ","))
|
||
}
|
||
await persist(updated, successTitle: "已加入分组 \(values.joined(separator: "、"))")
|
||
}
|
||
|
||
func removeGroup(_ group: String, from sourceIDs: Set<String>) async {
|
||
guard !sourceIDs.isEmpty else { return }
|
||
var updated = sources
|
||
for index in updated.indices where sourceIDs.contains(updated[index].id) {
|
||
updated[index].removeGroups(group)
|
||
}
|
||
await persist(updated, successTitle: "已移出分组")
|
||
}
|
||
|
||
func renameGroup(_ oldValue: String, to newValue: String) async {
|
||
guard !BookSource.parseGroups(newValue).isEmpty else { return }
|
||
var updated = sources
|
||
for index in updated.indices where updated[index].hasGroup(oldValue) {
|
||
updated[index].replaceGroup(oldValue, with: newValue)
|
||
}
|
||
await persist(updated, successTitle: "分组已重命名")
|
||
}
|
||
|
||
func deleteGroup(_ group: String) async {
|
||
var updated = sources
|
||
for index in updated.indices where updated[index].hasGroup(group) {
|
||
updated[index].removeGroups(group)
|
||
}
|
||
await persist(updated, successTitle: "已删除分组 \(group)")
|
||
}
|
||
|
||
func handleDeepLink(_ url: URL) async {
|
||
if url.isFileURL {
|
||
await previewFiles([url])
|
||
return
|
||
}
|
||
await previewInput(url.absoluteString)
|
||
}
|
||
|
||
@discardableResult
|
||
private func persist(_ updated: [BookSource], successTitle: String?) async -> Bool {
|
||
do {
|
||
try await repository.save(updated)
|
||
sources = updated
|
||
if let successTitle {
|
||
notice = .init(kind: .success, title: successTitle, detail: "")
|
||
}
|
||
return true
|
||
} catch {
|
||
notice = .init(kind: .error, title: "保存失败", detail: error.localizedDescription)
|
||
return false
|
||
}
|
||
}
|
||
|
||
private func applyImport(_ data: Data, successTitle: String) async throws {
|
||
let result = try await Task.detached(priority: .userInitiated) { [importer, existing = sources] in
|
||
try importer.prepare(data: data, existing: existing, duplicatePolicy: .replace)
|
||
}.value
|
||
try await repository.save(result.sources)
|
||
sources = result.sources
|
||
notice = .init(
|
||
kind: .success,
|
||
title: successTitle,
|
||
detail: "新增 \(result.inserted) 个,覆盖 \(result.replaced) 个,警告 \(result.warnings.count) 条"
|
||
)
|
||
}
|
||
|
||
private nonisolated func validatedSources(from data: Data) throws -> (
|
||
sources: [BookSource],
|
||
invalidItems: [PendingImport.InvalidItem]
|
||
) {
|
||
let result = try importer.inspect(data)
|
||
return (result.sources, result.rejected.map { .init(source: $0.source, issues: $0.issues) })
|
||
}
|
||
|
||
private func stage(
|
||
_ incoming: [BookSource],
|
||
invalidItems: [PendingImport.InvalidItem] = []
|
||
) {
|
||
let existingByID = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, $0) })
|
||
let unique = incoming.reduce(into: [String: BookSource]()) { $0[$1.id] = $1 }
|
||
pendingImport = .init(items: Array(unique.values)
|
||
.sorted { $0.bookSourceName.localizedStandardCompare($1.bookSourceName) == .orderedAscending }
|
||
.map { .init(source: $0, existing: existingByID[$0.id]) },
|
||
invalidItems: invalidItems)
|
||
}
|
||
}
|