mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"images": [
|
||||
{ "filename": "BrandIcon.png", "idiom": "universal" }
|
||||
],
|
||||
"info": { "author": "xcode", "version": 1 }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"images": [
|
||||
{ "filename": "profile-reading-header.png", "idiom": "universal" }
|
||||
],
|
||||
"info": { "author": "xcode", "version": 1 }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,342 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum ReadingCompatibility {
|
||||
static var usesLegacyUI: Bool {
|
||||
#if DEBUG
|
||||
ProcessInfo.processInfo.arguments.contains("-uiLegacyCompatibility")
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadingNavigationStack<Content: View>: View {
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
NavigationStack { content }
|
||||
} else {
|
||||
NavigationView { content }.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadingLabeledContent<Content: View>: View {
|
||||
let title: String
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
init(_ title: String, @ViewBuilder content: () -> Content) {
|
||||
self.title = title
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
LabeledContent(title) { content }
|
||||
} else {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
content.foregroundStyle(.secondary).multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ReadingLabeledContent where Content == Text {
|
||||
init(_ title: String, value: String) {
|
||||
self.init(title) { Text(value) }
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadingTextField: View {
|
||||
let title: String
|
||||
@Binding var text: String
|
||||
var axis: Axis
|
||||
|
||||
init(_ title: String, text: Binding<String>, axis: Axis) {
|
||||
self.title = title
|
||||
_text = text
|
||||
self.axis = axis
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
TextField(title, text: $text, axis: axis)
|
||||
} else if axis == .vertical {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title).font(.caption).foregroundStyle(.secondary)
|
||||
TextEditor(text: $text).frame(minHeight: 80).accessibilityLabel(title)
|
||||
}
|
||||
} else {
|
||||
TextField(title, text: $text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadingShareLink<Label: View>: View {
|
||||
private let items: [Any]
|
||||
private let label: Label
|
||||
|
||||
init(item: String, @ViewBuilder label: () -> Label) {
|
||||
items = [item]
|
||||
self.label = label()
|
||||
}
|
||||
|
||||
init(item: URL, @ViewBuilder label: () -> Label) {
|
||||
items = [item]
|
||||
self.label = label()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button { presentShareSheet() } label: { label }
|
||||
}
|
||||
|
||||
private func presentShareSheet() {
|
||||
guard var presenter = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.filter({ $0.activationState == .foregroundActive })
|
||||
.flatMap(\.windows)
|
||||
.first(where: \.isKeyWindow)?.rootViewController else { return }
|
||||
while let presented = presenter.presentedViewController { presenter = presented }
|
||||
guard !(presenter is UIActivityViewController) else { return }
|
||||
let activity = UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
if let popover = activity.popoverPresentationController {
|
||||
popover.sourceView = presenter.view
|
||||
popover.sourceRect = CGRect(x: presenter.view.bounds.midX, y: presenter.view.bounds.midY, width: 0, height: 0)
|
||||
popover.permittedArrowDirections = []
|
||||
}
|
||||
presenter.present(activity, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension ReadingShareLink where Label == SwiftUI.Label<Text, Image> {
|
||||
init(item: URL) {
|
||||
self.init(item: item) { SwiftUI.Label("分享", systemImage: "square.and.arrow.up") }
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadingPhotoPicker<Label: View>: View {
|
||||
let onData: (Data) -> Void
|
||||
let onError: (Error) -> Void
|
||||
@ViewBuilder var label: Label
|
||||
@State private var isPresented = false
|
||||
|
||||
var body: some View {
|
||||
Button { isPresented = true } label: { label }
|
||||
.sheet(isPresented: $isPresented) {
|
||||
ReadingPhotoPickerController { result in
|
||||
isPresented = false
|
||||
guard let result else { return }
|
||||
switch result {
|
||||
case .success(let data): onData(data)
|
||||
case .failure(let error): onError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReadingPhotoPickerController: UIViewControllerRepresentable {
|
||||
let onResult: @MainActor (Result<Data, Error>?) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(onResult: onResult) }
|
||||
|
||||
func makeUIViewController(context: Context) -> PHPickerViewController {
|
||||
var configuration = PHPickerConfiguration()
|
||||
configuration.filter = .images
|
||||
configuration.selectionLimit = 1
|
||||
let picker = PHPickerViewController(configuration: configuration)
|
||||
picker.delegate = context.coordinator
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: PHPickerViewController, context: Context) {}
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, PHPickerViewControllerDelegate {
|
||||
let onResult: @MainActor (Result<Data, Error>?) -> Void
|
||||
|
||||
init(onResult: @escaping @MainActor (Result<Data, Error>?) -> Void) {
|
||||
self.onResult = onResult
|
||||
}
|
||||
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
guard let provider = results.first?.itemProvider else { onResult(nil); return }
|
||||
let identifier = provider.registeredTypeIdentifiers.first {
|
||||
UTType($0)?.conforms(to: .image) == true
|
||||
} ?? UTType.image.identifier
|
||||
provider.loadDataRepresentation(forTypeIdentifier: identifier) { [weak self] data, error in
|
||||
Task { @MainActor in
|
||||
if let data { self?.onResult(.success(data)) }
|
||||
else { self?.onResult(.failure(error ?? CocoaError(.fileReadCorruptFile))) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ReadingSheetDetent { case medium, large }
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func readingNumericTransition() -> some View {
|
||||
if #available(iOS 16.0, *) { contentTransition(.numericText()) }
|
||||
else { self }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func readingNavigationDestination<Destination: View>(
|
||||
isPresented: Binding<Bool>, @ViewBuilder destination: () -> Destination
|
||||
) -> some View {
|
||||
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
navigationDestination(isPresented: isPresented, destination: destination)
|
||||
} else {
|
||||
background(NavigationLink(isActive: isPresented, destination: destination) { EmptyView() }.hidden())
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func readingScrollContentBackgroundHidden() -> some View {
|
||||
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
scrollContentBackground(.hidden)
|
||||
} else {
|
||||
background(ReadingUIKitConfiguration { controller in
|
||||
func clearTables(in view: UIView) {
|
||||
if let table = view as? UITableView { table.backgroundColor = .clear }
|
||||
for child in view.subviews { clearTables(in: child) }
|
||||
}
|
||||
if let parent = controller.parent { clearTables(in: parent.view) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func readingScrollDismissesKeyboard() -> some View {
|
||||
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
scrollDismissesKeyboard(.immediately)
|
||||
} else {
|
||||
simultaneousGesture(DragGesture().onChanged { _ in
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func readingTabBarBackground() -> some View {
|
||||
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
toolbarBackground(ReadingTheme.canvas, for: .tabBar).toolbarBackground(.visible, for: .tabBar)
|
||||
} else {
|
||||
background(ReadingUIKitConfiguration { controller in
|
||||
let appearance = UITabBarAppearance()
|
||||
appearance.configureWithOpaqueBackground()
|
||||
appearance.backgroundColor = UIColor(ReadingTheme.canvas)
|
||||
controller.tabBarController?.tabBar.standardAppearance = appearance
|
||||
controller.tabBarController?.tabBar.scrollEdgeAppearance = appearance
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func readingTabBarHidden() -> some View {
|
||||
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
toolbar(.hidden, for: .tabBar)
|
||||
} else {
|
||||
background(ReadingHiddenTabBar())
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func readingHomeIndicatorHidden(_ hidden: Bool) -> some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
persistentSystemOverlays(hidden ? .hidden : .automatic)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func readingPresentationDetents(_ detents: [ReadingSheetDetent]) -> some View {
|
||||
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
presentationDetents(Set(detents.map { $0 == .medium ? PresentationDetent.medium : .large }))
|
||||
} else {
|
||||
background(ReadingUIKitConfiguration { controller in
|
||||
var current: UIViewController? = controller
|
||||
while let candidate = current {
|
||||
if let sheet = candidate.presentationController as? UISheetPresentationController {
|
||||
sheet.detents = detents.map { $0 == .medium ? .medium() : .large() }
|
||||
break
|
||||
}
|
||||
current = candidate.parent
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func readingPresentationDragIndicator() -> some View {
|
||||
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
presentationDragIndicator(.visible)
|
||||
} else {
|
||||
background(ReadingUIKitConfiguration { controller in
|
||||
var current: UIViewController? = controller
|
||||
while let candidate = current {
|
||||
if let sheet = candidate.presentationController as? UISheetPresentationController {
|
||||
if !sheet.prefersGrabberVisible { sheet.prefersGrabberVisible = true }
|
||||
break
|
||||
}
|
||||
current = candidate.parent
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReadingUIKitConfiguration: UIViewControllerRepresentable {
|
||||
let configure: (UIViewController) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> Controller { Controller(configure: configure) }
|
||||
func updateUIViewController(_ controller: Controller, context: Context) { controller.configure = configure }
|
||||
|
||||
final class Controller: UIViewController {
|
||||
var configure: (UIViewController) -> Void
|
||||
|
||||
init(configure: @escaping (UIViewController) -> Void) {
|
||||
self.configure = configure
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
override func loadView() { view = UIView(); view.isUserInteractionEnabled = false }
|
||||
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); configure(self) }
|
||||
override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews(); configure(self) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReadingHiddenTabBar: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context: Context) -> Controller { Controller() }
|
||||
func updateUIViewController(_ controller: Controller, context: Context) {}
|
||||
|
||||
final class Controller: UIViewController {
|
||||
private weak var hiddenBar: UITabBar?
|
||||
private var previouslyHidden = false
|
||||
|
||||
override func loadView() { view = UIView(); view.isUserInteractionEnabled = false }
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
guard let bar = tabBarController?.tabBar else { return }
|
||||
hiddenBar = bar
|
||||
previouslyHidden = bar.isHidden
|
||||
bar.isHidden = true
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
hiddenBar?.isHidden = previouslyHidden
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import Foundation
|
||||
|
||||
enum DemoContent {
|
||||
static let sources: [BookSource] = {
|
||||
var classic = BookSource(bookSourceUrl: "https://classic.example", bookSourceName: "经典文学馆")
|
||||
classic.bookSourceGroup = "文学"
|
||||
classic.exploreUrl = "编辑推荐::/explore/recommended?page={{page}}\n热门连载::/explore/serial?page={{page}}\n完结佳作::/explore/finished?page={{page}}"
|
||||
classic.searchUrl = "/search?q={{key}}"
|
||||
classic.ruleExplore = BookListRule(bookList: "$.books", name: "$.name", author: "$.author", kind: "$.kind", bookUrl: "$.url", coverUrl: "$.cover")
|
||||
classic.ruleSearch = SearchRule(bookList: "$.books", name: "$.name", author: "$.author", bookUrl: "$.url")
|
||||
classic.ruleBookInfo = BookInfoRule()
|
||||
classic.ruleToc = TocRule()
|
||||
classic.ruleContent = ContentRule()
|
||||
|
||||
var science = BookSource(bookSourceUrl: "https://science.example", bookSourceName: "远方书库")
|
||||
science.bookSourceGroup = "科幻 · 悬疑"
|
||||
science.exploreUrl = "星际远征::/explore/space?page={{page}}&&悬疑推理::/explore/mystery?page={{page}}"
|
||||
science.searchUrl = "/api/search?key={{key}}"
|
||||
science.ruleSearch = SearchRule(bookList: "$.data", name: "$.title", author: "$.writer", bookUrl: "$.href")
|
||||
science.ruleExplore = BookListRule(bookList: "$.data", name: "$.title", author: "$.writer", kind: "$.tags", bookUrl: "$.href")
|
||||
science.ruleToc = TocRule()
|
||||
science.ruleContent = ContentRule()
|
||||
|
||||
var audio = BookSource(bookSourceUrl: "https://audio.example", bookSourceName: "听见故事")
|
||||
audio.bookSourceGroup = "听书"
|
||||
audio.bookSourceType = 1
|
||||
audio.searchUrl = "/search, {\"method\":\"POST\",\"body\":{\"title\":\"searchKey\"}}"
|
||||
audio.ruleSearch = SearchRule(bookList: "$.content", name: "$.title", author: "$.anchor", bookUrl: "$.id@js:'/book/' + result")
|
||||
audio.ruleToc = TocRule()
|
||||
audio.ruleContent = ContentRule()
|
||||
return [classic, science, audio]
|
||||
}()
|
||||
|
||||
static let exploreBooks: [SearchBook] = [
|
||||
("11", "潮汐来信", "周暮", "治愈 · 成长"),
|
||||
("12", "银河邮差", "许野", "科幻 · 冒险"),
|
||||
("13", "风从旧城来", "沈灯", "都市 · 悬疑"),
|
||||
("14", "万物有声", "唐溪", "自然 · 随笔"),
|
||||
("15", "雾中航线", "江屿", "推理 · 剧情"),
|
||||
("16", "春山可望", "苏禾", "古风 · 群像")
|
||||
].map { id, name, author, kind in
|
||||
var book = SearchBook(
|
||||
bookURL: "https://classic.example/book/\(id)",
|
||||
origin: sources[0].bookSourceUrl,
|
||||
originName: sources[0].bookSourceName,
|
||||
type: 0,
|
||||
name: name,
|
||||
author: author
|
||||
)
|
||||
book.kind = kind
|
||||
return book
|
||||
}
|
||||
|
||||
static let books: [Book] = {
|
||||
var first = Book(bookUrl: "https://classic.example/book/1", name: "长夜里的灯", author: "林舟", origin: sources[0].bookSourceUrl)
|
||||
first.originName = sources[0].bookSourceName
|
||||
first.kind = "当代文学"
|
||||
first.latestChapterTitle = "第十八章 · 微光"
|
||||
first.totalChapterNum = 42
|
||||
first.durChapterIndex = 17
|
||||
first.lastCheckCount = 3
|
||||
|
||||
var second = Book(bookUrl: "https://science.example/book/2", name: "群星沉默时", author: "顾屿", origin: sources[1].bookSourceUrl)
|
||||
second.originName = sources[1].bookSourceName
|
||||
second.kind = "科幻"
|
||||
second.latestChapterTitle = "第七卷 · 返航"
|
||||
second.totalChapterNum = 96
|
||||
second.durChapterIndex = 63
|
||||
|
||||
var third = Book(bookUrl: "https://audio.example/book/3", name: "山海入梦来", author: "南声", origin: sources[2].bookSourceUrl)
|
||||
third.originName = sources[2].bookSourceName
|
||||
third.type = 1
|
||||
third.latestChapterTitle = "第十二集 · 海上月"
|
||||
third.totalChapterNum = 30
|
||||
third.durChapterIndex = 4
|
||||
return [first, second, third]
|
||||
}()
|
||||
|
||||
static let readerChapters: [BookChapter] = (0..<5).map { index in
|
||||
BookChapter(
|
||||
url: "demo://chapter/\(index)",
|
||||
title: ["第一章 · 雨夜", "第二章 · 灯塔", "第三章 · 潮声", "第四章 · 来信", "第五章 · 天明"][index],
|
||||
bookUrl: books[0].bookUrl,
|
||||
index: index
|
||||
)
|
||||
}
|
||||
|
||||
static let sourceSwitchCandidates: [BookSourceCandidate] = {
|
||||
var science = SearchBook(
|
||||
bookURL: "https://science.example/book/long-night",
|
||||
origin: sources[1].bookSourceUrl,
|
||||
originName: sources[1].bookSourceName,
|
||||
type: 0,
|
||||
name: books[0].name,
|
||||
author: books[0].author
|
||||
)
|
||||
science.latestChapterTitle = "第四十二章 · 灯火长明"
|
||||
var audio = SearchBook(
|
||||
bookURL: "https://audio.example/book/long-night",
|
||||
origin: sources[2].bookSourceUrl,
|
||||
originName: sources[2].bookSourceName,
|
||||
type: 1,
|
||||
name: books[0].name,
|
||||
author: "主播:林舟"
|
||||
)
|
||||
audio.latestChapterTitle = "第四十集 · 归途"
|
||||
return [.init(book: science, score: 190), .init(book: audio, score: 110)]
|
||||
}()
|
||||
|
||||
static let readerText = """
|
||||
雨落在旧城的瓦檐上,细密得像一封迟到了许多年的信。
|
||||
林深推开阁楼的窗,远处的灯塔正越过雾气,一次又一次照亮沉睡的海湾。桌上的书摊开在同一页,纸边压着一枚褪色的蓝色邮票。
|
||||
他记得祖父说过,所有没有寄出的故事,最后都会顺着河流抵达海上。
|
||||
楼下传来水壶轻响。木质楼梯保存着雨天特有的气味,也保存着这里每个人走过的声音。林深把信纸翻到背面,看见一行很淡的铅笔字:等灯塔第三次亮起,就沿着潮声出发。
|
||||
窗外,第一束光穿过雨幕。
|
||||
他没有立刻动身,只是把书签夹进那一页。屋里很安静,只有雨水、钟摆,以及某个故事终于开始时微小而确定的回声。
|
||||
第二束光掠过海面时,远处出现了一艘白色小船。它没有开灯,却像知道方向一样,稳稳停在旧码头边。
|
||||
林深收好信,披上外套。门被推开的瞬间,潮湿的风涌进走廊,也把许多年前那个未完的夜晚重新带回眼前。
|
||||
"""
|
||||
|
||||
static let bookmarks: [Bookmark] = {
|
||||
var first = Bookmark(time: 1_786_989_000_000, bookName: books[0].name, bookAuthor: books[0].author)
|
||||
first.chapterIndex = 0
|
||||
first.chapterPosition = 48
|
||||
first.chapterName = readerChapters[0].title
|
||||
first.bookText = "远处的灯塔正越过雾气,一次又一次照亮沉睡的海湾。"
|
||||
var second = Bookmark(time: 1_786_989_100_000, bookName: books[0].name, bookAuthor: books[0].author)
|
||||
second.chapterIndex = 0
|
||||
second.chapterPosition = 186
|
||||
second.chapterName = readerChapters[0].title
|
||||
second.bookText = "等灯塔第三次亮起,就沿着潮声出发。"
|
||||
return [first, second]
|
||||
}()
|
||||
|
||||
static let readRecords: [ReadRecord] = {
|
||||
let calendar = Calendar.current
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
func key(_ offset: Int) -> String {
|
||||
let date = calendar.date(byAdding: .day, value: offset, to: today) ?? today
|
||||
let components = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
|
||||
}
|
||||
return [
|
||||
.init(
|
||||
deviceId: "ios-simulator",
|
||||
bookName: books[0].name,
|
||||
readTime: 5_460_000,
|
||||
dailyReadTimes: [key(-2): 1_020_000, key(-1): 1_440_000, key(0): 1_260_000]
|
||||
),
|
||||
.init(
|
||||
deviceId: "ios-simulator",
|
||||
bookName: books[1].name,
|
||||
readTime: 2_220_000,
|
||||
dailyReadTimes: [key(-4): 720_000, key(-1): 540_000]
|
||||
)
|
||||
]
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ReadingUnavailableView<Label: View, Description: View, Actions: View>: View {
|
||||
private let label: Label
|
||||
private let description: Description
|
||||
private let actions: Actions
|
||||
|
||||
init(
|
||||
@ViewBuilder label: () -> Label,
|
||||
@ViewBuilder description: () -> Description,
|
||||
@ViewBuilder actions: () -> Actions
|
||||
) {
|
||||
self.label = label()
|
||||
self.description = description()
|
||||
self.actions = actions()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 17.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
ContentUnavailableView {
|
||||
label
|
||||
} description: {
|
||||
description
|
||||
} actions: {
|
||||
actions
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 12) {
|
||||
label.labelStyle(ReadingUnavailableLabelStyle())
|
||||
description
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
actions.padding(.top, 4)
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ReadingUnavailableView where Label == SwiftUI.Label<Text, Image>, Description == Text?, Actions == EmptyView {
|
||||
init(_ title: String, systemImage: String, description: Text? = nil) {
|
||||
self.init {
|
||||
SwiftUI.Label(title, systemImage: systemImage)
|
||||
} description: {
|
||||
description
|
||||
} actions: {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
static func search(text: String) -> some View {
|
||||
if #available(iOS 17.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
ContentUnavailableView.search(text: text)
|
||||
} else {
|
||||
ReadingUnavailableView(
|
||||
"无搜索结果",
|
||||
systemImage: "magnifyingglass",
|
||||
description: Text("没有找到与“\(text)”相关的内容,请尝试其他关键词。")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReadingUnavailableLabelStyle: LabelStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
configuration.icon
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
configuration.title.font(.title2.bold())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func readingSearchable(text: Binding<String>, isPresented: Binding<Bool>, prompt: String) -> some View {
|
||||
modifier(ReadingSearchModifier(text: text, isPresented: isPresented, prompt: prompt))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func readingListTopMargin(_ margin: CGFloat) -> some View {
|
||||
if #available(iOS 17.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
contentMargins(.top, margin, for: .scrollContent)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReadingSearchModifier: ViewModifier {
|
||||
@Binding var text: String
|
||||
@Binding var isPresented: Bool
|
||||
let prompt: String
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 17.0, *), !ReadingCompatibility.usesLegacyUI {
|
||||
content.searchable(text: $text, isPresented: $isPresented, prompt: prompt)
|
||||
} else {
|
||||
content
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
TextField(prompt, text: $text)
|
||||
.focused($isFocused)
|
||||
.textInputAutocapitalization(.never)
|
||||
.disableAutocorrection(true)
|
||||
.submitLabel(.search)
|
||||
if !text.isEmpty {
|
||||
Button {
|
||||
text = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityLabel("清除搜索")
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 10))
|
||||
if isFocused {
|
||||
Button("取消") {
|
||||
text = ""
|
||||
isFocused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(ReadingTheme.canvas)
|
||||
}
|
||||
.onAppear { isFocused = isPresented }
|
||||
.onChange(of: isPresented) { isFocused = $0 }
|
||||
.onChange(of: isFocused) { isPresented = $0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ReadingTheme {
|
||||
static let accent = adaptive(0x171717, dark: 0xF2F2F2)
|
||||
static let accentSoft = adaptive(0xEEEEEF, dark: 0x29292C)
|
||||
static let coral = Color(red: 0.76, green: 0.48, blue: 0.27)
|
||||
static let paper = Color(red: 0.965, green: 0.945, blue: 0.895)
|
||||
static let ink = Color.primary
|
||||
static let canvas = adaptive(0xFCFCFC, dark: 0x111113)
|
||||
static let groupedCanvas = adaptive(0xF3F3F4, dark: 0x1C1C1F)
|
||||
static let surface = adaptive(0xFFFFFF, dark: 0x232326)
|
||||
static let separator = adaptive(0xE6E6E8, dark: 0x363639)
|
||||
static let onAccent = adaptive(0xFFFFFF, dark: 0x171717)
|
||||
|
||||
private static func adaptive(_ light: UInt32, dark: UInt32) -> Color {
|
||||
Color(uiColor: UIColor { traits in
|
||||
let hex = traits.userInterfaceStyle == .dark ? dark : light
|
||||
return UIColor(
|
||||
red: CGFloat((hex >> 16) & 0xff) / 255,
|
||||
green: CGFloat((hex >> 8) & 0xff) / 255,
|
||||
blue: CGFloat(hex & 0xff) / 255,
|
||||
alpha: 1
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadingPageHeader<Actions: View>: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
@ViewBuilder var actions: Actions
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
Text(title)
|
||||
.font(.largeTitle.bold())
|
||||
.foregroundStyle(ReadingTheme.ink)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: 10) { actions }
|
||||
}
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadingToolbarIcon: View {
|
||||
let systemName: String
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: 19, weight: .regular))
|
||||
.foregroundStyle(ReadingTheme.accent)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(ReadingTheme.groupedCanvas, in: Circle())
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
enum BookReadingPresentation {
|
||||
static func hasStarted(_ book: Book) -> Bool {
|
||||
book.durChapterIndex > 0 || book.durChapterPos > 0 || book.durChapterTime > 0
|
||||
}
|
||||
|
||||
static func progress(_ book: Book) -> Double {
|
||||
guard hasStarted(book), book.totalChapterNum > 0 else { return 0 }
|
||||
return min(1, max(0, Double(book.durChapterIndex + 1) / Double(book.totalChapterNum)))
|
||||
}
|
||||
|
||||
static func position(_ book: Book) -> String {
|
||||
if let title = book.durChapterTitle, !title.isEmpty { return title }
|
||||
guard hasStarted(book) else { return "尚未阅读" }
|
||||
return book.totalChapterNum > 0
|
||||
? "第\(book.durChapterIndex + 1)章 · 共\(book.totalChapterNum)章"
|
||||
: "已读至第\(book.durChapterIndex + 1)章"
|
||||
}
|
||||
|
||||
static func progressLabel(_ book: Book) -> String {
|
||||
guard hasStarted(book) else { return "尚未阅读" }
|
||||
guard book.totalChapterNum > 0 else { return position(book) }
|
||||
return "已读\(Int((progress(book) * 100).rounded()))%"
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadingProgressLine: View {
|
||||
let value: Double
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
Capsule().fill(ReadingTheme.separator)
|
||||
.overlay(alignment: .leading) {
|
||||
Capsule().fill(ReadingTheme.accent)
|
||||
.frame(width: geometry.size.width * min(1, max(0, value)))
|
||||
}
|
||||
}
|
||||
.frame(height: 3)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
struct BookCoverArtwork: View {
|
||||
let title: String
|
||||
var author: String = ""
|
||||
var remoteURL: String?
|
||||
var width: CGFloat = 68
|
||||
var height: CGFloat = 96
|
||||
|
||||
private var coverStyle: Int {
|
||||
switch title {
|
||||
case "长夜里的灯": 0
|
||||
case "群星沉默时": 1
|
||||
case "山海入梦来": 2
|
||||
default: title.unicodeScalars.reduce(0) { ($0 + Int($1.value)) % 3 }
|
||||
}
|
||||
}
|
||||
|
||||
private var coverColor: Color {
|
||||
switch coverStyle {
|
||||
case 1: Color(red: 0.22, green: 0.35, blue: 0.44)
|
||||
case 2: Color(red: 0.71, green: 0.44, blue: 0.34)
|
||||
default: Color(red: 0.14, green: 0.15, blue: 0.16)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
AsyncImage(url: remoteURL.flatMap(URL.init(string:))) { phase in
|
||||
if case .success(let image) = phase {
|
||||
image.resizable().scaledToFill()
|
||||
} else {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
|
||||
.overlay(alignment: .leading) {
|
||||
Rectangle().fill(.black.opacity(0.15)).frame(width: max(2, width * 0.035))
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.strokeBorder(.black.opacity(0.08), lineWidth: 0.5)
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
coverColor
|
||||
Canvas { context, size in
|
||||
drawArtwork(context: context, size: size)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: width * 0.10) {
|
||||
Text(coverStyle == 0 ? title.map(String.init).joined(separator: "\n") : title)
|
||||
.font(.system(size: max(9, width * (coverStyle == 0 ? 0.16 : 0.18)), weight: .medium, design: .serif))
|
||||
.lineLimit(coverStyle == 0 ? 7 : 4)
|
||||
.minimumScaleFactor(0.65)
|
||||
.frame(maxWidth: coverStyle == 0 ? width * 0.32 : .infinity, alignment: .leading)
|
||||
if !author.isEmpty, width >= 50 {
|
||||
Text(author)
|
||||
.font(.system(size: max(7, width * 0.09)))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white.opacity(0.94))
|
||||
.padding(width * 0.15)
|
||||
.padding(.top, coverStyle == 1 ? height * 0.33 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func drawArtwork(context: GraphicsContext, size: CGSize) {
|
||||
if coverStyle == 0 {
|
||||
let moon = CGRect(x: size.width * 0.68, y: size.height * 0.18, width: size.width * 0.17, height: size.width * 0.17)
|
||||
context.fill(Path(ellipseIn: moon), with: .color(Color(red: 0.89, green: 0.87, blue: 0.81)))
|
||||
} else if coverStyle == 1 {
|
||||
let normalizedPoints: [CGPoint] = [
|
||||
.init(x: 0.12, y: 0.19), .init(x: 0.39, y: 0.11), .init(x: 0.62, y: 0.22),
|
||||
.init(x: 0.88, y: 0.13), .init(x: 0.76, y: 0.42), .init(x: 0.33, y: 0.48)
|
||||
]
|
||||
let points = normalizedPoints.map { point in
|
||||
CGPoint(x: point.x * size.width, y: point.y * size.height)
|
||||
}
|
||||
var constellation = Path()
|
||||
constellation.addLines(points)
|
||||
context.stroke(constellation, with: .color(.white.opacity(0.22)), lineWidth: 0.6)
|
||||
for point in points {
|
||||
context.fill(Path(ellipseIn: CGRect(x: point.x, y: point.y, width: 1.5, height: 1.5)), with: .color(.white.opacity(0.7)))
|
||||
}
|
||||
} else {
|
||||
let arch = CGRect(x: size.width * 0.28, y: size.height * 0.40, width: size.width * 0.58, height: size.height * 0.48)
|
||||
context.fill(Path(roundedRect: arch, cornerRadius: size.width * 0.29), with: .color(.white.opacity(0.25)))
|
||||
}
|
||||
for index in 0..<3 {
|
||||
var horizon = Path()
|
||||
let baseline = size.height * (0.78 + CGFloat(index) * 0.065)
|
||||
horizon.move(to: CGPoint(x: 0, y: baseline))
|
||||
horizon.addCurve(
|
||||
to: CGPoint(x: size.width, y: baseline + 8),
|
||||
control1: CGPoint(x: size.width * 0.3, y: baseline + 18),
|
||||
control2: CGPoint(x: size.width * 0.6, y: baseline - 20)
|
||||
)
|
||||
context.stroke(horizon, with: .color(.white.opacity(0.17)), lineWidth: 0.7)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadingCard<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(20)
|
||||
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusPill: View {
|
||||
let text: String
|
||||
var color: Color = ReadingTheme.accent
|
||||
var systemImage: String?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
if let systemImage { Image(systemName: systemImage) }
|
||||
Text(text)
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
private final class CacheManagementModel: ObservableObject {
|
||||
@Published private(set) var entries = 0
|
||||
@Published private(set) var bytes = 0
|
||||
@Published private(set) var isWorking = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let cache: ChapterContentCache
|
||||
|
||||
init(cache: ChapterContentCache = .live()) { self.cache = cache }
|
||||
|
||||
func load() async {
|
||||
do {
|
||||
let statistics = try await cache.statistics()
|
||||
entries = statistics.entries
|
||||
bytes = statistics.bytes
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func clear() async {
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
do {
|
||||
try await cache.removeAll()
|
||||
entries = 0
|
||||
bytes = 0
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CacheManagementView: View {
|
||||
@StateObject private var model = CacheManagementModel()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("章节正文") {
|
||||
ReadingLabeledContent("已缓存章节", value: "\(model.entries)")
|
||||
ReadingLabeledContent("占用空间", value: ByteCountFormatter.string(fromByteCount: Int64(model.bytes), countStyle: .file))
|
||||
Text("阅读网络章节后自动落盘,预加载数量可配置。缓存上限可在“其他设置 → 网络与缓存设置”调整,超限时优先清理最久未读取的章节。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
Section {
|
||||
Button("清空章节缓存", systemImage: "trash", role: .destructive) {
|
||||
Task { await model.clear() }
|
||||
}
|
||||
.disabled(model.entries == 0 || model.isWorking)
|
||||
}
|
||||
}
|
||||
.navigationTitle("缓存管理")
|
||||
.task { await model.load() }
|
||||
.overlay { if model.isWorking { ProgressView() } }
|
||||
.alert("缓存操作失败", isPresented: Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { if !$0 { model.errorMessage = nil } }
|
||||
)) {
|
||||
Button("好") { model.errorMessage = nil }
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "未知错误")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
private final class FileManagementModel: ObservableObject {
|
||||
@Published var files: [LocalFileEntry] = []
|
||||
@Published var isWorking = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var readyExport: BookFileExport?
|
||||
@Published var partialExport: BookFileExport?
|
||||
private var activeExport: BookFileExport?
|
||||
private let exporter = BookFileExporter()
|
||||
|
||||
func reload(_ library: BookLibraryModel) async {
|
||||
do { files = try await library.localFiles.list() }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func run(_ library: BookLibraryModel, operation: () async throws -> Void) async {
|
||||
guard !isWorking else { return }
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
do { try await operation() }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
await reload(library)
|
||||
}
|
||||
|
||||
func importFiles(_ urls: [URL], library: BookLibraryModel) async {
|
||||
await run(library) {
|
||||
await library.importLocalFiles(urls)
|
||||
errorMessage = library.errorMessage
|
||||
library.clearError()
|
||||
}
|
||||
}
|
||||
|
||||
func exportLocal(_ url: URL, filename: String, library: BookLibraryModel) async {
|
||||
await run(library) {
|
||||
let file = try await exporter.originalFile(url, filename: filename)
|
||||
activeExport = file
|
||||
readyExport = file
|
||||
}
|
||||
}
|
||||
|
||||
func exportCached(_ book: Book, library: BookLibraryModel) async {
|
||||
await run(library) {
|
||||
let chapters = try await AppBookChapterRepository.live().load(bookURL: book.bookUrl)
|
||||
let file = try await exporter.cachedBook(book, chapters: chapters)
|
||||
activeExport = file
|
||||
if file.isPartial { partialExport = file }
|
||||
else { readyExport = file }
|
||||
}
|
||||
}
|
||||
|
||||
func finishExport() {
|
||||
guard let file = activeExport else { return }
|
||||
activeExport = nil
|
||||
readyExport = nil
|
||||
partialExport = nil
|
||||
Task { try? await exporter.discard(file) }
|
||||
}
|
||||
}
|
||||
|
||||
struct FileManagementView: View {
|
||||
@EnvironmentObject private var library: BookLibraryModel
|
||||
@StateObject private var model = FileManagementModel()
|
||||
@State private var tab = 0
|
||||
@State private var query = ""
|
||||
@State private var sortBySize = false
|
||||
@State private var importsFile = false
|
||||
@State private var confirmsPartialExport = false
|
||||
@State private var selecting = false
|
||||
@State private var selection = Set<URL>()
|
||||
@State private var renameURL: URL?
|
||||
@State private var newName = ""
|
||||
@State private var pendingDeletion: [URL] = []
|
||||
|
||||
private var localBooks: [Book] { library.books.filter { $0.origin == "local" } }
|
||||
private var networkBooks: [Book] {
|
||||
library.books.filter { $0.origin != "local" && (query.isEmpty || $0.name.localizedCaseInsensitiveContains(query)) }
|
||||
}
|
||||
private func book(for url: URL) -> Book? { localBooks.first { $0.bookUrl == url.absoluteString } }
|
||||
private func filename(for url: URL) -> String {
|
||||
guard let book = book(for: url), !book.originName.isEmpty else { return url.lastPathComponent }
|
||||
return book.originName
|
||||
}
|
||||
private var displayedFiles: [LocalFileEntry] {
|
||||
model.files.filter { query.isEmpty || filename(for: $0.url).localizedCaseInsensitiveContains(query) }
|
||||
.sorted {
|
||||
if sortBySize, $0.bytes != $1.bytes { return $0.bytes > $1.bytes }
|
||||
return filename(for: $0.url).localizedStandardCompare(filename(for: $1.url)) == .orderedAscending
|
||||
}
|
||||
}
|
||||
private var missingBooks: [Book] {
|
||||
let available = Set(model.files.map(\.url.absoluteString))
|
||||
return localBooks.filter { !available.contains($0.bookUrl) && (query.isEmpty || $0.name.localizedCaseInsensitiveContains(query)) }
|
||||
}
|
||||
|
||||
private var fileList: some View {
|
||||
List(selection: $selection) {
|
||||
Section {
|
||||
Picker("文件类型", selection: $tab) {
|
||||
Text("本地文件").tag(0)
|
||||
Text("缓存正文").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(selecting)
|
||||
}
|
||||
if tab == 0 { localFilesSection }
|
||||
else { cachedBooksSection }
|
||||
if !selecting {
|
||||
Section {
|
||||
NavigationLink { CacheManagementView() } label: {
|
||||
Label("章节缓存与空间清理", systemImage: "internaldrive")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.environment(\.editMode, .constant(selecting ? .active : .inactive))
|
||||
.navigationTitle("文件管理")
|
||||
.searchable(text: $query, prompt: tab == 0 ? "搜索本地文件" : "搜索缓存书籍")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if selecting {
|
||||
Button("完成") { selecting = false; selection.removeAll() }
|
||||
} else {
|
||||
Menu {
|
||||
Button("导入 TXT 文件", systemImage: "square.and.arrow.down") { importsFile = true }
|
||||
if tab == 0 {
|
||||
Button("选择文件", systemImage: "checkmark.circle") { selecting = true }
|
||||
.disabled(displayedFiles.isEmpty)
|
||||
Picker("排序", selection: $sortBySize) {
|
||||
Text("按名称").tag(false)
|
||||
Text("按大小").tag(true)
|
||||
}
|
||||
}
|
||||
} label: { Image(systemName: "ellipsis.circle") }
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if selecting {
|
||||
HStack {
|
||||
Button(selection.count == displayedFiles.count ? "取消全选" : "全选") {
|
||||
selection = selection.count == displayedFiles.count ? [] : Set(displayedFiles.map(\.url))
|
||||
}
|
||||
Spacer()
|
||||
Button("删除 \(selection.count) 个文件", role: .destructive) { pendingDeletion = Array(selection) }
|
||||
.disabled(selection.isEmpty)
|
||||
}
|
||||
.padding().background(.bar)
|
||||
}
|
||||
}
|
||||
.disabled(model.isWorking)
|
||||
.overlay { if model.isWorking { ProgressView("正在处理文件…").padding().background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) } }
|
||||
.task { await model.reload(library) }
|
||||
.onChange(of: library.books) { _ in Task { await model.reload(library) } }
|
||||
.onChange(of: query) { _ in selection.removeAll() }
|
||||
.refreshable { await model.reload(library) }
|
||||
}
|
||||
|
||||
private var importAndRenameView: some View {
|
||||
fileList
|
||||
.fileImporter(isPresented: $importsFile, allowedContentTypes: [.text, .plainText, .data], allowsMultipleSelection: true) { result in
|
||||
switch result {
|
||||
case .success(let urls): Task { await model.importFiles(urls, library: library) }
|
||||
case .failure(let error): model.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
.alert("重命名文件", isPresented: Binding(get: { renameURL != nil }, set: { if !$0 { renameURL = nil } }), presenting: renameURL) { url in
|
||||
TextField("文件名", text: $newName)
|
||||
Button("取消", role: .cancel) { renameURL = nil }
|
||||
Button("保存") {
|
||||
let name = newName
|
||||
renameURL = nil
|
||||
Task { await model.run(library) { try await library.renameLocalFile(at: url, to: name) } }
|
||||
}
|
||||
} message: { _ in
|
||||
Text("保留 TXT 格式,书架中的书名、书签和阅读进度不变。")
|
||||
}
|
||||
}
|
||||
|
||||
private var deletionView: some View {
|
||||
importAndRenameView
|
||||
.confirmationDialog("删除 \(pendingDeletion.count) 个文件?", isPresented: Binding(get: { !pendingDeletion.isEmpty }, set: { if !$0 { pendingDeletion = [] } }), titleVisibility: .visible, presenting: pendingDeletion) { urls in
|
||||
Button("删除文件并移出书架", role: .destructive) {
|
||||
pendingDeletion = []
|
||||
Task {
|
||||
await model.run(library) {
|
||||
var failures: [String] = []
|
||||
for url in urls {
|
||||
do { try await library.deleteLocalFile(at: url) }
|
||||
catch { failures.append("\(filename(for: url)):\(error.localizedDescription)") }
|
||||
}
|
||||
selection.removeAll()
|
||||
if !failures.isEmpty { model.errorMessage = failures.joined(separator: "\n") }
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("取消", role: .cancel) { pendingDeletion = [] }
|
||||
} message: { _ in
|
||||
Text("将删除应用内文件及对应书架条目,系统“文件”中用于导入的原文件保留。")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
deletionView
|
||||
.onChange(of: model.partialExport?.id) { value in confirmsPartialExport = value != nil }
|
||||
.onChange(of: confirmsPartialExport) { isPresented in
|
||||
if !isPresented {
|
||||
Task { @MainActor in
|
||||
await Task.yield()
|
||||
if !confirmsPartialExport, model.readyExport == nil { model.finishExport() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog("缓存正文不完整", isPresented: $confirmsPartialExport, titleVisibility: .visible, presenting: model.partialExport) { file in
|
||||
Button("导出已缓存内容") {
|
||||
model.readyExport = file
|
||||
model.partialExport = nil
|
||||
}
|
||||
Button("取消", role: .cancel) { model.finishExport() }
|
||||
} message: { file in
|
||||
Text("已缓存 \(file.cachedChapters)/\(file.totalChapters) 章。导出文件会标记未缓存章节,可先返回详情缓存完整后再导出。")
|
||||
}
|
||||
.sheet(item: $model.readyExport, onDismiss: { model.finishExport() }) { file in
|
||||
FileExportPicker(url: file.url) { model.readyExport = nil }
|
||||
}
|
||||
.alert("文件操作失败", isPresented: Binding(get: { model.errorMessage != nil }, set: { if !$0 { model.errorMessage = nil } })) {
|
||||
Button("好") { model.errorMessage = nil }
|
||||
} message: { Text(model.errorMessage ?? "未知错误") }
|
||||
}
|
||||
|
||||
@ViewBuilder private var localFilesSection: some View {
|
||||
Section {
|
||||
ReadingLabeledContent("本地文件", value: "\(model.files.count) 个")
|
||||
ReadingLabeledContent("占用空间", value: ByteCountFormatter.string(fromByteCount: model.files.reduce(0) { $0 + $1.bytes }, countStyle: .file))
|
||||
}
|
||||
Section("文件") {
|
||||
if displayedFiles.isEmpty {
|
||||
ReadingUnavailableView(query.isEmpty ? "还没有本地文件" : "没有匹配的文件", systemImage: "doc.text", description: Text("可从系统“文件”导入 TXT,文件会复制到应用中。"))
|
||||
Button("导入 TXT 文件", systemImage: "plus") { importsFile = true }
|
||||
}
|
||||
ForEach(displayedFiles) { file in
|
||||
Group {
|
||||
if selecting { fileLabel(file) }
|
||||
else if let book = book(for: file.url) {
|
||||
NavigationLink { BookshelfReadView(book: book, source: nil) } label: { fileLabel(file) }
|
||||
} else {
|
||||
Button { Task { await model.run(library) { try await library.addLocalFile(at: file.url) } } } label: { fileLabel(file) }
|
||||
}
|
||||
}
|
||||
.tag(file.url)
|
||||
.contextMenu {
|
||||
Button("导出文件", systemImage: "square.and.arrow.up") { export(file) }
|
||||
Button("重命名", systemImage: "pencil") { renameURL = file.url; newName = filename(for: file.url) }
|
||||
Button("删除文件", systemImage: "trash", role: .destructive) { pendingDeletion = [file.url] }
|
||||
}
|
||||
.swipeActions {
|
||||
Button("删除", role: .destructive) { pendingDeletion = [file.url] }
|
||||
Button("导出", systemImage: "square.and.arrow.up") { export(file) }.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !missingBooks.isEmpty {
|
||||
Section("文件已丢失") {
|
||||
ForEach(missingBooks) { book in
|
||||
HStack {
|
||||
Label(book.name, systemImage: "doc.badge.ellipsis")
|
||||
Spacer()
|
||||
Button("移出书架", role: .destructive) {
|
||||
if let url = URL(string: book.bookUrl) { pendingDeletion = [url] }
|
||||
}.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var cachedBooksSection: some View {
|
||||
Section {
|
||||
Text("将书架中已缓存的网络正文导出为 TXT,按目录顺序排列。导出过程不下载新章节。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
if networkBooks.isEmpty {
|
||||
ReadingUnavailableView("没有可导出的书籍", systemImage: "books.vertical", description: Text("将网络书加入书架并缓存章节后,可在这里导出。"))
|
||||
}
|
||||
ForEach(networkBooks) { book in
|
||||
Button {
|
||||
Task { await model.exportCached(book, library: library) }
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(book.name).foregroundStyle(.primary)
|
||||
Text(book.author.isEmpty ? "导出缓存正文" : book.author).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: { Text("缓存正文") }
|
||||
}
|
||||
|
||||
private func fileLabel(_ file: LocalFileEntry) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "doc.text").foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(filename(for: file.url)).foregroundStyle(.primary).lineLimit(2)
|
||||
HStack(spacing: 8) {
|
||||
Text(ByteCountFormatter.string(fromByteCount: file.bytes, countStyle: .file))
|
||||
if let modified = file.modified { Text(modified, style: .date) }
|
||||
}.font(.caption).foregroundStyle(.secondary)
|
||||
if let book = book(for: file.url) {
|
||||
Text("书架:\(book.name)").font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
} else { Text("点按加入书架").font(.caption).foregroundStyle(.secondary) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func export(_ file: LocalFileEntry) {
|
||||
Task { await model.exportLocal(file.url, filename: filename(for: file.url), library: library) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct FileExportPicker: UIViewControllerRepresentable {
|
||||
let url: URL
|
||||
let onCompletion: () -> Void
|
||||
func makeCoordinator() -> Coordinator { Coordinator(onCompletion) }
|
||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||
let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
|
||||
picker.delegate = context.coordinator
|
||||
return picker
|
||||
}
|
||||
func updateUIViewController(_ controller: UIDocumentPickerViewController, context: Context) {}
|
||||
final class Coordinator: NSObject, UIDocumentPickerDelegate {
|
||||
let completion: () -> Void
|
||||
init(_ completion: @escaping () -> Void) { self.completion = completion }
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { completion() }
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { completion() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ReadingActivityModel: ObservableObject {
|
||||
@Published private(set) var bookmarks: [Bookmark] = []
|
||||
@Published private(set) var readRecords: [ReadRecord] = []
|
||||
@Published private(set) var errorMessage: String?
|
||||
|
||||
private let repository: any ReadingActivityRepository
|
||||
private let service = ReadingActivityService()
|
||||
private let deviceID: String
|
||||
private var sessions: [String: Date] = [:]
|
||||
|
||||
init(repository: any ReadingActivityRepository, deviceID: String) {
|
||||
self.repository = repository
|
||||
self.deviceID = deviceID
|
||||
}
|
||||
|
||||
static func live() -> ReadingActivityModel {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Legado", isDirectory: true)
|
||||
let key = "legado.reading.device-id"
|
||||
let defaults = UserDefaults.standard
|
||||
let identifier = defaults.string(forKey: key) ?? UUID().uuidString
|
||||
defaults.set(identifier, forKey: key)
|
||||
let bookmarksURL = base.appendingPathComponent("bookmarks.json")
|
||||
let recordsURL = base.appendingPathComponent("read-records.json")
|
||||
let repository: any ReadingActivityRepository
|
||||
if let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) {
|
||||
repository = SQLiteReadingActivityRepository(
|
||||
database: database,
|
||||
legacyBookmarksURL: bookmarksURL,
|
||||
legacyRecordsURL: recordsURL
|
||||
)
|
||||
} else {
|
||||
repository = FileReadingActivityRepository(bookmarksURL: bookmarksURL, recordsURL: recordsURL)
|
||||
}
|
||||
return ReadingActivityModel(repository: repository, deviceID: identifier)
|
||||
}
|
||||
|
||||
static func demo(bookmarks: [Bookmark] = [], records: [ReadRecord] = []) -> ReadingActivityModel {
|
||||
ReadingActivityModel(
|
||||
repository: InMemoryReadingActivityRepository(bookmarks: bookmarks, records: records),
|
||||
deviceID: "ios-simulator"
|
||||
)
|
||||
}
|
||||
|
||||
var summaries: [ReadRecordSummary] { service.summaries(readRecords) }
|
||||
var statistics: ReadingStatistics { service.statistics(readRecords) }
|
||||
|
||||
func summaries(searchKey: String, sortMode: Int) -> [ReadRecordSummary] {
|
||||
service.displayedSummaries(readRecords, searchKey: searchKey, sortMode: sortMode)
|
||||
}
|
||||
|
||||
func load() async {
|
||||
do {
|
||||
async let storedBookmarks = repository.loadBookmarks()
|
||||
async let storedRecords = repository.loadReadRecords()
|
||||
bookmarks = service.sortedBookmarks(try await storedBookmarks)
|
||||
readRecords = try await storedRecords
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func bookmarks(for book: Book) -> [Bookmark] {
|
||||
bookmarks.filter { $0.bookName == book.name && $0.bookAuthor == book.author }
|
||||
.sorted {
|
||||
if $0.chapterIndex == $1.chapterIndex { return $0.chapterPosition < $1.chapterPosition }
|
||||
return $0.chapterIndex < $1.chapterIndex
|
||||
}
|
||||
}
|
||||
|
||||
func addBookmark(book: Book, chapter: BookChapter, position: Int, content: String) async {
|
||||
var timestamp = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
while bookmarks.contains(where: { $0.time == timestamp }) { timestamp += 1 }
|
||||
var bookmark = Bookmark(time: timestamp, bookName: book.name, bookAuthor: book.author)
|
||||
bookmark.chapterIndex = chapter.index
|
||||
bookmark.chapterPosition = max(position, 0)
|
||||
bookmark.chapterName = chapter.title
|
||||
bookmark.bookText = excerpt(content, around: position)
|
||||
var updated = bookmarks
|
||||
updated.append(bookmark)
|
||||
do {
|
||||
try await repository.saveBookmarks(updated)
|
||||
bookmarks = service.sortedBookmarks(updated)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func removeBookmark(_ bookmark: Bookmark) async {
|
||||
let updated = bookmarks.filter { $0.id != bookmark.id }
|
||||
do {
|
||||
try await repository.saveBookmarks(updated)
|
||||
bookmarks = updated
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func updateBookmark(_ bookmark: Bookmark) async {
|
||||
var updated = bookmarks
|
||||
if let index = updated.firstIndex(where: { $0.id == bookmark.id }) {
|
||||
updated[index] = bookmark
|
||||
} else {
|
||||
updated.append(bookmark)
|
||||
}
|
||||
do {
|
||||
try await repository.saveBookmarks(updated)
|
||||
bookmarks = service.sortedBookmarks(updated)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func beginReading(_ book: Book, now: Date = Date()) {
|
||||
sessions[book.bookUrl] = sessions[book.bookUrl] ?? now
|
||||
}
|
||||
|
||||
func checkpoint(_ book: Book, now: Date = Date()) async {
|
||||
guard let start = sessions[book.bookUrl] else {
|
||||
sessions[book.bookUrl] = now
|
||||
return
|
||||
}
|
||||
sessions[book.bookUrl] = now
|
||||
await record(book: book, elapsed: now.timeIntervalSince(start), now: now)
|
||||
}
|
||||
|
||||
func endReading(_ book: Book, now: Date = Date()) async {
|
||||
guard let start = sessions.removeValue(forKey: book.bookUrl) else { return }
|
||||
await record(book: book, elapsed: now.timeIntervalSince(start), now: now)
|
||||
}
|
||||
|
||||
func cancelReading(_ book: Book) {
|
||||
sessions.removeValue(forKey: book.bookUrl)
|
||||
}
|
||||
|
||||
func removeHistory(bookName: String) async {
|
||||
let updated = readRecords.filter { $0.bookName != bookName }
|
||||
do {
|
||||
try await repository.saveReadRecords(updated)
|
||||
readRecords = updated
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func clearHistory() async {
|
||||
do {
|
||||
try await repository.saveReadRecords([])
|
||||
readRecords = []
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func clearError() { errorMessage = nil }
|
||||
|
||||
private func record(book: Book, elapsed: TimeInterval, now: Date) async {
|
||||
let milliseconds = Int64(max(elapsed, 0) * 1_000)
|
||||
guard milliseconds > 0 else { return }
|
||||
let updated = service.addingReadTime(
|
||||
milliseconds,
|
||||
deviceID: deviceID,
|
||||
bookName: book.name,
|
||||
now: Int64(now.timeIntervalSince1970 * 1_000),
|
||||
to: readRecords
|
||||
)
|
||||
readRecords = updated
|
||||
do {
|
||||
try await repository.saveReadRecords(updated)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func excerpt(_ content: String, around position: Int) -> String {
|
||||
guard !content.isEmpty else { return "" }
|
||||
let center = min(max(position, 0), content.count)
|
||||
let start = max(center - 24, 0)
|
||||
let end = min(center + 96, content.count)
|
||||
let lower = content.index(content.startIndex, offsetBy: start)
|
||||
let upper = content.index(content.startIndex, offsetBy: end)
|
||||
return String(content[lower..<upper]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ReadingHistoryView: View {
|
||||
@EnvironmentObject private var activity: ReadingActivityModel
|
||||
@EnvironmentObject private var books: BookLibraryModel
|
||||
@EnvironmentObject private var sources: SourceLibraryModel
|
||||
@AppStorage("enableReadRecord") private var enableReadRecord = true
|
||||
@AppStorage("readRecordSort") private var sortMode = 0
|
||||
@State private var searchText = ""
|
||||
@State private var confirmsClear = false
|
||||
@State private var pendingDelete: ReadRecordSummary?
|
||||
|
||||
private var displayedRecords: [ReadRecordSummary] {
|
||||
activity.summaries(searchKey: searchText, sortMode: sortMode)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if activity.summaries.isEmpty {
|
||||
ReadingUnavailableView(
|
||||
"还没有阅读记录",
|
||||
systemImage: "clock.arrow.circlepath",
|
||||
description: Text(enableReadRecord ? "翻页后会按设备累计阅读时长。" : "阅读记录当前已关闭。")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
Section {
|
||||
ReadingLabeledContent("累计阅读", value: duration(activity.summaries.reduce(0) { $0 + $1.readTime }))
|
||||
ReadingLabeledContent("读过", value: "\(activity.summaries.count) 本")
|
||||
}
|
||||
if displayedRecords.isEmpty {
|
||||
ReadingUnavailableView.search(text: searchText)
|
||||
} else {
|
||||
ForEach(displayedRecords) { record in
|
||||
NavigationLink {
|
||||
destination(for: record)
|
||||
} label: {
|
||||
HStack(spacing: 13) {
|
||||
Image(systemName: "book.pages.fill")
|
||||
.foregroundStyle(ReadingTheme.accent)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(ReadingTheme.accentSoft.opacity(0.55), in: RoundedRectangle(cornerRadius: 11))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(record.bookName).font(.headline)
|
||||
Text(duration(record.readTime))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(date(record.lastRead)).font(.caption).foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.swipeActions {
|
||||
Button("删除", role: .destructive) { pendingDelete = record }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
.navigationTitle("阅读记录")
|
||||
.searchable(text: $searchText, prompt: "搜索")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu("阅读记录选项", systemImage: "ellipsis.circle") {
|
||||
Toggle("记录阅读时长", isOn: $enableReadRecord)
|
||||
Section("排序") {
|
||||
sortButton("书名", value: 0)
|
||||
sortButton("阅读时长", value: 1)
|
||||
sortButton("最后阅读", value: 2)
|
||||
}
|
||||
if !activity.summaries.isEmpty {
|
||||
Button("清空", systemImage: "trash", role: .destructive) { confirmsClear = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("清空阅读记录", isPresented: $confirmsClear) {
|
||||
Button("取消", role: .cancel) {}
|
||||
Button("删除", role: .destructive) { Task { await activity.clearHistory() } }
|
||||
} message: {
|
||||
Text("确定删除全部阅读记录吗?")
|
||||
}
|
||||
.alert("删除阅读记录", isPresented: Binding(
|
||||
get: { pendingDelete != nil },
|
||||
set: { if !$0 { pendingDelete = nil } }
|
||||
)) {
|
||||
Button("取消", role: .cancel) { pendingDelete = nil }
|
||||
Button("删除", role: .destructive) {
|
||||
guard let record = pendingDelete else { return }
|
||||
pendingDelete = nil
|
||||
Task { await activity.removeHistory(bookName: record.bookName) }
|
||||
}
|
||||
} message: {
|
||||
Text("确定删除“\(pendingDelete?.bookName ?? "")”的阅读记录吗?")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func destination(for record: ReadRecordSummary) -> some View {
|
||||
if let book = books.books.first(where: { $0.name == record.bookName }) {
|
||||
BookshelfReadView(
|
||||
book: book,
|
||||
source: sources.sources.first(where: { $0.bookSourceUrl == book.origin })
|
||||
)
|
||||
} else {
|
||||
SearchView(initialKeyword: record.bookName)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func sortButton(_ title: String, value: Int) -> some View {
|
||||
Button {
|
||||
sortMode = value
|
||||
} label: {
|
||||
if sortMode == value { Label(title, systemImage: "checkmark") }
|
||||
else { Text(title) }
|
||||
}
|
||||
}
|
||||
|
||||
private func duration(_ milliseconds: Int64) -> String {
|
||||
let value = max(milliseconds, 0)
|
||||
let days = value / 86_400_000
|
||||
let hours = value % 86_400_000 / 3_600_000
|
||||
let minutes = value % 3_600_000 / 60_000
|
||||
let seconds = value % 60_000 / 1_000
|
||||
var result = ""
|
||||
if days > 0 { result += "\(days)天" }
|
||||
if hours > 0 { result += "\(hours)小时" }
|
||||
if minutes > 0 { result += "\(minutes)分钟" }
|
||||
if seconds > 0 { result += "\(seconds)秒" }
|
||||
return result.isEmpty ? "0秒" : result
|
||||
}
|
||||
|
||||
private func date(_ milliseconds: Int64) -> String {
|
||||
let formatter = DateFormatter()
|
||||
guard milliseconds > 0 else { return "" }
|
||||
formatter.locale = .current
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: Date(timeIntervalSince1970: Double(milliseconds) / 1_000))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ReadingStatisticsView: View {
|
||||
@EnvironmentObject private var activity: ReadingActivityModel
|
||||
@ScaledMetric(relativeTo: .largeTitle) private var durationFontSize = 42
|
||||
@ScaledMetric(relativeTo: .caption2) private var chartHeight = 138
|
||||
|
||||
private var statistics: ReadingStatistics { activity.statistics }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
ReadingPageHeader(title: "阅读统计", subtitle: "每一页,都算数") { EmptyView() }
|
||||
VStack(spacing: 16) {
|
||||
todayCard
|
||||
overviewGrid
|
||||
weeklyCard
|
||||
historyLink
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
}
|
||||
.background(ReadingTheme.canvas)
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
|
||||
private var todayCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("今日阅读")
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text(Date.now.formatted(.dateTime.locale(Locale(identifier: "zh_CN")).month().day()))
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(ReadingTheme.onAccent.opacity(0.7))
|
||||
Text(duration(statistics.todayReadTime, compact: false))
|
||||
.font(.system(size: durationFontSize, weight: .medium))
|
||||
.foregroundStyle(ReadingTheme.onAccent)
|
||||
.readingNumericTransition()
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
Text(statistics.todayReadTime == 0 ? "今天还没有阅读记录" : "已记录今天的阅读时长")
|
||||
.font(.caption)
|
||||
.foregroundStyle(ReadingTheme.onAccent.opacity(0.65))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(24)
|
||||
.background(ReadingTheme.accent, in: RoundedRectangle(cornerRadius: 20))
|
||||
}
|
||||
|
||||
private var overviewGrid: some View {
|
||||
ReadingCard {
|
||||
VStack(spacing: 18) {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
statisticTile(title: "累计阅读", value: duration(statistics.totalReadTime, compact: true))
|
||||
statisticTile(title: "阅读书籍", value: "\(statistics.bookCount) 本")
|
||||
}
|
||||
Rectangle().fill(ReadingTheme.separator).frame(height: 0.5)
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
statisticTile(
|
||||
title: "近 7 天",
|
||||
value: duration(statistics.recentDays.reduce(0) { $0 + $1.readTime }, compact: true)
|
||||
)
|
||||
statisticTile(title: "连续阅读", value: "\(statistics.currentStreak) 天")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func statisticTile(title: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.title3.weight(.semibold))
|
||||
.readingNumericTransition()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("\(title),\(value)")
|
||||
}
|
||||
|
||||
private var weeklyCard: some View {
|
||||
ReadingCard {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack {
|
||||
Text("最近 7 天")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text("每日阅读时长")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
ForEach(statistics.recentDays, id: \.date) { day in
|
||||
VStack(spacing: 7) {
|
||||
Text(barLabel(day.readTime))
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(day.readTime > 0 ? ReadingTheme.accent.opacity(Calendar.current.isDateInToday(day.date) ? 1 : 0.22) : ReadingTheme.separator)
|
||||
.frame(width: 22, height: barHeight(day.readTime))
|
||||
Text(weekday(day.date))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Calendar.current.isDateInToday(day.date) ? ReadingTheme.accent : .secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("\(weekday(day.date)),阅读 \(duration(day.readTime, compact: false))")
|
||||
}
|
||||
}
|
||||
.frame(height: chartHeight, alignment: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var historyLink: some View {
|
||||
NavigationLink {
|
||||
ReadingHistoryView()
|
||||
} label: {
|
||||
ReadingCard {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "clock.arrow.circlepath").font(.title3)
|
||||
Text("阅读记录").font(.subheadline.weight(.medium))
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right").font(.subheadline)
|
||||
}
|
||||
.foregroundStyle(ReadingTheme.accent)
|
||||
.frame(minHeight: 24)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func barHeight(_ milliseconds: Int64) -> CGFloat {
|
||||
let maximum = max(statistics.recentDays.map(\.readTime).max() ?? 0, 1)
|
||||
guard milliseconds > 0 else { return 2 }
|
||||
return 12 + 70 * CGFloat(milliseconds) / CGFloat(maximum)
|
||||
}
|
||||
|
||||
private func barLabel(_ milliseconds: Int64) -> String {
|
||||
guard milliseconds >= 60_000 else { return milliseconds > 0 ? "<1分" : "0" }
|
||||
if milliseconds < 3_600_000 { return "\(milliseconds / 60_000)分" }
|
||||
let hours = Double(milliseconds) / 3_600_000
|
||||
return String(format: "%.1f时", hours)
|
||||
}
|
||||
|
||||
private func weekday(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "EEE"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func duration(_ milliseconds: Int64, compact: Bool) -> String {
|
||||
let value = max(milliseconds, 0)
|
||||
let hours = value / 3_600_000
|
||||
let minutes = value % 3_600_000 / 60_000
|
||||
if compact {
|
||||
if hours > 0 { return minutes > 0 ? "\(hours)时\(minutes)分" : "\(hours)小时" }
|
||||
return "\(minutes)分钟"
|
||||
}
|
||||
let seconds = value % 60_000 / 1_000
|
||||
if hours > 0 { return minutes > 0 ? "\(hours)小时 \(minutes)分钟" : "\(hours)小时" }
|
||||
if minutes > 0 { return "\(minutes)分钟" }
|
||||
return "\(seconds)秒"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BookDetailView: View {
|
||||
@EnvironmentObject private var library: BookLibraryModel
|
||||
@EnvironmentObject private var sourceLibrary: SourceLibraryModel
|
||||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||
@StateObject private var model: BookDetailViewModel
|
||||
@State private var showsSourceSwitch = false
|
||||
@State private var showsRemoveConfirmation = false
|
||||
@State private var showsTableOfContents = false
|
||||
|
||||
init(searchBook: SearchBook, source: BookSource) {
|
||||
_model = StateObject(wrappedValue: BookDetailViewModel(searchBook: searchBook, source: source))
|
||||
}
|
||||
|
||||
init(book: Book, source: BookSource) {
|
||||
_model = StateObject(wrappedValue: BookDetailViewModel(book: book, source: source))
|
||||
}
|
||||
|
||||
init(demoBook: Book, source: BookSource, chapters: [BookChapter]) {
|
||||
_model = StateObject(wrappedValue: BookDetailViewModel(demoBook: demoBook, source: source, chapters: chapters))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
hero
|
||||
Divider()
|
||||
if let intro = MetadataTextSanitizer().clean(model.book.intro), !intro.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("简介").font(.headline)
|
||||
Text(intro).font(.subheadline).foregroundStyle(.secondary).lineSpacing(5)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
chapterCard
|
||||
if model.isCaching || model.cacheCompleted > 0 { cacheProgressCard }
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.background(ReadingTheme.canvas)
|
||||
.navigationTitle("书籍详情")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button("换源", systemImage: "arrow.triangle.2.circlepath") { showsSourceSwitch = true }
|
||||
.disabled(sourceLibrary.sources.filter(\.enabled).count < 2)
|
||||
Button("允许更新", systemImage: model.book.canUpdate ? "checkmark" : "circle") {
|
||||
model.toggleCanUpdate()
|
||||
Task { await saveCurrentBook() }
|
||||
}
|
||||
Button("刷新", systemImage: "arrow.clockwise") {
|
||||
Task {
|
||||
let oldURL = model.book.bookUrl
|
||||
let wasInLibrary = library.contains(model.book)
|
||||
await model.refresh(sources: sourceLibrary.sources)
|
||||
if wasInLibrary, oldURL != model.book.bookUrl {
|
||||
await library.replace(oldBookURL: oldURL, with: model.book)
|
||||
} else if wasInLibrary {
|
||||
await saveCurrentBook()
|
||||
}
|
||||
}
|
||||
}
|
||||
if model.source.loginUrl?.isEmpty == false {
|
||||
NavigationLink {
|
||||
SourceLoginView(source: model.source)
|
||||
} label: {
|
||||
Label("登录", systemImage: "person.badge.key")
|
||||
}
|
||||
}
|
||||
ReadingShareLink(item: model.book.bookUrl) {
|
||||
Label("分享", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
Divider()
|
||||
Button("缓存接下来 10 章", systemImage: "arrow.down.circle") { model.cacheNextTen() }
|
||||
.disabled(model.chapters.isEmpty || model.isCaching)
|
||||
Button("缓存剩余章节", systemImage: "square.and.arrow.down.on.square") { model.cacheRemaining() }
|
||||
.disabled(model.chapters.isEmpty || model.isCaching)
|
||||
if model.isCaching {
|
||||
Divider()
|
||||
Button("停止缓存", systemImage: "stop.circle", role: .destructive) { model.cancelCaching() }
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
model.restoreSourceAfterReading(from: library.books, sources: sourceLibrary.sources)
|
||||
let oldURL = model.book.bookUrl
|
||||
let shouldPersistUpdate = library.contains(model.book)
|
||||
if let stored = library.books.first(where: { $0.bookUrl == oldURL }) {
|
||||
model.restoreLibraryBook(from: stored, sources: sourceLibrary.sources)
|
||||
}
|
||||
await model.load(sources: sourceLibrary.sources)
|
||||
if shouldPersistUpdate {
|
||||
if let stored = library.books.first(where: { $0.bookUrl == model.book.bookUrl }) {
|
||||
model.restoreUserSettings(from: stored)
|
||||
model.restoreReadingProgress(from: stored)
|
||||
}
|
||||
if oldURL != model.book.bookUrl {
|
||||
await library.replace(oldBookURL: oldURL, with: model.book)
|
||||
} else {
|
||||
await saveCurrentBook()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showsSourceSwitch) {
|
||||
ReadingNavigationStack {
|
||||
SourceSwitchView(book: model.book, sources: sourceLibrary.sources) { report, source in
|
||||
let oldURL = model.book.bookUrl
|
||||
model.applySourceSwitch(report, source: source)
|
||||
Task { await library.replace(oldBookURL: oldURL, with: report.book) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showsTableOfContents) {
|
||||
BookTableOfContentsView(book: model.book, chapters: model.chapters, source: model.source)
|
||||
}
|
||||
.confirmationDialog("从书架移除《\(model.book.name)》?", isPresented: $showsRemoveConfirmation) {
|
||||
Button("移出书架", role: .destructive) {
|
||||
Task { await library.remove(bookURL: model.book.bookUrl) }
|
||||
}
|
||||
Button("取消", role: .cancel) { }
|
||||
}
|
||||
}
|
||||
|
||||
private var cacheProgressCard: some View {
|
||||
ReadingCard {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Label(model.isCaching ? "正在缓存章节" : "章节缓存完成", systemImage: model.isCaching ? "arrow.down.circle.fill" : "checkmark.circle.fill")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text("\(model.cacheCompleted)/\(model.cacheTotal)")
|
||||
.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||
}
|
||||
ProgressView(value: Double(model.cacheCompleted), total: Double(max(model.cacheTotal, 1)))
|
||||
.tint(ReadingTheme.accent)
|
||||
if model.isCaching {
|
||||
Button("停止缓存", role: .destructive) { model.cancelCaching() }
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var hero: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(alignment: .top, spacing: 18) {
|
||||
BookCoverArtwork(
|
||||
title: model.book.name,
|
||||
author: model.book.author,
|
||||
remoteURL: model.book.coverUrl,
|
||||
width: 88,
|
||||
height: 126
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(model.book.name).font(.title2.weight(.semibold)).lineLimit(3)
|
||||
Text(model.book.author.isEmpty ? "未知作者" : model.book.author)
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
if let kind = model.book.kind, !kind.isEmpty {
|
||||
Text(kind).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Text(model.source.bookSourceName)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
if let latest = model.book.latestChapterTitle, !latest.isEmpty {
|
||||
Text("更新至 \(latest)")
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
Group {
|
||||
if dynamicTypeSize.isAccessibilitySize {
|
||||
VStack(spacing: 12) { readingActions }
|
||||
} else {
|
||||
HStack(spacing: 12) { readingActions }
|
||||
}
|
||||
}
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var readingActions: some View {
|
||||
NavigationLink {
|
||||
readingDestination
|
||||
} label: {
|
||||
Text(model.book.durChapterIndex > 0 || model.book.durChapterPos > 0 ? "继续阅读" : "开始阅读")
|
||||
.font(.body.weight(.medium))
|
||||
.frame(maxWidth: .infinity, minHeight: 28)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(model.chapters.isEmpty)
|
||||
Button {
|
||||
if library.contains(model.book) {
|
||||
showsRemoveConfirmation = true
|
||||
} else {
|
||||
Task { await saveCurrentBook() }
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
library.contains(model.book) ? "已在书架" : "加入书架",
|
||||
systemImage: library.contains(model.book) ? "checkmark" : "plus"
|
||||
)
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: dynamicTypeSize.isAccessibilitySize ? .infinity : nil, minHeight: 28)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var readingDestination: some View {
|
||||
let index = min(max(model.book.durChapterIndex, 0), max(model.chapters.count - 1, 0))
|
||||
if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
|
||||
ReaderView(demoBook: model.book, chapters: model.chapters, initialIndex: index, content: DemoContent.readerText)
|
||||
} else {
|
||||
ReaderView(book: model.book, chapters: model.chapters, initialIndex: index, source: model.source)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveCurrentBook() async {
|
||||
await library.addOrUpdate(model.book)
|
||||
if let stored = library.books.first(where: { $0.bookUrl == model.book.bookUrl }) {
|
||||
model.restoreReadingProgress(from: stored)
|
||||
}
|
||||
}
|
||||
|
||||
private var chapterCard: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("目录").font(.headline)
|
||||
Spacer()
|
||||
Text("\(model.chapters.count) 章").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
if model.state == .loading || model.state == .idle {
|
||||
HStack { Spacer(); ProgressView("正在解析目录…"); Spacer() }.padding(.vertical, 28)
|
||||
} else if model.state == .findingSource {
|
||||
HStack { Spacer(); ProgressView("当前源不可用,正在自动寻找可用源…"); Spacer() }
|
||||
.padding(.vertical, 28)
|
||||
} else if model.state == .failed {
|
||||
ReadingUnavailableView(
|
||||
"没有找到可用目录",
|
||||
systemImage: "exclamationmark.arrow.triangle.2.circlepath",
|
||||
description: Text("已自动尝试其他可搜索书源,可稍后刷新重试或手动换源。")
|
||||
)
|
||||
.padding(.vertical, 12)
|
||||
} else {
|
||||
ForEach(Array(model.chapters.filter { !$0.isVolume }.prefix(8).enumerated()), id: \.element.id) { offset, chapter in
|
||||
if offset > 0 { Divider() }
|
||||
if chapter.isVolume {
|
||||
Text(chapter.title).font(.subheadline.bold()).foregroundStyle(ReadingTheme.accent)
|
||||
.padding(.vertical, 12)
|
||||
} else {
|
||||
NavigationLink {
|
||||
ReaderView(
|
||||
book: model.book,
|
||||
chapters: model.chapters,
|
||||
initialIndex: chapter.index,
|
||||
source: model.source
|
||||
)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Text(String(format: "%02d", chapter.index + 1))
|
||||
.font(.caption.monospacedDigit()).foregroundStyle(.tertiary).frame(width: 28)
|
||||
Text(chapter.title).font(.subheadline).foregroundStyle(.primary).lineLimit(2)
|
||||
Spacer()
|
||||
if chapter.isVip { Image(systemName: "lock.fill").font(.caption).foregroundStyle(.orange) }
|
||||
Image(systemName: "chevron.right").font(.caption.bold()).foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
if model.chapters.count > 8 {
|
||||
Divider()
|
||||
Button {
|
||||
showsTableOfContents = true
|
||||
} label: {
|
||||
Label("查看全部目录", systemImage: "list.bullet")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BookTableOfContentsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let book: Book
|
||||
let chapters: [BookChapter]
|
||||
let source: BookSource
|
||||
@State private var searchText = ""
|
||||
@State private var descending = false
|
||||
|
||||
private var displayedChapters: [BookChapter] {
|
||||
let filtered = searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? chapters
|
||||
: chapters.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
|
||||
return descending ? Array(filtered.reversed()) : filtered
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
List(displayedChapters) { chapter in
|
||||
if chapter.isVolume {
|
||||
Text(chapter.title).font(.headline).foregroundStyle(ReadingTheme.accent)
|
||||
} else {
|
||||
NavigationLink {
|
||||
ReaderView(book: book, chapters: chapters, initialIndex: chapter.index, source: source)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Text("\(chapter.index + 1)")
|
||||
.font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width: 42, alignment: .trailing)
|
||||
Text(chapter.title).lineLimit(2)
|
||||
Spacer()
|
||||
if chapter.index == book.durChapterIndex {
|
||||
Image(systemName: "location.fill").foregroundStyle(ReadingTheme.accent)
|
||||
}
|
||||
if chapter.isVip { Image(systemName: "lock.fill").foregroundStyle(.orange) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "搜索目录")
|
||||
.navigationTitle("目录")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } }
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(descending ? "正序" : "倒序", systemImage: "arrow.up.arrow.down") {
|
||||
descending.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
HStack {
|
||||
Text(book.durChapterTitle ?? "尚未阅读").lineLimit(1)
|
||||
Spacer()
|
||||
Text("\(min(book.durChapterIndex + 1, chapters.count))/\(chapters.count)")
|
||||
.monospacedDigit().foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 16).padding(.vertical, 10)
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import SwiftUI
|
||||
#if SWIFT_PACKAGE
|
||||
import LegadoCore
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class BookDetailViewModel: ObservableObject {
|
||||
enum State: Equatable { case idle, loading, findingSource, ready, failed }
|
||||
|
||||
@Published private(set) var state: State = .idle
|
||||
@Published private(set) var book: Book
|
||||
@Published private(set) var chapters: [BookChapter] = []
|
||||
@Published private(set) var diagnostics: [RuleDiagnostic] = []
|
||||
@Published private(set) var isCaching = false
|
||||
@Published private(set) var cacheCompleted = 0
|
||||
@Published private(set) var cacheTotal = 0
|
||||
|
||||
@Published private(set) var source: BookSource
|
||||
private var searchBook: SearchBook?
|
||||
private let service: NetworkBookService
|
||||
private let updateCoordinator: BookUpdateCoordinator
|
||||
private let contentCoordinator: ChapterContentCoordinator
|
||||
private let sourceSwitchService: BookSourceSwitchService
|
||||
private var cacheTask: Task<Void, Never>?
|
||||
|
||||
init(
|
||||
searchBook: SearchBook,
|
||||
source: BookSource,
|
||||
service: NetworkBookService = .init(),
|
||||
chapterRepository: any BookChapterRepository = AppBookChapterRepository.live()
|
||||
) {
|
||||
self.searchBook = searchBook
|
||||
self.source = source
|
||||
self.service = service
|
||||
let chapters = chapterRepository
|
||||
updateCoordinator = BookUpdateCoordinator(repository: chapters, loader: service)
|
||||
contentCoordinator = ChapterContentCoordinator(loader: service)
|
||||
sourceSwitchService = BookSourceSwitchService(
|
||||
detailLoader: service,
|
||||
chapterLoader: service,
|
||||
contentLoader: service,
|
||||
chapterRepository: chapters
|
||||
)
|
||||
var book = Book(
|
||||
bookUrl: searchBook.bookURL,
|
||||
name: searchBook.name,
|
||||
author: searchBook.author,
|
||||
origin: source.bookSourceUrl
|
||||
)
|
||||
book.originName = source.bookSourceName
|
||||
book.coverUrl = searchBook.coverURL
|
||||
book.intro = searchBook.intro
|
||||
book.kind = searchBook.kind
|
||||
self.book = book
|
||||
}
|
||||
|
||||
init(book: Book, source: BookSource, service: NetworkBookService = .init()) {
|
||||
searchBook = nil
|
||||
self.book = book
|
||||
self.source = source
|
||||
self.service = service
|
||||
let chapters = AppBookChapterRepository.live()
|
||||
updateCoordinator = BookUpdateCoordinator(repository: chapters, loader: service)
|
||||
contentCoordinator = ChapterContentCoordinator(loader: service)
|
||||
sourceSwitchService = BookSourceSwitchService(
|
||||
detailLoader: service,
|
||||
chapterLoader: service,
|
||||
contentLoader: service,
|
||||
chapterRepository: chapters
|
||||
)
|
||||
}
|
||||
|
||||
init(demoBook: Book, source: BookSource, chapters: [BookChapter]) {
|
||||
searchBook = nil
|
||||
book = demoBook
|
||||
self.source = source
|
||||
service = .init()
|
||||
updateCoordinator = .init()
|
||||
contentCoordinator = .init()
|
||||
sourceSwitchService = .init()
|
||||
self.chapters = chapters
|
||||
state = .ready
|
||||
isCaching = true
|
||||
cacheCompleted = 2
|
||||
cacheTotal = chapters.filter { !$0.isVolume }.count
|
||||
}
|
||||
|
||||
func load(sources: [BookSource] = []) async {
|
||||
guard state == .idle else { return }
|
||||
state = .loading
|
||||
if searchBook == nil {
|
||||
let stored = await updateCoordinator.storedChapters(bookURL: book.bookUrl)
|
||||
if !stored.isEmpty {
|
||||
chapters = stored
|
||||
state = .ready
|
||||
}
|
||||
}
|
||||
if let searchBook {
|
||||
let detail = await service.loadDetail(searchBook: searchBook, source: source)
|
||||
let details = detail.book
|
||||
book.name = details.name
|
||||
book.author = details.author
|
||||
book.originName = details.originName
|
||||
if !details.tocUrl.isEmpty { book.tocUrl = details.tocUrl }
|
||||
book.type = details.type
|
||||
book.kind = details.kind ?? book.kind
|
||||
book.coverUrl = details.coverUrl ?? book.coverUrl
|
||||
book.intro = details.intro ?? book.intro
|
||||
book.charset = details.charset ?? book.charset
|
||||
book.wordCount = details.wordCount ?? book.wordCount
|
||||
book.latestChapterTitle = details.latestChapterTitle ?? book.latestChapterTitle
|
||||
book.originOrder = details.originOrder
|
||||
book.variable = details.variable ?? book.variable
|
||||
diagnostics += detail.diagnostics
|
||||
}
|
||||
let toc = await updateCoordinator.refresh(book: book, source: source)
|
||||
if !toc.chapters.isEmpty { chapters = toc.chapters }
|
||||
diagnostics += toc.diagnostics
|
||||
book = toc.book
|
||||
if !chapters.isEmpty {
|
||||
state = .ready
|
||||
return
|
||||
}
|
||||
|
||||
state = .findingSource
|
||||
if let available = await sourceSwitchService.firstAvailableSource(for: book, sources: sources) {
|
||||
applySourceSwitch(available.report, source: available.source)
|
||||
} else {
|
||||
state = .failed
|
||||
}
|
||||
}
|
||||
|
||||
func cacheNextTen() { startCaching(limit: 10) }
|
||||
|
||||
func cacheRemaining() { startCaching(limit: nil) }
|
||||
|
||||
func cancelCaching() {
|
||||
cacheTask?.cancel()
|
||||
cacheTask = nil
|
||||
isCaching = false
|
||||
}
|
||||
|
||||
func applySourceSwitch(_ report: BookSourceSwitchReport, source: BookSource) {
|
||||
cancelCaching()
|
||||
searchBook = nil
|
||||
book = report.book
|
||||
chapters = report.chapters
|
||||
diagnostics = report.diagnostics
|
||||
self.source = source
|
||||
state = .ready
|
||||
}
|
||||
|
||||
func toggleCanUpdate() { book.canUpdate.toggle() }
|
||||
|
||||
func restoreLibraryBook(from stored: Book, sources: [BookSource] = []) {
|
||||
guard state == .idle, stored.bookUrl == book.bookUrl else { return }
|
||||
if stored.origin != source.id,
|
||||
let resolved = sources.first(where: { $0.id == stored.origin }) {
|
||||
source = resolved
|
||||
searchBook = nil
|
||||
}
|
||||
book = stored
|
||||
book.origin = source.id
|
||||
book.originName = source.bookSourceName
|
||||
}
|
||||
|
||||
func restoreSourceAfterReading(from books: [Book], sources: [BookSource]) {
|
||||
guard state == .ready,
|
||||
let stored = books.first(where: { $0.bookUrl == book.bookUrl })
|
||||
?? books.first(where: { $0.name == book.name && $0.author == book.author }),
|
||||
stored.bookUrl != book.bookUrl || stored.origin != book.origin,
|
||||
let source = sources.first(where: { $0.id == stored.origin }) else { return }
|
||||
book = stored
|
||||
self.source = source
|
||||
searchBook = nil
|
||||
chapters = []
|
||||
diagnostics = []
|
||||
state = .idle
|
||||
}
|
||||
|
||||
func restoreUserSettings(from stored: Book) {
|
||||
guard stored.bookUrl == book.bookUrl else { return }
|
||||
book.group = stored.group
|
||||
book.order = stored.order
|
||||
book.customTag = stored.customTag
|
||||
book.customCoverUrl = stored.customCoverUrl
|
||||
book.customIntro = stored.customIntro
|
||||
book.canUpdate = stored.canUpdate
|
||||
book.readConfig = stored.readConfig
|
||||
book.syncTime = stored.syncTime
|
||||
}
|
||||
|
||||
func restoreReadingProgress(from stored: Book) {
|
||||
guard stored.bookUrl == book.bookUrl, stored.durChapterTime > book.durChapterTime else { return }
|
||||
let chapterIndex = stored.durChapterTitle.flatMap { title in
|
||||
chapters.firstIndex { !$0.isVolume && $0.title == title }
|
||||
} ?? stored.durChapterIndex
|
||||
guard chapters.indices.contains(chapterIndex), !chapters[chapterIndex].isVolume else { return }
|
||||
book.durChapterIndex = chapterIndex
|
||||
book.durChapterTitle = chapters[chapterIndex].title
|
||||
book.durChapterPos = stored.durChapterPos
|
||||
book.durChapterTime = stored.durChapterTime
|
||||
}
|
||||
|
||||
func refresh(sources: [BookSource] = []) async {
|
||||
cancelCaching()
|
||||
chapters = []
|
||||
diagnostics = []
|
||||
state = .idle
|
||||
await load(sources: sources)
|
||||
}
|
||||
|
||||
private func startCaching(limit: Int?) {
|
||||
cancelCaching()
|
||||
let start = min(max(book.durChapterIndex, 0), max(chapters.count - 1, 0))
|
||||
let jobs = ChapterCachePlanner.plan(chapters: chapters, startIndex: start, limit: limit)
|
||||
guard !jobs.isEmpty else { return }
|
||||
|
||||
isCaching = true
|
||||
cacheCompleted = 0
|
||||
cacheTotal = jobs.count
|
||||
let coordinator = contentCoordinator
|
||||
let currentBook = book
|
||||
let currentSource = source
|
||||
cacheTask = Task { [weak self] in
|
||||
await withTaskGroup(of: Bool.self) { group in
|
||||
var next = 0
|
||||
func enqueue() {
|
||||
guard next < jobs.count else { return }
|
||||
let job = jobs[next]
|
||||
next += 1
|
||||
group.addTask {
|
||||
guard !Task.isCancelled else { return false }
|
||||
return await coordinator.prefetch(
|
||||
book: currentBook,
|
||||
chapter: job.chapter,
|
||||
source: currentSource,
|
||||
nextChapterURL: job.nextChapterURL
|
||||
)
|
||||
}
|
||||
}
|
||||
for _ in 0..<min(3, jobs.count) { enqueue() }
|
||||
while await group.next() != nil {
|
||||
guard !Task.isCancelled else {
|
||||
group.cancelAll()
|
||||
break
|
||||
}
|
||||
self?.cacheCompleted += 1
|
||||
enqueue()
|
||||
}
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.isCaching = false
|
||||
self?.cacheTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
#if SWIFT_PACKAGE
|
||||
import LegadoCore
|
||||
#endif
|
||||
|
||||
enum AppBookChapterRepository {
|
||||
static func live() -> any BookChapterRepository {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Legado", isDirectory: true)
|
||||
guard let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) else {
|
||||
return FileBookChapterRepository.live()
|
||||
}
|
||||
return SQLiteBookChapterRepository(
|
||||
database: database,
|
||||
legacyDirectory: base.appendingPathComponent("Chapters", isDirectory: true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class BookLibraryModel: ObservableObject {
|
||||
@Published private(set) var books: [Book] = []
|
||||
@Published private(set) var groups: [BookGroup] = []
|
||||
@Published private(set) var errorMessage: String?
|
||||
@Published private(set) var updateMessage: String?
|
||||
@Published private(set) var isCheckingUpdates = false
|
||||
|
||||
private let repository: any BookRepository
|
||||
private let localDirectory: URL
|
||||
let localFiles: LocalFileService
|
||||
private let localService = LocalBookService.live()
|
||||
private let updateCoordinator: BookUpdateCoordinator
|
||||
private let chapterRepository: any BookChapterRepository
|
||||
private let groupRepository: any BookGroupRepository
|
||||
private let pendingProgressKey = "pendingBookReadingProgress"
|
||||
private var pendingProgress: [String: BookProgress]
|
||||
private var bookSaveTask: Task<Bool, Never>?
|
||||
private var bookSaveGeneration = 0
|
||||
private var persistedBooks: [Book] = []
|
||||
|
||||
init(
|
||||
repository: any BookRepository,
|
||||
localDirectory: URL? = nil,
|
||||
updateCoordinator: BookUpdateCoordinator = .init(),
|
||||
chapterRepository: any BookChapterRepository = FileBookChapterRepository.live(),
|
||||
groupRepository: any BookGroupRepository = InMemoryBookGroupRepository()
|
||||
) {
|
||||
self.repository = repository
|
||||
self.localDirectory = localDirectory ?? FileManager.default.temporaryDirectory.appendingPathComponent("LegadoLocalBooks")
|
||||
localFiles = LocalFileService(directory: self.localDirectory)
|
||||
self.updateCoordinator = updateCoordinator
|
||||
self.chapterRepository = chapterRepository
|
||||
self.groupRepository = groupRepository
|
||||
pendingProgress = (UserDefaults.standard.data(forKey: pendingProgressKey)
|
||||
.flatMap { try? JSONDecoder().decode([String: BookProgress].self, from: $0) }) ?? [:]
|
||||
}
|
||||
|
||||
static func live() -> BookLibraryModel {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Legado", isDirectory: true)
|
||||
let legacyBooks = base.appendingPathComponent("books.json")
|
||||
guard let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) else {
|
||||
return BookLibraryModel(
|
||||
repository: FileBookRepository(fileURL: legacyBooks),
|
||||
localDirectory: base.appendingPathComponent("LocalBooks", isDirectory: true),
|
||||
groupRepository: FileBookGroupRepository(fileURL: base.appendingPathComponent("book-groups.json"))
|
||||
)
|
||||
}
|
||||
let chapters = SQLiteBookChapterRepository(
|
||||
database: database,
|
||||
legacyDirectory: base.appendingPathComponent("Chapters", isDirectory: true)
|
||||
)
|
||||
return BookLibraryModel(
|
||||
repository: SQLiteBookRepository(database: database, legacyFileURL: legacyBooks),
|
||||
localDirectory: base.appendingPathComponent("LocalBooks", isDirectory: true),
|
||||
updateCoordinator: BookUpdateCoordinator(repository: chapters),
|
||||
chapterRepository: chapters,
|
||||
groupRepository: SQLiteBookGroupRepository(
|
||||
database: database,
|
||||
legacyFileURL: base.appendingPathComponent("book-groups.json")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
static func demo(books: [Book]) -> BookLibraryModel {
|
||||
BookLibraryModel(repository: InMemoryBookRepository(books: books))
|
||||
}
|
||||
|
||||
func load() async {
|
||||
do {
|
||||
async let storedBooks = repository.load()
|
||||
async let storedGroups = groupRepository.load()
|
||||
books = try await storedBooks
|
||||
persistedBooks = books
|
||||
groups = try await storedGroups
|
||||
if groups.isEmpty {
|
||||
groups = [.init(groupId: BookGroup.idAll, groupName: "全部", order: 0)]
|
||||
try await persistGroups()
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func contains(_ book: Book) -> Bool {
|
||||
books.contains { $0.bookUrl == book.bookUrl }
|
||||
}
|
||||
|
||||
func addOrUpdate(_ book: Book) async {
|
||||
var book = book
|
||||
if let existing = books.first(where: { $0.bookUrl == book.bookUrl }),
|
||||
existing.durChapterTime > book.durChapterTime {
|
||||
applyProgress(BookProgress(book: existing), to: &book)
|
||||
}
|
||||
if let pending = pendingProgress.removeValue(forKey: book.bookUrl) {
|
||||
if pending.durChapterTime > book.durChapterTime {
|
||||
applyProgress(pending, to: &book)
|
||||
}
|
||||
persistPendingProgress()
|
||||
}
|
||||
var updated = books
|
||||
if let index = updated.firstIndex(where: { $0.bookUrl == book.bookUrl }) {
|
||||
updated[index] = book
|
||||
} else {
|
||||
updated.append(book)
|
||||
}
|
||||
await persist(updated)
|
||||
}
|
||||
|
||||
func replace(oldBookURL: String, with book: Book) async {
|
||||
guard books.contains(where: { $0.bookUrl == oldBookURL }) else {
|
||||
await addOrUpdate(book)
|
||||
return
|
||||
}
|
||||
var updated = books.filter { $0.bookUrl == oldBookURL || $0.bookUrl != book.bookUrl }
|
||||
guard let index = updated.firstIndex(where: { $0.bookUrl == oldBookURL }) else { return }
|
||||
updated[index] = book
|
||||
await persist(updated)
|
||||
if oldBookURL != book.bookUrl, books.contains(where: { $0.bookUrl == book.bookUrl }) {
|
||||
try? await chapterRepository.remove(bookURL: oldBookURL)
|
||||
}
|
||||
}
|
||||
|
||||
func updateProgress(for book: Book, chapter: BookChapter, position: Int = 0) async {
|
||||
guard let index = books.firstIndex(where: { $0.bookUrl == book.bookUrl }) else {
|
||||
var reading = book
|
||||
reading.durChapterIndex = chapter.index
|
||||
reading.durChapterTitle = chapter.title
|
||||
reading.durChapterPos = max(position, 0)
|
||||
reading.durChapterTime = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
pendingProgress[book.bookUrl] = BookProgress(book: reading)
|
||||
persistPendingProgress()
|
||||
return
|
||||
}
|
||||
var updated = books
|
||||
updated[index].durChapterIndex = chapter.index
|
||||
updated[index].durChapterTitle = chapter.title
|
||||
updated[index].durChapterPos = max(position, 0)
|
||||
updated[index].durChapterTime = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
updated[index].lastCheckCount = 0
|
||||
updated[index].totalChapterNum = max(updated[index].totalChapterNum, chapter.index + 1)
|
||||
await persist(updated)
|
||||
}
|
||||
|
||||
private func applyProgress(_ progress: BookProgress, to book: inout Book) {
|
||||
book.durChapterIndex = progress.durChapterIndex
|
||||
book.durChapterTitle = progress.durChapterTitle
|
||||
book.durChapterPos = progress.durChapterPos
|
||||
book.durChapterTime = progress.durChapterTime
|
||||
}
|
||||
|
||||
private func persistPendingProgress() {
|
||||
if pendingProgress.isEmpty {
|
||||
UserDefaults.standard.removeObject(forKey: pendingProgressKey)
|
||||
} else if let data = try? JSONEncoder().encode(pendingProgress) {
|
||||
UserDefaults.standard.set(data, forKey: pendingProgressKey)
|
||||
}
|
||||
}
|
||||
|
||||
func checkForUpdates(sources: [BookSource], onlyRead: Bool = false) async {
|
||||
guard !isCheckingUpdates else { return }
|
||||
isCheckingUpdates = true
|
||||
updateMessage = nil
|
||||
defer { isCheckingUpdates = false }
|
||||
|
||||
let sourceByURL = Dictionary(uniqueKeysWithValues: sources.map { ($0.bookSourceUrl, $0) })
|
||||
let requestedBooks = books.filter { $0.canUpdate && $0.origin != "local" && (!onlyRead || $0.durChapterTime > 0) }
|
||||
var checked = 0
|
||||
var added = 0
|
||||
var failed = 0
|
||||
for requestedBook in requestedBooks {
|
||||
guard let source = sourceByURL[requestedBook.origin], source.enabled else {
|
||||
failed += 1
|
||||
continue
|
||||
}
|
||||
let report = await updateCoordinator.refresh(book: requestedBook, source: source)
|
||||
checked += 1
|
||||
if report.refreshed {
|
||||
// Network requests suspend this actor. Merge the new directory
|
||||
// into the current shelf, including any progress or edits made
|
||||
// while the request was running, and never resurrect removals.
|
||||
guard let index = books.firstIndex(where: {
|
||||
$0.bookUrl == requestedBook.bookUrl && $0.origin == requestedBook.origin
|
||||
}) else { continue }
|
||||
var updated = books
|
||||
updated[index] = BookUpdateMerger().merge(
|
||||
book: updated[index],
|
||||
oldChapters: [],
|
||||
newChapters: report.chapters,
|
||||
now: report.book.lastCheckTime
|
||||
).book
|
||||
added += report.addedChapterCount
|
||||
await persist(updated)
|
||||
} else {
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
|
||||
if checked == 0 && failed == 0 {
|
||||
updateMessage = "书架里暂时没有可更新的网络书籍。"
|
||||
} else if failed > 0 {
|
||||
updateMessage = "已检查 \(checked) 本,发现 \(added) 个新章节;另有 \(failed) 本因书源不可用或解析失败未更新。"
|
||||
} else {
|
||||
updateMessage = added > 0 ? "更新完成,共发现 \(added) 个新章节。" : "更新完成,已是最新目录。"
|
||||
}
|
||||
}
|
||||
|
||||
func remove(at offsets: IndexSet) async {
|
||||
var updated = books
|
||||
updated.remove(atOffsets: offsets)
|
||||
await persist(updated)
|
||||
}
|
||||
|
||||
var visibleGroups: [BookGroup] {
|
||||
groups.filter(\.show).sorted {
|
||||
$0.order == $1.order ? $0.groupId < $1.groupId : $0.order < $1.order
|
||||
}
|
||||
}
|
||||
|
||||
func books(in group: BookGroup) -> [Book] {
|
||||
switch group.groupId {
|
||||
case BookGroup.idAll: return books
|
||||
case BookGroup.idLocal: return books.filter { $0.origin == "local" }
|
||||
case BookGroup.idNetNone: return books.filter { $0.origin != "local" && $0.group == 0 }
|
||||
case BookGroup.idLocalNone: return books.filter { $0.origin == "local" && $0.group == 0 }
|
||||
default:
|
||||
return group.groupId > 0
|
||||
? books.filter { ($0.group & group.groupId) != 0 }
|
||||
: books
|
||||
}
|
||||
}
|
||||
|
||||
func createGroup(name: String) async {
|
||||
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty, !groups.contains(where: { $0.groupName == name }) else { return }
|
||||
let highest = groups.map(\.groupId).filter { $0 > 0 }.max() ?? 0
|
||||
let nextID: Int64 = highest == 0 ? 1 : highest << 1
|
||||
groups.append(.init(groupId: nextID, groupName: name, order: groups.count))
|
||||
do { try await persistGroups() } catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func renameGroup(_ group: BookGroup, name: String) async {
|
||||
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty, let index = groups.firstIndex(where: { $0.id == group.id }) else { return }
|
||||
groups[index].groupName = name
|
||||
do { try await persistGroups() } catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func setGroupVisible(_ group: BookGroup, visible: Bool) async {
|
||||
guard let index = groups.firstIndex(where: { $0.id == group.id }) else { return }
|
||||
groups[index].show = visible
|
||||
do { try await persistGroups() } catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func moveGroups(from offsets: IndexSet, to destination: Int) async {
|
||||
var editable = groups.filter { $0.groupId > 0 }.sorted { $0.order < $1.order }
|
||||
editable.move(fromOffsets: offsets, toOffset: destination)
|
||||
for (order, group) in editable.enumerated() {
|
||||
if let index = groups.firstIndex(where: { $0.id == group.id }) {
|
||||
groups[index].order = order + 1
|
||||
}
|
||||
}
|
||||
do { try await persistGroups() } catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func deleteGroup(_ group: BookGroup) async {
|
||||
guard group.groupId > 0 else { return }
|
||||
groups.removeAll { $0.id == group.id }
|
||||
var updated = books
|
||||
for index in updated.indices { updated[index].group &= ~group.groupId }
|
||||
await persist(updated)
|
||||
do { try await persistGroups() } catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func setBook(_ book: Book, in group: BookGroup, included: Bool) async {
|
||||
guard group.groupId > 0, let index = books.firstIndex(where: { $0.id == book.id }) else { return }
|
||||
var updated = books
|
||||
if included { updated[index].group |= group.groupId }
|
||||
else { updated[index].group &= ~group.groupId }
|
||||
await persist(updated)
|
||||
}
|
||||
|
||||
func setBook(_ book: Book, groupIDs: Set<Int64>) async {
|
||||
guard let index = books.firstIndex(where: { $0.id == book.id }) else { return }
|
||||
let customMask = groups.reduce(Int64(0)) { value, group in
|
||||
group.groupId > 0 ? value | group.groupId : value
|
||||
}
|
||||
let selectedMask = groupIDs.reduce(Int64(0), |)
|
||||
var updated = books
|
||||
updated[index].group = (updated[index].group & ~customMask) | selectedMask
|
||||
await persist(updated)
|
||||
}
|
||||
|
||||
func remove(bookURL: String) async {
|
||||
await persist(books.filter { $0.bookUrl != bookURL })
|
||||
try? await chapterRepository.remove(bookURL: bookURL)
|
||||
}
|
||||
|
||||
func importLocalFiles(_ urls: [URL]) async {
|
||||
var failures: [String] = []
|
||||
for url in urls {
|
||||
let granted = url.startAccessingSecurityScopedResource()
|
||||
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
||||
do {
|
||||
let stored = try await localFiles.importFile(from: url)
|
||||
do {
|
||||
try await addLocalFile(at: stored)
|
||||
} catch {
|
||||
if let staged = try? await localFiles.stageRemoval(stored) {
|
||||
try? await localFiles.finishRemoval(staged)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} catch {
|
||||
failures.append("\(url.lastPathComponent):\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
errorMessage = failures.isEmpty ? nil : failures.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func addLocalFile(at url: URL) async throws {
|
||||
guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return }
|
||||
let report = try await Task.detached { [localService] in try localService.openTextFile(at: url) }.value
|
||||
guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return }
|
||||
guard await persist(books + [report.book]) else {
|
||||
throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库")
|
||||
}
|
||||
}
|
||||
|
||||
func renameLocalFile(at url: URL, to name: String) async throws {
|
||||
let target = try await localFiles.rename(url, to: name)
|
||||
guard target != url else { return }
|
||||
var updated = books
|
||||
for index in updated.indices where updated[index].origin == "local" && updated[index].bookUrl == url.absoluteString {
|
||||
updated[index].bookUrl = target.absoluteString
|
||||
updated[index].originName = target.lastPathComponent
|
||||
}
|
||||
guard await persist(updated) else {
|
||||
_ = try await localFiles.rename(target, to: url.lastPathComponent)
|
||||
throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库")
|
||||
}
|
||||
if let progress = pendingProgress.removeValue(forKey: url.absoluteString) {
|
||||
pendingProgress[target.absoluteString] = progress
|
||||
persistPendingProgress()
|
||||
}
|
||||
try? await chapterRepository.remove(bookURL: url.absoluteString)
|
||||
}
|
||||
|
||||
func deleteLocalFile(at url: URL) async throws {
|
||||
let staged: StagedLocalFile?
|
||||
if url.isFileURL,
|
||||
books.contains(where: { $0.origin == "local" && $0.bookUrl == url.absoluteString }),
|
||||
!FileManager.default.fileExists(atPath: url.path) {
|
||||
// Stale shelf paths can belong to a previous app container. Only
|
||||
// remove their metadata; existing files still require containment.
|
||||
staged = nil
|
||||
} else {
|
||||
staged = try await localFiles.stageRemoval(url)
|
||||
}
|
||||
guard await persist(books.filter { $0.bookUrl != url.absoluteString }) else {
|
||||
if let staged { try await localFiles.restore(staged) }
|
||||
throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库")
|
||||
}
|
||||
pendingProgress[url.absoluteString] = nil
|
||||
persistPendingProgress()
|
||||
try? await chapterRepository.remove(bookURL: url.absoluteString)
|
||||
if let staged { try await localFiles.finishRemoval(staged) }
|
||||
}
|
||||
|
||||
func clearError() { errorMessage = nil }
|
||||
|
||||
func clearUpdateMessage() { updateMessage = nil }
|
||||
|
||||
func reportError(_ error: Error) { errorMessage = error.localizedDescription }
|
||||
|
||||
@discardableResult
|
||||
private func persist(_ updated: [Book]) async -> Bool {
|
||||
// Publish before suspending so the next mutation sees the latest
|
||||
// shelf. Serialize disk writes to keep their order identical to it.
|
||||
books = updated
|
||||
bookSaveGeneration &+= 1
|
||||
let generation = bookSaveGeneration
|
||||
let previousSave = bookSaveTask
|
||||
let save = Task { @MainActor [weak self, repository] in
|
||||
_ = await previousSave?.value
|
||||
do {
|
||||
try await repository.save(updated)
|
||||
self?.persistedBooks = updated
|
||||
if self?.bookSaveGeneration == generation { self?.errorMessage = nil }
|
||||
return true
|
||||
} catch {
|
||||
if let self, bookSaveGeneration == generation {
|
||||
books = persistedBooks
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
bookSaveTask = save
|
||||
let succeeded = await save.value
|
||||
if bookSaveGeneration == generation { bookSaveTask = nil }
|
||||
return succeeded
|
||||
}
|
||||
|
||||
private func persistGroups() async throws {
|
||||
try await groupRepository.save(groups)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import SwiftUI
|
||||
#if SWIFT_PACKAGE
|
||||
import LegadoCore
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class BookshelfReadViewModel: ObservableObject {
|
||||
@Published private(set) var chapters: [BookChapter] = []
|
||||
@Published private(set) var resolvedBook: Book?
|
||||
@Published private(set) var isLoading = false
|
||||
@Published private(set) var errorMessage: String?
|
||||
private let localService: LocalBookService
|
||||
private let updateCoordinator: BookUpdateCoordinator
|
||||
|
||||
init(
|
||||
localService: LocalBookService = .init(),
|
||||
updateCoordinator: BookUpdateCoordinator = .init(repository: AppBookChapterRepository.live())
|
||||
) {
|
||||
self.localService = localService
|
||||
self.updateCoordinator = updateCoordinator
|
||||
}
|
||||
|
||||
func load(book: Book, source: BookSource?) async {
|
||||
guard chapters.isEmpty, !isLoading else { return }
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
if book.origin == "local" {
|
||||
do {
|
||||
let report = try await Task.detached { [localService] in
|
||||
try localService.loadChapters(book: book)
|
||||
}.value
|
||||
chapters = report.chapters
|
||||
resolvedBook = book
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
return
|
||||
}
|
||||
let stored = await updateCoordinator.storedChapters(bookURL: book.bookUrl)
|
||||
if !stored.isEmpty, matchesReadingProgress(book, chapters: stored) {
|
||||
chapters = stored
|
||||
resolvedBook = book
|
||||
return
|
||||
}
|
||||
guard let source else {
|
||||
errorMessage = "目录与阅读进度不一致,且当前书籍的书源不存在。请先恢复或更换书源。"
|
||||
return
|
||||
}
|
||||
let report = await updateCoordinator.refresh(book: book, source: source)
|
||||
if report.refreshed, matchesReadingProgress(report.book, chapters: report.chapters) {
|
||||
chapters = report.chapters
|
||||
resolvedBook = report.book
|
||||
} else {
|
||||
errorMessage = report.diagnostics.first?.message ?? "目录刷新后仍与阅读进度不一致,请稍后重试。"
|
||||
}
|
||||
}
|
||||
|
||||
private func matchesReadingProgress(_ book: Book, chapters: [BookChapter]) -> Bool {
|
||||
guard book.totalChapterNum == 0 || book.totalChapterNum == chapters.count else { return false }
|
||||
guard let title = book.durChapterTitle, !title.isEmpty else { return true }
|
||||
return chapters.indices.contains(book.durChapterIndex)
|
||||
&& chapters[book.durChapterIndex].title == title
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class LocalBookDetailViewModel: ObservableObject {
|
||||
@Published private(set) var chapters: [BookChapter] = []
|
||||
@Published private(set) var isLoading = false
|
||||
@Published private(set) var errorMessage: String?
|
||||
|
||||
private let service = LocalBookService.live()
|
||||
|
||||
func load(book: Book) async {
|
||||
guard chapters.isEmpty, !isLoading else { return }
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let report = try await Task.detached { [service] in try service.loadChapters(book: book) }.value
|
||||
chapters = report.chapters
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalBookDetailView: View {
|
||||
@EnvironmentObject private var library: BookLibraryModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let book: Book
|
||||
@StateObject private var model = LocalBookDetailViewModel()
|
||||
@State private var showsTableOfContents = false
|
||||
@State private var showsRemoveConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
VStack(spacing: 14) {
|
||||
BookCoverArtwork(title: book.name, author: book.author, width: 108, height: 154)
|
||||
Text(book.name).font(.title2.bold())
|
||||
HStack {
|
||||
StatusPill(text: "本地 TXT", systemImage: "doc.text.fill")
|
||||
if let charset = book.charset { StatusPill(text: charset, color: ReadingTheme.coral, systemImage: "character") }
|
||||
}
|
||||
Text(book.originName).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
ReadingCard {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Label("目录", systemImage: "list.bullet.rectangle.portrait.fill").font(.headline)
|
||||
Spacer()
|
||||
Text("\(model.chapters.count) 章").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
if model.isLoading {
|
||||
HStack { Spacer(); ProgressView("正在识别章节…"); Spacer() }.padding(.vertical, 28)
|
||||
} else if let error = model.errorMessage {
|
||||
ReadingUnavailableView("无法读取本地书", systemImage: "doc.badge.ellipsis", description: Text(error))
|
||||
} else {
|
||||
ForEach(model.chapters.prefix(8)) { chapter in
|
||||
Divider()
|
||||
NavigationLink {
|
||||
ReaderView(book: book, chapters: model.chapters, initialIndex: chapter.index)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Text(String(format: "%02d", chapter.index + 1))
|
||||
.font(.caption.monospacedDigit()).foregroundStyle(.tertiary).frame(width: 28)
|
||||
Text(chapter.title).font(.subheadline).foregroundStyle(.primary).lineLimit(2)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").font(.caption.bold()).foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
if model.chapters.count > 8 {
|
||||
Divider()
|
||||
Button("查看全部目录", systemImage: "list.bullet") {
|
||||
showsTableOfContents = true
|
||||
}
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.bottom, 30)
|
||||
}
|
||||
.background(ReadingTheme.canvas)
|
||||
.navigationTitle(book.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button("全部目录", systemImage: "list.bullet") { showsTableOfContents = true }
|
||||
Button("移出书架", systemImage: "trash", role: .destructive) { showsRemoveConfirmation = true }
|
||||
} label: { Image(systemName: "ellipsis.circle") }
|
||||
}
|
||||
}
|
||||
.task { await model.load(book: book) }
|
||||
.sheet(isPresented: $showsTableOfContents) {
|
||||
LocalBookTableOfContentsView(book: book, chapters: model.chapters)
|
||||
}
|
||||
.confirmationDialog("从书架移除《\(book.name)》?", isPresented: $showsRemoveConfirmation) {
|
||||
Button("移出书架", role: .destructive) {
|
||||
Task { await library.remove(bookURL: book.bookUrl); dismiss() }
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LocalBookTableOfContentsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let book: Book
|
||||
let chapters: [BookChapter]
|
||||
@State private var query = ""
|
||||
@State private var descending = false
|
||||
|
||||
private var displayed: [BookChapter] {
|
||||
let filtered = query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? chapters
|
||||
: chapters.filter { $0.title.localizedCaseInsensitiveContains(query) }
|
||||
return descending ? Array(filtered.reversed()) : filtered
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
List(displayed) { chapter in
|
||||
NavigationLink {
|
||||
ReaderView(book: book, chapters: chapters, initialIndex: chapter.index)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("\(chapter.index + 1)").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width: 42, alignment: .trailing)
|
||||
Text(chapter.title).lineLimit(2)
|
||||
Spacer()
|
||||
if chapter.index == book.durChapterIndex {
|
||||
Image(systemName: "location.fill").foregroundStyle(ReadingTheme.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $query, prompt: "搜索目录")
|
||||
.navigationTitle("目录")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } }
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(descending ? "正序" : "倒序", systemImage: "arrow.up.arrow.down") { descending.toggle() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class SourceSwitchViewModel: ObservableObject {
|
||||
enum State: Equatable { case idle, searching, ready, empty }
|
||||
|
||||
@Published private(set) var state: State = .idle
|
||||
@Published private(set) var candidates: [BookSourceCandidate] = []
|
||||
@Published private(set) var switchingID: String?
|
||||
@Published private(set) var errorMessage: String?
|
||||
|
||||
private let book: Book
|
||||
var bookName: String { book.name }
|
||||
private let service: BookSourceSwitchService
|
||||
private var selectionGeneration = 0
|
||||
|
||||
init(book: Book, service: BookSourceSwitchService = .init(), candidates: [BookSourceCandidate] = []) {
|
||||
self.book = book
|
||||
self.service = service
|
||||
self.candidates = candidates
|
||||
state = candidates.isEmpty ? .idle : .ready
|
||||
}
|
||||
|
||||
func load(sources: [BookSource]) async {
|
||||
guard state == .idle else { return }
|
||||
state = .searching
|
||||
for await update in await service.candidateUpdates(for: book, sources: sources) {
|
||||
guard !Task.isCancelled else { state = candidates.isEmpty ? .idle : .ready; return }
|
||||
candidates = update
|
||||
}
|
||||
guard !Task.isCancelled else { state = candidates.isEmpty ? .idle : .ready; return }
|
||||
state = candidates.isEmpty ? .empty : .ready
|
||||
}
|
||||
|
||||
func select(_ candidate: BookSourceCandidate, sources: [BookSource]) async -> (BookSourceSwitchReport, BookSource)? {
|
||||
guard switchingID == nil,
|
||||
let source = sources.first(where: { $0.bookSourceUrl == candidate.book.origin }) else { return nil }
|
||||
switchingID = candidate.id
|
||||
let generation = selectionGeneration
|
||||
errorMessage = nil
|
||||
defer { switchingID = nil }
|
||||
let report = await service.prepareSwitch(current: book, candidate: candidate.book, source: source)
|
||||
guard generation == selectionGeneration, !Task.isCancelled else { return nil }
|
||||
guard let report else {
|
||||
errorMessage = "候选书源无法读取目录或当前章节正文,请选择其他书源。已保留当前书源和阅读位置。"
|
||||
return nil
|
||||
}
|
||||
return (report, source)
|
||||
}
|
||||
|
||||
func clearError() { errorMessage = nil }
|
||||
func cancelSelection() { selectionGeneration &+= 1 }
|
||||
}
|
||||
|
||||
struct SourceSwitchView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var model: SourceSwitchViewModel
|
||||
let sources: [BookSource]
|
||||
let onSwitched: @MainActor (BookSourceSwitchReport, BookSource) -> Void
|
||||
|
||||
init(
|
||||
book: Book,
|
||||
sources: [BookSource],
|
||||
demoCandidates: [BookSourceCandidate] = [],
|
||||
onSwitched: @escaping @MainActor (BookSourceSwitchReport, BookSource) -> Void
|
||||
) {
|
||||
self.sources = sources
|
||||
self.onSwitched = onSwitched
|
||||
_model = StateObject(wrappedValue: SourceSwitchViewModel(book: book, candidates: demoCandidates))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if model.candidates.isEmpty && (model.state == .searching || model.state == .idle) {
|
||||
VStack(spacing: 14) {
|
||||
ProgressView()
|
||||
Text("正在从其他书源查找同名作品…").foregroundStyle(.secondary)
|
||||
}
|
||||
} else if model.state == .empty {
|
||||
ReadingUnavailableView(
|
||||
"没有可用换源",
|
||||
systemImage: "arrow.triangle.2.circlepath",
|
||||
description: Text("仅显示书名一致的候选,并优先匹配作者。请确认其他书源已启用且支持搜索。")
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
if model.state == .searching {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("仍在查找其他书源,可直接选择已找到的结果")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
ForEach(model.candidates) { candidate in candidateRow(candidate) }
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.readingScrollContentBackgroundHidden()
|
||||
.background(ReadingTheme.canvas)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(ReadingTheme.canvas)
|
||||
.navigationTitle("为《\(model.bookName)》换源")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
|
||||
}
|
||||
.task { await model.load(sources: sources) }
|
||||
.onDisappear { model.cancelSelection() }
|
||||
.alert("换源失败", isPresented: Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { if !$0 { model.clearError() } }
|
||||
)) {
|
||||
Button("好") { model.clearError() }
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private func candidateRow(_ candidate: BookSourceCandidate) -> some View {
|
||||
Button {
|
||||
Task {
|
||||
if let (report, source) = await model.select(candidate, sources: sources) {
|
||||
onSwitched(report, source)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 13) {
|
||||
Image(systemName: "network")
|
||||
.foregroundStyle(ReadingTheme.accent)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(ReadingTheme.accentSoft.opacity(0.55), in: RoundedRectangle(cornerRadius: 13))
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(candidate.book.originName).font(.headline).foregroundStyle(.primary)
|
||||
Text(candidate.book.author.isEmpty ? "作者信息缺失" : candidate.book.author)
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
if model.switchingID == candidate.id {
|
||||
Text("正在验证目录和正文…").font(.caption).foregroundStyle(.secondary)
|
||||
} else if let latest = candidate.book.latestChapterTitle, !latest.isEmpty {
|
||||
Text(latest).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if model.switchingID == candidate.id {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(systemName: "arrow.right.circle.fill").foregroundStyle(ReadingTheme.coral)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
.disabled(model.switchingID != nil)
|
||||
.accessibilityIdentifier("sourceSwitch.candidate.\(candidate.book.origin)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class ExploreSourceViewModel: ObservableObject {
|
||||
enum State: Equatable { case idle, loading, ready, empty, failed }
|
||||
|
||||
let source: BookSource
|
||||
@Published private(set) var kinds: [ExploreKind] = []
|
||||
@Published private(set) var selectedKind: ExploreKind?
|
||||
@Published private(set) var books: [SearchBook] = []
|
||||
@Published private(set) var diagnostics: [RuleDiagnostic] = []
|
||||
@Published private(set) var state: State = .idle
|
||||
@Published private(set) var isLoadingMore = false
|
||||
|
||||
private let service: BookExploreService
|
||||
private var page = 1
|
||||
private var canLoadMore = false
|
||||
private var paginationTask: Task<Void, Never>?
|
||||
|
||||
init(source: BookSource, service: BookExploreService = .init()) {
|
||||
self.source = source
|
||||
self.service = service
|
||||
}
|
||||
|
||||
deinit { paginationTask?.cancel() }
|
||||
|
||||
func load() async {
|
||||
guard state == .idle else { return }
|
||||
await reload()
|
||||
}
|
||||
|
||||
func reload() async {
|
||||
paginationTask?.cancel()
|
||||
selectedKind = nil
|
||||
kinds = []
|
||||
books = []
|
||||
diagnostics = []
|
||||
page = 1
|
||||
canLoadMore = false
|
||||
state = .loading
|
||||
|
||||
let report = await service.kinds(for: source)
|
||||
guard !Task.isCancelled else { return }
|
||||
kinds = report.kinds.filter { $0.navigableURL != nil }
|
||||
diagnostics = report.diagnostics
|
||||
guard let first = kinds.first else {
|
||||
state = diagnostics.contains(where: { $0.severity == .error }) ? .failed : .empty
|
||||
return
|
||||
}
|
||||
await select(kind: first)
|
||||
}
|
||||
|
||||
func select(kind: ExploreKind) async {
|
||||
guard kind.navigableURL != nil else { return }
|
||||
paginationTask?.cancel()
|
||||
selectedKind = kind
|
||||
books = []
|
||||
diagnostics = []
|
||||
page = 1
|
||||
canLoadMore = false
|
||||
state = .loading
|
||||
|
||||
if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
|
||||
books = DemoContent.exploreBooks
|
||||
state = .ready
|
||||
canLoadMore = true
|
||||
return
|
||||
}
|
||||
|
||||
let outcome = await service.explore(source: source, kind: kind, page: page)
|
||||
guard !Task.isCancelled, selectedKind?.id == kind.id else { return }
|
||||
diagnostics = outcome.diagnostics
|
||||
books = outcome.books
|
||||
canLoadMore = !outcome.books.isEmpty
|
||||
state = books.isEmpty ? (outcome.diagnostics.isEmpty ? .empty : .failed) : .ready
|
||||
}
|
||||
|
||||
func loadNextPageIfNeeded(current book: SearchBook) {
|
||||
guard book.id == books.last?.id,
|
||||
canLoadMore,
|
||||
!isLoadingMore,
|
||||
let kind = selectedKind else { return }
|
||||
isLoadingMore = true
|
||||
let nextPage = page + 1
|
||||
paginationTask = Task { [service, source] in
|
||||
if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
canLoadMore = false
|
||||
isLoadingMore = false
|
||||
return
|
||||
}
|
||||
let outcome = await service.explore(source: source, kind: kind, page: nextPage)
|
||||
guard !Task.isCancelled, selectedKind?.id == kind.id else {
|
||||
isLoadingMore = false
|
||||
return
|
||||
}
|
||||
let knownURLs = Set(books.map(\.bookURL))
|
||||
books.append(contentsOf: outcome.books.filter { !knownURLs.contains($0.bookURL) })
|
||||
diagnostics += outcome.diagnostics
|
||||
page = nextPage
|
||||
canLoadMore = !outcome.books.isEmpty
|
||||
isLoadingMore = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ExploreView: View {
|
||||
@EnvironmentObject private var sourceLibrary: SourceLibraryModel
|
||||
|
||||
private var sources: [BookSource] {
|
||||
sourceLibrary.sources
|
||||
.filter { $0.enabled && $0.enabledExplore && !($0.exploreUrl?.isEmpty ?? true) }
|
||||
.sorted {
|
||||
if $0.customOrder != $1.customOrder { return $0.customOrder < $1.customOrder }
|
||||
return $0.bookSourceName.localizedStandardCompare($1.bookSourceName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ReadingPageHeader(title: "发现", subtitle: "下一本好书,在这里遇见") {
|
||||
NavigationLink { SearchView() } label: {
|
||||
ReadingToolbarIcon(systemName: "magnifyingglass")
|
||||
}
|
||||
.accessibilityLabel("搜索")
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: "safari")
|
||||
.font(.system(size: 30, weight: .ultraLight))
|
||||
.accessibilityHidden(true)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("从喜欢的书源开始").font(.headline)
|
||||
Text("浏览分类与榜单,发现更多故事")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(22)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 20))
|
||||
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("分类与榜单").font(.title3.bold())
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
Spacer()
|
||||
Text("\(sources.count)个书源").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
if sources.isEmpty {
|
||||
ReadingUnavailableView {
|
||||
Label("暂无发现书源", systemImage: "books.vertical")
|
||||
} description: {
|
||||
Text("添加带有发现分类的书源,即可在这里浏览书籍。")
|
||||
} actions: {
|
||||
NavigationLink("管理书源") { SourceManagementView() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
} else {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(sources) { source in
|
||||
NavigationLink { ExploreSourceView(source: source) } label: {
|
||||
HStack(spacing: 12) {
|
||||
ExploreSourceRow(source: source)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption).foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if source.id != sources.last?.id {
|
||||
Rectangle().fill(ReadingTheme.separator).frame(height: 0.5)
|
||||
.padding(.leading, 58)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NavigationLink { SourceManagementView() } label: {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.font(.system(size: 20))
|
||||
.frame(width: 40, height: 44)
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("管理书源").font(.subheadline.weight(.semibold))
|
||||
Text("添加与整理阅读来源").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "arrow.up.right").font(.subheadline)
|
||||
}
|
||||
.foregroundStyle(ReadingTheme.ink)
|
||||
.padding(18)
|
||||
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
}
|
||||
.background(ReadingTheme.canvas)
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ExploreSourceRow: View {
|
||||
let source: BookSource
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: source.bookSourceType == 1 ? "headphones" : "books.vertical")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(ReadingTheme.accent)
|
||||
.frame(width: 44, height: 48)
|
||||
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 12))
|
||||
.accessibilityHidden(true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(source.bookSourceName)
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(2)
|
||||
Text(source.groups.isEmpty ? (URL(string: source.bookSourceUrl)?.host ?? source.bookSourceUrl) : source.groups.joined(separator: " · "))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 7)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
private struct ExploreSourceView: View {
|
||||
@StateObject private var model: ExploreSourceViewModel
|
||||
|
||||
init(source: BookSource) {
|
||||
_model = StateObject(wrappedValue: ExploreSourceViewModel(source: source))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
if model.kinds.count > 1 { kindPicker }
|
||||
content
|
||||
if model.isLoadingMore { ProgressView("继续加载…").padding(.vertical, 12) }
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.background(ReadingTheme.canvas)
|
||||
.navigationTitle(model.source.bookSourceName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task { await model.load() }
|
||||
.refreshable { await model.reload() }
|
||||
}
|
||||
|
||||
private var kindPicker: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(model.kinds) { kind in
|
||||
Button(kind.title) { Task { await model.select(kind: kind) } }
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minHeight: 44)
|
||||
.foregroundStyle(model.selectedKind?.id == kind.id ? Color.primary : .secondary)
|
||||
.overlay(alignment: .bottom) {
|
||||
if model.selectedKind?.id == kind.id {
|
||||
Rectangle().fill(Color.primary).frame(height: 2)
|
||||
}
|
||||
}
|
||||
.accessibilityAddTraits(model.selectedKind?.id == kind.id ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch model.state {
|
||||
case .idle, .loading:
|
||||
ProgressView("正在加载小说…")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 80)
|
||||
case .empty:
|
||||
ReadingUnavailableView(
|
||||
"暂无小说",
|
||||
systemImage: "books.vertical",
|
||||
description: Text("当前书源或分类暂时没有内容,可以下拉刷新。")
|
||||
)
|
||||
.padding(.vertical, 40)
|
||||
case .failed where model.books.isEmpty:
|
||||
ReadingUnavailableView(
|
||||
"加载失败",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text(model.diagnostics.first?.message ?? "书源发现规则解析失败。")
|
||||
)
|
||||
.padding(.vertical, 40)
|
||||
default:
|
||||
ForEach(model.books) { book in
|
||||
NavigationLink {
|
||||
BookDetailView(searchBook: book, source: model.source)
|
||||
} label: {
|
||||
ExploreBookRow(book: book)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onAppear { model.loadNextPageIfNeeded(current: book) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ExploreBookRow: View {
|
||||
let book: SearchBook
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 13) {
|
||||
BookCoverArtwork(title: book.name, author: book.author, remoteURL: book.coverURL, width: 52, height: 74)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(book.name)
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(ReadingTheme.ink)
|
||||
.lineLimit(2)
|
||||
Text(book.author.isEmpty ? "未知作者" : book.author)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
if let kind = book.kind, !kind.isEmpty {
|
||||
Text(kind)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let intro = book.intro, !intro.isEmpty {
|
||||
Text(intro)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle().fill(ReadingTheme.separator).frame(height: 0.5)
|
||||
.padding(.leading, 65)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RSSAddBookView: View {
|
||||
@EnvironmentObject private var books: BookLibraryModel
|
||||
@EnvironmentObject private var sources: SourceLibraryModel
|
||||
let bookURL: String
|
||||
@State private var resolved: AddBookURLResult?
|
||||
@State private var existing: (Book, BookSource)?
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let existing {
|
||||
BookDetailView(book: existing.0, source: existing.1)
|
||||
} else if let resolved {
|
||||
BookDetailView(book: resolved.book, source: resolved.source)
|
||||
} else if let errorMessage {
|
||||
ReadingUnavailableView("无法添加书籍", systemImage: "books.vertical.circle", description: Text(errorMessage))
|
||||
} else {
|
||||
VStack(spacing: 14) {
|
||||
ProgressView()
|
||||
Text("正在匹配书源并解析详情…").foregroundStyle(.secondary)
|
||||
Text(bookURL).font(.caption.monospaced()).foregroundStyle(.tertiary).lineLimit(3)
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
.navigationTitle("添加到书架")
|
||||
.task(id: bookURL) { await load() }
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
if let book = books.books.first(where: { $0.bookUrl == bookURL }),
|
||||
let source = sources.sources.first(where: { $0.bookSourceUrl == book.origin }) {
|
||||
existing = (book, source)
|
||||
return
|
||||
}
|
||||
do {
|
||||
resolved = try await AddBookURLService().resolve(bookURL: bookURL, sources: sources.sources)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class RSSLibraryModel: ObservableObject {
|
||||
@Published private(set) var sources: [RssSource] = []
|
||||
@Published private(set) var articles: [RssArticle] = []
|
||||
@Published private(set) var stars: [RssStar] = []
|
||||
@Published private(set) var records: [RssReadRecord] = []
|
||||
@Published private(set) var isWorking = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let sourceRepository: SQLiteRssSourceRepository
|
||||
private let articleRepository: SQLiteRssArticleRepository
|
||||
private let starRepository: SQLiteRssStarRepository
|
||||
private let recordRepository: SQLiteRssReadRecordRepository
|
||||
private let service: RSSService
|
||||
private let importer = RSSSourceImporter()
|
||||
private let database: LegadoDatabase
|
||||
private var nextURL: String?
|
||||
private var page = 1
|
||||
private var order: Int64 = 0
|
||||
|
||||
init(database: LegadoDatabase, service: RSSService = .init()) {
|
||||
self.database = database
|
||||
sourceRepository = .init(database: database)
|
||||
articleRepository = .init(database: database)
|
||||
starRepository = .init(database: database)
|
||||
recordRepository = .init(database: database)
|
||||
self.service = service
|
||||
}
|
||||
|
||||
static func live() -> RSSLibraryModel {
|
||||
let database = try! LegadoDatabase(url: LegadoDatabase.defaultURL())
|
||||
return .init(database: database)
|
||||
}
|
||||
|
||||
func load() async {
|
||||
do {
|
||||
async let loadedSources = sourceRepository.all()
|
||||
async let loadedStars = starRepository.all()
|
||||
async let loadedRecords = recordRepository.all()
|
||||
(sources, stars, records) = try await (loadedSources, loadedStars, loadedRecords)
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func importFiles(_ urls: [URL]) async {
|
||||
await perform {
|
||||
var merged = self.sources
|
||||
for url in urls {
|
||||
let granted = url.startAccessingSecurityScopedResource()
|
||||
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
||||
let payload = try self.importer.decode(Data(contentsOf: url))
|
||||
merged = try self.importer.merge(payload.sources, into: merged)
|
||||
for remote in payload.subscriptionURLs { merged = try await self.importRemote(remote, existing: merged) }
|
||||
}
|
||||
try await self.sourceRepository.save(merged)
|
||||
self.sources = try await self.sourceRepository.all()
|
||||
}
|
||||
}
|
||||
|
||||
func importText(_ text: String) async {
|
||||
await perform {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let url = URL(string: trimmed), ["http", "https"].contains(url.scheme?.lowercased() ?? "") {
|
||||
self.sources = try await self.importRemote(trimmed, existing: self.sources)
|
||||
} else {
|
||||
let payload = try self.importer.decode(Data(trimmed.utf8))
|
||||
var merged = try self.importer.merge(payload.sources, into: self.sources)
|
||||
for remote in payload.subscriptionURLs { merged = try await self.importRemote(remote, existing: merged) }
|
||||
self.sources = merged
|
||||
}
|
||||
try await self.sourceRepository.save(self.sources)
|
||||
}
|
||||
}
|
||||
|
||||
func applySilentSubscription(_ data: Data) async throws {
|
||||
let incoming = try importer.decode(data).sources
|
||||
var updated = sources
|
||||
for var source in incoming {
|
||||
if let index = updated.firstIndex(where: { $0.sourceUrl == source.sourceUrl }) {
|
||||
guard updated[index].lastUpdateTime < source.lastUpdateTime else { continue }
|
||||
source.sourceGroup = updated[index].sourceGroup
|
||||
updated[index] = source
|
||||
} else { updated.append(source) }
|
||||
}
|
||||
try await sourceRepository.save(updated)
|
||||
sources = try await sourceRepository.all()
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool, source: RssSource) async {
|
||||
do { try await sourceRepository.setEnabled(enabled, sourceURL: source.sourceUrl); sources = try await sourceRepository.all() }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
func remove(_ source: RssSource) async {
|
||||
do { try await sourceRepository.remove(sourceURL: source.sourceUrl); sources = try await sourceRepository.all() }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func moveSources(from offsets: IndexSet, to destination: Int) async {
|
||||
var values = sources
|
||||
values.move(fromOffsets: offsets, toOffset: destination)
|
||||
for index in values.indices { values[index].customOrder = index }
|
||||
do { try await sourceRepository.save(values); sources = try await sourceRepository.all() }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func save(_ value: RssSource, replacing originalURL: String?) async -> Bool {
|
||||
let name = value.sourceName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let url = value.sourceUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty, !url.isEmpty else { errorMessage = "源名称和URL不能为空"; return false }
|
||||
do {
|
||||
var source = value; source.sourceName = name; source.sourceUrl = url
|
||||
let original = originalURL.flatMap { key in sources.first { $0.sourceUrl == key } }
|
||||
let encoder = JSONEncoder()
|
||||
let oldData = try encoder.encode(original)
|
||||
let newData = try encoder.encode(source)
|
||||
if oldData != newData { source.lastUpdateTime = Int64(Date().timeIntervalSince1970 * 1_000) }
|
||||
if let originalURL, originalURL != source.sourceUrl {
|
||||
try await starRepository.updateOrigin(from: originalURL, to: source.sourceUrl)
|
||||
try await articleRepository.updateOrigin(from: originalURL, to: source.sourceUrl)
|
||||
try await database.deleteSourceVariables(originalURL)
|
||||
}
|
||||
var values = sources.filter { $0.sourceUrl != originalURL && $0.sourceUrl != source.sourceUrl }
|
||||
values.append(source)
|
||||
try await sourceRepository.save(values)
|
||||
sources = try await sourceRepository.all()
|
||||
return true
|
||||
} catch { errorMessage = error.localizedDescription; return false }
|
||||
}
|
||||
|
||||
func refresh(_ source: RssSource, sortName: String? = nil, sortURL: String? = nil, key: String? = nil) async {
|
||||
page = 1; order = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
let name = sortName ?? source.sourceName
|
||||
let url = sortURL ?? source.sortUrl?.nilIfBlank ?? source.sourceUrl
|
||||
await perform {
|
||||
let report = try await self.service.articles(sortName: name, sortURL: url, source: source, page: 1, key: key)
|
||||
var values = report.result.articles
|
||||
for index in values.indices { values[index].order = self.order; self.order -= 1 }
|
||||
try await self.articleRepository.put(values)
|
||||
if source.ruleNextPage?.isEmpty == false { try await self.articleRepository.clearOld(origin: source.sourceUrl, sort: name, before: self.order) }
|
||||
self.nextURL = report.result.nextURL
|
||||
self.articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
|
||||
}
|
||||
}
|
||||
|
||||
func loadMore(_ source: RssSource, sortName: String? = nil, key: String? = nil) async {
|
||||
guard let nextURL, !nextURL.isEmpty else { return }
|
||||
page += 1
|
||||
let name = sortName ?? source.sourceName
|
||||
await perform {
|
||||
let report = try await self.service.articles(sortName: name, sortURL: nextURL, source: source, page: self.page, key: key)
|
||||
var values = report.result.articles
|
||||
for index in values.indices { values[index].order = self.order; self.order -= 1 }
|
||||
try await self.articleRepository.append(values)
|
||||
self.nextURL = report.result.nextURL
|
||||
self.articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
|
||||
}
|
||||
}
|
||||
|
||||
func read(_ article: RssArticle) async {
|
||||
do {
|
||||
try await recordRepository.insertIgnoringExisting(article.record())
|
||||
records = try await recordRepository.all()
|
||||
if let index = articles.firstIndex(of: article) { articles[index].read = true }
|
||||
}
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
func readingMode(for article: RssArticle, source: RssSource) async -> RSSReadingMode {
|
||||
let policy = RSSReadingPolicy()
|
||||
if article.type == 0, article.description?.nilIfBlank != nil {
|
||||
return policy.mode(article: article, source: source)
|
||||
}
|
||||
guard let rule = source.ruleContent?.nilIfBlank else {
|
||||
return policy.mode(article: article, source: source)
|
||||
}
|
||||
do {
|
||||
let content = try await service.content(article: article, rule: rule, source: source)
|
||||
if article.type == 0 {
|
||||
var updated = article; updated.description = content
|
||||
try await articleRepository.put([updated])
|
||||
if var star = try await starRepository.star(origin: article.origin, link: article.link) {
|
||||
star.description = content; try await starRepository.save(star); stars = try await starRepository.all()
|
||||
}
|
||||
}
|
||||
return policy.mode(article: article, source: source, resolvedContent: content)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
if article.type == 0 { return .html(content: "加载正文失败\n\(error.localizedDescription)", baseURL: nil) }
|
||||
return policy.mode(article: article, source: source)
|
||||
}
|
||||
}
|
||||
func toggleStar(_ article: RssArticle) async {
|
||||
do {
|
||||
if try await starRepository.star(origin: article.origin, link: article.link) != nil {
|
||||
try await starRepository.remove(origin: article.origin, link: article.link)
|
||||
} else { try await starRepository.save(article.star()) }
|
||||
stars = try await starRepository.all()
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
func isStarred(_ article: RssArticle) -> Bool { stars.contains { $0.origin == article.origin && $0.link == article.link } }
|
||||
|
||||
func removeStar(_ value: RssStar) async {
|
||||
do { try await starRepository.remove(origin: value.origin, link: value.link); stars = try await starRepository.all() }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func saveStar(_ value: RssStar) async {
|
||||
do { try await starRepository.save(value); stars = try await starRepository.all() }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func clearStars() async {
|
||||
do { try await starRepository.clear(); stars = [] }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
func clearRecords() async {
|
||||
do { try await recordRepository.clear(); records = [] }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func importRemote(_ value: String, existing: [RssSource]) async throws -> [RssSource] {
|
||||
let withoutUA = value.hasSuffix("#requestWithoutUA")
|
||||
let raw = withoutUA ? String(value.dropLast("#requestWithoutUA".count)) : value
|
||||
guard let url = URL(string: raw) else { throw URLError(.badURL) }
|
||||
var request = URLRequest(url: url)
|
||||
if withoutUA { request.setValue("", forHTTPHeaderField: "User-Agent") }
|
||||
let (data, response) = try await URLSessionHTTPTransport.shared.download(for: request)
|
||||
guard (response as? HTTPURLResponse).map({ (200...299).contains($0.statusCode) }) != false else { throw URLError(.badServerResponse) }
|
||||
let payload = try importer.decode(data)
|
||||
return try importer.merge(payload.sources, into: existing)
|
||||
}
|
||||
private func perform(_ operation: () async throws -> Void) async {
|
||||
isWorking = true; defer { isWorking = false }
|
||||
do { try await operation() } catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nilIfBlank: String? { trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
private final class RSSSourceDebugModel: ObservableObject {
|
||||
@Published var input = ""
|
||||
@Published var running = false
|
||||
@Published var report: RSSSourceDebugReport?
|
||||
private let debugger = RSSSourceDebugger()
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
func run(_ source: RssSource) {
|
||||
task?.cancel(); running = true; report = nil
|
||||
task = Task { [debugger, input] in
|
||||
let result = await debugger.run(source: source, input: input)
|
||||
guard !Task.isCancelled else { return }
|
||||
report = result; running = false
|
||||
}
|
||||
}
|
||||
deinit { task?.cancel() }
|
||||
}
|
||||
|
||||
struct RSSSourceDebugView: View {
|
||||
let source: RssSource
|
||||
@StateObject private var model = RSSSourceDebugModel()
|
||||
@State private var rawTitle = ""
|
||||
@State private var rawText: String?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("调试输入") {
|
||||
Text(source.sourceName).font(.headline)
|
||||
TextField("关键词,或 分类::地址", text: $model.input)
|
||||
.textInputAutocapitalization(.never).disableAutocorrection(true)
|
||||
.onSubmit { model.run(source) }
|
||||
Button { model.run(source) } label: { Label(model.running ? "正在调试…" : "运行完整链路", systemImage: "play.fill") }
|
||||
.disabled(model.running)
|
||||
}
|
||||
if model.running { Section { HStack { ProgressView(); Text("正在请求并解析列表与正文") } } }
|
||||
if let report = model.report {
|
||||
Section(report.passed ? "调试通过" : "发现兼容问题") {
|
||||
ForEach(report.steps) { step in
|
||||
DisclosureGroup {
|
||||
if step.diagnostics.isEmpty { Text("无规则诊断").foregroundStyle(.secondary) }
|
||||
ForEach(step.diagnostics) { item in
|
||||
VStack(alignment: .leading) { Text(item.message); Text(item.code).font(.caption.monospaced()).foregroundStyle(.secondary) }
|
||||
}
|
||||
} label: {
|
||||
Label { VStack(alignment: .leading) { Text(step.stage.title); Text(step.summary).font(.caption).foregroundStyle(.secondary).lineLimit(2) } } icon: { Image(systemName: icon(step.status)).foregroundStyle(color(step.status)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if !report.articles.isEmpty {
|
||||
Section("文章样本") { ForEach(report.articles.prefix(10)) { Text($0.title) } }
|
||||
}
|
||||
if let preview = report.contentPreview { Section("正文样本") { Text(preview).textSelection(.enabled) } }
|
||||
}
|
||||
}
|
||||
.navigationTitle("RSS源调试")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu("查看源码", systemImage: "doc.plaintext") {
|
||||
Button("列表源码") { rawTitle = "列表源码"; rawText = model.report?.listSource }.disabled(model.report?.listSource == nil)
|
||||
Button("正文源码") { rawTitle = "正文源码"; rawText = model.report?.contentSource }.disabled(model.report?.contentSource == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: Binding(get: { rawText != nil }, set: { if !$0 { rawText = nil } })) {
|
||||
ReadingNavigationStack { ScrollView { Text(rawText ?? "").font(.caption.monospaced()).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading).padding() }.navigationTitle(rawTitle).toolbar { ToolbarItem(placement: .confirmationAction) { Button("完成") { rawText = nil } } } }
|
||||
}
|
||||
}
|
||||
|
||||
private func icon(_ status: SourceDebugStatus) -> String { switch status { case .passed: "checkmark.circle.fill"; case .warning: "exclamationmark.triangle.fill"; case .failed: "xmark.circle.fill"; case .skipped: "forward.end.circle" } }
|
||||
private func color(_ status: SourceDebugStatus) -> Color { switch status { case .passed: .green; case .warning: .orange; case .failed: .red; case .skipped: .secondary } }
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RSSSourceEditorView: View {
|
||||
@EnvironmentObject private var library: RSSLibraryModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var source: RssSource
|
||||
private let originalURL: String?
|
||||
|
||||
init(source: RssSource? = nil) {
|
||||
let value = source ?? RssSource()
|
||||
_source = State(initialValue: value)
|
||||
originalURL = source?.sourceUrl
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("基本") {
|
||||
TextField("名称", text: $source.sourceName)
|
||||
TextField("URL", text: $source.sourceUrl).textInputAutocapitalization(.never).keyboardType(.URL)
|
||||
requiredField("图标", \.sourceIcon)
|
||||
optionalField("分组", \.sourceGroup)
|
||||
optionalField("注释", \.sourceComment, axis: .vertical)
|
||||
Toggle("启用", isOn: $source.enabled)
|
||||
}
|
||||
Section("请求与登录") {
|
||||
optionalField("自定义变量说明", \.variableComment, axis: .vertical)
|
||||
optionalField("JS库", \.jsLib, axis: .vertical)
|
||||
Toggle("自动保存Cookie", isOn: optionalBool(\.enabledCookieJar, default: true))
|
||||
optionalField("并发率", \.concurrentRate)
|
||||
optionalField("请求头", \.header, axis: .vertical)
|
||||
optionalField("登录地址", \.loginUrl)
|
||||
optionalField("登录UI", \.loginUi, axis: .vertical)
|
||||
optionalField("登录检测JS", \.loginCheckJs, axis: .vertical)
|
||||
optionalField("封面解密JS", \.coverDecodeJs, axis: .vertical)
|
||||
}
|
||||
Section("分类与列表规则") {
|
||||
optionalField("分类URL(名称::URL)", \.sortUrl, axis: .vertical)
|
||||
Toggle("单URL源", isOn: $source.singleUrl)
|
||||
Picker("列表样式", selection: $source.articleStyle) { ForEach(0..<5, id: \.self) { Text("样式 \($0)").tag($0) } }
|
||||
optionalField("列表规则", \.ruleArticles, axis: .vertical)
|
||||
optionalField("下一页规则", \.ruleNextPage, axis: .vertical)
|
||||
optionalField("标题规则", \.ruleTitle, axis: .vertical)
|
||||
optionalField("发布日期规则", \.rulePubDate, axis: .vertical)
|
||||
optionalField("描述规则", \.ruleDescription, axis: .vertical)
|
||||
optionalField("图片规则", \.ruleImage, axis: .vertical)
|
||||
optionalField("链接规则", \.ruleLink, axis: .vertical)
|
||||
optionalField("正文规则", \.ruleContent, axis: .vertical)
|
||||
}
|
||||
Section("WebView规则") {
|
||||
optionalField("正文URL白名单", \.contentWhitelist, axis: .vertical)
|
||||
optionalField("正文URL黑名单", \.contentBlacklist, axis: .vertical)
|
||||
optionalField("跳转URL拦截JS", \.shouldOverrideUrlLoading, axis: .vertical)
|
||||
optionalField("样式", \.style, axis: .vertical)
|
||||
Toggle("启用JavaScript", isOn: $source.enableJs)
|
||||
Toggle("使用Base URL加载", isOn: $source.loadWithBaseUrl)
|
||||
optionalField("注入JS", \.injectJs, axis: .vertical)
|
||||
optionalField("预注入JS", \.preloadJs, axis: .vertical)
|
||||
optionalField("起始HTML", \.startHtml, axis: .vertical)
|
||||
optionalField("起始样式", \.startStyle, axis: .vertical)
|
||||
optionalField("起始JS", \.startJs, axis: .vertical)
|
||||
Toggle("输出网页日志", isOn: $source.showWebLog)
|
||||
}
|
||||
Section("其他") {
|
||||
Stepper("自定义排序:\(source.customOrder)", value: $source.customOrder)
|
||||
Picker("类型", selection: $source.type) { Text("网页").tag(0); Text("图片").tag(1); Text("视频").tag(2) }
|
||||
Toggle("启用预加载", isOn: $source.preload)
|
||||
Toggle("优先加载缓存", isOn: $source.cacheFirst)
|
||||
optionalField("搜索URL", \.searchUrl, axis: .vertical)
|
||||
}
|
||||
}
|
||||
.navigationTitle(originalURL == nil ? "新建RSS源" : "编辑RSS源")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("保存") { Task { if await library.save(source, replacing: originalURL) { dismiss() } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func optionalField(_ title: String, _ keyPath: WritableKeyPath<RssSource, String?>, axis: Axis = .horizontal) -> some View {
|
||||
ReadingTextField(title, text: optionalText(keyPath), axis: axis).textInputAutocapitalization(.never)
|
||||
}
|
||||
@ViewBuilder private func requiredField(_ title: String, _ keyPath: WritableKeyPath<RssSource, String>, axis: Axis = .horizontal) -> some View {
|
||||
ReadingTextField(title, text: Binding(get: { source[keyPath: keyPath] }, set: { source[keyPath: keyPath] = $0 }), axis: axis).textInputAutocapitalization(.never)
|
||||
}
|
||||
private func optionalText(_ keyPath: WritableKeyPath<RssSource, String?>) -> Binding<String> {
|
||||
Binding(get: { source[keyPath: keyPath] ?? "" }, set: { source[keyPath: keyPath] = $0.isEmpty ? nil : $0 })
|
||||
}
|
||||
private func optionalBool(_ keyPath: WritableKeyPath<RssSource, Bool?>, default defaultValue: Bool) -> Binding<Bool> {
|
||||
Binding(get: { source[keyPath: keyPath] ?? defaultValue }, set: { source[keyPath: keyPath] = $0 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
import Photos
|
||||
|
||||
struct RSSHomeView: View {
|
||||
@EnvironmentObject private var library: RSSLibraryModel
|
||||
@State private var section = 0
|
||||
@State private var searchText = ""
|
||||
@State private var showsFiles = false
|
||||
@State private var showsTextImport = false
|
||||
@State private var showsExport = false
|
||||
@State private var confirmsClear = false
|
||||
@State private var editingStar: RssStar?
|
||||
|
||||
var body: some View {
|
||||
let content = AnyView(VStack(spacing: 0) {
|
||||
Picker("RSS页面", selection: $section) {
|
||||
Text("订阅").tag(0); Text("收藏").tag(1); Text("记录").tag(2)
|
||||
}
|
||||
.pickerStyle(.segmented).padding()
|
||||
selectedList
|
||||
})
|
||||
let navigation = AnyView(content
|
||||
.navigationTitle("RSS")
|
||||
.searchable(text: $searchText, prompt: section == 0 ? "搜索订阅源" : "搜索文章"))
|
||||
let actions = AnyView(navigation.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu("RSS操作", systemImage: "ellipsis.circle") {
|
||||
NavigationLink { RSSSourceEditorView() } label: { Label("新建RSS源", systemImage: "plus") }
|
||||
NavigationLink { RuleSubscriptionView() } label: { Label("规则订阅", systemImage: "link") }
|
||||
Button("导入文件", systemImage: "doc.badge.plus") { showsFiles = true }
|
||||
Button("URL或粘贴JSON", systemImage: "link.badge.plus") { showsTextImport = true }
|
||||
Button("导出全部RSS源", systemImage: "square.and.arrow.up") { showsExport = true }
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) { if section == 0 { EditButton() } }
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
if section == 1, !library.stars.isEmpty { Button("清空收藏", systemImage: "trash", role: .destructive) { confirmsClear = true } }
|
||||
else if section == 2, !library.records.isEmpty { Button("清空记录", systemImage: "trash", role: .destructive) { confirmsClear = true } }
|
||||
}
|
||||
})
|
||||
let importing = AnyView(actions.fileImporter(isPresented: $showsFiles, allowedContentTypes: [.json, .text], allowsMultipleSelection: true) { result in
|
||||
if case .success(let urls) = result { Task { await library.importFiles(urls) } }
|
||||
if case .failure(let error) = result { library.errorMessage = error.localizedDescription }
|
||||
})
|
||||
let sheets = AnyView(importing
|
||||
.sheet(isPresented: $showsTextImport) { RSSImportSheet() }
|
||||
.sheet(item: $editingStar) { value in
|
||||
RSSStarEditSheet(star: value, save: { updated in
|
||||
Task { await library.saveStar(updated) }
|
||||
})
|
||||
})
|
||||
let exporting = AnyView(sheets.fileExporter(isPresented: $showsExport, document: RSSExportDocument(sources: library.sources), contentType: UTType.json, defaultFilename: "rssSources") { result in
|
||||
if case .failure(let error) = result { library.errorMessage = error.localizedDescription }
|
||||
})
|
||||
let status = AnyView(exporting
|
||||
.overlay { if library.isWorking { ProgressView().controlSize(.large) } }
|
||||
.alert("RSS操作失败", isPresented: Binding(get: { library.errorMessage != nil }, set: { if !$0 { library.errorMessage = nil } })) {
|
||||
Button("好") { library.errorMessage = nil }
|
||||
} message: { Text(library.errorMessage ?? "") })
|
||||
return status.confirmationDialog(section == 1 ? "确定清空全部RSS收藏?" : "确定清空全部RSS阅读记录?", isPresented: $confirmsClear, titleVisibility: Visibility.visible) {
|
||||
Button("清空", role: .destructive) { Task { if section == 1 { await library.clearStars() } else { await library.clearRecords() } } }
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var selectedList: some View {
|
||||
switch section {
|
||||
case 1: starList
|
||||
case 2: recordList
|
||||
default: sourceList
|
||||
}
|
||||
}
|
||||
|
||||
private var sourceList: some View {
|
||||
List {
|
||||
if filteredSources.isEmpty {
|
||||
ReadingUnavailableView("还没有RSS源", systemImage: "dot.radiowaves.left.and.right", description: Text("从文件、URL或剪贴板导入 RSS 源。"))
|
||||
}
|
||||
ForEach(filteredSources) { source in
|
||||
NavigationLink { RSSSourceDestinationView(source: source) } label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack { Text(source.sourceName).font(.headline); Spacer(); if !source.enabled { Text("停用").font(.caption).foregroundStyle(.secondary) } }
|
||||
Text(source.sourceUrl).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
if let group = source.sourceGroup, !group.isEmpty { Text(group).font(.caption2).foregroundStyle(ReadingTheme.accent) }
|
||||
}.padding(.vertical, 3)
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button(source.enabled ? "停用" : "启用") { Task { await library.setEnabled(!source.enabled, source: source) } }.tint(.orange)
|
||||
}
|
||||
.swipeActions {
|
||||
Button("删除", role: .destructive) { Task { await library.remove(source) } }
|
||||
}
|
||||
.contextMenu {
|
||||
NavigationLink { RSSSourceEditorView(source: source) } label: { Label("编辑", systemImage: "pencil") }
|
||||
}
|
||||
.moveDisabled(!searchText.isEmpty)
|
||||
}
|
||||
.onMove { offsets, destination in Task { await library.moveSources(from: offsets, to: destination) } }
|
||||
}.listStyle(.plain)
|
||||
}
|
||||
|
||||
private var starList: some View {
|
||||
List {
|
||||
ForEach(filteredStars) { star in
|
||||
Group {
|
||||
if let source = library.sources.first(where: { $0.sourceUrl == star.origin }) {
|
||||
NavigationLink { RSSArticleDetailView(article: star.article, source: source) } label: { RSSArticleRow(article: star.article) }
|
||||
} else { RSSArticleRow(article: star.article) }
|
||||
}.swipeActions(edge: .leading) { Button("编辑", systemImage: "pencil") { editingStar = star }.tint(.blue) }
|
||||
}.onDelete { offsets in
|
||||
let values = offsets.map { filteredStars[$0] }
|
||||
Task { for value in values { await library.removeStar(value) } }
|
||||
}
|
||||
}.overlay { if filteredStars.isEmpty { ReadingUnavailableView("没有收藏", systemImage: "star") } }.listStyle(.plain)
|
||||
}
|
||||
private var recordList: some View {
|
||||
List(filteredRecords) { record in
|
||||
if let source = library.sources.first(where: { $0.sourceUrl == record.origin }) {
|
||||
NavigationLink { RSSArticleDetailView(article: record.article, source: source) } label: { RSSArticleRow(article: record.article) }
|
||||
} else { RSSArticleRow(article: record.article) }
|
||||
}.overlay { if filteredRecords.isEmpty { ReadingUnavailableView("没有阅读记录", systemImage: "clock") } }.listStyle(.plain)
|
||||
}
|
||||
private var filteredSources: [RssSource] { library.sources.filter { searchText.isEmpty || $0.sourceName.localizedCaseInsensitiveContains(searchText) || $0.sourceUrl.localizedCaseInsensitiveContains(searchText) || ($0.sourceGroup ?? "").localizedCaseInsensitiveContains(searchText) } }
|
||||
private var filteredStars: [RssStar] { library.stars.filter { searchText.isEmpty || $0.title.localizedCaseInsensitiveContains(searchText) } }
|
||||
private var filteredRecords: [RssReadRecord] { library.records.filter { searchText.isEmpty || ($0.title ?? "").localizedCaseInsensitiveContains(searchText) } }
|
||||
}
|
||||
|
||||
private struct RSSArticleListView: View {
|
||||
@EnvironmentObject private var library: RSSLibraryModel
|
||||
let source: RssSource
|
||||
@State private var searchKey = ""
|
||||
@State private var sorts: [RSSSort] = []
|
||||
@State private var selectedSort = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if sorts.count > 1 {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack { ForEach(Array(sorts.enumerated()), id: \.element.id) { index, sort in
|
||||
Button(sort.name.isEmpty ? "默认" : sort.name) { selectedSort = index; Task { await refresh() } }
|
||||
.buttonStyle(.bordered).tint(selectedSort == index ? ReadingTheme.accent : .secondary)
|
||||
}}.padding(.horizontal)
|
||||
}.padding(.vertical, 6)
|
||||
}
|
||||
articleCollection
|
||||
}
|
||||
.overlay { if library.articles.isEmpty, !library.isWorking { ReadingUnavailableView("没有文章", systemImage: "newspaper") } }
|
||||
.navigationTitle(source.sourceName)
|
||||
.searchable(text: $searchKey, prompt: "源内搜索")
|
||||
.refreshable { await refresh() }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) { Button("刷新", systemImage: "arrow.clockwise") { Task { await refresh() } } }
|
||||
ToolbarItem(placement: .primaryAction) { NavigationLink { RSSSourceEditorView(source: source) } label: { Label("编辑", systemImage: "pencil") } }
|
||||
ToolbarItem(placement: .primaryAction) { NavigationLink { RSSSourceDebugView(source: source) } label: { Label("调试", systemImage: "stethoscope") } }
|
||||
ToolbarItem(placement: .bottomBar) { Button("加载下一页", systemImage: "arrow.down") { Task { await library.loadMore(source, sortName: activeName, key: searchKey.nilIfEmpty) } } }
|
||||
}
|
||||
.task { sorts = await RSSSortResolver().resolve(source); await refresh() }
|
||||
}
|
||||
|
||||
private var activeSort: RSSSort { sorts.indices.contains(selectedSort) ? sorts[selectedSort] : .init(name: "", url: source.sourceUrl) }
|
||||
private var activeName: String { searchKey.nilIfEmpty == nil ? activeSort.name : "搜索" }
|
||||
private func refresh() async {
|
||||
let url = searchKey.nilIfEmpty == nil ? activeSort.url : (source.searchUrl?.nilIfEmpty ?? activeSort.url)
|
||||
await library.refresh(source, sortName: activeName, sortURL: url, key: searchKey.nilIfEmpty)
|
||||
}
|
||||
|
||||
@ViewBuilder private var articleCollection: some View {
|
||||
if source.articleStyle == 0 {
|
||||
List(library.articles) { article in
|
||||
NavigationLink { RSSArticleDetailView(article: article, source: source) } label: { RSSArticleRow(article: article) }
|
||||
}.listStyle(.plain)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: articleColumns, spacing: source.articleStyle == 3 ? 20 : 8) {
|
||||
ForEach(library.articles) { article in
|
||||
NavigationLink { RSSArticleDetailView(article: article, source: source) } label: {
|
||||
RSSArticleCard(article: article, style: source.articleStyle)
|
||||
}.buttonStyle(.plain)
|
||||
}
|
||||
}.padding(.horizontal, source.articleStyle == 3 ? 20 : 8).padding(.bottom, 20)
|
||||
}.refreshable { await refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
private var articleColumns: [GridItem] {
|
||||
let count = source.articleStyle == 1 ? 1 : (source.articleStyle == 4 ? 3 : 2)
|
||||
return Array(repeating: GridItem(.flexible(), spacing: source.articleStyle == 3 ? 20 : 8, alignment: .top), count: count)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RSSSourceDestinationView: View {
|
||||
let source: RssSource
|
||||
var body: some View {
|
||||
if source.singleUrl {
|
||||
RSSStandaloneReaderView(source: source, mode: .web(url: source.sourceUrl))
|
||||
} else if let mode = try? RSSStartPageResolver().resolve(source) {
|
||||
RSSStandaloneReaderView(source: source, mode: mode)
|
||||
} else {
|
||||
RSSArticleListView(source: source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RSSStandaloneReaderView: View {
|
||||
let source: RssSource
|
||||
let mode: RSSReadingMode
|
||||
@State private var route: RSSNativeRoute?
|
||||
var body: some View {
|
||||
RSSWebReaderView(mode: mode, source: source, onAction: handle)
|
||||
.navigationTitle(source.sourceName).navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(item: $route) { route in
|
||||
switch route {
|
||||
case .photo(let value): RSSPhotoView(value: value, source: source)
|
||||
case .search(let value): ReadingNavigationStack { SearchView(initialKeyword: value) }
|
||||
case .rss(let article): ReadingNavigationStack { RSSArticleDetailView(article: article, source: source) }
|
||||
case .login: ReadingNavigationStack { SourceLoginView(source: source.bookSourceAdapter) }
|
||||
case .sort(let value): ReadingNavigationStack { RSSArticleListView(source: value) }
|
||||
case .addBook(let value): ReadingNavigationStack { RSSAddBookView(bookURL: value) }
|
||||
case .download(let url, let fileName): ReadingNavigationStack { RSSDownloadView(url: url, fileName: fileName, source: source) }
|
||||
case .start(let mode): ReadingNavigationStack { RSSStandaloneReaderView(source: source, mode: mode) }
|
||||
}
|
||||
}
|
||||
}
|
||||
private func handle(_ action: RSSWebAction) {
|
||||
switch action {
|
||||
case .showPhoto(let value): route = .photo(value)
|
||||
case .searchBook(let key, _): route = .search(key)
|
||||
case .addBook(let value): route = .addBook(value)
|
||||
case .download(let url, let fileName): route = .download(url: url, fileName: fileName)
|
||||
case .open(let name, let url, let title, _):
|
||||
switch name.lowercased() {
|
||||
case "login": route = .login
|
||||
case "search": if let value = title ?? url { route = .search(value) }
|
||||
case "sort": var value = source; value.sortUrl = url; route = .sort(value)
|
||||
case "rss":
|
||||
if let url { route = .rss(.init(origin: source.sourceUrl, sort: "", title: title ?? source.sourceName, link: url)) }
|
||||
else if source.singleUrl { route = .start(.web(url: source.sourceUrl)) }
|
||||
else if let mode = try? RSSStartPageResolver().resolve(source) { route = .start(mode) }
|
||||
else { route = .sort(source) }
|
||||
default: if let url, let target = URL(string: url) { UIApplication.shared.open(target) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RSSArticleRow: View {
|
||||
let article: RssArticle
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(article.title).font(.headline).foregroundStyle(article.read ? .secondary : .primary)
|
||||
if let description = article.description, !description.isEmpty { Text(description).font(.caption).foregroundStyle(.secondary).lineLimit(2) }
|
||||
if let date = article.pubDate, !date.isEmpty { Text(date).font(.caption2).foregroundStyle(.tertiary) }
|
||||
}
|
||||
Spacer()
|
||||
if article.type == 1 { Image(systemName: "photo") }
|
||||
else if article.type == 2 { Image(systemName: "play.rectangle") }
|
||||
}.padding(.vertical, 3)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RSSArticleCard: View {
|
||||
let article: RssArticle
|
||||
let style: Int
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
articleImage
|
||||
Text(article.title)
|
||||
.font(style == 1 || style == 3 ? .subheadline.bold() : .caption)
|
||||
.foregroundStyle(article.read ? .secondary : .primary)
|
||||
.lineLimit(style == 3 ? 9 : 2)
|
||||
.padding(.horizontal, style == 1 ? 12 : 6).padding(.top, 10)
|
||||
if let date = article.pubDate, !date.isEmpty {
|
||||
Text(date).font(.caption2).foregroundStyle(.secondary).lineLimit(style == 3 ? 39 : 1)
|
||||
.padding(.horizontal, style == 1 ? 12 : 6).padding(.top, 8).padding(.bottom, 10)
|
||||
} else { Spacer().frame(height: 10) }
|
||||
}
|
||||
.background(style == 3 ? Color(.secondarySystemBackground) : .clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: style == 3 ? 12 : 0, style: .continuous))
|
||||
.overlay { if style == 3 { RoundedRectangle(cornerRadius: 12).stroke(.primary.opacity(0.08)) } }
|
||||
}
|
||||
|
||||
@ViewBuilder private var articleImage: some View {
|
||||
let height = imageHeight
|
||||
if let raw = article.image, let url = URL(string: raw) {
|
||||
AsyncImage(url: url) { phase in
|
||||
if let image = phase.image { image.resizable().scaledToFill() }
|
||||
else if phase.error != nil { placeholder }
|
||||
else { ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) }
|
||||
}.frame(maxWidth: .infinity).frame(height: height).clipped()
|
||||
} else { placeholder.frame(height: height) }
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
ZStack { Color(.tertiarySystemFill); Image(systemName: article.type == 2 ? "play.rectangle" : "photo").foregroundStyle(.secondary) }
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var imageHeight: CGFloat {
|
||||
switch style {
|
||||
case 1: return 220
|
||||
case 2: return 272
|
||||
case 4: return 182
|
||||
default: return 180
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RSSArticleDetailView: View {
|
||||
@EnvironmentObject private var library: RSSLibraryModel
|
||||
let article: RssArticle
|
||||
let source: RssSource
|
||||
@State private var mode: RSSReadingMode?
|
||||
@State private var speechText = ""
|
||||
@State private var reloadID = UUID()
|
||||
@StateObject private var speech = RSSSpeechController()
|
||||
@State private var nativeRoute: RSSNativeRoute?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let mode {
|
||||
RSSWebReaderView(mode: mode, source: source, onTextChange: { speechText = $0 }, onAction: handleWebAction)
|
||||
.id(reloadID)
|
||||
} else {
|
||||
ProgressView("正在加载…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.navigationTitle("文章")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(library.isStarred(article) ? "取消收藏" : "收藏", systemImage: library.isStarred(article) ? "star.fill" : "star") { Task { await library.toggleStar(article) } }
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
if let url = URL(string: article.link) { ReadingShareLink(item: url) }
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu("阅读操作", systemImage: "ellipsis.circle") {
|
||||
Button(speech.isSpeaking ? "停止朗读" : "朗读正文", systemImage: speech.isSpeaking ? "stop.fill" : "speaker.wave.2.fill") {
|
||||
speech.isSpeaking ? speech.stop() : speech.speak(speechText)
|
||||
}.disabled(speechText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
Button("刷新", systemImage: "arrow.clockwise") { reloadID = UUID() }
|
||||
if let url = URL(string: article.link) { Link("浏览器打开", destination: url) }
|
||||
if source.loginUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
NavigationLink { SourceLoginView(source: source.bookSourceAdapter) } label: { Label("源登录", systemImage: "person.badge.key") }
|
||||
}
|
||||
NavigationLink { RSSSourceEditorView(source: source) } label: { Label("编辑源", systemImage: "pencil") }
|
||||
NavigationLink { RSSSourceDebugView(source: source) } label: { Label("调试源", systemImage: "stethoscope") }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: article.link) {
|
||||
await library.read(article)
|
||||
mode = await library.readingMode(for: article, source: source)
|
||||
}
|
||||
.sheet(item: $nativeRoute) { route in
|
||||
switch route {
|
||||
case .photo(let value): RSSPhotoView(value: value, source: source)
|
||||
case .search(let key): ReadingNavigationStack { SearchView(initialKeyword: key) }
|
||||
case .rss(let target): ReadingNavigationStack { RSSArticleDetailView(article: target, source: source) }
|
||||
case .login: ReadingNavigationStack { SourceLoginView(source: source.bookSourceAdapter) }
|
||||
case .sort(let target): ReadingNavigationStack { RSSArticleListView(source: target) }
|
||||
case .addBook(let value): ReadingNavigationStack { RSSAddBookView(bookURL: value) }
|
||||
case .download(let url, let fileName): ReadingNavigationStack { RSSDownloadView(url: url, fileName: fileName, source: source) }
|
||||
case .start(let value): ReadingNavigationStack { RSSStandaloneReaderView(source: source, mode: value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleWebAction(_ action: RSSWebAction) {
|
||||
switch action {
|
||||
case .showPhoto(let value): nativeRoute = .photo(value)
|
||||
case .searchBook(let key, _): nativeRoute = .search(key)
|
||||
case .addBook(let value): nativeRoute = .addBook(value)
|
||||
case .open(let name, let url, let title, _):
|
||||
switch name.lowercased() {
|
||||
case "search": if let key = title ?? url { nativeRoute = .search(key) }
|
||||
case "login": nativeRoute = .login
|
||||
case "sort":
|
||||
var target = source
|
||||
if let url {
|
||||
if let title, let data = try? JSONSerialization.data(withJSONObject: [title: url]) { target.sortUrl = String(decoding: data, as: UTF8.self) }
|
||||
else { target.sortUrl = url }
|
||||
}
|
||||
nativeRoute = .sort(target)
|
||||
case "rss":
|
||||
if let url { nativeRoute = .rss(.init(origin: source.sourceUrl, sort: "", title: title ?? source.sourceName, link: url)) }
|
||||
else if source.singleUrl { nativeRoute = .start(.web(url: source.sourceUrl)) }
|
||||
else if let mode = try? RSSStartPageResolver().resolve(source) { nativeRoute = .start(mode) }
|
||||
else { nativeRoute = .sort(source) }
|
||||
default: if let url, let target = URL(string: url) { UIApplication.shared.open(target) }
|
||||
}
|
||||
case .download(let url, let fileName): nativeRoute = .download(url: url, fileName: fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum RSSNativeRoute: Identifiable {
|
||||
case photo(String), search(String), rss(RssArticle), login, sort(RssSource), addBook(String), download(url: String, fileName: String), start(RSSReadingMode)
|
||||
var id: String {
|
||||
switch self {
|
||||
case .photo(let value): return "photo:\(value)"
|
||||
case .search(let value): return "search:\(value)"
|
||||
case .rss(let value): return "rss:\(value.id)"
|
||||
case .login: return "login"
|
||||
case .sort(let value): return "sort:\(value.sourceUrl):\(value.sortUrl ?? "")"
|
||||
case .addBook(let value): return "addBook:\(value)"
|
||||
case .download(let url, _): return "download:\(url)"
|
||||
case .start(let mode): return "start:\(String(describing: mode))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RSSDownloadView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let url: String
|
||||
let fileName: String
|
||||
let source: RssSource
|
||||
@State private var data = Data()
|
||||
@State private var loading = true
|
||||
@State private var showsExporter = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
if loading { ProgressView("正在下载…") }
|
||||
else if let errorMessage { ReadingUnavailableView("下载失败", systemImage: "exclamationmark.arrow.triangle.2.circlepath", description: Text(errorMessage)) }
|
||||
else {
|
||||
Image(systemName: "doc.badge.arrow.up").font(.system(size: 48)).foregroundStyle(ReadingTheme.accent)
|
||||
Text(fileName).font(.headline)
|
||||
Text(ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)).foregroundStyle(.secondary)
|
||||
Button("选择保存位置", systemImage: "folder") { showsExporter = true }.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}.padding().navigationTitle("文件下载").toolbar { ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } } }
|
||||
.task(id: url) { await download() }
|
||||
.fileExporter(isPresented: $showsExporter, document: RSSBinaryDocument(data: data), contentType: .data, defaultFilename: fileName) { result in if case .failure(let error) = result { errorMessage = error.localizedDescription } }
|
||||
}
|
||||
|
||||
private func download() async {
|
||||
do {
|
||||
guard let target = URL(string: url) else { throw URLError(.badURL) }
|
||||
var request = URLRequest(url: target)
|
||||
if let headers = try? BookSourceHeaderResolver().resolve(source.bookSourceAdapter) { for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } }
|
||||
let cookie = LegadoCookieStore.shared.cookieHeader(for: url)
|
||||
if !cookie.isEmpty { request.setValue(cookie, forHTTPHeaderField: "Cookie") }
|
||||
let (value, response) = try await URLSessionHTTPTransport.shared.download(for: request)
|
||||
if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) { throw URLError(.badServerResponse) }
|
||||
data = value; loading = false; showsExporter = true
|
||||
} catch { errorMessage = error.localizedDescription; loading = false }
|
||||
}
|
||||
}
|
||||
|
||||
private struct RSSBinaryDocument: FileDocument {
|
||||
static var readableContentTypes: [UTType] { [.data] }
|
||||
let data: Data
|
||||
init(data: Data) { self.data = data }
|
||||
init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() }
|
||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { .init(regularFileWithContents: data) }
|
||||
}
|
||||
|
||||
private struct RSSPhotoView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let value: String
|
||||
let source: RssSource
|
||||
@State private var image: UIImage?
|
||||
@State private var loading = true
|
||||
@State private var message: String?
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
GeometryReader { proxy in
|
||||
ScrollView([.horizontal, .vertical]) {
|
||||
Group {
|
||||
if let image { Image(uiImage: image).resizable().scaledToFit() }
|
||||
else if loading { ProgressView() }
|
||||
else { ReadingUnavailableView("图片加载失败", systemImage: "photo.badge.exclamationmark") }
|
||||
}.frame(minWidth: proxy.size.width, minHeight: proxy.size.height)
|
||||
}.background(.black)
|
||||
}
|
||||
.navigationTitle(source.sourceName).navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) { Button("保存到照片", systemImage: "square.and.arrow.down") { save() }.disabled(image == nil) }
|
||||
ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } }
|
||||
}
|
||||
.task(id: value) { await load() }
|
||||
.alert("图片", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) { Button("好") { message = nil } } message: { Text(message ?? "") }
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
do {
|
||||
let data: Data
|
||||
if value.lowercased().hasPrefix("data:"), let comma = value.firstIndex(of: ",") {
|
||||
let metadata = value[..<comma].lowercased()
|
||||
let payload = String(value[value.index(after: comma)...])
|
||||
data = metadata.contains(";base64") ? (Data(base64Encoded: payload) ?? Data()) : Data((payload.removingPercentEncoding ?? payload).utf8)
|
||||
} else {
|
||||
guard let url = URL(string: value) else { throw URLError(.badURL) }
|
||||
var request = URLRequest(url: url)
|
||||
if let headers = try? BookSourceHeaderResolver().resolve(source.bookSourceAdapter) { for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } }
|
||||
let cookie = LegadoCookieStore.shared.cookieHeader(for: value)
|
||||
if !cookie.isEmpty { request.setValue(cookie, forHTTPHeaderField: "Cookie") }
|
||||
(data, _) = try await URLSessionHTTPTransport.shared.download(for: request)
|
||||
}
|
||||
guard let decoded = UIImage(data: data) else { throw URLError(.cannotDecodeContentData) }
|
||||
image = decoded; loading = false
|
||||
} catch { loading = false; message = "加载图片失败:\(error.localizedDescription)" }
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard let image else { return }
|
||||
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
|
||||
guard status == .authorized || status == .limited else { DispatchQueue.main.async { message = "没有照片写入权限" }; return }
|
||||
PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.creationRequestForAsset(from: image) } completionHandler: { success, error in
|
||||
DispatchQueue.main.async { message = success ? "保存成功" : "保存失败:\(error?.localizedDescription ?? "未知错误")" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class RSSSpeechController: NSObject, ObservableObject, AVSpeechSynthesizerDelegate {
|
||||
@Published private(set) var isSpeaking = false
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
|
||||
override init() { super.init(); synthesizer.delegate = self }
|
||||
func speak(_ text: String) {
|
||||
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { return }
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
let utterance = AVSpeechUtterance(string: value)
|
||||
utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN")
|
||||
synthesizer.speak(utterance)
|
||||
isSpeaking = true
|
||||
}
|
||||
func stop() { synthesizer.stopSpeaking(at: .immediate); isSpeaking = false }
|
||||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { Task { @MainActor in self.isSpeaking = false } }
|
||||
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { Task { @MainActor in self.isSpeaking = false } }
|
||||
}
|
||||
|
||||
private struct RSSImportSheet: View {
|
||||
@EnvironmentObject private var library: RSSLibraryModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var text = ""
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
TextEditor(text: $text).font(.system(.body, design: .monospaced)).padding()
|
||||
.navigationTitle("导入RSS源")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) { Button("导入") { Task { await library.importText(text); dismiss() } }.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RSSExportDocument: FileDocument {
|
||||
static var readableContentTypes: [UTType] { [.json] }
|
||||
let data: Data
|
||||
init(sources: [RssSource]) {
|
||||
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
data = (try? encoder.encode(sources)) ?? Data("[]".utf8)
|
||||
}
|
||||
init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() }
|
||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) }
|
||||
}
|
||||
|
||||
private struct RSSStarEditSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var value: RssStar
|
||||
let save: (RssStar) -> Void
|
||||
init(star: RssStar, save: @escaping (RssStar) -> Void) { _value = State(initialValue: star); self.save = save }
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
Form {
|
||||
TextField("标题", text: $value.title)
|
||||
TextField("分组", text: $value.group)
|
||||
ReadingLabeledContent("链接", value: value.link).textSelection(.enabled)
|
||||
}.navigationTitle("编辑RSS收藏").toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) { Button("保存") { save(value); dismiss() }.disabled(value.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import SwiftUI
|
||||
@preconcurrency import WebKit
|
||||
import UIKit
|
||||
|
||||
enum RSSWebAction {
|
||||
case showPhoto(String)
|
||||
case searchBook(key: String, scope: String?)
|
||||
case addBook(String)
|
||||
case open(name: String, url: String?, title: String?, origin: String?)
|
||||
case download(url: String, fileName: String)
|
||||
}
|
||||
|
||||
struct RSSWebReaderView: UIViewRepresentable {
|
||||
let mode: RSSReadingMode
|
||||
let source: RssSource
|
||||
let onTextChange: (String) -> Void
|
||||
let onAction: (RSSWebAction) -> Void
|
||||
|
||||
init(mode: RSSReadingMode, source: RssSource, onTextChange: @escaping (String) -> Void = { _ in }, onAction: @escaping (RSSWebAction) -> Void = { _ in }) {
|
||||
self.mode = mode
|
||||
self.source = source
|
||||
self.onTextChange = onTextChange
|
||||
self.onAction = onAction
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(source: source, onTextChange: onTextChange, onAction: onAction) }
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = .default()
|
||||
configuration.allowsInlineMediaPlayback = true
|
||||
configuration.mediaTypesRequiringUserActionForPlayback = []
|
||||
configuration.defaultWebpagePreferences.allowsContentJavaScript = source.enableJs
|
||||
let controller = configuration.userContentController
|
||||
controller.add(context.coordinator, name: "legado")
|
||||
controller.addUserScript(.init(source: context.coordinator.bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false))
|
||||
if let preload = source.preloadJs?.trimmedNonEmpty {
|
||||
controller.addUserScript(.init(source: preload, injectionTime: .atDocumentStart, forMainFrameOnly: false))
|
||||
}
|
||||
if let inject = source.injectJs?.trimmedNonEmpty {
|
||||
controller.addUserScript(.init(source: inject, injectionTime: .atDocumentEnd, forMainFrameOnly: false))
|
||||
}
|
||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
webView.navigationDelegate = context.coordinator
|
||||
webView.uiDelegate = context.coordinator
|
||||
webView.allowsBackForwardNavigationGestures = true
|
||||
webView.scrollView.contentInsetAdjustmentBehavior = .automatic
|
||||
context.coordinator.installRulesAndLoad(mode: mode, in: webView)
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
context.coordinator.update(source: source)
|
||||
context.coordinator.onTextChange = onTextChange
|
||||
context.coordinator.onAction = onAction
|
||||
context.coordinator.loadIfChanged(mode: mode, in: webView)
|
||||
}
|
||||
|
||||
static func dismantleUIView(_ webView: WKWebView, coordinator: Coordinator) {
|
||||
webView.stopLoading()
|
||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "legado")
|
||||
webView.navigationDelegate = nil
|
||||
webView.uiDelegate = nil
|
||||
}
|
||||
|
||||
@MainActor final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler {
|
||||
private var source: RssSource
|
||||
var onTextChange: (String) -> Void
|
||||
var onAction: (RSSWebAction) -> Void
|
||||
private var signature = ""
|
||||
private var rulesInstalled = false
|
||||
private let htmlProcessor = RSSHTMLProcessor()
|
||||
private var analyzeContent: JSONValue = .string("")
|
||||
private var analyzeBaseURL = ""
|
||||
private var analyzeRedirectURL: String?
|
||||
private var analyzeVariables: [String: String] = [:]
|
||||
|
||||
init(source: RssSource, onTextChange: @escaping (String) -> Void, onAction: @escaping (RSSWebAction) -> Void) { self.source = source; self.onTextChange = onTextChange; self.onAction = onAction }
|
||||
func update(source: RssSource) { self.source = source }
|
||||
|
||||
var bridgeScript: String {
|
||||
let sourceJSON = (try? JSONEncoder().encode(source)).map { String(decoding: $0, as: UTF8.self) } ?? "{}"
|
||||
return """
|
||||
(() => {
|
||||
const native = (action, payload) => window.webkit.messageHandlers.legado.postMessage(Object.assign({action}, payload || {}));
|
||||
const get = key => window.prompt('__legado_get__', String(key)) || '';
|
||||
const put = (key, value) => { value = String(value ?? ''); native('put', {key:String(key), value}); return value; };
|
||||
const ruleCall = payload => {
|
||||
const result = window.prompt('__legado_rule__', JSON.stringify(payload));
|
||||
if (result == null || result === '') return null;
|
||||
try { return JSON.parse(result); } catch (_) { return result; }
|
||||
};
|
||||
const analyzer = {
|
||||
setContent(content, baseUrl) { if (content == null) throw new Error('内容不可空(Content cannot be null)'); ruleCall({op:'setContent', content, baseUrl:baseUrl == null ? null : String(baseUrl)}); return analyzer; },
|
||||
setBaseUrl(baseUrl) { ruleCall({op:'setBaseUrl', baseUrl:baseUrl == null ? null : String(baseUrl)}); return analyzer; },
|
||||
setRedirectUrl(url) { return ruleCall({op:'setRedirectUrl', url:String(url)}); },
|
||||
getStringList(rule, content, isUrl) { return ruleCall({op:'getStringList', rule:rule == null ? null : String(rule), content:content === undefined ? null : content, hasContent:content !== undefined, isUrl:!!isUrl}); },
|
||||
getString(rule, content, isUrl) { return ruleCall({op:'getString', rule:rule == null ? null : String(rule), content:content === undefined ? null : content, hasContent:content !== undefined && typeof content !== 'boolean', unescape:typeof content === 'boolean' ? content : false, isUrl:!!isUrl}); },
|
||||
getElement(rule) { return ruleCall({op:'getElement', rule:String(rule)}); },
|
||||
getElements(rule) { return ruleCall({op:'getElements', rule:String(rule)}); }
|
||||
};
|
||||
window.java = window.java || {};
|
||||
java.get = get; java.put = put;
|
||||
java.getSource = () => window.source; java.getTag = () => \(Self.jsString(source.sourceName));
|
||||
java.showPhoto = src => native('showPhoto', {url:String(src)});
|
||||
java.addBook = url => native('addBook', {url:String(url)});
|
||||
java.searchBook = (key, scope) => native('searchBook', {key:String(key), scope:scope == null ? null : String(scope)});
|
||||
java.open = (name, url, title, origin) => native('open', {name:String(name), url:url == null ? null : String(url), title:title == null ? null : String(title), origin:origin == null ? null : String(origin)});
|
||||
java.setContent = analyzer.setContent; java.setBaseUrl = analyzer.setBaseUrl; java.setRedirectUrl = analyzer.setRedirectUrl;
|
||||
java.getString = analyzer.getString; java.getStringList = analyzer.getStringList; java.getElement = analyzer.getElement; java.getElements = analyzer.getElements;
|
||||
window.source = Object.assign(\(sourceJSON), {
|
||||
getKey: () => \(Self.jsString(source.sourceUrl)),
|
||||
getTag: () => \(Self.jsString(source.sourceName)),
|
||||
get, put
|
||||
});
|
||||
document.addEventListener('contextmenu', event => {
|
||||
const image = event.target && event.target.closest ? event.target.closest('img') : null;
|
||||
if (!image || !image.src) return;
|
||||
event.preventDefault();
|
||||
native('showPhoto', {url:String(image.currentSrc || image.src)});
|
||||
}, true);
|
||||
})();
|
||||
"""
|
||||
}
|
||||
|
||||
func installRulesAndLoad(mode: RSSReadingMode, in webView: WKWebView) {
|
||||
let policy = RSSResourcePolicy(blacklist: source.contentBlacklist, whitelist: source.contentWhitelist)
|
||||
guard let json = policy.contentBlockerJSON() else {
|
||||
rulesInstalled = true; loadIfChanged(mode: mode, in: webView); return
|
||||
}
|
||||
WKContentRuleListStore.default().compileContentRuleList(forIdentifier: "rss-\(source.sourceUrl.hashValue)", encodedContentRuleList: json) { [weak self, weak webView] list, _ in
|
||||
DispatchQueue.main.async {
|
||||
guard let self, let webView else { return }
|
||||
if let list { webView.configuration.userContentController.add(list) }
|
||||
self.rulesInstalled = true
|
||||
self.loadIfChanged(mode: mode, in: webView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadIfChanged(mode: RSSReadingMode, in webView: WKWebView) {
|
||||
guard rulesInstalled else { return }
|
||||
let newSignature = String(describing: mode)
|
||||
guard signature != newSignature else { return }
|
||||
signature = newSignature
|
||||
switch mode {
|
||||
case .html(let content, let baseURL):
|
||||
webView.loadHTMLString(htmlProcessor.contentHTML(content, style: source.style), baseURL: baseURL.flatMap(URL.init(string:)))
|
||||
case .startHTML(let content, let baseURL):
|
||||
webView.loadHTMLString(htmlProcessor.startHTML(content, source: source), baseURL: baseURL.flatMap(URL.init(string:)))
|
||||
case .web(let value): load(value, in: webView)
|
||||
case .image(let value):
|
||||
let escaped = Self.htmlEscape(value)
|
||||
webView.loadHTMLString(htmlProcessor.contentHTML("<body><img src=\"\(escaped)\"></body>", style: source.style), baseURL: URL(string: value))
|
||||
case .video(let value):
|
||||
let escaped = Self.htmlEscape(value)
|
||||
webView.loadHTMLString(htmlProcessor.contentHTML("<body><video src=\"\(escaped)\" controls autoplay playsinline></video></body>", style: source.style), baseURL: URL(string: value))
|
||||
}
|
||||
}
|
||||
|
||||
private func load(_ value: String, in webView: WKWebView) {
|
||||
guard let url = URL(string: value) else { return }
|
||||
var request = URLRequest(url: url, cachePolicy: source.cacheFirst ? .returnCacheDataElseLoad : .useProtocolCachePolicy, timeoutInterval: 180)
|
||||
if let headers = try? BookSourceHeaderResolver().resolve(source.bookSourceAdapter) {
|
||||
for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) }
|
||||
webView.customUserAgent = headers.first { $0.key.caseInsensitiveCompare("User-Agent") == .orderedSame }?.value
|
||||
}
|
||||
if webView.customUserAgent == nil { webView.customUserAgent = ApplicationSettingsStore.shared.settings.effectiveUserAgent }
|
||||
request = ApplicationSettingsStore.shared.settings.configure(request)
|
||||
let cookie = LegadoCookieStore.shared.cookieHeader(for: value)
|
||||
if !cookie.isEmpty { request.setValue(cookie, forHTTPHeaderField: "Cookie") }
|
||||
webView.load(request)
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
guard let body = message.body as? [String: Any], let action = body["action"] as? String else { return }
|
||||
switch action {
|
||||
case "put":
|
||||
if let key = body["key"] as? String { BookSourceRuntimeStore.shared.put(sourceID: source.sourceUrl, key: key, value: String(describing: body["value"] ?? "")) }
|
||||
case "showPhoto":
|
||||
if let raw = body["url"] as? String { DispatchQueue.main.async { self.onAction(.showPhoto(raw)) } }
|
||||
case "searchBook":
|
||||
if let key = body["key"] as? String { DispatchQueue.main.async { self.onAction(.searchBook(key: key, scope: body["scope"] as? String)) } }
|
||||
case "addBook":
|
||||
if let raw = body["url"] as? String { DispatchQueue.main.async { self.onAction(.addBook(raw)) } }
|
||||
case "open":
|
||||
let name = body["name"] as? String ?? ""
|
||||
DispatchQueue.main.async { self.onAction(.open(name: name, url: body["url"] as? String, title: body["title"] as? String, origin: body["origin"] as? String)) }
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping @MainActor @Sendable (String?) -> Void) {
|
||||
if prompt == "__legado_get__" {
|
||||
completionHandler(BookSourceRuntimeStore.shared.get(sourceID: source.sourceUrl, key: defaultText ?? ""))
|
||||
} else if prompt == "__legado_rule__" {
|
||||
handleRulePrompt(defaultText, completionHandler: completionHandler)
|
||||
} else { completionHandler(defaultText) }
|
||||
}
|
||||
|
||||
private func handleRulePrompt(_ payload: String?, completionHandler: @escaping @MainActor @Sendable (String?) -> Void) {
|
||||
guard let payload, let data = payload.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let operation = object["op"] as? String else { completionHandler("null"); return }
|
||||
switch operation {
|
||||
case "setContent":
|
||||
analyzeContent = JSONValue.bridge(object["content"])
|
||||
if let base = object["baseUrl"] as? String, !base.isEmpty { analyzeBaseURL = base }
|
||||
completionHandler("true")
|
||||
case "setBaseUrl":
|
||||
if let base = object["baseUrl"] as? String, !base.isEmpty { analyzeBaseURL = base }
|
||||
completionHandler("true")
|
||||
case "setRedirectUrl":
|
||||
let value = object["url"] as? String ?? ""
|
||||
if !value.lowercased().hasPrefix("data:"), URL(string: value) != nil { analyzeRedirectURL = value }
|
||||
completionHandler(Self.jsonString(analyzeRedirectURL))
|
||||
default:
|
||||
let rule = object["rule"] as? String ?? ""
|
||||
guard !rule.isEmpty else { completionHandler(operation == "getStringList" || operation == "getElements" ? "null" : "\"\""); return }
|
||||
let input = (object["hasContent"] as? Bool) == true ? JSONValue.bridge(object["content"]) : analyzeContent
|
||||
let baseURL = analyzeRedirectURL ?? analyzeBaseURL
|
||||
let context = RuleExecutionContext(variables: analyzeVariables, baseURL: baseURL, source: source.bookSourceAdapter)
|
||||
Task {
|
||||
let evaluation: RuleEvaluation
|
||||
switch input {
|
||||
case .string(let text):
|
||||
evaluation = await RuleEngine().evaluate(rule, inText: text, baseURL: baseURL, listMode: operation == "getStringList" || operation == "getElements", context: context)
|
||||
default:
|
||||
evaluation = await RuleEngine().evaluate(rule, in: input, context: context)
|
||||
}
|
||||
self.analyzeVariables = evaluation.variables
|
||||
let isURL = object["isUrl"] as? Bool == true
|
||||
let values = evaluation.values.map { value -> JSONValue in
|
||||
guard isURL else { return value }
|
||||
let raw = value.stringValue
|
||||
return .string(URL(string: raw, relativeTo: URL(string: baseURL))?.absoluteURL.absoluteString ?? raw)
|
||||
}
|
||||
let response: Any
|
||||
switch operation {
|
||||
case "getStringList": response = values.map(\.stringValue)
|
||||
case "getElement": response = values.first?.foundationObject ?? NSNull()
|
||||
case "getElements": response = values.map(\.foundationObject)
|
||||
default:
|
||||
var value = values.map(\.stringValue).joined(separator: "\n")
|
||||
if object["unescape"] as? Bool == true { value = Self.unescapeHTML(value) }
|
||||
response = value
|
||||
}
|
||||
completionHandler(Self.json(response))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) {
|
||||
guard let url = navigationAction.request.url else { decisionHandler(.cancel); return }
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
if !["http", "https", "file", "data", "about"].contains(scheme) {
|
||||
UIApplication.shared.open(url); decisionHandler(.cancel); return
|
||||
}
|
||||
if navigationAction.navigationType == .linkActivated, shouldOverride(url.absoluteString) {
|
||||
decisionHandler(.cancel); return
|
||||
}
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping @MainActor @Sendable (WKNavigationResponsePolicy) -> Void) {
|
||||
guard let response = navigationResponse.response as? HTTPURLResponse, let url = response.url else { decisionHandler(.allow); return }
|
||||
let disposition = response.value(forHTTPHeaderField: "Content-Disposition")?.lowercased() ?? ""
|
||||
if disposition.contains("attachment") || !navigationResponse.canShowMIMEType {
|
||||
let name = response.suggestedFilename ?? url.lastPathComponent.nilIfBlank ?? "download"
|
||||
DispatchQueue.main.async { self.onAction(.download(url: url.absoluteString, fileName: name)) }
|
||||
decisionHandler(.cancel)
|
||||
} else { decisionHandler(.allow) }
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
webView.evaluateJavaScript("document.body ? document.body.innerText : ''") { [weak self] value, _ in
|
||||
guard let text = value as? String else { return }
|
||||
DispatchQueue.main.async { self?.onTextChange(text) }
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldOverride(_ url: String) -> Bool {
|
||||
guard let script = source.shouldOverrideUrlLoading?.trimmedNonEmpty else { return false }
|
||||
let quoted = Self.jsString(url)
|
||||
let expression = "(function(url){return (\(script));})(\(quoted))"
|
||||
let body = "(function(url){\(script)})(\(quoted))"
|
||||
let evaluator = JavaScriptRuleEvaluator()
|
||||
let context = RuleExecutionContext(variables: ["url": url], baseURL: url, source: source.bookSourceAdapter)
|
||||
let result = (try? evaluator.evaluate(script: expression, result: .string(url), executionContext: context))
|
||||
?? (try? evaluator.evaluate(script: body, result: .string(url), executionContext: context))
|
||||
let value = result?.value.stringValue.lowercased() ?? ""
|
||||
return ["true", "1", "yes"].contains(value)
|
||||
}
|
||||
|
||||
private static func jsString(_ value: String) -> String {
|
||||
let data = try? JSONEncoder().encode(value)
|
||||
return data.map { String(decoding: $0, as: UTF8.self) } ?? "\"\""
|
||||
}
|
||||
private static func htmlEscape(_ value: String) -> String {
|
||||
value.replacingOccurrences(of: "&", with: "&").replacingOccurrences(of: "\"", with: """).replacingOccurrences(of: "<", with: "<")
|
||||
}
|
||||
private static func json(_ value: Any) -> String {
|
||||
guard JSONSerialization.isValidJSONObject([value]), let data = try? JSONSerialization.data(withJSONObject: value, options: .fragmentsAllowed) else { return "null" }
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
private static func jsonString(_ value: String?) -> String { value.map { json($0) } ?? "null" }
|
||||
private static func unescapeHTML(_ value: String) -> String {
|
||||
guard let data = "<span>\(value)</span>".data(using: .utf8), let attributed = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil) else { return value }
|
||||
return attributed.string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var trimmedNonEmpty: String? {
|
||||
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
var nilIfBlank: String? { trimmedNonEmpty }
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import CoreText
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
private struct ReaderPositionPreferenceKey: PreferenceKey {
|
||||
static let defaultValue: [Int: CGFloat] = [:]
|
||||
static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
|
||||
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
|
||||
}
|
||||
}
|
||||
|
||||
struct ReaderStylePreset: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let background: UIColor
|
||||
let foreground: UIColor
|
||||
|
||||
static let legadoDefaults: [ReaderStylePreset] = [
|
||||
.init(id: "wechat", name: "微信读书", background: UIColor(hex: 0xC0EDC6), foreground: UIColor(hex: 0x0B0B0B)),
|
||||
.init(id: "preset1", name: "预设1", background: UIColor(hex: 0xFFFFFF), foreground: UIColor(hex: 0x000000)),
|
||||
.init(id: "preset2", name: "预设2", background: UIColor(hex: 0xDDC090), foreground: UIColor(hex: 0x3E3422)),
|
||||
.init(id: "preset3", name: "预设3", background: UIColor(hex: 0xC2D8AA), foreground: UIColor(hex: 0x596C44)),
|
||||
.init(id: "preset4", name: "预设4", background: UIColor(hex: 0xDBB8E2), foreground: UIColor(hex: 0x68516C)),
|
||||
.init(id: "preset5", name: "预设5", background: UIColor(hex: 0xABCEE0), foreground: UIColor(hex: 0x3D4C54)),
|
||||
.init(id: "night", name: "夜间", background: UIColor(hex: 0x000000), foreground: UIColor(hex: 0xADADAD))
|
||||
]
|
||||
static let eInk = ReaderStylePreset(
|
||||
id: "eink",
|
||||
name: "E-Ink",
|
||||
background: UIColor(hex: 0xFFFFFF),
|
||||
foreground: UIColor(hex: 0x000000)
|
||||
)
|
||||
|
||||
static func resolve(_ id: String) -> ReaderStylePreset {
|
||||
let migratedID = switch id {
|
||||
case "dark": "night"
|
||||
case "light": "preset1"
|
||||
case "paper": "preset2"
|
||||
default: id
|
||||
}
|
||||
return legadoDefaults.first(where: { $0.id == migratedID }) ?? legadoDefaults[0]
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
convenience init(hex: UInt32) {
|
||||
self.init(
|
||||
red: CGFloat((hex >> 16) & 0xff) / 255,
|
||||
green: CGFloat((hex >> 8) & 0xff) / 255,
|
||||
blue: CGFloat(hex & 0xff) / 255,
|
||||
alpha: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum ReaderBackgroundStore {
|
||||
static var url: URL {
|
||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Legado/reader-background.jpg")
|
||||
}
|
||||
|
||||
static func load() -> UIImage? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return UIImage(data: data)
|
||||
}
|
||||
|
||||
static func save(_ data: Data) throws -> UIImage {
|
||||
guard let image = UIImage(data: data), let encoded = image.jpegData(compressionQuality: 0.9) else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try encoded.write(to: url, options: .atomic)
|
||||
return image
|
||||
}
|
||||
|
||||
static func remove() { try? FileManager.default.removeItem(at: url) }
|
||||
}
|
||||
|
||||
enum ReaderFontStore {
|
||||
static var directory: URL {
|
||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Legado/fonts", isDirectory: true)
|
||||
}
|
||||
|
||||
static func install(from source: URL) throws -> String {
|
||||
let accessing = source.startAccessingSecurityScopedResource()
|
||||
defer { if accessing { source.stopAccessingSecurityScopedResource() } }
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let target = directory.appendingPathComponent(source.lastPathComponent)
|
||||
if target != source {
|
||||
try? FileManager.default.removeItem(at: target)
|
||||
try FileManager.default.copyItem(at: source, to: target)
|
||||
}
|
||||
guard font(at: target.path, size: 20, weight: 0) != nil else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
return target.path
|
||||
}
|
||||
|
||||
static func font(at path: String, size: CGFloat, weight: Int) -> UIFont? {
|
||||
guard !path.isEmpty,
|
||||
let provider = CGDataProvider(url: URL(fileURLWithPath: path) as CFURL),
|
||||
let graphicsFont = CGFont(provider) else { return nil }
|
||||
CTFontManagerRegisterGraphicsFont(graphicsFont, nil)
|
||||
guard let postScriptName = graphicsFont.postScriptName else { return nil }
|
||||
let name = postScriptName as String
|
||||
guard let base = UIFont(name: name, size: size) else { return nil }
|
||||
if weight == 1,
|
||||
let descriptor = base.fontDescriptor.withSymbolicTraits(.traitBold) {
|
||||
return UIFont(descriptor: descriptor, size: size)
|
||||
}
|
||||
if weight == 2 {
|
||||
let descriptor = base.fontDescriptor.addingAttributes([
|
||||
.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue]
|
||||
])
|
||||
return UIFont(descriptor: descriptor, size: size)
|
||||
}
|
||||
return base
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct ReaderScrollView: UIViewRepresentable {
|
||||
let configuration: ReaderTextView
|
||||
let chapters: [ReaderScrollChapter]
|
||||
let resetID: Int
|
||||
let activeIndex: Int
|
||||
let loadingAhead: Bool
|
||||
let loadingBehind: Bool
|
||||
let onNeedChapters: (Int) -> Void
|
||||
let onPositionChanged: (Int, Int) -> Void
|
||||
let onRetainChapters: (Set<Int>) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
|
||||
|
||||
func makeUIView(context: Context) -> ReaderScrollCollectionView {
|
||||
context.coordinator.makeView()
|
||||
}
|
||||
|
||||
func updateUIView(_ view: ReaderScrollCollectionView, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
context.coordinator.update(view)
|
||||
}
|
||||
|
||||
static func dismantleUIView(_ view: ReaderScrollCollectionView, coordinator: Coordinator) {
|
||||
coordinator.displayLink?.invalidate()
|
||||
view.viewportChanged = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegate,
|
||||
UIGestureRecognizerDelegate {
|
||||
var parent: ReaderScrollView
|
||||
private(set) var blocks: [ReaderScrollBlock] = []
|
||||
private var chapterLayouts: [Int: ReaderScrollChapterLayout] = [:]
|
||||
private weak var view: ReaderScrollCollectionView?
|
||||
var displayLink: CADisplayLink?
|
||||
private var lastTimestamp: CFTimeInterval = 0
|
||||
private var styleKey = ""
|
||||
private var viewportHeight: CGFloat = 0
|
||||
private var revision: Int?
|
||||
private var restored = false
|
||||
private var jumpKey: String?
|
||||
private var reporting = false
|
||||
private var applying = false
|
||||
private var requestedAhead: Int?
|
||||
private var requestedBehind: Int?
|
||||
private var wasLoadingAhead = false
|
||||
private var wasLoadingBehind = false
|
||||
private var lastPageKey = ""
|
||||
private var reachedEnd = false
|
||||
#if DEBUG
|
||||
private var decelerationInsertions = 0
|
||||
private var continuedDecelerations = 0
|
||||
private var interruptedDecelerations = 0
|
||||
private var awaitingDecelerationOffset: CGFloat?
|
||||
#endif
|
||||
|
||||
init(parent: ReaderScrollView) { self.parent = parent }
|
||||
|
||||
func makeView() -> ReaderScrollCollectionView {
|
||||
let collection = ReaderScrollCollectionView(frame: .zero, collectionViewLayout: ReaderContinuousLayout())
|
||||
collection.backgroundColor = parent.configuration.backgroundColor
|
||||
collection.contentInsetAdjustmentBehavior = .never
|
||||
collection.alwaysBounceVertical = true
|
||||
collection.showsVerticalScrollIndicator = false
|
||||
collection.decelerationRate = .normal
|
||||
collection.dataSource = self
|
||||
collection.delegate = self
|
||||
collection.accessibilityIdentifier = "reader.continuous"
|
||||
collection.register(ReaderScrollCell.self, forCellWithReuseIdentifier: "text")
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
|
||||
tap.delegate = self
|
||||
collection.addGestureRecognizer(tap)
|
||||
collection.viewportChanged = { [weak self, weak collection] in
|
||||
guard let self, let collection else { return }
|
||||
self.update(collection)
|
||||
}
|
||||
let link = CADisplayLink(target: self, selector: #selector(step(_:)))
|
||||
link.add(to: .main, forMode: .common)
|
||||
displayLink = link
|
||||
view = collection
|
||||
return collection
|
||||
}
|
||||
|
||||
func update(_ view: ReaderScrollCollectionView) {
|
||||
guard !applying, view.bounds.width > 0, view.bounds.height > 0 else { return }
|
||||
applying = true
|
||||
defer { applying = false }
|
||||
let config = parent.configuration
|
||||
if wasLoadingAhead, !parent.loadingAhead { requestedAhead = nil }
|
||||
if wasLoadingBehind, !parent.loadingBehind { requestedBehind = nil }
|
||||
wasLoadingAhead = parent.loadingAhead
|
||||
wasLoadingBehind = parent.loadingBehind
|
||||
view.backgroundColor = config.backgroundColor
|
||||
view.loadingLabel.textColor = config.foregroundColor.withAlphaComponent(0.6)
|
||||
view.loadingLabel.text = parent.chapters.isEmpty ? "正在加载正文…"
|
||||
: (parent.loadingAhead || parent.loadingBehind ? "正在加载后续章节…" : nil)
|
||||
view.positionLoadingLabel()
|
||||
let reset = revision != parent.resetID
|
||||
if reset {
|
||||
revision = parent.resetID
|
||||
restored = false
|
||||
jumpKey = nil
|
||||
requestedAhead = nil
|
||||
requestedBehind = nil
|
||||
lastPageKey = ""
|
||||
chapterLayouts = [:]
|
||||
}
|
||||
let anchor = reset ? nil : captureAnchor()
|
||||
let newStyleKey = config.scrollTypographyKey + "|\(view.bounds.width)"
|
||||
let styleChanged = styleKey != newStyleKey
|
||||
let contentChanged = styleChanged || viewportHeight != view.bounds.height || parent.chapters.count != chapterLayouts.count
|
||||
|| parent.chapters.contains { chapterLayouts[$0.id]?.chapter != $0 }
|
||||
if contentChanged || reset {
|
||||
let oldIDs = blocks.map(\.id)
|
||||
var layouts: [Int: ReaderScrollChapterLayout] = [:]
|
||||
for chapter in parent.chapters {
|
||||
if !styleChanged, let cached = chapterLayouts[chapter.id], cached.chapter == chapter {
|
||||
layouts[chapter.id] = cached
|
||||
} else {
|
||||
layouts[chapter.id] = ReaderScrollChapterLayout(
|
||||
chapter: chapter, configuration: config, width: view.bounds.width
|
||||
)
|
||||
}
|
||||
}
|
||||
let newBlocks = parent.chapters.flatMap { layouts[$0.id]?.blocks ?? [] }
|
||||
let layout = view.collectionViewLayout as! ReaderContinuousLayout
|
||||
let geometry = ReaderContinuousLayout.geometry(
|
||||
blocks: newBlocks, width: view.bounds.width,
|
||||
top: CGFloat(config.paddingTop), bottom: CGFloat(config.paddingBottom), viewportHeight: view.bounds.height
|
||||
)
|
||||
let targetY = anchor.flatMap { offset(for: $0, blocks: newBlocks, frames: geometry.frames) }
|
||||
let correction = targetY.map { $0 - view.contentOffset.y } ?? 0
|
||||
let newIDs = newBlocks.map(\.id)
|
||||
let oldSet = Set(oldIDs), newSet = Set(newIDs)
|
||||
let deletions = oldIDs.indices.filter { !newSet.contains(oldIDs[$0]) }.map { IndexPath(item: $0, section: 0) }
|
||||
let insertions = newIDs.indices.filter { !oldSet.contains(newIDs[$0]) }.map { IndexPath(item: $0, section: 0) }
|
||||
if !deletions.isEmpty { requestedAhead = nil; requestedBehind = nil }
|
||||
chapterLayouts = layouts
|
||||
styleKey = newStyleKey
|
||||
viewportHeight = view.bounds.height
|
||||
#if DEBUG
|
||||
let wasDecelerating = view.isDecelerating
|
||||
#endif
|
||||
UIView.performWithoutAnimation {
|
||||
view.performBatchUpdates {
|
||||
self.blocks = newBlocks
|
||||
view.deleteItems(at: deletions)
|
||||
view.insertItems(at: insertions)
|
||||
// UIKit applies the geometry and anchor compensation together.
|
||||
// Setting contentOffset here would cancel an in-flight deceleration.
|
||||
layout.install(frames: geometry.frames, size: geometry.size, correction: correction)
|
||||
}
|
||||
view.layoutIfNeeded()
|
||||
}
|
||||
#if DEBUG
|
||||
if wasDecelerating, !insertions.isEmpty {
|
||||
decelerationInsertions += 1
|
||||
if view.isDecelerating { awaitingDecelerationOffset = view.contentOffset.y }
|
||||
else { interruptedDecelerations += 1 }
|
||||
}
|
||||
#endif
|
||||
for cell in view.visibleCells {
|
||||
guard let cell = cell as? ReaderScrollCell,
|
||||
let index = view.indexPath(for: cell)?.item, blocks.indices.contains(index) else { continue }
|
||||
cell.canvas.block = blocks[index]
|
||||
}
|
||||
reachedEnd = false
|
||||
}
|
||||
if !restored, let chapter = chapterLayouts[parent.activeIndex] {
|
||||
restored = true
|
||||
seek(chapter: chapter.chapter.id, position: config.requestedPosition, includeHeading: config.requestedPosition == 0)
|
||||
}
|
||||
if let position = config.jumpPosition {
|
||||
let key = "\(parent.activeIndex):\(position)"
|
||||
if jumpKey != key {
|
||||
jumpKey = key
|
||||
seek(chapter: parent.activeIndex, position: position, includeHeading: position == 0)
|
||||
}
|
||||
} else { jumpKey = nil }
|
||||
scheduleReport()
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { blocks.count }
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "text", for: indexPath) as! ReaderScrollCell
|
||||
cell.canvas.block = blocks[indexPath.item]
|
||||
return cell
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard !applying else { return }
|
||||
#if DEBUG
|
||||
if let offset = awaitingDecelerationOffset, scrollView.isDecelerating,
|
||||
abs(scrollView.contentOffset.y - offset) > 0.5 {
|
||||
continuedDecelerations += 1
|
||||
awaitingDecelerationOffset = nil
|
||||
}
|
||||
#endif
|
||||
view?.positionLoadingLabel()
|
||||
scheduleReport()
|
||||
}
|
||||
|
||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
if !decelerate { scheduleReport() }
|
||||
}
|
||||
|
||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { scheduleReport() }
|
||||
|
||||
private func scheduleReport() {
|
||||
guard !reporting else { return }
|
||||
reporting = true
|
||||
let scheduledRevision = parent.resetID
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.reporting = false
|
||||
guard self.parent.resetID == scheduledRevision else { self.scheduleReport(); return }
|
||||
self.reportViewport()
|
||||
}
|
||||
}
|
||||
|
||||
private func reportViewport() {
|
||||
guard let view, restored, let anchor = captureAnchor(),
|
||||
let chapter = chapterLayouts[anchor.chapter] else { return }
|
||||
parent.onPositionChanged(anchor.chapter, anchor.position)
|
||||
let config = parent.configuration
|
||||
let chapterRows = blocks.indices.filter { blocks[$0].chapter.chapter.id == anchor.chapter }
|
||||
let layout = view.collectionViewLayout as! ReaderContinuousLayout
|
||||
if let first = chapterRows.first, let last = chapterRows.last {
|
||||
let start = layout.frames[first].minY, end = layout.frames[last].maxY
|
||||
let height = max(view.bounds.height, 1)
|
||||
let total = max(Int(ceil((end - start) / height)), 1)
|
||||
let page = min(max(Int(floor((view.contentOffset.y - start) / height)) + 1, 1), total)
|
||||
if config.currentPage != page { config.currentPage = page }
|
||||
if config.totalPages != total { config.totalPages = total }
|
||||
let ranges = chapter.chapter.content.isEmpty ? [] : [NSRange(location: 0, length: chapter.chapter.content.utf16.count)]
|
||||
if config.pageRanges != ranges { config.pageRanges = ranges }
|
||||
let key = "\(anchor.chapter):\(page)"
|
||||
if lastPageKey != key { lastPageKey = key; config.onPageChanged() }
|
||||
}
|
||||
let runway = view.bounds.height * 3
|
||||
if let last = parent.chapters.last, layout.collectionViewContentSize.height - view.bounds.maxY < runway,
|
||||
requestedAhead != last.id {
|
||||
requestedAhead = last.id
|
||||
parent.onNeedChapters(1)
|
||||
}
|
||||
if let first = parent.chapters.first, view.contentOffset.y < runway, requestedBehind != first.id {
|
||||
requestedBehind = first.id
|
||||
parent.onNeedChapters(-1)
|
||||
}
|
||||
if !view.isDragging, !view.isDecelerating, parent.chapters.count > 12 {
|
||||
let retainedRect = view.bounds.insetBy(dx: 0, dy: -view.bounds.height * 6)
|
||||
let visible = blocks.indices.filter { layout.frames[$0].intersects(retainedRect) }
|
||||
let indices = Set(visible.map { blocks[$0].chapter.chapter.id })
|
||||
let ordered = parent.chapters.map(\.id)
|
||||
if let lo = ordered.firstIndex(where: { indices.contains($0) }),
|
||||
let hi = ordered.lastIndex(where: { indices.contains($0) }) {
|
||||
let keep = Set(ordered[max(lo - 2, 0)...min(hi + 2, ordered.count - 1)])
|
||||
if keep.count < ordered.count { parent.onRetainChapters(keep) }
|
||||
}
|
||||
}
|
||||
#if DEBUG
|
||||
view.accessibilityValue = "chapter=\(anchor.chapter) position=\(anchor.position) chapters=\(parent.chapters.count) decelInsertions=\(decelerationInsertions) continued=\(continuedDecelerations) interrupted=\(interruptedDecelerations)"
|
||||
#endif
|
||||
}
|
||||
|
||||
private struct Anchor {
|
||||
let chapter: Int
|
||||
let position: Int
|
||||
let inHeading: Bool
|
||||
let screenY: CGFloat
|
||||
}
|
||||
|
||||
private func captureAnchor() -> Anchor? {
|
||||
guard let view, !blocks.isEmpty else { return nil }
|
||||
let layout = view.collectionViewLayout as! ReaderContinuousLayout
|
||||
let y = max(view.contentOffset.y, layout.frames.first?.minY ?? 0)
|
||||
guard let row = layout.row(at: y) else { return nil }
|
||||
let block = blocks[row], chapter = block.chapter
|
||||
let localY = y - layout.frames[row].minY + block.top
|
||||
let glyph = chapter.manager.glyphIndex(for: CGPoint(x: 0, y: localY + 0.5), in: chapter.container)
|
||||
let safeGlyph = min(glyph, max(chapter.manager.numberOfGlyphs - 1, 0))
|
||||
let character = chapter.manager.characterIndexForGlyph(at: safeGlyph)
|
||||
let lineY = chapter.manager.lineFragmentRect(forGlyphAt: safeGlyph, effectiveRange: nil).minY
|
||||
return Anchor(
|
||||
chapter: chapter.chapter.id,
|
||||
position: min(max(character - chapter.bodyStart, 0), chapter.chapter.content.utf16.count),
|
||||
inHeading: character < chapter.bodyStart,
|
||||
screenY: layout.frames[row].minY + lineY - block.top - view.contentOffset.y
|
||||
)
|
||||
}
|
||||
|
||||
private func offset(for anchor: Anchor, blocks: [ReaderScrollBlock], frames: [CGRect]) -> CGFloat? {
|
||||
guard let chapter = blocks.first(where: { $0.chapter.chapter.id == anchor.chapter })?.chapter else { return nil }
|
||||
let character = anchor.inHeading ? 0 : ReaderPositionResolver.renderedLocation(
|
||||
position: anchor.position, prefixLength: chapter.bodyStart, renderedLength: chapter.storage.length
|
||||
)
|
||||
let glyph = chapter.manager.glyphIndexForCharacter(at: character)
|
||||
guard let row = blocks.firstIndex(where: {
|
||||
$0.chapter.chapter.id == anchor.chapter && NSLocationInRange(glyph, $0.glyphRange)
|
||||
}) else { return nil }
|
||||
let lineY = chapter.manager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil).minY
|
||||
return frames[row].minY + lineY - blocks[row].top - anchor.screenY
|
||||
}
|
||||
|
||||
private func seek(chapter: Int, position: Int, includeHeading: Bool) {
|
||||
guard let view else { return }
|
||||
let layout = view.collectionViewLayout as! ReaderContinuousLayout
|
||||
let anchor = Anchor(chapter: chapter, position: max(position, 0), inHeading: includeHeading, screenY: 0)
|
||||
guard let y = offset(for: anchor, blocks: blocks, frames: layout.frames) else { return }
|
||||
let maximum = max(layout.collectionViewContentSize.height - view.bounds.height, 0)
|
||||
view.setContentOffset(CGPoint(x: 0, y: min(max(y, 0), maximum)), animated: false)
|
||||
}
|
||||
|
||||
@objc private func tapped(_ gesture: UITapGestureRecognizer) {
|
||||
guard let view, !view.isDragging, !view.isDecelerating else { return }
|
||||
let point = gesture.location(in: view)
|
||||
if let row = (view.collectionViewLayout as? ReaderContinuousLayout)?.row(at: point.y),
|
||||
blocks[row].chapter.chapter.error != nil {
|
||||
parent.configuration.onTapAction(0)
|
||||
return
|
||||
}
|
||||
let x = point.x - view.bounds.minX, y = point.y - view.bounds.minY
|
||||
let column = min(max(Int(x / max(view.bounds.width / 3, 1)), 0), 2)
|
||||
let row = min(max(Int(y / max(view.bounds.height / 3, 1)), 0), 2)
|
||||
let index = row * 3 + column
|
||||
let actions = parent.configuration.clickActions
|
||||
let action = index == 4 ? 0 : (actions.indices.contains(index) ? actions[index] : 0)
|
||||
if action != 1, action != 2, action != -1 { parent.configuration.onTapAction(action) }
|
||||
}
|
||||
|
||||
@objc private func step(_ link: CADisplayLink) {
|
||||
defer { lastTimestamp = link.timestamp }
|
||||
guard let view, lastTimestamp > 0, parent.configuration.isAutoReading,
|
||||
!parent.configuration.isAutoReadPaused, !view.isDragging, !view.isDecelerating else { return }
|
||||
let elapsed = min(link.timestamp - lastTimestamp, 0.1)
|
||||
let maximum = max(view.contentSize.height - view.bounds.height, 0)
|
||||
let next = min(view.contentOffset.y + view.bounds.height / CGFloat(max(parent.configuration.secondsPerScreen, 1)) * elapsed, maximum)
|
||||
if next > view.contentOffset.y { view.setContentOffset(CGPoint(x: 0, y: next), animated: false) }
|
||||
if next >= maximum - 0.5, !reachedEnd {
|
||||
reachedEnd = true
|
||||
parent.configuration.onReachedEnd(parent.configuration.readerPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ReaderScrollCollectionView: UICollectionView {
|
||||
var viewportChanged: (() -> Void)?
|
||||
let loadingLabel = UILabel()
|
||||
private var viewportSize = CGSize.zero
|
||||
|
||||
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
|
||||
super.init(frame: frame, collectionViewLayout: layout)
|
||||
loadingLabel.font = .systemFont(ofSize: 12)
|
||||
loadingLabel.textAlignment = .center
|
||||
loadingLabel.isUserInteractionEnabled = false
|
||||
addSubview(loadingLabel)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
positionLoadingLabel()
|
||||
if viewportSize != bounds.size {
|
||||
viewportSize = bounds.size
|
||||
viewportChanged?()
|
||||
}
|
||||
}
|
||||
|
||||
func positionLoadingLabel() {
|
||||
loadingLabel.frame = CGRect(x: 0, y: bounds.maxY - 28, width: bounds.width, height: 24)
|
||||
bringSubviewToFront(loadingLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ReaderContinuousLayout: UICollectionViewLayout {
|
||||
private(set) var frames: [CGRect] = []
|
||||
private var size = CGSize.zero
|
||||
override var collectionViewContentSize: CGSize { size }
|
||||
|
||||
static func geometry(blocks: [ReaderScrollBlock], width: CGFloat, top: CGFloat, bottom: CGFloat, viewportHeight: CGFloat) -> (frames: [CGRect], size: CGSize) {
|
||||
var y = top
|
||||
let frames = blocks.map { block -> CGRect in
|
||||
let frame = CGRect(x: 0, y: y, width: width, height: block.height)
|
||||
y += block.height
|
||||
return frame
|
||||
}
|
||||
let finalChapter = blocks.last?.chapter.chapter.id
|
||||
let finalStart = blocks.indices.first(where: { blocks[$0].chapter.chapter.id == finalChapter })
|
||||
.map { frames[$0].minY } ?? top
|
||||
return (frames, CGSize(width: width, height: max(y, finalStart + viewportHeight) + bottom))
|
||||
}
|
||||
|
||||
func install(frames: [CGRect], size: CGSize, correction: CGFloat) {
|
||||
let context = UICollectionViewLayoutInvalidationContext()
|
||||
context.contentOffsetAdjustment = CGPoint(x: 0, y: correction)
|
||||
context.contentSizeAdjustment = CGSize(width: size.width - self.size.width, height: size.height - self.size.height)
|
||||
self.frames = frames
|
||||
self.size = size
|
||||
invalidateLayout(with: context)
|
||||
}
|
||||
|
||||
func row(at y: CGFloat) -> Int? {
|
||||
guard !frames.isEmpty else { return nil }
|
||||
var low = 0, high = frames.count
|
||||
while low < high {
|
||||
let middle = (low + high) / 2
|
||||
if frames[middle].maxY <= y { low = middle + 1 } else { high = middle }
|
||||
}
|
||||
return min(low, frames.count - 1)
|
||||
}
|
||||
|
||||
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
|
||||
guard let start = row(at: rect.minY) else { return [] }
|
||||
var result: [UICollectionViewLayoutAttributes] = []
|
||||
for row in start..<frames.count {
|
||||
if frames[row].minY > rect.maxY { break }
|
||||
if let attributes = layoutAttributesForItem(at: IndexPath(item: row, section: 0)) { result.append(attributes) }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
|
||||
guard frames.indices.contains(indexPath.item) else { return nil }
|
||||
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
|
||||
attributes.frame = frames[indexPath.item]
|
||||
return attributes
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ReaderScrollBlock {
|
||||
struct ID: Hashable { let chapter: Int; let firstGlyph: Int }
|
||||
let chapter: ReaderScrollChapterLayout
|
||||
let glyphRange: NSRange
|
||||
let top: CGFloat
|
||||
let height: CGFloat
|
||||
var id: ID { .init(chapter: chapter.chapter.id, firstGlyph: glyphRange.location) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ReaderScrollChapterLayout {
|
||||
let chapter: ReaderScrollChapter
|
||||
let storage: NSTextStorage
|
||||
let manager = NSLayoutManager()
|
||||
let container: NSTextContainer
|
||||
let bodyStart: Int
|
||||
let left: CGFloat
|
||||
private var fragments: [(range: NSRange, top: CGFloat, height: CGFloat)] = []
|
||||
var blocks: [ReaderScrollBlock] {
|
||||
fragments.map { .init(chapter: self, glyphRange: $0.range, top: $0.top, height: $0.height) }
|
||||
}
|
||||
|
||||
init(chapter: ReaderScrollChapter, configuration: ReaderTextView, width: CGFloat) {
|
||||
self.chapter = chapter
|
||||
let typography = ReaderTypography(configuration: configuration)
|
||||
left = CGFloat(configuration.paddingLeft) + 4
|
||||
let textWidth = max(width - left - CGFloat(configuration.paddingRight) - 4, 1)
|
||||
container = NSTextContainer(size: CGSize(width: textWidth, height: .greatestFiniteMagnitude))
|
||||
container.lineFragmentPadding = 0
|
||||
let attributed = NSMutableAttributedString(string: "")
|
||||
if configuration.titleMode != 2, !chapter.title.isEmpty {
|
||||
attributed.append(NSAttributedString(string: chapter.title + "\n", attributes: typography.headingAttributes))
|
||||
}
|
||||
bodyStart = attributed.length
|
||||
let body = chapter.error.map { "本章加载失败\n\n\($0)\n\n轻点打开阅读菜单。\n" } ?? (chapter.content.isEmpty ? "本章暂无正文\n" : chapter.content)
|
||||
attributed.append(NSAttributedString(string: body, attributes: typography.bodyAttributes))
|
||||
storage = NSTextStorage(attributedString: attributed)
|
||||
storage.addLayoutManager(manager)
|
||||
manager.addTextContainer(container)
|
||||
manager.ensureLayout(for: container)
|
||||
var startGlyph = 0
|
||||
var top: CGFloat = 0
|
||||
let count = manager.numberOfGlyphs
|
||||
manager.enumerateLineFragments(forGlyphRange: NSRange(location: 0, length: count)) { rect, _, _, range, _ in
|
||||
if rect.minY - top >= 1000, range.location > startGlyph {
|
||||
self.fragments.append((NSRange(location: startGlyph, length: range.location - startGlyph),
|
||||
top, rect.minY - top))
|
||||
startGlyph = range.location
|
||||
top = rect.minY
|
||||
}
|
||||
}
|
||||
let bottom = manager.usedRect(for: container).maxY + CGFloat(configuration.fontSize * 1.4)
|
||||
fragments.append((NSRange(location: startGlyph, length: count - startGlyph), top, max(bottom - top, 1)))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ReaderScrollCell: UICollectionViewCell {
|
||||
let canvas = ReaderScrollCanvas()
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
contentView.backgroundColor = .clear
|
||||
contentView.addSubview(canvas)
|
||||
}
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
override func layoutSubviews() { super.layoutSubviews(); canvas.frame = contentView.bounds }
|
||||
override func prepareForReuse() { super.prepareForReuse(); canvas.block = nil }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ReaderScrollCanvas: UIView {
|
||||
var block: ReaderScrollBlock? {
|
||||
didSet {
|
||||
if let block {
|
||||
let range = block.chapter.manager.characterRange(forGlyphRange: block.glyphRange, actualGlyphRange: nil)
|
||||
accessibilityLabel = block.chapter.storage.attributedSubstring(from: range).string
|
||||
} else { accessibilityLabel = nil }
|
||||
setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
isOpaque = false
|
||||
isAccessibilityElement = true
|
||||
accessibilityTraits = .staticText
|
||||
contentMode = .redraw
|
||||
}
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard let block else { return }
|
||||
let origin = CGPoint(x: block.chapter.left, y: -block.top)
|
||||
block.chapter.manager.drawBackground(forGlyphRange: block.glyphRange, at: origin)
|
||||
block.chapter.manager.drawGlyphs(forGlyphRange: block.glyphRange, at: origin)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ReaderTypography {
|
||||
let bodyAttributes: [NSAttributedString.Key: Any]
|
||||
let headingAttributes: [NSAttributedString.Key: Any]
|
||||
|
||||
init(configuration c: ReaderTextView) {
|
||||
let weight: UIFont.Weight = switch c.fontWeight { case 1: .bold; case 2: .light; default: .regular }
|
||||
let font = ReaderFontStore.font(at: c.fontPath, size: CGFloat(c.fontSize), weight: c.fontWeight)
|
||||
?? (c.fontWeight == 0 ? (UIFont(name: "STSongti-SC-Regular", size: CGFloat(c.fontSize))
|
||||
?? UIFont.systemFont(ofSize: CGFloat(c.fontSize))) : UIFont.systemFont(ofSize: CGFloat(c.fontSize), weight: weight))
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = CGFloat(c.lineSpacing)
|
||||
paragraph.paragraphSpacing = CGFloat(c.paragraphSpacing)
|
||||
paragraph.firstLineHeadIndent = (c.paragraphIndent as NSString).size(withAttributes: [
|
||||
.font: font, .kern: CGFloat(c.letterSpacing * c.fontSize)
|
||||
]).width
|
||||
paragraph.alignment = c.fullJustify ? .justified : .natural
|
||||
var body: [NSAttributedString.Key: Any] = [
|
||||
.font: font, .kern: CGFloat(c.letterSpacing * c.fontSize),
|
||||
.foregroundColor: c.foregroundColor, .paragraphStyle: paragraph
|
||||
]
|
||||
if c.underlineMode != 0 {
|
||||
var underline = NSUnderlineStyle.single
|
||||
if c.underlineMode == 2 { underline.insert(.patternDash) }
|
||||
body[.underlineStyle] = underline.rawValue
|
||||
body[.underlineColor] = c.foregroundColor
|
||||
}
|
||||
bodyAttributes = body
|
||||
let headingFont = ReaderFontStore.font(at: c.fontPath, size: CGFloat(max(5, c.fontSize + c.titleSize)), weight: c.fontWeight)
|
||||
?? UIFont.systemFont(ofSize: CGFloat(max(5, c.fontSize + c.titleSize)), weight: weight)
|
||||
let heading = NSMutableParagraphStyle()
|
||||
heading.alignment = c.titleMode == 1 ? .center : .left
|
||||
heading.paragraphSpacingBefore = CGFloat(c.titleTopSpacing)
|
||||
heading.paragraphSpacing = CGFloat(c.titleBottomSpacing)
|
||||
headingAttributes = [.font: headingFont, .foregroundColor: c.foregroundColor, .paragraphStyle: heading]
|
||||
}
|
||||
}
|
||||
|
||||
extension ReaderTextView {
|
||||
var scrollTypographyKey: String {
|
||||
"\(fontSize)|\(fontPath)|\(letterSpacing)|\(lineSpacing)|\(paragraphSpacing)|\(paragraphIndent)|\(paddingLeft)|\(paddingRight)|\(paddingTop)|\(paddingBottom)|\(fontWeight)|\(titleMode)|\(titleSize)|\(titleTopSpacing)|\(titleBottomSpacing)|\(underlineMode)|\(fullJustify)|\(foregroundColor.hash)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
@preconcurrency import AVFoundation
|
||||
@preconcurrency import MediaPlayer
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
private final class IncrementalAudioResourceLoader: NSObject, AVAssetResourceLoaderDelegate, @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "io.legado.tts.stream-loader")
|
||||
private let contentType: String?
|
||||
private let expectedLength: Int64
|
||||
private var data = Data()
|
||||
private var pending: [AVAssetResourceLoadingRequest] = []
|
||||
private var finished = false
|
||||
private var failure: Error?
|
||||
private var consumeTask: Task<Void, Never>?
|
||||
|
||||
init(stream: HttpTTSAudioStream) {
|
||||
contentType = stream.contentType
|
||||
expectedLength = stream.expectedContentLength ?? 0
|
||||
super.init()
|
||||
consumeTask = Task { [weak self] in
|
||||
do {
|
||||
for try await chunk in stream.chunks {
|
||||
guard let self else { return }
|
||||
self.queue.async { [weak self] in
|
||||
self?.data.append(chunk)
|
||||
self?.processPendingRequests()
|
||||
}
|
||||
}
|
||||
self?.queue.async { [weak self] in
|
||||
self?.finished = true
|
||||
self?.processPendingRequests()
|
||||
}
|
||||
} catch {
|
||||
self?.queue.async { [weak self] in
|
||||
self?.failure = error
|
||||
self?.finished = true
|
||||
self?.processPendingRequests()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit { consumeTask?.cancel() }
|
||||
|
||||
func cancel() {
|
||||
consumeTask?.cancel()
|
||||
queue.async { [weak self] in
|
||||
guard let self, !finished else { return }
|
||||
failure = CancellationError()
|
||||
finished = true
|
||||
processPendingRequests()
|
||||
}
|
||||
}
|
||||
|
||||
func install(on asset: AVURLAsset) {
|
||||
asset.resourceLoader.setDelegate(self, queue: queue)
|
||||
}
|
||||
|
||||
func resourceLoader(
|
||||
_ resourceLoader: AVAssetResourceLoader,
|
||||
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
|
||||
) -> Bool {
|
||||
pending.append(loadingRequest)
|
||||
processPendingRequests()
|
||||
return true
|
||||
}
|
||||
|
||||
func resourceLoader(
|
||||
_ resourceLoader: AVAssetResourceLoader,
|
||||
didCancel loadingRequest: AVAssetResourceLoadingRequest
|
||||
) {
|
||||
pending.removeAll { $0 === loadingRequest }
|
||||
}
|
||||
|
||||
private func processPendingRequests() {
|
||||
pending.removeAll { request in
|
||||
if let info = request.contentInformationRequest {
|
||||
info.contentType = contentType.flatMap { UTType(mimeType: $0)?.identifier }
|
||||
info.contentLength = expectedLength
|
||||
info.isByteRangeAccessSupported = false
|
||||
}
|
||||
if let failure {
|
||||
request.finishLoading(with: failure)
|
||||
return true
|
||||
}
|
||||
guard let dataRequest = request.dataRequest else {
|
||||
if finished { request.finishLoading(); return true }
|
||||
return false
|
||||
}
|
||||
let offset = max(Int(dataRequest.currentOffset), Int(dataRequest.requestedOffset))
|
||||
if offset < data.count {
|
||||
let requestedEnd = Int(dataRequest.requestedOffset) + dataRequest.requestedLength
|
||||
let end = min(data.count, requestedEnd)
|
||||
if end > offset { dataRequest.respond(with: data.subdata(in: offset..<end)) }
|
||||
}
|
||||
let deliveredEnd = max(Int(dataRequest.currentOffset), Int(dataRequest.requestedOffset))
|
||||
let requestedEnd = Int(dataRequest.requestedOffset) + dataRequest.requestedLength
|
||||
if deliveredEnd >= requestedEnd || finished {
|
||||
request.finishLoading()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ReaderSpeechController: NSObject, ObservableObject, @preconcurrency AVSpeechSynthesizerDelegate, @preconcurrency AVAudioPlayerDelegate {
|
||||
@Published private(set) var isSpeaking = false
|
||||
@Published private(set) var isPaused = false
|
||||
@Published private(set) var errorMessage: String?
|
||||
@Published private(set) var completionCount = 0
|
||||
@Published private(set) var currentSegmentIndex = 0
|
||||
private let synthesizer = AVSpeechSynthesizer()
|
||||
private let httpService = HttpTTSService()
|
||||
private var audioPlayer: AVAudioPlayer?
|
||||
private var streamPlayer: AVPlayer?
|
||||
private var streamLoader: IncrementalAudioResourceLoader?
|
||||
private var streamCompletionToken: NSObjectProtocol?
|
||||
private var httpTask: Task<Void, Never>?
|
||||
private var httpPrefetchTasks: [Task<Void, Never>] = []
|
||||
private var httpSegments: [String] = []
|
||||
private var httpSegmentIndex = 0
|
||||
private var httpEngine: HttpTTS?
|
||||
private var systemSegments: [String] = []
|
||||
private var systemSegmentIndex = 0
|
||||
private var segmentIndexOffset = 0
|
||||
private var followsSystemRate = true
|
||||
private var speechRate = 5
|
||||
private var readByPage = false
|
||||
private var streamsHTTPAudio = true
|
||||
private var voiceIdentifier = ""
|
||||
private var continuesAcrossChapters = false
|
||||
private var notificationTokens: [NSObjectProtocol] = []
|
||||
nonisolated(unsafe) private var remoteCommandTargets: [(MPRemoteCommand, Any)] = []
|
||||
private var resumesAfterInterruption = false
|
||||
private var isChangingSystemSegment = false
|
||||
private var ignoresAudioFocus = false
|
||||
private var pausesForPhoneCalls = false
|
||||
private var mediaButtonPerNext = false
|
||||
private var bookName = ""
|
||||
private var chapterTitle = ""
|
||||
private var author = ""
|
||||
var onNextChapter: (() -> Void)?
|
||||
var onPreviousChapter: (() -> Void)?
|
||||
var onRemoteStop: (() -> Void)?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
synthesizer.delegate = self
|
||||
observeAudioSession()
|
||||
installRemoteCommands()
|
||||
}
|
||||
|
||||
deinit {
|
||||
notificationTokens.forEach(NotificationCenter.default.removeObserver)
|
||||
remoteCommandTargets.forEach { command, target in command.removeTarget(target) }
|
||||
}
|
||||
|
||||
func toggle(text: String, continuesAcrossChapters: Bool = false) {
|
||||
if isSpeaking || isPaused {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
speak(text, continuesAcrossChapters: continuesAcrossChapters)
|
||||
}
|
||||
|
||||
func toggleHTTP(engine: HttpTTS, text: String, continuesAcrossChapters: Bool = false) {
|
||||
if isSpeaking || isPaused {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
speakHTTP(engine: engine, text: text, continuesAcrossChapters: continuesAcrossChapters)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
synthesizer.stopSpeaking(at: .word)
|
||||
audioPlayer?.stop()
|
||||
audioPlayer = nil
|
||||
streamPlayer?.pause()
|
||||
streamLoader?.cancel()
|
||||
streamPlayer = nil
|
||||
streamLoader = nil
|
||||
if let streamCompletionToken {
|
||||
NotificationCenter.default.removeObserver(streamCompletionToken)
|
||||
self.streamCompletionToken = nil
|
||||
}
|
||||
httpTask?.cancel()
|
||||
httpTask = nil
|
||||
httpPrefetchTasks.forEach { $0.cancel() }
|
||||
httpPrefetchTasks.removeAll()
|
||||
httpSegments = []
|
||||
httpSegmentIndex = 0
|
||||
httpEngine = nil
|
||||
systemSegments = []
|
||||
systemSegmentIndex = 0
|
||||
segmentIndexOffset = 0
|
||||
continuesAcrossChapters = false
|
||||
isSpeaking = false
|
||||
isPaused = false
|
||||
updateNowPlaying()
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
func configure(
|
||||
followsSystemRate: Bool,
|
||||
speechRate: Int,
|
||||
readByPage: Bool,
|
||||
streamsHTTPAudio: Bool = true,
|
||||
voiceIdentifier: String = "",
|
||||
ignoresAudioFocus: Bool = false,
|
||||
pausesForPhoneCalls: Bool = false,
|
||||
mediaButtonPerNext: Bool = false,
|
||||
bookName: String = "",
|
||||
chapterTitle: String = "",
|
||||
author: String = ""
|
||||
) {
|
||||
self.followsSystemRate = followsSystemRate
|
||||
self.speechRate = min(max(speechRate, 0), 45)
|
||||
self.readByPage = readByPage
|
||||
self.streamsHTTPAudio = streamsHTTPAudio
|
||||
self.voiceIdentifier = voiceIdentifier
|
||||
self.ignoresAudioFocus = ignoresAudioFocus
|
||||
self.pausesForPhoneCalls = pausesForPhoneCalls
|
||||
self.mediaButtonPerNext = mediaButtonPerNext
|
||||
self.bookName = bookName
|
||||
self.chapterTitle = chapterTitle
|
||||
self.author = author
|
||||
}
|
||||
|
||||
func speak(_ text: String, continuesAcrossChapters: Bool = false, segmentIndexOffset: Int = 0) {
|
||||
stop()
|
||||
errorMessage = nil
|
||||
activateAudioSession()
|
||||
self.continuesAcrossChapters = continuesAcrossChapters
|
||||
if synthesizer.isSpeaking || synthesizer.isPaused {
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
}
|
||||
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
systemSegments = segments(for: text)
|
||||
guard !systemSegments.isEmpty else { return }
|
||||
systemSegmentIndex = 0
|
||||
self.segmentIndexOffset = max(segmentIndexOffset, 0)
|
||||
playCurrentSystemSegment()
|
||||
}
|
||||
|
||||
func speakHTTP(
|
||||
engine: HttpTTS,
|
||||
text: String,
|
||||
continuesAcrossChapters: Bool = false,
|
||||
segmentIndexOffset: Int = 0
|
||||
) {
|
||||
stop()
|
||||
errorMessage = nil
|
||||
activateAudioSession()
|
||||
self.continuesAcrossChapters = continuesAcrossChapters
|
||||
httpEngine = engine
|
||||
httpSegments = segments(for: text)
|
||||
guard !httpSegments.isEmpty else { return }
|
||||
httpSegmentIndex = 0
|
||||
self.segmentIndexOffset = max(segmentIndexOffset, 0)
|
||||
isSpeaking = true
|
||||
playCurrentHTTPSegment()
|
||||
prefetchHTTP(engine: engine, texts: Array(httpSegments.dropFirst()))
|
||||
}
|
||||
|
||||
func prefetchHTTP(engine: HttpTTS, text: String, limit: Int = 10) {
|
||||
let values = Array(segments(for: text).prefix(max(limit, 0)))
|
||||
prefetchHTTP(engine: engine, texts: values)
|
||||
}
|
||||
|
||||
func pauseOrResume(text: String, continuesAcrossChapters: Bool = false) {
|
||||
if isPaused {
|
||||
resumePlayback()
|
||||
} else if isSpeaking {
|
||||
pausePlayback()
|
||||
} else {
|
||||
speak(text, continuesAcrossChapters: continuesAcrossChapters)
|
||||
}
|
||||
}
|
||||
|
||||
func pauseOrResumeHTTP(engine: HttpTTS, text: String, continuesAcrossChapters: Bool = false) {
|
||||
if audioPlayer != nil {
|
||||
pauseOrResume(text: text, continuesAcrossChapters: continuesAcrossChapters)
|
||||
} else if isSpeaking {
|
||||
httpTask?.cancel()
|
||||
isSpeaking = false
|
||||
isPaused = true
|
||||
} else if isPaused, httpEngine != nil {
|
||||
isPaused = false
|
||||
isSpeaking = true
|
||||
playCurrentHTTPSegment()
|
||||
} else {
|
||||
speakHTTP(engine: engine, text: text, continuesAcrossChapters: continuesAcrossChapters)
|
||||
}
|
||||
}
|
||||
|
||||
private func segments(for text: String) -> [String] {
|
||||
let values = readByPage ? [text] : text.components(separatedBy: .newlines)
|
||||
return values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
private func activateAudioSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try? session.setCategory(.playback, mode: .spokenAudio, options: ignoresAudioFocus ? [.mixWithOthers] : [])
|
||||
try? session.setActive(true)
|
||||
}
|
||||
|
||||
private func pausePlayback() {
|
||||
if let audioPlayer, audioPlayer.isPlaying {
|
||||
audioPlayer.pause()
|
||||
} else if let streamPlayer {
|
||||
streamPlayer.pause()
|
||||
} else if synthesizer.isSpeaking {
|
||||
synthesizer.pauseSpeaking(at: .word)
|
||||
} else if httpTask != nil {
|
||||
httpTask?.cancel()
|
||||
httpTask = nil
|
||||
} else {
|
||||
return
|
||||
}
|
||||
isPaused = true
|
||||
isSpeaking = false
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
private func resumePlayback() {
|
||||
activateAudioSession()
|
||||
if let audioPlayer {
|
||||
isSpeaking = audioPlayer.play()
|
||||
} else if let streamPlayer {
|
||||
streamPlayer.play()
|
||||
isSpeaking = true
|
||||
} else if synthesizer.isPaused {
|
||||
synthesizer.continueSpeaking()
|
||||
isSpeaking = true
|
||||
} else if httpEngine != nil, httpSegments.indices.contains(httpSegmentIndex) {
|
||||
isSpeaking = true
|
||||
playCurrentHTTPSegment()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
isPaused = false
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
private func observeAudioSession() {
|
||||
let center = NotificationCenter.default
|
||||
notificationTokens.append(center.addObserver(
|
||||
forName: AVAudioSession.interruptionNotification,
|
||||
object: AVAudioSession.sharedInstance(),
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
let type = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt
|
||||
let options = note.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
||||
Task { @MainActor in self?.handleAudioInterruption(type: type, options: options) }
|
||||
})
|
||||
notificationTokens.append(center.addObserver(
|
||||
forName: AVAudioSession.routeChangeNotification,
|
||||
object: AVAudioSession.sharedInstance(),
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
let reason = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt
|
||||
Task { @MainActor in self?.handleRouteChange(reason: reason) }
|
||||
})
|
||||
}
|
||||
|
||||
private func handleAudioInterruption(type raw: UInt?, options rawOptions: UInt) {
|
||||
guard let raw,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
|
||||
switch type {
|
||||
case .began:
|
||||
if ignoresAudioFocus && !pausesForPhoneCalls { return }
|
||||
resumesAfterInterruption = isSpeaking
|
||||
if isSpeaking { pausePlayback() }
|
||||
case .ended:
|
||||
if ignoresAudioFocus && !pausesForPhoneCalls { return }
|
||||
let options = AVAudioSession.InterruptionOptions(rawValue: rawOptions)
|
||||
if resumesAfterInterruption, options.contains(.shouldResume) { resumePlayback() }
|
||||
resumesAfterInterruption = false
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRouteChange(reason raw: UInt?) {
|
||||
guard let raw,
|
||||
AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable else { return }
|
||||
if isSpeaking { pausePlayback() }
|
||||
}
|
||||
|
||||
private func installRemoteCommands() {
|
||||
let center = MPRemoteCommandCenter.shared()
|
||||
center.playCommand.isEnabled = true
|
||||
center.pauseCommand.isEnabled = true
|
||||
center.togglePlayPauseCommand.isEnabled = true
|
||||
center.stopCommand.isEnabled = true
|
||||
center.nextTrackCommand.isEnabled = true
|
||||
center.previousTrackCommand.isEnabled = true
|
||||
addRemoteTarget(center.playCommand) { $0.resumePlayback() }
|
||||
addRemoteTarget(center.pauseCommand) { $0.pausePlayback() }
|
||||
addRemoteTarget(center.togglePlayPauseCommand) { controller in
|
||||
controller.isPaused ? controller.resumePlayback() : controller.pausePlayback()
|
||||
}
|
||||
addRemoteTarget(center.stopCommand) { controller in
|
||||
controller.stop()
|
||||
controller.onRemoteStop?()
|
||||
}
|
||||
addRemoteTarget(center.nextTrackCommand) { $0.skipForward(usesChapterPreference: true) }
|
||||
addRemoteTarget(center.previousTrackCommand) { $0.skipBackward(usesChapterPreference: true) }
|
||||
}
|
||||
|
||||
private func addRemoteTarget(_ command: MPRemoteCommand, action: @escaping @MainActor (ReaderSpeechController) -> Void) {
|
||||
let target = command.addTarget { [weak self] _ in
|
||||
guard self != nil else { return .noSuchContent }
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
action(self)
|
||||
}
|
||||
return .success
|
||||
}
|
||||
remoteCommandTargets.append((command, target))
|
||||
}
|
||||
|
||||
func nextSegment() {
|
||||
guard isSpeaking || isPaused else { return }
|
||||
skipForward(usesChapterPreference: false)
|
||||
}
|
||||
|
||||
func previousSegment() {
|
||||
guard isSpeaking || isPaused else { return }
|
||||
skipBackward(usesChapterPreference: false)
|
||||
}
|
||||
|
||||
private func playCurrentSystemSegment() {
|
||||
guard systemSegments.indices.contains(systemSegmentIndex) else {
|
||||
finishNaturally()
|
||||
return
|
||||
}
|
||||
let utterance = AVSpeechUtterance(string: systemSegments[systemSegmentIndex])
|
||||
utterance.voice = AVSpeechSynthesisVoice(identifier: voiceIdentifier)
|
||||
?? AVSpeechSynthesisVoice(language: "zh-CN")
|
||||
let multiplier = followsSystemRate ? 1 : Float(speechRate + 5) / 10
|
||||
utterance.rate = min(
|
||||
max(AVSpeechUtteranceDefaultSpeechRate * multiplier, AVSpeechUtteranceMinimumSpeechRate),
|
||||
AVSpeechUtteranceMaximumSpeechRate
|
||||
)
|
||||
utterance.pitchMultiplier = 1
|
||||
synthesizer.speak(utterance)
|
||||
currentSegmentIndex = segmentIndexOffset + systemSegmentIndex
|
||||
isSpeaking = true
|
||||
isPaused = false
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
private func skipForward(usesChapterPreference: Bool) {
|
||||
if usesChapterPreference && mediaButtonPerNext {
|
||||
onNextChapter?()
|
||||
} else if httpEngine != nil, httpSegmentIndex + 1 < httpSegments.count {
|
||||
stopCurrentHTTPPlayback()
|
||||
httpSegmentIndex += 1
|
||||
playCurrentHTTPSegment()
|
||||
} else if !systemSegments.isEmpty, systemSegmentIndex + 1 < systemSegments.count {
|
||||
changeSystemSegment(to: systemSegmentIndex + 1)
|
||||
} else {
|
||||
onNextChapter?()
|
||||
}
|
||||
}
|
||||
|
||||
private func skipBackward(usesChapterPreference: Bool) {
|
||||
if usesChapterPreference && mediaButtonPerNext {
|
||||
onPreviousChapter?()
|
||||
} else if httpEngine != nil, httpSegmentIndex > 0 {
|
||||
stopCurrentHTTPPlayback()
|
||||
httpSegmentIndex -= 1
|
||||
playCurrentHTTPSegment()
|
||||
} else if !systemSegments.isEmpty, systemSegmentIndex > 0 {
|
||||
changeSystemSegment(to: systemSegmentIndex - 1)
|
||||
} else {
|
||||
onPreviousChapter?()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopCurrentHTTPPlayback() {
|
||||
audioPlayer?.stop()
|
||||
audioPlayer = nil
|
||||
streamPlayer?.pause()
|
||||
streamLoader?.cancel()
|
||||
streamPlayer = nil
|
||||
streamLoader = nil
|
||||
if let streamCompletionToken {
|
||||
NotificationCenter.default.removeObserver(streamCompletionToken)
|
||||
self.streamCompletionToken = nil
|
||||
}
|
||||
httpTask?.cancel()
|
||||
httpTask = nil
|
||||
}
|
||||
|
||||
private func changeSystemSegment(to index: Int) {
|
||||
isChangingSystemSegment = true
|
||||
synthesizer.stopSpeaking(at: .immediate)
|
||||
systemSegmentIndex = index
|
||||
playCurrentSystemSegment()
|
||||
DispatchQueue.main.async { [weak self] in self?.isChangingSystemSegment = false }
|
||||
}
|
||||
|
||||
private func updateNowPlaying() {
|
||||
guard isSpeaking || isPaused else {
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
return
|
||||
}
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = [
|
||||
MPMediaItemPropertyTitle: chapterTitle,
|
||||
MPMediaItemPropertyAlbumTitle: bookName,
|
||||
MPMediaItemPropertyArtist: author,
|
||||
MPNowPlayingInfoPropertyPlaybackRate: isSpeaking ? 1 : 0
|
||||
]
|
||||
}
|
||||
|
||||
private func playCurrentHTTPSegment() {
|
||||
guard let engine = httpEngine, httpSegments.indices.contains(httpSegmentIndex) else {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
let text = httpSegments[httpSegmentIndex]
|
||||
currentSegmentIndex = segmentIndexOffset + httpSegmentIndex
|
||||
let rate = speechRate + 5
|
||||
httpTask?.cancel()
|
||||
httpTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
if streamsHTTPAudio {
|
||||
let stream = try await httpService.audioStream(engine: engine, text: text, speechRate: rate)
|
||||
try Task.checkCancellation()
|
||||
if stream.isIncremental, stream.expectedContentLength ?? 0 > 0 {
|
||||
playIncrementalHTTPStream(stream)
|
||||
} else {
|
||||
var data = Data()
|
||||
for try await chunk in stream.chunks { data.append(chunk) }
|
||||
try Task.checkCancellation()
|
||||
try playBufferedHTTPAudio(data)
|
||||
}
|
||||
} else {
|
||||
let audio = try await httpService.audio(engine: engine, text: text, speechRate: rate)
|
||||
try Task.checkCancellation()
|
||||
try playBufferedHTTPAudio(audio.data)
|
||||
}
|
||||
isPaused = false
|
||||
updateNowPlaying()
|
||||
if !isSpeaking { throw CocoaError(.fileReadCorruptFile) }
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func playBufferedHTTPAudio(_ data: Data) throws {
|
||||
let player = try AVAudioPlayer(data: data)
|
||||
player.delegate = self
|
||||
player.prepareToPlay()
|
||||
audioPlayer = player
|
||||
isSpeaking = player.play()
|
||||
}
|
||||
|
||||
private func playIncrementalHTTPStream(_ stream: HttpTTSAudioStream) {
|
||||
let url = URL(string: "legado-tts-stream://audio/\(UUID().uuidString)")!
|
||||
let asset = AVURLAsset(url: url)
|
||||
let loader = IncrementalAudioResourceLoader(stream: stream)
|
||||
loader.install(on: asset)
|
||||
let item = AVPlayerItem(asset: asset)
|
||||
let player = AVPlayer(playerItem: item)
|
||||
streamLoader = loader
|
||||
streamPlayer = player
|
||||
streamCompletionToken = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemDidPlayToEndTime,
|
||||
object: item,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in self?.incrementalPlayerDidFinish() }
|
||||
}
|
||||
player.play()
|
||||
isSpeaking = true
|
||||
}
|
||||
|
||||
private func incrementalPlayerDidFinish() {
|
||||
stopCurrentHTTPPlayback()
|
||||
httpSegmentIndex += 1
|
||||
if httpSegmentIndex < httpSegments.count { playCurrentHTTPSegment() }
|
||||
else { finishNaturally() }
|
||||
}
|
||||
|
||||
private func prefetchHTTP(engine: HttpTTS, texts: [String]) {
|
||||
guard !texts.isEmpty else { return }
|
||||
let rate = speechRate + 5
|
||||
let task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
for text in texts {
|
||||
guard !Task.isCancelled else { return }
|
||||
_ = try? await self.httpService.audio(engine: engine, text: text, speechRate: rate)
|
||||
}
|
||||
}
|
||||
httpPrefetchTasks.append(task)
|
||||
}
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||
audioPlayer = nil
|
||||
guard flag else {
|
||||
errorMessage = "HTTP TTS 音频播放失败"
|
||||
stop()
|
||||
return
|
||||
}
|
||||
httpSegmentIndex += 1
|
||||
if httpSegmentIndex < httpSegments.count {
|
||||
playCurrentHTTPSegment()
|
||||
} else {
|
||||
finishNaturally()
|
||||
}
|
||||
}
|
||||
|
||||
private func finishNaturally() {
|
||||
let shouldContinue = continuesAcrossChapters
|
||||
stop()
|
||||
if shouldContinue { completionCount += 1 }
|
||||
}
|
||||
|
||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||
guard !isChangingSystemSegment else { return }
|
||||
systemSegmentIndex += 1
|
||||
if systemSegmentIndex < systemSegments.count { playCurrentSystemSegment() }
|
||||
else { finishNaturally() }
|
||||
}
|
||||
|
||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||||
guard !isChangingSystemSegment else { return }
|
||||
guard !synthesizer.isSpeaking else { return }
|
||||
isSpeaking = false
|
||||
isPaused = false
|
||||
updateNowPlaying()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct ReaderInteractivePopEnabler: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context: Context) -> Controller {
|
||||
Controller()
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: Controller, context: Context) {
|
||||
uiViewController.enableInteractivePopIfAvailable()
|
||||
}
|
||||
|
||||
static func dismantleUIViewController(_ uiViewController: Controller, coordinator: Void) {
|
||||
uiViewController.restoreInteractivePopDelegate()
|
||||
}
|
||||
|
||||
final class Controller: UIViewController {
|
||||
private weak var popGesture: UIGestureRecognizer?
|
||||
private var previousDelegate: (any UIGestureRecognizerDelegate)?
|
||||
private var releasedDelegate = false
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
enableInteractivePopIfAvailable()
|
||||
}
|
||||
|
||||
func enableInteractivePopIfAvailable() {
|
||||
guard let navigationController,
|
||||
navigationController.viewControllers.count > 1,
|
||||
let gesture = navigationController.interactivePopGestureRecognizer else { return }
|
||||
if gesture.delegate != nil {
|
||||
previousDelegate = gesture.delegate
|
||||
gesture.delegate = nil
|
||||
releasedDelegate = true
|
||||
}
|
||||
gesture.isEnabled = true
|
||||
popGesture = gesture
|
||||
}
|
||||
|
||||
func restoreInteractivePopDelegate() {
|
||||
guard releasedDelegate, let popGesture, popGesture.delegate == nil else { return }
|
||||
popGesture.delegate = previousDelegate
|
||||
previousDelegate = nil
|
||||
releasedDelegate = false
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct ReaderMenuDismissShape: Shape {
|
||||
var leadingExclusion: CGFloat = 24
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
Path(CGRect(
|
||||
x: min(max(leadingExclusion, 0), rect.width),
|
||||
y: 0,
|
||||
width: max(rect.width - leadingExclusion, 0),
|
||||
height: rect.height
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor enum ReaderScreenOrientation {
|
||||
static private(set) var supportedOrientations: UIInterfaceOrientationMask = .allButUpsideDown
|
||||
|
||||
static func apply(_ value: String) {
|
||||
let mask: UIInterfaceOrientationMask = switch value {
|
||||
case "1": .portrait
|
||||
case "2": .landscape
|
||||
case "3": .all
|
||||
case "4": .portraitUpsideDown
|
||||
case "5": .landscapeLeft
|
||||
default: .allButUpsideDown
|
||||
}
|
||||
supportedOrientations = mask
|
||||
guard let scene = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.first(where: { $0.activationState == .foregroundActive }) else { return }
|
||||
if #available(iOS 16.0, *) {
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { _ in }
|
||||
scene.windows.first?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
} else {
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
}
|
||||
}
|
||||
|
||||
static func restoreApplicationDefault() { apply("0") }
|
||||
}
|
||||
@@ -0,0 +1,936 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
private final class ReaderLayoutTextView: UITextView {
|
||||
var onLayout: ((UITextView) -> Void)?
|
||||
private var reportedSize = CGSize.zero
|
||||
private var reportedContentSize = CGSize.zero
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard bounds.width > 0, bounds.height > 0,
|
||||
bounds.size != reportedSize || contentSize != reportedContentSize else { return }
|
||||
reportedSize = bounds.size
|
||||
reportedContentSize = contentSize
|
||||
onLayout?(self)
|
||||
}
|
||||
}
|
||||
|
||||
struct ReaderTextView: UIViewRepresentable {
|
||||
let text: String
|
||||
let title: String
|
||||
let previousTitle: String?
|
||||
let previousText: String
|
||||
let isPreviousReady: Bool
|
||||
let continuationTitle: String?
|
||||
let continuationText: String
|
||||
let isContinuationReady: Bool
|
||||
let isVolume: Bool
|
||||
let fontSize: Double
|
||||
let fontPath: String
|
||||
let letterSpacing: Double
|
||||
let lineSpacing: Double
|
||||
let paragraphSpacing: Double
|
||||
let paragraphIndent: String
|
||||
let paddingLeft: Double
|
||||
let paddingRight: Double
|
||||
let paddingTop: Double
|
||||
let paddingBottom: Double
|
||||
let headerInset: Double
|
||||
let footerInset: Double
|
||||
let fontWeight: Int
|
||||
let titleMode: Int
|
||||
let titleSize: Double
|
||||
let titleTopSpacing: Double
|
||||
let titleBottomSpacing: Double
|
||||
let underlineMode: Int
|
||||
let isSelectable: Bool
|
||||
let fullJustify: Bool
|
||||
let pageAnimation: Int
|
||||
let isEInkMode: Bool
|
||||
let clickActions: [Int]
|
||||
let backgroundColor: UIColor
|
||||
let foregroundColor: UIColor
|
||||
let requestedPosition: Int
|
||||
let jumpPosition: Int?
|
||||
let renderRevision: Int
|
||||
@Binding var readerPosition: Int
|
||||
@Binding var pageRanges: [NSRange]
|
||||
@Binding var currentPage: Int
|
||||
@Binding var totalPages: Int
|
||||
let isAutoReading: Bool
|
||||
let isAutoReadPaused: Bool
|
||||
let isLoadingNextChapter: Bool
|
||||
let secondsPerScreen: Double
|
||||
let onReachedEnd: (Int) -> Void
|
||||
let onReachedStart: (Int) -> Void
|
||||
let onEnteredPrevious: (Int) -> Void
|
||||
let onEnteredContinuation: (Int) -> Void
|
||||
let onDeferredRender: () -> Void
|
||||
let onCancelNextLoading: () -> Void
|
||||
let onPageChanged: () -> Void
|
||||
let onTapAction: (Int) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
// Pagination uses NSLayoutManager. Start in TextKit 1 rather than
|
||||
// switching a TextKit 2 view after its content size has been computed.
|
||||
let view: ReaderLayoutTextView
|
||||
if #available(iOS 16.0, *) {
|
||||
view = ReaderLayoutTextView(usingTextLayoutManager: false)
|
||||
} else {
|
||||
view = ReaderLayoutTextView(frame: .zero)
|
||||
}
|
||||
view.onLayout = { [weak coordinator = context.coordinator] view in
|
||||
coordinator?.reportPage(in: view)
|
||||
coordinator?.reportPageRanges(in: view)
|
||||
}
|
||||
view.accessibilityIdentifier = "reader.text"
|
||||
let effectivePageAnimation = isEInkMode ? 4 : pageAnimation
|
||||
view.backgroundColor = backgroundColor
|
||||
view.isOpaque = backgroundColor != .clear
|
||||
view.delegate = context.coordinator
|
||||
view.isEditable = false
|
||||
view.isSelectable = false
|
||||
view.alwaysBounceVertical = effectivePageAnimation == 3
|
||||
view.isScrollEnabled = true
|
||||
view.panGestureRecognizer.isEnabled = effectivePageAnimation == 3
|
||||
view.textContainer.lineFragmentPadding = 4
|
||||
view.showsVerticalScrollIndicator = false
|
||||
view.isPagingEnabled = effectivePageAnimation != 3
|
||||
view.decelerationRate = effectivePageAnimation == 3 ? .normal : .fast
|
||||
context.coordinator.installDisplayLink(for: view)
|
||||
context.coordinator.installGestures(on: view)
|
||||
context.coordinator.updateInteractionMode(on: view, isScrollMode: effectivePageAnimation == 3)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: UITextView, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
let viewportSizeChanged = view.bounds.size != .zero
|
||||
&& context.coordinator.lastViewportSize != view.bounds.size
|
||||
if viewportSizeChanged {
|
||||
context.coordinator.lastViewportSize = view.bounds.size
|
||||
}
|
||||
let effectivePageAnimation = isEInkMode ? 4 : pageAnimation
|
||||
view.isSelectable = false
|
||||
view.alwaysBounceVertical = effectivePageAnimation == 3
|
||||
view.isScrollEnabled = true
|
||||
view.panGestureRecognizer.isEnabled = effectivePageAnimation == 3
|
||||
view.showsVerticalScrollIndicator = false
|
||||
view.isPagingEnabled = effectivePageAnimation != 3
|
||||
view.decelerationRate = effectivePageAnimation == 3 ? .normal : .fast
|
||||
context.coordinator.updateInteractionMode(on: view, isScrollMode: effectivePageAnimation == 3)
|
||||
view.backgroundColor = backgroundColor
|
||||
view.isOpaque = backgroundColor != .clear
|
||||
view.isSelectable = false
|
||||
view.selectedRange = NSRange(location: 0, length: 0)
|
||||
view.resignFirstResponder()
|
||||
view.textContainerInset = .init(
|
||||
top: CGFloat(paddingTop + headerInset),
|
||||
left: CGFloat(paddingLeft),
|
||||
bottom: CGFloat(paddingBottom + footerInset),
|
||||
right: CGFloat(paddingRight)
|
||||
)
|
||||
let contentKey = text.hashValue
|
||||
let primaryContentKey = "\(title)|\(text)".hashValue
|
||||
let showsContinuation = effectivePageAnimation == 3 && isContinuationReady && continuationTitle != nil
|
||||
let showsPrevious = showsContinuation && isPreviousReady && previousTitle != nil
|
||||
let adjacentContentKey = "\(showsPrevious)|\(previousTitle ?? "")|\(previousText)|\(showsContinuation)|\(continuationTitle ?? "")|\(continuationText)".hashValue
|
||||
let displaysTitle = titleMode != 2 || isVolume
|
||||
let titlePrefix = displaysTitle && !title.isEmpty ? title + "\n" : ""
|
||||
let styleKey = "\(contentKey)|\(adjacentContentKey)|\(title)|\(isVolume)|\(fontSize)|\(fontPath)|\(letterSpacing)|\(lineSpacing)|\(paragraphSpacing)|\(paragraphIndent)|\(paddingLeft)|\(paddingRight)|\(paddingTop)|\(paddingBottom)|\(headerInset)|\(footerInset)|\(fontWeight)|\(titleMode)|\(titleSize)|\(titleTopSpacing)|\(titleBottomSpacing)|\(underlineMode)|\(isSelectable)|\(fullJustify)|\(effectivePageAnimation)|\(foregroundColor.hash)"
|
||||
if context.coordinator.styleKey != styleKey {
|
||||
if context.coordinator.shouldDeferRender(in: view) {
|
||||
context.coordinator.needsDeferredRender = true
|
||||
return
|
||||
}
|
||||
let primaryContentChanged = context.coordinator.primaryContentKey != primaryContentKey
|
||||
let visiblePosition = context.coordinator.contentCharacter(at: view.contentOffset, in: view)
|
||||
context.coordinator.resetAutoReadForPageChange()
|
||||
context.coordinator.resetPageRangeRetry()
|
||||
context.coordinator.contentKey = contentKey
|
||||
context.coordinator.primaryContentKey = primaryContentKey
|
||||
context.coordinator.styleKey = styleKey
|
||||
let typography = ReaderTypography(configuration: self)
|
||||
let bodyAttributes = typography.bodyAttributes
|
||||
let rendered = NSMutableAttributedString(string: "")
|
||||
context.coordinator.previousBodyStart = nil
|
||||
context.coordinator.previousBodyEnd = nil
|
||||
context.coordinator.continuationSectionStart = nil
|
||||
context.coordinator.continuationBodyStart = nil
|
||||
if showsPrevious {
|
||||
let previousHeading = displaysTitle ? (previousTitle ?? "") + "\n" : ""
|
||||
rendered.append(NSAttributedString(string: previousHeading, attributes: typography.headingAttributes))
|
||||
context.coordinator.previousBodyStart = rendered.length
|
||||
rendered.append(NSAttributedString(
|
||||
string: previousText.isEmpty ? String(repeating: "\n", count: 80) : previousText,
|
||||
attributes: bodyAttributes
|
||||
))
|
||||
context.coordinator.previousBodyEnd = rendered.length
|
||||
rendered.append(NSAttributedString(string: "\n\n", attributes: bodyAttributes))
|
||||
}
|
||||
context.coordinator.currentSectionStart = rendered.length
|
||||
if !titlePrefix.isEmpty {
|
||||
rendered.append(NSAttributedString(string: titlePrefix, attributes: typography.headingAttributes))
|
||||
}
|
||||
context.coordinator.renderedPrefixLength = rendered.length
|
||||
rendered.append(NSAttributedString(string: text, attributes: bodyAttributes))
|
||||
context.coordinator.currentBodyEnd = rendered.length
|
||||
if showsContinuation {
|
||||
rendered.append(NSAttributedString(string: "\n\n", attributes: bodyAttributes))
|
||||
context.coordinator.continuationSectionStart = rendered.length
|
||||
let continuationHeading = displaysTitle ? (continuationTitle ?? "") + "\n" : ""
|
||||
rendered.append(NSAttributedString(string: continuationHeading, attributes: typography.headingAttributes))
|
||||
context.coordinator.continuationBodyStart = rendered.length
|
||||
rendered.append(NSAttributedString(
|
||||
string: continuationText.isEmpty ? String(repeating: "\n", count: 80) : continuationText,
|
||||
attributes: bodyAttributes
|
||||
))
|
||||
}
|
||||
context.coordinator.isApplyingContent = true
|
||||
view.attributedText = rendered
|
||||
context.coordinator.isApplyingContent = false
|
||||
context.coordinator.scroll(
|
||||
to: primaryContentChanged ? requestedPosition : visiblePosition,
|
||||
in: view
|
||||
)
|
||||
let generation = context.coordinator.layoutGeneration
|
||||
DispatchQueue.main.async { [weak coordinator = context.coordinator] in
|
||||
guard coordinator?.layoutGeneration == generation else { return }
|
||||
coordinator?.reportPage(in: view)
|
||||
coordinator?.reportPageRanges(in: view)
|
||||
}
|
||||
} else if viewportSizeChanged {
|
||||
context.coordinator.scroll(to: readerPosition, in: view)
|
||||
let generation = context.coordinator.layoutGeneration
|
||||
DispatchQueue.main.async { [weak coordinator = context.coordinator] in
|
||||
guard coordinator?.layoutGeneration == generation else { return }
|
||||
coordinator?.reportPage(in: view)
|
||||
coordinator?.reportPageRanges(in: view)
|
||||
}
|
||||
}
|
||||
if let jumpPosition, context.coordinator.lastJumpPosition != jumpPosition {
|
||||
context.coordinator.lastJumpPosition = jumpPosition
|
||||
context.coordinator.scroll(to: jumpPosition, in: view)
|
||||
} else if jumpPosition == nil {
|
||||
context.coordinator.lastJumpPosition = nil
|
||||
}
|
||||
context.coordinator.updateLoadingFooter(in: view, visible: isLoadingNextChapter)
|
||||
context.coordinator.updateAutoRead(
|
||||
isRunning: isAutoReading,
|
||||
isPaused: isAutoReadPaused,
|
||||
secondsPerScreen: secondsPerScreen
|
||||
)
|
||||
// SwiftUI can reset the bindings after the chapter's TextKit layout has
|
||||
// already completed. Reconcile them even when the text/style is unchanged.
|
||||
if view.bounds.width > 0, view.bounds.height > 0 {
|
||||
context.coordinator.reportPage(in: view)
|
||||
if pageRanges.isEmpty { context.coordinator.reportPageRanges(in: view) }
|
||||
}
|
||||
}
|
||||
|
||||
static func dismantleUIView(_ view: UITextView, coordinator: Coordinator) {
|
||||
coordinator.invalidate()
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, UITextViewDelegate, UIGestureRecognizerDelegate {
|
||||
var parent: ReaderTextView
|
||||
var contentKey = 0
|
||||
var primaryContentKey = 0
|
||||
var styleKey = ""
|
||||
var renderedPrefixLength = 0
|
||||
var currentSectionStart = 0
|
||||
var currentBodyEnd = 0
|
||||
var previousBodyStart: Int?
|
||||
var previousBodyEnd: Int?
|
||||
var continuationSectionStart: Int?
|
||||
var continuationBodyStart: Int?
|
||||
var lastJumpPosition: Int?
|
||||
private(set) var layoutGeneration = 0
|
||||
var lastViewportSize = CGSize.zero
|
||||
private var isAutoReading = false
|
||||
private var isAutoReadPaused = false
|
||||
private var secondsPerScreen = 10.0
|
||||
private weak var textView: UITextView?
|
||||
private var displayLink: CADisplayLink?
|
||||
private var lastTimestamp: CFTimeInterval = 0
|
||||
private var isUserDragging = false
|
||||
private var lastDragOffsetY: CGFloat = 0
|
||||
private var dragDirection = 0
|
||||
private var didReachEnd = false
|
||||
private var didReachStart = false
|
||||
private var didEnterPrevious = false
|
||||
private var didEnterContinuation = false
|
||||
private var pendingPreviousPosition: Int?
|
||||
private var pendingContinuationPosition: Int?
|
||||
var needsDeferredRender = false
|
||||
var isApplyingContent = false
|
||||
private var isTurningPage = false
|
||||
private var isRenderingAutoPage = false
|
||||
private var lastReportedPage: Int?
|
||||
private var autoPageProgress: CGFloat = 0
|
||||
private var eInkElapsed: CFTimeInterval = 0
|
||||
private var eInkPageInterval: CFTimeInterval = 10
|
||||
private var autoPageTarget: CGFloat?
|
||||
private weak var autoPageOverlay: UIImageView?
|
||||
private var pageRangeRetryCount = 0
|
||||
private weak var nextSwipe: UISwipeGestureRecognizer?
|
||||
private weak var previousSwipe: UISwipeGestureRecognizer?
|
||||
private weak var readerTap: UITapGestureRecognizer?
|
||||
private weak var loadingFooter: UIView?
|
||||
private var didRequestLoadingCancellation = false
|
||||
|
||||
init(parent: ReaderTextView) { self.parent = parent }
|
||||
|
||||
func installDisplayLink(for view: UITextView) {
|
||||
textView = view
|
||||
let link = CADisplayLink(target: self, selector: #selector(step(_:)))
|
||||
link.add(to: .main, forMode: .common)
|
||||
displayLink = link
|
||||
}
|
||||
|
||||
func installGestures(on view: UITextView) {
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
|
||||
tap.delaysTouchesBegan = false
|
||||
tap.cancelsTouchesInView = false
|
||||
tap.delegate = self
|
||||
view.addGestureRecognizer(tap)
|
||||
readerTap = tap
|
||||
let next = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
|
||||
next.direction = .left
|
||||
view.addGestureRecognizer(next)
|
||||
let previous = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
|
||||
previous.direction = .right
|
||||
view.addGestureRecognizer(previous)
|
||||
nextSwipe = next
|
||||
previousSwipe = previous
|
||||
}
|
||||
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
gestureRecognizer === readerTap || otherGestureRecognizer === readerTap
|
||||
}
|
||||
|
||||
func updateInteractionMode(on view: UITextView, isScrollMode: Bool) {
|
||||
// The scroll reader owns the vertical pan gesture. Horizontal
|
||||
// swipe recognizers must be disabled there or UIKit can cancel the
|
||||
// pan before the user reaches the end of a long chapter.
|
||||
nextSwipe?.isEnabled = !isScrollMode
|
||||
previousSwipe?.isEnabled = !isScrollMode
|
||||
view.panGestureRecognizer.isEnabled = isScrollMode
|
||||
view.alwaysBounceVertical = isScrollMode
|
||||
view.isPagingEnabled = !isScrollMode
|
||||
view.showsVerticalScrollIndicator = false
|
||||
}
|
||||
|
||||
func updateLoadingFooter(in view: UITextView, visible: Bool) {
|
||||
if !visible {
|
||||
loadingFooter?.removeFromSuperview()
|
||||
loadingFooter = nil
|
||||
view.contentInset.bottom = 0
|
||||
didRequestLoadingCancellation = false
|
||||
return
|
||||
}
|
||||
let footer: UIView
|
||||
if let loadingFooter {
|
||||
footer = loadingFooter
|
||||
} else {
|
||||
let container = UIView()
|
||||
container.isUserInteractionEnabled = false
|
||||
let indicator = UIActivityIndicatorView(style: .medium)
|
||||
indicator.color = parent.foregroundColor.withAlphaComponent(0.65)
|
||||
indicator.startAnimating()
|
||||
let label = UILabel()
|
||||
label.text = "正在加载下一章…"
|
||||
label.textColor = parent.foregroundColor.withAlphaComponent(0.65)
|
||||
label.font = .systemFont(ofSize: 13)
|
||||
let stack = UIStackView(arrangedSubviews: [indicator, label])
|
||||
stack.axis = .horizontal
|
||||
stack.spacing = 8
|
||||
stack.alignment = .center
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
||||
stack.centerYAnchor.constraint(equalTo: container.centerYAnchor)
|
||||
])
|
||||
view.addSubview(container)
|
||||
loadingFooter = container
|
||||
footer = container
|
||||
}
|
||||
view.contentInset.bottom = 64
|
||||
footer.frame = CGRect(x: 0, y: view.contentSize.height, width: view.bounds.width, height: 52)
|
||||
}
|
||||
|
||||
func shouldDeferRender(in view: UITextView) -> Bool {
|
||||
parent.pageAnimation == 3 && (isUserDragging || view.isDragging || view.isDecelerating)
|
||||
}
|
||||
|
||||
func invalidate() {
|
||||
layoutGeneration &+= 1
|
||||
resetAutoPage()
|
||||
displayLink?.invalidate()
|
||||
displayLink = nil
|
||||
}
|
||||
|
||||
func updateAutoRead(isRunning: Bool, isPaused: Bool, secondsPerScreen: Double) {
|
||||
let wasRunning = isAutoReading
|
||||
let wasPaused = isAutoReadPaused
|
||||
self.isAutoReading = isRunning
|
||||
isAutoReadPaused = isPaused
|
||||
self.secondsPerScreen = max(1, secondsPerScreen)
|
||||
if wasRunning && !isRunning {
|
||||
resetAutoPage()
|
||||
} else if !wasRunning && isRunning {
|
||||
didReachEnd = false
|
||||
lastTimestamp = 0
|
||||
eInkPageInterval = self.secondsPerScreen
|
||||
} else if isPaused || wasPaused != isPaused {
|
||||
lastTimestamp = 0
|
||||
}
|
||||
}
|
||||
|
||||
func resetAutoReadForPageChange() {
|
||||
didReachEnd = false
|
||||
didReachStart = false
|
||||
lastReportedPage = nil
|
||||
didEnterPrevious = false
|
||||
didEnterContinuation = false
|
||||
pendingPreviousPosition = nil
|
||||
pendingContinuationPosition = nil
|
||||
resetAutoPage()
|
||||
}
|
||||
|
||||
func scroll(to characterPosition: Int, in view: UITextView) {
|
||||
guard !view.text.isEmpty else { return }
|
||||
layoutGeneration &+= 1
|
||||
let generation = layoutGeneration
|
||||
let prefix = characterPosition == 0 && parent.pageAnimation == 3
|
||||
? currentSectionStart
|
||||
: renderedPrefixLength
|
||||
let location = ReaderPositionResolver.renderedLocation(
|
||||
position: characterPosition,
|
||||
prefixLength: prefix,
|
||||
renderedLength: view.text.utf16.count
|
||||
)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
view.layoutManager.ensureLayout(for: view.textContainer)
|
||||
let glyph = view.layoutManager.glyphIndexForCharacter(at: location)
|
||||
var point = view.layoutManager.boundingRect(
|
||||
forGlyphRange: NSRange(location: glyph, length: 1),
|
||||
in: view.textContainer
|
||||
).origin
|
||||
point.y += view.textContainerInset.top
|
||||
let limits = self.scrollLimits(for: view)
|
||||
let target = min(
|
||||
max(point.y - view.adjustedContentInset.top, limits.minimum),
|
||||
limits.maximum
|
||||
)
|
||||
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func readingViewportHeight(for view: UITextView) -> CGFloat {
|
||||
max(
|
||||
view.bounds.height
|
||||
- view.adjustedContentInset.top
|
||||
- view.adjustedContentInset.bottom
|
||||
- view.textContainerInset.top
|
||||
- view.textContainerInset.bottom,
|
||||
1
|
||||
)
|
||||
}
|
||||
|
||||
private func scrollLimits(for view: UITextView) -> (minimum: CGFloat, maximum: CGFloat) {
|
||||
let minimum = -view.adjustedContentInset.top
|
||||
let maximum = max(
|
||||
view.contentSize.height - view.bounds.height + view.adjustedContentInset.bottom,
|
||||
minimum
|
||||
)
|
||||
return (minimum, maximum)
|
||||
}
|
||||
|
||||
private func pageMetrics(for view: UITextView) -> (current: Int, total: Int) {
|
||||
let pageHeight = readingViewportHeight(for: view)
|
||||
if parent.pageAnimation == 3, currentBodyEnd > currentSectionStart {
|
||||
view.layoutManager.ensureLayout(for: view.textContainer)
|
||||
let glyphCount = view.layoutManager.numberOfGlyphs
|
||||
if glyphCount > 0 {
|
||||
let first = min(view.layoutManager.glyphIndexForCharacter(at: currentSectionStart), glyphCount - 1)
|
||||
let last = min(view.layoutManager.glyphIndexForCharacter(at: currentBodyEnd - 1), glyphCount - 1)
|
||||
let start = view.layoutManager.boundingRect(
|
||||
forGlyphRange: NSRange(location: first, length: 1), in: view.textContainer
|
||||
).minY + view.textContainerInset.top - view.adjustedContentInset.top
|
||||
let end = view.layoutManager.boundingRect(
|
||||
forGlyphRange: NSRange(location: last, length: 1), in: view.textContainer
|
||||
).maxY + view.textContainerInset.top - view.adjustedContentInset.top
|
||||
let total = max(Int(ceil((end - start) / pageHeight)), 1)
|
||||
let position = max(view.contentOffset.y - start, 0)
|
||||
return (min(Int(floor(position / pageHeight)) + 1, total), total)
|
||||
}
|
||||
}
|
||||
let limits = scrollLimits(for: view)
|
||||
let scrollRange = max(limits.maximum - limits.minimum, 0)
|
||||
let total = max(Int(ceil(scrollRange / pageHeight)) + 1, 1)
|
||||
guard total > 1 else { return (1, 1) }
|
||||
let position = min(max(view.contentOffset.y - limits.minimum, 0), scrollRange)
|
||||
let current = position >= scrollRange - 0.5
|
||||
? total
|
||||
: min(Int(floor(position / pageHeight)) + 1, total)
|
||||
return (current, total)
|
||||
}
|
||||
|
||||
private func renderedCharacter(at offset: CGPoint, in view: UITextView) -> Int {
|
||||
guard !view.text.isEmpty else { return 0 }
|
||||
let point = CGPoint(
|
||||
x: view.textContainerInset.left + 1,
|
||||
y: offset.y + view.textContainerInset.top + 1
|
||||
)
|
||||
let glyphCount = view.layoutManager.numberOfGlyphs
|
||||
guard glyphCount > 0 else { return 0 }
|
||||
let glyph = min(view.layoutManager.glyphIndex(for: point, in: view.textContainer), glyphCount - 1)
|
||||
let character = view.layoutManager.characterIndexForGlyph(at: glyph)
|
||||
return min(max(character, 0), view.text.utf16.count)
|
||||
}
|
||||
|
||||
func contentCharacter(at offset: CGPoint, in view: UITextView) -> Int {
|
||||
let character = renderedCharacter(at: offset, in: view)
|
||||
return min(max(character - renderedPrefixLength, 0), parent.text.utf16.count)
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard !isRenderingAutoPage, !isApplyingContent else { return }
|
||||
guard let view = scrollView as? UITextView, !view.text.isEmpty else { return }
|
||||
if parent.isLoadingNextChapter, !didRequestLoadingCancellation {
|
||||
let textBottom = max(
|
||||
view.contentSize.height - view.bounds.height
|
||||
+ view.adjustedContentInset.bottom - view.contentInset.bottom,
|
||||
-view.adjustedContentInset.top
|
||||
)
|
||||
if view.contentOffset.y < textBottom - 44 {
|
||||
didRequestLoadingCancellation = true
|
||||
DispatchQueue.main.async { [weak self] in self?.parent.onCancelNextLoading() }
|
||||
}
|
||||
}
|
||||
if isUserDragging {
|
||||
let delta = scrollView.contentOffset.y - lastDragOffsetY
|
||||
if abs(delta) > 0.5 { dragDirection = delta > 0 ? 1 : -1 }
|
||||
lastDragOffsetY = scrollView.contentOffset.y
|
||||
}
|
||||
let metrics = pageMetrics(for: view)
|
||||
reportPage(in: view)
|
||||
if let lastReportedPage, lastReportedPage != metrics.current {
|
||||
self.lastReportedPage = metrics.current
|
||||
let generation = layoutGeneration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.parent.onPageChanged()
|
||||
}
|
||||
} else if lastReportedPage == nil {
|
||||
lastReportedPage = metrics.current
|
||||
}
|
||||
let contentCharacter = contentCharacter(at: scrollView.contentOffset, in: view)
|
||||
let renderedCharacter = renderedCharacter(at: scrollView.contentOffset, in: view)
|
||||
if let previousBodyStart,
|
||||
let previousBodyEnd,
|
||||
renderedCharacter < currentSectionStart {
|
||||
pendingContinuationPosition = nil
|
||||
let position = min(max(renderedCharacter - previousBodyStart, 0), max(previousBodyEnd - previousBodyStart - 1, 0))
|
||||
if isUserDragging || view.isDecelerating {
|
||||
pendingPreviousPosition = position
|
||||
return
|
||||
}
|
||||
guard !didEnterPrevious else { return }
|
||||
didEnterPrevious = true
|
||||
let generation = layoutGeneration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.parent.onEnteredPrevious(position)
|
||||
}
|
||||
return
|
||||
}
|
||||
if let continuationSectionStart,
|
||||
let continuationBodyStart,
|
||||
renderedCharacter >= continuationSectionStart {
|
||||
pendingPreviousPosition = nil
|
||||
let position = max(renderedCharacter - continuationBodyStart, 0)
|
||||
if isUserDragging || view.isDecelerating {
|
||||
pendingContinuationPosition = position
|
||||
return
|
||||
}
|
||||
guard !didEnterContinuation else { return }
|
||||
didEnterContinuation = true
|
||||
let generation = layoutGeneration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.parent.onEnteredContinuation(position)
|
||||
}
|
||||
return
|
||||
}
|
||||
pendingPreviousPosition = nil
|
||||
pendingContinuationPosition = nil
|
||||
guard contentCharacter != parent.readerPosition else { return }
|
||||
let generation = layoutGeneration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.parent.readerPosition = contentCharacter
|
||||
}
|
||||
}
|
||||
|
||||
func reportPage(in view: UITextView) {
|
||||
guard view.bounds.width > 0, view.bounds.height > 0 else { return }
|
||||
let metrics = pageMetrics(for: view)
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("-uiPagingDiagnostics") {
|
||||
view.accessibilityLabel = "bounds=\(view.bounds.size) content=\(view.contentSize) used=\(view.layoutManager.usedRect(for: view.textContainer)) mode=\(parent.pageAnimation) pages=\(metrics.total)"
|
||||
}
|
||||
#endif
|
||||
guard metrics.current != parent.currentPage || metrics.total != parent.totalPages else { return }
|
||||
let generation = layoutGeneration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.parent.currentPage = metrics.current
|
||||
self.parent.totalPages = metrics.total
|
||||
}
|
||||
}
|
||||
|
||||
func reportPageRanges(in view: UITextView) {
|
||||
guard view.bounds.width > 0, view.bounds.height > 0 else { return }
|
||||
if parent.pageAnimation == 3 {
|
||||
let ranges = parent.text.isEmpty
|
||||
? []
|
||||
: [NSRange(location: 0, length: parent.text.utf16.count)]
|
||||
guard ranges != parent.pageRanges else { return }
|
||||
DispatchQueue.main.async { [weak self] in self?.parent.pageRanges = ranges }
|
||||
return
|
||||
}
|
||||
guard !view.text.isEmpty else { return }
|
||||
view.layoutManager.ensureLayout(for: view.textContainer)
|
||||
let pageHeight = readingViewportHeight(for: view)
|
||||
let limits = scrollLimits(for: view)
|
||||
let metrics = pageMetrics(for: view)
|
||||
var ranges: [NSRange] = []
|
||||
for page in 0..<metrics.total {
|
||||
let offset = page == metrics.total - 1
|
||||
? limits.maximum
|
||||
: min(limits.minimum + CGFloat(page) * pageHeight, limits.maximum)
|
||||
let rect = CGRect(
|
||||
x: 0,
|
||||
y: max(0, offset + view.textContainerInset.top),
|
||||
width: max(view.textContainer.size.width, 1),
|
||||
height: pageHeight
|
||||
)
|
||||
let glyphRange = view.layoutManager.glyphRange(
|
||||
forBoundingRect: rect,
|
||||
in: view.textContainer
|
||||
)
|
||||
guard glyphRange.length > 0 else { continue }
|
||||
let characterRange = view.layoutManager.characterRange(
|
||||
forGlyphRange: glyphRange,
|
||||
actualGlyphRange: nil
|
||||
)
|
||||
let start = max(characterRange.location - renderedPrefixLength, 0)
|
||||
let end = min(
|
||||
characterRange.location + characterRange.length - renderedPrefixLength,
|
||||
parent.text.utf16.count
|
||||
)
|
||||
guard end > start else { continue }
|
||||
let range = NSRange(location: start, length: end - start)
|
||||
if ranges.last?.location == range.location,
|
||||
ranges.last?.length == range.length { continue }
|
||||
ranges.append(range)
|
||||
}
|
||||
if ranges.isEmpty {
|
||||
guard pageRangeRetryCount < 10 else { return }
|
||||
pageRangeRetryCount += 1
|
||||
let generation = layoutGeneration
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.reportPageRanges(in: view)
|
||||
}
|
||||
return
|
||||
}
|
||||
pageRangeRetryCount = 0
|
||||
guard ranges != parent.pageRanges else { return }
|
||||
let generation = layoutGeneration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.parent.pageRanges = ranges
|
||||
}
|
||||
}
|
||||
|
||||
func resetPageRangeRetry() {
|
||||
pageRangeRetryCount = 0
|
||||
}
|
||||
|
||||
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||
isUserDragging = true
|
||||
lastDragOffsetY = scrollView.contentOffset.y
|
||||
dragDirection = 0
|
||||
didReachEnd = false
|
||||
didReachStart = false
|
||||
}
|
||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
if parent.pageAnimation == 3, let view = scrollView as? UITextView {
|
||||
triggerChapterBoundaryIfNeeded(in: view)
|
||||
}
|
||||
if !decelerate {
|
||||
isUserDragging = false
|
||||
flushPendingChapterTransition()
|
||||
requestDeferredRenderIfNeeded()
|
||||
}
|
||||
}
|
||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
if parent.pageAnimation == 3, let view = scrollView as? UITextView {
|
||||
triggerChapterBoundaryIfNeeded(in: view)
|
||||
}
|
||||
isUserDragging = false
|
||||
flushPendingChapterTransition()
|
||||
requestDeferredRenderIfNeeded()
|
||||
}
|
||||
|
||||
private func requestDeferredRenderIfNeeded() {
|
||||
guard needsDeferredRender else { return }
|
||||
needsDeferredRender = false
|
||||
DispatchQueue.main.async { [weak self] in self?.parent.onDeferredRender() }
|
||||
}
|
||||
|
||||
private func flushPendingChapterTransition() {
|
||||
let previous = pendingPreviousPosition
|
||||
let continuation = pendingContinuationPosition
|
||||
pendingPreviousPosition = nil
|
||||
pendingContinuationPosition = nil
|
||||
let generation = layoutGeneration
|
||||
if let previous, !didEnterPrevious {
|
||||
didEnterPrevious = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.parent.onEnteredPrevious(previous)
|
||||
}
|
||||
} else if let continuation, !didEnterContinuation {
|
||||
didEnterContinuation = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.layoutGeneration == generation else { return }
|
||||
self.parent.onEnteredContinuation(continuation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerChapterBoundaryIfNeeded(in view: UITextView) {
|
||||
let (minimum, maximum) = scrollLimits(for: view)
|
||||
let atEnd = view.contentOffset.y >= maximum - 0.5
|
||||
let atStart = view.contentOffset.y <= minimum + 0.5
|
||||
if atEnd, dragDirection > 0, continuationSectionStart == nil, !didReachEnd {
|
||||
didReachEnd = true
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
|
||||
} else if atStart, dragDirection < 0, previousBodyStart == nil, !didReachStart {
|
||||
didReachStart = true
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
parent.onReachedStart(contentCharacter(at: view.contentOffset, in: view))
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func tapped(_ gesture: UITapGestureRecognizer) {
|
||||
guard let view = textView, gesture.state == .ended else { return }
|
||||
let point = gesture.location(in: view)
|
||||
// UIScrollView changes its bounds origin as content scrolls, and
|
||||
// gesture locations use that content coordinate space. Convert
|
||||
// back to viewport coordinates before dividing the visible screen
|
||||
// into the 3×3 tap grid.
|
||||
let viewportX = point.x - view.bounds.minX
|
||||
let viewportY = point.y - view.bounds.minY
|
||||
let column = min(max(Int(viewportX / max(view.bounds.width / 3, 1)), 0), 2)
|
||||
let row = min(max(Int(viewportY / max(view.bounds.height / 3, 1)), 0), 2)
|
||||
let index = row * 3 + column
|
||||
let action = index == 4
|
||||
? 0
|
||||
: (parent.clickActions.indices.contains(index) ? parent.clickActions[index] : 0)
|
||||
|
||||
if parent.pageAnimation == 3, action == 1 || action == 2 {
|
||||
return
|
||||
}
|
||||
switch action {
|
||||
case 1: turnPage(1)
|
||||
case 2: turnPage(-1)
|
||||
case -1: break
|
||||
default: parent.onTapAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func swiped(_ gesture: UISwipeGestureRecognizer) {
|
||||
turnPage(gesture.direction == .left ? 1 : -1)
|
||||
}
|
||||
|
||||
private func turnPage(_ direction: Int) {
|
||||
guard !isTurningPage, let view = textView else { return }
|
||||
if isAutoReading { resetAutoReadForPageChange() }
|
||||
let pageHeight = readingViewportHeight(for: view)
|
||||
let (minimum, maximum) = scrollLimits(for: view)
|
||||
let target = min(max(view.contentOffset.y + CGFloat(direction) * pageHeight, minimum), maximum)
|
||||
if direction < 0, view.contentOffset.y <= minimum + 0.5 {
|
||||
parent.onReachedStart(contentCharacter(at: view.contentOffset, in: view))
|
||||
return
|
||||
}
|
||||
if direction > 0, view.contentOffset.y >= maximum - 0.5 {
|
||||
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
|
||||
return
|
||||
}
|
||||
isTurningPage = true
|
||||
let completion: (Bool) -> Void = { [weak self] _ in self?.isTurningPage = false }
|
||||
switch parent.isEInkMode ? 4 : parent.pageAnimation {
|
||||
case 4:
|
||||
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
|
||||
isTurningPage = false
|
||||
case 0:
|
||||
let transition = CATransition()
|
||||
transition.duration = 0.3
|
||||
transition.type = direction > 0 ? .reveal : .moveIn
|
||||
transition.subtype = direction > 0 ? .fromRight : .fromLeft
|
||||
view.layer.add(transition, forKey: "reader-cover")
|
||||
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.31) { [weak self] in self?.isTurningPage = false }
|
||||
case 1:
|
||||
let transition = CATransition()
|
||||
transition.duration = 0.3
|
||||
transition.type = .push
|
||||
transition.subtype = direction > 0 ? .fromRight : .fromLeft
|
||||
view.layer.add(transition, forKey: "reader-slide")
|
||||
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.31) { [weak self] in self?.isTurningPage = false }
|
||||
case 2:
|
||||
UIView.transition(
|
||||
with: view,
|
||||
duration: 0.38,
|
||||
options: direction > 0 ? .transitionCurlUp : .transitionCurlDown,
|
||||
animations: { view.setContentOffset(CGPoint(x: 0, y: target), animated: false) },
|
||||
completion: completion
|
||||
)
|
||||
default:
|
||||
view.setContentOffset(CGPoint(x: 0, y: target), animated: true)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) { [weak self] in self?.isTurningPage = false }
|
||||
}
|
||||
}
|
||||
|
||||
private func resetAutoPage() {
|
||||
autoPageProgress = 0
|
||||
eInkElapsed = 0
|
||||
eInkPageInterval = secondsPerScreen
|
||||
autoPageTarget = nil
|
||||
autoPageOverlay?.removeFromSuperview()
|
||||
autoPageOverlay = nil
|
||||
lastTimestamp = 0
|
||||
}
|
||||
|
||||
private func prepareAutoPage(in view: UITextView) -> Bool {
|
||||
let pageHeight = readingViewportHeight(for: view)
|
||||
let maximum = scrollLimits(for: view).maximum
|
||||
guard view.contentOffset.y < maximum - 0.5 else { return false }
|
||||
let target = min(view.contentOffset.y + pageHeight, maximum)
|
||||
let originalOffset = view.contentOffset
|
||||
isRenderingAutoPage = true
|
||||
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
|
||||
view.layoutIfNeeded()
|
||||
let renderer = UIGraphicsImageRenderer(bounds: view.bounds)
|
||||
let image = renderer.image { context in view.layer.render(in: context.cgContext) }
|
||||
view.setContentOffset(originalOffset, animated: false)
|
||||
isRenderingAutoPage = false
|
||||
|
||||
let overlay = UIImageView(image: image)
|
||||
overlay.isUserInteractionEnabled = false
|
||||
overlay.contentMode = .top
|
||||
overlay.clipsToBounds = true
|
||||
overlay.layer.borderColor = view.tintColor.cgColor
|
||||
overlay.layer.borderWidth = 0
|
||||
overlay.frame = CGRect(x: 0, y: originalOffset.y, width: view.bounds.width, height: 0)
|
||||
view.addSubview(overlay)
|
||||
autoPageOverlay = overlay
|
||||
autoPageTarget = target
|
||||
return true
|
||||
}
|
||||
|
||||
private func advancePagedAutoRead(in view: UITextView, elapsed: CFTimeInterval) {
|
||||
if autoPageOverlay == nil, !prepareAutoPage(in: view) {
|
||||
guard !didReachEnd else { return }
|
||||
didReachEnd = true
|
||||
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
|
||||
return
|
||||
}
|
||||
didReachEnd = false
|
||||
let pageHeight = readingViewportHeight(for: view)
|
||||
autoPageProgress += pageHeight / CGFloat(secondsPerScreen) * CGFloat(elapsed)
|
||||
let revealedHeight = min(autoPageProgress, pageHeight)
|
||||
if let overlay = autoPageOverlay {
|
||||
overlay.frame = CGRect(
|
||||
x: 0,
|
||||
y: view.contentOffset.y,
|
||||
width: view.bounds.width,
|
||||
height: revealedHeight
|
||||
)
|
||||
overlay.layer.borderWidth = revealedHeight > 0 ? 0.5 : 0
|
||||
}
|
||||
guard autoPageProgress >= pageHeight, let target = autoPageTarget else { return }
|
||||
autoPageOverlay?.removeFromSuperview()
|
||||
autoPageOverlay = nil
|
||||
autoPageTarget = nil
|
||||
autoPageProgress = 0
|
||||
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
|
||||
}
|
||||
|
||||
private func advanceEInkAutoRead(in view: UITextView, elapsed: CFTimeInterval) {
|
||||
eInkElapsed += elapsed
|
||||
guard eInkElapsed >= eInkPageInterval else { return }
|
||||
eInkElapsed = 0
|
||||
eInkPageInterval = secondsPerScreen
|
||||
let pageHeight = readingViewportHeight(for: view)
|
||||
let maximum = scrollLimits(for: view).maximum
|
||||
guard view.contentOffset.y < maximum - 0.5 else {
|
||||
guard !didReachEnd else { return }
|
||||
didReachEnd = true
|
||||
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
|
||||
return
|
||||
}
|
||||
didReachEnd = false
|
||||
let target = min(view.contentOffset.y + pageHeight, maximum)
|
||||
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
|
||||
}
|
||||
|
||||
@objc private func step(_ link: CADisplayLink) {
|
||||
guard isAutoReading, !isAutoReadPaused, !isUserDragging, let view = textView else {
|
||||
lastTimestamp = link.timestamp
|
||||
return
|
||||
}
|
||||
guard lastTimestamp > 0 else {
|
||||
lastTimestamp = link.timestamp
|
||||
return
|
||||
}
|
||||
let elapsed = link.timestamp - lastTimestamp
|
||||
lastTimestamp = link.timestamp
|
||||
if parent.isEInkMode {
|
||||
advanceEInkAutoRead(in: view, elapsed: elapsed)
|
||||
return
|
||||
}
|
||||
if parent.pageAnimation != 3 {
|
||||
advancePagedAutoRead(in: view, elapsed: elapsed)
|
||||
return
|
||||
}
|
||||
let maximum = scrollLimits(for: view).maximum
|
||||
let next = view.contentOffset.y
|
||||
+ readingViewportHeight(for: view) / CGFloat(secondsPerScreen) * CGFloat(elapsed)
|
||||
if next >= maximum - 0.5 {
|
||||
view.setContentOffset(CGPoint(x: 0, y: maximum), animated: false)
|
||||
guard !didReachEnd else { return }
|
||||
didReachEnd = true
|
||||
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
|
||||
} else {
|
||||
didReachEnd = false
|
||||
view.setContentOffset(CGPoint(x: 0, y: next), animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,611 @@
|
||||
import SwiftUI
|
||||
#if SWIFT_PACKAGE
|
||||
import LegadoCore
|
||||
#endif
|
||||
|
||||
struct ReaderParagraph: Identifiable, Equatable {
|
||||
let id: Int
|
||||
let text: String
|
||||
}
|
||||
|
||||
struct ReaderScrollChapter: Identifiable, Equatable {
|
||||
let id: Int
|
||||
let title: String
|
||||
let content: String
|
||||
var error: String? = nil
|
||||
}
|
||||
|
||||
enum ReaderPositionResolver {
|
||||
static func renderedLocation(position: Int, prefixLength: Int, renderedLength: Int) -> Int {
|
||||
let lastLocation = max(renderedLength, 1) - 1
|
||||
let prefix = min(max(prefixLength, 0), lastLocation)
|
||||
return prefix + min(max(position, 0), lastLocation - prefix)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ReaderViewModel: ObservableObject {
|
||||
@Published private(set) var isLoading = false
|
||||
@Published private(set) var title: String
|
||||
@Published private(set) var content = ""
|
||||
@Published private(set) var diagnostics: [RuleDiagnostic] = []
|
||||
@Published private(set) var currentIndex: Int
|
||||
@Published private(set) var chapterLoadGeneration = 0
|
||||
@Published private(set) var paragraphs: [ReaderParagraph] = []
|
||||
@Published private(set) var requestedPosition: Int
|
||||
@Published private(set) var loadedFromCache = false
|
||||
@Published private(set) var hasLoadedCurrentChapter = false
|
||||
@Published private(set) var previousTitle = ""
|
||||
@Published private(set) var previousContent = ""
|
||||
@Published private(set) var continuationTitle = ""
|
||||
@Published private(set) var continuationContent = ""
|
||||
@Published private(set) var isPreviousWindowReady = false
|
||||
@Published private(set) var isContinuationWindowReady = false
|
||||
@Published private(set) var scrollChapters: [ReaderScrollChapter] = []
|
||||
@Published private(set) var scrollRevision = 0
|
||||
@Published private(set) var isLoadingScrollAhead = false
|
||||
@Published private(set) var isLoadingScrollBehind = false
|
||||
|
||||
@Published private(set) var book: Book
|
||||
private(set) var chapters: [BookChapter]
|
||||
private var source: BookSource?
|
||||
private let localService: LocalBookService
|
||||
private let sanitizer = ContentSanitizer()
|
||||
private let contentCoordinator: ChapterContentCoordinator
|
||||
private var demoChapterContent: String?
|
||||
private var previousLoadedIndex: Int?
|
||||
private var continuationLoadedIndex: Int?
|
||||
@Published private(set) var scrollRetries: Set<Int> = []
|
||||
private var sourceCacheNeedsInvalidation = false
|
||||
private var pendingSourceContent: ChapterContentReport?
|
||||
|
||||
func prepareContinuousReading(rules: [ReplaceRule]) async {
|
||||
guard hasLoadedCurrentChapter else { return }
|
||||
if scrollChapters.isEmpty {
|
||||
scrollChapters = [scrollChapterForCurrentContent()]
|
||||
}
|
||||
async let ahead: Void = extendContinuousReading(direction: 1, rules: rules)
|
||||
async let behind: Void = extendContinuousReading(direction: -1, rules: rules)
|
||||
_ = await (ahead, behind)
|
||||
}
|
||||
|
||||
func extendContinuousReading(direction: Int, rules: [ReplaceRule]) async {
|
||||
let initialEdge = direction > 0 ? scrollChapters.last?.id : scrollChapters.first?.id
|
||||
guard let initialEdge, readableIndex(from: initialEdge, direction: direction) != nil else { return }
|
||||
if direction > 0 {
|
||||
guard !isLoadingScrollAhead else { return }
|
||||
isLoadingScrollAhead = true
|
||||
} else {
|
||||
guard !isLoadingScrollBehind else { return }
|
||||
isLoadingScrollBehind = true
|
||||
}
|
||||
let revision = scrollRevision
|
||||
defer {
|
||||
if revision == scrollRevision {
|
||||
if direction > 0 { isLoadingScrollAhead = false }
|
||||
else { isLoadingScrollBehind = false }
|
||||
}
|
||||
}
|
||||
for _ in 0..<2 {
|
||||
let edge = direction > 0 ? scrollChapters.last?.id : scrollChapters.first?.id
|
||||
guard let edge, let index = readableIndex(from: edge, direction: direction) else { return }
|
||||
let value = await loadAdjacentChapter(chapters[index], rules: rules)
|
||||
guard revision == scrollRevision, !Task.isCancelled else { return }
|
||||
// The viewport may have recycled this edge while the request was
|
||||
// in flight. Installing its result now would create a chapter gap.
|
||||
guard scrollChapters.contains(where: { $0.id == edge }) else { return }
|
||||
let chapter = ReaderScrollChapter(
|
||||
id: index, title: value.title, content: value.content, error: value.error
|
||||
)
|
||||
if !scrollChapters.contains(where: { $0.id == index }) {
|
||||
scrollChapters.append(chapter)
|
||||
scrollChapters.sort { $0.id < $1.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func retryContinuousChapter(index: Int, rules: [ReplaceRule]) async {
|
||||
guard chapters.indices.contains(index), !scrollRetries.contains(index) else { return }
|
||||
let revision = scrollRevision
|
||||
scrollRetries.insert(index)
|
||||
defer { if revision == scrollRevision { scrollRetries.remove(index) } }
|
||||
let value = await loadAdjacentChapter(chapters[index], rules: rules, forceRefresh: true)
|
||||
guard revision == scrollRevision, !Task.isCancelled,
|
||||
let slot = scrollChapters.firstIndex(where: { $0.id == index }) else { return }
|
||||
scrollChapters[slot] = .init(id: index, title: value.title, content: value.content, error: value.error)
|
||||
if currentIndex == index { setContinuousChapter(scrollChapters[slot]) }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateContinuousPosition(chapter index: Int) -> Bool {
|
||||
guard index != currentIndex,
|
||||
let chapter = scrollChapters.first(where: { $0.id == index }) else { return false }
|
||||
setContinuousChapter(chapter)
|
||||
return true
|
||||
}
|
||||
|
||||
func retainContinuousChapters(_ indices: Set<Int>) {
|
||||
guard indices.contains(currentIndex) else { return }
|
||||
scrollChapters.removeAll { !indices.contains($0.id) }
|
||||
}
|
||||
|
||||
private func setContinuousChapter(_ chapter: ReaderScrollChapter) {
|
||||
currentIndex = chapter.id
|
||||
title = chapter.title
|
||||
content = chapter.content
|
||||
paragraphs = Self.makeParagraphs(chapter.content)
|
||||
diagnostics = []
|
||||
hasLoadedCurrentChapter = true
|
||||
}
|
||||
|
||||
private func scrollChapterForCurrentContent() -> ReaderScrollChapter {
|
||||
.init(id: currentIndex, title: title, content: content,
|
||||
error: currentChapterError)
|
||||
}
|
||||
|
||||
private func clearContinuousReading() {
|
||||
scrollRevision &+= 1
|
||||
scrollChapters = []
|
||||
isLoadingScrollAhead = false
|
||||
isLoadingScrollBehind = false
|
||||
scrollRetries = []
|
||||
}
|
||||
|
||||
init(
|
||||
book: Book,
|
||||
chapters: [BookChapter],
|
||||
initialIndex: Int,
|
||||
source: BookSource,
|
||||
service: any ChapterContentLoading = NetworkBookService(),
|
||||
cache: ChapterContentCache = .live()
|
||||
) {
|
||||
let resolvedIndex = Self.validIndex(initialIndex, chapters: chapters)
|
||||
self.book = book
|
||||
self.chapters = chapters
|
||||
currentIndex = resolvedIndex
|
||||
self.source = source
|
||||
localService = .init()
|
||||
contentCoordinator = .init(loader: service, cache: cache)
|
||||
requestedPosition = resolvedIndex == book.durChapterIndex ? max(book.durChapterPos, 0) : 0
|
||||
title = chapters.indices.contains(resolvedIndex) ? chapters[resolvedIndex].title : book.name
|
||||
}
|
||||
|
||||
convenience init(demoBook book: Book, chapters: [BookChapter], initialIndex: Int, content: String) {
|
||||
self.init(book: book, chapters: chapters, initialIndex: initialIndex)
|
||||
self.content = content
|
||||
demoChapterContent = content
|
||||
paragraphs = Self.makeParagraphs(content)
|
||||
hasLoadedCurrentChapter = true
|
||||
}
|
||||
|
||||
init(
|
||||
book: Book,
|
||||
chapters: [BookChapter],
|
||||
initialIndex: Int,
|
||||
localService: LocalBookService = .init(),
|
||||
cache: ChapterContentCache = .live()
|
||||
) {
|
||||
let resolvedIndex = Self.validIndex(initialIndex, chapters: chapters)
|
||||
self.book = book
|
||||
self.chapters = chapters
|
||||
currentIndex = resolvedIndex
|
||||
source = nil
|
||||
self.localService = localService
|
||||
contentCoordinator = .init(cache: cache)
|
||||
requestedPosition = resolvedIndex == book.durChapterIndex ? max(book.durChapterPos, 0) : 0
|
||||
title = chapters.indices.contains(resolvedIndex) ? chapters[resolvedIndex].title : book.name
|
||||
}
|
||||
|
||||
var currentChapter: BookChapter? { chapters.indices.contains(currentIndex) ? chapters[currentIndex] : nil }
|
||||
var nextChapter: BookChapter? { nextReadableChapter(after: currentIndex) }
|
||||
var canGoPrevious: Bool { navigationIndex(direction: -1) != nil }
|
||||
var canGoNext: Bool { navigationIndex(direction: 1) != nil }
|
||||
var hasPreviousWindow: Bool { previousLoadedIndex != nil }
|
||||
var hasContinuationWindow: Bool { continuationLoadedIndex != nil }
|
||||
var canEditContent: Bool { source != nil }
|
||||
var usesReplaceRules: Bool { book.readConfig?.useReplaceRule ?? true }
|
||||
var currentChapterError: String? {
|
||||
guard hasLoadedCurrentChapter, !isLoading,
|
||||
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
currentChapter?.isVolume != true else { return nil }
|
||||
return scrollChapters.first(where: { $0.id == currentIndex })?.error
|
||||
?? diagnostics.first(where: { $0.severity == .error })?.message
|
||||
?? (source != nil ? "当前书源没有返回正文。" : nil)
|
||||
}
|
||||
|
||||
func bookForSourceSwitch(position: Int) -> Book {
|
||||
var current = book
|
||||
current.durChapterIndex = currentIndex
|
||||
current.durChapterTitle = currentChapter?.title
|
||||
current.durChapterPos = max(position, 0)
|
||||
current.totalChapterNum = chapters.count
|
||||
return current
|
||||
}
|
||||
|
||||
func applySourceSwitch(_ report: BookSourceSwitchReport, source: BookSource) {
|
||||
book = report.book
|
||||
chapters = report.chapters
|
||||
self.source = source
|
||||
demoChapterContent = nil
|
||||
sourceCacheNeedsInvalidation = true
|
||||
select(index: Self.validIndex(report.book.durChapterIndex, chapters: chapters), position: report.book.durChapterPos)
|
||||
pendingSourceContent = report.verifiedContent
|
||||
}
|
||||
|
||||
func load(rules: [ReplaceRule], forceRefresh: Bool = false, prefetchNextChapter: Bool = true) async {
|
||||
guard (content.isEmpty || forceRefresh), !isLoading else { return }
|
||||
guard let chapter = currentChapter else { return }
|
||||
let requestedIndex = currentIndex
|
||||
let requestedGeneration = chapterLoadGeneration
|
||||
if forceRefresh {
|
||||
pendingSourceContent = nil
|
||||
clearContinuousReading()
|
||||
content = ""
|
||||
paragraphs = []
|
||||
diagnostics = []
|
||||
hasLoadedCurrentChapter = false
|
||||
}
|
||||
isLoading = true
|
||||
defer {
|
||||
if requestedGeneration == chapterLoadGeneration { isLoading = false }
|
||||
}
|
||||
let report: ChapterContentReport
|
||||
let fromCache: Bool
|
||||
if sourceCacheNeedsInvalidation {
|
||||
await contentCoordinator.invalidate(bookURL: book.bookUrl)
|
||||
guard requestedGeneration == chapterLoadGeneration, !Task.isCancelled else { return }
|
||||
sourceCacheNeedsInvalidation = false
|
||||
}
|
||||
if let demoChapterContent {
|
||||
fromCache = true
|
||||
report = .init(title: chapter.title, content: demoChapterContent)
|
||||
} else if let verified = pendingSourceContent, let source, !forceRefresh {
|
||||
pendingSourceContent = nil
|
||||
fromCache = false
|
||||
report = verified
|
||||
await contentCoordinator.saveContent(verified, book: book, chapter: chapter, source: source)
|
||||
} else if let source {
|
||||
let nextURL = nextReadableChapter(after: requestedIndex)?.url
|
||||
let result = await contentCoordinator.load(
|
||||
book: book,
|
||||
chapter: chapter,
|
||||
source: source,
|
||||
nextChapterURL: nextURL,
|
||||
forceRefresh: forceRefresh
|
||||
)
|
||||
fromCache = result.loadedFromCache
|
||||
report = result.report
|
||||
} else {
|
||||
fromCache = false
|
||||
do {
|
||||
report = try await Task.detached { [localService, book, chapter] in
|
||||
try localService.loadContent(book: book, chapter: chapter)
|
||||
}.value
|
||||
} catch {
|
||||
report = .init(diagnostics: [
|
||||
.init(severity: .error, code: "local.read", message: error.localizedDescription, rule: book.bookUrl)
|
||||
])
|
||||
}
|
||||
}
|
||||
guard requestedGeneration == chapterLoadGeneration, !Task.isCancelled else { return }
|
||||
loadedFromCache = fromCache
|
||||
if prefetchNextChapter, !fromCache, let source { schedulePreload(after: requestedIndex, source: source) }
|
||||
hasLoadedCurrentChapter = true
|
||||
var effectiveChapter = chapter
|
||||
if let resolvedTitle = report.title { effectiveChapter.title = resolvedTitle }
|
||||
let processed = sanitizer.process(
|
||||
book: book,
|
||||
chapter: effectiveChapter,
|
||||
content: report.content,
|
||||
rules: rules
|
||||
)
|
||||
title = processed.title
|
||||
content = processed.content
|
||||
paragraphs = Self.makeParagraphs(processed.content)
|
||||
diagnostics = report.diagnostics + processed.diagnostics
|
||||
}
|
||||
|
||||
func prepareAdjacentWindows() {
|
||||
let previousIndex = navigationIndex(direction: -1)
|
||||
if previousLoadedIndex != previousIndex {
|
||||
previousLoadedIndex = previousIndex
|
||||
previousTitle = previousIndex.map { chapters[$0].title } ?? ""
|
||||
previousContent = ""
|
||||
isPreviousWindowReady = false
|
||||
}
|
||||
let nextIndex = navigationIndex(direction: 1)
|
||||
if continuationLoadedIndex != nextIndex {
|
||||
continuationLoadedIndex = nextIndex
|
||||
continuationTitle = nextIndex.map { chapters[$0].title } ?? ""
|
||||
continuationContent = ""
|
||||
isContinuationWindowReady = false
|
||||
}
|
||||
}
|
||||
|
||||
func loadAdjacentChapters(rules: [ReplaceRule]) async {
|
||||
prepareAdjacentWindows()
|
||||
let requestedIndex = currentIndex
|
||||
let requestedGeneration = chapterLoadGeneration
|
||||
let previousIndex = previousLoadedIndex
|
||||
let nextIndex = continuationLoadedIndex
|
||||
let needsPrevious = previousIndex != nil && !isPreviousWindowReady
|
||||
let needsNext = nextIndex != nil && !isContinuationWindowReady
|
||||
|
||||
async let previousValue: (title: String, content: String, error: String?)? = needsPrevious
|
||||
? loadAdjacentChapter(chapters[previousIndex!], rules: rules)
|
||||
: nil
|
||||
async let nextValue: (title: String, content: String, error: String?)? = needsNext
|
||||
? loadAdjacentChapter(chapters[nextIndex!], rules: rules)
|
||||
: nil
|
||||
let resolvedNext = await nextValue
|
||||
guard requestedGeneration == chapterLoadGeneration, requestedIndex == currentIndex, !Task.isCancelled else { return }
|
||||
if continuationLoadedIndex == nextIndex, needsNext {
|
||||
continuationTitle = resolvedNext?.title ?? nextIndex.map { chapters[$0].title } ?? ""
|
||||
continuationContent = resolvedNext?.content ?? ""
|
||||
isContinuationWindowReady = true
|
||||
}
|
||||
let resolvedPrevious = await previousValue
|
||||
guard requestedGeneration == chapterLoadGeneration, requestedIndex == currentIndex, !Task.isCancelled else { return }
|
||||
if previousLoadedIndex == previousIndex, needsPrevious {
|
||||
previousTitle = resolvedPrevious?.title ?? previousIndex.map { chapters[$0].title } ?? ""
|
||||
previousContent = resolvedPrevious?.content ?? ""
|
||||
isPreviousWindowReady = true
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAdjacentChapter(
|
||||
_ chapter: BookChapter,
|
||||
rules: [ReplaceRule],
|
||||
forceRefresh: Bool = false
|
||||
) async -> (title: String, content: String, error: String?) {
|
||||
let report: ChapterContentReport
|
||||
if let demoChapterContent {
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("-uiScrollRegression") {
|
||||
try? await Task.sleep(nanoseconds: 80_000_000)
|
||||
}
|
||||
#endif
|
||||
report = .init(title: chapter.title, content: demoChapterContent)
|
||||
} else if let source {
|
||||
report = await contentCoordinator.load(
|
||||
book: book,
|
||||
chapter: chapter,
|
||||
source: source,
|
||||
nextChapterURL: nextReadableChapter(after: chapter.index)?.url,
|
||||
forceRefresh: forceRefresh
|
||||
).report
|
||||
} else {
|
||||
do {
|
||||
report = try await Task.detached { [localService, book, chapter] in
|
||||
try localService.loadContent(book: book, chapter: chapter)
|
||||
}.value
|
||||
} catch {
|
||||
return (chapter.title, "", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
var effectiveChapter = chapter
|
||||
if let resolvedTitle = report.title { effectiveChapter.title = resolvedTitle }
|
||||
let processed = sanitizer.process(
|
||||
book: book,
|
||||
chapter: effectiveChapter,
|
||||
content: report.content,
|
||||
rules: rules
|
||||
)
|
||||
let error = processed.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? (report.diagnostics + processed.diagnostics).first(where: { $0.severity == .error })?.message
|
||||
?? (source != nil && !chapter.isVolume ? "当前书源没有返回正文。" : nil)
|
||||
: nil
|
||||
return (processed.title, processed.content, error)
|
||||
}
|
||||
|
||||
func saveEditedContent(_ value: String) async throws {
|
||||
guard let chapter = currentChapter, source != nil else { return }
|
||||
try await contentCoordinator.saveEditedContent(book: book, chapter: chapter, title: title, content: value)
|
||||
content = value
|
||||
paragraphs = Self.makeParagraphs(value)
|
||||
loadedFromCache = true
|
||||
if let slot = scrollChapters.firstIndex(where: { $0.id == currentIndex }) {
|
||||
scrollChapters[slot] = scrollChapterForCurrentContent()
|
||||
}
|
||||
}
|
||||
|
||||
func nextSpeechContent(rules: [ReplaceRule]) async -> String? {
|
||||
guard let chapter = nextReadableChapter(after: currentIndex) else { return nil }
|
||||
let report: ChapterContentReport
|
||||
if let source {
|
||||
report = await contentCoordinator.load(
|
||||
book: book,
|
||||
chapter: chapter,
|
||||
source: source,
|
||||
nextChapterURL: nextReadableChapter(after: chapter.index)?.url
|
||||
).report
|
||||
} else {
|
||||
do {
|
||||
report = try await Task.detached { [localService, book, chapter] in
|
||||
try localService.loadContent(book: book, chapter: chapter)
|
||||
}.value
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var effectiveChapter = chapter
|
||||
if let resolvedTitle = report.title { effectiveChapter.title = resolvedTitle }
|
||||
let value = sanitizer.process(
|
||||
book: book,
|
||||
chapter: effectiveChapter,
|
||||
content: report.content,
|
||||
rules: rules
|
||||
).content
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func toggleReplaceRules() -> Book {
|
||||
var config = book.readConfig ?? BookReadConfig()
|
||||
config.useReplaceRule = !usesReplaceRules
|
||||
book.readConfig = config
|
||||
content = ""
|
||||
pendingSourceContent = nil
|
||||
paragraphs = []
|
||||
diagnostics = []
|
||||
loadedFromCache = false
|
||||
hasLoadedCurrentChapter = false
|
||||
clearAdjacentChapters()
|
||||
return book
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func useGlobalPageAnimation() -> Book {
|
||||
var config = book.readConfig ?? BookReadConfig()
|
||||
config.pageAnim = -1
|
||||
book.readConfig = config
|
||||
return book
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setTTSEngine(_ value: String?) -> Book {
|
||||
var config = book.readConfig ?? BookReadConfig()
|
||||
config.ttsEngine = value
|
||||
book.readConfig = config
|
||||
return book
|
||||
}
|
||||
|
||||
func goPrevious(position: Int = 0) { if let value = navigationIndex(direction: -1) { select(index: value, position: position) } }
|
||||
func goNext(position: Int = 0) { if let value = navigationIndex(direction: 1) { select(index: value, position: position) } }
|
||||
|
||||
func promoteContinuation(position: Int) {
|
||||
guard let value = navigationIndex(direction: 1), hasContinuationWindow else {
|
||||
return
|
||||
}
|
||||
let oldIndex = currentIndex
|
||||
let oldTitle = title
|
||||
let oldContent = content
|
||||
let promotedContentResolved = isContinuationWindowReady
|
||||
currentIndex = value
|
||||
title = continuationTitle.isEmpty ? chapters[value].title : continuationTitle
|
||||
content = continuationContent
|
||||
paragraphs = Self.makeParagraphs(continuationContent)
|
||||
diagnostics = []
|
||||
requestedPosition = max(position, 0)
|
||||
loadedFromCache = promotedContentResolved
|
||||
hasLoadedCurrentChapter = promotedContentResolved
|
||||
previousLoadedIndex = oldIndex
|
||||
previousTitle = oldTitle
|
||||
previousContent = oldContent
|
||||
isPreviousWindowReady = true
|
||||
continuationLoadedIndex = nil
|
||||
continuationTitle = ""
|
||||
continuationContent = ""
|
||||
isContinuationWindowReady = false
|
||||
isLoading = false
|
||||
chapterLoadGeneration &+= 1
|
||||
}
|
||||
|
||||
func promotePrevious(position: Int) {
|
||||
guard let value = navigationIndex(direction: -1), hasPreviousWindow else { return }
|
||||
let oldIndex = currentIndex
|
||||
let oldTitle = title
|
||||
let oldContent = content
|
||||
let promotedContentResolved = isPreviousWindowReady
|
||||
currentIndex = value
|
||||
title = previousTitle.isEmpty ? chapters[value].title : previousTitle
|
||||
content = previousContent
|
||||
paragraphs = Self.makeParagraphs(previousContent)
|
||||
diagnostics = []
|
||||
requestedPosition = max(position, 0)
|
||||
loadedFromCache = promotedContentResolved
|
||||
hasLoadedCurrentChapter = promotedContentResolved
|
||||
continuationLoadedIndex = oldIndex
|
||||
continuationTitle = oldTitle
|
||||
continuationContent = oldContent
|
||||
isContinuationWindowReady = true
|
||||
previousLoadedIndex = nil
|
||||
previousTitle = ""
|
||||
previousContent = ""
|
||||
isPreviousWindowReady = false
|
||||
isLoading = false
|
||||
chapterLoadGeneration &+= 1
|
||||
}
|
||||
|
||||
func select(index: Int, position: Int = 0) {
|
||||
guard chapters.indices.contains(index), !chapters[index].isVolume else { return }
|
||||
currentIndex = index
|
||||
pendingSourceContent = nil
|
||||
title = chapters[index].title
|
||||
content = ""
|
||||
paragraphs = []
|
||||
diagnostics = []
|
||||
requestedPosition = max(position, 0)
|
||||
loadedFromCache = false
|
||||
hasLoadedCurrentChapter = false
|
||||
clearAdjacentChapters()
|
||||
isLoading = false
|
||||
chapterLoadGeneration &+= 1
|
||||
}
|
||||
|
||||
private func clearAdjacentChapters() {
|
||||
clearContinuousReading()
|
||||
previousLoadedIndex = nil
|
||||
previousTitle = ""
|
||||
previousContent = ""
|
||||
isPreviousWindowReady = false
|
||||
continuationLoadedIndex = nil
|
||||
continuationTitle = ""
|
||||
continuationContent = ""
|
||||
isContinuationWindowReady = false
|
||||
}
|
||||
|
||||
func paragraphID(at position: Int) -> Int? {
|
||||
paragraphs.last(where: { $0.id <= position })?.id ?? paragraphs.first?.id
|
||||
}
|
||||
|
||||
private func navigationIndex(direction: Int) -> Int? {
|
||||
readableIndex(from: currentIndex, direction: direction)
|
||||
}
|
||||
|
||||
private func readableIndex(from index: Int, direction: Int) -> Int? {
|
||||
var value = index + direction
|
||||
while chapters.indices.contains(value) {
|
||||
if !chapters[value].isVolume { return value }
|
||||
value += direction
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func nextReadableChapter(after index: Int) -> BookChapter? {
|
||||
var value = index + 1
|
||||
while chapters.indices.contains(value) {
|
||||
if !chapters[value].isVolume { return chapters[value] }
|
||||
value += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func schedulePreload(after index: Int, source: BookSource) {
|
||||
let count = ApplicationSettingsStore.shared.settings.prefetchChapters
|
||||
let jobs = ChapterCachePlanner.plan(chapters: chapters, startIndex: index + 1, limit: count)
|
||||
guard index + 1 < chapters.count, !jobs.isEmpty else { return }
|
||||
Task { [book, contentCoordinator] in
|
||||
for job in jobs {
|
||||
guard !Task.isCancelled else { return }
|
||||
await contentCoordinator.prefetch(book: book, chapter: job.chapter, source: source, nextChapterURL: job.nextChapterURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeParagraphs(_ content: String) -> [ReaderParagraph] {
|
||||
var position = 0
|
||||
var output: [ReaderParagraph] = []
|
||||
for line in content.components(separatedBy: .newlines) {
|
||||
if !line.isEmpty { output.append(.init(id: position, text: line)) }
|
||||
position += line.utf16.count + 1
|
||||
}
|
||||
if output.isEmpty, !content.isEmpty { output.append(.init(id: 0, text: content)) }
|
||||
return output
|
||||
}
|
||||
|
||||
private static func validIndex(_ requested: Int, chapters: [BookChapter]) -> Int {
|
||||
if chapters.indices.contains(requested), !chapters[requested].isVolume { return requested }
|
||||
return chapters.firstIndex(where: { !$0.isVolume }) ?? 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import Vision
|
||||
import VisionKit
|
||||
|
||||
struct QRCodeImportView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let onValue: (String) -> Void
|
||||
@State private var cameraAuthorized = false
|
||||
@State private var cameraMessage = "正在检查相机…"
|
||||
@State private var values: [String] = []
|
||||
@State private var errorMessage: String?
|
||||
@State private var isReading = false
|
||||
@State private var scanError: String?
|
||||
@State private var delivered = false
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
VStack(spacing: 16) {
|
||||
if cameraAuthorized, scanError == nil {
|
||||
cameraScanner
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(alignment: .bottom) {
|
||||
Text("将书源或净化规则二维码放入取景框").font(.footnote)
|
||||
.padding(10).background(.ultraThinMaterial, in: Capsule()).padding()
|
||||
}
|
||||
} else {
|
||||
ReadingUnavailableView("相机扫描暂不可用", systemImage: "qrcode.viewfinder",
|
||||
description: Text(scanError ?? cameraMessage))
|
||||
if AVCaptureDevice.authorizationStatus(for: .video) == .denied {
|
||||
Link("打开系统设置", destination: URL(string: UIApplication.openSettingsURLString)!)
|
||||
}
|
||||
}
|
||||
if isReading { ProgressView("识别图片中的二维码…") }
|
||||
ReadingPhotoPicker { data in
|
||||
readPhoto(data)
|
||||
} onError: { error in
|
||||
errorMessage = error.localizedDescription
|
||||
} label: {
|
||||
Label("从照片选择二维码", systemImage: "photo")
|
||||
.frame(maxWidth: .infinity)
|
||||
}.buttonStyle(.borderedProminent).disabled(isReading)
|
||||
Text("支持 HTTP(S) 链接、Legado 导入深链和 JSON 内容。识别后进入导入流程。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
if values.count > 1 {
|
||||
List(values, id: \.self) { value in
|
||||
Button { accept(value) } label: { Text(value).font(.caption).lineLimit(3) }
|
||||
}.frame(maxHeight: 220)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("二维码导入")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } }
|
||||
.task {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized: cameraAuthorized = true
|
||||
case .notDetermined: cameraAuthorized = await AVCaptureDevice.requestAccess(for: .video)
|
||||
default: break
|
||||
}
|
||||
cameraMessage = cameraAuthorized ? "当前设备不支持实时扫描,请从照片选择二维码。" : "未获得相机权限,仍可从照片选择二维码。"
|
||||
}
|
||||
.alert("识别失败", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
|
||||
Button("好", role: .cancel) {}
|
||||
} message: { Text(errorMessage ?? "") }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var cameraScanner: some View {
|
||||
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI,
|
||||
DataScannerViewController.isSupported, DataScannerViewController.isAvailable {
|
||||
QRScanner { accept($0) } onError: { scanError = $0 }
|
||||
} else {
|
||||
LegacyQRScanner { accept($0) } onError: { scanError = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
private func readPhoto(_ data: Data) {
|
||||
Task {
|
||||
isReading = true
|
||||
defer { isReading = false }
|
||||
do {
|
||||
let found = try await Task.detached(priority: .userInitiated) {
|
||||
try QRCodeDecoder().detect(in: data)
|
||||
}.value
|
||||
values = found
|
||||
if found.count == 1 { accept(found[0]) }
|
||||
else if found.isEmpty { errorMessage = "图片中没有识别到二维码,请选择清晰的原图。" }
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
private func accept(_ value: String) {
|
||||
guard !delivered else { return }
|
||||
delivered = true
|
||||
dismiss()
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 400_000_000)
|
||||
onValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
private struct QRScanner: UIViewControllerRepresentable {
|
||||
let onValue: (String) -> Void
|
||||
let onError: (String) -> Void
|
||||
func makeCoordinator() -> Coordinator { Coordinator(onValue: onValue) }
|
||||
func makeUIViewController(context: Context) -> DataScannerViewController {
|
||||
let scanner = DataScannerViewController(recognizedDataTypes: [.barcode(symbologies: [.qr])],
|
||||
qualityLevel: .balanced, recognizesMultipleItems: false, isHighFrameRateTrackingEnabled: false,
|
||||
isPinchToZoomEnabled: true, isGuidanceEnabled: true, isHighlightingEnabled: true)
|
||||
scanner.delegate = context.coordinator
|
||||
return scanner
|
||||
}
|
||||
func updateUIViewController(_ scanner: DataScannerViewController, context: Context) {
|
||||
guard !scanner.isScanning else { return }
|
||||
do { try scanner.startScanning() }
|
||||
catch { Task { @MainActor in onError(error.localizedDescription) } }
|
||||
}
|
||||
static func dismantleUIViewController(_ scanner: DataScannerViewController, coordinator: Coordinator) { scanner.stopScanning() }
|
||||
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
|
||||
let onValue: (String) -> Void
|
||||
init(onValue: @escaping (String) -> Void) { self.onValue = onValue }
|
||||
func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
|
||||
for item in addedItems {
|
||||
if case .barcode(let code) = item, let value = code.payloadStringValue {
|
||||
dataScanner.stopScanning()
|
||||
onValue(value)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LegacyQRScanner: UIViewControllerRepresentable {
|
||||
let onValue: (String) -> Void
|
||||
let onError: (String) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> Controller {
|
||||
Controller(onValue: onValue, onError: onError)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ controller: Controller, context: Context) {}
|
||||
|
||||
static func dismantleUIViewController(_ controller: Controller, coordinator: Void) {
|
||||
controller.capture.stop()
|
||||
}
|
||||
|
||||
final class Controller: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||
let capture = VideoCapture()
|
||||
let onValue: (String) -> Void
|
||||
let onError: (String) -> Void
|
||||
private var preview: AVCaptureVideoPreviewLayer?
|
||||
private var didDeliver = false
|
||||
|
||||
init(onValue: @escaping (String) -> Void, onError: @escaping (String) -> Void) {
|
||||
self.onValue = onValue
|
||||
self.onError = onError
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
do {
|
||||
guard let device = AVCaptureDevice.default(for: .video) else { throw AVError(.deviceNotConnected) }
|
||||
let input = try AVCaptureDeviceInput(device: device)
|
||||
let output = AVCaptureMetadataOutput()
|
||||
capture.session.beginConfiguration()
|
||||
defer { capture.session.commitConfiguration() }
|
||||
guard capture.session.canAddInput(input), capture.session.canAddOutput(output) else {
|
||||
throw AVError(.deviceInUseByAnotherApplication)
|
||||
}
|
||||
capture.session.addInput(input)
|
||||
capture.session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||
output.metadataObjectTypes = [.qr]
|
||||
let preview = AVCaptureVideoPreviewLayer(session: capture.session)
|
||||
preview.videoGravity = .resizeAspectFill
|
||||
view.layer.addSublayer(preview)
|
||||
self.preview = preview
|
||||
} catch {
|
||||
let message = error.localizedDescription
|
||||
DispatchQueue.main.async { self.onError(message) }
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
preview?.frame = view.bounds
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
if preview != nil { capture.start() }
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
capture.stop()
|
||||
}
|
||||
|
||||
nonisolated func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput objects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||
guard let value = objects.compactMap({ ($0 as? AVMetadataMachineReadableCodeObject)?.stringValue }).first else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, !didDeliver else { return }
|
||||
didDeliver = true
|
||||
capture.stop()
|
||||
onValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class VideoCapture: @unchecked Sendable {
|
||||
let session = AVCaptureSession()
|
||||
private let queue = DispatchQueue(label: "com.epiboly.legado.qr-camera")
|
||||
func start() { queue.async { self.session.startRunning() } }
|
||||
func stop() { queue.async { self.session.stopRunning() } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SyntaxCodeEditor: UIViewRepresentable {
|
||||
enum Language { case json, rule, javascript, regex }
|
||||
@Binding var text: String
|
||||
var language: Language = .rule
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let view = UITextView()
|
||||
view.delegate = context.coordinator
|
||||
view.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
|
||||
view.backgroundColor = .secondarySystemGroupedBackground
|
||||
view.textContainerInset = UIEdgeInsets(top: 14, left: 12, bottom: 20, right: 12)
|
||||
view.autocorrectionType = .no
|
||||
view.autocapitalizationType = .none
|
||||
view.smartQuotesType = .no
|
||||
view.smartDashesType = .no
|
||||
view.smartInsertDeleteType = .no
|
||||
view.keyboardDismissMode = .interactive
|
||||
view.alwaysBounceVertical = true
|
||||
view.accessibilityIdentifier = "syntaxCodeEditor"
|
||||
if #available(iOS 16.0, *) { view.isFindInteractionEnabled = true }
|
||||
let bar = UIToolbar()
|
||||
bar.sizeToFit()
|
||||
bar.items = [
|
||||
UIBarButtonItem(image: UIImage(systemName: "magnifyingglass"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view, coordinator = context.coordinator] _ in
|
||||
guard let view else { return }
|
||||
if #available(iOS 16.0, *) { view.findInteraction?.presentFindNavigator(showingReplace: true) }
|
||||
else { coordinator.presentFindAndReplace(in: view) }
|
||||
}),
|
||||
UIBarButtonItem(image: UIImage(systemName: "arrow.uturn.backward"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.undoManager?.undo() }),
|
||||
UIBarButtonItem(image: UIImage(systemName: "arrow.uturn.forward"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.undoManager?.redo() }),
|
||||
] + ["@", "$", "{}", "[]", "##", "\n", "\t"].map { symbol in
|
||||
UIBarButtonItem(title: symbol == "\n" ? "↵" : symbol == "\t" ? "⇥" : symbol,
|
||||
primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.insertText(symbol) })
|
||||
} + [UIBarButtonItem(systemItem: .flexibleSpace), UIBarButtonItem(title: "完成", primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.resignFirstResponder() })]
|
||||
view.inputAccessoryView = bar
|
||||
view.text = text
|
||||
context.coordinator.highlight(view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: UITextView, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
guard view.markedTextRange == nil else { return }
|
||||
// SwiftUI can render an older binding snapshot while the next native
|
||||
// keystroke is already in the text view. Wait for acknowledgement of
|
||||
// the latest local edit instead of replacing it with that snapshot.
|
||||
if let pending = context.coordinator.pendingLocalText {
|
||||
guard text == pending else { return }
|
||||
context.coordinator.pendingLocalText = nil
|
||||
}
|
||||
if view.text != text {
|
||||
let selection = view.selectedRange
|
||||
view.text = text
|
||||
view.selectedRange = NSRange(location: min(selection.location, view.text.utf16.count), length: 0)
|
||||
}
|
||||
context.coordinator.highlight(view)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, UITextViewDelegate {
|
||||
var parent: SyntaxCodeEditor
|
||||
private var highlightedText: String?
|
||||
private var highlightedStyle: UIUserInterfaceStyle?
|
||||
private var highlightedLanguage: Language?
|
||||
var pendingLocalText: String?
|
||||
init(_ parent: SyntaxCodeEditor) { self.parent = parent }
|
||||
|
||||
func presentFindAndReplace(in view: UITextView) {
|
||||
guard var presenter = view.window?.rootViewController else { return }
|
||||
while let presented = presenter.presentedViewController { presenter = presented }
|
||||
let alert = UIAlertController(title: "查找与替换", message: nil, preferredStyle: .alert)
|
||||
alert.addTextField { $0.placeholder = "查找内容" }
|
||||
alert.addTextField { $0.placeholder = "替换为(可留空)" }
|
||||
alert.addAction(UIAlertAction(title: "查找下一个", style: .default) { [weak view, weak alert] _ in
|
||||
guard let view, let query = alert?.textFields?.first?.text, !query.isEmpty else { return }
|
||||
let text = (view.text ?? "") as NSString
|
||||
let start = min(NSMaxRange(view.selectedRange), text.length)
|
||||
var range = text.range(of: query, range: NSRange(location: start, length: text.length - start))
|
||||
if range.location == NSNotFound { range = text.range(of: query) }
|
||||
guard range.location != NSNotFound else { return }
|
||||
view.becomeFirstResponder()
|
||||
view.selectedRange = range
|
||||
view.scrollRangeToVisible(range)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "全部替换", style: .default) { [weak view, weak alert] _ in
|
||||
guard let view, let fields = alert?.textFields,
|
||||
let query = fields.first?.text, !query.isEmpty else { return }
|
||||
let original = view.text ?? ""
|
||||
let replacement = original.replacingOccurrences(of: query, with: fields.last?.text ?? "")
|
||||
guard replacement != original else { return }
|
||||
view.becomeFirstResponder()
|
||||
view.selectedRange = NSRange(location: 0, length: original.utf16.count)
|
||||
view.insertText(replacement)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
presenter.present(alert, animated: true)
|
||||
}
|
||||
|
||||
func textViewDidChange(_ view: UITextView) {
|
||||
guard view.markedTextRange == nil else { return }
|
||||
pendingLocalText = view.text
|
||||
parent.text = view.text
|
||||
highlight(view)
|
||||
}
|
||||
|
||||
func textViewDidEndEditing(_ view: UITextView) {
|
||||
pendingLocalText = nil
|
||||
parent.text = view.text
|
||||
highlight(view)
|
||||
}
|
||||
|
||||
func highlight(_ view: UITextView) {
|
||||
guard view.markedTextRange == nil,
|
||||
highlightedText != view.text || highlightedStyle != view.traitCollection.userInterfaceStyle || highlightedLanguage != parent.language else { return }
|
||||
highlightedText = view.text
|
||||
highlightedStyle = view.traitCollection.userInterfaceStyle
|
||||
highlightedLanguage = parent.language
|
||||
let storage = view.textStorage
|
||||
let selection = view.selectedRange
|
||||
let all = NSRange(location: 0, length: storage.length)
|
||||
storage.beginEditing()
|
||||
storage.addAttributes([.font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: UIColor.label], range: all)
|
||||
if storage.length <= 500_000 {
|
||||
let tokens: [(String, UIColor)] = [
|
||||
(#"\b(?:true|false|null|undefined|return|var|let|const|if|else|function|new|for|while|try|catch|throw|await|async)\b"#, .systemPurple),
|
||||
(#"\b\d+(?:\.\d+)?\b"#, .systemOrange),
|
||||
(#"@(?:css|xpath|json|js|put|get):?|\$\.|&&|\|\||%%|##|\{\{|\}\}"#, .systemPink),
|
||||
(#"[{}\[\]()]"#, .systemTeal),
|
||||
(#""(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`"#, .systemGreen),
|
||||
(#""(?:\\.|[^"\\])*"(?=\s*:)"#, .systemBlue),
|
||||
]
|
||||
for (pattern, color) in tokens {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
|
||||
regex.enumerateMatches(in: view.text, range: all) { result, _, _ in
|
||||
if let result { storage.addAttribute(.foregroundColor, value: color, range: result.range) }
|
||||
}
|
||||
}
|
||||
if parent.language == .regex {
|
||||
for pattern in [#"[.*+?^$|]"#, #"\\[dDsSwWbB0-9]|\{[0-9,]+\}"#] {
|
||||
let regex = try? NSRegularExpression(pattern: pattern)
|
||||
regex?.enumerateMatches(in: view.text, range: all) { result, _, _ in
|
||||
if let result { storage.addAttribute(.foregroundColor, value: UIColor.systemPink, range: result.range) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if parent.language == .javascript {
|
||||
let pattern = #"(?m)^\s*//.*$|/\*[\s\S]*?\*/"#
|
||||
let regex = try? NSRegularExpression(pattern: pattern)
|
||||
regex?.enumerateMatches(in: view.text, range: all) { result, _, _ in
|
||||
if let result { storage.addAttribute(.foregroundColor, value: UIColor.secondaryLabel, range: result.range) }
|
||||
}
|
||||
}
|
||||
}
|
||||
storage.endEditing()
|
||||
view.selectedRange = selection
|
||||
view.typingAttributes = [.font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: UIColor.label]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RuleCodeFieldView: View {
|
||||
let title: String
|
||||
let path: String
|
||||
@Binding var text: String
|
||||
var language: SyntaxCodeEditor.Language = .rule
|
||||
var help: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Text(path).font(.caption.monospaced()).foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading).padding(12)
|
||||
if let help {
|
||||
Text(help).font(.footnote).foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 12).padding(.bottom, 10)
|
||||
}
|
||||
SyntaxCodeEditor(text: $text, language: language)
|
||||
HStack {
|
||||
Text("\(text.components(separatedBy: "\n").count) 行 · \(text.utf16.count) 字符")
|
||||
Spacer()
|
||||
if language == .rule { Text("CSS · XPath · JSONPath · JS") }
|
||||
}.font(.caption).foregroundStyle(.secondary).padding(12)
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct ReplaceRuleEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var library: ReplaceRuleLibraryModel
|
||||
@State private var rule: ReplaceRule
|
||||
@State private var mode: Int
|
||||
@State private var testTitle = "第一章 开始"
|
||||
@State private var testContent = "在这里粘贴需要净化的正文。"
|
||||
@State private var testBook = ""
|
||||
@State private var testSource = ""
|
||||
@State private var testResult: ReplaceRuleTestResult?
|
||||
@State private var errorMessage: String?
|
||||
@State private var isTesting = false
|
||||
@State private var isSaving = false
|
||||
@State private var showsDiscard = false
|
||||
private let original: ReplaceRule
|
||||
|
||||
init(rule: ReplaceRule, startsWithTest: Bool = false) {
|
||||
original = rule
|
||||
_rule = State(initialValue: rule)
|
||||
_mode = State(initialValue: startsWithTest ? 1 : 0)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
Picker("模式", selection: $mode) { Text("编辑规则").tag(0); Text("测试规则").tag(1) }
|
||||
.pickerStyle(.segmented).padding()
|
||||
Form { if mode == 0 { configuration } else { testForm } }
|
||||
}
|
||||
.navigationTitle(rule.name.isEmpty ? "净化规则" : rule.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { if rule != original { showsDiscard = true } else { dismiss() } }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu("更多", systemImage: "ellipsis.circle") {
|
||||
Button("复制规则 JSON", systemImage: "doc.on.doc") { UIPasteboard.general.string = json }
|
||||
Button("粘贴规则 JSON", systemImage: "doc.on.clipboard") {
|
||||
do {
|
||||
guard let text = UIPasteboard.general.string else { return }
|
||||
var pasted = try JSONDecoder().decode(ReplaceRule.self, from: Data(text.utf8))
|
||||
pasted.id = rule.id
|
||||
pasted.sortOrder = rule.sortOrder
|
||||
rule = pasted
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
ReadingShareLink(item: json) { Label("分享规则", systemImage: "square.and.arrow.up") }
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(isSaving ? "保存中" : "保存") {
|
||||
isSaving = true
|
||||
Task {
|
||||
let saved = await library.save(rule)
|
||||
isSaving = false
|
||||
if saved { dismiss() }
|
||||
else {
|
||||
let message = library.notice?.detail ?? "保存失败"
|
||||
library.notice = nil
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
}.disabled(isSaving || isTesting)
|
||||
}
|
||||
}
|
||||
.alert("规则提示", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
|
||||
Button("好", role: .cancel) {}
|
||||
} message: { Text(errorMessage ?? "") }
|
||||
.confirmationDialog("放弃未保存的修改?", isPresented: $showsDiscard, titleVisibility: .visible) {
|
||||
Button("放弃修改", role: .destructive) { dismiss() }
|
||||
}
|
||||
.onChange(of: rule) { _ in testResult = nil }
|
||||
.onChange(of: testTitle + testContent + testBook + testSource) { _ in testResult = nil }
|
||||
}
|
||||
.interactiveDismissDisabled(rule != original || isSaving)
|
||||
}
|
||||
|
||||
private var configuration: some View {
|
||||
Group {
|
||||
Section("基本信息") {
|
||||
TextField("名称", text: $rule.name)
|
||||
TextField("分组", text: optional(\.group))
|
||||
Toggle("启用", isOn: $rule.isEnabled)
|
||||
}
|
||||
Section("匹配与替换") {
|
||||
Toggle("使用正则表达式", isOn: $rule.isRegex)
|
||||
NavigationLink {
|
||||
RuleCodeFieldView(title: "匹配内容", path: "pattern", text: $rule.pattern, language: .regex)
|
||||
} label: { fieldPreview("匹配内容", value: rule.pattern) }
|
||||
NavigationLink {
|
||||
RuleCodeFieldView(title: "替换为", path: "replacement", text: $rule.replacement, language: .regex)
|
||||
} label: { fieldPreview("替换为(留空即删除)", value: rule.replacement) }
|
||||
Text("正则替换使用 $1、$2 引用捕获组;普通文本替换按字面匹配。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
Section("作用范围") {
|
||||
Toggle("章节标题", isOn: $rule.scopeTitle)
|
||||
Toggle("正文", isOn: $rule.scopeContent)
|
||||
ReadingTextField("限定书名或书源(留空为全部)", text: optional(\.scope), axis: .vertical)
|
||||
ReadingTextField("排除书名或书源", text: optional(\.excludeScope), axis: .vertical)
|
||||
}
|
||||
Section("执行设置") {
|
||||
HStack {
|
||||
Text("超时 (ms)")
|
||||
TextField("3000", value: $rule.timeoutMillisecond, format: .number.grouping(.never))
|
||||
.keyboardType(.numbersAndPunctuation).multilineTextAlignment(.trailing)
|
||||
}
|
||||
HStack {
|
||||
Text("排序编号")
|
||||
TextField("0", value: $rule.sortOrder, format: .number.grouping(.never))
|
||||
.keyboardType(.numbersAndPunctuation).multilineTextAlignment(.trailing)
|
||||
}
|
||||
Text("超时小于等于 0 时使用 3000 ms。单次正则最多运行 60 秒。规则按排序编号依次执行;资源超限或超时会保留原文并报告错误。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var testForm: some View {
|
||||
Group {
|
||||
Section("测试上下文") {
|
||||
TextField("书名(用于作用范围判断)", text: $testBook)
|
||||
TextField("书源地址(用于作用范围判断)", text: $testSource).textInputAutocapitalization(.never)
|
||||
TextField("章节标题", text: $testTitle)
|
||||
NavigationLink {
|
||||
RuleCodeFieldView(title: "测试正文", path: "测试输入", text: $testContent)
|
||||
} label: { fieldPreview("正文", value: testContent) }
|
||||
}
|
||||
Section {
|
||||
Button { runTest() } label: {
|
||||
HStack { Label(isTesting ? "测试中…" : "运行当前草稿", systemImage: "play.fill"); Spacer(); if isTesting { ProgressView() } }
|
||||
}.disabled(isTesting)
|
||||
if !rule.isEnabled { Text("规则处于停用状态,结果将保持原文。可在编辑页启用后测试。").font(.footnote).foregroundStyle(.secondary) }
|
||||
}
|
||||
if let result = testResult {
|
||||
Section("测试结果 · \(result.elapsedMilliseconds.formatted(.number.precision(.fractionLength(1)))) ms") {
|
||||
Label(result.report.effectiveRuleIDs.isEmpty ? "未改变文本(请检查匹配、启用状态及作用范围)" : "规则已生效",
|
||||
systemImage: result.report.effectiveRuleIDs.isEmpty ? "info.circle" : "checkmark.circle")
|
||||
ReadingLabeledContent("标题", value: result.report.title)
|
||||
Text(result.report.content.isEmpty ? "(正文为空)" : result.report.content)
|
||||
.font(.body.monospaced()).textSelection(.enabled)
|
||||
}
|
||||
if !result.report.diagnostics.isEmpty {
|
||||
Section("执行错误") {
|
||||
ForEach(result.report.diagnostics) { diagnostic in Text(diagnostic.message).foregroundStyle(.red) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var json: String {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||||
return (try? encoder.encode(rule)).map { String(decoding: $0, as: UTF8.self) } ?? "{}"
|
||||
}
|
||||
private func optional(_ path: WritableKeyPath<ReplaceRule, String?>) -> Binding<String> {
|
||||
Binding(get: { rule[keyPath: path] ?? "" }, set: { rule[keyPath: path] = $0.isEmpty ? nil : $0 })
|
||||
}
|
||||
private func fieldPreview(_ title: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(title)
|
||||
Text(value.isEmpty ? "未填写" : value).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(3)
|
||||
}
|
||||
}
|
||||
private func runTest() {
|
||||
let snapshot = rule
|
||||
let title = testTitle, content = testContent, book = testBook, source = testSource
|
||||
isTesting = true
|
||||
testResult = nil
|
||||
Task {
|
||||
do {
|
||||
let result = try await Task.detached(priority: .userInitiated) {
|
||||
try ReplaceRuleTester().test(rule: snapshot, title: title, content: content, bookName: book, sourceURL: source)
|
||||
}.value
|
||||
if rule == snapshot, title == testTitle, content == testContent, book == testBook, source == testSource { testResult = result }
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
isTesting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
#if SWIFT_PACKAGE
|
||||
import LegadoCore
|
||||
#endif
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class ReplaceRuleLibraryModel: ObservableObject {
|
||||
@Published private(set) var rules: [ReplaceRule] = []
|
||||
@Published var notice: Notice?
|
||||
|
||||
struct Notice: Identifiable {
|
||||
enum Kind { case success, error }
|
||||
let id = UUID()
|
||||
let kind: Kind
|
||||
let title: String
|
||||
let detail: String
|
||||
}
|
||||
|
||||
private let repository: any ReplaceRuleRepository
|
||||
private let importer = ReplaceRuleImporter()
|
||||
|
||||
init(repository: any ReplaceRuleRepository) { self.repository = repository }
|
||||
|
||||
static func live() -> ReplaceRuleLibraryModel {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Legado", isDirectory: true)
|
||||
return ReplaceRuleLibraryModel(repository: FileReplaceRuleRepository(fileURL: base.appendingPathComponent("replace-rules.json")))
|
||||
}
|
||||
|
||||
static func demo() -> ReplaceRuleLibraryModel {
|
||||
ReplaceRuleLibraryModel(repository: InMemoryReplaceRuleRepository())
|
||||
}
|
||||
|
||||
func load() async {
|
||||
do { rules = try await repository.load() }
|
||||
catch { notice = .init(kind: .error, title: "净化规则读取失败", detail: error.localizedDescription) }
|
||||
}
|
||||
|
||||
func importFiles(_ urls: [URL]) async {
|
||||
do {
|
||||
var updated = rules
|
||||
var count = 0
|
||||
for url in urls {
|
||||
let granted = url.startAccessingSecurityScopedResource()
|
||||
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
||||
let incoming = try importer.decode(Data(contentsOf: url, options: .mappedIfSafe))
|
||||
updated = importer.merge(incoming, into: updated)
|
||||
count += incoming.count
|
||||
}
|
||||
try await repository.save(updated)
|
||||
rules = updated
|
||||
notice = .init(kind: .success, title: "净化规则已导入", detail: "本次读取 \(count) 条,当前共 \(rules.count) 条。")
|
||||
} catch {
|
||||
notice = .init(kind: .error, title: "净化规则导入失败", detail: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func importRemoteURL(_ value: String) async { await importText(value) }
|
||||
|
||||
func importText(_ value: String) async {
|
||||
do {
|
||||
let input = try RuleImportInput.parse(value, kind: "replaceRule")
|
||||
let data: Data
|
||||
switch input {
|
||||
case .json(let value): data = Data(value.utf8)
|
||||
case .remote:
|
||||
let (body, response) = try await URLSessionHTTPTransport.shared.download(for: input.request()!)
|
||||
if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) { throw URLError(.badServerResponse) }
|
||||
data = body
|
||||
}
|
||||
let incoming = try importer.decode(data)
|
||||
let updated = importer.merge(incoming, into: rules)
|
||||
try await repository.save(updated)
|
||||
rules = updated
|
||||
notice = .init(kind: .success, title: "净化规则已导入", detail: "本次读取 \(incoming.count) 条,当前共 \(rules.count) 条。")
|
||||
} catch { notice = .init(kind: .error, title: "净化规则导入失败", detail: error.localizedDescription) }
|
||||
}
|
||||
|
||||
func newRule(copying original: ReplaceRule? = nil) -> ReplaceRule {
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
let maximumID = rules.map(\.id).max() ?? 0
|
||||
let id = max(now, maximumID < Int64.max ? maximumID + 1 : now)
|
||||
var rule = original ?? ReplaceRule(id: id, name: "", pattern: "")
|
||||
rule.id = id
|
||||
if original != nil { rule.name += " 副本" }
|
||||
rule.sortOrder = (rules.map(\.sortOrder).filter { $0 != Int.min && $0 < Int.max }.max() ?? -1) + 1
|
||||
return rule
|
||||
}
|
||||
|
||||
func save(_ rule: ReplaceRule) async -> Bool {
|
||||
do { try ReplaceRuleValidator.validate(rule) }
|
||||
catch { notice = .init(kind: .error, title: "规则无效", detail: error.localizedDescription); return false }
|
||||
return await persist(importer.merge([rule], into: rules))
|
||||
}
|
||||
|
||||
func move(from offsets: IndexSet, to destination: Int) async {
|
||||
var updated = rules
|
||||
updated.move(fromOffsets: offsets, toOffset: destination)
|
||||
for index in updated.indices { updated[index].sortOrder = index }
|
||||
await persist(updated)
|
||||
}
|
||||
|
||||
func remove(id: Int64) async { await persist(rules.filter { $0.id != id }) }
|
||||
|
||||
func applySilentSubscription(_ data: Data) async throws {
|
||||
let incoming = try importer.decode(data)
|
||||
let changed = incoming.filter { value in
|
||||
guard let old = rules.first(where: { $0.id == value.id }) else { return true }
|
||||
return old.pattern != value.pattern || old.replacement != value.replacement
|
||||
}
|
||||
guard !changed.isEmpty else { return }
|
||||
let updated = importer.merge(changed, into: rules)
|
||||
try await repository.save(updated)
|
||||
rules = updated
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool, for id: Int64) async {
|
||||
guard let index = rules.firstIndex(where: { $0.id == id }) else { return }
|
||||
var updated = rules
|
||||
updated[index].isEnabled = enabled
|
||||
await persist(updated)
|
||||
}
|
||||
|
||||
func remove(at offsets: IndexSet) async {
|
||||
var updated = rules
|
||||
updated.remove(atOffsets: offsets)
|
||||
await persist(updated)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func persist(_ updated: [ReplaceRule]) async -> Bool {
|
||||
do {
|
||||
try await repository.save(updated)
|
||||
rules = updated
|
||||
return true
|
||||
} catch {
|
||||
notice = .init(kind: .error, title: "净化规则保存失败", detail: error.localizedDescription)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct ReplaceRuleManagementView: View {
|
||||
@EnvironmentObject private var library: ReplaceRuleLibraryModel
|
||||
@State private var showsImporter = false
|
||||
@State private var showsTextImporter = false
|
||||
@State private var showsQRImporter = false
|
||||
@State private var editingRule: ReplaceRule?
|
||||
@State private var startsWithTest = false
|
||||
@State private var query = ""
|
||||
@State private var group: String?
|
||||
|
||||
private var filtered: [ReplaceRule] {
|
||||
library.rules.filter { rule in
|
||||
(group == nil || rule.group == group) && (query.isEmpty || rule.name.localizedCaseInsensitiveContains(query)
|
||||
|| rule.pattern.localizedCaseInsensitiveContains(query) || (rule.group?.localizedCaseInsensitiveContains(query) ?? false))
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if library.rules.isEmpty {
|
||||
ReadingUnavailableView("还没有净化规则", systemImage: "wand.and.stars", description: Text("新建规则,或导入 Legado 净化规则。"))
|
||||
Button("新建规则", systemImage: "plus") { edit(library.newRule()) }
|
||||
} else {
|
||||
Section {
|
||||
ReadingLabeledContent("已启用", value: "\(library.rules.filter(\.isEnabled).count) / \(library.rules.count)")
|
||||
Picker("分组", selection: $group) {
|
||||
Text("全部").tag(String?.none)
|
||||
ForEach(Array(Set(library.rules.compactMap(\.group))).sorted(), id: \.self) { Text($0).tag(String?.some($0)) }
|
||||
}
|
||||
}
|
||||
Section {
|
||||
ForEach(filtered) { rule in
|
||||
HStack(spacing: 12) {
|
||||
Button { edit(rule) } label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: rule.isRegex ? "textformat.alt" : "character.cursor.ibeam")
|
||||
.foregroundStyle(rule.isEnabled ? ReadingTheme.accent : .secondary)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(rule.name.isEmpty ? rule.pattern : rule.name).foregroundStyle(.primary).lineLimit(1)
|
||||
Text(rule.group ?? (rule.scopeTitle && rule.scopeContent ? "标题与正文" : rule.scopeTitle ? "标题" : "正文"))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Text(rule.pattern).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}.buttonStyle(.plain)
|
||||
Toggle("启用", isOn: Binding(get: { rule.isEnabled }, set: { value in Task { await library.setEnabled(value, for: rule.id) } }))
|
||||
.labelsHidden().accessibilityLabel("启用\(rule.name)")
|
||||
}
|
||||
.contextMenu {
|
||||
Button("编辑", systemImage: "pencil") { edit(rule) }
|
||||
Button("测试", systemImage: "play") { edit(rule, test: true) }
|
||||
Button("复制", systemImage: "doc.on.doc") { edit(library.newRule(copying: rule)) }
|
||||
Button("删除", systemImage: "trash", role: .destructive) { Task { await library.remove(id: rule.id) } }
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
let ids = offsets.compactMap { filtered.indices.contains($0) ? filtered[$0].id : nil }
|
||||
Task { for id in ids { await library.remove(id: id) } }
|
||||
}
|
||||
.onMove { offsets, destination in Task { await library.move(from: offsets, to: destination) } }
|
||||
.moveDisabled(group != nil || !query.isEmpty)
|
||||
} footer: { Text("点击规则编辑,长按可复制或测试。拖动排序决定执行顺序。").font(.footnote) }
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle("净化规则")
|
||||
.searchable(text: $query, prompt: "名称、匹配内容或分组")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) { EditButton().disabled(library.rules.isEmpty || group != nil || !query.isEmpty) }
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu("更多", systemImage: "plus") {
|
||||
Button("新建规则", systemImage: "plus") { edit(library.newRule()) }
|
||||
Button("从文件导入", systemImage: "doc.badge.plus") { showsImporter = true }
|
||||
Button("URL 或粘贴 JSON", systemImage: "link.badge.plus") { showsTextImporter = true }
|
||||
Button("二维码导入", systemImage: "qrcode.viewfinder") { showsQRImporter = true }
|
||||
ReadingShareLink(item: exportJSON) { Label("导出全部规则", systemImage: "square.and.arrow.up") }
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingRule) { ReplaceRuleEditorView(rule: $0, startsWithTest: startsWithTest).environmentObject(library) }
|
||||
.sheet(isPresented: $showsQRImporter) { QRCodeImportView { value in Task { await library.importText(value) } } }
|
||||
.sheet(isPresented: $showsTextImporter) { ReplaceRuleTextImportView().environmentObject(library) }
|
||||
.fileImporter(isPresented: $showsImporter, allowedContentTypes: [.item], allowsMultipleSelection: true) { result in
|
||||
switch result {
|
||||
case .success(let urls): Task { await library.importFiles(urls) }
|
||||
case .failure(let error): library.notice = .init(kind: .error, title: "无法选择文件", detail: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
.alert(item: $library.notice) { notice in
|
||||
Alert(title: Text(notice.title), message: Text(notice.detail), dismissButton: .default(Text("好")))
|
||||
}
|
||||
}
|
||||
private func edit(_ rule: ReplaceRule, test: Bool = false) { startsWithTest = test; editingRule = rule }
|
||||
private var exportJSON: String {
|
||||
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return (try? encoder.encode(library.rules)).map { String(decoding: $0, as: UTF8.self) } ?? "[]"
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReplaceRuleTextImportView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var library: ReplaceRuleLibraryModel
|
||||
@State private var value = ""
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("粘贴净化规则 JSON、HTTP(S) 链接或 Legado 净化规则深链。").font(.footnote).foregroundStyle(.secondary)
|
||||
SyntaxCodeEditor(text: $value, language: .json)
|
||||
}.padding()
|
||||
.navigationTitle("导入净化规则").navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("导入") {
|
||||
let text = value
|
||||
dismiss()
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 400_000_000)
|
||||
await library.importText(text)
|
||||
}
|
||||
}.disabled(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class RuleSubscriptionModel: ObservableObject {
|
||||
@Published private(set) var values: [RuleSub] = []
|
||||
@Published var errorMessage: String?
|
||||
@Published private(set) var pendingIDs = Set<Int64>()
|
||||
private let repository: SQLiteConfigurationRepository?
|
||||
|
||||
init() {
|
||||
repository = (try? LegadoDatabase(url: LegadoDatabase.defaultURL())).map { SQLiteConfigurationRepository(database: $0) }
|
||||
}
|
||||
func load() async {
|
||||
guard let repository else { errorMessage = "无法打开规则订阅数据库"; return }
|
||||
do { values = try await repository.loadRuleSubs().sorted { $0.customOrder < $1.customOrder } }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
func save(_ value: RuleSub) async -> Bool {
|
||||
guard !value.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { errorMessage = "URL不能为空"; return false }
|
||||
guard !values.contains(where: { $0.id != value.id && $0.url == value.url }) else { errorMessage = "URL已存在"; return false }
|
||||
var updated = values
|
||||
if let index = updated.firstIndex(where: { $0.id == value.id }) { updated[index] = value }
|
||||
else { var item = value; item.customOrder = (updated.map(\.customOrder).max() ?? -1) + 1; updated.append(item) }
|
||||
return await persist(updated)
|
||||
}
|
||||
func remove(at offsets: IndexSet) async { _ = await persist(values.enumerated().filter { !offsets.contains($0.offset) }.map(\.element)) }
|
||||
func move(from offsets: IndexSet, to destination: Int) async {
|
||||
var updated = values; updated.move(fromOffsets: offsets, toOffset: destination)
|
||||
for index in updated.indices { updated[index].customOrder = index + 1 }
|
||||
_ = await persist(updated)
|
||||
}
|
||||
func checkAutomaticUpdates(bookSources: SourceLibraryModel, rssSources: RSSLibraryModel, replaceRules: ReplaceRuleLibraryModel) async {
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
for index in values.indices where values[index].autoUpdate && values[index].type != 1 {
|
||||
let interval = Int64(max(values[index].updateInterval, 0)) * 3_600_000
|
||||
guard values[index].update + interval <= now else { continue }
|
||||
var subscription = values[index]
|
||||
subscription.update = now
|
||||
var timestamped = values; timestamped[index] = subscription
|
||||
guard await persist(timestamped) else { continue }
|
||||
do {
|
||||
let data = try await fetch(subscription.url)
|
||||
if subscription.silentUpdate {
|
||||
switch subscription.type {
|
||||
case 1: try await rssSources.applySilentSubscription(data)
|
||||
case 2: try await replaceRules.applySilentSubscription(data)
|
||||
default: try await bookSources.applySilentSubscription(data)
|
||||
}
|
||||
} else if try hasUpdates(data, type: subscription.type, bookSources: bookSources, rssSources: rssSources, replaceRules: replaceRules) {
|
||||
pendingIDs.insert(subscription.id)
|
||||
}
|
||||
} catch { errorMessage = "\(subscription.name.isEmpty ? subscription.url : subscription.name):\(error.localizedDescription)" }
|
||||
}
|
||||
}
|
||||
func clearPending(_ id: Int64) { pendingIDs.remove(id) }
|
||||
|
||||
private func fetch(_ value: String) async throws -> Data {
|
||||
let withoutUA = value.hasSuffix("#requestWithoutUA")
|
||||
let raw = withoutUA ? String(value.dropLast("#requestWithoutUA".count)) : value
|
||||
guard let url = URL(string: raw) else { throw URLError(.badURL) }
|
||||
var request = URLRequest(url: url)
|
||||
if withoutUA { request.setValue("", forHTTPHeaderField: "User-Agent") }
|
||||
let (data, response) = try await URLSessionHTTPTransport.shared.download(for: request)
|
||||
if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) { throw URLError(.badServerResponse) }
|
||||
return data
|
||||
}
|
||||
|
||||
private func hasUpdates(_ data: Data, type: Int, bookSources: SourceLibraryModel, rssSources: RSSLibraryModel, replaceRules: ReplaceRuleLibraryModel) throws -> Bool {
|
||||
switch type {
|
||||
case 1:
|
||||
let incoming = try RSSSourceImporter().decode(data).sources
|
||||
guard incoming.first?.sourceUrl.isEmpty == false else { throw RSSSourceImportError.invalidSource }
|
||||
return incoming.contains { value in rssSources.sources.first(where: { $0.sourceUrl == value.sourceUrl }).map { $0.lastUpdateTime < value.lastUpdateTime } ?? true }
|
||||
case 2:
|
||||
let incoming = try ReplaceRuleImporter().decode(data)
|
||||
return incoming.contains { value in
|
||||
guard let old = replaceRules.rules.first(where: { $0.id == value.id }) else { return true }
|
||||
return old.pattern != value.pattern || old.replacement != value.replacement
|
||||
}
|
||||
default:
|
||||
let incoming = try BookSourceImporter().decode(data)
|
||||
guard incoming.first?.bookSourceUrl.isEmpty == false else { throw BookSourceImportError.emptyDocument }
|
||||
return incoming.contains { value in bookSources.sources.first(where: { $0.bookSourceUrl == value.bookSourceUrl }).map { $0.lastUpdateTime < value.lastUpdateTime } ?? true }
|
||||
}
|
||||
}
|
||||
private func persist(_ updated: [RuleSub]) async -> Bool {
|
||||
guard let repository else { errorMessage = "无法打开规则订阅数据库"; return false }
|
||||
do { try await repository.saveRuleSubs(updated); values = try await repository.loadRuleSubs().sorted { $0.customOrder < $1.customOrder }; return true }
|
||||
catch { errorMessage = error.localizedDescription; return false }
|
||||
}
|
||||
}
|
||||
|
||||
struct RuleSubscriptionView: View {
|
||||
@EnvironmentObject private var bookSources: SourceLibraryModel
|
||||
@EnvironmentObject private var replaceRules: ReplaceRuleLibraryModel
|
||||
@EnvironmentObject private var model: RuleSubscriptionModel
|
||||
@State private var editing: RuleSub?
|
||||
|
||||
private var visibleValues: [RuleSub] { model.values.filter { $0.type != 1 } }
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if visibleValues.isEmpty { ReadingUnavailableView("没有规则订阅", systemImage: "link.badge.plus") }
|
||||
ForEach(visibleValues) { value in
|
||||
Button { open(value) } label: {
|
||||
HStack(spacing: 12) {
|
||||
Text(typeName(value.type)).font(.caption.bold()).foregroundStyle(ReadingTheme.accent).frame(width: 48)
|
||||
VStack(alignment: .leading, spacing: 4) { Text(value.name.isEmpty ? value.url : value.name).foregroundStyle(.primary); Text(value.url).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
|
||||
Spacer()
|
||||
if value.autoUpdate { Image(systemName: value.silentUpdate ? "arrow.triangle.2.circlepath.circle.fill" : "arrow.triangle.2.circlepath.circle").foregroundStyle(.secondary) }
|
||||
if model.pendingIDs.contains(value.id) { Text("有更新").font(.caption2).foregroundStyle(.orange) }
|
||||
}
|
||||
}.swipeActions(edge: .leading) { Button("编辑", systemImage: "pencil") { editing = value }.tint(.blue) }
|
||||
}.onDelete { offsets in
|
||||
let ids = Set(offsets.compactMap { visibleValues.indices.contains($0) ? visibleValues[$0].id : nil })
|
||||
let sourceOffsets = IndexSet(model.values.indices.filter { ids.contains(model.values[$0].id) })
|
||||
Task { await model.remove(at: sourceOffsets) }
|
||||
}
|
||||
}
|
||||
.navigationTitle("规则订阅")
|
||||
.toolbar { ToolbarItem(placement: .primaryAction) { Button("新建", systemImage: "plus") { editing = RuleSub() } }; ToolbarItem(placement: .primaryAction) { EditButton() } }
|
||||
.task { await model.load() }
|
||||
.sheet(item: $editing) { value in RuleSubscriptionEditView(value: value) { item in await model.save(item) } }
|
||||
.alert("规则订阅", isPresented: Binding(get: { model.errorMessage != nil }, set: { if !$0 { model.errorMessage = nil } })) { Button("好") { model.errorMessage = nil } } message: { Text(model.errorMessage ?? "") }
|
||||
}
|
||||
|
||||
private func open(_ value: RuleSub) {
|
||||
model.clearPending(value.id)
|
||||
Task {
|
||||
switch value.type {
|
||||
case 2: await replaceRules.importRemoteURL(value.url)
|
||||
default: await bookSources.importRemoteURL(value.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
private func typeName(_ type: Int) -> String { type == 2 ? "净化" : "书源" }
|
||||
}
|
||||
|
||||
private struct RuleSubscriptionEditView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var value: RuleSub
|
||||
@State private var saving = false
|
||||
let save: (RuleSub) async -> Bool
|
||||
init(value: RuleSub, save: @escaping (RuleSub) async -> Bool) { _value = State(initialValue: value); self.save = save }
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
Form {
|
||||
Picker("类型", selection: $value.type) { Text("书源").tag(0); Text("净化规则").tag(2) }
|
||||
TextField("名称", text: $value.name)
|
||||
ReadingTextField("订阅URL", text: $value.url, axis: .vertical).textInputAutocapitalization(.never).disableAutocorrection(true)
|
||||
Toggle("自动更新", isOn: $value.autoUpdate)
|
||||
if value.autoUpdate {
|
||||
Stepper("更新间隔:\(value.updateInterval) 小时", value: $value.updateInterval, in: 1...720)
|
||||
Toggle("静默更新", isOn: $value.silentUpdate)
|
||||
}
|
||||
}.navigationTitle("规则订阅").toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) { Button(saving ? "保存中…" : "保存") { saving = true; Task { if await save(value) { dismiss() }; saving = false } }.disabled(saving || value.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor
|
||||
final class TxtTocRuleManagementModel: ObservableObject {
|
||||
@Published private(set) var rules: [TxtTocRule] = []
|
||||
@Published var message: String?
|
||||
private let repository: SQLiteConfigurationRepository?
|
||||
|
||||
init() {
|
||||
repository = (try? LegadoDatabase(url: LegadoDatabase.defaultURL())).map {
|
||||
SQLiteConfigurationRepository(database: $0)
|
||||
}
|
||||
}
|
||||
|
||||
func load() async {
|
||||
guard let repository else { message = "无法打开目录规则数据库"; return }
|
||||
do {
|
||||
rules = try await repository.loadTxtTocRules().sorted { $0.serialNumber < $1.serialNumber }
|
||||
if rules.isEmpty { await importDefaults(report: false) }
|
||||
}
|
||||
catch { message = error.localizedDescription }
|
||||
}
|
||||
|
||||
func importDefaults(report: Bool = true) async {
|
||||
let defaults = TxtTocRuleDefaults.load()
|
||||
guard !defaults.isEmpty else { message = "应用内置目录规则缺失"; return }
|
||||
var updated = rules.filter { $0.id >= 0 }
|
||||
updated.append(contentsOf: defaults)
|
||||
if await persist(updated), report { message = "已恢复内置目录规则" }
|
||||
}
|
||||
|
||||
func save(_ rule: TxtTocRule) async -> Bool {
|
||||
guard !rule.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
!rule.rule.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rule.id == -100 else {
|
||||
message = "名称和正则不能为空"
|
||||
return false
|
||||
}
|
||||
guard rule.rule.isEmpty || (try? NSRegularExpression(pattern: rule.rule, options: .anchorsMatchLines)) != nil else {
|
||||
message = "目录正则语法错误"
|
||||
return false
|
||||
}
|
||||
var updated = rules
|
||||
if let index = updated.firstIndex(where: { $0.id == rule.id }) { updated[index] = rule }
|
||||
else { var value = rule; value.serialNumber = (updated.map(\.serialNumber).max() ?? 0) + 1; updated.append(value) }
|
||||
return await persist(updated)
|
||||
}
|
||||
|
||||
func toggle(_ rule: TxtTocRule) async {
|
||||
var value = rule; value.enable.toggle(); _ = await save(value)
|
||||
}
|
||||
|
||||
func remove(at offsets: IndexSet) async {
|
||||
_ = await persist(rules.enumerated().filter { !offsets.contains($0.offset) }.map(\.element))
|
||||
}
|
||||
|
||||
func move(from offsets: IndexSet, to destination: Int) async {
|
||||
var updated = rules
|
||||
updated.move(fromOffsets: offsets, toOffset: destination)
|
||||
for index in updated.indices { updated[index].serialNumber = index + 1 }
|
||||
_ = await persist(updated)
|
||||
}
|
||||
|
||||
func importData(_ data: Data) async {
|
||||
do {
|
||||
let decoded: [TxtTocRule]
|
||||
if let values = try? JSONDecoder().decode([TxtTocRule].self, from: data) { decoded = values }
|
||||
else { decoded = [try JSONDecoder().decode(TxtTocRule.self, from: data)] }
|
||||
var merged = rules
|
||||
for value in decoded {
|
||||
if let index = merged.firstIndex(where: { $0.id == value.id }) { merged[index] = value }
|
||||
else { merged.append(value) }
|
||||
}
|
||||
for index in merged.indices { merged[index].serialNumber = index + 1 }
|
||||
if await persist(merged) { message = "已导入 \(decoded.count) 条目录规则" }
|
||||
} catch { message = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func persist(_ values: [TxtTocRule]) async -> Bool {
|
||||
guard let repository else { message = "无法打开目录规则数据库"; return false }
|
||||
do {
|
||||
try await repository.saveTxtTocRules(values)
|
||||
rules = try await repository.loadTxtTocRules().sorted { $0.serialNumber < $1.serialNumber }
|
||||
return true
|
||||
} catch { message = error.localizedDescription; return false }
|
||||
}
|
||||
}
|
||||
|
||||
struct TxtTocRuleManagementView: View {
|
||||
@StateObject private var model = TxtTocRuleManagementModel()
|
||||
@State private var editing: TxtTocRule?
|
||||
@State private var importing = false
|
||||
@State private var exporting = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if model.rules.isEmpty {
|
||||
ReadingUnavailableView("没有TXT目录规则", systemImage: "list.bullet.rectangle")
|
||||
}
|
||||
ForEach(model.rules) { rule in
|
||||
Button { editing = rule } label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: rule.enable ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(rule.enable ? ReadingTheme.accent : .secondary)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(rule.name).foregroundStyle(.primary)
|
||||
Text(rule.rule).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button(rule.enable ? "停用" : "启用") { Task { await model.toggle(rule) } }
|
||||
.tint(rule.enable ? .orange : .green)
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in Task { await model.remove(at: offsets) } }
|
||||
.onMove { offsets, destination in Task { await model.move(from: offsets, to: destination) } }
|
||||
}
|
||||
.navigationTitle("TXT目录规则")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("新建", systemImage: "plus") { editing = TxtTocRule() }
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu {
|
||||
Button("导入JSON", systemImage: "square.and.arrow.down") { importing = true }
|
||||
Button("导出JSON", systemImage: "square.and.arrow.up") { exporting = true }
|
||||
.disabled(model.rules.isEmpty)
|
||||
Button("恢复内置规则", systemImage: "arrow.counterclockwise") {
|
||||
Task { await model.importDefaults() }
|
||||
}
|
||||
EditButton()
|
||||
} label: { Image(systemName: "ellipsis.circle") }
|
||||
}
|
||||
}
|
||||
.task { await model.load() }
|
||||
.sheet(item: $editing) { value in
|
||||
TxtTocRuleEditView(value: value) { await model.save($0) }
|
||||
}
|
||||
.fileImporter(isPresented: $importing, allowedContentTypes: [.json, .plainText]) { result in
|
||||
guard case .success(let url) = result else { return }
|
||||
let granted = url.startAccessingSecurityScopedResource()
|
||||
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
||||
if let data = try? Data(contentsOf: url) { Task { await model.importData(data) } }
|
||||
}
|
||||
.fileExporter(
|
||||
isPresented: $exporting,
|
||||
document: TxtTocRuleDocument(rules: model.rules),
|
||||
contentType: .json,
|
||||
defaultFilename: "txtTocRules.json"
|
||||
) { result in
|
||||
if case .failure(let error) = result { model.message = error.localizedDescription }
|
||||
}
|
||||
.alert("TXT目录规则", isPresented: Binding(
|
||||
get: { model.message != nil },
|
||||
set: { if !$0 { model.message = nil } }
|
||||
)) { Button("好") { model.message = nil } } message: { Text(model.message ?? "") }
|
||||
}
|
||||
}
|
||||
|
||||
private struct TxtTocRuleEditView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var value: TxtTocRule
|
||||
@State private var sample = ""
|
||||
@State private var saving = false
|
||||
let save: (TxtTocRule) async -> Bool
|
||||
|
||||
init(value: TxtTocRule, save: @escaping (TxtTocRule) async -> Bool) {
|
||||
_value = State(initialValue: value)
|
||||
_sample = State(initialValue: value.example ?? "")
|
||||
self.save = save
|
||||
}
|
||||
|
||||
private var testResult: String {
|
||||
guard let expression = try? NSRegularExpression(pattern: value.rule, options: .anchorsMatchLines) else { return "正则语法错误" }
|
||||
let range = NSRange(sample.startIndex..., in: sample)
|
||||
let values = expression.matches(in: sample, range: range).prefix(20).compactMap { match -> String? in
|
||||
guard let range = Range(match.range, in: sample) else { return nil }
|
||||
return String(sample[range])
|
||||
}
|
||||
return values.isEmpty ? "没有匹配" : "匹配 \(values.count) 条:\n" + values.joined(separator: "\n")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
Form {
|
||||
TextField("名称", text: $value.name)
|
||||
ReadingTextField("目录正则", text: $value.rule, axis: .vertical)
|
||||
.font(.body.monospaced()).textInputAutocapitalization(.never).disableAutocorrection(true)
|
||||
ReadingTextField("标题处理JavaScript(可选)", text: Binding(
|
||||
get: { value.replacement }, set: { value.replacement = $0 }
|
||||
), axis: .vertical).font(.body.monospaced())
|
||||
Toggle("启用", isOn: $value.enable)
|
||||
Section("测试文本") {
|
||||
TextEditor(text: $sample).frame(minHeight: 130)
|
||||
Text(testResult).font(.caption.monospaced()).foregroundStyle(.secondary).textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.navigationTitle("TXT目录规则")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(saving ? "保存中…" : "保存") {
|
||||
value.example = sample.isEmpty ? nil : sample
|
||||
saving = true
|
||||
Task { if await save(value) { dismiss() }; saving = false }
|
||||
}.disabled(saving)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TxtTocRuleDocument: FileDocument {
|
||||
static var readableContentTypes: [UTType] { [.json] }
|
||||
let rules: [TxtTocRule]
|
||||
init(rules: [TxtTocRule]) { self.rules = rules }
|
||||
init(configuration: ReadConfiguration) throws { rules = [] }
|
||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
||||
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return FileWrapper(regularFileWithContents: try encoder.encode(rules))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
#if SWIFT_PACKAGE
|
||||
import LegadoCore
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class SearchViewModel: ObservableObject {
|
||||
enum State: Equatable { case idle, searching, finished, empty, unavailable }
|
||||
|
||||
@Published private(set) var state: State = .idle
|
||||
@Published private(set) var books: [SearchBook] = []
|
||||
@Published private(set) var diagnostics: [RuleDiagnostic] = []
|
||||
@Published private(set) var isLoadingMore = false
|
||||
@Published private(set) var hasMore = false
|
||||
@Published private(set) var isPaused = false
|
||||
@Published private(set) var searchKeywords: [SearchKeyword] = []
|
||||
@Published private(set) var completedSourceCount = 0
|
||||
@Published private(set) var totalSourceCount = 0
|
||||
@Published private(set) var submittedKeyword = ""
|
||||
@Published private(set) var isShowingCachedResults = false
|
||||
|
||||
private let service: BookSearchService
|
||||
private let keywordRepository: any SearchKeywordRepository
|
||||
private let searchBookRepository: any SearchBookRepository
|
||||
private var searchTask: Task<Void, Never>?
|
||||
private var currentKeyword = ""
|
||||
private var pagedSources: [BookSource] = []
|
||||
private var currentPrecision = false
|
||||
private var page = 1
|
||||
private var activePage = 1
|
||||
private var searchGeneration = 0
|
||||
private var pendingSources: [BookSource] = []
|
||||
private var pendingSourceIDs = Set<String>()
|
||||
private var sourceNames: [String: String] = [:]
|
||||
private var accumulator = BookSearchAccumulator()
|
||||
private var cachedIdentities = Set<String>()
|
||||
private var liveIdentities = Set<String>()
|
||||
private var collectedDiagnostics: [RuleDiagnostic] = []
|
||||
private var completedCount = 0
|
||||
private var foundMoreOnActivePage = false
|
||||
|
||||
init(service: BookSearchService = .init(),
|
||||
keywordRepository: any SearchKeywordRepository = SearchKeywordRepositoryFactory.live(),
|
||||
searchBookRepository: any SearchBookRepository = SearchBookRepositoryFactory.live()) {
|
||||
self.service = service
|
||||
self.keywordRepository = keywordRepository
|
||||
self.searchBookRepository = searchBookRepository
|
||||
}
|
||||
|
||||
deinit { searchTask?.cancel() }
|
||||
|
||||
func search(keyword: String, sources: [BookSource], precision: Bool = false) {
|
||||
let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !keyword.isEmpty else { cancel(reset: true); return }
|
||||
searchTask?.cancel()
|
||||
searchGeneration &+= 1
|
||||
currentKeyword = keyword
|
||||
submittedKeyword = keyword
|
||||
currentPrecision = precision
|
||||
page = 1
|
||||
activePage = 1
|
||||
pendingSources = eligible(sources)
|
||||
pendingSourceIDs = Set(pendingSources.map(\.id))
|
||||
pagedSources = pendingSources.filter {
|
||||
let url = $0.searchUrl ?? ""
|
||||
return url.contains("{{page}}") || url.contains("searchPage") || url.contains("<")
|
||||
}
|
||||
sourceNames = Dictionary(pendingSources.map { ($0.id, $0.bookSourceName) }, uniquingKeysWith: { first, _ in first })
|
||||
accumulator = .init()
|
||||
cachedIdentities = []
|
||||
liveIdentities = []
|
||||
collectedDiagnostics = []
|
||||
completedCount = 0
|
||||
foundMoreOnActivePage = false
|
||||
isShowingCachedResults = false
|
||||
hasMore = true
|
||||
isPaused = false
|
||||
isLoadingMore = false
|
||||
books = []
|
||||
diagnostics = []
|
||||
completedSourceCount = 0
|
||||
totalSourceCount = pendingSources.count
|
||||
guard totalSourceCount > 0 else { hasMore = false; state = .unavailable; return }
|
||||
state = .searching
|
||||
Task { await recordKeyword(keyword) }
|
||||
startWorker(loadCache: true)
|
||||
}
|
||||
|
||||
private func eligible(_ sources: [BookSource]) -> [BookSource] {
|
||||
var seen = Set<String>()
|
||||
return sources.filter { $0.enabled && $0.searchUrl?.isEmpty == false && seen.insert($0.id).inserted }
|
||||
}
|
||||
|
||||
private func startWorker(loadCache: Bool = false) {
|
||||
let generation = searchGeneration
|
||||
let requestedPage = activePage
|
||||
let keyword = currentKeyword
|
||||
let sources = pendingSources.filter { pendingSourceIDs.contains($0.id) }
|
||||
searchTask = Task { [service, searchBookRepository] in
|
||||
var preferred = Set<String>()
|
||||
if loadCache {
|
||||
let cached = (try? await searchBookRepository.cachedMatches(keyword: keyword)) ?? []
|
||||
guard !Task.isCancelled, generation == searchGeneration else { return }
|
||||
let enabled = Dictionary(sources.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
|
||||
let now = Date().timeIntervalSince1970 * 1_000
|
||||
let usable = cached.filter { book in
|
||||
guard let source = enabled[book.origin] else { return false }
|
||||
let age = now - Double(book.time)
|
||||
return age >= 0 && age < 86_400_000 && source.lastUpdateTime <= book.time
|
||||
}
|
||||
accumulator.add(usable)
|
||||
cachedIdentities = Set(usable.map { BookSearchService.mergeIdentity(for: $0) })
|
||||
preferred = Set(usable.flatMap(\.origins)).intersection(Set(enabled.keys))
|
||||
publishResults()
|
||||
}
|
||||
var lastVisibleUpdate = ProcessInfo.processInfo.systemUptime - 1
|
||||
for await outcome in service.searchOutcomes(keyword: keyword, page: requestedPage, sources: sources, preferredSourceIDs: preferred) {
|
||||
guard !Task.isCancelled, generation == searchGeneration else { return }
|
||||
pendingSourceIDs.remove(outcome.sourceID)
|
||||
completedCount += 1
|
||||
collectedDiagnostics += namedDiagnostics(outcome)
|
||||
for book in outcome.books {
|
||||
let identity = BookSearchService.mergeIdentity(for: book)
|
||||
if requestedPage > 1 && !liveIdentities.contains(identity) { foundMoreOnActivePage = true }
|
||||
liveIdentities.insert(identity)
|
||||
accumulator.add([book], replacingExisting: cachedIdentities.remove(identity) != nil)
|
||||
}
|
||||
let now = ProcessInfo.processInfo.systemUptime
|
||||
if books.isEmpty || now - lastVisibleUpdate >= 0.25 {
|
||||
publishResults()
|
||||
lastVisibleUpdate = now
|
||||
}
|
||||
if !outcome.books.isEmpty { try? await searchBookRepository.put(outcome.books) }
|
||||
}
|
||||
guard !Task.isCancelled, generation == searchGeneration else { return }
|
||||
publishResults()
|
||||
page = requestedPage
|
||||
hasMore = requestedPage == 1 ? !books.isEmpty && !pagedSources.isEmpty : foundMoreOnActivePage
|
||||
isLoadingMore = false
|
||||
state = books.isEmpty ? .empty : .finished
|
||||
}
|
||||
}
|
||||
|
||||
private func publishResults() {
|
||||
let values = accumulator.results(keyword: currentKeyword)
|
||||
let visible = currentPrecision ? values.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(currentKeyword)
|
||||
|| $0.author.localizedCaseInsensitiveContains(currentKeyword)
|
||||
|| $0.kind?.localizedCaseInsensitiveContains(currentKeyword) == true
|
||||
} : values
|
||||
if books != visible { books = visible }
|
||||
if diagnostics != collectedDiagnostics { diagnostics = collectedDiagnostics }
|
||||
if completedSourceCount != completedCount { completedSourceCount = completedCount }
|
||||
let cachedOnly = !visible.isEmpty && liveIdentities.isEmpty
|
||||
if isShowingCachedResults != cachedOnly { isShowingCachedResults = cachedOnly }
|
||||
}
|
||||
|
||||
func loadMore() {
|
||||
guard state == .finished, hasMore, !isLoadingMore, !isPaused, !currentKeyword.isEmpty else { return }
|
||||
isLoadingMore = true
|
||||
searchGeneration &+= 1
|
||||
activePage = page + 1
|
||||
pendingSources = eligible(pagedSources)
|
||||
pendingSourceIDs = Set(pendingSources.map(\.id))
|
||||
foundMoreOnActivePage = false
|
||||
startWorker()
|
||||
}
|
||||
|
||||
func cancel(reset: Bool = false) {
|
||||
searchTask?.cancel()
|
||||
searchTask = nil
|
||||
searchGeneration &+= 1
|
||||
isLoadingMore = false
|
||||
if reset {
|
||||
accumulator = .init()
|
||||
cachedIdentities = []
|
||||
liveIdentities = []
|
||||
collectedDiagnostics = []
|
||||
pendingSources = []
|
||||
pendingSourceIDs = []
|
||||
isShowingCachedResults = false
|
||||
isPaused = false
|
||||
state = .idle
|
||||
books = []
|
||||
diagnostics = []
|
||||
hasMore = false
|
||||
completedSourceCount = 0
|
||||
totalSourceCount = 0
|
||||
submittedKeyword = ""
|
||||
} else {
|
||||
publishResults()
|
||||
isPaused = true
|
||||
state = books.isEmpty ? .empty : .finished
|
||||
}
|
||||
}
|
||||
|
||||
func resume() {
|
||||
guard isPaused else { return }
|
||||
isPaused = false
|
||||
guard !pendingSourceIDs.isEmpty else { loadMore(); return }
|
||||
if activePage == 1 { state = .searching }
|
||||
else { isLoadingMore = true }
|
||||
startWorker()
|
||||
}
|
||||
|
||||
private func namedDiagnostics(_ outcome: SourceSearchOutcome) -> [RuleDiagnostic] {
|
||||
guard let name = sourceNames[outcome.sourceID] else { return outcome.diagnostics }
|
||||
return outcome.diagnostics.map {
|
||||
var value = $0
|
||||
value.message = "\(name):\(value.message)"
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private func addDiagnostic(_ diagnostic: RuleDiagnostic) {
|
||||
collectedDiagnostics.append(diagnostic)
|
||||
diagnostics.append(diagnostic)
|
||||
}
|
||||
|
||||
func loadSearchKeywords() async {
|
||||
do { searchKeywords = try await keywordRepository.load(order: .lastUseTime) }
|
||||
catch { addDiagnostic(.init(severity: .warning, code: "search.history.load", message: error.localizedDescription, rule: "SearchKeywordDao.flowByTime")) }
|
||||
}
|
||||
|
||||
func clearSearchKeywords() async {
|
||||
do {
|
||||
try await keywordRepository.deleteAll()
|
||||
searchKeywords = []
|
||||
UserDefaults.standard.removeObject(forKey: "bookSearchHistory")
|
||||
} catch { addDiagnostic(.init(severity: .warning, code: "search.history.clear", message: error.localizedDescription, rule: "SearchKeywordDao.deleteAll")) }
|
||||
}
|
||||
|
||||
func deleteSearchKeyword(_ word: String) async {
|
||||
do {
|
||||
try await keywordRepository.delete(word)
|
||||
searchKeywords.removeAll { $0.word == word }
|
||||
} catch { addDiagnostic(.init(severity: .warning, code: "search.history.delete", message: error.localizedDescription, rule: "SearchKeywordDao.delete")) }
|
||||
}
|
||||
|
||||
private func recordKeyword(_ word: String) async {
|
||||
do {
|
||||
_ = try await keywordRepository.record(word, at: Int64(Date().timeIntervalSince1970 * 1_000))
|
||||
searchKeywords = try await keywordRepository.load(order: .lastUseTime)
|
||||
UserDefaults.standard.removeObject(forKey: "bookSearchHistory")
|
||||
} catch { addDiagnostic(.init(severity: .warning, code: "search.history.record", message: error.localizedDescription, rule: "SearchKeywordDao.insert/update")) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum AppIdentity {
|
||||
static let name = "小浣熊阅读"
|
||||
static let authorURL = URL(string: "https://github.com/Aminark")!
|
||||
static let projectURL = URL(string: "https://github.com/Aminark/legado-ios")!
|
||||
}
|
||||
|
||||
struct AboutView: View {
|
||||
private var name: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? AppIdentity.name }
|
||||
private var version: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—" }
|
||||
private var build: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "—" }
|
||||
private var diagnosticSummary: String {
|
||||
"\(name) \(version) (\(build))\niOS \(UIDevice.current.systemVersion)\n数据库版本 \(LegadoDatabase.schemaVersion)"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
VStack(spacing: 12) {
|
||||
Image("BrandIcon").resizable().scaledToFit()
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
Text(name).font(.title2.weight(.semibold))
|
||||
Text("快速搜书,自由阅读").font(.subheadline).foregroundStyle(.secondary)
|
||||
Text("兼容阅读书源的 iOS 阅读应用")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}.frame(maxWidth: .infinity).padding(.vertical, 16)
|
||||
ReadingLabeledContent("版本", value: version)
|
||||
ReadingLabeledContent("构建", value: build)
|
||||
ReadingLabeledContent("系统", value: "iOS \(UIDevice.current.systemVersion)")
|
||||
ReadingShareLink(item: diagnosticSummary) { Label("分享版本信息", systemImage: "square.and.arrow.up") }
|
||||
}
|
||||
Section {
|
||||
Link(destination: AppIdentity.authorURL) {
|
||||
Label("开发者 · Aminark", systemImage: "person.crop.circle")
|
||||
}
|
||||
Link(destination: AppIdentity.projectURL) {
|
||||
Label("项目源码 · legado-ios", systemImage: "chevron.left.forwardslash.chevron.right")
|
||||
}
|
||||
ReadingShareLink(item: AppIdentity.projectURL) {
|
||||
Label("分享项目地址", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
ReadingLabeledContent("许可证", value: "GPL-3.0")
|
||||
ReadingLabeledContent("安装方式", value: "IPA 自行签名")
|
||||
ReadingLabeledContent("App Store / TestFlight", value: "暂不提供")
|
||||
} header: {
|
||||
Text("项目与开发者")
|
||||
} footer: {
|
||||
Text("IPA 发布后可从项目 GitHub Releases 获取,并自行签名安装。项目暂不上架 App Store,也不提供 TestFlight。")
|
||||
}
|
||||
Section("开源许可") {
|
||||
NavigationLink("开源许可") { OpenSourceLicensesView() }
|
||||
}
|
||||
Section("免责协议") {
|
||||
Text("小浣熊阅读不提供、内置或分发网络书源,也不提供在线书库。书源由用户自行从网络公开分享获取,或自行编写后导入;在线书籍及订阅内容由对应的第三方网站提供。")
|
||||
Text("书籍正文、封面、简介等内容的版权归原作者或合法权利人所有。书源可被导入、内容可被访问,不代表已获得版权授权;请仅在获得授权或法律允许的范围内使用,并支持正版阅读。")
|
||||
Text("第三方书源及其内容不受本应用控制,可能因网站调整、网络环境等原因失效。本应用不保证第三方内容的准确性、完整性、安全性或书源的持续可用性,请自行核实来源。")
|
||||
Text("用户应对自行导入的书源及使用行为负责,遵守适用法律法规和来源网站的使用规则,不得利用本应用侵犯他人权益、传播违法内容或未经授权复制、分发作品。")
|
||||
Text("如发现涉嫌侵权内容,请停止访问、删除相关书源及本地缓存,并向内容来源网站反馈。本协议不免除任何一方依法应承担的责任。")
|
||||
}
|
||||
.font(.footnote)
|
||||
.lineSpacing(4)
|
||||
.textSelection(.enabled)
|
||||
Section("数据与隐私") {
|
||||
Text("书架、阅读进度、书源和诊断日志保存在本机。导入书源、阅读在线内容和刷新订阅时,会连接相应来源的网站。")
|
||||
Text("应用不会自动上传诊断日志;可在日志页面手动导出。")
|
||||
Text("应用不提供内置网络书源或在线书库。请使用你有权访问的内容与书源。")
|
||||
}.font(.footnote)
|
||||
}
|
||||
.navigationTitle("关于")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
private struct OpenSourceLicensesView: View {
|
||||
var body: some View {
|
||||
List {
|
||||
license("小浣熊阅读 / Legado", resource: "Legado-LICENSE", detail: "GNU GPL v3")
|
||||
license("SwiftSoup", resource: "SwiftSoup-LICENSE", detail: "MIT License")
|
||||
license("Kanna", resource: "Kanna-LICENSE", detail: "MIT License")
|
||||
}.navigationTitle("开源许可").navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func license(_ title: String, resource: String, detail: String) -> some View {
|
||||
NavigationLink {
|
||||
ScrollView {
|
||||
Text((Bundle.main.url(forResource: resource, withExtension: "txt")
|
||||
.flatMap { try? String(contentsOf: $0, encoding: .utf8) }) ?? "许可文件无法读取")
|
||||
.font(.system(.footnote, design: .monospaced)).textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading).padding()
|
||||
}.navigationTitle(title).navigationBarTitleDisplayMode(.inline)
|
||||
} label: { ReadingLabeledContent(title, value: detail) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
import MetricKit
|
||||
|
||||
final class AppDiagnostics: NSObject, MXMetricManagerSubscriber, @unchecked Sendable {
|
||||
static let shared = AppDiagnostics()
|
||||
|
||||
func start() {
|
||||
MXMetricManager.shared.add(self)
|
||||
DiagnosticLog.shared.record(category: "应用", message: "启动 \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "")")
|
||||
}
|
||||
|
||||
func didReceive(_ payloads: [MXDiagnosticPayload]) {
|
||||
for payload in payloads {
|
||||
for crash in payload.crashDiagnostics ?? [] {
|
||||
DiagnosticLog.shared.record(.error, category: "系统诊断", message: "崩溃诊断:\(String(decoding: crash.jsonRepresentation(), as: UTF8.self))")
|
||||
}
|
||||
for hang in payload.hangDiagnostics ?? [] {
|
||||
DiagnosticLog.shared.record(.warning, category: "系统诊断", message: "卡顿诊断:\(String(decoding: hang.jsonRepresentation(), as: UTF8.self))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct DiagnosticLogView: View {
|
||||
@State private var entries: [DiagnosticLogEntry] = []
|
||||
@State private var query = ""
|
||||
@State private var level = "all"
|
||||
@State private var confirmsClear = false
|
||||
@State private var exportsLog = false
|
||||
@State private var exportText = ""
|
||||
@State private var errorMessage: String?
|
||||
|
||||
private var filtered: [DiagnosticLogEntry] {
|
||||
entries.filter {
|
||||
(level == "all" || $0.level.rawValue == level)
|
||||
&& (query.isEmpty || "\($0.category) \($0.message)".localizedCaseInsensitiveContains(query))
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
Picker("级别", selection: $level) {
|
||||
Text("全部").tag("all")
|
||||
Text("信息").tag("info")
|
||||
Text("警告").tag("warning")
|
||||
Text("错误").tag("error")
|
||||
}.pickerStyle(.segmented)
|
||||
Text("\(entries.count) 条日志 · 下拉刷新")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
} footer: {
|
||||
Text("在“其他设置”开启记录后,会保存网络请求、JS 输出和运行错误。崩溃诊断由 iOS 延迟提供,可搜索“系统诊断”查看。")
|
||||
}
|
||||
if filtered.isEmpty {
|
||||
ReadingUnavailableView(query.isEmpty ? "暂无日志" : "没有匹配的日志", systemImage: "doc.text.magnifyingglass")
|
||||
} else {
|
||||
ForEach(filtered) { entry in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(entry.category).font(.caption.weight(.semibold))
|
||||
Text(levelTitle(entry.level)).font(.caption)
|
||||
.foregroundStyle(entry.level == .error ? .red : .secondary)
|
||||
Spacer()
|
||||
Text(entry.date, format: .dateTime.month().day().hour().minute().second())
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Text(entry.message).font(.system(.footnote, design: .monospaced)).textSelection(.enabled)
|
||||
}.padding(.vertical, 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("日志与诊断")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.searchable(text: $query, prompt: "搜索内容或分类")
|
||||
.task { reload() }
|
||||
.refreshable { reload() }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu("日志操作", systemImage: "ellipsis") {
|
||||
Button("刷新", systemImage: "arrow.clockwise") { reload() }
|
||||
Button("导出全部日志", systemImage: "square.and.arrow.up") {
|
||||
exportText = DiagnosticLog.shared.export()
|
||||
exportsLog = true
|
||||
}.disabled(entries.isEmpty)
|
||||
Button("清空日志", systemImage: "trash", role: .destructive) { confirmsClear = true }
|
||||
.disabled(entries.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog("清空全部日志?", isPresented: $confirmsClear, titleVisibility: .visible) {
|
||||
Button("清空日志", role: .destructive) {
|
||||
do { try DiagnosticLog.shared.clear(); reload() }
|
||||
catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
.fileExporter(isPresented: $exportsLog, document: DiagnosticLogDocument(text: exportText), contentType: .plainText, defaultFilename: "阅读诊断日志") { result in
|
||||
if case .failure(let error) = result { errorMessage = error.localizedDescription }
|
||||
}
|
||||
.alert("日志操作失败", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
|
||||
Button("好") { errorMessage = nil }
|
||||
} message: { Text(errorMessage ?? "") }
|
||||
}
|
||||
|
||||
private func reload() { entries = DiagnosticLog.shared.snapshot() }
|
||||
private func levelTitle(_ level: DiagnosticLogEntry.Level) -> String {
|
||||
switch level { case .info: "信息"; case .warning: "警告"; case .error: "错误" }
|
||||
}
|
||||
}
|
||||
|
||||
private struct DiagnosticLogDocument: FileDocument {
|
||||
static var readableContentTypes: [UTType] { [.plainText] }
|
||||
var text: String
|
||||
init(text: String) { self.text = text }
|
||||
init(configuration: ReadConfiguration) throws {
|
||||
text = String(decoding: configuration.file.regularFileContents ?? Data(), as: UTF8.self)
|
||||
}
|
||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { .init(regularFileWithContents: Data(text.utf8)) }
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct OtherSettingsView: View {
|
||||
@AppStorage("auto_refresh") private var autoRefresh = false
|
||||
@AppStorage("onlyUpdateRead") private var onlyUpdateRead = false
|
||||
@AppStorage("defaultHomePage") private var defaultHomePage = "bookshelf"
|
||||
@State private var settings = ApplicationSettingsStore.shared.settings
|
||||
@State private var maintenance: Maintenance?
|
||||
@State private var isWorking = false
|
||||
@State private var message: String?
|
||||
|
||||
private enum Maintenance: String, Identifiable {
|
||||
case webCache = "清理网页缓存"
|
||||
case database = "整理数据库"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("启动与书架") {
|
||||
Picker("启动页面", selection: $defaultHomePage) {
|
||||
Text("书架").tag("bookshelf")
|
||||
Text("发现").tag("explore")
|
||||
Text("统计").tag("statistics")
|
||||
Text("设置").tag("settings")
|
||||
}
|
||||
Toggle("启动时更新书架", isOn: $autoRefresh)
|
||||
Toggle("自动更新仅检查读过的书", isOn: $onlyUpdateRead).disabled(!autoRefresh)
|
||||
}
|
||||
Section("网络与存储") {
|
||||
NavigationLink {
|
||||
NetworkSettingsView()
|
||||
} label: { Label("网络与缓存设置", systemImage: "network") }
|
||||
NavigationLink {
|
||||
CacheManagementView()
|
||||
} label: { Label("章节缓存管理", systemImage: "internaldrive") }
|
||||
Button("清理网页缓存", systemImage: "globe") { maintenance = .webCache }
|
||||
Button("整理数据库", systemImage: "externaldrive.badge.checkmark") { maintenance = .database }
|
||||
}
|
||||
Section {
|
||||
Toggle("记录运行日志", isOn: Binding(get: { settings.recordLog }, set: { enabled in
|
||||
var value = ApplicationSettingsStore.shared.settings
|
||||
value.recordLog = enabled
|
||||
do { try ApplicationSettingsStore.shared.save(value); settings = value }
|
||||
catch { message = error.localizedDescription }
|
||||
}))
|
||||
NavigationLink {
|
||||
DiagnosticLogView()
|
||||
} label: { Label("日志与诊断", systemImage: "doc.text.magnifyingglass") }
|
||||
} header: { Text("诊断") } footer: {
|
||||
Text("日志仅保存在本机,最多保留 500 条。网络日志不记录请求正文与 Header,URL 查询参数和常见凭据会隐藏。")
|
||||
}
|
||||
}
|
||||
.navigationTitle("其他设置")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.disabled(isWorking)
|
||||
.overlay { if isWorking { ProgressView("正在处理…").padding().background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) } }
|
||||
.onAppear { settings = ApplicationSettingsStore.shared.settings }
|
||||
.confirmationDialog(maintenance?.rawValue ?? "", isPresented: Binding(
|
||||
get: { maintenance != nil }, set: { if !$0 { maintenance = nil } }
|
||||
), titleVisibility: .visible) {
|
||||
if let operation = maintenance {
|
||||
Button(operation.rawValue) { Task { await perform(operation) } }
|
||||
}
|
||||
Button("取消", role: .cancel) { maintenance = nil }
|
||||
} message: {
|
||||
Text(maintenance == .webCache ? "清理网页和图片的临时缓存,保留登录 Cookie、书架和阅读进度。" : "回收数据库中的空闲空间,保留全部书籍、规则和阅读记录。")
|
||||
}
|
||||
.alert("操作结果", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) {
|
||||
Button("好") { message = nil }
|
||||
} message: { Text(message ?? "") }
|
||||
}
|
||||
|
||||
@MainActor private func perform(_ operation: Maintenance) async {
|
||||
maintenance = nil
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
do {
|
||||
switch operation {
|
||||
case .webCache:
|
||||
await WKWebsiteDataStore.default().removeData(ofTypes: [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache, WKWebsiteDataTypeOfflineWebApplicationCache], modifiedSince: .distantPast)
|
||||
URLCache.shared.removeAllCachedResponses()
|
||||
case .database:
|
||||
let database = try LegadoDatabase(url: LegadoDatabase.defaultURL())
|
||||
try await database.compact()
|
||||
}
|
||||
DiagnosticLog.shared.record(category: "存储", message: "\(operation.rawValue)完成")
|
||||
message = "\(operation.rawValue)完成"
|
||||
} catch {
|
||||
DiagnosticLog.shared.record(.error, category: "存储", message: error.localizedDescription)
|
||||
message = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkSettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var settings = ApplicationSettingsStore.shared.settings
|
||||
@State private var errorMessage: String?
|
||||
@State private var isSaving = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
ReadingTextField("留空使用默认浏览器 UA", text: $settings.userAgent, axis: .vertical)
|
||||
.textInputAutocapitalization(.never).disableAutocorrection(true)
|
||||
.accessibilityLabel("User-Agent")
|
||||
} header: { Text("默认 User-Agent") } footer: {
|
||||
Text("书源或 RSS 单独设置的 UA 优先于此项。")
|
||||
}
|
||||
Section {
|
||||
TextField("例如 http://127.0.0.1:7890", text: $settings.proxy)
|
||||
.keyboardType(.URL).textInputAutocapitalization(.never).disableAutocorrection(true)
|
||||
.accessibilityLabel("全局代理")
|
||||
} header: { Text("全局代理") } footer: {
|
||||
Text("支持 HTTP、HTTPS 和 SOCKS5 地址,不含账号密码。书源代理优先于全局代理;使用代理时由代理解析域名。")
|
||||
}
|
||||
Section {
|
||||
TextEditor(text: $settings.hosts)
|
||||
.font(.system(.footnote, design: .monospaced)).frame(minHeight: 130)
|
||||
.textInputAutocapitalization(.never).disableAutocorrection(true)
|
||||
.accessibilityLabel("Hosts")
|
||||
} header: { Text("自定义 Hosts") } footer: {
|
||||
Text("每行填写 IP地址 域名,例如 192.168.1.2 books.example.com。支持 IPv4、IPv6 和 # 注释。适用于应用内 HTTP/HTTPS 请求;系统网页、登录网页和系统媒体播放器使用系统网络设置。")
|
||||
}
|
||||
Section("连接") {
|
||||
Picker("请求超时", selection: $settings.timeoutSeconds) {
|
||||
Text("跟随书源").tag(0)
|
||||
ForEach([5, 10, 15, 30, 60, 120, 180, 300], id: \.self) { Text("\($0) 秒").tag($0) }
|
||||
}
|
||||
Stepper("搜索并发数:\(settings.concurrentSources)", value: $settings.concurrentSources, in: 1...64)
|
||||
Toggle("兼容无效证书的书源", isOn: $settings.ignoreCertificateErrors)
|
||||
Text("证书兼容开启时允许自签名或过期证书;关闭后由系统验证 HTTPS 证书。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
Section("章节缓存") {
|
||||
Picker("容量上限", selection: $settings.chapterCacheMB) {
|
||||
ForEach([16, 32, 64, 128, 256, 512, 1024, 2048, 4096], id: \.self) {
|
||||
Text($0 >= 1024 ? "\($0 / 1024) GB" : "\($0) MB").tag($0)
|
||||
}
|
||||
}
|
||||
Stepper(settings.prefetchChapters == 0 ? "关闭章节预加载" : "预加载 \(settings.prefetchChapters) 章", value: $settings.prefetchChapters, in: 0...20)
|
||||
Text("超出上限时清理最久未读取的章节;容量调整在保存时生效。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
Section {
|
||||
Button("恢复本页默认值") {
|
||||
let recordLog = settings.recordLog
|
||||
settings = .init()
|
||||
settings.recordLog = recordLog
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("网络与缓存")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("保存") { Task { await save() } }.disabled(isSaving)
|
||||
}
|
||||
}
|
||||
.disabled(isSaving)
|
||||
.alert("保存失败", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
|
||||
Button("好") { errorMessage = nil }
|
||||
} message: { Text(errorMessage ?? "") }
|
||||
}
|
||||
|
||||
@MainActor private func save() async {
|
||||
isSaving = true
|
||||
defer { isSaving = false }
|
||||
do {
|
||||
settings.recordLog = ApplicationSettingsStore.shared.settings.recordLog
|
||||
try ApplicationSettingsStore.shared.save(settings)
|
||||
try await ChapterContentCache.live().pruneIfNeeded()
|
||||
DiagnosticLog.shared.record(category: "设置", message: "网络与缓存设置已保存")
|
||||
dismiss()
|
||||
} catch { errorMessage = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class SourceDebugViewModel: ObservableObject {
|
||||
@Published var keyword = ""
|
||||
@Published private(set) var isRunning = false
|
||||
@Published private(set) var report: BookSourceDebugReport?
|
||||
|
||||
private let debugger: BookSourceDebugger
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
init(debugger: BookSourceDebugger = .init()) {
|
||||
self.debugger = debugger
|
||||
}
|
||||
|
||||
deinit { task?.cancel() }
|
||||
|
||||
func run(source: BookSource) {
|
||||
let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !keyword.isEmpty else { return }
|
||||
task?.cancel()
|
||||
isRunning = true
|
||||
report = nil
|
||||
task = Task { [debugger] in
|
||||
let value = await debugger.run(source: source, keyword: keyword)
|
||||
guard !Task.isCancelled else { return }
|
||||
report = value
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SourceDebugView: View {
|
||||
let source: BookSource
|
||||
@StateObject private var model = SourceDebugViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
introCard
|
||||
runCard
|
||||
if model.isRunning { runningCard }
|
||||
if let report = model.report { reportView(report) }
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.background(ReadingTheme.canvas)
|
||||
.navigationTitle("书源调试")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var introCard: some View {
|
||||
ReadingCard {
|
||||
HStack(spacing: 13) {
|
||||
Image(systemName: "stethoscope")
|
||||
.font(.title2).foregroundStyle(ReadingTheme.coral)
|
||||
.frame(width: 46, height: 46)
|
||||
.background(ReadingTheme.coral.opacity(0.11), in: RoundedRectangle(cornerRadius: 14))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(source.bookSourceName).font(.headline)
|
||||
Text("依次验证搜索 → 详情 → 目录 → 第一章正文")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private var runCard: some View {
|
||||
ReadingCard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("样本关键词").font(.caption.bold()).foregroundStyle(.secondary)
|
||||
TextField("输入该书源能搜到的书名", text: $model.keyword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textInputAutocapitalization(.never)
|
||||
.submitLabel(.go)
|
||||
.onSubmit { model.run(source: source) }
|
||||
Button { model.run(source: source) } label: {
|
||||
Label(model.isRunning ? "正在调试…" : "运行完整链路", systemImage: "play.fill")
|
||||
.font(.headline).frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(ReadingTheme.accent)
|
||||
.disabled(model.isRunning || model.keyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var runningCard: some View {
|
||||
ReadingCard {
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("端到端检查进行中").font(.subheadline.bold())
|
||||
Text("请求只发送给这个书源,最多读取 5 页目录与正文。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func reportView(_ report: BookSourceDebugReport) -> some View {
|
||||
ReadingCard {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(report.passed ? "完整链路通过" : "发现兼容问题").font(.headline)
|
||||
Text(report.passed ? "该样本已成功读取到正文" : "展开失败阶段查看规则诊断")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: report.passed ? "checkmark.seal.fill" : "exclamationmark.triangle.fill")
|
||||
.font(.title2).foregroundStyle(report.passed ? .green : .orange)
|
||||
}
|
||||
}
|
||||
VStack(spacing: 10) {
|
||||
ForEach(report.steps) { step in DebugStepCard(step: step) }
|
||||
}
|
||||
if let preview = report.contentPreview {
|
||||
ReadingCard {
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
Label(report.sampleChapterTitle ?? "正文样本", systemImage: "text.quote")
|
||||
.font(.headline)
|
||||
Text(preview).font(.footnote).foregroundStyle(.secondary).lineSpacing(3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class SourceBatchTestModel: ObservableObject {
|
||||
enum Status: Equatable {
|
||||
case waiting
|
||||
case testing
|
||||
case passed(Int)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@Published private(set) var statuses: [String: Status] = [:]
|
||||
@Published private(set) var completedCount = 0
|
||||
@Published private(set) var totalCount = 0
|
||||
@Published private(set) var isTesting = false
|
||||
@Published var selectedFailedIDs = Set<String>()
|
||||
|
||||
var failedCount: Int {
|
||||
statuses.values.reduce(into: 0) { count, status in
|
||||
if case .failed = status { count += 1 }
|
||||
}
|
||||
}
|
||||
|
||||
private let service: BookSearchService
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
init(service: BookSearchService = .init()) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
deinit { task?.cancel() }
|
||||
|
||||
func start(sources: [BookSource], keyword: String) {
|
||||
stop()
|
||||
let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !keyword.isEmpty, !sources.isEmpty else { return }
|
||||
statuses = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, .waiting) })
|
||||
selectedFailedIDs = []
|
||||
completedCount = 0
|
||||
totalCount = sources.count
|
||||
isTesting = true
|
||||
|
||||
task = Task { [service] in
|
||||
for source in sources.prefix(16) { statuses[source.id] = .testing }
|
||||
var startedCount = min(16, sources.count)
|
||||
for await outcome in service.searchOutcomes(
|
||||
keyword: keyword,
|
||||
sources: sources,
|
||||
maximumConcurrentSources: ApplicationSettingsStore.shared.settings.concurrentSources,
|
||||
includeDisabled: true
|
||||
) {
|
||||
guard !Task.isCancelled else { return }
|
||||
completedCount += 1
|
||||
if outcome.books.isEmpty {
|
||||
let message = outcome.diagnostics.first(where: { $0.severity == .error })?.message
|
||||
?? "没有搜索到结果"
|
||||
statuses[outcome.sourceID] = .failed(message)
|
||||
selectedFailedIDs.insert(outcome.sourceID)
|
||||
} else {
|
||||
statuses[outcome.sourceID] = .passed(outcome.books.count)
|
||||
}
|
||||
if startedCount < sources.count {
|
||||
statuses[sources[startedCount].id] = .testing
|
||||
startedCount += 1
|
||||
}
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
isTesting = false
|
||||
task = nil
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
isTesting = false
|
||||
for (id, status) in statuses where status == .testing || status == .waiting {
|
||||
statuses[id] = .waiting
|
||||
}
|
||||
}
|
||||
|
||||
func toggleSelection(_ sourceID: String) {
|
||||
if !selectedFailedIDs.insert(sourceID).inserted { selectedFailedIDs.remove(sourceID) }
|
||||
}
|
||||
}
|
||||
|
||||
struct SourceBatchTestView: View {
|
||||
private enum CleanupAction: String, Identifiable {
|
||||
case disable
|
||||
case delete
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
@StateObject private var model = SourceBatchTestModel()
|
||||
@AppStorage("sourceTestKeyword") private var keyword = "我的"
|
||||
@State private var includesDisabled = false
|
||||
@State private var onlyShowsFailed = false
|
||||
@State private var cleanupAction: CleanupAction?
|
||||
|
||||
private var testedSources: [BookSource] {
|
||||
includesDisabled ? library.sources : library.sources.filter(\.enabled)
|
||||
}
|
||||
|
||||
private var visibleSources: [BookSource] {
|
||||
guard onlyShowsFailed else { return testedSources }
|
||||
return testedSources.filter {
|
||||
if case .failed? = model.statuses[$0.id] { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
TextField("校验搜索词", text: $keyword)
|
||||
.textInputAutocapitalization(.never)
|
||||
Toggle("包含已停用书源", isOn: $includesDisabled)
|
||||
.disabled(model.isTesting)
|
||||
Toggle("只看失败", isOn: $onlyShowsFailed)
|
||||
} footer: {
|
||||
Text("快速测试以搜索是否能返回结果为准。部分正常书源可能不收录当前关键词,删除前请复核或更换关键词重测。")
|
||||
}
|
||||
|
||||
if model.totalCount > 0 {
|
||||
Section {
|
||||
ProgressView(value: Double(model.completedCount), total: Double(model.totalCount))
|
||||
HStack {
|
||||
Text("已完成 \(model.completedCount)/\(model.totalCount)")
|
||||
Spacer()
|
||||
Text("失败 \(model.failedCount) · 已选 \(model.selectedFailedIDs.count)")
|
||||
.foregroundStyle(model.failedCount == 0 ? Color.secondary : Color.red)
|
||||
}
|
||||
.font(.subheadline.monospacedDigit())
|
||||
}
|
||||
}
|
||||
|
||||
Section("书源") {
|
||||
ForEach(visibleSources) { source in
|
||||
SourceBatchTestRow(
|
||||
source: source,
|
||||
status: model.statuses[source.id] ?? .waiting,
|
||||
selected: model.selectedFailedIDs.contains(source.id),
|
||||
onToggle: { model.toggleSelection(source.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("书源测试")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
if model.isTesting {
|
||||
Button("停止", systemImage: "stop.fill") { model.stop() }
|
||||
} else {
|
||||
Button("开始", systemImage: "play.fill") {
|
||||
model.start(sources: testedSources, keyword: keyword)
|
||||
}
|
||||
.disabled(testedSources.isEmpty || keyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if !model.isTesting, !model.selectedFailedIDs.isEmpty {
|
||||
HStack {
|
||||
Button("停用所选", systemImage: "pause.circle") { cleanupAction = .disable }
|
||||
Spacer()
|
||||
Button("删除所选", systemImage: "trash", role: .destructive) { cleanupAction = .delete }
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 18).padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
.alert(item: $cleanupAction) { action in
|
||||
Alert(
|
||||
title: Text(action == .delete ? "删除失败书源?" : "停用失败书源?"),
|
||||
message: Text("将处理已勾选的 \(model.selectedFailedIDs.count) 个书源。搜索不到当前关键词不一定代表书源永久失效。"),
|
||||
primaryButton: .destructive(Text(action == .delete ? "删除" : "停用")) {
|
||||
let ids = model.selectedFailedIDs
|
||||
Task {
|
||||
if action == .delete { await library.remove(sourceIDs: ids) }
|
||||
else { await library.setEnabled(false, for: ids) }
|
||||
model.selectedFailedIDs = []
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceBatchTestRow: View {
|
||||
let source: BookSource
|
||||
let status: SourceBatchTestModel.Status
|
||||
let selected: Bool
|
||||
let onToggle: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(color)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(source.bookSourceName).font(.headline)
|
||||
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
if case .failed = status {
|
||||
Button(action: onToggle) {
|
||||
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var detail: String {
|
||||
switch status {
|
||||
case .waiting: "等待测试"
|
||||
case .testing: "正在测试…"
|
||||
case .passed(let count): "通过,搜索到 \(count) 条结果"
|
||||
case .failed(let message): message
|
||||
}
|
||||
}
|
||||
|
||||
private var icon: String {
|
||||
switch status {
|
||||
case .waiting: "clock"
|
||||
case .testing: "arrow.triangle.2.circlepath"
|
||||
case .passed: "checkmark.circle.fill"
|
||||
case .failed: "xmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
switch status {
|
||||
case .waiting: .secondary
|
||||
case .testing: ReadingTheme.accent
|
||||
case .passed: .green
|
||||
case .failed: .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DebugStepCard: View {
|
||||
let step: SourceDebugStep
|
||||
|
||||
var body: some View {
|
||||
DisclosureGroup {
|
||||
if step.diagnostics.isEmpty {
|
||||
Text("该阶段没有规则诊断。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 8)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
ForEach(step.diagnostics) { diagnostic in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(diagnostic.message).font(.caption)
|
||||
Text(diagnostic.code).font(.caption2.monospaced()).foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(color)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(color.opacity(0.1), in: Circle())
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(step.stage.title).font(.subheadline.bold())
|
||||
Text(step.summary).font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
if step.elapsedMilliseconds > 0 {
|
||||
Text("\(step.elapsedMilliseconds) ms")
|
||||
.font(.caption2.monospacedDigit()).foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(.background, in: RoundedRectangle(cornerRadius: 17, style: .continuous))
|
||||
.overlay { RoundedRectangle(cornerRadius: 17, style: .continuous).stroke(.primary.opacity(0.06)) }
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
switch step.status {
|
||||
case .passed: .green
|
||||
case .warning: .orange
|
||||
case .failed: .red
|
||||
case .skipped: .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var icon: String {
|
||||
switch step.status {
|
||||
case .passed: "checkmark"
|
||||
case .warning: "exclamationmark"
|
||||
case .failed: "xmark"
|
||||
case .skipped: "forward.end"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SourceEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
let original: BookSource?
|
||||
let onSaved: ((BookSource) -> Void)?
|
||||
@State private var document: BookSourceEditorDocument
|
||||
@State private var jsonText: String
|
||||
@State private var mode = 0
|
||||
@State private var sectionID = "basic"
|
||||
@State private var errorMessage: String?
|
||||
@State private var validation: [BookSourceIssue] = []
|
||||
@State private var debugSource: BookSource?
|
||||
@State private var isSaving = false
|
||||
@State private var showsDiscard = false
|
||||
private let initialJSON: String
|
||||
|
||||
init(source: BookSource? = nil, onSaved: ((BookSource) -> Void)? = nil) {
|
||||
self.onSaved = onSaved
|
||||
original = source
|
||||
let doc = try! BookSourceEditorDocument(source: source ?? BookSource(bookSourceUrl: "", bookSourceName: ""))
|
||||
let json = (try? doc.json()) ?? "{}"
|
||||
initialJSON = json
|
||||
_document = State(initialValue: doc)
|
||||
_jsonText = State(initialValue: json)
|
||||
}
|
||||
|
||||
private var isDirty: Bool { mode == 1 ? jsonText != initialJSON : (try? document.json()) != initialJSON }
|
||||
private var currentSection: BookSourceEditorSection { BookSourceEditorSchema.sections.first { $0.id == sectionID }! }
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
Picker("编辑模式", selection: Binding(get: { mode }, set: { value in switchMode(value) })) {
|
||||
Text("字段编辑").tag(0)
|
||||
Text("JSON").tag(1)
|
||||
}.pickerStyle(.segmented).padding(.horizontal).padding(.vertical, 8)
|
||||
if mode == 1 {
|
||||
SyntaxCodeEditor(text: $jsonText, language: .json)
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(BookSourceEditorSchema.sections) { section in
|
||||
Button(section.title) { sectionID = section.id }
|
||||
.buttonStyle(.bordered)
|
||||
.tint(sectionID == section.id ? ReadingTheme.accent : .secondary)
|
||||
}
|
||||
Button("选项") { sectionID = "options" }.buttonStyle(.bordered)
|
||||
.tint(sectionID == "options" ? ReadingTheme.accent : .secondary)
|
||||
}.padding(.horizontal)
|
||||
}.padding(.vertical, 6)
|
||||
Form {
|
||||
if sectionID == "options" { options }
|
||||
else {
|
||||
Section(currentSection.title) {
|
||||
ForEach(currentSection.fields) { field in
|
||||
NavigationLink {
|
||||
RuleCodeFieldView(title: field.title, path: field.path, text: string(field.path),
|
||||
language: field.isJavaScript ? .javascript : .rule, help: field.help)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(field.title)
|
||||
Text(document.value(at: field.path)?.stringValue.isEmpty == false ? document.value(at: field.path)!.stringValue : field.path)
|
||||
.font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Text("点击字段可全屏编辑规则。支持 @css:、XPath、$. JSONPath、@js:、{{key}}、{{page}} 和 ## 替换语法。未识别的扩展字段保留在 JSON 中。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if !validation.isEmpty {
|
||||
Section("静态检查") {
|
||||
ForEach(validation) { issue in
|
||||
Label("\(issue.field):\(issue.message)", systemImage: issue.severity == .error ? "xmark.circle" : "exclamationmark.triangle")
|
||||
.font(.footnote).foregroundStyle(issue.severity == .error ? .red : .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(original == nil ? "新建书源" : "编辑书源")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { if isDirty { showsDiscard = true } else { dismiss() } }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu("更多", systemImage: "ellipsis.circle") {
|
||||
Button("格式化 JSON", systemImage: "text.alignleft") { perform { try synchronize(); jsonText = try document.json() } }
|
||||
Button("校验配置", systemImage: "checkmark.shield") { check() }
|
||||
Button("调试当前草稿", systemImage: "stethoscope") { perform { try synchronize(); debugSource = try document.source() } }
|
||||
Button("复制 JSON", systemImage: "doc.on.doc") { perform { try synchronize(); UIPasteboard.general.string = try document.json() } }
|
||||
Button("粘贴 JSON", systemImage: "doc.on.clipboard") {
|
||||
perform {
|
||||
guard let value = UIPasteboard.general.string else { return }
|
||||
let pasted = try BookSourceEditorDocument(json: value)
|
||||
_ = try JSONDecoder().decode(BookSource.self, from: Data(pasted.json().utf8))
|
||||
document = pasted
|
||||
jsonText = try pasted.json()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(isSaving ? "保存中" : "保存", action: save).disabled(isSaving)
|
||||
}
|
||||
}
|
||||
.readingNavigationDestination(isPresented: Binding(
|
||||
get: { debugSource != nil },
|
||||
set: { if !$0 { debugSource = nil } }
|
||||
)) {
|
||||
if let debugSource { SourceDebugView(source: debugSource) }
|
||||
}
|
||||
.alert("无法完成操作", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
|
||||
Button("好", role: .cancel) {}
|
||||
} message: { Text(errorMessage ?? "") }
|
||||
.confirmationDialog("放弃未保存的修改?", isPresented: $showsDiscard, titleVisibility: .visible) {
|
||||
Button("放弃修改", role: .destructive) { dismiss() }
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(isDirty || isSaving)
|
||||
}
|
||||
|
||||
private var options: some View {
|
||||
Group {
|
||||
Section("书源类型") {
|
||||
Picker("类型", selection: Binding(get: { Int(document.value(at: "bookSourceType")?.stringValue ?? "0") ?? 0 },
|
||||
set: { document.set(.number(Double($0)), at: "bookSourceType") })) {
|
||||
Text("文本").tag(0); Text("音频").tag(1); Text("图片").tag(2); Text("文件").tag(3); Text("视频").tag(4)
|
||||
}
|
||||
}
|
||||
Section("开关") {
|
||||
Toggle("启用书源", isOn: boolean("enabled", fallback: true))
|
||||
Toggle("启用发现", isOn: boolean("enabledExplore", fallback: true))
|
||||
Toggle("Cookie Jar", isOn: boolean("enabledCookieJar", fallback: true))
|
||||
Toggle("事件监听", isOn: boolean("eventListener"))
|
||||
Toggle("定制按钮", isOn: boolean("customButton"))
|
||||
}
|
||||
Section("排序与状态") {
|
||||
ForEach(["customOrder", "weight", "lastUpdateTime", "respondTime"], id: \.self) { key in
|
||||
HStack {
|
||||
Text(["customOrder": "排序编号", "weight": "搜索权重", "lastUpdateTime": "更新时间 (ms)", "respondTime": "响应时间 (ms)"][key]!)
|
||||
TextField(key, text: number(key)).keyboardType(.numbersAndPunctuation).multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func string(_ path: String) -> Binding<String> {
|
||||
Binding(get: { document.value(at: path)?.stringValue ?? "" }, set: { document.set($0.isEmpty ? nil : .string($0), at: path) })
|
||||
}
|
||||
private func boolean(_ path: String, fallback: Bool = false) -> Binding<Bool> {
|
||||
Binding(get: { if case .bool(let value) = document.value(at: path) { return value }; return fallback },
|
||||
set: { document.set(.bool($0), at: path) })
|
||||
}
|
||||
private func number(_ path: String) -> Binding<String> {
|
||||
Binding(get: { document.value(at: path)?.stringValue ?? "0" }, set: { document.set(.string($0), at: path) })
|
||||
}
|
||||
private func synchronize() throws { if mode == 1 { document = try BookSourceEditorDocument(json: jsonText) } }
|
||||
private func switchMode(_ next: Int) {
|
||||
guard next != mode else { return }
|
||||
perform {
|
||||
if next == 0 { try synchronize() }
|
||||
else { jsonText = try document.json() }
|
||||
mode = next
|
||||
}
|
||||
}
|
||||
private func perform(_ action: () throws -> Void) {
|
||||
do { try action() } catch { errorMessage = readable(error) }
|
||||
}
|
||||
private func readable(_ error: Error) -> String {
|
||||
if case DecodingError.typeMismatch(_, let context) = error {
|
||||
return "\(context.codingPath.map(\.stringValue).joined(separator: ".")):字段类型不正确。\(context.debugDescription)"
|
||||
}
|
||||
return error.localizedDescription
|
||||
}
|
||||
private func check() {
|
||||
perform {
|
||||
try synchronize()
|
||||
let source = try JSONDecoder().decode(BookSource.self, from: Data(document.json().utf8))
|
||||
validation = BookSourceValidator.validate(source)
|
||||
if validation.isEmpty { errorMessage = "静态检查通过。可通过“调试当前草稿”验证实际请求及解析结果。" }
|
||||
}
|
||||
}
|
||||
private func save() {
|
||||
perform {
|
||||
try synchronize()
|
||||
var source = try document.source()
|
||||
if library.sources.contains(where: { $0.id == source.id && $0.id != original?.id }) {
|
||||
throw BookSourceImportError.duplicate(source.id)
|
||||
}
|
||||
source.lastUpdateTime = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
isSaving = true
|
||||
Task {
|
||||
let saved = await library.saveSource(source, replacing: original?.id)
|
||||
isSaving = false
|
||||
if saved { onSaved?(source); dismiss() }
|
||||
else {
|
||||
let message = library.notice?.detail ?? "保存失败"
|
||||
library.notice = nil
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
#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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
@preconcurrency import WebKit
|
||||
import SwiftUI
|
||||
|
||||
struct SourceLoginView: View {
|
||||
let source: BookSource
|
||||
@State private var reloadID = UUID()
|
||||
@State private var status = "打开登录页后,Cookie 会自动同步给搜索与阅读请求。"
|
||||
@State private var isChecking = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "lock.shield.fill").foregroundStyle(ReadingTheme.accent)
|
||||
Text(status).font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||||
Spacer()
|
||||
if isChecking { ProgressView().controlSize(.small) }
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 10)
|
||||
.background(Color(uiColor: .secondarySystemBackground))
|
||||
|
||||
if let request = loginRequest {
|
||||
SourceLoginWebView(
|
||||
request: request,
|
||||
sourceURL: source.bookSourceUrl,
|
||||
sourceID: source.id,
|
||||
loginCheckJavaScript: source.loginCheckJs,
|
||||
reloadID: reloadID,
|
||||
status: $status,
|
||||
isChecking: $isChecking
|
||||
)
|
||||
} else {
|
||||
ReadingUnavailableView("登录地址无效", systemImage: "link.badge.plus", description: Text(source.loginUrl ?? "书源没有配置登录地址"))
|
||||
}
|
||||
}
|
||||
.navigationTitle("登录 · \(source.bookSourceName)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("重新载入", systemImage: "arrow.clockwise") { reloadID = UUID() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var loginRequest: URLRequest? {
|
||||
guard let value = source.loginUrl,
|
||||
let prepared = try? LegadoURLBuilder().prepare(
|
||||
value,
|
||||
baseURL: URL(string: source.bookSourceUrl),
|
||||
defaultHeaders: sourceHeaders
|
||||
) else { return nil }
|
||||
return prepared.urlRequest(handleCookies: source.enabledCookieJar ?? true)
|
||||
}
|
||||
|
||||
private var sourceHeaders: [String: String] {
|
||||
guard let value = source.header,
|
||||
let data = value.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
|
||||
return object.mapValues { String(describing: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceLoginWebView: UIViewRepresentable {
|
||||
let request: URLRequest
|
||||
let sourceURL: String
|
||||
let sourceID: String
|
||||
let loginCheckJavaScript: String?
|
||||
let reloadID: UUID
|
||||
@Binding var status: String
|
||||
@Binding var isChecking: Bool
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = .default()
|
||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
webView.navigationDelegate = context.coordinator
|
||||
context.coordinator.installExistingCookies(in: webView) { webView.load(request) }
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
guard context.coordinator.lastReloadID != reloadID else { return }
|
||||
context.coordinator.lastReloadID = reloadID
|
||||
webView.reload()
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, WKNavigationDelegate {
|
||||
var parent: SourceLoginWebView
|
||||
var lastReloadID: UUID
|
||||
|
||||
init(parent: SourceLoginWebView) {
|
||||
self.parent = parent
|
||||
lastReloadID = parent.reloadID
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation?) {
|
||||
parent.status = "正在打开登录页…"
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation?) {
|
||||
syncCookies(from: webView)
|
||||
guard let script = parent.loginCheckJavaScript?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!script.isEmpty else {
|
||||
parent.status = "页面已载入;登录后 Cookie 会自动同步。"
|
||||
return
|
||||
}
|
||||
parent.isChecking = true
|
||||
webView.evaluateJavaScript(script) { [weak self] value, error in
|
||||
guard let self else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.parent.isChecking = false
|
||||
if let error {
|
||||
self.parent.status = "登录检测脚本失败:\(error.localizedDescription)"
|
||||
} else {
|
||||
self.parent.status = Self.truthy(value) ? "书源登录检测已通过" : "页面已载入,尚未检测到登录状态"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation?, withError error: Error) {
|
||||
parent.status = "登录页加载失败:\(error.localizedDescription)"
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation?, withError error: Error) {
|
||||
parent.status = "登录页加载失败:\(error.localizedDescription)"
|
||||
}
|
||||
|
||||
func installExistingCookies(in webView: WKWebView, completion: @escaping () -> Void) {
|
||||
guard let url = URL(string: parent.sourceURL) else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
let cookies = HTTPCookieStorage.shared.cookies(for: url) ?? []
|
||||
guard !cookies.isEmpty else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
let group = DispatchGroup()
|
||||
for cookie in cookies {
|
||||
group.enter()
|
||||
webView.configuration.websiteDataStore.httpCookieStore.setCookie(cookie) { group.leave() }
|
||||
}
|
||||
group.notify(queue: .main, execute: completion)
|
||||
}
|
||||
|
||||
private func syncCookies(from webView: WKWebView) {
|
||||
webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in
|
||||
cookies.forEach(HTTPCookieStorage.shared.setCookie)
|
||||
guard let url = URL(string: self.parent.sourceURL) else { return }
|
||||
let matching = HTTPCookieStorage.shared.cookies(for: url) ?? []
|
||||
let header = HTTPCookie.requestHeaderFields(with: matching)["Cookie"] ?? ""
|
||||
Task {
|
||||
await SourceNetworkPolicy.shared.setCookieHeader(
|
||||
header,
|
||||
sourceID: self.parent.sourceID,
|
||||
url: url
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func truthy(_ value: Any?) -> Bool {
|
||||
if let value = value as? Bool { return value }
|
||||
if let value = value as? NSNumber { return value.boolValue }
|
||||
guard let text = value.map(String.init(describing:))?.lowercased() else { return false }
|
||||
return !text.isEmpty && !["false", "0", "null", "undefined"].contains(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class WKSourceWebViewExecutor: NSObject, SourceWebViewRequestExecuting, @unchecked Sendable {
|
||||
@MainActor
|
||||
func execute(_ prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) async throws -> HTTPResponse {
|
||||
let override = ApplicationSettingsStore.shared.settings.timeoutSeconds
|
||||
return try await BackgroundWebViewRequest(prepared: prepared, source: source, timeout: override > 0 ? Double(override) : timeout).run()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class BackgroundWebViewRequest: NSObject, WKNavigationDelegate {
|
||||
private let prepared: PreparedRequest
|
||||
private let source: BookSource
|
||||
private let timeout: TimeInterval
|
||||
private let webView: WKWebView
|
||||
private var continuation: CheckedContinuation<HTTPResponse, Error>?
|
||||
private var responseStatus = 200
|
||||
private var responseHeaders: [String: String] = [:]
|
||||
private var completed = false
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
|
||||
init(prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) {
|
||||
self.prepared = prepared
|
||||
self.source = source
|
||||
self.timeout = timeout
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = .nonPersistent()
|
||||
webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
super.init()
|
||||
webView.navigationDelegate = self
|
||||
}
|
||||
|
||||
func run() async throws -> HTTPResponse {
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.continuation = continuation
|
||||
installRequestCookies { [weak self] in self?.startLoading() }
|
||||
}
|
||||
} onCancel: {
|
||||
Task { @MainActor [weak self] in self?.finish(.failure(CancellationError())) }
|
||||
}
|
||||
}
|
||||
|
||||
private func startLoading() {
|
||||
timeoutTask = Task { [weak self] in
|
||||
let seconds = min(max(self?.timeout ?? 30, 1), 300)
|
||||
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run { self?.finish(.failure(URLError(.timedOut))) }
|
||||
}
|
||||
webView.load(prepared.urlRequest(timeout: timeout, handleCookies: true))
|
||||
}
|
||||
|
||||
private func installRequestCookies(completion: @escaping () -> Void) {
|
||||
guard let header = prepared.headers.first(where: { $0.key.caseInsensitiveCompare("Cookie") == .orderedSame })?.value,
|
||||
let host = prepared.url.host, !header.isEmpty else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
let cookies = header.split(separator: ";").compactMap { part -> HTTPCookie? in
|
||||
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
guard pair.count == 2 else { return nil }
|
||||
return HTTPCookie(properties: [
|
||||
.name: pair[0].trimmingCharacters(in: .whitespaces),
|
||||
.value: pair[1],
|
||||
.domain: host,
|
||||
.path: "/",
|
||||
.secure: prepared.url.scheme == "https" ? "TRUE" : "FALSE"
|
||||
])
|
||||
}
|
||||
guard !cookies.isEmpty else { completion(); return }
|
||||
let group = DispatchGroup()
|
||||
for cookie in cookies {
|
||||
group.enter()
|
||||
webView.configuration.websiteDataStore.httpCookieStore.setCookie(cookie) { group.leave() }
|
||||
}
|
||||
group.notify(queue: .main, execute: completion)
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
decidePolicyFor navigationResponse: WKNavigationResponse,
|
||||
decisionHandler: @escaping @MainActor (WKNavigationResponsePolicy) -> Void
|
||||
) {
|
||||
if let response = navigationResponse.response as? HTTPURLResponse {
|
||||
responseStatus = response.statusCode
|
||||
responseHeaders = response.allHeaderFields.reduce(into: [:]) { result, entry in
|
||||
result[String(describing: entry.key)] = String(describing: entry.value)
|
||||
}
|
||||
}
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation?) {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
if prepared.webViewDelayMilliseconds > 0 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(max(0, prepared.webViewDelayMilliseconds)) * 1_000_000)
|
||||
}
|
||||
guard !Task.isCancelled, !completed else { return }
|
||||
if let script = prepared.webJavaScript, !script.isEmpty {
|
||||
do { _ = try await webView.evaluateJavaScript(script) }
|
||||
catch { finish(.failure(error)); return }
|
||||
}
|
||||
do {
|
||||
let html = try await webView.evaluateJavaScript("document.documentElement.outerHTML") as? String ?? ""
|
||||
let cookies = await webView.configuration.websiteDataStore.httpCookieStore.allCookies()
|
||||
cookies.forEach(HTTPCookieStorage.shared.setCookie)
|
||||
let cookieHeader = HTTPCookie.requestHeaderFields(with: cookies)["Cookie"] ?? ""
|
||||
if !cookieHeader.isEmpty {
|
||||
await SourceNetworkPolicy.shared.setCookieHeader(cookieHeader, sourceID: source.id, url: prepared.url)
|
||||
}
|
||||
var headers = responseHeaders
|
||||
if !cookieHeader.isEmpty { headers["Set-Cookie"] = cookieHeader }
|
||||
finish(.success(.init(
|
||||
data: Data(html.utf8),
|
||||
statusCode: responseStatus,
|
||||
headers: headers,
|
||||
finalURL: webView.url ?? prepared.url,
|
||||
requestedCharset: "utf-8"
|
||||
)))
|
||||
} catch {
|
||||
finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation?, withError error: Error) {
|
||||
finish(.failure(error))
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation?, withError error: Error) {
|
||||
finish(.failure(error))
|
||||
}
|
||||
|
||||
private func finish(_ result: Result<HTTPResponse, Error>) {
|
||||
guard !completed else { return }
|
||||
completed = true
|
||||
timeoutTask?.cancel()
|
||||
webView.stopLoading()
|
||||
continuation?.resume(with: result)
|
||||
continuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension WKHTTPCookieStore {
|
||||
func allCookies() async -> [HTTPCookie] {
|
||||
await withCheckedContinuation { continuation in
|
||||
getAllCookies { continuation.resume(returning: $0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct SourceManagementView: View {
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
@State private var showsImporter = false
|
||||
@State private var showsNewSource = false
|
||||
@State private var showsQRImporter = false
|
||||
@State private var showsTextImporter = false
|
||||
@State private var showsGroupManager = false
|
||||
@State private var showsBatchGroups = false
|
||||
@State private var selectedGroup: String?
|
||||
@State private var searchText = ""
|
||||
@State private var isSelecting = false
|
||||
@State private var selectedSourceIDs = Set<String>()
|
||||
@AppStorage("bookSourceSort") private var sortMode = "manual"
|
||||
@AppStorage("bookSourceSortDescending") private var sortDescending = false
|
||||
|
||||
private var filteredSources: [BookSource] {
|
||||
let filtered = library.sources.filter { source in
|
||||
let matchesGroup: Bool
|
||||
if let selectedGroup {
|
||||
matchesGroup = selectedGroup.isEmpty ? source.groups.isEmpty : source.hasGroup(selectedGroup)
|
||||
} else {
|
||||
matchesGroup = true
|
||||
}
|
||||
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return matchesGroup && (query.isEmpty
|
||||
|| source.bookSourceName.localizedCaseInsensitiveContains(query)
|
||||
|| source.bookSourceUrl.localizedCaseInsensitiveContains(query)
|
||||
|| source.groups.contains(where: { $0.localizedCaseInsensitiveContains(query) }))
|
||||
}
|
||||
guard sortMode != "manual" else { return filtered }
|
||||
return filtered.sorted { lhs, rhs in
|
||||
sortDescending ? isOrdered(rhs, before: lhs) : isOrdered(lhs, before: rhs)
|
||||
}
|
||||
}
|
||||
|
||||
private func isOrdered(_ lhs: BookSource, before rhs: BookSource) -> Bool {
|
||||
switch sortMode {
|
||||
case "weight":
|
||||
return lhs.weight == rhs.weight ? lhs.bookSourceName.localizedStandardCompare(rhs.bookSourceName) == .orderedAscending : lhs.weight < rhs.weight
|
||||
case "name": return lhs.bookSourceName.localizedStandardCompare(rhs.bookSourceName) == .orderedAscending
|
||||
case "url": return lhs.bookSourceUrl.localizedStandardCompare(rhs.bookSourceUrl) == .orderedAscending
|
||||
case "update": return lhs.lastUpdateTime == rhs.lastUpdateTime ? lhs.bookSourceName < rhs.bookSourceName : lhs.lastUpdateTime > rhs.lastUpdateTime
|
||||
case "respond": return lhs.respondTime == rhs.respondTime ? lhs.bookSourceName < rhs.bookSourceName : lhs.respondTime < rhs.respondTime
|
||||
case "enabled": return lhs.enabled == rhs.enabled ? lhs.bookSourceName < rhs.bookSourceName : lhs.enabled && !rhs.enabled
|
||||
default: return lhs.customOrder < rhs.customOrder
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if library.sources.isEmpty, !library.isWorking {
|
||||
ReadingUnavailableView {
|
||||
Label("还没有书源", systemImage: "network")
|
||||
} description: {
|
||||
Text("新建书源,或从文件、链接、JSON、二维码导入。")
|
||||
} actions: {
|
||||
Button("导入书源") { showsImporter = true }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
Section {
|
||||
Picker("分组", selection: $selectedGroup) {
|
||||
Text("全部").tag(String?.none)
|
||||
Text("未分组").tag(String?.some(""))
|
||||
ForEach(library.groups, id: \.self) { group in
|
||||
Text(group).tag(String?.some(group))
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
ForEach(filteredSources) { source in
|
||||
if isSelecting {
|
||||
Button {
|
||||
if !selectedSourceIDs.insert(source.id).inserted { selectedSourceIDs.remove(source.id) }
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: selectedSourceIDs.contains(source.id) ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(selectedSourceIDs.contains(source.id) ? ReadingTheme.accent : .secondary)
|
||||
SourceRow(source: source, showsToggle: false) { _ in }
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
NavigationLink {
|
||||
SourceDetailView(source: source)
|
||||
} label: {
|
||||
SourceRow(source: source) { enabled in
|
||||
Task { await library.setEnabled(enabled, for: source.id) }
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
NavigationLink {
|
||||
SearchView(fixedSource: source)
|
||||
} label: {
|
||||
Label("搜索", systemImage: "magnifyingglass")
|
||||
}
|
||||
Button(source.enabledExplore ? "停用发现" : "启用发现", systemImage: "safari") {
|
||||
Task { await library.setExploreEnabled(!source.enabledExplore, for: source.id) }
|
||||
}
|
||||
Button("删除", systemImage: "trash", role: .destructive) {
|
||||
Task { await library.remove(sourceIDs: [source.id]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
let ids = Set(offsets.compactMap { filteredSources.indices.contains($0) ? filteredSources[$0].id : nil })
|
||||
Task { await library.remove(sourceIDs: ids) }
|
||||
}
|
||||
} header: {
|
||||
Text("\(filteredSources.count) 个书源")
|
||||
} footer: {
|
||||
Text("共 \(library.sources.count) 个书源,\(library.sources.filter(\.enabled).count) 个已启用")
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.readingScrollContentBackgroundHidden()
|
||||
.background(ReadingTheme.groupedCanvas)
|
||||
.overlay {
|
||||
if library.isWorking { ProgressView() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("书源")
|
||||
.background(ReadingTheme.groupedCanvas)
|
||||
.searchable(text: $searchText, prompt: "名称、地址或分组")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button(isSelecting ? "完成" : "选择") {
|
||||
isSelecting.toggle()
|
||||
if !isSelecting { selectedSourceIDs.removeAll() }
|
||||
}
|
||||
.disabled(library.sources.isEmpty)
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu("更多", systemImage: "ellipsis.circle") {
|
||||
Button("新建书源", systemImage: "plus") { showsNewSource = true }
|
||||
Button("二维码导入", systemImage: "qrcode.viewfinder") { showsQRImporter = true }
|
||||
Button("从文件导入", systemImage: "doc.badge.plus") { showsImporter = true }
|
||||
Button("URL 或粘贴 JSON", systemImage: "link.badge.plus") { showsTextImporter = true }
|
||||
Divider()
|
||||
NavigationLink {
|
||||
SourceBatchTestView()
|
||||
} label: {
|
||||
Label("测试书源", systemImage: "checkmark.circle.badge.questionmark")
|
||||
}
|
||||
Button("管理分组", systemImage: "folder.badge.gearshape") { showsGroupManager = true }
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu("排序", systemImage: "arrow.up.arrow.down") {
|
||||
Toggle("倒序", isOn: $sortDescending)
|
||||
Picker("排序方式", selection: $sortMode) {
|
||||
Text("手动排序").tag("manual")
|
||||
Text("自动排序").tag("weight")
|
||||
Text("按名称").tag("name")
|
||||
Text("按网址").tag("url")
|
||||
Text("按更新时间").tag("update")
|
||||
Text("按响应时间").tag("respond")
|
||||
Text("按启用状态").tag("enabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if isSelecting {
|
||||
HStack(spacing: 18) {
|
||||
Button(selectedSourceIDs.count == filteredSources.count ? "取消全选" : "全选") {
|
||||
if selectedSourceIDs.count == filteredSources.count { selectedSourceIDs.removeAll() }
|
||||
else { selectedSourceIDs = Set(filteredSources.map(\.id)) }
|
||||
}
|
||||
Spacer()
|
||||
Button("分组", systemImage: "folder") { showsBatchGroups = true }
|
||||
.disabled(selectedSourceIDs.isEmpty)
|
||||
Button("启用", systemImage: "checkmark.circle") {
|
||||
Task { await library.setEnabled(true, for: selectedSourceIDs) }
|
||||
}
|
||||
.disabled(selectedSourceIDs.isEmpty)
|
||||
Button("停用", systemImage: "pause.circle") {
|
||||
Task { await library.setEnabled(false, for: selectedSourceIDs) }
|
||||
}
|
||||
.disabled(selectedSourceIDs.isEmpty)
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 18).padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showsNewSource) { SourceEditorView().environmentObject(library) }
|
||||
.sheet(isPresented: $showsQRImporter) {
|
||||
QRCodeImportView { value in Task { await library.previewInput(value) } }
|
||||
}
|
||||
.sheet(isPresented: $showsTextImporter) {
|
||||
SourceTextImportView()
|
||||
.environmentObject(library)
|
||||
}
|
||||
.sheet(isPresented: $showsGroupManager) {
|
||||
SourceGroupManagerView().environmentObject(library)
|
||||
}
|
||||
.sheet(isPresented: $showsBatchGroups) {
|
||||
SourceBatchGroupView(sourceIDs: selectedSourceIDs).environmentObject(library)
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showsImporter,
|
||||
allowedContentTypes: [.item],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
let files = urls.filter { !$0.hasDirectoryPath }
|
||||
if files.isEmpty {
|
||||
library.notice = .init(kind: .error, title: "无法选择文件", detail: "请选择书源 JSON 文件,而不是文件夹。")
|
||||
} else {
|
||||
Task { await library.previewFiles(files) }
|
||||
}
|
||||
case .failure(let error):
|
||||
library.notice = .init(kind: .error, title: "无法选择文件", detail: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceTextImportView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
@State private var mode = 0
|
||||
@State private var value = ""
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
Form {
|
||||
Picker("导入方式", selection: $mode) {
|
||||
Text("URL").tag(0)
|
||||
Text("粘贴 JSON").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Section(mode == 0 ? "书源 URL" : "书源 JSON") {
|
||||
if mode == 0 {
|
||||
TextField("https://example.com/sources.json", text: $value)
|
||||
.textInputAutocapitalization(.never)
|
||||
.keyboardType(.URL)
|
||||
} else {
|
||||
SyntaxCodeEditor(text: $value, language: .json)
|
||||
.frame(minHeight: 220)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Text("导入前可预览并选择书源。地址相同的书源将被替换。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("导入书源")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("导入") {
|
||||
let input = value
|
||||
let selectedMode = mode
|
||||
dismiss()
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
if selectedMode == 0 { await library.previewRemoteURL(input) }
|
||||
else { await library.previewText(input) }
|
||||
}
|
||||
}
|
||||
.disabled(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceRow: View {
|
||||
let source: BookSource
|
||||
var showsToggle = true
|
||||
let onEnabledChanged: @MainActor @Sendable (Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: source.enabled ? "network" : "network.slash")
|
||||
.font(.system(size: 20))
|
||||
.frame(width: 28)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(source.bookSourceName)
|
||||
.font(.body)
|
||||
.foregroundStyle(source.enabled ? Color.primary : .secondary)
|
||||
Text(source.groups.isEmpty ? (URL(string: source.bookSourceUrl)?.host ?? source.bookSourceUrl) : source.groups.joined(separator: " · "))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
if showsToggle {
|
||||
Toggle("启用", isOn: Binding(
|
||||
get: { source.enabled },
|
||||
set: { enabled in
|
||||
Task { @MainActor in onEnabledChanged(enabled) }
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
.tint(.green)
|
||||
.accessibilityLabel("启用\(source.bookSourceName)")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceGroupManagerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
@State private var newGroup = ""
|
||||
@State private var renaming: String?
|
||||
@State private var renamedValue = ""
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
List {
|
||||
Section("新建分组") {
|
||||
HStack {
|
||||
TextField("例如:文学", text: $newGroup)
|
||||
Button("添加") {
|
||||
let value = newGroup
|
||||
newGroup = ""
|
||||
let ungrouped = Set(library.sources.filter { $0.groups.isEmpty }.map(\.id))
|
||||
Task { await library.addGroup(value, to: ungrouped) }
|
||||
}
|
||||
.disabled(BookSource.parseGroups(newGroup).isEmpty)
|
||||
}
|
||||
Text("新分组会分配给当前未分组书源,与 Legado 的分组管理行为一致。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Section("已有分组") {
|
||||
ForEach(library.groups, id: \.self) { group in
|
||||
HStack {
|
||||
Label(group, systemImage: "folder.fill")
|
||||
Spacer()
|
||||
Text("\(library.sources.filter { $0.hasGroup(group) }.count)")
|
||||
.foregroundStyle(.secondary)
|
||||
Menu {
|
||||
Button("重命名", systemImage: "pencil") {
|
||||
renaming = group
|
||||
renamedValue = group
|
||||
}
|
||||
Button("删除分组", systemImage: "trash", role: .destructive) {
|
||||
Task { await library.deleteGroup(group) }
|
||||
}
|
||||
} label: { Image(systemName: "ellipsis.circle") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("书源分组")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } } }
|
||||
.alert("重命名分组", isPresented: Binding(
|
||||
get: { renaming != nil },
|
||||
set: { if !$0 { renaming = nil } }
|
||||
)) {
|
||||
TextField("分组名称", text: $renamedValue)
|
||||
Button("取消", role: .cancel) { renaming = nil }
|
||||
Button("保存") {
|
||||
if let old = renaming { Task { await library.renameGroup(old, to: renamedValue) } }
|
||||
renaming = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceBatchGroupView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
let sourceIDs: Set<String>
|
||||
@State private var customGroup = ""
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
List {
|
||||
Section("已选择 \(sourceIDs.count) 个书源") {
|
||||
HStack {
|
||||
TextField("输入新分组", text: $customGroup)
|
||||
Button("加入") {
|
||||
let value = customGroup
|
||||
customGroup = ""
|
||||
Task { await library.addGroup(value, to: sourceIDs) }
|
||||
}
|
||||
.disabled(BookSource.parseGroups(customGroup).isEmpty)
|
||||
}
|
||||
}
|
||||
Section("加入现有分组") {
|
||||
ForEach(library.groups, id: \.self) { group in
|
||||
Button { Task { await library.addGroup(group, to: sourceIDs) } } label: {
|
||||
Label(group, systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("从分组移除") {
|
||||
ForEach(library.groups, id: \.self) { group in
|
||||
Button(role: .destructive) {
|
||||
Task { await library.removeGroup(group, from: sourceIDs) }
|
||||
} label: { Label(group, systemImage: "folder.badge.minus") }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("批量归组")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceDetailView: View {
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
let source: BookSource
|
||||
@State private var showsEditor = false
|
||||
@State private var editedSourceID: String?
|
||||
private var current: BookSource { library.sources.first { $0.id == (editedSourceID ?? source.id) } ?? source }
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("基本信息") {
|
||||
ReadingLabeledContent("名称", value: current.bookSourceName)
|
||||
ReadingLabeledContent("地址", value: current.bookSourceUrl)
|
||||
ReadingLabeledContent("分组", value: current.bookSourceGroup ?? "未分组")
|
||||
ReadingLabeledContent("类型", value: String(current.bookSourceType))
|
||||
}
|
||||
Section("能力") {
|
||||
CapabilityRow(title: "搜索", available: current.searchUrl?.isEmpty == false)
|
||||
CapabilityRow(title: "发现", available: current.exploreUrl?.isEmpty == false)
|
||||
CapabilityRow(title: "详情", available: current.ruleBookInfo != nil)
|
||||
CapabilityRow(title: "目录", available: current.ruleToc != nil)
|
||||
CapabilityRow(title: "正文", available: current.ruleContent != nil)
|
||||
}
|
||||
Section("状态") {
|
||||
Toggle("启用书源", isOn: Binding(
|
||||
get: { library.sources.first(where: { $0.id == current.id })?.enabled ?? current.enabled },
|
||||
set: { value in Task { await library.setEnabled(value, for: current.id) } }
|
||||
))
|
||||
Toggle("启用发现", isOn: Binding(
|
||||
get: { library.sources.first(where: { $0.id == current.id })?.enabledExplore ?? current.enabledExplore },
|
||||
set: { value in Task { await library.setExploreEnabled(value, for: current.id) } }
|
||||
))
|
||||
}
|
||||
Section("兼容检查") {
|
||||
NavigationLink {
|
||||
SearchView(fixedSource: current)
|
||||
} label: {
|
||||
Label("搜索此书源", systemImage: "magnifyingglass")
|
||||
}
|
||||
NavigationLink {
|
||||
SourceDebugView(source: current)
|
||||
} label: {
|
||||
Label("运行端到端调试", systemImage: "stethoscope")
|
||||
}
|
||||
Text("使用你输入的关键词,逐步验证搜索、详情、目录和正文规则。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
if current.loginUrl?.isEmpty == false {
|
||||
Section("账户") {
|
||||
NavigationLink {
|
||||
SourceLoginView(source: current)
|
||||
} label: {
|
||||
Label("登录此书源", systemImage: "person.badge.key.fill")
|
||||
}
|
||||
Text("登录 Cookie 与阅读请求共享;若书源提供检测脚本,会在页面载入后自动验证。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let comment = current.bookSourceComment, !comment.isEmpty {
|
||||
Section("说明") { Text(comment) }
|
||||
}
|
||||
}
|
||||
.navigationTitle(current.bookSourceName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("编辑") { showsEditor = true }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showsEditor) {
|
||||
SourceEditorView(source: current) { editedSourceID = $0.id }.environmentObject(library)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SourceImportReviewView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
let pending: SourceLibraryModel.PendingImport
|
||||
@State private var selectedIDs: Set<String>
|
||||
@State private var importError: String?
|
||||
@State private var isImporting = false
|
||||
@State private var keepOriginalName = false
|
||||
@State private var keepOriginalGroup = false
|
||||
@State private var keepOriginalEnabled = false
|
||||
@State private var showComments = false
|
||||
@State private var customGroup = ""
|
||||
@State private var addCustomGroup = true
|
||||
|
||||
init(pending: SourceLibraryModel.PendingImport) {
|
||||
self.pending = pending
|
||||
_selectedIDs = State(initialValue: Set(pending.items.filter { $0.existing == nil || $0.state == "更新" }.map(\.id)))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
List {
|
||||
Section {
|
||||
HStack(spacing: 10) {
|
||||
importCountCard(
|
||||
title: "有效",
|
||||
count: pending.validCount,
|
||||
color: .green,
|
||||
systemImage: "checkmark.circle.fill"
|
||||
)
|
||||
importCountCard(
|
||||
title: "失效",
|
||||
count: pending.invalidCount,
|
||||
color: .red,
|
||||
systemImage: "xmark.circle.fill"
|
||||
)
|
||||
importCountCard(
|
||||
title: "总计",
|
||||
count: pending.totalCount,
|
||||
color: .secondary,
|
||||
systemImage: "doc.text.fill"
|
||||
)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||
|
||||
Label("仅有效书源可被选择和导入", systemImage: "checkmark.shield.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !pending.items.isEmpty {
|
||||
Section("导入设置") {
|
||||
HStack {
|
||||
TextField("自定义分组", text: $customGroup)
|
||||
Toggle("追加", isOn: $addCustomGroup).labelsHidden()
|
||||
}
|
||||
Toggle("保留原名称", isOn: $keepOriginalName)
|
||||
Toggle("保留原分组", isOn: $keepOriginalGroup)
|
||||
Toggle("保留原启用状态", isOn: $keepOriginalEnabled)
|
||||
Toggle("显示书源备注", isOn: $showComments)
|
||||
}
|
||||
}
|
||||
|
||||
Section("有效书源(\(pending.validCount))") {
|
||||
ForEach(pending.items) { item in
|
||||
Button {
|
||||
if !selectedIDs.insert(item.id).inserted { selectedIDs.remove(item.id) }
|
||||
} label: {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: selectedIDs.contains(item.id) ? "checkmark.square.fill" : "square")
|
||||
.foregroundStyle(selectedIDs.contains(item.id) ? ReadingTheme.accent : .secondary)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.source.bookSourceName).foregroundStyle(.primary)
|
||||
HStack {
|
||||
Text(item.state)
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(item.state == "新增" ? .green : item.state == "更新" ? .orange : .secondary)
|
||||
Text(item.source.bookSourceUrl)
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
if showComments, let comment = item.source.bookSourceComment, !comment.isEmpty {
|
||||
Text(comment).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !pending.invalidItems.isEmpty {
|
||||
Section("失效书源(\(pending.invalidCount))") {
|
||||
ForEach(pending.invalidItems) { item in
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.source.bookSourceName.isEmpty ? "未命名书源" : item.source.bookSourceName)
|
||||
.foregroundStyle(.primary)
|
||||
if !item.source.bookSourceUrl.isEmpty {
|
||||
Text(item.source.bookSourceUrl)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(item.issues.map(\.message).joined(separator: ";"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("书源导入预检")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") {
|
||||
library.cancelPendingImport()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu("选择") {
|
||||
Button("全选") { selectedIDs = Set(pending.items.map(\.id)) }
|
||||
Button("取消全选") { selectedIDs.removeAll() }
|
||||
Button("仅选择新增") {
|
||||
selectedIDs = Set(pending.items.filter { $0.existing == nil }.map(\.id))
|
||||
}
|
||||
Button("仅选择更新") {
|
||||
selectedIDs = Set(pending.items.filter { $0.existing != nil }.map(\.id))
|
||||
}
|
||||
}
|
||||
.disabled(pending.items.isEmpty)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("导入") {
|
||||
let ids = selectedIDs
|
||||
isImporting = true
|
||||
Task {
|
||||
await library.commitPendingImport(
|
||||
selectedIDs: ids,
|
||||
keepOriginalName: keepOriginalName,
|
||||
keepOriginalGroup: keepOriginalGroup,
|
||||
keepOriginalEnabled: keepOriginalEnabled,
|
||||
customGroup: customGroup.isEmpty ? nil : customGroup,
|
||||
addCustomGroup: addCustomGroup
|
||||
)
|
||||
isImporting = false
|
||||
if library.pendingImport == nil { dismiss() }
|
||||
else {
|
||||
let message = library.notice?.detail ?? "保存失败,请重试"
|
||||
library.notice = nil
|
||||
importError = message
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(selectedIDs.isEmpty || isImporting)
|
||||
}
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled()
|
||||
.alert("导入保存失败", isPresented: Binding(get: { importError != nil }, set: { if !$0 { importError = nil } })) {
|
||||
Button("好", role: .cancel) {}
|
||||
} message: { Text(importError ?? "") }
|
||||
}
|
||||
|
||||
private func importCountCard(
|
||||
title: String,
|
||||
count: Int,
|
||||
color: Color,
|
||||
systemImage: String
|
||||
) -> some View {
|
||||
VStack(spacing: 5) {
|
||||
Label(title, systemImage: systemImage)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
Text("\(count)")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(color.opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
private struct CapabilityRow: View {
|
||||
let title: String
|
||||
let available: Bool
|
||||
|
||||
var body: some View {
|
||||
ReadingLabeledContent(title) {
|
||||
Image(systemName: available ? "checkmark.circle.fill" : "minus.circle")
|
||||
.foregroundStyle(available ? .green : .secondary)
|
||||
.accessibilityLabel(available ? "已配置" : "未配置")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor
|
||||
final class HttpTTSLibraryModel: ObservableObject {
|
||||
@Published private(set) var engines: [HttpTTS] = []
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private func repository() throws -> SQLiteConfigurationRepository {
|
||||
SQLiteConfigurationRepository(database: try LegadoDatabase(url: LegadoDatabase.defaultURL()))
|
||||
}
|
||||
|
||||
func load() async {
|
||||
do {
|
||||
var values = try await repository().loadHttpTTS()
|
||||
if values.isEmpty { values = try bundledDefaults() }
|
||||
engines = sorted(values)
|
||||
if !values.isEmpty { try await repository().saveHttpTTS(engines) }
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func save(_ value: HttpTTS) async {
|
||||
var value = value
|
||||
value.lastUpdateTime = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
var values = engines.filter { $0.id != value.id }
|
||||
values.append(value)
|
||||
await persist(values)
|
||||
}
|
||||
|
||||
func delete(at offsets: IndexSet) async {
|
||||
var values = engines
|
||||
values.remove(atOffsets: offsets)
|
||||
await persist(values)
|
||||
}
|
||||
|
||||
func importData(_ data: Data) async {
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let imported: [HttpTTS]
|
||||
if let array = try? decoder.decode([HttpTTS].self, from: data) {
|
||||
imported = array
|
||||
} else {
|
||||
imported = [try decoder.decode(HttpTTS.self, from: data)]
|
||||
}
|
||||
var byID = Dictionary(uniqueKeysWithValues: engines.map { ($0.id, $0) })
|
||||
for value in imported { byID[value.id] = value }
|
||||
await persist(Array(byID.values))
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func restoreDefaults() async {
|
||||
do {
|
||||
let defaults = try bundledDefaults()
|
||||
var byID = Dictionary(uniqueKeysWithValues: engines.filter { $0.id >= 0 }.map { ($0.id, $0) })
|
||||
for value in defaults { byID[value.id] = value }
|
||||
await persist(Array(byID.values))
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func exportData() throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||||
return try encoder.encode(engines)
|
||||
}
|
||||
|
||||
private func bundledDefaults() throws -> [HttpTTS] {
|
||||
guard let url = Bundle.main.url(forResource: "httpTTS", withExtension: "json") else {
|
||||
throw CocoaError(.fileNoSuchFile)
|
||||
}
|
||||
return try JSONDecoder().decode([HttpTTS].self, from: Data(contentsOf: url))
|
||||
}
|
||||
|
||||
private func persist(_ values: [HttpTTS]) async {
|
||||
do {
|
||||
let values = sorted(values)
|
||||
try await repository().saveHttpTTS(values)
|
||||
engines = values
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func sorted(_ values: [HttpTTS]) -> [HttpTTS] {
|
||||
values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
}
|
||||
|
||||
struct HttpTTSManagementView: View {
|
||||
@StateObject private var model = HttpTTSLibraryModel()
|
||||
@State private var editing: HttpTTS?
|
||||
@State private var importsFile = false
|
||||
@State private var exportsFile = false
|
||||
@State private var exportDocument = HttpTTSJSONDocument(data: Data("[]".utf8))
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(model.engines) { engine in
|
||||
Button {
|
||||
editing = engine
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(engine.name).foregroundStyle(.primary)
|
||||
Text(engine.url).font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in Task { await model.delete(at: offsets) } }
|
||||
}
|
||||
.overlay {
|
||||
if model.engines.isEmpty {
|
||||
ReadingUnavailableView("没有在线朗读引擎", systemImage: "waveform", description: Text("可恢复内置引擎或导入 HTTP TTS JSON。"))
|
||||
}
|
||||
}
|
||||
.navigationTitle("在线朗读引擎")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button("新建", systemImage: "plus") { editing = HttpTTS() }
|
||||
Menu("更多", systemImage: "ellipsis.circle") {
|
||||
Button("导入", systemImage: "square.and.arrow.down") { importsFile = true }
|
||||
Button("导出", systemImage: "square.and.arrow.up") {
|
||||
do {
|
||||
exportDocument = .init(data: try model.exportData())
|
||||
exportsFile = true
|
||||
} catch { model.errorMessage = error.localizedDescription }
|
||||
}
|
||||
Button("恢复内置引擎", systemImage: "arrow.counterclockwise") {
|
||||
Task { await model.restoreDefaults() }
|
||||
}
|
||||
Button("清除朗读缓存", systemImage: "trash") {
|
||||
Task { await HttpTTSAudioCache.shared.clear() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await model.load() }
|
||||
.sheet(item: $editing) { engine in
|
||||
ReadingNavigationStack {
|
||||
HttpTTSEditorView(engine: engine) { value in
|
||||
Task { await model.save(value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.fileImporter(isPresented: $importsFile, allowedContentTypes: [.json, .data]) { result in
|
||||
do {
|
||||
let url = try result.get()
|
||||
let accessing = url.startAccessingSecurityScopedResource()
|
||||
defer { if accessing { url.stopAccessingSecurityScopedResource() } }
|
||||
let data = try Data(contentsOf: url)
|
||||
Task { await model.importData(data) }
|
||||
} catch { model.errorMessage = error.localizedDescription }
|
||||
}
|
||||
.fileExporter(
|
||||
isPresented: $exportsFile,
|
||||
document: exportDocument,
|
||||
contentType: .json,
|
||||
defaultFilename: "httpTTS"
|
||||
) { result in
|
||||
if case .failure(let error) = result { model.errorMessage = error.localizedDescription }
|
||||
}
|
||||
.alert("在线朗读引擎", isPresented: Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { if !$0 { model.errorMessage = nil } }
|
||||
)) {
|
||||
Button("确定", role: .cancel) { model.errorMessage = nil }
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HttpTTSEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var engine: HttpTTS
|
||||
let onSave: (HttpTTS) -> Void
|
||||
@StateObject private var speech = ReaderSpeechController()
|
||||
@State private var testText = "这是一段在线朗读引擎测试文本。"
|
||||
@State private var loginInfo: String
|
||||
@State private var showsLoginForm = false
|
||||
|
||||
init(engine: HttpTTS, onSave: @escaping (HttpTTS) -> Void) {
|
||||
_engine = State(initialValue: engine)
|
||||
self.onSave = onSave
|
||||
_loginInfo = State(initialValue: BookSourceRuntimeStore.shared.get(sourceID: engine.sourceKey, key: "loginInfo"))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("基本") {
|
||||
TextField("名称", text: $engine.name)
|
||||
TextEditor(text: $engine.url).frame(minHeight: 110)
|
||||
TextField("Content-Type 正则", text: optional(\.contentType))
|
||||
TextField("并发率", text: optional(\.concurrentRate))
|
||||
Toggle("启用 Cookie", isOn: optionalBool(\.enabledCookieJar))
|
||||
}
|
||||
Section("请求与脚本") {
|
||||
fieldLabel("Header")
|
||||
TextEditor(text: optional(\.header)).frame(minHeight: 80)
|
||||
fieldLabel("JS 库")
|
||||
TextEditor(text: optional(\.jsLib)).frame(minHeight: 100)
|
||||
fieldLabel("登录地址/脚本")
|
||||
TextEditor(text: optional(\.loginUrl)).frame(minHeight: 80)
|
||||
fieldLabel("登录 UI")
|
||||
TextEditor(text: optional(\.loginUi)).frame(minHeight: 80)
|
||||
fieldLabel("登录信息 JSON")
|
||||
TextEditor(text: $loginInfo).frame(minHeight: 80)
|
||||
if engine.loginUi?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
Button("打开登录表单", systemImage: "person.badge.key") { showsLoginForm = true }
|
||||
}
|
||||
fieldLabel("登录检查 JS")
|
||||
TextEditor(text: optional(\.loginCheckJs)).frame(minHeight: 100)
|
||||
}
|
||||
Section("测试") {
|
||||
TextField("测试文本", text: $testText)
|
||||
Button(speech.isSpeaking ? "停止测试" : "播放测试", systemImage: speech.isSpeaking ? "stop.fill" : "play.fill") {
|
||||
speech.configure(followsSystemRate: false, speechRate: 5, readByPage: true, streamsHTTPAudio: true)
|
||||
speech.toggleHTTP(engine: engine, text: testText)
|
||||
}
|
||||
if let error = speech.errorMessage { Text(error).foregroundStyle(.red).font(.footnote) }
|
||||
}
|
||||
}
|
||||
.navigationTitle(engine.name.isEmpty ? "新建在线朗读引擎" : engine.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("保存") {
|
||||
BookSourceRuntimeStore.shared.put(sourceID: engine.sourceKey, key: "loginInfo", value: loginInfo)
|
||||
onSave(engine)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(engine.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || engine.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showsLoginForm) {
|
||||
ReadingNavigationStack {
|
||||
HttpTTSLoginView(engine: engine) { value in loginInfo = value }
|
||||
}
|
||||
}
|
||||
.onDisappear { speech.stop() }
|
||||
}
|
||||
|
||||
private func fieldLabel(_ value: String) -> some View {
|
||||
Text(value).font(.subheadline.weight(.medium))
|
||||
}
|
||||
|
||||
private func optional(_ keyPath: WritableKeyPath<HttpTTS, String?>) -> Binding<String> {
|
||||
Binding(get: { engine[keyPath: keyPath] ?? "" }, set: { engine[keyPath: keyPath] = $0.isEmpty ? nil : $0 })
|
||||
}
|
||||
|
||||
private func optionalBool(_ keyPath: WritableKeyPath<HttpTTS, Bool?>) -> Binding<Bool> {
|
||||
Binding(get: { engine[keyPath: keyPath] == true }, set: { engine[keyPath: keyPath] = $0 })
|
||||
}
|
||||
}
|
||||
|
||||
private struct HttpTTSLoginRow: Decodable, Identifiable {
|
||||
var name = ""
|
||||
var type = "text"
|
||||
var action: String?
|
||||
var chars: [String?]?
|
||||
var `default`: String?
|
||||
var viewName: String?
|
||||
var id: String { "\(name)-\(type)" }
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case name, type, action, chars, `default`, viewName }
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = try values.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
type = try values.decodeIfPresent(String.self, forKey: .type) ?? "text"
|
||||
action = try values.decodeIfPresent(String.self, forKey: .action)
|
||||
chars = try values.decodeIfPresent([String?].self, forKey: .chars)
|
||||
`default` = try values.decodeIfPresent(String.self, forKey: .default)
|
||||
viewName = try values.decodeIfPresent(String.self, forKey: .viewName)
|
||||
}
|
||||
}
|
||||
|
||||
private struct HttpTTSLoginView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let engine: HttpTTS
|
||||
let onSave: (String) -> Void
|
||||
@State private var rows: [HttpTTSLoginRow] = []
|
||||
@State private var values: [String: String] = [:]
|
||||
@State private var errorMessage: String?
|
||||
@State private var isRunning = false
|
||||
@State private var pendingFieldAction: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if rows.isEmpty, errorMessage == nil {
|
||||
ProgressView("正在生成登录表单…")
|
||||
}
|
||||
ForEach(rows) { row in
|
||||
switch row.type {
|
||||
case "password":
|
||||
SecureField(label(for: row), text: binding(for: row))
|
||||
.onChange(of: values[row.name]) { _ in scheduleFieldAction(row.action) }
|
||||
case "select":
|
||||
Picker(label(for: row), selection: binding(for: row)) {
|
||||
ForEach(options(for: row), id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
.onChange(of: values[row.name]) { _ in runAction(row.action) }
|
||||
case "toggle":
|
||||
Picker(label(for: row), selection: binding(for: row)) {
|
||||
ForEach(options(for: row), id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: values[row.name]) { _ in runAction(row.action) }
|
||||
case "button":
|
||||
Button(label(for: row)) { runAction(row.action) }
|
||||
default:
|
||||
TextField(label(for: row), text: binding(for: row))
|
||||
.onChange(of: values[row.name]) { _ in scheduleFieldAction(row.action) }
|
||||
}
|
||||
}
|
||||
if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.footnote) }
|
||||
}
|
||||
.navigationTitle("登录 · \(engine.name)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(isRunning ? "执行中…" : "登录") { saveAndLogin() }.disabled(isRunning)
|
||||
}
|
||||
}
|
||||
.task { loadRows() }
|
||||
.onDisappear { pendingFieldAction?.cancel() }
|
||||
}
|
||||
|
||||
private func loadRows() {
|
||||
do {
|
||||
values = decodeLoginInfo()
|
||||
let json = try renderedLoginUI()
|
||||
rows = try JSONDecoder().decode([HttpTTSLoginRow].self, from: Data(json.utf8))
|
||||
for row in rows where row.type != "button" && values[row.name] == nil {
|
||||
values[row.name] = row.default ?? options(for: row).first ?? ""
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func renderedLoginUI() throws -> String {
|
||||
guard var raw = engine.loginUi?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
return "[]"
|
||||
}
|
||||
let script: String?
|
||||
if raw.hasPrefix("@js:") {
|
||||
script = String(raw.dropFirst(4))
|
||||
} else if raw.hasPrefix("<js>"), raw.hasSuffix("</js>") {
|
||||
script = String(raw.dropFirst(4).dropLast(5))
|
||||
} else {
|
||||
script = nil
|
||||
}
|
||||
if let script {
|
||||
let result = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: script,
|
||||
result: .object(values.mapValues(JSONValue.string)),
|
||||
executionContext: .init(source: engine.bookSourceAdapter)
|
||||
)
|
||||
raw = result.value.stringValue
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
private func decodeLoginInfo() -> [String: String] {
|
||||
let raw = BookSourceRuntimeStore.shared.get(sourceID: engine.sourceKey, key: "loginInfo")
|
||||
guard let data = raw.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
|
||||
return object.mapValues { String(describing: $0) }
|
||||
}
|
||||
|
||||
private func binding(for row: HttpTTSLoginRow) -> Binding<String> {
|
||||
Binding(get: { values[row.name] ?? row.default ?? "" }, set: { values[row.name] = $0 })
|
||||
}
|
||||
|
||||
private func options(for row: HttpTTSLoginRow) -> [String] {
|
||||
let options = row.chars?.compactMap { $0 } ?? []
|
||||
return options.isEmpty ? [row.default ?? ""] : options
|
||||
}
|
||||
|
||||
private func label(for row: HttpTTSLoginRow) -> String {
|
||||
guard let value = row.viewName, value.count >= 2,
|
||||
value.first == "'", value.last == "'" else { return row.name }
|
||||
return String(value.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
private func runAction(_ action: String?) {
|
||||
guard let action, !action.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
isRunning = true
|
||||
errorMessage = nil
|
||||
let engine = engine
|
||||
let values = values
|
||||
Task { @MainActor in
|
||||
defer { isRunning = false }
|
||||
do {
|
||||
_ = try await Task.detached {
|
||||
try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "\(engine.loginUrl ?? "")\n\(action)",
|
||||
result: .object(values.mapValues(JSONValue.string)),
|
||||
executionContext: .init(source: engine.bookSourceAdapter)
|
||||
)
|
||||
}.value
|
||||
if engine.loginUi?.hasPrefix("@js:") == true { loadRows() }
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleFieldAction(_ action: String?) {
|
||||
guard action?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { return }
|
||||
pendingFieldAction?.cancel()
|
||||
pendingFieldAction = Task { @MainActor in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 600_000_000)
|
||||
runAction(action)
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAndLogin() {
|
||||
isRunning = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: values, options: [.sortedKeys])
|
||||
let raw = String(decoding: data, as: UTF8.self)
|
||||
BookSourceRuntimeStore.shared.put(sourceID: engine.sourceKey, key: "loginInfo", value: raw)
|
||||
let engine = engine
|
||||
let values = values
|
||||
Task { @MainActor in
|
||||
do {
|
||||
if let login = engine.loginUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty {
|
||||
_ = try await Task.detached {
|
||||
try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "\(login)\nif (typeof login === 'function') login.apply(this);",
|
||||
result: .object(values.mapValues(JSONValue.string)),
|
||||
executionContext: .init(source: engine.bookSourceAdapter)
|
||||
)
|
||||
}.value
|
||||
}
|
||||
onSave(raw)
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HttpTTSJSONDocument: FileDocument {
|
||||
static var readableContentTypes: [UTType] { [.json] }
|
||||
var data: Data
|
||||
|
||||
init(data: Data) { self.data = data }
|
||||
init(configuration: ReadConfiguration) throws {
|
||||
data = configuration.file.regularFileContents ?? Data()
|
||||
}
|
||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
||||
FileWrapper(regularFileWithContents: data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>小浣熊阅读</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Legado 书源</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.json</string>
|
||||
<string>public.plain-text</string>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>小浣熊阅读</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.9</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>legado</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>用于扫描书源和净化规则的导入二维码。</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>用于保存阅读页面中的图片到照片图库。</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,82 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct LegadoApp: App {
|
||||
@UIApplicationDelegateAdaptor(LegadoAppDelegate.self) private var appDelegate
|
||||
@StateObject private var sourceLibrary: SourceLibraryModel
|
||||
@StateObject private var bookLibrary: BookLibraryModel
|
||||
@StateObject private var replaceRuleLibrary: ReplaceRuleLibraryModel
|
||||
@StateObject private var readingActivity: ReadingActivityModel
|
||||
@StateObject private var rssLibrary: RSSLibraryModel
|
||||
@StateObject private var ruleSubscriptions: RuleSubscriptionModel
|
||||
|
||||
init() {
|
||||
AppDiagnostics.shared.start()
|
||||
SourceWebViewExecutorRegistry.shared.register(WKSourceWebViewExecutor())
|
||||
let demo = ProcessInfo.processInfo.arguments.contains("-uiDemo")
|
||||
_sourceLibrary = StateObject(wrappedValue: demo
|
||||
? SourceLibraryModel.demo(sources: DemoContent.sources)
|
||||
: SourceLibraryModel.live())
|
||||
_bookLibrary = StateObject(wrappedValue: demo
|
||||
? BookLibraryModel.demo(books: DemoContent.books)
|
||||
: BookLibraryModel.live())
|
||||
_replaceRuleLibrary = StateObject(wrappedValue: demo
|
||||
? ReplaceRuleLibraryModel.demo()
|
||||
: ReplaceRuleLibraryModel.live())
|
||||
_readingActivity = StateObject(wrappedValue: demo
|
||||
? ReadingActivityModel.demo(bookmarks: DemoContent.bookmarks, records: DemoContent.readRecords)
|
||||
: ReadingActivityModel.live())
|
||||
_rssLibrary = StateObject(wrappedValue: RSSLibraryModel.live())
|
||||
_ruleSubscriptions = StateObject(wrappedValue: RuleSubscriptionModel())
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootTabView()
|
||||
.environmentObject(sourceLibrary)
|
||||
.environmentObject(bookLibrary)
|
||||
.environmentObject(replaceRuleLibrary)
|
||||
.environmentObject(readingActivity)
|
||||
.environmentObject(rssLibrary)
|
||||
.environmentObject(ruleSubscriptions)
|
||||
.task {
|
||||
await sourceLibrary.load()
|
||||
if let index = ProcessInfo.processInfo.arguments.firstIndex(of: "-uiAutomationImportURL"),
|
||||
ProcessInfo.processInfo.arguments.indices.contains(index + 1),
|
||||
let url = URL(string: ProcessInfo.processInfo.arguments[index + 1]) {
|
||||
await sourceLibrary.handleDeepLink(url)
|
||||
if ProcessInfo.processInfo.arguments.contains("-uiAutomation") {
|
||||
sourceLibrary.notice = nil
|
||||
}
|
||||
}
|
||||
await bookLibrary.load()
|
||||
await replaceRuleLibrary.load()
|
||||
await readingActivity.load()
|
||||
await ruleSubscriptions.load()
|
||||
if UserDefaults.standard.bool(forKey: "auto_refresh") {
|
||||
await bookLibrary.checkForUpdates(sources: sourceLibrary.sources, onlyRead: UserDefaults.standard.bool(forKey: "onlyUpdateRead"))
|
||||
}
|
||||
await ruleSubscriptions.checkAutomaticUpdates(bookSources: sourceLibrary, rssSources: rssLibrary, replaceRules: replaceRuleLibrary)
|
||||
}
|
||||
.onOpenURL { url in
|
||||
Task {
|
||||
await sourceLibrary.load()
|
||||
if url.scheme?.lowercased() == "legado", url.path.lowercased() == "/replacerule" {
|
||||
await replaceRuleLibrary.load()
|
||||
await replaceRuleLibrary.importText(url.absoluteString)
|
||||
if let notice = replaceRuleLibrary.notice {
|
||||
sourceLibrary.notice = .init(kind: notice.kind == .error ? .error : .success, title: notice.title, detail: notice.detail)
|
||||
replaceRuleLibrary.notice = nil
|
||||
}
|
||||
} else { await sourceLibrary.handleDeepLink(url) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class LegadoAppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
|
||||
ReaderScreenOrientation.supportedOrientations
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 - 2015 Atsushi Kiwaki (@_tid_)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
Legado Copyright (C) 2019-2023 gedoor
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,22 @@
|
||||
The MIT License
|
||||
|
||||
Copyright (c) 2009-2025 Jonathan Hedley <https://jsoup.org/>
|
||||
Copyright (c) 2016-2025 Nabil Chatbi (Swift port)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,44 @@
|
||||
[
|
||||
{
|
||||
"id": -100,
|
||||
"name": "1.百度",
|
||||
"url": "http://tts.baidu.com/text2audio,{\n \"method\": \"POST\",\n \"body\": \"tex={{java.encodeURI(java.encodeURI(speakText))}}&spd={{(speakSpeed + 5) / 10 + 4}}&per=3&cuid=baidu_speech_demo&idx=1&cod=2&lan=zh&ctp=1&pdt=160&vol=5&aue=6&pit=5&_res_tag_=audio\"\n}",
|
||||
"contentType": "audio/wav"
|
||||
},
|
||||
{
|
||||
"id": -29,
|
||||
"name": "2.阿里云语音",
|
||||
"url": "https://nls-gateway.cn-shanghai.aliyuncs.com/stream/v1/tts,{\"method\": \"POST\",\"body\": {\"appkey\":\"{{source.getLoginInfoMap().get('AppKey')}}\",\"text\":\"{{speakText}}\",\"format\":\"mp3\",\"volume\":100,\"speech_rate\":{{String((speakSpeed) * 20 - 400)}} }}",
|
||||
"contentType": "audio/mpeg",
|
||||
"loginUrl": "function login(){var loginInfo = source.getLoginInfoMap();\nvar accessKeyId = loginInfo.get('AccessKeyId');\nvar accessKeySecret = loginInfo.get('AccessKeySecret');\nvar timestamp = java.timeFormatUTC(new Date().getTime(), \"yyyy-MM-dd'T'HH:mm:ss'Z'\", 0);\nvar aly = new JavaImporter(Packages.javax.crypto.Mac, Packages.javax.crypto.spec.SecretKeySpec, Packages.javax.xml.bind.DatatypeConverter, Packages.java.net.URLEncoder, Packages.java.lang.String, Packages.android.util.Base64);\nwith (aly) {\n function percentEncode(value) {\n return URLEncoder.encode(value, \"UTF-8\").replace(\"+\", \"%20\")\n .replace(\"*\", \"%2A\").replace(\"%7E\", \"~\")\n }\n\n function sign(stringToSign, accessKeySecret) {\n var mac = Mac.getInstance('HmacSHA1');\n mac.init(new SecretKeySpec(String(accessKeySecret + '&').getBytes(\"UTF-8\"), \"HmacSHA1\"));\n var signData = mac.doFinal(String(stringToSign).getBytes(\"UTF-8\"));\n var signBase64 = Base64.encodeToString(signData, Base64.NO_WRAP);\n var signUrlEncode = percentEncode(signBase64);\n return signUrlEncode;\n }\n}\nvar query = 'AccessKeyId=' + accessKeyId + '&Action=CreateToken&Format=JSON&RegionId=cn-shanghai&SignatureMethod=HMAC-SHA1&SignatureNonce=' + java.randomUUID() + '&SignatureVersion=1.0&Timestamp=' + percentEncode(timestamp) + '&Version=2019-02-28';\nvar signStr = sign('GET&' + percentEncode('/') + '&' + percentEncode(query), accessKeySecret);\nvar queryStringWithSign = \"Signature=\" + signStr + \"&\" + query;\nvar body = java.ajax('http://nls-meta.cn-shanghai.aliyuncs.com/?' + queryStringWithSign)\nvar res = JSON.parse(body)\nif (res.Message) {\n throw new Error(res.Message)\n}\nvar header = { \"X-NLS-Token\": res.Token.Id };\nsource.putLoginHeader(JSON.stringify(header))}",
|
||||
"loginUi": [
|
||||
{
|
||||
"name": "AppKey",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"name": "AccessKeyId",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"name": "AccessKeySecret",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"loginCheckJs": "var response = result;\nif (response.headers().get(\"Content-Type\") != \"audio/mpeg\") {\n var body = JSON.parse(response.body().string())\n if (body.status == 40000001) {\n source.login()\n java.getHeaderMap().putAll(source.getHeaderMap(true))\n response = java.getResponse()\n } else {\n throw body.message\n }\n}\nresponse"
|
||||
},
|
||||
{
|
||||
"concurrentRate": "1500",
|
||||
"contentType": "audio/mpeg",
|
||||
"enabledCookieJar": false,
|
||||
"header": "",
|
||||
"id": 1768175007437,
|
||||
"jsLib": "//明月照大江 · next引擎源\n//--洛娅橙\nvar banben = \"0.0.2\";\nvar styleM = {\n \"无风格\": \"\",\n \"助手\": \"assistant\",\n \"聊天\": \"chat\",\n \"客户服务\": \"customerservice\",\n \"新闻播报\": \"newscast\",\n \"深情\": \"affectionate\",\n \"愤怒\": \"angry\",\n \"平静\": \"calm\",\n \"欢快\": \"cheerful\",\n \"不满\": \"disgruntled\",\n \"恐惧\": \"fearful\",\n \"温柔\": \"gentle\",\n \"抒情\": \"lyrical\",\n \"悲伤\": \"sad\",\n \"严肃\": \"serious\",\n \"诗歌朗诵\": \"poetry-reading\",\n \"友好\": \"friendly\",\n \"随意聊天\": \"chat-casual\",\n \"耳语\": \"whispering\",\n \"歉意\": \"sorry\",\n \"兴奋\": \"excited\"\n};\n\nvar apiM = {\n \"http://171.113.113.119:8085/tts\": true,\n \"http://5.45.99.149:8075/tts\": true,\n \"http://104.214.168.83:8080/tts\": true,\n \"http://74.48.40.244:8010/tts\": true,\n \"http://47.119.125.172:8080/tts\": true,\n \"http://64.112.42.45:9080/tts\": true,\n \"http://146.56.188.115:8080/tts\": false,\n \"https://t.leftsite.cn/tts\": false,\n \"http://47.79.92.215:18080/tts\": false,\n \"http://36.248.181.23:22335/tts\": true,\n \"http://180.114.35.250:1080/tts\": true,\n \"http://124.71.164.73:8085/tts\": true,\n \"http://190.92.218.92:8080/tts\": true\n};\n\nvar fyrM = {\n \"晓晓\": \"zh-CN-XiaoxiaoNeural\",\n \"晓亦\": \"zh-CN-XiaoyiNeural\",\n \"晓辰\": \"zh-CN-XiaochenNeural\",\n \"晓涵\": \"zh-CN-XiaohanNeural\",\n \"晓梦\": \"zh-CN-XiaomengNeural\",\n \"晓墨\": \"zh-CN-XiaomoNeural\",\n \"晓秋\": \"zh-CN-XiaoqiuNeural\",\n \"晓柔\": \"zh-CN-XiaorouNeural\",\n \"晓睿\": \"zh-CN-XiaoruiNeural\",\n \"晓双\": \"zh-CN-XiaoshuangNeural\",\n \"晓颜\": \"zh-CN-XiaoyanNeural\",\n \"晓悠\": \"zh-CN-XiaoyouNeural\",\n \"晓甄\": \"zh-CN-XiaozhenNeural\",\n \"云溪\": \"zh-CN-YunxiNeural\",\n \"云间\": \"zh-CN-YunjianNeural\",\n \"云扬\": \"zh-CN-YunyangNeural\",\n \"云枫\": \"zh-CN-YunfengNeural\",\n \"云皓\": \"zh-CN-YunhaoNeural\",\n \"云杰\": \"zh-CN-YunjieNeural\",\n \"云夏\": \"zh-CN-YunxiaNeural\",\n \"云野\": \"zh-CN-YunyeNeural\",\n \"云泽\": \"zh-CN-YunzeNeural\",\n\n \"晓晓DIA\": \"zh-CN-XiaoxiaoDialectsNeural\",\n \"晓晓MT\": \"zh-CN-XiaoxiaoMultilingualNeural\",\n \"晓宇MT\": \"zh-CN-XiaoyuMultilingualNeural\",\n \"云晓MT\": \"zh-CN-YunxiaoMultilingualNeural\",\n \"云逸MT\": \"zh-CN-YunyiMultilingualNeural\",\n \"云帆MT\": \"zh-CN-YunfanMultilingualNeural\",\n\n \"晓辰HD\": \"zh-CN-Xiaochen:DragonHDLatestNeural\",\n \"云帆HD\": \"zh-CN-Yunfan:DragonHDLatestNeural\",\n\n \"晓辰FHD\": \"zh-CN-Xiaochen:DragonHDFlashLatestNeural\",\n \"晓晓FHD\": \"zh-CN-Xiaoxiao:DragonHDFlashLatestNeural\",\n \"潇潇FHD\": \"zh-CN-Xiaoxiao2:DragonHDFlashLatestNeural\",\n \"云萧FHD\": \"zh-CN-Yunxiao:DragonHDFlashLatestNeural\",\n \"云野FHD\": \"zh-CN-Yunye:DragonHDFlashLatestNeural\",\n \"云逸FHD\": \"zh-CN-Yunyi:DragonHDFlashLatestNeural\",\n\n \"晓宸_繁\": \"zh-TW-HsiaoChenNeural\",\n \"晓语_繁\": \"zh-TW-HsiaoYuNeural\",\n \"云喆_繁\": \"zh-TW-YunJheNeural\"\n};\n\nfunction apiurl(speakText, speakSpeed, apiurl) {\n let info = this.source.getLoginInfoMap();\n let api = apiurl || info[\"api\"];\n let apik = apiM[api];\n let fyr = info[\"发言人\"];\n let fyrk = fyrM[fyr];\n let fg = info[\"风格\"];\n let fgk = styleM[fg];\n let body = {};\n let params = `?t=${speakText}&v=${fyrk}&r=${(speakSpeed - 10) * 10}&p=0&s=${fgk}&api_key=`;\n return `${api}${params}`;\n}",
|
||||
"lastUpdateTime": 1770778835144,
|
||||
"loginCheckJs": "if (result.code() != 200) {\n java.toast(\"接口失效\\n正在切换中...\");\n java.log(\"失效接口:\" + java.ruleUrl)\n let ulm = Object.keys(apiM);\n let uls = ulm.map(x => apiurl(\"测试\", 10, x));\n let resl = java.ajaxTestAll(uls, 3000, true);\n let goodI = -1,\n i = 0;\n let goodt = 9999;\n for (let res of resl) {\n let time = res.callTime();\n if (time > 0 && time < goodt) {\n goodI = i;\n goodt = time;\n }\n i++;\n }\n if (goodI != -1) {\n let goodurl = ulm[goodI];\n java.toast(java.log(\"\\n已选择最优接口:\" + goodurl));\n let Info = source.getLoginInfoMap();\n Info['api'] = goodurl;\n source.putLoginInfo(JSON.stringify(Info));\n java.initUrl();\n result = java.getResponse();\n } else {\n java.toast(\"需在登录界面切换接口\\n引擎版本号:\" + banben);\n }\n}\nresult",
|
||||
"loginUi": "@js:\nlet styleS = Object.keys(styleM).map(key => `\"${key}\"`).join(',');\nlet fyrS = Object.keys(fyrM).map(key => `\"${key}\"`).join(',');\nlet apiS = Object.keys(apiM).map(key => `\"${key}\"`).join(',');\n`[{\n \"name\": \"发言人\",\n \"viewName\": \"'发言人:'\",\n \"action\":\"clear()\",\n \"type\": \"select\",\n \"chars\": [${fyrS}],\n \"style\": {\n \"layout_flexGrow\": 1,\n \"layout_flexBasisPercent\": 0.4\n }\n },\n {\n \"name\": \"风格\",\n \"viewName\": \"'风格:'\",\n \"action\":\"clear()\",\n \"type\": \"select\",\n \"chars\": [${styleS}],\n \"style\": {\n \"layout_flexGrow\": 1,\n \"layout_flexBasisPercent\": 0.4\n }\n },\n {\n \"name\": \"api\",\n \"viewName\": \"'接口:'\",\n \"action\":\"checkjk()\",\n \"type\": \"select\",\n \"chars\": [${apiS}],\n \"style\": {\n \"layout_flexGrow\": 1,\n \"layout_flexBasisPercent\": 0.4,\n \"layout_wrapBefore\": true\n }\n },\n {\n \"name\": \"自动清除缓存\",\n \"viewName\": \"'切换发言人时清理缓存 '\",\n \"type\": \"toggle\",\n \"chars\": [\"✅\",\"🔳\"],\n \"style\": {\n \"layout_flexGrow\": 0,\n \"layout_flexBasisPercent\": -1,\n \"layout_justifySelf\":\"right\",\n \"layout_wrapBefore\": true\n }\n }\n]`;",
|
||||
"loginUrl": "function login() {}\n\nfunction checkjk() {\n let jk = result[\"api\"];\n let fhd = apiM[jk];\n java.toast(`\\n当前接口${fhd?\"✓支持\":\"X不支持\"}FHD发言人\\n正在测试延迟...`);\n let url = apiurl(\"测试\", 10, jk);\n let res = java.ajaxTestAll([url], 3000, true)[0];\n let time = res.callTime();\n if (time < 0) {\n java.toast(java.log(`\\n接口:${jk}\\n无法访问 ${time}`));\n return\n }\n java.toast(`\\n接口:${jk}\\n延迟时间为${time}`);\n}\n\nfunction clear() {\n if (result[\"自动清除缓存\"] == \"🔳\") return;\n java.clearTtsCache();\n}",
|
||||
"name": "🌈Next引擎-长按切换音色",
|
||||
"url": "@js:apiurl(speakText,speakSpeed);"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,210 @@
|
||||
[
|
||||
{
|
||||
"id": -1,
|
||||
"enable": true,
|
||||
"name": "目录(去空白)",
|
||||
"rule": "(?<=[ \\s])(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和]))).{0,30}$",
|
||||
"example": "第一章 假装第一章前面有空白但我不要",
|
||||
"serialNumber": 0
|
||||
},
|
||||
{
|
||||
"id": -2,
|
||||
"enable": true,
|
||||
"name": "目录",
|
||||
"rule": "^[ \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|篇(?!张))).{0,30}$",
|
||||
"example": "第一章 标准的粤语就是这样",
|
||||
"serialNumber": 1
|
||||
},
|
||||
{
|
||||
"id": -3,
|
||||
"enable": false,
|
||||
"name": "目录(匹配简介)",
|
||||
"rule": "(?<=[ \\s])(?:(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|回(?![合来事去])|场(?![和合比电是])|篇(?!张))).{0,30}$",
|
||||
"example": "简介 老夫诸葛村夫",
|
||||
"serialNumber": 2
|
||||
},
|
||||
{
|
||||
"id": -4,
|
||||
"enable": false,
|
||||
"name": "目录(古典、轻小说备用)",
|
||||
"rule": "^[ \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|回(?![合来事去])|场(?![和合比电是])|话|篇(?!张))).{0,30}$",
|
||||
"example": "第一章 比上面只多了回和话",
|
||||
"serialNumber": 3
|
||||
},
|
||||
{
|
||||
"id": -5,
|
||||
"enable": false,
|
||||
"name": "数字(纯数字标题)",
|
||||
"rule": "(?<=[ \\s])\\d+\\.?[ \\t]{0,4}$",
|
||||
"example": "12",
|
||||
"serialNumber": 4
|
||||
},
|
||||
{
|
||||
"id": -6,
|
||||
"enable": false,
|
||||
"name": "大写数字(纯数字标题)",
|
||||
"rule": "(?<=[ \\s])[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,12}[ \\t]{0,4}$",
|
||||
"example": "一百七十",
|
||||
"serialNumber": 5
|
||||
},
|
||||
{
|
||||
"id": -7,
|
||||
"enable": false,
|
||||
"name": "数字混合(纯数字标题)",
|
||||
"rule": "(?<=[ \\s])[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟\\d]{1,12}[ \\t]{0,4}$",
|
||||
"example": "12\n一百七十",
|
||||
"serialNumber": 6
|
||||
},
|
||||
{
|
||||
"id": -8,
|
||||
"enable": true,
|
||||
"name": "数字 分隔符 标题名称",
|
||||
"rule": "^[ \\t]{0,4}\\d{1,5}[::,., 、_—\\-].{1,30}$",
|
||||
"example": "1、这个就是标题",
|
||||
"serialNumber": 7
|
||||
},
|
||||
{
|
||||
"id": -9,
|
||||
"enable": true,
|
||||
"name": "大写数字 分隔符 标题名称",
|
||||
"rule": "^[ \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章?)[ 、_—\\-].{1,30}$",
|
||||
"example": "一、只有前面的数字有差别\n二十四章 我瞎编的标题",
|
||||
"serialNumber": 8
|
||||
},
|
||||
{
|
||||
"id": -10,
|
||||
"enable": false,
|
||||
"name": "数字混合 分隔符 标题名称",
|
||||
"rule": "^[ \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章?[ 、_—\\-]|\\d{1,5}章?[::,., 、_—\\-]).{0,30}$",
|
||||
"example": "1、人参公鸡\n二百二十章 boy next door",
|
||||
"serialNumber": 9
|
||||
},
|
||||
{
|
||||
"id": -11,
|
||||
"enable": true,
|
||||
"name": "正文 标题/序号",
|
||||
"rule": "^[ \\t]{0,4}正文[ ]{1,4}.{0,20}$",
|
||||
"example": "正文 我奶常山赵子龙",
|
||||
"serialNumber": 10
|
||||
},
|
||||
{
|
||||
"id": -12,
|
||||
"enable": true,
|
||||
"name": "Chapter/Section/Part/Episode 序号 标题",
|
||||
"rule": "^[ \\t]{0,4}(?:[Cc]hapter|[Ss]ection|[Pp]art|PART|[Nn][oO][.、]|[Ee]pisode|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)\\s{0,4}\\d{1,4}.{0,30}$",
|
||||
"example": "Chapter 1 MyGrandmaIsNB",
|
||||
"serialNumber": 11
|
||||
},
|
||||
{
|
||||
"id": -13,
|
||||
"enable": false,
|
||||
"name": "Chapter(去简介)",
|
||||
"rule": "^[ \\t]{0,4}(?:[Cc]hapter|[Ss]ection|[Pp]art|PART|[Nn][Oo]\\.|[Ee]pisode)\\s{0,4}\\d{1,4}.{0,30}$",
|
||||
"example": "Chapter 1 MyGrandmaIsNB",
|
||||
"serialNumber": 12
|
||||
},
|
||||
{
|
||||
"id": -14,
|
||||
"enable": true,
|
||||
"name": "特殊符号 序号 标题",
|
||||
"rule": "(?<=[\\s ])[【〔〖「『〈[\\[](?:第|[Cc]hapter)[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,10}[章节].{0,20}$",
|
||||
"example": "【第一章 后面的符号可以没有",
|
||||
"serialNumber": 13
|
||||
},
|
||||
{
|
||||
"id": -15,
|
||||
"enable": false,
|
||||
"name": "特殊符号 标题(成对)",
|
||||
"rule": "(?<=[\\s ]{0,4})(?:[\\[〈「『〖〔《(【\\(].{1,30}[\\)】)》〕〗』」〉\\]]?|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)[ ]{0,4}$",
|
||||
"example": "『加个直角引号更专业』\n(11)我奶常山赵子聋",
|
||||
"serialNumber": 14
|
||||
},
|
||||
{
|
||||
"id": -16,
|
||||
"enable": true,
|
||||
"name": "特殊符号 标题(单个)",
|
||||
"rule": "(?<=[\\s ]{0,4})(?:[☆★✦✧].{1,30}|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)[ ]{0,4}$",
|
||||
"example": "☆、晋江作者最喜欢的格式",
|
||||
"serialNumber": 15
|
||||
},
|
||||
{
|
||||
"id": -17,
|
||||
"enable": true,
|
||||
"name": "章/卷 序号 标题",
|
||||
"rule": "^[ \\t ]{0,4}(?:(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[卷章][\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8})[ ]{0,4}.{0,30}$",
|
||||
"example": "卷五 开源盛世",
|
||||
"serialNumber": 16
|
||||
},
|
||||
{
|
||||
"id": -18,
|
||||
"enable": false,
|
||||
"name": "顶格标题",
|
||||
"rule": "^\\S.{1,20}$",
|
||||
"example": "20字以内顶格写的都是标题",
|
||||
"serialNumber": 17
|
||||
},
|
||||
{
|
||||
"id": -19,
|
||||
"enable": false,
|
||||
"name": "双标题(前向)",
|
||||
"rule": "(?m)(?<=[ \\t ]{0,4})第[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$(?=[\\s ]{0,8}第[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章)",
|
||||
"example": "第一章 真正的标题\n第一章 这个不要",
|
||||
"serialNumber": 18
|
||||
},
|
||||
{
|
||||
"id": -20,
|
||||
"enable": false,
|
||||
"name": "双标题(后向)",
|
||||
"rule": "(?m)(?<=[ \\t ]{0,4}第[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$[\\s ]{0,8})第[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$",
|
||||
"example": "第一章 这个标题不要\n第一章真正的标题",
|
||||
"serialNumber": 19
|
||||
},
|
||||
{
|
||||
"id": -21,
|
||||
"enable": true,
|
||||
"name": "书名 括号 序号",
|
||||
"rule": "^[一-龥]{1,20}[ \\t]{0,4}[((][\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}[))][ \\t]{0,4}$",
|
||||
"example": "标题后面数字有括号(12)",
|
||||
"serialNumber": 20
|
||||
},
|
||||
{
|
||||
"id": -22,
|
||||
"enable": true,
|
||||
"name": "书名 序号",
|
||||
"rule": "^[一-龥]{1,20}[ \\t]{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}[ \\t]{0,4}$",
|
||||
"example": "标题后面数字没有括号124",
|
||||
"serialNumber": 21
|
||||
},
|
||||
{
|
||||
"id": -23,
|
||||
"enable": false,
|
||||
"name": "特定字符 标题 特定符号",
|
||||
"rule": "(?<=\\={3,6}).{1,40}?(?=\\=)",
|
||||
"example": "===起这种标题干什么===",
|
||||
"serialNumber": 22
|
||||
},
|
||||
{
|
||||
"id": -24,
|
||||
"enable": true,
|
||||
"name": "字数分割 分节阅读",
|
||||
"rule": "(?<=[ \\t]{0,4})(?:.{0,15}分[页节章段]阅读[-_ ]|第\\s{0,4}[\\d零一二两三四五六七八九十百千万]{1,6}\\s{0,4}[页节]).{0,30}$",
|
||||
"example": "分节|分页|分段阅读\n第一页",
|
||||
"serialNumber": 23
|
||||
},
|
||||
{
|
||||
"id": -25,
|
||||
"enable": false,
|
||||
"name": "通用规则",
|
||||
"rule": "(?im)^.{0,6}(?:[引楔]子|正文(?!完|结)|[引序前]言|[序终]章|扉页|[上中下][部篇卷]|卷首语|后记|尾声|番外|={2,4}|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|页[、 ]|集(?![合和])|部(?![分是门落])|篇(?!张))).{0,40}$|^.{0,6}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟a-z]{1,8}[、. ].{0,20}$",
|
||||
"example": "激进规则,适配更多非常用格式",
|
||||
"serialNumber": 24
|
||||
},
|
||||
{
|
||||
"id": -100,
|
||||
"enable": false,
|
||||
"name": "默认分章规则",
|
||||
"rule": "",
|
||||
"example": "兜底规则,请勿改动此内容",
|
||||
"serialNumber": 99
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user