mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
add custom date field menus and picker support
This commit is contained in:
parent
81973b9ce7
commit
2b63db9e1a
17 changed files with 491 additions and 30 deletions
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { renameVaultFile } from "../services/VaultMutationService";
|
||||
import { showConfirmationModal } from "../modals/ConfirmationModal";
|
||||
import { DateContextMenu } from "./DateContextMenu";
|
||||
import { DateTimePickerModal } from "../modals/DateTimePickerModal";
|
||||
import {
|
||||
buildWeekdaysOnlyRecurrenceRule,
|
||||
getPluginCalendarLocale,
|
||||
|
|
@ -44,6 +45,7 @@ import {
|
|||
openOrCreateOccurrenceNote,
|
||||
} from "../ui/occurrenceNoteActions";
|
||||
import { createTaskNotesLogger } from "../utils/tasknotesLogger";
|
||||
import type { UserMappedField } from "../types/settings";
|
||||
|
||||
const tasknotesLogger = createTaskNotesLogger({ tag: "Components/TaskContextMenu" });
|
||||
|
||||
|
|
@ -266,6 +268,8 @@ export class TaskContextMenu {
|
|||
);
|
||||
});
|
||||
|
||||
this.addCustomDateFieldMenuItems(task, plugin);
|
||||
|
||||
if (task.recurrence) {
|
||||
this.addRecurringInstanceMenuItems(task, plugin);
|
||||
}
|
||||
|
|
@ -1757,7 +1761,8 @@ export class TaskContextMenu {
|
|||
submenu: Menu,
|
||||
currentValue: string | undefined,
|
||||
onSelect: (value: string | null) => Promise<void>,
|
||||
onCustomDate: () => void
|
||||
onCustomDate: () => void,
|
||||
options: { pickDateTitle?: string } = {}
|
||||
): void {
|
||||
const dateContextMenu = new DateContextMenu({
|
||||
currentValue: currentValue,
|
||||
|
|
@ -1826,7 +1831,7 @@ export class TaskContextMenu {
|
|||
submenu.addSeparator();
|
||||
|
||||
submenu.addItem((item) => {
|
||||
item.setTitle(this.t("contextMenus.date.pickDateTime"));
|
||||
item.setTitle(options.pickDateTitle ?? this.t("contextMenus.date.pickDateTime"));
|
||||
item.setIcon("calendar");
|
||||
item.onClick(onCustomDate);
|
||||
});
|
||||
|
|
@ -1842,6 +1847,113 @@ export class TaskContextMenu {
|
|||
}
|
||||
}
|
||||
|
||||
private addCustomDateFieldMenuItems(task: TaskInfo, plugin: TaskNotesPlugin): void {
|
||||
const dateFields = this.getCustomDateFields(plugin);
|
||||
if (dateFields.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.menu.addItem((item) => {
|
||||
item.setTitle(this.t("contextMenus.task.customDates"));
|
||||
item.setIcon("calendar-days");
|
||||
|
||||
const submenu = getSubmenu(item);
|
||||
dateFields.forEach((field) => {
|
||||
submenu.addItem((fieldItem) => {
|
||||
const fieldLabel = this.getCustomFieldLabel(field);
|
||||
fieldItem.setTitle(fieldLabel);
|
||||
fieldItem.setIcon("calendar");
|
||||
|
||||
const fieldSubmenu = getSubmenu(fieldItem);
|
||||
const currentValue = this.getCustomDateFieldValue(task, field);
|
||||
this.addDateOptions(
|
||||
fieldSubmenu,
|
||||
currentValue,
|
||||
async (value) => {
|
||||
await this.updateCustomDateField(task, plugin, field, value);
|
||||
},
|
||||
() => {
|
||||
this.openCustomDateFieldPicker(task, plugin, field, currentValue);
|
||||
},
|
||||
{
|
||||
pickDateTitle: this.t("modals.task.userFields.pickDate", {
|
||||
field: fieldLabel,
|
||||
}),
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private getCustomDateFields(plugin: TaskNotesPlugin): UserMappedField[] {
|
||||
return (plugin.settings.userFields || []).filter(
|
||||
(field) => field.type === "date" && field.key.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
private getCustomFieldLabel(field: UserMappedField): string {
|
||||
return field.displayName.trim() || field.key || field.id;
|
||||
}
|
||||
|
||||
private getCustomDateFieldValue(task: TaskInfo, field: UserMappedField): string | undefined {
|
||||
const taskRecord = task as unknown as Record<string, unknown>;
|
||||
const value = taskRecord[field.key] ?? task.customProperties?.[field.key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
private openCustomDateFieldPicker(
|
||||
task: TaskInfo,
|
||||
plugin: TaskNotesPlugin,
|
||||
field: UserMappedField,
|
||||
currentValue: string | undefined
|
||||
): void {
|
||||
this.menu.hide();
|
||||
const fieldLabel = this.getCustomFieldLabel(field);
|
||||
const modal = new DateTimePickerModal(plugin.app, {
|
||||
currentDate: currentValue || null,
|
||||
title: this.t("modals.task.userFields.pickDate", { field: fieldLabel }),
|
||||
showTime: false,
|
||||
plugin,
|
||||
onSelect: (date) => {
|
||||
void this.updateCustomDateField(task, plugin, field, date);
|
||||
},
|
||||
});
|
||||
modal.open();
|
||||
}
|
||||
|
||||
private async updateCustomDateField(
|
||||
task: TaskInfo,
|
||||
plugin: TaskNotesPlugin,
|
||||
field: UserMappedField,
|
||||
value: string | null
|
||||
): Promise<void> {
|
||||
const fieldLabel = this.getCustomFieldLabel(field);
|
||||
try {
|
||||
const updatedTask = await plugin.updateTaskProperty(
|
||||
task,
|
||||
field.key as keyof TaskInfo,
|
||||
value || undefined
|
||||
);
|
||||
Object.assign(task, updatedTask);
|
||||
this.options.onUpdate?.();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
tasknotesLogger.error("Error updating custom date field:", {
|
||||
category: "persistence",
|
||||
operation: "updating-custom-date-field",
|
||||
details: { taskPath: task.path, field: field.key },
|
||||
error: errorMessage,
|
||||
});
|
||||
new Notice(
|
||||
this.t("contextMenus.task.notices.updateCustomDateFailure", {
|
||||
field: fieldLabel,
|
||||
message: errorMessage,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private addRecurrenceOptions(
|
||||
submenu: Menu,
|
||||
currentValue: string | undefined,
|
||||
|
|
|
|||
|
|
@ -2594,6 +2594,8 @@ export const de: TranslationTree = {
|
|||
},
|
||||
metadata: {
|
||||
totalTrackedTime: "Gesamte erfasste Zeit:",
|
||||
due: "Fällig:",
|
||||
scheduled: "Geplant:",
|
||||
created: "Erstellt:",
|
||||
modified: "Geändert:",
|
||||
file: "Datei:"
|
||||
|
|
@ -2771,6 +2773,7 @@ export const de: TranslationTree = {
|
|||
prioritySelected: "✓ {label}",
|
||||
dueDate: "Fälligkeitsdatum",
|
||||
scheduledDate: "Planungsdatum",
|
||||
customDates: "Benutzerdefinierte Datumsfelder",
|
||||
reminders: "Erinnerungen",
|
||||
remindBeforeDue: "Vor Fälligkeit erinnern…",
|
||||
remindBeforeScheduled: "Vor Planung erinnern…",
|
||||
|
|
@ -2878,6 +2881,7 @@ export const de: TranslationTree = {
|
|||
toggleSkipFailure: "Überspringen der wiederkehrenden Aufgabe konnte nicht umgeschaltet werden: {message}",
|
||||
updateDueDateFailure: "Aufgaben-Fälligkeitsdatum konnte nicht aktualisiert werden: {message}",
|
||||
updateScheduledFailure: "Aufgaben-Planungsdatum konnte nicht aktualisiert werden: {message}",
|
||||
updateCustomDateFailure: "Fehler beim Aktualisieren von {field}: {message}",
|
||||
updateRemindersFailure: "Erinnerungen konnten nicht aktualisiert werden",
|
||||
clearRemindersFailure: "Erinnerungen konnten nicht gelöscht werden",
|
||||
addReminderFailure: "Erinnerung konnte nicht hinzugefügt werden",
|
||||
|
|
|
|||
|
|
@ -2753,6 +2753,8 @@ export const en: TranslationTree = {
|
|||
},
|
||||
metadata: {
|
||||
totalTrackedTime: "Total tracked time:",
|
||||
due: "Due:",
|
||||
scheduled: "Scheduled:",
|
||||
created: "Created:",
|
||||
modified: "Modified:",
|
||||
file: "File:",
|
||||
|
|
@ -2940,6 +2942,7 @@ export const en: TranslationTree = {
|
|||
clearTags: "Clear tags",
|
||||
dueDate: "Due date",
|
||||
scheduledDate: "Scheduled date",
|
||||
customDates: "Custom dates",
|
||||
reminders: "Reminders",
|
||||
remindBeforeDue: "Remind before due…",
|
||||
remindBeforeScheduled: "Remind before scheduled…",
|
||||
|
|
@ -3047,6 +3050,7 @@ export const en: TranslationTree = {
|
|||
toggleSkipFailure: "Failed to toggle recurring task skip: {message}",
|
||||
updateDueDateFailure: "Failed to update task due date: {message}",
|
||||
updateScheduledFailure: "Failed to update task scheduled date: {message}",
|
||||
updateCustomDateFailure: "Failed to update {field}: {message}",
|
||||
updateRemindersFailure: "Failed to update reminders",
|
||||
clearRemindersFailure: "Failed to clear reminders",
|
||||
addReminderFailure: "Failed to add reminder",
|
||||
|
|
|
|||
|
|
@ -2594,6 +2594,8 @@ export const es: TranslationTree = {
|
|||
},
|
||||
metadata: {
|
||||
totalTrackedTime: "Tiempo total rastreado:",
|
||||
due: "Vencimiento:",
|
||||
scheduled: "Programada:",
|
||||
created: "Creado:",
|
||||
modified: "Modificado:",
|
||||
file: "Archivo:"
|
||||
|
|
@ -2771,6 +2773,7 @@ export const es: TranslationTree = {
|
|||
prioritySelected: "✓ {label}",
|
||||
dueDate: "Fecha de vencimiento",
|
||||
scheduledDate: "Fecha programada",
|
||||
customDates: "Fechas personalizadas",
|
||||
reminders: "Recordatorios",
|
||||
remindBeforeDue: "Recordar antes del vencimiento…",
|
||||
remindBeforeScheduled: "Recordar antes de programado…",
|
||||
|
|
@ -2878,6 +2881,7 @@ export const es: TranslationTree = {
|
|||
toggleSkipFailure: "Error al alternar omisión de tarea recurrente: {message}",
|
||||
updateDueDateFailure: "Error al actualizar fecha de vencimiento de tarea: {message}",
|
||||
updateScheduledFailure: "Error al actualizar fecha programada de tarea: {message}",
|
||||
updateCustomDateFailure: "No se pudo actualizar {field}: {message}",
|
||||
updateRemindersFailure: "Error al actualizar recordatorios",
|
||||
clearRemindersFailure: "Error al limpiar recordatorios",
|
||||
addReminderFailure: "Error al agregar recordatorio",
|
||||
|
|
|
|||
|
|
@ -2594,6 +2594,8 @@ export const fr: TranslationTree = {
|
|||
},
|
||||
metadata: {
|
||||
totalTrackedTime: "Temps suivi total :",
|
||||
due: "Échéance :",
|
||||
scheduled: "Planifiée :",
|
||||
created: "Créée :",
|
||||
modified: "Modifiée :",
|
||||
file: "Fichier :"
|
||||
|
|
@ -2771,6 +2773,7 @@ export const fr: TranslationTree = {
|
|||
prioritySelected: "Priorité sélectionnée : {label}",
|
||||
dueDate: "Échéance",
|
||||
scheduledDate: "Date planifiée",
|
||||
customDates: "Dates personnalisées",
|
||||
reminders: "Rappels",
|
||||
remindBeforeDue: "Rappeler avant l'échéance…",
|
||||
remindBeforeScheduled: "Rappeler avant la date planifiée…",
|
||||
|
|
@ -2878,6 +2881,7 @@ export const fr: TranslationTree = {
|
|||
toggleSkipFailure: "Impossible de modifier l'omission de tâche récurrente : {message}",
|
||||
updateDueDateFailure: "Impossible de mettre à jour l'échéance : {message}",
|
||||
updateScheduledFailure: "Impossible de mettre à jour la date planifiée : {message}",
|
||||
updateCustomDateFailure: "Échec de la mise à jour de {field} : {message}",
|
||||
updateRemindersFailure: "Impossible de mettre à jour les rappels",
|
||||
clearRemindersFailure: "Impossible de supprimer les rappels",
|
||||
addReminderFailure: "Impossible d'ajouter un rappel",
|
||||
|
|
|
|||
|
|
@ -2594,6 +2594,8 @@ export const ja: TranslationTree = {
|
|||
},
|
||||
metadata: {
|
||||
totalTrackedTime: "総追跡時間:",
|
||||
due: "期限:",
|
||||
scheduled: "予定:",
|
||||
created: "作成:",
|
||||
modified: "変更:",
|
||||
file: "ファイル:"
|
||||
|
|
@ -2771,6 +2773,7 @@ export const ja: TranslationTree = {
|
|||
prioritySelected: "✓ {label}",
|
||||
dueDate: "期限日",
|
||||
scheduledDate: "予定日",
|
||||
customDates: "カスタム日付",
|
||||
reminders: "リマインダー",
|
||||
remindBeforeDue: "期限前にリマインド…",
|
||||
remindBeforeScheduled: "予定前にリマインド…",
|
||||
|
|
@ -2878,6 +2881,7 @@ export const ja: TranslationTree = {
|
|||
toggleSkipFailure: "繰り返しタスクのスキップ切り替えに失敗しました:{message}",
|
||||
updateDueDateFailure: "タスク期限日の更新に失敗しました:{message}",
|
||||
updateScheduledFailure: "タスク予定日の更新に失敗しました:{message}",
|
||||
updateCustomDateFailure: "{field}の更新に失敗しました:{message}",
|
||||
updateRemindersFailure: "リマインダーの更新に失敗しました",
|
||||
clearRemindersFailure: "リマインダーのクリアに失敗しました",
|
||||
addReminderFailure: "リマインダーの追加に失敗しました",
|
||||
|
|
|
|||
|
|
@ -2755,6 +2755,7 @@ export const ko: TranslationTree = {
|
|||
prioritySelected: "✓ {label}",
|
||||
dueDate: "마감일",
|
||||
scheduledDate: "예정일",
|
||||
customDates: "사용자 지정 날짜",
|
||||
reminders: "리마인더",
|
||||
remindBeforeDue: "마감 전 알림...",
|
||||
remindBeforeScheduled: "예정 전 알림...",
|
||||
|
|
@ -2862,6 +2863,7 @@ export const ko: TranslationTree = {
|
|||
toggleSkipFailure: "반복 작업 건너뛰기 토글 실패: {message}",
|
||||
updateDueDateFailure: "작업 마감일 업데이트 실패: {message}",
|
||||
updateScheduledFailure: "작업 예정일 업데이트 실패: {message}",
|
||||
updateCustomDateFailure: "{field} 업데이트 실패: {message}",
|
||||
updateRemindersFailure: "리마인더 업데이트 실패",
|
||||
clearRemindersFailure: "리마인더 지우기 실패",
|
||||
addReminderFailure: "리마인더 추가 실패",
|
||||
|
|
|
|||
|
|
@ -2596,6 +2596,8 @@ export const pt: TranslationTree = {
|
|||
},
|
||||
metadata: {
|
||||
totalTrackedTime: "Tempo total registrado:",
|
||||
due: "Vencimento:",
|
||||
scheduled: "Agendada:",
|
||||
created: "Criada:",
|
||||
modified: "Modificada:",
|
||||
file: "Arquivo:"
|
||||
|
|
@ -2773,6 +2775,7 @@ export const pt: TranslationTree = {
|
|||
prioritySelected: "✓ {label}",
|
||||
dueDate: "Data de vencimento",
|
||||
scheduledDate: "Data agendada",
|
||||
customDates: "Datas personalizadas",
|
||||
reminders: "Lembretes",
|
||||
remindBeforeDue: "Lembrar antes do vencimento…",
|
||||
remindBeforeScheduled: "Lembrar antes da data agendada…",
|
||||
|
|
@ -2880,6 +2883,7 @@ export const pt: TranslationTree = {
|
|||
toggleSkipFailure: "Falha ao alternar pulo de tarefa recorrente: {message}",
|
||||
updateDueDateFailure: "Falha ao atualizar data de vencimento da tarefa: {message}",
|
||||
updateScheduledFailure: "Falha ao atualizar data agendada da tarefa: {message}",
|
||||
updateCustomDateFailure: "Falha ao atualizar {field}: {message}",
|
||||
updateRemindersFailure: "Falha ao atualizar lembretes",
|
||||
clearRemindersFailure: "Falha ao limpar lembretes",
|
||||
addReminderFailure: "Falha ao adicionar lembrete",
|
||||
|
|
|
|||
|
|
@ -2594,6 +2594,8 @@ export const ru: TranslationTree = {
|
|||
},
|
||||
metadata: {
|
||||
totalTrackedTime: "Общее отслеженное время:",
|
||||
due: "Срок:",
|
||||
scheduled: "Запланировано:",
|
||||
created: "Создано:",
|
||||
modified: "Изменено:",
|
||||
file: "Файл:"
|
||||
|
|
@ -2771,6 +2773,7 @@ export const ru: TranslationTree = {
|
|||
prioritySelected: "✓ {label}",
|
||||
dueDate: "Срок выполнения",
|
||||
scheduledDate: "Запланированная дата",
|
||||
customDates: "Пользовательские даты",
|
||||
reminders: "Напоминания",
|
||||
remindBeforeDue: "Напомнить до срока выполнения…",
|
||||
remindBeforeScheduled: "Напомнить до запланированной даты…",
|
||||
|
|
@ -2878,6 +2881,7 @@ export const ru: TranslationTree = {
|
|||
toggleSkipFailure: "Не удалось переключить пропуск повторяющейся задачи: {message}",
|
||||
updateDueDateFailure: "Не удалось обновить срок выполнения задачи: {message}",
|
||||
updateScheduledFailure: "Не удалось обновить запланированную дату задачи: {message}",
|
||||
updateCustomDateFailure: "Не удалось обновить {field}: {message}",
|
||||
updateRemindersFailure: "Не удалось обновить напоминания",
|
||||
clearRemindersFailure: "Не удалось очистить напоминания",
|
||||
addReminderFailure: "Не удалось добавить напоминание",
|
||||
|
|
|
|||
|
|
@ -2594,6 +2594,8 @@ export const zh: TranslationTree = {
|
|||
},
|
||||
metadata: {
|
||||
totalTrackedTime: "总跟踪时间:",
|
||||
due: "截止:",
|
||||
scheduled: "计划:",
|
||||
created: "创建:",
|
||||
modified: "修改:",
|
||||
file: "文件:"
|
||||
|
|
@ -2770,6 +2772,7 @@ export const zh: TranslationTree = {
|
|||
prioritySelected: "✓ {label}",
|
||||
dueDate: "到期日期",
|
||||
scheduledDate: "安排日期",
|
||||
customDates: "自定义日期",
|
||||
reminders: "提醒",
|
||||
remindBeforeDue: "到期前提醒...",
|
||||
remindBeforeScheduled: "安排前提醒...",
|
||||
|
|
@ -2877,6 +2880,7 @@ export const zh: TranslationTree = {
|
|||
toggleSkipFailure: "切换重复任务跳过失败:{message}",
|
||||
updateDueDateFailure: "更新任务到期日期失败:{message}",
|
||||
updateScheduledFailure: "更新任务安排日期失败:{message}",
|
||||
updateCustomDateFailure: "更新 {field} 失败:{message}",
|
||||
updateRemindersFailure: "更新提醒失败",
|
||||
clearRemindersFailure: "清除提醒失败",
|
||||
addReminderFailure: "添加提醒失败",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export interface DateTimePickerOptions {
|
|||
currentTime?: string | null;
|
||||
title?: string;
|
||||
dateRole?: "due" | "scheduled";
|
||||
showTime?: boolean;
|
||||
plugin?: TaskNotesPlugin;
|
||||
naturalLanguageParser?: NaturalLanguageDateParser;
|
||||
onSelect: (date: string | null, time: string | null) => void;
|
||||
|
|
@ -114,7 +115,9 @@ export class DateTimePickerModal extends Modal {
|
|||
this.renderQuickActions(contentEl);
|
||||
this.renderNaturalLanguageInput(contentEl);
|
||||
this.renderDateInput(contentEl);
|
||||
this.renderTimeInput(contentEl);
|
||||
if (this.shouldShowTime()) {
|
||||
this.renderTimeInput(contentEl);
|
||||
}
|
||||
this.renderActions(contentEl);
|
||||
this.updateSelectButtonState();
|
||||
|
||||
|
|
@ -300,7 +303,10 @@ export class DateTimePickerModal extends Modal {
|
|||
|
||||
private confirmSelectedDate(): void {
|
||||
if (!this.selectedDate) return;
|
||||
this.options.onSelect(this.selectedDate, this.timeInput?.value || null);
|
||||
this.options.onSelect(
|
||||
this.selectedDate,
|
||||
this.shouldShowTime() ? this.timeInput?.value || null : null
|
||||
);
|
||||
this.close();
|
||||
}
|
||||
|
||||
|
|
@ -314,6 +320,10 @@ export class DateTimePickerModal extends Modal {
|
|||
return Boolean(this.options.plugin?.settings.enableNaturalLanguageInput);
|
||||
}
|
||||
|
||||
private shouldShowTime(): boolean {
|
||||
return this.options.showTime !== false;
|
||||
}
|
||||
|
||||
private getNaturalLanguageParser(): NaturalLanguageDateParser | null {
|
||||
if (this.options.naturalLanguageParser) return this.options.naturalLanguageParser;
|
||||
if (!this.options.plugin?.settings.enableNaturalLanguageInput) return null;
|
||||
|
|
@ -332,7 +342,7 @@ export class DateTimePickerModal extends Modal {
|
|||
return;
|
||||
}
|
||||
|
||||
this.options.onSelect(selection.date, selection.time);
|
||||
this.options.onSelect(selection.date, this.shouldShowTime() ? selection.time : null);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Setting, setIcon, type App, type TextComponent } from "obsidian";
|
||||
import { DateContextMenu } from "../components/DateContextMenu";
|
||||
import type TaskNotesPlugin from "../main";
|
||||
import { DateTimePickerModal } from "./DateTimePickerModal";
|
||||
import type { UserMappedField } from "../types/settings";
|
||||
import { attachDateInputBehavior } from "../ui/dateInputBehavior";
|
||||
import { stringifyUnknown } from "../utils/stringUtils";
|
||||
|
|
@ -266,16 +266,19 @@ function createTaskModalDateUserField(
|
|||
setIcon(button, "calendar");
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
const menu = new DateContextMenu({
|
||||
currentValue: getTextValue(text) || undefined,
|
||||
const picker = new DateTimePickerModal(context.app, {
|
||||
currentDate: getTextValue(text) || null,
|
||||
title: context.translate("modals.task.userFields.pickDate", {
|
||||
field: field.displayName,
|
||||
}),
|
||||
showTime: false,
|
||||
plugin: context.plugin,
|
||||
onSelect: (value) => {
|
||||
text.setValue(value || "");
|
||||
options.onValueChange(field.key, parseNullableTextUserFieldInput(value));
|
||||
},
|
||||
plugin: context.plugin,
|
||||
app: context.app,
|
||||
});
|
||||
menu.showAtElement(button);
|
||||
picker.open();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -230,22 +230,32 @@ export class NaturalLanguageParser extends NaturalLanguageParserCore {
|
|||
const userFields = parsed.userFields as Record<string, unknown>;
|
||||
|
||||
for (const userField of this.taskNotesUserFields) {
|
||||
if (userField.type !== "boolean") continue;
|
||||
|
||||
const value = userFields[userField.id];
|
||||
if (typeof value !== "string") continue;
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "true") {
|
||||
userFields[userField.id] = true;
|
||||
} else if (normalized === "false") {
|
||||
userFields[userField.id] = false;
|
||||
if (userField.type === "boolean") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "true") {
|
||||
userFields[userField.id] = true;
|
||||
} else if (normalized === "false") {
|
||||
userFields[userField.id] = false;
|
||||
}
|
||||
} else if (userField.type === "date") {
|
||||
userFields[userField.id] = this.normalizeDateUserFieldValue(value) ?? value;
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private normalizeDateUserFieldValue(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const parsed = super.parseInput(trimmed);
|
||||
return parsed.scheduledDate || parsed.dueDate || null;
|
||||
}
|
||||
|
||||
private extractLinkedUserFields(input: string, parsed: ParsedTaskData): ParsedTaskData {
|
||||
const triggers = this.taskNotesNlpTriggers?.triggers || [];
|
||||
if (triggers.length === 0 || this.taskNotesUserFields.length === 0) {
|
||||
|
|
|
|||
|
|
@ -100,4 +100,35 @@ describe("Issue #1462: NLP date edits", () => {
|
|||
|
||||
expect(onSelect).toHaveBeenCalledWith("2026-01-23", "08:00");
|
||||
});
|
||||
|
||||
it("can run as a date-only picker for custom date fields", () => {
|
||||
const onSelect = jest.fn();
|
||||
const parser = {
|
||||
parseInput: jest.fn(() =>
|
||||
parsedTaskData({
|
||||
scheduledDate: "2026-01-24",
|
||||
scheduledTime: "09:30",
|
||||
})
|
||||
),
|
||||
};
|
||||
const modal = new DateTimePickerModal({} as any, {
|
||||
showTime: false,
|
||||
naturalLanguageParser: parser,
|
||||
onSelect,
|
||||
});
|
||||
|
||||
modal.open();
|
||||
|
||||
expect(modal.contentEl.querySelector(".date-time-picker-modal__time-field")).toBeNull();
|
||||
|
||||
const input = modal.contentEl.querySelector<HTMLInputElement>(
|
||||
".date-time-picker-modal__nlp-input"
|
||||
);
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
input!.value = "next Saturday at 9:30am";
|
||||
input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("2026-01-24", null);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
62
tests/unit/issues/issue-2058-nlp-date-user-fields.test.ts
Normal file
62
tests/unit/issues/issue-2058-nlp-date-user-fields.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type TaskNotesPlugin from "../../../src/main";
|
||||
import { NaturalLanguageParser } from "../../../src/services/NaturalLanguageParser";
|
||||
import { buildTaskCreationDataFromParsed } from "../../../src/services/buildTaskCreationDataFromParsed";
|
||||
import { DEFAULT_SETTINGS } from "../../../src/settings/defaults";
|
||||
import type { NLPTriggersConfig, UserMappedField } from "../../../src/types/settings";
|
||||
|
||||
const DATE_FIELD: UserMappedField = {
|
||||
id: "snoozed",
|
||||
displayName: "Snoozed",
|
||||
key: "snoozed",
|
||||
type: "date",
|
||||
};
|
||||
|
||||
const NLP_TRIGGERS: NLPTriggersConfig = {
|
||||
triggers: [{ propertyId: "snoozed", trigger: "s:", enabled: true }],
|
||||
};
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function tomorrowDate(): string {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
return formatDate(tomorrow);
|
||||
}
|
||||
|
||||
function createParser(): NaturalLanguageParser {
|
||||
return new NaturalLanguageParser([], [], true, "en", NLP_TRIGGERS, [DATE_FIELD]);
|
||||
}
|
||||
|
||||
function createPlugin(): TaskNotesPlugin {
|
||||
return {
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
userFields: [DATE_FIELD],
|
||||
},
|
||||
} as unknown as TaskNotesPlugin;
|
||||
}
|
||||
|
||||
describe("Issue #2058: NLP date user fields", () => {
|
||||
it("parses natural-language values for date-type custom fields", () => {
|
||||
const parsed = createParser().parseInput("Review invoices s:tomorrow");
|
||||
|
||||
expect(parsed.title).toBe("Review invoices");
|
||||
expect((parsed.userFields as Record<string, unknown>).snoozed).toBe(tomorrowDate());
|
||||
|
||||
const taskData = buildTaskCreationDataFromParsed(createPlugin(), parsed);
|
||||
|
||||
expect(taskData.customFrontmatter).toEqual({ snoozed: tomorrowDate() });
|
||||
});
|
||||
|
||||
it("leaves unrecognized date custom field values untouched", () => {
|
||||
const parsed = createParser().parseInput("Review invoices s:later");
|
||||
|
||||
expect(parsed.title).toBe("Review invoices");
|
||||
expect(parsed.userFields).toEqual({ snoozed: "later" });
|
||||
});
|
||||
});
|
||||
195
tests/unit/issues/issue-2060-custom-date-context-menu.test.ts
Normal file
195
tests/unit/issues/issue-2060-custom-date-context-menu.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { App, Menu } from "obsidian";
|
||||
import { TaskContextMenu } from "../../../src/components/TaskContextMenu";
|
||||
import { DateTimePickerModal } from "../../../src/modals/DateTimePickerModal";
|
||||
import { createI18nService } from "../../../src/i18n";
|
||||
import type TaskNotesPlugin from "../../../src/main";
|
||||
import type { TaskInfo } from "../../../src/types";
|
||||
|
||||
jest.mock("../../../src/modals/DateTimePickerModal", () => ({
|
||||
DateTimePickerModal: jest.fn().mockImplementation(() => ({
|
||||
open: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
type MockMenuItem = {
|
||||
setTitle?: jest.Mock;
|
||||
setIcon?: jest.Mock;
|
||||
onClick?: jest.Mock;
|
||||
submenu?: MockMenu;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
type MockMenu = {
|
||||
items: MockMenuItem[];
|
||||
hide: jest.Mock;
|
||||
};
|
||||
|
||||
const menuMock = Menu as unknown as jest.Mock;
|
||||
const dateTimePickerMock = DateTimePickerModal as unknown as jest.Mock;
|
||||
|
||||
function createTask(overrides: Record<string, unknown> = {}): TaskInfo {
|
||||
return {
|
||||
id: "Tasks/custom-date-menu.md",
|
||||
path: "Tasks/custom-date-menu.md",
|
||||
title: "Custom date menu",
|
||||
status: "open",
|
||||
priority: "normal",
|
||||
archived: false,
|
||||
tags: [],
|
||||
contexts: [],
|
||||
projects: [],
|
||||
...overrides,
|
||||
} as unknown as TaskInfo;
|
||||
}
|
||||
|
||||
function createPlugin(): TaskNotesPlugin {
|
||||
const app = new App();
|
||||
return {
|
||||
app,
|
||||
i18n: createI18nService(),
|
||||
settings: {
|
||||
customStatuses: [],
|
||||
customPriorities: [],
|
||||
userFields: [
|
||||
{
|
||||
id: "snoozed",
|
||||
key: "snoozed",
|
||||
displayName: "Snoozed",
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
id: "energy",
|
||||
key: "energy",
|
||||
displayName: "Energy",
|
||||
type: "number",
|
||||
},
|
||||
],
|
||||
calendarViewSettings: {
|
||||
enableTimeblocking: false,
|
||||
},
|
||||
useFrontmatterMarkdownLinks: true,
|
||||
},
|
||||
statusManager: {
|
||||
getAllStatuses: jest.fn(() => []),
|
||||
getNonCompletionStatuses: jest.fn(() => []),
|
||||
isCompletedStatus: jest.fn(() => false),
|
||||
},
|
||||
priorityManager: {
|
||||
getAllPriorities: jest.fn(() => []),
|
||||
getPrioritiesByWeight: jest.fn(() => []),
|
||||
},
|
||||
taskService: {
|
||||
toggleRecurringTaskSkipped: jest.fn(),
|
||||
updateBlockingRelationships: jest.fn(),
|
||||
deleteTask: jest.fn(),
|
||||
},
|
||||
cacheManager: {
|
||||
getAllContexts: jest.fn(() => []),
|
||||
getAllTasks: jest.fn(() => []),
|
||||
getTaskInfo: jest.fn(),
|
||||
},
|
||||
updateTaskProperty: jest.fn(async (task, property, value) => ({
|
||||
...task,
|
||||
[property]: value,
|
||||
})),
|
||||
toggleRecurringTaskComplete: jest.fn(),
|
||||
getActiveTimeSession: jest.fn(() => null),
|
||||
stopTimeTracking: jest.fn(),
|
||||
startTimeTracking: jest.fn(),
|
||||
openDueDateModal: jest.fn(),
|
||||
openScheduledDateModal: jest.fn(),
|
||||
openTimeEntryEditor: jest.fn(),
|
||||
toggleTaskArchive: jest.fn(),
|
||||
openTaskEditModal: jest.fn(),
|
||||
openTaskCreationModal: jest.fn(),
|
||||
} as unknown as TaskNotesPlugin;
|
||||
}
|
||||
|
||||
function getTopLevelMenu(): MockMenu {
|
||||
return menuMock.mock.results[0].value as MockMenu;
|
||||
}
|
||||
|
||||
function findMenuItemByTitle(menu: MockMenu | undefined, title: string): MockMenuItem | undefined {
|
||||
return menu?.items.find((item) =>
|
||||
item.setTitle?.mock.calls.some(([value]) => value === title)
|
||||
);
|
||||
}
|
||||
|
||||
function createMenuContext(task = createTask({ snoozed: "2026-06-24" })) {
|
||||
const plugin = createPlugin();
|
||||
const onUpdate = jest.fn();
|
||||
|
||||
new TaskContextMenu({
|
||||
task,
|
||||
plugin,
|
||||
targetDate: new Date("2026-06-21T12:00:00"),
|
||||
onUpdate,
|
||||
});
|
||||
|
||||
const customDatesItem = findMenuItemByTitle(getTopLevelMenu(), "Custom dates");
|
||||
const snoozedItem = findMenuItemByTitle(customDatesItem?.submenu, "Snoozed");
|
||||
|
||||
return {
|
||||
plugin,
|
||||
onUpdate,
|
||||
topLevelMenu: getTopLevelMenu(),
|
||||
customDatesItem,
|
||||
snoozedItem,
|
||||
snoozedMenu: snoozedItem?.submenu,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Issue #2060: custom date fields in the task context menu", () => {
|
||||
beforeEach(() => {
|
||||
menuMock.mockClear();
|
||||
dateTimePickerMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
menuMock.mockClear();
|
||||
dateTimePickerMock.mockClear();
|
||||
});
|
||||
|
||||
it("adds date-type user fields to a Custom dates submenu", () => {
|
||||
const { customDatesItem, snoozedItem, snoozedMenu } = createMenuContext();
|
||||
|
||||
expect(customDatesItem).toBeDefined();
|
||||
expect(customDatesItem?.setIcon).toHaveBeenCalledWith("calendar-days");
|
||||
expect(snoozedItem).toBeDefined();
|
||||
expect(findMenuItemByTitle(customDatesItem?.submenu, "Energy")).toBeUndefined();
|
||||
expect(findMenuItemByTitle(snoozedMenu, "Pick Snoozed date")).toBeDefined();
|
||||
expect(findMenuItemByTitle(snoozedMenu, "Clear date")).toBeDefined();
|
||||
});
|
||||
|
||||
it("clears custom dates through the configured field key", async () => {
|
||||
const task = createTask({ snoozed: "2026-06-24" });
|
||||
const { plugin, onUpdate, snoozedMenu } = createMenuContext(task);
|
||||
const clearItem = findMenuItemByTitle(snoozedMenu, "Clear date");
|
||||
|
||||
clearItem?.onClick?.mock.calls[0][0]();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(plugin.updateTaskProperty).toHaveBeenCalledWith(task, "snoozed", undefined);
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("opens the date-only picker for custom date fields", () => {
|
||||
const { plugin, topLevelMenu, snoozedMenu } = createMenuContext();
|
||||
const pickItem = findMenuItemByTitle(snoozedMenu, "Pick Snoozed date");
|
||||
|
||||
pickItem?.onClick?.mock.calls[0][0]();
|
||||
|
||||
expect(topLevelMenu.hide).toHaveBeenCalledTimes(1);
|
||||
expect(dateTimePickerMock).toHaveBeenCalledWith(
|
||||
plugin.app,
|
||||
expect.objectContaining({
|
||||
currentDate: "2026-06-24",
|
||||
title: "Pick Snoozed date",
|
||||
showTime: false,
|
||||
plugin,
|
||||
})
|
||||
);
|
||||
expect(dateTimePickerMock.mock.results[0].value.open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -12,12 +12,14 @@ const mockToggleControls: Array<{
|
|||
setValue: (value: boolean) => unknown;
|
||||
}> = [];
|
||||
|
||||
const mockDateMenus: Array<{
|
||||
const mockDateTimePickers: Array<{
|
||||
options: {
|
||||
currentValue?: string | null;
|
||||
onSelect: (value: string | null) => void;
|
||||
currentDate?: string | null;
|
||||
showTime?: boolean;
|
||||
onSelect: (value: string | null, time: string | null) => void;
|
||||
};
|
||||
showAtElement: jest.Mock;
|
||||
open: jest.Mock;
|
||||
}> = [];
|
||||
|
||||
const mockSetIcon = jest.fn();
|
||||
|
|
@ -102,14 +104,14 @@ jest.mock("obsidian", () => ({
|
|||
setIcon: mockSetIcon,
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/components/DateContextMenu", () => ({
|
||||
DateContextMenu: jest.fn().mockImplementation((options) => {
|
||||
const menu = {
|
||||
jest.mock("../../../src/modals/DateTimePickerModal", () => ({
|
||||
DateTimePickerModal: jest.fn().mockImplementation((_app, options) => {
|
||||
const picker = {
|
||||
options,
|
||||
showAtElement: jest.fn(),
|
||||
open: jest.fn(),
|
||||
};
|
||||
mockDateMenus.push(menu);
|
||||
return menu;
|
||||
mockDateTimePickers.push(picker);
|
||||
return picker;
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
@ -154,7 +156,7 @@ describe("taskModalUserFieldControls", () => {
|
|||
document.body.innerHTML = "";
|
||||
mockTextControls.length = 0;
|
||||
mockToggleControls.length = 0;
|
||||
mockDateMenus.length = 0;
|
||||
mockDateTimePickers.length = 0;
|
||||
mockSetIcon.mockClear();
|
||||
mockUserFieldSuggest.mockClear();
|
||||
mockAttachDateInputBehavior.mockClear();
|
||||
|
|
@ -306,7 +308,7 @@ describe("taskModalUserFieldControls", () => {
|
|||
mockTextControls[3].onChangeCallback?.("");
|
||||
mockToggleControls[0].onChangeCallback?.(false);
|
||||
dateButton?.click();
|
||||
mockDateMenus[0].options.onSelect("2026-06-03");
|
||||
mockDateTimePickers[0].options.onSelect("2026-06-03", null);
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith("assignee", null);
|
||||
expect(onValueChange).toHaveBeenCalledWith("labels", ["one", "two"]);
|
||||
|
|
@ -314,7 +316,9 @@ describe("taskModalUserFieldControls", () => {
|
|||
expect(onValueChange).toHaveBeenCalledWith("custom_date", null);
|
||||
expect(onValueChange).toHaveBeenCalledWith("custom_date", "2026-06-03");
|
||||
expect(onValueChange).toHaveBeenCalledWith("flagged", false);
|
||||
expect(mockDateMenus[0].showAtElement).toHaveBeenCalledWith(dateButton);
|
||||
expect(mockDateTimePickers[0].options.currentDate).toBe("2026-05-19");
|
||||
expect(mockDateTimePickers[0].options.showTime).toBe(false);
|
||||
expect(mockDateTimePickers[0].open).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates existing user-field input and toggle controls from modal state", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue