원본 지우기 옵션: 확인 창에 보이는 것과 실제 실행을 같게 (178, 데이터)

환경 설정의 rsync 옵션에 --remove-source-files 가 있으면 [좌 → 우] 확인 창은 [원본 파일 지우기] 꺼짐 · [실행] 인데
실행하면 원본이 지워졌다 (사용자는 복사라고 믿음).
- rsyncArgs · robocopyRuns: 원본을 지우는 옵션 (--remove-source-files · --remove-sent-files · /MOV · /MOVE) 은 옵션 글에서 늘 빼고
  move (이동 · [원본 파일 지우기]) 일 때만 넣는다 - 탐색기 복사 · Rsync 확인 창 · 모니터링 다시 실행 모두.
- Rsync 확인 창: 설정 (기억한) 옵션에 원본 지우기가 있으면 체크를 켠 채로 · [이동] · 경고. 끄면 명령 줄 · 실행에서 빠진다.
  ⇄ 는 원본을 지우지 않는다고 알린다. 복사 확인 창 · 모니터링 카드의 옵션 줄도 실제 쓰는 것으로.
- sync_tools_test · explorer_webdav_test (끄면 원본이 남음).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음4
RyuJongJin 2026-10-10 06:08:47 +09:00
parent d5bb200d00
commit ae51fdfa34
9 changed files with 97 additions and 16 deletions

View File

@ -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",

View File

@ -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 回合わせます。",
"원본 폴더가 비어 있어 멈춤": "元フォルダーが空のため停止",

View File

@ -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 所以已勾选 - 取消勾选则本次保留源文件。",
"원본 폴더": "源文件夹",
"원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "源文件夹变化时立即同步到目标 (应用运行期间)。启动时也同步一次。",
"원본 폴더가 비어 있어 멈춤": "源文件夹为空,已停止",

View File

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

View File

@ -75,13 +75,14 @@ List<String> 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<String> 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<List<String>> 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 = <List<String>>[
for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra],

View File

@ -1736,7 +1736,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
'\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<List<NameClash>>(
future: clashesFuture,
@ -1892,8 +1892,14 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 96: 확인 창 · 미리 보기 · 실행이 같은 작업 (원본 파일 지우기 = 이동 작업) 과 같은 옵션을 쓴다
List<CopyTask> 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<ExplorerPage> {
return;
}
// 한 방향 (→ · ←) 만: 원본 파일 지우기 (--remove-source-files) 와 끝난 뒤 원본 정리
var removeSource = false;
var removeSource = optionMove && !both;
var prune = 'keep';
// 72: 실행 전 비교 결과 (지워질 수를 실행 버튼에)
List<PreviewItem>? preview;
@ -1986,10 +1992,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
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<ExplorerPage> {
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),

View File

@ -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('취소'))),

View File

@ -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<CheckboxListTile>(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

View File

@ -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<String> 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<String> 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']);