Files
waypoint/DOCUMENTATION.md
T
olivier 61c191505e docs: correct claims contradicted by the source, extend QA checklist
DOCUMENTATION.md said recent files were in-memory only and lost on vault
close, while the code persists them under waypointData.recentFiles; the
WaypointData/RecentFilesSettings/WaypointSettings snippets were also missing
real fields. Fix those, describe the new behaviour (single-writer saves,
settings-driven period detection, O(1) basename lookups, folder-aware rename
remapping, unified openPeriodNote, typechecked build), and trim Known
Limitations to the two still true (full-DOM redraw, CDN icon dependency).

QA.md: restate the 'No icon' case and add coverage for week-number
middle-click and for moving a folder that contains bookmarked files.
2026-09-07 20:04:01 -04:00

397 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 constant
├── settings-tab.ts PluginSettingTab UI — tabs: calendar, periodic, recent, display, about
├── models/
│ └── bookmark.ts BookmarkItem + WaypointData interfaces
├── utils/
│ ├── date-utils.ts Moment.js helpers: period formatting, month grid, navigation
│ └── 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**`loadSettings()` then `loadWaypointData()`, both merge saved partials over defaults via `Object.assign({}, DEFAULT, partial)`.
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; // firstDayOfWeek (0=Sun,1=Mon), showNoteIndicators
daily: PeriodNoteSettings;
weekly: PeriodNoteSettings;
monthly: PeriodNoteSettings;
quarterly: PeriodNoteSettings;
yearly: PeriodNoteSettings;
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; // dot on days with existing .md files
}
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 (1840)
rowSpacing: number; // px, gap between items (012)
indentSize: number; // px, indent per depth level (832)
fontSize: number; // px, font size for bookmark labels (1018)
iconSize: number; // px, icon size (1224)
calendarCellSize: number; // px, calendar day cell height (2048)
}
```
### 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 |
---
## 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`. For a given period + moment date:
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)`.
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. `loadWaypointData()` 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:** 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).
- **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`.
### 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 `RenameModal`), "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`.
**Rename modal (`RenameModal`):** Simple Modal with text input + Cancel/Save buttons. Enter key submits.
**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.
---
## 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`
- `--background-modifier-border`, `--background-modifier-active-hover`, `--background-primary`, `--background-secondary`
- `--interactive-accent`
- `--cursor` (for custom cursor support)
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-section-header` — uppercase, muted, with bottom border.
- Calendar table — `table-layout: fixed`, `border-collapse: collapse`.
- `.waypoint-day.today` — accent color text + 1px accent border.
- `.waypoint-day.has-note::after` — 4px dot indicator.
- `.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: [...] }
}
```
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()`.
---
## 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.