From 7578c9672272c5f256646964bcca18eb8b332d8a Mon Sep 17 00:00:00 2001 From: Ben Floyd Date: Mon, 17 Nov 2025 19:00:32 -0700 Subject: [PATCH] phase 2 refactor --- .eslintrc | 9 +- src/__tests__/mocks/obsidian.ts | 14 +- src/__tests__/setup.ts | 102 ++- src/features/backlinks/BacklinksSlice.ts | 516 +++---------- .../backlinks/__tests__/BacklinksCore.test.ts | 258 +++++++ .../BacklinksSlice.integration.test.ts | 24 +- src/features/backlinks/core/BacklinksCore.ts | 415 ++++++++++ .../backlinks/core/BacklinksEvents.ts | 108 +++ src/features/backlinks/core/BacklinksState.ts | 195 +++++ .../backlinks/ui/BacklinksViewController.ts | 709 ++++++++++++++++++ 10 files changed, 1901 insertions(+), 449 deletions(-) create mode 100644 src/features/backlinks/__tests__/BacklinksCore.test.ts create mode 100644 src/features/backlinks/core/BacklinksCore.ts create mode 100644 src/features/backlinks/core/BacklinksEvents.ts create mode 100644 src/features/backlinks/core/BacklinksState.ts create mode 100644 src/features/backlinks/ui/BacklinksViewController.ts diff --git a/.eslintrc b/.eslintrc index 0807290..c07a853 100644 --- a/.eslintrc +++ b/.eslintrc @@ -18,6 +18,11 @@ "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], "@typescript-eslint/ban-ts-comment": "off", "no-prototype-builtins": "off", - "@typescript-eslint/no-empty-function": "off" - } + "@typescript-eslint/no-empty-function": "off", + + "complexity": ["error", 10], + "max-lines-per-function": ["error", 50], + "@typescript-eslint/no-explicit-any": "error", + "import/order": ["error", { "groups": ["builtin", "external", "internal"] }] + } } \ No newline at end of file diff --git a/src/__tests__/mocks/obsidian.ts b/src/__tests__/mocks/obsidian.ts index 6c6710e..c505a1b 100644 --- a/src/__tests__/mocks/obsidian.ts +++ b/src/__tests__/mocks/obsidian.ts @@ -96,4 +96,16 @@ export class WorkspaceLeaf { } // Export other commonly used types -export type { Plugin, Component } from 'obsidian'; \ No newline at end of file +export type { Plugin, Component } from 'obsidian'; + +/** + * Minimal setIcon mock for tests. + * + * The real Obsidian setIcon injects an SVG into the target element. + * For our purposes, we just append a so IconProvider and header UI + * can find and style it. + */ +export function setIcon(element: HTMLElement, _icon: string, _options?: any): void { + const svg = document.createElement('svg'); + element.appendChild(svg); +} \ No newline at end of file diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index b16d4ba..b6ac6a2 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -24,39 +24,75 @@ afterAll(() => { Object.assign(console, originalConsole); }); -// Mock DOM elements that might not be available -Object.defineProperty(document, 'createElement', { - writable: true, - value: jest.fn().mockImplementation((tagName: string) => { - const element = { - tagName: tagName.toUpperCase(), - className: '', - classList: { - add: jest.fn(), - remove: jest.fn(), - contains: jest.fn(), - toggle: jest.fn() - }, - style: {}, - setAttribute: jest.fn(), - getAttribute: jest.fn(), - appendChild: jest.fn(), - removeChild: jest.fn(), - insertAdjacentElement: jest.fn(), - querySelector: jest.fn(), - querySelectorAll: jest.fn().mockReturnValue([]), - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - parentElement: null, - children: [], - textContent: '', - innerHTML: '', - outerHTML: '' - }; - return element; - }) -}); + // Add Obsidian-style helper methods to HTMLElement for tests, while keeping + // jsdom's native DOM implementation intact. +const elementProto = (HTMLElement.prototype as any); + +if (!elementProto.createDiv) { + elementProto.createDiv = function ( + options?: { cls?: string; attr?: Record } + ) { + const div = document.createElement('div'); + if (options?.cls) { + div.className = options.cls; + } + if (options?.attr) { + Object.entries(options.attr).forEach(([key, value]) => { + div.setAttribute(key, value); + }); + } + this.appendChild(div); + return div; + }; +} + +if (!elementProto.createEl) { + elementProto.createEl = function ( + tag: string, + options?: { cls?: string; attr?: Record; text?: string } + ) { + const el = document.createElement(tag); + if (options?.cls) { + el.className = options.cls; + } + if (options?.attr) { + Object.entries(options.attr).forEach(([key, value]) => { + el.setAttribute(key, value); + }); + } + if (options?.text) { + el.textContent = options.text; + } + this.appendChild(el); + return el; + }; +} + +if (!elementProto.createSvg) { + elementProto.createSvg = function ( + tag: string, + options?: { attr?: Record } + ) { + const svg = document.createElement(tag); + if (options?.attr) { + Object.entries(options.attr).forEach(([key, value]) => { + (svg as any).setAttribute(key, value); + }); + } + this.appendChild(svg); + return svg; + }; +} + +// Polyfill ResizeObserver for jsdom-based tests +if (!(global as any).ResizeObserver) { + (global as any).ResizeObserver = class { + constructor(_callback: any) {} + observe(_target: Element) {} + unobserve(_target: Element) {} + disconnect() {} + }; +} // Mock requestAnimationFrame global.requestAnimationFrame = jest.fn().mockImplementation(cb => setTimeout(cb, 0)); diff --git a/src/features/backlinks/BacklinksSlice.ts b/src/features/backlinks/BacklinksSlice.ts index b8efb9f..288af8f 100644 --- a/src/features/backlinks/BacklinksSlice.ts +++ b/src/features/backlinks/BacklinksSlice.ts @@ -4,10 +4,13 @@ import { BacklinkDiscoverer } from './BacklinkDiscoverer'; import { LinkResolver } from './LinkResolver'; import { BacklinkCache } from './BacklinkCache'; import { Logger } from '../shared-utilities/Logger'; -import { DailyNote } from '../shared-utilities/DailyNote'; import { BacklinkDiscoveryOptions, BacklinkFilterOptions, BacklinkStatistics } from './types'; import { CoalesceEvent, EventHandler } from '../shared-contracts/events'; import { AppWithInternalPlugins } from '../shared-contracts/obsidian'; +import { BacklinksState } from './core/BacklinksState'; +import { BacklinksEvents } from './core/BacklinksEvents'; +import { BacklinksCore } from './core/BacklinksCore'; +import { BacklinksViewController } from './ui/BacklinksViewController'; // Import components from BacklinkBlocks slice import { BlockExtractor } from './BlockExtractor'; @@ -33,6 +36,13 @@ import { HeaderCreateOptions, HeaderState, HeaderStatistics } from './types'; export class BacklinksSlice implements IBacklinksSlice { private app: App; private logger: Logger; + + // Core/domain services + private state: BacklinksState; + private events: BacklinksEvents; + private core: BacklinksCore; + + // Backlink components private backlinkDiscoverer: BacklinkDiscoverer; private linkResolver: LinkResolver; private backlinkCache: BacklinkCache; @@ -60,6 +70,9 @@ export class BacklinksSlice implements IBacklinksSlice { // UI state tracking for consistency private attachedViews: Map = new Map(); + // View controller (new UI layer) + private viewController: BacklinksViewController; + constructor(app: App, options?: Partial) { this.app = app; this.logger = new Logger('BacklinksSlice'); @@ -74,10 +87,15 @@ export class BacklinksSlice implements IBacklinksSlice { ...options }; - // Initialize backlink components - this.backlinkDiscoverer = new BacklinkDiscoverer(app, this.logger, this.options); + // Initialize core/domain services and backlink components + this.state = new BacklinksState(); + this.events = new BacklinksEvents(this.logger); + this.core = new BacklinksCore(this.app, this.logger, this.options, this.state, this.events); + + // Use core-owned components for backward compatible methods + this.backlinkDiscoverer = this.core.getBacklinkDiscoverer(); + this.backlinkCache = this.core.getBacklinkCache(); this.linkResolver = new LinkResolver(app, this.logger); - this.backlinkCache = new BacklinkCache(app, this.logger); // Initialize block components this.renderOptions = { @@ -129,93 +147,50 @@ export class BacklinksSlice implements IBacklinksSlice { totalHeaderStyleChanges: 0 }; + // Initialize view controller (UI layer) + this.viewController = new BacklinksViewController( + app, + this.logger, + this.core, + this.blockExtractor, + this.blockRenderer, + this.strategyManager, + this.headerUI, + this.filterControls, + this.settingsControls + ); + this.logger.debug('Consolidated BacklinksSlice initialized', { options: this.options }); } /** * Update backlinks for a file + * + * Delegates to BacklinksCore for discovery, caching, state updates, + * and event emission, while keeping the error boundary at slice level. */ async updateBacklinks(filePath: string, leafId?: string): Promise { - return this.withErrorBoundary(async () => { - this.logger.debug('Updating backlinks', { filePath, leafId }); - - // Check if we should skip daily notes - if (this.options.onlyDailyNotes && - DailyNote.isDaily(this.app as AppWithInternalPlugins, filePath)) { - this.logger.debug('Skipping daily note', { filePath }); - const emptyBacklinks: string[] = []; - this.currentBacklinks.set(filePath, emptyBacklinks); - return emptyBacklinks; - } - - // Try to get from cache first - let backlinks: string[] = []; - let fromCache = false; - - if (this.options.useCache) { - const cachedBacklinks = this.backlinkCache.getCachedBacklinks(filePath); - if (cachedBacklinks) { - backlinks = cachedBacklinks; - fromCache = true; - this.logger.debug('Backlinks retrieved from cache', { - filePath, - count: backlinks.length - }); - } - } - - // If not from cache, discover backlinks - if (!fromCache) { - backlinks = await this.backlinkDiscoverer.discoverBacklinks(filePath); - - // Cache the results - if (this.options.useCache) { - this.backlinkCache.cacheBacklinks(filePath, backlinks); - } - - this.logger.debug('Backlinks discovered', { - filePath, - count: backlinks.length - }); - } - - // Store current backlinks - this.currentBacklinks.set(filePath, backlinks); - - // Emit event - this.emitEvent({ - type: 'backlinks:updated', - payload: { - files: backlinks, - leafId: leafId || '', - count: backlinks.length - } - }); - - this.logger.debug('Backlinks updated successfully', { - filePath, - count: backlinks.length, - fromCache - }); - - return backlinks; - }, `updateBacklinks(${filePath})`); + return this.withErrorBoundary( + () => this.core.updateBacklinks(filePath, leafId), + `updateBacklinks(${filePath})` + ); } /** * Get current backlinks for a file + * Delegates to BacklinksCore/BacklinksState. */ getCurrentBacklinks(filePath: string): string[] { this.logger.debug('Getting current backlinks', { filePath }); - + try { - const backlinks = this.currentBacklinks.get(filePath) || []; - - this.logger.debug('Current backlinks retrieved', { - filePath, - count: backlinks.length + const backlinks = this.core.getCurrentBacklinks(filePath); + + this.logger.debug('Current backlinks retrieved', { + filePath, + count: backlinks.length }); - + return backlinks; } catch (error) { this.logger.error('Failed to get current backlinks', { filePath, error }); @@ -246,150 +221,54 @@ export class BacklinksSlice implements IBacklinksSlice { /** * Get cached backlinks (required by interface) + * Delegates to BacklinksCore/cache. */ getCachedBacklinks(filePath: string): string[] | null { this.logger.debug('Getting cached backlinks', { filePath }); - - try { - const cachedBacklinks = this.backlinkCache.getCachedBacklinks(filePath); - - this.logger.debug('Cached backlinks retrieved', { - filePath, - found: cachedBacklinks !== null, - count: cachedBacklinks?.length || 0 - }); - - return cachedBacklinks; - } catch (error) { - this.logger.error('Failed to get cached backlinks', { filePath, error }); - return null; - } + return this.core.getCachedBacklinks(filePath); } /** * Clear cache (required by interface) + * Delegates to BacklinksCore. */ clearCache(filePath?: string): void { this.logger.debug('Clearing cache', { filePath }); - - try { - if (filePath) { - // Clear cache for specific file - this.backlinkCache.invalidateCache(filePath); - this.currentBacklinks.delete(filePath); - } else { - // Clear all cache - this.backlinkCache.clearCache(); - this.currentBacklinks.clear(); - } - - this.logger.debug('Cache cleared successfully', { filePath }); - } catch (error) { - this.logger.error('Failed to clear cache', { filePath, error }); - } + this.core.clearCache(filePath); } /** * Get backlink metadata (required by interface) + * Delegates to BacklinksCore. */ - getBacklinkMetadata(): { lastUpdated: Date; cacheSize: number; } { + getBacklinkMetadata(): { lastUpdated: Date; cacheSize: number } { this.logger.debug('Getting backlink metadata'); - - try { - const cacheStats = this.backlinkCache.getCacheStatistics(); - const metadata = { - lastUpdated: cacheStats.lastCleanup || new Date(), - cacheSize: cacheStats.totalCachedFiles - }; - - this.logger.debug('Backlink metadata retrieved', metadata); - - return metadata; - } catch (error) { - this.logger.error('Failed to get backlink metadata', { error }); - - // Return default metadata on error - return { - lastUpdated: new Date(), - cacheSize: 0 - }; - } + return this.core.getBacklinkMetadata(); } /** * Check if backlinks have changed + * Delegates to BacklinksCore. */ haveBacklinksChanged(filePath: string, newBacklinks: string[]): boolean { - this.logger.debug('Checking if backlinks have changed', { filePath, newBacklinksCount: newBacklinks.length }); - - try { - const currentBacklinks = this.currentBacklinks.get(filePath) || []; - - // Check if counts are different - if (currentBacklinks.length !== newBacklinks.length) { - this.logger.debug('Backlinks count changed', { - filePath, - oldCount: currentBacklinks.length, - newCount: newBacklinks.length - }); - return true; - } - - // Check if content is different - const currentSorted = [...currentBacklinks].sort(); - const newSorted = [...newBacklinks].sort(); - - for (let i = 0; i < currentSorted.length; i++) { - if (currentSorted[i] !== newSorted[i]) { - this.logger.debug('Backlinks content changed', { - filePath, - oldBacklink: currentSorted[i], - newBacklink: newSorted[i] - }); - return true; - } - } - - this.logger.debug('Backlinks unchanged', { filePath }); - return false; - } catch (error) { - this.logger.error('Failed to check if backlinks changed', { filePath, error }); - return true; // Assume changed on error - } + return this.core.haveBacklinksChanged(filePath, newBacklinks); } /** * Invalidate cache for a file + * Delegates to BacklinksCore. */ invalidateCache(filePath: string): void { - this.logger.debug('Invalidating cache', { filePath }); - - try { - this.backlinkCache.invalidateCache(filePath); - - this.logger.debug('Cache invalidated successfully', { filePath }); - } catch (error) { - this.logger.error('Failed to invalidate cache', { filePath, error }); - } + this.core.invalidateCache(filePath); } /** * Clear all backlinks + * Delegates to BacklinksCore. */ clearBacklinks(): void { this.logger.debug('Clearing all backlinks'); - - try { - // Clear current backlinks - this.currentBacklinks.clear(); - - // Clear cache - this.backlinkCache.clearCache(); - - this.logger.debug('All backlinks cleared successfully'); - } catch (error) { - this.logger.error('Failed to clear backlinks', { error }); - } + this.core.clearBacklinks(); } /** @@ -415,43 +294,35 @@ export class BacklinksSlice implements IBacklinksSlice { /** * Get statistics + * + * Domain stats come from BacklinksCore, while block/header/user interaction + * stats are provided by the view layer (BacklinksViewController). */ getStatistics(): BacklinkStatistics { - const backlinkStats = this.backlinkDiscoverer.getStatistics(); + const backlinkStats = this.core.getStatistics(); + const blockStats = this.viewController.getBlockStatistics(); + const headerStats = this.viewController.getHeaderStatistics(); - // For consolidated slice, we extend the basic backlink stats with block and header stats return { ...backlinkStats, - // Add consolidated statistics - totalBlocksExtracted: this.getBlockStatistics().totalBlocksExtracted, - totalBlocksRendered: this.getBlockStatistics().totalBlocksRendered, - totalHeadersCreated: this.headerStatistics.totalHeadersCreated, - totalUserInteractions: this.headerStatistics.totalFilterChanges + - this.headerStatistics.totalSortToggles + - this.headerStatistics.totalCollapseToggles + - this.headerStatistics.totalStrategyChanges + - this.headerStatistics.totalThemeChanges + - this.headerStatistics.totalAliasSelections - } as any; // Type assertion needed due to extended interface + totalBlocksExtracted: blockStats.totalBlocksExtracted, + totalBlocksRendered: blockStats.totalBlocksRendered, + totalHeadersCreated: headerStats.totalHeadersCreated, + totalUserInteractions: + headerStats.totalFilterChanges + + headerStats.totalSortToggles + + headerStats.totalCollapseToggles + + headerStats.totalStrategyChanges + + headerStats.totalThemeChanges + + headerStats.totalAliasSelections + } as any; } /** - * Get block statistics + * Get block statistics (delegated to view controller) */ private getBlockStatistics(): BlockStatistics { - const extractorStats = this.blockExtractor.getStatistics(); - const rendererStats = this.blockRenderer.getStatistics(); - - return { - totalBlocksExtracted: extractorStats.totalBlocksExtracted, - totalBlocksRendered: rendererStats.totalBlocksRendered, - blocksHidden: 0, // This would need tracking - blocksCollapsed: this.renderOptions.collapsed ? this.getCurrentBlocks('').length : 0, - averageBlockSize: extractorStats.totalBlocksExtracted > 0 ? - extractorStats.totalBlocksExtracted / extractorStats.totalExtractions : 0, - lastExtractionTime: extractorStats.lastExtractionTime, - lastRenderTime: rendererStats.lastRenderTime - }; + return this.viewController.getBlockStatistics(); } /** @@ -459,12 +330,8 @@ export class BacklinksSlice implements IBacklinksSlice { */ updateOptions(options: Partial): void { this.logger.debug('Updating options', { options }); - this.options = { ...this.options, ...options }; - - // Update discoverer options - this.backlinkDiscoverer.updateOptions(options); - + this.core.updateOptions(options); this.logger.debug('Options updated successfully', { options: this.options }); } @@ -515,100 +382,23 @@ export class BacklinksSlice implements IBacklinksSlice { // ===== CONSOLIDATED PUBLIC API METHODS ===== /** - * Attach the complete backlinks UI to a view - * This is the main entry point for rendering the full backlinks feature - * @param view The markdown view to attach to - * @param currentNotePath The current note file path - * @param forceRefresh If true, skip the recent attachment optimization - * @returns true if UI was attached, false if skipped due to recent attachment + * Attach the complete backlinks UI to a view. + * Delegates DOM work to BacklinksViewController while preserving error boundary. */ - async attachToDOM(view: MarkdownView, currentNotePath: string, forceRefresh = false): Promise { - return this.withErrorBoundary(async () => { - const viewId = (view.leaf as any).id || 'unknown'; - - this.logger.debug('Attaching backlinks UI to view', { currentNotePath, viewId, forceRefresh }); - - // Check if UI is already attached and recent (within last 5 seconds), unless force refresh is requested - const existingAttachment = this.attachedViews.get(viewId); - if (!forceRefresh && existingAttachment && (Date.now() - existingAttachment.lastUpdate) < 5000) { - this.logger.debug('UI already attached recently, skipping', { viewId, currentNotePath }); - return false; - } - - // Clear any existing coalesce containers from the view - const existingContainers = view.contentEl.querySelectorAll('.coalesce-custom-backlinks-container'); - existingContainers.forEach(container => container.remove()); - - // Get backlinks for the current note - const backlinks = await this.updateBacklinks(currentNotePath); - - if (backlinks.length === 0) { - this.logger.debug('No backlinks found, will render UI with no backlinks message', { currentNotePath }); - } - - // Create main container for the backlinks UI - const container = document.createElement('div'); - container.className = 'coalesce-custom-backlinks-container'; - - // Create header - const headerElement = this.createHeader(container, { - fileCount: backlinks.length, - sortDescending: this.currentHeaderState.sortDescending, - isCollapsed: this.currentHeaderState.isCollapsed, - currentStrategy: this.currentHeaderState.currentStrategy, - currentTheme: this.currentHeaderState.currentTheme, - showFullPathTitle: false, - aliases: [], // TODO: Extract from frontmatter - currentAlias: null, - unsavedAliases: [], - currentHeaderStyle: this.currentHeaderState.currentHeaderStyle, - currentFilter: this.currentHeaderState.currentFilter, - onSortToggle: () => this.handleSortToggle(), - onCollapseToggle: () => this.handleCollapseToggle(), - onStrategyChange: (strategy: string) => this.handleStrategyChange(strategy), - onThemeChange: (theme: string) => this.handleThemeChange(theme), - onFullPathTitleChange: (show: boolean) => this.updateHeaderState({ showFullPathTitle: show }), - onAliasSelect: (alias: string | null) => this.handleAliasSelection(alias), - onHeaderStyleChange: (style: string) => this.handleHeaderStyleChange(style), - onFilterChange: (filterText: string) => this.handleFilterChange(filterText), - onSettingsClick: () => this.handleSettingsClick() - }); - - if (headerElement) { - container.appendChild(headerElement); - - // Ensure header visual state is consistent with current state - this.headerUI.updateHeader(headerElement, this.currentHeaderState); - } - - // Create blocks container - const blocksContainer = document.createElement('div'); - blocksContainer.className = 'backlinks-list'; - container.appendChild(blocksContainer); - - // Extract and render blocks - await this.extractAndRenderBlocks(backlinks, currentNotePath, blocksContainer, view); - - // Apply current theme - this.applyThemeToContainer(this.currentTheme); - - // Attach the container to the view (after the content) - this.attachContainerToView(view, container); - - // Track the attachment - this.attachedViews.set(viewId, { - container, - lastUpdate: Date.now() - }); - - this.logger.debug('Backlinks UI attached successfully', { currentNotePath }); - return true; - }, `attachToDOM(${currentNotePath})`); + async attachToDOM( + view: MarkdownView, + currentNotePath: string, + forceRefresh = false + ): Promise { + return this.withErrorBoundary( + () => this.viewController.attachToDOM(view, currentNotePath, forceRefresh), + `attachToDOM(${currentNotePath})` + ); } /** - * Set options for the backlinks feature - * This controls sorting, collapsing, strategy, theme, alias, and filter settings + * Set options for the backlinks feature. + * Delegates to BacklinksViewController. */ setOptions(options: { sort?: boolean; @@ -620,32 +410,7 @@ export class BacklinksSlice implements IBacklinksSlice { }): void { try { this.logger.debug('Setting backlinks options', { options }); - - // Update header state - if (options.sort !== undefined) { - this.currentHeaderState.sortByPath = options.sort; - } - if (options.collapsed !== undefined) { - this.currentHeaderState.isCollapsed = options.collapsed; - this.renderOptions.collapsed = options.collapsed; - } - if (options.strategy !== undefined) { - this.currentHeaderState.currentStrategy = options.strategy; - } - if (options.theme !== undefined) { - this.currentHeaderState.currentTheme = options.theme; - this.currentTheme = options.theme; - } - if (options.alias !== undefined) { - this.currentHeaderState.currentAlias = options.alias; - } - if (options.filter !== undefined) { - this.currentHeaderState.currentFilter = options.filter; - } - - // Apply changes to current UI if it exists - this.applyCurrentOptions(); - + this.viewController.setOptions(options); this.logger.debug('Backlinks options set successfully', { options }); } catch (error) { this.logger.logErrorWithContext(error, `setOptions(${JSON.stringify(options)})`); @@ -684,21 +449,12 @@ export class BacklinksSlice implements IBacklinksSlice { } /** - * Request focus when the view is ready - * This handles timing for focus management + * Request focus when the view is ready. + * Delegates to BacklinksViewController. */ requestFocusWhenReady(leafId: string): void { - this.logger.debug('Requesting focus when ready', { leafId }); - - // For now, this is a simple implementation - // In a full implementation, this would coordinate with ViewIntegration - const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); - if (activeView && activeView.file) { - // Simple focus request - in practice this would be more sophisticated - setTimeout(() => { - this.logger.debug('Focus requested (simplified implementation)', { leafId }); - }, 100); - } + this.logger.debug('Requesting focus when ready (slice)', { leafId }); + this.viewController.requestFocusWhenReady(leafId); } // ===== HEADER-RELATED METHODS (from BacklinksHeader slice) ===== @@ -1353,65 +1109,24 @@ export class BacklinksSlice implements IBacklinksSlice { /** * Emit an event + * Delegates to BacklinksEvents facade. */ private emitEvent(event: CoalesceEvent): void { - this.logger.debug('Emitting event', { event }); - - try { - const handlers = this.eventHandlers.get(event.type) || []; - - for (const handler of handlers) { - try { - handler(event); - } catch (error) { - this.logger.error('Event handler failed', { event, error }); - } - } - - this.logger.debug('Event emitted successfully', { event }); - } catch (error) { - this.logger.error('Failed to emit event', { event, error }); - } + this.events.emitEvent(event); } /** * Add event listener */ addEventListener(eventType: T['type'], handler: EventHandler): void { - this.logger.debug('Adding event listener', { eventType }); - - try { - const handlers = this.eventHandlers.get(eventType) || []; - handlers.push(handler as EventHandler); - this.eventHandlers.set(eventType, handlers); - - this.logger.debug('Event listener added successfully', { eventType }); - } catch (error) { - this.logger.error('Failed to add event listener', { eventType, error }); - } + this.events.addEventListener(eventType, handler); } /** * Remove event listener */ removeEventListener(eventType: T['type'], handler: EventHandler): void { - this.logger.debug('Removing event listener', { eventType }); - - try { - const handlers = this.eventHandlers.get(eventType) || []; - const index = handlers.indexOf(handler as EventHandler); - - if (index !== -1) { - handlers.splice(index, 1); - this.eventHandlers.set(eventType, handlers); - - this.logger.debug('Event listener removed successfully', { eventType }); - } else { - this.logger.debug('Event listener not found', { eventType }); - } - } catch (error) { - this.logger.error('Failed to remove event listener', { eventType, error }); - } + this.events.removeEventListener(eventType, handler); } /** @@ -1436,7 +1151,14 @@ export class BacklinksSlice implements IBacklinksSlice { this.logger.debug('Cleaning up consolidated BacklinksSlice'); try { - // Cleanup backlink components + // Cleanup core services + this.core.cleanup(); + this.events.clearAllListeners(); + + // Cleanup view controller + this.viewController.cleanup(); + + // Cleanup backlink components (kept for backward compatibility) this.backlinkDiscoverer.cleanup(); this.linkResolver.cleanup(); this.backlinkCache.cleanup(); @@ -1455,7 +1177,6 @@ export class BacklinksSlice implements IBacklinksSlice { this.currentBacklinks.clear(); this.currentBlocks.clear(); this.currentHeaders.clear(); - this.eventHandlers.clear(); this.attachedViews.clear(); this.logger.debug('Consolidated BacklinksSlice cleanup completed'); @@ -1466,17 +1187,10 @@ export class BacklinksSlice implements IBacklinksSlice { /** * Remove UI attachment for a specific view + * Delegates to BacklinksViewController. */ removeAttachment(viewId: string): void { - const attachment = this.attachedViews.get(viewId); - if (attachment) { - // Remove the container from DOM if it still exists - if (attachment.container.parentElement) { - attachment.container.parentElement.removeChild(attachment.container); - } - this.attachedViews.delete(viewId); - this.logger.debug('Removed attachment for view', { viewId }); - } + this.viewController.removeAttachment(viewId); } } diff --git a/src/features/backlinks/__tests__/BacklinksCore.test.ts b/src/features/backlinks/__tests__/BacklinksCore.test.ts new file mode 100644 index 0000000..2d806e5 --- /dev/null +++ b/src/features/backlinks/__tests__/BacklinksCore.test.ts @@ -0,0 +1,258 @@ +import { BacklinksCore } from '../core/BacklinksCore'; +import { BacklinksState } from '../core/BacklinksState'; +import { BacklinksEvents } from '../core/BacklinksEvents'; +import { BacklinkDiscoveryOptions } from '../types'; +import { BacklinkDiscoverer } from '../BacklinkDiscoverer'; +import { BacklinkCache } from '../BacklinkCache'; +import { DailyNote } from '../../shared-utilities/DailyNote'; + +jest.mock('../BacklinkDiscoverer', () => { + return { + BacklinkDiscoverer: jest.fn().mockImplementation(() => ({ + discoverBacklinks: jest.fn(), + filterBacklinks: jest.fn(), + getStatistics: jest.fn().mockReturnValue({ + totalFilesChecked: 0, + filesWithBacklinks: 0, + totalBacklinksFound: 0, + resolvedBacklinks: 0, + unresolvedBacklinks: 0, + averageBacklinksPerFile: 0, + cacheHitRate: 0 + }), + cleanup: jest.fn() + })) + }; +}); + +jest.mock('../BacklinkCache', () => { + return { + BacklinkCache: jest.fn().mockImplementation(() => ({ + getCachedBacklinks: jest.fn(), + cacheBacklinks: jest.fn(), + clearCache: jest.fn(), + invalidateCache: jest.fn(), + getCacheStatistics: jest.fn().mockReturnValue({ + totalCachedFiles: 1, + cacheHits: 0, + cacheMisses: 0, + lastCleanup: new Date() + }), + cleanup: jest.fn() + })) + }; +}); + +jest.mock('../../shared-utilities/DailyNote', () => ({ + DailyNote: { + isDaily: jest.fn() + } +})); + +describe('BacklinksCore', () => { + let mockApp: any; + let mockLogger: any; + let state: BacklinksState; + let events: BacklinksEvents; + let core: BacklinksCore; + + const createCore = (options: Partial = {}) => { + const baseOptions: Partial = { + includeResolved: true, + includeUnresolved: true, + useCache: true, + cacheTimeout: 30000, + onlyDailyNotes: false, + ...options + }; + return new BacklinksCore(mockApp, mockLogger, baseOptions, state, events); + }; + + beforeEach(() => { + mockApp = { + metadataCache: { + resolvedLinks: {}, + unresolvedLinks: {} + }, + vault: { + getMarkdownFiles: jest.fn().mockReturnValue([]), + getAbstractFileByPath: jest.fn(), + read: jest.fn() + } + }; + + mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + child: jest.fn().mockReturnThis(), + logErrorWithContext: jest.fn() + }; + + state = new BacklinksState(); + events = new BacklinksEvents(mockLogger as any); + + (DailyNote.isDaily as jest.Mock).mockReset(); + (BacklinkDiscoverer as unknown as jest.Mock).mockClear(); + (BacklinkCache as unknown as jest.Mock).mockClear(); + + core = createCore(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('updateBacklinks', () => { + it('skips daily notes when onlyDailyNotes is true', async () => { + (DailyNote.isDaily as jest.Mock).mockReturnValue(true); + + const dailyCore = createCore({ onlyDailyNotes: true }); + const result = await dailyCore.updateBacklinks('2024-01-01.md'); + + expect(result).toEqual([]); + expect(DailyNote.isDaily).toHaveBeenCalledWith(mockApp, '2024-01-01.md'); + + const stateBacklinks = state.getBacklinks('2024-01-01.md'); + expect(stateBacklinks).toEqual([]); + }); + + it('uses cache when cached backlinks are available', async () => { + (DailyNote.isDaily as jest.Mock).mockReturnValue(false); + + core = createCore({ useCache: true }); + const cacheInstance = core.getBacklinkCache() as any; + const discovererInstance = core.getBacklinkDiscoverer() as any; + + cacheInstance.getCachedBacklinks.mockReturnValue(['cached1.md', 'cached2.md']); + + const result = await core.updateBacklinks('note.md'); + + expect(cacheInstance.getCachedBacklinks).toHaveBeenCalledWith('note.md'); + expect(discovererInstance.discoverBacklinks).not.toHaveBeenCalled(); + expect(result).toEqual(['cached1.md', 'cached2.md']); + + const stateBacklinks = state.getBacklinks('note.md'); + expect(stateBacklinks).toEqual(['cached1.md', 'cached2.md']); + }); + + it('discovers and caches backlinks when cache is empty', async () => { + (DailyNote.isDaily as jest.Mock).mockReturnValue(false); + + core = createCore({ useCache: true }); + const cacheInstance = core.getBacklinkCache() as any; + const discovererInstance = core.getBacklinkDiscoverer() as any; + + cacheInstance.getCachedBacklinks.mockReturnValue(null); + discovererInstance.discoverBacklinks.mockResolvedValue(['disc1.md', 'disc2.md']); + + const result = await core.updateBacklinks('note.md'); + + expect(cacheInstance.getCachedBacklinks).toHaveBeenCalledWith('note.md'); + expect(discovererInstance.discoverBacklinks).toHaveBeenCalledWith('note.md'); + expect(cacheInstance.cacheBacklinks).toHaveBeenCalledWith('note.md', ['disc1.md', 'disc2.md']); + expect(result).toEqual(['disc1.md', 'disc2.md']); + + const stateBacklinks = state.getBacklinks('note.md'); + expect(stateBacklinks).toEqual(['disc1.md', 'disc2.md']); + }); + + it('emits backlinks:updated event after updating backlinks', async () => { + (DailyNote.isDaily as jest.Mock).mockReturnValue(false); + + core = createCore({ useCache: false }); + const discovererInstance = core.getBacklinkDiscoverer() as any; + discovererInstance.discoverBacklinks.mockResolvedValue(['a.md', 'b.md']); + + const emitSpy = jest.spyOn(events, 'emitEvent'); + + await core.updateBacklinks('note.md', 'leaf-1'); + + expect(emitSpy).toHaveBeenCalledTimes(1); + const event = emitSpy.mock.calls[0][0] as any; + expect(event.type).toBe('backlinks:updated'); + expect(event.payload.files).toEqual(['a.md', 'b.md']); + expect(event.payload.leafId).toBe('leaf-1'); + expect(event.payload.count).toBe(2); + }); + }); + + describe('haveBacklinksChanged', () => { + it('returns false when backlinks are identical (order-insensitive)', () => { + state.setBacklinks('note.md', ['a.md', 'b.md']); + + const changed = core.haveBacklinksChanged('note.md', ['b.md', 'a.md']); + + expect(changed).toBe(false); + }); + + it('returns true when backlink count differs', () => { + state.setBacklinks('note.md', ['a.md']); + + const changed = core.haveBacklinksChanged('note.md', ['a.md', 'b.md']); + + expect(changed).toBe(true); + }); + + it('returns true when backlink contents differ', () => { + state.setBacklinks('note.md', ['a.md', 'b.md']); + + const changed = core.haveBacklinksChanged('note.md', ['a.md', 'c.md']); + + expect(changed).toBe(true); + }); + }); + + describe('getBacklinkMetadata', () => { + it('returns metadata based on cache statistics', () => { + const cacheInstance = core.getBacklinkCache() as any; + const lastCleanup = new Date('2024-01-01T00:00:00Z'); + cacheInstance.getCacheStatistics.mockReturnValue({ + totalCachedFiles: 5, + cacheHits: 10, + cacheMisses: 2, + lastCleanup + }); + + const metadata = core.getBacklinkMetadata(); + + expect(metadata.cacheSize).toBe(5); + expect(metadata.lastUpdated).toBe(lastCleanup); + }); + }); + + describe('clearCache / clearBacklinks', () => { + it('clearCache(filePath) invalidates only that file and clears state entry', () => { + const cacheInstance = core.getBacklinkCache() as any; + state.setBacklinks('note.md', ['a.md']); + + core.clearCache('note.md'); + + expect(cacheInstance.invalidateCache).toHaveBeenCalledWith('note.md'); + expect(state.getBacklinks('note.md')).toEqual([]); + }); + + it('clearCache() clears all cache and state', () => { + const cacheInstance = core.getBacklinkCache() as any; + state.setBacklinks('n1.md', ['a.md']); + state.setBacklinks('n2.md', ['b.md']); + + core.clearCache(); + + expect(cacheInstance.clearCache).toHaveBeenCalled(); + expect(state.getBacklinks('n1.md')).toEqual([]); + expect(state.getBacklinks('n2.md')).toEqual([]); + }); + + it('clearBacklinks() clears state and cache', () => { + const cacheInstance = core.getBacklinkCache() as any; + state.setBacklinks('note.md', ['a.md']); + + core.clearBacklinks(); + + expect(cacheInstance.clearCache).toHaveBeenCalled(); + expect(state.getBacklinks('note.md')).toEqual([]); + }); + }); +}); \ No newline at end of file diff --git a/src/features/backlinks/__tests__/BacklinksSlice.integration.test.ts b/src/features/backlinks/__tests__/BacklinksSlice.integration.test.ts index 458b5fe..4607d60 100644 --- a/src/features/backlinks/__tests__/BacklinksSlice.integration.test.ts +++ b/src/features/backlinks/__tests__/BacklinksSlice.integration.test.ts @@ -102,15 +102,14 @@ describe('BacklinksSlice Integration', () => { mockApp.metadataCache.resolvedLinks = {}; mockApp.metadataCache.unresolvedLinks = {}; - // Spy on console.log to verify the "no backlinks found" message - const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); - await backlinksSlice.attachToDOM(mockView, 'target.md', true); // forceRefresh = true - expect(consoleSpy).toHaveBeenCalledWith('Coalesce: no backlinks found, but will still render UI with message'); - expect(consoleSpy).toHaveBeenCalledWith('Coalesce: no blocks to render, adding "no backlinks" message'); + const container = mockView.containerEl.querySelector('.coalesce-custom-backlinks-container') as HTMLElement; + expect(container).toBeTruthy(); - consoleSpy.mockRestore(); + const message = container.querySelector('.coalesce-no-backlinks-message') as HTMLElement; + expect(message).toBeTruthy(); + expect(message.textContent).toContain('No backlinks'); }); it('should clear existing containers before attaching new ones', async () => { @@ -149,10 +148,8 @@ describe('BacklinksSlice Integration', () => { (mockApp.vault.read as jest.Mock).mockResolvedValue('Content with [[target]] link'); - // Create markdown preview section - const markdownSection = document.createElement('div'); - markdownSection.className = 'markdown-preview-section'; - mockView.containerEl.appendChild(markdownSection); + // Use the markdown preview section created in beforeEach + const markdownSection = mockView.containerEl.querySelector('.markdown-preview-section') as HTMLElement; await backlinksSlice.attachToDOM(mockView, 'target.md', true); // forceRefresh = true @@ -189,10 +186,13 @@ describe('BacklinksSlice Integration', () => { // Re-attach UI - should maintain collapsed state await backlinksSlice.attachToDOM(mockView, 'target.md', true); // forceRefresh = true - // Check that the collapse button reflects the collapsed state + // Check that the collapse state is reflected in the header collapse icon const collapseButton = mockView.containerEl.querySelector('.coalesce-collapse-button') as HTMLElement; expect(collapseButton).toBeTruthy(); - expect(collapseButton.classList.contains('is-collapsed')).toBe(true); + + const collapseIcon = collapseButton.querySelector('svg') as SVGElement; + expect(collapseIcon).toBeTruthy(); + expect(collapseIcon.classList.contains('is-collapsed')).toBe(true); }); }); diff --git a/src/features/backlinks/core/BacklinksCore.ts b/src/features/backlinks/core/BacklinksCore.ts new file mode 100644 index 0000000..2161613 --- /dev/null +++ b/src/features/backlinks/core/BacklinksCore.ts @@ -0,0 +1,415 @@ +import { App } from 'obsidian'; +import { Logger } from '../../shared-utilities/Logger'; +import { DailyNote } from '../../shared-utilities/DailyNote'; +import { BacklinkDiscoverer } from '../BacklinkDiscoverer'; +import { BacklinkCache } from '../BacklinkCache'; +import { + BacklinkDiscoveryOptions, + BacklinkFilterOptions, + BacklinkStatistics +} from '../types'; +import { AppWithInternalPlugins } from '../../shared-contracts/obsidian'; +import { BacklinksState } from './BacklinksState'; +import { BacklinksEvents } from './BacklinksEvents'; +import { CoalesceEvent } from '../../shared-contracts/events'; + +/** + * Core/domain service for the Backlinks feature. + * + * Responsibilities: + * - Backlink discovery and cache orchestration + * - Backlink-related state updates + * - Emitting backlinks-related events + * + * This class is intentionally free of any DOM or view concerns. + */ +export class BacklinksCore { + private options: BacklinkDiscoveryOptions; + private backlinkDiscoverer: BacklinkDiscoverer; + private backlinkCache: BacklinkCache; + + constructor( + private readonly app: App, + private readonly logger: Logger, + options: Partial | BacklinkDiscoveryOptions, + private readonly state: BacklinksState, + private readonly events: BacklinksEvents + ) { + // Normalize options with defaults + this.options = { + includeResolved: true, + includeUnresolved: true, + useCache: true, + cacheTimeout: 30000, // 30 seconds + onlyDailyNotes: false, + ...(options as Partial) + }; + + this.backlinkDiscoverer = new BacklinkDiscoverer(this.app, this.logger, this.options); + this.backlinkCache = new BacklinkCache(this.app, this.logger); + + this.logger.debug('BacklinksCore initialized', { options: this.options }); + } + + /** + * Update backlinks for a file and emit a backlinks:updated event. + */ + async updateBacklinks(filePath: string, leafId?: string): Promise { + this.logger.debug('Updating backlinks', { filePath, leafId }); + + // Check if we should skip daily notes + if ( + this.options.onlyDailyNotes && + DailyNote.isDaily(this.app as AppWithInternalPlugins, filePath) + ) { + this.logger.debug('Skipping daily note', { filePath }); + const emptyBacklinks: string[] = []; + this.state.setBacklinks(filePath, emptyBacklinks); + return emptyBacklinks; + } + + // Try to get from cache first + let backlinks: string[] = []; + let fromCache = false; + + if (this.options.useCache) { + const cachedBacklinks = this.backlinkCache.getCachedBacklinks(filePath); + if (cachedBacklinks) { + backlinks = cachedBacklinks; + fromCache = true; + this.logger.debug('Backlinks retrieved from cache', { + filePath, + count: backlinks.length + }); + } + } + + // If not from cache, discover backlinks + if (!fromCache) { + backlinks = await this.backlinkDiscoverer.discoverBacklinks(filePath); + + // Cache the results + if (this.options.useCache) { + this.backlinkCache.cacheBacklinks(filePath, backlinks); + } + + this.logger.debug('Backlinks discovered', { + filePath, + count: backlinks.length + }); + } + + // Store current backlinks in shared state + this.state.setBacklinks(filePath, backlinks); + + // Emit event (keeps existing event contract) + const event: CoalesceEvent = { + type: 'backlinks:updated', + payload: { + files: backlinks, + leafId: leafId || '', + count: backlinks.length + } + } as any; + this.events.emitEvent(event); + + this.logger.debug('Backlinks updated successfully', { + filePath, + count: backlinks.length, + fromCache + }); + + return backlinks; + } + + /** + * Get current backlinks for a file from state. + */ + getCurrentBacklinks(filePath: string): string[] { + this.logger.debug('Getting current backlinks', { filePath }); + + try { + const backlinks = this.state.getBacklinks(filePath); + + this.logger.debug('Current backlinks retrieved', { + filePath, + count: backlinks.length + }); + + return backlinks; + } catch (error) { + this.logger.error('Failed to get current backlinks', { filePath, error }); + return []; + } + } + + /** + * Discover backlinks for a given file (wrapper around updateBacklinks). + */ + async discoverBacklinks(filePath: string): Promise { + this.logger.debug('Discovering backlinks', { filePath }); + + try { + const backlinks = await this.updateBacklinks(filePath); + + this.logger.debug('Backlinks discovered successfully', { + filePath, + count: backlinks.length + }); + + return backlinks; + } catch (error) { + this.logger.error('Failed to discover backlinks', { filePath, error }); + return []; + } + } + + /** + * Get cached backlinks (from cache only, without discovery). + */ + getCachedBacklinks(filePath: string): string[] | null { + this.logger.debug('Getting cached backlinks', { filePath }); + + try { + const cachedBacklinks = this.backlinkCache.getCachedBacklinks(filePath); + + this.logger.debug('Cached backlinks retrieved', { + filePath, + found: cachedBacklinks !== null, + count: cachedBacklinks?.length || 0 + }); + + return cachedBacklinks; + } catch (error) { + this.logger.error('Failed to get cached backlinks', { filePath, error }); + return null; + } + } + + /** + * Clear cache for a specific file or for all files. + */ + clearCache(filePath?: string): void { + this.logger.debug('Clearing cache', { filePath }); + + try { + if (filePath) { + // Clear cache for specific file + this.backlinkCache.invalidateCache(filePath); + this.state.clearBacklinks(filePath); + } else { + // Clear all cache + this.backlinkCache.clearCache(); + this.state.clearBacklinks(); + } + + this.logger.debug('Cache cleared successfully', { filePath }); + } catch (error) { + this.logger.error('Failed to clear cache', { filePath, error }); + } + } + + /** + * Invalidate cache for a file. + */ + invalidateCache(filePath: string): void { + this.logger.debug('Invalidating cache', { filePath }); + + try { + this.backlinkCache.invalidateCache(filePath); + + this.logger.debug('Cache invalidated successfully', { filePath }); + } catch (error) { + this.logger.error('Failed to invalidate cache', { filePath, error }); + } + } + + /** + * Clear all backlinks in state and cache. + */ + clearBacklinks(): void { + this.logger.debug('Clearing all backlinks'); + + try { + this.state.clearBacklinks(); + this.backlinkCache.clearCache(); + + this.logger.debug('All backlinks cleared successfully'); + } catch (error) { + this.logger.error('Failed to clear backlinks', { error }); + } + } + + /** + * Get backlink metadata (cache statistics). + */ + getBacklinkMetadata(): { lastUpdated: Date; cacheSize: number } { + this.logger.debug('Getting backlink metadata'); + + try { + const cacheStats = this.backlinkCache.getCacheStatistics(); + const metadata = { + lastUpdated: cacheStats.lastCleanup || new Date(), + cacheSize: cacheStats.totalCachedFiles + }; + + this.logger.debug('Backlink metadata retrieved', metadata); + + return metadata; + } catch (error) { + this.logger.error('Failed to get backlink metadata', { error }); + + // Return default metadata on error + return { + lastUpdated: new Date(), + cacheSize: 0 + }; + } + } + + /** + * Check if backlinks have changed compared to the current state. + */ + haveBacklinksChanged(filePath: string, newBacklinks: string[]): boolean { + this.logger.debug('Checking if backlinks have changed', { + filePath, + newBacklinksCount: newBacklinks.length + }); + + try { + const currentBacklinks = this.state.getBacklinks(filePath); + + // Check if counts are different + if (currentBacklinks.length !== newBacklinks.length) { + this.logger.debug('Backlinks count changed', { + filePath, + oldCount: currentBacklinks.length, + newCount: newBacklinks.length + }); + return true; + } + + // Check if content is different + const currentSorted = [...currentBacklinks].sort(); + const newSorted = [...newBacklinks].sort(); + + for (let i = 0; i < currentSorted.length; i++) { + if (currentSorted[i] !== newSorted[i]) { + this.logger.debug('Backlinks content changed', { + filePath, + oldBacklink: currentSorted[i], + newBacklink: newSorted[i] + }); + return true; + } + } + + this.logger.debug('Backlinks unchanged', { filePath }); + return false; + } catch (error) { + this.logger.error('Failed to check if backlinks changed', { filePath, error }); + return true; // Assume changed on error + } + } + + /** + * Filter backlinks based on criteria. + */ + filterBacklinks( + backlinks: string[], + filePath: string, + options?: BacklinkFilterOptions + ): string[] { + this.logger.debug('Filtering backlinks', { + backlinkCount: backlinks.length, + filePath, + options + }); + + try { + let filteredBacklinks = [...backlinks]; + + // Apply discoverer filtering + filteredBacklinks = this.backlinkDiscoverer.filterBacklinks(filteredBacklinks, { + excludeDailyNotes: options?.excludeDailyNotes, + excludeCurrentFile: options?.excludeCurrentFile ? filePath : undefined, + sortByPath: options?.sortByPath + }); + + // Apply alias filtering if specified (not yet implemented) + if (options?.filterByAlias) { + this.logger.debug('Alias filtering requested but not implemented', { + alias: options.filterByAlias + }); + } + + this.logger.debug('Backlinks filtered successfully', { + originalCount: backlinks.length, + filteredCount: filteredBacklinks.length + }); + + return filteredBacklinks; + } catch (error) { + this.logger.error('Failed to filter backlinks', { backlinks, options, error }); + return backlinks; + } + } + + /** + * Update discovery options. + */ + updateOptions(options: Partial): void { + this.logger.debug('Updating options', { options }); + + this.options = { ...this.options, ...options }; + + // Update discoverer options + this.backlinkDiscoverer.updateOptions(options); + + this.logger.debug('Options updated successfully', { options: this.options }); + } + + /** + * Get current discovery options. + */ + getOptions(): BacklinkDiscoveryOptions { + return { ...this.options }; + } + + /** + * Get backlink discovery statistics. + */ + getStatistics(): BacklinkStatistics { + return this.backlinkDiscoverer.getStatistics(); + } + + /** + * Expose discoverer for callers that still need low-level access. + */ + getBacklinkDiscoverer(): BacklinkDiscoverer { + return this.backlinkDiscoverer; + } + + /** + * Expose cache for callers that still need low-level access. + */ + getBacklinkCache(): BacklinkCache { + return this.backlinkCache; + } + + /** + * Cleanup resources used by this core service. + */ + cleanup(): void { + this.logger.debug('Cleaning up BacklinksCore'); + + try { + this.backlinkDiscoverer.cleanup(); + this.backlinkCache.cleanup(); + + this.logger.debug('BacklinksCore cleanup completed'); + } catch (error) { + this.logger.error('Failed to cleanup BacklinksCore', { error }); + } + } +} \ No newline at end of file diff --git a/src/features/backlinks/core/BacklinksEvents.ts b/src/features/backlinks/core/BacklinksEvents.ts new file mode 100644 index 0000000..60e0862 --- /dev/null +++ b/src/features/backlinks/core/BacklinksEvents.ts @@ -0,0 +1,108 @@ +import { Logger } from '../../shared-utilities/Logger'; +import { CoalesceEvent, EventHandler } from '../../shared-contracts/events'; + +/** + * Event facade for the Backlinks feature. + * + * This class encapsulates the event handler map and exposes a small, + * focused API for emitting and subscribing to Coalesce events related + * to backlinks. + * + * It is extracted from BacklinksSlice to: + * - reduce responsibilities on the slice + * - make event behavior easier to test in isolation + * - provide a reusable abstraction for other collaborators if needed + */ +export class BacklinksEvents { + private readonly logger: Logger; + private readonly eventHandlers: Map = new Map(); + + constructor(logger: Logger) { + this.logger = logger.child('BacklinksEvents'); + this.logger.debug('BacklinksEvents initialized'); + } + + /** + * Emit an event to all registered handlers. + */ + emitEvent(event: CoalesceEvent): void { + this.logger.debug('Emitting event', { event }); + + try { + const handlers = this.eventHandlers.get(event.type) || []; + + for (const handler of handlers) { + try { + handler(event); + } catch (error) { + this.logger.error('Event handler failed', { event, error }); + } + } + + this.logger.debug('Event emitted successfully', { eventType: event.type, handlerCount: handlers.length }); + } catch (error) { + this.logger.error('Failed to emit event', { event, error }); + } + } + + /** + * Register an event listener for a specific event type. + */ + addEventListener(eventType: T['type'], handler: EventHandler): void { + this.logger.debug('Adding event listener', { eventType }); + + try { + const handlers = this.eventHandlers.get(eventType) || []; + handlers.push(handler as EventHandler); + this.eventHandlers.set(eventType, handlers); + + this.logger.debug('Event listener added successfully', { + eventType, + totalHandlers: handlers.length + }); + } catch (error) { + this.logger.error('Failed to add event listener', { eventType, error }); + } + } + + /** + * Remove a previously registered event listener. + */ + removeEventListener(eventType: T['type'], handler: EventHandler): void { + this.logger.debug('Removing event listener', { eventType }); + + try { + const handlers = this.eventHandlers.get(eventType) || []; + const index = handlers.indexOf(handler as EventHandler); + + if (index !== -1) { + handlers.splice(index, 1); + this.eventHandlers.set(eventType, handlers); + + this.logger.debug('Event listener removed successfully', { + eventType, + remainingHandlers: handlers.length + }); + } else { + this.logger.debug('Event listener not found', { eventType }); + } + } catch (error) { + this.logger.error('Failed to remove event listener', { eventType, error }); + } + } + + /** + * Clear all registered listeners for all event types. + * Intended to be called from BacklinksSlice.cleanup(). + */ + clearAllListeners(): void { + this.logger.debug('Clearing all event listeners'); + + try { + this.eventHandlers.clear(); + this.logger.debug('All event listeners cleared'); + } catch (error) { + this.logger.error('Failed to clear event listeners', { error }); + } + } +} \ No newline at end of file diff --git a/src/features/backlinks/core/BacklinksState.ts b/src/features/backlinks/core/BacklinksState.ts new file mode 100644 index 0000000..27878a0 --- /dev/null +++ b/src/features/backlinks/core/BacklinksState.ts @@ -0,0 +1,195 @@ +import { BlockData, HeaderState, HeaderStatistics } from '../types'; + +/** + * Centralized state container for the Backlinks feature. + * + * This class owns all in-memory state that used to live directly on BacklinksSlice: + * - Backlinks by file + * - Extracted blocks by current note + * - Attached view containers + * - Header UI state and statistics + * + * It is intentionally free of any Obsidian APIs or direct DOM querying + * so it stays easy to test and reuse. + */ +export interface AttachmentInfo { + container: HTMLElement; + lastUpdate: number; +} + +export class BacklinksState { + private backlinksByFile = new Map(); + private blocksByNote = new Map(); + private attachmentsByView = new Map(); + private headerState: HeaderState; + private headerStatistics: HeaderStatistics; + + constructor( + initialHeaderState?: Partial, + initialHeaderStats?: Partial + ) { + this.headerState = { + fileCount: 0, + sortByPath: false, + sortDescending: true, + isCollapsed: false, + currentStrategy: 'default', + currentTheme: 'default', + showFullPathTitle: false, + currentAlias: null, + currentHeaderStyle: 'full', + currentFilter: '', + isCompact: false, + ...initialHeaderState + }; + + this.headerStatistics = { + totalHeadersCreated: 0, + totalFilterChanges: 0, + totalSortToggles: 0, + totalCollapseToggles: 0, + totalStrategyChanges: 0, + totalThemeChanges: 0, + totalAliasSelections: 0, + totalSettingsClicks: 0, + totalHeaderStyleChanges: 0, + ...initialHeaderStats + }; + } + + // Backlinks + + setBacklinks(filePath: string, backlinks: string[]): void { + this.backlinksByFile.set(filePath, [...backlinks]); + this.headerState.fileCount = backlinks.length; + } + + getBacklinks(filePath: string): string[] { + const backlinks = this.backlinksByFile.get(filePath); + return backlinks ? [...backlinks] : []; + } + + clearBacklinks(filePath?: string): void { + if (filePath) { + this.backlinksByFile.delete(filePath); + } else { + this.backlinksByFile.clear(); + this.headerState.fileCount = 0; + } + } + + getBacklinksMap(): ReadonlyMap { + return this.backlinksByFile; + } + + // Blocks + + setBlocks(noteName: string, blocks: BlockData[]): void { + this.blocksByNote.set(noteName, [...blocks]); + } + + getBlocks(noteName: string): BlockData[] { + const blocks = this.blocksByNote.get(noteName); + return blocks ? [...blocks] : []; + } + + clearBlocks(noteName?: string): void { + if (noteName) { + this.blocksByNote.delete(noteName); + } else { + this.blocksByNote.clear(); + } + } + + getBlocksMap(): ReadonlyMap { + return this.blocksByNote; + } + + // Attachments + + setAttachment(viewId: string, attachment: AttachmentInfo): void { + this.attachmentsByView.set(viewId, attachment); + } + + getAttachment(viewId: string): AttachmentInfo | undefined { + return this.attachmentsByView.get(viewId); + } + + removeAttachment(viewId: string): void { + this.attachmentsByView.delete(viewId); + } + + clearAttachments(): void { + this.attachmentsByView.clear(); + } + + getAttachments(): ReadonlyMap { + return this.attachmentsByView; + } + + // Header state + + getHeaderState(): HeaderState { + return { ...this.headerState }; + } + + /** + * Shallow-merge the current header state with the provided partial + * and return the updated value. + */ + updateHeaderState(partial: Partial): HeaderState { + this.headerState = { + ...this.headerState, + ...partial + }; + return this.getHeaderState(); + } + + // Header statistics + + getHeaderStatistics(): HeaderStatistics { + return { ...this.headerStatistics }; + } + + /** + * Apply a mutating update function to the header statistics in a controlled way. + */ + updateHeaderStatistics(mutator: (stats: HeaderStatistics) => void): HeaderStatistics { + const copy: HeaderStatistics = { ...this.headerStatistics }; + mutator(copy); + this.headerStatistics = copy; + return this.getHeaderStatistics(); + } + + reset(): void { + this.backlinksByFile.clear(); + this.blocksByNote.clear(); + this.attachmentsByView.clear(); + + this.headerState = { + fileCount: 0, + sortByPath: false, + sortDescending: true, + isCollapsed: false, + currentStrategy: 'default', + currentTheme: 'default', + showFullPathTitle: false, + currentAlias: null, + currentHeaderStyle: 'full', + currentFilter: '', + isCompact: false + }; + + this.headerStatistics = { + totalHeadersCreated: 0, + totalFilterChanges: 0, + totalSortToggles: 0, + totalCollapseToggles: 0, + totalStrategyChanges: 0, + totalThemeChanges: 0, + totalAliasSelections: 0, + totalSettingsClicks: 0, + totalHeaderStyleChanges: 0 + }; + } +} \ No newline at end of file diff --git a/src/features/backlinks/ui/BacklinksViewController.ts b/src/features/backlinks/ui/BacklinksViewController.ts new file mode 100644 index 0000000..81032b6 --- /dev/null +++ b/src/features/backlinks/ui/BacklinksViewController.ts @@ -0,0 +1,709 @@ +import { App, TFile, MarkdownView } from 'obsidian'; +import { Logger } from '../../shared-utilities/Logger'; +import { + BlockData, + BlockRenderOptions, + BlockStatistics, + HeaderCreateOptions, + HeaderState, + HeaderStatistics +} from '../types'; +import { BacklinksCore } from '../core/BacklinksCore'; +import { BlockExtractor } from '../BlockExtractor'; +import { BlockRenderer } from '../BlockRenderer'; +import { StrategyManager } from '../StrategyManager'; +import { HeaderUI } from '../HeaderUI'; +import { FilterControls } from '../FilterControls'; +import { SettingsControls } from '../SettingsControls'; + +/** + * BacklinksViewController + * + * Owns all DOM- and view-related behaviour for the backlinks UI: + * - Attaching the backlinks container into a MarkdownView + * - Creating and updating the header + * - Extracting and rendering backlink blocks + * - Applying sort/collapse/theme/filter options to the DOM + * + * This class has no knowledge of plugin lifecycle or orchestrator wiring. + * It collaborates with BacklinksCore for data and keeps its own view state. + */ +export class BacklinksViewController { + private currentBlocks: Map = new Map(); + private renderOptions: BlockRenderOptions; + private lastRenderContext?: { + filePaths: string[]; + currentNoteName: string; + container: HTMLElement; + view: MarkdownView; + }; + private currentTheme = 'default'; + + private currentHeaders: Map = new Map(); + private headerStatistics: HeaderStatistics; + private currentHeaderState: HeaderState; + + private attachedViews: Map = new Map(); + + constructor( + private readonly app: App, + private readonly logger: Logger, + private readonly core: BacklinksCore, + private readonly blockExtractor: BlockExtractor, + private readonly blockRenderer: BlockRenderer, + private readonly strategyManager: StrategyManager, + private readonly headerUI: HeaderUI, + private readonly filterControls: FilterControls, + private readonly settingsControls: SettingsControls + ) { + // Initial render options mirror the previous BacklinksSlice defaults + this.renderOptions = { + headerStyle: 'full', + hideBacklinkLine: false, + hideFirstHeader: false, + showFullPathTitle: false, + collapsed: false, + sortByPath: false, + sortDescending: true + }; + + // Initial header state mirrors BacklinksSlice defaults + this.currentHeaderState = { + fileCount: 0, + sortByPath: false, + sortDescending: true, + isCollapsed: false, + currentStrategy: 'default', + currentTheme: 'default', + showFullPathTitle: false, + currentAlias: null, + currentHeaderStyle: 'full', + currentFilter: '', + isCompact: false + }; + + this.renderOptions.collapsed = this.currentHeaderState.isCollapsed; + + this.headerStatistics = { + totalHeadersCreated: 0, + totalFilterChanges: 0, + totalSortToggles: 0, + totalCollapseToggles: 0, + totalStrategyChanges: 0, + totalThemeChanges: 0, + totalAliasSelections: 0, + totalSettingsClicks: 0, + totalHeaderStyleChanges: 0 + }; + + this.logger.debug('BacklinksViewController initialized'); + } + + /** + * Attach the complete backlinks UI to a view. + * Returns true if UI attached, false if skipped due to recent attachment. + */ + async attachToDOM( + view: MarkdownView, + currentNotePath: string, + forceRefresh = false + ): Promise { + const viewId = (view.leaf as any).id || 'unknown'; + + this.logger.debug('BacklinksViewController.attachToDOM', { + currentNotePath, + viewId, + forceRefresh + }); + + // Check if UI is already attached and recent (within last 5 seconds), unless force refresh is requested + const existingAttachment = this.attachedViews.get(viewId); + if (!forceRefresh && existingAttachment && Date.now() - existingAttachment.lastUpdate < 5000) { + this.logger.debug('UI already attached recently, skipping', { viewId, currentNotePath }); + return false; + } + + // Clear any existing coalesce containers from the view + const existingContainers = view.contentEl.querySelectorAll('.coalesce-custom-backlinks-container'); + existingContainers.forEach(container => container.remove()); + + // Get backlinks for the current note via core + const backlinks = await this.core.updateBacklinks(currentNotePath, viewId); + + if (backlinks.length === 0) { + this.logger.debug('No backlinks found, will render UI with no backlinks message', { + currentNotePath + }); + } + + // Create main container for the backlinks UI + const container = document.createElement('div'); + container.className = 'coalesce-custom-backlinks-container'; + + // Create header + const headerElement = this.createHeader(container, { + fileCount: backlinks.length, + sortDescending: this.currentHeaderState.sortDescending, + isCollapsed: this.currentHeaderState.isCollapsed, + currentStrategy: this.currentHeaderState.currentStrategy, + currentTheme: this.currentHeaderState.currentTheme, + showFullPathTitle: false, + aliases: [], + currentAlias: null, + unsavedAliases: [], + currentHeaderStyle: this.currentHeaderState.currentHeaderStyle, + currentFilter: this.currentHeaderState.currentFilter, + onSortToggle: () => this.handleSortToggle(), + onCollapseToggle: () => this.handleCollapseToggle(), + onStrategyChange: (strategy: string) => this.handleStrategyChange(strategy), + onThemeChange: (theme: string) => this.handleThemeChange(theme), + onFullPathTitleChange: (show: boolean) => this.updateHeaderState({ showFullPathTitle: show }), + onAliasSelect: (alias: string | null) => this.handleAliasSelection(alias), + onHeaderStyleChange: (style: string) => this.handleHeaderStyleChange(style), + onFilterChange: (filterText: string) => this.handleFilterChange(filterText), + onSettingsClick: () => this.handleSettingsClick() + }); + + if (headerElement) { + container.appendChild(headerElement); + this.headerUI.updateHeader(headerElement, this.currentHeaderState); + } + + // Create blocks container + const blocksContainer = document.createElement('div'); + blocksContainer.className = 'backlinks-list'; + container.appendChild(blocksContainer); + + // Extract and render blocks + await this.extractAndRenderBlocks(backlinks, currentNotePath, blocksContainer, view); + + // Apply current theme + this.applyThemeToContainer(this.currentTheme); + + // Attach the container to the view (after the content) + this.attachContainerToView(view, container); + + // Track the attachment + this.attachedViews.set(viewId, { + container, + lastUpdate: Date.now() + }); + + this.logger.debug('Backlinks UI attached successfully (controller)', { currentNotePath }); + return true; + } + + /** + * Apply options (sort/collapse/strategy/theme/alias/filter) to the current view. + */ + setOptions(options: { + sort?: boolean; + collapsed?: boolean; + strategy?: string; + theme?: string; + alias?: string | null; + filter?: string; + }): void { + this.logger.debug('BacklinksViewController.setOptions', { options }); + + if (options.sort !== undefined) { + this.currentHeaderState.sortByPath = options.sort; + } + if (options.collapsed !== undefined) { + this.currentHeaderState.isCollapsed = options.collapsed; + this.renderOptions.collapsed = options.collapsed; + } + if (options.strategy !== undefined) { + this.currentHeaderState.currentStrategy = options.strategy; + } + if (options.theme !== undefined) { + this.currentHeaderState.currentTheme = options.theme; + this.currentTheme = options.theme; + } + if (options.alias !== undefined) { + this.currentHeaderState.currentAlias = options.alias; + } + if (options.filter !== undefined) { + this.currentHeaderState.currentFilter = options.filter; + } + + this.applyCurrentOptions(); + } + + /** + * Request focus when the view is ready. + */ + requestFocusWhenReady(leafId: string): void { + this.logger.debug('BacklinksViewController.requestFocusWhenReady', { leafId }); + + const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (activeView && activeView.file) { + setTimeout(() => { + this.logger.debug('BacklinksViewController focus requested (simplified)', { leafId }); + }, 100); + } + } + + /** + * Remove UI attachment for a specific view. + */ + removeAttachment(viewId: string): void { + const attachment = this.attachedViews.get(viewId); + if (attachment) { + if (attachment.container.parentElement) { + attachment.container.parentElement.removeChild(attachment.container); + } + this.attachedViews.delete(viewId); + this.logger.debug('BacklinksViewController removed attachment for view', { viewId }); + } + } + + /** + * Cleanup view controller resources. + */ + cleanup(): void { + this.logger.debug('BacklinksViewController.cleanup'); + this.currentBlocks.clear(); + this.currentHeaders.clear(); + this.attachedViews.clear(); + } + + // ===== Header methods ===== + + private createHeader(container: HTMLElement, options: HeaderCreateOptions): HTMLElement { + this.logger.debug('BacklinksViewController.createHeader', { options }); + + const header = this.headerUI.createHeader(container, options); + const headerId = this.generateHeaderId(container); + this.currentHeaders.set(headerId, header); + + this.updateCurrentHeaderState(options); + this.headerStatistics.totalHeadersCreated++; + + return header; + } + + private handleSortToggle(): void { + this.logger.debug('BacklinksViewController.handleSortToggle'); + + this.headerStatistics.totalSortToggles++; + this.currentHeaderState.sortDescending = !this.currentHeaderState.sortDescending; + this.currentHeaderState.sortByPath = true; + + if (this.lastRenderContext) { + this.applySortingToDOM(this.lastRenderContext.container, this.currentHeaderState.sortDescending); + } + } + + private handleCollapseToggle(): void { + this.logger.debug('BacklinksViewController.handleCollapseToggle'); + + this.headerStatistics.totalCollapseToggles++; + this.currentHeaderState.isCollapsed = !this.currentHeaderState.isCollapsed; + this.renderOptions.collapsed = this.currentHeaderState.isCollapsed; + + this.setAllBlocksCollapsed(this.currentHeaderState.isCollapsed); + + if (this.lastRenderContext) { + this.applyCollapseStateToDOM(this.lastRenderContext.container, this.currentHeaderState.isCollapsed); + } + + const event = new CustomEvent('coalesce-settings-collapse-changed', { + detail: { collapsed: this.currentHeaderState.isCollapsed } + }); + document.dispatchEvent(event); + } + + private async handleStrategyChange(strategy: string): Promise { + this.logger.debug('BacklinksViewController.handleStrategyChange', { strategy }); + + this.headerStatistics.totalStrategyChanges++; + this.currentHeaderState.currentStrategy = strategy; + this.strategyManager.setCurrentStrategy(strategy); + + if (this.lastRenderContext) { + const { filePaths, currentNoteName, container, view } = this.lastRenderContext; + await this.extractAndRenderBlocks(filePaths, currentNoteName, container, view); + } + } + + private handleThemeChange(theme: string): void { + this.logger.debug('BacklinksViewController.handleThemeChange', { theme }); + + this.headerStatistics.totalThemeChanges++; + this.currentHeaderState.currentTheme = theme; + this.currentTheme = theme; + + this.applyThemeToContainer(theme); + } + + private handleHeaderStyleChange(style: string): void { + this.logger.debug('BacklinksViewController.handleHeaderStyleChange', { style }); + + this.headerStatistics.totalHeaderStyleChanges++; + this.currentHeaderState.currentHeaderStyle = style; + this.renderOptions.headerStyle = style; + + this.updateBlockTitleDisplay(style); + } + + private handleAliasSelection(alias: string | null): void { + this.logger.debug('BacklinksViewController.handleAliasSelection', { alias }); + + this.headerStatistics.totalAliasSelections++; + this.currentHeaderState.currentAlias = alias; + + const currentNoteName = this.lastRenderContext?.currentNoteName || ''; + this.filterBlocksByAlias(currentNoteName, alias); + } + + private handleFilterChange(filterText: string): void { + this.logger.debug('BacklinksViewController.handleFilterChange', { filterText }); + + this.headerStatistics.totalFilterChanges++; + this.currentHeaderState.currentFilter = filterText; + + this.filterBlocksByText('', filterText); + } + + private handleSettingsClick(): void { + this.logger.debug('BacklinksViewController.handleSettingsClick'); + + this.headerStatistics.totalSettingsClicks++; + this.logger.debug('Settings click handled (delegated elsewhere)'); + } + + private updateHeaderState(state: Partial): void { + this.logger.debug('BacklinksViewController.updateHeaderState', { state }); + this.currentHeaderState = { ...this.currentHeaderState, ...state }; + } + + private updateCurrentHeaderState(options: HeaderCreateOptions): void { + this.currentHeaderState = { + fileCount: options.fileCount, + sortByPath: this.currentHeaderState.sortByPath, + sortDescending: this.currentHeaderState.sortDescending, + isCollapsed: options.isCollapsed, + currentStrategy: options.currentStrategy, + currentTheme: options.currentTheme, + showFullPathTitle: options.showFullPathTitle, + currentAlias: options.currentAlias, + currentHeaderStyle: options.currentHeaderStyle, + currentFilter: options.currentFilter, + isCompact: this.currentHeaderState.isCompact + }; + } + + private generateHeaderId(container: HTMLElement): string { + return `header-${container.id || 'unknown'}-${Date.now()}`; + } + + // ===== View / DOM helpers ===== + + private attachContainerToView(view: MarkdownView, container: HTMLElement): void { + this.logger.debug('BacklinksViewController.attachContainerToView', { filePath: view.file?.path }); + + const markdownSection = view.containerEl.querySelector('.markdown-preview-section') as HTMLElement; + if (markdownSection) { + markdownSection.insertAdjacentElement('afterend', container); + container.style.minHeight = '50px'; + container.style.display = 'block'; + container.style.visibility = 'visible'; + } else { + this.logger.error('Could not find .markdown-preview-section for attachment'); + } + } + + private async extractAndRenderBlocks( + filePaths: string[], + currentNoteName: string, + container: HTMLElement, + view?: MarkdownView + ): Promise { + this.logger.debug('BacklinksViewController.extractAndRenderBlocks', { + filePathCount: filePaths.length, + currentNoteName + }); + + this.lastRenderContext = { + filePaths, + currentNoteName, + container, + view: view || (this.app.workspace.getActiveViewOfType(MarkdownView) as MarkdownView) + }; + + let allBlocks: BlockData[] = []; + + for (const filePath of filePaths) { + this.logger.debug('Processing backlink file', { filePath }); + const file = this.app.vault.getAbstractFileByPath(filePath); + if (file && file instanceof TFile) { + const content = await this.app.vault.read(file); + this.logger.debug('File content loaded', { filePath, contentLength: content.length }); + const blocks = await this.blockExtractor.extractBlocks( + content, + currentNoteName, + this.strategyManager.getCurrentStrategy() + ); + this.logger.debug('Blocks extracted from file', { filePath, blockCount: blocks.length }); + + blocks.forEach(block => { + block.sourcePath = filePath; + }); + + allBlocks.push(...blocks); + } else { + this.logger.warn('File not found or not TFile', { + filePath, + fileType: (file as any)?.constructor?.name + }); + } + } + + this.logger.debug('Total blocks extracted from all files', { totalBlocks: allBlocks.length }); + + allBlocks.forEach(block => { + block.isCollapsed = this.renderOptions.collapsed; + }); + + this.currentBlocks.set(currentNoteName, allBlocks); + + if (this.renderOptions.sortByPath) { + allBlocks = this.sortBlocks(allBlocks, { + by: 'path', + descending: this.renderOptions.sortDescending + }); + } + + await this.blockRenderer.renderBlocks( + container, + allBlocks, + this.renderOptions, + currentNoteName, + this.strategyManager.getCurrentStrategy(), + undefined, + this.lastRenderContext.view + ); + + if (allBlocks.length === 0) { + this.logger.debug('No blocks to render, adding no backlinks message'); + this.addNoBacklinksMessage(container); + } + + this.applyThemeToContainer(this.currentTheme); + } + + private applySortingToDOM(container: HTMLElement, descending: boolean): void { + const linksContainer = container.classList.contains('backlinks-list') + ? container + : (container.querySelector('.backlinks-list') as HTMLElement); + if (!linksContainer) return; + + const blockContainers = Array.from( + linksContainer.querySelectorAll('.coalesce-backlink-item') + ); + + blockContainers.sort((a, b) => { + const pathA = (a as HTMLElement).getAttribute('data-path') || ''; + const pathB = (b as HTMLElement).getAttribute('data-path') || ''; + const fileNameA = pathA.split('/').pop() || ''; + const fileNameB = pathB.split('/').pop() || ''; + const comparison = fileNameA.localeCompare(fileNameB); + return descending ? -comparison : comparison; + }); + + blockContainers.forEach(block => { + linksContainer.appendChild(block); + }); + } + + private applyCollapseStateToDOM(container: HTMLElement, collapsed: boolean): void { + let blockContainers: NodeListOf = container.querySelectorAll('.coalesce-backlink-item'); + + if (blockContainers.length === 0) { + const backlinksList = + container.querySelector('.backlinks-list') || + document.querySelector('.backlinks-list'); + if (backlinksList) { + blockContainers = backlinksList.querySelectorAll('.coalesce-backlink-item'); + } + } + + blockContainers.forEach(blockContainer => { + const blockElement = blockContainer as HTMLElement; + + if (collapsed) { + blockElement.classList.add('is-collapsed'); + } else { + blockElement.classList.remove('is-collapsed'); + } + + const toggleArrow = blockElement.querySelector( + '.coalesce-toggle-arrow' + ) as HTMLElement; + if (toggleArrow) { + toggleArrow.textContent = collapsed ? '▶' : '▼'; + } + }); + } + + private applyThemeToContainer(theme: string): void { + if (!this.lastRenderContext) return; + const { container } = this.lastRenderContext; + + container.classList.forEach((className: string) => { + if (className.startsWith('theme-')) { + container.classList.remove(className); + } + }); + + container.classList.add(`theme-${theme}`); + } + + private updateBlockTitleDisplay(headerStyle: string): void { + this.logger.debug('BacklinksViewController.updateBlockTitleDisplay', { headerStyle }); + this.renderOptions.headerStyle = headerStyle; + this.blockRenderer.updateBlockTitleDisplay(headerStyle); + } + + private filterBlocksByAlias(currentNoteName: string, alias: string | null): void { + this.logger.debug('BacklinksViewController.filterBlocksByAlias', { + currentNoteName, + alias + }); + + const blocks = this.getCurrentBlocks(currentNoteName); + this.blockRenderer.filterBlocksByAlias(blocks, alias, currentNoteName); + } + + private filterBlocksByText(currentNoteName: string, filterText: string): void { + this.logger.debug('BacklinksViewController.filterBlocksByText', { + currentNoteName, + filterText + }); + + this.renderOptions.filterText = filterText; + + if (this.lastRenderContext) { + const { container } = this.lastRenderContext; + this.applyTextFilterToDOM(container, filterText); + } + } + + private applyTextFilterToDOM(container: HTMLElement, filterText: string): void { + const blockContainers = container.querySelectorAll('.coalesce-backlink-item'); + + blockContainers.forEach(blockContainer => { + const blockElement = blockContainer as HTMLElement; + const content = blockElement.textContent || ''; + const title = + blockElement.querySelector('.coalesce-block-title')?.textContent || ''; + + const contentMatch = content.toLowerCase().includes(filterText.toLowerCase()); + const titleMatch = title.toLowerCase().includes(filterText.toLowerCase()); + const matchesFilter = !filterText || contentMatch || titleMatch; + + if (matchesFilter) { + blockElement.classList.add('has-alias'); + blockElement.classList.remove('no-alias'); + } else { + blockElement.classList.add('no-alias'); + blockElement.classList.remove('has-alias'); + } + }); + } + + private setAllBlocksCollapsed(collapsed: boolean): void { + this.renderOptions.collapsed = collapsed; + + for (const blocks of this.currentBlocks.values()) { + this.blockRenderer.updateBlockCollapsedState(blocks, collapsed); + } + } + + private sortBlocks(blocks: any[], sort: { by?: string; descending: boolean }): any[] { + const sortedBlocks = [...blocks].sort((a, b) => { + let comparison = 0; + + switch (sort.by) { + case 'path': { + const fileNameA = a.sourcePath?.split('/').pop() || ''; + const fileNameB = b.sourcePath?.split('/').pop() || ''; + comparison = fileNameA.localeCompare(fileNameB); + break; + } + case 'heading': + comparison = (a.heading || '').localeCompare(b.heading || ''); + break; + default: + comparison = 0; + } + + return sort.descending ? -comparison : comparison; + }); + + return sortedBlocks; + } + + private getCurrentBlocks(currentNoteName: string): BlockData[] { + return this.currentBlocks.get(currentNoteName) || []; + } + + private addNoBacklinksMessage(container: HTMLElement): void { + const messageElement = document.createElement('div'); + messageElement.className = 'coalesce-no-backlinks-message'; + messageElement.textContent = 'No backlinks found for this note.'; + container.appendChild(messageElement); + } + + private applyCurrentOptions(): void { + if (!this.lastRenderContext) return; + const { container } = this.lastRenderContext; + + this.applyThemeToContainer(this.currentTheme); + this.applyCollapseStateToDOM(container, this.currentHeaderState.isCollapsed); + + if (this.currentHeaderState.sortByPath) { + this.applySortingToDOM(container, this.currentHeaderState.sortDescending); + } + + if (this.currentHeaderState.currentFilter) { + this.applyTextFilterToDOM(container, this.currentHeaderState.currentFilter); + } + } + + // ===== Block statistics helper (used by BacklinksSlice if needed) ===== + + getBlockStatistics(): BlockStatistics { + const extractorStats = (this.blockExtractor as any).getStatistics?.() || { + totalBlocksExtracted: 0, + totalExtractions: 0, + lastExtractionTime: undefined + }; + const rendererStats = this.blockRenderer.getStatistics(); + + return { + totalBlocksExtracted: extractorStats.totalBlocksExtracted || 0, + totalBlocksRendered: rendererStats.totalBlocksRendered, + blocksHidden: 0, + blocksCollapsed: this.renderOptions.collapsed + ? this.getCurrentBlocks('').length + : 0, + averageBlockSize: + extractorStats.totalBlocksExtracted > 0 + ? extractorStats.totalBlocksExtracted / + (extractorStats.totalExtractions || 1) + : 0, + lastExtractionTime: extractorStats.lastExtractionTime, + lastRenderTime: rendererStats.lastRenderTime + }; + } + + getHeaderStatistics(): HeaderStatistics { + return { ...this.headerStatistics }; + } + + getHeaderState(): HeaderState { + return { ...this.currentHeaderState }; + } +} \ No newline at end of file