Skip to content
Merged
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
6 changes: 6 additions & 0 deletions src/engine/MacroChoiceEngine.entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ vi.mock("../gui/GenericInputPrompt/GenericInputPrompt", () => ({
Prompt: mockInputPrompt,
},
}));
vi.mock("../gui/VDateInputPrompt/VDateInputPrompt", () => ({
__esModule: true,
default: {
Prompt: vi.fn(),
},
}));
vi.mock("../gui/GenericCheckboxPrompt/genericCheckboxPrompt", () => ({
__esModule: true,
default: { Open: vi.fn() },
Expand Down
38 changes: 22 additions & 16 deletions src/formatters/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,24 +472,30 @@ export abstract class Formatter {
variableName,
{ type: "VDATE", dateFormat, defaultValue }
);
this.variables.set(variableName, dateInput);

if (!this.dateParser) throw new Error("Date parser is not available");

const aliasMap = settingsStore.getState().dateAliases;
const normalizedInput = normalizeDateInput(dateInput, aliasMap);
const parseAttempt = this.dateParser.parseDate(normalizedInput);

if (parseAttempt) {
// Store the ISO string with a special prefix
this.variables.set(
variableName,
`@date:${parseAttempt.moment.toISOString()}`,
);
if (dateInput?.startsWith("@date:")) {
this.variables.set(variableName, dateInput);
} else {
throw new Error(
`unable to parse date variable ${dateInput}`,
if (!this.dateParser)
throw new Error("Date parser is not available");

const aliasMap = settingsStore.getState().dateAliases;
const normalizedInput = normalizeDateInput(
dateInput,
aliasMap,
);
const parseAttempt = this.dateParser.parseDate(normalizedInput);

if (parseAttempt) {
// Store the ISO string with a special prefix
this.variables.set(
variableName,
`@date:${parseAttempt.moment.toISOString()}`,
);
} else {
throw new Error(
`unable to parse date variable ${dateInput}`,
);
}
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/formatters/vdate-default.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,16 @@ describe('VDATE Default Value Support', () => {
expect(result).toBe("Test YYYY-MM-DD-formatted");
});

it('should accept @date: values returned from prompts', async () => {
const input = "Test {{VDATE:date,YYYY-MM-DD}}";
formatter.setMockPromptValue("@date:2025-08-01T00:00:00.000Z");

const result = await formatter.testReplaceDateVariableInString(input);

expect(formatter.testDateParser.parseDate).not.toHaveBeenCalled();
expect(result).toBe("Test YYYY-MM-DD-formatted");
});

it('should use user input over default value', async () => {
const input = "Test {{VDATE:date,YYYY-MM-DD|today}}";
formatter.setMockPromptValue("tomorrow");
Expand Down
9 changes: 7 additions & 2 deletions src/gui/GenericInputPrompt/GenericInputPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default class GenericInputPrompt extends Modal {
private resolvePromise: (input: string) => void;
private rejectPromise: (reason?: unknown) => void;
private didSubmit = false;
private inputComponent: TextComponent;
protected inputComponent: TextComponent;
protected input: string;
private readonly placeholder: string;
private readonly draftHandler: InputPromptDraftHandler;
Expand Down Expand Up @@ -174,8 +174,13 @@ export default class GenericInputPrompt extends Modal {
}
};

protected transformInputOnSubmit(input: string): string {
return input;
}

private submit() {
this.input = this.inputComponent?.inputEl?.value ?? this.input;
const rawInput = this.inputComponent?.inputEl?.value ?? this.input;
this.input = this.transformInputOnSubmit(rawInput);
this.didSubmit = true;

this.close();
Expand Down
167 changes: 144 additions & 23 deletions src/gui/VDateInputPrompt/VDateInputPrompt.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { App, Debouncer } from "obsidian";
import { TextComponent, debounce } from "obsidian";
import GenericInputPrompt from "../GenericInputPrompt/GenericInputPrompt";
import { parseNaturalLanguageDate } from "../../utils/dateParser";
import { createDatePicker, type DatePickerController } from "../date-picker/datePicker";
import { formatISODate, parseNaturalLanguageDate } from "../../utils/dateParser";
import { settingsStore } from "../../settingsStore";
import {
formatDateAliasInline,
Expand All @@ -15,6 +16,9 @@ export default class VDateInputPrompt extends GenericInputPrompt {
private currentInput: string;
private isOpen = true;
private defaultValue: string | undefined;
private datePicker?: DatePickerController;
private selectedIso?: string;
private lastPickerDisplayValue?: string;
private static readonly PREVIEW_PLACEHOLDER = "Preview will appear here";

public static Prompt(
Expand Down Expand Up @@ -43,29 +47,29 @@ export default class VDateInputPrompt extends GenericInputPrompt {
) {
// Pass the defaultValue to the parent so the input box is pre-filled
super(app, header, placeholder, defaultValue ?? "");


this.containerEl.addClass("qaDatePrompt");
this.dateFormat = dateFormat || "YYYY-MM-DD";
this.defaultValue = defaultValue;
this.currentInput = defaultValue ?? "";

// Create debounced preview update function (250ms delay, reset on each call)
this.updatePreviewDebounced = debounce(
this.updatePreview.bind(this),
250,
true // Reset timer on each call (standard debounce behavior)
);

// Trigger initial preview update now that all fields are properly set
if (this.defaultValue) {
this.updatePreview();
}
this.updatePreview();
}

protected createInputField(
container: HTMLElement,
placeholder?: string,
value?: string
) {
container.addClass("qa-date-input");

// Create TextComponent directly to avoid duplicate onChange listeners
const textComponent = new TextComponent(container);

Expand All @@ -74,6 +78,7 @@ export default class VDateInputPrompt extends GenericInputPrompt {
.setPlaceholder(placeholder ?? "")
.setValue(value ?? "")
.onChange((newValue) => {
this.lastPickerDisplayValue = undefined;
this.onInputChanged(newValue);
this.currentInput = newValue;
this.updatePreviewDebounced();
Expand All @@ -82,13 +87,30 @@ export default class VDateInputPrompt extends GenericInputPrompt {

// Initialize currentInput with the initial value (which should be defaultValue)
this.currentInput = value ?? "";


this.createDatePicker(container);

// Create preview element
this.createPreviewElement(container);

return textComponent;
}

private createDatePicker(container: HTMLElement) {
const pickerContainer = container.createDiv({
cls: "qa-date-picker-container",
});

this.datePicker = createDatePicker({
container: pickerContainer,
initialIso: this.selectedIso,
onSelect: (iso) => {
if (iso) this.applyPickerSelection(iso);
else this.clearPickerSelection();
},
});
}

private createPreviewElement(container: HTMLElement) {
const previewContainer = container.createDiv("vdate-preview-container");
previewContainer.style.marginTop = "0.5rem";
Expand Down Expand Up @@ -143,38 +165,109 @@ export default class VDateInputPrompt extends GenericInputPrompt {
private updatePreview() {
// Don't update if modal is closed
if (!this.isOpen) return;

const input = this.currentInput.trim();

// If no input and we have a default, show preview for default
if (!input && this.defaultValue) {
this.renderPreview(this.defaultValue);
this.renderPreviewFromInput(this.defaultValue);
return;
}

if (!input) {
this.selectedIso = undefined;
this.lastPickerDisplayValue = undefined;
this.syncPickerSelection();
this.setPreviewText(VDateInputPrompt.PREVIEW_PLACEHOLDER, false);
return;
}


if (input.startsWith("@date:")) {
const iso = input.slice(6).trim();
if (iso) {
this.selectedIso = iso;
this.lastPickerDisplayValue = undefined;
this.syncPickerSelection(iso);
this.renderPreviewFromIso(iso);
return;
}
}

if (
this.selectedIso &&
this.lastPickerDisplayValue &&
input === this.lastPickerDisplayValue
) {
this.syncPickerSelection(this.selectedIso, false);
this.renderPreviewFromIso(this.selectedIso);
return;
}

// If input matches default value or regular input, render the preview
this.renderPreview(input);
this.renderPreviewFromInput(input);
}

private renderPreview(value: string) {
private formatIsoForInput(iso: string): string {
const formatted = formatISODate(iso, this.dateFormat);
if (formatted) return formatted;
return iso.length >= 10 ? iso.slice(0, 10) : iso;
}

private syncPickerSelection(iso?: string, updateView = true) {
this.datePicker?.setSelectedIso(iso, { updateView });
}

private applyPickerSelection(iso: string) {
const displayValue = this.formatIsoForInput(iso);
this.selectedIso = iso;
this.lastPickerDisplayValue = displayValue;
if (this.inputComponent?.inputEl) {
this.inputComponent.inputEl.value = displayValue;
}
this.onInputChanged(displayValue);
this.currentInput = displayValue;
this.syncPickerSelection(iso);
this.renderPreviewFromIso(iso);
}

private clearPickerSelection() {
if (this.inputComponent?.inputEl) {
this.inputComponent.inputEl.value = "";
}
this.onInputChanged("");
this.currentInput = "";
this.selectedIso = undefined;
this.lastPickerDisplayValue = undefined;
this.syncPickerSelection();
this.updatePreview();
}

private renderPreviewFromIso(iso: string) {
this.setPreviewText(this.formatIsoForInput(iso), false);
}

private renderPreviewFromInput(value: string) {
const parseResult = parseNaturalLanguageDate(value, this.dateFormat);

if (parseResult.isValid && parseResult.formatted) {
this.setPreviewText(parseResult.formatted, false);

if (parseResult.isValid && parseResult.isoString) {
this.selectedIso = parseResult.isoString;
this.lastPickerDisplayValue = undefined;
this.syncPickerSelection(parseResult.isoString);
const formatted =
parseResult.formatted ?? this.formatIsoForInput(parseResult.isoString);
this.setPreviewText(formatted, false);
} else {
this.selectedIso = undefined;
this.lastPickerDisplayValue = undefined;
this.syncPickerSelection();
const errorMessage = parseResult.error || "Unable to parse date";
this.setPreviewText(errorMessage, true);
}
}

private setPreviewText(text: string, isError: boolean) {
this.previewEl.textContent = text;

if (isError) {
this.previewEl.style.color = "var(--text-error)";
} else {
Expand All @@ -186,18 +279,46 @@ export default class VDateInputPrompt extends GenericInputPrompt {
super.onOpen();
}

protected transformInputOnSubmit(input: string): string {
const trimmed = input.trim();
if (trimmed.startsWith("@date:")) return trimmed;
if (
this.selectedIso &&
this.lastPickerDisplayValue &&
trimmed === this.lastPickerDisplayValue
) {
return `@date:${this.selectedIso}`;
}
if (!trimmed && this.defaultValue) {
const parsed = parseNaturalLanguageDate(
this.defaultValue,
this.dateFormat,
);
if (parsed.isValid && parsed.isoString) {
return `@date:${parsed.isoString}`;
}
}
if (trimmed) {
const parsed = parseNaturalLanguageDate(trimmed, this.dateFormat);
if (parsed.isValid && parsed.isoString) {
return `@date:${parsed.isoString}`;
}
}
return input;
}

onClose() {
// Prevent any pending debounced updates
this.isOpen = false;

// Cancel any pending debounced calls
this.updatePreviewDebounced.cancel();

// If input is empty and we have a default, use the default
if (!this.input.trim() && this.defaultValue) {
this.input = this.defaultValue;
}

super.onClose();
}
}
Loading