import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, Observable, forkJoin, of } from 'rxjs'; import { catchError, map, switchMap, tap } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; import { ConfigService } from './config.service'; import { AuthService } from './auth.service'; export interface CommandParam { key: string; label: string; labelKey?: string; type: 'text' | 'number' | 'select' | 'checkbox'; options?: string[]; default?: any; required?: boolean; } export interface PluginCommand { slug: string; translationKey?: string; labelKey?: string; label: string; icon: string; disabled: boolean; endpoint?: string; os?: string[]; // Ej: ['windows'], ['linux'] params?: CommandParam[]; confirm?: string; confirmKey?: string; roles?: string[]; // Roles autorizados por defecto (ej. ['ROLE_SUPER_ADMIN', 'ROLE_ORGANIZATIONAL_UNIT_ADMIN']) } export interface RoleDefinition { key: string; // Clave del rol Symfony (ej: 'ROLE_SUPER_ADMIN') name: string; // Nombre descriptivo (ej: 'Super Admin') shortCode?: string;// Código corto usado en frontend (ej: 'super-admin') } export interface PluginBadgeRule { status: string; type: string; icon: string; label: string; labelKey?: string; tooltip: string; tooltipKey?: string; } export interface PluginBadge { id: string; // Plugin name type: string; icon: string; label: string; tooltip: string; } export interface FieldAction { id: string; label: string; icon?: string; endpoint: string; } export interface SchemaField { key: string; label: string; labelKey?: string; description?: string; descriptionKey?: string; type: 'text' | 'number' | 'select' | 'checkbox' | 'textarea'; options?: string[]; default: any; action?: FieldAction; } export interface RecordColumn { key: string; label: string; labelKey?: string; } export interface PluginManifest { name: string; title: string; titleKey?: string; version: string; description: string; descriptionKey?: string; author: string; icon: string; enabled: boolean; commands: PluginCommand[]; statusEndpoint?: string; recordsEndpoint?: string; recordsColumns?: RecordColumn[]; badges?: PluginBadgeRule[]; i18n?: Record>; schema: SchemaField[]; defaultConfig?: Record; config?: Record; } export interface PluginGroup { plugin: PluginManifest; commands: PluginCommand[]; } @Injectable({ providedIn: 'root' }) export class PluginService { private http = inject(HttpClient); private configService = inject(ConfigService); private translateService = inject(TranslateService); private authService = inject(AuthService); private allPlugins: PluginManifest[] = []; private activePlugins: PluginManifest[] = []; private pluginsSubject = new BehaviorSubject([]); public plugins$ = this.pluginsSubject.asObservable(); // Dynamic state cache: key is `${pluginName}:${clientIdOrUuid}` -> status string private clientPluginStatusMap = new Map(); // Dynamic metadata cache: key is `${pluginName}:${clientIdOrUuid}` -> extra attributes (e.g. dni, remaining_minutes) private clientPluginMetadataMap = new Map(); private mercureEventSource: EventSource | null = null; constructor() { this.refreshPlugins().subscribe(); this.initMercureSubscription(); // Re-aplicar traducciones dinámicas al cambiar de idioma this.translateService.onLangChange.subscribe((event) => { this.loadPluginTranslations(event.lang); }); } private getStatusKey(pluginName: string, idOrUuid: string | number): string { return `${pluginName}:${idOrUuid}`; } private getPartitionStatusKey(pluginName: string, idOrUuid: string | number, disk: number, partition: number): string { return `${pluginName}:${idOrUuid}:${disk}:${partition}`; } public setPluginStatus(pluginName: string, idOrUuid: string | number, status: string): void { this.clientPluginStatusMap.set(this.getStatusKey(pluginName, idOrUuid), status); } public setPartitionPluginStatus(pluginName: string, idOrUuid: string | number, disk: number, partition: number, status: string): void { this.clientPluginStatusMap.set(this.getPartitionStatusKey(pluginName, idOrUuid, disk, partition), status); } public getPluginStatus(pluginName: string, idOrUuid: string | number): string | undefined { return this.clientPluginStatusMap.get(this.getStatusKey(pluginName, idOrUuid)); } public getPartitionPluginStatus(pluginName: string, idOrUuid: string | number, disk: number, partition: number): string | undefined { return this.clientPluginStatusMap.get(this.getPartitionStatusKey(pluginName, idOrUuid, disk, partition)); } public setPluginMetadata(pluginName: string, idOrUuid: string | number, metadata: any): void { const key = this.getStatusKey(pluginName, idOrUuid); const existing = this.clientPluginMetadataMap.get(key) || {}; this.clientPluginMetadataMap.set(key, { ...existing, ...metadata }); } public getPluginMetadata(pluginName: string, idOrUuid: string | number): any { return this.clientPluginMetadataMap.get(this.getStatusKey(pluginName, idOrUuid)); } public clearPluginMetadata(pluginName: string, idOrUuid: string | number): void { this.clientPluginMetadataMap.delete(this.getStatusKey(pluginName, idOrUuid)); } private initMercureSubscription(): void { const mercureUrl = this.configService.mercureUrl; if (!mercureUrl || this.mercureEventSource) { return; } try { this.mercureEventSource = new EventSource(`${mercureUrl}?topic=` + encodeURIComponent('plugins')); this.mercureEventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data && data.plugin && data.status) { const disk = data.disk ?? data.disk_number ?? data.diskNumber; const part = data.partition ?? data.partition_number ?? data.partitionNumber; const clearEntriesForPrefix = (prefix: string) => { for (const key of Array.from(this.clientPluginStatusMap.keys())) { if (key.startsWith(prefix)) { this.clientPluginStatusMap.delete(key); } } for (const key of Array.from(this.clientPluginMetadataMap.keys())) { if (key.startsWith(prefix)) { this.clientPluginMetadataMap.delete(key); } } }; if (data.status === 'NONE') { if (data.clientId !== undefined && data.clientId !== null) { const idStr = String(data.clientId); this.clientPluginMetadataMap.delete(this.getStatusKey(data.plugin, idStr)); if (disk !== undefined && disk !== null && part !== undefined && part !== null) { this.clientPluginStatusMap.delete(this.getPartitionStatusKey(data.plugin, idStr, Number(disk), Number(part))); const curGlobal = this.clientPluginStatusMap.get(this.getStatusKey(data.plugin, idStr)); if (curGlobal && ['REMOVING', 'CHECKING', 'FREEZING', 'UNFREEZING'].includes(curGlobal.toUpperCase())) { this.clientPluginStatusMap.delete(this.getStatusKey(data.plugin, idStr)); } } else if (disk !== undefined && disk !== null) { clearEntriesForPrefix(`${data.plugin}:${idStr}:${disk}:`); } else { this.clientPluginStatusMap.delete(this.getStatusKey(data.plugin, idStr)); clearEntriesForPrefix(`${data.plugin}:${idStr}:`); } } if (data.clientUuid) { const uuidStr = data.clientUuid; this.clientPluginMetadataMap.delete(this.getStatusKey(data.plugin, uuidStr)); if (disk !== undefined && disk !== null && part !== undefined && part !== null) { this.clientPluginStatusMap.delete(this.getPartitionStatusKey(data.plugin, uuidStr, Number(disk), Number(part))); const curGlobal = this.clientPluginStatusMap.get(this.getStatusKey(data.plugin, uuidStr)); if (curGlobal && ['REMOVING', 'CHECKING', 'FREEZING', 'UNFREEZING'].includes(curGlobal.toUpperCase())) { this.clientPluginStatusMap.delete(this.getStatusKey(data.plugin, uuidStr)); } } else if (disk !== undefined && disk !== null) { clearEntriesForPrefix(`${data.plugin}:${uuidStr}:${disk}:`); } else { this.clientPluginStatusMap.delete(this.getStatusKey(data.plugin, uuidStr)); clearEntriesForPrefix(`${data.plugin}:${uuidStr}:`); } } } else { // Extraer metadatos contextuales (p. ej. dni, remainingMinutes, remaining_minutes) const metadata: any = {}; for (const [k, v] of Object.entries(data)) { if (!['plugin', 'status', 'clientId', 'clientUuid', 'disk', 'disk_number', 'diskNumber', 'partition', 'partition_number', 'partitionNumber', 'action', 'timestamp'].includes(k) && v !== null && v !== undefined) { metadata[k] = v; } } const hasMeta = Object.keys(metadata).length > 0; if (data.clientId !== undefined && data.clientId !== null) { const idStr = String(data.clientId); this.setPluginStatus(data.plugin, idStr, data.status); if (hasMeta && data.status !== 'FREE') { this.setPluginMetadata(data.plugin, idStr, metadata); } else if (data.status === 'FREE') { this.clearPluginMetadata(data.plugin, idStr); } if (disk !== undefined && disk !== null && part !== undefined && part !== null) { this.setPartitionPluginStatus(data.plugin, idStr, Number(disk), Number(part), data.status); } } if (data.clientUuid) { this.setPluginStatus(data.plugin, data.clientUuid, data.status); if (hasMeta && data.status !== 'FREE') { this.setPluginMetadata(data.plugin, data.clientUuid, metadata); } else if (data.status === 'FREE') { this.clearPluginMetadata(data.plugin, data.clientUuid); } if (disk !== undefined && disk !== null && part !== undefined && part !== null) { this.setPartitionPluginStatus(data.plugin, data.clientUuid, Number(disk), Number(part), data.status); } } } } } catch { } }; } catch { } } refreshPlugins(): Observable { const apiUrl = this.configService.apiUrl; if (!apiUrl) { return of([]); } return this.http.get(`${apiUrl}/plugins?all=true`).pipe( catchError(() => of([])), switchMap((plugins) => { if (!plugins || plugins.length === 0) { this.allPlugins = []; this.activePlugins = []; this.pluginsSubject.next([]); return of([]); } // Descubrir y fusionar manifiestos extendidos de cada plugin const manifestRequests = plugins.map(p => this.http.get>(`${apiUrl}/plugins/${p.name}/manifest`).pipe( catchError(() => of({})), map(extra => ({ ...p, ...extra, enabled: Boolean(p.enabled) // La base de datos es la única fuente de verdad para el estado de activación })) ) ); return forkJoin(manifestRequests).pipe( tap((fullPlugins) => { this.allPlugins = fullPlugins; this.activePlugins = fullPlugins.filter(p => p.enabled); this.pluginsSubject.next(this.allPlugins); this.loadPluginTranslations(); this.loadActivePluginsStatus().subscribe(); }) ); }) ); } togglePlugin(name: string, enabled?: boolean): Observable { const apiUrl = this.configService.apiUrl; if (!apiUrl) return of(null); return this.http.post(`${apiUrl}/plugins/${name}/toggle`, enabled !== undefined ? { enabled } : {}).pipe( switchMap(() => this.refreshPlugins()) ); } public loadPluginTranslations(lang?: string): void { const currentLang = lang || this.translateService.currentLang || this.translateService.defaultLang || 'es'; const apiUrl = this.configService.apiUrl; for (const plugin of this.allPlugins) { // 1. Si el plugin declara i18n directamente en el manifest, lo inyectamos if (plugin.i18n && typeof plugin.i18n === 'object') { for (const [l, dict] of Object.entries(plugin.i18n)) { if (dict && typeof dict === 'object') { this.translateService.setTranslation(l, dict, true); } } } // 2. Si el plugin tiene endpoint /i18n/{lang} (carpeta i18n/ estandarizada), lo descargamos bajo demanda if (apiUrl) { this.http.get>(`${apiUrl}/plugins/${plugin.name}/i18n/${currentLang}`).pipe( catchError(() => of(null)), tap((catalog) => { if (catalog && typeof catalog === 'object' && Object.keys(catalog).length > 0) { this.translateService.setTranslation(currentLang, catalog, true); } }) ).subscribe(); } } } loadActivePluginsStatus(): Observable { const apiUrl = this.configService.apiUrl; if (!apiUrl || this.activePlugins.length === 0) { return of(undefined); } const pluginsWithStatus = this.activePlugins.filter(p => !!p.statusEndpoint); if (pluginsWithStatus.length === 0) { return of(undefined); } const requests = pluginsWithStatus.map(plugin => { const endpoint = plugin.statusEndpoint!.startsWith('http') ? plugin.statusEndpoint! : `${apiUrl.replace(/\/api\/?$/, '')}${plugin.statusEndpoint!.startsWith('/') ? '' : '/'}${plugin.statusEndpoint}`; return this.http.get(endpoint).pipe( catchError(() => of([])), tap(rows => { if (Array.isArray(rows)) { for (const row of rows) { const status = row.status; const id = row.client_id ?? row.clientId ?? row.id; const uuid = row.client_uuid ?? row.clientUuid ?? row.uuid; const disk = row.disk_number ?? row.diskNumber ?? row.disk; const part = row.partition_number ?? row.partitionNumber ?? row.partition; // Extraer metadatos arbitrarios (dni, remaining_minutes, etc.) const metadata: any = {}; for (const [k, v] of Object.entries(row)) { if (!['status', 'client_id', 'clientId', 'id', 'client_uuid', 'clientUuid', 'uuid', 'disk_number', 'diskNumber', 'disk', 'partition_number', 'partitionNumber', 'partition'].includes(k) && v !== null && v !== undefined) { metadata[k] = v; } } const hasMeta = Object.keys(metadata).length > 0; if (status) { if (id !== undefined && id !== null) { const idStr = String(id); this.setPluginStatus(plugin.name, idStr, status); if (hasMeta && status !== 'FREE') { this.setPluginMetadata(plugin.name, idStr, metadata); } else if (status === 'FREE') { this.clearPluginMetadata(plugin.name, idStr); } if (disk !== undefined && disk !== null && part !== undefined && part !== null) { this.setPartitionPluginStatus(plugin.name, idStr, Number(disk), Number(part), status); } } if (uuid) { this.setPluginStatus(plugin.name, uuid, status); if (hasMeta && status !== 'FREE') { this.setPluginMetadata(plugin.name, uuid, metadata); } else if (status === 'FREE') { this.clearPluginMetadata(plugin.name, uuid); } if (disk !== undefined && disk !== null && part !== undefined && part !== null) { this.setPartitionPluginStatus(plugin.name, uuid, Number(disk), Number(part), status); } } } } } }) ); }); return forkJoin(requests).pipe(map(() => undefined)); } getPlugins(): PluginManifest[] { return this.activePlugins.filter(p => p.enabled); } getAllPlugins(): PluginManifest[] { return this.allPlugins; } hasPlugins(): boolean { return this.allPlugins.length > 0; } hasActivePlugins(): boolean { return this.getPlugins().length > 0; } isPluginActive(name: string): boolean { return this.activePlugins.some(p => p.name === name && p.enabled); } getPlugin(name: string): PluginManifest | undefined { return this.allPlugins.find(p => p.name === name); } getPluginGroups(): PluginGroup[] { return this.getPlugins() .filter(p => p.commands && p.commands.length > 0) .map(p => { const translatedTitle = p.titleKey ? this.translateService.instant(p.titleKey) : p.title; const filteredCommands = p.commands .filter(cmd => this.isCommandAllowedForUser(cmd, p.name)) .map(cmd => { const key = cmd.translationKey || cmd.labelKey; if (key) { const trans = this.translateService.instant(key); if (trans && trans !== key) { return { ...cmd, label: trans }; } } return cmd; }); return { plugin: { ...p, title: (translatedTitle && translatedTitle !== p.titleKey) ? translatedTitle : p.title }, commands: filteredCommands }; }) .filter(group => group.commands.length > 0); } /** * Comprueba si el usuario autenticado tiene permiso para ejecutar un comando específico. * Jerarquía: configData.command_roles[slug] > command.roles > público (todos los roles) */ isCommandAllowedForUser(command: PluginCommand, pluginName: string): boolean { if (this.authService.isSuperAdmin) { return true; } const config = this.getPluginConfig(pluginName); const commandRolesConfig = config?.['command_roles'] || {}; let allowedRoles: string[] | undefined = commandRolesConfig[command.slug]; if (!allowedRoles || !Array.isArray(allowedRoles)) { allowedRoles = command.roles; } // Si ni en configuración ni en el manifiesto se especifican roles, la función es pública if (!allowedRoles || allowedRoles.length === 0) { return true; } const userRoles = (this.authService.roles || []).map(r => r.toUpperCase()); const currentCategory = (this.authService.getCurrentRole() || '').toLowerCase(); return allowedRoles.some(role => { const rUpper = role.toUpperCase(); const rLower = role.toLowerCase(); return userRoles.includes(rUpper) || rLower === currentCategory || (rUpper === 'ROLE_SUPER_ADMIN' && this.authService.isSuperAdmin) || (rUpper === 'ROLE_ORGANIZATIONAL_UNIT_ADMIN' && this.authService.isOUAdmin) || (rUpper === 'ROLE_ORGANIZATIONAL_UNIT_OPERATOR' && this.authService.isOUOperator) || (rUpper === 'ROLE_ORGANIZATIONAL_UNIT_MINIMAL' && this.authService.isOUMinimal); }); } /** * Obtiene la lista de roles/grupos disponibles en el sistema consultando a ogCore, * con fallback seguro a los roles canónicos de OpenGnSys. */ getUserRoles(): Observable { const apiUrl = this.configService.apiUrl; const defaultRoles: RoleDefinition[] = [ { key: 'ROLE_SUPER_ADMIN', name: 'Super Admin', shortCode: 'super-admin' }, { key: 'ROLE_ORGANIZATIONAL_UNIT_ADMIN', name: 'Admin de aulas', shortCode: 'ou-admin' }, { key: 'ROLE_ORGANIZATIONAL_UNIT_OPERATOR', name: 'Operador de aulas', shortCode: 'ou-operator' }, { key: 'ROLE_ORGANIZATIONAL_UNIT_MINIMAL', name: 'Usuario básico de aulas', shortCode: 'ou-minimal' }, ]; if (!apiUrl) { return of(defaultRoles); } return this.http.get(`${apiUrl}/user-groups?page=1&itemsPerPage=1000`).pipe( map(res => { const groups = res?.['hydra:member'] || (Array.isArray(res) ? res : []); if (!Array.isArray(groups) || groups.length === 0) { return defaultRoles; } const roles: RoleDefinition[] = []; const seenKeys = new Set(); // Descubrir roles desde los grupos registrados en la base de datos for (const g of groups) { const perms = Array.isArray(g.permissions) ? g.permissions : []; for (const perm of perms) { if (!seenKeys.has(perm)) { seenKeys.add(perm); const matchedDef = defaultRoles.find(dr => dr.key === perm); roles.push({ key: perm, name: g.name || matchedDef?.name || perm, shortCode: matchedDef?.shortCode }); } } } // Garantizar que todos los roles canónicos estándar estén presentes for (const dr of defaultRoles) { if (!seenKeys.has(dr.key)) { roles.push(dr); } } return roles; }), catchError(() => of(defaultRoles)) ); } /** * Devuelve los roles actualmente asignados a un comando del plugin. */ getCommandRoles(pluginName: string, commandSlug: string): string[] { const plugin = this.getPlugin(pluginName); const cmd = plugin?.commands?.find(c => c.slug === commandSlug); const config = this.getPluginConfig(pluginName); const commandRolesConfig = config?.['command_roles'] || {}; if (commandRolesConfig[commandSlug] && Array.isArray(commandRolesConfig[commandSlug])) { return [...commandRolesConfig[commandSlug]]; } if (cmd?.roles && Array.isArray(cmd.roles)) { return [...cmd.roles]; } return ['ROLE_SUPER_ADMIN', 'ROLE_ORGANIZATIONAL_UNIT_ADMIN', 'ROLE_ORGANIZATIONAL_UNIT_OPERATOR', 'ROLE_ORGANIZATIONAL_UNIT_MINIMAL']; } /** * Persiste la matriz de roles configurados para los comandos de un plugin. */ savePluginCommandRoles(pluginName: string, commandRoles: Record): void { const currentConfig = this.getPluginConfig(pluginName); const updatedConfig = { ...currentConfig, command_roles: commandRoles }; this.savePluginConfig(pluginName, updatedConfig); } getCommands(): PluginCommand[] { const commands: PluginCommand[] = []; for (const group of this.getPluginGroups()) { if (group.commands && Array.isArray(group.commands)) { commands.push(...group.commands); } } return commands; } findCommand(slug: string): PluginCommand | undefined { return this.getCommands().find(c => c.slug === slug); } isCommandSupportedForClients(command: PluginCommand, clients: any[]): boolean { if (!command.os || command.os.length === 0) { return true; } if (!clients || clients.length === 0) { return true; } const requiredOsList = command.os.map(os => os.toLowerCase()); return clients.every(client => { const statusStr = (client.status || '').toLowerCase(); const matchesStatus = requiredOsList.some(req => statusStr.includes(req)); const hasMatchingPartition = (client.partitions || []).some((partition: any) => { const osName = (partition.operativeSystem?.name || partition.operativeSystem || '').toLowerCase(); return requiredOsList.some(req => osName.includes(req)); }); return matchesStatus || hasMatchingPartition; }); } getClientBadges(client: any): PluginBadge[] { const badges: PluginBadge[] = []; if (!client || this.activePlugins.length === 0) return badges; const idKey = client.id ? String(client.id) : null; const uuidKey = client.uuid || client.name; for (const plugin of this.activePlugins) { if (!plugin.badges || plugin.badges.length === 0) { continue; } // 1. Estado global del cliente let currentStatus = (idKey ? this.getPluginStatus(plugin.name, idKey) : null) || (uuidKey ? this.getPluginStatus(plugin.name, uuidKey) : null); // 2. Si no hay estado global explícito, buscar si alguna partición del equipo tiene un estado activo if (!currentStatus) { for (const [key, val] of this.clientPluginStatusMap.entries()) { const isMatch = (idKey && key.startsWith(`${plugin.name}:${idKey}:`)) || (uuidKey && key.startsWith(`${plugin.name}:${uuidKey}:`)); if (isMatch && val && val !== 'NONE' && val !== 'UNFROZEN') { currentStatus = val; break; } } } if (!currentStatus) { continue; } const matchingBadge = plugin.badges.find( b => b.status.toUpperCase() === currentStatus.toUpperCase() ); if (matchingBadge) { let label = matchingBadge.label; if (matchingBadge.labelKey) { const trans = this.translateService.instant(matchingBadge.labelKey); if (trans && trans !== matchingBadge.labelKey) { label = trans; } } let tooltip = matchingBadge.tooltip; if (matchingBadge.tooltipKey) { const trans = this.translateService.instant(matchingBadge.tooltipKey); if (trans && trans !== matchingBadge.tooltipKey) { tooltip = trans; } } // Enriquecer tooltip con metadatos contextuales si están disponibles (p. ej. DNI del alumno, tiempo restante) const meta = (idKey ? this.getPluginMetadata(plugin.name, idKey) : null) || (uuidKey ? this.getPluginMetadata(plugin.name, uuidKey) : null); if (meta) { const dni = meta.dni || meta.user || meta.student; const mins = meta.remaining_minutes !== undefined ? meta.remaining_minutes : meta.remainingMinutes; const infoParts: string[] = []; if (dni) { infoParts.push(`DNI: ${dni}`); } if (mins !== undefined && mins !== null && mins !== '') { infoParts.push(`${mins} min`); } if (infoParts.length > 0) { const detailStr = infoParts.join(' · '); tooltip = tooltip ? `${tooltip} (${detailStr})` : detailStr; } } badges.push({ id: plugin.name, type: matchingBadge.type, icon: matchingBadge.icon, label, tooltip }); } } return badges; } getPartitionBadges(client: any, diskNumber: number, partitionNumber: number): PluginBadge[] { const badges: PluginBadge[] = []; if (!client || this.activePlugins.length === 0) return badges; const idKey = client.id ? String(client.id) : null; const uuidKey = client.uuid || client.name; for (const plugin of this.activePlugins) { if (!plugin.badges || plugin.badges.length === 0) { continue; } const currentStatus = (idKey ? this.getPartitionPluginStatus(plugin.name, idKey, diskNumber, partitionNumber) : null) || (uuidKey ? this.getPartitionPluginStatus(plugin.name, uuidKey, diskNumber, partitionNumber) : null); if (!currentStatus) { continue; } const matchingBadge = plugin.badges.find( b => b.status.toUpperCase() === currentStatus.toUpperCase() ); if (matchingBadge) { let label = matchingBadge.label; if (matchingBadge.labelKey) { const trans = this.translateService.instant(matchingBadge.labelKey); if (trans && trans !== matchingBadge.labelKey) { label = trans; } } let tooltip = matchingBadge.tooltip; if (matchingBadge.tooltipKey) { const trans = this.translateService.instant(matchingBadge.tooltipKey); if (trans && trans !== matchingBadge.tooltipKey) { tooltip = trans; } } badges.push({ id: plugin.name, type: matchingBadge.type, icon: matchingBadge.icon, label, tooltip }); } } return badges; } getPluginConfig(pluginName: string): Record { const plugin = this.getPlugin(pluginName); const defaults = plugin?.defaultConfig || {}; const storageKey = `og_plugin_config_${pluginName}`; const saved = localStorage.getItem(storageKey); if (saved) { try { return { ...defaults, ...(plugin?.config || {}), ...JSON.parse(saved) }; } catch { return { ...defaults, ...(plugin?.config || {}) }; } } return { ...defaults, ...(plugin?.config || {}) }; } savePluginConfig(pluginName: string, config: Record): void { const storageKey = `og_plugin_config_${pluginName}`; localStorage.setItem(storageKey, JSON.stringify(config)); const plugin = this.getPlugin(pluginName); if (plugin) { plugin.config = config; } const apiUrl = this.configService.apiUrl; if (apiUrl) { this.http.post(`${apiUrl}/plugins/${pluginName}/config`, config).pipe( catchError((err) => { console.warn(`[PluginService] Could not persist config for ${pluginName} to backend`, err); return of(null); }) ).subscribe(); } } resetPluginConfig(pluginName: string): Record { const plugin = this.getPlugin(pluginName); const defaults = plugin?.defaultConfig || {}; const storageKey = `og_plugin_config_${pluginName}`; localStorage.removeItem(storageKey); this.savePluginConfig(pluginName, defaults); return defaults; } }