Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ lib/ # 应用层(纯 TypeScript,只在本站点用)
guards/ # chrome / shared-worker / fetch-fonts / image-pipeline /
# serverless-save / long-action / series-settings /
# font-loading / comment-selection / canvas-loss /
# wasm-binary-release / unload-prompt
# wasm-binary-release / unload-prompt / hint-fallback
open-state.ts # 就绪、打开失败、frame 首个错误(三处共用的单一状态源)
open-failure.ts # 失败分类、-82 guard、环境类失败重开一次(经 setOpenRunner 注入避免环)
font-system.ts # 字体系统就绪判定 + awaitFontSystem(#144)
Expand Down Expand Up @@ -827,6 +827,20 @@ v7 代码分支(OO_VARIANT、页面级 x2t 打开转换、empty_bin 模板、v
-82),`registerOpenAttempt` 在用户发起新的打开时 `resetMemoryProbe()` 清掉。别把重建时导航旧 frame 到 `about:blank` 当优化加回来:它会掐断 vendor
在 ready 之后仍在取的 SVG 图标请求,每次文档切换都报 `Failed to fetch`(试过
并撤掉,见 docs/explorations/2026-08-20-x2t-wasm-oom-misclassified.md)。
- **语言包缺键(vendor 补丁,2026-08-23)**:vendor 的 45 个 locale JSON **没有一个**
对得齐 `en.json`,少的差 1 个键、多的差 3000+。多数缺口无害(组件源码里有默认值),
但**有些字符串只存在于 locale 文件**,缺翻译就是 `undefined`——而 tooltip 的
`updateHint` 直接 `hint[0]`,于是抛 TypeError,被 app 当成文档错误,弹出
"文档处理时发生错误,请用『另存为』保存备份"的模态框。**空白文档、还没打字就弹**。
韩语实测撞到的是 `DE.Views.Statusbar.tipMultiplePages`(en 有、ko 没有,状态栏渲染时读)。
两道防线,都要在:
1. `node bin/locale-fill.mjs` 把**站点 7 种语言**的 locale 用 en 值补齐(共 ~110 KB,
幂等,`bin/build.sh` 在 vite build 前跑它,所以补齐结果参与 `VENDOR_VERSION` 哈希)。
**vendor 升级后必须重跑**,`test/unit/vendor-locale.test.ts` 会拦下漏跑。
2. 守卫 11(`guards/hint-fallback.ts`):把 `updateHint(undefined)` 变成空操作。
这条覆盖我们没补的另外 38 种语言(`?locale=` 可以选到)以及下次升级新引入的缺口。
反向验证过:把那个键从 ko.json 拿掉且禁用守卫,`test/e2e/editor-locales.spec.ts`
的 ko 用例立刻红(弹框回来);只拿掉键、留着守卫则不红——两道防线各自有效。
- **CSV**:新 vendor 编辑器不能直接吃 CSV——打开前用 SheetJS 转 XLSX、保存
流转回 CSV(`packages/converter` 的 `convertCsvToXlsx` / `xlsxToCsvBytes`)。
解码带严格编码嗅探(fatal UTF-8 → GB18030 → latin1),GBK CSV 不再乱码。
Expand Down
6 changes: 6 additions & 0 deletions bin/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ else
fi

# Run Vite build
# The vendor's locale files are short against en.json, and some of the missing
# strings exist only there -- a gap can surface as a modal "an error occurred"
# on a blank document (see bin/locale-fill.mjs). Fill them for the languages the
# site ships before the tree is hashed into VENDOR_VERSION and copied to dist.
node bin/locale-fill.mjs

pnpm vite build $VITE_MODE_ARGS

# Fingerprint the vendored design tokens.
Expand Down
1 change: 1 addition & 0 deletions bin/locale-fill.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export function fill(opts?: { check?: boolean }): Array<{ file: string; count: number }>;
102 changes: 102 additions & 0 deletions bin/locale-fill.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* Fill the vendor editor's translation gaps for the languages this site ships.
*
* The OnlyOffice locale files are not complete against `en.json` -- every one
* of the 44 non-English files is missing keys, from a couple to a few thousand.
* That is normally harmless (a string keeps whatever default the component has)
* but some strings exist ONLY in the locale file, and then the property is
* `undefined` rather than English. When one of those reaches a tooltip:
*
* updateHint: function (t) { ... "string" == typeof t ? t : t[0] ... }
*
* `undefined[0]` throws, the editor catches it as a document error and shows
* the modal "An error occurred while working with the document. Use the
* 'Download as' option to save a backup copy". Korean hit exactly this on a
* blank document: `DE.Views.Statusbar.tipMultiplePages` is in en.json and not
* in ko.json, and the status bar asks for it while rendering.
*
* So: for the locales the site is translated into, any key en.json has and the
* locale does not gets the English value. An English tooltip is a cosmetic gap;
* a modal error dialog on an empty document is not. Everything else is left
* exactly as the vendor shipped it.
*
* Idempotent -- run it again after a vendor upgrade (bin/build.sh does, and
* test/unit/vendor-locale.test.ts fails if it has not been run).
*
* node bin/locale-fill.mjs # fill in place
* node bin/locale-fill.mjs --check # exit 1 if any gap remains
*/
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { LOCALES } from './build-pages.mjs';

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const APPS_DIR = resolve(ROOT, 'public/web-apps/apps');

/**
* Site locale -> vendor locale file. The vendor uses bare language codes with a
* handful of four-letter exceptions (pt-pt, zh-tw, sr-cyrl); `zh-CN` is served
* by `zh.json`. Mirrors resolveEditorLocale() in packages/shared/src/i18n.ts,
* which decides what `lang=` the editor is loaded with.
*/
const VENDOR_LOCALE = { 'zh-CN': 'zh' };
const vendorCodeFor = (locale) => VENDOR_LOCALE[locale] ?? locale;

/** Apps that have a locale directory (documenteditor, pdfeditor, ...). */
function localeDirs() {
return readdirSync(APPS_DIR)
.map((app) => resolve(APPS_DIR, app, 'main/locale'))
.filter((dir) => existsSync(dir) && existsSync(resolve(dir, 'en.json')));
}

/** Keys en.json has that `locale` does not, in en.json's order. */
function gaps(dir, code) {
const target = resolve(dir, `${code}.json`);
if (!existsSync(target)) return null;
const en = JSON.parse(readFileSync(resolve(dir, 'en.json'), 'utf8'));
const translated = JSON.parse(readFileSync(target, 'utf8'));
const missing = Object.keys(en).filter((key) => !(key in translated));
return { target, en, translated, missing };
}

export function fill({ check = false } = {}) {
const codes = Object.keys(LOCALES).map(vendorCodeFor);
const report = [];
for (const dir of localeDirs()) {
for (const code of codes) {
if (code === 'en') continue;
const found = gaps(dir, code);
if (!found || !found.missing.length) continue;
report.push({ file: found.target.slice(ROOT.length + 1), count: found.missing.length });
if (check) continue;
// Rebuild in en.json's key order so the file stays diffable rather than
// growing an appendix of filled keys at the bottom.
const merged = {};
for (const key of Object.keys(found.en)) merged[key] = found.translated[key] ?? found.en[key];
for (const [key, value] of Object.entries(found.translated)) if (!(key in merged)) merged[key] = value;
// Keep the vendor's formatting. These files ship minified on one line;
// pretty-printing them would add ~25 KB each to a tree that is already
// 600 MB and is downloaded by every visitor's service worker.
const pretty = readFileSync(found.target, 'utf8').includes('\n "');
writeFileSync(found.target, pretty ? `${JSON.stringify(merged, null, 2)}\n` : JSON.stringify(merged));
}
}
return report;
}

if (process.argv[1] && process.argv[1].endsWith('locale-fill.mjs')) {
const check = process.argv.includes('--check');
const report = fill({ check });
if (!report.length) {
console.log('[locale-fill] every shipped locale covers en.json');
} else if (check) {
for (const { file, count } of report) console.error(` ${file}: ${count} keys missing`);
console.error('[locale-fill] vendor locales are short; run node bin/locale-fill.mjs');
process.exit(1);
} else {
for (const { file, count } of report) console.log(` ${file}: filled ${count} keys from en.json`);
console.log(`[locale-fill] filled ${report.length} file(s)`);
}
}
57 changes: 57 additions & 0 deletions lib/onlyoffice/guards/hint-fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Guard 11: a missing translation must not become a document error.
*
* The vendor's locale files are incomplete against `en.json` -- all 44 of them,
* from a couple of keys to a few thousand. Most gaps are invisible, because the
* component keeps whatever default its own source defines. But some strings
* exist only in the locale file, so a gap leaves the property `undefined`, and
* the tooltip setter does not check:
*
* updateHint: function (t) { ... "string" == typeof t ? t : t[0] ... }
*
* `undefined[0]` throws inside the view layer, the app catches it as a document
* error, and the user gets the modal "An error occurred while working with the
* document. Use the 'Download as' option to save a backup copy" -- on a blank
* document, before they have typed anything. Korean hit precisely this:
* `DE.Views.Statusbar.tipMultiplePages` is in en.json, absent from ko.json, and
* the status bar reads it while rendering.
*
* bin/locale-fill.mjs closes the gaps for the languages this site is
* translated into, which is the real fix and the one users see (they get an
* English tooltip rather than a broken editor). This guard covers the rest: the
* editor accepts any of the vendor's 45 locales through `?locale=`, and a
* vendor upgrade can introduce a new gap in any of them at any time. A tooltip
* that never appears is a blemish; a modal error on an empty document is not.
*/
type HintTarget = { updateHint?: (hint?: unknown, ...rest: unknown[]) => unknown; __hintGuarded?: boolean };

/** Component prototypes that own an updateHint reading `hint[0]`. */
function hintOwners(win: Window): HintTarget[] {
const ui = (win as unknown as { Common?: { UI?: Record<string, { prototype?: HintTarget }> } }).Common?.UI;
if (!ui) return [];
return Object.values(ui)
.map((component) => component?.prototype)
.filter((proto): proto is HintTarget => Boolean(proto && typeof proto.updateHint === 'function'));
}

export function installHintFallbackGuard(win: Window): boolean {
const frame = win as Window & { __ooHintGuarded?: boolean };
const owners = hintOwners(win);
// Common.UI lands during the editor's boot; report "not yet" so the caller
// keeps re-applying until the components exist.
if (!owners.length) return Boolean(frame.__ooHintGuarded);

for (const proto of owners) {
if (proto.__hintGuarded) continue;
const original = proto.updateHint!;
proto.updateHint = function (this: unknown, hint?: unknown, ...rest: unknown[]) {
// Keep whatever tooltip is already there rather than throwing. An empty
// array would clear it; undefined is what a missing translation gives.
if (hint === undefined || hint === null) return undefined;
return original.call(this, hint, ...rest);
};
proto.__hintGuarded = true;
}
frame.__ooHintGuarded = true;
return true;
}
2 changes: 2 additions & 0 deletions lib/onlyoffice/iframe-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { installCommentSelectionGuard } from './guards/comment-selection';
import { installCanvasLossGuard } from './guards/canvas-loss';
import { releaseWasmBinary } from './guards/wasm-binary-release';
import { installSingleUnloadPrompt } from './guards/unload-prompt';
import { installHintFallbackGuard } from './guards/hint-fallback';

/**
* Same-origin preparation of the editor iframe, applied from onAppReady and
Expand Down Expand Up @@ -52,6 +53,7 @@ export function prepareEditorIframe(): boolean {
installCommentSelectionGuard(win);
installCanvasLossGuard(win, doc);
installSingleUnloadPrompt(win);
installHintFallbackGuard(win);
const wasmBinaryHandled = releaseWasmBinary(win);

if (
Expand Down
2 changes: 1 addition & 1 deletion public/web-apps/apps/documenteditor/main/locale/de.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/web-apps/apps/documenteditor/main/locale/es.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/web-apps/apps/documenteditor/main/locale/ja.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/web-apps/apps/documenteditor/main/locale/ko.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/web-apps/apps/documenteditor/main/locale/pt.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/web-apps/apps/documenteditor/main/locale/zh.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/web-apps/apps/pdfeditor/main/locale/de.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/web-apps/apps/pdfeditor/main/locale/es.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/web-apps/apps/pdfeditor/main/locale/ja.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/web-apps/apps/pdfeditor/main/locale/ko.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/web-apps/apps/pdfeditor/main/locale/pt.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/web-apps/apps/pdfeditor/main/locale/zh.json

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/web-apps/apps/spreadsheeteditor/main/locale/es.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/web-apps/apps/spreadsheeteditor/main/locale/ja.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/web-apps/apps/spreadsheeteditor/main/locale/ko.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/web-apps/apps/visioeditor/main/locale/ja.json

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions test/e2e/editor-locales.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { expect, test } from './lib/l0';
import type { Frame, Page } from '@playwright/test';

/**
* The editor, in every language the site is translated into.
*
* A Korean visitor opening a blank document got the vendor's modal "An error
* occurred while working with the document. Use the 'Download as' option to
* save a backup copy" -- before typing anything. The cause was a missing
* translation: `DE.Views.Statusbar.tipMultiplePages` exists in en.json and not
* in ko.json, the status bar passes it to a tooltip setter that does `hint[0]`,
* and the TypeError is caught and reported as a document error.
*
* Nothing in the suite would have caught it, because everything else opens the
* editor in English. This does what the user did: for each locale, create a
* blank document and wait for the editor to settle.
*/
const LOCALES = ['zh-CN', 'ja', 'de', 'es', 'ko', 'pt'] as const;

/** The vendor's modal dialog, if one is on screen. */
async function fatalDialog(frame: Frame): Promise<string | null> {
return frame
.evaluate(() => {
const box = document.querySelector('.asc-window.modal .body, .asc-window .body');
return box && (box as HTMLElement).offsetParent !== null
? (box.textContent || '').replace(/\s+/g, ' ').trim()
: null;
})
.catch(() => null);
}

const editorFrame = (page: Page) => page.frames().find((f) => /documenteditor/.test(f.url()));

test.describe('editor in every site language', () => {
for (const locale of LOCALES) {
test(`${locale}: a blank document opens without a vendor error dialog`, async ({ page }) => {
await page.goto(`/editor?locale=${locale}&new=docx`);

// Wait for the toolbar rather than a fixed delay: the crash happened
// while the chrome rendered, so the assertion has to come after it.
const frame = await expect
.poll(async () => editorFrame(page)?.url() ?? null, { timeout: 60_000 })
.not.toBeNull()
.then(() => editorFrame(page)!);
await expect
.poll(async () => frame.evaluate(() => document.querySelectorAll('.ribtab a').length).catch(() => 0), {
timeout: 60_000,
})
.toBeGreaterThan(3);

// Give the status bar and the rest of the late chrome time to render;
// the Korean failure surfaced there, after the tabs were up.
await page.waitForTimeout(3_000);
expect(await fatalDialog(frame), `${locale} shows a vendor error dialog`).toBeNull();

// And the UI really is in that language, not English with a locale in the
// URL -- the tabs are the first thing a translation touches.
const tabs = await frame.evaluate(() =>
[...document.querySelectorAll('.ribtab a')].map((a) => a.textContent?.trim() ?? ''),
);
expect(tabs.filter(Boolean).length, `${locale} has no toolbar tabs`).toBeGreaterThan(3);
// None of these locales writes its Home tab in English, so seeing it
// means the lang never reached the editor.
expect(tabs.join(' '), `${locale} toolbar is still English`).not.toContain('Home');
});
}
});
58 changes: 58 additions & 0 deletions test/unit/vendor-locale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { LOCALES } from '../../bin/build-pages.mjs';
import { fill } from '../../bin/locale-fill.mjs';

/**
* The vendor's translation files, for the languages this site claims to ship.
*
* They are not complete against en.json -- every one of the 44 is short, some
* by thousands of keys. That is usually cosmetic, but a string that exists only
* in the locale file leaves the property `undefined` when its translation is
* missing, and the tooltip setter does `hint[0]` without checking. The editor
* catches the TypeError as a document error and shows "an error occurred while
* working with the document" on a blank page. Korean did exactly this.
*
* bin/locale-fill.mjs backfills the gaps from en.json. This is the check that
* it has been run: after a vendor upgrade it will not have been.
*/
const ROOT = resolve(__dirname, '../..');
const APPS = resolve(ROOT, 'public/web-apps/apps');
const VENDOR_LOCALE: Record<string, string> = { 'zh-CN': 'zh' };

const localeDirs = () =>
readdirSync(APPS)
.map((app) => resolve(APPS, app, 'main/locale'))
.filter((dir) => existsSync(resolve(dir, 'en.json')));

describe('vendor editor locales', () => {
it('finds the vendor locale trees (sanity)', () => {
expect(localeDirs().length).toBeGreaterThanOrEqual(3);
});

it('covers every key en.json has, for every language the site ships', () => {
// fill({ check: true }) reports the gaps without writing.
expect(fill({ check: true })).toEqual([]);
});

/**
* The specific string that took Korean down, kept as a named example so the
* next person meets the failure mode rather than only the rule.
*/
it('has the status-bar tooltip Korean was missing', () => {
const ko = JSON.parse(readFileSync(resolve(APPS, 'documenteditor/main/locale/ko.json'), 'utf8')) as Record<
string,
string
>;
expect(ko['DE.Views.Statusbar.tipMultiplePages']).toBeTruthy();
});

it('maps each site locale to a vendor file that exists', () => {
const dir = resolve(APPS, 'documenteditor/main/locale');
for (const locale of Object.keys(LOCALES)) {
const code = VENDOR_LOCALE[locale] ?? locale;
expect(existsSync(resolve(dir, `${code}.json`)), `${locale} -> ${code}.json`).toBe(true);
}
});
});
Loading