fix(import): 修复本地 TXT 文件选择与导入

- 书架和文件管理统一使用 TXT 文件选择器,支持大小写扩展名及多选,排除 JSON 书源
- 保留所选文件访问权限至异步导入完成,等待选择器关闭后展示导入确认
- 补充双入口文件选择回归、导入校验和 TXT 测试样本
This commit is contained in:
Aminark
2026-10-22 10:51:09 +08:00
parent 7daa6b5635
commit bf72ebc894
9 changed files with 252 additions and 37 deletions
+79
View File
@@ -207,9 +207,88 @@ struct ReadingPhotoPickerController: UIViewControllerRepresentable {
}
}
private struct ReadingTXTFileImporterModifier: ViewModifier {
@Binding var isPresented: Bool
let onFiles: ([LocalBookImportFile]) -> Void
let onError: (Error) -> Void
@State private var pickedFiles: [LocalBookImportFile] = []
@State private var pickerHasDismissed = false
func body(content: Content) -> some View {
content.sheet(isPresented: $isPresented, onDismiss: {
pickerHasDismissed = true
deliverFilesWhenReady()
}) {
ReadingTXTFilePicker { files in
pickedFiles = files
isPresented = false
deliverFilesWhenReady()
} onCancel: {
pickedFiles = []
isPresented = false
}
.onAppear {
pickedFiles = []
pickerHasDismissed = false
}
}
}
private func deliverFilesWhenReady() {
// Providers may return their copies before or after dismissing Files.
guard pickerHasDismissed, !pickedFiles.isEmpty else { return }
let files = pickedFiles
pickedFiles = []
guard files.allSatisfy({ $0.url.pathExtension.lowercased() == "txt" && !$0.url.hasDirectoryPath }) else {
onError(LocalBookError.unsupportedFormat)
return
}
onFiles(files)
}
}
private struct ReadingTXTFilePicker: UIViewControllerRepresentable {
let onPick: @MainActor ([LocalBookImportFile]) -> Void
let onCancel: @MainActor () -> Void
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
// Plain text includes UTF-8/UTF-16 TXT, while .text and .data also allow JSON.
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.plainText], asCopy: true)
picker.allowsMultipleSelection = true
picker.shouldShowFileExtensions = true
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ controller: UIDocumentPickerViewController, context: Context) {}
@MainActor
final class Coordinator: NSObject, UIDocumentPickerDelegate {
let parent: ReadingTXTFilePicker
init(parent: ReadingTXTFilePicker) { self.parent = parent }
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { parent.onCancel() }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
parent.onPick(urls.map { LocalBookImportFile(url: $0) })
}
}
}
enum ReadingSheetDetent { case medium, large }
extension View {
func readingTXTFileImporter(
isPresented: Binding<Bool>,
onFiles: @escaping ([LocalBookImportFile]) -> Void,
onError: @escaping (Error) -> Void
) -> some View {
modifier(ReadingTXTFileImporterModifier(isPresented: isPresented, onFiles: onFiles, onError: onError))
}
func readingPhotoPicker(
isPresented: Binding<Bool>,
onData: @escaping (Data) -> Void,
@@ -26,9 +26,9 @@ private final class FileManagementModel: ObservableObject {
await reload(library)
}
func importFiles(_ urls: [URL], library: BookLibraryModel) async {
func importFiles(_ files: [LocalBookImportFile], library: BookLibraryModel) async {
await run(library) {
await library.importLocalFiles(urls)
await library.importPickedLocalFiles(files)
errorMessage = library.errorMessage
library.clearError()
}
@@ -136,6 +136,7 @@ struct FileManagementView: View {
}
}
} label: { Image(systemName: "ellipsis.circle") }
.accessibilityLabel("更多")
}
}
}
@@ -162,11 +163,10 @@ struct FileManagementView: View {
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
}
.readingTXTFileImporter(isPresented: $importsFile) { files in
Task { await model.importFiles(files, library: library) }
} onError: { error in
model.errorMessage = error.localizedDescription
}
.alert("重命名文件", isPresented: Binding(get: { renameURL != nil }, set: { if !$0 { renameURL = nil } }), presenting: renameURL) { url in
TextField("文件名", text: $newName)
+22 -3
View File
@@ -4,6 +4,20 @@ import SwiftUI
import LegadoCore
#endif
final class LocalBookImportFile: @unchecked Sendable {
let url: URL
private let hasSecurityScope: Bool
init(url: URL) {
self.url = url
hasSecurityScope = url.startAccessingSecurityScopedResource()
}
deinit {
if hasSecurityScope { url.stopAccessingSecurityScopedResource() }
}
}
enum AppBookChapterRepository {
static func live() -> any BookChapterRepository {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
@@ -318,10 +332,15 @@ final class BookLibraryModel: ObservableObject {
}
func importLocalFiles(_ urls: [URL]) async {
await importPickedLocalFiles(urls.map { LocalBookImportFile(url: $0) })
}
func importPickedLocalFiles(_ files: [LocalBookImportFile]) async {
// Keep the picker grants alive through the review and asynchronous copy.
defer { withExtendedLifetime(files) {} }
var failures: [String] = []
for url in urls {
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
for file in files {
let url = file.url
do {
let stored = try await localFiles.importFile(from: url)
do {
+17 -24
View File
@@ -146,7 +146,7 @@ private struct BookshelfView: View {
@EnvironmentObject private var library: BookLibraryModel
@EnvironmentObject private var sources: SourceLibraryModel
@State private var showsLocalImporter = false
@State private var pendingLocalURLs: [URL] = []
@State private var pendingLocalFiles: [LocalBookImportFile] = []
@State private var selectedGroupID = BookGroup.idAll
@State private var showsGroupManager = false
@AppStorage("bookshelfLayout") private var bookshelfLayout = "grid3"
@@ -222,26 +222,17 @@ private struct BookshelfView: View {
}
.background(ReadingTheme.canvas)
.navigationBarHidden(true)
.fileImporter(
isPresented: $showsLocalImporter,
allowedContentTypes: [.text, .plainText, .data],
allowsMultipleSelection: true
) { result in
switch result {
case .success(let urls):
pendingLocalURLs = urls.filter { $0.pathExtension.lowercased() == "txt" }
if pendingLocalURLs.isEmpty, !urls.isEmpty {
library.reportError(LocalBookError.unsupportedFormat)
}
case .failure(let error): library.reportError(error)
}
.readingTXTFileImporter(isPresented: $showsLocalImporter) { files in
pendingLocalFiles = files
} onError: { error in
library.reportError(error)
}
.sheet(isPresented: Binding(
get: { !pendingLocalURLs.isEmpty },
set: { if !$0 { pendingLocalURLs = [] } }
get: { !pendingLocalFiles.isEmpty },
set: { if !$0 { pendingLocalFiles = [] } }
)) {
LocalBookImportReviewView(urls: pendingLocalURLs) {
pendingLocalURLs = []
LocalBookImportReviewView(files: pendingLocalFiles) {
pendingLocalFiles = []
}
.environmentObject(library)
}
@@ -646,15 +637,17 @@ private struct BookGroupSelectView: View {
private struct LocalBookImportReviewView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: BookLibraryModel
let urls: [URL]
let files: [LocalBookImportFile]
let onFinish: () -> Void
@State private var selectedPaths: Set<String>
@State private var isImporting = false
init(urls: [URL], onFinish: @escaping () -> Void) {
self.urls = urls
private var urls: [URL] { files.map(\.url) }
init(files: [LocalBookImportFile], onFinish: @escaping () -> Void) {
self.files = files
self.onFinish = onFinish
_selectedPaths = State(initialValue: Set(urls.map(\.path)))
_selectedPaths = State(initialValue: Set(files.map { $0.url.path }))
}
var body: some View {
@@ -690,9 +683,9 @@ private struct LocalBookImportReviewView: View {
ToolbarItem(placement: .confirmationAction) {
Button("导入") {
isImporting = true
let selected = urls.filter { selectedPaths.contains($0.path) }
let selected = files.filter { selectedPaths.contains($0.url.path) }
Task {
await library.importLocalFiles(selected)
await library.importPickedLocalFiles(selected)
finish()
}
}