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

ระบบ Translation ของ Frappe ถูกออกแบบมาสำหรับ Server-side Jinja Templates และ Python Code แต่เมื่อเราพัฒนา Single Page Application (SPA) ด้วย React/TypeScript เราต้อง Override ระบบเพื่อรองรับการทำงานใหม่
ปัญหาของระบบเดิม
Frappe's default write_translations_file() มี 2 ปัญหาสำหรับ SPA:
- ข้าม Untranslated Strings - เขียนเฉพาะ String ที่มี Translation แล้ว ทำให้ยากต่อการระบุว่ายังต้องแปลอะไรอีก
- ไม่รองรับ 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
- Wrap ทุก Translatable Strings - ใช้
__("text")แม้จะเป็นภาษาอังกฤษ - หลีกเลี่ยง Dynamic Strings - ไม่ใช้
__(`Hello ${name}`)ให้ใช้__("Hello {0}").replace("{0}", name) - Keep Strings Simple - ไม่ใส่ HTML ใน Translatable Strings
- Review Untranslated - Translation ที่ว่างใน CSV = ยังไม่แปล
- 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