diff --git a/apps/mh3g-save-converter-macos/README.md b/apps/mh3g-save-converter-macos/README.md index 0ee39f4..c719134 100644 --- a/apps/mh3g-save-converter-macos/README.md +++ b/apps/mh3g-save-converter-macos/README.md @@ -28,6 +28,9 @@ The core picker accepts an exact `user1`, `user2`, or `user3` file or its direct parent. It does not recursively scan an SD card or MLC and does not open ZIP, 7z, or RAR archives. A directory resolves only to the directly contained same-named selected slot. +The UI keeps the file-or-folder choice visible and provides an expandable +`user2` directory-tree example, so the expected level is clear before opening +the system picker. Every write is bound to the immediately preceding Dry Run's SHA-256 values. Normal conversion uses a single-file manifest; compatibility repair uses diff --git a/apps/mh3g-save-converter-macos/README.zh-CN.md b/apps/mh3g-save-converter-macos/README.zh-CN.md index 4f6be58..5e728d5 100644 --- a/apps/mh3g-save-converter-macos/README.zh-CN.md +++ b/apps/mh3g-save-converter-macos/README.zh-CN.md @@ -21,6 +21,8 @@ sidecar 并展示 JSON 报告,不在 Swift 中重复实现存档转换。 核心选择器接受准确 `user1`、`user2`、`user3` 文件或其直接父目录。它不会递归 扫描 SD 卡/MLC,不接受 ZIP、7z、RAR。目录只解析成当前选中槽位的同名直接子文件。 +界面会持续显示“文件或目录”选择类型,并提供可展开的 `user2` 目录树示例;无需 +先打开系统文件选择器才能知道应当选择哪一层。 每次写入都绑定紧邻 Dry Run 的 SHA-256。普通转换使用单文件 manifest;兼容修复 使用 `.mh3g-compatibility-repair-.json` 并通过 `rollback-repair` 总体回滚。 diff --git a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift index e375f63..0333d64 100644 --- a/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift +++ b/apps/mh3g-save-converter-macos/Sources/ConverterPresentation/Localization.swift @@ -133,6 +133,15 @@ public enum ConverterCopy { "Input.FinalOutput": "最终输出", "Input.NewOutput": "新输出文件;写入时必须保持不存在", "Input.Select": "选择…", + "Input.SelectFile": "选择文件…", + "Input.SelectDirectory": "选择目录…", + "Input.SelectFileOrDirectory": "选择文件或目录…", + "Input.SourceSelectionHint": "允许选择:单个 user# 文件,或直接包含所选 user# 的父目录。", + "Input.CurrentSelectionHint": "允许选择:单个同槽位 Wii U/Cemu user# 文件,或它的直接父目录;必须已存在。", + "Input.OutputSelectionHint": "允许选择:同名 user# 文件,或一个已有输出目录;选择目录时会解析为 <目录>/<所选 user#>。", + "Input.PathGuideTitle": "支持单文件或直接父目录(查看结构示例)", + "Input.PathGuideSummary": "只解析所选目录的直接同名 user#;不会递归扫描 SD 卡或 MLC,也不会读取 ZIP、7z、RAR。", + "Input.PathGuideExample": "示例(选择 user2)\n\n3DS 存档直接父目录/\n├── system (本控件不读取)\n└── user2 ← 3DS 源\n\n当前 Cemu 存档目录/\n├── user2 ← 当前引用(仅修复,只读)\n├── card1、card2、card3、cardbox (修复名片时读取)\n└── quest1 … quest4 (修复名片时校验并保留)\n\n输出目录/\n├── user2 ← 转换结果(仅处理核心角色时可暂不存在)\n└── card1、card2、card3、cardbox (修复名片并独立输出时必须已存在)", "Input.Inspect": "检查所选内容", "Input.NoSelection": "尚未选择文件", "Components.System": "共享 system", @@ -346,6 +355,15 @@ public enum ConverterCopy { "Input.FinalOutput": "Final output", "Input.NewOutput": "New output file; it must remain absent before writing", "Input.Select": "Choose…", + "Input.SelectFile": "Choose File…", + "Input.SelectDirectory": "Choose Folder…", + "Input.SelectFileOrDirectory": "Choose File or Folder…", + "Input.SourceSelectionHint": "Allowed: one user# file, or the direct parent folder containing the selected user#.", + "Input.CurrentSelectionHint": "Allowed: one existing same-slot Wii U/Cemu user# file, or its direct parent folder.", + "Input.OutputSelectionHint": "Allowed: a same-name user# file, or an existing output folder; a folder resolves to /.", + "Input.PathGuideTitle": "File or direct parent folder supported (show example)", + "Input.PathGuideSummary": "Only the direct same-name user# is resolved. The app never recursively scans an SD card or MLC and never reads ZIP, 7z, or RAR archives.", + "Input.PathGuideExample": "Example (user2 selected)\n\n3DS save parent/\n├── system (not read by this control)\n└── user2 ← 3DS source\n\nCurrent Cemu save folder/\n├── user2 ← current reference (repair only, read-only)\n├── card1, card2, card3, cardbox (read for guild-card repair)\n└── quest1 … quest4 (validated and preserved for repair)\n\nOutput folder/\n├── user2 ← result (may be absent for a core-only operation)\n└── card1, card2, card3, cardbox (must exist for separate guild-card output)", "Input.Inspect": "Inspect selection", "Input.NoSelection": "No file selected", "Components.System": "Shared system", diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ComponentSelectionView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ComponentSelectionView.swift index d9dee84..b3d02e6 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ComponentSelectionView.swift +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ComponentSelectionView.swift @@ -21,14 +21,14 @@ struct ComponentSelectionView: View { SelectedPathRow( title: ConverterCopy.text("Components.SystemSource", language: language), value: workflow.components.systemSource, - chooseTitle: ConverterCopy.text("Input.Select", language: language) + chooseTitle: ConverterCopy.text("Input.SelectFile", language: language) ) { chooseSystemSource() } SelectedPathRow( title: ConverterCopy.text("Components.SystemTarget", language: language), value: workflow.components.systemTarget, - chooseTitle: ConverterCopy.text("Input.Select", language: language) + chooseTitle: ConverterCopy.text("Input.SelectFile", language: language) ) { chooseSystemTarget() } @@ -77,7 +77,7 @@ struct ComponentSelectionView: View { SelectedPathRow( title: ConverterCopy.text("Components.ExtDataSource", language: language), value: workflow.components.extraSourceDirectory, - chooseTitle: ConverterCopy.text("Input.Select", language: language) + chooseTitle: ConverterCopy.text("Input.SelectDirectory", language: language) ) { chooseExtraSource() } @@ -85,14 +85,14 @@ struct ComponentSelectionView: View { SelectedPathRow( title: ConverterCopy.text("Components.Staging", language: language), value: workflow.components.extraStagingDirectory, - chooseTitle: ConverterCopy.text("Input.Select", language: language) + chooseTitle: ConverterCopy.text("Input.SelectDirectory", language: language) ) { chooseExtraStaging() } SelectedPathRow( title: ConverterCopy.text("Components.Target", language: language), value: workflow.components.extraTargetDirectory, - chooseTitle: ConverterCopy.text("Input.Select", language: language) + chooseTitle: ConverterCopy.text("Input.SelectDirectory", language: language) ) { chooseExtraTarget() } diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ExperimentalCECView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ExperimentalCECView.swift index cdc96e2..39ec62d 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ExperimentalCECView.swift +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/ExperimentalCECView.swift @@ -22,14 +22,14 @@ struct ExperimentalCECView: View { SelectedPathRow( title: ConverterCopy.text("CEC.Source", language: language), value: workflow.components.cecSourceDirectory, - chooseTitle: ConverterCopy.text("Input.Select", language: language) + chooseTitle: ConverterCopy.text("Input.SelectDirectory", language: language) ) { chooseSource() } SelectedPathRow( title: ConverterCopy.text("CEC.Target", language: language), value: workflow.components.cecTarget, - chooseTitle: ConverterCopy.text("Input.Select", language: language) + chooseTitle: ConverterCopy.text("Input.SelectFile", language: language) ) { chooseTarget() } @@ -73,7 +73,7 @@ struct ExperimentalCECView: View { SelectedPathRow( title: ConverterCopy.text("CEC.Manifest", language: language), value: manifest, - chooseTitle: ConverterCopy.text("Input.Select", language: language) + chooseTitle: ConverterCopy.text("Input.SelectFile", language: language) ) { manifest = OpenPanel.selectFile( title: ConverterCopy.text("CEC.Manifest", language: language), diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/InputInspectionView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/InputInspectionView.swift index de1974d..ce2350e 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/InputInspectionView.swift +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/InputInspectionView.swift @@ -53,10 +53,12 @@ struct InputInspectionView: View { .onChange(of: slot) { _, _ in resolveSelections() } + CorePathSelectionGuide(language: language) SelectedPathRow( title: ConverterCopy.text("Input.Source", language: language), value: source, - chooseTitle: ConverterCopy.text("Input.Select", language: language) + chooseTitle: ConverterCopy.text("Input.SelectFileOrDirectory", language: language), + selectionHint: ConverterCopy.text("Input.SourceSelectionHint", language: language) ) { chooseSource() } @@ -64,7 +66,8 @@ struct InputInspectionView: View { SelectedPathRow( title: ConverterCopy.text("Input.Current", language: language), value: current ?? workflow.input?.current, - chooseTitle: ConverterCopy.text("Input.Select", language: language) + chooseTitle: ConverterCopy.text("Input.SelectFileOrDirectory", language: language), + selectionHint: ConverterCopy.text("Input.CurrentSelectionHint", language: language) ) { chooseCurrent() } @@ -81,7 +84,8 @@ struct InputInspectionView: View { language: language ), value: target ?? workflow.input?.target, - chooseTitle: ConverterCopy.text("Input.Select", language: language) + chooseTitle: ConverterCopy.text("Input.SelectFileOrDirectory", language: language), + selectionHint: ConverterCopy.text("Input.OutputSelectionHint", language: language) ) { chooseTarget() } @@ -328,36 +332,59 @@ struct SelectedPathRow: View { let title: String let value: URL? let chooseTitle: String + let selectionHint: String? let choose: () -> Void + init( + title: String, + value: URL?, + chooseTitle: String, + selectionHint: String? = nil, + choose: @escaping () -> Void + ) { + self.title = title + self.value = value + self.chooseTitle = chooseTitle + self.selectionHint = selectionHint + self.choose = choose + } + var body: some View { - LabeledContent(title) { - HStack(alignment: .center, spacing: 8) { - if let value { - Image(systemName: icon(for: value)) - .foregroundStyle(.secondary) - VStack(alignment: .trailing, spacing: 2) { - Text(value.lastPathComponent) - .lineLimit(1) - Text(value.deletingLastPathComponent().path) - .font(.caption.monospaced()) + VStack(alignment: .leading, spacing: 6) { + LabeledContent(title) { + HStack(alignment: .center, spacing: 8) { + if let value { + Image(systemName: icon(for: value)) .foregroundStyle(.secondary) - .lineLimit(1) - .textSelection(.enabled) - } - .frame(maxWidth: .infinity, alignment: .trailing) - .help(value.path) - } else { - Text("—") - .foregroundStyle(.secondary) + VStack(alignment: .trailing, spacing: 2) { + Text(value.lastPathComponent) + .lineLimit(1) + Text(value.deletingLastPathComponent().path) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .textSelection(.enabled) + } .frame(maxWidth: .infinity, alignment: .trailing) + .help(value.path) + } else { + Text("—") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .trailing) + } + Button(action: choose) { + Label(chooseTitle, systemImage: "folder.badge.plus") + } + .buttonStyle(.borderless) + .help(chooseTitle) + .accessibilityLabel(chooseTitle) } - Button(action: choose) { - Image(systemName: "folder.badge.plus") - } - .buttonStyle(.borderless) - .help(chooseTitle) - .accessibilityLabel(chooseTitle) + } + if let selectionHint { + Label(selectionHint, systemImage: "info.circle") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } } } @@ -369,6 +396,31 @@ struct SelectedPathRow: View { } } +private struct CorePathSelectionGuide: View { + let language: ConverterLanguage + + var body: some View { + DisclosureGroup { + VStack(alignment: .leading, spacing: 10) { + Text(ConverterCopy.text("Input.PathGuideSummary", language: language)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Text(ConverterCopy.text("Input.PathGuideExample", language: language)) + .font(.caption.monospaced()) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 6) + } label: { + Label( + ConverterCopy.text("Input.PathGuideTitle", language: language), + systemImage: "folder.badge.questionmark" + ) + } + } +} + struct InspectionTable: View { let source: InputInspection let current: InputInspection? diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings index b7d35a3..0fa3ba7 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/Resources/Localizable.xcstrings @@ -10,8 +10,17 @@ "Navigation.WriteRollback" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Write & Rollback" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "写入与回滚" } } } }, "Input.Current" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Current Wii U / Cemu reference save" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "当前 Wii U / Cemu 引用存档" } } } }, "Input.CurrentReadOnly" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "This save supplies continued-play data only. Repair never writes to it." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "该存档只用于读取继续游玩后的数据,修复流程不会写入它。" } } } }, + "Input.CurrentSelectionHint" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Allowed: one existing same-slot Wii U/Cemu user# file, or its direct parent folder." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "允许选择:单个同槽位 Wii U/Cemu user# 文件,或它的直接父目录;必须已存在。" } } } }, + "Input.OutputSelectionHint" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Allowed: a same-name user# file, or an existing output folder; a folder resolves to /." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "允许选择:同名 user# 文件,或一个已有输出目录;选择目录时会解析为 <目录>/<所选 user#>。" } } } }, + "Input.PathGuideExample" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Example (user2 selected)\n\n3DS save parent/\n├── system (not read by this control)\n└── user2 ← 3DS source\n\nCurrent Cemu save folder/\n├── user2 ← current reference (repair only, read-only)\n├── card1, card2, card3, cardbox (read for guild-card repair)\n└── quest1 … quest4 (validated and preserved for repair)\n\nOutput folder/\n├── user2 ← result (may be absent for a core-only operation)\n└── card1, card2, card3, cardbox (must exist for separate guild-card output)" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "示例(选择 user2)\n\n3DS 存档直接父目录/\n├── system (本控件不读取)\n└── user2 ← 3DS 源\n\n当前 Cemu 存档目录/\n├── user2 ← 当前引用(仅修复,只读)\n├── card1、card2、card3、cardbox (修复名片时读取)\n└── quest1 … quest4 (修复名片时校验并保留)\n\n输出目录/\n├── user2 ← 转换结果(仅处理核心角色时可暂不存在)\n└── card1、card2、card3、cardbox (修复名片并独立输出时必须已存在)" } } } }, + "Input.PathGuideSummary" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Only the direct same-name user# is resolved. The app never recursively scans an SD card or MLC and never reads ZIP, 7z, or RAR archives." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "只解析所选目录的直接同名 user#;不会递归扫描 SD 卡或 MLC,也不会读取 ZIP、7z、RAR。" } } } }, + "Input.PathGuideTitle" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "File or direct parent folder supported (show example)" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "支持单文件或直接父目录(查看结构示例)" } } } }, "Input.RepairOutput" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Repaired Wii U / Cemu output" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "修复后的 Wii U / Cemu 输出位置" } } } }, "Input.Required" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Required" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "必选" } } } }, + "Input.SelectDirectory" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Choose Folder…" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "选择目录…" } } } }, + "Input.SelectFile" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Choose File…" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "选择文件…" } } } }, + "Input.SelectFileOrDirectory" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Choose File or Folder…" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "选择文件或目录…" } } } }, + "Input.SourceSelectionHint" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Allowed: one user# file, or the direct parent folder containing the selected user#." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "允许选择:单个 user# 文件,或直接包含所选 user# 的父目录。" } } } }, "Repair.Version" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Original converter version" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "原转换器版本" } } } }, "Repair.Version.Auto" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Detect automatically" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "自动检测" } } } }, "Repair.Version.Hint" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", "value" : "Use automatic detection if the version is unknown. If several historical versions match, Dry Run will require an explicit selection before it can authorize a write." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "不知道版本时先自动检测;若多个历史版本都匹配,Dry Run 会要求你明确选择后再运行。" } } } }, diff --git a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/WriteRollbackView.swift b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/WriteRollbackView.swift index bd2378a..ad9fa18 100644 --- a/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/WriteRollbackView.swift +++ b/apps/mh3g-save-converter-macos/Sources/MH3GSaveConverterMac/WriteRollbackView.swift @@ -121,7 +121,7 @@ struct WriteRollbackView: View { SelectedPathRow( title: ConverterCopy.text("Write.Manifest", language: language), value: coreManifest, - chooseTitle: ConverterCopy.text("Input.Select", language: language) + chooseTitle: ConverterCopy.text("Input.SelectFile", language: language) ) { coreManifest = selectManifest( title: ConverterCopy.text("Write.SelectManifest", language: language) @@ -155,7 +155,7 @@ struct WriteRollbackView: View { SelectedPathRow( title: ConverterCopy.text("Write.Manifest", language: language), value: systemManifest, - chooseTitle: ConverterCopy.text("Input.Select", language: language) + chooseTitle: ConverterCopy.text("Input.SelectFile", language: language) ) { systemManifest = selectManifest( title: ConverterCopy.text("Write.SelectManifest", language: language) @@ -205,7 +205,7 @@ struct WriteRollbackView: View { SelectedPathRow( title: ConverterCopy.text("Write.ExtrasManifest", language: language), value: extrasManifest, - chooseTitle: ConverterCopy.text("Input.Select", language: language) + chooseTitle: ConverterCopy.text("Input.SelectFile", language: language) ) { extrasManifest = selectManifest( title: ConverterCopy.text("Write.ExtrasManifest", language: language) diff --git a/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/LocalizationTests.swift b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/LocalizationTests.swift index bcbbe12..239f9ed 100644 --- a/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/LocalizationTests.swift +++ b/apps/mh3g-save-converter-macos/Tests/ConverterPresentationTests/LocalizationTests.swift @@ -69,6 +69,15 @@ final class LocalizationTests: XCTestCase { "Repair.Version.Hint", "Repair.Version.Required", "Repair.PreviewSHA256", + "Input.SelectFile", + "Input.SelectDirectory", + "Input.SelectFileOrDirectory", + "Input.SourceSelectionHint", + "Input.CurrentSelectionHint", + "Input.OutputSelectionHint", + "Input.PathGuideTitle", + "Input.PathGuideSummary", + "Input.PathGuideExample", ] { let entry = try XCTUnwrap(strings[key] as? [String: Any], "missing \(key)") let localizations = try XCTUnwrap(entry["localizations"] as? [String: Any]) @@ -120,6 +129,17 @@ final class LocalizationTests: XCTestCase { } } + func testPathSelectionCopyExplainsFilesFoldersAndDirectChildren() { + for language in [ConverterLanguage.zhHans, .english] { + let guide = ConverterCopy.text("Input.PathGuideSummary", language: language) + let example = ConverterCopy.text("Input.PathGuideExample", language: language) + XCTAssertTrue(guide.localizedCaseInsensitiveContains("user#")) + XCTAssertTrue(example.contains("user2")) + XCTAssertTrue(example.contains("3DS")) + XCTAssertTrue(example.contains("Cemu")) + } + } + func testWorkflowStatesUseLocalizedDisplayKeysInsteadOfRawValues() { let expectedChinese: [(WorkflowState, String)] = [ (.input, "等待选择存档"), diff --git a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs index e3605d7..7464068 100644 --- a/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs +++ b/apps/mh3g-save-converter-windows/Infrastructure/ConverterCopy.cs @@ -83,6 +83,9 @@ public void SetLanguage(AppLanguageOverride language) public string CoreSectionDescription => Text(nameof(CoreSectionDescription)); public string SaveSlot => Text(nameof(SaveSlot)); public string SaveSlotHint => Text(nameof(SaveSlotHint)); + public string PathSelectionGuideTitle => Text(nameof(PathSelectionGuideTitle)); + public string PathSelectionGuideSummary => Text(nameof(PathSelectionGuideSummary)); + public string PathSelectionGuideExample => Text(nameof(PathSelectionGuideExample)); public string SourceSlot => Text(nameof(SourceSlot)); public string SourceSlotHint => Text(nameof(SourceSlotHint)); public string CurrentSlot => Text(nameof(CurrentSlot)); @@ -258,6 +261,9 @@ private string Text(string key) [nameof(CoreSectionDescription)] = "Choose one user# slot. Each selected input folder resolves only its direct child; the output folder resolves to the same user# name.", [nameof(SaveSlot)] = "Save slot", [nameof(SaveSlotHint)] = "This selects the direct user# child used when you choose a folder. A directly selected source file must match it.", + [nameof(PathSelectionGuideTitle)] = "File or direct parent folder supported (show example)", + [nameof(PathSelectionGuideSummary)] = "Only the direct same-name user# is resolved. The app never recursively scans an SD card or MLC and never reads ZIP, 7z, or RAR archives.", + [nameof(PathSelectionGuideExample)] = "Example (user2 selected)\n\n3DS save parent/\n├── system (not read by this control)\n└── user2 <- 3DS source\n\nCurrent Cemu save folder/\n├── user2 <- current reference (repair only, read-only)\n├── card1, card2, card3, cardbox (read for guild-card repair)\n└── quest1 ... quest4 (validated and preserved for repair)\n\nOutput folder/\n├── user2 <- result (may be absent for a core-only operation)\n└── card1, card2, card3, cardbox (must exist for separate guild-card output)", [nameof(SourceSlot)] = "3DS source", [nameof(SourceSlotHint)] = "Choose user1, user2, or user3, or a folder that directly contains the selected file. No archive or recursive SD-card search is used.", [nameof(CurrentSlot)] = "Current Wii U / Cemu reference save", @@ -437,6 +443,9 @@ private string Text(string key) [nameof(CoreSectionDescription)] = "选择一个 user# 槽位。每个输入目录只解析其直接子文件;输出目录会解析为同名 user#。", [nameof(SaveSlot)] = "存档槽位", [nameof(SaveSlotHint)] = "选择目录时会使用这个直接子文件。直接选择的源文件必须与这个槽位同名。", + [nameof(PathSelectionGuideTitle)] = "支持单文件或直接父目录(查看结构示例)", + [nameof(PathSelectionGuideSummary)] = "只解析所选目录的直接同名 user#;不会递归扫描 SD 卡或 MLC,也不会读取 ZIP、7z、RAR。", + [nameof(PathSelectionGuideExample)] = "示例(选择 user2)\n\n3DS 存档直接父目录/\n├── system (本控件不读取)\n└── user2 <- 3DS 源\n\n当前 Cemu 存档目录/\n├── user2 <- 当前引用(仅修复,只读)\n├── card1、card2、card3、cardbox (修复名片时读取)\n└── quest1 ... quest4 (修复名片时校验并保留)\n\n输出目录/\n├── user2 <- 转换结果(仅处理核心角色时可暂不存在)\n└── card1、card2、card3、cardbox (修复名片并独立输出时必须已存在)", [nameof(SourceSlot)] = "3DS 源存档", [nameof(SourceSlotHint)] = "选择 user1、user2 或 user3,或直接包含所选文件的目录。不扫描压缩包,也不会递归扫描 SD 卡。", [nameof(CurrentSlot)] = "当前 Wii U / Cemu 引用存档", diff --git a/apps/mh3g-save-converter-windows/MainWindow.xaml b/apps/mh3g-save-converter-windows/MainWindow.xaml index 288add3..a456fff 100644 --- a/apps/mh3g-save-converter-windows/MainWindow.xaml +++ b/apps/mh3g-save-converter-windows/MainWindow.xaml @@ -199,6 +199,13 @@ + + + + + + + diff --git a/apps/mh3g-save-converter-windows/README.md b/apps/mh3g-save-converter-windows/README.md index 0e46f75..5602af3 100644 --- a/apps/mh3g-save-converter-windows/README.md +++ b/apps/mh3g-save-converter-windows/README.md @@ -21,6 +21,9 @@ code. `repair-converted`; optional complete 3DS ExtData enables guild-card repair. A separate card output must already be initialized. Ambiguous detection requires choosing 0.0.3 through 0.0.6 and repeating Dry Run. +- Every core path exposes explicit `File` and `Folder` buttons. An expandable + `user2` tree shows the accepted directory level and states that resolution is + direct-only, non-recursive, and archive-free. - The UI opens a confirmation dialog before writing. Normal conversion records its single-file manifest; repair records `.mh3g-compatibility-repair-.json` and uses `rollback-repair`. diff --git a/apps/mh3g-save-converter-windows/README.zh-CN.md b/apps/mh3g-save-converter-windows/README.zh-CN.md index badba16..9b87571 100644 --- a/apps/mh3g-save-converter-windows/README.zh-CN.md +++ b/apps/mh3g-save-converter-windows/README.zh-CN.md @@ -19,6 +19,8 @@ `repair-converted`。可选完整 3DS ExtData 用于修复公会名片;独立输出目录必须 已初始化相应卡片文件。自动检测结果为歧义时,界面会要求选择 0.0.3 至 0.0.6 后重新 Dry Run。 +- 每个核心路径都明确提供“文件”和“文件夹”按钮;槽位下方的可展开说明展示 + `user2` 示例目录树,并说明目录只解析直接同名子文件、不递归、不读取压缩包。 - 写入前有确认对话框。普通转换记录单文件 manifest;兼容修复记录 `.mh3g-compatibility-repair-.json`,并通过 `rollback-repair` 总体回滚。 - C# 仅用 `ProcessStartInfo.ArgumentList` 逐个传递 argv,且 diff --git a/scripts/verify-mh3g-save-converter-windows-source.py b/scripts/verify-mh3g-save-converter-windows-source.py index 9009b35..57c2919 100644 --- a/scripts/verify-mh3g-save-converter-windows-source.py +++ b/scripts/verify-mh3g-save-converter-windows-source.py @@ -614,6 +614,10 @@ def main() -> int: 'PlaceholderText="{Binding CoreTargetPlaceholder}"', 'Click="ChooseCurrentFile_Click"', 'Click="ChooseCurrentFolder_Click"', + 'Header="{Binding Copy.PathSelectionGuideTitle}"', + 'Text="{Binding Copy.PathSelectionGuideSummary}"', + 'Text="{Binding Copy.PathSelectionGuideExample}"', + 'IsTextSelectionEnabled="True"', ): require(expected in window, f"repair mode must expose independent current/output controls: {expected}") require('x:Name="OptionalConfigurationAnchor"' in window, "optional configuration requires a stable destination") @@ -880,6 +884,10 @@ def main() -> int: "Check for Updates", "检查更新", "UpdateNetworkNote", + "File or direct parent folder supported", + "支持单文件或直接父目录", + "ZIP, 7z, or RAR", + "ZIP、7z、RAR", ): require(expected in copy, f"localized copy is missing {expected}") require((APP / "README.zh-CN.md").is_file(), "Windows shell must include Chinese usage guidance")