|
| 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 | +}; |
0 commit comments