Skip to content

Commit 9026fb2

Browse files
trangdoan982claude
andauthored
ENG-1550: Fix # fragment encoding in markdown and wikilinks during import (#995)
- encodePathForMarkdownLink: split on first # before encoding so heading/block fragments (e.g. #section, #^block-id) are preserved as-is instead of being encoded as %23 - processLink: strip # fragment before file resolution, re-append after — previously resolved links silently dropped heading references - Wikilink .md stripping: operate on path before # so e.g. "import/Note.md#Section" correctly strips .md extension Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8c86b7a commit 9026fb2

1 file changed

Lines changed: 66 additions & 42 deletions

File tree

apps/obsidian/src/utils/importNodes.ts

Lines changed: 66 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -625,51 +625,59 @@ const updateMarkdownAssetLinks = ({
625625
return linkPath;
626626
}
627627

628-
// First, try to find if this link resolves to one of our imported assets
629-
const importedAssetFile = findImportedAssetFile(linkPath);
630-
if (importedAssetFile) {
631-
return getRelativeLinkPath(importedAssetFile.path);
632-
}
628+
// Separate file path from heading/block fragment (e.g. "Note.md#section" → filePath="Note.md", fragment="#section")
629+
// so that file resolution operates only on the file path portion.
630+
const hashIndex = linkPath.indexOf("#");
631+
const filePath = hashIndex !== -1 ? linkPath.slice(0, hashIndex) : linkPath;
632+
const fragment = hashIndex !== -1 ? linkPath.slice(hashIndex) : "";
633+
634+
const resolveFilePath = (path: string): string => {
635+
// First, try to find if this link resolves to one of our imported assets
636+
const importedAssetFile = findImportedAssetFile(path);
637+
if (importedAssetFile) {
638+
return getRelativeLinkPath(importedAssetFile.path);
639+
}
640+
641+
// Direct lookup from pathMapping (record built when we downloaded each asset)
642+
const newPath = getNewPathForLink(path);
643+
if (newPath) {
644+
const newFile = app.metadataCache.getFirstLinkpathDest(
645+
newPath,
646+
targetFile.path,
647+
);
648+
if (newFile) {
649+
return getRelativeLinkPath(newFile.path);
650+
}
651+
}
633652

634-
// Direct lookup from pathMapping (record built when we downloaded each asset)
635-
const newPath = getNewPathForLink(linkPath);
636-
if (newPath) {
637-
const newFile = app.metadataCache.getFirstLinkpathDest(
638-
newPath,
653+
// Only resolve to files under import/{spaceName}/ so we don't point at the wrong vault's files
654+
const resolvedFile = app.metadataCache.getFirstLinkpathDest(
655+
path,
639656
targetFile.path,
640657
);
641-
if (newFile) {
642-
return getRelativeLinkPath(newFile.path);
658+
const isInImportFolder =
659+
importFolder &&
660+
resolvedFile &&
661+
resolvedFile.path.startsWith(importFolder + "/");
662+
if (isInImportFolder && resolvedFile) {
663+
return getRelativeLinkPath(resolvedFile.path);
643664
}
644-
}
645665

646-
// Only resolve to files under import/{spaceName}/ so we don't point at the wrong vault's files
647-
const resolvedFile = app.metadataCache.getFirstLinkpathDest(
648-
linkPath,
649-
targetFile.path,
650-
);
651-
const isInImportFolder =
652-
importFolder &&
653-
resolvedFile &&
654-
resolvedFile.path.startsWith(importFolder + "/");
655-
if (isInImportFolder && resolvedFile) {
656-
return getRelativeLinkPath(resolvedFile.path);
657-
}
666+
// Unresolved (dead) link from another vault: rewrite so that when the user creates the file from this link, it is created under import/{vaultName}/ in the same relative position as in the source vault
667+
if (importFolder && originalNodePath && !resolvedFile) {
668+
// Vault-relative link (e.g. "Discourse Nodes/EVD - no relation testing") -> use as-is. Path-from-current-file (e.g. "EVD - no relation testing") -> resolve relative to source note dir
669+
const canonicalSourcePath =
670+
path.includes("/") && !path.startsWith(".") && !path.startsWith("/")
671+
? normalizePathForLookup(path)
672+
: (getCanonicalFromOriginalNote(path) ??
673+
normalizePathForLookup(path));
674+
return `${importFolder}/${canonicalSourcePath}`;
675+
}
658676

659-
// Unresolved (dead) link from another vault: rewrite so that when the user creates the file from this link, it is created under import/{vaultName}/ in the same relative position as in the source vault
660-
if (importFolder && originalNodePath && !resolvedFile) {
661-
// Vault-relative link (e.g. "Discourse Nodes/EVD - no relation testing") -> use as-is. Path-from-current-file (e.g. "EVD - no relation testing") -> resolve relative to source note dir
662-
const canonicalSourcePath =
663-
linkPath.includes("/") &&
664-
!linkPath.startsWith(".") &&
665-
!linkPath.startsWith("/")
666-
? normalizePathForLookup(linkPath)
667-
: (getCanonicalFromOriginalNote(linkPath) ??
668-
normalizePathForLookup(linkPath));
669-
return `${importFolder}/${canonicalSourcePath}`;
670-
}
677+
return path;
678+
};
671679

672-
return linkPath;
680+
return resolveFilePath(filePath) + fragment;
673681
};
674682

675683
// Match wiki links: [[path]] or [[path|alias]]
@@ -683,8 +691,13 @@ const updateMarkdownAssetLinks = ({
683691
.map((s: string) => s.trim());
684692
if (!linkPath) return match;
685693
let processedPath = processLink(linkPath);
686-
if (processedPath.endsWith(".md") && !linkPath.endsWith(".md"))
687-
processedPath = processedPath.substring(0, processedPath.length - 3);
694+
const hashIdx = processedPath.indexOf("#");
695+
const pathBeforeHash =
696+
hashIdx !== -1 ? processedPath.slice(0, hashIdx) : processedPath;
697+
const pathAfterHash = hashIdx !== -1 ? processedPath.slice(hashIdx) : "";
698+
if (pathBeforeHash.endsWith(".md") && !linkPath.endsWith(".md")) {
699+
processedPath = pathBeforeHash.slice(0, -3) + pathAfterHash;
700+
}
688701
if (alias) {
689702
return `[[${processedPath}|${alias}]]`;
690703
}
@@ -1572,9 +1585,20 @@ export const refreshAllImportedFiles = async (
15721585
};
15731586

15741587
const encodePathForMarkdownLink = (linkPath: string): string => {
1575-
// Input is already decoded; encode each segment (spaces → %20) but keep / as separator
1576-
return linkPath
1588+
// Input is already decoded; encode each segment (spaces → %20) but keep / as separator.
1589+
// Split on the first # to preserve heading/block fragments (e.g. "Note.md#section" → "Note.md#section", not "Note.md%23section").
1590+
const hashIndex = linkPath.indexOf("#");
1591+
if (hashIndex === -1) {
1592+
return linkPath
1593+
.split("/")
1594+
.map((segment) => encodeURIComponent(segment))
1595+
.join("/");
1596+
}
1597+
const pathPart = linkPath.slice(0, hashIndex);
1598+
const fragment = linkPath.slice(hashIndex); // includes the leading #
1599+
const encodedPath = pathPart
15771600
.split("/")
15781601
.map((segment) => encodeURIComponent(segment))
15791602
.join("/");
1603+
return encodedPath + fragment;
15801604
};

0 commit comments

Comments
 (0)