diff --git a/apps/obsidian/src/components/ImportNodesModal.tsx b/apps/obsidian/src/components/ImportNodesModal.tsx index 0584e1524..530da532d 100644 --- a/apps/obsidian/src/components/ImportNodesModal.tsx +++ b/apps/obsidian/src/components/ImportNodesModal.tsx @@ -82,7 +82,7 @@ const ImportNodesContent = ({ plugin, onClose }: ImportNodesModalProps) => { getSpaceUris(client, uniqueSpaceIds), ]); - // Populate plugin settings with current space names so they stay up to date + // Keep spaceNames in settings up to date for UI display (formatImportSource reads it) if (uniqueSpaceIds.length > 0) { if (!plugin.settings.spaceNames) plugin.settings.spaceNames = {}; diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 290a35c62..4afe8f55c 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -37,6 +37,7 @@ import { migrateFrontmatterRelationsToRelationsJson, mergeAllRelationsJsonToRoot, } from "~/utils/relationsStore"; +import { migrateImportFolderMetadata } from "./utils/importFolderMetadata"; export default class DiscourseGraphPlugin extends Plugin { settings: Settings = { ...DEFAULT_SETTINGS }; @@ -57,6 +58,10 @@ export default class DiscourseGraphPlugin extends Plugin { console.error("Failed to migrate frontmatter relations:", error); }); + await migrateImportFolderMetadata(this).catch((error) => { + console.error("Failed to migrate import folder metadata:", error); + }); + if (this.settings.syncModeEnabled === true) { void initializeSupabaseSync(this).catch((error) => { console.error("Failed to initialize Supabase sync:", error); diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts index 716c85d57..de6d61b22 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -100,4 +100,10 @@ export type GroupWithNodes = { nodes: ImportableNode[]; }; +export type ImportFolderMetadata = { + spaceUri: string; + spaceName: string; + userName?: string; +}; + export const VIEW_TYPE_DISCOURSE_CONTEXT = "discourse-context-view"; diff --git a/apps/obsidian/src/utils/importFolderMetadata.ts b/apps/obsidian/src/utils/importFolderMetadata.ts new file mode 100644 index 000000000..2a4abf2af --- /dev/null +++ b/apps/obsidian/src/utils/importFolderMetadata.ts @@ -0,0 +1,222 @@ +import { DataAdapter } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import type { ImportFolderMetadata } from "~/types"; + +const DG_METADATA_FILE = ".dg.metadata"; +const IMPORT_ROOT = "import"; + +const sanitizeFileName = (fileName: string): string => { + return fileName + .replace(/[<>:"/\\|?*]/g, "") + .replace(/\s+/g, " ") + .trim(); +}; + +const generateShortId = (): string => Math.random().toString(36).slice(2, 8); + +const readImportFolderMetadata = async ( + adapter: DataAdapter, + folderPath: string, +): Promise => { + const metadataPath = `${folderPath}/${DG_METADATA_FILE}`; + try { + const exists = await adapter.exists(metadataPath); + if (!exists) return null; + + const raw = await adapter.read(metadataPath); + const parsed: unknown = JSON.parse(raw); + + if ( + parsed !== null && + typeof parsed === "object" && + "spaceUri" in parsed && + typeof (parsed as Record).spaceUri === "string" + ) { + return parsed as ImportFolderMetadata; + } + + return null; + } catch { + return null; + } +}; + +const writeImportFolderMetadata = async ({ + adapter, + folderPath, + metadata, +}: { + adapter: DataAdapter; + folderPath: string; + metadata: ImportFolderMetadata; +}): Promise => { + const metadataPath = `${folderPath}/${DG_METADATA_FILE}`; + await adapter.write(metadataPath, JSON.stringify(metadata, null, 2)); +}; + +const resolveMetadataDuplicate = async ({ + adapter, + existingFolderPath, + newFolderPath, +}: { + adapter: DataAdapter; + existingFolderPath: string; + newFolderPath: string; +}): Promise => { + const existingMetadataPath = `${existingFolderPath}/${DG_METADATA_FILE}`; + const newMetadataPath = `${newFolderPath}/${DG_METADATA_FILE}`; + + const existingStat = await adapter.stat(existingMetadataPath); + const newStat = await adapter.stat(newMetadataPath); + + const newIsNewer = + existingStat && newStat && existingStat.mtime < newStat.mtime; + if (newIsNewer) { + await adapter.remove(existingMetadataPath); + return newFolderPath; + } + + await adapter.remove(newMetadataPath); + return existingFolderPath; +}; + +const buildSpaceUriToFolderMap = async ( + adapter: DataAdapter, +): Promise> => { + const map = new Map(); + + const importExists = await adapter.exists(IMPORT_ROOT); + if (!importExists) return map; + + const { folders } = await adapter.list(IMPORT_ROOT); + + for (const folderPath of folders) { + const metadata = await readImportFolderMetadata(adapter, folderPath); + if (!metadata) continue; + + if (map.has(metadata.spaceUri)) { + const existingPath = map.get(metadata.spaceUri)!; + const keptPath = await resolveMetadataDuplicate({ + adapter, + existingFolderPath: existingPath, + newFolderPath: folderPath, + }); + map.set(metadata.spaceUri, keptPath); + } else { + map.set(metadata.spaceUri, folderPath); + } + } + + return map; +}; + +export const resolveFolderForSpaceUri = async ({ + adapter, + spaceUri, + spaceName, +}: { + adapter: DataAdapter; + spaceUri: string; + spaceName: string; +}): Promise => { + const spaceUriToFolder = await buildSpaceUriToFolderMap(adapter); + + // 1. Exact spaceUri match + if (spaceUriToFolder.has(spaceUri)) { + const folderPath = spaceUriToFolder.get(spaceUri)!; + const existingMetadata = await readImportFolderMetadata( + adapter, + folderPath, + ); + if (existingMetadata && existingMetadata.spaceName !== spaceName) { + await writeImportFolderMetadata({ + adapter, + folderPath, + metadata: { ...existingMetadata, spaceName }, + }); + } + return folderPath; + } + + // 2. Fallback: scan for a folder whose basename matches the sanitized spaceName + // but has no metadata yet + const { folders } = (await adapter.exists(IMPORT_ROOT)) + ? await adapter.list(IMPORT_ROOT) + : { folders: [] }; + + const sanitized = sanitizeFileName(spaceName); + + for (const folderPath of folders) { + const basename = folderPath.split("/").pop(); + if (basename === sanitized) { + const existingMetadata = await readImportFolderMetadata( + adapter, + folderPath, + ); + if (!existingMetadata) { + await writeImportFolderMetadata({ + adapter, + folderPath, + metadata: { spaceUri, spaceName }, + }); + return folderPath; + } + } + } + + // 3. Create a new folder, handling name collisions + const desiredPath = `${IMPORT_ROOT}/${sanitized}`; + const desiredExists = await adapter.exists(desiredPath); + + let newPath: string; + if (desiredExists) { + // The existing folder has a different spaceUri (would have been returned above otherwise) + newPath = `${IMPORT_ROOT}/${sanitized}-${generateShortId()}`; + } else { + newPath = desiredPath; + } + + await adapter.mkdir(newPath); + await writeImportFolderMetadata({ + adapter, + folderPath: newPath, + metadata: { spaceUri, spaceName }, + }); + + return newPath; +}; + +export const migrateImportFolderMetadata = async ( + plugin: DiscourseGraphPlugin, +): Promise => { + const adapter = plugin.app.vault.adapter; + + const importExists = await adapter.exists(IMPORT_ROOT); + if (!importExists) return; + + const { folders } = await adapter.list(IMPORT_ROOT); + + // Invert spaceNames: Record → Map + const spaceNames = plugin.settings.spaceNames ?? {}; + const nameToSpaceUri = new Map(); + for (const [spaceUri, name] of Object.entries(spaceNames)) { + nameToSpaceUri.set(sanitizeFileName(name), spaceUri); + } + + for (const folderPath of folders) { + const metadataPath = `${folderPath}/${DG_METADATA_FILE}`; + const metadataExists = await adapter.exists(metadataPath); + if (metadataExists) continue; + + const basename = folderPath.split("/").pop() ?? ""; + const spaceUri = nameToSpaceUri.get(basename); + + if (spaceUri) { + await writeImportFolderMetadata({ + adapter, + folderPath, + metadata: { spaceUri, spaceName: spaceNames[spaceUri] ?? basename }, + }); + } + } +}; diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index 2a8841b71..5282ff5aa 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -19,6 +19,7 @@ import { importRelationsForImportedNodes, type RemoteRelationInstance, } from "./importRelations"; +import { resolveFolderForSpaceUri } from "./importFolderMetadata"; export const getAvailableGroupIds = async ( client: DGSupabaseClient, @@ -754,7 +755,7 @@ const importAssetsForNode = async ({ client, spaceId, nodeInstanceId, - spaceName, + importBasePath, targetMarkdownFile, originalNodePath, }: { @@ -762,7 +763,7 @@ const importAssetsForNode = async ({ client: DGSupabaseClient; spaceId: number; nodeInstanceId: string; - spaceName: string; + importBasePath: string; targetMarkdownFile: TFile; /** Source vault path of the note (e.g. from Content metadata filePath). Used to place assets under import/{space}/ relative to note. */ originalNodePath?: string; @@ -794,8 +795,6 @@ const importAssetsForNode = async ({ return { success: true, pathMapping, errors }; } - const importBasePath = `import/${sanitizeFileName(spaceName)}`; - // Get existing asset mappings from frontmatter const cache = plugin.app.metadataCache.getFileCache(targetMarkdownFile); const frontmatter = (cache?.frontmatter as Record) || {}; @@ -1223,11 +1222,12 @@ export const importSelectedNodes = async ({ } const spaceUris = await getSpaceUris(client, [...nodesBySpace.keys()]); + const spaceNames = await getSpaceNameFromIds(client, [ + ...nodesBySpace.keys(), + ]); // Process each space for (const [spaceId, nodes] of nodesBySpace.entries()) { - const spaceName = await getSpaceNameFromId(client, spaceId); - const importFolderPath = `import/${sanitizeFileName(spaceName)}`; const spaceUri = spaceUris.get(spaceId); if (!spaceUri) { for (const _node of nodes) { @@ -1238,12 +1238,12 @@ export const importSelectedNodes = async ({ continue; } - // Ensure the import folder exists - const folderExists = - await plugin.app.vault.adapter.exists(importFolderPath); - if (!folderExists) { - await plugin.app.vault.createFolder(importFolderPath); - } + const spaceName = spaceNames.get(spaceId) ?? `space-${spaceId}`; + const importFolderPath = await resolveFolderForSpaceUri({ + adapter: plugin.app.vault.adapter, + spaceUri, + spaceName, + }); // Process each node in this space for (const node of nodes) { @@ -1344,7 +1344,7 @@ export const importSelectedNodes = async ({ client, spaceId, nodeInstanceId: node.nodeInstanceId, - spaceName, + importBasePath: importFolderPath, targetMarkdownFile: processedFile, originalNodePath, });