Repository: platers/obsidian-linter (ts)

Issue:
Bug: move-footnotes-to-the-bottom Incorrectly Changes Order of Footnotes
## Describe the Bug

move-footnotes-to-the-bottom incorrectly changes the order of footnotes at the bottom when multiple footnote references to the same footnote are used.

## How to Reproduce

Steps to reproduce the behavior:

1. Enable the rule `move-footnotes-to-the-bottom`, and disable the rule `re-index-footnotes`.
2. Create a file with the following text:
```markdown
First,[^1] followed by the Second,[^2] and then the First[^1] again.

[^1]: First Reference
[^2]: Second reference
```
3. Run Linter.
4. The text above is changed to the following:
```markdown
First,[^1] followed by the Second,[^2] and then the First[^1] again

[^2]: Second reference
[^1]: First Reference
```

## Expected Behavior

Don't re-order the footnotes, leave them in order as 1, 2, ...

## Device

- [x] Desktop
- [ ] Mobile



Pull request:
Fix Issue with Footnote Reorder When There are More Footnote References than Footnotes and the Last Footnote Reference for One of the Footnotes Comes After a Different Footnote Reference
Fixes #626 

Changes Made:
- Changed the logic from always just using the last currently unused reference to an index key to determine the order of the footnotes at the end of the file to instead use the earliest unused reference for each footnote using the same key
- Added a test for the scenario in question

Patch:
diff --git a/src/utils/mdast.ts b/src/utils/mdast.ts
index 7b58361..9340f6b 100644
--- a/src/utils/mdast.ts
+++ b/src/utils/mdast.ts
@@ -102,22 +102,47 @@ export function moveFootnotesToEnd(text: string) {
   const positions: Position[] = getPositions(MDAstTypes.Footnote, text);
   let footnotes: string[] = [];
 
-  const alreadyUsedReferencePositions = new Set<number>();
+  type footnoteKeyInfo = {
+    key: string,
+    referencePositions: number[], // last instance to first instance in file
+    footnotesReferencingKey: string[], // last instance to first instance in file
+  };
+
+  const footnoteKeyToFootnoteKeyInfo = new Map<string, footnoteKeyInfo>();
   const mapOfFootnoteToFootnoteReferenceIndex = new Map<string, number>();
-  const referencePosition = function(footnote: string, startOfFootnoteReferenceSearch: number): number {
+
+  const getAllReferencePositionsForFootnote = function(footnote: string, startOfFootnoteReferenceSearch: number): void {
     const footnoteReference = footnote.match(/\[\^.*?\]/)[0];
+
+    if (footnoteKeyToFootnoteKeyInfo.has(footnoteReference)) {
+      const keyInfo = footnoteKeyToFootnoteKeyInfo.get(footnoteReference);
+      keyInfo.footnotesReferencingKey.push(footnote);
+
+      footnoteKeyToFootnoteKeyInfo.set(footnoteReference, keyInfo);
+
+      return;
+    }
+
     let footnoteReferenceLocation: number;
+    const footnoteReferenceLocations: number[] = [];
     do {
       footnoteReferenceLocation = text.lastIndexOf(footnoteReference, startOfFootnoteReferenceSearch);
-      startOfFootnoteReferenceSearch = footnoteReferenceLocation;
-    } while (alreadyUsedReferencePositions.has(footnoteReferenceLocation) && footnoteReferenceLocation !== -1 );
+      if (footnoteReferenceLocation === -1) {
+        continue;
+      }
 
-    if (footnoteReferenceLocation === -1) {
-      throw new Error(getTextInLanguage('logs.missing-footnote-error-message').replace('{FOOTNOTE}', footnote));
-    }
+      footnoteReferenceLocations.push(footnoteReferenceLocation);
+
+      startOfFootnoteReferenceSearch = footnoteReferenceLocation - 1;
+    } while (footnoteReferenceLocation > -1);
 
-    alreadyUsedReferencePositions.add(footnoteReferenceLocation);
-    return footnoteReferenceLocation;
+    const keyInfo: footnoteKeyInfo = {
+      key: footnoteReference,
+      referencePositions: footnoteReferenceLocations,
+      footnotesReferencingKey: [footnote],
+    };
+
+    footnoteKeyToFootnoteKeyInfo.set(footnoteReference, keyInfo);
   };
 
   for (const position of positions) {
@@ -132,7 +157,22 @@ export function moveFootnotesToEnd(text: string) {
       text = text.substring(0, position.end.offset) + text.substring(position.end.offset + 1);
     }
     text = text.substring(0, position.start.offset) + text.substring(position.end.offset);
-    mapOfFootnoteToFootnoteReferenceIndex.set(footnote, referencePosition(footnote, position.start.offset));
+    getAllReferencePositionsForFootnote(footnote, position.start.offset);
+  }
+
+  for (const footnoteData of footnoteKeyToFootnoteKeyInfo) {
+    const keyInfo = footnoteData[1];
+    // we need to offset the index to pull from for the footnote based on the difference in the amount of keys present, but make sure it is >= 0
+    let offset = keyInfo.referencePositions.length - keyInfo.footnotesReferencingKey.length;
+    offset = offset >= 0 ? offset: 0; // this allows us to properly hit not found error messages
+    let index = 0;
+    for (const footnote of keyInfo.footnotesReferencingKey) {
+      if (index + offset >= keyInfo.referencePositions.length) {
+        throw new Error(getTextInLanguage('logs.missing-footnote-error-message').replace('{FOOTNOTE}', footnote));
+      }
+
+      mapOfFootnoteToFootnoteReferenceIndex.set(footnote, keyInfo.referencePositions[offset + index++]);
+    }
   }
 
   // Sort the footnotes into the order of their references in the text


Test patch:
diff --git a/__tests__/move-footnotes-to-the-bottom.test.ts b/__tests__/move-footnotes-to-the-bottom.test.ts
index 226c04d..91780a3 100644
--- a/__tests__/move-footnotes-to-the-bottom.test.ts
+++ b/__tests__/move-footnotes-to-the-bottom.test.ts
@@ -173,6 +173,20 @@ ruleTest({
         [^1]:[b](http://b.com)
       `,
     },
+    { // accounts for https://github.com/platers/obsidian-linter/issues/626
+      testName: 'Make sure that footnote references are not accidentally reordered when there are multiple references to the same footnote where the first and last reference to the footnote have other footnote references in between them',
+      before: dedent`
+        First,[^1] followed by the Second,[^2] and then the First[^1] again.
+        ${''}
+        [^1]: First Reference
+        [^2]: Second reference
+      `,
+      after: dedent`
+        First,[^1] followed by the Second,[^2] and then the First[^1] again.
+        ${''}
+        [^1]: First Reference
+        [^2]: Second reference
+      `,
+    },
   ],
 });
-