From bcbf08b4db5115d47aee893689932d1f381b74c7 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Sun, 24 May 2026 14:20:59 -0400 Subject: [PATCH 1/2] cleanup --- .../src/components/NodeTypeSettings.tsx | 571 +++++++++++++++++- apps/obsidian/src/components/Settings.tsx | 2 +- apps/obsidian/src/utils/templateImport.ts | 174 ++++++ apps/obsidian/src/utils/templates.ts | 87 ++- 4 files changed, 795 insertions(+), 39 deletions(-) create mode 100644 apps/obsidian/src/utils/templateImport.ts diff --git a/apps/obsidian/src/components/NodeTypeSettings.tsx b/apps/obsidian/src/components/NodeTypeSettings.tsx index b8f08ab79..72e7e7fd0 100644 --- a/apps/obsidian/src/components/NodeTypeSettings.tsx +++ b/apps/obsidian/src/components/NodeTypeSettings.tsx @@ -1,11 +1,16 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { validateNodeFormat, validateNodeName } from "~/utils/validateNodeType"; import { usePlugin } from "./PluginContext"; -import { Notice, setIcon } from "obsidian"; +import { App, Component, MarkdownRenderer, Notice, setIcon } from "obsidian"; import generateUid from "~/utils/generateUid"; import { DiscourseNode } from "~/types"; import { ConfirmationModal } from "./ConfirmationModal"; -import { getTemplateFiles, getTemplatePluginInfo } from "~/utils/templates"; +import { + createTemplateFileWithUniqueName, + getImportedTemplateFileName, + getTemplateFiles, + getTemplatePluginInfo, +} from "~/utils/templates"; import { getImportInfo, formatImportSource, @@ -14,6 +19,10 @@ import { } from "~/utils/typeUtils"; import { FolderSuggestInput } from "./GeneralSettings"; import { createBaseForNodeType } from "~/utils/baseForNodeType"; +import { + fetchTemplateImportCandidates, + type TemplateImportCandidate, +} from "~/utils/templateImport"; const generateTagPlaceholder = (format: string, nodeName?: string): string => { if (!format) return "Enter tag (e.g., clm-candidate)"; @@ -221,6 +230,8 @@ const TemplateField = ({ templateConfig, templateFiles, disabled, + onImportClick, + importDisabledReason, }: { value: string; error?: string; @@ -228,27 +239,117 @@ const TemplateField = ({ templateConfig: { isEnabled: boolean; folderPath: string }; templateFiles: string[]; disabled?: boolean; -}) => ( - -); + onImportClick: () => void; + importDisabledReason?: string; +}) => { + const [isOpen, setIsOpen] = useState(false); + const isTemplateConfigured = + templateConfig.isEnabled && !!templateConfig.folderPath; + const isDisabled = disabled || !isTemplateConfigured; + const displayValue = !isTemplateConfigured + ? "Template folder not configured" + : value + ? value + : "No template"; + + const handleSelect = (nextValue: string): void => { + onChange(nextValue); + setIsOpen(false); + }; + + const menuItemStyle = { + background: "transparent", + border: "none", + borderRadius: 0, + boxShadow: "none", + color: "var(--text-normal)", + fontSize: "var(--font-ui-small)", + height: "28px", + justifyContent: "flex-start", + padding: "4px 10px", + textAlign: "left" as const, + width: "100%", + }; + + return ( +
+ + {isOpen && ( +
+ + {templateFiles.map((templateFile) => ( + + ))} +
+ +
+
+ )} +
+ ); +}; const FieldWrapper = ({ fieldConfig, @@ -275,6 +376,279 @@ const FieldWrapper = ({ ); +const formatRelativeTime = (timestamp?: number): string => { + if (!timestamp) return "unknown"; + + const diffMs = Date.now() - timestamp; + const diffDays = Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24))); + + if (diffDays === 0) return "today"; + if (diffDays === 1) return "1 day ago"; + if (diffDays < 7) return `${diffDays} days ago`; + + const diffWeeks = Math.floor(diffDays / 7); + if (diffWeeks === 1) return "1 week ago"; + if (diffWeeks < 5) return `${diffWeeks} weeks ago`; + + const diffMonths = Math.floor(diffDays / 30); + if (diffMonths <= 1) return "1 month ago"; + return `${diffMonths} months ago`; +}; + +const MarkdownTemplatePreview = ({ + app, + templateContent, + sourcePath, + className, +}: { + app: App; + templateContent: string; + sourcePath: string; + className?: string; +}) => { + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + container.innerHTML = ""; + const component = new Component(); + void MarkdownRenderer.render( + app, + templateContent.trim() || "This template is empty.", + container, + sourcePath, + component, + ); + + return () => { + component.unload(); + container.innerHTML = ""; + }; + }, [app, sourcePath, templateContent]); + + return ( +
+ ); +}; + +const TemplateImportPanel = ({ + app, + nodeTypeName, + candidates, + selectedCandidateId, + isLoading, + isImporting, + error, + templateFolderPath, + onSelectCandidate, + onClose, + onImport, +}: { + app: App; + nodeTypeName: string; + candidates: TemplateImportCandidate[]; + selectedCandidateId: number | null; + isLoading: boolean; + isImporting: boolean; + error?: string; + templateFolderPath: string; + onSelectCandidate: (candidateId: number) => void; + onClose: () => void; + onImport: () => void; +}) => { + const panelRef = useRef(null); + const selectedCandidate = + candidates.find((candidate) => candidate.id === selectedCandidateId) ?? + candidates[0]; + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + onClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + const handleBackdropPointerDown = ( + event: React.PointerEvent, + ): void => { + if ( + panelRef.current && + event.target instanceof Node && + !panelRef.current.contains(event.target) + ) { + onClose(); + } + }; + + return ( +
+
+
+
+ +
+

Import template from groups

+

+ {nodeTypeName} templates shared by members of your Discourse + Graphs groups. +

+
+
+ +
+ +
+
+ {isLoading && ( +
+ Loading shared templates... +
+ )} + {!isLoading && error && ( +
{error}
+ )} + {!isLoading && !error && candidates.length === 0 && ( +
+ No new shared templates available for node type "{nodeTypeName} + ". Already imported templates are hidden here. +
+ )} + {!isLoading && + !error && + candidates.map((candidate) => { + const isSelected = candidate.id === selectedCandidate?.id; + return ( +
onSelectCandidate(candidate.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectCandidate(candidate.id); + } + }} + role="button" + tabIndex={0} + > + + {candidate.templateName}.md + + {candidate.authorName && ( + + {candidate.authorName} + + )} + + (el && setIcon(el, "folder")) || undefined} + /> + + {candidate.spaceName} + + + {formatRelativeTime(candidate.lastModified)} + +
+ ); + })} +
+ +
+ +
+ {selectedCandidate ? ( +
+

+ {selectedCandidate.templateName}.md +

+ +
+ ) : ( +
+ Select a shared template to preview its content. +
+ )} +
+
+ +
+
+ (el && setIcon(el, "info")) || undefined} + /> + + Imports a copy to{" "} + {templateFolderPath} and + auto-renames on conflict. + +
+ +
+
+
+ ); +}; + const NodeTypeSettings = () => { const plugin = usePlugin(); const [nodeTypes, setNodeTypes] = useState([]); @@ -292,10 +666,20 @@ const NodeTypeSettings = () => { const [selectedNodeIndex, setSelectedNodeIndex] = useState( null, ); + const [isTemplateImportOpen, setIsTemplateImportOpen] = useState(false); + const [templateImportCandidates, setTemplateImportCandidates] = useState< + TemplateImportCandidate[] + >([]); + const [selectedTemplateCandidateId, setSelectedTemplateCandidateId] = + useState(null); + const [isLoadingTemplateImports, setIsLoadingTemplateImports] = + useState(false); + const [isImportingTemplate, setIsImportingTemplate] = useState(false); + const [templateImportError, setTemplateImportError] = useState(); // Ref to always have the latest editing state for onBlur handlers const editingRef = useRef(null); - useEffect(() => { + const refreshTemplateFiles = useCallback((): void => { const config = getTemplatePluginInfo(plugin.app); setTemplateConfig(config); @@ -303,6 +687,10 @@ const NodeTypeSettings = () => { setTemplateFiles(files); }, [plugin.app]); + useEffect(() => { + refreshTemplateFiles(); + }, [refreshTemplateFiles]); + useEffect(() => { setNodeTypes(plugin.settings.nodeTypes ?? []); }, [plugin.settings.nodeTypes]); @@ -493,6 +881,112 @@ const NodeTypeSettings = () => { if (editingRef.current) saveSettings(editingRef.current); }; + const openTemplateImportPanel = async (): Promise => { + if (!editingNodeType) return; + + if (!templateConfig.isEnabled || !templateConfig.folderPath) { + new Notice("Configure and enable the Obsidian Templates plugin first."); + return; + } + + if (!editingNodeType.name.trim()) { + new Notice("Name this node type before importing shared templates."); + return; + } + + if (!plugin.settings.syncModeEnabled) { + new Notice("Enable sync mode before importing shared templates."); + return; + } + + setIsTemplateImportOpen(true); + setIsLoadingTemplateImports(true); + setTemplateImportError(undefined); + setTemplateImportCandidates([]); + setSelectedTemplateCandidateId(null); + + try { + const candidates = await fetchTemplateImportCandidates({ + plugin, + nodeTypeName: editingNodeType.name, + }); + const existingTemplateNames = new Set( + getTemplateFiles(plugin.app).map((templateFileName) => + templateFileName.toLowerCase(), + ), + ); + const filteredCandidates = candidates.filter((candidate) => { + const importedTemplateName = getImportedTemplateFileName({ + templateName: candidate.templateName, + sourceName: candidate.spaceName, + }); + return !existingTemplateNames.has(importedTemplateName.toLowerCase()); + }); + + setTemplateImportCandidates(filteredCandidates); + setSelectedTemplateCandidateId(filteredCandidates[0]?.id ?? null); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + setTemplateImportError(errorMessage); + new Notice(`Failed to load shared templates: ${errorMessage}`, 5000); + } finally { + setIsLoadingTemplateImports(false); + } + }; + + const closeTemplateImportPanel = (): void => { + if (isImportingTemplate) return; + + setIsTemplateImportOpen(false); + setTemplateImportCandidates([]); + setSelectedTemplateCandidateId(null); + setTemplateImportError(undefined); + }; + + const importSelectedTemplate = async (): Promise => { + if (!editingRef.current) return; + + const selectedCandidate = templateImportCandidates.find( + (candidate) => candidate.id === selectedTemplateCandidateId, + ); + if (!selectedCandidate) return; + + setIsImportingTemplate(true); + try { + const result = await createTemplateFileWithUniqueName({ + app: plugin.app, + templateName: selectedCandidate.templateName, + sourceName: selectedCandidate.spaceName, + content: selectedCandidate.templateContent, + }); + + if (!result.created) { + new Notice(`Template import failed: ${result.reason}`, 5000); + return; + } + + const updatedNodeType = { + ...editingRef.current, + template: result.templateName, + modified: new Date().getTime(), + }; + setEditingNodeType(updatedNodeType); + editingRef.current = updatedNodeType; + saveSettings(updatedNodeType); + refreshTemplateFiles(); + setIsTemplateImportOpen(false); + new Notice(`Imported and selected "${result.templateName}.md"`, 3000); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error("Error importing shared template:", error); + new Notice(`Template import failed: ${errorMessage}`, 5000); + } finally { + setIsImportingTemplate(false); + } + }; + const renderField = (fieldConfig: BaseFieldConfig) => { if (!editingNodeType) return null; @@ -522,6 +1016,16 @@ const NodeTypeSettings = () => { templateConfig={templateConfig} templateFiles={templateFiles} disabled={isEditingImported} + onImportClick={() => { + void openTemplateImportPanel(); + }} + importDisabledReason={ + !editingNodeType.name.trim() + ? "Name this node type before importing shared templates." + : !plugin.settings.syncModeEnabled + ? "Enable sync mode before importing shared templates." + : undefined + } /> ) : fieldConfig.type === "color" ? ( { return (
{selectedNodeIndex === null ? renderNodeList() : renderEditForm()} + {isTemplateImportOpen && editingNodeType && ( + { + void importSelectedTemplate(); + }} + /> + )}
); }; diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index eed9e31c1..b08c0094a 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -29,7 +29,7 @@ const Settings = () => { }, []); return ( -
+