From d9c36e4ce766d9f39ae53f5d77ad2c843f0d17b2 Mon Sep 17 00:00:00 2001 From: sid597 Date: Sun, 23 Aug 2026 13:19:31 +0530 Subject: [PATCH 1/3] ENG-2157 Add decorateTitle helper for rebuilding titles from core_title --- .../src/lib/__tests__/decorateTitle.test.ts | 31 +++++++++++++++++++ packages/database/src/lib/decorateTitle.ts | 12 +++++++ 2 files changed, 43 insertions(+) create mode 100644 packages/database/src/lib/__tests__/decorateTitle.test.ts create mode 100644 packages/database/src/lib/decorateTitle.ts diff --git a/packages/database/src/lib/__tests__/decorateTitle.test.ts b/packages/database/src/lib/__tests__/decorateTitle.test.ts new file mode 100644 index 000000000..fef3776cb --- /dev/null +++ b/packages/database/src/lib/__tests__/decorateTitle.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { decorateTitle } from "../decorateTitle"; + +describe("decorateTitle", () => { + it("substitutes the core title for {content}", () => { + expect(decorateTitle("[[CLM]] - {content}", "sleep improves memory")).toBe( + "[[CLM]] - sleep improves memory", + ); + expect(decorateTitle("CLM - {content}", "sleep improves memory")).toBe( + "CLM - sleep improves memory", + ); + }); + + it("matches the content placeholder case-insensitively", () => { + expect(decorateTitle("QUE - {Content}", "why")).toBe("QUE - why"); + }); + + it("substitutes the empty string for other placeholders", () => { + expect( + decorateTitle("[[EVD]] - {content} - {Source}", "REM sleep and recall"), + ).toBe("[[EVD]] - REM sleep and recall - "); + }); + + it("returns the empty string for an empty format", () => { + expect(decorateTitle("", "anything")).toBe(""); + }); + + it("keeps a core title that contains the separator", () => { + expect(decorateTitle("CLM - {content}", "a - b")).toBe("CLM - a - b"); + }); +}); diff --git a/packages/database/src/lib/decorateTitle.ts b/packages/database/src/lib/decorateTitle.ts new file mode 100644 index 000000000..6017fdb02 --- /dev/null +++ b/packages/database/src/lib/decorateTitle.ts @@ -0,0 +1,12 @@ +// Inverse of the apps' extractContentFromTitle: rebuild a local title from a +// node type format and the core_title stored in Concept.literal_content. +// "{content}" takes the core title; every other placeholder (e.g. "{Source}") +// becomes the empty string, so "[[EVD]] - {content} - {Source}" yields +// "[[EVD]] - - ". Callers decide the fallback when the format is +// empty or the core title is missing. +const FORMAT_PLACEHOLDER = /{[a-zA-Z]+}/g; + +export const decorateTitle = (format: string, coreTitle: string): string => + format.replace(FORMAT_PLACEHOLDER, (placeholder) => + placeholder.toLowerCase() === "{content}" ? coreTitle : "", + ); From c2b5f237de46a16b51326f91919b9e08036bd16c Mon Sep 17 00:00:00 2001 From: sid597 Date: Sun, 23 Aug 2026 13:43:42 +0530 Subject: [PATCH 2/3] ENG-2157 Decorate imported node titles in Obsidian from core_title --- apps/obsidian/src/utils/importNodes.ts | 185 ++++++++++++----------- apps/obsidian/src/utils/importPreview.ts | 9 +- 2 files changed, 102 insertions(+), 92 deletions(-) diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index c8dfc391e..4d7880d0f 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -21,6 +21,8 @@ import { } from "./importRelations"; import { createTemplateFile } from "./templates"; import { resolveFolderForSpaceUri } from "./importFolderMetadata"; +import { getNodeTypeById } from "./typeUtils"; +import { decorateTitle } from "@repo/database/lib/decorateTitle"; type PublishedNode = { source_local_id: string; @@ -327,7 +329,12 @@ type NodeTypeSchemaForInstance = { name: string; }; -export const fetchNodeTypeSchemasForInstances = async ({ +type NodeInstanceImportInfo = { + schema?: NodeTypeSchemaForInstance; + coreTitle?: string; +}; + +export const fetchNodeImportInfoForInstances = async ({ client, spaceId, nodeInstanceIds, @@ -335,12 +342,14 @@ export const fetchNodeTypeSchemasForInstances = async ({ client: DGSupabaseClient; spaceId: number; nodeInstanceIds: string[]; -}): Promise> => { - const result = new Map(); +}): Promise> => { + const result = new Map(); const { data: instanceRows, error: instanceError } = await client .from("my_concepts") - .select("source_local_id, schema_id") + .select( + "source_local_id, schema_id, core_title:literal_content->>core_title", + ) .eq("space_id", spaceId) .eq("is_schema", false) .eq("is_relation", false) @@ -358,35 +367,42 @@ export const fetchNodeTypeSchemasForInstances = async ({ .filter((id): id is number => id !== null), ), ]; - if (schemaIds.length === 0) return result; - - const { data: schemaRows, error: schemaError } = await client - .from("my_concepts") - .select("id, source_local_id, name") - .eq("space_id", spaceId) - .eq("is_schema", true) - .eq("is_relation", false) - .in("id", schemaIds); - - if (schemaError || !schemaRows) { - console.error("Error fetching node type schemas:", schemaError); - return result; - } const schemasById = new Map(); - for (const row of schemaRows) { - if (row.id !== null && row.source_local_id !== null && row.name !== null) { - schemasById.set(row.id, { - nodeTypeId: row.source_local_id, - name: row.name, - }); + if (schemaIds.length > 0) { + const { data: schemaRows, error: schemaError } = await client + .from("my_concepts") + .select("id, source_local_id, name") + .eq("space_id", spaceId) + .eq("is_schema", true) + .eq("is_relation", false) + .in("id", schemaIds); + + if (schemaError || !schemaRows) { + console.error("Error fetching node type schemas:", schemaError); + } else { + for (const row of schemaRows) { + if ( + row.id !== null && + row.source_local_id !== null && + row.name !== null + ) { + schemasById.set(row.id, { + nodeTypeId: row.source_local_id, + name: row.name, + }); + } + } } } for (const row of instanceRows) { - if (row.source_local_id === null || row.schema_id === null) continue; - const schema = schemasById.get(row.schema_id); - if (schema) result.set(row.source_local_id, schema); + if (row.source_local_id === null) continue; + result.set(row.source_local_id, { + schema: + row.schema_id === null ? undefined : schemasById.get(row.schema_id), + coreTitle: row.core_title ?? undefined, + }); } return result; @@ -1159,8 +1175,6 @@ export const mapNodeTypeIdToLocal = async ({ const processFileContent = async ({ plugin, - client, - sourceSpaceId, sourceSpaceUri, rawContent, filePath, @@ -1168,11 +1182,9 @@ const processFileContent = async ({ importedModifiedAt, authorId, nodeInstanceId, - nodeTypeIdFromConcept, + nodeTypeId, }: { plugin: DiscourseGraphPlugin; - client: DGSupabaseClient; - sourceSpaceId: number; sourceSpaceUri: string; rawContent: string; filePath: string; @@ -1180,25 +1192,9 @@ const processFileContent = async ({ importedModifiedAt?: number; authorId?: number; nodeInstanceId: string; - nodeTypeIdFromConcept?: string; -}): Promise< - { file: TFile; error?: never } | { file?: never; error: string } -> => { - // 1. Parse frontmatter from rawContent (metadataCache is updated async and is - // often empty immediately after create/modify) and resolve the node type - // before any vault write, so a failed lookup leaves existing files untouched. - const { frontmatter } = parseFrontmatter(rawContent); - const sourceNodeTypeId = - typeof frontmatter.nodeTypeId === "string" - ? frontmatter.nodeTypeId - : nodeTypeIdFromConcept; - if (sourceNodeTypeId === undefined) { - return { - error: "importedNode missing sourceNodeTypeId", - }; - } - - // 2. Create or update the file with the fetched content. + nodeTypeId: string; +}): Promise => { + // Create or update the file with the fetched content. // On create, set file metadata (ctime/mtime) to original vault dates via vault adapter. let file: TFile | null = plugin.app.vault.getFileByPath(filePath); const stat = @@ -1214,19 +1210,11 @@ const processFileContent = async ({ await plugin.app.vault.process(file, () => rawContent, stat); } - const mappedNodeTypeId = await mapNodeTypeIdToLocal({ - plugin, - client, - sourceSpaceId, - sourceSpaceUri, - sourceNodeTypeId, - }); - await plugin.app.fileManager.processFrontMatter( file, (fm) => { const record = fm as Record; - record.nodeTypeId = mappedNodeTypeId; + record.nodeTypeId = nodeTypeId; record.nodeInstanceId = nodeInstanceId; record.importedFromRid = spaceUriAndLocalIdToRid( sourceSpaceUri, @@ -1239,7 +1227,7 @@ const processFileContent = async ({ stat, ); - return { file }; + return file; }; export const importSelectedNodes = async ({ @@ -1308,7 +1296,7 @@ export const importSelectedNodes = async ({ spaceName, }); - const nodeTypeSchemasByInstance = await fetchNodeTypeSchemasForInstances({ + const nodeImportInfoByInstance = await fetchNodeImportInfoForInstances({ client, spaceId, nodeInstanceIds: nodes.map((n) => n.nodeInstanceId), @@ -1355,8 +1343,44 @@ export const importSelectedNodes = async ({ const originalNodePath: string | undefined = contentFilePath ?? node.filePath; - // Sanitize file name - const sanitizedFileName = sanitizeFileName(fileName); + const nodeImportInfo = nodeImportInfoByInstance.get( + node.nodeInstanceId, + ); + + // Parse frontmatter from content (metadataCache is updated async and is + // often empty immediately after create/modify) and resolve the node type + // before any vault write, so a failed lookup leaves existing files untouched. + const { frontmatter } = parseFrontmatter(content); + const sourceNodeTypeId = + typeof frontmatter.nodeTypeId === "string" + ? frontmatter.nodeTypeId + : nodeImportInfo?.schema?.nodeTypeId; + if (sourceNodeTypeId === undefined) { + console.error( + `Error processing file content for node ${node.nodeInstanceId}:`, + "importedNode missing sourceNodeTypeId", + ); + failedCount++; + processedCount++; + onProgress?.(processedCount, totalNodes); + continue; + } + + const mappedNodeTypeId = await mapNodeTypeIdToLocal({ + plugin, + client, + sourceSpaceId: spaceId, + sourceSpaceUri: spaceUri, + sourceNodeTypeId, + }); + + const localNodeType = getNodeTypeById(plugin, mappedNodeTypeId); + const coreTitle = nodeImportInfo?.coreTitle; + const titleForFileName = + coreTitle !== undefined && localNodeType?.format + ? decorateTitle(localNodeType.format, coreTitle) + : fileName; + const sanitizedFileName = sanitizeFileName(titleForFileName); let finalFilePath: string; if (existingFile) { @@ -1364,10 +1388,13 @@ export const importSelectedNodes = async ({ finalFilePath = existingFile.path; } else { // Preserve source vault folder structure under import/{vaultName} when we have filePath from Content - const pathUnderImport = + const sourceFolder = contentFilePath && contentFilePath.includes("/") - ? sanitizePathForImport(contentFilePath) - : `${sanitizedFileName}.md`; + ? sanitizePathForImport(contentFilePath.replace(/\/[^/]*$/, "")) + : ""; + const pathUnderImport = sourceFolder + ? `${sourceFolder}/${sanitizedFileName}.md` + : `${sanitizedFileName}.md`; finalFilePath = `${importFolderPath}/${pathUnderImport}`; // Ensure all parent folders exist (e.g. import/VaultName/Discourse Nodes/SubFolder) @@ -1380,12 +1407,8 @@ export const importSelectedNodes = async ({ } } - // Process the file content (maps nodeTypeId, handles frontmatter, stores import timestamps) - // This updates existing file or creates new one - const result = await processFileContent({ + const processedFile = await processFileContent({ plugin, - client, - sourceSpaceId: spaceId, sourceSpaceUri: spaceUri, rawContent: content, filePath: finalFilePath, @@ -1393,25 +1416,9 @@ export const importSelectedNodes = async ({ importedModifiedAt: modifiedAt, authorId, nodeInstanceId: node.nodeInstanceId, - nodeTypeIdFromConcept: nodeTypeSchemasByInstance.get( - node.nodeInstanceId, - )?.nodeTypeId, + nodeTypeId: mappedNodeTypeId, }); - if (result.error) { - console.error( - `Error processing file content for node ${node.nodeInstanceId}:`, - result.error, - ); - failedCount++; - processedCount++; - onProgress?.(processedCount, totalNodes); - continue; - } - - // typescript should not need this assertion? - const processedFile = result.file!; - // Import assets for this node (use originalNodePath so assets go under import/{space}/ relative to note) const assetImportResult = await importAssetsForNode({ plugin, diff --git a/apps/obsidian/src/utils/importPreview.ts b/apps/obsidian/src/utils/importPreview.ts index 6507b04a1..99c0a913c 100644 --- a/apps/obsidian/src/utils/importPreview.ts +++ b/apps/obsidian/src/utils/importPreview.ts @@ -5,7 +5,7 @@ import { getImportedNodesInfo, getLocalNodeKeyToEndpointId, } from "./relationsStore"; -import { fetchNodeTypeSchemasForInstances, getSpaceUris } from "./importNodes"; +import { fetchNodeImportInfoForInstances, getSpaceUris } from "./importNodes"; import { QueryEngine } from "~/services/QueryEngine"; import { fetchRelationInstancesFromSpace, @@ -82,13 +82,16 @@ export const computeImportPreview = async ({ } for (const [spaceId, nodes] of nodesBySpace.entries()) { - const nodeTypeSchemasByInstance = await fetchNodeTypeSchemasForInstances({ + const nodeImportInfoByInstance = await fetchNodeImportInfoForInstances({ client, spaceId, nodeInstanceIds: nodes.map((n) => n.nodeInstanceId), }); - for (const { nodeTypeId, name } of nodeTypeSchemasByInstance.values()) { + for (const { schema } of nodeImportInfoByInstance.values()) { + if (!schema) continue; + const { nodeTypeId, name } = schema; + // Track name for triplet resolution if (!nodeTypeIdToName.has(nodeTypeId)) { nodeTypeIdToName.set(nodeTypeId, name); From 94ea2deda3e97815a1e81c591f280163d79ac3f6 Mon Sep 17 00:00:00 2001 From: sid597 Date: Sun, 23 Aug 2026 16:20:29 +0530 Subject: [PATCH 3/3] Keep the incoming title when the format has placeholders core_title cannot fill decorateTitle now returns null for formats without a {content} placeholder or with placeholders such as {Source}: substituting the empty string dropped the source from a Roam-format Evidence name and produced a title that no longer matched the format. The Obsidian format-expression helper reuses the shared placeholder pattern so decorate and match agree. --- .../utils/getDiscourseNodeFormatExpression.ts | 4 ++- apps/obsidian/src/utils/importNodes.ts | 8 +++--- .../src/lib/__tests__/decorateTitle.test.ts | 14 ++++++---- packages/database/src/lib/decorateTitle.ts | 28 +++++++++++++------ 4 files changed, 35 insertions(+), 19 deletions(-) diff --git a/apps/obsidian/src/utils/getDiscourseNodeFormatExpression.ts b/apps/obsidian/src/utils/getDiscourseNodeFormatExpression.ts index ea2e96bdc..0c4cd63fb 100644 --- a/apps/obsidian/src/utils/getDiscourseNodeFormatExpression.ts +++ b/apps/obsidian/src/utils/getDiscourseNodeFormatExpression.ts @@ -1,9 +1,11 @@ +import { FORMAT_PLACEHOLDER } from "@repo/database/lib/decorateTitle"; + export const getDiscourseNodeFormatExpression = (format: string) => format ? new RegExp( `^${format .replace(/(\[|\]|\?|\.|\+)/g, "\\$1") - .replace(/{[a-zA-Z]+}/g, "(.*?)")}$`, + .replace(FORMAT_PLACEHOLDER, "(.*?)")}$`, "s", ) : /$^/; diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index 4d7880d0f..bde0f108d 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -1376,11 +1376,11 @@ export const importSelectedNodes = async ({ const localNodeType = getNodeTypeById(plugin, mappedNodeTypeId); const coreTitle = nodeImportInfo?.coreTitle; - const titleForFileName = - coreTitle !== undefined && localNodeType?.format + const decoratedTitle = + coreTitle !== undefined && localNodeType ? decorateTitle(localNodeType.format, coreTitle) - : fileName; - const sanitizedFileName = sanitizeFileName(titleForFileName); + : null; + const sanitizedFileName = sanitizeFileName(decoratedTitle ?? fileName); let finalFilePath: string; if (existingFile) { diff --git a/packages/database/src/lib/__tests__/decorateTitle.test.ts b/packages/database/src/lib/__tests__/decorateTitle.test.ts index fef3776cb..5bff56c35 100644 --- a/packages/database/src/lib/__tests__/decorateTitle.test.ts +++ b/packages/database/src/lib/__tests__/decorateTitle.test.ts @@ -15,17 +15,21 @@ describe("decorateTitle", () => { expect(decorateTitle("QUE - {Content}", "why")).toBe("QUE - why"); }); - it("substitutes the empty string for other placeholders", () => { + it("returns null for a format with placeholders the core title cannot fill", () => { expect( decorateTitle("[[EVD]] - {content} - {Source}", "REM sleep and recall"), - ).toBe("[[EVD]] - REM sleep and recall - "); + ).toBeNull(); }); - it("returns the empty string for an empty format", () => { - expect(decorateTitle("", "anything")).toBe(""); + it("returns null for a format without a content placeholder", () => { + expect(decorateTitle("", "anything")).toBeNull(); + expect(decorateTitle("CLM", "anything")).toBeNull(); }); - it("keeps a core title that contains the separator", () => { + it("keeps a core title that contains the separator or replacement patterns", () => { expect(decorateTitle("CLM - {content}", "a - b")).toBe("CLM - a - b"); + expect(decorateTitle("CLM - {content}", "costs $& more")).toBe( + "CLM - costs $& more", + ); }); }); diff --git a/packages/database/src/lib/decorateTitle.ts b/packages/database/src/lib/decorateTitle.ts index 6017fdb02..7b3e39b1b 100644 --- a/packages/database/src/lib/decorateTitle.ts +++ b/packages/database/src/lib/decorateTitle.ts @@ -1,12 +1,22 @@ // Inverse of the apps' extractContentFromTitle: rebuild a local title from a // node type format and the core_title stored in Concept.literal_content. -// "{content}" takes the core title; every other placeholder (e.g. "{Source}") -// becomes the empty string, so "[[EVD]] - {content} - {Source}" yields -// "[[EVD]] - - ". Callers decide the fallback when the format is -// empty or the core title is missing. -const FORMAT_PLACEHOLDER = /{[a-zA-Z]+}/g; +// Returns null when the format cannot be rebuilt from the core title alone: +// it is empty, has no {content} placeholder, or carries other placeholders +// such as {Source} whose values the database does not hold yet. Callers fall +// back to the incoming title in that case. +export const FORMAT_PLACEHOLDER = /{[a-zA-Z]+}/g; -export const decorateTitle = (format: string, coreTitle: string): string => - format.replace(FORMAT_PLACEHOLDER, (placeholder) => - placeholder.toLowerCase() === "{content}" ? coreTitle : "", - ); +export const decorateTitle = ( + format: string, + coreTitle: string, +): string | null => { + const placeholders = format.match(FORMAT_PLACEHOLDER) ?? []; + if ( + placeholders.length === 0 || + placeholders.some( + (placeholder) => placeholder.toLowerCase() !== "{content}", + ) + ) + return null; + return format.replace(FORMAT_PLACEHOLDER, () => coreTitle); +};