-
Notifications
You must be signed in to change notification settings - Fork 7
ENG-2157 Decorate imported node titles in Obsidian from core_title #1330
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
| ) | ||
| : /$^/; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,20 +329,27 @@ type NodeTypeSchemaForInstance = { | |
| name: string; | ||
| }; | ||
|
|
||
| export const fetchNodeTypeSchemasForInstances = async ({ | ||
| type NodeInstanceImportInfo = { | ||
| schema?: NodeTypeSchemaForInstance; | ||
| coreTitle?: string; | ||
| }; | ||
|
|
||
| export const fetchNodeImportInfoForInstances = async ({ | ||
| client, | ||
| spaceId, | ||
| nodeInstanceIds, | ||
| }: { | ||
| client: DGSupabaseClient; | ||
| spaceId: number; | ||
| nodeInstanceIds: string[]; | ||
| }): Promise<Map<string, NodeTypeSchemaForInstance>> => { | ||
| const result = new Map<string, NodeTypeSchemaForInstance>(); | ||
| }): Promise<Map<string, NodeInstanceImportInfo>> => { | ||
| const result = new Map<string, NodeInstanceImportInfo>(); | ||
|
|
||
| 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", | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This reads the single JSON key by path instead of selecting the whole |
||
| ) | ||
| .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<number, NodeTypeSchemaForInstance>(); | ||
| 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) { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The schema fetch moved inside an |
||
| 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, { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One write per instance instead of two passes over |
||
| schema: | ||
| row.schema_id === null ? undefined : schemasById.get(row.schema_id), | ||
| coreTitle: row.core_title ?? undefined, | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| }); | ||
| } | ||
|
|
||
| return result; | ||
|
|
@@ -1159,46 +1175,26 @@ export const mapNodeTypeIdToLocal = async ({ | |
|
|
||
| const processFileContent = async ({ | ||
| plugin, | ||
| client, | ||
| sourceSpaceId, | ||
| sourceSpaceUri, | ||
| rawContent, | ||
| filePath, | ||
| importedCreatedAt, | ||
| importedModifiedAt, | ||
| authorId, | ||
| nodeInstanceId, | ||
| nodeTypeIdFromConcept, | ||
| nodeTypeId, | ||
| }: { | ||
| plugin: DiscourseGraphPlugin; | ||
| client: DGSupabaseClient; | ||
| sourceSpaceId: number; | ||
| sourceSpaceUri: string; | ||
| rawContent: string; | ||
| filePath: string; | ||
| importedCreatedAt?: number; | ||
| 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<TFile> => { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| // 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<string, unknown>; | ||
| 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,19 +1343,58 @@ 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); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This derivation moved up from |
||
| 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({ | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| plugin, | ||
| client, | ||
| sourceSpaceId: spaceId, | ||
| sourceSpaceUri: spaceUri, | ||
| sourceNodeTypeId, | ||
| }); | ||
|
|
||
| const localNodeType = getNodeTypeById(plugin, mappedNodeTypeId); | ||
| const coreTitle = nodeImportInfo?.coreTitle; | ||
| const decoratedTitle = | ||
| coreTitle !== undefined && localNodeType | ||
| ? decorateTitle(localNodeType.format, coreTitle) | ||
| : null; | ||
| const sanitizedFileName = sanitizeFileName(decoratedTitle ?? fileName); | ||
| let finalFilePath: string; | ||
|
|
||
| if (existingFile) { | ||
| // Update existing file - use its current path | ||
| 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(/\/[^/]*$/, "")) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Obsidian-origin nodes that lived in a subfolder used to be created at their full source path, which skipped the decorated name entirely. A later refresh would then compare against the decorated basename and rename the file. Keeping the folders but replacing the last segment makes create and refresh agree. |
||
| : ""; | ||
| const pathUnderImport = sourceFolder | ||
| ? `${sourceFolder}/${sanitizedFileName}.md` | ||
| : `${sanitizedFileName}.md`; | ||
|
Comment on lines
+1395
to
+1397
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When two selected nodes in the same source folder produce the same Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed, and the case you describe is closed by the placeholder gate in 94ea2de: a format with a placeholder other than |
||
| finalFilePath = `${importFolderPath}/${pathUnderImport}`; | ||
|
|
||
| // Ensure all parent folders exist (e.g. import/VaultName/Discourse Nodes/SubFolder) | ||
|
|
@@ -1380,38 +1407,18 @@ 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, | ||
| importedCreatedAt: createdAt, | ||
| 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, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Preview only wants node type names, so it skips entries whose schema didn't resolve. One check instead of two, since nesting makes the co-presence of |
||
| const { nodeTypeId, name } = schema; | ||
|
|
||
| // Track name for triplet resolution | ||
| if (!nodeTypeIdToName.has(nodeTypeId)) { | ||
| nodeTypeIdToName.set(nodeTypeId, name); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| 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("returns null for a format with placeholders the core title cannot fill", () => { | ||
| expect( | ||
| decorateTitle("[[EVD]] - {content} - {Source}", "REM sleep and recall"), | ||
| ).toBeNull(); | ||
| }); | ||
|
|
||
| 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 or replacement patterns", () => { | ||
| expect(decorateTitle("CLM - {content}", "a - b")).toBe("CLM - a - b"); | ||
| expect(decorateTitle("CLM - {content}", "costs $& more")).toBe( | ||
| "CLM - costs $& more", | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NodeInstanceImportInfonests the existingNodeTypeSchemaForInstanceinstead of flattening its fields.nodeTypeIdandnameonly ever get set together from one schema row, so two independent optional fields would describe a state this code can't produce. The map now holds an entry for every visible instance rather than only the schema-resolved ones. That's what lets a core title reach the caller when the schema lookup comes back empty.