Skip to content

Commit fabd259

Browse files
fix(service-storage): Setup storage Test connection answers in the caller's language (#30335) (#30627)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgH3fGSa9E9qgZfcdFLtAhtm)_ domain:server#2 · os-bill · session_01Utp6CaWLG2RrgKub5oRNjC ## 维护者速读 - 改了什么:设置 → 存储 → 「测试连接」的结果提示按管理员的界面语言显示(成功、下载内容不一致、S3 缺存储桶或区域三种),覆盖平台全部 11 种语言;没给语言或语言不在平台表上时,仍是原来的英文。 - 为什么改:中文控制台点「测试连接」看到的是 `Storage round-trip succeeded (adapter=local).`,管理员可能读不懂下一步。#30551 已让设置动作拿到调用者语言,本 PR 让存储这个按钮用上它。 - 风险与回滚:只动 service-storage 的 `storage/test` 处理器和一个新文案文件;`ok`、`severity`、探针上传下载删除、运维日志都不变;英文调用方的句子一字不变。回滚即还原本 PR。 - 先看:`packages/services/service-storage/src/storage-test-copy.ts`(文案表与 `storageTestCopyLocaleOf` 语言查找);其次 `storage-service-plugin.ts` 里 `storage/test` 的四处 `copy.` 调用。 - 两分钟验证:`pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2 src/storage-test-locale.test.ts`,预期 10 个全绿,第一个用例的消息是 `存储连接测试成功(适配器:local)。` - 需要你做的:无,席位落地。 Refs #30335 Clause-②: no Metadata-protocol: none ## What changes The console prints the `storage/test` settings action's `message` as the toast, bare. Since #30551 the settings action route hands each action handler the caller's display language as the input's `locale` (`Accept-Language`, else `?locale`; `settings-routes.ts` `requestLocaleOf`). The storage handler now writes its answer in that language. - New `packages/services/service-storage/src/storage-test-copy.ts`: one entry per language on the platform locale table (`PLATFORM_LOCALES`): `en`, `zh-CN`, `zh-Hant`, `es-ES`, `ar`, `fr`, `pt-BR`, `ru`, `de`, `ja-JP`, `ko`. The four codes this package's bundles already used keep their keys. Four sentences each: success (still names the adapter), probe read back not the one written, S3 settings missing a bucket or a region, and a lead-in for an adapter's own error. - `storage/test` reads `locale` from the handler input, the same source as the mail test notice in #30551. The adapter's own error text (S3 SDK, file system) stays verbatim: alone in English, as before; in another language behind the lead-in (`存储连接测试失败:…`), so the reader knows the rest is the adapter's untranslated report. - `buildAdapterFromValues` throws a new `S3SettingsIncompleteError` with the same English message as before, so the settings re-apply path and its `warn` log line are byte-identical; only the `storage/test` answer reads the type to localise the refusal. - Unchanged: `ok`, `severity`, the adapter name, the probe and its cleanup, every `ctx.logger` line, and every sentence an English or locale-less caller gets. **Where the strings live, and one deviation from the precedent.** The precedent is #30376 (`mfa-required-enrollment-predicate.ts`): a settings handler's prose sits in a module-level copy table beside its code, not in the package's `translations/` bundles (those carry object metadata). This PR follows it, with one difference: the lookup does not use `@objectstack/core`'s `resolveLocale`. With both `zh-CN` and `zh-Hant` in one table, that ladder's base-language and first-variant steps answer `zh-TW` by key order, which is the trap the platform table's own reader warns about (`packages/spec/src/system/platform-locales.ts`, the `platformLocaleTagOf` note). So `storageTestCopyLocaleOf` reads the caller's code through `platformLocaleTagOf` (aliases `zh-TW`, `pt`, `en-US`; table tags `zh-Hans`, `ja`), then drops one subtag at a time (RFC 4647 lookup: `fr-CA` → `fr`, `zh-Hant-TW` → `zh-Hant`), and falls back to English. This is the gap #30538 names (`resolveBundleLocale` / `resolveLocale` do not read `platformLocaleTagOf`); once #30538 lands this module can go back to the shared resolver. ## Tests New `packages/services/service-storage/src/storage-test-locale.test.ts`, 10 cases, all through the REAL handler the plugin registers, on a real local adapter, with the input `locale` set or absent: - zh-CN success sentence, `ok: true`, `severity: 'info'`; en-US success is the old English sentence; - no locale, `tr-TR` (not on the table) and `''` answer the old English sentence; - `zh` resolves to the zh-CN copy; - zh-CN probe mismatch (`ok: false`, `severity: 'error'`), and the probe object is still removed; - zh-CN incomplete S3 settings; no locale keeps the English S3 refusal; - zh-CN adapter error = lead-in + the exact English adapter text; - the table carries all 11 languages, every key in every language, placeholders verbatim, no non-English entry copied from English, and every `PLATFORM_LOCALE_TAGS` tag finds its own entry; - 24 code → entry mappings (aliases, table tags, shortened subtags, unknown → English). Existing pins in `storage-service-plugin.test.ts` and `storage-service-plugin.probe-cleanup-store.test.ts` (English messages, no locale) pass unchanged. ## Acceptance notes - Measured before the change on the real plugin handler (base `c23eafa8`): `locale: 'zh-CN'` and no locale both answered `Storage round-trip succeeded (adapter=local).`; mismatch `Probe download did not match upload.`; S3 `StorageServicePlugin: S3 adapter requires s3_bucket and s3_region`. - `Clause-②: no`: nothing outside this package's own tests matches the English text (`git grep -n "round-trip succeeded\|did not match upload"` hits only `service-storage`). The console prints the message and reads `ok`. - Arabic copy carries no bidi control characters; direction is the console's `dir`. - An end-to-end pin through `SettingsService` + routes was not added here: service-storage has no service-settings dependency, and #30551 pins the route → handler `locale` hop. ## Verification All on head `47c43908c` (merge of `main` at `bdbaf80c`, which carries #30551). <details><summary>Local runs</summary> - `pnpm --filter @objectstack/service-storage test`: Test Files 73 passed (73), Tests 1353 passed (1353). - `pnpm --filter @objectstack/service-storage typecheck`: exit 0 (`check:test-typecheck: OK`). - `node scripts/check-i18n-bundles.mjs`: exit 0 (10 packages, all bundles in sync). - `node scripts/pm/dispatch-gates.mjs --commands`: 62 derived families, all 62 exit 0; `--ran`: 62 derived, 62 run, 0 NOT-MEASURED. - `eslint --no-inline-config` on the three changed source files: exit 0. - Full dogfood under the verify lock (`pnpm --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood test`): `VERDICT command-exit 0`, Test Files 420 passed, 3 skipped (423); Tests 3496 passed, 36 skipped (3532). </details> <details><summary>Ablations (scripts/ablation-replace.mjs, each restored, git diff HEAD empty after)</summary> - A1 handler ignores the input `locale`: 5 failed, 5 passed (the five zh cases). - A2 fallback `en` → `zh-CN`: 4 failed (no-locale English, no-locale S3, the lead-in case, the mapping pin). - A3 S3 type check off: 1 failed (zh-CN incomplete S3). - A4 drop the `ko` entry: 1 failed (all 11 languages pin). - A5 no subtag shortening: 1 failed (mapping pin). </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CaWLG2RrgKub5oRNjC --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c5101a0 commit fabd259

4 files changed

Lines changed: 396 additions & 6 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
fix(service-storage): Setup → Storage → Test connection answers in the caller's language
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The `storage/test` settings action writes its `message` in the language the settings action route hands its handler as `locale` (`Accept-Language`, else `?locale`), the same source the mail test notice reads. It covers the sentences the product writes: the success sentence (which still names the adapter), the refusal when the probe read back is not the one written, and the refusal for S3 settings missing a bucket or a region. An adapter's own error text stays verbatim: alone in English, as before; in another language behind a lead-in saying the connection test failed. Every language on the platform locale table is served (`en`, `zh-CN`, `zh-Hant`, `es-ES`, `ar`, `fr`, `pt-BR`, `ru`, `de`, `ja-JP`, `ko`), the caller's code read through `platformLocaleTagOf` and then shortened one subtag at a time; an absent locale, or one the table does not name, is answered in English exactly as before. `ok`, `severity`, the probe and its cleanup, and the operator log lines are unchanged.

‎packages/services/service-storage/src/storage-service-plugin.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import { mayActIrreversibly } from '@objectstack/platform-objects/system';
8787
import { FILE_REFERENCES_MIGRATION_ID } from '@objectstack/spec/system';
8888
import { inProcessSessionReadInput } from '@objectstack/types';
8989
import { SwappableStorageService } from './swappable-storage-service.js';
90+
import { S3SettingsIncompleteError, storageTestCopy } from './storage-test-copy.js';
9091
import {
9192
resolveStorageTarget,
9293
needsStorageSwap,
@@ -286,7 +287,7 @@ export class StorageServicePlugin implements Plugin {
286287
const bucket = values.s3_bucket as string | undefined;
287288
const region = values.s3_region as string | undefined;
288289
if (!bucket || !region) {
289-
throw new Error('StorageServicePlugin: S3 adapter requires s3_bucket and s3_region');
290+
throw new S3SettingsIncompleteError();
290291
}
291292
const opts: S3StorageAdapterOptions = {
292293
bucket,
@@ -814,7 +815,11 @@ export class StorageServicePlugin implements Plugin {
814815
// Register the live `storage/test` probe handler.
815816
if (typeof settings.registerAction === 'function' && this.storage) {
816817
const proxy = this.storage;
817-
settings.registerAction('storage', 'test', async ({ values, payload }: any) => {
818+
settings.registerAction('storage', 'test', async ({ values, payload, locale }: any) => {
819+
// [#30335] The answer is the toast the administrator reads: written
820+
// in the caller's display language, which the settings action
821+
// route hands over as `locale` (English when none is given).
822+
const copy = storageTestCopy(typeof locale === 'string' ? locale : undefined);
818823
// Merge the (possibly unsaved) form state posted as
819824
// `payload.values` over the persisted snapshot so an operator
820825
// can validate edits before hitting "Save". Matches the
@@ -851,7 +856,10 @@ export class StorageServicePlugin implements Plugin {
851856
try {
852857
target = await this.buildAdapterFromValues(merged);
853858
} catch (err: any) {
854-
return { ok: false, severity: 'error', message: err?.message ?? String(err) };
859+
const message = err instanceof S3SettingsIncompleteError
860+
? copy.s3SettingsIncomplete
861+
: copy.failed(err?.message ?? String(err));
862+
return { ok: false, severity: 'error', message };
855863
}
856864
}
857865

@@ -900,18 +908,18 @@ export class StorageServicePlugin implements Plugin {
900908
// unguarded delete, whose failure IS a probe failure the
901909
// operator must be told about, and no path deletes twice.
902910
await removeProbeObject();
903-
return { ok: false, severity: 'error', message: 'Probe download did not match upload.' };
911+
return { ok: false, severity: 'error', message: copy.downloadMismatch };
904912
}
905913
await target.delete(probeKey);
906914
const adapter = String(merged.adapter ?? this.options.adapter ?? 'local');
907915
return {
908916
ok: true,
909917
severity: 'info',
910-
message: `Storage round-trip succeeded (adapter=${adapter}).`,
918+
message: copy.succeeded(adapter),
911919
};
912920
} catch (err: any) {
913921
await removeProbeObject();
914-
return { ok: false, severity: 'error', message: err?.message ?? String(err) };
922+
return { ok: false, severity: 'error', message: copy.failed(err?.message ?? String(err)) };
915923
}
916924
});
917925
ctx.logger.info('StorageServicePlugin: registered settings action storage/test');
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#30335] The sentences Setup → Storage → 测试连接 (`storage/test`) answers
5+
* with, one entry per language on the platform locale table.
6+
*
7+
* The console prints the action's `message` as the toast, bare, so the
8+
* sentence is the administrator's whole answer. The settings action route
9+
* hands the handler the caller's display language (`Accept-Language`, else
10+
* `?locale`) as the input's `locale`; an absent locale, or one with no entry
11+
* here, is answered in English, the sentence every caller got before.
12+
*
13+
* The table carries every language on the platform locale table
14+
* (`@objectstack/spec/system` `PLATFORM_LOCALES`). The four entries this package
15+
* served first keep their keys (`zh-CN`, `ja-JP`, `es-ES`); the caller's code is
16+
* read through the table's own reader, `platformLocaleTagOf`, so a stored
17+
* alias (`zh-TW`, `pt`, `en-US`) and a table tag (`zh-Hans`, `ja`) both find
18+
* their entry. A code the table does not name is shortened one subtag at a
19+
* time (RFC 4647 lookup: `fr-CA` → `fr`, `zh-Hant-TW` → `zh-Hant`); nothing
20+
* left ⇒ English.
21+
*
22+
* Only the sentences the product writes live here. An adapter's own error
23+
* text (the S3 SDK's, the file system's) is passed through verbatim: in
24+
* English on its own, as it always was; in another language behind a lead-in
25+
* that says the connection test failed, so the reader knows what the
26+
* untranslated part is. The operator log lines stay English.
27+
*/
28+
29+
import { platformLocaleTagOf, type PlatformLocaleTag } from '@objectstack/spec/system';
30+
31+
/** The English refusal for S3 settings missing a bucket or a region. */
32+
export const S3_SETTINGS_INCOMPLETE_MESSAGE =
33+
'StorageServicePlugin: S3 adapter requires s3_bucket and s3_region';
34+
35+
/**
36+
* Thrown when the `storage` settings name the S3 adapter without a bucket or a
37+
* region. Its message is the English refusal the operator log has always
38+
* carried; the `storage/test` answer reads the type to write the refusal in the
39+
* caller's language instead.
40+
*/
41+
export class S3SettingsIncompleteError extends Error {
42+
constructor() {
43+
super(S3_SETTINGS_INCOMPLETE_MESSAGE);
44+
this.name = 'S3SettingsIncompleteError';
45+
}
46+
}
47+
48+
export interface StorageTestCopy {
49+
/** The probe object was written, read back identical and deleted. */
50+
succeeded(adapter: string): string;
51+
/** The probe object read back is not the one written. */
52+
downloadMismatch: string;
53+
/** The S3 adapter was chosen without a bucket or a region. */
54+
s3SettingsIncomplete: string;
55+
/** The adapter refused; `reason` is its own text, verbatim. */
56+
failed(reason: string): string;
57+
}
58+
59+
/** The table's keys: the platform locale table's languages, the first four under their older codes. */
60+
export type StorageTestCopyLocale =
61+
'en' | 'zh-CN' | 'zh-Hant' | 'es-ES' | 'ar' | 'fr' | 'pt-BR' | 'ru' | 'de' | 'ja-JP' | 'ko';
62+
63+
export const STORAGE_TEST_COPY: Record<StorageTestCopyLocale, StorageTestCopy> = {
64+
en: {
65+
succeeded: (adapter) => `Storage round-trip succeeded (adapter=${adapter}).`,
66+
downloadMismatch: 'Probe download did not match upload.',
67+
s3SettingsIncomplete: S3_SETTINGS_INCOMPLETE_MESSAGE,
68+
failed: (reason) => reason,
69+
},
70+
'zh-CN': {
71+
succeeded: (adapter) => `存储连接测试成功(适配器:${adapter})。`,
72+
downloadMismatch: '探测文件下载后的内容与上传的不一致。',
73+
s3SettingsIncomplete: 'S3 适配器需要填写存储桶(s3_bucket)和区域(s3_region)。',
74+
failed: (reason) => `存储连接测试失败:${reason}`,
75+
},
76+
'zh-Hant': {
77+
succeeded: (adapter) => `儲存空間連線測試成功(轉接器:${adapter})。`,
78+
downloadMismatch: '探測檔案下載後的內容與上傳的不一致。',
79+
s3SettingsIncomplete: 'S3 轉接器需要填寫儲存貯體(s3_bucket)和區域(s3_region)。',
80+
failed: (reason) => `儲存空間連線測試失敗:${reason}`,
81+
},
82+
'ja-JP': {
83+
succeeded: (adapter) => `ストレージの接続テストに成功しました(アダプター: ${adapter})。`,
84+
downloadMismatch: 'プローブファイルのダウンロード内容がアップロードした内容と一致しません。',
85+
s3SettingsIncomplete: 'S3 アダプターにはバケット(s3_bucket)とリージョン(s3_region)の指定が必要です。',
86+
failed: (reason) => `ストレージの接続テストに失敗しました: ${reason}`,
87+
},
88+
ko: {
89+
succeeded: (adapter) => `스토리지 연결 테스트에 성공했습니다(어댑터: ${adapter}).`,
90+
downloadMismatch: '다운로드한 테스트 파일이 업로드한 파일과 일치하지 않습니다.',
91+
s3SettingsIncomplete: 'S3 어댑터에는 버킷(s3_bucket)과 리전(s3_region)이 필요합니다.',
92+
failed: (reason) => `스토리지 연결 테스트에 실패했습니다: ${reason}`,
93+
},
94+
'es-ES': {
95+
succeeded: (adapter) => `La prueba de conexión del almacenamiento se ha completado correctamente (adaptador: ${adapter}).`,
96+
downloadMismatch: 'El archivo de prueba descargado no coincide con el que se subió.',
97+
s3SettingsIncomplete: 'El adaptador S3 necesita un bucket (s3_bucket) y una región (s3_region).',
98+
failed: (reason) => `La prueba de conexión del almacenamiento ha fallado: ${reason}`,
99+
},
100+
ar: {
101+
succeeded: (adapter) => `نجح اختبار الاتصال بالتخزين (المحوّل: ${adapter}).`,
102+
downloadMismatch: 'محتوى ملف الاختبار الذي تم تنزيله لا يطابق الملف الذي تم رفعه.',
103+
s3SettingsIncomplete: 'يتطلب محوّل S3 تحديد الحاوية (s3_bucket) والمنطقة (s3_region).',
104+
failed: (reason) => `فشل اختبار الاتصال بالتخزين: ${reason}`,
105+
},
106+
fr: {
107+
succeeded: (adapter) => `Le test de connexion au stockage a réussi (adaptateur : ${adapter}).`,
108+
downloadMismatch: 'Le fichier de test téléchargé ne correspond pas à celui qui a été envoyé.',
109+
s3SettingsIncomplete: 'L’adaptateur S3 nécessite un bucket (s3_bucket) et une région (s3_region).',
110+
failed: (reason) => `Le test de connexion au stockage a échoué : ${reason}`,
111+
},
112+
'pt-BR': {
113+
succeeded: (adapter) => `O teste de conexão com o armazenamento foi concluído com sucesso (adaptador: ${adapter}).`,
114+
downloadMismatch: 'O arquivo de teste baixado não corresponde ao que foi enviado.',
115+
s3SettingsIncomplete: 'O adaptador S3 exige um bucket (s3_bucket) e uma região (s3_region).',
116+
failed: (reason) => `O teste de conexão com o armazenamento falhou: ${reason}`,
117+
},
118+
ru: {
119+
succeeded: (adapter) => `Проверка подключения к хранилищу прошла успешно (адаптер: ${adapter}).`,
120+
downloadMismatch: 'Скачанный тестовый файл не совпадает с загруженным.',
121+
s3SettingsIncomplete: 'Для адаптера S3 нужно указать бакет (s3_bucket) и регион (s3_region).',
122+
failed: (reason) => `Проверка подключения к хранилищу не удалась: ${reason}`,
123+
},
124+
de: {
125+
succeeded: (adapter) => `Der Verbindungstest zum Speicher war erfolgreich (Adapter: ${adapter}).`,
126+
downloadMismatch: 'Die heruntergeladene Testdatei stimmt nicht mit der hochgeladenen überein.',
127+
s3SettingsIncomplete: 'Der S3-Adapter benötigt einen Bucket (s3_bucket) und eine Region (s3_region).',
128+
failed: (reason) => `Der Verbindungstest zum Speicher ist fehlgeschlagen: ${reason}`,
129+
},
130+
};
131+
132+
/** Each platform locale table tag → the key that serves it. */
133+
const KEY_BY_TABLE_TAG: Record<PlatformLocaleTag, StorageTestCopyLocale> = {
134+
en: 'en',
135+
'zh-Hans': 'zh-CN',
136+
'zh-Hant': 'zh-Hant',
137+
es: 'es-ES',
138+
ar: 'ar',
139+
fr: 'fr',
140+
'pt-BR': 'pt-BR',
141+
ru: 'ru',
142+
de: 'de',
143+
ja: 'ja-JP',
144+
ko: 'ko',
145+
};
146+
147+
/** The key that serves a caller's code, or `undefined` (⇒ English). */
148+
export function storageTestCopyLocaleOf(locale: string | undefined): StorageTestCopyLocale | undefined {
149+
if (!locale) return undefined;
150+
const subtags = locale.split('-');
151+
for (let n = subtags.length; n > 0; n -= 1) {
152+
const tag = platformLocaleTagOf(subtags.slice(0, n).join('-'));
153+
if (tag) return KEY_BY_TABLE_TAG[tag];
154+
}
155+
return undefined;
156+
}
157+
158+
/** The copy for a caller's locale; English when the code names no language on the table. */
159+
export function storageTestCopy(locale: string | undefined): StorageTestCopy {
160+
return STORAGE_TEST_COPY[storageTestCopyLocaleOf(locale) ?? 'en'];
161+
}

0 commit comments

Comments
 (0)