복사 모니터링 · 복사 기억 · lsync 일정 (cron + 격자 편집)
- 설정 [복사 모니터링] 켜면 파일 탐색기 가운데에 [모니터링] 버튼 - 복사 · rsync 탭: 복사 대상 기억, 진행률, 대상 디스크 용량, 옵션 고치기 → 저장 후 현재 유지 / 설정 반영 후 실행, 실행 · 재개 · 취소 · 지우기, lsync 로 이동 - lsync 탭: 맞출 파일 자동 표시, 지금 맞추기, 복사 · rsync 로 이동 - 동기화 시간: 계속 / cron 시간 창 (요일 × 시 격자로 칠해서 편집) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>묶음3
parent
7308f8ce1b
commit
b38253fd67
|
|
@ -64,6 +64,14 @@ class MainActivity : FlutterActivity() {
|
|||
result.error("INSTALL", e.message ?: e.toString(), null)
|
||||
}
|
||||
"openFolder" -> result.success(openFolder(call.argument<String>("path") ?: ""))
|
||||
"diskSpace" -> result.success(
|
||||
try {
|
||||
val st = android.os.StatFs(call.argument<String>("path") ?: "")
|
||||
listOf(st.availableBytes, st.totalBytes)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
)
|
||||
"openWith" -> result.success(
|
||||
openWith(call.argument<String>("path") ?: "", call.argument<Boolean>("choose") ?: false)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
" / 전체 {0}": " / total {0}",
|
||||
" · ⚠ {0}개는 원본보다 커서 화질 향상 없이 용량만 늘어납니다": " · ⚠ {0} are larger than the original: the file grows without better quality",
|
||||
" · 다시 시작해야 Chrome 으로 바뀝니다": " · Restart to switch to Chrome",
|
||||
" · 다음 시작 시간에 맞춥니다": " · synced at the next start time",
|
||||
" · 대기 {0}": " · {0} waiting",
|
||||
" · 대기 {0}개": " · {0} waiting",
|
||||
" · 전에 만든 자막 {0}개 (jj_mkv)": " · {0} subtitles made earlier (jj_mkv)",
|
||||
|
|
@ -32,6 +33,7 @@
|
|||
"(AI 번역을 쓸 수 없는 기기라 언어를 더할 수 없습니다)": "(Languages cannot be added because AI translation is unavailable on this device)",
|
||||
"(비어 있음)": "(empty)",
|
||||
"(설명 없음)": "(no description)",
|
||||
"(현재 방식은 옵션 없음)": "(no options for the built-in method)",
|
||||
"+ 여기에 새 줄": "+ New line here",
|
||||
".{0} 재생 프로그램 (exe)": ".{0} player (exe)",
|
||||
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = unlimited. Applies to every method: rsync --bwlimit, the built-in method throttles itself, robocopy approximates with /IPG. You can also put it in the options field (that takes priority)",
|
||||
|
|
@ -144,8 +146,11 @@
|
|||
"aria2 오류: {0}": "aria2 error: {0}",
|
||||
"cookies.txt (Netscape 형식)": "cookies.txt (Netscape format)",
|
||||
"cookies.txt 파일…": "cookies.txt file…",
|
||||
"cron (분 시 일 월 요일) · 한 줄에 하나": "cron (minute hour day month weekday) · one per line",
|
||||
"ffmpeg 를 실행할 수 없습니다: {0}": "Cannot run ffmpeg: {0}",
|
||||
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg error (code {0})\n{1}",
|
||||
"lsync (실시간 동기화)": "lsync (live sync)",
|
||||
"lsync 로 이동": "Move to lsync",
|
||||
"quit 시작": "quit started",
|
||||
"robocopy 옵션": "robocopy options",
|
||||
"rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만": "rsync --delete · robocopy /PURGE. When off, only adds · updates in the target",
|
||||
|
|
@ -180,6 +185,7 @@
|
|||
"{0} 안의 파일": "Files in {0}",
|
||||
"{0} 에 붙여넣기 ({1}개)": "Paste into {0} ({1})",
|
||||
"{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}",
|
||||
"{0} 외 {1}개": "{0} and {1} more",
|
||||
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg does not have the {0} encoder.",
|
||||
"{0} 추가": "Add {0}",
|
||||
"{0} 파일": "{0} file",
|
||||
|
|
@ -193,6 +199,7 @@
|
|||
"{0}개 · 주소 {1}개": "{0} · {1} links",
|
||||
"{0}개 맞춤": "{0} synced",
|
||||
"{0}개 선택": "{0} selected",
|
||||
"{0}개 폴더를 lsync (실시간 동기화) 로 옮겼습니다.": "Moved {0} folders to lsync (live sync).",
|
||||
"{0}개 표시함": "{0} marked",
|
||||
"{0}개 항목을 {1}": "{0} items {1}",
|
||||
"{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} items to this folder — {1}\n{2}",
|
||||
|
|
@ -238,6 +245,8 @@
|
|||
"결과 폴더 열기 (동영상을 고르세요)": "Open output folder (choose a video)",
|
||||
"결과 폴더 열기: {0}": "Open output folder: {0}",
|
||||
"결과물 이동 실패: {0} ({1})": "Failed to move result: {0} ({1})",
|
||||
"계속": "Always",
|
||||
"계속 (앱이 켜져 있는 동안)": "Always (while the app is running)",
|
||||
"계속 편집": "Keep editing",
|
||||
"고르기": "Choose",
|
||||
"고르지 않음": "Not chosen",
|
||||
|
|
@ -249,7 +258,9 @@
|
|||
"권한 허용 화면 열기": "Open the permission screen",
|
||||
"그 파일 하나만": "Only that file",
|
||||
"그리스어": "Greek",
|
||||
"글을 격자에 반영": "Apply text to grid",
|
||||
"글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "Text · button size. Change it any time with − · + on the top bar; press the number in the middle to return to this size (now {0}%)",
|
||||
"금": "Fri",
|
||||
"기계 번역": "Machine translation",
|
||||
"기기": "device",
|
||||
"기능 버튼 줄": "Button bar",
|
||||
|
|
@ -263,6 +274,7 @@
|
|||
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "Default: MP4. Audio only is saved as MP3 · M4A",
|
||||
"기본값": "Default",
|
||||
"기본값으로 ({0})": "Default ({0})",
|
||||
"기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "No remembered copies. Copies · moves in the file explorer are added here automatically.",
|
||||
"기타 즐겨찾기": "Other bookmarks",
|
||||
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "Long press · right click always opens the action menu (copy · move · delete · rename …). Ctrl + click: select several",
|
||||
"길이 {0}": "Length {0}",
|
||||
|
|
@ -274,6 +286,7 @@
|
|||
"끝": "End",
|
||||
"끝 (오른쪽 · 아래)": "Edge (right · bottom)",
|
||||
"끝 = 현재": "End = now",
|
||||
"끝 · {0} · 파일 {1}개": "Done · {0} · {1} files",
|
||||
"끝 시간이 시작보다 빠른 줄: {0}{1}": "Lines whose end is before their start: {0}{1}",
|
||||
"끝: {0}": "End: {0}",
|
||||
"끝나지 못했습니다: {0}": "Did not finish: {0}",
|
||||
|
|
@ -312,6 +325,7 @@
|
|||
"노르웨이어": "Norwegian",
|
||||
"누르고 있으면 조정 전": "Hold to see before",
|
||||
"누르기": "Tap",
|
||||
"누르면 일정 바꾸기": "Tap to change the schedule",
|
||||
"다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)": "As soon as they finish, put them in the Make MKV video list and remove them from the download list (same as the checkbox on the download screen)",
|
||||
"다 받으면 MKV 만들기의 동영상 목록에 자동으로 넣고, 이 다운로드 목록에서는 뺍니다 (받은 파일은 그대로)": "When finished, automatically add to the Make MKV video list and remove from this download list (the downloaded file stays)",
|
||||
"다른 브라우저의 즐겨찾기 가져오기": "Import bookmarks from another browser",
|
||||
|
|
@ -327,6 +341,7 @@
|
|||
"다른 창으로 이동": "Move to other pane",
|
||||
"다른 폴더로 이동": "Move to another folder",
|
||||
"다시 만들기 대기로": "Back to waiting for remake",
|
||||
"다시 세기": "Recount",
|
||||
"다시 시작": "Restart",
|
||||
"다운로드": "Downloads",
|
||||
"다운로드 (jj_yt-dlp · jj_aria2)": "Downloads (jj_yt-dlp · jj_aria2)",
|
||||
|
|
@ -358,9 +373,12 @@
|
|||
"닫기": "Close",
|
||||
"대기": "Waiting",
|
||||
"대기 {0}개:": "{0} waiting:",
|
||||
"대기 · 다음 시작 {0}": "Waiting · next start {0}",
|
||||
"대기 중인 작업 {0}개 취소": "Cancel {0} waiting jobs",
|
||||
"대기열에 추가: {0} (대기 {1}개)": "Added to queue: {0} ({1} waiting)",
|
||||
"대비": "Contrast",
|
||||
"대상 디스크: 남은 {0} / 전체 {1}": "Target disk: {0} free / {1} total",
|
||||
"대상 디스크: 알 수 없음": "Target disk: unknown",
|
||||
"대상 폴더 열기": "Open containing folder",
|
||||
"더 보기": "More",
|
||||
"더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "When JJ_MKVMaker is chosen by double-click · default app (the right-click menu stays)",
|
||||
|
|
@ -368,6 +386,8 @@
|
|||
"덴마크어": "Danish",
|
||||
"독일어": "German",
|
||||
"돌아가기": "Go back",
|
||||
"동기화 시간": "Sync time",
|
||||
"동기화 시간 (계속 / 시간 지정)": "Sync time (always / scheduled)",
|
||||
"동시 MKV 변환 수": "Simultaneous MKV conversions",
|
||||
"동시 다운로드 수": "Simultaneous downloads",
|
||||
"동시에 처리할 개수 (0 = 무한)": "Number processed at once (0 = unlimited)",
|
||||
|
|
@ -386,6 +406,7 @@
|
|||
"동영상을 고르고, 동영상 옆 jj_mkv 폴더에 MKV 를 만들려면\n\"모든 파일에 대한 접근\" 권한이 필요합니다.": "To choose videos and make MKVs in the jj_mkv folder next to them,\n\"All files access\" permission is needed.",
|
||||
"동영상을 끌어다 놓으면 목록에 추가됩니다": "Drag and drop videos to add them to the list",
|
||||
"동영상이 있는 폴더 아래 jj_mkv (기본)": "jj_mkv under the video's folder (default)",
|
||||
"동작 중 · {0} 까지": "Running · until {0}",
|
||||
"되살리기": "Restore",
|
||||
"두 창": "Two panes",
|
||||
"두 창 배치": "Two-pane arrangement",
|
||||
|
|
@ -398,6 +419,7 @@
|
|||
"러시아어": "Russian",
|
||||
"로그인하면 하루 받기 횟수가 늘어납니다": "Signing in raises the daily download limit",
|
||||
"루마니아어": "Romanian",
|
||||
"마지막": "Last",
|
||||
"마지막 인코딩 설정을 기억합니다": "Remembers the last encoding settings",
|
||||
"만들 자막 언어 (파일명_언어코드.srt)": "Subtitle languages to make (filename_languagecode.srt)",
|
||||
"말레이어": "Malay",
|
||||
|
|
@ -405,9 +427,15 @@
|
|||
"맞추기 (비율이 다를 때)": "Conform (when the aspect differs)",
|
||||
"맞추기 (흐린 배경)": "Fit (blurred background)",
|
||||
"맞추는 중…": "Syncing…",
|
||||
"맞출 것 {0}개{1}": "{0} to sync{1}",
|
||||
"맞출 것 없음 (원본과 대상이 같습니다)": "Nothing to sync (source and target match)",
|
||||
"맞출 것을 세는 중…": "Counting what to sync…",
|
||||
"맞춤": "Synced",
|
||||
"매번 고르기": "Ask every time",
|
||||
"매일 종일": "Every day, all day",
|
||||
"멈춤": "Stopped",
|
||||
"멈춤 · {0}": "Stopped · {0}",
|
||||
"모니터링": "Monitor",
|
||||
"모델 내려받는 중 {0}%": "Downloading model {0}%",
|
||||
"모델 내려받는 중: {0} ({1})": "Downloading model: {0} ({1})",
|
||||
"모델 파일이 잘렸습니다 (위치 {0})": "The model file is truncated (position {0})",
|
||||
|
|
@ -418,9 +446,11 @@
|
|||
"모두 지우기": "Clear all",
|
||||
"모두 초기화": "Reset all",
|
||||
"모든 작업 취소": "Cancel all jobs",
|
||||
"목": "Thu",
|
||||
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos",
|
||||
"목록 보기": "View list",
|
||||
"목록에서 제거": "Remove from list",
|
||||
"목록에서 지우기 (파일은 그대로)": "Remove from list (files stay)",
|
||||
"목록의 동영상 {0}개 모두": "All {0} videos in the list",
|
||||
"몽골어": "Mongolian",
|
||||
"무한": "Unlimited",
|
||||
|
|
@ -428,6 +458,7 @@
|
|||
"미리보기: 목록에서 동영상을 고르세요": "Preview: choose a video in the list",
|
||||
"미얀마어": "Burmese",
|
||||
"미지정": "Unspecified",
|
||||
"바꾸기": "Change",
|
||||
"바꾸면 바로 저장됩니다": "Changes are saved right away",
|
||||
"바꾼 것이 없습니다 (원본 그대로)": "Nothing changed (original as is)",
|
||||
"바뀐 날": "Modified",
|
||||
|
|
@ -448,6 +479,7 @@
|
|||
"받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "The downloaded file is corrupt or a different file (SHA256 mismatch). Installation was stopped.",
|
||||
"받음 {0}": "Received {0}",
|
||||
"밝기": "Brightness",
|
||||
"밤 0~6시": "Night 0-6",
|
||||
"방법: {0}": "Method: {0}",
|
||||
"백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "Background: hides only the window and keeps downloading · converting. Reopen with {0} or the tray icon.\nTo quit completely: right-click the tray icon > Quit (always quits whatever the setting)",
|
||||
"백그라운드로": "To background",
|
||||
|
|
@ -470,8 +502,14 @@
|
|||
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "Choose the buttons to show and drag to reorder.",
|
||||
"복사": "Copy",
|
||||
"복사 (붙여넣기로)": "Copy (to paste)",
|
||||
"복사 · rsync": "Copy · rsync",
|
||||
"복사 · rsync 로 이동": "Move to copy · rsync",
|
||||
"복사 · rsync 목록으로 옮겼습니다.": "Moved to the copy · rsync list.",
|
||||
"복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).": "Copy · move works with two panes (Pane layout > Two panes).",
|
||||
"복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to copy · move (tap, or mark with the circle on the right).",
|
||||
"복사 끝 ({0}): {1}개 → {2}": "Copy done ({0}): {1} → {2}",
|
||||
"복사 모니터링": "Copy monitor",
|
||||
"복사 실패 ({0}): {1}": "Copy failed ({0}): {1}",
|
||||
"복사(Ctrl+C)한 주소 자동 다운로드": "Auto-download copied (Ctrl+C) URLs",
|
||||
"복사하는 중": "Copying",
|
||||
"복사할까요?": "copy?",
|
||||
|
|
@ -523,6 +561,7 @@
|
|||
"선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)": "Play selected files: the video you are viewing (check several in the list)",
|
||||
"선택한 파일 재생: 체크한 동영상 {0}개를 목록 순서대로 이어서": "Play selected files: the {0} checked videos in list order",
|
||||
"선택한 파일 재생{0}": "Play selected files{0}",
|
||||
"설정 반영 후 실행": "Apply and run",
|
||||
"설정 화면에서 \"이 출처의 앱 설치 허용\" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. 받은 파일을 그대로 씁니다 (다시 받지 않음).": "Turn on \"Allow from this source\" in the settings screen, then come back and tap [Continue install]. The downloaded file is used as is (no download again).",
|
||||
"설치 계속": "Continue install",
|
||||
"설치 실패": "Install failed",
|
||||
|
|
@ -537,8 +576,10 @@
|
|||
"세로 4:5": "Portrait 4:5",
|
||||
"세로 9:16": "Portrait 9:16",
|
||||
"세로 고정": "Portrait lock",
|
||||
"속도 제한": "Speed limit",
|
||||
"속도 제한 (KB/s)": "Speed limit (KB/s)",
|
||||
"속도 제한 {0} KB/s": "Speed limit {0} KB/s",
|
||||
"수": "Wed",
|
||||
"수정": "Edit",
|
||||
"수정 · 이동": "Edit · move",
|
||||
"수정한 날짜": "Date modified",
|
||||
|
|
@ -551,6 +592,7 @@
|
|||
"스웨덴어": "Swedish",
|
||||
"스타일": "Style",
|
||||
"스페인어": "Spanish",
|
||||
"시간 지정 (cron)": "Scheduled (cron)",
|
||||
"시간 형식 오류 {0}곳": "{0} time format errors",
|
||||
"시간순 정렬": "Sort by time",
|
||||
"시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "Series: files that differ only by number, like file_001 · file_002, S01E01 · S01E02",
|
||||
|
|
@ -567,7 +609,9 @@
|
|||
"실시간 동기화 추가": "Add live sync",
|
||||
"실시간 동기화: 대상 폴더": "Live sync: target folder",
|
||||
"실시간 동기화: 원본 폴더": "Live sync: source folder",
|
||||
"실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.": "No live sync. [Add] one, or use [Move to lsync] in the copy · rsync tab.",
|
||||
"실패": "Failed",
|
||||
"실패 · {0}": "Failed · {0}",
|
||||
"실패: {0}": "Failed: {0}",
|
||||
"실패: {0}\n{1}": "Failed: {0}\n{1}",
|
||||
"실행": "Run",
|
||||
|
|
@ -583,6 +627,7 @@
|
|||
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "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])",
|
||||
"아직 만든 MKV · 자막이 없습니다: {0}": "No MKV · subtitles made yet: {0}",
|
||||
"아직 실행 안 함": "Not run yet",
|
||||
"아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "None yet. Videos come in automatically once downloaded (change in Settings).",
|
||||
"아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "Not yet (downloaded when you copy with rsync · or press [Download])",
|
||||
"아직 지원하지 않는 플랫폼입니다: {0}": "This platform is not supported yet: {0}",
|
||||
|
|
@ -595,6 +640,7 @@
|
|||
"앱 안 브라우저 (권장)": "In-app browser (recommended)",
|
||||
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "Use the in-app browser's sign-in (sign in to YouTube on the browser screen)",
|
||||
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "In-app web browser: presses [Skip] for you, and mutes and fast-forwards ads that can't be skipped (\"Skipping ad…\" shows at the top while skipping)",
|
||||
"앱이 켜져 있는 동안 늘 동기화합니다.": "Always syncs while the app is running.",
|
||||
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "Press anywhere to bring up the JJ_MKVMaker window",
|
||||
"언어": "Language",
|
||||
"언어 추가": "Add language",
|
||||
|
|
@ -620,6 +666,7 @@
|
|||
"영어": "English",
|
||||
"예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 이동은 --remove-source-files 를 자동으로 붙임": "e.g. -avPog (archive · verbose · progress · owner · group). Move adds --remove-source-files 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",
|
||||
"예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "e.g. Ctrl+Shift+X (Ctrl·Shift·Alt·Win + letter·digit·F1~F12)",
|
||||
"예전 구조의 프로그램 파일 정리 ({0}개): {1}": "Cleaned up program files of the old layout ({0}): {1}",
|
||||
"오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "You have used all of today's subtitle downloads. ({0})",
|
||||
|
|
@ -630,6 +677,8 @@
|
|||
"옮겼습니다.": "moved.",
|
||||
"옮기는 중": "Moving",
|
||||
"옮길까요?": "move?",
|
||||
"옵션": "Options",
|
||||
"옵션을 저장했습니다": "Options saved",
|
||||
"완료": "Done",
|
||||
"완료 정리": "Clear completed",
|
||||
"완료: 성공 {0} / 전체 {1}": "Done: {0} succeeded / {1} total",
|
||||
|
|
@ -654,10 +703,12 @@
|
|||
"원본과 대상이 서로 안에 있습니다": "Source and target are inside each other",
|
||||
"원본에 없는 것을 대상에서 지우기": "Delete from target what is not in the source",
|
||||
"원본의 {0}배": "{0}× original",
|
||||
"원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "When the source changes, the target is synced. With a schedule, sync happens only in that time; changes outside it are collected and synced at the next start time.",
|
||||
"원어 (영상 속 언어)": "Source language (spoken in the video)",
|
||||
"원어({0})와 같은 언어로는 번역하지 않습니다.": "Will not translate into the same language as the source ({0}).",
|
||||
"원어: {0} ({1})": "Source: {0} ({1})",
|
||||
"원어: 자동 감지 (글자로 추정)": "Source: auto-detect (guessed from the text)",
|
||||
"월": "Mon",
|
||||
"웹 브라우저": "Web browser",
|
||||
"웹 브라우저 (지금 여기)": "Web browser (you are here)",
|
||||
"웹 브라우저 (홈 주소)": "Web browser (home page)",
|
||||
|
|
@ -687,6 +738,7 @@
|
|||
"이 버전 건너뛰기": "Skip this version",
|
||||
"이 버전에는 Android 용 APK 가 없습니다.": "This version has no APK for Android.",
|
||||
"이 버전에는 Windows 용 zip 이 없습니다.": "This version has no zip for Windows.",
|
||||
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "These options are used the next time you do this copy. Run now with the new options?",
|
||||
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "Could not find this browser's bookmarks file.",
|
||||
"이 언어 지우기": "Remove this language",
|
||||
"이 이동 버튼 지우기": "Delete this move button",
|
||||
|
|
@ -738,6 +790,7 @@
|
|||
"인터넷 자막 (OpenSubtitles.com)": "Internet subtitles (OpenSubtitles.com)",
|
||||
"인터넷 자막 찾기": "Find subtitles online",
|
||||
"인터넷 자막 찾기{0}": "Find subtitles online{0}",
|
||||
"일": "Sun",
|
||||
"일반": "General",
|
||||
"일본어": "Japanese",
|
||||
"일시정지": "Pause",
|
||||
|
|
@ -846,6 +899,7 @@
|
|||
"종료할까요?": "Quit?",
|
||||
"종류 (확장자)": "Type (extension)",
|
||||
"좌우": "Side by side",
|
||||
"주말 종일": "Weekends, all day",
|
||||
"주소": "Address",
|
||||
"주소 입력 또는 검색 (Ctrl+L)": "Enter an address or search (Ctrl+L)",
|
||||
"준비 중": "Preparing",
|
||||
|
|
@ -870,6 +924,7 @@
|
|||
"즐겨찾기 추가…": "Add bookmark…",
|
||||
"즐겨찾기 추가됨": "Bookmark added",
|
||||
"즐겨찾기 표시줄": "Bookmarks bar",
|
||||
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "The running copy uses the previous options. Stop it and run again with the new options?",
|
||||
"지금 맞추기": "Sync now",
|
||||
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "{0} for the video you are viewing (check several in the list)",
|
||||
"지금 설치": "Install now",
|
||||
|
|
@ -925,6 +980,8 @@
|
|||
"취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": ".part · .ytdl · .aria2 · unmerged fragments left by cancelled · failed downloads (jj_yt-dlp · jj_aria2)",
|
||||
"취소됨": "Cancelled",
|
||||
"취소했습니다.": "Cancelled.",
|
||||
"칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "Syncs only in the painted hours; changes outside are collected and synced at the next start. Tap or drag cells to paint (tap a weekday name · hour number for the whole row · column).",
|
||||
"칠한 시간이 없어 동기화하지 않습니다.": "No hours painted, so nothing will sync.",
|
||||
"켜져 있는 창에서 (기본)": "In the open window (default)",
|
||||
"코덱": "Codec",
|
||||
"크기": "Size",
|
||||
|
|
@ -936,6 +993,7 @@
|
|||
"탐색기에서 연 동영상을 재생할 창": "Window to play videos opened from Explorer",
|
||||
"태국어": "Thai",
|
||||
"터키어": "Turkish",
|
||||
"토": "Sat",
|
||||
"토렌트 · 마그넷 다운로드": "Torrent · magnet download",
|
||||
"트랙 #{0} ({1}){2}": "Track #{0} ({1}){2}",
|
||||
"트랙 {0}": "Track {0}",
|
||||
|
|
@ -951,6 +1009,7 @@
|
|||
"파일 이름": "File name",
|
||||
"파일 탐색기": "File explorer",
|
||||
"파일 탐색기 (지금 여기)": "File explorer (you are here)",
|
||||
"파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, 옵션을 고쳐 다시 실행 · 재개하거나 lsync 로 옮깁니다 (복사 · rsync 와 lsync 를 따로).": "[Monitor] button in the middle of the file explorer: remembers copies · moves and shows progress · target disk space · options; change options to run again · resume, or move to lsync (copy · rsync and lsync shown separately).",
|
||||
"파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "You can also change it from the ⋮ menu at the top of the file explorer (side by side · top and bottom · button bar position · buttons)",
|
||||
"파일 폴더": "File folder",
|
||||
"파일 확인 중…": "Checking files…",
|
||||
|
|
@ -965,6 +1024,7 @@
|
|||
"편집 목록에 있음": "In the edit list",
|
||||
"편집 목록에 추가": "Add to the edit list",
|
||||
"편집 목록에 추가 (MKV 만들기 대상)": "Add to the edit list (Make MKV target)",
|
||||
"평일 9~18시": "Weekdays 9-18",
|
||||
"포르투갈어": "Portuguese",
|
||||
"포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "Included components and licenses (THIRD_PARTY_NOTICES.txt)",
|
||||
"폴더": "Folder",
|
||||
|
|
@ -982,6 +1042,7 @@
|
|||
"폴더가 들어 있을 때": "When folders are included",
|
||||
"폴더가 아닙니다: {0}": "Not a folder: {0}",
|
||||
"폴더를 만들 수 없습니다: {0}": "Cannot create the folder: {0}",
|
||||
"폴더만 lsync 로 옮길 수 있습니다.": "Only folders can be moved to lsync.",
|
||||
"폴란드어": "Polish",
|
||||
"표시 (여러 개 고르기)": "Mark (select several)",
|
||||
"표시 이름": "Display name",
|
||||
|
|
@ -1018,6 +1079,7 @@
|
|||
"허용했으면 다시 확인": "Check again after allowing",
|
||||
"헝가리어": "Hungarian",
|
||||
"현재 방식": "Built-in",
|
||||
"현재 유지": "Keep as is",
|
||||
"현재 페이지": "Current page",
|
||||
"현재 페이지 추가": "Add current page",
|
||||
"형식 오류": "Format error",
|
||||
|
|
@ -1025,6 +1087,7 @@
|
|||
"홈 주소": "Home page",
|
||||
"홈 화면": "Home screen",
|
||||
"홈 화면 ({0}) · 환경 설정에서 바꿈": "Home screen ({0}) · change in Settings",
|
||||
"화": "Tue",
|
||||
"화면": "Display",
|
||||
"화면 · 색 보정": "Frame · color",
|
||||
"화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "Translates all of the program's text on this device. It may take a few minutes.",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
" / 전체 {0}": " / 全体 {0}",
|
||||
" · ⚠ {0}개는 원본보다 커서 화질 향상 없이 용량만 늘어납니다": " · ⚠ {0}個は元より大きく、画質は上がらず容量だけ増えます",
|
||||
" · 다시 시작해야 Chrome 으로 바뀝니다": " · 再起動すると Chrome に切り替わります",
|
||||
" · 다음 시작 시간에 맞춥니다": " · 次の開始時刻に同期します",
|
||||
" · 대기 {0}": " · 待機 {0}",
|
||||
" · 대기 {0}개": " · 待機 {0}個",
|
||||
" · 전에 만든 자막 {0}개 (jj_mkv)": " · 以前作成した字幕 {0}個(jj_mkv)",
|
||||
|
|
@ -32,6 +33,7 @@
|
|||
"(AI 번역을 쓸 수 없는 기기라 언어를 더할 수 없습니다)": "(この端末では AI 翻訳が使えないため言語を追加できません)",
|
||||
"(비어 있음)": "(空)",
|
||||
"(설명 없음)": "(説明なし)",
|
||||
"(현재 방식은 옵션 없음)": "(現在の方式にはオプションなし)",
|
||||
"+ 여기에 새 줄": "+ ここに新しい行",
|
||||
".{0} 재생 프로그램 (exe)": ".{0} 再生プログラム(exe)",
|
||||
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 制限なし。すべての方法に適用: rsync は --bwlimit、現在の方式はアプリが調整、robocopy は /IPG で近似。オプション欄に直接入れても構いません (そちらが優先)",
|
||||
|
|
@ -144,8 +146,11 @@
|
|||
"aria2 오류: {0}": "aria2 エラー: {0}",
|
||||
"cookies.txt (Netscape 형식)": "cookies.txt(Netscape 形式)",
|
||||
"cookies.txt 파일…": "cookies.txt ファイル…",
|
||||
"cron (분 시 일 월 요일) · 한 줄에 하나": "cron (分 時 日 月 曜日) · 1 行に 1 つ",
|
||||
"ffmpeg 를 실행할 수 없습니다: {0}": "ffmpeg を実行できません: {0}",
|
||||
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg エラー(コード {0})\n{1}",
|
||||
"lsync (실시간 동기화)": "lsync (リアルタイム同期)",
|
||||
"lsync 로 이동": "lsync へ移動",
|
||||
"quit 시작": "終了開始",
|
||||
"robocopy 옵션": "robocopy オプション",
|
||||
"rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만": "rsync --delete · robocopy /PURGE。オフなら対象に追加 · 更新のみ",
|
||||
|
|
@ -180,6 +185,7 @@
|
|||
"{0} 안의 파일": "{0} 内のファイル",
|
||||
"{0} 에 붙여넣기 ({1}개)": "{0} に貼り付け ({1} 件)",
|
||||
"{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした",
|
||||
"{0} 외 {1}개": "{0} ほか {1} 件",
|
||||
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg に {0} エンコーダーがありません。",
|
||||
"{0} 추가": "{0} を追加",
|
||||
"{0} 파일": "{0} ファイル",
|
||||
|
|
@ -193,6 +199,7 @@
|
|||
"{0}개 · 주소 {1}개": "{0}個・URL {1}個",
|
||||
"{0}개 맞춤": "{0} 件を同期",
|
||||
"{0}개 선택": "{0}個選択",
|
||||
"{0}개 폴더를 lsync (실시간 동기화) 로 옮겼습니다.": "{0} 個のフォルダーを lsync (リアルタイム同期) へ移しました。",
|
||||
"{0}개 표시함": "{0} 件をマーク",
|
||||
"{0}개 항목을 {1}": "{0} 件の項目を{1}",
|
||||
"{0}개 항목을 다음 폴더로 {1}\n{2}": "{0} 件の項目を次のフォルダーへ{1}\n{2}",
|
||||
|
|
@ -238,6 +245,8 @@
|
|||
"결과 폴더 열기 (동영상을 고르세요)": "出力フォルダーを開く(動画を選んでください)",
|
||||
"결과 폴더 열기: {0}": "出力フォルダーを開く: {0}",
|
||||
"결과물 이동 실패: {0} ({1})": "成果物の移動失敗: {0}({1})",
|
||||
"계속": "常時",
|
||||
"계속 (앱이 켜져 있는 동안)": "常時 (アプリの起動中)",
|
||||
"계속 편집": "編集を続ける",
|
||||
"고르기": "選ぶ",
|
||||
"고르지 않음": "未選択",
|
||||
|
|
@ -249,7 +258,9 @@
|
|||
"권한 허용 화면 열기": "権限の許可画面を開く",
|
||||
"그 파일 하나만": "そのファイルだけ",
|
||||
"그리스어": "ギリシャ語",
|
||||
"글을 격자에 반영": "テキストをグリッドに反映",
|
||||
"글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字・ボタンの大きさです。上部バーの − ・ + でその都度変更でき、中央の数字を押すとこの大きさに戻ります(現在 {0}%)",
|
||||
"금": "金",
|
||||
"기계 번역": "機械翻訳",
|
||||
"기기": "端末",
|
||||
"기능 버튼 줄": "機能ボタンの列",
|
||||
|
|
@ -263,6 +274,7 @@
|
|||
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "既定: MP4。音声のみなら MP3・M4A で保存",
|
||||
"기본값": "既定値",
|
||||
"기본값으로 ({0})": "既定値に戻す ({0})",
|
||||
"기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "記憶したコピーはありません。ファイルエクスプローラーでコピー · 移動すると自動で登録されます。",
|
||||
"기타 즐겨찾기": "その他のブックマーク",
|
||||
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "長押し · 右クリックは常に機能メニュー (コピー · 移動 · 削除 · 名前の変更 …)。Ctrl + クリック: 複数選択",
|
||||
"길이 {0}": "長さ {0}",
|
||||
|
|
@ -274,6 +286,7 @@
|
|||
"끝": "終了",
|
||||
"끝 (오른쪽 · 아래)": "端 (右 · 下)",
|
||||
"끝 = 현재": "終了 = 現在",
|
||||
"끝 · {0} · 파일 {1}개": "完了 · {0} · ファイル {1} 件",
|
||||
"끝 시간이 시작보다 빠른 줄: {0}{1}": "終了時間が開始より早い行: {0}{1}",
|
||||
"끝: {0}": "終了: {0}",
|
||||
"끝나지 못했습니다: {0}": "完了できませんでした: {0}",
|
||||
|
|
@ -312,6 +325,7 @@
|
|||
"노르웨이어": "ノルウェー語",
|
||||
"누르고 있으면 조정 전": "押している間は調整前",
|
||||
"누르기": "タップ",
|
||||
"누르면 일정 바꾸기": "タップで予定を変更",
|
||||
"다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)": "ダウンロードが終わり次第 MKV 作成の動画リストに入れ、ダウンロードリストからは外します(ダウンロード画面のチェックボックスと同じ)",
|
||||
"다 받으면 MKV 만들기의 동영상 목록에 자동으로 넣고, 이 다운로드 목록에서는 뺍니다 (받은 파일은 그대로)": "ダウンロードが終わると MKV 作成の動画リストに自動で入れ、このダウンロードリストからは外します(ダウンロードしたファイルはそのまま)",
|
||||
"다른 브라우저의 즐겨찾기 가져오기": "他のブラウザーのブックマークを取り込む",
|
||||
|
|
@ -327,6 +341,7 @@
|
|||
"다른 창으로 이동": "もう一方のペインへ移動",
|
||||
"다른 폴더로 이동": "他のフォルダーに移動",
|
||||
"다시 만들기 대기로": "作り直し待ちに戻す",
|
||||
"다시 세기": "再カウント",
|
||||
"다시 시작": "再起動",
|
||||
"다운로드": "ダウンロード",
|
||||
"다운로드 (jj_yt-dlp · jj_aria2)": "ダウンロード(jj_yt-dlp・jj_aria2)",
|
||||
|
|
@ -358,9 +373,12 @@
|
|||
"닫기": "閉じる",
|
||||
"대기": "待機",
|
||||
"대기 {0}개:": "待機 {0}個:",
|
||||
"대기 · 다음 시작 {0}": "待機 · 次の開始 {0}",
|
||||
"대기 중인 작업 {0}개 취소": "待機中の作業 {0}個をキャンセル",
|
||||
"대기열에 추가: {0} (대기 {1}개)": "キューに追加: {0}(待機 {1}個)",
|
||||
"대비": "コントラスト",
|
||||
"대상 디스크: 남은 {0} / 전체 {1}": "対象ディスク: 空き {0} / 全体 {1}",
|
||||
"대상 디스크: 알 수 없음": "対象ディスク: 不明",
|
||||
"대상 폴더 열기": "対象フォルダーを開く",
|
||||
"더 보기": "その他",
|
||||
"더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "ダブルクリック・既定のアプリで JJ_MKVMaker を選んだとき(右クリックメニューはそのまま)",
|
||||
|
|
@ -368,6 +386,8 @@
|
|||
"덴마크어": "デンマーク語",
|
||||
"독일어": "ドイツ語",
|
||||
"돌아가기": "戻る",
|
||||
"동기화 시간": "同期時間",
|
||||
"동기화 시간 (계속 / 시간 지정)": "同期時間 (常時 / 時間指定)",
|
||||
"동시 MKV 변환 수": "同時 MKV 変換数",
|
||||
"동시 다운로드 수": "同時ダウンロード数",
|
||||
"동시에 처리할 개수 (0 = 무한)": "同時に処理する数(0 = 無制限)",
|
||||
|
|
@ -386,6 +406,7 @@
|
|||
"동영상을 고르고, 동영상 옆 jj_mkv 폴더에 MKV 를 만들려면\n\"모든 파일에 대한 접근\" 권한이 필요합니다.": "動画を選び、動画の隣の jj_mkv フォルダーに MKV を作成するには\n「すべてのファイルへのアクセス」権限が必要です。",
|
||||
"동영상을 끌어다 놓으면 목록에 추가됩니다": "動画をドラッグ&ドロップするとリストに追加されます",
|
||||
"동영상이 있는 폴더 아래 jj_mkv (기본)": "動画があるフォルダーの下の jj_mkv(既定)",
|
||||
"동작 중 · {0} 까지": "動作中 · {0} まで",
|
||||
"되살리기": "元に戻す",
|
||||
"두 창": "2 ペイン",
|
||||
"두 창 배치": "2 ペインの配置",
|
||||
|
|
@ -398,6 +419,7 @@
|
|||
"러시아어": "ロシア語",
|
||||
"로그인하면 하루 받기 횟수가 늘어납니다": "ログインすると 1日のダウンロード回数が増えます",
|
||||
"루마니아어": "ルーマニア語",
|
||||
"마지막": "最後",
|
||||
"마지막 인코딩 설정을 기억합니다": "最後のエンコード設定を記憶します",
|
||||
"만들 자막 언어 (파일명_언어코드.srt)": "作成する字幕の言語(ファイル名_言語コード.srt)",
|
||||
"말레이어": "マレー語",
|
||||
|
|
@ -405,9 +427,15 @@
|
|||
"맞추기 (비율이 다를 때)": "合わせ方(比率が違うとき)",
|
||||
"맞추기 (흐린 배경)": "フィット(ぼかし背景)",
|
||||
"맞추는 중…": "同期中…",
|
||||
"맞출 것 {0}개{1}": "同期対象 {0} 件{1}",
|
||||
"맞출 것 없음 (원본과 대상이 같습니다)": "同期対象なし (元と対象が同じです)",
|
||||
"맞출 것을 세는 중…": "同期対象を数えています…",
|
||||
"맞춤": "同期済み",
|
||||
"매번 고르기": "毎回選ぶ",
|
||||
"매일 종일": "毎日 終日",
|
||||
"멈춤": "停止",
|
||||
"멈춤 · {0}": "停止 · {0}",
|
||||
"모니터링": "モニタリング",
|
||||
"모델 내려받는 중 {0}%": "モデルをダウンロード中 {0}%",
|
||||
"모델 내려받는 중: {0} ({1})": "モデルをダウンロード中: {0}({1})",
|
||||
"모델 파일이 잘렸습니다 (위치 {0})": "モデルファイルが途中で切れています(位置 {0})",
|
||||
|
|
@ -418,9 +446,11 @@
|
|||
"모두 지우기": "すべて削除",
|
||||
"모두 초기화": "すべてリセット",
|
||||
"모든 작업 취소": "すべての作業をキャンセル",
|
||||
"목": "木",
|
||||
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します",
|
||||
"목록 보기": "リストを見る",
|
||||
"목록에서 제거": "リストから削除",
|
||||
"목록에서 지우기 (파일은 그대로)": "リストから削除 (ファイルはそのまま)",
|
||||
"목록의 동영상 {0}개 모두": "リストの動画 {0}個すべて",
|
||||
"몽골어": "モンゴル語",
|
||||
"무한": "無制限",
|
||||
|
|
@ -428,6 +458,7 @@
|
|||
"미리보기: 목록에서 동영상을 고르세요": "プレビュー: リストで動画を選んでください",
|
||||
"미얀마어": "ミャンマー語",
|
||||
"미지정": "未指定",
|
||||
"바꾸기": "変更",
|
||||
"바꾸면 바로 저장됩니다": "変更するとすぐ保存されます",
|
||||
"바꾼 것이 없습니다 (원본 그대로)": "変更なし(元のまま)",
|
||||
"바뀐 날": "更新日時",
|
||||
|
|
@ -448,6 +479,7 @@
|
|||
"받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "ダウンロードしたファイルが壊れているか別のファイルです(SHA256 不一致)。インストールを中止しました。",
|
||||
"받음 {0}": "受信 {0}",
|
||||
"밝기": "明るさ",
|
||||
"밤 0~6시": "夜 0~6 時",
|
||||
"방법: {0}": "方法: {0}",
|
||||
"백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "バックグラウンド: ウィンドウだけ隠してダウンロード・変換は続けます。{0} またはトレイアイコンで再表示します。\n完全に終了するには トレイアイコンを右クリック > 終了(どの設定でも常に終了)",
|
||||
"백그라운드로": "バックグラウンドへ",
|
||||
|
|
@ -470,8 +502,14 @@
|
|||
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "表示するボタンを選び、ドラッグで順序を変えます。",
|
||||
"복사": "コピー",
|
||||
"복사 (붙여넣기로)": "コピー (貼り付け用)",
|
||||
"복사 · rsync": "コピー · rsync",
|
||||
"복사 · rsync 로 이동": "コピー · rsync へ移動",
|
||||
"복사 · rsync 목록으로 옮겼습니다.": "コピー · rsync の一覧へ移しました。",
|
||||
"복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).": "コピー · 移動は 2 ペインで使います (ペインの配置 > 2 ペイン)。",
|
||||
"복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "コピー · 移動する項目を選んでください (タップ、または右の丸でマーク)。",
|
||||
"복사 끝 ({0}): {1}개 → {2}": "コピー完了 ({0}): {1} 件 → {2}",
|
||||
"복사 모니터링": "コピーのモニタリング",
|
||||
"복사 실패 ({0}): {1}": "コピー失敗 ({0}): {1}",
|
||||
"복사(Ctrl+C)한 주소 자동 다운로드": "コピー(Ctrl+C)した URL を自動ダウンロード",
|
||||
"복사하는 중": "コピー中",
|
||||
"복사할까요?": "コピーしますか?",
|
||||
|
|
@ -523,6 +561,7 @@
|
|||
"선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)": "選択したファイルを再生: 今見ている動画(複数はリストでチェック)",
|
||||
"선택한 파일 재생: 체크한 동영상 {0}개를 목록 순서대로 이어서": "選択したファイルを再生: チェックした動画 {0}個をリスト順に続けて",
|
||||
"선택한 파일 재생{0}": "選択したファイルを再生{0}",
|
||||
"설정 반영 후 실행": "反映して実行",
|
||||
"설정 화면에서 \"이 출처의 앱 설치 허용\" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. 받은 파일을 그대로 씁니다 (다시 받지 않음).": "設定画面で「この提供元のアプリを許可」をオンにしてから戻り、[インストールを続ける] を押してください。ダウンロードしたファイルをそのまま使います (再ダウンロードしません)。",
|
||||
"설치 계속": "インストールを続ける",
|
||||
"설치 실패": "インストール失敗",
|
||||
|
|
@ -537,8 +576,10 @@
|
|||
"세로 4:5": "縦 4:5",
|
||||
"세로 9:16": "縦 9:16",
|
||||
"세로 고정": "縦向き固定",
|
||||
"속도 제한": "速度制限",
|
||||
"속도 제한 (KB/s)": "速度制限 (KB/s)",
|
||||
"속도 제한 {0} KB/s": "速度制限 {0} KB/s",
|
||||
"수": "水",
|
||||
"수정": "編集",
|
||||
"수정 · 이동": "編集・移動",
|
||||
"수정한 날짜": "更新日時",
|
||||
|
|
@ -551,6 +592,7 @@
|
|||
"스웨덴어": "スウェーデン語",
|
||||
"스타일": "スタイル",
|
||||
"스페인어": "スペイン語",
|
||||
"시간 지정 (cron)": "時間指定 (cron)",
|
||||
"시간 형식 오류 {0}곳": "時間形式エラー {0}か所",
|
||||
"시간순 정렬": "時間順に並べ替え",
|
||||
"시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "シリーズ: file_001・file_002、S01E01・S01E02 のように番号だけ違うファイル",
|
||||
|
|
@ -567,7 +609,9 @@
|
|||
"실시간 동기화 추가": "リアルタイム同期を追加",
|
||||
"실시간 동기화: 대상 폴더": "リアルタイム同期: 対象フォルダー",
|
||||
"실시간 동기화: 원본 폴더": "リアルタイム同期: 元フォルダー",
|
||||
"실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.": "リアルタイム同期はありません。[追加] するか、コピー · rsync タブで [lsync へ移動] してください。",
|
||||
"실패": "失敗",
|
||||
"실패 · {0}": "失敗 · {0}",
|
||||
"실패: {0}": "失敗: {0}",
|
||||
"실패: {0}\n{1}": "失敗: {0}\n{1}",
|
||||
"실행": "実行",
|
||||
|
|
@ -583,6 +627,7 @@
|
|||
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "ユーザー名・パスワードは環境設定で入力できます(任意、1日のダウンロード回数が増えます)。",
|
||||
"아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "アイコン · 一覧の見た目: X-plore (サムネイル · 2 行) · Windows エクスプローラー (カラーアイコン · 列) · Total Commander (詰めた一覧 · [フォルダー])",
|
||||
"아직 만든 MKV · 자막이 없습니다: {0}": "まだ作成した MKV・字幕がありません: {0}",
|
||||
"아직 실행 안 함": "まだ実行していません",
|
||||
"아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "まだありません。動画のダウンロードが終わると自動で入ります(設定で変更)。",
|
||||
"아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "まだありません (rsync でコピーするとき · [ダウンロード] で入手)",
|
||||
"아직 지원하지 않는 플랫폼입니다: {0}": "まだ対応していないプラットフォームです: {0}",
|
||||
|
|
@ -595,6 +640,7 @@
|
|||
"앱 안 브라우저 (권장)": "アプリ内ブラウザー(推奨)",
|
||||
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "アプリ内ブラウザーのログインを使う(ブラウザー画面で YouTube にログインしてください)",
|
||||
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)",
|
||||
"앱이 켜져 있는 동안 늘 동기화합니다.": "アプリの起動中は常に同期します。",
|
||||
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "どこからでも押すと JJ_MKVMaker ウィンドウが表示されます",
|
||||
"언어": "言語",
|
||||
"언어 추가": "言語を追加",
|
||||
|
|
@ -620,6 +666,7 @@
|
|||
"영어": "英語",
|
||||
"예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 이동은 --remove-source-files 를 자동으로 붙임": "例: -avPog (アーカイブ · 詳細 · 進捗 · 所有者 · グループ)。移動では --remove-source-files を自動で付加",
|
||||
"예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임": "例: /E /COPY:DAT /DCOPY:T /R:2 /W:2。移動では /MOVE を自動で付加",
|
||||
"예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위": "例: 0 9-17 * * 1-5 (平日 9 時~17 時 59 分) · 0 0-5 * * * (毎日 0~5 時)。分は無視し 1 時間単位",
|
||||
"예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl・Shift・Alt・Win + 英字・数字・F1〜F12)",
|
||||
"예전 구조의 프로그램 파일 정리 ({0}개): {1}": "旧構成のプログラムファイルを整理({0}個): {1}",
|
||||
"오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "今日ダウンロードできる字幕数をすべて使いました。({0})",
|
||||
|
|
@ -630,6 +677,8 @@
|
|||
"옮겼습니다.": "移動しました。",
|
||||
"옮기는 중": "移動中",
|
||||
"옮길까요?": "移動しますか?",
|
||||
"옵션": "オプション",
|
||||
"옵션을 저장했습니다": "オプションを保存しました",
|
||||
"완료": "完了",
|
||||
"완료 정리": "完了を整理",
|
||||
"완료: 성공 {0} / 전체 {1}": "完了: 成功 {0} / 全体 {1}",
|
||||
|
|
@ -654,10 +703,12 @@
|
|||
"원본과 대상이 서로 안에 있습니다": "元と対象が互いの中にあります",
|
||||
"원본에 없는 것을 대상에서 지우기": "元にないものを対象から削除",
|
||||
"원본의 {0}배": "元の {0}倍",
|
||||
"원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "元が変わると対象に合わせます。予定を決めるとその時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。",
|
||||
"원어 (영상 속 언어)": "原語(動画内の言語)",
|
||||
"원어({0})와 같은 언어로는 번역하지 않습니다.": "原語({0})と同じ言語には翻訳しません。",
|
||||
"원어: {0} ({1})": "原語: {0}({1})",
|
||||
"원어: 자동 감지 (글자로 추정)": "原語: 自動検出(文字から推定)",
|
||||
"월": "月",
|
||||
"웹 브라우저": "ウェブブラウザー",
|
||||
"웹 브라우저 (지금 여기)": "ウェブブラウザー(現在地)",
|
||||
"웹 브라우저 (홈 주소)": "ウェブブラウザー(ホームページ)",
|
||||
|
|
@ -687,6 +738,7 @@
|
|||
"이 버전 건너뛰기": "このバージョンをスキップ",
|
||||
"이 버전에는 Android 용 APK 가 없습니다.": "このバージョンには Android 用 APK がありません。",
|
||||
"이 버전에는 Windows 용 zip 이 없습니다.": "このバージョンには Windows 用 zip がありません。",
|
||||
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "このコピーを次に行うときこのオプションを使います。今、新しいオプションで実行しますか?",
|
||||
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "このブラウザーのブックマークファイルが見つかりませんでした。",
|
||||
"이 언어 지우기": "この言語を削除",
|
||||
"이 이동 버튼 지우기": "この移動ボタンを削除",
|
||||
|
|
@ -738,6 +790,7 @@
|
|||
"인터넷 자막 (OpenSubtitles.com)": "インターネット字幕(OpenSubtitles.com)",
|
||||
"인터넷 자막 찾기": "インターネットで字幕を探す",
|
||||
"인터넷 자막 찾기{0}": "インターネットで字幕を探す{0}",
|
||||
"일": "日",
|
||||
"일반": "一般",
|
||||
"일본어": "日本語",
|
||||
"일시정지": "一時停止",
|
||||
|
|
@ -846,6 +899,7 @@
|
|||
"종료할까요?": "終了しますか?",
|
||||
"종류 (확장자)": "種類 (拡張子)",
|
||||
"좌우": "左右",
|
||||
"주말 종일": "週末 終日",
|
||||
"주소": "アドレス",
|
||||
"주소 입력 또는 검색 (Ctrl+L)": "アドレスを入力または検索(Ctrl+L)",
|
||||
"준비 중": "準備中",
|
||||
|
|
@ -870,6 +924,7 @@
|
|||
"즐겨찾기 추가…": "ブックマークを追加…",
|
||||
"즐겨찾기 추가됨": "ブックマークを追加しました",
|
||||
"즐겨찾기 표시줄": "ブックマークバー",
|
||||
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "実行中のコピーは前のオプションで動いています。止めて新しいオプションで再実行しますか?",
|
||||
"지금 맞추기": "今すぐ同期",
|
||||
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "今見ている動画の{0}(複数はリストでチェック)",
|
||||
"지금 설치": "今すぐインストール",
|
||||
|
|
@ -925,6 +980,8 @@
|
|||
"취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": "キャンセル・失敗したダウンロードが残した .part・.ytdl・.aria2・結合前の断片(jj_yt-dlp・jj_aria2)",
|
||||
"취소됨": "キャンセル済み",
|
||||
"취소했습니다.": "キャンセルしました。",
|
||||
"칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "塗った時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。マスをタップまたはドラッグして塗ります (曜日名 · 時の数字をタップするとその行 · 列全体)。",
|
||||
"칠한 시간이 없어 동기화하지 않습니다.": "塗った時間がないため同期しません。",
|
||||
"켜져 있는 창에서 (기본)": "開いているウィンドウで(既定)",
|
||||
"코덱": "コーデック",
|
||||
"크기": "サイズ",
|
||||
|
|
@ -936,6 +993,7 @@
|
|||
"탐색기에서 연 동영상을 재생할 창": "エクスプローラーで開いた動画を再生するウィンドウ",
|
||||
"태국어": "タイ語",
|
||||
"터키어": "トルコ語",
|
||||
"토": "土",
|
||||
"토렌트 · 마그넷 다운로드": "トレント・マグネットのダウンロード",
|
||||
"트랙 #{0} ({1}){2}": "トラック #{0}({1}){2}",
|
||||
"트랙 {0}": "トラック {0}",
|
||||
|
|
@ -951,6 +1009,7 @@
|
|||
"파일 이름": "ファイル名",
|
||||
"파일 탐색기": "ファイルエクスプローラー",
|
||||
"파일 탐색기 (지금 여기)": "ファイルエクスプローラー (現在地)",
|
||||
"파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, 옵션을 고쳐 다시 실행 · 재개하거나 lsync 로 옮깁니다 (복사 · rsync 와 lsync 를 따로).": "ファイルエクスプローラー中央に [モニタリング] ボタン: コピー · 移動を記憶して進捗 · 対象ディスク容量 · オプションを表示し、オプションを変えて再実行 · 再開、または lsync へ移動します (コピー · rsync と lsync は別々)。",
|
||||
"파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "ファイルエクスプローラー上部の ⋮ メニューからも変えられます (左右 · 上下 · ボタン列の位置 · ボタン構成)",
|
||||
"파일 폴더": "ファイル フォルダー",
|
||||
"파일 확인 중…": "ファイルを確認中…",
|
||||
|
|
@ -965,6 +1024,7 @@
|
|||
"편집 목록에 있음": "編集リストにあり",
|
||||
"편집 목록에 추가": "編集リストに追加",
|
||||
"편집 목록에 추가 (MKV 만들기 대상)": "編集リストに追加(MKV 作成の対象)",
|
||||
"평일 9~18시": "平日 9~18 時",
|
||||
"포르투갈어": "ポルトガル語",
|
||||
"포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "含まれるコンポーネントとライセンス(THIRD_PARTY_NOTICES.txt)",
|
||||
"폴더": "フォルダー",
|
||||
|
|
@ -982,6 +1042,7 @@
|
|||
"폴더가 들어 있을 때": "フォルダーを含むとき",
|
||||
"폴더가 아닙니다: {0}": "フォルダーではありません: {0}",
|
||||
"폴더를 만들 수 없습니다: {0}": "フォルダーを作成できません: {0}",
|
||||
"폴더만 lsync 로 옮길 수 있습니다.": "lsync へ移せるのはフォルダーだけです。",
|
||||
"폴란드어": "ポーランド語",
|
||||
"표시 (여러 개 고르기)": "マーク (複数選択)",
|
||||
"표시 이름": "表示名",
|
||||
|
|
@ -1018,6 +1079,7 @@
|
|||
"허용했으면 다시 확인": "許可したらもう一度確認",
|
||||
"헝가리어": "ハンガリー語",
|
||||
"현재 방식": "現在の方式",
|
||||
"현재 유지": "現状維持",
|
||||
"현재 페이지": "現在のページ",
|
||||
"현재 페이지 추가": "現在のページを追加",
|
||||
"형식 오류": "形式エラー",
|
||||
|
|
@ -1025,6 +1087,7 @@
|
|||
"홈 주소": "ホームページ",
|
||||
"홈 화면": "ホーム画面",
|
||||
"홈 화면 ({0}) · 환경 설정에서 바꿈": "ホーム画面({0})・環境設定で変更",
|
||||
"화": "火",
|
||||
"화면": "画面",
|
||||
"화면 · 색 보정": "画面・カラー補正",
|
||||
"화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "画面の文字をすべてこの端末で翻訳します。数分かかることがあります。",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
" / 전체 {0}": " / 共 {0}",
|
||||
" · ⚠ {0}개는 원본보다 커서 화질 향상 없이 용량만 늘어납니다": " · ⚠ {0} 个比原始尺寸大,只会增加体积而不会提升画质",
|
||||
" · 다시 시작해야 Chrome 으로 바뀝니다": " · 重新启动后切换到 Chrome",
|
||||
" · 다음 시작 시간에 맞춥니다": " · 将在下次开始时间同步",
|
||||
" · 대기 {0}": " · 等待 {0}",
|
||||
" · 대기 {0}개": " · 等待 {0} 个",
|
||||
" · 전에 만든 자막 {0}개 (jj_mkv)": " · 之前制作的字幕 {0} 个(jj_mkv)",
|
||||
|
|
@ -32,6 +33,7 @@
|
|||
"(AI 번역을 쓸 수 없는 기기라 언어를 더할 수 없습니다)": "(此设备无法使用 AI 翻译,因此无法添加语言)",
|
||||
"(비어 있음)": "(空)",
|
||||
"(설명 없음)": "(无说明)",
|
||||
"(현재 방식은 옵션 없음)": "(当前方式没有选项)",
|
||||
"+ 여기에 새 줄": "+ 在此新增一行",
|
||||
".{0} 재생 프로그램 (exe)": ".{0} 播放程序(exe)",
|
||||
"0 = 제한 없음. 모든 방법에 적용: rsync --bwlimit, 현재 방식은 앱이 조절, robocopy 는 /IPG 로 비슷하게. 옵션 칸에 직접 넣어도 됩니다 (그쪽이 우선)": "0 = 不限制。适用于所有方式: rsync 用 --bwlimit,当前方式由应用调节,robocopy 用 /IPG 近似。也可直接写在选项栏中 (优先)",
|
||||
|
|
@ -144,8 +146,11 @@
|
|||
"aria2 오류: {0}": "aria2 错误: {0}",
|
||||
"cookies.txt (Netscape 형식)": "cookies.txt(Netscape 格式)",
|
||||
"cookies.txt 파일…": "cookies.txt 文件…",
|
||||
"cron (분 시 일 월 요일) · 한 줄에 하나": "cron (分 时 日 月 星期) · 每行一个",
|
||||
"ffmpeg 를 실행할 수 없습니다: {0}": "无法运行 ffmpeg: {0}",
|
||||
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg 错误(代码 {0})\n{1}",
|
||||
"lsync (실시간 동기화)": "lsync (实时同步)",
|
||||
"lsync 로 이동": "移到 lsync",
|
||||
"quit 시작": "开始退出",
|
||||
"robocopy 옵션": "robocopy 选项",
|
||||
"rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만": "rsync --delete · robocopy /PURGE。关闭时只在目标中添加 · 更新",
|
||||
|
|
@ -180,6 +185,7 @@
|
|||
"{0} 안의 파일": "{0} 中的文件",
|
||||
"{0} 에 붙여넣기 ({1}개)": "粘贴到 {0} ({1} 个)",
|
||||
"{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件",
|
||||
"{0} 외 {1}개": "{0} 等 {1} 个",
|
||||
"{0} 인코더가 FFmpeg 에 없습니다.": "FFmpeg 中没有 {0} 编码器。",
|
||||
"{0} 추가": "添加 {0}",
|
||||
"{0} 파일": "{0} 文件",
|
||||
|
|
@ -193,6 +199,7 @@
|
|||
"{0}개 · 주소 {1}개": "{0} 个 · 地址 {1} 个",
|
||||
"{0}개 맞춤": "已同步 {0} 个",
|
||||
"{0}개 선택": "已选 {0} 个",
|
||||
"{0}개 폴더를 lsync (실시간 동기화) 로 옮겼습니다.": "已将 {0} 个文件夹移到 lsync (实时同步)。",
|
||||
"{0}개 표시함": "已标记 {0} 个",
|
||||
"{0}개 항목을 {1}": "{0} 个项目{1}",
|
||||
"{0}개 항목을 다음 폴더로 {1}\n{2}": "将 {0} 个项目到以下文件夹 — {1}\n{2}",
|
||||
|
|
@ -238,6 +245,8 @@
|
|||
"결과 폴더 열기 (동영상을 고르세요)": "打开输出文件夹(请选择视频)",
|
||||
"결과 폴더 열기: {0}": "打开输出文件夹: {0}",
|
||||
"결과물 이동 실패: {0} ({1})": "成品移动失败: {0}({1})",
|
||||
"계속": "持续",
|
||||
"계속 (앱이 켜져 있는 동안)": "持续 (应用运行期间)",
|
||||
"계속 편집": "继续编辑",
|
||||
"고르기": "选择",
|
||||
"고르지 않음": "未选择",
|
||||
|
|
@ -249,7 +258,9 @@
|
|||
"권한 허용 화면 열기": "打开权限授予界面",
|
||||
"그 파일 하나만": "仅该文件",
|
||||
"그리스어": "希腊语",
|
||||
"글을 격자에 반영": "将文本应用到网格",
|
||||
"글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字 · 按钮大小。可随时用顶部栏的 − · + 调整,点击中间的数字恢复为此大小(当前 {0}%)",
|
||||
"금": "五",
|
||||
"기계 번역": "机器翻译",
|
||||
"기기": "设备",
|
||||
"기능 버튼 줄": "功能按钮栏",
|
||||
|
|
@ -263,6 +274,7 @@
|
|||
"기본: MP4. 음성만 받으면 MP3 · M4A 로 저장": "默认: MP4。仅音频时保存为 MP3 · M4A",
|
||||
"기본값": "默认值",
|
||||
"기본값으로 ({0})": "恢复默认 ({0})",
|
||||
"기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.": "没有记住的复制。在文件浏览器中复制 · 移动后会自动登记到这里。",
|
||||
"기타 즐겨찾기": "其他书签",
|
||||
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "长按 · 右键总是打开功能菜单 (复制 · 移动 · 删除 · 重命名 …)。Ctrl + 点击: 多选",
|
||||
"길이 {0}": "时长 {0}",
|
||||
|
|
@ -274,6 +286,7 @@
|
|||
"끝": "结束",
|
||||
"끝 (오른쪽 · 아래)": "边缘 (右 · 下)",
|
||||
"끝 = 현재": "结束 = 当前",
|
||||
"끝 · {0} · 파일 {1}개": "完成 · {0} · {1} 个文件",
|
||||
"끝 시간이 시작보다 빠른 줄: {0}{1}": "结束时间早于开始时间的行: {0}{1}",
|
||||
"끝: {0}": "结束: {0}",
|
||||
"끝나지 못했습니다: {0}": "未能完成: {0}",
|
||||
|
|
@ -312,6 +325,7 @@
|
|||
"노르웨이어": "挪威语",
|
||||
"누르고 있으면 조정 전": "按住查看调整前",
|
||||
"누르기": "点按",
|
||||
"누르면 일정 바꾸기": "点按以更改计划",
|
||||
"다 받는 대로 MKV 만들기의 동영상 목록에 넣고, 다운로드 목록에서는 뺍니다 (다운로드 화면의 체크 상자와 같음)": "下载完成后立即放入制作 MKV 的视频列表,并从下载列表移除(与下载界面的复选框相同)",
|
||||
"다 받으면 MKV 만들기의 동영상 목록에 자동으로 넣고, 이 다운로드 목록에서는 뺍니다 (받은 파일은 그대로)": "下载完成后自动加入制作 MKV 的视频列表,并从此下载列表移除(已下载的文件保留)",
|
||||
"다른 브라우저의 즐겨찾기 가져오기": "导入其他浏览器的书签",
|
||||
|
|
@ -327,6 +341,7 @@
|
|||
"다른 창으로 이동": "移动到另一窗格",
|
||||
"다른 폴더로 이동": "移动到其他文件夹",
|
||||
"다시 만들기 대기로": "改回等待重新制作",
|
||||
"다시 세기": "重新统计",
|
||||
"다시 시작": "重新启动",
|
||||
"다운로드": "下载",
|
||||
"다운로드 (jj_yt-dlp · jj_aria2)": "下载(jj_yt-dlp · jj_aria2)",
|
||||
|
|
@ -358,9 +373,12 @@
|
|||
"닫기": "关闭",
|
||||
"대기": "等待",
|
||||
"대기 {0}개:": "等待 {0} 个:",
|
||||
"대기 · 다음 시작 {0}": "等待 · 下次开始 {0}",
|
||||
"대기 중인 작업 {0}개 취소": "取消 {0} 个等待中的任务",
|
||||
"대기열에 추가: {0} (대기 {1}개)": "已加入队列: {0}(等待 {1} 个)",
|
||||
"대비": "对比度",
|
||||
"대상 디스크: 남은 {0} / 전체 {1}": "目标磁盘: 剩余 {0} / 总共 {1}",
|
||||
"대상 디스크: 알 수 없음": "目标磁盘: 未知",
|
||||
"대상 폴더 열기": "打开所在文件夹",
|
||||
"더 보기": "更多",
|
||||
"더블클릭 · 연결 프로그램으로 JJ_MKVMaker 를 골랐을 때 (오른쪽 클릭 메뉴는 그대로)": "双击 · 默认打开方式选择 JJ_MKVMaker 时(右键菜单不变)",
|
||||
|
|
@ -368,6 +386,8 @@
|
|||
"덴마크어": "丹麦语",
|
||||
"독일어": "德语",
|
||||
"돌아가기": "返回",
|
||||
"동기화 시간": "同步时间",
|
||||
"동기화 시간 (계속 / 시간 지정)": "同步时间 (持续 / 指定时间)",
|
||||
"동시 MKV 변환 수": "同时转换 MKV 数",
|
||||
"동시 다운로드 수": "同时下载数",
|
||||
"동시에 처리할 개수 (0 = 무한)": "同时处理的数量(0 = 无限制)",
|
||||
|
|
@ -386,6 +406,7 @@
|
|||
"동영상을 고르고, 동영상 옆 jj_mkv 폴더에 MKV 를 만들려면\n\"모든 파일에 대한 접근\" 권한이 필요합니다.": "要选择视频并在视频旁的 jj_mkv 文件夹中制作 MKV,\n需要“所有文件访问权限”。",
|
||||
"동영상을 끌어다 놓으면 목록에 추가됩니다": "拖放视频即可加入列表",
|
||||
"동영상이 있는 폴더 아래 jj_mkv (기본)": "视频所在文件夹下的 jj_mkv(默认)",
|
||||
"동작 중 · {0} 까지": "运行中 · 直到 {0}",
|
||||
"되살리기": "恢复",
|
||||
"두 창": "双窗格",
|
||||
"두 창 배치": "双窗格排列",
|
||||
|
|
@ -398,6 +419,7 @@
|
|||
"러시아어": "俄语",
|
||||
"로그인하면 하루 받기 횟수가 늘어납니다": "登录后每日下载次数会增加",
|
||||
"루마니아어": "罗马尼亚语",
|
||||
"마지막": "上次",
|
||||
"마지막 인코딩 설정을 기억합니다": "记住上次的编码设置",
|
||||
"만들 자막 언어 (파일명_언어코드.srt)": "要生成的字幕语言(文件名_语言代码.srt)",
|
||||
"말레이어": "马来语",
|
||||
|
|
@ -405,9 +427,15 @@
|
|||
"맞추기 (비율이 다를 때)": "适配方式(比例不同时)",
|
||||
"맞추기 (흐린 배경)": "适应(模糊背景)",
|
||||
"맞추는 중…": "正在同步…",
|
||||
"맞출 것 {0}개{1}": "待同步 {0} 个{1}",
|
||||
"맞출 것 없음 (원본과 대상이 같습니다)": "无需同步 (源与目标一致)",
|
||||
"맞출 것을 세는 중…": "正在统计待同步项…",
|
||||
"맞춤": "已同步",
|
||||
"매번 고르기": "每次选择",
|
||||
"매일 종일": "每天 全天",
|
||||
"멈춤": "已停止",
|
||||
"멈춤 · {0}": "已停止 · {0}",
|
||||
"모니터링": "监控",
|
||||
"모델 내려받는 중 {0}%": "正在下载模型 {0}%",
|
||||
"모델 내려받는 중: {0} ({1})": "正在下载模型: {0}({1})",
|
||||
"모델 파일이 잘렸습니다 (위치 {0})": "模型文件被截断(位置 {0})",
|
||||
|
|
@ -418,9 +446,11 @@
|
|||
"모두 지우기": "全部清除",
|
||||
"모두 초기화": "全部重置",
|
||||
"모든 작업 취소": "取消所有任务",
|
||||
"목": "四",
|
||||
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域",
|
||||
"목록 보기": "查看列表",
|
||||
"목록에서 제거": "从列表中移除",
|
||||
"목록에서 지우기 (파일은 그대로)": "从列表中删除 (文件保留)",
|
||||
"목록의 동영상 {0}개 모두": "列表中的全部 {0} 个视频",
|
||||
"몽골어": "蒙古语",
|
||||
"무한": "无限制",
|
||||
|
|
@ -428,6 +458,7 @@
|
|||
"미리보기: 목록에서 동영상을 고르세요": "预览: 请在列表中选择视频",
|
||||
"미얀마어": "缅甸语",
|
||||
"미지정": "未指定",
|
||||
"바꾸기": "更改",
|
||||
"바꾸면 바로 저장됩니다": "更改后立即保存",
|
||||
"바꾼 것이 없습니다 (원본 그대로)": "未做更改(保持原样)",
|
||||
"바뀐 날": "修改时间",
|
||||
|
|
@ -448,6 +479,7 @@
|
|||
"받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "下载的文件已损坏或不是正确的文件(SHA256 不匹配)。已停止安装。",
|
||||
"받음 {0}": "已接收 {0}",
|
||||
"밝기": "亮度",
|
||||
"밤 0~6시": "夜间 0~6 点",
|
||||
"방법: {0}": "方式: {0}",
|
||||
"백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "后台: 仅隐藏窗口,下载 · 转换继续进行。用 {0} 或托盘图标重新打开。\n要完全退出: 右键托盘图标 > 退出(无论何种设置都会退出)",
|
||||
"백그라운드로": "转到后台",
|
||||
|
|
@ -470,8 +502,14 @@
|
|||
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "选择要显示的按钮,拖动以调整顺序。",
|
||||
"복사": "复制",
|
||||
"복사 (붙여넣기로)": "复制 (用于粘贴)",
|
||||
"복사 · rsync": "复制 · rsync",
|
||||
"복사 · rsync 로 이동": "移到 复制 · rsync",
|
||||
"복사 · rsync 목록으로 옮겼습니다.": "已移到 复制 · rsync 列表。",
|
||||
"복사 · 이동은 두 창에서 씁니다 (창 배치 > 두 창).": "复制 · 移动需在双窗格中使用 (窗格布局 > 双窗格)。",
|
||||
"복사 · 이동할 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要复制 · 移动的项目 (点按,或用右侧圆圈标记)。",
|
||||
"복사 끝 ({0}): {1}개 → {2}": "复制完成 ({0}): {1} 个 → {2}",
|
||||
"복사 모니터링": "复制监控",
|
||||
"복사 실패 ({0}): {1}": "复制失败 ({0}): {1}",
|
||||
"복사(Ctrl+C)한 주소 자동 다운로드": "自动下载复制(Ctrl+C)的地址",
|
||||
"복사하는 중": "正在复制",
|
||||
"복사할까요?": "复制吗?",
|
||||
|
|
@ -523,6 +561,7 @@
|
|||
"선택한 파일 재생: 지금 보고 있는 동영상 (여러 개는 목록에서 체크)": "播放所选文件: 当前查看的视频(多个请在列表中勾选)",
|
||||
"선택한 파일 재생: 체크한 동영상 {0}개를 목록 순서대로 이어서": "播放所选文件: 按列表顺序连续播放勾选的 {0} 个视频",
|
||||
"선택한 파일 재생{0}": "播放所选文件{0}",
|
||||
"설정 반영 후 실행": "应用后运行",
|
||||
"설정 화면에서 \"이 출처의 앱 설치 허용\" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. 받은 파일을 그대로 씁니다 (다시 받지 않음).": "请在设置页面开启\"允许来自此来源的应用\",然后返回并点按 [继续安装]。将直接使用已下载的文件 (不会重新下载)。",
|
||||
"설치 계속": "继续安装",
|
||||
"설치 실패": "安装失败",
|
||||
|
|
@ -537,8 +576,10 @@
|
|||
"세로 4:5": "竖屏 4:5",
|
||||
"세로 9:16": "竖屏 9:16",
|
||||
"세로 고정": "锁定竖屏",
|
||||
"속도 제한": "速度限制",
|
||||
"속도 제한 (KB/s)": "速度限制 (KB/s)",
|
||||
"속도 제한 {0} KB/s": "速度限制 {0} KB/s",
|
||||
"수": "三",
|
||||
"수정": "编辑",
|
||||
"수정 · 이동": "编辑 · 移动",
|
||||
"수정한 날짜": "修改日期",
|
||||
|
|
@ -551,6 +592,7 @@
|
|||
"스웨덴어": "瑞典语",
|
||||
"스타일": "样式",
|
||||
"스페인어": "西班牙语",
|
||||
"시간 지정 (cron)": "指定时间 (cron)",
|
||||
"시간 형식 오류 {0}곳": "{0} 处时间格式错误",
|
||||
"시간순 정렬": "按时间排序",
|
||||
"시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "系列: 像 file_001 · file_002、S01E01 · S01E02 这样仅编号不同的文件",
|
||||
|
|
@ -567,7 +609,9 @@
|
|||
"실시간 동기화 추가": "添加实时同步",
|
||||
"실시간 동기화: 대상 폴더": "实时同步: 目标文件夹",
|
||||
"실시간 동기화: 원본 폴더": "实时同步: 源文件夹",
|
||||
"실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.": "没有实时同步。请 [添加],或在 复制 · rsync 标签中 [移到 lsync]。",
|
||||
"실패": "失败",
|
||||
"실패 · {0}": "失败 · {0}",
|
||||
"실패: {0}": "失败: {0}",
|
||||
"실패: {0}\n{1}": "失败: {0}\n{1}",
|
||||
"실행": "运行",
|
||||
|
|
@ -583,6 +627,7 @@
|
|||
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "可在设置中填写用户名 · 密码(可选,可增加每日下载次数)。",
|
||||
"아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "图标 · 列表外观: X-plore (缩略图 · 两行) · Windows 资源管理器 (彩色图标 · 列) · Total Commander (紧凑列表 · [文件夹])",
|
||||
"아직 만든 MKV · 자막이 없습니다: {0}": "尚未制作 MKV · 字幕: {0}",
|
||||
"아직 실행 안 함": "尚未运行",
|
||||
"아직 없습니다. 영상을 다 받으면 자동으로 들어옵니다 (설정에서 변경).": "暂无。视频下载完成后会自动加入(可在设置中更改)。",
|
||||
"아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "尚未安装 (用 rsync 复制时 · 或按 [下载] 获取)",
|
||||
"아직 지원하지 않는 플랫폼입니다: {0}": "尚不支持此平台: {0}",
|
||||
|
|
@ -595,6 +640,7 @@
|
|||
"앱 안 브라우저 (권장)": "应用内浏览器(推荐)",
|
||||
"앱 안 브라우저의 로그인 사용 (브라우저 화면에서 YouTube 에 로그인하세요)": "使用应用内浏览器的登录(请在浏览器界面登录 YouTube)",
|
||||
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)",
|
||||
"앱이 켜져 있는 동안 늘 동기화합니다.": "应用运行期间始终同步。",
|
||||
"어디서나 누르면 JJ_MKVMaker 창이 나타납니다": "在任何地方按下即可显示 JJ_MKVMaker 窗口",
|
||||
"언어": "语言",
|
||||
"언어 추가": "添加语言",
|
||||
|
|
@ -620,6 +666,7 @@
|
|||
"영어": "英语",
|
||||
"예: -avPog (보관 · 자세히 · 진행 · 소유자 · 그룹). 이동은 --remove-source-files 를 자동으로 붙임": "例: -avPog (归档 · 详细 · 进度 · 所有者 · 组)。移动时自动加上 --remove-source-files",
|
||||
"예: /E /COPY:DAT /DCOPY:T /R:2 /W:2. 이동은 /MOVE 를 자동으로 붙임": "例: /E /COPY:DAT /DCOPY:T /R:2 /W:2。移动时自动加上 /MOVE",
|
||||
"예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위": "例: 0 9-17 * * 1-5 (工作日 9:00~17:59) · 0 0-5 * * * (每天 0~5 点)。忽略分钟,以 1 小时为单位",
|
||||
"예: Ctrl+Shift+X (Ctrl·Shift·Alt·Win + 영문자·숫자·F1~F12)": "例: Ctrl+Shift+X(Ctrl·Shift·Alt·Win + 字母·数字·F1~F12)",
|
||||
"예전 구조의 프로그램 파일 정리 ({0}개): {1}": "已清理旧结构的程序文件({0} 个): {1}",
|
||||
"오늘 받을 수 있는 자막 수를 모두 사용했습니다. ({0})": "今天可下载的字幕数已用完。({0})",
|
||||
|
|
@ -630,6 +677,8 @@
|
|||
"옮겼습니다.": "已移动。",
|
||||
"옮기는 중": "正在移动",
|
||||
"옮길까요?": "移动吗?",
|
||||
"옵션": "选项",
|
||||
"옵션을 저장했습니다": "已保存选项",
|
||||
"완료": "完成",
|
||||
"완료 정리": "清理已完成",
|
||||
"완료: 성공 {0} / 전체 {1}": "完成: 成功 {0} / 共 {1}",
|
||||
|
|
@ -654,10 +703,12 @@
|
|||
"원본과 대상이 서로 안에 있습니다": "源与目标互相包含",
|
||||
"원본에 없는 것을 대상에서 지우기": "从目标中删除源中没有的内容",
|
||||
"원본의 {0}배": "原始的 {0} 倍",
|
||||
"원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "源变化时同步到目标。设定计划后只在该时间同步,其他时间的变化会累积到下次开始时间再同步。",
|
||||
"원어 (영상 속 언어)": "原语言(视频中的语言)",
|
||||
"원어({0})와 같은 언어로는 번역하지 않습니다.": "不会翻译成与原语言({0})相同的语言。",
|
||||
"원어: {0} ({1})": "原语言: {0}({1})",
|
||||
"원어: 자동 감지 (글자로 추정)": "原语言: 自动检测(根据文字推测)",
|
||||
"월": "一",
|
||||
"웹 브라우저": "网页浏览器",
|
||||
"웹 브라우저 (지금 여기)": "网页浏览器(当前位置)",
|
||||
"웹 브라우저 (홈 주소)": "网页浏览器(主页)",
|
||||
|
|
@ -687,6 +738,7 @@
|
|||
"이 버전 건너뛰기": "跳过此版本",
|
||||
"이 버전에는 Android 용 APK 가 없습니다.": "此版本没有 Android 版 APK。",
|
||||
"이 버전에는 Windows 용 zip 이 없습니다.": "此版本没有 Windows 版 zip。",
|
||||
"이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次进行此复制时将使用这些选项。现在用新选项运行吗?",
|
||||
"이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "未找到此浏览器的书签文件。",
|
||||
"이 언어 지우기": "删除此语言",
|
||||
"이 이동 버튼 지우기": "删除此移动按钮",
|
||||
|
|
@ -738,6 +790,7 @@
|
|||
"인터넷 자막 (OpenSubtitles.com)": "网络字幕(OpenSubtitles.com)",
|
||||
"인터넷 자막 찾기": "在线查找字幕",
|
||||
"인터넷 자막 찾기{0}": "在线查找字幕{0}",
|
||||
"일": "日",
|
||||
"일반": "常规",
|
||||
"일본어": "日语",
|
||||
"일시정지": "暂停",
|
||||
|
|
@ -846,6 +899,7 @@
|
|||
"종료할까요?": "要退出吗?",
|
||||
"종류 (확장자)": "类型 (扩展名)",
|
||||
"좌우": "左右",
|
||||
"주말 종일": "周末 全天",
|
||||
"주소": "地址",
|
||||
"주소 입력 또는 검색 (Ctrl+L)": "输入地址或搜索(Ctrl+L)",
|
||||
"준비 중": "准备中",
|
||||
|
|
@ -870,6 +924,7 @@
|
|||
"즐겨찾기 추가…": "添加书签…",
|
||||
"즐겨찾기 추가됨": "已添加书签",
|
||||
"즐겨찾기 표시줄": "书签栏",
|
||||
"지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "正在运行的复制使用的是之前的选项。停止并用新选项重新运行吗?",
|
||||
"지금 맞추기": "立即同步",
|
||||
"지금 보고 있는 동영상의 {0} (여러 개는 목록에서 체크)": "当前查看视频的{0}(多个请在列表中勾选)",
|
||||
"지금 설치": "立即安装",
|
||||
|
|
@ -925,6 +980,8 @@
|
|||
"취소 · 실패한 다운로드가 남긴 .part · .ytdl · .aria2 · 합치기 전 조각 파일 (jj_yt-dlp · jj_aria2)": "取消 · 失败的下载留下的 .part · .ytdl · .aria2 · 合并前的碎片文件(jj_yt-dlp · jj_aria2)",
|
||||
"취소됨": "已取消",
|
||||
"취소했습니다.": "已取消。",
|
||||
"칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. 칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).": "只在涂色的时间同步,其他时间的变化累积到下次开始再同步。点按或拖动格子涂色 (点按星期名 · 小时数字可选整行 · 整列)。",
|
||||
"칠한 시간이 없어 동기화하지 않습니다.": "没有涂色的时间,因此不会同步。",
|
||||
"켜져 있는 창에서 (기본)": "在已打开的窗口中(默认)",
|
||||
"코덱": "编解码器",
|
||||
"크기": "大小",
|
||||
|
|
@ -936,6 +993,7 @@
|
|||
"탐색기에서 연 동영상을 재생할 창": "播放从资源管理器打开的视频的窗口",
|
||||
"태국어": "泰语",
|
||||
"터키어": "土耳其语",
|
||||
"토": "六",
|
||||
"토렌트 · 마그넷 다운로드": "种子 · 磁力链接下载",
|
||||
"트랙 #{0} ({1}){2}": "轨道 #{0}({1}){2}",
|
||||
"트랙 {0}": "轨道 {0}",
|
||||
|
|
@ -951,6 +1009,7 @@
|
|||
"파일 이름": "文件名",
|
||||
"파일 탐색기": "文件浏览器",
|
||||
"파일 탐색기 (지금 여기)": "文件浏览器 (当前位置)",
|
||||
"파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, 옵션을 고쳐 다시 실행 · 재개하거나 lsync 로 옮깁니다 (복사 · rsync 와 lsync 를 따로).": "文件浏览器中间的 [监控] 按钮: 记住复制 · 移动并显示进度 · 目标磁盘容量 · 选项,可修改选项后重新运行 · 继续,或移到 lsync (复制 · rsync 与 lsync 分开显示)。",
|
||||
"파일 탐색기 위쪽 ⋮ 메뉴에서도 바꿀 수 있습니다 (좌우 · 위아래 · 버튼 줄 위치 · 버튼 구성)": "也可以在文件浏览器顶部的 ⋮ 菜单中更改 (左右 · 上下 · 按钮栏位置 · 按钮配置)",
|
||||
"파일 폴더": "文件夹",
|
||||
"파일 확인 중…": "正在检查文件…",
|
||||
|
|
@ -965,6 +1024,7 @@
|
|||
"편집 목록에 있음": "已在编辑列表中",
|
||||
"편집 목록에 추가": "加入编辑列表",
|
||||
"편집 목록에 추가 (MKV 만들기 대상)": "加入编辑列表(制作 MKV 的对象)",
|
||||
"평일 9~18시": "工作日 9~18 点",
|
||||
"포르투갈어": "葡萄牙语",
|
||||
"포함된 구성 요소와 라이선스 (THIRD_PARTY_NOTICES.txt)": "包含的组件与许可(THIRD_PARTY_NOTICES.txt)",
|
||||
"폴더": "文件夹",
|
||||
|
|
@ -982,6 +1042,7 @@
|
|||
"폴더가 들어 있을 때": "包含文件夹时",
|
||||
"폴더가 아닙니다: {0}": "不是文件夹: {0}",
|
||||
"폴더를 만들 수 없습니다: {0}": "无法创建文件夹: {0}",
|
||||
"폴더만 lsync 로 옮길 수 있습니다.": "只有文件夹可以移到 lsync。",
|
||||
"폴란드어": "波兰语",
|
||||
"표시 (여러 개 고르기)": "标记 (多选)",
|
||||
"표시 이름": "显示名称",
|
||||
|
|
@ -1018,6 +1079,7 @@
|
|||
"허용했으면 다시 확인": "允许后重新检查",
|
||||
"헝가리어": "匈牙利语",
|
||||
"현재 방식": "当前方式",
|
||||
"현재 유지": "保持现状",
|
||||
"현재 페이지": "当前页面",
|
||||
"현재 페이지 추가": "添加当前页面",
|
||||
"형식 오류": "格式错误",
|
||||
|
|
@ -1025,6 +1087,7 @@
|
|||
"홈 주소": "主页地址",
|
||||
"홈 화면": "主屏幕",
|
||||
"홈 화면 ({0}) · 환경 설정에서 바꿈": "主屏幕({0})· 在设置中更改",
|
||||
"화": "二",
|
||||
"화면": "显示",
|
||||
"화면 · 색 보정": "画面 · 色彩",
|
||||
"화면 글자 전체를 이 기기에서 번역합니다. 몇 분 걸릴 수 있습니다.": "将在此设备上翻译所有界面文字。可能需要几分钟。",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/copy_center.dart';
|
||||
import 'package:jj_mkvmaker/app/live_sync.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/monitor_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/schedule_editor.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 모니터링 화면 (복사 진행 중 · lsync · 일정 격자) 을 실제 글꼴로 그려 PNG 로 (JJ_SHOT_DIR). 화면 배치 확인용.
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('모니터링 화면 그림', (tester) async {
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_monshot_');
|
||||
final src = p.join(tmp.path, '영상 원본'), dst = p.join(tmp.path, '백업');
|
||||
for (var i = 0; i < 6; i++) {
|
||||
File(p.join(src, i < 3 ? 'A' : 'B', 'clip$i.mkv'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsBytesSync(List.filled(200 * 1024, i));
|
||||
}
|
||||
Directory(dst).createSync();
|
||||
final c = AppController(PlatformServices.create());
|
||||
c.settings
|
||||
..copyMonitor = true
|
||||
..liveSyncPairs = [
|
||||
LiveSyncPair(src, p.join(dst, 'mirror'), schedule: const ['0 9-17 * * 1-5', '0 22-23 * * 0,6'], delete: true),
|
||||
];
|
||||
final live = LiveSync(c)..start();
|
||||
final center = CopyCenter.of(c);
|
||||
final t = await tester.runAsync(() => center.remember([p.join(src, 'A'), p.join(src, 'B')], dst));
|
||||
await center.update(t!.copyWith(bandwidthKBps: 300));
|
||||
final key = GlobalKey();
|
||||
tester.view.physicalSize = const Size(1500, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
Future<void> shot(String name) async {
|
||||
final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final image = await tester.runAsync(() => boundary.toImage());
|
||||
final bytes = await tester.runAsync(() => image!.toByteData(format: ui.ImageByteFormat.png));
|
||||
File('$out/$name.png').writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
Future<void> wait(int ms) async {
|
||||
for (var i = 0; i < ms ~/ 100; i++) {
|
||||
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
|
||||
await tester.pump();
|
||||
}
|
||||
}
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: buildTheme(),
|
||||
builder: (_, child) => RepaintBoundary(key: key, child: child),
|
||||
home: MonitorPage(c: c),
|
||||
));
|
||||
await tester.runAsync(() => center.start(center.tasks.single));
|
||||
await wait(2000);
|
||||
await shot('monitor_copy');
|
||||
await tester.tap(find.text('lsync (실시간 동기화)'));
|
||||
await wait(5000);
|
||||
await shot('monitor_lsync');
|
||||
// 일정 격자
|
||||
final ctx = tester.element(find.byType(MonitorPage));
|
||||
final f = editSchedule(ctx, const ['0 9-17 * * 1-5', '0 22-23 * * 0,6']);
|
||||
await wait(800);
|
||||
await shot('monitor_schedule');
|
||||
Navigator.of(ctx).pop();
|
||||
await f;
|
||||
center.cancel(center.tasks.single.id);
|
||||
await wait(500);
|
||||
live.dispose();
|
||||
tester.view.reset();
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../core/file_ops.dart';
|
||||
import '../core/sync_tools.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import '../platform/windows/windows_usage.dart';
|
||||
import 'app_controller.dart';
|
||||
import 'live_sync.dart';
|
||||
import 'settings.dart';
|
||||
import 'transfer_job.dart';
|
||||
|
||||
/// 복사 · 이동 모니터링 (파일 탐색기 > 모니터링 > 복사 · rsync):
|
||||
/// 복사한 원본 → 대상을 기억 (설정의 copyTasks) 해 진행 · 결과를 보여 주고, 옵션을 고쳐 다시 실행하고,
|
||||
/// 실시간 동기화 (lsync) 로 옮기거나 lsync 에서 가져온다.
|
||||
class CopyCenter extends ChangeNotifier {
|
||||
final AppController c;
|
||||
CopyCenter(this.c);
|
||||
|
||||
static CopyCenter? _instance;
|
||||
static CopyCenter of(AppController c) => _instance != null && _instance!.c == c ? _instance! : (_instance = CopyCenter(c));
|
||||
|
||||
/// 작업 id → 지금 (또는 마지막) 실행
|
||||
final jobs = <String, TransferJob>{};
|
||||
|
||||
List<CopyTask> get tasks => c.settings.copyTasks;
|
||||
|
||||
bool isRunning(String id) => jobs[id] != null && !jobs[id]!.finished;
|
||||
bool get anyRunning => jobs.values.any((j) => !j.finished);
|
||||
|
||||
/// 새 작업: 지금 설정의 방법 · 옵션으로
|
||||
CopyTask fresh(List<String> sources, String dest, {bool move = false, bool contents = false, String? method}) {
|
||||
final s = c.settings;
|
||||
final hasDir = sources.any(FileSystemEntity.isDirectorySync);
|
||||
var m = CopyMethod.of(method ?? (hasDir ? s.copyMethodFolder : s.copyMethodFile));
|
||||
if (!copyMethodAvailable(m, s)) m = CopyMethod.builtin;
|
||||
return CopyTask(
|
||||
id: '${DateTime.now().microsecondsSinceEpoch}',
|
||||
sources: sources,
|
||||
dest: dest,
|
||||
move: move,
|
||||
contents: contents,
|
||||
method: m.name,
|
||||
options: m == CopyMethod.robocopy ? s.robocopyOptions : s.rsyncOptions,
|
||||
once: s.copyRunMode == 'once',
|
||||
bandwidthKBps: s.copyBandwidthKBps,
|
||||
);
|
||||
}
|
||||
|
||||
/// 같은 복사를 기억해 두었으면 그것 (그 옵션을 쓴다), 없으면 새로 기억
|
||||
Future<CopyTask> remember(List<String> sources, String dest, {bool move = false}) async {
|
||||
final t = fresh(sources, dest, move: move);
|
||||
final old = tasks.where((x) => x.key == t.key).firstOrNull;
|
||||
if (old != null) return old;
|
||||
await c.updateSettings((x) => x.copyTasks = [t, ...x.copyTasks]);
|
||||
notifyListeners();
|
||||
return t;
|
||||
}
|
||||
|
||||
Future<void> update(CopyTask t) async {
|
||||
await c.updateSettings((x) => x.copyTasks = [for (final y in x.copyTasks) y.id == t.id ? t : y]);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> remove(String id) async {
|
||||
cancel(id);
|
||||
jobs.remove(id);
|
||||
await c.updateSettings((x) => x.copyTasks = [for (final y in x.copyTasks) if (y.id != id) y]);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void cancel(String id) => jobs[id]?.cancel();
|
||||
|
||||
/// 실행을 시작하고 그 작업을 바로 돌려준다 (rsync 가 없으면 null). 끝나면 결과를 기억한다 ([TransferJob.done]).
|
||||
Future<TransferJob?> start(CopyTask t, {String? rsyncExe}) async {
|
||||
if (isRunning(t.id)) return jobs[t.id];
|
||||
final method = CopyMethod.of(t.method);
|
||||
final exe = method == CopyMethod.rsync ? (rsyncExe ?? await rsyncExecutable(c.settings)) : null;
|
||||
if (method == CopyMethod.rsync && exe == null) return null;
|
||||
final job = TransferJob(
|
||||
sources: t.sources,
|
||||
dest: t.dest,
|
||||
move: t.move,
|
||||
method: method,
|
||||
options: t.options,
|
||||
once: t.once,
|
||||
bandwidthKBps: t.bandwidthKBps,
|
||||
rsyncExe: exe,
|
||||
contents: t.contents,
|
||||
);
|
||||
jobs[t.id] = job;
|
||||
job.addListener(notifyListeners);
|
||||
notifyListeners();
|
||||
unawaited(_runAndRecord(t, job, method));
|
||||
return job;
|
||||
}
|
||||
|
||||
Future<void> _runAndRecord(CopyTask t, TransferJob job, CopyMethod method) async {
|
||||
await job.run();
|
||||
final e = job.error;
|
||||
final cur = tasks.where((x) => x.id == t.id).firstOrNull;
|
||||
if (cur != null) {
|
||||
await update(cur.copyWith(
|
||||
lastRun: DateTime.now().toIso8601String(),
|
||||
lastResult: e == null ? 'done' : e is FileOpCancelled ? 'cancelled' : 'failed',
|
||||
lastMessage: e == null ? '' : '$e',
|
||||
lastFiles: job.allDone,
|
||||
));
|
||||
}
|
||||
c.note(e == null
|
||||
? trf('복사 끝 ({0}): {1}개 → {2}', [method.label, t.sources.length, t.dest])
|
||||
: trf('복사 실패 ({0}): {1}', [method.label, e]));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// lsync 로 옮기기: 원본 폴더마다 (원본 → 대상\이름) 실시간 동기화 쌍. 파일 원본은 넣을 수 없어 남긴다.
|
||||
Future<int> toLiveSync(CopyTask t) async {
|
||||
final dirs = t.sources.where(FileSystemEntity.isDirectorySync).toList();
|
||||
if (dirs.isEmpty) return 0;
|
||||
final pairs = [
|
||||
for (final d in dirs) LiveSyncPair(d, t.contents ? t.dest : p.join(t.dest, p.basename(d)), method: t.method),
|
||||
];
|
||||
final rest = t.sources.where((s) => !dirs.contains(s)).toList();
|
||||
await c.updateSettings((x) => x
|
||||
..liveSyncPairs = [
|
||||
...x.liveSyncPairs,
|
||||
for (final n in pairs)
|
||||
if (!x.liveSyncPairs.any((o) => o.source == n.source && o.target == n.target)) n,
|
||||
]
|
||||
..copyTasks = [
|
||||
for (final y in x.copyTasks)
|
||||
if (y.id != t.id)
|
||||
y
|
||||
else if (rest.isNotEmpty)
|
||||
CopyTask(id: y.id, sources: rest, dest: y.dest, method: y.method, options: y.options, bandwidthKBps: y.bandwidthKBps),
|
||||
]);
|
||||
notifyListeners();
|
||||
return pairs.length;
|
||||
}
|
||||
|
||||
/// lsync 에서 빼서 복사 목록으로 (rsync 로, 원본 "안의 것" → 대상)
|
||||
Future<CopyTask> fromLiveSync(LiveSyncPair x) async {
|
||||
final s = c.settings;
|
||||
final m = copyMethodAvailable(CopyMethod.rsync, s) ? 'rsync' : x.method;
|
||||
final t = fresh([x.source], x.target, contents: true, method: m);
|
||||
await c.updateSettings((y) => y
|
||||
..liveSyncPairs = [for (final o in y.liveSyncPairs) if (!(o.source == x.source && o.target == x.target)) o]
|
||||
..copyTasks = [t, ...y.copyTasks]);
|
||||
notifyListeners();
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
/// 디스크 남은 용량 · 전체 용량 (바이트). 알 수 없으면 null.
|
||||
Future<(int, int)?> diskSpace(String path) async {
|
||||
try {
|
||||
if (Platform.isWindows) return WindowsUsage().disk(path);
|
||||
if (Platform.isAndroid) {
|
||||
final r = await const MethodChannel('jj_mkvmaker/android').invokeMethod<List<Object?>>('diskSpace', {'path': path});
|
||||
if (r != null && r.length == 2) return ((r[0] as num).toInt(), (r[1] as num).toInt());
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -4,6 +4,9 @@ import 'dart:io';
|
|||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../core/cron_window.dart';
|
||||
import '../core/file_ops.dart';
|
||||
import '../core/sync_tools.dart';
|
||||
import '../l10n/tr.dart';
|
||||
|
|
@ -46,22 +49,84 @@ class LiveSync extends ChangeNotifier {
|
|||
/// 쌍마다 마지막 결과 (화면 표시용): (시각, 글)
|
||||
final status = <String, (DateTime, String)>{};
|
||||
|
||||
/// 쌍마다 아직 맞추지 않은 것 (원본 기준 상대 경로, 지울 것은 "− " 를 앞에). 바뀌면 자동으로 다시 센다.
|
||||
final pending = <String, List<String>>{};
|
||||
|
||||
String _sig = '';
|
||||
|
||||
/// 1분마다: 동작 시간 (cron) 이 시작되면 밀린 것을 맞춘다
|
||||
Timer? _clock;
|
||||
|
||||
void start() {
|
||||
instance = this;
|
||||
c.addListener(_apply);
|
||||
_apply();
|
||||
_clock ??= Timer.periodic(const Duration(minutes: 1), (_) => _onClock());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
c.removeListener(_apply);
|
||||
_clock?.cancel();
|
||||
_stopAll();
|
||||
if (instance == this) instance = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 지금 동작 시간인지 (일정이 없으면 늘)
|
||||
static bool activeNow(LiveSyncPair x, [DateTime? now]) => scheduleActive(x.schedule, now ?? DateTime.now());
|
||||
|
||||
void _onClock() {
|
||||
for (final x in c.settings.liveSyncPairs.where((x) => x.enabled)) {
|
||||
if (activeNow(x) && (pending[keyOf(x)]?.isNotEmpty ?? false)) _schedule(x);
|
||||
}
|
||||
notifyListeners(); // 남은 시간 표시
|
||||
}
|
||||
|
||||
/// 원본과 대상의 다른 점 (크기 · 바뀐 시각). [x.delete] 면 원본에 없는 대상 항목도 "− 이름" 으로.
|
||||
static Future<List<String>> diff(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 names = <String>{};
|
||||
await for (final e in Directory(src).list(followLinks: false).handleError((_) {})) {
|
||||
if (out.length >= limit) return;
|
||||
final name = p.basename(e.path);
|
||||
names.add(name);
|
||||
final r = rel.isEmpty ? name : '$rel/$name';
|
||||
final t = p.join(dst, name);
|
||||
if (e is Directory) {
|
||||
await walk(e.path, t, r);
|
||||
} else if (e is File) {
|
||||
final st = await e.stat();
|
||||
final tf = File(t);
|
||||
if (!await tf.exists()) {
|
||||
out.add(r);
|
||||
continue;
|
||||
}
|
||||
final ts = await tf.stat();
|
||||
if (ts.size != st.size || ts.modified.difference(st.modified).inSeconds.abs() > 2) out.add(r);
|
||||
}
|
||||
}
|
||||
if (x.delete && await Directory(dst).exists()) {
|
||||
await for (final e in Directory(dst).list(followLinks: false).handleError((_) {})) {
|
||||
final name = p.basename(e.path);
|
||||
if (!names.contains(name) && !name.endsWith('.jjsync')) out.add('− ${rel.isEmpty ? name : '$rel/$name'}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (await Directory(x.source).exists()) await walk(x.source, x.target, '');
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<void> refreshPending(LiveSyncPair x) async {
|
||||
try {
|
||||
pending[keyOf(x)] = await diff(x);
|
||||
} catch (_) {}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
static String keyOf(LiveSyncPair x) => '${x.source}=>${x.target}';
|
||||
|
||||
void _stopAll() {
|
||||
|
|
@ -110,10 +175,14 @@ class LiveSync extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 바뀜 · 정해진 간격: 다른 점을 다시 세고, 동작 시간이면 3초 뒤 맞춘다 (모아서)
|
||||
void _schedule(LiveSyncPair x) {
|
||||
final k = keyOf(x);
|
||||
_pending[k]?.cancel();
|
||||
_pending[k] = Timer(const Duration(seconds: 3), () => syncNow(x));
|
||||
_pending[k] = Timer(const Duration(seconds: 3), () async {
|
||||
await refreshPending(x);
|
||||
if (activeNow(x) && (pending[k]?.isNotEmpty ?? false)) await syncNow(x);
|
||||
});
|
||||
}
|
||||
|
||||
bool isRunning(LiveSyncPair x) => _running.contains(keyOf(x));
|
||||
|
|
@ -136,6 +205,7 @@ class LiveSync extends ChangeNotifier {
|
|||
CopyMethod.robocopy => await _robocopy(x, s),
|
||||
};
|
||||
status[k] = (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n]));
|
||||
pending[k] = await diff(x);
|
||||
if (n != 0) c.note(trf('실시간 동기화 ({0}): {1} → {2}', [method.label, x.source, x.target]));
|
||||
} catch (e) {
|
||||
status[k] = (DateTime.now(), trf('실패: {0}', [e]));
|
||||
|
|
|
|||
|
|
@ -40,13 +40,20 @@ class LiveSyncPair {
|
|||
/// 원본에 없는 것을 대상에서 지우기 (rsync --delete, robocopy /PURGE)
|
||||
final bool delete;
|
||||
final bool enabled;
|
||||
const LiveSyncPair(this.source, this.target, {this.method = 'builtin', this.delete = false, this.enabled = true});
|
||||
|
||||
LiveSyncPair copyWith({String? method, bool? delete, bool? enabled}) =>
|
||||
LiveSyncPair(source, target, method: method ?? this.method, delete: delete ?? this.delete, enabled: enabled ?? this.enabled);
|
||||
/// 동작 시간 (cron 줄들, core/cron_window.dart). 비어 있으면 계속 (앱이 켜져 있는 동안 늘)
|
||||
final List<String> schedule;
|
||||
const LiveSyncPair(this.source, this.target,
|
||||
{this.method = 'builtin', this.delete = false, this.enabled = true, this.schedule = const []});
|
||||
|
||||
LiveSyncPair copyWith({String? method, bool? delete, bool? enabled, List<String>? schedule}) => LiveSyncPair(source, target,
|
||||
method: method ?? this.method,
|
||||
delete: delete ?? this.delete,
|
||||
enabled: enabled ?? this.enabled,
|
||||
schedule: schedule ?? this.schedule);
|
||||
|
||||
Map<String, Object?> toJson() =>
|
||||
{'source': source, 'target': target, 'method': method, 'delete': delete, 'enabled': enabled};
|
||||
{'source': source, 'target': target, 'method': method, 'delete': delete, 'enabled': enabled, 'schedule': schedule};
|
||||
|
||||
factory LiveSyncPair.fromJson(Map<Object?, Object?> j) => LiveSyncPair(
|
||||
j['source'] as String,
|
||||
|
|
@ -54,6 +61,108 @@ class LiveSyncPair {
|
|||
method: const ['rsync', 'robocopy'].contains(j['method']) ? j['method'] as String : 'builtin',
|
||||
delete: j['delete'] == true,
|
||||
enabled: j['enabled'] != false,
|
||||
schedule: [for (final x in (j['schedule'] as List?) ?? const []) '$x'],
|
||||
);
|
||||
}
|
||||
|
||||
/// 복사 · 이동 기억 (모니터링 > 복사 · rsync): 같은 원본 → 대상을 다시 복사하면 이 옵션을 쓴다. 언제든 [실행] 으로 다시.
|
||||
class CopyTask {
|
||||
final String id;
|
||||
final List<String> sources;
|
||||
final String dest;
|
||||
final bool move;
|
||||
|
||||
/// 폴더 "안의 것" 을 대상에 맞추기 (lsync 에서 옮겨 온 것: 원본/ → 대상/)
|
||||
final bool contents;
|
||||
|
||||
/// CopyMethod 이름 · 그 방법의 옵션 · 한 번에 · 속도 제한 (KB/s)
|
||||
final String method;
|
||||
final String options;
|
||||
final bool once;
|
||||
final int bandwidthKBps;
|
||||
|
||||
/// 마지막 실행: 시각 (ISO) · 결과 ('done' · 'failed' · 'cancelled' · '') · 글 · 파일 수
|
||||
final String lastRun;
|
||||
final String lastResult;
|
||||
final String lastMessage;
|
||||
final int lastFiles;
|
||||
|
||||
const CopyTask({
|
||||
required this.id,
|
||||
required this.sources,
|
||||
required this.dest,
|
||||
this.move = false,
|
||||
this.contents = false,
|
||||
this.method = 'builtin',
|
||||
this.options = '',
|
||||
this.once = false,
|
||||
this.bandwidthKBps = 0,
|
||||
this.lastRun = '',
|
||||
this.lastResult = '',
|
||||
this.lastMessage = '',
|
||||
this.lastFiles = 0,
|
||||
});
|
||||
|
||||
/// 같은 복사인지 (원본들 · 대상 · 이동)
|
||||
String get key => '${move ? 'M' : contents ? 'S' : 'C'}|${[...sources]..sort()}|$dest';
|
||||
|
||||
CopyTask copyWith({
|
||||
String? method,
|
||||
String? options,
|
||||
bool? once,
|
||||
int? bandwidthKBps,
|
||||
bool? move,
|
||||
String? lastRun,
|
||||
String? lastResult,
|
||||
String? lastMessage,
|
||||
int? lastFiles,
|
||||
}) =>
|
||||
CopyTask(
|
||||
id: id,
|
||||
sources: sources,
|
||||
dest: dest,
|
||||
move: move ?? this.move,
|
||||
contents: contents,
|
||||
method: method ?? this.method,
|
||||
options: options ?? this.options,
|
||||
once: once ?? this.once,
|
||||
bandwidthKBps: bandwidthKBps ?? this.bandwidthKBps,
|
||||
lastRun: lastRun ?? this.lastRun,
|
||||
lastResult: lastResult ?? this.lastResult,
|
||||
lastMessage: lastMessage ?? this.lastMessage,
|
||||
lastFiles: lastFiles ?? this.lastFiles,
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'sources': sources,
|
||||
'dest': dest,
|
||||
'move': move,
|
||||
'contents': contents,
|
||||
'method': method,
|
||||
'options': options,
|
||||
'once': once,
|
||||
'bandwidthKBps': bandwidthKBps,
|
||||
'lastRun': lastRun,
|
||||
'lastResult': lastResult,
|
||||
'lastMessage': lastMessage,
|
||||
'lastFiles': lastFiles,
|
||||
};
|
||||
|
||||
factory CopyTask.fromJson(Map<Object?, Object?> j) => CopyTask(
|
||||
id: '${j['id']}',
|
||||
sources: [for (final x in (j['sources'] as List?) ?? const []) '$x'],
|
||||
dest: '${j['dest']}',
|
||||
move: j['move'] == true,
|
||||
contents: j['contents'] == true,
|
||||
method: const ['rsync', 'robocopy'].contains(j['method']) ? j['method'] as String : 'builtin',
|
||||
options: j['options'] as String? ?? '',
|
||||
once: j['once'] == true,
|
||||
bandwidthKBps: (j['bandwidthKBps'] as num?)?.toInt() ?? 0,
|
||||
lastRun: j['lastRun'] as String? ?? '',
|
||||
lastResult: j['lastResult'] as String? ?? '',
|
||||
lastMessage: j['lastMessage'] as String? ?? '',
|
||||
lastFiles: (j['lastFiles'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -206,6 +315,10 @@ class AppSettings {
|
|||
/// 실시간 동기화 (lsyncd 처럼)
|
||||
List<LiveSyncPair> liveSyncPairs = [];
|
||||
|
||||
/// 복사 모니터링: 파일 탐색기 가운데 [모니터링] 버튼, 복사 · 이동을 기억해 다시 실행 · 옵션 고치기
|
||||
bool copyMonitor = false;
|
||||
List<CopyTask> copyTasks = [];
|
||||
|
||||
/// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다.
|
||||
int liveSyncIntervalSec = 30;
|
||||
|
||||
|
|
@ -301,6 +414,8 @@ class AppSettings {
|
|||
'rsyncPath': rsyncPath,
|
||||
'liveSyncPairs': [for (final x in liveSyncPairs) x.toJson()],
|
||||
'liveSyncIntervalSec': liveSyncIntervalSec,
|
||||
'copyMonitor': copyMonitor,
|
||||
'copyTasks': [for (final x in copyTasks) x.toJson()],
|
||||
'explorerHistory': explorerHistory,
|
||||
'autoCheckUpdates': autoCheckUpdates,
|
||||
'lastUpdateCheck': lastUpdateCheck,
|
||||
|
|
@ -403,6 +518,11 @@ class AppSettings {
|
|||
if (x is Map && x['source'] is String && x['target'] is String) LiveSyncPair.fromJson(x),
|
||||
]
|
||||
..liveSyncIntervalSec = ((j['liveSyncIntervalSec'] as num?)?.toInt() ?? 30).clamp(5, 3600)
|
||||
..copyMonitor = j['copyMonitor'] == true
|
||||
..copyTasks = [
|
||||
for (final x in (j['copyTasks'] as List?) ?? const [])
|
||||
if (x is Map && x['dest'] is String) CopyTask.fromJson(x),
|
||||
]
|
||||
..explorerHistory = [for (final x in (j['explorerHistory'] as List?) ?? const []) '$x']
|
||||
..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true
|
||||
..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? ''
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ class TransferJob extends ChangeNotifier {
|
|||
/// rsync 실행 파일 (rsync 방법일 때)
|
||||
final String? rsyncExe;
|
||||
|
||||
/// 폴더 "안의 것" 을 [dest] 에 맞추기 (rsync 원본/ → 대상/, 현재 방식은 바뀐 것만). lsync 에서 옮겨 온 복사.
|
||||
final bool contents;
|
||||
|
||||
TransferJob({
|
||||
required this.sources,
|
||||
required this.dest,
|
||||
|
|
@ -36,6 +39,7 @@ class TransferJob extends ChangeNotifier {
|
|||
this.once = false,
|
||||
this.bandwidthKBps = 0,
|
||||
this.rsyncExe,
|
||||
this.contents = false,
|
||||
});
|
||||
|
||||
// ── 진행 상태 ──
|
||||
|
|
@ -77,6 +81,11 @@ class TransferJob extends ChangeNotifier {
|
|||
Process? _proc;
|
||||
FileOps? _ops;
|
||||
|
||||
final _done = Completer<void>();
|
||||
|
||||
/// 끝날 때 (성공 · 실패 · 취소)
|
||||
Future<void> get done => _done.future;
|
||||
|
||||
void cancel() {
|
||||
cancelled = true;
|
||||
_ops?.cancel();
|
||||
|
|
@ -120,6 +129,7 @@ class TransferJob extends ChangeNotifier {
|
|||
} finally {
|
||||
finished = true;
|
||||
_tick();
|
||||
if (!_done.isCompleted) _done.complete();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -132,8 +142,13 @@ class TransferJob extends ChangeNotifier {
|
|||
_tick();
|
||||
});
|
||||
_tick();
|
||||
if (contents && FileSystemEntity.isDirectorySync(sources[i])) {
|
||||
await _ops!.mirror(sources[i], dest);
|
||||
made.add(dest);
|
||||
} else {
|
||||
final r = move ? await _ops!.move([sources[i]], dest) : await _ops!.copy([sources[i]], dest);
|
||||
made.addAll(r);
|
||||
}
|
||||
filesDone[i] = filesTotal[i]; // 이름 바꾸기로 옮긴 경우
|
||||
}
|
||||
index = sources.length;
|
||||
|
|
@ -175,14 +190,15 @@ class TransferJob extends ChangeNotifier {
|
|||
dest: dest,
|
||||
bandwidthKBps: bandwidthKBps,
|
||||
move: move,
|
||||
windows: windows),
|
||||
windows: windows,
|
||||
contents: contents),
|
||||
utf8,
|
||||
(chunk) {
|
||||
for (final f in out.feed(chunk)) {
|
||||
_addLog(f);
|
||||
// 출력 경로의 첫 부분 = 원본 이름 → 어느 항목인지
|
||||
// 출력 경로의 첫 부분 = 원본 이름 → 어느 항목인지 (안의 것 모드는 이름이 없어 지금 항목)
|
||||
final first = f.split('/').first;
|
||||
var k = idx.indexWhere((i) => p.basename(sources[i]) == first);
|
||||
var k = contents ? -1 : idx.indexWhere((i) => p.basename(sources[i]) == first);
|
||||
if (k < 0) k = names.indexOf(f);
|
||||
final at = k < 0 ? index : idx[k];
|
||||
if (at > index) index = at;
|
||||
|
|
@ -230,6 +246,7 @@ class TransferJob extends ChangeNotifier {
|
|||
dest: dest,
|
||||
bandwidthKBps: bandwidthKBps,
|
||||
move: move,
|
||||
contents: contents,
|
||||
);
|
||||
_tick();
|
||||
for (final args in runs) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
/// 실시간 동기화의 동작 시간 (cron 과 같은 글: "분 시 일 월 요일").
|
||||
///
|
||||
/// 시간 창으로 쓴다: 시 · 일 · 월 · 요일이 맞는 "그 시간 동안" 동기화한다 (분은 무시, 1시간 단위).
|
||||
/// 여러 줄이면 하나라도 맞으면 동작. 빈 목록 = 계속 (앱이 켜져 있는 동안 늘).
|
||||
///
|
||||
/// 예: "0 9-17 * * 1-5" 평일 9시~17시 59분, "0 0-5 * * *" 매일 0시~5시 59분, "0 */2 * * 0,6" 주말 2시간마다 1시간씩.
|
||||
library;
|
||||
|
||||
/// cron 글 한 줄
|
||||
class CronExpr {
|
||||
final Set<int> minutes, hours, days, months, weekdays;
|
||||
final bool dayStar, weekdayStar;
|
||||
final String text;
|
||||
|
||||
CronExpr._(this.text, this.minutes, this.hours, this.days, this.months, this.weekdays, this.dayStar, this.weekdayStar);
|
||||
|
||||
static const _dows = {'SUN': 0, 'MON': 1, 'TUE': 2, 'WED': 3, 'THU': 4, 'FRI': 5, 'SAT': 6};
|
||||
static const _mons = {
|
||||
'JAN': 1, 'FEB': 2, 'MAR': 3, 'APR': 4, 'MAY': 5, 'JUN': 6, //
|
||||
'JUL': 7, 'AUG': 8, 'SEP': 9, 'OCT': 10, 'NOV': 11, 'DEC': 12,
|
||||
};
|
||||
|
||||
/// 잘못된 글이면 FormatException
|
||||
static CronExpr parse(String text) {
|
||||
final f = text.trim().split(RegExp(r'\s+'));
|
||||
if (f.length != 5) throw FormatException('cron 은 다섯 칸입니다 (분 시 일 월 요일)', text);
|
||||
final wd = _field(f[4], 0, 7, _dows).map((d) => d == 7 ? 0 : d).toSet();
|
||||
return CronExpr._(text.trim(), _field(f[0], 0, 59), _field(f[1], 0, 23), _field(f[2], 1, 31), _field(f[3], 1, 12, _mons),
|
||||
wd, f[2] == '*', f[4] == '*');
|
||||
}
|
||||
|
||||
static CronExpr? tryParse(String text) {
|
||||
try {
|
||||
return parse(text);
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Set<int> _field(String s, int lo, int hi, [Map<String, int> names = const {}]) {
|
||||
int num(String x) {
|
||||
final n = names[x.toUpperCase()] ?? int.tryParse(x);
|
||||
if (n == null || n < lo || n > hi) throw FormatException('범위 밖 값: $x ($lo~$hi)');
|
||||
return n;
|
||||
}
|
||||
|
||||
final out = <int>{};
|
||||
for (final part in s.split(',')) {
|
||||
final stepParts = part.split('/');
|
||||
if (stepParts.length > 2) throw FormatException('잘못된 칸: $part');
|
||||
final step = stepParts.length == 2 ? int.tryParse(stepParts[1]) : 1;
|
||||
if (step == null || step < 1) throw FormatException('잘못된 간격: $part');
|
||||
final range = stepParts[0];
|
||||
int a, b;
|
||||
if (range == '*') {
|
||||
a = lo;
|
||||
b = hi;
|
||||
} else if (range.contains('-')) {
|
||||
final r = range.split('-');
|
||||
if (r.length != 2) throw FormatException('잘못된 범위: $part');
|
||||
a = num(r[0]);
|
||||
b = num(r[1]);
|
||||
if (b < a) throw FormatException('거꾸로 된 범위: $part');
|
||||
} else {
|
||||
a = num(range);
|
||||
b = stepParts.length == 2 ? hi : a;
|
||||
}
|
||||
for (var v = a; v <= b; v += step) {
|
||||
out.add(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// [t] 가 이 줄의 시간 창 안인지 (시 · 일 · 월 · 요일. cron 처럼 일 · 요일을 둘 다 정하면 둘 중 하나만 맞아도 됨)
|
||||
bool activeAt(DateTime t) {
|
||||
if (!hours.contains(t.hour) || !months.contains(t.month)) return false;
|
||||
final dom = days.contains(t.day), dow = weekdays.contains(t.weekday % 7);
|
||||
if (dayStar || weekdayStar) return dom && dow;
|
||||
return dom || dow;
|
||||
}
|
||||
|
||||
/// 시간 격자로 나타낼 수 있는지 (일 · 월이 * 일 때)
|
||||
bool get gridable => dayStar && months.length == 12;
|
||||
}
|
||||
|
||||
/// 일정 (cron 여러 줄). 빈 목록 = 계속
|
||||
bool scheduleActive(List<String> lines, DateTime t) {
|
||||
if (lines.isEmpty) return true;
|
||||
for (final l in lines) {
|
||||
final e = CronExpr.tryParse(l);
|
||||
if (e != null && e.activeAt(t)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 다음에 동작이 시작되는 때 (지금 동작 중이면 null, 8일 안에 없으면 null)
|
||||
DateTime? scheduleNextStart(List<String> lines, DateTime now) {
|
||||
if (lines.isEmpty || scheduleActive(lines, now)) return null;
|
||||
var t = DateTime(now.year, now.month, now.day, now.hour).add(const Duration(hours: 1));
|
||||
for (var i = 0; i < 24 * 8; i++, t = t.add(const Duration(hours: 1))) {
|
||||
if (scheduleActive(lines, t)) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 지금 동작 중이면 끝나는 때
|
||||
DateTime? scheduleEnd(List<String> lines, DateTime now) {
|
||||
if (lines.isEmpty || !scheduleActive(lines, now)) return null;
|
||||
var t = DateTime(now.year, now.month, now.day, now.hour).add(const Duration(hours: 1));
|
||||
for (var i = 0; i < 24 * 8; i++, t = t.add(const Duration(hours: 1))) {
|
||||
if (!scheduleActive(lines, t)) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 요일 (0 = 일요일) × 시 (0~23) 격자 칸: 요일 * 24 + 시
|
||||
Set<int> gridFromCron(List<String> lines) {
|
||||
final out = <int>{};
|
||||
for (final l in lines) {
|
||||
final e = CronExpr.tryParse(l);
|
||||
if (e == null || !e.gridable) continue;
|
||||
for (final d in e.weekdays) {
|
||||
for (final h in e.hours) {
|
||||
out.add(d * 24 + h);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// 격자 → cron 줄 (같은 시간대를 가진 요일끼리 한 줄)
|
||||
List<String> cronFromGrid(Set<int> cells) {
|
||||
if (cells.length == 7 * 24) return const ['0 * * * *'];
|
||||
final byHours = <String, List<int>>{};
|
||||
for (var d = 0; d < 7; d++) {
|
||||
final hours = [for (var h = 0; h < 24; h++) if (cells.contains(d * 24 + h)) h];
|
||||
if (hours.isEmpty) continue;
|
||||
byHours.putIfAbsent(_ranges(hours, 23), () => []).add(d);
|
||||
}
|
||||
return [for (final e in byHours.entries) '0 ${e.key} * * ${_ranges(e.value, 6)}'];
|
||||
}
|
||||
|
||||
/// [1,2,3,5] → "1-3,5" (전부면 *)
|
||||
String _ranges(List<int> v, int max) {
|
||||
if (v.length == max + 1) return '*';
|
||||
final parts = <String>[];
|
||||
var i = 0;
|
||||
while (i < v.length) {
|
||||
var j = i;
|
||||
while (j + 1 < v.length && v[j + 1] == v[j] + 1) {
|
||||
j++;
|
||||
}
|
||||
parts.add(i == j ? '${v[i]}' : '${v[i]}-${v[j]}');
|
||||
i = j + 1;
|
||||
}
|
||||
return parts.join(',');
|
||||
}
|
||||
|
|
@ -66,13 +66,15 @@ List<String> rsyncArgs({
|
|||
int bandwidthKBps = 0,
|
||||
bool move = false,
|
||||
bool windows = true,
|
||||
bool contents = false,
|
||||
}) {
|
||||
final opts = splitOptions(options);
|
||||
return [
|
||||
...opts,
|
||||
if (bandwidthKBps > 0 && !opts.any((o) => o.startsWith('--bwlimit'))) '--bwlimit=$bandwidthKBps',
|
||||
if (move && !opts.contains('--remove-source-files')) '--remove-source-files',
|
||||
for (final s in sources) toCygwinPath(_noTrailingSlash(s), windows: windows),
|
||||
// [contents]: 폴더 "안의 것" 을 대상에 (rsync 의 끝 / - 동기화 · lsync 와 같은 모양)
|
||||
for (final s in sources) '${toCygwinPath(_noTrailingSlash(s), windows: windows)}${contents ? '/' : ''}',
|
||||
'${toCygwinPath(_noTrailingSlash(dest), windows: windows)}/',
|
||||
];
|
||||
}
|
||||
|
|
@ -90,6 +92,7 @@ List<List<String>> robocopyRuns({
|
|||
required String dest,
|
||||
int bandwidthKBps = 0,
|
||||
bool move = false,
|
||||
bool contents = false,
|
||||
}) {
|
||||
final opts = splitOptions(options);
|
||||
final extra = [
|
||||
|
|
@ -97,7 +100,7 @@ List<List<String>> robocopyRuns({
|
|||
if (move && !opts.any((o) => o.toUpperCase().startsWith('/MOV'))) '/MOVE',
|
||||
];
|
||||
final runs = <List<String>>[
|
||||
for (final f in folders) [f, p.join(dest, p.basename(f)), ...opts, ...extra],
|
||||
for (final f in folders) [f, contents ? dest : p.join(dest, p.basename(f)), ...opts, ...extra],
|
||||
];
|
||||
final byDir = <String, List<String>>{};
|
||||
for (final f in files) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import '../l10n/tr.dart';
|
|||
import '../platform/windows/rsync_installer.dart';
|
||||
import 'folder_picker.dart';
|
||||
import 'rsync_setup.dart';
|
||||
import 'schedule_editor.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// 환경 설정 > 파일 탐색기 의 복사 · 이동 · 동기화 부분:
|
||||
|
|
@ -195,23 +196,34 @@ class _CopySyncSettingsState extends State<CopySyncSettings> {
|
|||
child: Text(tr('고르기')),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: s.copyMonitor,
|
||||
onChanged: (v) => c.updateSettings((x) => x.copyMonitor = v),
|
||||
title: Text(tr('복사 모니터링')),
|
||||
subtitle: Text(tr('파일 탐색기 가운데에 [모니터링] 버튼: 복사 · 이동을 기억해 진행 · 대상 디스크 용량 · 옵션을 보여 주고, '
|
||||
'옵션을 고쳐 다시 실행 · 재개하거나 lsync 로 옮깁니다 (복사 · rsync 와 lsync 를 따로).')),
|
||||
),
|
||||
_LiveSyncTile(c: c),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 실시간 동기화 (lsyncd 처럼): 폴더 쌍 목록 · 추가 · 켜기 / 끄기 · 지금 맞추기 · 지우기
|
||||
/// 실시간 동기화 쌍 추가 (원본 · 대상 폴더 고르기 → 방법 · 지우기 · 일정)
|
||||
Future<void> addLiveSyncPairDialog(BuildContext context, AppController c) => _LiveSyncTile._add(context, c);
|
||||
|
||||
/// 실시간 동기화 (lsyncd 처럼): 폴더 쌍 목록 · 추가 · 켜기 / 끄기 · 일정 · 지금 맞추기 · 지우기
|
||||
class _LiveSyncTile extends StatelessWidget {
|
||||
final AppController c;
|
||||
const _LiveSyncTile({required this.c});
|
||||
|
||||
Future<void> _add(BuildContext context) async {
|
||||
static Future<void> _add(BuildContext context, AppController c) async {
|
||||
final src = await pickFolder(context, tr('실시간 동기화: 원본 폴더'));
|
||||
if (src == null || !context.mounted) return;
|
||||
final dst = await pickFolder(context, tr('실시간 동기화: 대상 폴더'));
|
||||
if (dst == null || !context.mounted) return;
|
||||
var method = 'builtin';
|
||||
var method = copyMethodAvailable(CopyMethod.rsync, c.settings) ? 'rsync' : 'builtin';
|
||||
var delete = false;
|
||||
var schedule = <String>[];
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
|
|
@ -232,6 +244,19 @@ class _LiveSyncTile extends StatelessWidget {
|
|||
title: Text(tr('원본에 없는 것을 대상에서 지우기')),
|
||||
subtitle: Text(tr('rsync --delete · robocopy /PURGE. 끄면 대상에 더하기 · 바꾸기만')),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: WeekGridPreview(lines: schedule, width: 96),
|
||||
title: Text(scheduleSummary(schedule)),
|
||||
subtitle: Text(tr('동기화 시간 (계속 / 시간 지정)')),
|
||||
trailing: TextButton(
|
||||
onPressed: () async {
|
||||
final r = await editSchedule(ctx, schedule);
|
||||
if (r != null) set(() => schedule = r);
|
||||
},
|
||||
child: Text(tr('바꾸기')),
|
||||
),
|
||||
),
|
||||
]),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||
|
|
@ -241,7 +266,8 @@ class _LiveSyncTile extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
await c.updateSettings((x) => x.liveSyncPairs = [...x.liveSyncPairs, LiveSyncPair(src, dst, method: method, delete: delete)]);
|
||||
await c.updateSettings(
|
||||
(x) => x.liveSyncPairs = [...x.liveSyncPairs, LiveSyncPair(src, dst, method: method, delete: delete, schedule: schedule)]);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -255,7 +281,7 @@ class _LiveSyncTile extends StatelessWidget {
|
|||
? tr('원본 폴더가 바뀌면 곧바로 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안). 시작할 때 한 번 맞춥니다.')
|
||||
: trf('{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).', [c.settings.liveSyncIntervalSec])),
|
||||
trailing: TextButton.icon(
|
||||
onPressed: () => _add(context),
|
||||
onPressed: () => _add(context, c),
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: Text(tr('추가')),
|
||||
),
|
||||
|
|
@ -270,11 +296,23 @@ class _LiveSyncTile extends StatelessWidget {
|
|||
subtitle: Text([
|
||||
tr(CopyMethod.of(pairs[i].method).label),
|
||||
if (pairs[i].delete) tr('지우기 포함'),
|
||||
scheduleSummary(pairs[i].schedule),
|
||||
if (live?.isRunning(pairs[i]) ?? false) tr('맞추는 중…'),
|
||||
if (live?.status[LiveSync.keyOf(pairs[i])] case final st?)
|
||||
'${st.$1.hour.toString().padLeft(2, '0')}:${st.$1.minute.toString().padLeft(2, '0')} ${st.$2}',
|
||||
].join(' · ')),
|
||||
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
IconButton(
|
||||
tooltip: tr('동기화 시간 (계속 / 시간 지정)'),
|
||||
icon: const Icon(Icons.schedule, size: 18),
|
||||
onPressed: () async {
|
||||
final r = await editSchedule(context, pairs[i].schedule);
|
||||
if (r == null) return;
|
||||
await c.updateSettings((x) => x.liveSyncPairs = [
|
||||
for (var k = 0; k < pairs.length; k++) k == i ? pairs[k].copyWith(schedule: r) : pairs[k],
|
||||
]);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
tooltip: tr('지금 맞추기'),
|
||||
icon: const Icon(Icons.sync, size: 18),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:flutter/services.dart';
|
|||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../app/app_controller.dart';
|
||||
import '../app/copy_center.dart';
|
||||
import '../app/live_sync.dart';
|
||||
import '../app/transfer_job.dart';
|
||||
import '../core/sync_tools.dart';
|
||||
|
|
@ -17,6 +18,7 @@ import '../core/playlist.dart' show isVideoFile;
|
|||
import '../platform/android/android_storage.dart';
|
||||
import 'app_actions.dart';
|
||||
import 'explorer_look.dart';
|
||||
import 'monitor_page.dart';
|
||||
import 'player_page.dart';
|
||||
import 'rsync_setup.dart';
|
||||
import 'theme.dart';
|
||||
|
|
@ -61,6 +63,7 @@ enum ExplorerButton {
|
|||
up(Icons.arrow_upward, '상위 폴더'),
|
||||
refresh(Icons.refresh, '새로 고침'),
|
||||
select(Icons.checklist, '선택'),
|
||||
monitor(Icons.monitor_heart_outlined, '모니터링'),
|
||||
play(Icons.play_circle_outline, '재생'),
|
||||
addMkv(Icons.playlist_add, 'MKV 목록에 추가'),
|
||||
newFolder(Icons.create_new_folder_outlined, '새 폴더'),
|
||||
|
|
@ -527,6 +530,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
await _goTo(pane, up);
|
||||
case ExplorerButton.select:
|
||||
_setSelecting(!_selecting);
|
||||
case ExplorerButton.monitor:
|
||||
await MonitorPage.open(context, c);
|
||||
case ExplorerButton.refresh:
|
||||
pane.cache.clear();
|
||||
for (final d in pane.expanded.toList()) {
|
||||
|
|
@ -719,14 +724,6 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
/// 지금 도는 복사 · 이동 (아래에서 올라오는 진행 막대)
|
||||
TransferJob? _job;
|
||||
|
||||
/// 고른 것에 맞는 방법: 폴더가 하나라도 있으면 "폴더 복사 방법", 파일만이면 "파일 복사 방법"
|
||||
CopyMethod _methodFor(List<String> sources) {
|
||||
final s = c.settings;
|
||||
final hasDir = sources.any(FileSystemEntity.isDirectorySync);
|
||||
final m = CopyMethod.of(hasDir ? s.copyMethodFolder : s.copyMethodFile);
|
||||
return copyMethodAvailable(m, s) ? m : CopyMethod.builtin;
|
||||
}
|
||||
|
||||
/// rsync 가 없으면 (Windows · 내려받기 설정) 지금 내려받을지 묻는다. 쓸 수 있는 rsync 경로 (없으면 null)
|
||||
Future<String?> _ensureRsync() async {
|
||||
final s = c.settings;
|
||||
|
|
@ -750,14 +747,21 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
_snack(tr('앞의 복사 · 이동이 끝난 뒤에 하세요.'));
|
||||
return false;
|
||||
}
|
||||
final method = _methodFor(sources);
|
||||
// 복사 모니터링을 켜면 기억한 같은 복사의 옵션을 쓰고, 처음이면 기억한다
|
||||
final center = CopyCenter.of(c);
|
||||
final task = c.settings.copyMonitor
|
||||
? await center.remember(sources, dest, move: move)
|
||||
: center.fresh(sources, dest, move: move);
|
||||
final method = CopyMethod.of(task.method);
|
||||
if (!mounted) return false;
|
||||
final go = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(move ? tr('이동') : tr('복사')),
|
||||
content: Text('${trf('{0}개 항목을 다음 폴더로 {1}\n{2}', [sources.length, move ? tr('옮길까요?') : tr('복사할까요?'), dest])}'
|
||||
'\n\n${trf('방법: {0}', [tr(method.label)])}'
|
||||
'${c.settings.copyBandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [c.settings.copyBandwidthKBps])}' : ''}'),
|
||||
'${method == CopyMethod.builtin ? '' : ' · ${task.options}'}'
|
||||
'${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(move ? tr('이동') : tr('복사'))),
|
||||
|
|
@ -770,19 +774,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
rsync = await _ensureRsync();
|
||||
if (rsync == null || !mounted) return false;
|
||||
}
|
||||
final s = c.settings;
|
||||
final job = TransferJob(
|
||||
sources: sources,
|
||||
dest: dest,
|
||||
move: move,
|
||||
method: method,
|
||||
options: method == CopyMethod.robocopy ? s.robocopyOptions : s.rsyncOptions,
|
||||
once: s.copyRunMode == 'once',
|
||||
bandwidthKBps: s.copyBandwidthKBps,
|
||||
rsyncExe: rsync,
|
||||
);
|
||||
final job = await center.start(task, rsyncExe: rsync);
|
||||
if (job == null || !mounted) return false;
|
||||
setState(() => _job = job);
|
||||
await job.run();
|
||||
await job.done;
|
||||
final error = job.error;
|
||||
if (!mounted) return false;
|
||||
pane.marked.clear();
|
||||
|
|
@ -1227,7 +1222,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
final bar = s.explorerToolbar == 'hidden'
|
||||
? null
|
||||
: _Toolbar(
|
||||
buttons: ExplorerButton.fromSettings(s.explorerButtons),
|
||||
// [모니터링] 은 환경 설정 > 복사 모니터링 을 켰을 때만
|
||||
buttons: [
|
||||
for (final b in ExplorerButton.fromSettings(s.explorerButtons))
|
||||
if (b != ExplorerButton.monitor || s.copyMonitor) b,
|
||||
],
|
||||
vertical: side,
|
||||
onPressed: _button,
|
||||
enabled: (b) => switch (b) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,580 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../app/app_controller.dart';
|
||||
import '../app/copy_center.dart';
|
||||
import '../app/live_sync.dart';
|
||||
import '../app/settings.dart';
|
||||
import '../app/transfer_job.dart';
|
||||
import '../core/file_ops.dart';
|
||||
import '../core/sync_tools.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import 'app_actions.dart';
|
||||
import 'copy_sync_settings.dart' show addLiveSyncPairDialog;
|
||||
import 'rsync_setup.dart';
|
||||
import 'schedule_editor.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// 모니터링 (파일 탐색기 > 모니터링): 복사 · rsync 와 lsync (실시간 동기화) 를 따로 보여 준다.
|
||||
class MonitorPage extends StatelessWidget {
|
||||
final AppController c;
|
||||
final int initialTab;
|
||||
const MonitorPage({super.key, required this.c, this.initialTab = 0});
|
||||
|
||||
static Future<void> open(BuildContext context, AppController c, {int tab = 0}) => Navigator.of(context)
|
||||
.push(MaterialPageRoute<void>(builder: (_) => MonitorPage(c: c, initialTab: tab)));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => DefaultTabController(
|
||||
length: 2,
|
||||
initialIndex: initialTab,
|
||||
child: Scaffold(
|
||||
body: Column(children: [
|
||||
Container(
|
||||
height: appBarHeight,
|
||||
color: JjColors.panel,
|
||||
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
|
||||
child: Row(children: [
|
||||
const AppNavButtons(),
|
||||
const SizedBox(width: 8),
|
||||
Text(tr('모니터링'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TabBar(
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
tabs: [
|
||||
Tab(icon: const Icon(Icons.copy_all_outlined, size: 18), text: tr('복사 · rsync')),
|
||||
Tab(icon: const Icon(Icons.sync, size: 18), text: tr('lsync (실시간 동기화)')),
|
||||
],
|
||||
),
|
||||
),
|
||||
AppActions(c: c),
|
||||
]),
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(children: [
|
||||
_CopyTab(c: c),
|
||||
_LiveTab(c: c),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _hm(String iso) {
|
||||
final d = DateTime.tryParse(iso);
|
||||
if (d == null) return '';
|
||||
String two(int x) => x.toString().padLeft(2, '0');
|
||||
return '${d.month}/${d.day} ${two(d.hour)}:${two(d.minute)}';
|
||||
}
|
||||
|
||||
/// 대상 디스크 남은 용량 / 전체
|
||||
class _DiskInfo extends StatelessWidget {
|
||||
final String path;
|
||||
const _DiskInfo({required this.path});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var dir = path;
|
||||
while (!Directory(dir).existsSync() && p.dirname(dir) != dir) {
|
||||
dir = p.dirname(dir);
|
||||
}
|
||||
return FutureBuilder<(int, int)?>(
|
||||
future: diskSpace(dir),
|
||||
builder: (_, s) {
|
||||
final d = s.data;
|
||||
if (d == null) return Text(tr('대상 디스크: 알 수 없음'), style: const TextStyle(fontSize: 12, color: JjColors.textDim));
|
||||
final (free, total) = d;
|
||||
final used = total == 0 ? 0.0 : (total - free) / total;
|
||||
return Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
const Icon(Icons.storage, size: 14, color: JjColors.textDim),
|
||||
const SizedBox(width: 4),
|
||||
Text(trf('대상 디스크: 남은 {0} / 전체 {1}', [formatSize(free), formatSize(total)]), style: const TextStyle(fontSize: 12)),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 6,
|
||||
value: used,
|
||||
color: used > 0.9 ? Colors.redAccent : JjColors.accent,
|
||||
backgroundColor: JjColors.border,
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 진행 두 줄 (위: 전체 항목 · 아래: 지금 폴더의 파일)
|
||||
class TransferProgressLines extends StatelessWidget {
|
||||
final TransferJob job;
|
||||
const TransferProgressLines({super.key, required this.job});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListenableBuilder(
|
||||
listenable: job,
|
||||
builder: (_, _) {
|
||||
final cur = (job.index < job.total ? job.index : job.total - 1).clamp(0, job.total - 1);
|
||||
String pct(double v) => '${(v * 100).round()}%';
|
||||
Widget line(String label, double v, String right) => Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(children: [
|
||||
SizedBox(width: 220, child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12))),
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 7, value: job.counting ? null : v, color: JjColors.accent, backgroundColor: JjColors.border),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: Text(right, textAlign: TextAlign.end, style: const TextStyle(fontSize: 12, color: JjColors.textDim))),
|
||||
]),
|
||||
);
|
||||
return Column(children: [
|
||||
line(trf('전체 {0}개 중 {1}번째', [job.total, cur + 1]), job.overall,
|
||||
'${pct(job.overall)} · ${trf('파일 {0}/{1}', [job.allDone, job.allFiles])}'),
|
||||
line(job.counting ? tr('파일 세는 중…') : trf('{0} 안의 파일', [p.basename(job.sources[cur])]), job.current,
|
||||
'${pct(job.current)} · ${trf('파일 {0}/{1}', [job.filesDone[cur], job.filesTotal[cur]])}'),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── 복사 · rsync ─────────
|
||||
|
||||
class _CopyTab extends StatelessWidget {
|
||||
final AppController c;
|
||||
const _CopyTab({required this.c});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final center = CopyCenter.of(c);
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([center, c]),
|
||||
builder: (context, _) {
|
||||
final tasks = center.tasks;
|
||||
if (tasks.isEmpty) {
|
||||
return Center(
|
||||
child: Text(tr('기억한 복사가 없습니다. 파일 탐색기에서 복사 · 이동하면 여기에 자동으로 등록됩니다.'),
|
||||
style: const TextStyle(color: JjColors.textDim)),
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [for (final t in tasks) _CopyCard(key: ValueKey(t.id), c: c, task: t)],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CopyCard extends StatefulWidget {
|
||||
final AppController c;
|
||||
final CopyTask task;
|
||||
const _CopyCard({super.key, required this.c, required this.task});
|
||||
|
||||
@override
|
||||
State<_CopyCard> createState() => _CopyCardState();
|
||||
}
|
||||
|
||||
class _CopyCardState extends State<_CopyCard> {
|
||||
CopyCenter get center => CopyCenter.of(widget.c);
|
||||
late String _method = widget.task.method;
|
||||
late final _options = TextEditingController(text: widget.task.options);
|
||||
late final _bw = TextEditingController(text: '${widget.task.bandwidthKBps}');
|
||||
late bool _once = widget.task.once;
|
||||
|
||||
bool get _dirty =>
|
||||
_method != widget.task.method ||
|
||||
_options.text.trim() != widget.task.options ||
|
||||
(int.tryParse(_bw.text) ?? 0) != widget.task.bandwidthKBps ||
|
||||
_once != widget.task.once;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_options.dispose();
|
||||
_bw.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _run(CopyTask t) async {
|
||||
String? exe;
|
||||
if (t.method == 'rsync') {
|
||||
exe = await rsyncExecutable(widget.c.settings);
|
||||
if (exe == null && Platform.isWindows && widget.c.settings.rsyncSource == 'download' && mounted) {
|
||||
exe = await installRsyncWithDialog(context);
|
||||
}
|
||||
if (exe == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.'))));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
await center.start(t, rsyncExe: exe);
|
||||
}
|
||||
|
||||
/// 옵션 저장 → 지금 실행할지 묻기 (현재 유지 / 반영 후 실행)
|
||||
Future<void> _save() async {
|
||||
final t = widget.task.copyWith(
|
||||
method: _method,
|
||||
options: _options.text.trim(),
|
||||
bandwidthKBps: int.tryParse(_bw.text) ?? 0,
|
||||
once: _once,
|
||||
);
|
||||
await center.update(t);
|
||||
if (!mounted) return;
|
||||
final running = center.isRunning(t.id);
|
||||
final go = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(tr('옵션을 저장했습니다')),
|
||||
content: Text(running
|
||||
? tr('지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?')
|
||||
: tr('이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?')),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('현재 유지'))),
|
||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('설정 반영 후 실행'))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (go != true) return;
|
||||
if (running) {
|
||||
final old = center.jobs[t.id]!;
|
||||
old.cancel();
|
||||
await old.done;
|
||||
}
|
||||
await _run(t);
|
||||
}
|
||||
|
||||
Future<void> _toLive(CopyTask t) async {
|
||||
final n = await center.toLiveSync(t);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(n == 0 ? tr('폴더만 lsync 로 옮길 수 있습니다.') : trf('{0}개 폴더를 lsync (실시간 동기화) 로 옮겼습니다.', [n]))));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = widget.task;
|
||||
final job = center.jobs[t.id];
|
||||
final running = job != null && !job.finished;
|
||||
final s = widget.c.settings;
|
||||
final (icon, color, status) = running
|
||||
? (Icons.sync, JjColors.accent, job.move ? tr('옮기는 중') : tr('복사하는 중'))
|
||||
: switch (t.lastResult) {
|
||||
'done' => (Icons.check_circle, Colors.greenAccent, trf('끝 · {0} · 파일 {1}개', [_hm(t.lastRun), t.lastFiles])),
|
||||
'failed' => (Icons.error, Colors.redAccent, trf('실패 · {0}', [_hm(t.lastRun)])),
|
||||
'cancelled' => (Icons.pause_circle, Colors.orangeAccent, trf('멈춤 · {0}', [_hm(t.lastRun)])),
|
||||
_ => (Icons.schedule, JjColors.textDim, tr('아직 실행 안 함')),
|
||||
};
|
||||
final optsHint = switch (CopyMethod.of(_method)) {
|
||||
CopyMethod.rsync => defaultRsyncOptions,
|
||||
CopyMethod.robocopy => defaultRobocopyOptions,
|
||||
CopyMethod.builtin => tr('(현재 방식은 옵션 없음)'),
|
||||
};
|
||||
return Card(
|
||||
color: JjColors.panel,
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(icon, color: color, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(
|
||||
'${t.sources.length == 1 ? t.sources.first : trf('{0} 외 {1}개', [p.basename(t.sources.first), t.sources.length - 1])}'
|
||||
'${t.contents ? '/' : ''}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text('→ ${t.dest}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
),
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text('${t.move ? tr('이동') : tr('복사')} · ${tr(CopyMethod.of(t.method).label)}'),
|
||||
),
|
||||
]),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 28, top: 2),
|
||||
child: Wrap(spacing: 16, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||||
Text(status, style: TextStyle(fontSize: 12, color: color)),
|
||||
_DiskInfo(path: t.dest),
|
||||
]),
|
||||
),
|
||||
if (t.lastResult == 'failed' && !running && t.lastMessage.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 28, top: 4),
|
||||
child: Text(t.lastMessage, maxLines: 3, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: Colors.redAccent)),
|
||||
),
|
||||
if (job != null && (running || job.finished && job.error == null))
|
||||
Padding(padding: const EdgeInsets.only(left: 28, top: 6), child: TransferProgressLines(job: job)),
|
||||
const SizedBox(height: 8),
|
||||
// 옵션 (고치면 [저장] → 지금 실행할지 묻기)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 28),
|
||||
child: Wrap(spacing: 10, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||||
DropdownButton<String>(
|
||||
value: _method,
|
||||
items: [
|
||||
for (final m in CopyMethod.values)
|
||||
if (copyMethodAvailable(m, s) || m.name == _method) DropdownMenuItem(value: m.name, child: Text(tr(m.label))),
|
||||
],
|
||||
onChanged: (v) => setState(() {
|
||||
if (v != _method && _options.text.trim().isEmpty || _options.text.trim() == defaultRsyncOptions || _options.text.trim() == defaultRobocopyOptions) {
|
||||
_options.text = v == 'robocopy' ? s.robocopyOptions : v == 'rsync' ? s.rsyncOptions : '';
|
||||
}
|
||||
_method = v!;
|
||||
}),
|
||||
),
|
||||
SizedBox(
|
||||
width: 320,
|
||||
child: TextField(
|
||||
controller: _options,
|
||||
enabled: _method != 'builtin',
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||
decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('옵션'), hintText: optsHint),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 130,
|
||||
child: TextField(
|
||||
controller: _bw,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('속도 제한'), suffixText: 'KB/s'),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
if (t.sources.length > 1 && _method == 'rsync')
|
||||
FilterChip(label: Text(tr('한 번에')), selected: _once, onSelected: (v) => setState(() => _once = v)),
|
||||
if (_dirty) FilledButton.tonal(onPressed: _save, child: Text(tr('저장'))),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(children: [
|
||||
const Spacer(),
|
||||
if (running)
|
||||
TextButton.icon(onPressed: () => center.cancel(t.id), icon: const Icon(Icons.stop, size: 18), label: Text(tr('취소')))
|
||||
else
|
||||
FilledButton.icon(
|
||||
onPressed: () => _run(t),
|
||||
icon: const Icon(Icons.play_arrow, size: 18),
|
||||
label: Text(t.lastResult == 'cancelled' || t.lastResult == 'failed' ? tr('재개') : tr('실행')),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
TextButton.icon(
|
||||
onPressed: running ? null : () => _toLive(t),
|
||||
icon: const Icon(Icons.sync_alt, size: 18),
|
||||
label: Text(tr('lsync 로 이동')),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: tr('목록에서 지우기 (파일은 그대로)'),
|
||||
icon: const Icon(Icons.delete_outline, color: JjColors.textDim),
|
||||
onPressed: () => center.remove(t.id),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ───────── lsync (실시간 동기화) ─────────
|
||||
|
||||
class _LiveTab extends StatefulWidget {
|
||||
final AppController c;
|
||||
const _LiveTab({required this.c});
|
||||
|
||||
@override
|
||||
State<_LiveTab> createState() => _LiveTabState();
|
||||
}
|
||||
|
||||
class _LiveTabState extends State<_LiveTab> {
|
||||
AppController get c => widget.c;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 열 때 맞출 것을 새로 센다
|
||||
final live = LiveSync.instance;
|
||||
if (live != null) {
|
||||
for (final x in c.settings.liveSyncPairs) {
|
||||
live.refreshPending(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _replace(LiveSyncPair old, LiveSyncPair now) => c.updateSettings((x) => x.liveSyncPairs = [
|
||||
for (final y in x.liveSyncPairs) y.source == old.source && y.target == old.target ? now : y,
|
||||
]);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final live = LiveSync.instance;
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([c, ?live]),
|
||||
builder: (context, _) {
|
||||
final pairs = c.settings.liveSyncPairs;
|
||||
return ListView(padding: const EdgeInsets.all(12), children: [
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
tr('원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.'),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim),
|
||||
),
|
||||
),
|
||||
TextButton.icon(onPressed: () => addLiveSyncPairDialog(context, c), icon: const Icon(Icons.add), label: Text(tr('추가'))),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
if (pairs.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(child: Text(tr('실시간 동기화가 없습니다. [추가] 하거나 복사 · rsync 탭에서 [lsync 로 이동] 하세요.'),
|
||||
style: const TextStyle(color: JjColors.textDim))),
|
||||
),
|
||||
for (final x in pairs) _liveCard(context, live, x),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _liveCard(BuildContext context, LiveSync? live, LiveSyncPair x) {
|
||||
final k = LiveSync.keyOf(x);
|
||||
final pend = live?.pending[k];
|
||||
final st = live?.status[k];
|
||||
final active = LiveSync.activeNow(x);
|
||||
final running = live?.isRunning(x) ?? false;
|
||||
return Card(
|
||||
color: JjColors.panel,
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(running ? Icons.sync : x.enabled ? (active ? Icons.sync : Icons.bedtime_outlined) : Icons.sync_disabled,
|
||||
color: x.enabled ? (active ? JjColors.accent : Colors.orangeAccent) : JjColors.textDim),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('${x.source}/', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text('→ ${x.target}/', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
),
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text([tr(CopyMethod.of(x.method).label), if (x.delete) tr('지우기 포함')].join(' · ')),
|
||||
),
|
||||
Switch(value: x.enabled, onChanged: (v) => _replace(x, x.copyWith(enabled: v))),
|
||||
]),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 32, top: 4),
|
||||
child: Wrap(spacing: 16, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||||
// 일정: 계속 / 시간 지정 (cron) + 작은 격자
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
final r = await editSchedule(context, x.schedule);
|
||||
if (r != null) await _replace(x, x.copyWith(schedule: r));
|
||||
},
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
WeekGridPreview(lines: x.schedule, width: 120),
|
||||
const SizedBox(width: 8),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(scheduleSummary(x.schedule), style: TextStyle(fontSize: 12, color: active ? JjColors.accent : Colors.orangeAccent)),
|
||||
if (x.schedule.isNotEmpty)
|
||||
Text(x.schedule.join(' | '), style: const TextStyle(fontSize: 11, fontFamily: 'monospace', color: JjColors.textDim)),
|
||||
Text(tr('누르면 일정 바꾸기'), style: const TextStyle(fontSize: 10, color: JjColors.textDim)),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
if (st != null)
|
||||
Text('${tr('마지막')}: ${st.$1.hour.toString().padLeft(2, '0')}:${st.$1.minute.toString().padLeft(2, '0')} ${st.$2}',
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
_DiskInfo(path: x.target),
|
||||
]),
|
||||
),
|
||||
// 맞출 것 (바뀐 파일) - 자동으로 다시 셈
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 32, top: 8),
|
||||
child: pend == null
|
||||
? Text(tr('맞출 것을 세는 중…'), style: const TextStyle(fontSize: 12, color: JjColors.textDim))
|
||||
: pend.isEmpty
|
||||
? Text(tr('맞출 것 없음 (원본과 대상이 같습니다)'), style: const TextStyle(fontSize: 12, color: Colors.greenAccent))
|
||||
: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(
|
||||
trf('맞출 것 {0}개{1}', [pend.length >= 500 ? '500+' : pend.length, active ? '' : tr(' · 다음 시작 시간에 맞춥니다')]),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 140),
|
||||
decoration: BoxDecoration(color: JjColors.bg, borderRadius: BorderRadius.circular(6)),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: ListView(shrinkWrap: true, children: [
|
||||
for (final f in pend.take(100))
|
||||
Text(f,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5, fontFamily: 'monospace', color: f.startsWith('− ') ? Colors.redAccent : null)),
|
||||
if (pend.length > 100) Text(trf('… 외 {0}개', [pend.length - 100]), style: const TextStyle(fontSize: 11)),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(children: [
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: live == null ? null : () => live.refreshPending(x),
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: Text(tr('다시 세기')),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: live == null || running ? null : () => live.syncNow(x),
|
||||
icon: const Icon(Icons.sync, size: 18),
|
||||
label: Text(tr('지금 맞추기')),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
await CopyCenter.of(c).fromLiveSync(x);
|
||||
if (context.mounted) {
|
||||
DefaultTabController.of(context).animateTo(0);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('복사 · rsync 목록으로 옮겼습니다.'))));
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.swap_horiz, size: 18),
|
||||
label: Text(tr('복사 · rsync 로 이동')),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: tr('지우기'),
|
||||
icon: const Icon(Icons.delete_outline, color: JjColors.textDim),
|
||||
onPressed: () => c.updateSettings((y) => y.liveSyncPairs = [
|
||||
for (final o in y.liveSyncPairs) if (!(o.source == x.source && o.target == x.target)) o,
|
||||
]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../core/cron_window.dart';
|
||||
import '../l10n/tr.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// 요일 이름 (0 = 일요일)
|
||||
List<String> weekdayNames() => [tr('일'), tr('월'), tr('화'), tr('수'), tr('목'), tr('금'), tr('토')];
|
||||
|
||||
/// 일정 요약: "계속" · cron 줄 · 지금 상태 (동작 중 ~ 끝 / 다음 시작)
|
||||
String scheduleSummary(List<String> lines, [DateTime? now]) {
|
||||
final t = now ?? DateTime.now();
|
||||
if (lines.isEmpty) return tr('계속 (앱이 켜져 있는 동안)');
|
||||
String hm(DateTime d) => '${weekdayNames()[d.weekday % 7]} ${d.hour.toString().padLeft(2, '0')}:00';
|
||||
final end = scheduleEnd(lines, t);
|
||||
if (scheduleActive(lines, t)) return trf('동작 중 · {0} 까지', [end == null ? '…' : hm(end)]);
|
||||
final next = scheduleNextStart(lines, t);
|
||||
return trf('대기 · 다음 시작 {0}', [next == null ? '-' : hm(next)]);
|
||||
}
|
||||
|
||||
/// 일정 편집: 계속 / 시간 지정 (요일 × 시 격자를 누르거나 끌어서 칠하기, cron 글로도). 취소면 null.
|
||||
Future<List<String>?> editSchedule(BuildContext context, List<String> initial) =>
|
||||
showDialog<List<String>>(context: context, builder: (_) => _ScheduleDialog(initial: initial));
|
||||
|
||||
class _ScheduleDialog extends StatefulWidget {
|
||||
final List<String> initial;
|
||||
const _ScheduleDialog({required this.initial});
|
||||
|
||||
@override
|
||||
State<_ScheduleDialog> createState() => _ScheduleDialogState();
|
||||
}
|
||||
|
||||
class _ScheduleDialogState extends State<_ScheduleDialog> {
|
||||
late bool _always = widget.initial.isEmpty;
|
||||
late Set<int> _cells = widget.initial.isEmpty ? {for (var d = 1; d <= 5; d++) for (var h = 9; h < 18; h++) d * 24 + h} : gridFromCron(widget.initial);
|
||||
|
||||
/// 격자로 나타낼 수 없는 줄 (일 · 월을 정한 cron) 은 글로만 둔다
|
||||
late List<String> _extra = [for (final l in widget.initial) if (!(CronExpr.tryParse(l)?.gridable ?? false)) l];
|
||||
late final _text = TextEditingController(text: _lines.join('\n'));
|
||||
String? _error;
|
||||
|
||||
List<String> get _lines => [...cronFromGrid(_cells), ..._extra];
|
||||
|
||||
void _syncText() => _text.text = _lines.join('\n');
|
||||
|
||||
void _set(Set<int> cells) {
|
||||
setState(() {
|
||||
_cells = cells;
|
||||
_error = null;
|
||||
});
|
||||
_syncText();
|
||||
}
|
||||
|
||||
void _applyText() {
|
||||
final lines = _text.text.split('\n').map((l) => l.trim()).where((l) => l.isNotEmpty).toList();
|
||||
for (final l in lines) {
|
||||
try {
|
||||
CronExpr.parse(l);
|
||||
} on FormatException catch (e) {
|
||||
setState(() => _error = '${e.message}: $l');
|
||||
return;
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_cells = gridFromCron(lines);
|
||||
_extra = [for (final l in lines) if (!CronExpr.parse(l).gridable) l];
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_text.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lines = _lines;
|
||||
return AlertDialog(
|
||||
title: Text(tr('동기화 시간')),
|
||||
content: SizedBox(
|
||||
width: 760,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Wrap(spacing: 8, children: [
|
||||
ChoiceChip(
|
||||
label: Text(tr('계속')),
|
||||
selected: _always,
|
||||
onSelected: (_) => setState(() => _always = true),
|
||||
),
|
||||
ChoiceChip(
|
||||
label: Text(tr('시간 지정 (cron)')),
|
||||
selected: !_always,
|
||||
onSelected: (_) => setState(() => _always = false),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_always
|
||||
? tr('앱이 켜져 있는 동안 늘 동기화합니다.')
|
||||
: tr('칠한 시간에만 동기화하고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다. '
|
||||
'칸을 누르거나 끌어서 칠합니다 (요일 이름 · 시 숫자를 누르면 그 줄 전체).'),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.textDim),
|
||||
),
|
||||
if (!_always) ...[
|
||||
const SizedBox(height: 10),
|
||||
_WeekGrid(cells: _cells, onChanged: _set),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(spacing: 6, runSpacing: 6, children: [
|
||||
for (final (label, cells) in [
|
||||
(tr('평일 9~18시'), {for (var d = 1; d <= 5; d++) for (var h = 9; h < 18; h++) d * 24 + h}),
|
||||
(tr('밤 0~6시'), {for (var d = 0; d < 7; d++) for (var h = 0; h < 6; h++) d * 24 + h}),
|
||||
(tr('주말 종일'), {for (final d in [0, 6]) for (var h = 0; h < 24; h++) d * 24 + h}),
|
||||
(tr('매일 종일'), {for (var i = 0; i < 7 * 24; i++) i}),
|
||||
(tr('모두 지우기'), <int>{}),
|
||||
])
|
||||
ActionChip(label: Text(label), onPressed: () => _set(cells)),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Text(tr('cron (분 시 일 월 요일) · 한 줄에 하나'), style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
TextField(
|
||||
controller: _text,
|
||||
minLines: 2,
|
||||
maxLines: 5,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: _error,
|
||||
helperText: tr('예: 0 9-17 * * 1-5 (평일 9시~17시 59분) · 0 0-5 * * * (매일 0~5시). 분은 무시하고 1시간 단위'),
|
||||
suffixIcon: IconButton(tooltip: tr('글을 격자에 반영'), icon: const Icon(Icons.check), onPressed: _applyText),
|
||||
),
|
||||
onSubmitted: (_) => _applyText(),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(lines.isEmpty ? tr('칠한 시간이 없어 동기화하지 않습니다.') : scheduleSummary(lines),
|
||||
style: const TextStyle(fontSize: 12, color: JjColors.accent)),
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))),
|
||||
FilledButton(
|
||||
onPressed: _error != null ? null : () => Navigator.pop(context, _always ? const <String>[] : lines),
|
||||
child: Text(tr('저장')),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 요일 (세로 7) × 시 (가로 24) 격자. 누르면 켜기 · 끄기, 끌면 첫 칸의 반대로 칠하기.
|
||||
class _WeekGrid extends StatefulWidget {
|
||||
final Set<int> cells;
|
||||
final ValueChanged<Set<int>> onChanged;
|
||||
const _WeekGrid({required this.cells, required this.onChanged});
|
||||
|
||||
@override
|
||||
State<_WeekGrid> createState() => _WeekGridState();
|
||||
}
|
||||
|
||||
class _WeekGridState extends State<_WeekGrid> {
|
||||
static const _label = 34.0, _head = 18.0, _rowH = 26.0;
|
||||
bool? _paint;
|
||||
Set<int> _work = {};
|
||||
|
||||
int? _cellAt(Offset pos, double cellW) {
|
||||
final x = pos.dx - _label, y = pos.dy - _head;
|
||||
if (x < 0 || y < 0) return null;
|
||||
final h = (x / cellW).floor(), d = (y / _rowH).floor();
|
||||
if (h < 0 || h > 23 || d < 0 || d > 6) return null;
|
||||
return d * 24 + h;
|
||||
}
|
||||
|
||||
void _toggleRow(int d) {
|
||||
final all = [for (var h = 0; h < 24; h++) d * 24 + h];
|
||||
final on = all.every(widget.cells.contains);
|
||||
widget.onChanged({...widget.cells}..removeAll(all)..addAll(on ? const [] : all));
|
||||
}
|
||||
|
||||
void _toggleCol(int h) {
|
||||
final all = [for (var d = 0; d < 7; d++) d * 24 + h];
|
||||
final on = all.every(widget.cells.contains);
|
||||
widget.onChanged({...widget.cells}..removeAll(all)..addAll(on ? const [] : all));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) {
|
||||
final cellW = (box.maxWidth - _label) / 24;
|
||||
final names = weekdayNames();
|
||||
void paintAt(Offset pos) {
|
||||
final i = _cellAt(pos, cellW);
|
||||
if (i == null || _paint == null) return;
|
||||
final next = {..._work};
|
||||
_paint! ? next.add(i) : next.remove(i);
|
||||
if (next.length != _work.length) {
|
||||
_work = next;
|
||||
widget.onChanged(next);
|
||||
}
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: _head + _rowH * 7,
|
||||
child: Stack(children: [
|
||||
// 시 숫자 (누르면 그 시간 전체 요일)
|
||||
for (var h = 0; h < 24; h++)
|
||||
Positioned(
|
||||
left: _label + h * cellW,
|
||||
top: 0,
|
||||
width: cellW,
|
||||
height: _head,
|
||||
child: InkWell(
|
||||
onTap: () => _toggleCol(h),
|
||||
child: Center(
|
||||
child: Text(h % 3 == 0 ? '$h' : '', style: const TextStyle(fontSize: 10, color: JjColors.textDim)),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 요일 이름 (누르면 그 요일 전체)
|
||||
for (var d = 0; d < 7; d++)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: _head + d * _rowH,
|
||||
width: _label,
|
||||
height: _rowH,
|
||||
child: InkWell(
|
||||
onTap: () => _toggleRow(d),
|
||||
child: Center(
|
||||
child: Text(names[d],
|
||||
style: TextStyle(fontSize: 12, color: d == 0 ? Colors.redAccent : d == 6 ? Colors.lightBlueAccent : null)),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: _label,
|
||||
top: _head,
|
||||
right: 0,
|
||||
height: _rowH * 7,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapUp: (e) {
|
||||
final i = _cellAt(e.localPosition + const Offset(_label, _head), cellW);
|
||||
if (i == null) return;
|
||||
final next = {...widget.cells};
|
||||
next.contains(i) ? next.remove(i) : next.add(i);
|
||||
widget.onChanged(next);
|
||||
},
|
||||
onPanStart: (e) {
|
||||
final i = _cellAt(e.localPosition + const Offset(_label, _head), cellW);
|
||||
_work = {...widget.cells};
|
||||
_paint = i == null ? null : !widget.cells.contains(i);
|
||||
paintAt(e.localPosition + const Offset(_label, _head));
|
||||
},
|
||||
onPanUpdate: (e) => paintAt(e.localPosition + const Offset(_label, _head)),
|
||||
onPanEnd: (_) => _paint = null,
|
||||
child: CustomPaint(painter: _GridPainter(widget.cells, cellW, _rowH)),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class _GridPainter extends CustomPainter {
|
||||
final Set<int> cells;
|
||||
final double cellW, rowH;
|
||||
_GridPainter(this.cells, this.cellW, this.rowH);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final on = Paint()..color = JjColors.accent;
|
||||
final off = Paint()..color = JjColors.border.withValues(alpha: 0.45);
|
||||
for (var d = 0; d < 7; d++) {
|
||||
for (var h = 0; h < 24; h++) {
|
||||
final r = Rect.fromLTWH(h * cellW + 1, d * rowH + 1, cellW - 2, rowH - 2);
|
||||
canvas.drawRRect(RRect.fromRectAndRadius(r, const Radius.circular(3)), cells.contains(d * 24 + h) ? on : off);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_GridPainter old) => old.cells != cells || old.cellW != cellW;
|
||||
}
|
||||
|
||||
/// 읽기 전용 작은 격자 (모니터링 · 설정의 목록에서 일정 한눈에)
|
||||
class WeekGridPreview extends StatelessWidget {
|
||||
final List<String> lines;
|
||||
final double width;
|
||||
const WeekGridPreview({super.key, required this.lines, this.width = 168});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cells = lines.isEmpty ? {for (var i = 0; i < 168; i++) i} : gridFromCron(lines);
|
||||
final cellW = width / 24;
|
||||
return SizedBox(
|
||||
width: width,
|
||||
height: cellW * 7 * 1.2,
|
||||
child: CustomPaint(painter: _GridPainter(cells, cellW, cellW * 1.2)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
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/app/copy_center.dart';
|
||||
import 'package:jj_mkvmaker/app/live_sync.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/cron_window.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:jj_mkvmaker/ui/monitor_page.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
void main() {
|
||||
group('cron 일정', () {
|
||||
// 2026-10-05 은 월요일
|
||||
final mon10 = DateTime(2026, 10, 5, 10, 30), mon20 = DateTime(2026, 10, 5, 20), sat10 = DateTime(2026, 10, 10, 10);
|
||||
|
||||
test('읽기 · 시간 창 (시 · 요일 · 범위 · 간격 · 이름)', () {
|
||||
final e = CronExpr.parse('0 9-17 * * 1-5');
|
||||
expect([e.activeAt(mon10), e.activeAt(mon20), e.activeAt(sat10)], [true, false, false]);
|
||||
expect(CronExpr.parse('0 */2 * * SAT,SUN').activeAt(sat10), isTrue);
|
||||
expect(CronExpr.parse('0 * * * 7').activeAt(DateTime(2026, 10, 11, 3)), isTrue); // 7 = 일요일
|
||||
expect(CronExpr.parse('0 10 5 10 *').activeAt(mon10), isTrue); // 10월 5일 10시
|
||||
expect(() => CronExpr.parse('0 25 * * *'), throwsFormatException);
|
||||
expect(() => CronExpr.parse('0 9 * *'), throwsFormatException);
|
||||
expect(CronExpr.tryParse('x'), isNull);
|
||||
});
|
||||
|
||||
test('계속 · 다음 시작 · 끝나는 때', () {
|
||||
expect(scheduleActive(const [], sat10), isTrue);
|
||||
const work = ['0 9-17 * * 1-5'];
|
||||
expect(scheduleNextStart(work, mon20), DateTime(2026, 10, 6, 9));
|
||||
expect(scheduleNextStart(work, mon10), isNull);
|
||||
expect(scheduleEnd(work, mon10), DateTime(2026, 10, 5, 18));
|
||||
expect(scheduleNextStart(work, sat10), DateTime(2026, 10, 12, 9)); // 다음 월요일
|
||||
});
|
||||
|
||||
test('격자 ↔ cron (같은 시간대 요일끼리 한 줄)', () {
|
||||
final cells = {for (var d = 1; d <= 5; d++) for (var h = 9; h < 18; h++) d * 24 + h, 6 * 24 + 22, 6 * 24 + 23};
|
||||
final lines = cronFromGrid(cells);
|
||||
expect(lines, ['0 9-17 * * 1-5', '0 22-23 * * 6']);
|
||||
expect(gridFromCron(lines), cells);
|
||||
expect(cronFromGrid({for (var i = 0; i < 168; i++) i}), ['0 * * * *']);
|
||||
expect(gridFromCron(['0 10 5 10 *']), isEmpty); // 일 · 월을 정한 줄은 격자로 못 그림
|
||||
});
|
||||
});
|
||||
|
||||
group('복사 기억 · lsync', () {
|
||||
late Directory tmp;
|
||||
late AppController c;
|
||||
late String src, dst;
|
||||
setUp(() {
|
||||
tmp = Directory.systemTemp.createTempSync('jj_mon_');
|
||||
src = p.join(tmp.path, 'src');
|
||||
dst = p.join(tmp.path, 'dst');
|
||||
File(p.join(src, 'a.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('a');
|
||||
File(p.join(src, 'sub', 'b.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('b');
|
||||
Directory(dst).createSync();
|
||||
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
});
|
||||
tearDown(() => tmp.deleteSync(recursive: true));
|
||||
|
||||
test('같은 복사는 기억한 옵션 · 실행 결과 기록 · lsync 로 이동 · 다시 복사 목록으로', () async {
|
||||
final center = CopyCenter(c);
|
||||
final t = await center.remember([src], dst);
|
||||
await center.update(t.copyWith(options: '-a --custom', bandwidthKBps: 77));
|
||||
final again = await center.remember([src], dst);
|
||||
expect([again.id, again.options, again.bandwidthKBps], [t.id, '-a --custom', 77]);
|
||||
expect(center.tasks, hasLength(1));
|
||||
|
||||
final job = await center.start(center.tasks.single);
|
||||
await job!.done;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
expect(File(p.join(dst, 'src', 'sub', 'b.txt')).readAsStringSync(), 'b');
|
||||
expect(center.tasks.single.lastResult, 'done');
|
||||
expect(center.tasks.single.lastFiles, 2);
|
||||
|
||||
// lsync 로: 원본 → 대상\이름
|
||||
expect(await center.toLiveSync(center.tasks.single), 1);
|
||||
expect(center.tasks, isEmpty);
|
||||
final pair = c.settings.liveSyncPairs.single;
|
||||
expect([pair.source, pair.target], [src, p.join(dst, 'src')]);
|
||||
|
||||
// 다시 복사 목록으로 (안의 것 → 대상)
|
||||
final back = await center.fromLiveSync(pair);
|
||||
expect(c.settings.liveSyncPairs, isEmpty);
|
||||
expect([back.contents, back.dest], [true, p.join(dst, 'src')]);
|
||||
expect(back.method, Platform.isWindows ? 'rsync' : 'builtin'); // rsync 로 옮겨 온다 (쓸 수 있으면)
|
||||
File(p.join(src, 'new.txt')).writeAsStringSync('n');
|
||||
// (시험 환경에는 rsync 가 없어 현재 방식으로 실행 - rsync 실행은 sync_tools_test)
|
||||
final j2 = await center.start(back.copyWith(method: 'builtin'));
|
||||
await j2!.done;
|
||||
expect(File(p.join(dst, 'src', 'new.txt')).readAsStringSync(), 'n'); // 안의 것 모드: 이름 폴더가 또 생기지 않음
|
||||
expect(Directory(p.join(dst, 'src', 'src')).existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('lsync: 맞출 것 자동으로 세기 · 일정 밖이면 맞추지 않음', () async {
|
||||
final target = p.join(dst, 'mirror');
|
||||
// 일정: 지금이 아닌 시간 (지금 시 + 2)
|
||||
final off = (DateTime.now().hour + 2) % 24;
|
||||
final pair = LiveSyncPair(src, target, schedule: ['0 $off * * *'], delete: true);
|
||||
expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt']));
|
||||
expect(LiveSync.activeNow(pair), isFalse);
|
||||
c.settings.liveSyncPairs = [pair];
|
||||
final live = LiveSync(c)..start();
|
||||
await Future<void>.delayed(const Duration(seconds: 4)); // 모아서 3초 뒤
|
||||
expect(live.pending[LiveSync.keyOf(pair)], hasLength(2));
|
||||
expect(Directory(target).existsSync(), isFalse); // 일정 밖: 맞추지 않음
|
||||
// 지금 맞추기는 늘 된다
|
||||
await live.syncNow(pair);
|
||||
expect(live.pending[LiveSync.keyOf(pair)], isEmpty);
|
||||
File(p.join(target, 'extra.txt')).writeAsStringSync('x');
|
||||
expect(await LiveSync.diff(pair), ['− extra.txt']);
|
||||
live.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('모니터링 화면: 복사 목록 · 옵션 고치기 → 현재 유지 / 반영 후 실행, lsync 탭 · 일정', (tester) async {
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_monui_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..copyMonitor = true
|
||||
..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, '..'), method: 'builtin')]
|
||||
..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'), schedule: const ['0 9-17 * * 1-5'])];
|
||||
tester.view.physicalSize = const Size(1500, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(MaterialApp(home: MonitorPage(c: c)));
|
||||
await tester.pump();
|
||||
expect(find.text('아직 실행 안 함'), findsOneWidget);
|
||||
expect(find.text('실행'), findsOneWidget);
|
||||
expect(find.text('lsync 로 이동'), findsOneWidget);
|
||||
|
||||
// 속도 제한을 고치면 [저장] → 묻기 → 현재 유지 (실행 안 함)
|
||||
await tester.enterText(find.widgetWithText(TextField, '속도 제한'), '500');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('저장'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('옵션을 저장했습니다'), findsOneWidget);
|
||||
expect(find.text('설정 반영 후 실행'), findsOneWidget);
|
||||
await tester.tap(find.text('현재 유지'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(c.settings.copyTasks.single.bandwidthKBps, 500);
|
||||
expect(CopyCenter.of(c).jobs, isEmpty);
|
||||
|
||||
// lsync 탭: 일정 요약 · 맞추기 버튼
|
||||
await tester.tap(find.text('lsync (실시간 동기화)'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('0 9-17 * * 1-5'), findsOneWidget);
|
||||
expect(find.text('지금 맞추기'), findsOneWidget);
|
||||
expect(find.text('복사 · rsync 로 이동'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue