Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: begin adding settings menu #647

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

python357-1
Copy link
Collaborator

This PR addresses the "Settings Menu" item in the tagstudio roadmap

@CyanVoxel CyanVoxel added Type: UI/UX User interface and/or user experience Priority: High An important issue requiring attention labels Dec 16, 2024
@CyanVoxel CyanVoxel added this to the Alpha v9.5 (Post-SQL) milestone Dec 16, 2024
@VasigaranAndAngel
Copy link
Collaborator

this might be helpful for you

translation json files should be converted to .ts then, compiled into .qm using pyside6-lrelease to use it in the application easily. the following was my attempt.

localization
import json
import os
from pathlib import Path
from xml.etree import ElementTree

import structlog
from PySide6.QtCore import QLocale, QTranslator
from PySide6.QtWidgets import QApplication

TRANSLATIONS_PATH: Path = Path("tagstudio/resources/translations").absolute()

logger = structlog.getLogger()

installed_translators: list[QTranslator] = []
"List of currently installed QTranslator objects."


def _convert_json_to_ts(json_data: dict[str, str], language: str) -> str:
    """Convert JSON data to a TS (Qt Linguist XML) string format.

    This function takes a dictionary representing JSON data and a language code,
    and converts it into a TS XML string used for localization in Qt applications.
    The JSON data should have keys in the format "context.source" and values as
    translations. The function organizes the data into contexts and messages,
    constructs an XML tree, and returns the serialized XML as a string.

    Args:
        json_data (dict): A dictionary with keys as "context.source" and values as translations.
        language (str): The language code for the TS file.

    Returns:
        str: String representing the TS XML data.
    """
    # get contexts, sources and translations
    # `"home.base_title": "TagStudio Alpha"` -> `{"home": [("source", "TagStudio Alpha")]}`

    contexts: dict[str, list[tuple[str, str]]] = {}  # Contexts to sources and translations mapping.

    for key, translation in json_data.items():
        context_name, source = key.split(".", 1)
        if context_name not in contexts:
            contexts[context_name] = []
        contexts[context_name].append((source, translation))

    # make xml tree from contexts

    ts_root = ElementTree.Element("TS", version="2.1", language=language)

    for context_name, messages in contexts.items():
        context_element = ElementTree.SubElement(ts_root, "context")

        name_element = ElementTree.SubElement(context_element, "name")
        name_element.text = context_name

        for message in messages:
            source = message[0]
            translation = message[1]

            message_element = ElementTree.SubElement(context_element, "message")

            source_element = ElementTree.SubElement(message_element, "source")
            source_element.text = source

            translation_element = ElementTree.SubElement(message_element, "translation")
            translation_element.text = translation

    return ElementTree.tostring(ts_root, encoding="utf-8", xml_declaration=True).decode("utf-8")


def compile_language(json_path: Path) -> Path | None:
    """Compile a JSON localization file to a QM file using PySide6 tools.

    This function reads a JSON file containing localization data, converts it
    to a TS XML format, and then compiles it into a QM file using the
    `pyside6-lrelease` command. The compiled QM file is stored in a "compiled"
    directory within the same parent directory as the JSON file.

    Args:
        json_path (Path): The path to the JSON file to be compiled.

    Returns:
        Path | None: The path to the compiled QM file, or None if the compilation fails.
    """
    assert json_path.exists() and json_path.is_file()
    language_code = json_path.stem
    ts_path = json_path.with_suffix(".ts")
    qm_path = json_path.parent / "compiled" / json_path.with_suffix(".qm").name

    with json_path.open("r") as json_f:
        json_data = json.load(json_f)
        ts_data = _convert_json_to_ts(json_data, language_code)
        ts_path.write_text(ts_data)

        qm_path.parent.mkdir(exist_ok=True)
        command = f"pyside6-lrelease {ts_path} -qm {qm_path}"
        if os.system(command) != 0:
            logger.error(f"os.system({command}) failed")
            qm_path = None

    ts_path.unlink()

    return qm_path


def get_available_languages() -> list[tuple[str, QLocale.Language, QLocale.Country | None]]:
    """Retrieve a list of available languages from list of JSON translation files.

    This function scans the TRANSLATIONS_PATH directory for JSON files, extracts
    language codes from the filenames, and uses QLocale to determine the language
    and country associated with each code. It returns a list of tuples containing
    the language code, QLocale.Language, and optionally QLocale.Country.

    Returns:
        list[tuple[str, QLocale.Language, QLocale.Country | None]]: A list of
        tuples with language code, language, and country information.
    """
    languages = []
    for file in os.listdir(str(TRANSLATIONS_PATH)):
        if file.endswith(".json"):
            code = file.split(".")[0]

            locale = QLocale(code)
            language, country = locale.language(), locale.country()
            languages.append((code, language, country if "_" in code else None))

    return languages


def set_application_language(language: str, fallback_language: str) -> None:
    """Set the language and fallback language for the application.

    This function sets the language and fallback language for the application
    based on the provided language and fallback language codes.

    Args:
        language (str): The language code for the application's language.
        fallback_language (str): The language code for the application's fallback language.
    """
    app = QApplication.instance()
    if app is None:
        logger.error("Set Language failed. QApplication instance not found.")
        return

    _installed_translators: list[QTranslator] = []
    for json_file in {
        TRANSLATIONS_PATH / f"{fallback_language}.json",
        TRANSLATIONS_PATH / f"{language}.json",
    }:
        if not json_file.exists():
            logger.warning(f"Translation json file not found: {json_file}")
            continue
        
        qm_file = compile_language(json_file)

        if qm_file is None:
            logger.warning(f"Failed to compile translation file: {json_file}")
            continue
                
        translator = QTranslator()
        if translator.load(str(qm_file)):
            if app.installTranslator(translator):
                _installed_translators.append(translator)
            else:
                logger.error("Failed to install translator", translator=translator)
        else:
            logger.error("Failed to load file into translator", file=qm_file)
            
    # remove old translators                
    for translator in installed_translators:
        if app.removeTranslator(translator):
            installed_translators.remove(translator)
        else:
            logger.error("Failed to remove translator", translator=translator)
            
    installed_translators.extend(_installed_translators)

tests:

import os
from pathlib import Path

import py
import pytest
from PySide6.QtCore import QLocale, QTranslator
from PySide6.QtWidgets import QApplication
from src.qt.localization import compile_language, get_available_languages


def test_compile_language(tmpdir: py.path.local) -> None:
    json_path = tmpdir / "en.json"
    json_path.write_text('{"home.thumbnail_size": "Thumbnail Size"}', encoding="utf-8")

    tm_file = compile_language(Path(json_path))

    assert tm_file is not None
    assert tm_file.exists()
    assert tm_file.name == "en.qm"

    translator = QTranslator()
    translator.load(QLocale.Language.English, str(tm_file))

    instance = QApplication.instance() or QApplication([])
    instance.installTranslator(translator)

    assert instance.translate("home", "thumbnail_size") == "Thumbnail Size"


def test_get_available_languages(monkeypatch: pytest.MonkeyPatch) -> None:
    # NOTE: add more varied language codes to test if weblate uses them

    monkeypatch.setattr(
        os, "listdir", lambda _: ["en.json", "tok.json", "nb_NO.json", "yue_Hant.json"]
    )

    available_languages = get_available_languages()

    lang = QLocale.Language
    coun = QLocale.Country
    expect_code = ("en", "tok", "nb_NO", "yue_Hant")
    expect_lang = (lang.English, lang.TokiPona, lang.NorwegianBokmal, lang.Cantonese)
    expect_coun = (None, None, coun.Norway, coun.HongKong)

    for result, expected in zip(available_languages, zip(expect_code, expect_lang, expect_coun)):
        assert result == expected

python357-1 and others added 5 commits December 23, 2024 20:23

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
oops
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Priority: High An important issue requiring attention Type: UI/UX User interface and/or user experience
Projects
Status: 🚧 In progress
Development

Successfully merging this pull request may close these issues.

None yet

3 participants