mirror of
https://github.com/bfloydd/coalesce.git
synced 2026-07-22 05:49:31 +00:00
phase 2 refactor
This commit is contained in:
parent
fc28db3c80
commit
7578c96722
10 changed files with 1901 additions and 449 deletions
|
|
@ -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"] }]
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
258
src/features/backlinks/__tests__/BacklinksCore.test.ts
Normal file
258
src/features/backlinks/__tests__/BacklinksCore.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
415
src/features/backlinks/core/BacklinksCore.ts
Normal file
415
src/features/backlinks/core/BacklinksCore.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
108
src/features/backlinks/core/BacklinksEvents.ts
Normal file
108
src/features/backlinks/core/BacklinksEvents.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
195
src/features/backlinks/core/BacklinksState.ts
Normal file
195
src/features/backlinks/core/BacklinksState.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
709
src/features/backlinks/ui/BacklinksViewController.ts
Normal file
709
src/features/backlinks/ui/BacklinksViewController.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue