Repository: platers/obsidian-linter (ts)

Issue:
Bug: Numerical aliases need to be quoted to work
## Describe the Bug

Purely numerical aliases (e.g. `20230603151900`) need to be quoted to make them work in obsidian. The linter doesn't pick this up, yet.

## How to Reproduce

Steps to reproduce the behavior: See [this forum post](https://forum.obsidian.md/t/cannot-use-uid-as-alias/20306/3). The described behavior is still found in today's Obsidian.

## Expected Behavior

Purely numerical aliases are quoted to make them valid (e.g. `"20230603151900"`).

## Device

- [x] Desktop
- [x] Mobile

Pull request description:
Escape Numeric Aliases & Refactor `YAML Title Alias` Logic
Fixes #747 

Changes Made:
- Refactored logic in the `YAML Title Alias` rule to make things easier to work with in the future
  - The title retrieval and new alias value creation logic were moved to their own methods 
- Added tests for 2 scenarios in question to make sure they get escaped
- Added an `isNumeric` method for checking if a string is a number

Patch:
diff --git a/src/rules/yaml-title-alias.ts b/src/rules/yaml-title-alias.ts
index caf2d39..cedd31b 100644
--- a/src/rules/yaml-title-alias.ts
+++ b/src/rules/yaml-title-alias.ts
@@ -4,6 +4,7 @@ import dedent from 'ts-dedent';
 import {convertAliasValueToStringOrStringArray, escapeStringIfNecessaryAndPossible, formatYamlArrayValue, getYamlSectionValue, initYAML, LINTER_ALIASES_HELPER_KEY, loadYAML, NormalArrayFormats, OBSIDIAN_ALIASES_KEYS, OBSIDIAN_ALIAS_KEY_PLURAL, QuoteCharacter, removeYamlSection, setYamlSection, SpecialArrayFormats, splitValueIfSingleOrMultilineArray} from '../utils/yaml';
 import {ignoreListOfTypes, IgnoreTypes} from '../utils/ignore-types';
 import {getFirstHeaderOneText, yamlRegex} from '../utils/regex';
+import {isNumeric} from '../utils/strings';
 
 class YamlTitleAliasOptions implements Options {
   preserveExistingAliasesSectionStyle?: boolean = true;
@@ -37,13 +38,12 @@ export default class YamlTitleAlias extends RuleBuilder<YamlTitleAliasOptions> {
   }
   apply(text: string, options: YamlTitleAliasOptions): string {
     text = initYAML(text);
-    let title = ignoreListOfTypes([IgnoreTypes.code, IgnoreTypes.math, IgnoreTypes.yaml, IgnoreTypes.tag], text, getFirstHeaderOneText);
-    title = title || options.fileName;
+    const [unescapedTitle, title] = this.getTitleInfo(text, options.fileName, options.aliasArrayStyle, options.defaultEscapeCharacter);
 
     let previousTitle: string = null;
     const yaml = text.match(yamlRegex)[1];
 
-    const shouldRemoveTitleAlias = !options.keepAliasThatMatchesTheFilename && title === options.fileName;
+    const shouldRemoveTitleAlias = !options.keepAliasThatMatchesTheFilename && unescapedTitle === options.fileName;
     if (options.useYamlKeyToKeepTrackOfOldFilenameOrHeading) {
       previousTitle = loadYAML(getYamlSectionValue(yaml, LINTER_ALIASES_HELPER_KEY));
     }
@@ -53,50 +53,6 @@ export default class YamlTitleAlias extends RuleBuilder<YamlTitleAliasOptions> {
 
     previousTitle = loadYAML(getYamlSectionValue(yaml, LINTER_ALIASES_HELPER_KEY));
 
-    const forceEscape = title.includes(',') && (options.aliasArrayStyle === NormalArrayFormats.SingleLine || options.aliasArrayStyle === SpecialArrayFormats.SingleStringToSingleLine || options.aliasArrayStyle === SpecialArrayFormats.SingleStringCommaDelimited);
-    title = escapeStringIfNecessaryAndPossible(title, options.defaultEscapeCharacter, forceEscape);
-    const getNewAliasValue = function(originalValue: string |string[], shouldRemoveTitle: boolean): string |string[] {
-      if (originalValue == null) {
-        return shouldRemoveTitle ? '' : title;
-      }
-
-      if (typeof originalValue === 'string') {
-        if (shouldRemoveTitle) {
-          if (originalValue === title) {
-            originalValue = '';
-          }
-        } else if (previousTitle === originalValue) {
-          originalValue = title;
-        } else {
-          originalValue = [title, originalValue];
-        }
-      } else if (previousTitle !== null) {
-        const previousTitleIndex = originalValue.indexOf(previousTitle);
-        if (previousTitleIndex !== -1) {
-          if (shouldRemoveTitle) {
-            originalValue.splice(previousTitleIndex, 1);
-          } else {
-            originalValue[previousTitleIndex] = title;
-          }
-        }
-      } else {
-        const currentTitleIndex = originalValue.indexOf(title);
-        if (currentTitleIndex !== -1) {
-          if (shouldRemoveTitle) {
-            originalValue.splice(currentTitleIndex, 1);
-          }
-        } else if (!shouldRemoveTitle) {
-          originalValue = [title, ...originalValue];
-        }
-      }
-
-      if (originalValue === '' || originalValue.length === 0) {
-        return '';
-      }
-
-      return originalValue;
-    };
-
     let aliasKeyForFile: string = null;
     const yamlKeys = Object.keys(parsedYaml);
     for (const aliasKey of OBSIDIAN_ALIASES_KEYS) {
@@ -107,7 +63,6 @@ export default class YamlTitleAlias extends RuleBuilder<YamlTitleAliasOptions> {
       }
     }
 
-
     if (aliasKeyForFile != null) {
       const aliasesValue = getYamlSectionValue(newYaml, aliasKeyForFile);
       let currentAliasStyle: NormalArrayFormats | SpecialArrayFormats = NormalArrayFormats.MultiLine;
@@ -124,7 +79,7 @@ export default class YamlTitleAlias extends RuleBuilder<YamlTitleAliasOptions> {
       }
 
       const currentAliasValue = convertAliasValueToStringOrStringArray(splitValueIfSingleOrMultilineArray(aliasesValue));
-      const newAliasValue = getNewAliasValue(currentAliasValue, shouldRemoveTitleAlias);
+      const newAliasValue = this.getNewAliasValue(currentAliasValue, shouldRemoveTitleAlias, title, previousTitle);
 
       if (newAliasValue === '') {
         newYaml = removeYamlSection(newYaml, aliasKeyForFile);
@@ -147,10 +102,60 @@ export default class YamlTitleAlias extends RuleBuilder<YamlTitleAliasOptions> {
       newYaml = setYamlSection(newYaml, LINTER_ALIASES_HELPER_KEY, ` ${title}`);
     }
 
-    text = text.replace(`---\n${yaml}---\n`, `---\n${newYaml}---\n`);
+    text = text.replace(`---\n${yaml}---`, `---\n${newYaml}---`);
 
     return text;
   }
+  getTitleInfo(text: string, fileName: string, aliasArrayStyle: NormalArrayFormats | SpecialArrayFormats, defaultEscapeCharacter: QuoteCharacter): [string, string] {
+    let unescapedTitle = ignoreListOfTypes([IgnoreTypes.code, IgnoreTypes.math, IgnoreTypes.yaml, IgnoreTypes.tag], text, getFirstHeaderOneText);
+    unescapedTitle = unescapedTitle || fileName;
+
+    const forceEscape = (unescapedTitle.includes(',') && (aliasArrayStyle === NormalArrayFormats.SingleLine || aliasArrayStyle === SpecialArrayFormats.SingleStringToSingleLine || aliasArrayStyle === SpecialArrayFormats.SingleStringCommaDelimited)) || isNumeric(unescapedTitle);
+    const escapedTitle = escapeStringIfNecessaryAndPossible(unescapedTitle, defaultEscapeCharacter, forceEscape);
+
+    return [unescapedTitle, escapedTitle];
+  }
+  getNewAliasValue(originalValue: string |string[], shouldRemoveTitle: boolean, title: string, previousTitle: string): string |string[] {
+    if (originalValue == null) {
+      return shouldRemoveTitle ? '' : title;
+    }
+
+    if (typeof originalValue === 'string') {
+      if (shouldRemoveTitle) {
+        if (originalValue === title) {
+          originalValue = '';
+        }
+      } else if (previousTitle === originalValue) {
+        originalValue = title;
+      } else {
+        originalValue = [title, originalValue];
+      }
+    } else if (previousTitle !== null) {
+      const previousTitleIndex = originalValue.indexOf(previousTitle);
+      if (previousTitleIndex !== -1) {
+        if (shouldRemoveTitle) {
+          originalValue.splice(previousTitleIndex, 1);
+        } else {
+          originalValue[previousTitleIndex] = title;
+        }
+      }
+    } else {
+      const currentTitleIndex = originalValue.indexOf(title);
+      if (currentTitleIndex !== -1) {
+        if (shouldRemoveTitle) {
+          originalValue.splice(currentTitleIndex, 1);
+        }
+      } else if (!shouldRemoveTitle) {
+        originalValue = [title, ...originalValue];
+      }
+    }
+
+    if (originalValue === '' || originalValue.length === 0) {
+      return '';
+    }
+
+    return originalValue;
+  }
   get exampleBuilders(): ExampleBuilder<YamlTitleAliasOptions>[] {
     return [
       new ExampleBuilder({
diff --git a/src/utils/strings.ts b/src/utils/strings.ts
index eabc9f0..6e2b103 100644
--- a/src/utils/strings.ts
+++ b/src/utils/strings.ts
@@ -341,3 +341,10 @@ export function countInstances(text: string, instancesOf: string): number {
 
   return counter;
 }
+
+// based on https://stackoverflow.com/a/175787/8353749
+export function isNumeric(str: string) {
+  if (typeof str != 'string') return false; // we only process strings!
+  return !isNaN(str as unknown as number) && // use type coercion to parse the _entirety_ of the string (`parseFloat` alone does not do this)...
+         !isNaN(parseFloat(str)); // ...and ensure strings of whitespace fail
+}


Test patch:
diff --git a/__tests__/yaml-title-alias.test.ts b/__tests__/yaml-title-alias.test.ts
index 0999bc8..3465893 100644
--- a/__tests__/yaml-title-alias.test.ts
+++ b/__tests__/yaml-title-alias.test.ts
@@ -1235,6 +1235,48 @@ ruleTest({
         useYamlKeyToKeepTrackOfOldFilenameOrHeading: false,
       },
     },
-
+    { // accounts for https://github.com/platers/obsidian-linter/issues/747
+      testName: 'Make sure numerical header gets escaped with quotes when converted to alias',
+      before: dedent`
+        ---
+        aliases:
+          - alias1
+        ---
+        # 12456378
+      `,
+      after: dedent`
+        ---
+        aliases:
+          - '12456378'
+          - alias1
+        ---
+        # 12456378
+      `,
+      options: {
+        aliasArrayStyle: NormalArrayFormats.MultiLine,
+        useYamlKeyToKeepTrackOfOldFilenameOrHeading: false,
+        defaultEscapeCharacter: '\'',
+      },
+    },
+    { // accounts for https://github.com/platers/obsidian-linter/issues/747
+      testName: 'Make sure numerical filename gets escaped with quotes when converted to alias due to not having a header in the file',
+      before: dedent`
+        ---
+        aliases: [alias1]
+        ---
+      `,
+      after: dedent`
+        ---
+        aliases: ["12345678", alias1]
+        ---
+      `,
+      options: {
+        aliasArrayStyle: NormalArrayFormats.SingleLine,
+        useYamlKeyToKeepTrackOfOldFilenameOrHeading: false,
+        keepAliasThatMatchesTheFilename: true,
+        defaultEscapeCharacter: '"',
+        fileName: '12345678',
+      },
+    },
   ],
 });