Repository: platers/obsidian-linter

Issue:
FR: Add Support for Custom Auto-Correct Misspellings
## Is Your Feature Request Related to a Problem? Please Describe.

I would like to add in custom auto-correct misspellings for things that might be specific to me (i.e. misspelling PowerShell as Powershell) but might be too frivolous to request adding to the main auto-correct map located here [Auto-correct map.ts](https://github.com/platers/obsidian-linter/tree/master/src/utils/auto-correct-misspellings.ts)

## Describe the Solution You'd Like

A new input box similar to "Ignore Words" in the Content tab which allows me to enter in custom autocorrect words. 
I'd be happy with any format the devs choose, but I think using an array format like below makes most sense.
```
['Powershell'],['PowerShell'], 
['diahorrea', 'diarrhea']
```

**Please include an example where applicable:**

*Current example*
``` markdown
You'll need: [A supported version of Windows](https://docs.chocolatey.org/en-us/chocolatey-components-dependencies-and-support-lifecycle#supported-windows-versions)
Powershell 2.0 or higher
	Powershell 3.0 
```

*Corrected example*
``` markdown
You'll need: [A supported version of Windows](https://docs.chocolatey.org/en-us/chocolatey-components-dependencies-and-support-lifecycle#supported-windows-versions)
PowerShell 2.0 or higher
	PowerShell 3.0 
```

## Describe Alternatives You've Considered

I tried using the plugin Completr, but found it to be excessive for my needs.

## Additional Context

I have included a mockup of the UI element for your consideration.
<img width="643" alt="image" src="https://github.com/platers/obsidian-linter/assets/143239044/994af400-af01-4849-8d12-75b77f6718b3">



Hints from issue comments:
@pjkaufman: Hey @Skroderider , thanks for the suggestion. Is there a particular reason why Custom Regex Replacement would not work for what you are trying to do here?

It would be much cleaner and be less prone to input issues that would come along with having to input a JS map/array type value into a text box.@Skroderider: Honestly had never occurred to me to use regex to fix spelling mistakes! 
I can do that, no problems@Gewerd-Strauss: I think however that this should be reconsidered, for several reasons:

1. Regex is comparatively more complicated, particularly to people who don't or rarely use it. It's not exactly a _common_ thing once you step outside programming/coding.
2. Simplicity. Editing a textfile which is read into the map allows a user to keep simple replacements which are much easier to deal with without regex.  

---

However, as I am not fluent in javascript-regex (JS is not my focus at all and time is all too short right now), I could not figure out how to do this myself to open up for PR. Instead, I have a small script which modifies the relevant line in the compiled js file installed:

```js
}, We = class extends Wi { buildOption() { return new Vn(this.configKey, this.nameKey, this.descriptionKey, this.defaultValue) } }, cs = class extends Wi { buildOption() { return new is(this.configKey, this.nameKey, this.descriptionKey, this.defaultValue) } }; var sl = new Map([["magno", "mango"], ["qpcr", "qPCR"], ["pca", "PCA"], ["arbeit", "arbeit"], ["judsa", "Judas"], ["jdusa", "Judas"], ["madnatory", "mandatory"], ["uniformaly", "uniformly"], 
```

where variable `sl` contains all custom word replacements. It prepends custom definitions to the beginning of the array, thus allowing me to sidestep the issue entirely. 
The Hot Reload plugin then deals with reloading the plugin on the fly due to its filechange.
@pjkaufman: Hey @Gewerd-Strauss , it sounds like we may be talking past each other or I have poorly explained/documented how custom replace works. Custom replace is designed for regex usage. However regex is not necessary. If you just use values as is (i.e. type in the find text as is and the replace text as is) then no regex knowledge is necessary.

It may make sense to add a checkbox for regular find and replace to make that the type for custome replace, but it will be case sensitive in that case.

Edit: Is this what you are asking for or am I missing something? I ask because autocorrect is a complex system that will get unrully if it is allowed to have custom input. If you have an idea on how to avoid making the settings screen clunky while adding custom replace values, I can see this being possible.@Gewerd-Strauss: I will quickly answer this in part because I am unfortunately _extremely_ short on time and have no clue if I can answer over the coming week.

> If you have an idea on how to avoid making the settings screen clunky while adding custom replace values, I can see this being possible.

One could simply ask for a user to supply a simple file containing needle/replace pairs. THat file could be part of the vault, or be located elsewhere on the computer. I have no experience writing this, but the [Zotero integration](https://github.com/mgmeyers/obsidian-zotero-integration) has a file dialogue in its options (clicking on the folder-icon):
![grafik](https://github.com/platers/obsidian-linter/assets/80548781/d9b3b8f4-4bc5-46fe-b6d5-3f6cf40f9137)

SUch a means (or heck, even a super simple input line to give a path to a txt-file to be read in at plugin startup) could be used to load pairs from a custom user-sided file - which then shouldn't be purged upon plugin changes/reinstalls/deinstalls/whatever.


---

Edit 26.11.2023 15:10

To elaborate, I suggest a file as input, over the current implementation. Or maybe these kinds of data gets written to a simple file from the UI, but the file should follow a simple format so that external modification/generation of possible. 

Obsidian already behaves moreso like a code editor, heck you could argue it to be somewhat closer to a typical IDE than a simple word processor. 
With that in mind, and considering all the other under-the-hood stuff obsidian users are happy to do via files and configurations, this should be a simple complementary solution to make using this much more efficient for those who need it. 

@pjkaufman: If I were to use a file format, I would much prefer either YAML as the key value pairs or a table that has only two columns and has all `|` values present since that would make the most sense to me personally. I would really hate having to deal with JSON in a markdown file. So if I were to add a file to reference it would need likely use a table. That will be very hard to explain in the description so it would need to link out to the documentation website to handle the explanation of the expected file format.

But beyond that, if there is nothing wrong with that, I would be fine with proceeding with this.@Gewerd-Strauss: Sounds good to me. @pjkaufman: I am currently working on this feature. But it may take me a minute to get this ready and present. I want it to be able to have a popup that lets the user know the exact values and their replacements that were found in a file specified as well as a count of how many word replacements were found in the file. So far I have started the process of just getting the setting to show up in the UI even though it does not fit the normal kind of rule setting.@pjkaufman: I did want to make sure I added some context here as well for how this rule worked since I looked at some of the comments here and I think there may be a slight misconception on how the rule works.

Earlier I saw that there was a reference to `Powershell` being a word to correct. That is fine so long as you want all spellings of `powershell` to get corrected and to follow the case of the original word found. As it stands right now, the auto-correct logic assumes that all words present can be either uppercase or lowercase. It will assume all initial text to search for is lowercase. The replaced value can have any casing, but the first letter's case is determined by the instance of the lowercase word found. So if you had the following with a replacement of `powershell` to `PowerShell`:
``` markdown
powershell is awesome!
```
you would get:
``` markdown
powerShell is awesome!
```

This by design at this time and there is no current functionality built in to handle casing (if casing is a deal breaker, custom replacement is the way to go at this time).

Will this be a problem or does that make sense?@pjkaufman: Also, as an update, I have the ability to select a file and parse the tables present. Whitespace is trimmed, and the value to replace is lowercased. It currently only loads the value to replace when the Linter loads initially or when a change is made in the settings. This is not the most robust, but for an initially implementation, I think it can work. I would like to put it out as a BRAT release once I get a couple of other things ironed out. Any thoughts or suggestions so far based on what I have said here?@Gewerd-Strauss: I can't think of any issue with this proposal right now, I will think a bit more about it over the day. So far, this sounds good.


Once you have published to BRAT, would you mind letting us know? I would like to test-drive it a bit.@pjkaufman: I will definitely let you know here when I create something on BRAT.@pjkaufman: I want to give an update on this. I am almost done with the PR that will add this to the Linter. I just need to fix one part of the logic which is preventing me from being able to generate the docs. Once that is done, I plan to verify that the new docs make sense and then will see about moving forward with a BRAT release. It may be late tonight before I get to finish this. But I hope to have it today if not in the next couple of days.@pjkaufman: Some good news is that I figured out the issues with the docs and got it to a state I like the results in. So I plan to go ahead and merge the changes. I am first running some local tests, but should have the PR merged in the next 10 minutes barring an issue popping up.@pjkaufman: I just went ahead and added the changes for this to a BRAT release. Please let us know if there are any issues @Gewerd-Strauss and @Skroderider .

Solution patch:
diff --git a/__mocks__/obsidian.ts b/__mocks__/obsidian.ts
index e6bb2ed..50bc4fb 100644
--- a/__mocks__/obsidian.ts
+++ b/__mocks__/obsidian.ts
@@ -2,3 +2,6 @@
 import moment from 'moment';
 
 export {moment as moment};
+
+// Needed to make sure that auto-correct tests work due to the auto-correct option using a modal in some scenarios
+export class Modal {}
diff --git a/docs/additional-info/rules/auto-correct-common-misspellings.md b/docs/additional-info/rules/auto-correct-common-misspellings.md
new file mode 100644
index 0000000..1161e3a
--- /dev/null
+++ b/docs/additional-info/rules/auto-correct-common-misspellings.md
@@ -0,0 +1,32 @@
+#### How to Use Custom Misspellings
+
+There is a default list of common misspellings that is used as the base for how this rule works.
+However, there may be instances where the user may want to add their own list of misspellings to handle.
+In those scenarios, they can add files to the list of files that have custom misspellings in them.
+
+##### Format
+
+A file that has custom misspellings in them can have any content in them. But the only content that will
+be parsed as custom misspellings should be found in a two column table. For example the following table
+will result in `th` being replaced with `the` and `tht` being replaced with `that`:
+
+``` markdown
+The following is a table with custom misspellings:
+| Replace | With |
+| ------- | ---- |
+| th | the |
+| tht | that |
+```
+
+!!! Note
+    The first two lines of the table are skipped (the header and separator) and all rows after that
+    must start and end with a pipe (`|`). If any do not start or end with a pipe or they have more
+    than 2 columns, then they will be skipped.
+
+##### Current Limitations
+
+
+- The list of custom replacements is only loaded when the plugin initially loads or when the file is added to the list of files that include custom misspellings
+    * This means that making a change to a file that is already in the list of custom misspelling files will not work unless the Linter is reloaded or the file is removed and re-added to the list of custom misspelling files
+- There is no way to specify that a word is to always be capitalized
+    - This is due to how the auto-correct rule was designed as it sets the first letter of the replacement word to the case of the first letter of the word being replaced
diff --git a/docs/docs/settings/content-rules.md b/docs/docs/settings/content-rules.md
index b551962..fd9a1bb 100644
--- a/docs/docs/settings/content-rules.md
+++ b/docs/docs/settings/content-rules.md
@@ -15,7 +15,43 @@ Uses a dictionary of common misspellings to automatically convert them to their
 | Name | Description | List Items | Default Value |
 | ---- | ----------- | ---------- | ------------- |
 | `Ignore Words` | A comma separated list of lowercased words to ignore when auto-correcting | N/A |  |
+| `Extra Auto-Correct Source Files` | These are files that have a markdown table in them that have the initial word and the word to correct it to (these are case insensitive corrections). **Note: the tables used should have the starting and ending `|` indicators present for each line.** | N/A |  |
 
+### Additional Info
+
+
+#### How to Use Custom Misspellings
+
+There is a default list of common misspellings that is used as the base for how this rule works.
+However, there may be instances where the user may want to add their own list of misspellings to handle.
+In those scenarios, they can add files to the list of files that have custom misspellings in them.
+
+##### Format
+
+A file that has custom misspellings in them can have any content in them. But the only content that will
+be parsed as custom misspellings should be found in a two column table. For example the following table
+will result in `th` being replaced with `the` and `tht` being replaced with `that`:
+
+``` markdown
+The following is a table with custom misspellings:
+| Replace | With |
+| ------- | ---- |
+| th | the |
+| tht | that |
+```
+
+!!! Note
+    The first two lines of the table are skipped (the header and separator) and all rows after that
+    must start and end with a pipe (`|`). If any do not start or end with a pipe or they have more
+    than 2 columns, then they will be skipped.
+
+##### Current Limitations
+
+
+- The list of custom replacements is only loaded when the plugin initially loads or when the file is added to the list of files that include custom misspellings
+    * This means that making a change to a file that is already in the list of custom misspelling files will not work unless the Linter is reloaded or the file is removed and re-added to the list of custom misspelling files
+- There is no way to specify that a word is to always be capitalized
+    - This is due to how the auto-correct rule was designed as it sets the first letter of the replacement word to the case of the first letter of the word being replaced
 
 
 ### Examples
diff --git a/esbuild.config.mjs b/esbuild.config.mjs
index 87228e5..958d373 100644
--- a/esbuild.config.mjs
+++ b/esbuild.config.mjs
@@ -28,6 +28,16 @@ const mockedPlugins = [replace({
     'import {Setting} from \'obsidian\';': '',
     // remove the use of obsidian in settings helper to allow for docs.js to run
     'import {Component, MarkdownRenderer} from \'obsidian\';': '',
+    // remove the use of obsidian in the auto-correct files picker to allow for docs.js to run
+    'import {Setting, Component, App, TFile, normalizePath, ExtraButtonComponent} from \'obsidian\';': '',
+    // remove the use of obsidian in add custom row to allow for docs.js to run
+    'import {Component, Setting} from \'obsidian\';': '',
+    // remove the use of obsidian in suggest to allow for docs.js to run
+    'import {App, ISuggestOwner, Scope} from \'obsidian\';': '',
+    // remove the use of obsidian in md file suggester to allow for docs.js to run
+    'import {App, TFile} from \'obsidian\';': '',
+    // remove the use of obsidian in parse results modal to allow for docs.js to run
+    'import {Modal, App} from \'obsidian\';': 'class Modal {}',
   },
   delimiters: ['', ''],
 })];
diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts
index eec6421..c6af694 100644
--- a/src/lang/locale/en.ts
+++ b/src/lang/locale/en.ts
@@ -104,9 +104,18 @@ export default {
   'warning-text': 'Warning',
   'file-backup-text': 'Make sure you have backed up your files.',
   'custom-command-warning': 'Linting multiple files with custom commands enabled is a slow process that requires the ability to open panes in the side panel. It is noticeably slower than running without custom commands enabled. Please proceed with caution.',
+  'cancel-button-text': 'Cancel',
 
   'copy-aria-label': 'Copy',
 
+  // parse-results-modal.ts
+  'parse-results-heading-text': 'Custom Parse Values',
+  'file-parse-description-text': 'The following is the list of custom replacements found in {FILE}.',
+  'no-parsed-values-found-text': 'There were no custom replacements found in {FILE}. Please make sure that all tables with custom replacements in {FILE} only have two columns and all rows start and end with a pipe (i.e. |).',
+  'find-header-text': 'Word to Find',
+  'replace-header-text': 'Replacement Word',
+  'close-button-text': 'Close',
+
   'tabs': {
     'names': {
       // tab.ts
@@ -231,6 +240,13 @@ export default {
       'move-down-tooltip': 'Move down',
       'delete-tooltip': 'Delete',
     },
+    'custom-auto-correct': {
+      'delete-tooltip': 'Delete',
+      'show-parsed-contents-tooltip': 'View parsed replacements',
+      'custom-row-parse-warning': '"{ROW}" is not a valid row with custom replacements. It must have only 2 columns.',
+      'file-search-placeholder-text': 'File name',
+      'add-input-button-text': 'Add another custom file',
+    },
   },
 
   // rules
@@ -243,6 +259,10 @@ export default {
         'name': 'Ignore Words',
         'description': 'A comma separated list of lowercased words to ignore when auto-correcting',
       },
+      'extra-auto-correct-files': {
+        'name': 'Extra Auto-Correct Source Files',
+        'description': 'These are files that have a markdown table in them that have the initial word and the word to correct it to (these are case insensitive corrections). **Note: the tables used should have the starting and ending `|` indicators present for each line.**',
+      },
     },
     // add-blank-line-after-yaml.ts
     'add-blank-line-after-yaml': {
diff --git a/src/main.ts b/src/main.ts
index 677560b..5310eb7 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,8 +1,8 @@
-import {App, Editor, EventRef, MarkdownView, Menu, Notice, Plugin, TAbstractFile, TFile, TFolder, addIcon, htmlToMarkdown, EditorSelection, EditorChange, MarkdownViewModeType, getFrontMatterInfo, FileView} from 'obsidian';
+import {App, Editor, EventRef, MarkdownView, Menu, Notice, Plugin, TAbstractFile, TFile, TFolder, addIcon, htmlToMarkdown, EditorSelection, EditorChange, MarkdownViewModeType, getFrontMatterInfo, FileView, normalizePath} from 'obsidian';
 import {Options, RuleType, ruleTypeToRules, rules} from './rules';
 import DiffMatchPatch from 'diff-match-patch';
 import dedent from 'ts-dedent';
-import {stripCr} from './utils/strings';
+import {parseCustomReplacements, stripCr} from './utils/strings';
 import {logInfo, logError, logDebug, setLogLevel, logWarn, setCollectLogs, clearLogs, convertNumberToLogLevel} from './utils/logger';
 import {moment} from 'obsidian';
 import './rules-registry';
@@ -17,6 +17,7 @@ import {RuleAliasSuggest} from './cm6/rule-alias-suggester';
 import {DEFAULT_SETTINGS, LinterSettings} from './settings-data';
 import AsyncLock from 'async-lock';
 import {warn} from 'loglevel';
+import {CustomAutoCorrectContent} from './ui/linter-components/auto-correct-files-picker-option';
 
 // https://github.com/liamcain/obsidian-calendar-ui/blob/03ceecbf6d88ef260dadf223ee5e483d98d24ffc/src/localization.ts#L20-L43
 const langToMomentLocale = {
@@ -138,6 +139,16 @@ export default class LinterPlugin extends Plugin {
       }
     }
 
+    // load the custom replacements since they are not stored in the data.json when the settings data is saved
+    for (const replacementFileInfo of this.settings.ruleConfigs['auto-correct-common-misspellings']['extra-auto-correct-files'] as CustomAutoCorrectContent[]) {
+      if (replacementFileInfo.filePath != '') {
+        const file = this.getFileFromPath(replacementFileInfo.filePath);
+        if (file) {
+          replacementFileInfo.customReplacements = parseCustomReplacements(stripCr(await this.app.vault.cachedRead(file)));
+        }
+      }
+    }
+
     this.updatePasteOverrideStatus();
     this.updateHasCustomCommandStatus();
   }
@@ -869,4 +880,13 @@ export default class LinterPlugin extends Plugin {
     const lines = doc.split('\n');
     return {line: lines.length - 1, ch: lines[lines.length - 1].length};
   }
+
+  private getFileFromPath(filePath: string): TFile {
+    const file = this.app.vault.getAbstractFileByPath(normalizePath(filePath));
+    if (file instanceof TFile) {
+      return file;
+    }
+
+    return null;
+  }
 }
diff --git a/src/option.ts b/src/option.ts
index c5e01d3..9065a31 100644
--- a/src/option.ts
+++ b/src/option.ts
@@ -3,6 +3,7 @@ import {getTextInLanguage, LanguageStringKey} from './lang/helpers';
 import LinterPlugin from './main';
 import {parseTextToHTMLWithoutOuterParagraph} from './ui/helpers';
 import {LinterSettings} from './settings-data';
+import {AutoCorrectFilesPickerOption} from './ui/linter-components/auto-correct-files-picker-option';
 
 export type SearchOptionInfo = {name: string, description: string, options?: DropdownRecord[]}
 
@@ -171,3 +172,18 @@ export class DropdownOption extends Option {
     this.parseNameAndDescriptionAndRemoveSettingBorder(setting, plugin);
   }
 }
+
+
+export class MdFilePickerOption extends Option {
+  constructor(configKey: string, nameKey: LanguageStringKey, descriptionKey: LanguageStringKey, ruleAlias?: string | null) {
+    super(configKey, nameKey, descriptionKey, [], ruleAlias);
+  }
+
+  public display(containerEl: HTMLElement, settings: LinterSettings, plugin: LinterPlugin): void {
+    settings.ruleConfigs[this.ruleAlias][this.configKey] = settings.ruleConfigs[this.ruleAlias][this.configKey] ?? [];
+
+    new AutoCorrectFilesPickerOption(containerEl, plugin.settingsTab.component, settings.ruleConfigs[this.ruleAlias][this.configKey], plugin.app, () => {
+      plugin.saveSettings();
+    }, this.nameKey, this.descriptionKey);
+  }
+}
diff --git a/src/rules/auto-correct-common-misspellings.ts b/src/rules/auto-correct-common-misspellings.ts
index 6602e1d..bd2203d 100644
--- a/src/rules/auto-correct-common-misspellings.ts
+++ b/src/rules/auto-correct-common-misspellings.ts
@@ -1,12 +1,14 @@
 import {IgnoreTypes} from '../utils/ignore-types';
 import {Options, RuleType} from '../rules';
-import RuleBuilder, {ExampleBuilder, OptionBuilderBase, TextAreaOptionBuilder} from './rule-builder';
+import RuleBuilder, {ExampleBuilder, MdFilePickerOptionBuilder, OptionBuilderBase, TextAreaOptionBuilder} from './rule-builder';
 import dedent from 'ts-dedent';
 import {misspellingToCorrection} from '../utils/auto-correct-misspellings';
 import {wordRegex, wordSplitterRegex} from '../utils/regex';
+import {CustomAutoCorrectContent} from '../ui/linter-components/auto-correct-files-picker-option';
 
 class AutoCorrectCommonMisspellingsOptions implements Options {
   ignoreWords?: string[] = [];
+  extraAutoCorrectFiles?: CustomAutoCorrectContent[] = [];
 }
 
 @RuleBuilder.register
@@ -27,16 +29,30 @@ export default class AutoCorrectCommonMisspellings extends RuleBuilder<AutoCorre
   }
   replaceWordWithCorrectCasing(word: string, options: AutoCorrectCommonMisspellingsOptions): string {
     const lowercasedWord = word.toLowerCase();
-    if (!misspellingToCorrection.has(lowercasedWord) || options.ignoreWords.includes(lowercasedWord)) {
+    if (options.ignoreWords.includes(lowercasedWord)) {
       return word;
     }
 
-    let correctedWord = misspellingToCorrection.get(lowercasedWord);
-    if (word.charAt(0) == word.charAt(0).toUpperCase()) {
-      correctedWord = correctedWord.charAt(0).toUpperCase() + correctedWord.substring(1);
+    if (misspellingToCorrection.has(lowercasedWord)) {
+      return this.determineCorrectedWord(word, misspellingToCorrection.get(lowercasedWord));
     }
 
-    return correctedWord;
+    if (options.extraAutoCorrectFiles) {
+      for (let i = 0; i < options.extraAutoCorrectFiles.length; i++) {
+        if (options.extraAutoCorrectFiles[i].customReplacements?.has(lowercasedWord)) {
+          return this.determineCorrectedWord(word, options.extraAutoCorrectFiles[i].customReplacements?.get(lowercasedWord));
+        }
+      }
+    }
+
+    return word;
+  }
+  determineCorrectedWord(originalWord: string, replacement: string): string {
+    if (originalWord.charAt(0) == originalWord.charAt(0).toUpperCase()) {
+      replacement = replacement.charAt(0).toUpperCase() + replacement.substring(1);
+    }
+
+    return replacement;
   }
   get exampleBuilders(): ExampleBuilder<AutoCorrectCommonMisspellingsOptions>[] {
     return [
@@ -115,6 +131,13 @@ export default class AutoCorrectCommonMisspellings extends RuleBuilder<AutoCorre
         splitter: wordSplitterRegex,
         separator: ', ',
       }),
+      new MdFilePickerOptionBuilder({
+        OptionsClass: AutoCorrectCommonMisspellingsOptions,
+        nameKey: 'rules.auto-correct-common-misspellings.extra-auto-correct-files.name',
+        descriptionKey: 'rules.auto-correct-common-misspellings.extra-auto-correct-files.description',
+        // @ts-expect-error since it looks like there is an issue with the types here
+        optionsKey: 'extraAutoCorrectFiles',
+      }),
     ];
   }
 }
diff --git a/src/rules/rule-builder.ts b/src/rules/rule-builder.ts
index 2609132..8269574 100644
--- a/src/rules/rule-builder.ts
+++ b/src/rules/rule-builder.ts
@@ -1,5 +1,5 @@
 import {Example, Options, Rule, RuleType, registerRule, wrapLintError} from '../rules';
-import {BooleanOption, DropdownOption, DropdownRecord, MomentFormatOption, Option, TextAreaOption, TextOption} from '../option';
+import {BooleanOption, DropdownOption, DropdownRecord, MdFilePickerOption, MomentFormatOption, Option, TextAreaOption, TextOption} from '../option';
 import {logDebug, timingBegin, timingEnd} from '../utils/logger';
 import {getTextInLanguage, LanguageStringKey} from '../lang/helpers';
 import {IgnoreType, IgnoreTypes} from '../utils/ignore-types';
@@ -294,3 +294,9 @@ export class MomentFormatOptionBuilder<TOptions extends Options> extends OptionB
     return new MomentFormatOption(this.configKey, this.nameKey, this.descriptionKey, this.defaultValue);
   }
 }
+
+export class MdFilePickerOptionBuilder<TOptions extends Options> extends OptionBuilder<TOptions, string[]> {
+  protected buildOption(): Option {
+    return new MdFilePickerOption(this.configKey, this.nameKey, this.descriptionKey);
+  }
+}
diff --git a/src/ui/linter-components/auto-correct-files-picker-option.ts b/src/ui/linter-components/auto-correct-files-picker-option.ts
new file mode 100644
index 0000000..9bea7cb
--- /dev/null
+++ b/src/ui/linter-components/auto-correct-files-picker-option.ts
@@ -0,0 +1,113 @@
+import {Setting, Component, App, TFile, normalizePath, ExtraButtonComponent} from 'obsidian';
+import {LanguageStringKey, getTextInLanguage} from '../../lang/helpers';
+import {AddCustomRow} from '../components/add-custom-row';
+import MdFileSuggester from '../suggesters/md-file-suggester';
+import {parseCustomReplacements, stripCr} from '../../utils/strings';
+import {ParseResultsModal} from '../modals/parse-results-modal';
+
+export type CustomAutoCorrectContent = {filePath: string, customReplacements: Map<string, string>};
+
+export class AutoCorrectFilesPickerOption extends AddCustomRow {
+  private selectedFiles: string[] = [];
+
+  constructor(containerEl: HTMLElement, parentComponent: Component, public filesPicked: CustomAutoCorrectContent[], private app: App, saveSettings: () => void, name: LanguageStringKey, description: LanguageStringKey) {
+    super(
+        containerEl,
+        parentComponent,
+        getTextInLanguage(name),
+        getTextInLanguage(description),
+        null,
+        getTextInLanguage('options.custom-auto-correct.add-input-button-text'),
+        saveSettings,
+        ()=>{
+          this.selectedFiles = [];
+          for (const filePicked of this.filesPicked) {
+            this.selectedFiles.push(filePicked.filePath);
+          }
+
+          const newPickedFile: CustomAutoCorrectContent = {filePath: '', customReplacements: null};
+          this.filesPicked.push(newPickedFile);
+          this.selectedFiles.push('');
+          this.saveSettings();
+          this.addPickedFile(newPickedFile, this.filesPicked.length - 1, true);
+        });
+    this.display();
+    this.inputElDiv.addClass('linter-folder-ignore-container');
+  }
+
+  protected showInputEls(): void {
+    this.filesPicked.forEach((pickedFile, index) => {
+      this.addPickedFile(pickedFile, index);
+    });
+  }
+
+  private addPickedFile(pickedFile: CustomAutoCorrectContent, index: number, focusOnCommand: boolean = false) {
+    let showCustomParseContentButton: ExtraButtonComponent;
+    const setting = new Setting(this.inputElDiv)
+        .addSearch((cb) => {
+          new MdFileSuggester(this.app, cb.inputEl, this.selectedFiles);
+          cb.setPlaceholder(getTextInLanguage('options.custom-auto-correct.file-search-placeholder-text'))
+              .setValue(pickedFile.filePath)
+              .onChange(async (newPickedFile) => {
+                const customReplacementFile = newPickedFile;
+
+                if (customReplacementFile === '' || customReplacementFile === cb.inputEl.getAttribute('fileName')) {
+                  const file = this.getFileFromPath(customReplacementFile);
+                  pickedFile.filePath = customReplacementFile;
+                  if (file) {
+                    pickedFile.customReplacements = parseCustomReplacements(stripCr(await this.app.vault.read(file)));
+                    showCustomParseContentButton.disabled = false;
+                    showCustomParseContentButton.extraSettingsEl.addClass('clickable-icon');
+                  } else {
+                    showCustomParseContentButton.disabled = true;
+                    pickedFile.customReplacements = null;
+                    showCustomParseContentButton.extraSettingsEl.removeClass('clickable-icon');
+                  }
+
+                  this.filesPicked[index] = pickedFile;
+                  this.saveSettings();
+                }
+              });
+
+          cb.inputEl.setAttr('tabIndex', index);
+          cb.inputEl.addClass('linter-folder-ignore');
+
+          if (focusOnCommand) {
+            cb.inputEl.focus();
+          }
+        })
+        .addExtraButton((cb) => {
+          showCustomParseContentButton = cb;
+          cb.setIcon('info')
+              .setTooltip(getTextInLanguage('options.custom-auto-correct.show-parsed-contents-tooltip'))
+              .onClick(() => {
+                new ParseResultsModal(this.app, pickedFile).open();
+              });
+
+          if (pickedFile.filePath === '') {
+            cb.disabled = true;
+            cb.extraSettingsEl.removeClass('clickable-icon');
+          }
+        })
+        .addExtraButton((cb) => {
+          cb.setIcon('cross')
+              .setTooltip(getTextInLanguage('options.custom-auto-correct.delete-tooltip'))
+              .onClick(() => {
+                this.filesPicked.splice(index, 1);
+                this.saveSettings();
+                this.resetInputEls();
+              });
+        });
+
+    setting.settingEl.addClass('linter-no-border');
+  }
+
+  private getFileFromPath(filePath: string): TFile {
+    const file = this.app.vault.getAbstractFileByPath(normalizePath(filePath));
+    if (file instanceof TFile) {
+      return file;
+    }
+
+    return null;
+  }
+}
diff --git a/src/ui/modals/lint-confirmation-modal.ts b/src/ui/modals/lint-confirmation-modal.ts
index 42ce24b..8cdfa24 100644
--- a/src/ui/modals/lint-confirmation-modal.ts
+++ b/src/ui/modals/lint-confirmation-modal.ts
@@ -18,7 +18,7 @@ export class LintConfirmationModal extends Modal {
         {text: startModalMessageText + ' ' + getTextInLanguage('file-backup-text')}).id = 'confirm-dialog';
 
     this.contentEl.createDiv('modal-button-container', (buttonsEl) => {
-      buttonsEl.createEl('button', {text: 'Cancel'}).addEventListener('click', () => this.close());
+      buttonsEl.createEl('button', {text: getTextInLanguage('cancel-button-text')}).addEventListener('click', () => this.close());
 
       const btnSubmit = buttonsEl.createEl('button', {
         attr: {type: 'submit'},
diff --git a/src/ui/modals/parse-results-modal.ts b/src/ui/modals/parse-results-modal.ts
new file mode 100644
index 0000000..ac9ccd3
--- /dev/null
+++ b/src/ui/modals/parse-results-modal.ts
@@ -0,0 +1,36 @@
+import {Modal, App} from 'obsidian';
+import {getTextInLanguage} from '../../lang/helpers';
+import {CustomAutoCorrectContent} from '../linter-components/auto-correct-files-picker-option';
+
+// https://github.com/nothingislost/obsidian-workspaces-plus/blob/bbba928ec64b30b8dec7fe8fc9e5d2d96543f1f3/src/modal.ts#L68
+export class ParseResultsModal extends Modal {
+  constructor(app: App, customAutoCorrectionInfo: CustomAutoCorrectContent) {
+    super(app);
+    this.modalEl.addClass('confirm-modal');
+
+    this.contentEl.createEl('h3', {text: getTextInLanguage('parse-results-heading-text')}).style.textAlign = 'center';
+
+    if (customAutoCorrectionInfo?.customReplacements.size > 0) {
+      this.contentEl.createEl('p', {text: getTextInLanguage('file-parse-description-text').replace('{FILE}', customAutoCorrectionInfo.filePath)}).id = 'confirm-dialog';
+
+      const tableEl = this.contentEl.createDiv().createEl('table', {cls: 'markdown-rendered'});
+      const tableHeaderEl = tableEl.createEl('tr');
+      tableHeaderEl.createEl('th', {text: getTextInLanguage('find-header-text')});
+      tableHeaderEl.createEl('th', {text: getTextInLanguage('replace-header-text')});
+
+      let tableRow: HTMLElement;
+      for (const replacementInfo of customAutoCorrectionInfo.customReplacements) {
+        tableRow = tableEl.createEl('tr');
+        tableRow.createEl('td', {text: replacementInfo[0]});
+        tableRow.createEl('td', {text: replacementInfo[1]});
+      }
+    } else {
+      this.contentEl.createEl('p', {text: getTextInLanguage('no-parsed-values-found-text').replaceAll('{FILE}', customAutoCorrectionInfo.filePath)}).style.fontWeight = 'bold';
+    }
+
+    this.contentEl.createDiv('modal-button-container', (buttonsEl) => {
+      buttonsEl.createEl('button', {text: getTextInLanguage('close-button-text')}).addEventListener('click', () => this.close());
+    });
+  }
+}
+
diff --git a/src/ui/suggesters/folder-suggester.ts b/src/ui/suggesters/folder-suggester.ts
index 47585cd..6fb6486 100644
--- a/src/ui/suggesters/folder-suggester.ts
+++ b/src/ui/suggesters/folder-suggester.ts
@@ -17,7 +17,7 @@ export default class FolderSuggester extends TextInputSuggest<string> {
     }
 
     const nonSelectedFolders = all_folders.filter((el: string) => {
-      return !this.valuesToExclude.includes(el) || el === this.inputEl.getAttribute('folderExists');
+      return !this.valuesToExclude.includes(el) || el === this.inputEl.getAttribute('folderName');
     });
 
     const folders: string[] = [];
diff --git a/src/ui/suggesters/md-file-suggester.ts b/src/ui/suggesters/md-file-suggester.ts
new file mode 100644
index 0000000..26da7a3
--- /dev/null
+++ b/src/ui/suggesters/md-file-suggester.ts
@@ -0,0 +1,44 @@
+import {TextInputSuggest} from './suggest';
+import {App, TFile} from 'obsidian';
+
+export default class MdFileSuggester extends TextInputSuggest<string> {
+  constructor(
+    public app: App,
+    public inputEl: HTMLInputElement,
+    public valuesToExclude: string[] = [],
+  ) {
+    super(app, inputEl);
+  }
+
+  getSuggestions(input_str: string): string[] {
+    const all_md_files = this.app.vault.getAllLoadedFiles().filter((f) => f instanceof TFile && f.path.endsWith('.md')).map((f) => f.path);
+    if (!all_md_files) {
+      return [];
+    }
+
+    const nonSelectedMdFiles = all_md_files.filter((el: string) => {
+      return !this.valuesToExclude.includes(el) || el === this.inputEl.getAttribute('fileName');
+    });
+
+    const files: string[] = [];
+    const lower_input_str = input_str.toLowerCase();
+    nonSelectedMdFiles.forEach((folderPath: string) => {
+      if (folderPath.toLowerCase().contains(lower_input_str)) {
+        files.push(folderPath);
+      }
+    });
+
+    return files;
+  }
+
+  renderSuggestion(filePath: string, el: HTMLElement): void {
+    el.setText(filePath);
+  }
+
+  selectSuggestion(filePath: string): void {
+    this.inputEl.setAttribute('fileName', filePath);
+    this.inputEl.value = filePath;
+    this.inputEl.trigger('input');
+    this.close();
+  }
+}
diff --git a/src/utils/strings.ts b/src/utils/strings.ts
index e6cc13b..ff050ce 100644
--- a/src/utils/strings.ts
+++ b/src/utils/strings.ts
@@ -1,4 +1,7 @@
 import {calloutRegex, codeBlockBlockquoteRegex} from './regex';
+import {getTextInLanguage} from '../lang/helpers';
+import {logWarn} from './logger';
+import {getAllTablesInText} from './mdast';
 /**
  * Inserts a string at the given position in a string.
  * @param {string} str - The string to insert into
@@ -505,3 +508,30 @@ function getIndexOfEndOfLastNonEmptyLine(text: string, currentEndOfBlockquote: n
 
   return currentEndOfBlockquote;
 }
+
+export function parseCustomReplacements(text: string): Map<string, string> {
+  const tableInfo = getAllTablesInText(text);
+  const customReplacements = new Map<string, string>();
+
+  let tableContent = '';
+  let tableRows = [] as string[];
+  let rowParts = [] as string[];
+  for (const table of tableInfo) {
+    tableContent = text.substring(table.startIndex, table.endIndex);
+    tableRows = tableContent.split('\n');
+    tableRows.splice(0, 2); // skip header and divider rows
+
+    for (const row of tableRows) {
+      rowParts = row.split('|');
+
+      if (rowParts.length !== 4) {
+        logWarn(getTextInLanguage('options.custom-auto-correct.custom-row-parse-warning').replace('{ROW}', row));
+        continue;
+      }
+
+      customReplacements.set(rowParts[1].trim().toLowerCase(), rowParts[2].trim());
+    }
+  }
+
+  return customReplacements;
+}



Test patch:
diff --git a/__tests__/auto-correct-common-misspellings.test.ts b/__tests__/auto-correct-common-misspellings.test.ts
index 469ad84..5e55b3a 100644
--- a/__tests__/auto-correct-common-misspellings.test.ts
+++ b/__tests__/auto-correct-common-misspellings.test.ts
@@ -36,5 +36,20 @@ ruleTest({
         être
       `,
     },
+    {
+      testName: 'Custom replacements should work on file content',
+      before: dedent`
+        The cartt is over theree.
+      `,
+      after: dedent`
+        The cart is over there.
+      `,
+      options: {
+        extraAutoCorrectFiles: [{
+          filePath: 'file_path',
+          customReplacements: new Map<string, string>([['cartt', 'cart'], ['theree', 'there']]),
+        }],
+      },
+    },
   ],
 });
diff --git a/__tests__/parse-custom-replacements.test.ts b/__tests__/parse-custom-replacements.test.ts
new file mode 100644
index 0000000..1bf1e8c
--- /dev/null
+++ b/__tests__/parse-custom-replacements.test.ts
@@ -0,0 +1,77 @@
+import {parseCustomReplacements} from '../src/utils/strings';
+import dedent from 'ts-dedent';
+
+type parseCustomReplacementsTestCase = {
+  name: string,
+  text: string,
+  expectedCustomReplacements: Map<string, string>
+};
+
+const getTablesInTextTestCases: parseCustomReplacementsTestCase[] = [
+  {
+    name: 'parsing a file with a single table with two columns gets the correct amount of parsed out entries',
+    text: dedent`
+      Here is some text
+      | column1 | column2 |
+      | ------- | ------- |
+      | replaceme   | withme   |
+      |     replace | with |
+    `,
+    expectedCustomReplacements: new Map<string, string>([['replaceme', 'withme'], ['replace', 'with']]),
+  },
+  {
+    name: 'parsing a file with a single table with three columns gets no entries parsed out',
+    text: dedent`
+      Here is some text
+      | column1 | column2 | column3 |
+      | ------- | ------- | ---- |
+      | replaceme   | withme   | me |
+      |     replace | with | me2 |
+    `,
+    expectedCustomReplacements: new Map<string, string>(),
+  },
+  {
+    name: 'parsing a file with two tables with two columns gets the correct amount of parsed out entries',
+    text: dedent`
+      Here is some text
+      | column1 | column2 |
+      | ------- | ------- |
+      | replaceme   | withme   |
+      |     replace | with |
+      The following table also has some replacements
+      | header1 | header2 |
+      | ------- | ------- |
+      | var   | variablE   |
+      |     hack | hacker |
+    `,
+    expectedCustomReplacements: new Map<string, string>([['replaceme', 'withme'], ['replace', 'with'], ['var', 'variablE'], ['hack', 'hacker']]),
+  },
+  {
+    name: 'parsing a file with no tables gets no parsed out entries',
+    text: dedent`
+      Here is some text without any tables in it.
+    `,
+    expectedCustomReplacements: new Map<string, string>(),
+  },
+  {
+    name: 'parsing a file with a single table with two columns gets no entries parsed out when each line is missing a pipe (i.e. |)',
+    text: dedent`
+      Here is some text
+      | column1 | column2 |
+      | ------- | ------- |
+      | replaceme   | withme   
+           replace | with |
+    `,
+    expectedCustomReplacements: new Map<string, string>(),
+  },
+];
+
+describe('Get All Tables in Text', () => {
+  for (const testCase of getTablesInTextTestCases) {
+    it(testCase.name, () => {
+      const customReplacements = parseCustomReplacements(testCase.text);
+
+      expect(customReplacements).toEqual(testCase.expectedCustomReplacements);
+    });
+  }
+});