From 9ce55bbb747e879697313f21530a150def987b84 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:08:53 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B3=B5=EC=82=AC=20=C2=B7=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EC=8B=9C=EC=9E=91=20=EC=A0=84=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?:=20=EC=97=86=EB=8A=94=20=EC=9B=90=EB=B3=B8=20=C2=B7=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EB=8C=80=EC=83=81=20=C2=B7=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=EB=A5=BC=20=EC=9E=90=EA=B8=B0=20=EC=95=88=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20=EA=B0=99=EC=9D=80=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 다른 곳에서 지워진 폴더 (탐색기 목록에는 남아 있던 것) 를 복사 · 이동하면 rsync 오류 대신 알리고 목록을 새로 고침 - 폴더를 자기 자신 (또는 그 안) 으로 복사 · 이동하는 것을 막음 (현재 방식만 막고 있었고 rsync 는 막지 않았음) - 모니터링의 실행 · 재개도 같은 확인: 시작하지 않고 실패와 이유를 남김 Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 6 ++++++ assets/l10n/ja.json | 6 ++++++ assets/l10n/zh-Hans.json | 6 ++++++ lib/app/copy_center.dart | 8 ++++++++ lib/core/file_ops.dart | 21 +++++++++++++++++++++ lib/ui/explorer_page.dart | 11 +++++++++++ lib/ui/monitor_page.dart | 2 ++ test/file_ops_test.dart | 15 +++++++++++++++ test/monitor_test.dart | 12 ++++++++++++ 9 files changed, 87 insertions(+) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 5e3828c..6a6f9ed 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -384,6 +384,7 @@ "대상 디스크: 남은 {0} / 전체 {1}": "Target disk: {0} free / {1} total", "대상 디스크: 알 수 없음": "Target disk: unknown", "대상 폴더 열기": "Open containing folder", + "대상 폴더가 없습니다: {0}": "Target folder not found: {0}", "더 보기": "More", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "When JJ_MKVMaker is chosen by double-click · default app (the right-click menu stays)", "더한 언어 (AI 자동 번역)": "Added language (AI auto-translated)", @@ -526,6 +527,7 @@ "복사 실패 ({0}): {1}": "Copy failed ({0}): {1}", "복사(Ctrl+C)한 주소 자동 다운로드": "Auto-download copied (Ctrl+C) URLs", "복사하는 중": "Copying", + "복사할": "copy", "복사할까요?": "copy?", "복사했습니다.": "copied.", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "This video is unavailable (deleted · region restricted).", @@ -695,6 +697,7 @@ "올린 이 {0}": "Uploaded by {0}", "옮겼습니다.": "moved.", "옮기는 중": "Moving", + "옮길": "move", "옮길까요?": "move?", "옵션": "Options", "옵션을 저장했습니다": "Options saved", @@ -723,6 +726,7 @@ "원본에 없는 것을 대상에서 지우기": "Delete from target what is not in the source", "원본의 {0}배": "{0}× original", "원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "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.", + "원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {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}).", "원어: {0} ({1})": "Source: {0} ({1})", @@ -797,6 +801,7 @@ "이미 동영상 목록에 있습니다 ({0}개).": "Already in the video list ({0}).", "이미 목록에 있는 동영상입니다 ({0}개).": "These videos are already in the list ({0}).", "이미 받는 중이거나 받을 수 없는 주소입니다.": "Already downloading or a URL that cannot be downloaded.", + "이미 이 폴더에 있습니다: {0}": "Already in this folder: {0}", "이미 있는 폴더입니다: {0}": "The folder already exists: {0}", "이미지 자막은 번역할 수 없습니다": "Image subtitles cannot be translated", "이미지 자막은 편집할 수 없습니다": "Image subtitles cannot be edited", @@ -1064,6 +1069,7 @@ "폴더가 들어 있을 때": "When folders are included", "폴더가 아닙니다: {0}": "Not a folder: {0}", "폴더를 만들 수 없습니다: {0}": "Cannot create the folder: {0}", + "폴더를 자기 자신 안으로 {0} 수 없습니다: {1}": "Cannot {0} a folder into itself: {1}", "폴더만 lsync 로 옮길 수 있습니다.": "Only folders can be moved to lsync.", "폴란드어": "Polish", "표시 (여러 개 고르기)": "Mark (select several)", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 2d055a0..45429f6 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -384,6 +384,7 @@ "대상 디스크: 남은 {0} / 전체 {1}": "対象ディスク: 空き {0} / 全体 {1}", "대상 디스크: 알 수 없음": "対象ディスク: 不明", "대상 폴더 열기": "対象フォルダーを開く", + "대상 폴더가 없습니다: {0}": "対象フォルダーがありません: {0}", "더 보기": "その他", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "ダブルクリック・既定のアプリで JJ_MKVMaker を選んだとき(右クリックメニューはそのまま)", "더한 언어 (AI 자동 번역)": "追加した言語(AI 自動翻訳)", @@ -526,6 +527,7 @@ "복사 실패 ({0}): {1}": "コピー失敗 ({0}): {1}", "복사(Ctrl+C)한 주소 자동 다운로드": "コピー(Ctrl+C)した URL を自動ダウンロード", "복사하는 중": "コピー中", + "복사할": "コピーする", "복사할까요?": "コピーしますか?", "복사했습니다.": "コピーしました。", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "視聴できない動画です(削除・地域制限)。", @@ -695,6 +697,7 @@ "올린 이 {0}": "投稿者 {0}", "옮겼습니다.": "移動しました。", "옮기는 중": "移動中", + "옮길": "移す", "옮길까요?": "移動しますか?", "옵션": "オプション", "옵션을 저장했습니다": "オプションを保存しました", @@ -723,6 +726,7 @@ "원본에 없는 것을 대상에서 지우기": "元にないものを対象から削除", "원본의 {0}배": "元の {0}倍", "원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "元が変わると対象に合わせます。予定を決めるとその時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。", + "원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}": "元がありません (他の場所で削除または移動されました): {0}", "원어 (영상 속 언어)": "原語(動画内の言語)", "원어({0})와 같은 언어로는 번역하지 않습니다.": "原語({0})と同じ言語には翻訳しません。", "원어: {0} ({1})": "原語: {0}({1})", @@ -797,6 +801,7 @@ "이미 동영상 목록에 있습니다 ({0}개).": "既に動画リストにあります({0}個)。", "이미 목록에 있는 동영상입니다 ({0}개).": "既にリストにある動画です({0}個)。", "이미 받는 중이거나 받을 수 없는 주소입니다.": "既にダウンロード中か、ダウンロードできない URL です。", + "이미 이 폴더에 있습니다: {0}": "すでにこのフォルダーにあります: {0}", "이미 있는 폴더입니다: {0}": "既にあるフォルダーです: {0}", "이미지 자막은 번역할 수 없습니다": "画像字幕は翻訳できません", "이미지 자막은 편집할 수 없습니다": "画像字幕は編集できません", @@ -1064,6 +1069,7 @@ "폴더가 들어 있을 때": "フォルダーを含むとき", "폴더가 아닙니다: {0}": "フォルダーではありません: {0}", "폴더를 만들 수 없습니다: {0}": "フォルダーを作成できません: {0}", + "폴더를 자기 자신 안으로 {0} 수 없습니다: {1}": "フォルダーを自分自身の中へ{0}ことはできません: {1}", "폴더만 lsync 로 옮길 수 있습니다.": "lsync へ移せるのはフォルダーだけです。", "폴란드어": "ポーランド語", "표시 (여러 개 고르기)": "マーク (複数選択)", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 36dbd94..d6bd234 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -384,6 +384,7 @@ "대상 디스크: 남은 {0} / 전체 {1}": "目标磁盘: 剩余 {0} / 总共 {1}", "대상 디스크: 알 수 없음": "目标磁盘: 未知", "대상 폴더 열기": "打开所在文件夹", + "대상 폴더가 없습니다: {0}": "目标文件夹不存在: {0}", "더 보기": "更多", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "双击 · 默认打开方式选择 JJ_MKVMaker 时(右键菜单不变)", "더한 언어 (AI 자동 번역)": "已添加的语言(AI 自动翻译)", @@ -526,6 +527,7 @@ "복사 실패 ({0}): {1}": "复制失败 ({0}): {1}", "복사(Ctrl+C)한 주소 자동 다운로드": "自动下载复制(Ctrl+C)的地址", "복사하는 중": "正在复制", + "복사할": "复制", "복사할까요?": "复制吗?", "복사했습니다.": "已复制。", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "该视频无法观看(已删除 · 地区限制)。", @@ -695,6 +697,7 @@ "올린 이 {0}": "上传者 {0}", "옮겼습니다.": "已移动。", "옮기는 중": "正在移动", + "옮길": "移动", "옮길까요?": "移动吗?", "옵션": "选项", "옵션을 저장했습니다": "已保存选项", @@ -723,6 +726,7 @@ "원본에 없는 것을 대상에서 지우기": "从目标中删除源中没有的内容", "원본의 {0}배": "原始的 {0} 倍", "원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "源变化时同步到目标。设定计划后只在该时间同步,其他时间的变化会累积到下次开始时间再同步。", + "원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}": "源不存在 (已在别处删除或移动): {0}", "원어 (영상 속 언어)": "原语言(视频中的语言)", "원어({0})와 같은 언어로는 번역하지 않습니다.": "不会翻译成与原语言({0})相同的语言。", "원어: {0} ({1})": "原语言: {0}({1})", @@ -797,6 +801,7 @@ "이미 동영상 목록에 있습니다 ({0}개).": "已在视频列表中({0} 个)。", "이미 목록에 있는 동영상입니다 ({0}개).": "这些视频已在列表中({0} 个)。", "이미 받는 중이거나 받을 수 없는 주소입니다.": "已在下载中,或该地址无法下载。", + "이미 이 폴더에 있습니다: {0}": "已在此文件夹中: {0}", "이미 있는 폴더입니다: {0}": "文件夹已存在: {0}", "이미지 자막은 번역할 수 없습니다": "图像字幕无法翻译", "이미지 자막은 편집할 수 없습니다": "图像字幕无法编辑", @@ -1064,6 +1069,7 @@ "폴더가 들어 있을 때": "包含文件夹时", "폴더가 아닙니다: {0}": "不是文件夹: {0}", "폴더를 만들 수 없습니다: {0}": "无法创建文件夹: {0}", + "폴더를 자기 자신 안으로 {0} 수 없습니다: {1}": "不能将文件夹{0}到其自身内部: {1}", "폴더만 lsync 로 옮길 수 있습니다.": "只有文件夹可以移到 lsync。", "폴란드어": "波兰语", "표시 (여러 개 고르기)": "标记 (多选)", diff --git a/lib/app/copy_center.dart b/lib/app/copy_center.dart index 481e19c..de2df6f 100644 --- a/lib/app/copy_center.dart +++ b/lib/app/copy_center.dart @@ -76,8 +76,16 @@ class CopyCenter extends ChangeNotifier { void cancel(String id) => jobs[id]?.cancel(); /// 실행을 시작하고 그 작업을 바로 돌려준다 (rsync 가 없으면 null). 끝나면 결과를 기억한다 ([TransferJob.done]). + /// 실행 · 재개. 원본 · 대상이 없거나 폴더를 자기 안으로 넣는 것이면 시작하지 않고 실패로 남긴다 (null). Future start(CopyTask t, {String? rsyncExe}) async { if (isRunning(t.id)) return jobs[t.id]; + final problem = transferProblem(t.sources, t.dest, move: t.move); + if (problem != null) { + if (tasks.any((x) => x.id == t.id)) { + await update(t.copyWith(lastRun: DateTime.now().toIso8601String(), lastResult: 'failed', lastMessage: problem)); + } + return null; + } final method = CopyMethod.of(t.method); final exe = method == CopyMethod.rsync ? (rsyncExe ?? await rsyncExecutable(c.settings)) : null; if (method == CopyMethod.rsync && exe == null) return null; diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index 833efd9..5c43ef9 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; +import '../l10n/tr.dart'; import 'playlist.dart' show naturalCompare; /// 파일 탐색기 (X-plore 참고) 의 화면과 상관없는 부분: 폴더 읽기 · 정렬 · 복사 / 이동 / 삭제 · 찾기. @@ -85,6 +86,26 @@ bool isSameOrInside(String a, String b) { return samePath(a, b) || p.isWithin(y, x) || (Platform.isWindows && p.isWithin(y.toLowerCase(), x.toLowerCase())); } +/// 복사 · 이동을 시작하기 전 확인 (모든 방법 공통 - rsync · robocopy 는 스스로 막지 않는다). +/// 문제가 있으면 알릴 글, 없으면 null. +/// - 원본이 없음 (다른 곳에서 지워졌거나 옮겨짐) · 대상 폴더가 없음 +/// - 폴더를 자기 자신 (또는 그 안) 으로 복사 · 이동 +/// - 이미 그 폴더에 있는 것을 같은 폴더로 이동 +String? transferProblem(List sources, String dest, {required bool move}) { + final missing = [for (final s in sources) if (FileSystemEntity.typeSync(s) == FileSystemEntityType.notFound) s]; + if (missing.isNotEmpty) { + return trf('원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}', [missing.map(p.basename).join(', ')]); + } + if (!Directory(dest).existsSync()) return trf('대상 폴더가 없습니다: {0}', [dest]); + for (final s in sources) { + if (FileSystemEntity.isDirectorySync(s) && isSameOrInside(dest, s)) { + return trf('폴더를 자기 자신 안으로 {0} 수 없습니다: {1}', [move ? tr('옮길') : tr('복사할'), p.basename(s)]); + } + if (move && samePath(p.dirname(s), dest)) return trf('이미 이 폴더에 있습니다: {0}', [p.basename(s)]); + } + return null; +} + /// 복사 · 이동 중 알림 (지금 파일, 지금까지 바이트, 전체 바이트) 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 db4d976..79bf824 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -781,6 +781,17 @@ class _ExplorerPageState extends State { _snack(tr('앞의 복사 · 이동이 끝난 뒤에 하세요.')); return false; } + // 원본 · 대상이 없거나 (다른 곳에서 지움 - 목록을 새로 고침) 폴더를 자기 안으로 넣으려 하면 시작하지 않는다 + final problem = transferProblem(sources, dest, move: move); + if (problem != null) { + _snack(problem); + pane.marked.removeWhere((m) => FileSystemEntity.typeSync(m) == FileSystemEntityType.notFound); + await _refreshAll([dest, ...sources.map(p.dirname)]); + for (final x in _panes) { + x.changed(); + } + return false; + } // 복사 모니터링을 켜면 기억한 같은 복사의 옵션을 쓰고, 처음이면 기억한다 final center = CopyCenter.of(c); final task = c.settings.copyMonitor diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index 03d2467..c10efbb 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -222,7 +222,9 @@ class _CopyCardState extends State<_CopyCard> { return; } } + final problem = transferProblem(t.sources, t.dest, move: t.move); await center.start(t, rsyncExe: exe); + if (problem != null && mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problem))); } /// 옵션 저장 → 지금 실행할지 묻기 (현재 유지 / 반영 후 실행) diff --git a/test/file_ops_test.dart b/test/file_ops_test.dart index 625d08b..ddc4789 100644 --- a/test/file_ops_test.dart +++ b/test/file_ops_test.dart @@ -76,6 +76,21 @@ void main() { expect(b.existsSync(), isFalse); }); + test('시작 전 확인: 없는 원본 · 없는 대상 · 자기 안으로 · 같은 폴더로 이동', () { + final a = Directory(p.join(tmp.path, 'a'))..createSync(); + final inner = Directory(p.join(a.path, 'inner'))..createSync(); + final f = File(p.join(tmp.path, 'f.txt'))..writeAsStringSync('f'); + final other = Directory(p.join(tmp.path, 'other'))..createSync(); + expect(transferProblem([a.path, f.path], other.path, move: false), isNull); + expect(transferProblem([a.path], other.path, move: true), isNull); + expect(transferProblem([p.join(tmp.path, 'gone')], other.path, move: false), contains('원본이 없습니다')); + expect(transferProblem([a.path], p.join(tmp.path, 'nowhere'), move: false), contains('대상 폴더가 없습니다')); + expect(transferProblem([a.path], a.path, move: false), contains('자기 자신 안으로')); + expect(transferProblem([a.path], inner.path, move: true), contains('자기 자신 안으로')); + expect(transferProblem([f.path], tmp.path, move: true), contains('이미 이 폴더에')); + expect(transferProblem([f.path], tmp.path, move: false), isNull); // 같은 폴더에 복사는 "이름 (2)" + }); + test('복사 취소: 만들던 파일을 지우고 FileOpCancelled', () async { final big = file('big.bin', 'x' * (2 * 1024 * 1024)); final dst = Directory(p.join(tmp.path, 'dst'))..createSync(); diff --git a/test/monitor_test.dart b/test/monitor_test.dart index 4ca8332..6a14dd9 100644 --- a/test/monitor_test.dart +++ b/test/monitor_test.dart @@ -102,6 +102,18 @@ void main() { expect(Directory(p.join(dst, 'src', 'src')).existsSync(), isFalse); }); + test('기억한 복사의 원본이 없어졌거나 자기 안으로면 시작하지 않고 실패로 남긴다', () async { + c.settings.copyMethodFolder = 'rsync'; + final center = CopyCenter(c); + final t = await center.remember([src], src); // 자기 안으로 + expect(await center.start(t), isNull); + expect([center.tasks.single.lastResult, center.tasks.single.lastMessage], ['failed', contains('자기 자신 안으로')]); + final t2 = await center.remember([p.join(tmp.path, 'gone')], dst); + expect(await center.start(t2), isNull); + expect(center.tasks.firstWhere((x) => x.id == t2.id).lastMessage, contains('원본이 없습니다')); + expect(center.jobs, isEmpty); + }); + test('lsync: 맞출 것 자동으로 세기 · 일정 밖이면 맞추지 않음', () async { final target = p.join(dst, 'mirror'); // 일정: 지금이 아닌 시간 (지금 시 + 2)