Skip to content

Commit 1cb8ce1

Browse files
trangdoan982devin-ai-integration[bot]maparent
authored
[ENG-1450] Update wikilink (#807)
* wip * make sure the import has filePath now * address PR comments * make sure it rewrites filePath * cleanup log * prettier * Update apps/obsidian/src/utils/importNodes.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> * address the [markdown link]() * Factor out common code. Also avoid adding spurious file extension. Also disguise relative links. * Devin correction; and decode linkPath * make sure to use decodeURIComponents instead * address PR comments * address PR comment --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Marc-Antoine Parent <maparent@acm.org>
1 parent 5b9ffa8 commit 1cb8ce1

1 file changed

Lines changed: 144 additions & 63 deletions

File tree

apps/obsidian/src/utils/importNodes.ts

Lines changed: 144 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -483,11 +483,7 @@ const updateMarkdownAssetLinks = ({
483483
app: App;
484484
originalNodePath?: string;
485485
}): string => {
486-
if (oldPathToNewPath.size === 0) {
487-
return content;
488-
}
489-
490-
// Create a set of all new paths for quick lookup (used by findImportedAssetFile)
486+
// Create a set of all new paths for quick lookup (used by findImportedAssetFile when pathMapping has entries)
491487
const newPaths = new Set(oldPathToNewPath.values());
492488

493489
let updatedContent = content;
@@ -496,6 +492,13 @@ const updateMarkdownAssetLinks = ({
496492
? targetFile.path.replace(/\/[^/]*$/, "")
497493
: "";
498494

495+
// When the note is under import/{spaceName}/, only treat wiki links as resolved if the target is in this folder (not some other vault file).
496+
const pathParts = targetFile.path.split("/");
497+
const importFolder =
498+
pathParts[0] === "import" && pathParts.length >= 2
499+
? pathParts.slice(0, 2).join("/")
500+
: null;
501+
499502
/** Path of targetFile relative to the current note, for use in links. Obsidian resolves relative links from the note's directory. */
500503
const getRelativeLinkPath = (assetPath: string): string => {
501504
const noteParts = noteDir ? noteDir.split("/").filter(Boolean) : [];
@@ -599,48 +602,100 @@ const updateMarkdownAssetLinks = ({
599602
return null;
600603
};
601604

605+
const processLink = (linkPath: string): string => {
606+
// Skip external URLs
607+
if (linkPath.startsWith("http://") || linkPath.startsWith("https://")) {
608+
return linkPath;
609+
}
610+
611+
// First, try to find if this link resolves to one of our imported assets
612+
const importedAssetFile = findImportedAssetFile(linkPath);
613+
if (importedAssetFile) {
614+
return getRelativeLinkPath(importedAssetFile.path);
615+
}
616+
617+
// Direct lookup from pathMapping (record built when we downloaded each asset)
618+
const newPath = getNewPathForLink(linkPath);
619+
if (newPath) {
620+
const newFile = app.metadataCache.getFirstLinkpathDest(
621+
newPath,
622+
targetFile.path,
623+
);
624+
if (newFile) {
625+
return getRelativeLinkPath(newFile.path);
626+
}
627+
}
628+
629+
// Only resolve to files under import/{spaceName}/ so we don't point at the wrong vault's files
630+
const resolvedFile = app.metadataCache.getFirstLinkpathDest(
631+
linkPath,
632+
targetFile.path,
633+
);
634+
const isInImportFolder =
635+
importFolder &&
636+
resolvedFile &&
637+
resolvedFile.path.startsWith(importFolder + "/");
638+
if (isInImportFolder && resolvedFile) {
639+
return getRelativeLinkPath(resolvedFile.path);
640+
}
641+
642+
// 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
643+
if (importFolder && originalNodePath && !resolvedFile) {
644+
// 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
645+
const canonicalSourcePath =
646+
linkPath.includes("/") &&
647+
!linkPath.startsWith(".") &&
648+
!linkPath.startsWith("/")
649+
? normalizePathForLookup(linkPath)
650+
: (getCanonicalFromOriginalNote(linkPath) ??
651+
normalizePathForLookup(linkPath));
652+
return `${importFolder}/${canonicalSourcePath}`;
653+
}
654+
655+
return linkPath;
656+
};
657+
602658
// Match wiki links: [[path]] or [[path|alias]]
603659
const wikiLinkRegex = /\[\[([^\]]+)\]\]/g;
604660
updatedContent = updatedContent.replace(
605661
wikiLinkRegex,
606-
(match, linkContent) => {
662+
(match, linkContent: string) => {
607663
// Extract path and optional alias
608664
const [linkPath, alias] = linkContent
609665
.split("|")
610666
.map((s: string) => s.trim());
611-
612-
// Skip external URLs
613-
if (linkPath.startsWith("http://") || linkPath.startsWith("https://")) {
614-
return match;
615-
}
616-
617-
// First, try to find if this link resolves to one of our imported assets
618-
const importedAssetFile = findImportedAssetFile(linkPath);
619-
if (importedAssetFile) {
620-
const linkText = getRelativeLinkPath(importedAssetFile.path);
621-
if (alias) {
622-
return `[[${linkText}|${alias}]]`;
623-
}
624-
return `[[${linkText}]]`;
667+
if (!linkPath) return match;
668+
let processedPath = processLink(linkPath);
669+
if (processedPath.endsWith(".md") && !linkPath.endsWith(".md"))
670+
processedPath = processedPath.substring(0, processedPath.length - 3);
671+
if (alias) {
672+
return `[[${processedPath}|${alias}]]`;
625673
}
674+
return `[[${processedPath}|${linkPath}]]`;
675+
},
676+
);
626677

627-
// Direct lookup from pathMapping (record built when we downloaded each asset)
628-
const newPath = getNewPathForLink(linkPath);
629-
if (newPath) {
630-
const newFile = app.metadataCache.getFirstLinkpathDest(
631-
newPath,
632-
targetFile.path,
633-
);
634-
if (newFile) {
635-
const linkText = getRelativeLinkPath(newFile.path);
636-
if (alias) {
637-
return `[[${linkText}|${alias}]]`;
678+
// Match markdown links (non-image): [text](path) — internal paths resolved like wikilinks, href kept URL-encoded
679+
const markdownLinkRegex = /(?<!!)\[([^\]]*)\]\(([^)]+)\)/g;
680+
updatedContent = updatedContent.replace(
681+
markdownLinkRegex,
682+
(match, linkText: string, linkPath: string) => {
683+
if (!linkPath) return match;
684+
linkPath = linkPath
685+
.split("/")
686+
.map((segment) => {
687+
try {
688+
return decodeURIComponent(segment);
689+
} catch {
690+
return segment;
638691
}
639-
return `[[${linkText}]]`;
640-
}
692+
})
693+
.join("/");
694+
if (linkPath.startsWith("http://") || linkPath.startsWith("https://")) {
695+
return match;
641696
}
642-
643-
return match;
697+
const processedPath = encodePathForMarkdownLink(processLink(linkPath));
698+
return `[${linkText}](${processedPath})`;
644699
},
645700
);
646701

@@ -911,6 +966,15 @@ const sanitizeFileName = (fileName: string): string => {
911966
.trim();
912967
};
913968

969+
/** Sanitize each path segment for use under import folder (preserves source vault folder structure). */
970+
const sanitizePathForImport = (path: string): string => {
971+
return path
972+
.split("/")
973+
.map((segment) => sanitizeFileName(segment))
974+
.filter(Boolean)
975+
.join("/");
976+
};
977+
914978
type ParsedFrontmatter = {
915979
nodeTypeId?: string;
916980
nodeInstanceId?: string;
@@ -1210,11 +1274,13 @@ export const importSelectedNodes = async ({
12101274
content,
12111275
createdAt: contentCreatedAt,
12121276
modifiedAt: contentModifiedAt,
1213-
filePath,
1277+
filePath: contentFilePath,
12141278
} = nodeContent;
12151279
const createdAt = node.createdAt ?? contentCreatedAt;
12161280
const modifiedAt = node.modifiedAt ?? contentModifiedAt;
1217-
const originalNodePath: string | undefined = node.filePath;
1281+
// Use source vault path from Content direct variant metadata for wikilink rewriting and asset placement
1282+
const originalNodePath: string | undefined =
1283+
contentFilePath ?? node.filePath;
12181284

12191285
// Sanitize file name
12201286
const sanitizedFileName = sanitizeFileName(fileName);
@@ -1224,14 +1290,20 @@ export const importSelectedNodes = async ({
12241290
// Update existing file - use its current path
12251291
finalFilePath = existingFile.path;
12261292
} else {
1227-
// Create new file in the import folder
1228-
finalFilePath = `${importFolderPath}/${sanitizedFileName}.md`;
1229-
1230-
// Check if file path already exists (edge case: same title but different nodeInstanceId)
1231-
let counter = 1;
1232-
while (await plugin.app.vault.adapter.exists(finalFilePath)) {
1233-
finalFilePath = `${importFolderPath}/${sanitizedFileName} (${counter}).md`;
1234-
counter++;
1293+
// Preserve source vault folder structure under import/{vaultName} when we have filePath from Content
1294+
const pathUnderImport =
1295+
contentFilePath && contentFilePath.includes("/")
1296+
? sanitizePathForImport(contentFilePath)
1297+
: `${sanitizedFileName}.md`;
1298+
finalFilePath = `${importFolderPath}/${pathUnderImport}`;
1299+
1300+
// Ensure all parent folders exist (e.g. import/VaultName/Discourse Nodes/SubFolder)
1301+
const dirParts = finalFilePath.split("/");
1302+
for (let i = 1; i < dirParts.length - 1; i++) {
1303+
const folderPath = dirParts.slice(0, i + 1).join("/");
1304+
if (!(await plugin.app.vault.adapter.exists(folderPath))) {
1305+
await plugin.app.vault.createFolder(folderPath);
1306+
}
12351307
}
12361308
}
12371309

@@ -1243,7 +1315,7 @@ export const importSelectedNodes = async ({
12431315
sourceSpaceId: spaceId,
12441316
sourceSpaceUri: spaceUri,
12451317
rawContent: content,
1246-
originalFilePath: filePath,
1318+
originalFilePath: contentFilePath,
12471319
filePath: finalFilePath,
12481320
importedCreatedAt: createdAt,
12491321
importedModifiedAt: modifiedAt,
@@ -1274,30 +1346,31 @@ export const importSelectedNodes = async ({
12741346
originalNodePath,
12751347
});
12761348

1277-
// Update markdown content with new asset paths if assets were imported
1278-
if (assetImportResult.pathMapping.size > 0) {
1279-
const currentContent = await plugin.app.vault.read(processedFile);
1280-
const updatedContent = updateMarkdownAssetLinks({
1281-
content: currentContent,
1282-
oldPathToNewPath: assetImportResult.pathMapping,
1283-
targetFile: processedFile,
1284-
app: plugin.app,
1285-
originalNodePath,
1286-
});
1287-
1288-
// Only update if content changed
1289-
if (updatedContent !== currentContent) {
1290-
await plugin.app.vault.process(processedFile, () => updatedContent);
1291-
}
1349+
// Update markdown content: rewrite asset paths from pathMapping and normalize all wiki links to relative paths
1350+
const currentContent = await plugin.app.vault.read(processedFile);
1351+
const updatedContent = updateMarkdownAssetLinks({
1352+
content: currentContent,
1353+
oldPathToNewPath: assetImportResult.pathMapping,
1354+
targetFile: processedFile,
1355+
app: plugin.app,
1356+
originalNodePath,
1357+
});
1358+
1359+
// Only update if content changed
1360+
if (updatedContent !== currentContent) {
1361+
await plugin.app.vault.process(processedFile, () => updatedContent);
12921362
}
12931363

12941364
// If title changed and file exists, rename it to match the new title
12951365
if (existingFile && processedFile.basename !== sanitizedFileName) {
1296-
const newPath = `${importFolderPath}/${sanitizedFileName}.md`;
1366+
const currentDir = processedFile.path.includes("/")
1367+
? processedFile.path.replace(/\/[^/]*$/, "")
1368+
: importFolderPath;
1369+
const newPath = `${currentDir}/${sanitizedFileName}.md`;
12971370
let targetPath = newPath;
12981371
let counter = 1;
12991372
while (await plugin.app.vault.adapter.exists(targetPath)) {
1300-
targetPath = `${importFolderPath}/${sanitizedFileName} (${counter}).md`;
1373+
targetPath = `${currentDir}/${sanitizedFileName} (${counter}).md`;
13011374
counter++;
13021375
}
13031376
await plugin.app.fileManager.renameFile(processedFile, targetPath);
@@ -1442,3 +1515,11 @@ export const refreshAllImportedFiles = async (
14421515

14431516
return { success: successCount, failed: failedCount, errors };
14441517
};
1518+
1519+
const encodePathForMarkdownLink = (linkPath: string): string => {
1520+
// Input is already decoded; encode each segment (spaces → %20) but keep / as separator
1521+
return linkPath
1522+
.split("/")
1523+
.map((segment) => encodeURIComponent(segment))
1524+
.join("/");
1525+
};

0 commit comments

Comments
 (0)