WIP P3 보충: 폴더 속성 · 파일 [정보] 에 만든 때 · 숨김 · 읽기 전용 · 시스템 (보기만) (검토 대기)

관리자 지시 (10/18). lib/core/file_attributes.dart: Windows 는 FFI GetFileAttributesExW (kernel32, file_ops 의 GetDriveTypeW 와 같은 꼴), 그 밖 (Android) 은 이름이 점으로 시작하면 숨김 · 만든 때는 모름. 바꾸기는 넣지 않음.
폴더 속성 창: 만든 때 · 속성 (없으면 없음). 파일 [정보] 창: 만든 날 · 속성 (원격은 뺌).
시험: file_attributes_test 2 (시험 파일에만 attrib +h +r 을 켜고 끝에 끔 - 숨김 · 읽기 전용 · 폴더 · 없는 경로 null · 만든 때) · file_tools · explorer P3 통과, analyze 문제 없음, l10n 빠짐 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xplore_p3
RyuJongJin 2026-10-18 01:47:45 +09:00
parent 8eb1674382
commit d29593b2e6
7 changed files with 118 additions and 0 deletions

View File

@ -1060,6 +1060,8 @@
"마지막으로: {0}": "To the last: {0}", "마지막으로: {0}": "To the last: {0}",
"막은 경로 {0}개": "{0} paths blocked", "막은 경로 {0}개": "{0} paths blocked",
"만든 그림 · 저장: {0}": "Made images · saved in: {0}", "만든 그림 · 저장: {0}": "Made images · saved in: {0}",
"만든 날": "Created",
"만든 때: {0}": "Created: {0}",
"만들 MKV 가 이미 있으면": "If the MKV already exists", "만들 MKV 가 이미 있으면": "If the MKV already exists",
"만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)", "만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)",
"만들 파일: {0}": "File to create: {0}", "만들 파일: {0}": "File to create: {0}",
@ -1454,6 +1456,8 @@
"속도 제한": "Speed limit", "속도 제한": "Speed limit",
"속도 제한 (KB/s)": "Speed limit (KB/s)", "속도 제한 (KB/s)": "Speed limit (KB/s)",
"속도 제한 {0} KB/s": "Speed limit {0} KB/s", "속도 제한 {0} KB/s": "Speed limit {0} KB/s",
"속성": "Attributes",
"속성: {0}": "Attributes: {0}",
"수": "Wed", "수": "Wed",
"수정": "Edit", "수정": "Edit",
"수정 · 이동": "Edit · move", "수정 · 이동": "Edit · move",
@ -1479,6 +1483,7 @@
"시드 (-1 은 아무렇게나)": "Seed (-1 = random)", "시드 (-1 은 아무렇게나)": "Seed (-1 = random)",
"시드 {0}": "Seed {0}", "시드 {0}": "Seed {0}",
"시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "Series: files that differ only by number, like file_001 · file_002, S01E01 · S01E02", "시리즈: 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",
"시스템 DNS (끄기)": "System DNS (off)", "시스템 DNS (끄기)": "System DNS (off)",
"시스템 DNS 로 바꾸기": "Switch to system DNS", "시스템 DNS 로 바꾸기": "Switch to system DNS",
@ -1941,6 +1946,7 @@
"일반 오류": "general failure", "일반 오류": "general failure",
"일본어": "Japanese", "일본어": "Japanese",
"일시정지": "Pause", "일시정지": "Pause",
"읽기 전용": "Read-only",
"읽기 전용 매체입니다": "Read-only media", "읽기 전용 매체입니다": "Read-only media",
"읽는 중 · 폴더 {0}개 · {1}": "Reading · {0} folders · {1}", "읽는 중 · 폴더 {0}개 · {1}": "Reading · {0} folders · {1}",
"읽을 수 없습니다": "Cannot read", "읽을 수 없습니다": "Cannot read",

View File

@ -1060,6 +1060,8 @@
"마지막으로: {0}": "最後へ: {0}", "마지막으로: {0}": "最後へ: {0}",
"막은 경로 {0}개": "ブロックしたパス {0} 件", "막은 경로 {0}개": "ブロックしたパス {0} 件",
"만든 그림 · 저장: {0}": "作成した画像・保存先: {0}", "만든 그림 · 저장: {0}": "作成した画像・保存先: {0}",
"만든 날": "作成日",
"만든 때: {0}": "作成日時: {0}",
"만들 MKV 가 이미 있으면": "作る MKV がすでにあるとき", "만들 MKV 가 이미 있으면": "作る MKV がすでにあるとき",
"만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)", "만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)",
"만들 파일: {0}": "作成するファイル: {0}", "만들 파일: {0}": "作成するファイル: {0}",
@ -1454,6 +1456,8 @@
"속도 제한": "速度制限", "속도 제한": "速度制限",
"속도 제한 (KB/s)": "速度制限 (KB/s)", "속도 제한 (KB/s)": "速度制限 (KB/s)",
"속도 제한 {0} KB/s": "速度制限 {0} KB/s", "속도 제한 {0} KB/s": "速度制限 {0} KB/s",
"속성": "属性",
"속성: {0}": "属性: {0}",
"수": "水", "수": "水",
"수정": "編集", "수정": "編集",
"수정 · 이동": "編集・移動", "수정 · 이동": "編集・移動",
@ -1479,6 +1483,7 @@
"시드 (-1 은 아무렇게나)": "シード (-1 はランダム)", "시드 (-1 은 아무렇게나)": "シード (-1 はランダム)",
"시드 {0}": "シード {0}", "시드 {0}": "シード {0}",
"시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "シリーズ: file_001・file_002、S01E01・S01E02 のように番号だけ違うファイル", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "シリーズ: file_001・file_002、S01E01・S01E02 のように番号だけ違うファイル",
"시스템": "システム",
"시스템 DNS": "システム DNS", "시스템 DNS": "システム DNS",
"시스템 DNS (끄기)": "システム DNS (オフ)", "시스템 DNS (끄기)": "システム DNS (オフ)",
"시스템 DNS 로 바꾸기": "システム DNS に切り替える", "시스템 DNS 로 바꾸기": "システム DNS に切り替える",
@ -1941,6 +1946,7 @@
"일반 오류": "一般エラー", "일반 오류": "一般エラー",
"일본어": "日本語", "일본어": "日本語",
"일시정지": "一時停止", "일시정지": "一時停止",
"읽기 전용": "読み取り専用",
"읽기 전용 매체입니다": "読み取り専用メディアです", "읽기 전용 매체입니다": "読み取り専用メディアです",
"읽는 중 · 폴더 {0}개 · {1}": "読み込み中 · フォルダー {0} 件 · {1}", "읽는 중 · 폴더 {0}개 · {1}": "読み込み中 · フォルダー {0} 件 · {1}",
"읽을 수 없습니다": "読み込めません", "읽을 수 없습니다": "読み込めません",

View File

@ -1060,6 +1060,8 @@
"마지막으로: {0}": "到最后: {0}", "마지막으로: {0}": "到最后: {0}",
"막은 경로 {0}개": "已阻止 {0} 个路径", "막은 경로 {0}개": "已阻止 {0} 个路径",
"만든 그림 · 저장: {0}": "生成的图片 · 保存位置: {0}", "만든 그림 · 저장: {0}": "生成的图片 · 保存位置: {0}",
"만든 날": "创建日期",
"만든 때: {0}": "创建时间:{0}",
"만들 MKV 가 이미 있으면": "要制作的 MKV 已存在时", "만들 MKV 가 이미 있으면": "要制作的 MKV 已存在时",
"만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)", "만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)",
"만들 파일: {0}": "要创建的文件: {0}", "만들 파일: {0}": "要创建的文件: {0}",
@ -1454,6 +1456,8 @@
"속도 제한": "速度限制", "속도 제한": "速度限制",
"속도 제한 (KB/s)": "速度限制 (KB/s)", "속도 제한 (KB/s)": "速度限制 (KB/s)",
"속도 제한 {0} KB/s": "速度限制 {0} KB/s", "속도 제한 {0} KB/s": "速度限制 {0} KB/s",
"속성": "属性",
"속성: {0}": "属性:{0}",
"수": "三", "수": "三",
"수정": "编辑", "수정": "编辑",
"수정 · 이동": "编辑 · 移动", "수정 · 이동": "编辑 · 移动",
@ -1479,6 +1483,7 @@
"시드 (-1 은 아무렇게나)": "种子 (-1 为随机)", "시드 (-1 은 아무렇게나)": "种子 (-1 为随机)",
"시드 {0}": "种子 {0}", "시드 {0}": "种子 {0}",
"시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "系列: 像 file_001 · file_002、S01E01 · S01E02 这样仅编号不同的文件", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "系列: 像 file_001 · file_002、S01E01 · S01E02 这样仅编号不同的文件",
"시스템": "系统",
"시스템 DNS": "系统 DNS", "시스템 DNS": "系统 DNS",
"시스템 DNS (끄기)": "系统 DNS (关闭)", "시스템 DNS (끄기)": "系统 DNS (关闭)",
"시스템 DNS 로 바꾸기": "切换为系统 DNS", "시스템 DNS 로 바꾸기": "切换为系统 DNS",
@ -1941,6 +1946,7 @@
"일반 오류": "一般错误", "일반 오류": "一般错误",
"일본어": "日语", "일본어": "日语",
"일시정지": "暂停", "일시정지": "暂停",
"읽기 전용": "只读",
"읽기 전용 매체입니다": "只读介质", "읽기 전용 매체입니다": "只读介质",
"읽는 중 · 폴더 {0}개 · {1}": "正在读取 · {0} 个文件夹 · {1}", "읽는 중 · 폴더 {0}개 · {1}": "正在读取 · {0} 个文件夹 · {1}",
"읽을 수 없습니다": "无法读取", "읽을 수 없습니다": "无法读取",

View File

@ -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<String> get labels => [if (hidden) tr('숨김'), if (readOnly) tr('읽기 전용'), if (system) tr('시스템')];
}
typedef _GetAttrsExC = Int32 Function(Pointer<Utf16> name, Int32 level, Pointer<Uint8> info);
typedef _GetAttrsExD = int Function(Pointer<Utf16> name, int level, Pointer<Uint8> 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<Uint8>(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);
}
}

View File

@ -48,6 +48,7 @@ import 'explorer_bookmarks.dart';
import 'explorer_search.dart'; import 'explorer_search.dart';
import 'quick_look.dart'; import 'quick_look.dart';
import 'file_tools.dart' show showFileHash, showFolderProperties; import 'file_tools.dart' show showFileHash, showFolderProperties;
import '../core/file_attributes.dart' show readFileAttributes;
import 'explorer_drag.dart'; import 'explorer_drag.dart';
import 'archive_ui.dart'; import 'archive_ui.dart';
import 'storage_page.dart'; import 'storage_page.dart';
@ -3215,6 +3216,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
Future<void> _info(FileEntry e) async { Future<void> _info(FileEntry e) async {
final size = e.isDir ? await FileOps.totalSize([e.path]) : e.size; 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; 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; if (!mounted) return;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
@ -3224,7 +3227,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
content: SelectableText([ content: SelectableText([
e.path, e.path,
'${tr('크기')}: ${formatSize(size)}', '${tr('크기')}: ${formatSize(size)}',
if (attrs?.created != null) '${tr('만든 날')}: ${_date(attrs!.created!)}',
if (e.modified.year > 1) '${tr('바뀐 날')}: ${_date(e.modified)}', 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', if (meta != null && meta.isNotEmpty) '${tr('동영상')}: $meta',
].join('\n')), ].join('\n')),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))], actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기')))],

View File

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../core/file_attributes.dart';
import '../core/file_ops.dart' show formatSize; import '../core/file_ops.dart' show formatSize;
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'theme.dart'; import 'theme.dart';
@ -228,6 +229,7 @@ Future<({int files, int folders, int bytes, int links})> folderStats(String dir,
Future<void> showFolderProperties(BuildContext context, String dir) async { Future<void> showFolderProperties(BuildContext context, String dir) async {
var cancelled = false; var cancelled = false;
final stat = await FileStat.stat(dir); final stat = await FileStat.stat(dir);
final attrs = readFileAttributes(dir);
if (!context.mounted) return; if (!context.mounted) return;
String date(DateTime d) => 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')}'; '${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<void> showFolderProperties(BuildContext context, String dir) async {
if (r.links > 0) Text(trf('링크 · junction {0}개 (따라가지 않고 세지 않음)', [r.links])), if (r.links > 0) Text(trf('링크 · junction {0}개 (따라가지 않고 세지 않음)', [r.links])),
], ],
const SizedBox(height: 6), 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.modified)])),
Text(trf('열어 본 때: {0}', [date(stat.accessed)])), Text(trf('열어 본 때: {0}', [date(stat.accessed)])),
Text(trf('속성: {0}', [attrs == null || attrs.labels.isEmpty ? tr('없음') : attrs.labels.join(' · ')])),
]); ]);
}, },
), ),

View File

@ -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);
});
}