diff --git a/apps/obsidian/src/utils/conceptConversion.ts b/apps/obsidian/src/utils/conceptConversion.ts index b768715de..7a414f08c 100644 --- a/apps/obsidian/src/utils/conceptConversion.ts +++ b/apps/obsidian/src/utils/conceptConversion.ts @@ -31,10 +31,15 @@ const getNodeExtraData = ( /* eslint-enable @typescript-eslint/naming-convention */ }; -export const discourseNodeSchemaToLocalConcept = ( - context: SupabaseContext, - node: DiscourseNode, -): LocalConceptDataInput => { +export const discourseNodeSchemaToLocalConcept = ({ + context, + node, + templateContent, +}: { + context: SupabaseContext; + node: DiscourseNode; + templateContent?: string; +}): LocalConceptDataInput => { const { description, template, @@ -51,6 +56,7 @@ export const discourseNodeSchemaToLocalConcept = ( source_data: otherData, }; if (template) literal_content.template = template; + if (templateContent) literal_content.template_content = templateContent; if (importedFromRid) literal_content.importedFromRid = importedFromRid; return { space_id: context.spaceId, diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index 6ddf0cc02..819d1c833 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import type { Json } from "@repo/database/dbTypes"; import matter from "gray-matter"; -import { App, TFile } from "obsidian"; +import { App, Notice, TFile } from "obsidian"; import type { DGSupabaseClient } from "@repo/database/lib/client"; import type DiscourseGraphPlugin from "~/index"; import { getLoggedInClient, getSupabaseContext } from "./supabaseContext"; @@ -19,6 +19,7 @@ import { importRelationsForImportedNodes, type RemoteRelationInstance, } from "./importRelations"; +import { createTemplateFile } from "./templates"; import { resolveFolderForSpaceUri } from "./importFolderMetadata"; export const getAvailableGroupIds = async ( @@ -1019,7 +1020,7 @@ const parseSchemaLiteralContent = ( ): Pick< DiscourseNode, "name" | "format" | "color" | "tag" | "template" | "keyImage" -> => { +> & { templateContent?: string } => { const obj = typeof literalContent === "string" ? (JSON.parse(literalContent) as Record) @@ -1036,6 +1037,7 @@ const parseSchemaLiteralContent = ( color: (src.color as string) || (obj.color as string) || undefined, tag: (src.tag as string) || (obj.tag as string) || undefined, template: (obj.template as string) || undefined, + templateContent: (obj.template_content as string) || undefined, keyImage: (src.keyImage as boolean) ?? (obj.keyImage as boolean) ?? undefined, }; @@ -1111,6 +1113,32 @@ export const mapNodeTypeIdToLocal = async ({ authorId: schemaData.author_id ?? undefined, importedFromRid, }; + + if (parsed.templateContent && parsed.template) { + const result = await createTemplateFile({ + app: plugin.app, + templateName: parsed.template, + content: parsed.templateContent, + }); + if (result.created) { + new Notice( + `Template "${parsed.template}" created for imported node type "${parsed.name}".`, + 4000, + ); + } else if ( + result.reason === "Templates plugin is not enabled" || + result.reason === "Templates folder path is not configured" + ) { + // Don't store a template filename that can never resolve + newNodeType.template = undefined; + new Notice( + `Node type "${parsed.name}" imported without template: ${result.reason}. Configure the Templates plugin to use templates.`, + 6000, + ); + } + // If reason is "template already exists", keep newNodeType.template — local file takes precedence + } + plugin.settings.nodeTypes = [...plugin.settings.nodeTypes, newNodeType]; await plugin.saveSettings(); return newNodeType.id; diff --git a/apps/obsidian/src/utils/syncDgNodesToSupabase.ts b/apps/obsidian/src/utils/syncDgNodesToSupabase.ts index c7a37a720..17351f5a2 100644 --- a/apps/obsidian/src/utils/syncDgNodesToSupabase.ts +++ b/apps/obsidian/src/utils/syncDgNodesToSupabase.ts @@ -28,6 +28,7 @@ import { collectDiscourseNodesFromVault, } from "./getDiscourseNodes"; import { isAcceptedSchema } from "./typeUtils"; +import { getTemplatePluginInfo } from "./templates"; const DEFAULT_TIME = "1970-01-01"; export type ChangeType = "title" | "content"; @@ -472,9 +473,29 @@ const convertDgToSupabaseConcepts = async ({ nodeTypes.map((nodeType) => [nodeType.id, nodeType]), ); - const nodesTypesToLocalConcepts = nodeTypes - .filter((nodeType) => nodeType.modified > lastNodeSchemaSync) - .map((nodeType) => discourseNodeSchemaToLocalConcept(context, nodeType)); + const { isEnabled: templatesEnabled, folderPath: templatesFolderPath } = + getTemplatePluginInfo(plugin.app); + + const nodesTypesToLocalConcepts = await Promise.all( + nodeTypes + .filter((nodeType) => nodeType.modified > lastNodeSchemaSync) + .map(async (nodeType) => { + let templateContent: string | undefined; + if (nodeType.template && templatesEnabled && templatesFolderPath) { + const templateFilePath = `${templatesFolderPath}/${nodeType.template}.md`; + const templateFile = + plugin.app.vault.getAbstractFileByPath(templateFilePath); + if (templateFile instanceof TFile) { + templateContent = await plugin.app.vault.read(templateFile); + } + } + return discourseNodeSchemaToLocalConcept({ + context, + node: nodeType, + templateContent, + }); + }), + ); const relationTypesById = Object.fromEntries( relationTypes.map((relationType) => [relationType.id, relationType]), diff --git a/apps/obsidian/src/utils/templates.ts b/apps/obsidian/src/utils/templates.ts index 6805c0e33..5219f5806 100644 --- a/apps/obsidian/src/utils/templates.ts +++ b/apps/obsidian/src/utils/templates.ts @@ -93,6 +93,57 @@ export const getTemplateFiles = (app: App): string[] => { } }; +type CreateTemplateFileResult = + | { created: true } + | { created: false; reason: string }; + +export const createTemplateFile = async ({ + app, + templateName, + content, +}: { + app: App; + templateName: string; + content: string; +}): Promise => { + const { isEnabled, folderPath } = getTemplatePluginInfo(app); + + if (!isEnabled) { + return { created: false, reason: "Templates plugin is not enabled" }; + } + + if (!folderPath) { + return { + created: false, + reason: "Templates folder path is not configured", + }; + } + + // Ensure every segment of the folder path exists, creating missing ones + const segments = folderPath.split("/").filter(Boolean); + let currentPath = ""; + for (const segment of segments) { + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + const existing = app.vault.getAbstractFileByPath(currentPath); + if (!existing) { + await app.vault.createFolder(currentPath); + } + } + + // Sanitize to prevent path traversal (e.g. "../../sensitive" from a malicious sync) + const sanitizedName = templateName.replace(/[/\\]/g, "-"); + const templateFilePath = `${folderPath}/${sanitizedName}.md`; + + // Don't overwrite an existing template — the local file takes precedence + const existingFile = app.vault.getAbstractFileByPath(templateFilePath); + if (existingFile instanceof TFile) { + return { created: false, reason: "template already exists" }; + } + + await app.vault.create(templateFilePath, content); + return { created: true }; +}; + export const applyTemplate = async ({ app, targetFile,