From fe65d4b9a6072c1b2c85ac7ff59887522a6fefa4 Mon Sep 17 00:00:00 2001 From: felix bear Date: Tue, 9 Jun 2026 03:44:43 +0900 Subject: [PATCH] fix(plugin): refresh procrastination buster i18n #5102 (#8145) Co-authored-by: cocojojo5213 --- packages/build-packages.js | 10 +++ packages/plugin-api/src/types.ts | 12 +++ .../procrastination-buster/src/plugin.ts | 87 ++++++++++++++++--- packages/plugin-dev/scripts/build-all.js | 19 ++++ src/app/plugins/plugin-hooks.effects.spec.ts | 39 +++++++++ src/app/plugins/plugin-hooks.effects.ts | 1 + .../plugin-index/plugin-index.component.html | 2 + 7 files changed, 158 insertions(+), 12 deletions(-) diff --git a/packages/build-packages.js b/packages/build-packages.js index b13f7c0f5e..0cc09a182b 100755 --- a/packages/build-packages.js +++ b/packages/build-packages.js @@ -100,6 +100,7 @@ async function getPlugins() { // Read manifest.json to check for additional files (e.g., config schema) const files = ['manifest.json', 'plugin.js', 'icon.svg']; + const requiredFiles = [...files]; // manifest.json may live at the root or inside src/ const manifestCandidates = [ path.join(pluginPath, 'manifest.json'), @@ -112,9 +113,16 @@ async function getPlugins() { manifestRead = true; if (manifest.iFrame !== false) { files.push('index.html'); + requiredFiles.push('index.html'); } if (manifest.jsonSchemaCfg) { files.push(manifest.jsonSchemaCfg); + requiredFiles.push(manifest.jsonSchemaCfg); + } + if (manifest.i18n?.languages) { + for (const lang of manifest.i18n.languages) { + requiredFiles.push(`i18n/${lang}.json`); + } } break; } catch { @@ -124,6 +132,7 @@ async function getPlugins() { if (!manifestRead) { // No manifest.json found — assume index.html is needed files.push('index.html'); + requiredFiles.push('index.html'); } plugins.push({ @@ -131,6 +140,7 @@ async function getPlugins() { path: `packages/plugin-dev/${entry.name}`, buildCommand: hasRealBuildScript ? 'npm run build' : undefined, files, + requiredFiles, sourcePath: hasRealBuildScript ? 'dist' : undefined, }); } catch (e) { diff --git a/packages/plugin-api/src/types.ts b/packages/plugin-api/src/types.ts index e8af521e94..43a00434f6 100644 --- a/packages/plugin-api/src/types.ts +++ b/packages/plugin-api/src/types.ts @@ -168,6 +168,7 @@ export interface FinishDayPayload { export interface LanguageChangePayload { code: string; + newLanguage: string; [key: string]: unknown; } @@ -472,6 +473,7 @@ export interface PluginAppState { export interface PluginAPI { cfg: PluginBaseCfg; + readonly Hooks: typeof PluginHooks; registerHook(hook: T, fn: PluginHookHandler): void; @@ -630,6 +632,16 @@ export interface PluginAPI { getConfig>(): Promise; + // i18n + translate(key: string, params?: Record): string; + + formatDate( + date: Date | string | number, + format: 'short' | 'medium' | 'long' | 'time' | 'datetime', + ): string; + + getCurrentLanguage(): string; + // oauth startOAuthFlow(config: OAuthFlowConfig): Promise; diff --git a/packages/plugin-dev/procrastination-buster/src/plugin.ts b/packages/plugin-dev/procrastination-buster/src/plugin.ts index 4b7efa8654..93911c8938 100644 --- a/packages/plugin-dev/procrastination-buster/src/plugin.ts +++ b/packages/plugin-dev/procrastination-buster/src/plugin.ts @@ -1,8 +1,59 @@ // Procrastination Buster Plugin for Super Productivity -import { PluginAPI } from '@super-productivity/plugin-api'; +import type { PluginAPI } from '@super-productivity/plugin-api'; declare const plugin: PluginAPI; +const PLUGIN_ID = 'procrastination-buster'; + +type PluginMessage = { + type?: unknown; + payload?: { + key?: unknown; + params?: unknown; + }; +}; + +const getPluginMessage = (message: unknown): PluginMessage | undefined => + message && typeof message === 'object' ? (message as PluginMessage) : undefined; + +const isTranslateParams = (params: unknown): params is Record => + !!params && + typeof params === 'object' && + Object.values(params).every( + (value) => typeof value === 'string' || typeof value === 'number', + ); + +const getLanguageFromHookPayload = (payload: unknown): string | undefined => { + if (typeof payload === 'string') { + return payload; + } + if (!payload || typeof payload !== 'object') { + return undefined; + } + + const languagePayload = payload as { + code?: unknown; + newLanguage?: unknown; + language?: unknown; + }; + + if (typeof languagePayload.code === 'string') { + return languagePayload.code; + } + if (typeof languagePayload.newLanguage === 'string') { + return languagePayload.newLanguage; + } + if (typeof languagePayload.language === 'string') { + return languagePayload.language; + } + + return undefined; +}; + +const getPluginIframe = (): HTMLIFrameElement | null => + document.querySelector(`iframe[data-plugin-id="${PLUGIN_ID}"]`) ?? + document.querySelector('iframe[data-plugin-iframe]'); + // Plugin initialization plugin.log.info('Procrastination Buster plugin initialized'); @@ -11,12 +62,22 @@ plugin.log.info('Procrastination Buster plugin initialized'); // i18n support - handle translation requests from iframe if (plugin.onMessage) { - plugin.onMessage(async (message: any) => { - switch (message?.type) { + plugin.onMessage((message: unknown) => { + const pluginMessage = getPluginMessage(message); + + switch (pluginMessage?.type) { case 'translate': - return await plugin.translate(message.payload.key, message.payload.params); + if (typeof pluginMessage.payload?.key !== 'string') { + return { error: 'Missing translation key' }; + } + return plugin.translate( + pluginMessage.payload.key, + isTranslateParams(pluginMessage.payload.params) + ? pluginMessage.payload.params + : undefined, + ); case 'getCurrentLanguage': - return await plugin.getCurrentLanguage(); + return plugin.getCurrentLanguage(); default: return { error: 'Unknown message type' }; } @@ -24,13 +85,15 @@ if (plugin.onMessage) { } // Listen for language changes and notify iframe -plugin.registerHook('languageChange', (language: string) => { +plugin.registerHook(plugin.Hooks.LANGUAGE_CHANGE, (payload: unknown) => { + const language = getLanguageFromHookPayload(payload); + if (!language) { + return; + } + // Notify the iframe about language change - const iframe = document.querySelector('iframe[data-plugin-iframe]'); - if (iframe && (iframe as HTMLIFrameElement).contentWindow) { - (iframe as HTMLIFrameElement).contentWindow!.postMessage( - { type: 'languageChanged', language }, - '*', - ); + const iframe = getPluginIframe(); + if (iframe?.contentWindow) { + iframe.contentWindow.postMessage({ type: 'languageChanged', language }, '*'); } }); diff --git a/packages/plugin-dev/scripts/build-all.js b/packages/plugin-dev/scripts/build-all.js index d514cfc808..4f1e5a41b0 100755 --- a/packages/plugin-dev/scripts/build-all.js +++ b/packages/plugin-dev/scripts/build-all.js @@ -38,6 +38,15 @@ function copyRecursive(src, dest) { } } +function assertFilesExist(basePath, files, pluginName) { + const missing = files.filter((file) => !fs.existsSync(path.join(basePath, file))); + if (missing.length) { + throw new Error( + `${pluginName} build is missing required asset(s): ${missing.join(', ')}`, + ); + } +} + // Plugin configurations const plugins = [ { @@ -47,6 +56,11 @@ const plugins = [ copyToAssets: true, buildCommand: async (pluginPath) => { await execAsync(`cd ${pluginPath} && npm run build`); + assertFilesExist( + path.join(pluginPath, 'dist'), + ['i18n/en.json', 'i18n/de.json'], + 'procrastination-buster', + ); // Copy to assets directory const targetDir = path.join( __dirname, @@ -64,6 +78,11 @@ const plugins = [ copyRecursive(src, dest); } } + assertFilesExist( + targetDir, + ['i18n/en.json', 'i18n/de.json'], + 'procrastination-buster', + ); return 'Built and copied to assets'; }, }, diff --git a/src/app/plugins/plugin-hooks.effects.spec.ts b/src/app/plugins/plugin-hooks.effects.spec.ts index 3e67ef6ac5..ab3f2d85be 100644 --- a/src/app/plugins/plugin-hooks.effects.spec.ts +++ b/src/app/plugins/plugin-hooks.effects.spec.ts @@ -9,6 +9,7 @@ import { TaskSharedActions } from '../root-store/meta/task-shared.actions'; import { PlannerActions } from '../features/planner/store/planner.actions'; import { TaskWithSubTasks } from '../features/tasks/task.model'; import { PluginHooks } from './plugin-api.model'; +import { PluginI18nService } from './plugin-i18n.service'; import { selectCurrentTask, selectTaskById, @@ -17,11 +18,15 @@ import { selectPluginUserDataFeatureState } from './store/plugin-user-data.reduc import { SyncTriggerService } from '../imex/sync/sync-trigger.service'; import { HydrationStateService } from '../op-log/apply/hydration-state.service'; import { PluginUserData } from './plugin-persistence.model'; +import { updateGlobalConfigSection } from '../features/config/store/global-config.actions'; +import { selectLocalizationConfig } from '../features/config/store/global-config.reducer'; +import { LanguageCode } from '../core/locale.constants'; describe('PluginHooksEffects', () => { let effects: PluginHooksEffects; let actions$: Observable; let pluginServiceMock: jasmine.SpyObj; + let pluginI18nServiceMock: jasmine.SpyObj; let store: MockStore; let gateSubject: ReplaySubject; @@ -64,6 +69,9 @@ describe('PluginHooksEffects', () => { 'dispatchHook', 'dispatchHookToPlugin', ]); + pluginI18nServiceMock = jasmine.createSpyObj('PluginI18nService', [ + 'setCurrentLanguage', + ]); // MUST be a ReplaySubject(1) / Subject — NOT BehaviorSubject(true) or // of(true). The boot-suppression spec depends on the gate being un-emitted @@ -87,6 +95,7 @@ describe('PluginHooksEffects', () => { }, }), { provide: PluginService, useValue: pluginServiceMock }, + { provide: PluginI18nService, useValue: pluginI18nServiceMock }, // workContextChange$ reads activeWorkContext$ at construction; an // empty stream keeps that effect inert for the other effects' tests. { provide: WorkContextService, useValue: { activeWorkContext$: EMPTY } }, @@ -380,6 +389,36 @@ describe('PluginHooksEffects', () => { }); }); + describe('languageChange$', () => { + it('updates plugin i18n and dispatches a language hook payload compatible with documented and typed plugins', (done) => { + store.overrideSelector(selectLocalizationConfig, { + lng: LanguageCode.de, + dateTimeLocale: undefined, + firstDayOfWeek: undefined, + }); + actions$ = of( + updateGlobalConfigSection({ + sectionKey: 'localization', + sectionCfg: {}, + }), + ); + + effects.languageChange$.subscribe(() => { + expect(pluginI18nServiceMock.setCurrentLanguage).toHaveBeenCalledOnceWith( + LanguageCode.de, + ); + expect(pluginServiceMock.dispatchHook).toHaveBeenCalledWith( + PluginHooks.LANGUAGE_CHANGE, + { + code: LanguageCode.de, + newLanguage: LanguageCode.de, + }, + ); + done(); + }); + }); + }); + describe('firePersistedDataChanged$', () => { const entry = (id: string, data: string): PluginUserData => ({ id, data }); diff --git a/src/app/plugins/plugin-hooks.effects.ts b/src/app/plugins/plugin-hooks.effects.ts index c9c308097e..08d7628176 100644 --- a/src/app/plugins/plugin-hooks.effects.ts +++ b/src/app/plugins/plugin-hooks.effects.ts @@ -238,6 +238,7 @@ export class PluginHooksEffects { // Dispatch hook to notify plugins this.pluginService.dispatchHook(PluginHooks.LANGUAGE_CHANGE, { + code: newLanguage, newLanguage, }); }), diff --git a/src/app/plugins/ui/plugin-index/plugin-index.component.html b/src/app/plugins/ui/plugin-index/plugin-index.component.html index aea6a9f983..60f43bf0b3 100644 --- a/src/app/plugins/ui/plugin-index/plugin-index.component.html +++ b/src/app/plugins/ui/plugin-index/plugin-index.component.html @@ -67,6 +67,8 @@