diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8b8f6a0..dd65414 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -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"> 다운로드 > 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", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index f49a363..405d822 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -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 など (信頼できるサーバーのみ)", "작업": "作業", "작업 기록 보기": "作業記録を表示", "작업 기록 숨기기": "作業記録を隠す", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index ac6be00..be51ee0 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -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 等 (仅限可信服务器)", "작업": "任务", "작업 기록 보기": "显示任务记录", "작업 기록 숨기기": "隐藏任务记录", diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 70e94aa..c02810a 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -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; } diff --git a/lib/app/copy_center.dart b/lib/app/copy_center.dart index 0400692..b9711c6 100644 --- a/lib/app/copy_center.dart +++ b/lib/app/copy_center.dart @@ -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 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 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>('diskSpace', {'path': path}); diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index e924212..c9cc3dd 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -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> diff(LiveSyncPair x, {int limit = 500}) async { + if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit); final out = []; Future 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> _diffV(LiveSyncPair x, {int limit = 500}) async { + final out = []; + Future walk(String src, String dst, String rel) async { + if (out.length >= limit) return; + final there = {}; + try { + for (final e in await vList(dst)) { + there[vBasename(e.path)] = e; + } + } catch (_) {} // 대상 폴더가 아직 없음 + final names = {}; + 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 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), diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 0fd898d..cade1d6 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -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 rsyncPaths = []; + /// WebDAV 서버 (파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆 탭). 비밀번호는 이 설정 파일에만. + List 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' diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart index bd40991..c8a2423 100644 --- a/lib/app/transfer_job.dart +++ b/lib/app/transfer_job.dart @@ -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 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) { - filesDone[i]++; - currentFile = p.basename(src); - // 옮긴 (이름 바꾼) 파일은 원본이 없어 0 - final st = FileStat.statSync(src); - _addBytes(st.type == FileSystemEntityType.file ? st.size : 0); - _tick(); - }); + _ops = FileOps( + bandwidthKBps: bandwidthKBps, + onFileDone: (src) { + filesDone[i]++; + 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 _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()); diff --git a/lib/app/version_snapshot.dart b/lib/app/version_snapshot.dart index 33cffdf..f0ddfbd 100644 --- a/lib/app/version_snapshot.dart +++ b/lib/app/version_snapshot.dart @@ -62,10 +62,18 @@ class VersionSnapshot { Future restoreFrom(String from) async { final current = File(p.join(dataDir, 'settings.json')); Map secrets = {}; + Map davPasswords = {}; if (await current.exists()) { try { final j = jsonDecode(await current.readAsString()) as Map; 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; 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 '{}'; diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index 4c7f4d5..f47f176 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -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 sortEntries(List list, SortBy by, {bool descending = /// 폴더 읽기 (읽을 수 없는 항목은 건너뜀). [showHidden] 이 아니면 점으로 시작하는 항목을 뺀다. Future> 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 = []; 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 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 sources, String dest, {required bool move}) /// [root] 안의 빈 폴더를 안쪽부터 지운다 (find root/ -type d -empty -delete 와 같음). 지운 폴더 수. /// [keepRoot] 면 [root] 자신은 비어도 남긴다. 파일 · 링크가 하나라도 있는 폴더는 그대로. Future removeEmptyDirs(String root, {bool keepRoot = true}) async { + if (isDav(root)) return _removeEmptyDirsV(root, keepRoot: keepRoot); var n = 0; Future walk(Directory d) async { var empty = true; @@ -142,6 +157,36 @@ Future removeEmptyDirs(String root, {bool keepRoot = true}) async { /// 복사 · 이동 중 알림 (지금 파일, 지금까지 바이트, 전체 바이트) typedef CopyProgress = void Function(String current, int done, int total); +/// [removeEmptyDirs] 의 WebDAV 판 +Future _removeEmptyDirsV(String root, {bool keepRoot = true}) async { + var n = 0; + Future 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 paths) => paths.any(isDav); final _clock = Stopwatch(); int _sent = 0; @@ -178,6 +229,7 @@ class FileOps { /// 전체 크기 (진행률용) static Future totalSize(List 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> copy(List 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 = []; @@ -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> move(List sources, String destDir, {CopyProgress? onProgress}) async { + if (_anyDav([...sources, destDir])) return _moveV(sources, destDir, onProgress: onProgress); final made = []; for (final s in sources) { _check(); @@ -283,7 +338,10 @@ class FileOps { /// 동기화 (실시간 동기화 · lsyncd 처럼): [srcDir] 의 내용을 [dstDir] 에 맞춘다. /// 크기나 바뀐 시각이 다른 파일만 복사, [delete] 면 원본에 없는 것을 대상에서 지운다. 복사한 파일 수를 돌려준다. - Future mirror(String srcDir, String dstDir, {bool delete = false}) async { + /// + /// [update] (rsync -u): 대상이 더 새 파일이면 건너뛴다 (양쪽 ⇄ 을 함께 맞출 때 서로 덮어쓰지 않게). + Future 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 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 delete(List 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 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 _totalSizeV(List paths) async { + var total = 0; + Future 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 _copyFileV(String src, String target, int size, DateTime modified, void Function(int n)? onChunk) async { + _check(); + final input = await vOpenRead(src); + final relay = StreamController>(); + 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> _copyV(List sources, String destDir, {CopyProgress? onProgress}) async { + final total = await _totalSizeV(sources); + var done = 0; + final made = []; + Future 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> _moveV(List sources, String destDir, {CopyProgress? onProgress}) async { + final made = []; + 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 _mirrorV(String srcDir, String dstDir, {bool delete = false, bool update = false}) async { + var n = 0; + Future 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 = {}; + 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] 가 들어간 항목 찾기 (대소문자 무시, * ? 사용 가능). 찾는 대로 내보낸다. diff --git a/lib/core/vfs.dart b/lib/core/vfs.dart new file mode 100644 index 0000000..9b19e0d --- /dev/null +++ b/lib/core/vfs.dart @@ -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 vIsDir(String path) async => (await vStat(path))?.isDir ?? false; +Future 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> 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 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 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>> 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 vWrite(String path, Stream> 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 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 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 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 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}'; +} diff --git a/lib/core/webdav.dart b/lib/core/webdav.dart new file mode 100644 index 0000000..4d60183 --- /dev/null +++ b/lib/core/webdav.dart @@ -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 toJson() => + {'id': id, 'name': name, 'url': url, 'user': user, 'password': password, 'insecure': insecure}; + + factory DavServer.fromJson(Map 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 _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 = '' + ''; + + Future _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 _parse(String xml) { + final doc = XmlDocument.parse(xml); + final out = []; + 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(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 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 mkdir(String rel) async { + final res = await (await _open('MKCOL', uriOf(rel, dir: true))).close(); + await res.drain(); + if (res.statusCode == 201 || res.statusCode == 200) return; + if (res.statusCode == 405 && (await stat(rel))?.isDir == true) return; // 이미 있음 + _fail('폴더 만들기', res); + } + + /// 위 폴더까지 차례로 만든다 + Future 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 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(); + if (res.statusCode == 404) return; + if (res.statusCode >= 300) _fail('지우기', res); + } + + Future _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(); + if (res.statusCode >= 300) _fail(method == 'MOVE' ? '이동' : '복사', res); + } + + Future move(String from, String to, {bool overwrite = false}) => _moveOrCopy('MOVE', from, to, overwrite: overwrite); + Future copy(String from, String to, {bool overwrite = false}) => _moveOrCopy('COPY', from, to, overwrite: overwrite); + + /// 받기: 내용을 조각으로 (끝나면 스트림이 닫힘) + Future>> openRead(String rel) async { + final res = await (await _open('GET', uriOf(rel))).close(); + if (res.statusCode != 200) { + await res.drain(); + _fail('받기', res); + } + return res; + } + + /// 올리기 ([length] 를 알면 Content-Length 로) + Future write(String rel, Stream> 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(); + if (res.statusCode >= 300) _fail('올리기', res); + } + + /// 남은 용량 · 쓴 용량 (바이트, 서버가 알려 주지 않으면 null) + Future<(int?, int?)> quota() async { + try { + final xml = await _propfind('/', depth: 0, + body: '' + ''); + 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 = {}; + static final _servers = {}; + + static void configure(List 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 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)); + } +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 1489982..ddfc6d0 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -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 { 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 { } Future _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 { 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 { Future _goTo(_Pane pane, String dir, {bool remember = true}) async { pane.root = _volumeOf(dir); final chain = []; - 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 { } 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 { 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 { /// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램 Future _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 _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( + 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 _play(List paths, {bool internal = false, bool keepOrder = false}) async { + final local = []; + 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 _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 { ('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 { 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 { 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 { 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 { } 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 { return; } if (!mounted) return; - await playFiles(context, c, list); + await _play(list); } Future _addMkv(List paths) async { + if (paths.any(isDav)) { + _snack(tr('WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.')); + return; + } final videos = []; 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 { } Future _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 { 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 { 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 { /// (Rsync 화면의 ⇄ 는 둘이 함께) List _jobs = const []; + /// 한쪽이라도 WebDAV 면 rsync 대신 앱이 직접 맞춘다 (rsync 는 WebDAV 를 모름) + bool _viaDav(Iterable paths) => paths.any(isDav); + /// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null) Future _ensureRsync() async { final s = c.settings; @@ -871,7 +958,7 @@ class _ExplorerPageState extends State { 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 { 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 { ); 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 { 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 { 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 { ], 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 { )), ); 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 { } final jobs = []; 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 { 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 { ? 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.delayed(const Duration(milliseconds: 1200)); if (mounted && identical(_jobs, jobs)) setState(() => _jobs = const []); } @@ -1147,7 +1238,7 @@ class _ExplorerPageState extends State { ].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 { Future _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( context: context, @@ -1223,7 +1314,7 @@ class _ExplorerPageState extends State { 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 { } Future _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( context: context, builder: (ctx) => SimpleDialog( @@ -1463,7 +1554,7 @@ class _ExplorerPageState extends State { 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( @@ -1614,7 +1705,7 @@ class _ExplorerPageState extends State { final entries = pane.cache[dir] ?? const []; 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 { 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 _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 { 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), ); }, diff --git a/lib/ui/monitor_page.dart b/lib/ui/monitor_page.dart index df1d26b..7c664a8 100644 --- a/lib/ui/monitor_page.dart +++ b/lib/ui/monitor_page.dart @@ -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 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)), ), ]), diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 84f5b89..c500346 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -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 { ), // 복사 · 이동 방법 (현재 방식 · robocopy) · 속도 제한 CopySyncSettings(c: c), + // WebDAV 서버 (탐색기 · Rsync 화면 위쪽 탭) + WebDavSettings(c: c), ]), // Rsync 화면: rsync 옵션 · 가져오기 · 실시간 동기화 (lsync) · 백그라운드로 실행 _group('rsync', Icons.sync_alt, 'Rsync', [ diff --git a/lib/ui/webdav_settings.dart b/lib/ui/webdav_settings.dart new file mode 100644 index 0000000..2265be8 --- /dev/null +++ b/lib/ui/webdav_settings.dart @@ -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 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( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) { + Future 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; +} diff --git a/pubspec.lock b/pubspec.lock index 4cb939d..0278694 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1001,7 +1001,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: transitive + dependency: "direct main" description: name: xml sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" diff --git a/pubspec.yaml b/pubspec.yaml index 81c4fc6..464601a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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) diff --git a/test/explorer_webdav_test.dart b/test/explorer_webdav_test.dart new file mode 100644 index 0000000..bfb7d0f --- /dev/null +++ b/test/explorer_webdav_test.dart @@ -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 = []; + @override + Future 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 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/원격'); // 모니터링에 남음 + }); +} diff --git a/test/real_dav_test.dart b/test/real_dav_test.dart new file mode 100644 index 0000000..90ed727 --- /dev/null +++ b/test/real_dav_test.dart @@ -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))); +} diff --git a/test/support/dav_server.dart b/test/support/dav_server.dart new file mode 100644 index 0000000..6b01eea --- /dev/null +++ b/test/support/dav_server.dart @@ -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 = []; + + TestDavServer(this.root, {this.user = 'user', this.pass = 'pass'}); + + String get url => 'http://127.0.0.1:${_server.port}/dav'; + + Future start() async { + _server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + _server.listen(_handle); + } + + Future 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 == '.' ? [] : 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 '${_href(e.path, dir)}' + '${dir ? '' : ''}' + '${dir ? '' : '${st.size}'}' + '${HttpDate.format(st.modified)}' + '1000000500' + 'HTTP/1.1 200 OK'; + } + + Future _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(); + if (type == FileSystemEntityType.notFound) { + res.statusCode = 404; + break; + } + final depth = req.headers.value('Depth') ?? '1'; + final items = [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('${items.map(_entry).join()}'); + 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(); + res.statusCode = 409; + break; + } + final out = File(local).openWrite(); + await out.addStream(req); + await out.close(); + res.statusCode = 201; + case 'MKCOL': + await req.drain(); + 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(); + 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(); + 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(); + 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); + } + } + } +} diff --git a/test/webdav_test.dart b/test/webdav_test.dart new file mode 100644 index 0000000..12235a0 --- /dev/null +++ b/test/webdav_test.dart @@ -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().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(); + }); +}