20 Commits

Author SHA1 Message Date
olivier e8ba4cd2ad release v1.7.0: redesigned calendar with coloured system indicators 2026-09-08 10:18:43 -04:00
olivier 5a9b7bfff8 docs: describe calendar indicator defaults and autocomplete 2026-09-07 21:53:03 -04:00
olivier 72a61e49ee fix: default calendar to system indicators
Use coloured system markers by default, preserve configured colours through settings migration, expose all template fields through native vault-path autocomplete, and remove the redundant Calendar heading.
2026-09-07 21:52:34 -04:00
olivier 069456799e feat: add coloured calendar system indicators
Add a selectable single-dot or per-system indicator mode. Date systems now carry configurable colours; results are cached per displayed month and invalidated when paths/settings change. Refine calendar hierarchy, spacing, hover, and today states.
2026-09-07 21:43:21 -04:00
olivier 12209155dc release v1.6.0: add calendar date systems 2026-09-07 21:34:32 -04:00
olivier e027ac4a9e fix: let Templater own date-system templates
Leave the default Journal and Meeting systems without a raw Waypoint template so Templater folder triggers own interactive rendering. Their fallback frontmatter now matches the journal and meeting types. Document the required folder-trigger setup and selected-date filename contract.
2026-09-07 21:33:03 -04:00
olivier 26a1a481ac feat: add date systems menu to calendar days
Right-clicking a calendar day now lists every configured date-prefixed note system. Journal notes are singletons; {title} formats support multiple notes per date, such as meetings.\n\nAdds default Journal and Meeting systems, configurable settings UI, safe filename/title handling, one shared dated-note creation path, and a compiled menu smoke test fixture in the test vault.
2026-09-07 21:07:18 -04:00
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
16 changed files with 1784 additions and 582 deletions
+203 -65
View File
@@ -9,19 +9,21 @@
```
src/
├── main.ts Plugin entry — lifecycle, commands, events, data persistence
├── settings.ts Interface definitions + DEFAULT_SETTINGS constant
├── settings-tab.ts PluginSettingTab UI (calendar, periodic paths, recent files)
├── settings.ts Interface definitions + DEFAULT_SETTINGS / DEFAULT_DATE_SYSTEM constants
├── settings-tab.ts PluginSettingTab UI calendar, periodic, date systems, recent, display, about
├── models/
│ └── bookmark.ts BookmarkItem + WaypointData interfaces
├── utils/
── date-utils.ts Moment.js helpers: period formatting, month grid, navigation
── date-utils.ts Moment.js helpers: period formatting, month grid, navigation
│ ├── date-systems.ts Pure date-system filename helpers (no `obsidian` import, unit-testable)
│ └── path-utils.ts Pure path helper: rename remapping (no `obsidian` import, unit-testable)
└── views/
└── waypoint-view.ts ItemView subclass — full sidebar rendering + interaction
```
**Build:** esbuild bundles `src/main.ts``main.js`. TypeScript strict, ESNext target.
**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). Recent files are in-memory only (lost on vault close).
**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.
---
@@ -29,11 +31,11 @@ src/
### `onload()`
1. **Load data**`loadSettings()` then `loadWaypointData()`, both merge saved partials over defaults via `Object.assign({}, DEFAULT, partial)`.
1. **Load data**reads `data.json` exactly once, then `applySettings(saved)` and `applyWaypointData(saved)` merge its partial data over defaults.
2. **Register view**`WAYPOINT_VIEW_TYPE = "waypoint-view"` maps to `WaypointView` factory `(leaf) => new WaypointView(leaf, this)`.
3. **Settings tab**`WaypointSettingTab` receives the live `settings` object + `() => this.redrawAll()` callback for live preview.
4. **Register commands** (see Commands section below).
5. **Register events**`file-open`, `vault:create`, `vault:delete`, `vault:rename`.
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).
@@ -49,13 +51,15 @@ Detaches all leaves of `WAYPOINT_VIEW_TYPE`.
```typescript
interface WaypointSettings {
calendar: CalendarSettings; // firstDayOfWeek (0=Sun,1=Mon), showNoteIndicators
calendar: CalendarSettings; // week start, indicator visibility/style, daily indicator colour
daily: PeriodNoteSettings;
weekly: PeriodNoteSettings;
monthly: PeriodNoteSettings;
quarterly: PeriodNoteSettings;
yearly: PeriodNoteSettings;
recentFiles: RecentFilesSettings; // maxItems, updateOn, omittedPaths[], omittedTags[]
dateSystems: DateSystemSettings[]; // day-scoped systems beyond the daily note, in menu order
recentFiles: RecentFilesSettings; // maxItems, updateOn, omittedPaths[], omittedTags[], filterTags[]
display: DisplaySettings; // px sizing for bookmark rows, fonts, icons, calendar cells
}
interface PeriodNoteSettings {
@@ -66,18 +70,32 @@ interface PeriodNoteSettings {
}
interface CalendarSettings {
firstDayOfWeek: number; // 0 = Sunday, 1 = Monday
showNoteIndicators: boolean; // dot on days with existing .md files
firstDayOfWeek: number; // 0 = Sunday, 1 = Monday
showNoteIndicators: boolean; // show or hide all calendar markers
indicatorMode: 'any' | 'systems'; // defaults to systems; one neutral dot or one per date system
dailyIndicatorColor: string; // daily's colour in systems mode
}
interface RecentFilesSettings {
maxItems: number; // default 50
updateOn: 'file-open' | 'file-edit'; // trigger mode
omittedPaths: string[]; // regex patterns (one per line)
omittedTags: string[]; // regex patterns for frontmatter tags
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)
}
```
`dateSystems` holds the user's configured date systems in menu order — see the Date Systems section for the `DateSystemSettings` shape and the two systems shipped by default.
### BookmarkItem (recursive tree)
```typescript
@@ -94,10 +112,11 @@ interface BookmarkItem {
interface WaypointData {
bookmarks: BookmarkItem[];
recentFiles: { path: string; basename: string }[];
}
```
The bookmark tree is stored flat in `data.json` under `waypointData.bookmarks` but rendered recursively. Groups contain children; files/separators/spacers are leaf nodes.
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).
---
@@ -121,15 +140,9 @@ All call `openPeriodNote(period, moment())` — opens or creates the current per
These call `navigatePeriodNote(direction)` which:
1. Gets the active file's basename.
2. Calls `detectPeriodType(basename)`regex matching:
- `^\d{4}-\d{2}-\d{2}$` → daily
- `^\d{4}-W\d{2}$` → weekly
- `^\d{4}-\d{2}$` → monthly
- `^\d{4}-Q[1-4]$` → quarterly
- `^\d{4}$` → yearly
3. Parses date via `moment(basename, format)`.
4. Adds ±1 period (quarters add ±3 months).
5. Calls `openPeriodNote` for the new date.
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
@@ -140,18 +153,131 @@ These call `navigatePeriodNote(direction)` which:
---
## Date Systems
A **date system** is a folder of notes whose filenames begin with a date. Daily notes, a journal and meeting notes are all the same shape, so they share one settings model, one discovery pass and one creation path.
### `DateSystemSettings`
interface DateSystemSettings {
id: string; // stable across edits + reordering, so settings rows can key on it
name: string; // menu label, e.g. "Journal"
folder: string; // e.g. "periodic/journal"
nameFormat: string; // moment format string; may contain "{title}"
templateFile: string; // may omit the .md extension
typeProperty: string; // fallback frontmatter `type:` value
icon: string; // Lucide icon name for the menu item
indicatorColor: string; // calendar dot colour in systems mode
}
```
Configured systems live in `settings.dateSystems`, where array order is menu order. `DEFAULT_DATE_SYSTEM` (in `settings.ts`, typed `Omit<DateSystemSettings, 'id'>`) supplies the field defaults for a newly added row **and** the merge base for saved ones, so a system persisted before a field existed still loads with that field defined. `DEFAULT_SETTINGS.dateSystems` ships two:
| `id` | `name` | Folder | `nameFormat` | Indicator colour | Notes per date |
|---|---|---|---|---|---|
| `journal` | Journal | `periodic/journal` | `YYYY-MM-DD - [Journal]` | green (`#22c55e`) | one |
| `meetings` | Meeting | `periodic/meetings` | `YYYY-MM-DD - {title}` | violet (`#a855f7`) | many |
The default calendar mode is `systems`, while `any` preserves the older one-neutral-dot behaviour. Template file fields in both Periodic Notes and Date systems use a native `datalist` populated from every Markdown path in the vault (without the optional `.md` extension), so templates stored outside a conventional `Templates/` folder remain discoverable.
### Templater folder triggers
Waypoint deliberately does not evaluate Templater syntax. For an interactive Templater template, configure Templater's **Trigger Templater on new file creation** mode for the relevant folder, then leave that date system's `templateFile` blank in Waypoint. Waypoint creates a safe fallback note from `typeProperty` and the selected `YYYY-MM-DD` date; Templater's folder trigger owns the rendered content, prompts, scripts, and cursor placement.
The target filename is the contract between them. A calendar-created journal note is already named `YYYY-MM-DD - Journal`; a meeting is already `YYYY-MM-DD - {title}`. Templates must extract the date from `tp.file.title`, not `tp.date` or `tp.file.creation_date()`, because those refer to when Templater runs rather than the date selected in the calendar.
The journal template may retain a manual fallback: only prompt for a date and rename when `tp.file.title` does **not** match the date-system filename. That preserves manual note creation without a prompt or rename race on calendar creation.
### The `{title}` convention
`nameFormat` is a moment format string, so literal text needs bracket escaping — `YYYY-MM-DD - [Journal]` — the same convention the periodic formats `GGGG-[W]WW` and `YYYY-[Q]Q` already use.
When the format contains `{title}`, the system holds **many** notes per date: the text before the token is the date prefix used to discover them, and the token marks where a typed free-text title goes on creation. Without the token, a date maps to **exactly one** filename.
**Why the token is split out before moment sees the string:** `t`, `i`, `l` and `e` are all live moment format tokens — `l` on its own expands to an entire localized date. Passing `{title}` through `date.format()` would expand those letters and destroy the placeholder, with no way to recover it afterwards. `splitNameFormat()` therefore cuts the format at the token *first*, and callers format `before` and `after` separately, then join the two results around the title. It is also why discovery matches on formatted affixes instead of on a regex derived from the raw format.
### Helpers (`src/utils/date-systems.ts`)
Imports nothing from `'obsidian'` — callers make every moment call and pass the resulting strings in, which keeps the module unit-testable in plain node (same rationale as `path-utils.ts`).
| Export | Signature | Purpose |
|---|---|---|
| `TITLE_TOKEN` | `'{title}'` | The literal placeholder. |
| `splitNameFormat(nameFormat)` | `string → NameFormatParts` | `{ before, after, hasTitle }` — the format cut around the token. |
| `isInFolder(path, folder)` | `string, string → boolean` | Path sits in `folder` or any subfolder. An empty folder means the vault root, so everything matches. |
| `matchesSystemName(basename, before, after, hasTitle)` | `string, string, string, boolean → boolean` | One-per-date: the basename must equal `before + after`. Many-per-date: prefix/suffix match, since the middle is free text. |
| `titleFromBasename(basename, before, after)` | `string, string, string → string` | Strips the affixes to recover the menu label; falls back to the whole basename when they do not line up. |
| `sanitizeTitle(title)` | `string → string` | Collapses filename-illegal and link-syntax characters (`\ / : * ? " < > \| # ^ [ ]`) to `-`, squeezes runs of whitespace, trims — so a typed "Meeting w/ Mark" still yields a creatable filename. |
| `formatHasDateToken(nameFormat)` | `string → boolean` | Strips bracket-escaped literals out of `before`, then looks for any moment date token. Drives the settings warning. |
`matchesSystemName` deliberately returns `false` for a many-per-date format whose `before` is empty (a format of just `{title}`): an empty date prefix would otherwise claim every file in the folder for every date. The settings tab flags formats with no date token, and `findDateSystemNotes()` excludes them from the menu; `openDateSystemNote()` also refuses them. An unfinished or invalid row therefore cannot create `.md` or a static filename.
### Plugin API (`main.ts`)
```typescript
interface DateSystemNote {
file: TFile;
label: string; // free-text title for many-per-date systems, else the system name
}
interface DateSystemNotes {
system: DateSystemSettings;
notes: DateSystemNote[];
multiple: boolean; // nameFormat carries {title}
}
interface DateSystemIndicator {
id: string;
name: string;
color: string;
}
dateSystems(): DateSystemSettings[]
findDateSystemNotes(date: moment.Moment): DateSystemNotes[]
getDateSystemIndicators(dates: moment.Moment[]): Map<string, DateSystemIndicator[]>
openDateSystemNote(
system: DateSystemSettings,
date: moment.Moment,
opts?: { title?: string; leaf?: WorkspaceLeaf },
): Promise<void>
```
**`getDateSystemIndicators(dates)`** — returns one `{ id, name, color }` marker per configured system with one or more notes on a requested day. The calendar calls it once for the visible month only in `indicatorMode: 'systems'`. It makes one vault pass, caches the result by displayed dates and date-system settings, and invalidates it on markdown create/delete/rename or any Settings change. It does not run on ordinary markdown edits: content edits cannot change a filename-derived indicator.
**`dateSystems()`** — every day-scoped system in menu order: the daily periodic note, synthesized into a `DateSystemSettings` from `settings.daily`, followed by `settings.dateSystems`. The daily note is therefore not a special case in any consumer.
**`findDateSystemNotes(date)`** — **one** vault scan per call, not one per system. It formats each usable system's `before`/`after` affixes for `date` once, then walks the markdown file list a single time, bucketing each file into the system that claims it (`isInFolder` + `matchesSystemName`). Systems whose formats have no date token are omitted rather than offering an unsafe create action. It returns the remaining `DateSystemNotes` in `dateSystems()` order, each bucket sorted by basename. The calendar's day context menu is built from exactly one of these calls per right-click.
**`openDateSystemNote(system, date, opts)`** — the single opener for every dated note:
1. **Filename**`splitNameFormat(system.nameFormat)`, format `before` and `after` against `date`, and for a many-per-date system join `sanitizeTitle(opts.title)` between them. Append `.md`; prepend `system.folder` when it is set.
2. **Existing note** — a `vault.getFileByPath()` hit is opened as-is, never overwritten. A date that already has notes can still gain another in a many-per-date system, because the title makes the filename unique.
3. **Missing note** — created via `createDatedNote`, then opened.
4. **Leaf**`opts.leaf` when supplied (middle-click and Ctrl/Cmd-click pass an explicit tab leaf), otherwise `workspace.getLeaf(false)`.
### Note creation (`createDatedNote`)
A single creation path shared by periodic notes and date systems; it replaced the period-only `createPeriodNote`.
1. With a non-empty `system.templateFile`, reads it (appending `.md` when omitted) and creates the note with its raw contents. This is for plain, non-Templater templates.
2. With no template, the note is created with minimal frontmatter instead: `type: {system.typeProperty}` + `date: YYYY-MM-DD`. Use this mode with a Templater folder trigger, which replaces that fallback content with its rendered template.
---
## Period Note Creation (`openPeriodNote`)
For a given period + moment date:
```typescript
openPeriodNote(
period: 'day' | 'week' | 'month' | 'quarter' | 'year',
date: moment.Moment,
leaf?: WorkspaceLeaf,
): Promise<void>
```
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 new leaf:** `workspace.getLeaf(false).openFile(file)`.
Single unified opener — there is no separate `openPeriodNoteInLeaf`, and since the date-systems refactor there is no separate creation path either. `openPeriodNote` synthesizes the period's `PeriodNoteSettings` into a `DateSystemSettings` (`folder`, `nameFormat`, `templateFile` and `typeProperty` carry over verbatim; a period format never contains `{title}`, so a period maps to exactly one filename) and delegates to `openDateSystemNote(system, date, { leaf })`.
Filename building, the existence check, template-or-frontmatter creation and leaf selection are therefore documented once, under Date Systems above.
Middle-click handlers in the view pass an explicit tab leaf: `openPeriodNote(period, date, this.app.workspace.getLeaf('tab'))`.
---
@@ -159,32 +285,42 @@ For a given period + moment date:
### `file-open`
If `settings.recentFiles.updateOn === 'file-open'` → calls `addToRecentFiles(file)`.
Tracks the opened file into recent files **only** when `settings.recentFiles.updateOn === 'file-open'`.
If `updateOn === 'file-edit'` → currently skipped (placeholder for future edit-detection).
### `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 trigger `broadcastRedraw()` — tells all WaypointView instances to re-render (needed for calendar note indicators and stale recent file references).
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 `recentFiles` entry if path matches `oldPath`.
2. Calls `updateBookmarkPath(oldPath, newPath)` — recursively scans `waypointData.bookmarks` and updates any `filePath` matching `oldPath`.
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 (in-memory)
## Recent Files (persisted)
```typescript
recentFiles: { path: string; basename: string }[]
```
**Update flow:**
1. `addToRecentFiles(file)` — dedupes (removes existing entry), prepends to front, truncates to `maxItems`.
2. Calls `broadcastRedraw()`.
Backed by `waypointData.recentFiles` in `data.json`, so the list survives vault reload. `applyWaypointData(saved)` restores it and re-applies the current `maxItems` limit (in case the setting shrank since the last save).
**Omitted paths/tags:** Settings store regex patterns but the current `addToRecentFiles` does **not** filter by them — these are only exposed in the settings UI. The filtering is not yet wired up in the add logic.
**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`.
@@ -222,17 +358,24 @@ The last section (`waypoint-section:last-child`) gets `margin-top: auto`, pushin
- **Breadcrumb:** Q-label, month name, year — each clickable to open that period note.
- **Nav buttons:** ◀/▶ shift month ±1. "Today" resets to current month.
- **Week number column:** Clicking a week number opens the weekly note for that week's Monday.
- **Day cells:** Click opens daily note. `.other-month` dimmed. `.today` has accent border. `.has-note` gets a dot indicator (checked via `vault.getFiles().some(f => f.basename === dateStr)`).
- **Week number column:** Clicking a week number opens the weekly note for that week's Monday; middle-clicking opens it in a new tab.
- **Day cells:** Left-click opens the daily note, middle-click opens it in a new tab, right-click opens the day context menu (below). `.other-month` is muted; `.today` has an inset accent ring; hover receives a quiet fill. With `indicatorMode: 'any'`, `.has-note` shows the existing single neutral dot through the O(1) `plugin.hasNoteForDate(dateStr)` lookup. With `indicatorMode: 'systems'`, the view calls `getDateSystemIndicators()` once for the month and renders one tooltip-labelled coloured dot per matching daily/journal/meeting/custom system.
- **Grid generation:** `getMonthGrid(year, month, firstDayOfWeek)` in `date-utils.ts` produces up to 6 weeks, each with 7 `CalendarDay` objects containing `moment`, `dayOfMonth`, `isToday`, `isCurrentMonth`, `isoWeekNumber`.
**Day context menu (right-click):** Built from a single `plugin.findDateSystemNotes(day.date)` call. The first entry is a non-clickable header showing the full date. Then, per system in `dateSystems()` order:
- Each existing note, labelled with its `DateSystemNote.label` and the system's `icon`. Click opens it in the current leaf; Ctrl/Cmd-click opens it in a new tab.
- A create entry (icon `plus`) labelled `New {lowercased system name} note`. Many-per-date systems always show one, with a trailing ellipsis because it first opens a `PromptModal` for the title (`New meeting note…`). A one-per-date system shows one without the ellipsis only while its note is missing (`New journal note`); once the note exists, the existing-note entry is all it gets.
Every label is derived from `system.name`, so a renamed or newly added system needs no view changes. Left-click and middle-click on the cell are untouched.
### Recent Files Panel
Renders `plugin.recentFiles` as Obsidian-native `nav-file` elements using the file explorer's CSS classes (`tree-item`, `nav-file-title`, `nav-file-title-content`).
**Per-file features:**
- **Active indicator:** `.is-active` class if path matches `workspace.getActiveFile()`.
- **Remove button:** × icon, appears on hover (`.waypoint-recent-remove`), removes entry from array + saves settings + redraws.
- **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.
@@ -265,7 +408,7 @@ Renders `plugin.recentFiles` as Obsidian-native `nav-file` elements using the fi
**Context menu (right-click):**
- File items: "Open in new tab"
- File/Group items: "Rename" (opens `RenameModal`), "Change icon" (opens `IconSuggestModal`)
- File/Group items: "Rename" (opens `PromptModal`), "Change icon" (opens `IconSuggestModal`)
- "Move to group" submenu: Lists all available groups (excluding self + descendants) + "(Root)" for ungrouping
- Group items: "Expand/Collapse", "Add bookmark here", "New sub-group"
- All items: "Insert separator above/below", "Insert spacer above/below", "Remove"
@@ -275,13 +418,13 @@ Renders `plugin.recentFiles` as Obsidian-native `nav-file` elements using the fi
2. If targetGroupId: find group, set `item.indent = group.indent + 1`, push to group's children.
3. If null (root): set `item.indent = 0`, push to `waypointData.bookmarks`.
**Rename modal (`RenameModal`):** Simple Modal with text input + Cancel/Save buttons. Enter key submits.
**Prompt modal (`PromptModal`):** Simple Modal with a text input plus Cancel and CTA buttons. Enter key submits. Constructed as `new PromptModal(app, options, onSubmit)`, where `options` is `{ title, placeholder?, initialValue?, cta? }` and `cta` (the submit button label) defaults to `Save`. Generalized from the old rename-only `RenameModal` so the calendar's day context menu can reuse it to prompt for a note title.
**Icon picker (`IconSuggestModal`):** Modal with:
- Live preview of selected icon.
- Search input with 60ms debounce.
- Grid of matching icons (max 80 shown), loaded from `https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json` with fallback to `https://lucide.dev/api/tags` and a hardcoded `FALLBACK_ICONS` object (~300 icons).
- Click to select, "No icon" link to clear, Save/Cancel buttons.
- Grid of matching icons (max 80 shown), loaded from `https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json` with fallback to `https://lucide.dev/api/tags` and a hardcoded `FALLBACK_ICONS` object (~300 icons). The fetch result is cached for the session, so at most one network round-trip happens no matter how often the picker is opened.
- Click to select, **No icon** button to clear, Save/Cancel buttons.
---
@@ -290,18 +433,19 @@ Renders `plugin.recentFiles` as Obsidian-native `nav-file` elements using the fi
All classes prefixed with `waypoint-`. Uses Obsidian CSS variables throughout:
- `--font-ui-small`, `--font-ui-medium`, `--font-semibold`, `--font-medium`, `--font-light`
- `--text-muted`, `--text-faint`, `--text-accent`, `--text-on-accent`
- `--background-modifier-border`, `--background-modifier-active-hover`, `--background-primary`, `--background-secondary`
- `--text-muted`, `--text-faint`, `--text-accent`, `--text-on-accent`, `--text-error`
- `--background-modifier-border`, `--background-modifier-active-hover`, `--background-modifier-hover`, `--background-primary`, `--background-primary-alt`, `--background-secondary`
- `--interactive-accent`
- `--cursor` (for custom cursor support)
- `--cursor-link` (pointer cursor, with a `pointer` fallback)
Key layout:
- `.waypoint-view` — flex column, `overflow-y: auto`, 8px padding.
- `.waypoint-section``flex-shrink: 0`, 16px bottom margin. Last section gets `margin-top: auto` (pins calendar to bottom).
- `.waypoint-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-calendar` — padded, bordered surface with separated day cells for clear scan lines.
- Calendar table — `table-layout: fixed`, `border-collapse: separate`, 2px horizontal / 3px vertical cell spacing.
- `.waypoint-day.today` — accent text with an inset accent ring, avoiding layout shifts.
- `.waypoint-day.has-note::after`neutral single-dot indicator.
- `.waypoint-day-indicators` / `.waypoint-day-indicator` — compact ordered dot row; `--waypoint-indicator-color` carries each configured system colour.
- `.waypoint-bm-chevron``rotate(-90deg)` on collapsed groups.
- `.waypoint-bm-drop-line` / `.waypoint-bm-drop-below` — 3px accent border for drag indicators.
@@ -345,7 +489,7 @@ Settings and waypoint data share a single `data.json` via Obsidian's `Plugin.loa
}
```
Both `loadSettings()` and `loadWaypointData()` read from the same file, merging partials over defaults. `saveSettings()` and `saveWaypointData()` each re-read the full data, update their key, and write back. This means they must not run concurrently (no locking — relies on Obsidian's synchronous save behavior).
`onload()` reads `data.json` once, then `applySettings(saved)` and `applyWaypointData(saved)` merge partial persisted values over defaults. `persistAll()` writes both in-memory keys through one promise chain; it never re-reads stale disk state. Recent-file writes additionally go through the 300ms debounce in `persistRecentFiles()`.
---
@@ -353,7 +497,7 @@ Both `loadSettings()` and `loadWaypointData()` read from the same file, merging
`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 events
- File open/create/delete/rename/modify events
- Bookmark add/remove/update
- Period navigation
- Settings changes (via `onSettingsChange` callback)
@@ -365,11 +509,5 @@ The sidebar is re-rendered from scratch on every change. For a small sidebar thi
## Known Limitations / Gaps
1. **Recent files omittedPaths/omittedTags** — Settings UI exposes these regex filters, but `addToRecentFiles()` does not apply them. Files are never filtered out.
2. **`updateOn: 'file-edit'`** — The `file-open` handler returns early for this mode, but no edit-detection event is registered. Recent files never update in edit mode.
3. **No virtual DOM** — Full rebuild on every redraw. Bookmark drag/drop, rename, and any change tears down and rebuilds the entire sidebar.
4. **Concurrent saves**`saveSettings()` and `saveWaypointData()` both re-read then write `data.json`. Rapid sequential calls could race.
5. **Recent files not persisted** — Cleared on vault reload. Only bookmarks survive restarts.
6. **Note indicators scan all vault files**`hasNoteForDate()` iterates `vault.getFiles()` on every calendar render (called per day cell). For large vaults, this is O(days × files). Not cached.
7. **`detectPeriodType` uses hardcoded formats** — The regex patterns in `main.ts` don't read from `settings.*.nameFormat`. If a user changes the name format to something non-standard, next/prev navigation breaks.
8. **IconSuggestModal fetches from CDN** — Network dependency on first icon picker open. Falls back to hardcoded list on failure.
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.
+33 -1
View File
@@ -40,7 +40,7 @@
- [ ] 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
- [ ] Click "No icon" link → clears the icon
- [ ] 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
@@ -56,18 +56,50 @@
- [ ] 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
## Calendar: System-coloured indicators
- [ ] Calendar → Indicator style → **Single dot** keeps one neutral dot for an existing date-named note
- [ ] Calendar → Indicator style → **Colour by note system** shows a blue dot for Daily, green for Journal, and violet for Meeting by default
- [ ] A day holding multiple meeting notes still shows exactly one Meeting dot
- [ ] A day holding Daily, Journal, and Meeting notes shows three compact dots in that order
- [ ] Hover each coloured dot → its date-system name is identified by a tooltip
- [ ] Change Daily/Journal/Meeting indicator colours in Settings → the visible calendar refreshes immediately
- [ ] Add a custom date system with its own colour and a matching note → its dot appears in system order
- [ ] Create, delete, or rename a date-system note → coloured dots refresh without changing months
- [ ] Start typing in any Template file field → matching vault Markdown paths appear as native autocomplete suggestions
## Calendar: Date systems
- [ ] Right-click a date with a daily note, journal note, and meeting note → full-date header, then all three systems appear in the menu
- [ ] Right-click a date with no date-system notes → each single-note system offers a create action and the many-note system offers “New …”
- [ ] Right-click a date with one meeting → the existing meeting and “New meeting note…” both appear
- [ ] Create a second meeting for the same date → it is created with that date prefix and both meetings appear on the next right-click
- [ ] Ctrl/Cmd-click an existing date-system menu entry → it opens in a new tab
- [ ] Submit an empty title for a new many-note system → a clear notice appears and no malformed file is created
- [ ] Add a date system in Settings → Date systems → it appears in the day menu
- [ ] Reorder and delete date systems → the day menu immediately follows the configured order and removes the deleted system
- [ ] Enter a non-empty name format with no date token → Settings shows the inline warning
- [ ] Left-click a day still opens/creates its daily note; middle-click still opens it in a new tab
- [ ] With Templater folder mappings for journal and meetings, create a date-system note → Templater expands `<% … %>` syntax rather than leaving it literal
- [ ] Create a journal from the calendar → no duplicate date prompt and no rename; its frontmatter `date` equals the selected calendar day
- [ ] Create a meeting from the calendar for a non-today date → its frontmatter `date` equals the selected calendar day
## Recent Files (Regression)
- [ ] Opening a file adds it to recent files
+32 -1
View File
@@ -4,7 +4,8 @@ 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)
- **Calendar panel** — refined month grid; left-click a day opens its daily note, middle-click opens it in a tab, and right-click reveals every configured note system for that date
- **Calendar indicators** — retain a single neutral note dot or show a coloured dot per daily, journal, meeting, or custom date system
- **Recent files** — track recently opened/edited files
- **Favorites** — custom bookmarks with groups, icons, and rename
@@ -19,6 +20,36 @@ Calendar, recent files, and custom bookmarks sidebar for Obsidian.
| Go to yearly note | `Ctrl+Shift+Alt+Y` |
| Next/Previous daily/weekly/monthly/quarterly/yearly note | — |
## Date systems
Right-click any calendar day to open its **date systems** menu. The daily note always appears first; left-click behaviour is unchanged. The included defaults match this vault's common layouts:
| System | Folder | Name format | Behaviour |
|---|---|---|---|
| Journal | `periodic/journal` | `YYYY-MM-DD - [Journal]` | Opens or creates one journal note for the date |
| Meeting | `periodic/meetings` | `YYYY-MM-DD - {title}` | Lists every meeting for the date and can create another |
Configure systems in **Settings → Waypoint Sidebar → Date systems**. Each system has a folder, filename format, template, fallback frontmatter type, Lucide icon, and indicator colour. Template file fields autocomplete every Markdown path in the vault.
`Name format` uses moment.js tokens. Bracket-escape literal text: `YYYY-MM-DD - [Journal]`. Add `{title}` when a date can have multiple notes; it is replaced with the title requested when creating a note. Without `{title}`, the system has exactly one note per date.
Ctrl/Cmd-click a menu entry opens it in a new tab.
### Calendar indicators
Under **Settings → Waypoint Sidebar → Calendar**, choose either:
- **Colour by note system** — the default; one compact coloured dot per configured date system that has a note on that day. Hover a dot to identify its system.
- **Single dot** — a neutral marker for any note whose filename is exactly the date.
Daily uses the Calendar tabs colour picker. Journal, Meeting, and every custom system have their own **Indicator colour** picker under **Date systems**. The defaults are blue for Daily, green for Journal, and violet for Meeting.
### Templater folder templates
If a date system uses a Templater folder mapping, leave its Waypoint **Template file** field blank. Waypoint then creates a safe fallback note while Templater owns prompts, scripts, cursors, and rendered content.
The selected calendar date is encoded in the filename. Templater templates should derive their `date` from `tp.file.title`, not from `tp.date` or `tp.file.creation_date()`. A journal template can retain a prompt-and-rename fallback for manual note creation, but it must skip that flow when the filename already follows `YYYY-MM-DD - Journal`.
## Installation
### Via BRAT
+20 -15
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,8 +1,8 @@
{
"id": "waypoint-sidebar",
"name": "Waypoint Sidebar",
"version": "1.4.1",
"minAppVersion": "0.16.3",
"version": "1.7.0",
"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.4.1",
"version": "1.7.0",
"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",
+601 -194
View File
@@ -3,34 +3,72 @@
import {
Plugin,
WorkspaceLeaf,
ItemView,
Notice,
TFile,
TFolder,
TAbstractFile,
getAllTags,
moment,
} from 'obsidian';
import { WaypointSettings, DEFAULT_SETTINGS, PeriodNoteSettings } from 'src/settings';
import { WaypointSettings, DEFAULT_SETTINGS, DateSystemSettings, DEFAULT_DATE_SYSTEM } 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';
import {
splitNameFormat,
isInFolder,
matchesSystemName,
titleFromBasename,
sanitizeTitle,
formatHasDateToken,
} from 'src/utils/date-systems';
const DEFAULT_DATA: WaypointData = {
bookmarks: [],
recentFiles: [],
};
export type PeriodKey = 'day' | 'week' | 'month' | 'quarter' | 'year';
/** One existing note of a date system, with the label the menu should show. */
export interface DateSystemNote {
file: TFile;
/** Free-text title for many-per-date systems, else the system name. */
label: string;
}
/** A day-scoped system and the notes it already holds for one date. */
export interface DateSystemNotes {
system: DateSystemSettings;
/** Sorted by basename. Empty when the date has no note in this system. */
notes: DateSystemNote[];
/** True when nameFormat carries {title}, i.e. many notes per date. */
multiple: boolean;
}
/** One coloured calendar marker for a date system that has a note on a day. */
export interface DateSystemIndicator {
id: string;
name: string;
color: string;
}
export default class WaypointPlugin extends Plugin {
public settings: WaypointSettings;
public waypointData: WaypointData;
public recentFiles: { path: string; basename: string }[] = [];
private recentFilesSaveTimer: ReturnType<typeof setTimeout> | null = null;
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();
/** Results for the displayed month; invalidated by markdown-file changes. */
private dateSystemIndicators: Map<string, DateSystemIndicator[]> | null = null;
private dateSystemIndicatorKey = '';
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(
@@ -45,6 +83,7 @@ export default class WaypointPlugin extends Plugin {
this.settings,
() => {
this.enforceRecentFilesLimit();
this.invalidateDateSystemIndicators();
this.redrawAll();
},
));
@@ -156,23 +195,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();
}
});
@@ -193,27 +240,44 @@ 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] || {});
}
// Cloned, not Object.assign'd in: the defaults array would otherwise be
// aliased into the live settings and the settings UI would edit
// DEFAULT_DATE_SYSTEM so older configs pick up fields added since. Known
// built-ins use their own defaults too, preserving Journal/Meeting colours
// when this field is first introduced.
this.settings.dateSystems = Array.isArray(s.dateSystems)
? s.dateSystems.map(sys => {
const builtIn = DEFAULT_SETTINGS.dateSystems.find(defaultSystem => defaultSystem.id === sys.id);
return Object.assign({}, builtIn || DEFAULT_DATE_SYSTEM, sys);
})
: DEFAULT_SETTINGS.dateSystems.map(sys => Object.assign({}, sys));
}
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;
private applyWaypointData(saved: Record<string, unknown> | null): void {
const d = (saved?.waypointData || {}) as Partial<WaypointData>;
this.waypointData = Object.assign({}, DEFAULT_DATA, d);
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 || [];
this.recentFiles = this.waypointData.recentFiles;
// Apply current limit (in case maxItems was reduced since last save)
if (this.recentFiles.length > this.settings.recentFiles.maxItems) {
@@ -222,6 +286,37 @@ export default class WaypointPlugin extends Plugin {
}
}
/**
* 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> {
await this.persistAll();
}
async saveWaypointData(): Promise<void> {
await this.persistAll();
}
/**
* Trim recent files to the current maxItems limit and persist.
* Called when the maxItems setting changes.
@@ -233,21 +328,13 @@ export default class WaypointPlugin extends Plugin {
}
}
async saveWaypointData(): Promise<void> {
// Sync recentFiles into waypointData before saving
this.waypointData.recentFiles = this.recentFiles;
const all = (await this.loadData()) as Record<string, unknown> || {};
all.waypointData = this.waypointData;
await this.saveData(all);
}
/**
* Persist recent files to disk (debounced to avoid excessive writes on rapid opens).
*/
persistRecentFiles(): void {
this.waypointData.recentFiles = this.recentFiles;
if (this.recentFilesSaveTimer) clearTimeout(this.recentFilesSaveTimer);
this.recentFilesSaveTimer = setTimeout(() => {
window.clearTimeout(this.recentFilesSaveTimer);
this.recentFilesSaveTimer = window.setTimeout(() => {
this.saveWaypointData();
}, 300);
}
@@ -255,24 +342,20 @@ export default class WaypointPlugin extends Plugin {
// ── 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 {
// Apply omitted paths filter
if (this.settings.recentFiles.omittedPaths.length > 0) {
for (const pattern of this.settings.recentFiles.omittedPaths) {
try {
if (new RegExp(pattern).test(file.path)) return;
} catch {
// Invalid regex, skip
}
}
}
if (this.isOmittedFromRecentFiles(file)) return;
this.recentFiles = this.recentFiles.filter(f => f.path !== file.path);
this.recentFiles.unshift({ path: file.path, basename: file.basename });
@@ -286,23 +369,146 @@ export default class WaypointPlugin extends Plugin {
this.broadcastRedraw();
}
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(/\.[^/.]+$/, '');
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
}
}
// Update bookmark file paths
this.updateBookmarkPath(oldPath, file.path);
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;
}
private onVaultChange(): void {
/**
* 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 {
this.invalidateDateSystemIndicators();
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.invalidateDateSystemIndicators();
}
this.broadcastRedraw();
}
private onVaultDelete(file: TAbstractFile): void {
if (file instanceof TFile && file.extension === 'md') {
this.removeFromMarkdownIndex(file.basename, file.path);
this.invalidateDateSystemIndicators();
}
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);
}
}
/** Forget a month result when markdown paths or date-system settings change. */
private invalidateDateSystemIndicators(): void {
this.dateSystemIndicators = null;
this.dateSystemIndicatorKey = '';
}
/**
* 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 = ''): BookmarkItem {
@@ -359,137 +565,314 @@ 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);
}
// ── Date systems: discovery, creation, opening ──
/** Periodic notes are date systems with a fixed one-note-per-period format. */
private periodAsDateSystem(period: PeriodKey): DateSystemSettings {
const config = PERIOD_CONFIGS[period];
const periodSettings = this.settings[config.key];
return {
id: config.key,
name: config.label,
folder: periodSettings.folder,
nameFormat: periodSettings.nameFormat,
templateFile: periodSettings.templateFile,
typeProperty: periodSettings.typeProperty,
icon: 'calendar',
indicatorColor: this.settings.calendar.dailyIndicatorColor,
};
updateRecursive(this.waypointData.bookmarks);
}
// ── Period note creation/opening ──
/** All day-scoped systems: the daily periodic note first, then settings.dateSystems. */
dateSystems(): DateSystemSettings[] {
return [this.periodAsDateSystem('day'), ...this.settings.dateSystems];
}
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;
};
/**
* Bucket the notes every day-scoped system already holds for `date`.
*
* One vault scan serves all systems at once. That is affordable because
* this runs once per right-click, not once per calendar cell, and it keeps
* arbitrary user-defined name formats out of the incremental index.
*/
findDateSystemNotes(date: moment.Moment): DateSystemNotes[] {
const buckets = this.dateSystems().map(system => {
const parts = splitNameFormat(system.nameFormat);
return {
result: { system, notes: [] as DateSystemNote[], multiple: parts.hasTitle },
// Each half is formatted on its own: the raw format may still
// hold {title}, whose letters are live moment tokens.
before: formatDatePart(date, parts.before),
after: formatDatePart(date, parts.after),
hasTitle: parts.hasTitle,
// Empty/date-less formats have no date-specific filename. They stay
// editable in Settings but must not contribute a menu action.
skip: !formatHasDateToken(system.nameFormat),
};
});
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;
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);
}
} catch (err) {
new Notice(`Failed to create ${config.label.toLowerCase()} note: ${err.message}`);
return;
for (const file of this.app.vault.getMarkdownFiles()) {
for (const bucket of buckets) {
if (bucket.skip) continue;
const system = bucket.result.system;
if (!isInFolder(file.path, system.folder)) continue;
if (!matchesSystemName(file.basename, bucket.before, bucket.after, bucket.hasTitle)) continue;
bucket.result.notes.push({
file,
label: bucket.hasTitle
? titleFromBasename(file.basename, bucket.before, bucket.after)
: system.name,
});
}
new Notice(`Created ${config.label.toLowerCase()} note: ${filename}`);
}
if (file) {
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
for (const bucket of buckets) {
bucket.result.notes.sort((a, b) => a.file.basename.localeCompare(b.file.basename));
}
return buckets
.filter(bucket => !bucket.skip)
.map(bucket => bucket.result);
}
/**
* Return one coloured indicator per system with a note on each requested day.
*
* The day grid calls this once per render. Its result is cached by the
* displayed dates and system settings, then invalidated by file changes, so
* a 42-cell calendar never repeats the vault scan per cell or per redraw.
*/
getDateSystemIndicators(dates: moment.Moment[]): Map<string, DateSystemIndicator[]> {
const dateStrings = dates.map(date => date.format('YYYY-MM-DD'));
const systems = this.dateSystems()
.filter(system => formatHasDateToken(system.nameFormat))
.map(system => {
const parts = splitNameFormat(system.nameFormat);
return {
system,
hasTitle: parts.hasTitle,
dates: dates.map((date, index) => ({
dateStr: dateStrings[index],
before: formatDatePart(date, parts.before),
after: formatDatePart(date, parts.after),
})),
};
});
const key = JSON.stringify({
dates: dateStrings,
systems: systems.map(({ system }) => [
system.id,
system.name,
system.folder,
system.nameFormat,
system.indicatorColor,
]),
});
if (this.dateSystemIndicators && this.dateSystemIndicatorKey === key) {
return this.dateSystemIndicators;
}
const matchingIds = new Map<string, Set<string>>();
for (const dateStr of dateStrings) matchingIds.set(dateStr, new Set());
// The only vault traversal: filter a file by system folder first, then
// test it against the month's at-most-42 formatted day patterns.
for (const file of this.app.vault.getMarkdownFiles()) {
for (const entry of systems) {
if (!isInFolder(file.path, entry.system.folder)) continue;
for (const match of entry.dates) {
if (matchesSystemName(file.basename, match.before, match.after, entry.hasTitle)) {
matchingIds.get(match.dateStr)?.add(entry.system.id);
}
}
}
}
const result = new Map<string, DateSystemIndicator[]>();
for (const dateStr of dateStrings) {
const ids = matchingIds.get(dateStr);
result.set(dateStr, systems
.filter(({ system }) => ids?.has(system.id))
.map(({ system }) => ({
id: system.id,
name: system.name,
color: system.indicatorColor,
})));
}
this.dateSystemIndicatorKey = key;
this.dateSystemIndicators = result;
return result;
}
/**
* Open (creating if needed) the note `system` holds for `date`.
*
* `opts.title` fills the `{title}` token of a many-per-date system and is
* ignored by systems without one. Opens in `opts.leaf` when given,
* otherwise in the active leaf.
*/
async openDateSystemNote(
system: DateSystemSettings,
date: moment.Moment,
opts?: { title?: string; leaf?: WorkspaceLeaf },
): Promise<void> {
if (!formatHasDateToken(system.nameFormat)) {
new Notice(`Waypoint: ${system.name || 'This'} name format needs a date placeholder.`);
return;
}
const parts = splitNameFormat(system.nameFormat);
const before = formatDatePart(date, parts.before);
const after = formatDatePart(date, parts.after);
let basename: string;
if (parts.hasTitle) {
const title = sanitizeTitle(opts?.title || '');
// Public method, so a caller can legitimately hand us nothing;
// creating "2026-09-07 - .md" would be worse than refusing.
if (!title) {
new Notice(`Waypoint: a ${system.name.toLowerCase()} note needs a title.`);
return;
}
basename = before + title + after;
} else {
basename = before + after;
}
const fullPath = system.folder ? `${system.folder}/${basename}.md` : `${basename}.md`;
let file = this.app.vault.getFileByPath(fullPath);
if (!file) {
file = await this.createDatedNote(fullPath, system, date);
if (!file) return;
}
const target = opts?.leaf || this.app.workspace.getLeaf(false);
await target.openFile(file);
}
/**
* 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> {
await this.openDateSystemNote(this.periodAsDateSystem(period), date, { leaf });
}
/**
* Create a date system's 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 createDatedNote(
fullPath: string,
system: DateSystemSettings,
date: moment.Moment,
): Promise<TFile | null> {
const noun = system.name.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 = system.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: ${system.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;
}
}
}
// ── Open period note in a specific leaf (for middle-click) ──
async openPeriodNoteInLeaf(period: 'day' | 'week' | 'month' | 'quarter' | 'year', date: moment.Moment, leaf: any): Promise<void> {
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;
const filename = date.format(periodSettings.nameFormat) + '.md';
const fullPath = periodSettings.folder ? `${periodSettings.folder}/${filename}` : filename;
let file = this.app.vault.getFileByPath(fullPath);
if (!file) {
try {
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 {
const content = `---\ntype: ${periodSettings.typeProperty}\ndate: ${date.format('YYYY-MM-DD')}\n---\n\n`;
file = await this.app.vault.create(fullPath, content);
}
} catch (err) {
new Notice(`Failed to create ${config.label.toLowerCase()} note: ${err.message}`);
return;
}
}
if (file) {
await leaf.openFile(file);
}
/** 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.
* 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: 'day' | 'week' | 'month' | 'quarter' | 'year'; date: moment.Moment } | null {
// YYYY-MM-DD → daily
if (/^\d{4}-\d{2}-\d{2}$/.test(basename)) {
return { period: 'day', date: moment(basename, 'YYYY-MM-DD') };
}
// GGGG-WWW → weekly (e.g. 2026-W24)
if (/^\d{4}-W\d{2}$/.test(basename)) {
return { period: 'week', date: moment(basename, 'GGGG-[W]WW') };
}
// YYYY-MM → monthly
if (/^\d{4}-\d{2}$/.test(basename)) {
return { period: 'month', date: moment(basename, 'YYYY-MM') };
}
// YYYY-Q# → quarterly
if (/^\d{4}-Q[1-4]$/.test(basename)) {
return { period: 'quarter', date: moment(basename, 'YYYY-[Q]Q') };
}
// YYYY → yearly
if (/^\d{4}$/.test(basename)) {
return { period: 'year', date: moment(basename, 'YYYY') };
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;
}
@@ -506,17 +889,19 @@ export default class WaypointPlugin extends Plugin {
const detected = this.detectPeriodType(file.basename);
if (!detected) {
new Notice('Current file is not a periodic note (daily/weekly/monthly/quarterly/yearly)');
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;
}
const { period, date } = detected;
if (!date.isValid()) {
new Notice(`Could not parse date from filename: ${file.basename}`);
return;
}
const amount = direction === 'next' ? 1 : -1;
// Map period to moment duration unit
@@ -541,20 +926,42 @@ 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);
}
/**
* Format one half of a split name format. Moment falls back to its default
* ISO output when handed an empty format string, so an empty half must never
* reach it.
*/
function formatDatePart(date: moment.Moment, part: string): string {
return part ? date.format(part) : '';
}
+234 -16
View File
@@ -1,13 +1,15 @@
import { Setting, PluginSettingTab, App, Plugin, setIcon } from 'obsidian';
import { WaypointSettings, DEFAULT_SETTINGS, PeriodNoteSettings } from 'src/settings';
import { Setting, PluginSettingTab, App, setIcon } from 'obsidian';
import type WaypointPlugin from 'src/main';
import { WaypointSettings, DEFAULT_SETTINGS, DEFAULT_DATE_SYSTEM, PeriodNoteSettings } from 'src/settings';
import { formatHasDateToken } from 'src/utils/date-systems';
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';
private activeTab: 'calendar' | 'periodic' | 'systems' | '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;
@@ -23,6 +25,7 @@ export class WaypointSettingTab extends PluginSettingTab {
const tabs = [
{ key: 'calendar' as const, label: 'Calendar' },
{ key: 'periodic' as const, label: 'Periodic Notes' },
{ key: 'systems' as const, label: 'Date systems' },
{ key: 'recent' as const, label: 'Recent Files' },
{ key: 'display' as const, label: 'Display' },
{ key: 'about' as const, label: 'About' },
@@ -48,6 +51,9 @@ export class WaypointSettingTab extends PluginSettingTab {
case 'periodic':
this.renderPeriodicTab(tabContent);
break;
case 'systems':
this.renderSystemsTab(tabContent);
break;
case 'recent':
this.renderRecentTab(tabContent);
break;
@@ -90,6 +96,32 @@ export class WaypointSettingTab extends PluginSettingTab {
this.saveAndRefresh();
});
});
new Setting(container)
.setName('Indicator style')
.setDesc('Show one neutral dot for any dated note, or one coloured dot for each configured date system.')
.addDropdown((dropdown) => {
dropdown
.addOption('any', 'Single dot')
.addOption('systems', 'Colour by note system')
.setValue(this.settings.calendar.indicatorMode)
.onChange((value: 'any' | 'systems') => {
this.settings.calendar.indicatorMode = value;
this.saveAndRefresh();
});
});
new Setting(container)
.setName('Daily indicator colour')
.setDesc('Colour for daily-note dots when using “Colour by note system”.')
.addColorPicker((color) => {
color
.setValue(this.settings.calendar.dailyIndicatorColor)
.onChange((value) => {
this.settings.calendar.dailyIndicatorColor = value;
this.saveAndRefresh();
});
});
}
// ═══════════════════════════════
@@ -97,14 +129,20 @@ export class WaypointSettingTab extends PluginSettingTab {
// ═══════════════════════════════
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);
const templateSuggestionsId = this.createTemplateSuggestions(container);
this.addPeriodNoteSettings(container, 'Daily', this.settings.daily, templateSuggestionsId);
this.addPeriodNoteSettings(container, 'Weekly', this.settings.weekly, templateSuggestionsId);
this.addPeriodNoteSettings(container, 'Monthly', this.settings.monthly, templateSuggestionsId);
this.addPeriodNoteSettings(container, 'Quarterly', this.settings.quarterly, templateSuggestionsId);
this.addPeriodNoteSettings(container, 'Yearly', this.settings.yearly, templateSuggestionsId);
}
private addPeriodNoteSettings(container: HTMLElement, label: string, period: PeriodNoteSettings): void {
private addPeriodNoteSettings(
container: HTMLElement,
label: string,
period: PeriodNoteSettings,
templateSuggestionsId: string,
): void {
new Setting(container).setHeading().setName(label);
new Setting(container)
@@ -133,10 +171,11 @@ export class WaypointSettingTab extends PluginSettingTab {
new Setting(container)
.setName('Template file')
.setDesc(`Path to the template file (without .md extension).`)
.setDesc('Path to the template file. The .md extension is optional.')
.addText((text) => {
text.setPlaceholder('Templates/Daily note');
text.setValue(period.templateFile);
text.inputEl.setAttr('list', templateSuggestionsId);
text.onChange((value) => {
period.templateFile = value;
this.saveAndRefresh();
@@ -156,6 +195,170 @@ export class WaypointSettingTab extends PluginSettingTab {
});
}
// ═══════════════════════════════
// Date systems tab
// ═══════════════════════════════
private renderSystemsTab(container: HTMLElement): void {
const intro = new DocumentFragment();
intro.createDiv({ text: 'A date system is a folder of notes whose filenames start with a date. Each one appears in the calendar\'s right-click menu for that day.' });
intro.createDiv({ text: 'The daily note is configured under Periodic Notes and always comes first in that menu.' });
intro.createDiv({ text: 'Name format is a moment.js format. Literal words need bracket escaping, e.g. YYYY-MM-DD - [Journal].' });
intro.createDiv({ text: 'Include {title} for systems that hold many notes per date, such as meetings: the text before the token finds the existing notes, and the token marks where a typed title goes. Without it, a date has exactly one note.' });
new Setting(container)
.setHeading()
.setName('Date systems')
.setDesc(intro);
const templateSuggestionsId = this.createTemplateSuggestions(container);
this.settings.dateSystems.forEach((system, i, arr) => {
new Setting(container)
.setHeading()
.setName(system.name || 'Untitled system')
.addExtraButton((btn) => {
btn
.setIcon('arrow-up')
.setTooltip('Move up')
.setDisabled(i === 0)
.onClick(async () => {
if (i === 0) return;
const above = arr[i - 1];
arr[i - 1] = arr[i];
arr[i] = above;
await this.saveAndRefresh();
this.display();
});
})
.addExtraButton((btn) => {
btn
.setIcon('arrow-down')
.setTooltip('Move down')
.setDisabled(i === arr.length - 1)
.onClick(async () => {
if (i === arr.length - 1) return;
const below = arr[i + 1];
arr[i + 1] = arr[i];
arr[i] = below;
await this.saveAndRefresh();
this.display();
});
})
.addExtraButton((btn) => {
btn
.setIcon('trash')
.setTooltip('Delete this date system')
.onClick(async () => {
arr.splice(i, 1);
await this.saveAndRefresh();
this.display();
});
});
this.addSystemTextSetting(container,
'Name', 'Label shown in the calendar right-click menu.',
system, 'name', 'Journal',
);
this.addSystemTextSetting(container,
'Folder', 'Folder these notes live in.',
system, 'folder', 'periodic/journal',
);
const formatSetting = this.addSystemTextSetting(container,
'Name format', 'Filename format (moment.js format). Include {title} for many notes per date.',
system, 'nameFormat', 'YYYY-MM-DD - {title}',
);
// A blank row is still being filled in, so only warn once something was typed.
if (system.nameFormat && !formatHasDateToken(system.nameFormat)) {
formatSetting.descEl.createDiv({
cls: 'waypoint-settings-warning',
text: 'This name format has no date placeholder, so it will never match or create dated notes.',
});
}
this.addSystemTextSetting(container,
'Template file', 'Path to the template file. The .md extension is optional.',
system, 'templateFile', 'resources/template/journal', templateSuggestionsId,
);
this.addSystemTextSetting(container,
'Type property', `Fallback value for the 'type' frontmatter property, used when no template is found.`,
system, 'typeProperty', 'journal-note',
);
this.addSystemTextSetting(container,
'Icon', 'Lucide icon name for the menu item. Browse names at lucide.dev.',
system, 'icon', 'book-open',
);
new Setting(container)
.setName('Indicator colour')
.setDesc('Colour for this systems dot when using “Colour by note system”.')
.addColorPicker((color) => {
color
.setValue(system.indicatorColor)
.onChange((value) => {
system.indicatorColor = value;
this.saveAndRefresh();
});
});
});
new Setting(container)
.addButton((btn) =>
btn
.setButtonText('Add date system')
.setCta()
.onClick(async () => {
this.settings.dateSystems.push(Object.assign(
{ id: `ds-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` },
DEFAULT_DATE_SYSTEM,
));
await this.saveAndRefresh();
this.display();
}),
);
}
/** Returns the Setting so callers can append validation notices to its description. */
private addSystemTextSetting<K extends string>(
container: HTMLElement,
name: string,
desc: string,
obj: Record<K, string>,
key: K,
placeholder: string,
templateSuggestionsId?: string,
): Setting {
return new Setting(container)
.setName(name)
.setDesc(desc)
.addText((text) => {
text.setPlaceholder(placeholder);
text.setValue(obj[key]);
if (templateSuggestionsId) text.inputEl.setAttr('list', templateSuggestionsId);
text.onChange((value) => {
obj[key] = value;
this.saveAndRefresh();
});
});
}
/**
* Native datalist keeps template paths searchable without another custom
* modal, and accepts templates stored anywhere in the vault.
*/
private createTemplateSuggestions(container: HTMLElement): string {
const id = 'waypoint-template-suggestions';
const list = container.createEl('datalist', { attr: { id } });
const paths = this.app.vault.getMarkdownFiles()
.map(file => file.path.replace(/\.md$/, ''))
.sort((a, b) => a.localeCompare(b));
for (const path of paths) {
list.createEl('option', { value: path });
}
return id;
}
// ═══════════════════════════════
// Recent Files tab
// ═══════════════════════════════
@@ -220,6 +423,21 @@ 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();
};
});
}
// ═══════════════════════════════
@@ -269,12 +487,12 @@ export class WaypointSettingTab extends PluginSettingTab {
);
}
private addSliderSetting(
private addSliderSetting<K extends string>(
container: HTMLElement,
name: string,
desc: string,
obj: any,
key: string,
obj: Record<K, number>,
key: K,
min: number,
max: number,
step: number,
@@ -298,7 +516,7 @@ export class WaypointSettingTab extends PluginSettingTab {
}
private async saveAndRefresh(): Promise<void> {
await (this.plugin as any).saveSettings();
await this.plugin.saveSettings();
this.onSettingsChange();
}
+67
View File
@@ -7,9 +7,48 @@ export interface PeriodNoteSettings {
typeProperty: string;
}
/**
* A folder of notes whose filenames begin with a date the vault's "systems":
* daily notes, a journal, meeting notes.
*
* `nameFormat` is a moment format string, so literal text needs bracket
* escaping (`YYYY-MM-DD - [Journal]`). When it contains `{title}` the system
* holds many notes per date and the token marks where the free-text title
* goes; without it a date maps to exactly one filename.
*/
export interface DateSystemSettings {
/** Stable across edits and reordering, so the settings UI can key rows. */
id: string;
/** Shown in the calendar's right-click menu. */
name: string;
folder: string;
nameFormat: string;
templateFile: string;
typeProperty: string;
/** Lucide icon name for the menu item. */
icon: string;
/** CSS colour for this system's calendar indicator. */
indicatorColor: string;
}
/** Field defaults for a newly added system, and the merge base for saved ones. */
export const DEFAULT_DATE_SYSTEM: Omit<DateSystemSettings, 'id'> = {
name: '',
folder: '',
nameFormat: 'YYYY-MM-DD - {title}',
templateFile: '',
typeProperty: '',
icon: 'file',
indicatorColor: '#64748b',
};
export interface CalendarSettings {
firstDayOfWeek: number; // 0=Sunday, 1=Monday
showNoteIndicators: boolean;
/** Single neutral dot, or a dot per day-scoped date system. */
indicatorMode: 'any' | 'systems';
/** Colour used by the synthesized daily system in `systems` mode. */
dailyIndicatorColor: string;
}
export interface RecentFilesSettings {
@@ -17,6 +56,7 @@ 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 {
@@ -35,6 +75,8 @@ export interface WaypointSettings {
monthly: PeriodNoteSettings;
quarterly: PeriodNoteSettings;
yearly: PeriodNoteSettings;
/** Day-scoped systems beyond the daily note, in menu order. */
dateSystems: DateSystemSettings[];
recentFiles: RecentFilesSettings;
display: DisplaySettings;
}
@@ -43,6 +85,8 @@ export const DEFAULT_SETTINGS: WaypointSettings = {
calendar: {
firstDayOfWeek: 1, // Monday
showNoteIndicators: true,
indicatorMode: 'systems',
dailyIndicatorColor: '#3b82f6',
},
daily: {
folder: 'periodic/daily',
@@ -74,11 +118,34 @@ export const DEFAULT_SETTINGS: WaypointSettings = {
nameFormat: 'YYYY',
typeProperty: 'yearly-note',
},
dateSystems: [
{
id: 'journal',
name: 'Journal',
folder: 'periodic/journal',
nameFormat: 'YYYY-MM-DD - [Journal]',
templateFile: '',
typeProperty: 'journal',
icon: 'book-open',
indicatorColor: '#22c55e',
},
{
id: 'meetings',
name: 'Meeting',
folder: 'periodic/meetings',
nameFormat: 'YYYY-MM-DD - {title}',
templateFile: '',
typeProperty: 'meeting',
icon: 'users',
indicatorColor: '#a855f7',
},
],
recentFiles: {
maxItems: 50,
updateOn: 'file-open',
omittedPaths: [],
omittedTags: [],
filterTags: [],
},
display: {
rowSize: 26,
+103
View File
@@ -0,0 +1,103 @@
// ── Date system filename helpers ──
//
// A "date system" is a folder of notes whose filenames begin with a date:
//
// periodic/daily/2026-09-07.md one per date
// periodic/journal/2026-09-07 - Journal.md one per date
// periodic/meetings/2026-09-07 - Meeting w Mark.md many per date
//
// The system's `nameFormat` is a moment format string. When it contains
// `{title}` the system holds many notes per date: the part before the token is
// the date prefix used to find them, and the token marks where a free-text
// title goes when creating one.
//
// This module imports nothing from 'obsidian' so it stays unit-testable in
// plain node. Callers do the moment formatting and pass the results in.
export const TITLE_TOKEN = '{title}';
export interface NameFormatParts {
/** Moment format for the text before the title. */
before: string;
/** Moment format for the text after the title. Empty for most systems. */
after: string;
/** True when the format carries a title token, i.e. many notes per date. */
hasTitle: boolean;
}
/**
* Split a name format around its title token.
*
* The split has to happen before moment sees the string: `t`, `i`, `l` and `e`
* are all live moment tokens, so formatting `{title}` directly would mangle it.
*/
export function splitNameFormat(nameFormat: string): NameFormatParts {
const idx = nameFormat.indexOf(TITLE_TOKEN);
if (idx < 0) return { before: nameFormat, after: '', hasTitle: false };
return {
before: nameFormat.slice(0, idx),
after: nameFormat.slice(idx + TITLE_TOKEN.length),
hasTitle: true,
};
}
/** Whether `path` sits in `folder` or any subfolder. An empty folder is the vault root. */
export function isInFolder(path: string, folder: string): boolean {
if (!folder) return true;
const prefix = folder.endsWith('/') ? folder : `${folder}/`;
return path.startsWith(prefix);
}
/**
* Whether `basename` is a note of a system whose formatted name parts are
* `before` and `after`.
*
* One-per-date systems must match the whole basename. Many-per-date systems
* match on the date prefix, since the middle is a free-text title.
*/
export function matchesSystemName(
basename: string,
before: string,
after: string,
hasTitle: boolean,
): boolean {
if (!hasTitle) return basename === before + after;
// A format of just `{title}` formats to an empty prefix, which would claim
// every file in the folder for every date. Treat it as matching nothing;
// the settings tab flags the format as missing a date instead.
if (!before) return false;
if (basename.length < before.length + after.length) return false;
return basename.startsWith(before) && basename.endsWith(after);
}
/**
* The free-text part of a many-per-date basename, for use as a menu label.
* Falls back to the whole basename when the affixes do not line up.
*/
export function titleFromBasename(basename: string, before: string, after: string): string {
const start = basename.startsWith(before) ? before.length : 0;
const end = after && basename.endsWith(after)
? basename.length - after.length
: basename.length;
if (end <= start) return basename;
return basename.slice(start, end).trim() || basename;
}
/**
* Characters Obsidian rejects in filenames, plus the link-syntax characters
* (`#^[]|`) it refuses to index cleanly. Collapsed to a dash so a typed title
* like "Meeting w/ Mark" still produces a creatable filename.
*/
const ILLEGAL_FILENAME_CHARS = /[\\/:*?"<>|#^[\]]/g;
export function sanitizeTitle(title: string): string {
return title.replace(ILLEGAL_FILENAME_CHARS, '-').replace(/\s+/g, ' ').trim();
}
/** Whether a name format resolves to a usable date prefix at all. */
export function formatHasDateToken(nameFormat: string): boolean {
const { before } = splitNameFormat(nameFormat);
// Strip bracket-escaped literals, then look for any moment date token.
const unescaped = before.replace(/\[[^\]]*\]/g, '');
return /[YMDQGWEwdgeo]/.test(unescaped);
}
+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;
}
+333 -254
View File
@@ -12,13 +12,27 @@ import {
Notice,
TFile,
moment,
type PaneType,
} from 'obsidian';
import type WaypointPlugin from 'src/main';
import type { DateSystemNotes } from 'src/main';
import { getMonthGrid } from 'src/utils/date-utils';
import { BookmarkItem } from 'src/models/bookmark';
export const WAYPOINT_VIEW_TYPE = 'waypoint-view';
/** Obsidian's internal drag manager — undocumented, so it has no public typings. */
interface DragManager {
dragFile(event: DragEvent, file: TFile): unknown;
onDragStart(event: DragEvent, draggable: unknown): void;
}
function getDragManager(app: App): DragManager {
// `dragManager` is an internal Obsidian API, absent from the public typings.
const internal = app as unknown as { dragManager: DragManager };
return internal.dragManager;
}
export class WaypointView extends ItemView {
private plugin: WaypointPlugin;
@@ -72,11 +86,11 @@ export class WaypointView extends ItemView {
private currentDisplayMonth: number = moment().month(); // 0-indexed
private currentDisplayYear: number = moment().year();
private dragId: string | null = null;
private dropZones = new WeakMap<HTMLElement, { above: boolean; into: boolean }>();
private recentFilesFilter: string | null = null; // null = show all, else filter by type
private renderCalendar(): void {
const section = this.contentEl.createDiv({ cls: 'waypoint-section' });
section.createDiv({ cls: 'waypoint-section-header', text: 'Calendar' });
const cal = section.createDiv({ cls: 'waypoint-calendar' });
@@ -97,7 +111,7 @@ export class WaypointView extends ItemView {
qEl.addEventListener('mousedown', (event: MouseEvent) => {
if (event.button === 1) {
event.preventDefault();
this.plugin.openPeriodNoteInLeaf('quarter', displayDate, this.app.workspace.getLeaf('tab'));
this.plugin.openPeriodNote('quarter', displayDate, this.app.workspace.getLeaf('tab'));
}
});
@@ -109,7 +123,7 @@ export class WaypointView extends ItemView {
mEl.addEventListener('mousedown', (event: MouseEvent) => {
if (event.button === 1) {
event.preventDefault();
this.plugin.openPeriodNoteInLeaf('month', displayDate, this.app.workspace.getLeaf('tab'));
this.plugin.openPeriodNote('month', displayDate, this.app.workspace.getLeaf('tab'));
}
});
@@ -118,10 +132,10 @@ export class WaypointView extends ItemView {
yEl.addEventListener('click', () => {
this.plugin.openPeriodNote('year', displayDate);
});
yEl.addEventListener('mousedown', (event: MouseEvent) => {
yEl.addEventListener('mousedown', (event: MouseEvent) => {
if (event.button === 1) {
event.preventDefault();
this.plugin.openPeriodNoteInLeaf('year', displayDate, this.app.workspace.getLeaf('tab'));
this.plugin.openPeriodNote('year', displayDate, this.app.workspace.getLeaf('tab'));
}
});
@@ -166,21 +180,28 @@ export class WaypointView extends ItemView {
this.plugin.settings.calendar.firstDayOfWeek,
);
const systemIndicators = this.plugin.settings.calendar.showNoteIndicators
&& this.plugin.settings.calendar.indicatorMode === 'systems'
? this.plugin.getDateSystemIndicators(weeks.reduce<moment.Moment[]>(
(dates, week) => dates.concat(week.days.map(day => day.date)),
[],
))
: null;
for (const week of weeks) {
const row = tbody.createEl('tr');
// Week number cell
const wnCell = row.createEl('td', { cls: 'waypoint-weeknum' });
wnCell.setText(String(week.weekNumber));
const weekStart = week.days[0].date;
wnCell.addEventListener('click', () => {
const monday = week.days[0].date;
this.plugin.openPeriodNote('week', monday);
this.plugin.openPeriodNote('week', weekStart);
});
wnCell.addEventListener('mousedown', (event: MouseEvent) => {
if (event.button === 1) {
event.preventDefault();
const file = this.app.workspace.getLeaf('tab');
this.plugin.openPeriodNoteInLeaf('week', monday, file);
this.plugin.openPeriodNote('week', weekStart, this.app.workspace.getLeaf('tab'));
}
});
@@ -197,11 +218,20 @@ export class WaypointView extends ItemView {
if (this.plugin.settings.calendar.showNoteIndicators) {
const dateStr = day.date.format('YYYY-MM-DD');
const hasNote = this.plugin.app.vault.getFiles().some(
f => f.extension === 'md' && f.basename === dateStr,
);
if (hasNote) {
cell.addClass('has-note');
if (this.plugin.settings.calendar.indicatorMode === 'any') {
if (this.plugin.hasNoteForDate(dateStr)) {
cell.addClass('has-note');
}
} else {
const indicators = systemIndicators?.get(dateStr) || [];
if (indicators.length > 0) {
const dots = cell.createDiv({ cls: 'waypoint-day-indicators' });
for (const indicator of indicators) {
const dot = dots.createSpan({ cls: 'waypoint-day-indicator' });
dot.style.setProperty('--waypoint-indicator-color', indicator.color);
setTooltip(dot, indicator.name);
}
}
}
}
@@ -211,10 +241,14 @@ export class WaypointView extends ItemView {
cell.addEventListener('mousedown', (event: MouseEvent) => {
if (event.button === 1) {
event.preventDefault();
const file = this.app.workspace.getLeaf('tab');
this.plugin.openPeriodNoteInLeaf('day', day.date, file);
this.plugin.openPeriodNote('day', day.date, this.app.workspace.getLeaf('tab'));
}
});
cell.addEventListener('contextmenu', (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
this.showDayContextMenu(event, day.date);
});
}
}
@@ -227,6 +261,79 @@ export class WaypointView extends ItemView {
this.redraw();
}
/**
* Right-click menu for a day cell: every date system's existing notes for
* that date, plus the actions that would create the ones it is missing.
*/
private showDayContextMenu(event: MouseEvent, date: moment.Moment): void {
const menu = new Menu();
menu.addItem((i) => i.setTitle(date.format('dddd, MMMM D, YYYY')).setIsLabel(true));
for (const bucket of this.plugin.findDateSystemNotes(date)) {
// Many-per-date systems can always take another note; one-per-date
// systems only offer creation while their single note is missing.
const canCreate = bucket.multiple || bucket.notes.length === 0;
if (bucket.notes.length + (canCreate ? 1 : 0) === 0) continue;
menu.addSeparator();
for (const note of bucket.notes) {
menu.addItem((i) =>
i
.setTitle(note.label)
.setIcon(bucket.system.icon)
.onClick((evt) => this.focusFile(note.file, Keymap.isModEvent(evt))),
);
}
if (!canCreate) continue;
const noun = bucket.system.name.toLowerCase();
menu.addItem((i) =>
i
// The ellipsis promises the prompt that a free-text title needs.
.setTitle(bucket.multiple ? `New ${noun} note…` : `New ${noun} note`)
.setIcon('plus')
.onClick((evt) => this.createDateSystemNote(bucket, date, Keymap.isModEvent(evt))),
);
}
menu.showAtPosition({ x: event.clientX, y: event.clientY });
}
/**
* Creates a date system's note, asking for a title first when the system
* holds many notes per date. A held modifier opens the result in a new pane.
*/
private createDateSystemNote(bucket: DateSystemNotes, date: moment.Moment, newLeaf: PaneType | boolean): void {
if (!bucket.multiple) {
void this.plugin.openDateSystemNote(bucket.system, date, {
leaf: newLeaf ? this.app.workspace.getLeaf(newLeaf) : undefined,
});
return;
}
const name = bucket.system.name;
new PromptModal(
this.app,
{
title: `New ${name.toLowerCase()} note`,
placeholder: `${name} with…`,
cta: 'Create',
},
(title) => {
// `getLeaf` materialises the pane immediately, so the target pane is
// resolved only after the title is confirmed — cancelling the prompt
// must not leave an empty tab behind.
void this.plugin.openDateSystemNote(bucket.system, date, {
title,
leaf: newLeaf ? this.app.workspace.getLeaf(newLeaf) : undefined,
});
},
).open();
}
// ════════════════════════════════════════
// Recent Files panel
// ════════════════════════════════════════
@@ -241,14 +348,34 @@ export class WaypointView extends ItemView {
}
// ── Type filter bar ──
const typeCounts: Record<string, number> = {};
for (const file of this.plugin.recentFiles) {
const tfile = this.app.vault.getAbstractFileByPath(file.path);
if (tfile instanceof TFile) {
const cache = this.app.metadataCache.getFileCache(tfile);
const type = cache?.frontmatter?.type;
if (type && typeof type === 'string') {
typeCounts[type] = (typeCounts[type] || 0) + 1;
const configuredTags = this.plugin.settings.recentFiles.filterTags || [];
let typeCounts: Record<string, number> = {};
if (configuredTags.length > 0) {
// Use configured tags — count occurrences in recent files
for (const tag of configuredTags) {
typeCounts[tag] = 0;
}
for (const file of this.plugin.recentFiles) {
const tfile = this.app.vault.getAbstractFileByPath(file.path);
if (tfile instanceof TFile) {
const cache = this.app.metadataCache.getFileCache(tfile);
const type = cache?.frontmatter?.type;
if (type && typeof type === 'string' && typeCounts.hasOwnProperty(type)) {
typeCounts[type]++;
}
}
}
} else {
// Auto-detect from frontmatter `type` property
for (const file of this.plugin.recentFiles) {
const tfile = this.app.vault.getAbstractFileByPath(file.path);
if (tfile instanceof TFile) {
const cache = this.app.metadataCache.getFileCache(tfile);
const type = cache?.frontmatter?.type;
if (type && typeof type === 'string') {
typeCounts[type] = (typeCounts[type] || 0) + 1;
}
}
}
}
@@ -262,7 +389,9 @@ export class WaypointView extends ItemView {
this.redraw();
});
// Type pills
const sortedTypes = Object.entries(typeCounts).sort(([,a], [,b]) => b - a);
const sortedTypes = configuredTags.length > 0
? Object.entries(typeCounts) // preserve configured order
: Object.entries(typeCounts).sort((a, b) => b[1] - a[1]); // sort by count
for (const [type, count] of sortedTypes) {
const pill = filterBar.createSpan({ cls: `waypoint-recent-pill${this.recentFilesFilter === type ? ' is-active' : ''}` });
pill.setText(`${type} ${count}`);
@@ -317,8 +446,9 @@ export class WaypointView extends ItemView {
navFileTitle.addEventListener('dragstart', (event: DragEvent) => {
const tfile = this.app.metadataCache.getFirstLinkpathDest(file.path, '');
if (tfile) {
(this.app as any).dragManager.dragFile(event, tfile);
(this.app as any).dragManager.onDragStart(event, (this.app as any).dragManager.dragFile(event, tfile));
const dragManager = getDragManager(this.app);
const draggable = dragManager.dragFile(event, tfile);
dragManager.onDragStart(event, draggable);
}
});
@@ -376,10 +506,10 @@ export class WaypointView extends ItemView {
}
}
private focusFile(file: { path: string; basename: string }, newLeaf: boolean | string | 'split'): void {
private focusFile(file: { path: string; basename: string }, newLeaf: PaneType | boolean): void {
const targetFile = this.app.vault.getFiles().find(f => f.path === file.path);
if (targetFile) {
const leaf = this.app.workspace.getLeaf(newLeaf as any);
const leaf = this.app.workspace.getLeaf(newLeaf);
leaf.openFile(targetFile);
} else {
new Notice('Cannot find file');
@@ -477,45 +607,7 @@ export class WaypointView extends ItemView {
rowEl.style.cursor = 'grab';
// Drag events
rowEl.addEventListener('dragstart', (e) => {
this.dragId = item.id;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', item.id);
rowEl.addClass('waypoint-bm-dragging');
});
rowEl.addEventListener('dragend', () => {
this.dragId = null;
container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below, .waypoint-bm-drop-into').forEach(el => {
el.removeClass('waypoint-bm-dragging');
el.removeClass('waypoint-bm-drop-line');
el.removeClass('waypoint-bm-drop-below');
});
});
rowEl.addEventListener('dragenter', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e, false);
});
rowEl.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e, false);
});
rowEl.addEventListener('dragleave', () => {
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
rowEl.removeClass('waypoint-bm-drop-into');
});
rowEl.addEventListener('drop', (e) => {
e.preventDefault();
this.dragId = null;
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
const draggedId = e.dataTransfer?.getData('text/plain');
if (!draggedId || draggedId === item.id) return;
const dropAbove = (rowEl as any).__dropAbove;
this.moveBookmarkToPosition(draggedId, item.id, dropAbove);
});
this.attachBookmarkDragHandlers(rowEl, container, item, false);
// Context menu
rowEl.addEventListener('contextmenu', (event: MouseEvent) => {
@@ -536,45 +628,7 @@ export class WaypointView extends ItemView {
rowEl.style.cursor = 'grab';
// Drag events
rowEl.addEventListener('dragstart', (e) => {
this.dragId = item.id;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', item.id);
rowEl.addClass('waypoint-bm-dragging');
});
rowEl.addEventListener('dragend', () => {
this.dragId = null;
container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below, .waypoint-bm-drop-into').forEach(el => {
el.removeClass('waypoint-bm-dragging');
el.removeClass('waypoint-bm-drop-line');
el.removeClass('waypoint-bm-drop-below');
});
});
rowEl.addEventListener('dragenter', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e, false);
});
rowEl.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e, false);
});
rowEl.addEventListener('dragleave', () => {
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
rowEl.removeClass('waypoint-bm-drop-into');
});
rowEl.addEventListener('drop', (e) => {
e.preventDefault();
this.dragId = null;
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
const draggedId = e.dataTransfer?.getData('text/plain');
if (!draggedId || draggedId === item.id) return;
const dropAbove = (rowEl as any).__dropAbove;
this.moveBookmarkToPosition(draggedId, item.id, dropAbove);
});
this.attachBookmarkDragHandlers(rowEl, container, item, false);
// Context menu
rowEl.addEventListener('contextmenu', (event: MouseEvent) => {
@@ -597,65 +651,7 @@ export class WaypointView extends ItemView {
}
// ── Drag events ──
rowEl.addEventListener('dragstart', (e) => {
this.dragId = item.id;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', item.id);
rowEl.addClass('waypoint-bm-dragging');
});
const canAcceptChildren = true; // all file/group bookmarks can accept drops
rowEl.addEventListener('dragend', () => {
this.dragId = null;
container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below, .waypoint-bm-drop-into').forEach(el => {
el.removeClass('waypoint-bm-dragging');
el.removeClass('waypoint-bm-drop-line');
el.removeClass('waypoint-bm-drop-below');
el.removeClass('waypoint-bm-drop-into');
});
});
rowEl.addEventListener('dragenter', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e, canAcceptChildren);
});
rowEl.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e, canAcceptChildren);
});
rowEl.addEventListener('dragleave', () => {
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
rowEl.removeClass('waypoint-bm-drop-into');
});
rowEl.addEventListener('drop', (e) => {
e.preventDefault();
this.dragId = null;
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
rowEl.removeClass('waypoint-bm-drop-into');
const draggedId = e.dataTransfer?.getData('text/plain');
if (!draggedId || draggedId === item.id) return;
const dropInto = (rowEl as any).__dropInto;
if (dropInto && canAcceptChildren) {
if (isGroup) {
this.moveBookmarkToGroup(draggedId, item.id);
} else {
this.createParentNoteAndMove(draggedId, item.id);
}
} else {
const dropAbove = (rowEl as any).__dropAbove;
this.moveBookmarkToPosition(draggedId, item.id, dropAbove);
}
});
this.attachBookmarkDragHandlers(rowEl, container, item, true);
// ── Group: chevron + icon + label ──
if (isGroup) {
@@ -677,7 +673,7 @@ export class WaypointView extends ItemView {
const tfile = this.app.vault.getFileByPath(item.filePath);
if (tfile) {
const newLeaf = Keymap.isModEvent(event);
this.app.workspace.getLeaf(newLeaf as any).openFile(tfile);
this.app.workspace.getLeaf(newLeaf).openFile(tfile);
return;
}
}
@@ -729,7 +725,7 @@ export class WaypointView extends ItemView {
const tfile = this.app.vault.getFileByPath(item.filePath);
if (tfile) {
const newLeaf = Keymap.isModEvent(event);
this.app.workspace.getLeaf(newLeaf as any).openFile(tfile);
this.app.workspace.getLeaf(newLeaf).openFile(tfile);
} else {
new Notice('File not found');
this.plugin.removeBookmark(item.id);
@@ -913,6 +909,75 @@ export class WaypointView extends ItemView {
menu.showAtPosition({ x: event.clientX, y: event.clientY });
}
/**
* Attach the shared bookmark drag-and-drop listeners to a row element.
* `canAcceptChildren` selects 3-zone (above/into/below) drop targeting for
* file and group rows; separators and spacers use 2-zone (above/below).
*/
private attachBookmarkDragHandlers(
rowEl: HTMLElement,
container: HTMLElement,
item: BookmarkItem,
canAcceptChildren: boolean,
): void {
const clearIndicators = (): void => {
rowEl.removeClass('waypoint-bm-drop-line');
rowEl.removeClass('waypoint-bm-drop-below');
rowEl.removeClass('waypoint-bm-drop-into');
};
rowEl.addEventListener('dragstart', (e) => {
this.dragId = item.id;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', item.id);
rowEl.addClass('waypoint-bm-dragging');
});
rowEl.addEventListener('dragend', () => {
this.dragId = null;
container.querySelectorAll('.waypoint-bm-dragging, .waypoint-bm-drop-line, .waypoint-bm-drop-below, .waypoint-bm-drop-into').forEach(el => {
el.removeClass('waypoint-bm-dragging');
el.removeClass('waypoint-bm-drop-line');
el.removeClass('waypoint-bm-drop-below');
el.removeClass('waypoint-bm-drop-into');
});
});
rowEl.addEventListener('dragenter', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e, canAcceptChildren);
});
rowEl.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this.dragId || this.dragId === item.id) return;
this.showDropIndicator(rowEl, e, canAcceptChildren);
});
rowEl.addEventListener('dragleave', clearIndicators);
rowEl.addEventListener('drop', (e) => {
e.preventDefault();
this.dragId = null;
clearIndicators();
const draggedId = e.dataTransfer?.getData('text/plain');
if (!draggedId || draggedId === item.id) return;
const zone = this.dropZones.get(rowEl);
if (canAcceptChildren && zone?.into) {
if (item.type === 'group') {
this.moveBookmarkToGroup(draggedId, item.id);
} else {
this.createParentNoteAndMove(draggedId, item.id);
}
} else {
this.moveBookmarkToPosition(draggedId, item.id, zone?.above ?? false);
}
});
}
private showDropIndicator(el: HTMLElement, e: MouseEvent, isGroupLike: boolean): void {
// Clear all indicators
const parent = el.parentElement;
@@ -934,25 +999,21 @@ export class WaypointView extends ItemView {
if (y < topThreshold) {
el.addClass('waypoint-bm-drop-line');
(el as any).__dropAbove = true;
(el as any).__dropInto = false;
this.dropZones.set(el, { above: true, into: false });
} else if (y > bottomThreshold) {
el.addClass('waypoint-bm-drop-line');
el.addClass('waypoint-bm-drop-below');
(el as any).__dropAbove = false;
(el as any).__dropInto = false;
this.dropZones.set(el, { above: false, into: false });
} else {
el.addClass('waypoint-bm-drop-into');
(el as any).__dropAbove = false;
(el as any).__dropInto = true;
this.dropZones.set(el, { above: false, into: true });
}
} else {
// 2-zone: top half = above, bottom half = below
const above = y < rect.top + rect.height / 2;
el.addClass('waypoint-bm-drop-line');
if (!above) el.addClass('waypoint-bm-drop-below');
(el as any).__dropAbove = above;
(el as any).__dropInto = false;
this.dropZones.set(el, { above, into: false });
}
}
@@ -1099,7 +1160,7 @@ export class WaypointView extends ItemView {
}
private promptRename(item: BookmarkItem): void {
new RenameModal(this.app, item.label, (newLabel) => {
new PromptModal(this.app, { title: 'Rename bookmark', initialValue: item.label }, (newLabel) => {
if (newLabel && newLabel.trim()) {
this.plugin.updateBookmark(item.id, { label: newLabel.trim() });
}
@@ -1108,31 +1169,40 @@ export class WaypointView extends ItemView {
private promptIcon(item: BookmarkItem): void {
new IconSuggestModal(this.app, item.icon, (iconName) => {
if (iconName) {
this.plugin.updateBookmark(item.id, { icon: iconName });
}
// An empty string is a valid value meaning "no icon", so always apply.
this.plugin.updateBookmark(item.id, { icon: iconName });
}).open();
}
}
// ── Rename modal ──
// ── Text prompt modal ──
class RenameModal extends Modal {
private currentValue: string;
interface PromptModalOptions {
title: string;
placeholder?: string;
initialValue?: string;
/** Submit button label. Defaults to 'Save'. */
cta?: string;
}
/** Single-line text prompt, shared by bookmark renaming and note creation. */
class PromptModal extends Modal {
private options: PromptModalOptions;
private onSubmit: (value: string) => void;
constructor(app: App, currentValue: string, onSubmit: (value: string) => void) {
constructor(app: App, options: PromptModalOptions, onSubmit: (value: string) => void) {
super(app);
this.currentValue = currentValue;
this.options = options;
this.onSubmit = onSubmit;
}
onOpen(): void {
this.titleEl.setText('Rename bookmark');
this.titleEl.setText(this.options.title);
const input = this.contentEl.createEl('input', {
type: 'text',
value: this.currentValue,
value: this.options.initialValue ?? '',
placeholder: this.options.placeholder ?? '',
});
input.style.width = '100%';
input.style.marginBottom = '12px';
@@ -1145,7 +1215,7 @@ class RenameModal extends Modal {
cancelBtn.style.marginRight = '8px';
cancelBtn.addEventListener('click', () => this.close());
const saveBtn = btnContainer.createEl('button', { text: 'Save', cls: 'mod-cta' });
const saveBtn = btnContainer.createEl('button', { text: this.options.cta ?? 'Save', cls: 'mod-cta' });
saveBtn.addEventListener('click', () => {
this.onSubmit(input.value);
this.close();
@@ -1166,6 +1236,31 @@ class RenameModal extends Modal {
// ── Icon picker modal (full Lucide icon set, grid layout) ──
// Lucide icon metadata, fetched at most once per session and shared by every
// picker. Concurrent opens await the same in-flight promise; a failed fetch is
// not cached, so a later open retries once the network is back.
let iconCatalog: Promise<Record<string, string[]>> | null = null;
function loadIconCatalog(): Promise<Record<string, string[]>> {
if (!iconCatalog) {
iconCatalog = (async () => {
try {
const res = await fetch('https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json');
return (await res.json()) as Record<string, string[]>;
} catch {
try {
const res = await fetch('https://lucide.dev/api/tags');
return (await res.json()) as Record<string, string[]>;
} catch {
iconCatalog = null;
return FALLBACK_ICONS;
}
}
})();
}
return iconCatalog;
}
class IconSuggestModal extends Modal {
private onSubmit: (icon: string) => void;
private selected: string;
@@ -1241,15 +1336,6 @@ class IconSuggestModal extends Modal {
const statusEl = statusRow.createSpan();
statusEl.setText('Loading\u2026');
const clearEl = statusRow.createSpan();
clearEl.style.cursor = 'var(--cursor)';
clearEl.style.color = 'var(--text-accent)';
clearEl.setText('No icon');
clearEl.addEventListener('click', () => {
this.onSubmit('');
this.close();
});
// ── Load icons ──
this.loadIcons().then(() => {
this.loaded = true;
@@ -1258,7 +1344,7 @@ class IconSuggestModal extends Modal {
});
// ── Render grid ──
let debounce: any = null;
let debounce: number | undefined;
const renderGrid = (query: string) => {
grid.empty();
@@ -1284,16 +1370,15 @@ class IconSuggestModal extends Modal {
return;
}
for (var i = 0; i < matches.length; i++) {
var name = matches[i];
var tile = grid.createDiv();
for (const name of matches) {
const tile = grid.createDiv();
tile.setAttr('data-icon', name);
tile.style.display = 'flex';
tile.style.alignItems = 'center';
tile.style.justifyContent = 'center';
tile.style.aspectRatio = '1';
tile.style.borderRadius = '6px';
tile.style.cursor = 'var(--cursor)';
tile.style.cursor = 'var(--cursor-link, pointer)';
tile.style.transition = 'background 80ms';
tile.setAttr('title', name);
@@ -1304,60 +1389,67 @@ class IconSuggestModal extends Modal {
tile.style.color = 'var(--text-muted)';
}
var svg = tile.createSpan();
const svg = tile.createSpan();
svg.style.display = 'flex';
setIcon(svg, name);
;(function(_self, _tile, _name, _query, _grid, _previewIcon, _previewLabel) {
_tile.addEventListener('mouseenter', function() {
if (_name !== _self.selected) _tile.style.background = 'var(--background-modifier-hover)';
});
_tile.addEventListener('mouseleave', function() {
if (_name !== _self.selected) _tile.style.background = '';
});
tile.addEventListener('mouseenter', () => {
if (name !== this.selected) tile.style.background = 'var(--background-modifier-hover)';
});
tile.addEventListener('mouseleave', () => {
if (name !== this.selected) tile.style.background = '';
});
_tile.addEventListener('click', function() {
_self.selected = _name;
renderGrid(_query);
_previewIcon.empty();
setIcon(_previewIcon, _name);
_previewLabel.setText(_name);
_grid.querySelectorAll('div[data-icon]').forEach(function(_el) {
var el = _el as HTMLElement;
if (el.getAttr('data-icon') === _name) {
el.style.background = 'var(--interactive-accent)';
el.style.color = 'var(--text-on-accent)';
} else {
el.style.background = '';
el.style.color = 'var(--text-muted)';
}
});
tile.addEventListener('click', () => {
this.selected = name;
renderGrid(query);
previewIcon.empty();
setIcon(previewIcon, name);
previewLabel.setText(name);
grid.querySelectorAll('div[data-icon]').forEach((node) => {
const el = node as HTMLElement;
if (el.getAttr('data-icon') === name) {
el.style.background = 'var(--interactive-accent)';
el.style.color = 'var(--text-on-accent)';
} else {
el.style.background = '';
el.style.color = 'var(--text-muted)';
}
});
})(this, tile, name, query, grid, previewIcon, previewLabel);
});
}
statusEl.setText(matches.length + ' of ' + this.allIcons.length + ' icons');
};
input.addEventListener('input', function() {
if (debounce !== null) clearTimeout(debounce);
debounce = setTimeout(function() { renderGrid(input.value); }, 60);
input.addEventListener('input', () => {
window.clearTimeout(debounce);
debounce = window.setTimeout(() => renderGrid(input.value), 60);
});
input.addEventListener('keydown', function(e: KeyboardEvent) {
input.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') this.close();
}.bind(this));
});
// ── Buttons ──
var btns = modal.createDiv({ cls: 'modal-button-container' });
var cancel = btns.createEl('button', { text: 'Cancel' });
cancel.addEventListener('click', function() { this.close(); }.bind(this));
var saveBtn = btns.createEl('button', { text: 'Save', cls: 'mod-cta' });
const btns = modal.createDiv({ cls: 'modal-button-container' });
// Clearing the icon commits a value and closes, exactly like Save, so it
// belongs with the buttons rather than as a bare span in the status row.
const clearBtn = btns.createEl('button', { text: 'No icon', cls: 'waypoint-icon-clear' });
clearBtn.addEventListener('click', () => {
this.onSubmit('');
this.close();
});
const cancel = btns.createEl('button', { text: 'Cancel' });
cancel.addEventListener('click', () => this.close());
const saveBtn = btns.createEl('button', { text: 'Save', cls: 'mod-cta' });
saveBtn.style.marginLeft = '8px';
saveBtn.addEventListener('click', function() {
saveBtn.addEventListener('click', () => {
this.onSubmit(this.selected);
this.close();
}.bind(this));
});
}
onClose(): void {
@@ -1365,22 +1457,9 @@ class IconSuggestModal extends Modal {
}
private async loadIcons(): Promise<void> {
try {
var r = await fetch('https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json');
var d = await r.json();
this.tagsMap = d as Record<string, string[]>;
this.allIcons = Object.keys(d).sort();
} catch (_e) {
try {
var r2 = await fetch('https://lucide.dev/api/tags');
var d2 = await r2.json();
this.tagsMap = d2 as Record<string, string[]>;
this.allIcons = Object.keys(d2).sort();
} catch (_e2) {
this.tagsMap = {};
this.allIcons = Object.keys(FALLBACK_ICONS).sort();
}
}
const catalog = await loadIconCatalog();
this.tagsMap = catalog;
this.allIcons = Object.keys(catalog).sort();
}
}
+107 -29
View File
@@ -36,7 +36,7 @@
margin-left: auto;
background: none;
border: none;
cursor: var(--cursor);
cursor: var(--cursor-link, pointer);
color: var(--text-faint);
padding: 2px 4px;
border-radius: 4px;
@@ -58,29 +58,34 @@
.waypoint-calendar {
font-size: var(--font-ui-small);
padding: 8px;
border: 1px solid var(--background-modifier-border);
border-radius: 10px;
background: var(--background-primary-alt);
}
.waypoint-calendar-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
margin-bottom: 8px;
}
.waypoint-calendar-breadcrumb {
display: flex;
align-items: center;
gap: 6px;
gap: 4px;
font-size: var(--font-ui-medium);
font-weight: var(--font-semibold);
padding: 4px 0;
padding: 2px 0;
}
.waypoint-calendar-breadcrumb .waypoint-clickable {
color: var(--text-muted);
cursor: var(--cursor);
padding: 1px 4px;
cursor: var(--cursor-link, pointer);
padding: 2px 4px;
border-radius: 4px;
transition: color 80ms, background-color 80ms;
}
.waypoint-calendar-breadcrumb .waypoint-clickable:hover {
@@ -90,23 +95,25 @@
.waypoint-calendar .waypoint-separator {
color: var(--text-faint);
margin: 0 2px;
margin: 0;
}
.waypoint-calendar-today-group {
display: flex;
align-items: center;
gap: 2px;
gap: 1px;
}
.waypoint-calendar-today-group button {
background: none;
border: none;
cursor: var(--cursor);
padding: 2px 6px;
cursor: var(--cursor-link, pointer);
padding: 2px 5px;
border-radius: 4px;
color: var(--text-muted);
font-size: var(--font-ui-small);
transition: color 80ms, background-color 80ms;
box-shadow: none;
}
.waypoint-calendar-today-group button:hover {
@@ -121,30 +128,34 @@
.waypoint-calendar table {
table-layout: fixed;
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 2px 3px;
}
.waypoint-calendar th,
.waypoint-calendar td {
text-align: center;
padding: 4px 2px;
padding: 0;
font-size: var(--font-ui-small);
line-height: 1.6;
}
.waypoint-calendar th {
padding-bottom: 2px;
font-weight: var(--font-medium);
color: var(--text-faint);
font-size: calc(var(--font-ui-small) * 0.85);
font-size: calc(var(--font-ui-small) * 0.78);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.waypoint-calendar .waypoint-weeknum {
font-size: calc(var(--font-ui-small) * 0.75);
width: 18px;
font-size: calc(var(--font-ui-small) * 0.72);
color: var(--text-faint);
font-weight: var(--font-light);
cursor: var(--cursor);
padding: 2px 0;
cursor: var(--cursor-link, pointer);
border-radius: 4px;
transition: color 80ms, background-color 80ms;
}
.waypoint-calendar .waypoint-weeknum:hover {
@@ -153,42 +164,72 @@
}
.waypoint-calendar .waypoint-day {
cursor: var(--cursor);
min-height: var(--wp-cal-cell-size, 32px);
padding: 2px 0;
cursor: var(--cursor-link, pointer);
height: var(--wp-cal-cell-size, 32px);
box-sizing: border-box;
padding: 2px 0 7px;
border-radius: 6px;
position: relative;
width: 100%;
vertical-align: middle;
transition: color 80ms, background-color 80ms, box-shadow 80ms;
}
.waypoint-calendar .waypoint-day:hover {
background-color: var(--background-modifier-active-hover);
background-color: var(--background-modifier-hover);
}
.waypoint-calendar .waypoint-day.other-month {
opacity: 0.35;
color: var(--text-faint);
opacity: 0.5;
}
.waypoint-calendar .waypoint-day.today {
color: var(--text-accent);
border: 1px solid var(--text-accent);
font-weight: var(--font-semibold);
box-shadow: inset 0 0 0 1px var(--text-accent);
}
.waypoint-calendar .waypoint-day.today:hover {
background-color: var(--background-modifier-active-hover);
}
/* Single-dot mode preserves the pre-date-systems indicator. */
.waypoint-calendar .waypoint-day.has-note::after {
content: '';
display: block;
position: absolute;
left: 50%;
bottom: 3px;
width: 4px;
height: 4px;
border-radius: 50%;
background-color: var(--text-faint);
margin: 0 auto;
transform: translateX(-50%);
}
.waypoint-calendar .waypoint-day.today.has-note::after {
background-color: var(--text-accent);
}
/* Colour-by-system mode: one dot per matching system, in Settings order. */
.waypoint-day-indicators {
position: absolute;
right: 0;
bottom: 3px;
left: 0;
display: flex;
justify-content: center;
gap: 3px;
}
.waypoint-day-indicator {
width: 5px;
height: 5px;
border-radius: 50%;
background-color: var(--waypoint-indicator-color);
box-shadow: 0 0 0 1px var(--background-primary);
}
/* ── Recent Files ── */
.waypoint-recent-filter {
@@ -204,7 +245,7 @@
background: var(--background-modifier-hover);
padding: 1px 6px;
border-radius: 10px;
cursor: var(--cursor);
cursor: var(--cursor-link, pointer);
transition: color 80ms, background 80ms;
user-select: none;
}
@@ -235,7 +276,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;
@@ -262,7 +303,7 @@
padding: 2px 8px 2px 4px;
font-size: var(--wp-font-size, 13px);
min-height: var(--wp-row-size, 26px);
cursor: var(--cursor);
cursor: var(--cursor-link, pointer);
border-radius: 4px;
user-select: none;
position: relative;
@@ -309,6 +350,27 @@
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 {
@@ -370,7 +432,7 @@
.waypoint-settings-tab {
background: none;
border: none;
cursor: var(--cursor);
cursor: var(--cursor-link, pointer);
color: var(--text-muted);
padding: 6px 14px;
border-radius: 6px 6px 0 0;
@@ -390,3 +452,19 @@
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);
}
/* ── Settings: date systems ── */
.waypoint-settings-warning {
margin-top: 4px;
color: var(--text-error);
font-size: var(--font-ui-smaller);
}
+2 -1
View File
@@ -15,7 +15,8 @@
"DOM",
"ES5",
"ES6",
"ES7"
"ES7",
"ES2017"
],
"paths": {
"src/*": ["./src/*"]