Bunchee
Back to Blog

วิธี Override Translation System ใน Frappe สำหรับ React SPA

เรียนรู้วิธี Override ระบบ Translation ของ Frappe เพื่อรองรับ React/TypeScript SPA รวมถึงการ Extract strings จากไฟล์ TSX และการ Export เป็น JSON สำหรับ Frontend

วิธี Override Translation System ใน Frappe สำหรับ React SPA

ระบบ Translation ของ Frappe ถูกออกแบบมาสำหรับ Server-side Jinja Templates และ Python Code แต่เมื่อเราพัฒนา Single Page Application (SPA) ด้วย React/TypeScript เราต้อง Override ระบบเพื่อรองรับการทำงานใหม่

ปัญหาของระบบเดิม

Frappe's default write_translations_file() มี 2 ปัญหาสำหรับ SPA:

  1. ข้าม Untranslated Strings - เขียนเฉพาะ String ที่มี Translation แล้ว ทำให้ยากต่อการระบุว่ายังต้องแปลอะไรอีก
  2. ไม่รองรับ React Files - ไม่สามารถ Extract __() calls จาก .tsx/.jsx ได้

สิ่งที่จะทำ

  • รองรับ React/TypeScript SPA
  • Extract __() function จากไฟล์ .tsx/.jsx
  • เขียน ทุก Messages ลง CSV (รวมที่ยังไม่แปล)
  • รองรับภาษา ASEAN (ไทย, เวียดนาม, ลาว, เขมร, พม่า)

โครงสร้างไฟล์

your_app/
├── your_app/
│   ├── overrides/
│   │   └── translate.py           # Translation Override
│   └── translations/
│       ├── th.csv                 # Thai
│       ├── vi.csv                 # Vietnamese
│       └── lo.csv                 # Lao
├── dashboard/                     # React SPA
│   └── src/
│       ├── utils/
│       │   └── translations.ts    # SPA Translation Helper
│       └── components/
│           └── MyComponent.tsx
└── hooks.py

1. สร้าง Translation Override

สร้างไฟล์ translate.py:

import frappe
import os
import re
import csv
from frappe import _
from frappe.translate import (
    get_messages_for_app,
    get_all_translations,
    deduplicate_messages,
)

# รองรับภาษา ASEAN
ASEAN_LOCALES = ["th", "vi", "lo", "km", "my", "en", "en-US", "en-GB"]

def get_messages_from_spa_files(app_name: str):
    """
    Extract Translatable Strings จากไฟล์ React/TypeScript
    เฉพาะ Strings ที่ Wrap ด้วย __() function
    """
    messages = []
    spa_extensions = ('.tsx', '.jsx', '.ts', '.js')

    app_path = frappe.get_app_path(app_name)

    # Directories ที่มักเก็บ SPA Code
    spa_dirs = ['dashboard', 'frontend', 'spa', 'src']

    for spa_dir in spa_dirs:
        spa_path = os.path.join(app_path, spa_dir)
        if not os.path.exists(spa_path):
            continue

        for root, dirs, files in os.walk(spa_path):
            # ข้าม node_modules และ build directories
            dirs[:] = [d for d in dirs if d not in ['node_modules', 'dist', 'build', '.next']]

            for filename in files:
                if not filename.endswith(spa_extensions):
                    continue

                filepath = os.path.join(root, filename)
                try:
                    with open(filepath, 'r', encoding='utf-8') as f:
                        content = f.read()

                    # Extract __("string") และ __('string') patterns
                    patterns = [
                        r'__\(\s*["\']([^"\']+)["\']\s*\)',  # __("text") หรือ __('text')
                        r'__\(\s*`([^`]+)`\s*\)',            # __(`text`)
                    ]

                    for pattern in patterns:
                        matches = re.findall(pattern, content)
                        for match in matches:
                            if match.strip():
                                messages.append((match, filepath))

                except Exception as e:
                    frappe.log_error(f"Error reading {filepath}: {e}")

    return messages


def write_translations_file(app: str, lang: str, full_dict: dict = None, app_messages: list = None):
    """
    Override Frappe's write_translations_file:
    1. รวม SPA file messages
    2. เขียนทุก messages (รวมที่ยังไม่แปล)
    3. Sort alphabetically
    """
    from frappe.translate import get_translation_dict_from_file

    # ดึง Translations ที่มีอยู่
    if full_dict is None:
        full_dict = get_all_translations(lang)

    # ดึง Messages ทั้งหมดของ App
    if app_messages is None:
        app_messages = get_messages_for_app(app)

    # เพิ่ม SPA Messages
    spa_messages = get_messages_from_spa_files(app)
    app_messages.extend(spa_messages)

    # ลบ Duplicates
    app_messages = deduplicate_messages(app_messages)

    # สร้าง Translations Directory
    app_path = frappe.get_app_path(app)
    translations_dir = os.path.join(app_path, "translations")
    os.makedirs(translations_dir, exist_ok=True)

    # Path ไฟล์ Translation
    translation_file = os.path.join(translations_dir, f"{lang}.csv")

    # โหลด Translations จากไฟล์ที่มีอยู่ (preserve manual edits)
    existing_translations = {}
    if os.path.exists(translation_file):
        existing_translations = get_translation_dict_from_file(
            translation_file, lang, app
        )

    # Merge: ไฟล์ที่มีอยู่มีความสำคัญกว่า
    merged_dict = {**full_dict, **existing_translations}

    # สร้าง Output Rows - รวมทุก messages
    rows = []
    seen_sources = set()

    for message, source in app_messages:
        source_key = (message, source)
        if source_key in seen_sources:
            continue
        seen_sources.add(source_key)

        translation = merged_dict.get(message, "")

        # สำคัญ: รวมทุก messages แม้ยังไม่มี translation
        rows.append({
            "source": message,
            "translation": translation,
            "context": source if source else ""
        })

    # Sort alphabetically
    rows.sort(key=lambda x: x["source"].lower())

    # เขียน CSV
    with open(translation_file, 'w', encoding='utf-8', newline='') as f:
        writer = csv.DictWriter(f, fieldnames=["source", "translation", "context"])
        writer.writeheader()
        writer.writerows(rows)

    return len(rows)


def rebuild_all_translation_files(app: str = None):
    """Rebuild Translation Files สำหรับทุก Locales"""
    apps = [app] if app else frappe.get_installed_apps()

    for app_name in apps:
        for lang in ASEAN_LOCALES:
            try:
                count = write_translations_file(app_name, lang)
                print(f"  {app_name}/{lang}: {count} messages")
            except Exception as e:
                frappe.log_error(f"Error rebuilding {app_name}/{lang}: {e}")


@frappe.whitelist()
def export_translations_for_spa(app: str, lang: str = "th"):
    """
    API Endpoint สำหรับ Export Translations ให้ SPA
    Return JSON Format สำหรับ i18n Libraries
    """
    from frappe.translate import get_translation_dict_from_file

    app_path = frappe.get_app_path(app)
    translation_file = os.path.join(app_path, "translations", f"{lang}.csv")

    if not os.path.exists(translation_file):
        return {}

    translations = get_translation_dict_from_file(translation_file, lang, app)
    return translations

2. สร้าง SPA Integration Helper

สร้างไฟล์ translations.ts สำหรับ React:

// src/utils/translations.ts
import { useFrappeGetCall } from 'frappe-react-sdk';

interface TranslationDict {
  [key: string]: string;
}

// Global Translation Cache
let translationCache: TranslationDict = {};

export function useTranslations(app: string, lang: string = 'th') {
  const { data, error, isLoading } = useFrappeGetCall<{ message: TranslationDict }>(
    'your_app.overrides.translate.export_translations_for_spa',
    { app, lang }
  );

  if (data?.message) {
    translationCache = data.message;
  }

  return { translations: translationCache, error, isLoading };
}

// Translation Function สำหรับใช้ใน Components
export function __(text: string): string {
  return translationCache[text] || text;
}

// สำหรับใช้นอก React Components
export function translate(text: string): string {
  return translationCache[text] || text;
}

3. การใช้งานใน React Component

// src/components/MyComponent.tsx
import { __ } from '../utils/translations';

export function MyComponent() {
  return (
    <div>
      <h1>{__("Welcome to the Dashboard")}</h1>
      <p>{__("Select an option below")}</p>
      <button>{__("Submit")}</button>
    </div>
  );
}

4. ตั้งค่า hooks.py

# Override Translation Functions
override_whitelisted_methods = {
    "frappe.translate.write_translations_file":
        "your_app.overrides.translate.write_translations_file",
    "frappe.translate.rebuild_all_translation_files":
        "your_app.overrides.translate.rebuild_all_translation_files",
}

# Whitelist API Endpoint
whitelisted_methods = [
    "your_app.overrides.translate.export_translations_for_spa"
]

5. คำสั่ง Rebuild Translations

# Rebuild สำหรับ App เฉพาะ
bench --site your-site.local execute your_app.overrides.translate.rebuild_all_translation_files --args "['your_app']"

# Rebuild ทุก Apps
bench --site your-site.local execute your_app.overrides.translate.rebuild_all_translation_files

รูปแบบ CSV ที่ได้

CSV จะถูก Sort และมี Context ชัดเจน:

source,translation,context
Add New Item,เพิ่มรายการใหม่,/dashboard/src/components/ItemList.tsx
Cancel,ยกเลิก,/dashboard/src/components/Modal.tsx
Confirm,ยืนยัน,/dashboard/src/components/Modal.tsx
Dashboard,แดชบอร์ด,/dashboard/src/pages/Home.tsx
Delete,ลบ,/dashboard/src/components/ItemList.tsx
Submit,ส่ง,/dashboard/src/components/Form.tsx
Welcome,ยินดีต้อนรับ,/dashboard/src/pages/Home.tsx

ความแตกต่างจากระบบเดิม

| Feature | Frappe เดิม | Override ใหม่ | |---------|-------------|---------------| | Untranslated Strings | ข้าม | รวม (translation ว่าง) | | SPA Files | ไม่รองรับ | รองรับ .tsx/.jsx/.ts/.js | | Extraction Pattern | หลาย Patterns | เฉพาะ __() | | Sorting | ไม่ Sort | Sort Alphabetically | | Context | จำกัด | Full File Path |

Best Practices

  1. Wrap ทุก Translatable Strings - ใช้ __("text") แม้จะเป็นภาษาอังกฤษ
  2. หลีกเลี่ยง Dynamic Strings - ไม่ใช้ __(`Hello ${name}`) ให้ใช้ __("Hello {0}").replace("{0}", name)
  3. Keep Strings Simple - ไม่ใส่ HTML ใน Translatable Strings
  4. Review Untranslated - Translation ที่ว่างใน CSV = ยังไม่แปล
  5. Sort Matters - Alphabetical Sorting ทำให้ Review และ Diff ง่ายขึ้น

การแก้ไขปัญหา

Strings ไม่ถูก Extract

  • ตรวจสอบ File Extension เป็น .tsx/.jsx/.ts/.js
  • ตรวจสอบว่า Wrap ด้วย __()
  • ตรวจสอบ SPA Directory อยู่ใน Search List

Translations ไม่โหลดใน SPA

  • ตรวจสอบ API Endpoint Whitelisted
  • ตรวจสอบ CORS Settings
  • ตรวจสอบ Translation File มีอยู่และมี Content

สรุป

การ Override Translation System ช่วยให้:

  • Extract Strings จาก React/TypeScript ได้
  • เห็น Strings ที่ยังไม่แปลชัดเจน
  • Export เป็น JSON สำหรับ SPA
  • รองรับหลายภาษา ASEAN

เอกสารอ้างอิง