From 1d5167e56ba39a7ce0d00de86d9cdc0015ceba3f Mon Sep 17 00:00:00 2001 From: zihu12 <93234588+zihu12@users.noreply.github.com> Date: Wed, 12 Jun 2024 08:13:35 +0000 Subject: [PATCH] Allow custom LibreTranslate instance --- lib/constants.dart | 3 + lib/generated/intl/messages_en.dart | 3 + lib/generated/l10n.dart | 20 ++++ lib/l10n/intl_en.arb | 4 + lib/main.dart | 3 + lib/settings/_general.dart | 154 ++++++++++++++++++---------- lib/utils/translation.dart | 58 +++++------ 7 files changed, 161 insertions(+), 84 deletions(-) diff --git a/lib/constants.dart b/lib/constants.dart index 06b7a36c..b49961d1 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -42,6 +42,9 @@ const optionThemeMode = 'theme.mode'; const optionThemeTrueBlack = 'theme.true_black'; const optionThemeColorScheme = 'theme.color_scheme'; +const optionTranslator = 'translator'; +const optionTranslatorKey = 'translatorKey'; + const optionTweetsHideSensitive = 'tweets.hide_sensitive'; const optionUserTrendsLocations = 'trends.locations'; diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index bd68c090..f4b49ca9 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -481,6 +481,9 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage( "To import subscriptions from an existing Twitter/X account, enter your username below."), "toggle_all": MessageLookupByLibrary.simpleMessage("Toggle All"), + "translator_description": MessageLookupByLibrary.simpleMessage( + "Use a custom LibreTranslate instance"), + "translator_label": MessageLookupByLibrary.simpleMessage("Translator"), "trending": MessageLookupByLibrary.simpleMessage("Trending"), "trends": MessageLookupByLibrary.simpleMessage("Trends"), "true_black": MessageLookupByLibrary.simpleMessage("True Black?"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 10e3af66..56d58e96 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -2998,6 +2998,26 @@ class L10n { ); } + /// `Translator` + String get translator_label { + return Intl.message( + 'Translator', + name: 'translator_label', + desc: '', + args: [], + ); + } + + /// `Use a custom LibreTranslate instance` + String get translator_description { + return Intl.message( + 'Use a custom LibreTranslate instance', + name: 'translator_description', + desc: '', + args: [], + ); + } + /// `Enter your comma separated Twitter/X usernames` String get enter_comma_separated_twitter_usernames { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 63210c65..60c03531 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -581,6 +581,10 @@ "@share_tweet_as_image": {}, "to_import_specific_subscriptions_enter_your_comma_separated_usernames_below": "To import specific subscriptions, enter your comma separated usernames below.", "@to_import_specific_subscriptions_enter_your_comma_separated_usernames_below": {}, + "translator_label": "Translator", + "@translator_label": {}, + "translator_description": "Use a custom LibreTranslate instance", + "@translator_description": {}, "enter_comma_separated_twitter_usernames": "Enter your comma separated Twitter/X usernames", "@enter_comma_separated_twitter_usernames": {}, "usernames": "Usernames", diff --git a/lib/main.dart b/lib/main.dart index a0046d98..f0507e23 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -41,6 +41,7 @@ import 'package:squawker/utils/accent_util.dart'; import 'package:squawker/utils/data_service.dart'; import 'package:squawker/utils/iterables.dart'; import 'package:squawker/utils/misc.dart'; +import 'package:squawker/utils/translation.dart'; import 'package:squawker/utils/urls.dart'; import 'package:timeago/timeago.dart' as timeago; @@ -239,6 +240,8 @@ Future main() async { AppHttpClient.setProxy(prefService.get(optionProxy)); + TranslationAPI.setTranslationHost(prefService.get(optionTranslator),prefService.get(optionTranslatorKey)); + runApp(PrefService( service: prefService, child: MultiProvider( diff --git a/lib/settings/_general.dart b/lib/settings/_general.dart index fd350008..0b039bf4 100644 --- a/lib/settings/_general.dart +++ b/lib/settings/_general.dart @@ -15,6 +15,7 @@ import 'package:squawker/utils/misc.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:pref/pref.dart'; +import 'package:squawker/utils/translation.dart'; class SettingLocale { final String code; @@ -42,29 +43,69 @@ class SettingsGeneralFragment extends StatelessWidget { final controller = TextEditingController(text: prefs.get(optionShareBaseUrl)); return PrefDialog( - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: Text(L10n.of(context).cancel)), - TextButton( - onPressed: () async { - await prefs.set(optionShareBaseUrl, controller.text); - Navigator.pop(context); - }, + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(L10n.of(context).cancel)), + TextButton( + onPressed: () async { + await prefs.set(optionShareBaseUrl, controller.text); + Navigator.pop(context); + }, child: Text(L10n.of(context).save) ) - ], - title: Text(L10n.of(context).share_base_url), - children: [ - SizedBox( - width: mediaQuery.size.width, - child: TextFormField( - controller: controller, + ], + title: Text(L10n.of(context).share_base_url), + children: [ + SizedBox( + width: mediaQuery.size.width, + child: TextFormField( + controller: controller, decoration: InputDecoration(hintText: 'https://x.com', hintStyle: TextStyle(color: Theme.of(context).disabledColor)), - ), - ) + ), + ) ] ); } + PrefDialog _createTranslateDialog(BuildContext context) { + BasePrefService prefs = PrefService.of(context); + MediaQueryData mediaQuery = MediaQuery.of(context); + + final controllerAPI = TextEditingController(text: prefs.get(optionTranslator)); + final controllerKEY = TextEditingController(text: prefs.get(optionTranslatorKey)); + + return PrefDialog( + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(L10n.of(context).cancel)), + TextButton( + onPressed: () async { + TranslationAPI.setTranslationHost(controllerAPI.text, controllerKEY.text); + await prefs.set(optionTranslator, controllerAPI.text); + await prefs.set(optionTranslatorKey, controllerKEY.text); + Navigator.pop(context); + }, + child: Text(L10n.of(context).save)) + ], + title: Text(L10n.of(context).translator_label), + children: [ + SizedBox( + width: mediaQuery.size.width, + child: TextFormField( + controller: controllerAPI, + decoration: InputDecoration( + hintText: 'libretranslate.example.org', hintStyle: TextStyle(color: Theme.of(context).disabledColor)), + ), + ), + SizedBox( + width: mediaQuery.size.width, + child: TextFormField( + controller: controllerKEY, + decoration: + InputDecoration(hintText: 'API KEY', hintStyle: TextStyle(color: Theme.of(context).disabledColor)), + ), + ) + ]); + } + PrefDialog _createProxyDialog(BuildContext context) { BasePrefService prefs = PrefService.of(context); MediaQueryData mediaQuery = MediaQuery.of(context); @@ -72,31 +113,31 @@ class SettingsGeneralFragment extends StatelessWidget { final controller = TextEditingController(text: prefs.get(optionProxy)); return PrefDialog( - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: Text(L10n.of(context).cancel)), - TextButton( - onPressed: () async { - try { - AppHttpClient.setProxy(controller.text); - await prefs.set(optionProxy, controller.text); + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(L10n.of(context).cancel)), + TextButton( + onPressed: () async { + try { + AppHttpClient.setProxy(controller.text); + await prefs.set(optionProxy, controller.text); } catch (e, s) { - await showAlertDialog(context, L10n.of(context).proxy_error, e.toString()); - } - Navigator.pop(context); - }, + await showAlertDialog(context, L10n.of(context).proxy_error, e.toString()); + } + Navigator.pop(context); + }, child: Text(L10n.of(context).save) ) - ], - title: Text(L10n.of(context).proxy_label), - children: [ - SizedBox( - width: mediaQuery.size.width, - child: TextFormField( - controller: controller, + ], + title: Text(L10n.of(context).proxy_label), + children: [ + SizedBox( + width: mediaQuery.size.width, + child: TextFormField( + controller: controller, decoration: InputDecoration(hintText: 'scheme://[user:pwd@]host:port', hintStyle: TextStyle(color: Theme.of(context).disabledColor)), - ), - ) + ), + ) ] ); } @@ -106,22 +147,22 @@ class SettingsGeneralFragment extends StatelessWidget { List exclusionsFeedLst = (prefs.get(optionExclusionsFeed) as String).split(','); return PrefDialog( - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: Text(L10n.of(context).cancel)), - TextButton( - onPressed: () async { - await prefs.set(optionExclusionsFeed, exclusionsFeedLst.join(',')); - Navigator.pop(context); - }, + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(L10n.of(context).cancel)), + TextButton( + onPressed: () async { + await prefs.set(optionExclusionsFeed, exclusionsFeedLst.join(',')); + Navigator.pop(context); + }, child: Text(L10n.of(context).save) ) - ], - title: Text(L10n.of(context).exclusions_feed_label), - children: [ - ExclusionsFeedSetting( - exclusionsFeedLst: exclusionsFeedLst, - onChanged: (List lst) { - exclusionsFeedLst = lst; + ], + title: Text(L10n.of(context).exclusions_feed_label), + children: [ + ExclusionsFeedSetting( + exclusionsFeedLst: exclusionsFeedLst, + onChanged: (List lst) { + exclusionsFeedLst = lst; } ), ] @@ -203,6 +244,11 @@ class SettingsGeneralFragment extends StatelessWidget { subtitle: Text(L10n.of(context).proxy_description), dialog: _createProxyDialog(context), ), + PrefDialogButton( + title: Text(L10n.of(context).translator_label), + subtitle: Text(L10n.of(context).translator_description), + dialog: _createTranslateDialog(context), + ), PrefSwitch( title: Text(L10n.of(context).disable_screenshots), subtitle: Text(L10n.of(context).disable_screenshots_hint), @@ -480,10 +526,10 @@ class ExclusionsFeedSettingState extends State { children: [ Expanded( child: DynamicTextfield( - key: UniqueKey(), - initialValue: _exclusionsFeedLst[index].trim(), - onChanged: (String v) { - _exclusionsFeedLst[index] = v.trim(); + key: UniqueKey(), + initialValue: _exclusionsFeedLst[index].trim(), + onChanged: (String v) { + _exclusionsFeedLst[index] = v.trim(); } ), ), diff --git a/lib/utils/translation.dart b/lib/utils/translation.dart index 74a8c645..4fb1221b 100644 --- a/lib/utils/translation.dart +++ b/lib/utils/translation.dart @@ -34,22 +34,21 @@ class TranslationAPI { // translate.astian.org, translate.foxhaven.cyou, trans.zillyhuhn.com // other possibilities working but badly translated: // translate.terraprint.co - static final translation_hosts = ['libretranslate.de', 'translate.fedilab.app', 'translate.argosopentech.com']; - static int current_translation_host_idx = 0; // Random().nextInt(translation_hosts.length); + static var translationHost = 'translate.fedilab.app'; + static var translationAPIKey = ''; + static const maxRetries = 3; static Map langCodeReplace = { 'iw': 'he' }; - static String translationHost() { - return translation_hosts[current_translation_host_idx]; - } - - static String nextTranslationHost() { - current_translation_host_idx++; - if (current_translation_host_idx == translation_hosts.length) { - current_translation_host_idx = 0; + static void setTranslationHost(String? host, String? key) { + if (host == null || host.isEmpty) { + translationHost = 'translate.fedilab.app'; + translationAPIKey = ''; + } else { + translationHost = host; + translationAPIKey = key?.toString() ?? ''; } - return translationHost(); } static Future getSupportedLanguages() async { @@ -58,25 +57,24 @@ class TranslationAPI { return cacheRequest(key, () async { int connectTries = 0; String? errorMessage; - while (connectTries < translation_hosts.length) { + while (connectTries < maxRetries) { try { - var response = await AppHttpClient.httpGet(Uri.https(translationHost(), '/languages')).timeout(const Duration(seconds: 3)); + var response = await AppHttpClient.httpGet(Uri.https(translationHost, '/languages')).timeout(const Duration(seconds: 3)); TranslationAPIResult rsp = await parseResponse(response); if (rsp.success) { return rsp; } else { - errorMessage ??= 'get supported languages error ${rsp.errorMessage} from host ${translationHost()}'; - log.warning('get supported languages error ${rsp.errorMessage} from host ${translationHost()}'); + errorMessage ??= 'get supported languages error ${rsp.errorMessage} from host $translationHost'; + log.warning('get supported languages error ${rsp.errorMessage} from host $translationHost'); } } on TimeoutException { - errorMessage ??= 'get supported languages timeout from host ${translationHost()}'; - log.warning('get supported languages timeout from host ${translationHost()}'); + errorMessage ??= 'get supported languages timeout from host $translationHost'; + log.warning('get supported languages timeout from host $translationHost'); } catch (exc) { - errorMessage ??= 'get supported languages error from ${translationHost()}\n${exc.toString()}'; - log.warning('get supported languages error from ${translationHost()}\n${exc.toString()}'); + errorMessage ??= 'get supported languages error from $translationHost\n${exc.toString()}'; + log.warning('get supported languages error from $translationHost\n${exc.toString()}'); } - nextTranslationHost(); connectTries++; } return TranslationAPIResult(success: false, body: '', errorMessage: errorMessage ?? 'Unable to get supported languages'); @@ -94,7 +92,8 @@ class TranslationAPI { 'q': text.where((e) => e.isNotEmpty).toList(), 'source': actualSourceLanguage, 'target': targetLanguage, - 'format': 'text' + 'format': 'text', + 'api_key': translationAPIKey }; var key = 'translation.$actualSourceLanguage.$targetLanguage.$id'; @@ -102,26 +101,25 @@ class TranslationAPI { var res = await cacheRequest(key, () async { int connectTries = 0; String? errorMessage; - while (connectTries < translation_hosts.length) { + while (connectTries < maxRetries) { try { - var response = await AppHttpClient.httpPost(Uri.https(translationHost(), '/translate'), + var response = await AppHttpClient.httpPost(Uri.https(translationHost, '/translate'), body: jsonEncode(formData), headers: {'Content-Type': 'application/json'}).timeout(const Duration(seconds: 3)); TranslationAPIResult rsp = await parseResponse(response); if (rsp.success) { return rsp; } else { - errorMessage ??= 'translate error ${rsp.errorMessage} from host ${translationHost()}'; - log.warning('translate error ${rsp.errorMessage} from host ${translationHost()}'); + errorMessage ??= 'translate error ${rsp.errorMessage} from host $translationHost'; + log.warning('translate error ${rsp.errorMessage} from host $translationHost'); } } on TimeoutException { - errorMessage ??= 'translate timeout from host ${translationHost()}'; - log.warning('translate timeout from host ${translationHost()}'); + errorMessage ??= 'translate timeout from host $translationHost'; + log.warning('translate timeout from host $translationHost'); } catch (exc) { - errorMessage ??= 'translate error from ${translationHost()}\n${exc.toString()}'; - log.warning('translate error from ${translationHost()}\n${exc.toString()}'); + errorMessage ??= 'translate error from $translationHost\n${exc.toString()}'; + log.warning('translate error from $translationHost\n${exc.toString()}'); } - nextTranslationHost(); connectTries++; } return TranslationAPIResult(success: false, body: '', errorMessage: errorMessage ?? 'Unable to send translation request');