diff --git a/assets/l10n/en.json b/assets/l10n/en.json index abf91fb..877cec2 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -164,6 +164,7 @@ "rsync 를 항목마다 따로 실행하거나, 한 번에 실행 (robocopy 는 늘 폴더마다)": "Run rsync separately for each item, or once for all (robocopy always runs per folder)", "rsync 받는 중": "Downloading rsync", "rsync 실행 파일": "rsync executable", + "rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > Rsync 에서 확인하세요.": "Cannot find the rsync executable. Check Settings > Rsync.", "rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.": "rsync executable not found. Check the path in Settings > File explorer.", "rsync 양쪽 (⇄)": "rsync both ways (⇄)", "rsync 옵션": "rsync options", @@ -298,6 +299,7 @@ "끝 시간이 시작보다 빠른 줄: {0}{1}": "Lines whose end is before their start: {0}{1}", "끝: {0}": "End: {0}", "끝나지 못했습니다: {0}": "Did not finish: {0}", + "끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:": "Afterwards, empty folders in the source are deleted (find source/ -type d -empty -delete). The source folder:", "나가기": "Exit", "나머지는 대기했다가 차례로 받습니다": "The rest wait and download in turn", "나중에": "Later", @@ -390,6 +392,7 @@ "대상 디스크: 알 수 없음": "Target disk: unknown", "대상 폴더 열기": "Open containing folder", "대상 폴더가 없습니다: {0}": "Target folder not found: {0}", + "대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "Files transferred to the target are deleted from the source (move)", "더 보기": "More", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "When JJ_MKVMaker is chosen by double-click · default app (the right-click menu stays)", "더한 언어 (AI 자동 번역)": "Added language (AI auto-translated)", @@ -546,6 +549,10 @@ "비어 있는 폴더": "Empty folder", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.", "비어 있음": "Empty", + "빈 폴더 {0}개 지움": "Deleted {0} empty folders", + "빈 폴더 그대로": "Leave empty folders", + "빈 폴더 지움 · 원본 폴더는 남김": "Delete empty folders · keep the source folder", + "빈 폴더 지움 · 원본 폴더도 지움": "Delete empty folders · delete the source folder too", "사용 안 함": "Off", "사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "Off. Set this if the \"Sign in to confirm you're not a bot\" error appears", "사용: {0}": "Using: {0}", @@ -730,12 +737,17 @@ "원본 비율": "Original aspect", "원본 유지 (재인코딩 없음)": "Keep original (no re-encoding)", "원본 크기": "Original size", + "원본 파일 지우기 (--remove-source-files)": "Delete source files (--remove-source-files)", + "원본 폴더": "Source folder", "원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "When the source folder changes, the target is synced right away (while the app is running). Also syncs once at start.", "원본 폴더가 없습니다": "Source folder not found", "원본과 대상이 서로 안에 있습니다": "Source and target are inside each other", "원본에 없는 것을 대상에서 지우기": "Delete from target what is not in the source", "원본의 {0}배": "{0}× original", + "원본의 빈 폴더 정리 중…": "Cleaning up empty folders in the source…", + "원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.": "Moves all files from the source to the target (deleting them from the source), then deletes the empty folders left in the source.", "원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "When the source changes, the target is synced. With a schedule, sync happens only in that time; changes outside it are collected and synced at the next start time.", + "원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.": "The source will be empty, so this live sync is turned off. See progress in the [Copy · rsync] tab.", "원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}": "Source not found (deleted or moved elsewhere): {0}", "원어 (영상 속 언어)": "Source language (spoken in the video)", "원어({0})와 같은 언어로는 번역하지 않습니다.": "Will not translate into the same language as the source ({0}).", @@ -966,6 +978,7 @@ "즐겨찾기 표시줄": "Bookmarks bar", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "The running copy uses the previous options. Stop it and run again with the new options?", "지금 맞추기": "Sync now", + "지금 맞추는 중입니다. 끝난 뒤에 하세요.": "Syncing right now. Try again when it finishes.", "지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "{0} for the video you are viewing (check several in the list)", "지금 설치": "Install now", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "Current version: v{0} → new version: v{1}", @@ -1011,6 +1024,7 @@ "최신 버전 확인": "Check for updates", "최신 버전을 확인할 수 없습니다 ({0})": "Cannot check the latest version ({0})", "최신 버전입니다 (v{0})": "You have the latest version (v{0})", + "최종 정리": "Final cleanup", "추가": "Add", "추가 ({0})": "Add ({0})", "추가 취소": "Cancel adding", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 7500d65..0edef04 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -164,6 +164,7 @@ "rsync 를 항목마다 따로 실행하거나, 한 번에 실행 (robocopy 는 늘 폴더마다)": "rsync を項目ごとに実行するか、まとめて 1 回で実行 (robocopy は常にフォルダーごと)", "rsync 받는 중": "rsync をダウンロード中", "rsync 실행 파일": "rsync 実行ファイル", + "rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > Rsync 에서 확인하세요.": "rsync 実行ファイルが見つかりません。環境設定 > Rsync を確認してください。", "rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.": "rsync 実行ファイルが見つかりません。環境設定 > ファイルエクスプローラーでパスを確認してください。", "rsync 양쪽 (⇄)": "rsync 双方向 (⇄)", "rsync 옵션": "rsync オプション", @@ -298,6 +299,7 @@ "끝 시간이 시작보다 빠른 줄: {0}{1}": "終了時間が開始より早い行: {0}{1}", "끝: {0}": "終了: {0}", "끝나지 못했습니다: {0}": "完了できませんでした: {0}", + "끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:": "終了後、元の空フォルダーを削除します (find 元/ -type d -empty -delete)。元フォルダーは:", "나가기": "終了する", "나머지는 대기했다가 차례로 받습니다": "残りは待機して順番にダウンロードします", "나중에": "後で", @@ -390,6 +392,7 @@ "대상 디스크: 알 수 없음": "対象ディスク: 不明", "대상 폴더 열기": "対象フォルダーを開く", "대상 폴더가 없습니다: {0}": "対象フォルダーがありません: {0}", + "대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "対象へ移したファイルを元から削除します (移動)", "더 보기": "その他", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "ダブルクリック・既定のアプリで JJ_MKVMaker を選んだとき(右クリックメニューはそのまま)", "더한 언어 (AI 자동 번역)": "追加した言語(AI 自動翻訳)", @@ -546,6 +549,10 @@ "비어 있는 폴더": "空のフォルダー", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。", "비어 있음": "空", + "빈 폴더 {0}개 지움": "空フォルダー {0} 個を削除", + "빈 폴더 그대로": "空フォルダーはそのまま", + "빈 폴더 지움 · 원본 폴더는 남김": "空フォルダーを削除 · 元フォルダーは残す", + "빈 폴더 지움 · 원본 폴더도 지움": "空フォルダーを削除 · 元フォルダーも削除", "사용 안 함": "使用しない", "사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "使用しない。「Sign in to confirm you're not a bot」エラーが出たら設定してください", "사용: {0}": "使用: {0}", @@ -730,12 +737,17 @@ "원본 비율": "元の比率", "원본 유지 (재인코딩 없음)": "元のまま(再エンコードなし)", "원본 크기": "元のサイズ", + "원본 파일 지우기 (--remove-source-files)": "元ファイルを削除 (--remove-source-files)", + "원본 폴더": "元フォルダー", "원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "元フォルダーが変わるとすぐ対象フォルダーに合わせます (アプリの起動中)。起動時にも 1 回合わせます。", "원본 폴더가 없습니다": "元フォルダーがありません", "원본과 대상이 서로 안에 있습니다": "元と対象が互いの中にあります", "원본에 없는 것을 대상에서 지우기": "元にないものを対象から削除", "원본의 {0}배": "元の {0}倍", + "원본의 빈 폴더 정리 중…": "元の空フォルダーを整理中…", + "원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.": "元のファイルをすべて対象へ移し (元から削除)、元に残った空フォルダーを削除します。", "원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "元が変わると対象に合わせます。予定を決めるとその時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。", + "원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.": "元が空になるため、このリアルタイム同期はオフにします。進捗は [コピー · rsync] タブで確認します。", "원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}": "元がありません (他の場所で削除または移動されました): {0}", "원어 (영상 속 언어)": "原語(動画内の言語)", "원어({0})와 같은 언어로는 번역하지 않습니다.": "原語({0})と同じ言語には翻訳しません。", @@ -966,6 +978,7 @@ "즐겨찾기 표시줄": "ブックマークバー", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "実行中のコピーは前のオプションで動いています。止めて新しいオプションで再実行しますか?", "지금 맞추기": "今すぐ同期", + "지금 맞추는 중입니다. 끝난 뒤에 하세요.": "同期中です。終わってから行ってください。", "지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "今見ている動画の{0}(複数はリストでチェック)", "지금 설치": "今すぐインストール", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}", @@ -1011,6 +1024,7 @@ "최신 버전 확인": "最新バージョンを確認", "최신 버전을 확인할 수 없습니다 ({0})": "最新バージョンを確認できません({0})", "최신 버전입니다 (v{0})": "最新バージョンです(v{0})", + "최종 정리": "最終整理", "추가": "追加", "추가 ({0})": "追加({0})", "추가 취소": "追加をキャンセル", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index a09fbf7..02c9859 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -164,6 +164,7 @@ "rsync 를 항목마다 따로 실행하거나, 한 번에 실행 (robocopy 는 늘 폴더마다)": "rsync 按项目分别执行,或一次执行 (robocopy 总是按文件夹)", "rsync 받는 중": "正在下载 rsync", "rsync 실행 파일": "rsync 可执行文件", + "rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > Rsync 에서 확인하세요.": "找不到 rsync 可执行文件。请检查 设置 > Rsync。", "rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.": "找不到 rsync 可执行文件。请在 设置 > 文件浏览器 中检查路径。", "rsync 양쪽 (⇄)": "rsync 双向 (⇄)", "rsync 옵션": "rsync 选项", @@ -298,6 +299,7 @@ "끝 시간이 시작보다 빠른 줄: {0}{1}": "结束时间早于开始时间的行: {0}{1}", "끝: {0}": "结束: {0}", "끝나지 못했습니다: {0}": "未能完成: {0}", + "끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:": "完成后删除源中的空文件夹 (find 源/ -type d -empty -delete)。源文件夹:", "나가기": "退出", "나머지는 대기했다가 차례로 받습니다": "其余的等待并依次下载", "나중에": "以后再说", @@ -390,6 +392,7 @@ "대상 디스크: 알 수 없음": "目标磁盘: 未知", "대상 폴더 열기": "打开所在文件夹", "대상 폴더가 없습니다: {0}": "目标文件夹不存在: {0}", + "대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "已传到目标的文件从源中删除 (移动)", "더 보기": "更多", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "双击 · 默认打开方式选择 JJ_MKVMaker 时(右键菜单不变)", "더한 언어 (AI 자동 번역)": "已添加的语言(AI 自动翻译)", @@ -546,6 +549,10 @@ "비어 있는 폴더": "空文件夹", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。", "비어 있음": "空", + "빈 폴더 {0}개 지움": "已删除 {0} 个空文件夹", + "빈 폴더 그대로": "保留空文件夹", + "빈 폴더 지움 · 원본 폴더는 남김": "删除空文件夹 · 保留源文件夹", + "빈 폴더 지움 · 원본 폴더도 지움": "删除空文件夹 · 源文件夹也删除", "사용 안 함": "不使用", "사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "不使用。如出现“Sign in to confirm you're not a bot”错误请设置", "사용: {0}": "使用: {0}", @@ -730,12 +737,17 @@ "원본 비율": "原始比例", "원본 유지 (재인코딩 없음)": "保持原样(不重新编码)", "원본 크기": "原始尺寸", + "원본 파일 지우기 (--remove-source-files)": "删除源文件 (--remove-source-files)", + "원본 폴더": "源文件夹", "원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "源文件夹变化时立即同步到目标 (应用运行期间)。启动时也同步一次。", "원본 폴더가 없습니다": "源文件夹不存在", "원본과 대상이 서로 안에 있습니다": "源与目标互相包含", "원본에 없는 것을 대상에서 지우기": "从目标中删除源中没有的内容", "원본의 {0}배": "原始的 {0} 倍", + "원본의 빈 폴더 정리 중…": "正在清理源中的空文件夹…", + "원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.": "将源中的所有文件移到目标 (从源中删除),然后删除源中剩下的空文件夹。", "원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "源变化时同步到目标。设定计划后只在该时间同步,其他时间的变化会累积到下次开始时间再同步。", + "원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.": "源将变空,因此关闭此实时同步。进度请在 [复制 · rsync] 标签中查看。", "원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}": "源不存在 (已在别处删除或移动): {0}", "원어 (영상 속 언어)": "原语言(视频中的语言)", "원어({0})와 같은 언어로는 번역하지 않습니다.": "不会翻译成与原语言({0})相同的语言。", @@ -966,6 +978,7 @@ "즐겨찾기 표시줄": "书签栏", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "正在运行的复制使用的是之前的选项。停止并用新选项重新运行吗?", "지금 맞추기": "立即同步", + "지금 맞추는 중입니다. 끝난 뒤에 하세요.": "正在同步。请等结束后再进行。", "지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "当前查看视频的{0}(多个请在列表中勾选)", "지금 설치": "立即安装", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}", @@ -1011,6 +1024,7 @@ "최신 버전 확인": "检查最新版本", "최신 버전을 확인할 수 없습니다 ({0})": "无法检查最新版本({0})", "최신 버전입니다 (v{0})": "已是最新版本(v{0})", + "최종 정리": "最终整理", "추가": "添加", "추가 ({0})": "添加({0})", "추가 취소": "取消添加", diff --git a/integration_test/rsync_page_test.dart b/integration_test/rsync_page_test.dart index 56e0c64..ff95e49 100644 --- a/integration_test/rsync_page_test.dart +++ b/integration_test/rsync_page_test.dart @@ -7,8 +7,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_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/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/monitor_page.dart'; import 'package:jj_mkvmaker/ui/theme.dart'; import 'package:path/path.dart' as p; @@ -103,4 +105,96 @@ void main() { tester.view.reset(); tmp.deleteSync(recursive: true); }); + + testWidgets('원본 파일 지우기 (--remove-source-files) · 빈 폴더 정리 · lsync 최종 정리', (tester) async { + final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path; + final tmp = Directory.systemTemp.createTempSync('jj_rsyncmove_'); + final a = p.join(tmp.path, 'srcA'), b = p.join(tmp.path, 'dstB'); + final cDir = p.join(tmp.path, 'liveC'), d = p.join(tmp.path, 'liveD'); + for (final (dir, name) in [(a, 'x'), (cDir, 'y')]) { + File(p.join(dir, 'sub', 'deep', '$name.txt')) + ..createSync(recursive: true) + ..writeAsStringSync(name); + Directory(p.join(dir, 'empty')).createSync(); + } + Directory(b).createSync(); + Directory(d).createSync(); + await tester.runAsync(findBundledRsync); + final c = AppController(PlatformServices.create()); + c.settings + ..rsyncPaths = [tmp.path, tmp.path] + ..liveSyncPairs = [LiveSyncPair(cDir, d, method: 'rsync', delete: true)]; + final key = GlobalKey(); + tester.view.physicalSize = const Size(1500, 900); + tester.view.devicePixelRatio = 1; + Future wait(int ms) async { + for (var i = 0; i < ms ~/ 100; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await tester.pump(); + } + } + + Future shot(String name) async { + final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await tester.runAsync(() => boundary.toImage()); + final bytes = await tester.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png)); + File('$out/$name.png').writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + Future tap(Finder f) async { + await tester.tap(f); + await wait(600); + } + + // ── Rsync 화면: 좌 → 우 + 원본 파일 지우기 + 원본 폴더도 지움 + await tester.pumpWidget(MaterialApp( + theme: buildTheme(), + builder: (_, child) => RepaintBoundary(key: key, child: child), + home: ExplorerPage(c: c, rsync: true), + )); + await wait(2500); + await tap(find.text('srcA').first); + await tap(find.text('dstB').last); + await tap(find.text('좌 → 우')); + await tap(find.text('원본 파일 지우기 (--remove-source-files)')); + await tap(find.text('빈 폴더 지움 · 원본 폴더도 지움')); + await shot('rsync_move_confirm'); + await tap(find.widgetWithText(FilledButton, '이동')); + for (var i = 0; i < 100 && Directory(a).existsSync(); i++) { + await wait(200); + } + await wait(1500); + expect(File(p.join(b, 'sub', 'deep', 'x.txt')).readAsStringSync(), 'x'); + expect(Directory(p.join(b, 'empty')).existsSync(), isTrue); // 빈 폴더도 대상에는 만들어짐 (rsync -a) + expect(Directory(a).existsSync(), isFalse, reason: '원본 폴더까지 지움'); + final moved = c.settings.copyTasks.single; + expect([moved.move, moved.prune, moved.lastResult], [true, 'all', 'done']); + expect(moved.lastMessage, '빈 폴더 4개 지움'); // sub/deep · sub · empty · srcA + + // ── lsync 최종 정리: 원본 폴더는 남김 (기본) + await tester.pumpWidget(MaterialApp( + theme: buildTheme(), + builder: (_, child) => RepaintBoundary(key: key, child: child), + home: MonitorPage(c: c, initialTab: 1), + )); + await wait(1500); + await tap(find.text('최종 정리').first); + await shot('lsync_finalize_confirm'); + expect(find.textContaining('rsync -avHPOg --remove-source-files'), findsOneWidget); + await tap(find.widgetWithText(FilledButton, '최종 정리')); + for (var i = 0; i < 100 && File(p.join(cDir, 'sub', 'deep', 'y.txt')).existsSync(); i++) { + await wait(200); + } + await wait(2500); + await shot('lsync_finalize_done'); + expect(File(p.join(d, 'sub', 'deep', 'y.txt')).readAsStringSync(), 'y'); + expect(Directory(cDir).existsSync(), isTrue, reason: '원본 폴더는 남김'); + expect(Directory(cDir).listSync(), isEmpty, reason: '빈 폴더는 모두 지움'); + expect(c.settings.liveSyncPairs.single.enabled, isFalse, reason: '원본이 비었으므로 실시간 동기화는 끔'); + final fin = c.settings.copyTasks.firstWhere((t) => t.sources.single == cDir); + expect([fin.options, fin.move, fin.prune, fin.lastResult], ['-avHPOg', true, 'keep', 'done']); + expect(fin.lastMessage, '빈 폴더 3개 지움'); // sub/deep · sub · empty (liveC 는 남김) + tester.view.reset(); + tmp.deleteSync(recursive: true); + }); } diff --git a/lib/app/copy_center.dart b/lib/app/copy_center.dart index df49093..0400692 100644 --- a/lib/app/copy_center.dart +++ b/lib/app/copy_center.dart @@ -106,6 +106,7 @@ class CopyCenter extends ChangeNotifier { bandwidthKBps: t.bandwidthKBps, rsyncExe: exe, contents: t.contents, + prune: t.move ? t.prune : '', ); jobs[t.id] = job; job.addListener(notifyListeners); @@ -122,7 +123,7 @@ class CopyCenter extends ChangeNotifier { await update(cur.copyWith( lastRun: DateTime.now().toIso8601String(), lastResult: e == null ? 'done' : e is FileOpCancelled ? 'cancelled' : 'failed', - lastMessage: e == null ? '' : '$e', + lastMessage: e == null ? (job.pruned > 0 ? trf('빈 폴더 {0}개 지움', [job.pruned]) : '') : '$e', lastFiles: job.allDone, )); } diff --git a/lib/app/settings.dart b/lib/app/settings.dart index d658032..52d1bb9 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -87,6 +87,10 @@ class CopyTask { final String lastMessage; final int lastFiles; + /// 이동 ([move], rsync --remove-source-files) 뒤 원본 정리: '' 그대로 · 'keep' 빈 폴더를 지우고 원본 폴더는 남김 · + /// 'all' 원본 폴더까지 (비었으면) 지움 (find 원본/ -type d -empty -delete) + final String prune; + const CopyTask({ required this.id, required this.sources, @@ -101,6 +105,7 @@ class CopyTask { this.lastResult = '', this.lastMessage = '', this.lastFiles = 0, + this.prune = '', }); /// 같은 복사인지 (원본들 · 대상 · 이동) @@ -116,6 +121,7 @@ class CopyTask { String? lastResult, String? lastMessage, int? lastFiles, + String? prune, }) => CopyTask( id: id, @@ -131,6 +137,7 @@ class CopyTask { lastResult: lastResult ?? this.lastResult, lastMessage: lastMessage ?? this.lastMessage, lastFiles: lastFiles ?? this.lastFiles, + prune: prune ?? this.prune, ); Map toJson() => { @@ -147,6 +154,7 @@ class CopyTask { 'lastResult': lastResult, 'lastMessage': lastMessage, 'lastFiles': lastFiles, + 'prune': prune, }; factory CopyTask.fromJson(Map j) => CopyTask( @@ -163,6 +171,7 @@ class CopyTask { lastResult: j['lastResult'] as String? ?? '', lastMessage: j['lastMessage'] as String? ?? '', lastFiles: (j['lastFiles'] as num?)?.toInt() ?? 0, + prune: const ['keep', 'all'].contains(j['prune']) ? j['prune'] as String : '', ); } diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart index 7ae4e30..19b5393 100644 --- a/lib/app/transfer_job.dart +++ b/lib/app/transfer_job.dart @@ -30,6 +30,9 @@ class TransferJob extends ChangeNotifier { /// 폴더 "안의 것" 을 [dest] 에 맞추기 (rsync 원본/ → 대상/, 현재 방식은 바뀐 것만). lsync 에서 옮겨 온 복사. final bool contents; + /// 이동 뒤 원본 정리: '' 안 함 · 'keep' 빈 폴더 지움 (원본 폴더는 남김) · 'all' 원본 폴더까지 + final String prune; + TransferJob({ required this.sources, required this.dest, @@ -40,8 +43,13 @@ class TransferJob extends ChangeNotifier { this.bandwidthKBps = 0, this.rsyncExe, this.contents = false, + this.prune = '', }); + /// 원본 정리 중 · 지운 빈 폴더 수 + bool pruning = false; + int pruned = 0; + // ── 진행 상태 ── int index = 0; // 지금 항목 int get total => sources.length; @@ -124,6 +132,14 @@ class TransferJob extends ChangeNotifier { await _runRobocopy(); } if (cancelled) throw const FileOpCancelled(); + // 옮긴 뒤 원본에 남은 빈 폴더 정리 (find 원본/ -type d -empty -delete) + if (move && prune.isNotEmpty) { + pruning = true; + _tick(); + for (final s in sources) { + if (FileSystemEntity.isDirectorySync(s)) pruned += await removeEmptyDirs(s, keepRoot: prune == 'keep'); + } + } } catch (e) { error = cancelled ? const FileOpCancelled() : e; } finally { @@ -213,8 +229,9 @@ class TransferJob extends ChangeNotifier { for (final i in idx) { filesDone[i] = filesTotal[i]; made.add(p.join(dest, p.basename(sources[i]))); - // 이동: rsync 는 파일만 지우므로 빈 폴더를 정리 - if (move && FileSystemEntity.isDirectorySync(sources[i]) && await countFiles(sources[i]) == 0) { + // 폴더째 이동 (안의 것 [contents] 이 아니고 원본 정리 [prune] 를 고르지 않음): rsync 는 파일만 지우므로 + // 비어 남은 원본 폴더를 지운다. 원본 정리를 고른 이동은 끝에서 그 고른 대로 ([prune]). + if (move && !contents && prune.isEmpty && FileSystemEntity.isDirectorySync(sources[i]) && await countFiles(sources[i]) == 0) { await Directory(sources[i]).delete(recursive: true); } } diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index 5c43ef9..4c7f4d5 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -106,6 +106,39 @@ String? transferProblem(List sources, String dest, {required bool move}) return null; } +/// [root] 안의 빈 폴더를 안쪽부터 지운다 (find root/ -type d -empty -delete 와 같음). 지운 폴더 수. +/// [keepRoot] 면 [root] 자신은 비어도 남긴다. 파일 · 링크가 하나라도 있는 폴더는 그대로. +Future removeEmptyDirs(String root, {bool keepRoot = true}) async { + var n = 0; + Future walk(Directory d) async { + var empty = true; + final items = await d.list(followLinks: false).handleError((_) => empty = false).toList(); + for (final e in items) { + if (e is Directory && await walk(e)) { + try { + await e.delete(); + n++; + } catch (_) { + empty = false; + } + } else { + empty = false; + } + } + return empty; + } + + final d = Directory(root); + if (!await d.exists()) return 0; + if (await walk(d) && !keepRoot) { + try { + await d.delete(); + n++; + } catch (_) {} + } + return n; +} + /// 복사 · 이동 중 알림 (지금 파일, 지금까지 바이트, 전체 바이트) typedef CopyProgress = void Function(String current, int done, int total); diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 569c6ea..7e7e8dc 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -953,10 +953,13 @@ class _ExplorerPageState extends State { // 확인 창에는 기억한 옵션 (없으면 지금 설정) 을 보여 주고, 실행할 때 모니터링에 기억한다 var tasks = [for (final (src, dst) in runs) center.peek([src], dst, contents: true, method: 'rsync')]; String opts(CopyTask t) => both ? withUpdateOption(t.options) : t.options; + // 한 방향 (→ · ←) 만: 원본 파일 지우기 (--remove-source-files) 와 끝난 뒤 원본 정리 + var removeSource = false; + var prune = 'keep'; if (!mounted) return; final go = await showDialog( context: context, - builder: (ctx) => AlertDialog( + builder: (ctx) => StatefulBuilder(builder: (ctx, set) => AlertDialog( title: Text(both ? tr('rsync 양쪽 (⇄)') : 'rsync'), content: SizedBox( width: 560, @@ -964,7 +967,8 @@ class _ExplorerPageState extends State { for (final t in tasks) ...[ Text('${t.sources.first}/', style: const TextStyle(fontWeight: FontWeight.w600)), Text('→ ${t.dest}/', style: const TextStyle(fontWeight: FontWeight.w600)), - Text('rsync ${opts(t)}${t.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [t.bandwidthKBps])}' : ''}', + Text('rsync ${opts(t)}${removeSource ? ' --remove-source-files' : ''}' + '${t.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [t.bandwidthKBps])}' : ''}', style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)), const SizedBox(height: 10), ], @@ -972,19 +976,42 @@ class _ExplorerPageState extends State { style: const TextStyle(fontSize: 12, color: JjColors.textDim)), if (both) Text(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).'), - style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)) + else ...[ + CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: removeSource, + onChanged: (v) => set(() => removeSource = v ?? false), + title: Text(tr('원본 파일 지우기 (--remove-source-files)')), + subtitle: Text(tr('대상으로 옮긴 파일을 원본에서 지웁니다 (이동)')), + ), + if (removeSource) ...[ + Text(tr('끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:'), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 6), + PruneChoice(value: prune, onChanged: (v) => set(() => prune = v)), + ], + ], ]), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('실행'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(removeSource ? tr('이동') : tr('실행'))), ], - ), + )), ); if (go != true || !mounted) return; final exe = await _ensureRsync(); if (exe == null || !mounted) return; - tasks = [for (final (src, dst) in runs) await center.remember([src], dst, contents: true, method: 'rsync')]; + tasks = [ + for (final (src, dst) in runs) await center.remember([src], dst, contents: true, method: 'rsync', move: removeSource), + ]; + if (removeSource) { + tasks = [for (final t in tasks) t.copyWith(prune: prune)]; + for (final t in tasks) { + await center.update(t); + } + } final jobs = []; for (final t in tasks) { final j = await center.start(t.copyWith(options: opts(t)), rsyncExe: exe); @@ -994,7 +1021,11 @@ class _ExplorerPageState extends State { setState(() => _jobs = jobs); await Future.wait(jobs.map((j) => j.done)); if (!mounted) return; - await _refreshAll([l, r]); + // 원본 폴더까지 지웠으면 고른 표시도 뺀다 + for (final x in _panes) { + x.marked.removeWhere((m) => FileSystemEntity.typeSync(m) == FileSystemEntityType.notFound); + } + await _refreshAll([l, r, p.dirname(l), p.dirname(r)]); for (final x in _panes) { x.changed(); } @@ -1091,7 +1122,11 @@ class _ExplorerPageState extends State { '${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}', ), line( - job.counting ? tr('파일 세는 중…') : trf('{0} 안의 파일', [job.currentName]), + job.counting + ? tr('파일 세는 중…') + : job.pruning + ? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…')) + : trf('{0} 안의 파일', [job.currentName]), job.current, '${pct(job.current)} · ${trf('파일 {0}/{1}', [curDone, curFiles])}', ), diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index d506c49..af5f171 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -144,7 +144,13 @@ class TransferProgressLines extends StatelessWidget { return Column(children: [ line(trf('전체 {0}개 중 {1}번째', [job.total, cur + 1]), job.overall, '${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}'), - line(job.counting ? tr('파일 세는 중…') : trf('{0} 안의 파일', [p.basename(job.sources[cur])]), job.current, + line( + job.counting + ? tr('파일 세는 중…') + : job.pruning + ? (job.finished ? trf('빈 폴더 {0}개 지움', [job.pruned]) : tr('원본의 빈 폴더 정리 중…')) + : trf('{0} 안의 파일', [p.basename(job.sources[cur])]), + job.current, '${pct(job.current)} · ${trf('파일 {0}/{1}', [job.filesDone[cur], job.filesTotal[cur]])}'), ]); }, @@ -179,6 +185,27 @@ class _CopyTab extends StatelessWidget { } } +/// 이동 (rsync --remove-source-files) 뒤 원본 정리 이름 +String pruneLabel(String v) => switch (v) { + 'keep' => tr('빈 폴더 지움 · 원본 폴더는 남김'), + 'all' => tr('빈 폴더 지움 · 원본 폴더도 지움'), + _ => tr('빈 폴더 그대로'), + }; + +/// 원본 정리 고르기: 원본 폴더를 남길지 · 지울지 (빈 폴더는 지움 = find 원본/ -type d -empty -delete) +class PruneChoice extends StatelessWidget { + final String value; + final ValueChanged onChanged; + final bool allowNone; + const PruneChoice({super.key, required this.value, required this.onChanged, this.allowNone = false}); + + @override + Widget build(BuildContext context) => Wrap(spacing: 6, runSpacing: 6, children: [ + for (final v in [if (allowNone) '', 'keep', 'all']) + ChoiceChip(label: Text(pruneLabel(v)), selected: value == v, onSelected: (_) => onChanged(v)), + ]); +} + class _CopyCard extends StatefulWidget { final AppController c; final CopyTask task; @@ -194,8 +221,10 @@ class _CopyCardState extends State<_CopyCard> { late final _options = TextEditingController(text: widget.task.options); late final _bw = TextEditingController(text: '${widget.task.bandwidthKBps}'); late bool _once = widget.task.once; + late String _prune = widget.task.prune; bool get _dirty => + _prune != widget.task.prune || _method != widget.task.method || _options.text.trim() != widget.task.options || (int.tryParse(_bw.text) ?? 0) != widget.task.bandwidthKBps || @@ -234,6 +263,7 @@ class _CopyCardState extends State<_CopyCard> { options: _options.text.trim(), bandwidthKBps: int.tryParse(_bw.text) ?? 0, once: _once, + prune: _prune, ); await center.update(t); if (!mounted) return; @@ -366,6 +396,13 @@ class _CopyCardState extends State<_CopyCard> { ), if (t.sources.length > 1 && _method == 'rsync') FilterChip(label: Text(tr('한 번에')), selected: _once, onSelected: (v) => setState(() => _once = v)), + // 이동 (원본 파일 지우기) 이면: 끝난 뒤 원본의 빈 폴더 · 원본 폴더 정리 + if (t.move && _method == 'rsync') + DropdownButton( + value: _prune, + items: [for (final v in ['', 'keep', 'all']) DropdownMenuItem(value: v, child: Text(pruneLabel(v)))], + onChanged: (v) => setState(() => _prune = v!), + ), if (_dirty) FilledButton.tonal(onPressed: _save, child: Text(tr('저장'))), ]), ), @@ -427,6 +464,71 @@ class _LiveTabState extends State<_LiveTab> { for (final y in x.liveSyncPairs) y.source == old.source && y.target == old.target ? now : y, ]); + /// 최종 정리: 원본의 것을 모두 대상으로 옮기고 (rsync -avHPOg --remove-source-files 원본/ 대상/) + /// 빈 폴더를 지운다 (find 원본/ -type d -empty -delete). 원본 폴더를 남길지 · 지울지만 고른다. + /// 원본이 비므로 이 실시간 동기화는 끈다 (켜 둔 채 "지우기 포함" 이면 대상까지 비워 버린다). + Future _finalize(LiveSyncPair x) async { + final live = LiveSync.instance; + final messenger = ScaffoldMessenger.of(context); + if (live?.isRunning(x) ?? false) { + messenger.showSnackBar(SnackBar(content: Text(tr('지금 맞추는 중입니다. 끝난 뒤에 하세요.')))); + return; + } + var prune = 'keep'; + final go = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + title: Text(tr('최종 정리')), + content: SizedBox( + width: 600, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(tr('원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.')), + const SizedBox(height: 10), + SelectableText( + 'rsync -avHPOg --remove-source-files ${x.source}/ ${x.target}/\n' + 'find ${x.source}/ -type d -empty -delete', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 14), + Text(tr('원본 폴더'), style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 6), + PruneChoice(value: prune, onChanged: (v) => set(() => prune = v)), + const SizedBox(height: 12), + Text(tr('원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.'), + style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('최종 정리'))), + ], + ), + ), + ); + if (go != true || !mounted) return; + var exe = await rsyncExecutable(c.settings); + if (exe == null && Platform.isWindows && c.settings.rsyncSource == 'download' && mounted) { + exe = await installRsyncWithDialog(context); + } + if (exe == null) { + messenger.showSnackBar(SnackBar(content: Text(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > Rsync 에서 확인하세요.')))); + return; + } + await _replace(x, x.copyWith(enabled: false)); + final center = CopyCenter.of(c); + var t = await center.remember([x.source], x.target, move: true, contents: true, method: 'rsync'); + // 처음이면 정해진 옵션 (모니터링에서 고친 것이 있으면 그것) + t = t.copyWith(prune: prune, options: t.lastRun.isEmpty ? '-avHPOg' : null); + await center.update(t); + final job = await center.start(t, rsyncExe: exe); + if (!mounted) return; + if (job == null) { + messenger.showSnackBar(SnackBar(content: Text(center.tasks.firstWhere((y) => y.id == t.id).lastMessage))); + return; + } + DefaultTabController.of(context).animateTo(0); + } + @override Widget build(BuildContext context) { final live = LiveSync.instance; @@ -591,6 +693,11 @@ class _LiveTabState extends State<_LiveTab> { label: Text(tr('지금 맞추기')), ), const SizedBox(width: 6), + TextButton.icon( + onPressed: running ? null : () => _finalize(x), + icon: const Icon(Icons.cleaning_services_outlined, size: 18), + label: Text(tr('최종 정리')), + ), TextButton.icon( onPressed: () async { await CopyCenter.of(c).fromLiveSync(x); diff --git a/test/explorer_test.dart b/test/explorer_test.dart index 4d8ec34..c0054ef 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -333,12 +333,20 @@ void main() { await tester.tap(find.text('취소')); await tester.pumpAndSettle(); expect(c.settings.copyTasks, isEmpty); - // → 는 한 방향 · -u 없이 + expect(find.text('원본 파일 지우기 (--remove-source-files)'), findsNothing); // 양쪽에는 없음 + // → 는 한 방향 · -u 없이 · 원본 파일 지우기를 고를 수 있다 (고르면 원본 폴더 남김 / 지움) await act(tester, () => tester.tap(find.text('좌 → 우'))); await tester.pumpAndSettle(); expect(find.text('→ ${p.join(right, 'rsub')}/'), findsOneWidget); expect(find.text('→ ${p.join(left, 'sub')}/'), findsNothing); expect(find.textContaining('-u'), findsNothing); + expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsNothing); + await tester.tap(find.text('원본 파일 지우기 (--remove-source-files)')); + await tester.pumpAndSettle(); + expect(find.textContaining('-avPog --remove-source-files'), findsOneWidget); + expect(find.text('빈 폴더 지움 · 원본 폴더는 남김'), findsOneWidget); + expect(find.text('빈 폴더 지움 · 원본 폴더도 지움'), findsOneWidget); + expect(find.widgetWithText(FilledButton, '이동'), findsOneWidget); await tester.tap(find.text('취소')); await tester.pumpAndSettle(); // 고른 폴더는 기억 (다음에 열 때 그 폴더부터) diff --git a/test/file_ops_test.dart b/test/file_ops_test.dart index ddc4789..f6ce0e1 100644 --- a/test/file_ops_test.dart +++ b/test/file_ops_test.dart @@ -76,6 +76,22 @@ void main() { expect(b.existsSync(), isFalse); }); + test('빈 폴더 지우기 (find -type d -empty -delete): 안쪽부터 · 파일 있는 폴더는 그대로 · 원본 폴더 남김 / 지움', () async { + final root = p.join(tmp.path, 'src'); + Directory(p.join(root, 'a', 'b', 'c')).createSync(recursive: true); + Directory(p.join(root, 'empty')).createSync(); + file('src/keep/x.txt'); + expect(await removeEmptyDirs(root), 4); // a/b/c, a/b, a, empty + expect(Directory(p.join(root, 'keep')).existsSync(), isTrue); + expect(Directory(p.join(root, 'a')).existsSync(), isFalse); + File(p.join(root, 'keep', 'x.txt')).deleteSync(); + expect(await removeEmptyDirs(root), 1); // keep (원본 폴더는 남김) + expect(Directory(root).existsSync(), isTrue); + expect(await removeEmptyDirs(root, keepRoot: false), 1); // 원본 폴더도 + expect(Directory(root).existsSync(), isFalse); + expect(await removeEmptyDirs(root), 0); // 없으면 아무것도 + }); + test('시작 전 확인: 없는 원본 · 없는 대상 · 자기 안으로 · 같은 폴더로 이동', () { final a = Directory(p.join(tmp.path, 'a'))..createSync(); final inner = Directory(p.join(a.path, 'inner'))..createSync(); diff --git a/test/monitor_test.dart b/test/monitor_test.dart index 4582852..bc789a3 100644 --- a/test/monitor_test.dart +++ b/test/monitor_test.dart @@ -102,6 +102,14 @@ void main() { expect(Directory(p.join(dst, 'src', 'src')).existsSync(), isFalse); }); + test('이동 뒤 원본 정리 (prune) 저장 · 읽기', () { + final t = CopyTask(id: '1', sources: const ['/a'], dest: '/b', move: true, contents: true, method: 'rsync', prune: 'all'); + final back = CopyTask.fromJson(t.toJson()); + expect([back.move, back.prune], [true, 'all']); + expect(CopyTask.fromJson({'id': '2', 'dest': '/b', 'prune': 'x'}).prune, ''); + expect(t.copyWith(prune: 'keep').prune, 'keep'); + }); + test('기억한 복사의 원본이 없어졌거나 자기 안으로면 시작하지 않고 실패로 남긴다', () async { c.settings.copyMethodFolder = 'rsync'; final center = CopyCenter(c);