diff --git a/docs/docs/FormatSyntax.md b/docs/docs/FormatSyntax.md index a1de0a48..b3686945 100644 --- a/docs/docs/FormatSyntax.md +++ b/docs/docs/FormatSyntax.md @@ -14,6 +14,49 @@ Replace `` with a [Moment.js date format](https://momentjs.com/docs/ Example: `{{DATE:YYYY-MM-DD_HH-mm}}` or `{{DATE:YYYY-MM-DD+3}}`. +## `{{DATE:|startof:}}` / `{{...|endof:}}` {#date-snap} + +Snap the date to the **start** or **end** of a period before formatting. The +formatted output then reflects that boundary instead of the exact instant — so, +for example, the month of a week-snapped date is the month the *week* belongs to, +not today's calendar month. + +`` is one of: `year`, `quarter`, `month`, `week`, `isoweek`, `day` (case-insensitive). + +- `week` uses your locale's first day of the week (matching the `w`/`ww`/`gggg` tokens). +- `isoweek` uses Monday (matching the `W`/`WW`/`GGGG` tokens). + +| Token (on Thursday 2023-06-01) | Output | +| --- | --- | +| `{{DATE:gggg.MM.[Wk]w\|startof:week}}` | `2023.05.Wk22` | +| `{{DATE:YYYY-MM\|startof:month}}` | `2023-06` | +| `{{DATE:YYYY-MM-DD\|endof:month}}` | `2023-06-30` | +| `{{DATE:YYYY-[Q]Q\|startof:quarter}}` | `2023-Q2` | +| `{{DATE:GGGG-[W]WW\|startof:isoweek}}` | ISO week, Monday-anchored | + +**Weekly notes that cross months (issue #511).** A planner named `gggg.MM.[Wk]w` +should file the week of June 1 under May (`2023.05.Wk22`), while the in-note +heading still uses the actual day. Snap only the filename: + +```markdown +# filename +{{DATE:gggg.MM.[Wk]w|startof:week}} +# heading inside the note +{{DATE:M.DD dddd}} +``` + +This also works on `{{VDATE}}` — a single picked date can be week-snapped in one +place and day-actual in another: `{{VDATE:d,gggg.MM.[Wk]w|startof:week}}` and +`{{VDATE:d,M.DD dddd}}` share the same prompt. Combine freely with `|default`, +`|optional`, and `|time` in any order. + +**Notes:** + +- The `+N` day offset is applied **before** the snap, so `{{DATE:YYYY-MM-DD+7|startof:week}}` is "the start of next week". +- `endof:` snaps to the last moment of the period (`23:59:59.999`), so with a time format `{{DATE:YYYY-MM-DD HH:mm|endof:day}}` renders `... 23:59`. +- `|startof:` and `|endof:` are the only reserved pipe options in a date format; any other literal `|` is still rendered verbatim (e.g. `{{DATE:YYYY|MM}}` → `2023|06`). +- An unknown unit (e.g. `|startof:fortnight`) reports an error listing the valid units. + ## `{{VDATE:, }}` {#vdate} You'll get prompted to enter a date and it'll be parsed to the given date format. You could write 'today' or 'in two weeks' and it'll give you the date for that. Short aliases like `t` (today), `tm` (tomorrow), and `yd` (yesterday) are also supported and configurable in settings. Works like variables, so you can use the date in multiple places with different formats - enter once, format many times! diff --git a/package.json b/package.json index 61030dac..607fee61 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "eslint-plugin-svelte": "^3.19.0", "globals": "17", "jsdom": "29", + "moment": "2.29.4", "obsidian": "1.13.1", "obsidian-e2e": "0.6.0", "semantic-release": "^25.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a6ce80f..5ba78e5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: jsdom: specifier: '29' version: 29.1.1 + moment: + specifier: 2.29.4 + version: 2.29.4 obsidian: specifier: 1.13.1 version: 1.13.1(@codemirror/state@6.5.0)(@codemirror/view@6.38.6) @@ -3532,7 +3535,7 @@ snapshots: '@codemirror/view@6.38.6': dependencies: - '@codemirror/state': 6.5.0 + '@codemirror/state': 6.6.0 crelt: 1.0.6 style-mod: 4.1.3 w3c-keyname: 2.2.8 diff --git a/src/constants.ts b/src/constants.ts index 3769dfc1..12d22934 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -37,6 +37,8 @@ export const GLOBAL_VAR_SYNTAX = "{{global_var:}}"; export const FORMAT_SYNTAX: string[] = [ DATE_SYNTAX, "{{date:}}", + "{{date:|startof:}}", + "{{date:|endof:}}", "{{vdate:, }}", "{{vdate:, |}}", VDATE_OPTIONAL_SYNTAX, @@ -77,6 +79,8 @@ export const FORMAT_SYNTAX: string[] = [ export const FILE_NAME_FORMAT_SYNTAX: string[] = [ DATE_SYNTAX, "{{date:}}", + "{{date:|startof:}}", + "{{date:|endof:}}", "{{vdate:, }}", "{{vdate:, |}}", GLOBAL_VAR_SYNTAX, @@ -109,9 +113,19 @@ export const CREATE_IF_NOT_FOUND_CURSOR = "cursor"; export const CREATE_IF_NOT_FOUND_ORDERED = "ordered"; // == Format Syntax == // -export const DATE_REGEX = new RegExp(/{{DATE(\+-?[0-9]+)?}}/i); +// The optional trailing `(?:\|(?:startof|endof):)?` group captures a +// date "snap" option (issue #511). It is anchored to `|startof:`/`|endof:` +// followed by letters, so a literal `|` anywhere else in a date format is still +// rendered verbatim ({{DATE:YYYY|MM}} -> 2023|06). The format slot's pipe arm +// `\|(?!(?:startof|endof):[a-z])` is mutually exclusive with `[^}\n\r+|]`, so +// the star is deterministic (no quadratic backtracking on malformed input) and a +// `|startof:` that is NOT a real snap (e.g. inside a `[...]` literal, or followed +// by a space/non-letter) stays part of the format instead of aborting the run. +export const DATE_REGEX = new RegExp( + /{{DATE(\+-?[0-9]+)?(?:\|((?:startof|endof):[a-z]+))?}}/i, +); export const DATE_REGEX_FORMATTED = new RegExp( - /{{DATE:([^}\n\r+]*)(\+-?[0-9]+)?}}/i, + /{{DATE:((?:[^}\n\r+|]|\|(?!(?:startof|endof):[a-z]))*)(\+-?[0-9]+)?(?:\|((?:startof|endof):[a-z]+))?}}/i, ); export const TIME_REGEX = new RegExp(/{{TIME}}/i); export const TIME_REGEX_FORMATTED = new RegExp(/{{TIME:([^}\n\r+]*)}}/i); diff --git a/src/formatters/formatter-datesnap.test.ts b/src/formatters/formatter-datesnap.test.ts new file mode 100644 index 00000000..f4a83f1f --- /dev/null +++ b/src/formatters/formatter-datesnap.test.ts @@ -0,0 +1,129 @@ +import realMoment from "moment"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { Formatter, type PromptContext } from "./formatter"; + +// Integration test for the issue #511 snap wiring THROUGH the formatter passes +// (not just the regex/unit helpers). Uses real moment + a frozen clock so the +// rendered output is deterministic. en locale = Sunday-first week. +const originalMoment = (window as unknown as { moment?: unknown }).moment; +const previousLocale = realMoment.locale(); + +beforeAll(() => { + realMoment.locale("en"); + (window as unknown as { moment: unknown }).moment = realMoment; +}); +afterAll(() => { + (window as unknown as { moment?: unknown }).moment = originalMoment; + realMoment.locale(previousLocale); + vi.useRealTimers(); +}); +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2023-06-01T12:00:00")); // Thursday +}); + +class TestFormatter extends Formatter { + constructor() { + super(); + // Truthy so the @date: format branch in replaceDateVariableInString runs; + // parseDate is never called for a pre-seeded @date: value. + this.dateParser = { parseDate: () => null } as never; + } + public seed(name: string, value: unknown) { + this.variables.set(name, value); + } + public renderDate(input: string) { + return this.replaceDateInString(input); + } + public renderVDate(input: string) { + return this.replaceDateVariableInString(input); + } + protected async format(input: string) { + return input; + } + protected promptForValue(): string { + return ""; + } + protected getCurrentFileLink() { + return null; + } + protected getCurrentFileName() { + return null; + } + protected suggestForFile() { + return ""; + } + protected async promptForMathValue() { + return ""; + } + protected getVariableValue(name: string): string { + const v = this.variables.get(name); + return v == null ? "" : String(v); + } + protected suggestForValue() { + return ""; + } + protected async suggestForField() { + return ""; + } + protected getMacroValue() { + return ""; + } + protected async promptForVariable(_n: string, _c?: PromptContext) { + return ""; + } + protected async getTemplateContent() { + return ""; + } + protected async getSelectedText() { + return ""; + } + protected async getClipboardContent() { + return ""; + } + protected isTemplatePropertyTypesEnabled() { + return false; + } +} + +describe("{{DATE}} snap through replaceDateInString", () => { + let f: TestFormatter; + beforeEach(() => { + f = new TestFormatter(); + }); + + it("renders the #511 week-snapped filename and leaves naive DATE/heading alone", () => { + expect(f.renderDate("{{DATE:gggg.MM.[Wk]w|startof:week}}")).toBe("2023.05.Wk22"); + expect(f.renderDate("{{DATE:gggg.MM.[Wk]w}}")).toBe("2023.06.Wk22"); + expect(f.renderDate("{{DATE:M.DD dddd}}")).toBe("6.01 Thursday"); + }); + + it("renders endof:month and offset-then-snap", () => { + expect(f.renderDate("{{DATE:YYYY-MM-DD|endof:month}}")).toBe("2023-06-30"); + expect(f.renderDate("{{DATE:YYYY-MM-DD+7|startof:week}}")).toBe("2023-06-04"); + }); + + it("keeps a literal pipe byte-identical and a [literal |startof: x] intact", () => { + expect(f.renderDate("{{DATE:YYYY|MM}}")).toBe("2023|06"); + expect( + f.renderDate("{{DATE:[x |startof: y ]YYYY-MM-DD|startof:month}}"), + ).toBe("x |startof: y 2023-06-01"); + }); + + it("throws (does not silently no-op) on an unknown unit", () => { + expect(() => f.renderDate("{{DATE:YYYY|startof:fortnight}}")).toThrowError( + /Valid units/, + ); + }); +}); + +describe("{{VDATE}} snap through replaceDateVariableInString", () => { + it("snaps per-occurrence: one picked date, filename snapped + heading day-actual", async () => { + const f = new TestFormatter(); + f.seed("d", "@date:2023-06-01T12:00:00"); // Thursday + const out = await f.renderVDate( + "{{VDATE:d,gggg.MM.[Wk]w|startof:week}} :: {{VDATE:d,M.DD dddd}}", + ); + expect(out).toBe("2023.05.Wk22 :: 6.01 Thursday"); + }); +}); diff --git a/src/formatters/formatter.ts b/src/formatters/formatter.ts index b6115044..e81903fb 100644 --- a/src/formatters/formatter.ts +++ b/src/formatters/formatter.ts @@ -47,6 +47,7 @@ import { type ValueInputType, } from "../utils/valueSyntax"; import { parseVDateOptions } from "../utils/vdateSyntax"; +import { applyDateSnap, parseDateSnapSegment } from "../utils/dateModifiers"; import { parseMacroToken } from "../utils/macroSyntax"; import { formatUnknownValue } from "../utils/conditionalHelpers"; @@ -139,7 +140,12 @@ export abstract class Formatter { const offsetIsInt = NUMBER_REGEX.test(offsetString); if (offsetIsInt) offset = parseInt(offsetString); } - output = this.replacer(output, DATE_REGEX, getDate({ offset: offset })); + // dateMatch[2] holds a `startof:`/`endof:` snap option (issue #511); + // the regex only matches those keywords, so a bad unit throws here. + const snap = dateMatch?.[2] + ? parseDateSnapSegment(dateMatch[2]) ?? undefined + : undefined; + output = this.replacer(output, DATE_REGEX, getDate({ offset, snap })); } while (DATE_REGEX_FORMATTED.test(output)) { @@ -155,10 +161,14 @@ export abstract class Formatter { if (offsetIsInt) offset = parseInt(offsetString); } + const snap = dateMatch[3] + ? parseDateSnapSegment(dateMatch[3]) ?? undefined + : undefined; + output = this.replacer( output, DATE_REGEX_FORMATTED, - getDate({ format, offset: offset }), + getDate({ format, offset, snap }), ); } @@ -1101,7 +1111,7 @@ export abstract class Formatter { if (!match || !match[1]) break; const variableName = match[1].trim(); - const { defaultValue, optional, withTime } = parseVDateOptions(match[3]); + const { defaultValue, optional, withTime, snap } = parseVDateOptions(match[3]); // A |time/|datetime token with no explicit format gets a datetime // default so the rendered value carries the picked time. const dateFormat = @@ -1200,7 +1210,13 @@ export abstract class Formatter { if (this.dateParser && window.moment) { const moment = window.moment(isoString); if (moment && moment.isValid()) { - formattedDate = moment.format(dateFormat); + // Snap is per-occurrence (issue #511): the stored + // @date:ISO stays raw, so {{VDATE:d,F1|startof:week}} + // and {{VDATE:d,F2}} share one picked date but only one + // snaps. A fresh moment per iteration prevents leaks. + formattedDate = applyDateSnap(moment, snap).format( + dateFormat, + ); } } } else if (typeof storedValue === "string" && storedValue) { diff --git a/src/gui/ChoiceBuilder/components/InsertAfterFields.svelte b/src/gui/ChoiceBuilder/components/InsertAfterFields.svelte index cfc8c175..d651a89e 100644 --- a/src/gui/ChoiceBuilder/components/InsertAfterFields.svelte +++ b/src/gui/ChoiceBuilder/components/InsertAfterFields.svelte @@ -11,6 +11,7 @@ import { CREATE_IF_NOT_FOUND_TOP, } from "../../../constants"; import { FormatSyntaxSuggester } from "../../suggesters/formatSyntaxSuggester"; +import { detectDateFormatFromAfter } from "../../../utils/insertAfterDateFormat"; import { FormatDisplayFormatter } from "../../../formatters/formatDisplayFormatter"; import SettingItem from "../../components/SettingItem.svelte"; import Toggle from "../../components/Toggle.svelte"; @@ -149,22 +150,6 @@ const ordering = $derived( insertAfter.orderBy ?? { by: "insertion", direction: "desc", unparseable: "bottom" }, ); -/** - * Extract a moment date format from a `{{DATE:fmt}}` / `{{VDATE:name,fmt}}` token - * so an ordered date log auto-detects its sort format. Returns undefined for a - * bare `{{DATE}}`, a literal-`+` format, or a multi-token heading — those can't be - * reliably round-tripped, so the UI falls back to insertion + a warning. - */ -function detectDateFormatFromAfter(after: string): string | undefined { - const date = after.match(/\{\{DATE:([^}\n\r+]*)(?:\+-?\d+)?\}\}/i); - if (date?.[1]?.trim()) return date[1].trim(); - // VDATE is {{VDATE:name,format}} or {{VDATE:name,format|default}} — drop the - // "|default" segment so it doesn't leak into the moment parse format. - const vdate = after.match(/\{\{VDATE:[^,}]+,([^}\n\r]*)\}\}/i); - const vdateFormat = vdate?.[1]?.split("|")[0]?.trim(); - if (vdateFormat) return vdateFormat; - return undefined; -} const afterHasDateToken = $derived(/\{\{V?DATE\b/i.test(insertAfter.after ?? "")); const showInsertionFallbackWarning = $derived( diff --git a/src/utils/dateModifiers.test.ts b/src/utils/dateModifiers.test.ts new file mode 100644 index 00000000..0e3fd2c1 --- /dev/null +++ b/src/utils/dateModifiers.test.ts @@ -0,0 +1,114 @@ +import moment from "moment"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + applyDateSnap, + normalizeDateUnit, + parseDateSnapSegment, +} from "./dateModifiers"; + +// `week` is locale-dependent (first day of week); pin to en (Sunday-first) so +// the assertions are deterministic. `isoWeek` is always Monday-anchored. +// Restore the global locale afterwards so this never leaks into other suites. +const previousLocale = moment.locale(); +beforeAll(() => { + moment.locale("en"); +}); +afterAll(() => { + moment.locale(previousLocale); +}); + +describe("normalizeDateUnit", () => { + it("maps documented units (case-insensitive) to moment units", () => { + expect(normalizeDateUnit("week")).toBe("week"); + expect(normalizeDateUnit("WEEK")).toBe("week"); + expect(normalizeDateUnit("isoweek")).toBe("isoWeek"); + expect(normalizeDateUnit("IsoWeek")).toBe("isoWeek"); + expect(normalizeDateUnit("month")).toBe("month"); + expect(normalizeDateUnit("quarter")).toBe("quarter"); + expect(normalizeDateUnit("year")).toBe("year"); + expect(normalizeDateUnit("day")).toBe("day"); + }); + + it("accepts plural/short aliases", () => { + expect(normalizeDateUnit("weeks")).toBe("week"); + expect(normalizeDateUnit("w")).toBe("week"); + expect(normalizeDateUnit("months")).toBe("month"); + expect(normalizeDateUnit("d")).toBe("day"); + }); + + it("throws a self-correcting error on an unknown unit", () => { + expect(() => normalizeDateUnit("fortnight")).toThrowError( + /Unknown date unit "fortnight"\. Valid units: year, quarter, month, week, isoweek, day\./, + ); + // A no-op-prone typo must fail loudly, never silently pass through. + expect(() => normalizeDateUnit("wek")).toThrow(); + }); +}); + +describe("parseDateSnapSegment", () => { + it("parses startof:/endof: segments", () => { + expect(parseDateSnapSegment("startof:week")).toEqual({ + boundary: "start", + unit: "week", + }); + expect(parseDateSnapSegment("endof:month")).toEqual({ + boundary: "end", + unit: "month", + }); + expect(parseDateSnapSegment("startof:isoweek")).toEqual({ + boundary: "start", + unit: "isoWeek", + }); + }); + + it("returns null for non-snap segments (so callers keep them literal)", () => { + expect(parseDateSnapSegment("optional")).toBeNull(); + expect(parseDateSnapSegment("tomorrow")).toBeNull(); + expect(parseDateSnapSegment("MM")).toBeNull(); + // A typo'd key is not a snap — caller renders it literally. + expect(parseDateSnapSegment("starof:week")).toBeNull(); + }); + + it("throws on a snap key with a bad unit", () => { + expect(() => parseDateSnapSegment("startof:fortnight")).toThrow(); + }); +}); + +describe("applyDateSnap", () => { + const fmt = (iso: string, boundary: "start" | "end", unit: string) => + applyDateSnap(moment(iso), { boundary, unit: unit as never }).format( + "YYYY-MM-DD HH:mm:ss.SSS", + ); + + it("snaps to the start of the week (locale: Sunday)", () => { + // Thu 2023-06-01 -> Sun 2023-05-28 + expect(fmt("2023-06-01", "start", "week")).toBe("2023-05-28 00:00:00.000"); + }); + + it("snaps to the start of the ISO week (Monday)", () => { + expect(fmt("2023-06-01", "start", "isoWeek")).toBe( + "2023-05-29 00:00:00.000", + ); + }); + + it("snaps to start/end of month, quarter, year, day", () => { + expect(fmt("2023-06-16", "start", "month")).toBe("2023-06-01 00:00:00.000"); + expect(fmt("2023-06-16", "end", "month")).toBe("2023-06-30 23:59:59.999"); + expect(fmt("2023-06-16", "start", "quarter")).toBe( + "2023-04-01 00:00:00.000", + ); + expect(fmt("2023-06-16", "start", "year")).toBe("2023-01-01 00:00:00.000"); + expect(fmt("2023-06-16T13:45", "start", "day")).toBe( + "2023-06-16 00:00:00.000", + ); + expect(fmt("2023-06-16T13:45", "end", "day")).toBe( + "2023-06-16 23:59:59.999", + ); + }); + + it("is a no-op when snap is undefined", () => { + expect(applyDateSnap(moment("2023-06-01"), undefined).format("YYYY-MM-DD")).toBe( + "2023-06-01", + ); + }); +}); diff --git a/src/utils/dateModifiers.ts b/src/utils/dateModifiers.ts new file mode 100644 index 00000000..edcc0d0e --- /dev/null +++ b/src/utils/dateModifiers.ts @@ -0,0 +1,90 @@ +import type { Moment, unitOfTime } from "moment"; +import { parsePipeKeyValue } from "./pipeSyntax"; + +/** + * Shared "snap a date to the start/end of a period" support for {{DATE}} and + * {{VDATE}} tokens (issue #511). A snap moves an instant to the boundary of the + * period it belongs to, so e.g. `{{DATE:gggg.MM.[Wk]w|startof:week}}` renders + * the MONTH of the week's first day instead of today's calendar month. + * + * The locale-vs-ISO week distinction is an explicit unit choice — `week` uses + * the locale's first day of week (matching the `w`/`gggg` tokens), `isoweek` + * uses Monday (matching `W`/`GGGG`) — so there is no implicit anchor guessing. + */ + +export type DateSnapBoundary = "start" | "end"; + +export interface DateSnap { + boundary: DateSnapBoundary; + /** Canonical moment unit, e.g. "week", "isoWeek", "month". */ + unit: unitOfTime.StartOf; +} + +// Lower-cased user input -> canonical moment unit. Plural/short aliases are +// accepted for ergonomics; everything else throws so a typo can never silently +// no-op (moment's startOf returns the date unchanged on an unknown unit). +const UNIT_ALIASES: Record = { + year: "year", + years: "year", + y: "year", + quarter: "quarter", + quarters: "quarter", + q: "quarter", + month: "month", + months: "month", + week: "week", + weeks: "week", + w: "week", + isoweek: "isoWeek", + isoweeks: "isoWeek", + day: "day", + days: "day", + d: "day", +}; + +/** Human-facing list of the documented units, used in error messages. */ +export const VALID_DATE_SNAP_UNITS = "year, quarter, month, week, isoweek, day"; + +/** + * Normalises a user-supplied unit to moment's canonical form, throwing a + * self-correcting error on anything unrecognised. + */ +export function normalizeDateUnit(raw: string): unitOfTime.StartOf { + const key = raw.trim().toLowerCase(); + const unit = UNIT_ALIASES[key]; + if (!unit) { + throw new Error( + `Unknown date unit "${raw.trim()}". Valid units: ${VALID_DATE_SNAP_UNITS}.`, + ); + } + return unit; +} + +/** + * Parses a single pipe segment (already stripped of its leading `|`), e.g. + * "startof:week". Returns the snap for a `startof:`/`endof:` segment, or `null` + * when the segment is not a snap option (so callers can treat it as something + * else — a literal for {{DATE}}, a default value for {{VDATE}}). Throws when the + * key IS a snap key but the unit is unknown. + */ +export function parseDateSnapSegment(segment: string): DateSnap | null { + const keyed = parsePipeKeyValue(segment); + if (!keyed) return null; + if (keyed.key === "startof") + return { boundary: "start", unit: normalizeDateUnit(keyed.value) }; + if (keyed.key === "endof") + return { boundary: "end", unit: normalizeDateUnit(keyed.value) }; + return null; +} + +/** + * Applies a snap to a moment IN PLACE (moment mutates) and returns it. Callers + * pass a fresh/cloned instance. `start` -> startOf (00:00:00.000), `end` -> + * endOf (23:59:59.999). + */ +export function applyDateSnap(moment: Moment, snap: DateSnap | undefined): Moment { + if (!snap) return moment; + return snap.boundary === "start" + ? moment.startOf(snap.unit) + : moment.endOf(snap.unit); +} diff --git a/src/utils/dates.snap.test.ts b/src/utils/dates.snap.test.ts new file mode 100644 index 00000000..d0dd05f9 --- /dev/null +++ b/src/utils/dates.snap.test.ts @@ -0,0 +1,173 @@ +import realMoment from "moment"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { DATE_REGEX, DATE_REGEX_FORMATTED } from "../constants"; +import { getDate } from "./dates"; + +// getDate reads the wall clock via window.moment(); install the real moment and +// freeze "now" so the snap behaviour is deterministic. en locale = Sunday-first. +const originalMoment = (window as unknown as { moment?: unknown }).moment; +const previousLocale = realMoment.locale(); + +beforeAll(() => { + realMoment.locale("en"); + (window as unknown as { moment: unknown }).moment = realMoment; +}); +afterAll(() => { + (window as unknown as { moment?: unknown }).moment = originalMoment; + realMoment.locale(previousLocale); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +function freeze(isoLocal: string) { + vi.useFakeTimers(); + vi.setSystemTime(new Date(isoLocal)); +} + +describe("getDate snap — issue #511 canonical guard", () => { + it("week-snaps the month so a weekly note filename matches the week's month", () => { + freeze("2023-06-01T12:00:00"); // Thursday, week belongs to May + // The fix: + expect( + getDate({ + format: "gggg.MM.[Wk]w", + snap: { boundary: "start", unit: "week" }, + }), + ).toBe("2023.05.Wk22"); + // The bug it diverges from (naive {{DATE}} stays today's month): + expect(getDate({ format: "gggg.MM.[Wk]w" })).toBe("2023.06.Wk22"); + // The heading is day-actual and untouched: + expect(getDate({ format: "M.DD dddd" })).toBe("6.01 Thursday"); + }); + + it("keeps the week's month/week-year correct across year boundaries", () => { + freeze("2023-12-31T12:00:00"); // Sunday — its week rolls into week-year 2024 + expect( + getDate({ + format: "gggg.MM.[Wk]w", + snap: { boundary: "start", unit: "week" }, + }), + ).toBe("2024.12.Wk1"); + + freeze("2022-01-01T12:00:00"); // Saturday — week starts Dec 26 2021 + expect( + getDate({ + format: "gggg.MM.[Wk]w", + snap: { boundary: "start", unit: "week" }, + }), + ).toBe("2022.12.Wk1"); + }); +}); + +describe("getDate snap — other units & composition", () => { + beforeEach(() => freeze("2023-06-16T13:45:00")); + + it("startof:month / endof:month", () => { + expect( + getDate({ format: "YYYY-MM-DD", snap: { boundary: "start", unit: "month" } }), + ).toBe("2023-06-01"); + expect( + getDate({ format: "YYYY-MM-DD", snap: { boundary: "end", unit: "month" } }), + ).toBe("2023-06-30"); + }); + + it("ISO week anchors to Monday", () => { + expect( + getDate({ + format: "GGGG-[W]WW", + snap: { boundary: "start", unit: "isoWeek" }, + }), + ).toBe("2023-W24"); + }); + + it("applies the +N days offset BEFORE the snap", () => { + // 2023-06-16 (Fri) + 7 = 2023-06-23 (Fri); startOf week = Sun 2023-06-18. + expect( + getDate({ + format: "YYYY-MM-DD", + offset: 7, + snap: { boundary: "start", unit: "week" }, + }), + ).toBe("2023-06-18"); + }); + + it("leaves the default format / offset behaviour unchanged when no snap", () => { + expect(getDate({ format: "YYYY-MM-DD" })).toBe("2023-06-16"); + expect(getDate()).toBe("2023-06-16"); + expect(getDate({ offset: -1, format: "YYYY-MM-DD" })).toBe("2023-06-15"); + }); +}); + +describe("DATE regex grammar (backward-compat + snap option)", () => { + it("parses a formatted snap option into format + options groups", () => { + const m = "{{DATE:gggg.MM.[Wk]w|startof:week}}".match(DATE_REGEX_FORMATTED); + expect(m?.[1]).toBe("gggg.MM.[Wk]w"); + expect(m?.[2]).toBeUndefined(); // no offset + expect(m?.[3]).toBe("startof:week"); + }); + + it("parses offset + snap together (offset stays before the snap)", () => { + const m = "{{DATE:YYYY-MM-DD+7|startof:week}}".match(DATE_REGEX_FORMATTED); + expect(m?.[1]).toBe("YYYY-MM-DD"); + expect(m?.[2]).toBe("+7"); + expect(m?.[3]).toBe("startof:week"); + }); + + it("keeps a literal pipe in the format byte-identical (no snap keyword)", () => { + const m = "{{DATE:YYYY|MM}}".match(DATE_REGEX_FORMATTED); + expect(m?.[1]).toBe("YYYY|MM"); // whole thing stays the format + expect(m?.[3]).toBeUndefined(); // no snap option captured + }); + + it("treats a |startof: inside a [literal] (non-letter after colon) as format, not snap", () => { + // Only a trailing |startof: is the snap; an earlier |startof: + // with a space/non-letter after the colon stays in the format and must + // NOT abort the run with an 'unknown unit' error. + const m = "{{DATE:[literal |startof: label ]YYYY-MM-DD|startof:month}}".match( + DATE_REGEX_FORMATTED, + ); + expect(m?.[1]).toBe("[literal |startof: label ]YYYY-MM-DD"); + expect(m?.[3]).toBe("startof:month"); + }); + + it("leaves a malformed double-snap token literal (no match, no throw)", () => { + expect( + "{{DATE:YYYY|startof:week|endof:month}}".match(DATE_REGEX_FORMATTED), + ).toBeNull(); + }); + + it("does NOT support offset-before-colon (stays literal)", () => { + expect("{{DATE+7:YYYY-MM-DD|startof:week}}".match(DATE_REGEX_FORMATTED)).toBeNull(); + }); + + it("keeps a literal '+' format unmatched, exactly like master", () => { + expect("{{DATE:YYYY+MM}}".match(DATE_REGEX_FORMATTED)).toBeNull(); + }); + + it("parses bare {{DATE}} with and without a snap option", () => { + expect("{{DATE}}".match(DATE_REGEX)?.[2]).toBeUndefined(); + expect("{{DATE|startof:month}}".match(DATE_REGEX)?.[2]).toBe("startof:month"); + expect("{{DATE+1|endof:week}}".match(DATE_REGEX)?.[1]).toBe("+1"); + expect("{{DATE+1|endof:week}}".match(DATE_REGEX)?.[2]).toBe("endof:week"); + }); + + it("matches snap tokens in LINEAR time on long malformed input (no ReDoS)", () => { + // `|startof:`×N with no closing `}}` is the catastrophic-backtracking shape + // that a lazy/overlapping format slot would blow up on (O(n^2)). The + // mutually-exclusive format arm keeps this linear. + const evil = `{{DATE:${"|startof:".repeat(30000)}`; + const start = performance.now(); + expect(DATE_REGEX_FORMATTED.test(evil)).toBe(false); + expect(performance.now() - start).toBeLessThan(100); + }); +}); diff --git a/src/utils/dates.ts b/src/utils/dates.ts index 5deec6cc..177f8896 100644 --- a/src/utils/dates.ts +++ b/src/utils/dates.ts @@ -1,4 +1,10 @@ -export function getDate(input?: { format?: string; offset?: number; }) { +import { applyDateSnap, type DateSnap } from "./dateModifiers"; + +export function getDate(input?: { + format?: string; + offset?: number; + snap?: DateSnap; +}) { let duration; if ( @@ -9,7 +15,8 @@ export function getDate(input?: { format?: string; offset?: number; }) { duration = window.moment.duration(input.offset, "days"); } - return input?.format - ? window.moment().add(duration).format(input.format) - : window.moment().add(duration).format("YYYY-MM-DD"); + // Order: base instant -> +N days offset -> snap to period boundary -> format. + const moment = applyDateSnap(window.moment().add(duration), input?.snap); + + return moment.format(input?.format ?? "YYYY-MM-DD"); } diff --git a/src/utils/insertAfterDateFormat.test.ts b/src/utils/insertAfterDateFormat.test.ts new file mode 100644 index 00000000..e58b53e3 --- /dev/null +++ b/src/utils/insertAfterDateFormat.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { detectDateFormatFromAfter } from "./insertAfterDateFormat"; + +// Guards the issue #511 regression: the ordered-log sort-format detector must +// strip a |startof:/|endof: snap option but PRESERVE literal pipes, so headings +// for different months still sort correctly. +describe("detectDateFormatFromAfter", () => { + it("strips a snap option, keeping the sort format", () => { + expect(detectDateFormatFromAfter("## {{DATE:gggg.MM.[Wk]w|startof:week}}")).toBe( + "gggg.MM.[Wk]w", + ); + expect(detectDateFormatFromAfter("{{DATE:YYYY-MM-DD|endof:month}}")).toBe( + "YYYY-MM-DD", + ); + }); + + it("strips a snap option that follows an offset", () => { + expect(detectDateFormatFromAfter("{{DATE:YYYY-MM-DD+7|startof:week}}")).toBe( + "YYYY-MM-DD", + ); + }); + + it("PRESERVES a literal pipe in the sort format (the bot-found regression)", () => { + expect(detectDateFormatFromAfter("{{DATE:YYYY|MM}}")).toBe("YYYY|MM"); + expect(detectDateFormatFromAfter("{{DATE:YYYY|MM|startof:month}}")).toBe( + "YYYY|MM", + ); + }); + + it("detects plain DATE and VDATE formats (legacy behaviour)", () => { + expect(detectDateFormatFromAfter("## {{DATE:YYYY-MM-DD}} log")).toBe( + "YYYY-MM-DD", + ); + expect(detectDateFormatFromAfter("{{VDATE:d,gggg-[W]WW}}")).toBe("gggg-[W]WW"); + expect(detectDateFormatFromAfter("{{VDATE:d,gggg-[W]WW|startof:week}}")).toBe( + "gggg-[W]WW", + ); + }); + + it("returns undefined for a bare {{DATE}} or no date token", () => { + expect(detectDateFormatFromAfter("{{DATE}}")).toBeUndefined(); + expect(detectDateFormatFromAfter("# Today")).toBeUndefined(); + // A multi-token heading with no DATE/VDATE token falls back to undefined. + expect(detectDateFormatFromAfter("{{VALUE:x}} — {{TIME}}")).toBeUndefined(); + }); + + it("picks the date token's format from a multi-token heading", () => { + expect(detectDateFormatFromAfter("{{DATE:YYYY-MM-DD}} — {{VALUE:title}}")).toBe( + "YYYY-MM-DD", + ); + }); +}); diff --git a/src/utils/insertAfterDateFormat.ts b/src/utils/insertAfterDateFormat.ts new file mode 100644 index 00000000..5a326f7c --- /dev/null +++ b/src/utils/insertAfterDateFormat.ts @@ -0,0 +1,25 @@ +/** + * Extract a moment date format from a `{{DATE:fmt}}` / `{{VDATE:name,fmt}}` token + * in an ordered capture's "insert after" text, so an ordered date log can + * auto-detect its sort format. Returns undefined for a bare `{{DATE}}`, a + * literal-`+` format, or a multi-token heading — those can't be reliably + * round-tripped, so the UI falls back to insertion + a warning. + * + * The DATE regex mirrors DATE_REGEX_FORMATTED: the snap option (`|startof:`/ + * `|endof:` + letters, issue #511) is excluded from the captured sort format, + * while any other literal `|` is preserved ({{DATE:YYYY|MM}} -> "YYYY|MM"). + */ +export function detectDateFormatFromAfter(after: string): string | undefined { + const date = after.match( + /\{\{DATE:((?:[^}\n\r+|]|\|(?!(?:startof|endof):[a-z]))*)(?:\+-?\d+)?(?:\|(?:startof|endof):[a-z]+)?\}\}/i, + ); + const dateFormat = date?.[1]?.trim(); + if (dateFormat) return dateFormat; + // VDATE is {{VDATE:name,format}} or {{VDATE:name,format|default}} — drop the + // "|default"/"|startof:..." segment so it doesn't leak into the moment parse + // format (the VDATE format itself never contains a literal pipe). + const vdate = after.match(/\{\{VDATE:[^,}]+,([^}\n\r]*)\}\}/i); + const vdateFormat = vdate?.[1]?.split("|")[0]?.trim(); + if (vdateFormat) return vdateFormat; + return undefined; +} diff --git a/src/utils/vdateSyntax.snap.test.ts b/src/utils/vdateSyntax.snap.test.ts new file mode 100644 index 00000000..5ff89f2b --- /dev/null +++ b/src/utils/vdateSyntax.snap.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { parseVDateOptions } from "./vdateSyntax"; + +describe("parseVDateOptions — |startof:/|endof: snap (issue #511)", () => { + it("extracts a snap and keeps it OUT of the default value", () => { + const r = parseVDateOptions("startof:week"); + expect(r.snap).toEqual({ boundary: "start", unit: "week" }); + expect(r.defaultValue).toBeUndefined(); + }); + + it("supports endof and isoweek", () => { + expect(parseVDateOptions("endof:month").snap).toEqual({ + boundary: "end", + unit: "month", + }); + expect(parseVDateOptions("startof:isoweek").snap).toEqual({ + boundary: "start", + unit: "isoWeek", + }); + }); + + it("combines with |default, |optional, |time in any order", () => { + const a = parseVDateOptions("startof:week|next monday|optional"); + expect(a.snap).toEqual({ boundary: "start", unit: "week" }); + expect(a.defaultValue).toBe("next monday"); + expect(a.optional).toBe(true); + + const b = parseVDateOptions("optional|tomorrow|endof:month"); + expect(b.snap).toEqual({ boundary: "end", unit: "month" }); + expect(b.defaultValue).toBe("tomorrow"); + expect(b.optional).toBe(true); + + const c = parseVDateOptions("startof:day|time"); + expect(c.snap).toEqual({ boundary: "start", unit: "day" }); + expect(c.withTime).toBe(true); + }); + + it("preserves a pipe-containing default alongside a snap", () => { + const r = parseVDateOptions("startof:week|YYYY|MM"); + expect(r.snap).toEqual({ boundary: "start", unit: "week" }); + expect(r.defaultValue).toBe("YYYY|MM"); + }); + + it("first snap wins", () => { + expect(parseVDateOptions("startof:week|endof:month").snap).toEqual({ + boundary: "start", + unit: "week", + }); + }); + + it("throws on a snap key with an unknown unit", () => { + expect(() => parseVDateOptions("startof:fortnight")).toThrow(); + }); +}); + +describe("parseVDateOptions — legacy behaviour is unchanged when no snap present", () => { + const cases = [ + "", + "optional", + "tomorrow", + "tomorrow|optional", + "optional|tomorrow", + "time", + "datetime", + "type:datetime", + "next monday|time", + "YYYY|MM|DD", // pipe-containing default (pinned by vdate-default tests) + "3:00 pm tomorrow", + ]; + + it("does not introduce a snap for any existing option string", () => { + for (const c of cases) { + expect(parseVDateOptions(c).snap).toBeUndefined(); + } + }); + + it("keeps defaultValue/optional/withTime identical to pre-snap semantics", () => { + expect(parseVDateOptions("tomorrow|optional")).toMatchObject({ + defaultValue: "tomorrow", + optional: true, + withTime: false, + }); + expect(parseVDateOptions("YYYY|MM|DD").defaultValue).toBe("YYYY|MM|DD"); + expect(parseVDateOptions("next monday|time")).toMatchObject({ + defaultValue: "next monday", + withTime: true, + }); + }); +}); diff --git a/src/utils/vdateSyntax.ts b/src/utils/vdateSyntax.ts index ad3b35fd..0c1397ec 100644 --- a/src/utils/vdateSyntax.ts +++ b/src/utils/vdateSyntax.ts @@ -4,12 +4,15 @@ import { parsePipeKeyValue, splitPipeParts, } from "./pipeSyntax"; +import { type DateSnap, normalizeDateUnit } from "./dateModifiers"; export type ParsedVDateOptions = { defaultValue?: string; optional: boolean; /** Token requested a date AND time picker via |time / |datetime / |type:datetime. */ withTime: boolean; + /** Snap the resolved date to a period boundary via |startof:/|endof: (issue #511). */ + snap?: DateSnap; }; /** @@ -46,6 +49,7 @@ export function parseVDateOptions( let explicitOptional: boolean | undefined; let keyedDatetime = false; + let snap: DateSnap | undefined; const rest: string[] = []; for (const part of remaining) { @@ -60,6 +64,17 @@ export function parseVDateOptions( if (keyed.value.trim().toLowerCase() === "datetime") keyedDatetime = true; continue; } + if (keyed?.key === "startof" || keyed?.key === "endof") { + // |startof: / |endof: snap the resolved date (issue #511). + // First wins; a bad unit throws. Never part of the default value. + if (!snap) { + snap = { + boundary: keyed.key === "startof" ? "start" : "end", + unit: normalizeDateUnit(keyed.value), + }; + } + continue; + } rest.push(part); } @@ -69,5 +84,6 @@ export function parseVDateOptions( defaultValue, optional: explicitOptional ?? bareOptional, withTime: bareTime || bareDatetime || keyedDatetime, + snap, }; }