35 Commits

Author SHA1 Message Date
olivier b617c275eb release v1.5.2: crash, data-loss, and race-condition fixes 2026-09-07 20:30:05 -04:00
olivier bb842b55b4 chore: sync stale lockfile version (1.0.0 -> 1.5.1)
Written by a clean npm install; no dependency changes.
2026-09-07 20:22:43 -04:00
olivier 0eb529e604 chore: rebuild bundle 2026-09-07 20:22:33 -04:00
olivier ece066caf8 fix: pointer cursor on clickables, 'No icon' is a real button
styles.css used cursor: var(--cursor) in 10 places and the icon modal in 2
more. Obsidian defines --cursor as 'default', so nothing in the plugin ever
showed a pointer — the old 'correct cursor on hover' todo never actually
worked. Use var(--cursor-link, pointer): themeable, pointer by default.
cursor: grab on separators/spacers is left alone.

'No icon' was a bare accent-coloured span in the status row. It commits a
value and closes, exactly like Save, so it is now a real button in the
modal button container, left-aligned via margin-right: auto to read as a
secondary action. Also adds the missing trailing newline to styles.css.
2026-09-07 20:22:29 -04:00
olivier b064a64142 fix: create missing period folders and explain failures
Clicking a week/month/quarter/year in a vault whose configured folder does
not exist yet failed with a generic 'Failed to create X note: <raw error>',
because vault.create refuses to create parent folders. Create the folder
(and any missing ancestors) first, then report what actually went wrong.

- Every failure path now names the offending path and the reason, and points
  at the relevant settings section; diagnostic notices stay up for 10s.
- A configured-but-missing template used to fall back to basic frontmatter
  silently; the creation notice now says the template was not found.
- The unreachable !date.isValid() branch after detectPeriodType is gone; the
  'not a periodic note' notice now lists the configured formats it expected.
2026-09-07 20:22:29 -04:00
olivier ec3ad7d384 style: transparent nav buttons, tighter calendar breadcrumb
Drops the default button background/shadow from the header 'more', calendar
nav and Today buttons, and tightens breadcrumb spacing so Q/month/year read
as inline links rather than buttons.
2026-09-07 20:18:30 -04:00
olivier d22b53253e chore: rebuild bundle
Regenerated main.js from the fixed sources (this is what BRAT and manual
installs ship). The bare 'monday' global reference is gone from the bundle.
2026-09-07 20:04:01 -04:00
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
olivier c5282dbdef build: gate builds on tsc, fix lib floor, correct minAppVersion
The build only ran esbuild, which strips types without checking them — that
is how the week-number ReferenceError shipped. Add a typecheck script and run
it before bundling. It is invoked as 'node node_modules/typescript/bin/tsc'
rather than a bare 'tsc' because this repo's node_modules/.bin entries are
Linux-style symlinks with no Windows .cmd shims, matching how the existing
scripts already call 'node esbuild.config.mjs' directly.

- tsconfig lib gains ES2017 so Object.entries resolves (was TS2550 plus
  cascading TS7031 implicit-any errors); strictness untouched.
- manifest minAppVersion 0.16.3 -> 1.4.4: the plugin calls setTooltip(), added
  in API 1.4.4, so every declared-supported version below that threw
  TypeError on render.
- settings-tab: type plugin as WaypointPlugin via a type-only import (no
  runtime cycle) to drop the (this.plugin as any).saveSettings() cast, and
  make the slider helper generic instead of obj: any. No UI change.
- Add the MIT LICENSE file that package.json and the About tab already claim.
2026-09-07 20:03:53 -04:00
olivier dc9e4c3200 fix: week-number middle-click crash and 'No icon' never clearing
- Middle-clicking a calendar week number threw ReferenceError: monday is not
  defined (shipped in v1.5.1) — the variable was declared inside the click
  closure and read from the sibling mousedown handler. Hoist it to weekStart.
- promptIcon guarded on if (iconName), so the picker's 'No icon' link
  submitting '' was silently dropped and the old icon stayed. '' is a valid
  value; always apply it.
- renderCalendar scanned vault.getFiles() per day cell (up to 42 full vault
  scans per render); use the O(1) plugin.hasNoteForDate().
- Collapse ~180 lines of triplicated drag listeners into one
  attachBookmarkDragHandlers(); replace (el as any).__dropAbove/__dropInto
  stashing with a typed WeakMap; drop the getLeaf(x as any) casts.
- dragManager.dragFile was called twice per dragstart; call it once behind a
  single narrowly-typed accessor.
- Cache the Lucide catalog per session instead of refetching the CDN on every
  picker open; normalize IconSuggestModal to the file's const/arrow style.
2026-09-07 20:03:45 -04:00
olivier 6a1a044566 fix: folder renames no longer destroy bookmarks, plus data.json save race
- onRename remapped paths by exact equality only, so files inside a moved
  folder kept stale paths and the view then permanently deleted their
  bookmarks on next click. Add pure remapRenamedPath() (path-utils.ts,
  obsidian-free so it is unit-testable) and remap recentFiles + the whole
  bookmark tree, folder moves included.
- saveSettings/saveWaypointData each did loadData -> mutate one key ->
  saveData, so two concurrent saves carried a stale snapshot of the other
  key and one silently reverted the other. Both now delegate to persistAll(),
  which writes both keys from memory through a promise-chain mutex.
- data.json is read once in onload instead of twice; period sub-objects are
  deep-merged so old configs pick up new fields.
- detectPeriodType now derives from the configured nameFormat with strict
  moment parsing instead of hardcoded regexes, so custom formats work.
- Merge openPeriodNoteInLeaf into openPeriodNote(period, date, leaf?); stop
  double-appending .md to templateFile; type the caught error as unknown.
- hasNoteForDate is synchronous and O(1) over a maintained basename Set;
  delete dead getNotesForDate.
- Implement the previously dead omittedTags filter and the no-op
  updateOn: 'file-edit' mode (vault modify event).
2026-09-07 20:03:36 -04:00
olivier 3c7b6741f5 fix: calendar disappeared for existing users
Shallow Object.assign on settings meant filterTags (new in 1.5.0) was
undefined for anyone with prior saved settings. Accessing .length on
undefined threw, killing the entire redraw before calendar could render.

- Defensive fallback: filterTags || []
- Deep merge on loadSettings for recentFiles, calendar, display
2026-06-22 10:18:37 -04:00
olivier abf4d6ccf7 feat: configurable filter tags for recent files
New 'Filter tags' setting in Recent Files tab. Enter tag names one per line
to pin specific tags as filter pills. Leave empty to auto-detect from
frontmatter 'type' property (existing behavior).
2026-06-22 10:15:54 -04:00
olivier 141121878f feat: add About tab to settings with version and plugin description 2026-06-22 10:07:22 -04:00
olivier 6a89660cf4 release v1.4.1 2026-06-22 10:01:47 -04:00
olivier 44c2dd2abc fix: enforce recent files limit when maxItems setting changes
The limit was only applied on file-open and plugin load, not when the user
changed the maxItems setting in the settings tab. Now enforceRecentFilesLimit()
is called on every settings change via the onSettingsChange callback.
2026-06-22 10:00:45 -04:00
olivier 49c8da9863 feat: persistent recent files history + fix limit enforcement + fix settings data loss
- Recent files are now persisted to disk and survive restarts
- Max items limit is applied on load (in case setting was reduced)
- Omitted paths regex filtering is now wired up in addToRecentFiles
- Fixed saveAndRefresh() overwriting entire plugin data on settings change
- Debounced disk writes (300ms) to avoid excessive saves on rapid opens
2026-06-21 22:36:03 -04:00
olivier 993017f669 release v1.3.0: chevron right, tabbed settings, drag-into, display customization 2026-06-12 14:54:28 -04:00
olivier e0c3deee86 fix: drag onto file makes dragged item a child of that file
Dragging B onto A:
  ▶ File A
      File B

A becomes the parent, B becomes its child. No new group created.
2026-06-12 14:52:04 -04:00
olivier 7331a455ad fix: drag-to-create parent group is now an empty container
When dragging B onto A, the result is now:
- New Group (empty, no file)
  - File A
  - File B

Previously the group inherited A's file, creating a self-referencing parent.
2026-06-12 14:50:48 -04:00
olivier 7a5be506e0 feat: drag onto file bookmark creates parent note + fix calendar layout
Drag-and-drop:
- Dropping a bookmark onto a file bookmark (middle zone) creates a new
  parent note group containing both items
- Dropping onto a group still moves into the group as before
- All file/group bookmarks now accept drops (3-zone behavior)

Calendar:
- Fixed broken table layout caused by display:flex on td elements
- Uses vertical-align:middle instead for cell content centering
- Calendar cells still have configurable height via CSS variable
2026-06-12 13:18:25 -04:00
olivier 765c6f3b47 feat: chevron on right + tabbed settings + display customization
View:
- Chevron moved from left side to right side of bookmark rows
- Display settings applied as CSS variables on the view element

Settings:
- Tabbed UI with 4 tabs: Calendar, Periodic Notes, Recent Files, Display
- Display tab: row size, row spacing, indent size, font size, icon size, calendar cell size
- All settings use sliders with live preview and dynamic tooltips
- Reset to defaults button

CSS:
- Bookmark items use CSS variables (--wp-row-size, --wp-row-spacing, etc.)
- Calendar cells use --wp-cal-cell-size for configurable height
- Settings tab styling with active indicator
2026-06-12 12:56:27 -04:00
olivier 846726a5c7 docs: mark all todo items as done 2026-06-11 11:18:33 -04:00
olivier e92903a7aa feat: recent files type filter + calendar middle-click + calendar spacing
Recent files:
- Type filter bar with pill tags above the list
- Scans frontmatter 'type' property of each recent file
- Click a pill to filter, click again or 'all' to clear
- Active pill shows accent color

Calendar:
- Middle-click on day/week/quarter/month/year opens in new tab
- openPeriodNoteInLeaf() method for leaf-targeted opening
- More spacing: taller day cells, more padding, wider margins

Bookmarks:
- Right-click on recent file → 'Add to bookmarks' option
2026-06-11 11:16:17 -04:00
olivier 7bf1223673 style: improve calendar spacing — more room and height
- Day cell padding: 3px → 6px vertical
- Header/cell padding: 2px → 4px vertical
- Breadcrumb padding: 2px → 4px vertical
- Top row margin-bottom: 6px → 10px
- Line-height: 1.6 on calendar cells
2026-06-11 10:49:55 -04:00
olivier a36c8ab6f4 feat: add bookmark from recent files + calendar middle-click
- Recent files right-click now has 'Add to bookmarks' option
- Calendar: middle-click on day/week/quarter/month/year opens in new tab
- Added openPeriodNoteInLeaf() method for opening period notes in a specific leaf
2026-06-11 10:46:57 -04:00
olivier eeb6059fe5 feat: drag-and-drop into groups/parent notes + fix ⋯ button styling
Drag-and-drop:
- 3-zone drop indicator for groups and parent notes (files with children):
  top 25% = drop above, middle 50% = drop into, bottom 25% = drop below
- 2-zone for regular files/separators/spacers (unchanged)
- 'Drop into' shows background highlight instead of border line
- Circular reference guard: can't drop a group into itself or descendants

⋯ button:
- No visible button chrome when not hovered (no border, no background)
- Subtle opacity (0.6) at rest, full opacity on hover
- Hover shows text color change only, no background highlight
2026-06-10 21:53:22 -04:00
olivier 2e8ac7047f refactor: simplify bookmark menus — visible ⋯ button, flat context menus
Header:
- Replace hidden right-click with visible ⋯ button (more-horizontal icon)
- Menu: Add current file / Add as parent note / New group / separator / Add spacer

File bookmark right-click (5 items, flat):
  Open in new tab / Rename / Change icon / Remove

Group/parent note right-click (7 items max, flat):
  Open in new tab (only if linked) / Rename / Change icon / Expand|Collapse /
  Add child bookmark / Add child note / New sub-group / Remove

Separator/Spacer right-click (1 item):
  Remove

Removed dead code: insertAdjacent, collectGroups, collectDescendantIds,
moveBookmarkToGroup — all moving is now drag-and-drop only
2026-06-10 21:45:16 -04:00
olivier a692dc9a34 docs: add QA checklist for bookmark features 2026-06-10 20:20:27 -04:00
olivier 6978401613 feat: improved icon search — matches Lucide tags/keywords
The icon picker now searches against the Lucide tags.json keyword list,
not just the icon name. Searching 'meeting' now finds 'calendar',
'red-heart' finds 'heart', etc. Tags are loaded alongside icon names
from the CDN with fallback.
2026-06-10 20:19:57 -04:00
olivier 3c4fc2d91c feat: parent notes (file bookmarks with children) + no default icon
- File bookmarks can now have children, rendered with a chevron
- Group bookmarks can have an associated filePath; clicking opens the file,
  clicking chevron toggles collapse
- 'Add as parent note' option in header context menu creates a group
  with the current file linked
- 'Add child note' option in group context menu creates a sub-group
  with the current file linked (parent note pattern)
- Default icon changed from 'file'/'folder' to '' (no icon)
- Chevron click always toggles collapse regardless of type
2026-06-10 20:19:01 -04:00
olivier 4278507ae2 feat: middle-click + mod-click on bookmarks opens in new tab
- File bookmarks: middle-click opens in new tab, mod+click opens in new leaf
- Group bookmarks: middle-click opens group's linked file in new tab,
  mod+click opens group's linked file (click still toggles collapse)
2026-06-10 20:16:17 -04:00
olivier 99a3ea80ae new 2026-06-10 20:14:30 -04:00
olivier cb09ec0e83 bump to v1.2.0, rename plugin to Waypoint Sidebar 2026-06-10 15:42:13 -04:00
olivier a4714f6fa8 Add period note commands with hotkeys and next/prev navigation
- Add 5 'Go to' commands with default hotkeys:
  Ctrl+Shift+Alt+D/W/M/Q/Y for daily/weekly/monthly/quarterly/yearly
- Add 10 navigation commands (next/prev) for each period type
- Add detectPeriodType() and navigatePeriodNote() helpers
- Bump version to 1.1.0
2026-06-08 12:32:21 -04:00
17 changed files with 2050 additions and 579 deletions
+396
View File
@@ -0,0 +1,396 @@
# 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.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Olivier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+81
View File
@@ -0,0 +1,81 @@
# Waypoint Sidebar — QA Checklist
## Bookmarks: Middle Click
- [ ] Middle-click on a **file bookmark** opens the file in a new tab
- [ ] Middle-click on a **group bookmark with a linked file** opens that file in a new tab
- [ ] Middle-click on a **group bookmark with no file** does nothing (no error)
- [ ] Ctrl/Cmd+click on a **file bookmark** opens in a new leaf (same as Obsidian default)
- [ ] Ctrl/Cmd+click on a **group bookmark with a linked file** opens in a new leaf
- [ ] Ctrl/Cmd+click on a **group bookmark with no file** still toggles collapse
- [ ] Regular left-click on a **file bookmark** opens the file in the same leaf
- [ ] Regular left-click on a **group with a file** opens the file (does NOT toggle collapse)
- [ ] Regular left-click on a **group without a file** toggles collapse
## Bookmarks: Parent Notes (File as Group)
- [ ] Right-click header → "Add as parent note" creates a group with the current file linked
- [ ] Clicking the parent note row opens the linked file
- [ ] Clicking the chevron toggles collapse of children
- [ ] Children render correctly under the parent note (indented)
- [ ] Collapsing the parent hides its children
- [ ] A **file bookmark** that has children shows a chevron
- [ ] Clicking the chevron on a file bookmark toggles its children
- [ ] Clicking the file bookmark row itself still opens the file
- [ ] Drag-and-drop still works for reordering items inside/outside parent notes
## Bookmarks: No Default Icon
- [ ] New file bookmarks have **no icon** (empty space where icon was)
- [ ] New groups have **no icon** (empty space where folder icon was)
- [ ] "Add bookmark here" from group context menu creates child with no icon
- [ ] "Add child note" from group context menu creates child with no icon
- [ ] Existing bookmarks with icons still show their icons (no regression)
- [ ] Right-click → "Change icon" still works to add an icon later
## Bookmarks: Icon Search (Lucide Tags)
- [ ] Open the icon picker → type "meeting" → should find `calendar` (tag match)
- [ ] Type "heart" → should find `heart`, `red-heart`, etc.
- [ ] Type "arrow" → should find all arrow icons
- [ ] Type a tag keyword like "fitness" → should find `activity`, `dumbbell`, etc.
- [ ] Clear search (empty input) → shows first 80 icons
- [ ] Set an icon on a bookmark, reopen the picker, click "No icon" → the previously-set icon is cleared
- [ ] Click an icon → preview updates, grid highlights selection
- [ ] Click Save → icon applied, click Cancel → no change
- [ ] Test with **network offline** → falls back to FALLBACK_ICONS list
## Bookmarks: General (Regression)
- [ ] Drag-and-drop reordering still works between all item types
- [ ] Rename still works (right-click → Rename)
- [ ] Remove still works (right-click → Remove)
- [ ] Move to group still works (right-click → group submenu)
- [ ] Separators and spacers still render and can be dragged/deleted
- [ ] "Insert separator above/below" and "Insert spacer above/below" still work
- [ ] Hover preview (page preview popup) still works on file bookmarks
- [ ] Right-click context menu on a file bookmark still shows native Obsidian file-menu items
- [ ] Bookmarks persist across vault reload
- [ ] Move a folder containing bookmarked files to a new location → those bookmarks survive and still open the moved files
## Calendar (Regression)
- [ ] Calendar renders with correct month grid
- [ ] Click on a day → opens/creates daily note
- [ ] Click on week number → opens/creates weekly note
- [ ] Middle-click on a week number → opens/creates the weekly note in a new tab, with no console error
- [ ] Click Q/M/Y breadcrumb → opens respective period note
- [ ] ◀/▶ navigation shifts month
- [ ] "Today" button returns to current month
- [ ] Note indicator dots show on days with existing .md files
- [ ] Today is highlighted with accent border
## Recent Files (Regression)
- [ ] Opening a file adds it to recent files
- [ ] Most recent file appears at the top
- [ ] Active file is highlighted
- [ ] Remove button (×) works on hover
- [ ] Left-click opens file, middle-click opens in new tab
- [ ] Right-click shows "Open in new tab" + native file menu
- [ ] Hover preview popup works
+34
View File
@@ -0,0 +1,34 @@
# Waypoint
Calendar, recent files, and custom bookmarks sidebar for Obsidian.
## Features
- **Calendar panel** — month grid with clickable days, week numbers, period indicators (day/week/month/quarter/year)
- **Recent files** — track recently opened/edited files
- **Favorites** — custom bookmarks with groups, icons, and rename
## Commands
| Command | Default Hotkey |
|---|---|
| Go to daily note | `Ctrl+Shift+Alt+D` |
| Go to weekly note | `Ctrl+Shift+Alt+W` |
| Go to monthly note | `Ctrl+Shift+Alt+M` |
| Go to quarterly note | `Ctrl+Shift+Alt+Q` |
| Go to yearly note | `Ctrl+Shift+Alt+Y` |
| Next/Previous daily/weekly/monthly/quarterly/yearly note | — |
## Installation
### Via BRAT
1. Install the [BRAT](https://obsidian.md/plugins?search=brat) plugin
2. Add `https://gitea.mithrilforge.dev/olivier/waypoint` as a Beta Plugin
3. Enable **Waypoint** in Community Plugins
### Manual
1. Download the latest release from [Gitea](https://gitea.mithrilforge.dev/olivier/waypoint/releases)
2. Extract `main.js`, `manifest.json`, and `styles.css` to `{vault}/.obsidian/plugins/waypoint/`
3. Enable **Waypoint** in Community Plugins
+21 -10
View File
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -1,8 +1,8 @@
{
"id": "waypoint",
"name": "Waypoint",
"version": "1.0.0",
"minAppVersion": "0.16.3",
"id": "waypoint-sidebar",
"name": "Waypoint Sidebar",
"version": "1.5.2",
"minAppVersion": "1.4.4",
"description": "Calendar, recent files, and custom bookmarks sidebar.",
"author": "Olivier",
"isDesktopOnly": false
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "waypoint",
"version": "1.0.0",
"version": "1.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "waypoint",
"version": "1.0.0",
"version": "1.5.1",
"license": "MIT",
"devDependencies": {
"builtin-modules": "4.0.0",
+3 -2
View File
@@ -1,11 +1,12 @@
{
"name": "waypoint",
"version": "1.0.0",
"version": "1.5.2",
"description": "Calendar, recent files, and custom bookmarks sidebar.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "node esbuild.config.mjs production"
"typecheck": "node node_modules/typescript/bin/tsc --noEmit",
"build": "npm run typecheck && node esbuild.config.mjs production"
},
"keywords": [],
"author": "Olivier",
+491 -108
View File
@@ -3,32 +3,38 @@
import {
Plugin,
WorkspaceLeaf,
ItemView,
Notice,
TFile,
TFolder,
TAbstractFile,
getAllTags,
moment,
} from 'obsidian';
import { WaypointSettings, DEFAULT_SETTINGS, PeriodNoteSettings } from 'src/settings';
import { WaypointSettingTab } from 'src/settings-tab';
import { WaypointView, WAYPOINT_VIEW_TYPE } from 'src/views/waypoint-view';
import { BookmarkItem, WaypointData } from 'src/models/bookmark';
import { remapRenamedPath } from 'src/utils/path-utils';
const DEFAULT_DATA: WaypointData = {
bookmarks: [],
};
export type PeriodKey = 'day' | 'week' | 'month' | 'quarter' | 'year';
export default class WaypointPlugin extends Plugin {
public settings: WaypointSettings;
public waypointData: WaypointData;
public recentFiles: { path: string; basename: string }[] = [];
private recentFilesSaveTimer: number | undefined;
/** Serializes writes to data.json so overlapping saves cannot lose updates. */
private savePromise: Promise<void> = Promise.resolve();
/** Basenames of every markdown file in the vault, for O(1) calendar lookups. */
private markdownBasenames: Set<string> = new Set();
async onload(): Promise<void> {
console.debug('Waypoint: loading plugin v' + this.manifest.version);
// Load persisted data
await this.loadSettings();
await this.loadWaypointData();
// Load persisted data — data.json is read exactly once here.
const saved = await this.loadData() as Record<string, unknown> | null;
this.applySettings(saved);
this.applyWaypointData(saved);
// Register the sidebar view
this.registerView(
@@ -41,7 +47,10 @@ export default class WaypointPlugin extends Plugin {
this.app,
this,
this.settings,
() => this.redrawAll(),
() => {
this.enforceRecentFilesLimit();
this.redrawAll();
},
));
// ── Commands ──
@@ -78,13 +87,70 @@ export default class WaypointPlugin extends Plugin {
});
this.addCommand({
id: 'waypoint-go-to-today',
name: 'Go to today\'s daily note',
id: 'waypoint-go-to-daily',
name: 'Go to daily note',
hotkeys: [{ modifiers: ['Mod', 'Shift', 'Alt'], key: 'd' }],
callback: async () => {
await this.openPeriodNote('day', moment());
},
});
this.addCommand({
id: 'waypoint-go-to-weekly',
name: 'Go to weekly note',
hotkeys: [{ modifiers: ['Mod', 'Shift', 'Alt'], key: 'w' }],
callback: async () => {
await this.openPeriodNote('week', moment());
},
});
this.addCommand({
id: 'waypoint-go-to-monthly',
name: 'Go to monthly note',
hotkeys: [{ modifiers: ['Mod', 'Shift', 'Alt'], key: 'm' }],
callback: async () => {
await this.openPeriodNote('month', moment());
},
});
this.addCommand({
id: 'waypoint-go-to-quarterly',
name: 'Go to quarterly note',
hotkeys: [{ modifiers: ['Mod', 'Shift', 'Alt'], key: 'q' }],
callback: async () => {
await this.openPeriodNote('quarter', moment());
},
});
this.addCommand({
id: 'waypoint-go-to-yearly',
name: 'Go to yearly note',
hotkeys: [{ modifiers: ['Mod', 'Shift', 'Alt'], key: 'y' }],
callback: async () => {
await this.openPeriodNote('year', moment());
},
});
// ── Period navigation commands (no hotkeys) ──
const directions = ['next', 'prev'] as const;
const periodLabels = ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'] as const;
const directionLabels: Record<string, string> = { next: 'Next', prev: 'Previous' };
for (const period of periodLabels) {
for (const dir of directions) {
const id = `waypoint-go-to-${dir}-${period}`;
const name = `${directionLabels[dir]} ${period} note`;
this.addCommand({
id,
name,
callback: async () => {
await this.navigatePeriodNote(dir);
},
});
}
}
// ── Events ──
this.registerEvent(
@@ -94,23 +160,31 @@ export default class WaypointPlugin extends Plugin {
);
this.registerEvent(
this.app.vault.on('create', () => this.onVaultChange()),
this.app.vault.on('create', (file: TAbstractFile) => this.onVaultCreate(file)),
);
this.registerEvent(
this.app.vault.on('delete', () => this.onVaultChange()),
this.app.vault.on('delete', (file: TAbstractFile) => this.onVaultDelete(file)),
);
this.registerEvent(
this.app.vault.on('rename', (file, oldPath) => this.onRename(file, oldPath)),
);
this.registerEvent(
this.app.vault.on('modify', (file: TAbstractFile) => this.onFileModify(file)),
);
// Auto-open view on first load
this.app.workspace.onLayoutReady(() => {
this.buildMarkdownIndex();
const leaves = this.app.workspace.getLeavesOfType(WAYPOINT_VIEW_TYPE);
if (leaves.length === 0) {
const leaf = this.app.workspace.getLeftLeaf(false);
if (leaf) {
leaf.setViewState({ type: WAYPOINT_VIEW_TYPE });
}
} else {
// A restored view may have rendered before the index existed.
this.broadcastRedraw();
}
});
@@ -131,73 +205,258 @@ export default class WaypointPlugin extends Plugin {
this.app.workspace.detachLeavesOfType(WAYPOINT_VIEW_TYPE);
}
// ── Settings ──
// ── Settings & persistence ──
async loadSettings(): Promise<void> {
const saved = await this.loadData() as Record<string, unknown> | null;
/**
* Merge persisted settings over the defaults. Nested objects are merged
* individually so existing configs keep their values while picking up
* fields added in newer versions.
*/
private applySettings(saved: Record<string, unknown> | null): void {
const s = (saved?.settings || {}) as Partial<WaypointSettings>;
this.settings = Object.assign({}, DEFAULT_SETTINGS, s);
this.settings.recentFiles = Object.assign({}, DEFAULT_SETTINGS.recentFiles, s.recentFiles || {});
this.settings.calendar = Object.assign({}, DEFAULT_SETTINGS.calendar, s.calendar || {});
this.settings.display = Object.assign({}, DEFAULT_SETTINGS.display, s.display || {});
for (const key of PERIOD_SETTING_KEYS) {
this.settings[key] = Object.assign({}, DEFAULT_SETTINGS[key], s[key] || {});
}
}
private applyWaypointData(saved: Record<string, unknown> | null): void {
const d = (saved?.waypointData || {}) as Partial<WaypointData>;
this.waypointData = {
bookmarks: Array.isArray(d.bookmarks) ? d.bookmarks : [],
recentFiles: Array.isArray(d.recentFiles) ? d.recentFiles : [],
};
// Load persisted recent files
this.recentFiles = this.waypointData.recentFiles;
// Apply current limit (in case maxItems was reduced since last save)
if (this.recentFiles.length > this.settings.recentFiles.maxItems) {
this.recentFiles = this.recentFiles.slice(0, this.settings.recentFiles.maxItems);
this.waypointData.recentFiles = this.recentFiles;
}
}
/**
* Write both top-level keys of data.json from memory.
*
* Saves never re-read from disk: `settings` and `waypointData` are the only
* keys and both are held in memory, so a read-modify-write would only give
* two concurrent saves a stale snapshot of the other's key. Writes are
* chained onto `savePromise` so they cannot interleave.
*/
private persistAll(): Promise<void> {
const write = this.savePromise.then(() => {
// Sync recentFiles into waypointData before writing
this.waypointData.recentFiles = this.recentFiles;
return this.saveData({
settings: this.settings,
waypointData: this.waypointData,
});
});
// Keep the queue usable after a failed write without leaving an
// unhandled rejection behind; callers still see `write` reject.
this.savePromise = write.catch(() => undefined);
return write;
}
async saveSettings(): Promise<void> {
const all = (await this.loadData()) as Record<string, unknown> || {};
all.settings = this.settings;
await this.saveData(all);
}
async loadWaypointData(): Promise<void> {
const saved = await this.loadData() as Record<string, unknown> | null;
const d = (saved?.waypointData || {}) as Partial<WaypointData>;
this.waypointData = Object.assign({}, DEFAULT_DATA, d);
await this.persistAll();
}
async saveWaypointData(): Promise<void> {
const all = (await this.loadData()) as Record<string, unknown> || {};
all.waypointData = this.waypointData;
await this.saveData(all);
await this.persistAll();
}
/**
* Trim recent files to the current maxItems limit and persist.
* Called when the maxItems setting changes.
*/
enforceRecentFilesLimit(): void {
if (this.recentFiles.length > this.settings.recentFiles.maxItems) {
this.recentFiles = this.recentFiles.slice(0, this.settings.recentFiles.maxItems);
this.persistRecentFiles();
}
}
/**
* Persist recent files to disk (debounced to avoid excessive writes on rapid opens).
*/
persistRecentFiles(): void {
this.waypointData.recentFiles = this.recentFiles;
window.clearTimeout(this.recentFilesSaveTimer);
this.recentFilesSaveTimer = window.setTimeout(() => {
this.saveWaypointData();
}, 300);
}
// ── Recent Files ──
private onFileOpen(file: TFile): void {
if (this.settings.recentFiles.updateOn === 'file-edit') {
// We'll handle this via quick-preview in a future refinement
return;
if (this.settings.recentFiles.updateOn !== 'file-open') return;
this.addToRecentFiles(file);
}
private onFileModify(file: TAbstractFile): void {
if (this.settings.recentFiles.updateOn !== 'file-edit') return;
if (!(file instanceof TFile)) return;
// Already the most recent entry: nothing to reorder or redraw.
if (this.recentFiles.length > 0 && this.recentFiles[0].path === file.path) return;
this.addToRecentFiles(file);
}
addToRecentFiles(file: TFile): void {
if (this.isOmittedFromRecentFiles(file)) return;
this.recentFiles = this.recentFiles.filter(f => f.path !== file.path);
this.recentFiles.unshift({ path: file.path, basename: file.basename });
// Prune
// Apply max items limit
if (this.recentFiles.length > this.settings.recentFiles.maxItems) {
this.recentFiles = this.recentFiles.slice(0, this.settings.recentFiles.maxItems);
}
this.persistRecentFiles();
this.broadcastRedraw();
}
/**
* Apply the omittedPaths / omittedTags filters. Each entry is treated as a
* regex; an invalid pattern is skipped rather than throwing.
*/
private isOmittedFromRecentFiles(file: TFile): boolean {
for (const pattern of this.settings.recentFiles.omittedPaths) {
try {
if (new RegExp(pattern).test(file.path)) return true;
} catch {
// Invalid regex, skip
}
}
const omittedTags = this.settings.recentFiles.omittedTags;
if (omittedTags.length === 0) return false;
const cache = this.app.metadataCache.getFileCache(file);
const tags = (cache ? getAllTags(cache) : null) || [];
if (tags.length === 0) return false;
const bareTags = tags.map(tag => tag.replace(/^#/, ''));
for (const pattern of omittedTags) {
try {
const regex = new RegExp(pattern);
if (bareTags.some(tag => regex.test(tag))) return true;
} catch {
// Invalid regex, skip
}
}
return false;
}
/**
* Obsidian fires `rename` for folders too, so a stored path may need
* remapping either because it is the renamed item or because it sits
* inside a renamed folder.
*/
private onRename(file: TAbstractFile, oldPath: string): void {
const entry = this.recentFiles.find(f => f.path === oldPath);
if (entry) {
entry.path = file.path;
entry.basename = (file as TFile).basename || file.name.replace(/\.[^/.]+$/, '');
const indexChanged = this.syncIndexForRename(file, oldPath);
let dataChanged = false;
for (const entry of this.recentFiles) {
const remapped = remapRenamedPath(entry.path, oldPath, file.path);
if (remapped === null) continue;
entry.path = remapped;
entry.basename = basenameFromPath(remapped);
dataChanged = true;
}
const remapBookmarks = (items: BookmarkItem[]): void => {
for (const item of items) {
if (item.filePath) {
const remapped = remapRenamedPath(item.filePath, oldPath, file.path);
if (remapped !== null) {
item.filePath = remapped;
dataChanged = true;
}
}
if (item.children) remapBookmarks(item.children);
}
};
remapBookmarks(this.waypointData.bookmarks);
if (dataChanged) {
this.waypointData.recentFiles = this.recentFiles;
this.persistAll();
}
if (dataChanged || indexChanged) this.broadcastRedraw();
}
private onVaultCreate(file: TAbstractFile): void {
if (file instanceof TFile && file.extension === 'md') {
this.markdownBasenames.add(file.basename);
}
this.broadcastRedraw();
}
// Update bookmark file paths
this.updateBookmarkPath(oldPath, file.path);
private onVaultDelete(file: TAbstractFile): void {
if (file instanceof TFile && file.extension === 'md') {
this.removeFromMarkdownIndex(file.basename, file.path);
}
this.broadcastRedraw();
}
private onVaultChange(): void {
this.broadcastRedraw();
// ── Markdown basename index (calendar note indicators) ──
private buildMarkdownIndex(): void {
this.markdownBasenames.clear();
for (const file of this.app.vault.getMarkdownFiles()) {
this.markdownBasenames.add(file.basename);
}
}
/**
* Drop a basename from the index, unless another markdown file still
* carries it. `path` is excluded from that check because the vault may not
* have dropped the file yet when the event fires.
*/
private removeFromMarkdownIndex(basename: string, path: string): boolean {
if (!this.markdownBasenames.has(basename)) return false;
const stillExists = this.app.vault.getMarkdownFiles()
.some(f => f.basename === basename && f.path !== path);
if (stillExists) return false;
this.markdownBasenames.delete(basename);
return true;
}
/** Returns true when the index changed. Folder renames never change basenames. */
private syncIndexForRename(file: TAbstractFile, oldPath: string): boolean {
if (!(file instanceof TFile)) return false;
let changed = false;
const oldBasename = basenameFromPath(oldPath);
if (oldPath.toLowerCase().endsWith('.md') && (oldBasename !== file.basename || file.extension !== 'md')) {
changed = this.removeFromMarkdownIndex(oldBasename, oldPath);
}
if (file.extension === 'md' && !this.markdownBasenames.has(file.basename)) {
this.markdownBasenames.add(file.basename);
changed = true;
}
return changed;
}
/**
* Whether any markdown file in the vault is named exactly `dateStr`.
* Synchronous and O(1) — the calendar calls this once per day cell.
*/
hasNoteForDate(dateStr: string): boolean {
return this.markdownBasenames.has(dateStr);
}
// ── Bookmarks ──
addBookmark(filePath: string, label: string, type: 'file' | 'group' | 'separator' | 'spacer' = 'file', icon = 'file'): BookmarkItem {
addBookmark(filePath: string, label: string, type: 'file' | 'group' | 'separator' | 'spacer' = 'file', icon = ''): BookmarkItem {
const id = `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const item: BookmarkItem = {
id,
@@ -251,72 +510,183 @@ export default class WaypointPlugin extends Plugin {
}
}
private updateBookmarkPath(oldPath: string, newPath: string): void {
const updateRecursive = (items: BookmarkItem[]) => {
for (const item of items) {
if (item.filePath === oldPath) {
item.filePath = newPath;
}
if (item.children) updateRecursive(item.children);
}
};
updateRecursive(this.waypointData.bookmarks);
}
// ── Period note creation/opening ──
async openPeriodNote(period: 'day' | 'week' | 'month' | 'quarter' | 'year', date: moment.Moment): Promise<void> {
// Map period to settings and date format
type PeriodConfig = {
settings: PeriodNoteSettings;
label: string;
};
const configs: Record<string, PeriodConfig> = {
day: { settings: this.settings.daily, label: 'Daily' },
week: { settings: this.settings.weekly, label: 'Weekly' },
month: { settings: this.settings.monthly, label: 'Monthly' },
quarter: { settings: this.settings.quarterly, label: 'Quarterly' },
year: { settings: this.settings.yearly, label: 'Yearly' },
};
const config = configs[period];
if (!config) return;
const { settings: periodSettings } = config;
/**
* Open (creating if needed) the period note for `date`.
* Opens in `leaf` when given, otherwise in the active leaf.
*/
async openPeriodNote(period: PeriodKey, date: moment.Moment, leaf?: WorkspaceLeaf): Promise<void> {
const config = PERIOD_CONFIGS[period];
const periodSettings = this.settings[config.key];
const filename = date.format(periodSettings.nameFormat) + '.md';
const fullPath = periodSettings.folder
? `${periodSettings.folder}/${filename}`
: filename;
// Check if exists
let file = this.app.vault.getFileByPath(fullPath);
if (!file) {
// Create it from template
try {
// Try to find template file
const templatePath = periodSettings.templateFile + '.md';
const templateFile = this.app.vault.getFileByPath(templatePath);
if (templateFile) {
const templateContent = await this.app.vault.read(templateFile);
file = await this.app.vault.create(fullPath, templateContent);
} else {
// Fallback: create with minimal frontmatter
const content = `---\ntype: ${periodSettings.typeProperty}\ndate: ${date.format('YYYY-MM-DD')}\n---\n\n`;
file = await this.app.vault.create(fullPath, content);
file = await this.createPeriodNote(fullPath, periodSettings, date, config.label);
if (!file) return;
}
} catch (err) {
new Notice(`Failed to create ${config.label.toLowerCase()} note: ${err.message}`);
const target = leaf || this.app.workspace.getLeaf(false);
await target.openFile(file);
}
/**
* Create a period note from its template, or from minimal frontmatter.
*
* Every failure path names the offending path and the reason: the usual
* cause is a configured folder that does not exist yet, which `vault.create`
* refuses outright rather than creating.
*/
private async createPeriodNote(
fullPath: string,
periodSettings: PeriodNoteSettings,
date: moment.Moment,
label: string,
): Promise<TFile | null> {
const noun = label.toLowerCase();
const slash = fullPath.lastIndexOf('/');
const folder = slash < 0 ? '' : fullPath.slice(0, slash);
try {
await this.ensureFolderExists(folder);
} catch (err: unknown) {
new Notice(
`Waypoint: could not create the folder "${folder}" for the ${noun} note.\n`
+ `${describeError(err)}\n`
+ 'Check Settings → Waypoint Sidebar → Periodic Notes.',
DIAGNOSTIC_NOTICE_MS,
);
return null;
}
const configuredTemplate = periodSettings.templateFile;
const templateFile = this.resolveTemplateFile(configuredTemplate);
let content: string;
if (templateFile) {
try {
content = await this.app.vault.read(templateFile);
} catch (err: unknown) {
new Notice(
`Waypoint: could not read the template "${templateFile.path}" for the ${noun} note.\n`
+ describeError(err),
DIAGNOSTIC_NOTICE_MS,
);
return null;
}
} else {
content = `---\ntype: ${periodSettings.typeProperty}\ndate: ${date.format('YYYY-MM-DD')}\n---\n\n`;
}
let file: TFile;
try {
file = await this.app.vault.create(fullPath, content);
} catch (err: unknown) {
new Notice(
`Waypoint: could not create the ${noun} note at "${fullPath}".\n${describeError(err)}`,
DIAGNOSTIC_NOTICE_MS,
);
return null;
}
// A configured-but-missing template otherwise fails silently: the note
// just appears with the fallback frontmatter and no explanation.
if (configuredTemplate && !templateFile) {
new Notice(
`Created ${noun} note: ${file.basename}\n`
+ `Template "${configuredTemplate}" was not found, so a basic note was created instead.`,
DIAGNOSTIC_NOTICE_MS,
);
} else {
new Notice(`Created ${noun} note: ${file.basename}`);
}
return file;
}
/**
* Create `folder` and any missing ancestors.
* `vault.create` throws when the parent folder is absent, so this runs first.
*/
private async ensureFolderExists(folder: string): Promise<void> {
if (!folder) return;
if (this.app.vault.getAbstractFileByPath(folder) instanceof TFolder) return;
let path = '';
for (const segment of folder.split('/')) {
if (!segment) continue;
path = path ? `${path}/${segment}` : segment;
if (this.app.vault.getAbstractFileByPath(path) instanceof TFolder) continue;
try {
await this.app.vault.createFolder(path);
} catch (err: unknown) {
// Someone else may have created it between the check and the call.
if (!(this.app.vault.getAbstractFileByPath(path) instanceof TFolder)) throw err;
}
}
}
/** The setting may or may not already carry the .md extension. */
private resolveTemplateFile(templateFile: string): TFile | null {
if (!templateFile) return null;
const path = templateFile.toLowerCase().endsWith('.md') ? templateFile : `${templateFile}.md`;
return this.app.vault.getFileByPath(path);
}
// ── Period note navigation (next/prev from current file) ──
/**
* Detect the period type from a filename's basename using the configured
* name formats, so custom formats keep working. Parsing is strict, which
* stops a loose format (e.g. YYYY) from swallowing a longer basename.
* Returns the period key and the parsed moment, or null if not a period note.
*/
private detectPeriodType(basename: string): { period: PeriodKey; date: moment.Moment } | null {
for (const period of PERIOD_DETECTION_ORDER) {
const format = this.settings[PERIOD_CONFIGS[period].key].nameFormat;
if (!format) continue;
const date = moment(basename, format, true);
if (date.isValid()) return { period, date };
}
return null;
}
/**
* Navigate to the next or previous period note based on the currently active file.
*/
async navigatePeriodNote(direction: 'next' | 'prev'): Promise<void> {
const file = this.app.workspace.getActiveFile();
if (!file) {
new Notice('No active file');
return;
}
new Notice(`Created ${config.label.toLowerCase()} note: ${filename}`);
const detected = this.detectPeriodType(file.basename);
if (!detected) {
const formats = PERIOD_DETECTION_ORDER
.map(p => `${PERIOD_CONFIGS[p].label.toLowerCase()} "${this.settings[PERIOD_CONFIGS[p].key].nameFormat}"`)
.join(', ');
new Notice(
`Waypoint: "${file.basename}" does not match any configured periodic note format.\n`
+ `Expected one of: ${formats}.`,
DIAGNOSTIC_NOTICE_MS,
);
return;
}
if (file) {
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
}
const { period, date } = detected;
const amount = direction === 'next' ? 1 : -1;
// Map period to moment duration unit
const newDate = period === 'quarter'
? date.clone().add(amount * 3, 'months')
: date.clone().add(amount, `${period}s` as moment.unitOfTime.DurationConstructor);
await this.openPeriodNote(period, newDate);
}
// ── Redraw ──
@@ -333,20 +703,33 @@ export default class WaypointPlugin extends Plugin {
}
}
}
/**
* Get all markdown files that exist on a specific date.
* Used to show note indicators on the calendar.
*/
async getNotesForDate(dateStr: string): Promise<TFile[]> {
return this.app.vault.getFiles().filter(f =>
f.extension === 'md' && f.basename === dateStr,
);
}
async hasNoteForDate(dateStr: string): Promise<boolean> {
return this.app.vault.getFiles().some(f =>
f.extension === 'md' && f.basename === dateStr,
);
}
}
// ── Module helpers ──
type PeriodSettingKey = 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly';
const PERIOD_SETTING_KEYS: PeriodSettingKey[] = ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'];
const PERIOD_CONFIGS: Record<PeriodKey, { key: PeriodSettingKey; label: string }> = {
day: { key: 'daily', label: 'Daily' },
week: { key: 'weekly', label: 'Weekly' },
month: { key: 'monthly', label: 'Monthly' },
quarter: { key: 'quarterly', label: 'Quarterly' },
year: { key: 'yearly', label: 'Yearly' },
};
/** Checked day → year so a loose format (YYYY) cannot claim a longer basename. */
const PERIOD_DETECTION_ORDER: PeriodKey[] = ['day', 'week', 'month', 'quarter', 'year'];
function basenameFromPath(path: string): string {
const name = path.slice(path.lastIndexOf('/') + 1);
return name.replace(/\.[^/.]+$/, '');
}
/** Notices that carry a diagnosis need longer on screen than the default. */
const DIAGNOSTIC_NOTICE_MS = 10000;
function describeError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+1
View File
@@ -13,4 +13,5 @@ export interface BookmarkItem {
export interface WaypointData {
bookmarks: BookmarkItem[];
recentFiles: { path: string; basename: string }[];
}
+254 -64
View File
@@ -1,12 +1,14 @@
import { Setting, PluginSettingTab, App, Plugin } from 'obsidian';
import { Setting, PluginSettingTab, App, setIcon } from 'obsidian';
import type WaypointPlugin from 'src/main';
import { WaypointSettings, DEFAULT_SETTINGS, PeriodNoteSettings } from 'src/settings';
export class WaypointSettingTab extends PluginSettingTab {
private plugin: Plugin;
private plugin: WaypointPlugin;
private settings: WaypointSettings;
private onSettingsChange: () => void;
private activeTab: 'calendar' | 'periodic' | 'recent' | 'display' | 'about' = 'calendar';
constructor(app: App, plugin: Plugin, settings: WaypointSettings, onSettingsChange: () => void) {
constructor(app: App, plugin: WaypointPlugin, settings: WaypointSettings, onSettingsChange: () => void) {
super(app, plugin);
this.plugin = plugin;
this.settings = settings;
@@ -17,10 +19,54 @@ export class WaypointSettingTab extends PluginSettingTab {
const { containerEl } = this;
containerEl.empty();
// ── Calendar Section ──
new Setting(containerEl).setHeading().setName('Calendar');
// ── Tab bar ──
const tabBar = containerEl.createDiv({ cls: 'waypoint-settings-tabs' });
const tabs = [
{ key: 'calendar' as const, label: 'Calendar' },
{ key: 'periodic' as const, label: 'Periodic Notes' },
{ key: 'recent' as const, label: 'Recent Files' },
{ key: 'display' as const, label: 'Display' },
{ key: 'about' as const, label: 'About' },
];
new Setting(containerEl)
for (const tab of tabs) {
const btn = tabBar.createEl('button', {
cls: `waypoint-settings-tab${this.activeTab === tab.key ? ' is-active' : ''}`,
text: tab.label,
});
btn.addEventListener('click', () => {
this.activeTab = tab.key;
this.display();
});
}
const tabContent = containerEl.createDiv({ cls: 'waypoint-settings-content' });
switch (this.activeTab) {
case 'calendar':
this.renderCalendarTab(tabContent);
break;
case 'periodic':
this.renderPeriodicTab(tabContent);
break;
case 'recent':
this.renderRecentTab(tabContent);
break;
case 'display':
this.renderDisplayTab(tabContent);
break;
case 'about':
this.renderAboutTab(tabContent);
break;
}
}
// ═══════════════════════════════
// Calendar tab
// ═══════════════════════════════
private renderCalendarTab(container: HTMLElement): void {
new Setting(container)
.setName('First day of week')
.setDesc('Which day the calendar week starts on.')
.addDropdown((dropdown) => {
@@ -34,7 +80,7 @@ export class WaypointSettingTab extends PluginSettingTab {
});
});
new Setting(containerEl)
new Setting(container)
.setName('Show note indicators')
.setDesc('Show a dot on days that have existing notes.')
.addToggle((toggle) => {
@@ -45,20 +91,78 @@ export class WaypointSettingTab extends PluginSettingTab {
this.saveAndRefresh();
});
});
}
// ── Periodic Note Settings ──
new Setting(containerEl).setHeading().setName('Periodic Note Paths');
// ═══════════════════════════════
// Periodic Notes tab
// ═══════════════════════════════
this.addPeriodNoteSettings('Daily', this.settings.daily);
this.addPeriodNoteSettings('Weekly', this.settings.weekly);
this.addPeriodNoteSettings('Monthly', this.settings.monthly);
this.addPeriodNoteSettings('Quarterly', this.settings.quarterly);
this.addPeriodNoteSettings('Yearly', this.settings.yearly);
private renderPeriodicTab(container: HTMLElement): void {
this.addPeriodNoteSettings(container, 'Daily', this.settings.daily);
this.addPeriodNoteSettings(container, 'Weekly', this.settings.weekly);
this.addPeriodNoteSettings(container, 'Monthly', this.settings.monthly);
this.addPeriodNoteSettings(container, 'Quarterly', this.settings.quarterly);
this.addPeriodNoteSettings(container, 'Yearly', this.settings.yearly);
}
// ── Recent Files Section ──
new Setting(containerEl).setHeading().setName('Recent Files');
private addPeriodNoteSettings(container: HTMLElement, label: string, period: PeriodNoteSettings): void {
new Setting(container).setHeading().setName(label);
new Setting(containerEl)
new Setting(container)
.setName('Folder')
.setDesc(`Folder path for ${label.toLowerCase()} notes.`)
.addText((text) => {
text.setPlaceholder('periodic/daily');
text.setValue(period.folder);
text.onChange((value) => {
period.folder = value;
this.saveAndRefresh();
});
});
new Setting(container)
.setName('Name format')
.setDesc(`Date format for ${label.toLowerCase()} note filenames (moment.js format).`)
.addText((text) => {
text.setPlaceholder('yyyy-MM-dd');
text.setValue(period.nameFormat);
text.onChange((value) => {
period.nameFormat = value;
this.saveAndRefresh();
});
});
new Setting(container)
.setName('Template file')
.setDesc(`Path to the template file (without .md extension).`)
.addText((text) => {
text.setPlaceholder('Templates/Daily note');
text.setValue(period.templateFile);
text.onChange((value) => {
period.templateFile = value;
this.saveAndRefresh();
});
});
new Setting(container)
.setName('Type property')
.setDesc(`Value for the 'type' frontmatter property.`)
.addText((text) => {
text.setPlaceholder('daily-note');
text.setValue(period.typeProperty);
text.onChange((value) => {
period.typeProperty = value;
this.saveAndRefresh();
});
});
}
// ═══════════════════════════════
// Recent Files tab
// ═══════════════════════════════
private renderRecentTab(container: HTMLElement): void {
new Setting(container)
.setName('Max items')
.setDesc('Maximum number of recent files to track.')
.addText((text) => {
@@ -74,7 +178,7 @@ export class WaypointSettingTab extends PluginSettingTab {
};
});
new Setting(containerEl)
new Setting(container)
.setName('Update on')
.setDesc('When to add a file to the recent list.')
.addDropdown((dropdown) => {
@@ -90,7 +194,7 @@ export class WaypointSettingTab extends PluginSettingTab {
const pathDesc = new DocumentFragment();
pathDesc.appendText('Regex patterns for paths to exclude. One per line.');
new Setting(containerEl)
new Setting(container)
.setName('Omitted paths')
.setDesc(pathDesc)
.addTextArea((text) => {
@@ -105,7 +209,7 @@ export class WaypointSettingTab extends PluginSettingTab {
const tagDesc = new DocumentFragment();
tagDesc.appendText('Regex patterns for frontmatter tags to exclude. One per line.');
new Setting(containerEl)
new Setting(container)
.setName('Omitted tags')
.setDesc(tagDesc)
.addTextArea((text) => {
@@ -117,62 +221,148 @@ export class WaypointSettingTab extends PluginSettingTab {
this.saveAndRefresh();
};
});
const filterDesc = new DocumentFragment();
filterDesc.appendText('Tags to show as filter pills above the file list. One per line. Leave empty to auto-detect from frontmatter `type` property.');
new Setting(container)
.setName('Filter tags')
.setDesc(filterDesc)
.addTextArea((text) => {
text.inputEl.setAttr('rows', 4);
text.setPlaceholder('meeting\nperson\nproject');
text.setValue(this.settings.recentFiles.filterTags.join('\n'));
text.inputEl.onblur = () => {
this.settings.recentFiles.filterTags = text.getValue().split('\n').filter(t => t.trim());
this.saveAndRefresh();
};
});
}
private addPeriodNoteSettings(label: string, period: PeriodNoteSettings): void {
new Setting(this.containerEl).setHeading().setName(label);
// ═══════════════════════════════
// Display tab
// ═══════════════════════════════
new Setting(this.containerEl)
.setName('Folder')
.setDesc(`Folder path for ${label.toLowerCase()} notes.`)
.addText((text) => {
text.setPlaceholder('periodic/daily');
text.setValue(period.folder);
text.onChange((value) => {
period.folder = value;
private renderDisplayTab(container: HTMLElement): void {
new Setting(container).setHeading().setName('Bookmarks');
this.addSliderSetting(container,
'Row size', 'Height of bookmark items.',
this.settings.display, 'rowSize', 18, 40, 1, 'px',
);
this.addSliderSetting(container,
'Row spacing', 'Gap between bookmark items.',
this.settings.display, 'rowSpacing', 0, 12, 1, 'px',
);
this.addSliderSetting(container,
'Indent size', 'Indent per nesting depth.',
this.settings.display, 'indentSize', 8, 32, 2, 'px',
);
this.addSliderSetting(container,
'Font size', 'Label font size.',
this.settings.display, 'fontSize', 10, 18, 1, 'px',
);
this.addSliderSetting(container,
'Icon size', 'Bookmark icon size.',
this.settings.display, 'iconSize', 12, 24, 1, 'px',
);
new Setting(container).setHeading().setName('Calendar');
this.addSliderSetting(container,
'Cell size', 'Height of calendar day cells.',
this.settings.display, 'calendarCellSize', 20, 48, 2, 'px',
);
new Setting(container)
.addButton((btn) =>
btn
.setButtonText('Reset to defaults')
.onClick(() => {
this.settings.display = { ...DEFAULT_SETTINGS.display };
this.saveAndRefresh();
});
});
this.display();
}),
);
}
new Setting(this.containerEl)
.setName('Name format')
.setDesc(`Date format for ${label.toLowerCase()} note filenames (moment.js format).`)
.addText((text) => {
text.setPlaceholder('yyyy-MM-dd');
text.setValue(period.nameFormat);
text.onChange((value) => {
period.nameFormat = value;
this.saveAndRefresh();
});
});
private addSliderSetting<K extends string>(
container: HTMLElement,
name: string,
desc: string,
obj: Record<K, number>,
key: K,
min: number,
max: number,
step: number,
unit: string,
): void {
const setting = new Setting(container)
.setName(name)
.setDesc(`${desc} (${obj[key]}${unit})`);
new Setting(this.containerEl)
.setName('Template file')
.setDesc(`Path to the template file (without .md extension).`)
.addText((text) => {
text.setPlaceholder('Templates/Daily note');
text.setValue(period.templateFile);
text.onChange((value) => {
period.templateFile = value;
this.saveAndRefresh();
});
});
new Setting(this.containerEl)
.setName('Type property')
.setDesc(`Value for the 'type' frontmatter property.`)
.addText((text) => {
text.setPlaceholder('daily-note');
text.setValue(period.typeProperty);
text.onChange((value) => {
period.typeProperty = value;
setting.addSlider((slider) => {
slider
.setLimits(min, max, step)
.setValue(obj[key])
.setDynamicTooltip()
.onChange((value) => {
obj[key] = value;
setting.setDesc(`${desc} (${value}${unit})`);
this.saveAndRefresh();
});
});
}
private async saveAndRefresh(): Promise<void> {
await this.plugin.saveData(this.settings);
await this.plugin.saveSettings();
this.onSettingsChange();
}
// ═══════════════════════════════
// About tab
// ═══════════════════════════════
private renderAboutTab(container: HTMLElement): void {
const version = this.plugin.manifest.version;
const header = container.createDiv();
header.style.display = 'flex';
header.style.alignItems = 'center';
header.style.gap = '12px';
header.style.marginBottom = '16px';
const icon = header.createDiv();
icon.style.display = 'flex';
icon.style.alignItems = 'center';
icon.style.justifyContent = 'center';
icon.style.width = '48px';
icon.style.height = '48px';
icon.style.borderRadius = '12px';
icon.style.background = 'var(--interactive-accent)';
icon.style.color = 'var(--text-on-accent)';
icon.style.fontSize = '24px';
setIcon(icon, 'compass');
const titleGroup = header.createDiv();
const title = titleGroup.createEl('h2', { text: 'Waypoint Sidebar' });
title.style.margin = '0';
title.style.lineHeight = '1.2';
const subtitle = titleGroup.createDiv({ text: `v${version}` });
subtitle.style.color = 'var(--text-muted)';
subtitle.style.fontSize = 'var(--font-ui-small)';
const desc = container.createDiv();
desc.style.marginBottom = '20px';
desc.style.lineHeight = '1.6';
desc.style.color = 'var(--text-normal)';
desc.innerHTML = [
`<p><strong>Waypoint</strong> is a sidebar plugin that brings three essential panels into one view:</p>`,
`<ul style="padding-left: 20px; margin: 8px 0;">`,
`<li><strong>Calendar</strong> — a monthly grid for your periodic notes (daily, weekly, monthly, quarterly, yearly). Click any day, week, or month to open or create the corresponding note.</li>`,
`<li><strong>Recent Files</strong> — a list of recently opened files with type filtering, drag-and-drop, and right-click actions.</li>`,
`<li><strong>Bookmarks</strong> — custom bookmarks with icons, groups, nesting, and drag-and-drop reordering. Separate from Obsidian's native bookmarks.</li>`,
`</ul>`,
`<p style="color: var(--text-muted); font-size: var(--font-ui-small);">Made by Olivier. Licensed under MIT.</p>`,
].join('\n');
}
}
+20
View File
@@ -17,6 +17,16 @@ export interface RecentFilesSettings {
updateOn: 'file-open' | 'file-edit';
omittedPaths: string[];
omittedTags: string[];
filterTags: string[]; // tags to show as filter pills (empty = auto-detect from frontmatter)
}
export interface DisplaySettings {
rowSize: number; // px, base height of bookmark items (2040)
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)
}
export interface WaypointSettings {
@@ -27,6 +37,7 @@ export interface WaypointSettings {
quarterly: PeriodNoteSettings;
yearly: PeriodNoteSettings;
recentFiles: RecentFilesSettings;
display: DisplaySettings;
}
export const DEFAULT_SETTINGS: WaypointSettings = {
@@ -69,5 +80,14 @@ export const DEFAULT_SETTINGS: WaypointSettings = {
updateOn: 'file-open',
omittedPaths: [],
omittedTags: [],
filterTags: [],
},
display: {
rowSize: 26,
rowSpacing: 2,
indentSize: 16,
fontSize: 13,
iconSize: 16,
calendarCellSize: 32,
},
};
+21
View File
@@ -0,0 +1,21 @@
// ── Path helpers ──
// Pure functions only: no 'obsidian' imports, so this stays unit-testable in plain node.
/**
* Remap a stored vault path after a rename.
*
* Obsidian's `vault.on('rename')` fires for folders as well as files, so a
* stored path can be affected either because it *is* the renamed item or
* because it lives inside a renamed folder.
*
* The nested check requires a `/` boundary, so renaming `notes/foo` leaves
* `notes/foobar.md` untouched.
*
* @returns the updated path, or `null` when `path` is unaffected.
*/
export function remapRenamedPath(path: string, oldPath: string, newPath: string): string | null {
if (!path || !oldPath) return null;
if (path === oldPath) return newPath;
if (path.startsWith(oldPath + '/')) return newPath + path.slice(oldPath.length);
return null;
}
+515 -340
View File
File diff suppressed because it is too large Load Diff
+141 -13
View File
@@ -27,6 +27,31 @@
margin-bottom: 4px;
padding-bottom: 4px;
border-bottom: 1px solid var(--background-modifier-border);
display: flex;
align-items: center;
gap: 6px;
}
.waypoint-header-more {
margin-left: auto;
background: none;
border: none;
cursor: var(--cursor-link, pointer);
color: var(--text-faint);
padding: 2px 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color 80ms, background 80ms;
box-shadow: none;
opacity: 0.6;
}
.waypoint-header-more:hover {
color: var(--text-normal);
background: none;
opacity: 1;
}
/* ── Calendar ── */
@@ -39,7 +64,7 @@
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
margin-bottom: 10px;
}
.waypoint-calendar-breadcrumb {
@@ -48,12 +73,12 @@
gap: 6px;
font-size: var(--font-ui-medium);
font-weight: var(--font-semibold);
padding: 2px 0;
padding: 4px 0;
}
.waypoint-calendar-breadcrumb .waypoint-clickable {
color: var(--text-muted);
cursor: var(--cursor);
cursor: var(--cursor-link, pointer);
padding: 1px 4px;
border-radius: 4px;
}
@@ -77,7 +102,7 @@
.waypoint-calendar-today-group button {
background: none;
border: none;
cursor: var(--cursor);
cursor: var(--cursor-link, pointer);
padding: 2px 6px;
border-radius: 4px;
color: var(--text-muted);
@@ -102,8 +127,9 @@
.waypoint-calendar th,
.waypoint-calendar td {
text-align: center;
padding: 2px 1px;
padding: 4px 2px;
font-size: var(--font-ui-small);
line-height: 1.6;
}
.waypoint-calendar th {
@@ -116,7 +142,7 @@
font-size: calc(var(--font-ui-small) * 0.75);
color: var(--text-faint);
font-weight: var(--font-light);
cursor: var(--cursor);
cursor: var(--cursor-link, pointer);
padding: 2px 0;
border-radius: 4px;
}
@@ -127,11 +153,13 @@
}
.waypoint-calendar .waypoint-day {
cursor: var(--cursor);
padding: 3px 0;
cursor: var(--cursor-link, pointer);
min-height: var(--wp-cal-cell-size, 32px);
padding: 2px 0;
border-radius: 6px;
position: relative;
width: 100%;
vertical-align: middle;
}
.waypoint-calendar .waypoint-day:hover {
@@ -161,8 +189,37 @@
.waypoint-calendar .waypoint-day.today.has-note::after {
background-color: var(--text-accent);
}
/* ── Recent Files ── */
.waypoint-recent-filter {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.waypoint-recent-pill {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
background: var(--background-modifier-hover);
padding: 1px 6px;
border-radius: 10px;
cursor: var(--cursor-link, pointer);
transition: color 80ms, background 80ms;
user-select: none;
}
.waypoint-recent-pill:hover {
color: var(--text-normal);
background: var(--background-modifier-active-hover);
}
.waypoint-recent-pill.is-active {
color: var(--text-on-accent);
background: var(--interactive-accent);
}
.waypoint-recent-files .nav-file {
padding: 2px 0;
}
@@ -179,7 +236,7 @@
top: 50%;
transform: translateY(-50%);
opacity: 0;
cursor: var(--cursor);
cursor: var(--cursor-link, pointer);
color: var(--text-faint);
display: flex;
align-items: center;
@@ -202,10 +259,11 @@
.waypoint-favorites .waypoint-bookmark-item {
display: flex;
align-items: center;
gap: 4px;
gap: var(--wp-row-spacing, 4px);
padding: 2px 8px 2px 4px;
font-size: var(--font-ui-small);
cursor: var(--cursor);
font-size: var(--wp-font-size, 13px);
min-height: var(--wp-row-size, 26px);
cursor: var(--cursor-link, pointer);
border-radius: 4px;
user-select: none;
position: relative;
@@ -219,6 +277,8 @@
display: flex;
align-items: center;
flex-shrink: 0;
width: var(--wp-icon-size, 16px);
height: var(--wp-icon-size, 16px);
}
.waypoint-favorites .waypoint-bookmark-item .waypoint-bm-label {
@@ -243,13 +303,34 @@
}
.waypoint-favorites .waypoint-bookmark-children {
padding-left: 16px;
padding-left: var(--wp-indent-size, 16px);
}
.waypoint-favorites .waypoint-bookmark-children.collapsed {
display: none;
}
[data-type="waypoint-view"] button.waypoint-header-more,
button.waypoint-calendar-nav-btn,
button.waypoint-calendar-today-btn {
background-color: transparent;
box-shadow: none;
}
.waypoint-calendar-breadcrumb {
gap: 4px;
.waypoint-clickable {
padding: 0px;
&:hover {
background-color: transparent;
}
}
}
/* ── Drag-and-drop ── */
.waypoint-bm-dragging {
@@ -265,6 +346,11 @@
border-bottom: 3px solid var(--text-accent) !important;
}
.waypoint-favorites .waypoint-bm-drop-into {
background-color: var(--background-modifier-active-hover) !important;
border-radius: 4px;
}
/* ── Separator & Spacer ── */
.waypoint-favorites .waypoint-bookmark-item.waypoint-bookmark-separator {
@@ -292,3 +378,45 @@
cursor: grab;
background: transparent;
}
/* ── Settings Tabs ── */
.waypoint-settings-tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 1px solid var(--background-modifier-border);
padding-bottom: 8px;
}
.waypoint-settings-tab {
background: none;
border: none;
cursor: var(--cursor-link, pointer);
color: var(--text-muted);
padding: 6px 14px;
border-radius: 6px 6px 0 0;
font-size: var(--font-ui-small);
font-weight: var(--font-medium);
transition: color 100ms, background 100ms;
}
.waypoint-settings-tab:hover {
color: var(--text-normal);
background: var(--background-modifier-hover);
}
.waypoint-settings-tab.is-active {
color: var(--text-accent);
background: var(--background-modifier-active-hover);
border-bottom: 2px solid var(--text-accent);
margin-bottom: -9px;
}
/* ── Icon picker ── */
/* Sits left of Cancel/Save so clearing reads as a separate, secondary action. */
.waypoint-icon-clear {
margin-right: auto;
cursor: var(--cursor-link, pointer);
}
+13 -5
View File
@@ -1,5 +1,13 @@
- [ ] when user middle clicks on a bookmark, it should open in a new tab
- [ ] the sidebar should never scroll. the content of recent files should wrap after 10 files or so (no sidebar visible)
- [ ] it should be possible to add a separator line and/or an empty space
- [ ] by default, the bookmark should have no icon
- [ ]
- [x] bookmarks
- [x] when user middle clicks on a bookmark, it should open in a new tab
- [x] in the bookmarks, i want to have notes that are parents to other notes
- [x] by default, the bookmark should have no icon
- [x] the way to search for icons should be improved. on the lucide icon website, the search works by more than only the icon's title. i want to be able to load all the icon (not by default though)
- [x] add a right click menu on the recent files to make them bookmarks
- [x] improve the drag and drop to have identations and groups
- [x] calendar
- [x] middle click on a date/week/etc. should open it in a new tab
- [x] i want to improve the look of the calendar. more space, a bit more height
- [x] when i hover over something that's clickable, I should see the correct cursor
- [x] recent files
- [x] I want to be able to filter the recent files. i want to have a little tag, that when i selectt, wsill show only recents notes where type == meeting, type == person
+2 -1
View File
@@ -15,7 +15,8 @@
"DOM",
"ES5",
"ES6",
"ES7"
"ES7",
"ES2017"
],
"paths": {
"src/*": ["./src/*"]