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
parent
8eb1674382
commit
d29593b2e6
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
"읽을 수 없습니다": "読み込めません",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
"읽을 수 없습니다": "无法读取",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ExplorerPage> {
|
|||
Future<void> _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<void>(
|
||||
context: context,
|
||||
|
|
@ -3224,7 +3227,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
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('닫기')))],
|
||||
|
|
|
|||
|
|
@ -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<void> 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<void> 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(' · ')])),
|
||||
]);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue