From b35bfde557c175b035117c6ed3d7012a668269aa Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 9 Sep 2026 22:10:07 +0800 Subject: [PATCH] Add zh/en i18n and follow DSH locale.bind for UI. Wire API/delivery/tool strings through lib/i18n.js, and make the sidebar use ctx.locale.bind plus locale/change so English host preference no longer stays frozen on Chinese. Co-authored-by: Cursor --- lib/client.js | 128 +++++++++++++++++++++++++++++++++++++----- lib/delivery.js | 12 ++-- lib/host.js | 27 +++++---- lib/i18n.js | 126 +++++++++++++++++++++++++++++++++++++++++ lib/index.d.ts | 9 ++- lib/index.js | 9 +++ lib/isolation.js | 14 ++++- lib/tools.js | 11 ++-- package.json | 1 + test/delivery.test.js | 2 + test/i18n.test.js | 62 ++++++++++++++++++++ test/package.test.js | 9 +++ 12 files changed, 373 insertions(+), 37 deletions(-) create mode 100644 lib/i18n.js create mode 100644 test/i18n.test.js diff --git a/lib/client.js b/lib/client.js index 2ae177b..16efa41 100644 --- a/lib/client.js +++ b/lib/client.js @@ -186,6 +186,43 @@ window.__ModuleLoader__.load({ const LOCALE_NS = 'settings.dshCronTasks' const TITLE_PREFIX = '定时任务 · ' + const TITLE_PREFIX_EN = 'Scheduled tasks · ' + /** Live translator from ctx.locale.bind; falls back until apply() wires it. */ + let translate = (key) => en[key] || zh[key] || key + function uiT(key) { + try { return translate(key) } catch { return en[key] || zh[key] || key } + } + function resolveUiLang(ctx) { + const snap = typeof ctx?.locale?.getSnapshot === 'function' ? ctx.locale.getSnapshot() : null + const raw = snap?.active + || snap?.locale + || snap?.preference + || ctx?.locale?.active + || (typeof document !== 'undefined' ? document.documentElement?.lang : '') + || 'en' + return String(raw).toLowerCase().startsWith('zh') ? 'zh' : 'en' + } + function makeTranslator(ctx) { + if (typeof ctx?.locale?.bind === 'function') { + try { + const bound = ctx.locale.bind(LOCALE_NS) + if (typeof bound === 'function') { + return (key, params) => { + try { + const out = bound(key, params) + if (out != null && out !== key) return out + } catch { /* fall through */ } + const dict = resolveUiLang(ctx) === 'zh' ? zh : en + return dict[key] || en[key] || zh[key] || key + } + } + } catch { /* fall through */ } + } + return (key) => { + const dict = resolveUiLang(ctx) === 'zh' ? zh : en + return dict[key] || en[key] || zh[key] || key + } + } const listSnapshot = { jobs: [], runs: [], workspaces: [], catalog: { groups: [], current: null }, @@ -365,6 +402,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba deliveryHint: '选 WhatsApp/IM 后从下拉选择已保存的投递目标;也可手动填写。目标在 IM 机器人 → 投递设置里创建。', mirrorToSession: '镜像回原会话', mirrorToSessionHint: '开启后把每次运行摘要写入创建时的 WhatsApp/Web 会话(不新开模型轮次)。默认关闭。', + loginRequired: '登录后才能使用定时任务', + runNoSession: '这次运行没有可打开的会话', + runOpenFailed: '打不开这次对话,会话可能已被删除', } const en = { title: 'Scheduled tasks', @@ -408,6 +448,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba deliveryHint: 'Pick a saved IM delivery target from the list, or enter botId/targetId manually. Create targets under IM bot → Delivery settings.', mirrorToSession: 'Mirror into origin session', mirrorToSessionHint: 'When enabled, append each run summary into the creating WhatsApp/Web session (no new model turn). Off by default.', + loginRequired: 'Sign in to use scheduled tasks', + runNoSession: 'This run has no session to open', + runOpenFailed: 'Could not open this chat; the session may have been deleted', } function normalizePrefs(value) { @@ -487,7 +530,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba } function SettingsCard(props) { - const t = typeof props.t === 'function' ? props.t : (key) => zh[key] || key + const t = typeof props.t === 'function' + ? props.t + : (key) => (resolveUiLang() === 'zh' ? zh : en)[key] || en[key] || zh[key] || key const state = props.useCard ? props.useCard((s) => s) : { writable: false, dirty: false, saving: false, failed: false, value: DEFAULTS, } @@ -551,14 +596,16 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba async function api(path, options = {}) { if (!hasUdsLoginCookie() && path !== '/health') { - throw new Error('登录后才能使用定时任务') + throw new Error(uiT('loginRequired')) } const response = await fetch(`${API}${path}`, { ...options, headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}), ...options.headers }, }) const body = await response.json().catch(() => ({})) - if (!response.ok || body.ok === false) throw new Error(body.error || `HTTP ${response.status}`) + if (!response.ok || body.ok === false) { + throw new Error(body.message || body.error || `HTTP ${response.status}`) + } return body } @@ -822,7 +869,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba const ordered = [...groups.values()].sort((a, b) => { if (a.key === '__unassigned__') return 1 if (b.key === '__unassigned__') return -1 - return String(a.label).localeCompare(String(b.label), 'zh') + return String(a.label).localeCompare(String(b.label), resolveUiLang() === 'zh' ? 'zh' : 'en') }) return ordered.map((group) => { const folderId = `owner:${group.key}` @@ -1181,7 +1228,17 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba const [presets, setPresets] = useState(() => listSnapshot.presets || { items: [], current: null }) const [imCatalog, setImCatalog] = useState(() => listSnapshot.imCatalog || { available: true, options: [], loading: false }) const [paneHost, setPaneHost] = useState(() => (typeof document === 'undefined' ? null : document.body)) + const [localeTick, setLocaleTick] = useState(0) const skipAutoSelect = useRef(false) + void localeTick + + useEffect(() => { + const bump = () => setLocaleTick((n) => n + 1) + const offs = [] + window.addEventListener('dsh-ct-locale', bump) + offs.push(() => window.removeEventListener('dsh-ct-locale', bump)) + return () => { for (const off of offs) off() } + }, []) useEffect(() => { const bind = () => { @@ -1366,7 +1423,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba expandJobPath(jobId) setError('') if (!run.sessionId) { - setError('这次运行没有可打开的会话') + setError(uiT('runNoSession')) return } try { @@ -1375,7 +1432,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba setError(err instanceof Error ? err.message : String(err)) } const opened = await faces.openSession?.(run.sessionId) - if (opened === false) setError('打不开这次对话,会话可能已被删除') + if (opened === false) setError(uiT('runOpenFailed')) } function currentSessionId(faces) { @@ -1522,11 +1579,15 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba || value.startsWith('运维定时') || value.startsWith(TITLE_PREFIX.trim()) || value.includes(TITLE_PREFIX) + || value.startsWith(TITLE_PREFIX_EN.trim()) + || value.includes(TITLE_PREFIX_EN) + || /^scheduled tasks/i.test(value) } function workspaceTitleFromCronFork(title) { const value = String(title || '').trim() if (value.startsWith(TITLE_PREFIX)) return value.slice(TITLE_PREFIX.length).trim() || value + if (value.startsWith(TITLE_PREFIX_EN)) return value.slice(TITLE_PREFIX_EN.length).trim() || value if (value.startsWith('运维定时')) { const stripped = value.replace(/^运维定时(?:\s*·\s*|\s+)/, '').trim() return stripped || value @@ -1535,6 +1596,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba const stripped = value.replace(/^定时任务(?:\s*·\s*|\s+)/, '').trim() return stripped || value } + if (/^scheduled tasks(?:\s*·\s*|\s+)/i.test(value)) { + const stripped = value.replace(/^scheduled tasks(?:\s*·\s*|\s+)/i, '').trim() + return stripped || value + } return value } @@ -1547,7 +1612,11 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba const parent = String(row.parentId || row.parentSessionId || '').trim() if (parent && ids.has(parent)) return true const title = String(row.title || row.displayTitle || '') - const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('定时任务') || title.startsWith('运维定时') + const looksCron = title.startsWith(TITLE_PREFIX) + || title.startsWith(TITLE_PREFIX_EN) + || title.startsWith('定时任务') + || title.startsWith('运维定时') + || /^scheduled tasks/i.test(title) if (previousId && ids.has(String(previousId)) && looksCron) return true return false } @@ -1653,7 +1722,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba } } - function installChrome(t, faces) { + function installChrome(t, faces, ctx) { let entry = null let listRoot = null let appRoot = null @@ -1672,6 +1741,19 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba }) } + function syncEntryCopy() { + if (!entry) return + const label = entry.querySelector('span') + if (label) label.textContent = cronOn ? t('backToWorkspace') : t('entry') + entry.setAttribute('aria-label', cronOn ? t('backLabel') : t('entryLabel')) + } + + function onLocaleChange() { + syncEntryCopy() + window.dispatchEvent(new CustomEvent('dsh-ct-locale')) + if (appRoot) appRoot.render(h(CronApp, { t, faces })) + } + const place = () => { const found = findNewSessionButton() if (!found) return @@ -1722,9 +1804,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba entry.append(icon, label) } syncEntryLook(entry, found) - const label = entry.querySelector('span') - if (label) label.textContent = cronOn ? t('backToWorkspace') : t('entry') - entry.setAttribute('aria-label', cronOn ? t('backLabel') : t('entryLabel')) + syncEntryCopy() if (anchor.parentNode && anchor.nextSibling !== entry) { if (anchor.nextSibling) anchor.parentNode.insertBefore(entry, anchor.nextSibling) else anchor.parentNode.appendChild(entry) @@ -1781,6 +1861,24 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba const onAuth = () => { place() } window.addEventListener('focus', onAuth) window.addEventListener('uds-auth-changed', onAuth) + const localeOffs = [] + try { + const off = ctx?.on?.('locale/change', onLocaleChange) + if (typeof off === 'function') localeOffs.push(off) + } catch { /* optional */ } + try { + const off = ctx?.locale?.subscribe?.(onLocaleChange) + if (typeof off === 'function') localeOffs.push(off) + } catch { /* optional */ } + // document.lang flips with DSH syncDocumentLanguage — catch hosts without locale events. + let lastLang = typeof document !== 'undefined' ? document.documentElement?.lang : '' + const langTimer = setInterval(() => { + const next = document.documentElement?.lang || '' + if (next !== lastLang) { + lastLang = next + onLocaleChange() + } + }, 1000) let boots = 0 const boot = () => { boots += 1 @@ -1792,6 +1890,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba return () => { window.removeEventListener('focus', onAuth) window.removeEventListener('uds-auth-changed', onAuth) + for (const off of localeOffs) off() + clearInterval(langTimer) observer.disconnect() clearInterval(timer) if (placeRaf) cancelAnimationFrame(placeRaf) @@ -1975,10 +2075,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba }, 'dsh-ops-cron: fork promote') ctx.effect(() => { - const lang = ctx.locale?.locale || ctx.locale?.current || 'zh' - const dict = String(lang).toLowerCase().startsWith('zh') ? zh : en - const t = (key) => dict[key] || zh[key] || key - return installChrome(t, faces) + translate = makeTranslator(ctx) + return installChrome(translate, faces, ctx) }, 'dsh-ops-cron: chrome') } diff --git a/lib/delivery.js b/lib/delivery.js index cd85de2..5dd7d31 100644 --- a/lib/delivery.js +++ b/lib/delivery.js @@ -3,6 +3,8 @@ * Group jobs may also pin mentionJid so delivery @s the creator. */ +import { DEFAULT_LOCALE, t } from './i18n.js' + export function normalizeDelivery(input = {}) { const src = input && typeof input === 'object' ? input : {} const kind = String(src.kind || 'dsh').trim().toLowerCase() @@ -181,7 +183,7 @@ export function deliveryDraftFromPeer(peer) { // Non-WhatsApp peers are not auto-materialized yet. return null } - const name = String(peer.label || (isGroup ? 'WhatsApp 群聊' : 'WhatsApp 私聊')).trim().slice(0, 80) + const name = String(peer.label || (isGroup ? t('delivery.wa_group') : t('delivery.wa_dm'))).trim().slice(0, 80) const targetId = isGroup ? `auto-group-${slugId(routeJid, 'chat')}` : `auto-dm-${slugId(peer.phone || routeJid, 'user')}` @@ -427,11 +429,13 @@ const IM_MAX_CHARS = 3500 * Shared body for IM delivery and session mirror (without @mention prefix). * @param {object} job * @param {string} [summary] + * @param {'zh' | 'en' | string} [locale] */ -export function formatRunResultBody(job, summary) { - const text = String(summary || '').trim() || `(定时任务「${job?.name || ''}」已完成,无文本摘要)` +export function formatRunResultBody(job, summary, locale = DEFAULT_LOCALE) { + const name = job?.name || '' + const text = String(summary || '').trim() || t('delivery.empty_summary', locale, { name }) const clipped = text.length > IM_MAX_CHARS ? `${text.slice(0, IM_MAX_CHARS)}…` : text - return `【定时任务 · ${job?.name || ''}】\n${clipped}` + return `${t('delivery.header', locale, { name })}\n${clipped}` } /** diff --git a/lib/host.js b/lib/host.js index 4451271..4f6f3be 100644 --- a/lib/host.js +++ b/lib/host.js @@ -9,6 +9,7 @@ import { homedir } from 'node:os' import { basename, join } from 'node:path' import { claimOccurrence, executeClaimedRun, extractAssistantText, interruptActiveRuns, publicJob, settleRun, TITLE_PREFIX } from './fire.js' import { deliverRunToIm, mergeDeliveryMention, mirrorRunToSession, normalizeOrigin } from './delivery.js' +import { apiError, resolveLocale } from './i18n.js' import { workspaceVisibleIds } from './isolation.js' import { decideDispatch, nextFire, validateSchedule } from './scheduler.js' import { @@ -148,8 +149,9 @@ async function resolveBrowserIdentity(request, getUdsAuth) { * @returns {Promise} identity or null after writing error response */ async function requireIdentity(request, write, getUdsAuth) { + const locale = resolveLocale(request) if (!isTrustedApiRequest(request)) { - write(403, { ok: false, error: 'forbidden' }) + write(403, apiError('forbidden', locale)) return null } const uds = typeof getUdsAuth === 'function' ? getUdsAuth() : null @@ -159,10 +161,10 @@ async function requireIdentity(request, write, getUdsAuth) { } const identity = await resolveBrowserIdentity(request, getUdsAuth) if (!identity?.empNo) { - write(401, { ok: false, error: 'login_required', message: '登录后才能使用定时任务' }) + write(401, apiError('login_required', locale)) return null } - return { ...identity, mode: 'multi' } + return { ...identity, mode: 'multi', lang: identity.lang || locale } } function sanitizeJobCwd(cwd, identity, getUdsAuth, { forOwnerEmpNo } = {}) { @@ -740,7 +742,7 @@ export function createHostService(options = {}) { const runId = decodeURIComponent(openMatch[1]) const state = await snapshot() const run = (state.runs || []).find((row) => row.id === runId) - if (!run?.sessionId) return write(404, { ok: false, error: 'run not found' }) + if (!run?.sessionId) return write(404, apiError('run_not_found', resolveLocale(req, identity))) assertCanAccessJob(getJob(state, run.jobId), identity) let unarchived = false if (typeof sessionPort?.revealSession === 'function') { @@ -755,7 +757,7 @@ export function createHostService(options = {}) { const identity = await requireIdentity(req, write, getUdsAuth) if (!identity) return const sessionId = decodeURIComponent(adoptMatch[1]) - if (!sessionId) return write(400, { ok: false, error: 'sessionId required' }) + if (!sessionId) return write(400, apiError('session_id_required', resolveLocale(req, identity))) if (typeof sessionPort?.adoptSession !== 'function') { return write(200, { ok: false, attached: false, sessionId }) } @@ -810,16 +812,19 @@ export function createHostService(options = {}) { return } - write(404, { ok: false, error: 'not found' }) + write(404, apiError('not_found', resolveLocale(req))) } catch (error) { + const locale = resolveLocale(req) const code = error && error.code - if (code === 'NOT_FOUND') return write(404, { ok: false, error: error.message }) - write(401, { ok: false, error: 'login_required', message: '登录后才能使用定时任务' }) + if (code === 'NOT_FOUND') return write(404, { ...apiError('not_found', locale), error: error.message || 'not_found' }) if (code === 'INVALID_CRON' || code === 'INVALID_AT' || code === 'INVALID_SCHEDULE' || code === 'INVALID_JOB' || code === 'INVALID_TIMEZONE' || code === 'INVALID_CWD') { - return write(400, { ok: false, error: error.message, code }) + return write(400, { ok: false, error: error.message, code, message: error.message }) } - if (code === 'PAYLOAD_TOO_LARGE') return write(413, { ok: false, error: 'payload too large' }) - write(500, { ok: false, error: error instanceof Error ? error.message : 'internal error' }) + if (code === 'PAYLOAD_TOO_LARGE') return write(413, apiError('payload_too_large', locale)) + write(500, { + ...apiError('internal', locale), + error: error instanceof Error ? error.message : 'internal', + }) } } diff --git a/lib/i18n.js b/lib/i18n.js new file mode 100644 index 0000000..25b105d --- /dev/null +++ b/lib/i18n.js @@ -0,0 +1,126 @@ +/** + * dsh-ops-cron i18n — API / delivery / tool card strings. + * UI sidebar+settings strings live in lib/client.js (DSH locale.register shape). + * Locales: zh (default), en. + */ + +export const DEFAULT_LOCALE = 'zh' + +/** Canonical session title prefix (stored on run sessions). */ +export const TITLE_PREFIX = '定时任务 · ' +/** Alternate English prefix — recognized when stripping / matching. */ +export const TITLE_PREFIX_EN = 'Scheduled tasks · ' + +/** @type {Record>} */ +export const MESSAGES = { + zh: { + 'err.login_required': '登录后才能使用定时任务', + 'err.forbidden': '禁止访问', + 'err.not_found': '未找到', + 'err.run_not_found': '运行记录不存在', + 'err.session_id_required': '需要 sessionId', + 'err.payload_too_large': '请求体过大', + 'err.internal': '内部错误', + + 'delivery.empty_summary': '(定时任务「{name}」已完成,无文本摘要)', + 'delivery.header': '【定时任务 · {name}】', + 'delivery.wa_group': 'WhatsApp 群聊', + 'delivery.wa_dm': 'WhatsApp 私聊', + + 'tool.create': '创建定时任务', + 'tool.list': '列出定时任务', + 'tool.pause': '暂停定时任务', + 'tool.resume': '恢复定时任务', + 'tool.delete': '删除定时任务', + }, + en: { + 'err.login_required': 'Sign in to use scheduled tasks', + 'err.forbidden': 'Forbidden', + 'err.not_found': 'Not found', + 'err.run_not_found': 'Run not found', + 'err.session_id_required': 'sessionId required', + 'err.payload_too_large': 'Payload too large', + 'err.internal': 'Internal error', + + 'delivery.empty_summary': '(Scheduled task "{name}" finished with no text summary)', + 'delivery.header': '[Scheduled tasks · {name}]', + 'delivery.wa_group': 'WhatsApp group', + 'delivery.wa_dm': 'WhatsApp DM', + + 'tool.create': 'Create scheduled task', + 'tool.list': 'List scheduled tasks', + 'tool.pause': 'Pause scheduled task', + 'tool.resume': 'Resume scheduled task', + 'tool.delete': 'Delete scheduled task', + }, +} + +/** + * @param {unknown} lang + * @returns {'zh' | 'en'} + */ +export function normalizeLocale(lang) { + const raw = String(lang || '').trim().toLowerCase() + if (!raw) return DEFAULT_LOCALE + if (raw.startsWith('en')) return 'en' + if (raw.startsWith('zh')) return DEFAULT_LOCALE + return DEFAULT_LOCALE +} + +/** + * @param {string} key + * @param {'zh' | 'en' | string} [locale] + * @param {Record} [vars] + */ +export function t(key, locale = DEFAULT_LOCALE, vars = {}) { + const loc = normalizeLocale(locale) + const table = MESSAGES[loc] || MESSAGES[DEFAULT_LOCALE] + let text = table[key] || MESSAGES[DEFAULT_LOCALE][key] || key + for (const [k, v] of Object.entries(vars || {})) { + text = text.split('{' + k + '}').join(String(v)) + } + return text +} + +function parseCookie(header, name) { + if (!header) return null + const prefix = name + '=' + for (const part of String(header).split(';')) { + const v = part.trim() + if (v.startsWith(prefix)) { + try { return decodeURIComponent(v.slice(prefix.length)) } catch { return v.slice(prefix.length) } + } + } + return null +} + +/** + * Resolve locale from HTTP request + optional identity.lang. + * @param {import('node:http').IncomingMessage | null | undefined} req + * @param {{ lang?: string } | null | undefined} [identity] + */ +export function resolveLocale(req, identity) { + const headers = req?.headers || {} + const cookie = headers.cookie || '' + const fromIdentity = identity?.lang + const fromHeader = headers['x-lang-id'] || headers['X-Lang-Id'] + const fromCookie = parseCookie(cookie, 'PORTALSSOLanguage') + || parseCookie(cookie, 'ZTEDPGSSOLanguage') + const accept = String(headers['accept-language'] || '').split(',')[0] + return normalizeLocale(fromIdentity || fromHeader || fromCookie || accept || DEFAULT_LOCALE) +} + +/** + * API error payload: stable `error` code + localized `message`. + * @param {string} code + * @param {'zh' | 'en' | string} locale + * @param {Record} [vars] + */ +export function apiError(code, locale, vars) { + const key = code.startsWith('err.') ? code : 'err.' + code + return { + ok: false, + error: code.startsWith('err.') ? code.slice(4) : code, + message: t(key, locale, vars), + } +} diff --git a/lib/index.d.ts b/lib/index.d.ts index 7f9b7ff..981ee96 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -59,6 +59,13 @@ export function resolveCreateOrigin(args?: object, exec?: object, deps?: object) export function resolveMirrorSession(job: object, deps?: object): Promise<{ sessionId: string, via: string } | null> export function resolveCallerPeer(exec?: object, dshIm?: object): Promise export function jobVisibleToPeer(job: object, peer: object): boolean -export function formatRunResultBody(job: object, summary?: string): string +export function formatRunResultBody(job: object, summary?: string, locale?: string): string export function deliverRunToIm(job: object, summary?: string, deps?: object): Promise export function mirrorRunToSession(job: object, summary?: string, deps?: object): Promise +export const DEFAULT_LOCALE: 'zh' +export const TITLE_PREFIX_EN: string +export const MESSAGES: Record> +export function normalizeLocale(lang?: unknown): 'zh' | 'en' +export function resolveLocale(req?: object | null, identity?: { lang?: string } | null): 'zh' | 'en' +export function t(key: string, locale?: string, vars?: Record): string +export function apiError(code: string, locale?: string, vars?: Record): { ok: false, error: string, message: string } diff --git a/lib/index.js b/lib/index.js index 2a67684..94e59de 100644 --- a/lib/index.js +++ b/lib/index.js @@ -74,6 +74,15 @@ export { resolveCreateOrigin, resolveMirrorSession, } from './delivery.js' +export { + DEFAULT_LOCALE, + MESSAGES, + TITLE_PREFIX_EN, + apiError, + normalizeLocale, + resolveLocale, + t, +} from './i18n.js' function loadPkg(id) { const homes = [ diff --git a/lib/isolation.js b/lib/isolation.js index 80500f5..afacae0 100644 --- a/lib/isolation.js +++ b/lib/isolation.js @@ -6,6 +6,7 @@ export const HIDDEN_REASON = 'cron-run' export const TITLE_PREFIX = '定时任务 · ' +export const TITLE_PREFIX_EN = 'Scheduled tasks · ' /** * Strip the scheduler title prefix so a forked child can sit in the @@ -17,6 +18,9 @@ export function workspaceTitleFromCronFork(title) { if (value.startsWith(TITLE_PREFIX)) { return value.slice(TITLE_PREFIX.length).trim() || value } + if (value.startsWith(TITLE_PREFIX_EN)) { + return value.slice(TITLE_PREFIX_EN.length).trim() || value + } if (value.startsWith('运维定时')) { const stripped = value.replace(/^运维定时(?:\s*·\s*|\s+)/, '').trim() return stripped || value @@ -26,6 +30,10 @@ export function workspaceTitleFromCronFork(title) { const stripped = value.replace(/^定时任务(?:\s*·\s*|\s+)/, '').trim() return stripped || value } + if (/^scheduled tasks(?:\s*·\s*|\s+)/i.test(value)) { + const stripped = value.replace(/^scheduled tasks(?:\s*·\s*|\s+)/i, '').trim() + return stripped || value + } return value } @@ -60,7 +68,11 @@ export function shouldPromoteCronFork(row, cronSessionIds, previousId) { const parent = String(row.parentId || row.parentSessionId || '').trim() if (parent && ids.has(parent)) return true const title = String(row.title || row.displayTitle || '') - const looksCron = title.startsWith(TITLE_PREFIX) || title.startsWith('运维定时') || title.startsWith('定时任务') + const looksCron = title.startsWith(TITLE_PREFIX) + || title.startsWith(TITLE_PREFIX_EN) + || title.startsWith('运维定时') + || title.startsWith('定时任务') + || /^scheduled tasks/i.test(title) if (previousId && ids.has(String(previousId)) && looksCron) return true return false } diff --git a/lib/tools.js b/lib/tools.js index a9e68df..aa3103f 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -11,6 +11,7 @@ import { resolveCreateDelivery, resolveCreateOrigin, } from './delivery.js' +import { t } from './i18n.js' import { resolveCreateAgentPreset } from './preset.js' import { assertCanAccessJob, @@ -287,7 +288,7 @@ export function cronToolDefinitions(service, deps = {}) { return text(`Created "${job?.name}" (${job?.id}) ${sched} in ${tz}.${cwd}.${model}.${delivery}. Next run: ${when}. If that local time is wrong, delete this job and recreate with 24h hours (22=10pm).`) }, }, - presentCall: (args) => ({ card: 'generic', title: '创建定时任务', content: String(args?.name || '') }), + presentCall: (args) => ({ card: 'generic', title: t('tool.create'), content: String(args?.name || '') }), async execute(args, exec) { aborted(exec) const timeout = Number(args.timeout_minutes) @@ -352,7 +353,7 @@ export function cronToolDefinitions(service, deps = {}) { return text(rows.length ? `Scheduled tasks (${value.count}):\n${rows.join('\n')}` : 'No scheduled tasks yet.') }, }, - presentCall: () => ({ card: 'generic', title: '列出定时任务' }), + presentCall: () => ({ card: 'generic', title: t('tool.list') }), async execute(args, exec) { aborted(exec) const peer = await resolveCallerPeer(exec, getDshIm()) @@ -376,7 +377,7 @@ export function cronToolDefinitions(service, deps = {}) { schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } }, render: (_args, value) => text(`Paused "${value.job?.name}" (${value.job?.id}).`), }, - presentCall: (args) => ({ card: 'generic', title: '暂停定时任务', content: String(args?.id || '') }), + presentCall: (args) => ({ card: 'generic', title: t('tool.pause'), content: String(args?.id || '') }), async execute(args, exec) { aborted(exec) const peer = await resolveCallerPeer(exec, getDshIm()) @@ -400,7 +401,7 @@ export function cronToolDefinitions(service, deps = {}) { schema: { type: 'object', additionalProperties: true, properties: { job: JOB_SCHEMA } }, render: (_args, value) => text(`Resumed "${value.job?.name}" (${value.job?.id}).`), }, - presentCall: (args) => ({ card: 'generic', title: '恢复定时任务', content: String(args?.id || '') }), + presentCall: (args) => ({ card: 'generic', title: t('tool.resume'), content: String(args?.id || '') }), async execute(args, exec) { aborted(exec) const peer = await resolveCallerPeer(exec, getDshIm()) @@ -432,7 +433,7 @@ export function cronToolDefinitions(service, deps = {}) { }, render: (_args, value) => text(value.deleted ? `Deleted scheduled task ${value.id}.` : `Scheduled task ${value.id} was not found.`), }, - presentCall: (args) => ({ card: 'generic', title: '删除定时任务', content: String(args?.id || '') }), + presentCall: (args) => ({ card: 'generic', title: t('tool.delete'), content: String(args?.id || '') }), async execute(args, exec) { aborted(exec) const peer = await resolveCallerPeer(exec, getDshIm()) diff --git a/package.json b/package.json index 918dba0..0823b30 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "default": "./lib/index.js" }, "./client": "./lib/client.js", + "./i18n": "./lib/i18n.js", "./cordis.patch.yml": "./cordis.patch.yml", "./package.json": "./package.json" }, diff --git a/test/delivery.test.js b/test/delivery.test.js index f973c10..1cf7153 100644 --- a/test/delivery.test.js +++ b/test/delivery.test.js @@ -436,4 +436,6 @@ test('formatRunResultBody matches IM delivery header', () => { const body = formatRunResultBody({ name: '晨报' }, 'ok') assert.match(body, /^【定时任务 · 晨报】\n/) assert.match(body, /ok$/) + const en = formatRunResultBody({ name: 'Daily' }, 'ok', 'en') + assert.match(en, /^\[Scheduled tasks · Daily\]\n/) }) diff --git a/test/i18n.test.js b/test/i18n.test.js new file mode 100644 index 0000000..fcca2ec --- /dev/null +++ b/test/i18n.test.js @@ -0,0 +1,62 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + MESSAGES, + apiError, + formatRunResultBody, + normalizeLocale, + resolveLocale, + t, + workspaceTitleFromCronFork, +} from '../lib/index.js' + +describe('dsh-ops-cron i18n', () => { + it('normalizes locales', () => { + assert.equal(normalizeLocale('en-US'), 'en') + assert.equal(normalizeLocale('zh-CN'), 'zh') + assert.equal(normalizeLocale(''), 'zh') + }) + + it('translates API and delivery keys', () => { + assert.equal(t('err.login_required', 'zh'), '登录后才能使用定时任务') + assert.equal(t('err.login_required', 'en'), 'Sign in to use scheduled tasks') + assert.equal(t('tool.create', 'en'), 'Create scheduled task') + }) + + it('interpolates delivery vars', () => { + assert.match(t('delivery.header', 'zh', { name: '晨报' }), /晨报/) + assert.match(t('delivery.header', 'en', { name: 'Daily' }), /Daily/) + }) + + it('returns stable API error codes with localized message', () => { + const zh = apiError('login_required', 'zh') + const en = apiError('login_required', 'en') + assert.equal(zh.error, 'login_required') + assert.equal(en.error, 'login_required') + assert.equal(zh.ok, false) + assert.equal(zh.message, '登录后才能使用定时任务') + assert.equal(en.message, 'Sign in to use scheduled tasks') + }) + + it('resolves locale from Accept-Language', () => { + assert.equal(resolveLocale({ headers: { 'accept-language': 'en-US,en;q=0.9' } }), 'en') + assert.equal(resolveLocale({ headers: { 'accept-language': 'zh-CN' } }), 'zh') + }) + + it('formatRunResultBody respects locale', () => { + const zh = formatRunResultBody({ name: '晨报' }, 'ok', 'zh') + const en = formatRunResultBody({ name: 'Daily' }, 'ok', 'en') + assert.match(zh, /^【定时任务 · 晨报】\n/) + assert.match(en, /^\[Scheduled tasks · Daily\]\n/) + }) + + it('strips English title prefix on fork promote', () => { + assert.equal(workspaceTitleFromCronFork('Scheduled tasks · Daily (1)'), 'Daily (1)') + }) + + it('zh and en tables share the same keys', () => { + const zhKeys = Object.keys(MESSAGES.zh).sort() + const enKeys = Object.keys(MESSAGES.en).sort() + assert.deepEqual(zhKeys, enKeys) + }) +}) diff --git a/test/package.test.js b/test/package.test.js index 4f41663..15d2fdd 100644 --- a/test/package.test.js +++ b/test/package.test.js @@ -68,6 +68,15 @@ test('installable bundle declares host apply, client half, unique id, and no @de assert.match(client, /agentPreset/) assert.match(client, /mirrorToSession/) assert.match(client, /inject = \['slots', 'locale', 'settingsScope'\]/) + assert.match(client, /locale\.bind/) + assert.match(client, /locale\/change/) + assert.match(client, /loginRequired/) + assert.match(client, /runNoSession/) + assert.match(client, /body\.message/) + const i18n = await readFile(join(root, 'lib/i18n.js'), 'utf8') + assert.match(i18n, /err\.login_required/) + assert.match(i18n, /export function apiError/) + assert.ok(pkg.exports['./i18n']) const preset = await readFile(join(root, 'lib/preset.js'), 'utf8') assert.match(preset, /resolveCreateAgentPreset/) const delivery = await readFile(join(root, 'lib/delivery.js'), 'utf8')