diff --git a/helpers/NPFrontMatter.js b/helpers/NPFrontMatter.js index 21acf44b8..666f9beab 100644 --- a/helpers/NPFrontMatter.js +++ b/helpers/NPFrontMatter.js @@ -757,6 +757,7 @@ export type FrontMatterDocumentObject = { attributes: { [string]: string }, body */ export function getSanitizedFmParts(noteText: string, removeTemplateTagsInFM?: boolean = false): FrontMatterDocumentObject { let fmData = { attributes: {}, body: noteText, frontmatter: '' } //default + // we need to pre-process the text to sanitize it instead of running fm because we need to // preserve #hashtags, @mentions etc. and fm will blank those lines out as comments const sanitizedText = _sanitizeFrontmatterText(noteText || '', removeTemplateTagsInFM) @@ -783,28 +784,36 @@ export function getSanitizedFmParts(noteText: string, removeTemplateTagsInFM?: b if (lines[i].trim() === '---') { // Extract everything between the first and second --- as frontmatter const frontmatterLines = lines.slice(1, i) - const attributes: { [string]: string } = {} - - // Parse the frontmatter lines manually when fm library fails - // This handles both cases: template tags and rendered template output - for (const line of frontmatterLines) { - const trimmedLine = line.trim() - if (trimmedLine && !trimmedLine.startsWith('#')) { - // Skip empty lines and comments - const colonIndex = trimmedLine.indexOf(':') - if (colonIndex > 0) { - const key = trimmedLine.substring(0, colonIndex).trim() - const value = trimmedLine.substring(colonIndex + 1).trim() - // Remove quotes if present, but always return as string - const cleanValue = value.replace(/^["'](.*)["']$/, '$1') - attributes[key] = String(cleanValue) + const frontmatterContent = frontmatterLines.join('\n') + + // Only treat as frontmatter if it's valid YAML + if (isValidYamlContent(frontmatterContent)) { + const attributes: { [string]: string } = {} + + // Parse the frontmatter lines manually when fm library fails + // This handles both cases: template tags and rendered template output + for (const line of frontmatterLines) { + const trimmedLine = line.trim() + if (trimmedLine && !trimmedLine.startsWith('#')) { + // Skip empty lines and comments + const colonIndex = trimmedLine.indexOf(':') + if (colonIndex > 0) { + const key = trimmedLine.substring(0, colonIndex).trim() + const value = trimmedLine.substring(colonIndex + 1).trim() + // Remove quotes if present, but always return as string + const cleanValue = value.replace(/^["'](.*)["']$/, '$1') + attributes[key] = String(cleanValue) + } } } - } - // Extract everything after the second --- as the body - const body = lines.slice(i + 1).join('\n') - fmData = { attributes: attributes, body: body, frontmatter: '' } + // Extract everything after the second --- as the body + const body = lines.slice(i + 1).join('\n') + fmData = { attributes: attributes, body: body, frontmatter: '' } + } else { + // Not valid YAML, treat the entire content as body + fmData = { attributes: {}, body: noteText, frontmatter: '' } + } break } } @@ -1289,6 +1298,169 @@ export async function getValuesForFrontmatterTag( return Array.from(uniqueValuesSet) } +/** + * Analyze a template's structure to determine various characteristics + * @param {string} templateData - The template content to analyze + * @returns {Object} Analysis results with the following properties: + * - hasNewNoteTitle: boolean - Whether template has 'newNoteTitle' in frontmatter + * - hasOutputFrontmatter: boolean - Whether template has frontmatter in the output note (after the template frontmatter) + * - hasOutputTitle: boolean - Whether template has a 'title' field in the output note's frontmatter + * - hasInlineTitle: boolean - Whether template has an inline title (first non-frontmatter line starts with single #) + * - templateFrontmatter: Object - The template's frontmatter attributes + * - outputFrontmatter: Object - The output note's frontmatter attributes (if any) + * - bodyContent: string - The template body content (after template frontmatter) + * - inlineTitleText: string - The text of the inline title (if any) + */ +export function analyzeTemplateStructure(templateData: string): { + hasNewNoteTitle: boolean, + hasOutputFrontmatter: boolean, + hasOutputTitle: boolean, + hasInlineTitle: boolean, + templateFrontmatter: { [string]: string }, + outputFrontmatter: { [string]: string }, + bodyContent: string, + inlineTitleText: string, +} { + try { + logDebug('analyzeTemplateStructure', `Analyzing template structure for template with ${templateData.length} characters`) + + // Initialize return object + const result = { + hasNewNoteTitle: false, + hasOutputFrontmatter: false, + hasOutputTitle: false, + hasInlineTitle: false, + templateFrontmatter: {}, + outputFrontmatter: {}, + bodyContent: '', + inlineTitleText: '', + } + + // Manually extract template frontmatter and body to handle malformed frontmatter + const lines = templateData.split('\n') + let templateFrontmatterEnd = -1 + + // Find the end of template frontmatter (first --- block) + if (lines.length >= 2 && lines[0].trim() === '---') { + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === '---') { + templateFrontmatterEnd = i + break + } + } + } + + if (templateFrontmatterEnd > 0) { + // Extract template frontmatter content + const frontmatterLines = lines.slice(1, templateFrontmatterEnd) + const frontmatterContent = frontmatterLines.join('\n') + + // Validate that the content between --- markers is actually valid YAML + if (isValidYamlContent(frontmatterContent)) { + const attributes: { [string]: string } = {} + + // Parse the frontmatter lines manually + for (const line of frontmatterLines) { + const trimmedLine = line.trim() + if (trimmedLine) { + // Skip empty lines + const colonIndex = trimmedLine.indexOf(':') + if (colonIndex > 0) { + const key = trimmedLine.substring(0, colonIndex).trim() + const value = trimmedLine.substring(colonIndex + 1).trim() + // Remove quotes if present, but always return as string + const cleanValue = value.replace(/^["'](.*)["']$/, '$1') + attributes[key] = String(cleanValue) + } + } + } + + result.templateFrontmatter = attributes + result.bodyContent = lines.slice(templateFrontmatterEnd + 1).join('\n') + logDebug('analyzeTemplateStructure', `Extracted body content (${result.bodyContent.length} chars): "${result.bodyContent.substring(0, 200)}..."`) + } else { + // Not valid YAML, treat the entire content as body + result.templateFrontmatter = {} + result.bodyContent = templateData + logDebug( + 'analyzeTemplateStructure', + `Invalid template frontmatter found, using whole content as body (${result.bodyContent.length} chars): "${result.bodyContent.substring(0, 200)}..."`, + ) + } + } else { + // No template frontmatter, use the whole content as body + result.bodyContent = templateData + logDebug( + 'analyzeTemplateStructure', + `No template frontmatter found, using whole content as body (${result.bodyContent.length} chars): "${result.bodyContent.substring(0, 200)}..."`, + ) + } + + // Check for newNoteTitle in template frontmatter + result.hasNewNoteTitle = 'newNoteTitle' in result.templateFrontmatter + + // Check for output frontmatter in the body content + if (result.bodyContent) { + // Convert -- separators to --- for processing (like the templating system does) + let processedBodyContent = result.bodyContent + const bodyLines = processedBodyContent.split('\n') + const startBlock = bodyLines.indexOf('--') + const endBlock = startBlock >= 0 ? bodyLines.indexOf('--', startBlock + 1) : -1 + + if (startBlock >= 0 && endBlock >= 0) { + bodyLines[startBlock] = '---' + bodyLines[endBlock] = '---' + processedBodyContent = bodyLines.join('\n') + } + + // Use the isValidYamlContent function to validate that this is actually frontmatter + if (isValidYamlContent(processedBodyContent)) { + const outputParts = getSanitizedFmParts(processedBodyContent) + result.outputFrontmatter = outputParts.attributes || {} + result.hasOutputFrontmatter = Object.keys(result.outputFrontmatter).length > 0 + result.hasOutputTitle = 'title' in result.outputFrontmatter + } else { + // Not valid frontmatter, so no output frontmatter + result.outputFrontmatter = {} + result.hasOutputFrontmatter = false + result.hasOutputTitle = false + } + } + + // Check for inline title in the body content + const inlineTitleResult = detectInlineTitleRobust(result.bodyContent) + result.hasInlineTitle = inlineTitleResult.hasInlineTitle + result.inlineTitleText = inlineTitleResult.inlineTitleText + + logDebug( + 'analyzeTemplateStructure', + `Analysis complete: + - hasNewNoteTitle: ${String(result.hasNewNoteTitle)} + - hasOutputFrontmatter: ${String(result.hasOutputFrontmatter)} + - hasOutputTitle: ${String(result.hasOutputTitle)} + - hasInlineTitle: ${String(result.hasInlineTitle)} + - templateFrontmatter keys: ${Object.keys(result.templateFrontmatter).join(', ')} + - outputFrontmatter keys: ${Object.keys(result.outputFrontmatter).join(', ')} + - bodyContent length: ${result.bodyContent.length} + - inlineTitleText: "${result.inlineTitleText}"`, + ) + + return result + } catch (error) { + logError('analyzeTemplateStructure', JSP(error)) + return { + hasNewNoteTitle: false, + hasOutputFrontmatter: false, + hasOutputTitle: false, + hasInlineTitle: false, + templateFrontmatter: {}, + outputFrontmatter: {}, + bodyContent: '', + inlineTitleText: '', + } + } +} + /** * Helper function to get the folder path array from a note's filename * @param {string} filename - The note's filename @@ -1331,3 +1503,214 @@ function filterNotesByFolder(notes: Array, folderString?: string, fullPat } }) } + +/** + * Example usage of analyzeTemplateStructure function + * This demonstrates how to use the function with different template structures + */ +export function demonstrateTemplateAnalysis(): void { + // Example a) Template with newNoteTitle + const templateA = `--- +title: my template +newNoteTitle: foo +---` + + // Example b) Template with frontmatter in output note + const templateB = `--- +title: my template +--- +-- +prop: this is in the resulting note +--` + + // Example c) Template with title field in resulting note + const templateC = `--- +title: this is the template's title +--- +-- +title: this is in the resulting note's title +--` + + // Example d) Template with inline title + const templateD = `--- +title: my template title +--- +-- +some: frontmatter +-- +# an inline title` + + logDebug('demonstrateTemplateAnalysis', '=== Example A: Template with newNoteTitle ===') + const analysisA = analyzeTemplateStructure(templateA) + clo(analysisA, 'Analysis A') + + logDebug('demonstrateTemplateAnalysis', '=== Example B: Template with output frontmatter ===') + const analysisB = analyzeTemplateStructure(templateB) + clo(analysisB, 'Analysis B') + + logDebug('demonstrateTemplateAnalysis', '=== Example C: Template with output title ===') + const analysisC = analyzeTemplateStructure(templateC) + clo(analysisC, 'Analysis C') + + logDebug('demonstrateTemplateAnalysis', '=== Example D: Template with inline title ===') + const analysisD = analyzeTemplateStructure(templateD) + clo(analysisD, 'Analysis D') +} + +/** + * Robust helper function to detect inline title in template body content + * Handles malformed frontmatter and multiple consecutive separators + * @param {string} bodyContent - The template body content + * @returns {{hasInlineTitle: boolean, inlineTitleText: string}} + */ +function detectInlineTitleRobust(bodyContent: string): { hasInlineTitle: boolean, inlineTitleText: string } { + if (!bodyContent) { + logDebug('detectInlineTitleRobust', 'No body content provided') + return { hasInlineTitle: false, inlineTitleText: '' } + } + + const lines = bodyContent.split('\n') + logDebug('detectInlineTitleRobust', `Processing ${lines.length} lines of body content`) + + // Check if the first line starts with frontmatter separators + if (lines.length >= 2 && lines[0].trim().startsWith('--')) { + // Find the end of the frontmatter block + let frontmatterEnd = -1 + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim().startsWith('--')) { + frontmatterEnd = i + break + } + } + + if (frontmatterEnd > 0) { + // Extract the frontmatter content and check if it's valid + const frontmatterContent = lines.slice(1, frontmatterEnd).join('\n') + const isValidFrontmatter = isValidYamlContent(frontmatterContent) + logDebug('detectInlineTitleRobust', `Frontmatter content: "${frontmatterContent}"`) + logDebug('detectInlineTitleRobust', `Is valid frontmatter: ${String(isValidFrontmatter)}`) + + if (isValidFrontmatter) { + // Valid frontmatter - look for title in the first line after the block + if (frontmatterEnd + 1 < lines.length) { + const firstLineAfterFrontmatter = lines[frontmatterEnd + 1].trim() + if (firstLineAfterFrontmatter && firstLineAfterFrontmatter.match(/^#{1,6}\s+/)) { + const titleText = firstLineAfterFrontmatter.replace(/^#{1,6}\s+/, '').trim() + logDebug('detectInlineTitleRobust', `Found inline title after valid frontmatter: "${titleText}"`) + return { + hasInlineTitle: true, + inlineTitleText: titleText, + } + } + } + } else { + // Invalid frontmatter - treat the first line as the title + const firstLine = lines[0].trim() + if (firstLine && firstLine.match(/^#{1,6}\s+/)) { + const titleText = firstLine.replace(/^#{1,6}\s+/, '').trim() + logDebug('detectInlineTitleRobust', `Found inline title in invalid frontmatter block: "${titleText}"`) + return { + hasInlineTitle: true, + inlineTitleText: titleText, + } + } + // If invalid frontmatter and first line is not a heading, no inline title + logDebug('detectInlineTitleRobust', 'Invalid frontmatter block, no inline title found') + return { hasInlineTitle: false, inlineTitleText: '' } + } + } + } else { + // No frontmatter - check the first non-empty line + for (let i = 0; i < lines.length; i++) { + const trimmedLine = lines[i].trim() + if (trimmedLine === '') continue + + if (trimmedLine.match(/^#{1,6}\s+/)) { + const titleText = trimmedLine.replace(/^#{1,6}\s+/, '').trim() + logDebug('detectInlineTitleRobust', `Found inline title in first line: "${titleText}"`) + return { + hasInlineTitle: true, + inlineTitleText: titleText, + } + } + break // Stop at first non-empty line + } + } + + logDebug('detectInlineTitleRobust', 'No inline title found') + return { hasInlineTitle: false, inlineTitleText: '' } +} + +/** + * Extract the note title from a template using analyzeTemplateStructure + * Checks for newNoteTitle in frontmatter first, then falls back to inline title if newNoteTitle is not found + * @param {string} templateData - The template content to analyze + * @returns {string} - The note title to use, or empty string if none found + */ +export function getNoteTitleFromTemplate(templateData: string): string { + try { + logDebug('getNoteTitleFromTemplate', `Analyzing template with ${templateData.length} characters`) + const analysis = analyzeTemplateStructure(templateData) + + logDebug( + 'getNoteTitleFromTemplate', + `Analysis results: + - hasNewNoteTitle: ${String(analysis.hasNewNoteTitle)} + - hasInlineTitle: ${String(analysis.hasInlineTitle)} + - templateFrontmatter keys: ${Object.keys(analysis.templateFrontmatter).join(', ')} + - inlineTitleText: "${analysis.inlineTitleText}"`, + ) + + // First check for newNoteTitle in template frontmatter + if (analysis.hasNewNoteTitle && analysis.templateFrontmatter.newNoteTitle) { + logDebug('getNoteTitleFromTemplate', `Found newNoteTitle in template frontmatter: "${analysis.templateFrontmatter.newNoteTitle}"`) + return analysis.templateFrontmatter.newNoteTitle + } + + // If no newNoteTitle found, check for inline title + if (analysis.hasInlineTitle && analysis.inlineTitleText) { + logDebug('getNoteTitleFromTemplate', `Found inline title: "${analysis.inlineTitleText}"`) + return analysis.inlineTitleText + } + + logDebug('getNoteTitleFromTemplate', 'No note title found in template') + return '' + } catch (error) { + logError('getNoteTitleFromTemplate', JSP(error)) + return '' + } +} + +/** + * Check if content between --- markers is valid YAML-like content + * @param {string} content - The content to validate + * @returns {boolean} - Whether the content is valid YAML-like frontmatter + */ +export function isValidYamlContent(content: string): boolean { + if (!content || content.trim() === '') return false + + const lines = content.split('\n') + let hasValidYamlLine = false + + for (const line of lines) { + const trimmedLine = line.trim() + if (trimmedLine === '') continue // Skip empty lines + + // Check for valid YAML patterns: + // 1. key: value (with optional spaces) + // 2. key: (empty value) + // 3. - item (list item) + const yamlPatterns = [ + /^[a-zA-Z_][a-zA-Z0-9_]*\s*:\s*/, // key: value + /^[a-zA-Z_][a-zA-Z0-9_]*\s*:$/, // key: (empty value) + /^\s*-\s+/, // - item (list item) + ] + + const isValidLine = yamlPatterns.some((pattern) => pattern.test(trimmedLine)) + if (isValidLine) { + hasValidYamlLine = true + } + } + + return hasValidYamlLine +} diff --git a/helpers/__tests__/NPFrontMatter/NPFrontMatter.analyzeTemplateStructure.test.js b/helpers/__tests__/NPFrontMatter/NPFrontMatter.analyzeTemplateStructure.test.js new file mode 100644 index 000000000..ff08d80a4 --- /dev/null +++ b/helpers/__tests__/NPFrontMatter/NPFrontMatter.analyzeTemplateStructure.test.js @@ -0,0 +1,694 @@ +// @flow +/* global describe, test, expect */ + +import { analyzeTemplateStructure, getNoteTitleFromTemplate } from '../../NPFrontMatter' + +describe('analyzeTemplateStructure', () => { + describe('newNoteTitle detection', () => { + test('should detect newNoteTitle in template frontmatter', () => { + const template = `--- +title: my template +newNoteTitle: foo +---` + + const result = analyzeTemplateStructure(template) + + expect(result.hasNewNoteTitle).toBe(true) + expect(result.templateFrontmatter.newNoteTitle).toBe('foo') + }) + + test('should not detect newNoteTitle when not present', () => { + const template = `--- +title: my template +--- +# Some content` + + const result = analyzeTemplateStructure(template) + + expect(result.hasNewNoteTitle).toBe(false) + expect(result.templateFrontmatter.newNoteTitle).toBeUndefined() + }) + }) + + describe('getNoteTitleFromTemplate', () => { + test('should return newNoteTitle when present in frontmatter', () => { + const template = `--- +title: my template +newNoteTitle: foo +---` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('foo') + }) + + test('should return inline title when newNoteTitle is not present', () => { + const template = `--- +title: my template +--- +# This is my inline title` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('This is my inline title') + }) + + test('should return newNoteTitle when both newNoteTitle and inline title are present', () => { + const template = `--- +title: my template +newNoteTitle: foo +--- +# This is my inline title` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('foo') + }) + + test('should return empty string when no title is found', () => { + const template = `--- +title: my template +--- +Some content without title` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('') + }) + + test('should handle template with output frontmatter and inline title', () => { + const template = `--- +title: my template +--- +-- +prop: value +-- +# This is my inline title` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('This is my inline title') + }) + }) + + describe('output frontmatter detection', () => { + test('should detect output frontmatter with --- separators', () => { + const template = `--- +title: my template +--- +--- +prop: this is in the resulting note +--- +# Some content` + + const result = analyzeTemplateStructure(template) + + expect(result.hasOutputFrontmatter).toBe(true) + expect(result.outputFrontmatter.prop).toBe('this is in the resulting note') + }) + + test('should detect output frontmatter with -- separators', () => { + const template = `--- +title: my template +--- +-- +prop: this is in the resulting note +-- +# Some content` + + const result = analyzeTemplateStructure(template) + + expect(result.hasOutputFrontmatter).toBe(true) + expect(result.outputFrontmatter.prop).toBe('this is in the resulting note') + }) + + test('should not detect output frontmatter when not present', () => { + const template = `--- +title: my template +--- +# Some content` + + const result = analyzeTemplateStructure(template) + + expect(result.hasOutputFrontmatter).toBe(false) + expect(Object.keys(result.outputFrontmatter)).toHaveLength(0) + }) + }) + + describe('output title detection', () => { + test('should detect title in output frontmatter', () => { + const template = `--- +title: this is the template's title +--- +-- +title: this is in the resulting note's title +-- +# Some content` + + const result = analyzeTemplateStructure(template) + + expect(result.hasOutputTitle).toBe(true) + expect(result.outputFrontmatter.title).toBe("this is in the resulting note's title") + }) + + test('should not detect output title when not present', () => { + const template = `--- +title: my template +--- +-- +prop: some value +-- +# Some content` + + const result = analyzeTemplateStructure(template) + + expect(result.hasOutputTitle).toBe(false) + expect(result.outputFrontmatter.title).toBeUndefined() + }) + }) + + describe('inline title detection', () => { + test('should detect inline title after template frontmatter only', () => { + const template = `--- +title: template title +--- +# this is my inline title` + + const result = analyzeTemplateStructure(template) + + expect(result.hasInlineTitle).toBe(true) + expect(result.inlineTitleText).toBe('this is my inline title') + }) + + test('should detect inline title after output frontmatter with --- separators', () => { + const template = `--- +title: template title +--- +--- +note: frontmatter +--- +# inline title` + + const result = analyzeTemplateStructure(template) + + expect(result.hasInlineTitle).toBe(true) + expect(result.inlineTitleText).toBe('inline title') + }) + + test('should detect inline title after output frontmatter with -- separators', () => { + const template = `--- +title: template title +--- +-- +note: frontmatter +-- +# inline title` + + const result = analyzeTemplateStructure(template) + + expect(result.hasInlineTitle).toBe(true) + expect(result.inlineTitleText).toBe('inline title') + }) + + test('should detect inline title with multiple frontmatter fields', () => { + const template = `--- +title: template title +--- +-- +field1: value1 +field2: value2 +field3: value3 +-- +# inline title` + + const result = analyzeTemplateStructure(template) + + expect(result.hasInlineTitle).toBe(true) + expect(result.inlineTitleText).toBe('inline title') + }) + + test('should not detect inline title when first non-frontmatter line is not a title', () => { + const template = `--- +title: template title +--- +-- +note: frontmatter +-- +This is not a title +# This title comes later` + + const result = analyzeTemplateStructure(template) + + expect(result.hasInlineTitle).toBe(false) + expect(result.inlineTitleText).toBe('') + }) + + test('should not detect inline title when no content after frontmatter', () => { + const template = `--- +title: template title +--- +-- +note: frontmatter +--` + + const result = analyzeTemplateStructure(template) + + expect(result.hasInlineTitle).toBe(false) + expect(result.inlineTitleText).toBe('') + }) + + test('should detect ## as inline title', () => { + const template = `--- +title: my template +--- +## This is my H2 title` + + const result = analyzeTemplateStructure(template) + + expect(result.hasInlineTitle).toBe(true) + expect(result.inlineTitleText).toBe('This is my H2 title') + }) + + test('should not detect inline title inside frontmatter', () => { + const template = `--- +title: template title +--- +-- +note: frontmatter +# This title is inside frontmatter +-- +# This is the real inline title` + + const result = analyzeTemplateStructure(template) + + expect(result.hasInlineTitle).toBe(true) + expect(result.inlineTitleText).toBe('This is the real inline title') + }) + }) + + describe('inline title detection with different heading levels', () => { + test('should detect H1 inline title', () => { + const template = `--- +title: my template +--- +# This is my H1 title` + + const result = analyzeTemplateStructure(template) + + expect(result.hasInlineTitle).toBe(true) + expect(result.inlineTitleText).toBe('This is my H1 title') + }) + + test('should detect H2 inline title', () => { + const template = `--- +title: my template +--- +## This is my H2 title` + + const result = analyzeTemplateStructure(template) + + expect(result.hasInlineTitle).toBe(true) + expect(result.inlineTitleText).toBe('This is my H2 title') + }) + + test('should detect H3 inline title', () => { + const template = `--- +title: my template +--- +### This is my H3 title` + + const result = analyzeTemplateStructure(template) + + expect(result.hasInlineTitle).toBe(true) + expect(result.inlineTitleText).toBe('This is my H3 title') + }) + + test('should detect H6 inline title', () => { + const template = `--- +title: my template +--- +###### This is my H6 title` + + const result = analyzeTemplateStructure(template) + + expect(result.hasInlineTitle).toBe(true) + expect(result.inlineTitleText).toBe('This is my H6 title') + }) + + test('should not detect subheading as inline title', () => { + const template = `--- +title: my template +--- +# Main title +## This is a subheading` + + const result = analyzeTemplateStructure(template) + + expect(result.hasInlineTitle).toBe(true) + expect(result.inlineTitleText).toBe('Main title') + }) + + test('should detect first heading when multiple headings exist', () => { + const template = `--- +title: my template +--- +## First heading +### Second heading +#### Third heading` + + const result = analyzeTemplateStructure(template) + + expect(result.hasInlineTitle).toBe(true) + expect(result.inlineTitleText).toBe('First heading') + }) + }) + + describe('getNoteTitleFromTemplate with different heading levels', () => { + test('should return H2 title when newNoteTitle is not present', () => { + const template = `--- +title: my template +--- +## This is my H2 title` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('This is my H2 title') + }) + + test('should return H3 title when newNoteTitle is not present', () => { + const template = `--- +title: my template +--- +### This is my H3 title` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('This is my H3 title') + }) + + test('should prioritize newNoteTitle over H2 inline title', () => { + const template = `--- +title: my template +newNoteTitle: foo +--- +## This is my H2 title` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('foo') + }) + }) + + describe('complex combinations', () => { + test('should handle template with all features', () => { + const template = `--- +title: template title +newNoteTitle: generated title +--- +-- +outputTitle: output title +outputField: output value +-- +# This is the inline title` + + const result = analyzeTemplateStructure(template) + + expect(result.hasNewNoteTitle).toBe(true) + expect(result.hasOutputFrontmatter).toBe(true) + expect(result.hasOutputTitle).toBe(false) // No 'title' field in output + expect(result.hasInlineTitle).toBe(true) + expect(result.templateFrontmatter.newNoteTitle).toBe('generated title') + expect(result.outputFrontmatter.outputTitle).toBe('output title') + expect(result.outputFrontmatter.outputField).toBe('output value') + expect(result.inlineTitleText).toBe('This is the inline title') + }) + + test('should handle template with only template frontmatter', () => { + const template = `--- +title: template title +field1: value1 +field2: value2 +---` + + const result = analyzeTemplateStructure(template) + + expect(result.hasNewNoteTitle).toBe(false) + expect(result.hasOutputFrontmatter).toBe(false) + expect(result.hasOutputTitle).toBe(false) + expect(result.hasInlineTitle).toBe(false) + expect(result.templateFrontmatter.title).toBe('template title') + expect(result.templateFrontmatter.field1).toBe('value1') + expect(result.templateFrontmatter.field2).toBe('value2') + }) + + test('should handle template with no frontmatter', () => { + const template = `# This is just a title +Some content here` + + const result = analyzeTemplateStructure(template) + + expect(result.hasNewNoteTitle).toBe(false) + expect(result.hasOutputFrontmatter).toBe(false) + expect(result.hasOutputTitle).toBe(false) + expect(result.hasInlineTitle).toBe(true) + expect(result.inlineTitleText).toBe('This is just a title') + }) + + test('should handle empty template', () => { + const template = `` + + const result = analyzeTemplateStructure(template) + + expect(result.hasNewNoteTitle).toBe(false) + expect(result.hasOutputFrontmatter).toBe(false) + expect(result.hasOutputTitle).toBe(false) + expect(result.hasInlineTitle).toBe(false) + expect(Object.keys(result.templateFrontmatter)).toHaveLength(0) + expect(Object.keys(result.outputFrontmatter)).toHaveLength(0) + }) + }) + + describe('edge cases', () => { + test('should handle malformed frontmatter gracefully', () => { + const template = `--- +title: template title +--- +--- +incomplete frontmatter +# inline title` + + const result = analyzeTemplateStructure(template) + + // Should still detect the inline title even with malformed frontmatter + expect(result.hasInlineTitle).toBe(false) + }) + + test('should handle extra separators', () => { + const template = `--- +title: template title +--- +--- +some: frontmatter +--- +# a title +--- +note: frontmatter +--- +# inline title` + + const result = analyzeTemplateStructure(template) + + expect(result.hasInlineTitle).toBe(true) + expect(result.inlineTitleText).toBe('a title') + }) + }) + + describe('title precedence and frontmatter creation logic', () => { + test('should prioritize newNoteTitle over inline title when both present', () => { + const template = `--- +title: template title +newNoteTitle: "Project Review" +--- +# Weekly Update` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('Project Review') + }) + + test('should use inline title when newNoteTitle is not present', () => { + const template = `--- +title: template title +--- +# Weekly Update` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('Weekly Update') + }) + + test('should handle newNoteTitle with special characters', () => { + const template = `--- +title: template title +newNoteTitle: "This has: colons and @symbols" +--- +# Malformed Template Test` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('This has: colons and @symbols') + }) + + test('should handle newNoteTitle and inline title being the same', () => { + const template = `--- +title: template title +newNoteTitle: "Project Review" +--- +# Project Review` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('Project Review') + }) + + test('should handle template with output frontmatter and inline title', () => { + const template = `--- +title: template title +--- +-- +foo: bar +-- +# This should be the title but not in frontmatter` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('This should be the title but not in frontmatter') + }) + + test('should handle template with only inline title (no frontmatter)', () => { + const template = `# Simple Inline Title +Some content here` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('Simple Inline Title') + }) + + test('should handle template with only newNoteTitle (no inline title)', () => { + const template = `--- +title: template title +newNoteTitle: "Generated Title" +--- +Some content without inline title` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('Generated Title') + }) + + test('should handle template with no title at all', () => { + const template = `--- +title: template title +--- +Some content without any title` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('') + }) + + test('should handle template with malformed frontmatter but valid inline title', () => { + const template = `--- +title: template title +newNoteTitle: "This has: colons and @symbols" +folder: DELETEME +--- +# Malformed Template Test` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('This has: colons and @symbols') + }) + + test('should handle template with multiple frontmatter blocks', () => { + const template = `--- +title: template title +newNoteTitle: "Final Title" +--- +-- +intermediate: frontmatter +-- +--- +final: frontmatter +--- +# Inline Title` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('Final Title') + }) + + test('should handle template with subheading only (not detected as inline title)', () => { + const template = `--- +title: my template +--- +## This is a subheading, not an inline title +Some content here` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('This is a subheading, not an inline title') + }) + + test('should detect H1 headings as inline titles', () => { + const template = `--- +title: my template +--- +# This is an H1 heading and should be detected as inline title +Some content here` + + const result = getNoteTitleFromTemplate(template) + + expect(result).toBe('This is an H1 heading and should be detected as inline title') + }) + + test('should not consider non-frontmatter separators as output frontmatter', () => { + const template = `--- +title: Test Template +type: meeting-note +--- +--- +## This is not frontmatter, just a separator + +Some content here` + + const result = analyzeTemplateStructure(template) + + expect(result.hasOutputFrontmatter).toBe(false) + expect(result.hasOutputTitle).toBe(false) + expect(Object.keys(result.outputFrontmatter).length).toBe(0) + }) + + test('should not detect inline title from content after invalid frontmatter', () => { + const template = `--- +**Event:** <%- calendarItemLink %> +**Links:** <%- eventLink %> +**Attendees:** <%- eventAttendees %> +**Location:** <%- eventLocation %> +--- +### Agenda ++ + +### Notes +- + +### Actions +* ` + + const result = analyzeTemplateStructure(template) + + expect(result.hasInlineTitle).toBe(false) + expect(result.inlineTitleText).toBe('') + }) + }) +}) diff --git a/helpers/__tests__/NPFrontMatter/NPFrontMatterFormatting.test.js b/helpers/__tests__/NPFrontMatter/NPFrontMatterFormatting.test.js index eebee9eda..171770143 100644 --- a/helpers/__tests__/NPFrontMatter/NPFrontMatterFormatting.test.js +++ b/helpers/__tests__/NPFrontMatter/NPFrontMatterFormatting.test.js @@ -295,6 +295,73 @@ describe(`${PLUGIN_NAME}`, () => { expect(result).toEqual(expected) }) + test('should not treat invalid YAML content as frontmatter', () => { + const before = `--- +**Event:** <%- calendarItemLink %> +**Links:** <%- eventLink %> +**Attendees:** <%- eventAttendees %> +**Location:** <%- eventLocation %> +--- +### Agenda ++ + +### Notes +- + +### Actions +* ` + const result = f.getSanitizedFmParts(before) + // Should treat the entire content as body since the content between --- is not valid YAML + expect(result.attributes).toEqual({}) + expect(result.body).toEqual(before) + expect(result.frontmatter).toEqual('') + }) + + test('should treat content with template tags as frontmatter', () => { + const before = `--- +title: <%- eventTitle %> +date: <%- eventDate() %> +type: meeting-note +--- +# Meeting Notes + +Some content here.` + const result = f.getSanitizedFmParts(before) + // Should extract the frontmatter correctly even with template tags + expect(result.attributes).toEqual({ + title: '<%- eventTitle %>', + date: '<%- eventDate() %>', + type: 'meeting-note', + }) + expect(result.body).toEqual('# Meeting Notes\n\nSome content here.') + // The frontmatter field should contain the actual frontmatter content when fm library succeeds + expect(result.frontmatter).toContain('title:') + expect(result.frontmatter).toContain('date:') + expect(result.frontmatter).toContain('type:') + }) + + test('should treat valid YAML content as frontmatter even when fm library fails', () => { + const before = `--- +title: Valid YAML +date: 2024-01-15 +type: note +invalid_yaml: [unclosed array +--- +# Valid Content + +This is the body.` + const result = f.getSanitizedFmParts(before) + // Should extract the frontmatter correctly using fallback logic + expect(result.attributes).toEqual({ + title: 'Valid YAML', + date: '2024-01-15', + type: 'note', + invalid_yaml: '[unclosed array', + }) + expect(result.body).toEqual('# Valid Content\n\nThis is the body.') + expect(result.frontmatter).toEqual('') + }) + describe('sanitizeFrontmatterInNote()', () => { test.skip('should do nothing if none are necesary', () => { const note = new Note({ content: 'baz' }) diff --git a/helpers/editor.js b/helpers/editor.js index 83cc3d74a..6232e0f8d 100644 --- a/helpers/editor.js +++ b/helpers/editor.js @@ -3,6 +3,7 @@ import { logDebug } from './dev' import { showMessageYesNo, showMessage } from './userInput' import { getFolderFromFilename } from '@helpers/folders' +import { getNoteTitleFromTemplate } from './NPFrontMatter' /** * Run Editor.save() if active Editor is dirty and needs saving @@ -37,7 +38,10 @@ export async function checkAndProcessFolderAndNewNoteTitle(templateNote: TNote, // Check if the template wants the note to be created in a folder and if so, move the empty note to the trash and create a new note in the folder const isEditorEmpty = editorIsEmpty() const theFolder = frontmatterAttributes?.folder?.trim() || '' - const newNoteTitle = frontmatterAttributes?.newNoteTitle?.trim() || '' + + // Use the new function to get note title from template, checking both newNoteTitle and inline title + const newNoteTitle = getNoteTitleFromTemplate(templateNote?.content || '') || frontmatterAttributes?.newNoteTitle?.trim() || '' + logDebug(`checkAndProcessFolderAndNewNoteTitle starting: templateNote:"${templateNote?.title || ''}", frontmatterAttributes:${JSON.stringify(frontmatterAttributes)}`) if (theFolder.length > 0 || newNoteTitle.length > 0) { if (isEditorEmpty) { diff --git a/np.MeetingNotes/CHANGELOG.md b/np.MeetingNotes/CHANGELOG.md index e525538a0..5d521b4e5 100644 --- a/np.MeetingNotes/CHANGELOG.md +++ b/np.MeetingNotes/CHANGELOG.md @@ -4,6 +4,14 @@ See Plugin [README](https://github.com/NotePlan/plugins/blob/main/np.MeetingNotes/README.md) for details on available commands and use case. +## [2.0.3] - 2025-08-06 @dwertheimer + +- Make it possible for a template to have any level of heading for the title (was previously H1 only) + +## [2.0.2] - 2025-08-05 @dwertheimer + +- Fix bug where inline H1 title was not being used in templateNew (thx @crussell) + ## [2.0.1] - 2025-08-02 @dwertheimer - Add override when inserting a template into a blank note but template has folder or newNoteTitle attribute diff --git a/np.MeetingNotes/plugin.json b/np.MeetingNotes/plugin.json index 69d29d274..84dd65fdf 100644 --- a/np.MeetingNotes/plugin.json +++ b/np.MeetingNotes/plugin.json @@ -3,7 +3,7 @@ "noteplan.minAppVersion": "3.5.0", "plugin.id": "np.MeetingNotes", "plugin.name": "✍️ Meeting Notes", - "plugin.version": "2.0.1", + "plugin.version": "2.0.3", "plugin.description": "Create Meeting Notes from events using templates.", "plugin.author": "NotePlan", "plugin.dependencies": [], diff --git a/np.MeetingNotes/src/NPMeetingNotes.js b/np.MeetingNotes/src/NPMeetingNotes.js index de8600012..1b9c315f1 100644 --- a/np.MeetingNotes/src/NPMeetingNotes.js +++ b/np.MeetingNotes/src/NPMeetingNotes.js @@ -11,7 +11,7 @@ import { getNoteByFilename } from '../../helpers/note' import { isCalendarNoteFilename } from '@helpers/regex' import { log, logDebug, logError, clo, JSP, timer } from '@helpers/dev' import { findProjectNoteUrlInText } from '@helpers/urls' -import { getAttributes } from '@helpers/NPFrontMatter' +import { getAttributes, getNoteTitleFromTemplate } from '@helpers/NPFrontMatter' import { checkAndProcessFolderAndNewNoteTitle } from '@helpers/editor' /** @@ -210,7 +210,7 @@ function titleExistsInNote(content: string): string | null { // logDebug(pluginJson, `titleExistsInNote attributes?.title=${attributes?.title}`) // if (attributes?.title) return attributes.title // commenting this out because attributes is the template's attributes, not the resulting doc const lines = content.split('\n') - const headingLine = lines.find((l) => l.startsWith('# ')) + const headingLine = lines.find((l) => l.match(/^#{1,6}\s+/)) logDebug(pluginJson, `titleExistsInNote headingLine || null=${headingLine || 'null (no title in content)'}`) return headingLine || null } @@ -227,7 +227,7 @@ function getNoteTitle(_noteTitle: string, renderedTemplateContent: string, attri // if (attributes?.title) return attributes.title // grab the first line of the result as the title const lines = renderedTemplateContent.split('\n') - const headingLine = lines.find((l) => l.startsWith('#')) // may need to infer the title from a ## title etc. + const headingLine = lines.find((l) => l.match(/^#{1,6}\s+/)) // may need to infer the title from a ## title etc. const noteTitle = headingLine ? headingLine.replace(/(^#*\s*)/, '').trim() : '' logDebug(pluginJson, `No title specified directly. Trying to infer it from the headingLine: "${headingLine || ''}" => "${noteTitle}"`) return noteTitle @@ -292,7 +292,9 @@ async function createNoteAndLinkEvent(selectedEvent: TCalendarItem | null, rende const append: string = attrs?.append || '' const prepend: string = attrs?.prepend || '' const cursor: string = attrs?.cursor || '' - const newNoteTitle: string = attrs?.newNoteTitle || '' + // Use the new function to get note title from template, checking both newNoteTitle and inline title + const templateNoteTitle = getNoteTitleFromTemplate(renderedContent) + const newNoteTitle: string = templateNoteTitle || attrs?.newNoteTitle || '' let noteTitle: string = (append || prepend || cursor).trim() const location: string = append.length ? 'append' : cursor.length ? 'cursor' : 'prepend' diff --git a/np.Templating/CHANGELOG.md b/np.Templating/CHANGELOG.md index f2593583e..e851095a4 100644 --- a/np.Templating/CHANGELOG.md +++ b/np.Templating/CHANGELOG.md @@ -4,6 +4,23 @@ See Plugin [Documentation](https://noteplan.co/templates/docs) for details on available commands and use case. +## [2.0.17] 2025-08-06 @dwertheimer +- Fix bug where non-fm-body templates which started with -- were being treated as frontmatter + +## [2.0.16] 2025-08-06 @dwertheimer +- Add pivot offset to date.now() method + +## [2.0.15] 2025-08-06 @dwertheimer +- Fix date module edge cases with timezones + +## [2.0.14] 2025-08-06 @dwertheimer +- Make it possible for a template to have any level of heading for the title (was previously H1 only) + +## [2.0.13] 2025-08-05 @dwertheimer +- Fix bug where inline H1 title was not being used in templateNew (thx @crussell) +- Ensure that inline H1 title is not created in frontmatter even if there is other frontmatter being created +- if there is newNoteTitle and also an inline H1 title, the newNoteTitle will take precedence and will be created in frontmatter + ## [2.0.12] 2025-08-02 @dwertheimer - Fix templateNew to handle blank meeting note edge case diff --git a/np.Templating/__tests__/date-module-now-fix.test.js b/np.Templating/__tests__/date-module-now-fix.test.js new file mode 100644 index 000000000..ef442fb04 --- /dev/null +++ b/np.Templating/__tests__/date-module-now-fix.test.js @@ -0,0 +1,403 @@ +/* eslint-disable */ + +import colors from 'chalk' +import DateModule from '../lib/support/modules/DateModule' +import moment from 'moment-business-days' + +const PLUGIN_NAME = `📙 ${colors.yellow('np.Templating - Now Method Fix Test')}` +const section = colors.blue +const method = colors.magenta.bold + +describe(`${PLUGIN_NAME}`, () => { + let originalTimezone + + beforeEach(() => { + global.DataStore = { + settings: { _logLevel: 'none' }, + } + + // Mock Calendar methods for consistent testing + global.Calendar = { + weekNumber: jest.fn((date) => { + // Default to moment's ISO week + 1 for Sunday adjustment (mimicking typical NotePlan behavior) + const momentWeek = parseInt(moment(date).format('W')) + return moment(date).day() === 0 ? momentWeek + 1 : momentWeek + }), + startOfWeek: jest.fn((date) => { + // Default to Sunday start (moment's default with adjustment) + return moment(date).startOf('week').toDate() + }), + endOfWeek: jest.fn((date) => { + // Default to Saturday end (moment's default with adjustment) + return moment(date).endOf('week').toDate() + }), + } + + // Store original timezone + originalTimezone = process.env.TZ + }) + + afterEach(() => { + jest.clearAllMocks() + delete global.Calendar + + // Restore original timezone + if (originalTimezone) { + process.env.TZ = originalTimezone + } else { + delete process.env.TZ + } + }) + + describe(section('Now Method Fix Tests'), () => { + describe(method('now() with date parameter'), () => { + it('should format a specific date correctly', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + const result = dateModule.now('YYYY - dddd', '2025-08-06') + + expect(result).toBe('2025 - Wednesday') + }) + + it('should handle different date formats', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + // Test various date formats + expect(dateModule.now('YYYY-MM-DD', '2025-08-06')).toBe('2025-08-06') + expect(dateModule.now('MM/DD/YYYY', '2025-08-06')).toBe('08/06/2025') + expect(dateModule.now('MMMM Do, YYYY', '2025-08-06')).toBe('August 6th, 2025') + expect(dateModule.now('dddd, MMMM Do', '2025-08-06')).toBe('Wednesday, August 6th') + }) + + it('should handle different timezones consistently', () => { + const testDate = '2025-08-06' + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Asia/Tokyo'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.now('YYYY - dddd', testDate) + expect(result).toBe('2025 - Wednesday') + }) + }) + + it('should still work with offset parameters', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + // Test offset functionality still works + const tomorrow = dateModule.now('YYYY-MM-DD', '1d') + const expectedTomorrow = moment().add(1, 'day').format('YYYY-MM-DD') + expect(tomorrow).toBe(expectedTomorrow) + + const yesterday = dateModule.now('YYYY-MM-DD', '-1d') + const expectedYesterday = moment().subtract(1, 'day').format('YYYY-MM-DD') + expect(yesterday).toBe(expectedYesterday) + }) + + it('should handle invalid date strings gracefully', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + // Invalid date should fall back to current date + const result = dateModule.now('YYYY-MM-DD', 'invalid-date') + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + + it('should handle empty date parameter', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + // Empty parameter should use current date + const result = dateModule.now('YYYY-MM-DD', '') + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + + it('should handle null date parameter', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + // Null parameter should use current date + const result = dateModule.now('YYYY-MM-DD', null) + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + describe(method('Numeric offset compatibility'), () => { + it('should handle positive numeric offsets', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + // Test numeric offsets (the main fix) + const result7 = dateModule.now('YYYY-MM-DD', 7) + const expected7 = moment().add(7, 'days').format('YYYY-MM-DD') + expect(result7).toBe(expected7) + + const result10 = dateModule.now('YYYY-MM-DD', 10) + const expected10 = moment().add(10, 'days').format('YYYY-MM-DD') + expect(result10).toBe(expected10) + }) + + it('should handle negative numeric offsets', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + const result7 = dateModule.now('YYYY-MM-DD', -7) + const expected7 = moment().subtract(7, 'days').format('YYYY-MM-DD') + expect(result7).toBe(expected7) + + const result45 = dateModule.now('YYYY-MM-DD', -45) + const expected45 = moment().subtract(45, 'days').format('YYYY-MM-DD') + expect(result45).toBe(expected45) + }) + + it('should handle zero numeric offset', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + const result = dateModule.now('YYYY-MM-DD', 0) + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + describe(method('String offset compatibility'), () => { + it('should handle shorthand string offsets', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + // Test shorthand offsets still work + const result10w = dateModule.now('YYYY-MM-DD', '10w') + const expected10w = moment().add(10, 'w').format('YYYY-MM-DD') + expect(result10w).toBe(expected10w) + + const result3M = dateModule.now('YYYY-MM-DD', '3M') + const expected3M = moment().add(3, 'M').format('YYYY-MM-DD') + expect(result3M).toBe(expected3M) + + const result1y = dateModule.now('YYYY-MM-DD', '1y') + const expected1y = moment().add(1, 'y').format('YYYY-MM-DD') + expect(result1y).toBe(expected1y) + }) + + it('should handle negative shorthand string offsets', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + const result10w = dateModule.now('YYYY-MM-DD', '-10w') + const expected10w = moment().subtract(10, 'w').format('YYYY-MM-DD') + expect(result10w).toBe(expected10w) + + const result3M = dateModule.now('YYYY-MM-DD', '-3M') + const expected3M = moment().subtract(3, 'M').format('YYYY-MM-DD') + expect(result3M).toBe(expected3M) + }) + + it('should handle numeric string offsets', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + // String numbers should be treated as day offsets + const result7 = dateModule.now('YYYY-MM-DD', '7') + const expected7 = moment().add(7, 'days').format('YYYY-MM-DD') + expect(result7).toBe(expected7) + + const resultNeg7 = dateModule.now('YYYY-MM-DD', '-7') + const expectedNeg7 = moment().subtract(7, 'days').format('YYYY-MM-DD') + expect(resultNeg7).toBe(expectedNeg7) + }) + }) + + describe(method('Date string detection'), () => { + it('should detect YYYY-MM-DD format as date', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + const result = dateModule.now('YYYY-MM-DD', '2025-08-06') + expect(result).toBe('2025-08-06') + }) + + it('should detect MM/DD/YYYY format as date', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + const result = dateModule.now('YYYY-MM-DD', '08/06/2025') + expect(result).toBe('2025-08-06') + }) + + it('should detect DD/MM/YYYY format as date', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + const result = dateModule.now('YYYY-MM-DD', '06/08/2025') + expect(result).toBe('2025-08-06') + }) + + it('should detect month name format as date', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + const result = dateModule.now('YYYY-MM-DD', 'August 6, 2025') + expect(result).toBe('2025-08-06') + }) + + it('should detect ISO datetime format as date', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + const result = dateModule.now('YYYY-MM-DD', '2025-08-06T14:30:00') + expect(result).toBe('2025-08-06') + }) + + it('should treat single digits as offsets, not dates', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + // Single digits should be treated as day offsets + const result = dateModule.now('YYYY-MM-DD', '7') + const expected = moment().add(7, 'days').format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + + it('should treat shorthand offsets as offsets, not dates', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + // Shorthand should be treated as offsets + const result = dateModule.now('YYYY-MM-DD', '1d') + const expected = moment().add(1, 'day').format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + describe(method('Edge cases'), () => { + it('should handle dates with time information', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + expect(dateModule.now('YYYY-MM-DD', '2025-08-06T14:30:00')).toBe('2025-08-06') + expect(dateModule.now('YYYY-MM-DD', '2025-08-06T14:30:00-08:00')).toBe('2025-08-06') + }) + + it('should handle various date string formats', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + expect(dateModule.now('YYYY-MM-DD', '2025/08/06')).toBe('2025-08-06') + expect(dateModule.now('YYYY-MM-DD', 'August 6, 2025')).toBe('2025-08-06') + expect(dateModule.now('YYYY-MM-DD', 'Aug 6, 2025')).toBe('2025-08-06') + }) + + it('should handle undefined and null parameters', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + const resultUndefined = dateModule.now('YYYY-MM-DD', undefined) + const expected = moment().format('YYYY-MM-DD') + expect(resultUndefined).toBe(expected) + + const resultNull = dateModule.now('YYYY-MM-DD', null) + expect(resultNull).toBe(expected) + }) + + it('should handle empty string parameters', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + const result = dateModule.now('YYYY-MM-DD', '') + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + + it('should handle whitespace-only parameters', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + const result = dateModule.now('YYYY-MM-DD', ' ') + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + describe(method('Backwards compatibility'), () => { + it('should maintain existing behavior for no parameters', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + const result = dateModule.now() + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + + it('should maintain existing behavior for format-only parameter', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + const result = dateModule.now('MM/DD/YYYY') + const expected = moment().format('MM/DD/YYYY') + expect(result).toBe(expected) + }) + + it('should maintain existing behavior for all existing offset patterns', () => { + process.env.TZ = 'America/Los_Angeles' + + const dateModule = new DateModule() + + // Test all the patterns from the original failing tests + const result7 = dateModule.now('', 7) + const expected7 = moment().add(7, 'days').format('YYYY-MM-DD') + expect(result7).toBe(expected7) + + const result10 = dateModule.now('', 10) + const expected10 = moment().add(10, 'days').format('YYYY-MM-DD') + expect(result10).toBe(expected10) + + const resultNeg7 = dateModule.now('', -7) + const expectedNeg7 = moment().subtract(7, 'days').format('YYYY-MM-DD') + expect(resultNeg7).toBe(expectedNeg7) + + const resultNeg45 = dateModule.now('', -45) + const expectedNeg45 = moment().subtract(45, 'days').format('YYYY-MM-DD') + expect(resultNeg45).toBe(expectedNeg45) + + const result10w = dateModule.now('', '10w') + const expected10w = moment().add(10, 'w').format('YYYY-MM-DD') + expect(result10w).toBe(expected10w) + + const result3M = dateModule.now('', '3M') + const expected3M = moment().add(3, 'M').format('YYYY-MM-DD') + expect(result3M).toBe(expected3M) + }) + }) + }) +}) diff --git a/np.Templating/__tests__/date-module-timezone-debug.test.js b/np.Templating/__tests__/date-module-timezone-debug.test.js new file mode 100644 index 000000000..e8de80df6 --- /dev/null +++ b/np.Templating/__tests__/date-module-timezone-debug.test.js @@ -0,0 +1,144 @@ +/* eslint-disable */ + +import colors from 'chalk' +import DateModule from '../lib/support/modules/DateModule' +import moment from 'moment-business-days' + +import { currentDate, format, date8601, createDateTime } from '../lib/support/modules/DateModule' + +const PLUGIN_NAME = `📙 ${colors.yellow('np.Templating - Timezone Debug Tests')}` +const section = colors.blue +const method = colors.magenta.bold + +describe(`${PLUGIN_NAME}`, () => { + let originalTimezone + + beforeEach(() => { + global.DataStore = { + settings: { _logLevel: 'none' }, + } + + // Mock Calendar methods for consistent testing + global.Calendar = { + weekNumber: jest.fn((date) => { + // Default to moment's ISO week + 1 for Sunday adjustment (mimicking typical NotePlan behavior) + const momentWeek = parseInt(moment(date).format('W')) + return moment(date).day() === 0 ? momentWeek + 1 : momentWeek + }), + startOfWeek: jest.fn((date) => { + // Default to Sunday start (moment's default with adjustment) + return moment(date).startOf('week').toDate() + }), + endOfWeek: jest.fn((date) => { + // Default to Saturday end (moment's default with adjustment) + return moment(date).endOf('week').toDate() + }), + } + + // Store original timezone + originalTimezone = process.env.TZ + }) + + afterEach(() => { + jest.clearAllMocks() + delete global.Calendar + + // Restore original timezone + if (originalTimezone) { + process.env.TZ = originalTimezone + } else { + delete process.env.TZ + } + }) + + describe(section('Debug Timezone Issues'), () => { + describe(method('format function with mocked dates'), () => { + it('should debug the format function behavior', () => { + process.env.TZ = 'America/Los_Angeles' + + // Mock current time to be 12:01 AM on 2024-01-16 + const mockDate = new Date('2024-01-16T00:01:00-08:00') + jest.spyOn(global, 'Date').mockImplementation(() => mockDate) + + console.log('Mocked date:', mockDate) + console.log('Mocked date ISO string:', mockDate.toISOString()) + console.log('Mocked date local string:', mockDate.toString()) + + // Test the standalone format function + const formatResult = format('YYYY-MM-DD') + console.log('format() result:', formatResult) + + // Test the DateModule class format method + const dateModule = new DateModule() + const classResult = dateModule.format('YYYY-MM-DD') + console.log('DateModule.format() result:', classResult) + + // Test what moment() returns + const momentNow = moment() + console.log('moment() format YYYY-MM-DD:', momentNow.format('YYYY-MM-DD')) + console.log('moment() toDate():', momentNow.toDate()) + + // Test what new Date() returns + const newDate = new Date() + console.log('new Date():', newDate) + console.log('new Date() toISOString():', newDate.toISOString()) + + expect(formatResult).toBe('2024-01-16') + expect(classResult).toBe('2024-01-16') + + jest.restoreAllMocks() + }) + + it('should debug createDateTime function', () => { + process.env.TZ = 'America/Los_Angeles' + + const testDate = '2024-01-15' + console.log('Testing createDateTime with:', testDate) + + const result = createDateTime(testDate) + console.log('createDateTime result:', result) + console.log('createDateTime result type:', typeof result) + console.log('createDateTime result instanceof Date:', result instanceof Date) + + const momentResult = moment(result) + console.log('moment(createDateTime result).format(YYYY-MM-DD):', momentResult.format('YYYY-MM-DD')) + + expect(result).toBeInstanceOf(Date) + expect(moment(result).format('YYYY-MM-DD')).toBe('2024-01-15') + }) + + it('should debug the format function flow', () => { + process.env.TZ = 'America/Los_Angeles' + + const testDate = '2024-01-15' + console.log('Testing format function with dateString:', testDate) + + // Step 1: Check what moment(dateString) returns + const momentFromString = moment(testDate) + console.log('moment(dateString).format(YYYY-MM-DD):', momentFromString.format('YYYY-MM-DD')) + + // Step 2: Check what moment().format('YYYY-MM-DD') returns + const momentNow = moment() + console.log('moment().format(YYYY-MM-DD):', momentNow.format('YYYY-MM-DD')) + + // Step 3: Check the dt variable in format function + const dt = testDate ? moment(testDate).format('YYYY-MM-DD') : moment().format('YYYY-MM-DD') + console.log('dt variable:', dt) + + // Step 4: Check what createDateTime(dt) returns + const createDateTimeResult = createDateTime(dt) + console.log('createDateTime(dt):', createDateTimeResult) + + // Step 5: Check what moment(createDateTime(dt)) returns + const momentFromCreateDateTime = moment(createDateTimeResult) + console.log('moment(createDateTime(dt)).format(YYYY-MM-DD):', momentFromCreateDateTime.format('YYYY-MM-DD')) + + // Step 6: Final result + const finalResult = format('YYYY-MM-DD', testDate) + console.log('Final format result:', finalResult) + + expect(finalResult).toBe('2024-01-15') + }) + }) + }) +}) diff --git a/np.Templating/__tests__/date-module-timezone-simple.test.js b/np.Templating/__tests__/date-module-timezone-simple.test.js new file mode 100644 index 000000000..688e89613 --- /dev/null +++ b/np.Templating/__tests__/date-module-timezone-simple.test.js @@ -0,0 +1,268 @@ +/* eslint-disable */ + +import colors from 'chalk' +import DateModule from '../lib/support/modules/DateModule' +import moment from 'moment-business-days' + +import { currentDate, format, date8601, createDateTime } from '../lib/support/modules/DateModule' + +const PLUGIN_NAME = `📙 ${colors.yellow('np.Templating - Simple Timezone Tests')}` +const section = colors.blue +const method = colors.magenta.bold + +describe(`${PLUGIN_NAME}`, () => { + let originalTimezone + + beforeEach(() => { + global.DataStore = { + settings: { _logLevel: 'none' }, + } + + // Mock Calendar methods for consistent testing + global.Calendar = { + weekNumber: jest.fn((date) => { + // Default to moment's ISO week + 1 for Sunday adjustment (mimicking typical NotePlan behavior) + const momentWeek = parseInt(moment(date).format('W')) + return moment(date).day() === 0 ? momentWeek + 1 : momentWeek + }), + startOfWeek: jest.fn((date) => { + // Default to Sunday start (moment's default with adjustment) + return moment(date).startOf('week').toDate() + }), + endOfWeek: jest.fn((date) => { + // Default to Saturday end (moment's default with adjustment) + return moment(date).endOf('week').toDate() + }), + } + + // Store original timezone + originalTimezone = process.env.TZ + }) + + afterEach(() => { + jest.clearAllMocks() + delete global.Calendar + + // Restore original timezone + if (originalTimezone) { + process.env.TZ = originalTimezone + } else { + delete process.env.TZ + } + }) + + describe(section('Simple Timezone Tests'), () => { + describe(method('Basic timezone consistency'), () => { + it('should return consistent dates across timezones when given a specific date', () => { + const testDate = '2024-01-15' + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Asia/Tokyo'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.format('YYYY-MM-DD', testDate) + expect(result).toBe('2024-01-15') + }) + }) + + it('should handle date strings with time information consistently', () => { + const testCases = [ + { input: '2024-01-15T14:30:00', expected: '2024-01-15' }, + { input: '2024-01-15T14:30:00-08:00', expected: '2024-01-15' }, + { input: '2024-01-15T14:30:00-05:00', expected: '2024-01-15' }, + { input: '2024-01-15T00:00:00', expected: '2024-01-15' }, + { input: '2024-01-15T23:59:59', expected: '2024-01-15' }, + ] + + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + + testCases.forEach(({ input, expected }) => { + const result = dateModule.format('YYYY-MM-DD', input) + expect(result).toBe(expected) + }) + }) + }) + + it('should handle various date string formats consistently', () => { + const testCases = [ + { input: '2024-01-15', expected: '2024-01-15' }, + { input: '2024/01/15', expected: '2024-01-15' }, + { input: 'January 15, 2024', expected: '2024-01-15' }, + { input: 'Jan 15, 2024', expected: '2024-01-15' }, + { input: '15 Jan 2024', expected: '2024-01-15' }, + ] + + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + + testCases.forEach(({ input, expected }) => { + const result = dateModule.format('YYYY-MM-DD', input) + expect(result).toBe(expected) + }) + }) + }) + }) + + describe(method('Current date methods timezone handling'), () => { + it('should handle today() consistently across timezones', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Asia/Tokyo'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.today('YYYY-MM-DD') + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + it('should handle tomorrow() consistently across timezones', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Asia/Tokyo'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.tomorrow('YYYY-MM-DD') + const expected = moment().add(1, 'day').format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + it('should handle yesterday() consistently across timezones', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Asia/Tokyo'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.yesterday('YYYY-MM-DD') + const expected = moment().subtract(1, 'day').format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + it('should handle now() consistently across timezones', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Asia/Tokyo'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.now('YYYY-MM-DD') + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + it('should handle now() with offsets consistently across timezones', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + + // Test +1 day offset + const tomorrow = dateModule.now('YYYY-MM-DD', '1d') + const expectedTomorrow = moment().add(1, 'day').format('YYYY-MM-DD') + expect(tomorrow).toBe(expectedTomorrow) + + // Test -1 day offset + const yesterday = dateModule.now('YYYY-MM-DD', '-1d') + const expectedYesterday = moment().subtract(1, 'day').format('YYYY-MM-DD') + expect(yesterday).toBe(expectedYesterday) + }) + }) + }) + + describe(method('Standalone function timezone handling'), () => { + it('should handle format() function consistently across timezones', () => { + const testDate = '2024-01-15' + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Asia/Tokyo'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const result = format('YYYY-MM-DD', testDate) + expect(result).toBe('2024-01-15') + }) + }) + + it('should handle currentDate() function consistently across timezones', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Asia/Tokyo'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const result = currentDate('YYYY-MM-DD') + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + it('should handle createDateTime() function consistently across timezones', () => { + const testDate = '2024-01-15' + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Asia/Tokyo'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const result = createDateTime(testDate) + expect(result).toBeInstanceOf(Date) + expect(moment(result).format('YYYY-MM-DD')).toBe('2024-01-15') + }) + }) + }) + + describe(method('Edge cases'), () => { + it('should handle empty date input consistently', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.format('YYYY-MM-DD', '') + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + it('should handle null date input consistently', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.format('YYYY-MM-DD', null) + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + it('should handle undefined date input consistently', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.format('YYYY-MM-DD', undefined) + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + it('should handle invalid date strings gracefully', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.format('YYYY-MM-DD', 'invalid-date') + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + }) + }) +}) diff --git a/np.Templating/__tests__/date-module-timezone-working.test.js b/np.Templating/__tests__/date-module-timezone-working.test.js new file mode 100644 index 000000000..18af3b09f --- /dev/null +++ b/np.Templating/__tests__/date-module-timezone-working.test.js @@ -0,0 +1,337 @@ +/* eslint-disable */ + +import colors from 'chalk' +import DateModule from '../lib/support/modules/DateModule' +import moment from 'moment-business-days' + +import { currentDate, format, date8601, createDateTime } from '../lib/support/modules/DateModule' + +const PLUGIN_NAME = `📙 ${colors.yellow('np.Templating - Working Timezone Tests')}` +const section = colors.blue +const method = colors.magenta.bold + +describe(`${PLUGIN_NAME}`, () => { + let originalTimezone + + beforeEach(() => { + global.DataStore = { + settings: { _logLevel: 'none' }, + } + + // Mock Calendar methods for consistent testing + global.Calendar = { + weekNumber: jest.fn((date) => { + // Default to moment's ISO week + 1 for Sunday adjustment (mimicking typical NotePlan behavior) + const momentWeek = parseInt(moment(date).format('W')) + return moment(date).day() === 0 ? momentWeek + 1 : momentWeek + }), + startOfWeek: jest.fn((date) => { + // Default to Sunday start (moment's default with adjustment) + return moment(date).startOf('week').toDate() + }), + endOfWeek: jest.fn((date) => { + // Default to Saturday end (moment's default with adjustment) + return moment(date).endOf('week').toDate() + }), + } + + // Store original timezone + originalTimezone = process.env.TZ + }) + + afterEach(() => { + jest.clearAllMocks() + delete global.Calendar + + // Restore original timezone + if (originalTimezone) { + process.env.TZ = originalTimezone + } else { + delete process.env.TZ + } + }) + + describe(section('Working Timezone Tests'), () => { + describe(method('Basic timezone consistency'), () => { + it('should return consistent dates across timezones when given a specific date', () => { + const testDate = '2024-01-15' + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Asia/Tokyo'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.format('YYYY-MM-DD', testDate) + expect(result).toBe('2024-01-15') + }) + }) + + it('should handle date strings with time information consistently', () => { + const testCases = [ + { input: '2024-01-15T14:30:00', expected: '2024-01-15' }, + { input: '2024-01-15T14:30:00-08:00', expected: '2024-01-15' }, + { input: '2024-01-15T14:30:00-05:00', expected: '2024-01-15' }, + { input: '2024-01-15T00:00:00', expected: '2024-01-15' }, + { input: '2024-01-15T23:59:59', expected: '2024-01-15' }, + ] + + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + + testCases.forEach(({ input, expected }) => { + const result = dateModule.format('YYYY-MM-DD', input) + expect(result).toBe(expected) + }) + }) + }) + + it('should handle various date string formats consistently', () => { + const testCases = [ + { input: '2024-01-15', expected: '2024-01-15' }, + { input: '2024/01/15', expected: '2024-01-15' }, + { input: 'January 15, 2024', expected: '2024-01-15' }, + { input: 'Jan 15, 2024', expected: '2024-01-15' }, + { input: '15 Jan 2024', expected: '2024-01-15' }, + ] + + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + + testCases.forEach(({ input, expected }) => { + const result = dateModule.format('YYYY-MM-DD', input) + expect(result).toBe(expected) + }) + }) + }) + }) + + describe(method('Current date methods timezone handling'), () => { + it('should handle today() consistently across timezones', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Asia/Tokyo'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.today('YYYY-MM-DD') + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + it('should handle tomorrow() consistently across timezones', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Asia/Tokyo'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.tomorrow('YYYY-MM-DD') + const expected = moment().add(1, 'day').format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + it('should handle yesterday() consistently across timezones', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Asia/Tokyo'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.yesterday('YYYY-MM-DD') + const expected = moment().subtract(1, 'day').format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + it('should handle now() consistently across timezones', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Asia/Tokyo'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.now('YYYY-MM-DD') + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + it('should handle now() with offsets consistently across timezones', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + + // Test +1 day offset + const tomorrow = dateModule.now('YYYY-MM-DD', '1d') + const expectedTomorrow = moment().add(1, 'day').format('YYYY-MM-DD') + expect(tomorrow).toBe(expectedTomorrow) + + // Test -1 day offset + const yesterday = dateModule.now('YYYY-MM-DD', '-1d') + const expectedYesterday = moment().subtract(1, 'day').format('YYYY-MM-DD') + expect(yesterday).toBe(expectedYesterday) + }) + }) + }) + + describe(method('Standalone function timezone handling'), () => { + it('should handle format() function consistently across timezones', () => { + const testDate = '2024-01-15' + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Asia/Tokyo'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const result = format('YYYY-MM-DD', testDate) + expect(result).toBe('2024-01-15') + }) + }) + + it('should handle currentDate() function consistently across timezones', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Asia/Tokyo'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const result = currentDate('YYYY-MM-DD') + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + it('should handle createDateTime() function consistently across timezones', () => { + const testDate = '2024-01-15' + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York', 'Europe/London', 'Asia/Tokyo'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const result = createDateTime(testDate) + expect(result).toBeInstanceOf(Date) + expect(moment(result).format('YYYY-MM-DD')).toBe('2024-01-15') + }) + }) + }) + + describe(method('Edge cases'), () => { + it('should handle empty date input consistently', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.format('YYYY-MM-DD', '') + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + it('should handle null date input consistently', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.format('YYYY-MM-DD', null) + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + it('should handle undefined date input consistently', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.format('YYYY-MM-DD', undefined) + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + + it('should handle invalid date strings gracefully', () => { + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + const result = dateModule.format('YYYY-MM-DD', 'invalid-date') + const expected = moment().format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + }) + + describe(method('Timezone-specific edge cases'), () => { + it('should handle DST transition dates consistently', () => { + // Test dates around DST transitions + const testCases = [ + { date: '2024-03-10', description: 'Spring forward in PST' }, + { date: '2024-11-03', description: 'Fall back in PST' }, + { date: '2024-03-10', description: 'Spring forward in EST' }, + { date: '2024-11-03', description: 'Fall back in EST' }, + ] + + const timezones = ['America/Los_Angeles', 'America/New_York'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + + testCases.forEach(({ date, description }) => { + const result = dateModule.format('YYYY-MM-DD', date) + expect(result).toBe(date) + }) + }) + }) + + it('should handle year boundary dates consistently', () => { + // Test dates around year boundaries + const testCases = [ + { date: '2024-12-31', description: 'December 31st' }, + { date: '2025-01-01', description: 'January 1st' }, + { date: '2024-12-31T23:59:59', description: 'December 31st with time' }, + { date: '2025-01-01T00:00:00', description: 'January 1st with time' }, + ] + + const timezones = ['UTC', 'America/Los_Angeles', 'America/New_York'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + + testCases.forEach(({ date, description }) => { + const result = dateModule.format('YYYY-MM-DD', date) + const expected = moment(date).format('YYYY-MM-DD') + expect(result).toBe(expected) + }) + }) + }) + + it('should handle midnight edge cases consistently', () => { + // Test dates around midnight + const testCases = [ + { date: '2024-01-15T23:59:59', expected: '2024-01-15' }, + { date: '2024-01-16T00:00:00', expected: '2024-01-16' }, + { date: '2024-01-15T23:59:59-08:00', expected: '2024-01-15' }, + { date: '2024-01-16T00:00:00-08:00', expected: '2024-01-16' }, + ] + + const timezones = ['America/Los_Angeles', 'America/New_York'] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + const dateModule = new DateModule() + + testCases.forEach(({ date, expected }) => { + const result = dateModule.format('YYYY-MM-DD', date) + expect(result).toBe(expected) + }) + }) + }) + }) + }) +}) diff --git a/np.Templating/__tests__/frontmatter-module.test.js b/np.Templating/__tests__/frontmatter-module.test.js index 1ca385182..c434f699a 100644 --- a/np.Templating/__tests__/frontmatter-module.test.js +++ b/np.Templating/__tests__/frontmatter-module.test.js @@ -35,6 +35,36 @@ describe(`${PLUGIN_NAME}`, () => { expect(result).toEqual(false) }) + it(`should return false for content with non-frontmatter separators`, async () => { + // This template has --- separators but no valid YAML content between them + const data = `--- +--- +# This is not frontmatter, just separators + +## Content here +Some content that looks like it might be frontmatter but isn't` + + let result = new FrontmatterModule().isFrontmatterTemplate(data) + + expect(result).toEqual(false) + }) + + it(`should return false for content with separators that look like frontmatter but aren't`, async () => { + // This template has valid frontmatter followed by separators that aren't frontmatter + const data = `--- +title: Test Template +type: meeting-note +--- +--- +## This is not frontmatter, just a separator + +Some content here` + + let result = new FrontmatterModule().isFrontmatterTemplate(data) + + expect(result).toEqual(true) // The first block is valid frontmatter + }) + it(`should extract frontmatter attributes using ${method('.attributes')}`, async () => { const data = await factory('frontmatter-minimal.ejs') diff --git a/np.Templating/lib/engine/templateRenderer.js b/np.Templating/lib/engine/templateRenderer.js index 81131ad27..66dc0f914 100644 --- a/np.Templating/lib/engine/templateRenderer.js +++ b/np.Templating/lib/engine/templateRenderer.js @@ -84,6 +84,7 @@ export function convertToDoubleDashesIfNecessary(templateData: string): string { lines[startBlock] = '--' lines[endBlock] = '--' returnedData = lines.join('\n') + logDebug(pluginJson, `convertToDoubleDashesIfNecessary: converted triple dashes to double dashes; templateData is now: "${templateData}"`) } return returnedData diff --git a/np.Templating/lib/rendering/templateProcessor.js b/np.Templating/lib/rendering/templateProcessor.js index 4370fe833..b94f4053a 100644 --- a/np.Templating/lib/rendering/templateProcessor.js +++ b/np.Templating/lib/rendering/templateProcessor.js @@ -1341,7 +1341,7 @@ async function _renderWithConfig(inputTemplateData: string, userData: any = {}, const startsWithFrontmatter = protectedTemplate.startsWith('--') const hasBacktickWrappedEJS = /`[^`]*<%.*?%>.*?`/.test(protectedTemplate) // This is probably redundant - if (!hasEJSTags && !hasBacktickWrappedEJS && frontmatterErrors.length === 0 && !startsWithFrontmatter) { + if (!hasEJSTags && !hasBacktickWrappedEJS && frontmatterErrors.length === 0) { renderedData = protectedTemplate } else { // If the body of the template starts with "---", we need to convert it to "--" diff --git a/np.Templating/lib/support/modules/DateModule.js b/np.Templating/lib/support/modules/DateModule.js index f72e99280..d7e3e19d5 100644 --- a/np.Templating/lib/support/modules/DateModule.js +++ b/np.Templating/lib/support/modules/DateModule.js @@ -116,13 +116,14 @@ export default class DateModule { dateToFormat = dateInput // Already a valid Date object } else { // Default to current date if dateInput is empty, invalid, or unexpected type - dateToFormat = new Date() + // Use moment() to get current date consistently with timezone handling + dateToFormat = moment().toDate() } // Ensure dateToFormat is a valid, finite Date object for Intl.DateTimeFormat if (!(dateToFormat instanceof Date) || !isFinite(dateToFormat.getTime())) { // console.warn(`DateModule.format: dateToFormat is not a finite Date after processing input:`, dateInput, `. Defaulting to now.`); - dateToFormat = new Date() // Final fallback + dateToFormat = moment().toDate() // Final fallback - use moment for consistent timezone handling } let formattedDateString @@ -136,50 +137,57 @@ export default class DateModule { return formattedDateString } - now(format = '', offset = '') { + now(format = '', dateOrOffset = '') { const locale = this.config?.templateLocale || 'en-US' const configFormat = this.config?.dateFormat || 'YYYY-MM-DD' const effectiveFormat = typeof format === 'string' && format.length > 0 ? format : configFormat - const dateValue = new Date() this.setLocale() - let momentToProcess = moment(dateValue) - - if (offset !== null && offset !== undefined && String(offset).trim().length > 0) { - const offsetStr = String(offset).trim() - let successfullyAppliedOffset = false - - // Try to parse as shorthand first (e.g., "1w", "-2m", "+7d") - // Regex: optional sign, numbers (with optional decimal), then letters - const shorthandMatch = offsetStr.match(/^([+-]?[0-9\.]+)([a-zA-Z]+)$/) - if (shorthandMatch) { - const value = parseFloat(shorthandMatch[1]) - const unit = shorthandMatch[2] - if (!isNaN(value) && unit.length > 0) { - // Moment's add/subtract take positive magnitude for subtract - if (value < 0) { - momentToProcess = moment(dateValue).subtract(Math.abs(value), unit) + let momentToProcess + + // Handle different types of second parameter + if (dateOrOffset !== null && dateOrOffset !== undefined && dateOrOffset !== '') { + if (typeof dateOrOffset === 'number') { + // Numeric offset - treat as days + const dateValue = moment().toDate() + momentToProcess = moment(dateValue).add(dateOrOffset, 'days') + } else if (typeof dateOrOffset === 'string' && dateOrOffset.trim().length > 0) { + const dateStr = dateOrOffset.trim() + + // Check if it looks like a date string (contains dashes, slashes, or is a full date) + const looksLikeDate = + /^\d{4}[-/]\d{1,2}[-/]\d{1,2}/.test(dateStr) || + /^\d{1,2}[-/]\d{1,2}[-/]\d{4}/.test(dateStr) || + /^[A-Za-z]+\s+\d{1,2},?\s+\d{4}/.test(dateStr) || + /^\d{4}-\d{2}-\d{2}T/.test(dateStr) + + if (looksLikeDate) { + // Try to parse as a date first + const parsedDate = moment(dateStr) + if (parsedDate.isValid()) { + // It's a valid date, use it + momentToProcess = parsedDate } else { - momentToProcess = moment(dateValue).add(value, unit) + // Not a valid date, treat as offset + const dateValue = moment().toDate() + momentToProcess = moment(dateValue) + this._applyOffset(momentToProcess, dateStr) } - successfullyAppliedOffset = true - } - } - - if (!successfullyAppliedOffset) { - // If not parsed as shorthand, try as a plain number (for days) - const numDays = parseFloat(offsetStr) - if (!isNaN(numDays)) { - momentToProcess = moment(dateValue).add(numDays, 'days') - successfullyAppliedOffset = true + } else { + // Doesn't look like a date, treat as offset + const dateValue = moment().toDate() + momentToProcess = moment(dateValue) + this._applyOffset(momentToProcess, dateStr) } + } else { + // Unexpected type, use current date + const dateValue = moment().toDate() + momentToProcess = moment(dateValue) } - - // If offset was provided but couldn't be parsed, momentToProcess remains moment(dateValue) - // which means no offset is applied, or you could add a warning here. - // if (!successfullyAppliedOffset) { - // console.warn(`DateModule.now: Could not parse offset: ${offsetStr}`) - // } + } else { + // No second parameter, use current date + const dateValue = moment().toDate() + momentToProcess = moment(dateValue) } let formattedDate @@ -193,6 +201,39 @@ export default class DateModule { return this.isValid(formattedDate) } + _applyOffset(momentToProcess, offsetStr) { + let successfullyAppliedOffset = false + + // Try to parse as shorthand first (e.g., "1w", "-2m", "+7d") + // Regex: optional sign, numbers (with optional decimal), then letters + const shorthandMatch = offsetStr.match(/^([+-]?[0-9\.]+)([a-zA-Z]+)$/) + if (shorthandMatch) { + const value = parseFloat(shorthandMatch[1]) + const unit = shorthandMatch[2] + if (!isNaN(value) && unit.length > 0) { + // Moment's add/subtract take positive magnitude for subtract + if (value < 0) { + momentToProcess.subtract(Math.abs(value), unit) + } else { + momentToProcess.add(value, unit) + } + successfullyAppliedOffset = true + } + } + + if (!successfullyAppliedOffset) { + // If not parsed as shorthand, try as a plain number (for days) + const numDays = parseFloat(offsetStr) + if (!isNaN(numDays)) { + momentToProcess.add(numDays, 'days') + successfullyAppliedOffset = true + } + } + + // If offset was provided but couldn't be parsed, momentToProcess remains unchanged + // which means no offset is applied + } + date8601() { return moment().format('YYYY-MM-DD') } @@ -200,7 +241,7 @@ export default class DateModule { today(format = '') { this.setLocale() - return this.format(format, new Date()) + return this.format(format, moment().toDate()) } tomorrow(format = '') { @@ -209,7 +250,7 @@ export default class DateModule { const configFormat = this.config?.dateFormat || 'YYYY-MM-DD' format = format.length > 0 ? format : configFormat - const dateValue = moment(new Date()).add(1, 'days') + const dateValue = moment().add(1, 'days') return this.format(format, dateValue) } @@ -218,7 +259,7 @@ export default class DateModule { const configFormat = this.config?.dateFormat || 'YYYY-MM-DD' format = format.length > 0 ? format : configFormat - const dateValue = moment(new Date()).subtract(1, 'days') + const dateValue = moment().subtract(1, 'days') return this.format(format, dateValue) } diff --git a/np.Templating/lib/support/modules/FrontmatterModule.js b/np.Templating/lib/support/modules/FrontmatterModule.js index 889ff61ea..5dfaf8d0b 100644 --- a/np.Templating/lib/support/modules/FrontmatterModule.js +++ b/np.Templating/lib/support/modules/FrontmatterModule.js @@ -7,8 +7,8 @@ import fm from 'front-matter' import pluginJson from '../../../plugin.json' -import { JSP, logError, logDebug } from '@helpers/dev' -import { getSanitizedFmParts, getValuesForFrontmatterTag, updateFrontMatterVars, getFrontmatterAttributes } from '@helpers/NPFrontMatter' +import { getSanitizedFmParts, isValidYamlContent, getValuesForFrontmatterTag, updateFrontMatterVars, getFrontmatterAttributes } from '@helpers/NPFrontMatter' +import { logDebug, logError, JSP } from '@helpers/dev' export default class FrontmatterModule { // $FlowIgnore @@ -27,17 +27,13 @@ export default class FrontmatterModule { // Find the second --- separator for (let i = 1; i < lines.length; i++) { if (lines[i].trim() === '---') { - return true + // Now validate that the content between the --- markers is actually YAML-like + // Extract the content between the first and second --- + const frontmatterContent = lines.slice(1, i).join('\n') + return isValidYamlContent(frontmatterContent) } } } - // Fallback to the original method for edge cases - // dbw note 2025-08-02: I can't imagine why this would ever be needed, so commenting out for now - // FIXME: remove this in the future if no edge cases are discovered - // const parts = getSanitizedFmParts(templateData) - // const hasAttributes = parts?.attributes && Object.keys(parts.attributes).length > 0 - // logDebug(pluginJson, `FrontmatterModule.isFrontmatterTemplate: Fallback check - hasAttributes=${String(hasAttributes)} templateData:"${templateData}"`) - // return hasAttributes return false } diff --git a/np.Templating/plugin.json b/np.Templating/plugin.json index 5d96a976c..e731d57f7 100644 --- a/np.Templating/plugin.json +++ b/np.Templating/plugin.json @@ -3,8 +3,8 @@ "noteplan.minAppVersion": "3.9.10", "plugin.id": "np.Templating", "plugin.name": "📒 Templating", - "plugin.version": "2.0.12", - "plugin.lastUpdateInfo": "2.0.12: fix templateNew to handle blank meeting note edge case", + "plugin.version": "2.0.17 ", + "plugin.lastUpdateInfo": "2.0.17: - Fix bug where non-fm-body templates which started with -- were being treated as frontmatter", "plugin.description": "Templating Plugin for NotePlan", "plugin.author": "Mike Erickson ( codedungeon)", "plugin.dependencies": [], diff --git a/np.Templating/src/NPTemplateRunner.js b/np.Templating/src/NPTemplateRunner.js index ec74a9c7b..e3f60f501 100644 --- a/np.Templating/src/NPTemplateRunner.js +++ b/np.Templating/src/NPTemplateRunner.js @@ -14,6 +14,7 @@ import { getISOWeekAndYear, getISOWeekString } from '@helpers/dateTime' import { getNPWeekData } from '@helpers/NPdateTime' import { getNote } from '@helpers/note' import { chooseNote } from '@helpers/userInput' +import { getNoteTitleFromTemplate } from '@helpers/NPFrontMatter' import NPTemplating from '../lib/NPTemplating' import FrontmatterModule from '@templatingModules/FrontmatterModule' @@ -167,9 +168,13 @@ export async function templateRunnerExecute(selectedTemplate?: string = '', open const { frontmatterBody, frontmatterAttributes } = await NPTemplating.renderFrontmatter(templateData, argObj) clo(frontmatterAttributes, `templateRunnerExecute frontMatterAttributes after renderFrontmatter`) let data = { ...frontmatterAttributes, ...argObj, frontmatter: { ...frontmatterAttributes, ...argObj } } - if (data['newNoteTitle']) { + // Check for newNoteTitle in the data or template + // For template runner, we only want to create new notes when there's an explicit newNoteTitle + // Don't use inline titles for template runner - they should only be used for templateNew + const templateNoteTitleToUse = data['newNoteTitle'] || null + if (templateNoteTitleToUse) { // if form or template has a newNoteTitle field then we need to call templateNew - const argsArray = [selectedTemplate, data['folder'] || null, data['newNoteTitle'], argObj] + const argsArray = [selectedTemplate, data['folder'] || null, templateNoteTitleToUse, argObj] await DataStore.invokePluginCommandByName('templateNew', 'np.Templating', argsArray) return } diff --git a/np.Templating/src/Templating.js b/np.Templating/src/Templating.js index 3a04d002b..37579a5e1 100644 --- a/np.Templating/src/Templating.js +++ b/np.Templating/src/Templating.js @@ -6,7 +6,7 @@ * Licensed under the MIT license. See LICENSE in the project root for license information. * -----------------------------------------------------------------------------------------*/ -import { log, clo, logDebug, logError } from '@helpers/dev' +import { log, clo, logDebug, logError, JSP } from '@helpers/dev' import { getCodeBlocksOfType } from '@helpers/codeBlocks' import NPTemplating from 'NPTemplating' import FrontmatterModule from '@templatingModules/FrontmatterModule' @@ -26,10 +26,11 @@ import { getAdvice } from '../lib/support/modules/advice' import { getDailyQuote } from '../lib/support/modules/quote' import { getVerse, getVersePlain } from '../lib/support/modules/verse' -import { initConfiguration, updateSettingData } from '@helpers/NPConfiguration' +import { initConfiguration, updateSettingData, pluginUpdated } from '@helpers/NPConfiguration' import { selectFirstNonTitleLineInEditor } from '@helpers/NPnote' import { hasFrontMatter, updateFrontMatterVars } from '@helpers/NPFrontMatter' import { checkAndProcessFolderAndNewNoteTitle } from '@helpers/editor' +import { getNoteTitleFromTemplate, analyzeTemplateStructure } from '@helpers/NPFrontMatter' import pluginJson from '../plugin.json' import DateModule from '../lib/support/modules/DateModule' @@ -63,27 +64,11 @@ export async function onSettingsUpdated() { export async function onUpdateOrInstall(config: any = { silent: false }): Promise { try { - let result: number = 0 - const pluginSettingsData = await DataStore.loadJSON(`../${pluginJson['plugin.id']}/settings.json`) - // if we don't have settings, this will be a first time install so we will perform migrations - if (typeof pluginSettingsData == 'undefined') { - result = updateSettingData(pluginJson) - } - - // ===== PLUGIN SPECIFIC SETTING UPDATE CODE - // this will be different for all plugins, you can do whatever you wish to configuration - const templateSettings = await NPTemplating.updateOrInstall(DataStore.settings, pluginJson['plugin.version']) - - // set application settings with any adjustments after template specific updates - DataStore.settings = { ...templateSettings } - - const pluginList = DataStore.installedPlugins() - // clo(pluginList) - - const version = await DataStore.invokePluginCommandByName('np:about', 'np.Templating', [{}]) - logDebug(version) + logDebug(pluginJson, `${pluginJson['plugin.id']} :: onUpdateOrInstall running`) + await updateSettingData(pluginJson) + await pluginUpdated(pluginJson, { code: 2, message: `Plugin Installed.` }) } catch (error) { - logError(pluginJson, error) + logError(pluginJson, `onUpdateOrInstall: ${JSP(error)}`) } } @@ -264,6 +249,7 @@ export async function templateInvoke(templateName?: string): Promise { */ export async function templateNew(templateTitle: string = '', _folder?: string, newNoteTitle?: string, _args?: Object | string): Promise { try { + logDebug(pluginJson, `templateNew: STARTING - templateTitle:"${templateTitle}", folder:"${_folder}", newNoteTitle:"${newNoteTitle}"`) let args = _args if (typeof _args === 'string') { args = JSON.parse(_args) @@ -320,7 +306,15 @@ export async function templateNew(templateTitle: string = '', _folder?: string, folder = await NPTemplating.getFolder(frontmatterAttributes.folder, 'Select Destination Folder') } - const noteTitle = newNoteTitle || frontmatterAttributes.newNoteTitle || (await CommandBar.textPrompt('Template', 'Enter New Note Title', '')) + // Use the new function to get note title from template, checking both newNoteTitle and inline title + const templateNoteTitle = getNoteTitleFromTemplate(templateData) + logDebug(pluginJson, `templateNew: templateNoteTitle from getNoteTitleFromTemplate: "${templateNoteTitle}"`) + logDebug(pluginJson, `templateNew: newNoteTitle parameter: "${newNoteTitle}"`) + logDebug(pluginJson, `templateNew: frontmatterAttributes.newNoteTitle: "${frontmatterAttributes.newNoteTitle}"`) + + const noteTitle = newNoteTitle || templateNoteTitle || frontmatterAttributes.newNoteTitle || (await CommandBar.textPrompt('Template', 'Enter New Note Title', '')) + logDebug(pluginJson, `templateNew: final noteTitle: "${noteTitle}"`) + if (typeof noteTitle === 'boolean' || noteTitle.length === 0) { return // user did not provide note title (Cancel) abort } @@ -350,9 +344,35 @@ export async function templateNew(templateTitle: string = '', _folder?: string, if (renderedTemplateHasFM) { Editor.content = templateResult - updateFrontMatterVars(Editor, { title: noteTitle }) + // Always add title to frontmatter if we have a newNoteTitle from template frontmatter + // Only skip adding title if the template has an inline title but NO newNoteTitle + // OR if newNoteTitle and inline title are the same (no need to duplicate) + const analysis = analyzeTemplateStructure(templateData) + const hasInlineTitle = analysis.hasInlineTitle && analysis.inlineTitleText + const hasNewNoteTitle = analysis.hasNewNoteTitle && analysis.templateFrontmatter.newNoteTitle + const titlesAreSame = hasInlineTitle && hasNewNoteTitle && analysis.templateFrontmatter.newNoteTitle === analysis.inlineTitleText + + if ((hasNewNoteTitle && !titlesAreSame) || !hasInlineTitle) { + updateFrontMatterVars(Editor, { title: noteTitle }) + } } else { - Editor.content = `# ${noteTitle}\n${templateResult}` + // Check if the template already contains an inline title to avoid duplication + // Also check if we have newNoteTitle that should create frontmatter + const analysis = analyzeTemplateStructure(templateData) + const hasInlineTitle = analysis.hasInlineTitle && analysis.inlineTitleText + const hasNewNoteTitle = analysis.hasNewNoteTitle && analysis.templateFrontmatter.newNoteTitle + const titlesAreSame = hasInlineTitle && hasNewNoteTitle && analysis.templateFrontmatter.newNoteTitle === analysis.inlineTitleText + + if (hasNewNoteTitle && !titlesAreSame) { + // We have newNoteTitle, so create frontmatter with title + Editor.content = `---\ntitle: ${noteTitle}\n---\n${templateResult}` + } else if (hasInlineTitle) { + // Template already has an inline title, don't add another one + Editor.content = templateResult + } else { + // No inline title in template, add the title + Editor.content = `# ${noteTitle}\n${templateResult}` + } } selectFirstNonTitleLineInEditor() } else { @@ -365,6 +385,7 @@ export async function templateNew(templateTitle: string = '', _folder?: string, export async function templateQuickNote(templateTitle: string = ''): Promise { try { + logDebug(pluginJson, `templateQuickNote: STARTING - templateTitle:"${templateTitle}"`) const content: string = Editor.content || '' const templateFolder = await getTemplateFolder() @@ -397,8 +418,12 @@ export async function templateQuickNote(templateTitle: string = ''): Promise