From ce88772895dd5e1c1619326fd1c974c280fefb13 Mon Sep 17 00:00:00 2001 From: Functionhx <172989722+Functionhx@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:57:41 +0800 Subject: [PATCH] Add native macOS text question import --- Documentation/Validation.md | 7 ++ README.md | 2 +- RELEASE-NOTES.md | 7 ++ WriteBench.xcodeproj/project.pbxproj | 28 +++++- WriteBench/Components/PlainTextEditor.swift | 2 +- .../Writing/TextQuestionImportView.swift | 98 +++++++++++++++++++ .../Features/Writing/WritingStore.swift | 20 ++++ WriteBench/Features/Writing/WritingView.swift | 13 ++- .../Services/Import/QuestionTextReader.swift | 55 +++++++++++ WriteBenchTests/QuestionImportTests.swift | 59 +++++++++++ WriteBenchTests/WriteBenchTests.swift | 7 ++ project.yml | 4 +- 12 files changed, 293 insertions(+), 9 deletions(-) create mode 100644 WriteBench/Features/Writing/TextQuestionImportView.swift create mode 100644 WriteBench/Services/Import/QuestionTextReader.swift create mode 100644 WriteBenchTests/QuestionImportTests.swift diff --git a/Documentation/Validation.md b/Documentation/Validation.md index a4850d2..1a954e7 100644 --- a/Documentation/Validation.md +++ b/Documentation/Validation.md @@ -1,3 +1,10 @@ +# 1.3.1 validation · 2026-09-13 + +- 33 Swift tests pass. New coverage includes Chinese/English UTF-8 (with/without BOM), UTF-16 LE/BE, paragraph normalization, rejected empty/binary/oversized input, a real text-file read, per-task SwiftData persistence, answer/image/time preservation, and detachment from a historical rewrite even when the new question text is identical. +- In a separate app copy with an in-memory store, inspected the actual native import sheet and verified paste → Command-Return → question refill, disabled empty submission, and Escape cancellation without changing the question. The user's active writing session was left untouched. +- Text file decoding and disk reads are tested; native file-picker automation could navigate and preview the fixture but did not complete confirmation. This interaction still needs a normal user check. The app uses AppKit's asynchronous NSOpenPanel with text/Markdown content types. +- macOS version 1.3.1 (6), Universal arm64 + x86_64. No persistence schema change; Windows and Android remain 0.1.0. + # 1.3.0 validation · 2026-09-13 ## macOS diff --git a/README.md b/README.md index 0e76d4c..c83818b 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ macOS 当前是本地 ad-hoc 签名版本,尚未经过 Apple Developer ID 公 1. 打开 **Settings**,填写自己的 DeepSeek API Key,点击 **使用此 Key**。默认仅在本次运行内存中保留;macOS 可勾选 **在这台 Mac 上记住 Key**。提交时只读已启用的内存 Key,记住与恢复在后台完成。 2. 使用 Codex 评审时,先安装[官方 Codex CLI](https://learn.chatgpt.com/docs/codex-cli),在终端运行 `codex login`。已登录的用户直接点击 **Check Connection**,无需再走浏览器。 -3. 选择考试与题型,输入题目,或导入题目图片。 +3. 选择考试与题型。macOS 点击题目卡片的 **导入文字**,粘贴完整题目,或选择 `.txt` / `.md` 文件(UTF-8 / UTF-16),编辑确认后自动保存;也可以导入题目图片。 4. 点击 **开始答题**,在沉浸式界面完成作文,然后 **交卷**。 5. 阅读三位评审的分数与修改建议,点击 **开始重写** 完成下一稿。 diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 96e57eb..4e2a17c 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,3 +1,10 @@ +# 1.3.1 · macOS 文字题目导入 + +- 题目卡片新增「导入文字」,支持直接粘贴、编辑和命名,再确认填入。 +- 支持从 `.txt` / `.md` 文件导入 UTF-8 / UTF-16 中英文题目,保留编号与段落。空内容、无法读取的编码和超过 1 MB 的文件会明确提示。 +- 确认后自动保存到当前题型草稿,已有作答、计时与手写稿保留;旧题目图片与历史重写关联解除。取消不会修改当前题目,导入不会触发评分。 +- 原有图片 OCR、我的题库与必选沉浸式作答继续可用。 + # 1.3.0 · 翻译练习与原生多端 - 新增考研英语一英译汉(10 分)、英语二英译汉(15 分)、六级汉译英(15 分),共用沉浸式作答、真实三评、历史与重写。 diff --git a/WriteBench.xcodeproj/project.pbxproj b/WriteBench.xcodeproj/project.pbxproj index b6b52d1..dfd6125 100644 --- a/WriteBench.xcodeproj/project.pbxproj +++ b/WriteBench.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 0108E61BCDE2D4568F4D5C8B /* ProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3584B2D03E15D42CF5A4B1 /* ProviderTests.swift */; }; 0B2FE86F272731005E6A17BF /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A3647AF5325DFD1CE43543B /* Theme.swift */; }; + 17236CF00A287A2737947138 /* QuestionTextReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5F2FF0974E091C6E391D13 /* QuestionTextReader.swift */; }; 21B1D6631C778F17583209A5 /* ielts_task2.md in Resources */ = {isa = PBXBuildFile; fileRef = AA7BA8561FCE7DD2841C904B /* ielts_task2.md */; }; 27C8DB78E1B529A54B818ABD /* Exam.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEB65E44D0601AEC6053865 /* Exam.swift */; }; 2B8AA947E573B4AD052F6F95 /* OCRService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F892E2F452C5999A8C08043 /* OCRService.swift */; }; @@ -32,6 +33,7 @@ 8938EFAD301830E8B39DF4FD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF0EC53CAA982107C3AA320 /* SettingsView.swift */; }; 938D3916C068089FACA81D61 /* MistakesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4391A8E13E5C7A35D0A1467A /* MistakesView.swift */; }; 96EB80336D8E7D70E3839817 /* kaoyan_english1_small.md in Resources */ = {isa = PBXBuildFile; fileRef = 6A7B637179FC6E271F31DBF4 /* kaoyan_english1_small.md */; }; + A104DD7F66FF512A80BA9717 /* QuestionImportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29453693001AD3ECDE2185B6 /* QuestionImportTests.swift */; }; C66E0F5339DADB4AE603BD5D /* GradingModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0CA900CDD0D63C0AA6C39F /* GradingModels.swift */; }; C7DF6658F1F355665EEC175A /* GradingProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFD296324E8F63CD0B525458 /* GradingProvider.swift */; }; CB760C24CAE63253B9400E19 /* WriteBenchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 679F1D25813DBB4821B089EF /* WriteBenchTests.swift */; }; @@ -39,6 +41,7 @@ D3F29CFA674B891722D5B739 /* cet6_writing.md in Resources */ = {isa = PBXBuildFile; fileRef = 650B4D62F506EDDC71D6396A /* cet6_writing.md */; }; D4A1396AF9FFF2FF6C40C24D /* kaoyan_english2_translation.md in Resources */ = {isa = PBXBuildFile; fileRef = 56792E2C1501DBCE7B80739B /* kaoyan_english2_translation.md */; }; DB6452447E988C02C326B0BE /* WriteBenchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2322E689ACEC8741C4B1C432 /* WriteBenchApp.swift */; }; + DB8D1A55F0E264D96E7E1D8D /* TextQuestionImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D56C8434F452DE88A5B9669 /* TextQuestionImportView.swift */; }; DCC333A3FAF19BC2ECC79BA0 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17802ADDF6589CB80F4B4F01 /* KeychainService.swift */; }; DEBA962949C4AADDFCB2C5D0 /* ielts_task1.md in Resources */ = {isa = PBXBuildFile; fileRef = 405A85F2A07E72B5AD277D96 /* ielts_task1.md */; }; DEF094BE47996A62C68AB985 /* CodexJudgeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27300883AE5AE3DD46C36924 /* CodexJudgeService.swift */; }; @@ -65,9 +68,11 @@ 100F5B726342F502CA8C4207 /* LocalProcessRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalProcessRunner.swift; sourceTree = ""; }; 161DE1C1158C7D408EAD0C20 /* WriteBenchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WriteBenchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 17802ADDF6589CB80F4B4F01 /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = ""; }; + 1D56C8434F452DE88A5B9669 /* TextQuestionImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextQuestionImportView.swift; sourceTree = ""; }; 2322E689ACEC8741C4B1C432 /* WriteBenchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteBenchApp.swift; sourceTree = ""; }; 26C9A0B8BBB075532D147B45 /* QuestionLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionLibraryView.swift; sourceTree = ""; }; 27300883AE5AE3DD46C36924 /* CodexJudgeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexJudgeService.swift; sourceTree = ""; }; + 29453693001AD3ECDE2185B6 /* QuestionImportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionImportTests.swift; sourceTree = ""; }; 296A6193086826B2B071503F /* MockGradingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGradingService.swift; sourceTree = ""; }; 37444757CFEC8F67705A2D6C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 3A3647AF5325DFD1CE43543B /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; @@ -101,6 +106,7 @@ D46587A10C9A9B126523FD04 /* DeepSeekCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepSeekCredentials.swift; sourceTree = ""; }; DA0CA900CDD0D63C0AA6C39F /* GradingModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradingModels.swift; sourceTree = ""; }; E94CDCC1049F7BD4C287AF84 /* WriteBench.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WriteBench.entitlements; sourceTree = ""; }; + EE5F2FF0974E091C6E391D13 /* QuestionTextReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionTextReader.swift; sourceTree = ""; }; F3788E5EB782131E544DDEDC /* WritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WritingView.swift; sourceTree = ""; }; F54C82E0AE1ACA592616CDDB /* OCRConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCRConfirmationView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -129,6 +135,7 @@ F54C82E0AE1ACA592616CDDB /* OCRConfirmationView.swift */, 876B3B648AA84D3EBCAD18D8 /* PracticeRail.swift */, 26C9A0B8BBB075532D147B45 /* QuestionLibraryView.swift */, + 1D56C8434F452DE88A5B9669 /* TextQuestionImportView.swift */, C00C1A68BA4227431C3B8884 /* WritingStore.swift */, F3788E5EB782131E544DDEDC /* WritingView.swift */, ); @@ -193,6 +200,7 @@ children = ( 296A6193086826B2B071503F /* MockGradingService.swift */, BB3584B2D03E15D42CF5A4B1 /* ProviderTests.swift */, + 29453693001AD3ECDE2185B6 /* QuestionImportTests.swift */, 679F1D25813DBB4821B089EF /* WriteBenchTests.swift */, ); path = WriteBenchTests; @@ -270,12 +278,21 @@ path = WriteBench; sourceTree = ""; }; + DAF48BE01DF036F8B771BCD9 /* Import */ = { + isa = PBXGroup; + children = ( + EE5F2FF0974E091C6E391D13 /* QuestionTextReader.swift */, + ); + path = Import; + sourceTree = ""; + }; DBA8F5BDA3B0730A44F6788A /* Services */ = { isa = PBXGroup; children = ( E2A118C9C3713319FBFF2693 /* Codex */, EDA45D75F74FEFF4ADDE5BDB /* DeepSeek */, 6C1FD4E39240395189A0EE57 /* Grading */, + DAF48BE01DF036F8B771BCD9 /* Import */, 0D89876DBC0D032FAD2FC813 /* OCR */, ); path = Services; @@ -442,9 +459,11 @@ 86CD1B5AEC144D4283491FC5 /* PlainTextEditor.swift in Sources */, ECFAF47EFB1BFE1187B43C7D /* PracticeRail.swift in Sources */, 3FF816DF3394076A7EAFCA8E /* QuestionLibraryView.swift in Sources */, + 17236CF00A287A2737947138 /* QuestionTextReader.swift in Sources */, 55C7907B2F577F01BC57A08B /* ReviewView.swift in Sources */, 8938EFAD301830E8B39DF4FD /* SettingsView.swift in Sources */, FBEB290B54B4DA0E050F77E1 /* StatisticsView.swift in Sources */, + DB8D1A55F0E264D96E7E1D8D /* TextQuestionImportView.swift in Sources */, 0B2FE86F272731005E6A17BF /* Theme.swift in Sources */, 7ADD51F051853BB04DC26A4D /* WindowImmersionBridge.swift in Sources */, 3197344EF97F73265170A114 /* WorkspaceView.swift in Sources */, @@ -460,6 +479,7 @@ files = ( 32367F4CF29D3A467EFA453D /* MockGradingService.swift in Sources */, 0108E61BCDE2D4568F4D5C8B /* ProviderTests.swift in Sources */, + A104DD7F66FF512A80BA9717 /* QuestionImportTests.swift in Sources */, CB760C24CAE63253B9400E19 /* WriteBenchTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -527,7 +547,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_HARDENED_RUNTIME = YES; ENABLE_NS_ASSERTIONS = NO; @@ -542,7 +562,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -623,7 +643,7 @@ CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_HARDENED_RUNTIME = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -644,7 +664,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/WriteBench/Components/PlainTextEditor.swift b/WriteBench/Components/PlainTextEditor.swift index 7d4e1a1..c125531 100644 --- a/WriteBench/Components/PlainTextEditor.swift +++ b/WriteBench/Components/PlainTextEditor.swift @@ -43,7 +43,7 @@ struct PlainTextEditor: NSViewRepresentable { view.typingAttributes = [.font: view.font ?? NSFont.systemFont(ofSize: fontSize), .foregroundColor: NSColor(WB.ink), .paragraphStyle: style] view.delegate = context.coordinator view.setAccessibilityIdentifier(identifier) - let labels = ["essayEditor": "我的作文", "questionEditor": "题目文字", "rewriteEditor": "重写作文", "ocrEssayEditor": "识别文字校对", "ocrQuestionEditor": "识别题目校对"] + let labels = ["essayEditor": "我的作文", "questionEditor": "题目文字", "textQuestionImportEditor": "导入题目文字", "rewriteEditor": "重写作文", "ocrEssayEditor": "识别文字校对", "ocrQuestionEditor": "识别题目校对"] view.setAccessibilityLabel(labels[identifier] ?? "Writing editor") scroll.documentView = view return scroll diff --git a/WriteBench/Features/Writing/TextQuestionImportView.swift b/WriteBench/Features/Writing/TextQuestionImportView.swift new file mode 100644 index 0000000..31cab07 --- /dev/null +++ b/WriteBench/Features/Writing/TextQuestionImportView.swift @@ -0,0 +1,98 @@ +import SwiftUI +import AppKit +import UniformTypeIdentifiers + +struct TextQuestionImportView: View { + @Environment(\.dismiss) private var dismiss + let task: WritingTask + let hasAnswer: Bool + var onImport: (String, String) -> Bool + @State private var title = "" + @State private var text = "" + @State private var error: String? + @State private var readingFile = false + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 8) { + Text("导入题目文字").font(.system(size: 23, weight: .semibold)) + Text(task.fullTitle).font(.system(size: 12)).foregroundStyle(WB.secondary) + } + Spacer() + IconButton(symbol: "xmark", help: "取消导入") { dismiss() } + } + + TextField("题目名称(选填)", text: $title) + .textFieldStyle(.plain).font(.system(size: 14)) + .padding(12).background(.white, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(WB.line)) + .accessibilityLabel("题目名称") + + ZStack(alignment: .topLeading) { + PlainTextEditor(text: $text, fontSize: 15, identifier: "textQuestionImportEditor", requestFocus: true) + if text.isEmpty { + Text("在这里粘贴或输入完整题目…\n请包含作答要求、原文或必要的图表数据。") + .font(.system(size: 14)).foregroundStyle(WB.secondary.opacity(0.8)) + .lineSpacing(7).padding(.horizontal, 13).padding(.vertical, 15) + .allowsHitTesting(false).accessibilityHidden(true) + } + }.frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.white, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(WB.line)) + + HStack(spacing: 12) { + Button { importFile() } label: { Label("从文本文件导入", systemImage: "doc.text") } + .buttonStyle(QuietButtonStyle()).disabled(readingFile) + if readingFile { ProgressView().controlSize(.small) } + else { Text(".txt / .md · UTF-8 / UTF-16").font(.system(size: 11)).foregroundStyle(WB.secondary) } + } + + if let error { + Label(error, systemImage: "exclamationmark.circle") + .font(.system(size: 12)).foregroundStyle(WB.amber).fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 12) { + Text(hasAnswer ? "确认后替换当前题目,已有作答保留。" : "确认后填入当前题目并自动保存。") + .font(.system(size: 11)).foregroundStyle(WB.secondary) + Spacer(minLength: 12) + Button("取消") { dismiss() }.buttonStyle(QuietButtonStyle()).keyboardShortcut(.cancelAction) + Button("确认导入") { confirm() }.buttonStyle(PrimaryButtonStyle()) + .keyboardShortcut(.return, modifiers: .command) + .disabled(readingFile || text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityIdentifier("confirmTextQuestionImport") + } + }.padding(28).frame(width: 660, height: 590).background(WB.canvas).foregroundStyle(WB.ink) + .onChange(of: text) { _, _ in error = nil } + } + + private func importFile() { + readingFile = true + Task { + defer { readingFile = false } + let panel = NSOpenPanel() + panel.title = "导入题目文字" + panel.prompt = "导入" + panel.allowedContentTypes = [.plainText, UTType(filenameExtension: "md") ?? .plainText] + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + guard await panel.begin() == .OK, let url = panel.url else { return } + do { + let imported = try await QuestionTextReader().read(url) + text = imported + if title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { title = url.deletingPathExtension().lastPathComponent } + error = nil + } catch { self.error = error.localizedDescription } + } + } + + private func confirm() { + do { + let prompt = try QuestionTextReader.validated(text) + if onImport(prompt, title) { dismiss() } + else { error = "导入未保存,请稍后重试。" } + } catch { self.error = error.localizedDescription } + } +} diff --git a/WriteBench/Features/Writing/WritingStore.swift b/WriteBench/Features/Writing/WritingStore.swift index 1ea21f5..99103bf 100644 --- a/WriteBench/Features/Writing/WritingStore.swift +++ b/WriteBench/Features/Writing/WritingStore.swift @@ -60,6 +60,26 @@ enum WritingStage { case preparation, answering, grading } func textChanged() { queueSave() } + @discardableResult func importQuestion(text: String, title: String) -> Bool { + guard stage == .preparation else { return false } + do { + let prompt = try QuestionTextReader.validated(text) + guard persistDraft() else { return false } + let previous = (question, questionLabel, questionImage, rewriteSessionID) + saveTask?.cancel() + question = prompt + let name = title.trimmingCharacters(in: .whitespacesAndNewlines) + questionLabel = name.isEmpty ? "文字导入" : name + questionImage = nil + rewriteSessionID = nil + guard persistDraft() else { + context?.rollback() + (question, questionLabel, questionImage, rewriteSessionID) = previous + return false + } + return true + } catch { self.error = error.localizedDescription; return false } + } func queueSave() { saveTask?.cancel(); saveStatus = "正在保存…" saveTask = Task { [weak self] in diff --git a/WriteBench/Features/Writing/WritingView.swift b/WriteBench/Features/Writing/WritingView.swift index 2913de7..7704b92 100644 --- a/WriteBench/Features/Writing/WritingView.swift +++ b/WriteBench/Features/Writing/WritingView.swift @@ -8,6 +8,7 @@ struct WritingView: View { @State private var isRecognizing = false @State private var ocrTask: Task? @State private var editingQuestion = false + @State private var importingText = false private let clock = Timer.publish(every: 1, on: .main, in: .common).autoconnect() var body: some View { Group { @@ -29,6 +30,13 @@ struct WritingView: View { } } .sheet(isPresented: $store.showLibrary) { QuestionLibraryView(store: store) } + .sheet(isPresented: $importingText) { + TextQuestionImportView(task: store.task, hasAnswer: !store.essay.isEmpty) { text, title in + let saved = store.importQuestion(text: text, title: title) + if saved { editingQuestion = false } + return saved + } + } .onDisappear { ocrTask?.cancel(); store.persistDraft() } .onChange(of: store.question) { _, _ in store.queueSave() } .onChange(of: store.essay) { _, _ in store.textChanged() } @@ -79,7 +87,7 @@ struct WritingView: View { HStack { Text("题目").font(.system(size: 15, weight: .semibold)) Spacer() - Text(store.questionLabel).font(.system(size: 12)).foregroundStyle(WB.secondary) + Text(store.questionLabel).font(.system(size: 12)).foregroundStyle(WB.secondary).lineLimit(1).help(store.questionLabel) IconButton(symbol: editingQuestion ? "checkmark" : "pencil", help: editingQuestion ? "完成编辑" : "编辑题目") { editingQuestion.toggle() } } if editingQuestion { @@ -93,6 +101,9 @@ struct WritingView: View { HStack { Text(store.task.fullTitle).font(.system(size: 11)).foregroundStyle(WB.secondary) Spacer() + Button { importingText = true } label: { Label("导入文字", systemImage: "doc.text") } + .buttonStyle(QuietButtonStyle()).disabled(isRecognizing) + .accessibilityIdentifier("importQuestionText") Button { importImages(.question) } label: { Label("导入题目图片", systemImage: "photo") }.buttonStyle(QuietButtonStyle()).disabled(isRecognizing) } }.padding(28).background(.white, in: RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(WB.line.opacity(0.8))) diff --git a/WriteBench/Services/Import/QuestionTextReader.swift b/WriteBench/Services/Import/QuestionTextReader.swift new file mode 100644 index 0000000..f0f3039 --- /dev/null +++ b/WriteBench/Services/Import/QuestionTextReader.swift @@ -0,0 +1,55 @@ +import Foundation + +enum QuestionTextImportError: LocalizedError { + case empty, tooLarge, unsupportedEncoding, invalidText + + var errorDescription: String? { + switch self { + case .empty: "请先粘贴或输入题目文字。" + case .tooLarge: "题目文件过大,请选择不超过 1 MB 的文本文件。" + case .unsupportedEncoding: "无法读取文件编码,请将文件另存为 UTF-8 或 UTF-16 后导入。" + case .invalidText: "文件包含非文本内容,请选择 .txt 或 .md 文本文件。" + } + } +} + +/// Reads only user-selected files, off the main actor. Never sends imported text to a provider. +actor QuestionTextReader { + static let maximumBytes = 1_048_576 + + func read(_ url: URL) throws -> String { + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + let file = try FileHandle(forReadingFrom: url) + defer { try? file.close() } + let data = try file.read(upToCount: Self.maximumBytes + 1) ?? Data() + return try Self.decode(data) + } + + static func decode(_ data: Data) throws -> String { + guard data.count <= maximumBytes else { throw QuestionTextImportError.tooLarge } + let text: String? + if data.starts(with: [0xFF, 0xFE]) { + text = String(data: data.dropFirst(2), encoding: .utf16LittleEndian) + } else if data.starts(with: [0xFE, 0xFF]) { + text = String(data: data.dropFirst(2), encoding: .utf16BigEndian) + } else { + text = String(data: data, encoding: .utf8) + } + guard let text else { throw QuestionTextImportError.unsupportedEncoding } + return try validated(text) + } + + static func validated(_ text: String) throws -> String { + guard text.utf8.count <= maximumBytes else { throw QuestionTextImportError.tooLarge } + let withoutBOM = text.hasPrefix("\u{FEFF}") ? String(text.dropFirst()) : text + let normalized = withoutBOM.replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { throw QuestionTextImportError.empty } + guard !normalized.unicodeScalars.contains(where: { $0.value < 32 && $0.value != 9 && $0.value != 10 }) else { + throw QuestionTextImportError.invalidText + } + return normalized + } +} diff --git a/WriteBenchTests/QuestionImportTests.swift b/WriteBenchTests/QuestionImportTests.swift new file mode 100644 index 0000000..9b52b0b --- /dev/null +++ b/WriteBenchTests/QuestionImportTests.swift @@ -0,0 +1,59 @@ +import Foundation +import SwiftData +import Testing +@testable import WriteBench + +@Test func questionImportPreservesChineseAndParagraphsAcrossEncodings() throws { + let input = "请将以下段落译成汉语。\r\n\r\nLearning takes time.\r1. Keep practising." + let expected = "请将以下段落译成汉语。\n\nLearning takes time.\n1. Keep practising." + for (encoding, bom): (String.Encoding, [UInt8]) in [(.utf8, []), (.utf8, [0xEF, 0xBB, 0xBF]), (.utf16LittleEndian, [0xFF, 0xFE]), (.utf16BigEndian, [0xFE, 0xFF])] { + let data = Data(bom) + (try #require(input.data(using: encoding))) + #expect(try QuestionTextReader.decode(data) == expected) + } +} + +@Test func questionImportRejectsEmptyBinaryAndOversizedFiles() { + for data in [Data(), Data(" \n\t".utf8), Data([0xFF, 0x80]), Data("Question\0binary".utf8), Data(repeating: 65, count: QuestionTextReader.maximumBytes + 1)] { + #expect(throws: (any Error).self) { try QuestionTextReader.decode(data) } + } +} + +@Test func questionImportReadsARealTextFile() async throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("writebench-question-\(UUID()).txt") + let question = "把这段中文译成英语。\n\n阅读使人不断成长。" + try Data(question.utf8).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + #expect(try await QuestionTextReader().read(url) == question) +} + +@Test @MainActor func importingQuestionPersistsWithoutLosingTheAnswerOrOtherTaskDrafts() throws { + let container = try ModelContainer(for: EssaySession.self, WritingDraft.self, SavedQuestion.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true)) + let store = WritingStore() + store.attach(ModelContext(container)) + store.select(.cet6Translation) + store.essay = "An existing translation." + store.elapsed = 420 + store.inputMode = .handwritten + store.sourceImages = [Data([4, 5, 6])] + store.questionImage = Data([1, 2, 3]) + let previousQuestion = store.question + #expect(!store.importQuestion(text: "\n ", title: "")) + #expect(store.question == previousQuestion) + #expect(store.questionImage != nil) + #expect(store.importQuestion(text: " 请将这段中文译成英语。\n\n阅读使人不断成长。 ", title: " 我的六级题目 ")) + #expect(store.stage == .preparation && !store.timerRunning) + let restored = WritingStore() + restored.attach(ModelContext(container)) + #expect(restored.question == WritingTask.kaoyanSmall.sampleQuestion) + restored.select(.cet6Translation) + #expect(restored.question == "请将这段中文译成英语。\n\n阅读使人不断成长。") + #expect(restored.questionLabel == "我的六级题目") + #expect(restored.questionImage == nil) + #expect(restored.essay == "An existing translation.") + #expect(restored.elapsed == 420) + #expect(restored.inputMode == .handwritten) + #expect(restored.sourceImages == [Data([4, 5, 6])]) + #expect(restored.startAnswering()) + #expect(!restored.importQuestion(text: "Cannot replace an active exam.", title: "")) + #expect(restored.questionLabel == "我的六级题目") +} diff --git a/WriteBenchTests/WriteBenchTests.swift b/WriteBenchTests/WriteBenchTests.swift index 09d353f..63d5006 100644 --- a/WriteBenchTests/WriteBenchTests.swift +++ b/WriteBenchTests/WriteBenchTests.swift @@ -245,6 +245,13 @@ private struct UnavailableGrader: EssayGradingService { restored.persistDraft() let unaffected = try #require(ModelContext(container).fetch(FetchDescriptor()).first) #expect(unaffected.finalRewrite == "The resumed rewrite.") + restored.beginRewrite(unaffected) + #expect(restored.leaveAnswering()) + #expect(restored.importQuestion(text: unaffected.question, title: "A fresh attempt at the same question")) + restored.essay = "A separate practice attempt." + #expect(restored.persistDraft()) + let detached = try #require(ModelContext(container).fetch(FetchDescriptor()).first) + #expect(detached.finalRewrite == "The resumed rewrite.") } @Test @MainActor func missingAPIKeyBlocksTypedAndHandwrittenGradingWithoutAResult() throws { diff --git a/project.yml b/project.yml index 9b09e11..fa4e79b 100644 --- a/project.yml +++ b/project.yml @@ -11,8 +11,8 @@ settings: CODE_SIGN_IDENTITY: "-" ENABLE_HARDENED_RUNTIME: YES GENERATE_INFOPLIST_FILE: YES - MARKETING_VERSION: "1.3.0" - CURRENT_PROJECT_VERSION: "5" + MARKETING_VERSION: "1.3.1" + CURRENT_PROJECT_VERSION: "6" targets: WriteBench: type: application