WebDAV: 파일 탐색기 · Rsync 화면 · lsync 에서 연결 · 복사 · 동기화

- 환경 설정 > 파일 탐색기 > WebDAV: 서버 추가 · 고치기 · 지우기 · 연결 확인 (기본 인증, 인증서 확인 안 함)
- 위쪽 "SD 카드" 옆 서버 탭 (없으면 "WebDAV" 탭 → 바로 추가), 경로 dav://서버/경로
- 목록 · 새 폴더 · 이름 변경 · 삭제 · 찾기 · 복사 · 이동 (같은 서버는 MOVE), 열기 · 재생은 임시로 받아서
- Rsync · lsync: 한쪽이 WebDAV 면 앱이 크기 · 시각 비교로 맞춤 (-u · --remove-source-files 따름)
- PROPFIND 에 Content-Length (chunked 면 일부 서버가 남은 본문을 다음 요청으로 오해)
- Android: http:// NAS 연결 허용 (usesCleartextTraffic)
- 화면에는 내부 id 대신 "☁ 서버 이름/경로"
- 시험: webdav_test (시험 서버) · explorer_webdav_test · real_dav_test (JJ_REAL_DAV 일 때 실제 서버)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-08 11:30:54 +09:00
parent 0cf7a9a7b7
commit 8f779fa7e6
23 changed files with 1829 additions and 95 deletions

View File

@ -19,7 +19,8 @@
android:label="JJ_MKVMaker" android:label="JJ_MKVMaker"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true"> android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true">
<!-- Android 16: 큰 화면 (태블릿) 에서도 환경 설정 > 화면 방향 (가로 고정 · 세로 고정) 을 따르게 --> <!-- Android 16: 큰 화면 (태블릿) 에서도 환경 설정 > 화면 방향 (가로 고정 · 세로 고정) 을 따르게 -->
<property <property
android:name="android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY" android:name="android.window.PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY"

View File

@ -113,6 +113,7 @@
"MKV 화면으로": "To the MKV screen", "MKV 화면으로": "To the MKV screen",
"MKV 화면으로 (전체 화면)": "To the MKV screen (full screen)", "MKV 화면으로 (전체 화면)": "To the MKV screen (full screen)",
"MP4 (동영상)": "MP4 (video)", "MP4 (동영상)": "MP4 (video)",
"NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 설정 파일에만 저장합니다.": "Open, copy and sync WebDAV folders on a NAS or cloud in the file explorer and the Rsync screen (tab next to \"SD card\" at the top). The password is stored only in this device's settings file.",
"NLLB 언어 코드가 아닙니다: {0}": "Not an NLLB language code: {0}", "NLLB 언어 코드가 아닙니다: {0}": "Not an NLLB language code: {0}",
"NLLB-200 번역 (600M)": "NLLB-200 translation (600M)", "NLLB-200 번역 (600M)": "NLLB-200 translation (600M)",
"OpenSubtitles API 키 또는 로그인 정보가 올바르지 않습니다. ({0})": "OpenSubtitles API key or sign-in details are incorrect. ({0})", "OpenSubtitles API 키 또는 로그인 정보가 올바르지 않습니다. ({0})": "OpenSubtitles API key or sign-in details are incorrect. ({0})",
@ -125,6 +126,11 @@
"SRT 형식이 아니거나 비어 있습니다.": "Not in SRT format or empty.", "SRT 형식이 아니거나 비어 있습니다.": "Not in SRT format or empty.",
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC is not installed. Once installed it appears in the list (videolan.org).", "VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC is not installed. Once installed it appears in the list (videolan.org).",
"VLC 로 열기": "Open with VLC", "VLC 로 열기": "Open with VLC",
"WebDAV 서버 고치기": "Edit WebDAV server",
"WebDAV 서버 추가": "Add WebDAV server",
"WebDAV 에서 받는 중": "Downloading from WebDAV",
"WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.": "Videos on WebDAV can't be added to the MKV list. Copy them to this device first.",
"WebDAV: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --remove-source-files 만 따름).": "WebDAV: the app syncs by itself instead of rsync (compares size and modified time; of the options only -u and --remove-source-files apply).",
"WebM (동영상)": "WebM (video)", "WebM (동영상)": "WebM (video)",
"Whale 에서 가져오기": "Import from Whale", "Whale 에서 가져오기": "Import from Whale",
"Whale 에서 가져옴": "Imported from Whale", "Whale 에서 가져옴": "Imported from Whale",
@ -263,6 +269,7 @@
"고른 것 시작": "Start selected", "고른 것 시작": "Start selected",
"고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "Best quality at or below the chosen size (nearest if the video lacks that size)", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "Best quality at or below the chosen size (nearest if the video lacks that size)",
"고지 문서": "Notices", "고지 문서": "Notices",
"고치기": "Edit",
"고화질": "High quality", "고화질": "High quality",
"골라서 시작": "Choose and start", "골라서 시작": "Choose and start",
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "Cannot roll back: shared storage (Download) is not available.", "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "Cannot roll back: shared storage (Download) is not available.",
@ -309,6 +316,7 @@
"나중에": "Later", "나중에": "Later",
"날짜": "Date", "날짜": "Date",
"남은 시간 {0}": "{0} left", "남은 시간 {0}": "{0} left",
"남은 용량": "free",
"내려받기": "Download", "내려받기": "Download",
"내려받기 (Windows 만)": "Download (Windows only)", "내려받기 (Windows 만)": "Download (Windows only)",
"내려받기 실패 ({0})": "Download failed ({0})", "내려받기 실패 ({0})": "Download failed ({0})",
@ -509,6 +517,7 @@
"받은 파일에 libcef.dll 이 없습니다.": "The downloaded file has no libcef.dll.", "받은 파일에 libcef.dll 이 없습니다.": "The downloaded file has no libcef.dll.",
"받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "The downloaded file is corrupt or a different file (SHA256 mismatch). Installation was stopped.", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "The downloaded file is corrupt or a different file (SHA256 mismatch). Installation was stopped.",
"받음 {0}": "Received {0}", "받음 {0}": "Received {0}",
"받지 못했습니다: {0}": "Could not download: {0}",
"밝기": "Brightness", "밝기": "Brightness",
"밤 0~6시": "Night 0-6", "밤 0~6시": "Night 0-6",
"방법: {0}": "Method: {0}", "방법: {0}": "Method: {0}",
@ -558,6 +567,7 @@
"브라우저 엔진 (앱 안)": "Browser engine (in-app)", "브라우저 엔진 (앱 안)": "Browser engine (in-app)",
"브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 \"앱 안 브라우저\" 를 고르거나, 고른 브라우저가 설치되어 있는지 확인하세요 (Chrome · Edge 는 브라우저를 닫아야 읽힙니다).": "Could not read the browser cookies. In Settings > Downloads > YouTube cookies, choose \"In-app browser\", or check that the chosen browser is installed (Chrome · Edge must be closed to be read).", "브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > 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.", "비공개 · 회원 전용 영상입니다.": "This is a private · members-only video.",
"비밀번호": "Password",
"비밀번호 (선택)": "Password (optional)", "비밀번호 (선택)": "Password (optional)",
"비어 있는 폴더": "Empty folder", "비어 있는 폴더": "Empty folder",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.", "비어 있는 폴더입니다.\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?", "아래 프로그램이 없어 일부 기능을 쓸 수 없습니다. 공식 배포처에서 내려받아 프로그램 폴더에 설치할까요?": "Some features are unavailable because the programs below are missing. Download them from the official sources and install them in the program folder?",
"아래로": "Down", "아래로": "Down",
"아래에 줄 추가": "Add line below", "아래에 줄 추가": "Add line below",
"아이디": "User name",
"아이디 (선택)": "Username (optional)", "아이디 (선택)": "Username (optional)",
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "You can enter the username · password in Settings (optional, raises the daily download limit).", "아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "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])", "아이콘 · 목록 모양: 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", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "Drop here to add to the Make MKV video list",
"여러 개를 골랐을 때": "When several are selected", "여러 개를 골랐을 때": "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)", "여러 동영상을 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", "연도": "Year",
"열기": "Open", "열기": "Open",
"영상 {0}": "Video {0}", "영상 {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", "예: /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", "예: 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)", "예: 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}", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "Cleaned up program files of the old layout ({0}): {1}",
"예전 버전": "an earlier version", "예전 버전": "an earlier version",
"예전 버전 v{0} 으로 되돌리기": "Roll back to earlier version v{0}", "예전 버전 v{0} 으로 되돌리기": "Roll back to earlier version v{0}",
@ -847,6 +862,7 @@
"이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "No move folder is set (Settings > Save locations)", "이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "No move folder is set (Settings > Save locations)",
"이동할 폴더를 만들 수 없습니다: {0} ({1})": "Cannot create the move folder: {0} ({1})", "이동할 폴더를 만들 수 없습니다: {0} ({1})": "Cannot create the move folder: {0} ({1})",
"이름": "Name", "이름": "Name",
"이름 (탭에 보임, 비우면 주소)": "Name (shown on the tab; address if empty)",
"이름 바꾸기 · 이동": "Rename · move", "이름 바꾸기 · 이동": "Rename · move",
"이름 변경": "Rename", "이름 변경": "Rename",
"이름을 바꿀 수 없습니다: {0}": "Cannot rename: {0}", "이름을 바꿀 수 없습니다: {0}": "Cannot rename: {0}",
@ -864,6 +880,7 @@
"이탈리아어": "Italian", "이탈리아어": "Italian",
"인도네시아어": "Indonesian", "인도네시아어": "Indonesian",
"인식된 말이 없습니다.": "No speech was recognized.", "인식된 말이 없습니다.": "No speech was recognized.",
"인증서 확인 안 함": "Don't verify certificate",
"인코딩": "Encoding", "인코딩": "Encoding",
"인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "Check your internet connection, then try again in Settings > Check required programs.\n\n{0}", "인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "Check your internet connection, then try again in Settings > Check required programs.\n\n{0}",
"인터넷 자막 (OpenSubtitles.com)": "Internet subtitles (OpenSubtitles.com)", "인터넷 자막 (OpenSubtitles.com)": "Internet subtitles (OpenSubtitles.com)",
@ -910,6 +927,7 @@
"자막을 불러올 수 없습니다: {0}": "Cannot load subtitles: {0}", "자막을 불러올 수 없습니다: {0}": "Cannot load subtitles: {0}",
"자막이 비어 있습니다.": "The subtitles are empty.", "자막이 비어 있습니다.": "The subtitles are empty.",
"자막이 없습니다.": "No subtitles.", "자막이 없습니다.": "No subtitles.",
"자체 서명 인증서를 쓰는 집 NAS 등 (믿을 수 있는 서버에서만)": "For a home NAS with a self-signed certificate, etc. (trusted servers only)",
"작업": "Jobs", "작업": "Jobs",
"작업 기록 보기": "Show job log", "작업 기록 보기": "Show job log",
"작업 기록 숨기기": "Hide job log", "작업 기록 숨기기": "Hide job log",

View File

@ -113,6 +113,7 @@
"MKV 화면으로": "MKV 画面へ", "MKV 화면으로": "MKV 画面へ",
"MKV 화면으로 (전체 화면)": "MKV 画面へ(全画面)", "MKV 화면으로 (전체 화면)": "MKV 画面へ(全画面)",
"MP4 (동영상)": "MP4(動画)", "MP4 (동영상)": "MP4(動画)",
"NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 설정 파일에만 저장합니다.": "NAS・クラウドの WebDAV フォルダーをファイルエクスプローラー・Rsync 画面で開き、コピー・同期します (上部の「SD カード」の隣のタブ)。パスワードはこの端末の設定ファイルにのみ保存されます。",
"NLLB 언어 코드가 아닙니다: {0}": "NLLB の言語コードではありません: {0}", "NLLB 언어 코드가 아닙니다: {0}": "NLLB の言語コードではありません: {0}",
"NLLB-200 번역 (600M)": "NLLB-200 翻訳(600M)", "NLLB-200 번역 (600M)": "NLLB-200 翻訳(600M)",
"OpenSubtitles API 키 또는 로그인 정보가 올바르지 않습니다. ({0})": "OpenSubtitles の API キーまたはログイン情報が正しくありません。({0})", "OpenSubtitles API 키 또는 로그인 정보가 올바르지 않습니다. ({0})": "OpenSubtitles の API キーまたはログイン情報が正しくありません。({0})",
@ -125,6 +126,11 @@
"SRT 형식이 아니거나 비어 있습니다.": "SRT 形式ではないか、空です。", "SRT 형식이 아니거나 비어 있습니다.": "SRT 形式ではないか、空です。",
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC がインストールされていません。インストールするとリストに表示されます(videolan.org)。", "VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC がインストールされていません。インストールするとリストに表示されます(videolan.org)。",
"VLC 로 열기": "VLC で開く", "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(動画)", "WebM (동영상)": "WebM(動画)",
"Whale 에서 가져오기": "Whale から取り込む", "Whale 에서 가져오기": "Whale から取り込む",
"Whale 에서 가져옴": "Whale から取り込み", "Whale 에서 가져옴": "Whale から取り込み",
@ -263,6 +269,7 @@
"고른 것 시작": "選んだものを開始", "고른 것 시작": "選んだものを開始",
"고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "選んだサイズ以下で最も良い画質(動画にないサイズなら近いもの)", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "選んだサイズ以下で最も良い画質(動画にないサイズなら近いもの)",
"고지 문서": "告知文書", "고지 문서": "告知文書",
"고치기": "編集",
"고화질": "高画質", "고화질": "高画質",
"골라서 시작": "選んで開始", "골라서 시작": "選んで開始",
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "共有ストレージ (Download) を使えないため戻せません。", "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "共有ストレージ (Download) を使えないため戻せません。",
@ -309,6 +316,7 @@
"나중에": "後で", "나중에": "後で",
"날짜": "日付", "날짜": "日付",
"남은 시간 {0}": "残り時間 {0}", "남은 시간 {0}": "残り時間 {0}",
"남은 용량": "空き容量",
"내려받기": "ダウンロード", "내려받기": "ダウンロード",
"내려받기 (Windows 만)": "ダウンロード (Windows のみ)", "내려받기 (Windows 만)": "ダウンロード (Windows のみ)",
"내려받기 실패 ({0})": "ダウンロード失敗({0})", "내려받기 실패 ({0})": "ダウンロード失敗({0})",
@ -509,6 +517,7 @@
"받은 파일에 libcef.dll 이 없습니다.": "ダウンロードしたファイルに libcef.dll がありません。", "받은 파일에 libcef.dll 이 없습니다.": "ダウンロードしたファイルに libcef.dll がありません。",
"받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "ダウンロードしたファイルが壊れているか別のファイルです(SHA256 不一致)。インストールを中止しました。", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "ダウンロードしたファイルが壊れているか別のファイルです(SHA256 不一致)。インストールを中止しました。",
"받음 {0}": "受信 {0}", "받음 {0}": "受信 {0}",
"받지 못했습니다: {0}": "ダウンロードできませんでした: {0}",
"밝기": "明るさ", "밝기": "明るさ",
"밤 0~6시": "夜 0~6 時", "밤 0~6시": "夜 0~6 時",
"방법: {0}": "方法: {0}", "방법: {0}": "方法: {0}",
@ -558,6 +567,7 @@
"브라우저 엔진 (앱 안)": "ブラウザーエンジン(アプリ内)", "브라우저 엔진 (앱 안)": "ブラウザーエンジン(アプリ内)",
"브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 \"앱 안 브라우저\" 를 고르거나, 고른 브라우저가 설치되어 있는지 확인하세요 (Chrome · Edge 는 브라우저를 닫아야 읽힙니다).": "ブラウザーのクッキーを読めませんでした。環境設定 > ダウンロード > YouTube クッキー で「アプリ内ブラウザー」を選ぶか、選んだブラウザーがインストールされているか確認してください(Chrome・Edge はブラウザーを閉じないと読めません)。", "브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 \"앱 안 브라우저\" 를 고르거나, 고른 브라우저가 설치되어 있는지 확인하세요 (Chrome · Edge 는 브라우저를 닫아야 읽힙니다).": "ブラウザーのクッキーを読めませんでした。環境設定 > ダウンロード > YouTube クッキー で「アプリ内ブラウザー」を選ぶか、選んだブラウザーがインストールされているか確認してください(Chrome・Edge はブラウザーを閉じないと読めません)。",
"비공개 · 회원 전용 영상입니다.": "非公開・メンバー限定の動画です。", "비공개 · 회원 전용 영상입니다.": "非公開・メンバー限定の動画です。",
"비밀번호": "パスワード",
"비밀번호 (선택)": "パスワード(任意)", "비밀번호 (선택)": "パスワード(任意)",
"비어 있는 폴더": "空のフォルダー", "비어 있는 폴더": "空のフォルダー",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。",
@ -675,6 +685,7 @@
"아래 프로그램이 없어 일부 기능을 쓸 수 없습니다. 공식 배포처에서 내려받아 프로그램 폴더에 설치할까요?": "以下のプログラムがないため一部の機能が使えません。公式配布元からダウンロードしてプログラムフォルダーにインストールしますか?", "아래 프로그램이 없어 일부 기능을 쓸 수 없습니다. 공식 배포처에서 내려받아 프로그램 폴더에 설치할까요?": "以下のプログラムがないため一部の機能が使えません。公式配布元からダウンロードしてプログラムフォルダーにインストールしますか?",
"아래로": "下へ", "아래로": "下へ",
"아래에 줄 추가": "下に行を追加", "아래에 줄 추가": "下に行を追加",
"아이디": "ユーザー名",
"아이디 (선택)": "ユーザー名(任意)", "아이디 (선택)": "ユーザー名(任意)",
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "ユーザー名・パスワードは環境設定で入力できます(任意、1日のダウンロード回数が増えます)。", "아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "ユーザー名・パスワードは環境設定で入力できます(任意、1日のダウンロード回数が増えます)。",
"아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "アイコン · 一覧の見た目: X-plore (サムネイル · 2 行) · Windows エクスプローラー (カラーアイコン · 列) · Total Commander (詰めた一覧 · [フォルダー])", "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "アイコン · 一覧の見た目: X-plore (サムネイル · 2 行) · Windows エクスプローラー (カラーアイコン · 列) · Total Commander (詰めた一覧 · [フォルダー])",
@ -713,6 +724,9 @@
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "ここにドロップすると MKV 作成の動画リストに追加します", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "ここにドロップすると MKV 作成の動画リストに追加します",
"여러 개를 골랐을 때": "複数選んだとき", "여러 개를 골랐을 때": "複数選んだとき",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)",
"연결 실패: {0}": "接続失敗: {0}",
"연결 확인": "接続テスト",
"연결됨: 항목 {0}개{1}": "接続しました: 項目 {0} 個{1}",
"연도": "年", "연도": "年",
"열기": "開く", "열기": "開く",
"영상 {0}": "映像 {0}", "영상 {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 を自動で付加", "예: /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 時間単位", "예: 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)", "예: 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}", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "旧構成のプログラムファイルを整理({0}個): {1}",
"예전 버전": "以前のバージョン", "예전 버전": "以前のバージョン",
"예전 버전 v{0} 으로 되돌리기": "以前のバージョン v{0} に戻す", "예전 버전 v{0} 으로 되돌리기": "以前のバージョン v{0} に戻す",
@ -847,6 +862,7 @@
"이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "移動フォルダーが決まっていません(環境設定 > 保存先)", "이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "移動フォルダーが決まっていません(環境設定 > 保存先)",
"이동할 폴더를 만들 수 없습니다: {0} ({1})": "移動フォルダーを作成できません: {0}({1})", "이동할 폴더를 만들 수 없습니다: {0} ({1})": "移動フォルダーを作成できません: {0}({1})",
"이름": "名前", "이름": "名前",
"이름 (탭에 보임, 비우면 주소)": "名前 (タブに表示、空ならアドレス)",
"이름 바꾸기 · 이동": "名前の変更・移動", "이름 바꾸기 · 이동": "名前の変更・移動",
"이름 변경": "名前の変更", "이름 변경": "名前の変更",
"이름을 바꿀 수 없습니다: {0}": "名前を変更できません: {0}", "이름을 바꿀 수 없습니다: {0}": "名前を変更できません: {0}",
@ -864,6 +880,7 @@
"이탈리아어": "イタリア語", "이탈리아어": "イタリア語",
"인도네시아어": "インドネシア語", "인도네시아어": "インドネシア語",
"인식된 말이 없습니다.": "認識された音声がありません。", "인식된 말이 없습니다.": "認識された音声がありません。",
"인증서 확인 안 함": "証明書を検証しない",
"인코딩": "エンコード", "인코딩": "エンコード",
"인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "インターネット接続を確認してから、環境設定 > 必須プログラムの点検 でもう一度試してください。\n\n{0}", "인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "インターネット接続を確認してから、環境設定 > 必須プログラムの点検 でもう一度試してください。\n\n{0}",
"인터넷 자막 (OpenSubtitles.com)": "インターネット字幕(OpenSubtitles.com)", "인터넷 자막 (OpenSubtitles.com)": "インターネット字幕(OpenSubtitles.com)",
@ -910,6 +927,7 @@
"자막을 불러올 수 없습니다: {0}": "字幕を読み込めません: {0}", "자막을 불러올 수 없습니다: {0}": "字幕を読み込めません: {0}",
"자막이 비어 있습니다.": "字幕が空です。", "자막이 비어 있습니다.": "字幕が空です。",
"자막이 없습니다.": "字幕がありません。", "자막이 없습니다.": "字幕がありません。",
"자체 서명 인증서를 쓰는 집 NAS 등 (믿을 수 있는 서버에서만)": "自己署名証明書を使う自宅 NAS など (信頼できるサーバーのみ)",
"작업": "作業", "작업": "作業",
"작업 기록 보기": "作業記録を表示", "작업 기록 보기": "作業記録を表示",
"작업 기록 숨기기": "作業記録を隠す", "작업 기록 숨기기": "作業記録を隠す",

View File

@ -113,6 +113,7 @@
"MKV 화면으로": "转到 MKV 界面", "MKV 화면으로": "转到 MKV 界面",
"MKV 화면으로 (전체 화면)": "转到 MKV 界面(全屏)", "MKV 화면으로 (전체 화면)": "转到 MKV 界面(全屏)",
"MP4 (동영상)": "MP4(视频)", "MP4 (동영상)": "MP4(视频)",
"NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 설정 파일에만 저장합니다.": "在文件浏览器和 Rsync 界面中打开、复制、同步 NAS·云端的 WebDAV 文件夹 (顶部“SD 卡”旁的标签)。密码仅保存在本设备的设置文件中。",
"NLLB 언어 코드가 아닙니다: {0}": "不是 NLLB 语言代码: {0}", "NLLB 언어 코드가 아닙니다: {0}": "不是 NLLB 语言代码: {0}",
"NLLB-200 번역 (600M)": "NLLB-200 翻译(600M)", "NLLB-200 번역 (600M)": "NLLB-200 翻译(600M)",
"OpenSubtitles API 키 또는 로그인 정보가 올바르지 않습니다. ({0})": "OpenSubtitles API 密钥或登录信息不正确。({0})", "OpenSubtitles API 키 또는 로그인 정보가 올바르지 않습니다. ({0})": "OpenSubtitles API 密钥或登录信息不正确。({0})",
@ -125,6 +126,11 @@
"SRT 형식이 아니거나 비어 있습니다.": "不是 SRT 格式或为空。", "SRT 형식이 아니거나 비어 있습니다.": "不是 SRT 格式或为空。",
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "未安装 VLC。安装后会出现在列表中(videolan.org)。", "VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "未安装 VLC。安装后会出现在列表中(videolan.org)。",
"VLC 로 열기": "用 VLC 打开", "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(视频)", "WebM (동영상)": "WebM(视频)",
"Whale 에서 가져오기": "从 Whale 导入", "Whale 에서 가져오기": "从 Whale 导入",
"Whale 에서 가져옴": "从 Whale 导入", "Whale 에서 가져옴": "从 Whale 导入",
@ -263,6 +269,7 @@
"고른 것 시작": "开始所选", "고른 것 시작": "开始所选",
"고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "不超过所选尺寸的最佳画质(视频没有该尺寸时取最接近的)", "고른 크기 이하에서 가장 좋은 화질 (영상에 없는 크기면 가까운 것)": "不超过所选尺寸的最佳画质(视频没有该尺寸时取最接近的)",
"고지 문서": "声明文件", "고지 문서": "声明文件",
"고치기": "编辑",
"고화질": "高画质", "고화질": "高画质",
"골라서 시작": "选择后开始", "골라서 시작": "选择后开始",
"공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "无法使用公共存储 (Download),因此无法回退。", "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "无法使用公共存储 (Download),因此无法回退。",
@ -309,6 +316,7 @@
"나중에": "以后再说", "나중에": "以后再说",
"날짜": "日期", "날짜": "日期",
"남은 시간 {0}": "剩余时间 {0}", "남은 시간 {0}": "剩余时间 {0}",
"남은 용량": "剩余空间",
"내려받기": "下载", "내려받기": "下载",
"내려받기 (Windows 만)": "下载 (仅 Windows)", "내려받기 (Windows 만)": "下载 (仅 Windows)",
"내려받기 실패 ({0})": "下载失败({0})", "내려받기 실패 ({0})": "下载失败({0})",
@ -509,6 +517,7 @@
"받은 파일에 libcef.dll 이 없습니다.": "下载的文件中没有 libcef.dll。", "받은 파일에 libcef.dll 이 없습니다.": "下载的文件中没有 libcef.dll。",
"받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "下载的文件已损坏或不是正确的文件(SHA256 不匹配)。已停止安装。", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "下载的文件已损坏或不是正确的文件(SHA256 不匹配)。已停止安装。",
"받음 {0}": "已接收 {0}", "받음 {0}": "已接收 {0}",
"받지 못했습니다: {0}": "无法下载: {0}",
"밝기": "亮度", "밝기": "亮度",
"밤 0~6시": "夜间 0~6 点", "밤 0~6시": "夜间 0~6 点",
"방법: {0}": "方式: {0}", "방법: {0}": "方式: {0}",
@ -558,6 +567,7 @@
"브라우저 엔진 (앱 안)": "浏览器引擎(应用内)", "브라우저 엔진 (앱 안)": "浏览器引擎(应用内)",
"브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 \"앱 안 브라우저\" 를 고르거나, 고른 브라우저가 설치되어 있는지 확인하세요 (Chrome · Edge 는 브라우저를 닫아야 읽힙니다).": "未能读取浏览器 Cookie。请在 设置 > 下载 > YouTube Cookie 中选择“应用内浏览器”,或确认所选浏览器已安装(Chrome · Edge 需关闭浏览器才能读取)。", "브라우저 쿠키를 읽지 못했습니다. 환경 설정 > 다운로드 > YouTube 쿠키 에서 \"앱 안 브라우저\" 를 고르거나, 고른 브라우저가 설치되어 있는지 확인하세요 (Chrome · Edge 는 브라우저를 닫아야 읽힙니다).": "未能读取浏览器 Cookie。请在 设置 > 下载 > YouTube Cookie 中选择“应用内浏览器”,或确认所选浏览器已安装(Chrome · Edge 需关闭浏览器才能读取)。",
"비공개 · 회원 전용 영상입니다.": "这是私享 · 会员专享视频。", "비공개 · 회원 전용 영상입니다.": "这是私享 · 会员专享视频。",
"비밀번호": "密码",
"비밀번호 (선택)": "密码(可选)", "비밀번호 (선택)": "密码(可选)",
"비어 있는 폴더": "空文件夹", "비어 있는 폴더": "空文件夹",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。", "비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。",
@ -675,6 +685,7 @@
"아래 프로그램이 없어 일부 기능을 쓸 수 없습니다. 공식 배포처에서 내려받아 프로그램 폴더에 설치할까요?": "由于缺少以下程序,部分功能无法使用。要从官方来源下载并安装到程序文件夹吗?", "아래 프로그램이 없어 일부 기능을 쓸 수 없습니다. 공식 배포처에서 내려받아 프로그램 폴더에 설치할까요?": "由于缺少以下程序,部分功能无法使用。要从官方来源下载并安装到程序文件夹吗?",
"아래로": "向下", "아래로": "向下",
"아래에 줄 추가": "在下方添加一行", "아래에 줄 추가": "在下方添加一行",
"아이디": "用户名",
"아이디 (선택)": "用户名(可选)", "아이디 (선택)": "用户名(可选)",
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "可在设置中填写用户名 · 密码(可选,可增加每日下载次数)。", "아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "可在设置中填写用户名 · 密码(可选,可增加每日下载次数)。",
"아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "图标 · 列表外观: X-plore (缩略图 · 两行) · Windows 资源管理器 (彩色图标 · 列) · Total Commander (紧凑列表 · [文件夹])", "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "图标 · 列表外观: X-plore (缩略图 · 两行) · Windows 资源管理器 (彩色图标 · 列) · Total Commander (紧凑列表 · [文件夹])",
@ -713,6 +724,9 @@
"여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "拖放到此处即可加入制作 MKV 的视频列表", "여기에 놓으면 MKV 만들기의 동영상 목록에 추가합니다": "拖放到此处即可加入制作 MKV 的视频列表",
"여러 개를 골랐을 때": "选择多个时", "여러 개를 골랐을 때": "选择多个时",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)",
"연결 실패: {0}": "连接失败: {0}",
"연결 확인": "测试连接",
"연결됨: 항목 {0}개{1}": "已连接: {0} 个项目{1}",
"연도": "年份", "연도": "年份",
"열기": "打开", "열기": "打开",
"영상 {0}": "视频 {0}", "영상 {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", "예: /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 小时为单位", "예: 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)", "예: 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}", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "已清理旧结构的程序文件({0} 个): {1}",
"예전 버전": "以前的版本", "예전 버전": "以前的版本",
"예전 버전 v{0} 으로 되돌리기": "回退到以前的版本 v{0}", "예전 버전 v{0} 으로 되돌리기": "回退到以前的版本 v{0}",
@ -847,6 +862,7 @@
"이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "尚未设置移动目标文件夹(设置 > 保存位置)", "이동할 폴더가 정해지지 않았습니다 (환경 설정 > 저장 위치)": "尚未设置移动目标文件夹(设置 > 保存位置)",
"이동할 폴더를 만들 수 없습니다: {0} ({1})": "无法创建移动目标文件夹: {0}({1})", "이동할 폴더를 만들 수 없습니다: {0} ({1})": "无法创建移动目标文件夹: {0}({1})",
"이름": "名称", "이름": "名称",
"이름 (탭에 보임, 비우면 주소)": "名称 (显示在标签上,留空则显示地址)",
"이름 바꾸기 · 이동": "重命名 · 移动", "이름 바꾸기 · 이동": "重命名 · 移动",
"이름 변경": "重命名", "이름 변경": "重命名",
"이름을 바꿀 수 없습니다: {0}": "无法重命名: {0}", "이름을 바꿀 수 없습니다: {0}": "无法重命名: {0}",
@ -864,6 +880,7 @@
"이탈리아어": "意大利语", "이탈리아어": "意大利语",
"인도네시아어": "印尼语", "인도네시아어": "印尼语",
"인식된 말이 없습니다.": "未识别到语音。", "인식된 말이 없습니다.": "未识别到语音。",
"인증서 확인 안 함": "不验证证书",
"인코딩": "编码", "인코딩": "编码",
"인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "请检查网络连接后,在 设置 > 检查必需程序 中重试。\n\n{0}", "인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "请检查网络连接后,在 设置 > 检查必需程序 中重试。\n\n{0}",
"인터넷 자막 (OpenSubtitles.com)": "网络字幕(OpenSubtitles.com)", "인터넷 자막 (OpenSubtitles.com)": "网络字幕(OpenSubtitles.com)",
@ -910,6 +927,7 @@
"자막을 불러올 수 없습니다: {0}": "无法加载字幕: {0}", "자막을 불러올 수 없습니다: {0}": "无法加载字幕: {0}",
"자막이 비어 있습니다.": "字幕为空。", "자막이 비어 있습니다.": "字幕为空。",
"자막이 없습니다.": "没有字幕。", "자막이 없습니다.": "没有字幕。",
"자체 서명 인증서를 쓰는 집 NAS 등 (믿을 수 있는 서버에서만)": "用于使用自签名证书的家用 NAS 等 (仅限可信服务器)",
"작업": "任务", "작업": "任务",
"작업 기록 보기": "显示任务记录", "작업 기록 보기": "显示任务记录",
"작업 기록 숨기기": "隐藏任务记录", "작업 기록 숨기기": "隐藏任务记录",

View File

@ -10,6 +10,7 @@ import '../core/charset_detector.dart';
import '../core/encode_options.dart'; import '../core/encode_options.dart';
import '../core/languages.dart'; import '../core/languages.dart';
import '../core/mkv_command_builder.dart'; import '../core/mkv_command_builder.dart';
import '../core/webdav.dart';
import '../core/models.dart'; import '../core/models.dart';
import '../core/output_paths.dart'; import '../core/output_paths.dart';
import '../core/playlist.dart'; import '../core/playlist.dart';
@ -128,6 +129,8 @@ class AppController extends ChangeNotifier {
} }
void _applySettings() { void _applySettings() {
// WebDAV 서버 (파일 탐색기 · Rsync 화면 · 복사 · 동기화가 dav:// 경로로 쓴다)
DavRegistry.configure(settings.webdavServers);
outputRootOverride = outputRootOverride =
(settings.mkvOutputRoot?.isNotEmpty ?? false) ? settings.mkvOutputRoot : null; (settings.mkvOutputRoot?.isNotEmpty ?? false) ? settings.mkvOutputRoot : null;
} }

View File

@ -7,6 +7,7 @@ import 'package:path/path.dart' as p;
import '../core/file_ops.dart'; import '../core/file_ops.dart';
import '../core/sync_tools.dart'; import '../core/sync_tools.dart';
import '../core/vfs.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import '../platform/windows/windows_usage.dart'; import '../platform/windows/windows_usage.dart';
import 'app_controller.dart'; import 'app_controller.dart';
@ -35,7 +36,7 @@ class CopyCenter extends ChangeNotifier {
/// 새 작업: 지금 설정의 방법 · 옵션으로 /// 새 작업: 지금 설정의 방법 · 옵션으로
CopyTask fresh(List<String> sources, String dest, {bool move = false, bool contents = false, String? method}) { CopyTask fresh(List<String> sources, String dest, {bool move = false, bool contents = false, String? method}) {
final s = c.settings; 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)); var m = CopyMethod.of(method ?? (hasDir ? s.copyMethodFolder : s.copyMethodFile));
if (!copyMethodAvailable(m, s)) m = CopyMethod.builtin; if (!copyMethodAvailable(m, s)) m = CopyMethod.builtin;
return CopyTask( return CopyTask(
@ -94,8 +95,10 @@ class CopyCenter extends ChangeNotifier {
return null; return null;
} }
final method = CopyMethod.of(t.method); final method = CopyMethod.of(t.method);
final exe = method == CopyMethod.rsync ? (rsyncExe ?? await rsyncExecutable(c.settings)) : null; // WebDAV 가 끼면 rsync 없이 앱이 직접 맞춘다
if (method == CopyMethod.rsync && exe == null) return null; 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( final job = TransferJob(
sources: t.sources, sources: t.sources,
dest: t.dest, dest: t.dest,
@ -135,7 +138,7 @@ class CopyCenter extends ChangeNotifier {
/// lsync 로 옮기기: 원본 폴더마다 (원본 → 대상\이름) 실시간 동기화 쌍. 파일 원본은 넣을 수 없어 남긴다. /// lsync 로 옮기기: 원본 폴더마다 (원본 → 대상\이름) 실시간 동기화 쌍. 파일 원본은 넣을 수 없어 남긴다.
Future<int> toLiveSync(CopyTask t) async { Future<int> toLiveSync(CopyTask t) async {
final dirs = t.sources.where(FileSystemEntity.isDirectorySync).toList(); final dirs = t.sources.where((s) => isDav(s) ? vIsDirSync(s) : FileSystemEntity.isDirectorySync(s)).toList();
if (dirs.isEmpty) return 0; if (dirs.isEmpty) return 0;
final pairs = [ final pairs = [
for (final d in dirs) LiveSyncPair(d, t.contents ? t.dest : p.join(t.dest, p.basename(d)), method: t.method), 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. /// 디스크 남은 용량 · 전체 용량 (바이트). 알 수 없으면 null.
Future<(int, int)?> diskSpace(String path) async { Future<(int, int)?> diskSpace(String path) async {
try { 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.isWindows) return WindowsUsage().disk(path);
if (Platform.isAndroid) { if (Platform.isAndroid) {
final r = await const MethodChannel('jj_mkvmaker/android').invokeMethod<List<Object?>>('diskSpace', {'path': path}); final r = await const MethodChannel('jj_mkvmaker/android').invokeMethod<List<Object?>>('diskSpace', {'path': path});

View File

@ -10,6 +10,7 @@ import 'package:path/path.dart' as p;
import '../core/cron_window.dart'; import '../core/cron_window.dart';
import '../core/file_ops.dart'; import '../core/file_ops.dart';
import '../core/sync_tools.dart'; import '../core/sync_tools.dart';
import '../core/vfs.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import '../platform/windows/rsync_installer.dart'; import '../platform/windows/rsync_installer.dart';
import 'app_controller.dart'; import 'app_controller.dart';
@ -138,6 +139,7 @@ class LiveSync extends ChangeNotifier {
/// 원본과 대상의 다른 점 (크기 · 바뀐 시각). [x.delete] 면 원본에 없는 대상 항목도 "− 이름" 으로. /// 원본과 대상의 다른 점 (크기 · 바뀐 시각). [x.delete] 면 원본에 없는 대상 항목도 "− 이름" 으로.
static Future<List<String>> diff(LiveSyncPair x, {int limit = 500}) async { static Future<List<String>> diff(LiveSyncPair x, {int limit = 500}) async {
if (isDav(x.source) || isDav(x.target)) return _diffV(x, limit: limit);
final out = <String>[]; final out = <String>[];
Future<void> walk(String src, String dst, String rel) async { Future<void> walk(String src, String dst, String rel) async {
if (out.length >= limit) return; if (out.length >= limit) return;
@ -173,6 +175,43 @@ class LiveSync extends ChangeNotifier {
return out; return out;
} }
/// [diff] 의 WebDAV 판 (한쪽이라도 dav://): 크기가 다르거나, 대상이 원본보다 옛것이면
static Future<List<String>> _diffV(LiveSyncPair x, {int limit = 500}) async {
final out = <String>[];
Future<void> walk(String src, String dst, String rel) async {
if (out.length >= limit) return;
final there = <String, ({String path, bool isDir, int size, DateTime modified})>{};
try {
for (final e in await vList(dst)) {
there[vBasename(e.path)] = e;
}
} catch (_) {} // 대상 폴더가 아직 없음
final names = <String>{};
for (final e in await vList(src)) {
if (out.length >= limit) return;
final name = vBasename(e.path);
names.add(name);
final r = rel.isEmpty ? name : '$rel/$name';
if (e.isDir) {
await walk(e.path, vJoin(dst, name), r);
continue;
}
final t = there[name];
if (t == null || t.isDir || t.size != e.size || t.modified.isBefore(e.modified.subtract(const Duration(seconds: 2)))) {
out.add(r);
}
}
if (x.delete) {
for (final name in there.keys) {
if (!names.contains(name) && !name.endsWith('.jjsync')) out.add('− ${rel.isEmpty ? name : '$rel/$name'}');
}
}
}
if (await vExists(x.source)) await walk(x.source, x.target, '');
return out;
}
Future<void> refreshPending(LiveSyncPair x) async { Future<void> refreshPending(LiveSyncPair x) async {
try { try {
pending[keyOf(x)] = await diff(x); pending[keyOf(x)] = await diff(x);
@ -204,7 +243,7 @@ class LiveSync extends ChangeNotifier {
_stopAll(); _stopAll();
for (final x in pairs) { for (final x in pairs) {
final k = keyOf(x); final k = keyOf(x);
if (!Directory(x.source).existsSync()) { if (!isDav(x.source) && !Directory(x.source).existsSync()) {
status[k] = (DateTime.now(), tr('원본 폴더가 없습니다')); status[k] = (DateTime.now(), tr('원본 폴더가 없습니다'));
continue; continue;
} }
@ -213,14 +252,14 @@ class LiveSync extends ChangeNotifier {
status[k] = (DateTime.now(), tr('원본과 대상이 서로 안에 있습니다')); status[k] = (DateTime.now(), tr('원본과 대상이 서로 안에 있습니다'));
continue; continue;
} }
if (Platform.isWindows) { if (Platform.isWindows && !isDav(x.source)) {
try { try {
_watch[k] = Directory(x.source).watch(recursive: true).listen((_) => _schedule(x)); _watch[k] = Directory(x.source).watch(recursive: true).listen((_) => _schedule(x));
} catch (_) { } catch (_) {
_timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x)); _timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x));
} }
} else { } else {
// Android 등: 폴더 안쪽까지 감시가 안 되므로 정해진 간격으로 살핀다 // Android 등 · 원본이 WebDAV: 폴더 안쪽까지 감시가 안 되므로 정해진 간격으로 살핀다
_timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x)); _timers[k] = Timer.periodic(Duration(seconds: s.liveSyncIntervalSec), (_) => _schedule(x));
} }
_schedule(x); // 시작할 때 한 번 _schedule(x); // 시작할 때 한 번
@ -254,7 +293,8 @@ class LiveSync extends ChangeNotifier {
notifyListeners(); notifyListeners();
final s = c.settings; final s = c.settings;
try { 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) { final n = switch (method) {
CopyMethod.builtin => await FileOps(bandwidthKBps: s.copyBandwidthKBps).mirror(x.source, x.target, delete: x.delete), CopyMethod.builtin => await FileOps(bandwidthKBps: s.copyBandwidthKBps).mirror(x.source, x.target, delete: x.delete),
CopyMethod.rsync => await _rsync(x, s), CopyMethod.rsync => await _rsync(x, s),

View File

@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart';
import '../core/download_detect.dart'; import '../core/download_detect.dart';
import '../core/encode_options.dart'; import '../core/encode_options.dart';
import '../core/playlist.dart'; import '../core/playlist.dart';
import '../core/webdav.dart';
import '../services/app_shell.dart' show appIconOf; import '../services/app_shell.dart' show appIconOf;
/// MKV 세부 정보의 이동 버튼 하나: 표시 이름 · 옮길 폴더 /// MKV 세부 정보의 이동 버튼 하나: 표시 이름 · 옮길 폴더
@ -331,6 +332,9 @@ class AppSettings {
/// Rsync 화면에서 마지막으로 연 폴더 (왼쪽 · 오른쪽) /// Rsync 화면에서 마지막으로 연 폴더 (왼쪽 · 오른쪽)
List<String> rsyncPaths = []; List<String> rsyncPaths = [];
/// WebDAV 서버 (파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆 탭). 비밀번호는 이 설정 파일에만.
List<DavServer> webdavServers = [];
/// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다. /// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다.
int liveSyncIntervalSec = 30; int liveSyncIntervalSec = 30;
@ -440,6 +444,7 @@ class AppSettings {
'copyMethodFile': copyMethodFile, 'copyMethodFile': copyMethodFile,
'copyMethodFolder': copyMethodFolder, 'copyMethodFolder': copyMethodFolder,
'rsyncPaths': rsyncPaths, 'rsyncPaths': rsyncPaths,
'webdavServers': [for (final x in webdavServers) x.toJson()],
'rsyncOptions': rsyncOptions, 'rsyncOptions': rsyncOptions,
'robocopyOptions': robocopyOptions, 'robocopyOptions': robocopyOptions,
'copyRunMode': copyRunMode, 'copyRunMode': copyRunMode,
@ -545,6 +550,10 @@ class AppSettings {
..copyMethodFile = _method(j['copyMethodFile']) ..copyMethodFile = _method(j['copyMethodFile'])
..copyMethodFolder = _method(j['copyMethodFolder']) ..copyMethodFolder = _method(j['copyMethodFolder'])
..rsyncPaths = [for (final x in (j['rsyncPaths'] as List?) ?? const []) '$x'] ..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' ..rsyncOptions = j['rsyncOptions'] as String? ?? '-avPog'
..robocopyOptions = j['robocopyOptions'] as String? ?? '/E /COPY:DAT /DCOPY:T /R:2 /W:2' ..robocopyOptions = j['robocopyOptions'] as String? ?? '/E /COPY:DAT /DCOPY:T /R:2 /W:2'
..copyRunMode = j['copyRunMode'] == 'once' ? 'once' : 'each' ..copyRunMode = j['copyRunMode'] == 'once' ? 'once' : 'each'

View File

@ -7,6 +7,7 @@ import 'package:path/path.dart' as p;
import '../core/file_ops.dart'; import '../core/file_ops.dart';
import '../core/sync_tools.dart'; import '../core/sync_tools.dart';
import '../core/vfs.dart';
/// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다: /// 복사 · 이동 한 번 (파일 탐색기). 방법 (현재 방식 · rsync · robocopy) 과 상관없이 같은 진행 상태를 알린다:
/// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률 /// - 위쪽: 고른 항목 (폴더) 중 몇 번째인지 · 전체 진행률
@ -133,7 +134,14 @@ class TransferJob extends ChangeNotifier {
void _tick() => notifyListeners(); void _tick() => notifyListeners();
/// 한쪽이라도 WebDAV 면 rsync · robocopy 대신 앱이 직접 (크기 · 시각 비교) 맞춘다
bool get viaWebDav => isDav(dest) || sources.any(isDav);
/// rsync -u (--update) 를 옵션에 넣었는지 (앱이 맞출 때도 같은 뜻으로: 대상이 더 새것이면 건너뜀)
bool get _update => splitOptions(options).any((o) => o == '--update' || (o.startsWith('-') && !o.startsWith('--') && o.contains('u')));
static Future<int> countFiles(String path) async { static Future<int> countFiles(String path) async {
if (isDav(path)) return vCountFiles(path);
if (!FileSystemEntity.isDirectorySync(path)) return 1; if (!FileSystemEntity.isDirectorySync(path)) return 1;
var n = 0; var n = 0;
await for (final e in Directory(path).list(recursive: true, followLinks: false).handleError((_) {})) { await for (final e in Directory(path).list(recursive: true, followLinks: false).handleError((_) {})) {
@ -146,14 +154,14 @@ class TransferJob extends ChangeNotifier {
try { try {
for (var i = 0; i < sources.length; i++) { for (var i = 0; i < sources.length; i++) {
final s = sources[i]; final s = sources[i];
if (FileSystemEntity.isDirectorySync(s) && isSameOrInside(dest, s)) { if ((isDav(s) || FileSystemEntity.isDirectorySync(s)) && isSameOrInside(dest, s)) {
throw FileSystemException('폴더를 자기 안으로 복사 · 이동할 수 없습니다', s); throw FileSystemException('폴더를 자기 안으로 복사 · 이동할 수 없습니다', s);
} }
filesTotal[i] = await countFiles(s); filesTotal[i] = await countFiles(s);
} }
counting = false; counting = false;
_tick(); _tick();
switch (method) { switch (viaWebDav ? CopyMethod.builtin : method) {
case CopyMethod.builtin: case CopyMethod.builtin:
await _runBuiltin(); await _runBuiltin();
case CopyMethod.rsync: case CopyMethod.rsync:
@ -167,7 +175,7 @@ class TransferJob extends ChangeNotifier {
pruning = true; pruning = true;
_tick(); _tick();
for (final s in sources) { 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) { } catch (e) {
@ -184,17 +192,21 @@ class TransferJob extends ChangeNotifier {
for (index = 0; index < sources.length; index++) { for (index = 0; index < sources.length; index++) {
if (cancelled) return; if (cancelled) return;
final i = index; final i = index;
_ops = FileOps(bandwidthKBps: bandwidthKBps, onFileDone: (src) { _ops = FileOps(
filesDone[i]++; bandwidthKBps: bandwidthKBps,
currentFile = p.basename(src); onFileDone: (src) {
// 옮긴 (이름 바꾼) 파일은 원본이 없어 0 filesDone[i]++;
final st = FileStat.statSync(src); currentFile = vBasename(src);
_addBytes(st.type == FileSystemEntityType.file ? st.size : 0); _tick();
_tick(); },
}); // 보낸 양으로 전송 속도 (WebDAV 포함)
onBytes: _addBytes,
);
_tick(); _tick();
if (contents && FileSystemEntity.isDirectorySync(sources[i])) { if (contents && (isDav(sources[i]) || FileSystemEntity.isDirectorySync(sources[i]))) {
await _ops!.mirror(sources[i], dest); // 폴더 "안의 것" 맞추기 (rsync 원본/ 대상/ 과 같은 뜻): -u · 원본 파일 지우기 (--remove-source-files) 도
await _ops!.mirror(sources[i], dest, update: _update);
if (move && !cancelled) await _deleteFilesIn(sources[i]);
made.add(dest); made.add(dest);
} else { } else {
final r = move ? await _ops!.move([sources[i]], dest) : await _ops!.copy([sources[i]], dest); 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; index = sources.length;
} }
/// 맞춘 뒤 원본의 파일만 지운다 (rsync --remove-source-files 처럼, 빈 폴더는 [prune] 이 정리)
Future<void> _deleteFilesIn(String dir) async {
for (final e in await vList(dir)) {
if (cancelled) return;
if (e.isDir) {
await _deleteFilesIn(e.path);
} else {
await vDelete(e.path);
}
}
}
void _addLog(String line) { void _addLog(String line) {
if (line.trim().isEmpty) return; if (line.trim().isEmpty) return;
log.add(line.trim()); log.add(line.trim());

View File

@ -62,10 +62,18 @@ class VersionSnapshot {
Future<void> restoreFrom(String from) async { Future<void> restoreFrom(String from) async {
final current = File(p.join(dataDir, 'settings.json')); final current = File(p.join(dataDir, 'settings.json'));
Map<String, dynamic> secrets = {}; Map<String, dynamic> secrets = {};
Map<String, Object?> davPasswords = {};
if (await current.exists()) { if (await current.exists()) {
try { try {
final j = jsonDecode(await current.readAsString()) as Map<String, dynamic>; final j = jsonDecode(await current.readAsString()) as Map<String, dynamic>;
secrets = {for (final k in secretKeys) if (j[k] != null) k: j[k]}; 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 (_) {} } catch (_) {}
} }
for (final f in files) { for (final f in files) {
@ -75,6 +83,12 @@ class VersionSnapshot {
if (f == 'settings.json') { if (f == 'settings.json') {
final j = jsonDecode(await src.readAsString()) as Map<String, dynamic>; final j = jsonDecode(await src.readAsString()) as Map<String, dynamic>;
j.addAll(secrets); 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)); await dst.writeAsString(const JsonEncoder.withIndent(' ').convert(j));
} else { } else {
await src.copy(dst.path); await src.copy(dst.path);
@ -104,6 +118,13 @@ class VersionSnapshot {
for (final k in secretKeys) { for (final k in secretKeys) {
j.remove(k); 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); return const JsonEncoder.withIndent(' ').convert(j);
} catch (_) { } catch (_) {
return '{}'; return '{}';

View File

@ -5,6 +5,7 @@ import 'package:path/path.dart' as p;
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'playlist.dart' show naturalCompare; import 'playlist.dart' show naturalCompare;
import 'vfs.dart';
/// 파일 탐색기 (X-plore 참고) 의 화면과 상관없는 부분: 폴더 읽기 · 정렬 · 복사 / 이동 / 삭제 · 찾기. /// 파일 탐색기 (X-plore 참고) 의 화면과 상관없는 부분: 폴더 읽기 · 정렬 · 복사 / 이동 / 삭제 · 찾기.
@ -44,6 +45,14 @@ List<FileEntry> sortEntries(List<FileEntry> list, SortBy by, {bool descending =
/// 폴더 읽기 (읽을 수 없는 항목은 건너뜀). [showHidden] 이 아니면 점으로 시작하는 항목을 뺀다. /// 폴더 읽기 (읽을 수 없는 항목은 건너뜀). [showHidden] 이 아니면 점으로 시작하는 항목을 뺀다.
Future<List<FileEntry>> listEntries(String dir, {bool showHidden = false}) async { Future<List<FileEntry>> listEntries(String dir, {bool showHidden = false}) async {
if (isDav(dir)) {
// WebDAV: 네트워크 오류 · 인증 실패는 그대로 던진다 (화면이 "읽을 수 없음" 과 이유를 보여 줌)
return [
for (final e in await vList(dir))
if (showHidden || !vBasename(e.path).startsWith('.'))
FileEntry(e.path, isDir: e.isDir, size: e.size, modified: e.modified),
];
}
final out = <FileEntry>[]; final out = <FileEntry>[];
await for (final e in Directory(dir).list(followLinks: false).handleError((_) {})) { await for (final e in Directory(dir).list(followLinks: false).handleError((_) {})) {
try { try {
@ -76,12 +85,16 @@ String uniqueTarget(String dir, String name) {
/// 같은 경로인지 (Windows 는 대소문자 무시) /// 같은 경로인지 (Windows 는 대소문자 무시)
bool samePath(String a, String b) { 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)); final x = p.normalize(p.absolute(a)), y = p.normalize(p.absolute(b));
return Platform.isWindows ? x.toLowerCase() == y.toLowerCase() : x == y; return Platform.isWindows ? x.toLowerCase() == y.toLowerCase() : x == y;
} }
/// [a] 가 [b] 와 같거나 그 안에 있는지 (폴더를 자기 안으로 복사 · 이동하지 않게) /// [a] 가 [b] 와 같거나 그 안에 있는지 (폴더를 자기 안으로 복사 · 이동하지 않게)
bool isSameOrInside(String a, String 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)); 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())); return samePath(a, b) || p.isWithin(y, x) || (Platform.isWindows && p.isWithin(y.toLowerCase(), x.toLowerCase()));
} }
@ -92,16 +105,17 @@ bool isSameOrInside(String a, String b) {
/// - 폴더를 자기 자신 (또는 그 안) 으로 복사 · 이동 /// - 폴더를 자기 자신 (또는 그 안) 으로 복사 · 이동
/// - 이미 그 폴더에 있는 것을 같은 폴더로 이동 /// - 이미 그 폴더에 있는 것을 같은 폴더로 이동
String? transferProblem(List<String> sources, String dest, {required bool move}) { String? transferProblem(List<String> sources, String dest, {required bool move}) {
final missing = [for (final s in sources) if (FileSystemEntity.typeSync(s) == FileSystemEntityType.notFound) s]; // WebDAV 경로는 네트워크라 여기서 있는지 확인하지 않는다 (없으면 복사할 때 알림)
final missing = [for (final s in sources) if (vMissingSync(s)) s];
if (missing.isNotEmpty) { 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) { for (final s in sources) {
if (FileSystemEntity.isDirectorySync(s) && isSameOrInside(dest, s)) { if ((isDav(s) || FileSystemEntity.isDirectorySync(s)) && isSameOrInside(dest, s)) {
return trf('폴더를 자기 자신 안으로 {0} 수 없습니다: {1}', [move ? tr('옮길') : tr('복사할'), p.basename(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; return null;
} }
@ -109,6 +123,7 @@ String? transferProblem(List<String> sources, String dest, {required bool move})
/// [root] 안의 빈 폴더를 안쪽부터 지운다 (find root/ -type d -empty -delete 와 같음). 지운 폴더 수. /// [root] 안의 빈 폴더를 안쪽부터 지운다 (find root/ -type d -empty -delete 와 같음). 지운 폴더 수.
/// [keepRoot] 면 [root] 자신은 비어도 남긴다. 파일 · 링크가 하나라도 있는 폴더는 그대로. /// [keepRoot] 면 [root] 자신은 비어도 남긴다. 파일 · 링크가 하나라도 있는 폴더는 그대로.
Future<int> removeEmptyDirs(String root, {bool keepRoot = true}) async { Future<int> removeEmptyDirs(String root, {bool keepRoot = true}) async {
if (isDav(root)) return _removeEmptyDirsV(root, keepRoot: keepRoot);
var n = 0; var n = 0;
Future<bool> walk(Directory d) async { Future<bool> walk(Directory d) async {
var empty = true; var empty = true;
@ -142,6 +157,36 @@ Future<int> removeEmptyDirs(String root, {bool keepRoot = true}) async {
/// 복사 · 이동 중 알림 (지금 파일, 지금까지 바이트, 전체 바이트) /// 복사 · 이동 중 알림 (지금 파일, 지금까지 바이트, 전체 바이트)
typedef CopyProgress = void Function(String current, int done, int total); typedef CopyProgress = void Function(String current, int done, int total);
/// [removeEmptyDirs] 의 WebDAV 판
Future<int> _removeEmptyDirsV(String root, {bool keepRoot = true}) async {
var n = 0;
Future<bool> walk(String dir) async {
var empty = true;
for (final e in await vList(dir)) {
if (e.isDir && await walk(e.path)) {
try {
await vDelete(e.path);
n++;
} catch (_) {
empty = false;
}
} else {
empty = false;
}
}
return empty;
}
if (!await vExists(root)) return 0;
if (await walk(root) && !keepRoot) {
try {
await vDelete(root);
n++;
} catch (_) {}
}
return n;
}
class FileOpCancelled implements Exception { class FileOpCancelled implements Exception {
const FileOpCancelled(); const FileOpCancelled();
} }
@ -157,7 +202,13 @@ class FileOps {
/// 파일 하나를 다 복사할 때마다 (원본 경로) /// 파일 하나를 다 복사할 때마다 (원본 경로)
final void Function(String source)? onFileDone; final void Function(String source)? onFileDone;
FileOps({this.bandwidthKBps = 0, this.onFileDone}); /// 조각을 보낼 때마다 (바이트) - 전송 속도 계산용
final void Function(int bytes)? onBytes;
FileOps({this.bandwidthKBps = 0, this.onFileDone, this.onBytes});
/// 한쪽이라도 WebDAV 면 아래의 "V" (어느 저장소든) 판으로
static bool _anyDav(Iterable<String> paths) => paths.any(isDav);
final _clock = Stopwatch(); final _clock = Stopwatch();
int _sent = 0; int _sent = 0;
@ -178,6 +229,7 @@ class FileOps {
/// 전체 크기 (진행률용) /// 전체 크기 (진행률용)
static Future<int> totalSize(List<String> paths) async { static Future<int> totalSize(List<String> paths) async {
if (_anyDav(paths)) return _totalSizeV(paths);
var total = 0; var total = 0;
for (final s in paths) { for (final s in paths) {
final t = FileSystemEntity.typeSync(s, followLinks: false); final t = FileSystemEntity.typeSync(s, followLinks: false);
@ -198,6 +250,7 @@ class FileOps {
/// [sources] 를 [destDir] 로 복사 (같은 이름이 있으면 "이름 (2)"). 만든 경로들을 돌려준다. /// [sources] 를 [destDir] 로 복사 (같은 이름이 있으면 "이름 (2)"). 만든 경로들을 돌려준다.
Future<List<String>> copy(List<String> sources, String destDir, {CopyProgress? onProgress}) async { Future<List<String>> copy(List<String> sources, String destDir, {CopyProgress? onProgress}) async {
if (_anyDav([...sources, destDir])) return _copyV(sources, destDir, onProgress: onProgress);
final total = await totalSize(sources); final total = await totalSize(sources);
var done = 0; var done = 0;
final made = <String>[]; final made = <String>[];
@ -211,6 +264,7 @@ class FileOps {
out.add(chunk); out.add(chunk);
done += chunk.length; done += chunk.length;
onProgress?.call(f.path, done, total); onProgress?.call(f.path, done, total);
onBytes?.call(chunk.length);
await _throttle(chunk.length); await _throttle(chunk.length);
} }
} finally { } finally {
@ -254,6 +308,7 @@ class FileOps {
/// 이동: 같은 드라이브면 이름 바꾸기, 아니면 복사한 뒤 원본 삭제 /// 이동: 같은 드라이브면 이름 바꾸기, 아니면 복사한 뒤 원본 삭제
Future<List<String>> move(List<String> sources, String destDir, {CopyProgress? onProgress}) async { Future<List<String>> move(List<String> sources, String destDir, {CopyProgress? onProgress}) async {
if (_anyDav([...sources, destDir])) return _moveV(sources, destDir, onProgress: onProgress);
final made = <String>[]; final made = <String>[];
for (final s in sources) { for (final s in sources) {
_check(); _check();
@ -283,7 +338,10 @@ class FileOps {
/// 동기화 (실시간 동기화 · lsyncd 처럼): [srcDir] 의 내용을 [dstDir] 에 맞춘다. /// 동기화 (실시간 동기화 · lsyncd 처럼): [srcDir] 의 내용을 [dstDir] 에 맞춘다.
/// 크기나 바뀐 시각이 다른 파일만 복사, [delete] 면 원본에 없는 것을 대상에서 지운다. 복사한 파일 수를 돌려준다. /// 크기나 바뀐 시각이 다른 파일만 복사, [delete] 면 원본에 없는 것을 대상에서 지운다. 복사한 파일 수를 돌려준다.
Future<int> mirror(String srcDir, String dstDir, {bool delete = false}) async { ///
/// [update] (rsync -u): 대상이 더 새 파일이면 건너뛴다 (양쪽 ⇄ 을 함께 맞출 때 서로 덮어쓰지 않게).
Future<int> mirror(String srcDir, String dstDir, {bool delete = false, bool update = false}) async {
if (_anyDav([srcDir, dstDir])) return _mirrorV(srcDir, dstDir, delete: delete, update: update);
var n = 0; var n = 0;
Future<void> walk(String src, String dst) async { Future<void> walk(String src, String dst) async {
_check(); _check();
@ -298,16 +356,19 @@ class FileOps {
} else if (e is File) { } else if (e is File) {
final st = await e.stat(); final st = await e.stat();
final t = File(target); final t = File(target);
final same = await t.exists() && final exists = await t.exists();
final same = exists &&
(await t.length()) == st.size && (await t.length()) == st.size &&
((await t.lastModified()).difference(st.modified).inSeconds.abs() <= 2); ((await t.lastModified()).difference(st.modified).inSeconds.abs() <= 2);
if (same) continue; if (same) continue;
if (update && exists && (await t.lastModified()).isAfter(st.modified.add(const Duration(seconds: 2)))) continue;
final tmp = '$target.jjsync'; final tmp = '$target.jjsync';
final out = File(tmp).openWrite(); final out = File(tmp).openWrite();
try { try {
await for (final chunk in e.openRead()) { await for (final chunk in e.openRead()) {
_check(); _check();
out.add(chunk); out.add(chunk);
onBytes?.call(chunk.length);
await _throttle(chunk.length); await _throttle(chunk.length);
} }
} finally { } finally {
@ -337,6 +398,10 @@ class FileOps {
Future<void> delete(List<String> paths) async { Future<void> delete(List<String> paths) async {
for (final s in paths) { for (final s in paths) {
_check(); _check();
if (isDav(s)) {
await vDelete(s);
continue;
}
final t = FileSystemEntity.typeSync(s, followLinks: false); final t = FileSystemEntity.typeSync(s, followLinks: false);
if (t == FileSystemEntityType.directory) { if (t == FileSystemEntityType.directory) {
await Directory(s).delete(recursive: true); await Directory(s).delete(recursive: true);
@ -352,6 +417,13 @@ class FileOps {
if (name.isEmpty || name.contains(RegExp(r'[\\/:*?"<>|]'))) { if (name.isEmpty || name.contains(RegExp(r'[\\/:*?"<>|]'))) {
throw FileSystemException('쓸 수 없는 이름입니다', name); 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); final target = p.join(p.dirname(path), name);
if (target == path) return path; if (target == path) return path;
if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound && if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound &&
@ -367,12 +439,166 @@ class FileOps {
static Future<String> makeFolder(String dir, String name) async { static Future<String> makeFolder(String dir, String name) async {
final n = name.trim(); final n = name.trim();
if (n.isEmpty || n.contains(RegExp(r'[\\/:*?"<>|]'))) throw FileSystemException('쓸 수 없는 이름입니다', n); 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); final target = p.join(dir, n);
if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound) { if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound) {
throw FileSystemException('같은 이름이 이미 있습니다', target); throw FileSystemException('같은 이름이 이미 있습니다', target);
} }
return (await Directory(target).create()).path; return (await Directory(target).create()).path;
} }
// ───────── 어느 저장소든 (로컬 · WebDAV) ─────────
static Future<int> _totalSizeV(List<String> paths) async {
var total = 0;
Future<void> walk(String path) async {
final st = await vStat(path);
if (st == null) return;
if (!st.isDir) {
total += st.size;
return;
}
for (final e in await vList(path)) {
if (e.isDir) {
await walk(e.path);
} else {
total += e.size;
}
}
}
for (final s in paths) {
await walk(s);
}
return total;
}
/// 파일 하나: 읽어서 쓴다 (속도 제한 · 취소 · 진행)
Future<void> _copyFileV(String src, String target, int size, DateTime modified, void Function(int n)? onChunk) async {
_check();
final input = await vOpenRead(src);
final relay = StreamController<List<int>>();
final pump = () async {
try {
await for (final chunk in input) {
if (_cancel) break;
relay.add(chunk);
onBytes?.call(chunk.length);
onChunk?.call(chunk.length);
await _throttle(chunk.length);
}
} catch (e, st) {
relay.addError(e, st);
} finally {
await relay.close();
}
}();
try {
await vWrite(target, relay.stream, length: size, modified: modified);
} finally {
await pump;
}
if (_cancel) {
try {
await vDelete(target);
} catch (_) {}
throw const FileOpCancelled();
}
onFileDone?.call(src);
}
Future<List<String>> _copyV(List<String> sources, String destDir, {CopyProgress? onProgress}) async {
final total = await _totalSizeV(sources);
var done = 0;
final made = <String>[];
Future<void> copyAny(String src, String target) async {
_check();
final st = await vStat(src);
if (st == null) throw FileSystemException('원본이 없습니다', src);
if (st.isDir) {
await vMkdirs(target);
for (final e in await vList(src)) {
await copyAny(e.path, vJoin(target, vBasename(e.path)));
}
} else {
await _copyFileV(src, target, st.size, st.modified, (n) {
done += n;
onProgress?.call(src, done, total);
});
}
}
for (final s in sources) {
if (isSameOrInside(destDir, s) && await vIsDir(s)) throw FileSystemException('폴더를 자기 안으로 복사할 수 없습니다', s);
final target = await vUniqueTarget(destDir, vBasename(s));
await copyAny(s, target);
made.add(target);
}
return made;
}
Future<List<String>> _moveV(List<String> sources, String destDir, {CopyProgress? onProgress}) async {
final made = <String>[];
for (final s in sources) {
_check();
if (isSameOrInside(destDir, s)) throw FileSystemException('폴더를 자기 안으로 옮길 수 없습니다', s);
if (samePath(vDirname(s), destDir)) {
made.add(s);
continue;
}
final target = await vUniqueTarget(destDir, vBasename(s));
// 같은 WebDAV 서버 안이면 서버가 옮긴다 (빠름)
if (isDav(s) && isDav(destDir) && DavPath.parse(s).server == DavPath.parse(destDir).server) {
await vRename(s, target);
made.add(target);
continue;
}
made.addAll(await _copyV([s], destDir, onProgress: onProgress));
await vDelete(s);
}
return made;
}
Future<int> _mirrorV(String srcDir, String dstDir, {bool delete = false, bool update = false}) async {
var n = 0;
Future<void> walk(String src, String dst) async {
_check();
await vMkdirs(dst);
final there = {for (final e in await vList(dst)) vBasename(e.path): e};
final names = <String>{};
for (final e in await vList(src)) {
final name = vBasename(e.path);
names.add(name);
final target = vJoin(dst, name);
if (e.isDir) {
await walk(e.path, target);
continue;
}
final t = there[name];
if (t != null && !t.isDir && t.size == e.size) {
// 크기가 같고 대상이 원본보다 새것이거나 (올린 파일은 서버 시각) 거의 같으면 그대로
if (!t.modified.isBefore(e.modified.subtract(const Duration(seconds: 2)))) continue;
}
if (update && t != null && t.modified.isAfter(e.modified.add(const Duration(seconds: 2)))) continue;
if (t != null && t.isDir) await vDelete(target);
await _copyFileV(e.path, target, e.size, e.modified, null);
n++;
}
if (delete) {
for (final name in there.keys) {
if (!names.contains(name) && !name.endsWith('.jjsync')) await vDelete(vJoin(dst, name));
}
}
}
await walk(srcDir, dstDir);
return n;
}
} }
/// [root] 아래에서 이름에 [query] 가 들어간 항목 찾기 (대소문자 무시, * ? 사용 가능). 찾는 대로 내보낸다. /// [root] 아래에서 이름에 [query] 가 들어간 항목 찾기 (대소문자 무시, * ? 사용 가능). 찾는 대로 내보낸다.

227
lib/core/vfs.dart Normal file
View File

@ -0,0 +1,227 @@
import 'dart:async';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'webdav.dart';
/// 로컬 경로와 WebDAV 경로 (`dav://<서버 id>/<경로>`) 를 같이 다루는 도구.
/// 로컬 경로는 원래 코드 (dart:io) 를 그대로 쓰고, dav:// 만 여기서 WebDAV 로 처리한다.
const davScheme = 'dav://';
bool isDav(String path) => path.startsWith(davScheme);
/// dav:// 경로: 서버 id 와 서버 기준 경로 ("/" 로 시작, 끝 / 없음, 맨 위는 "/")
class DavPath {
final String server;
final String rel;
const DavPath(this.server, this.rel);
/// "dav://id/a/b" · "dav://id/a\b" (p.join 이 붙인 \ ) · "dav://id" → 정리
static DavPath parse(String path) {
var rest = path.substring(davScheme.length).replaceAll('\\', '/');
final slash = rest.indexOf('/');
final server = slash < 0 ? rest : rest.substring(0, slash);
var rel = slash < 0 ? '/' : rest.substring(slash);
rel = rel.replaceAll(RegExp(r'/+'), '/');
if (rel.length > 1 && rel.endsWith('/')) rel = rel.substring(0, rel.length - 1);
if (rel.isEmpty) rel = '/';
return DavPath(server, rel);
}
bool get isRoot => rel == '/';
String get full => isRoot ? '$davScheme$server/' : '$davScheme$server$rel';
String get name => isRoot ? '' : rel.substring(rel.lastIndexOf('/') + 1);
DavPath get parent => isRoot ? this : DavPath(server, rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/')));
DavPath child(String n) => DavPath(server, isRoot ? '/$n' : '$rel/$n');
DavClient get client => DavRegistry.client(server);
}
/// 경로를 한 모양으로 (dav:// 만 정리, 로컬은 그대로)
String vNorm(String path) => isDav(path) ? DavPath.parse(path).full : path;
String vJoin(String dir, String name) => isDav(dir) ? DavPath.parse(dir).child(name).full : p.join(dir, name);
String vDirname(String path) => isDav(path) ? DavPath.parse(path).parent.full : p.dirname(path);
String vBasename(String path) => isDav(path) ? DavPath.parse(path).name : p.basename(path);
/// 같은 서버의 dav 경로끼리 같은지 · 안에 있는지 (로컬은 null - 원래 함수가 판단)
bool? davSame(String a, String b) {
if (!isDav(a) && !isDav(b)) return null;
if (isDav(a) != isDav(b)) return false;
return DavPath.parse(a).full == DavPath.parse(b).full;
}
bool? davInside(String a, String b) {
if (!isDav(a) && !isDav(b)) return null;
if (isDav(a) != isDav(b)) return false;
final x = DavPath.parse(a), y = DavPath.parse(b);
if (x.server != y.server) return false;
return x.rel == y.rel || y.isRoot || x.rel.startsWith('${y.rel}/');
}
/// 항목 정보 (없으면 null)
Future<({bool isDir, int size, DateTime modified})?> vStat(String path) async {
if (isDav(path)) {
final d = DavPath.parse(path);
final s = await d.client.stat(d.rel);
return s == null ? null : (isDir: s.isDir, size: s.size, modified: s.modified);
}
final st = await FileStat.stat(path);
if (st.type == FileSystemEntityType.notFound) return null;
return (isDir: st.type == FileSystemEntityType.directory, size: st.size, modified: st.modified);
}
Future<bool> vIsDir(String path) async => (await vStat(path))?.isDir ?? false;
Future<bool> vExists(String path) async => (await vStat(path)) != null;
/// 동기 확인 (dav 는 네트워크라 알 수 없으므로 "있다" 로 본다)
bool vMissingSync(String path) => !isDav(path) && FileSystemEntity.typeSync(path) == FileSystemEntityType.notFound;
bool vIsDirSync(String path) => isDav(path) ? !DavPath.parse(path).name.contains('.') : FileSystemEntity.isDirectorySync(path);
/// 폴더 안의 항목: (경로, 폴더인지, 크기, 바뀐 때)
Future<List<({String path, bool isDir, int size, DateTime modified})>> vList(String dir) async {
if (isDav(dir)) {
final d = DavPath.parse(dir);
return [
for (final i in await d.client.list(d.rel))
(path: DavPath(d.server, i.rel).full, isDir: i.isDir, size: i.size, modified: i.modified),
];
}
final out = <({String path, bool isDir, int size, DateTime modified})>[];
await for (final e in Directory(dir).list(followLinks: false).handleError((_) {})) {
try {
final st = await e.stat();
final isDir = st.type == FileSystemEntityType.directory;
out.add((path: e.path, isDir: isDir, size: isDir ? 0 : st.size, modified: st.modified));
} catch (_) {}
}
return out;
}
Future<void> vMkdirs(String dir) async {
if (isDav(dir)) {
final d = DavPath.parse(dir);
await d.client.mkdirs(d.rel);
} else {
await Directory(dir).create(recursive: true);
}
}
/// 지우기 (폴더면 안의 것까지)
Future<void> vDelete(String path) async {
if (isDav(path)) {
final d = DavPath.parse(path);
await d.client.delete(d.rel);
return;
}
final t = FileSystemEntity.typeSync(path, followLinks: false);
if (t == FileSystemEntityType.directory) {
await Directory(path).delete(recursive: true);
} else if (t != FileSystemEntityType.notFound) {
await File(path).delete();
}
}
Future<Stream<List<int>>> vOpenRead(String path) async {
if (isDav(path)) {
final d = DavPath.parse(path);
return d.client.openRead(d.rel);
}
return File(path).openRead();
}
/// [data] 를 [path] 에 쓴다 (로컬은 임시 이름에 쓴 뒤 바꾸고, [modified] 가 있으면 바뀐 때도 맞춘다)
Future<void> vWrite(String path, Stream<List<int>> data, {int? length, DateTime? modified}) async {
if (isDav(path)) {
final d = DavPath.parse(path);
await d.client.write(d.rel, data, length: length);
return;
}
final tmp = '$path.jjsync';
final out = File(tmp).openWrite();
try {
await out.addStream(data);
} finally {
await out.close();
}
if (await File(path).exists()) await File(path).delete();
await File(tmp).rename(path);
if (modified != null) {
try {
await File(path).setLastModified(modified);
} catch (_) {}
}
}
/// 같은 서버 안에서 이름 바꾸기 · 옮기기
Future<void> vRename(String from, String to) async {
if (isDav(from) && isDav(to)) {
final a = DavPath.parse(from), b = DavPath.parse(to);
if (a.server == b.server) {
await a.client.move(a.rel, b.rel);
return;
}
}
if (isDav(from) || isDav(to)) throw FileSystemException('다른 저장소로는 이름만 바꿀 수 없습니다', from);
if (FileSystemEntity.isDirectorySync(from)) {
await Directory(from).rename(to);
} else {
await File(from).rename(to);
}
}
/// [dir] 안에서 겹치지 않는 이름 ("이름 (2).확장자")
Future<String> vUniqueTarget(String dir, String name) async {
var target = vJoin(dir, name);
if (!await vExists(target)) return target;
final ext = p.extension(name), base = p.basenameWithoutExtension(name);
for (var i = 2;; i++) {
target = vJoin(dir, '$base ($i)$ext');
if (!await vExists(target)) return target;
}
}
/// 원격 파일을 열거나 재생하려면 로컬로 받아야 한다: 임시 폴더에 받은 경로 (로컬이면 그대로)
Future<String> vLocalCopy(String path, String tempDir, {void Function(int done, int total)? onProgress}) async {
if (!isDav(path)) return path;
final d = DavPath.parse(path);
final st = await d.client.stat(d.rel);
final dir = Directory(p.join(tempDir, 'jj_webdav', d.server, p.dirname(d.rel.substring(1))));
await dir.create(recursive: true);
final out = File(p.join(dir.path, d.name));
// 이미 받아 둔 것 (크기 · 바뀐 때 같음) 은 다시 받지 않는다
if (st != null && await out.exists() && await out.length() == st.size && !(await out.lastModified()).isBefore(st.modified)) {
return out.path;
}
var done = 0;
final sink = out.openWrite();
try {
await for (final chunk in await d.client.openRead(d.rel)) {
sink.add(chunk);
done += chunk.length;
onProgress?.call(done, st?.size ?? 0);
}
} finally {
await sink.close();
}
return out.path;
}
/// 폴더 안 파일 수 (안쪽까지)
Future<int> vCountFiles(String path) async {
if (!await vIsDir(path)) return 1;
var n = 0;
for (final e in await vList(path)) {
n += e.isDir ? await vCountFiles(e.path) : 1;
}
return n;
}
/// 화면에 보일 경로: WebDAV 는 내부 id 대신 "☁ 서버 이름/경로"
String vDisplay(String path) => isDav(path) ? '☁ ${vVolumeLabel(path)}' : path;
/// 저장소 이름 (화면 표시): dav 는 서버 이름
String vVolumeLabel(String path) {
if (!isDav(path)) return path;
final d = DavPath.parse(path);
return '${DavRegistry.server(d.server)?.label ?? 'WebDAV'}${d.rel == '/' ? '' : d.rel}';
}

305
lib/core/webdav.dart Normal file
View File

@ -0,0 +1,305 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:xml/xml.dart';
/// WebDAV 서버 설정 (환경 설정 > 파일 탐색기 > WebDAV). 비밀번호는 설정 파일에만 (버전별 설정 보관본에는 넣지 않음).
class DavServer {
final String id;
final String name;
/// 예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/me
final String url;
final String user;
final String password;
/// 인증서를 확인하지 않음 (자체 서명 인증서를 쓰는 집 NAS 등)
final bool insecure;
const DavServer({
required this.id,
required this.name,
required this.url,
this.user = '',
this.password = '',
this.insecure = false,
});
/// 탐색기 위쪽에 보일 이름
String get label => name.trim().isNotEmpty ? name.trim() : (Uri.tryParse(url)?.host ?? 'WebDAV');
DavServer copyWith({String? name, String? url, String? user, String? password, bool? insecure}) => DavServer(
id: id,
name: name ?? this.name,
url: url ?? this.url,
user: user ?? this.user,
password: password ?? this.password,
insecure: insecure ?? this.insecure,
);
Map<String, Object?> toJson() =>
{'id': id, 'name': name, 'url': url, 'user': user, 'password': password, 'insecure': insecure};
factory DavServer.fromJson(Map<Object?, Object?> j) => DavServer(
id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}',
name: j['name'] as String? ?? '',
url: j['url'] as String? ?? '',
user: j['user'] as String? ?? '',
password: j['password'] as String? ?? '',
insecure: j['insecure'] == true,
);
}
/// 목록의 한 항목 ([rel]: 서버 기준 경로, "/" 로 시작, 끝 / 없음 · 맨 위는 "/")
class DavItem {
final String rel;
final bool isDir;
final int size;
final DateTime modified;
const DavItem(this.rel, {required this.isDir, this.size = 0, required this.modified});
}
class DavException implements Exception {
final String message;
final int? status;
const DavException(this.message, {this.status});
@override
String toString() => message;
}
/// WebDAV 클라이언트: PROPFIND (목록) · GET · PUT · MKCOL · DELETE · MOVE · COPY. 기본 인증 (Basic).
class DavClient {
final DavServer server;
late final HttpClient _http = HttpClient()
..connectionTimeout = const Duration(seconds: 15)
..badCertificateCallback = server.insecure ? (_, _, _) => true : null;
DavClient(this.server);
late final Uri _base = () {
final u = Uri.parse(server.url.trim());
final path = u.path.endsWith('/') ? u.path.substring(0, u.path.length - 1) : u.path;
return u.replace(path: path);
}();
/// 서버 기준 경로 → 주소 (각 부분을 인코딩)
Uri uriOf(String rel, {bool dir = false}) {
final parts = rel.split('/').where((x) => x.isNotEmpty).map(Uri.encodeComponent);
final tail = parts.join('/');
final path = '${_base.path}/$tail${dir && tail.isNotEmpty ? '/' : ''}';
return _base.replace(path: path);
}
String? get _auth => server.user.isEmpty && server.password.isEmpty
? null
: 'Basic ${base64Encode(utf8.encode('${server.user}:${server.password}'))}';
Future<HttpClientRequest> _open(String method, Uri uri) async {
final req = await _http.openUrl(method, uri);
final a = _auth;
if (a != null) req.headers.set(HttpHeaders.authorizationHeader, a);
req.followRedirects = false;
return req;
}
Never _fail(String what, HttpClientResponse res, [String body = '']) {
final hint = switch (res.statusCode) {
401 => ' (아이디 · 비밀번호를 확인하세요)',
403 => ' (권한 없음)',
404 => ' (없는 경로)',
405 => ' (서버가 이 기능을 허용하지 않음)',
507 => ' (서버 저장 공간 부족)',
_ => '',
};
throw DavException('WebDAV $what 실패: ${res.statusCode} ${res.reasonPhrase}$hint', status: res.statusCode);
}
static const _propfindBody = '<?xml version="1.0" encoding="utf-8"?>'
'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:getcontentlength/><d:getlastmodified/></d:prop></d:propfind>';
Future<String> _propfind(String rel, {required int depth, String body = _propfindBody}) async {
final req = await _open('PROPFIND', uriOf(rel, dir: depth > 0));
req.headers
..set('Depth', '$depth')
..contentType = ContentType('application', 'xml', charset: 'utf-8');
// 길이를 꼭 알린다 (chunked 로 보내면 404 등에서 본문을 읽지 않는 서버가 남은 본문을 다음 요청으로 오해함)
final bytes = utf8.encode(body);
req.contentLength = bytes.length;
req.add(bytes);
final res = await req.close();
final text = await res.transform(utf8.decoder).join();
if (res.statusCode == 404) throw DavException('없는 경로: $rel', status: 404);
if (res.statusCode != 207 && res.statusCode != 200) _fail('목록', res, text);
return text;
}
/// 응답의 href → 서버 기준 경로
String _relOf(String href) {
var path = Uri.tryParse(href)?.path ?? href;
path = Uri.decodeFull(path);
final base = Uri.decodeFull(_base.path);
if (path.startsWith(base)) path = path.substring(base.length);
path = path.replaceAll(RegExp(r'/+'), '/');
if (path.length > 1 && path.endsWith('/')) path = path.substring(0, path.length - 1);
return path.isEmpty ? '/' : (path.startsWith('/') ? path : '/$path');
}
List<DavItem> _parse(String xml) {
final doc = XmlDocument.parse(xml);
final out = <DavItem>[];
for (final r in doc.findAllElements('response', namespaceUri: 'DAV:')) {
final href = r.findElements('href', namespaceUri: 'DAV:').firstOrNull?.innerText.trim();
if (href == null) continue;
// 200 인 propstat 만
XmlElement? prop;
for (final ps in r.findElements('propstat', namespaceUri: 'DAV:')) {
final st = ps.findElements('status', namespaceUri: 'DAV:').firstOrNull?.innerText ?? '';
if (st.contains(' 200')) prop = ps.findElements('prop', namespaceUri: 'DAV:').firstOrNull;
}
prop ??= r.findAllElements('prop', namespaceUri: 'DAV:').firstOrNull;
final isDir = prop?.findAllElements('collection', namespaceUri: 'DAV:').isNotEmpty ?? href.endsWith('/');
final len = int.tryParse(prop?.findElements('getcontentlength', namespaceUri: 'DAV:').firstOrNull?.innerText.trim() ?? '');
final mod = prop?.findElements('getlastmodified', namespaceUri: 'DAV:').firstOrNull?.innerText.trim();
DateTime when;
try {
when = mod == null || mod.isEmpty ? DateTime.fromMillisecondsSinceEpoch(0) : HttpDate.parse(mod).toLocal();
} catch (_) {
when = DateTime.fromMillisecondsSinceEpoch(0);
}
out.add(DavItem(_relOf(href), isDir: isDir, size: isDir ? 0 : (len ?? 0), modified: when));
}
return out;
}
/// 폴더 안의 항목 (자기 자신은 뺌)
Future<List<DavItem>> list(String rel) async {
final self = _norm(rel);
final items = _parse(await _propfind(self, depth: 1));
return [for (final i in items) if (i.rel != self) i];
}
/// 항목 정보 (없으면 null)
Future<DavItem?> stat(String rel) async {
try {
final items = _parse(await _propfind(_norm(rel), depth: 0));
return items.isEmpty ? null : items.first;
} on DavException catch (e) {
if (e.status == 404) return null;
rethrow;
}
}
/// 폴더 만들기 (이미 있으면 그대로)
Future<void> mkdir(String rel) async {
final res = await (await _open('MKCOL', uriOf(rel, dir: true))).close();
await res.drain<void>();
if (res.statusCode == 201 || res.statusCode == 200) return;
if (res.statusCode == 405 && (await stat(rel))?.isDir == true) return; // 이미 있음
_fail('폴더 만들기', res);
}
/// 위 폴더까지 차례로 만든다
Future<void> mkdirs(String rel) async {
var cur = '';
for (final part in rel.split('/').where((x) => x.isNotEmpty)) {
cur = '$cur/$part';
if ((await stat(cur))?.isDir == true) continue;
await mkdir(cur);
}
}
Future<void> delete(String rel) async {
final st = await stat(rel);
final res = await (await _open('DELETE', uriOf(rel, dir: st?.isDir ?? false))).close();
await res.drain<void>();
if (res.statusCode == 404) return;
if (res.statusCode >= 300) _fail('지우기', res);
}
Future<void> _moveOrCopy(String method, String from, String to, {bool overwrite = false}) async {
final st = await stat(from);
final req = await _open(method, uriOf(from, dir: st?.isDir ?? false));
req.headers
..set('Destination', uriOf(to, dir: st?.isDir ?? false).toString())
..set('Overwrite', overwrite ? 'T' : 'F');
if (st?.isDir ?? false) req.headers.set('Depth', 'infinity');
final res = await req.close();
await res.drain<void>();
if (res.statusCode >= 300) _fail(method == 'MOVE' ? '이동' : '복사', res);
}
Future<void> move(String from, String to, {bool overwrite = false}) => _moveOrCopy('MOVE', from, to, overwrite: overwrite);
Future<void> copy(String from, String to, {bool overwrite = false}) => _moveOrCopy('COPY', from, to, overwrite: overwrite);
/// 받기: 내용을 조각으로 (끝나면 스트림이 닫힘)
Future<Stream<List<int>>> openRead(String rel) async {
final res = await (await _open('GET', uriOf(rel))).close();
if (res.statusCode != 200) {
await res.drain<void>();
_fail('받기', res);
}
return res;
}
/// 올리기 ([length] 를 알면 Content-Length 로)
Future<void> write(String rel, Stream<List<int>> data, {int? length}) async {
final req = await _open('PUT', uriOf(rel));
if (length != null) req.contentLength = length;
await req.addStream(data);
final res = await req.close();
await res.drain<void>();
if (res.statusCode >= 300) _fail('올리기', res);
}
/// 남은 용량 · 쓴 용량 (바이트, 서버가 알려 주지 않으면 null)
Future<(int?, int?)> quota() async {
try {
final xml = await _propfind('/', depth: 0,
body: '<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop>'
'<d:quota-available-bytes/><d:quota-used-bytes/></d:prop></d:propfind>');
final doc = XmlDocument.parse(xml);
int? n(String name) => int.tryParse(doc.findAllElements(name, namespaceUri: 'DAV:').firstOrNull?.innerText.trim() ?? '');
return (n('quota-available-bytes'), n('quota-used-bytes'));
} catch (_) {
return (null, null);
}
}
void close() => _http.close(force: true);
static String _norm(String rel) {
var r = rel.replaceAll('\\', '/').replaceAll(RegExp(r'/+'), '/');
if (!r.startsWith('/')) r = '/$r';
if (r.length > 1 && r.endsWith('/')) r = r.substring(0, r.length - 1);
return r;
}
}
/// 설정의 WebDAV 서버들 (id → 클라이언트). 설정이 바뀌면 [configure].
class DavRegistry {
static final _clients = <String, DavClient>{};
static final _servers = <String, DavServer>{};
static void configure(List<DavServer> servers) {
final keep = {for (final s in servers) s.id: s};
for (final id in _clients.keys.toList()) {
final old = _servers[id], now = keep[id];
if (now == null || old == null || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) {
_clients.remove(id)?.close();
}
}
_servers
..clear()
..addAll(keep);
}
static List<DavServer> get servers => _servers.values.toList();
static DavServer? server(String id) => _servers[id];
static DavClient client(String id) {
final s = _servers[id];
if (s == null) throw DavException('WebDAV 서버 설정이 없습니다 ($id)');
return _clients.putIfAbsent(id, () => DavClient(s));
}
}

View File

@ -16,6 +16,8 @@ import '../app/transfer_job.dart';
import '../core/sync_tools.dart'; import '../core/sync_tools.dart';
import '../core/file_ops.dart'; import '../core/file_ops.dart';
import '../core/playlist.dart' show isVideoFile; import '../core/playlist.dart' show isVideoFile;
import '../core/vfs.dart';
import '../core/webdav.dart' show DavRegistry;
import '../platform/android/android_storage.dart'; import '../platform/android/android_storage.dart';
import 'app_actions.dart'; import 'app_actions.dart';
import 'explorer_look.dart'; import 'explorer_look.dart';
@ -23,6 +25,7 @@ import 'monitor_page.dart';
import 'player_page.dart'; import 'player_page.dart';
import 'rsync_setup.dart'; import 'rsync_setup.dart';
import 'theme.dart'; import 'theme.dart';
import 'webdav_settings.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
/// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄. /// 파일 탐색기 (X-plore 참고): 두 창 (트리 목록) + 가운데 기능 버튼 줄.
@ -168,8 +171,14 @@ class _Row {
class _ExplorerPageState extends State<ExplorerPage> { class _ExplorerPageState extends State<ExplorerPage> {
AppController get c => widget.c; 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('')]; late final List<_Pane> _panes = [_Pane(''), _Pane('')];
int _active = 0; int _active = 0;
bool _ready = false; bool _ready = false;
@ -206,11 +215,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
Future<void> _init() async { Future<void> _init() async {
_volumes = await _loadVolumes(); _local = await _loadVolumes();
if (_volumes.isEmpty) _volumes = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')]; if (_local.isEmpty) _local = [(Platform.isWindows ? r'C:\' : '/', Platform.isWindows ? 'C:' : '/')];
final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorerPaths; final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorerPaths;
for (var i = 0; i < 2; i++) { 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); await _goTo(_panes[i], want, remember: false);
} }
if (!mounted) return; if (!mounted) return;
@ -229,10 +238,16 @@ class _ExplorerPageState extends State<ExplorerPage> {
return [('/', '/')]; return [('/', '/')];
} }
/// 다시 열 수 있는 폴더: 로컬은 있으면, WebDAV 는 그 서버가 아직 설정에 있으면 (열 때 확인)
bool _reachable(String path) => isDav(path)
? DavRegistry.server(DavPath.parse(path).server) != null
: Directory(path).existsSync();
String _volumeOf(String path) { 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; var len = -1;
for (final (v, _) in _volumes) { for (final (v, _) in _local) {
final inside = samePath(v, path) || p.isWithin(v, path) || final inside = samePath(v, path) || p.isWithin(v, path) ||
(Platform.isWindows && p.isWithin(v.toLowerCase(), path.toLowerCase())); (Platform.isWindows && p.isWithin(v.toLowerCase(), path.toLowerCase()));
if (inside && v.length > len) { if (inside && v.length > len) {
@ -271,11 +286,12 @@ class _ExplorerPageState extends State<ExplorerPage> {
Future<void> _goTo(_Pane pane, String dir, {bool remember = true}) async { Future<void> _goTo(_Pane pane, String dir, {bool remember = true}) async {
pane.root = _volumeOf(dir); pane.root = _volumeOf(dir);
final chain = <String>[]; final chain = <String>[];
var d = p.normalize(dir); dir = isDav(dir) ? vNorm(dir) : p.normalize(dir);
var d = dir;
while (true) { while (true) {
chain.insert(0, d); chain.insert(0, d);
if (samePath(d, pane.root) || p.dirname(d) == d) break; if (samePath(d, pane.root) || vDirname(d) == d) break;
d = p.dirname(d); d = vDirname(d);
} }
for (final x in chain) { for (final x in chain) {
pane.expanded.add(x); pane.expanded.add(x);
@ -385,7 +401,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
if (e.isDir || !_selectMode) return _onOpen(pane, row, list: list); if (e.isDir || !_selectMode) return _onOpen(pane, row, list: list);
pane.focused = e.path; pane.focused = e.path;
if (!list) pane.current = p.dirname(e.path); if (!list) pane.current = vDirname(e.path);
pane.changed(); pane.changed();
} }
@ -476,7 +492,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
await _toggle(pane, e); await _toggle(pane, e);
return; return;
} }
if (!list) pane.current = p.dirname(e.path); if (!list) pane.current = vDirname(e.path);
pane.changed(); pane.changed();
await _open(e.path); await _open(e.path);
} }
@ -503,27 +519,89 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램 /// 파일 열기 (기본 동작): 동영상은 내장 플레이어 (또는 확장자별 프로그램), 그 밖은 기본 연결 프로그램
Future<void> _open(String path) async { Future<void> _open(String path) async {
if (isVideoFile(path)) { if (isVideoFile(path)) {
await playFiles(context, c, [path]); await _play([path]);
return; 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('이 파일을 열 수 있는 앱이 없습니다.')); if (!ok) _snack(tr('이 파일을 열 수 있는 앱이 없습니다.'));
} }
/// WebDAV 파일은 열거나 재생하려면 임시 폴더로 받는다 (받는 동안 진행 창 · 취소). 로컬은 그대로.
Future<String?> _fetch(String path) async {
if (!isDav(path)) return path;
final temp = await c.services.storage.tempDirectory();
if (!mounted) return null;
final progress = ValueNotifier<(int, int)>((0, 0));
var cancelled = false;
final dialog = showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: Text(tr('WebDAV 에서 받는 중')),
content: ValueListenableBuilder<(int, int)>(
valueListenable: progress,
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(vBasename(path)),
const SizedBox(height: 10),
LinearProgressIndicator(value: v.$2 > 0 ? v.$1 / v.$2 : null),
const SizedBox(height: 6),
Text('${formatSize(v.$1)}${v.$2 > 0 ? ' / ${formatSize(v.$2)}' : ''}',
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
]),
),
actions: [
TextButton(
onPressed: () {
cancelled = true;
Navigator.pop(ctx);
},
child: Text(tr('취소')),
),
],
),
);
try {
final local = await vLocalCopy(path, temp, onProgress: (d, t) => progress.value = (d, t));
return cancelled ? null : local;
} catch (e) {
if (!cancelled) _snack(trf('받지 못했습니다: {0}', [e]));
return null;
} finally {
if (!cancelled && mounted) Navigator.of(context).pop();
await dialog;
progress.dispose();
}
}
/// 동영상 재생 (WebDAV 는 받은 뒤)
Future<void> _play(List<String> paths, {bool internal = false, bool keepOrder = false}) async {
final local = <String>[];
for (final x in paths) {
final l = await _fetch(x);
if (l == null) return;
local.add(l);
}
if (!mounted || local.isEmpty) return;
await playFiles(context, c, local, internal: internal, keepOrder: keepOrder);
}
Future<void> _menu(_Pane pane, FileEntry e, Offset at) async { Future<void> _menu(_Pane pane, FileEntry e, Offset at) async {
setState(() => _active = _panes.indexOf(pane)); setState(() => _active = _panes.indexOf(pane));
pane.focused = e.path; pane.focused = e.path;
pane.changed(); pane.changed();
final video = !e.isDir && isVideoFile(e.path); final video = !e.isDir && isVideoFile(e.path);
final dav = isDav(e.path);
final dual = _dual; final dual = _dual;
final items = <(String, IconData, String)>[ final items = <(String, IconData, String)>[
if (e.isDir) ('open', Icons.folder_open, tr('열기')), if (e.isDir) ('open', Icons.folder_open, tr('열기')),
if (e.isDir && dual) ('openOther', Icons.vertical_split_outlined, 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 (video) ('playInternal', Icons.play_circle_outline, tr('내장 플레이어로 재생')),
if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')), if (!e.isDir) ('openWith', Icons.open_in_new, tr('다른 앱으로 열기')),
if (!e.isDir && !video) ('openDefault', Icons.launch, 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('선택')), ('select', Icons.check_circle_outline, tr('선택')),
('rename', Icons.drive_file_rename_outline, tr('이름 변경')), ('rename', Icons.drive_file_rename_outline, tr('이름 변경')),
if (dual) ('copy', Icons.copy_outlined, tr('다른 창으로 복사')), if (dual) ('copy', Icons.copy_outlined, tr('다른 창으로 복사')),
@ -532,9 +610,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
('clipCopy', Icons.content_copy, dual ? tr('복사 (붙여넣기로)') : tr('복사')), ('clipCopy', Icons.content_copy, dual ? tr('복사 (붙여넣기로)') : tr('복사')),
('clipMove', Icons.content_cut, dual ? tr('이동 (붙여넣기로)') : tr('이동')), ('clipMove', Icons.content_cut, dual ? tr('이동 (붙여넣기로)') : tr('이동')),
if (_clip.isNotEmpty) 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('삭제')), ('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('정보')), ('info', Icons.info_outline, tr('정보')),
]; ];
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
@ -560,11 +638,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
case 'playFolder': case 'playFolder':
await _playFolder(e.path); await _playFolder(e.path);
case 'playInternal': case 'playInternal':
await playFiles(context, c, [e.path], internal: true); await _play([e.path], internal: true);
case 'openWith': 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': 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': case 'addMkv':
await _addMkv([e.path]); await _addMkv([e.path]);
case 'select': case 'select':
@ -581,7 +661,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
case 'clipMove': case 'clipMove':
_toClipboard(_isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: true); _toClipboard(_isMarked(pane, e.path) ? pane.marked.toList() : [e.path], move: true);
case 'paste': 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': case 'delete':
await _delete(pane, [e.path]); await _delete(pane, [e.path]);
case 'reveal': case 'reveal':
@ -627,7 +707,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
switch (b) { switch (b) {
case ExplorerButton.up: case ExplorerButton.up:
if (samePath(pane.current, pane.root)) return; if (samePath(pane.current, pane.root)) return;
final up = p.dirname(pane.current); final up = vDirname(pane.current);
pane.expanded.remove(pane.current); pane.expanded.remove(pane.current);
await _goTo(pane, up); await _goTo(pane, up);
case ExplorerButton.select: case ExplorerButton.select:
@ -647,11 +727,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
case ExplorerButton.play: case ExplorerButton.play:
final t = _targets(pane); 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) { if (videos.isNotEmpty) {
await playFiles(context, c, videos, keepOrder: pane.marked.isNotEmpty); await _play(videos, keepOrder: pane.marked.isNotEmpty);
} else { } 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: case ExplorerButton.addMkv:
final t = _targets(pane); final t = _targets(pane);
@ -722,13 +802,17 @@ class _ExplorerPageState extends State<ExplorerPage> {
return; return;
} }
if (!mounted) return; if (!mounted) return;
await playFiles(context, c, list); await _play(list);
} }
Future<void> _addMkv(List<String> paths) async { Future<void> _addMkv(List<String> paths) async {
if (paths.any(isDav)) {
_snack(tr('WebDAV 의 동영상은 MKV 목록에 넣을 수 없습니다. 먼저 이 기기로 복사하세요.'));
return;
}
final videos = <String>[]; final videos = <String>[];
for (final x in paths) { 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)); videos.addAll((await listEntries(x)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path));
} else if (isVideoFile(x)) { } else if (isVideoFile(x)) {
videos.add(x); videos.add(x);
@ -743,11 +827,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
Future<void> _rename(_Pane pane, String path) async { Future<void> _rename(_Pane pane, String path) async {
final name = await _askName(tr('이름 변경'), p.basename(path)); final name = await _askName(tr('이름 변경'), vBasename(path));
if (name == null || name == p.basename(path)) return; if (name == null || name == vBasename(path)) return;
try { try {
final now = await FileOps.rename(path, name); final now = await FileOps.rename(path, name);
await _refreshAll([p.dirname(path)]); await _refreshAll([vDirname(path)]);
for (final x in _panes) { for (final x in _panes) {
if (x.marked.remove(path)) x.marked.add(now); if (x.marked.remove(path)) x.marked.add(now);
} }
@ -769,12 +853,12 @@ class _ExplorerPageState extends State<ExplorerPage> {
title: Text(tr('삭제')), title: Text(tr('삭제')),
// 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더) // 무엇을 지우는지 늘 보여 준다 (여러 개면 앞의 8개 이름 · 폴더)
content: Text(paths.length == 1 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]), 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]), 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')), ].join('\n')),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
@ -796,7 +880,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
x.marked.removeAll(paths); x.marked.removeAll(paths);
if (_selecting && x.marked.isEmpty) _selecting = false; if (_selecting && x.marked.isEmpty) _selecting = false;
x.expanded.removeWhere((d) => paths.any((s) => isSameOrInside(d, s))); 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)); await _refreshAll(paths.map(p.dirname));
} }
@ -844,6 +928,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// (Rsync 화면의 ⇄ 는 둘이 함께) /// (Rsync 화면의 ⇄ 는 둘이 함께)
List<TransferJob> _jobs = const []; List<TransferJob> _jobs = const [];
/// 한쪽이라도 WebDAV 면 rsync 대신 앱이 직접 맞춘다 (rsync 는 WebDAV 를 모름)
bool _viaDav(Iterable<String> paths) => paths.any(isDav);
/// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null) /// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null)
Future<String?> _ensureRsync() async { Future<String?> _ensureRsync() async {
final s = c.settings; final s = c.settings;
@ -871,7 +958,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
final problem = transferProblem(sources, dest, move: move); final problem = transferProblem(sources, dest, move: move);
if (problem != null) { if (problem != null) {
_snack(problem); _snack(problem);
pane.marked.removeWhere((m) => FileSystemEntity.typeSync(m) == FileSystemEntityType.notFound); pane.marked.removeWhere((m) => vMissingSync(m));
await _refreshAll([dest, ...sources.map(p.dirname)]); await _refreshAll([dest, ...sources.map(p.dirname)]);
for (final x in _panes) { for (final x in _panes) {
x.changed(); x.changed();
@ -887,7 +974,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: Text(move ? tr('이동') : tr('복사')), 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)])}' '\n\n${trf('방법: {0}', [tr(method.label)])}'
'${method == CopyMethod.builtin ? '' : ' · ${task.options}'}' '${method == CopyMethod.builtin ? '' : ' · ${task.options}'}'
'${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'), '${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'),
@ -899,7 +986,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
); );
if (go != true || !mounted) return false; if (go != true || !mounted) return false;
String? rsync; String? rsync;
if (method == CopyMethod.rsync) { if (method == CopyMethod.rsync && !_viaDav([...sources, dest])) {
rsync = await _ensureRsync(); rsync = await _ensureRsync();
if (rsync == null || !mounted) return false; if (rsync == null || !mounted) return false;
} }
@ -954,9 +1041,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (problem != null) { if (problem != null) {
_snack(problem); _snack(problem);
for (final x in _panes) { 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; return;
} }
} }
@ -977,8 +1064,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
width: 560, width: 560,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
for (final t in tasks) ...[ for (final t in tasks) ...[
Text('${t.sources.first}/', style: const TextStyle(fontWeight: FontWeight.w600)), Text('${vDisplay(t.sources.first)}/', style: const TextStyle(fontWeight: FontWeight.w600)),
Text('→ ${t.dest}/', style: const TextStyle(fontWeight: FontWeight.w600)), Text('→ ${vDisplay(t.dest)}/', style: const TextStyle(fontWeight: FontWeight.w600)),
Text('rsync ${opts(t)}${removeSource ? ' --remove-source-files' : ''}' Text('rsync ${opts(t)}${removeSource ? ' --remove-source-files' : ''}'
'${t.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [t.bandwidthKBps])}' : ''}', '${t.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [t.bandwidthKBps])}' : ''}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)), style: const TextStyle(fontSize: 12, fontFamily: 'monospace', color: JjColors.textDim)),
@ -986,6 +1073,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
], ],
Text(tr('폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).'), Text(tr('폴더 안의 것을 맞춥니다 (대상에 같은 이름의 폴더를 새로 만들지 않음).'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)), 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) if (both)
Text(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).'), Text(tr('양쪽을 함께: 받는 쪽이 더 새 파일은 덮어쓰지 않습니다 (-u).'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)) style: const TextStyle(fontSize: 12, color: JjColors.textDim))
@ -1013,7 +1103,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
)), )),
); );
if (go != true || !mounted) return; if (go != true || !mounted) return;
final exe = await _ensureRsync(); final dav = _viaDav([l, r]);
final exe = dav ? '' : await _ensureRsync();
if (exe == null || !mounted) return; if (exe == null || !mounted) return;
tasks = [ tasks = [
for (final (src, dst) in runs) await center.remember([src], dst, contents: true, method: 'rsync', move: removeSource), for (final (src, dst) in runs) await center.remember([src], dst, contents: true, method: 'rsync', move: removeSource),
@ -1026,7 +1117,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
final jobs = <TransferJob>[]; final jobs = <TransferJob>[];
for (final t in tasks) { 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 (j != null) jobs.add(j);
} }
if (jobs.isEmpty || !mounted) return; if (jobs.isEmpty || !mounted) return;
@ -1035,9 +1126,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (!mounted) return; if (!mounted) return;
// 원본 폴더까지 지웠으면 고른 표시도 뺀다 // 원본 폴더까지 지웠으면 고른 표시도 뺀다
for (final x in _panes) { 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) { for (final x in _panes) {
x.changed(); x.changed();
} }
@ -1046,7 +1137,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
? tr('취소했습니다.') ? tr('취소했습니다.')
: error != null : error != null
? trf('끝나지 못했습니다: {0}', [error]) ? trf('끝나지 못했습니다: {0}', [error])
: trf('rsync 끝: {0}', [jobs.map((j) => '${p.basename(j.sources.first)} → ${p.basename(j.dest)}').join(' · ')])); : trf('rsync 끝: {0}', [jobs.map((j) => '${vBasename(j.sources.first)} → ${vBasename(j.dest)}').join(' · ')]));
await Future<void>.delayed(const Duration(milliseconds: 1200)); await Future<void>.delayed(const Duration(milliseconds: 1200));
if (mounted && identical(_jobs, jobs)) setState(() => _jobs = const []); if (mounted && identical(_jobs, jobs)) setState(() => _jobs = const []);
} }
@ -1147,7 +1238,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
].join(' · '), ].join(' · '),
), ),
line( 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, job.folderProgress,
'${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.allDone, job.allFiles])}', '${pct(job.folderProgress)} · ${job.checkTotal > 0 && !job.finished ? trf('항목 {0}/{1}', [job.checked, job.checkTotal]) : trf('파일 {0}/{1}', [job.allDone, job.allFiles])}',
), ),
@ -1198,7 +1289,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
Future<void> _info(FileEntry e) async { Future<void> _info(FileEntry e) async {
final size = e.isDir ? await FileOps.totalSize([e.path]) : e.size; final size = e.isDir ? await FileOps.totalSize([e.path]) : e.size;
final meta = !e.isDir && 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; if (!mounted) return;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
@ -1223,7 +1314,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
builder: (ctx) => _SearchDialog(root: pane.current, query: q, showHidden: c.settings.explorerShowHidden), builder: (ctx) => _SearchDialog(root: pane.current, query: q, showHidden: c.settings.explorerShowHidden),
); );
if (found == null) return; 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.focused = found.path;
pane.changed(); pane.changed();
} }
@ -1269,7 +1360,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
Future<void> _historyDialog(_Pane pane) async { Future<void> _historyDialog(_Pane pane) async {
final hist = c.settings.explorerHistory.where((h) => Directory(h).existsSync()).toList(); final hist = c.settings.explorerHistory.where(_reachable).toList();
final pick = await showDialog<String>( final pick = await showDialog<String>(
context: context, context: context,
builder: (ctx) => SimpleDialog( builder: (ctx) => SimpleDialog(
@ -1463,7 +1554,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), Text(widget.rsync ? 'Rsync' : tr('파일 탐색기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text(_ready ? _pane.current : '', child: Text(_ready ? vDisplay(_pane.current) : '',
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: JjColors.textDim)),
), ),
PopupMenuButton<ExplorerButton>( PopupMenuButton<ExplorerButton>(
@ -1614,7 +1705,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
final entries = pane.cache[dir] ?? const <FileEntry>[]; final entries = pane.cache[dir] ?? const <FileEntry>[];
final up = !samePath(dir, pane.root); final up = !samePath(dir, pane.root);
final rows = [ 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), for (final e in entries) _Row(e, 0),
]; ];
final look = _look; final look = _look;
@ -1660,18 +1751,54 @@ class _ExplorerPageState extends State<ExplorerPage> {
backgroundColor: samePath(pane.root, path) ? JjColors.accent.withValues(alpha: 0.18) : null, backgroundColor: samePath(pane.root, path) ? JjColors.accent.withValues(alpha: 0.18) : null,
foregroundColor: samePath(pane.root, path) ? JjColors.accent : 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), label: Text(label),
onPressed: () { onPressed: () {
setState(() => _active = _panes.indexOf(pane)); setState(() => _active = _panes.indexOf(pane));
_goTo(pane, path); _goTo(pane, path);
}, },
onLongPress: isDav(path) ? () => _editDav(pane, path) : null,
), ),
), ),
if (c.settings.webdavServers.isEmpty)
TextButton.icon(
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 10),
),
icon: const Icon(Icons.cloud_outlined, size: 16),
label: const Text('WebDAV'),
onPressed: () => _editDav(pane, null),
)
else
IconButton(
tooltip: tr('WebDAV 서버 추가'),
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.add, size: 16),
onPressed: () => _editDav(pane, null),
),
], ],
), ),
); );
/// WebDAV 서버 추가 (없으면 [root] 는 null) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다.
Future<void> _editDav(_Pane pane, String? root) async {
setState(() => _active = _panes.indexOf(pane));
final old = root == null ? null : DavRegistry.server(DavPath.parse(root).server);
final saved = await editDavServer(context, c, old: old);
if (saved == null || !mounted) return;
for (final x in _panes) {
x.cache.removeWhere((k, _) => isDav(k) && DavPath.parse(k).server == saved.id);
}
await _goTo(pane, '$davScheme${saved.id}/');
}
/// 한 줄. [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이) /// 한 줄. [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이)
Widget _rowView(_Pane pane, _Row row, {bool list = false, bool compact = false}) { Widget _rowView(_Pane pane, _Row row, {bool list = false, bool compact = false}) {
final e = row.entry; final e = row.entry;
@ -1826,9 +1953,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
Widget _icon(FileEntry e, bool isRoot, bool open, {bool up = false}) { Widget _icon(FileEntry e, bool isRoot, bool open, {bool up = false}) {
final look = _look; final look = _look;
if (up) return Icon(Icons.arrow_upward, size: look.iconSize, color: JjColors.textDim); 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); if (isRoot) return Icon(isDav(e.path) ? Icons.cloud : Icons.sd_storage, color: JjColors.accent, size: look.iconSize - 2);
// X-plore: 동영상 썸네일 · 그림 미리 보기 // X-plore: 동영상 썸네일 · 그림 미리 보기 (WebDAV 는 받아야 하므로 아이콘만)
if (look.rich && !e.isDir) { if (look.rich && !e.isDir && !isDav(e.path)) {
if (isVideoFile(e.path)) return _Thumb(c: c, entry: e); if (isVideoFile(e.path)) return _Thumb(c: c, entry: e);
const images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'}; const images = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'};
if (images.contains(e.ext)) { 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, leading: Icon(e.isDir ? Icons.folder : Icons.insert_drive_file_outlined,
color: e.isDir ? Colors.amber : null), color: e.isDir ? Colors.amber : null),
title: Text(e.name), 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), onTap: () => Navigator.pop(context, e),
); );
}, },

View File

@ -11,6 +11,7 @@ import '../app/settings.dart';
import '../app/transfer_job.dart'; import '../app/transfer_job.dart';
import '../core/file_ops.dart'; import '../core/file_ops.dart';
import '../core/sync_tools.dart'; import '../core/sync_tools.dart';
import '../core/vfs.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'app_actions.dart'; import 'app_actions.dart';
import 'copy_sync_settings.dart' show addLiveSyncPairDialog, LiveSyncRunOptions; import 'copy_sync_settings.dart' show addLiveSyncPairDialog, LiveSyncRunOptions;
@ -357,13 +358,13 @@ class _CopyCardState extends State<_CopyCard> {
Expanded( Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text( 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 ? '/' : ''}', '${t.contents ? '/' : ''}',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600), 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( Chip(
@ -628,8 +629,8 @@ class _LiveTabState extends State<_LiveTab> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('${x.source}/', 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('→ ${x.target}/', 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( Chip(
@ -777,7 +778,7 @@ Future<void> askLiveSyncStart(BuildContext context, LiveSync live) async {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
value: pick.contains(LiveSync.keyOf(x)), value: pick.contains(LiveSync.keyOf(x)),
onChanged: (v) => set(() => v == true ? pick.add(LiveSync.keyOf(x)) : pick.remove(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)), subtitle: Text(scheduleSummary(x.schedule)),
), ),
]), ]),

View File

@ -23,6 +23,7 @@ import 'app_actions.dart';
import 'cef_setup.dart'; import 'cef_setup.dart';
import 'cleanup_dialog.dart'; import 'cleanup_dialog.dart';
import 'copy_sync_settings.dart'; import 'copy_sync_settings.dart';
import 'webdav_settings.dart';
import 'explorer_look.dart' show ExplorerStyle; import 'explorer_look.dart' show ExplorerStyle;
import 'theme.dart'; import 'theme.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
@ -606,6 +607,8 @@ class _SettingsPageState extends State<SettingsPage> {
), ),
// 복사 · 이동 방법 (현재 방식 · robocopy) · 속도 제한 // 복사 · 이동 방법 (현재 방식 · robocopy) · 속도 제한
CopySyncSettings(c: c), CopySyncSettings(c: c),
// WebDAV 서버 (탐색기 · Rsync 화면 위쪽 탭)
WebDavSettings(c: c),
]), ]),
// Rsync 화면: rsync 옵션 · 가져오기 · 실시간 동기화 (lsync) · 백그라운드로 실행 // Rsync 화면: rsync 옵션 · 가져오기 · 실시간 동기화 (lsync) · 백그라운드로 실행
_group('rsync', Icons.sync_alt, 'Rsync', [ _group('rsync', Icons.sync_alt, 'Rsync', [

160
lib/ui/webdav_settings.dart Normal file
View File

@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../core/webdav.dart';
import '../l10n/tr.dart';
import 'theme.dart';
/// 환경 설정 > 파일 탐색기 > WebDAV: 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인).
/// 서버마다 파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆에 탭이 생긴다.
class WebDavSettings extends StatelessWidget {
final AppController c;
const WebDavSettings({super.key, required this.c});
@override
Widget build(BuildContext context) {
final servers = c.settings.webdavServers;
return Column(children: [
ListTile(
leading: const Icon(Icons.cloud_outlined),
title: const Text('WebDAV'),
subtitle: Text(tr('NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 '
'(위쪽 "SD 카드" 옆 탭). 비밀번호는 이 기기의 설정 파일에만 저장합니다.')),
trailing: TextButton.icon(
onPressed: () => editDavServer(context, c),
icon: const Icon(Icons.add, size: 18),
label: Text(tr('추가')),
),
),
for (final s in servers)
Padding(
padding: const EdgeInsets.only(left: 16),
child: ListTile(
dense: true,
leading: const Icon(Icons.cloud, color: JjColors.accent),
title: Text(s.label),
subtitle: Text('${s.url}${s.user.isEmpty ? '' : ' · ${s.user}'}${s.insecure ? ' · ${tr('인증서 확인 안 함')}' : ''}',
maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
IconButton(
tooltip: tr('고치기'),
icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: () => editDavServer(context, c, old: s),
),
IconButton(
tooltip: tr('지우기'),
icon: const Icon(Icons.delete_outline, size: 18, color: JjColors.textDim),
onPressed: () => c.updateSettings((x) => x.webdavServers = [for (final y in x.webdavServers) if (y.id != s.id) y]),
),
]),
),
),
]);
}
}
/// WebDAV 서버 추가 · 고치기 창. 저장한 서버를 돌려준다 (취소면 null).
Future<DavServer?> editDavServer(BuildContext context, AppController c, {DavServer? old}) async {
final name = TextEditingController(text: old?.name ?? '');
final url = TextEditingController(text: old?.url ?? 'https://');
final user = TextEditingController(text: old?.user ?? '');
final pass = TextEditingController(text: old?.password ?? '');
var insecure = old?.insecure ?? false;
var hide = true;
String? result; // 연결 확인 결과
var passed = false;
var testing = false;
DavServer current() => DavServer(
id: old?.id ?? 'dav${DateTime.now().microsecondsSinceEpoch}',
name: name.text.trim(),
url: url.text.trim(),
user: user.text.trim(),
password: pass.text,
insecure: insecure,
);
final saved = await showDialog<DavServer>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) {
Future<void> test() async {
set(() {
testing = true;
result = null;
});
final client = DavClient(current());
try {
final items = await client.list('/');
final (free, _) = await client.quota();
passed = true;
result = trf('연결됨: 항목 {0}개{1}', [items.length, free == null ? '' : ' · ${tr('남은 용량')} ${(free / (1 << 30)).toStringAsFixed(1)}GB']);
} catch (e) {
passed = false;
result = trf('연결 실패: {0}', [e]);
} finally {
client.close();
}
if (ctx.mounted) set(() => testing = false);
}
final ok = Uri.tryParse(url.text.trim())?.hasAuthority ?? false;
return AlertDialog(
title: Text(old == null ? tr('WebDAV 서버 추가') : tr('WebDAV 서버 고치기')),
content: SizedBox(
width: 520,
child: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(controller: name, decoration: InputDecoration(labelText: tr('이름 (탭에 보임, 비우면 주소)'))),
TextField(
controller: url,
keyboardType: TextInputType.url,
decoration: InputDecoration(
labelText: tr('주소'),
helperText: tr('예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/아이디'),
),
onChanged: (_) => set(() {}),
),
TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디'))),
TextField(
controller: pass,
obscureText: hide,
decoration: InputDecoration(
labelText: tr('비밀번호'),
suffixIcon: IconButton(
icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18),
onPressed: () => set(() => hide = !hide),
),
),
),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: insecure,
onChanged: (v) => set(() => insecure = v ?? false),
title: Text(tr('인증서 확인 안 함')),
subtitle: Text(tr('자체 서명 인증서를 쓰는 집 NAS 등 (믿을 수 있는 서버에서만)')),
),
if (testing) const LinearProgressIndicator(),
if (result != null)
Align(
alignment: Alignment.centerLeft,
child: Text(result!,
style: TextStyle(fontSize: 12, color: passed ? Colors.greenAccent : Colors.redAccent)),
),
]),
),
actions: [
TextButton(onPressed: !ok || testing ? null : test, child: Text(tr('연결 확인'))),
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
FilledButton(onPressed: ok ? () => Navigator.pop(ctx, current()) : null, child: Text(tr('저장'))),
],
);
},
),
);
if (saved == null) return null;
await c.updateSettings((x) => x.webdavServers = [
for (final y in x.webdavServers) y.id == saved.id ? saved : y,
if (!x.webdavServers.any((y) => y.id == saved.id)) saved,
]);
return saved;
}

View File

@ -1001,7 +1001,7 @@ packages:
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml: xml:
dependency: transitive dependency: "direct main"
description: description:
name: xml name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"

View File

@ -57,6 +57,8 @@ dependencies:
screen_retriever: ^0.2.2 screen_retriever: ^0.2.2
package_info_plus: ^10.2.1 package_info_plus: ^10.2.1
crypto: ^3.0.7 crypto: ^3.0.7
# WebDAV 목록 (PROPFIND 응답) 읽기
xml: ^7.0.1
flutter_inappwebview: ^6.1.5 flutter_inappwebview: ^6.1.5
ffmpeg_kit_flutter_new_min_gpl: 2.6.2 ffmpeg_kit_flutter_new_min_gpl: 2.6.2
# Android 빌드 도구 (AGP 9) 에 맞게 고친 사본 (packages/flutter_inappwebview_android/JJ_PATCH.md) # Android 빌드 도구 (AGP 9) 에 맞게 고친 사본 (packages/flutter_inappwebview_android/JJ_PATCH.md)

View File

@ -0,0 +1,136 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/core/webdav.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/app_shell.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:path/path.dart' as p;
import 'explorer_test.dart' show act, doubleTap, settle;
import 'support/dav_server.dart';
class _Shell extends NoopShell {
final opened = <String>[];
@override
Future<bool> openWith(String path, {bool choose = false}) async {
opened.add(path);
return true;
}
}
void main() {
late Directory tmp, remote;
late String left;
late TestDavServer server;
late _Shell shell;
late AppController c;
setUp(() async {
tmp = Directory.systemTemp.createTempSync('jj_explorer_dav_');
left = p.join(tmp.path, 'left');
remote = Directory(p.join(tmp.path, 'remote'))..createSync();
File(p.join(left, 'doc.txt'))
..createSync(recursive: true)
..writeAsStringSync('hello');
File(p.join(left, 'sub', 'inner.txt'))
..createSync(recursive: true)
..writeAsStringSync('inner');
File(p.join(remote.path, '원격', 'note.txt'))
..createSync(recursive: true)
..writeAsStringSync('remote');
server = TestDavServer(remote);
await server.start();
shell = _Shell();
c = AppController(PlatformServices(
mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: shell));
});
tearDown(() async {
DavRegistry.configure([]);
await server.stop();
tmp.deleteSync(recursive: true);
});
Future<void> pump(WidgetTester tester, Widget page) async {
HttpOverrides.global = null; // testWidgets 는 HttpClient 를 막아 둔다 - 이 시험은 실제 (로컬) 서버에 연결
tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: page)));
}
testWidgets('파일 탐색기: WebDAV 탭 → 서버 추가 (연결 확인) → 목록 · 열기 (받아서) · 로컬 → WebDAV 복사', (tester) async {
c.settings.explorerPaths = [left, left];
await pump(tester, ExplorerPage(c: c));
await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty);
// 서버가 없으면 위쪽 저장 장치 옆에 "WebDAV" 탭 (창마다)
expect(find.text('WebDAV'), findsNWidgets(2));
await act(tester, () => tester.tap(find.text('WebDAV').last));
await tester.pumpAndSettle();
expect(find.text('WebDAV 서버 추가'), findsOneWidget);
await tester.enterText(find.widgetWithText(TextField, '이름 (탭에 보임, 비우면 주소)'), '집 NAS');
await tester.enterText(find.widgetWithText(TextField, '주소'), server.url);
await tester.enterText(find.widgetWithText(TextField, '아이디'), 'user');
await tester.enterText(find.widgetWithText(TextField, '비밀번호'), 'pass');
await tester.pump();
await act(tester, () => tester.tap(find.text('연결 확인')));
await settle(tester, () => find.textContaining('연결됨').evaluate().isNotEmpty);
expect(find.textContaining('연결됨: 항목 1개'), findsOneWidget);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '저장')));
// 저장하면 설정에 남고 오른쪽 창이 그 서버로
await settle(tester, () => find.text('원격').evaluate().isNotEmpty);
expect(c.settings.webdavServers.single.url, server.url);
final id = c.settings.webdavServers.single.id;
expect(find.text('집 NAS'), findsNWidgets(3)); // 두 창의 탭 + 오른쪽 창의 맨 위 줄
expect(find.text('WebDAV'), findsNothing);
// WebDAV 폴더 펼치기 · 파일 두 번 누르기 = 임시 폴더로 받아 기본 앱으로
await act(tester, () => tester.tap(find.text('원격')));
await settle(tester, () => find.text('note.txt').evaluate().isNotEmpty);
await doubleTap(tester, find.text('note.txt'));
await settle(tester, () => shell.opened.isNotEmpty);
expect(p.split(shell.opened.single), containsAllInOrder(['jj_webdav', id, '원격', 'note.txt']));
expect(File(shell.opened.single).readAsStringSync(), 'remote');
// 왼쪽 doc.txt → 오른쪽 (WebDAV 원격 폴더) 으로 복사
await act(tester, () => tester.tap(find.text('원격')));
await settle(tester, () => false, rounds: 15);
await act(tester, () => tester.tap(find.text('doc.txt')));
await settle(tester, () => false, rounds: 15);
await act(tester, () => tester.tap(find.text('복사').first));
await tester.pumpAndSettle();
expect(find.textContaining('☁ 집 NAS/원격'), findsOneWidget);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '복사')));
final up = File(p.join(remote.path, '원격', 'doc.txt'));
await settle(tester, () => up.existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty);
expect(up.readAsStringSync(), 'hello');
});
testWidgets('Rsync 화면: 로컬 ↔ WebDAV 폴더 → (앱이 직접 맞춤 · rsync 필요 없음)', (tester) async {
final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass');
c.settings
..webdavServers = [s]
..rsyncPaths = [left, 'dav://nas/'];
DavRegistry.configure([s]);
await pump(tester, ExplorerPage(c: c, rsync: true));
await settle(tester, () => find.text('sub').evaluate().isNotEmpty && find.text('원격').evaluate().isNotEmpty);
expect(find.byIcon(Icons.cloud), findsOneWidget); // 오른쪽 창 맨 위 줄
await act(tester, () => tester.tap(find.text('sub')));
await act(tester, () => tester.tap(find.text('원격')));
await settle(tester, () => false, rounds: 5);
await act(tester, () => tester.tap(find.text('좌 → 우')));
await tester.pumpAndSettle();
expect(find.text('→ ☁ 집 NAS/원격/'), findsOneWidget);
expect(find.textContaining('WebDAV: rsync 대신 앱이 직접 맞춥니다'), findsOneWidget);
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '실행')));
final got = File(p.join(remote.path, '원격', 'inner.txt'));
await settle(tester, () => got.existsSync() && find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 200);
expect(got.readAsStringSync(), 'inner');
expect(File(p.join(remote.path, '원격', 'note.txt')).existsSync(), isTrue); // 지우기 없이
expect(c.settings.copyTasks.single.dest, 'dav://nas/원격'); // 모니터링에 남음
});
}

70
test/real_dav_test.dart Normal file
View File

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

View File

@ -0,0 +1,155 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
/// 시험용 작은 WebDAV 서버 (실제 HTTP): [root] 폴더를 /dav/ 아래로 내보낸다. 기본 인증 (user / pass).
/// PROPFIND (Depth 0 · 1) · GET · PUT · MKCOL · DELETE · MOVE · COPY · quota.
class TestDavServer {
final Directory root;
final String user, pass;
late HttpServer _server;
final requests = <String>[];
TestDavServer(this.root, {this.user = 'user', this.pass = 'pass'});
String get url => 'http://127.0.0.1:${_server.port}/dav';
Future<void> start() async {
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
_server.listen(_handle);
}
Future<void> stop() => _server.close(force: true);
String _local(String uriPath) {
var rel = Uri.decodeFull(uriPath);
if (rel.startsWith('/dav')) rel = rel.substring(4);
rel = rel.replaceAll(RegExp(r'^/+|/+$'), '');
return rel.isEmpty ? root.path : p.join(root.path, p.joinAll(rel.split('/')));
}
String _href(String local, bool dir) {
final rel = p.relative(local, from: root.path).replaceAll('\\', '/');
final parts = rel == '.' ? <String>[] : rel.split('/');
return '/dav/${parts.map(Uri.encodeComponent).join('/')}${dir && parts.isNotEmpty ? '/' : ''}';
}
String _entry(FileSystemEntity e) {
final dir = e is Directory;
final st = e.statSync();
return '<d:response><d:href>${_href(e.path, dir)}</d:href><d:propstat><d:prop>'
'<d:resourcetype>${dir ? '<d:collection/>' : ''}</d:resourcetype>'
'${dir ? '' : '<d:getcontentlength>${st.size}</d:getcontentlength>'}'
'<d:getlastmodified>${HttpDate.format(st.modified)}</d:getlastmodified>'
'<d:quota-available-bytes>1000000</d:quota-available-bytes><d:quota-used-bytes>500</d:quota-used-bytes>'
'</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
Future<void> _handle(HttpRequest req) async {
final res = req.response;
requests.add('${req.method} ${req.uri.path}');
final auth = req.headers.value(HttpHeaders.authorizationHeader);
if (auth != 'Basic ${base64Encode(utf8.encode('$user:$pass'))}') {
res.statusCode = 401;
await res.close();
return;
}
final local = _local(req.uri.path);
final type = FileSystemEntity.typeSync(local);
try {
switch (req.method) {
case 'PROPFIND':
await req.drain<void>();
if (type == FileSystemEntityType.notFound) {
res.statusCode = 404;
break;
}
final depth = req.headers.value('Depth') ?? '1';
final items = <FileSystemEntity>[type == FileSystemEntityType.directory ? Directory(local) : File(local)];
if (depth != '0' && type == FileSystemEntityType.directory) items.addAll(Directory(local).listSync());
res.statusCode = 207;
res.headers.contentType = ContentType('application', 'xml', charset: 'utf-8');
res.write('<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:">${items.map(_entry).join()}</d:multistatus>');
case 'GET':
if (type != FileSystemEntityType.file) {
res.statusCode = 404;
break;
}
res.contentLength = File(local).lengthSync();
await res.addStream(File(local).openRead());
case 'PUT':
if (!Directory(p.dirname(local)).existsSync()) {
await req.drain<void>();
res.statusCode = 409;
break;
}
final out = File(local).openWrite();
await out.addStream(req);
await out.close();
res.statusCode = 201;
case 'MKCOL':
await req.drain<void>();
if (type != FileSystemEntityType.notFound) {
res.statusCode = 405;
} else if (!Directory(p.dirname(local)).existsSync()) {
res.statusCode = 409;
} else {
Directory(local).createSync();
res.statusCode = 201;
}
case 'DELETE':
await req.drain<void>();
if (type == FileSystemEntityType.directory) {
Directory(local).deleteSync(recursive: true);
} else if (type == FileSystemEntityType.file) {
File(local).deleteSync();
} else {
res.statusCode = 404;
break;
}
res.statusCode = 204;
case 'MOVE' || 'COPY':
await req.drain<void>();
final dest = _local(Uri.parse(req.headers.value('Destination')!).path);
if (FileSystemEntity.typeSync(dest) != FileSystemEntityType.notFound && req.headers.value('Overwrite') == 'F') {
res.statusCode = 412;
break;
}
if (req.method == 'MOVE') {
if (type == FileSystemEntityType.directory) {
Directory(local).renameSync(dest);
} else {
File(local).renameSync(dest);
}
} else {
if (type == FileSystemEntityType.directory) {
_copyDir(Directory(local), Directory(dest));
} else {
File(local).copySync(dest);
}
}
res.statusCode = 201;
default:
await req.drain<void>();
res.statusCode = 405;
}
} catch (e) {
res.statusCode = 500;
res.write('$e');
}
await res.close();
}
static void _copyDir(Directory from, Directory to) {
to.createSync(recursive: true);
for (final e in from.listSync()) {
final t = p.join(to.path, p.basename(e.path));
if (e is Directory) {
_copyDir(e, Directory(t));
} else if (e is File) {
e.copySync(t);
}
}
}
}

162
test/webdav_test.dart Normal file
View File

@ -0,0 +1,162 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/live_sync.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/core/vfs.dart';
import 'package:jj_mkvmaker/core/webdav.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:path/path.dart' as p;
import 'support/dav_server.dart';
void main() {
late Directory tmp, remote, local;
late TestDavServer server;
const id = 'nas';
setUp(() async {
tmp = Directory.systemTemp.createTempSync('jj_dav_');
remote = Directory(p.join(tmp.path, 'remote'))..createSync();
local = Directory(p.join(tmp.path, 'local'))..createSync();
server = TestDavServer(remote);
await server.start();
DavRegistry.configure([DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]);
});
tearDown(() async {
DavRegistry.configure([]);
await server.stop();
tmp.deleteSync(recursive: true);
});
File lf(String rel, [String text = 'x']) => File(p.join(local.path, rel))
..parent.createSync(recursive: true)
..writeAsStringSync(text);
File rf(String rel, [String text = 'x']) => File(p.join(remote.path, rel))
..parent.createSync(recursive: true)
..writeAsStringSync(text);
test('경로: dav://서버/경로 정리 · 이어 붙이기 · 같은지 · 안에 있는지', () {
expect(DavPath.parse(r'dav://nas/a\b/').full, 'dav://nas/a/b');
expect(DavPath.parse('dav://nas').full, 'dav://nas/');
expect(vJoin('dav://nas/', '한글 폴더'), 'dav://nas/한글 폴더');
expect(vDirname('dav://nas/a/b'), 'dav://nas/a');
expect(vDirname('dav://nas/a'), 'dav://nas/');
expect(vBasename('dav://nas/a/b.mkv'), 'b.mkv');
expect(samePath('dav://nas/a/', r'dav://nas/a'), isTrue);
expect(isSameOrInside('dav://nas/a/b', 'dav://nas/a'), isTrue);
expect(isSameOrInside('dav://nas/ab', 'dav://nas/a'), isFalse);
expect(isSameOrInside('dav://nas/a', 'dav://other/a'), isFalse);
expect(samePath('dav://nas/a', r'C:\a'), isFalse);
});
test('목록 · 폴더 만들기 · 이름 바꾸기 · 지우기 (한글 이름 · 인증)', () async {
rf('영상/a.mkv', 'aaaa');
rf('영상/sub/b.txt', 'b');
final top = await listEntries('dav://$id/');
expect(top.map((e) => (e.name, e.isDir)), [('영상', true)]);
final inside = sortEntries(await listEntries('dav://$id/영상'), SortBy.name);
expect(inside.map((e) => (e.name, e.isDir, e.size)), [('sub', true, 0), ('a.mkv', false, 4)]);
final made = await FileOps.makeFolder('dav://$id/영상', '새 폴더');
expect(made, 'dav://$id/영상/새 폴더');
expect(Directory(p.join(remote.path, '영상', '새 폴더')).existsSync(), isTrue);
final renamed = await FileOps.rename('dav://$id/영상/a.mkv', 'c.mkv');
expect(renamed, 'dav://$id/영상/c.mkv');
expect(File(p.join(remote.path, '영상', 'c.mkv')).readAsStringSync(), 'aaaa');
await FileOps().delete(['dav://$id/영상/sub']);
expect(Directory(p.join(remote.path, '영상', 'sub')).existsSync(), isFalse);
// 잘못된 비밀번호
DavRegistry.configure([DavServer(id: id, name: 'x', url: server.url, user: 'user', password: 'wrong')]);
expect(() => listEntries('dav://$id/'), throwsA(isA<DavException>().having((e) => e.status, 'status', 401)));
});
test('복사 · 이동: 로컬 → WebDAV · WebDAV → 로컬 · 같은 서버 안 이동 (속도 알림)', () async {
lf('up/x.txt', 'xx');
lf('up/deep/y.bin', 'yyy');
var bytes = 0;
final ops = FileOps(onBytes: (n) => bytes += n);
expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up']);
expect(File(p.join(remote.path, 'up', 'deep', 'y.bin')).readAsStringSync(), 'yyy');
expect(bytes, 5);
// 같은 이름이면 "이름 (2)"
expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up (2)']);
// WebDAV → 로컬
final down = Directory(p.join(local.path, 'down'))..createSync();
await ops.copy(['dav://$id/up'], down.path);
expect(File(p.join(down.path, 'up', 'x.txt')).readAsStringSync(), 'xx');
// 같은 서버 안 이동 (서버가 MOVE)
await FileOps.makeFolder('dav://$id/', 'box');
await ops.move(['dav://$id/up (2)'], 'dav://$id/box');
expect(Directory(p.join(remote.path, 'box', 'up (2)')).existsSync(), isTrue);
expect(Directory(p.join(remote.path, 'up (2)')).existsSync(), isFalse);
expect(server.requests.where((r) => r.startsWith('MOVE')), isNotEmpty);
// 로컬 → WebDAV 이동 (복사 후 원본 지움)
lf('mv/m.txt', 'm');
await ops.move([p.join(local.path, 'mv')], 'dav://$id/box');
expect(File(p.join(remote.path, 'box', 'mv', 'm.txt')).readAsStringSync(), 'm');
expect(Directory(p.join(local.path, 'mv')).existsSync(), isFalse);
});
test('동기화 (mirror): 바뀐 것만 · 지우기 · -u · 원격 → 로컬도', () async {
lf('src/a.txt', 'a');
lf('src/sub/b.txt', 'b');
final ops = FileOps();
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 2);
expect(File(p.join(remote.path, 'dst', 'sub', 'b.txt')).readAsStringSync(), 'b');
// 다시: 바뀐 것 없음 (올린 파일은 서버 시각이 원본보다 새것)
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 0);
// 원본에서 내용 (크기) 이 바뀐 것만
lf('src/a.txt', 'aaa');
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 1);
expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'aaa');
// 지우기 포함: 대상에만 있는 것
rf('dst/extra.txt', 'e');
await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', delete: true);
expect(File(p.join(remote.path, 'dst', 'extra.txt')).existsSync(), isFalse);
// -u: 대상이 더 새것이면 덮어쓰지 않음
rf('dst/a.txt', 'remote newer!!');
final old = DateTime.now().subtract(const Duration(days: 1));
File(p.join(local.path, 'src', 'a.txt')).setLastModifiedSync(old);
await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', update: true);
expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'remote newer!!');
// 원격 → 로컬 (받은 파일은 원격의 바뀐 때로 맞춰져 다시 받지 않음)
final back = p.join(local.path, 'back');
expect(await ops.mirror('dav://$id/dst', back), 2);
expect(File(p.join(back, 'sub', 'b.txt')).readAsStringSync(), 'b');
expect(await ops.mirror('dav://$id/dst', back), 0);
});
test('빈 폴더 지우기 · 파일 수 · 용량 · 임시로 받기', () async {
rf('p/e1/e2/.keep', '');
File(p.join(remote.path, 'p', 'e1', 'e2', '.keep')).deleteSync();
rf('p/f/z.txt', 'z');
expect(await vCountFiles('dav://$id/p'), 1);
expect(await removeEmptyDirs('dav://$id/p'), 2); // e1/e2, e1
expect(Directory(p.join(remote.path, 'p', 'f')).existsSync(), isTrue);
expect(await DavRegistry.client(id).quota(), (1000000, 500));
final got = await vLocalCopy('dav://$id/p/f/z.txt', p.join(local.path, 'cache'));
expect(File(got).readAsStringSync(), 'z');
expect(transferProblem(['dav://$id/p'], 'dav://$id/p/f', move: false), contains('자기 자신 안으로'));
});
test('실시간 동기화 (lsync): 로컬 → WebDAV 쌍은 rsync 로 정해 두어도 앱이 맞추고 남은 것 · 지우기', () async {
lf('live/a.txt', 'a');
lf('live/sub/b.txt', 'bb');
rf('mirror/old.txt', 'o');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);
expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt', '− old.txt']));
await live.syncNow(pair);
expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패')));
expect(File(p.join(remote.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bb');
expect(File(p.join(remote.path, 'mirror', 'old.txt')).existsSync(), isFalse);
expect(live.pending[LiveSync.keyOf(pair)], isEmpty);
live.dispose();
});
}