Files
legado-ios/App/Features/Sources/SourceLibraryModel.swift
T

435 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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)
}
}