feat: add date systems menu to calendar days
Right-clicking a calendar day now lists every configured date-prefixed note system. Journal notes are singletons; {title} formats support multiple notes per date, such as meetings.\n\nAdds default Journal and Meeting systems, configurable settings UI, safe filename/title handling, one shared dated-note creation path, and a compiled menu smoke test fixture in the test vault.
This commit is contained in:
+117
-20
@@ -9,12 +9,13 @@
|
||||
```
|
||||
src/
|
||||
├── main.ts Plugin entry — lifecycle, commands, events, data persistence
|
||||
├── settings.ts Interface definitions + DEFAULT_SETTINGS constant
|
||||
├── settings-tab.ts PluginSettingTab UI — tabs: calendar, periodic, recent, display, about
|
||||
├── settings.ts Interface definitions + DEFAULT_SETTINGS / DEFAULT_DATE_SYSTEM constants
|
||||
├── settings-tab.ts PluginSettingTab UI — calendar, periodic, date systems, recent, display, about
|
||||
├── models/
|
||||
│ └── bookmark.ts BookmarkItem + WaypointData interfaces
|
||||
├── utils/
|
||||
│ ├── date-utils.ts Moment.js helpers: period formatting, month grid, navigation
|
||||
│ ├── date-systems.ts Pure date-system filename helpers (no `obsidian` import, unit-testable)
|
||||
│ └── path-utils.ts Pure path helper: rename remapping (no `obsidian` import, unit-testable)
|
||||
└── views/
|
||||
└── waypoint-view.ts ItemView subclass — full sidebar rendering + interaction
|
||||
@@ -30,7 +31,7 @@ src/
|
||||
|
||||
### `onload()`
|
||||
|
||||
1. **Load data** — `loadSettings()` then `loadWaypointData()`, both merge saved partials over defaults via `Object.assign({}, DEFAULT, partial)`.
|
||||
1. **Load data** — reads `data.json` exactly once, then `applySettings(saved)` and `applyWaypointData(saved)` merge its partial data over defaults.
|
||||
2. **Register view** — `WAYPOINT_VIEW_TYPE = "waypoint-view"` maps to `WaypointView` factory `(leaf) => new WaypointView(leaf, this)`.
|
||||
3. **Settings tab** — `WaypointSettingTab` receives the live `settings` object + `() => this.redrawAll()` callback for live preview.
|
||||
4. **Register commands** (see Commands section below).
|
||||
@@ -56,6 +57,7 @@ interface WaypointSettings {
|
||||
monthly: PeriodNoteSettings;
|
||||
quarterly: PeriodNoteSettings;
|
||||
yearly: PeriodNoteSettings;
|
||||
dateSystems: DateSystemSettings[]; // day-scoped systems beyond the daily note, in menu order
|
||||
recentFiles: RecentFilesSettings; // maxItems, updateOn, omittedPaths[], omittedTags[], filterTags[]
|
||||
display: DisplaySettings; // px sizing for bookmark rows, fonts, icons, calendar cells
|
||||
}
|
||||
@@ -90,6 +92,8 @@ interface DisplaySettings {
|
||||
}
|
||||
```
|
||||
|
||||
`dateSystems` holds the user's configured date systems in menu order — see the Date Systems section for the `DateSystemSettings` shape and the two systems shipped by default.
|
||||
|
||||
### BookmarkItem (recursive tree)
|
||||
|
||||
```typescript
|
||||
@@ -147,6 +151,99 @@ These call `navigatePeriodNote(direction)` which:
|
||||
|
||||
---
|
||||
|
||||
## Date Systems
|
||||
|
||||
A **date system** is a folder of notes whose filenames begin with a date. Daily notes, a journal and meeting notes are all the same shape, so they share one settings model, one discovery pass and one creation path.
|
||||
|
||||
### `DateSystemSettings`
|
||||
|
||||
```typescript
|
||||
interface DateSystemSettings {
|
||||
id: string; // stable across edits + reordering, so settings rows can key on it
|
||||
name: string; // menu label, e.g. "Journal"
|
||||
folder: string; // e.g. "periodic/journal"
|
||||
nameFormat: string; // moment format string; may contain "{title}"
|
||||
templateFile: string; // may omit the .md extension
|
||||
typeProperty: string; // fallback frontmatter `type:` value
|
||||
icon: string; // Lucide icon name for the menu item
|
||||
}
|
||||
```
|
||||
|
||||
Configured systems live in `settings.dateSystems`, where array order is menu order. `DEFAULT_DATE_SYSTEM` (in `settings.ts`, typed `Omit<DateSystemSettings, 'id'>`) supplies the field defaults for a newly added row **and** the merge base for saved ones, so a system persisted before a field existed still loads with that field defined. `DEFAULT_SETTINGS.dateSystems` ships two:
|
||||
|
||||
| `id` | `name` | Folder | `nameFormat` | Notes per date |
|
||||
|---|---|---|---|---|
|
||||
| `journal` | Journal | `periodic/journal` | `YYYY-MM-DD - [Journal]` | one |
|
||||
| `meetings` | Meeting | `periodic/meetings` | `YYYY-MM-DD - {title}` | many |
|
||||
|
||||
### The `{title}` convention
|
||||
|
||||
`nameFormat` is a moment format string, so literal text needs bracket escaping — `YYYY-MM-DD - [Journal]` — the same convention the periodic formats `GGGG-[W]WW` and `YYYY-[Q]Q` already use.
|
||||
|
||||
When the format contains `{title}`, the system holds **many** notes per date: the text before the token is the date prefix used to discover them, and the token marks where a typed free-text title goes on creation. Without the token, a date maps to **exactly one** filename.
|
||||
|
||||
**Why the token is split out before moment sees the string:** `t`, `i`, `l` and `e` are all live moment format tokens — `l` on its own expands to an entire localized date. Passing `{title}` through `date.format()` would expand those letters and destroy the placeholder, with no way to recover it afterwards. `splitNameFormat()` therefore cuts the format at the token *first*, and callers format `before` and `after` separately, then join the two results around the title. It is also why discovery matches on formatted affixes instead of on a regex derived from the raw format.
|
||||
|
||||
### Helpers (`src/utils/date-systems.ts`)
|
||||
|
||||
Imports nothing from `'obsidian'` — callers make every moment call and pass the resulting strings in, which keeps the module unit-testable in plain node (same rationale as `path-utils.ts`).
|
||||
|
||||
| Export | Signature | Purpose |
|
||||
|---|---|---|
|
||||
| `TITLE_TOKEN` | `'{title}'` | The literal placeholder. |
|
||||
| `splitNameFormat(nameFormat)` | `string → NameFormatParts` | `{ before, after, hasTitle }` — the format cut around the token. |
|
||||
| `isInFolder(path, folder)` | `string, string → boolean` | Path sits in `folder` or any subfolder. An empty folder means the vault root, so everything matches. |
|
||||
| `matchesSystemName(basename, before, after, hasTitle)` | `string, string, string, boolean → boolean` | One-per-date: the basename must equal `before + after`. Many-per-date: prefix/suffix match, since the middle is free text. |
|
||||
| `titleFromBasename(basename, before, after)` | `string, string, string → string` | Strips the affixes to recover the menu label; falls back to the whole basename when they do not line up. |
|
||||
| `sanitizeTitle(title)` | `string → string` | Collapses filename-illegal and link-syntax characters (`\ / : * ? " < > \| # ^ [ ]`) to `-`, squeezes runs of whitespace, trims — so a typed "Meeting w/ Mark" still yields a creatable filename. |
|
||||
| `formatHasDateToken(nameFormat)` | `string → boolean` | Strips bracket-escaped literals out of `before`, then looks for any moment date token. Drives the settings warning. |
|
||||
`matchesSystemName` deliberately returns `false` for a many-per-date format whose `before` is empty (a format of just `{title}`): an empty date prefix would otherwise claim every file in the folder for every date. The settings tab flags formats with no date token, and `findDateSystemNotes()` excludes them from the menu; `openDateSystemNote()` also refuses them. An unfinished or invalid row therefore cannot create `.md` or a static filename.
|
||||
|
||||
### Plugin API (`main.ts`)
|
||||
|
||||
```typescript
|
||||
/** One existing note of a date system, with the label the menu should show. */
|
||||
interface DateSystemNote {
|
||||
file: TFile;
|
||||
label: string; // free-text title for many-per-date systems, else the system name
|
||||
}
|
||||
|
||||
/** A day-scoped system and the notes it already holds for one date. */
|
||||
interface DateSystemNotes {
|
||||
system: DateSystemSettings;
|
||||
notes: DateSystemNote[]; // sorted by basename; empty when the date has no note
|
||||
multiple: boolean; // nameFormat carries {title}, i.e. many notes per date
|
||||
}
|
||||
|
||||
dateSystems(): DateSystemSettings[]
|
||||
findDateSystemNotes(date: moment.Moment): DateSystemNotes[]
|
||||
openDateSystemNote(
|
||||
system: DateSystemSettings,
|
||||
date: moment.Moment,
|
||||
opts?: { title?: string; leaf?: WorkspaceLeaf },
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
**`dateSystems()`** — every day-scoped system in menu order: the daily periodic note, synthesized into a `DateSystemSettings` from `settings.daily`, followed by `settings.dateSystems`. The daily note is therefore not a special case in any consumer.
|
||||
|
||||
**`findDateSystemNotes(date)`** — **one** vault scan per call, not one per system. It formats each usable system's `before`/`after` affixes for `date` once, then walks the markdown file list a single time, bucketing each file into the system that claims it (`isInFolder` + `matchesSystemName`). Systems whose formats have no date token are omitted rather than offering an unsafe create action. It returns the remaining `DateSystemNotes` in `dateSystems()` order, each bucket sorted by basename. The calendar's day context menu is built from exactly one of these calls per right-click.
|
||||
|
||||
**`openDateSystemNote(system, date, opts)`** — the single opener for every dated note:
|
||||
|
||||
1. **Filename** — `splitNameFormat(system.nameFormat)`, format `before` and `after` against `date`, and for a many-per-date system join `sanitizeTitle(opts.title)` between them. Append `.md`; prepend `system.folder` when it is set.
|
||||
2. **Existing note** — a `vault.getFileByPath()` hit is opened as-is, never overwritten. A date that already has notes can still gain another in a many-per-date system, because the title makes the filename unique.
|
||||
3. **Missing note** — created via `createDatedNote`, then opened.
|
||||
4. **Leaf** — `opts.leaf` when supplied (middle-click and Ctrl/Cmd-click pass an explicit tab leaf), otherwise `workspace.getLeaf(false)`.
|
||||
|
||||
### Note creation (`createDatedNote`)
|
||||
|
||||
A single creation path shared by periodic notes and date systems; it replaced the period-only `createPeriodNote`.
|
||||
|
||||
1. Read the template at `system.templateFile` (the `.md` extension is appended when omitted). If it exists, the note is created with the template's contents.
|
||||
2. With no template, the note is created with minimal frontmatter instead: `type: {system.typeProperty}` + `date: YYYY-MM-DD`.
|
||||
|
||||
---
|
||||
|
||||
## Period Note Creation (`openPeriodNote`)
|
||||
|
||||
```typescript
|
||||
@@ -157,16 +254,9 @@ openPeriodNote(
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
Single unified opener — there is no separate `openPeriodNoteInLeaf`. For a given period + moment date:
|
||||
Single unified opener — there is no separate `openPeriodNoteInLeaf`, and since the date-systems refactor there is no separate creation path either. `openPeriodNote` synthesizes the period's `PeriodNoteSettings` into a `DateSystemSettings` (`folder`, `nameFormat`, `templateFile` and `typeProperty` carry over verbatim; a period format never contains `{title}`, so a period maps to exactly one filename) and delegates to `openDateSystemNote(system, date, { leaf })`.
|
||||
|
||||
1. **Build filename:** `date.format(periodSettings.nameFormat) + ".md"`.
|
||||
2. **Build full path:** If `periodSettings.folder` is set, prepend it; otherwise root.
|
||||
3. **Check existence:** `vault.getFileByPath(fullPath)`.
|
||||
4. **If not found, create:**
|
||||
- Try to read template at `periodSettings.templateFile + ".md"`.
|
||||
- If template exists → `vault.create(fullPath, templateContent)`.
|
||||
- If no template → `vault.create(fullPath, minimalFrontmatter)` where frontmatter is `type: {typeProperty}` + `date: YYYY-MM-DD`.
|
||||
5. **Open:** in `leaf` when one is supplied, otherwise in `workspace.getLeaf(false)`.
|
||||
Filename building, the existence check, template-or-frontmatter creation and leaf selection are therefore documented once, under Date Systems above.
|
||||
|
||||
Middle-click handlers in the view pass an explicit tab leaf: `openPeriodNote(period, date, this.app.workspace.getLeaf('tab'))`.
|
||||
|
||||
@@ -202,7 +292,7 @@ Both update the markdown-basename set used for calendar note indicators, then tr
|
||||
recentFiles: { path: string; basename: string }[]
|
||||
```
|
||||
|
||||
Backed by `waypointData.recentFiles` in `data.json`, so the list survives vault reload. `loadWaypointData()` restores it and re-applies the current `maxItems` limit (in case the setting shrank since the last save).
|
||||
Backed by `waypointData.recentFiles` in `data.json`, so the list survives vault reload. `applyWaypointData(saved)` restores it and re-applies the current `maxItems` limit (in case the setting shrank since the last save).
|
||||
|
||||
**Update flow:**
|
||||
1. `addToRecentFiles(file)` — omission check, then dedupes (removes existing entry), prepends to front, truncates to `maxItems`.
|
||||
@@ -250,9 +340,16 @@ The last section (`waypoint-section:last-child`) gets `margin-top: auto`, pushin
|
||||
- **Breadcrumb:** Q-label, month name, year — each clickable to open that period note.
|
||||
- **Nav buttons:** ◀/▶ shift month ±1. "Today" resets to current month.
|
||||
- **Week number column:** Clicking a week number opens the weekly note for that week's Monday; middle-clicking opens it in a new tab.
|
||||
- **Day cells:** Click opens daily note, middle-click opens it in a new tab. `.other-month` dimmed. `.today` has accent border. `.has-note` gets a dot indicator, decided by the synchronous O(1) `plugin.hasNoteForDate(dateStr)` (a `Set` lookup — no per-cell vault scan).
|
||||
- **Day cells:** Left-click opens the daily note, middle-click opens it in a new tab, right-click opens the day context menu (below). `.other-month` dimmed. `.today` has accent border. `.has-note` gets a dot indicator, decided by the synchronous O(1) `plugin.hasNoteForDate(dateStr)` (a `Set` lookup — no per-cell vault scan).
|
||||
- **Grid generation:** `getMonthGrid(year, month, firstDayOfWeek)` in `date-utils.ts` produces up to 6 weeks, each with 7 `CalendarDay` objects containing `moment`, `dayOfMonth`, `isToday`, `isCurrentMonth`, `isoWeekNumber`.
|
||||
|
||||
**Day context menu (right-click):** Built from a single `plugin.findDateSystemNotes(day.date)` call. The first entry is a non-clickable header showing the full date. Then, per system in `dateSystems()` order:
|
||||
|
||||
- Each existing note, labelled with its `DateSystemNote.label` and the system's `icon`. Click opens it in the current leaf; Ctrl/Cmd-click opens it in a new tab.
|
||||
- A create entry (icon `plus`) labelled `New {lowercased system name} note`. Many-per-date systems always show one, with a trailing ellipsis because it first opens a `PromptModal` for the title (`New meeting note…`). A one-per-date system shows one without the ellipsis only while its note is missing (`New journal note`); once the note exists, the existing-note entry is all it gets.
|
||||
|
||||
Every label is derived from `system.name`, so a renamed or newly added system needs no view changes. Left-click and middle-click on the cell are untouched.
|
||||
|
||||
### Recent Files Panel
|
||||
|
||||
Renders `plugin.recentFiles` as Obsidian-native `nav-file` elements using the file explorer's CSS classes (`tree-item`, `nav-file-title`, `nav-file-title-content`).
|
||||
@@ -292,7 +389,7 @@ Renders `plugin.recentFiles` as Obsidian-native `nav-file` elements using the fi
|
||||
|
||||
**Context menu (right-click):**
|
||||
- File items: "Open in new tab"
|
||||
- File/Group items: "Rename" (opens `RenameModal`), "Change icon" (opens `IconSuggestModal`)
|
||||
- File/Group items: "Rename" (opens `PromptModal`), "Change icon" (opens `IconSuggestModal`)
|
||||
- "Move to group" submenu: Lists all available groups (excluding self + descendants) + "(Root)" for ungrouping
|
||||
- Group items: "Expand/Collapse", "Add bookmark here", "New sub-group"
|
||||
- All items: "Insert separator above/below", "Insert spacer above/below", "Remove"
|
||||
@@ -302,13 +399,13 @@ Renders `plugin.recentFiles` as Obsidian-native `nav-file` elements using the fi
|
||||
2. If targetGroupId: find group, set `item.indent = group.indent + 1`, push to group's children.
|
||||
3. If null (root): set `item.indent = 0`, push to `waypointData.bookmarks`.
|
||||
|
||||
**Rename modal (`RenameModal`):** Simple Modal with text input + Cancel/Save buttons. Enter key submits.
|
||||
**Prompt modal (`PromptModal`):** Simple Modal with a text input plus Cancel and CTA buttons. Enter key submits. Constructed as `new PromptModal(app, options, onSubmit)`, where `options` is `{ title, placeholder?, initialValue?, cta? }` and `cta` (the submit button label) defaults to `Save`. Generalized from the old rename-only `RenameModal` so the calendar's day context menu can reuse it to prompt for a note title.
|
||||
|
||||
**Icon picker (`IconSuggestModal`):** Modal with:
|
||||
- Live preview of selected icon.
|
||||
- Search input with 60ms debounce.
|
||||
- Grid of matching icons (max 80 shown), loaded from `https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json` with fallback to `https://lucide.dev/api/tags` and a hardcoded `FALLBACK_ICONS` object (~300 icons). The fetch result is cached for the session, so at most one network round-trip happens no matter how often the picker is opened.
|
||||
- Click to select, "No icon" link to clear, Save/Cancel buttons.
|
||||
- Click to select, **No icon** button to clear, Save/Cancel buttons.
|
||||
|
||||
---
|
||||
|
||||
@@ -317,10 +414,10 @@ Renders `plugin.recentFiles` as Obsidian-native `nav-file` elements using the fi
|
||||
All classes prefixed with `waypoint-`. Uses Obsidian CSS variables throughout:
|
||||
|
||||
- `--font-ui-small`, `--font-ui-medium`, `--font-semibold`, `--font-medium`, `--font-light`
|
||||
- `--text-muted`, `--text-faint`, `--text-accent`, `--text-on-accent`
|
||||
- `--text-muted`, `--text-faint`, `--text-accent`, `--text-on-accent`, `--text-error`
|
||||
- `--background-modifier-border`, `--background-modifier-active-hover`, `--background-primary`, `--background-secondary`
|
||||
- `--interactive-accent`
|
||||
- `--cursor` (for custom cursor support)
|
||||
- `--cursor-link` (pointer cursor, with a `pointer` fallback)
|
||||
|
||||
Key layout:
|
||||
- `.waypoint-view` — flex column, `overflow-y: auto`, 8px padding.
|
||||
@@ -372,7 +469,7 @@ Settings and waypoint data share a single `data.json` via Obsidian's `Plugin.loa
|
||||
}
|
||||
```
|
||||
|
||||
Both `loadSettings()` and `loadWaypointData()` read from the same file, merging partials over defaults. `saveSettings()` and `saveWaypointData()` each re-read the full data, update their key, and write back — so they are serialized through a single-writer save queue: each call chains onto the previous one's promise instead of racing it. Recent-file writes additionally go through the 300ms debounce in `persistRecentFiles()`.
|
||||
`onload()` reads `data.json` once, then `applySettings(saved)` and `applyWaypointData(saved)` merge partial persisted values over defaults. `persistAll()` writes both in-memory keys through one promise chain; it never re-reads stale disk state. Recent-file writes additionally go through the 300ms debounce in `persistRecentFiles()`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user