WIP 옵션 글의 줄바꿈 없는 빈칸 · 보이지 않는 글자 (관리자 지시 10/20): 옵션을 나눌 때 (splitOptions) 와 옵션 칸에 붙여 넣을 때 (OptionTextFormatter) 보통 빈칸으로 / 지움 - 묶은 글 · 다른 곳의 글을 붙여 넣어도 rsync 가 같은 인수로 읽음 · 소스의 보이지 않는 글자는 \u 표기로 (검토 대기)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>review_d2dc267
parent
0bd1466212
commit
57676371dc
|
|
@ -21,13 +21,19 @@ enum CopyMethod {
|
||||||
const defaultRsyncOptions = '-avPog';
|
const defaultRsyncOptions = '-avPog';
|
||||||
const defaultRobocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
|
const defaultRobocopyOptions = '/E /COPY:DAT /DCOPY:T /R:2 /W:2';
|
||||||
|
|
||||||
/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로)
|
/// 관리자 지시 (10/20): 화면에서 줄바꿈 없이 묶은 옵션 글 (sync_preview_view 의 unbreakableOption) 의 줄바꿈 없는 빈칸 (U+00A0 등) ·
|
||||||
|
/// 보이지 않는 글자 (U+2060 등) 가 복사 · 붙여 넣기로 옵션에 들어오면 rsync 가 다른 인수로 읽는다 ("-", 보이지 않는 글자, "-filter") → 보통 빈칸으로 / 지움.
|
||||||
|
/// 다른 곳 (웹 페이지 · 문서) 에서 붙여 넣은 것도 같다
|
||||||
|
String cleanOptionText(String s) =>
|
||||||
|
s.replaceAll(RegExp('[\u00A0\u2007\u202F]'), ' ').replaceAll(RegExp('[\u200B\u200C\u200D\u2060\uFEFF]'), '');
|
||||||
|
|
||||||
|
/// 옵션 글자를 인수로 나눈다 (큰따옴표 · 작은따옴표로 묶은 것은 하나로). 먼저 [cleanOptionText]
|
||||||
List<String> splitOptions(String s) {
|
List<String> splitOptions(String s) {
|
||||||
final out = <String>[];
|
final out = <String>[];
|
||||||
final cur = StringBuffer();
|
final cur = StringBuffer();
|
||||||
String? quote;
|
String? quote;
|
||||||
var has = false;
|
var has = false;
|
||||||
for (final ch in s.split('')) {
|
for (final ch in cleanOptionText(s).split('')) {
|
||||||
if (quote != null) {
|
if (quote != null) {
|
||||||
if (ch == quote) {
|
if (ch == quote) {
|
||||||
quote = null;
|
quote = null;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import 'rsync_setup.dart';
|
||||||
import 'schedule_editor.dart';
|
import 'schedule_editor.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
import 'setting_tile.dart';
|
import 'setting_tile.dart';
|
||||||
|
import 'sync_preview_view.dart' show OptionTextFormatter;
|
||||||
|
|
||||||
/// 환경 설정의 복사 · 동기화 부분.
|
/// 환경 설정의 복사 · 동기화 부분.
|
||||||
/// - 파일 탐색기 ([rsync] false): 파일 / 폴더 복사 방법 (현재 방식 · robocopy), robocopy 옵션, 속도 제한
|
/// - 파일 탐색기 ([rsync] false): 파일 / 폴더 복사 방법 (현재 방식 · robocopy), robocopy 옵션, 속도 제한
|
||||||
|
|
@ -84,6 +85,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
|
||||||
width: 380,
|
width: 380,
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: ctl,
|
controller: ctl,
|
||||||
|
inputFormatters: const [OptionTextFormatter()],
|
||||||
scrollPadding: const EdgeInsets.all(80),
|
scrollPadding: const EdgeInsets.all(80),
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import 'path_label.dart';
|
||||||
import 'rsync_setup.dart';
|
import 'rsync_setup.dart';
|
||||||
import 'schedule_editor.dart';
|
import 'schedule_editor.dart';
|
||||||
import '../core/sync_preview.dart' show optionsRemoveSource;
|
import '../core/sync_preview.dart' show optionsRemoveSource;
|
||||||
import 'sync_preview_view.dart' show FilterLimitNote, confirmDeletingRun, effectiveRunText, unbreakableOption;
|
import 'sync_preview_view.dart' show FilterLimitNote, OptionTextFormatter, confirmDeletingRun, effectiveRunText, unbreakableOption;
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
/// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다.
|
/// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다.
|
||||||
|
|
@ -647,6 +647,7 @@ class _CopyCardState extends State<_CopyCard> {
|
||||||
width: 320,
|
width: 320,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _options,
|
controller: _options,
|
||||||
|
inputFormatters: const [OptionTextFormatter()],
|
||||||
enabled: _method != 'builtin',
|
enabled: _method != 'builtin',
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||||
decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('옵션'), hintText: optsHint),
|
decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('옵션'), hintText: optsHint),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../app/settings.dart' show CopyTask;
|
import '../app/settings.dart' show CopyTask;
|
||||||
import '../core/file_ops.dart' show formatSize, isPartialFile;
|
import '../core/file_ops.dart' show formatSize, isPartialFile;
|
||||||
|
|
@ -309,7 +310,25 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
|
||||||
/// 지울 목록이 실제보다 적게 보일 수 있다 - 빨갛게 알리고, 지우기 (--delete) 와 함께면 체크해야 [실행] 이 켜진다 (72 · 104 와 같게)
|
/// 지울 목록이 실제보다 적게 보일 수 있다 - 빨갛게 알리고, 지우기 (--delete) 와 함께면 체크해야 [실행] 이 켜진다 (72 · 104 와 같게)
|
||||||
/// 옵션 글 (예: --filter=H a.txt) 이 좁은 창에서 "--" 와 "filter=…" 로 갈리지 않게 줄바꿈 없이 묶는다 (관리자 지시 10/20, 28 지적):
|
/// 옵션 글 (예: --filter=H a.txt) 이 좁은 창에서 "--" 와 "filter=…" 로 갈리지 않게 줄바꿈 없이 묶는다 (관리자 지시 10/20, 28 지적):
|
||||||
/// 빈칸은 줄바꿈 없는 빈칸, - · / 뒤에는 줄바꿈을 막는 글자 (U+2060, 보이지 않음)
|
/// 빈칸은 줄바꿈 없는 빈칸, - · / 뒤에는 줄바꿈을 막는 글자 (U+2060, 보이지 않음)
|
||||||
String unbreakableOption(String s) => s.replaceAll(' ', ' ').replaceAllMapped(RegExp(r'[-/]'), (m) => '${m[0]}');
|
String unbreakableOption(String s) => s.replaceAll(' ', '\u00A0').replaceAllMapped(RegExp(r'[-/]'), (m) => '${m[0]}\u2060');
|
||||||
|
|
||||||
|
/// 옵션 입력 칸: 붙여 넣거나 친 글의 U+00A0 · U+2060 등을 고쳐 받는다 ([cleanOptionText], 관리자 지시 10/20).
|
||||||
|
/// 화면의 묶은 옵션 글 ([unbreakableOption]) 이나 다른 곳의 글을 붙여 넣어도 rsync 가 다르게 읽지 않게
|
||||||
|
class OptionTextFormatter extends TextInputFormatter {
|
||||||
|
const OptionTextFormatter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||||
|
final text = cleanOptionText(newValue.text);
|
||||||
|
if (text == newValue.text) return newValue;
|
||||||
|
// 커서 · 고른 곳은 앞부분을 고친 길이로 옮긴다
|
||||||
|
int at(int i) => i < 0 ? i : cleanOptionText(newValue.text.substring(0, i.clamp(0, newValue.text.length))).length;
|
||||||
|
return TextEditingValue(
|
||||||
|
text: text,
|
||||||
|
selection: TextSelection(baseOffset: at(newValue.selection.baseOffset), extentOffset: at(newValue.selection.extentOffset)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class FilterLimitNote extends StatelessWidget {
|
class FilterLimitNote extends StatelessWidget {
|
||||||
final List<String> unsupported;
|
final List<String> unsupported;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/rsync_filter.dart';
|
||||||
|
import 'package:jj_mkvmaker/core/sync_tools.dart';
|
||||||
import 'package:jj_mkvmaker/ui/sync_preview_view.dart';
|
import 'package:jj_mkvmaker/ui/sync_preview_view.dart';
|
||||||
|
|
||||||
/// 관리자 지시 (10/20, 28 지적): 경고 글 안의 옵션 (--filter=H a.txt) 이 좁은 창에서 "--" 와 "filter=…" 로 갈리던 것
|
/// 관리자 지시 (10/20, 28 지적): 경고 글 안의 옵션 (--filter=H a.txt) 이 좁은 창에서 "--" 와 "filter=…" 로 갈리던 것
|
||||||
|
|
@ -15,9 +17,36 @@ void main() {
|
||||||
for (final o in ['--filter=H a.txt', '-f"- *.log"', '--files-from=list/a b.txt', '-C']) {
|
for (final o in ['--filter=H a.txt', '-f"- *.log"', '--files-from=list/a b.txt', '-C']) {
|
||||||
final u = unbreakableOption(o);
|
final u = unbreakableOption(o);
|
||||||
expect(widest(u), closeTo(widest(u, min: false), 0.01), reason: o);
|
expect(widest(u), closeTo(widest(u, min: false), 0.01), reason: o);
|
||||||
expect(u.replaceAll('', '').replaceAll(' ', ' '), o, reason: '보이는 글은 그대로');
|
expect(u.replaceAll('\u2060', '').replaceAll('\u00A0', ' '), o, reason: '보이는 글은 그대로');
|
||||||
}
|
}
|
||||||
// 묶지 않은 글은 줄이 갈릴 수 있다 (시험이 실제로 무언가를 보는지)
|
// 묶지 않은 글은 줄이 갈릴 수 있다 (시험이 실제로 무언가를 보는지)
|
||||||
expect(widest('--filter=H a.txt'), lessThan(widest('--filter=H a.txt', min: false)));
|
expect(widest('--filter=H a.txt'), lessThan(widest('--filter=H a.txt', min: false)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 관리자 지시 (10/20): 묶은 글을 복사해 옵션 칸에 붙여 넣어도 rsync 가 같은 인수로 읽는다
|
||||||
|
test('묶은 옵션 글 · 다른 곳의 글을 붙여 넣어도 인수는 원래대로', () {
|
||||||
|
const o = '-a --delete --filter="H a.txt" -f"- *.log"';
|
||||||
|
final pasted = unbreakableOption(o);
|
||||||
|
expect(pasted, isNot(o));
|
||||||
|
expect(cleanOptionText(pasted), o);
|
||||||
|
expect(splitOptions(pasted), splitOptions(o));
|
||||||
|
expect(splitOptions(pasted), ['-a', '--delete', '--filter=H a.txt', '-f- *.log']);
|
||||||
|
// 따옴표 안의 줄바꿈 없는 빈칸도 보통 빈칸 (거르기 규칙 "H a.txt" 가 그대로)
|
||||||
|
expect(RsyncFilter.parse(pasted).unsupported, RsyncFilter.parse(o).unsupported);
|
||||||
|
expect(RsyncFilter.parse(pasted).excluded('old.log', isDir: false), isTrue);
|
||||||
|
// 웹 · 문서에서 온 글 (U+202F · U+200B · U+FEFF)
|
||||||
|
expect(splitOptions('\uFEFF-av\u202F--delete\u200B'), ['-av', '--delete']);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('옵션 칸 (OptionTextFormatter): 붙여 넣은 묶은 글을 고쳐 받고 커서는 끝에', (tester) async {
|
||||||
|
final ctl = TextEditingController();
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
home: Scaffold(body: TextField(controller: ctl, inputFormatters: const [OptionTextFormatter()]))));
|
||||||
|
final pasted = unbreakableOption('--filter=H a.txt');
|
||||||
|
await tester.enterText(find.byType(TextField), pasted);
|
||||||
|
await tester.pump();
|
||||||
|
expect(ctl.text, '--filter=H a.txt');
|
||||||
|
expect(ctl.selection.baseOffset, ctl.text.length);
|
||||||
|
ctl.dispose();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue