From fe8e5b7bc6de5e6730e9eeb966273bdfd34ca2e5 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:10:22 +0900 Subject: [PATCH] =?UTF-8?q?modular=5Fsync=20#6=20F18:=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EA=B8=80=EB=8F=84=20=EB=B3=B5=EC=82=AC=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=EA=B3=BC=20=EA=B0=99=EC=9D=80=20userErrorText=20(copy=5Ferror?= =?UTF-8?q?=5Ftext=20=EB=AA=A8=EB=93=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/app/copy_error_text.dart: userErrorText · rsyncFailureText 를 transfer_job 에서 옮김 (transfer_job 은 다시 내보냄 - 부르는 곳 그대로) - live_sync: 상태 글 = trf('실패: {0}', [userErrorText(e, sources: [원본])]) - 예외 이름 · Dart 의 영어 파일 오류 없이 화면 언어로. 작업 기록 (c.note) 에는 원문 그대로 (237 규칙) - 고치기 전 판 (ecb2b9f) 에서 같은 실제 실패 (받는 쪽 폴더 자리에 파일) 의 상태 글이 "실패: PathExistsException: Creation failed, path = … errno = 183" 였음을 확인 (f18_before.log) - 새 화면 글 없음 (있던 번역만) - en · ja · zh 사전 그대로 - 약속 시험: live_sync_error_text (실제 폴더 · 실제 실패) 사용자가 보게 될 동작: 실시간 동기화가 실패하면 "실패: 만들거나 쓰지 못했습니다 · 경로 · 이유" 처럼 짧은 말 (영어 예외 원문 없이) 확인: analyze 0 · 단위 1490 ×2 · 통합 6/6 ×2 · base10 214장 0px · lsync_p0_shot · live_excl_shot ×2 Co-Authored-By: Claude Opus 5.5 --- lib/app/copy_error_text.dart | 63 +++++++++++++++++++++++++++++ lib/app/live_sync.dart | 4 +- lib/app/transfer_job.dart | 57 ++------------------------ test/live_sync_error_text_test.dart | 39 ++++++++++++++++++ 4 files changed, 108 insertions(+), 55 deletions(-) create mode 100644 lib/app/copy_error_text.dart create mode 100644 test/live_sync_error_text_test.dart diff --git a/lib/app/copy_error_text.dart b/lib/app/copy_error_text.dart new file mode 100644 index 0000000..d71f4a2 --- /dev/null +++ b/lib/app/copy_error_text.dart @@ -0,0 +1,63 @@ +/// 약속 (modular_sync #6 · F18): 복사 · 이동 · 동기화가 사용자에게 보이는 실패 글 - 예외 이름 ("ProcessException:") · Dart 의 영어 파일 오류 +/// ("Cannot copy file") 를 빼고 화면 언어로. 입력: 예외 (와 원본 경로들), 출력: 글. 원래 글 ('$e') 은 부르는 쪽이 작업 기록에 남긴다. +/// 쓰는 곳: 복사 · 이동 작업 (copy_center) · 실시간 동기화 상태 (live_sync) +library; + +import 'dart:io'; + +import '../core/file_ops.dart' show isSameOrInside; +import '../l10n/tr.dart'; + +/// 237: 사용자에게 보이는 실패 글 - "ProcessException:" 같은 예외 이름 · "Command:" 줄은 빼고 내용만. +/// 원래 글 ('$e') 은 작업 기록에 그대로 남긴다. +/// 245: 앱이 직접 할 때의 파일 오류 (Dart 의 "Cannot delete file" 등 영어) 는 화면 언어로. [sources] 안의 파일을 못 지웠으면 +/// rsync 때 (215) 와 같은 글 - 이동에서 원본만 남은 것 +String userErrorText(Object e, {List sources = const []}) => switch (e) { + ProcessException(:final message) when message.isNotEmpty => message, + FileSystemException(:final message, :final path, :final osError) => [ + _fileErrorText(message, path, sources), + if (path != null && path.isNotEmpty) path, + if (osError != null && osError.message.isNotEmpty) osError.message, + ].join(' · '), + _ => '$e'.replaceFirst(RegExp(r'^\w*(Exception|Error)(\([^)]*\))?: '), ''), + }; + +String _fileErrorText(String message, String? path, List sources) { + final m = message.toLowerCase(); + if (m.startsWith('cannot delete') || m.startsWith('deletion failed')) { + final inSource = path != null && sources.any((s) => isSameOrInside(path, s)); + return inSource + ? tr('원본 파일을 지우지 못했습니다 (권한이 없거나 사용 중). 대상에는 복사됐고 원본은 남아 있습니다.') + : tr('지우지 못했습니다 (권한이 없거나 사용 중)'); + } + if (m.startsWith('cannot copy')) return tr('파일을 복사하지 못했습니다'); + if (m.startsWith('cannot rename')) return tr('옮기거나 이름을 바꾸지 못했습니다'); + if (m.startsWith('cannot open') || m.startsWith('cannot read')) return tr('파일을 읽지 못했습니다'); + if (m.startsWith('cannot create') || m.startsWith('creation failed') || m.startsWith('cannot write')) { + return tr('만들거나 쓰지 못했습니다'); + } + return message; +} + +/// 215: rsync 가 실패했을 때 보일 글 - 원본을 못 지웠으면 쉬운 말 한 줄, 그리고 오류 · 권한 줄을 끝쪽부터 (전송한 파일 이름이 아니라). +/// 예전에는 출력의 앞쪽 8줄이라 파일 이름만 보이고 "Permission denied" · "rsync error … (code 23)" 가 빠지기 쉬웠다 +String rsyncFailureText(int code, List errLines, List allLines) { + final all = [...errLines, ...allLines]; + final removeFailed = all.any((l) { + final x = l.toLowerCase(); + return x.contains('failed to remove') || x.contains('cannot delete') || x.contains('unable to remove'); + }); + bool important(String l) { + final x = l.toLowerCase(); + return x.contains('error') || x.contains('fail') || x.contains('denied') || x.contains('cannot') || x.contains('permission'); + } + + final picked = errLines.where(important).toList(); + final lines = picked.isNotEmpty ? picked : (errLines.isNotEmpty ? errLines : allLines); + final tail = lines.length > 6 ? lines.sublist(lines.length - 6) : lines; + return [ + if (removeFailed) tr('원본 파일을 지우지 못했습니다 (권한이 없거나 사용 중). 대상에는 복사됐고 원본은 남아 있습니다.'), + trf('rsync 종료 코드 {0}', [code]), + ...tail, + ].join('\n'); +} diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index c8e2d5f..4b48a90 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -16,6 +16,7 @@ import '../core/rsync_filter.dart'; import '../core/rsync_launch.dart'; import 'sync/sync_commands.dart'; import 'sync/sync_diff.dart'; +import 'copy_error_text.dart' show userErrorText; export 'sync/sync_commands.dart' show bundledRsync, findBundledRsync, rsyncExecutable, copyMethodAvailable; @@ -428,7 +429,8 @@ class LiveSync extends ChangeNotifier { } c.note(trf('실시간 동기화 멈춤: {0} → {1}: {2}', [x.source, x.target, e])); } catch (e) { - status[k] = (DateTime.now(), trf('실패: {0}', [e])); + // F18: 화면에는 화면 언어의 짧은 글 (예외 이름 · 영어 파일 오류 없이), 작업 기록에는 원문 그대로 (아래) + status[k] = (DateTime.now(), trf('실패: {0}', [userErrorText(e, sources: [x.source])])); c.note(trf('실시간 동기화 실패: {0} → {1}: {2}', [x.source, x.target, e])); } finally { _running.remove(k); diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart index 119bbd9..6e237f8 100644 --- a/lib/app/transfer_job.dart +++ b/lib/app/transfer_job.dart @@ -12,64 +12,13 @@ import '../core/vfs.dart'; import '../l10n/tr.dart'; import '../core/rsync_filter.dart'; import '../core/rsync_launch.dart'; +import 'copy_error_text.dart'; + +export 'copy_error_text.dart' show userErrorText, rsyncFailureText; /// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다: /// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률 /// - 아래쪽: 지금 항목 (폴더) 의 파일 중 몇 개를 했는지 -/// 237: 사용자에게 보이는 실패 글 - "ProcessException:" 같은 예외 이름 · "Command:" 줄은 빼고 내용만. -/// 원래 글 ('$e') 은 작업 기록에 그대로 남긴다. -/// 245: 앱이 직접 할 때의 파일 오류 (Dart 의 "Cannot delete file" 등 영어) 는 화면 언어로. [sources] 안의 파일을 못 지웠으면 -/// rsync 때 (215) 와 같은 글 - 이동에서 원본만 남은 것 -String userErrorText(Object e, {List sources = const []}) => switch (e) { - ProcessException(:final message) when message.isNotEmpty => message, - FileSystemException(:final message, :final path, :final osError) => [ - _fileErrorText(message, path, sources), - if (path != null && path.isNotEmpty) path, - if (osError != null && osError.message.isNotEmpty) osError.message, - ].join(' · '), - _ => '$e'.replaceFirst(RegExp(r'^\w*(Exception|Error)(\([^)]*\))?: '), ''), - }; - -String _fileErrorText(String message, String? path, List sources) { - final m = message.toLowerCase(); - if (m.startsWith('cannot delete') || m.startsWith('deletion failed')) { - final inSource = path != null && sources.any((s) => isSameOrInside(path, s)); - return inSource - ? tr('원본 파일을 지우지 못했습니다 (권한이 없거나 사용 중). 대상에는 복사됐고 원본은 남아 있습니다.') - : tr('지우지 못했습니다 (권한이 없거나 사용 중)'); - } - if (m.startsWith('cannot copy')) return tr('파일을 복사하지 못했습니다'); - if (m.startsWith('cannot rename')) return tr('옮기거나 이름을 바꾸지 못했습니다'); - if (m.startsWith('cannot open') || m.startsWith('cannot read')) return tr('파일을 읽지 못했습니다'); - if (m.startsWith('cannot create') || m.startsWith('creation failed') || m.startsWith('cannot write')) { - return tr('만들거나 쓰지 못했습니다'); - } - return message; -} - -/// 215: rsync 가 실패했을 때 보일 글 - 원본을 못 지웠으면 쉬운 말 한 줄, 그리고 오류 · 권한 줄을 끝쪽부터 (전송한 파일 이름이 아니라). -/// 예전에는 출력의 앞쪽 8줄이라 파일 이름만 보이고 "Permission denied" · "rsync error … (code 23)" 가 빠지기 쉬웠다 -String rsyncFailureText(int code, List errLines, List allLines) { - final all = [...errLines, ...allLines]; - final removeFailed = all.any((l) { - final x = l.toLowerCase(); - return x.contains('failed to remove') || x.contains('cannot delete') || x.contains('unable to remove'); - }); - bool important(String l) { - final x = l.toLowerCase(); - return x.contains('error') || x.contains('fail') || x.contains('denied') || x.contains('cannot') || x.contains('permission'); - } - - final picked = errLines.where(important).toList(); - final lines = picked.isNotEmpty ? picked : (errLines.isNotEmpty ? errLines : allLines); - final tail = lines.length > 6 ? lines.sublist(lines.length - 6) : lines; - return [ - if (removeFailed) tr('원본 파일을 지우지 못했습니다 (권한이 없거나 사용 중). 대상에는 복사됐고 원본은 남아 있습니다.'), - trf('rsync 종료 코드 {0}', [code]), - ...tail, - ].join('\n'); -} - class TransferJob extends ChangeNotifier { final List sources; final String dest; diff --git a/test/live_sync_error_text_test.dart b/test/live_sync_error_text_test.dart new file mode 100644 index 0000000..e3ee81c --- /dev/null +++ b/test/live_sync_error_text_test.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/live_sync.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:path/path.dart' as p; + +/// modular_sync #6 F18 약속: 실시간 동기화가 실패하면 화면 상태 글은 복사 작업과 같은 userErrorText (예외 이름 · Dart 의 영어 파일 오류 없이), +/// 작업 기록에는 원문 그대로. 예전에는 상태 글에 "FileSystemException: …" 원문이 보였다. 실제 폴더 · 실제 실패 (받는 쪽 자리가 파일) +void main() { + test('받는 쪽 폴더 자리에 파일 - 상태 글은 짧은 말, 작업 기록은 원문', () async { + final tmp = Directory.systemTemp.createTempSync('jj_liveerr_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final src = p.join(tmp.path, 'src'), dst = p.join(tmp.path, 'dst'); + File(p.join(src, 'sub', 'a.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('a'); + // 받는 쪽의 sub 자리에 파일이 있어 그 안으로 맞출 수 없다 + File(p.join(dst, 'sub', 'x')) + ..createSync(recursive: true) + ..writeAsStringSync('x'); + Directory(p.join(dst, 'sub')).deleteSync(recursive: true); + File(p.join(dst, 'sub')).writeAsStringSync('file in the way'); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final pair = LiveSyncPair(src, dst); + c.settings.copy.liveSyncPairs = [pair]; + final live = LiveSync(c); + await live.syncNow(pair); + final shown = live.status[LiveSync.keyOf(pair)]!.$2; + expect(shown, startsWith('실패')); + expect(shown, isNot(matches(RegExp(r'\w*(Exception|Error)\b'))), reason: '예외 이름 없이: $shown'); + expect(c.logs.any((l) => l.contains('실시간 동기화 실패') && l.contains('Exception')), isTrue, reason: '작업 기록에는 원문'); + expect(File(p.join(dst, 'sub')).readAsStringSync(), 'file in the way', reason: '받는 쪽 파일은 그대로'); + }); +}