Jelajahi Sumber

Merge 0dcc9bcc6b7411fc495999f83a4da46cbc879f55 into 1e4f7a367eba5712b4eda420a337db6888f42d2a

CPK 6 bulan lalu
induk
melakukan
b80796dc53

+ 90 - 0
.github/workflows/localization.yml

@@ -0,0 +1,90 @@
+name: Language Sync
+
+on:
+  workflow_dispatch:
+    inputs:
+      mode:
+        description: "Mode"
+        required: true
+        default: "Full Sync"
+        type: choice
+        options:
+        - Full Sync
+        - Download only
+        - Upload only
+  pull_request:
+
+permissions:
+  contents: write
+  pull-requests: write
+
+jobs:
+  compare-upload:
+    name: "Compare local English"
+    runs-on: ubuntu-latest
+    if: ${{ inputs.mode != 'Download only' }}
+    steps:
+      - name: Checkout
+        uses: actions/[email protected]
+      - name: Compare local reference
+        id: upload-comparison
+        run: python .github/workflows/localization/reference-comparison.py
+        env:
+          POEDITOR_API_KEY: ${{ secrets.POEDITORKEY }}
+          POEDITOR_PROJECT_ID: ${{ secrets.POEDITORPROJECT }}
+    outputs:
+      hasChanges: ${{ steps.upload-comparison.outputs.HAS_CHANGES }}
+
+  upload:
+    name: "Upload English changes"
+    runs-on: ubuntu-latest
+    needs: compare-upload
+    environment: poeditor
+    if: ${{ needs.compare-upload.outputs.hasChanges == 'true' }}
+    steps:
+      - name: Checkout
+        uses: actions/[email protected]
+      - name: Upload Changes
+        run: python .github/workflows/localization/upload-reference.py
+        env:
+          POEDITOR_API_KEY: ${{ secrets.POEDITORKEY }}
+          POEDITOR_PROJECT_ID: ${{ secrets.POEDITORPROJECT }}
+
+  download:
+    name: "Download changes"
+    runs-on: ubuntu-latest
+    if: ${{ inputs.mode != 'Upload only' }}
+    steps:
+      - name: Checkout
+        uses: actions/[email protected]
+      - name: Download languages
+        id: download-languages
+        run: python .github/workflows/localization/download-languages.py
+        env:
+          POEDITOR_API_KEY: ${{ secrets.POEDITORKEY }}
+          POEDITOR_PROJECT_ID: ${{ secrets.POEDITORPROJECT }}
+      - name: Create branch
+        if: ${{ steps.download-languages.outputs.HAS_CHANGES == 'true' }}
+        run: git checkout -B "language-update-${{ github.run_number }}-${{ github.run_attempt }}"
+      - name: Add changes and commit
+        if: ${{ steps.download-languages.outputs.HAS_CHANGES == 'true' }}
+        run: |
+          git config --global user.name "github-actions[bot]"
+          git config --global user.email "github-actions[bot]@users.noreply.github.com"
+          git add -A
+          git commit -m "Update language files"
+      - name: Push changes
+        if: ${{ steps.download-languages.outputs.HAS_CHANGES == 'true' }}
+        run: |
+          git push --set-upstream origin $(git rev-parse --abbrev-ref HEAD)
+      # - name: Create Pull Request
+      #   if: ${{ steps.download-languages.outputs.HAS_CHANGES == 'true' }}
+      #   run: |
+      #     gh pr create --title "Language Update ${{ github.run_number }}" \
+      #           --body "This PR was created automatically.
+
+      #           https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs/${{ github.job }}" \
+      #           --base master \
+      #           --head $(git rev-parse --abbrev-ref HEAD)
+      #   env:
+      #     GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 178 - 0
.github/workflows/localization/download-languages.py

@@ -0,0 +1,178 @@
+import json
+import os
+import requests
+from collections import OrderedDict
+from datetime import datetime
+
+API_KEY = os.getenv("POEDITOR_API_KEY")
+PROJECT_ID = os.getenv("POEDITOR_PROJECT_ID")
+GITHUB_OUTPUT = os.getenv('GITHUB_OUTPUT')
+
+# POEditor API URLs
+EXPORT_API_URL = "https://api.poeditor.com/v2/projects/export"
+LANGUAGES_LIST_URL = "https://api.poeditor.com/v2/languages/list"
+
+# Paths
+LOCALIZATION_DATA_PATH = "src/PixiEditor/Data/Localization/LocalizationData.json"
+LOCALES_DIR = "src/PixiEditor/Data/Localization/Languages/"
+
+def load_ordered_json(file_path):
+    """Load JSON preserving order (as an OrderedDict) and handle UTF-8 BOM."""
+    try:
+        with open(file_path, "r", encoding="utf-8-sig") as f:
+            return json.load(f, object_pairs_hook=OrderedDict)
+    except FileNotFoundError:
+        print(f"::error::File not found: {file_path}")
+        return OrderedDict()
+    except json.JSONDecodeError as e:
+        print(f"::error::Failed to parse JSON in {file_path}: {e}")
+        return OrderedDict()
+
+def write_ordered_json(file_path, data):
+    """Write an OrderedDict to a JSON file in UTF-8 (without BOM)."""
+    with open(file_path, "w", encoding="utf-8") as f:
+        json.dump(data, f, indent=2, ensure_ascii=False)
+
+def fetch_poeditor_language_json(language_code):
+    """
+    Fetch the latest key-value JSON for the given language from POEditor.
+    Returns a dictionary if successful, otherwise None.
+    """
+    if not API_KEY or not PROJECT_ID:
+        print("::error::Missing API_KEY or PROJECT_ID in environment variables.")
+        return None
+
+    response = requests.post(EXPORT_API_URL, data={
+        "api_token": API_KEY,
+        "id": PROJECT_ID,
+        "type": "key_value_json",
+        "language": language_code
+    })
+    if response.status_code == 200:
+        data = response.json()
+        if "result" in data and "url" in data["result"]:
+            download_url = data["result"]["url"]
+            remote_response = requests.get(download_url)
+            if remote_response.status_code == 200:
+                return remote_response.json()
+    print(f"::error::Failed to fetch POEditor data for language '{language_code}'")
+    return None
+
+def update_locale_file(language):
+    """
+    For a given language (dict from LocalizationData.json), update its locale file:
+      - Only keep keys that exist in the POEditor (remote) file.
+      - For keys present both locally and remotely, update with the remote value (preserving the original local order).
+      - Append new keys from POEditor at the bottom.
+    """
+    # Use "remote-code" if available, otherwise default to "code"
+    lang_code = language.get("remoteCode", language["code"])
+    if language["code"].lower() == "en":
+        return False # Skip English (do not update)
+
+    file_name = language["localeFileName"]
+    file_path = os.path.join(LOCALES_DIR, file_name)
+    local_data = load_ordered_json(file_path)
+    remote_data = fetch_poeditor_language_json(lang_code)
+    if remote_data is None:
+        print(f"::error::Skipping update for {language['name']} ({lang_code}) due to fetch error.")
+        return False
+
+    # Build new ordered data:
+    # 1. Start with keys from local file that exist in remote.
+    updated_data = OrderedDict()
+    for key in local_data:
+        if key in remote_data:
+            updated_data[key] = remote_data[key]
+    # 2. Append keys from remote that are missing locally.
+    for key in remote_data:
+        if key not in updated_data:
+            updated_data[key] = remote_data[key]
+
+    # Write file if changes exist (or if file was missing)
+    if updated_data != local_data:
+        write_ordered_json(file_path, updated_data)
+        print(f"✅ Updated locale file for {language['name']} ({language['code']}).")
+        return True
+    else:
+        print(f"✅ No changes for {language['name']} ({language['code']}).")
+        return False
+
+def fetch_languages_list():
+    """
+    Fetch the languages list from POEditor and return a mapping of language codes to
+    updated dates (formatted as "YYYY-MM-DD hh:MM:ss").
+    """
+    if not API_KEY or not PROJECT_ID:
+        print("::error::Missing API_KEY or PROJECT_ID in environment variables.")
+        return {}
+
+    response = requests.post(LANGUAGES_LIST_URL, data={
+        "api_token": API_KEY,
+        "id": PROJECT_ID
+    })
+    languages_updates = {}
+    if response.status_code == 200:
+        data = response.json()
+        if "result" in data and "languages" in data["result"]:
+            for lang in data["result"]["languages"]:
+                code = lang.get("code")
+                updated_iso = lang.get("updated")
+                if code and updated_iso:
+                    try:
+                        # Parse ISO8601 format (example: "2015-05-04T14:21:41+0000")
+                        dt = datetime.strptime(updated_iso, "%Y-%m-%dT%H:%M:%S%z")
+                        formatted = dt.strftime("%Y-%m-%d %H:%M:%S")
+                        languages_updates[code.lower()] = formatted
+                    except Exception as e:
+                        print(f"::error::Failed to parse date for language '{code}': {e}")
+    else:
+        print("::error::Failed to fetch languages list from POEditor.")
+    return languages_updates
+
+def update_localization_data(languages_updates):
+    """
+    Update the lastUpdated field for each language (except English) in LocalizationData.json.
+    """
+    localization_data = load_ordered_json(LOCALIZATION_DATA_PATH)
+    if "Languages" not in localization_data:
+        print("::error::'Languages' key not found in LocalizationData.json")
+        return
+
+    for language in localization_data["Languages"]:
+        code = language.get("code", "").lower()
+        if code == "en":
+            continue  # Do not update English
+        if code in languages_updates:
+            language["lastUpdated"] = languages_updates[code]
+    write_ordered_json(LOCALIZATION_DATA_PATH, localization_data)
+    print("✅ Updated LocalizationData.json with new lastUpdated values.")
+
+def main():
+    # Fetch updated dates for languages from POEditor
+    languages_updates = fetch_languages_list()
+
+    # Load LocalizationData.json and update each language file (except English)
+    localization_data = load_ordered_json(LOCALIZATION_DATA_PATH)
+    if "Languages" not in localization_data:
+        print("::error::'Languages' key not found in LocalizationData.json")
+        exit(1)
+        return
+    
+    has_changes = False
+
+    for language in localization_data["Languages"]:
+        if language.get("code", "").lower() == "en":
+            continue
+        if update_locale_file(language):
+            has_changes = True
+
+    with open(GITHUB_OUTPUT, "a") as f:
+        f.write(f"HAS_CHANGES={str(has_changes).lower()}")
+
+    # Update lastUpdated field in LocalizationData.json
+    update_localization_data(languages_updates)
+    print("🎉 All language updates complete.")
+
+if __name__ == "__main__":
+    main()

+ 96 - 0
.github/workflows/localization/reference-comparison.py

@@ -0,0 +1,96 @@
+import json
+import os
+import requests
+
+API_KEY = os.getenv("POEDITOR_API_KEY")
+PROJECT_ID = os.getenv("POEDITOR_PROJECT_ID")
+GITHUB_OUTPUT = os.getenv('GITHUB_OUTPUT')
+
+API_URL = "https://api.poeditor.com/v2/projects/export"
+LANGUAGE = "en"
+LOCAL_FILE_PATH = "src/PixiEditor/Data/Localization/Languages/en.json"
+
+def fetch_poeditor_json():
+    """Fetches the latest en.json from POEditor API (remote data)"""
+    if not API_KEY or not PROJECT_ID:
+        print("::error::Missing API_KEY or PROJECT_ID in environment variables.")
+        return None
+
+    response = requests.post(API_URL, data={
+        "api_token": API_KEY,
+        "id": PROJECT_ID,
+        "type": "key_value_json",
+        "language": LANGUAGE
+    })
+
+    if response.status_code == 200:
+        data = response.json()
+        if "result" in data and "url" in data["result"]:
+            download_url = data["result"]["url"]
+            latest_response = requests.get(download_url)
+            return latest_response.json() if latest_response.status_code == 200 else None
+    return None
+
+def load_local_json():
+    """Loads the local en.json file (authoritative source)"""
+    try:
+        with open(LOCAL_FILE_PATH, "r", encoding="utf-8") as file:
+            return json.load(file)
+    except FileNotFoundError:
+        print("::error::Local en.json file not found!")
+        return {}
+
+def compare_json(local_data, remote_data):
+    """Compares the local and remote JSON data, detecting added, removed, and modified keys"""
+    modifications = []
+    additions = []
+    deletions = []
+    
+    # Check for modified keys (key exists in both, but value changed)
+    for key, local_value in local_data.items():
+        remote_value = remote_data.get(key)
+        if remote_value is not None and local_value != remote_value:
+            modifications.append(f"🔄 {key}: '{remote_value}' → '{local_value}'")
+
+    # Check for added keys (exist in local but missing in POEditor)
+    for key in local_data.keys() - remote_data.keys():
+        additions.append(f"➕ {key}: '{local_data[key]}'")
+
+    # Check for removed keys (exist in POEditor but missing locally)
+    for key in remote_data.keys() - local_data.keys():
+        deletions.append(f"❌ {key}: '{remote_data[key]}'")
+
+    return modifications, additions, deletions
+
+def print_group(title, items):
+    """Prints grouped items using GitHub Actions logging format"""
+    if items:
+        print(f"::group::{len(items)} {title}")
+        for item in items:
+            print(item)
+        print("::endgroup::")
+
+def main():
+    remote_json = fetch_poeditor_json()
+    if remote_json is None:
+        print("::error::Failed to fetch POEditor en.json")
+        exit(1)
+        return
+    
+    local_json = load_local_json()
+    
+    modifications, additions, deletions = compare_json(local_json, remote_json)
+    has_changes = (modifications or additions or deletions)
+
+    with open(GITHUB_OUTPUT, "a") as f:
+        f.write(f"HAS_CHANGES={str(has_changes).lower()}")
+
+    if not has_changes:
+        print("✅ No changes detected. Local and remote are in sync.")
+    else:
+        print_group("Key(s) Modified", modifications)
+        print_group("Key(s) to be Added", additions)
+        print_group("Key(s) to be Removed", deletions)
+
+if __name__ == "__main__":
+    main()

+ 50 - 0
.github/workflows/localization/upload-reference.py

@@ -0,0 +1,50 @@
+import os
+import requests
+
+# Configuration
+API_KEY = os.getenv("POEDITOR_API_KEY")
+PROJECT_ID = os.getenv("POEDITOR_PROJECT_ID")
+UPLOAD_API_URL = "https://api.poeditor.com/v2/projects/upload"
+LOCAL_FILE_PATH = "src/PixiEditor/Data/Localization/Languages/en.json"
+
+def upload_en_json():
+    if not API_KEY or not PROJECT_ID:
+        print("::error::Missing POEDITOR_API_KEY or POEDITOR_PROJECT_ID environment variables.")
+        exit(1)
+        return
+
+    try:
+        with open(LOCAL_FILE_PATH, "rb") as file:
+            files = {
+                "file": ("en.json", file, "application/json")
+            }
+            data = {
+                "api_token": API_KEY,
+                "id": PROJECT_ID,
+                "updating": "terms_translations",  # Updates both terms and translations.
+                "language": "en",                  # Specify language as English.
+                "overwrite": 1,                    # Overwrite existing terms/translations.
+                "sync_terms": 1,                   # Sync terms: delete terms not in the uploaded file.
+                "fuzzy_trigger": 1                 # Mark translations in other languages as fuzzy.
+            }
+            response = requests.post(UPLOAD_API_URL, data=data, files=files)
+    except FileNotFoundError:
+        print(f"::error::Local file not found: {LOCAL_FILE_PATH}")
+        return
+
+    if response.status_code == 200:
+        result = response.json()
+        if result.get("response", {}).get("status") == "success":
+            print("✅ Upload succeeded:")
+            print(result)
+        else:
+            print("::error::Upload failed:")
+            print(result)
+            exit(1)
+    else:
+        print("::error::HTTP Error:", response.status_code)
+        print(response.text)
+        exit(1)
+
+if __name__ == "__main__":
+    upload_en_json()

+ 1 - 0
src/PixiEditor/Data/Localization/LocalizationData.json

@@ -32,6 +32,7 @@
     {
       "name": "中文",
       "code": "zh",
+      "remoteCode": "zh-CN",
       "localeFileName": "zh.json",
       "iconFileName": "zh.png",
       "lastUpdated": "2023-05-15 03:23:28"

+ 5 - 2
src/PixiEditor/Data/Localization/LocalizationDataSchema.json

@@ -16,18 +16,21 @@
             "description": "The code associated with the language",
             "minLength": 2
           },
+          "remote-code": {
+            "type": "string",
+            "description": "Code used by POEditor",
+            "minLength": 2
+          },
           "localeFileName": {
             "type": "string",
             "description": "The name of the key-value json file found in Data/Localization/Languages. Must be prepended with extension unique name and : (e.g. pixieditor.sampleExtension:en.json)",
             "pattern": ".*\\.json",
-            "format": "uri",
             "default": ".json"
           },
           "iconFileName": {
             "type": "string",
             "description": "The name of the png icon for the language found in Images/LanguageFlags",
             "pattern": ".*\\.png",
-            "format": "uri",
             "default": ".png"
           },
           "rightToLeft": {