From 9376fe151f06fb70aca35c5fc14d5ce6ac61d471 Mon Sep 17 00:00:00 2001 From: Inex Code Date: Wed, 12 Apr 2023 14:55:34 +0300 Subject: [PATCH] feat(l10n): Add option for localizing the output of strings in Service classes --- selfprivacy_api/graphql/queries/services.py | 4 +- selfprivacy_api/locales/en/services.json | 52 ++++++++++++++++ selfprivacy_api/locales/ru/services.json | 12 ++++ .../services/bitwarden/__init__.py | 17 +++--- selfprivacy_api/services/gitea/__init__.py | 17 +++--- selfprivacy_api/services/jitsi/__init__.py | 20 +++---- .../services/mailserver/__init__.py | 17 +++--- .../services/nextcloud/__init__.py | 17 +++--- selfprivacy_api/services/ocserv/__init__.py | 20 +++---- selfprivacy_api/services/pleroma/__init__.py | 17 +++--- selfprivacy_api/utils/localization.py | 59 +++++++++++++++++++ 11 files changed, 194 insertions(+), 58 deletions(-) create mode 100644 selfprivacy_api/locales/en/services.json create mode 100644 selfprivacy_api/locales/ru/services.json create mode 100644 selfprivacy_api/utils/localization.py diff --git a/selfprivacy_api/graphql/queries/services.py b/selfprivacy_api/graphql/queries/services.py index 5398f81..17e0b98 100644 --- a/selfprivacy_api/graphql/queries/services.py +++ b/selfprivacy_api/graphql/queries/services.py @@ -13,6 +13,6 @@ from selfprivacy_api.services import get_all_services @strawberry.type class Services: @strawberry.field - def all_services(self) -> typing.List[Service]: + def all_services(self, locale: str = "en") -> typing.List[Service]: services = get_all_services() - return [service_to_graphql_service(service) for service in services] + return [service_to_graphql_service(service, locale) for service in services] diff --git a/selfprivacy_api/locales/en/services.json b/selfprivacy_api/locales/en/services.json new file mode 100644 index 0000000..fb8bba0 --- /dev/null +++ b/selfprivacy_api/locales/en/services.json @@ -0,0 +1,52 @@ +{ + "services": { + "bitwarden": { + "display_name": "Bitwarden", + "description": "Bitwarden is an open source password management solution you can run on your own server.", + "move_job": { + "name": "Move Bitwarden", + "description": "Moving Bitwarden data to {volume}" + } + }, + "gitea": { + "display_name": "Gitea", + "description": "Gitea is a lightweight code hosting solution written in Go.", + "move_job": { + "name": "Move Gitea", + "description": "Moving Gitea data to {volume}" + } + }, + "jitsi": { + "display_name": "Jitsi", + "description": "Jitsi is a free and open source video conferencing solution." + }, + "mailserver": { + "display_name": "Email", + "description": "E-Mail for company and family.", + "move_job": { + "name": "Move Mail Server", + "description": "Moving mailserver data to {volume}" + } + }, + "nextcloud": { + "display_name": "Nextcloud", + "description": "Nextcloud is a cloud storage service that offers a web interface and a desktop client.", + "move_job": { + "name": "Move Nextcloud", + "description": "Moving Nextcloud data to {volume}" + } + }, + "ocserv": { + "display_name": "OpenConnect VPN", + "description": "OpenConnect VPN to connect your devices and access the internet." + }, + "pleroma": { + "display_name": "Pleroma", + "description": "Pleroma is a free and open source microblogging server.", + "move_job": { + "name": "Move Pleroma", + "description": "Moving Pleroma data to {volume}" + } + } + } +} \ No newline at end of file diff --git a/selfprivacy_api/locales/ru/services.json b/selfprivacy_api/locales/ru/services.json new file mode 100644 index 0000000..22ac865 --- /dev/null +++ b/selfprivacy_api/locales/ru/services.json @@ -0,0 +1,12 @@ +{ + "services": { + "bitwarden": { + "display_name": "Bitwarden", + "description": "Bitwarden это менеджер паролей с открытым исходным кодом, который может работать на вашем сервере.", + "move_job": { + "name": "Переместить Bitwarden", + "description": "Перемещение данных Bitwarden на {volume}" + } + } + } +} \ No newline at end of file diff --git a/selfprivacy_api/services/bitwarden/__init__.py b/selfprivacy_api/services/bitwarden/__init__.py index 16d7746..76f9912 100644 --- a/selfprivacy_api/services/bitwarden/__init__.py +++ b/selfprivacy_api/services/bitwarden/__init__.py @@ -11,6 +11,7 @@ from selfprivacy_api.services.service import Service, ServiceDnsRecord, ServiceS from selfprivacy_api.utils import ReadUserData, WriteUserData, get_domain from selfprivacy_api.utils.block_devices import BlockDevice from selfprivacy_api.utils.huey import huey +from selfprivacy_api.utils.localization import Localization as L10n import selfprivacy_api.utils.network as network_utils from selfprivacy_api.services.bitwarden.icon import BITWARDEN_ICON @@ -24,14 +25,14 @@ class Bitwarden(Service): return "bitwarden" @staticmethod - def get_display_name() -> str: + def get_display_name(locale: str = "en") -> str: """Return service display name.""" - return "Bitwarden" + return L10n().get("services.bitwarden.display_name", locale) @staticmethod - def get_description() -> str: + def get_description(locale: str = "en") -> str: """Return service description.""" - return "Bitwarden is a password manager." + return L10n().get("services.bitwarden.description", locale) @staticmethod def get_svg_icon() -> str: @@ -143,11 +144,13 @@ class Bitwarden(Service): ), ] - def move_to_volume(self, volume: BlockDevice) -> Job: + def move_to_volume(self, volume: BlockDevice, locale: str = "en") -> Job: job = Jobs.add( type_id="services.bitwarden.move", - name="Move Bitwarden", - description=f"Moving Bitwarden data to {volume.name}", + name=L10n().get("services.bitwarden.move_job.name", locale), + description=L10n() + .get("services.bitwarden.move_job.description") + .format(volume=volume.name), ) move_service( diff --git a/selfprivacy_api/services/gitea/__init__.py b/selfprivacy_api/services/gitea/__init__.py index aacda5f..f5d889a 100644 --- a/selfprivacy_api/services/gitea/__init__.py +++ b/selfprivacy_api/services/gitea/__init__.py @@ -11,6 +11,7 @@ from selfprivacy_api.services.service import Service, ServiceDnsRecord, ServiceS from selfprivacy_api.utils import ReadUserData, WriteUserData, get_domain from selfprivacy_api.utils.block_devices import BlockDevice from selfprivacy_api.utils.huey import huey +from selfprivacy_api.utils.localization import Localization as L10n import selfprivacy_api.utils.network as network_utils from selfprivacy_api.services.gitea.icon import GITEA_ICON @@ -24,14 +25,14 @@ class Gitea(Service): return "gitea" @staticmethod - def get_display_name() -> str: + def get_display_name(locale: str = "en") -> str: """Return service display name.""" - return "Gitea" + return L10n().get("services.gitea.display_name", locale) @staticmethod - def get_description() -> str: + def get_description(locale: str = "en") -> str: """Return service description.""" - return "Gitea is a Git forge." + return L10n().get("services.gitea.description", locale) @staticmethod def get_svg_icon() -> str: @@ -140,11 +141,13 @@ class Gitea(Service): ), ] - def move_to_volume(self, volume: BlockDevice) -> Job: + def move_to_volume(self, volume: BlockDevice, locale: str = "en") -> Job: job = Jobs.add( type_id="services.gitea.move", - name="Move Gitea", - description=f"Moving Gitea data to {volume.name}", + name=L10n().get("services.gitea.move_job.name", locale), + description=L10n() + .get("services.gitea.move_job.description", locale) + .format(volume=volume.name), ) move_service( diff --git a/selfprivacy_api/services/jitsi/__init__.py b/selfprivacy_api/services/jitsi/__init__.py index 6b3a973..50ff4a9 100644 --- a/selfprivacy_api/services/jitsi/__init__.py +++ b/selfprivacy_api/services/jitsi/__init__.py @@ -3,19 +3,17 @@ import base64 import subprocess import typing -from selfprivacy_api.jobs import Job, Jobs -from selfprivacy_api.services.generic_service_mover import FolderMoveNames, move_service +import selfprivacy_api.utils.network as network_utils +from selfprivacy_api.jobs import Job from selfprivacy_api.services.generic_size_counter import get_storage_usage from selfprivacy_api.services.generic_status_getter import ( - get_service_status, get_service_status_from_several_units, ) +from selfprivacy_api.services.jitsi.icon import JITSI_ICON +from selfprivacy_api.utils.localization import Localization as L10n from selfprivacy_api.services.service import Service, ServiceDnsRecord, ServiceStatus from selfprivacy_api.utils import ReadUserData, WriteUserData, get_domain from selfprivacy_api.utils.block_devices import BlockDevice -from selfprivacy_api.utils.huey import huey -import selfprivacy_api.utils.network as network_utils -from selfprivacy_api.services.jitsi.icon import JITSI_ICON class Jitsi(Service): @@ -27,14 +25,14 @@ class Jitsi(Service): return "jitsi" @staticmethod - def get_display_name() -> str: + def get_display_name(locale: str = "en") -> str: """Return service display name.""" - return "Jitsi" + return L10n().get("services.jitsi.display_name", locale) @staticmethod - def get_description() -> str: + def get_description(locale: str = "en") -> str: """Return service description.""" - return "Jitsi is a free and open-source video conferencing solution." + return L10n().get("services.jitsi.description", locale) @staticmethod def get_svg_icon() -> str: @@ -138,5 +136,5 @@ class Jitsi(Service): ), ] - def move_to_volume(self, volume: BlockDevice) -> Job: + def move_to_volume(self, volume: BlockDevice, locale: str = "en") -> Job: raise NotImplementedError("jitsi service is not movable") diff --git a/selfprivacy_api/services/mailserver/__init__.py b/selfprivacy_api/services/mailserver/__init__.py index 78a2441..1fe5333 100644 --- a/selfprivacy_api/services/mailserver/__init__.py +++ b/selfprivacy_api/services/mailserver/__init__.py @@ -13,6 +13,7 @@ from selfprivacy_api.services.generic_status_getter import ( ) from selfprivacy_api.services.service import Service, ServiceDnsRecord, ServiceStatus import selfprivacy_api.utils as utils +from selfprivacy_api.utils.localization import Localization as L10n from selfprivacy_api.utils.block_devices import BlockDevice from selfprivacy_api.utils.huey import huey import selfprivacy_api.utils.network as network_utils @@ -27,12 +28,12 @@ class MailServer(Service): return "mailserver" @staticmethod - def get_display_name() -> str: - return "Mail Server" + def get_display_name(locale: str = "en") -> str: + return L10n().get("services.mailserver.display_name", locale) @staticmethod - def get_description() -> str: - return "E-Mail for company and family." + def get_description(locale: str = "en") -> str: + return L10n().get("services.mailserver.description", locale) @staticmethod def get_svg_icon() -> str: @@ -148,11 +149,13 @@ class MailServer(Service): ), ] - def move_to_volume(self, volume: BlockDevice) -> Job: + def move_to_volume(self, volume: BlockDevice, locale: str = "en") -> Job: job = Jobs.add( type_id="services.mailserver.move", - name="Move Mail Server", - description=f"Moving mailserver data to {volume.name}", + name=L10n().get("services.mailserver.move_job.name", locale), + description=L10n() + .get("services.mailserver.move_job.description", locale) + .format(volume=volume.name), ) move_service( diff --git a/selfprivacy_api/services/nextcloud/__init__.py b/selfprivacy_api/services/nextcloud/__init__.py index ad74354..115432d 100644 --- a/selfprivacy_api/services/nextcloud/__init__.py +++ b/selfprivacy_api/services/nextcloud/__init__.py @@ -9,6 +9,7 @@ from selfprivacy_api.services.generic_status_getter import get_service_status from selfprivacy_api.services.service import Service, ServiceDnsRecord, ServiceStatus from selfprivacy_api.utils import ReadUserData, WriteUserData, get_domain from selfprivacy_api.utils.block_devices import BlockDevice +from selfprivacy_api.utils.localization import Localization as L10n import selfprivacy_api.utils.network as network_utils from selfprivacy_api.services.nextcloud.icon import NEXTCLOUD_ICON @@ -22,14 +23,14 @@ class Nextcloud(Service): return "nextcloud" @staticmethod - def get_display_name() -> str: + def get_display_name(locale: str = "en") -> str: """Return service display name.""" - return "Nextcloud" + return L10n().get("services.nextcloud.display_name", locale) @staticmethod - def get_description() -> str: + def get_description(locale: str = "en") -> str: """Return service description.""" - return "Nextcloud is a cloud storage service that offers a web interface and a desktop client." + return L10n().get("services.nextcloud.description", locale) @staticmethod def get_svg_icon() -> str: @@ -148,11 +149,13 @@ class Nextcloud(Service): ), ] - def move_to_volume(self, volume: BlockDevice) -> Job: + def move_to_volume(self, volume: BlockDevice, locale: str = "en") -> Job: job = Jobs.add( type_id="services.nextcloud.move", - name="Move Nextcloud", - description=f"Moving Nextcloud to volume {volume.name}", + name=L10n().get("services.nextcloud.move_job.name", locale), + description=L10n() + .get("services.nextcloud.move_job.description", locale) + .format(volume=volume.name), ) move_service( self, diff --git a/selfprivacy_api/services/ocserv/__init__.py b/selfprivacy_api/services/ocserv/__init__.py index dcfacaa..34720d6 100644 --- a/selfprivacy_api/services/ocserv/__init__.py +++ b/selfprivacy_api/services/ocserv/__init__.py @@ -2,15 +2,15 @@ import base64 import subprocess import typing -from selfprivacy_api.jobs import Job, Jobs -from selfprivacy_api.services.generic_service_mover import FolderMoveNames, move_service -from selfprivacy_api.services.generic_size_counter import get_storage_usage + +import selfprivacy_api.utils.network as network_utils +from selfprivacy_api.jobs import Job from selfprivacy_api.services.generic_status_getter import get_service_status +from selfprivacy_api.services.ocserv.icon import OCSERV_ICON from selfprivacy_api.services.service import Service, ServiceDnsRecord, ServiceStatus from selfprivacy_api.utils import ReadUserData, WriteUserData from selfprivacy_api.utils.block_devices import BlockDevice -from selfprivacy_api.services.ocserv.icon import OCSERV_ICON -import selfprivacy_api.utils.network as network_utils +from selfprivacy_api.utils.localization import Localization as L10n class Ocserv(Service): @@ -21,12 +21,12 @@ class Ocserv(Service): return "ocserv" @staticmethod - def get_display_name() -> str: - return "OpenConnect VPN" + def get_display_name(locale: str = "en") -> str: + return L10n().get("services.ocserv.display_name", locale) @staticmethod - def get_description() -> str: - return "OpenConnect VPN to connect your devices and access the internet." + def get_description(locale: str = "en") -> str: + return L10n().get("services.ocserv.description", locale) @staticmethod def get_svg_icon() -> str: @@ -117,5 +117,5 @@ class Ocserv(Service): def get_storage_usage() -> int: return 0 - def move_to_volume(self, volume: BlockDevice) -> Job: + def move_to_volume(self, volume: BlockDevice, locale: str = "en") -> Job: raise NotImplementedError("ocserv service is not movable") diff --git a/selfprivacy_api/services/pleroma/__init__.py b/selfprivacy_api/services/pleroma/__init__.py index 4d2b85e..aa16dcc 100644 --- a/selfprivacy_api/services/pleroma/__init__.py +++ b/selfprivacy_api/services/pleroma/__init__.py @@ -9,6 +9,7 @@ from selfprivacy_api.services.generic_status_getter import get_service_status from selfprivacy_api.services.service import Service, ServiceDnsRecord, ServiceStatus from selfprivacy_api.utils import ReadUserData, WriteUserData, get_domain from selfprivacy_api.utils.block_devices import BlockDevice +from selfprivacy_api.utils.localization import Localization as L10n import selfprivacy_api.utils.network as network_utils from selfprivacy_api.services.pleroma.icon import PLEROMA_ICON @@ -21,12 +22,12 @@ class Pleroma(Service): return "pleroma" @staticmethod - def get_display_name() -> str: - return "Pleroma" + def get_display_name(locale: str = "en") -> str: + return L10n().get("services.pleroma.display_name", locale) @staticmethod - def get_description() -> str: - return "Pleroma is a microblogging service that offers a web interface and a desktop client." + def get_description(locale: str = "en") -> str: + return L10n().get("services.pleroma.description", locale) @staticmethod def get_svg_icon() -> str: @@ -128,11 +129,13 @@ class Pleroma(Service): ), ] - def move_to_volume(self, volume: BlockDevice) -> Job: + def move_to_volume(self, volume: BlockDevice, locale: str = "en") -> Job: job = Jobs.add( type_id="services.pleroma.move", - name="Move Pleroma", - description=f"Moving Pleroma to volume {volume.name}", + name=L10n().get("services.pleroma.move_job.name", locale), + description=L10n() + .get("services.pleroma.move_job.description", locale) + .format(volume=volume.name), ) move_service( self, diff --git a/selfprivacy_api/utils/localization.py b/selfprivacy_api/utils/localization.py new file mode 100644 index 0000000..89a1a54 --- /dev/null +++ b/selfprivacy_api/utils/localization.py @@ -0,0 +1,59 @@ +""" +A localization module that loads strings from JSONs in the locale directory. +It provides a function to get a localized string by its ID. +If the string is not found in the current locale, it will try to find it in the default locale. +If the string is not found in the default locale, it will return the ID. + +The locales are loaded into the memory at the api startup and kept in a singleton. +""" +import json +import os +import typing +from pathlib import Path + +from selfprivacy_api.utils.singleton_metaclass import SingletonMetaclass + +DEFAULT_LOCALE = "en" +LOCALE_DIR: Path = Path(__file__).parent.parent / "locales" + + +class Localization(metaclass=SingletonMetaclass): + """Localization class.""" + + def __init__(self): + self.locales: typing.Dict[str, typing.Dict[str, str]] = {} + self.load_locales() + + def load_locales(self): + """Load locales from locale directory.""" + for locale in os.listdir(str(LOCALE_DIR)): + locale_path = LOCALE_DIR / locale + if not locale_path.is_dir(): + continue + self.locales[locale] = {} + for file in os.listdir(str(locale_path)): + if file.endswith(".json"): + with open(locale_path / file, "r") as locale_file: + locale_data = self.flatten_dict(json.load(locale_file)) + self.locales[locale].update(locale_data) + + def get(self, string_id: str, locale: str = DEFAULT_LOCALE) -> str: + """Get localized string by its ID.""" + if locale in self.locales and string_id in self.locales[locale]: + return self.locales[locale][string_id] + if DEFAULT_LOCALE in self.locales and string_id in self.locales[DEFAULT_LOCALE]: + return self.locales[DEFAULT_LOCALE][string_id] + return string_id + + def flatten_dict( + self, d: typing.Dict[str, typing.Any], parent_key: str = "", sep: str = "." + ) -> typing.Dict[str, str]: + """Flatten a dict.""" + items = [] + for k, v in d.items(): + new_key = parent_key + sep + k if parent_key else k + if isinstance(v, dict): + items.extend(self.flatten_dict(v, new_key, sep=sep).items()) + else: + items.append((new_key, v)) + return dict(items)