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
43 changes: 43 additions & 0 deletions docs/docs/FormatSyntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,49 @@ Replace `<DATEFORMAT>` with a [Moment.js date format](https://momentjs.com/docs/

Example: `{{DATE:YYYY-MM-DD_HH-mm}}` or `{{DATE:YYYY-MM-DD+3}}`.

## `{{DATE:<DATEFORMAT>|startof:<unit>}}` / `{{...|endof:<unit>}}` {#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.

`<unit>` 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:<variable name>, <date format>}}` {#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!
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 16 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export const GLOBAL_VAR_SYNTAX = "{{global_var:<name>}}";
export const FORMAT_SYNTAX: string[] = [
DATE_SYNTAX,
"{{date:<dateformat>}}",
"{{date:<dateformat>|startof:<unit>}}",
"{{date:<dateformat>|endof:<unit>}}",
"{{vdate:<variable name>, <date format>}}",
"{{vdate:<variable name>, <date format>|<default value>}}",
VDATE_OPTIONAL_SYNTAX,
Expand Down Expand Up @@ -77,6 +79,8 @@ export const FORMAT_SYNTAX: string[] = [
export const FILE_NAME_FORMAT_SYNTAX: string[] = [
DATE_SYNTAX,
"{{date:<dateformat>}}",
"{{date:<dateformat>|startof:<unit>}}",
"{{date:<dateformat>|endof:<unit>}}",
"{{vdate:<variable name>, <date format>}}",
"{{vdate:<variable name>, <date format>|<default value>}}",
GLOBAL_VAR_SYNTAX,
Expand Down Expand Up @@ -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):<letters>)?` 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);
Expand Down
129 changes: 129 additions & 0 deletions src/formatters/formatter-datesnap.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
24 changes: 20 additions & 4 deletions src/formatters/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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)) {
Expand All @@ -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 }),
);
}

Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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) {
Expand Down
17 changes: 1 addition & 16 deletions src/gui/ChoiceBuilder/components/InsertAfterFields.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -149,22 +150,6 @@ const ordering = $derived<SectionOrdering>(
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(
Expand Down
Loading