From 5601f9d4fbf86c7583bb4bac9bb73d76deba66f0 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:38:20 +0900 Subject: [PATCH] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B8=80=EC=9E=90=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=EA=B8=B0=EA=B0=80=20=EB=86=93=EC=B9=98?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83=20=EC=9E=A1=EA=B8=B0=20=C2=B7=20=EC=B0=BE?= =?UTF-8?q?=EC=9D=80=20=EB=B2=88=EC=97=AD=20=EB=B9=A0=EC=A7=90=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=98=EA=B8=B0=20(60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tool/l10n: trf( 뒤에서 줄을 바꾼 열쇠도 (앞 200글자까지 봄). tr 로 감싸지 않은 한글 글이 사전에 없으면 "번역 안 된 글" 로 실패 (tr(변수) 로 쓰이는 글 포함). 일부러 번역하지 않는 자료는 // l10n-skip (줄) · // l10n-skip-file. - 찾아 고친 것: Rsync 위아래 배치 버튼 "위 → 아래 · 위 ← 아래 · 위 ⇄ 아래", 컴포넌트 "AI 그림" 설명, AI 목록의 NVIDIA 라이선스 이름 (받기 화면), 라이선스 화면의 이름 · stable-diffusion.cpp 요약 (화면을 열 때 언어로), Android 되돌리기 안내 한 줄, 파일 작업 오류 글 (같은 이름 · 원본 없음 · 자기 안으로 · 쓸 수 없는 이름). - 자료로 표시: 음성인식 말버릇 목록 (ai_subtitle) · 실제 폴더 이름 · PowerShell 스크립트. - l10n_check_test: check.py 가 통과하는지, 검사기가 줄 바꾼 trf( · tr(변수) 글을 잡고 skip · raw 는 넘기는지. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 16 +++++++++ assets/l10n/ja.json | 16 +++++++++ assets/l10n/zh-Hans.json | 16 +++++++++ lib/core/ai_catalog.dart | 8 +++-- lib/core/ai_subtitle.dart | 1 + lib/core/file_ops.dart | 20 +++++------ lib/main.dart | 1 + lib/platform/windows/start_menu.dart | 1 + lib/ui/ai_settings.dart | 8 ++--- lib/ui/license_texts.dart | 12 +++---- test/l10n_check_test.dart | 50 ++++++++++++++++++++++++++++ tool/l10n/check.py | 9 ++++- tool/l10n/extract_keys.py | 20 +++++++++-- 13 files changed, 153 insertions(+), 25 deletions(-) create mode 100644 test/l10n_check_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 256af1d..2475f9f 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -106,6 +106,7 @@ "Android 11 부터 다른 앱의 데이터 폴더 (Android/data · Android/obb) 는 어떤 파일 앱도 열 수 없습니다. 그 앱 안의 내보내기 · 공유로 파일을 꺼내세요.": "Since Android 11, no file app can open other apps' data folders (Android/data · Android/obb). Use export or share inside that app to get the files.", "Android 가 막은 폴더 (Android/data 등) 이거나 \"모든 파일에 대한 접근\" 권한이 없습니다.": "This folder is blocked by Android (such as Android/data), or \"All files access\" is not allowed.", "Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "rsync cannot be downloaded on Android. Specify it yourself.", + "Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서 v{0} 설치 파일과 아래 \"남는 것\" 을 Download/JJ_MKVMaker 에 저장한 뒤 이 앱을 지우고 (Android 확인 창), 파일 앱에서 그 APK 를 눌러 설치합니다.": "Android cannot install an older version over the current app. So the v{0} installer and the \"kept\" items below are saved to Download/JJ_MKVMaker, then this app is removed (Android confirmation), and you install it by tapping that APK in a file app.", "Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서:\n1. v{0} 설치 파일과 지금 설정 (동영상 목록 · 즐겨찾기 포함) 을 Download/JJ_MKVMaker 에 저장합니다.\n2. 이 앱을 지웁니다 (Android 확인 창).\n3. 파일 앱에서 Download/JJ_MKVMaker 의 APK 를 눌러 설치합니다.\n4. 이 기능이 있는 버전이면 시작할 때 저장해 둔 설정을 되살릴지 묻습니다. 더 예전 버전은 설정을 다시 해야 합니다.\n※ 자막 API 키 · 비밀번호는 저장하지 않으므로 다시 넣어야 합니다.": "Android cannot install a lower version over the current app. So:\n1. Save the v{0} install file and the current settings (including the video list and bookmarks) to Download/JJ_MKVMaker.\n2. Uninstall this app (Android confirmation).\n3. Tap the APK in Download/JJ_MKVMaker in a file manager to install it.\n4. Versions with this feature ask at startup whether to restore the saved settings. Older versions need to be set up again.\n* Subtitle API keys and passwords are not saved, so enter them again.", "Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "Press [Install] on the Android install screen. Settings, downloaded files and AI models stay as they are.\n(The first time, you may need to turn on \"Allow from this source\")", "Android 에서는 앱을 지운 뒤 예전 버전을 설치합니다. 받은 AI 모델 · 비밀번호 · 브라우저 로그인 등은 사라집니다 (남는 것과 사라지는 것은 다음 창에서 보여 줍니다).": "On Android the app is removed before the older version is installed. Downloaded AI models, passwords, browser logins and more are lost (the next window shows what stays and what is lost).", @@ -142,6 +143,7 @@ "JavaScript 사용": "Enable JavaScript", "LCM-LoRA (SD1.5, 4단계로 빠르게)": "LCM-LoRA (SD1.5, fast in 4 steps)", "LibreOffice 를 먼저 설치해야 합니다": "LibreOffice must be installed first", + "MIT License - https://github.com/leejet/stable-diffusion.cpp ({0})\n\n함께 묶인 구성 요소: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json · miniz · stb (MIT) · zip (Unlicense)": "MIT License - https://github.com/leejet/stable-diffusion.cpp ({0})\n\nBundled components: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json · miniz · stb (MIT) · zip (Unlicense)", "MKV · AI 작업": "MKV · AI task", "MKV · 자막 (jj_mkv)": "MKV · subtitles (jj_mkv)", "MKV · 자막 저장 위치": "Where to save MKV · subtitles", @@ -171,7 +173,9 @@ "NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 안전 저장소에만 두고 설정 파일에는 쓰지 않습니다. 한 번 넣으면 업데이트 뒤에도 남습니다.": "Open, copy and sync WebDAV folders on a NAS or cloud in the file explorer and the Rsync screen (tab next to \"SD card\" at the top). The password is kept only in this device's secure storage, never in the settings file, and stays after updates.", "NLLB 언어 코드가 아닙니다: {0}": "Not an NLLB language code: {0}", "NLLB-200 번역 (600M)": "NLLB-200 translation (600M)", + "NLLB-200 번역 모델 (CC BY-NC 4.0 · 비상업적 이용만)": "NLLB-200 translation model (CC BY-NC 4.0 · non-commercial only)", "NVIDIA CUDA 12 런타임 (CUDA 판과 함께)": "NVIDIA CUDA 12 runtime (with the CUDA build)", + "NVIDIA CUDA Toolkit EULA (재배포 허용 파일)": "NVIDIA CUDA Toolkit EULA (redistributable files)", "OpenAI 이미지 API 형식": "OpenAI image API format", "OpenSubtitles API 키 또는 로그인 정보가 올바르지 않습니다. ({0})": "OpenSubtitles API key or sign-in details are incorrect. ({0})", "OpenSubtitles 무료 API 키가 필요합니다.\n1. opensubtitles.com 가입 (무료)\n2. 프로필 → API consumers → New consumer 에서 키 만들기\n3. 아래 칸 또는 환경 설정에 키 붙여넣기": "A free OpenSubtitles API key is required.\n1. Sign up at opensubtitles.com (free)\n2. Profile → API consumers → New consumer to create a key\n3. Paste the key in the box below or in Settings", @@ -216,6 +220,7 @@ "Whale 에서 가져옴": "Imported from Whale", "Whisper base (빠름)": "Whisper base (fast)", "Whisper small (정확)": "Whisper small (accurate)", + "Whisper 모델 (OpenAI)": "Whisper models (OpenAI)", "Windows 기본 브라우저": "Windows default browser", "Windows 기본 프로그램": "Windows default program", "Windows 알림 설정 열기": "Open Windows notification settings", @@ -283,6 +288,9 @@ "v{0} 은 복사해 둔 모델을 되살리지 못합니다. 이 기능이 있는 판으로 다시 올리면 그때 되살립니다.": "v{0} cannot restore copied models. They will be restored when you update again to a version with this feature.", "v{0} 은 저장해 둔 설정을 되살리지 못해 설정을 다시 해야 합니다.": "v{0} cannot restore the saved settings, so you will need to set things up again.", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "Downloaded v{0} and verified the file (SHA256).\n{1}", + "whisper.cpp · ggml (whisper_ggml 안)": "whisper.cpp · ggml (inside whisper_ggml)", + "yt-dlp (실행 파일 yt-dlp.exe)": "yt-dlp (executable yt-dlp.exe)", + "yt-dlp (실행 파일에 묶인 구성 요소)": "yt-dlp (components bundled in the executable)", "yt-dlp 를 새 버전으로 바꿨습니다": "Updated yt-dlp to a new version", "yt-dlp 를 실행할 수 없습니다: {0}": "Cannot run yt-dlp: {0}", "yt-dlp 업데이트 확인 실패 (지금 버전으로 받습니다): {0}": "yt-dlp update check failed (downloading with the current version): {0}", @@ -398,6 +406,7 @@ "가져온 즐겨찾기 ({0})": "Imported bookmarks ({0})", "가져올 즐겨찾기가 없습니다.": "No bookmarks to import.", "같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)", + "같은 이름이 이미 있습니다": "An item with the same name already exists", "같은 이름이 이미 있습니다: {0}": "Already exists with the same name: {0}", "같은 이름이 있는지 확인하는 중 …": "Checking for items with the same name …", "같은 이름이 있을 때": "When a file with the same name exists", @@ -478,6 +487,7 @@ "그림을 읽을 수 없습니다": "Cannot read the image", "그림을 페이지로 넣기 (이 페이지 뒤)": "Add images as pages (after this page)", "그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "Almost the same image, and the last step is about 10× faster (when downloaded)", + "글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "Text → image · image → image (on this device; download models in Settings)", "글을 격자에 반영": "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", @@ -1301,6 +1311,7 @@ "원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "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.", "원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.": "The source will be empty, so this live sync is turned off. See progress in the [Copy · rsync] tab.", "원본이 비어 있습니다. 대상에서 {0}개를 지울까요?": "The source is empty. Delete {0} items from the target?", + "원본이 없습니다": "The source does not exist", "원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}": "Source not found (deleted or moved elsewhere): {0}", "원어 (영상 속 언어)": "Source language (spoken in the video)", "원어({0})와 같은 언어로는 번역하지 않습니다.": "Will not translate into the same language as the source ({0}).", @@ -1316,6 +1327,9 @@ "웹 브라우저의 시작 · 🏠 주소": "Start · 🏠 page of the web browser", "웹 페이지 자동 번역": "Translate web pages automatically", "웹 페이지를 번역하지 못했습니다: {0}": "Could not translate the web page: {0}", + "위 ← 아래": "Top ← bottom", + "위 → 아래": "Top → bottom", + "위 ⇄ 아래": "Top ⇄ bottom", "위 폴더": "Parent folder", "위 폴더로": "Go up", "위로": "Up", @@ -1846,6 +1860,8 @@ "폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "Folders are shown, but the videos and files inside need this permission. Turn on JJ_MKVMaker on the permission screen and come back.", "폴더를 만들 수 없습니다: {0}": "Cannot create the folder: {0}", "폴더를 자기 안으로 복사 · 이동할 수 없습니다": "Cannot copy or move a folder into itself", + "폴더를 자기 안으로 복사할 수 없습니다": "Cannot copy a folder into itself", + "폴더를 자기 안으로 옮길 수 없습니다": "Cannot move a folder into itself", "폴더를 자기 자신 안으로 {0} 수 없습니다: {1}": "Cannot {0} a folder into itself: {1}", "폴더만 lsync 로 옮길 수 있습니다.": "Only folders can be moved to lsync.", "폴란드어": "Polish", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 32bb9bf..2f788f8 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -106,6 +106,7 @@ "Android 11 부터 다른 앱의 데이터 폴더 (Android/data · Android/obb) 는 어떤 파일 앱도 열 수 없습니다. 그 앱 안의 내보내기 · 공유로 파일을 꺼내세요.": "Android 11 以降、他のアプリのデータフォルダー (Android/data・Android/obb) はどのファイルアプリでも開けません。そのアプリ内のエクスポート・共有でファイルを取り出してください。", "Android 가 막은 폴더 (Android/data 등) 이거나 \"모든 파일에 대한 접근\" 권한이 없습니다.": "Android が制限しているフォルダー (Android/data など) か、「すべてのファイルへのアクセス」権限がありません。", "Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "Android では rsync をダウンロードできません。直接指定してください。", + "Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서 v{0} 설치 파일과 아래 \"남는 것\" 을 Download/JJ_MKVMaker 에 저장한 뒤 이 앱을 지우고 (Android 확인 창), 파일 앱에서 그 APK 를 눌러 설치합니다.": "Android では古いバージョンのアプリを今のアプリの上にインストールできません。そのため v{0} のインストールファイルと下の「残るもの」を Download/JJ_MKVMaker に保存してからこのアプリを削除し (Android の確認画面)、ファイルアプリでその APK をタップしてインストールします。", "Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서:\n1. v{0} 설치 파일과 지금 설정 (동영상 목록 · 즐겨찾기 포함) 을 Download/JJ_MKVMaker 에 저장합니다.\n2. 이 앱을 지웁니다 (Android 확인 창).\n3. 파일 앱에서 Download/JJ_MKVMaker 의 APK 를 눌러 설치합니다.\n4. 이 기능이 있는 버전이면 시작할 때 저장해 둔 설정을 되살릴지 묻습니다. 더 예전 버전은 설정을 다시 해야 합니다.\n※ 자막 API 키 · 비밀번호는 저장하지 않으므로 다시 넣어야 합니다.": "Android はバージョンの低いアプリを今のアプリの上にインストールできません。そこで:\n1. v{0} のインストールファイルと今の設定 (動画リスト · ブックマークを含む) を Download/JJ_MKVMaker に保存します。\n2. このアプリを削除します (Android の確認画面)。\n3. ファイルアプリで Download/JJ_MKVMaker の APK をタップしてインストールします。\n4. この機能があるバージョンなら起動時に保存した設定を復元するか尋ねます。それより古いバージョンは設定し直す必要があります。\n※ 字幕 API キー · パスワードは保存しないので再入力が必要です。", "Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "Android のインストール画面で[インストール]を押してください。設定・ダウンロードしたファイル・AI モデルはそのまま残ります。\n(初回は「この提供元のアプリを許可」をオンにする必要があるかもしれません)", "Android 에서는 앱을 지운 뒤 예전 버전을 설치합니다. 받은 AI 모델 · 비밀번호 · 브라우저 로그인 등은 사라집니다 (남는 것과 사라지는 것은 다음 창에서 보여 줍니다).": "Android ではアプリを削除してから古いバージョンをインストールします。ダウンロードした AI モデル・パスワード・ブラウザーのログインなどは消えます (残るもの・消えるものは次の画面で表示します)。", @@ -142,6 +143,7 @@ "JavaScript 사용": "JavaScript を使う", "LCM-LoRA (SD1.5, 4단계로 빠르게)": "LCM-LoRA (SD1.5、4 ステップで高速)", "LibreOffice 를 먼저 설치해야 합니다": "先に LibreOffice をインストールする必要があります", + "MIT License - https://github.com/leejet/stable-diffusion.cpp ({0})\n\n함께 묶인 구성 요소: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json · miniz · stb (MIT) · zip (Unlicense)": "MIT License - https://github.com/leejet/stable-diffusion.cpp ({0})\n\n同梱のコンポーネント: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json · miniz · stb (MIT) · zip (Unlicense)", "MKV · AI 작업": "MKV・AI 作業", "MKV · 자막 (jj_mkv)": "MKV・字幕(jj_mkv)", "MKV · 자막 저장 위치": "MKV・字幕の保存先", @@ -171,7 +173,9 @@ "NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 안전 저장소에만 두고 설정 파일에는 쓰지 않습니다. 한 번 넣으면 업데이트 뒤에도 남습니다.": "NAS・クラウドの WebDAV フォルダーをファイルエクスプローラー・Rsync 画面で開き、コピー・同期します (上部の「SD カード」の隣のタブ)。パスワードはこの端末の安全な保存領域にだけ保存し、設定ファイルには書きません。一度入力すればアップデート後も残ります。", "NLLB 언어 코드가 아닙니다: {0}": "NLLB の言語コードではありません: {0}", "NLLB-200 번역 (600M)": "NLLB-200 翻訳(600M)", + "NLLB-200 번역 모델 (CC BY-NC 4.0 · 비상업적 이용만)": "NLLB-200 翻訳モデル (CC BY-NC 4.0 · 非営利のみ)", "NVIDIA CUDA 12 런타임 (CUDA 판과 함께)": "NVIDIA CUDA 12 ランタイム (CUDA 版と一緒に)", + "NVIDIA CUDA Toolkit EULA (재배포 허용 파일)": "NVIDIA CUDA Toolkit EULA (再配布可能なファイル)", "OpenAI 이미지 API 형식": "OpenAI 画像 API 形式", "OpenSubtitles API 키 또는 로그인 정보가 올바르지 않습니다. ({0})": "OpenSubtitles の API キーまたはログイン情報が正しくありません。({0})", "OpenSubtitles 무료 API 키가 필요합니다.\n1. opensubtitles.com 가입 (무료)\n2. 프로필 → API consumers → New consumer 에서 키 만들기\n3. 아래 칸 또는 환경 설정에 키 붙여넣기": "OpenSubtitles の無料 API キーが必要です。\n1. opensubtitles.com に登録(無料)\n2. プロフィール → API consumers → New consumer でキーを作成\n3. 下の欄または環境設定にキーを貼り付け", @@ -216,6 +220,7 @@ "Whale 에서 가져옴": "Whale から取り込み", "Whisper base (빠름)": "Whisper base(高速)", "Whisper small (정확)": "Whisper small(高精度)", + "Whisper 모델 (OpenAI)": "Whisper モデル (OpenAI)", "Windows 기본 브라우저": "Windows の既定のブラウザー", "Windows 기본 프로그램": "Windows の既定のプログラム", "Windows 알림 설정 열기": "Windows の通知設定を開く", @@ -283,6 +288,9 @@ "v{0} 은 복사해 둔 모델을 되살리지 못합니다. 이 기능이 있는 판으로 다시 올리면 그때 되살립니다.": "v{0} はコピーしたモデルを復元できません。この機能がある版に戻すとその時に復元します。", "v{0} 은 저장해 둔 설정을 되살리지 못해 설정을 다시 해야 합니다.": "v{0} は保存した設定を復元できないため、設定し直す必要があります。", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "v{0} をダウンロードし、ファイル検証(SHA256)を終えました。\n{1}", + "whisper.cpp · ggml (whisper_ggml 안)": "whisper.cpp · ggml (whisper_ggml 内)", + "yt-dlp (실행 파일 yt-dlp.exe)": "yt-dlp (実行ファイル yt-dlp.exe)", + "yt-dlp (실행 파일에 묶인 구성 요소)": "yt-dlp (実行ファイルに同梱のコンポーネント)", "yt-dlp 를 새 버전으로 바꿨습니다": "yt-dlp を新しいバージョンに更新しました", "yt-dlp 를 실행할 수 없습니다: {0}": "yt-dlp を実行できません: {0}", "yt-dlp 업데이트 확인 실패 (지금 버전으로 받습니다): {0}": "yt-dlp の更新確認に失敗しました(現在のバージョンでダウンロードします): {0}", @@ -398,6 +406,7 @@ "가져온 즐겨찾기 ({0})": "取り込んだブックマーク({0})", "가져올 즐겨찾기가 없습니다.": "取り込むブックマークがありません。", "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", + "같은 이름이 이미 있습니다": "同じ名前のものが既にあります", "같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}", "같은 이름이 있는지 확인하는 중 …": "同じ名前があるか確認中 …", "같은 이름이 있을 때": "同じ名前があるとき", @@ -478,6 +487,7 @@ "그림을 읽을 수 없습니다": "画像を読み込めません", "그림을 페이지로 넣기 (이 페이지 뒤)": "画像をページとして追加 (このページの後)", "그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "画像はほぼ同じで、最後の段階が約 10 倍速くなります (ダウンロード済みの場合)", + "글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "テキスト → 画像 · 画像 → 画像 (端末内で。モデルは設定でダウンロード)", "글을 격자에 반영": "テキストをグリッドに反映", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字・ボタンの大きさです。上部バーの − ・ + でその都度変更でき、中央の数字を押すとこの大きさに戻ります(現在 {0}%)", "금": "金", @@ -1301,6 +1311,7 @@ "원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "元が変わると対象に合わせます。予定を決めるとその時間だけ同期し、それ以外は変更を貯めて次の開始時刻に同期します。", "원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.": "元が空になるため、このリアルタイム同期はオフにします。進捗は [コピー · rsync] タブで確認します。", "원본이 비어 있습니다. 대상에서 {0}개를 지울까요?": "元が空です。対象から {0} 個を削除しますか?", + "원본이 없습니다": "元のファイルがありません", "원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}": "元がありません (他の場所で削除または移動されました): {0}", "원어 (영상 속 언어)": "原語(動画内の言語)", "원어({0})와 같은 언어로는 번역하지 않습니다.": "原語({0})と同じ言語には翻訳しません。", @@ -1316,6 +1327,9 @@ "웹 브라우저의 시작 · 🏠 주소": "ウェブブラウザーの開始・🏠 ページ", "웹 페이지 자동 번역": "ウェブページを自動翻訳", "웹 페이지를 번역하지 못했습니다: {0}": "ウェブページを翻訳できませんでした: {0}", + "위 ← 아래": "上 ← 下", + "위 → 아래": "上 → 下", + "위 ⇄ 아래": "上 ⇄ 下", "위 폴더": "上のフォルダー", "위 폴더로": "上のフォルダーへ", "위로": "上へ", @@ -1846,6 +1860,8 @@ "폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "フォルダーは見えても、中の動画・ファイルはこの権限が必要です。許可画面で JJ_MKVMaker をオンにして戻ってください。", "폴더를 만들 수 없습니다: {0}": "フォルダーを作成できません: {0}", "폴더를 자기 안으로 복사 · 이동할 수 없습니다": "フォルダーを自分自身の中にコピー · 移動できません", + "폴더를 자기 안으로 복사할 수 없습니다": "フォルダーを自分自身の中にコピーできません", + "폴더를 자기 안으로 옮길 수 없습니다": "フォルダーを自分自身の中に移動できません", "폴더를 자기 자신 안으로 {0} 수 없습니다: {1}": "フォルダーを自分自身の中へ{0}ことはできません: {1}", "폴더만 lsync 로 옮길 수 있습니다.": "lsync へ移せるのはフォルダーだけです。", "폴란드어": "ポーランド語", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 636cd83..ad1e4a1 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -106,6 +106,7 @@ "Android 11 부터 다른 앱의 데이터 폴더 (Android/data · Android/obb) 는 어떤 파일 앱도 열 수 없습니다. 그 앱 안의 내보내기 · 공유로 파일을 꺼내세요.": "从 Android 11 起,任何文件应用都无法打开其他应用的数据文件夹 (Android/data · Android/obb)。请在该应用内使用导出或分享取出文件。", "Android 가 막은 폴더 (Android/data 등) 이거나 \"모든 파일에 대한 접근\" 권한이 없습니다.": "这是 Android 限制的文件夹 (如 Android/data),或没有“所有文件访问”权限。", "Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "Android 无法下载 rsync,请自行指定。", + "Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서 v{0} 설치 파일과 아래 \"남는 것\" 을 Download/JJ_MKVMaker 에 저장한 뒤 이 앱을 지우고 (Android 확인 창), 파일 앱에서 그 APK 를 눌러 설치합니다.": "Android 无法在当前应用之上安装较低版本。因此会先把 v{0} 安装文件和下面的“保留项”保存到 Download/JJ_MKVMaker,然后删除此应用(Android 确认窗口),再在文件应用中点按该 APK 进行安装。", "Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서:\n1. v{0} 설치 파일과 지금 설정 (동영상 목록 · 즐겨찾기 포함) 을 Download/JJ_MKVMaker 에 저장합니다.\n2. 이 앱을 지웁니다 (Android 확인 창).\n3. 파일 앱에서 Download/JJ_MKVMaker 의 APK 를 눌러 설치합니다.\n4. 이 기능이 있는 버전이면 시작할 때 저장해 둔 설정을 되살릴지 묻습니다. 더 예전 버전은 설정을 다시 해야 합니다.\n※ 자막 API 키 · 비밀번호는 저장하지 않으므로 다시 넣어야 합니다.": "Android 无法在当前应用上安装版本更低的应用。因此:\n1. 将 v{0} 安装文件和当前设置 (包括视频列表 · 书签) 保存到 Download/JJ_MKVMaker。\n2. 卸载此应用 (Android 确认窗口)。\n3. 在文件应用中点按 Download/JJ_MKVMaker 中的 APK 进行安装。\n4. 带有此功能的版本会在启动时询问是否恢复保存的设置。更早的版本需要重新设置。\n※ 字幕 API 密钥 · 密码不会保存,需要重新输入。", "Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "请在 Android 安装界面点击[安装]。设置、已下载的文件和 AI 模型都会保留。\n(首次可能需要开启“允许安装此来源的应用”)", "Android 에서는 앱을 지운 뒤 예전 버전을 설치합니다. 받은 AI 모델 · 비밀번호 · 브라우저 로그인 등은 사라집니다 (남는 것과 사라지는 것은 다음 창에서 보여 줍니다).": "在 Android 上会先删除应用再安装旧版本。已下载的 AI 模型、密码、浏览器登录等将丢失 (下一个窗口会列出保留和丢失的内容)。", @@ -142,6 +143,7 @@ "JavaScript 사용": "启用 JavaScript", "LCM-LoRA (SD1.5, 4단계로 빠르게)": "LCM-LoRA (SD1.5,4 步快速生成)", "LibreOffice 를 먼저 설치해야 합니다": "需要先安装 LibreOffice", + "MIT License - https://github.com/leejet/stable-diffusion.cpp ({0})\n\n함께 묶인 구성 요소: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json · miniz · stb (MIT) · zip (Unlicense)": "MIT License - https://github.com/leejet/stable-diffusion.cpp ({0})\n\n一同打包的组件: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json · miniz · stb (MIT) · zip (Unlicense)", "MKV · AI 작업": "MKV · AI 任务", "MKV · 자막 (jj_mkv)": "MKV · 字幕(jj_mkv)", "MKV · 자막 저장 위치": "MKV · 字幕保存位置", @@ -171,7 +173,9 @@ "NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 안전 저장소에만 두고 설정 파일에는 쓰지 않습니다. 한 번 넣으면 업데이트 뒤에도 남습니다.": "在文件浏览器和 Rsync 界面中打开、复制和同步 NAS · 云端的 WebDAV 文件夹 (顶部“SD 卡”旁的标签)。密码只保存在本设备的安全存储中,不写入设置文件,更新后仍会保留。", "NLLB 언어 코드가 아닙니다: {0}": "不是 NLLB 语言代码: {0}", "NLLB-200 번역 (600M)": "NLLB-200 翻译(600M)", + "NLLB-200 번역 모델 (CC BY-NC 4.0 · 비상업적 이용만)": "NLLB-200 翻译模型(CC BY-NC 4.0 · 仅限非商业用途)", "NVIDIA CUDA 12 런타임 (CUDA 판과 함께)": "NVIDIA CUDA 12 运行时 (与 CUDA 版一起)", + "NVIDIA CUDA Toolkit EULA (재배포 허용 파일)": "NVIDIA CUDA Toolkit EULA(可再分发文件)", "OpenAI 이미지 API 형식": "OpenAI 图像 API 格式", "OpenSubtitles API 키 또는 로그인 정보가 올바르지 않습니다. ({0})": "OpenSubtitles API 密钥或登录信息不正确。({0})", "OpenSubtitles 무료 API 키가 필요합니다.\n1. opensubtitles.com 가입 (무료)\n2. 프로필 → API consumers → New consumer 에서 키 만들기\n3. 아래 칸 또는 환경 설정에 키 붙여넣기": "需要 OpenSubtitles 免费 API 密钥。\n1. 注册 opensubtitles.com(免费)\n2. 个人资料 → API consumers → New consumer 创建密钥\n3. 将密钥粘贴到下方栏或设置中", @@ -216,6 +220,7 @@ "Whale 에서 가져옴": "从 Whale 导入", "Whisper base (빠름)": "Whisper base(快速)", "Whisper small (정확)": "Whisper small(准确)", + "Whisper 모델 (OpenAI)": "Whisper 模型(OpenAI)", "Windows 기본 브라우저": "Windows 默认浏览器", "Windows 기본 프로그램": "Windows 默认程序", "Windows 알림 설정 열기": "打开 Windows 通知设置", @@ -283,6 +288,9 @@ "v{0} 은 복사해 둔 모델을 되살리지 못합니다. 이 기능이 있는 판으로 다시 올리면 그때 되살립니다.": "v{0} 无法恢复已复制的模型。再次升级到带有此功能的版本时会恢复。", "v{0} 은 저장해 둔 설정을 되살리지 못해 설정을 다시 해야 합니다.": "v{0} 无法恢复已保存的设置,需要重新设置。", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "已下载 v{0} 并完成文件校验(SHA256)。\n{1}", + "whisper.cpp · ggml (whisper_ggml 안)": "whisper.cpp · ggml(whisper_ggml 内)", + "yt-dlp (실행 파일 yt-dlp.exe)": "yt-dlp(可执行文件 yt-dlp.exe)", + "yt-dlp (실행 파일에 묶인 구성 요소)": "yt-dlp(可执行文件中打包的组件)", "yt-dlp 를 새 버전으로 바꿨습니다": "已将 yt-dlp 更新为新版本", "yt-dlp 를 실행할 수 없습니다: {0}": "无法运行 yt-dlp: {0}", "yt-dlp 업데이트 확인 실패 (지금 버전으로 받습니다): {0}": "yt-dlp 更新检查失败(使用当前版本下载): {0}", @@ -398,6 +406,7 @@ "가져온 즐겨찾기 ({0})": "导入的书签({0})", "가져올 즐겨찾기가 없습니다.": "没有可导入的书签。", "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", + "같은 이름이 이미 있습니다": "已存在同名项目", "같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}", "같은 이름이 있는지 확인하는 중 …": "正在检查是否有同名项目 …", "같은 이름이 있을 때": "存在同名项目时", @@ -478,6 +487,7 @@ "그림을 읽을 수 없습니다": "无法读取图片", "그림을 페이지로 넣기 (이 페이지 뒤)": "将图片作为页面加入 (本页之后)", "그림이 거의 같고 마지막 단계가 10배쯤 빠릅니다 (받아 두었을 때)": "图片几乎相同,最后一步快约 10 倍 (已下载时)", + "글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)": "文字 → 图片 · 图片 → 图片(在本设备上;模型在设置中下载)", "글을 격자에 반영": "将文本应用到网格", "글자 · 버튼 크기입니다. 위쪽 막대의 − · + 로 그때그때 바꿀 수 있고, 가운데 숫자를 누르면 이 크기로 돌아갑니다 (지금 {0}%)": "文字 · 按钮大小。可随时用顶部栏的 − · + 调整,点击中间的数字恢复为此大小(当前 {0}%)", "금": "五", @@ -1301,6 +1311,7 @@ "원본이 바뀌면 대상에 맞춥니다. 일정을 정하면 그 시간에만 맞추고, 그 밖의 시간에는 바뀐 것을 모아 두었다가 다음 시작 시간에 맞춥니다.": "源变化时同步到目标。设定计划后只在该时间同步,其他时间的变化会累积到下次开始时间再同步。", "원본이 비므로 이 실시간 동기화는 끕니다. 진행은 [복사 · rsync] 탭에서 봅니다.": "源将变空,因此关闭此实时同步。进度请在 [复制 · rsync] 标签中查看。", "원본이 비어 있습니다. 대상에서 {0}개를 지울까요?": "源为空。从目标中删除 {0} 个项目吗?", + "원본이 없습니다": "源不存在", "원본이 없습니다 (다른 곳에서 지워졌거나 옮겨졌습니다): {0}": "源不存在 (已在别处删除或移动): {0}", "원어 (영상 속 언어)": "原语言(视频中的语言)", "원어({0})와 같은 언어로는 번역하지 않습니다.": "不会翻译成与原语言({0})相同的语言。", @@ -1316,6 +1327,9 @@ "웹 브라우저의 시작 · 🏠 주소": "网页浏览器的起始 · 🏠 页面", "웹 페이지 자동 번역": "自动翻译网页", "웹 페이지를 번역하지 못했습니다: {0}": "无法翻译网页: {0}", + "위 ← 아래": "上 ← 下", + "위 → 아래": "上 → 下", + "위 ⇄ 아래": "上 ⇄ 下", "위 폴더": "上级文件夹", "위 폴더로": "返回上级文件夹", "위로": "向上", @@ -1846,6 +1860,8 @@ "폴더는 보여도 그 안의 동영상 · 파일은 이 권한이 있어야 보입니다. 허용 화면에서 JJ_MKVMaker 를 켜고 돌아오세요.": "虽然能看到文件夹,但其中的视频和文件需要此权限。请在权限界面开启 JJ_MKVMaker 后返回。", "폴더를 만들 수 없습니다: {0}": "无法创建文件夹: {0}", "폴더를 자기 안으로 복사 · 이동할 수 없습니다": "不能将文件夹复制或移动到其自身内部", + "폴더를 자기 안으로 복사할 수 없습니다": "不能将文件夹复制到其自身内部", + "폴더를 자기 안으로 옮길 수 없습니다": "不能将文件夹移动到其自身内部", "폴더를 자기 자신 안으로 {0} 수 없습니다: {1}": "不能将文件夹{0}到其自身内部: {1}", "폴더만 lsync 로 옮길 수 있습니다.": "只有文件夹可以移到 lsync。", "폴란드어": "波兰语", diff --git a/lib/core/ai_catalog.dart b/lib/core/ai_catalog.dart index 45d74cb..20bc06d 100644 --- a/lib/core/ai_catalog.dart +++ b/lib/core/ai_catalog.dart @@ -1,3 +1,5 @@ +import '../l10n/tr.dart'; + /// 123 · 121: 기기 안 AI (그림 만들기 · 해상도 올리기) 에 받는 파일. 공식 배포처 주소와 SHA-256 을 고정한다. /// 상업 사용이 막힌 라이선스 (CC-BY-NC 계열) 는 넣지 않는다. class AiFile { @@ -161,14 +163,16 @@ const sdCppLicenseAsset = 'assets/licenses/STABLE_DIFFUSION_CPP_LICENSES.txt'; List<(String, String, String?)> aiLicenseTexts() => [ ( 'stable-diffusion.cpp', - 'MIT License - https://github.com/leejet/stable-diffusion.cpp ($sdCppVersion)\n\n' + trf( + 'MIT License - https://github.com/leejet/stable-diffusion.cpp ({0})\n\n' '함께 묶인 구성 요소: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · ' 'nlohmann/json · miniz · stb (MIT) · zip (Unlicense)', + [sdCppVersion]), sdCppLicenseAsset, ), for (final f in aiCatalog) if (f.kind != 'engine' || f.licenseAsset == null) - (f.name, '${f.license} - ${f.licenseUrl}${f.terms == null ? '' : '\n\n${f.terms}'}', f.licenseAsset), + (tr(f.name), '${tr(f.license)} - ${f.licenseUrl}${f.terms == null ? '' : '\n\n${tr(f.terms!)}'}', f.licenseAsset), ]; /// 121: 해상도 올리기 모델 (설정 'auto' · 'photo' · 'anime'). 자동은 폰이면 가볍고 빠른 만화용, PC 면 사진용 (x4plus) diff --git a/lib/core/ai_subtitle.dart b/lib/core/ai_subtitle.dart index 0180dc7..8960f6e 100644 --- a/lib/core/ai_subtitle.dart +++ b/lib/core/ai_subtitle.dart @@ -1,3 +1,4 @@ +// l10n-skip-file: 음성인식 결과를 고르는 자료 (말버릇 · 맞장구 목록) 라 번역하지 않는다 import 'languages.dart'; import 'srt.dart'; diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index 6e1b546..17e6d8f 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -430,7 +430,7 @@ class FileOps { for (final s in sources) { if (FileSystemEntity.isDirectorySync(s) && isSameOrInside(destDir, s)) { - throw FileSystemException('폴더를 자기 안으로 복사할 수 없습니다', s); + throw FileSystemException(tr('폴더를 자기 안으로 복사할 수 없습니다'), s); } final target = await _targetFor(s, destDir); if (target == null) continue; @@ -446,7 +446,7 @@ class FileOps { final made = []; for (final s in sources) { _check(); - if (isSameOrInside(destDir, s)) throw FileSystemException('폴더를 자기 안으로 옮길 수 없습니다', s); + if (isSameOrInside(destDir, s)) throw FileSystemException(tr('폴더를 자기 안으로 옮길 수 없습니다'), s); if (samePath(p.dirname(s), destDir)) { made.add(s); // 이미 그 폴더에 있음 continue; @@ -574,7 +574,7 @@ class FileOps { if (isDav(path)) { final target = vJoin(vDirname(path), name); if (target == vNorm(path)) return target; - if (await vExists(target)) throw FileSystemException('같은 이름이 이미 있습니다', target); + if (await vExists(target)) throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); await vRename(path, target); return target; } @@ -582,7 +582,7 @@ class FileOps { if (target == path) return path; if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound && target.toLowerCase() != path.toLowerCase()) { - throw FileSystemException('같은 이름이 이미 있습니다', target); + throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); } final t = FileSystemEntity.typeSync(path); return t == FileSystemEntityType.directory @@ -592,16 +592,16 @@ class FileOps { static Future makeFolder(String dir, String name) async { final n = name.trim(); - if (n.isEmpty || n.contains(RegExp(r'[\\/:*?"<>|]'))) throw FileSystemException('쓸 수 없는 이름입니다', n); + if (n.isEmpty || n.contains(RegExp(r'[\\/:*?"<>|]'))) throw FileSystemException(tr('쓸 수 없는 이름입니다'), n); if (isDav(dir)) { final target = vJoin(dir, n); - if (await vExists(target)) throw FileSystemException('같은 이름이 이미 있습니다', target); + if (await vExists(target)) throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); await vMkdirs(target); return target; } final target = p.join(dir, n); if (FileSystemEntity.typeSync(target) != FileSystemEntityType.notFound) { - throw FileSystemException('같은 이름이 이미 있습니다', target); + throw FileSystemException(tr('같은 이름이 이미 있습니다'), target); } return (await Directory(target).create()).path; } @@ -676,7 +676,7 @@ class FileOps { Future copyAny(String src, String target) async { _check(); final st = await vStat(src); - if (st == null) throw FileSystemException('원본이 없습니다', src); + if (st == null) throw FileSystemException(tr('원본이 없습니다'), src); if (st.isDir) { await vMkdirs(target); for (final e in await vList(src)) { @@ -691,7 +691,7 @@ class FileOps { } for (final s in sources) { - if (isSameOrInside(destDir, s) && await vIsDir(s)) throw FileSystemException('폴더를 자기 안으로 복사할 수 없습니다', s); + if (isSameOrInside(destDir, s) && await vIsDir(s)) throw FileSystemException(tr('폴더를 자기 안으로 복사할 수 없습니다'), s); final target = await _targetFor(s, destDir); if (target == null) continue; await copyAny(s, target); @@ -704,7 +704,7 @@ class FileOps { final made = []; for (final s in sources) { _check(); - if (isSameOrInside(destDir, s)) throw FileSystemException('폴더를 자기 안으로 옮길 수 없습니다', s); + if (isSameOrInside(destDir, s)) throw FileSystemException(tr('폴더를 자기 안으로 옮길 수 없습니다'), s); if (samePath(vDirname(s), destDir)) { made.add(s); continue; diff --git a/lib/main.dart b/lib/main.dart index bd0daf2..e73b272 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -479,6 +479,7 @@ Future runAndroid(String dataDir) async { String? sharedSnapshots; try { final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod('storageRoot'); + // l10n-skip: 실제 폴더 이름 (예전 보관을 찾아야 하므로 바꾸지 않음) if (root != null) sharedSnapshots = p.join(root, 'Download', 'JJ_MKVMaker', '설정 보관'); if (root != null) ModelBackup.sharedRoot = p.join(root, 'Download', 'JJ_MKVMaker'); } catch (_) {} diff --git a/lib/platform/windows/start_menu.dart b/lib/platform/windows/start_menu.dart index 09840cd..4927c8c 100644 --- a/lib/platform/windows/start_menu.dart +++ b/lib/platform/windows/start_menu.dart @@ -76,6 +76,7 @@ class StartMenu { static String _q(String s) => s.replaceAll("'", "''"); + // l10n-skip: PowerShell 스크립트 (안의 한글은 주석) static String _script(String lnk, String exe, {bool registerProtocol = true}) => ''' \$ErrorActionPreference = 'Stop' \$lnk = '${_q(lnk)}' diff --git a/lib/ui/ai_settings.dart b/lib/ui/ai_settings.dart index 973ff8e..5bead91 100644 --- a/lib/ui/ai_settings.dart +++ b/lib/ui/ai_settings.dart @@ -84,7 +84,7 @@ class _AiSettingsState extends State { context: context, builder: (ctx) => AlertDialog( scrollable: true, - title: Text(trf('{0} 라이선스', [f.license])), + title: Text(trf('{0} 라이선스', [tr(f.license)])), content: SizedBox( width: 520, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -93,7 +93,7 @@ class _AiSettingsState extends State { SelectableText(f.licenseUrl, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), if (f.licenseAsset != null) TextButton( - onPressed: () => showLicenseDocument(ctx, trf('{0} 라이선스', [f.license]), f.licenseAsset!), + onPressed: () => showLicenseDocument(ctx, trf('{0} 라이선스', [tr(f.license)]), f.licenseAsset!), child: Text(tr('라이선스 원문 보기')), ), ]), @@ -364,7 +364,7 @@ class _AiSettingsState extends State { : Wrap(crossAxisAlignment: WrapCrossAlignment.center, children: [ Text([ AiStore.sizeText(f.size), - f.license, + tr(f.license), if (installed) tr('받음'), // 받을 때 SHA-256 을 공식 값과 맞춰 보았다 if (installed && store.isVerified(f)) tr('확인됨'), @@ -376,7 +376,7 @@ class _AiSettingsState extends State { TextButton( style: TextButton.styleFrom( visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 6)), - onPressed: () => showLicenseDocument(context, trf('{0} 라이선스', [f.license]), f.licenseAsset!), + onPressed: () => showLicenseDocument(context, trf('{0} 라이선스', [tr(f.license)]), f.licenseAsset!), child: Text(tr('원문')), ), ]), diff --git a/lib/ui/license_texts.dart b/lib/ui/license_texts.dart index e324a9d..4cfc5bc 100644 --- a/lib/ui/license_texts.dart +++ b/lib/ui/license_texts.dart @@ -13,13 +13,13 @@ import 'theme.dart'; String get licenseNoticesAsset => Platform.isAndroid ? 'assets/licenses/THIRD_PARTY_NOTICES_ANDROID.txt' : 'assets/licenses/THIRD_PARTY_NOTICES.txt'; -/// 앱 안 라이선스 화면 (showLicensePage) 에 넣을 것: (구성 요소들, 원문 asset) +/// 앱 안 라이선스 화면 (showLicensePage) 에 넣을 것: (구성 요소들, 원문 asset). 화면을 열 때마다 모으므로 이름은 지금 화면 언어로 List<(List, String)> get _licenseAssets => [ (['JJ_MKVMaker - THIRD_PARTY_NOTICES'], licenseNoticesAsset), ( [ 'FFmpeg (Windows ffmpeg.exe 9.0.2 · Android FFmpegKit n8.1.2)', - 'yt-dlp (실행 파일 yt-dlp.exe)', + tr('yt-dlp (실행 파일 yt-dlp.exe)'), 'rsync', 'FFmpegKit (ffmpeg_kit_flutter_new_min_gpl)', 'youtubedl-android', @@ -29,14 +29,14 @@ List<(List, String)> get _licenseAssets => [ ), (['aria2'], 'assets/licenses/GPL-2.0.txt'), (['libmpv (media_kit, LGPL-2.1+)'], 'assets/licenses/LGPL-2.1.txt'), - (['yt-dlp (실행 파일에 묶인 구성 요소)'], 'assets/licenses/YT-DLP_THIRD_PARTY_LICENSES.txt'), + ([tr('yt-dlp (실행 파일에 묶인 구성 요소)')], 'assets/licenses/YT-DLP_THIRD_PARTY_LICENSES.txt'), (['rsync'], 'assets/licenses/RSYNC_COPYING.txt'), (['PDFium'], 'assets/licenses/PDFIUM_LICENSES.txt'), // 175: 함께 빌드한 네이티브 라이브러리 (Dart 쪽 패키지 라이선스와 따로) - (['whisper.cpp · ggml (whisper_ggml 안)'], 'assets/licenses/WHISPER_CPP_LICENSE.txt'), + ([tr('whisper.cpp · ggml (whisper_ggml 안)')], 'assets/licenses/WHISPER_CPP_LICENSE.txt'), (['ONNX Runtime 1.15.1 (Microsoft)'], 'assets/licenses/ONNXRUNTIME_LICENSES.txt'), - (['Whisper 모델 (OpenAI)'], 'assets/licenses/WHISPER_MODEL_LICENSE.txt'), - (['NLLB-200 번역 모델 (CC BY-NC 4.0 · 비상업적 이용만)'], nllbLicenseAsset), + ([tr('Whisper 모델 (OpenAI)')], 'assets/licenses/WHISPER_MODEL_LICENSE.txt'), + ([tr('NLLB-200 번역 모델 (CC BY-NC 4.0 · 비상업적 이용만)')], nllbLicenseAsset), ]; /// main 에서 한 번: 위 원문과 AI 구성 요소 · 모델 (OpenRAIL-M 용도 제한 포함) 을 라이선스 화면에 diff --git a/test/l10n_check_test.dart b/test/l10n_check_test.dart new file mode 100644 index 0000000..31b0a65 --- /dev/null +++ b/test/l10n_check_test.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +/// 60: 화면 글자 사전 검사기 (tool/l10n) - 사전에 빠진 번역이 없는지, 검사기가 놓치던 것을 잡는지 +void main() { + final python = Platform.isWindows ? 'python' : 'python3'; + bool hasPython() { + try { + return Process.runSync(python, ['--version']).exitCode == 0; + } catch (_) { + return false; + } + } + + final skip = hasPython() ? false : 'python 이 없음'; + + test('사전에 빠진 번역이 없다 (tool/l10n/check.py)', () { + final r = Process.runSync(python, ['tool/l10n/check.py'], environment: {'PYTHONIOENCODING': 'utf-8'}, stdoutEncoding: utf8); + expect(r.exitCode, 0, reason: '${r.stdout}'); + }, skip: skip); + + test('검사기: 줄을 바꾼 trf( · tr(변수) 로 쓰는 글을 잡고, // l10n-skip · raw 문자열은 넘긴다', () { + final dir = Directory.systemTemp.createTempSync('jj_l10n_'); + addTearDown(() => dir.deleteSync(recursive: true)); + Directory(p.join(dir.path, 'lib')).createSync(); + File(p.join(dir.path, 'lib', 'a.dart')).writeAsStringSync(''' +String f(int n) => trf( + '줄을 바꾼 열쇠 {0}', [n]); +String label(bool v) => v ? '위 → 아래' : '좌 → 우'; +String g() => tr(label(true)); +// l10n-skip: 실제 폴더 이름 +const folder = '설정 보관'; +final re = RegExp(r'한국어|kor'); +'''); + File(p.join(dir.path, 'lib', 'b.dart')).writeAsStringSync(''' +// l10n-skip-file: 자료 +const words = ['네.', '응.']; +'''); + final keys = p.join(dir.path, 'keys.json'), loose = p.join(dir.path, 'loose.json'); + final r = Process.runSync(python, [p.absolute('tool/l10n/extract_keys.py'), keys, loose], + workingDirectory: dir.path, environment: {'PYTHONIOENCODING': 'utf-8'}); + expect(r.exitCode, 0, reason: '${r.stderr}'); + expect(jsonDecode(File(keys).readAsStringSync()), contains('줄을 바꾼 열쇠 {0}')); + final texts = [for (final x in jsonDecode(File(loose).readAsStringSync()) as List) (x as List)[2]]; + expect(texts, unorderedEquals(['위 → 아래', '좌 → 우'])); + }, skip: skip); +} diff --git a/tool/l10n/check.py b/tool/l10n/check.py index 1002d26..9887682 100644 --- a/tool/l10n/check.py +++ b/tool/l10n/check.py @@ -14,7 +14,8 @@ import tempfile here = os.path.dirname(os.path.abspath(__file__)) out = os.path.join(tempfile.gettempdir(), 'jj_l10n_keys.json') -subprocess.run([sys.executable, os.path.join(here, 'extract_keys.py'), out], check=True) +out2 = os.path.join(tempfile.gettempdir(), 'jj_l10n_unwrapped.json') +subprocess.run([sys.executable, os.path.join(here, 'extract_keys.py'), out, out2], check=True) keys = json.load(open(out, encoding='utf-8')) missing = 0 for lang in ['en', 'ja', 'zh-Hans']: @@ -23,5 +24,11 @@ for lang in ['en', 'ja', 'zh-Hans']: missing += len(miss) for k in miss: print('%s 사전에 없음: %r' % (lang, k)) +# 60: tr 로 감싸지 않은 한글 글. 사전에 있으면 tr(변수) 로 번역되는 것으로 본다 - 없으면 번역이 빠진 것 +en = json.load(open(os.path.join('assets', 'l10n', 'en.json'), encoding='utf-8')) +loose = [(p, n, t) for p, n, t in json.load(open(out2, encoding='utf-8')) if t not in en] +for p, n, t in loose: + print('번역 안 된 글 (tr 로 감싸거나 사전에 넣기, 자료면 // l10n-skip): %s:%d %r' % (p, n, t[:80])) +missing += len(loose) print('열쇠 %d개, 빠진 번역 %d개' % (len(keys), missing)) sys.exit(1 if missing else 0) diff --git a/tool/l10n/extract_keys.py b/tool/l10n/extract_keys.py index b203edb..9ee4fef 100644 --- a/tool/l10n/extract_keys.py +++ b/tool/l10n/extract_keys.py @@ -1,4 +1,9 @@ -"""lib 의 tr('…') / trf('…') 열쇠와 한국어 이름 (언어 · 선택 목록 · 모델) 을 모아 JSON 으로 쓴다.""" +"""lib 의 tr('…') / trf('…') 열쇠와 한국어 이름 (언어 · 선택 목록 · 모델) 을 모아 JSON 으로 쓴다. + +두 번째 인자를 주면 tr 로 감싸지 않은 한글 글 ([경로, 줄, 글]) 도 쓴다 (60: tr(변수) 로 나중에 번역되거나 아예 번역이 빠진 글). +번역하지 않는 자료 (말버릇 목록 · 정규식 · 스크립트 · 실제 폴더 이름 등) 는 그 줄이나 바로 위 줄에 `// l10n-skip`, +파일 전체면 맨 위쪽에 `// l10n-skip-file`. raw 문자열 (r'…') 은 정규식이라 보지 않는다. +""" import json import os import re @@ -86,6 +91,7 @@ def literal_groups(src): keys = set() +unwrapped = [] NAMED = {'lib/core/languages.dart', 'lib/core/download_detect.dart', 'lib/core/encode_options.dart', 'lib/core/playlist.dart', 'lib/services/model_store.dart'} for root, _, files in os.walk('lib'): @@ -104,16 +110,26 @@ for root, _, files in os.walk('lib'): if part[0] == 'expr': todo.extend((part[1], st, ls) for st, ls in literal_groups(part[1])) k += 1 + skip_file = '// l10n-skip-file' in src + lines = src.split('\n') for text_src, start, lits in todo: - before = text_src[max(0, start - 8):start] + # 60: trf( 뒤에서 줄을 바꾼 것도 (예전에는 앞 8글자만 봐서 놓쳤다) + before = text_src[max(0, start - 200):start] wrapped = re.search(r'\btrf?\(\s*$', before) is not None text = group_text(lits) if not HANGUL.search(text): continue if wrapped or (path in NAMED and not any(l.raw for l in lits)): keys.add(text) + elif not skip_file and not any(l.raw for l in lits) and text_src is src: + line = src.count('\n', 0, start) + near = lines[line] + (lines[line - 1] if line > 0 else '') + if '// l10n-skip' not in near: + unwrapped.append([path, line + 1, text]) # 광고 건너뛰기 표시 (youtube_ads.dart 는 스크립트라 따로) keys.add('광고 건너뛰는 중…') out = sorted(keys) json.dump(out, open(sys.argv[1], 'w', encoding='utf-8'), ensure_ascii=False, indent=1) +if len(sys.argv) > 2: + json.dump(unwrapped, open(sys.argv[2], 'w', encoding='utf-8'), ensure_ascii=False, indent=1) print(len(out), sum(len(k) for k in out))