From 849b3ecfe6f02e290f57fdf730e9932cf6a81fd9 Mon Sep 17 00:00:00 2001 From: lbcz Date: Sat, 17 Oct 2026 22:12:18 +0800 Subject: [PATCH 1/2] Add English and Simplified Chinese localization Add English and Simplified Chinese resources and a persisted language selector with automatic locale detection. Initialize localization in the lazy-loaded main and create-torrent views, and localize dialogs, torrent details, tables, native menus, notifications, and client-side errors. --- package-lock.json | 98 +++- package.json | 2 + src-tauri/src/commands.rs | 61 +- src-tauri/src/i18n.rs | 68 +++ src-tauri/src/macos.rs | 26 +- src-tauri/src/main.rs | 7 +- src-tauri/src/torrentcache.rs | 2 +- src-tauri/src/tray.rs | 42 +- src/components/app.tsx | 70 ++- src/components/colorchooser.tsx | 4 +- src/components/createtorrentapp.tsx | 49 ++ src/components/createtorrentform.tsx | 77 +-- src/components/details.tsx | 139 +++-- src/components/filters.tsx | 30 +- src/components/miscbuttons.tsx | 10 +- src/components/modals/add.tsx | 116 ++-- src/components/modals/common.tsx | 36 +- src/components/modals/daemon.tsx | 169 +++--- src/components/modals/editlabels.tsx | 18 +- src/components/modals/edittorrent.tsx | 36 +- src/components/modals/edittrackers.tsx | 12 +- src/components/modals/interfacepanel.tsx | 106 ++-- src/components/modals/move.tsx | 16 +- src/components/modals/remove.tsx | 16 +- src/components/modals/settings.tsx | 96 ++-- src/components/modals/version.tsx | 42 +- src/components/sectionscontextmenu.tsx | 3 +- src/components/server.tsx | 8 +- src/components/servertabs.tsx | 8 +- src/components/statusbar.tsx | 25 +- src/components/tables/common.tsx | 4 +- src/components/tables/filetreetable.tsx | 71 ++- src/components/tables/peerstable.tsx | 101 ++-- src/components/tables/torrenttable.tsx | 150 +++-- src/components/tables/trackertable.tsx | 67 ++- src/components/toolbar.tsx | 72 +-- src/config.ts | 12 +- src/createtorrent.tsx | 25 +- src/i18n.ts | 82 +++ src/index.tsx | 4 - src/language.ts | 41 ++ src/locales/en.json | 699 +++++++++++++++++++++++ src/locales/zh-CN.json | 688 ++++++++++++++++++++++ src/rpc/client.ts | 21 +- src/taurishim.ts | 17 +- src/translate.ts | 31 + src/trutil.ts | 11 +- 47 files changed, 2766 insertions(+), 722 deletions(-) create mode 100644 src-tauri/src/i18n.rs create mode 100644 src/components/createtorrentapp.tsx create mode 100644 src/i18n.ts create mode 100644 src/language.ts create mode 100644 src/locales/en.json create mode 100644 src/locales/zh-CN.json create mode 100644 src/translate.ts diff --git a/package-lock.json b/package-lock.json index 1d4f41a2..ab306776 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,11 +31,13 @@ "buffer": "^6.0.3", "date-fns": "^4.1.0", "flag-icons": "^7.5.0", + "i18next": "^25.1.2", "lodash-es": "^4.17.21", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", "react-bootstrap-icons": "^1.11.6", "react-dom": "^18.3.1", + "react-i18next": "^15.1.4", "react-resize-detector": "^8.1.0", "react-split": "^2.0.14", "split.js": "^1.6.5", @@ -113,7 +115,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2774,7 +2775,6 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.18.tgz", "integrity": "sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA==", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -2822,7 +2822,6 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.18.tgz", "integrity": "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -3316,7 +3315,6 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -3450,7 +3448,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.44.0.tgz", "integrity": "sha512-RuIqHYrS98LrK/8kJJOJMMSQ/BCpojwsXDh7p0fBmp38ZOz6dlk+uyFRRusH+V+t3POoCsDOQ2zhomEYOeReXw==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "4.44.0", "use-sync-external-store": "^1.6.0" @@ -4040,7 +4037,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.42.tgz", "integrity": "sha512-c1zEr96MjakLYus/wPnuWDo1/zErfdU9rNsIGmE+NV71nx88FG9Ttgo5dqorXTu/LImX2f63WBP986gJkMPNbA==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4195,7 +4191,6 @@ "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.71.0", "@typescript-eslint/types": "8.71.0", @@ -4719,7 +4714,6 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5286,7 +5280,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001812", @@ -6854,7 +6847,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8163,6 +8155,15 @@ "node": ">=12" } }, + "node_modules/html-parse-stringify": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.1.0.tgz", + "integrity": "sha512-E0oAXcELOtsXe+BmpJ2EZyedbldPpriV5vICzEuo6xjC/D1lDukOI7KrpfQGF2Qc4wWEy0nk3bFORS2K5ZAhFQ==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-webpack-plugin": { "version": "5.6.8", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.8.tgz", @@ -8291,6 +8292,37 @@ "node": ">=10.18" } }, + "node_modules/i18next": { + "version": "25.10.10", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz", + "integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -10383,7 +10415,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", @@ -11411,7 +11442,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -11456,7 +11486,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -11482,6 +11511,32 @@ "react": ">= 16.8 || 18.0.0" } }, + "node_modules/react-i18next": { + "version": "15.7.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", + "integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.4.0", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -12063,7 +12118,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -13161,8 +13215,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsyringe": { "version": "4.10.0", @@ -13340,9 +13393,8 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13690,6 +13742,15 @@ "node": ">= 0.8" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/watchpack": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", @@ -13709,7 +13770,6 @@ "integrity": "sha512-cNypaz0RP+S4cvQVi1M/3bRUkvNP0xZpL0TjFx+c6jg64VbxD+2weWDgY9UnjRI6dhZgtaj8DU76GGFcJ79xPQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", diff --git a/package.json b/package.json index 69ab9808..0ab2539a 100644 --- a/package.json +++ b/package.json @@ -81,11 +81,13 @@ "buffer": "^6.0.3", "date-fns": "^4.1.0", "flag-icons": "^7.5.0", + "i18next": "^25.1.2", "lodash-es": "^4.17.21", "react": "^18.3.1", "react-beautiful-dnd": "^13.1.1", "react-bootstrap-icons": "^1.11.6", "react-dom": "^18.3.1", + "react-i18next": "^15.1.4", "react-resize-detector": "^8.1.0", "react-split": "^2.0.14", "split.js": "^1.6.5", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 74b1ebb5..6e016315 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -19,10 +19,11 @@ use std::fs; use base64::{engine::general_purpose::STANDARD as b64engine, Engine as _}; use font_loader::system_fonts; use lava_torrent::torrent::v1::Torrent; -use tauri::{Emitter, EventTarget, State}; +use tauri::{Emitter, EventTarget, Manager, State}; use crate::{ createtorrent::{CreateCheckResult, CreationRequestsHandle, TorrentCreateInfo}, + i18n, poller::PollerConfig, tray, PollerHandle, }; @@ -52,26 +53,26 @@ pub struct TorrentReadResult { } #[tauri::command] -pub async fn read_file(path: String) -> Result { +pub async fn read_file(app: tauri::AppHandle, path: String) -> Result { let metadata = std::fs::metadata(path.clone()); match metadata { - Err(_) => return Err(format!("Failed to read file {path:?}")), + Err(_) => return Err(i18n::text(&app, "native.errors.readFile").replace("{{path}}", &path)), Ok(metadata) => { if metadata.len() > 10 * 1024 * 1024 { - return Err("File is too large".to_string()); + return Err(i18n::text(&app, "native.errors.fileTooLarge")); } } } let read_result = tokio::fs::read(path.clone()).await; if read_result.is_err() { - return Err(format!("Failed to read file {path:?}")); + return Err(i18n::text(&app, "native.errors.readFile").replace("{{path}}", &path)); } match Torrent::read_from_bytes(&read_result.as_ref().unwrap()[..]) { Err(e) => { println!("Failed to parse torrent {:?}", e.to_string()); - Err(format!("Failed to parse torrent {path:?}")) + Err(i18n::text(&app, "native.errors.parseTorrent").replace("{{path}}", &path)) } Ok(torrent) => { let b64 = b64engine.encode(read_result.unwrap()); @@ -181,12 +182,12 @@ pub async fn create_torrent( let label_split: Vec<&str> = window.label().split('-').collect(); if label_split.len() != 2 { - return Err("Incorrect window label".to_string()); + return Err(i18n::text(window.app_handle(), "native.errors.invalidWindow")); } let id = label_split[1] .parse::() - .map_err(|_| "Incorrect window label".to_string())?; + .map_err(|_| i18n::text(window.app_handle(), "native.errors.invalidWindow"))?; let mut requests = creation_requests_handle.0.lock().await; requests.add(id, info).map_err(|e| e.to_string())?; @@ -202,12 +203,12 @@ pub async fn check_create_torrent( let label_split: Vec<&str> = window.label().split('-').collect(); if label_split.len() != 2 { - return Err("Incorrect window label".to_string()); + return Err(i18n::text(window.app_handle(), "native.errors.invalidWindow")); } let id = label_split[1] .parse::() - .map_err(|_| "Incorrect window label".to_string())?; + .map_err(|_| i18n::text(window.app_handle(), "native.errors.invalidWindow"))?; let mut requests = creation_requests_handle.0.lock().await; @@ -222,16 +223,17 @@ pub async fn cancel_create_torrent( let label_split: Vec<&str> = window.label().split('-').collect(); if label_split.len() != 2 { - return Err("Incorrect window label".to_string()); + return Err(i18n::text(window.app_handle(), "native.errors.invalidWindow")); } let id = label_split[1] .parse::() - .map_err(|_| "Incorrect window label".to_string())?; + .map_err(|_| i18n::text(window.app_handle(), "native.errors.invalidWindow"))?; let mut requests = creation_requests_handle.0.lock().await; requests.cancel(id) + .map_err(|error| localize_creation_error(window.app_handle(), error)) } #[tauri::command] @@ -243,16 +245,17 @@ pub async fn save_create_torrent( let label_split: Vec<&str> = window.label().split('-').collect(); if label_split.len() != 2 { - return Err("Incorrect window label".to_string()); + return Err(i18n::text(window.app_handle(), "native.errors.invalidWindow")); } let id = label_split[1] .parse::() - .map_err(|_| "Incorrect window label".to_string())?; + .map_err(|_| i18n::text(window.app_handle(), "native.errors.invalidWindow"))?; let mut requests = creation_requests_handle.0.lock().await; requests.save(id, &path) + .map_err(|error| localize_creation_error(window.app_handle(), error)) } #[derive(serde::Serialize, Clone)] @@ -291,11 +294,33 @@ pub fn create_tray(app_handle: tauri::AppHandle) { } #[tauri::command] -pub async fn save_text_file(contents: String, path: String) -> Result<(), String> { - fs::write(path, contents).map_err(|e| format!("Unable to write file: {e}")) +pub async fn save_text_file(app: tauri::AppHandle, contents: String, path: String) -> Result<(), String> { + fs::write(path, contents).map_err(|e| { + i18n::text(&app, "native.errors.writeFile").replace("{{error}}", &e.to_string()) + }) } #[tauri::command] -pub async fn load_text_file(path: String) -> Result { - fs::read_to_string(path).map_err(|e| format!("Unable to read file: {e}")) +pub async fn load_text_file(app: tauri::AppHandle, path: String) -> Result { + fs::read_to_string(path).map_err(|e| { + i18n::text(&app, "native.errors.loadFile").replace("{{error}}", &e.to_string()) + }) +} + +fn localize_creation_error(app: &tauri::AppHandle, error: String) -> String { + if error == "Torrent build request not found" { + i18n::text(app, "createTorrent.errors.notFound") + } else { + error + } +} + +#[tauri::command] +pub fn set_language(app: tauri::AppHandle, language: String) -> Result<(), String> { + *app.state::().0.write().unwrap() = i18n::resolve(&language); + tray::refresh_menu(&app); + #[cfg(target_os = "macos")] + app.set_menu(crate::macos::make_menu(&app).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + Ok(()) } diff --git a/src-tauri/src/i18n.rs b/src-tauri/src/i18n.rs new file mode 100644 index 00000000..3face085 --- /dev/null +++ b/src-tauri/src/i18n.rs @@ -0,0 +1,68 @@ +// TrguiNG - next gen remote GUI for transmission torrent daemon +// Copyright (C) 2023 qu1ck (mail at qu1ck.org) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::sync::{OnceLock, RwLock}; + +use serde_json::Value; +use tauri::{AppHandle, Manager, Runtime}; + +pub struct Language(pub RwLock<&'static str>); + +impl Default for Language { + fn default() -> Self { + Self(RwLock::new("en")) + } +} + +pub fn resolve(language: &str) -> &'static str { + if language.to_ascii_lowercase().starts_with("zh") { + "zh-CN" + } else { + "en" + } +} + +pub fn translate(language: &str, key: &str) -> String { + static ENGLISH: OnceLock = OnceLock::new(); + static CHINESE: OnceLock = OnceLock::new(); + let english = ENGLISH.get_or_init(|| { + serde_json::from_str(include_str!("../../src/locales/en.json")) + .expect("Invalid English locale") + }); + let resources = if resolve(language) == "zh-CN" { + CHINESE.get_or_init(|| { + serde_json::from_str(include_str!("../../src/locales/zh-CN.json")) + .expect("Invalid Chinese locale") + }) + } else { + english + }; + let lookup = |resources: &'static Value| { + key.split('.') + .try_fold(resources, |value, part| value.get(part)) + .and_then(Value::as_str) + }; + lookup(resources) + .or_else(|| lookup(english)) + .unwrap_or(key) + .to_string() +} + +pub fn text(app: &AppHandle, key: &str) -> String { + let state = app.state::(); + let language = *state.0.read().unwrap(); + translate(language, key) +} diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 6da6d8a1..57d08f6e 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -14,6 +14,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use crate::i18n; + use tauri::{menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}, AppHandle}; pub fn make_menu(app: &AppHandle) -> tauri::Result> @@ -24,23 +26,23 @@ where Menu::with_items(app, &[ &Submenu::with_items(app, app_name.as_str(), true, &[ - &PredefinedMenuItem::close_window(app, "Close".into())?, - &MenuItem::with_id(app, "appquit", "Quit", true, "Cmd+q".into())?, + &PredefinedMenuItem::close_window(app, Some(i18n::text(app, "modals.common.close").as_str()))?, + &MenuItem::with_id(app, "appquit", i18n::text(app, "native.quit"), true, "Cmd+q".into())?, ])?, - &Submenu::with_items(app, "Edit", true, &[ - &PredefinedMenuItem::select_all(app, "Select All".into())?, - &PredefinedMenuItem::cut(app, "Cut".into())?, - &PredefinedMenuItem::copy(app, "Copy".into())?, - &PredefinedMenuItem::paste(app, "Paste".into())?, + &Submenu::with_items(app, i18n::text(app, "native.edit"), true, &[ + &PredefinedMenuItem::select_all(app, Some(i18n::text(app, "native.selectAll").as_str()))?, + &PredefinedMenuItem::cut(app, Some(i18n::text(app, "native.cut").as_str()))?, + &PredefinedMenuItem::copy(app, Some(i18n::text(app, "native.copy").as_str()))?, + &PredefinedMenuItem::paste(app, Some(i18n::text(app, "native.paste").as_str()))?, ])?, - &Submenu::with_items(app, "View", true, &[ - &PredefinedMenuItem::fullscreen(app, "Full Screen".into())? + &Submenu::with_items(app, i18n::text(app, "native.view"), true, &[ + &PredefinedMenuItem::fullscreen(app, Some(i18n::text(app, "native.fullScreen").as_str()))? ])?, - &Submenu::with_items(app, "Window", true, &[ - &PredefinedMenuItem::minimize(app, "Minimize".into())?, + &Submenu::with_items(app, i18n::text(app, "native.window"), true, &[ + &PredefinedMenuItem::minimize(app, Some(i18n::text(app, "native.minimize").as_str()))?, // zoom missing ?? &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::close_window(app, "Close".into())?, + &PredefinedMenuItem::close_window(app, Some(i18n::text(app, "modals.common.close").as_str()))?, ])?, ]) } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index b4ce60ff..47bd2719 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -34,6 +34,7 @@ mod createtorrent; mod geoip; mod integrations; mod ipc; +mod i18n; #[cfg(target_os = "macos")] mod macos; mod poller; @@ -147,7 +148,7 @@ fn setup(app: &mut App) -> Result<(), Box> { let app_clone = app.clone(); app.listen("window-hidden", move |_| { - tray::set_tray_showhide_text(&app_clone, "Show"); + tray::set_tray_window_visible(&app_clone, false); }) }); @@ -196,6 +197,7 @@ fn main() { .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_clipboard_manager::init()) .invoke_handler(tauri::generate_handler![ + commands::set_language, commands::read_file, commands::remove_file, commands::shell_open, @@ -215,6 +217,7 @@ fn main() { .manage(ListenerHandle(Arc::new(RwLock::new(ipc)))) .manage(TorrentCacheHandle::default()) .manage(PollerHandle::default()) + .manage(i18n::Language::default()) .manage(MmdbReaderHandle::default()) .manage(CreationRequestsHandle::default()) .manage(http_clients()) @@ -245,7 +248,7 @@ fn main() { } tauri::RunEvent::ExitRequested { api, .. } => { api.prevent_exit(); - tray::set_tray_showhide_text(app_handle, "Show"); + tray::set_tray_window_visible(app_handle, false); } _ => {} }); diff --git a/src-tauri/src/torrentcache.rs b/src-tauri/src/torrentcache.rs index 7358a518..daa09d91 100644 --- a/src-tauri/src/torrentcache.rs +++ b/src-tauri/src/torrentcache.rs @@ -138,7 +138,7 @@ fn show_notification(app: &AppHandle, name: &str) { if let Err(e) = app .notification() .builder() - .title("Download complete") + .title(crate::i18n::text(app, "native.downloadComplete")) .body(name) .show() { diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 3a5ed9b3..d4953030 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -14,7 +14,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::sync::{Arc, Mutex}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; use tauri::{ async_runtime, @@ -24,12 +27,13 @@ use tauri::{ }; use tokio::sync::oneshot; -use crate::ListenerHandle; +use crate::{i18n, ListenerHandle}; pub const TRAY_ID: &str = "tray"; +static WINDOW_VISIBLE: AtomicBool = AtomicBool::new(true); pub fn create_tray(app: AppHandle) { - let menu = create_menu(&app, "Hide"); + let menu = create_menu(&app); let builder = TrayIconBuilder::with_id(TRAY_ID) .icon(app.default_window_icon().unwrap().clone()) @@ -65,19 +69,33 @@ pub fn create_tray(app: AppHandle) { builder.build(&app).ok(); } -fn create_menu(app: &AppHandle, showhide_text: &str) -> tauri::menu::Menu +fn create_menu(app: &AppHandle) -> tauri::menu::Menu where R: tauri::Runtime, { - let hide = MenuItem::with_id(app, "showhide", showhide_text, true, None::<&str>).unwrap(); - let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>).unwrap(); + let showhide = i18n::text( + app, + if WINDOW_VISIBLE.load(Ordering::Relaxed) { + "native.hide" + } else { + "native.show" + }, + ); + let hide = MenuItem::with_id(app, "showhide", showhide, true, None::<&str>).unwrap(); + let quit = MenuItem::with_id(app, "quit", i18n::text(app, "native.quit"), true, None::<&str>) + .unwrap(); let separator = PredefinedMenuItem::separator(app).unwrap(); Menu::with_items(app, &[&hide, &separator, &quit]).unwrap() } -pub fn set_tray_showhide_text(app: &AppHandle, text: &str) { +pub fn set_tray_window_visible(app: &AppHandle, visible: bool) { + WINDOW_VISIBLE.store(visible, Ordering::Relaxed); + refresh_menu(app); +} + +pub fn refresh_menu(app: &AppHandle) { if let Some(tray) = app.tray_by_id(TRAY_ID) { - tray.set_menu(Some(create_menu(app, text))).ok(); + tray.set_menu(Some(create_menu(app))).ok(); } } @@ -101,10 +119,10 @@ pub fn toggle_main_window(app: &AppHandle, window: Option) { window.unminimize().ok(); window.set_focus().ok(); window.emit("window-shown", "").ok(); - set_tray_showhide_text(app, "Hide"); + set_tray_window_visible(app, true); return; } - set_tray_showhide_text(app, "Show"); + set_tray_window_visible(app, false); async_runtime::spawn(async move { close_main(window).await; }); @@ -112,11 +130,11 @@ pub fn toggle_main_window(app: &AppHandle, window: Option) { None => { let window = WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into())) - .title("Transmission GUI") + .title(i18n::text(app, "native.windowTitle")) .inner_size(1024., 800.) .build() .unwrap(); - set_tray_showhide_text(app, "Hide"); + set_tray_window_visible(app, true); window.set_focus().ok(); } } diff --git a/src/components/app.tsx b/src/components/app.tsx index e4858483..16510053 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -38,8 +38,11 @@ import { ToolbarButton } from "./toolbar"; import "@mantine/core/styles.css"; import "@mantine/notifications/styles.css"; import "css/custom.css"; +import { I18nextProvider, useTranslation } from "react-i18next"; +import i18n, { initializeI18n, synchronizeNativeLanguage } from "i18n"; +import { Loader } from "@mantine/core"; -const { appWindow, invoke, makeCreateTorrentView } = await import(/* webpackChunkName: "taurishim" */"taurishim"); +const { TAURI, appWindow, invoke, makeCreateTorrentView } = await import(/* webpackChunkName: "taurishim" */"taurishim"); interface PassEventData { from: string, @@ -47,6 +50,7 @@ interface PassEventData { } function CreateTorrentButton() { + const { t } = useTranslation(); const config = useContext(ConfigContext); const colorScheme = useComputedColorScheme(); @@ -55,7 +59,7 @@ function CreateTorrentButton() { if (data.payload === "ready") { void invoke("pass_to_window", { to: data.from, - payload: JSON.stringify({ colorScheme, defaultTrackers: config.values.interface.defaultTrackers }), + payload: JSON.stringify({ colorScheme, defaultTrackers: config.values.interface.defaultTrackers, language: config.values.app.language }), }); } }); @@ -79,7 +83,7 @@ function CreateTorrentButton() { variant="default" size="lg" onClick={onClick} - title={`Create new torrent file (${modKeyString()} + T)`} + title={t("toolbar.createTorrentWithHotkey", { hotkey: modKeyString() })} my="auto" > @@ -88,16 +92,46 @@ function CreateTorrentButton() { } export function App(props: React.PropsWithChildren) { + const config = useContext(ConfigContext); + const [ready, setReady] = useState(false); + + useEffect(() => { + let active = true; + let stopNativeSync: (() => void) | undefined; + const initialize = async () => { + await initializeI18n(config.values.app.language); + if (!active) return; + if (TAURI) { + const nativeSync = synchronizeNativeLanguage(async (language) => await invoke("set_language", { language })); + stopNativeSync = nativeSync.stop; + await nativeSync.ready; + if (!active) return; + if (config.values.app.showTrayIcon) void invoke("create_tray").catch(console.error); + } + if (active) setReady(true); + }; + void initialize().catch(console.error); + return () => { + active = false; + stopNativeSync?.(); + }; + }, [config]); + + if (!ready) return ; + return ( - - - {props.children} - - + + + + {props.children} + + + ); } -export default function TauriApp() { +function TauriAppContent() { + const { t } = useTranslation(); const config = useContext(ConfigContext); const clientManager = useMemo(() => { const cm = new ClientManager(config); @@ -137,7 +171,7 @@ export default function TauriApp() { }, []); return ( - + <> @@ -173,10 +207,10 @@ export default function TauriApp() { hostname={clientManager.getHostname(currentServer.name)} tabsRef={tabsRef} toolbarExtra={!showTabStrip && <> - + - + {tabsRef.current?.getOpenTabs() !== undefined && tabsRef.current?.getOpenTabs()?.length > 1 @@ -188,7 +222,7 @@ export default function TauriApp() { middlewares={{ shift: true, flip: true }} > - + @@ -223,11 +257,15 @@ export default function TauriApp() { })} } - + ); } + +export default function TauriApp() { + return ; +} diff --git a/src/components/colorchooser.tsx b/src/components/colorchooser.tsx index e838b66e..36af6648 100644 --- a/src/components/colorchooser.tsx +++ b/src/components/colorchooser.tsx @@ -19,6 +19,7 @@ import { ActionIcon, ColorSwatch, Popover, useMantineTheme, Group, Button } from "@mantine/core"; import type { ColorSetting } from "config"; import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; interface ColorChooserProps { value: ColorSetting, @@ -28,6 +29,7 @@ interface ColorChooserProps { const shades = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; export default function ColorChooser(props: ColorChooserProps) { + const { t } = useTranslation(); const theme = useMantineTheme(); const [opened, setOpened] = useState(false); @@ -57,7 +59,7 @@ export default function ColorChooser(props: ColorChooserProps) { setOpened(false); }} > - Reset + {t("interface.reset")} {Object.keys(theme.colors).map((color) => diff --git a/src/components/createtorrentapp.tsx b/src/components/createtorrentapp.tsx new file mode 100644 index 00000000..b4e6258a --- /dev/null +++ b/src/components/createtorrentapp.tsx @@ -0,0 +1,49 @@ +/** + * TrguiNG - next gen remote GUI for transmission torrent daemon + * Copyright (C) 2023 qu1ck (mail at qu1ck.org) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React, { useEffect, useState } from "react"; +import { I18nextProvider } from "react-i18next"; +import { Config, ConfigContext } from "config"; +import i18n, { initializeI18n } from "i18n"; +import CreateTorrentForm from "./createtorrentform"; +import CustomMantineProvider from "./mantinetheme"; +import "@mantine/core/styles.css"; + +export default function CreateTorrentApp() { + const [config, setConfig] = useState(); + useEffect(() => { + let active = true; + const initialize = async () => { + const loaded = await new Config().read(); + await initializeI18n(loaded.values.app.language); + if (active) setConfig(loaded); + }; + void initialize().catch(console.error); + return () => { + active = false; + }; + }, []); + if (config === undefined) return
; + return ( + + + + + + ); +} diff --git a/src/components/createtorrentform.tsx b/src/components/createtorrentform.tsx index 0a4334b1..93f78c68 100644 --- a/src/components/createtorrentform.tsx +++ b/src/components/createtorrentform.tsx @@ -19,8 +19,11 @@ import { Box, Button, Checkbox, Flex, Group, Slider, Text, TextInput, Textarea, useMantineColorScheme } from "@mantine/core"; import { useForm } from "@mantine/form"; import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { appVersion } from "./modals/version"; import { ProgressBar } from "./progressbar"; +import { initializeI18n } from "i18n"; +import { isAvailableLanguage } from "language"; import { bytesToHumanReadableStr } from "trutil"; import classes from "./createtorrentform.module.css"; const { appWindow, invoke, dialogOpen, dialogSave } = await import(/* webpackChunkName: "taurishim" */"taurishim"); @@ -77,6 +80,7 @@ interface InfobarState { } export default function CreateTorrentForm() { + const { t } = useTranslation(); const { setColorScheme } = useMantineColorScheme(); const [defaultTrackers, setDefaultTrackers] = useState([]); const [pieces, setPieces] = useState({ @@ -89,6 +93,12 @@ export default function CreateTorrentForm() { hash: "", }); + useEffect(() => { + const title = t("createTorrent.title"); + document.title = title; + void appWindow.setTitle(title); + }, [t]); + const form = useForm({ initialValues: { path: "", @@ -105,7 +115,8 @@ export default function CreateTorrentForm() { useEffect(() => { void appWindow.once("pass-from-window", ({ payload: data }) => { - const { colorScheme, defaultTrackers } = JSON.parse(data.payload); + const { colorScheme, defaultTrackers, language } = JSON.parse(data.payload); + void initializeI18n(isAvailableLanguage(language) ? language : undefined).catch(console.error); setColorScheme(colorScheme); setDefaultTrackers(defaultTrackers); }); @@ -128,7 +139,7 @@ export default function CreateTorrentForm() { if (sizes.files === -1) { setState({ state: "error", - error: "Failed to calculate file sizes", + error: t("createTorrent.errors.calculateSizes"), hash: "", }); return; @@ -148,23 +159,23 @@ export default function CreateTorrentForm() { setState({ state: "error", error, hash: "" }); clearInterval(timer.current); }); - }, [setFieldValue]); + }, [setFieldValue, t]); const onBrowseFile = useCallback(() => { dialogOpen({ - title: "Select file", + title: t("createTorrent.dialog.selectFile"), defaultPath: form.values.path === "" ? undefined : form.values.path, multiple: false, }).then(setPathAndCalculate).catch(console.error); - }, [form.values.path, setPathAndCalculate]); + }, [form.values.path, setPathAndCalculate, t]); const onBrowseDirectory = useCallback(() => { dialogOpen({ - title: "Select directory", + title: t("createTorrent.dialog.selectDirectory"), defaultPath: form.values.path === "" ? undefined : form.values.path, directory: true, }).then(setPathAndCalculate).catch(console.error); - }, [form.values.path, setPathAndCalculate]); + }, [form.values.path, setPathAndCalculate, t]); const timer = useRef(0); @@ -184,7 +195,7 @@ export default function CreateTorrentForm() { clearInterval(timer.current); } if (result.notFound !== undefined) { - setState({ state: "error", error: "Torrent create request not found on backend", hash: "" }); + setState({ state: "error", error: t("createTorrent.errors.notFound"), hash: "" }); clearInterval(timer.current); } if (result.inProgress !== undefined) { @@ -198,7 +209,7 @@ export default function CreateTorrentForm() { }).catch((error) => { setState({ state: "error", error, hash: "" }); }); - }, [form.values]); + }, [form.values, t]); const onCancel = useCallback(() => { clearInterval(timer.current); @@ -211,10 +222,10 @@ export default function CreateTorrentForm() { const onSave = useCallback(() => { dialogSave({ - title: "Save torrent file", + title: t("createTorrent.dialog.saveTorrentFile"), defaultPath: form.values.name, filters: [{ - name: "Torrent", + name: t("modals.common.torrentFile"), extensions: ["torrent"], }], }).then((path) => { @@ -226,7 +237,7 @@ export default function CreateTorrentForm() { }); } }).catch(console.error); - }, [form.values.name]); + }, [form.values.name, t]); const addDefaultTrackers = useCallback(() => { let list = form.values.announceList; @@ -246,7 +257,7 @@ export default function CreateTorrentForm() { > - - + + - Piece size + {t("createTorrent.pieceSize")} { form.setFieldValue("pieceLength", 2 ** value); }} /> - Tracker list, one per line, empty line between tiers - + {t("createTorrent.trackerList")} +