diff --git a/dwertheimer.Forms/CHANGELOG.md b/dwertheimer.Forms/CHANGELOG.md index eadb8417f..9073a8ff9 100644 --- a/dwertheimer.Forms/CHANGELOG.md +++ b/dwertheimer.Forms/CHANGELOG.md @@ -4,6 +4,24 @@ See Plugin [README](https://github.com/NotePlan/plugins/blob/main/dwertheimer.Forms/README.md) for details on available commands and use case. +## [1.0.17] 2026-01-25 @dwertheimer + +### Fixed +- **SearchableChooser Templating Field Filter**: Fixed SearchableChooser to automatically filter out options containing templating fields (e.g., containing "<%") by default. This prevents templating syntax from appearing in frontmatter key chooser and other dropdown option lists. +- **SearchableChooser Manual Entry Indicator**: Fixed issue where the pencil icon (manual entry indicator) was incorrectly appearing in empty/blank fields. The indicator now only appears when a non-empty value has been entered that is not in the items list, and only after the items list has finished loading. +- **Frontmatter Key Values Filtering**: Fixed `getFrontmatterKeyValues` to filter out templating syntax values (containing "<%") at the source, preventing templating errors when forms load. Templating syntax values are now excluded from frontmatter key chooser dropdowns. +- **ContainedMultiSelectChooser Create Mode**: Fixed issue where ContainedMultiSelectChooser was not allowing creation of new items when the list was empty. Now allows creating new items even when `items.length === 0`, as long as `allowCreate` is true and there's a search term with no matches. + +### Changed +- **GenericDatePicker Calendar Auto-Close**: Improved date picker UX by automatically closing the calendar picker immediately after selecting a date. Previously, users had to click the date and then click outside the picker to close it. Now a single click on a date both selects it and closes the calendar. +- **SearchableChooser Debug Logging**: Added comprehensive debug logging to SearchableChooser to help diagnose manual entry indicator issues. Logs include value checks, placeholder matching, and manual entry determination logic. +- **FormBuilder Create-New Mode Fields**: Split "Content to Insert" into two separate fields when processing method is "Create New Note": + - **New Note Frontmatter**: Separate field for frontmatter content (saved to `template:ignore newNoteFrontmatter` codeblock) + - **New Note Body Content**: Renamed from "Content to Insert" to clarify it's the body content (saved to `template:ignore templateBody` codeblock) + - Frontmatter and body content are automatically combined with `--` delimiters when sending to TemplateRunner + - Fields are ordered with Frontmatter above Body Content for better workflow +- **TemplateTagEditor Raw Mode**: All template tag editor fields (NewNoteTitle, Content to Insert, New Note Frontmatter, New Note Body Content) now default to raw mode with the toggle hidden, showing monospace text directly instead of pill/chip display for better readability + ## [1.0.16] 2026-01-19 @dwertheimer ### Added diff --git a/dwertheimer.Forms/plugin.json b/dwertheimer.Forms/plugin.json index f1137aaf7..ff9517d8c 100644 --- a/dwertheimer.Forms/plugin.json +++ b/dwertheimer.Forms/plugin.json @@ -4,9 +4,9 @@ "noteplan.minAppVersion": "3.4.0", "plugin.id": "dwertheimer.Forms", "plugin.name": "📝 Template Forms", - "plugin.version": "1.0.16", + "plugin.version": "1.0.17", "plugin.releaseStatus": "beta", - "plugin.lastUpdateInfo": "Added NoteChooser output format options (title/filename for multi-select and single-select), advanced filtering (startFolder, includeRegex, excludeRegex), and SearchableChooser shortDescription optimization. Reverted compact label width to 10rem while keeping input width at 360px.", + "plugin.lastUpdateInfo": "Fixed SearchableChooser to filter out templating fields (containing '<%') by default and fixed pencil icon appearing incorrectly for empty/blank values.", "plugin.description": "Dynamic Forms for NotePlan using Templating -- fill out a multi-field form and have the data sent to a template for processing", "plugin.author": "dwertheimer", "plugin.requiredFiles": ["react.c.FormView.bundle.dev.js", "react.c.FormBuilderView.bundle.dev.js", "react.c.FormBrowserView.bundle.dev.js"], diff --git a/dwertheimer.Forms/src/NPTemplateForm.js b/dwertheimer.Forms/src/NPTemplateForm.js index 541f4d366..083180e44 100644 --- a/dwertheimer.Forms/src/NPTemplateForm.js +++ b/dwertheimer.Forms/src/NPTemplateForm.js @@ -4,7 +4,7 @@ import pluginJson from '../plugin.json' import { type PassedData } from './shared/types.js' // Note: getAllNotesAsOptions is no longer used here - FormView loads notes dynamically via requestFromPlugin import { testRequestHandlers, updateFormLinksInNote, removeEmptyLinesFromNote } from './requestHandlers' -import { loadTemplateBodyFromTemplate, loadTemplateRunnerArgsFromTemplate, loadCustomCSSFromTemplate, getFormTemplateList, findDuplicateFormTemplates } from './templateIO.js' +import { loadTemplateBodyFromTemplate, loadTemplateRunnerArgsFromTemplate, loadCustomCSSFromTemplate, loadNewNoteFrontmatterFromTemplate, getFormTemplateList, findDuplicateFormTemplates } from './templateIO.js' import { openFormWindow, openFormBuilderWindow, getFormBrowserWindowId, getFormBuilderWindowId, getFormWindowId } from './windowManagement.js' import { log, logError, logDebug, logWarn, timer, clo, JSP, logInfo } from '@helpers/dev' import { showMessage } from '@helpers/userInput' @@ -15,7 +15,7 @@ import { waitForCondition } from '@helpers/promisePolyfill' import NPTemplating from 'NPTemplating' import { getNoteByFilename } from '@helpers/note' import { validateObjectString, parseObjectString } from '@helpers/stringTransforms' -import { updateFrontMatterVars, ensureFrontmatter, noteHasFrontMatter, getFrontmatterAttributes } from '@helpers/NPFrontMatter' +import { updateFrontMatterVars, ensureFrontmatter, noteHasFrontMatter, getFrontmatterAttributes, getSanitizedFmParts } from '@helpers/NPFrontMatter' import { loadCodeBlockFromNote } from '@helpers/codeBlocks' import { parseTeamspaceFilename } from '@helpers/teamspace' import { getFolderFromFilename } from '@helpers/folders' @@ -273,9 +273,18 @@ export async function openTemplateForm(templateTitle?: string): Promise { } } - //TODO: we may not need this step, ask @codedungeon what he thinks - // for now, we'll call renderFrontmatter() via DataStore.invokePluginCommandByName() - const { _, frontmatterAttributes } = await DataStore.invokePluginCommandByName('renderFrontmatter', 'np.Templating', [templateData]) + // Parse frontmatter WITHOUT rendering templating syntax during form initialization + // Templating syntax in frontmatter attributes will be rendered later when form is submitted + // Use getFrontmatterAttributes to get parsed but unrendered frontmatter attributes + // This prevents errors when frontmatter contains templating syntax referencing form fields that don't exist yet + let frontmatterAttributes = getFrontmatterAttributes(templateNote) || {} + + // If frontmatterAttributes is empty, try parsing from templateData directly (without rendering) + if (!frontmatterAttributes || Object.keys(frontmatterAttributes).length === 0) { + // Fallback: parse frontmatter from templateData without rendering + const fmParts = getSanitizedFmParts(templateData, false) + frontmatterAttributes = fmParts.attributes || {} + } // Load TemplateRunner processing variables from codeblock (not frontmatter) // These contain template tags that reference form field values and should not be processed during form opening @@ -299,6 +308,12 @@ export async function openTemplateForm(templateTitle?: string): Promise { frontmatterAttributes.customCSS = '' } + // Load new note frontmatter from codeblock + const newNoteFrontmatterFromCodeblock = await loadNewNoteFrontmatterFromTemplate(templateNote) + if (newNoteFrontmatterFromCodeblock) { + frontmatterAttributes.newNoteFrontmatter = newNoteFrontmatterFromCodeblock + } + // Load TemplateRunner args from codeblock const templateRunnerArgs = await loadTemplateRunnerArgsFromTemplate(templateNote) if (templateRunnerArgs) { diff --git a/dwertheimer.Forms/src/ProcessingTemplate.js b/dwertheimer.Forms/src/ProcessingTemplate.js index 9d9d67efd..6da339687 100644 --- a/dwertheimer.Forms/src/ProcessingTemplate.js +++ b/dwertheimer.Forms/src/ProcessingTemplate.js @@ -13,6 +13,7 @@ export const templateBodyCodeBlockType = 'template:ignore templateBody' export const templateRunnerArgsCodeBlockType = 'template:ignore templateRunnerArgs' export const templateJSCodeBlockType = 'template:ignore templateJS' export const customCSSCodeBlockType = 'template:ignore customCSS' +export const newNoteFrontmatterCodeBlockType = 'template:ignore newNoteFrontmatter' /** * Create a form processing template (standalone command or called from Form Builder) diff --git a/dwertheimer.Forms/src/components/FormBuilder.jsx b/dwertheimer.Forms/src/components/FormBuilder.jsx index 344369533..711fe1b88 100644 --- a/dwertheimer.Forms/src/components/FormBuilder.jsx +++ b/dwertheimer.Forms/src/components/FormBuilder.jsx @@ -31,6 +31,7 @@ type FormBuilderProps = { y?: ?number | ?string, templateBody?: string, // Load from codeblock customCSS?: string, // Load from codeblock + newNoteFrontmatter?: string, // Load from codeblock templateRunnerArgs?: { [key: string]: any }, // TemplateRunner processing variables (loaded from codeblock) isNewForm?: boolean, templateTitle?: string, @@ -57,6 +58,7 @@ export function FormBuilder({ y, templateBody = '', // Load from codeblock customCSS = '', // Load from codeblock + newNoteFrontmatter = '', // Load from codeblock templateRunnerArgs = {}, // TemplateRunner processing variables (loaded from codeblock) isNewForm = false, templateTitle = '', @@ -146,6 +148,7 @@ You can edit or delete this comment field - it's just a note to help you get sta // Option B: Create new note (defaults) newNoteTitle: '', newNoteFolder: '', + newNoteFrontmatter: '', // Frontmatter for new note (saved to codeblock) // Option C: Form processor formProcessorTitle: cleanedReceivingTemplateTitle, // Set to receivingTemplateTitle for backward compatibility // Space selection (empty string = Private, teamspace ID = Teamspace) @@ -155,6 +158,8 @@ You can edit or delete this comment field - it's just a note to help you get sta templateBody: templateBody || '', // Custom CSS (loaded from codeblock) customCSS: customCSS || '', + // New note frontmatter (loaded from codeblock) + newNoteFrontmatter: newNoteFrontmatter || '', } // Merge TemplateRunner args from codeblock (these override defaults) diff --git a/dwertheimer.Forms/src/components/FormBuilderView.jsx b/dwertheimer.Forms/src/components/FormBuilderView.jsx index f8bab03bd..e245d0927 100644 --- a/dwertheimer.Forms/src/components/FormBuilderView.jsx +++ b/dwertheimer.Forms/src/components/FormBuilderView.jsx @@ -206,6 +206,7 @@ export function WebView({ data, dispatch, reactSettings, setReactSettings, onSub y={y} templateBody={pluginData.templateBody || ''} // Load from codeblock customCSS={pluginData.customCSS || ''} // Load from codeblock + newNoteFrontmatter={pluginData.newNoteFrontmatter || ''} // Load from codeblock isNewForm={isNewForm} templateTitle={templateTitle} templateFilename={templateFilename} diff --git a/dwertheimer.Forms/src/components/ProcessingMethodSection.jsx b/dwertheimer.Forms/src/components/ProcessingMethodSection.jsx index 7cb389a20..7312fc628 100644 --- a/dwertheimer.Forms/src/components/ProcessingMethodSection.jsx +++ b/dwertheimer.Forms/src/components/ProcessingMethodSection.jsx @@ -324,6 +324,8 @@ export function ProcessingMethodSection({ minRows={5} maxRows={15} fields={fields.filter((f) => f.key && f.type !== 'separator' && f.type !== 'heading')} + defaultRawMode={true} + hideRawToggle={true} actionButtons={ <> + + + } + /> +
+ Frontmatter content for the new note. Use template tags like <%- fieldKey %> for form fields. This will be saved to the ```template:ignore newNoteFrontmatter``` codeblock. +
+ )} diff --git a/dwertheimer.Forms/src/components/TemplateTagEditor.jsx b/dwertheimer.Forms/src/components/TemplateTagEditor.jsx index 2c4c6a3b0..0bdd5e22b 100644 --- a/dwertheimer.Forms/src/components/TemplateTagEditor.jsx +++ b/dwertheimer.Forms/src/components/TemplateTagEditor.jsx @@ -28,6 +28,8 @@ export type TemplateTagEditorProps = { className?: string, style?: { [key: string]: any }, actionButtons?: React$Node, // Buttons to display in the toggle area + defaultRawMode?: boolean, // If true, start in raw mode (default: false) + hideRawToggle?: boolean, // If true, hide the raw mode toggle switch (default: false) } /** @@ -127,26 +129,7 @@ function reconstructText(pills: Array): string { return pills.map((pill) => (pill.type === 'tag' ? pill.content : pill.content)).join('') } -/** - * Replace leading and trailing spaces with visible space indicator for display - * Also replaces newlines with indicators - * Only shows space indicators at the start or end of text, not in the middle - * @param {string} text - The text to process - * @returns {string} Text with leading/trailing spaces replaced by "" and newlines by "" - */ -function displayTextWithSpaces(text: string): string { - // First, replace all newlines with - let processedText = text.replace(/\n/g, '') - - // Then replace leading spaces and trailing spaces separately - // Leading spaces: ^\s+ - // Trailing spaces: \s+$ - // Middle spaces are left as-is - processedText = processedText.replace(/^(\s+)/, (match) => ''.repeat(match.length)) - processedText = processedText.replace(/(\s+)$/, (match) => ''.repeat(match.length)) - - return processedText -} +// Removed displayTextWithSpaces function - now showing text directly with whitespace preserved /** * TemplateTagEditor Component @@ -164,8 +147,10 @@ export function TemplateTagEditor({ className = '', style = {}, actionButtons, + defaultRawMode = false, + hideRawToggle = false, }: TemplateTagEditorProps): React$Node { - const [showRaw, setShowRaw] = useState(false) + const [showRaw, setShowRaw] = useState(defaultRawMode) const [pills, setPills] = useState>([]) const [selectedPillId, setSelectedPillId] = useState(null) const [editingTextIndex, setEditingTextIndex] = useState(null) @@ -670,7 +655,7 @@ export function TemplateTagEditor({ style={{ cursor: isDragging ? 'text' : 'text', fontFamily: 'Menlo, monospace' }} > {showDropIndicatorBefore &&
} - {displayTextWithSpaces(pill.content)} + {pill.content} {showDropIndicatorAfter &&
}
{/* Drop zone for between pills - also clickable for text input */} @@ -724,9 +709,34 @@ export function TemplateTagEditor({ return (
- {/* Toggle switch for raw mode */} -
- {actionButtons && ( + {/* Toggle switch for raw mode - hidden if hideRawToggle is true */} + {!hideRawToggle && ( +
+ {actionButtons && ( +
{ + // Ensure button clicks work + e.stopPropagation() + }} + onMouseDown={(e) => { + // Ensure button clicks work + e.stopPropagation() + }} + > + {actionButtons} +
+ )} + +
+ )} + {/* Show action buttons even when toggle is hidden */} + {hideRawToggle && actionButtons && ( +
{ @@ -740,13 +750,8 @@ export function TemplateTagEditor({ > {actionButtons}
- )} - -
+
+ )} {/* Raw mode - simple textarea */} {showRaw ? ( diff --git a/dwertheimer.Forms/src/formBuilderHandlers.js b/dwertheimer.Forms/src/formBuilderHandlers.js index 1f61aa739..dd70d7a11 100644 --- a/dwertheimer.Forms/src/formBuilderHandlers.js +++ b/dwertheimer.Forms/src/formBuilderHandlers.js @@ -18,6 +18,7 @@ import { saveTemplateBodyToTemplate, saveTemplateRunnerArgsToTemplate, saveCustomCSSToTemplate, + saveNewNoteFrontmatterToTemplate, updateReceivingTemplateWithFields, } from './templateIO' import { removeEmptyLinesFromNote, updateFormLinksInNote } from './requestHandlers' @@ -659,6 +660,14 @@ export async function handleSaveRequest(data: any): Promise<{ success: boolean, logDebug(pluginJson, `[${saveId}] handleSaveRequest: customCSS saved to codeblock`) } + // Save newNoteFrontmatter to codeblock if provided + logDebug(pluginJson, `[${saveId}] handleSaveRequest: Checking newNoteFrontmatter: ${data?.frontmatter?.newNoteFrontmatter !== undefined ? 'exists' : 'missing'}`) + if (data?.frontmatter?.newNoteFrontmatter !== undefined) { + logDebug(pluginJson, `[${saveId}] handleSaveRequest: About to save newNoteFrontmatter to codeblock`) + await saveNewNoteFrontmatterToTemplate(finalTemplateFilename, data.frontmatter.newNoteFrontmatter || '') + logDebug(pluginJson, `[${saveId}] handleSaveRequest: newNoteFrontmatter saved to codeblock`) + } + // Save TemplateRunner args to codeblock if any exist logDebug(pluginJson, `[${saveId}] handleSaveRequest: Checking TemplateRunner args: ${Object.keys(templateRunnerArgs).length} keys`) if (Object.keys(templateRunnerArgs).length > 0) { diff --git a/dwertheimer.Forms/src/formSubmission.js b/dwertheimer.Forms/src/formSubmission.js index 9aa8714bd..443695427 100644 --- a/dwertheimer.Forms/src/formSubmission.js +++ b/dwertheimer.Forms/src/formSubmission.js @@ -932,13 +932,70 @@ async function processCreateNew(data: any, reactWindowData: PassedData): Promise }) // Step 7: Build template body (DO NOT insert templatejs blocks - they're already executed) + // Get new note frontmatter and body content (templateBody) + let newNoteFrontmatter = reactWindowData?.pluginData?.newNoteFrontmatter || data?.newNoteFrontmatter || '' const templateBody = reactWindowData?.pluginData?.templateBody || data?.templateBody || '' - const finalTemplateBody = - templateBody || - Object.keys(formSpecificVars) - .filter((key) => key !== '__isJSON__') - .map((key) => `${key}: <%- ${key} %>`) - .join('\n') + + // Ensure title is preserved: if frontmatter exists, check if it has a title field + // If not, and body doesn't start with "# <%- newNoteTitle %>", add title to frontmatter + if (newNoteFrontmatter && newNoteFrontmatter.trim()) { + // Parse frontmatter to check for title field + const frontmatterLines = newNoteFrontmatter.trim().split('\n') + let hasTitleField = false + + for (const line of frontmatterLines) { + const trimmedLine = line.trim() + // Check if line matches "title:" (case-insensitive, with optional whitespace) + if (trimmedLine.match(/^title\s*:/i)) { + hasTitleField = true + break + } + } + + // If no title field exists, check body content + if (!hasTitleField) { + const bodyFirstLine = templateBody.trim().split('\n')[0] || '' + const hasTitleHeading = bodyFirstLine.trim() === '# <%- newNoteTitle %>' + + // If body doesn't have the title heading, add title to frontmatter + if (!hasTitleHeading) { + // Use the original newNoteTitle template tag if it contains template syntax, + // otherwise use the newNoteTitle variable (which will be available in template context) + const originalNewNoteTitle = newNoteTitleToUse || reactWindowData?.pluginData?.newNoteTitle || data?.newNoteTitle || '' + + // If newNoteTitle contains template tags, use them directly; otherwise reference newNoteTitle variable + let titleTemplateTag = '<%- newNoteTitle %>' + if (originalNewNoteTitle && typeof originalNewNoteTitle === 'string' && originalNewNoteTitle.includes('<%')) { + // Use the original template tag (e.g., "<%- Contact_Name %>") + titleTemplateTag = originalNewNoteTitle + } + + // Add title field to the top of frontmatter + newNoteFrontmatter = `title: ${titleTemplateTag}\n${newNoteFrontmatter.trim()}` + logDebug(pluginJson, `processCreateNew: Added title field to frontmatter to preserve title from being overwritten: title: ${titleTemplateTag}`) + } + } + } + + let finalTemplateBody = '' + + // If we have frontmatter, combine it with templateBody using -- delimiters + if (newNoteFrontmatter && newNoteFrontmatter.trim()) { + const parts = ['--', newNoteFrontmatter.trim(), '--'] + if (templateBody && templateBody.trim()) { + parts.push(templateBody.trim()) + } + finalTemplateBody = parts.join('\n') + logDebug(pluginJson, `processCreateNew: Combined newNoteFrontmatter and templateBody with -- delimiters`) + } else { + // No frontmatter, just use templateBody (backward compatibility - old forms may have -- in templateBody) + finalTemplateBody = + templateBody || + Object.keys(formSpecificVars) + .filter((key) => key !== '__isJSON__') + .map((key) => `${key}: <%- ${key} %>`) + .join('\n') + } // Step 8: Build templateRunner args with form-specific variables const templateRunnerArgs: { [string]: any } = { diff --git a/dwertheimer.Forms/src/requestHandlers.js b/dwertheimer.Forms/src/requestHandlers.js index b9012fae5..6261b2e66 100644 --- a/dwertheimer.Forms/src/requestHandlers.js +++ b/dwertheimer.Forms/src/requestHandlers.js @@ -221,7 +221,15 @@ export async function getFrontmatterKeyValues(params: { const values = await getValuesForFrontmatterTag(params.frontmatterKey, noteType, caseSensitive, folderString, fullPathMatch) // Convert all values to strings (frontmatter values can be various types) - const stringValues = values.map((v: any) => String(v)) + let stringValues = values.map((v: any) => String(v)) + + // Filter out templating syntax values (containing "<%") - these are template code, not actual values + // This prevents templating errors when forms load and process frontmatter + const beforeFilterCount = stringValues.length + stringValues = stringValues.filter((v: string) => !v.includes('<%')) + if (beforeFilterCount !== stringValues.length) { + logDebug(pluginJson, `[DIAG] getFrontmatterKeyValues: Filtered out ${beforeFilterCount - stringValues.length} templating syntax values`) + } const totalElapsed: number = Date.now() - startTime logDebug(pluginJson, `[DIAG] getFrontmatterKeyValues COMPLETE: totalElapsed=${totalElapsed}ms, found=${stringValues.length} values for key "${params.frontmatterKey}"`) diff --git a/dwertheimer.Forms/src/templateIO.js b/dwertheimer.Forms/src/templateIO.js index cc89f1e5a..03709178a 100644 --- a/dwertheimer.Forms/src/templateIO.js +++ b/dwertheimer.Forms/src/templateIO.js @@ -4,7 +4,7 @@ //-------------------------------------------------------------------------- import pluginJson from '../plugin.json' -import { templateBodyCodeBlockType, templateRunnerArgsCodeBlockType, varsCodeBlockType, varsInForm, customCSSCodeBlockType } from './ProcessingTemplate' +import { templateBodyCodeBlockType, templateRunnerArgsCodeBlockType, varsCodeBlockType, varsInForm, customCSSCodeBlockType, newNoteFrontmatterCodeBlockType } from './ProcessingTemplate' import { getNoteByFilename } from '@helpers/note' import { saveCodeBlockToNote, loadCodeBlockFromNote, replaceCodeBlockContent } from '@helpers/codeBlocks' import { parseObjectString, stripDoubleQuotes } from '@helpers/stringTransforms' @@ -339,6 +339,70 @@ export async function loadCustomCSSFromTemplate(templateNoteOrFilename: CoreNote } } +/** + * Save newNoteFrontmatter to template as code block + * @param {string} templateFilename - The template filename + * @param {string} newNoteFrontmatter - The frontmatter content + * @returns {Promise} + */ +export async function saveNewNoteFrontmatterToTemplate(templateFilename: string, newNoteFrontmatter: string): Promise { + try { + logDebug(pluginJson, `saveNewNoteFrontmatterToTemplate: Saving frontmatter (length=${newNoteFrontmatter?.length || 0})`) + let cleanedFrontmatter = newNoteFrontmatter || '' + + // Check for double encoding issues + if (cleanedFrontmatter && isDoubleEncoded(cleanedFrontmatter)) { + logDebug(pluginJson, `saveNewNoteFrontmatterToTemplate: Detected corruption, fixing before save`) + const fixed = fixDoubleEncoded(cleanedFrontmatter) + if (fixed !== cleanedFrontmatter) { + cleanedFrontmatter = fixed + } + } + + await saveCodeBlockToNote( + templateFilename, + newNoteFrontmatterCodeBlockType, + cleanedFrontmatter, + pluginJson.id, + null, // No format function needed + false, // Don't show error messages to user (silent operation) + ) + logDebug(pluginJson, `saveNewNoteFrontmatterToTemplate: Successfully saved codeblock`) + } catch (error) { + logDebug(pluginJson, `saveNewNoteFrontmatterToTemplate: ERROR: ${error.message || String(error)}`) + logError(pluginJson, `saveNewNoteFrontmatterToTemplate error: ${JSP(error)}`) + } +} + +/** + * Load newNoteFrontmatter from template code block + * @param {CoreNoteFields | string} templateNoteOrFilename - The template note or filename + * @returns {Promise} - The frontmatter content, or empty string if not found + */ +export async function loadNewNoteFrontmatterFromTemplate(templateNoteOrFilename: CoreNoteFields | string): Promise { + try { + const content = await loadCodeBlockFromNote(templateNoteOrFilename, newNoteFrontmatterCodeBlockType, pluginJson.id, null) + const loadedContent = content || '' + + // Check for and fix double-encoded UTF-8 issues + if (loadedContent && (isDoubleEncoded(loadedContent) || loadedContent.includes('ðŸ') || loadedContent.includes('ô€') || loadedContent.includes(''))) { + logDebug(pluginJson, `loadNewNoteFrontmatterFromTemplate: Detected double-encoded UTF-8, attempting fix`) + const fixed = fixDoubleEncoded(loadedContent) + if (fixed !== loadedContent && typeof templateNoteOrFilename === 'string') { + logDebug(pluginJson, `loadNewNoteFrontmatterFromTemplate: Auto-saving fixed content back to template`) + await saveNewNoteFrontmatterToTemplate(templateNoteOrFilename, fixed) + return fixed + } + return fixed + } + + return loadedContent + } catch (error) { + logError(pluginJson, `loadNewNoteFrontmatterFromTemplate error: ${JSP(error)}`) + return '' + } +} + /** * Update receiving template with field keys from form fields * Adds template variables for each field key at the end of the template diff --git a/dwertheimer.Forms/src/windowManagement.js b/dwertheimer.Forms/src/windowManagement.js index aa545decc..d74e6c511 100644 --- a/dwertheimer.Forms/src/windowManagement.js +++ b/dwertheimer.Forms/src/windowManagement.js @@ -6,7 +6,7 @@ import pluginJson from '../plugin.json' import { type PassedData } from './shared/types.js' import { FORMBUILDER_WINDOW_ID, WEBVIEW_WINDOW_ID } from './shared/constants.js' -import { loadTemplateBodyFromTemplate, loadTemplateRunnerArgsFromTemplate, loadCustomCSSFromTemplate } from './templateIO.js' +import { loadTemplateBodyFromTemplate, loadTemplateRunnerArgsFromTemplate, loadCustomCSSFromTemplate, loadNewNoteFrontmatterFromTemplate } from './templateIO.js' import { getFolders, getNotes, getTeamspaces, getMentions, getHashtags, getEvents } from './dataHandlers' import { getNoteByFilename } from '@helpers/note' import { generateCSSFromTheme } from '@helpers/NPThemeToCSS' @@ -669,6 +669,7 @@ export async function openFormBuilderWindow(argObj: Object): Promise { let templateBody = '' let templateRunnerArgs = null let customCSSValue = '' + let newNoteFrontmatter = '' let templateTitleForWindow = argObj.templateTitle || '' let launchLink = '' // Will be generated or read from frontmatter @@ -706,14 +707,16 @@ export async function openFormBuilderWindow(argObj: Object): Promise { y = typeof yStr === 'number' ? yStr : String(yStr) } - // Load templateBody, TemplateRunner args, and custom CSS in parallel (performance optimization) + // Load templateBody, TemplateRunner args, custom CSS, and new note frontmatter in parallel (performance optimization) // Start all promises to run in parallel, then await them const templateBodyPromise = loadTemplateBodyFromTemplate(templateNote) const templateRunnerArgsPromise = loadTemplateRunnerArgsFromTemplate(templateNote) const customCSSPromise = loadCustomCSSFromTemplate(templateNote) + const newNoteFrontmatterPromise = loadNewNoteFrontmatterFromTemplate(templateNote) templateBody = await templateBodyPromise templateRunnerArgs = await templateRunnerArgsPromise customCSSValue = await customCSSPromise + newNoteFrontmatter = await newNoteFrontmatterPromise // Merge TemplateRunner args into the data object that will be passed to FormBuilder // These will override any values that might be in frontmatter @@ -753,6 +756,7 @@ export async function openFormBuilderWindow(argObj: Object): Promise { processingMethod: processingMethod, // Pass processingMethod from frontmatter templateBody: templateBody, // Load from codeblock customCSS: customCSSValue || '', // Load custom CSS from codeblock + newNoteFrontmatter: newNoteFrontmatter || '', // Load from codeblock isNewForm: isNewForm, launchLink: launchLink, // Add launchLink to pluginData windowId: windowId, // Store window ID in pluginData so React can send it in requests diff --git a/helpers/react/DynamicDialog/ContainedMultiSelectChooser.css b/helpers/react/DynamicDialog/ContainedMultiSelectChooser.css index 036ad458b..7efb3123a 100644 --- a/helpers/react/DynamicDialog/ContainedMultiSelectChooser.css +++ b/helpers/react/DynamicDialog/ContainedMultiSelectChooser.css @@ -201,6 +201,21 @@ background-color: var(--bg-disabled-color, #f5f5f5); } +/* Highlighted confirm button when in create mode */ +.contained-multi-select-create-confirm-btn-highlighted:not(:disabled) { + background-color: var(--tint-color, #dc8a78); + color: white; + border-color: var(--tint-color, #dc8a78); + font-weight: bold; + box-shadow: 0 0 0 2px rgba(220, 138, 120, 0.2); +} + +.contained-multi-select-create-confirm-btn-highlighted:not(:disabled):hover { + background-color: var(--tint-color, #dc8a78); + color: white; + box-shadow: 0 0 0 3px rgba(220, 138, 120, 0.3); +} + /* Select All/None buttons */ .contained-multi-select-select-all-btn, .contained-multi-select-select-none-btn { diff --git a/helpers/react/DynamicDialog/ContainedMultiSelectChooser.jsx b/helpers/react/DynamicDialog/ContainedMultiSelectChooser.jsx index ea44b3d65..60c5cc365 100644 --- a/helpers/react/DynamicDialog/ContainedMultiSelectChooser.jsx +++ b/helpers/react/DynamicDialog/ContainedMultiSelectChooser.jsx @@ -264,9 +264,16 @@ export function ContainedMultiSelectChooser({ // Show create mode automatically when search has no matches and allowCreate is true // Skip create mode when "is:checked" filter is active useEffect(() => { - logDebug('ContainedMultiSelectChooser', `[CREATE MODE] Effect triggered: searchTerm="${searchTerm}", displayItems.length=${displayItems.length}, filteredItems.length=${filteredItems.length}, showCreateMode=${String(showCreateMode)}, showCheckedOnly=${String(showCheckedOnly)}`) + logDebug('ContainedMultiSelectChooser', `[CREATE MODE] Effect triggered: searchTerm="${searchTerm}", displayItems.length=${displayItems.length}, filteredItems.length=${filteredItems.length}, items.length=${items.length}, showCreateMode=${String(showCreateMode)}, showCheckedOnly=${String(showCheckedOnly)}`) - if (allowCreate && searchTerm.trim() && searchTerm.toLowerCase() !== 'is:checked' && !showCheckedOnly && displayItems.length === 0 && filteredItems.length > 0) { + // Allow create mode when: + // 1. allowCreate is true + // 2. There's a search term (not empty) + // 3. Search term is not "is:checked" + // 4. "is:checked" filter is not active + // 5. No display items match the search (displayItems.length === 0) + // Note: Allow creation even when items.length is 0 (empty list) - user should be able to create new items + if (allowCreate && searchTerm.trim() && searchTerm.toLowerCase() !== 'is:checked' && !showCheckedOnly && displayItems.length === 0) { // No matches found for the search term, show create mode with the search term pre-filled if (!showCreateMode) { logDebug('ContainedMultiSelectChooser', `[CREATE MODE] Auto-showing create mode with searchTerm="${searchTerm.trim()}"`) @@ -279,7 +286,7 @@ export function ContainedMultiSelectChooser({ setShowCreateMode(false) setCreateValue('') } - }, [displayItems.length, searchTerm, filteredItems.length, allowCreate, showCreateMode, showCheckedOnly]) + }, [displayItems.length, searchTerm, filteredItems.length, items.length, allowCreate, showCreateMode, showCheckedOnly]) // Handle checkbox toggle (multi-select) or item selection (single-value) const handleToggle = (itemName: string) => { @@ -561,12 +568,16 @@ export function ContainedMultiSelectChooser({ }} onClick={handleInputClick} onKeyDown={(e) => { - // Prevent Enter key from submitting the form + // Handle Enter key if (e.key === 'Enter') { e.preventDefault() e.stopPropagation() - // Don't do anything else - just prevent form submission - // The input is for filtering/searching, not for submitting + // If in create mode, confirm creation + if (showCreateMode && createValue.trim() && !disabled && !isCreating) { + handleCreateConfirm() + return + } + // Otherwise, just prevent form submission return } // Prevent space key for tag-chooser and mention-chooser when in create mode @@ -607,10 +618,10 @@ export function ContainedMultiSelectChooser({
diff --git a/helpers/react/DynamicDialog/GenericDatePicker.jsx b/helpers/react/DynamicDialog/GenericDatePicker.jsx index 3bfd34e8d..d9862d93a 100644 --- a/helpers/react/DynamicDialog/GenericDatePicker.jsx +++ b/helpers/react/DynamicDialog/GenericDatePicker.jsx @@ -156,6 +156,38 @@ const GenericDatePicker = ({ onSelectDate, startingSelectedDate, disabled = fals if (valueChanged) { lastSentValueRef.current = formatted onSelectDate(formatted) // Propagate the formatted value to parent + // Close the native calendar picker after date selection + // The native calendar needs time to process the selection, so we use a delay + if (inputRef.current) { + // Use requestAnimationFrame to wait for the next frame, then setTimeout for additional delay + // This ensures the browser has finished processing the date selection + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setTimeout(() => { + if (inputRef.current) { + // Check if input is still focused (calendar might still be open) + const isFocused = document.activeElement === inputRef.current + if (isFocused) { + // Blur to close the calendar + inputRef.current.blur() + // Also try clicking outside by programmatically clicking the wrapper's parent + // This simulates the user clicking outside the calendar + const wrapper = inputRef.current.closest('.generic-date-picker-wrapper') + if (wrapper && wrapper.parentElement) { + // Create a synthetic click event on the parent + const clickEvent = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + view: window, + }) + wrapper.parentElement.dispatchEvent(clickEvent) + } + } + } + }, 50) + }) + }) + } } } else if (!value || value.trim() === '') { // Value was cleared @@ -170,7 +202,6 @@ const GenericDatePicker = ({ onSelectDate, startingSelectedDate, disabled = fals onSelectDate(clearedValue) } } - // Note: Native HTML date picker automatically closes after selection, no need to blur } // Handle input blur - for HTML date inputs, this is mainly for validation diff --git a/helpers/react/DynamicDialog/SearchableChooser.jsx b/helpers/react/DynamicDialog/SearchableChooser.jsx index 8417d5014..090827478 100644 --- a/helpers/react/DynamicDialog/SearchableChooser.jsx +++ b/helpers/react/DynamicDialog/SearchableChooser.jsx @@ -179,7 +179,7 @@ export function SearchableChooser({ // } // }, [items, isOpen, filteredItems.length, debugLogging, fieldType, getDisplayValue]) - // Filter items: first apply itemFilter (if provided), then apply search filter + // Filter items: first apply itemFilter (if provided), then apply default templating filter, then apply search filter useEffect(() => { // Apply itemFilter first (if provided) - this filters items regardless of search term let preFilteredItems = items @@ -187,6 +187,13 @@ export function SearchableChooser({ preFilteredItems = items.filter((item: any) => itemFilter(item)) } + // Apply default filter to screen out templating fields (containing "<%") + // This prevents templating syntax from appearing in option lists + preFilteredItems = preFilteredItems.filter((item: any) => { + const optionText = getOptionText(item) + return !optionText.includes('<%') + }) + // Then apply search filter if there's a search term if (!searchTerm.trim()) { setFilteredItems(preFilteredItems) @@ -194,7 +201,7 @@ export function SearchableChooser({ const filtered = preFilteredItems.filter((item: any) => filterFn(item, searchTerm)) setFilteredItems(filtered) } - }, [searchTerm, items, filterFn, itemFilter]) + }, [searchTerm, items, filterFn, itemFilter, getOptionText]) // Scroll highlighted item into view when hoveredIndex changes useEffect(() => { @@ -359,9 +366,6 @@ export function SearchableChooser({ suppressOpenOnFocusRef.current = false return } - if (debugLogging) { - console.log(`${fieldType}: Input focused, opening dropdown. items=${items.length}, filteredItems=${filteredItems.length}`) - } if (!isOpen && onOpen) { onOpen() // Trigger lazy loading callback } @@ -501,84 +505,49 @@ export function SearchableChooser({ let isManualEntryValue = false // Check if current value is a manual entry - if (allowManualEntry && displayValue && isManualEntry) { - isManualEntryValue = isManualEntry(displayValue, items) + // Don't show manual entry indicator for empty/blank values or placeholder text + const trimmedDisplayValue = displayValue ? displayValue.trim() : '' + const isPlaceholderValue = placeholder && trimmedDisplayValue === placeholder.trim() + + if (allowManualEntry && trimmedDisplayValue !== '' && !isPlaceholderValue && isManualEntry) { + // Don't show manual entry indicator if items list is empty (still loading) + if (items && items.length > 0) { + const manualEntryResult = isManualEntry(trimmedDisplayValue, items) + isManualEntryValue = manualEntryResult + } } if (displayValue && items && items.length > 0 && !isManualEntryValue) { - if (debugLogging) { - console.log(`${fieldType}: Looking up display value for stored value: "${value}"`) - console.log(`${fieldType}: Items available: ${items.length}, first item type:`, typeof items[0]) - if (items.length > 0 && typeof items[0] === 'object') { - console.log(`${fieldType}: First item keys:`, Object.keys(items[0])) - } - } - // Try to find the item that matches this value // For notes, we need to match by filename; for folders, by path const foundItem = items.find((item: any) => { // Check if this item's value matches our stored value // For note objects, compare filename; for folder strings, compare the string itself if (typeof item === 'string') { - const matches = item === displayValue - if (debugLogging && matches) { - console.log(`${fieldType}: Matched string item: "${item}" === "${displayValue}"`) - } - return matches + return item === displayValue } else if (item && typeof item === 'object' && 'filename' in item) { // It's a note object, match by filename - const matches = item.filename === displayValue - if (debugLogging && matches) { - console.log(`${fieldType}: Matched note item by filename: "${item.filename}" === "${displayValue}", title: "${item.title}"`) - } - return matches + return item.filename === displayValue } else if (item && typeof item === 'object' && 'id' in item) { // It's an object with an id property (event, space, etc.), match by id first const matchesById = item.id === displayValue if (matchesById) { - if (debugLogging) { - console.log(`${fieldType}: Matched item by id: "${item.id}" === "${displayValue}", title: "${item.title || ''}"`) - } return true } // If id doesn't match, also check display value as fallback // This handles cases where value is a display string (e.g., "Private") instead of id (e.g., "") const displayVal = getDisplayValue(item) - const matchesByDisplay = displayVal === displayValue - if (debugLogging && matchesByDisplay) { - console.log(`${fieldType}: Matched item by display value: "${displayVal}" === "${displayValue}", id: "${item.id || ''}"`) - } - return matchesByDisplay + return displayVal === displayValue } // For other object types, try to match by comparing getDisplayValue result // or by checking if the item itself is the value const displayVal = getDisplayValue(item) - const matches = item === displayValue || displayVal === displayValue - if (debugLogging && matches) { - console.log(`${fieldType}: Matched object item:`, item) - } - return matches + return item === displayValue || displayVal === displayValue }) if (foundItem) { // Use the display label from the found item - const originalDisplayValue = displayValue displayValue = getDisplayValue(foundItem) - if (debugLogging) { - console.log(`${fieldType}: Found item! Original value: "${originalDisplayValue}" -> Display value: "${displayValue}"`) - } - } else { - if (debugLogging) { - console.log(`${fieldType}: No item found for value "${value}", will display value directly`) - // Show a few examples of what we're searching through - if (items.length > 0) { - const examples = items.slice(0, 3).map((item: any) => { - if (typeof item === 'string') return item - if (item && typeof item === 'object' && 'filename' in item) return `{title: "${item.title}", filename: "${item.filename}"}` - return String(item) - }) - } - } } } @@ -744,12 +713,6 @@ export function SearchableChooser({ data-debug-items-count={items.length} data-debug-isloading={String(isLoading)} > - {debugLogging && - console.log( - `${fieldType}: Rendering dropdown, isOpen=${String(isOpen)}, isLoading=${String(isLoading)}, items.length=${items.length}, filteredItems.length=${ - filteredItems.length - }`, - )} {isLoading ? (
{ // Show all items if maxResults is undefined, otherwise limit to maxResults const itemsToShow = maxResults != null && maxResults > 0 ? filteredItems.slice(0, maxResults) : filteredItems - if (debugLogging) { - console.log(`${fieldType}: Rendering ${itemsToShow.length} options (filtered from ${filteredItems.length} total, maxResults=${maxResults || 'unlimited'})`) - } // Check if any items have icons or shortDescriptions (calculate once for all items) const hasIconsOrDescriptions = itemsToShow.some((item: any) => { @@ -798,14 +758,6 @@ export function SearchableChooser({ // For shorter items, let CSS handle truncation based on actual width const truncatedText = optionText.length > dropdownMaxLength ? truncateDisplay(optionText, dropdownMaxLength) : optionText const optionTitle = getOptionTitle(item) - if (debugLogging && index < 3) { - const jsTruncated = optionText.length > dropdownMaxLength - console.log( - `${fieldType}: Dropdown option[${index}]: original="${optionText}", length=${optionText.length}, truncated="${truncatedText}", length=${ - truncatedText.length - }, maxLength=${dropdownMaxLength}, jsTruncated=${String(jsTruncated)}`, - ) - } const optionIcon = getOptionIcon ? getOptionIcon(item) : null const optionColor = getOptionColor ? getOptionColor(item) : null let optionShortDesc = getOptionShortDescription ? getOptionShortDescription(item) : null diff --git a/np.Shared/src/installPluginVersion.js b/np.Shared/src/installPluginVersion.js new file mode 100644 index 000000000..8f8bbab00 --- /dev/null +++ b/np.Shared/src/installPluginVersion.js @@ -0,0 +1,498 @@ +// @flow +import pluginJson from '../plugin.json' +import { openReactWindow } from './NPReactLocal' +import { sendToHTMLWindow, sendBannerMessage } from '@helpers/HTMLView' +import { logDebug, logError, logInfo, logWarn, JSP, clo } from '@helpers/dev' + +/** + * Type for a plugin version release + */ +type PluginVersion = { + pluginId: string, + pluginName: string, + version: string, + releaseDate: string, + updatedAt: ?string, + isBeta: boolean, + tag: string, + isInstalled: boolean, + installedVersion: ?string, + releaseBody: ?string, + releaseUrl: ?string, +} + +/** + * Fetch all GitHub releases for NotePlan plugins (handles pagination) + * @returns {Promise>} Array of release objects from GitHub API + */ +async function fetchGitHubReleases(): Promise> { + try { + logDebug(pluginJson, 'fetchGitHubReleases: Fetching releases from GitHub API') + const allReleases: Array = [] + let page = 1 + const perPage = 100 + let hasMore = true + + while (hasMore) { + const url = `https://api.github.com/repos/NotePlan/plugins/releases?per_page=${perPage}&page=${page}` + const response = await fetch(url) + + if (!response) { + logError(pluginJson, `fetchGitHubReleases: Failed to fetch releases page ${page}. No response.`) + hasMore = false + } else { + // NotePlan's fetch returns a string, not a Response object + const releases = JSON.parse(response) + + if (!releases || releases.length === 0) { + // No more releases + hasMore = false + } else { + allReleases.push(...releases) + logDebug(pluginJson, `fetchGitHubReleases: Fetched page ${page}, ${releases.length} releases (total so far: ${allReleases.length})`) + + // If we got fewer than perPage, we're done + if (releases.length < perPage) { + hasMore = false + } else { + page++ + } + } + } + } + + logDebug(pluginJson, `fetchGitHubReleases: Found ${allReleases.length} total releases`) + return allReleases + } catch (error) { + logError(pluginJson, `fetchGitHubReleases: Error: ${JSP(error)}`) + return [] + } +} + +/** + * Extract plugin ID and version from a GitHub release tag + * Tags are in format: pluginId-v1.2.3 or pluginId-v1.2.3-beta + * @param {string} tag - The release tag + * @param {boolean} isPrerelease - Whether the GitHub release is marked as a prerelease + * @returns {?{pluginId: string, version: string, isBeta: boolean}} Parsed plugin info or null + */ +function parseReleaseTag(tag: string, isPrerelease: boolean = false): ?{ pluginId: string, version: string, isBeta: boolean } { + try { + // Tag format: pluginId-v1.2.3 or pluginId-v1.2.3-beta + const match = tag.match(/^(.+?)-v(.+)$/) + if (!match) return null + + const [, pluginId, versionPart] = match + // Check if version part contains beta/alpha/rc, or if GitHub marks it as prerelease + const hasBetaInTag = versionPart.includes('-beta') || versionPart.includes('-alpha') || versionPart.includes('-rc') + const isBeta = isPrerelease || hasBetaInTag + + // Clean version string (remove beta/alpha/rc suffixes for display) + const version = versionPart.replace(/-beta$/, '').replace(/-alpha$/, '').replace(/-rc\d+$/, '') + + return { pluginId, version, isBeta } + } catch (error) { + logError(pluginJson, `parseReleaseTag: Error parsing tag "${tag}": ${JSP(error)}`) + return null + } +} + +/** + * Get installed plugins and their versions + * @returns {Array<{id: string, version: string}>} Array of installed plugin info + */ +function getInstalledPlugins(): Array<{ id: string, version: string }> { + try { + const installed = DataStore.installedPlugins() + return installed.map((p) => ({ id: p.id, version: p.version })) + } catch (error) { + logError(pluginJson, `getInstalledPlugins: Error: ${JSP(error)}`) + return [] + } +} + +/** + * Process GitHub releases and group by plugin + * Also fetches plugin data from NotePlan's DataStore.listPlugins() for comparison + * @param {Array} releases - Raw GitHub releases + * @returns {Promise>} Processed plugin versions + */ +async function processReleases(releases: Array): Promise> { + const installedPlugins = getInstalledPlugins() + const versionMap = new Map>() + + // Get plugin data from NotePlan's DataStore (has more info like releaseUrl, etc.) + let allPlugins: Array = [] + try { + allPlugins = await DataStore.listPlugins(true,true,false) + logDebug(pluginJson, `processReleases: Fetched ${allPlugins.length} plugins from DataStore.listPlugins()`) + clo(allPlugins, `processReleases: allPlugins`) + throw new Error('test') + } catch (error) { + logWarn(pluginJson, `processReleases: Could not fetch plugin list: ${JSP(error)}`) + } + + // Find a matching plugin to compare data sources + let comparisonPlugin: ?any = null + let comparisonRelease: ?any = null + + // Process all releases + for (const release of releases) { + // Check if release is marked as prerelease in GitHub (indicates beta/alpha/rc) + const isPrerelease = release.prerelease === true + const parsed = parseReleaseTag(release.tag_name, isPrerelease) + if (!parsed) continue + + const { pluginId, version, isBeta } = parsed + + // Find matching plugin in NotePlan's data + const noteplanPlugin = allPlugins.find((p) => p.id === pluginId) + + // For comparison, pick the first matching plugin we find + if (!comparisonPlugin && noteplanPlugin) { + comparisonPlugin = noteplanPlugin + comparisonRelease = release + } + + const installed = installedPlugins.find((p) => p.id === pluginId) + + const pluginVersion: PluginVersion = { + pluginId, + pluginName: noteplanPlugin?.name || pluginId, // Use NotePlan's plugin name if available + version, + releaseDate: release.published_at || release.created_at || '', + updatedAt: release.updated_at || null, + isBeta, + tag: release.tag_name, + isInstalled: installed != null, + installedVersion: installed?.version || null, + releaseBody: release.body || null, + releaseUrl: release.html_url || null, + } + + // Group by plugin ID + if (!versionMap.has(pluginId)) { + versionMap.set(pluginId, []) + } + versionMap.get(pluginId)?.push(pluginVersion) + } + + // Log comparison of data sources for one matching plugin + if (comparisonPlugin && comparisonRelease) { + const parsed = parseReleaseTag(comparisonRelease.tag_name, comparisonRelease.prerelease === true) + if (parsed) { + clo(comparisonPlugin, `processReleases: NotePlan DataStore.listPlugins() data for plugin "${parsed.pluginId}":`) + clo(comparisonRelease, `processReleases: GitHub API release data for plugin "${parsed.pluginId}":`) + logDebug( + pluginJson, + `processReleases: Comparison - NotePlan has releaseUrl: ${comparisonPlugin.releaseUrl || 'none'}, GitHub has tag: ${comparisonRelease.tag_name}`, + ) + } + } + + // Flatten and add plugin names (already added above, but ensure all have names) + const result: Array = [] + for (const [pluginId, versions] of versionMap.entries()) { + const pluginInfo = allPlugins.find((p) => p.id === pluginId) + const pluginName = pluginInfo?.name || pluginId + + // Sort versions by date (newest first) + versions.sort((a, b) => new Date(b.releaseDate).getTime() - new Date(a.releaseDate).getTime()) + + // Ensure all versions have the correct plugin name + versions.forEach((v) => { + v.pluginName = pluginName + result.push(v) + }) + } + + // Sort by plugin name, then by version date + result.sort((a, b) => { + if (a.pluginName !== b.pluginName) { + return a.pluginName.localeCompare(b.pluginName) + } + return new Date(b.releaseDate).getTime() - new Date(a.releaseDate).getTime() + }) + + return result +} + +/** + * Command handler for installing a plugin version + * Called from React component via returnPluginCommand routing + * Supports both REQUEST pattern (with correlation ID) and regular action pattern + * @param {string} actionType - The action type (should be 'installPluginVersion') + * @param {any} data - Data object containing pluginId, tag, and optionally __requestType, __correlationId, __windowId + * @returns {Promise} Response object (or empty if REQUEST pattern - response sent via sendToHTMLWindow) + */ +export async function onPluginVersionInstall(actionType: string, data: any): Promise { + try { + logDebug(pluginJson, `onPluginVersionInstall: actionType="${actionType}", data=${JSP(data)}`) + + // Check if this is a REQUEST pattern message + const isRequest = data?.__requestType === 'REQUEST' + const correlationId = data?.__correlationId + const windowId = data?.__windowId || '' + + if (actionType === 'installPluginVersion' && data?.pluginId && data?.tag) { + const result = await handleInstallPluginVersion(data.pluginId, data.tag) + + // If REQUEST pattern, send response via sendToHTMLWindow + if (isRequest && correlationId && windowId) { + logDebug(pluginJson, `onPluginVersionInstall: Sending RESPONSE for correlationId="${correlationId}"`) + logDebug(pluginJson, `onPluginVersionInstall: Response data: ${JSP(result)}`) + + // Send banner message if there's an error + if (!result.success && windowId) { + try { + await sendBannerMessage(windowId, result.message || 'Installation failed', 'ERROR', 10000) + } catch (bannerError) { + logWarn(pluginJson, `onPluginVersionInstall: Failed to send banner message: ${JSP(bannerError)}`) + } + } + + await sendToHTMLWindow(windowId, 'RESPONSE', { + correlationId, + success: result.success, + data: result.success ? { + success: result.success, + installedVersion: result.installedVersion, + message: result.message + } : null, + error: result.success ? null : result.message, + }) + return {} // Empty return for REQUEST pattern + } + + // Regular action pattern - return result directly + return result + } + + logError(pluginJson, `onPluginVersionInstall: Invalid actionType or missing data`) + const errorResponse = { success: false, message: 'Invalid request' } + + // If REQUEST pattern, send error response + if (isRequest && correlationId && windowId) { + try { + await sendBannerMessage(windowId, 'Invalid request', 'ERROR', 10000) + } catch (bannerError) { + logWarn(pluginJson, `onPluginVersionInstall: Failed to send banner message: ${JSP(bannerError)}`) + } + await sendToHTMLWindow(windowId, 'RESPONSE', { + correlationId, + success: false, + data: null, + error: 'Invalid request', + }) + return {} + } + + return errorResponse + } catch (error) { + logError(pluginJson, `onPluginVersionInstall: Error: ${JSP(error)}`) + const errorResponse = { success: false, message: error.message || JSP(error) } + + // If REQUEST pattern, send error response + if (data?.__requestType === 'REQUEST' && data?.__correlationId && data?.__windowId) { + const errorMsg = error.message || JSP(error) + try { + await sendBannerMessage(data.__windowId, errorMsg, 'ERROR', 10000) + } catch (bannerError) { + logWarn(pluginJson, `onPluginVersionInstall: Failed to send banner message: ${JSP(bannerError)}`) + } + await sendToHTMLWindow(data.__windowId, 'RESPONSE', { + correlationId: data.__correlationId, + success: false, + data: null, + error: errorMsg, + }) + return {} + } + + return errorResponse + } +} + +/** + * Install a specific plugin version + * Internal function called by onPluginVersionInstall + * @param {string} pluginId - The plugin ID + * @param {string} tag - The release tag (e.g., "pluginId-v1.2.3") + * @returns {Promise<{success: boolean, message?: string}>} Success status + */ +async function handleInstallPluginVersion(pluginId: string, tag: string): Promise<{ success: boolean, message?: string, installedVersion?: string }> { + try { + logDebug(pluginJson, `handleInstallPluginVersion: START - Installing ${pluginId} version ${tag}`) + + // Get current installed version before installation + const installedBefore = DataStore.installedPlugins() + const pluginBefore = installedBefore.find((p) => p.id === pluginId) + const versionBefore = pluginBefore?.version || 'not installed' + logDebug(pluginJson, `handleInstallPluginVersion: Current installed version before install: ${versionBefore}`) + + // Extract version from tag (e.g., "jgclark.Dashboard-v2.4.0.b14-beta" -> "2.4.0.b14") + const tagVersionMatch = tag.match(/-v(.+?)(?:-beta|-alpha|-rc)?$/i) + const requestedVersion = tagVersionMatch ? tagVersionMatch[1] : tag + logDebug(pluginJson, `handleInstallPluginVersion: Extracted requested version from tag: ${requestedVersion}`) + + // Normalize version for matching (handles variations like "2.4.0.b15" vs "2.4.0-b15") + const normalizeVersion = (ver: string) => (ver || '').toLowerCase().replace(/[-_]/g, '.').trim() + const normalizedRequested = normalizeVersion(requestedVersion) + logDebug(pluginJson, `handleInstallPluginVersion: Normalized requested version: ${normalizedRequested}`) + + // Fetch all available plugins and find one that matches both pluginId AND version + // Note: DataStore.listPlugins() may only return the latest version, not all versions + const plugins = await DataStore.listPlugins(true, true, false) + logDebug(pluginJson, `handleInstallPluginVersion: Found ${plugins?.length || 0} available plugins`) + + // Log all versions of the requested plugin for debugging + const matchingPlugins = plugins?.filter(p => p.id === pluginId) || [] + logDebug(pluginJson, `handleInstallPluginVersion: Found ${matchingPlugins.length} plugin(s) with id "${pluginId}":`) + matchingPlugins.forEach((p, idx) => { + logDebug(pluginJson, `handleInstallPluginVersion: [${idx}] id=${p.id}, version=${p.version}, name=${p.name || 'unknown'}`) + }) + + // Search for plugin with matching ID and version + let plugin = null + for (const p of plugins || []) { + if (p.id === pluginId) { + const normalizedPluginVersion = normalizeVersion(p.version || '') + logDebug(pluginJson, `handleInstallPluginVersion: Checking plugin ${p.id} version ${p.version} (normalized: ${normalizedPluginVersion}) against requested ${requestedVersion} (normalized: ${normalizedRequested})`) + + // Match if normalized versions are equal or one contains the other + const exactMatch = normalizedPluginVersion === normalizedRequested + const containsMatch = normalizedPluginVersion.includes(normalizedRequested) || normalizedRequested.includes(normalizedPluginVersion) + logDebug(pluginJson, `handleInstallPluginVersion: exactMatch: ${exactMatch}, containsMatch: ${containsMatch}`) + + if (exactMatch || containsMatch) { + plugin = p + logDebug(pluginJson, `handleInstallPluginVersion: Found matching plugin: id=${plugin.id}, version=${plugin.version}, name=${plugin.name}`) + break + } + } + } + + if (!plugin) { + const availableVersions = matchingPlugins.map(p => p.version).join(', ') || 'none' + const message = `Could not find plugin "${pluginId}" version "${requestedVersion}" to install. NotePlan's API only shows the latest version. Available in NotePlan API: ${availableVersions}. You may need to install this version manually from GitHub.` + logError(pluginJson, message) + logDebug(pluginJson, `handleInstallPluginVersion: NotePlan's listPlugins() typically only returns the latest version of each plugin. To install older versions, we would need to download directly from GitHub releases.`) + return { success: false, message } + } + + logDebug(pluginJson, `handleInstallPluginVersion: Using plugin object: id=${plugin.id}, version=${plugin.version}, name=${plugin.name}`) + + // Note: DataStore.installPlugin might install the latest version, not a specific one + // This is a limitation we'll need to work with for now + logDebug(pluginJson, `handleInstallPluginVersion: Calling DataStore.installPlugin(${plugin.id}, true)`) + const installedPlugin = await DataStore.installPlugin(plugin, true) + logDebug(pluginJson, `handleInstallPluginVersion: DataStore.installPlugin returned: ${JSP(installedPlugin)}`) + + // Refresh installed plugins list to get actual installed version + const installedAfter = DataStore.installedPlugins() + const pluginAfter = installedAfter.find((p) => p.id === pluginId) + const versionAfter = pluginAfter?.version || 'unknown' + logDebug(pluginJson, `handleInstallPluginVersion: Installed version after install: ${versionAfter}`) + + if (versionAfter === versionBefore) { + logWarn(pluginJson, `handleInstallPluginVersion: Version unchanged after install. Before: ${versionBefore}, After: ${versionAfter}, Requested: ${requestedVersion}`) + } else { + logInfo(pluginJson, `handleInstallPluginVersion: Version changed. Before: ${versionBefore}, After: ${versionAfter}, Requested: ${requestedVersion}`) + } + + // Check if the installed version matches what was requested (normalizedRequested was already calculated above) + const normalizedAfter = normalizeVersion(versionAfter) + const versionMatches = normalizedAfter === normalizedRequested || normalizedAfter.includes(normalizedRequested) || normalizedRequested.includes(normalizedAfter) + + if (!versionMatches) { + logWarn(pluginJson, `handleInstallPluginVersion: Installed version (${versionAfter}) does not match requested version (${requestedVersion}). NotePlan may have installed the latest version instead.`) + } + + return { + success: true, + installedVersion: versionAfter, + message: versionMatches + ? `Installed ${pluginId} version ${versionAfter}` + : `Installed ${pluginId} version ${versionAfter} (requested ${requestedVersion}, but NotePlan may have installed the latest version instead)` + } + } catch (error) { + const message = `Error installing plugin: ${error.message || JSP(error)}` + logError(pluginJson, `handleInstallPluginVersion: ERROR - ${message}`) + return { success: false, message } + } +} + +/** + * Main entry point for "Install Plugin Version" command + * Shows a dialog with available plugin versions in a table + */ +export async function installPluginVersion(): Promise { + try { + logDebug(pluginJson, 'installPluginVersion: Starting') + + // Fetch releases + const releases = await fetchGitHubReleases() + if (releases.length === 0) { + logError(pluginJson, 'installPluginVersion: No plugin releases found. This might be a network issue.') + return + } + + // Process releases + const pluginVersions = await processReleases(releases) + if (pluginVersions.length === 0) { + logError(pluginJson, 'installPluginVersion: No valid plugin versions found.') + return + } + + logDebug(pluginJson, `installPluginVersion: Found ${pluginVersions.length} plugin versions`) + + // Show React dialog with table + showPluginVersionsDialog(pluginVersions) + } catch (error) { + logError(pluginJson, `installPluginVersion: Error: ${JSP(error)}`) + } +} + +/** + * Show the plugin versions dialog using React + * @param {Array} versions - Array of plugin versions to display + */ +function showPluginVersionsDialog(versions: Array): void { + try { + showPluginVersionsDialogReact(versions) + } catch (error) { + logError(pluginJson, `showPluginVersionsDialog: Error: ${JSP(error)}`) + } +} + +/** + * Show plugin versions using React component in main window + * @param {Array} versions - Array of plugin versions + */ +function showPluginVersionsDialogReact(versions: Array): void { + // Set up the global data for the React window + // Note: componentPath must point to a built bundle file, not the source .jsx + // The bundle needs to be built first using: node np.Shared/src/react/support/performRollup.node.js + const windowId = 'plugin-versions-installer' + const globalData = { + componentPath: `../np.Shared/react.c.PluginVersionsEntry.bundle.dev.js`, + versions: versions, + pluginId: pluginJson['plugin.id'], + startTime: new Date(), + ENV_MODE: 'development', + // Set returnPluginCommand so React can send messages back to this plugin + returnPluginCommand: { id: pluginJson['plugin.id'], command: 'onPluginVersionInstall' }, + // Set pluginData with windowId so React can send it back in requests + pluginData: { + windowId: windowId, + }, + } + + logDebug(pluginJson, `showPluginVersionsDialogReact: Opening window with ${versions.length} versions`) + + // Use openReactWindow to display the React component (since we're in np.Shared, we can call it directly) + openReactWindow(globalData, { + windowTitle: 'Install Plugin Version', + customId: windowId, + }) +} diff --git a/np.Shared/src/requestHandlers/getFrontmatterKeyValues.js b/np.Shared/src/requestHandlers/getFrontmatterKeyValues.js index 4fb005ad7..cd6f7415d 100644 --- a/np.Shared/src/requestHandlers/getFrontmatterKeyValues.js +++ b/np.Shared/src/requestHandlers/getFrontmatterKeyValues.js @@ -56,7 +56,15 @@ export async function getFrontmatterKeyValues( const values = await getValuesForFrontmatterTag(params.frontmatterKey, noteType, caseSensitive, folderString, fullPathMatch) // Convert all values to strings (frontmatter values can be various types) - const stringValues = values.map((v: any) => String(v)) + let stringValues = values.map((v: any) => String(v)) + + // Filter out templating syntax values (containing "<%") - these are template code, not actual values + // This prevents templating errors when forms load and process frontmatter + const beforeFilterCount = stringValues.length + stringValues = stringValues.filter((v: string) => !v.includes('<%')) + if (beforeFilterCount !== stringValues.length) { + logDebug(pluginJson, `[np.Shared/requestHandlers] getFrontmatterKeyValues: Filtered out ${beforeFilterCount - stringValues.length} templating syntax values`) + } const totalElapsed: number = Date.now() - startTime logDebug(pluginJson, `[np.Shared/requestHandlers] getFrontmatterKeyValues COMPLETE: totalElapsed=${totalElapsed}ms, found=${stringValues.length} values for key "${params.frontmatterKey}"`)