WIP 78 보충 (15 확인 · 관리자 판단 a · b): Windows 에서 쓸 수 없는 이름 미리 막기 · 임시 이름은 고리일 때만 · 동기화에서 임시 이름 빼기 (검토 대기)

a 이름 검사 nameProblem (일괄 미리 보기 · 한 이름 바꾸기 · 새 폴더 공통):
  - 어디서나: 빈 이름 · . · .. · \ / : * ? " < > | · 제어 문자 (예전과 같음).
  - Windows 의 로컬 경로 (WebDAV 는 빼고): CON · PRN · AUX · NUL · COM1~9 · LPT1~9 (확장자가 붙어도), 끝이 점 · 빈칸, 255자 넘음.
    예전에는 미리 보기가 괜찮다고 보인 뒤 [바꾸기] 에서 실패해 되돌려지거나 '.' 이 말없이 빠질 수 있었다.
  - Android · WebDAV 는 지금처럼 (관리자 판단).
b 임시 이름 (~jjren_ · ~jjback_) 이 실시간 동기화 · rsync 에 섞이지 않게:
  - 일괄 바꾸기는 새 이름 자리가 비는 차례대로 바로 바꾸고, 고른 것끼리 고리로 엮였을 때 (a↔b) 만 하나를 임시 이름으로 비켜 둔다.
    되돌리기 (실패 · [되돌리기]) 도 같은 방식.
  - lsync: 감시 (Windows 폴더 감시) · 맞출 것 세기 (로컬 · WebDAV) · 지울 것 세기 · 앱이 맞추기 (로컬 · WebDAV) 에서 뺀다.
    rsync 인수 --exclude=~jjren_* --exclude=~jjback_*, robocopy /XF. 맞추기 미리 보기에서도 뺀다.
  - 탐색기 · Rsync 화면의 rsync 복사 · 이동 (rsyncArgs) 에도 같은 제외 (화면의 명령 줄에도 보임). robocopy 복사 · 이동은 그대로.
- 시험: 이름 검사 (예약 · 끝 점 · 빈칸 · 256자, Windows 규칙일 때만), 이어진 이름은 임시 이름 없이 · 고리는 하나만,
  실패 되돌림, lsync 가 임시 이름을 세지 않음 · 인수에 제외, rsync 인수 기대값.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음6
RyuJongJin 2026-10-17 11:42:57 +09:00
parent ef1cb069c1
commit a15f6c25af
11 changed files with 159 additions and 46 deletions

View File

@ -332,6 +332,7 @@
"Whisper base (빠름)": "Whisper base (fast)",
"Whisper small (정확)": "Whisper small (accurate)",
"Whisper 모델 (OpenAI)": "Whisper models (OpenAI)",
"Windows 가 쓰는 이름 ({0})": "Name reserved by Windows ({0})",
"Windows 기본 브라우저": "Windows default browser",
"Windows 기본 프로그램": "Windows default program",
"Windows 알림 설정 열기": "Open Windows notification settings",
@ -1762,6 +1763,7 @@
"이름": "Name",
"이름 (비워도 됨)": "Name (optional)",
"이름 (탭에 보임, 비우면 주소)": "Name (shown on the tab; address if empty)",
"이름 끝에 점 · 빈칸은 쓸 수 없음": "A name can't end with a dot or space",
"이름 바꾸기": "Rename",
"이름 바꾸기 (예: 이름 (2))": "Keep both (e.g. name (2))",
"이름 바꾸기 · 이동": "Rename · move",
@ -1772,6 +1774,7 @@
"이름을 바꿀 항목 하나를 고르세요.": "Choose one item to rename.",
"이름을 바꿉니다": "renames it",
"이름을 찾을 수 없습니다: {0} (DNS {1})": "Name not found: {0} (DNS {1})",
"이름이 너무 김 (255자 넘음)": "Name too long (over 255 characters)",
"이미 그리는 중입니다": "Already drawing",
"이미 동영상 목록에 있습니다 ({0}개).": "Already in the video list ({0}).",
"이미 만든 MKV 가 있습니다 ({0}개)": "Some MKVs already exist ({0})",

View File

@ -332,6 +332,7 @@
"Whisper base (빠름)": "Whisper base(高速)",
"Whisper small (정확)": "Whisper small(高精度)",
"Whisper 모델 (OpenAI)": "Whisper モデル (OpenAI)",
"Windows 가 쓰는 이름 ({0})": "Windows が使う名前 ({0})",
"Windows 기본 브라우저": "Windows の既定のブラウザー",
"Windows 기본 프로그램": "Windows の既定のプログラム",
"Windows 알림 설정 열기": "Windows の通知設定を開く",
@ -1762,6 +1763,7 @@
"이름": "名前",
"이름 (비워도 됨)": "名前 (空欄可)",
"이름 (탭에 보임, 비우면 주소)": "名前 (タブに表示、空ならアドレス)",
"이름 끝에 점 · 빈칸은 쓸 수 없음": "名前の末尾に点・空白は使えません",
"이름 바꾸기": "名前を変更",
"이름 바꾸기 (예: 이름 (2))": "名前を変える (例: 名前 (2))",
"이름 바꾸기 · 이동": "名前の変更・移動",
@ -1772,6 +1774,7 @@
"이름을 바꿀 항목 하나를 고르세요.": "名前を変更する項目を 1 つ選んでください。",
"이름을 바꿉니다": "名前を変えます",
"이름을 찾을 수 없습니다: {0} (DNS {1})": "名前が見つかりません: {0} (DNS {1})",
"이름이 너무 김 (255자 넘음)": "名前が長すぎます (255 文字超)",
"이미 그리는 중입니다": "すでに描画中です",
"이미 동영상 목록에 있습니다 ({0}개).": "既に動画リストにあります({0}個)。",
"이미 만든 MKV 가 있습니다 ({0}개)": "作成済みの MKV があります ({0}件)",

View File

@ -332,6 +332,7 @@
"Whisper base (빠름)": "Whisper base(快速)",
"Whisper small (정확)": "Whisper small(准确)",
"Whisper 모델 (OpenAI)": "Whisper 模型(OpenAI)",
"Windows 가 쓰는 이름 ({0})": "Windows 保留名称 ({0})",
"Windows 기본 브라우저": "Windows 默认浏览器",
"Windows 기본 프로그램": "Windows 默认程序",
"Windows 알림 설정 열기": "打开 Windows 通知设置",
@ -1762,6 +1763,7 @@
"이름": "名称",
"이름 (비워도 됨)": "名称 (可留空)",
"이름 (탭에 보임, 비우면 주소)": "名称 (显示在标签上,留空则显示地址)",
"이름 끝에 점 · 빈칸은 쓸 수 없음": "名称不能以点或空格结尾",
"이름 바꾸기": "重命名",
"이름 바꾸기 (예: 이름 (2))": "重命名 (例: 名称 (2))",
"이름 바꾸기 · 이동": "重命名 · 移动",
@ -1772,6 +1774,7 @@
"이름을 바꿀 항목 하나를 고르세요.": "请选择一个要重命名的项目。",
"이름을 바꿉니다": "重命名",
"이름을 찾을 수 없습니다: {0} (DNS {1})": "找不到名称: {0} (DNS {1})",
"이름이 너무 김 (255자 넘음)": "名称过长 (超过 255 个字符)",
"이미 그리는 중입니다": "已在绘制中",
"이미 동영상 목록에 있습니다 ({0}개).": "已在视频列表中({0} 个)。",
"이미 만든 MKV 가 있습니다 ({0}개)": "已有制作好的 MKV({0} 个)",

View File

@ -164,6 +164,7 @@ class LiveSync extends ChangeNotifier {
for (final e in items) {
if (out.length >= limit) return;
final name = p.basename(e.path);
if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞출 것이 아니다
names.add(name);
final r = rel.isEmpty ? name : '$rel/$name';
final t = p.join(dst, name);
@ -183,7 +184,7 @@ class LiveSync extends ChangeNotifier {
if (x.delete && await Directory(dst).exists()) {
await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) {
final name = p.basename(e.path);
if (!names.contains(name) && !name.endsWith('.jjsync')) out.add('− ${rel.isEmpty ? name : '$rel/$name'}');
if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) out.add('− ${rel.isEmpty ? name : '$rel/$name'}');
}
}
}
@ -213,6 +214,7 @@ class LiveSync extends ChangeNotifier {
for (final e in items) {
if (out.length >= limit) return;
final name = vBasename(e.path);
if (isAppTempName(name)) continue; // 78 보충
names.add(name);
final r = rel.isEmpty ? name : '$rel/$name';
if (e.isDir) {
@ -226,7 +228,7 @@ class LiveSync extends ChangeNotifier {
}
if (x.delete) {
for (final name in there.keys) {
if (!names.contains(name) && !name.endsWith('.jjsync')) out.add('− ${rel.isEmpty ? name : '$rel/$name'}');
if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) out.add('− ${rel.isEmpty ? name : '$rel/$name'}');
}
}
}
@ -338,7 +340,11 @@ class LiveSync extends ChangeNotifier {
}
if (Platform.isWindows && !isDav(x.source)) {
try {
_watch[k] = Directory(x.source).watch(recursive: true).listen((_) => _schedule(x));
// 78 보충: 이름 바꾸는 중의 임시 이름 (~jjren_ · ~jjback_) 은 바뀐 것으로 치지 않는다
_watch[k] = Directory(x.source).watch(recursive: true).listen((ev) {
if (isAppTempName(p.basename(ev.path))) return;
_schedule(x);
});
} catch (_) {
_timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x));
}
@ -472,6 +478,7 @@ class LiveSync extends ChangeNotifier {
if (!windows) '-8', // 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 (Android 빌드는 iconv 없음)
if (s.copyBandwidthKBps > 0) '--bwlimit=${s.copyBandwidthKBps}',
if (x.delete) '--delete',
...appTempRsyncExcludes, // 78 보충: 이름 바꾸는 중의 임시 이름은 보내지도 지우지도 않는다
'${toCygwinPath(x.source, windows: windows)}/',
'${toCygwinPath(x.target, windows: windows)}/',
];
@ -490,6 +497,7 @@ class LiveSync extends ChangeNotifier {
// 데이터: 링크 · junction 은 따라가지 않는다 (복사 · 이동과 같게 - robocopyExtraOptions)
if (!robocopyLinkOption(opts)) '/XJ',
if (x.delete) '/PURGE',
...appTempRobocopyExcludes, // 78 보충
if (s.copyBandwidthKBps > 0) '/IPG:${robocopyIpg(s.copyBandwidthKBps)}',
'/BYTES', '/NJH', '/NJS', '/NDL', '/NP',
];

View File

@ -79,8 +79,10 @@ List<RenameRow> planRename(List<String> paths, RenameRule rule,
final dir = vDirname(paths[i]);
final name = names[i];
String? problem;
if (name.isEmpty || name == '.' || name == '..' || name.contains(RegExp(r'[\\/:*?"<>|]'))) {
problem = tr('쓸 수 없는 이름');
// 78 보충: Windows 예약 이름 · 끝 점 · 빈칸 · 255자 (Windows 로컬 경로만)
final bad = nameProblem(name, windowsRules: usesWindowsNames(paths[i]));
if (bad != null) {
problem = bad;
} else if ([for (var k = 0; k < paths.length; k++) if (k != i && vDirname(paths[k]) == dir && same(names[k], name)) k].isNotEmpty) {
problem = tr('다른 항목과 같은 이름');
} else if (!same(name, vBasename(paths[i])) &&
@ -105,8 +107,10 @@ class BatchRenameException implements Exception {
String toString() => '$path: $error';
}
/// 바꾸기: 먼저 모두 임시 이름으로, 그다음 새 이름으로 (맞바꾸기 a↔b 도 되게).
/// 도중에 실패하면 이미 움직인 것을 원래 이름으로 되돌리고 (되돌리기도 임시 이름을 거쳐) [BatchRenameException].
/// 바꾸기. 78 보충 (관리자 판단): 임시 이름은 꼭 필요할 때만 - 새 이름 자리가 비는 차례대로 바로 바꾸고,
/// 고른 것끼리 고리로 엮여 (a↔b) 아무것도 먼저 바꿀 수 없을 때만 하나를 임시 이름 (~jjren_) 으로 비켜 둔다.
/// (예전에는 모두 임시 이름을 거쳐, 실시간 동기화가 그 순간을 맞추면 백업 쪽에 지우기 · 새 파일이 생길 수 있었다)
/// 도중에 실패하면 이미 움직인 것을 같은 방식으로 원래 이름으로 되돌리고 (~jjback_) [BatchRenameException].
/// 돌려주는 값: (원래 경로, 새 경로) - 되돌리기에 쓴다
Future<List<(String, String)>> runBatchRename(List<(String path, String newName)> jobs,
{Future<String> Function(String path, String name)? renamer}) async {
@ -116,38 +120,48 @@ Future<List<(String, String)>> runBatchRename(List<(String path, String newName)
// 항목마다 지금 있는 곳
final now = [for (final j in todo) j.$1];
Future<Never> rollback(String at, Object e) async {
final stuck = <String>[];
final moved = [for (var i = 0; i < todo.length; i++) if (now[i] != todo[i].$1) i];
// 되돌리기도 두 단계 (맞바꾼 이름끼리 부딪히지 않게)
for (final i in moved) {
/// [idx] 의 항목을 [want] 이름으로. [stuck] 이 있으면 (되돌리기) 실패한 것을 거기 남기고 계속
Future<void> settle(List<int> idx, String Function(int) want, String tempTag, {List<String>? stuck}) async {
bool same(String a, String b) => a.toLowerCase() == b.toLowerCase();
final pending = [...idx];
while (pending.isNotEmpty) {
var moved = false;
for (final i in [...pending]) {
final name = want(i);
final dir = vDirname(now[i]);
// 다른 남은 항목이 아직 그 이름을 쓰고 있으면 그것이 먼저 비켜야 한다
if (pending.any((j) => j != i && vDirname(now[j]) == dir && same(vBasename(now[j]), name))) continue;
try {
now[i] = await ren(now[i], name);
} catch (e) {
if (stuck == null) rethrow;
stuck.add(now[i]);
}
pending.remove(i);
moved = true;
}
if (moved) continue;
// 고리 (맞바꾸기 등): 하나를 임시 이름으로 비켜 둔다
final i = pending.first;
try {
now[i] = await ren(now[i], '~jjback_${stamp}_$i');
} catch (_) {}
}
for (final i in moved) {
try {
now[i] = await ren(now[i], vBasename(todo[i].$1));
} catch (_) {
now[i] = await ren(now[i], '~${tempTag}_${stamp}_$i');
} catch (e) {
if (stuck == null) rethrow;
stuck.add(now[i]);
pending.remove(i);
}
}
throw BatchRenameException(at, e, stuck);
}
for (var i = 0; i < todo.length; i++) {
try {
now[i] = await ren(now[i], '~jjren_${stamp}_$i');
} catch (e) {
await rollback(todo[i].$1, e);
}
}
for (var i = 0; i < todo.length; i++) {
try {
now[i] = await ren(now[i], todo[i].$2);
} catch (e) {
await rollback(todo[i].$1, e);
}
try {
await settle([for (var i = 0; i < todo.length; i++) i], (i) => todo[i].$2, 'jjren');
} catch (e) {
final moved = [for (var i = 0; i < todo.length; i++) if (now[i] != todo[i].$1) i];
// 멈춘 곳: 아직 움직이지 않은 첫 항목 (없으면 첫 항목)
final at = [for (var i = 0; i < todo.length; i++) if (now[i] == todo[i].$1) todo[i].$1].firstOrNull ?? todo.first.$1;
final stuck = <String>[];
await settle(moved, (i) => vBasename(todo[i].$1), 'jjback', stuck: stuck);
throw BatchRenameException(at, e, stuck);
}
return [for (var i = 0; i < todo.length; i++) (todo[i].$1, now[i])];
}

View File

@ -298,6 +298,29 @@ String uncRoot(String path) {
/// 복사 · 동기화가 다 쓴 뒤 이름을 바꾸기 전의 임시 파일 (끊기면 남는다 - 71)
bool isPartialFile(String name) => name.endsWith('.jjpart') || name.endsWith('.jjsync');
/// 78 보충 (관리자 판단): 일괄 이름 바꾸기가 잠깐 쓰는 임시 이름 (~jjren_ · ~jjback_) - 실시간 동기화 · 맞추기 · rsync 에 섞이지 않게
bool isAppTempName(String name) => name.startsWith('~jjren_') || name.startsWith('~jjback_');
/// rsync · robocopy 에 넣을 임시 이름 제외 (앱이 만드는 인수)
const appTempRsyncExcludes = ['--exclude=~jjren_*', '--exclude=~jjback_*'];
const appTempRobocopyExcludes = ['/XF', '~jjren_*', '~jjback_*'];
/// 78 보충 (관리자 판단): 이름으로 쓸 수 없는 까닭 (괜찮으면 null).
/// 모든 곳: 빈 이름 · . · .. · \ / : * ? " < > | · 제어 문자.
/// [windowsRules] (Windows 의 로컬 경로): CON · PRN · AUX · NUL · COM1~9 · LPT1~9 (확장자가 붙어도), 끝이 점 · 빈칸, 255자 넘음
String? nameProblem(String name, {required bool windowsRules}) {
if (name.isEmpty || name == '.' || name == '..' || name.contains(RegExp(r'[\\/:*?"<>|\x00-\x1f]'))) return tr('쓸 수 없는 이름');
if (!windowsRules) return null;
final base = name.split('.').first.trim().toUpperCase();
if (RegExp(r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$').hasMatch(base)) return trf('Windows 가 쓰는 이름 ({0})', [base]);
if (name.endsWith('.') || name.endsWith(' ')) return tr('이름 끝에 점 · 빈칸은 쓸 수 없음');
if (name.length > 255) return tr('이름이 너무 김 (255자 넘음)');
return null;
}
/// 이 경로에 Windows 이름 규칙을 쓰는지 (Windows 의 로컬 · 네트워크 공유. WebDAV 는 서버 규칙이라 빼고)
bool usesWindowsNames(String path) => Platform.isWindows && !isDav(path);
/// "지우기 포함" 으로 맞추기 전에: 원본을 읽을 수 있는지, 원본이 통째로 비었는데 대상에 파일이 있지 않은지.
/// 문제가 있으면 [SourceUnreadableException] (아무것도 지우지 않게).
/// [allowEmpty]: 사용자가 지울 목록을 보고 "원본이 비어도 그래도 맞추기" 를 고른 경우 (70) - 읽지 못하는 것은 그래도 막는다
@ -602,6 +625,7 @@ class FileOps {
}
for (final e in items) {
final name = p.basename(e.path);
if (isAppTempName(name)) continue; // 78 보충: 이름 바꾸는 중의 임시 이름은 맞추지 않는다
names.add(name);
final target = p.join(dst, name);
if (e is Directory) {
@ -641,7 +665,7 @@ class FileOps {
}
if (delete) {
await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) {
if (!names.contains(p.basename(e.path)) && !e.path.endsWith('.jjsync')) {
if (!names.contains(p.basename(e.path)) && !e.path.endsWith('.jjsync') && !isAppTempName(p.basename(e.path))) {
await e.delete(recursive: true);
}
}
@ -671,9 +695,8 @@ class FileOps {
/// 이름 바꾸기 (같은 폴더 안). 새 경로를 돌려준다.
static Future<String> rename(String path, String newName) async {
final name = newName.trim();
if (name.isEmpty || name.contains(RegExp(r'[\\/:*?"<>|]'))) {
throw FileSystemException(tr('쓸 수 없는 이름입니다'), name);
}
final bad = nameProblem(name, windowsRules: usesWindowsNames(path));
if (bad != null) throw FileSystemException(bad, name);
if (isDav(path)) {
final target = vJoin(vDirname(path), name);
if (target == vNorm(path)) return target;
@ -695,7 +718,8 @@ class FileOps {
static Future<String> makeFolder(String dir, String name) async {
final n = name.trim();
if (n.isEmpty || n.contains(RegExp(r'[\\/:*?"<>|]'))) throw FileSystemException(tr('쓸 수 없는 이름입니다'), n);
final bad = nameProblem(n, windowsRules: usesWindowsNames(dir));
if (bad != null) throw FileSystemException(bad, n);
if (isDav(dir)) {
final target = vJoin(dir, n);
if (await vExists(target)) throw FileSystemException(tr('같은 이름이 이미 있습니다'), target);
@ -874,6 +898,7 @@ class FileOps {
}
for (final e in items) {
final name = vBasename(e.path);
if (isAppTempName(name)) continue; // 78 보충
names.add(name);
final target = vJoin(dst, name);
// 214: 로컬 원본의 링크 · junction 은 따라가지 않는다 (로컬끼리 맞출 때처럼 - 링크가 가리키는 바깥 폴더를 건드리지 않게)
@ -900,7 +925,7 @@ class FileOps {
}
if (delete) {
for (final name in there.keys) {
if (!names.contains(name) && !name.endsWith('.jjsync')) await vDelete(vJoin(dst, name));
if (!names.contains(name) && !name.endsWith('.jjsync') && !isAppTempName(name)) await vDelete(vJoin(dst, name));
}
}
}

View File

@ -1,6 +1,6 @@
import 'dart:io' show FileSystemEntity;
import 'file_ops.dart' show isPartialFile, SourceUnreadableException;
import 'file_ops.dart' show isAppTempName, isPartialFile, SourceUnreadableException;
import 'sync_tools.dart' show splitOptions, isSourceRemovingOption;
export 'sync_tools.dart' show isSourceRemovingOption;
@ -116,7 +116,7 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
for (final e in items) {
if (out.length >= limit) return;
final name = vBasename(e.path);
if (isPartialFile(name)) continue;
if (isPartialFile(name) || isAppTempName(name)) continue;
names.add(name);
final r = rel.isEmpty ? name : '$rel/$name';
final t = there[name];
@ -147,7 +147,7 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
}
for (final e in there.values) {
final name = vBasename(e.path);
if (names.contains(name) || isPartialFile(name)) continue;
if (names.contains(name) || isPartialFile(name) || isAppTempName(name)) continue;
out.add(PreviewItem(rel.isEmpty ? name : '$rel/$name', delete ? PreviewAction.delete : PreviewAction.onlyTarget,
toRight: toRight, isDir: e.isDir, size: e.size));
}

View File

@ -1,5 +1,7 @@
import 'package:path/path.dart' as p;
import 'file_ops.dart' show appTempRsyncExcludes;
/// 파일 탐색기의 복사 · 이동 방법 (환경 설정 > 파일 탐색기 > 복사 · 동기화)
///
/// - builtin: 앱이 직접 복사 ("현재 방식"). 모든 기기.
@ -83,6 +85,7 @@ List<String> rsyncArgs({
// 한글 등 이름을 \#355… 로 바꾸지 않고 그대로 출력 (Android 빌드는 iconv 없음 · 진행 표시에 쓰는 이름)
if (!windows && !opts.contains('-8') && !opts.contains('--8-bit-output')) '-8',
if (move) '--remove-source-files',
...appTempRsyncExcludes, // 78 보충: 일괄 이름 바꾸기 중의 임시 이름 (~jjren_ · ~jjback_) 은 보내지 않는다
// [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양)
for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}',
'${toCygwinPath(_noTrailingSlash(dest), windows: windows)}/',

View File

@ -58,6 +58,19 @@ void main() {
});
});
// 78 보충 (관리자 판단 a): Windows 에서 쓸 수 없는 이름
test('이름 검사: 예약 이름 (확장자 붙어도) · 끝 점 · 끝 빈칸 · 255자 넘음 - Windows 규칙일 때만', () {
for (final n in ['CON', 'con.txt', 'Nul.mkv', 'LPT1', 'com9.srt', 'a.', 'a ', 'x' * 256]) {
expect(nameProblem(n, windowsRules: true), isNotNull, reason: n);
expect(nameProblem(n, windowsRules: false), isNull, reason: '$n (Android · WebDAV 는 지금처럼)');
}
for (final n in ['CONSOLE.txt', 'com10', 'a.b', 'x' * 255, 'Show S01E01.mkv']) {
expect(nameProblem(n, windowsRules: true), isNull, reason: n);
}
expect(nameProblem('a:b', windowsRules: false), '쓸 수 없는 이름', reason: '쓸 수 없는 글자는 어디서나');
expect(nameProblem('', windowsRules: false), '쓸 수 없는 이름');
});
group('바꾸기 (실제 파일)', () {
late Directory tmp;
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_ren78_'));
@ -65,6 +78,34 @@ void main() {
String mk(String n, [String body = '']) => (File(p.join(tmp.path, n))..writeAsStringSync(body.isEmpty ? n : body)).path;
List<String> names() => (tmp.listSync().map((e) => p.basename(e.path)).toList()..sort());
test('78 보충 b: 엮이지 않은 이름 · 이어진 이름 (a→b, b→c) 은 임시 이름 없이 바로, 고리 (a↔b) 만 임시 이름 하나', () async {
final used = <String>[];
Future<String> spy(String path, String name) {
used.add(name);
return FileOps.rename(path, name);
}
final x = mk('x.txt'), a = mk('a.txt'), b = mk('b.txt');
await runBatchRename([(x, 'y.txt'), (a, 'b.txt'), (b, 'c.txt')], renamer: spy);
expect(used.where(isAppTempName), isEmpty, reason: '$used');
expect(used.indexOf('c.txt'), lessThan(used.indexOf('b.txt')), reason: 'b 가 먼저 비켜야 a 가 b 로');
expect(names(), ['b.txt', 'c.txt', 'y.txt']);
used.clear();
await runBatchRename([(p.join(tmp.path, 'b.txt'), 'c.txt'), (p.join(tmp.path, 'c.txt'), 'b.txt')], renamer: spy);
expect(used.where(isAppTempName), hasLength(1), reason: '$used');
expect(File(p.join(tmp.path, 'c.txt')).readAsStringSync(), 'a.txt');
});
test('Windows: 미리 보기에서 예약 이름 · 끝 점을 빨갛게 (바꾸기 전에)', () {
final a = mk('a.txt');
final rows = planRename([a], const RenameRule(template: 'CON'), isDir: (_) => false, siblings: {tmp.path: {'a.txt'}});
expect(rows.single.problem, Platform.isWindows ? 'Windows 가 쓰는 이름 (CON)' : isNull);
final dir = Directory(p.join(tmp.path, 'd'))..createSync();
final dots = planRename([dir.path], const RenameRule(template: 'end.'), isDir: (_) => true, siblings: {tmp.path: {'d'}});
expect(dots.single.problem, Platform.isWindows ? '이름 끝에 점 · 빈칸은 쓸 수 없음' : isNull);
}, skip: !Platform.isWindows);
test('여러 개 · 맞바꾸기 (a↔b) · 되돌리기', () async {
final a = mk('a.txt'), b = mk('b.txt'), c = mk('c.txt');
final done = await runBatchRename([(a, 'b.txt'), (b, 'a.txt'), (c, 'Show 01.txt')]);
@ -81,8 +122,8 @@ void main() {
final a = mk('a.txt'), b = mk('b.txt'), c = mk('c.txt');
var calls = 0;
Future<String> flaky(String path, String name) {
// 2단계 (새 이름으로) 의 세 번째에서 실패
if (++calls == 6) throw const FileSystemException('잠김');
// 세 번째 바꾸기에서 실패 (앞의 둘은 이미 바뀜)
if (++calls == 3) throw const FileSystemException('잠김');
return FileOps.rename(path, name);
}

View File

@ -172,6 +172,19 @@ void main() {
expect(await LiveSync.diff(pair), ['− extra.txt']);
live.dispose();
});
test('78 보충 b: lsync 는 일괄 이름 바꾸기의 임시 이름 (~jjren_ · ~jjback_) 을 맞출 것으로 세지 않고, rsync 인수에도 제외', () async {
final target = p.join(dst, 'mirror2');
final pair = LiveSyncPair(src, target, delete: true);
File(p.join(src, '~jjren_abc_0')).writeAsStringSync('x');
Directory(target).createSync();
File(p.join(target, '~jjback_abc_1')).writeAsStringSync('x');
final pending = await LiveSync.diff(pair);
expect(pending.where((x) => x.contains('~jj')), isEmpty, reason: '$pending');
final args = LiveSync.rsyncArgsFor(pair, c.settings, windows: true);
expect(args, containsAll(['--exclude=~jjren_*', '--exclude=~jjback_*']));
expect(LiveSync.robocopyArgsFor(pair, c.settings), containsAll(['/XF', '~jjren_*', '~jjback_*']));
});
});
group('백그라운드 · 다시 켤 때 동기화', () {

View File

@ -30,10 +30,10 @@ void main() {
test('rsync 인수: 옵션 · 속도 제한 · 이동 · 원본 (끝 / 없음) · 대상/', () {
expect(
rsyncArgs(options: '-avPog', sources: [r'C:\a\src\', r'C:\a\f.mkv'], dest: r'E:\dst', bandwidthKBps: 500, move: true),
['-avPog', '--bwlimit=500', '--remove-source-files', '/cygdrive/c/a/src', '/cygdrive/c/a/f.mkv', '/cygdrive/e/dst/']);
['-avPog', '--bwlimit=500', '--remove-source-files', '--exclude=~jjren_*', '--exclude=~jjback_*', '/cygdrive/c/a/src', '/cygdrive/c/a/f.mkv', '/cygdrive/e/dst/']); // 78 보충: 일괄 이름 바꾸기 임시 이름 제외
// 옵션 칸에 직접 넣은 속도 제한이 우선
expect(rsyncArgs(options: '-a --bwlimit=10', sources: ['/x'], dest: '/y', bandwidthKBps: 500, windows: false),
['-a', '--bwlimit=10', '-8', '/x', '/y/']); // Android: 한글 이름을 그대로 출력 (-8)
['-a', '--bwlimit=10', '-8', '--exclude=~jjren_*', '--exclude=~jjback_*', '/x', '/y/']); // Android: 한글 이름을 그대로 출력 (-8)
});
test('파일 탐색기 복사 방법: 현재 방식 (기본) · robocopy, 예전 값 rsync 는 현재 방식으로 (rsync 는 Rsync 화면)', () {