Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions exampleVault/Buttons/Button Example.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,20 @@ actions:

```

```meta-bind-button
label: Daily Note (with date)
hidden: false
id: ""
style: primary
actions:
- type: templaterCreateNote
templateFile: "templates/templater/Templater Template.md"
folderPath: "Daily/{YYYY}/{MM}"
fileName: "{YYYY-MM-DD}"
openIfAlreadyExists: true

```

```meta-bind-button
label: Sleep
hidden: false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
} from 'packages/core/src/config/ButtonConfig';
import { ButtonActionType } from 'packages/core/src/config/ButtonConfig';
import { AbstractButtonActionConfig } from 'packages/core/src/fields/button/AbstractButtonActionConfig';
import { ensureFileExtension, joinPath } from 'packages/core/src/utils/Utils';
import { ensureFileExtension, joinPath, processDateFormatPlaceholders } from 'packages/core/src/utils/Utils';

export class CreateNoteButtonActionConfig extends AbstractButtonActionConfig<CreateNoteButtonAction> {
constructor(mb: MetaBind) {
Expand All @@ -21,8 +21,11 @@ export class CreateNoteButtonActionConfig extends AbstractButtonActionConfig<Cre
_context: ButtonContext,
click: ButtonClickContext,
): Promise<void> {
if (action.openIfAlreadyExists) {
const filePath = ensureFileExtension(joinPath(action.folderPath ?? '', action.fileName), 'md');
const processedFileName = processDateFormatPlaceholders(action.fileName);
const processedFolderPath = processDateFormatPlaceholders(action.folderPath);

if (action.openIfAlreadyExists && action.fileName) {
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition checks action.fileName instead of processedFileName. If date placeholder processing results in an empty string or the original action.fileName is just a placeholder that becomes empty, the openIfAlreadyExists logic may not work as intended. Consider checking processedFileName instead, or ensure the processed value is truthy before attempting to open the existing file.

Copilot uses AI. Check for mistakes.
const filePath = ensureFileExtension(joinPath(processedFolderPath ?? '', processedFileName ?? ''), 'md');
// if the file already exists, open it in the same tab
if (await this.mb.file.exists(filePath)) {
await this.mb.file.open(filePath, '', false);
Expand All @@ -31,8 +34,8 @@ export class CreateNoteButtonActionConfig extends AbstractButtonActionConfig<Cre
}

await this.mb.file.create(
action.folderPath ?? '',
action.fileName,
processedFolderPath ?? '',
processedFileName ?? 'Untitled',
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If processedFileName is an empty string (which can happen if action.fileName is an empty string), the nullish coalescing operator won't trigger and an empty string will be passed to mb.file.create(). Consider using a falsy check instead: processedFileName || 'Untitled' to handle empty strings properly.

Copilot uses AI. Check for mistakes.
'md',
action.openNote ?? false,
click.openInNewTab(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
} from 'packages/core/src/config/ButtonConfig';
import { ButtonActionType } from 'packages/core/src/config/ButtonConfig';
import { AbstractButtonActionConfig } from 'packages/core/src/fields/button/AbstractButtonActionConfig';
import { ensureFileExtension, joinPath } from 'packages/core/src/utils/Utils';
import { ensureFileExtension, joinPath, processDateFormatPlaceholders } from 'packages/core/src/utils/Utils';

export class TemplaterCreateNoteButtonActionConfig extends AbstractButtonActionConfig<TemplaterCreateNoteButtonAction> {
constructor(mb: MetaBind) {
Expand All @@ -21,8 +21,11 @@ export class TemplaterCreateNoteButtonActionConfig extends AbstractButtonActionC
_context: ButtonContext,
click: ButtonClickContext,
): Promise<void> {
const processedFileName = processDateFormatPlaceholders(action.fileName);
const processedFolderPath = processDateFormatPlaceholders(action.folderPath);

if (action.openIfAlreadyExists && action.fileName) {
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition checks action.fileName instead of processedFileName. If date placeholder processing results in an empty string or the original action.fileName is just a placeholder that becomes empty, the openIfAlreadyExists logic may not work as intended. Consider checking processedFileName instead, or ensure the processed value is truthy before attempting to open the existing file.

Copilot uses AI. Check for mistakes.
const filePath = ensureFileExtension(joinPath(action.folderPath ?? '', action.fileName), 'md');
const filePath = ensureFileExtension(joinPath(processedFolderPath ?? '', processedFileName ?? ''), 'md');
// if the file already exists, open it in the same tab
if (await this.mb.file.exists(filePath)) {
await this.mb.file.open(filePath, '', false);
Expand All @@ -32,8 +35,8 @@ export class TemplaterCreateNoteButtonActionConfig extends AbstractButtonActionC

await this.mb.internal.createNoteWithTemplater(
action.templateFile,
action.folderPath,
action.fileName,
processedFolderPath,
processedFileName,
action.openNote,
click.openInNewTab(),
);
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/utils/Utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Moment from 'moment/moment';

/**
* Clamp
*
Expand Down Expand Up @@ -371,6 +373,32 @@ export function ensureFileExtension(filePath: string, extension: string): string
return filePath + extension;
}

/**
* Processes date format placeholders in a string.
* Replaces patterns like {YYYY-MM-DD} with formatted dates using moment.js.
*/
export function processDateFormatPlaceholders(value: string | undefined): string | undefined {
if (value === undefined || value === '') {
return value;
}

const placeholderRegex = /\{([^}]+)\}/g;

return value.replace(placeholderRegex, (match, format: string) => {
// Validate that the format string only contains valid moment.js tokens and delimiters
// Moment.js tokens: Y M D d H h m s S a A Q W w X x Z z G g E e o k l
// Common delimiters: : / - space . , [ ]
const validMomentFormat = /^[YMDdHhmsaAQWwXxZzGgEeSsokl:/\-\s.,[\]]+$/.test(format);
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex character class contains 'S' twice: once standalone and once in 'Ss'. While this doesn't break functionality (duplicate characters in character classes are simply ignored), it's redundant and could indicate a typo or oversight. Consider simplifying the pattern to remove the duplication.

Copilot uses AI. Check for mistakes.

if (!validMomentFormat) {
// Leave unknown/invalid formats unchanged
return match;
}

return Moment().format(format);
});
}

export function toArray<T>(value: T | T[] | undefined): T[] {
if (value === undefined) {
return [];
Expand Down
105 changes: 105 additions & 0 deletions tests/utils/Utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
joinPath,
mod,
optClamp,
processDateFormatPlaceholders,
remapRange,
toArray,
toEnumeration,
Expand Down Expand Up @@ -400,4 +401,108 @@ describe('utils', () => {
).toBe(false);
});
});

describe('processDateFormatPlaceholders function', () => {
test('should return undefined for undefined input', () => {
expect(processDateFormatPlaceholders(undefined)).toBeUndefined();
});

test('should return empty string for empty string input', () => {
expect(processDateFormatPlaceholders('')).toBe('');
});

test('should return original string if no placeholders are present', () => {
expect(processDateFormatPlaceholders('folder/subfolder')).toBe('folder/subfolder');
expect(processDateFormatPlaceholders('note-title')).toBe('note-title');
});

test('should leave invalid format placeholders unchanged', () => {
expect(processDateFormatPlaceholders('{INVALID}')).toBe('{INVALID}');
expect(processDateFormatPlaceholders('{random text}')).toBe('{random text}');
expect(processDateFormatPlaceholders('{123}')).toBe('{123}');
expect(processDateFormatPlaceholders('{abc}')).toBe('{abc}');
});

test('should process valid year formats', () => {
const result = processDateFormatPlaceholders('{YYYY}');
expect(result).toMatch(/^\d{4}$/); // 4-digit year

const yearShort = processDateFormatPlaceholders('{YY}');
expect(yearShort).toMatch(/^\d{2}$/); // 2-digit year
});

test('should process valid month formats', () => {
const result = processDateFormatPlaceholders('{MM}');
expect(result).toMatch(/^(0[1-9]|1[0-2])$/); // 01-12

const monthName = processDateFormatPlaceholders('{MMMM}');
expect(monthName).toMatch(/^(January|February|March|April|May|June|July|August|September|October|November|December)$/);
});

test('should process valid day formats', () => {
const result = processDateFormatPlaceholders('{DD}');
expect(result).toMatch(/^(0[1-9]|[12][0-9]|3[01])$/); // 01-31

const dayOfWeek = processDateFormatPlaceholders('{dddd}');
expect(dayOfWeek).toMatch(/^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)$/);
});

test('should process combined date format', () => {
const result = processDateFormatPlaceholders('{YYYY-MM-DD}');
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/); // YYYY-MM-DD format
});

test('should process multiple placeholders in one string', () => {
const result = processDateFormatPlaceholders('folder/{YYYY}/{MM}/{YYYY-MM-DD}');
expect(result).toMatch(/^folder\/\d{4}\/\d{2}\/\d{4}-\d{2}-\d{2}$/);
});

test('should mix processed and unprocessed placeholders', () => {
const result = processDateFormatPlaceholders('{YYYY}-{INVALID}-{MM}');
expect(result).toMatch(/^\d{4}-\{INVALID\}-\d{2}$/);
});

test('should handle nested braces correctly', () => {
const result = processDateFormatPlaceholders('{{YYYY}}');
// The outer braces capture "{YYYY}" which contains braces that aren't valid moment tokens
// So it should be left unchanged
expect(result).toBe('{{YYYY}}');
});

test('should handle strings with text around placeholders', () => {
const result = processDateFormatPlaceholders('Daily note {YYYY-MM-DD}.md');
expect(result).toMatch(/^Daily note \d{4}-\d{2}-\d{2}\.md$/);
});

test('should handle hour, minute, second formats', () => {
const hourResult = processDateFormatPlaceholders('{HH}');
expect(hourResult).toMatch(/^\d{2}$/); // 00-23

const minuteResult = processDateFormatPlaceholders('{mm}');
expect(minuteResult).toMatch(/^\d{2}$/); // 00-59

const secondResult = processDateFormatPlaceholders('{ss}');
expect(secondResult).toMatch(/^\d{2}$/); // 00-59
});

test('should handle quarter format', () => {
const result = processDateFormatPlaceholders('{Q}');
expect(result).toMatch(/^[1-4]$/); // 1-4
});

test('should handle week formats', () => {
const weekResult = processDateFormatPlaceholders('{W}');
expect(weekResult).toMatch(/^\d{1,2}$/); // 1-53
});

test('should handle time with AM/PM', () => {
const result = processDateFormatPlaceholders('{h:mm A}');
expect(result).toMatch(/^\d{1,2}:\d{2} (AM|PM)$/);
});

test('should handle locale-aware formats', () => {
const result = processDateFormatPlaceholders('{YYYY-MM-DD, dddd}');
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}, (Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)$/);
});
});
});