Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12209155dc | |||
| e027ac4a9e | |||
| 26a1a481ac | |||
| b617c275eb | |||
| bb842b55b4 | |||
| 0eb529e604 | |||
| ece066caf8 | |||
| b064a64142 | |||
| ec3ad7d384 | |||
| d22b53253e | |||
| 61c191505e | |||
| c5282dbdef | |||
| dc9e4c3200 | |||
| 6a1a044566 | |||
| 3c7b6741f5 | |||
| abf4d6ccf7 | |||
| 141121878f | |||
| 6a89660cf4 | |||
| 44c2dd2abc | |||
| 49c8da9863 | |||
| 993017f669 | |||
| e0c3deee86 | |||
| 7331a455ad | |||
| 7a5be506e0 | |||
| 765c6f3b47 | |||
| 846726a5c7 | |||
| e92903a7aa | |||
| 7bf1223673 | |||
| a36c8ab6f4 | |||
| eeb6059fe5 | |||
| 2e8ac7047f | |||
| a692dc9a34 | |||
| 6978401613 | |||
| 3c4fc2d91c | |||
| 4278507ae2 | |||
| 99a3ea80ae | |||
| cb09ec0e83 |
@@ -0,0 +1,501 @@
|
||||
# Waypoint Sidebar — Technical Documentation
|
||||
|
||||
> Obsidian plugin (id: `waypoint-sidebar`) providing a unified sidebar with calendar, recent files, and custom bookmarks. Replaces separate Calendar and Bookmarks core plugins.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.ts Plugin entry — lifecycle, commands, events, data persistence
|
||||
├── settings.ts Interface definitions + DEFAULT_SETTINGS / DEFAULT_DATE_SYSTEM constants
|
||||
├── settings-tab.ts PluginSettingTab UI — calendar, periodic, date systems, recent, display, about
|
||||
├── models/
|
||||
│ └── bookmark.ts BookmarkItem + WaypointData interfaces
|
||||
├── utils/
|
||||
│ ├── date-utils.ts Moment.js helpers: period formatting, month grid, navigation
|
||||
│ ├── date-systems.ts Pure date-system filename helpers (no `obsidian` import, unit-testable)
|
||||
│ └── path-utils.ts Pure path helper: rename remapping (no `obsidian` import, unit-testable)
|
||||
└── views/
|
||||
└── waypoint-view.ts ItemView subclass — full sidebar rendering + interaction
|
||||
```
|
||||
|
||||
**Build:** `npm run build` runs `tsc --noEmit` first, then esbuild bundles `src/main.ts` → `main.js`. The typecheck is a real gate — esbuild strips types without checking them, so without it type errors would ship. `npm run typecheck` runs the check alone; `npm run dev` (watch) stays ungated for speed. TypeScript strict-null + `noImplicitAny`, ESNext modules, ES6 target, `lib` floor at ES2017.
|
||||
|
||||
**Data persistence:** Single `data.json` via Obsidian `Plugin.loadData()/saveData()`. Top-level keys: `settings` (merged with `DEFAULT_SETTINGS`), `waypointData` (bookmark tree **and** recent files). Recent files are persisted under `waypointData.recentFiles` and reloaded on startup; writes are debounced 300ms so rapid file opens collapse into one save.
|
||||
|
||||
---
|
||||
|
||||
## Plugin Lifecycle
|
||||
|
||||
### `onload()`
|
||||
|
||||
1. **Load data** — reads `data.json` exactly once, then `applySettings(saved)` and `applyWaypointData(saved)` merge its partial data over defaults.
|
||||
2. **Register view** — `WAYPOINT_VIEW_TYPE = "waypoint-view"` maps to `WaypointView` factory `(leaf) => new WaypointView(leaf, this)`.
|
||||
3. **Settings tab** — `WaypointSettingTab` receives the live `settings` object + `() => this.redrawAll()` callback for live preview.
|
||||
4. **Register commands** (see Commands section below).
|
||||
5. **Register events** — `file-open`, `vault:create`, `vault:delete`, `vault:rename`, `vault:modify`.
|
||||
6. **Auto-open** — On `onLayoutReady`, if no existing leaves of the view type, opens one in the left sidebar.
|
||||
7. **Midnight refresh** — 10-minute `setInterval` checks if `new Date().toDateString()` changed; if so, calls `redrawAll()` (calendar day indicators refresh).
|
||||
|
||||
### `onunload()`
|
||||
|
||||
Detaches all leaves of `WAYPOINT_VIEW_TYPE`.
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
### WaypointSettings
|
||||
|
||||
```typescript
|
||||
interface WaypointSettings {
|
||||
calendar: CalendarSettings; // firstDayOfWeek (0=Sun,1=Mon), showNoteIndicators
|
||||
daily: PeriodNoteSettings;
|
||||
weekly: PeriodNoteSettings;
|
||||
monthly: PeriodNoteSettings;
|
||||
quarterly: PeriodNoteSettings;
|
||||
yearly: PeriodNoteSettings;
|
||||
dateSystems: DateSystemSettings[]; // day-scoped systems beyond the daily note, in menu order
|
||||
recentFiles: RecentFilesSettings; // maxItems, updateOn, omittedPaths[], omittedTags[], filterTags[]
|
||||
display: DisplaySettings; // px sizing for bookmark rows, fonts, icons, calendar cells
|
||||
}
|
||||
|
||||
interface PeriodNoteSettings {
|
||||
folder: string; // e.g. "periodic/daily"
|
||||
templateFile: string; // path without .md, e.g. "Templates/Daily note"
|
||||
nameFormat: string; // moment.js format string
|
||||
typeProperty: string; // frontmatter type value, e.g. "daily-note"
|
||||
}
|
||||
|
||||
interface CalendarSettings {
|
||||
firstDayOfWeek: number; // 0 = Sunday, 1 = Monday
|
||||
showNoteIndicators: boolean; // dot on days with existing .md files
|
||||
}
|
||||
|
||||
interface RecentFilesSettings {
|
||||
maxItems: number; // default 50
|
||||
updateOn: 'file-open' | 'file-edit'; // trigger mode
|
||||
omittedPaths: string[]; // regex patterns (one per line)
|
||||
omittedTags: string[]; // regex patterns for frontmatter/inline tags
|
||||
filterTags: string[]; // tags shown as filter pills (empty = auto-detect from frontmatter)
|
||||
}
|
||||
|
||||
interface DisplaySettings {
|
||||
rowSize: number; // px, base height of bookmark items (18–40)
|
||||
rowSpacing: number; // px, gap between items (0–12)
|
||||
indentSize: number; // px, indent per depth level (8–32)
|
||||
fontSize: number; // px, font size for bookmark labels (10–18)
|
||||
iconSize: number; // px, icon size (12–24)
|
||||
calendarCellSize: number; // px, calendar day cell height (20–48)
|
||||
}
|
||||
```
|
||||
|
||||
`dateSystems` holds the user's configured date systems in menu order — see the Date Systems section for the `DateSystemSettings` shape and the two systems shipped by default.
|
||||
|
||||
### BookmarkItem (recursive tree)
|
||||
|
||||
```typescript
|
||||
interface BookmarkItem {
|
||||
id: string; // "bm-{timestamp}-{random4}"
|
||||
type: 'file' | 'group' | 'separator' | 'spacer';
|
||||
label: string; // display text (empty for separator/spacer)
|
||||
filePath: string; // vault-relative path (empty for non-file types)
|
||||
icon: string; // Lucide icon name
|
||||
children: BookmarkItem[]; // nested items (groups contain children)
|
||||
collapsed: boolean; // group collapse state
|
||||
indent: number; // depth level (0 = root)
|
||||
}
|
||||
|
||||
interface WaypointData {
|
||||
bookmarks: BookmarkItem[];
|
||||
recentFiles: { path: string; basename: string }[];
|
||||
}
|
||||
```
|
||||
|
||||
The bookmark tree is stored under `waypointData.bookmarks` and rendered recursively. Groups contain children; files/separators/spacers are leaf nodes. `waypointData.recentFiles` holds the persisted recent-files list (mirrored into the live `plugin.recentFiles` array on load).
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
### Period note navigation (with default hotkeys)
|
||||
|
||||
| ID | Name | Hotkey |
|
||||
|---|---|---|
|
||||
| `waypoint-go-to-daily` | Go to daily note | `Mod+Shift+Alt+D` |
|
||||
| `waypoint-go-to-weekly` | Go to weekly note | `Mod+Shift+Alt+W` |
|
||||
| `waypoint-go-to-monthly` | Go to monthly note | `Mod+Shift+Alt+M` |
|
||||
| `waypoint-go-to-quarterly` | Go to quarterly note | `Mod+Shift+Alt+Q` |
|
||||
| `waypoint-go-to-yearly` | Go to yearly note | `Mod+Shift+Alt+Y` |
|
||||
|
||||
All call `openPeriodNote(period, moment())` — opens or creates the current period's note.
|
||||
|
||||
### Next/Previous period (no default hotkeys)
|
||||
|
||||
10 commands generated in a loop: `waypoint-go-to-{next|prev}-{daily|weekly|monthly|quarterly|yearly}`.
|
||||
|
||||
These call `navigatePeriodNote(direction)` which:
|
||||
1. Gets the active file's basename.
|
||||
2. Calls `detectPeriodType(basename)` — **settings-driven**, not hardcoded regexes. It reads `settings.{daily,weekly,monthly,quarterly,yearly}.nameFormat` and tries `moment(basename, nameFormat, true).isValid()` (strict parsing) in order day → week → month → quarter → year, returning the first match as `{ period, date }`. Periods with an empty `nameFormat` are skipped; returns `null` if nothing matches. Changing a name format in settings therefore keeps next/prev navigation working.
|
||||
3. Adds ±1 period (quarters add ±3 months).
|
||||
4. Calls `openPeriodNote` for the new date.
|
||||
|
||||
### Other commands
|
||||
|
||||
| ID | Name |
|
||||
|---|---|
|
||||
| `waypoint-open-view` | Open Waypoint sidebar |
|
||||
| `waypoint-add-bookmark` | Add current file as Waypoint bookmark |
|
||||
|
||||
---
|
||||
|
||||
## Date Systems
|
||||
|
||||
A **date system** is a folder of notes whose filenames begin with a date. Daily notes, a journal and meeting notes are all the same shape, so they share one settings model, one discovery pass and one creation path.
|
||||
|
||||
### `DateSystemSettings`
|
||||
|
||||
```typescript
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
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` | Notes per date |
|
||||
|---|---|---|---|---|
|
||||
| `journal` | Journal | `periodic/journal` | `YYYY-MM-DD - [Journal]` | one |
|
||||
| `meetings` | Meeting | `periodic/meetings` | `YYYY-MM-DD - {title}` | many |
|
||||
|
||||
### 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
|
||||
/** One existing note of a date system, with the label the menu should show. */
|
||||
interface DateSystemNote {
|
||||
file: TFile;
|
||||
label: string; // free-text title for many-per-date systems, else the system name
|
||||
}
|
||||
|
||||
/** A day-scoped system and the notes it already holds for one date. */
|
||||
interface DateSystemNotes {
|
||||
system: DateSystemSettings;
|
||||
notes: DateSystemNote[]; // sorted by basename; empty when the date has no note
|
||||
multiple: boolean; // nameFormat carries {title}, i.e. many notes per date
|
||||
}
|
||||
|
||||
dateSystems(): DateSystemSettings[]
|
||||
findDateSystemNotes(date: moment.Moment): DateSystemNotes[]
|
||||
openDateSystemNote(
|
||||
system: DateSystemSettings,
|
||||
date: moment.Moment,
|
||||
opts?: { title?: string; leaf?: WorkspaceLeaf },
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
**`dateSystems()`** — every day-scoped system in menu order: the daily periodic note, synthesized into a `DateSystemSettings` from `settings.daily`, followed by `settings.dateSystems`. The daily note is therefore not a special case in any consumer.
|
||||
|
||||
**`findDateSystemNotes(date)`** — **one** vault scan per call, not one per system. It formats each usable system's `before`/`after` affixes for `date` once, then walks the markdown file list a single time, bucketing each file into the system that claims it (`isInFolder` + `matchesSystemName`). Systems whose formats have no date token are omitted rather than offering an unsafe create action. It returns the remaining `DateSystemNotes` in `dateSystems()` order, each bucket sorted by basename. The calendar's day context menu is built from exactly one of these calls per right-click.
|
||||
|
||||
**`openDateSystemNote(system, date, opts)`** — the single opener for every dated note:
|
||||
|
||||
1. **Filename** — `splitNameFormat(system.nameFormat)`, format `before` and `after` against `date`, and for a many-per-date system join `sanitizeTitle(opts.title)` between them. Append `.md`; prepend `system.folder` when it is set.
|
||||
2. **Existing note** — a `vault.getFileByPath()` hit is opened as-is, never overwritten. A date that already has notes can still gain another in a many-per-date system, because the title makes the filename unique.
|
||||
3. **Missing note** — created via `createDatedNote`, then opened.
|
||||
4. **Leaf** — `opts.leaf` when supplied (middle-click and Ctrl/Cmd-click pass an explicit tab leaf), otherwise `workspace.getLeaf(false)`.
|
||||
|
||||
### Note creation (`createDatedNote`)
|
||||
|
||||
A single creation path shared by periodic notes and date systems; it replaced the period-only `createPeriodNote`.
|
||||
|
||||
1. With a non-empty `system.templateFile`, reads it (appending `.md` when omitted) and creates the note with its raw contents. This is for plain, non-Templater templates.
|
||||
2. With no template, the note is created with minimal frontmatter instead: `type: {system.typeProperty}` + `date: YYYY-MM-DD`. Use this mode with a Templater folder trigger, which replaces that fallback content with its rendered template.
|
||||
|
||||
---
|
||||
|
||||
## Period Note Creation (`openPeriodNote`)
|
||||
|
||||
```typescript
|
||||
openPeriodNote(
|
||||
period: 'day' | 'week' | 'month' | 'quarter' | 'year',
|
||||
date: moment.Moment,
|
||||
leaf?: WorkspaceLeaf,
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
Single unified opener — there is no separate `openPeriodNoteInLeaf`, and since the date-systems refactor there is no separate creation path either. `openPeriodNote` synthesizes the period's `PeriodNoteSettings` into a `DateSystemSettings` (`folder`, `nameFormat`, `templateFile` and `typeProperty` carry over verbatim; a period format never contains `{title}`, so a period maps to exactly one filename) and delegates to `openDateSystemNote(system, date, { leaf })`.
|
||||
|
||||
Filename building, the existence check, template-or-frontmatter creation and leaf selection are therefore documented once, under Date Systems above.
|
||||
|
||||
Middle-click handlers in the view pass an explicit tab leaf: `openPeriodNote(period, date, this.app.workspace.getLeaf('tab'))`.
|
||||
|
||||
---
|
||||
|
||||
## Event Handling
|
||||
|
||||
### `file-open`
|
||||
|
||||
Tracks the opened file into recent files **only** when `settings.recentFiles.updateOn === 'file-open'`.
|
||||
|
||||
### `vault:modify`
|
||||
|
||||
Tracks the modified file (guarded to `TFile`) into recent files **only** when `updateOn === 'file-edit'`. Exactly one of the two modes is active at a time, so `'file-edit'` is a working mode rather than a no-op.
|
||||
|
||||
### `vault:create` / `vault:delete`
|
||||
|
||||
Both update the markdown-basename set used for calendar note indicators, then trigger `broadcastRedraw()` — telling all WaypointView instances to re-render (needed for the indicators and for stale recent file references).
|
||||
|
||||
### `vault:rename`
|
||||
|
||||
1. Updates the markdown-basename set (old basename out, new basename in).
|
||||
2. Remaps the `recentFiles` entry through `remapRenamedPath(entry.path, oldPath, newPath)`.
|
||||
3. Remaps every bookmark `filePath` through the same helper.
|
||||
|
||||
`remapRenamedPath(path, oldPath, newPath)` (in `src/utils/path-utils.ts`) returns the updated path when `path` **is** `oldPath` (a plain file rename) *or* is nested under it (`path.startsWith(oldPath + '/')`, i.e. a folder rename/move), and `null` when the path is unaffected. This is why moving a folder no longer orphans the bookmarks inside it. The helper deliberately imports nothing from `'obsidian'` so it stays pure and unit-testable in plain node.
|
||||
|
||||
---
|
||||
|
||||
## Recent Files (persisted)
|
||||
|
||||
```typescript
|
||||
recentFiles: { path: string; basename: string }[]
|
||||
```
|
||||
|
||||
Backed by `waypointData.recentFiles` in `data.json`, so the list survives vault reload. `applyWaypointData(saved)` restores it and re-applies the current `maxItems` limit (in case the setting shrank since the last save).
|
||||
|
||||
**Update flow:**
|
||||
1. `addToRecentFiles(file)` — omission check, then dedupes (removes existing entry), prepends to front, truncates to `maxItems`.
|
||||
2. `persistRecentFiles()` — mirrors the array into `waypointData.recentFiles` and schedules a debounced (300ms) `saveWaypointData()`.
|
||||
3. Calls `broadcastRedraw()`.
|
||||
|
||||
**Omitted paths/tags:** Both filters are applied in `addToRecentFiles`. `omittedPaths` entries are treated as regexes tested against `file.path`; `omittedTags` entries are regexes tested against the file's tags, read via `getAllTags(metadataCache.getFileCache(file))` with the leading `#` stripped. An invalid regex is skipped rather than throwing.
|
||||
|
||||
**`enforceRecentFilesLimit()`:** Called from the settings-change callback — trims the list when `maxItems` is lowered and persists the result.
|
||||
|
||||
**On file not found:** When clicking a recent file that no longer exists, `focusFile()` shows a Notice and removes the stale entry from `recentFiles`.
|
||||
|
||||
---
|
||||
|
||||
## Sidebar View (`WaypointView`)
|
||||
|
||||
Extends `ItemView`. View type: `"waypoint-view"`, icon: `compass`.
|
||||
|
||||
### Rendering (`redraw`)
|
||||
|
||||
Called on every change. Full DOM rebuild — `contentEl.empty()` then three sections in order:
|
||||
|
||||
1. **Favorites** (`renderFavorites`) — bookmark tree
|
||||
2. **Recent Files** (`renderRecentFiles`) — flat list
|
||||
3. **Calendar** (`renderCalendar`) — month grid
|
||||
|
||||
The last section (`waypoint-section:last-child`) gets `margin-top: auto`, pushing it to the bottom of the sidebar.
|
||||
|
||||
### Calendar Panel
|
||||
|
||||
**State:** `currentDisplayMonth` (0-indexed), `currentDisplayYear` — allows navigating months independently of today.
|
||||
|
||||
**Layout:**
|
||||
|
||||
```
|
||||
┌────────────────────────────────────┐
|
||||
│ Q2 June 2026 ◀ Today ▶ │ ← breadcrumb (clickable) + nav
|
||||
├──┬───┬───┬───┬───┬───┬───┬───┤
|
||||
│24│sun│mon│tue│wed│thu│fri│sat│ ← header row + week# col
|
||||
├──┼───┼───┼───┼───┼───┼───┼───┤
|
||||
│25│ 1 │ 2 │ 3 │... ← day cells (clickable)
|
||||
└──┴───┴───┴───┴───┴───┴───┴───┘
|
||||
```
|
||||
|
||||
- **Breadcrumb:** Q-label, month name, year — each clickable to open that period note.
|
||||
- **Nav buttons:** ◀/▶ shift month ±1. "Today" resets to current month.
|
||||
- **Week number column:** Clicking a week number opens the weekly note for that week's Monday; middle-clicking opens it in a new tab.
|
||||
- **Day cells:** Left-click opens the daily note, middle-click opens it in a new tab, right-click opens the day context menu (below). `.other-month` dimmed. `.today` has accent border. `.has-note` gets a dot indicator, decided by the synchronous O(1) `plugin.hasNoteForDate(dateStr)` (a `Set` lookup — no per-cell vault scan).
|
||||
- **Grid generation:** `getMonthGrid(year, month, firstDayOfWeek)` in `date-utils.ts` produces up to 6 weeks, each with 7 `CalendarDay` objects containing `moment`, `dayOfMonth`, `isToday`, `isCurrentMonth`, `isoWeekNumber`.
|
||||
|
||||
**Day context menu (right-click):** Built from a single `plugin.findDateSystemNotes(day.date)` call. The first entry is a non-clickable header showing the full date. Then, per system in `dateSystems()` order:
|
||||
|
||||
- Each existing note, labelled with its `DateSystemNote.label` and the system's `icon`. Click opens it in the current leaf; Ctrl/Cmd-click opens it in a new tab.
|
||||
- A create entry (icon `plus`) labelled `New {lowercased system name} note`. Many-per-date systems always show one, with a trailing ellipsis because it first opens a `PromptModal` for the title (`New meeting note…`). A one-per-date system shows one without the ellipsis only while its note is missing (`New journal note`); once the note exists, the existing-note entry is all it gets.
|
||||
|
||||
Every label is derived from `system.name`, so a renamed or newly added system needs no view changes. Left-click and middle-click on the cell are untouched.
|
||||
|
||||
### Recent Files Panel
|
||||
|
||||
Renders `plugin.recentFiles` as Obsidian-native `nav-file` elements using the file explorer's CSS classes (`tree-item`, `nav-file-title`, `nav-file-title-content`).
|
||||
|
||||
**Per-file features:**
|
||||
- **Active indicator:** `.is-active` class if path matches `workspace.getActiveFile()`.
|
||||
- **Remove button:** × icon, appears on hover (`.waypoint-recent-remove`), drops the entry from `plugin.recentFiles`, calls `persistRecentFiles()`, then redraws.
|
||||
- **Drag:** Uses `app.dragManager.dragFile()` for native Obsidian drag.
|
||||
- **Hover preview:** Triggers `hover-link` event for Obsidian's page preview popup.
|
||||
- **Context menu:** "Open in new tab" + Obsidian's native `file-menu` event.
|
||||
- **Click:** `Keymap.isModEvent(event)` determines if opening in new leaf; otherwise same leaf.
|
||||
- **Middle click:** Opens in new tab.
|
||||
|
||||
### Bookmarks/Favorites Panel
|
||||
|
||||
**Empty state:** Right-click on "Waypoint Bookmarks" header shows add menu:
|
||||
- Add current file → `addBookmark(path, basename, 'file')`
|
||||
- New group → `addBookmark('', 'New Group', 'group', 'folder')`
|
||||
- Add separator → `addBookmark('', '', 'separator')`
|
||||
- Add spacer → `addBookmark('', '', 'spacer')`
|
||||
|
||||
**Rendering:** `renderBookmarkList(container, items, depth)` recursively renders items with `paddingLeft: 8 + depth * 16px` for indentation.
|
||||
|
||||
**Item types:**
|
||||
| Type | Render | Behavior |
|
||||
|---|---|---|
|
||||
| `file` | icon + label | Click opens file. Tooltip = filePath. |
|
||||
| `group` | chevron + icon + label | Click toggles `collapsed`. Children rendered in nested `waypoint-bookmark-children` div (hidden when collapsed). |
|
||||
| `separator` | horizontal line (::after pseudo-element) | No click handler. Context menu for delete. |
|
||||
| `spacer` | 14px empty div | No click handler. Context menu for delete. |
|
||||
|
||||
**Drag-and-drop:** All bookmark items are draggable. Drop indicators show a 3px accent border above/below the target. On drop:
|
||||
1. Remove dragged item from its current position (recursive search).
|
||||
2. Insert at the target position (before or after based on cursor Y position relative to target midY).
|
||||
3. If target not found (edge case), push to root.
|
||||
4. Save + redraw.
|
||||
|
||||
**Context menu (right-click):**
|
||||
- File items: "Open in new tab"
|
||||
- File/Group items: "Rename" (opens `PromptModal`), "Change icon" (opens `IconSuggestModal`)
|
||||
- "Move to group" submenu: Lists all available groups (excluding self + descendants) + "(Root)" for ungrouping
|
||||
- Group items: "Expand/Collapse", "Add bookmark here", "New sub-group"
|
||||
- All items: "Insert separator above/below", "Insert spacer above/below", "Remove"
|
||||
|
||||
**Move to group (`moveBookmarkToGroup`):**
|
||||
1. Remove item from current position.
|
||||
2. If targetGroupId: find group, set `item.indent = group.indent + 1`, push to group's children.
|
||||
3. If null (root): set `item.indent = 0`, push to `waypointData.bookmarks`.
|
||||
|
||||
**Prompt modal (`PromptModal`):** Simple Modal with a text input plus Cancel and CTA buttons. Enter key submits. Constructed as `new PromptModal(app, options, onSubmit)`, where `options` is `{ title, placeholder?, initialValue?, cta? }` and `cta` (the submit button label) defaults to `Save`. Generalized from the old rename-only `RenameModal` so the calendar's day context menu can reuse it to prompt for a note title.
|
||||
|
||||
**Icon picker (`IconSuggestModal`):** Modal with:
|
||||
- Live preview of selected icon.
|
||||
- Search input with 60ms debounce.
|
||||
- Grid of matching icons (max 80 shown), loaded from `https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json` with fallback to `https://lucide.dev/api/tags` and a hardcoded `FALLBACK_ICONS` object (~300 icons). The fetch result is cached for the session, so at most one network round-trip happens no matter how often the picker is opened.
|
||||
- Click to select, **No icon** button to clear, Save/Cancel buttons.
|
||||
|
||||
---
|
||||
|
||||
## CSS Architecture (`styles.css`)
|
||||
|
||||
All classes prefixed with `waypoint-`. Uses Obsidian CSS variables throughout:
|
||||
|
||||
- `--font-ui-small`, `--font-ui-medium`, `--font-semibold`, `--font-medium`, `--font-light`
|
||||
- `--text-muted`, `--text-faint`, `--text-accent`, `--text-on-accent`, `--text-error`
|
||||
- `--background-modifier-border`, `--background-modifier-active-hover`, `--background-primary`, `--background-secondary`
|
||||
- `--interactive-accent`
|
||||
- `--cursor-link` (pointer cursor, with a `pointer` fallback)
|
||||
|
||||
Key layout:
|
||||
- `.waypoint-view` — flex column, `overflow-y: auto`, 8px padding.
|
||||
- `.waypoint-section` — `flex-shrink: 0`, 16px bottom margin. Last section gets `margin-top: auto` (pins calendar to bottom).
|
||||
- `.waypoint-section-header` — uppercase, muted, with bottom border.
|
||||
- Calendar table — `table-layout: fixed`, `border-collapse: collapse`.
|
||||
- `.waypoint-day.today` — accent color text + 1px accent border.
|
||||
- `.waypoint-day.has-note::after` — 4px dot indicator.
|
||||
- `.waypoint-bm-chevron` — `rotate(-90deg)` on collapsed groups.
|
||||
- `.waypoint-bm-drop-line` / `.waypoint-bm-drop-below` — 3px accent border for drag indicators.
|
||||
|
||||
---
|
||||
|
||||
## Date Utilities (`date-utils.ts`)
|
||||
|
||||
All use Obsidian's bundled `moment` (not the npm package).
|
||||
|
||||
| Function | Signature | Returns |
|
||||
|---|---|---|
|
||||
| `getPeriodInfo(date)` | `Moment → PeriodInfo` | `{day, week, month, quarter, year}` strings for the date |
|
||||
| `getQuarterString(date)` | `Moment → string` | e.g. `"2026-Q3"` |
|
||||
| `getTodayPeriod()` | `() → PeriodInfo` | Current date's period info |
|
||||
| `getMonthGrid(year, month, firstDayOfWeek)` | `number, number, number → CalendarWeek[]` | Up to 6 weeks, each with 7 `CalendarDay` objects |
|
||||
| `formatDateLabel(date)` | `Moment → string` | `"June 1st, 2026"` |
|
||||
| `formatMonthLabel(date)` | `Moment → string` | `"June 2026"` |
|
||||
| `formatQuarterLabel(date)` | `Moment → string` | `"Q3 2026"` |
|
||||
| `navigateDate(date, period, delta)` | `Moment, period, number → Moment` | Clones + adds delta (quarters: delta×3 months) |
|
||||
| `formatPeriodName(date, format)` | `Moment, string → string` | `date.format(format)` wrapper |
|
||||
| `getISOWeek(date)` | `Moment → number` | `date.isoWeek()` |
|
||||
|
||||
**`getMonthGrid` algorithm:**
|
||||
1. Find the first day to display by subtracting `(firstOfMonth.day() - firstDayOfWeek + 7) % 7` days from the 1st.
|
||||
2. Iterate day-by-day, building 7-day weeks.
|
||||
3. Each `CalendarDay` tracks: `date` (Moment), `dayOfMonth`, `isToday`, `isCurrentMonth`, `isoWeekNumber`.
|
||||
4. Week's `weekNumber` = first day's ISO week number.
|
||||
5. Safety break after 6 weeks.
|
||||
|
||||
---
|
||||
|
||||
## Settings Persistence
|
||||
|
||||
Settings and waypoint data share a single `data.json` via Obsidian's `Plugin.loadData()/saveData()`:
|
||||
|
||||
```typescript
|
||||
// loadData() returns:
|
||||
{
|
||||
settings: { calendar: {...}, daily: {...}, ... },
|
||||
waypointData: { bookmarks: [...] }
|
||||
}
|
||||
```
|
||||
|
||||
`onload()` reads `data.json` once, then `applySettings(saved)` and `applyWaypointData(saved)` merge partial persisted values over defaults. `persistAll()` writes both in-memory keys through one promise chain; it never re-reads stale disk state. Recent-file writes additionally go through the 300ms debounce in `persistRecentFiles()`.
|
||||
|
||||
---
|
||||
|
||||
## Redraw Mechanism
|
||||
|
||||
`broadcastRedraw()` iterates all leaves of `WAYPOINT_VIEW_TYPE` and calls `view.redraw()` on each. `redraw()` is a full DOM rebuild (no virtual DOM, no diffing). This is triggered by:
|
||||
|
||||
- File open/create/delete/rename/modify events
|
||||
- Bookmark add/remove/update
|
||||
- Period navigation
|
||||
- Settings changes (via `onSettingsChange` callback)
|
||||
- Midnight detection (10-min interval)
|
||||
|
||||
The sidebar is re-rendered from scratch on every change. For a small sidebar this is acceptable; for larger bookmark lists it may cause flicker.
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations / Gaps
|
||||
|
||||
1. **No virtual DOM** — Full rebuild on every redraw. Bookmark drag/drop, rename, and any change tears down and rebuilds the entire sidebar. For a small sidebar this is acceptable; for larger bookmark lists it may cause flicker.
|
||||
2. **IconSuggestModal fetches from CDN** — Network dependency for the Lucide tag list. The fetch happens at most once per session (the result is cached) and falls back to the hardcoded `FALLBACK_ICONS` list on failure, but a first-open with no network still degrades to that reduced list.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Waypoint Sidebar — QA Checklist
|
||||
|
||||
## Bookmarks: Middle Click
|
||||
|
||||
- [ ] Middle-click on a **file bookmark** opens the file in a new tab
|
||||
- [ ] Middle-click on a **group bookmark with a linked file** opens that file in a new tab
|
||||
- [ ] Middle-click on a **group bookmark with no file** does nothing (no error)
|
||||
- [ ] Ctrl/Cmd+click on a **file bookmark** opens in a new leaf (same as Obsidian default)
|
||||
- [ ] Ctrl/Cmd+click on a **group bookmark with a linked file** opens in a new leaf
|
||||
- [ ] Ctrl/Cmd+click on a **group bookmark with no file** still toggles collapse
|
||||
- [ ] Regular left-click on a **file bookmark** opens the file in the same leaf
|
||||
- [ ] Regular left-click on a **group with a file** opens the file (does NOT toggle collapse)
|
||||
- [ ] Regular left-click on a **group without a file** toggles collapse
|
||||
|
||||
## Bookmarks: Parent Notes (File as Group)
|
||||
|
||||
- [ ] Right-click header → "Add as parent note" creates a group with the current file linked
|
||||
- [ ] Clicking the parent note row opens the linked file
|
||||
- [ ] Clicking the chevron toggles collapse of children
|
||||
- [ ] Children render correctly under the parent note (indented)
|
||||
- [ ] Collapsing the parent hides its children
|
||||
- [ ] A **file bookmark** that has children shows a chevron
|
||||
- [ ] Clicking the chevron on a file bookmark toggles its children
|
||||
- [ ] Clicking the file bookmark row itself still opens the file
|
||||
- [ ] Drag-and-drop still works for reordering items inside/outside parent notes
|
||||
|
||||
## Bookmarks: No Default Icon
|
||||
|
||||
- [ ] New file bookmarks have **no icon** (empty space where icon was)
|
||||
- [ ] New groups have **no icon** (empty space where folder icon was)
|
||||
- [ ] "Add bookmark here" from group context menu creates child with no icon
|
||||
- [ ] "Add child note" from group context menu creates child with no icon
|
||||
- [ ] Existing bookmarks with icons still show their icons (no regression)
|
||||
- [ ] Right-click → "Change icon" still works to add an icon later
|
||||
|
||||
## Bookmarks: Icon Search (Lucide Tags)
|
||||
|
||||
- [ ] Open the icon picker → type "meeting" → should find `calendar` (tag match)
|
||||
- [ ] Type "heart" → should find `heart`, `red-heart`, etc.
|
||||
- [ ] Type "arrow" → should find all arrow icons
|
||||
- [ ] Type a tag keyword like "fitness" → should find `activity`, `dumbbell`, etc.
|
||||
- [ ] Clear search (empty input) → shows first 80 icons
|
||||
- [ ] Set an icon on a bookmark, reopen the picker, click "No icon" → the previously-set icon is cleared
|
||||
- [ ] Click an icon → preview updates, grid highlights selection
|
||||
- [ ] Click Save → icon applied, click Cancel → no change
|
||||
- [ ] Test with **network offline** → falls back to FALLBACK_ICONS list
|
||||
|
||||
## Bookmarks: General (Regression)
|
||||
|
||||
- [ ] Drag-and-drop reordering still works between all item types
|
||||
- [ ] Rename still works (right-click → Rename)
|
||||
- [ ] Remove still works (right-click → Remove)
|
||||
- [ ] Move to group still works (right-click → group submenu)
|
||||
- [ ] Separators and spacers still render and can be dragged/deleted
|
||||
- [ ] "Insert separator above/below" and "Insert spacer above/below" still work
|
||||
- [ ] Hover preview (page preview popup) still works on file bookmarks
|
||||
- [ ] Right-click context menu on a file bookmark still shows native Obsidian file-menu items
|
||||
- [ ] Bookmarks persist across vault reload
|
||||
- [ ] Move a folder containing bookmarked files to a new location → those bookmarks survive and still open the moved files
|
||||
|
||||
## Calendar (Regression)
|
||||
|
||||
- [ ] Calendar renders with correct month grid
|
||||
- [ ] Click on a day → opens/creates daily note
|
||||
- [ ] Click on week number → opens/creates weekly note
|
||||
- [ ] Middle-click on a week number → opens/creates the weekly note in a new tab, with no console error
|
||||
- [ ] Click Q/M/Y breadcrumb → opens respective period note
|
||||
- [ ] ◀/▶ navigation shifts month
|
||||
- [ ] "Today" button returns to current month
|
||||
- [ ] Note indicator dots show on days with existing .md files
|
||||
- [ ] Today is highlighted with accent border
|
||||
|
||||
|
||||
## 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
|
||||
- [ ] Most recent file appears at the top
|
||||
- [ ] Active file is highlighted
|
||||
- [ ] Remove button (×) works on hover
|
||||
- [ ] Left-click opens file, middle-click opens in new tab
|
||||
- [ ] Right-click shows "Open in new tab" + native file menu
|
||||
- [ ] Hover preview popup works
|
||||
@@ -0,0 +1,56 @@
|
||||
# Waypoint
|
||||
|
||||
Calendar, recent files, and custom bookmarks sidebar for Obsidian.
|
||||
|
||||
## Features
|
||||
|
||||
- **Calendar panel** — 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
|
||||
- **Date systems** — browse or create daily, journal, meeting, and other date-prefixed notes from a single day menu
|
||||
- **Recent files** — track recently opened/edited files
|
||||
- **Favorites** — custom bookmarks with groups, icons, and rename
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Default Hotkey |
|
||||
|---|---|
|
||||
| Go to daily note | `Ctrl+Shift+Alt+D` |
|
||||
| Go to weekly note | `Ctrl+Shift+Alt+W` |
|
||||
| Go to monthly note | `Ctrl+Shift+Alt+M` |
|
||||
| Go to quarterly note | `Ctrl+Shift+Alt+Q` |
|
||||
| Go to yearly note | `Ctrl+Shift+Alt+Y` |
|
||||
| Next/Previous daily/weekly/monthly/quarterly/yearly note | — |
|
||||
|
||||
## 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, and Lucide icon.
|
||||
|
||||
`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.
|
||||
|
||||
### 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
|
||||
|
||||
1. Install the [BRAT](https://obsidian.md/plugins?search=brat) plugin
|
||||
2. Add `https://gitea.mithrilforge.dev/olivier/waypoint` as a Beta Plugin
|
||||
3. Enable **Waypoint** in Community Plugins
|
||||
|
||||
### Manual
|
||||
|
||||
1. Download the latest release from [Gitea](https://gitea.mithrilforge.dev/olivier/waypoint/releases)
|
||||
2. Extract `main.js`, `manifest.json`, and `styles.css` to `{vault}/.obsidian/plugins/waypoint/`
|
||||
3. Enable **Waypoint** in Community Plugins
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"id": "waypoint",
|
||||
"name": "Waypoint",
|
||||
"version": "1.1.0",
|
||||
"minAppVersion": "0.16.3",
|
||||
"id": "waypoint-sidebar",
|
||||
"name": "Waypoint Sidebar",
|
||||
"version": "1.6.0",
|
||||
"minAppVersion": "1.4.4",
|
||||
"description": "Calendar, recent files, and custom bookmarks sidebar.",
|
||||
"author": "Olivier",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+4
-3
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "waypoint",
|
||||
"version": "1.1.0",
|
||||
"version": "1.6.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",
|
||||
@@ -16,4 +17,4 @@
|
||||
"obsidian": "latest",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+537
-136
@@ -3,32 +3,62 @@
|
||||
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: [],
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
export default class WaypointPlugin extends Plugin {
|
||||
public settings: WaypointSettings;
|
||||
public waypointData: WaypointData;
|
||||
public recentFiles: { path: string; basename: string }[] = [];
|
||||
private recentFilesSaveTimer: number | undefined;
|
||||
/** Serializes writes to data.json so overlapping saves cannot lose updates. */
|
||||
private savePromise: Promise<void> = Promise.resolve();
|
||||
/** Basenames of every markdown file in the vault, for O(1) calendar lookups. */
|
||||
private markdownBasenames: Set<string> = new Set();
|
||||
|
||||
async onload(): Promise<void> {
|
||||
console.debug('Waypoint: loading plugin v' + this.manifest.version);
|
||||
|
||||
// Load persisted data
|
||||
await this.loadSettings();
|
||||
await this.loadWaypointData();
|
||||
// Load persisted data — data.json is read exactly once here.
|
||||
const saved = await this.loadData() as Record<string, unknown> | null;
|
||||
this.applySettings(saved);
|
||||
this.applyWaypointData(saved);
|
||||
|
||||
// Register the sidebar view
|
||||
this.registerView(
|
||||
@@ -41,7 +71,10 @@ export default class WaypointPlugin extends Plugin {
|
||||
this.app,
|
||||
this,
|
||||
this.settings,
|
||||
() => this.redrawAll(),
|
||||
() => {
|
||||
this.enforceRecentFilesLimit();
|
||||
this.redrawAll();
|
||||
},
|
||||
));
|
||||
|
||||
// ── Commands ──
|
||||
@@ -151,23 +184,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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -188,73 +229,265 @@ 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_SETTINGS itself. Saved entries merge over
|
||||
// DEFAULT_DATE_SYSTEM so older configs pick up fields added since.
|
||||
this.settings.dateSystems = Array.isArray(s.dateSystems)
|
||||
? s.dateSystems.map(sys => Object.assign({}, DEFAULT_DATE_SYSTEM, sys))
|
||||
: DEFAULT_SETTINGS.dateSystems.map(sys => Object.assign({}, sys));
|
||||
}
|
||||
|
||||
private applyWaypointData(saved: Record<string, unknown> | null): void {
|
||||
const d = (saved?.waypointData || {}) as Partial<WaypointData>;
|
||||
this.waypointData = {
|
||||
bookmarks: Array.isArray(d.bookmarks) ? d.bookmarks : [],
|
||||
recentFiles: Array.isArray(d.recentFiles) ? d.recentFiles : [],
|
||||
};
|
||||
|
||||
// Load persisted recent files
|
||||
this.recentFiles = this.waypointData.recentFiles;
|
||||
|
||||
// Apply current limit (in case maxItems was reduced since last save)
|
||||
if (this.recentFiles.length > this.settings.recentFiles.maxItems) {
|
||||
this.recentFiles = this.recentFiles.slice(0, this.settings.recentFiles.maxItems);
|
||||
this.waypointData.recentFiles = this.recentFiles;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write both top-level keys of data.json from memory.
|
||||
*
|
||||
* Saves never re-read from disk: `settings` and `waypointData` are the only
|
||||
* keys and both are held in memory, so a read-modify-write would only give
|
||||
* two concurrent saves a stale snapshot of the other's key. Writes are
|
||||
* chained onto `savePromise` so they cannot interleave.
|
||||
*/
|
||||
private persistAll(): Promise<void> {
|
||||
const write = this.savePromise.then(() => {
|
||||
// Sync recentFiles into waypointData before writing
|
||||
this.waypointData.recentFiles = this.recentFiles;
|
||||
return this.saveData({
|
||||
settings: this.settings,
|
||||
waypointData: this.waypointData,
|
||||
});
|
||||
});
|
||||
// Keep the queue usable after a failed write without leaving an
|
||||
// unhandled rejection behind; callers still see `write` reject.
|
||||
this.savePromise = write.catch(() => undefined);
|
||||
return write;
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
const all = (await this.loadData()) as Record<string, unknown> || {};
|
||||
all.settings = this.settings;
|
||||
await this.saveData(all);
|
||||
}
|
||||
|
||||
async loadWaypointData(): Promise<void> {
|
||||
const saved = await this.loadData() as Record<string, unknown> | null;
|
||||
const d = (saved?.waypointData || {}) as Partial<WaypointData>;
|
||||
this.waypointData = Object.assign({}, DEFAULT_DATA, d);
|
||||
await this.persistAll();
|
||||
}
|
||||
|
||||
async saveWaypointData(): Promise<void> {
|
||||
const all = (await this.loadData()) as Record<string, unknown> || {};
|
||||
all.waypointData = this.waypointData;
|
||||
await this.saveData(all);
|
||||
await this.persistAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim recent files to the current maxItems limit and persist.
|
||||
* Called when the maxItems setting changes.
|
||||
*/
|
||||
enforceRecentFilesLimit(): void {
|
||||
if (this.recentFiles.length > this.settings.recentFiles.maxItems) {
|
||||
this.recentFiles = this.recentFiles.slice(0, this.settings.recentFiles.maxItems);
|
||||
this.persistRecentFiles();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist recent files to disk (debounced to avoid excessive writes on rapid opens).
|
||||
*/
|
||||
persistRecentFiles(): void {
|
||||
this.waypointData.recentFiles = this.recentFiles;
|
||||
window.clearTimeout(this.recentFilesSaveTimer);
|
||||
this.recentFilesSaveTimer = window.setTimeout(() => {
|
||||
this.saveWaypointData();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// ── Recent Files ──
|
||||
|
||||
private onFileOpen(file: TFile): void {
|
||||
if (this.settings.recentFiles.updateOn === 'file-edit') {
|
||||
// We'll handle this via quick-preview in a future refinement
|
||||
return;
|
||||
}
|
||||
if (this.settings.recentFiles.updateOn !== 'file-open') return;
|
||||
this.addToRecentFiles(file);
|
||||
}
|
||||
|
||||
private onFileModify(file: TAbstractFile): void {
|
||||
if (this.settings.recentFiles.updateOn !== 'file-edit') return;
|
||||
if (!(file instanceof TFile)) return;
|
||||
// Already the most recent entry: nothing to reorder or redraw.
|
||||
if (this.recentFiles.length > 0 && this.recentFiles[0].path === file.path) return;
|
||||
this.addToRecentFiles(file);
|
||||
}
|
||||
|
||||
addToRecentFiles(file: TFile): void {
|
||||
if (this.isOmittedFromRecentFiles(file)) return;
|
||||
|
||||
this.recentFiles = this.recentFiles.filter(f => f.path !== file.path);
|
||||
this.recentFiles.unshift({ path: file.path, basename: file.basename });
|
||||
|
||||
// Prune
|
||||
// Apply max items limit
|
||||
if (this.recentFiles.length > this.settings.recentFiles.maxItems) {
|
||||
this.recentFiles = this.recentFiles.slice(0, this.settings.recentFiles.maxItems);
|
||||
}
|
||||
|
||||
this.persistRecentFiles();
|
||||
this.broadcastRedraw();
|
||||
}
|
||||
|
||||
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.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 {
|
||||
const indexChanged = this.syncIndexForRename(file, oldPath);
|
||||
let dataChanged = false;
|
||||
|
||||
for (const entry of this.recentFiles) {
|
||||
const remapped = remapRenamedPath(entry.path, oldPath, file.path);
|
||||
if (remapped === null) continue;
|
||||
entry.path = remapped;
|
||||
entry.basename = basenameFromPath(remapped);
|
||||
dataChanged = true;
|
||||
}
|
||||
|
||||
const remapBookmarks = (items: BookmarkItem[]): void => {
|
||||
for (const item of items) {
|
||||
if (item.filePath) {
|
||||
const remapped = remapRenamedPath(item.filePath, oldPath, file.path);
|
||||
if (remapped !== null) {
|
||||
item.filePath = remapped;
|
||||
dataChanged = true;
|
||||
}
|
||||
}
|
||||
if (item.children) remapBookmarks(item.children);
|
||||
}
|
||||
};
|
||||
remapBookmarks(this.waypointData.bookmarks);
|
||||
|
||||
if (dataChanged) {
|
||||
this.waypointData.recentFiles = this.recentFiles;
|
||||
this.persistAll();
|
||||
}
|
||||
if (dataChanged || indexChanged) this.broadcastRedraw();
|
||||
}
|
||||
|
||||
private onVaultCreate(file: TAbstractFile): void {
|
||||
if (file instanceof TFile && file.extension === 'md') {
|
||||
this.markdownBasenames.add(file.basename);
|
||||
}
|
||||
this.broadcastRedraw();
|
||||
}
|
||||
|
||||
private onVaultDelete(file: TAbstractFile): void {
|
||||
if (file instanceof TFile && file.extension === 'md') {
|
||||
this.removeFromMarkdownIndex(file.basename, file.path);
|
||||
}
|
||||
this.broadcastRedraw();
|
||||
}
|
||||
|
||||
// ── Markdown basename index (calendar note indicators) ──
|
||||
|
||||
private buildMarkdownIndex(): void {
|
||||
this.markdownBasenames.clear();
|
||||
for (const file of this.app.vault.getMarkdownFiles()) {
|
||||
this.markdownBasenames.add(file.basename);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a basename from the index, unless another markdown file still
|
||||
* carries it. `path` is excluded from that check because the vault may not
|
||||
* have dropped the file yet when the event fires.
|
||||
*/
|
||||
private removeFromMarkdownIndex(basename: string, path: string): boolean {
|
||||
if (!this.markdownBasenames.has(basename)) return false;
|
||||
const stillExists = this.app.vault.getMarkdownFiles()
|
||||
.some(f => f.basename === basename && f.path !== path);
|
||||
if (stillExists) return false;
|
||||
this.markdownBasenames.delete(basename);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Returns true when the index changed. Folder renames never change basenames. */
|
||||
private syncIndexForRename(file: TAbstractFile, oldPath: string): boolean {
|
||||
if (!(file instanceof TFile)) return false;
|
||||
|
||||
let changed = false;
|
||||
const oldBasename = basenameFromPath(oldPath);
|
||||
if (oldPath.toLowerCase().endsWith('.md') && (oldBasename !== file.basename || file.extension !== 'md')) {
|
||||
changed = this.removeFromMarkdownIndex(oldBasename, oldPath);
|
||||
}
|
||||
if (file.extension === 'md' && !this.markdownBasenames.has(file.basename)) {
|
||||
this.markdownBasenames.add(file.basename);
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any markdown file in the vault is named exactly `dateStr`.
|
||||
* Synchronous and O(1) — the calendar calls this once per day cell.
|
||||
*/
|
||||
hasNoteForDate(dateStr: string): boolean {
|
||||
return this.markdownBasenames.has(dateStr);
|
||||
}
|
||||
|
||||
// ── Bookmarks ──
|
||||
|
||||
addBookmark(filePath: string, label: string, type: 'file' | 'group' | 'separator' | 'spacer' = 'file', icon = 'file'): BookmarkItem {
|
||||
addBookmark(filePath: string, label: string, type: 'file' | 'group' | 'separator' | 'spacer' = 'file', icon = ''): BookmarkItem {
|
||||
const id = `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
const item: BookmarkItem = {
|
||||
id,
|
||||
@@ -308,100 +541,244 @@ 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',
|
||||
};
|
||||
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' },
|
||||
};
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const config = configs[period];
|
||||
if (!config) return;
|
||||
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);
|
||||
}
|
||||
|
||||
const { settings: periodSettings } = config;
|
||||
const filename = date.format(periodSettings.nameFormat) + '.md';
|
||||
const fullPath = periodSettings.folder
|
||||
? `${periodSettings.folder}/${filename}`
|
||||
: filename;
|
||||
/**
|
||||
* 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> {
|
||||
|
||||
// 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 (!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);
|
||||
|
||||
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}`);
|
||||
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;
|
||||
}
|
||||
new Notice(`Created ${config.label.toLowerCase()} note: ${filename}`);
|
||||
basename = before + title + after;
|
||||
} else {
|
||||
basename = before + after;
|
||||
}
|
||||
|
||||
if (file) {
|
||||
const leaf = this.app.workspace.getLeaf(false);
|
||||
await leaf.openFile(file);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -418,17 +795,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
|
||||
@@ -453,20 +832,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) : '';
|
||||
}
|
||||
|
||||
@@ -13,4 +13,5 @@ export interface BookmarkItem {
|
||||
|
||||
export interface WaypointData {
|
||||
bookmarks: BookmarkItem[];
|
||||
recentFiles: { path: string; basename: string }[];
|
||||
}
|
||||
|
||||
+396
-69
@@ -1,12 +1,15 @@
|
||||
import { Setting, PluginSettingTab, App, Plugin } 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' | '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;
|
||||
@@ -17,10 +20,58 @@ export class WaypointSettingTab extends PluginSettingTab {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
// ── Calendar Section ──
|
||||
new Setting(containerEl).setHeading().setName('Calendar');
|
||||
// ── Tab bar ──
|
||||
const tabBar = containerEl.createDiv({ cls: 'waypoint-settings-tabs' });
|
||||
const tabs = [
|
||||
{ key: 'calendar' as const, label: 'Calendar' },
|
||||
{ key: 'periodic' as const, label: 'Periodic Notes' },
|
||||
{ key: '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' },
|
||||
];
|
||||
|
||||
new Setting(containerEl)
|
||||
for (const tab of tabs) {
|
||||
const btn = tabBar.createEl('button', {
|
||||
cls: `waypoint-settings-tab${this.activeTab === tab.key ? ' is-active' : ''}`,
|
||||
text: tab.label,
|
||||
});
|
||||
btn.addEventListener('click', () => {
|
||||
this.activeTab = tab.key;
|
||||
this.display();
|
||||
});
|
||||
}
|
||||
|
||||
const tabContent = containerEl.createDiv({ cls: 'waypoint-settings-content' });
|
||||
|
||||
switch (this.activeTab) {
|
||||
case 'calendar':
|
||||
this.renderCalendarTab(tabContent);
|
||||
break;
|
||||
case 'periodic':
|
||||
this.renderPeriodicTab(tabContent);
|
||||
break;
|
||||
case 'systems':
|
||||
this.renderSystemsTab(tabContent);
|
||||
break;
|
||||
case 'recent':
|
||||
this.renderRecentTab(tabContent);
|
||||
break;
|
||||
case 'display':
|
||||
this.renderDisplayTab(tabContent);
|
||||
break;
|
||||
case 'about':
|
||||
this.renderAboutTab(tabContent);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════
|
||||
// Calendar tab
|
||||
// ═══════════════════════════════
|
||||
|
||||
private renderCalendarTab(container: HTMLElement): void {
|
||||
new Setting(container)
|
||||
.setName('First day of week')
|
||||
.setDesc('Which day the calendar week starts on.')
|
||||
.addDropdown((dropdown) => {
|
||||
@@ -34,7 +85,7 @@ export class WaypointSettingTab extends PluginSettingTab {
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
new Setting(container)
|
||||
.setName('Show note indicators')
|
||||
.setDesc('Show a dot on days that have existing notes.')
|
||||
.addToggle((toggle) => {
|
||||
@@ -45,20 +96,210 @@ export class WaypointSettingTab extends PluginSettingTab {
|
||||
this.saveAndRefresh();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Periodic Note Settings ──
|
||||
new Setting(containerEl).setHeading().setName('Periodic Note Paths');
|
||||
// ═══════════════════════════════
|
||||
// Periodic Notes tab
|
||||
// ═══════════════════════════════
|
||||
|
||||
this.addPeriodNoteSettings('Daily', this.settings.daily);
|
||||
this.addPeriodNoteSettings('Weekly', this.settings.weekly);
|
||||
this.addPeriodNoteSettings('Monthly', this.settings.monthly);
|
||||
this.addPeriodNoteSettings('Quarterly', this.settings.quarterly);
|
||||
this.addPeriodNoteSettings('Yearly', this.settings.yearly);
|
||||
private renderPeriodicTab(container: HTMLElement): void {
|
||||
this.addPeriodNoteSettings(container, 'Daily', this.settings.daily);
|
||||
this.addPeriodNoteSettings(container, 'Weekly', this.settings.weekly);
|
||||
this.addPeriodNoteSettings(container, 'Monthly', this.settings.monthly);
|
||||
this.addPeriodNoteSettings(container, 'Quarterly', this.settings.quarterly);
|
||||
this.addPeriodNoteSettings(container, 'Yearly', this.settings.yearly);
|
||||
}
|
||||
|
||||
// ── Recent Files Section ──
|
||||
new Setting(containerEl).setHeading().setName('Recent Files');
|
||||
private addPeriodNoteSettings(container: HTMLElement, label: string, period: PeriodNoteSettings): void {
|
||||
new Setting(container).setHeading().setName(label);
|
||||
|
||||
new Setting(containerEl)
|
||||
new Setting(container)
|
||||
.setName('Folder')
|
||||
.setDesc(`Folder path for ${label.toLowerCase()} notes.`)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder('periodic/daily');
|
||||
text.setValue(period.folder);
|
||||
text.onChange((value) => {
|
||||
period.folder = value;
|
||||
this.saveAndRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(container)
|
||||
.setName('Name format')
|
||||
.setDesc(`Date format for ${label.toLowerCase()} note filenames (moment.js format).`)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder('yyyy-MM-dd');
|
||||
text.setValue(period.nameFormat);
|
||||
text.onChange((value) => {
|
||||
period.nameFormat = value;
|
||||
this.saveAndRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(container)
|
||||
.setName('Template file')
|
||||
.setDesc(`Path to the template file (without .md extension).`)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder('Templates/Daily note');
|
||||
text.setValue(period.templateFile);
|
||||
text.onChange((value) => {
|
||||
period.templateFile = value;
|
||||
this.saveAndRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(container)
|
||||
.setName('Type property')
|
||||
.setDesc(`Value for the 'type' frontmatter property.`)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder('daily-note');
|
||||
text.setValue(period.typeProperty);
|
||||
text.onChange((value) => {
|
||||
period.typeProperty = value;
|
||||
this.saveAndRefresh();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════
|
||||
// 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);
|
||||
|
||||
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',
|
||||
);
|
||||
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)
|
||||
.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,
|
||||
): Setting {
|
||||
return new Setting(container)
|
||||
.setName(name)
|
||||
.setDesc(desc)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder(placeholder);
|
||||
text.setValue(obj[key]);
|
||||
text.onChange((value) => {
|
||||
obj[key] = value;
|
||||
this.saveAndRefresh();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════
|
||||
// Recent Files tab
|
||||
// ═══════════════════════════════
|
||||
|
||||
private renderRecentTab(container: HTMLElement): void {
|
||||
new Setting(container)
|
||||
.setName('Max items')
|
||||
.setDesc('Maximum number of recent files to track.')
|
||||
.addText((text) => {
|
||||
@@ -74,7 +315,7 @@ export class WaypointSettingTab extends PluginSettingTab {
|
||||
};
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
new Setting(container)
|
||||
.setName('Update on')
|
||||
.setDesc('When to add a file to the recent list.')
|
||||
.addDropdown((dropdown) => {
|
||||
@@ -90,7 +331,7 @@ export class WaypointSettingTab extends PluginSettingTab {
|
||||
|
||||
const pathDesc = new DocumentFragment();
|
||||
pathDesc.appendText('Regex patterns for paths to exclude. One per line.');
|
||||
new Setting(containerEl)
|
||||
new Setting(container)
|
||||
.setName('Omitted paths')
|
||||
.setDesc(pathDesc)
|
||||
.addTextArea((text) => {
|
||||
@@ -105,7 +346,7 @@ export class WaypointSettingTab extends PluginSettingTab {
|
||||
|
||||
const tagDesc = new DocumentFragment();
|
||||
tagDesc.appendText('Regex patterns for frontmatter tags to exclude. One per line.');
|
||||
new Setting(containerEl)
|
||||
new Setting(container)
|
||||
.setName('Omitted tags')
|
||||
.setDesc(tagDesc)
|
||||
.addTextArea((text) => {
|
||||
@@ -117,62 +358,148 @@ export class WaypointSettingTab extends PluginSettingTab {
|
||||
this.saveAndRefresh();
|
||||
};
|
||||
});
|
||||
|
||||
const filterDesc = new DocumentFragment();
|
||||
filterDesc.appendText('Tags to show as filter pills above the file list. One per line. Leave empty to auto-detect from frontmatter `type` property.');
|
||||
new Setting(container)
|
||||
.setName('Filter tags')
|
||||
.setDesc(filterDesc)
|
||||
.addTextArea((text) => {
|
||||
text.inputEl.setAttr('rows', 4);
|
||||
text.setPlaceholder('meeting\nperson\nproject');
|
||||
text.setValue(this.settings.recentFiles.filterTags.join('\n'));
|
||||
text.inputEl.onblur = () => {
|
||||
this.settings.recentFiles.filterTags = text.getValue().split('\n').filter(t => t.trim());
|
||||
this.saveAndRefresh();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private addPeriodNoteSettings(label: string, period: PeriodNoteSettings): void {
|
||||
new Setting(this.containerEl).setHeading().setName(label);
|
||||
// ═══════════════════════════════
|
||||
// Display tab
|
||||
// ═══════════════════════════════
|
||||
|
||||
new Setting(this.containerEl)
|
||||
.setName('Folder')
|
||||
.setDesc(`Folder path for ${label.toLowerCase()} notes.`)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder('periodic/daily');
|
||||
text.setValue(period.folder);
|
||||
text.onChange((value) => {
|
||||
period.folder = value;
|
||||
private renderDisplayTab(container: HTMLElement): void {
|
||||
new Setting(container).setHeading().setName('Bookmarks');
|
||||
|
||||
this.addSliderSetting(container,
|
||||
'Row size', 'Height of bookmark items.',
|
||||
this.settings.display, 'rowSize', 18, 40, 1, 'px',
|
||||
);
|
||||
this.addSliderSetting(container,
|
||||
'Row spacing', 'Gap between bookmark items.',
|
||||
this.settings.display, 'rowSpacing', 0, 12, 1, 'px',
|
||||
);
|
||||
this.addSliderSetting(container,
|
||||
'Indent size', 'Indent per nesting depth.',
|
||||
this.settings.display, 'indentSize', 8, 32, 2, 'px',
|
||||
);
|
||||
this.addSliderSetting(container,
|
||||
'Font size', 'Label font size.',
|
||||
this.settings.display, 'fontSize', 10, 18, 1, 'px',
|
||||
);
|
||||
this.addSliderSetting(container,
|
||||
'Icon size', 'Bookmark icon size.',
|
||||
this.settings.display, 'iconSize', 12, 24, 1, 'px',
|
||||
);
|
||||
|
||||
new Setting(container).setHeading().setName('Calendar');
|
||||
|
||||
this.addSliderSetting(container,
|
||||
'Cell size', 'Height of calendar day cells.',
|
||||
this.settings.display, 'calendarCellSize', 20, 48, 2, 'px',
|
||||
);
|
||||
|
||||
new Setting(container)
|
||||
.addButton((btn) =>
|
||||
btn
|
||||
.setButtonText('Reset to defaults')
|
||||
.onClick(() => {
|
||||
this.settings.display = { ...DEFAULT_SETTINGS.display };
|
||||
this.saveAndRefresh();
|
||||
this.display();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private addSliderSetting<K extends string>(
|
||||
container: HTMLElement,
|
||||
name: string,
|
||||
desc: string,
|
||||
obj: Record<K, number>,
|
||||
key: K,
|
||||
min: number,
|
||||
max: number,
|
||||
step: number,
|
||||
unit: string,
|
||||
): void {
|
||||
const setting = new Setting(container)
|
||||
.setName(name)
|
||||
.setDesc(`${desc} (${obj[key]}${unit})`);
|
||||
|
||||
setting.addSlider((slider) => {
|
||||
slider
|
||||
.setLimits(min, max, step)
|
||||
.setValue(obj[key])
|
||||
.setDynamicTooltip()
|
||||
.onChange((value) => {
|
||||
obj[key] = value;
|
||||
setting.setDesc(`${desc} (${value}${unit})`);
|
||||
this.saveAndRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(this.containerEl)
|
||||
.setName('Name format')
|
||||
.setDesc(`Date format for ${label.toLowerCase()} note filenames (moment.js format).`)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder('yyyy-MM-dd');
|
||||
text.setValue(period.nameFormat);
|
||||
text.onChange((value) => {
|
||||
period.nameFormat = value;
|
||||
this.saveAndRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(this.containerEl)
|
||||
.setName('Template file')
|
||||
.setDesc(`Path to the template file (without .md extension).`)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder('Templates/Daily note');
|
||||
text.setValue(period.templateFile);
|
||||
text.onChange((value) => {
|
||||
period.templateFile = value;
|
||||
this.saveAndRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(this.containerEl)
|
||||
.setName('Type property')
|
||||
.setDesc(`Value for the 'type' frontmatter property.`)
|
||||
.addText((text) => {
|
||||
text.setPlaceholder('daily-note');
|
||||
text.setValue(period.typeProperty);
|
||||
text.onChange((value) => {
|
||||
period.typeProperty = value;
|
||||
this.saveAndRefresh();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async saveAndRefresh(): Promise<void> {
|
||||
await this.plugin.saveData(this.settings);
|
||||
await this.plugin.saveSettings();
|
||||
this.onSettingsChange();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════
|
||||
// About tab
|
||||
// ═══════════════════════════════
|
||||
|
||||
private renderAboutTab(container: HTMLElement): void {
|
||||
const version = this.plugin.manifest.version;
|
||||
|
||||
const header = container.createDiv();
|
||||
header.style.display = 'flex';
|
||||
header.style.alignItems = 'center';
|
||||
header.style.gap = '12px';
|
||||
header.style.marginBottom = '16px';
|
||||
|
||||
const icon = header.createDiv();
|
||||
icon.style.display = 'flex';
|
||||
icon.style.alignItems = 'center';
|
||||
icon.style.justifyContent = 'center';
|
||||
icon.style.width = '48px';
|
||||
icon.style.height = '48px';
|
||||
icon.style.borderRadius = '12px';
|
||||
icon.style.background = 'var(--interactive-accent)';
|
||||
icon.style.color = 'var(--text-on-accent)';
|
||||
icon.style.fontSize = '24px';
|
||||
setIcon(icon, 'compass');
|
||||
|
||||
const titleGroup = header.createDiv();
|
||||
const title = titleGroup.createEl('h2', { text: 'Waypoint Sidebar' });
|
||||
title.style.margin = '0';
|
||||
title.style.lineHeight = '1.2';
|
||||
const subtitle = titleGroup.createDiv({ text: `v${version}` });
|
||||
subtitle.style.color = 'var(--text-muted)';
|
||||
subtitle.style.fontSize = 'var(--font-ui-small)';
|
||||
|
||||
const desc = container.createDiv();
|
||||
desc.style.marginBottom = '20px';
|
||||
desc.style.lineHeight = '1.6';
|
||||
desc.style.color = 'var(--text-normal)';
|
||||
desc.innerHTML = [
|
||||
`<p><strong>Waypoint</strong> is a sidebar plugin that brings three essential panels into one view:</p>`,
|
||||
`<ul style="padding-left: 20px; margin: 8px 0;">`,
|
||||
`<li><strong>Calendar</strong> — a monthly grid for your periodic notes (daily, weekly, monthly, quarterly, yearly). Click any day, week, or month to open or create the corresponding note.</li>`,
|
||||
`<li><strong>Recent Files</strong> — a list of recently opened files with type filtering, drag-and-drop, and right-click actions.</li>`,
|
||||
`<li><strong>Bookmarks</strong> — custom bookmarks with icons, groups, nesting, and drag-and-drop reordering. Separate from Obsidian's native bookmarks.</li>`,
|
||||
`</ul>`,
|
||||
`<p style="color: var(--text-muted); font-size: var(--font-ui-small);">Made by Olivier. Licensed under MIT.</p>`,
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,38 @@ 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;
|
||||
}
|
||||
|
||||
/** 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',
|
||||
};
|
||||
|
||||
export interface CalendarSettings {
|
||||
firstDayOfWeek: number; // 0=Sunday, 1=Monday
|
||||
showNoteIndicators: boolean;
|
||||
@@ -17,6 +49,16 @@ export interface RecentFilesSettings {
|
||||
updateOn: 'file-open' | 'file-edit';
|
||||
omittedPaths: string[];
|
||||
omittedTags: string[];
|
||||
filterTags: string[]; // tags to show as filter pills (empty = auto-detect from frontmatter)
|
||||
}
|
||||
|
||||
export interface DisplaySettings {
|
||||
rowSize: number; // px, base height of bookmark items (20–40)
|
||||
rowSpacing: number; // px, gap between items (0–12)
|
||||
indentSize: number; // px, indent per depth level (8–32)
|
||||
fontSize: number; // px, font size for bookmark labels (10–18)
|
||||
iconSize: number; // px, icon size (12–24)
|
||||
calendarCellSize: number; // px, calendar day cell height (20–48)
|
||||
}
|
||||
|
||||
export interface WaypointSettings {
|
||||
@@ -26,7 +68,10 @@ 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;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: WaypointSettings = {
|
||||
@@ -64,10 +109,39 @@ 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',
|
||||
},
|
||||
{
|
||||
id: 'meetings',
|
||||
name: 'Meeting',
|
||||
folder: 'periodic/meetings',
|
||||
nameFormat: 'YYYY-MM-DD - {title}',
|
||||
templateFile: '',
|
||||
typeProperty: 'meeting',
|
||||
icon: 'users',
|
||||
},
|
||||
],
|
||||
recentFiles: {
|
||||
maxItems: 50,
|
||||
updateOn: 'file-open',
|
||||
omittedPaths: [],
|
||||
omittedTags: [],
|
||||
filterTags: [],
|
||||
},
|
||||
display: {
|
||||
rowSize: 26,
|
||||
rowSpacing: 2,
|
||||
indentSize: 16,
|
||||
fontSize: 13,
|
||||
iconSize: 16,
|
||||
calendarCellSize: 32,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+634
-370
File diff suppressed because it is too large
Load Diff
+150
-14
@@ -27,6 +27,31 @@
|
||||
margin-bottom: 4px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.waypoint-header-more {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: var(--cursor-link, pointer);
|
||||
color: var(--text-faint);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 80ms, background 80ms;
|
||||
box-shadow: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.waypoint-header-more:hover {
|
||||
color: var(--text-normal);
|
||||
background: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Calendar ── */
|
||||
@@ -39,7 +64,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.waypoint-calendar-breadcrumb {
|
||||
@@ -48,12 +73,12 @@
|
||||
gap: 6px;
|
||||
font-size: var(--font-ui-medium);
|
||||
font-weight: var(--font-semibold);
|
||||
padding: 2px 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.waypoint-calendar-breadcrumb .waypoint-clickable {
|
||||
color: var(--text-muted);
|
||||
cursor: var(--cursor);
|
||||
cursor: var(--cursor-link, pointer);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -77,7 +102,7 @@
|
||||
.waypoint-calendar-today-group button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: var(--cursor);
|
||||
cursor: var(--cursor-link, pointer);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
@@ -102,8 +127,9 @@
|
||||
.waypoint-calendar th,
|
||||
.waypoint-calendar td {
|
||||
text-align: center;
|
||||
padding: 2px 1px;
|
||||
padding: 4px 2px;
|
||||
font-size: var(--font-ui-small);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.waypoint-calendar th {
|
||||
@@ -116,7 +142,7 @@
|
||||
font-size: calc(var(--font-ui-small) * 0.75);
|
||||
color: var(--text-faint);
|
||||
font-weight: var(--font-light);
|
||||
cursor: var(--cursor);
|
||||
cursor: var(--cursor-link, pointer);
|
||||
padding: 2px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -127,11 +153,13 @@
|
||||
}
|
||||
|
||||
.waypoint-calendar .waypoint-day {
|
||||
cursor: var(--cursor);
|
||||
padding: 3px 0;
|
||||
cursor: var(--cursor-link, pointer);
|
||||
min-height: var(--wp-cal-cell-size, 32px);
|
||||
padding: 2px 0;
|
||||
border-radius: 6px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.waypoint-calendar .waypoint-day:hover {
|
||||
@@ -161,8 +189,37 @@
|
||||
.waypoint-calendar .waypoint-day.today.has-note::after {
|
||||
background-color: var(--text-accent);
|
||||
}
|
||||
|
||||
/* ── Recent Files ── */
|
||||
|
||||
.waypoint-recent-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.waypoint-recent-pill {
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-muted);
|
||||
background: var(--background-modifier-hover);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
cursor: var(--cursor-link, pointer);
|
||||
transition: color 80ms, background 80ms;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.waypoint-recent-pill:hover {
|
||||
color: var(--text-normal);
|
||||
background: var(--background-modifier-active-hover);
|
||||
}
|
||||
|
||||
.waypoint-recent-pill.is-active {
|
||||
color: var(--text-on-accent);
|
||||
background: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.waypoint-recent-files .nav-file {
|
||||
padding: 2px 0;
|
||||
}
|
||||
@@ -179,7 +236,7 @@
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
cursor: var(--cursor);
|
||||
cursor: var(--cursor-link, pointer);
|
||||
color: var(--text-faint);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -202,10 +259,11 @@
|
||||
.waypoint-favorites .waypoint-bookmark-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: var(--wp-row-spacing, 4px);
|
||||
padding: 2px 8px 2px 4px;
|
||||
font-size: var(--font-ui-small);
|
||||
cursor: var(--cursor);
|
||||
font-size: var(--wp-font-size, 13px);
|
||||
min-height: var(--wp-row-size, 26px);
|
||||
cursor: var(--cursor-link, pointer);
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
@@ -219,6 +277,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: var(--wp-icon-size, 16px);
|
||||
height: var(--wp-icon-size, 16px);
|
||||
}
|
||||
|
||||
.waypoint-favorites .waypoint-bookmark-item .waypoint-bm-label {
|
||||
@@ -243,13 +303,34 @@
|
||||
}
|
||||
|
||||
.waypoint-favorites .waypoint-bookmark-children {
|
||||
padding-left: 16px;
|
||||
padding-left: var(--wp-indent-size, 16px);
|
||||
}
|
||||
|
||||
.waypoint-favorites .waypoint-bookmark-children.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-type="waypoint-view"] button.waypoint-header-more,
|
||||
button.waypoint-calendar-nav-btn,
|
||||
button.waypoint-calendar-today-btn {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.waypoint-calendar-breadcrumb {
|
||||
gap: 4px;
|
||||
|
||||
.waypoint-clickable {
|
||||
padding: 0px;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Drag-and-drop ── */
|
||||
|
||||
.waypoint-bm-dragging {
|
||||
@@ -265,6 +346,11 @@
|
||||
border-bottom: 3px solid var(--text-accent) !important;
|
||||
}
|
||||
|
||||
.waypoint-favorites .waypoint-bm-drop-into {
|
||||
background-color: var(--background-modifier-active-hover) !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── Separator & Spacer ── */
|
||||
|
||||
.waypoint-favorites .waypoint-bookmark-item.waypoint-bookmark-separator {
|
||||
@@ -291,4 +377,54 @@
|
||||
border: none;
|
||||
cursor: grab;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Settings Tabs ── */
|
||||
|
||||
.waypoint-settings-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.waypoint-settings-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: var(--cursor-link, pointer);
|
||||
color: var(--text-muted);
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
font-size: var(--font-ui-small);
|
||||
font-weight: var(--font-medium);
|
||||
transition: color 100ms, background 100ms;
|
||||
}
|
||||
|
||||
.waypoint-settings-tab:hover {
|
||||
color: var(--text-normal);
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.waypoint-settings-tab.is-active {
|
||||
color: var(--text-accent);
|
||||
background: var(--background-modifier-active-hover);
|
||||
border-bottom: 2px solid var(--text-accent);
|
||||
margin-bottom: -9px;
|
||||
}
|
||||
|
||||
/* ── Icon picker ── */
|
||||
|
||||
/* Sits left of Cancel/Save so clearing reads as a separate, secondary action. */
|
||||
.waypoint-icon-clear {
|
||||
margin-right: auto;
|
||||
cursor: var(--cursor-link, pointer);
|
||||
}
|
||||
|
||||
/* ── Settings: date systems ── */
|
||||
|
||||
.waypoint-settings-warning {
|
||||
margin-top: 4px;
|
||||
color: var(--text-error);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
- [ ] when user middle clicks on a bookmark, it should open in a new tab
|
||||
- [ ] the sidebar should never scroll. the content of recent files should wrap after 10 files or so (no sidebar visible)
|
||||
- [ ] it should be possible to add a separator line and/or an empty space
|
||||
- [ ] by default, the bookmark should have no icon
|
||||
- [ ]
|
||||
- [x] bookmarks
|
||||
- [x] when user middle clicks on a bookmark, it should open in a new tab
|
||||
- [x] in the bookmarks, i want to have notes that are parents to other notes
|
||||
- [x] by default, the bookmark should have no icon
|
||||
- [x] the way to search for icons should be improved. on the lucide icon website, the search works by more than only the icon's title. i want to be able to load all the icon (not by default though)
|
||||
- [x] add a right click menu on the recent files to make them bookmarks
|
||||
- [x] improve the drag and drop to have identations and groups
|
||||
- [x] calendar
|
||||
- [x] middle click on a date/week/etc. should open it in a new tab
|
||||
- [x] i want to improve the look of the calendar. more space, a bit more height
|
||||
- [x] when i hover over something that's clickable, I should see the correct cursor
|
||||
- [x] recent files
|
||||
- [x] I want to be able to filter the recent files. i want to have a little tag, that when i selectt, wsill show only recents notes where type == meeting, type == person
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
"ES7",
|
||||
"ES2017"
|
||||
],
|
||||
"paths": {
|
||||
"src/*": ["./src/*"]
|
||||
|
||||
Reference in New Issue
Block a user