diff --git a/jest.config.js b/jest.config.js index 222a34a786..0427fbba68 100644 --- a/jest.config.js +++ b/jest.config.js @@ -16,6 +16,9 @@ module.exports = { // A list of paths to modules that run some code to configure or // set up the testing framework before each test. - setupFilesAfterEnv: ['/tests/CustomMatchers/jest.custom_matchers.setup.ts'], - globalSetup: "./tests/global-setup.js" + setupFilesAfterEnv: [ + '/tests/CustomMatchers/jest.custom_matchers.setup.ts', + '/tests/Task/LinkResolver.setup.ts', + ], + globalSetup: './tests/global-setup.js', }; diff --git a/resources/sample_vaults/Tasks-Demo/Test Data/chain_link1.md b/resources/sample_vaults/Tasks-Demo/Test Data/chain_link1.md new file mode 100644 index 0000000000..5a4eec06fe --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/Test Data/chain_link1.md @@ -0,0 +1,7 @@ +--- +link_to_file: "[[chain_link2]]" +--- + +# chain_link1 + +- [ ] #task Task in 'chain_link1' diff --git a/resources/sample_vaults/Tasks-Demo/Test Data/chain_link2.md b/resources/sample_vaults/Tasks-Demo/Test Data/chain_link2.md new file mode 100644 index 0000000000..b2426e174c --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/Test Data/chain_link2.md @@ -0,0 +1,7 @@ +--- +link_to_file: "[[chain_link3]]" +--- + +# chain_link2 + +- [ ] #task Task in 'chain_link2' diff --git a/resources/sample_vaults/Tasks-Demo/Test Data/chain_link3.md b/resources/sample_vaults/Tasks-Demo/Test Data/chain_link3.md new file mode 100644 index 0000000000..b62791daaa --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/Test Data/chain_link3.md @@ -0,0 +1,7 @@ +--- +link_to_file: "[[chain_link4]]" +--- + +# chain_link3 + +- [ ] #task Task in 'chain_link3' diff --git a/resources/sample_vaults/Tasks-Demo/Test Data/chain_link4.md b/resources/sample_vaults/Tasks-Demo/Test Data/chain_link4.md new file mode 100644 index 0000000000..b8d680d737 --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/Test Data/chain_link4.md @@ -0,0 +1,7 @@ +--- +link_to_file: "[[chain_link1]]" +--- + +# chain_link4 + +- [ ] #task Task in 'chain_link4' diff --git a/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Active Project note.md b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Active Project note.md new file mode 100644 index 0000000000..0e8e0d42c4 --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Active Project note.md @@ -0,0 +1,13 @@ +--- +created: 2025-09-22 +project: "[[Active Project]]" +themes: +--- + +# note + +# Active Project note + +--- + +- [ ] #task Active task diff --git a/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Active Project.md b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Active Project.md new file mode 100644 index 0000000000..054f017b14 --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Active Project.md @@ -0,0 +1,17 @@ +--- +created: 2025-09-22 +project: "[[Active Project]]" +themes: +status: active +priority: 5 medium +due: +dependencies: +--- + +# project #note + +# Active Project + +--- + +- [[Active Project note]] diff --git a/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Chaining links together.md b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Chaining links together.md new file mode 100644 index 0000000000..f1625e5c60 --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Chaining links together.md @@ -0,0 +1,32 @@ +# Chaining links together + +## Chaining links together in 'group by' instructions + +```tasks +filename includes chain_link + +# TODO Make 'group by' detect if the object is a link, and group by its markdown + +group by function 'Level 1 link: ' + \ + task.file.\ + propertyAsLink('link_to_file').markdown + +group by function 'Level 2 link: ' + \ + task.file.\ + propertyAsLink('link_to_file').asFile().\ + propertyAsLink('link_to_file').markdown + +group by function 'Level 3 link: ' + \ + task.file.\ + propertyAsLink('link_to_file').asFile().\ + propertyAsLink('link_to_file').asFile().\ + propertyAsLink('link_to_file').markdown + +group by function 'Level 4 link: ' + \ + task.file.\ + propertyAsLink('link_to_file').asFile().\ + propertyAsLink('link_to_file').asFile().\ + propertyAsLink('link_to_file').asFile().\ + propertyAsLink('link_to_file').markdown + +``` diff --git a/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Homepage - Bases.md b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Homepage - Bases.md new file mode 100644 index 0000000000..5df5476716 --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Homepage - Bases.md @@ -0,0 +1,46 @@ +# Homepage - Bases + +With Obsidian 1.10.0. + +```base +filters: + and: + - file.folder == this.file.folder +formulas: + project status: project.asFile().properties.status +views: + - type: table + name: All Notes + groupBy: + property: formula.project status + direction: ASC + order: + - file.name + - project + - formula.project status + - type: table + name: Active Project Notes + filters: + and: + - formula["project status"] == "active" + groupBy: + property: formula.project status + direction: ASC + order: + - file.name + - project + - formula.project status + - type: table + name: Ideas + filters: + and: + - formula["project status"] == "idea" + groupBy: + property: formula.project status + direction: ASC + order: + - file.name + - project + - formula.project status + +``` diff --git a/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Homepage - Dataview.md b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Homepage - Dataview.md new file mode 100644 index 0000000000..ed550caaef --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Homepage - Dataview.md @@ -0,0 +1,37 @@ +# Homepage - Dataview + +## Dataview - group by project + +If **either** note could have inline properties: + +```dataview +task +WHERE startswith(file.folder, this.file.folder) +GROUP BY file.frontmatter.project +``` + +If **neither** note could have inline properties: + +```dataview +task +WHERE startswith(file.folder, this.file.folder) +GROUP BY project +``` + +## Dataview - group by project status + +If **either** note could have inline properties: + +```dataview +task +WHERE startswith(file.folder, this.file.folder) +GROUP BY project.file.frontmatter.status +``` + +If **neither** note could have inline properties: + +```dataview +task +WHERE startswith(file.folder, this.file.folder) +GROUP BY project.status +``` diff --git a/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Homepage - Tasks.md b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Homepage - Tasks.md new file mode 100644 index 0000000000..114e59b1a9 --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Homepage - Tasks.md @@ -0,0 +1,55 @@ +# Homepage - Tasks + +## Tasks queries to visualise behaviour + +### Use 'group by' to visualise behaviour - see raw text + +```tasks +not done +preset this_folder + +group by function '1 `' + JSON.stringify(task.file.property("project")) + '`' +group by function '2 `' + JSON.stringify(task.file.propertyAsLink("project")?.destinationPath) + '`' +group by function '3 `' + JSON.stringify(task.file.propertyAsLink("project")?.asFile().property("status")) + '`' +``` + +### Use 'group by' to visualise behaviour - see rendered values + +```tasks +not done +preset this_folder + +group by function task.file.propertyAsLink("project")?.markdown ?? '' +``` + +## What the user requested + +I want to only filter tasks that: + +- are in active project (property status), every project has file representing it, +- but there are also other notes related to that project via project property. + +So in the example i provided, i only want Active task to be present. + +## Tasks searches + +### One instruction + +```tasks +not done +preset this_folder + +filter by function task.file.propertyAsLink("project")?.asFile()?.property("status") === "active" +``` + +### Two instructions + +Possibly slightly faster version? + +```tasks +not done +preset this_folder + +filter by function task.file.hasProperty("project") +filter by function task.file.propertyAsLink("project")?.asFile()?.property("status") === "active" +``` diff --git a/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Inactive Project note.md b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Inactive Project note.md new file mode 100644 index 0000000000..f1fd1cb824 --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Inactive Project note.md @@ -0,0 +1,13 @@ +--- +created: 2025-09-22 +project: "[[Inactive project]]" +themes: +--- + +# note + +# Inactive Project note + +--- + +- [ ] #task Inactive task diff --git a/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Inactive project.md b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Inactive project.md new file mode 100644 index 0000000000..4d6c98dd7a --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/Inactive project.md @@ -0,0 +1,17 @@ +--- +created: 2025-09-22 +project: "[[Inactive project]]" +themes: +status: idea +priority: 5 medium +due: +dependencies: +--- + +# project #note + +# Inactive project + +--- + +- [[Inactive Project note]] diff --git a/resources/sample_vaults/Tasks-Demo/discussion-3627-example/No project.md b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/No project.md new file mode 100644 index 0000000000..fac0b9f7c6 --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/discussion-3627-example/No project.md @@ -0,0 +1,12 @@ +--- +created: 2025-09-22 +themes: +--- + +# note + +# No Project note + +--- + +- [ ] #task No project task diff --git a/src/Obsidian/CacheReader.ts b/src/Obsidian/CacheReader.ts new file mode 100644 index 0000000000..3b995930c8 --- /dev/null +++ b/src/Obsidian/CacheReader.ts @@ -0,0 +1,10 @@ +import { App, type CachedMetadata, TFile } from 'obsidian'; + +export function globalGetFileCache(app: App, filePath: string) { + const tFile = app.vault.getAbstractFileByPath(filePath); + let fileCache: CachedMetadata | null = null; + if (tFile && tFile instanceof TFile) { + fileCache = app.metadataCache.getFileCache(tFile); + } + return fileCache; +} diff --git a/src/Renderer/QueryRenderer.ts b/src/Renderer/QueryRenderer.ts index 01f23929e7..266072faa0 100644 --- a/src/Renderer/QueryRenderer.ts +++ b/src/Renderer/QueryRenderer.ts @@ -20,6 +20,7 @@ import type { TasksEvents } from '../Obsidian/TasksEvents'; import { TasksFile } from '../Scripting/TasksFile'; import { DateFallback } from '../DateTime/DateFallback'; import type { Task } from '../Task/Task'; +import { globalGetFileCache } from '../Obsidian/CacheReader'; import { type BacklinksEventHandler, type EditButtonClickHandler, QueryResultsRenderer } from './QueryResultsRenderer'; import { createAndAppendElement } from './TaskLineRenderer'; @@ -57,17 +58,12 @@ export class QueryRenderer { // not yet available, so empty. // - Multi-line properties are supported, but they cannot contain // continuation lines. - const app = this.app; const filePath = context.sourcePath; - const tFile = app.vault.getAbstractFileByPath(filePath); - let fileCache: CachedMetadata | null = null; - if (tFile && tFile instanceof TFile) { - fileCache = app.metadataCache.getFileCache(tFile); - } + const fileCache = this.getFileCache(filePath); const tasksFile = new TasksFile(filePath, fileCache ?? {}); const queryRenderChild = new QueryRenderChild({ - app: app, + app: this.app, plugin: this.plugin, events: this.events, container: element, @@ -77,6 +73,10 @@ export class QueryRenderer { context.addChild(queryRenderChild); queryRenderChild.load(); } + + private getFileCache(filePath: string) { + return globalGetFileCache(this.app, filePath); + } } /** diff --git a/src/Scripting/TasksFile.ts b/src/Scripting/TasksFile.ts index cc6fa94160..dfbd87c239 100644 --- a/src/Scripting/TasksFile.ts +++ b/src/Scripting/TasksFile.ts @@ -245,6 +245,55 @@ export class TasksFile { return propertyValue; } + public propertyAsLink(key: string): Link | null { + const value = this.property(key); + if (typeof value === 'string' && this.isWikilink(value)) { + return this.parseWikilink(value); + } + return null; + } + + public propertyAsLinks(key: string): Link[] { + const value = this.property(key); + if (Array.isArray(value)) { + return value + .filter((item) => typeof item === 'string' && this.isWikilink(item)) + .map((item) => this.parseWikilink(item)); + } + const singleLink = this.propertyAsLink(key); + return singleLink ? [singleLink] : []; + } + + private isWikilink(value: string): boolean { + if (typeof value !== 'string') return false; + const trimmed = value.trim(); + return /^\[\[.*\]\]$/.test(trimmed) || /^!\[\[.*\]\]$/.test(trimmed); + } + + private parseWikilink(wikilink: string): Link { + const trimmed = wikilink.trim(); + + // Handle both regular links [[...]] and embed links ![[...]] + const isEmbed = trimmed.startsWith('![['); + const linkContent = isEmbed + ? trimmed.slice(3, -2) // Remove ![[ and ]] + : trimmed.slice(2, -2); // Remove [[ and ]] + + // Parse destination|display format + const pipeIndex = linkContent.indexOf('|'); + const destination = pipeIndex >= 0 ? linkContent.slice(0, pipeIndex) : linkContent; + const displayText = pipeIndex >= 0 ? linkContent.slice(pipeIndex + 1) : destination; + + // Create Reference object + const reference: Reference = { + link: destination, + original: wikilink, + displayText: displayText, + }; + + return new Link(reference, this.path); + } + private findKeyInFrontmatter(key: string) { const lowerCaseKey = key.toLowerCase(); return Object.keys(this.frontmatter).find((searchKey: string) => { diff --git a/src/Task/Link.ts b/src/Task/Link.ts index 02e38dda3c..785e89f254 100644 --- a/src/Task/Link.ts +++ b/src/Task/Link.ts @@ -1,5 +1,5 @@ import type { Reference } from 'obsidian'; -import type { TasksFile } from '../Scripting/TasksFile'; +import { TasksFile } from '../Scripting/TasksFile'; import { LinkResolver } from './LinkResolver'; export class Link { @@ -81,6 +81,23 @@ export class Link { return LinkResolver.getInstance().getDestinationPath(this.rawLink, this.pathContainingLink) ?? null; } + /** + * Returns a TasksFile for the destination of this link, if it exists. + * Returns null if the link destination doesn't exist or can't be resolved. + * + * Note: The returned TasksFile will have metadata if LinkResolver.getInstance().getFileCache + * has been configured. + */ + public asFile(): TasksFile | null { + const destPath = this.destinationPath; + if (!destPath) { + return null; + } + + const fileCache = LinkResolver.getInstance().getFileCache(destPath); + return new TasksFile(destPath, fileCache ?? {}); + } + /** * For "[[Styling of Queries]]", it would return "Styling of Queries" * For "[[link_in_task_wikilink|alias]]", it would return "alias" diff --git a/src/Task/LinkResolver.ts b/src/Task/LinkResolver.ts index 3ee533d850..131c0ddd42 100644 --- a/src/Task/LinkResolver.ts +++ b/src/Task/LinkResolver.ts @@ -1,34 +1,42 @@ -import type { Reference } from 'obsidian'; +import type { CachedMetadata, Reference } from 'obsidian'; export type GetFirstLinkpathDestFn = (rawLink: Reference, sourcePath: string) => string | null; +export type GetFileCacheFn = (filePath: string) => CachedMetadata | null; const defaultGetFirstLinkpathDestFn = (_rawLink: Reference, _sourcePath: string) => null; +const defaultGetFileCacheFn = (_filePath: string) => null; /** - * An abstraction to implement {@link Link.destinationPath}. + * An abstraction to implement {@link Link.destinationPath} and help {@link Link} to create {@link TasksFile} objects. * * See also: - * - `src/main.ts` - search for `LinkResolver.getInstance()` - * - Uses of {@link getFirstLinkpathDest} and {@link getFirstLinkpathDestFromData} in - * `tests/__mocks__/obsidian.ts`. + * - For how this is configured in the **released plugin**: + * - `src/main.ts` + * - For how this is configured in the **tests**: + * - `LinkResolver.setup.ts` */ export class LinkResolver { private static instance: LinkResolver; private getFirstLinkpathDestFn: GetFirstLinkpathDestFn = defaultGetFirstLinkpathDestFn; + private getFileCacheFn: GetFileCacheFn = defaultGetFileCacheFn; public setGetFirstLinkpathDestFn(getFirstLinkpathDestFn: GetFirstLinkpathDestFn) { this.getFirstLinkpathDestFn = getFirstLinkpathDestFn; } - public resetGetFirstLinkpathDestFn() { - this.getFirstLinkpathDestFn = defaultGetFirstLinkpathDestFn; + public setGetFileCacheFn(getFileCacheFn: GetFileCacheFn) { + this.getFileCacheFn = getFileCacheFn; } public getDestinationPath(rawLink: Reference, pathContainingLink: string) { return this.getFirstLinkpathDestFn(rawLink, pathContainingLink) ?? undefined; } + public getFileCache(filePath: string): CachedMetadata | null { + return this.getFileCacheFn(filePath); + } + /** * Provides access to the single global instance of the LinkResolver. * This should be used in the plugin code. diff --git a/src/main.ts b/src/main.ts index 619c80be8b..5c48f16b7b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -20,6 +20,7 @@ import { tasksApiV1 } from './Api'; import { GlobalFilter } from './Config/GlobalFilter'; import { QueryFileDefaults } from './Query/QueryFileDefaults'; import { LinkResolver } from './Task/LinkResolver'; +import { globalGetFileCache } from './Obsidian/CacheReader'; export default class TasksPlugin extends Plugin { private cache: Cache | undefined; @@ -49,6 +50,11 @@ export default class TasksPlugin extends Plugin { return tFile ? tFile.path : null; }); + // Configure LinkResolver.getInstance().getFileCache(): + LinkResolver.getInstance().setGetFileCacheFn((filePath: string) => { + return globalGetFileCache(this.app, filePath); + }); + const events = new TasksEvents({ obsidianEvents: this.app.workspace }); this.addSettingTab(new SettingsTab({ plugin: this, events })); diff --git a/tests/Obsidian/AllCacheSampleData.ts b/tests/Obsidian/AllCacheSampleData.ts index 9f7f409590..3229b1650f 100644 --- a/tests/Obsidian/AllCacheSampleData.ts +++ b/tests/Obsidian/AllCacheSampleData.ts @@ -11,6 +11,10 @@ export type MockDataName = | 'callout_labelled' | 'callouts_nested_issue_2890_labelled' | 'callouts_nested_issue_2890_unlabelled' + | 'chain_link1' + | 'chain_link2' + | 'chain_link3' + | 'chain_link4' | 'code_block_in_task' | 'comments_html_style' | 'comments_markdown_style' @@ -115,6 +119,10 @@ export const AllMockDataNames: MockDataName[] = [ 'callout_labelled', 'callouts_nested_issue_2890_labelled', 'callouts_nested_issue_2890_unlabelled', + 'chain_link1', + 'chain_link2', + 'chain_link3', + 'chain_link4', 'code_block_in_task', 'comments_html_style', 'comments_markdown_style', diff --git a/tests/Obsidian/__test_data__/chain_link1.json b/tests/Obsidian/__test_data__/chain_link1.json new file mode 100644 index 0000000000..1cf76056ef --- /dev/null +++ b/tests/Obsidian/__test_data__/chain_link1.json @@ -0,0 +1,136 @@ +{ + "cachedMetadata": { + "frontmatter": { + "link_to_file": "[[chain_link2]]" + }, + "frontmatterLinks": [ + { + "displayText": "chain_link2", + "key": "link_to_file", + "link": "chain_link2", + "original": "[[chain_link2]]" + } + ], + "frontmatterPosition": { + "end": { + "col": 3, + "line": 2, + "offset": 39 + }, + "start": { + "col": 0, + "line": 0, + "offset": 0 + } + }, + "headings": [ + { + "heading": "chain_link1", + "level": 1, + "position": { + "end": { + "col": 13, + "line": 4, + "offset": 54 + }, + "start": { + "col": 0, + "line": 4, + "offset": 41 + } + } + } + ], + "listItems": [ + { + "parent": -6, + "position": { + "end": { + "col": 33, + "line": 6, + "offset": 89 + }, + "start": { + "col": 0, + "line": 6, + "offset": 56 + } + }, + "task": " " + } + ], + "sections": [ + { + "position": { + "end": { + "col": 3, + "line": 2, + "offset": 39 + }, + "start": { + "col": 0, + "line": 0, + "offset": 0 + } + }, + "type": "yaml" + }, + { + "position": { + "end": { + "col": 13, + "line": 4, + "offset": 54 + }, + "start": { + "col": 0, + "line": 4, + "offset": 41 + } + }, + "type": "heading" + }, + { + "position": { + "end": { + "col": 33, + "line": 6, + "offset": 89 + }, + "start": { + "col": 0, + "line": 6, + "offset": 56 + } + }, + "type": "list" + } + ], + "tags": [ + { + "position": { + "end": { + "col": 11, + "line": 6, + "offset": 67 + }, + "start": { + "col": 6, + "line": 6, + "offset": 62 + } + }, + "tag": "#task" + } + ] + }, + "fileContents": "---\nlink_to_file: \"[[chain_link2]]\"\n---\n\n# chain_link1\n\n- [ ] #task Task in 'chain_link1'\n", + "filePath": "Test Data/chain_link1.md", + "getAllTags": [ + "#task" + ], + "parseFrontMatterTags": null, + "resolveLinkToPath": { + "chain_link2": "Test Data/chain_link2.md" + } +} \ No newline at end of file diff --git a/tests/Obsidian/__test_data__/chain_link2.json b/tests/Obsidian/__test_data__/chain_link2.json new file mode 100644 index 0000000000..5e042e3488 --- /dev/null +++ b/tests/Obsidian/__test_data__/chain_link2.json @@ -0,0 +1,136 @@ +{ + "cachedMetadata": { + "frontmatter": { + "link_to_file": "[[chain_link3]]" + }, + "frontmatterLinks": [ + { + "displayText": "chain_link3", + "key": "link_to_file", + "link": "chain_link3", + "original": "[[chain_link3]]" + } + ], + "frontmatterPosition": { + "end": { + "col": 3, + "line": 2, + "offset": 39 + }, + "start": { + "col": 0, + "line": 0, + "offset": 0 + } + }, + "headings": [ + { + "heading": "chain_link2", + "level": 1, + "position": { + "end": { + "col": 13, + "line": 4, + "offset": 54 + }, + "start": { + "col": 0, + "line": 4, + "offset": 41 + } + } + } + ], + "listItems": [ + { + "parent": -6, + "position": { + "end": { + "col": 33, + "line": 6, + "offset": 89 + }, + "start": { + "col": 0, + "line": 6, + "offset": 56 + } + }, + "task": " " + } + ], + "sections": [ + { + "position": { + "end": { + "col": 3, + "line": 2, + "offset": 39 + }, + "start": { + "col": 0, + "line": 0, + "offset": 0 + } + }, + "type": "yaml" + }, + { + "position": { + "end": { + "col": 13, + "line": 4, + "offset": 54 + }, + "start": { + "col": 0, + "line": 4, + "offset": 41 + } + }, + "type": "heading" + }, + { + "position": { + "end": { + "col": 33, + "line": 6, + "offset": 89 + }, + "start": { + "col": 0, + "line": 6, + "offset": 56 + } + }, + "type": "list" + } + ], + "tags": [ + { + "position": { + "end": { + "col": 11, + "line": 6, + "offset": 67 + }, + "start": { + "col": 6, + "line": 6, + "offset": 62 + } + }, + "tag": "#task" + } + ] + }, + "fileContents": "---\nlink_to_file: \"[[chain_link3]]\"\n---\n\n# chain_link2\n\n- [ ] #task Task in 'chain_link2'\n", + "filePath": "Test Data/chain_link2.md", + "getAllTags": [ + "#task" + ], + "parseFrontMatterTags": null, + "resolveLinkToPath": { + "chain_link3": "Test Data/chain_link3.md" + } +} \ No newline at end of file diff --git a/tests/Obsidian/__test_data__/chain_link3.json b/tests/Obsidian/__test_data__/chain_link3.json new file mode 100644 index 0000000000..069803d010 --- /dev/null +++ b/tests/Obsidian/__test_data__/chain_link3.json @@ -0,0 +1,136 @@ +{ + "cachedMetadata": { + "frontmatter": { + "link_to_file": "[[chain_link4]]" + }, + "frontmatterLinks": [ + { + "displayText": "chain_link4", + "key": "link_to_file", + "link": "chain_link4", + "original": "[[chain_link4]]" + } + ], + "frontmatterPosition": { + "end": { + "col": 3, + "line": 2, + "offset": 39 + }, + "start": { + "col": 0, + "line": 0, + "offset": 0 + } + }, + "headings": [ + { + "heading": "chain_link3", + "level": 1, + "position": { + "end": { + "col": 13, + "line": 4, + "offset": 54 + }, + "start": { + "col": 0, + "line": 4, + "offset": 41 + } + } + } + ], + "listItems": [ + { + "parent": -6, + "position": { + "end": { + "col": 33, + "line": 6, + "offset": 89 + }, + "start": { + "col": 0, + "line": 6, + "offset": 56 + } + }, + "task": " " + } + ], + "sections": [ + { + "position": { + "end": { + "col": 3, + "line": 2, + "offset": 39 + }, + "start": { + "col": 0, + "line": 0, + "offset": 0 + } + }, + "type": "yaml" + }, + { + "position": { + "end": { + "col": 13, + "line": 4, + "offset": 54 + }, + "start": { + "col": 0, + "line": 4, + "offset": 41 + } + }, + "type": "heading" + }, + { + "position": { + "end": { + "col": 33, + "line": 6, + "offset": 89 + }, + "start": { + "col": 0, + "line": 6, + "offset": 56 + } + }, + "type": "list" + } + ], + "tags": [ + { + "position": { + "end": { + "col": 11, + "line": 6, + "offset": 67 + }, + "start": { + "col": 6, + "line": 6, + "offset": 62 + } + }, + "tag": "#task" + } + ] + }, + "fileContents": "---\nlink_to_file: \"[[chain_link4]]\"\n---\n\n# chain_link3\n\n- [ ] #task Task in 'chain_link3'\n", + "filePath": "Test Data/chain_link3.md", + "getAllTags": [ + "#task" + ], + "parseFrontMatterTags": null, + "resolveLinkToPath": { + "chain_link4": "Test Data/chain_link4.md" + } +} \ No newline at end of file diff --git a/tests/Obsidian/__test_data__/chain_link4.json b/tests/Obsidian/__test_data__/chain_link4.json new file mode 100644 index 0000000000..8f699f1ffc --- /dev/null +++ b/tests/Obsidian/__test_data__/chain_link4.json @@ -0,0 +1,136 @@ +{ + "cachedMetadata": { + "frontmatter": { + "link_to_file": "[[chain_link1]]" + }, + "frontmatterLinks": [ + { + "displayText": "chain_link1", + "key": "link_to_file", + "link": "chain_link1", + "original": "[[chain_link1]]" + } + ], + "frontmatterPosition": { + "end": { + "col": 3, + "line": 2, + "offset": 39 + }, + "start": { + "col": 0, + "line": 0, + "offset": 0 + } + }, + "headings": [ + { + "heading": "chain_link4", + "level": 1, + "position": { + "end": { + "col": 13, + "line": 4, + "offset": 54 + }, + "start": { + "col": 0, + "line": 4, + "offset": 41 + } + } + } + ], + "listItems": [ + { + "parent": -6, + "position": { + "end": { + "col": 33, + "line": 6, + "offset": 89 + }, + "start": { + "col": 0, + "line": 6, + "offset": 56 + } + }, + "task": " " + } + ], + "sections": [ + { + "position": { + "end": { + "col": 3, + "line": 2, + "offset": 39 + }, + "start": { + "col": 0, + "line": 0, + "offset": 0 + } + }, + "type": "yaml" + }, + { + "position": { + "end": { + "col": 13, + "line": 4, + "offset": 54 + }, + "start": { + "col": 0, + "line": 4, + "offset": 41 + } + }, + "type": "heading" + }, + { + "position": { + "end": { + "col": 33, + "line": 6, + "offset": 89 + }, + "start": { + "col": 0, + "line": 6, + "offset": 56 + } + }, + "type": "list" + } + ], + "tags": [ + { + "position": { + "end": { + "col": 11, + "line": 6, + "offset": 67 + }, + "start": { + "col": 6, + "line": 6, + "offset": 62 + } + }, + "tag": "#task" + } + ] + }, + "fileContents": "---\nlink_to_file: \"[[chain_link1]]\"\n---\n\n# chain_link4\n\n- [ ] #task Task in 'chain_link4'\n", + "filePath": "Test Data/chain_link4.md", + "getAllTags": [ + "#task" + ], + "parseFrontMatterTags": null, + "resolveLinkToPath": { + "chain_link1": "Test Data/chain_link1.md" + } +} \ No newline at end of file diff --git a/tests/Obsidian/__test_data__/metadataCache/resolvedLinks.json b/tests/Obsidian/__test_data__/metadataCache/resolvedLinks.json index a5c904c0af..ee181cea4d 100644 --- a/tests/Obsidian/__test_data__/metadataCache/resolvedLinks.json +++ b/tests/Obsidian/__test_data__/metadataCache/resolvedLinks.json @@ -149,6 +149,18 @@ "Test Data/callout_labelled.md": {}, "Test Data/callouts_nested_issue_2890_labelled.md": {}, "Test Data/callouts_nested_issue_2890_unlabelled.md": {}, + "Test Data/chain_link1.md": { + "Test Data/chain_link2.md": 1 + }, + "Test Data/chain_link2.md": { + "Test Data/chain_link3.md": 1 + }, + "Test Data/chain_link3.md": { + "Test Data/chain_link4.md": 1 + }, + "Test Data/chain_link4.md": { + "Test Data/chain_link1.md": 1 + }, "Test Data/code_block_in_task.md": {}, "Test Data/comments_html_style.md": {}, "Test Data/comments_markdown_style.md": { @@ -278,5 +290,20 @@ "_meta/templates/tasks with range of all dates - Templater plugin.md": {}, "_meta/templates/tasks with range of due dates - Templater plugin.md": {}, "_meta/templates/tasks with range of scheduled dates - Templater plugin.md": {}, - "_meta/templates/tasks with range of start dates - Templater plugin.md": {} + "_meta/templates/tasks with range of start dates - Templater plugin.md": {}, + "discussion-3627-example/Active Project.md": { + "discussion-3627-example/Active Project note.md": 1 + }, + "discussion-3627-example/Active Project note.md": { + "discussion-3627-example/Active Project.md": 1 + }, + "discussion-3627-example/Homepage.md": {}, + "discussion-3627-example/Inactive Project note.md": { + "discussion-3627-example/Inactive project.md": 1 + }, + "discussion-3627-example/Inactive project.md": { + "discussion-3627-example/Inactive project.md": 1, + "discussion-3627-example/Inactive Project note.md": 1 + }, + "discussion-3627-example/No project.md": {} } \ No newline at end of file diff --git a/tests/Obsidian/__test_data__/metadataCache/unresolvedLinks.json b/tests/Obsidian/__test_data__/metadataCache/unresolvedLinks.json index bdf0ef2e06..fd2667ec3b 100644 --- a/tests/Obsidian/__test_data__/metadataCache/unresolvedLinks.json +++ b/tests/Obsidian/__test_data__/metadataCache/unresolvedLinks.json @@ -111,6 +111,10 @@ "Test Data/callout_labelled.md": {}, "Test Data/callouts_nested_issue_2890_labelled.md": {}, "Test Data/callouts_nested_issue_2890_unlabelled.md": {}, + "Test Data/chain_link1.md": {}, + "Test Data/chain_link2.md": {}, + "Test Data/chain_link3.md": {}, + "Test Data/chain_link4.md": {}, "Test Data/code_block_in_task.md": {}, "Test Data/comments_html_style.md": {}, "Test Data/comments_markdown_style.md": {}, @@ -204,5 +208,11 @@ "_meta/templates/tasks with range of all dates - Templater plugin.md": {}, "_meta/templates/tasks with range of due dates - Templater plugin.md": {}, "_meta/templates/tasks with range of scheduled dates - Templater plugin.md": {}, - "_meta/templates/tasks with range of start dates - Templater plugin.md": {} + "_meta/templates/tasks with range of start dates - Templater plugin.md": {}, + "discussion-3627-example/Active Project.md": {}, + "discussion-3627-example/Active Project note.md": {}, + "discussion-3627-example/Homepage.md": {}, + "discussion-3627-example/Inactive Project note.md": {}, + "discussion-3627-example/Inactive project.md": {}, + "discussion-3627-example/No project.md": {} } \ No newline at end of file diff --git a/tests/Scripting/QueryProperties.test.ts b/tests/Scripting/QueryProperties.test.ts index 4f5c49901f..058fb7bda5 100644 --- a/tests/Scripting/QueryProperties.test.ts +++ b/tests/Scripting/QueryProperties.test.ts @@ -12,12 +12,6 @@ import { getFirstLinkpathDestFromData } from '../__mocks__/obsidian'; import { MockDataLoader } from '../TestingTools/MockDataLoader'; import { addBackticks, determineExpressionType, formatToRepresentType } from './ScriptingTestHelpers'; -beforeEach(() => {}); - -afterEach(() => { - LinkResolver.getInstance().resetGetFirstLinkpathDestFn(); -}); - describe('query', () => { function verifyFieldDataForReferenceDocs(fields: string[]) { const markdownTable = new MarkdownTable(['Field', 'Type', 'Example']); @@ -25,7 +19,8 @@ describe('query', () => { const query_using_properties = MockDataLoader.get(testDataName); const cachedMetadata = getTasksFileFromMockData(testDataName).cachedMetadata; - // This is getting annoying, having to do this repeatedly. + // Because we are using a file path that differs from query_using_properties.md, + // we have to bypass MockDataLoader and specify which link resolver to user: LinkResolver.getInstance().setGetFirstLinkpathDestFn((rawLink: Reference, _sourcePath: string) => getFirstLinkpathDestFromData(query_using_properties, rawLink), ); diff --git a/tests/Scripting/TaskProperties.test.ts b/tests/Scripting/TaskProperties.test.ts index f2c96fc961..74769e012d 100644 --- a/tests/Scripting/TaskProperties.test.ts +++ b/tests/Scripting/TaskProperties.test.ts @@ -13,20 +13,12 @@ import { makeQueryContextWithTasks } from '../../src/Scripting/QueryContext'; import { TasksFile } from '../../src/Scripting/TasksFile'; import type { Task } from '../../src/Task/Task'; import { readTasksFromSimulatedFile } from '../Obsidian/SimulatedFile'; -import { LinkResolver } from '../../src/Task/LinkResolver'; -import { getFirstLinkpathDest } from '../__mocks__/obsidian'; import { addBackticks, determineExpressionType, formatToRepresentType } from './ScriptingTestHelpers'; window.moment = moment; // TODO Show a task in a callout, or an ordered list -beforeEach(() => {}); - -afterEach(() => { - LinkResolver.getInstance().resetGetFirstLinkpathDestFn(); -}); - describe('task', () => { function verifyFieldDataForReferenceDocs(fields: string[]) { const task1 = TaskBuilder.createFullyPopulatedTask(); @@ -155,9 +147,6 @@ describe('task', () => { }); it('links', () => { - // This is getting annoying, having to do this repeatedly. - LinkResolver.getInstance().setGetFirstLinkpathDestFn(getFirstLinkpathDest); - const tasks = readTasksFromSimulatedFile('links_everywhere'); verifyFieldDataFromTasksForReferenceDocs(tasks, [ 'task.outlinks', diff --git a/tests/Scripting/TasksFile.test.ts b/tests/Scripting/TasksFile.test.ts index 5b6faf3ee2..81b512f1de 100644 --- a/tests/Scripting/TasksFile.test.ts +++ b/tests/Scripting/TasksFile.test.ts @@ -8,10 +8,6 @@ import { getAllTags, getFirstLinkpathDest, parseFrontMatterTags } from '../__moc import { MockDataLoader } from '../TestingTools/MockDataLoader'; import { determineExpressionType, formatToRepresentType } from './ScriptingTestHelpers'; -afterEach(() => { - LinkResolver.getInstance().resetGetFirstLinkpathDestFn(); -}); - describe('TasksFile', () => { it('should provide access to path', () => { const path = 'a/b/c/d.md'; @@ -258,21 +254,11 @@ describe('TasksFile - accessing links', () => { } }); - it('should access all links in properties', () => { + it('should access all links in properties, for files obtained from MockDataLoader', () => { const tasksFile = getTasksFileFromMockData('link_in_yaml'); expect(tasksFile.outlinksInProperties.length).toEqual(1); expect(tasksFile.outlinksInProperties[0].originalMarkdown).toEqual('[[yaml_tags_is_empty]]'); - expect(tasksFile.outlinksInProperties[0].destinationPath).toBeNull(); - }); - - it('should save destinationPath when LinksResolver is supplied', () => { - LinkResolver.getInstance().setGetFirstLinkpathDestFn( - (_rawLink: Reference, _sourcePath: string) => 'Hello World.md', - ); - - const tasksFile = getTasksFileFromMockData('link_in_yaml'); - expect(tasksFile.outlinksInProperties[0].originalMarkdown).toEqual('[[yaml_tags_is_empty]]'); - expect(tasksFile.outlinksInProperties[0].destinationPath).toEqual('Hello World.md'); + expect(tasksFile.outlinksInProperties[0].destinationPath).toEqual('Test Data/yaml_tags_is_empty.md'); }); }); @@ -363,6 +349,12 @@ describe('TasksFile - reading tags', () => { }); describe('TasksFile - properties', () => { + beforeEach(() => { + LinkResolver.getInstance().setGetFirstLinkpathDestFn((rawLink: Reference, sourcePath: string) => { + return getFirstLinkpathDest(rawLink, sourcePath); + }); + }); + it('should not have any properties in a file with empty frontmatter', () => { const tasksFile = getTasksFileFromMockData('yaml_all_property_types_empty'); @@ -433,6 +425,37 @@ describe('TasksFile - properties', () => { expect(tasksFile.hasProperty('capital_property')).toEqual(true); expect(tasksFile.property('capital_property')).toEqual('some value'); }); + + it('should obtain a single property as a Link', () => { + const tasksFile = getTasksFileFromMockData('link_in_yaml'); + + const link = tasksFile.propertyAsLink('test-link'); + + expect(link).not.toBeNull(); + expect(link?.originalMarkdown).toEqual('[[yaml_tags_is_empty]]'); + expect(link?.destinationPath).toEqual('Test Data/yaml_tags_is_empty.md'); + }); + + it('should obtain a single property as an array of Links', () => { + const tasksFile = getTasksFileFromMockData('link_in_yaml'); + + const links = tasksFile.propertyAsLinks('test-link'); + + expect(links.length).toEqual(1); + expect(links[0].originalMarkdown).toEqual('[[yaml_tags_is_empty]]'); + }); + + it('should obtain a list property as an array of Links', () => { + const tasksFile = getTasksFileFromMockData('yaml_all_property_types_populated'); + + const links = tasksFile.propertyAsLinks('sample_link_list_property'); + + expect(links.length).toEqual(2); + expect(links[0].originalMarkdown).toEqual('[[yaml_all_property_types_populated]]'); + expect(links[0].destinationPath).toEqual('Test Data/yaml_all_property_types_populated.md'); + expect(links[1].originalMarkdown).toEqual('[[yaml_all_property_types_empty]]'); + expect(links[1].destinationPath).toEqual('Test Data/yaml_all_property_types_empty.md'); + }); }); describe('TasksFile - identicalTo', () => { diff --git a/tests/Task/Link.test.ts b/tests/Task/Link.test.ts index 2fb3a3da06..69189f12fe 100644 --- a/tests/Task/Link.test.ts +++ b/tests/Task/Link.test.ts @@ -5,19 +5,12 @@ import { AllMockDataNames, type MockDataName } from '../Obsidian/AllCacheSampleD import { addBackticks, formatToRepresentType } from '../Scripting/ScriptingTestHelpers'; import { getTasksFileFromMockData } from '../TestingTools/MockDataHelpers'; import { verifyMarkdown } from '../TestingTools/VerifyMarkdown'; -import { LinkResolver } from '../../src/Task/LinkResolver'; -import { getFirstLinkpathDest, getFirstLinkpathDestFromData } from '../__mocks__/obsidian'; import { MockDataLoader } from '../TestingTools/MockDataLoader'; function getLink(testDataName: MockDataName, index: number) { const data = MockDataLoader.get(testDataName); expect(data.cachedMetadata.links).toBeDefined(); const rawLink = data.cachedMetadata.links![index]; - const destinationPath = getFirstLinkpathDestFromData(data, rawLink); - - const resolver = LinkResolver.getInstance(); - resolver.setGetFirstLinkpathDestFn(() => destinationPath); - return new Link(rawLink, data.filePath); } @@ -34,15 +27,79 @@ describe('linkClass', () => { expect(link.linksTo('link_in_file_body.md')).toEqual(true); }); - describe('getLink() configures Link.destinationPath automatically', () => { + describe('getLink() configures Link.destinationPath and asFile automatically', () => { it('should set the full path for a resolved link', () => { const link = getLink('link_in_heading', 0); expect(link.destinationPath).toEqual('Test Data/multiple_headings.md'); + expect(link.asFile()?.path).toEqual('Test Data/multiple_headings.md'); }); it('should not set the full path for a broken/unresolved link', () => { const link = getLink('link_is_broken', 0); expect(link.destinationPath).toEqual(null); + expect(link.asFile()).toEqual(null); + }); + + it('should follow a frontmatter link to get a property in the destination file', () => { + const tasksFile = getTasksFileFromMockData('docs_sample_for_task_properties_reference'); + + const link = tasksFile.propertyAsLink('sample_link_property'); + expect(link?.originalMarkdown).toEqual('[[yaml_all_property_types_populated]]'); + expect(link?.destinationPath).toEqual('Test Data/yaml_all_property_types_populated.md'); + + const linkAsFile = link?.asFile(); + expect(linkAsFile?.path).toEqual('Test Data/yaml_all_property_types_populated.md'); + expect(linkAsFile?.property('sample_text_property')).toEqual('Sample Text Value'); + + expect( + tasksFile.propertyAsLink('sample_link_property')?.asFile()?.property('sample_text_property'), + ).toEqual('Sample Text Value'); + }); + + it('should follow a chain of links', () => { + const tasksFile = getTasksFileFromMockData('chain_link1'); + + // There are 4 files, all of which have a property called 'link_to_file', which: + // - links to the next file in the list + // - the last file links to the first + // chain_link1 + // chain_link2 + // chain_link3 + // chain_link4 + + // Test 1: Direct link (1 hop) + expect( + // prettier-ignore + tasksFile. + propertyAsLink('link_to_file')?.asFile()?.path, // hop 2 + ).toEqual('Test Data/chain_link2.md'); + + // Test 2: Chain through 1 intermediate file (2 hops) + expect( + // prettier-ignore + tasksFile. + propertyAsLink('link_to_file')?.asFile()?. // hop 1 + propertyAsLink('link_to_file')?.asFile()?.path, // hop 2 + ).toEqual('Test Data/chain_link3.md'); + + // Test 3: Chain through 2 intermediate files (3 hops) + expect( + // prettier-ignore + tasksFile. + propertyAsLink('link_to_file')?.asFile()?. // hop 1 + propertyAsLink('link_to_file')?.asFile()?. // hop 2 + propertyAsLink('link_to_file')?.asFile()?.path, // hop 3 + ).toEqual('Test Data/chain_link4.md'); + + // Test 4: Chain through 3 intermediate files (4 hops) - circular back to start + expect( + // prettier-ignore + tasksFile. + propertyAsLink('link_to_file')?.asFile()?. // hop 1 + propertyAsLink('link_to_file')?.asFile()?. // hop 2 + propertyAsLink('link_to_file')?.asFile()?. // hop 3 + propertyAsLink('link_to_file')?.asFile()?.path, // hop 4 + ).toEqual('Test Data/chain_link1.md'); }); }); @@ -301,14 +358,6 @@ describe('linkClass', () => { }); describe('visualise links', () => { - beforeAll(() => { - LinkResolver.getInstance().setGetFirstLinkpathDestFn(getFirstLinkpathDest); - }); - - afterAll(() => { - LinkResolver.getInstance().resetGetFirstLinkpathDestFn(); - }); - function createRow(field: string, value: string | undefined): string { // We use NBSP - non-breaking spaces - so that the approved file content // is correctly aligned when viewed in Obsidian: diff --git a/tests/Task/Link.test.visualise_links_outlinks.approved.md b/tests/Task/Link.test.visualise_links_outlinks.approved.md index 1e43b22ed9..76a2750572 100644 --- a/tests/Task/Link.test.visualise_links_outlinks.approved.md +++ b/tests/Task/Link.test.visualise_links_outlinks.approved.md @@ -132,6 +132,38 @@ `link.destinationPath      `: `'Test Attachments/markdownLink.md'` `link.displayText          `: `'markdownLink with alias and block'` +## Test Data/chain_link1.md + +`link.originalMarkdown     `: `'[[chain_link2]]'` +`link.markdown             `: `'[[chain_link2]]'` +`link.destination          `: `'chain_link2'` +`link.destinationPath      `: `'Test Data/chain_link2.md'` +`link.displayText          `: `'chain_link2'` + +## Test Data/chain_link2.md + +`link.originalMarkdown     `: `'[[chain_link3]]'` +`link.markdown             `: `'[[chain_link3]]'` +`link.destination          `: `'chain_link3'` +`link.destinationPath      `: `'Test Data/chain_link3.md'` +`link.displayText          `: `'chain_link3'` + +## Test Data/chain_link3.md + +`link.originalMarkdown     `: `'[[chain_link4]]'` +`link.markdown             `: `'[[chain_link4]]'` +`link.destination          `: `'chain_link4'` +`link.destinationPath      `: `'Test Data/chain_link4.md'` +`link.displayText          `: `'chain_link4'` + +## Test Data/chain_link4.md + +`link.originalMarkdown     `: `'[[chain_link1]]'` +`link.markdown             `: `'[[chain_link1]]'` +`link.destination          `: `'chain_link1'` +`link.destinationPath      `: `'Test Data/chain_link1.md'` +`link.displayText          `: `'chain_link1'` + ## Test Data/comments_markdown_style.md `link.originalMarkdown     `: `'[[comments_html_style]]'` diff --git a/tests/Task/Link.test.visualise_links_properties.approved.md b/tests/Task/Link.test.visualise_links_properties.approved.md index c6f214b5d9..5b4b98b1ad 100644 --- a/tests/Task/Link.test.visualise_links_properties.approved.md +++ b/tests/Task/Link.test.visualise_links_properties.approved.md @@ -1,3 +1,35 @@ +## Test Data/chain_link1.md + +`link.originalMarkdown     `: `'[[chain_link2]]'` +`link.markdown             `: `'[[chain_link2]]'` +`link.destination          `: `'chain_link2'` +`link.destinationPath      `: `'Test Data/chain_link2.md'` +`link.displayText          `: `'chain_link2'` + +## Test Data/chain_link2.md + +`link.originalMarkdown     `: `'[[chain_link3]]'` +`link.markdown             `: `'[[chain_link3]]'` +`link.destination          `: `'chain_link3'` +`link.destinationPath      `: `'Test Data/chain_link3.md'` +`link.displayText          `: `'chain_link3'` + +## Test Data/chain_link3.md + +`link.originalMarkdown     `: `'[[chain_link4]]'` +`link.markdown             `: `'[[chain_link4]]'` +`link.destination          `: `'chain_link4'` +`link.destinationPath      `: `'Test Data/chain_link4.md'` +`link.displayText          `: `'chain_link4'` + +## Test Data/chain_link4.md + +`link.originalMarkdown     `: `'[[chain_link1]]'` +`link.markdown             `: `'[[chain_link1]]'` +`link.destination          `: `'chain_link1'` +`link.destinationPath      `: `'Test Data/chain_link1.md'` +`link.displayText          `: `'chain_link1'` + ## Test Data/docs_sample_for_task_properties_reference.md `link.originalMarkdown     `: `'[[yaml_all_property_types_populated]]'` diff --git a/tests/Task/LinkResolver.setup.ts b/tests/Task/LinkResolver.setup.ts new file mode 100644 index 0000000000..34ee45c431 --- /dev/null +++ b/tests/Task/LinkResolver.setup.ts @@ -0,0 +1,13 @@ +import type { Reference } from 'obsidian'; +import { LinkResolver } from '../../src/Task/LinkResolver'; +import { getFirstLinkpathDest } from '../__mocks__/obsidian'; +import { MockDataLoader } from '../TestingTools/MockDataLoader'; + +beforeAll(() => { + LinkResolver.getInstance().setGetFirstLinkpathDestFn((rawLink: Reference, sourcePath: string) => { + return getFirstLinkpathDest(rawLink, sourcePath); + }); + LinkResolver.getInstance().setGetFileCacheFn( + (filePath: string) => MockDataLoader.findDataFromMarkdownPath(filePath).cachedMetadata, + ); +}); diff --git a/tests/Task/LinkResolver.test.ts b/tests/Task/LinkResolver.test.ts index 57ae4c7e37..db9bf43dfd 100644 --- a/tests/Task/LinkResolver.test.ts +++ b/tests/Task/LinkResolver.test.ts @@ -11,18 +11,11 @@ describe('LinkResolver', () => { rawLink = link_in_file_body.cachedMetadata.links![0]; }); - it('should resolve a link via local instance', () => { + it('should resolve a link', () => { const link = new Link(rawLink, link_in_file_body.filePath); expect(link.originalMarkdown).toEqual('[[yaml_tags_is_empty]]'); - expect(link.destinationPath).toBeNull(); - }); - - it('should resolve a link via global instance', () => { - const link = new Link(rawLink, link_in_file_body.filePath); - - expect(link.originalMarkdown).toEqual('[[yaml_tags_is_empty]]'); - expect(link.destinationPath).toBeNull(); + expect(link.destinationPath).toEqual('Test Data/yaml_tags_is_empty.md'); }); it('should allow a function to be supplied, to find the destination of a link', () => { @@ -32,29 +25,4 @@ describe('LinkResolver', () => { const link = new Link(rawLink, link_in_file_body.filePath); expect(link.destinationPath).toEqual('Hello World.md'); }); - - it('should allow the global instance to be reset', () => { - const globalInstance = LinkResolver.getInstance(); - globalInstance.setGetFirstLinkpathDestFn(() => 'From Global Instance.md'); - - const link1 = new Link(rawLink, link_in_file_body.filePath); - expect(link1.destinationPath).toEqual('From Global Instance.md'); - - globalInstance.resetGetFirstLinkpathDestFn(); - - const link2 = new Link(rawLink, link_in_file_body.filePath); - expect(link2.destinationPath).toBeNull(); - }); - - it('resetting global instance affects pre-existing links', () => { - const globalInstance = LinkResolver.getInstance(); - globalInstance.setGetFirstLinkpathDestFn(() => 'From Global Instance.md'); - - const link1 = new Link(rawLink, link_in_file_body.filePath); - expect(link1.destinationPath).toEqual('From Global Instance.md'); - - globalInstance.resetGetFirstLinkpathDestFn(); - - expect(link1.destinationPath).toBeNull(); - }); }); diff --git a/tests/Task/ListItem.test.ts b/tests/Task/ListItem.test.ts index dea4a0215e..163283b8d4 100644 --- a/tests/Task/ListItem.test.ts +++ b/tests/Task/ListItem.test.ts @@ -3,7 +3,6 @@ */ import moment from 'moment/moment'; -import type { Reference } from 'obsidian'; import { TasksFile } from '../../src/Scripting/TasksFile'; import { ListItem } from '../../src/Task/ListItem'; import { Task } from '../../src/Task/Task'; @@ -11,17 +10,12 @@ import { TaskLocation } from '../../src/Task/TaskLocation'; import { TaskBuilder } from '../TestingTools/TaskBuilder'; import { fromLine } from '../TestingTools/TestHelpers'; import { readTasksFromSimulatedFile } from '../Obsidian/SimulatedFile'; -import { LinkResolver } from '../../src/Task/LinkResolver'; import { createChildListItem } from './ListItemHelpers'; window.moment = moment; const taskLocation = TaskLocation.fromUnknownPosition(new TasksFile('anything.md')); -afterEach(() => { - LinkResolver.getInstance().resetGetFirstLinkpathDestFn(); -}); - describe('list item tests', () => { it('should create list item with empty children and absent parent', () => { const listItem = ListItem.fromListItemLine('- list item', null, taskLocation)!; @@ -217,25 +211,14 @@ describe('outlinks', () => { expect(tasks[0].outlinks.length).toEqual(1); expect(tasks[0].outlinks[0].originalMarkdown).toEqual('[[link_in_task_wikilink]]'); - expect(tasks[0].outlinks[0].destinationPath).toBeNull(); + // If the next test fails, LinkResolver.getInstance().setGetFirstLinkpathDestFn() is not set up + expect(tasks[0].outlinks[0].destinationPath).toEqual('Test Data/link_in_task_wikilink.md'); }); it('should return [] when no links in the task line', () => { const tasks = readTasksFromSimulatedFile('multi_line_task_and_list_item'); expect(tasks[0].outlinks).toEqual([]); }); - - it('should save destinationPath when LinksResolver is supplied', () => { - LinkResolver.getInstance().setGetFirstLinkpathDestFn( - (_rawLink: Reference, _sourcePath: string) => 'Hello World.md', - ); - - const tasks = readTasksFromSimulatedFile('links_everywhere'); - - expect(tasks[0].outlinks.length).toEqual(1); - expect(tasks[0].outlinks[0].originalMarkdown).toEqual('[[link_in_task_wikilink]]'); - expect(tasks[0].outlinks[0].destinationPath).toEqual('Hello World.md'); - }); }); describe('identicalTo', () => { diff --git a/tests/TestingTools/MockDataLoader.test.ts b/tests/TestingTools/MockDataLoader.test.ts index a9942b49e5..0840c1f548 100644 --- a/tests/TestingTools/MockDataLoader.test.ts +++ b/tests/TestingTools/MockDataLoader.test.ts @@ -67,7 +67,7 @@ describe('MockDataLoader', () => { expect(t).toThrowError('FrontMatterCache not found in any loaded SimulatedFile'); }); - it('should locate loaded SimulatedFile from its path', () => { + it('should locate loaded SimulatedFile from its path, for paths already loaded', () => { const data1 = MockDataLoader.get('callout'); const data2 = MockDataLoader.get('no_yaml'); @@ -75,11 +75,18 @@ describe('MockDataLoader', () => { expect(MockDataLoader.findDataFromMarkdownPath('Test Data/no_yaml.md')).toBe(data2); }); + it('should locate not-yet-loaded SimulatedFile from its path, for paths not yet loaded', () => { + // This test is only guaranteed to really test the behaviour when it is run on its own, + // as earlier tests may already have loaded 'callout'. + const data = MockDataLoader.findDataFromMarkdownPath('Test Data/callout.md'); + expect(data.filePath).toEqual('Test Data/callout.md'); + }); + it('should detect call of findDataFromMarkdownPath() with unknown path', () => { const t = () => { MockDataLoader.findDataFromMarkdownPath('Test Data/non-existent path.md'); }; expect(t).toThrow(Error); - expect(t).toThrowError('Markdown path not found in any loaded SimulatedFile'); + expect(t).toThrowError('Markdown path not found in any SimulatedFile'); }); }); diff --git a/tests/TestingTools/MockDataLoader.ts b/tests/TestingTools/MockDataLoader.ts index 421f5a11c0..684802ed23 100644 --- a/tests/TestingTools/MockDataLoader.ts +++ b/tests/TestingTools/MockDataLoader.ts @@ -4,7 +4,7 @@ import path from 'path'; import type { CachedMetadata, FrontMatterCache } from 'obsidian'; import type { SimulatedFile } from '../Obsidian/SimulatedFile'; -import type { MockDataName } from '../Obsidian/AllCacheSampleData'; +import { AllMockDataNames, type MockDataName } from '../Obsidian/AllCacheSampleData'; /** * Utility class for loading and caching test data saved from Obsidian's cache. @@ -105,19 +105,21 @@ export class MockDataLoader { /** * Find the {@link SimulatedFile} that matches the specified Markdown file path. * - * Searches through all cached {@link SimulatedFile} entries to find the one whose + * Searches through all {@link SimulatedFile} entries to find the one whose * filePath property exactly matches the provided Markdown path. This enables * lookup of test data by the original file path from the test vault. * * @param markdownPath - The Markdown file path to search for (such as "Test Data/example.md") * @returns The SimulatedFile with the matching file path - * @throws Error if no matching SimulatedFile is found in the cache + * @throws Error if no matching SimulatedFile is found on disk */ public static findDataFromMarkdownPath(markdownPath: string) { - return this.findByPredicate( - (simulatedFile) => simulatedFile.filePath === markdownPath, - 'Markdown path not found in any loaded SimulatedFile', - ); + for (const allMockDataName of AllMockDataNames) { + if (MockDataLoader.markdownPath(allMockDataName) === markdownPath) { + return MockDataLoader.get(allMockDataName); + } + } + throw new Error(`Markdown path not found in any SimulatedFile: '${markdownPath}' `); } /**