미리 보기: 원본의 링크 · junction 은 따라가지 않음 (231)

- 버그: 미리 보기가 링크 안으로 들어가 실제로는 건너뛰는 (214) 바깥 파일을 옮길 것으로 보였다
- 앱이 직접 (현재 방식 · WebDAV) 이면 '링크 · junction - 따라가지 않고 건너뜀', rsync 는 링크 하나로 셈

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix_mkv
RyuJongJin 2026-10-12 00:06:43 +09:00
parent 41d4d8afbf
commit d86a929ba1
7 changed files with 43 additions and 4 deletions

View File

@ -49,6 +49,7 @@
"(더 새 파일을 덮어씀)": "(overwrites a newer file)", "(더 새 파일을 덮어씀)": "(overwrites a newer file)",
"(덮어씀)": "(overwritten)", "(덮어씀)": "(overwritten)",
"(동영상을 세 번 누르면 옮깁니다)": "(Tapping a video three times moves it)", "(동영상을 세 번 누르면 옮깁니다)": "(Tapping a video three times moves it)",
"(링크 · junction - 따라가지 않고 건너뜀)": "(link · junction - not followed, skipped)",
"(받는 쪽에만 있음)": "(only on receiving side)", "(받는 쪽에만 있음)": "(only on receiving side)",
"(받는 쪽이 더 새것 - 건너뜀)": "(receiving side is newer - skipped)", "(받는 쪽이 더 새것 - 건너뜀)": "(receiving side is newer - skipped)",
"(비어 있음)": "(empty)", "(비어 있음)": "(empty)",
@ -787,6 +788,7 @@
"로그인이 필요합니다 (403)": "Login required (403)", "로그인이 필요합니다 (403)": "Login required (403)",
"로그인하면 하루 받기 횟수가 늘어납니다": "Signing in raises the daily download limit", "로그인하면 하루 받기 횟수가 늘어납니다": "Signing in raises the daily download limit",
"루마니아어": "Romanian", "루마니아어": "Romanian",
"링크 {0} (건너뜀)": "Links {0} (skipped)",
"마스터 · 최상 비밀번호": "Master and super passwords", "마스터 · 최상 비밀번호": "Master and super passwords",
"마스터 비밀번호": "Master password", "마스터 비밀번호": "Master password",
"마스터 비밀번호 넣기": "Enter master password", "마스터 비밀번호 넣기": "Enter master password",

View File

@ -49,6 +49,7 @@
"(더 새 파일을 덮어씀)": "(新しいファイルを上書き)", "(더 새 파일을 덮어씀)": "(新しいファイルを上書き)",
"(덮어씀)": "(上書き)", "(덮어씀)": "(上書き)",
"(동영상을 세 번 누르면 옮깁니다)": "(動画を 3 回タップすると移動します)", "(동영상을 세 번 누르면 옮깁니다)": "(動画を 3 回タップすると移動します)",
"(링크 · junction - 따라가지 않고 건너뜀)": "(リンク · ジャンクション - たどらずにスキップ)",
"(받는 쪽에만 있음)": "(受け取る側にだけある)", "(받는 쪽에만 있음)": "(受け取る側にだけある)",
"(받는 쪽이 더 새것 - 건너뜀)": "(受け取る側が新しい - スキップ)", "(받는 쪽이 더 새것 - 건너뜀)": "(受け取る側が新しい - スキップ)",
"(비어 있음)": "(空)", "(비어 있음)": "(空)",
@ -787,6 +788,7 @@
"로그인이 필요합니다 (403)": "ログインが必要です (403)", "로그인이 필요합니다 (403)": "ログインが必要です (403)",
"로그인하면 하루 받기 횟수가 늘어납니다": "ログインすると 1日のダウンロード回数が増えます", "로그인하면 하루 받기 횟수가 늘어납니다": "ログインすると 1日のダウンロード回数が増えます",
"루마니아어": "ルーマニア語", "루마니아어": "ルーマニア語",
"링크 {0} (건너뜀)": "リンク {0} (スキップ)",
"마스터 · 최상 비밀번호": "マスター / 最上位パスワード", "마스터 · 최상 비밀번호": "マスター / 最上位パスワード",
"마스터 비밀번호": "マスターパスワード", "마스터 비밀번호": "マスターパスワード",
"마스터 비밀번호 넣기": "マスターパスワードを入力", "마스터 비밀번호 넣기": "マスターパスワードを入力",

View File

@ -49,6 +49,7 @@
"(더 새 파일을 덮어씀)": "(覆盖较新的文件)", "(더 새 파일을 덮어씀)": "(覆盖较新的文件)",
"(덮어씀)": "(覆盖)", "(덮어씀)": "(覆盖)",
"(동영상을 세 번 누르면 옮깁니다)": "(连按视频三次会移动它)", "(동영상을 세 번 누르면 옮깁니다)": "(连按视频三次会移动它)",
"(링크 · junction - 따라가지 않고 건너뜀)": "(链接 · 联接 - 不跟随,已跳过)",
"(받는 쪽에만 있음)": "(仅在接收方)", "(받는 쪽에만 있음)": "(仅在接收方)",
"(받는 쪽이 더 새것 - 건너뜀)": "(接收端较新 - 已跳过)", "(받는 쪽이 더 새것 - 건너뜀)": "(接收端较新 - 已跳过)",
"(비어 있음)": "(空)", "(비어 있음)": "(空)",
@ -787,6 +788,7 @@
"로그인이 필요합니다 (403)": "需要登录 (403)", "로그인이 필요합니다 (403)": "需要登录 (403)",
"로그인하면 하루 받기 횟수가 늘어납니다": "登录后每日下载次数会增加", "로그인하면 하루 받기 횟수가 늘어납니다": "登录后每日下载次数会增加",
"루마니아어": "罗马尼亚语", "루마니아어": "罗马尼亚语",
"링크 {0} (건너뜀)": "链接 {0}(跳过)",
"마스터 · 최상 비밀번호": "主密码和最高密码", "마스터 · 최상 비밀번호": "主密码和最高密码",
"마스터 비밀번호": "主密码", "마스터 비밀번호": "主密码",
"마스터 비밀번호 넣기": "输入主密码", "마스터 비밀번호 넣기": "输入主密码",

View File

@ -1,3 +1,5 @@
import 'dart:io' show FileSystemEntity;
import 'file_ops.dart' show isPartialFile, SourceUnreadableException; import 'file_ops.dart' show isPartialFile, SourceUnreadableException;
import 'sync_tools.dart' show splitOptions, isSourceRemovingOption; import 'sync_tools.dart' show splitOptions, isSourceRemovingOption;
@ -20,6 +22,9 @@ enum PreviewAction {
/// 229: 받는 쪽이 더 새것이라 건너뜀 (-u · 환경 설정 "받는 쪽이 더 새것이면 건너뛰기") /// 229: 받는 쪽이 더 새것이라 건너뜀 (-u · 환경 설정 "받는 쪽이 더 새것이면 건너뛰기")
skipNewer, skipNewer,
/// 231: 원본의 링크 · junction - 앱이 직접 맞출 때는 따라가지 않고 건너뛴다 (214)
skipLink,
} }
class PreviewItem { class PreviewItem {
@ -86,8 +91,10 @@ bool optionsUpdate(String options) =>
/// [src] 안의 것 → [dst] 안으로 맞출 때 바뀌는 것. 원본을 읽지 못한 폴더에서는 "지워짐" 을 세지 않는다. /// [src] 안의 것 → [dst] 안으로 맞출 때 바뀌는 것. 원본을 읽지 못한 폴더에서는 "지워짐" 을 세지 않는다.
/// [limit] 개를 넘으면 멈춘다 (화면에는 "… 이상"). /// [limit] 개를 넘으면 멈춘다 (화면에는 "… 이상").
/// 231: 원본의 링크 · junction 은 안으로 들어가지 않는다. [skipLinks] (앱이 직접 - 현재 방식 · WebDAV) 면 "건너뜀 (링크)",
/// 아니면 (rsync -a · robocopy) 링크 자체 하나로 센다
Future<List<PreviewItem>> previewSync(String src, String dst, Future<List<PreviewItem>> previewSync(String src, String dst,
{required bool toRight, bool delete = false, bool update = false, int limit = 2000}) async { {required bool toRight, bool delete = false, bool update = false, bool skipLinks = false, int limit = 2000}) async {
final out = <PreviewItem>[]; final out = <PreviewItem>[];
Future<void> walk(String s, String d, String rel) async { Future<void> walk(String s, String d, String rel) async {
if (out.length >= limit) return; if (out.length >= limit) return;
@ -113,6 +120,14 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
names.add(name); names.add(name);
final r = rel.isEmpty ? name : '$rel/$name'; final r = rel.isEmpty ? name : '$rel/$name';
final t = there[name]; final t = there[name];
if (!isDav(e.path) && FileSystemEntity.isLinkSync(e.path)) {
if (skipLinks) {
out.add(PreviewItem(r, PreviewAction.skipLink, toRight: toRight, isDir: e.isDir));
} else if (t == null) {
out.add(PreviewItem(r, PreviewAction.add, toRight: toRight));
}
continue;
}
if (e.isDir) { if (e.isDir) {
if (t == null) out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, isDir: true)); if (t == null) out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, isDir: true));
await walk(e.path, vJoin(d, name), r); await walk(e.path, vJoin(d, name), r);

View File

@ -2166,7 +2166,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
toRight: t.sources.first == l, toRight: t.sources.first == l,
delete: deletes(), delete: deletes(),
// 229: WebDAV 가 끼면 앱이 직접 - 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" 도 따른다 // 229: WebDAV 가 끼면 앱이 직접 - 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" 도 따른다
update: optionsUpdate(opts(t)) || (_viaDav([l, r]) && c.settings.copySkipNewer)), update: optionsUpdate(opts(t)) || (_viaDav([l, r]) && c.settings.copySkipNewer),
skipLinks: _viaDav([l, r])),
], ],
// 97: 다시 비교하는 동안은 예전 결과로 실행하지 못하게 // 97: 다시 비교하는 동안은 예전 결과로 실행하지 못하게
onStart: () => set(() { onStart: () => set(() {

View File

@ -100,6 +100,7 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
final overwrites = n(PreviewAction.update); final overwrites = n(PreviewAction.update);
final overNewer = items.where((x) => x.action == PreviewAction.update && x.newer).length; final overNewer = items.where((x) => x.action == PreviewAction.update && x.newer).length;
final skipped = n(PreviewAction.skipNewer); final skipped = n(PreviewAction.skipNewer);
final links = n(PreviewAction.skipLink);
final more = items.length >= widget.limit; final more = items.length >= widget.limit;
final parts = [ final parts = [
for (final right in [true, false]) for (final right in [true, false])
@ -107,6 +108,7 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
trf('{0} 새로 {1} · 덮어씀 {2}', [right ? '→' : '←', n(PreviewAction.add, right), n(PreviewAction.update, right)]), trf('{0} 새로 {1} · 덮어씀 {2}', [right ? '→' : '←', n(PreviewAction.add, right), n(PreviewAction.update, right)]),
if (n(PreviewAction.onlyTarget) > 0) trf('받는 쪽에만 있음 {0} (그대로 둠)', [n(PreviewAction.onlyTarget)]), if (n(PreviewAction.onlyTarget) > 0) trf('받는 쪽에만 있음 {0} (그대로 둠)', [n(PreviewAction.onlyTarget)]),
if (skipped > 0) trf('받는 쪽이 더 새것 {0} (건너뜀)', [skipped]), if (skipped > 0) trf('받는 쪽이 더 새것 {0} (건너뜀)', [skipped]),
if (links > 0) trf('링크 {0} (건너뜀)', [links]),
]; ];
return Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ return Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Row(children: [ Row(children: [
@ -149,6 +151,7 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
PreviewAction.update => PreviewAction.update =>
'${_arrow(x)} ~ ${x.rel} (${formatSize(x.size)}) ${x.newer ? tr('(더 새 파일을 덮어씀)') : tr('(덮어씀)')}', '${_arrow(x)} ~ ${x.rel} (${formatSize(x.size)}) ${x.newer ? tr('(더 새 파일을 덮어씀)') : tr('(덮어씀)')}',
PreviewAction.skipNewer => ' · ${x.rel} ${tr('(받는 쪽이 더 새것 - 건너뜀)')}', PreviewAction.skipNewer => ' · ${x.rel} ${tr('(받는 쪽이 더 새것 - 건너뜀)')}',
PreviewAction.skipLink => ' · ${x.rel}${x.isDir ? '/' : ''} ${tr('(링크 · junction - 따라가지 않고 건너뜀)')}',
PreviewAction.onlyTarget => ' = ${x.rel}${x.isDir ? '/' : ''} ${tr('(받는 쪽에만 있음)')}', PreviewAction.onlyTarget => ' = ${x.rel}${x.isDir ? '/' : ''} ${tr('(받는 쪽에만 있음)')}',
PreviewAction.delete => '${_arrow(x)} − ${x.rel}${x.isDir ? '/' : ''} ${tr('(지워짐)')}', PreviewAction.delete => '${_arrow(x)} − ${x.rel}${x.isDir ? '/' : ''} ${tr('(지워짐)')}',
}, },
@ -158,7 +161,7 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
color: switch (x.action) { color: switch (x.action) {
PreviewAction.delete => Colors.redAccent, PreviewAction.delete => Colors.redAccent,
PreviewAction.update => Colors.orangeAccent, PreviewAction.update => Colors.orangeAccent,
PreviewAction.onlyTarget || PreviewAction.skipNewer => JjColors.textDim, PreviewAction.onlyTarget || PreviewAction.skipNewer || PreviewAction.skipLink => JjColors.textDim,
_ => null, _ => null,
}, },
), ),
@ -243,7 +246,7 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
const SizedBox(height: 8), const SizedBox(height: 8),
SyncPreviewView( SyncPreviewView(
compute: () async => [ compute: () async => [
for (final s in t.sources) ...await previewSync(s, dstOf(s), toRight: true, delete: deletes, update: update), for (final s in t.sources) ...await previewSync(s, dstOf(s), toRight: true, delete: deletes, update: update, skipLinks: direct),
], ],
onStart: () => set(() { onStart: () => set(() {
preview = null; preview = null;

View File

@ -77,6 +77,20 @@ void main() {
}); });
}); });
test('231: 원본의 링크 · junction 은 안으로 들어가지 않는다 - 앱이 직접이면 "건너뜀 (링크)", rsync 면 링크 하나', () async {
final outside = p.join(tmp.path, 'outside');
f(outside, 'big.bin', 'x', old);
Link(p.join(a, 'linked')).createSync(outside); // Windows 는 junction
final direct = await previewSync(a, b, toRight: true, skipLinks: true);
expect([for (final x in direct) if (x.rel.startsWith('linked')) (x.rel, x.action)], [('linked', PreviewAction.skipLink)]);
final viaRsync = await previewSync(a, b, toRight: true);
expect([for (final x in viaRsync) if (x.rel.startsWith('linked')) (x.rel, x.action)], [('linked', PreviewAction.add)]);
// 받는 쪽에 같은 이름이 있으면 지울 것으로 세지 않는다 (맞추기도 지우지 않음 - 214)
Directory(p.join(b, 'linked')).createSync();
final del = await previewSync(a, b, toRight: true, delete: true, skipLinks: true);
expect([for (final x in del) if (x.rel == 'linked') x.action], [PreviewAction.skipLink]);
});
test('93: 원본 폴더를 읽지 못하면 비교 실패 (지우는 실행을 막는다)', () async { test('93: 원본 폴더를 읽지 못하면 비교 실패 (지우는 실행을 막는다)', () async {
await expectLater(previewSync(p.join(tmp.path, 'none'), b, toRight: true, delete: true), await expectLater(previewSync(p.join(tmp.path, 'none'), b, toRight: true, delete: true),
throwsA(isA<SourceUnreadableException>())); throwsA(isA<SourceUnreadableException>()));