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'; export interface PluginCommand { slug: string; translationKey?: string; labelKey?: string; label: string; icon: string; disabled: boolean; endpoint?: string; os?: string[]; // Ej: ['windows'], ['linux'] } 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 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(); 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)); } 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); } } }; if (data.status === 'NONE') { if (data.clientId !== undefined && data.clientId !== null) { const idStr = String(data.clientId); 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; 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 { if (data.clientId !== undefined && data.clientId !== null) { this.setPluginStatus(data.plugin, String(data.clientId), data.status); if (disk !== undefined && disk !== null && part !== undefined && part !== null) { this.setPartitionPluginStatus(data.plugin, String(data.clientId), Number(disk), Number(part), data.status); } } if (data.clientUuid) { this.setPluginStatus(data.plugin, data.clientUuid, data.status); 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; if (status) { if (id !== undefined && id !== null) { this.setPluginStatus(plugin.name, String(id), status); if (disk !== undefined && disk !== null && part !== undefined && part !== null) { this.setPartitionPluginStatus(plugin.name, String(id), Number(disk), Number(part), status); } } if (uuid) { this.setPluginStatus(plugin.name, uuid, status); 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; return { plugin: { ...p, title: (translatedTitle && translatedTitle !== p.titleKey) ? translatedTitle : p.title }, commands: p.commands.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; }) }; }); } 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; } } 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; } }