diff --git a/src/services/BasesFilterConverter.ts b/src/services/BasesFilterConverter.ts index e7d54d0c1..79d0f1ab9 100644 --- a/src/services/BasesFilterConverter.ts +++ b/src/services/BasesFilterConverter.ts @@ -12,6 +12,7 @@ import { PriorityManager } from "./PriorityManager"; import type TaskNotesPlugin from "../main"; import type { UserMappedField } from "../types/settings"; import { stringifyUnknown } from "../utils/stringUtils"; +import { getNaturalLanguageDateOffsetDays } from "../utils/dateUtils"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; const tasknotesLogger = createTaskNotesLogger({ tag: "Services/BasesFilterConverter" }); @@ -353,6 +354,17 @@ export class BasesFilterConverter { originalProperty: FilterProperty, fieldType?: string ): string { + const naturalLanguageDateExpression = this.convertNaturalLanguageDateCondition( + basesProperty, + operator, + value, + originalProperty, + fieldType + ); + if (naturalLanguageDateExpression) { + return naturalLanguageDateExpression; + } + switch (operator) { case "is": return this.convertIsOperator(basesProperty, value, fieldType); @@ -412,6 +424,66 @@ export class BasesFilterConverter { } } + /** + * Convert a date comparison against a natural language date ("today", "in 3 days", ...) + * to a Bases expression relative to today(). + * + * Without this, the value would be compared as a literal string: `note.due == "today"` + * never matches, and `note.due <= "today"` matches every date because digits sort + * before letters. Dates are compared by their date part, like FilterUtils does. + * + * Returns null when the condition is not a natural language date comparison. + */ + private convertNaturalLanguageDateCondition( + basesProperty: string, + operator: FilterOperator, + value: unknown, + originalProperty: FilterProperty, + fieldType?: string + ): string | null { + const dateProperties: FilterProperty[] = [ + "due", + "scheduled", + "completedDate", + "dateCreated", + "dateModified", + ]; + if (!dateProperties.includes(originalProperty) && fieldType != "date") { + return null; + } + if (typeof value != "string") { + return null; + } + + const offsetDays = getNaturalLanguageDateOffsetDays(value); + if (offsetDays === null) { + return null; + } + + const comparisons: Partial> = { + is: "==", + "is-not": "==", + "is-before": "<", + "is-after": ">", + "is-on-or-before": "<=", + "is-on-or-after": ">=", + }; + const comparison = comparisons[operator]; + if (!comparison) { + return null; + } + + const dateFormat = `format("YYYY-MM-DD")`; + const days = Math.abs(offsetDays); + const relativeDate = + offsetDays == 0 + ? "today()" + : `(today() ${offsetDays > 0 ? "+" : "-"} "${days} ${days == 1 ? "day" : "days"}")`; + const expression = `(!${basesProperty}.isEmpty() && date(${basesProperty}).${dateFormat} ${comparison} ${relativeDate}.${dateFormat})`; + + return operator == "is-not" ? `!${expression}` : expression; + } + /** * Convert "is" operator to Bases expression */ diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts index 09fde369a..831c2bd76 100644 --- a/src/utils/dateUtils.ts +++ b/src/utils/dateUtils.ts @@ -1745,6 +1745,47 @@ export function resolveNaturalLanguageDate(value: string): string { return value; } +/** + * Get a natural language date as a day offset from today (e.g. "today" → 0, "2 weeks ago" → -14). + * Returns null if the value is not a recognized natural language pattern. + * + * Unlike resolveNaturalLanguageDate, this keeps the date relative, so callers can emit + * expressions (such as Bases `today()`) that are evaluated when the filter runs. + */ +export function getNaturalLanguageDateOffsetDays(value: string): number | null { + if (!value || typeof value !== "string") { + return null; + } + + const normalized = value.toLowerCase().trim().replace(/\s+/g, " "); + + const exactOffsets: Record = { + today: 0, + tomorrow: 1, + yesterday: -1, + "next week": 7, + "last week": -7, + }; + if (normalized in exactOffsets) { + return exactOffsets[normalized as keyof typeof exactOffsets]; + } + + const relativePatterns: { pattern: RegExp; daysPerUnit: number }[] = [ + { pattern: /^in\s+(\d+)\s+(days?)$/, daysPerUnit: 1 }, + { pattern: /^(\d+)\s+(days?)\s+ago$/, daysPerUnit: -1 }, + { pattern: /^in\s+(\d+)\s+(weeks?)$/, daysPerUnit: 7 }, + { pattern: /^(\d+)\s+(weeks?)\s+ago$/, daysPerUnit: -7 }, + ]; + for (const { pattern, daysPerUnit } of relativePatterns) { + const match = normalized.match(pattern); + if (match) { + return parseInt(match[1], 10) * daysPerUnit; + } + } + + return null; +} + /** * Get all supported natural language date patterns for UI suggestions */ diff --git a/tests/unit/services/BasesFilterConverter.naturalLanguageDates.test.ts b/tests/unit/services/BasesFilterConverter.naturalLanguageDates.test.ts new file mode 100644 index 000000000..d2a7fb808 --- /dev/null +++ b/tests/unit/services/BasesFilterConverter.naturalLanguageDates.test.ts @@ -0,0 +1,105 @@ +import { BasesFilterConverter } from "../../../src/services/BasesFilterConverter"; +import { getNaturalLanguageDateOffsetDays } from "../../../src/utils/dateUtils"; +import type { FilterOperator, FilterProperty, FilterQuery } from "../../../src/types"; + +function makeConverter(userFields: unknown[] = []) { + return new BasesFilterConverter({ + fieldMapper: { toUserField: (key: string) => key }, + settings: { userFields }, + } as any); +} + +function convert( + property: FilterProperty | string, + operator: FilterOperator, + value: unknown, + userFields: unknown[] = [] +): unknown { + const query = { + type: "group", + id: "root", + conjunction: "and", + children: [{ type: "condition", id: "c1", property, operator, value }], + } as unknown as FilterQuery; + return makeConverter(userFields).convertToBasesFilter(query); +} + +const DAY = `format("YYYY-MM-DD")`; + +describe("BasesFilterConverter natural language dates", () => { + it("converts 'is today' to a date-part comparison with today()", () => { + expect(convert("due", "is", "today")).toBe( + `(!note.due.isEmpty() && date(note.due).${DAY} == today().${DAY})` + ); + }); + + it.each<[FilterOperator, string]>([ + ["is-before", "<"], + ["is-after", ">"], + ["is-on-or-before", "<="], + ["is-on-or-after", ">="], + ])( + "converts '%s today' to a date comparison instead of a string comparison", + (operator, cmp) => { + expect(convert("due", operator, "today")).toBe( + `(!note.due.isEmpty() && date(note.due).${DAY} ${cmp} today().${DAY})` + ); + } + ); + + it("negates 'is-not today'", () => { + expect(convert("scheduled", "is-not", "Today")).toBe( + `!(!note.scheduled.isEmpty() && date(note.scheduled).${DAY} == today().${DAY})` + ); + }); + + it.each([ + ["tomorrow", `(today() + "1 day")`], + ["yesterday", `(today() - "1 day")`], + ["next week", `(today() + "7 days")`], + ["in 3 days", `(today() + "3 days")`], + ["2 weeks ago", `(today() - "14 days")`], + ])("converts '%s' relative to today()", (value, relativeDate) => { + expect(convert("due", "is-on-or-before", value)).toBe( + `(!note.due.isEmpty() && date(note.due).${DAY} <= ${relativeDate}.${DAY})` + ); + }); + + it("uses file timestamps for created and modified dates", () => { + expect(convert("dateCreated", "is", "today")).toBe( + `(!file.ctime.isEmpty() && date(file.ctime).${DAY} == today().${DAY})` + ); + }); + + it("supports user date fields", () => { + const userFields = [{ id: "review", key: "reviewDate", type: "date" }]; + expect(convert("user:review", "is", "today", userFields)).toBe( + `(!note.reviewDate.isEmpty() && date(note.reviewDate).${DAY} == today().${DAY})` + ); + }); + + it("keeps literal dates and non-date properties unchanged", () => { + expect(convert("due", "is-on-or-before", "2026-10-15")).toBe(`note.due <= "2026-10-15"`); + expect(convert("status", "is", "today")).toBe(`note.status == "today"`); + }); +}); + +describe("getNaturalLanguageDateOffsetDays", () => { + it.each([ + ["today", 0], + [" TODAY ", 0], + ["tomorrow", 1], + ["yesterday", -1], + ["last week", -7], + ["in 1 day", 1], + ["5 days ago", -5], + ["in 2 weeks", 14], + ["1 week ago", -7], + ])("returns the offset for '%s'", (value, offset) => { + expect(getNaturalLanguageDateOffsetDays(value)).toBe(offset); + }); + + it.each(["", "2026-10-15", "next month", "someday"])("returns null for '%s'", (value) => { + expect(getNaturalLanguageDateOffsetDays(value)).toBeNull(); + }); +});