From bf72ebc8947308d836e3f85510ccbae1d2691018 Mon Sep 17 00:00:00 2001 From: Aminark <27661225+Aminark@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:36:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(import):=20=E4=BF=AE=E5=A4=8D=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=20TXT=20=E6=96=87=E4=BB=B6=E9=80=89=E6=8B=A9=E4=B8=8E?= =?UTF-8?q?=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 书架和文件管理统一使用 TXT 文件选择器,支持大小写扩展名及多选,排除 JSON 书源 - 保留所选文件访问权限至异步导入完成,等待选择器关闭后展示导入确认 - 补充双入口文件选择回归、导入校验和 TXT 测试样本 --- App/Compatibility.swift | 79 +++++++++++++++++ .../Activity/FileManagementView.swift | 14 +-- App/Features/Books/BookLibraryModel.swift | 25 +++++- App/RootTabView.swift | 41 ++++----- .../LocalFileManagementTests.swift | 32 +++++++ .../RuleEditorUITests.swift | 86 +++++++++++++++++++ docs/DEVELOPMENT.md | 6 +- scripts/fixtures/SourceImportFixture.swift | 4 + scripts/prepare-source-import-fixture.sh | 2 +- 9 files changed, 252 insertions(+), 37 deletions(-) diff --git a/App/Compatibility.swift b/App/Compatibility.swift index 5dadf34..5020720 100644 --- a/App/Compatibility.swift +++ b/App/Compatibility.swift @@ -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, + onFiles: @escaping ([LocalBookImportFile]) -> Void, + onError: @escaping (Error) -> Void + ) -> some View { + modifier(ReadingTXTFileImporterModifier(isPresented: isPresented, onFiles: onFiles, onError: onError)) + } + func readingPhotoPicker( isPresented: Binding, onData: @escaping (Data) -> Void, diff --git a/App/Features/Activity/FileManagementView.swift b/App/Features/Activity/FileManagementView.swift index 01bffa1..d08c805 100644 --- a/App/Features/Activity/FileManagementView.swift +++ b/App/Features/Activity/FileManagementView.swift @@ -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) diff --git a/App/Features/Books/BookLibraryModel.swift b/App/Features/Books/BookLibraryModel.swift index 3eeafe5..106c8f1 100644 --- a/App/Features/Books/BookLibraryModel.swift +++ b/App/Features/Books/BookLibraryModel.swift @@ -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 { diff --git a/App/RootTabView.swift b/App/RootTabView.swift index 0470493..e7f6011 100644 --- a/App/RootTabView.swift +++ b/App/RootTabView.swift @@ -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 @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() } } diff --git a/Tests/LegadoAppModelsTests/LocalFileManagementTests.swift b/Tests/LegadoAppModelsTests/LocalFileManagementTests.swift index 3192cc1..83271b2 100644 --- a/Tests/LegadoAppModelsTests/LocalFileManagementTests.swift +++ b/Tests/LegadoAppModelsTests/LocalFileManagementTests.swift @@ -5,6 +5,38 @@ import LegadoCore @MainActor struct LocalFileManagementTests { + @Test func pickedTXTImportsButJSONFilesCannotBecomeLocalBooks() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let inputs = [ + ("小说.TXT", "第一章 开始\n本地小说的正文"), + ("书源.json", #"{"bookSourceName":"书源"}"#), + ("大写书源.JSON", #"{"bookSourceName":"书源"}"#) + ] + let files = try inputs.map { name, text in + let url = root.appendingPathComponent(name) + try Data(text.utf8).write(to: url) + return LocalBookImportFile(url: url) + } + let model = BookLibraryModel(repository: InMemoryBookRepository(), + localDirectory: root.appendingPathComponent("managed"), + chapterRepository: InMemoryBookChapterRepository()) + await model.load() + + await model.importPickedLocalFiles(files) + + let book = try #require(model.books.first) + #expect(model.books.count == 1) + #expect(book.origin == "local") + #expect(book.originName == "小说.TXT") + #expect(model.errorMessage?.contains("书源.json") == true) + #expect(model.errorMessage?.contains("大写书源.JSON") == true) + #expect(try await model.localFiles.list().count == 1) + let imported = try #require(URL(string: book.bookUrl)) + #expect(try Data(contentsOf: imported) == Data(inputs[0].1.utf8)) + } + private func fixture(fails: Bool = false) async throws -> (URL, Book, BookLibraryModel) { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) diff --git a/Tests/LegadoReaderUITests/RuleEditorUITests.swift b/Tests/LegadoReaderUITests/RuleEditorUITests.swift index 471ec97..59fe857 100644 --- a/Tests/LegadoReaderUITests/RuleEditorUITests.swift +++ b/Tests/LegadoReaderUITests/RuleEditorUITests.swift @@ -2,6 +2,92 @@ import XCTest @MainActor final class RuleEditorUITests: XCTestCase { + func testBookshelfTXTFilePickerImportsTXTAndDisablesJSON() throws { + try exerciseLocalTXTImport(fromFileManagement: false) + } + + func testFileManagementTXTFilePickerImportsTXTAndDisablesJSON() throws { + try exerciseLocalTXTImport(fromFileManagement: true) + } + + private func exerciseLocalTXTImport(fromFileManagement: Bool) throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-uiDemo", "-themeMode", "1", "-bookshelfLayout", "grid3"] + if fromFileManagement { app.launchArguments += ["-uiDemoTab", "settings", "-uiDemoFiles"] } + app.launch() + let importTitle = fromFileManagement ? "导入 TXT 文件" : "本地书" + let more = fromFileManagement + ? app.navigationBars["文件管理"].buttons["更多"].firstMatch + : app.buttons["更多"].firstMatch + XCTAssertTrue(more.waitForExistence(timeout: 20)) + more.tap() + app.buttons[importTitle].tap() + let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch + XCTAssertTrue(cancel.waitForExistence(timeout: 10)) + cancel.tap() + let closed = expectation(for: NSPredicate { _, _ in !cancel.exists }, evaluatedWith: nil) + wait(for: [closed], timeout: 10) + more.tap() + app.buttons[importTitle].tap() + XCTAssertTrue(cancel.waitForExistence(timeout: 10)) + + let pickerFiles = app.collectionViews["File View"].cells + let txt = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Book")).firstMatch + let fixtureJSON = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "JSON-Source")).firstMatch + let fixtures = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "书源导入测试")).firstMatch + // The file-management page underneath the picker can also contain TXT. + // JSON exists only inside our fixture folder in the actual picker. + if !fixtureJSON.waitForExistence(timeout: 3) { + if !fixtures.exists { + let browse = app.buttons.matching(NSPredicate(format: "label IN %@", ["Browse", "浏览", "Locations", "位置"])).firstMatch + if browse.exists { browse.tap() } + if !fixtures.waitForExistence(timeout: 2) { + let local = app.descendants(matching: .any).matching(NSPredicate( + format: "label CONTAINS %@ OR label CONTAINS %@ OR label == %@", "我的 iPhone", "我的iPhone", "On My iPhone" + )).firstMatch + XCTAssertTrue(local.waitForExistence(timeout: 5)) + local.tap() + } + } + XCTAssertTrue(fixtures.waitForExistence(timeout: 10), "Run sh scripts/prepare-source-import-fixture.sh ") + fixtures.tap() + } + XCTAssertTrue(txt.waitForExistence(timeout: 10)) + let open = app.buttons.matching(NSPredicate(format: "label IN %@", ["Open", "打开", "Import", "导入"])).firstMatch + for name in ["JSON-Source", "JSON-Uppercase"] { + let json = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", name)).firstMatch + XCTAssertTrue(json.waitForExistence(timeout: 5)) + XCTAssertFalse(json.isEnabled) + json.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + XCTAssertFalse(open.exists && open.isEnabled, "JSON must not be selectable as a local book") + } + txt.tap() + let uppercase = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Uppercase")).firstMatch + XCTAssertTrue(uppercase.waitForExistence(timeout: 5)) + uppercase.tap() + XCTAssertTrue(open.waitForExistence(timeout: 5)) + XCTAssertTrue(open.isEnabled) + attach(app, name: "TXT selectable and JSON excluded") + open.tap() + + if fromFileManagement { + XCTAssertTrue(app.navigationBars["文件管理"].waitForExistence(timeout: 15)) + app.tabBars.buttons["书架"].tap() + } else { + XCTAssertTrue(app.navigationBars["导入本地书"].waitForExistence(timeout: 15)) + XCTAssertFalse(app.staticTexts["JSON-Source.json"].exists) + XCTAssertFalse(app.staticTexts["JSON-Uppercase.JSON"].exists) + XCTAssertTrue(app.staticTexts["TXT-Book.txt"].exists) + XCTAssertTrue(app.staticTexts["TXT-Uppercase.TXT"].exists) + app.buttons["导入"].tap() + } + // Repeated runs preserve managed files and correctly add a numeric suffix. + XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Book")).firstMatch.waitForExistence(timeout: 15)) + XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Uppercase")).firstMatch.waitForExistence(timeout: 15)) + attach(app, name: "Local TXT books imported onto shelf") + } + func testJSONFilePickerOffersOpenAndImportsSelectedFiles() throws { continueAfterFailure = false let app = XCUIApplication() diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e346830..57735db 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -41,7 +41,7 @@ sh scripts/self-check.sh 阅读器测试在 Xcode 选择 `LegadoReaderTests` / `LegadoReaderUITests` Scheme 和具体模拟器运行。联网书源测试需要显式配置对应环境变量,默认核心回归主要采用自造响应,不能将其通过解读为所有线上书源均可用。 -书源文件选择回归需要先在已启动的测试模拟器中准备文件共享应用(将下方 UUID 换成实际值): +书源和本地 TXT 文件选择回归需要先在已启动的测试模拟器中准备文件共享应用(将下方 UUID 换成实际值): ```sh LEGADO_TEST_DEVICE="模拟器 UUID" @@ -49,10 +49,12 @@ sh scripts/prepare-source-import-fixture.sh "$LEGADO_TEST_DEVICE" xcodebuild -project Legado.xcodeproj -scheme LegadoReaderUITests \ -destination "platform=iOS Simulator,id=$LEGADO_TEST_DEVICE" \ -only-testing:LegadoReaderUITests/RuleEditorUITests/testJSONFilePickerOffersOpenAndImportsSelectedFiles \ + -only-testing:LegadoReaderUITests/RuleEditorUITests/testBookshelfTXTFilePickerImportsTXTAndDisablesJSON \ + -only-testing:LegadoReaderUITests/RuleEditorUITests/testFileManagementTXTFilePickerImportsTXTAndDisablesJSON \ -collect-test-diagnostics never CODE_SIGNING_ALLOWED=NO test ``` -独立测试应用只提供 `.json`、`.JSON` 和 `.txt` 样本,不会被打包进正式 App。此回归检查取消后重新打开、JSON 多选后的打开按钮、导入预检及最终保存。 +独立测试应用提供 JSON 书源和 `.txt`、`.TXT` 小说样本,不会被打包进正式 App。此回归检查取消后重新打开、书源 JSON 多选、本地 TXT 可选且 JSON 不可选、导入确认及最终保存。 ## 兼容性验证 diff --git a/scripts/fixtures/SourceImportFixture.swift b/scripts/fixtures/SourceImportFixture.swift index 3c05ea9..e5aac87 100644 --- a/scripts/fixtures/SourceImportFixture.swift +++ b/scripts/fixtures/SourceImportFixture.swift @@ -19,6 +19,10 @@ final class FixtureAppDelegate: UIResponder, UIApplicationDelegate { try? data.write(to: documents.appendingPathComponent(name), options: .atomic) } } + for name in ["TXT-Book.txt", "TXT-Uppercase.TXT"] { + try? Data("第一章 开始\n这是用于验证本地 TXT 导入的小说正文。\n第二章 后续\n正文继续。".utf8) + .write(to: documents.appendingPathComponent(name), options: .atomic) + } let controller = UIViewController() controller.view.backgroundColor = .systemBackground let label = UILabel() diff --git a/scripts/prepare-source-import-fixture.sh b/scripts/prepare-source-import-fixture.sh index 89b7290..ef3989d 100644 --- a/scripts/prepare-source-import-fixture.sh +++ b/scripts/prepare-source-import-fixture.sh @@ -42,4 +42,4 @@ xcrun --sdk iphonesimulator swiftc -parse-as-library -swift-version 6 \ -o "$legado_fixture_app/SourceImportFixtures" xcrun simctl install "$legado_fixture_device" "$legado_fixture_app" xcrun simctl launch --terminate-running-process "$legado_fixture_device" com.epiboly.legado.source-import-fixtures -printf 'JSON file import fixtures are ready in Files > On My iPhone > 书源导入测试.\n' +printf 'JSON and TXT import fixtures are ready in Files > On My iPhone > 书源导入测试.\n'