Skip to content

Commit a70c62a

Browse files
authored
Merge pull request #3527 from obsidian-tasks-group/fix-relative-links
feat: Add `Link.markdown` for reliable rendering of links
2 parents 6ac5406 + d78dcaa commit a70c62a

File tree

8 files changed

+181
-177
lines changed

8 files changed

+181
-177
lines changed

resources/sample_vaults/Tasks-Demo/How To/Access links.md

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -50,24 +50,6 @@ limit groups 1
5050
```tasks
5151
# Task line has a link
5252
filter by function task.outlinks.length > 0
53-
group by function task.outlinks.map(link => link.originalMarkdown).sort().join(' · ')
54-
limit groups 1
55-
```
56-
57-
### Group by task outlinks - task outlinks that give broken group headings
58-
59-
- Hovering over these links **in the task lines** works because of [#3357](https://github.com/obsidian-tasks-group/obsidian-tasks/pull/3357).
60-
- Hovering over these links **in the group headings** gives things like **Unable to find “Basic Internal Links” in Access links**
61-
62-
```tasks
63-
# Task line has a link
64-
filter by function task.outlinks.length > 0
65-
66-
# Task's link goes to a heading in the same file
67-
description includes [[#
68-
69-
filename includes internal_heading_links
70-
71-
group by function task.outlinks.map(link => link.originalMarkdown).sort().join(' · ')
53+
group by function task.outlinks.map(link => link.markdown).sort().join(' · ')
7254
limit groups 1
7355
```

src/Scripting/TasksFile.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,14 @@ export class TasksFile {
6161
* Return an array of {@link Link} in the file's properties/frontmatter.
6262
*/
6363
get outlinksInProperties(): Link[] {
64-
return this.cachedMetadata.frontmatterLinks?.map((link) => new Link(link, this.filenameWithoutExtension)) ?? [];
64+
return this.cachedMetadata.frontmatterLinks?.map((link) => new Link(link, this.path)) ?? [];
6565
}
6666

6767
/**
6868
* Return an array of {@link Link} in the body of the file.
6969
*/
7070
get outlinksInBody(): Link[] {
71-
return this.cachedMetadata?.links?.map((link) => new Link(link, this.filenameWithoutExtension)) ?? [];
71+
return this.cachedMetadata?.links?.map((link) => new Link(link, this.path)) ?? [];
7272
}
7373

7474
/**

src/Task/Link.ts

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,44 @@ import type { Reference } from 'obsidian';
22

33
export class Link {
44
private readonly rawLink: Reference;
5-
private readonly filenameContainingLink: string;
5+
private readonly pathContainingLink: string;
66

77
/**
88
* @param {Reference} rawLink - The raw link from Obsidian cache.
9-
* @param {string} filenameContainingLink - The name of the file where this link is located.
9+
* @param {string} pathContainingLink - The path of the file where this link is located.
1010
*/
11-
constructor(rawLink: Reference, filenameContainingLink: string) {
11+
constructor(rawLink: Reference, pathContainingLink: string) {
1212
this.rawLink = rawLink;
13-
this.filenameContainingLink = filenameContainingLink;
13+
this.pathContainingLink = pathContainingLink;
1414
}
1515

16+
/**
17+
* Return the original Markdown.
18+
*
19+
* See also {@link markdown}
20+
*/
1621
public get originalMarkdown(): string {
1722
return this.rawLink.original;
1823
}
1924

20-
public get destination(): string {
21-
return this.rawLink.link;
22-
}
23-
2425
/**
25-
* Returns the filename of the link destination without the path or alias
26-
* Removes the .md extension if present leaves other extensions intact.
27-
* No accommodation for empty links.
28-
* @returns {string}
26+
* This is like {@link originalMarkdown}, but will also work for heading-only links
27+
* when viewed in files other than the one containing the original link.
28+
*
29+
* See also {@link originalMarkdown}
2930
*/
30-
public get destinationFilename(): string {
31-
// Handle internal links (starting with '#')
32-
if (this.destination.startsWith('#')) {
33-
return this.filenameContainingLink;
31+
public get markdown(): string {
32+
if (!this.destination.startsWith('#')) {
33+
// The link already has a file name, so just return it
34+
return this.originalMarkdown;
3435
}
3536

36-
// Extract filename from path (handles both path and optional hash fragment)
37-
const pathPart = this.destination.split('#', 1)[0];
38-
const destFilename = pathPart.substring(pathPart.lastIndexOf('/') + 1);
37+
// We will need to construct a new link, containing the filename (later, the full path)
38+
return `[[${this.pathContainingLink}${this.destination}|${this.displayText}]]`;
39+
}
3940

40-
// Remove.md extension if present
41-
return destFilename.endsWith('.md') ? destFilename.slice(0, -3) : destFilename;
41+
public get destination(): string {
42+
return this.rawLink.link;
4243
}
4344

4445
public get displayText(): string | undefined {

src/Task/ListItem.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ export class ListItem {
210210
public get outlinks(): Link[] {
211211
return this.rawLinksInFileBody
212212
.filter((link) => link.position.start.line === this.lineNumber)
213-
.map((link) => new Link(link, this.file.filenameWithoutExtension));
213+
.map((link) => new Link(link, this.file.path));
214214
}
215215

216216
/**

tests/Task/Link.test.ts

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Link } from '../../src/Task/Link';
2-
import { TasksFile } from '../../src/Scripting/TasksFile';
32

43
import links_everywhere from '../Obsidian/__test_data__/links_everywhere.json';
54
import internal_heading_links from '../Obsidian/__test_data__/internal_heading_links.json';
@@ -13,7 +12,7 @@ import type { SimulatedFile } from '../Obsidian/SimulatedFile';
1312

1413
function getLink(data: any, index: number) {
1514
const rawLink = data.cachedMetadata.links[index];
16-
return new Link(rawLink, new TasksFile(data.filePath).filenameWithoutExtension);
15+
return new Link(rawLink, data.filePath);
1716
}
1817

1918
describe('linkClass', () => {
@@ -24,10 +23,28 @@ describe('linkClass', () => {
2423
expect(link.originalMarkdown).toEqual('[[link_in_file_body]]');
2524
expect(link.destination).toEqual('link_in_file_body');
2625
expect(link.displayText).toEqual('link_in_file_body');
27-
expect(link.destinationFilename).toEqual('link_in_file_body');
26+
expect(link.markdown).toEqual(link.originalMarkdown);
2827
});
2928

30-
describe('.destinationFilename()', () => {
29+
describe('return markdown to navigate to a link', () => {
30+
// These links are useful
31+
it('should return the filename if simple [[filename]]', () => {
32+
const link = getLink(link_in_task_wikilink, 0);
33+
34+
expect(link.originalMarkdown).toEqual('[[link_in_task_wikilink]]');
35+
expect(link.markdown).toEqual('[[link_in_task_wikilink]]');
36+
});
37+
38+
// For more test examples, see QueryResultsRenderer.test.ts
39+
it('should return a working link to [[#heading]]', () => {
40+
const link = getLink(internal_heading_links, 0);
41+
42+
expect(link.originalMarkdown).toEqual('[[#Basic Internal Links]]');
43+
expect(link.markdown).toEqual(
44+
'[[Test Data/internal_heading_links.md#Basic Internal Links|Basic Internal Links]]',
45+
);
46+
});
47+
3148
// ================================
3249
// WIKILINK TESTS
3350
// ================================
@@ -37,73 +54,77 @@ describe('linkClass', () => {
3754
const link = getLink(internal_heading_links, 0);
3855

3956
expect(link.originalMarkdown).toEqual('[[#Basic Internal Links]]');
40-
expect(link.destinationFilename).toEqual('internal_heading_links');
57+
expect(link.markdown).toEqual(
58+
'[[Test Data/internal_heading_links.md#Basic Internal Links|Basic Internal Links]]',
59+
);
4160
});
4261

4362
it('should return the filename of the containing note if the link is internal and has an alias [[#heading|display text]]', () => {
4463
const link = getLink(internal_heading_links, 6);
4564

4665
expect(link.originalMarkdown).toEqual('[[#Header Links With File Reference]]');
47-
expect(link.destinationFilename).toEqual('internal_heading_links');
66+
expect(link.markdown).toEqual(
67+
'[[Test Data/internal_heading_links.md#Header Links With File Reference|Header Links With File Reference]]',
68+
);
4869
});
4970

5071
// Tests checking against __link_in_task_wikilink__
5172
it('should return the filename if simple [[filename]]', () => {
5273
const link = getLink(link_in_task_wikilink, 0);
5374

5475
expect(link.originalMarkdown).toEqual('[[link_in_task_wikilink]]');
55-
expect(link.destinationFilename).toEqual('link_in_task_wikilink');
76+
expect(link.markdown).toEqual(link.originalMarkdown);
5677
});
5778

5879
it('should return the filename if link has a path [[path/filename]]', () => {
5980
const link = getLink(link_in_task_wikilink, 2);
6081

6182
expect(link.originalMarkdown).toEqual('[[Test Data/link_in_task_wikilink]]');
62-
expect(link.destinationFilename).toEqual('link_in_task_wikilink');
83+
expect(link.markdown).toEqual(link.originalMarkdown);
6384
});
6485

6586
it('should return the filename if link has a path and a heading link [[path/filename#heading]]', () => {
6687
const link = getLink(link_in_task_wikilink, 3);
6788

6889
expect(link.originalMarkdown).toEqual('[[Test Data/link_in_task_wikilink#heading_link]]');
69-
expect(link.destinationFilename).toEqual('link_in_task_wikilink');
90+
expect(link.markdown).toEqual(link.originalMarkdown);
7091
});
7192

7293
it('should return the filename if link has an alias [[filename|alias]]', () => {
7394
const link = getLink(link_in_task_wikilink, 4);
7495

7596
expect(link.originalMarkdown).toEqual('[[link_in_task_wikilink|alias]]');
76-
expect(link.destinationFilename).toEqual('link_in_task_wikilink');
97+
expect(link.markdown).toEqual(link.originalMarkdown);
7798
});
7899

79100
it('should return the filename if link has a path and an alias [[path/path/filename|alias]]', () => {
80101
const link = getLink(link_in_task_wikilink, 5);
81102

82103
expect(link.originalMarkdown).toEqual('[[Test Data/link_in_task_wikilink|alias]]');
83-
expect(link.destinationFilename).toEqual('link_in_task_wikilink');
104+
expect(link.markdown).toEqual(link.originalMarkdown);
84105
});
85106

86107
// # is a valid character in a filename or a path but Obsidian does not support it in links
87108
it('should return the filename if path contains a # [[pa#th/path/filename]]', () => {
88109
const link = getLink(link_in_task_wikilink, 6);
89110

90111
expect(link.originalMarkdown).toEqual('[[pa#th/path/link_in_task_wikilink]]');
91-
expect(link.destinationFilename).toEqual('pa');
112+
expect(link.markdown).toEqual(link.originalMarkdown);
92113
});
93114

94115
// When grouping a Wikilink link expect [[file.md]] to be grouped with [[file]].
95116
it('should return a filename with no file extension if suffixed with .md [[link_in_task_wikilink.md]]', () => {
96117
const link = getLink(link_in_task_wikilink, 7);
97118

98119
expect(link.originalMarkdown).toEqual('[[link_in_task_wikilink.md]]');
99-
expect(link.destinationFilename).toEqual('link_in_task_wikilink');
120+
expect(link.markdown).toEqual(link.originalMarkdown);
100121
});
101122

102123
it('should return a filename with corresponding file extension if not markdown [[a_pdf_file.pdf]]', () => {
103124
const link = getLink(link_in_task_wikilink, 8);
104125

105126
expect(link.originalMarkdown).toEqual('[[a_pdf_file.pdf]]');
106-
expect(link.destinationFilename).toEqual('a_pdf_file.pdf');
127+
expect(link.markdown).toEqual(link.originalMarkdown);
107128
});
108129

109130
// Empty Wikilink Tests
@@ -113,21 +134,21 @@ describe('linkClass', () => {
113134
const link = getLink(link_in_task_wikilink, 9);
114135

115136
expect(link.originalMarkdown).toEqual('[[|]]');
116-
expect(link.destinationFilename).toEqual('|');
137+
expect(link.markdown).toEqual(link.originalMarkdown);
117138
});
118139

119140
it('should provide no special functionality for [[|alias]]; returns "|alias".)', () => {
120141
const link = getLink(link_in_task_wikilink, 10);
121142

122143
expect(link.originalMarkdown).toEqual('[[|alias]]');
123-
expect(link.destinationFilename).toEqual('|alias');
144+
expect(link.markdown).toEqual(link.originalMarkdown);
124145
});
125146

126147
it('should provide no special functionality for [[|#alias]]; returns "|".)', () => {
127148
const link = getLink(link_in_task_wikilink, 11);
128149

129150
expect(link.originalMarkdown).toEqual('[[|#alias]]');
130-
expect(link.destinationFilename).toEqual('|');
151+
expect(link.markdown).toEqual(link.originalMarkdown);
131152
});
132153

133154
// ================================
@@ -140,14 +161,14 @@ describe('linkClass', () => {
140161
const link = getLink(link_in_task_markdown_link, 8);
141162

142163
expect(link.originalMarkdown).toEqual('[heading](#heading)');
143-
expect(link.destinationFilename).toEqual('link_in_task_markdown_link');
164+
expect(link.markdown).toEqual('[[Test Data/link_in_task_markdown_link.md#heading|heading]]');
144165
});
145166

146167
it('should return the filename when a simple markdown link [display name](filename)', () => {
147168
const link = getLink(link_in_task_markdown_link, 2);
148169

149170
expect(link.originalMarkdown).toEqual('[link_in_task_markdown_link](link_in_task_markdown_link.md)');
150-
expect(link.destinationFilename).toEqual('link_in_task_markdown_link');
171+
expect(link.markdown).toEqual(link.originalMarkdown);
151172
});
152173

153174
it('should return the filename if link has a path [link_in_task_markdown_link](path/filename.md)', () => {
@@ -156,28 +177,28 @@ describe('linkClass', () => {
156177
expect(link.originalMarkdown).toEqual(
157178
'[link_in_task_markdown_link](Test%20Data/link_in_task_markdown_link.md)',
158179
);
159-
expect(link.destinationFilename).toEqual('link_in_task_markdown_link');
180+
expect(link.markdown).toEqual(link.originalMarkdown);
160181
});
161182

162183
it('should return the filename if link has a path and a heading link [heading_link](path/filename.md#heading)', () => {
163184
const link = getLink(link_in_task_markdown_link, 4);
164185

165186
expect(link.originalMarkdown).toEqual('[heading_link](Test%20Data/link_in_task_markdown_link.md#heading)');
166-
expect(link.destinationFilename).toEqual('link_in_task_markdown_link');
187+
expect(link.markdown).toEqual(link.originalMarkdown);
167188
});
168189

169190
it('should return the filename if link has an alias [alias](filename.md)', () => {
170191
const link = getLink(link_in_task_markdown_link, 5);
171192

172193
expect(link.originalMarkdown).toEqual('[alias](link_in_task_markdown_link.md)');
173-
expect(link.destinationFilename).toEqual('link_in_task_markdown_link');
194+
expect(link.markdown).toEqual(link.originalMarkdown);
174195
});
175196

176197
it('should return the filename if link has a path and an alias [alias](path/path/filename.md)', () => {
177198
const link = getLink(link_in_task_markdown_link, 6);
178199

179200
expect(link.originalMarkdown).toEqual('[alias](Test%20Data/link_in_task_markdown_link.md)');
180-
expect(link.destinationFilename).toEqual('link_in_task_markdown_link');
201+
expect(link.markdown).toEqual(link.originalMarkdown);
181202
});
182203

183204
// # is a valid character in a filename or a path but Obsidian does not support it in links
@@ -187,22 +208,22 @@ describe('linkClass', () => {
187208
expect(link.originalMarkdown).toEqual(
188209
'[link_in_task_markdown_link](pa#th/path/link_in_task_markdown_link.md)',
189210
);
190-
expect(link.destinationFilename).toEqual('pa');
211+
expect(link.markdown).toEqual(link.originalMarkdown);
191212
});
192213

193214
// When grouping a Wikilink link expect [[file.md]] to be grouped with [[file]].
194215
it('should return a filename when no .md extension if the .md exists in markdown link [alias](filename)', () => {
195216
const link = getLink(link_in_task_markdown_link, 9);
196217

197218
expect(link.originalMarkdown).toEqual('[link_in_task_markdown_link](link_in_task_markdown_link)');
198-
expect(link.destinationFilename).toEqual('link_in_task_markdown_link');
219+
expect(link.markdown).toEqual(link.originalMarkdown);
199220
});
200221

201222
it('should return a filename with corresponding file extension if not markdown [a_pdf_file](a_pdf_file.pdf)', () => {
202223
const link = getLink(link_in_task_markdown_link, 10);
203224

204225
expect(link.originalMarkdown).toEqual('[a_pdf_file](a_pdf_file.pdf)');
205-
expect(link.destinationFilename).toEqual('a_pdf_file.pdf');
226+
expect(link.markdown).toEqual(link.originalMarkdown);
206227
});
207228

208229
it('should handle spaces in the path, filename, and heading link [heading link](path/filename with spaces.md#heading link)', () => {
@@ -211,7 +232,7 @@ describe('linkClass', () => {
211232
expect(link.originalMarkdown).toEqual(
212233
'[spaces everywhere](Manual%20Testing/Smoke%20Testing%20the%20Tasks%20Plugin#How%20the%20tests%20work)',
213234
);
214-
expect(link.destinationFilename).toEqual('Smoke Testing the Tasks Plugin');
235+
expect(link.markdown).toEqual(link.originalMarkdown);
215236
});
216237

217238
// Empty Markdown Link Tests
@@ -236,7 +257,7 @@ describe('visualise links', () => {
236257
output += `## ${file.filePath}\n\n`;
237258
outlinks.forEach((link) => {
238259
output += createRow('link.originalMarkdown', link.originalMarkdown);
239-
output += createRow('link.destinationFilename', link.destinationFilename);
260+
output += createRow('link.markdown', link.markdown);
240261
output += createRow('link.destination', link.destination);
241262
output += createRow('link.displayText', link.displayText);
242263
output += '\n';

0 commit comments

Comments
 (0)