-
Notifications
You must be signed in to change notification settings - Fork 4
ENG-1652 Add .dg.metadata for stable import folder identification #970
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1432131
2a6db1a
c9cc256
1698399
a633a37
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<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 resolveMetadataDuplicate = async ({ | ||
| adapter, | ||
| existingFolderPath, | ||
| newFolderPath, | ||
| }: { | ||
| adapter: DataAdapter; | ||
| existingFolderPath: string; | ||
| newFolderPath: string; | ||
| }): Promise<string> => { | ||
| 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<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)) { | ||
| 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<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); | ||
| } | ||
|
trangdoan982 marked this conversation as resolved.
Comment on lines
+201
to
+204
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Migration silently assigns wrong spaceUri when multiple spaces share the same sanitized name In Possible fix: detect collisions and skip ambiguous foldersBefore assigning metadata, check if the sanitized name has already been used. If a collision is detected in the inverted map, skip migration for that folder and log a warning, rather than silently assigning the wrong URI. Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| 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 }, | ||
| }); | ||
| } | ||
| } | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 No existence check for randomly generated collision-avoidance folder path
When
resolveFolderForSpaceUridetects a name collision at step 3 (line 169), it appends a random 6-char suffix viagenerateShortId()but never checks whether the resulting path already exists. While the collision probability is very low (~1 in 2.2 billion), if a collision occurs,adapter.mkdirmay be a no-op on an existing folder, andwriteImportFolderMetadataat line 180 would overwrite that folder's.dg.metadata, effectively stealing it from another space.Was this helpful? React with 👍 or 👎 to provide feedback.