Initial commit: Waypoint Obsidian plugin with calendar, recent files, and bookmarks

This commit is contained in:
2026-06-03 20:26:23 -04:00
commit fbdc42b4a3
16 changed files with 3400 additions and 0 deletions
+352
View File
@@ -0,0 +1,352 @@
// ── Main plugin entry ──
import {
Plugin,
WorkspaceLeaf,
ItemView,
Notice,
TFile,
TAbstractFile,
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';
const DEFAULT_DATA: WaypointData = {
bookmarks: [],
};
export default class WaypointPlugin extends Plugin {
public settings: WaypointSettings;
public waypointData: WaypointData;
public recentFiles: { path: string; basename: string }[] = [];
async onload(): Promise<void> {
console.debug('Waypoint: loading plugin v' + this.manifest.version);
// Load persisted data
await this.loadSettings();
await this.loadWaypointData();
// 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.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-today',
name: 'Go to today\'s daily note',
callback: async () => {
await this.openPeriodNote('day', moment());
},
});
// ── Events ──
this.registerEvent(
this.app.workspace.on('file-open', (file: TFile | null) => {
if (file) this.onFileOpen(file);
}),
);
this.registerEvent(
this.app.vault.on('create', () => this.onVaultChange()),
);
this.registerEvent(
this.app.vault.on('delete', () => this.onVaultChange()),
);
this.registerEvent(
this.app.vault.on('rename', (file, oldPath) => this.onRename(file, oldPath)),
);
// Auto-open view on first load
this.app.workspace.onLayoutReady(() => {
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 });
}
}
});
// 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<void> {
this.app.workspace.detachLeavesOfType(WAYPOINT_VIEW_TYPE);
}
// ── Settings ──
async loadSettings(): Promise<void> {
const saved = await this.loadData() as Record<string, unknown> | null;
const s = (saved?.settings || {}) as Partial<WaypointSettings>;
this.settings = Object.assign({}, DEFAULT_SETTINGS, s);
}
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);
}
async saveWaypointData(): Promise<void> {
const all = (await this.loadData()) as Record<string, unknown> || {};
all.waypointData = this.waypointData;
await this.saveData(all);
}
// ── 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;
}
this.addToRecentFiles(file);
}
addToRecentFiles(file: TFile): void {
this.recentFiles = this.recentFiles.filter(f => f.path !== file.path);
this.recentFiles.unshift({ path: file.path, basename: file.basename });
// Prune
if (this.recentFiles.length > this.settings.recentFiles.maxItems) {
this.recentFiles = this.recentFiles.slice(0, this.settings.recentFiles.maxItems);
}
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();
}
// Update bookmark file paths
this.updateBookmarkPath(oldPath, file.path);
}
private onVaultChange(): void {
this.broadcastRedraw();
}
// ── Bookmarks ──
addBookmark(filePath: string, label: string, type: 'file' | 'group' | 'separator' | 'spacer' = 'file', icon = 'file'): 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<BookmarkItem>): 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();
}
}
private updateBookmarkPath(oldPath: string, newPath: string): void {
const updateRecursive = (items: BookmarkItem[]) => {
for (const item of items) {
if (item.filePath === oldPath) {
item.filePath = newPath;
}
if (item.children) updateRecursive(item.children);
}
};
updateRecursive(this.waypointData.bookmarks);
}
// ── Period note creation/opening ──
async openPeriodNote(period: 'day' | 'week' | 'month' | 'quarter' | 'year', date: moment.Moment): Promise<void> {
// Map period to settings and date format
type PeriodConfig = {
settings: PeriodNoteSettings;
label: string;
};
const configs: Record<string, PeriodConfig> = {
day: { settings: this.settings.daily, label: 'Daily' },
week: { settings: this.settings.weekly, label: 'Weekly' },
month: { settings: this.settings.monthly, label: 'Monthly' },
quarter: { settings: this.settings.quarterly, label: 'Quarterly' },
year: { settings: this.settings.yearly, label: 'Yearly' },
};
const config = configs[period];
if (!config) return;
const { settings: periodSettings } = config;
const filename = date.format(periodSettings.nameFormat) + '.md';
const fullPath = periodSettings.folder
? `${periodSettings.folder}/${filename}`
: filename;
// Check if exists
let file = this.app.vault.getFileByPath(fullPath);
if (!file) {
// Create it from template
try {
// Try to find template file
const templatePath = periodSettings.templateFile + '.md';
const templateFile = this.app.vault.getFileByPath(templatePath);
if (templateFile) {
const templateContent = await this.app.vault.read(templateFile);
file = await this.app.vault.create(fullPath, templateContent);
} else {
// Fallback: create with minimal frontmatter
const content = `---\ntype: ${periodSettings.typeProperty}\ndate: ${date.format('YYYY-MM-DD')}\n---\n\n`;
file = await this.app.vault.create(fullPath, content);
}
} catch (err) {
new Notice(`Failed to create ${config.label.toLowerCase()} note: ${err.message}`);
return;
}
new Notice(`Created ${config.label.toLowerCase()} note: ${filename}`);
}
if (file) {
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
}
}
// ── 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();
}
}
}
/**
* 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,
);
}
}