diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6991d1f..e98bbc9 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -10,18 +10,19 @@ src/ ├── main.ts Plugin entry — lifecycle, commands, events, data persistence ├── settings.ts Interface definitions + DEFAULT_SETTINGS constant -├── settings-tab.ts PluginSettingTab UI (calendar, periodic paths, recent files) +├── settings-tab.ts PluginSettingTab UI — tabs: calendar, periodic, recent, display, about ├── models/ │ └── bookmark.ts BookmarkItem + WaypointData interfaces ├── utils/ -│ └── date-utils.ts Moment.js helpers: period formatting, month grid, navigation +│ ├── date-utils.ts Moment.js helpers: period formatting, month grid, navigation +│ └── path-utils.ts Pure path helper: rename remapping (no `obsidian` import, unit-testable) └── views/ └── waypoint-view.ts ItemView subclass — full sidebar rendering + interaction ``` -**Build:** esbuild bundles `src/main.ts` → `main.js`. TypeScript strict, ESNext target. +**Build:** `npm run build` runs `tsc --noEmit` first, then esbuild bundles `src/main.ts` → `main.js`. The typecheck is a real gate — esbuild strips types without checking them, so without it type errors would ship. `npm run typecheck` runs the check alone; `npm run dev` (watch) stays ungated for speed. TypeScript strict-null + `noImplicitAny`, ESNext modules, ES6 target, `lib` floor at ES2017. -**Data persistence:** Single `data.json` via Obsidian `Plugin.loadData()/saveData()`. Top-level keys: `settings` (merged with `DEFAULT_SETTINGS`), `waypointData` (bookmark tree). Recent files are in-memory only (lost on vault close). +**Data persistence:** Single `data.json` via Obsidian `Plugin.loadData()/saveData()`. Top-level keys: `settings` (merged with `DEFAULT_SETTINGS`), `waypointData` (bookmark tree **and** recent files). Recent files are persisted under `waypointData.recentFiles` and reloaded on startup; writes are debounced 300ms so rapid file opens collapse into one save. --- @@ -33,7 +34,7 @@ src/ 2. **Register view** — `WAYPOINT_VIEW_TYPE = "waypoint-view"` maps to `WaypointView` factory `(leaf) => new WaypointView(leaf, this)`. 3. **Settings tab** — `WaypointSettingTab` receives the live `settings` object + `() => this.redrawAll()` callback for live preview. 4. **Register commands** (see Commands section below). -5. **Register events** — `file-open`, `vault:create`, `vault:delete`, `vault:rename`. +5. **Register events** — `file-open`, `vault:create`, `vault:delete`, `vault:rename`, `vault:modify`. 6. **Auto-open** — On `onLayoutReady`, if no existing leaves of the view type, opens one in the left sidebar. 7. **Midnight refresh** — 10-minute `setInterval` checks if `new Date().toDateString()` changed; if so, calls `redrawAll()` (calendar day indicators refresh). @@ -55,7 +56,8 @@ interface WaypointSettings { monthly: PeriodNoteSettings; quarterly: PeriodNoteSettings; yearly: PeriodNoteSettings; - recentFiles: RecentFilesSettings; // maxItems, updateOn, omittedPaths[], omittedTags[] + recentFiles: RecentFilesSettings; // maxItems, updateOn, omittedPaths[], omittedTags[], filterTags[] + display: DisplaySettings; // px sizing for bookmark rows, fonts, icons, calendar cells } interface PeriodNoteSettings { @@ -74,7 +76,17 @@ interface RecentFilesSettings { maxItems: number; // default 50 updateOn: 'file-open' | 'file-edit'; // trigger mode omittedPaths: string[]; // regex patterns (one per line) - omittedTags: string[]; // regex patterns for frontmatter tags + omittedTags: string[]; // regex patterns for frontmatter/inline tags + filterTags: string[]; // tags shown as filter pills (empty = auto-detect from frontmatter) +} + +interface DisplaySettings { + rowSize: number; // px, base height of bookmark items (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) } ``` @@ -94,10 +106,11 @@ interface BookmarkItem { interface WaypointData { bookmarks: BookmarkItem[]; + recentFiles: { path: string; basename: string }[]; } ``` -The bookmark tree is stored flat in `data.json` under `waypointData.bookmarks` but rendered recursively. Groups contain children; files/separators/spacers are leaf nodes. +The bookmark tree is stored under `waypointData.bookmarks` and rendered recursively. Groups contain children; files/separators/spacers are leaf nodes. `waypointData.recentFiles` holds the persisted recent-files list (mirrored into the live `plugin.recentFiles` array on load). --- @@ -121,15 +134,9 @@ All call `openPeriodNote(period, moment())` — opens or creates the current per These call `navigatePeriodNote(direction)` which: 1. Gets the active file's basename. -2. Calls `detectPeriodType(basename)` — regex matching: - - `^\d{4}-\d{2}-\d{2}$` → daily - - `^\d{4}-W\d{2}$` → weekly - - `^\d{4}-\d{2}$` → monthly - - `^\d{4}-Q[1-4]$` → quarterly - - `^\d{4}$` → yearly -3. Parses date via `moment(basename, format)`. -4. Adds ±1 period (quarters add ±3 months). -5. Calls `openPeriodNote` for the new date. +2. Calls `detectPeriodType(basename)` — **settings-driven**, not hardcoded regexes. It reads `settings.{daily,weekly,monthly,quarterly,yearly}.nameFormat` and tries `moment(basename, nameFormat, true).isValid()` (strict parsing) in order day → week → month → quarter → year, returning the first match as `{ period, date }`. Periods with an empty `nameFormat` are skipped; returns `null` if nothing matches. Changing a name format in settings therefore keeps next/prev navigation working. +3. Adds ±1 period (quarters add ±3 months). +4. Calls `openPeriodNote` for the new date. ### Other commands @@ -142,7 +149,15 @@ These call `navigatePeriodNote(direction)` which: ## Period Note Creation (`openPeriodNote`) -For a given period + moment date: +```typescript +openPeriodNote( + period: 'day' | 'week' | 'month' | 'quarter' | 'year', + date: moment.Moment, + leaf?: WorkspaceLeaf, +): Promise +``` + +Single unified opener — there is no separate `openPeriodNoteInLeaf`. For a given period + moment date: 1. **Build filename:** `date.format(periodSettings.nameFormat) + ".md"`. 2. **Build full path:** If `periodSettings.folder` is set, prepend it; otherwise root. @@ -151,7 +166,9 @@ For a given period + moment date: - Try to read template at `periodSettings.templateFile + ".md"`. - If template exists → `vault.create(fullPath, templateContent)`. - If no template → `vault.create(fullPath, minimalFrontmatter)` where frontmatter is `type: {typeProperty}` + `date: YYYY-MM-DD`. -5. **Open in new leaf:** `workspace.getLeaf(false).openFile(file)`. +5. **Open:** in `leaf` when one is supplied, otherwise in `workspace.getLeaf(false)`. + +Middle-click handlers in the view pass an explicit tab leaf: `openPeriodNote(period, date, this.app.workspace.getLeaf('tab'))`. --- @@ -159,32 +176,42 @@ For a given period + moment date: ### `file-open` -If `settings.recentFiles.updateOn === 'file-open'` → calls `addToRecentFiles(file)`. +Tracks the opened file into recent files **only** when `settings.recentFiles.updateOn === 'file-open'`. -If `updateOn === 'file-edit'` → currently skipped (placeholder for future edit-detection). +### `vault:modify` + +Tracks the modified file (guarded to `TFile`) into recent files **only** when `updateOn === 'file-edit'`. Exactly one of the two modes is active at a time, so `'file-edit'` is a working mode rather than a no-op. ### `vault:create` / `vault:delete` -Both trigger `broadcastRedraw()` — tells all WaypointView instances to re-render (needed for calendar note indicators and stale recent file references). +Both update the markdown-basename set used for calendar note indicators, then trigger `broadcastRedraw()` — telling all WaypointView instances to re-render (needed for the indicators and for stale recent file references). ### `vault:rename` -1. Updates `recentFiles` entry if path matches `oldPath`. -2. Calls `updateBookmarkPath(oldPath, newPath)` — recursively scans `waypointData.bookmarks` and updates any `filePath` matching `oldPath`. +1. Updates the markdown-basename set (old basename out, new basename in). +2. Remaps the `recentFiles` entry through `remapRenamedPath(entry.path, oldPath, newPath)`. +3. Remaps every bookmark `filePath` through the same helper. + +`remapRenamedPath(path, oldPath, newPath)` (in `src/utils/path-utils.ts`) returns the updated path when `path` **is** `oldPath` (a plain file rename) *or* is nested under it (`path.startsWith(oldPath + '/')`, i.e. a folder rename/move), and `null` when the path is unaffected. This is why moving a folder no longer orphans the bookmarks inside it. The helper deliberately imports nothing from `'obsidian'` so it stays pure and unit-testable in plain node. --- -## Recent Files (in-memory) +## Recent Files (persisted) ```typescript recentFiles: { path: string; basename: string }[] ``` -**Update flow:** -1. `addToRecentFiles(file)` — dedupes (removes existing entry), prepends to front, truncates to `maxItems`. -2. Calls `broadcastRedraw()`. +Backed by `waypointData.recentFiles` in `data.json`, so the list survives vault reload. `loadWaypointData()` restores it and re-applies the current `maxItems` limit (in case the setting shrank since the last save). -**Omitted paths/tags:** Settings store regex patterns but the current `addToRecentFiles` does **not** filter by them — these are only exposed in the settings UI. The filtering is not yet wired up in the add logic. +**Update flow:** +1. `addToRecentFiles(file)` — omission check, then dedupes (removes existing entry), prepends to front, truncates to `maxItems`. +2. `persistRecentFiles()` — mirrors the array into `waypointData.recentFiles` and schedules a debounced (300ms) `saveWaypointData()`. +3. Calls `broadcastRedraw()`. + +**Omitted paths/tags:** Both filters are applied in `addToRecentFiles`. `omittedPaths` entries are treated as regexes tested against `file.path`; `omittedTags` entries are regexes tested against the file's tags, read via `getAllTags(metadataCache.getFileCache(file))` with the leading `#` stripped. An invalid regex is skipped rather than throwing. + +**`enforceRecentFilesLimit()`:** Called from the settings-change callback — trims the list when `maxItems` is lowered and persists the result. **On file not found:** When clicking a recent file that no longer exists, `focusFile()` shows a Notice and removes the stale entry from `recentFiles`. @@ -222,8 +249,8 @@ The last section (`waypoint-section:last-child`) gets `margin-top: auto`, pushin - **Breadcrumb:** Q-label, month name, year — each clickable to open that period note. - **Nav buttons:** ◀/▶ shift month ±1. "Today" resets to current month. -- **Week number column:** Clicking a week number opens the weekly note for that week's Monday. -- **Day cells:** Click opens daily note. `.other-month` dimmed. `.today` has accent border. `.has-note` gets a dot indicator (checked via `vault.getFiles().some(f => f.basename === dateStr)`). +- **Week number column:** Clicking a week number opens the weekly note for that week's Monday; middle-clicking opens it in a new tab. +- **Day cells:** Click opens daily note, middle-click opens it in a new tab. `.other-month` dimmed. `.today` has accent border. `.has-note` gets a dot indicator, decided by the synchronous O(1) `plugin.hasNoteForDate(dateStr)` (a `Set` lookup — no per-cell vault scan). - **Grid generation:** `getMonthGrid(year, month, firstDayOfWeek)` in `date-utils.ts` produces up to 6 weeks, each with 7 `CalendarDay` objects containing `moment`, `dayOfMonth`, `isToday`, `isCurrentMonth`, `isoWeekNumber`. ### Recent Files Panel @@ -232,7 +259,7 @@ Renders `plugin.recentFiles` as Obsidian-native `nav-file` elements using the fi **Per-file features:** - **Active indicator:** `.is-active` class if path matches `workspace.getActiveFile()`. -- **Remove button:** × icon, appears on hover (`.waypoint-recent-remove`), removes entry from array + saves settings + redraws. +- **Remove button:** × icon, appears on hover (`.waypoint-recent-remove`), drops the entry from `plugin.recentFiles`, calls `persistRecentFiles()`, then redraws. - **Drag:** Uses `app.dragManager.dragFile()` for native Obsidian drag. - **Hover preview:** Triggers `hover-link` event for Obsidian's page preview popup. - **Context menu:** "Open in new tab" + Obsidian's native `file-menu` event. @@ -280,7 +307,7 @@ Renders `plugin.recentFiles` as Obsidian-native `nav-file` elements using the fi **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). +- Grid of matching icons (max 80 shown), loaded from `https://cdn.jsdelivr.net/npm/lucide-static@0.517.0/tags.json` with fallback to `https://lucide.dev/api/tags` and a hardcoded `FALLBACK_ICONS` object (~300 icons). The fetch result is cached for the session, so at most one network round-trip happens no matter how often the picker is opened. - Click to select, "No icon" link to clear, Save/Cancel buttons. --- @@ -345,7 +372,7 @@ Settings and waypoint data share a single `data.json` via Obsidian's `Plugin.loa } ``` -Both `loadSettings()` and `loadWaypointData()` read from the same file, merging partials over defaults. `saveSettings()` and `saveWaypointData()` each re-read the full data, update their key, and write back. This means they must not run concurrently (no locking — relies on Obsidian's synchronous save behavior). +Both `loadSettings()` and `loadWaypointData()` read from the same file, merging partials over defaults. `saveSettings()` and `saveWaypointData()` each re-read the full data, update their key, and write back — so they are serialized through a single-writer save queue: each call chains onto the previous one's promise instead of racing it. Recent-file writes additionally go through the 300ms debounce in `persistRecentFiles()`. --- @@ -353,7 +380,7 @@ Both `loadSettings()` and `loadWaypointData()` read from the same file, merging `broadcastRedraw()` iterates all leaves of `WAYPOINT_VIEW_TYPE` and calls `view.redraw()` on each. `redraw()` is a full DOM rebuild (no virtual DOM, no diffing). This is triggered by: -- File open/create/delete/rename events +- File open/create/delete/rename/modify events - Bookmark add/remove/update - Period navigation - Settings changes (via `onSettingsChange` callback) @@ -365,11 +392,5 @@ The sidebar is re-rendered from scratch on every change. For a small sidebar thi ## Known Limitations / Gaps -1. **Recent files omittedPaths/omittedTags** — Settings UI exposes these regex filters, but `addToRecentFiles()` does not apply them. Files are never filtered out. -2. **`updateOn: 'file-edit'`** — The `file-open` handler returns early for this mode, but no edit-detection event is registered. Recent files never update in edit mode. -3. **No virtual DOM** — Full rebuild on every redraw. Bookmark drag/drop, rename, and any change tears down and rebuilds the entire sidebar. -4. **Concurrent saves** — `saveSettings()` and `saveWaypointData()` both re-read then write `data.json`. Rapid sequential calls could race. -5. **Recent files not persisted** — Cleared on vault reload. Only bookmarks survive restarts. -6. **Note indicators scan all vault files** — `hasNoteForDate()` iterates `vault.getFiles()` on every calendar render (called per day cell). For large vaults, this is O(days × files). Not cached. -7. **`detectPeriodType` uses hardcoded formats** — The regex patterns in `main.ts` don't read from `settings.*.nameFormat`. If a user changes the name format to something non-standard, next/prev navigation breaks. -8. **IconSuggestModal fetches from CDN** — Network dependency on first icon picker open. Falls back to hardcoded list on failure. +1. **No virtual DOM** — Full rebuild on every redraw. Bookmark drag/drop, rename, and any change tears down and rebuilds the entire sidebar. For a small sidebar this is acceptable; for larger bookmark lists it may cause flicker. +2. **IconSuggestModal fetches from CDN** — Network dependency for the Lucide tag list. The fetch happens at most once per session (the result is cached) and falls back to the hardcoded `FALLBACK_ICONS` list on failure, but a first-open with no network still degrades to that reduced list. diff --git a/QA.md b/QA.md index 7bdc5c9..9e0bc46 100644 --- a/QA.md +++ b/QA.md @@ -40,7 +40,7 @@ - [ ] Type "arrow" → should find all arrow icons - [ ] Type a tag keyword like "fitness" → should find `activity`, `dumbbell`, etc. - [ ] Clear search (empty input) → shows first 80 icons -- [ ] Click "No icon" link → clears the icon +- [ ] Set an icon on a bookmark, reopen the picker, click "No icon" → the previously-set icon is cleared - [ ] Click an icon → preview updates, grid highlights selection - [ ] Click Save → icon applied, click Cancel → no change - [ ] Test with **network offline** → falls back to FALLBACK_ICONS list @@ -56,12 +56,14 @@ - [ ] 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