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.
20 KiB
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()
- Load data —
loadSettings()thenloadWaypointData(), both merge saved partials over defaults viaObject.assign({}, DEFAULT, partial). - Register view —
WAYPOINT_VIEW_TYPE = "waypoint-view"maps toWaypointViewfactory(leaf) => new WaypointView(leaf, this). - Settings tab —
WaypointSettingTabreceives the livesettingsobject +() => this.redrawAll()callback for live preview. - Register commands (see Commands section below).
- Register events —
file-open,vault:create,vault:delete,vault:rename,vault:modify. - Auto-open — On
onLayoutReady, if no existing leaves of the view type, opens one in the left sidebar. - Midnight refresh — 10-minute
setIntervalchecks ifnew Date().toDateString()changed; if so, callsredrawAll()(calendar day indicators refresh).
onunload()
Detaches all leaves of WAYPOINT_VIEW_TYPE.
Data Models
WaypointSettings
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 (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)
}
BookmarkItem (recursive tree)
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:
- Gets the active file's basename.
- Calls
detectPeriodType(basename)— settings-driven, not hardcoded regexes. It readssettings.{daily,weekly,monthly,quarterly,yearly}.nameFormatand triesmoment(basename, nameFormat, true).isValid()(strict parsing) in order day → week → month → quarter → year, returning the first match as{ period, date }. Periods with an emptynameFormatare skipped; returnsnullif nothing matches. Changing a name format in settings therefore keeps next/prev navigation working. - Adds ±1 period (quarters add ±3 months).
- Calls
openPeriodNotefor 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)
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:
- Build filename:
date.format(periodSettings.nameFormat) + ".md". - Build full path: If
periodSettings.folderis set, prepend it; otherwise root. - Check existence:
vault.getFileByPath(fullPath). - 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 istype: {typeProperty}+date: YYYY-MM-DD.
- Try to read template at
- Open: in
leafwhen one is supplied, otherwise inworkspace.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
- Updates the markdown-basename set (old basename out, new basename in).
- Remaps the
recentFilesentry throughremapRenamedPath(entry.path, oldPath, newPath). - Remaps every bookmark
filePaththrough 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)
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:
addToRecentFiles(file)— omission check, then dedupes (removes existing entry), prepends to front, truncates tomaxItems.persistRecentFiles()— mirrors the array intowaypointData.recentFilesand schedules a debounced (300ms)saveWaypointData().- 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:
- Favorites (
renderFavorites) — bookmark tree - Recent Files (
renderRecentFiles) — flat list - 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-monthdimmed..todayhas accent border..has-notegets a dot indicator, decided by the synchronous O(1)plugin.hasNoteForDate(dateStr)(aSetlookup — no per-cell vault scan). - Grid generation:
getMonthGrid(year, month, firstDayOfWeek)indate-utils.tsproduces up to 6 weeks, each with 7CalendarDayobjects containingmoment,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-activeclass if path matchesworkspace.getActiveFile(). - Remove button: × icon, appears on hover (
.waypoint-recent-remove), drops the entry fromplugin.recentFiles, callspersistRecentFiles(), then redraws. - Drag: Uses
app.dragManager.dragFile()for native Obsidian drag. - Hover preview: Triggers
hover-linkevent for Obsidian's page preview popup. - Context menu: "Open in new tab" + Obsidian's native
file-menuevent. - 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:
- Remove dragged item from its current position (recursive search).
- Insert at the target position (before or after based on cursor Y position relative to target midY).
- If target not found (edge case), push to root.
- Save + redraw.
Context menu (right-click):
- File items: "Open in new tab"
- File/Group items: "Rename" (opens
RenameModal), "Change icon" (opensIconSuggestModal) - "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):
- Remove item from current position.
- If targetGroupId: find group, set
item.indent = group.indent + 1, push to group's children. - If null (root): set
item.indent = 0, push towaypointData.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.jsonwith fallback tohttps://lucide.dev/api/tagsand a hardcodedFALLBACK_ICONSobject (~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 getsmargin-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:
- Find the first day to display by subtracting
(firstOfMonth.day() - firstDayOfWeek + 7) % 7days from the 1st. - Iterate day-by-day, building 7-day weeks.
- Each
CalendarDaytracks:date(Moment),dayOfMonth,isToday,isCurrentMonth,isoWeekNumber. - Week's
weekNumber= first day's ISO week number. - Safety break after 6 weeks.
Settings Persistence
Settings and waypoint data share a single data.json via Obsidian's Plugin.loadData()/saveData():
// 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
onSettingsChangecallback) - 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
- 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.
- 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_ICONSlist on failure, but a first-open with no network still degrades to that reduced list.