import { Injectable, signal, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, Subject, debounceTime, catchError, of, tap } from 'rxjs'; import { GraphConfig, DEFAULT_GRAPH_CONFIG, validateGraphConfig } from './graph-settings.types'; import { LogService } from '../../core/logging/log.service'; /** Response from graph config API */ interface GraphConfigResponse { config: GraphConfig; rev: string; } /** Unsubscribe function type */ export type Unsubscribe = () => void; /** * Service for managing graph settings stored in .obsidian/graph.json * Handles loading, saving, watching for external changes, and debouncing writes */ @Injectable({ providedIn: 'root' }) export class GraphSettingsService { private readonly logService = inject(LogService); private configSubject = new BehaviorSubject(DEFAULT_GRAPH_CONFIG); private saveQueue = new Subject>(); private currentRev: string | null = null; private pollingInterval: any = null; /** Current graph configuration as signal */ config = signal(DEFAULT_GRAPH_CONFIG); constructor(private http: HttpClient) { // Setup debounced save mechanism this.saveQueue.pipe( debounceTime(250) ).subscribe(patch => { this.performSave(patch); }); // Load initial config this.load(); // Start polling for external changes (every 2 seconds) this.startWatching(); } /** * Load configuration from server * Falls back to defaults if file doesn't exist or is invalid */ async load(): Promise { try { const response = await this.http.get('/api/vault/graph') .pipe( catchError(err => { console.warn('Failed to load graph config, using defaults:', err); return of({ config: DEFAULT_GRAPH_CONFIG, rev: 'default' }); }) ) .toPromise(); if (response) { const validated = this.validateAndMerge(response.config); this.currentRev = response.rev; this.config.set(validated); this.configSubject.next(validated); return validated; } } catch (error) { console.error('Error loading graph config:', error); } return DEFAULT_GRAPH_CONFIG; } /** * Queue a partial configuration update for saving * Actual save is debounced to avoid excessive writes */ save(patch: Partial): void { // Update local state immediately for reactive UI const updated = { ...this.config(), ...patch }; const validated = this.validateAndMerge(updated); this.config.set(validated); this.configSubject.next(validated); // Queue for debounced save this.saveQueue.next(patch); // Log settings change this.logService.log('GRAPH_VIEW_SETTINGS_CHANGE', { changes: Object.keys(patch), }); } /** * Perform the actual save operation to the server */ private async performSave(patch: Partial): Promise { try { const current = this.config(); const updated = { ...current, ...patch }; const validated = this.validateAndMerge(updated); const headers: any = {}; if (this.currentRev) { headers['If-Match'] = this.currentRev; } const response = await this.http.put<{ rev: string }>( '/api/vault/graph', validated, { headers } ).pipe( tap(result => { this.currentRev = result.rev; }), catchError(err => { if (err.status === 409) { console.warn('Graph config conflict detected, reloading...'); this.load(); } else { console.error('Failed to save graph config:', err); } return of(null); }) ).toPromise(); } catch (error) { console.error('Error saving graph config:', error); } } /** * Watch for external changes to the configuration file */ watch(callback: (config: GraphConfig) => void): Unsubscribe { const subscription = this.configSubject.subscribe(callback); return () => subscription.unsubscribe(); } /** * Start polling for external changes */ private startWatching(): void { if (this.pollingInterval) { return; } this.pollingInterval = setInterval(() => { this.checkForExternalChanges(); }, 2000); } /** * Stop polling for external changes */ private stopWatching(): void { if (this.pollingInterval) { clearInterval(this.pollingInterval); this.pollingInterval = null; } } /** * Check if the config file was modified externally */ private async checkForExternalChanges(): Promise { try { const response = await this.http.get('/api/vault/graph') .pipe( catchError(() => of(null)) ) .toPromise(); if (response && response.rev !== this.currentRev) { console.log('External graph config change detected, reloading...'); const validated = this.validateAndMerge(response.config); this.currentRev = response.rev; this.config.set(validated); this.configSubject.next(validated); } } catch (error) { // Silently ignore polling errors } } /** * Reset to default configuration */ resetToDefaults(): void { this.save(DEFAULT_GRAPH_CONFIG); } /** * Reset a specific section to defaults */ resetSection(section: 'filters' | 'groups' | 'display' | 'forces'): void { const defaults = DEFAULT_GRAPH_CONFIG; switch (section) { case 'filters': this.save({ search: defaults.search, showTags: defaults.showTags, showAttachments: defaults.showAttachments, hideUnresolved: defaults.hideUnresolved, showOrphans: defaults.showOrphans }); break; case 'groups': this.save({ colorGroups: defaults.colorGroups }); break; case 'display': this.save({ showArrow: defaults.showArrow, textFadeMultiplier: defaults.textFadeMultiplier, nodeSizeMultiplier: defaults.nodeSizeMultiplier, lineSizeMultiplier: defaults.lineSizeMultiplier }); break; case 'forces': this.save({ centerStrength: defaults.centerStrength, repelStrength: defaults.repelStrength, linkStrength: defaults.linkStrength, linkDistance: defaults.linkDistance }); break; } } /** * Toggle a collapse state */ toggleCollapse(section: 'filter' | 'color-groups' | 'display' | 'forces'): void { const key = `collapse-${section}` as keyof GraphConfig; const current = this.config()[key] as boolean; this.save({ [key]: !current } as Partial); } /** * Validate and merge configuration with defaults * Ensures all required fields exist and values are within bounds */ private validateAndMerge(config: Partial): GraphConfig { const merged = { ...DEFAULT_GRAPH_CONFIG, ...config }; // Validate numeric bounds const validated = validateGraphConfig(merged); // Ensure colorGroups is an array if (!Array.isArray(validated.colorGroups)) { validated.colorGroups = []; } return merged as GraphConfig; } /** * Clean up resources */ ngOnDestroy(): void { this.stopWatching(); this.configSubject.complete(); this.saveQueue.complete(); } }