phase 2 refactor

This commit is contained in:
Ben Floyd 2025-11-17 19:00:32 -07:00
parent fc28db3c80
commit 7578c96722
10 changed files with 1901 additions and 449 deletions

View file

@ -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"] }]
}
}

View file

@ -96,4 +96,16 @@ export class WorkspaceLeaf {
}
// Export other commonly used types
export type { Plugin, Component } from 'obsidian';
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 <svg> 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);
}

View file

@ -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<string, string> }
) {
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<string, string>; 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<string, string> }
) {
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));

View file

@ -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<string, { container: HTMLElement; lastUpdate: number }> = new Map();
// View controller (new UI layer)
private viewController: BacklinksViewController;
constructor(app: App, options?: Partial<BacklinkDiscoveryOptions>) {
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<string[]> {
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<BacklinkDiscoveryOptions>): 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<boolean> {
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<boolean> {
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<T extends CoalesceEvent>(eventType: T['type'], handler: EventHandler<T>): 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<T extends CoalesceEvent>(eventType: T['type'], handler: EventHandler<T>): 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);
}
}

View file

@ -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<BacklinkDiscoveryOptions> = {}) => {
const baseOptions: Partial<BacklinkDiscoveryOptions> = {
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([]);
});
});
});

View file

@ -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);
});
});

View file

@ -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> | 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<BacklinkDiscoveryOptions>)
};
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<string[]> {
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<string[]> {
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<BacklinkDiscoveryOptions>): 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 });
}
}
}

View file

@ -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<string, EventHandler[]> = 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<T extends CoalesceEvent>(eventType: T['type'], handler: EventHandler<T>): 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<T extends CoalesceEvent>(eventType: T['type'], handler: EventHandler<T>): 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 });
}
}
}

View file

@ -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<string, string[]>();
private blocksByNote = new Map<string, BlockData[]>();
private attachmentsByView = new Map<string, AttachmentInfo>();
private headerState: HeaderState;
private headerStatistics: HeaderStatistics;
constructor(
initialHeaderState?: Partial<HeaderState>,
initialHeaderStats?: Partial<HeaderStatistics>
) {
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<string, string[]> {
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<string, BlockData[]> {
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<string, AttachmentInfo> {
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>): 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
};
}
}

View file

@ -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<string, BlockData[]> = new Map();
private renderOptions: BlockRenderOptions;
private lastRenderContext?: {
filePaths: string[];
currentNoteName: string;
container: HTMLElement;
view: MarkdownView;
};
private currentTheme = 'default';
private currentHeaders: Map<string, HTMLElement> = new Map();
private headerStatistics: HeaderStatistics;
private currentHeaderState: HeaderState;
private attachedViews: Map<string, { container: HTMLElement; lastUpdate: number }> = 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<boolean> {
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<void> {
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<HeaderState>): 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<void> {
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<Element> = 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 };
}
}