JJ_MKVMaker/lib/ui/monitor/live_sync_flow.dart

284 lines
15 KiB
Dart

/// 실시간 동기화 (lsync) 흐름과 확인 창. 화면 상태 없이 (context · AppController · LiveSync · 쌍) 만 받는다.
/// 약속:
/// - confirmLiveDeletes · confirmLiveDeleteNow · confirmEmptySourceSync: 지울 목록을 보이고, 고른 대로만
/// live.decideDelete / deleteNow / syncEmptyAnyway 를 부른다. 취소면 아무것도 하지 않음.
/// - askLiveSyncStart: 켜 둔 쌍 가운데 이번 실행에 시작할 것을 고르게 해 live.runOnly 로 시작.
/// - replaceLiveSyncPair: 같은 원본 · 대상 쌍만 바꾸고 순서는 그대로 저장.
/// - showLivePairMenu: 카드의 고치기 · 지우기 메뉴.
/// - finalizeLiveSync: 최종 정리 (rsync --remove-source-files + 빈 폴더 지우기). 작업이 시작되면 true.
/// rsync 를 못 찾거나 시작하지 못하면 SnackBar 로 알리고 false.
library;
import 'dart:io';
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/copy_center.dart';
import '../../app/live_sync.dart';
import '../../app/settings.dart';
import '../../core/vfs.dart';
import '../../l10n/tr.dart';
import '../copy_sync_settings.dart' show editLiveSyncPairDialog, removeLiveSyncPair;
import '../rsync_setup.dart';
import '../schedule_editor.dart';
import '../sync_preview_view.dart' show FilterLimitNote, confirmDeletingRun, keepOptionsTogether;
import '../theme.dart';
import 'transfer_widgets.dart';
/// 실시간 동기화 "지우기" 확인 창 (42): 대상에만 있어 지워질 항목을 보여 주고 [지우기 포함으로 맞추기] · [지우지 않기] · [나중에]
Future<void> confirmLiveDeletes(BuildContext context, LiveSync live, LiveSyncPair x) async {
final list = live.toDelete[LiveSync.keyOf(x)] ?? const <String>[];
if (list.isEmpty) return;
final pick = await _liveDeleteDialog(context, live, x, list, live.toDeleteExcluded[LiveSync.keyOf(x)] ?? const [], now: false);
if (pick == null) return;
await live.decideDelete(x, delete: pick);
}
/// P0 (관리자 3a 10/20): 미리 볼 수 없는 거르기 규칙이 있어 자동으로 지우지 않는 쌍의 [지금 지우기 (확인)].
/// 빨간 FilterLimitNote 와 [알고 실행합니다] 체크를 거쳐 이번 한 번만 지운다 (다음 자동 맞추기는 다시 보내기만)
Future<void> confirmLiveDeleteNow(BuildContext context, LiveSync live, LiveSyncPair x) async {
final (list, excl) = await live.previewDeletes(x);
if (!context.mounted) return;
final pick = await _liveDeleteDialog(context, live, x, list, excl, now: true);
if (pick == true) await live.deleteNow(x);
}
Future<bool?> _liveDeleteDialog(BuildContext context, LiveSync live, LiveSyncPair x, List<String> list, List<String> exclIn,
{required bool now}) {
// 관리자 3a 조건 (10/20): --delete-excluded 때문에 지워지는 것 (거른 것 · 거른 것이 든 폴더) 은 따로 묶어 알고 누르게
final excl = exclIn.toSet();
final plain = [for (final n in list) if (!excl.contains(n)) n];
final filtered = [for (final n in list) if (excl.contains(n)) n];
// P0: 미리 볼 수 없는 규칙 - 목록이 실제와 다를 수 있음을 빨갛게, 체크해야 [지우고 맞추기]
final limits = live.unsupportedOf(x);
var ack = false;
return showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
title: Text(list.isEmpty ? tr('대상에서 지울까요?') : trf('대상에서 {0}개를 지울까요?', [list.length])),
content: SizedBox(
width: 560,
// 세로 폰에서도 목록 (특히 거른 것 묶음) 이 더 보이게 화면 높이를 따라 (360 ~ 520)
height: (MediaQuery.sizeOf(ctx).height * 0.55).clamp(360.0, 520.0),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (limits.isNotEmpty) ...[
FilterLimitNote(unsupported: limits, deletes: true, ack: ack, onAck: (v) => set(() => ack = v)),
const SizedBox(height: 4),
],
// 관리자 3a 판단 (10/20): 확인한 쌍인데 옵션이 바뀌어 지울 것이 늘어 다시 묻는 것
if (!now && live.reasked.contains(LiveSync.keyOf(x))) ...[
Text(tr('지우기를 확인한 뒤 rsync 옵션이 바뀌어 지울 것이 늘었습니다. 다시 확인해 주세요.'),
style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.warning)),
const SizedBox(height: 6),
],
Text(trf('"{0}" 에는 없고 대상 "{1}" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).',
[vDisplay(x.source), vDisplay(x.target)])),
const SizedBox(height: 8),
Expanded(
child: ListView(children: [
if (list.isEmpty)
Text(tr('미리 본 지울 것은 없습니다 - 위 규칙 때문에 실제로는 지워질 수 있습니다'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
for (final n in plain.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')),
if (plain.length > 500) Text(trf('… 외 {0}개', [plain.length - 500])),
if (filtered.isNotEmpty) ...[
if (plain.isNotEmpty) const SizedBox(height: 10),
Text(keepOptionsTogether(trf('거른 것 {0}개도 지워집니다 (--delete-excluded)', [filtered.length])),
style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.danger)),
Text(keepOptionsTogether(tr('rsync 옵션에서 뺀 것 (--exclude 등) 이지만 --delete-excluded 가 있어 대상에서 지웁니다. 지키려면 옵션에서 --delete-excluded 를 빼세요.')),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 4),
for (final n in filtered.take(500)) Text(n, style: TextStyle(fontSize: 12, fontFamily: 'Consolas', color: JjColors.danger)),
if (filtered.length > 500) Text(trf('… 외 {0}개', [filtered.length - 500])),
],
]),
),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('나중에'))),
if (!now) TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('지우지 않기 (지우기 끄기)'))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: JjColors.danger),
onPressed: limits.isNotEmpty && !ack ? null : () => Navigator.pop(ctx, true),
child: Text(list.isEmpty ? tr('지우고 맞추기') : trf('{0}개 지우고 맞추기', [list.length])),
),
],
),
),
);
}
/// 70: 원본이 비어 멈춘 쌍 - 지워질 목록을 보여 주고 [그래도 맞추기] 로 확인받는다 (42 와 같은 창)
Future<void> confirmEmptySourceSync(BuildContext context, LiveSync live, LiveSyncPair x) async {
final list = live.emptyDeletes[LiveSync.keyOf(x)] ?? const <String>[];
if (list.isEmpty) return;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(trf('원본이 비어 있습니다. 대상에서 {0}개를 지울까요?', [list.length])),
content: SizedBox(
width: 560,
height: 360,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(trf('"{0}" 이 비어 있어, 맞추면 대상 "{1}" 의 아래 항목이 모두 지워집니다 (되돌릴 수 없음). '
'원본을 일부러 비운 경우에만 누르세요.', [vDisplay(x.source), vDisplay(x.target)])),
const SizedBox(height: 8),
Expanded(
child: ListView(children: [
for (final n in list.take(500)) Text(n, style: const TextStyle(fontSize: 12, fontFamily: 'Consolas')),
if (list.length > 500) Text(trf('… 외 {0}개', [list.length - 500])),
]),
),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: JjColors.danger),
onPressed: () => Navigator.pop(ctx, true),
child: Text(trf('그래도 맞추기 ({0}개 지움)', [list.length])),
),
],
),
);
if (ok == true) await live.syncEmptyAnyway(x);
}
/// 앱을 다시 켤 때 ([AppSettings.liveSyncOnStart] 'ask'): 어느 실시간 동기화를 시작할지 고르기.
/// 고르지 않은 것은 이번 실행 동안 멈춤 (모니터링 > lsync 에서 ▶ 로 시작).
Future<void> askLiveSyncStart(BuildContext context, LiveSync live) async {
final pairs = live.c.settings.liveSyncPairs.where((x) => x.enabled).toList();
if (pairs.isEmpty) return;
final pick = {for (final x in pairs) LiveSync.keyOf(x)};
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
title: Text(tr('실시간 동기화 시작')),
content: SizedBox(
width: 520,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(tr('시작할 동기화를 고르세요. 고르지 않은 것은 이번 실행 동안 멈춥니다 (모니터링 > lsync 에서 시작).'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 8),
Flexible(
child: ListView(shrinkWrap: true, children: [
for (final x in pairs)
CheckboxListTile(
dense: true,
contentPadding: EdgeInsets.zero,
value: pick.contains(LiveSync.keyOf(x)),
onChanged: (v) => set(() => v == true ? pick.add(LiveSync.keyOf(x)) : pick.remove(LiveSync.keyOf(x))),
title: Text('${vDisplay(x.source)} → ${vDisplay(x.target)}', maxLines: 2, overflow: TextOverflow.ellipsis),
subtitle: Text(scheduleSummary(x.schedule)),
),
]),
),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('모두 나중에'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('고른 것 시작'))),
],
),
),
);
if (ok == true) live.runOnly(pairs.where((x) => pick.contains(LiveSync.keyOf(x))));
}
/// 같은 원본 · 대상 쌍만 [now] 로 바꿔 저장 (나머지 순서 그대로)
Future<void> replaceLiveSyncPair(AppController c, LiveSyncPair old, LiveSyncPair now) => c.updateSettings((x) => x.liveSyncPairs = [
for (final y in x.liveSyncPairs) y.source == old.source && y.target == old.target ? now : y,
]);
/// 69: 카드의 길게 누르기 · 오른쪽 클릭 메뉴
Future<void> showLivePairMenu(BuildContext context, AppController c, LiveSyncPair x, Offset at) async {
final pick = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(at.dx, at.dy, at.dx, at.dy),
items: [
PopupMenuItem(value: 'edit', child: Text(tr('고치기'))),
PopupMenuItem(value: 'remove', child: Text(tr('지우기'))),
],
);
if (!context.mounted) return;
if (pick == 'edit') {
await editLiveSyncPairDialog(context, c, x);
} else if (pick == 'remove') {
await removeLiveSyncPair(context, c, x);
}
}
/// 최종 정리: 원본의 것을 모두 대상으로 옮기고 (rsync -avHPOg --remove-source-files 원본/ 대상/)
/// 빈 폴더를 지운다 (find 원본/ -type d -empty -delete). 원본 폴더를 남길지 · 지울지만 고른다.
/// 원본이 비므로 이 실시간 동기화는 끈다 (켜 둔 채 "지우기 포함" 이면 대상까지 비워 버린다).
/// 작업이 시작되면 true (진행은 복사 · rsync 목록에서 봄 - 그쪽으로 넘기는 일은 부르는 쪽이 한다).
Future<bool> finalizeLiveSync(BuildContext context, AppController c, LiveSyncPair x) async {
final live = LiveSync.instance;
final messenger = ScaffoldMessenger.of(context);
if (live?.isRunning(x) ?? false) {
messenger.showSnackBar(SnackBar(content: Text(tr('지금 맞추는 중입니다. 끝난 뒤에 하세요.'))));
return false;
}
var prune = 'keep';
final go = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
scrollable: true,
title: Text(tr('최종 정리')),
content: SizedBox(
width: 600,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(tr('원본의 파일을 모두 대상으로 옮기고 (원본에서 지움), 원본에 남은 빈 폴더를 지웁니다.')),
const SizedBox(height: 10),
SelectableText(
'rsync -avHPOg --remove-source-files ${x.source}/ ${x.target}/\n'
'find ${x.source}/ -type d -empty -delete',
style: TextStyle(fontFamily: 'monospace', fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 14),
Text(tr('원본 폴더'), style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
PruneChoice(value: prune, onChanged: (v) => set(() => prune = v)),
const SizedBox(height: 12),
Text(tr('원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.'),
style: TextStyle(fontSize: 12, color: JjColors.warning)),
]),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('최종 정리'))),
],
),
),
);
if (go != true || !context.mounted) return false;
var exe = await rsyncExecutable(c.settings);
if (exe == null && Platform.isWindows && c.settings.rsyncSource == 'download' && context.mounted) {
exe = await installRsyncWithDialog(context);
}
if (exe == null) {
messenger.showSnackBar(SnackBar(content: Text(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > Rsync 에서 확인하세요.'))));
return false;
}
final center = CopyCenter.of(c);
var t = await center.remember([x.source], x.target, move: true, contents: true, method: 'rsync');
// 처음이면 정해진 옵션 (모니터링에서 고친 것이 있으면 그것)
t = t.copyWith(prune: prune, options: t.lastRun.isEmpty ? '-avHPOg' : null);
await center.update(t);
// 104: 고친 옵션에 지우기가 있으면 미리 보기를 거친다
if (!context.mounted || !await confirmDeletingRun(context, t) || !context.mounted) return false;
await replaceLiveSyncPair(c, x, x.copyWith(enabled: false));
c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('실시간 동기화의 [최종 정리]'), x.source, x.target]));
final job = await center.start(t, rsyncExe: exe);
if (!context.mounted) return false;
if (job == null) {
messenger.showSnackBar(SnackBar(content: Text(center.tasks.firstWhere((y) => y.id == t.id).lastMessage)));
return false;
}
return true;
}