Use parent folder titles for index graph nodes

This commit is contained in:
fa1k3 2026-05-20 07:40:12 +02:00
parent a2e552104c
commit 2754545a99
3 changed files with 137 additions and 11 deletions

View file

@ -274,7 +274,7 @@ export class GraphSearchTool {
const paths = rawPaths.map(pathList =>
pathList.map(filePath => ({
path: filePath,
title: filePath.replace(/\.md$/, '').split('/').pop() || filePath
title: this.graphTraversal.getNodeTitleForPath(filePath)
}))
);
@ -324,12 +324,15 @@ export class GraphSearchTool {
const stats = this.graphTraversal.getNodeStatistics(params.sourcePath);
const file = this.app.vault.getAbstractFileByPath(params.sourcePath);
const title = file instanceof TFile
? this.graphTraversal.getNodeTitle(file)
: params.sourcePath;
return {
operation: 'statistics',
sourcePath: params.sourcePath,
statistics: stats,
message: `Link statistics for ${file?.name || params.sourcePath}`,
message: `Link statistics for ${title}`,
workflow: {
message: 'Statistics retrieved. You can explore the actual links or find connected nodes.',
suggested_next: [
@ -371,7 +374,7 @@ export class GraphSearchTool {
const cache = this.app.metadataCache.getFileCache(file);
nodes.push({
path: edge.source,
title: file.name.replace(/\.md$/, ''),
title: this.graphTraversal.getNodeTitle(file),
type: 'file',
tags: cache?.tags?.map(t => t.tag),
links: {
@ -427,7 +430,7 @@ export class GraphSearchTool {
const cache = this.app.metadataCache.getFileCache(file);
nodes.push({
path: edge.target,
title: file.name.replace(/\.md$/, ''),
title: this.graphTraversal.getNodeTitle(file),
type: 'file',
tags: cache?.tags?.map(t => t.tag),
links: {
@ -464,4 +467,4 @@ export class GraphSearchTool {
}
};
}
}
}

View file

@ -55,6 +55,31 @@ export interface GraphTraversalResult {
export class GraphTraversal {
constructor(private app: App) {}
/**
* Get a human-friendly graph node title for a file.
*
* Vaults commonly use <topic>/index.md as landing pages. Showing every
* such node as "index" makes graph results ambiguous, so use the parent
* folder name for index files when available.
*/
getNodeTitle(file: TFile): string {
if (file.basename === 'index' && file.parent?.name) {
return file.parent.name;
}
return file.basename;
}
/**
* Resolve a path to a graph node title, falling back to the path basename.
*/
getNodeTitleForPath(filePath: string): string {
const file = this.app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
return this.getNodeTitle(file);
}
return filePath.replace(/\.md$/, '').split('/').pop() || filePath;
}
/**
* Get all nodes (files) in the vault
*/
@ -63,7 +88,7 @@ export class GraphTraversal {
return files.map(file => ({
file,
path: file.path,
title: file.basename,
title: this.getNodeTitle(file),
metadata: this.app.metadataCache.getFileCache(file) || undefined
}));
}
@ -223,7 +248,7 @@ export class GraphTraversal {
const node: GraphNode = {
file,
path: file.path,
title: file.basename,
title: this.getNodeTitle(file),
metadata: this.app.metadataCache.getFileCache(file) || undefined
};
@ -378,7 +403,7 @@ export class GraphTraversal {
const neighbors: GraphNode[] = recentFiles.map(file => ({
file,
path: file.path,
title: file.basename,
title: this.getNodeTitle(file),
metadata: this.app.metadataCache.getFileCache(file) || undefined
}));
@ -413,7 +438,7 @@ export class GraphTraversal {
const node: GraphNode = {
file,
path: file.path,
title: file.basename,
title: this.getNodeTitle(file),
metadata: this.app.metadataCache.getFileCache(file) || undefined
};
@ -432,7 +457,7 @@ export class GraphTraversal {
neighbors.push({
file: neighborFile,
path: neighborFile.path,
title: neighborFile.basename,
title: this.getNodeTitle(neighborFile),
metadata: this.app.metadataCache.getFileCache(neighborFile) || undefined
});
}
@ -470,4 +495,4 @@ export class GraphTraversal {
tagCount
};
}
}
}

View file

@ -0,0 +1,98 @@
import { App, TFile } from 'obsidian';
import { GraphSearchTool } from '../src/tools/graph-search';
import { GraphTraversal } from '../src/utils/graph-traversal';
import { ObsidianAPI } from '../src/utils/obsidian-api';
function makeFile(path: string, basename: string, parentName?: string): TFile {
const file = new TFile();
file.path = path;
file.name = `${basename}.md`;
file.basename = basename;
file.extension = 'md';
(file as any).parent = parentName
? { name: parentName }
: null;
return file;
}
describe('graph node titles', () => {
let app: App;
let traversal: GraphTraversal;
let search: GraphSearchTool;
let indexFile: TFile;
let regularFile: TFile;
let sourceFile: TFile;
beforeEach(() => {
indexFile = makeFile('topics/rendering/index.md', 'index', 'rendering');
regularFile = makeFile('topics/rendering/overview.md', 'overview', 'rendering');
sourceFile = makeFile('source.md', 'source');
const filesByPath = new Map([
[indexFile.path, indexFile],
[regularFile.path, regularFile],
[sourceFile.path, sourceFile]
]);
app = new App();
(app as any).metadataCache = {
resolvedLinks: {
'source.md': { 'topics/rendering/index.md': 1 },
'topics/rendering/index.md': { 'topics/rendering/overview.md': 1 }
},
unresolvedLinks: {},
getFileCache: jest.fn().mockReturnValue({ tags: [] })
};
app.vault.getFiles = jest.fn(() => [indexFile, regularFile, sourceFile]);
app.vault.getAbstractFileByPath = jest.fn((path: string) => filesByPath.get(path) ?? null);
traversal = new GraphTraversal(app);
search = new GraphSearchTool({} as ObsidianAPI, app);
});
it('uses the parent folder as the graph title for index files', () => {
expect(traversal.getNodeTitle(indexFile)).toBe('rendering');
expect(traversal.getNodeTitle(regularFile)).toBe('overview');
});
it('uses resolved graph titles in vault node listings', () => {
const nodes = traversal.getAllNodes();
expect(nodes).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'topics/rendering/index.md', title: 'rendering' }),
expect.objectContaining({ path: 'topics/rendering/overview.md', title: 'overview' })
])
);
});
it('uses resolved graph titles in forwardlink results', () => {
const result = search.search({ operation: 'forwardlinks', sourcePath: 'source.md' });
expect(result.nodes).toEqual([
expect.objectContaining({ path: 'topics/rendering/index.md', title: 'rendering' })
]);
});
it('uses resolved graph titles in backlink results', () => {
const result = search.search({ operation: 'backlinks', sourcePath: 'topics/rendering/overview.md' });
expect(result.nodes).toEqual([
expect.objectContaining({ path: 'topics/rendering/index.md', title: 'rendering' })
]);
});
it('uses resolved graph titles in path results', () => {
const result = search.search({
operation: 'path',
sourcePath: 'source.md',
targetPath: 'topics/rendering/overview.md'
});
expect(result.paths?.[0]).toEqual([
{ path: 'source.md', title: 'source' },
{ path: 'topics/rendering/index.md', title: 'rendering' },
{ path: 'topics/rendering/overview.md', title: 'overview' }
]);
});
});