# zh_cn **Repository Path**: fwr567/zh_cn ## Basic Information - **Project Name**: zh_cn - **Description**: No description available - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: version-16 - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-10-10 - **Last Updated**: 2026-10-10 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # zh_cn — Chinese translation overlay for this bench A **zero-touch** Chinese localisation layer for every app installed on this bench, including third-party apps that ship no `zh` catalogue at all. Nothing outside this app is edited: no upstream Frappe / ERPNext source, no `hooks.py` of another app, not a single line of `shop`, `crm`, `huf` … The whole mechanism relies on three documented Frappe extension points, which is what makes it survive `bench update`. --- ## 1. Why an overlay app works `frappe.translate.get_translations_from_apps()` merges the dictionary of every installed app **in install order**, later apps winning: ```python for app in frappe.get_installed_apps(_ensure_on_bench=True): translations.update(get_translations_from_csv(lang, app) or {}) translations.update(get_translations_from_mo(lang, app) or {}) ``` `zh_cn` is installed **last** (`installed_apps[-1]`), so its `zh_cn/translations/zh.csv` has the highest priority on the bench. Every string that any app sends through `_()` / `__()` / `N_()` — Desk server rendering, Desk boot `__messages`, Jinja website templates, error messages, PDFs — is resolved against it. Two consequences worth remembering: * an app upgrade can overwrite its **own** `translations/zh.csv`; that never removes our entries, because ours live in a different app and win the merge; * a `Translation` record created in the Desk (the built-in *Translation* DocType) still outranks us, and so do country names. That is Frappe's documented three-level priority, and it is exactly what we want for ad-hoc overrides. `_()` has **no idea which app called it** — it is `get_all_translations(lang).get(msg.strip(), msg)` — so "translate only these apps" cannot be decided at lookup time. It can only be decided while the dictionary is being assembled, which is why this app keeps a master list and *compiles* the CSV rather than asking anyone to hand-edit it. ## 2. Layout ``` zh_cn/ hooks.py include js / after_request / after_migrate compiler.py master list -> translations/zh.csv (+ .mo) scan.py which app contains which English literal inject.py after_request: add the browser layers to SPA shells session.py before_request: per-user language override boot.py / bootstrap.py boot payload, after-migrate self-heal api.py all whitelisted endpoints (switcher + workbench) data/translations.json THE master list - the only file to edit by hand translations/zh.csv compiled from the master list locale/zh.po generated, for translators / gettext tooling public/js/lang_switcher.js language picker (navbar / footer / draggable pill) public/js/dom_i18n.js DOM translator for non-Frappe SPA pages public/js/zh_cn_desk.js Desk-side language bootstrap public/js/zh_cn_settings.js the "translation workbench" UI zh_cn_translations/doctype/ ZH CN Settings + ZH CN App Scope www/shop.html shell override for the Vite built storefront ``` `build_translations.py` at the app root turns the CSV into `sites/assets/locale/zh/LC_MESSAGES/zh_cn.mo`. It is normally invoked by `compiler.build()`. ## 3. Adding or changing a translation ### 3.1 The normal route — the translation workbench Open `/desk/zh-cn-settings` as a **System Manager** and use the **翻译工作台 (Translation workbench)** group: | button | what it does | | --- | --- | | **未翻译英文清单** | The outstanding list, filtered by app / free-text search, paginated. Type into the 译文 column and press **保存并生成词表**. | | **批量导入** | Paste `原文译文` (or comma-separated) lines — fastest for a spreadsheet column. | | **导出 CSV** | Download the outstanding list as `source,target,app`, fill it offline, paste it back through 批量导入. | | **生成词表** | Recompile `translations/zh.csv` from the master list and flush the cache. | Everything you save goes into `zh_cn/data/translations.json` and is compiled on the spot: there is no separate "apply" step and no file to hand-edit. ### 3.2 The direct route — `data/translations.json` `data/translations.json` is the single source of truth and is safe to edit directly (it is version controlled): ```json [ {"source": "Cart is full", "target": "购物车已满", "owners": ["shop"]}, {"source": "ASN not found", "target": "未找到 ASN", "owners": ["hsrm"]} ] ``` After editing run either **生成词表** in the Desk or: ```bash cd ~/frappe-bench bench --site erp execute zh_cn.compiler.build ``` `owners` is *derived* — `compiler.export_master()` recomputes it from the source scan, and `merge_translations()` recomputes it for new entries. Leave it out and the entry is treated as unattributed, which is fine unless you use the per-app scope feature. ### 3.3 Rules that actually matter * **the source column must be stripped.** `_()` calls `msg.strip()` before the dictionary lookup, so a key with leading/trailing spaces can never match. * Keep `{0}` / `{1}` placeholders intact in the target text. * **A `Select` field's options are translated one option at a time**, not as a block. `frappe/public/js/frappe/form/controls/select.js` does `label = __(v, null, doctype)` for each option token, so for `options: "Draft\nOpen\nClosed"` you need **three** entries (`Draft` → `草稿`, `Open` → `打开`, `Closed` → `已关闭`), never one entry for the joined string. * Values are stripped on load and `\n` in a cell becomes a real newline. * Don't translate a source string so generic that it means different things in different places (`Cancel`, `Name`, `View`) unless you have checked every call site — the CSV has no per-context control, only `msgid:context` keys do. Frappe *does* fall back to the plain key, so a plain entry always works. * A source string that is **already Chinese** is skipped by the report on purpose: without that, apps written in Chinese (`hsrm`) drown the real work. The scanner decodes `\uXXXX` escapes first, because a lot of generated JSON stores Chinese that way. ### 3.4 How to tell whether an app is already translated ```bash bench --site erp console >>> from frappe.translate import get_all_translations >>> d = get_all_translations("zh"); len(d) >>> d.get("Cart is full") ``` `d` holds the merged result of every installed app; a miss means the string has never been translated anywhere on this bench. ## 4. App scope — deciding *which* apps the overlay speaks for Each installed app gets a row in `ZH CN Settings → 应用范围`: | state | meaning | | --- | --- | | **自动** | take part, but every entry yields to a native translation if one exists | | **强制覆盖** | take part and override native translations too | | **关闭** | stay out of the overlay entirely | The state is applied **per entry**, not per app: an entry is injected only if its `owners` list agrees. With the default policy (`全部使用方启用才注入`) a shared string is injected only when /every/ app that contains it is participating, so switching one app off cannot silently change another app's UI. `fill_gaps_only` (default on) is what makes `自动` harmless: an entry is skipped whenever Frappe already has a translation for that key from a native catalogue. ## 5. The browser layers **`lang_switcher.js`** lives in `hooks.py::web_include_js`, so it loads on every page that extends `templates/base.html` (login, portal pages, web forms, hsrm …) and on every SPA shell through `inject.py` (§6). It picks its own home, in order: 1. the standard navbar (`.navbar-nav`) when the page has one, 2. the footer, 3. a draggable, edge-snapping, collapsible pill. The candidate is **measured after mounting**; if the chosen container ends up invisible (the login theme sets `.web-footer { display: none }`) the switcher moves to the next candidate instead of disappearing. Position and collapsed state persist in `localStorage`. Admin-selectable via `ZH CN Settings → 网站切换器位置`. Language resolution order on the server is `?lang=` → `User.language` → `preferred_language` cookie → `Accept-Language` → `System Settings.language`, so: * guests are switched purely through the cookie, * signed in users go through `zh_cn.api.set_my_language`, which writes **only the calling user's own** `User.language` and rejects guests. No other user's record is ever touched. **`dom_i18n.js`** exists because some apps are Vite built SPAs that never load `frappe-web.bundle`, never define `__()`, and therefore ignore the dictionary entirely (`shop`, `crm`, `builder`, `insights` are examples). It fetches the short, DOM-matchable slice of the zh dictionary from `zh_cn.api.get_dom_messages` (cached in `localStorage` for 6 hours), then replaces **whole text nodes** and a handful of attributes that exactly match a source string. A `MutationObserver` re-applies it on re-render, and anything that should never be rewritten can opt out with `data-zh-skip` or `translate="no"`. Limits, by design: parameterised strings (`Item {0} …`) and sentences assembled from several spans are not matched, and there is a brief flash of English on the first visit. ## 6. SPAs get the layers injected, not overridden `inject.py` registers an `after_request` hook that appends the two `