Skip to content

Commit e04a3f6

Browse files
authored
feat: support short-form date aliases
Add configurable date aliases for natural language date inputs. Includes normalization in parsing/VDATE, settings UI for aliases, and expandable alias hints in date prompts.
1 parent 62a67f4 commit e04a3f6

File tree

12 files changed

+308
-8
lines changed

12 files changed

+308
-8
lines changed

docs/docs/Advanced/onePageInputs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ This feature is currently in Beta.
2323

2424
## Date UX
2525
- Date fields support natural language (e.g., “today”, “next friday”).
26+
- Short aliases like `t` (today), `tm` (tomorrow), and `yd` (yesterday) are supported and configurable in settings.
2627
- The modal shows a formatted preview and stores a normalized `@date:ISO` internally.
2728

2829
## FIELD UX

docs/docs/FormatSyntax.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ title: Format syntax
66
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
77
| `{{DATE}}` | Outputs the current date in `YYYY-MM-DD` format. You could write `{{DATE+3}}` to offset the date with 3 days. You can use `+-3` to offset with `-3` days. |
88
| `{{DATE:<DATEFORMAT>}}` | Replace `<DATEFORMAT>` with a [Moment.js date format](https://momentjs.com/docs/#/displaying/format/). You could write `{{DATE<DATEFORMAT>+3}}` to offset the date with 3 days. |
9-
| `{{VDATE:<variable name>, <date format>}}` | 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. Works like variables, so you can use the date in multiple places with different formats - enter once, format many times! Example: `{{VDATE:date,YYYY}}/{{VDATE:date,MM}}/{{VDATE:date,DD}}` |
10-
| `{{VDATE:<variable name>, <date format>\|<default>}}` | Same as above, but with a default value. If you leave the prompt empty, the default value will be used instead. Example: `{{VDATE:date,YYYY-MM-DD\|today}}` will use "today" if no input is provided. Default values can be any natural language date like "tomorrow", "next monday", "+7 days", etc. **Note:** If your date format contains pipe characters (`|`), you'll need to escape them as `\|` or use square brackets like `[|]` to avoid conflicts with the default value separator. |
9+
| `{{VDATE:<variable name>, <date format>}}` | 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! Example: `{{VDATE:date,YYYY}}/{{VDATE:date,MM}}/{{VDATE:date,DD}}` |
10+
| `{{VDATE:<variable name>, <date format>\|<default>}}` | Same as above, but with a default value. If you leave the prompt empty, the default value will be used instead. Example: `{{VDATE:date,YYYY-MM-DD\|today}}` will use "today" if no input is provided. Default values can be any natural language date like "tomorrow", "next monday", "+7 days", etc. Short aliases like `t`, `tm`, and `yd` work here too. **Note:** If your date format contains pipe characters (`|`), you'll need to escape them as `\|` or use square brackets like `[|]` to avoid conflicts with the default value separator. |
1111
| `{{VALUE}}` or `{{NAME}}` | Interchangeable. Represents the value given in an input prompt. If text is selected in the current editor, it will be used as the value. When using the QuickAdd API, this can be passed programmatically using the reserved variable name 'value'. |
1212
| `{{VALUE:<variable name>}}` | You can now use variable names in values. They'll get saved and inserted just like values, but the difference is that you can have as many of them as you want. Use comma separation to get a suggester rather than a prompt. |
1313
| `{{VALUE:<variable name>\|<default>}}` | Same as above, but with a default value. For single-value prompts (e.g., `{{VALUE:name\|Anonymous}}`), the default is pre-populated in the input field - press Enter to accept or clear/edit it. For multi-value suggesters without `\|custom`, you must select one of the provided options (no default applies). |

docs/docs/QuickAddAPI.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Opens a one-page modal to collect multiple inputs in one go. Values already pres
4444
- `text`: Single-line text input
4545
- `textarea`: Multi-line text input
4646
- `dropdown`: Fixed dropdown menu (no search, must select from list)
47-
- `date`: Date input with natural language support
47+
- `date`: Date input with natural language support (short aliases like `t`, `tm`, `yd` are supported and configurable in settings)
4848
- `field-suggest`: Vault field suggestions (uses `{{FIELD:...}}` syntax)
4949
- `suggester`: **NEW** - Searchable autocomplete with custom options (allows custom input)
5050
- Supports multi-select mode via `suggesterConfig.multiSelect: true` for comma-separated selections

src/formatters/formatter.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { getDate } from "../utilityObsidian";
2424
import type { IDateParser } from "../parsers/IDateParser";
2525
import { log } from "../logger/logManager";
2626
import { TemplatePropertyCollector } from "../utils/TemplatePropertyCollector";
27+
import { settingsStore } from "../settingsStore";
28+
import { normalizeDateInput } from "../utils/dateAliases";
2729

2830
export type LinkToCurrentFileBehavior = "required" | "optional";
2931

@@ -444,7 +446,9 @@ export abstract class Formatter {
444446

445447
if (!this.dateParser) throw new Error("Date parser is not available");
446448

447-
const parseAttempt = this.dateParser.parseDate(dateInput);
449+
const aliasMap = settingsStore.getState().dateAliases;
450+
const normalizedInput = normalizeDateInput(dateInput, aliasMap);
451+
const parseAttempt = this.dateParser.parseDate(normalizedInput);
448452

449453
if (parseAttempt) {
450454
// Store the ISO string with a special prefix

src/gui/VDateInputPrompt/VDateInputPrompt.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import type { App, Debouncer } from "obsidian";
22
import { TextComponent, debounce } from "obsidian";
33
import GenericInputPrompt from "../GenericInputPrompt/GenericInputPrompt";
44
import { parseNaturalLanguageDate } from "../../utils/dateParser";
5+
import { settingsStore } from "../../settingsStore";
6+
import {
7+
formatDateAliasInline,
8+
getOrderedDateAliases,
9+
} from "../../utils/dateAliases";
510

611
export default class VDateInputPrompt extends GenericInputPrompt {
712
private previewEl: HTMLElement;
@@ -106,6 +111,33 @@ export default class VDateInputPrompt extends GenericInputPrompt {
106111
this.previewEl.style.fontFamily = "var(--font-monospace)";
107112
this.previewEl.textContent = VDateInputPrompt.PREVIEW_PLACEHOLDER;
108113
this.previewEl.style.color = "var(--text-normal)";
114+
115+
const aliasEntries = getOrderedDateAliases(
116+
settingsStore.getState().dateAliases,
117+
);
118+
if (aliasEntries.length > 0) {
119+
const aliasDetails = previewContainer.createEl("details", {
120+
cls: "vdate-alias-details",
121+
});
122+
aliasDetails.style.marginTop = "0.25rem";
123+
124+
const aliasSummary = aliasDetails.createEl("summary", {
125+
text: `Aliases (${aliasEntries.length})`,
126+
});
127+
aliasSummary.style.fontSize = "0.85em";
128+
aliasSummary.style.color = "var(--text-muted)";
129+
130+
const aliasList = aliasDetails.createEl("div", {
131+
cls: "vdate-alias-list",
132+
});
133+
aliasList.textContent = formatDateAliasInline(
134+
settingsStore.getState().dateAliases,
135+
);
136+
aliasList.style.marginTop = "0.25rem";
137+
aliasList.style.fontSize = "0.85em";
138+
aliasList.style.color = "var(--text-muted)";
139+
aliasList.style.fontFamily = "var(--font-monospace)";
140+
}
109141
}
110142

111143
private updatePreview() {

src/preflight/OnePageInputModal.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import {
1010
import { FieldValueInputSuggest } from "src/gui/suggesters/FieldValueInputSuggest";
1111
import { SuggesterInputSuggest } from "src/gui/suggesters/SuggesterInputSuggest";
1212
import { formatISODate, parseNaturalLanguageDate } from "src/utils/dateParser";
13+
import {
14+
formatDateAliasInline,
15+
getOrderedDateAliases,
16+
} from "src/utils/dateAliases";
17+
import { settingsStore } from "src/settingsStore";
1318
import type { FieldRequirement } from "./RequirementCollector";
1419

1520
type PreviewComputer = (
@@ -179,6 +184,28 @@ export class OnePageInputModal extends Modal {
179184
preview.style.marginTop = "0.25rem";
180185
preview.style.fontSize = "0.9em";
181186
preview.style.fontFamily = "var(--font-monospace)";
187+
const aliasEntries = getOrderedDateAliases(
188+
settingsStore.getState().dateAliases,
189+
);
190+
if (aliasEntries.length > 0) {
191+
const aliasDetails = container.createEl("details");
192+
aliasDetails.style.marginTop = "0.25rem";
193+
194+
const aliasSummary = aliasDetails.createEl("summary", {
195+
text: `Aliases (${aliasEntries.length})`,
196+
});
197+
aliasSummary.style.fontSize = "0.85em";
198+
aliasSummary.style.color = "var(--text-muted)";
199+
200+
const aliasList = aliasDetails.createEl("div");
201+
aliasList.textContent = formatDateAliasInline(
202+
settingsStore.getState().dateAliases,
203+
);
204+
aliasList.style.marginTop = "0.25rem";
205+
aliasList.style.fontSize = "0.85em";
206+
aliasList.style.color = "var(--text-muted)";
207+
aliasList.style.fontFamily = "var(--font-monospace)";
208+
}
182209
const updatePreview = (val: string) => {
183210
const inputVal = (val ?? "").trim();
184211
if (!inputVal && req.defaultValue) {

src/quickAddSettingsTab.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { App, TAbstractFile } from "obsidian";
2+
import type { TextAreaComponent } from "obsidian";
23
import {
34
BaseComponent,
45
PluginSettingTab,
@@ -16,6 +17,11 @@ import { ExportPackageModal } from "./gui/PackageManager/ExportPackageModal";
1617
import { ImportPackageModal } from "./gui/PackageManager/ImportPackageModal";
1718
import { InputPromptDraftStore } from "./utils/InputPromptDraftStore";
1819
import type { QuickAddSettings } from "./settings";
20+
import {
21+
DEFAULT_DATE_ALIASES,
22+
formatDateAliasLines,
23+
parseDateAliasLines,
24+
} from "./utils/dateAliases";
1925

2026
type SettingGroupLike = {
2127
addSetting(cb: (setting: Setting) => void): void;
@@ -52,6 +58,7 @@ export class QuickAddSettingsTab extends PluginSettingTab {
5258
this.addUseMultiLineInputPromptSetting(inputGroup);
5359
this.addPersistInputPromptDraftsSetting(inputGroup);
5460
this.addOnePageInputSetting(inputGroup);
61+
this.addDateAliasesSetting(inputGroup);
5562

5663
const templatesGroup = this.createSettingGroup("Templates & Properties");
5764
this.addTemplateFolderPathSetting(templatesGroup);
@@ -412,6 +419,61 @@ export class QuickAddSettingsTab extends PluginSettingTab {
412419
});
413420
}
414421

422+
private addDateAliasesSetting(group: SettingGroupLike) {
423+
group.addSetting((setting) => {
424+
setting.setName("Date aliases");
425+
setting.setDesc(
426+
"Shortcodes for natural language date parsing. " +
427+
"One per line: alias = phrase. Example: tm = tomorrow.",
428+
);
429+
setting.settingEl.style.alignItems = "flex-start";
430+
setting.controlEl.style.display = "flex";
431+
setting.controlEl.style.flexWrap = "wrap";
432+
setting.controlEl.style.gap = "0.5rem";
433+
setting.controlEl.style.alignItems = "flex-start";
434+
setting.controlEl.style.flex = "1 1 320px";
435+
setting.controlEl.style.minWidth = "240px";
436+
437+
let textAreaRef: TextAreaComponent | null = null;
438+
439+
setting.addTextArea((textArea) => {
440+
textAreaRef = textArea;
441+
textArea
442+
.setPlaceholder("t = today\ntm = tomorrow\nyd = yesterday")
443+
.setValue(
444+
formatDateAliasLines(settingsStore.getState().dateAliases),
445+
)
446+
.onChange((value) => {
447+
settingsStore.setState({
448+
dateAliases: parseDateAliasLines(value),
449+
});
450+
});
451+
textArea.inputEl.style.width = "100%";
452+
textArea.inputEl.style.minHeight = "6rem";
453+
textArea.inputEl.style.flex = "1 1 280px";
454+
textArea.inputEl.style.maxWidth = "100%";
455+
textArea.inputEl.style.boxSizing = "border-box";
456+
});
457+
458+
setting.addButton((button) => {
459+
button
460+
.setButtonText("Reset to defaults")
461+
.onClick(() => {
462+
settingsStore.setState({
463+
dateAliases: DEFAULT_DATE_ALIASES,
464+
});
465+
if (textAreaRef) {
466+
textAreaRef.setValue(
467+
formatDateAliasLines(DEFAULT_DATE_ALIASES),
468+
);
469+
}
470+
});
471+
button.buttonEl.style.alignSelf = "flex-start";
472+
button.buttonEl.style.whiteSpace = "nowrap";
473+
});
474+
});
475+
}
476+
415477
private addDisableOnlineFeaturesSetting(group: SettingGroupLike) {
416478
group.addSetting((setting) => {
417479
setting

src/settings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Model } from "./ai/Provider";
22
import { DefaultProviders, type AIProvider } from "./ai/Provider";
33
import type IChoice from "./types/choices/IChoice";
4+
import { DEFAULT_DATE_ALIASES } from "./utils/dateAliases";
45

56
export interface QuickAddSettings {
67
choices: IChoice[];
@@ -25,6 +26,7 @@ export interface QuickAddSettings {
2526
showCaptureNotification: boolean;
2627
showInputCancellationNotification: boolean;
2728
enableTemplatePropertyTypes: boolean;
29+
dateAliases: Record<string, string>;
2830
ai: {
2931
defaultModel: Model["name"] | "Ask me";
3032
defaultSystemPrompt: string;
@@ -60,6 +62,7 @@ export const DEFAULT_SETTINGS: QuickAddSettings = {
6062
showCaptureNotification: true,
6163
showInputCancellationNotification: false,
6264
enableTemplatePropertyTypes: false,
65+
dateAliases: DEFAULT_DATE_ALIASES,
6366
ai: {
6467
defaultModel: "Ask me",
6568
defaultSystemPrompt: `As an AI assistant within Obsidian, your primary goal is to help users manage their ideas and knowledge more effectively. Format your responses using Markdown syntax. Please use the [[Obsidian]] link format. You can write aliases for the links by writing [[Obsidian|the alias after the pipe symbol]]. To use mathematical notation, use LaTeX syntax. LaTeX syntax for larger equations should be on separate lines, surrounded with double dollar signs ($$). You can also inline math expressions by wrapping it in $ symbols. For example, use $$w_{ij}^{\text{new}}:=w_{ij}^{\text{current}}+\eta\cdot\delta_j\cdot x_{ij}$$ on a separate line, but you can write "($\eta$ = learning rate, $\delta_j$ = error term, $x_{ij}$ = input)" inline.`,

src/utils/dateAliases.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
formatDateAliasInline,
4+
formatDateAliasLines,
5+
getOrderedDateAliases,
6+
normalizeDateInput,
7+
parseDateAliasLines,
8+
} from "./dateAliases";
9+
10+
describe("dateAliases", () => {
11+
it("normalizes direct aliases", () => {
12+
const result = normalizeDateInput("tm", { tm: "tomorrow" });
13+
expect(result).toBe("tomorrow");
14+
});
15+
16+
it("normalizes aliases in the first token", () => {
17+
const result = normalizeDateInput("tm 5pm", { tm: "tomorrow" });
18+
expect(result).toBe("tomorrow 5pm");
19+
});
20+
21+
it("leaves non-alias input untouched", () => {
22+
const result = normalizeDateInput("next friday", { tm: "tomorrow" });
23+
expect(result).toBe("next friday");
24+
});
25+
26+
it("parses alias lines into a map", () => {
27+
const parsed = parseDateAliasLines("tm = tomorrow\n# comment\nyd=yesterday");
28+
expect(parsed).toEqual({ tm: "tomorrow", yd: "yesterday" });
29+
});
30+
31+
it("formats aliases back into lines", () => {
32+
const formatted = formatDateAliasLines({
33+
tm: "tomorrow",
34+
yd: "yesterday",
35+
});
36+
expect(formatted).toBe("tm = tomorrow\nyd = yesterday");
37+
});
38+
39+
it("orders aliases with preferred keys first", () => {
40+
const ordered = getOrderedDateAliases({
41+
foo: "bar",
42+
tm: "tomorrow",
43+
yd: "yesterday",
44+
t: "today",
45+
});
46+
expect(ordered[0][0]).toBe("t");
47+
expect(ordered[1][0]).toBe("tm");
48+
});
49+
50+
it("formats aliases inline", () => {
51+
const summary = formatDateAliasInline({
52+
tm: "tomorrow",
53+
yd: "yesterday",
54+
});
55+
expect(summary).toContain("tm=tomorrow");
56+
});
57+
});

src/utils/dateAliases.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
export type DateAliasMap = Record<string, string>;
2+
3+
export const DEFAULT_DATE_ALIASES: DateAliasMap = {
4+
t: "today",
5+
tm: "tomorrow",
6+
yd: "yesterday",
7+
nw: "next week",
8+
nm: "next month",
9+
ny: "next year",
10+
lw: "last week",
11+
lm: "last month",
12+
ly: "last year",
13+
};
14+
15+
export function normalizeDateInput(
16+
input: string,
17+
aliases: DateAliasMap = DEFAULT_DATE_ALIASES,
18+
): string {
19+
const trimmed = input.trim();
20+
if (!trimmed) return input;
21+
22+
const direct = aliases[trimmed.toLowerCase()];
23+
if (direct) return direct;
24+
25+
const [first, ...rest] = trimmed.split(/\s+/);
26+
const replacement = aliases[first.toLowerCase()];
27+
if (!replacement) return input;
28+
29+
return [replacement, ...rest].join(" ");
30+
}
31+
32+
export function parseDateAliasLines(text: string): DateAliasMap {
33+
const result: DateAliasMap = {};
34+
const lines = text.split(/\r?\n/);
35+
36+
for (const line of lines) {
37+
const trimmed = line.trim();
38+
if (!trimmed || trimmed.startsWith("#")) continue;
39+
40+
const separatorIndex = trimmed.indexOf("=");
41+
if (separatorIndex === -1) continue;
42+
43+
const key = trimmed.slice(0, separatorIndex).trim().toLowerCase();
44+
const value = trimmed.slice(separatorIndex + 1).trim();
45+
46+
if (!key || !value) continue;
47+
result[key] = value;
48+
}
49+
50+
return result;
51+
}
52+
53+
export function formatDateAliasLines(aliases: DateAliasMap): string {
54+
return Object.entries(aliases)
55+
.filter(([key, value]) => key && value)
56+
.map(([key, value]) => `${key} = ${value}`)
57+
.join("\n");
58+
}
59+
60+
export function getOrderedDateAliases(
61+
aliases: DateAliasMap,
62+
): Array<[string, string]> {
63+
const entries = Object.entries(aliases);
64+
if (entries.length === 0) return [];
65+
66+
const preferredKeys = ["t", "tm", "yd"];
67+
const preferred = preferredKeys
68+
.map((key) => {
69+
const value = aliases[key];
70+
return value ? ([key, value] as [string, string]) : null;
71+
})
72+
.filter((entry): entry is [string, string] => entry !== null);
73+
74+
const used = new Set(preferred.map(([key]) => key));
75+
const remaining = entries
76+
.filter(([key]) => !used.has(key))
77+
.sort((a, b) => {
78+
const lenDiff = a[0].length - b[0].length;
79+
if (lenDiff !== 0) return lenDiff;
80+
return a[0].localeCompare(b[0]);
81+
});
82+
83+
return [...preferred, ...remaining];
84+
}
85+
86+
export function formatDateAliasInline(aliases: DateAliasMap): string {
87+
return getOrderedDateAliases(aliases)
88+
.map(([key, value]) => `${key}=${value}`)
89+
.join(", ");
90+
}

0 commit comments

Comments
 (0)