Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/obsidian/src/components/ImportNodesModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};

Expand Down
5 changes: 5 additions & 0 deletions apps/obsidian/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions apps/obsidian/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
196 changes: 196 additions & 0 deletions apps/obsidian/src/utils/importFolderMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
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<ImportFolderMetadata | null> => {
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<string, unknown>).spaceUri === "string"
) {
return parsed as ImportFolderMetadata;
}

return null;
} catch {
return null;
}
};

const writeImportFolderMetadata = async ({
adapter,
folderPath,
metadata,
}: {
adapter: DataAdapter;
folderPath: string;
metadata: ImportFolderMetadata;
}): Promise<void> => {
const metadataPath = `${folderPath}/${DG_METADATA_FILE}`;
await adapter.write(metadataPath, JSON.stringify(metadata, null, 2));
};

const buildSpaceUriToFolderMap = async (
adapter: DataAdapter,
): Promise<Map<string, string>> => {
const map = new Map<string, string>();

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)) {
console.warn(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's think of a resolution step here. I would say delete the older metadata file of the two?

`[importFolderMetadata] Duplicate spaceUri "${metadata.spaceUri}" found in "${folderPath}". Using first occurrence.`,
);
} else {
map.set(metadata.spaceUri, folderPath);
}
}

return map;
};

export const resolveFolderForSpaceUri = async ({
adapter,
spaceUri,
spaceName,
}: {
adapter: DataAdapter;
spaceUri: string;
spaceName: string;
}): Promise<string> => {
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<void> => {
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<spaceUri, spaceName> → Map<spaceName, spaceUri>
const spaceNames = plugin.settings.spaceNames ?? {};
const nameToSpaceUri = new Map<string, string>();
for (const [spaceUri, name] of Object.entries(spaceNames)) {
nameToSpaceUri.set(sanitizeFileName(name), spaceUri);
}
Comment thread
trangdoan982 marked this conversation as resolved.

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 },
});
} else {
console.debug(
`[importFolderMetadata] No spaceUri found for folder "${basename}", skipping migration.`,
);
}
}
};
23 changes: 10 additions & 13 deletions apps/obsidian/src/utils/importNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
importRelationsForImportedNodes,
type RemoteRelationInstance,
} from "./importRelations";
import { resolveFolderForSpaceUri } from "./importFolderMetadata";

export const getAvailableGroupIds = async (
client: DGSupabaseClient,
Expand Down Expand Up @@ -754,15 +755,15 @@ const importAssetsForNode = async ({
client,
spaceId,
nodeInstanceId,
spaceName,
importBasePath,
targetMarkdownFile,
originalNodePath,
}: {
plugin: DiscourseGraphPlugin;
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;
Expand Down Expand Up @@ -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<string, unknown>) || {};
Expand Down Expand Up @@ -1226,8 +1225,6 @@ export const importSelectedNodes = async ({

// 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) {
Expand All @@ -1238,12 +1235,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 = await getSpaceNameFromId(client, spaceId);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Realizing that this is another case of a database call within a loop. Can you use getSpaceNameFromIds instead?

const importFolderPath = await resolveFolderForSpaceUri({
adapter: plugin.app.vault.adapter,
spaceUri,
spaceName,
});

// Process each node in this space
for (const node of nodes) {
Expand Down Expand Up @@ -1344,7 +1341,7 @@ export const importSelectedNodes = async ({
client,
spaceId,
nodeInstanceId: node.nodeInstanceId,
spaceName,
importBasePath: importFolderPath,
targetMarkdownFile: processedFile,
originalNodePath,
});
Expand Down
Loading