Repository: obsidian-tasks-group/obsidian-tasks
Pull request #1258 (2022-10-25 23:39:15)

feat: use filename as date fallback
<!--- Provide a general summary of your changes in the Title above -->

# Description

When no scheduled/due/start day is set, set the scheduled day from the filename.

- User settings to enable, limit scope to chosen folders
- parse date in YYYY-MM-DD or YYYYMMDD format (compatible with dataview)
- scheduled date is implicit (task line not changed)

## Motivation and Context

In some workflows tasks added to certain notes (eg. dailies) can be considered to be scheduled at the day of the note. Currently one needs to add date fields to all the tasks in such notes to make them queriable by the file date.

This PR automatically uses the file date as a fallback for the scheduled date.

This has the added benefit that tasks can be rescheduled by moving them to a new file without having to modify any date.

See also :
https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/1230

## How has this been tested?

- Unit tests on all the code aspects that affect the Task object.
- Manual testing, also by @claremacrae (thanks for the help !)
- Daily use 

## Screenshots (if appropriate)

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

Changes visible to users:

- [ ] **Bug fix** (prefix: `fix` - non-breaking change which fixes an issue)
- [x] **New feature** (prefix: `feat` - non-breaking change which adds functionality)
- [ ] **Breaking change** (prefix: `feat!!` or `fix!!` - fix or feature that would cause existing functionality to not work as expected)
- [x] **Documentation** (prefix: `docs` - improvements to any documentation content)
- [ ] **Sample vault** (prefix: `vault` - improvements to the [Tasks-Demo sample vault](https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/resources/sample_vaults/Tasks-Demo))

Internal changes:

- [ ] **Refactor** (prefix: `refactor` - non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [ ] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)
- [ ] **Infrastructure** (prefix: `chore` - examples include GitHub Actions, issue templates)

## Checklist

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [x] My change requires a change to the documentation.
- [x] I have [updated the documentation](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md#updating-documentation) accordingly.
- [x] My change has adequate [Unit Test coverage](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md#maintaining-the-tests).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md) and
[Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md).
Put an x in the boxes to confirm you agree.
-->

- [x] My contribution follow this project's [contributing guide](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md)
- [x] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)


claremacrae: Many thanks @mvdkwast - I will review it as soon as I can!
claremacrae: Assigning back to me, for final code review
claremacrae: I'm currently merging `main` in to this branch to resolve conflicts in settings changes, and will then merge in the PR...

Patch:
diff --git a/docs/getting-started/auto-suggest.md b/docs/getting-started/auto-suggest.md
index b961dab4..23173d07 100644
--- a/docs/getting-started/auto-suggest.md
+++ b/docs/getting-started/auto-suggest.md
@@ -1,7 +1,7 @@
 ---
 layout: default
 title: Auto-Suggest
-nav_order: 5
+nav_order: 6
 parent: Getting Started
 has_toc: false
 ---
diff --git a/docs/getting-started/create-or-edit-task.md b/docs/getting-started/create-or-edit-task.md
index 32ef9994..510a4a14 100644
--- a/docs/getting-started/create-or-edit-task.md
+++ b/docs/getting-started/create-or-edit-task.md
@@ -1,7 +1,7 @@
 ---
 layout: default
 title: Create or edit Task
-nav_order: 6
+nav_order: 7
 parent: Getting Started
 has_toc: false
 ---
diff --git a/docs/getting-started/use-filename-as-date-fallback.md b/docs/getting-started/use-filename-as-date-fallback.md
new file mode 100644
index 00000000..9a16303f
--- /dev/null
+++ b/docs/getting-started/use-filename-as-date-fallback.md
@@ -0,0 +1,68 @@
+---
+layout: default
+title: Use Filename as Date Fallback
+nav_order: 5
+parent: Getting Started
+has_toc: false
+---
+
+# Use Filename as Date Fallback
+
+You can automatically set a scheduled date for tasks based on the name of its file. This feature can be enabled in the
+settings : `Use filename as date fallback` (requires a restart of Obsidian).
+
+This allows you, for instance, to make all the tasks in your daily notes to be considered as scheduled. You can then
+query them using [filters]({{ site.baseurl }}{% link queries/filters.md %}) on the scheduled date (`scheduled`
+, `happens`).
+
+Example query :
+
+````text
+```tasks
+scheduled before today
+not done
+```
+````
+
+<div class="code-example" markdown="1">
+Info
+{: .label .label-blue }
+The task not visually modified in the file : the scheduled date is only implied. It will however appear in the
+[edit dialog]({{ site.baseurl }}{% link getting-started/create-or-edit-task.md %}).
+</div>
+
+## Rules
+
+For the scheduled date to be automatically set from the file name, the following rules apply :
+
+- the task has no existing scheduled date, due date or start date.
+- the file name contains a date in the format `YYYY-MM-DD` or `YYYYMMDD`.
+- the file is in one of the configured folders or its sub-folders if that setting is enabled.
+
+Examples of file names :
+
+```text
+daily/2022-10-12 Wednesday.md
+meetings/rd. 2022-09-07.md
+20220612 - random thoughts.md
+```
+
+### Folders setting
+
+If the `Folders with date fallback` field is left empty in the settings dialog, date fallback is applied to all the
+files in the vault. A comma-separated list of folders can be entered to limit the scope. Date fallback will only be done
+is the specified folders and their subfolders.
+
+Examples:
+
+| Folders setting | Matching files                                                                    | Not matching                           |
+|-----------------|-----------------------------------------------------------------------------------|----------------------------------------|
+| (empty)         | 20221022.md<br/>daily/20221012.md                                                 |                                        |
+| daily           | daily/2022-10-12.md<br/>daily/notes/2022-10-12.md                                 | 20221022.md<br/>meetings/2022-10-12.md |
+| daily,prj/daily | daily/2022-10-12.md<br/>prj/daily/2022-10-12.md<br/>prj/daily/notes/2022-06-12.md | 20221022.md<br/>meetings/2022-10-12.md |
+
+<div class="code-example" markdown="1">
+Warning
+{: .label .label-yellow }
+Folders with a comma (`,`) in their name are not supported.
+</div>
diff --git a/src/Cache.ts b/src/Cache.ts
index bbe303a6..88ea4bf8 100644
--- a/src/Cache.ts
+++ b/src/Cache.ts
@@ -5,6 +5,9 @@ import { Mutex } from 'async-mutex';
 
 import { Task } from './Task';
 import type { TasksEvents } from './TasksEvents';
+import { DateFallback } from './DateFallback';
+import { getSettings } from './Config/Settings';
+import { Lazy } from './lib/Lazy';
 
 export enum State {
     Cold = 'Cold',
@@ -105,6 +108,8 @@ export class Cache {
     }
 
     private subscribeToVault(): void {
+        const { enableDateFallback } = getSettings();
+
         const createdEventReference = this.vault.on('create', (file: TAbstractFile) => {
             if (!(file instanceof TFile)) {
                 return;
@@ -137,9 +142,15 @@ export class Cache {
             }
 
             this.tasksMutex.runExclusive(() => {
+                const fallbackDate = new Lazy(() => DateFallback.fromPath(file.path));
+
                 this.tasks = this.tasks.map((task: Task): Task => {
                     if (task.path === oldPath) {
-                        return new Task({ ...task, path: file.path });
+                        if (!enableDateFallback) {
+                            return new Task({ ...task, path: file.path });
+                        } else {
+                            return DateFallback.updateTaskPath(task, file.path, fallbackDate.value);
+                        }
                     } else {
                         return task;
                     }
@@ -231,6 +242,9 @@ export class Cache {
         const tasks: Task[] = [];
         const fileLines = fileContent.split('\n');
 
+        // Lazily store date extracted from filename to avoid parsing more than needed
+        const dateFromFileName = new Lazy(() => DateFallback.fromPath(file.path));
+
         // We want to store section information with every task so
         // that we can use that when we post process the markdown
         // rendered lists.
@@ -257,6 +271,7 @@ export class Cache {
                     sectionStart: currentSection.position.start.line,
                     sectionIndex,
                     precedingHeader: Cache.getPrecedingHeader(listItem.position.start.line, fileCache.headings),
+                    fallbackDate: dateFromFileName.value,
                 });
 
                 if (task !== null) {
diff --git a/src/Commands/CreateOrEdit.ts b/src/Commands/CreateOrEdit.ts
index 149b8bd0..46a58623 100644
--- a/src/Commands/CreateOrEdit.ts
+++ b/src/Commands/CreateOrEdit.ts
@@ -1,6 +1,7 @@
 import { App, Editor, MarkdownView, View } from 'obsidian';
 import { TaskModal } from '../TaskModal';
 import { Priority, Status, Task, TaskRegularExpressions } from '../Task';
+import { DateFallback } from '../DateFallback';
 
 export const createOrEdit = (checking: boolean, editor: Editor, view: View, app: App) => {
     if (checking) {
@@ -22,8 +23,22 @@ export const createOrEdit = (checking: boolean, editor: Editor, view: View, app:
     const line = editor.getLine(lineNumber);
     const task = taskFromLine({ line, path });
 
+    // save the inferred scheduled to compare it later
+    const inferredScheduledDate = task.scheduledDateIsInferred ? task.scheduledDate : null;
+
     const onSubmit = (updatedTasks: Task[]): void => {
-        const serialized = updatedTasks.map((task: Task) => task.toFileLineString()).join('\n');
+        const serialized = updatedTasks
+            .map((task: Task) => {
+                if (inferredScheduledDate !== null && !inferredScheduledDate.isSame(task.scheduledDate, 'day')) {
+                    // if a fallback date was used before modification, and the scheduled date was modified, we have to mark
+                    // the scheduled date as not inferred anymore.
+                    task = new Task({ ...task, scheduledDateIsInferred: false });
+                }
+
+                return task;
+            })
+            .map((task: Task) => task.toFileLineString())
+            .join('\n');
         editor.setLine(lineNumber, serialized);
     };
 
@@ -37,12 +52,15 @@ export const createOrEdit = (checking: boolean, editor: Editor, view: View, app:
 };
 
 const taskFromLine = ({ line, path }: { line: string; path: string }): Task => {
+    const fallbackDate = DateFallback.fromPath(path);
+
     const task = Task.fromLine({
         line,
         path,
         sectionStart: 0, // We don't need this to toggle it here in the editor.
         sectionIndex: 0, // We don't need this to toggle it here in the editor.
         precedingHeader: null, // We don't need this to toggle it here in the editor.
+        fallbackDate, // set the scheduled date from the filename, so it can be displayed in the dialog
     });
 
     if (task !== null) {
@@ -74,6 +92,7 @@ const taskFromLine = ({ line, path }: { line: string; path: string }): Task => {
             blockLink: '',
             tags: [],
             originalMarkdown: '',
+            scheduledDateIsInferred: false,
         });
     }
 
@@ -108,5 +127,7 @@ const taskFromLine = ({ line, path }: { line: string; path: string }): Task => {
         precedingHeader: null,
         tags: [],
         originalMarkdown: '',
+        // Not needed since the inferred status is always re-computed after submitting.
+        scheduledDateIsInferred: false,
     });
 };
diff --git a/src/Commands/ToggleDone.ts b/src/Commands/ToggleDone.ts
index 6f1ad917..67487882 100644
--- a/src/Commands/ToggleDone.ts
+++ b/src/Commands/ToggleDone.ts
@@ -61,6 +61,7 @@ export const toggleLine = (line: string, path: string) => {
         sectionStart: 0, // We don't need this to toggle it here in the editor.
         sectionIndex: 0, // We don't need this to toggle it here in the editor.
         precedingHeader: null, // We don't need this to toggle it here in the editor.
+        fallbackDate: null, // We don't need this to toggle it here in the editor.
     });
     if (task !== null) {
         toggledLine = toggleTask(task);
diff --git a/src/Config/Settings.ts b/src/Config/Settings.ts
index ac5097ab..ad58dbdb 100644
--- a/src/Config/Settings.ts
+++ b/src/Config/Settings.ts
@@ -9,6 +9,8 @@ export interface Settings {
     autoSuggestMinMatch: number;
     autoSuggestMaxItems: number;
     provideAccessKeys: boolean;
+    enableDateFallback: boolean;
+    dateFallbackFolders: string[];
 
     // Collection of feature flag IDs and their state.
     features: FeatureFlag;
@@ -22,6 +24,8 @@ const defaultSettings: Settings = {
     autoSuggestMinMatch: 0,
     autoSuggestMaxItems: 6,
     provideAccessKeys: true,
+    enableDateFallback: false,
+    dateFallbackFolders: [],
     features: Feature.settingsFlags,
 };
 
diff --git a/src/Config/SettingsTab.ts b/src/Config/SettingsTab.ts
index 48e70fa3..c8095ce4 100644
--- a/src/Config/SettingsTab.ts
+++ b/src/Config/SettingsTab.ts
@@ -131,5 +131,45 @@ export class SettingsTab extends PluginSettingTab {
                     await this.plugin.saveSettings();
                 });
             });
+
+        new Setting(containerEl)
+            .setName('Use filename as date fallback')
+            .setDesc('Automatically schedule tasks at the date contained in the filename if no other date is set.')
+            .addToggle((toggle) => {
+                const settings = getSettings();
+                toggle.setValue(settings.enableDateFallback).onChange(async (value) => {
+                    updateSettings({ enableDateFallback: value });
+                    await this.plugin.saveSettings();
+                });
+            });
+
+        new Setting(containerEl)
+            .setName('Folders with date fallback')
+            .setDesc('Leave empty if you want to use fallback everywhere, or enter a comma-separated list of folders.')
+            .addText(async (input) => {
+                const settings = getSettings();
+                await this.plugin.saveSettings();
+                input.setValue(SettingsTab.renderFolderArray(settings.dateFallbackFolders)).onChange(async (value) => {
+                    const folders = SettingsTab.parseCommaSeparatedFolders(value);
+                    updateSettings({ dateFallbackFolders: folders });
+                    await this.plugin.saveSettings();
+                });
+            });
+    }
+
+    private static parseCommaSeparatedFolders(input: string): string[] {
+        return (
+            input
+                // a limitation is that folder names may not contain commas
+                .split(',')
+                .map((folder) => folder.trim())
+                // remove leading and trailing slashes
+                .map((folder) => folder.replace(/^\/|\/$/g, ''))
+                .filter((folder) => folder !== '')
+        );
+    }
+
+    private static renderFolderArray(folders: string[]): string {
+        return folders.join(',');
     }
 }
diff --git a/src/DateFallback.ts b/src/DateFallback.ts
new file mode 100644
index 00000000..fe0eee17
--- /dev/null
+++ b/src/DateFallback.ts
@@ -0,0 +1,122 @@
+import type { Moment } from 'moment/moment';
+import { getSettings } from './Config/Settings';
+import { Task } from './Task';
+
+/**
+ * Implement date from path detection
+ */
+export class DateFallback {
+    /**
+     * Attempt to parse the filename to extract a date taking user settings into account. If date inference is not
+     * enabled parsing is bypassed and null is returned.
+     * @param path the full path of the file
+     * @return a Moment or null if no date was found.
+     */
+    public static fromPath(path: string): Moment | null {
+        const { enableDateFallback, dateFallbackFolders } = getSettings();
+
+        if (!enableDateFallback) {
+            // feature is disabled
+            return null;
+        }
+
+        if (!this.matchesAnyFolder(dateFallbackFolders, path)) {
+            // file is not in any folder or subfolder that was selected for date inference
+            return null;
+        }
+
+        return this.extractDateFromPath(path);
+    }
+
+    private static matchesAnyFolder(folders: string[], path: string) {
+        if (folders.length === 0) {
+            // no constraints on matching folders
+            return true;
+        }
+
+        // folders never end with a '/', and paths contain at least on slash (separating the folder from the
+        // filename)
+        return folders.some((folder) => path.startsWith(folder + '/'));
+    }
+
+    private static extractDateFromPath(path: string): Moment | null {
+        const firstPos = Math.max(0, path.lastIndexOf('/') + 1);
+        const lastPos = path.lastIndexOf('.');
+
+        const basename = path.substring(firstPos, lastPos);
+
+        let dateMatch = /(\d{4})-(\d{2})-(\d{2})/.exec(basename);
+        if (!dateMatch) dateMatch = /(\d{4})(\d{2})(\d{2})/.exec(basename);
+
+        if (dateMatch) {
+            const date = window.moment([parseInt(dateMatch[1]), parseInt(dateMatch[2]) - 1, parseInt(dateMatch[3])]);
+            if (date.isValid()) {
+                return date;
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * Return true iff a fallback can be set
+     **/
+    public static canApplyFallback({
+        startDate,
+        scheduledDate,
+        dueDate,
+    }: {
+        startDate: Moment | null;
+        scheduledDate: Moment | null;
+        dueDate: Moment | null;
+    }): boolean {
+        return startDate === null && dueDate === null && scheduledDate === null;
+    }
+
+    /**
+     * Implement the logic to update the fields related to date fallback of a task when its file has moved
+     * @param task         - task to update
+     * @param newPath      - new location
+     * @param fallbackDate - fallback date from new location, for efficiency. Can be null
+     */
+    public static updateTaskPath(task: Task, newPath: string, fallbackDate: Moment | null): Task {
+        // initialize with values from before the path was changed
+        let scheduledDate = task.scheduledDate;
+        let scheduledDateIsInferred = task.scheduledDateIsInferred;
+
+        if (fallbackDate === null) {
+            // The new path doesn't contain a date...
+
+            if (scheduledDateIsInferred) {
+                // ...but the previous path had one : remove inferred date from Task
+                scheduledDateIsInferred = false;
+                scheduledDate = null;
+            } else {
+                // ...and the old path didn't contain any either :
+                // do nothing, and keep any explicitly set scheduled date
+            }
+        } else {
+            // The new path contains a date...
+
+            if (scheduledDateIsInferred) {
+                // ...and we used the fallback date from the previous path :
+                // set the scheduled date from the new path
+                scheduledDate = fallbackDate;
+            } else if (this.canApplyFallback(task)) {
+                // ...and the task is candidate to date fallback
+                // sest the scheduled date from the new path
+                scheduledDate = fallbackDate;
+                scheduledDateIsInferred = true;
+            } else {
+                // preserve existing dates, including explicit scheduledDate
+            }
+        }
+
+        return new Task({
+            ...task,
+            path: newPath,
+            scheduledDate,
+            scheduledDateIsInferred,
+        });
+    }
+}
diff --git a/src/File.ts b/src/File.ts
index 1b8ca194..b47a8eb7 100644
--- a/src/File.ts
+++ b/src/File.ts
@@ -19,7 +19,7 @@ export const initializeFile = ({
 };
 
 /**
- * Replaces the original task with one or more new tassk.
+ * Replaces the original task with one or more new tasks.
  *
  * If you pass more than one replacement task, all subsequent tasks in the same
  * section must be re-rendered, as their section indexes change. Assuming that
diff --git a/src/InlineRenderer.ts b/src/InlineRenderer.ts
index 5cbe76e0..4264310b 100644
--- a/src/InlineRenderer.ts
+++ b/src/InlineRenderer.ts
@@ -70,6 +70,7 @@ export class InlineRenderer {
                 sectionStart: section.lineStart,
                 sectionIndex,
                 precedingHeader: null, // We don't need the preceding header for in-line rendering.
+                fallbackDate: null, // We don't need the fallback date for in-line rendering
             });
             if (task !== null) {
                 fileTasks.push(task);
diff --git a/src/LivePreviewExtension.ts b/src/LivePreviewExtension.ts
index 8d6a3cb5..8795ba22 100644
--- a/src/LivePreviewExtension.ts
+++ b/src/LivePreviewExtension.ts
@@ -63,6 +63,7 @@ class LivePreviewExtension implements PluginValue {
             sectionStart: 0,
             sectionIndex: 0,
             precedingHeader: null,
+            fallbackDate: null,
         });
 
         console.debug(`Live Preview Extension: toggle called. Position: ${position} Line: ${line.text}`);
diff --git a/src/Task.ts b/src/Task.ts
index effd4b2b..0b25ad97 100644
--- a/src/Task.ts
+++ b/src/Task.ts
@@ -6,6 +6,7 @@ import { Recurrence } from './Recurrence';
 import { getSettings } from './Config/Settings';
 import { Urgency } from './Urgency';
 import { Sort } from './Query/Sort';
+import { DateFallback } from './DateFallback';
 
 /**
  * Collection of status types supported by the plugin.
@@ -155,6 +156,8 @@ export class Task {
      * (for example, by Create or Edit Task, or in tests, including via {@link TaskBuilder}). */
     public readonly originalMarkdown: string;
 
+    public readonly scheduledDateIsInferred: boolean;
+
     private _urgency: number | null = null;
 
     constructor({
@@ -175,6 +178,7 @@ export class Task {
         blockLink,
         tags,
         originalMarkdown,
+        scheduledDateIsInferred,
     }: {
         status: Status;
         description: string;
@@ -193,6 +197,7 @@ export class Task {
         blockLink: string;
         tags: string[] | [];
         originalMarkdown: string;
+        scheduledDateIsInferred: boolean;
     }) {
         this.status = status;
         this.description = description;
@@ -215,6 +220,8 @@ export class Task {
         this.recurrence = recurrence;
         this.blockLink = blockLink;
         this.originalMarkdown = originalMarkdown;
+
+        this.scheduledDateIsInferred = scheduledDateIsInferred;
     }
 
     /**
@@ -226,6 +233,7 @@ export class Task {
      * @param {number} sectionStart - Line number where the section starts that contains this task.
      * @param {number} sectionIndex - The index of the nth task in its section.
      * @param {(string | null)} precedingHeader - The header before this task.
+     * @param {(Moment | null)} fallbackDate - The date to use as the scheduled date if no other date is set
      * @return {*}  {(Task | null)}
      * @memberof Task
      */
@@ -235,12 +243,14 @@ export class Task {
         sectionStart,
         sectionIndex,
         precedingHeader,
+        fallbackDate,
     }: {
         line: string;
         path: string;
         sectionStart: number;
         sectionIndex: number;
         precedingHeader: string | null;
+        fallbackDate: Moment | null;
     }): Task | null {
         // Check the line to see if it is a markdown task.
         const regexMatch = line.match(TaskRegularExpressions.taskRegex);
@@ -289,6 +299,7 @@ export class Task {
         let priority: Priority = Priority.None;
         let startDate: Moment | null = null;
         let scheduledDate: Moment | null = null;
+        let scheduledDateIsInferred = false;
         let dueDate: Moment | null = null;
         let doneDate: Moment | null = null;
         let recurrenceRule: string = '';
@@ -384,6 +395,12 @@ export class Task {
             });
         }
 
+        // Infer the scheduled date from the file name if not set explicitly
+        if (DateFallback.canApplyFallback({ startDate, scheduledDate, dueDate }) && fallbackDate !== null) {
+            scheduledDate = fallbackDate;
+            scheduledDateIsInferred = true;
+        }
+
         // Add back any trailing tags to the description. We removed them so we can parse the rest of the
         // components but now we want them back.
         // The goal is for a task of them form 'Do something #tag1 (due) tomorrow #tag2 (start) today'
@@ -417,6 +434,7 @@ export class Task {
             blockLink,
             tags,
             originalMarkdown: line,
+            scheduledDateIsInferred,
         });
     }
 
@@ -548,7 +566,7 @@ export class Task {
             taskString += startDate;
         }
 
-        if (!layoutOptions.hideScheduledDate && this.scheduledDate) {
+        if (!layoutOptions.hideScheduledDate && this.scheduledDate && !this.scheduledDateIsInferred) {
             const scheduledDate: string = layoutOptions.shortMode
                 ? ' ' + scheduledDateSymbol
                 : ` ${scheduledDateSymbol} ${this.scheduledDate.format(TaskRegularExpressions.dateFormat)}`;
@@ -741,6 +759,7 @@ export class Task {
             'precedingHeader',
             'priority',
             'blockLink',
+            'scheduledDateIsInferred',
         ];
         for (const el of args) {
             if (this[el] !== other[el]) return false;
diff --git a/src/lib/Lazy.ts b/src/lib/Lazy.ts
new file mode 100644
index 00000000..504cae27
--- /dev/null
+++ b/src/lib/Lazy.ts
@@ -0,0 +1,23 @@
+/**
+ * Lazy loaded variable : fetching the value is postponed until the first get()
+ */
+export class Lazy<T> {
+    private _value: T | undefined = undefined;
+
+    /**
+     * Construct a lazy object
+     * @param fetch a function that produces a value
+     */
+    constructor(private fetch: () => T) {}
+
+    /**
+     * Retrieve the lazy value, calling the fetch function the first time.
+     */
+    get value(): T {
+        if (this._value === undefined) {
+            this._value = this.fetch();
+        }
+
+        return this._value;
+    }
+}


Test patch:
diff --git a/tests/DateFallback.test.ts b/tests/DateFallback.test.ts
new file mode 100644
index 00000000..54526b57
--- /dev/null
+++ b/tests/DateFallback.test.ts
@@ -0,0 +1,386 @@
+/**
+ * @jest-environment jsdom
+ */
+import type { Moment } from 'moment';
+import moment from 'moment';
+import { Task } from '../src/Task';
+import { resetSettings, updateSettings } from '../src/Config/Settings';
+import { DateFallback } from '../src/DateFallback';
+import { TaskBuilder } from './TestingTools/TaskBuilder';
+
+jest.mock('obsidian');
+window.moment = moment;
+
+function date(value: string | null): Moment | null {
+    return value == null ? null : window.moment(value, 'YYYY-MM-DD');
+}
+
+describe('extract date from filename', () => {
+    const ANY_FOLDER: string[] = [];
+
+    type TestCase = {
+        path: string;
+        expectedDate: string | null;
+        enableDateFallback?: boolean;
+        dateFallbackFolders?: string[];
+    };
+
+    const testDefaults: Partial<TestCase> = {
+        enableDateFallback: true,
+        dateFallbackFolders: ANY_FOLDER,
+    };
+
+    test.each<TestCase>([
+        {
+            path: '2022-10-22.md',
+            enableDateFallback: false,
+            expectedDate: null,
+        },
+        {
+            path: '2022-10-22.md',
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: '2022-10-22.md',
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: 'prefix-2022-10-22.md',
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: '2022-10-22-suffix.md',
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: 'p2022-10-22s.md',
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: 'folder/2022-10-22.md',
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: 'folder/prefix-2022-10-22.md',
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: 'folder/2022-10-22-suffix.md',
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: 'folder/prefix-2022-10-22-suffix.md',
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: 'folder/p2022-10-22-suffix.md',
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: 'folder/prefix-2022-10-22x.md',
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: '2022-10-22.md',
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: '20221022.md',
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: 'folder/20221022.md',
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: 'folder/prefix-20221022-suffix.md',
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: 'short.md',
+            expectedDate: null,
+        },
+        {
+            path: 'folder/02. prefix 2022-10-22.md',
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: '2022-10-22 2022-10-29.md',
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: '2022-10-22/2022-10-29.md',
+            expectedDate: '2022-10-29',
+        },
+        {
+            path: '2022/10-29.md',
+            expectedDate: null,
+        },
+        {
+            path: 'folder/2022-10-22.md',
+            dateFallbackFolders: ['folder'],
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: 'folder/subfolder/2022-10-22.md',
+            dateFallbackFolders: ['folder'],
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: '2022-10-22.md',
+            dateFallbackFolders: ['folder'],
+            expectedDate: null,
+        },
+        {
+            path: 'outside/2022-10-22.md',
+            dateFallbackFolders: ['folder'],
+            expectedDate: null,
+        },
+        {
+            path: 'folder2/2022-10-22.md',
+            dateFallbackFolders: ['folder1', 'folder2'],
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: 'folder/2022-10-22.md',
+            dateFallbackFolders: ['folder', 'folder/subfolder'],
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: 'folder/subfolder/2022-10-22.md',
+            dateFallbackFolders: ['folder', 'folder/subfolder'],
+            expectedDate: '2022-10-22',
+        },
+        {
+            path: 'folder/other-folder/2022-10-22.md',
+            dateFallbackFolders: ['folder', 'folder/subfolder'],
+            expectedDate: '2022-10-22',
+        },
+    ])('%s', (testCase: TestCase) => {
+        // Arrange
+        const options = { ...testDefaults, ...testCase };
+
+        updateSettings({
+            enableDateFallback: options.enableDateFallback,
+            dateFallbackFolders: options.dateFallbackFolders,
+        });
+
+        try {
+            // Act
+            const parsedDate = DateFallback.fromPath(testCase.path);
+
+            // Assert
+            if (testCase.expectedDate !== null) {
+                expect(parsedDate).not.toBeNull();
+                expect(parsedDate!.isSame(date(testCase.expectedDate))).toStrictEqual(true);
+            } else {
+                expect(parsedDate).toBeNull();
+            }
+        } finally {
+            resetSettings();
+        }
+    });
+});
+
+function constructTaskFromLine(line: string, fallbackDate: string | null) {
+    return Task.fromLine({
+        line,
+        path: 'file.md', // filename must be parsed before calling Task.fromLine, so irrelevant for these tests
+        sectionStart: 0,
+        sectionIndex: 0,
+        precedingHeader: '',
+        fallbackDate: date(fallbackDate),
+    });
+}
+
+describe('parse task with date fallback', () => {
+    beforeEach(() => {
+        updateSettings({ enableDateFallback: true });
+    });
+
+    afterEach(() => {
+        resetSettings();
+    });
+
+    it('should set scheduled date and mark as inferred', () => {
+        // Arrange
+        const line = '- [ ] this is a task';
+
+        // Act
+        const task = constructTaskFromLine(line, '2022-10-22');
+
+        // Assert
+        expect(task!.scheduledDate).not.toBeNull();
+        expect(task!.scheduledDate!.isSame(date('2022-10-22'))).toStrictEqual(true);
+        expect(task!.scheduledDateIsInferred).toBe(true);
+    });
+
+    it('should not detect dates if due date is set', () => {
+        // Arrange
+        const line = '- [ ] this is a task 📅 2022-11-20';
+
+        // Act
+        const task = constructTaskFromLine(line, '2022-10-22');
+
+        // Assert
+        expect(task!.scheduledDate).toBeNull();
+        expect(task!.scheduledDateIsInferred).toBe(false);
+    });
+
+    it('should not detect dates if start date is set', () => {
+        // Arrange
+        const line = '- [ ] this is a task 🛫 2022-11-22';
+
+        // Act
+        const task = constructTaskFromLine(line, '2022-10-22');
+
+        // Assert
+        expect(task!.scheduledDate).toBeNull();
+        expect(task!.scheduledDateIsInferred).toBe(false);
+    });
+
+    it('should preserve existing scheduled date', () => {
+        // Arrange
+        const line = '- [ ] this is a task ⏳ 2022-11-22';
+
+        // Act
+        const task = constructTaskFromLine(line, '2022-10-22');
+
+        // Assert
+        expect(task!.scheduledDate).not.toBeNull();
+        expect(task!.scheduledDate!.isSame(date('2022-11-22'))).toStrictEqual(true);
+        expect(task!.scheduledDateIsInferred).toBe(false);
+    });
+
+    it('should detect scheduled date even if task is done', () => {
+        // Arrange
+        const line = '- [x] this is a task ✅ 2022-10-22';
+
+        // Act
+        const task = constructTaskFromLine(line, '2022-10-22');
+
+        // Assert
+        expect(task!.scheduledDate).not.toBeNull();
+        expect(task!.scheduledDate!.isSame(date('2022-10-22'))).toStrictEqual(true);
+        expect(task!.scheduledDateIsInferred).toBe(true);
+    });
+
+    it('should not alter the representation when a date is detected', () => {
+        // Arrange
+        const line = '- [ ] this is a task';
+
+        // Act
+        const task = constructTaskFromLine(line, '2022-10-22');
+
+        // Assert
+        expect(task?.toFileLineString()).toEqual(line);
+    });
+
+    it('should not add the inferred scheduled date when toggled', () => {
+        // Arrange
+        // toggle from a completed to uncompleted task to avoid having to mock moment
+        const line = '- [x] this is a task ✅ 2022-10-22';
+
+        // Act
+        const task = constructTaskFromLine(line, '2022-10-22');
+        const toggled: Task = task!.toggle()[0];
+
+        // Assert
+        expect(toggled.scheduledDateIsInferred).toBe(true);
+        expect(toggled.scheduledDate).not.toBeNull();
+        expect(toggled.scheduledDate!.isSame(date('2022-10-22'))).toStrictEqual(true);
+        expect(toggled.toFileLineString()).toBe('- [ ] this is a task');
+    });
+});
+
+describe('update fallback date when path is changed', () => {
+    beforeEach(() => {
+        updateSettings({ enableDateFallback: true });
+    });
+
+    afterEach(() => {
+        resetSettings();
+    });
+
+    type TestCase = {
+        description: string;
+        task: Task;
+        newPath: string;
+        expectedScheduledDate: Moment | null;
+        expectedIsInferred: boolean;
+    };
+
+    test.each<TestCase>([
+        {
+            description: 'it should update fallback date',
+            task: new TaskBuilder().scheduledDate('2022-10-28').scheduledDateIsInferred(true).build(),
+            newPath: '2022-10-29.md',
+            expectedScheduledDate: date('2022-10-29'),
+            expectedIsInferred: true,
+        },
+        {
+            description: 'it should use fallback if none before',
+            task: new TaskBuilder().build(),
+            newPath: '2022-10-29.md',
+            expectedScheduledDate: date('2022-10-29'),
+            expectedIsInferred: true,
+        },
+        {
+            description: 'it should not override explicit scheduled date',
+            task: new TaskBuilder().scheduledDate('2022-10-28').build(),
+            newPath: '2022-10-29.md',
+            expectedScheduledDate: date('2022-10-28'),
+            expectedIsInferred: false,
+        },
+        {
+            description: 'it should not use fallback if due date set',
+            task: new TaskBuilder().dueDate('2022-10-28').build(),
+            newPath: '2022-10-29.md',
+            expectedScheduledDate: null,
+            expectedIsInferred: false,
+        },
+        {
+            description: 'it should not use fallback if start date set',
+            task: new TaskBuilder().startDate('2022-10-28').build(),
+            newPath: '2022-10-29.md',
+            expectedScheduledDate: null,
+            expectedIsInferred: false,
+        },
+        {
+            description: 'it should remove existing fallback if none in new path',
+            task: new TaskBuilder().scheduledDate('2022-10-28').scheduledDateIsInferred(true).build(),
+            newPath: 'no-date.md',
+            expectedScheduledDate: null,
+            expectedIsInferred: false,
+        },
+        {
+            description: 'it should not infer dates if none present',
+            task: new TaskBuilder().build(),
+            newPath: 'no-date.md',
+            expectedScheduledDate: null,
+            expectedIsInferred: false,
+        },
+        {
+            description: 'it should keep explicit scheduled date',
+            task: new TaskBuilder().scheduledDate('2022-10-28').build(),
+            newPath: 'no-date.md',
+            expectedScheduledDate: date('2022-10-28'),
+            expectedIsInferred: false,
+        },
+    ])('update path: $description', ({ task, newPath, expectedScheduledDate, expectedIsInferred }: TestCase) => {
+        // Arrange
+        const fallbackDate = DateFallback.fromPath(newPath);
+
+        // Act
+        const updatedTask = DateFallback.updateTaskPath(task, newPath, fallbackDate);
+
+        // Assert
+        if (expectedScheduledDate === null) {
+            expect(updatedTask.scheduledDate).toBeNull();
+        } else {
+            expect(updatedTask.scheduledDate!.isSame(expectedScheduledDate)).toStrictEqual(true);
+        }
+
+        expect(updatedTask.scheduledDateIsInferred).toBe(expectedIsInferred);
+    });
+});
diff --git a/tests/Query.test.ts b/tests/Query.test.ts
index 743b93e9..13789902 100644
--- a/tests/Query.test.ts
+++ b/tests/Query.test.ts
@@ -266,6 +266,7 @@ describe('Query', () => {
                     blockLink: '',
                     tags: [],
                     originalMarkdown: '',
+                    scheduledDateIsInferred: false,
                 }),
                 new Task({
                     status: Status.TODO,
@@ -285,6 +286,7 @@ describe('Query', () => {
                     blockLink: '',
                     tags: [],
                     originalMarkdown: '',
+                    scheduledDateIsInferred: false,
                 }),
             ];
             const input = 'path includes ab/c d';
diff --git a/tests/Task.test.ts b/tests/Task.test.ts
index 5a3ff5b7..bccf6804 100644
--- a/tests/Task.test.ts
+++ b/tests/Task.test.ts
@@ -2,6 +2,7 @@
  * @jest-environment jsdom
  */
 import moment from 'moment';
+import type { Moment } from 'moment';
 import { Priority, Status, Task } from '../src/Task';
 import { resetSettings, updateSettings } from '../src/Config/Settings';
 import { fromLine } from './TestHelpers';
@@ -222,13 +223,14 @@ type TagParsingExpectations = {
     globalFilter: string;
 };
 
-function constructTaskFromLine(line: string) {
+function constructTaskFromLine(line: string, path: string = 'file.md', fallbackDate: Moment | null = null) {
     return Task.fromLine({
         line,
-        path: 'file.md',
+        path: path,
         sectionStart: 0,
         sectionIndex: 0,
         precedingHeader: '',
+        fallbackDate,
     });
 }
 
@@ -895,6 +897,12 @@ describe('identicalTo', () => {
         expect(lhs).not.toBeIdenticalTo(new TaskBuilder().dueDate('2012-12-26'));
     });
 
+    it('should check isScheduledDateInferred', () => {
+        const lhs = new TaskBuilder().scheduledDateIsInferred(false);
+        expect(lhs).toBeIdenticalTo(new TaskBuilder().scheduledDateIsInferred(false));
+        expect(lhs).not.toBeIdenticalTo(new TaskBuilder().scheduledDateIsInferred(true));
+    });
+
     it('should check doneDate', () => {
         const lhs = new TaskBuilder().doneDate('2012-12-27');
         expect(lhs).toBeIdenticalTo(new TaskBuilder().doneDate('2012-12-27'));
diff --git a/tests/TestHelpers.ts b/tests/TestHelpers.ts
index 2de68839..eaa84eba 100644
--- a/tests/TestHelpers.ts
+++ b/tests/TestHelpers.ts
@@ -15,6 +15,7 @@ export function fromLine({
         precedingHeader,
         sectionIndex: 0,
         sectionStart: 0,
+        fallbackDate: null,
     })!;
 }
 
@@ -28,6 +29,7 @@ export function createTasksFromMarkdown(tasksAsMarkdown: string, path: string, p
             precedingHeader: precedingHeader,
             sectionIndex: 0,
             sectionStart: 0,
+            fallbackDate: null,
         });
         if (task) {
             tasks.push(task);
diff --git a/tests/TestingTools/FilterTestHelpers.ts b/tests/TestingTools/FilterTestHelpers.ts
index 5625f098..5cf339d4 100644
--- a/tests/TestingTools/FilterTestHelpers.ts
+++ b/tests/TestingTools/FilterTestHelpers.ts
@@ -79,6 +79,7 @@ export function shouldSupportFiltering(
                 sectionIndex: 0,
                 path: '',
                 precedingHeader: '',
+                fallbackDate: null, // For tests scheduled date needs to be set explicitly
             }) as Task,
     );
 
diff --git a/tests/TestingTools/TaskBuilder.ts b/tests/TestingTools/TaskBuilder.ts
index 17a2bfbd..d22b2e33 100644
--- a/tests/TestingTools/TaskBuilder.ts
+++ b/tests/TestingTools/TaskBuilder.ts
@@ -38,6 +38,8 @@ export class TaskBuilder {
     private _recurrence: Recurrence | null = null;
     private _blockLink: string = '';
 
+    private _scheduledDateIsInferred: boolean = false;
+
     /**
      * Build a Task
      *
@@ -73,6 +75,7 @@ export class TaskBuilder {
             blockLink: this._blockLink,
             tags: this._tags,
             originalMarkdown: '',
+            scheduledDateIsInferred: this._scheduledDateIsInferred,
         });
     }
 
@@ -166,6 +169,11 @@ export class TaskBuilder {
         return this;
     }
 
+    public scheduledDateIsInferred(isInferred: boolean) {
+        this._scheduledDateIsInferred = isInferred;
+        return this;
+    }
+
     private static parseDate(date: string | null): Moment | null {
         if (date) {
             return DateParser.parseDate(date);
diff --git a/tests/lib/Lazy.test.ts b/tests/lib/Lazy.test.ts
new file mode 100644
index 00000000..e057f148
--- /dev/null
+++ b/tests/lib/Lazy.test.ts
@@ -0,0 +1,34 @@
+import { Lazy } from '../../src/lib/Lazy';
+
+describe('Lazy', () => {
+    it('should return produced value', () => {
+        const lazyString = new Lazy(() => 'result');
+        expect(lazyString.value).toBe('result');
+    });
+
+    it('should call the produced once', () => {
+        let counter = 0;
+        const lazyString = new Lazy(() => {
+            ++counter;
+            return 'result' + counter;
+        });
+
+        expect(lazyString.value).toBe('result1');
+        expect(lazyString.value).toBe('result1');
+        expect(counter).toBe(1);
+    });
+
+    it('should cache null values', () => {
+        let counter = 0;
+        const lazyString = new Lazy<string | null>(() => {
+            ++counter;
+            return null;
+        });
+
+        expect(lazyString.value).toBeNull();
+        expect(lazyString.value).toBeNull();
+
+        // producer called only once even if it returned null
+        expect(counter).toBe(1);
+    });
+});