Skip to content
Closed
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
72 changes: 72 additions & 0 deletions src/services/BasesFilterConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<Record<FilterOperator, string>> = {
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
*/
Expand Down
41 changes: 41 additions & 0 deletions src/utils/dateUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof typeof NATURAL_LANGUAGE_DATE_PATTERNS, number> = {
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
*/
Expand Down
105 changes: 105 additions & 0 deletions tests/unit/services/BasesFilterConverter.naturalLanguageDates.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});