diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 5a216f1..4038f8d 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -49,6 +49,7 @@ "(더 새 파일을 덮어씀)": "(overwrites a newer file)", "(덮어씀)": "(overwritten)", "(동영상을 세 번 누르면 옮깁니다)": "(Tapping a video three times moves it)", + "(링크 · junction - 따라가지 않고 건너뜀)": "(link · junction - not followed, skipped)", "(받는 쪽에만 있음)": "(only on receiving side)", "(받는 쪽이 더 새것 - 건너뜀)": "(receiving side is newer - skipped)", "(비어 있음)": "(empty)", @@ -787,6 +788,7 @@ "로그인이 필요합니다 (403)": "Login required (403)", "로그인하면 하루 받기 횟수가 늘어납니다": "Signing in raises the daily download limit", "루마니아어": "Romanian", + "링크 {0} (건너뜀)": "Links {0} (skipped)", "마스터 · 최상 비밀번호": "Master and super passwords", "마스터 비밀번호": "Master password", "마스터 비밀번호 넣기": "Enter master password", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index b6f261f..2f0eeb9 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -49,6 +49,7 @@ "(더 새 파일을 덮어씀)": "(新しいファイルを上書き)", "(덮어씀)": "(上書き)", "(동영상을 세 번 누르면 옮깁니다)": "(動画を 3 回タップすると移動します)", + "(링크 · junction - 따라가지 않고 건너뜀)": "(リンク · ジャンクション - たどらずにスキップ)", "(받는 쪽에만 있음)": "(受け取る側にだけある)", "(받는 쪽이 더 새것 - 건너뜀)": "(受け取る側が新しい - スキップ)", "(비어 있음)": "(空)", @@ -787,6 +788,7 @@ "로그인이 필요합니다 (403)": "ログインが必要です (403)", "로그인하면 하루 받기 횟수가 늘어납니다": "ログインすると 1日のダウンロード回数が増えます", "루마니아어": "ルーマニア語", + "링크 {0} (건너뜀)": "リンク {0} (スキップ)", "마스터 · 최상 비밀번호": "マスター / 最上位パスワード", "마스터 비밀번호": "マスターパスワード", "마스터 비밀번호 넣기": "マスターパスワードを入力", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index ecd8735..20e4299 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -49,6 +49,7 @@ "(더 새 파일을 덮어씀)": "(覆盖较新的文件)", "(덮어씀)": "(覆盖)", "(동영상을 세 번 누르면 옮깁니다)": "(连按视频三次会移动它)", + "(링크 · junction - 따라가지 않고 건너뜀)": "(链接 · 联接 - 不跟随,已跳过)", "(받는 쪽에만 있음)": "(仅在接收方)", "(받는 쪽이 더 새것 - 건너뜀)": "(接收端较新 - 已跳过)", "(비어 있음)": "(空)", @@ -787,6 +788,7 @@ "로그인이 필요합니다 (403)": "需要登录 (403)", "로그인하면 하루 받기 횟수가 늘어납니다": "登录后每日下载次数会增加", "루마니아어": "罗马尼亚语", + "링크 {0} (건너뜀)": "链接 {0}(跳过)", "마스터 · 최상 비밀번호": "主密码和最高密码", "마스터 비밀번호": "主密码", "마스터 비밀번호 넣기": "输入主密码", diff --git a/lib/core/sync_preview.dart b/lib/core/sync_preview.dart index ba6521f..bbeedf0 100644 --- a/lib/core/sync_preview.dart +++ b/lib/core/sync_preview.dart @@ -1,3 +1,5 @@ +import 'dart:io' show FileSystemEntity; + import 'file_ops.dart' show isPartialFile, SourceUnreadableException; import 'sync_tools.dart' show splitOptions, isSourceRemovingOption; @@ -20,6 +22,9 @@ enum PreviewAction { /// 229: 받는 쪽이 더 새것이라 건너뜀 (-u · 환경 설정 "받는 쪽이 더 새것이면 건너뛰기") skipNewer, + + /// 231: 원본의 링크 · junction - 앱이 직접 맞출 때는 따라가지 않고 건너뛴다 (214) + skipLink, } class PreviewItem { @@ -86,8 +91,10 @@ bool optionsUpdate(String options) => /// [src] 안의 것 → [dst] 안으로 맞출 때 바뀌는 것. 원본을 읽지 못한 폴더에서는 "지워짐" 을 세지 않는다. /// [limit] 개를 넘으면 멈춘다 (화면에는 "… 이상"). +/// 231: 원본의 링크 · junction 은 안으로 들어가지 않는다. [skipLinks] (앱이 직접 - 현재 방식 · WebDAV) 면 "건너뜀 (링크)", +/// 아니면 (rsync -a · robocopy) 링크 자체 하나로 센다 Future> 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 = []; Future walk(String s, String d, String rel) async { if (out.length >= limit) return; @@ -113,6 +120,14 @@ Future> previewSync(String src, String dst, names.add(name); final r = rel.isEmpty ? name : '$rel/$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 (t == null) out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, isDir: true)); await walk(e.path, vJoin(d, name), r); diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 18a03a0..8e17a52 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -2166,7 +2166,8 @@ class _ExplorerPageState extends State { toRight: t.sources.first == l, delete: deletes(), // 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: 다시 비교하는 동안은 예전 결과로 실행하지 못하게 onStart: () => set(() { diff --git a/lib/ui/sync_preview_view.dart b/lib/ui/sync_preview_view.dart index de5b11e..67cbb26 100644 --- a/lib/ui/sync_preview_view.dart +++ b/lib/ui/sync_preview_view.dart @@ -100,6 +100,7 @@ class _SyncPreviewViewState extends State { final overwrites = n(PreviewAction.update); final overNewer = items.where((x) => x.action == PreviewAction.update && x.newer).length; final skipped = n(PreviewAction.skipNewer); + final links = n(PreviewAction.skipLink); final more = items.length >= widget.limit; final parts = [ for (final right in [true, false]) @@ -107,6 +108,7 @@ class _SyncPreviewViewState extends State { trf('{0} 새로 {1} · 덮어씀 {2}', [right ? '→' : '←', n(PreviewAction.add, right), n(PreviewAction.update, right)]), if (n(PreviewAction.onlyTarget) > 0) trf('받는 쪽에만 있음 {0} (그대로 둠)', [n(PreviewAction.onlyTarget)]), if (skipped > 0) trf('받는 쪽이 더 새것 {0} (건너뜀)', [skipped]), + if (links > 0) trf('링크 {0} (건너뜀)', [links]), ]; return Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row(children: [ @@ -149,6 +151,7 @@ class _SyncPreviewViewState extends State { PreviewAction.update => '${_arrow(x)} ~ ${x.rel} (${formatSize(x.size)}) ${x.newer ? tr('(더 새 파일을 덮어씀)') : tr('(덮어씀)')}', PreviewAction.skipNewer => ' · ${x.rel} ${tr('(받는 쪽이 더 새것 - 건너뜀)')}', + PreviewAction.skipLink => ' · ${x.rel}${x.isDir ? '/' : ''} ${tr('(링크 · junction - 따라가지 않고 건너뜀)')}', PreviewAction.onlyTarget => ' = ${x.rel}${x.isDir ? '/' : ''} ${tr('(받는 쪽에만 있음)')}', PreviewAction.delete => '${_arrow(x)} − ${x.rel}${x.isDir ? '/' : ''} ${tr('(지워짐)')}', }, @@ -158,7 +161,7 @@ class _SyncPreviewViewState extends State { color: switch (x.action) { PreviewAction.delete => Colors.redAccent, PreviewAction.update => Colors.orangeAccent, - PreviewAction.onlyTarget || PreviewAction.skipNewer => JjColors.textDim, + PreviewAction.onlyTarget || PreviewAction.skipNewer || PreviewAction.skipLink => JjColors.textDim, _ => null, }, ), @@ -243,7 +246,7 @@ Future confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM const SizedBox(height: 8), SyncPreviewView( 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(() { preview = null; diff --git a/test/sync_preview_test.dart b/test/sync_preview_test.dart index 2cb14ea..084ef07 100644 --- a/test/sync_preview_test.dart +++ b/test/sync_preview_test.dart @@ -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 { await expectLater(previewSync(p.join(tmp.path, 'none'), b, toRight: true, delete: true), throwsA(isA()));