fix(plugin): refresh procrastination buster i18n #5102 (#8145)

Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
This commit is contained in:
felix bear 2026-06-09 03:44:43 +09:00 committed by GitHub
parent b13303c202
commit fe65d4b9a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 158 additions and 12 deletions

View file

@ -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) {

View file

@ -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<T extends Hooks>(hook: T, fn: PluginHookHandler<T>): void;
@ -630,6 +632,16 @@ export interface PluginAPI {
getConfig<T = Record<string, unknown>>(): Promise<T | null>;
// i18n
translate(key: string, params?: Record<string, string | number>): string;
formatDate(
date: Date | string | number,
format: 'short' | 'medium' | 'long' | 'time' | 'datetime',
): string;
getCurrentLanguage(): string;
// oauth
startOAuthFlow(config: OAuthFlowConfig): Promise<OAuthTokenResult>;

View file

@ -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<string, string | number> =>
!!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<HTMLIFrameElement>(`iframe[data-plugin-id="${PLUGIN_ID}"]`) ??
document.querySelector<HTMLIFrameElement>('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 }, '*');
}
});

View file

@ -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';
},
},

View file

@ -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<any>;
let pluginServiceMock: jasmine.SpyObj<PluginService>;
let pluginI18nServiceMock: jasmine.SpyObj<PluginI18nService>;
let store: MockStore;
let gateSubject: ReplaySubject<boolean>;
@ -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 });

View file

@ -238,6 +238,7 @@ export class PluginHooksEffects {
// Dispatch hook to notify plugins
this.pluginService.dispatchHook(PluginHooks.LANGUAGE_CHANGE, {
code: newLanguage,
newLanguage,
});
}),

View file

@ -67,6 +67,8 @@
<iframe
#iframe
[src]="iframeSrc()"
data-plugin-iframe
[attr.data-plugin-id]="pluginId()"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
class="plugin-iframe"
[class.hidden]="isLoading()"