WebDAV: 파일 탐색기 · Rsync 화면 · lsync 에서 연결 · 복사 · 동기화
- 환경 설정 > 파일 탐색기 > WebDAV: 서버 추가 · 고치기 · 지우기 · 연결 확인 (기본 인증, 인증서 확인 안 함) - 위쪽 "SD 카드" 옆 서버 탭 (없으면 "WebDAV" 탭 → 바로 추가), 경로 dav://서버/경로 - 목록 · 새 폴더 · 이름 변경 · 삭제 · 찾기 · 복사 · 이동 (같은 서버는 MOVE), 열기 · 재생은 임시로 받아서 - Rsync · lsync: 한쪽이 WebDAV 면 앱이 크기 · 시각 비교로 맞춤 (-u · --remove-source-files 따름) - PROPFIND 에 Content-Length (chunked 면 일부 서버가 남은 본문을 다음 요청으로 오해) - Android: http:// NAS 연결 허용 (usesCleartextTraffic) - 화면에는 내부 id 대신 "☁ 서버 이름/경로" - 시험: webdav_test (시험 서버) · explorer_webdav_test · real_dav_test (JJ_REAL_DAV 일 때 실제 서버) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>묶음3
parent
0cf7a9a7b7
commit
8f779fa7e6
|
|
@ -19,7 +19,8 @@
|
|||
android:label="JJ_MKVMaker"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:usesCleartextTraffic="true">
|
||||
<!-- Android 16: 큰 화면 (태블릿) 에서도 환경 설정 > 화면 방향 (가로 고정 · 세로 고정) 을 따르게 -->
|
||||
<property
|
||||
android:name="android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY"
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@
|
|||
"MKV 화면으로": "To the MKV screen",
|
||||
"MKV 화면으로 (전체 화면)": "To the MKV screen (full screen)",
|
||||
"MP4 (동영상)": "MP4 (video)",
|
||||
"NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 설정 파일에만 저장합니다.": "Open, copy and sync WebDAV folders on a NAS or cloud in the file explorer and the Rsync screen (tab next to \"SD card\" at the top). The password is stored only in this device's settings file.",
|
||||
"NLLB 언어 코드가 아닙니다: {0}": "Not an NLLB language code: {0}",
|
||||
"NLLB-200 번역 (600M)": "NLLB-200 translation (600M)",
|
||||
"OpenSubtitles API 키 또는 로그인 정보가 올바르지 않습니다. ({0})": "OpenSubtitles API key or sign-in details are incorrect. ({0})",
|
||||
|
|
@ -125,6 +126,11 @@
|
|||
"SRT 형식이 아니거나 비어 있습니다.": "Not in SRT format or empty.",
|
||||
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC is not installed. Once installed it appears in the list (videolan.org).",
|
||||
"VLC 로 열기": "Open with VLC",
|
||||
"WebDAV 서버 고치기": "Edit WebDAV server",
|
||||
"WebDAV 서버 추가": "Add WebDAV server",
|
||||
"WebDAV 에서 받는 중": "Downloading from WebDAV",
|
||||
"WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "Videos on WebDAV can't be added to the MKV list. Copy them to this device first.",
|
||||
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: the app syncs by itself instead of rsync (compares size and modified time; of the options only -u and --remove-source-files apply).",
|
||||
"WebM (동영상)": "WebM (video)",
|
||||
"Whale 에서 가져오기": "Import from Whale",
|
||||
"Whale 에서 가져옴": "Imported from Whale",
|
||||
|
|
@ -263,6 +269,7 @@
|
|||
"고른 것 시작": "Start selected",
|
||||
"고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "Best quality at or below the chosen size (nearest if the video lacks that size)",
|
||||
"고지 문서": "Notices",
|
||||
"고치기": "Edit",
|
||||
"고화질": "High quality",
|
||||
"골라서 시작": "Choose and start",
|
||||
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "Cannot roll back: shared storage (Download) is not available.",
|
||||
|
|
@ -309,6 +316,7 @@
|
|||
"나중에": "Later",
|
||||
"날짜": "Date",
|
||||
"남은 시간 {0}": "{0} left",
|
||||
"남은 용량": "free",
|
||||
"내려받기": "Download",
|
||||
"내려받기 (Windows 만)": "Download (Windows only)",
|
||||
"내려받기 실패 ({0})": "Download failed ({0})",
|
||||
|
|
@ -509,6 +517,7 @@
|
|||
"받은 파일에 libcef.dll 이 없습니다.": "The downloaded file has no libcef.dll.",
|
||||
"받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "The downloaded file is corrupt or a different file (SHA256 mismatch). Installation was stopped.",
|
||||
"받음 {0}": "Received {0}",
|
||||
"받지 못했습니다: {0}": "Could not download: {0}",
|
||||
"밝기": "Brightness",
|
||||
"밤 0~6시": "Night 0-6",
|
||||
"방법: {0}": "Method: {0}",
|
||||
|
|
@ -558,6 +567,7 @@
|
|||
"브라우저 엔진 (앱 안)": "Browser engine (in-app)",
|
||||
"브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > 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.",
|
||||
"비밀번호": "Password",
|
||||
"비밀번호 (선택)": "Password (optional)",
|
||||
"비어 있는 폴더": "Empty folder",
|
||||
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.",
|
||||
|
|
@ -675,6 +685,7 @@
|
|||
"아래 프로그램이 없어 일부 기능을 쓸 수 없습니다. 공식 배포처에서 내려받아 프로그램 폴더에 설치할까요?": "Some features are unavailable because the programs below are missing. Download them from the official sources and install them in the program folder?",
|
||||
"아래로": "Down",
|
||||
"아래에 줄 추가": "Add line below",
|
||||
"아이디": "User name",
|
||||
"아이디 (선택)": "Username (optional)",
|
||||
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "You can enter the username · password in Settings (optional, raises the daily download limit).",
|
||||
"아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "Icons · list look: X-plore (thumbnails · two lines) · Windows Explorer (color icons · columns) · Total Commander (dense list · [folders])",
|
||||
|
|
@ -713,6 +724,9 @@
|
|||
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "Drop here to add to the Make MKV video list",
|
||||
"여러 개를 골랐을 때": "When several are selected",
|
||||
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "How many to process at once when making several videos into MKV (re-encoding uses a lot of CPU)",
|
||||
"연결 실패: {0}": "Connection failed: {0}",
|
||||
"연결 확인": "Test connection",
|
||||
"연결됨: 항목 {0}개{1}": "Connected: {0} items{1}",
|
||||
"연도": "Year",
|
||||
"열기": "Open",
|
||||
"영상 {0}": "Video {0}",
|
||||
|
|
@ -728,6 +742,7 @@
|
|||
"예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임": "e.g. /E /COPY:DAT /DCOPY:T /R:2 /W:2. Move adds /MOVE automatically",
|
||||
"예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위": "e.g. 0 9-17 * * 1-5 (weekdays 9:00-17:59) · 0 0-5 * * * (daily 0-5). Minutes are ignored, 1-hour units",
|
||||
"예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "e.g. Ctrl+Shift+X (Ctrl·Shift·Alt·Win + letter·digit·F1~F12)",
|
||||
"예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/아이디": "e.g. https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/username",
|
||||
"예전 구조의 프로그램 파일 정리 ({0}개): {1}": "Cleaned up program files of the old layout ({0}): {1}",
|
||||
"예전 버전": "an earlier version",
|
||||
"예전 버전 v{0} 으로 되돌리기": "Roll back to earlier version v{0}",
|
||||
|
|
@ -847,6 +862,7 @@
|
|||
"이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "No move folder is set (Settings > Save locations)",
|
||||
"이동할 폴더를 만들 수 없습니다: {0} ({1})": "Cannot create the move folder: {0} ({1})",
|
||||
"이름": "Name",
|
||||
"이름 (탭에 보임, 비우면 주소)": "Name (shown on the tab; address if empty)",
|
||||
"이름 바꾸기 · 이동": "Rename · move",
|
||||
"이름 변경": "Rename",
|
||||
"이름을 바꿀 수 없습니다: {0}": "Cannot rename: {0}",
|
||||
|
|
@ -864,6 +880,7 @@
|
|||
"이탈리아어": "Italian",
|
||||
"인도네시아어": "Indonesian",
|
||||
"인식된 말이 없습니다.": "No speech was recognized.",
|
||||
"인증서 확인 안 함": "Don't verify certificate",
|
||||
"인코딩": "Encoding",
|
||||
"인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "Check your internet connection, then try again in Settings > Check required programs.\n\n{0}",
|
||||
"인터넷 자막 (OpenSubtitles.com)": "Internet subtitles (OpenSubtitles.com)",
|
||||
|
|
@ -910,6 +927,7 @@
|
|||
"자막을 불러올 수 없습니다: {0}": "Cannot load subtitles: {0}",
|
||||
"자막이 비어 있습니다.": "The subtitles are empty.",
|
||||
"자막이 없습니다.": "No subtitles.",
|
||||
"자체 서명 인증서를 쓰는 집 NAS 등 (믿을 수 있는 서버에서만)": "For a home NAS with a self-signed certificate, etc. (trusted servers only)",
|
||||
"작업": "Jobs",
|
||||
"작업 기록 보기": "Show job log",
|
||||
"작업 기록 숨기기": "Hide job log",
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@
|
|||
"MKV 화면으로": "MKV 画面へ",
|
||||
"MKV 화면으로 (전체 화면)": "MKV 画面へ(全画面)",
|
||||
"MP4 (동영상)": "MP4(動画)",
|
||||
"NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 설정 파일에만 저장합니다.": "NAS・クラウドの WebDAV フォルダーをファイルエクスプローラー・Rsync 画面で開き、コピー・同期します (上部の「SD カード」の隣のタブ)。パスワードはこの端末の設定ファイルにのみ保存されます。",
|
||||
"NLLB 언어 코드가 아닙니다: {0}": "NLLB の言語コードではありません: {0}",
|
||||
"NLLB-200 번역 (600M)": "NLLB-200 翻訳(600M)",
|
||||
"OpenSubtitles API 키 또는 로그인 정보가 올바르지 않습니다. ({0})": "OpenSubtitles の API キーまたはログイン情報が正しくありません。({0})",
|
||||
|
|
@ -125,6 +126,11 @@
|
|||
"SRT 형식이 아니거나 비어 있습니다.": "SRT 形式ではないか、空です。",
|
||||
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC がインストールされていません。インストールするとリストに表示されます(videolan.org)。",
|
||||
"VLC 로 열기": "VLC で開く",
|
||||
"WebDAV 서버 고치기": "WebDAV サーバーを編集",
|
||||
"WebDAV 서버 추가": "WebDAV サーバーを追加",
|
||||
"WebDAV 에서 받는 중": "WebDAV からダウンロード中",
|
||||
"WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "WebDAV の動画は MKV リストに追加できません。先にこの端末へコピーしてください。",
|
||||
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションは -u・--remove-source-files のみ反映)。",
|
||||
"WebM (동영상)": "WebM(動画)",
|
||||
"Whale 에서 가져오기": "Whale から取り込む",
|
||||
"Whale 에서 가져옴": "Whale から取り込み",
|
||||
|
|
@ -263,6 +269,7 @@
|
|||
"고른 것 시작": "選んだものを開始",
|
||||
"고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "選んだサイズ以下で最も良い画質(動画にないサイズなら近いもの)",
|
||||
"고지 문서": "告知文書",
|
||||
"고치기": "編集",
|
||||
"고화질": "高画質",
|
||||
"골라서 시작": "選んで開始",
|
||||
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "共有ストレージ (Download) を使えないため戻せません。",
|
||||
|
|
@ -309,6 +316,7 @@
|
|||
"나중에": "後で",
|
||||
"날짜": "日付",
|
||||
"남은 시간 {0}": "残り時間 {0}",
|
||||
"남은 용량": "空き容量",
|
||||
"내려받기": "ダウンロード",
|
||||
"내려받기 (Windows 만)": "ダウンロード (Windows のみ)",
|
||||
"내려받기 실패 ({0})": "ダウンロード失敗({0})",
|
||||
|
|
@ -509,6 +517,7 @@
|
|||
"받은 파일에 libcef.dll 이 없습니다.": "ダウンロードしたファイルに libcef.dll がありません。",
|
||||
"받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "ダウンロードしたファイルが壊れているか別のファイルです(SHA256 不一致)。インストールを中止しました。",
|
||||
"받음 {0}": "受信 {0}",
|
||||
"받지 못했습니다: {0}": "ダウンロードできませんでした: {0}",
|
||||
"밝기": "明るさ",
|
||||
"밤 0~6시": "夜 0~6 時",
|
||||
"방법: {0}": "方法: {0}",
|
||||
|
|
@ -558,6 +567,7 @@
|
|||
"브라우저 엔진 (앱 안)": "ブラウザーエンジン(アプリ内)",
|
||||
"브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 \"앱 안 브라우저\" 를 고르거나, 고른 브라우저가 설치되어 있는지 확인하세요 (Chrome · Edge 는 브라우저를 닫아야 읽힙니다).": "ブラウザーのクッキーを読めませんでした。環境設定 > ダウンロード > YouTube クッキー で「アプリ内ブラウザー」を選ぶか、選んだブラウザーがインストールされているか確認してください(Chrome・Edge はブラウザーを閉じないと読めません)。",
|
||||
"비공개 · 회원 전용 영상입니다.": "非公開・メンバー限定の動画です。",
|
||||
"비밀번호": "パスワード",
|
||||
"비밀번호 (선택)": "パスワード(任意)",
|
||||
"비어 있는 폴더": "空のフォルダー",
|
||||
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。",
|
||||
|
|
@ -675,6 +685,7 @@
|
|||
"아래 프로그램이 없어 일부 기능을 쓸 수 없습니다. 공식 배포처에서 내려받아 프로그램 폴더에 설치할까요?": "以下のプログラムがないため一部の機能が使えません。公式配布元からダウンロードしてプログラムフォルダーにインストールしますか?",
|
||||
"아래로": "下へ",
|
||||
"아래에 줄 추가": "下に行を追加",
|
||||
"아이디": "ユーザー名",
|
||||
"아이디 (선택)": "ユーザー名(任意)",
|
||||
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "ユーザー名・パスワードは環境設定で入力できます(任意、1日のダウンロード回数が増えます)。",
|
||||
"아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "アイコン · 一覧の見た目: X-plore (サムネイル · 2 行) · Windows エクスプローラー (カラーアイコン · 列) · Total Commander (詰めた一覧 · [フォルダー])",
|
||||
|
|
@ -713,6 +724,9 @@
|
|||
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "ここにドロップすると MKV 作成の動画リストに追加します",
|
||||
"여러 개를 골랐을 때": "複数選んだとき",
|
||||
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)",
|
||||
"연결 실패: {0}": "接続失敗: {0}",
|
||||
"연결 확인": "接続テスト",
|
||||
"연결됨: 항목 {0}개{1}": "接続しました: 項目 {0} 個{1}",
|
||||
"연도": "年",
|
||||
"열기": "開く",
|
||||
"영상 {0}": "映像 {0}",
|
||||
|
|
@ -728,6 +742,7 @@
|
|||
"예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임": "例: /E /COPY:DAT /DCOPY:T /R:2 /W:2。移動では /MOVE を自動で付加",
|
||||
"예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위": "例: 0 9-17 * * 1-5 (平日 9 時~17 時 59 分) · 0 0-5 * * * (毎日 0~5 時)。分は無視し 1 時間単位",
|
||||
"예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl・Shift・Alt・Win + 英字・数字・F1〜F12)",
|
||||
"예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/아이디": "例: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/ユーザー名",
|
||||
"예전 구조의 프로그램 파일 정리 ({0}개): {1}": "旧構成のプログラムファイルを整理({0}個): {1}",
|
||||
"예전 버전": "以前のバージョン",
|
||||
"예전 버전 v{0} 으로 되돌리기": "以前のバージョン v{0} に戻す",
|
||||
|
|
@ -847,6 +862,7 @@
|
|||
"이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "移動フォルダーが決まっていません(環境設定 > 保存先)",
|
||||
"이동할 폴더를 만들 수 없습니다: {0} ({1})": "移動フォルダーを作成できません: {0}({1})",
|
||||
"이름": "名前",
|
||||
"이름 (탭에 보임, 비우면 주소)": "名前 (タブに表示、空ならアドレス)",
|
||||
"이름 바꾸기 · 이동": "名前の変更・移動",
|
||||
"이름 변경": "名前の変更",
|
||||
"이름을 바꿀 수 없습니다: {0}": "名前を変更できません: {0}",
|
||||
|
|
@ -864,6 +880,7 @@
|
|||
"이탈리아어": "イタリア語",
|
||||
"인도네시아어": "インドネシア語",
|
||||
"인식된 말이 없습니다.": "認識された音声がありません。",
|
||||
"인증서 확인 안 함": "証明書を検証しない",
|
||||
"인코딩": "エンコード",
|
||||
"인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "インターネット接続を確認してから、環境設定 > 必須プログラムの点検 でもう一度試してください。\n\n{0}",
|
||||
"인터넷 자막 (OpenSubtitles.com)": "インターネット字幕(OpenSubtitles.com)",
|
||||
|
|
@ -910,6 +927,7 @@
|
|||
"자막을 불러올 수 없습니다: {0}": "字幕を読み込めません: {0}",
|
||||
"자막이 비어 있습니다.": "字幕が空です。",
|
||||
"자막이 없습니다.": "字幕がありません。",
|
||||
"자체 서명 인증서를 쓰는 집 NAS 등 (믿을 수 있는 서버에서만)": "自己署名証明書を使う自宅 NAS など (信頼できるサーバーのみ)",
|
||||
"작업": "作業",
|
||||
"작업 기록 보기": "作業記録を表示",
|
||||
"작업 기록 숨기기": "作業記録を隠す",
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@
|
|||
"MKV 화면으로": "转到 MKV 界面",
|
||||
"MKV 화면으로 (전체 화면)": "转到 MKV 界面(全屏)",
|
||||
"MP4 (동영상)": "MP4(视频)",
|
||||
"NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 설정 파일에만 저장합니다.": "在文件浏览器和 Rsync 界面中打开、复制、同步 NAS·云端的 WebDAV 文件夹 (顶部“SD 卡”旁的标签)。密码仅保存在本设备的设置文件中。",
|
||||
"NLLB 언어 코드가 아닙니다: {0}": "不是 NLLB 语言代码: {0}",
|
||||
"NLLB-200 번역 (600M)": "NLLB-200 翻译(600M)",
|
||||
"OpenSubtitles API 키 또는 로그인 정보가 올바르지 않습니다. ({0})": "OpenSubtitles API 密钥或登录信息不正确。({0})",
|
||||
|
|
@ -125,6 +126,11 @@
|
|||
"SRT 형식이 아니거나 비어 있습니다.": "不是 SRT 格式或为空。",
|
||||
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "未安装 VLC。安装后会出现在列表中(videolan.org)。",
|
||||
"VLC 로 열기": "用 VLC 打开",
|
||||
"WebDAV 서버 고치기": "编辑 WebDAV 服务器",
|
||||
"WebDAV 서버 추가": "添加 WebDAV 服务器",
|
||||
"WebDAV 에서 받는 중": "正在从 WebDAV 下载",
|
||||
"WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "无法将 WebDAV 上的视频加入 MKV 列表。请先复制到本设备。",
|
||||
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: 由应用代替 rsync 直接同步 (比较大小和修改时间,选项中仅 -u・--remove-source-files 生效)。",
|
||||
"WebM (동영상)": "WebM(视频)",
|
||||
"Whale 에서 가져오기": "从 Whale 导入",
|
||||
"Whale 에서 가져옴": "从 Whale 导入",
|
||||
|
|
@ -263,6 +269,7 @@
|
|||
"고른 것 시작": "开始所选",
|
||||
"고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "不超过所选尺寸的最佳画质(视频没有该尺寸时取最接近的)",
|
||||
"고지 문서": "声明文件",
|
||||
"고치기": "编辑",
|
||||
"고화질": "高画质",
|
||||
"골라서 시작": "选择后开始",
|
||||
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "无法使用公共存储 (Download),因此无法回退。",
|
||||
|
|
@ -309,6 +316,7 @@
|
|||
"나중에": "以后再说",
|
||||
"날짜": "日期",
|
||||
"남은 시간 {0}": "剩余时间 {0}",
|
||||
"남은 용량": "剩余空间",
|
||||
"내려받기": "下载",
|
||||
"내려받기 (Windows 만)": "下载 (仅 Windows)",
|
||||
"내려받기 실패 ({0})": "下载失败({0})",
|
||||
|
|
@ -509,6 +517,7 @@
|
|||
"받은 파일에 libcef.dll 이 없습니다.": "下载的文件中没有 libcef.dll。",
|
||||
"받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "下载的文件已损坏或不是正确的文件(SHA256 不匹配)。已停止安装。",
|
||||
"받음 {0}": "已接收 {0}",
|
||||
"받지 못했습니다: {0}": "无法下载: {0}",
|
||||
"밝기": "亮度",
|
||||
"밤 0~6시": "夜间 0~6 点",
|
||||
"방법: {0}": "方式: {0}",
|
||||
|
|
@ -558,6 +567,7 @@
|
|||
"브라우저 엔진 (앱 안)": "浏览器引擎(应用内)",
|
||||
"브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 \"앱 안 브라우저\" 를 고르거나, 고른 브라우저가 설치되어 있는지 확인하세요 (Chrome · Edge 는 브라우저를 닫아야 읽힙니다).": "未能读取浏览器 Cookie。请在 设置 > 下载 > YouTube Cookie 中选择“应用内浏览器”,或确认所选浏览器已安装(Chrome · Edge 需关闭浏览器才能读取)。",
|
||||
"비공개 · 회원 전용 영상입니다.": "这是私享 · 会员专享视频。",
|
||||
"비밀번호": "密码",
|
||||
"비밀번호 (선택)": "密码(可选)",
|
||||
"비어 있는 폴더": "空文件夹",
|
||||
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。",
|
||||
|
|
@ -675,6 +685,7 @@
|
|||
"아래 프로그램이 없어 일부 기능을 쓸 수 없습니다. 공식 배포처에서 내려받아 프로그램 폴더에 설치할까요?": "由于缺少以下程序,部分功能无法使用。要从官方来源下载并安装到程序文件夹吗?",
|
||||
"아래로": "向下",
|
||||
"아래에 줄 추가": "在下方添加一行",
|
||||
"아이디": "用户名",
|
||||
"아이디 (선택)": "用户名(可选)",
|
||||
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "可在设置中填写用户名 · 密码(可选,可增加每日下载次数)。",
|
||||
"아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "图标 · 列表外观: X-plore (缩略图 · 两行) · Windows 资源管理器 (彩色图标 · 列) · Total Commander (紧凑列表 · [文件夹])",
|
||||
|
|
@ -713,6 +724,9 @@
|
|||
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "拖放到此处即可加入制作 MKV 的视频列表",
|
||||
"여러 개를 골랐을 때": "选择多个时",
|
||||
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)",
|
||||
"연결 실패: {0}": "连接失败: {0}",
|
||||
"연결 확인": "测试连接",
|
||||
"연결됨: 항목 {0}개{1}": "已连接: {0} 个项目{1}",
|
||||
"연도": "年份",
|
||||
"열기": "打开",
|
||||
"영상 {0}": "视频 {0}",
|
||||
|
|
@ -728,6 +742,7 @@
|
|||
"예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임": "例: /E /COPY:DAT /DCOPY:T /R:2 /W:2。移动时自动加上 /MOVE",
|
||||
"예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위": "例: 0 9-17 * * 1-5 (工作日 9:00~17:59) · 0 0-5 * * * (每天 0~5 点)。忽略分钟,以 1 小时为单位",
|
||||
"예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl·Shift·Alt·Win + 字母·数字·F1~F12)",
|
||||
"예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/아이디": "例: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/用户名",
|
||||
"예전 구조의 프로그램 파일 정리 ({0}개): {1}": "已清理旧结构的程序文件({0} 个): {1}",
|
||||
"예전 버전": "以前的版本",
|
||||
"예전 버전 v{0} 으로 되돌리기": "回退到以前的版本 v{0}",
|
||||
|
|
@ -847,6 +862,7 @@
|
|||
"이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "尚未设置移动目标文件夹(设置 > 保存位置)",
|
||||
"이동할 폴더를 만들 수 없습니다: {0} ({1})": "无法创建移动目标文件夹: {0}({1})",
|
||||
"이름": "名称",
|
||||
"이름 (탭에 보임, 비우면 주소)": "名称 (显示在标签上,留空则显示地址)",
|
||||
"이름 바꾸기 · 이동": "重命名 · 移动",
|
||||
"이름 변경": "重命名",
|
||||
"이름을 바꿀 수 없습니다: {0}": "无法重命名: {0}",
|
||||
|
|
@ -864,6 +880,7 @@
|
|||
"이탈리아어": "意大利语",
|
||||
"인도네시아어": "印尼语",
|
||||
"인식된 말이 없습니다.": "未识别到语音。",
|
||||
"인증서 확인 안 함": "不验证证书",
|
||||
"인코딩": "编码",
|
||||
"인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "请检查网络连接后,在 设置 > 检查必需程序 中重试。\n\n{0}",
|
||||
"인터넷 자막 (OpenSubtitles.com)": "网络字幕(OpenSubtitles.com)",
|
||||
|
|
@ -910,6 +927,7 @@
|
|||
"자막을 불러올 수 없습니다: {0}": "无法加载字幕: {0}",
|
||||
"자막이 비어 있습니다.": "字幕为空。",
|
||||
"자막이 없습니다.": "没有字幕。",
|
||||
"자체 서명 인증서를 쓰는 집 NAS 등 (믿을 수 있는 서버에서만)": "用于使用自签名证书的家用 NAS 等 (仅限可信服务器)",
|
||||
"작업": "任务",
|
||||
"작업 기록 보기": "显示任务记录",
|
||||
"작업 기록 숨기기": "隐藏任务记录",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import '../core/charset_detector.dart';
|
|||
import '../core/encode_options.dart';
|
||||
import '../core/languages.dart';
|
||||
import '../core/mkv_command_builder.dart';
|
||||
import '../core/webdav.dart';
|
||||
import '../core/models.dart';
|
||||
import '../core/output_paths.dart';
|
||||
import '../core/playlist.dart';
|
||||
|
|
@ -128,6 +129,8 @@ class AppController extends ChangeNotifier {
|
|||
}
|
||||
|
||||
void _applySettings() {
|
||||
// WebDAV 서버 (파일 탐색기 · Rsync 화면 · 복사 · 동기화가 dav:// 경로로 쓴다)
|
||||
DavRegistry.configure(settings.webdavServers);
|
||||
outputRootOverride =
|
||||
(settings.mkvOutputRoot?.isNotEmpty ?? false) ? settings.mkvOutputRoot : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:path/path.dart' as p;
|
|||
|
||||
import '../core/file_ops.dart';
|
||||
import '../core/sync_tools.dart';
|
||||
import '../core/vfs.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import '../platform/windows/windows_usage.dart';
|
||||
import 'app_controller.dart';
|
||||
|
|
@ -35,7 +36,7 @@ class CopyCenter extends ChangeNotifier {
|
|||
/// 새 작업: 지금 설정의 방법 · 옵션으로
|
||||
CopyTask fresh(List<String> sources, String dest, {bool move = false, bool contents = false, String? method}) {
|
||||
final s = c.settings;
|
||||
final hasDir = sources.any(FileSystemEntity.isDirectorySync);
|
||||
final hasDir = sources.any((s) => isDav(s) || FileSystemEntity.isDirectorySync(s));
|
||||
var m = CopyMethod.of(method ?? (hasDir ? s.copyMethodFolder : s.copyMethodFile));
|
||||
if (!copyMethodAvailable(m, s)) m = CopyMethod.builtin;
|
||||
return CopyTask(
|
||||
|
|
@ -94,8 +95,10 @@ class CopyCenter extends ChangeNotifier {
|
|||
return null;
|
||||
}
|
||||
final method = CopyMethod.of(t.method);
|
||||
final exe = method == CopyMethod.rsync ? (rsyncExe ?? await rsyncExecutable(c.settings)) : null;
|
||||
if (method == CopyMethod.rsync && exe == null) return null;
|
||||
// WebDAV 가 끼면 rsync 없이 앱이 직접 맞춘다
|
||||
final dav = isDav(t.dest) || t.sources.any(isDav);
|
||||
final exe = method == CopyMethod.rsync && !dav ? (rsyncExe ?? await rsyncExecutable(c.settings)) : null;
|
||||
if (method == CopyMethod.rsync && !dav && exe == null) return null;
|
||||
final job = TransferJob(
|
||||
sources: t.sources,
|
||||
dest: t.dest,
|
||||
|
|
@ -135,7 +138,7 @@ class CopyCenter extends ChangeNotifier {
|
|||
|
||||
/// lsync 로 옮기기: 원본 폴더마다 (원본 → 대상\이름) 실시간 동기화 쌍. 파일 원본은 넣을 수 없어 남긴다.
|
||||
Future<int> toLiveSync(CopyTask t) async {
|
||||
final dirs = t.sources.where(FileSystemEntity.isDirectorySync).toList();
|
||||
final dirs = t.sources.where((s) => isDav(s) ? vIsDirSync(s) : FileSystemEntity.isDirectorySync(s)).toList();
|
||||
if (dirs.isEmpty) return 0;
|
||||
final pairs = [
|
||||
for (final d in dirs) LiveSyncPair(d, t.contents ? t.dest : p.join(t.dest, p.basename(d)), method: t.method),
|
||||
|
|
@ -174,6 +177,11 @@ class CopyCenter extends ChangeNotifier {
|
|||
/// 디스크 남은 용량 · 전체 용량 (바이트). 알 수 없으면 null.
|
||||
Future<(int, int)?> diskSpace(String path) async {
|
||||
try {
|
||||
// WebDAV: 서버가 알려 주는 남은 용량 · 쓴 용량 (전체 = 남은 + 쓴)
|
||||
if (isDav(path)) {
|
||||
final (free, used) = await DavPath.parse(path).client.quota();
|
||||
return free == null ? null : (free, free + (used ?? 0));
|
||||
}
|
||||
if (Platform.isWindows) return WindowsUsage().disk(path);
|
||||
if (Platform.isAndroid) {
|
||||
final r = await const MethodChannel('jj_mkvmaker/android').invokeMethod<List<Object?>>('diskSpace', {'path': path});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'package:path/path.dart' as p;
|
|||
import '../core/cron_window.dart';
|
||||
import '../core/file_ops.dart';
|
||||
import '../core/sync_tools.dart';
|
||||
import '../core/vfs.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import '../platform/windows/rsync_installer.dart';
|
||||
import 'app_controller.dart';
|
||||
|
|
@ -138,6 +139,7 @@ class LiveSync extends ChangeNotifier {
|
|||
|
||||
/// 원본과 대상의 다른 점 (크기 · 바뀐 시각). [x.delete] 면 원본에 없는 대상 항목도 "− 이름" 으로.
|
||||
static Future<List<String>> diff(LiveSyncPair x, {int limit = 500}) async {
|
||||
if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit);
|
||||
final out = <String>[];
|
||||
Future<void> walk(String src, String dst, String rel) async {
|
||||
if (out.length >= limit) return;
|
||||
|
|
@ -173,6 +175,43 @@ class LiveSync extends ChangeNotifier {
|
|||
return out;
|
||||
}
|
||||
|
||||
/// [diff] 의 WebDAV 판 (한쪽이라도 dav://): 크기가 다르거나, 대상이 원본보다 옛것이면
|
||||
static Future<List<String>> _diffV(LiveSyncPair x, {int limit = 500}) async {
|
||||
final out = <String>[];
|
||||
Future<void> walk(String src, String dst, String rel) async {
|
||||
if (out.length >= limit) return;
|
||||
final there = <String, ({String path, bool isDir, int size, DateTime modified})>{};
|
||||
try {
|
||||
for (final e in await vList(dst)) {
|
||||
there[vBasename(e.path)] = e;
|
||||
}
|
||||
} catch (_) {} // 대상 폴더가 아직 없음
|
||||
final names = <String>{};
|
||||
for (final e in await vList(src)) {
|
||||
if (out.length >= limit) return;
|
||||
final name = vBasename(e.path);
|
||||
names.add(name);
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
if (e.isDir) {
|
||||
await walk(e.path, vJoin(dst, name), r);
|
||||
continue;
|
||||
}
|
||||
final t = there[name];
|
||||
if (t == null || t.isDir || t.size != e.size || t.modified.isBefore(e.modified.subtract(const Duration(seconds: 2)))) {
|
||||
out.add(r);
|
||||
}
|
||||
}
|
||||
if (x.delete) {
|
||||
for (final name in there.keys) {
|
||||
if (!names.contains(name) && !name.endsWith('.jjsync')) out.add('− ${rel.isEmpty ? name : '$rel/$name'}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (await vExists(x.source)) await walk(x.source, x.target, '');
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<void> refreshPending(LiveSyncPair x) async {
|
||||
try {
|
||||
pending[keyOf(x)] = await diff(x);
|
||||
|
|
@ -204,7 +243,7 @@ class LiveSync extends ChangeNotifier {
|
|||
_stopAll();
|
||||
for (final x in pairs) {
|
||||
final k = keyOf(x);
|
||||
if (!Directory(x.source).existsSync()) {
|
||||
if (!isDav(x.source) && !Directory(x.source).existsSync()) {
|
||||
status[k] = (DateTime.now(), tr('원본 폴더가 없습니다'));
|
||||
continue;
|
||||
}
|
||||
|
|
@ -213,14 +252,14 @@ class LiveSync extends ChangeNotifier {
|
|||
status[k] = (DateTime.now(), tr('원본과 대상이 서로 안에 있습니다'));
|
||||
continue;
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
if (Platform.isWindows && !isDav(x.source)) {
|
||||
try {
|
||||
_watch[k] = Directory(x.source).watch(recursive: true).listen((_) => _schedule(x));
|
||||
} catch (_) {
|
||||
_timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x));
|
||||
}
|
||||
} else {
|
||||
// Android 등: 폴더 안쪽까지 감시가 안 되므로 정해진 간격으로 살핀다
|
||||
// Android 등 · 원본이 WebDAV: 폴더 안쪽까지 감시가 안 되므로 정해진 간격으로 살핀다
|
||||
_timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x));
|
||||
}
|
||||
_schedule(x); // 시작할 때 한 번
|
||||
|
|
@ -254,7 +293,8 @@ class LiveSync extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
final s = c.settings;
|
||||
try {
|
||||
final method = CopyMethod.of(x.method);
|
||||
// 한쪽이라도 WebDAV 면 rsync · robocopy 대신 앱이 맞춘다 (크기 · 시각 비교)
|
||||
final method = isDav(x.source) || isDav(x.target) ? CopyMethod.builtin : CopyMethod.of(x.method);
|
||||
final n = switch (method) {
|
||||
CopyMethod.builtin => await FileOps(bandwidthKBps: s.copyBandwidthKBps).mirror(x.source, x.target, delete: x.delete),
|
||||
CopyMethod.rsync => await _rsync(x, s),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart';
|
|||
import '../core/download_detect.dart';
|
||||
import '../core/encode_options.dart';
|
||||
import '../core/playlist.dart';
|
||||
import '../core/webdav.dart';
|
||||
import '../services/app_shell.dart' show appIconOf;
|
||||
|
||||
/// MKV 세부 정보의 이동 버튼 하나: 표시 이름 · 옮길 폴더
|
||||
|
|
@ -331,6 +332,9 @@ class AppSettings {
|
|||
/// Rsync 화면에서 마지막으로 연 폴더 (왼쪽 · 오른쪽)
|
||||
List<String> rsyncPaths = [];
|
||||
|
||||
/// WebDAV 서버 (파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆 탭). 비밀번호는 이 설정 파일에만.
|
||||
List<DavServer> webdavServers = [];
|
||||
|
||||
/// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다.
|
||||
int liveSyncIntervalSec = 30;
|
||||
|
||||
|
|
@ -440,6 +444,7 @@ class AppSettings {
|
|||
'copyMethodFile': copyMethodFile,
|
||||
'copyMethodFolder': copyMethodFolder,
|
||||
'rsyncPaths': rsyncPaths,
|
||||
'webdavServers': [for (final x in webdavServers) x.toJson()],
|
||||
'rsyncOptions': rsyncOptions,
|
||||
'robocopyOptions': robocopyOptions,
|
||||
'copyRunMode': copyRunMode,
|
||||
|
|
@ -545,6 +550,10 @@ class AppSettings {
|
|||
..copyMethodFile = _method(j['copyMethodFile'])
|
||||
..copyMethodFolder = _method(j['copyMethodFolder'])
|
||||
..rsyncPaths = [for (final x in (j['rsyncPaths'] as List?) ?? const []) '$x']
|
||||
..webdavServers = [
|
||||
for (final x in (j['webdavServers'] as List?) ?? const [])
|
||||
if (x is Map) DavServer.fromJson(x),
|
||||
]
|
||||
..rsyncOptions = j['rsyncOptions'] as String? ?? '-avPog'
|
||||
..robocopyOptions = j['robocopyOptions'] as String? ?? '/E /COPY:DAT /DCOPY:T /R:2 /W:2'
|
||||
..copyRunMode = j['copyRunMode'] == 'once' ? 'once' : 'each'
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:path/path.dart' as p;
|
|||
|
||||
import '../core/file_ops.dart';
|
||||
import '../core/sync_tools.dart';
|
||||
import '../core/vfs.dart';
|
||||
|
||||
/// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다:
|
||||
/// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률
|
||||
|
|
@ -133,7 +134,14 @@ class TransferJob extends ChangeNotifier {
|
|||
|
||||
void _tick() => notifyListeners();
|
||||
|
||||
/// 한쪽이라도 WebDAV 면 rsync · robocopy 대신 앱이 직접 (크기 · 시각 비교) 맞춘다
|
||||
bool get viaWebDav => isDav(dest) || sources.any(isDav);
|
||||
|
||||
/// rsync -u (--update) 를 옵션에 넣었는지 (앱이 맞출 때도 같은 뜻으로: 대상이 더 새것이면 건너뜀)
|
||||
bool get _update => splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u')));
|
||||
|
||||
static Future<int> countFiles(String path) async {
|
||||
if (isDav(path)) return vCountFiles(path);
|
||||
if (!FileSystemEntity.isDirectorySync(path)) return 1;
|
||||
var n = 0;
|
||||
await for (final e in Directory(path).list(recursive: true, followLinks: false).handleError((_) {})) {
|
||||
|
|
@ -146,14 +154,14 @@ class TransferJob extends ChangeNotifier {
|
|||
try {
|
||||
for (var i = 0; i < sources.length; i++) {
|
||||
final s = sources[i];
|
||||
if (FileSystemEntity.isDirectorySync(s) && isSameOrInside(dest, s)) {
|
||||
if ((isDav(s) || FileSystemEntity.isDirectorySync(s)) && isSameOrInside(dest, s)) {
|
||||
throw FileSystemException('폴더를 자기 안으로 복사 · 이동할 수 없습니다', s);
|
||||
}
|
||||
filesTotal[i] = await countFiles(s);
|
||||
}
|
||||
counting = false;
|
||||
_tick();
|
||||
switch (method) {
|
||||
switch (viaWebDav ? CopyMethod.builtin : method) {
|
||||
case CopyMethod.builtin:
|
||||
await _runBuiltin();
|
||||
case CopyMethod.rsync:
|
||||
|
|
@ -167,7 +175,7 @@ class TransferJob extends ChangeNotifier {
|
|||
pruning = true;
|
||||
_tick();
|
||||
for (final s in sources) {
|
||||
if (FileSystemEntity.isDirectorySync(s)) pruned += await removeEmptyDirs(s, keepRoot: prune == 'keep');
|
||||
if (isDav(s) || FileSystemEntity.isDirectorySync(s)) pruned += await removeEmptyDirs(s, keepRoot: prune == 'keep');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -184,17 +192,21 @@ class TransferJob extends ChangeNotifier {
|
|||
for (index = 0; index < sources.length; index++) {
|
||||
if (cancelled) return;
|
||||
final i = index;
|
||||
_ops = FileOps(bandwidthKBps: bandwidthKBps, onFileDone: (src) {
|
||||
_ops = FileOps(
|
||||
bandwidthKBps: bandwidthKBps,
|
||||
onFileDone: (src) {
|
||||
filesDone[i]++;
|
||||
currentFile = p.basename(src);
|
||||
// 옮긴 (이름 바꾼) 파일은 원본이 없어 0
|
||||
final st = FileStat.statSync(src);
|
||||
_addBytes(st.type == FileSystemEntityType.file ? st.size : 0);
|
||||
currentFile = vBasename(src);
|
||||
_tick();
|
||||
});
|
||||
},
|
||||
// 보낸 양으로 전송 속도 (WebDAV 포함)
|
||||
onBytes: _addBytes,
|
||||
);
|
||||
_tick();
|
||||
if (contents && FileSystemEntity.isDirectorySync(sources[i])) {
|
||||
await _ops!.mirror(sources[i], dest);
|
||||
if (contents && (isDav(sources[i]) || FileSystemEntity.isDirectorySync(sources[i]))) {
|
||||
// 폴더 "안의 것" 맞추기 (rsync 원본/ 대상/ 과 같은 뜻): -u · 원본 파일 지우기 (--remove-source-files) 도
|
||||
await _ops!.mirror(sources[i], dest, update: _update);
|
||||
if (move && !cancelled) await _deleteFilesIn(sources[i]);
|
||||
made.add(dest);
|
||||
} else {
|
||||
final r = move ? await _ops!.move([sources[i]], dest) : await _ops!.copy([sources[i]], dest);
|
||||
|
|
@ -205,6 +217,18 @@ class TransferJob extends ChangeNotifier {
|
|||
index = sources.length;
|
||||
}
|
||||
|
||||
/// 맞춘 뒤 원본의 파일만 지운다 (rsync --remove-source-files 처럼, 빈 폴더는 [prune] 이 정리)
|
||||
Future<void> _deleteFilesIn(String dir) async {
|
||||
for (final e in await vList(dir)) {
|
||||
if (cancelled) return;
|
||||
if (e.isDir) {
|
||||
await _deleteFilesIn(e.path);
|
||||
} else {
|
||||
await vDelete(e.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _addLog(String line) {
|
||||
if (line.trim().isEmpty) return;
|
||||
log.add(line.trim());
|
||||
|
|
|
|||
|
|
@ -62,10 +62,18 @@ class VersionSnapshot {
|
|||
Future<void> restoreFrom(String from) async {
|
||||
final current = File(p.join(dataDir, 'settings.json'));
|
||||
Map<String, dynamic> secrets = {};
|
||||
Map<String, Object?> davPasswords = {};
|
||||
if (await current.exists()) {
|
||||
try {
|
||||
final j = jsonDecode(await current.readAsString()) as Map<String, dynamic>;
|
||||
secrets = {for (final k in secretKeys) if (j[k] != null) k: j[k]};
|
||||
final servers = j['webdavServers'];
|
||||
if (servers is List) {
|
||||
davPasswords = {
|
||||
for (final s in servers)
|
||||
if (s is Map && s['id'] != null && s['password'] != null) '${s['id']}': s['password'],
|
||||
};
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
for (final f in files) {
|
||||
|
|
@ -75,6 +83,12 @@ class VersionSnapshot {
|
|||
if (f == 'settings.json') {
|
||||
final j = jsonDecode(await src.readAsString()) as Map<String, dynamic>;
|
||||
j.addAll(secrets);
|
||||
final servers = j['webdavServers'];
|
||||
if (servers is List) {
|
||||
for (final s in servers) {
|
||||
if (s is Map && davPasswords.containsKey('${s['id']}')) s['password'] = davPasswords['${s['id']}'];
|
||||
}
|
||||
}
|
||||
await dst.writeAsString(const JsonEncoder.withIndent(' ').convert(j));
|
||||
} else {
|
||||
await src.copy(dst.path);
|
||||
|
|
@ -104,6 +118,13 @@ class VersionSnapshot {
|
|||
for (final k in secretKeys) {
|
||||
j.remove(k);
|
||||
}
|
||||
// WebDAV 서버의 비밀번호
|
||||
final servers = j['webdavServers'];
|
||||
if (servers is List) {
|
||||
for (final s in servers) {
|
||||
if (s is Map) s.remove('password');
|
||||
}
|
||||
}
|
||||
return const JsonEncoder.withIndent(' ').convert(j);
|
||||
} catch (_) {
|
||||
return '{}';
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:path/path.dart' as p;
|
|||
|
||||
import '../l10n/tr.dart';
|
||||
import 'playlist.dart' show naturalCompare;
|
||||
import 'vfs.dart';
|
||||
|
||||
/// 파일 탐색기 (X-plore 참고) 의 화면과 상관없는 부분: 폴더 읽기 · 정렬 · 복사 / 이동 / 삭제 · 찾기.
|
||||
|
||||
|
|
@ -44,6 +45,14 @@ List<FileEntry> sortEntries(List<FileEntry> list, SortBy by, {bool descending =
|
|||
|
||||
/// 폴더 읽기 (읽을 수 없는 항목은 건너뜀). [showHidden] 이 아니면 점으로 시작하는 항목을 뺀다.
|
||||
Future<List<FileEntry>> listEntries(String dir, {bool showHidden = false}) async {
|
||||
if (isDav(dir)) {
|
||||
// WebDAV: 네트워크 오류 · 인증 실패는 그대로 던진다 (화면이 "읽을 수 없음" 과 이유를 보여 줌)
|
||||
return [
|
||||
for (final e in await vList(dir))
|
||||
if (showHidden || !vBasename(e.path).startsWith('.'))
|
||||
FileEntry(e.path, isDir: e.isDir, size: e.size, modified: e.modified),
|
||||
];
|
||||
}
|
||||
final out = <FileEntry>[];
|
||||
await for (final e in Directory(dir).list(followLinks: false).handleError((_) {})) {
|
||||
try {
|
||||
|
|
@ -76,12 +85,16 @@ String uniqueTarget(String dir, String name) {
|
|||
|
||||
/// 같은 경로인지 (Windows 는 대소문자 무시)
|
||||
bool samePath(String a, String b) {
|
||||
final dav = davSame(a, b);
|
||||
if (dav != null) return dav;
|
||||
final x = p.normalize(p.absolute(a)), y = p.normalize(p.absolute(b));
|
||||
return Platform.isWindows ? x.toLowerCase() == y.toLowerCase() : x == y;
|
||||
}
|
||||
|
||||
/// [a] 가 [b] 와 같거나 그 안에 있는지 (폴더를 자기 안으로 복사 · 이동하지 않게)
|
||||
bool isSameOrInside(String a, String b) {
|
||||
final dav = davInside(a, b);
|
||||
if (dav != null) return dav;
|
||||
final x = p.normalize(p.absolute(a)), y = p.normalize(p.absolute(b));
|
||||
return samePath(a, b) || p.isWithin(y, x) || (Platform.isWindows && p.isWithin(y.toLowerCase(), x.toLowerCase()));
|
||||
}
|
||||
|
|
@ -92,16 +105,17 @@ bool isSameOrInside(String a, String b) {
|
|||
/// - 폴더를 자기 자신 (또는 그 안) 으로 복사 · 이동
|
||||
/// - 이미 그 폴더에 있는 것을 같은 폴더로 이동
|
||||
String? transferProblem(List<String> sources, String dest, {required bool move}) {
|
||||
final missing = [for (final s in sources) if (FileSystemEntity.typeSync(s) == FileSystemEntityType.notFound) s];
|
||||
// WebDAV 경로는 네트워크라 여기서 있는지 확인하지 않는다 (없으면 복사할 때 알림)
|
||||
final missing = [for (final s in sources) if (vMissingSync(s)) s];
|
||||
if (missing.isNotEmpty) {
|
||||
return trf('원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}', [missing.map(p.basename).join(', ')]);
|
||||
return trf('원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}', [missing.map(vBasename).join(', ')]);
|
||||
}
|
||||
if (!Directory(dest).existsSync()) return trf('대상 폴더가 없습니다: {0}', [dest]);
|
||||
if (!isDav(dest) && !Directory(dest).existsSync()) return trf('대상 폴더가 없습니다: {0}', [dest]);
|
||||
for (final s in sources) {
|
||||
if (FileSystemEntity.isDirectorySync(s) && isSameOrInside(dest, s)) {
|
||||
return trf('폴더를 자기 자신 안으로 {0} 수 없습니다: {1}', [move ? tr('옮길') : tr('복사할'), p.basename(s)]);
|
||||
if ((isDav(s) || FileSystemEntity.isDirectorySync(s)) && isSameOrInside(dest, s)) {
|
||||
return trf('폴더를 자기 자신 안으로 {0} 수 없습니다: {1}', [move ? tr('옮길') : tr('복사할'), vBasename(s)]);
|
||||
}
|
||||
if (move && samePath(p.dirname(s), dest)) return trf('이미 이 폴더에 있습니다: {0}', [p.basename(s)]);
|
||||
if (move && samePath(vDirname(s), dest)) return trf('이미 이 폴더에 있습니다: {0}', [vBasename(s)]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -109,6 +123,7 @@ String? transferProblem(List<String> sources, String dest, {required bool move})
|
|||
/// [root] 안의 빈 폴더를 안쪽부터 지운다 (find root/ -type d -empty -delete 와 같음). 지운 폴더 수.
|
||||
/// [keepRoot] 면 [root] 자신은 비어도 남긴다. 파일 · 링크가 하나라도 있는 폴더는 그대로.
|
||||
Future<int> removeEmptyDirs(String root, {bool keepRoot = true}) async {
|
||||
if (isDav(root)) return _removeEmptyDirsV(root, keepRoot: keepRoot);
|
||||
var n = 0;
|
||||
Future<bool> walk(Directory d) async {
|
||||
var empty = true;
|
||||
|
|
@ -142,6 +157,36 @@ Future<int> removeEmptyDirs(String root, {bool keepRoot = true}) async {
|
|||
/// 복사 · 이동 중 알림 (지금 파일, 지금까지 바이트, 전체 바이트)
|
||||
typedef CopyProgress = void Function(String current, int done, int total);
|
||||
|
||||
/// [removeEmptyDirs] 의 WebDAV 판
|
||||
Future<int> _removeEmptyDirsV(String root, {bool keepRoot = true}) async {
|
||||
var n = 0;
|
||||
Future<bool> walk(String dir) async {
|
||||
var empty = true;
|
||||
for (final e in await vList(dir)) {
|
||||
if (e.isDir && await walk(e.path)) {
|
||||
try {
|
||||
await vDelete(e.path);
|
||||
n++;
|
||||
} catch (_) {
|
||||
empty = false;
|
||||
}
|
||||
} else {
|
||||
empty = false;
|
||||
}
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
if (!await vExists(root)) return 0;
|
||||
if (await walk(root) && !keepRoot) {
|
||||
try {
|
||||
await vDelete(root);
|
||||
n++;
|
||||
} catch (_) {}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
class FileOpCancelled implements Exception {
|
||||
const FileOpCancelled();
|
||||
}
|
||||
|
|
@ -157,7 +202,13 @@ class FileOps {
|
|||
/// 파일 하나를 다 복사할 때마다 (원본 경로)
|
||||
final void Function(String source)? onFileDone;
|
||||
|
||||
FileOps({this.bandwidthKBps = 0, this.onFileDone});
|
||||
/// 조각을 보낼 때마다 (바이트) - 전송 속도 계산용
|
||||
final void Function(int bytes)? onBytes;
|
||||
|
||||
FileOps({this.bandwidthKBps = 0, this.onFileDone, this.onBytes});
|
||||
|
||||
/// 한쪽이라도 WebDAV 면 아래의 "V" (어느 저장소든) 판으로
|
||||
static bool _anyDav(Iterable<String> paths) => paths.any(isDav);
|
||||
|
||||
final _clock = Stopwatch();
|
||||
int _sent = 0;
|
||||
|
|
@ -178,6 +229,7 @@ class FileOps {
|
|||
|
||||
/// 전체 크기 (진행률용)
|
||||
static Future<int> totalSize(List<String> paths) async {
|
||||
if (_anyDav(paths)) return _totalSizeV(paths);
|
||||
var total = 0;
|
||||
for (final s in paths) {
|
||||
final t = FileSystemEntity.typeSync(s, followLinks: false);
|
||||
|
|
@ -198,6 +250,7 @@ class FileOps {
|
|||
|
||||
/// [sources] 를 [destDir] 로 복사 (같은 이름이 있으면 "이름 (2)"). 만든 경로들을 돌려준다.
|
||||
Future<List<String>> copy(List<String> sources, String destDir, {CopyProgress? onProgress}) async {
|
||||
if (_anyDav([...sources, destDir])) return _copyV(sources, destDir, onProgress: onProgress);
|
||||
final total = await totalSize(sources);
|
||||
var done = 0;
|
||||
final made = <String>[];
|
||||
|
|
@ -211,6 +264,7 @@ class FileOps {
|
|||
out.add(chunk);
|
||||
done += chunk.length;
|
||||
onProgress?.call(f.path, done, total);
|
||||
onBytes?.call(chunk.length);
|
||||
await _throttle(chunk.length);
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -254,6 +308,7 @@ class FileOps {
|
|||
|
||||
/// 이동: 같은 드라이브면 이름 바꾸기, 아니면 복사한 뒤 원본 삭제
|
||||
Future<List<String>> move(List<String> sources, String destDir, {CopyProgress? onProgress}) async {
|
||||
if (_anyDav([...sources, destDir])) return _moveV(sources, destDir, onProgress: onProgress);
|
||||
final made = <String>[];
|
||||
for (final s in sources) {
|
||||
_check();
|
||||
|
|
@ -283,7 +338,10 @@ class FileOps {
|
|||
|
||||
/// 동기화 (실시간 동기화 · lsyncd 처럼): [srcDir] 의 내용을 [dstDir] 에 맞춘다.
|
||||
/// 크기나 바뀐 시각이 다른 파일만 복사, [delete] 면 원본에 없는 것을 대상에서 지운다. 복사한 파일 수를 돌려준다.
|
||||
Future<int> mirror(String srcDir, String dstDir, {bool delete = false}) async {
|
||||
///
|
||||
/// [update] (rsync -u): 대상이 더 새 파일이면 건너뛴다 (양쪽 ⇄ 을 함께 맞출 때 서로 덮어쓰지 않게).
|
||||
Future<int> mirror(String srcDir, String dstDir, {bool delete = false, bool update = false}) async {
|
||||
if (_anyDav([srcDir, dstDir])) return _mirrorV(srcDir, dstDir, delete: delete, update: update);
|
||||
var n = 0;
|
||||
Future<void> walk(String src, String dst) async {
|
||||
_check();
|
||||
|
|
@ -298,16 +356,19 @@ class FileOps {
|
|||
} else if (e is File) {
|
||||
final st = await e.stat();
|
||||
final t = File(target);
|
||||
final same = await t.exists() &&
|
||||
final exists = await t.exists();
|
||||
final same = exists &&
|
||||
(await t.length()) == st.size &&
|
||||
((await t.lastModified()).difference(st.modified).inSeconds.abs() <= 2);
|
||||
if (same) continue;
|
||||
if (update && exists && (await t.lastModified()).isAfter(st.modified.add(const Duration(seconds: 2)))) continue;
|
||||
final tmp = '$target.jjsync';
|
||||
final out = File(tmp).openWrite();
|
||||
try {
|
||||
await for (final chunk in e.openRead()) {
|
||||
_check();
|
||||
out.add(chunk);
|
||||
onBytes?.call(chunk.length);
|
||||
await _throttle(chunk.length);
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -337,6 +398,10 @@ class FileOps {
|
|||
Future<void> delete(List<String> paths) async {
|
||||
for (final s in paths) {
|
||||
_check();
|
||||
if (isDav(s)) {
|
||||
await vDelete(s);
|
||||
continue;
|
||||
}
|
||||
final t = FileSystemEntity.typeSync(s, followLinks: false);
|
||||
if (t == FileSystemEntityType.directory) {
|
||||
await Directory(s).delete(recursive: true);
|
||||
|
|
@ -352,6 +417,13 @@ class FileOps {
|
|||
if (name.isEmpty || name.contains(RegExp(r'[\\/:*?"<>|]'))) {
|
||||
throw FileSystemException('쓸 수 없는 이름입니다', name);
|
||||
}
|
||||
if (isDav(path)) {
|
||||
final target = vJoin(vDirname(path), name);
|
||||
if (target == vNorm(path)) return target;
|
||||
if (await vExists(target)) throw FileSystemException('같은 이름이 이미 있습니다', target);
|
||||
await vRename(path, target);
|
||||
return target;
|
||||
}
|
||||
final target = p.join(p.dirname(path), name);
|
||||
if (target == path) return path;
|
||||
if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound &&
|
||||
|
|
@ -367,12 +439,166 @@ class FileOps {
|
|||
static Future<String> makeFolder(String dir, String name) async {
|
||||
final n = name.trim();
|
||||
if (n.isEmpty || n.contains(RegExp(r'[\\/:*?"<>|]'))) throw FileSystemException('쓸 수 없는 이름입니다', n);
|
||||
if (isDav(dir)) {
|
||||
final target = vJoin(dir, n);
|
||||
if (await vExists(target)) throw FileSystemException('같은 이름이 이미 있습니다', target);
|
||||
await vMkdirs(target);
|
||||
return target;
|
||||
}
|
||||
final target = p.join(dir, n);
|
||||
if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound) {
|
||||
throw FileSystemException('같은 이름이 이미 있습니다', target);
|
||||
}
|
||||
return (await Directory(target).create()).path;
|
||||
}
|
||||
|
||||
// ───────── 어느 저장소든 (로컬 · WebDAV) ─────────
|
||||
|
||||
static Future<int> _totalSizeV(List<String> paths) async {
|
||||
var total = 0;
|
||||
Future<void> walk(String path) async {
|
||||
final st = await vStat(path);
|
||||
if (st == null) return;
|
||||
if (!st.isDir) {
|
||||
total += st.size;
|
||||
return;
|
||||
}
|
||||
for (final e in await vList(path)) {
|
||||
if (e.isDir) {
|
||||
await walk(e.path);
|
||||
} else {
|
||||
total += e.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (final s in paths) {
|
||||
await walk(s);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// 파일 하나: 읽어서 쓴다 (속도 제한 · 취소 · 진행)
|
||||
Future<void> _copyFileV(String src, String target, int size, DateTime modified, void Function(int n)? onChunk) async {
|
||||
_check();
|
||||
final input = await vOpenRead(src);
|
||||
final relay = StreamController<List<int>>();
|
||||
final pump = () async {
|
||||
try {
|
||||
await for (final chunk in input) {
|
||||
if (_cancel) break;
|
||||
relay.add(chunk);
|
||||
onBytes?.call(chunk.length);
|
||||
onChunk?.call(chunk.length);
|
||||
await _throttle(chunk.length);
|
||||
}
|
||||
} catch (e, st) {
|
||||
relay.addError(e, st);
|
||||
} finally {
|
||||
await relay.close();
|
||||
}
|
||||
}();
|
||||
try {
|
||||
await vWrite(target, relay.stream, length: size, modified: modified);
|
||||
} finally {
|
||||
await pump;
|
||||
}
|
||||
if (_cancel) {
|
||||
try {
|
||||
await vDelete(target);
|
||||
} catch (_) {}
|
||||
throw const FileOpCancelled();
|
||||
}
|
||||
onFileDone?.call(src);
|
||||
}
|
||||
|
||||
Future<List<String>> _copyV(List<String> sources, String destDir, {CopyProgress? onProgress}) async {
|
||||
final total = await _totalSizeV(sources);
|
||||
var done = 0;
|
||||
final made = <String>[];
|
||||
Future<void> copyAny(String src, String target) async {
|
||||
_check();
|
||||
final st = await vStat(src);
|
||||
if (st == null) throw FileSystemException('원본이 없습니다', src);
|
||||
if (st.isDir) {
|
||||
await vMkdirs(target);
|
||||
for (final e in await vList(src)) {
|
||||
await copyAny(e.path, vJoin(target, vBasename(e.path)));
|
||||
}
|
||||
} else {
|
||||
await _copyFileV(src, target, st.size, st.modified, (n) {
|
||||
done += n;
|
||||
onProgress?.call(src, done, total);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (final s in sources) {
|
||||
if (isSameOrInside(destDir, s) && await vIsDir(s)) throw FileSystemException('폴더를 자기 안으로 복사할 수 없습니다', s);
|
||||
final target = await vUniqueTarget(destDir, vBasename(s));
|
||||
await copyAny(s, target);
|
||||
made.add(target);
|
||||
}
|
||||
return made;
|
||||
}
|
||||
|
||||
Future<List<String>> _moveV(List<String> sources, String destDir, {CopyProgress? onProgress}) async {
|
||||
final made = <String>[];
|
||||
for (final s in sources) {
|
||||
_check();
|
||||
if (isSameOrInside(destDir, s)) throw FileSystemException('폴더를 자기 안으로 옮길 수 없습니다', s);
|
||||
if (samePath(vDirname(s), destDir)) {
|
||||
made.add(s);
|
||||
continue;
|
||||
}
|
||||
final target = await vUniqueTarget(destDir, vBasename(s));
|
||||
// 같은 WebDAV 서버 안이면 서버가 옮긴다 (빠름)
|
||||
if (isDav(s) && isDav(destDir) && DavPath.parse(s).server == DavPath.parse(destDir).server) {
|
||||
await vRename(s, target);
|
||||
made.add(target);
|
||||
continue;
|
||||
}
|
||||
made.addAll(await _copyV([s], destDir, onProgress: onProgress));
|
||||
await vDelete(s);
|
||||
}
|
||||
return made;
|
||||
}
|
||||
|
||||
Future<int> _mirrorV(String srcDir, String dstDir, {bool delete = false, bool update = false}) async {
|
||||
var n = 0;
|
||||
Future<void> walk(String src, String dst) async {
|
||||
_check();
|
||||
await vMkdirs(dst);
|
||||
final there = {for (final e in await vList(dst)) vBasename(e.path): e};
|
||||
final names = <String>{};
|
||||
for (final e in await vList(src)) {
|
||||
final name = vBasename(e.path);
|
||||
names.add(name);
|
||||
final target = vJoin(dst, name);
|
||||
if (e.isDir) {
|
||||
await walk(e.path, target);
|
||||
continue;
|
||||
}
|
||||
final t = there[name];
|
||||
if (t != null && !t.isDir && t.size == e.size) {
|
||||
// 크기가 같고 대상이 원본보다 새것이거나 (올린 파일은 서버 시각) 거의 같으면 그대로
|
||||
if (!t.modified.isBefore(e.modified.subtract(const Duration(seconds: 2)))) continue;
|
||||
}
|
||||
if (update && t != null && t.modified.isAfter(e.modified.add(const Duration(seconds: 2)))) continue;
|
||||
if (t != null && t.isDir) await vDelete(target);
|
||||
await _copyFileV(e.path, target, e.size, e.modified, null);
|
||||
n++;
|
||||
}
|
||||
if (delete) {
|
||||
for (final name in there.keys) {
|
||||
if (!names.contains(name) && !name.endsWith('.jjsync')) await vDelete(vJoin(dst, name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(srcDir, dstDir);
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
/// [root] 아래에서 이름에 [query] 가 들어간 항목 찾기 (대소문자 무시, * ? 사용 가능). 찾는 대로 내보낸다.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'webdav.dart';
|
||||
|
||||
/// 로컬 경로와 WebDAV 경로 (`dav://<서버 id>/<경로>`) 를 같이 다루는 도구.
|
||||
/// 로컬 경로는 원래 코드 (dart:io) 를 그대로 쓰고, dav:// 만 여기서 WebDAV 로 처리한다.
|
||||
const davScheme = 'dav://';
|
||||
|
||||
bool isDav(String path) => path.startsWith(davScheme);
|
||||
|
||||
/// dav:// 경로: 서버 id 와 서버 기준 경로 ("/" 로 시작, 끝 / 없음, 맨 위는 "/")
|
||||
class DavPath {
|
||||
final String server;
|
||||
final String rel;
|
||||
const DavPath(this.server, this.rel);
|
||||
|
||||
/// "dav://id/a/b" · "dav://id/a\b" (p.join 이 붙인 \ ) · "dav://id" → 정리
|
||||
static DavPath parse(String path) {
|
||||
var rest = path.substring(davScheme.length).replaceAll('\\', '/');
|
||||
final slash = rest.indexOf('/');
|
||||
final server = slash < 0 ? rest : rest.substring(0, slash);
|
||||
var rel = slash < 0 ? '/' : rest.substring(slash);
|
||||
rel = rel.replaceAll(RegExp(r'/+'), '/');
|
||||
if (rel.length > 1 && rel.endsWith('/')) rel = rel.substring(0, rel.length - 1);
|
||||
if (rel.isEmpty) rel = '/';
|
||||
return DavPath(server, rel);
|
||||
}
|
||||
|
||||
bool get isRoot => rel == '/';
|
||||
String get full => isRoot ? '$davScheme$server/' : '$davScheme$server$rel';
|
||||
String get name => isRoot ? '' : rel.substring(rel.lastIndexOf('/') + 1);
|
||||
DavPath get parent => isRoot ? this : DavPath(server, rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/')));
|
||||
DavPath child(String n) => DavPath(server, isRoot ? '/$n' : '$rel/$n');
|
||||
DavClient get client => DavRegistry.client(server);
|
||||
}
|
||||
|
||||
/// 경로를 한 모양으로 (dav:// 만 정리, 로컬은 그대로)
|
||||
String vNorm(String path) => isDav(path) ? DavPath.parse(path).full : path;
|
||||
String vJoin(String dir, String name) => isDav(dir) ? DavPath.parse(dir).child(name).full : p.join(dir, name);
|
||||
String vDirname(String path) => isDav(path) ? DavPath.parse(path).parent.full : p.dirname(path);
|
||||
String vBasename(String path) => isDav(path) ? DavPath.parse(path).name : p.basename(path);
|
||||
|
||||
/// 같은 서버의 dav 경로끼리 같은지 · 안에 있는지 (로컬은 null - 원래 함수가 판단)
|
||||
bool? davSame(String a, String b) {
|
||||
if (!isDav(a) && !isDav(b)) return null;
|
||||
if (isDav(a) != isDav(b)) return false;
|
||||
return DavPath.parse(a).full == DavPath.parse(b).full;
|
||||
}
|
||||
|
||||
bool? davInside(String a, String b) {
|
||||
if (!isDav(a) && !isDav(b)) return null;
|
||||
if (isDav(a) != isDav(b)) return false;
|
||||
final x = DavPath.parse(a), y = DavPath.parse(b);
|
||||
if (x.server != y.server) return false;
|
||||
return x.rel == y.rel || y.isRoot || x.rel.startsWith('${y.rel}/');
|
||||
}
|
||||
|
||||
/// 항목 정보 (없으면 null)
|
||||
Future<({bool isDir, int size, DateTime modified})?> vStat(String path) async {
|
||||
if (isDav(path)) {
|
||||
final d = DavPath.parse(path);
|
||||
final s = await d.client.stat(d.rel);
|
||||
return s == null ? null : (isDir: s.isDir, size: s.size, modified: s.modified);
|
||||
}
|
||||
final st = await FileStat.stat(path);
|
||||
if (st.type == FileSystemEntityType.notFound) return null;
|
||||
return (isDir: st.type == FileSystemEntityType.directory, size: st.size, modified: st.modified);
|
||||
}
|
||||
|
||||
Future<bool> vIsDir(String path) async => (await vStat(path))?.isDir ?? false;
|
||||
Future<bool> vExists(String path) async => (await vStat(path)) != null;
|
||||
|
||||
/// 동기 확인 (dav 는 네트워크라 알 수 없으므로 "있다" 로 본다)
|
||||
bool vMissingSync(String path) => !isDav(path) && FileSystemEntity.typeSync(path) == FileSystemEntityType.notFound;
|
||||
bool vIsDirSync(String path) => isDav(path) ? !DavPath.parse(path).name.contains('.') : FileSystemEntity.isDirectorySync(path);
|
||||
|
||||
/// 폴더 안의 항목: (경로, 폴더인지, 크기, 바뀐 때)
|
||||
Future<List<({String path, bool isDir, int size, DateTime modified})>> vList(String dir) async {
|
||||
if (isDav(dir)) {
|
||||
final d = DavPath.parse(dir);
|
||||
return [
|
||||
for (final i in await d.client.list(d.rel))
|
||||
(path: DavPath(d.server, i.rel).full, isDir: i.isDir, size: i.size, modified: i.modified),
|
||||
];
|
||||
}
|
||||
final out = <({String path, bool isDir, int size, DateTime modified})>[];
|
||||
await for (final e in Directory(dir).list(followLinks: false).handleError((_) {})) {
|
||||
try {
|
||||
final st = await e.stat();
|
||||
final isDir = st.type == FileSystemEntityType.directory;
|
||||
out.add((path: e.path, isDir: isDir, size: isDir ? 0 : st.size, modified: st.modified));
|
||||
} catch (_) {}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<void> vMkdirs(String dir) async {
|
||||
if (isDav(dir)) {
|
||||
final d = DavPath.parse(dir);
|
||||
await d.client.mkdirs(d.rel);
|
||||
} else {
|
||||
await Directory(dir).create(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 지우기 (폴더면 안의 것까지)
|
||||
Future<void> vDelete(String path) async {
|
||||
if (isDav(path)) {
|
||||
final d = DavPath.parse(path);
|
||||
await d.client.delete(d.rel);
|
||||
return;
|
||||
}
|
||||
final t = FileSystemEntity.typeSync(path, followLinks: false);
|
||||
if (t == FileSystemEntityType.directory) {
|
||||
await Directory(path).delete(recursive: true);
|
||||
} else if (t != FileSystemEntityType.notFound) {
|
||||
await File(path).delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Stream<List<int>>> vOpenRead(String path) async {
|
||||
if (isDav(path)) {
|
||||
final d = DavPath.parse(path);
|
||||
return d.client.openRead(d.rel);
|
||||
}
|
||||
return File(path).openRead();
|
||||
}
|
||||
|
||||
/// [data] 를 [path] 에 쓴다 (로컬은 임시 이름에 쓴 뒤 바꾸고, [modified] 가 있으면 바뀐 때도 맞춘다)
|
||||
Future<void> vWrite(String path, Stream<List<int>> data, {int? length, DateTime? modified}) async {
|
||||
if (isDav(path)) {
|
||||
final d = DavPath.parse(path);
|
||||
await d.client.write(d.rel, data, length: length);
|
||||
return;
|
||||
}
|
||||
final tmp = '$path.jjsync';
|
||||
final out = File(tmp).openWrite();
|
||||
try {
|
||||
await out.addStream(data);
|
||||
} finally {
|
||||
await out.close();
|
||||
}
|
||||
if (await File(path).exists()) await File(path).delete();
|
||||
await File(tmp).rename(path);
|
||||
if (modified != null) {
|
||||
try {
|
||||
await File(path).setLastModified(modified);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 같은 서버 안에서 이름 바꾸기 · 옮기기
|
||||
Future<void> vRename(String from, String to) async {
|
||||
if (isDav(from) && isDav(to)) {
|
||||
final a = DavPath.parse(from), b = DavPath.parse(to);
|
||||
if (a.server == b.server) {
|
||||
await a.client.move(a.rel, b.rel);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isDav(from) || isDav(to)) throw FileSystemException('다른 저장소로는 이름만 바꿀 수 없습니다', from);
|
||||
if (FileSystemEntity.isDirectorySync(from)) {
|
||||
await Directory(from).rename(to);
|
||||
} else {
|
||||
await File(from).rename(to);
|
||||
}
|
||||
}
|
||||
|
||||
/// [dir] 안에서 겹치지 않는 이름 ("이름 (2).확장자")
|
||||
Future<String> vUniqueTarget(String dir, String name) async {
|
||||
var target = vJoin(dir, name);
|
||||
if (!await vExists(target)) return target;
|
||||
final ext = p.extension(name), base = p.basenameWithoutExtension(name);
|
||||
for (var i = 2;; i++) {
|
||||
target = vJoin(dir, '$base ($i)$ext');
|
||||
if (!await vExists(target)) return target;
|
||||
}
|
||||
}
|
||||
|
||||
/// 원격 파일을 열거나 재생하려면 로컬로 받아야 한다: 임시 폴더에 받은 경로 (로컬이면 그대로)
|
||||
Future<String> vLocalCopy(String path, String tempDir, {void Function(int done, int total)? onProgress}) async {
|
||||
if (!isDav(path)) return path;
|
||||
final d = DavPath.parse(path);
|
||||
final st = await d.client.stat(d.rel);
|
||||
final dir = Directory(p.join(tempDir, 'jj_webdav', d.server, p.dirname(d.rel.substring(1))));
|
||||
await dir.create(recursive: true);
|
||||
final out = File(p.join(dir.path, d.name));
|
||||
// 이미 받아 둔 것 (크기 · 바뀐 때 같음) 은 다시 받지 않는다
|
||||
if (st != null && await out.exists() && await out.length() == st.size && !(await out.lastModified()).isBefore(st.modified)) {
|
||||
return out.path;
|
||||
}
|
||||
var done = 0;
|
||||
final sink = out.openWrite();
|
||||
try {
|
||||
await for (final chunk in await d.client.openRead(d.rel)) {
|
||||
sink.add(chunk);
|
||||
done += chunk.length;
|
||||
onProgress?.call(done, st?.size ?? 0);
|
||||
}
|
||||
} finally {
|
||||
await sink.close();
|
||||
}
|
||||
return out.path;
|
||||
}
|
||||
|
||||
/// 폴더 안 파일 수 (안쪽까지)
|
||||
Future<int> vCountFiles(String path) async {
|
||||
if (!await vIsDir(path)) return 1;
|
||||
var n = 0;
|
||||
for (final e in await vList(path)) {
|
||||
n += e.isDir ? await vCountFiles(e.path) : 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/// 화면에 보일 경로: WebDAV 는 내부 id 대신 "☁ 서버 이름/경로"
|
||||
String vDisplay(String path) => isDav(path) ? '☁ ${vVolumeLabel(path)}' : path;
|
||||
|
||||
/// 저장소 이름 (화면 표시): dav 는 서버 이름
|
||||
String vVolumeLabel(String path) {
|
||||
if (!isDav(path)) return path;
|
||||
final d = DavPath.parse(path);
|
||||
return '${DavRegistry.server(d.server)?.label ?? 'WebDAV'}${d.rel == '/' ? '' : d.rel}';
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
/// WebDAV 서버 설정 (환경 설정 > 파일 탐색기 > WebDAV). 비밀번호는 설정 파일에만 (버전별 설정 보관본에는 넣지 않음).
|
||||
class DavServer {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
/// 예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/me
|
||||
final String url;
|
||||
final String user;
|
||||
final String password;
|
||||
|
||||
/// 인증서를 확인하지 않음 (자체 서명 인증서를 쓰는 집 NAS 등)
|
||||
final bool insecure;
|
||||
|
||||
const DavServer({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.url,
|
||||
this.user = '',
|
||||
this.password = '',
|
||||
this.insecure = false,
|
||||
});
|
||||
|
||||
/// 탐색기 위쪽에 보일 이름
|
||||
String get label => name.trim().isNotEmpty ? name.trim() : (Uri.tryParse(url)?.host ?? 'WebDAV');
|
||||
|
||||
DavServer copyWith({String? name, String? url, String? user, String? password, bool? insecure}) => DavServer(
|
||||
id: id,
|
||||
name: name ?? this.name,
|
||||
url: url ?? this.url,
|
||||
user: user ?? this.user,
|
||||
password: password ?? this.password,
|
||||
insecure: insecure ?? this.insecure,
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() =>
|
||||
{'id': id, 'name': name, 'url': url, 'user': user, 'password': password, 'insecure': insecure};
|
||||
|
||||
factory DavServer.fromJson(Map<Object?, Object?> j) => DavServer(
|
||||
id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}',
|
||||
name: j['name'] as String? ?? '',
|
||||
url: j['url'] as String? ?? '',
|
||||
user: j['user'] as String? ?? '',
|
||||
password: j['password'] as String? ?? '',
|
||||
insecure: j['insecure'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 목록의 한 항목 ([rel]: 서버 기준 경로, "/" 로 시작, 끝 / 없음 · 맨 위는 "/")
|
||||
class DavItem {
|
||||
final String rel;
|
||||
final bool isDir;
|
||||
final int size;
|
||||
final DateTime modified;
|
||||
const DavItem(this.rel, {required this.isDir, this.size = 0, required this.modified});
|
||||
}
|
||||
|
||||
class DavException implements Exception {
|
||||
final String message;
|
||||
final int? status;
|
||||
const DavException(this.message, {this.status});
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// WebDAV 클라이언트: PROPFIND (목록) · GET · PUT · MKCOL · DELETE · MOVE · COPY. 기본 인증 (Basic).
|
||||
class DavClient {
|
||||
final DavServer server;
|
||||
late final HttpClient _http = HttpClient()
|
||||
..connectionTimeout = const Duration(seconds: 15)
|
||||
..badCertificateCallback = server.insecure ? (_, _, _) => true : null;
|
||||
|
||||
DavClient(this.server);
|
||||
|
||||
late final Uri _base = () {
|
||||
final u = Uri.parse(server.url.trim());
|
||||
final path = u.path.endsWith('/') ? u.path.substring(0, u.path.length - 1) : u.path;
|
||||
return u.replace(path: path);
|
||||
}();
|
||||
|
||||
/// 서버 기준 경로 → 주소 (각 부분을 인코딩)
|
||||
Uri uriOf(String rel, {bool dir = false}) {
|
||||
final parts = rel.split('/').where((x) => x.isNotEmpty).map(Uri.encodeComponent);
|
||||
final tail = parts.join('/');
|
||||
final path = '${_base.path}/$tail${dir && tail.isNotEmpty ? '/' : ''}';
|
||||
return _base.replace(path: path);
|
||||
}
|
||||
|
||||
String? get _auth => server.user.isEmpty && server.password.isEmpty
|
||||
? null
|
||||
: 'Basic ${base64Encode(utf8.encode('${server.user}:${server.password}'))}';
|
||||
|
||||
Future<HttpClientRequest> _open(String method, Uri uri) async {
|
||||
final req = await _http.openUrl(method, uri);
|
||||
final a = _auth;
|
||||
if (a != null) req.headers.set(HttpHeaders.authorizationHeader, a);
|
||||
req.followRedirects = false;
|
||||
return req;
|
||||
}
|
||||
|
||||
Never _fail(String what, HttpClientResponse res, [String body = '']) {
|
||||
final hint = switch (res.statusCode) {
|
||||
401 => ' (아이디 · 비밀번호를 확인하세요)',
|
||||
403 => ' (권한 없음)',
|
||||
404 => ' (없는 경로)',
|
||||
405 => ' (서버가 이 기능을 허용하지 않음)',
|
||||
507 => ' (서버 저장 공간 부족)',
|
||||
_ => '',
|
||||
};
|
||||
throw DavException('WebDAV $what 실패: ${res.statusCode} ${res.reasonPhrase}$hint', status: res.statusCode);
|
||||
}
|
||||
|
||||
static const _propfindBody = '<?xml version="1.0" encoding="utf-8"?>'
|
||||
'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:getcontentlength/><d:getlastmodified/></d:prop></d:propfind>';
|
||||
|
||||
Future<String> _propfind(String rel, {required int depth, String body = _propfindBody}) async {
|
||||
final req = await _open('PROPFIND', uriOf(rel, dir: depth > 0));
|
||||
req.headers
|
||||
..set('Depth', '$depth')
|
||||
..contentType = ContentType('application', 'xml', charset: 'utf-8');
|
||||
// 길이를 꼭 알린다 (chunked 로 보내면 404 등에서 본문을 읽지 않는 서버가 남은 본문을 다음 요청으로 오해함)
|
||||
final bytes = utf8.encode(body);
|
||||
req.contentLength = bytes.length;
|
||||
req.add(bytes);
|
||||
final res = await req.close();
|
||||
final text = await res.transform(utf8.decoder).join();
|
||||
if (res.statusCode == 404) throw DavException('없는 경로: $rel', status: 404);
|
||||
if (res.statusCode != 207 && res.statusCode != 200) _fail('목록', res, text);
|
||||
return text;
|
||||
}
|
||||
|
||||
/// 응답의 href → 서버 기준 경로
|
||||
String _relOf(String href) {
|
||||
var path = Uri.tryParse(href)?.path ?? href;
|
||||
path = Uri.decodeFull(path);
|
||||
final base = Uri.decodeFull(_base.path);
|
||||
if (path.startsWith(base)) path = path.substring(base.length);
|
||||
path = path.replaceAll(RegExp(r'/+'), '/');
|
||||
if (path.length > 1 && path.endsWith('/')) path = path.substring(0, path.length - 1);
|
||||
return path.isEmpty ? '/' : (path.startsWith('/') ? path : '/$path');
|
||||
}
|
||||
|
||||
List<DavItem> _parse(String xml) {
|
||||
final doc = XmlDocument.parse(xml);
|
||||
final out = <DavItem>[];
|
||||
for (final r in doc.findAllElements('response', namespaceUri: 'DAV:')) {
|
||||
final href = r.findElements('href', namespaceUri: 'DAV:').firstOrNull?.innerText.trim();
|
||||
if (href == null) continue;
|
||||
// 200 인 propstat 만
|
||||
XmlElement? prop;
|
||||
for (final ps in r.findElements('propstat', namespaceUri: 'DAV:')) {
|
||||
final st = ps.findElements('status', namespaceUri: 'DAV:').firstOrNull?.innerText ?? '';
|
||||
if (st.contains(' 200')) prop = ps.findElements('prop', namespaceUri: 'DAV:').firstOrNull;
|
||||
}
|
||||
prop ??= r.findAllElements('prop', namespaceUri: 'DAV:').firstOrNull;
|
||||
final isDir = prop?.findAllElements('collection', namespaceUri: 'DAV:').isNotEmpty ?? href.endsWith('/');
|
||||
final len = int.tryParse(prop?.findElements('getcontentlength', namespaceUri: 'DAV:').firstOrNull?.innerText.trim() ?? '');
|
||||
final mod = prop?.findElements('getlastmodified', namespaceUri: 'DAV:').firstOrNull?.innerText.trim();
|
||||
DateTime when;
|
||||
try {
|
||||
when = mod == null || mod.isEmpty ? DateTime.fromMillisecondsSinceEpoch(0) : HttpDate.parse(mod).toLocal();
|
||||
} catch (_) {
|
||||
when = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
}
|
||||
out.add(DavItem(_relOf(href), isDir: isDir, size: isDir ? 0 : (len ?? 0), modified: when));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 폴더 안의 항목 (자기 자신은 뺌)
|
||||
Future<List<DavItem>> list(String rel) async {
|
||||
final self = _norm(rel);
|
||||
final items = _parse(await _propfind(self, depth: 1));
|
||||
return [for (final i in items) if (i.rel != self) i];
|
||||
}
|
||||
|
||||
/// 항목 정보 (없으면 null)
|
||||
Future<DavItem?> stat(String rel) async {
|
||||
try {
|
||||
final items = _parse(await _propfind(_norm(rel), depth: 0));
|
||||
return items.isEmpty ? null : items.first;
|
||||
} on DavException catch (e) {
|
||||
if (e.status == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 폴더 만들기 (이미 있으면 그대로)
|
||||
Future<void> mkdir(String rel) async {
|
||||
final res = await (await _open('MKCOL', uriOf(rel, dir: true))).close();
|
||||
await res.drain<void>();
|
||||
if (res.statusCode == 201 || res.statusCode == 200) return;
|
||||
if (res.statusCode == 405 && (await stat(rel))?.isDir == true) return; // 이미 있음
|
||||
_fail('폴더 만들기', res);
|
||||
}
|
||||
|
||||
/// 위 폴더까지 차례로 만든다
|
||||
Future<void> mkdirs(String rel) async {
|
||||
var cur = '';
|
||||
for (final part in rel.split('/').where((x) => x.isNotEmpty)) {
|
||||
cur = '$cur/$part';
|
||||
if ((await stat(cur))?.isDir == true) continue;
|
||||
await mkdir(cur);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(String rel) async {
|
||||
final st = await stat(rel);
|
||||
final res = await (await _open('DELETE', uriOf(rel, dir: st?.isDir ?? false))).close();
|
||||
await res.drain<void>();
|
||||
if (res.statusCode == 404) return;
|
||||
if (res.statusCode >= 300) _fail('지우기', res);
|
||||
}
|
||||
|
||||
Future<void> _moveOrCopy(String method, String from, String to, {bool overwrite = false}) async {
|
||||
final st = await stat(from);
|
||||
final req = await _open(method, uriOf(from, dir: st?.isDir ?? false));
|
||||
req.headers
|
||||
..set('Destination', uriOf(to, dir: st?.isDir ?? false).toString())
|
||||
..set('Overwrite', overwrite ? 'T' : 'F');
|
||||
if (st?.isDir ?? false) req.headers.set('Depth', 'infinity');
|
||||
final res = await req.close();
|
||||
await res.drain<void>();
|
||||
if (res.statusCode >= 300) _fail(method == 'MOVE' ? '이동' : '복사', res);
|
||||
}
|
||||
|
||||
Future<void> move(String from, String to, {bool overwrite = false}) => _moveOrCopy('MOVE', from, to, overwrite: overwrite);
|
||||
Future<void> copy(String from, String to, {bool overwrite = false}) => _moveOrCopy('COPY', from, to, overwrite: overwrite);
|
||||
|
||||
/// 받기: 내용을 조각으로 (끝나면 스트림이 닫힘)
|
||||
Future<Stream<List<int>>> openRead(String rel) async {
|
||||
final res = await (await _open('GET', uriOf(rel))).close();
|
||||
if (res.statusCode != 200) {
|
||||
await res.drain<void>();
|
||||
_fail('받기', res);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/// 올리기 ([length] 를 알면 Content-Length 로)
|
||||
Future<void> write(String rel, Stream<List<int>> data, {int? length}) async {
|
||||
final req = await _open('PUT', uriOf(rel));
|
||||
if (length != null) req.contentLength = length;
|
||||
await req.addStream(data);
|
||||
final res = await req.close();
|
||||
await res.drain<void>();
|
||||
if (res.statusCode >= 300) _fail('올리기', res);
|
||||
}
|
||||
|
||||
/// 남은 용량 · 쓴 용량 (바이트, 서버가 알려 주지 않으면 null)
|
||||
Future<(int?, int?)> quota() async {
|
||||
try {
|
||||
final xml = await _propfind('/', depth: 0,
|
||||
body: '<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop>'
|
||||
'<d:quota-available-bytes/><d:quota-used-bytes/></d:prop></d:propfind>');
|
||||
final doc = XmlDocument.parse(xml);
|
||||
int? n(String name) => int.tryParse(doc.findAllElements(name, namespaceUri: 'DAV:').firstOrNull?.innerText.trim() ?? '');
|
||||
return (n('quota-available-bytes'), n('quota-used-bytes'));
|
||||
} catch (_) {
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
void close() => _http.close(force: true);
|
||||
|
||||
static String _norm(String rel) {
|
||||
var r = rel.replaceAll('\\', '/').replaceAll(RegExp(r'/+'), '/');
|
||||
if (!r.startsWith('/')) r = '/$r';
|
||||
if (r.length > 1 && r.endsWith('/')) r = r.substring(0, r.length - 1);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
/// 설정의 WebDAV 서버들 (id → 클라이언트). 설정이 바뀌면 [configure].
|
||||
class DavRegistry {
|
||||
static final _clients = <String, DavClient>{};
|
||||
static final _servers = <String, DavServer>{};
|
||||
|
||||
static void configure(List<DavServer> servers) {
|
||||
final keep = {for (final s in servers) s.id: s};
|
||||
for (final id in _clients.keys.toList()) {
|
||||
final old = _servers[id], now = keep[id];
|
||||
if (now == null || old == null || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) {
|
||||
_clients.remove(id)?.close();
|
||||
}
|
||||
}
|
||||
_servers
|
||||
..clear()
|
||||
..addAll(keep);
|
||||
}
|
||||
|
||||
static List<DavServer> get servers => _servers.values.toList();
|
||||
static DavServer? server(String id) => _servers[id];
|
||||
|
||||
static DavClient client(String id) {
|
||||
final s = _servers[id];
|
||||
if (s == null) throw DavException('WebDAV 서버 설정이 없습니다 ($id)');
|
||||
return _clients.putIfAbsent(id, () => DavClient(s));
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ import '../app/transfer_job.dart';
|
|||
import '../core/sync_tools.dart';
|
||||
import '../core/file_ops.dart';
|
||||
import '../core/playlist.dart' show isVideoFile;
|
||||
import '../core/vfs.dart';
|
||||
import '../core/webdav.dart' show DavRegistry;
|
||||
import '../platform/android/android_storage.dart';
|
||||
import 'app_actions.dart';
|
||||
import 'explorer_look.dart';
|
||||
|
|
@ -23,6 +25,7 @@ import 'monitor_page.dart';
|
|||
import 'player_page.dart';
|
||||
import 'rsync_setup.dart';
|
||||
import 'theme.dart';
|
||||
import 'webdav_settings.dart';
|
||||
import '../l10n/tr.dart';
|
||||
|
||||
/// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄.
|
||||
|
|
@ -168,8 +171,14 @@ class _Row {
|
|||
class _ExplorerPageState extends State<ExplorerPage> {
|
||||
AppController get c => widget.c;
|
||||
|
||||
/// 저장 장치: (경로, 이름)
|
||||
List<(String, String)> _volumes = [];
|
||||
/// 이 기기의 저장 장치: (경로, 이름)
|
||||
List<(String, String)> _local = [];
|
||||
|
||||
/// 저장 장치 + WebDAV 서버 (환경 설정 > 파일 탐색기 > WebDAV): (경로, 이름)
|
||||
List<(String, String)> get _volumes => [
|
||||
..._local,
|
||||
for (final s in c.settings.webdavServers) ('$davScheme${s.id}/', s.label),
|
||||
];
|
||||
late final List<_Pane> _panes = [_Pane(''), _Pane('')];
|
||||
int _active = 0;
|
||||
bool _ready = false;
|
||||
|
|
@ -206,11 +215,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
_volumes = await _loadVolumes();
|
||||
if (_volumes.isEmpty) _volumes = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')];
|
||||
_local = await _loadVolumes();
|
||||
if (_local.isEmpty) _local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')];
|
||||
final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorerPaths;
|
||||
for (var i = 0; i < 2; i++) {
|
||||
final want = i < saved.length && Directory(saved[i]).existsSync() ? saved[i] : _volumes[0].$1;
|
||||
final want = i < saved.length && _reachable(saved[i]) ? saved[i] : _local[0].$1;
|
||||
await _goTo(_panes[i], want, remember: false);
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
|
@ -229,10 +238,16 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
return [('/', '/')];
|
||||
}
|
||||
|
||||
/// 다시 열 수 있는 폴더: 로컬은 있으면, WebDAV 는 그 서버가 아직 설정에 있으면 (열 때 확인)
|
||||
bool _reachable(String path) => isDav(path)
|
||||
? DavRegistry.server(DavPath.parse(path).server) != null
|
||||
: Directory(path).existsSync();
|
||||
|
||||
String _volumeOf(String path) {
|
||||
String best = _volumes.first.$1;
|
||||
if (isDav(path)) return '$davScheme${DavPath.parse(path).server}/';
|
||||
String best = _local.first.$1;
|
||||
var len = -1;
|
||||
for (final (v, _) in _volumes) {
|
||||
for (final (v, _) in _local) {
|
||||
final inside = samePath(v, path) || p.isWithin(v, path) ||
|
||||
(Platform.isWindows && p.isWithin(v.toLowerCase(), path.toLowerCase()));
|
||||
if (inside && v.length > len) {
|
||||
|
|
@ -271,11 +286,12 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
Future<void> _goTo(_Pane pane, String dir, {bool remember = true}) async {
|
||||
pane.root = _volumeOf(dir);
|
||||
final chain = <String>[];
|
||||
var d = p.normalize(dir);
|
||||
dir = isDav(dir) ? vNorm(dir) : p.normalize(dir);
|
||||
var d = dir;
|
||||
while (true) {
|
||||
chain.insert(0, d);
|
||||
if (samePath(d, pane.root) || p.dirname(d) == d) break;
|
||||
d = p.dirname(d);
|
||||
if (samePath(d, pane.root) || vDirname(d) == d) break;
|
||||
d = vDirname(d);
|
||||
}
|
||||
for (final x in chain) {
|
||||
pane.expanded.add(x);
|
||||
|
|
@ -385,7 +401,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
}
|
||||
if (e.isDir || !_selectMode) return _onOpen(pane, row, list: list);
|
||||
pane.focused = e.path;
|
||||
if (!list) pane.current = p.dirname(e.path);
|
||||
if (!list) pane.current = vDirname(e.path);
|
||||
pane.changed();
|
||||
}
|
||||
|
||||
|
|
@ -476,7 +492,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
await _toggle(pane, e);
|
||||
return;
|
||||
}
|
||||
if (!list) pane.current = p.dirname(e.path);
|
||||
if (!list) pane.current = vDirname(e.path);
|
||||
pane.changed();
|
||||
await _open(e.path);
|
||||
}
|
||||
|
|
@ -503,27 +519,89 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
/// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램
|
||||
Future<void> _open(String path) async {
|
||||
if (isVideoFile(path)) {
|
||||
await playFiles(context, c, [path]);
|
||||
await _play([path]);
|
||||
return;
|
||||
}
|
||||
final ok = await c.services.shell.openWith(path);
|
||||
final local = await _fetch(path);
|
||||
if (local == null) return;
|
||||
final ok = await c.services.shell.openWith(local);
|
||||
if (!ok) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.'));
|
||||
}
|
||||
|
||||
/// WebDAV 파일은 열거나 재생하려면 임시 폴더로 받는다 (받는 동안 진행 창 · 취소). 로컬은 그대로.
|
||||
Future<String?> _fetch(String path) async {
|
||||
if (!isDav(path)) return path;
|
||||
final temp = await c.services.storage.tempDirectory();
|
||||
if (!mounted) return null;
|
||||
final progress = ValueNotifier<(int, int)>((0, 0));
|
||||
var cancelled = false;
|
||||
final dialog = showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(tr('WebDAV 에서 받는 중')),
|
||||
content: ValueListenableBuilder<(int, int)>(
|
||||
valueListenable: progress,
|
||||
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(vBasename(path)),
|
||||
const SizedBox(height: 10),
|
||||
LinearProgressIndicator(value: v.$2 > 0 ? v.$1 / v.$2 : null),
|
||||
const SizedBox(height: 6),
|
||||
Text('${formatSize(v.$1)}${v.$2 > 0 ? ' / ${formatSize(v.$2)}' : ''}',
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
cancelled = true;
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: Text(tr('취소')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
try {
|
||||
final local = await vLocalCopy(path, temp, onProgress: (d, t) => progress.value = (d, t));
|
||||
return cancelled ? null : local;
|
||||
} catch (e) {
|
||||
if (!cancelled) _snack(trf('받지 못했습니다: {0}', [e]));
|
||||
return null;
|
||||
} finally {
|
||||
if (!cancelled && mounted) Navigator.of(context).pop();
|
||||
await dialog;
|
||||
progress.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 동영상 재생 (WebDAV 는 받은 뒤)
|
||||
Future<void> _play(List<String> paths, {bool internal = false, bool keepOrder = false}) async {
|
||||
final local = <String>[];
|
||||
for (final x in paths) {
|
||||
final l = await _fetch(x);
|
||||
if (l == null) return;
|
||||
local.add(l);
|
||||
}
|
||||
if (!mounted || local.isEmpty) return;
|
||||
await playFiles(context, c, local, internal: internal, keepOrder: keepOrder);
|
||||
}
|
||||
|
||||
Future<void> _menu(_Pane pane, FileEntry e, Offset at) async {
|
||||
setState(() => _active = _panes.indexOf(pane));
|
||||
pane.focused = e.path;
|
||||
pane.changed();
|
||||
final video = !e.isDir && isVideoFile(e.path);
|
||||
final dav = isDav(e.path);
|
||||
final dual = _dual;
|
||||
final items = <(String, IconData, String)>[
|
||||
if (e.isDir) ('open', Icons.folder_open, tr('열기')),
|
||||
if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, tr('다른 창에서 열기')),
|
||||
if (e.isDir) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')),
|
||||
if (e.isDir && !dav) ('playFolder', Icons.play_circle_outline, tr('이 폴더의 동영상 재생')),
|
||||
if (video) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')),
|
||||
if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')),
|
||||
if (!e.isDir && !video) ('openDefault', Icons.launch, tr('기본 앱으로 열기')),
|
||||
if (video) ('addMkv', Icons.playlist_add, tr('MKV 목록에 추가')),
|
||||
if (video && !dav) ('addMkv', Icons.playlist_add, tr('MKV 목록에 추가')),
|
||||
('select', Icons.check_circle_outline, tr('선택')),
|
||||
('rename', Icons.drive_file_rename_outline, tr('이름 변경')),
|
||||
if (dual) ('copy', Icons.copy_outlined, tr('다른 창으로 복사')),
|
||||
|
|
@ -532,9 +610,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
('clipCopy', Icons.content_copy, dual ? tr('복사 (붙여넣기로)') : tr('복사')),
|
||||
('clipMove', Icons.content_cut, dual ? tr('이동 (붙여넣기로)') : tr('이동')),
|
||||
if (_clip.isNotEmpty)
|
||||
('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [p.basename(e.isDir ? e.path : p.dirname(e.path)), _clip.length])),
|
||||
('paste', Icons.content_paste, trf('{0} 에 붙여넣기 ({1}개)', [vBasename(e.isDir ? e.path : vDirname(e.path)), _clip.length])),
|
||||
('delete', Icons.delete_outline, tr('삭제')),
|
||||
if (!Platform.isAndroid || e.isDir) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')),
|
||||
if (!dav && (!Platform.isAndroid || e.isDir)) ('reveal', Icons.folder_outlined, tr('파일 관리자에서 보기')),
|
||||
('info', Icons.info_outline, tr('정보')),
|
||||
];
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
|
|
@ -560,11 +638,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
case 'playFolder':
|
||||
await _playFolder(e.path);
|
||||
case 'playInternal':
|
||||
await playFiles(context, c, [e.path], internal: true);
|
||||
await _play([e.path], internal: true);
|
||||
case 'openWith':
|
||||
if (!await c.services.shell.openWith(e.path, choose: true)) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.'));
|
||||
final local = await _fetch(e.path);
|
||||
if (local != null && !await c.services.shell.openWith(local, choose: true)) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.'));
|
||||
case 'openDefault':
|
||||
if (!await c.services.shell.openWith(e.path)) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.'));
|
||||
final local = await _fetch(e.path);
|
||||
if (local != null && !await c.services.shell.openWith(local)) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.'));
|
||||
case 'addMkv':
|
||||
await _addMkv([e.path]);
|
||||
case 'select':
|
||||
|
|
@ -581,7 +661,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
case 'clipMove':
|
||||
_toClipboard(_isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: true);
|
||||
case 'paste':
|
||||
await _paste(pane, e.isDir ? e.path : p.dirname(e.path));
|
||||
await _paste(pane, e.isDir ? e.path : vDirname(e.path));
|
||||
case 'delete':
|
||||
await _delete(pane, [e.path]);
|
||||
case 'reveal':
|
||||
|
|
@ -627,7 +707,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
switch (b) {
|
||||
case ExplorerButton.up:
|
||||
if (samePath(pane.current, pane.root)) return;
|
||||
final up = p.dirname(pane.current);
|
||||
final up = vDirname(pane.current);
|
||||
pane.expanded.remove(pane.current);
|
||||
await _goTo(pane, up);
|
||||
case ExplorerButton.select:
|
||||
|
|
@ -647,11 +727,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
}
|
||||
case ExplorerButton.play:
|
||||
final t = _targets(pane);
|
||||
final videos = t.where((x) => !FileSystemEntity.isDirectorySync(x) && isVideoFile(x)).toList();
|
||||
final videos = t.where((x) => !vIsDirSync(x) && isVideoFile(x)).toList();
|
||||
if (videos.isNotEmpty) {
|
||||
await playFiles(context, c, videos, keepOrder: pane.marked.isNotEmpty);
|
||||
await _play(videos, keepOrder: pane.marked.isNotEmpty);
|
||||
} else {
|
||||
await _playFolder(t.length == 1 && FileSystemEntity.isDirectorySync(t.first) ? t.first : pane.current);
|
||||
await _playFolder(t.length == 1 && vIsDirSync(t.first) ? t.first : pane.current);
|
||||
}
|
||||
case ExplorerButton.addMkv:
|
||||
final t = _targets(pane);
|
||||
|
|
@ -722,13 +802,17 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
await playFiles(context, c, list);
|
||||
await _play(list);
|
||||
}
|
||||
|
||||
Future<void> _addMkv(List<String> paths) async {
|
||||
if (paths.any(isDav)) {
|
||||
_snack(tr('WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.'));
|
||||
return;
|
||||
}
|
||||
final videos = <String>[];
|
||||
for (final x in paths) {
|
||||
if (FileSystemEntity.isDirectorySync(x)) {
|
||||
if (vIsDirSync(x)) {
|
||||
videos.addAll((await listEntries(x)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path));
|
||||
} else if (isVideoFile(x)) {
|
||||
videos.add(x);
|
||||
|
|
@ -743,11 +827,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
}
|
||||
|
||||
Future<void> _rename(_Pane pane, String path) async {
|
||||
final name = await _askName(tr('이름 변경'), p.basename(path));
|
||||
if (name == null || name == p.basename(path)) return;
|
||||
final name = await _askName(tr('이름 변경'), vBasename(path));
|
||||
if (name == null || name == vBasename(path)) return;
|
||||
try {
|
||||
final now = await FileOps.rename(path, name);
|
||||
await _refreshAll([p.dirname(path)]);
|
||||
await _refreshAll([vDirname(path)]);
|
||||
for (final x in _panes) {
|
||||
if (x.marked.remove(path)) x.marked.add(now);
|
||||
}
|
||||
|
|
@ -769,12 +853,12 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
title: Text(tr('삭제')),
|
||||
// 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더)
|
||||
content: Text(paths.length == 1
|
||||
? '${trf('"{0}" 을(를) 지울까요? 되돌릴 수 없습니다.', [p.basename(paths.first)])}\n${p.dirname(paths.first)}'
|
||||
? '${trf('"{0}" 을(를) 지울까요? 되돌릴 수 없습니다.', [vBasename(paths.first)])}\n${vDirname(paths.first)}'
|
||||
: [
|
||||
trf('{0}개 항목을 지울까요? 되돌릴 수 없습니다.', [paths.length]),
|
||||
for (final x in paths.take(8)) '· ${p.basename(x)}',
|
||||
for (final x in paths.take(8)) '· ${vBasename(x)}',
|
||||
if (paths.length > 8) trf('… 외 {0}개', [paths.length - 8]),
|
||||
{for (final x in paths) p.dirname(x)}.join('\n'),
|
||||
{for (final x in paths) vDirname(x)}.join('\n'),
|
||||
].join('\n')),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||
|
|
@ -796,7 +880,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
x.marked.removeAll(paths);
|
||||
if (_selecting && x.marked.isEmpty) _selecting = false;
|
||||
x.expanded.removeWhere((d) => paths.any((s) => isSameOrInside(d, s)));
|
||||
if (paths.any((s) => isSameOrInside(x.current, s))) x.current = p.dirname(paths.first);
|
||||
if (paths.any((s) => isSameOrInside(x.current, s))) x.current = vDirname(paths.first);
|
||||
}
|
||||
await _refreshAll(paths.map(p.dirname));
|
||||
}
|
||||
|
|
@ -844,6 +928,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
/// (Rsync 화면의 ⇄ 는 둘이 함께)
|
||||
List<TransferJob> _jobs = const [];
|
||||
|
||||
/// 한쪽이라도 WebDAV 면 rsync 대신 앱이 직접 맞춘다 (rsync 는 WebDAV 를 모름)
|
||||
bool _viaDav(Iterable<String> paths) => paths.any(isDav);
|
||||
|
||||
/// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null)
|
||||
Future<String?> _ensureRsync() async {
|
||||
final s = c.settings;
|
||||
|
|
@ -871,7 +958,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
final problem = transferProblem(sources, dest, move: move);
|
||||
if (problem != null) {
|
||||
_snack(problem);
|
||||
pane.marked.removeWhere((m) => FileSystemEntity.typeSync(m) == FileSystemEntityType.notFound);
|
||||
pane.marked.removeWhere((m) => vMissingSync(m));
|
||||
await _refreshAll([dest, ...sources.map(p.dirname)]);
|
||||
for (final x in _panes) {
|
||||
x.changed();
|
||||
|
|
@ -887,7 +974,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(move ? tr('이동') : tr('복사')),
|
||||
content: Text('${trf('{0}개 항목을 다음 폴더로 {1}\n{2}', [sources.length, move ? tr('옮길까요?') : tr('복사할까요?'), dest])}'
|
||||
content: Text('${trf('{0}개 항목을 다음 폴더로 {1}\n{2}', [sources.length, move ? tr('옮길까요?') : tr('복사할까요?'), vDisplay(dest)])}'
|
||||
'\n\n${trf('방법: {0}', [tr(method.label)])}'
|
||||
'${method == CopyMethod.builtin ? '' : ' · ${task.options}'}'
|
||||
'${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'),
|
||||
|
|
@ -899,7 +986,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
);
|
||||
if (go != true || !mounted) return false;
|
||||
String? rsync;
|
||||
if (method == CopyMethod.rsync) {
|
||||
if (method == CopyMethod.rsync && !_viaDav([...sources, dest])) {
|
||||
rsync = await _ensureRsync();
|
||||
if (rsync == null || !mounted) return false;
|
||||
}
|
||||
|
|
@ -954,9 +1041,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
if (problem != null) {
|
||||
_snack(problem);
|
||||
for (final x in _panes) {
|
||||
x.marked.removeWhere((m) => FileSystemEntity.typeSync(m) == FileSystemEntityType.notFound);
|
||||
x.marked.removeWhere((m) => vMissingSync(m));
|
||||
}
|
||||
await _refreshAll([p.dirname(l), p.dirname(r)]);
|
||||
await _refreshAll([vDirname(l), vDirname(r)]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -977,8 +1064,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
width: 560,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
for (final t in tasks) ...[
|
||||
Text('${t.sources.first}/', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text('→ ${t.dest}/', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text('${vDisplay(t.sources.first)}/', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text('→ ${vDisplay(t.dest)}/', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text('rsync ${opts(t)}${removeSource ? ' --remove-source-files' : ''}'
|
||||
'${t.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [t.bandwidthKBps])}' : ''}',
|
||||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)),
|
||||
|
|
@ -986,6 +1073,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
],
|
||||
Text(tr('폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).'),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
if (_viaDav([l, r]))
|
||||
Text(tr('WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).'),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.accent)),
|
||||
if (both)
|
||||
Text(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).'),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim))
|
||||
|
|
@ -1013,7 +1103,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
)),
|
||||
);
|
||||
if (go != true || !mounted) return;
|
||||
final exe = await _ensureRsync();
|
||||
final dav = _viaDav([l, r]);
|
||||
final exe = dav ? '' : await _ensureRsync();
|
||||
if (exe == null || !mounted) return;
|
||||
tasks = [
|
||||
for (final (src, dst) in runs) await center.remember([src], dst, contents: true, method: 'rsync', move: removeSource),
|
||||
|
|
@ -1026,7 +1117,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
}
|
||||
final jobs = <TransferJob>[];
|
||||
for (final t in tasks) {
|
||||
final j = await center.start(t.copyWith(options: opts(t)), rsyncExe: exe);
|
||||
final j = await center.start(t.copyWith(options: opts(t)), rsyncExe: dav ? null : exe);
|
||||
if (j != null) jobs.add(j);
|
||||
}
|
||||
if (jobs.isEmpty || !mounted) return;
|
||||
|
|
@ -1035,9 +1126,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
if (!mounted) return;
|
||||
// 원본 폴더까지 지웠으면 고른 표시도 뺀다
|
||||
for (final x in _panes) {
|
||||
x.marked.removeWhere((m) => FileSystemEntity.typeSync(m) == FileSystemEntityType.notFound);
|
||||
x.marked.removeWhere((m) => vMissingSync(m));
|
||||
}
|
||||
await _refreshAll([l, r, p.dirname(l), p.dirname(r)]);
|
||||
await _refreshAll([l, r, vDirname(l), vDirname(r)]);
|
||||
for (final x in _panes) {
|
||||
x.changed();
|
||||
}
|
||||
|
|
@ -1046,7 +1137,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
? tr('취소했습니다.')
|
||||
: error != null
|
||||
? trf('끝나지 못했습니다: {0}', [error])
|
||||
: trf('rsync 끝: {0}', [jobs.map((j) => '${p.basename(j.sources.first)} → ${p.basename(j.dest)}').join(' · ')]));
|
||||
: trf('rsync 끝: {0}', [jobs.map((j) => '${vBasename(j.sources.first)} → ${vBasename(j.dest)}').join(' · ')]));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1200));
|
||||
if (mounted && identical(_jobs, jobs)) setState(() => _jobs = const []);
|
||||
}
|
||||
|
|
@ -1147,7 +1238,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
].join(' · '),
|
||||
),
|
||||
line(
|
||||
trf('{0} 전체', [job.currentName.isEmpty ? p.basename(job.sources.first) : job.currentName]),
|
||||
trf('{0} 전체', [job.currentName.isEmpty ? vBasename(job.sources.first) : job.currentName]),
|
||||
job.folderProgress,
|
||||
'${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.allDone, job.allFiles])}',
|
||||
),
|
||||
|
|
@ -1198,7 +1289,7 @@ 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 && 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;
|
||||
if (!mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
|
|
@ -1223,7 +1314,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
builder: (ctx) => _SearchDialog(root: pane.current, query: q, showHidden: c.settings.explorerShowHidden),
|
||||
);
|
||||
if (found == null) return;
|
||||
await _goTo(pane, found.isDir ? found.path : p.dirname(found.path));
|
||||
await _goTo(pane, found.isDir ? found.path : vDirname(found.path));
|
||||
pane.focused = found.path;
|
||||
pane.changed();
|
||||
}
|
||||
|
|
@ -1269,7 +1360,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
}
|
||||
|
||||
Future<void> _historyDialog(_Pane pane) async {
|
||||
final hist = c.settings.explorerHistory.where((h) => Directory(h).existsSync()).toList();
|
||||
final hist = c.settings.explorerHistory.where(_reachable).toList();
|
||||
final pick = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => SimpleDialog(
|
||||
|
|
@ -1463,7 +1554,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(_ready ? _pane.current : '',
|
||||
child: Text(_ready ? vDisplay(_pane.current) : '',
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)),
|
||||
),
|
||||
PopupMenuButton<ExplorerButton>(
|
||||
|
|
@ -1614,7 +1705,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
final entries = pane.cache[dir] ?? const <FileEntry>[];
|
||||
final up = !samePath(dir, pane.root);
|
||||
final rows = [
|
||||
if (up) _Row(FileEntry(p.dirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true),
|
||||
if (up) _Row(FileEntry(vDirname(dir), isDir: true, modified: DateTime(0)), 0, isUp: true),
|
||||
for (final e in entries) _Row(e, 0),
|
||||
];
|
||||
final look = _look;
|
||||
|
|
@ -1660,18 +1751,54 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
backgroundColor: samePath(pane.root, path) ? JjColors.accent.withValues(alpha: 0.18) : null,
|
||||
foregroundColor: samePath(pane.root, path) ? JjColors.accent : null,
|
||||
),
|
||||
icon: Icon(Platform.isWindows ? Icons.storage : Icons.sd_storage_outlined, size: 16),
|
||||
icon: Icon(
|
||||
isDav(path)
|
||||
? Icons.cloud_outlined
|
||||
: Platform.isWindows
|
||||
? Icons.storage
|
||||
: Icons.sd_storage_outlined,
|
||||
size: 16),
|
||||
label: Text(label),
|
||||
onPressed: () {
|
||||
setState(() => _active = _panes.indexOf(pane));
|
||||
_goTo(pane, path);
|
||||
},
|
||||
onLongPress: isDav(path) ? () => _editDav(pane, path) : null,
|
||||
),
|
||||
),
|
||||
if (c.settings.webdavServers.isEmpty)
|
||||
TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
),
|
||||
icon: const Icon(Icons.cloud_outlined, size: 16),
|
||||
label: const Text('WebDAV'),
|
||||
onPressed: () => _editDav(pane, null),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
tooltip: tr('WebDAV 서버 추가'),
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
onPressed: () => _editDav(pane, null),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// WebDAV 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
|
||||
Future<void> _editDav(_Pane pane, String? root) async {
|
||||
setState(() => _active = _panes.indexOf(pane));
|
||||
final old = root == null ? null : DavRegistry.server(DavPath.parse(root).server);
|
||||
final saved = await editDavServer(context, c, old: old);
|
||||
if (saved == null || !mounted) return;
|
||||
for (final x in _panes) {
|
||||
x.cache.removeWhere((k, _) => isDav(k) && DavPath.parse(k).server == saved.id);
|
||||
}
|
||||
await _goTo(pane, '$davScheme${saved.id}/');
|
||||
}
|
||||
|
||||
/// 한 줄. [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이)
|
||||
Widget _rowView(_Pane pane, _Row row, {bool list = false, bool compact = false}) {
|
||||
final e = row.entry;
|
||||
|
|
@ -1826,9 +1953,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
Widget _icon(FileEntry e, bool isRoot, bool open, {bool up = false}) {
|
||||
final look = _look;
|
||||
if (up) return Icon(Icons.arrow_upward, size: look.iconSize, color: JjColors.textDim);
|
||||
if (isRoot) return Icon(Icons.sd_storage, color: JjColors.accent, size: look.iconSize - 2);
|
||||
// X-plore: 동영상 썸네일 · 그림 미리 보기
|
||||
if (look.rich && !e.isDir) {
|
||||
if (isRoot) return Icon(isDav(e.path) ? Icons.cloud : Icons.sd_storage, color: JjColors.accent, size: look.iconSize - 2);
|
||||
// X-plore: 동영상 썸네일 · 그림 미리 보기 (WebDAV 는 받아야 하므로 아이콘만)
|
||||
if (look.rich && !e.isDir && !isDav(e.path)) {
|
||||
if (isVideoFile(e.path)) return _Thumb(c: c, entry: e);
|
||||
const images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'};
|
||||
if (images.contains(e.ext)) {
|
||||
|
|
@ -1953,7 +2080,7 @@ class _SearchDialogState extends State<_SearchDialog> {
|
|||
leading: Icon(e.isDir ? Icons.folder : Icons.insert_drive_file_outlined,
|
||||
color: e.isDir ? Colors.amber : null),
|
||||
title: Text(e.name),
|
||||
subtitle: Text(p.dirname(e.path), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(vDirname(e.path), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
onTap: () => Navigator.pop(context, e),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import '../app/settings.dart';
|
|||
import '../app/transfer_job.dart';
|
||||
import '../core/file_ops.dart';
|
||||
import '../core/sync_tools.dart';
|
||||
import '../core/vfs.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import 'app_actions.dart';
|
||||
import 'copy_sync_settings.dart' show addLiveSyncPairDialog, LiveSyncRunOptions;
|
||||
|
|
@ -357,13 +358,13 @@ class _CopyCardState extends State<_CopyCard> {
|
|||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(
|
||||
'${t.sources.length == 1 ? t.sources.first : trf('{0} 외 {1}개', [p.basename(t.sources.first), t.sources.length - 1])}'
|
||||
'${t.sources.length == 1 ? vDisplay(t.sources.first) : trf('{0} 외 {1}개', [vBasename(t.sources.first), t.sources.length - 1])}'
|
||||
'${t.contents ? '/' : ''}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text('→ ${t.dest}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text('→ ${vDisplay(t.dest)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
),
|
||||
Chip(
|
||||
|
|
@ -628,8 +629,8 @@ class _LiveTabState extends State<_LiveTab> {
|
|||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('${x.source}/', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text('→ ${x.target}/', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text('${vDisplay(x.source)}/', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text('→ ${vDisplay(x.target)}/', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
),
|
||||
Chip(
|
||||
|
|
@ -777,7 +778,7 @@ Future<void> askLiveSyncStart(BuildContext context, LiveSync live) async {
|
|||
contentPadding: EdgeInsets.zero,
|
||||
value: pick.contains(LiveSync.keyOf(x)),
|
||||
onChanged: (v) => set(() => v == true ? pick.add(LiveSync.keyOf(x)) : pick.remove(LiveSync.keyOf(x))),
|
||||
title: Text('${x.source} → ${x.target}', maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
title: Text('${vDisplay(x.source)} → ${vDisplay(x.target)}', maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(scheduleSummary(x.schedule)),
|
||||
),
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import 'app_actions.dart';
|
|||
import 'cef_setup.dart';
|
||||
import 'cleanup_dialog.dart';
|
||||
import 'copy_sync_settings.dart';
|
||||
import 'webdav_settings.dart';
|
||||
import 'explorer_look.dart' show ExplorerStyle;
|
||||
import 'theme.dart';
|
||||
import '../l10n/tr.dart';
|
||||
|
|
@ -606,6 +607,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
),
|
||||
// 복사 · 이동 방법 (현재 방식 · robocopy) · 속도 제한
|
||||
CopySyncSettings(c: c),
|
||||
// WebDAV 서버 (탐색기 · Rsync 화면 위쪽 탭)
|
||||
WebDavSettings(c: c),
|
||||
]),
|
||||
// Rsync 화면: rsync 옵션 · 가져오기 · 실시간 동기화 (lsync) · 백그라운드로 실행
|
||||
_group('rsync', Icons.sync_alt, 'Rsync', [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_controller.dart';
|
||||
import '../core/webdav.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// 환경 설정 > 파일 탐색기 > WebDAV: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인).
|
||||
/// 서버마다 파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆에 탭이 생긴다.
|
||||
class WebDavSettings extends StatelessWidget {
|
||||
final AppController c;
|
||||
const WebDavSettings({super.key, required this.c});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final servers = c.settings.webdavServers;
|
||||
return Column(children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cloud_outlined),
|
||||
title: const Text('WebDAV'),
|
||||
subtitle: Text(tr('NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 '
|
||||
'(위쪽 "SD 카드" 옆 탭). 비밀번호는 이 기기의 설정 파일에만 저장합니다.')),
|
||||
trailing: TextButton.icon(
|
||||
onPressed: () => editDavServer(context, c),
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: Text(tr('추가')),
|
||||
),
|
||||
),
|
||||
for (final s in servers)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.cloud, color: JjColors.accent),
|
||||
title: Text(s.label),
|
||||
subtitle: Text('${s.url}${s.user.isEmpty ? '' : ' · ${s.user}'}${s.insecure ? ' · ${tr('인증서 확인 안 함')}' : ''}',
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
IconButton(
|
||||
tooltip: tr('고치기'),
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
onPressed: () => editDavServer(context, c, old: s),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: tr('지우기'),
|
||||
icon: const Icon(Icons.delete_outline, size: 18, color: JjColors.textDim),
|
||||
onPressed: () => c.updateSettings((x) => x.webdavServers = [for (final y in x.webdavServers) if (y.id != s.id) y]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// WebDAV 서버 추가 · 고치기 창. 저장한 서버를 돌려준다 (취소면 null).
|
||||
Future<DavServer?> editDavServer(BuildContext context, AppController c, {DavServer? old}) async {
|
||||
final name = TextEditingController(text: old?.name ?? '');
|
||||
final url = TextEditingController(text: old?.url ?? 'https://');
|
||||
final user = TextEditingController(text: old?.user ?? '');
|
||||
final pass = TextEditingController(text: old?.password ?? '');
|
||||
var insecure = old?.insecure ?? false;
|
||||
var hide = true;
|
||||
String? result; // 연결 확인 결과
|
||||
var passed = false;
|
||||
var testing = false;
|
||||
|
||||
DavServer current() => DavServer(
|
||||
id: old?.id ?? 'dav${DateTime.now().microsecondsSinceEpoch}',
|
||||
name: name.text.trim(),
|
||||
url: url.text.trim(),
|
||||
user: user.text.trim(),
|
||||
password: pass.text,
|
||||
insecure: insecure,
|
||||
);
|
||||
|
||||
final saved = await showDialog<DavServer>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, set) {
|
||||
Future<void> test() async {
|
||||
set(() {
|
||||
testing = true;
|
||||
result = null;
|
||||
});
|
||||
final client = DavClient(current());
|
||||
try {
|
||||
final items = await client.list('/');
|
||||
final (free, _) = await client.quota();
|
||||
passed = true;
|
||||
result = trf('연결됨: 항목 {0}개{1}', [items.length, free == null ? '' : ' · ${tr('남은 용량')} ${(free / (1 << 30)).toStringAsFixed(1)}GB']);
|
||||
} catch (e) {
|
||||
passed = false;
|
||||
result = trf('연결 실패: {0}', [e]);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
if (ctx.mounted) set(() => testing = false);
|
||||
}
|
||||
|
||||
final ok = Uri.tryParse(url.text.trim())?.hasAuthority ?? false;
|
||||
return AlertDialog(
|
||||
title: Text(old == null ? tr('WebDAV 서버 추가') : tr('WebDAV 서버 고치기')),
|
||||
content: SizedBox(
|
||||
width: 520,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
TextField(controller: name, decoration: InputDecoration(labelText: tr('이름 (탭에 보임, 비우면 주소)'))),
|
||||
TextField(
|
||||
controller: url,
|
||||
keyboardType: TextInputType.url,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('주소'),
|
||||
helperText: tr('예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/아이디'),
|
||||
),
|
||||
onChanged: (_) => set(() {}),
|
||||
),
|
||||
TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디'))),
|
||||
TextField(
|
||||
controller: pass,
|
||||
obscureText: hide,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('비밀번호'),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18),
|
||||
onPressed: () => set(() => hide = !hide),
|
||||
),
|
||||
),
|
||||
),
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: insecure,
|
||||
onChanged: (v) => set(() => insecure = v ?? false),
|
||||
title: Text(tr('인증서 확인 안 함')),
|
||||
subtitle: Text(tr('자체 서명 인증서를 쓰는 집 NAS 등 (믿을 수 있는 서버에서만)')),
|
||||
),
|
||||
if (testing) const LinearProgressIndicator(),
|
||||
if (result != null)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(result!,
|
||||
style: TextStyle(fontSize: 12, color: passed ? Colors.greenAccent : Colors.redAccent)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: !ok || testing ? null : test, child: Text(tr('연결 확인'))),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
|
||||
FilledButton(onPressed: ok ? () => Navigator.pop(ctx, current()) : null, child: Text(tr('저장'))),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (saved == null) return null;
|
||||
await c.updateSettings((x) => x.webdavServers = [
|
||||
for (final y in x.webdavServers) y.id == saved.id ? saved : y,
|
||||
if (!x.webdavServers.any((y) => y.id == saved.id)) saved,
|
||||
]);
|
||||
return saved;
|
||||
}
|
||||
|
|
@ -1001,7 +1001,7 @@ packages:
|
|||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: xml
|
||||
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ dependencies:
|
|||
screen_retriever: ^0.2.2
|
||||
package_info_plus: ^10.2.1
|
||||
crypto: ^3.0.7
|
||||
# WebDAV 목록 (PROPFIND 응답) 읽기
|
||||
xml: ^7.0.1
|
||||
flutter_inappwebview: ^6.1.5
|
||||
ffmpeg_kit_flutter_new_min_gpl: 2.6.2
|
||||
# Android 빌드 도구 (AGP 9) 에 맞게 고친 사본 (packages/flutter_inappwebview_android/JJ_PATCH.md)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/core/webdav.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||
import 'package:jj_mkvmaker/services/app_shell.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/explorer_page.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'explorer_test.dart' show act, doubleTap, settle;
|
||||
import 'support/dav_server.dart';
|
||||
|
||||
class _Shell extends NoopShell {
|
||||
final opened = <String>[];
|
||||
@override
|
||||
Future<bool> openWith(String path, {bool choose = false}) async {
|
||||
opened.add(path);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late Directory tmp, remote;
|
||||
late String left;
|
||||
late TestDavServer server;
|
||||
late _Shell shell;
|
||||
late AppController c;
|
||||
|
||||
setUp(() async {
|
||||
tmp = Directory.systemTemp.createTempSync('jj_explorer_dav_');
|
||||
left = p.join(tmp.path, 'left');
|
||||
remote = Directory(p.join(tmp.path, 'remote'))..createSync();
|
||||
File(p.join(left, 'doc.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('hello');
|
||||
File(p.join(left, 'sub', 'inner.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('inner');
|
||||
File(p.join(remote.path, '원격', 'note.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('remote');
|
||||
server = TestDavServer(remote);
|
||||
await server.start();
|
||||
shell = _Shell();
|
||||
c = AppController(PlatformServices(
|
||||
mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: shell));
|
||||
});
|
||||
tearDown(() async {
|
||||
DavRegistry.configure([]);
|
||||
await server.stop();
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
Future<void> pump(WidgetTester tester, Widget page) async {
|
||||
HttpOverrides.global = null; // testWidgets 는 HttpClient 를 막아 둔다 - 이 시험은 실제 (로컬) 서버에 연결
|
||||
tester.view.physicalSize = const Size(1600, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: page)));
|
||||
}
|
||||
|
||||
testWidgets('파일 탐색기: WebDAV 탭 → 서버 추가 (연결 확인) → 목록 · 열기 (받아서) · 로컬 → WebDAV 복사', (tester) async {
|
||||
c.settings.explorerPaths = [left, left];
|
||||
await pump(tester, ExplorerPage(c: c));
|
||||
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
|
||||
// 서버가 없으면 위쪽 저장 장치 옆에 "WebDAV" 탭 (창마다)
|
||||
expect(find.text('WebDAV'), findsNWidgets(2));
|
||||
await act(tester, () => tester.tap(find.text('WebDAV').last));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('WebDAV 서버 추가'), findsOneWidget);
|
||||
await tester.enterText(find.widgetWithText(TextField, '이름 (탭에 보임, 비우면 주소)'), '집 NAS');
|
||||
await tester.enterText(find.widgetWithText(TextField, '주소'), server.url);
|
||||
await tester.enterText(find.widgetWithText(TextField, '아이디'), 'user');
|
||||
await tester.enterText(find.widgetWithText(TextField, '비밀번호'), 'pass');
|
||||
await tester.pump();
|
||||
await act(tester, () => tester.tap(find.text('연결 확인')));
|
||||
await settle(tester, () => find.textContaining('연결됨').evaluate().isNotEmpty);
|
||||
expect(find.textContaining('연결됨: 항목 1개'), findsOneWidget);
|
||||
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '저장')));
|
||||
// 저장하면 설정에 남고 오른쪽 창이 그 서버로
|
||||
await settle(tester, () => find.text('원격').evaluate().isNotEmpty);
|
||||
expect(c.settings.webdavServers.single.url, server.url);
|
||||
final id = c.settings.webdavServers.single.id;
|
||||
expect(find.text('집 NAS'), findsNWidgets(3)); // 두 창의 탭 + 오른쪽 창의 맨 위 줄
|
||||
expect(find.text('WebDAV'), findsNothing);
|
||||
|
||||
// WebDAV 폴더 펼치기 · 파일 두 번 누르기 = 임시 폴더로 받아 기본 앱으로
|
||||
await act(tester, () => tester.tap(find.text('원격')));
|
||||
await settle(tester, () => find.text('note.txt').evaluate().isNotEmpty);
|
||||
await doubleTap(tester, find.text('note.txt'));
|
||||
await settle(tester, () => shell.opened.isNotEmpty);
|
||||
expect(p.split(shell.opened.single), containsAllInOrder(['jj_webdav', id, '원격', 'note.txt']));
|
||||
expect(File(shell.opened.single).readAsStringSync(), 'remote');
|
||||
|
||||
// 왼쪽 doc.txt → 오른쪽 (WebDAV 원격 폴더) 으로 복사
|
||||
await act(tester, () => tester.tap(find.text('원격')));
|
||||
await settle(tester, () => false, rounds: 15);
|
||||
await act(tester, () => tester.tap(find.text('doc.txt')));
|
||||
await settle(tester, () => false, rounds: 15);
|
||||
await act(tester, () => tester.tap(find.text('복사').first));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('☁ 집 NAS/원격'), findsOneWidget);
|
||||
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사')));
|
||||
final up = File(p.join(remote.path, '원격', 'doc.txt'));
|
||||
await settle(tester, () => up.existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty);
|
||||
expect(up.readAsStringSync(), 'hello');
|
||||
});
|
||||
|
||||
testWidgets('Rsync 화면: 로컬 ↔ WebDAV 폴더 → (앱이 직접 맞춤 · rsync 필요 없음)', (tester) async {
|
||||
final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass');
|
||||
c.settings
|
||||
..webdavServers = [s]
|
||||
..rsyncPaths = [left, 'dav://nas/'];
|
||||
DavRegistry.configure([s]);
|
||||
await pump(tester, ExplorerPage(c: c, rsync: true));
|
||||
await settle(tester, () => find.text('sub').evaluate().isNotEmpty && find.text('원격').evaluate().isNotEmpty);
|
||||
expect(find.byIcon(Icons.cloud), findsOneWidget); // 오른쪽 창 맨 위 줄
|
||||
await act(tester, () => tester.tap(find.text('sub')));
|
||||
await act(tester, () => tester.tap(find.text('원격')));
|
||||
await settle(tester, () => false, rounds: 5);
|
||||
await act(tester, () => tester.tap(find.text('좌 → 우')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('→ ☁ 집 NAS/원격/'), findsOneWidget);
|
||||
expect(find.textContaining('WebDAV: rsync 대신 앱이 직접 맞춥니다'), findsOneWidget);
|
||||
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '실행')));
|
||||
final got = File(p.join(remote.path, '원격', 'inner.txt'));
|
||||
await settle(tester, () => got.existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 200);
|
||||
expect(got.readAsStringSync(), 'inner');
|
||||
expect(File(p.join(remote.path, '원격', 'note.txt')).existsSync(), isTrue); // 지우기 없이
|
||||
expect(c.settings.copyTasks.single.dest, 'dav://nas/원격'); // 모니터링에 남음
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/core/file_ops.dart';
|
||||
import 'package:jj_mkvmaker/core/vfs.dart';
|
||||
import 'package:jj_mkvmaker/core/webdav.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 실제 WebDAV 서버 (예: wsgidav · NAS) 로 확인. JJ_REAL_DAV=주소 (JJ_REAL_DAV_USER · JJ_REAL_DAV_PASS) 일 때만.
|
||||
/// 서버의 맨 위에 jj_real_dav_test 폴더를 만들고 끝나면 지운다.
|
||||
void main() {
|
||||
final url = Platform.environment['JJ_REAL_DAV'];
|
||||
final skip = url == null ? 'JJ_REAL_DAV 일 때만' : null;
|
||||
const id = 'real';
|
||||
const top = 'dav://$id/jj_real_dav_test';
|
||||
late Directory tmp;
|
||||
|
||||
setUp(() async {
|
||||
tmp = Directory.systemTemp.createTempSync('jj_real_dav_');
|
||||
DavRegistry.configure([
|
||||
DavServer(id: id, name: 'real', url: url!, user: Platform.environment['JJ_REAL_DAV_USER'] ?? '',
|
||||
password: Platform.environment['JJ_REAL_DAV_PASS'] ?? ''),
|
||||
]);
|
||||
if (await vExists(top)) await vDelete(top);
|
||||
await vMkdirs(top);
|
||||
});
|
||||
tearDown(() async {
|
||||
if (await vExists(top)) await vDelete(top);
|
||||
DavRegistry.configure([]);
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('실제 서버: 올리기 · 목록 · 동기화 · 이름 바꾸기 · 이동 · 받기 · 지우기 (한글 · 빈칸 이름)', () async {
|
||||
final src = Directory(p.join(tmp.path, '원본 폴더'));
|
||||
File(p.join(src.path, 'a b.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('aa');
|
||||
File(p.join(src.path, '하위', '큰.bin'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsBytesSync(List.filled(3 << 20, 7));
|
||||
final ops = FileOps();
|
||||
expect(await ops.copy([src.path], top), ['$top/원본 폴더']);
|
||||
final list = sortEntries(await listEntries('$top/원본 폴더'), SortBy.name);
|
||||
expect(list.map((e) => (e.name, e.isDir, e.size)), [('하위', true, 0), ('a b.txt', false, 2)]);
|
||||
expect((await vStat('$top/원본 폴더/하위/큰.bin'))!.size, 3 << 20);
|
||||
// 동기화: 바뀐 것 없음 → 0, 하나 바꾸면 1
|
||||
expect(await ops.mirror(src.path, '$top/원본 폴더'), 0);
|
||||
File(p.join(src.path, 'a b.txt')).writeAsStringSync('aaaa');
|
||||
expect(await ops.mirror(src.path, '$top/원본 폴더'), 1);
|
||||
// 이름 바꾸기 · 같은 서버 안 이동
|
||||
expect(await FileOps.rename('$top/원본 폴더/a b.txt', '새 이름.txt'), '$top/원본 폴더/새 이름.txt');
|
||||
await FileOps.makeFolder(top, '상자');
|
||||
await ops.move(['$top/원본 폴더/하위'], '$top/상자');
|
||||
expect(await vIsDir('$top/상자/하위'), isTrue);
|
||||
expect(await vExists('$top/원본 폴더/하위'), isFalse);
|
||||
// 받기 (원격 → 로컬 동기화 · 임시로 받기)
|
||||
final back = p.join(tmp.path, 'back');
|
||||
expect(await ops.mirror(top, back), 2);
|
||||
expect(File(p.join(back, '원본 폴더', '새 이름.txt')).readAsStringSync(), 'aaaa');
|
||||
expect(await ops.mirror(top, back), 0);
|
||||
final got = await vLocalCopy('$top/상자/하위/큰.bin', p.join(tmp.path, 'cache'));
|
||||
expect(File(got).lengthSync(), 3 << 20);
|
||||
expect(await vCountFiles(top), 2);
|
||||
// 빈 폴더 정리 · 지우기
|
||||
await vMkdirs('$top/빈/더 빈');
|
||||
expect(await removeEmptyDirs(top, keepRoot: true), 2);
|
||||
await ops.delete(['$top/상자']);
|
||||
expect(await vExists('$top/상자'), isFalse);
|
||||
}, skip: skip, timeout: const Timeout(Duration(minutes: 3)));
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 시험용 작은 WebDAV 서버 (실제 HTTP): [root] 폴더를 /dav/ 아래로 내보낸다. 기본 인증 (user / pass).
|
||||
/// PROPFIND (Depth 0 · 1) · GET · PUT · MKCOL · DELETE · MOVE · COPY · quota.
|
||||
class TestDavServer {
|
||||
final Directory root;
|
||||
final String user, pass;
|
||||
late HttpServer _server;
|
||||
final requests = <String>[];
|
||||
|
||||
TestDavServer(this.root, {this.user = 'user', this.pass = 'pass'});
|
||||
|
||||
String get url => 'http://127.0.0.1:${_server.port}/dav';
|
||||
|
||||
Future<void> start() async {
|
||||
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
_server.listen(_handle);
|
||||
}
|
||||
|
||||
Future<void> stop() => _server.close(force: true);
|
||||
|
||||
String _local(String uriPath) {
|
||||
var rel = Uri.decodeFull(uriPath);
|
||||
if (rel.startsWith('/dav')) rel = rel.substring(4);
|
||||
rel = rel.replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
return rel.isEmpty ? root.path : p.join(root.path, p.joinAll(rel.split('/')));
|
||||
}
|
||||
|
||||
String _href(String local, bool dir) {
|
||||
final rel = p.relative(local, from: root.path).replaceAll('\\', '/');
|
||||
final parts = rel == '.' ? <String>[] : rel.split('/');
|
||||
return '/dav/${parts.map(Uri.encodeComponent).join('/')}${dir && parts.isNotEmpty ? '/' : ''}';
|
||||
}
|
||||
|
||||
String _entry(FileSystemEntity e) {
|
||||
final dir = e is Directory;
|
||||
final st = e.statSync();
|
||||
return '<d:response><d:href>${_href(e.path, dir)}</d:href><d:propstat><d:prop>'
|
||||
'<d:resourcetype>${dir ? '<d:collection/>' : ''}</d:resourcetype>'
|
||||
'${dir ? '' : '<d:getcontentlength>${st.size}</d:getcontentlength>'}'
|
||||
'<d:getlastmodified>${HttpDate.format(st.modified)}</d:getlastmodified>'
|
||||
'<d:quota-available-bytes>1000000</d:quota-available-bytes><d:quota-used-bytes>500</d:quota-used-bytes>'
|
||||
'</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
|
||||
}
|
||||
|
||||
Future<void> _handle(HttpRequest req) async {
|
||||
final res = req.response;
|
||||
requests.add('${req.method} ${req.uri.path}');
|
||||
final auth = req.headers.value(HttpHeaders.authorizationHeader);
|
||||
if (auth != 'Basic ${base64Encode(utf8.encode('$user:$pass'))}') {
|
||||
res.statusCode = 401;
|
||||
await res.close();
|
||||
return;
|
||||
}
|
||||
final local = _local(req.uri.path);
|
||||
final type = FileSystemEntity.typeSync(local);
|
||||
try {
|
||||
switch (req.method) {
|
||||
case 'PROPFIND':
|
||||
await req.drain<void>();
|
||||
if (type == FileSystemEntityType.notFound) {
|
||||
res.statusCode = 404;
|
||||
break;
|
||||
}
|
||||
final depth = req.headers.value('Depth') ?? '1';
|
||||
final items = <FileSystemEntity>[type == FileSystemEntityType.directory ? Directory(local) : File(local)];
|
||||
if (depth != '0' && type == FileSystemEntityType.directory) items.addAll(Directory(local).listSync());
|
||||
res.statusCode = 207;
|
||||
res.headers.contentType = ContentType('application', 'xml', charset: 'utf-8');
|
||||
res.write('<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:">${items.map(_entry).join()}</d:multistatus>');
|
||||
case 'GET':
|
||||
if (type != FileSystemEntityType.file) {
|
||||
res.statusCode = 404;
|
||||
break;
|
||||
}
|
||||
res.contentLength = File(local).lengthSync();
|
||||
await res.addStream(File(local).openRead());
|
||||
case 'PUT':
|
||||
if (!Directory(p.dirname(local)).existsSync()) {
|
||||
await req.drain<void>();
|
||||
res.statusCode = 409;
|
||||
break;
|
||||
}
|
||||
final out = File(local).openWrite();
|
||||
await out.addStream(req);
|
||||
await out.close();
|
||||
res.statusCode = 201;
|
||||
case 'MKCOL':
|
||||
await req.drain<void>();
|
||||
if (type != FileSystemEntityType.notFound) {
|
||||
res.statusCode = 405;
|
||||
} else if (!Directory(p.dirname(local)).existsSync()) {
|
||||
res.statusCode = 409;
|
||||
} else {
|
||||
Directory(local).createSync();
|
||||
res.statusCode = 201;
|
||||
}
|
||||
case 'DELETE':
|
||||
await req.drain<void>();
|
||||
if (type == FileSystemEntityType.directory) {
|
||||
Directory(local).deleteSync(recursive: true);
|
||||
} else if (type == FileSystemEntityType.file) {
|
||||
File(local).deleteSync();
|
||||
} else {
|
||||
res.statusCode = 404;
|
||||
break;
|
||||
}
|
||||
res.statusCode = 204;
|
||||
case 'MOVE' || 'COPY':
|
||||
await req.drain<void>();
|
||||
final dest = _local(Uri.parse(req.headers.value('Destination')!).path);
|
||||
if (FileSystemEntity.typeSync(dest) != FileSystemEntityType.notFound && req.headers.value('Overwrite') == 'F') {
|
||||
res.statusCode = 412;
|
||||
break;
|
||||
}
|
||||
if (req.method == 'MOVE') {
|
||||
if (type == FileSystemEntityType.directory) {
|
||||
Directory(local).renameSync(dest);
|
||||
} else {
|
||||
File(local).renameSync(dest);
|
||||
}
|
||||
} else {
|
||||
if (type == FileSystemEntityType.directory) {
|
||||
_copyDir(Directory(local), Directory(dest));
|
||||
} else {
|
||||
File(local).copySync(dest);
|
||||
}
|
||||
}
|
||||
res.statusCode = 201;
|
||||
default:
|
||||
await req.drain<void>();
|
||||
res.statusCode = 405;
|
||||
}
|
||||
} catch (e) {
|
||||
res.statusCode = 500;
|
||||
res.write('$e');
|
||||
}
|
||||
await res.close();
|
||||
}
|
||||
|
||||
static void _copyDir(Directory from, Directory to) {
|
||||
to.createSync(recursive: true);
|
||||
for (final e in from.listSync()) {
|
||||
final t = p.join(to.path, p.basename(e.path));
|
||||
if (e is Directory) {
|
||||
_copyDir(e, Directory(t));
|
||||
} else if (e is File) {
|
||||
e.copySync(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/live_sync.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/file_ops.dart';
|
||||
import 'package:jj_mkvmaker/core/vfs.dart';
|
||||
import 'package:jj_mkvmaker/core/webdav.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/dav_server.dart';
|
||||
|
||||
void main() {
|
||||
late Directory tmp, remote, local;
|
||||
late TestDavServer server;
|
||||
const id = 'nas';
|
||||
|
||||
setUp(() async {
|
||||
tmp = Directory.systemTemp.createTempSync('jj_dav_');
|
||||
remote = Directory(p.join(tmp.path, 'remote'))..createSync();
|
||||
local = Directory(p.join(tmp.path, 'local'))..createSync();
|
||||
server = TestDavServer(remote);
|
||||
await server.start();
|
||||
DavRegistry.configure([DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]);
|
||||
});
|
||||
tearDown(() async {
|
||||
DavRegistry.configure([]);
|
||||
await server.stop();
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
File lf(String rel, [String text = 'x']) => File(p.join(local.path, rel))
|
||||
..parent.createSync(recursive: true)
|
||||
..writeAsStringSync(text);
|
||||
File rf(String rel, [String text = 'x']) => File(p.join(remote.path, rel))
|
||||
..parent.createSync(recursive: true)
|
||||
..writeAsStringSync(text);
|
||||
|
||||
test('경로: dav://서버/경로 정리 · 이어 붙이기 · 같은지 · 안에 있는지', () {
|
||||
expect(DavPath.parse(r'dav://nas/a\b/').full, 'dav://nas/a/b');
|
||||
expect(DavPath.parse('dav://nas').full, 'dav://nas/');
|
||||
expect(vJoin('dav://nas/', '한글 폴더'), 'dav://nas/한글 폴더');
|
||||
expect(vDirname('dav://nas/a/b'), 'dav://nas/a');
|
||||
expect(vDirname('dav://nas/a'), 'dav://nas/');
|
||||
expect(vBasename('dav://nas/a/b.mkv'), 'b.mkv');
|
||||
expect(samePath('dav://nas/a/', r'dav://nas/a'), isTrue);
|
||||
expect(isSameOrInside('dav://nas/a/b', 'dav://nas/a'), isTrue);
|
||||
expect(isSameOrInside('dav://nas/ab', 'dav://nas/a'), isFalse);
|
||||
expect(isSameOrInside('dav://nas/a', 'dav://other/a'), isFalse);
|
||||
expect(samePath('dav://nas/a', r'C:\a'), isFalse);
|
||||
});
|
||||
|
||||
test('목록 · 폴더 만들기 · 이름 바꾸기 · 지우기 (한글 이름 · 인증)', () async {
|
||||
rf('영상/a.mkv', 'aaaa');
|
||||
rf('영상/sub/b.txt', 'b');
|
||||
final top = await listEntries('dav://$id/');
|
||||
expect(top.map((e) => (e.name, e.isDir)), [('영상', true)]);
|
||||
final inside = sortEntries(await listEntries('dav://$id/영상'), SortBy.name);
|
||||
expect(inside.map((e) => (e.name, e.isDir, e.size)), [('sub', true, 0), ('a.mkv', false, 4)]);
|
||||
final made = await FileOps.makeFolder('dav://$id/영상', '새 폴더');
|
||||
expect(made, 'dav://$id/영상/새 폴더');
|
||||
expect(Directory(p.join(remote.path, '영상', '새 폴더')).existsSync(), isTrue);
|
||||
final renamed = await FileOps.rename('dav://$id/영상/a.mkv', 'c.mkv');
|
||||
expect(renamed, 'dav://$id/영상/c.mkv');
|
||||
expect(File(p.join(remote.path, '영상', 'c.mkv')).readAsStringSync(), 'aaaa');
|
||||
await FileOps().delete(['dav://$id/영상/sub']);
|
||||
expect(Directory(p.join(remote.path, '영상', 'sub')).existsSync(), isFalse);
|
||||
// 잘못된 비밀번호
|
||||
DavRegistry.configure([DavServer(id: id, name: 'x', url: server.url, user: 'user', password: 'wrong')]);
|
||||
expect(() => listEntries('dav://$id/'), throwsA(isA<DavException>().having((e) => e.status, 'status', 401)));
|
||||
});
|
||||
|
||||
test('복사 · 이동: 로컬 → WebDAV · WebDAV → 로컬 · 같은 서버 안 이동 (속도 알림)', () async {
|
||||
lf('up/x.txt', 'xx');
|
||||
lf('up/deep/y.bin', 'yyy');
|
||||
var bytes = 0;
|
||||
final ops = FileOps(onBytes: (n) => bytes += n);
|
||||
expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up']);
|
||||
expect(File(p.join(remote.path, 'up', 'deep', 'y.bin')).readAsStringSync(), 'yyy');
|
||||
expect(bytes, 5);
|
||||
// 같은 이름이면 "이름 (2)"
|
||||
expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up (2)']);
|
||||
// WebDAV → 로컬
|
||||
final down = Directory(p.join(local.path, 'down'))..createSync();
|
||||
await ops.copy(['dav://$id/up'], down.path);
|
||||
expect(File(p.join(down.path, 'up', 'x.txt')).readAsStringSync(), 'xx');
|
||||
// 같은 서버 안 이동 (서버가 MOVE)
|
||||
await FileOps.makeFolder('dav://$id/', 'box');
|
||||
await ops.move(['dav://$id/up (2)'], 'dav://$id/box');
|
||||
expect(Directory(p.join(remote.path, 'box', 'up (2)')).existsSync(), isTrue);
|
||||
expect(Directory(p.join(remote.path, 'up (2)')).existsSync(), isFalse);
|
||||
expect(server.requests.where((r) => r.startsWith('MOVE')), isNotEmpty);
|
||||
// 로컬 → WebDAV 이동 (복사 후 원본 지움)
|
||||
lf('mv/m.txt', 'm');
|
||||
await ops.move([p.join(local.path, 'mv')], 'dav://$id/box');
|
||||
expect(File(p.join(remote.path, 'box', 'mv', 'm.txt')).readAsStringSync(), 'm');
|
||||
expect(Directory(p.join(local.path, 'mv')).existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('동기화 (mirror): 바뀐 것만 · 지우기 · -u · 원격 → 로컬도', () async {
|
||||
lf('src/a.txt', 'a');
|
||||
lf('src/sub/b.txt', 'b');
|
||||
final ops = FileOps();
|
||||
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 2);
|
||||
expect(File(p.join(remote.path, 'dst', 'sub', 'b.txt')).readAsStringSync(), 'b');
|
||||
// 다시: 바뀐 것 없음 (올린 파일은 서버 시각이 원본보다 새것)
|
||||
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 0);
|
||||
// 원본에서 내용 (크기) 이 바뀐 것만
|
||||
lf('src/a.txt', 'aaa');
|
||||
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 1);
|
||||
expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'aaa');
|
||||
// 지우기 포함: 대상에만 있는 것
|
||||
rf('dst/extra.txt', 'e');
|
||||
await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', delete: true);
|
||||
expect(File(p.join(remote.path, 'dst', 'extra.txt')).existsSync(), isFalse);
|
||||
// -u: 대상이 더 새것이면 덮어쓰지 않음
|
||||
rf('dst/a.txt', 'remote newer!!');
|
||||
final old = DateTime.now().subtract(const Duration(days: 1));
|
||||
File(p.join(local.path, 'src', 'a.txt')).setLastModifiedSync(old);
|
||||
await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', update: true);
|
||||
expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'remote newer!!');
|
||||
// 원격 → 로컬 (받은 파일은 원격의 바뀐 때로 맞춰져 다시 받지 않음)
|
||||
final back = p.join(local.path, 'back');
|
||||
expect(await ops.mirror('dav://$id/dst', back), 2);
|
||||
expect(File(p.join(back, 'sub', 'b.txt')).readAsStringSync(), 'b');
|
||||
expect(await ops.mirror('dav://$id/dst', back), 0);
|
||||
});
|
||||
|
||||
test('빈 폴더 지우기 · 파일 수 · 용량 · 임시로 받기', () async {
|
||||
rf('p/e1/e2/.keep', '');
|
||||
File(p.join(remote.path, 'p', 'e1', 'e2', '.keep')).deleteSync();
|
||||
rf('p/f/z.txt', 'z');
|
||||
expect(await vCountFiles('dav://$id/p'), 1);
|
||||
expect(await removeEmptyDirs('dav://$id/p'), 2); // e1/e2, e1
|
||||
expect(Directory(p.join(remote.path, 'p', 'f')).existsSync(), isTrue);
|
||||
expect(await DavRegistry.client(id).quota(), (1000000, 500));
|
||||
final got = await vLocalCopy('dav://$id/p/f/z.txt', p.join(local.path, 'cache'));
|
||||
expect(File(got).readAsStringSync(), 'z');
|
||||
expect(transferProblem(['dav://$id/p'], 'dav://$id/p/f', move: false), contains('자기 자신 안으로'));
|
||||
});
|
||||
|
||||
test('실시간 동기화 (lsync): 로컬 → WebDAV 쌍은 rsync 로 정해 두어도 앱이 맞추고 남은 것 · 지우기', () async {
|
||||
lf('live/a.txt', 'a');
|
||||
lf('live/sub/b.txt', 'bb');
|
||||
rf('mirror/old.txt', 'o');
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true);
|
||||
c.settings.liveSyncPairs = [pair];
|
||||
final live = LiveSync(c);
|
||||
expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt', '− old.txt']));
|
||||
await live.syncNow(pair);
|
||||
expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패')));
|
||||
expect(File(p.join(remote.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bb');
|
||||
expect(File(p.join(remote.path, 'mirror', 'old.txt')).existsSync(), isFalse);
|
||||
expect(live.pending[LiveSync.keyOf(pair)], isEmpty);
|
||||
live.dispose();
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue