diff --git a/src/tools/graph-search.ts b/src/tools/graph-search.ts index b10e519..8bfac80 100644 --- a/src/tools/graph-search.ts +++ b/src/tools/graph-search.ts @@ -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 { } }; } -} \ No newline at end of file +} diff --git a/src/utils/graph-traversal.ts b/src/utils/graph-traversal.ts index 477b3fe..3976e32 100644 --- a/src/utils/graph-traversal.ts +++ b/src/utils/graph-traversal.ts @@ -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 /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 }; } -} \ No newline at end of file +} diff --git a/tests/graph-node-title.test.ts b/tests/graph-node-title.test.ts new file mode 100644 index 0000000..29b9dcb --- /dev/null +++ b/tests/graph-node-title.test.ts @@ -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' } + ]); + }); +});