feat: add i18n API, render hooks, and new intercept hooks to plugin system
This commit is contained in:
+27
-2
@@ -14,6 +14,7 @@ import type {
|
|||||||
AdminPageSection,
|
AdminPageSection,
|
||||||
CalendarEventAction,
|
CalendarEventAction,
|
||||||
SlotName,
|
SlotName,
|
||||||
|
PluginI18n,
|
||||||
} from './plugin-types';
|
} from './plugin-types';
|
||||||
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
|
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
|
||||||
import {
|
import {
|
||||||
@@ -22,8 +23,9 @@ import {
|
|||||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
||||||
sidebarAppHooks, avatarHooks,
|
sidebarAppHooks, avatarHooks, renderHooks,
|
||||||
} from './plugin-hooks';
|
} from './plugin-hooks';
|
||||||
|
import { createPluginI18n } from './plugin-i18n';
|
||||||
import { toast as appToast } from '@/stores/toast-store';
|
import { toast as appToast } from '@/stores/toast-store';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
@@ -109,6 +111,8 @@ function createPluginLogger(pluginId: string) {
|
|||||||
|
|
||||||
export interface PluginAPI {
|
export interface PluginAPI {
|
||||||
plugin: { id: string; version: string; settings: Record<string, unknown> };
|
plugin: { id: string; version: string; settings: Record<string, unknown> };
|
||||||
|
/** Localisation API — register translations and call t() to get strings */
|
||||||
|
i18n: PluginI18n;
|
||||||
ui: {
|
ui: {
|
||||||
registerToolbarAction: (action: ToolbarAction) => Disposable;
|
registerToolbarAction: (action: ToolbarAction) => Disposable;
|
||||||
registerEmailBanner: (factory: BannerFactory) => Disposable;
|
registerEmailBanner: (factory: BannerFactory) => Disposable;
|
||||||
@@ -149,6 +153,8 @@ export interface PluginHooksAPI {
|
|||||||
onEmailClose: (handler: () => void) => Disposable;
|
onEmailClose: (handler: () => void) => Disposable;
|
||||||
onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onThreadExpand: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onThreadExpand: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
|
/** Intercept — receives ComposeOptions, may mutate fields, return false to cancel */
|
||||||
|
onBeforeCompose: (handler: (options: import('./plugin-types').ComposeOptions) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||||
onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -157,6 +163,10 @@ export interface PluginHooksAPI {
|
|||||||
onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onAfterEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onAfterEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
|
/** Emitted after emails are moved to the Archive mailbox */
|
||||||
|
onEmailArchive: (handler: (emailIds: string[]) => void) => Disposable;
|
||||||
|
/** Emitted after emails are moved out of the Archive mailbox */
|
||||||
|
onEmailUnarchive: (handler: (emailIds: string[]) => void) => Disposable;
|
||||||
onEmailReadStateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onEmailReadStateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -173,6 +183,8 @@ export interface PluginHooksAPI {
|
|||||||
onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
|
/** Intercept — receives MailtoContext, return false to prevent the system mail client */
|
||||||
|
onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||||
// Calendar
|
// Calendar
|
||||||
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -215,6 +227,8 @@ export interface PluginHooksAPI {
|
|||||||
onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onAfterFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onAfterFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
|
/** Intercept — receives { file: FileResourceView, newName: string }, return false to cancel */
|
||||||
|
onBeforeFileRename: (handler: (ctx: { file: import('./plugin-types').FileResourceView; newName: string }) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||||
onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
@@ -316,6 +330,9 @@ export interface PluginHooksAPI {
|
|||||||
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
// Avatar
|
// Avatar
|
||||||
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
|
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
|
// Render — transform hook for email list row badges
|
||||||
|
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
|
||||||
|
onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Permission mapping for hooks ----------------------------
|
// --- Permission mapping for hooks ----------------------------
|
||||||
@@ -324,14 +341,17 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
|||||||
// Email
|
// Email
|
||||||
onEmailOpen: 'email:read', onEmailClose: 'email:read',
|
onEmailOpen: 'email:read', onEmailClose: 'email:read',
|
||||||
onEmailContentRender: 'email:read', onThreadExpand: 'email:read',
|
onEmailContentRender: 'email:read', onThreadExpand: 'email:read',
|
||||||
onComposerOpen: 'email:read', onDraftAutoSave: 'email:read',
|
onBeforeCompose: 'email:read', onComposerOpen: 'email:read',
|
||||||
|
onDraftAutoSave: 'email:read',
|
||||||
onMailboxChange: 'email:read', onMailboxesRefresh: 'email:read',
|
onMailboxChange: 'email:read', onMailboxesRefresh: 'email:read',
|
||||||
onSearch: 'email:read', onSearchResults: 'email:read',
|
onSearch: 'email:read', onSearchResults: 'email:read',
|
||||||
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
|
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
|
||||||
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
|
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
|
||||||
|
onMailtoIntercept: 'email:read', onEmailListItemRender: 'email:read',
|
||||||
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
|
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
|
||||||
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
|
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
|
||||||
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
|
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
|
||||||
|
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
|
||||||
onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write',
|
onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write',
|
||||||
onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write',
|
onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write',
|
||||||
onMailboxCreate: 'email:write', onMailboxRename: 'email:write',
|
onMailboxCreate: 'email:write', onMailboxRename: 'email:write',
|
||||||
@@ -358,6 +378,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
|||||||
onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write',
|
onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write',
|
||||||
onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write',
|
onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write',
|
||||||
onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write',
|
onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write',
|
||||||
|
onBeforeFileRename: 'files:write',
|
||||||
onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write',
|
onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write',
|
||||||
onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write',
|
onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write',
|
||||||
// Auth
|
// Auth
|
||||||
@@ -469,6 +490,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
|
|||||||
...Object.fromEntries(Object.entries(sidebarAppHooks)),
|
...Object.fromEntries(Object.entries(sidebarAppHooks)),
|
||||||
// Avatar
|
// Avatar
|
||||||
...Object.fromEntries(Object.entries(avatarHooks)),
|
...Object.fromEntries(Object.entries(avatarHooks)),
|
||||||
|
// Render
|
||||||
|
...Object.fromEntries(Object.entries(renderHooks)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Slot registration bridge --------------------------------
|
// --- Slot registration bridge --------------------------------
|
||||||
@@ -530,6 +553,8 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
|||||||
settings: { ...plugin.settings },
|
settings: { ...plugin.settings },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
i18n: createPluginI18n(plugin.id),
|
||||||
|
|
||||||
ui: {
|
ui: {
|
||||||
registerToolbarAction: (action: ToolbarAction) => {
|
registerToolbarAction: (action: ToolbarAction) => {
|
||||||
requirePermission(plugin, 'ui:toolbar');
|
requirePermission(plugin, 'ui:toolbar');
|
||||||
|
|||||||
+26
-1
@@ -172,6 +172,10 @@ export const emailHooks = {
|
|||||||
onEmailClose: new HookBus(),
|
onEmailClose: new HookBus(),
|
||||||
onEmailContentRender: new HookBus(),
|
onEmailContentRender: new HookBus(),
|
||||||
onThreadExpand: new HookBus(),
|
onThreadExpand: new HookBus(),
|
||||||
|
// Intercept hook — fires before the composer opens.
|
||||||
|
// Handlers receive ComposeOptions and may mutate fields in place.
|
||||||
|
// Return false to cancel opening the composer.
|
||||||
|
onBeforeCompose: new HookBus(),
|
||||||
onComposerOpen: new HookBus(),
|
onComposerOpen: new HookBus(),
|
||||||
onBeforeEmailSend: new HookBus(),
|
onBeforeEmailSend: new HookBus(),
|
||||||
onAfterEmailSend: new HookBus(),
|
onAfterEmailSend: new HookBus(),
|
||||||
@@ -180,6 +184,10 @@ export const emailHooks = {
|
|||||||
onAfterEmailDelete: new HookBus(),
|
onAfterEmailDelete: new HookBus(),
|
||||||
onBeforeEmailMove: new HookBus(),
|
onBeforeEmailMove: new HookBus(),
|
||||||
onAfterEmailMove: new HookBus(),
|
onAfterEmailMove: new HookBus(),
|
||||||
|
// Fired after one or more emails are archived to the Archive mailbox
|
||||||
|
onEmailArchive: new HookBus(),
|
||||||
|
// Fired after one or more emails are moved out of the Archive mailbox
|
||||||
|
onEmailUnarchive: new HookBus(),
|
||||||
onEmailReadStateChange: new HookBus(),
|
onEmailReadStateChange: new HookBus(),
|
||||||
onEmailStarToggle: new HookBus(),
|
onEmailStarToggle: new HookBus(),
|
||||||
onEmailSpamToggle: new HookBus(),
|
onEmailSpamToggle: new HookBus(),
|
||||||
@@ -196,6 +204,9 @@ export const emailHooks = {
|
|||||||
onNewEmailReceived: new HookBus(),
|
onNewEmailReceived: new HookBus(),
|
||||||
onPushConnectionChange: new HookBus(),
|
onPushConnectionChange: new HookBus(),
|
||||||
onQuotaChange: new HookBus(),
|
onQuotaChange: new HookBus(),
|
||||||
|
// Intercept hook — fired when a mailto: link is clicked.
|
||||||
|
// Return false to prevent the browser from opening the system mail client.
|
||||||
|
onMailtoIntercept: new HookBus(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// §7.2 Calendar Hooks
|
// §7.2 Calendar Hooks
|
||||||
@@ -250,6 +261,10 @@ export const fileHooks = {
|
|||||||
onDirectoryCreate: new HookBus(),
|
onDirectoryCreate: new HookBus(),
|
||||||
onBeforeFileDelete: new HookBus(),
|
onBeforeFileDelete: new HookBus(),
|
||||||
onAfterFileDelete: new HookBus(),
|
onAfterFileDelete: new HookBus(),
|
||||||
|
// Intercept hook — fires before a file is renamed.
|
||||||
|
// Receives { file: FileResourceView, newName: string }.
|
||||||
|
// Return false to cancel the rename.
|
||||||
|
onBeforeFileRename: new HookBus(),
|
||||||
onFileRename: new HookBus(),
|
onFileRename: new HookBus(),
|
||||||
onFileMove: new HookBus(),
|
onFileMove: new HookBus(),
|
||||||
onFileCopy: new HookBus(),
|
onFileCopy: new HookBus(),
|
||||||
@@ -406,6 +421,16 @@ export const avatarHooks = {
|
|||||||
onAvatarResolve: new HookBus(),
|
onAvatarResolve: new HookBus(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// §7.22 Render Hooks
|
||||||
|
export const renderHooks = {
|
||||||
|
// Transform hook — runs for each visible email list row.
|
||||||
|
// Initial value: EmailListBadge[] (always starts as [])
|
||||||
|
// Second argument: { emailId: string; email: EmailReadView }
|
||||||
|
// Handlers return a new (or extended) badges array.
|
||||||
|
// Rendered by the email list row component next to the subject line.
|
||||||
|
onEmailListItemRender: new HookBus(),
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Aggregate: remove all handlers for a plugin across all buses ───
|
// ─── Aggregate: remove all handlers for a plugin across all buses ───
|
||||||
|
|
||||||
const allHookGroups = [
|
const allHookGroups = [
|
||||||
@@ -414,7 +439,7 @@ const allHookGroups = [
|
|||||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
|
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
|
||||||
avatarHooks,
|
avatarHooks, renderHooks,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function removeAllPluginHooks(pluginId: string): void {
|
export function removeAllPluginHooks(pluginId: string): void {
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Plugin i18n registry — manages per-plugin translation tables
|
||||||
|
//
|
||||||
|
// Each plugin gets its own namespace keyed by:
|
||||||
|
// pluginId → locale → { messageKey → translated string }
|
||||||
|
//
|
||||||
|
// Resolution order when calling t(key):
|
||||||
|
// 1. Exact locale match ("fr-CA")
|
||||||
|
// 2. Language-prefix match ("fr" from "fr-CA")
|
||||||
|
// 3. English fallback ("en")
|
||||||
|
// 4. Raw key (plugin is never broken by missing strings)
|
||||||
|
//
|
||||||
|
// Interpolation uses {paramName} placeholders.
|
||||||
|
|
||||||
|
// ─── Registry ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** pluginId → locale → key → translated string */
|
||||||
|
const registry = new Map<string, Map<string, Record<string, string>>>();
|
||||||
|
|
||||||
|
let currentLocale = 'en';
|
||||||
|
|
||||||
|
// ─── Locale sync (called by plugin-loader) ───────────────────
|
||||||
|
|
||||||
|
/** Keep the registry in sync with the app locale */
|
||||||
|
export function setPluginI18nLocale(locale: string): void {
|
||||||
|
currentLocale = locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPluginI18nLocale(): string {
|
||||||
|
return currentLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cleanup ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Remove all translations for a plugin (called on deactivation) */
|
||||||
|
export function clearPluginI18nTranslations(pluginId: string): void {
|
||||||
|
registry.delete(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function interpolate(template: string, params?: Record<string, string | number>): string {
|
||||||
|
if (!params) return template;
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolve(pluginId: string, key: string): string | undefined {
|
||||||
|
const byLocale = registry.get(pluginId);
|
||||||
|
if (!byLocale) return undefined;
|
||||||
|
|
||||||
|
// 1. Exact locale (e.g. "fr-CA")
|
||||||
|
const exact = byLocale.get(currentLocale)?.[key];
|
||||||
|
if (exact !== undefined) return exact;
|
||||||
|
|
||||||
|
// 2. Language prefix (e.g. "fr" from "fr-CA")
|
||||||
|
const lang = currentLocale.split('-')[0];
|
||||||
|
if (lang !== currentLocale) {
|
||||||
|
const langMatch = byLocale.get(lang)?.[key];
|
||||||
|
if (langMatch !== undefined) return langMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. English fallback
|
||||||
|
return byLocale.get('en')?.[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API factory ──────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the i18n API object exposed as `api.i18n` inside each plugin.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // In your plugin activate():
|
||||||
|
* api.i18n.addTranslations('en', { 'banner.title': 'Hello' });
|
||||||
|
* api.i18n.addTranslations('de', { 'banner.title': 'Hallo' });
|
||||||
|
*
|
||||||
|
* // Later, in any React component the plugin renders:
|
||||||
|
* const title = api.i18n.t('banner.title');
|
||||||
|
* const greeting = api.i18n.t('welcome', { name: 'Alice' }); // 'Hello, {name}!'
|
||||||
|
*/
|
||||||
|
export function createPluginI18n(pluginId: string) {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Register translations for one locale.
|
||||||
|
* Multiple calls for the same locale are merged (last-write-wins on key collision).
|
||||||
|
*
|
||||||
|
* @param locale BCP-47 locale tag, e.g. "en", "de", "fr-CA"
|
||||||
|
* @param strings Key → translated string map. Use {paramName} for interpolation.
|
||||||
|
*/
|
||||||
|
addTranslations(locale: string, strings: Record<string, string>): void {
|
||||||
|
let byLocale = registry.get(pluginId);
|
||||||
|
if (!byLocale) {
|
||||||
|
byLocale = new Map<string, Record<string, string>>();
|
||||||
|
registry.set(pluginId, byLocale);
|
||||||
|
}
|
||||||
|
const existing = byLocale.get(locale) ?? {};
|
||||||
|
byLocale.set(locale, { ...existing, ...strings });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate a key using the current app locale.
|
||||||
|
* Falls back through: exact locale → language prefix → 'en' → raw key.
|
||||||
|
*
|
||||||
|
* @param key Translation key, e.g. `'banner.title'`
|
||||||
|
* @param params Optional interpolation values, e.g. `{ count: 3 }`
|
||||||
|
*/
|
||||||
|
t(key: string, params?: Record<string, string | number>): string {
|
||||||
|
const template = resolve(pluginId, key);
|
||||||
|
if (template !== undefined) return interpolate(template, params);
|
||||||
|
return key; // never throw — just return the key
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The current app locale (e.g. "en", "de", "fr") */
|
||||||
|
getLocale(): string {
|
||||||
|
return currentLocale;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginI18nInstance = ReturnType<typeof createPluginI18n>;
|
||||||
+25
-1
@@ -1,15 +1,18 @@
|
|||||||
// Plugin Loader — loads and activates plugins via blob URL dynamic import
|
// Plugin Loader — loads and activates plugins via blob URL dynamic import
|
||||||
|
|
||||||
import type { InstalledPlugin, Disposable } from './plugin-types';
|
import type { InstalledPlugin, Disposable } from './plugin-types';
|
||||||
import { pluginStorage } from './plugin-storage';
|
import { pluginStorage } from './plugin-storage';
|
||||||
import { createPluginAPI, type PluginAPI } from './plugin-api';
|
import { createPluginAPI, type PluginAPI } from './plugin-api';
|
||||||
import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks';
|
import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks';
|
||||||
|
import { setPluginI18nLocale, clearPluginI18nTranslations } from './plugin-i18n';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import * as ReactJSX from 'react/jsx-runtime';
|
import * as ReactJSX from 'react/jsx-runtime';
|
||||||
|
|
||||||
// --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
|
// --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
|
||||||
|
|
||||||
|
let localeSyncInitialised = false;
|
||||||
|
|
||||||
export function exposePluginExternals(): void {
|
export function exposePluginExternals(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -18,6 +21,16 @@ export function exposePluginExternals(): void {
|
|||||||
ReactDOM,
|
ReactDOM,
|
||||||
ReactJSX,
|
ReactJSX,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sync plugin i18n with the app locale (runs once per page load)
|
||||||
|
if (!localeSyncInitialised) {
|
||||||
|
localeSyncInitialised = true;
|
||||||
|
// Dynamic import avoids a circular dependency chain at module evaluation time
|
||||||
|
import('@/stores/locale-store').then(({ useLocaleStore }) => {
|
||||||
|
setPluginI18nLocale(useLocaleStore.getState().locale);
|
||||||
|
useLocaleStore.subscribe((state) => setPluginI18nLocale(state.locale));
|
||||||
|
}).catch(() => {/* locale sync is best-effort */});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Active plugin tracking ----------------------------------
|
// --- Active plugin tracking ----------------------------------
|
||||||
@@ -78,6 +91,14 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
|
|||||||
// 4. Build sandboxed API
|
// 4. Build sandboxed API
|
||||||
const api = createPluginAPI(plugin);
|
const api = createPluginAPI(plugin);
|
||||||
|
|
||||||
|
// 4b. Auto-register translations bundled in the manifest (plugin.locales)
|
||||||
|
// Plugins may still call api.i18n.addTranslations() in activate() to add more.
|
||||||
|
if (plugin.locales) {
|
||||||
|
for (const [locale, strings] of Object.entries(plugin.locales)) {
|
||||||
|
api.i18n.addTranslations(locale, strings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 5. Call activate
|
// 5. Call activate
|
||||||
const disposable = await mod.activate(api);
|
const disposable = await mod.activate(api);
|
||||||
|
|
||||||
@@ -119,6 +140,9 @@ export function deactivatePlugin(pluginId: string): void {
|
|||||||
// Remove all hook subscriptions for this plugin
|
// Remove all hook subscriptions for this plugin
|
||||||
removeAllPluginHooks(pluginId);
|
removeAllPluginHooks(pluginId);
|
||||||
|
|
||||||
|
// Clear cached translations (avoids memory leak on repeated enable/disable cycles)
|
||||||
|
clearPluginI18nTranslations(pluginId);
|
||||||
|
|
||||||
// Reset error tracker
|
// Reset error tracker
|
||||||
pluginErrorTracker.reset(pluginId);
|
pluginErrorTracker.reset(pluginId);
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ export interface PluginManifest {
|
|||||||
entrypoint: string;
|
entrypoint: string;
|
||||||
minAppVersion?: string;
|
minAppVersion?: string;
|
||||||
settingsSchema?: Record<string, SettingFieldSchema>;
|
settingsSchema?: Record<string, SettingFieldSchema>;
|
||||||
|
/**
|
||||||
|
* Bundled translations shipped inside the plugin ZIP.
|
||||||
|
* Keyed by BCP-47 locale tag ("en", "de", "fr-CA", …).
|
||||||
|
* The loader auto-registers these before calling activate(),
|
||||||
|
* so plugins can use api.i18n.t() without calling addTranslations() first.
|
||||||
|
*/
|
||||||
|
locales?: Record<string, Record<string, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingFieldSchema {
|
export interface SettingFieldSchema {
|
||||||
@@ -83,6 +90,8 @@ export interface InstalledPlugin {
|
|||||||
adminApproved?: boolean;
|
adminApproved?: boolean;
|
||||||
settingsSchema?: Record<string, SettingFieldSchema>;
|
settingsSchema?: Record<string, SettingFieldSchema>;
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
|
/** Bundled translations, carried over from the manifest on install. */
|
||||||
|
locales?: Record<string, Record<string, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── UI Slots ────────────────────────────────────────────────
|
// ─── UI Slots ────────────────────────────────────────────────
|
||||||
@@ -388,6 +397,86 @@ export interface ComposerContext {
|
|||||||
originalSubject?: string;
|
originalSubject?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── New hook context types ──────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passed to onBeforeCompose handlers.
|
||||||
|
* Handlers may mutate the object in place to pre-fill fields; returning false cancels the compose.
|
||||||
|
*/
|
||||||
|
export interface ComposeOptions {
|
||||||
|
to: string[];
|
||||||
|
cc: string[];
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
mode: 'new' | 'reply' | 'reply-all' | 'forward';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small visual indicator injected into an email list row via onEmailListItemRender.
|
||||||
|
*/
|
||||||
|
export interface EmailListBadge {
|
||||||
|
/** Stable unique key within the plugin — used as React key */
|
||||||
|
key: string;
|
||||||
|
/** Short label text displayed in the badge */
|
||||||
|
label: string;
|
||||||
|
/** CSS color value for the badge background, e.g. "#e74c3c" or "var(--color-warning)" */
|
||||||
|
color?: string;
|
||||||
|
/** Tooltip / aria-label */
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passed to onMailtoIntercept handlers.
|
||||||
|
* Return false to prevent the browser from opening the system mail client.
|
||||||
|
*/
|
||||||
|
export interface MailtoContext {
|
||||||
|
/** The raw href, e.g. "mailto:alice@example.com?subject=Hello" */
|
||||||
|
href: string;
|
||||||
|
/** Parsed list of recipient addresses */
|
||||||
|
to: string[];
|
||||||
|
subject?: string;
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Plugin i18n API ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localisation API exposed as `api.i18n` inside every plugin.
|
||||||
|
*
|
||||||
|
* Plugins ship their own translation tables; the app locale is tracked
|
||||||
|
* automatically so `t()` always returns the right string without any
|
||||||
|
* extra setup from the plugin side.
|
||||||
|
*/
|
||||||
|
export interface PluginI18n {
|
||||||
|
/**
|
||||||
|
* Register translations for one locale.
|
||||||
|
* Multiple calls for the same locale are merged (last-write-wins per key).
|
||||||
|
*
|
||||||
|
* @param locale BCP-47 tag, e.g. "en", "de", "fr-CA"
|
||||||
|
* @param strings Key → translated string map. Use {paramName} for interpolation.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* api.i18n.addTranslations('en', { 'banner.title': 'Tracking blocked' });
|
||||||
|
* api.i18n.addTranslations('de', { 'banner.title': 'Tracking blockiert' });
|
||||||
|
*/
|
||||||
|
addTranslations(locale: string, strings: Record<string, string>): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the translated string for `key` using the current app locale,
|
||||||
|
* with optional {param} interpolation.
|
||||||
|
*
|
||||||
|
* Falls back: exact locale → language prefix → "en" → raw key.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* api.i18n.t('banner.title')
|
||||||
|
* api.i18n.t('items_found', { count: 3 }) // 'Found {count} items' → 'Found 3 items'
|
||||||
|
*/
|
||||||
|
t(key: string, params?: Record<string, string | number>): string;
|
||||||
|
|
||||||
|
/** The current app locale string (e.g. "en", "de", "fr") */
|
||||||
|
getLocale(): string;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Permission Reference ────────────────────────────────────
|
// ─── Permission Reference ────────────────────────────────────
|
||||||
|
|
||||||
export const ALL_PERMISSIONS = [
|
export const ALL_PERMISSIONS = [
|
||||||
|
|||||||
Reference in New Issue
Block a user