6a1a044566
- onRename remapped paths by exact equality only, so files inside a moved folder kept stale paths and the view then permanently deleted their bookmarks on next click. Add pure remapRenamedPath() (path-utils.ts, obsidian-free so it is unit-testable) and remap recentFiles + the whole bookmark tree, folder moves included. - saveSettings/saveWaypointData each did loadData -> mutate one key -> saveData, so two concurrent saves carried a stale snapshot of the other key and one silently reverted the other. Both now delegate to persistAll(), which writes both keys from memory through a promise-chain mutex. - data.json is read once in onload instead of twice; period sub-objects are deep-merged so old configs pick up new fields. - detectPeriodType now derives from the configured nameFormat with strict moment parsing instead of hardcoded regexes, so custom formats work. - Merge openPeriodNoteInLeaf into openPeriodNote(period, date, leaf?); stop double-appending .md to templateFile; type the caught error as unknown. - hasNoteForDate is synchronous and O(1) over a maintained basename Set; delete dead getNotesForDate. - Implement the previously dead omittedTags filter and the no-op updateOn: 'file-edit' mode (vault modify event).
652 lines
20 KiB
TypeScript
652 lines
20 KiB
TypeScript
// ── 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<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 — 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(
|
|
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<string, string> = { 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<void> {
|
|
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<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] || {});
|
|
}
|
|
}
|
|
|
|
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> {
|
|
await this.persistAll();
|
|
}
|
|
|
|
async saveWaypointData(): Promise<void> {
|
|
await this.persistAll();
|
|
}
|
|
|
|
/**
|
|
* Trim recent files to the current maxItems limit and persist.
|
|
* Called when the maxItems setting changes.
|
|
*/
|
|
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<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();
|
|
}
|
|
}
|
|
|
|
// ── 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<void> {
|
|
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<TFile | null> {
|
|
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<void> {
|
|
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<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(/\.[^/.]+$/, '');
|
|
}
|