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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 152 additions & 75 deletions apps/obsidian/src/utils/importFolderMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,73 @@ import type DiscourseGraphPlugin from "~/index";
import type { ImportFolderMetadata } from "~/types";

const DG_METADATA_FILE = ".dg.metadata";

/**
* Folder identity is carried by DG_METADATA_FILE, so renaming an individual import
* folder is safe — the next import finds it again by spaceUri.
*
* Known limitation: this root path is fixed and only its immediate children are
* scanned. Renaming or moving the root, or nesting import folders deeper inside it,
* makes existing imports invisible and the next import recreates them here.
*/
const IMPORT_ROOT = "import";

const sanitizeFileName = (fileName: string): string => {
const sanitizeImportFolderName = (fileName: string): string => {
return fileName
.replace(/[<>:"/\\|?*]/g, "")
.replace(/\s+/g, " ")
.trim();
};

/**
* Obsidian falls back to the vault name for the account name, and the vault name is
* also the space name, so the two are identical unless the user set a username in
* settings. Prefixing then produces folders like "my-vault-my-vault", so the owner
* name is only prepended when it actually adds information.
*/
const buildImportFolderBasename = (
userName: string,
spaceName: string,
): string => {
const sanitizedUserName = sanitizeImportFolderName(userName);
const sanitizedSpaceName = sanitizeImportFolderName(spaceName);
if (!sanitizedUserName || sanitizedUserName === sanitizedSpaceName) {
return sanitizedSpaceName;
}
return `${sanitizedUserName}-${sanitizedSpaceName}`;
};

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<string, unknown>;
return typeof v.spaceUri === "string" && typeof v.spaceName === "string";
};

const parseImportFolderMetadataRaw = (
raw: string,
): ImportFolderMetadata | null => {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
try {
// Tolerate trailing commas in case the file was hand-edited outside the plugin.
parsed = JSON.parse(raw.replace(/,\s*([\]}])/g, "$1"));
} catch {
return null;
}
}

return isImportFolderMetadata(parsed) ? parsed : null;
};

const readImportFolderMetadata = async (
adapter: DataAdapter,
folderPath: string,
Expand All @@ -24,18 +80,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<string, unknown>).spaceUri === "string"
) {
return parsed as ImportFolderMetadata;
}

return null;
return parseImportFolderMetadataRaw(raw);
} catch {
return null;
}
Expand Down Expand Up @@ -80,107 +125,139 @@ const resolveMetadataDuplicate = async ({
return existingFolderPath;
};

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

const findImportFolderBySpaceUri = async ({
adapter,
spaceUri,
}: {
adapter: DataAdapter;
spaceUri: string;
}): Promise<{ folderPath: string; metadata: ImportFolderMetadata } | null> => {
const importExists = await adapter.exists(IMPORT_ROOT);
if (!importExists) return map;
if (!importExists) return null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think that's the right call, but flagging that means we're not resilient to the user renaming the folder.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Worth splitting into two cases, because the answer differs:

Renaming an individual import folder is handled. Folder identity is carried by .dg.metadata (spaceUri), not by the basename. findImportFolderBySpaceUri scans every child of import/ and matches on spaceUri, so import/alice-vaultimport/whatever is found again on the next import and reused rather than duplicated. That is the reason the auto-rename migration could be dropped in 72bf26d.

Renaming or moving the import root is not handled — and I think that is the case you are pointing at, since the line is the if (!importExists) return null; guard. IMPORT_ROOT is the fixed string "import" and adapter.list is non-recursive, so renaming the root to imported/, moving it, or nesting import folders one level deeper makes existing imports invisible and the next import recreates them under a fresh import/. Same if .dg.metadata is deleted.

Not fixing that here — making the root configurable or the scan recursive is a bigger change than this ticket — but I recorded it as a known limitation on IMPORT_ROOT in 0f388d8 so it does not have to be rediscovered. Happy to file a follow-up if you think it is worth handling.


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) continue;
if (metadata?.spaceUri !== spaceUri) 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 (keptFolderPath === null) {
keptFolderPath = folderPath;
continue;
}

keptFolderPath = await resolveMetadataDuplicate({
adapter,
existingFolderPath: keptFolderPath,
newFolderPath: folderPath,
});
}

if (!keptFolderPath) return null;

const metadata = await readImportFolderMetadata(adapter, keptFolderPath);
if (!metadata) return null;

return { folderPath: keptFolderPath, metadata };
};

const resolveUniqueImportFolderPath = async ({
adapter,
desiredBasename,
spaceUri,
}: {
adapter: DataAdapter;
desiredBasename: string;
spaceUri: string;
}): Promise<string> => {
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 map;
return path;
};

export const resolveFolderForSpaceUri = async ({
adapter,
spaceUri,
spaceName,
ownerUserName,
}: {
adapter: DataAdapter;
spaceUri: string;
spaceName: string;
ownerUserName?: 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) {
const existingFolder = await findImportFolderBySpaceUri({
adapter,
spaceUri,
});
if (existingFolder) {
if (existingFolder.metadata.spaceName !== spaceName) {
await writeImportFolderMetadata({
adapter,
folderPath,
metadata: { ...existingMetadata, spaceName },
folderPath: existingFolder.folderPath,
metadata: { ...existingFolder.metadata, spaceName },
});
}
return folderPath;
return existingFolder.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);
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) {
await writeImportFolderMetadata({
adapter,
folderPath,
metadata: { spaceUri, spaceName },
});
return folderPath;
}
}
}
if (getImportFolderBasename(folderPath) !== sanitizedSpaceName) continue;

// 3. Create a new folder, handling name collisions
const desiredPath = `${IMPORT_ROOT}/${sanitized}`;
const desiredExists = await adapter.exists(desiredPath);
const existingMetadata = await readImportFolderMetadata(
adapter,
folderPath,
);
if (existingMetadata) continue;

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 writeImportFolderMetadata({
adapter,
folderPath,
metadata: {
spaceUri,
spaceName,
...(ownerUserName ? { userName: ownerUserName } : {}),
},
});
return folderPath;
}

const desiredBasename = ownerUserName
? buildImportFolderBasename(ownerUserName, spaceName)
: sanitizedSpaceName;
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 } : {}),
},
});

return newPath;
Expand All @@ -202,7 +279,7 @@ export const migrateImportFolderMetadata = async (
const spaceNames = plugin.settings.spaceNames ?? {};
const nameToSpaceUris = new Map<string, Set<string>>();
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);
Expand All @@ -219,7 +296,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) {
Expand Down
Loading