앱 직접 복사 · 이동은 받는 쪽이 더 새것이면 건너뜀 · 미리 보기 경고 (229)

- 데이터 (버그): 현재 방식 · WebDAV 의 이동이 받는 쪽의 더 새 파일을 예전 원본으로 덮어쓰고 원본까지 지웠다
- 환경 설정 '받는 쪽이 더 새것이면 건너뛰기' (copySkipNewer, 처음 값 켜짐 - 관리자 판단). rsync -u 와 같음
- 건너뛴 파일은 이동이라도 원본에 남기고 'n개는 원본에 남음' 으로 알림
- 미리 보기: 건너뛰는 것 (받는 쪽이 더 새것 - 건너뜀), 더 새 파일을 덮어쓸 때 주황 굵은 경고
- 시험 sync_preview_test 기대값: -u 일 때 leftold.txt 는 목록에서 빠지지 않고 skipNewer 로 보임 (관리자 승인)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix_mkv
RyuJongJin 2026-10-11 23:30:37 +09:00
parent 27d50891d0
commit c070486c84
14 changed files with 144 additions and 13 deletions

View File

@ -46,9 +46,11 @@
"'사진' 모델로 바꿔 다시": "Switch to 'Photos' and redo", "'사진' 모델로 바꿔 다시": "Switch to 'Photos' and redo",
"(AI 번역을 쓸 수 없는 기기라 언어를 더할 수 없습니다)": "(Languages cannot be added because AI translation is unavailable on this device)", "(AI 번역을 쓸 수 없는 기기라 언어를 더할 수 없습니다)": "(Languages cannot be added because AI translation is unavailable on this device)",
"({0}개 지움)": "(delete {0})", "({0}개 지움)": "(delete {0})",
"(더 새 파일을 덮어씀)": "(overwrites a newer file)",
"(덮어씀)": "(overwritten)", "(덮어씀)": "(overwritten)",
"(동영상을 세 번 누르면 옮깁니다)": "(Tapping a video three times moves it)", "(동영상을 세 번 누르면 옮깁니다)": "(Tapping a video three times moves it)",
"(받는 쪽에만 있음)": "(only on receiving side)", "(받는 쪽에만 있음)": "(only on receiving side)",
"(받는 쪽이 더 새것 - 건너뜀)": "(receiving side is newer - skipped)",
"(비어 있음)": "(empty)", "(비어 있음)": "(empty)",
"(설명 없음)": "(no description)", "(설명 없음)": "(no description)",
"(지워짐)": "(deleted)", "(지워짐)": "(deleted)",
@ -518,6 +520,7 @@
"그림을 읽을 수 없습니다": "Cannot read the image", "그림을 읽을 수 없습니다": "Cannot read the image",
"그림을 페이지로 넣기 (이 페이지 뒤)": "Add images as pages (after this page)", "그림을 페이지로 넣기 (이 페이지 뒤)": "Add images as pages (after this page)",
"그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "Almost the same image, and the last step is about 10× faster (when downloaded)", "그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "Almost the same image, and the last step is about 10× faster (when downloaded)",
"그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "{0} of them are newer on the receiving side - the newer files will be overwritten with older ones",
"글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "Text → image · image → image (on this device; download models in Settings)", "글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "Text → image · image → image (on this device; download models in Settings)",
"글을 격자에 반영": "Apply text to grid", "글을 격자에 반영": "Apply text to grid",
"글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "Text · button size. Change it any time with − · + on the top bar; press the number in the middle to return to this size (now {0}%)", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "Text · button size. Change it any time with − · + on the top bar; press the number in the middle to return to this size (now {0}%)",
@ -908,6 +911,8 @@
"받는 쪽에만 있음 {0} (그대로 둠)": "Only on receiving side {0} (kept)", "받는 쪽에만 있음 {0} (그대로 둠)": "Only on receiving side {0} (kept)",
"받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)": "{0} items will be deleted on the receiving side (--delete, cannot be undone)", "받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)": "{0} items will be deleted on the receiving side (--delete, cannot be undone)",
"받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "{0} files on the receiving side will be overwritten (same name, different content - the original is lost)", "받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "{0} files on the receiving side will be overwritten (same name, different content - the original is lost)",
"받는 쪽이 더 새것 {0} (건너뜀)": "Newer on receiving side {0} (skipped)",
"받는 쪽이 더 새것이면 건너뛰기": "Skip when the receiving side is newer",
"받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "Download size: about {0}MB · downloaded files · AI models · settings are kept", "받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "Download size: about {0}MB · downloaded files · AI models · settings are kept",
"받다 만 AI 모델": "Unfinished AI models", "받다 만 AI 모델": "Unfinished AI models",
"받다 만 다운로드": "Unfinished downloads", "받다 만 다운로드": "Unfinished downloads",
@ -2029,6 +2034,7 @@
"허용했으면 다시 확인": "Check again after allowing", "허용했으면 다시 확인": "Check again after allowing",
"헝가리어": "Hungarian", "헝가리어": "Hungarian",
"현재 방식": "Built-in", "현재 방식": "Built-in",
"현재 방식 · WebDAV 로 폴더 안의 것을 맞출 때 (rsync 의 -u 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "When syncing folder contents with the built-in method or WebDAV (same as rsync -u). Skipped files stay in the source even when moving",
"현재 유지": "Keep as is", "현재 유지": "Keep as is",
"현재 페이지": "Current page", "현재 페이지": "Current page",
"현재 페이지 추가": "Add current page", "현재 페이지 추가": "Add current page",

View File

@ -46,9 +46,11 @@
"'사진' 모델로 바꿔 다시": "「写真」モデルに変えてやり直す", "'사진' 모델로 바꿔 다시": "「写真」モデルに変えてやり直す",
"(AI 번역을 쓸 수 없는 기기라 언어를 더할 수 없습니다)": "(この端末では AI 翻訳が使えないため言語を追加できません)", "(AI 번역을 쓸 수 없는 기기라 언어를 더할 수 없습니다)": "(この端末では AI 翻訳が使えないため言語を追加できません)",
"({0}개 지움)": "({0} 個を削除)", "({0}개 지움)": "({0} 個を削除)",
"(더 새 파일을 덮어씀)": "(新しいファイルを上書き)",
"(덮어씀)": "(上書き)", "(덮어씀)": "(上書き)",
"(동영상을 세 번 누르면 옮깁니다)": "(動画を 3 回タップすると移動します)", "(동영상을 세 번 누르면 옮깁니다)": "(動画を 3 回タップすると移動します)",
"(받는 쪽에만 있음)": "(受け取る側にだけある)", "(받는 쪽에만 있음)": "(受け取る側にだけある)",
"(받는 쪽이 더 새것 - 건너뜀)": "(受け取る側が新しい - スキップ)",
"(비어 있음)": "(空)", "(비어 있음)": "(空)",
"(설명 없음)": "(説明なし)", "(설명 없음)": "(説明なし)",
"(지워짐)": "(削除)", "(지워짐)": "(削除)",
@ -518,6 +520,7 @@
"그림을 읽을 수 없습니다": "画像を読み込めません", "그림을 읽을 수 없습니다": "画像を読み込めません",
"그림을 페이지로 넣기 (이 페이지 뒤)": "画像をページとして追加 (このページの後)", "그림을 페이지로 넣기 (이 페이지 뒤)": "画像をページとして追加 (このページの後)",
"그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "画像はほぼ同じで、最後の段階が約 10 倍速くなります (ダウンロード済みの場合)", "그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "画像はほぼ同じで、最後の段階が約 10 倍速くなります (ダウンロード済みの場合)",
"그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "そのうち {0} 件は受け取る側の方が新しいです - 新しいファイルを古いもので上書きします",
"글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "テキスト → 画像 · 画像 → 画像 (端末内で。モデルは設定でダウンロード)", "글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "テキスト → 画像 · 画像 → 画像 (端末内で。モデルは設定でダウンロード)",
"글을 격자에 반영": "テキストをグリッドに反映", "글을 격자에 반영": "テキストをグリッドに反映",
"글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字・ボタンの大きさです。上部バーの − ・ + でその都度変更でき、中央の数字を押すとこの大きさに戻ります(現在 {0}%)", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字・ボタンの大きさです。上部バーの − ・ + でその都度変更でき、中央の数字を押すとこの大きさに戻ります(現在 {0}%)",
@ -908,6 +911,8 @@
"받는 쪽에만 있음 {0} (그대로 둠)": "受け取る側にだけある {0} (そのまま)", "받는 쪽에만 있음 {0} (그대로 둠)": "受け取る側にだけある {0} (そのまま)",
"받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)": "受け取る側で {0} 個が削除されます (--delete、元に戻せません)", "받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)": "受け取る側で {0} 個が削除されます (--delete、元に戻せません)",
"받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "受け取る側の {0} 件を上書きします (同じ名前で内容が違うもの - 元のものはなくなります)", "받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "受け取る側の {0} 件を上書きします (同じ名前で内容が違うもの - 元のものはなくなります)",
"받는 쪽이 더 새것 {0} (건너뜀)": "受け取る側が新しい {0} (スキップ)",
"받는 쪽이 더 새것이면 건너뛰기": "受け取る側が新しければスキップ",
"받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "ダウンロードサイズ: 約 {0}MB・ダウンロードしたファイル・AI モデル・設定はそのまま", "받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "ダウンロードサイズ: 約 {0}MB・ダウンロードしたファイル・AI モデル・設定はそのまま",
"받다 만 AI 모델": "ダウンロード途中の AI モデル", "받다 만 AI 모델": "ダウンロード途中の AI モデル",
"받다 만 다운로드": "ダウンロード途中のファイル", "받다 만 다운로드": "ダウンロード途中のファイル",
@ -2029,6 +2034,7 @@
"허용했으면 다시 확인": "許可したらもう一度確認", "허용했으면 다시 확인": "許可したらもう一度確認",
"헝가리어": "ハンガリー語", "헝가리어": "ハンガリー語",
"현재 방식": "現在の方式", "현재 방식": "現在の方式",
"현재 방식 · WebDAV 로 폴더 안의 것을 맞출 때 (rsync 의 -u 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "現在の方式 · WebDAV でフォルダーの中身を合わせるとき (rsync の -u と同じ)。スキップしたファイルは移動でも元の場所に残します",
"현재 유지": "現状維持", "현재 유지": "現状維持",
"현재 페이지": "現在のページ", "현재 페이지": "現在のページ",
"현재 페이지 추가": "現在のページを追加", "현재 페이지 추가": "現在のページを追加",

View File

@ -46,9 +46,11 @@
"'사진' 모델로 바꿔 다시": "改用“照片”模型重新处理", "'사진' 모델로 바꿔 다시": "改用“照片”模型重新处理",
"(AI 번역을 쓸 수 없는 기기라 언어를 더할 수 없습니다)": "(此设备无法使用 AI 翻译,因此无法添加语言)", "(AI 번역을 쓸 수 없는 기기라 언어를 더할 수 없습니다)": "(此设备无法使用 AI 翻译,因此无法添加语言)",
"({0}개 지움)": "(删除 {0} 个)", "({0}개 지움)": "(删除 {0} 个)",
"(더 새 파일을 덮어씀)": "(覆盖较新的文件)",
"(덮어씀)": "(覆盖)", "(덮어씀)": "(覆盖)",
"(동영상을 세 번 누르면 옮깁니다)": "(连按视频三次会移动它)", "(동영상을 세 번 누르면 옮깁니다)": "(连按视频三次会移动它)",
"(받는 쪽에만 있음)": "(仅在接收方)", "(받는 쪽에만 있음)": "(仅在接收方)",
"(받는 쪽이 더 새것 - 건너뜀)": "(接收端较新 - 已跳过)",
"(비어 있음)": "(空)", "(비어 있음)": "(空)",
"(설명 없음)": "(无说明)", "(설명 없음)": "(无说明)",
"(지워짐)": "(将删除)", "(지워짐)": "(将删除)",
@ -518,6 +520,7 @@
"그림을 읽을 수 없습니다": "无法读取图片", "그림을 읽을 수 없습니다": "无法读取图片",
"그림을 페이지로 넣기 (이 페이지 뒤)": "将图片作为页面加入 (本页之后)", "그림을 페이지로 넣기 (이 페이지 뒤)": "将图片作为页面加入 (本页之后)",
"그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "图片几乎相同,最后一步快约 10 倍 (已下载时)", "그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "图片几乎相同,最后一步快约 10 倍 (已下载时)",
"그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다": "其中 {0} 个在接收端较新 - 将用旧文件覆盖较新的文件",
"글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "文字 → 图片 · 图片 → 图片(在本设备上;模型在设置中下载)", "글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "文字 → 图片 · 图片 → 图片(在本设备上;模型在设置中下载)",
"글을 격자에 반영": "将文本应用到网格", "글을 격자에 반영": "将文本应用到网格",
"글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字 · 按钮大小。可随时用顶部栏的 − · + 调整,点击中间的数字恢复为此大小(当前 {0}%)", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字 · 按钮大小。可随时用顶部栏的 − · + 调整,点击中间的数字恢复为此大小(当前 {0}%)",
@ -908,6 +911,8 @@
"받는 쪽에만 있음 {0} (그대로 둠)": "仅在接收方 {0} (保留)", "받는 쪽에만 있음 {0} (그대로 둠)": "仅在接收方 {0} (保留)",
"받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)": "接收方将删除 {0} 个项目 (--delete,无法撤销)", "받는 쪽에서 {0}개가 지워집니다 (--delete, 되돌릴 수 없음)": "接收方将删除 {0} 个项目 (--delete,无法撤销)",
"받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "将覆盖接收端的 {0} 个文件(同名但内容不同 - 原文件将丢失)", "받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "将覆盖接收端的 {0} 个文件(同名但内容不同 - 原文件将丢失)",
"받는 쪽이 더 새것 {0} (건너뜀)": "接收端较新 {0}(跳过)",
"받는 쪽이 더 새것이면 건너뛰기": "接收端较新时跳过",
"받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "下载大小: 约 {0}MB · 已下载的文件 · AI 模型 · 设置均保留", "받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "下载大小: 约 {0}MB · 已下载的文件 · AI 模型 · 设置均保留",
"받다 만 AI 모델": "未下载完的 AI 模型", "받다 만 AI 모델": "未下载完的 AI 模型",
"받다 만 다운로드": "未完成的下载", "받다 만 다운로드": "未完成的下载",
@ -2029,6 +2034,7 @@
"허용했으면 다시 확인": "允许后重新检查", "허용했으면 다시 확인": "允许后重新检查",
"헝가리어": "匈牙利语", "헝가리어": "匈牙利语",
"현재 방식": "当前方式", "현재 방식": "当前方式",
"현재 방식 · WebDAV 로 폴더 안의 것을 맞출 때 (rsync 의 -u 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "使用当前方式 · WebDAV 同步文件夹内容时(与 rsync 的 -u 相同)。跳过的文件即使是移动也会保留在源中",
"현재 유지": "保持现状", "현재 유지": "保持现状",
"현재 페이지": "当前页面", "현재 페이지": "当前页面",
"현재 페이지 추가": "添加当前页面", "현재 페이지 추가": "添加当前页面",

View File

@ -119,6 +119,7 @@ class CopyCenter extends ChangeNotifier {
contents: t.contents, contents: t.contents,
prune: t.move ? t.prune : '', prune: t.move ? t.prune : '',
conflict: conflict, conflict: conflict,
skipNewer: c.settings.copySkipNewer,
); );
jobs[t.id] = job; jobs[t.id] = job;
job.addListener(notifyListeners); job.addListener(notifyListeners);

View File

@ -373,6 +373,10 @@ class AppSettings {
/// 48: 복사 · 이동할 때 받는 폴더에 같은 이름이 있으면 'ask' (확인 창에서 고름, 처음 값) · 'rename' · 'overwrite' · 'skip' /// 48: 복사 · 이동할 때 받는 폴더에 같은 이름이 있으면 'ask' (확인 창에서 고름, 처음 값) · 'rename' · 'overwrite' · 'skip'
String copyConflict = 'ask'; String copyConflict = 'ask';
/// 229: 앱이 직접 하는 복사 · 이동 (현재 방식 · WebDAV 의 안의 것 맞추기) 에서 받는 쪽이 더 새것이면 건너뛴다 (rsync -u 와 같음).
/// 처음 값 켜짐 - 예전 원본으로 새 파일을 덮어쓰고 (이동이면 원본까지 지워) 새 내용을 잃었다 (관리자 판단)
bool copySkipNewer = true;
String copyMethodFolder = 'builtin'; String copyMethodFolder = 'builtin';
String rsyncOptions = '-avPog'; String rsyncOptions = '-avPog';
String robocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2'; String robocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
@ -667,6 +671,7 @@ class AppSettings {
'explorerPaths': explorerPaths, 'explorerPaths': explorerPaths,
'copyMethodFile': copyMethodFile, 'copyMethodFile': copyMethodFile,
'copyConflict': copyConflict, 'copyConflict': copyConflict,
'copySkipNewer': copySkipNewer,
'copyMethodFolder': copyMethodFolder, 'copyMethodFolder': copyMethodFolder,
'rsyncPaths': rsyncPaths, 'rsyncPaths': rsyncPaths,
'components': components, 'components': components,
@ -853,6 +858,7 @@ class AppSettings {
..explorerPaths = [for (final x in (j['explorerPaths'] as List?) ?? const []) '$x'] ..explorerPaths = [for (final x in (j['explorerPaths'] as List?) ?? const []) '$x']
..copyMethodFile = _method(j['copyMethodFile']) ..copyMethodFile = _method(j['copyMethodFile'])
..copyConflict = const ['rename', 'overwrite', 'skip'].contains(j['copyConflict']) ? j['copyConflict'] as String : 'ask' ..copyConflict = const ['rename', 'overwrite', 'skip'].contains(j['copyConflict']) ? j['copyConflict'] as String : 'ask'
..copySkipNewer = j['copySkipNewer'] != false
..copyMethodFolder = _method(j['copyMethodFolder']) ..copyMethodFolder = _method(j['copyMethodFolder'])
..rsyncPaths = [for (final x in (j['rsyncPaths'] as List?) ?? const []) '$x'] ..rsyncPaths = [for (final x in (j['rsyncPaths'] as List?) ?? const []) '$x']
..components = j['components'] is List ..components = j['components'] is List

View File

@ -62,6 +62,9 @@ class TransferJob extends ChangeNotifier {
/// 48: 같은 이름이 있을 때 (앱이 직접 복사 · 이동할 때만 - robocopy · rsync 는 그 프로그램의 규칙) /// 48: 같은 이름이 있을 때 (앱이 직접 복사 · 이동할 때만 - robocopy · rsync 는 그 프로그램의 규칙)
final NameConflict conflict; final NameConflict conflict;
/// 229: 앱이 직접 맞출 때 받는 쪽이 더 새것이면 건너뛴다 (환경 설정 - 옵션 칸이 없는 현재 방식 · WebDAV 도). 건너뛴 원본은 이동이라도 남긴다
final bool skipNewer;
/// 같은 이름이라 건너뛴 항목 (원본 경로) /// 같은 이름이라 건너뛴 항목 (원본 경로)
final skipped = <String>[]; final skipped = <String>[];
@ -77,6 +80,7 @@ class TransferJob extends ChangeNotifier {
this.contents = false, this.contents = false,
this.prune = '', this.prune = '',
this.conflict = NameConflict.rename, this.conflict = NameConflict.rename,
this.skipNewer = false,
}); });
/// 지금 복사하는 파일 (원본 기준 상대 경로, rsync 는 이름을 먼저 알리고 그 파일의 진행률을 알린다) /// 지금 복사하는 파일 (원본 기준 상대 경로, rsync 는 이름을 먼저 알리고 그 파일의 진행률을 알린다)
@ -290,7 +294,7 @@ class TransferJob extends ChangeNotifier {
if (contents && (isDav(sources[i]) || FileSystemEntity.isDirectorySync(sources[i]))) { if (contents && (isDav(sources[i]) || FileSystemEntity.isDirectorySync(sources[i]))) {
// 폴더 "안의 것" 맞추기 (rsync 원본/ 대상/ 과 같은 뜻): -u · --delete · 원본 파일 지우기 (--remove-source-files) 도 // 폴더 "안의 것" 맞추기 (rsync 원본/ 대상/ 과 같은 뜻): -u · --delete · 원본 파일 지우기 (--remove-source-files) 도
settled.clear(); settled.clear();
await _ops!.mirror(sources[i], dest, update: _update, delete: _delete, strict: move); await _ops!.mirror(sources[i], dest, update: _update || skipNewer, delete: _delete, strict: move);
if (move && !cancelled) await _deleteSettled(); if (move && !cancelled) await _deleteSettled();
made.add(dest); made.add(dest);
} else { } else {

View File

@ -17,6 +17,9 @@ enum PreviewAction {
/// 받는 쪽에만 있어 지워짐 (--delete) /// 받는 쪽에만 있어 지워짐 (--delete)
delete, delete,
/// 229: 받는 쪽이 더 새것이라 건너뜀 (-u · 환경 설정 "받는 쪽이 더 새것이면 건너뛰기")
skipNewer,
} }
class PreviewItem { class PreviewItem {
@ -28,7 +31,10 @@ class PreviewItem {
final bool toRight; final bool toRight;
final bool isDir; final bool isDir;
final int size; final int size;
const PreviewItem(this.rel, this.action, {required this.toRight, this.isDir = false, this.size = 0}); const PreviewItem(this.rel, this.action, {required this.toRight, this.isDir = false, this.size = 0, this.newer = false});
/// 229: [PreviewAction.update] 인데 받는 쪽이 더 새것 (예전 원본으로 새 파일을 덮어씀)
final bool newer;
} }
/// rsync 의 지우기 옵션인지: --delete · --delete-before/during/delay/after/excluded/missing-args · --del (별칭) /// rsync 의 지우기 옵션인지: --delete · --delete-before/during/delay/after/excluded/missing-args · --del (별칭)
@ -115,9 +121,13 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
if (t == null) { if (t == null) {
out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, size: e.size)); out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, size: e.size));
} else if (t.size != e.size || t.modified.difference(e.modified).inSeconds.abs() > 2) { } else if (t.size != e.size || t.modified.difference(e.modified).inSeconds.abs() > 2) {
// -u: 받는 쪽이 더 새 것이면 건너뜀 // -u: 받는 쪽이 더 새 것이면 건너뜀. 229: 건너뛰는 것도 · 더 새 것을 덮어쓰는 것도 목록에 따로
if (update && t.modified.isAfter(e.modified.add(const Duration(seconds: 2)))) continue; final newer = t.modified.isAfter(e.modified.add(const Duration(seconds: 2)));
out.add(PreviewItem(r, PreviewAction.update, toRight: toRight, size: e.size)); if (update && newer) {
out.add(PreviewItem(r, PreviewAction.skipNewer, toRight: toRight, size: e.size));
continue;
}
out.add(PreviewItem(r, PreviewAction.update, toRight: toRight, size: e.size, newer: newer));
} }
} }
for (final e in there.values) { for (final e in there.values) {

View File

@ -151,6 +151,13 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
onChanged: (v) => c.updateSettings((x) => x.copyConflict = v ?? 'ask'), onChanged: (v) => c.updateSettings((x) => x.copyConflict = v ?? 'ask'),
), ),
), ),
// 229: 폴더 안의 것을 맞출 때 (모니터링의 현재 방식 · WebDAV) - rsync 의 -u 와 같다
SwitchListTile(
value: s.copySkipNewer,
onChanged: (v) => c.updateSettings((x) => x.copySkipNewer = v),
title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')),
subtitle: Text(tr('현재 방식 · WebDAV 로 폴더 안의 것을 맞출 때 (rsync 의 -u 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다')),
),
SettingTile( SettingTile(
title: Text(tr('속도 제한 (KB/s)')), title: Text(tr('속도 제한 (KB/s)')),
subtitle: Text(tr('0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. ' subtitle: Text(tr('0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. '

View File

@ -2163,7 +2163,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
: [ : [
for (final t in tasks) for (final t in tasks)
...await previewSync(t.sources.first, t.dest, ...await previewSync(t.sources.first, t.dest,
toRight: t.sources.first == l, delete: deletes(), update: optionsUpdate(opts(t))), toRight: t.sources.first == l,
delete: deletes(),
// 229: WebDAV 가 끼면 앱이 직접 - 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" 도 따른다
update: optionsUpdate(opts(t)) || (_viaDav([l, r]) && c.settings.copySkipNewer)),
], ],
// 97: 다시 비교하는 동안은 예전 결과로 실행하지 못하게 // 97: 다시 비교하는 동안은 예전 결과로 실행하지 못하게
onStart: () => set(() { onStart: () => set(() {

View File

@ -343,7 +343,7 @@ class _CopyCardState extends State<_CopyCard> {
Future<void> _run(CopyTask t) async { Future<void> _run(CopyTask t) async {
// 104: 지우기가 들어 있으면 Rsync 화면과 같은 미리 보기를 거친다 (그사이 대상에 생긴 파일을 묻지 않고 지우지 않게). // 104: 지우기가 들어 있으면 Rsync 화면과 같은 미리 보기를 거친다 (그사이 대상에 생긴 파일을 묻지 않고 지우지 않게).
// 210: 이동 (원본 지우기) 도 - 카드의 [실행] 은 따로 묻는 창이 없어 곧바로 원본을 지웠다. 복사만은 지금처럼 바로 // 210: 이동 (원본 지우기) 도 - 카드의 [실행] 은 따로 묻는 창이 없어 곧바로 원본을 지웠다. 복사만은 지금처럼 바로
if (!await confirmDeletingRun(context, t, includeMove: true) || !mounted) return; if (!await confirmDeletingRun(context, t, includeMove: true, skipNewer: widget.c.settings.copySkipNewer) || !mounted) return;
widget.c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('모니터링의 [실행]'), t.sources.join(', '), t.dest])); widget.c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('모니터링의 [실행]'), t.sources.join(', '), t.dest]));
String? exe; String? exe;
if (t.method == 'rsync') { if (t.method == 'rsync') {

View File

@ -98,12 +98,15 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
items.where((x) => x.action == a && (right == null || x.toRight == right)).length; items.where((x) => x.action == a && (right == null || x.toRight == right)).length;
final dels = n(PreviewAction.delete); final dels = n(PreviewAction.delete);
final overwrites = n(PreviewAction.update); final overwrites = n(PreviewAction.update);
final overNewer = items.where((x) => x.action == PreviewAction.update && x.newer).length;
final skipped = n(PreviewAction.skipNewer);
final more = items.length >= widget.limit; final more = items.length >= widget.limit;
final parts = [ final parts = [
for (final right in [true, false]) for (final right in [true, false])
if (n(PreviewAction.add, right) + n(PreviewAction.update, right) > 0) if (n(PreviewAction.add, right) + n(PreviewAction.update, right) > 0)
trf('{0} 새로 {1} · 덮어씀 {2}', [right ? '→' : '←', n(PreviewAction.add, right), n(PreviewAction.update, right)]), trf('{0} 새로 {1} · 덮어씀 {2}', [right ? '→' : '←', n(PreviewAction.add, right), n(PreviewAction.update, right)]),
if (n(PreviewAction.onlyTarget) > 0) trf('받는 쪽에만 있음 {0} (그대로 둠)', [n(PreviewAction.onlyTarget)]), if (n(PreviewAction.onlyTarget) > 0) trf('받는 쪽에만 있음 {0} (그대로 둠)', [n(PreviewAction.onlyTarget)]),
if (skipped > 0) trf('받는 쪽이 더 새것 {0} (건너뜀)', [skipped]),
]; ];
return Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ return Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Row(children: [ Row(children: [
@ -122,6 +125,10 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
if (overwrites > 0) if (overwrites > 0)
Text(trf('받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)', [overwrites]), Text(trf('받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)', [overwrites]),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent, fontWeight: FontWeight.w600)), style: const TextStyle(fontSize: 12, color: Colors.orangeAccent, fontWeight: FontWeight.w600)),
// 229: 받는 쪽이 더 새 파일을 예전 원본으로 덮어쓰게 되면 따로 크게 (건너뛰기를 끄거나 rsync 에 -u 가 없을 때)
if (overNewer > 0)
Text(trf('그중 {0}개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다', [overNewer]),
style: const TextStyle(fontSize: 12.5, color: Colors.orangeAccent, fontWeight: FontWeight.w800)),
if (items.isNotEmpty) if (items.isNotEmpty)
Container( Container(
margin: const EdgeInsets.only(top: 6), margin: const EdgeInsets.only(top: 6),
@ -139,7 +146,9 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
Text( Text(
switch (x.action) { switch (x.action) {
PreviewAction.add => '${_arrow(x)} + ${x.rel}${x.isDir ? '/' : ' (${formatSize(x.size)})'}', PreviewAction.add => '${_arrow(x)} + ${x.rel}${x.isDir ? '/' : ' (${formatSize(x.size)})'}',
PreviewAction.update => '${_arrow(x)} ~ ${x.rel} (${formatSize(x.size)}) ${tr('(덮어씀)')}', PreviewAction.update =>
'${_arrow(x)} ~ ${x.rel} (${formatSize(x.size)}) ${x.newer ? tr('(더 새 파일을 덮어씀)') : tr('(덮어씀)')}',
PreviewAction.skipNewer => ' · ${x.rel} ${tr('(받는 쪽이 더 새것 - 건너뜀)')}',
PreviewAction.onlyTarget => ' = ${x.rel}${x.isDir ? '/' : ''} ${tr('(받는 쪽에만 있음)')}', PreviewAction.onlyTarget => ' = ${x.rel}${x.isDir ? '/' : ''} ${tr('(받는 쪽에만 있음)')}',
PreviewAction.delete => '${_arrow(x)} − ${x.rel}${x.isDir ? '/' : ''} ${tr('(지워짐)')}', PreviewAction.delete => '${_arrow(x)} − ${x.rel}${x.isDir ? '/' : ''} ${tr('(지워짐)')}',
}, },
@ -149,7 +158,7 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
color: switch (x.action) { color: switch (x.action) {
PreviewAction.delete => Colors.redAccent, PreviewAction.delete => Colors.redAccent,
PreviewAction.update => Colors.orangeAccent, PreviewAction.update => Colors.orangeAccent,
PreviewAction.onlyTarget => JjColors.textDim, PreviewAction.onlyTarget || PreviewAction.skipNewer => JjColors.textDim,
_ => null, _ => null,
}, },
), ),
@ -173,14 +182,16 @@ bool taskDeletes(CopyTask t) {
/// 104: 지우기가 들어 있는 작업은 어디서 실행하든 (모니터링 [실행] · 옵션 저장 뒤 실행 · 최종 정리) Rsync 화면과 같은 미리 보기를 거친다. /// 104: 지우기가 들어 있는 작업은 어디서 실행하든 (모니터링 [실행] · 옵션 저장 뒤 실행 · 최종 정리) Rsync 화면과 같은 미리 보기를 거친다.
/// 지울 목록이 나온 뒤에만 [실행] 이 켜진다. 지우기가 없으면 묻지 않고 true /// 지울 목록이 나온 뒤에만 [실행] 이 켜진다. 지우기가 없으면 묻지 않고 true
/// [includeMove] (210): 이동 (원본 파일 지우기) 도 미리 보기 · 확인을 거친다 - 모니터링의 [실행] 처럼 따로 묻는 창이 없을 때 /// [includeMove] (210): 이동 (원본 파일 지우기) 도 미리 보기 · 확인을 거친다 - 모니터링의 [실행] 처럼 따로 묻는 창이 없을 때.
Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeMove = false}) async { /// [skipNewer] (229): 앱이 직접 할 때 (현재 방식 · WebDAV) 받는 쪽이 더 새것이면 건너뛰는지 (환경 설정)
Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeMove = false, bool skipNewer = false}) async {
final deletes = taskDeletes(t); final deletes = taskDeletes(t);
final moves = includeMove && t.move; final moves = includeMove && t.move;
if (!deletes && !moves) return true; if (!deletes && !moves) return true;
List<PreviewItem>? preview; List<PreviewItem>? preview;
Object? error; Object? error;
final update = optionsUpdate(t.options); final direct = t.method == 'builtin' || [...t.sources, t.dest].any(isDav);
final update = optionsUpdate(t.options) || (direct && skipNewer);
String dstOf(String src) => t.contents ? t.dest : vJoin(t.dest, vBasename(src)); String dstOf(String src) => t.contents ? t.dest : vJoin(t.dest, vBasename(src));
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,

View File

@ -2,6 +2,8 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/transfer_job.dart'; import 'package:jj_mkvmaker/app/transfer_job.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/sync_tools.dart' show CopyMethod; import 'package:jj_mkvmaker/core/sync_tools.dart' show CopyMethod;
import 'package:jj_mkvmaker/core/webdav.dart'; import 'package:jj_mkvmaker/core/webdav.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -109,6 +111,42 @@ void main() {
// (속성만 다른 "수정됨" 파일은 /IS · /IT 로도 남는다 - 그때는 216 의 "n개는 원본에 남음" 으로 알린다) // (속성만 다른 "수정됨" 파일은 /IS · /IT 로도 남는다 - 그때는 216 의 "n개는 원본에 남음" 으로 알린다)
}); });
test('229: 앱 직접 이동 - 받는 쪽이 더 새것이면 덮어쓰지 않고 원본도 남긴다 (환경 설정 처음 값 켜짐)', () async {
expect(AppSettings.fromJson({}).copySkipNewer, isTrue);
expect(AppSettings.fromJson((AppSettings()..copySkipNewer = false).toJson()).copySkipNewer, isFalse);
final old = File(p.join(src, 'older.txt'))..writeAsStringSync('old in src');
old.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2)));
File(p.join(dst, 'older.txt')).writeAsStringSync('NEW version in dst');
final job = TransferJob(sources: [src], dest: dst, move: true, contents: true, skipNewer: true);
await job.run();
expect(job.error, isNull);
expect(File(p.join(dst, 'older.txt')).readAsStringSync(), 'NEW version in dst', reason: '새 내용 그대로');
expect(old.existsSync(), isTrue, reason: '건너뛴 원본은 이동이라도 남김');
expect(File(p.join(src, 'a.txt')).existsSync(), isFalse, reason: '나머지는 옮김');
expect(job.leftInSource, 1);
expect(job.doneSummary(), contains('1개는 원본에 남음'));
});
test('229: 건너뛰기를 끄면 (사용자가 고름) 예전처럼 덮어쓴다', () async {
final old = File(p.join(src, 'older.txt'))..writeAsStringSync('old in src');
old.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2)));
File(p.join(dst, 'older.txt')).writeAsStringSync('NEW version in dst');
final job = TransferJob(sources: [src], dest: dst, move: false, contents: true, skipNewer: false);
await job.run();
expect(File(p.join(dst, 'older.txt')).readAsStringSync(), 'old in src');
});
test('229: 미리 보기 - 건너뛰면 "받는 쪽이 더 새것 - 건너뜀", 아니면 덮어쓸 줄에 "더 새 것" 표시', () async {
final old = File(p.join(src, 'older.txt'))..writeAsStringSync('old in src');
old.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2)));
File(p.join(dst, 'older.txt')).writeAsStringSync('NEW version in dst');
final skip = await previewSync(src, dst, toRight: true, update: true);
expect([for (final x in skip) if (x.rel == 'older.txt') x.action], [PreviewAction.skipNewer]);
final over = await previewSync(src, dst, toRight: true);
final o = over.firstWhere((x) => x.rel == 'older.txt');
expect([o.action, o.newer], [PreviewAction.update, true]);
});
group('214 b: WebDAV', () { group('214 b: WebDAV', () {
late Directory remote; late Directory remote;
late TestDavServer server; late TestDavServer server;
@ -138,6 +176,20 @@ void main() {
expect(File(p.join(remote.path, 'a.txt')).readAsStringSync(), 'a'); expect(File(p.join(remote.path, 'a.txt')).readAsStringSync(), 'a');
}); });
test('229 (WebDAV 로): 받는 쪽이 더 새것이면 건너뛰고 원본 남김', () async {
HttpOverrides.global = null;
File(p.join(src, 'older.txt'))
..writeAsStringSync('old in src')
..setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2)));
File(p.join(remote.path, 'older.txt')).writeAsStringSync('NEW version in dst');
final job = TransferJob(sources: [src], dest: 'dav://nas/', move: true, contents: true, skipNewer: true);
await job.run();
expect(job.error, isNull);
expect(File(p.join(remote.path, 'older.txt')).readAsStringSync(), 'NEW version in dst');
expect(File(p.join(src, 'older.txt')).existsSync(), isTrue);
expect(File(p.join(src, 'a.txt')).existsSync(), isFalse);
});
test('214 d (WebDAV 로): 로컬 원본의 링크는 따라가지 않는다', () async { test('214 d (WebDAV 로): 로컬 원본의 링크는 따라가지 않는다', () async {
HttpOverrides.global = null; HttpOverrides.global = null;
final outside = Directory(p.join(tmp.path, 'outside2'))..createSync(); final outside = Directory(p.join(tmp.path, 'outside2'))..createSync();

View File

@ -61,7 +61,8 @@ void main() {
test('→ --delete -u: 받는 쪽에만 있는 것은 지워짐 · 받는 쪽이 새것이면 건너뜀', () async { test('→ --delete -u: 받는 쪽에만 있는 것은 지워짐 · 받는 쪽이 새것이면 건너뜀', () async {
final r = m(await previewSync(a, b, toRight: true, delete: true, update: true)); final r = m(await previewSync(a, b, toRight: true, delete: true, update: true));
expect(r['only_b.txt'], PreviewAction.delete); expect(r['only_b.txt'], PreviewAction.delete);
expect(r.containsKey('leftold.txt'), isFalse); // 229: 건너뛰는 것도 목록에 "받는 쪽이 더 새것 - 건너뜀" 으로 (예전에는 빠져 있었다)
expect(r['leftold.txt'], PreviewAction.skipNewer);
expect(r.containsKey('half.mkv.jjpart'), isFalse); expect(r.containsKey('half.mkv.jjpart'), isFalse);
}); });

View File

@ -32,4 +32,22 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.textContaining('덮어씁니다'), findsNothing); expect(find.textContaining('덮어씁니다'), findsNothing);
}); });
testWidgets('229: 받는 쪽이 더 새것 - 건너뛰면 "(건너뜀)", 덮어쓰게 되면 따로 굵게 경고', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: SyncPreviewView(
compute: () async => const [
PreviewItem('skip.txt', PreviewAction.skipNewer, toRight: true, size: 3),
PreviewItem('older.txt', PreviewAction.update, toRight: true, size: 13, newer: true),
],
),
),
));
await tester.pumpAndSettle();
expect(find.textContaining('받는 쪽이 더 새것 1 (건너뜀)'), findsOneWidget);
expect(find.text('그중 1개는 받는 쪽이 더 새것입니다 - 더 새 파일을 예전 것으로 덮어씁니다'), findsOneWidget);
expect(find.textContaining('(더 새 파일을 덮어씀)'), findsOneWidget);
expect(find.textContaining('(받는 쪽이 더 새것 - 건너뜀)'), findsOneWidget);
});
} }