From 1c3615e27c17cb562831268409974ec697db554c Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 18 Jun 2026 11:52:03 -0400 Subject: [PATCH 01/10] ENG-1656: Rename imported vault folders to owner-username-spaceName Use .dg.metadata for folder identity and auto-rename legacy import/ folders on load and import so multi-user lab vaults are easier to tell apart. Co-authored-by: Cursor --- apps/obsidian/package.json | 3 +- apps/obsidian/src/index.ts | 33 +- apps/obsidian/src/types.ts | 2 + .../src/utils/importFolderMetadata.ts | 322 ++++++++++++++++-- apps/obsidian/src/utils/importFolderNaming.ts | 60 ++++ apps/obsidian/src/utils/importNodes.ts | 38 +++ .../tests/importFolderMetadata.test.ts | 98 ++++++ 7 files changed, 521 insertions(+), 35 deletions(-) create mode 100644 apps/obsidian/src/utils/importFolderNaming.ts create mode 100644 apps/obsidian/tests/importFolderMetadata.test.ts diff --git a/apps/obsidian/package.json b/apps/obsidian/package.json index 70537f7fc..a035a466e 100644 --- a/apps/obsidian/package.json +++ b/apps/obsidian/package.json @@ -10,7 +10,8 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "publish": "tsx scripts/publish.ts --version 0.1.0", - "check-types": "tsc --noEmit --skipLibCheck" + "check-types": "tsc --noEmit --skipLibCheck", + "test:import-folder-metadata": "tsx --test tests/importFolderMetadata.test.ts" }, "keywords": [], "author": "", diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index bd707c289..0e2849476 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -37,8 +37,13 @@ import { migrateFrontmatterRelationsToRelationsJson, mergeAllRelationsJsonToRoot, } from "~/utils/relationsStore"; -import { migrateImportFolderMetadata } from "./utils/importFolderMetadata"; +import { + migrateImportFolderMetadata, + migrateImportFolderNames, +} from "./utils/importFolderMetadata"; import { registerTemplateSettingsSync } from "~/utils/templateSettingsSync"; +import { fetchUserNames } from "~/utils/importNodes"; +import { getLoggedInClient } from "~/utils/supabaseContext"; export default class DiscourseGraphPlugin extends Plugin { settings: Settings = { ...DEFAULT_SETTINGS }; @@ -63,16 +68,28 @@ export default class DiscourseGraphPlugin extends Plugin { console.error("Failed to migrate import folder metadata:", error); }); + await migrateImportFolderNames(this).catch((error) => { + console.error("Failed to migrate import folder names:", error); + }); + registerTemplateSettingsSync(this); if (this.settings.syncModeEnabled === true) { - void initializeSupabaseSync(this).catch((error) => { - console.error("Failed to initialize Supabase sync:", error); - new Notice( - `Failed to initialize Supabase sync: ${error instanceof Error ? error.message : String(error)}`, - 5000, - ); - }); + void initializeSupabaseSync(this) + .then(async () => { + const client = await getLoggedInClient(this); + if (client) { + await fetchUserNames(this, client); + } + await migrateImportFolderNames(this); + }) + .catch((error) => { + console.error("Failed to initialize Supabase sync:", error); + new Notice( + `Failed to initialize Supabase sync: ${error instanceof Error ? error.message : String(error)}`, + 5000, + ); + }); try { this.fileChangeListener = new FileChangeListener(this); diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts index f7bc3fc41..1fb4a3907 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -115,6 +115,8 @@ export type ImportFolderMetadata = { spaceUri: string; spaceName: string; userName?: string; + /** Set after auto-rename or when the user has a custom folder name. */ + migrated?: boolean; }; 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 index fd1db4537..cafc71bae 100644 --- a/apps/obsidian/src/utils/importFolderMetadata.ts +++ b/apps/obsidian/src/utils/importFolderMetadata.ts @@ -1,17 +1,27 @@ -import { DataAdapter, Notice } from "obsidian"; +import { App, DataAdapter, Notice, TFolder } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; import type { ImportFolderMetadata } from "~/types"; +import { + buildImportFolderBasename, + isCustomFolderBasename, + isExpectedMigratedBasename, + isLegacyFolderBasename, + sanitizeImportFolderName, + shouldAutoRenameFolder, +} from "./importFolderNaming"; + +export { + buildImportFolderBasename, + isCustomFolderBasename, + isExpectedMigratedBasename, + isLegacyFolderBasename, + sanitizeImportFolderName, + shouldAutoRenameFolder, +} from "./importFolderNaming"; 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 ( @@ -110,14 +120,153 @@ const buildSpaceUriToFolderMap = async ( return map; }; +const resolveUniqueImportFolderPath = async ({ + adapter, + desiredBasename, + spaceUri, +}: { + adapter: DataAdapter; + desiredBasename: string; + spaceUri: string; +}): Promise => { + let basename = desiredBasename; + let path = `${IMPORT_ROOT}/${basename}`; + + while (await adapter.exists(path)) { + const existingMetadata = await readImportFolderMetadata(adapter, path); + if (existingMetadata?.spaceUri === spaceUri) { + return path; + } + basename = `${desiredBasename}-${generateShortId()}`; + path = `${IMPORT_ROOT}/${basename}`; + } + + return path; +}; + +export const renameImportFolder = async ({ + app, + adapter, + oldPath, + newPath, +}: { + app: App; + adapter: DataAdapter; + oldPath: string; + newPath: string; +}): Promise => { + if (oldPath === newPath) return; + + const folder = app.vault.getAbstractFileByPath(oldPath); + if (folder instanceof TFolder) { + await app.fileManager.renameFile(folder, newPath); + return; + } + + await adapter.rename(oldPath, newPath); +}; + +const maybeRenameImportFolder = async ({ + app, + adapter, + folderPath, + metadata, + spaceUri, + spaceName, + ownerUserName, +}: { + app?: App; + adapter: DataAdapter; + folderPath: string; + metadata: ImportFolderMetadata; + spaceUri: string; + spaceName: string; + ownerUserName?: string; +}): Promise => { + const basename = folderPath.split("/").pop() ?? ""; + const userName = ownerUserName ?? metadata.userName; + const updatedMetadata: ImportFolderMetadata = { + ...metadata, + spaceName, + ...(userName ? { userName } : {}), + }; + + if (metadata.migrated) { + if (updatedMetadata.spaceName !== metadata.spaceName) { + await writeImportFolderMetadata({ + adapter, + folderPath, + metadata: updatedMetadata, + }); + } + return folderPath; + } + + if (userName && isExpectedMigratedBasename(basename, userName, spaceName)) { + await writeImportFolderMetadata({ + adapter, + folderPath, + metadata: { ...updatedMetadata, migrated: true }, + }); + return folderPath; + } + + if (isCustomFolderBasename({ basename, spaceName, userName })) { + await writeImportFolderMetadata({ + adapter, + folderPath, + metadata: { ...updatedMetadata, migrated: true }, + }); + return folderPath; + } + + if (!shouldAutoRenameFolder({ metadata, basename, spaceName, userName })) { + if (updatedMetadata.spaceName !== metadata.spaceName) { + await writeImportFolderMetadata({ + adapter, + folderPath, + metadata: updatedMetadata, + }); + } + return folderPath; + } + + const newBasename = buildImportFolderBasename(userName!, spaceName); + const newPath = await resolveUniqueImportFolderPath({ + adapter, + desiredBasename: newBasename, + spaceUri, + }); + + if (newPath !== folderPath) { + if (app) { + await renameImportFolder({ app, adapter, oldPath: folderPath, newPath }); + } else { + await adapter.rename(folderPath, newPath); + } + } + + await writeImportFolderMetadata({ + adapter, + folderPath: newPath, + metadata: { ...updatedMetadata, userName: userName!, migrated: true }, + }); + + return newPath; +}; + export const resolveFolderForSpaceUri = async ({ adapter, + app, spaceUri, spaceName, + ownerUserName, }: { adapter: DataAdapter; + app?: App; spaceUri: string; spaceName: string; + ownerUserName?: string; }): Promise => { const spaceUriToFolder = await buildSpaceUriToFolderMap(adapter); @@ -128,11 +277,15 @@ export const resolveFolderForSpaceUri = async ({ adapter, folderPath, ); - if (existingMetadata && existingMetadata.spaceName !== spaceName) { - await writeImportFolderMetadata({ + if (existingMetadata) { + return maybeRenameImportFolder({ + app, adapter, folderPath, - metadata: { ...existingMetadata, spaceName }, + metadata: existingMetadata, + spaceUri, + spaceName, + ownerUserName, }); } return folderPath; @@ -144,7 +297,7 @@ export const resolveFolderForSpaceUri = async ({ ? await adapter.list(IMPORT_ROOT) : { folders: [] }; - const sanitized = sanitizeFileName(spaceName); + const sanitized = sanitizeImportFolderName(spaceName); for (const folderPath of folders) { const basename = folderPath.split("/").pop(); @@ -154,38 +307,155 @@ export const resolveFolderForSpaceUri = async ({ folderPath, ); if (!existingMetadata) { + const metadata: ImportFolderMetadata = { + spaceUri, + spaceName, + ...(ownerUserName ? { userName: ownerUserName } : {}), + }; await writeImportFolderMetadata({ adapter, folderPath, - metadata: { spaceUri, spaceName }, + metadata, + }); + return maybeRenameImportFolder({ + app, + adapter, + folderPath, + metadata, + spaceUri, + spaceName, + ownerUserName, }); - 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; - } + const desiredBasename = ownerUserName + ? buildImportFolderBasename(ownerUserName, spaceName) + : sanitized; + const newPath = await resolveUniqueImportFolderPath({ + adapter, + desiredBasename, + spaceUri, + }); await adapter.mkdir(newPath); await writeImportFolderMetadata({ adapter, folderPath: newPath, - metadata: { spaceUri, spaceName }, + metadata: { + spaceUri, + spaceName, + ...(ownerUserName ? { userName: ownerUserName, migrated: true } : {}), + }, }); return newPath; }; +const resolveUserNameFromFolder = ( + plugin: DiscourseGraphPlugin, + folderPath: string, +): string | undefined => { + const userNames = plugin.settings.userNames ?? {}; + const files = plugin.app.vault + .getMarkdownFiles() + .filter((file) => file.path.startsWith(`${folderPath}/`)); + + for (const file of files) { + const authorId = + plugin.app.metadataCache.getFileCache(file)?.frontmatter?.authorId; + if (typeof authorId === "number" && userNames[authorId]) { + return userNames[authorId]; + } + } + + return undefined; +}; + +export const migrateImportFolderNames = 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); + + for (const folderPath of folders) { + const metadata = await readImportFolderMetadata(adapter, folderPath); + if (!metadata) continue; + + const basename = folderPath.split("/").pop() ?? ""; + const userName = + metadata.userName ?? resolveUserNameFromFolder(plugin, folderPath); + + if (metadata.migrated) continue; + + if ( + isCustomFolderBasename({ + basename, + spaceName: metadata.spaceName, + userName, + }) + ) { + await writeImportFolderMetadata({ + adapter, + folderPath, + metadata: { ...metadata, migrated: true }, + }); + continue; + } + + if ( + !shouldAutoRenameFolder({ + metadata, + basename, + spaceName: metadata.spaceName, + userName, + }) + ) { + if (!userName) { + console.warn( + `Discourse Graphs: skipping import folder rename for "${folderPath}" — owner username unknown.`, + ); + } + continue; + } + + const newBasename = buildImportFolderBasename( + userName!, + metadata.spaceName, + ); + const newPath = await resolveUniqueImportFolderPath({ + adapter, + desiredBasename: newBasename, + spaceUri: metadata.spaceUri, + }); + + if (newPath !== folderPath) { + await renameImportFolder({ + app: plugin.app, + adapter, + oldPath: folderPath, + newPath, + }); + } + + await writeImportFolderMetadata({ + adapter, + folderPath: newPath, + metadata: { + ...metadata, + userName: userName!, + migrated: true, + }, + }); + } +}; + export const migrateImportFolderMetadata = async ( plugin: DiscourseGraphPlugin, ): Promise => { @@ -202,7 +472,7 @@ export const migrateImportFolderMetadata = async ( const spaceNames = plugin.settings.spaceNames ?? {}; const nameToSpaceUris = new Map>(); for (const [spaceUri, name] of Object.entries(spaceNames)) { - const sanitized = sanitizeFileName(name); + const sanitized = sanitizeImportFolderName(name); const existing = nameToSpaceUris.get(sanitized); if (existing) { existing.add(spaceUri); diff --git a/apps/obsidian/src/utils/importFolderNaming.ts b/apps/obsidian/src/utils/importFolderNaming.ts new file mode 100644 index 000000000..a17197764 --- /dev/null +++ b/apps/obsidian/src/utils/importFolderNaming.ts @@ -0,0 +1,60 @@ +export const sanitizeImportFolderName = (fileName: string): string => { + return fileName + .replace(/[<>:"/\\|?*]/g, "") + .replace(/\s+/g, " ") + .trim(); +}; + +export const buildImportFolderBasename = ( + userName: string, + spaceName: string, +): string => { + return sanitizeImportFolderName(`${userName}-${spaceName}`); +}; + +export const isLegacyFolderBasename = ( + basename: string, + spaceName: string, +): boolean => { + return basename === sanitizeImportFolderName(spaceName); +}; + +export const isExpectedMigratedBasename = ( + basename: string, + userName: string, + spaceName: string, +): boolean => { + return basename === buildImportFolderBasename(userName, spaceName); +}; + +export const isCustomFolderBasename = ({ + basename, + spaceName, + userName, +}: { + basename: string; + spaceName: string; + userName?: string; +}): boolean => { + if (isLegacyFolderBasename(basename, spaceName)) return false; + if (userName && isExpectedMigratedBasename(basename, userName, spaceName)) { + return false; + } + return true; +}; + +export const shouldAutoRenameFolder = ({ + metadata, + basename, + spaceName, + userName, +}: { + metadata: { migrated?: boolean }; + basename: string; + spaceName: string; + userName?: string; +}): boolean => { + if (metadata.migrated) return false; + if (!isLegacyFolderBasename(basename, spaceName)) return false; + return !!userName; +}; diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index 1502a3417..8a324c739 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -20,6 +20,7 @@ import { } from "./importRelations"; import { createTemplateFile } from "./templates"; import { resolveFolderForSpaceUri } from "./importFolderMetadata"; +import { getUserNameById } from "./typeUtils"; export type MyGroup = { id: string; @@ -281,6 +282,33 @@ export const fetchUserNames = async ( await plugin.saveSettings(); }; +export const resolveOwnerUserName = ( + nodes: ImportableNode[], + plugin: DiscourseGraphPlugin, +): string | undefined => { + const authorCounts = new Map(); + for (const node of nodes) { + if (node.authorId !== undefined) { + authorCounts.set( + node.authorId, + (authorCounts.get(node.authorId) ?? 0) + 1, + ); + } + } + if (authorCounts.size === 0) return undefined; + + let topAuthorId = 0; + let topCount = 0; + for (const [authorId, count] of authorCounts) { + if (count > topCount) { + topCount = count; + topAuthorId = authorId; + } + } + + return getUserNameById(plugin, topAuthorId); +}; + export const fetchNodeContent = async ({ client, spaceId, @@ -1304,6 +1332,13 @@ export const importSelectedNodes = async ({ const queryEngine = new QueryEngine(plugin.app); + if ( + !plugin.settings.userNames || + Object.keys(plugin.settings.userNames).length === 0 + ) { + await fetchUserNames(plugin, client); + } + let successCount = 0; let failedCount = 0; let processedCount = 0; @@ -1336,10 +1371,13 @@ export const importSelectedNodes = async ({ } const spaceName = spaceNames.get(spaceId) ?? `space-${spaceId}`; + const ownerUserName = resolveOwnerUserName(nodes, plugin); const importFolderPath = await resolveFolderForSpaceUri({ adapter: plugin.app.vault.adapter, + app: plugin.app, spaceUri, spaceName, + ownerUserName, }); // Process each node in this space diff --git a/apps/obsidian/tests/importFolderMetadata.test.ts b/apps/obsidian/tests/importFolderMetadata.test.ts new file mode 100644 index 000000000..9a902a908 --- /dev/null +++ b/apps/obsidian/tests/importFolderMetadata.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildImportFolderBasename, + isCustomFolderBasename, + isExpectedMigratedBasename, + isLegacyFolderBasename, + sanitizeImportFolderName, + shouldAutoRenameFolder, +} from "../src/utils/importFolderNaming.js"; + +test("buildImportFolderBasename sanitizes special characters", () => { + assert.equal( + buildImportFolderBasename("Alice Doe", "My/Vault"), + "Alice Doe-MyVault", + ); +}); + +test("isLegacyFolderBasename matches sanitized vault names", () => { + assert.equal(isLegacyFolderBasename("My Vault", "My Vault"), true); + assert.equal(isLegacyFolderBasename("Alice-My Vault", "My Vault"), false); +}); + +test("isExpectedMigratedBasename matches prefixed folder names", () => { + assert.equal( + isExpectedMigratedBasename("Alice-My Vault", "Alice", "My Vault"), + true, + ); + assert.equal( + isExpectedMigratedBasename("My Vault", "Alice", "My Vault"), + false, + ); +}); + +test("shouldAutoRenameFolder only renames legacy unmigrated folders with username", () => { + assert.equal( + shouldAutoRenameFolder({ + metadata: {}, + basename: "My Vault", + spaceName: "My Vault", + userName: "Alice", + }), + true, + ); + + assert.equal( + shouldAutoRenameFolder({ + metadata: { migrated: true }, + basename: "My Vault", + spaceName: "My Vault", + userName: "Alice", + }), + false, + ); + + assert.equal( + shouldAutoRenameFolder({ + metadata: {}, + basename: "Custom Name", + spaceName: "My Vault", + userName: "Alice", + }), + false, + ); + + assert.equal( + shouldAutoRenameFolder({ + metadata: {}, + basename: "My Vault", + spaceName: "My Vault", + }), + false, + ); +}); + +test("isCustomFolderBasename treats unexpected names as custom", () => { + assert.equal( + isCustomFolderBasename({ + basename: "Renamed By User", + spaceName: "My Vault", + userName: "Alice", + }), + true, + ); + + assert.equal( + isCustomFolderBasename({ + basename: buildImportFolderBasename("Alice", "My Vault"), + spaceName: "My Vault", + userName: "Alice", + }), + false, + ); +}); + +test("sanitizeImportFolderName trims and collapses whitespace", () => { + assert.equal(sanitizeImportFolderName(" Alice Doe "), "Alice Doe"); +}); From 2bd6a6d16bd11370bfc7db81e058018ad8b347db Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 18 Jun 2026 12:26:54 -0400 Subject: [PATCH 02/10] Fix import folder rename edge cases and CI lint findings Return undefined when owner username is not cached, treat legacy collision-suffixed folders as eligible for migration, and resolve lint warnings in changed files. Co-authored-by: Cursor --- .../src/utils/importFolderMetadata.ts | 6 ++-- apps/obsidian/src/utils/importFolderNaming.ts | 26 ++++++++++++-- apps/obsidian/src/utils/importNodes.ts | 4 +-- .../tests/importFolderMetadata.test.ts | 36 +++++++++++++++++++ 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/apps/obsidian/src/utils/importFolderMetadata.ts b/apps/obsidian/src/utils/importFolderMetadata.ts index cafc71bae..04d3d4fbe 100644 --- a/apps/obsidian/src/utils/importFolderMetadata.ts +++ b/apps/obsidian/src/utils/importFolderMetadata.ts @@ -5,7 +5,6 @@ import { buildImportFolderBasename, isCustomFolderBasename, isExpectedMigratedBasename, - isLegacyFolderBasename, sanitizeImportFolderName, shouldAutoRenameFolder, } from "./importFolderNaming"; @@ -364,8 +363,9 @@ const resolveUserNameFromFolder = ( .filter((file) => file.path.startsWith(`${folderPath}/`)); for (const file of files) { - const authorId = - plugin.app.metadataCache.getFileCache(file)?.frontmatter?.authorId; + const frontmatter = plugin.app.metadataCache.getFileCache(file) + ?.frontmatter as Record | undefined; + const authorId = frontmatter?.authorId; if (typeof authorId === "number" && userNames[authorId]) { return userNames[authorId]; } diff --git a/apps/obsidian/src/utils/importFolderNaming.ts b/apps/obsidian/src/utils/importFolderNaming.ts index a17197764..5893a9a5c 100644 --- a/apps/obsidian/src/utils/importFolderNaming.ts +++ b/apps/obsidian/src/utils/importFolderNaming.ts @@ -19,6 +19,28 @@ export const isLegacyFolderBasename = ( return basename === sanitizeImportFolderName(spaceName); }; +/** Matches old collision folders: `{spaceName}-{6-char id}`. */ +export const isLegacyCollisionFolderBasename = ( + basename: string, + spaceName: string, +): boolean => { + const sanitized = sanitizeImportFolderName(spaceName); + const prefix = `${sanitized}-`; + if (!basename.startsWith(prefix)) return false; + const suffix = basename.slice(prefix.length); + return /^[a-z0-9]{6}$/.test(suffix); +}; + +export const isLegacyOrCollisionFolderBasename = ( + basename: string, + spaceName: string, +): boolean => { + return ( + isLegacyFolderBasename(basename, spaceName) || + isLegacyCollisionFolderBasename(basename, spaceName) + ); +}; + export const isExpectedMigratedBasename = ( basename: string, userName: string, @@ -36,7 +58,7 @@ export const isCustomFolderBasename = ({ spaceName: string; userName?: string; }): boolean => { - if (isLegacyFolderBasename(basename, spaceName)) return false; + if (isLegacyOrCollisionFolderBasename(basename, spaceName)) return false; if (userName && isExpectedMigratedBasename(basename, userName, spaceName)) { return false; } @@ -55,6 +77,6 @@ export const shouldAutoRenameFolder = ({ userName?: string; }): boolean => { if (metadata.migrated) return false; - if (!isLegacyFolderBasename(basename, spaceName)) return false; + if (!isLegacyOrCollisionFolderBasename(basename, spaceName)) return false; return !!userName; }; diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index 8a324c739..7916204a2 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -20,7 +20,6 @@ import { } from "./importRelations"; import { createTemplateFile } from "./templates"; import { resolveFolderForSpaceUri } from "./importFolderMetadata"; -import { getUserNameById } from "./typeUtils"; export type MyGroup = { id: string; @@ -306,7 +305,8 @@ export const resolveOwnerUserName = ( } } - return getUserNameById(plugin, topAuthorId); + const userNames = plugin.settings.userNames ?? {}; + return userNames[topAuthorId]; }; export const fetchNodeContent = async ({ diff --git a/apps/obsidian/tests/importFolderMetadata.test.ts b/apps/obsidian/tests/importFolderMetadata.test.ts index 9a902a908..e01e73f13 100644 --- a/apps/obsidian/tests/importFolderMetadata.test.ts +++ b/apps/obsidian/tests/importFolderMetadata.test.ts @@ -1,9 +1,11 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ import assert from "node:assert/strict"; import test from "node:test"; import { buildImportFolderBasename, isCustomFolderBasename, isExpectedMigratedBasename, + isLegacyCollisionFolderBasename, isLegacyFolderBasename, sanitizeImportFolderName, shouldAutoRenameFolder, @@ -93,6 +95,40 @@ test("isCustomFolderBasename treats unexpected names as custom", () => { ); }); +test("isLegacyCollisionFolderBasename matches old collision suffix folders", () => { + assert.equal( + isLegacyCollisionFolderBasename("My Vault-abc123", "My Vault"), + true, + ); + assert.equal( + isLegacyCollisionFolderBasename("Alice-My Vault", "My Vault"), + false, + ); +}); + +test("shouldAutoRenameFolder treats collision-suffixed legacy folders as eligible", () => { + assert.equal( + shouldAutoRenameFolder({ + metadata: {}, + basename: "My Vault-abc123", + spaceName: "My Vault", + userName: "Alice", + }), + true, + ); +}); + +test("isCustomFolderBasename does not treat collision-suffixed legacy folders as custom", () => { + assert.equal( + isCustomFolderBasename({ + basename: "My Vault-abc123", + spaceName: "My Vault", + userName: "Alice", + }), + false, + ); +}); + test("sanitizeImportFolderName trims and collapses whitespace", () => { assert.equal(sanitizeImportFolderName(" Alice Doe "), "Alice Doe"); }); From 42425cf286d21a405881ae116837dea1fc7c87b5 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 18 Jun 2026 12:33:56 -0400 Subject: [PATCH 03/10] remove tests --- apps/obsidian/package.json | 3 +- .../tests/importFolderMetadata.test.ts | 134 ------------------ 2 files changed, 1 insertion(+), 136 deletions(-) delete mode 100644 apps/obsidian/tests/importFolderMetadata.test.ts diff --git a/apps/obsidian/package.json b/apps/obsidian/package.json index a035a466e..70537f7fc 100644 --- a/apps/obsidian/package.json +++ b/apps/obsidian/package.json @@ -10,8 +10,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "publish": "tsx scripts/publish.ts --version 0.1.0", - "check-types": "tsc --noEmit --skipLibCheck", - "test:import-folder-metadata": "tsx --test tests/importFolderMetadata.test.ts" + "check-types": "tsc --noEmit --skipLibCheck" }, "keywords": [], "author": "", diff --git a/apps/obsidian/tests/importFolderMetadata.test.ts b/apps/obsidian/tests/importFolderMetadata.test.ts deleted file mode 100644 index e01e73f13..000000000 --- a/apps/obsidian/tests/importFolderMetadata.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/* eslint-disable @typescript-eslint/no-floating-promises */ -import assert from "node:assert/strict"; -import test from "node:test"; -import { - buildImportFolderBasename, - isCustomFolderBasename, - isExpectedMigratedBasename, - isLegacyCollisionFolderBasename, - isLegacyFolderBasename, - sanitizeImportFolderName, - shouldAutoRenameFolder, -} from "../src/utils/importFolderNaming.js"; - -test("buildImportFolderBasename sanitizes special characters", () => { - assert.equal( - buildImportFolderBasename("Alice Doe", "My/Vault"), - "Alice Doe-MyVault", - ); -}); - -test("isLegacyFolderBasename matches sanitized vault names", () => { - assert.equal(isLegacyFolderBasename("My Vault", "My Vault"), true); - assert.equal(isLegacyFolderBasename("Alice-My Vault", "My Vault"), false); -}); - -test("isExpectedMigratedBasename matches prefixed folder names", () => { - assert.equal( - isExpectedMigratedBasename("Alice-My Vault", "Alice", "My Vault"), - true, - ); - assert.equal( - isExpectedMigratedBasename("My Vault", "Alice", "My Vault"), - false, - ); -}); - -test("shouldAutoRenameFolder only renames legacy unmigrated folders with username", () => { - assert.equal( - shouldAutoRenameFolder({ - metadata: {}, - basename: "My Vault", - spaceName: "My Vault", - userName: "Alice", - }), - true, - ); - - assert.equal( - shouldAutoRenameFolder({ - metadata: { migrated: true }, - basename: "My Vault", - spaceName: "My Vault", - userName: "Alice", - }), - false, - ); - - assert.equal( - shouldAutoRenameFolder({ - metadata: {}, - basename: "Custom Name", - spaceName: "My Vault", - userName: "Alice", - }), - false, - ); - - assert.equal( - shouldAutoRenameFolder({ - metadata: {}, - basename: "My Vault", - spaceName: "My Vault", - }), - false, - ); -}); - -test("isCustomFolderBasename treats unexpected names as custom", () => { - assert.equal( - isCustomFolderBasename({ - basename: "Renamed By User", - spaceName: "My Vault", - userName: "Alice", - }), - true, - ); - - assert.equal( - isCustomFolderBasename({ - basename: buildImportFolderBasename("Alice", "My Vault"), - spaceName: "My Vault", - userName: "Alice", - }), - false, - ); -}); - -test("isLegacyCollisionFolderBasename matches old collision suffix folders", () => { - assert.equal( - isLegacyCollisionFolderBasename("My Vault-abc123", "My Vault"), - true, - ); - assert.equal( - isLegacyCollisionFolderBasename("Alice-My Vault", "My Vault"), - false, - ); -}); - -test("shouldAutoRenameFolder treats collision-suffixed legacy folders as eligible", () => { - assert.equal( - shouldAutoRenameFolder({ - metadata: {}, - basename: "My Vault-abc123", - spaceName: "My Vault", - userName: "Alice", - }), - true, - ); -}); - -test("isCustomFolderBasename does not treat collision-suffixed legacy folders as custom", () => { - assert.equal( - isCustomFolderBasename({ - basename: "My Vault-abc123", - spaceName: "My Vault", - userName: "Alice", - }), - false, - ); -}); - -test("sanitizeImportFolderName trims and collapses whitespace", () => { - assert.equal(sanitizeImportFolderName(" Alice Doe "), "Alice Doe"); -}); From aaf0cdf73730eaf425a947cc08ce98e22e0ff444 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 18 Jun 2026 15:14:57 -0400 Subject: [PATCH 04/10] cleanup --- .../src/utils/importFolderMetadata.ts | 197 ++++++------------ apps/obsidian/src/utils/importFolderNaming.ts | 50 +---- 2 files changed, 74 insertions(+), 173 deletions(-) diff --git a/apps/obsidian/src/utils/importFolderMetadata.ts b/apps/obsidian/src/utils/importFolderMetadata.ts index 04d3d4fbe..a530c5b31 100644 --- a/apps/obsidian/src/utils/importFolderMetadata.ts +++ b/apps/obsidian/src/utils/importFolderMetadata.ts @@ -3,19 +3,9 @@ import type DiscourseGraphPlugin from "~/index"; import type { ImportFolderMetadata } from "~/types"; import { buildImportFolderBasename, - isCustomFolderBasename, - isExpectedMigratedBasename, + isUserRenamedFolderBasename, + needsLegacyFolderRename, sanitizeImportFolderName, - shouldAutoRenameFolder, -} from "./importFolderNaming"; - -export { - buildImportFolderBasename, - isCustomFolderBasename, - isExpectedMigratedBasename, - isLegacyFolderBasename, - sanitizeImportFolderName, - shouldAutoRenameFolder, } from "./importFolderNaming"; const DG_METADATA_FILE = ".dg.metadata"; @@ -23,6 +13,9 @@ const IMPORT_ROOT = "import"; const generateShortId = (): string => Math.random().toString(36).slice(2, 8); +const getImportFolderBasename = (folderPath: string): string => + folderPath.split("/").pop() ?? ""; + const readImportFolderMetadata = async ( adapter: DataAdapter, folderPath: string, @@ -143,7 +136,7 @@ const resolveUniqueImportFolderPath = async ({ return path; }; -export const renameImportFolder = async ({ +const renameImportFolder = async ({ app, adapter, oldPath, @@ -165,7 +158,7 @@ export const renameImportFolder = async ({ await adapter.rename(oldPath, newPath); }; -const maybeRenameImportFolder = async ({ +const reconcileImportFolderForSpace = async ({ app, adapter, folderPath, @@ -173,16 +166,18 @@ const maybeRenameImportFolder = async ({ spaceUri, spaceName, ownerUserName, + warnIfUserNameMissing = false, }: { - app?: App; + app: App; adapter: DataAdapter; folderPath: string; metadata: ImportFolderMetadata; spaceUri: string; spaceName: string; ownerUserName?: string; + warnIfUserNameMissing?: boolean; }): Promise => { - const basename = folderPath.split("/").pop() ?? ""; + const basename = getImportFolderBasename(folderPath); const userName = ownerUserName ?? metadata.userName; const updatedMetadata: ImportFolderMetadata = { ...metadata, @@ -201,16 +196,13 @@ const maybeRenameImportFolder = async ({ return folderPath; } - if (userName && isExpectedMigratedBasename(basename, userName, spaceName)) { - await writeImportFolderMetadata({ - adapter, - folderPath, - metadata: { ...updatedMetadata, migrated: true }, - }); - return folderPath; - } + const alreadyPrefixed = + !!userName && basename === buildImportFolderBasename(userName, spaceName); - if (isCustomFolderBasename({ basename, spaceName, userName })) { + if ( + alreadyPrefixed || + isUserRenamedFolderBasename(basename, spaceName, userName) + ) { await writeImportFolderMetadata({ adapter, folderPath, @@ -219,8 +211,12 @@ const maybeRenameImportFolder = async ({ return folderPath; } - if (!shouldAutoRenameFolder({ metadata, basename, spaceName, userName })) { - if (updatedMetadata.spaceName !== metadata.spaceName) { + if (!needsLegacyFolderRename({ metadata, basename, spaceName, userName })) { + if (warnIfUserNameMissing && !userName) { + console.warn( + `Discourse Graphs: skipping import folder rename for "${folderPath}" — owner username unknown.`, + ); + } else if (updatedMetadata.spaceName !== metadata.spaceName) { await writeImportFolderMetadata({ adapter, folderPath, @@ -230,19 +226,14 @@ const maybeRenameImportFolder = async ({ return folderPath; } - const newBasename = buildImportFolderBasename(userName!, spaceName); const newPath = await resolveUniqueImportFolderPath({ adapter, - desiredBasename: newBasename, + desiredBasename: buildImportFolderBasename(userName!, spaceName), spaceUri, }); if (newPath !== folderPath) { - if (app) { - await renameImportFolder({ app, adapter, oldPath: folderPath, newPath }); - } else { - await adapter.rename(folderPath, newPath); - } + await renameImportFolder({ app, adapter, oldPath: folderPath, newPath }); } await writeImportFolderMetadata({ @@ -262,14 +253,13 @@ export const resolveFolderForSpaceUri = async ({ ownerUserName, }: { adapter: DataAdapter; - app?: App; + app: App; spaceUri: string; spaceName: string; ownerUserName?: string; }): Promise => { const spaceUriToFolder = await buildSpaceUriToFolderMap(adapter); - // 1. Exact spaceUri match if (spaceUriToFolder.has(spaceUri)) { const folderPath = spaceUriToFolder.get(spaceUri)!; const existingMetadata = await readImportFolderMetadata( @@ -277,7 +267,7 @@ export const resolveFolderForSpaceUri = async ({ folderPath, ); if (existingMetadata) { - return maybeRenameImportFolder({ + return reconcileImportFolderForSpace({ app, adapter, folderPath, @@ -290,49 +280,41 @@ export const resolveFolderForSpaceUri = async ({ 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 = sanitizeImportFolderName(spaceName); + const sanitizedSpaceName = sanitizeImportFolderName(spaceName); for (const folderPath of folders) { - const basename = folderPath.split("/").pop(); - if (basename === sanitized) { - const existingMetadata = await readImportFolderMetadata( - adapter, - folderPath, - ); - if (!existingMetadata) { - const metadata: ImportFolderMetadata = { - spaceUri, - spaceName, - ...(ownerUserName ? { userName: ownerUserName } : {}), - }; - await writeImportFolderMetadata({ - adapter, - folderPath, - metadata, - }); - return maybeRenameImportFolder({ - app, - adapter, - folderPath, - metadata, - spaceUri, - spaceName, - ownerUserName, - }); - } - } + if (getImportFolderBasename(folderPath) !== sanitizedSpaceName) continue; + + const existingMetadata = await readImportFolderMetadata( + adapter, + folderPath, + ); + if (existingMetadata) continue; + + const metadata: ImportFolderMetadata = { + spaceUri, + spaceName, + ...(ownerUserName ? { userName: ownerUserName } : {}), + }; + await writeImportFolderMetadata({ adapter, folderPath, metadata }); + return reconcileImportFolderForSpace({ + app, + adapter, + folderPath, + metadata, + spaceUri, + spaceName, + ownerUserName, + }); } - // 3. Create a new folder, handling name collisions const desiredBasename = ownerUserName ? buildImportFolderBasename(ownerUserName, spaceName) - : sanitized; + : sanitizedSpaceName; const newPath = await resolveUniqueImportFolderPath({ adapter, desiredBasename, @@ -363,9 +345,8 @@ const resolveUserNameFromFolder = ( .filter((file) => file.path.startsWith(`${folderPath}/`)); for (const file of files) { - const frontmatter = plugin.app.metadataCache.getFileCache(file) - ?.frontmatter as Record | undefined; - const authorId = frontmatter?.authorId; + const authorId = plugin.app.metadataCache.getFileCache(file)?.frontmatter + ?.authorId as number | undefined; if (typeof authorId === "number" && userNames[authorId]) { return userNames[authorId]; } @@ -386,72 +367,20 @@ export const migrateImportFolderNames = async ( for (const folderPath of folders) { const metadata = await readImportFolderMetadata(adapter, folderPath); - if (!metadata) continue; + if (!metadata || metadata.migrated) continue; - const basename = folderPath.split("/").pop() ?? ""; const userName = metadata.userName ?? resolveUserNameFromFolder(plugin, folderPath); - if (metadata.migrated) continue; - - if ( - isCustomFolderBasename({ - basename, - spaceName: metadata.spaceName, - userName, - }) - ) { - await writeImportFolderMetadata({ - adapter, - folderPath, - metadata: { ...metadata, migrated: true }, - }); - continue; - } - - if ( - !shouldAutoRenameFolder({ - metadata, - basename, - spaceName: metadata.spaceName, - userName, - }) - ) { - if (!userName) { - console.warn( - `Discourse Graphs: skipping import folder rename for "${folderPath}" — owner username unknown.`, - ); - } - continue; - } - - const newBasename = buildImportFolderBasename( - userName!, - metadata.spaceName, - ); - const newPath = await resolveUniqueImportFolderPath({ + await reconcileImportFolderForSpace({ + app: plugin.app, adapter, - desiredBasename: newBasename, + folderPath, + metadata, spaceUri: metadata.spaceUri, - }); - - if (newPath !== folderPath) { - await renameImportFolder({ - app: plugin.app, - adapter, - oldPath: folderPath, - newPath, - }); - } - - await writeImportFolderMetadata({ - adapter, - folderPath: newPath, - metadata: { - ...metadata, - userName: userName!, - migrated: true, - }, + spaceName: metadata.spaceName, + ownerUserName: userName, + warnIfUserNameMissing: true, }); } }; @@ -489,7 +418,7 @@ export const migrateImportFolderMetadata = async ( const metadataExists = await adapter.exists(metadataPath); if (metadataExists) continue; - const basename = folderPath.split("/").pop() ?? ""; + const basename = getImportFolderBasename(folderPath); const spaceUris = nameToSpaceUris.get(basename); if (spaceUris?.size === 1) { diff --git a/apps/obsidian/src/utils/importFolderNaming.ts b/apps/obsidian/src/utils/importFolderNaming.ts index 5893a9a5c..f2537e42d 100644 --- a/apps/obsidian/src/utils/importFolderNaming.ts +++ b/apps/obsidian/src/utils/importFolderNaming.ts @@ -12,60 +12,32 @@ export const buildImportFolderBasename = ( return sanitizeImportFolderName(`${userName}-${spaceName}`); }; -export const isLegacyFolderBasename = ( - basename: string, - spaceName: string, -): boolean => { - return basename === sanitizeImportFolderName(spaceName); -}; - -/** Matches old collision folders: `{spaceName}-{6-char id}`. */ -export const isLegacyCollisionFolderBasename = ( +/** Pre-migration names: `{spaceName}` or old collision `{spaceName}-{6-char id}`. */ +export const isLegacyImportFolderBasename = ( basename: string, spaceName: string, ): boolean => { const sanitized = sanitizeImportFolderName(spaceName); + if (basename === sanitized) return true; + const prefix = `${sanitized}-`; if (!basename.startsWith(prefix)) return false; - const suffix = basename.slice(prefix.length); - return /^[a-z0-9]{6}$/.test(suffix); -}; - -export const isLegacyOrCollisionFolderBasename = ( - basename: string, - spaceName: string, -): boolean => { - return ( - isLegacyFolderBasename(basename, spaceName) || - isLegacyCollisionFolderBasename(basename, spaceName) - ); + return /^[a-z0-9]{6}$/.test(basename.slice(prefix.length)); }; -export const isExpectedMigratedBasename = ( +export const isUserRenamedFolderBasename = ( basename: string, - userName: string, spaceName: string, + userName?: string, ): boolean => { - return basename === buildImportFolderBasename(userName, spaceName); -}; - -export const isCustomFolderBasename = ({ - basename, - spaceName, - userName, -}: { - basename: string; - spaceName: string; - userName?: string; -}): boolean => { - if (isLegacyOrCollisionFolderBasename(basename, spaceName)) return false; - if (userName && isExpectedMigratedBasename(basename, userName, spaceName)) { + if (isLegacyImportFolderBasename(basename, spaceName)) return false; + if (userName && basename === buildImportFolderBasename(userName, spaceName)) { return false; } return true; }; -export const shouldAutoRenameFolder = ({ +export const needsLegacyFolderRename = ({ metadata, basename, spaceName, @@ -77,6 +49,6 @@ export const shouldAutoRenameFolder = ({ userName?: string; }): boolean => { if (metadata.migrated) return false; - if (!isLegacyOrCollisionFolderBasename(basename, spaceName)) return false; + if (!isLegacyImportFolderBasename(basename, spaceName)) return false; return !!userName; }; From 7eb950194b084fc60213b63479cc7b95671b2c9a Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 18 Jun 2026 15:41:01 -0400 Subject: [PATCH 05/10] simplify the flow --- .../src/utils/importFolderMetadata.ts | 150 ++++++------------ apps/obsidian/src/utils/importFolderNaming.ts | 41 ----- 2 files changed, 52 insertions(+), 139 deletions(-) diff --git a/apps/obsidian/src/utils/importFolderMetadata.ts b/apps/obsidian/src/utils/importFolderMetadata.ts index a530c5b31..9e8b81290 100644 --- a/apps/obsidian/src/utils/importFolderMetadata.ts +++ b/apps/obsidian/src/utils/importFolderMetadata.ts @@ -3,8 +3,6 @@ import type DiscourseGraphPlugin from "~/index"; import type { ImportFolderMetadata } from "~/types"; import { buildImportFolderBasename, - isUserRenamedFolderBasename, - needsLegacyFolderRename, sanitizeImportFolderName, } from "./importFolderNaming"; @@ -56,60 +54,26 @@ const writeImportFolderMetadata = async ({ await adapter.write(metadataPath, JSON.stringify(metadata, null, 2)); }; -const resolveMetadataDuplicate = async ({ +const findImportFolderBySpaceUri = async ({ adapter, - existingFolderPath, - newFolderPath, + spaceUri, }: { 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(); - + spaceUri: string; +}): Promise<{ folderPath: string; metadata: ImportFolderMetadata } | null> => { const importExists = await adapter.exists(IMPORT_ROOT); - if (!importExists) return map; + if (!importExists) return null; 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); + if (metadata?.spaceUri === spaceUri) { + return { folderPath, metadata }; } } - return map; + return null; }; const resolveUniqueImportFolderPath = async ({ @@ -177,7 +141,6 @@ const reconcileImportFolderForSpace = async ({ ownerUserName?: string; warnIfUserNameMissing?: boolean; }): Promise => { - const basename = getImportFolderBasename(folderPath); const userName = ownerUserName ?? metadata.userName; const updatedMetadata: ImportFolderMetadata = { ...metadata, @@ -185,64 +148,61 @@ const reconcileImportFolderForSpace = async ({ ...(userName ? { userName } : {}), }; - if (metadata.migrated) { - if (updatedMetadata.spaceName !== metadata.spaceName) { + const writeIfChanged = async (): Promise => { + if ( + updatedMetadata.spaceName !== metadata.spaceName || + updatedMetadata.userName !== metadata.userName + ) { await writeImportFolderMetadata({ adapter, folderPath, metadata: updatedMetadata, }); } - return folderPath; - } - - const alreadyPrefixed = - !!userName && basename === buildImportFolderBasename(userName, spaceName); + }; - if ( - alreadyPrefixed || - isUserRenamedFolderBasename(basename, spaceName, userName) - ) { - await writeImportFolderMetadata({ - adapter, - folderPath, - metadata: { ...updatedMetadata, migrated: true }, - }); + if (metadata.migrated) { + await writeIfChanged(); return folderPath; } - if (!needsLegacyFolderRename({ metadata, basename, spaceName, userName })) { - if (warnIfUserNameMissing && !userName) { + if (!userName) { + if (warnIfUserNameMissing) { console.warn( `Discourse Graphs: skipping import folder rename for "${folderPath}" — owner username unknown.`, ); - } else if (updatedMetadata.spaceName !== metadata.spaceName) { - await writeImportFolderMetadata({ - adapter, - folderPath, - metadata: updatedMetadata, - }); + } else { + await writeIfChanged(); } return folderPath; } - const newPath = await resolveUniqueImportFolderPath({ - adapter, - desiredBasename: buildImportFolderBasename(userName!, spaceName), - spaceUri, - }); + const targetBasename = buildImportFolderBasename(userName, spaceName); + let finalPath = folderPath; - if (newPath !== folderPath) { - await renameImportFolder({ app, adapter, oldPath: folderPath, newPath }); + if (getImportFolderBasename(folderPath) !== targetBasename) { + finalPath = await resolveUniqueImportFolderPath({ + adapter, + desiredBasename: targetBasename, + spaceUri, + }); + if (finalPath !== folderPath) { + await renameImportFolder({ + app, + adapter, + oldPath: folderPath, + newPath: finalPath, + }); + } } await writeImportFolderMetadata({ adapter, - folderPath: newPath, - metadata: { ...updatedMetadata, userName: userName!, migrated: true }, + folderPath: finalPath, + metadata: { ...updatedMetadata, userName, migrated: true }, }); - return newPath; + return finalPath; }; export const resolveFolderForSpaceUri = async ({ @@ -258,26 +218,20 @@ export const resolveFolderForSpaceUri = async ({ spaceName: string; ownerUserName?: string; }): Promise => { - const spaceUriToFolder = await buildSpaceUriToFolderMap(adapter); - - if (spaceUriToFolder.has(spaceUri)) { - const folderPath = spaceUriToFolder.get(spaceUri)!; - const existingMetadata = await readImportFolderMetadata( + const existingFolder = await findImportFolderBySpaceUri({ + adapter, + spaceUri, + }); + if (existingFolder) { + return reconcileImportFolderForSpace({ + app, adapter, - folderPath, - ); - if (existingMetadata) { - return reconcileImportFolderForSpace({ - app, - adapter, - folderPath, - metadata: existingMetadata, - spaceUri, - spaceName, - ownerUserName, - }); - } - return folderPath; + folderPath: existingFolder.folderPath, + metadata: existingFolder.metadata, + spaceUri, + spaceName, + ownerUserName, + }); } const { folders } = (await adapter.exists(IMPORT_ROOT)) diff --git a/apps/obsidian/src/utils/importFolderNaming.ts b/apps/obsidian/src/utils/importFolderNaming.ts index f2537e42d..4ae789af9 100644 --- a/apps/obsidian/src/utils/importFolderNaming.ts +++ b/apps/obsidian/src/utils/importFolderNaming.ts @@ -11,44 +11,3 @@ export const buildImportFolderBasename = ( ): string => { return sanitizeImportFolderName(`${userName}-${spaceName}`); }; - -/** Pre-migration names: `{spaceName}` or old collision `{spaceName}-{6-char id}`. */ -export const isLegacyImportFolderBasename = ( - basename: string, - spaceName: string, -): boolean => { - const sanitized = sanitizeImportFolderName(spaceName); - if (basename === sanitized) return true; - - const prefix = `${sanitized}-`; - if (!basename.startsWith(prefix)) return false; - return /^[a-z0-9]{6}$/.test(basename.slice(prefix.length)); -}; - -export const isUserRenamedFolderBasename = ( - basename: string, - spaceName: string, - userName?: string, -): boolean => { - if (isLegacyImportFolderBasename(basename, spaceName)) return false; - if (userName && basename === buildImportFolderBasename(userName, spaceName)) { - return false; - } - return true; -}; - -export const needsLegacyFolderRename = ({ - metadata, - basename, - spaceName, - userName, -}: { - metadata: { migrated?: boolean }; - basename: string; - spaceName: string; - userName?: string; -}): boolean => { - if (metadata.migrated) return false; - if (!isLegacyImportFolderBasename(basename, spaceName)) return false; - return !!userName; -}; From 159aaa884de3c2bfac820b4c92a96e56e40761fc Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sat, 20 Jun 2026 22:17:18 -0400 Subject: [PATCH 06/10] bug fix --- .../src/utils/importFolderMetadata.ts | 107 ++++++++++++++---- apps/obsidian/src/utils/importFolderNaming.ts | 13 --- 2 files changed, 88 insertions(+), 32 deletions(-) delete mode 100644 apps/obsidian/src/utils/importFolderNaming.ts diff --git a/apps/obsidian/src/utils/importFolderMetadata.ts b/apps/obsidian/src/utils/importFolderMetadata.ts index 9e8b81290..616e895c9 100644 --- a/apps/obsidian/src/utils/importFolderMetadata.ts +++ b/apps/obsidian/src/utils/importFolderMetadata.ts @@ -1,19 +1,57 @@ import { App, DataAdapter, Notice, TFolder } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; import type { ImportFolderMetadata } from "~/types"; -import { - buildImportFolderBasename, - sanitizeImportFolderName, -} from "./importFolderNaming"; const DG_METADATA_FILE = ".dg.metadata"; const IMPORT_ROOT = "import"; +const sanitizeImportFolderName = (fileName: string): string => { + return fileName + .replace(/[<>:"/\\|?*]/g, "") + .replace(/\s+/g, " ") + .trim(); +}; + +const buildImportFolderBasename = ( + userName: string, + spaceName: string, +): string => { + return sanitizeImportFolderName(`${userName}-${spaceName}`); +}; + const generateShortId = (): string => Math.random().toString(36).slice(2, 8); const getImportFolderBasename = (folderPath: string): string => folderPath.split("/").pop() ?? ""; +const parseImportFolderMetadataRaw = ( + raw: string, +): ImportFolderMetadata | null => { + const tryParse = (content: string): unknown => JSON.parse(content); + + let parsed: unknown; + try { + parsed = tryParse(raw); + } catch { + try { + parsed = tryParse(raw.replace(/,\s*([\]}])/g, "$1")); + } catch { + return null; + } + } + + if ( + parsed !== null && + typeof parsed === "object" && + "spaceUri" in parsed && + typeof (parsed as Record).spaceUri === "string" + ) { + return parsed as ImportFolderMetadata; + } + + return null; +}; + const readImportFolderMetadata = async ( adapter: DataAdapter, folderPath: string, @@ -24,18 +62,7 @@ const readImportFolderMetadata = async ( 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; + return parseImportFolderMetadataRaw(raw); } catch { return null; } @@ -54,6 +81,32 @@ const writeImportFolderMetadata = async ({ 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 findImportFolderBySpaceUri = async ({ adapter, spaceUri, @@ -66,14 +119,30 @@ const findImportFolderBySpaceUri = async ({ const { folders } = await adapter.list(IMPORT_ROOT); + let keptFolderPath: string | null = null; + for (const folderPath of folders) { const metadata = await readImportFolderMetadata(adapter, folderPath); - if (metadata?.spaceUri === spaceUri) { - return { folderPath, metadata }; + if (metadata?.spaceUri !== spaceUri) continue; + + if (keptFolderPath === null) { + keptFolderPath = folderPath; + continue; } + + keptFolderPath = await resolveMetadataDuplicate({ + adapter, + existingFolderPath: keptFolderPath, + newFolderPath: folderPath, + }); } - return null; + if (!keptFolderPath) return null; + + const metadata = await readImportFolderMetadata(adapter, keptFolderPath); + if (!metadata) return null; + + return { folderPath: keptFolderPath, metadata }; }; const resolveUniqueImportFolderPath = async ({ diff --git a/apps/obsidian/src/utils/importFolderNaming.ts b/apps/obsidian/src/utils/importFolderNaming.ts deleted file mode 100644 index 4ae789af9..000000000 --- a/apps/obsidian/src/utils/importFolderNaming.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const sanitizeImportFolderName = (fileName: string): string => { - return fileName - .replace(/[<>:"/\\|?*]/g, "") - .replace(/\s+/g, " ") - .trim(); -}; - -export const buildImportFolderBasename = ( - userName: string, - spaceName: string, -): string => { - return sanitizeImportFolderName(`${userName}-${spaceName}`); -}; From 72bf26dc33e2cfae804b4b22895f1ba44d11ccb2 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 22 Jun 2026 08:43:44 -0400 Subject: [PATCH 07/10] remove auto-rename migration --- apps/obsidian/src/index.ts | 10 +- apps/obsidian/src/types.ts | 2 - .../src/utils/importFolderMetadata.ts | 197 ++---------------- apps/obsidian/src/utils/importNodes.ts | 1 - 4 files changed, 18 insertions(+), 192 deletions(-) diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 0e2849476..13cd2ad38 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -37,10 +37,7 @@ import { migrateFrontmatterRelationsToRelationsJson, mergeAllRelationsJsonToRoot, } from "~/utils/relationsStore"; -import { - migrateImportFolderMetadata, - migrateImportFolderNames, -} from "./utils/importFolderMetadata"; +import { migrateImportFolderMetadata } from "./utils/importFolderMetadata"; import { registerTemplateSettingsSync } from "~/utils/templateSettingsSync"; import { fetchUserNames } from "~/utils/importNodes"; import { getLoggedInClient } from "~/utils/supabaseContext"; @@ -68,10 +65,6 @@ export default class DiscourseGraphPlugin extends Plugin { console.error("Failed to migrate import folder metadata:", error); }); - await migrateImportFolderNames(this).catch((error) => { - console.error("Failed to migrate import folder names:", error); - }); - registerTemplateSettingsSync(this); if (this.settings.syncModeEnabled === true) { @@ -81,7 +74,6 @@ export default class DiscourseGraphPlugin extends Plugin { if (client) { await fetchUserNames(this, client); } - await migrateImportFolderNames(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 1fb4a3907..f7bc3fc41 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -115,8 +115,6 @@ export type ImportFolderMetadata = { spaceUri: string; spaceName: string; userName?: string; - /** Set after auto-rename or when the user has a custom folder name. */ - migrated?: boolean; }; 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 index 616e895c9..78fac1589 100644 --- a/apps/obsidian/src/utils/importFolderMetadata.ts +++ b/apps/obsidian/src/utils/importFolderMetadata.ts @@ -1,4 +1,4 @@ -import { App, DataAdapter, Notice, TFolder } from "obsidian"; +import { DataAdapter, Notice } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; import type { ImportFolderMetadata } from "~/types"; @@ -169,120 +169,13 @@ const resolveUniqueImportFolderPath = async ({ return path; }; -const renameImportFolder = async ({ - app, - adapter, - oldPath, - newPath, -}: { - app: App; - adapter: DataAdapter; - oldPath: string; - newPath: string; -}): Promise => { - if (oldPath === newPath) return; - - const folder = app.vault.getAbstractFileByPath(oldPath); - if (folder instanceof TFolder) { - await app.fileManager.renameFile(folder, newPath); - return; - } - - await adapter.rename(oldPath, newPath); -}; - -const reconcileImportFolderForSpace = async ({ - app, - adapter, - folderPath, - metadata, - spaceUri, - spaceName, - ownerUserName, - warnIfUserNameMissing = false, -}: { - app: App; - adapter: DataAdapter; - folderPath: string; - metadata: ImportFolderMetadata; - spaceUri: string; - spaceName: string; - ownerUserName?: string; - warnIfUserNameMissing?: boolean; -}): Promise => { - const userName = ownerUserName ?? metadata.userName; - const updatedMetadata: ImportFolderMetadata = { - ...metadata, - spaceName, - ...(userName ? { userName } : {}), - }; - - const writeIfChanged = async (): Promise => { - if ( - updatedMetadata.spaceName !== metadata.spaceName || - updatedMetadata.userName !== metadata.userName - ) { - await writeImportFolderMetadata({ - adapter, - folderPath, - metadata: updatedMetadata, - }); - } - }; - - if (metadata.migrated) { - await writeIfChanged(); - return folderPath; - } - - if (!userName) { - if (warnIfUserNameMissing) { - console.warn( - `Discourse Graphs: skipping import folder rename for "${folderPath}" — owner username unknown.`, - ); - } else { - await writeIfChanged(); - } - return folderPath; - } - - const targetBasename = buildImportFolderBasename(userName, spaceName); - let finalPath = folderPath; - - if (getImportFolderBasename(folderPath) !== targetBasename) { - finalPath = await resolveUniqueImportFolderPath({ - adapter, - desiredBasename: targetBasename, - spaceUri, - }); - if (finalPath !== folderPath) { - await renameImportFolder({ - app, - adapter, - oldPath: folderPath, - newPath: finalPath, - }); - } - } - - await writeImportFolderMetadata({ - adapter, - folderPath: finalPath, - metadata: { ...updatedMetadata, userName, migrated: true }, - }); - - return finalPath; -}; - export const resolveFolderForSpaceUri = async ({ adapter, - app, spaceUri, spaceName, ownerUserName, }: { adapter: DataAdapter; - app: App; spaceUri: string; spaceName: string; ownerUserName?: string; @@ -292,15 +185,14 @@ export const resolveFolderForSpaceUri = async ({ spaceUri, }); if (existingFolder) { - return reconcileImportFolderForSpace({ - app, - adapter, - folderPath: existingFolder.folderPath, - metadata: existingFolder.metadata, - spaceUri, - spaceName, - ownerUserName, - }); + if (existingFolder.metadata.spaceName !== spaceName) { + await writeImportFolderMetadata({ + adapter, + folderPath: existingFolder.folderPath, + metadata: { ...existingFolder.metadata, spaceName }, + }); + } + return existingFolder.folderPath; } const { folders } = (await adapter.exists(IMPORT_ROOT)) @@ -318,21 +210,16 @@ export const resolveFolderForSpaceUri = async ({ ); if (existingMetadata) continue; - const metadata: ImportFolderMetadata = { - spaceUri, - spaceName, - ...(ownerUserName ? { userName: ownerUserName } : {}), - }; - await writeImportFolderMetadata({ adapter, folderPath, metadata }); - return reconcileImportFolderForSpace({ - app, + await writeImportFolderMetadata({ adapter, folderPath, - metadata, - spaceUri, - spaceName, - ownerUserName, + metadata: { + spaceUri, + spaceName, + ...(ownerUserName ? { userName: ownerUserName } : {}), + }, }); + return folderPath; } const desiredBasename = ownerUserName @@ -351,63 +238,13 @@ export const resolveFolderForSpaceUri = async ({ metadata: { spaceUri, spaceName, - ...(ownerUserName ? { userName: ownerUserName, migrated: true } : {}), + ...(ownerUserName ? { userName: ownerUserName } : {}), }, }); return newPath; }; -const resolveUserNameFromFolder = ( - plugin: DiscourseGraphPlugin, - folderPath: string, -): string | undefined => { - const userNames = plugin.settings.userNames ?? {}; - const files = plugin.app.vault - .getMarkdownFiles() - .filter((file) => file.path.startsWith(`${folderPath}/`)); - - for (const file of files) { - const authorId = plugin.app.metadataCache.getFileCache(file)?.frontmatter - ?.authorId as number | undefined; - if (typeof authorId === "number" && userNames[authorId]) { - return userNames[authorId]; - } - } - - return undefined; -}; - -export const migrateImportFolderNames = 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); - - for (const folderPath of folders) { - const metadata = await readImportFolderMetadata(adapter, folderPath); - if (!metadata || metadata.migrated) continue; - - const userName = - metadata.userName ?? resolveUserNameFromFolder(plugin, folderPath); - - await reconcileImportFolderForSpace({ - app: plugin.app, - adapter, - folderPath, - metadata, - spaceUri: metadata.spaceUri, - spaceName: metadata.spaceName, - ownerUserName: userName, - warnIfUserNameMissing: true, - }); - } -}; - export const migrateImportFolderMetadata = async ( plugin: DiscourseGraphPlugin, ): Promise => { diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index 7916204a2..3fc1ebff7 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -1374,7 +1374,6 @@ export const importSelectedNodes = async ({ const ownerUserName = resolveOwnerUserName(nodes, plugin); const importFolderPath = await resolveFolderForSpaceUri({ adapter: plugin.app.vault.adapter, - app: plugin.app, spaceUri, spaceName, ownerUserName, From 0803a251f6887cd4bdf3d9ef2fbc6b871f739a7a Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 22 Jun 2026 08:49:41 -0400 Subject: [PATCH 08/10] address review: type guard, inline JSON.parse, explain trailing-comma retry - Replace as-cast with isImportFolderMetadata type guard that validates both spaceUri and spaceName - Inline tryParse wrapper; no behaviour change - Add comment explaining why trailing-comma retry exists Co-authored-by: Cursor --- .../src/utils/importFolderMetadata.ts | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/apps/obsidian/src/utils/importFolderMetadata.ts b/apps/obsidian/src/utils/importFolderMetadata.ts index 78fac1589..e4644b0f3 100644 --- a/apps/obsidian/src/utils/importFolderMetadata.ts +++ b/apps/obsidian/src/utils/importFolderMetadata.ts @@ -24,32 +24,30 @@ const generateShortId = (): string => Math.random().toString(36).slice(2, 8); const getImportFolderBasename = (folderPath: string): string => folderPath.split("/").pop() ?? ""; +const isImportFolderMetadata = ( + value: unknown, +): value is ImportFolderMetadata => { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return typeof v.spaceUri === "string" && typeof v.spaceName === "string"; +}; + const parseImportFolderMetadataRaw = ( raw: string, ): ImportFolderMetadata | null => { - const tryParse = (content: string): unknown => JSON.parse(content); - let parsed: unknown; try { - parsed = tryParse(raw); + parsed = JSON.parse(raw); } catch { try { - parsed = tryParse(raw.replace(/,\s*([\]}])/g, "$1")); + // Tolerate trailing commas in case the file was hand-edited outside the plugin. + parsed = JSON.parse(raw.replace(/,\s*([\]}])/g, "$1")); } catch { return null; } } - if ( - parsed !== null && - typeof parsed === "object" && - "spaceUri" in parsed && - typeof (parsed as Record).spaceUri === "string" - ) { - return parsed as ImportFolderMetadata; - } - - return null; + return isImportFolderMetadata(parsed) ? parsed : null; }; const readImportFolderMetadata = async ( From 3ea55630cb059639b7886b7756ae86783ba4d182 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 22 Jun 2026 08:58:20 -0400 Subject: [PATCH 09/10] replace heuristic author resolution with Supabase space membership query Remove resolveOwnerUserName (which picked the most-frequent node author) in favour of getSpaceOwnerNames, which queries LocalAccess joined with my_accounts to get the actual person account for each space. Also remove the on-load fetchUserNames call that was only needed for the migration flow. Co-authored-by: Cursor --- apps/obsidian/src/index.ts | 23 +++----- apps/obsidian/src/utils/importNodes.ts | 80 +++++++++++++++----------- 2 files changed, 53 insertions(+), 50 deletions(-) diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index 13cd2ad38..bd707c289 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -39,8 +39,6 @@ import { } from "~/utils/relationsStore"; import { migrateImportFolderMetadata } from "./utils/importFolderMetadata"; import { registerTemplateSettingsSync } from "~/utils/templateSettingsSync"; -import { fetchUserNames } from "~/utils/importNodes"; -import { getLoggedInClient } from "~/utils/supabaseContext"; export default class DiscourseGraphPlugin extends Plugin { settings: Settings = { ...DEFAULT_SETTINGS }; @@ -68,20 +66,13 @@ export default class DiscourseGraphPlugin extends Plugin { registerTemplateSettingsSync(this); if (this.settings.syncModeEnabled === true) { - void initializeSupabaseSync(this) - .then(async () => { - const client = await getLoggedInClient(this); - if (client) { - await fetchUserNames(this, client); - } - }) - .catch((error) => { - console.error("Failed to initialize Supabase sync:", error); - new Notice( - `Failed to initialize Supabase sync: ${error instanceof Error ? error.message : String(error)}`, - 5000, - ); - }); + void initializeSupabaseSync(this).catch((error) => { + console.error("Failed to initialize Supabase sync:", error); + new Notice( + `Failed to initialize Supabase sync: ${error instanceof Error ? error.message : String(error)}`, + 5000, + ); + }); try { this.fileChangeListener = new FileChangeListener(this); diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index 3fc1ebff7..9a99313ee 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -281,32 +281,49 @@ export const fetchUserNames = async ( await plugin.saveSettings(); }; -export const resolveOwnerUserName = ( - nodes: ImportableNode[], - plugin: DiscourseGraphPlugin, -): string | undefined => { - const authorCounts = new Map(); - for (const node of nodes) { - if (node.authorId !== undefined) { - authorCounts.set( - node.authorId, - (authorCounts.get(node.authorId) ?? 0) + 1, - ); - } +export const getSpaceOwnerNames = async ( + client: DGSupabaseClient, + spaceIds: number[], +): Promise> => { + if (spaceIds.length === 0) return new Map(); + + const { data: localAccess, error: laError } = await client + .from("LocalAccess") + .select("space_id, account_id") + .in("space_id", spaceIds); + + if (laError || !localAccess) { + console.error("Error fetching space local access:", laError); + return new Map(); } - if (authorCounts.size === 0) return undefined; - - let topAuthorId = 0; - let topCount = 0; - for (const [authorId, count] of authorCounts) { - if (count > topCount) { - topCount = count; - topAuthorId = authorId; - } + + const accountIds = [...new Set(localAccess.map((r) => r.account_id))]; + + const { data: accounts, error: accError } = await client + .from("my_accounts") + .select("id, name") + .in("id", accountIds) + .eq("agent_type", "person"); + + if (accError || !accounts) { + console.error("Error fetching space owner names:", accError); + return new Map(); } - const userNames = plugin.settings.userNames ?? {}; - return userNames[topAuthorId]; + const nameById = new Map( + accounts + .filter((a): a is typeof a & { name: string } => a.name !== null) + .map((a) => [a.id, a.name]), + ); + + const result = new Map(); + for (const { space_id, account_id } of localAccess) { + if (!result.has(space_id)) { + const name = nameById.get(account_id); + if (name) result.set(space_id, name); + } + } + return result; }; export const fetchNodeContent = async ({ @@ -1332,13 +1349,6 @@ export const importSelectedNodes = async ({ const queryEngine = new QueryEngine(plugin.app); - if ( - !plugin.settings.userNames || - Object.keys(plugin.settings.userNames).length === 0 - ) { - await fetchUserNames(plugin, client); - } - let successCount = 0; let failedCount = 0; let processedCount = 0; @@ -1353,9 +1363,11 @@ export const importSelectedNodes = async ({ nodesBySpace.get(node.spaceId)!.push(node); } - const spaceUris = await getSpaceUris(client, [...nodesBySpace.keys()]); - const spaceNames = await getSpaceNameFromIds(client, [ - ...nodesBySpace.keys(), + const spaceIdList = [...nodesBySpace.keys()]; + const [spaceUris, spaceNames, spaceOwnerNames] = await Promise.all([ + getSpaceUris(client, spaceIdList), + getSpaceNameFromIds(client, spaceIdList), + getSpaceOwnerNames(client, spaceIdList), ]); // Process each space @@ -1371,7 +1383,7 @@ export const importSelectedNodes = async ({ } const spaceName = spaceNames.get(spaceId) ?? `space-${spaceId}`; - const ownerUserName = resolveOwnerUserName(nodes, plugin); + const ownerUserName = spaceOwnerNames.get(spaceId); const importFolderPath = await resolveFolderForSpaceUri({ adapter: plugin.app.vault.adapter, spaceUri, From 598da9cc3a183177651cbe9af95fa5251b7d78ec Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 22 Jun 2026 09:03:06 -0400 Subject: [PATCH 10/10] simplify owner name resolution: reuse fetchUserNames + authorId lookup Replace two-step LocalAccess/my_accounts query with fetchUserNames (already called pre-import in the modal) so plugin.settings.userNames is warm, then resolve the owner name via the first node authorId for the space. Co-authored-by: Cursor --- apps/obsidian/src/utils/importNodes.ts | 57 +++++--------------------- 1 file changed, 11 insertions(+), 46 deletions(-) diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index 9a99313ee..610dd3209 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -281,49 +281,14 @@ export const fetchUserNames = async ( await plugin.saveSettings(); }; -export const getSpaceOwnerNames = async ( - client: DGSupabaseClient, - spaceIds: number[], -): Promise> => { - if (spaceIds.length === 0) return new Map(); - - const { data: localAccess, error: laError } = await client - .from("LocalAccess") - .select("space_id, account_id") - .in("space_id", spaceIds); - - if (laError || !localAccess) { - console.error("Error fetching space local access:", laError); - return new Map(); - } - - const accountIds = [...new Set(localAccess.map((r) => r.account_id))]; - - const { data: accounts, error: accError } = await client - .from("my_accounts") - .select("id, name") - .in("id", accountIds) - .eq("agent_type", "person"); - - if (accError || !accounts) { - console.error("Error fetching space owner names:", accError); - return new Map(); - } - - const nameById = new Map( - accounts - .filter((a): a is typeof a & { name: string } => a.name !== null) - .map((a) => [a.id, a.name]), - ); - - const result = new Map(); - for (const { space_id, account_id } of localAccess) { - if (!result.has(space_id)) { - const name = nameById.get(account_id); - if (name) result.set(space_id, name); - } - } - return result; +const resolveOwnerUserName = ( + nodes: ImportableNode[], + plugin: DiscourseGraphPlugin, +): string | undefined => { + const authorId = nodes.find((n) => n.authorId !== undefined)?.authorId; + return authorId !== undefined + ? (plugin.settings.userNames ?? {})[authorId] + : undefined; }; export const fetchNodeContent = async ({ @@ -1364,10 +1329,10 @@ export const importSelectedNodes = async ({ } const spaceIdList = [...nodesBySpace.keys()]; - const [spaceUris, spaceNames, spaceOwnerNames] = await Promise.all([ + const [spaceUris, spaceNames] = await Promise.all([ getSpaceUris(client, spaceIdList), getSpaceNameFromIds(client, spaceIdList), - getSpaceOwnerNames(client, spaceIdList), + fetchUserNames(plugin, client), ]); // Process each space @@ -1383,7 +1348,7 @@ export const importSelectedNodes = async ({ } const spaceName = spaceNames.get(spaceId) ?? `space-${spaceId}`; - const ownerUserName = spaceOwnerNames.get(spaceId); + const ownerUserName = resolveOwnerUserName(nodes, plugin); const importFolderPath = await resolveFolderForSpaceUri({ adapter: plugin.app.vault.adapter, spaceUri,