modular_sync #6 F18: 실시간 동기화 실패 글도 복사 작업과 같은 userErrorText (copy_error_text 모듈)

- 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 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-24 14:10:22 +09:00
parent ecb2b9f7b1
commit fe8e5b7bc6
4 changed files with 108 additions and 55 deletions

View File

@ -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<String> 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<String> 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<String> errLines, List<String> 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');
}

View File

@ -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);

View File

@ -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<String> 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<String> 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<String> errLines, List<String> 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<String> sources;
final String dest;

View File

@ -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: '받는 쪽 파일은 그대로');
});
}