CI / test (push) Canceled after 0s
fix các bug theo yêu cầu https://fptsoftware362-my.sharepoint.com/❌/g/personal/nampdt_fpt_com/IQAHBJ4A9xqDTLgvt2bhukJEAdRB5LRz2hbJpTivvIiBSYM?wdExp=TEAMS-TREATMENT&web=1&isSPOFile=1&ovuser=f01e930a-b52e-42b1-b70f-a8882b5d043b%2CAnhTNM1%40fpt.com&clickparams=eyJBcHBOYW1lIjoiVGVhbXMtRGVza3RvcCIsIkFwcFZlcnNpb24iOiI0OS8yNjA4MTMxOTMxNyIsIkhhc0ZlZGVyYXRlZFVzZXIiOmZhbHNlfQ%3D%3D --------- Co-authored-by: Duy Le Huu <duylh19@fpt.com> Reviewed-on: #10 Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
399 lines
13 KiB
Markdown
399 lines
13 KiB
Markdown
# i18n — Quy tắc xử lý chuỗi hiển thị
|
|
|
|
**Nguồn:** docstring `i18n/__init__.py`
|
|
|
|
---
|
|
|
|
## 1. Ngôn ngữ được hỗ trợ
|
|
|
|
Cowork Local hỗ trợ 3 ngôn ngữ:
|
|
|
|
```python
|
|
LANGUAGES = {
|
|
"en": "English",
|
|
"ja": "日本語",
|
|
"vi": "Tiếng Việt",
|
|
}
|
|
|
|
LANGUAGE_SHORT = {
|
|
"en": "EN",
|
|
"ja": "JP",
|
|
"vi": "VN",
|
|
}
|
|
|
|
DEFAULT_LANGUAGE = "vi"
|
|
```
|
|
|
|
Ngôn ngữ mặc định là **Tiếng Việt (`vi`)**.
|
|
|
|
### Hàm `tr()`
|
|
|
|
Sử dụng:
|
|
|
|
```python
|
|
tr(key, **kwargs)
|
|
```
|
|
|
|
để lấy chuỗi hiển thị theo ngôn ngữ hiện tại.
|
|
|
|
Thứ tự fallback:
|
|
|
|
```text
|
|
Ngôn ngữ hiện tại → English (en) → chính key
|
|
```
|
|
|
|
Ví dụ, nếu đang dùng tiếng Nhật nhưng key `workspace.tab_folder` chưa có bản dịch tiếng Nhật:
|
|
|
|
```text
|
|
JA → EN → workspace.tab_folder
|
|
```
|
|
|
|
Ứng dụng **không được crash** chỉ vì thiếu bản dịch.
|
|
|
|
Nếu UI hiển thị một chuỗi dạng:
|
|
|
|
```text
|
|
workspace.tab_folder
|
|
```
|
|
|
|
thì đây là dấu hiệu cho thấy **đang thiếu translation key**.
|
|
|
|
### Placeholder
|
|
|
|
Nếu chuỗi có placeholder, truyền giá trị thông qua `kwargs`:
|
|
|
|
```python
|
|
tr("composer.attachments", n=3)
|
|
```
|
|
|
|
Việc `.format(**kwargs)` được thực hiện sau khi lấy chuỗi dịch.
|
|
|
|
---
|
|
|
|
## 2. Widget nào phải cập nhật khi đổi ngôn ngữ?
|
|
|
|
Có 2 loại widget:
|
|
|
|
| Loại widget | Cách xử lý |
|
|
| ------------------- | ----------------------------------------------------- |
|
|
| **Widget sống lâu** | `bind_*` cho chuỗi tĩnh; `on_language_changed(cb)` cho phần còn lại |
|
|
| **Widget tạm thời** | Không cần đăng ký callback; gọi `tr()` khi tạo widget |
|
|
|
|
### 2.0. `bind_*` — cách mặc định cho chuỗi tĩnh
|
|
|
|
`w.setToolTip(tr("k"))` chỉ đúng ở đúng thời điểm chạy dòng đó. `bind_*` gộp "gán ngay"
|
|
và "gán lại sau mỗi lần đổi ngôn ngữ" vào một lời gọi, dùng `weakref` nên không giữ widget
|
|
sống thêm và tự dọn khi widget bị xoá:
|
|
|
|
```python
|
|
from ...i18n import bind_dynamic, bind_items, bind_placeholder, bind_text, bind_tip
|
|
|
|
self.save_btn = bind_text(QPushButton(), "co4e.save") # thay QPushButton(tr(...))
|
|
bind_tip(self.save_btn, "co4e.tt_save") # thay .setToolTip(tr(...))
|
|
bind_placeholder(self.chat_input, "co4e.chat_placeholder")
|
|
bind_items(self.perm_combo, [f"co4e.perm.{p}" for p in PERMISSION_PRESETS])
|
|
form.addRow(bind_text(QLabel(), "co4e.f_label"), self.label_edit) # KHÔNG addRow(tr(...))
|
|
```
|
|
|
|
Ba luật:
|
|
|
|
1. **Chuỗi tĩnh → `bind_*`.** Đổi tại chỗ, **không thêm dòng** — quan trọng với file đã
|
|
sát trần Gate S hoặc đang bị bánh cóc `LEGACY_ALLOWANCE` chốt (`quality_gates.md` §4).
|
|
2. **Chữ phụ thuộc trạng thái → `bind_dynamic(w, setter, fn)`**, với `fn` đọc trạng thái:
|
|
nút Chạy ⇄ Dừng, tooltip Thu gọn ⇄ Mở rộng, nhãn có số đếm. Các nhánh xử lý trạng thái
|
|
**vẫn** gọi setter trực tiếp như cũ để phản hồi ngay khi bấm; `bind_dynamic` chỉ lo lúc
|
|
đổi ngôn ngữ. Bind cứng một nhãn động sẽ **xoá** trạng thái khi người dùng đổi ngôn ngữ
|
|
giữa lúc đang chạy.
|
|
3. **Chữ là DỮ LIỆU thì không bind.** Tên agent, tên project, tên nhà cung cấp trong
|
|
`config.PROVIDER_LABELS` — dịch danh tính là sai.
|
|
|
|
`QFormLayout.addRow(tr(...), w)` và `_add_section(outer, tr(...))` là hai bẫy hay gặp:
|
|
chúng tự dựng `QLabel` bên trong, không giữ tham chiếu nào để áp lại. Truyền
|
|
`bind_text(QLabel(), key)` hoặc truyền **khoá** thay vì chuỗi đã dịch.
|
|
|
|
### 2.0b. Nút do CHÍNH Qt vẽ chữ — `ui/dialog_buttons.py`
|
|
|
|
`tr()` không với tới được nhãn nút của mấy widget dựng sẵn: Qt lấy chữ từ bảng dịch của
|
|
riêng nó, mà ứng dụng không cài `QTranslator` nào (bản PySide6 đang dùng cũng không đóng
|
|
gói file `qtbase_*.qm` nào để cài). Kết quả: **luôn là tiếng Anh ở cả ba ngôn ngữ.**
|
|
|
|
| Không dùng | Dùng thay |
|
|
| --- | --- |
|
|
| `QDialogButtonBox(Save \| Cancel)` | `dialog_buttons(Save \| Cancel)` |
|
|
| `QMessageBox.question(...) == QMessageBox.Yes` | `confirm(parent, title, body)` |
|
|
| `QInputDialog.getText / getMultiLineText / getItem` | `ask_text` / `ask_multiline` / `ask_item` |
|
|
|
|
Muốn một nút mang chữ riêng thì truyền khoá vào `dialog_buttons`, **không** `setText(tr(...))`
|
|
sau khi dựng — lần đổi ngôn ngữ kế tiếp, ràng buộc sẽ áp lại khoá mặc định và xoá mất chữ đó:
|
|
|
|
```python
|
|
self.buttons = dialog_buttons(QDialogButtonBox.Ok | QDialogButtonBox.Cancel,
|
|
ok="schedtask.ai_confirm")
|
|
```
|
|
|
|
Ba cổng trong `tests/ui/test_i18n_khong_hardcode_chu.py` canh việc này.
|
|
|
|
### 2.1. Widget sống lâu
|
|
|
|
Ví dụ:
|
|
|
|
* Chrome của cửa sổ chính.
|
|
* Tab.
|
|
* Sidebar.
|
|
* Composer.
|
|
|
|
Các widget này vẫn tồn tại khi người dùng đổi ngôn ngữ.
|
|
|
|
Vì vậy phải:
|
|
|
|
1. Đăng ký `on_language_changed(cb)`.
|
|
2. Trong callback, gọi lại `tr()` cho các text của chính widget.
|
|
3. Callback phải chạy:
|
|
|
|
* Một lần ngay khi đăng ký.
|
|
* Mỗi lần người dùng đổi ngôn ngữ.
|
|
|
|
Tên callback được sử dụng trong repo:
|
|
|
|
```text
|
|
_retranslate()
|
|
_apply_i18n()
|
|
```
|
|
|
|
Có thể tham khảo implementation chuẩn từ:
|
|
|
|
```text
|
|
ui/workspace_tab.py:484
|
|
```
|
|
|
|
### 2.2. Widget tạm thời
|
|
|
|
Ví dụ:
|
|
|
|
* Settings dialog.
|
|
* Skills dialog.
|
|
* Flow dialog.
|
|
* Permission dialog.
|
|
|
|
Các dialog này được tạo lại từ đầu mỗi lần mở.
|
|
|
|
Vì vậy chỉ cần gọi `tr()` khi construct widget.
|
|
|
|
**Không cần đăng ký `on_language_changed()`**.
|
|
|
|
### Bug thường gặp
|
|
|
|
Triệu chứng:
|
|
|
|
> Đổi ngôn ngữ nhưng một label/nút vẫn giữ ngôn ngữ cũ.
|
|
|
|
Nguyên nhân thường là:
|
|
|
|
* Widget sống lâu nhưng chưa đăng ký `on_language_changed()`.
|
|
* Callback có đăng ký nhưng quên cập nhật label đó.
|
|
|
|
**Cách sửa đúng:**
|
|
|
|
`bind_*` tại chính dòng đang gán (mục 2.0), hoặc — nếu chữ phụ thuộc trạng thái/dữ liệu —
|
|
sửa trong `_retranslate()` / `_apply_i18n()` của chính widget.
|
|
|
|
**Không** giải quyết bằng cách gọi `tr()` ở một nơi khác chỉ để ép label thay đổi.
|
|
|
|
### Cách TÌM ra hết các chỗ bị lỗi
|
|
|
|
Đừng grep chuỗi tiếng Việt trong source: lượt audit tháng 9/2026 grep ra 962 dòng mà
|
|
**không dòng nào** là lỗi thật (toàn docstring), trong khi 84 lỗi thật lại không xuất hiện
|
|
— vì chúng đi qua `tr()` đúng cách, chỉ thiếu người áp lại.
|
|
|
|
Phép đo đúng nằm ở `tests/ui/test_i18n_khong_con_chu_cu.py`: dựng `MainWindow` thật, thay
|
|
`tr()` bằng chuỗi **mốc**, gọi `set_language()`, rồi tìm chỗ **không** mang mốc. Hai chi
|
|
tiết mà bản kiểm ngây thơ sẽ sai:
|
|
|
|
* `from ...i18n import tr` copy tham chiếu vào namespace từng module → phải thay `tr` ở
|
|
**mọi** module đã import, không chỉ `i18n.tr`;
|
|
* lưới vẽ lại bằng `deleteLater()` để lại widget cũ còn sống → không
|
|
`sendPostedEvents(DeferredDelete)` thì báo oan hàng chục widget bóng ma (lượt audit đầu
|
|
báo 84 lỗi, trong đó 65 là bóng ma và widget bị `id()` cấp lại làm cắt vòng quét).
|
|
|
|
Chạy: `QT_QPA_PLATFORM=offscreen pytest tests/ui/test_i18n_khong_con_chu_cu.py -q`
|
|
|
|
---
|
|
|
|
## 3. Tổ chức file translation
|
|
|
|
Thư mục `i18n/` được chia theo **màn hình/chức năng**, không gom tất cả translation vào một file lớn.
|
|
|
|
Ví dụ:
|
|
|
|
```text
|
|
i18n/
|
|
├── login_dialog.py
|
|
├── sidebar.py
|
|
├── composer.py
|
|
├── cowork_tab.py
|
|
├── settings_dialog.py
|
|
├── skills_dialog.py
|
|
├── libreoffice_view.py
|
|
├── agents_admin_tab.py
|
|
├── monitoring_overview.py
|
|
└── hint.py
|
|
```
|
|
|
|
Mỗi file export một dictionary có dạng:
|
|
|
|
```text
|
|
key → {
|
|
"en": "...",
|
|
"ja": "...",
|
|
"vi": "..."
|
|
}
|
|
```
|
|
|
|
`i18n/__init__.py` sẽ import và gộp các dictionary này.
|
|
|
|
### Khi thêm key mới
|
|
|
|
Thực hiện theo 3 bước:
|
|
|
|
#### Bước 1 — Chọn đúng file
|
|
|
|
Đưa key vào file tương ứng với màn hình/chức năng.
|
|
|
|
Ví dụ:
|
|
|
|
```text
|
|
workspace.* → file liên quan đến workspace
|
|
composer.* → composer.py
|
|
settings.* → settings_dialog.py
|
|
```
|
|
|
|
**Không** đưa key vào `login_dialog.py` chỉ vì file đó đang có nhiều key nhất.
|
|
|
|
#### Bước 2 — Điền đủ 3 ngôn ngữ
|
|
|
|
Mỗi key mới phải có:
|
|
|
|
```text
|
|
en
|
|
ja
|
|
vi
|
|
```
|
|
|
|
Thiếu `ja` là lỗi đặc biệt cần chú ý vì có thể chỉ được phát hiện khi khách hàng Nhật sử dụng.
|
|
|
|
#### Bước 3 — Đặt tên key nhất quán
|
|
|
|
Format khuyến nghị:
|
|
|
|
```text
|
|
<màn hình>.<thành phần>
|
|
```
|
|
|
|
Ví dụ:
|
|
|
|
```text
|
|
workspace.tab_folder
|
|
app.nav.recents
|
|
```
|
|
|
|
Tên key phải mô tả rõ nó được dùng ở đâu và cho thành phần nào.
|
|
|
|
---
|
|
|
|
## 4. Các rủi ro thường gặp với tiếng Nhật và tiếng Việt
|
|
|
|
| Vấn đề | Triệu chứng | Cách xử lý |
|
|
| ---------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
|
| Độ dài chuỗi khác nhau | EN vừa nút nhưng VI bị tràn hoặc JA bị `...` | Không đặt width cố định dựa trên tiếng Anh. Dùng `sizeHint()`, `minimumWidth` hoặc cho phép wrap |
|
|
| Dấu tiếng Việt bị cắt | Các chữ như `Ắ`, `ộ` bị mất dấu | Không dùng `setFixedHeight()` cho label. Để layout tự tính chiều cao |
|
|
| Thiếu font/glyph tiếng Nhật | Xuất hiện `□□□` | Kiểm tra `_FONT` trong `theme/palettes.py` và khai báo font fallback |
|
|
| Sắp xếp chuỗi | Project có dấu được sắp xếp không đúng | Dùng locale-aware sorting, không dùng `sorted()` một cách máy móc |
|
|
| Số ký tự không phản ánh chiều rộng | Text bị elide sai, đặc biệt với tiếng Nhật | Dùng `QFontMetrics.horizontalAdvance()`, không dùng `len()` để đo chiều rộng |
|
|
|
|
### Đặc biệt lưu ý về độ dài text
|
|
|
|
Không được giả định:
|
|
|
|
```text
|
|
số ký tự = chiều rộng hiển thị
|
|
```
|
|
|
|
Ví dụ hai chuỗi có cùng số ký tự nhưng có thể có chiều rộng hiển thị khác nhau.
|
|
|
|
Khi cần đo text trên UI, dùng:
|
|
|
|
```python
|
|
QFontMetrics.horizontalAdvance(...)
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Checklist khi sửa lỗi i18n
|
|
|
|
Trước khi hoàn thành bản vá i18n, phải kiểm tra:
|
|
|
|
* [ ] Key mới có đủ **`en` / `ja` / `vi`**?
|
|
|
|
* [ ] Đã chuyển qua cả 3 ngôn ngữ **ngay trong lúc app đang chạy** chưa?
|
|
|
|
```
|
|
Không chỉ restart app rồi kiểm tra.
|
|
```
|
|
|
|
* [ ] `ja` có **khác** `en` không? Bằng nhau nghĩa là chưa dịch — trừ tên thương hiệu /
|
|
ký hiệu, và khi đó phải khai vào `KHOA_KHONG_CAN_DICH` kèm lý do.
|
|
|
|
* [ ] Chuỗi tĩnh đã dùng `bind_text` / `bind_tip` / `bind_placeholder` / `bind_items`
|
|
thay cho `setX(tr(...))` một lần?
|
|
|
|
* [ ] Chữ phụ thuộc trạng thái đã dùng `bind_dynamic` (không bind cứng, kẻo mất trạng thái)?
|
|
|
|
* [ ] Nếu widget sống lâu và còn phần không bind được, đã đăng ký:
|
|
|
|
```
|
|
`on_language_changed(...)`
|
|
```
|
|
|
|
* [ ] Callback `_retranslate()` hoặc `_apply_i18n()` đã cập nhật **tất cả text liên quan**?
|
|
|
|
* [ ] Đã chạy `pytest tests/ui/test_i18n_khong_con_chu_cu.py -q` và nó **xanh**?
|
|
|
|
* [ ] Không còn chuỗi hardcode mới trong bản vá?
|
|
|
|
* [ ] Nút hộp thoại đi qua `ui/dialog_buttons.py` (mục 2.0b), không dựng
|
|
`QDialogButtonBox` / `QMessageBox.question` / `QInputDialog.get*` trực tiếp?
|
|
|
|
* [ ] Layout vẫn đúng với **chuỗi dài nhất** trong 3 ngôn ngữ?
|
|
|
|
* [ ] Không dùng `len()` để tính chiều rộng text?
|
|
|
|
* [ ] Nếu có thay đổi UI, đã kiểm tra cả Dark Mode và Light Mode?
|
|
|
|
---
|
|
|
|
## 6. Nguyên tắc quan trọng
|
|
|
|
Khi sửa lỗi i18n, **không sửa triệu chứng ở nơi khác**.
|
|
|
|
Ví dụ:
|
|
|
|
```text
|
|
Đổi ngôn ngữ
|
|
↓
|
|
Label X không thay đổi
|
|
↓
|
|
Kiểm tra widget X
|
|
↓
|
|
Widget sống lâu?
|
|
↓
|
|
Có on_language_changed()?
|
|
↓
|
|
_retranslate() có cập nhật Label X?
|
|
```
|
|
|
|
Nếu thiếu callback hoặc callback bỏ sót label, hãy sửa **đúng callback của widget đó**.
|
|
|
|
Không thêm các lệnh `tr()` rải rác ở nơi khác chỉ để làm cho UI thay đổi.
|
|
|
|
Mục tiêu là đảm bảo cơ chế i18n hoạt động đúng và nhất quán cho toàn bộ ứng dụng.
|