diff --git a/assets/l10n/en.json b/assets/l10n/en.json index ebd5d73..2540861 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1209,6 +1209,7 @@ "약 {0} 남음": "about {0} left", "약 {0}~{1}분": "about {0}–{1} min", "약 {0}~{1}분 (동영상 {2})": "about {0}–{1} min (videos: {2})", + "양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "Both ways (⇄) never deletes source files (--remove-source-files in the options is not used).", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "Both ways: newer files on the receiving side are not overwritten (-u).", "어둡게": "Darker", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "Press anywhere to bring up the JJ_MKVMaker window", @@ -1314,6 +1315,8 @@ "원본 유지 (재인코딩 없음)": "Keep original (no re-encoding)", "원본 크기": "Original size", "원본 파일 지우기 (--remove-source-files)": "Delete source files (--remove-source-files)", + "원본 파일이 지워집니다 (이동).": "Source files will be deleted (move).", + "원본 파일이 지워집니다 (이동). 환경 설정의 rsync 옵션에 --remove-source-files 가 있어 켜 두었습니다 - 끄면 이번에는 원본을 남깁니다.": "Source files will be deleted (move). This is on because the rsync options in Settings contain --remove-source-files - turn it off to keep the source this time.", "원본 폴더": "Source folder", "원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "When the source folder changes, the target is synced right away (while the app is running). Also syncs once at start.", "원본 폴더가 비어 있어 멈춤": "Stopped: source folder is empty", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 1cbcbd5..9f49238 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1209,6 +1209,7 @@ "약 {0} 남음": "残り約 {0}", "약 {0}~{1}분": "約 {0}~{1} 分", "약 {0}~{1}분 (동영상 {2})": "約 {0}~{1} 分 (動画 {2})", + "양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "双方向 (⇄) では元のファイルを削除しません (オプションの --remove-source-files は使いません)。", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双方向: 受け取る側の方が新しいファイルは上書きしません (-u)。", "어둡게": "暗く", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "どこからでも押すと JJ_MKVMaker ウィンドウが表示されます", @@ -1314,6 +1315,8 @@ "원본 유지 (재인코딩 없음)": "元のまま(再エンコードなし)", "원본 크기": "元のサイズ", "원본 파일 지우기 (--remove-source-files)": "元ファイルを削除 (--remove-source-files)", + "원본 파일이 지워집니다 (이동).": "元のファイルは削除されます (移動)。", + "원본 파일이 지워집니다 (이동). 환경 설정의 rsync 옵션에 --remove-source-files 가 있어 켜 두었습니다 - 끄면 이번에는 원본을 남깁니다.": "元のファイルは削除されます (移動)。設定の rsync オプションに --remove-source-files があるためオンにしています - オフにすると今回は元を残します。", "원본 폴더": "元フォルダー", "원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "元フォルダーが変わるとすぐ対象フォルダーに合わせます (アプリの起動中)。起動時にも 1 回合わせます。", "원본 폴더가 비어 있어 멈춤": "元フォルダーが空のため停止", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 541409a..2b44b0a 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1209,6 +1209,7 @@ "약 {0} 남음": "约剩 {0}", "약 {0}~{1}분": "约 {0}~{1} 分钟", "약 {0}~{1}분 (동영상 {2})": "约 {0}~{1} 分钟(视频 {2})", + "양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).": "双向 (⇄) 不会删除源文件(不使用选项中的 --remove-source-files)。", "양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).": "双向: 接收端较新的文件不会被覆盖 (-u)。", "어둡게": "调暗", "어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "在任何地方按下即可显示 JJ_MKVMaker 窗口", @@ -1314,6 +1315,8 @@ "원본 유지 (재인코딩 없음)": "保持原样(不重新编码)", "원본 크기": "原始尺寸", "원본 파일 지우기 (--remove-source-files)": "删除源文件 (--remove-source-files)", + "원본 파일이 지워집니다 (이동).": "源文件将被删除(移动)。", + "원본 파일이 지워집니다 (이동). 환경 설정의 rsync 옵션에 --remove-source-files 가 있어 켜 두었습니다 - 끄면 이번에는 원본을 남깁니다.": "源文件将被删除(移动)。因为设置中的 rsync 选项含有 --remove-source-files 所以已勾选 - 取消勾选则本次保留源文件。", "원본 폴더": "源文件夹", "원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "源文件夹变化时立即同步到目标 (应用运行期间)。启动时也同步一次。", "원본 폴더가 비어 있어 멈춤": "源文件夹为空,已停止", diff --git a/lib/core/sync_preview.dart b/lib/core/sync_preview.dart index af32265..3e5258e 100644 --- a/lib/core/sync_preview.dart +++ b/lib/core/sync_preview.dart @@ -1,5 +1,7 @@ import 'file_ops.dart' show isPartialFile, SourceUnreadableException; -import 'sync_tools.dart' show splitOptions; +import 'sync_tools.dart' show splitOptions, isSourceRemovingOption; + +export 'sync_tools.dart' show isSourceRemovingOption; import 'vfs.dart'; /// rsync (Rsync 화면의 → · ← · ⇄) 를 실행하기 전에 무엇이 바뀌는지 미리 보기 (72). @@ -64,9 +66,13 @@ String withoutDeleteOptions(String options) => rawOptionTokens(options) .where((raw) => !splitOptions(raw).any(isRsyncDeleteOption)) .join(' '); -/// 원본을 지우는 rsync · robocopy 옵션인지 (--remove-source-files · --remove-sent-files · /MOV · /MOVE) - 107 -bool isSourceRemovingOption(String o) => - o == '--remove-source-files' || o == '--remove-sent-files' || o.toUpperCase() == '/MOV' || o.toUpperCase() == '/MOVE'; +/// 178: 옵션 글에서 원본을 지우는 옵션을 뺀다 (원본 지우기는 [원본 파일 지우기] · [이동] 으로만 - 보이는 것과 실행이 같게) +String withoutSourceRemovingOptions(String options) => rawOptionTokens(options) + .where((raw) => !splitOptions(raw).any(isSourceRemovingOption)) + .join(' '); + +/// 옵션 글에 원본을 지우는 옵션이 들어 있는지 (환경 설정의 rsync 옵션 등) +bool optionsRemoveSource(String options) => splitOptions(options).any(isSourceRemovingOption); /// rsync 옵션에 -u (받는 쪽이 더 새 파일은 건너뜀) 가 있는지 bool optionsUpdate(String options) => diff --git a/lib/core/sync_tools.dart b/lib/core/sync_tools.dart index c7e1811..cbc7aa1 100644 --- a/lib/core/sync_tools.dart +++ b/lib/core/sync_tools.dart @@ -75,13 +75,14 @@ List rsyncArgs({ bool windows = true, bool contents = false, }) { - final opts = splitOptions(options); + // 178: 원본을 지우는 옵션은 [move] (이동 · [원본 파일 지우기]) 로만 - 옵션 글에 있어도 빼고, 이동일 때만 넣는다 + final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o]; return [ ...opts, if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 출력 (Android 빌드는 iconv 없음 · 진행 표시에 쓰는 이름) if (!windows && !opts.contains('-8') && !opts.contains('--8-bit-output')) '-8', - if (move && !opts.contains('--remove-source-files')) '--remove-source-files', + if (move) '--remove-source-files', // [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양) for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}', '${toCygwinPath(_noTrailingSlash(dest), windows: windows)}/', @@ -90,6 +91,10 @@ List rsyncArgs({ String _noTrailingSlash(String s) => s.length > 3 && (s.endsWith('/') || s.endsWith('\\')) ? s.substring(0, s.length - 1) : s; +/// 원본을 지우는 rsync · robocopy 옵션인지 (--remove-source-files · --remove-sent-files · /MOV · /MOVE) - 107 · 178 +bool isSourceRemovingOption(String o) => + o == '--remove-source-files' || o == '--remove-sent-files' || o.toUpperCase() == '/MOV' || o.toUpperCase() == '/MOVE'; + /// robocopy 한 번 실행할 인수 (robocopy 는 "폴더 → 폴더" 라 원본마다 따로 만든다). /// - 폴더: robocopy <폴더> <대상>\<폴더 이름> [옵션] /// - 파일: robocopy <파일이 있는 폴더> <대상> <파일 이름들…> [옵션] (같은 폴더의 파일은 한 번에) @@ -103,10 +108,11 @@ List> robocopyRuns({ bool move = false, bool contents = false, }) { - final opts = splitOptions(options); + // 178: 원본을 지우는 옵션 (/MOV · /MOVE) 은 [move] 로만 + final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o]; final extra = [ if (bandwidthKBps > 0 && !opts.any((o) => o.toUpperCase().startsWith('/IPG'))) '/IPG:${robocopyIpg(bandwidthKBps)}', - if (move && !opts.any((o) => o.toUpperCase().startsWith('/MOV'))) '/MOVE', + if (move) '/MOVE', ]; final runs = >[ for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra], diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 529ee24..ca8fc20 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -1736,7 +1736,7 @@ class _ExplorerPageState extends State { '\n${_namesOf(sources)}' '${skippedHere.isEmpty ? '' : '\n${trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)])}'}' '\n\n${trf('방법: {0}', [tr(method.label)])}' - '${method == CopyMethod.builtin ? '' : ' · ${task.options}'}' + '${method == CopyMethod.builtin ? '' : ' · ${withoutSourceRemovingOptions(task.options)}${move ? (method == CopyMethod.rsync ? ' --remove-source-files' : ' /MOVE') : ''}'}' '${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'), FutureBuilder>( future: clashesFuture, @@ -1892,8 +1892,14 @@ class _ExplorerPageState extends State { // 96: 확인 창 · 미리 보기 · 실행이 같은 작업 (원본 파일 지우기 = 이동 작업) 과 같은 옵션을 쓴다 List shownTasks(bool move) => [for (final (src, dst) in runs) center.peek([src], dst, contents: true, method: 'rsync', move: move)]; - var tasks = shownTasks(false); - String opts(CopyTask t) => both ? withUpdateOption(t.options) : t.options; + // 178: 환경 설정 (또는 기억한) 옵션에 원본 지우기가 있으면 [원본 파일 지우기] 를 켠 채로 연다 (버튼은 [이동] · 경고). + // 원본 지우기는 이 체크로만 정한다 - 옵션 글에서는 빼고 보이고, 끄면 이번 실행에서 정말 빠진다 + final optionMove = runs.map((x) => center.peek([x.$1], x.$2, contents: true, method: 'rsync', move: false)).any((t) => optionsRemoveSource(t.options)); + var tasks = shownTasks(optionMove && !both); + String opts(CopyTask t) { + final o = withoutSourceRemovingOptions(t.options); + return both ? withUpdateOption(o) : o; + } // 95: ⇄ 는 → 다음에 ← (차례로). ← 에서는 지우지 않는다 (→ 가 방금 복사한 것을 지우지 않게) String runOpts(int i) => both && i > 0 ? withoutDeleteOptions(opts(tasks[i])) : opts(tasks[i]); if (nested && tasks.any((t) => optionsDelete(opts(t)))) { @@ -1901,7 +1907,7 @@ class _ExplorerPageState extends State { return; } // 한 방향 (→ · ←) 만: 원본 파일 지우기 (--remove-source-files) 와 끝난 뒤 원본 정리 - var removeSource = false; + var removeSource = optionMove && !both; var prune = 'keep'; // 72: 실행 전 비교 결과 (지워질 수를 실행 버튼에) List? preview; @@ -1986,10 +1992,13 @@ class _ExplorerPageState extends State { if (_viaDav([l, r])) Text(tr('WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).'), style: const TextStyle(fontSize: 12, color: JjColors.accent)), - if (both) + if (both) ...[ Text(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).'), - style: const TextStyle(fontSize: 12, color: JjColors.textDim)) - else ...[ + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + if (optionMove) + Text(tr('양쪽 (⇄) 에서는 원본 파일을 지우지 않습니다 (옵션의 --remove-source-files 는 쓰지 않음).'), + style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), + ] else ...[ CheckboxListTile( contentPadding: EdgeInsets.zero, value: removeSource, @@ -2003,6 +2012,13 @@ class _ExplorerPageState extends State { subtitle: Text(tr('대상으로 옮긴 파일을 원본에서 지웁니다 (이동)')), ), if (removeSource) ...[ + // 178: 원본이 지워진다는 것을 분명히 (환경 설정 옵션 때문에 켜졌으면 그렇다고) + Text( + optionMove + ? tr('원본 파일이 지워집니다 (이동). 환경 설정의 rsync 옵션에 --remove-source-files 가 있어 켜 두었습니다 - 끄면 이번에는 원본을 남깁니다.') + : tr('원본 파일이 지워집니다 (이동).'), + style: const TextStyle(fontSize: 12, color: Colors.orangeAccent, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), Text(tr('끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), const SizedBox(height: 6), diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index 7e311e5..1e32cd6 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -19,6 +19,7 @@ import 'copy_sync_settings.dart' show addLiveSyncPairDialog, editLiveSyncPairDia import 'path_label.dart'; import 'rsync_setup.dart'; import 'schedule_editor.dart'; +import '../core/sync_preview.dart' show withoutSourceRemovingOptions; import 'sync_preview_view.dart' show confirmDeletingRun; import 'theme.dart'; @@ -373,7 +374,8 @@ class _CopyCardState extends State<_CopyCard> { title: Text(tr('고친 옵션을 저장하지 않았습니다')), content: Text(trf('칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?', [ '${_method == 'builtin' ? tr('현재 방식') : _method} ${_options.text.trim()}', - '${t.method == 'builtin' ? tr('현재 방식') : t.method} ${t.options}', + // 178: 실제로 쓰는 옵션 (원본 지우기는 이동일 때만) + '${t.method == 'builtin' ? tr('현재 방식') : t.method} ${withoutSourceRemovingOptions(t.options)}${t.move ? (t.method == 'robocopy' ? ' /MOVE' : t.method == 'rsync' ? ' --remove-source-files' : '') : ''}', ])), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), diff --git a/test/explorer_webdav_test.dart b/test/explorer_webdav_test.dart index 186d468..3e8e502 100644 --- a/test/explorer_webdav_test.dart +++ b/test/explorer_webdav_test.dart @@ -114,6 +114,39 @@ void main() { expect(up.readAsStringSync(), 'hello'); }); + testWidgets('178: 설정 옵션에 --remove-source-files 가 있으면 체크 켬 · [이동] · 경고, 끄면 정말 원본을 남긴다', (tester) async { + final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass'); + c.settings + ..webdavServers = [s] + ..rsyncOptions = '-av --remove-source-files' + ..rsyncPaths = [left, 'dav://nas/']; + DavRegistry.configure([s]); + await pump(tester, ExplorerPage(c: c, rsync: true)); + await settle(tester, () => find.text('sub').evaluate().isNotEmpty && find.text('원격').evaluate().isNotEmpty); + await act(tester, () => tester.tap(find.text('sub'))); + await act(tester, () => tester.tap(find.text('원격'))); + await settle(tester, () => false, rounds: 5); + await act(tester, () => tester.tap(find.text('좌 → 우'))); + await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400); + // 보이는 것 = 실행될 것: 체크 켬 · [이동] · 경고 · 명령 줄에 한 번 + CheckboxListTile box() => tester.widget(find.byType(CheckboxListTile)); + expect(box().value, isTrue); + expect(find.widgetWithText(FilledButton, '이동'), findsOneWidget); + expect(find.textContaining('원본 파일이 지워집니다 (이동). 환경 설정의 rsync 옵션에'), findsOneWidget); + expect(find.text('rsync -av --remove-source-files'), findsOneWidget); + // 끄면 [실행] · 옵션 줄에서도 빠진다 + await tester.tap(find.byType(CheckboxListTile)); + await settle(tester, () => find.textContaining('비교하는 중').evaluate().isEmpty, rounds: 400); + expect(box().value, isFalse); + expect(find.widgetWithText(FilledButton, '실행'), findsOneWidget); + expect(find.text('rsync -av'), findsOneWidget); + await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '실행'))); + final got = File(p.join(remote.path, '원격', 'inner.txt')); + await settle(tester, () => got.existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 200); + expect(got.readAsStringSync(), 'inner'); + expect(File(p.join(left, 'sub', 'inner.txt')).existsSync(), isTrue, reason: '체크를 끄면 원본은 그대로 (복사)'); + }); + testWidgets('Rsync 화면: 로컬 ↔ WebDAV 폴더 → (앱이 직접 맞춤 · rsync 필요 없음)', (tester) async { final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass'); c.settings diff --git a/test/sync_tools_test.dart b/test/sync_tools_test.dart index 0aeb8fd..29c49d2 100644 --- a/test/sync_tools_test.dart +++ b/test/sync_tools_test.dart @@ -98,6 +98,15 @@ void main() { expect(o.feed('100% \n\t Newer \t\t 200\tC:\\t\\src\\b.txt\n\t*EXTRA File \t\t 1\tC:\\x\n'), [r'C:\t\src\b.txt']); }); + test('178: 원본을 지우는 옵션은 이동일 때만 (옵션 글에 있어도)', () { + List r(bool move) => rsyncArgs(options: '-av --remove-source-files --remove-sent-files', sources: [r'C:\a'], dest: r'D:\b', move: move); + expect(r(false).where(isSourceRemovingOption), isEmpty); + expect(r(true).where(isSourceRemovingOption), ['--remove-source-files']); + List rc(bool move) => robocopyRuns(options: '/E /MOV /MOVE', folders: [r'C:\a'], files: const [], dest: r'D:\b', move: move).single; + expect(rc(false).where(isSourceRemovingOption), isEmpty); + expect(rc(true).where(isSourceRemovingOption), ['/MOVE']); + }); + test('61: robocopy 파일 줄은 Windows 언어와 상관없이 (일본어 · 중국어 · 한국어 종류 글)', () { final o = RobocopyOutput(); expect(o.feed('\t 新しいファイル \t\t 3000000\tC:\\t\\a b.txt\r\n 34% \r\n'), [r'C:\t\a b.txt']);