diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 3514bd9..98c6117 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1060,6 +1060,8 @@ "마지막으로: {0}": "To the last: {0}", "막은 경로 {0}개": "{0} paths blocked", "만든 그림 · 저장: {0}": "Made images · saved in: {0}", + "만든 날": "Created", + "만든 때: {0}": "Created: {0}", "만들 MKV 가 이미 있으면": "If the MKV already exists", "만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)", "만들 파일: {0}": "File to create: {0}", @@ -1454,6 +1456,8 @@ "속도 제한": "Speed limit", "속도 제한 (KB/s)": "Speed limit (KB/s)", "속도 제한 {0} KB/s": "Speed limit {0} KB/s", + "속성": "Attributes", + "속성: {0}": "Attributes: {0}", "수": "Wed", "수정": "Edit", "수정 · 이동": "Edit · move", @@ -1479,6 +1483,7 @@ "시드 (-1 은 아무렇게나)": "Seed (-1 = random)", "시드 {0}": "Seed {0}", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "Series: files that differ only by number, like file_001 · file_002, S01E01 · S01E02", + "시스템": "System", "시스템 DNS": "System DNS", "시스템 DNS (끄기)": "System DNS (off)", "시스템 DNS 로 바꾸기": "Switch to system DNS", @@ -1941,6 +1946,7 @@ "일반 오류": "general failure", "일본어": "Japanese", "일시정지": "Pause", + "읽기 전용": "Read-only", "읽기 전용 매체입니다": "Read-only media", "읽는 중 · 폴더 {0}개 · {1}": "Reading · {0} folders · {1}", "읽을 수 없습니다": "Cannot read", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 0e18cf4..851b15e 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1060,6 +1060,8 @@ "마지막으로: {0}": "最後へ: {0}", "막은 경로 {0}개": "ブロックしたパス {0} 件", "만든 그림 · 저장: {0}": "作成した画像・保存先: {0}", + "만든 날": "作成日", + "만든 때: {0}": "作成日時: {0}", "만들 MKV 가 이미 있으면": "作る MKV がすでにあるとき", "만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)", "만들 파일: {0}": "作成するファイル: {0}", @@ -1454,6 +1456,8 @@ "속도 제한": "速度制限", "속도 제한 (KB/s)": "速度制限 (KB/s)", "속도 제한 {0} KB/s": "速度制限 {0} KB/s", + "속성": "属性", + "속성: {0}": "属性: {0}", "수": "水", "수정": "編集", "수정 · 이동": "編集・移動", @@ -1479,6 +1483,7 @@ "시드 (-1 은 아무렇게나)": "シード (-1 はランダム)", "시드 {0}": "シード {0}", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "シリーズ: file_001・file_002、S01E01・S01E02 のように番号だけ違うファイル", + "시스템": "システム", "시스템 DNS": "システム DNS", "시스템 DNS (끄기)": "システム DNS (オフ)", "시스템 DNS 로 바꾸기": "システム DNS に切り替える", @@ -1941,6 +1946,7 @@ "일반 오류": "一般エラー", "일본어": "日本語", "일시정지": "一時停止", + "읽기 전용": "読み取り専用", "읽기 전용 매체입니다": "読み取り専用メディアです", "읽는 중 · 폴더 {0}개 · {1}": "読み込み中 · フォルダー {0} 件 · {1}", "읽을 수 없습니다": "読み込めません", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 9fa6dc0..ecf9a10 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1060,6 +1060,8 @@ "마지막으로: {0}": "到最后: {0}", "막은 경로 {0}개": "已阻止 {0} 个路径", "만든 그림 · 저장: {0}": "生成的图片 · 保存位置: {0}", + "만든 날": "创建日期", + "만든 때: {0}": "创建时间:{0}", "만들 MKV 가 이미 있으면": "要制作的 MKV 已存在时", "만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)", "만들 파일: {0}": "要创建的文件: {0}", @@ -1454,6 +1456,8 @@ "속도 제한": "速度限制", "속도 제한 (KB/s)": "速度限制 (KB/s)", "속도 제한 {0} KB/s": "速度限制 {0} KB/s", + "속성": "属性", + "속성: {0}": "属性:{0}", "수": "三", "수정": "编辑", "수정 · 이동": "编辑 · 移动", @@ -1479,6 +1483,7 @@ "시드 (-1 은 아무렇게나)": "种子 (-1 为随机)", "시드 {0}": "种子 {0}", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "系列: 像 file_001 · file_002、S01E01 · S01E02 这样仅编号不同的文件", + "시스템": "系统", "시스템 DNS": "系统 DNS", "시스템 DNS (끄기)": "系统 DNS (关闭)", "시스템 DNS 로 바꾸기": "切换为系统 DNS", @@ -1941,6 +1946,7 @@ "일반 오류": "一般错误", "일본어": "日语", "일시정지": "暂停", + "읽기 전용": "只读", "읽기 전용 매체입니다": "只读介质", "읽는 중 · 폴더 {0}개 · {1}": "正在读取 · {0} 个文件夹 · {1}", "읽을 수 없습니다": "无法读取", diff --git a/lib/core/file_attributes.dart b/lib/core/file_attributes.dart new file mode 100644 index 0000000..0dda27f --- /dev/null +++ b/lib/core/file_attributes.dart @@ -0,0 +1,49 @@ +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; +import 'package:path/path.dart' as p; + +import '../l10n/tr.dart'; + +/// X-plore P3 보충 (관리자 지시 10/18): 파일 · 폴더의 숨김 · 읽기 전용 · 시스템 속성과 만든 때 - 보기만 (바꾸지 않음). +/// Windows 는 GetFileAttributesExW (kernel32), 그 밖 (Android) 은 이름이 점으로 시작하면 숨김 · 만든 때는 모름. +class FileAttributes { + final bool hidden; + final bool readOnly; + final bool system; + + /// 만든 때 (모르면 null) + final DateTime? created; + const FileAttributes({this.hidden = false, this.readOnly = false, this.system = false, this.created}); + + /// 화면에 보일 속성 이름들 (없으면 빈 목록) + List get labels => [if (hidden) tr('숨김'), if (readOnly) tr('읽기 전용'), if (system) tr('시스템')]; +} + +typedef _GetAttrsExC = Int32 Function(Pointer name, Int32 level, Pointer info); +typedef _GetAttrsExD = int Function(Pointer name, int level, Pointer info); + +/// [path] 의 속성 (읽지 못하면 null). 로컬 경로만 (원격은 부르지 않는다) +FileAttributes? readFileAttributes(String path) { + if (!Platform.isWindows) return FileAttributes(hidden: p.basename(path).startsWith('.')); + final name = path.toNativeUtf16(); + // WIN32_FILE_ATTRIBUTE_DATA: DWORD 속성 · FILETIME 만든 · 열어 본 · 바뀐 때 · DWORD 크기 (위 · 아래) = 36 바이트 + final info = calloc(36); + try { + final f = DynamicLibrary.open('kernel32.dll').lookupFunction<_GetAttrsExC, _GetAttrsExD>('GetFileAttributesExW'); + if (f(name, 0 /* GetFileExInfoStandard */, info) == 0) return null; + final b = ByteData.sublistView(info.asTypedList(36)); + final attrs = b.getUint32(0, Endian.little); + final ft = b.getUint32(4, Endian.little) | (b.getUint32(8, Endian.little) << 32); + // FILETIME: 1601-01-01 부터 100ns 단위 + final created = ft == 0 ? null : DateTime.fromMicrosecondsSinceEpoch(ft ~/ 10 - 11644473600000000, isUtc: true).toLocal(); + return FileAttributes(hidden: attrs & 0x2 != 0, readOnly: attrs & 0x1 != 0, system: attrs & 0x4 != 0, created: created); + } catch (_) { + return null; + } finally { + calloc.free(name); + calloc.free(info); + } +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index c26f385..f914020 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -48,6 +48,7 @@ import 'explorer_bookmarks.dart'; import 'explorer_search.dart'; import 'quick_look.dart'; import 'file_tools.dart' show showFileHash, showFolderProperties; +import '../core/file_attributes.dart' show readFileAttributes; import 'explorer_drag.dart'; import 'archive_ui.dart'; import 'storage_page.dart'; @@ -3215,6 +3216,8 @@ class _ExplorerPageState extends State { Future _info(FileEntry e) async { final size = e.isDir ? await FileOps.totalSize([e.path]) : e.size; final meta = !e.isDir && !isDav(e.path) && isVideoFile(e.path) ? await _Meta.of(c, e.path) : null; + // 관리자 지시 10/18: 만든 때 · 숨김 · 읽기 전용 · 시스템 (보기만, 이 기기의 것만) + final attrs = isDav(e.path) ? null : readFileAttributes(e.path); if (!mounted) return; await showDialog( context: context, @@ -3224,7 +3227,9 @@ class _ExplorerPageState extends State { content: SelectableText([ e.path, '${tr('크기')}: ${formatSize(size)}', + if (attrs?.created != null) '${tr('만든 날')}: ${_date(attrs!.created!)}', if (e.modified.year > 1) '${tr('바뀐 날')}: ${_date(e.modified)}', + if (attrs != null) '${tr('속성')}: ${attrs.labels.isEmpty ? tr('없음') : attrs.labels.join(' · ')}', if (meta != null && meta.isNotEmpty) '${tr('동영상')}: $meta', ].join('\n')), actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))], diff --git a/lib/ui/file_tools.dart b/lib/ui/file_tools.dart index a3a5a8e..62a9087 100644 --- a/lib/ui/file_tools.dart +++ b/lib/ui/file_tools.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; +import '../core/file_attributes.dart'; import '../core/file_ops.dart' show formatSize; import '../l10n/tr.dart'; import 'theme.dart'; @@ -228,6 +229,7 @@ Future<({int files, int folders, int bytes, int links})> folderStats(String dir, Future showFolderProperties(BuildContext context, String dir) async { var cancelled = false; final stat = await FileStat.stat(dir); + final attrs = readFileAttributes(dir); if (!context.mounted) return; String date(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; @@ -253,8 +255,11 @@ Future showFolderProperties(BuildContext context, String dir) async { if (r.links > 0) Text(trf('링크 · junction {0}개 (따라가지 않고 세지 않음)', [r.links])), ], const SizedBox(height: 6), + // 관리자 지시 10/18: 만든 때 · 숨김 · 읽기 전용 (보기만) + if (attrs?.created != null) Text(trf('만든 때: {0}', [date(attrs!.created!)])), Text(trf('바뀐 때: {0}', [date(stat.modified)])), Text(trf('열어 본 때: {0}', [date(stat.accessed)])), + Text(trf('속성: {0}', [attrs == null || attrs.labels.isEmpty ? tr('없음') : attrs.labels.join(' · ')])), ]); }, ), diff --git a/test/file_attributes_test.dart b/test/file_attributes_test.dart new file mode 100644 index 0000000..b899278 --- /dev/null +++ b/test/file_attributes_test.dart @@ -0,0 +1,41 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/file_attributes.dart'; +import 'package:path/path.dart' as p; + +/// P3 보충: 숨김 · 읽기 전용 · 만든 때 (Windows GetFileAttributesExW) - 시험 파일에만 attrib 로 속성을 켜고 끝에 끈다 +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_attrs_')); + tearDown(() async { + if (Platform.isWindows) await Process.run('attrib', ['-h', '-r', '-s', p.join(tmp.path, '*'), '/s', '/d']); + tmp.deleteSync(recursive: true); + }); + + test('보통 파일: 속성 없음 · 만든 때는 지금 즈음', () { + final f = File(p.join(tmp.path, 'a.txt'))..writeAsStringSync('a'); + final a = readFileAttributes(f.path)!; + expect(a.labels, isEmpty); + if (Platform.isWindows) { + expect(a.created, isNotNull); + expect(DateTime.now().difference(a.created!).inMinutes.abs(), lessThan(5)); + } + }); + + test('숨김 · 읽기 전용 (Windows attrib) · 폴더', () async { + final f = File(p.join(tmp.path, 'b.txt'))..writeAsStringSync('b'); + final d = Directory(p.join(tmp.path, 'd'))..createSync(); + if (!Platform.isWindows) { + expect(readFileAttributes(p.join(tmp.path, '.hidden'))!.hidden, isTrue); + return; + } + await Process.run('attrib', ['+h', '+r', f.path]); + await Process.run('attrib', ['+h', d.path]); + final a = readFileAttributes(f.path)!; + expect((a.hidden, a.readOnly, a.system), (true, true, false)); + expect(a.labels, ['숨김', '읽기 전용']); + expect(readFileAttributes(d.path)!.hidden, isTrue); + expect(readFileAttributes(p.join(tmp.path, '없음')), isNull); + }); +}