621bef0d29
Template file fields used an unstyled <datalist>, not Obsidian's native suggest popup. Replace it with TemplateFileSuggest, an AbstractInputSuggest<TFile> matching Templater's own FileSuggest/FolderSuggest pattern exactly. Requires Obsidian >=1.4.10; minAppVersion corrected. The coloured-indicator default never reached existing installs: every install that loaded before that default changed had already persisted the prior default (indicatorMode: 'any') to data.json as if it were a deliberate choice, and Object.assign-based settings merging always prefers a value already on disk over a new code default. Hand-editing data.json directly does not fix this durably either -- Obsidian holds settings in memory while a vault is open and overwrites external edits on its own save cycle. Add a one-time, code-level migration: on load, an install with a persisted indicatorMode of 'any' and no indicatorModeMigrated flag is promoted to 'systems' and the flag is persisted immediately, so it survives even a session with no further settings changes. Any indicatorMode chosen after the flag exists, any (deliberately) included, is never touched again.
514 lines
32 KiB
Markdown
514 lines
32 KiB
Markdown
# Waypoint Sidebar — Technical Documentation
|
||
|
||
> Obsidian plugin (id: `waypoint-sidebar`) providing a unified sidebar with calendar, recent files, and custom bookmarks. Replaces separate Calendar and Bookmarks core plugins.
|
||
|
||
---
|
||
|
||
## Architecture Overview
|
||
|
||
```
|
||
src/
|
||
├── main.ts Plugin entry — lifecycle, commands, events, data persistence
|
||
├── 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
|
||
```
|
||
|
||
**Build:** `npm run build` runs `tsc --noEmit` first, then esbuild bundles `src/main.ts` → `main.js`. The typecheck is a real gate — esbuild strips types without checking them, so without it type errors would ship. `npm run typecheck` runs the check alone; `npm run dev` (watch) stays ungated for speed. TypeScript strict-null + `noImplicitAny`, ESNext modules, ES6 target, `lib` floor at ES2017.
|
||
|
||
**Data persistence:** Single `data.json` via Obsidian `Plugin.loadData()/saveData()`. Top-level keys: `settings` (merged with `DEFAULT_SETTINGS`), `waypointData` (bookmark tree **and** recent files). Recent files are persisted under `waypointData.recentFiles` and reloaded on startup; writes are debounced 300ms so rapid file opens collapse into one save.
|
||
|
||
---
|
||
|
||
## Plugin Lifecycle
|
||
|
||
### `onload()`
|
||
|
||
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).
|
||
5. **Register events** — `file-open`, `vault:create`, `vault:delete`, `vault:rename`, `vault:modify`.
|
||
6. **Auto-open** — On `onLayoutReady`, if no existing leaves of the view type, opens one in the left sidebar.
|
||
7. **Midnight refresh** — 10-minute `setInterval` checks if `new Date().toDateString()` changed; if so, calls `redrawAll()` (calendar day indicators refresh).
|
||
|
||
### `onunload()`
|
||
|
||
Detaches all leaves of `WAYPOINT_VIEW_TYPE`.
|
||
|
||
---
|
||
|
||
## Data Models
|
||
|
||
### WaypointSettings
|
||
|
||
```typescript
|
||
interface WaypointSettings {
|
||
calendar: CalendarSettings; // week start, indicator visibility/style, daily indicator colour
|
||
daily: PeriodNoteSettings;
|
||
weekly: PeriodNoteSettings;
|
||
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
|
||
}
|
||
|
||
interface PeriodNoteSettings {
|
||
folder: string; // e.g. "periodic/daily"
|
||
templateFile: string; // path without .md, e.g. "Templates/Daily note"
|
||
nameFormat: string; // moment.js format string
|
||
typeProperty: string; // frontmatter type value, e.g. "daily-note"
|
||
}
|
||
|
||
interface CalendarSettings {
|
||
firstDayOfWeek: number; // 0 = Sunday, 1 = Monday
|
||
showNoteIndicators: boolean; // show or hide all calendar markers
|
||
indicatorMode: 'any' | 'systems'; // defaults to systems; one neutral dot or one per date system
|
||
dailyIndicatorColor: string; // daily's colour in systems mode
|
||
}
|
||
|
||
interface RecentFilesSettings {
|
||
maxItems: number; // default 50
|
||
updateOn: 'file-open' | 'file-edit'; // trigger mode
|
||
omittedPaths: string[]; // regex patterns (one per line)
|
||
omittedTags: string[]; // regex patterns for frontmatter/inline tags
|
||
filterTags: string[]; // tags shown as filter pills (empty = auto-detect from frontmatter)
|
||
}
|
||
|
||
interface DisplaySettings {
|
||
rowSize: number; // px, base height of bookmark items (18–40)
|
||
rowSpacing: number; // px, gap between items (0–12)
|
||
indentSize: number; // px, indent per depth level (8–32)
|
||
fontSize: number; // px, font size for bookmark labels (10–18)
|
||
iconSize: number; // px, icon size (12–24)
|
||
calendarCellSize: number; // px, calendar day cell height (20–48)
|
||
}
|
||
```
|
||
|
||
`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
|
||
interface BookmarkItem {
|
||
id: string; // "bm-{timestamp}-{random4}"
|
||
type: 'file' | 'group' | 'separator' | 'spacer';
|
||
label: string; // display text (empty for separator/spacer)
|
||
filePath: string; // vault-relative path (empty for non-file types)
|
||
icon: string; // Lucide icon name
|
||
children: BookmarkItem[]; // nested items (groups contain children)
|
||
collapsed: boolean; // group collapse state
|
||
indent: number; // depth level (0 = root)
|
||
}
|
||
|
||
interface WaypointData {
|
||
bookmarks: BookmarkItem[];
|
||
recentFiles: { path: string; basename: string }[];
|
||
}
|
||
```
|
||
|
||
The bookmark tree is stored under `waypointData.bookmarks` and rendered recursively. Groups contain children; files/separators/spacers are leaf nodes. `waypointData.recentFiles` holds the persisted recent-files list (mirrored into the live `plugin.recentFiles` array on load).
|
||
|
||
---
|
||
|
||
## Commands
|
||
|
||
### Period note navigation (with default hotkeys)
|
||
|
||
| ID | Name | Hotkey |
|
||
|---|---|---|
|
||
| `waypoint-go-to-daily` | Go to daily note | `Mod+Shift+Alt+D` |
|
||
| `waypoint-go-to-weekly` | Go to weekly note | `Mod+Shift+Alt+W` |
|
||
| `waypoint-go-to-monthly` | Go to monthly note | `Mod+Shift+Alt+M` |
|
||
| `waypoint-go-to-quarterly` | Go to quarterly note | `Mod+Shift+Alt+Q` |
|
||
| `waypoint-go-to-yearly` | Go to yearly note | `Mod+Shift+Alt+Y` |
|
||
|
||
All call `openPeriodNote(period, moment())` — opens or creates the current period's note.
|
||
|
||
### Next/Previous period (no default hotkeys)
|
||
|
||
10 commands generated in a loop: `waypoint-go-to-{next|prev}-{daily|weekly|monthly|quarterly|yearly}`.
|
||
|
||
These call `navigatePeriodNote(direction)` which:
|
||
1. Gets the active file's basename.
|
||
2. Calls `detectPeriodType(basename)` — **settings-driven**, not hardcoded regexes. It reads `settings.{daily,weekly,monthly,quarterly,yearly}.nameFormat` and tries `moment(basename, nameFormat, true).isValid()` (strict parsing) in order day → week → month → quarter → year, returning the first match as `{ period, date }`. Periods with an empty `nameFormat` are skipped; returns `null` if nothing matches. Changing a name format in settings therefore keeps next/prev navigation working.
|
||
3. Adds ±1 period (quarters add ±3 months).
|
||
4. Calls `openPeriodNote` for the new date.
|
||
|
||
### Other commands
|
||
|
||
| ID | Name |
|
||
|---|---|
|
||
| `waypoint-open-view` | Open Waypoint sidebar |
|
||
| `waypoint-add-bookmark` | Add current file as Waypoint bookmark |
|
||
|
||
---
|
||
|
||
## 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`
|
||
|
||
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
|
||
indicatorColor: string; // calendar dot colour in systems mode
|
||
}
|
||
```
|
||
|
||
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` | Indicator colour | Notes per date |
|
||
|---|---|---|---|---|---|
|
||
| `journal` | Journal | `periodic/journal` | `YYYY-MM-DD - [Journal]` | green (`#22c55e`) | one |
|
||
| `meetings` | Meeting | `periodic/meetings` | `YYYY-MM-DD - {title}` | violet (`#a855f7`) | many |
|
||
|
||
The default calendar mode is `systems`, while `any` preserves the older one-neutral-dot behaviour. Existing installs that silently persisted the pre-`systems` default of `any` are promoted to `systems` exactly once, on load, via a `data.json`-level `indicatorModeMigrated` flag; a value chosen after that flag is set (including `any`) is never touched again. Template file fields in both Periodic Notes and Date systems use `TemplateFileSuggest`, an `AbstractInputSuggest<TFile>` (the same base class Templater's own template pickers use — Obsidian's native fuzzy-suggest popup, not an unstyled `<datalist>`), searching every Markdown path in the vault. `AbstractInputSuggest` requires Obsidian ≥1.4.10, hence `manifest.json`'s `minAppVersion`.
|
||
|
||
### Templater folder triggers
|
||
|
||
Waypoint deliberately does not evaluate Templater syntax. For an interactive Templater template, configure Templater's **Trigger Templater on new file creation** mode for the relevant folder, then leave that date system's `templateFile` blank in Waypoint. Waypoint creates a safe fallback note from `typeProperty` and the selected `YYYY-MM-DD` date; Templater's folder trigger owns the rendered content, prompts, scripts, and cursor placement.
|
||
|
||
The target filename is the contract between them. A calendar-created journal note is already named `YYYY-MM-DD - Journal`; a meeting is already `YYYY-MM-DD - {title}`. Templates must extract the date from `tp.file.title`, not `tp.date` or `tp.file.creation_date()`, because those refer to when Templater runs rather than the date selected in the calendar.
|
||
|
||
The journal template may retain a manual fallback: only prompt for a date and rename when `tp.file.title` does **not** match the date-system filename. That preserves manual note creation without a prompt or rename race on calendar creation.
|
||
|
||
### 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
|
||
interface DateSystemNote {
|
||
file: TFile;
|
||
label: string; // free-text title for many-per-date systems, else the system name
|
||
}
|
||
|
||
interface DateSystemNotes {
|
||
system: DateSystemSettings;
|
||
notes: DateSystemNote[];
|
||
multiple: boolean; // nameFormat carries {title}
|
||
}
|
||
|
||
interface DateSystemIndicator {
|
||
id: string;
|
||
name: string;
|
||
color: string;
|
||
}
|
||
|
||
dateSystems(): DateSystemSettings[]
|
||
findDateSystemNotes(date: moment.Moment): DateSystemNotes[]
|
||
getDateSystemIndicators(dates: moment.Moment[]): Map<string, DateSystemIndicator[]>
|
||
openDateSystemNote(
|
||
system: DateSystemSettings,
|
||
date: moment.Moment,
|
||
opts?: { title?: string; leaf?: WorkspaceLeaf },
|
||
): Promise<void>
|
||
```
|
||
|
||
**`getDateSystemIndicators(dates)`** — returns one `{ id, name, color }` marker per configured system with one or more notes on a requested day. The calendar calls it once for the visible month only in `indicatorMode: 'systems'`. It makes one vault pass, caches the result by displayed dates and date-system settings, and invalidates it on markdown create/delete/rename or any Settings change. It does not run on ordinary markdown edits: content edits cannot change a filename-derived indicator.
|
||
|
||
**`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. With a non-empty `system.templateFile`, reads it (appending `.md` when omitted) and creates the note with its raw contents. This is for plain, non-Templater templates.
|
||
2. With no template, the note is created with minimal frontmatter instead: `type: {system.typeProperty}` + `date: YYYY-MM-DD`. Use this mode with a Templater folder trigger, which replaces that fallback content with its rendered template.
|
||
|
||
---
|
||
|
||
## Period Note Creation (`openPeriodNote`)
|
||
|
||
```typescript
|
||
openPeriodNote(
|
||
period: 'day' | 'week' | 'month' | 'quarter' | 'year',
|
||
date: moment.Moment,
|
||
leaf?: WorkspaceLeaf,
|
||
): Promise<void>
|
||
```
|
||
|
||
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 })`.
|
||
|
||
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'))`.
|
||
|
||
---
|
||
|
||
## Event Handling
|
||
|
||
### `file-open`
|
||
|
||
Tracks the opened file into recent files **only** when `settings.recentFiles.updateOn === 'file-open'`.
|
||
|
||
### `vault:modify`
|
||
|
||
Tracks the modified file (guarded to `TFile`) into recent files **only** when `updateOn === 'file-edit'`. Exactly one of the two modes is active at a time, so `'file-edit'` is a working mode rather than a no-op.
|
||
|
||
### `vault:create` / `vault:delete`
|
||
|
||
Both update the markdown-basename set used for calendar note indicators, then trigger `broadcastRedraw()` — telling all WaypointView instances to re-render (needed for the indicators and for stale recent file references).
|
||
|
||
### `vault:rename`
|
||
|
||
1. Updates the markdown-basename set (old basename out, new basename in).
|
||
2. Remaps the `recentFiles` entry through `remapRenamedPath(entry.path, oldPath, newPath)`.
|
||
3. Remaps every bookmark `filePath` through the same helper.
|
||
|
||
`remapRenamedPath(path, oldPath, newPath)` (in `src/utils/path-utils.ts`) returns the updated path when `path` **is** `oldPath` (a plain file rename) *or* is nested under it (`path.startsWith(oldPath + '/')`, i.e. a folder rename/move), and `null` when the path is unaffected. This is why moving a folder no longer orphans the bookmarks inside it. The helper deliberately imports nothing from `'obsidian'` so it stays pure and unit-testable in plain node.
|
||
|
||
---
|
||
|
||
## Recent Files (persisted)
|
||
|
||
```typescript
|
||
recentFiles: { path: string; basename: string }[]
|
||
```
|
||
|
||
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`.
|
||
2. `persistRecentFiles()` — mirrors the array into `waypointData.recentFiles` and schedules a debounced (300ms) `saveWaypointData()`.
|
||
3. Calls `broadcastRedraw()`.
|
||
|
||
**Omitted paths/tags:** Both filters are applied in `addToRecentFiles`. `omittedPaths` entries are treated as regexes tested against `file.path`; `omittedTags` entries are regexes tested against the file's tags, read via `getAllTags(metadataCache.getFileCache(file))` with the leading `#` stripped. An invalid regex is skipped rather than throwing.
|
||
|
||
**`enforceRecentFilesLimit()`:** Called from the settings-change callback — trims the list when `maxItems` is lowered and persists the result.
|
||
|
||
**On file not found:** When clicking a recent file that no longer exists, `focusFile()` shows a Notice and removes the stale entry from `recentFiles`.
|
||
|
||
---
|
||
|
||
## Sidebar View (`WaypointView`)
|
||
|
||
Extends `ItemView`. View type: `"waypoint-view"`, icon: `compass`.
|
||
|
||
### Rendering (`redraw`)
|
||
|
||
Called on every change. Full DOM rebuild — `contentEl.empty()` then three sections in order:
|
||
|
||
1. **Favorites** (`renderFavorites`) — bookmark tree
|
||
2. **Recent Files** (`renderRecentFiles`) — flat list
|
||
3. **Calendar** (`renderCalendar`) — month grid
|
||
|
||
The last section (`waypoint-section:last-child`) gets `margin-top: auto`, pushing it to the bottom of the sidebar.
|
||
|
||
### Calendar Panel
|
||
|
||
**State:** `currentDisplayMonth` (0-indexed), `currentDisplayYear` — allows navigating months independently of today.
|
||
|
||
**Layout:**
|
||
|
||
```
|
||
┌────────────────────────────────────┐
|
||
│ Q2 June 2026 ◀ Today ▶ │ ← breadcrumb (clickable) + nav
|
||
├──┬───┬───┬───┬───┬───┬───┬───┤
|
||
│24│sun│mon│tue│wed│thu│fri│sat│ ← header row + week# col
|
||
├──┼───┼───┼───┼───┼───┼───┼───┤
|
||
│25│ 1 │ 2 │ 3 │... ← day cells (clickable)
|
||
└──┴───┴───┴───┴───┴───┴───┴───┘
|
||
```
|
||
|
||
- **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:** Left-click opens the daily note, middle-click opens it in a new tab, right-click opens the day context menu (below). `.other-month` is muted; `.today` has an inset accent ring; hover receives a quiet fill. With `indicatorMode: 'any'`, `.has-note` shows the existing single neutral dot through the O(1) `plugin.hasNoteForDate(dateStr)` lookup. With `indicatorMode: 'systems'`, the view calls `getDateSystemIndicators()` once for the month and renders one tooltip-labelled coloured dot per matching daily/journal/meeting/custom system.
|
||
- **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`).
|
||
|
||
**Per-file features:**
|
||
- **Active indicator:** `.is-active` class if path matches `workspace.getActiveFile()`.
|
||
- **Remove button:** × icon, appears on hover (`.waypoint-recent-remove`), drops the entry from `plugin.recentFiles`, calls `persistRecentFiles()`, then redraws.
|
||
- **Drag:** Uses `app.dragManager.dragFile()` for native Obsidian drag.
|
||
- **Hover preview:** Triggers `hover-link` event for Obsidian's page preview popup.
|
||
- **Context menu:** "Open in new tab" + Obsidian's native `file-menu` event.
|
||
- **Click:** `Keymap.isModEvent(event)` determines if opening in new leaf; otherwise same leaf.
|
||
- **Middle click:** Opens in new tab.
|
||
|
||
### Bookmarks/Favorites Panel
|
||
|
||
**Empty state:** Right-click on "Waypoint Bookmarks" header shows add menu:
|
||
- Add current file → `addBookmark(path, basename, 'file')`
|
||
- New group → `addBookmark('', 'New Group', 'group', 'folder')`
|
||
- Add separator → `addBookmark('', '', 'separator')`
|
||
- Add spacer → `addBookmark('', '', 'spacer')`
|
||
|
||
**Rendering:** `renderBookmarkList(container, items, depth)` recursively renders items with `paddingLeft: 8 + depth * 16px` for indentation.
|
||
|
||
**Item types:**
|
||
| Type | Render | Behavior |
|
||
|---|---|---|
|
||
| `file` | icon + label | Click opens file. Tooltip = filePath. |
|
||
| `group` | chevron + icon + label | Click toggles `collapsed`. Children rendered in nested `waypoint-bookmark-children` div (hidden when collapsed). |
|
||
| `separator` | horizontal line (::after pseudo-element) | No click handler. Context menu for delete. |
|
||
| `spacer` | 14px empty div | No click handler. Context menu for delete. |
|
||
|
||
**Drag-and-drop:** All bookmark items are draggable. Drop indicators show a 3px accent border above/below the target. On drop:
|
||
1. Remove dragged item from its current position (recursive search).
|
||
2. Insert at the target position (before or after based on cursor Y position relative to target midY).
|
||
3. If target not found (edge case), push to root.
|
||
4. Save + redraw.
|
||
|
||
**Context menu (right-click):**
|
||
- File items: "Open in new tab"
|
||
- 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"
|
||
|
||
**Move to group (`moveBookmarkToGroup`):**
|
||
1. Remove item from current position.
|
||
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`.
|
||
|
||
**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** button to clear, Save/Cancel buttons.
|
||
|
||
---
|
||
|
||
## CSS Architecture (`styles.css`)
|
||
|
||
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-error`
|
||
- `--background-modifier-border`, `--background-modifier-active-hover`, `--background-modifier-hover`, `--background-primary`, `--background-primary-alt`, `--background-secondary`
|
||
- `--interactive-accent`
|
||
- `--cursor-link` (pointer cursor, with a `pointer` fallback)
|
||
|
||
Key layout:
|
||
- `.waypoint-view` — flex column, `overflow-y: auto`, 8px padding.
|
||
- `.waypoint-section` — `flex-shrink: 0`, 16px bottom margin. Last section gets `margin-top: auto` (pins calendar to bottom).
|
||
- `.waypoint-calendar` — padded, bordered surface with separated day cells for clear scan lines.
|
||
- Calendar table — `table-layout: fixed`, `border-collapse: separate`, 2px horizontal / 3px vertical cell spacing.
|
||
- `.waypoint-day.today` — accent text with an inset accent ring, avoiding layout shifts.
|
||
- `.waypoint-day.has-note::after` — neutral single-dot indicator.
|
||
- `.waypoint-day-indicators` / `.waypoint-day-indicator` — compact ordered dot row; `--waypoint-indicator-color` carries each configured system colour.
|
||
- `.waypoint-bm-chevron` — `rotate(-90deg)` on collapsed groups.
|
||
- `.waypoint-bm-drop-line` / `.waypoint-bm-drop-below` — 3px accent border for drag indicators.
|
||
|
||
---
|
||
|
||
## Date Utilities (`date-utils.ts`)
|
||
|
||
All use Obsidian's bundled `moment` (not the npm package).
|
||
|
||
| Function | Signature | Returns |
|
||
|---|---|---|
|
||
| `getPeriodInfo(date)` | `Moment → PeriodInfo` | `{day, week, month, quarter, year}` strings for the date |
|
||
| `getQuarterString(date)` | `Moment → string` | e.g. `"2026-Q3"` |
|
||
| `getTodayPeriod()` | `() → PeriodInfo` | Current date's period info |
|
||
| `getMonthGrid(year, month, firstDayOfWeek)` | `number, number, number → CalendarWeek[]` | Up to 6 weeks, each with 7 `CalendarDay` objects |
|
||
| `formatDateLabel(date)` | `Moment → string` | `"June 1st, 2026"` |
|
||
| `formatMonthLabel(date)` | `Moment → string` | `"June 2026"` |
|
||
| `formatQuarterLabel(date)` | `Moment → string` | `"Q3 2026"` |
|
||
| `navigateDate(date, period, delta)` | `Moment, period, number → Moment` | Clones + adds delta (quarters: delta×3 months) |
|
||
| `formatPeriodName(date, format)` | `Moment, string → string` | `date.format(format)` wrapper |
|
||
| `getISOWeek(date)` | `Moment → number` | `date.isoWeek()` |
|
||
|
||
**`getMonthGrid` algorithm:**
|
||
1. Find the first day to display by subtracting `(firstOfMonth.day() - firstDayOfWeek + 7) % 7` days from the 1st.
|
||
2. Iterate day-by-day, building 7-day weeks.
|
||
3. Each `CalendarDay` tracks: `date` (Moment), `dayOfMonth`, `isToday`, `isCurrentMonth`, `isoWeekNumber`.
|
||
4. Week's `weekNumber` = first day's ISO week number.
|
||
5. Safety break after 6 weeks.
|
||
|
||
---
|
||
|
||
## Settings Persistence
|
||
|
||
Settings and waypoint data share a single `data.json` via Obsidian's `Plugin.loadData()/saveData()`:
|
||
|
||
```typescript
|
||
// loadData() returns:
|
||
{
|
||
settings: { calendar: {...}, daily: {...}, ... },
|
||
waypointData: { bookmarks: [...] }
|
||
}
|
||
```
|
||
|
||
`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()`.
|
||
|
||
---
|
||
|
||
## Redraw Mechanism
|
||
|
||
`broadcastRedraw()` iterates all leaves of `WAYPOINT_VIEW_TYPE` and calls `view.redraw()` on each. `redraw()` is a full DOM rebuild (no virtual DOM, no diffing). This is triggered by:
|
||
|
||
- File open/create/delete/rename/modify events
|
||
- Bookmark add/remove/update
|
||
- Period navigation
|
||
- Settings changes (via `onSettingsChange` callback)
|
||
- Midnight detection (10-min interval)
|
||
|
||
The sidebar is re-rendered from scratch on every change. For a small sidebar this is acceptable; for larger bookmark lists it may cause flicker.
|
||
|
||
---
|
||
|
||
## Known Limitations / Gaps
|
||
|
||
1. **No virtual DOM** — Full rebuild on every redraw. Bookmark drag/drop, rename, and any change tears down and rebuilds the entire sidebar. For a small sidebar this is acceptable; for larger bookmark lists it may cause flicker.
|
||
2. **IconSuggestModal fetches from CDN** — Network dependency for the Lucide tag list. The fetch happens at most once per session (the result is cached) and falls back to the hardcoded `FALLBACK_ICONS` list on failure, but a first-open with no network still degrades to that reduced list.
|