diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 4038f8d..3bb26fe 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1005,6 +1005,7 @@ "복사해 둔 AI 모델 ({0}) 을 되살렸습니다": "Restored the copied AI models ({0})", "복사해 둔 AI 모델 ({0}) 을 되살리는 중": "Restoring the copied AI models ({0})", "복사해 둔 AI 모델을 되살리지 못했습니다 (사본은 Download/JJ_MKVMaker/AI 모델 보관 에 그대로): {0}": "Could not restore the copied AI models (the copies remain in Download/JJ_MKVMaker/AI 모델 보관): {0}", + "복사했습니다": "Copied", "복사했습니다.": "copied.", "볼 그림이 없습니다.": "No images to show.", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "This video is unavailable (deleted · region restricted).", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 2f0eeb9..b541991 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1005,6 +1005,7 @@ "복사해 둔 AI 모델 ({0}) 을 되살렸습니다": "コピーした AI モデル ({0}) を復元しました", "복사해 둔 AI 모델 ({0}) 을 되살리는 중": "コピーした AI モデル ({0}) を復元中", "복사해 둔 AI 모델을 되살리지 못했습니다 (사본은 Download/JJ_MKVMaker/AI 모델 보관 에 그대로): {0}": "コピーした AI モデルを復元できませんでした (コピーは Download/JJ_MKVMaker/AI 모델 보관 にそのまま): {0}", + "복사했습니다": "コピーしました", "복사했습니다.": "コピーしました。", "볼 그림이 없습니다.": "表示する画像がありません。", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "視聴できない動画です(削除・地域制限)。", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 20e4299..06d62e0 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1005,6 +1005,7 @@ "복사해 둔 AI 모델 ({0}) 을 되살렸습니다": "已恢复复制的 AI 模型 ({0})", "복사해 둔 AI 모델 ({0}) 을 되살리는 중": "正在恢复复制的 AI 模型 ({0})", "복사해 둔 AI 모델을 되살리지 못했습니다 (사본은 Download/JJ_MKVMaker/AI 모델 보관 에 그대로): {0}": "无法恢复复制的 AI 模型 (副本仍在 Download/JJ_MKVMaker/AI 모델 보관): {0}", + "복사했습니다": "已复制", "복사했습니다.": "已复制。", "볼 그림이 없습니다.": "没有可显示的图片。", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "该视频无法观看(已删除 · 地区限制)。", diff --git a/lib/app/copy_center.dart b/lib/app/copy_center.dart index 9060abf..80a45cd 100644 --- a/lib/app/copy_center.dart +++ b/lib/app/copy_center.dart @@ -145,6 +145,7 @@ class CopyCenter extends ChangeNotifier { : userErrorText(e), // 237: 예외 이름 없이 (원래 글은 작업 기록에) lastFiles: job.doneFiles, lastLeft: e == null ? job.leftInSource : 0, + lastDetail: e == null || e is FileOpCancelled ? '' : '$e', )); } c.note(e == null diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 1c18e3e..cddf534 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -138,6 +138,9 @@ class CopyTask { /// 235: 이동 뒤 원본에 남은 파일 수 (건너뜀 - 216). 카드에 "n개는 원본에 남음" 으로 final int lastLeft; + /// 237: 실패 원문 (예외 이름 · 명령 줄 포함) - 카드의 [자세히] 에서 보고 복사한다. 화면 글은 [lastMessage] + final String lastDetail; + /// 이동 ([move], rsync --remove-source-files) 뒤 원본 정리: '' 그대로 · 'keep' 빈 폴더를 지우고 원본 폴더는 남김 · /// 'all' 원본 폴더까지 (비었으면) 지움 (find 원본/ -type d -empty -delete) final String prune; @@ -157,6 +160,7 @@ class CopyTask { this.lastMessage = '', this.lastFiles = 0, this.lastLeft = 0, + this.lastDetail = '', this.prune = '', }); @@ -174,6 +178,7 @@ class CopyTask { String? lastMessage, int? lastFiles, int? lastLeft, + String? lastDetail, String? prune, }) => CopyTask( @@ -191,6 +196,7 @@ class CopyTask { lastMessage: lastMessage ?? this.lastMessage, lastFiles: lastFiles ?? this.lastFiles, lastLeft: lastLeft ?? this.lastLeft, + lastDetail: lastDetail ?? this.lastDetail, prune: prune ?? this.prune, ); @@ -209,6 +215,7 @@ class CopyTask { 'lastMessage': lastMessage, 'lastFiles': lastFiles, 'lastLeft': lastLeft, + 'lastDetail': lastDetail, 'prune': prune, }; @@ -227,6 +234,7 @@ class CopyTask { lastMessage: j['lastMessage'] as String? ?? '', lastFiles: (j['lastFiles'] as num?)?.toInt() ?? 0, lastLeft: (j['lastLeft'] as num?)?.toInt() ?? 0, + lastDetail: j['lastDetail'] as String? ?? '', prune: const ['keep', 'all'].contains(j['prune']) ? j['prune'] as String : '', ); } diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index 4747310..fbe3cb8 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -9,6 +9,7 @@ import '../app/copy_center.dart'; import '../app/live_sync.dart'; import '../app/settings.dart'; import '../app/transfer_job.dart'; +import '../core/download_detect.dart' show redactUrlSecrets; import '../core/secret_gate.dart'; import '../core/file_ops.dart'; import '../core/sync_tools.dart'; @@ -332,6 +333,33 @@ class _CopyCardState extends State<_CopyCard> { super.dispose(); } + /// 237: 실패 원문 보기 · 복사 + Future _showFailure(CopyTask t) { + final detail = redactUrlSecrets(t.lastDetail); + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(t.lastMessage), + content: SizedBox( + width: 560, + child: SelectableText(detail, style: const TextStyle(fontFamily: 'monospace', fontSize: 12)), + ), + actions: [ + TextButton( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: detail)); + if (ctx.mounted) Navigator.pop(ctx); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('복사했습니다')))); + }, + child: Text(tr('복사')), + ), + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기'))), + ], + ), + ); + } + Future _run(CopyTask t) async { // 236: 옵션 칸의 포커스를 푼다 - 남아 있으면 폰 · 태블릿에서 키보드가 올라와 끝 알림을 가린다 FocusManager.instance.primaryFocus?.unfocus(); @@ -517,7 +545,17 @@ class _CopyCardState extends State<_CopyCard> { if (t.lastResult == 'failed' && !running && t.lastMessage.isNotEmpty) Padding( padding: const EdgeInsets.only(left: 28, top: 4), - child: Text(t.lastMessage, maxLines: 3, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: Colors.redAccent)), + child: Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [ + Text(t.lastMessage, maxLines: 3, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: Colors.redAccent)), + // 237: 원문 (예외 이름 · 명령 줄) 은 [자세히] 에서 - 보고 복사해 물어볼 수 있게 (다운로드 실패 줄 136 과 같게) + if (t.lastDetail.trim().isNotEmpty) + TextButton( + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 6), minimumSize: const Size(0, 28)), + onPressed: () => _showFailure(t), + child: Text(tr('자세히')), + ), + ]), ), if (job != null && (running || job.finished && job.error == null)) Padding(padding: const EdgeInsets.only(left: 28, top: 6), child: TransferProgressLines(job: job)), diff --git a/test/dismiss_test.dart b/test/dismiss_test.dart index 659c49c..2f1a0c9 100644 --- a/test/dismiss_test.dart +++ b/test/dismiss_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show SystemChannels; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/copy_center.dart'; @@ -241,6 +242,34 @@ void main() { await settle(tester, () => CopyCenter.of(c).jobs['1']?.finished ?? true, rounds: 200); }); + testWidgets('237: 모니터링 카드 실패 글은 예외 이름 없이, 원문 (명령 줄 포함) 은 [자세히] 에서 보고 복사', (tester) async { + const raw = 'ProcessException: 원본 파일을 지우지 못했습니다\n Command: rsync -avPog --remove-source-files a/ b/'; + c.settings.copyTasks = [ + const CopyTask(id: '1', sources: ['a'], dest: 'b', method: 'rsync', options: '-avPog', move: true) + .copyWith(lastResult: 'failed', lastRun: DateTime(2026, 10, 12, 9).toIso8601String(), lastMessage: '원본 파일을 지우지 못했습니다', lastDetail: raw), + ]; + expect(CopyTask.fromJson(c.settings.copyTasks.single.toJson()).lastDetail, raw); + tester.view.physicalSize = const Size(1500, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp(home: MonitorPage(c: c))); + await tester.pump(); + expect(find.text('원본 파일을 지우지 못했습니다'), findsOneWidget); + expect(find.textContaining('ProcessException'), findsNothing); + await tester.tap(find.text('자세히')); + await tester.pumpAndSettle(); + expect(find.textContaining('Command: rsync -avPog --remove-source-files'), findsOneWidget); + String? copied; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') copied = (call.arguments as Map)['text'] as String?; + return null; + }); + addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); + await tester.tap(find.text('복사')); + await tester.pumpAndSettle(); + expect(copied, raw); + }); + test('237: 사용자에게 보이는 실패 글에는 예외 이름 · 명령 줄이 없다', () { expect(userErrorText(const ProcessException('rsync', ['-a'], '원본 파일을 지우지 못했습니다', 23)), '원본 파일을 지우지 못했습니다'); expect(userErrorText(const FileSystemException('지울 수 없음', r'C:\x', OSError('액세스가 거부되었습니다', 5))), r'지울 수 없음 · C:\x · 액세스가 거부되었습니다');