Skip to content

Commit f2bf048

Browse files
trangdoan982claudedevin-ai-integration[bot]
authored
ENG-1652 Add .dg.metadata for stable import folder identification (#970)
* ENG-1652 Add .dg.metadata for stable import folder identification Replace vault-name-based folder matching with spaceUri-based matching to handle vault renames and name collisions. Each imported folder now gets a .dg.metadata file with spaceUri, spaceName, and optional userName fields. - New importFolderMetadata.ts utility: read/write .dg.metadata, build spaceUri→folderPath map, resolve folders by spaceUri with name-based fallback and collision-safe folder creation - Migration on onLoad backfills .dg.metadata for existing folders using plugin.settings.spaceNames as the source of truth - importSelectedNodes uses resolveFolderForSpaceUri instead of naive import/${sanitizedSpaceName} path construction - Stop writing to plugin.settings.spaceNames (reads kept for migration) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Keep writing spaceNames to settings for UI display UI components (NodeTypeSettings, RelationshipTypeSettings, etc.) read plugin.settings.spaceNames via formatImportSource() to display human-readable vault names. Restore the write so new imports don't show truncated vault IDs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Apply suggestion from @devin-ai-integration[bot] Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> * address PR comment * address PR comments * address devin comment * fix lint --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent fff0556 commit f2bf048

5 files changed

Lines changed: 259 additions & 14 deletions

File tree

apps/obsidian/src/components/ImportNodesModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ const ImportNodesContent = ({ plugin, onClose }: ImportNodesModalProps) => {
8686
getSpaceUris(client, uniqueSpaceIds),
8787
]);
8888

89-
// Populate plugin settings with current space names so they stay up to date
89+
// Keep spaceNames in settings up to date for UI display (formatImportSource reads it)
9090
if (uniqueSpaceIds.length > 0) {
9191
if (!plugin.settings.spaceNames) plugin.settings.spaceNames = {};
9292

apps/obsidian/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
migrateFrontmatterRelationsToRelationsJson,
3939
mergeAllRelationsJsonToRoot,
4040
} from "~/utils/relationsStore";
41+
import { migrateImportFolderMetadata } from "./utils/importFolderMetadata";
4142

4243
export default class DiscourseGraphPlugin extends Plugin {
4344
settings: Settings = { ...DEFAULT_SETTINGS };
@@ -58,6 +59,10 @@ export default class DiscourseGraphPlugin extends Plugin {
5859
console.error("Failed to migrate frontmatter relations:", error);
5960
});
6061

62+
await migrateImportFolderMetadata(this).catch((error) => {
63+
console.error("Failed to migrate import folder metadata:", error);
64+
});
65+
6166
if (this.settings.syncModeEnabled === true) {
6267
void initializeSupabaseSync(this).catch((error) => {
6368
console.error("Failed to initialize Supabase sync:", error);

apps/obsidian/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,10 @@ export type GroupWithNodes = {
111111
authorIds: Set<number>;
112112
};
113113

114+
export type ImportFolderMetadata = {
115+
spaceUri: string;
116+
spaceName: string;
117+
userName?: string;
118+
};
119+
114120
export const VIEW_TYPE_DISCOURSE_CONTEXT = "discourse-context-view";
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { DataAdapter, Notice } from "obsidian";
2+
import type DiscourseGraphPlugin from "~/index";
3+
import type { ImportFolderMetadata } from "~/types";
4+
5+
const DG_METADATA_FILE = ".dg.metadata";
6+
const IMPORT_ROOT = "import";
7+
8+
const sanitizeFileName = (fileName: string): string => {
9+
return fileName
10+
.replace(/[<>:"/\\|?*]/g, "")
11+
.replace(/\s+/g, " ")
12+
.trim();
13+
};
14+
15+
const generateShortId = (): string => Math.random().toString(36).slice(2, 8);
16+
17+
const readImportFolderMetadata = async (
18+
adapter: DataAdapter,
19+
folderPath: string,
20+
): Promise<ImportFolderMetadata | null> => {
21+
const metadataPath = `${folderPath}/${DG_METADATA_FILE}`;
22+
try {
23+
const exists = await adapter.exists(metadataPath);
24+
if (!exists) return null;
25+
26+
const raw = await adapter.read(metadataPath);
27+
const parsed: unknown = JSON.parse(raw);
28+
29+
if (
30+
parsed !== null &&
31+
typeof parsed === "object" &&
32+
"spaceUri" in parsed &&
33+
typeof (parsed as Record<string, unknown>).spaceUri === "string"
34+
) {
35+
return parsed as ImportFolderMetadata;
36+
}
37+
38+
return null;
39+
} catch {
40+
return null;
41+
}
42+
};
43+
44+
const writeImportFolderMetadata = async ({
45+
adapter,
46+
folderPath,
47+
metadata,
48+
}: {
49+
adapter: DataAdapter;
50+
folderPath: string;
51+
metadata: ImportFolderMetadata;
52+
}): Promise<void> => {
53+
const metadataPath = `${folderPath}/${DG_METADATA_FILE}`;
54+
await adapter.write(metadataPath, JSON.stringify(metadata, null, 2));
55+
};
56+
57+
const resolveMetadataDuplicate = async ({
58+
adapter,
59+
existingFolderPath,
60+
newFolderPath,
61+
}: {
62+
adapter: DataAdapter;
63+
existingFolderPath: string;
64+
newFolderPath: string;
65+
}): Promise<string> => {
66+
const existingMetadataPath = `${existingFolderPath}/${DG_METADATA_FILE}`;
67+
const newMetadataPath = `${newFolderPath}/${DG_METADATA_FILE}`;
68+
69+
const existingStat = await adapter.stat(existingMetadataPath);
70+
const newStat = await adapter.stat(newMetadataPath);
71+
72+
const newIsNewer =
73+
existingStat && newStat && existingStat.mtime < newStat.mtime;
74+
if (newIsNewer) {
75+
await adapter.remove(existingMetadataPath);
76+
return newFolderPath;
77+
}
78+
79+
await adapter.remove(newMetadataPath);
80+
return existingFolderPath;
81+
};
82+
83+
const buildSpaceUriToFolderMap = async (
84+
adapter: DataAdapter,
85+
): Promise<Map<string, string>> => {
86+
const map = new Map<string, string>();
87+
88+
const importExists = await adapter.exists(IMPORT_ROOT);
89+
if (!importExists) return map;
90+
91+
const { folders } = await adapter.list(IMPORT_ROOT);
92+
93+
for (const folderPath of folders) {
94+
const metadata = await readImportFolderMetadata(adapter, folderPath);
95+
if (!metadata) continue;
96+
97+
if (map.has(metadata.spaceUri)) {
98+
const existingPath = map.get(metadata.spaceUri)!;
99+
const keptPath = await resolveMetadataDuplicate({
100+
adapter,
101+
existingFolderPath: existingPath,
102+
newFolderPath: folderPath,
103+
});
104+
map.set(metadata.spaceUri, keptPath);
105+
} else {
106+
map.set(metadata.spaceUri, folderPath);
107+
}
108+
}
109+
110+
return map;
111+
};
112+
113+
export const resolveFolderForSpaceUri = async ({
114+
adapter,
115+
spaceUri,
116+
spaceName,
117+
}: {
118+
adapter: DataAdapter;
119+
spaceUri: string;
120+
spaceName: string;
121+
}): Promise<string> => {
122+
const spaceUriToFolder = await buildSpaceUriToFolderMap(adapter);
123+
124+
// 1. Exact spaceUri match
125+
if (spaceUriToFolder.has(spaceUri)) {
126+
const folderPath = spaceUriToFolder.get(spaceUri)!;
127+
const existingMetadata = await readImportFolderMetadata(
128+
adapter,
129+
folderPath,
130+
);
131+
if (existingMetadata && existingMetadata.spaceName !== spaceName) {
132+
await writeImportFolderMetadata({
133+
adapter,
134+
folderPath,
135+
metadata: { ...existingMetadata, spaceName },
136+
});
137+
}
138+
return folderPath;
139+
}
140+
141+
// 2. Fallback: scan for a folder whose basename matches the sanitized spaceName
142+
// but has no metadata yet
143+
const { folders } = (await adapter.exists(IMPORT_ROOT))
144+
? await adapter.list(IMPORT_ROOT)
145+
: { folders: [] };
146+
147+
const sanitized = sanitizeFileName(spaceName);
148+
149+
for (const folderPath of folders) {
150+
const basename = folderPath.split("/").pop();
151+
if (basename === sanitized) {
152+
const existingMetadata = await readImportFolderMetadata(
153+
adapter,
154+
folderPath,
155+
);
156+
if (!existingMetadata) {
157+
await writeImportFolderMetadata({
158+
adapter,
159+
folderPath,
160+
metadata: { spaceUri, spaceName },
161+
});
162+
return folderPath;
163+
}
164+
}
165+
}
166+
167+
// 3. Create a new folder, handling name collisions
168+
const desiredPath = `${IMPORT_ROOT}/${sanitized}`;
169+
const desiredExists = await adapter.exists(desiredPath);
170+
171+
let newPath: string;
172+
if (desiredExists) {
173+
// The existing folder has a different spaceUri (would have been returned above otherwise)
174+
newPath = `${IMPORT_ROOT}/${sanitized}-${generateShortId()}`;
175+
} else {
176+
newPath = desiredPath;
177+
}
178+
179+
await adapter.mkdir(newPath);
180+
await writeImportFolderMetadata({
181+
adapter,
182+
folderPath: newPath,
183+
metadata: { spaceUri, spaceName },
184+
});
185+
186+
return newPath;
187+
};
188+
189+
export const migrateImportFolderMetadata = async (
190+
plugin: DiscourseGraphPlugin,
191+
): Promise<void> => {
192+
const adapter = plugin.app.vault.adapter;
193+
194+
const importExists = await adapter.exists(IMPORT_ROOT);
195+
if (!importExists) return;
196+
197+
const { folders } = await adapter.list(IMPORT_ROOT);
198+
199+
// Invert spaceNames: Record<spaceUri, spaceName> → Map<sanitizedName, Set<spaceUri>>
200+
// Using a Set per name to detect collisions — two different spaceUris can share
201+
// the same sanitized folder name, making the mapping ambiguous.
202+
const spaceNames = plugin.settings.spaceNames ?? {};
203+
const nameToSpaceUris = new Map<string, Set<string>>();
204+
for (const [spaceUri, name] of Object.entries(spaceNames)) {
205+
const sanitized = sanitizeFileName(name);
206+
const existing = nameToSpaceUris.get(sanitized);
207+
if (existing) {
208+
existing.add(spaceUri);
209+
new Notice(
210+
`Discourse Graphs: ambiguous import folder name "${sanitized}" maps to multiple spaces — skipping migration for this folder.`,
211+
);
212+
} else {
213+
nameToSpaceUris.set(sanitized, new Set([spaceUri]));
214+
}
215+
}
216+
217+
for (const folderPath of folders) {
218+
const metadataPath = `${folderPath}/${DG_METADATA_FILE}`;
219+
const metadataExists = await adapter.exists(metadataPath);
220+
if (metadataExists) continue;
221+
222+
const basename = folderPath.split("/").pop() ?? "";
223+
const spaceUris = nameToSpaceUris.get(basename);
224+
225+
if (spaceUris?.size === 1) {
226+
const spaceUri = [...spaceUris][0]!;
227+
await writeImportFolderMetadata({
228+
adapter,
229+
folderPath,
230+
metadata: { spaceUri, spaceName: spaceNames[spaceUri] ?? basename },
231+
});
232+
}
233+
}
234+
};

apps/obsidian/src/utils/importNodes.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
importRelationsForImportedNodes,
2020
type RemoteRelationInstance,
2121
} from "./importRelations";
22+
import { resolveFolderForSpaceUri } from "./importFolderMetadata";
2223

2324
export const getAvailableGroupIds = async (
2425
client: DGSupabaseClient,
@@ -774,15 +775,15 @@ const importAssetsForNode = async ({
774775
client,
775776
spaceId,
776777
nodeInstanceId,
777-
spaceName,
778+
importBasePath,
778779
targetMarkdownFile,
779780
originalNodePath,
780781
}: {
781782
plugin: DiscourseGraphPlugin;
782783
client: DGSupabaseClient;
783784
spaceId: number;
784785
nodeInstanceId: string;
785-
spaceName: string;
786+
importBasePath: string;
786787
targetMarkdownFile: TFile;
787788
/** Source vault path of the note (e.g. from Content metadata filePath). Used to place assets under import/{space}/ relative to note. */
788789
originalNodePath?: string;
@@ -814,8 +815,6 @@ const importAssetsForNode = async ({
814815
return { success: true, pathMapping, errors };
815816
}
816817

817-
const importBasePath = `import/${sanitizeFileName(spaceName)}`;
818-
819818
// Get existing asset mappings from frontmatter
820819
const cache = plugin.app.metadataCache.getFileCache(targetMarkdownFile);
821820
const frontmatter = (cache?.frontmatter as Record<string, unknown>) || {};
@@ -1248,11 +1247,12 @@ export const importSelectedNodes = async ({
12481247
}
12491248

12501249
const spaceUris = await getSpaceUris(client, [...nodesBySpace.keys()]);
1250+
const spaceNames = await getSpaceNameFromIds(client, [
1251+
...nodesBySpace.keys(),
1252+
]);
12511253

12521254
// Process each space
12531255
for (const [spaceId, nodes] of nodesBySpace.entries()) {
1254-
const spaceName = await getSpaceNameFromId(client, spaceId);
1255-
const importFolderPath = `import/${sanitizeFileName(spaceName)}`;
12561256
const spaceUri = spaceUris.get(spaceId);
12571257
if (!spaceUri) {
12581258
for (const _node of nodes) {
@@ -1263,12 +1263,12 @@ export const importSelectedNodes = async ({
12631263
continue;
12641264
}
12651265

1266-
// Ensure the import folder exists
1267-
const folderExists =
1268-
await plugin.app.vault.adapter.exists(importFolderPath);
1269-
if (!folderExists) {
1270-
await plugin.app.vault.createFolder(importFolderPath);
1271-
}
1266+
const spaceName = spaceNames.get(spaceId) ?? `space-${spaceId}`;
1267+
const importFolderPath = await resolveFolderForSpaceUri({
1268+
adapter: plugin.app.vault.adapter,
1269+
spaceUri,
1270+
spaceName,
1271+
});
12721272

12731273
// Process each node in this space
12741274
for (const node of nodes) {
@@ -1371,7 +1371,7 @@ export const importSelectedNodes = async ({
13711371
client,
13721372
spaceId,
13731373
nodeInstanceId: node.nodeInstanceId,
1374-
spaceName,
1374+
importBasePath: importFolderPath,
13751375
targetMarkdownFile: processedFile,
13761376
originalNodePath,
13771377
});

0 commit comments

Comments
 (0)