From b6e47178e35953238722e7cdad764c7e2ab2654c Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:28:55 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20X-plore=20j=20=C2=B7=20P3=20=EB=8F=84?= =?UTF-8?q?=EC=9A=B0=EB=AF=B8=20(=EB=AF=B8=EC=97=B0=EA=B2=B0,=2015=20?= =?UTF-8?q?=EC=97=90=EA=B2=8C=20=EB=84=98=EA=B9=80)=20(=EA=B2=80=ED=86=A0?= =?UTF-8?q?=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/18): j (SMB 자동 찾기) 와 P3 의 해시 · 16진 보기 · 폴더 속성은 15 가 탐색기 · SMB 추가 창에 연결한다. cd 가 만든 도우미와 시험만 넣는다 (탐색기 · 설정에는 아직 연결 안 함). - lib/core/lan_scan.dart: 사설 IPv4 /24 를 445 포트 연결 시도로 훑기 (보내는 것 없음, 64개 동시, 취소 · 진행), 역방향 이름. Tailscale 100.64/10 은 넓어 훑지 않음. test/lan_scan_test.dart (이 기기의 열린 · 닫힌 포트). - lib/ui/file_tools.dart: MD5 · SHA-256 (조각 읽기 · 취소 · 붙여 넣은 값과 비교), 16진 보기 ([더 읽기]), 폴더 속성 (링크 · junction 은 따라가지 않음). test/file_tools_test.dart. 번역 en · ja · zh-Hans. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 14 ++ assets/l10n/ja.json | 14 ++ assets/l10n/zh-Hans.json | 14 ++ lib/core/lan_scan.dart | 78 +++++++++++ lib/ui/file_tools.dart | 271 ++++++++++++++++++++++++++++++++++++++ test/file_tools_test.dart | 30 +++++ test/lan_scan_test.dart | 23 ++++ 7 files changed, 444 insertions(+) create mode 100644 lib/core/lan_scan.dart create mode 100644 lib/ui/file_tools.dart create mode 100644 test/file_tools_test.dart create mode 100644 test/lan_scan_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index b5037a3..8d455ed 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -71,6 +71,7 @@ "1/2 · AI 그림으로 다듬는 중": "1/2 · Refining with AI image", "10초 뒤로 (←)": "Back 10 seconds (←)", "10초 앞으로 (→)": "Forward 10 seconds (→)", + "16진 보기 · {0}": "Hex view · {0}", "1배 (선명하게)": "1× (sharpen)", "1분 안": "under 1 min", "2/2 · 해상도 올리는 중": "2/2 · Upscaling", @@ -439,6 +440,7 @@ "{0} (기본): {1}": "{0} (default): {1}", "{0} (동영상 {1})": "{0} (videos: {1})", "{0} - {1} (약 {2}MB)": "{0} - {1} (about {2}MB)", + "{0} / {1} 읽음": "{0} / {1} read", "{0} / {1}장": "{0} / {1} images", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2} (change at the top of the main screen)", "{0} · {1}배 · {2}": "{0} · {1}× · {2}", @@ -596,6 +598,7 @@ "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "Downloaded only if the detected language differs from the subtitle language {0}", "강하게 (0.4)": "Strong (0.4)", "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "Stronger refines more but may drift from the original", + "같습니다 ✔": "Match ✔", "같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} files that replaced a file with the same name won't be undone.", "같은 이름이 이미 있습니다": "An item with the same name already exists", @@ -865,6 +868,7 @@ "다른 항목과 같은 이름": "Same name as another item", "다른 화면으로 가거나 앱이 뒤로 가면 Tor 화면의 페이지 · 연결은 바로 정리합니다. Tor 자체는 이 시간 동안 켜 두어 다시 들어오면 바로 쓸 수 있습니다.": "When you go to another screen or the app goes to the background, the Tor screen's pages and connections are cleared right away. Tor itself keeps running for this long so you can use it again immediately.", "다른 화면으로 가면 페이지 소리 멈춤": "Pause page sound when leaving the browser", + "다릅니다 - 파일이 바뀌었거나 다른 파일입니다": "Different - the file has changed or is a different file", "다시 만들기 대기로": "Back to waiting for remake", "다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "Queue again to rebuild (after the current batch finishes)", "다시 받기": "Download again", @@ -938,6 +942,7 @@ "대소문자 그대로": "Keep case", "대소문자 무시": "Ignore case", "더 보기": "More", + "더 읽기": "Read more", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "When JJ_MKVMaker is chosen by double-click · default app (the right-click menu stays)", "더한 언어 (AI 자동 번역)": "Added language (AI auto-translated)", "덮어쓰기": "Overwrite", @@ -1029,6 +1034,7 @@ "링크 {0}개는 넣지 않음": "{0} links not included", "링크 {0}개는 만들지 않고 건너뜁니다: {1}": "{0} links will be skipped (not created): {1}", "링크 {0}개는 세지 않음": "{0} links not counted", + "링크 · junction {0}개 (따라가지 않고 세지 않음)": "{0} links / junctions (not followed or counted)", "링크 · junction 은 넣지 않습니다": "Links and junctions are not included", "마스터 · 최상 비밀번호": "Master and super passwords", "마스터 비밀번호": "Master password", @@ -1158,6 +1164,7 @@ "바뀐 날": "Modified", "바뀐 날: 아무 때": "Modified: any time", "바뀐 내용": "Changes", + "바뀐 때: {0}": "Modified: {0}", "바뀐 지 … 일 안": "Modified within … days", "바뀐 지 … 일 지남": "Modified more than … days ago", "바로 끔": "Stop right away", @@ -1309,6 +1316,7 @@ "브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 \"앱 안 브라우저\" 를 고르거나, 고른 브라우저가 설치되어 있는지 확인하세요 (Chrome · Edge 는 브라우저를 닫아야 읽힙니다).": "Could not read the browser cookies. In Settings > Downloads > YouTube cookies, choose \"In-app browser\", or check that the chosen browser is installed (Chrome · Edge must be closed to be read).", "비공개 · 회원 전용 영상입니다.": "This is a private · members-only video.", "비교하지 못했습니다: {0}": "Couldn't compare: {0}", + "비교할 값 붙여넣기 (받은 곳의 해시)": "Paste a value to compare (hash from the source)", "비밀번호": "Password", "비밀번호 (선택)": "Password (optional)", "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "Passwords · API keys (WebDAV · OpenSubtitles · AI services)", @@ -1625,6 +1633,7 @@ "열 수 없습니다: {0}": "Cannot open: {0}", "열기": "Open", "열기 · 재생": "Open · Play", + "열어 본 때: {0}": "Accessed: {0}", "영구 삭제": "Delete permanently", "영구 삭제 ({0}개, 되돌릴 수 없음)": "Delete permanently ({0}, cannot be undone)", "영구 삭제하면 되살릴 수 없습니다.": "Permanently deleted items can't be restored.", @@ -1939,6 +1948,7 @@ "읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "The original file that could not be read was not deleted; it is kept here:", "읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다": "{0} unreadable folders (permission or connection problem) - not searched inside", "읽지 못한 폴더 {0}개 (크기가 실제보다 작을 수 있음)": "{0} folders couldn't be read (sizes may be smaller than actual)", + "읽지 못했습니다: {0}": "Couldn't read: {0}", "임시 파일 · 남은 조각 정리": "Clean temporary files · leftovers", "자동": "Auto", "자동 (가로로 긴 그림만)": "Auto (wide images only)", @@ -2312,6 +2322,7 @@ "크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "With size, date or type conditions, only files are found.", "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "Size and adjustments are back to original, so the codec was set back to \"Keep original\".", "크기 이상": "Min size", + "크기: {0} ({1} 바이트)": "Size: {0} ({1} bytes)", "크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "Sharpens without changing the size and saves new files. The originals and ZIP stay unchanged. It continues after you close the screen; the task notification shows remaining pages and estimated time.", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Changing the size requires re-encoding, so the codec was changed from \"Keep original\" to H.264.", "크기순 정렬일 때만 (기본)": "Only when sorting by size (default)", @@ -2343,6 +2354,7 @@ "파일 {0}/{1}": "Files {0}/{1}", "파일 {0}개": "{0} files", "파일 {0}개 · {1}": "{0} files · {1}", + "파일 {0}개 · 폴더 {1}개": "{0} files · {1} folders", "파일 관리자에서 보기": "Show in file manager", "파일 복사 · 이동 방법": "File copy · move method", "파일 세는 중…": "Counting files…", @@ -2401,6 +2413,7 @@ "폴더 복사 · 이동 방법": "Folder copy · move method", "폴더 삭제: {0} (안의 {1}개 포함)": "Folder deleted: {0} (including {1} inside)", "폴더 선택": "Choose folder", + "폴더 속성 · {0}": "Folder properties · {0}", "폴더 수정": "Edit folder", "폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. 길게 누르기 [폴더 크기 계산] 은 늘 됩니다": "Shows the total size of the files inside each folder. Large folders or a NAS can take a while to read. Long-press [Calculate folder size] always works", "폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).": "Syncs the folder contents (no same-named folder is created in the target).", @@ -2495,6 +2508,7 @@ "해상도 올리기 모델을 받아야 합니다. 어느 것을 받을까요?": "An upscaling model is needed. Which one should be downloaded?", "해상도를 올리지 못했습니다": "Couldn't upscale", "해상도를 올린 그림 저장 폴더": "Folder for upscaled images", + "해시 · {0}": "Hash · {0}", "해제": "Unset", "허용": "Allow", "허용했으면 다시 확인": "Check again after allowing", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 7faf995..36146db 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -71,6 +71,7 @@ "1/2 · AI 그림으로 다듬는 중": "1/2 · AI 画像で整え中", "10초 뒤로 (←)": "10秒戻る(←)", "10초 앞으로 (→)": "10秒進む(→)", + "16진 보기 · {0}": "16進表示 · {0}", "1배 (선명하게)": "1倍 (くっきり)", "1분 안": "1 分以内", "2/2 · 해상도 올리는 중": "2/2 · 解像度を上げています", @@ -439,6 +440,7 @@ "{0} (기본): {1}": "{0}(既定): {1}", "{0} (동영상 {1})": "{0} (動画 {1})", "{0} - {1} (약 {2}MB)": "{0} - {1}(約 {2}MB)", + "{0} / {1} 읽음": "{0} / {1} 読み込み済み", "{0} / {1}장": "{0} / {1} 枚", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(メイン画面の上部で変更)", "{0} · {1}배 · {2}": "{0}・{1} 倍・{2}", @@ -596,6 +598,7 @@ "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}", "강하게 (0.4)": "強め (0.4)", "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "強いほど大きく整えますが、元と違ってくることがあります", + "같습니다 ✔": "一致します ✔", "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "同じ名前を上書きしたファイル {0} 件は元に戻しません。", "같은 이름이 이미 있습니다": "同じ名前のものが既にあります", @@ -865,6 +868,7 @@ "다른 항목과 같은 이름": "他の項目と同じ名前", "다른 화면으로 가거나 앱이 뒤로 가면 Tor 화면의 페이지 · 연결은 바로 정리합니다. Tor 자체는 이 시간 동안 켜 두어 다시 들어오면 바로 쓸 수 있습니다.": "別の画面に移るかアプリがバックグラウンドに回ると、Tor 画面のページ · 接続はすぐに片付けます。Tor 自体はこの時間だけ動かしておき、戻ったらすぐ使えます。", "다른 화면으로 가면 페이지 소리 멈춤": "他の画面に移るとページの音を止める", + "다릅니다 - 파일이 바뀌었거나 다른 파일입니다": "異なります - ファイルが変更されたか別のファイルです", "다시 만들기 대기로": "作り直し待ちに戻す", "다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "再作成待ちに戻す (今のまとめ作業が終わった後)", "다시 받기": "再ダウンロード", @@ -938,6 +942,7 @@ "대소문자 그대로": "大文字・小文字はそのまま", "대소문자 무시": "大文字・小文字を区別しない", "더 보기": "その他", + "더 읽기": "続きを読む", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "ダブルクリック・既定のアプリで JJ_MKVMaker を選んだとき(右クリックメニューはそのまま)", "더한 언어 (AI 자동 번역)": "追加した言語(AI 自動翻訳)", "덮어쓰기": "上書き", @@ -1029,6 +1034,7 @@ "링크 {0}개는 넣지 않음": "リンク {0} 件は含めず", "링크 {0}개는 만들지 않고 건너뜁니다: {1}": "リンク {0} 件は作成せずスキップします: {1}", "링크 {0}개는 세지 않음": "リンク {0} 件は数えず", + "링크 · junction {0}개 (따라가지 않고 세지 않음)": "リンク・ジャンクション {0} 個 (たどらず数えません)", "링크 · junction 은 넣지 않습니다": "リンク・ジャンクションは含めません", "마스터 · 최상 비밀번호": "マスター / 最上位パスワード", "마스터 비밀번호": "マスターパスワード", @@ -1158,6 +1164,7 @@ "바뀐 날": "更新日時", "바뀐 날: 아무 때": "更新日: いつでも", "바뀐 내용": "変更内容", + "바뀐 때: {0}": "更新日時: {0}", "바뀐 지 … 일 안": "更新から … 日以内", "바뀐 지 … 일 지남": "更新から … 日以上", "바로 끔": "すぐ終了", @@ -1309,6 +1316,7 @@ "브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 \"앱 안 브라우저\" 를 고르거나, 고른 브라우저가 설치되어 있는지 확인하세요 (Chrome · Edge 는 브라우저를 닫아야 읽힙니다).": "ブラウザーのクッキーを読めませんでした。環境設定 > ダウンロード > YouTube クッキー で「アプリ内ブラウザー」を選ぶか、選んだブラウザーがインストールされているか確認してください(Chrome・Edge はブラウザーを閉じないと読めません)。", "비공개 · 회원 전용 영상입니다.": "非公開・メンバー限定の動画です。", "비교하지 못했습니다: {0}": "比較できませんでした: {0}", + "비교할 값 붙여넣기 (받은 곳의 해시)": "比較する値を貼り付け (入手元のハッシュ)", "비밀번호": "パスワード", "비밀번호 (선택)": "パスワード(任意)", "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "パスワード・API キー (WebDAV・OpenSubtitles・AI サービス)", @@ -1625,6 +1633,7 @@ "열 수 없습니다: {0}": "開けません: {0}", "열기": "開く", "열기 · 재생": "開く・再生", + "열어 본 때: {0}": "アクセス日時: {0}", "영구 삭제": "完全に削除", "영구 삭제 ({0}개, 되돌릴 수 없음)": "完全に削除 ({0} 件、元に戻せません)", "영구 삭제하면 되살릴 수 없습니다.": "完全に削除すると元に戻せません。", @@ -1939,6 +1948,7 @@ "읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "読めなかった元のファイルは削除せず、ここに保管しました:", "읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다": "読めなかったフォルダー {0} 個 (権限・接続の問題) - その中は探せませんでした", "읽지 못한 폴더 {0}개 (크기가 실제보다 작을 수 있음)": "読めなかったフォルダー {0} 件 (サイズが実際より小さいことがあります)", + "읽지 못했습니다: {0}": "読み込めませんでした: {0}", "임시 파일 · 남은 조각 정리": "一時ファイル・残った断片を整理", "자동": "自動", "자동 (가로로 긴 그림만)": "自動 (横長の画像のみ)", @@ -2312,6 +2322,7 @@ "크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "サイズ・更新日・種類を指定すると、ファイルだけを探します。", "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "サイズ・補正を元に戻したので、コーデックも「元のまま」に戻しました。", "크기 이상": "最小サイズ", + "크기: {0} ({1} 바이트)": "サイズ: {0} ({1} バイト)", "크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "サイズはそのままでくっきりさせ、新しいファイルに保存します。元のファイル・ZIP はそのままです。画面を閉じても続き、作業通知に残り枚数と予想時間が表示されます。", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "サイズを変えると再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。", "크기순 정렬일 때만 (기본)": "サイズ順のときだけ (既定)", @@ -2343,6 +2354,7 @@ "파일 {0}/{1}": "ファイル {0}/{1}", "파일 {0}개": "ファイル {0} 件", "파일 {0}개 · {1}": "ファイル {0} 件 · {1}", + "파일 {0}개 · 폴더 {1}개": "ファイル {0} 個・フォルダー {1} 個", "파일 관리자에서 보기": "ファイルマネージャーで表示", "파일 복사 · 이동 방법": "ファイルのコピー · 移動方法", "파일 세는 중…": "ファイルを数えています…", @@ -2401,6 +2413,7 @@ "폴더 복사 · 이동 방법": "フォルダーのコピー · 移動方法", "폴더 삭제: {0} (안의 {1}개 포함)": "フォルダーを削除: {0}(中の {1}個を含む)", "폴더 선택": "フォルダーを選択", + "폴더 속성 · {0}": "フォルダーのプロパティ · {0}", "폴더 수정": "フォルダーを編集", "폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. 길게 누르기 [폴더 크기 계산] 은 늘 됩니다": "フォルダー内のファイルサイズの合計を一覧に表示します。大きなフォルダー・NAS は読み込みに時間がかかることがあります。長押しの [フォルダーサイズを計算] はいつでも使えます", "폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).": "フォルダーの中身を合わせます (対象に同じ名前のフォルダーは作りません)。", @@ -2495,6 +2508,7 @@ "해상도 올리기 모델을 받아야 합니다. 어느 것을 받을까요?": "解像度アップのモデルが必要です。どれをダウンロードしますか?", "해상도를 올리지 못했습니다": "高解像度化できませんでした", "해상도를 올린 그림 저장 폴더": "高解像度化した画像の保存フォルダー", + "해시 · {0}": "ハッシュ · {0}", "해제": "解除", "허용": "許可", "허용했으면 다시 확인": "許可したらもう一度確認", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 06eeb70..0a790fa 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -71,6 +71,7 @@ "1/2 · AI 그림으로 다듬는 중": "1/2 · 正在用 AI 图片优化", "10초 뒤로 (←)": "后退 10 秒(←)", "10초 앞으로 (→)": "前进 10 秒(→)", + "16진 보기 · {0}": "十六进制查看 · {0}", "1배 (선명하게)": "1倍 (锐化)", "1분 안": "1 分钟以内", "2/2 · 해상도 올리는 중": "2/2 · 正在提高分辨率", @@ -439,6 +440,7 @@ "{0} (기본): {1}": "{0}(默认): {1}", "{0} (동영상 {1})": "{0}(视频 {1})", "{0} - {1} (약 {2}MB)": "{0} - {1}(约 {2}MB)", + "{0} / {1} 읽음": "已读取 {0} / {1}", "{0} / {1}장": "{0} / {1} 张", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(在主界面顶部更改)", "{0} · {1}배 · {2}": "{0} · {1} 倍 · {2}", @@ -596,6 +598,7 @@ "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}", "강하게 (0.4)": "强 (0.4)", "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "越强优化越多,但可能与原图不同", + "같습니다 ✔": "相同 ✔", "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} 个覆盖了同名文件的文件不会撤销。", "같은 이름이 이미 있습니다": "已存在同名项目", @@ -865,6 +868,7 @@ "다른 항목과 같은 이름": "与其他项同名", "다른 화면으로 가거나 앱이 뒤로 가면 Tor 화면의 페이지 · 연결은 바로 정리합니다. Tor 자체는 이 시간 동안 켜 두어 다시 들어오면 바로 쓸 수 있습니다.": "切换到其他页面或应用转到后台时,会立即清理 Tor 页面的网页和连接。Tor 本身会在这段时间内保持运行,返回后可立即使用。", "다른 화면으로 가면 페이지 소리 멈춤": "离开浏览器时暂停网页声音", + "다릅니다 - 파일이 바뀌었거나 다른 파일입니다": "不同 - 文件已更改或是另一个文件", "다시 만들기 대기로": "改回等待重新制作", "다시 만들기 대기로 (지금 묶음이 끝난 뒤)": "重新排队制作 (当前批次结束后)", "다시 받기": "重新下载", @@ -938,6 +942,7 @@ "대소문자 그대로": "保持大小写", "대소문자 무시": "忽略大小写", "더 보기": "更多", + "더 읽기": "继续读取", "더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "双击 · 默认打开方式选择 JJ_MKVMaker 时(右键菜单不变)", "더한 언어 (AI 자동 번역)": "已添加的语言(AI 自动翻译)", "덮어쓰기": "覆盖", @@ -1029,6 +1034,7 @@ "링크 {0}개는 넣지 않음": "{0} 个链接未包含", "링크 {0}개는 만들지 않고 건너뜁니다: {1}": "{0} 个链接将跳过 (不创建): {1}", "링크 {0}개는 세지 않음": "{0} 个链接未计入", + "링크 · junction {0}개 (따라가지 않고 세지 않음)": "{0} 个链接 / 联接 (不跟随、不计入)", "링크 · junction 은 넣지 않습니다": "不包含链接和 junction", "마스터 · 최상 비밀번호": "主密码和最高密码", "마스터 비밀번호": "主密码", @@ -1158,6 +1164,7 @@ "바뀐 날": "修改时间", "바뀐 날: 아무 때": "修改时间: 任意", "바뀐 내용": "更改内容", + "바뀐 때: {0}": "修改时间: {0}", "바뀐 지 … 일 안": "… 天内修改", "바뀐 지 … 일 지남": "超过 … 天未修改", "바로 끔": "立即关闭", @@ -1309,6 +1316,7 @@ "브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 \"앱 안 브라우저\" 를 고르거나, 고른 브라우저가 설치되어 있는지 확인하세요 (Chrome · Edge 는 브라우저를 닫아야 읽힙니다).": "未能读取浏览器 Cookie。请在 设置 > 下载 > YouTube Cookie 中选择“应用内浏览器”,或确认所选浏览器已安装(Chrome · Edge 需关闭浏览器才能读取)。", "비공개 · 회원 전용 영상입니다.": "这是私享 · 会员专享视频。", "비교하지 못했습니다: {0}": "无法比较: {0}", + "비교할 값 붙여넣기 (받은 곳의 해시)": "粘贴要比较的值 (来源处的哈希)", "비밀번호": "密码", "비밀번호 (선택)": "密码(可选)", "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "密码 · API 密钥 (WebDAV · OpenSubtitles · AI 服务)", @@ -1625,6 +1633,7 @@ "열 수 없습니다: {0}": "无法打开: {0}", "열기": "打开", "열기 · 재생": "打开 · 播放", + "열어 본 때: {0}": "访问时间: {0}", "영구 삭제": "永久删除", "영구 삭제 ({0}개, 되돌릴 수 없음)": "永久删除 ({0} 个,无法撤销)", "영구 삭제하면 되살릴 수 없습니다.": "永久删除后无法还原。", @@ -1939,6 +1948,7 @@ "읽지 못한 원래 파일은 지우지 않고 아래에 보관했습니다:": "无法读取的原文件没有删除,保存在这里:", "읽지 못한 폴더 {0}개 (권한 · 연결 문제) - 그 안은 찾지 못했습니다": "{0} 个无法读取的文件夹 (权限 · 连接问题) - 未在其中查找", "읽지 못한 폴더 {0}개 (크기가 실제보다 작을 수 있음)": "{0} 个文件夹无法读取 (大小可能小于实际)", + "읽지 못했습니다: {0}": "无法读取: {0}", "임시 파일 · 남은 조각 정리": "清理临时文件 · 残留碎片", "자동": "自动", "자동 (가로로 긴 그림만)": "自动 (仅横向图片)", @@ -2312,6 +2322,7 @@ "크기 · 바뀐 날 · 종류를 넣으면 파일만 찾습니다.": "设置大小、修改时间或类型时,只查找文件。", "크기 · 보정을 원래대로 돌려 코덱도 \"원본 유지\" 로 되돌렸습니다.": "大小 · 调整已恢复原样,编解码器也恢复为“保持原样”。", "크기 이상": "最小大小", + "크기: {0} ({1} 바이트)": "大小: {0} ({1} 字节)", "크기는 그대로 두고 선명하게 해 새 파일로 저장합니다. 원본 · ZIP 은 그대로입니다. 화면을 닫아도 계속하며 작업 알림에 남은 장과 예상 시간이 보입니다.": "保持尺寸只做锐化并保存为新文件。原文件和 ZIP 保持不变。关闭画面后仍会继续,任务通知中会显示剩余张数和预计时间。", "크기를 바꾸면 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "更改大小需要重新编码,因此编解码器已从“保持原样”改为 H.264。", "크기순 정렬일 때만 (기본)": "仅在按大小排序时 (默认)", @@ -2343,6 +2354,7 @@ "파일 {0}/{1}": "文件 {0}/{1}", "파일 {0}개": "{0} 个文件", "파일 {0}개 · {1}": "{0} 个文件 · {1}", + "파일 {0}개 · 폴더 {1}개": "{0} 个文件 · {1} 个文件夹", "파일 관리자에서 보기": "在文件管理器中显示", "파일 복사 · 이동 방법": "文件复制 · 移动方式", "파일 세는 중…": "正在统计文件…", @@ -2401,6 +2413,7 @@ "폴더 복사 · 이동 방법": "文件夹复制 · 移动方式", "폴더 삭제: {0} (안의 {1}개 포함)": "已删除文件夹: {0}(含其中 {1} 个)", "폴더 선택": "选择文件夹", + "폴더 속성 · {0}": "文件夹属性 · {0}", "폴더 수정": "编辑文件夹", "폴더 안 파일 크기의 합을 목록에 보입니다. 큰 폴더 · NAS 는 읽는 데 오래 걸릴 수 있습니다. 길게 누르기 [폴더 크기 계산] 은 늘 됩니다": "在列表中显示文件夹内文件大小的总和。大型文件夹或 NAS 可能需要较长时间读取。长按 [计算文件夹大小] 始终可用", "폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).": "同步文件夹内的内容 (不会在目标中新建同名文件夹)。", @@ -2495,6 +2508,7 @@ "해상도 올리기 모델을 받아야 합니다. 어느 것을 받을까요?": "需要放大模型。要下载哪一个?", "해상도를 올리지 못했습니다": "无法提高分辨率", "해상도를 올린 그림 저장 폴더": "提高分辨率后图片的保存文件夹", + "해시 · {0}": "哈希 · {0}", "해제": "解除", "허용": "允许", "허용했으면 다시 확인": "允许后重新检查", diff --git a/lib/core/lan_scan.dart b/lib/core/lan_scan.dart new file mode 100644 index 0000000..ae26ba3 --- /dev/null +++ b/lib/core/lan_scan.dart @@ -0,0 +1,78 @@ +import 'dart:async'; +import 'dart:io'; + +/// X-plore 비교 j: 같은 망에서 SMB (445) 서버 찾기 - 이 기기의 IPv4 망 (/24) 을 짧은 연결 시도로 훑는다. +/// 연결만 해 보고 바로 닫는다 (아무것도 보내지 않음). 찾은 것은 이름 (역방향 조회, 모르면 null) 과 함께. +/// SMB 서버 추가 창 (88 Android SMB) 의 [찾기] 에서 쓴다. +class LanHost { + final String address; + final String? name; + const LanHost(this.address, this.name); +} + +/// 이 기기의 사설 IPv4 주소들 (Wi-Fi · 유선 · Tailscale 은 빼고 - 100.64/10 은 넓어 훑지 않는다) +Future> localPrivateIPv4() async { + final out = []; + for (final ni in await NetworkInterface.list(type: InternetAddressType.IPv4)) { + for (final a in ni.addresses) { + final b = a.rawAddress; + final private = b[0] == 10 || (b[0] == 172 && b[1] >= 16 && b[1] <= 31) || (b[0] == 192 && b[1] == 168); + if (private && !a.isLoopback) out.add(a); + } + } + return out; +} + +/// [hosts] 의 [port] 가 열려 있는지 동시에 [parallel] 개씩 본다. 열린 것만 (주소 순서대로) +Future> scanPort( + List hosts, { + int port = 445, + Duration timeout = const Duration(milliseconds: 400), + int parallel = 64, + bool Function()? cancelled, + void Function(int done, int total)? onProgress, + bool lookupNames = true, +}) async { + final open = {}; + var next = 0, done = 0; + Future worker() async { + while (next < hosts.length) { + if (cancelled?.call() ?? false) return; + final h = hosts[next++]; + try { + final s = await Socket.connect(h, port, timeout: timeout); + open.add(h); + s.destroy(); + } catch (_) {} + onProgress?.call(++done, hosts.length); + } + } + + await Future.wait([for (var i = 0; i < parallel; i++) worker()]); + final sorted = hosts.where(open.contains).toList(); + final out = []; + for (final h in sorted) { + String? name; + if (lookupNames) { + try { + final r = await InternetAddress(h).reverse().timeout(const Duration(seconds: 1)); + if (r.host != h) name = r.host; + } catch (_) {} + } + out.add(LanHost(h, name)); + } + return out; +} + +/// 이 기기의 사설망 (/24 마다) 의 SMB 서버들 +Future> findSmbServers({bool Function()? cancelled, void Function(int done, int total)? onProgress}) async { + final hosts = []; + for (final a in await localPrivateIPv4()) { + final b = a.rawAddress; + for (var i = 1; i < 255; i++) { + final h = '${b[0]}.${b[1]}.${b[2]}.$i'; + if (!hosts.contains(h)) hosts.add(h); + } + } + return scanPort(hosts, cancelled: cancelled, onProgress: onProgress); +} diff --git a/lib/ui/file_tools.dart b/lib/ui/file_tools.dart new file mode 100644 index 0000000..ff546fa --- /dev/null +++ b/lib/ui/file_tools.dart @@ -0,0 +1,271 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; + +import '../core/file_ops.dart' show formatSize; +import '../l10n/tr.dart'; +import 'theme.dart'; + +/// X-plore 비교 P3: 해시 · 16진 보기 · 폴더 속성 (읽기만 - 파일을 바꾸지 않는다) + +/// 파일의 MD5 · SHA-256 (조각으로 읽어 큰 파일도 메모리를 쓰지 않음). [onProgress] 0~1, [cancelled] 면 null +Future<({String md5, String sha256})?> fileHashes(String path, {void Function(double)? onProgress, bool Function()? cancelled}) async { + final f = File(path); + final total = await f.length(); + final m = _Sink(), s = _Sink(); + final mIn = md5.startChunkedConversion(m), sIn = sha256.startChunkedConversion(s); + var done = 0; + await for (final chunk in f.openRead()) { + if (cancelled?.call() ?? false) return null; + mIn.add(chunk); + sIn.add(chunk); + done += chunk.length; + onProgress?.call(total == 0 ? 1 : done / total); + } + mIn.close(); + sIn.close(); + return (md5: '${m.value}', sha256: '${s.value}'); +} + +class _Sink implements Sink { + Digest? value; + @override + void add(Digest data) => value = data; + @override + void close() {} +} + +/// 해시 창 - 계산 (진행 · 취소) 뒤 MD5 · SHA-256, 붙여 넣은 값과 비교 +Future showFileHash(BuildContext context, String path) async { + var progress = 0.0; + var cancelled = false; + ({String md5, String sha256})? result; + Object? error; + final compare = TextEditingController(); + await showDialog( + context: context, + builder: (ctx) => StatefulBuilder(builder: (ctx, set) { + if (result == null && error == null && progress == 0) { + progress = 0.0001; + fileHashes(path, onProgress: (v) { + if (ctx.mounted) set(() => progress = v); + }, cancelled: () => cancelled) + .then((r) { + if (ctx.mounted) set(() => result = r); + }, onError: (Object e) { + if (ctx.mounted) set(() => error = e); + }); + } + final want = compare.text.trim().toLowerCase(); + final match = result != null && want.isNotEmpty ? (want == result!.md5 || want == result!.sha256) : null; + Widget line(String name, String v) => Padding( + padding: const EdgeInsets.only(top: 6), + child: Row(children: [ + SizedBox(width: 72, child: Text(name, style: const TextStyle(fontWeight: FontWeight.w600))), + Expanded(child: SelectableText(v, style: const TextStyle(fontFamily: 'monospace', fontSize: 12))), + IconButton( + tooltip: tr('복사'), + icon: const Icon(Icons.copy, size: 16), + onPressed: () => Clipboard.setData(ClipboardData(text: v)), + ), + ]), + ); + return AlertDialog( + scrollable: true, + title: Text(trf('해시 · {0}', [p.basename(path)])), + content: SizedBox( + width: 560, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (error != null) Text(trf('읽지 못했습니다: {0}', [error]), style: const TextStyle(color: JjColors.danger)), + if (result == null && error == null) LinearProgressIndicator(value: progress), + if (result != null) ...[ + line('MD5', result!.md5), + line('SHA-256', result!.sha256), + const SizedBox(height: 10), + TextField( + controller: compare, + decoration: InputDecoration(labelText: tr('비교할 값 붙여넣기 (받은 곳의 해시)'), isDense: true), + onChanged: (_) => set(() {}), + ), + if (match != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text(match ? tr('같습니다 ✔') : tr('다릅니다 - 파일이 바뀌었거나 다른 파일입니다'), + style: TextStyle(color: match ? Colors.greenAccent : JjColors.danger)), + ), + ], + ]), + ), + actions: [ + TextButton( + onPressed: () { + cancelled = true; + Navigator.pop(ctx); + }, + child: Text(result == null && error == null ? tr('취소') : tr('닫기')), + ), + ], + ); + }), + ); + compare.dispose(); +} + +/// 16진 보기: 한 줄 16 바이트 (위치 · 16진 · 글자). 큰 파일은 [더 읽기] +class HexViewerPage extends StatefulWidget { + final String path; + final int chunk; + const HexViewerPage({super.key, required this.path, this.chunk = 64 * 1024}); + + /// 한 줄 (16 바이트): "00000010 41 42 ... AB.." + static String line(int offset, List bytes) { + final hex = StringBuffer(); + for (var i = 0; i < 16; i++) { + hex.write(i < bytes.length ? bytes[i].toRadixString(16).padLeft(2, '0') : ' '); + hex.write(i == 7 ? ' ' : ' '); + } + final text = String.fromCharCodes([for (final b in bytes) b >= 0x20 && b < 0x7f ? b : 0x2e]); + return '${offset.toRadixString(16).padLeft(8, '0')} $hex $text'; + } + + @override + State createState() => _HexViewerPageState(); +} + +class _HexViewerPageState extends State { + final _data = []; + int _size = 0; + Object? _error; + + @override + void initState() { + super.initState(); + _more(); + } + + Future _more() async { + try { + final f = File(widget.path); + _size = await f.length(); + final to = (_data.length + widget.chunk).clamp(0, _size); + await for (final x in f.openRead(_data.length, to)) { + _data.addAll(x); + } + } catch (e) { + _error = e; + } + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final rows = (_data.length + 15) ~/ 16; + return Scaffold( + appBar: AppBar(title: Text(trf('16진 보기 · {0}', [p.basename(widget.path)]), maxLines: 1, overflow: TextOverflow.ellipsis)), + body: _error != null + ? Center(child: Text(trf('읽지 못했습니다: {0}', [_error]))) + : Column(children: [ + Expanded( + child: Scrollbar( + child: ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: rows, + itemExtent: 18, + itemBuilder: (_, i) => Text( + HexViewerPage.line(i * 16, _data.sublist(i * 16, (i * 16 + 16).clamp(0, _data.length))), + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + maxLines: 1, + softWrap: false, + ), + ), + ), + ), + if (_data.length < _size) + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(6), + child: Row(children: [ + Expanded( + child: Text(trf('{0} / {1} 읽음', [formatSize(_data.length), formatSize(_size)]), + style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + ), + FilledButton.tonal(onPressed: _more, child: Text(tr('더 읽기'))), + ]), + ), + ), + ]), + ); + } +} + +/// 폴더 속성: 안의 파일 · 폴더 수, 크기, 만든 · 바뀐 때 (링크 · junction 은 따라가지 않음) +Future<({int files, int folders, int bytes, int links})> folderStats(String dir, {bool Function()? cancelled}) async { + var files = 0, folders = 0, bytes = 0, links = 0; + await for (final e in Directory(dir).list(recursive: true, followLinks: false).handleError((_) {})) { + if (cancelled?.call() ?? false) break; + if (e is Link) { + links++; + } else if (e is Directory) { + folders++; + } else if (e is File) { + files++; + try { + bytes += await e.length(); + } catch (_) {} + } + } + return (files: files, folders: folders, bytes: bytes, links: links); +} + +Future showFolderProperties(BuildContext context, String dir) async { + var cancelled = false; + final stat = await FileStat.stat(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')}'; + final future = folderStats(dir, cancelled: () => cancelled); + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(trf('폴더 속성 · {0}', [p.basename(dir)])), + content: SizedBox( + width: 480, + child: FutureBuilder( + future: future, + builder: (_, s) { + final r = s.data; + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + SelectableText(dir, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + const SizedBox(height: 8), + if (r == null) const LinearProgressIndicator(), + if (r != null) ...[ + Text(trf('크기: {0} ({1} 바이트)', [formatSize(r.bytes), r.bytes])), + Text(trf('파일 {0}개 · 폴더 {1}개', [r.files, r.folders])), + if (r.links > 0) Text(trf('링크 · junction {0}개 (따라가지 않고 세지 않음)', [r.links])), + ], + const SizedBox(height: 6), + Text(trf('바뀐 때: {0}', [date(stat.modified)])), + Text(trf('열어 본 때: {0}', [date(stat.accessed)])), + ]); + }, + ), + ), + actions: [ + TextButton( + onPressed: () { + cancelled = true; + Navigator.pop(ctx); + }, + child: Text(tr('닫기')), + ), + ], + ), + ); + cancelled = true; +} diff --git a/test/file_tools_test.dart b/test/file_tools_test.dart new file mode 100644 index 0000000..2c70bec --- /dev/null +++ b/test/file_tools_test.dart @@ -0,0 +1,30 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/ui/file_tools.dart'; +import 'package:path/path.dart' as p; + +/// X-plore 비교 P3: 해시 · 16진 보기 · 폴더 속성 +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_ftools_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('P3 해시: 알려진 값 (MD5 · SHA-256 "abc") · 취소면 null', () async { + final f = File(p.join(tmp.path, 'abc.txt'))..writeAsStringSync('abc'); + final h = await fileHashes(f.path); + expect(h!.md5, '900150983cd24fb0d6963f7d28e17f72'); + expect(h.sha256, 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); + expect(await fileHashes(f.path, cancelled: () => true), isNull); + }); + + test('P3 16진 보기 한 줄 · 폴더 속성 (파일 · 폴더 수 · 크기)', () async { + expect(HexViewerPage.line(16, 'AB\n'.codeUnits), startsWith('00000010 41 42 0a')); + expect(HexViewerPage.line(16, 'AB\n'.codeUnits), endsWith(' AB.')); + Directory(p.join(tmp.path, 'd', 'e')).createSync(recursive: true); + File(p.join(tmp.path, 'd', 'a.bin')).writeAsBytesSync(List.filled(10, 1)); + File(p.join(tmp.path, 'd', 'e', 'b.bin')).writeAsBytesSync(List.filled(5, 1)); + final s = await folderStats(p.join(tmp.path, 'd')); + expect((s.files, s.folders, s.bytes), (2, 1, 15)); + }); +} diff --git a/test/lan_scan_test.dart b/test/lan_scan_test.dart new file mode 100644 index 0000000..039fede --- /dev/null +++ b/test/lan_scan_test.dart @@ -0,0 +1,23 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/lan_scan.dart'; + +/// X-plore 비교 j: 같은 망 SMB 서버 찾기의 포트 훑기 (실제 망 대신 이 기기의 열린 · 닫힌 포트로) +void main() { + test('열린 포트만 찾는다 · 순서는 넣은 대로 · 진행 · 취소', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(server.close); + server.listen((s) => s.destroy()); + final closed = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final closedPort = closed.port; + await closed.close(); + var progress = 0; + final found = await scanPort(['127.0.0.1', '127.0.0.2'], port: server.port, lookupNames: false, onProgress: (d, _) => progress = d); + expect(found.map((h) => h.address), ['127.0.0.1'], reason: '127.0.0.2 는 듣지 않음 (Windows 는 127.0.0.1 에만 묶임)'); + expect(progress, 2); + expect(await scanPort(['127.0.0.1'], port: closedPort, lookupNames: false), isEmpty); + expect(await scanPort(['127.0.0.1'], port: server.port, lookupNames: false, cancelled: () => true), isEmpty, reason: '취소'); + expect((await localPrivateIPv4()).every((a) => !a.isLoopback), isTrue); + }); +}