// ── Main plugin entry ── import { Plugin, WorkspaceLeaf, Notice, TFile, TAbstractFile, getAllTags, moment, } from 'obsidian'; import { WaypointSettings, DEFAULT_SETTINGS, PeriodNoteSettings } from 'src/settings'; import { WaypointSettingTab } from 'src/settings-tab'; import { WaypointView, WAYPOINT_VIEW_TYPE } from 'src/views/waypoint-view'; import { BookmarkItem, WaypointData } from 'src/models/bookmark'; import { remapRenamedPath } from 'src/utils/path-utils'; export type PeriodKey = 'day' | 'week' | 'month' | 'quarter' | 'year'; export default class WaypointPlugin extends Plugin { public settings: WaypointSettings; public waypointData: WaypointData; public recentFiles: { path: string; basename: string }[] = []; private recentFilesSaveTimer: number | undefined; /** Serializes writes to data.json so overlapping saves cannot lose updates. */ private savePromise: Promise = Promise.resolve(); /** Basenames of every markdown file in the vault, for O(1) calendar lookups. */ private markdownBasenames: Set = new Set(); async onload(): Promise { console.debug('Waypoint: loading plugin v' + this.manifest.version); // Load persisted data — data.json is read exactly once here. const saved = await this.loadData() as Record | null; this.applySettings(saved); this.applyWaypointData(saved); // Register the sidebar view this.registerView( WAYPOINT_VIEW_TYPE, (leaf: WorkspaceLeaf) => new WaypointView(leaf, this), ); // Register settings tab this.addSettingTab(new WaypointSettingTab( this.app, this, this.settings, () => { this.enforceRecentFilesLimit(); this.redrawAll(); }, )); // ── Commands ── this.addCommand({ id: 'waypoint-open-view', name: 'Open Waypoint sidebar', callback: async () => { const leaves = this.app.workspace.getLeavesOfType(WAYPOINT_VIEW_TYPE); if (leaves.length > 0) { await this.app.workspace.revealLeaf(leaves[0]); } else { const leaf = this.app.workspace.getLeftLeaf(false); if (leaf) { await leaf.setViewState({ type: WAYPOINT_VIEW_TYPE }); await this.app.workspace.revealLeaf(leaf); } } }, }); this.addCommand({ id: 'waypoint-add-bookmark', name: 'Add current file as Waypoint bookmark', callback: async () => { const file = this.app.workspace.getActiveFile(); if (!file) { new Notice('No active file'); return; } this.addBookmark(file.path, file.basename, 'file'); new Notice(`Bookmarked: ${file.basename}`); }, }); this.addCommand({ id: 'waypoint-go-to-daily', name: 'Go to daily note', hotkeys: [{ modifiers: ['Mod', 'Shift', 'Alt'], key: 'd' }], callback: async () => { await this.openPeriodNote('day', moment()); }, }); this.addCommand({ id: 'waypoint-go-to-weekly', name: 'Go to weekly note', hotkeys: [{ modifiers: ['Mod', 'Shift', 'Alt'], key: 'w' }], callback: async () => { await this.openPeriodNote('week', moment()); }, }); this.addCommand({ id: 'waypoint-go-to-monthly', name: 'Go to monthly note', hotkeys: [{ modifiers: ['Mod', 'Shift', 'Alt'], key: 'm' }], callback: async () => { await this.openPeriodNote('month', moment()); }, }); this.addCommand({ id: 'waypoint-go-to-quarterly', name: 'Go to quarterly note', hotkeys: [{ modifiers: ['Mod', 'Shift', 'Alt'], key: 'q' }], callback: async () => { await this.openPeriodNote('quarter', moment()); }, }); this.addCommand({ id: 'waypoint-go-to-yearly', name: 'Go to yearly note', hotkeys: [{ modifiers: ['Mod', 'Shift', 'Alt'], key: 'y' }], callback: async () => { await this.openPeriodNote('year', moment()); }, }); // ── Period navigation commands (no hotkeys) ── const directions = ['next', 'prev'] as const; const periodLabels = ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'] as const; const directionLabels: Record = { next: 'Next', prev: 'Previous' }; for (const period of periodLabels) { for (const dir of directions) { const id = `waypoint-go-to-${dir}-${period}`; const name = `${directionLabels[dir]} ${period} note`; this.addCommand({ id, name, callback: async () => { await this.navigatePeriodNote(dir); }, }); } } // ── Events ── this.registerEvent( this.app.workspace.on('file-open', (file: TFile | null) => { if (file) this.onFileOpen(file); }), ); this.registerEvent( this.app.vault.on('create', (file: TAbstractFile) => this.onVaultCreate(file)), ); this.registerEvent( 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(); } }); // Midnight refresh (check every 10 min) let lastDate = new Date().toDateString(); this.registerInterval( window.setInterval(() => { const currentDate = new Date().toDateString(); if (currentDate !== lastDate) { lastDate = currentDate; this.redrawAll(); } }, 600000), // 10 min ); } async onunload(): Promise { this.app.workspace.detachLeavesOfType(WAYPOINT_VIEW_TYPE); } // ── Settings & persistence ── /** * 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 | null): void { const s = (saved?.settings || {}) as Partial; this.settings = Object.assign({}, DEFAULT_SETTINGS, s); this.settings.recentFiles = Object.assign({}, DEFAULT_SETTINGS.recentFiles, s.recentFiles || {}); this.settings.calendar = Object.assign({}, DEFAULT_SETTINGS.calendar, s.calendar || {}); this.settings.display = Object.assign({}, DEFAULT_SETTINGS.display, s.display || {}); for (const key of PERIOD_SETTING_KEYS) { this.settings[key] = Object.assign({}, DEFAULT_SETTINGS[key], s[key] || {}); } } private applyWaypointData(saved: Record | null): void { const d = (saved?.waypointData || {}) as Partial; 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 { 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 { await this.persistAll(); } async saveWaypointData(): Promise { 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-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 }); // Apply max items limit if (this.recentFiles.length > this.settings.recentFiles.maxItems) { this.recentFiles = this.recentFiles.slice(0, this.settings.recentFiles.maxItems); } this.persistRecentFiles(); this.broadcastRedraw(); } /** * Apply the omittedPaths / omittedTags filters. Each entry is treated as a * regex; an invalid pattern is skipped rather than throwing. */ private isOmittedFromRecentFiles(file: TFile): boolean { for (const pattern of this.settings.recentFiles.omittedPaths) { try { if (new RegExp(pattern).test(file.path)) return true; } catch { // Invalid regex, skip } } const omittedTags = this.settings.recentFiles.omittedTags; if (omittedTags.length === 0) return false; const cache = this.app.metadataCache.getFileCache(file); const tags = (cache ? getAllTags(cache) : null) || []; if (tags.length === 0) return false; const bareTags = tags.map(tag => tag.replace(/^#/, '')); for (const pattern of omittedTags) { try { const regex = new RegExp(pattern); if (bareTags.some(tag => regex.test(tag))) return true; } catch { // Invalid regex, skip } } return false; } /** * Obsidian fires `rename` for folders too, so a stored path may need * remapping either because it is the renamed item or because it sits * inside a renamed folder. */ private onRename(file: TAbstractFile, oldPath: string): void { const 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 = ''): BookmarkItem { const id = `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const item: BookmarkItem = { id, type, label, filePath: type === 'file' ? filePath : '', icon, children: [], collapsed: false, indent: 0, }; this.waypointData.bookmarks.push(item); this.saveWaypointData(); this.broadcastRedraw(); return item; } removeBookmark(id: string): void { const removeRecursive = (items: BookmarkItem[]): boolean => { const idx = items.findIndex(i => i.id === id); if (idx >= 0) { items.splice(idx, 1); return true; } for (const item of items) { if (item.children && removeRecursive(item.children)) return true; } return false; }; removeRecursive(this.waypointData.bookmarks); this.saveWaypointData(); this.broadcastRedraw(); } updateBookmark(id: string, updates: Partial): void { const findRecursive = (items: BookmarkItem[]): BookmarkItem | null => { for (const item of items) { if (item.id === id) return item; if (item.children) { const found = findRecursive(item.children); if (found) return found; } } return null; }; const item = findRecursive(this.waypointData.bookmarks); if (item) { Object.assign(item, updates); this.saveWaypointData(); this.broadcastRedraw(); } } // ── Period note creation/opening ── /** * 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 { const config = PERIOD_CONFIGS[period]; const periodSettings = this.settings[config.key]; const filename = date.format(periodSettings.nameFormat) + '.md'; const fullPath = periodSettings.folder ? `${periodSettings.folder}/${filename}` : filename; let file = this.app.vault.getFileByPath(fullPath); if (!file) { file = await this.createPeriodNote(fullPath, periodSettings, date, config.label); if (!file) return; new Notice(`Created ${config.label.toLowerCase()} note: ${filename}`); } const target = leaf || this.app.workspace.getLeaf(false); await target.openFile(file); } /** Create a period note from its template, or from minimal frontmatter. */ private async createPeriodNote( fullPath: string, periodSettings: PeriodNoteSettings, date: moment.Moment, label: string, ): Promise { try { const templateFile = this.resolveTemplateFile(periodSettings.templateFile); const content = templateFile ? await this.app.vault.read(templateFile) : `---\ntype: ${periodSettings.typeProperty}\ndate: ${date.format('YYYY-MM-DD')}\n---\n\n`; return await this.app.vault.create(fullPath, content); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); new Notice(`Failed to create ${label.toLowerCase()} note: ${message}`); return null; } } /** The setting may or may not already carry the .md extension. */ private resolveTemplateFile(templateFile: string): TFile | null { if (!templateFile) return null; const path = templateFile.toLowerCase().endsWith('.md') ? templateFile : `${templateFile}.md`; return this.app.vault.getFileByPath(path); } // ── Period note navigation (next/prev from current file) ── /** * Detect the period type from a filename's basename using the configured * name formats, so custom formats keep working. Parsing is strict, which * stops a loose format (e.g. YYYY) from swallowing a longer basename. * Returns the period key and the parsed moment, or null if not a period note. */ private detectPeriodType(basename: string): { period: PeriodKey; date: moment.Moment } | null { for (const period of PERIOD_DETECTION_ORDER) { const format = this.settings[PERIOD_CONFIGS[period].key].nameFormat; if (!format) continue; const date = moment(basename, format, true); if (date.isValid()) return { period, date }; } return null; } /** * Navigate to the next or previous period note based on the currently active file. */ async navigatePeriodNote(direction: 'next' | 'prev'): Promise { const file = this.app.workspace.getActiveFile(); if (!file) { new Notice('No active file'); return; } const detected = this.detectPeriodType(file.basename); if (!detected) { new Notice('Current file is not a periodic note (daily/weekly/monthly/quarterly/yearly)'); 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 const newDate = period === 'quarter' ? date.clone().add(amount * 3, 'months') : date.clone().add(amount, `${period}s` as moment.unitOfTime.DurationConstructor); await this.openPeriodNote(period, newDate); } // ── Redraw ── private redrawAll(): void { this.broadcastRedraw(); } private broadcastRedraw(): void { const leaves = this.app.workspace.getLeavesOfType(WAYPOINT_VIEW_TYPE); for (const leaf of leaves) { if (leaf.view instanceof WaypointView) { leaf.view.redraw(); } } } } // ── Module helpers ── type PeriodSettingKey = 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly'; const PERIOD_SETTING_KEYS: PeriodSettingKey[] = ['daily', 'weekly', 'monthly', 'quarterly', 'yearly']; const PERIOD_CONFIGS: Record = { 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(/\.[^/.]+$/, ''); }