robocopy 도 '받는 쪽이 더 새것이면 건너뛰기' 를 따름 (/XO) (229)
- 데이터 (버그, 관리자 지적): robocopy 는 기본으로 예전 원본 (Older) 으로 대상의 새 파일을 덮어썼다 - 설정이 켜져 있으면 /XO - 건너뛴 원본은 남기고 알림, 미리 보기 · 명령 줄도 같게. 설정 설명 글에 robocopy 를 넣음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>fix_mkv
parent
d8ce0eb302
commit
90246717ec
|
|
@ -2034,7 +2034,7 @@
|
||||||
"허용했으면 다시 확인": "Check again after allowing",
|
"허용했으면 다시 확인": "Check again after allowing",
|
||||||
"헝가리어": "Hungarian",
|
"헝가리어": "Hungarian",
|
||||||
"현재 방식": "Built-in",
|
"현재 방식": "Built-in",
|
||||||
"현재 방식 · WebDAV 로 폴더 안의 것을 맞출 때 (rsync 의 -u 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "When syncing folder contents with the built-in method or WebDAV (same as rsync -u). Skipped files stay in the source even when moving",
|
"현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "When copying or moving with the built-in method, robocopy or WebDAV (same as rsync -u / robocopy /XO). Skipped files stay in the source even when moving",
|
||||||
"현재 유지": "Keep as is",
|
"현재 유지": "Keep as is",
|
||||||
"현재 페이지": "Current page",
|
"현재 페이지": "Current page",
|
||||||
"현재 페이지 추가": "Add current page",
|
"현재 페이지 추가": "Add current page",
|
||||||
|
|
|
||||||
|
|
@ -2034,7 +2034,7 @@
|
||||||
"허용했으면 다시 확인": "許可したらもう一度確認",
|
"허용했으면 다시 확인": "許可したらもう一度確認",
|
||||||
"헝가리어": "ハンガリー語",
|
"헝가리어": "ハンガリー語",
|
||||||
"현재 방식": "現在の方式",
|
"현재 방식": "現在の方式",
|
||||||
"현재 방식 · WebDAV 로 폴더 안의 것을 맞출 때 (rsync 의 -u 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "現在の方式 · WebDAV でフォルダーの中身を合わせるとき (rsync の -u と同じ)。スキップしたファイルは移動でも元の場所に残します",
|
"현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "現在の方式 · robocopy · WebDAV でコピー · 移動するとき (rsync の -u、robocopy の /XO と同じ)。スキップしたファイルは移動でも元の場所に残します",
|
||||||
"현재 유지": "現状維持",
|
"현재 유지": "現状維持",
|
||||||
"현재 페이지": "現在のページ",
|
"현재 페이지": "現在のページ",
|
||||||
"현재 페이지 추가": "現在のページを追加",
|
"현재 페이지 추가": "現在のページを追加",
|
||||||
|
|
|
||||||
|
|
@ -2034,7 +2034,7 @@
|
||||||
"허용했으면 다시 확인": "允许后重新检查",
|
"허용했으면 다시 확인": "允许后重新检查",
|
||||||
"헝가리어": "匈牙利语",
|
"헝가리어": "匈牙利语",
|
||||||
"현재 방식": "当前方式",
|
"현재 방식": "当前方式",
|
||||||
"현재 방식 · WebDAV 로 폴더 안의 것을 맞출 때 (rsync 의 -u 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "使用当前方式 · WebDAV 同步文件夹内容时(与 rsync 的 -u 相同)。跳过的文件即使是移动也会保留在源中",
|
"현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다": "使用当前方式 · robocopy · WebDAV 复制 · 移动时(与 rsync 的 -u、robocopy 的 /XO 相同)。跳过的文件即使是移动也会保留在源中",
|
||||||
"현재 유지": "保持现状",
|
"현재 유지": "保持现状",
|
||||||
"현재 페이지": "当前页面",
|
"현재 페이지": "当前页面",
|
||||||
"현재 페이지 추가": "添加当前页面",
|
"현재 페이지 추가": "添加当前页面",
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,8 @@ class TransferJob extends ChangeNotifier {
|
||||||
/// 48: 같은 이름이 있을 때 (앱이 직접 복사 · 이동할 때만 - robocopy · rsync 는 그 프로그램의 규칙)
|
/// 48: 같은 이름이 있을 때 (앱이 직접 복사 · 이동할 때만 - robocopy · rsync 는 그 프로그램의 규칙)
|
||||||
final NameConflict conflict;
|
final NameConflict conflict;
|
||||||
|
|
||||||
/// 229: 앱이 직접 맞출 때 받는 쪽이 더 새것이면 건너뛴다 (환경 설정 - 옵션 칸이 없는 현재 방식 · WebDAV 도). 건너뛴 원본은 이동이라도 남긴다
|
/// 229: 앱이 직접 맞출 때 · robocopy (/XO) 에서 받는 쪽이 더 새것이면 건너뛴다 (환경 설정 - 옵션 칸이 없는 현재 방식 · WebDAV 도).
|
||||||
|
/// 건너뛴 원본은 이동이라도 남긴다. rsync 는 옵션 칸의 -u 를 따른다
|
||||||
final bool skipNewer;
|
final bool skipNewer;
|
||||||
|
|
||||||
/// 같은 이름이라 건너뛴 항목 (원본 경로)
|
/// 같은 이름이라 건너뛴 항목 (원본 경로)
|
||||||
|
|
@ -440,6 +441,7 @@ class TransferJob extends ChangeNotifier {
|
||||||
bandwidthKBps: bandwidthKBps,
|
bandwidthKBps: bandwidthKBps,
|
||||||
move: move,
|
move: move,
|
||||||
contents: contents,
|
contents: contents,
|
||||||
|
skipNewer: skipNewer,
|
||||||
);
|
);
|
||||||
_tick();
|
_tick();
|
||||||
for (final args in runs) {
|
for (final args in runs) {
|
||||||
|
|
|
||||||
|
|
@ -107,12 +107,15 @@ List<List<String>> robocopyRuns({
|
||||||
int bandwidthKBps = 0,
|
int bandwidthKBps = 0,
|
||||||
bool move = false,
|
bool move = false,
|
||||||
bool contents = false,
|
bool contents = false,
|
||||||
|
bool skipNewer = false,
|
||||||
}) {
|
}) {
|
||||||
// 178: 원본을 지우는 옵션 (/MOV · /MOVE) 은 [move] 로만
|
// 178: 원본을 지우는 옵션 (/MOV · /MOVE) 은 [move] 로만
|
||||||
final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o];
|
final opts = [for (final o in splitOptions(options)) if (!isSourceRemovingOption(o)) o];
|
||||||
final extra = [
|
final extra = [
|
||||||
if (bandwidthKBps > 0 && !opts.any((o) => o.toUpperCase().startsWith('/IPG'))) '/IPG:${robocopyIpg(bandwidthKBps)}',
|
if (bandwidthKBps > 0 && !opts.any((o) => o.toUpperCase().startsWith('/IPG'))) '/IPG:${robocopyIpg(bandwidthKBps)}',
|
||||||
if (move) ...robocopyMoveOptions(opts),
|
if (move) ...robocopyMoveOptions(opts),
|
||||||
|
// 229: 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" - robocopy 는 기본으로 예전 원본 (Older) 도 덮어쓴다
|
||||||
|
if (skipNewer && !opts.any((o) => o.toUpperCase() == '/XO')) '/XO',
|
||||||
];
|
];
|
||||||
final runs = <List<String>>[
|
final runs = <List<String>>[
|
||||||
for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra],
|
for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra],
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,7 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
|
||||||
value: s.copySkipNewer,
|
value: s.copySkipNewer,
|
||||||
onChanged: (v) => c.updateSettings((x) => x.copySkipNewer = v),
|
onChanged: (v) => c.updateSettings((x) => x.copySkipNewer = v),
|
||||||
title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')),
|
title: Text(tr('받는 쪽이 더 새것이면 건너뛰기')),
|
||||||
subtitle: Text(tr('현재 방식 · WebDAV 로 폴더 안의 것을 맞출 때 (rsync 의 -u 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다')),
|
subtitle: Text(tr('현재 방식 · robocopy · WebDAV 로 복사 · 이동할 때 (rsync 의 -u, robocopy 의 /XO 와 같음). 건너뛴 파일은 이동이라도 원본에 남깁니다')),
|
||||||
),
|
),
|
||||||
SettingTile(
|
SettingTile(
|
||||||
title: Text(tr('속도 제한 (KB/s)')),
|
title: Text(tr('속도 제한 (KB/s)')),
|
||||||
|
|
|
||||||
|
|
@ -377,7 +377,7 @@ class _CopyCardState extends State<_CopyCard> {
|
||||||
content: Text(trf('칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?', [
|
content: Text(trf('칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?', [
|
||||||
'${_method == 'builtin' ? tr('현재 방식') : _method} ${_options.text.trim()}',
|
'${_method == 'builtin' ? tr('현재 방식') : _method} ${_options.text.trim()}',
|
||||||
// 178: 실제로 쓰는 옵션 (원본 지우기는 이동일 때만)
|
// 178: 실제로 쓰는 옵션 (원본 지우기는 이동일 때만)
|
||||||
effectiveRunText(t.method, t.options, move: t.move),
|
effectiveRunText(t.method, t.options, move: t.move, skipNewer: widget.c.settings.copySkipNewer),
|
||||||
])),
|
])),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
|
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
|
||||||
|
|
@ -552,7 +552,7 @@ class _CopyCardState extends State<_CopyCard> {
|
||||||
width: 460,
|
width: 460,
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||||||
Text(
|
Text(
|
||||||
'${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move)])}',
|
'${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move, skipNewer: s.copySkipNewer)])}',
|
||||||
style: TextStyle(fontSize: 12, color: t.move ? Colors.orangeAccent : JjColors.textDim),
|
style: TextStyle(fontSize: 12, color: t.move ? Colors.orangeAccent : JjColors.textDim),
|
||||||
),
|
),
|
||||||
if (!t.move && optionsRemoveSource(_options.text))
|
if (!t.move && optionsRemoveSource(_options.text))
|
||||||
|
|
|
||||||
|
|
@ -183,8 +183,8 @@ bool taskDeletes(CopyTask t) {
|
||||||
/// 104: 지우기가 들어 있는 작업은 어디서 실행하든 (모니터링 [실행] · 옵션 저장 뒤 실행 · 최종 정리) Rsync 화면과 같은 미리 보기를 거친다.
|
/// 104: 지우기가 들어 있는 작업은 어디서 실행하든 (모니터링 [실행] · 옵션 저장 뒤 실행 · 최종 정리) Rsync 화면과 같은 미리 보기를 거친다.
|
||||||
/// 지울 목록이 나온 뒤에만 [실행] 이 켜진다. 지우기가 없으면 묻지 않고 true
|
/// 지울 목록이 나온 뒤에만 [실행] 이 켜진다. 지우기가 없으면 묻지 않고 true
|
||||||
/// 178 · 209: 실제로 실행할 방법 · 옵션 (원본 지우기 옵션은 빼고, 이동일 때만 그 방법의 원본 지우기를 붙인다).
|
/// 178 · 209: 실제로 실행할 방법 · 옵션 (원본 지우기 옵션은 빼고, 이동일 때만 그 방법의 원본 지우기를 붙인다).
|
||||||
/// 238: robocopy 이동은 실제로 붙는 /MOVE /IS /IT /IM 까지 (robocopyRuns 와 같게)
|
/// 238: robocopy 이동은 실제로 붙는 /MOVE /IS /IT /IM 까지 (robocopyRuns 와 같게). [skipNewer] (229): robocopy 의 /XO
|
||||||
String effectiveRunText(String method, String options, {required bool move}) {
|
String effectiveRunText(String method, String options, {required bool move, bool skipNewer = false}) {
|
||||||
final name = method == 'builtin' ? tr('현재 방식') : method;
|
final name = method == 'builtin' ? tr('현재 방식') : method;
|
||||||
if (method == 'builtin') return name;
|
if (method == 'builtin') return name;
|
||||||
final opts = withoutSourceRemovingOptions(options);
|
final opts = withoutSourceRemovingOptions(options);
|
||||||
|
|
@ -195,7 +195,8 @@ String effectiveRunText(String method, String options, {required bool move}) {
|
||||||
: method == 'rsync'
|
: method == 'rsync'
|
||||||
? ' --remove-source-files'
|
? ' --remove-source-files'
|
||||||
: '';
|
: '';
|
||||||
return '$name $opts$extra'.trim();
|
final xo = method == 'robocopy' && skipNewer && !splitOptions(opts).any((o) => o.toUpperCase() == '/XO') ? ' /XO' : '';
|
||||||
|
return '$name $opts$extra$xo'.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [includeMove] (210): 이동 (원본 파일 지우기) 도 미리 보기 · 확인을 거친다 - 모니터링의 [실행] 처럼 따로 묻는 창이 없을 때.
|
/// [includeMove] (210): 이동 (원본 파일 지우기) 도 미리 보기 · 확인을 거친다 - 모니터링의 [실행] 처럼 따로 묻는 창이 없을 때.
|
||||||
|
|
@ -207,7 +208,11 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
|
||||||
List<PreviewItem>? preview;
|
List<PreviewItem>? preview;
|
||||||
Object? error;
|
Object? error;
|
||||||
final direct = t.method == 'builtin' || [...t.sources, t.dest].any(isDav);
|
final direct = t.method == 'builtin' || [...t.sources, t.dest].any(isDav);
|
||||||
final update = optionsUpdate(t.options) || (direct && skipNewer);
|
// robocopy 는 /XO (옵션 칸 또는 환경 설정) 가 -u 와 같다
|
||||||
|
final robo = t.method == 'robocopy' && !direct;
|
||||||
|
final update = robo
|
||||||
|
? skipNewer || splitOptions(t.options).any((o) => o.toUpperCase() == '/XO')
|
||||||
|
: optionsUpdate(t.options) || (direct && skipNewer);
|
||||||
String dstOf(String src) => t.contents ? t.dest : vJoin(t.dest, vBasename(src));
|
String dstOf(String src) => t.contents ? t.dest : vJoin(t.dest, vBasename(src));
|
||||||
final ok = await showDialog<bool>(
|
final ok = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -227,7 +232,7 @@ Future<bool> confirmDeletingRun(BuildContext context, CopyTask t, {bool includeM
|
||||||
Text('${vDisplay(s)} → ${vDisplay(dstOf(s))}', style: const TextStyle(fontSize: 11, color: JjColors.textDim)),
|
Text('${vDisplay(s)} → ${vDisplay(dstOf(s))}', style: const TextStyle(fontSize: 11, color: JjColors.textDim)),
|
||||||
],
|
],
|
||||||
// 230: 칸에 적힌 옵션이 아니라 실제로 실행할 것 (원본 지우기 옵션은 이동일 때만 붙는다)
|
// 230: 칸에 적힌 옵션이 아니라 실제로 실행할 것 (원본 지우기 옵션은 이동일 때만 붙는다)
|
||||||
Text(effectiveRunText(t.method, t.options, move: t.move), style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)),
|
Text(effectiveRunText(t.method, t.options, move: t.move, skipNewer: skipNewer), style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)),
|
||||||
// 210: 이동이면 실행 뒤 원본에서 지워진다는 것을 분명히
|
// 210: 이동이면 실행 뒤 원본에서 지워진다는 것을 분명히
|
||||||
if (t.move)
|
if (t.move)
|
||||||
Padding(
|
Padding(
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,7 @@ void main() {
|
||||||
expect(effectiveRunText('rsync', '-avPog', move: true), 'rsync -avPog --remove-source-files');
|
expect(effectiveRunText('rsync', '-avPog', move: true), 'rsync -avPog --remove-source-files');
|
||||||
expect(effectiveRunText('rsync', '-avPog --remove-source-files', move: false), 'rsync -avPog');
|
expect(effectiveRunText('rsync', '-avPog --remove-source-files', move: false), 'rsync -avPog');
|
||||||
expect(effectiveRunText('robocopy', '/E /MOV', move: true), 'robocopy /E /MOVE /IS /IT /IM'); // 238: 실제로 붙는 것까지
|
expect(effectiveRunText('robocopy', '/E /MOV', move: true), 'robocopy /E /MOVE /IS /IT /IM'); // 238: 실제로 붙는 것까지
|
||||||
|
expect(effectiveRunText('robocopy', '/E', move: true, skipNewer: true), 'robocopy /E /MOVE /IS /IT /IM /XO'); // 229
|
||||||
final a = p.join(tmp.path, 'ca'), b = p.join(tmp.path, 'cb');
|
final a = p.join(tmp.path, 'ca'), b = p.join(tmp.path, 'cb');
|
||||||
Directory(a).createSync(recursive: true);
|
Directory(a).createSync(recursive: true);
|
||||||
Directory(b).createSync(recursive: true);
|
Directory(b).createSync(recursive: true);
|
||||||
|
|
|
||||||
|
|
@ -128,16 +128,17 @@ void main() {
|
||||||
expect(job.leftInSource, 0);
|
expect(job.leftInSource, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('235: 이동 뒤 원본에 남은 것은 카드 기록에도 (남은 수 · 옮긴 수)', () async {
|
test('235 · 229: robocopy 이동도 받는 쪽이 더 새것이면 건너뜀 (환경 설정 → /XO) · 남은 것은 카드 기록에도 (남은 수 · 옮긴 수)', () async {
|
||||||
// robocopy 가 건너뛰게: 받는 쪽이 더 새것 (/XO)
|
// 옵션 칸에는 /XO 가 없다 - 환경 설정 (skipNewer) 만으로 robocopy 가 건너뛴다
|
||||||
final old = File(p.join(src, 'old.txt'))..writeAsStringSync('o');
|
final old = File(p.join(src, 'old.txt'))..writeAsStringSync('o');
|
||||||
old.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2)));
|
old.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 2)));
|
||||||
File(p.join(dst, 'old.txt')).writeAsStringSync('new');
|
File(p.join(dst, 'old.txt')).writeAsStringSync('new');
|
||||||
final job = TransferJob(sources: [src], dest: dst, move: true, contents: true, method: Platform.isWindows ? CopyMethod.robocopy : CopyMethod.builtin,
|
final job = TransferJob(sources: [src], dest: dst, move: true, contents: true, method: Platform.isWindows ? CopyMethod.robocopy : CopyMethod.builtin,
|
||||||
options: Platform.isWindows ? '/E /XO' : '', skipNewer: true);
|
options: Platform.isWindows ? '/E /COPY:DAT /DCOPY:T /R:2 /W:2' : '', skipNewer: true);
|
||||||
await job.run();
|
await job.run();
|
||||||
expect(job.error, isNull);
|
expect(job.error, isNull);
|
||||||
expect(old.existsSync(), isTrue);
|
expect(File(p.join(dst, 'old.txt')).readAsStringSync(), 'new', reason: '대상의 더 새 파일 그대로');
|
||||||
|
expect(old.existsSync(), isTrue, reason: '건너뛴 원본은 남김');
|
||||||
expect(job.leftInSource, 1);
|
expect(job.leftInSource, 1);
|
||||||
expect([job.doneFiles, job.allFiles], [job.allFiles - 1, job.allFiles], reason: '옮긴 수는 남은 것을 뺀다 (robocopy 는 다 했다고 셈)');
|
expect([job.doneFiles, job.allFiles], [job.allFiles - 1, job.allFiles], reason: '옮긴 수는 남은 것을 뺀다 (robocopy 는 다 했다고 셈)');
|
||||||
final t = CopyTask(id: '1', sources: [src], dest: dst, move: true).copyWith(lastResult: 'done', lastFiles: job.doneFiles, lastLeft: job.leftInSource);
|
final t = CopyTask(id: '1', sources: [src], dest: dst, move: true).copyWith(lastResult: 'done', lastFiles: job.doneFiles, lastLeft: job.leftInSource);
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,9 @@ void main() {
|
||||||
expect(runs[1], [r'C:\b', r'E:\dst', '1.mkv', '2.mkv', '/R:2', '/IPG:100', '/MOV', '/IS', '/IT', '/IM']); // 파일만: /E 빼고 /MOV. 217: 이동은 같은 파일도 (/IS · /IT · /IM)
|
expect(runs[1], [r'C:\b', r'E:\dst', '1.mkv', '2.mkv', '/R:2', '/IPG:100', '/MOV', '/IS', '/IT', '/IM']); // 파일만: /E 빼고 /MOV. 217: 이동은 같은 파일도 (/IS · /IT · /IM)
|
||||||
expect(runs[2].sublist(0, 3), [r'C:\c', r'E:\dst', '3.mkv']);
|
expect(runs[2].sublist(0, 3), [r'C:\c', r'E:\dst', '3.mkv']);
|
||||||
expect(robocopyIpg(0), 0);
|
expect(robocopyIpg(0), 0);
|
||||||
|
// 229: 환경 설정 "받는 쪽이 더 새것이면 건너뛰기" → /XO (옵션 칸에 있으면 두 번 넣지 않음)
|
||||||
|
expect(robocopyRuns(options: '/E', folders: [r'C:\a'], files: const [], dest: r'E:\d', skipNewer: true).single.last, '/XO');
|
||||||
|
expect(robocopyRuns(options: '/E /xo', folders: [r'C:\a'], files: const [], dest: r'E:\d', skipNewer: true).single.where((o) => o.toUpperCase() == '/XO').length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rsync 출력 읽기: 파일 줄만 (폴더 · 진행 · 요약 줄 뺌)', () {
|
test('rsync 출력 읽기: 파일 줄만 (폴더 · 진행 · 요약 줄 뺌)', () {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue