rsync 원본 파일 지우기 (--remove-source-files) · 빈 폴더 정리 · lsync 최종 정리

- Rsync 화면의 한 방향 (좌 → 우 · 좌 ← 우): 확인 창에서 [원본 파일 지우기 (--remove-source-files)] 를 고를 수 있고,
  고르면 끝난 뒤 원본의 빈 폴더를 지움 (find 원본/ -type d -empty -delete) - 원본 폴더는 남김 / 지움 중 고르기.
  양쪽 (⇄) 에는 없음
- 모니터링 lsync 의 쌍마다 [최종 정리]: rsync -avHPOg --remove-source-files 원본/ 대상/ 뒤 빈 폴더 지우기,
  원본 폴더 남김 / 지움 고르기. 원본이 비므로 그 실시간 동기화는 끔 (지우기 포함이면 대상까지 비우지 않게),
  진행은 복사 · rsync 탭에 남음
- 기억한 작업에 원본 정리 (prune) 저장, 모니터링 카드에서 바꾸기 · 진행 막대에 "빈 폴더 N개 지움"
- 원본 정리를 고른 이동은 예전의 "비면 원본 폴더째 지우기" 대신 고른 대로
- Windows 실제 rsync 로 시험 (integration_test/rsync_page_test.dart)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-05 21:47:20 +09:00
parent c273297b11
commit 1ac48354af
13 changed files with 383 additions and 13 deletions

View File

@ -164,6 +164,7 @@
"rsync 를 항목마다 따로 실행하거나, 한 번에 실행 (robocopy 는 늘 폴더마다)": "Run rsync separately for each item, or once for all (robocopy always runs per folder)", "rsync 를 항목마다 따로 실행하거나, 한 번에 실행 (robocopy 는 늘 폴더마다)": "Run rsync separately for each item, or once for all (robocopy always runs per folder)",
"rsync 받는 중": "Downloading rsync", "rsync 받는 중": "Downloading rsync",
"rsync 실행 파일": "rsync executable", "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 executable not found. Check the path in Settings > File explorer.",
"rsync 양쪽 (⇄)": "rsync both ways (⇄)", "rsync 양쪽 (⇄)": "rsync both ways (⇄)",
"rsync 옵션": "rsync options", "rsync 옵션": "rsync options",
@ -298,6 +299,7 @@
"끝 시간이 시작보다 빠른 줄: {0}{1}": "Lines whose end is before their start: {0}{1}", "끝 시간이 시작보다 빠른 줄: {0}{1}": "Lines whose end is before their start: {0}{1}",
"끝: {0}": "End: {0}", "끝: {0}": "End: {0}",
"끝나지 못했습니다: {0}": "Did not finish: {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", "나가기": "Exit",
"나머지는 대기했다가 차례로 받습니다": "The rest wait and download in turn", "나머지는 대기했다가 차례로 받습니다": "The rest wait and download in turn",
"나중에": "Later", "나중에": "Later",
@ -390,6 +392,7 @@
"대상 디스크: 알 수 없음": "Target disk: unknown", "대상 디스크: 알 수 없음": "Target disk: unknown",
"대상 폴더 열기": "Open containing folder", "대상 폴더 열기": "Open containing folder",
"대상 폴더가 없습니다: {0}": "Target folder not found: {0}", "대상 폴더가 없습니다: {0}": "Target folder not found: {0}",
"대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "Files transferred to the target are deleted from the source (move)",
"더 보기": "More", "더 보기": "More",
"더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "When JJ_MKVMaker is chosen by double-click · default app (the right-click menu stays)", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "When JJ_MKVMaker is chosen by double-click · default app (the right-click menu stays)",
"더한 언어 (AI 자동 번역)": "Added language (AI auto-translated)", "더한 언어 (AI 자동 번역)": "Added language (AI auto-translated)",
@ -546,6 +549,10 @@
"비어 있는 폴더": "Empty folder", "비어 있는 폴더": "Empty folder",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.",
"비어 있음": "Empty", "비어 있음": "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", "사용 안 함": "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", "사용 안 함. \"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}", "사용: {0}": "Using: {0}",
@ -730,12 +737,17 @@
"원본 비율": "Original aspect", "원본 비율": "Original aspect",
"원본 유지 (재인코딩 없음)": "Keep original (no re-encoding)", "원본 유지 (재인코딩 없음)": "Keep original (no re-encoding)",
"원본 크기": "Original size", "원본 크기": "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.", "원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "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 folder not found",
"원본과 대상이 서로 안에 있습니다": "Source and target are inside each other", "원본과 대상이 서로 안에 있습니다": "Source and target are inside each other",
"원본에 없는 것을 대상에서 지우기": "Delete from target what is not in the source", "원본에 없는 것을 대상에서 지우기": "Delete from target what is not in the source",
"원본의 {0}배": "{0}× original", "원본의 {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.", "원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "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}", "원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}": "Source not found (deleted or moved elsewhere): {0}",
"원어 (영상 속 언어)": "Source language (spoken in the video)", "원어 (영상 속 언어)": "Source language (spoken in the video)",
"원어({0})와 같은 언어로는 번역하지 않습니다.": "Will not translate into the same language as the source ({0}).", "원어({0})와 같은 언어로는 번역하지 않습니다.": "Will not translate into the same language as the source ({0}).",
@ -966,6 +978,7 @@
"즐겨찾기 표시줄": "Bookmarks bar", "즐겨찾기 표시줄": "Bookmarks bar",
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "The running copy uses the previous options. Stop it and run again with the new options?", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "The running copy uses the previous options. Stop it and run again with the new options?",
"지금 맞추기": "Sync now", "지금 맞추기": "Sync now",
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "Syncing right now. Try again when it finishes.",
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "{0} for the video you are viewing (check several in the list)", "지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "{0} for the video you are viewing (check several in the list)",
"지금 설치": "Install now", "지금 설치": "Install now",
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "Current version: v{0} → new version: v{1}", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "Current version: v{0} → new version: v{1}",
@ -1011,6 +1024,7 @@
"최신 버전 확인": "Check for updates", "최신 버전 확인": "Check for updates",
"최신 버전을 확인할 수 없습니다 ({0})": "Cannot check the latest version ({0})", "최신 버전을 확인할 수 없습니다 ({0})": "Cannot check the latest version ({0})",
"최신 버전입니다 (v{0})": "You have the latest version (v{0})", "최신 버전입니다 (v{0})": "You have the latest version (v{0})",
"최종 정리": "Final cleanup",
"추가": "Add", "추가": "Add",
"추가 ({0})": "Add ({0})", "추가 ({0})": "Add ({0})",
"추가 취소": "Cancel adding", "추가 취소": "Cancel adding",

View File

@ -164,6 +164,7 @@
"rsync 를 항목마다 따로 실행하거나, 한 번에 실행 (robocopy 는 늘 폴더마다)": "rsync を項目ごとに実行するか、まとめて 1 回で実行 (robocopy は常にフォルダーごと)", "rsync 를 항목마다 따로 실행하거나, 한 번에 실행 (robocopy 는 늘 폴더마다)": "rsync を項目ごとに実行するか、まとめて 1 回で実行 (robocopy は常にフォルダーごと)",
"rsync 받는 중": "rsync をダウンロード中", "rsync 받는 중": "rsync をダウンロード中",
"rsync 실행 파일": "rsync 実行ファイル", "rsync 실행 파일": "rsync 実行ファイル",
"rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > Rsync 에서 확인하세요.": "rsync 実行ファイルが見つかりません。環境設定 > Rsync を確認してください。",
"rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.": "rsync 実行ファイルが見つかりません。環境設定 > ファイルエクスプローラーでパスを確認してください。", "rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.": "rsync 実行ファイルが見つかりません。環境設定 > ファイルエクスプローラーでパスを確認してください。",
"rsync 양쪽 (⇄)": "rsync 双方向 (⇄)", "rsync 양쪽 (⇄)": "rsync 双方向 (⇄)",
"rsync 옵션": "rsync オプション", "rsync 옵션": "rsync オプション",
@ -298,6 +299,7 @@
"끝 시간이 시작보다 빠른 줄: {0}{1}": "終了時間が開始より早い行: {0}{1}", "끝 시간이 시작보다 빠른 줄: {0}{1}": "終了時間が開始より早い行: {0}{1}",
"끝: {0}": "終了: {0}", "끝: {0}": "終了: {0}",
"끝나지 못했습니다: {0}": "完了できませんでした: {0}", "끝나지 못했습니다: {0}": "完了できませんでした: {0}",
"끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:": "終了後、元の空フォルダーを削除します (find 元/ -type d -empty -delete)。元フォルダーは:",
"나가기": "終了する", "나가기": "終了する",
"나머지는 대기했다가 차례로 받습니다": "残りは待機して順番にダウンロードします", "나머지는 대기했다가 차례로 받습니다": "残りは待機して順番にダウンロードします",
"나중에": "後で", "나중에": "後で",
@ -390,6 +392,7 @@
"대상 디스크: 알 수 없음": "対象ディスク: 不明", "대상 디스크: 알 수 없음": "対象ディスク: 不明",
"대상 폴더 열기": "対象フォルダーを開く", "대상 폴더 열기": "対象フォルダーを開く",
"대상 폴더가 없습니다: {0}": "対象フォルダーがありません: {0}", "대상 폴더가 없습니다: {0}": "対象フォルダーがありません: {0}",
"대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "対象へ移したファイルを元から削除します (移動)",
"더 보기": "その他", "더 보기": "その他",
"더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "ダブルクリック・既定のアプリで JJ_MKVMaker を選んだとき(右クリックメニューはそのまま)", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "ダブルクリック・既定のアプリで JJ_MKVMaker を選んだとき(右クリックメニューはそのまま)",
"더한 언어 (AI 자동 번역)": "追加した言語(AI 自動翻訳)", "더한 언어 (AI 자동 번역)": "追加した言語(AI 自動翻訳)",
@ -546,6 +549,10 @@
"비어 있는 폴더": "空のフォルダー", "비어 있는 폴더": "空のフォルダー",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。",
"비어 있음": "空", "비어 있음": "空",
"빈 폴더 {0}개 지움": "空フォルダー {0} 個を削除",
"빈 폴더 그대로": "空フォルダーはそのまま",
"빈 폴더 지움 · 원본 폴더는 남김": "空フォルダーを削除 · 元フォルダーは残す",
"빈 폴더 지움 · 원본 폴더도 지움": "空フォルダーを削除 · 元フォルダーも削除",
"사용 안 함": "使用しない", "사용 안 함": "使用しない",
"사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "使用しない。「Sign in to confirm you're not a bot」エラーが出たら設定してください", "사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "使用しない。「Sign in to confirm you're not a bot」エラーが出たら設定してください",
"사용: {0}": "使用: {0}", "사용: {0}": "使用: {0}",
@ -730,12 +737,17 @@
"원본 비율": "元の比率", "원본 비율": "元の比率",
"원본 유지 (재인코딩 없음)": "元のまま(再エンコードなし)", "원본 유지 (재인코딩 없음)": "元のまま(再エンコードなし)",
"원본 크기": "元のサイズ", "원본 크기": "元のサイズ",
"원본 파일 지우기 (--remove-source-files)": "元ファイルを削除 (--remove-source-files)",
"원본 폴더": "元フォルダー",
"원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "元フォルダーが変わるとすぐ対象フォルダーに合わせます (アプリの起動中)。起動時にも 1 回合わせます。", "원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "元フォルダーが変わるとすぐ対象フォルダーに合わせます (アプリの起動中)。起動時にも 1 回合わせます。",
"원본 폴더가 없습니다": "元フォルダーがありません", "원본 폴더가 없습니다": "元フォルダーがありません",
"원본과 대상이 서로 안에 있습니다": "元と対象が互いの中にあります", "원본과 대상이 서로 안에 있습니다": "元と対象が互いの中にあります",
"원본에 없는 것을 대상에서 지우기": "元にないものを対象から削除", "원본에 없는 것을 대상에서 지우기": "元にないものを対象から削除",
"원본의 {0}배": "元の {0}倍", "원본의 {0}배": "元の {0}倍",
"원본의 빈 폴더 정리 중…": "元の空フォルダーを整理中…",
"원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.": "元のファイルをすべて対象へ移し (元から削除)、元に残った空フォルダーを削除します。",
"원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "元が変わると対象に合わせます。予定を決めるとその時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。", "원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "元が変わると対象に合わせます。予定を決めるとその時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。",
"원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.": "元が空になるため、このリアルタイム同期はオフにします。進捗は [コピー · rsync] タブで確認します。",
"원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}": "元がありません (他の場所で削除または移動されました): {0}", "원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}": "元がありません (他の場所で削除または移動されました): {0}",
"원어 (영상 속 언어)": "原語(動画内の言語)", "원어 (영상 속 언어)": "原語(動画内の言語)",
"원어({0})와 같은 언어로는 번역하지 않습니다.": "原語({0})と同じ言語には翻訳しません。", "원어({0})와 같은 언어로는 번역하지 않습니다.": "原語({0})と同じ言語には翻訳しません。",
@ -966,6 +978,7 @@
"즐겨찾기 표시줄": "ブックマークバー", "즐겨찾기 표시줄": "ブックマークバー",
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "実行中のコピーは前のオプションで動いています。止めて新しいオプションで再実行しますか?", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "実行中のコピーは前のオプションで動いています。止めて新しいオプションで再実行しますか?",
"지금 맞추기": "今すぐ同期", "지금 맞추기": "今すぐ同期",
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "同期中です。終わってから行ってください。",
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "今見ている動画の{0}(複数はリストでチェック)", "지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "今見ている動画の{0}(複数はリストでチェック)",
"지금 설치": "今すぐインストール", "지금 설치": "今すぐインストール",
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}",
@ -1011,6 +1024,7 @@
"최신 버전 확인": "最新バージョンを確認", "최신 버전 확인": "最新バージョンを確認",
"최신 버전을 확인할 수 없습니다 ({0})": "最新バージョンを確認できません({0})", "최신 버전을 확인할 수 없습니다 ({0})": "最新バージョンを確認できません({0})",
"최신 버전입니다 (v{0})": "最新バージョンです(v{0})", "최신 버전입니다 (v{0})": "最新バージョンです(v{0})",
"최종 정리": "最終整理",
"추가": "追加", "추가": "追加",
"추가 ({0})": "追加({0})", "추가 ({0})": "追加({0})",
"추가 취소": "追加をキャンセル", "추가 취소": "追加をキャンセル",

View File

@ -164,6 +164,7 @@
"rsync 를 항목마다 따로 실행하거나, 한 번에 실행 (robocopy 는 늘 폴더마다)": "rsync 按项目分别执行,或一次执行 (robocopy 总是按文件夹)", "rsync 를 항목마다 따로 실행하거나, 한 번에 실행 (robocopy 는 늘 폴더마다)": "rsync 按项目分别执行,或一次执行 (robocopy 总是按文件夹)",
"rsync 받는 중": "正在下载 rsync", "rsync 받는 중": "正在下载 rsync",
"rsync 실행 파일": "rsync 可执行文件", "rsync 실행 파일": "rsync 可执行文件",
"rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > Rsync 에서 확인하세요.": "找不到 rsync 可执行文件。请检查 设置 > Rsync。",
"rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.": "找不到 rsync 可执行文件。请在 设置 > 文件浏览器 中检查路径。", "rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.": "找不到 rsync 可执行文件。请在 设置 > 文件浏览器 中检查路径。",
"rsync 양쪽 (⇄)": "rsync 双向 (⇄)", "rsync 양쪽 (⇄)": "rsync 双向 (⇄)",
"rsync 옵션": "rsync 选项", "rsync 옵션": "rsync 选项",
@ -298,6 +299,7 @@
"끝 시간이 시작보다 빠른 줄: {0}{1}": "结束时间早于开始时间的行: {0}{1}", "끝 시간이 시작보다 빠른 줄: {0}{1}": "结束时间早于开始时间的行: {0}{1}",
"끝: {0}": "结束: {0}", "끝: {0}": "结束: {0}",
"끝나지 못했습니다: {0}": "未能完成: {0}", "끝나지 못했습니다: {0}": "未能完成: {0}",
"끝난 뒤 원본의 빈 폴더를 지웁니다 (find 원본/ -type d -empty -delete). 원본 폴더는:": "完成后删除源中的空文件夹 (find 源/ -type d -empty -delete)。源文件夹:",
"나가기": "退出", "나가기": "退出",
"나머지는 대기했다가 차례로 받습니다": "其余的等待并依次下载", "나머지는 대기했다가 차례로 받습니다": "其余的等待并依次下载",
"나중에": "以后再说", "나중에": "以后再说",
@ -390,6 +392,7 @@
"대상 디스크: 알 수 없음": "目标磁盘: 未知", "대상 디스크: 알 수 없음": "目标磁盘: 未知",
"대상 폴더 열기": "打开所在文件夹", "대상 폴더 열기": "打开所在文件夹",
"대상 폴더가 없습니다: {0}": "目标文件夹不存在: {0}", "대상 폴더가 없습니다: {0}": "目标文件夹不存在: {0}",
"대상으로 옮긴 파일을 원본에서 지웁니다 (이동)": "已传到目标的文件从源中删除 (移动)",
"더 보기": "更多", "더 보기": "更多",
"더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "双击 · 默认打开方式选择 JJ_MKVMaker 时(右键菜单不变)", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "双击 · 默认打开方式选择 JJ_MKVMaker 时(右键菜单不变)",
"더한 언어 (AI 자동 번역)": "已添加的语言(AI 自动翻译)", "더한 언어 (AI 자동 번역)": "已添加的语言(AI 自动翻译)",
@ -546,6 +549,10 @@
"비어 있는 폴더": "空文件夹", "비어 있는 폴더": "空文件夹",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。",
"비어 있음": "空", "비어 있음": "空",
"빈 폴더 {0}개 지움": "已删除 {0} 个空文件夹",
"빈 폴더 그대로": "保留空文件夹",
"빈 폴더 지움 · 원본 폴더는 남김": "删除空文件夹 · 保留源文件夹",
"빈 폴더 지움 · 원본 폴더도 지움": "删除空文件夹 · 源文件夹也删除",
"사용 안 함": "不使用", "사용 안 함": "不使用",
"사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "不使用。如出现“Sign in to confirm you're not a bot”错误请设置", "사용 안 함. \"Sign in to confirm you're not a bot\" 오류가 나오면 설정하세요": "不使用。如出现“Sign in to confirm you're not a bot”错误请设置",
"사용: {0}": "使用: {0}", "사용: {0}": "使用: {0}",
@ -730,12 +737,17 @@
"원본 비율": "原始比例", "원본 비율": "原始比例",
"원본 유지 (재인코딩 없음)": "保持原样(不重新编码)", "원본 유지 (재인코딩 없음)": "保持原样(不重新编码)",
"원본 크기": "原始尺寸", "원본 크기": "原始尺寸",
"원본 파일 지우기 (--remove-source-files)": "删除源文件 (--remove-source-files)",
"원본 폴더": "源文件夹",
"원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "源文件夹变化时立即同步到目标 (应用运行期间)。启动时也同步一次。", "원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.": "源文件夹变化时立即同步到目标 (应用运行期间)。启动时也同步一次。",
"원본 폴더가 없습니다": "源文件夹不存在", "원본 폴더가 없습니다": "源文件夹不存在",
"원본과 대상이 서로 안에 있습니다": "源与目标互相包含", "원본과 대상이 서로 안에 있습니다": "源与目标互相包含",
"원본에 없는 것을 대상에서 지우기": "从目标中删除源中没有的内容", "원본에 없는 것을 대상에서 지우기": "从目标中删除源中没有的内容",
"원본의 {0}배": "原始的 {0} 倍", "원본의 {0}배": "原始的 {0} 倍",
"원본의 빈 폴더 정리 중…": "正在清理源中的空文件夹…",
"원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.": "将源中的所有文件移到目标 (从源中删除),然后删除源中剩下的空文件夹。",
"원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "源变化时同步到目标。设定计划后只在该时间同步,其他时间的变化会累积到下次开始时间再同步。", "원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "源变化时同步到目标。设定计划后只在该时间同步,其他时间的变化会累积到下次开始时间再同步。",
"원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.": "源将变空,因此关闭此实时同步。进度请在 [复制 · rsync] 标签中查看。",
"원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}": "源不存在 (已在别处删除或移动): {0}", "원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}": "源不存在 (已在别处删除或移动): {0}",
"원어 (영상 속 언어)": "原语言(视频中的语言)", "원어 (영상 속 언어)": "原语言(视频中的语言)",
"원어({0})와 같은 언어로는 번역하지 않습니다.": "不会翻译成与原语言({0})相同的语言。", "원어({0})와 같은 언어로는 번역하지 않습니다.": "不会翻译成与原语言({0})相同的语言。",
@ -966,6 +978,7 @@
"즐겨찾기 표시줄": "书签栏", "즐겨찾기 표시줄": "书签栏",
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "正在运行的复制使用的是之前的选项。停止并用新选项重新运行吗?", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "正在运行的复制使用的是之前的选项。停止并用新选项重新运行吗?",
"지금 맞추기": "立即同步", "지금 맞추기": "立即同步",
"지금 맞추는 중입니다. 끝난 뒤에 하세요.": "正在同步。请等结束后再进行。",
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "当前查看视频的{0}(多个请在列表中勾选)", "지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "当前查看视频的{0}(多个请在列表中勾选)",
"지금 설치": "立即安装", "지금 설치": "立即安装",
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}",
@ -1011,6 +1024,7 @@
"최신 버전 확인": "检查最新版本", "최신 버전 확인": "检查最新版本",
"최신 버전을 확인할 수 없습니다 ({0})": "无法检查最新版本({0})", "최신 버전을 확인할 수 없습니다 ({0})": "无法检查最新版本({0})",
"최신 버전입니다 (v{0})": "已是最新版本(v{0})", "최신 버전입니다 (v{0})": "已是最新版本(v{0})",
"최종 정리": "最终整理",
"추가": "添加", "추가": "添加",
"추가 ({0})": "添加({0})", "추가 ({0})": "添加({0})",
"추가 취소": "取消添加", "추가 취소": "取消添加",

View File

@ -7,8 +7,10 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/live_sync.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/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.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:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -103,4 +105,96 @@ void main() {
tester.view.reset(); tester.view.reset();
tmp.deleteSync(recursive: true); 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<void> wait(int ms) async {
for (var i = 0; i < ms ~/ 100; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
await tester.pump();
}
}
Future<void> 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<void> 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);
});
} }

View File

@ -106,6 +106,7 @@ class CopyCenter extends ChangeNotifier {
bandwidthKBps: t.bandwidthKBps, bandwidthKBps: t.bandwidthKBps,
rsyncExe: exe, rsyncExe: exe,
contents: t.contents, contents: t.contents,
prune: t.move ? t.prune : '',
); );
jobs[t.id] = job; jobs[t.id] = job;
job.addListener(notifyListeners); job.addListener(notifyListeners);
@ -122,7 +123,7 @@ class CopyCenter extends ChangeNotifier {
await update(cur.copyWith( await update(cur.copyWith(
lastRun: DateTime.now().toIso8601String(), lastRun: DateTime.now().toIso8601String(),
lastResult: e == null ? 'done' : e is FileOpCancelled ? 'cancelled' : 'failed', 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, lastFiles: job.allDone,
)); ));
} }

View File

@ -87,6 +87,10 @@ class CopyTask {
final String lastMessage; final String lastMessage;
final int lastFiles; final int lastFiles;
/// 이동 ([move], rsync --remove-source-files) 뒤 원본 정리: '' 그대로 · 'keep' 빈 폴더를 지우고 원본 폴더는 남김 ·
/// 'all' 원본 폴더까지 (비었으면) 지움 (find 원본/ -type d -empty -delete)
final String prune;
const CopyTask({ const CopyTask({
required this.id, required this.id,
required this.sources, required this.sources,
@ -101,6 +105,7 @@ class CopyTask {
this.lastResult = '', this.lastResult = '',
this.lastMessage = '', this.lastMessage = '',
this.lastFiles = 0, this.lastFiles = 0,
this.prune = '',
}); });
/// 같은 복사인지 (원본들 · 대상 · 이동) /// 같은 복사인지 (원본들 · 대상 · 이동)
@ -116,6 +121,7 @@ class CopyTask {
String? lastResult, String? lastResult,
String? lastMessage, String? lastMessage,
int? lastFiles, int? lastFiles,
String? prune,
}) => }) =>
CopyTask( CopyTask(
id: id, id: id,
@ -131,6 +137,7 @@ class CopyTask {
lastResult: lastResult ?? this.lastResult, lastResult: lastResult ?? this.lastResult,
lastMessage: lastMessage ?? this.lastMessage, lastMessage: lastMessage ?? this.lastMessage,
lastFiles: lastFiles ?? this.lastFiles, lastFiles: lastFiles ?? this.lastFiles,
prune: prune ?? this.prune,
); );
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
@ -147,6 +154,7 @@ class CopyTask {
'lastResult': lastResult, 'lastResult': lastResult,
'lastMessage': lastMessage, 'lastMessage': lastMessage,
'lastFiles': lastFiles, 'lastFiles': lastFiles,
'prune': prune,
}; };
factory CopyTask.fromJson(Map<Object?, Object?> j) => CopyTask( factory CopyTask.fromJson(Map<Object?, Object?> j) => CopyTask(
@ -163,6 +171,7 @@ class CopyTask {
lastResult: j['lastResult'] as String? ?? '', lastResult: j['lastResult'] as String? ?? '',
lastMessage: j['lastMessage'] as String? ?? '', lastMessage: j['lastMessage'] as String? ?? '',
lastFiles: (j['lastFiles'] as num?)?.toInt() ?? 0, lastFiles: (j['lastFiles'] as num?)?.toInt() ?? 0,
prune: const ['keep', 'all'].contains(j['prune']) ? j['prune'] as String : '',
); );
} }

View File

@ -30,6 +30,9 @@ class TransferJob extends ChangeNotifier {
/// 폴더 "안의 것" 을 [dest] 에 맞추기 (rsync 원본/ → 대상/, 현재 방식은 바뀐 것만). lsync 에서 옮겨 온 복사. /// 폴더 "안의 것" 을 [dest] 에 맞추기 (rsync 원본/ → 대상/, 현재 방식은 바뀐 것만). lsync 에서 옮겨 온 복사.
final bool contents; final bool contents;
/// 이동 뒤 원본 정리: '' 안 함 · 'keep' 빈 폴더 지움 (원본 폴더는 남김) · 'all' 원본 폴더까지
final String prune;
TransferJob({ TransferJob({
required this.sources, required this.sources,
required this.dest, required this.dest,
@ -40,8 +43,13 @@ class TransferJob extends ChangeNotifier {
this.bandwidthKBps = 0, this.bandwidthKBps = 0,
this.rsyncExe, this.rsyncExe,
this.contents = false, this.contents = false,
this.prune = '',
}); });
/// 원본 정리 중 · 지운 빈 폴더 수
bool pruning = false;
int pruned = 0;
// ── 진행 상태 ── // ── 진행 상태 ──
int index = 0; // 지금 항목 int index = 0; // 지금 항목
int get total => sources.length; int get total => sources.length;
@ -124,6 +132,14 @@ class TransferJob extends ChangeNotifier {
await _runRobocopy(); await _runRobocopy();
} }
if (cancelled) throw const FileOpCancelled(); 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) { } catch (e) {
error = cancelled ? const FileOpCancelled() : e; error = cancelled ? const FileOpCancelled() : e;
} finally { } finally {
@ -213,8 +229,9 @@ class TransferJob extends ChangeNotifier {
for (final i in idx) { for (final i in idx) {
filesDone[i] = filesTotal[i]; filesDone[i] = filesTotal[i];
made.add(p.join(dest, p.basename(sources[i]))); made.add(p.join(dest, p.basename(sources[i])));
// 이동: rsync 는 파일만 지우므로 빈 폴더를 정리 // 폴더째 이동 (안의 것 [contents] 이 아니고 원본 정리 [prune] 를 고르지 않음): rsync 는 파일만 지우므로
if (move && FileSystemEntity.isDirectorySync(sources[i]) && await countFiles(sources[i]) == 0) { // 비어 남은 원본 폴더를 지운다. 원본 정리를 고른 이동은 끝에서 그 고른 대로 ([prune]).
if (move && !contents && prune.isEmpty && FileSystemEntity.isDirectorySync(sources[i]) && await countFiles(sources[i]) == 0) {
await Directory(sources[i]).delete(recursive: true); await Directory(sources[i]).delete(recursive: true);
} }
} }

View File

@ -106,6 +106,39 @@ String? transferProblem(List<String> sources, String dest, {required bool move})
return null; return null;
} }
/// [root] 안의 빈 폴더를 안쪽부터 지운다 (find root/ -type d -empty -delete 와 같음). 지운 폴더 수.
/// [keepRoot] 면 [root] 자신은 비어도 남긴다. 파일 · 링크가 하나라도 있는 폴더는 그대로.
Future<int> removeEmptyDirs(String root, {bool keepRoot = true}) async {
var n = 0;
Future<bool> 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); typedef CopyProgress = void Function(String current, int done, int total);

View File

@ -953,10 +953,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
// 확인 창에는 기억한 옵션 (없으면 지금 설정) 을 보여 주고, 실행할 때 모니터링에 기억한다 // 확인 창에는 기억한 옵션 (없으면 지금 설정) 을 보여 주고, 실행할 때 모니터링에 기억한다
var tasks = [for (final (src, dst) in runs) center.peek([src], dst, contents: true, method: 'rsync')]; 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; String opts(CopyTask t) => both ? withUpdateOption(t.options) : t.options;
// 한 방향 (→ · ←) 만: 원본 파일 지우기 (--remove-source-files) 와 끝난 뒤 원본 정리
var removeSource = false;
var prune = 'keep';
if (!mounted) return; if (!mounted) return;
final go = await showDialog<bool>( final go = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => StatefulBuilder(builder: (ctx, set) => AlertDialog(
title: Text(both ? tr('rsync 양쪽 (⇄)') : 'rsync'), title: Text(both ? tr('rsync 양쪽 (⇄)') : 'rsync'),
content: SizedBox( content: SizedBox(
width: 560, width: 560,
@ -964,7 +967,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
for (final t in tasks) ...[ for (final t in tasks) ...[
Text('${t.sources.first}/', style: const TextStyle(fontWeight: FontWeight.w600)), Text('${t.sources.first}/', style: const TextStyle(fontWeight: FontWeight.w600)),
Text('→ ${t.dest}/', 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)), style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)),
const SizedBox(height: 10), const SizedBox(height: 10),
], ],
@ -972,19 +976,42 @@ class _ExplorerPageState extends State<ExplorerPage> {
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
if (both) if (both)
Text(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).'), 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: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), 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; if (go != true || !mounted) return;
final exe = await _ensureRsync(); final exe = await _ensureRsync();
if (exe == null || !mounted) return; 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 = <TransferJob>[]; final jobs = <TransferJob>[];
for (final t in tasks) { for (final t in tasks) {
final j = await center.start(t.copyWith(options: opts(t)), rsyncExe: exe); final j = await center.start(t.copyWith(options: opts(t)), rsyncExe: exe);
@ -994,7 +1021,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
setState(() => _jobs = jobs); setState(() => _jobs = jobs);
await Future.wait(jobs.map((j) => j.done)); await Future.wait(jobs.map((j) => j.done));
if (!mounted) return; 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) { for (final x in _panes) {
x.changed(); x.changed();
} }
@ -1091,7 +1122,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
'${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}', '${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}',
), ),
line( 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, job.current,
'${pct(job.current)} · ${trf('파일 {0}/{1}', [curDone, curFiles])}', '${pct(job.current)} · ${trf('파일 {0}/{1}', [curDone, curFiles])}',
), ),

View File

@ -144,7 +144,13 @@ class TransferProgressLines extends StatelessWidget {
return Column(children: [ return Column(children: [
line(trf('전체 {0}개 중 {1}번째', [job.total, cur + 1]), job.overall, line(trf('전체 {0}개 중 {1}번째', [job.total, cur + 1]), job.overall,
'${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}'), '${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]])}'), '${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<String> 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 { class _CopyCard extends StatefulWidget {
final AppController c; final AppController c;
final CopyTask task; final CopyTask task;
@ -194,8 +221,10 @@ class _CopyCardState extends State<_CopyCard> {
late final _options = TextEditingController(text: widget.task.options); late final _options = TextEditingController(text: widget.task.options);
late final _bw = TextEditingController(text: '${widget.task.bandwidthKBps}'); late final _bw = TextEditingController(text: '${widget.task.bandwidthKBps}');
late bool _once = widget.task.once; late bool _once = widget.task.once;
late String _prune = widget.task.prune;
bool get _dirty => bool get _dirty =>
_prune != widget.task.prune ||
_method != widget.task.method || _method != widget.task.method ||
_options.text.trim() != widget.task.options || _options.text.trim() != widget.task.options ||
(int.tryParse(_bw.text) ?? 0) != widget.task.bandwidthKBps || (int.tryParse(_bw.text) ?? 0) != widget.task.bandwidthKBps ||
@ -234,6 +263,7 @@ class _CopyCardState extends State<_CopyCard> {
options: _options.text.trim(), options: _options.text.trim(),
bandwidthKBps: int.tryParse(_bw.text) ?? 0, bandwidthKBps: int.tryParse(_bw.text) ?? 0,
once: _once, once: _once,
prune: _prune,
); );
await center.update(t); await center.update(t);
if (!mounted) return; if (!mounted) return;
@ -366,6 +396,13 @@ class _CopyCardState extends State<_CopyCard> {
), ),
if (t.sources.length > 1 && _method == 'rsync') if (t.sources.length > 1 && _method == 'rsync')
FilterChip(label: Text(tr('한 번에')), selected: _once, onSelected: (v) => setState(() => _once = v)), FilterChip(label: Text(tr('한 번에')), selected: _once, onSelected: (v) => setState(() => _once = v)),
// 이동 (원본 파일 지우기) 이면: 끝난 뒤 원본의 빈 폴더 · 원본 폴더 정리
if (t.move && _method == 'rsync')
DropdownButton<String>(
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('저장'))), 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, 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<void> _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<bool>(
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final live = LiveSync.instance; final live = LiveSync.instance;
@ -591,6 +693,11 @@ class _LiveTabState extends State<_LiveTab> {
label: Text(tr('지금 맞추기')), label: Text(tr('지금 맞추기')),
), ),
const SizedBox(width: 6), 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( TextButton.icon(
onPressed: () async { onPressed: () async {
await CopyCenter.of(c).fromLiveSync(x); await CopyCenter.of(c).fromLiveSync(x);

View File

@ -333,12 +333,20 @@ void main() {
await tester.tap(find.text('취소')); await tester.tap(find.text('취소'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(c.settings.copyTasks, isEmpty); expect(c.settings.copyTasks, isEmpty);
// → 는 한 방향 · -u 없이 expect(find.text('원본 파일 지우기 (--remove-source-files)'), findsNothing); // 양쪽에는 없음
// → 는 한 방향 · -u 없이 · 원본 파일 지우기를 고를 수 있다 (고르면 원본 폴더 남김 / 지움)
await act(tester, () => tester.tap(find.text('좌 → 우'))); await act(tester, () => tester.tap(find.text('좌 → 우')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('→ ${p.join(right, 'rsub')}/'), findsOneWidget); expect(find.text('→ ${p.join(right, 'rsub')}/'), findsOneWidget);
expect(find.text('→ ${p.join(left, 'sub')}/'), findsNothing); expect(find.text('→ ${p.join(left, 'sub')}/'), findsNothing);
expect(find.textContaining('-u'), 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.tap(find.text('취소'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// 고른 폴더는 기억 (다음에 열 때 그 폴더부터) // 고른 폴더는 기억 (다음에 열 때 그 폴더부터)

View File

@ -76,6 +76,22 @@ void main() {
expect(b.existsSync(), isFalse); 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('시작 전 확인: 없는 원본 · 없는 대상 · 자기 안으로 · 같은 폴더로 이동', () { test('시작 전 확인: 없는 원본 · 없는 대상 · 자기 안으로 · 같은 폴더로 이동', () {
final a = Directory(p.join(tmp.path, 'a'))..createSync(); final a = Directory(p.join(tmp.path, 'a'))..createSync();
final inner = Directory(p.join(a.path, 'inner'))..createSync(); final inner = Directory(p.join(a.path, 'inner'))..createSync();

View File

@ -102,6 +102,14 @@ void main() {
expect(Directory(p.join(dst, 'src', 'src')).existsSync(), isFalse); 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 { test('기억한 복사의 원본이 없어졌거나 자기 안으로면 시작하지 않고 실패로 남긴다', () async {
c.settings.copyMethodFolder = 'rsync'; c.settings.copyMethodFolder = 'rsync';
final center = CopyCenter(c); final center = CopyCenter(c);