Repository: platers/obsidian-linter (ts)

Issue:
FR: Add option to save YAML timestamps in UTC
I'm currently using my Obsidian vault in conjunction with external tooling (TiddlyWiki) that expects the created & modified YAML frontmatter datetimes to always be in UTC. Adding an option to save YAML timestamps in UTC allows Linter's timestamps to be interoperable with TiddlyWiki and other external tools that might only be able to work with UTC.

I'd like the option to choose whether or not local times are converted to UTC times before being formatted & saved in created / modified frontmatter. For my specific use case, a boolean option is sufficient.

For example, if a file was created at 9 PM Eastern Standard Time on Jan 1st 2020 and convertToUTC was true, the file's _date created_ value would be 2 AM Jan 2nd 2020 in UTC.

Local time:
``` markdown
---
date created: 2020-01-01T21:00:00-05:00
---
```
Converted to UTC:
``` markdown
---
date created: 2020-01-02T02:00:00+00:00
---
```

Re: possible alternative solutions - I've attempted to modify TiddlyWiki to work with non-UTC time stamps and it's not feasible for me at this time. This also seems outside the scope of a change that would make sense for Obsidian's core. Adding to Linter makes the most sense to me for wider compatibility.

Pull request:
Basic implementation of YAML timestamp UTC option
Fixes #1069.

This is my rough attempt at implementing a option to convert YAML timestamps from local time to UTC time. I've added a few tests, but I'm unsure of their coverage / if any additional ones are needed. Happy to take any feedback and rework my implementation if desired.

When creation date retention is enabled and the format string does not include time zone info, this implementation assumes the existing creation date is already in UTC (no change). If the format string does include time zone info, the existing creation date is in a local time, and the UTC option is subsequently enabled, then the existing date created will be converted to UTC on next lint.

Patch:
diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts
index 0f172df..580bff2 100644
--- a/src/lang/locale/en.ts
+++ b/src/lang/locale/en.ts
@@ -786,6 +786,10 @@ export default {
         'name': 'Format',
         'description': 'Moment date format to use (see [Moment format options](https://momentjscom.readthedocs.io/en/latest/moment/04-displaying/01-format/))',
       },
+      'convert-to-utc': {
+        'name': 'Convert Local Time to UTC',
+        'description': 'Uses UTC equivalent for saved dates instead of local time',
+      },
     },
     // yaml-title-alias.ts
     'yaml-title-alias': {
diff --git a/src/rules-runner.ts b/src/rules-runner.ts
index 73b1efa..c598c2f 100644
--- a/src/rules-runner.ts
+++ b/src/rules-runner.ts
@@ -138,6 +138,9 @@ export class RulesRunner {
     const yamlTimestampOptions = YamlTimestamp.getRuleOptions(runOptions.settings);
 
     currentTime = runOptions.getCurrentTime();
+    if (yamlTimestampOptions.convertToUTC) {
+      currentTime = currentTime.utc();
+    }
     [newText] = YamlKeySort.applyIfEnabled(newText, runOptions.settings, this.disabledRules, {
       currentTimeFormatted: currentTime.format(yamlTimestampOptions.format.trimEnd()),
       yamlTimestampDateModifiedEnabled: isYamlTimestampEnabled && yamlTimestampOptions.dateModified,
diff --git a/src/rules/yaml-timestamp.ts b/src/rules/yaml-timestamp.ts
index 37fb0f3..2d215f4 100644
--- a/src/rules/yaml-timestamp.ts
+++ b/src/rules/yaml-timestamp.ts
@@ -23,6 +23,8 @@ class YamlTimestampOptions implements Options {
   dateModified?: boolean = true;
   dateModifiedKey?: string = 'date modified';
 
+  convertToUTC?: boolean = false;
+
   @RuleBuilder.noSettingControl()
     fileModifiedTime?: string;
 
@@ -80,7 +82,7 @@ export default class YamlTimestamp extends RuleBuilder<YamlTimestampOptions> {
     const created_date = moment(options.fileCreatedTime);
     created_date.locale(options.locale);
 
-    const formatted_date = created_date.format(options.format);
+    const formatted_date = options.convertToUTC ? created_date.utc().format(options.format) : created_date.format(options.format);
     const created_date_line = `\n${options.dateCreatedKey}: ${formatted_date}`;
 
     const keyWithValueFound = created_match.test(text);
@@ -106,13 +108,15 @@ export default class YamlTimestamp extends RuleBuilder<YamlTimestampOptions> {
       // @ts-expect-error
       const actualFormat = parseFormat(createdDateString);
       if (options.forceRetentionOfCreatedValue && options.format !== actualFormat) {
-        const yamlCreatedDateTime = this.parseValueToCurrentFormatIfPossible(createdDateString, options.format, options.locale);
+        const yamlCreatedDateTime = this.parseValueToCurrentFormatIfPossible(createdDateString, options.format, options.locale, options.convertToUTC);
         if (yamlCreatedDateTime == null) {
           throw new Error(getTextInLanguage('logs.invalid-date-format-error').replace('{DATE}', createdDateString).replace('{FILE_NAME}', options.fileName));
         }
 
-        if (yamlCreatedDateTime.format(options.format) !== createdDateString) {
-          const created_date_yaml_line = `\n${options.dateCreatedKey}: ${yamlCreatedDateTime.format(options.format)}`;
+        const formattedYamlCreatedDate = options.convertToUTC ? yamlCreatedDateTime.utc().format(options.format) : yamlCreatedDateTime.format(options.format);
+
+        if (formattedYamlCreatedDate !== createdDateString) {
+          const created_date_yaml_line = `\n${options.dateCreatedKey}: ${formattedYamlCreatedDate}`;
           text = text.replace(
               created_match,
               escapeDollarSigns(created_date_yaml_line) + '\n',
@@ -145,7 +149,7 @@ export default class YamlTimestamp extends RuleBuilder<YamlTimestampOptions> {
     modified_date.locale(options.locale);
     // using the current time helps prevent issues where the previous modified time was greater
     // than 5 seconds prior to the time the linter will finish with the file (i.e. helps prevent accidental infinite loops on updating the date modified value)
-    const formatted_modified_date = options.currentTime.format(options.format);
+    const formatted_modified_date = options.convertToUTC ? options.currentTime.utc().format(options.format) : options.currentTime.format(options.format);
     const modified_date_line = `\n${options.dateModifiedKey}: ${formatted_modified_date}`;
 
     const keyWithValueFound = modified_match.test(text);
@@ -171,12 +175,12 @@ export default class YamlTimestamp extends RuleBuilder<YamlTimestampOptions> {
 
     return text;
   }
-  parseValueToCurrentFormatIfPossible(timestamp: string, format: string, locale: string): moment.Moment | null {
+  parseValueToCurrentFormatIfPossible(timestamp: string, format: string, locale: string, utc: boolean): moment.Moment | null {
     if (timestamp == undefined) {
       return null;
     }
 
-    const desiredFormatDate = moment(timestamp, format, locale, true);
+    const desiredFormatDate = utc ? moment.utc(timestamp, format, locale, true) : moment(timestamp, format, locale, true);
     if (desiredFormatDate != undefined && desiredFormatDate.isValid()) {
       return desiredFormatDate;
     }
@@ -184,10 +188,11 @@ export default class YamlTimestamp extends RuleBuilder<YamlTimestampOptions> {
     // @ts-expect-error
     const actualFormat = parseFormat(timestamp);
     if (actualFormat != undefined) {
-      const date = moment(timestamp, actualFormat);
+      const date = utc ? moment.utc(timestamp, actualFormat) : moment(timestamp, actualFormat);
       date.locale(locale);
 
-      return moment(date.format(format), format, locale, true);
+      const formattedDateStr = utc ? date.utc().format(format) : date.format(format);
+      return moment(formattedDateStr, format, locale, true);
     }
 
     return null;
@@ -285,6 +290,92 @@ export default class YamlTimestamp extends RuleBuilder<YamlTimestampOptions> {
           alreadyModified: false,
         },
       }),
+      new ExampleBuilder({
+        description: 'Header is set with convert to UTC option true',
+        before: dedent`
+          # H1
+        `,
+        after: dedent`
+          ---
+          date created: 2020-01-01T14:00:00+00:00
+          date modified: 2020-01-02T02:00:05+00:00
+          ---
+          # H1
+        `,
+        options: {
+          format: 'YYYY-MM-DDTHH:mm:ssZ',
+          fileCreatedTime: '2020-01-01T09:00:00-05:00', // 9 AM Eastern Standard Time
+          fileModifiedTime: '2020-01-01T21:00:00-05:00', // 9 PM Eastern Standard Time, same day
+          currentTime: moment('2020-01-01T21:00:05-05:00', 'YYYY-MM-DDTHH:mm:ssZ'), // 9:00:05 PM EST, same day
+          alreadyModified: false,
+          convertToUTC: true,
+        },
+      }),
+      new ExampleBuilder({
+        description: 'dateCreated option is false with convert to UTC option true',
+        before: dedent`
+          # H1
+        `,
+        after: dedent`
+          ---
+          date modified: 2020-01-02T02:00:05+00:00
+          ---
+          # H1
+        `,
+        options: {
+          format: 'YYYY-MM-DDTHH:mm:ssZ',
+          dateCreated: false,
+          fileCreatedTime: '2020-01-01T09:00:00-05:00', // 9 AM Eastern Standard Time
+          fileModifiedTime: '2020-01-01T21:00:00-05:00', // 9 PM Eastern Standard Time, same day
+          currentTime: moment('2020-01-01T21:00:05-05:00', 'YYYY-MM-DDTHH:mm:ssZ'), // 9:00:05 PM EST, same day
+          alreadyModified: false,
+          convertToUTC: true,
+        },
+      }),
+      new ExampleBuilder({
+        description: 'Date Created Key is set with convert to UTC option true',
+        before: dedent`
+          # H1
+        `,
+        after: dedent`
+          ---
+          created: 2020-01-01T14:00:00+00:00
+          ---
+          # H1
+        `,
+        options: {
+          format: 'YYYY-MM-DDTHH:mm:ssZ',
+          dateCreated: true,
+          dateModified: false,
+          dateCreatedKey: 'created',
+          fileCreatedTime: '2020-01-01T09:00:00-05:00', // 9 AM Eastern Standard Time
+          currentTime: moment('2020-01-01T21:00:05-05:00', 'YYYY-MM-DDTHH:mm:ssZ'), // 9:00:05 PM EST, same day
+          alreadyModified: false,
+          convertToUTC: true,
+        },
+      }),
+      new ExampleBuilder({
+        description: 'Date Modified Key is set with convert to UTC option true',
+        before: dedent`
+          # H1
+        `,
+        after: dedent`
+          ---
+          modified: 2020-01-02T02:00:05+00:00
+          ---
+          # H1
+        `,
+        options: {
+          format: 'YYYY-MM-DDTHH:mm:ssZ',
+          dateCreated: false,
+          dateModified: true,
+          dateModifiedKey: 'modified',
+          fileModifiedTime: '2020-01-01T21:00:00-05:00', // 9 PM Eastern Standard Time, same day
+          currentTime: moment('2020-01-01T21:00:05-05:00', 'YYYY-MM-DDTHH:mm:ssZ'), // 9:00:05 PM EST, same day
+          alreadyModified: false,
+          convertToUTC: true,
+        },
+      }),
     ];
   }
   get optionBuilders(): OptionBuilderBase<YamlTimestampOptions>[] {
@@ -325,6 +416,12 @@ export default class YamlTimestamp extends RuleBuilder<YamlTimestampOptions> {
         descriptionKey: 'rules.yaml-timestamp.format.description',
         optionsKey: 'format',
       }),
+      new BooleanOptionBuilder({
+        OptionsClass: YamlTimestampOptions,
+        nameKey: 'rules.yaml-timestamp.convert-to-utc.name',
+        descriptionKey: 'rules.yaml-timestamp.convert-to-utc.description',
+        optionsKey: 'convertToUTC',
+      }),
     ];
   }
 }


Test patch:
diff --git a/__tests__/yaml-timestamp.test.ts b/__tests__/yaml-timestamp.test.ts
index 9e90ba2..86a4c2d 100644
--- a/__tests__/yaml-timestamp.test.ts
+++ b/__tests__/yaml-timestamp.test.ts
@@ -386,5 +386,54 @@ ruleTest({
         forceRetentionOfCreatedValue: true,
       },
     },
+    {
+      testName: 'When creation date exists and force retention of created date and convert to UTC are true, creation date should remain unchanged',
+      before: dedent`
+        ---
+        created: 2019-12-31T14:00:00+00:00
+        ---
+      `,
+      after: dedent`
+        ---
+        created: 2019-12-31T14:00:00+00:00
+        ---
+      `,
+      options: {
+        format: 'YYYY-MM-DDTHH:mm:ssZ',
+        dateCreated: true,
+        dateCreatedKey: 'created',
+        dateModified: false,
+        fileCreatedTime: '2020-01-01T09:00:00-05:00', // 9 AM Eastern Standard Time
+        currentTime: moment('2020-01-01T21:00:05-05:00', 'YYYY-MM-DDTHH:mm:ssZ'), // 9:00:05 PM EST, same day
+        forceRetentionOfCreatedValue: true,
+        convertToUTC: true,
+      },
+    },
+    {
+      testName: 'When timestamp format has changed and convert to UTC is true, updated timestamps should be in UTC',
+      before: dedent`
+        ---
+        created: Wed, 1 Jan 2020 09:00:00 -05:00
+        modified: Wed, 1 Jan 2020 18:00:00 -05:00
+        ---
+      `,
+      after: dedent`
+        ---
+        created: 2020-01-01T14:00:00+00:00
+        modified: 2020-01-02T02:00:05+00:00
+        ---
+      `,
+      options: {
+        format: 'YYYY-MM-DDTHH:mm:ssZ',
+        dateCreated: true,
+        dateCreatedKey: 'created',
+        dateModified: true,
+        dateModifiedKey: 'modified',
+        fileCreatedTime: '2020-01-01T09:00:00-05:00', // 9 AM Eastern Standard Time
+        fileModifiedTime: '2020-01-01T21:00:00-05:00', // 9 PM Eastern Standard Time, same day
+        currentTime: moment('2020-01-01T21:00:05-05:00', 'YYYY-MM-DDTHH:mm:ssZ'), // 9:00:05 PM EST, same day
+        convertToUTC: true,
+      },
+    },
   ],
 });