Initial commit: Waypoint Obsidian plugin with calendar, recent files, and bookmarks
This commit is contained in:
+352
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// ── Bookmark data model ──
|
||||
|
||||
export interface BookmarkItem {
|
||||
id: string;
|
||||
type: 'file' | 'group' | 'separator' | 'spacer';
|
||||
label: string;
|
||||
filePath: string;
|
||||
icon: string;
|
||||
children: BookmarkItem[];
|
||||
collapsed: boolean;
|
||||
indent: number;
|
||||
}
|
||||
|
||||
export interface WaypointData {
|
||||
bookmarks: BookmarkItem[];
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Setting, PluginSettingTab, App, Plugin } from 'obsidian';
|
||||
import { WaypointSettings, DEFAULT_SETTINGS, PeriodNoteSettings } from 'src/settings';
|
||||
|
||||
export class WaypointSettingTab extends PluginSettingTab {
|
||||
private plugin: Plugin;
|
||||
private settings: WaypointSettings;
|
||||
private onSettingsChange: () => void;
|
||||
|
||||
constructor(app: App, plugin: Plugin, settings: WaypointSettings, onSettingsChange: () => void) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
this.settings = settings;
|
||||
this.onSettingsChange = onSettingsChange;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
// ── Calendar Section ──
|
||||
new Setting(containerEl).setHeading().setName('Calendar');
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('First day of week')
|
||||
.setDesc('Which day the calendar week starts on.')
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption('0', 'Sunday')
|
||||
.addOption('1', 'Monday')
|
||||
.setValue(String(this.settings.calendar.firstDayOfWeek))
|
||||
.onChange((value) => {
|
||||
this.settings.calendar.firstDayOfWeek = parseInt(value, 10);
|
||||
this.saveAndRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Show note indicators')
|
||||
.setDesc('Show a dot on days that have existing notes.')
|
||||
.addToggle((toggle) => {
|
||||
toggle
|
||||
.setValue(this.settings.calendar.showNoteIndicators)
|
||||
.onChange((value) => {
|
||||
this.settings.calendar.showNoteIndicators = value;
|
||||
this.saveAndRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Periodic Note Settings ──
|
||||
new Setting(containerEl).setHeading().setName('Periodic Note Paths');
|
||||
|
||||
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);
|
||||
|
||||
// ── Recent Files Section ──
|
||||
new Setting(containerEl).setHeading().setName('Recent Files');
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Max items')
|
||||
.setDesc('Maximum number of recent files to track.')
|
||||
.addText((text) => {
|
||||
text.inputEl.setAttr('type', 'number');
|
||||
text.inputEl.setAttr('placeholder', '50');
|
||||
text.setValue(String(this.settings.recentFiles.maxItems));
|
||||
text.inputEl.onblur = () => {
|
||||
const val = parseInt(text.getValue(), 10);
|
||||
if (!isNaN(val) && val > 0) {
|
||||
this.settings.recentFiles.maxItems = val;
|
||||
this.saveAndRefresh();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Update on')
|
||||
.setDesc('When to add a file to the recent list.')
|
||||
.addDropdown((dropdown) => {
|
||||
dropdown
|
||||
.addOption('file-open', 'File opened')
|
||||
.addOption('file-edit', 'File changed')
|
||||
.setValue(this.settings.recentFiles.updateOn)
|
||||
.onChange((value: 'file-open' | 'file-edit') => {
|
||||
this.settings.recentFiles.updateOn = value;
|
||||
this.saveAndRefresh();
|
||||
});
|
||||
});
|
||||
|
||||
const pathDesc = new DocumentFragment();
|
||||
pathDesc.appendText('Regex patterns for paths to exclude. One per line.');
|
||||
new Setting(containerEl)
|
||||
.setName('Omitted paths')
|
||||
.setDesc(pathDesc)
|
||||
.addTextArea((text) => {
|
||||
text.inputEl.setAttr('rows', 4);
|
||||
text.setPlaceholder('^archives/\n\\.png$');
|
||||
text.setValue(this.settings.recentFiles.omittedPaths.join('\n'));
|
||||
text.inputEl.onblur = () => {
|
||||
this.settings.recentFiles.omittedPaths = text.getValue().split('\n').filter(p => p.trim());
|
||||
this.saveAndRefresh();
|
||||
};
|
||||
});
|
||||
|
||||
const tagDesc = new DocumentFragment();
|
||||
tagDesc.appendText('Regex patterns for frontmatter tags to exclude. One per line.');
|
||||
new Setting(containerEl)
|
||||
.setName('Omitted tags')
|
||||
.setDesc(tagDesc)
|
||||
.addTextArea((text) => {
|
||||
text.inputEl.setAttr('rows', 4);
|
||||
text.setPlaceholder('ignore\narchive');
|
||||
text.setValue(this.settings.recentFiles.omittedTags.join('\n'));
|
||||
text.inputEl.onblur = () => {
|
||||
this.settings.recentFiles.omittedTags = text.getValue().split('\n').filter(t => t.trim());
|
||||
this.saveAndRefresh();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private addPeriodNoteSettings(label: string, period: PeriodNoteSettings): void {
|
||||
new Setting(this.containerEl).setHeading().setName(label);
|
||||
|
||||
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;
|
||||
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);
|
||||
this.onSettingsChange();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// ── Waypoint Settings ──
|
||||
|
||||
export interface PeriodNoteSettings {
|
||||
folder: string;
|
||||
templateFile: string;
|
||||
nameFormat: string;
|
||||
typeProperty: string;
|
||||
}
|
||||
|
||||
export interface CalendarSettings {
|
||||
firstDayOfWeek: number; // 0=Sunday, 1=Monday
|
||||
showNoteIndicators: boolean;
|
||||
}
|
||||
|
||||
export interface RecentFilesSettings {
|
||||
maxItems: number;
|
||||
updateOn: 'file-open' | 'file-edit';
|
||||
omittedPaths: string[];
|
||||
omittedTags: string[];
|
||||
}
|
||||
|
||||
export interface WaypointSettings {
|
||||
calendar: CalendarSettings;
|
||||
daily: PeriodNoteSettings;
|
||||
weekly: PeriodNoteSettings;
|
||||
monthly: PeriodNoteSettings;
|
||||
quarterly: PeriodNoteSettings;
|
||||
yearly: PeriodNoteSettings;
|
||||
recentFiles: RecentFilesSettings;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: WaypointSettings = {
|
||||
calendar: {
|
||||
firstDayOfWeek: 1, // Monday
|
||||
showNoteIndicators: true,
|
||||
},
|
||||
daily: {
|
||||
folder: 'periodic/daily',
|
||||
templateFile: 'Templates/Daily note',
|
||||
nameFormat: 'YYYY-MM-DD',
|
||||
typeProperty: 'daily-note',
|
||||
},
|
||||
weekly: {
|
||||
folder: 'periodic/weekly',
|
||||
templateFile: 'Templates/Weekly note',
|
||||
nameFormat: 'GGGG-[W]WW',
|
||||
typeProperty: 'weekly-note',
|
||||
},
|
||||
monthly: {
|
||||
folder: 'periodic/monthly',
|
||||
templateFile: 'Templates/Monthly note',
|
||||
nameFormat: 'YYYY-MM',
|
||||
typeProperty: 'monthly-note',
|
||||
},
|
||||
quarterly: {
|
||||
folder: 'periodic/quarterly',
|
||||
templateFile: 'Templates/Quarterly note',
|
||||
nameFormat: 'YYYY-[Q]Q',
|
||||
typeProperty: 'quarterly-note',
|
||||
},
|
||||
yearly: {
|
||||
folder: 'periodic/yearly',
|
||||
templateFile: 'Templates/Yearly note',
|
||||
nameFormat: 'YYYY',
|
||||
typeProperty: 'yearly-note',
|
||||
},
|
||||
recentFiles: {
|
||||
maxItems: 50,
|
||||
updateOn: 'file-open',
|
||||
omittedPaths: [],
|
||||
omittedTags: [],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
// ── Date utilities ──
|
||||
// Uses moment.js which is bundled with Obsidian.
|
||||
// Period format helpers following O-vault conventions.
|
||||
|
||||
import { moment } from 'obsidian';
|
||||
|
||||
export interface PeriodInfo {
|
||||
day: string; // yyyy-MM-dd
|
||||
week: string; // GGGG-[W]WW
|
||||
month: string; // yyyy-MM
|
||||
quarter: string; // yyyy-[Q]Q
|
||||
year: string; // yyyy
|
||||
}
|
||||
|
||||
export interface CalendarDay {
|
||||
date: moment.Moment;
|
||||
dayOfMonth: number;
|
||||
isToday: boolean;
|
||||
isCurrentMonth: boolean;
|
||||
isoWeekNumber: number;
|
||||
}
|
||||
|
||||
export interface CalendarWeek {
|
||||
weekNumber: number;
|
||||
days: CalendarDay[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get period info for a given date.
|
||||
*/
|
||||
export function getPeriodInfo(date: moment.Moment): PeriodInfo {
|
||||
return {
|
||||
day: date.format('YYYY-MM-DD'),
|
||||
week: date.format('GGGG-[W]WW'),
|
||||
month: date.format('YYYY-MM'),
|
||||
quarter: date.format('YYYY-[Q]Q'),
|
||||
year: date.format('YYYY'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quarter string from a moment date (e.g. "2026-Q3").
|
||||
*/
|
||||
export function getQuarterString(date: moment.Moment): string {
|
||||
return date.format('YYYY-[Q]Q');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current date's period info.
|
||||
*/
|
||||
export function getTodayPeriod(): PeriodInfo {
|
||||
return getPeriodInfo(moment());
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a month grid as an array of weeks.
|
||||
* Each week has 7 days, starting on the configured first day of week.
|
||||
* ISO week numbers are calculated from the Monday of that week row.
|
||||
*/
|
||||
export function getMonthGrid(
|
||||
year: number,
|
||||
month: number, // 0-indexed (0 = January)
|
||||
firstDayOfWeek: number, // 0 = Sunday, 1 = Monday
|
||||
): CalendarWeek[] {
|
||||
const firstOfMonth = moment({ year, month, day: 1 });
|
||||
const lastOfMonth = moment(firstOfMonth).endOf('month');
|
||||
|
||||
// Find the first day to display (may go back into previous month)
|
||||
const startDate = moment(firstOfMonth)
|
||||
.subtract((firstOfMonth.day() - firstDayOfWeek + 7) % 7, 'days');
|
||||
|
||||
const today = moment().startOf('day');
|
||||
const weeks: CalendarWeek[] = [];
|
||||
let currentDay = moment(startDate);
|
||||
|
||||
while (currentDay.isBefore(lastOfMonth) || currentDay.month() === month) {
|
||||
const weekDays: CalendarDay[] = [];
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
weekDays.push({
|
||||
date: moment(currentDay),
|
||||
dayOfMonth: currentDay.date(),
|
||||
isToday: currentDay.isSame(today, 'day'),
|
||||
isCurrentMonth: currentDay.month() === month,
|
||||
isoWeekNumber: currentDay.isoWeek(),
|
||||
});
|
||||
currentDay.add(1, 'day');
|
||||
}
|
||||
|
||||
weeks.push({
|
||||
weekNumber: weekDays[0].isoWeekNumber,
|
||||
days: weekDays,
|
||||
});
|
||||
|
||||
// Safety: break after 6 weeks
|
||||
if (weeks.length >= 6) break;
|
||||
}
|
||||
|
||||
return weeks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date label for the breadcrumb (e.g. "June 1st, 2026").
|
||||
*/
|
||||
export function formatDateLabel(date: moment.Moment): string {
|
||||
return date.format('MMMM Do, YYYY');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a month label (e.g. "June 2026").
|
||||
*/
|
||||
export function formatMonthLabel(date: moment.Moment): string {
|
||||
return date.format('MMMM YYYY');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get readable quarter label (e.g. "Q3 2026").
|
||||
*/
|
||||
export function formatQuarterLabel(date: moment.Moment): string {
|
||||
return date.format('[Q]Q YYYY');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate a date by a given period and amount.
|
||||
*/
|
||||
export function navigateDate(
|
||||
date: moment.Moment,
|
||||
period: 'day' | 'week' | 'month' | 'quarter' | 'year',
|
||||
delta: number,
|
||||
): moment.Moment {
|
||||
if (period === 'quarter') {
|
||||
return moment(date).add(delta * 3, 'months');
|
||||
}
|
||||
return moment(date).add(delta, period);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a note filename from a date using the given format string.
|
||||
*/
|
||||
export function formatPeriodName(date: moment.Moment, format: string): string {
|
||||
return date.format(format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ISO week number for a given date.
|
||||
*/
|
||||
export function getISOWeek(date: moment.Moment): number {
|
||||
return date.isoWeek();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user