복사 · 이동 시작 전 확인: 없는 원본 · 없는 대상 · 폴더를 자기 안으로 · 같은 폴더로 이동

- 다른 곳에서 지워진 폴더 (탐색기 목록에는 남아 있던 것) 를 복사 · 이동하면 rsync 오류 대신 알리고 목록을 새로 고침
- 폴더를 자기 자신 (또는 그 안) 으로 복사 · 이동하는 것을 막음 (현재 방식만 막고 있었고 rsync 는 막지 않았음)
- 모니터링의 실행 · 재개도 같은 확인: 시작하지 않고 실패와 이유를 남김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-05 14:08:53 +09:00
parent ae1592d244
commit 9ce55bbb74
9 changed files with 87 additions and 0 deletions

View File

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

View File

@ -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 へ移せるのはフォルダーだけです。",
"폴란드어": "ポーランド語",
"표시 (여러 개 고르기)": "マーク (複数選択)",

View File

@ -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。",
"폴란드어": "波兰语",
"표시 (여러 개 고르기)": "标记 (多选)",

View File

@ -76,8 +76,16 @@ class CopyCenter extends ChangeNotifier {
void cancel(String id) => jobs[id]?.cancel();
/// 실행을 시작하고 그 작업을 바로 돌려준다 (rsync 가 없으면 null). 끝나면 결과를 기억한다 ([TransferJob.done]).
/// 실행 · 재개. 원본 · 대상이 없거나 폴더를 자기 안으로 넣는 것이면 시작하지 않고 실패로 남긴다 (null).
Future<TransferJob?> 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;

View File

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

View File

@ -781,6 +781,17 @@ class _ExplorerPageState extends State<ExplorerPage> {
_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

View File

@ -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)));
}
/// 옵션 저장 → 지금 실행할지 묻기 (현재 유지 / 반영 후 실행)

View File

@ -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();

View File

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