mirror of
https://github.com/mnaoumov/obsidian-backlink-full-path.git
synced 2026-07-22 05:41:39 +00:00
refactor: new template
This commit is contained in:
parent
338744bd46
commit
6f0f2b8d4e
11 changed files with 588 additions and 671 deletions
640
package-lock.json
generated
640
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
package.json
16
package.json
|
|
@ -37,6 +37,8 @@
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@vitest/coverage-v8": "$@vitest/coverage-v8",
|
"@vitest/coverage-v8": "$@vitest/coverage-v8",
|
||||||
"eslint": "$eslint",
|
"eslint": "$eslint",
|
||||||
|
"js-yaml": "^4.2.0",
|
||||||
|
"markdown-it": "^14.2.0",
|
||||||
"type-fest": "^5.6.0",
|
"type-fest": "^5.6.0",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
|
|
@ -44,23 +46,23 @@
|
||||||
"@commitlint/cli": "^21.0.2",
|
"@commitlint/cli": "^21.0.2",
|
||||||
"@commitlint/config-conventional": "^21.0.2",
|
"@commitlint/config-conventional": "^21.0.2",
|
||||||
"@commitlint/types": "^21.0.1",
|
"@commitlint/types": "^21.0.1",
|
||||||
"@obsidian-typings/obsidian-public-latest": "^6.14.0",
|
"@obsidian-typings/obsidian-public-latest": "^6.15.0",
|
||||||
"@total-typescript/ts-reset": "^0.6.1",
|
"@total-typescript/ts-reset": "^0.6.1",
|
||||||
"@tsconfig/strictest": "^2.0.8",
|
"@tsconfig/strictest": "^2.0.8",
|
||||||
"@types/node": "^25.9.3",
|
"@types/node": "^25.9.3",
|
||||||
"@vitest/coverage-v8": "^4.1.8",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
"better-typescript-lib": "^2.12.0",
|
"better-typescript-lib": "^2.12.0",
|
||||||
"czg": "^1.13.1",
|
"czg": "^1.13.1",
|
||||||
"eslint": "^10.4.1",
|
"eslint": "^10.5.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jiti": "^2.7.0",
|
"jiti": "^2.7.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"nano-staged": "^1.0.2",
|
"nano-staged": "^1.0.2",
|
||||||
"obsidian": "^1.13.1",
|
"obsidian": "^1.13.1",
|
||||||
"obsidian-dev-utils": "^70.2.0",
|
"obsidian-dev-utils": "^72.0.0",
|
||||||
"obsidian-integration-testing": "^4.2.3",
|
"obsidian-integration-testing": "^4.2.6",
|
||||||
"obsidian-test-mocks": "^3.1.0",
|
"obsidian-test-mocks": "^3.1.1",
|
||||||
"sass-embedded": "^1.100.0",
|
"sass-embedded": "^1.100.0",
|
||||||
"vitest": "^4.1.8"
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
201
src/backlink-full-path-component.ts
Normal file
201
src/backlink-full-path-component.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import type { BacklinkView } from '@obsidian-typings/obsidian-public-latest';
|
||||||
|
import type {
|
||||||
|
App,
|
||||||
|
TFile
|
||||||
|
} from 'obsidian';
|
||||||
|
|
||||||
|
import {
|
||||||
|
InternalPluginName,
|
||||||
|
ViewType
|
||||||
|
} from '@obsidian-typings/obsidian-public-latest/implementations';
|
||||||
|
import {
|
||||||
|
MarkdownView,
|
||||||
|
setTooltip
|
||||||
|
} from 'obsidian';
|
||||||
|
import { invokeAsyncSafely } from 'obsidian-dev-utils/async';
|
||||||
|
import { getPrototypeOf } from 'obsidian-dev-utils/object-utils';
|
||||||
|
import { LayoutReadyComponent } from 'obsidian-dev-utils/obsidian/components/layout-ready-component';
|
||||||
|
import { MonkeyAroundComponent } from 'obsidian-dev-utils/obsidian/components/monkey-around-component';
|
||||||
|
|
||||||
|
import { PluginSettingsComponent } from './plugin-settings-component.ts';
|
||||||
|
|
||||||
|
interface BacklinkFullPathComponentConstructorParams {
|
||||||
|
readonly app: App;
|
||||||
|
readonly pluginSettingsComponent: PluginSettingsComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BacklinkFullPathComponent extends LayoutReadyComponent {
|
||||||
|
private readonly pluginSettingsComponent: PluginSettingsComponent;
|
||||||
|
|
||||||
|
public constructor(params: BacklinkFullPathComponentConstructorParams) {
|
||||||
|
super(params.app);
|
||||||
|
|
||||||
|
this.pluginSettingsComponent = params.pluginSettingsComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async onLayoutReady(): Promise<void> {
|
||||||
|
this.pluginSettingsComponent.on('saveSettings', async () => {
|
||||||
|
await this.refreshBacklinkPanels();
|
||||||
|
});
|
||||||
|
|
||||||
|
const backlinksCorePlugin = this.app.internalPlugins.getPluginById(InternalPluginName.Backlink);
|
||||||
|
if (!backlinksCorePlugin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = this.addChild(new MonkeyAroundComponent());
|
||||||
|
|
||||||
|
patch.registerMethodPatch({
|
||||||
|
methodName: 'onUserEnable',
|
||||||
|
obj: getPrototypeOf(backlinksCorePlugin.instance),
|
||||||
|
patchHandler: ({
|
||||||
|
fallback
|
||||||
|
}) => {
|
||||||
|
fallback();
|
||||||
|
this.onBacklinksCorePluginEnable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (backlinksCorePlugin.enabled) {
|
||||||
|
await this.patchBacklinksPane();
|
||||||
|
await this.refreshBacklinkPanels();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.register(() => {
|
||||||
|
invokeAsyncSafely(async () => {
|
||||||
|
await this.refreshBacklinkPanels();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateBacklinkTitle(file: TFile): HTMLDivElement {
|
||||||
|
const fileNamePart = this.pluginSettingsComponent.settings.shouldIncludeExtension ? file.name : file.basename;
|
||||||
|
|
||||||
|
let parentPathParts = file.path.split('/').slice(0, -1);
|
||||||
|
|
||||||
|
for (let length = parentPathParts.length; length >= 1; length--) {
|
||||||
|
const rootPath = parentPathParts.slice(0, length).join('/');
|
||||||
|
if (this.pluginSettingsComponent.settings.rootPaths.includes(rootPath)) {
|
||||||
|
parentPathParts = parentPathParts.slice(length);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pluginSettingsComponent.settings.pathDepth > 0) {
|
||||||
|
const partsToSkipCount = Math.max(0, parentPathParts.length - this.pluginSettingsComponent.settings.pathDepth + 1);
|
||||||
|
if (partsToSkipCount > 0) {
|
||||||
|
parentPathParts.splice(0, partsToSkipCount);
|
||||||
|
if (this.pluginSettingsComponent.settings.shouldShowEllipsisForSkippedPathParts) {
|
||||||
|
parentPathParts.unshift('...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pluginSettingsComponent.settings.shouldReversePathParts) {
|
||||||
|
parentPathParts.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathSeparator = this.pluginSettingsComponent.settings.shouldReversePathParts ? ' \u2190 ' : '/';
|
||||||
|
const parentStr = parentPathParts.join(pathSeparator);
|
||||||
|
|
||||||
|
const container = createDiv({
|
||||||
|
cls: ['backlink-full-path', 'backlink-control']
|
||||||
|
});
|
||||||
|
container.dataset['shouldHighlightFileName'] = this.pluginSettingsComponent.settings.shouldHighlightFileName.toString();
|
||||||
|
container.dataset['shouldDisplayParentPathOnSeparateLine'] = this.pluginSettingsComponent.settings.shouldDisplayParentPathOnSeparateLine.toString();
|
||||||
|
container.createSpan({
|
||||||
|
cls: 'full-path',
|
||||||
|
text: file.path
|
||||||
|
});
|
||||||
|
const shadowRoot = container.attachShadow({ mode: 'open' });
|
||||||
|
setTooltip(container, file.path);
|
||||||
|
shadowRoot.createSpan({
|
||||||
|
attr: {
|
||||||
|
part: 'file-name'
|
||||||
|
},
|
||||||
|
text: fileNamePart
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parentStr) {
|
||||||
|
let text = parentStr;
|
||||||
|
if (!this.pluginSettingsComponent.settings.shouldDisplayParentPathOnSeparateLine) {
|
||||||
|
text = this.pluginSettingsComponent.settings.shouldReversePathParts ? pathSeparator + text : text + pathSeparator;
|
||||||
|
}
|
||||||
|
shadowRoot.createSpan({
|
||||||
|
attr: {
|
||||||
|
part: 'parent-path'
|
||||||
|
},
|
||||||
|
prepend: !this.pluginSettingsComponent.settings.shouldReversePathParts && !this.pluginSettingsComponent.settings.shouldDisplayParentPathOnSeparateLine,
|
||||||
|
text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getBacklinkView(): Promise<BacklinkView | null> {
|
||||||
|
const backlinksLeaf = this.app.workspace.getLeavesOfType(ViewType.Backlink)[0];
|
||||||
|
if (!backlinksLeaf) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await backlinksLeaf.loadIfDeferred();
|
||||||
|
return backlinksLeaf.view as BacklinkView;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onBacklinksCorePluginEnable(): void {
|
||||||
|
invokeAsyncSafely(() => this.patchBacklinksPane());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async patchBacklinksPane(): Promise<void> {
|
||||||
|
const backlinkView = await this.getBacklinkView();
|
||||||
|
if (!backlinkView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = this.addChild(new MonkeyAroundComponent());
|
||||||
|
|
||||||
|
patch.registerMethodPatch({
|
||||||
|
methodName: 'addResult',
|
||||||
|
obj: getPrototypeOf(backlinkView.backlink.backlinkDom),
|
||||||
|
patchHandler: ({
|
||||||
|
fallback,
|
||||||
|
originalArgs: [file]
|
||||||
|
}) => {
|
||||||
|
const resultDomItem = fallback();
|
||||||
|
const fileNameCaptionEl = resultDomItem.el.querySelector('.tree-item-inner');
|
||||||
|
if (fileNameCaptionEl) {
|
||||||
|
fileNameCaptionEl.empty();
|
||||||
|
fileNameCaptionEl.appendChild(this.generateBacklinkTitle(file));
|
||||||
|
}
|
||||||
|
return resultDomItem;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshBacklinkPanels(): Promise<void> {
|
||||||
|
await this.reloadBacklinksView();
|
||||||
|
|
||||||
|
for (const leaf of this.app.workspace.getLeavesOfType(ViewType.Markdown)) {
|
||||||
|
if (!(leaf.view instanceof MarkdownView)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!leaf.view.backlinks) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
leaf.view.backlinks.recomputeBacklink(leaf.view.backlinks.file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reloadBacklinksView(): Promise<void> {
|
||||||
|
const backlinkView = await this.getBacklinkView();
|
||||||
|
if (!backlinkView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (backlinkView.file) {
|
||||||
|
backlinkView.backlink.recomputeBacklink(backlinkView.file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,3 @@
|
||||||
/**
|
|
||||||
* @file
|
|
||||||
*
|
|
||||||
* Tests for the main module default export.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
describe,
|
describe,
|
||||||
expect,
|
expect,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,3 @@
|
||||||
/**
|
|
||||||
* @file
|
|
||||||
*
|
|
||||||
* Settings component that manages plugin settings persistence.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { DataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
|
import type { DataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
|
||||||
import type { PluginEventSource } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
|
import type { PluginEventSource } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,3 @@
|
||||||
/**
|
|
||||||
* @file
|
|
||||||
*
|
|
||||||
* Tests for the PluginSettingsTab class.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { PluginManifest } from 'obsidian';
|
import type { PluginManifest } from 'obsidian';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -21,15 +15,10 @@ import { PluginSettingsTab } from './plugin-settings-tab.ts';
|
||||||
import { Plugin } from './plugin.ts';
|
import { Plugin } from './plugin.ts';
|
||||||
|
|
||||||
interface DisplayedTabResult {
|
interface DisplayedTabResult {
|
||||||
names: string[];
|
readonly names: string[];
|
||||||
tab: PluginSettingsTab;
|
readonly tab: PluginSettingsTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a PluginSettingsTab attached to a freshly created Plugin and calls display().
|
|
||||||
*
|
|
||||||
* @returns An object with the tab and the extracted setting names.
|
|
||||||
*/
|
|
||||||
function createDisplayedTab(): DisplayedTabResult {
|
function createDisplayedTab(): DisplayedTabResult {
|
||||||
const plugin = createPlugin();
|
const plugin = createPlugin();
|
||||||
const pluginSettingsComponent = Reflect.get(plugin, 'pluginSettingsComponent') as InstanceType<
|
const pluginSettingsComponent = Reflect.get(plugin, 'pluginSettingsComponent') as InstanceType<
|
||||||
|
|
@ -41,17 +30,11 @@ function createDisplayedTab(): DisplayedTabResult {
|
||||||
pluginSettingsComponent
|
pluginSettingsComponent
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-deprecated -- Not ready to migrate `display()`.
|
tab.displayLegacy();
|
||||||
tab.display();
|
|
||||||
const names = getSettingNames(tab.containerEl);
|
const names = getSettingNames(tab.containerEl);
|
||||||
return { names, tab };
|
return { names, tab };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a Plugin instance with mocked dependencies.
|
|
||||||
*
|
|
||||||
* @returns The created Plugin.
|
|
||||||
*/
|
|
||||||
function createPlugin(): Plugin {
|
function createPlugin(): Plugin {
|
||||||
const app = App.createConfigured__();
|
const app = App.createConfigured__();
|
||||||
const manifest: PluginManifest = {
|
const manifest: PluginManifest = {
|
||||||
|
|
@ -65,15 +48,6 @@ function createPlugin(): Plugin {
|
||||||
return new Plugin(app.asOriginalType__(), manifest);
|
return new Plugin(app.asOriginalType__(), manifest);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts setting names from the tab's containerEl after calling display().
|
|
||||||
*
|
|
||||||
* Each Setting mock creates a settingEl > [controlEl, infoEl > [nameEl, descEl]].
|
|
||||||
* The nameEl is the second-level div that contains the setting name text.
|
|
||||||
*
|
|
||||||
* @param containerEl - The container element.
|
|
||||||
* @returns The array of setting name strings.
|
|
||||||
*/
|
|
||||||
function getSettingNames(containerEl: HTMLElement): string[] {
|
function getSettingNames(containerEl: HTMLElement): string[] {
|
||||||
const names: string[] = [];
|
const names: string[] = [];
|
||||||
for (const settingEl of containerEl.children) {
|
for (const settingEl of containerEl.children) {
|
||||||
|
|
@ -90,10 +64,6 @@ function getSettingNames(containerEl: HTMLElement): string[] {
|
||||||
return names;
|
return names;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Patches missing mock properties on obsidian-test-mocks components
|
|
||||||
* that obsidian-dev-utils's `bind()` method probes via strict proxy.
|
|
||||||
*/
|
|
||||||
function patchMissingMockProperties(): void {
|
function patchMissingMockProperties(): void {
|
||||||
// Obsidian-dev-utils checks for setPlaceholderValue to detect text-based components.
|
// Obsidian-dev-utils checks for setPlaceholderValue to detect text-based components.
|
||||||
if (!('setPlaceholderValue' in ToggleComponent.prototype)) {
|
if (!('setPlaceholderValue' in ToggleComponent.prototype)) {
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,8 @@ import { SettingEx } from 'obsidian-dev-utils/obsidian/setting-ex';
|
||||||
import type { PluginSettings } from './plugin-settings.ts';
|
import type { PluginSettings } from './plugin-settings.ts';
|
||||||
|
|
||||||
export class PluginSettingsTab extends PluginSettingsTabBase<PluginSettings> {
|
export class PluginSettingsTab extends PluginSettingsTabBase<PluginSettings> {
|
||||||
public override display(): void {
|
public override displayLegacy(): void {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-deprecated -- Not ready to migrate `display()`.
|
super.displayLegacy();
|
||||||
super.display();
|
|
||||||
|
|
||||||
new Setting(this.containerEl)
|
new Setting(this.containerEl)
|
||||||
.setName('Include extension')
|
.setName('Include extension')
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,3 @@
|
||||||
/**
|
|
||||||
* @file
|
|
||||||
*
|
|
||||||
* Tests for PluginSettings default values.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
describe,
|
describe,
|
||||||
expect,
|
expect,
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,15 @@
|
||||||
/**
|
|
||||||
* @file
|
|
||||||
*
|
|
||||||
* Plugin settings data class with default values.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Settings for the Backlink Full Path plugin.
|
|
||||||
*/
|
|
||||||
export class PluginSettings {
|
export class PluginSettings {
|
||||||
/**
|
|
||||||
* The depth of the path to include in backlinks (0 for unlimited).
|
|
||||||
*/
|
|
||||||
public pathDepth = 0;
|
public pathDepth = 0;
|
||||||
|
|
||||||
/**
|
|
||||||
* The paths to be treated as root paths.
|
|
||||||
*/
|
|
||||||
public rootPaths: string[] = [];
|
public rootPaths: string[] = [];
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to display the parent path on a separate line.
|
|
||||||
*/
|
|
||||||
public shouldDisplayParentPathOnSeparateLine = false;
|
public shouldDisplayParentPathOnSeparateLine = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to highlight the file name.
|
|
||||||
*/
|
|
||||||
public shouldHighlightFileName = true;
|
public shouldHighlightFileName = true;
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to include the file extension in backlinks.
|
|
||||||
*/
|
|
||||||
public shouldIncludeExtension = true;
|
public shouldIncludeExtension = true;
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to reverse the path parts.
|
|
||||||
*/
|
|
||||||
public shouldReversePathParts = false;
|
public shouldReversePathParts = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to show ellipsis for skipped path parts.
|
|
||||||
*/
|
|
||||||
public shouldShowEllipsisForSkippedPathParts = true;
|
public shouldShowEllipsisForSkippedPathParts = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,3 @@
|
||||||
/**
|
|
||||||
* @file
|
|
||||||
*
|
|
||||||
* Tests for the Plugin class.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BacklinkView,
|
BacklinkView,
|
||||||
ResultDom,
|
ResultDom,
|
||||||
|
|
@ -48,16 +42,10 @@ interface SettingsComponentProxy {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TestPluginResult {
|
interface TestPluginResult {
|
||||||
app: ReturnType<typeof App.createConfigured__>;
|
readonly app: ReturnType<typeof App.createConfigured__>;
|
||||||
plugin: Plugin;
|
readonly plugin: Plugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a mock TFile for testing.
|
|
||||||
*
|
|
||||||
* @param path - The file path.
|
|
||||||
* @returns A mock TFile.
|
|
||||||
*/
|
|
||||||
function createMockFile(path: string): TFile {
|
function createMockFile(path: string): TFile {
|
||||||
const parts = path.split('/');
|
const parts = path.split('/');
|
||||||
const name = parts.at(-1) ?? '';
|
const name = parts.at(-1) ?? '';
|
||||||
|
|
@ -72,12 +60,6 @@ function createMockFile(path: string): TFile {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a mock ResultDomItem with an element that has .tree-item-inner.
|
|
||||||
*
|
|
||||||
* @param hasTreeItemInner - Whether the result element should have a .tree-item-inner child.
|
|
||||||
* @returns The mock ResultDomItem.
|
|
||||||
*/
|
|
||||||
function createMockResultDomItem(hasTreeItemInner: boolean): ResultDomItem {
|
function createMockResultDomItem(hasTreeItemInner: boolean): ResultDomItem {
|
||||||
const el = activeDocument.createElement('div');
|
const el = activeDocument.createElement('div');
|
||||||
if (hasTreeItemInner) {
|
if (hasTreeItemInner) {
|
||||||
|
|
@ -89,11 +71,6 @@ function createMockResultDomItem(hasTreeItemInner: boolean): ResultDomItem {
|
||||||
return mockProxy<ResultDomItem>({ el });
|
return mockProxy<ResultDomItem>({ el });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a minimal PluginManifest for testing.
|
|
||||||
*
|
|
||||||
* @returns A test PluginManifest.
|
|
||||||
*/
|
|
||||||
function createTestManifest(): PluginManifest {
|
function createTestManifest(): PluginManifest {
|
||||||
return {
|
return {
|
||||||
author: 'test',
|
author: 'test',
|
||||||
|
|
@ -105,11 +82,6 @@ function createTestManifest(): PluginManifest {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a test Plugin instance with a configured App.
|
|
||||||
*
|
|
||||||
* @returns The plugin and app.
|
|
||||||
*/
|
|
||||||
function createTestPlugin(): TestPluginResult {
|
function createTestPlugin(): TestPluginResult {
|
||||||
const app = App.createConfigured__();
|
const app = App.createConfigured__();
|
||||||
const manifest = createTestManifest();
|
const manifest = createTestManifest();
|
||||||
|
|
@ -117,49 +89,22 @@ function createTestPlugin(): TestPluginResult {
|
||||||
return { app, plugin };
|
return { app, plugin };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the plugin's private pluginSettingsComponent via Reflect.
|
|
||||||
*
|
|
||||||
* @param plugin - The plugin instance.
|
|
||||||
* @returns The pluginSettingsComponent.
|
|
||||||
*/
|
|
||||||
function getSettingsComponent(plugin: Plugin): SettingsComponentProxy {
|
function getSettingsComponent(plugin: Plugin): SettingsComponentProxy {
|
||||||
return Reflect.get(plugin, 'pluginSettingsComponent') as SettingsComponentProxy;
|
return Reflect.get(plugin, 'pluginSettingsComponent') as SettingsComponentProxy;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Marks the plugin and its internal wrapperComponent as loaded
|
|
||||||
* without going through the full load lifecycle.
|
|
||||||
*
|
|
||||||
* @param plugin - The plugin to mark as loaded.
|
|
||||||
*/
|
|
||||||
function markPluginLoaded(plugin: Plugin): void {
|
function markPluginLoaded(plugin: Plugin): void {
|
||||||
Reflect.set(bypassStrictProxy(plugin), '_loaded', true);
|
Reflect.set(bypassStrictProxy(plugin), '_loaded', true);
|
||||||
const wrapperComponent = Reflect.get(bypassStrictProxy(plugin), 'wrapperComponent') as object;
|
const wrapperComponent = Reflect.get(bypassStrictProxy(plugin), 'wrapperComponent') as object;
|
||||||
Reflect.set(bypassStrictProxy(wrapperComponent), '_loaded', true);
|
Reflect.set(bypassStrictProxy(wrapperComponent), '_loaded', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up internalPlugins mock on the plugin's app.
|
|
||||||
*
|
|
||||||
* @param plugin - The plugin.
|
|
||||||
* @param returnValue - The value to return from getPluginById.
|
|
||||||
* @returns The mocked getPluginById function.
|
|
||||||
*/
|
|
||||||
function mockInternalPlugins(plugin: Plugin, returnValue: unknown): ReturnType<typeof vi.fn> {
|
function mockInternalPlugins(plugin: Plugin, returnValue: unknown): ReturnType<typeof vi.fn> {
|
||||||
const getPluginById = vi.fn().mockReturnValue(returnValue);
|
const getPluginById = vi.fn().mockReturnValue(returnValue);
|
||||||
Reflect.set(bypassStrictProxy(plugin.app), 'internalPlugins', strictProxy({ getPluginById }));
|
Reflect.set(bypassStrictProxy(plugin.app), 'internalPlugins', strictProxy({ getPluginById }));
|
||||||
return getPluginById;
|
return getPluginById;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Mocks `obsidianDevUtilsState` on both the plugin's app and the
|
|
||||||
* global `app` so that `super.onload()` can complete without errors.
|
|
||||||
* The library accesses state via the passed app instance and via the
|
|
||||||
* global `getApp()` fallback (`globalThis.app`).
|
|
||||||
*
|
|
||||||
* @param plugin - The plugin whose app needs the mock.
|
|
||||||
*/
|
|
||||||
function mockObsidianDevUtilsState(plugin: Plugin): void {
|
function mockObsidianDevUtilsState(plugin: Plugin): void {
|
||||||
Reflect.set(bypassStrictProxy(plugin.app), 'obsidianDevUtilsState', {});
|
Reflect.set(bypassStrictProxy(plugin.app), 'obsidianDevUtilsState', {});
|
||||||
const globalApp = Reflect.get(window, 'app') as unknown;
|
const globalApp = Reflect.get(window, 'app') as unknown;
|
||||||
|
|
@ -168,25 +113,11 @@ function mockObsidianDevUtilsState(plugin: Plugin): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a strict proxy, working around `PartialDeep<T>` incompatibility
|
|
||||||
* with complex DOM types by casting the partial before passing to strictProxy.
|
|
||||||
*
|
|
||||||
* @param partial - A partial object with the mocked members.
|
|
||||||
* @returns A strict proxy typed as T.
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- T is used in the body for strictProxy<T>.
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- T is used in the body for strictProxy<T>.
|
||||||
function mockProxy<T>(partial: object): T {
|
function mockProxy<T>(partial: object): T {
|
||||||
return strictProxy<T>(partial as StrictProxyPartial<T>);
|
return strictProxy<T>(partial as StrictProxyPartial<T>);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calls a private/protected method on an object via Reflect and binds it.
|
|
||||||
*
|
|
||||||
* @param obj - The object.
|
|
||||||
* @param name - The method name.
|
|
||||||
* @returns The bound method.
|
|
||||||
*/
|
|
||||||
function privateMethod(obj: object, name: string): (...args: never[]) => unknown {
|
function privateMethod(obj: object, name: string): (...args: never[]) => unknown {
|
||||||
const fn = Reflect.get(obj, name) as (...args: never[]) => unknown;
|
const fn = Reflect.get(obj, name) as (...args: never[]) => unknown;
|
||||||
return fn.bind(obj);
|
return fn.bind(obj);
|
||||||
|
|
@ -404,12 +335,6 @@ describe('Plugin', () => {
|
||||||
settings = getSettingsComponent(plugin).settings;
|
settings = getSettingsComponent(plugin).settings;
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Calls the private generateBacklinkTitle method.
|
|
||||||
*
|
|
||||||
* @param file - The TFile to generate a title for.
|
|
||||||
* @returns The generated HTMLDivElement.
|
|
||||||
*/
|
|
||||||
function callGenerateBacklinkTitle(file: TFile): HTMLDivElement {
|
function callGenerateBacklinkTitle(file: TFile): HTMLDivElement {
|
||||||
const fn = privateMethod(plugin, 'generateBacklinkTitle');
|
const fn = privateMethod(plugin, 'generateBacklinkTitle');
|
||||||
return (fn as (...args: unknown[]) => unknown)(file) as HTMLDivElement;
|
return (fn as (...args: unknown[]) => unknown)(file) as HTMLDivElement;
|
||||||
|
|
|
||||||
234
src/plugin.ts
234
src/plugin.ts
|
|
@ -1,62 +1,15 @@
|
||||||
/**
|
|
||||||
* @file
|
|
||||||
*
|
|
||||||
* Main plugin class for the Backlink Full Path plugin.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
BacklinkPlugin,
|
|
||||||
BacklinkView,
|
|
||||||
ResultDom,
|
|
||||||
ResultDomItem,
|
|
||||||
ResultDomResult
|
|
||||||
} from '@obsidian-typings/obsidian-public-latest';
|
|
||||||
import type {
|
|
||||||
App,
|
|
||||||
PluginManifest,
|
|
||||||
TFile
|
|
||||||
} from 'obsidian';
|
|
||||||
|
|
||||||
import {
|
|
||||||
InternalPluginName,
|
|
||||||
ViewType
|
|
||||||
} from '@obsidian-typings/obsidian-public-latest/implementations';
|
|
||||||
import {
|
|
||||||
MarkdownView,
|
|
||||||
setTooltip
|
|
||||||
} from 'obsidian';
|
|
||||||
import { invokeAsyncSafely } from 'obsidian-dev-utils/async';
|
|
||||||
import { getPrototypeOf } from 'obsidian-dev-utils/object-utils';
|
|
||||||
import { MonkeyAroundComponent } from 'obsidian-dev-utils/obsidian/components/monkey-around-component';
|
|
||||||
import { PluginSettingsTabComponent } from 'obsidian-dev-utils/obsidian/components/plugin-settings-tab-component';
|
import { PluginSettingsTabComponent } from 'obsidian-dev-utils/obsidian/components/plugin-settings-tab-component';
|
||||||
import { PluginDataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
|
import { PluginDataHandler } from 'obsidian-dev-utils/obsidian/data-handler';
|
||||||
import { PluginBase } from 'obsidian-dev-utils/obsidian/plugin/plugin';
|
import { PluginBase } from 'obsidian-dev-utils/obsidian/plugin/plugin';
|
||||||
import { PluginEventSourceImpl } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
|
import { PluginEventSourceImpl } from 'obsidian-dev-utils/obsidian/plugin/plugin-event-source';
|
||||||
|
|
||||||
|
import { BacklinkFullPathComponent } from './backlink-full-path-component.ts';
|
||||||
import { PluginSettingsComponent } from './plugin-settings-component.ts';
|
import { PluginSettingsComponent } from './plugin-settings-component.ts';
|
||||||
import { PluginSettingsTab } from './plugin-settings-tab.ts';
|
import { PluginSettingsTab } from './plugin-settings-tab.ts';
|
||||||
|
|
||||||
/**
|
|
||||||
* Type alias for the `addResult` method on `ResultDom`.
|
|
||||||
*/
|
|
||||||
type AddResultFn = ResultDom['addResult'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Backlink Full Path plugin. Replaces backlink titles with full paths.
|
|
||||||
*/
|
|
||||||
export class Plugin extends PluginBase {
|
export class Plugin extends PluginBase {
|
||||||
private readonly pluginSettingsComponent: PluginSettingsComponent;
|
protected override onloadImpl(): void {
|
||||||
|
const pluginSettingsComponent = this.addChild(
|
||||||
/**
|
|
||||||
* Creates a new Backlink Full Path plugin.
|
|
||||||
*
|
|
||||||
* @param app - The Obsidian app instance.
|
|
||||||
* @param manifest - The plugin manifest.
|
|
||||||
*/
|
|
||||||
public constructor(app: App, manifest: PluginManifest) {
|
|
||||||
super(app, manifest);
|
|
||||||
|
|
||||||
this.pluginSettingsComponent = this.addChild(
|
|
||||||
new PluginSettingsComponent({
|
new PluginSettingsComponent({
|
||||||
dataHandler: new PluginDataHandler(this),
|
dataHandler: new PluginDataHandler(this),
|
||||||
pluginEventSource: new PluginEventSourceImpl(this)
|
pluginEventSource: new PluginEventSourceImpl(this)
|
||||||
|
|
@ -68,183 +21,16 @@ export class Plugin extends PluginBase {
|
||||||
plugin: this,
|
plugin: this,
|
||||||
pluginSettingsTab: new PluginSettingsTab({
|
pluginSettingsTab: new PluginSettingsTab({
|
||||||
plugin: this,
|
plugin: this,
|
||||||
pluginSettingsComponent: this.pluginSettingsComponent
|
pluginSettingsComponent
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
this.addChild(
|
||||||
* Called after pre-loaded components are initialized.
|
new BacklinkFullPathComponent({
|
||||||
*/
|
app: this.app,
|
||||||
public override async onload(): Promise<void> {
|
pluginSettingsComponent
|
||||||
await super.onload();
|
})
|
||||||
this.pluginSettingsComponent.on('saveSettings', async () => {
|
);
|
||||||
await this.refreshBacklinkPanels();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the workspace layout is ready.
|
|
||||||
*/
|
|
||||||
protected async onLayoutReady(): Promise<void> {
|
|
||||||
const backlinksCorePlugin = this.app.internalPlugins.getPluginById(InternalPluginName.Backlink);
|
|
||||||
if (!backlinksCorePlugin) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const that = this;
|
|
||||||
const patch = this.addChild(new MonkeyAroundComponent());
|
|
||||||
patch.registerPatch(getPrototypeOf(backlinksCorePlugin.instance), {
|
|
||||||
onUserEnable: (next: () => void) => {
|
|
||||||
return function onUserEnablePatched(this: BacklinkPlugin): void {
|
|
||||||
next.call(this);
|
|
||||||
that.onBacklinksCorePluginEnable();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (backlinksCorePlugin.enabled) {
|
|
||||||
await this.patchBacklinksPane();
|
|
||||||
await this.refreshBacklinkPanels();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.register(() => {
|
|
||||||
invokeAsyncSafely(async () => {
|
|
||||||
await this.refreshBacklinkPanels();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private addResult(next: AddResultFn, resultDom: ResultDom, file: TFile, result: ResultDomResult, content: string, shouldShowTitle?: boolean): ResultDomItem {
|
|
||||||
const resultDomItem = next.call(resultDom, file, result, content, shouldShowTitle);
|
|
||||||
const fileNameCaptionEl = resultDomItem.el.querySelector('.tree-item-inner');
|
|
||||||
if (fileNameCaptionEl) {
|
|
||||||
fileNameCaptionEl.empty();
|
|
||||||
fileNameCaptionEl.appendChild(this.generateBacklinkTitle(file));
|
|
||||||
}
|
|
||||||
return resultDomItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateBacklinkTitle(file: TFile): HTMLDivElement {
|
|
||||||
const fileNamePart = this.pluginSettingsComponent.settings.shouldIncludeExtension ? file.name : file.basename;
|
|
||||||
|
|
||||||
let parentPathParts = file.path.split('/').slice(0, -1);
|
|
||||||
|
|
||||||
for (let length = parentPathParts.length; length >= 1; length--) {
|
|
||||||
const rootPath = parentPathParts.slice(0, length).join('/');
|
|
||||||
if (this.pluginSettingsComponent.settings.rootPaths.includes(rootPath)) {
|
|
||||||
parentPathParts = parentPathParts.slice(length);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.pluginSettingsComponent.settings.pathDepth > 0) {
|
|
||||||
const partsToSkipCount = Math.max(0, parentPathParts.length - this.pluginSettingsComponent.settings.pathDepth + 1);
|
|
||||||
if (partsToSkipCount > 0) {
|
|
||||||
parentPathParts.splice(0, partsToSkipCount);
|
|
||||||
if (this.pluginSettingsComponent.settings.shouldShowEllipsisForSkippedPathParts) {
|
|
||||||
parentPathParts.unshift('...');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.pluginSettingsComponent.settings.shouldReversePathParts) {
|
|
||||||
parentPathParts.reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathSeparator = this.pluginSettingsComponent.settings.shouldReversePathParts ? ' \u2190 ' : '/';
|
|
||||||
const parentStr = parentPathParts.join(pathSeparator);
|
|
||||||
|
|
||||||
const container = createDiv({
|
|
||||||
cls: ['backlink-full-path', 'backlink-control']
|
|
||||||
});
|
|
||||||
container.dataset['shouldHighlightFileName'] = this.pluginSettingsComponent.settings.shouldHighlightFileName.toString();
|
|
||||||
container.dataset['shouldDisplayParentPathOnSeparateLine'] = this.pluginSettingsComponent.settings.shouldDisplayParentPathOnSeparateLine.toString();
|
|
||||||
container.createSpan({
|
|
||||||
cls: 'full-path',
|
|
||||||
text: file.path
|
|
||||||
});
|
|
||||||
const shadowRoot = container.attachShadow({ mode: 'open' });
|
|
||||||
setTooltip(container, file.path);
|
|
||||||
shadowRoot.createSpan({
|
|
||||||
attr: {
|
|
||||||
part: 'file-name'
|
|
||||||
},
|
|
||||||
text: fileNamePart
|
|
||||||
});
|
|
||||||
|
|
||||||
if (parentStr) {
|
|
||||||
let text = parentStr;
|
|
||||||
if (!this.pluginSettingsComponent.settings.shouldDisplayParentPathOnSeparateLine) {
|
|
||||||
text = this.pluginSettingsComponent.settings.shouldReversePathParts ? pathSeparator + text : text + pathSeparator;
|
|
||||||
}
|
|
||||||
shadowRoot.createSpan({
|
|
||||||
attr: {
|
|
||||||
part: 'parent-path'
|
|
||||||
},
|
|
||||||
prepend: !this.pluginSettingsComponent.settings.shouldReversePathParts && !this.pluginSettingsComponent.settings.shouldDisplayParentPathOnSeparateLine,
|
|
||||||
text
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getBacklinkView(): Promise<BacklinkView | null> {
|
|
||||||
const backlinksLeaf = this.app.workspace.getLeavesOfType(ViewType.Backlink)[0];
|
|
||||||
if (!backlinksLeaf) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await backlinksLeaf.loadIfDeferred();
|
|
||||||
return backlinksLeaf.view as BacklinkView;
|
|
||||||
}
|
|
||||||
|
|
||||||
private onBacklinksCorePluginEnable(): void {
|
|
||||||
invokeAsyncSafely(() => this.patchBacklinksPane());
|
|
||||||
}
|
|
||||||
|
|
||||||
private async patchBacklinksPane(): Promise<void> {
|
|
||||||
const backlinkView = await this.getBacklinkView();
|
|
||||||
if (!backlinkView) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const that = this;
|
|
||||||
const patch = this.addChild(new MonkeyAroundComponent());
|
|
||||||
patch.registerPatch(getPrototypeOf(backlinkView.backlink.backlinkDom), {
|
|
||||||
addResult: (next: AddResultFn): AddResultFn => {
|
|
||||||
return function addResultPatched(this: ResultDom, file: TFile, result: ResultDomResult, content: string, shouldShowTitle?: boolean): ResultDomItem {
|
|
||||||
return that.addResult(next, this, file, result, content, shouldShowTitle);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async refreshBacklinkPanels(): Promise<void> {
|
|
||||||
await this.reloadBacklinksView();
|
|
||||||
|
|
||||||
for (const leaf of this.app.workspace.getLeavesOfType(ViewType.Markdown)) {
|
|
||||||
if (!(leaf.view instanceof MarkdownView)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!leaf.view.backlinks) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
leaf.view.backlinks.recomputeBacklink(leaf.view.backlinks.file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async reloadBacklinksView(): Promise<void> {
|
|
||||||
const backlinkView = await this.getBacklinkView();
|
|
||||||
if (!backlinkView) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (backlinkView.file) {
|
|
||||||
backlinkView.backlink.recomputeBacklink(backlinkView.file);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue