feat(plugins): add i18n API methods and fix LANGUAGE_CHANGE hook

Phase 4-5: Plugin API Extensions and Language Switching

- Add translate(), formatDate(), getCurrentLanguage() to PluginAPI
- Inject PluginI18nService into PluginAPI constructor
- Update PluginRunner to pass PluginI18nService to PluginAPI
- Fix LANGUAGE_CHANGE hook bug (was firing on work context changes)
- Add proper languageChange$ effect listening to global config updates
- Wire language changes to PluginI18nService.setCurrentLanguage()
- Remove incorrect workContextChange$ effect dispatch

Translation API features:
- Simple translate(key, params?) with fallback chain
- Locale-aware formatDate(date, format) with predefined formats
- getCurrentLanguage() to get current app language

Language switching:
- Listens to updateGlobalConfigSection for 'localization' section
- Uses distinctUntilChanged to fire only on actual language changes
- Updates plugin i18n service and dispatches hook to plugins
This commit is contained in:
Johannes Millan 2026-01-16 17:45:18 +01:00
parent cde660bd0c
commit c742295624
3 changed files with 65 additions and 8 deletions

View file

@ -23,6 +23,8 @@ import {
} from '@super-productivity/plugin-api';
import { PluginBridgeService } from './plugin-bridge.service';
import { PluginLog } from '../core/log';
import { PluginI18nService } from './plugin-i18n.service';
import { formatDateForPlugin } from './plugin-i18n-date.util';
import {
projectCopyToProjectData,
projectDataToPartialProjectCopy,
@ -62,6 +64,7 @@ export class PluginAPI implements PluginAPIInterface {
public cfg: PluginBaseCfg,
private _pluginId: string,
private _pluginBridge: PluginBridgeService,
private _pluginI18nService: PluginI18nService,
private _manifest?: PluginManifest,
) {
// Get bound methods for this plugin
@ -476,6 +479,33 @@ export class PluginAPI implements PluginAPIInterface {
return this._pluginBridge.setSimpleCounterDate(id, date, value);
}
/**
* Translate a key using plugin's translation files
* Falls back to English, then to the key itself if not found
*/
translate(key: string, params?: Record<string, string | number>): string {
return this._pluginI18nService.translate(this._pluginId, key, params);
}
/**
* Format a date according to predefined format and current locale
* Supports: 'short', 'medium', 'long', 'time', 'datetime'
*/
formatDate(
date: Date | string | number,
format: 'short' | 'medium' | 'long' | 'time' | 'datetime',
): string {
const locale = this._pluginI18nService.getCurrentLanguage();
return formatDateForPlugin(date, format, locale);
}
/**
* Get the current app language code
*/
getCurrentLanguage(): string {
return this._pluginI18nService.getCurrentLanguage();
}
/**
* Clean up all resources associated with this plugin API instance
* Called when the plugin is being unloaded

View file

@ -1,7 +1,15 @@
import { inject, Injectable } from '@angular/core';
import { createEffect, ofType } from '@ngrx/effects';
import { select, Store } from '@ngrx/store';
import { filter, map, switchMap, take, tap, withLatestFrom } from 'rxjs/operators';
import {
distinctUntilChanged,
filter,
map,
switchMap,
take,
tap,
withLatestFrom,
} from 'rxjs/operators';
import { EMPTY } from 'rxjs';
import {
@ -9,10 +17,12 @@ import {
selectTaskFeatureState,
} from '../features/tasks/store/task.selectors';
import { selectProjectFeatureState } from '../features/project/store/project.selectors';
import { selectLocalizationConfig } from '../features/config/store/global-config.reducer';
import { updateGlobalConfigSection } from '../features/config/store/global-config.actions';
import { Task } from '../features/tasks/task.model';
import { PluginService } from './plugin.service';
import { PluginHooks } from './plugin-api.model';
import { setActiveWorkContext } from '../features/work-context/store/work-context.actions';
import { PluginI18nService } from './plugin-i18n.service';
import { TaskSharedActions } from '../root-store/meta/task-shared.actions';
import {
setCurrentTask,
@ -41,6 +51,7 @@ export class PluginHooksEffects {
private readonly actions$ = inject(LOCAL_ACTIONS);
private readonly store = inject(Store);
private readonly pluginService = inject(PluginService);
private readonly pluginI18nService = inject(PluginI18nService);
taskComplete$ = createEffect(
() =>
@ -191,14 +202,22 @@ export class PluginHooksEffects {
{ dispatch: false },
);
workContextChange$ = createEffect(
// Language change effect - listens to actual language config changes
languageChange$ = createEffect(
() =>
this.actions$.pipe(
ofType(setActiveWorkContext),
tap((action) => {
ofType(updateGlobalConfigSection),
filter((action) => action.sectionKey === 'localization'),
withLatestFrom(this.store.pipe(select(selectLocalizationConfig))),
map(([_, localizationConfig]) => localizationConfig.lng),
distinctUntilChanged(),
tap((newLanguage) => {
// Update plugin i18n service with new language
this.pluginI18nService.setCurrentLanguage(newLanguage);
// Dispatch hook to notify plugins
this.pluginService.dispatchHook(PluginHooks.LANGUAGE_CHANGE, {
activeId: action.activeId,
activeType: action.activeType,
newLanguage,
});
}),
),

View file

@ -3,6 +3,7 @@ import { PluginManifest, PluginBaseCfg, PluginInstance } from './plugin-api.mode
import { PluginAPI } from './plugin-api';
import { PluginBridgeService } from './plugin-bridge.service';
import { PluginSecurityService } from './plugin-security';
import { PluginI18nService } from './plugin-i18n.service';
import { SnackService } from '../core/snack/snack.service';
import { PluginCleanupService } from './plugin-cleanup.service';
import { PluginLog } from '../core/log';
@ -17,6 +18,7 @@ import { PluginLog } from '../core/log';
export class PluginRunner {
private _pluginBridge = inject(PluginBridgeService);
private _securityService = inject(PluginSecurityService);
private _pluginI18nService = inject(PluginI18nService);
private _snackService = inject(SnackService);
private _cleanupService = inject(PluginCleanupService);
@ -34,7 +36,13 @@ export class PluginRunner {
): Promise<PluginInstance> {
try {
// Create plugin API
const pluginAPI = new PluginAPI(baseCfg, manifest.id, this._pluginBridge, manifest);
const pluginAPI = new PluginAPI(
baseCfg,
manifest.id,
this._pluginBridge,
this._pluginI18nService,
manifest,
);
// executeNodeScript is now automatically bound if permitted via createBoundMethods