Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Documentation/Validation.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 阅读三位评审的分数与修改建议,点击 **开始重写** 完成下一稿。

Expand Down
7 changes: 7 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# 1.3.1 · macOS 文字题目导入

- 题目卡片新增「导入文字」,支持直接粘贴、编辑和命名,再确认填入。
- 支持从 `.txt` / `.md` 文件导入 UTF-8 / UTF-16 中英文题目,保留编号与段落。空内容、无法读取的编码和超过 1 MB 的文件会明确提示。
- 确认后自动保存到当前题型草稿,已有作答、计时与手写稿保留;旧题目图片与历史重写关联解除。取消不会修改当前题目,导入不会触发评分。
- 原有图片 OCR、我的题库与必选沉浸式作答继续可用。

# 1.3.0 · 翻译练习与原生多端

- 新增考研英语一英译汉(10 分)、英语二英译汉(15 分)、六级汉译英(15 分),共用沉浸式作答、真实三评、历史与重写。
Expand Down
28 changes: 24 additions & 4 deletions WriteBench.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -32,13 +33,15 @@
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 */; };
CCB636BAB11DE1B7DE6C1CA2 /* ImmersiveWritingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B600C2CBBB1B477ABB65C42 /* ImmersiveWritingView.swift */; };
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 */; };
Expand All @@ -65,9 +68,11 @@
100F5B726342F502CA8C4207 /* LocalProcessRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalProcessRunner.swift; sourceTree = "<group>"; };
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 = "<group>"; };
1D56C8434F452DE88A5B9669 /* TextQuestionImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextQuestionImportView.swift; sourceTree = "<group>"; };
2322E689ACEC8741C4B1C432 /* WriteBenchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WriteBenchApp.swift; sourceTree = "<group>"; };
26C9A0B8BBB075532D147B45 /* QuestionLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionLibraryView.swift; sourceTree = "<group>"; };
27300883AE5AE3DD46C36924 /* CodexJudgeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexJudgeService.swift; sourceTree = "<group>"; };
29453693001AD3ECDE2185B6 /* QuestionImportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionImportTests.swift; sourceTree = "<group>"; };
296A6193086826B2B071503F /* MockGradingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGradingService.swift; sourceTree = "<group>"; };
37444757CFEC8F67705A2D6C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
3A3647AF5325DFD1CE43543B /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -101,6 +106,7 @@
D46587A10C9A9B126523FD04 /* DeepSeekCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepSeekCredentials.swift; sourceTree = "<group>"; };
DA0CA900CDD0D63C0AA6C39F /* GradingModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradingModels.swift; sourceTree = "<group>"; };
E94CDCC1049F7BD4C287AF84 /* WriteBench.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WriteBench.entitlements; sourceTree = "<group>"; };
EE5F2FF0974E091C6E391D13 /* QuestionTextReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuestionTextReader.swift; sourceTree = "<group>"; };
F3788E5EB782131E544DDEDC /* WritingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WritingView.swift; sourceTree = "<group>"; };
F54C82E0AE1ACA592616CDDB /* OCRConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCRConfirmationView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
Expand Down Expand Up @@ -129,6 +135,7 @@
F54C82E0AE1ACA592616CDDB /* OCRConfirmationView.swift */,
876B3B648AA84D3EBCAD18D8 /* PracticeRail.swift */,
26C9A0B8BBB075532D147B45 /* QuestionLibraryView.swift */,
1D56C8434F452DE88A5B9669 /* TextQuestionImportView.swift */,
C00C1A68BA4227431C3B8884 /* WritingStore.swift */,
F3788E5EB782131E544DDEDC /* WritingView.swift */,
);
Expand Down Expand Up @@ -193,6 +200,7 @@
children = (
296A6193086826B2B071503F /* MockGradingService.swift */,
BB3584B2D03E15D42CF5A4B1 /* ProviderTests.swift */,
29453693001AD3ECDE2185B6 /* QuestionImportTests.swift */,
679F1D25813DBB4821B089EF /* WriteBenchTests.swift */,
);
path = WriteBenchTests;
Expand Down Expand Up @@ -270,12 +278,21 @@
path = WriteBench;
sourceTree = "<group>";
};
DAF48BE01DF036F8B771BCD9 /* Import */ = {
isa = PBXGroup;
children = (
EE5F2FF0974E091C6E391D13 /* QuestionTextReader.swift */,
);
path = Import;
sourceTree = "<group>";
};
DBA8F5BDA3B0730A44F6788A /* Services */ = {
isa = PBXGroup;
children = (
E2A118C9C3713319FBFF2693 /* Codex */,
EDA45D75F74FEFF4ADDE5BDB /* DeepSeek */,
6C1FD4E39240395189A0EE57 /* Grading */,
DAF48BE01DF036F8B771BCD9 /* Import */,
0D89876DBC0D032FAD2FC813 /* OCR */,
);
path = Services;
Expand Down Expand Up @@ -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 */,
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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)";
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion WriteBench/Components/PlainTextEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions WriteBench/Features/Writing/TextQuestionImportView.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
20 changes: 20 additions & 0 deletions WriteBench/Features/Writing/WritingStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading