Files
date-calculator/main.ts

630 lines
22 KiB
TypeScript

import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, parseYaml, Component, MarkdownPostProcessorContext, MarkdownRenderChild } from 'obsidian';
import { EditorView, ViewPlugin, ViewUpdate, Decoration, DecorationSet, WidgetType } from '@codemirror/view';
import { RangeSetBuilder } from '@codemirror/state';
// Remember to rename these classes and interfaces!
interface MyPluginSettings {
mySetting: string;
}
const DEFAULT_SETTINGS: MyPluginSettings = {
mySetting: 'default'
}
// ===== Helper functions for date calculations =====
function zeroTime(d: Date): Date {
const nd = new Date(d);
nd.setHours(0, 0, 0, 0);
return nd;
}
function parseDate(input: any): Date | null {
if (!input) return null;
const d = new Date(input);
if (isNaN(d.getTime())) return null;
return d;
}
function humanizeDuration(ms: number): string {
const abs = Math.abs(ms);
let seconds = Math.floor(abs / 1000);
let minutes = Math.floor(seconds / 60);
let hours = Math.floor(minutes / 60);
let days = Math.floor(hours / 24);
let years = Math.floor(days / 365);
days = days % 365;
let months = Math.floor(days / 30);
days = days % 30;
hours = hours % 24;
minutes = minutes % 60;
seconds = seconds % 60;
const parts: string[] = [];
if (years) parts.push(`${years} year${years !== 1 ? 's' : ''}`);
if (months) parts.push(`${months} month${months !== 1 ? 's' : ''}`);
if (days) parts.push(`${days} day${days !== 1 ? 's' : ''}`);
if (hours && parts.length < 3) parts.push(`${hours} hour${hours !== 1 ? 's' : ''}`);
if (minutes && parts.length < 3) parts.push(`${minutes} minute${minutes !== 1 ? 's' : ''}`);
if (seconds && parts.length < 3) parts.push(`${seconds} second${seconds !== 1 ? 's' : ''}`);
if (!parts.length) return '0 seconds';
return parts.join(', ');
}
// Shared function to process inline date-calc code
function processInlineDateCalc(raw: string, app: App, sourcePath: string): string {
if (!/^date-calc\s*:/.test(raw)) return '';
const params = raw.replace(/^date-calc\s*:/, '').trim();
let cfg: any = {};
if (params) {
// Try YAML first (supports inline map like {a: b, c: d} or multiline pasted)
try {
cfg = parseYaml(params) || {};
} catch {
// Fallback: parse key=value pairs separated by whitespace
cfg = {};
params.split(/\s+/).forEach(pair => {
const eq = pair.indexOf('=');
if (eq > 0) {
const k = pair.slice(0, eq).trim();
const v = pair.slice(eq + 1).trim();
if (k) (cfg as any)[k] = v;
}
});
}
}
// Infer type if missing (also support bare-word like `date-calc:birthday`)
let type = (cfg as any)?.type ? (cfg.type as any).toString().toLowerCase().trim() : '';
if (!type) {
// If YAML parsed into a string, decide if it's a bare type or a key=value expression
if (typeof cfg === 'string') {
const s = cfg.trim();
if (/^[A-Za-z][A-Za-z-]*$/.test(s)) {
type = s.toLowerCase();
cfg = {};
} else if (s.includes('=')) {
// Parse as key=value pairs
const tmp: any = {};
s.split(/\s+/).forEach(pair => {
const eq = pair.indexOf('=');
if (eq > 0) {
const k = pair.slice(0, eq).trim();
const v = pair.slice(eq + 1).trim();
if (k) tmp[k] = v;
}
});
cfg = tmp;
}
}
}
if (!type) {
// If params is a single bare word without separators, treat as type
if (/^[A-Za-z][A-Za-z-]*$/.test(params)) {
type = params.toLowerCase();
}
}
if (!type) {
if ((cfg as any).birthday || (cfg as any).birthdate) type = 'birthday';
else if ((cfg as any).to || (cfg as any).until) type = 'countdown';
else if ((cfg as any).since) type = 'since';
else if ((cfg as any).from && (cfg as any).to) type = 'diff';
}
if (!type) return '';
// Render result similarly to the block processor
let out = '';
try {
switch (type) {
case 'birthday': {
const fileCache = app.metadataCache.getCache(sourcePath);
const fm = fileCache?.frontmatter || {} as any;
const bstr = cfg.birthday || cfg.birthdate || cfg.date || fm?.birthday || fm?.birthdate;
const birthDate = parseDate(bstr);
if (!birthDate) { out = 'date-calc: Missing or invalid "birthday" date.'; break; }
const today = zeroTime(new Date());
const bd = zeroTime(birthDate);
let age = today.getFullYear() - bd.getFullYear();
const m = today.getMonth() - bd.getMonth();
if (m < 0 || (m === 0 && today.getDate() < bd.getDate())) age--;
let nextBirthday = zeroTime(new Date(today.getFullYear(), bd.getMonth(), bd.getDate()));
if (today > nextBirthday) nextBirthday.setFullYear(today.getFullYear() + 1);
const oneDay = 24 * 60 * 60 * 1000;
const isBirthdayToday = (today.getMonth() === bd.getMonth() && today.getDate() === bd.getDate());
const daysUntil = isBirthdayToday ? 0 : Math.round((nextBirthday.getTime() - today.getTime()) / oneDay);
let msg: string;
if (daysUntil > 31) {
let monthsUntil = (nextBirthday.getFullYear() - today.getFullYear()) * 12 + (nextBirthday.getMonth() - today.getMonth());
if (today.getDate() > nextBirthday.getDate()) monthsUntil--;
const refDate = zeroTime(new Date(nextBirthday.getFullYear(), nextBirthday.getMonth() - monthsUntil, today.getDate()));
const partialMonthDays = Math.round((nextBirthday.getTime() - refDate.getTime()) / oneDay);
if (partialMonthDays > 15) monthsUntil += 0.5;
msg = `Next birthday in ${monthsUntil} months.`;
} else if (daysUntil === 0) {
msg = 'Wish them Happy Birthday!';
} else if (daysUntil === 1) {
msg = 'Their birthday is tomorrow!';
} else {
msg = `Next birthday in ${daysUntil} days.`;
}
out = `Age: ${age} years. ${msg}`;
break;
}
case 'countdown':
case 'until': {
const toStr = cfg.to || cfg.date || cfg.until;
const toDate = parseDate(toStr);
if (!toDate) { out = 'date-calc: Missing or invalid "to" date.'; break; }
const fromDate = parseDate(cfg.from) || new Date();
const diffMs = toDate.getTime() - fromDate.getTime();
const label = cfg.label ? `${cfg.label}: ` : '';
out = diffMs >= 0 ? `${label}Countdown: ${humanizeDuration(diffMs)}` : `${label}Event passed ${humanizeDuration(-diffMs)} ago`;
break;
}
case 'diff':
case 'difference': {
const fromStr = cfg.from || cfg.start;
const toStr = cfg.to || cfg.end;
const fromD = parseDate(fromStr);
const toD = parseDate(toStr);
if (!fromD || !toD) { out = 'date-calc: Provide valid "from" and "to" dates.'; break; }
const diffMs = toD.getTime() - fromD.getTime();
out = `Difference: ${humanizeDuration(Math.abs(diffMs))}${diffMs < 0 ? ' (to is before from)' : ''}`;
break;
}
case 'since': {
const sinceStr = cfg.since || cfg.from || cfg.date;
const sinceDate = parseDate(sinceStr);
if (!sinceDate) { out = 'date-calc: Missing or invalid "since" date.'; break; }
const diffMs = new Date().getTime() - sinceDate.getTime();
out = diffMs >= 0 ? `Since: ${humanizeDuration(diffMs)} ago` : `In: ${humanizeDuration(-diffMs)}`;
break;
}
default: {
out = 'date-calc: Unknown type. Supported types: birthday, countdown, diff, since';
}
}
} catch (e: any) {
out = `date-calc error: ${e?.message || e}`;
}
return out;
}
// Widget for displaying date-calc results in Live Preview
class DateCalcWidget extends WidgetType {
constructor(
private result: string,
private app: App
) {
super();
}
eq(other: DateCalcWidget) {
return other.result === this.result;
}
toDOM() {
const span = document.createElement('span');
span.classList.add('date-calc-inline');
span.setAttribute('contenteditable', 'false');
span.textContent = this.result;
return span;
}
ignoreEvent() {
return false;
}
}
// Live Preview ViewPlugin for inline date-calc processing
const dateCalcLivePreviewPlugin = (app: App) => ViewPlugin.fromClass(class {
decorations: DecorationSet = Decoration.none;
constructor(view: EditorView) {
this.buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.buildDecorations(update.view);
}
}
buildDecorations(view: EditorView) {
const builder = new RangeSetBuilder<Decoration>();
const text = view.state.doc.toString();
// Find inline code spans that start with date-calc:
const inlineCodeRegex = /`([^`]+)`/g;
let match;
while ((match = inlineCodeRegex.exec(text)) !== null) {
const codeContent = match[1];
if (/^date-calc\s*:/.test(codeContent)) {
const from = match.index;
const to = match.index + match[0].length;
// Get the active file path for processing
const activeFile = app.workspace.getActiveFile();
const sourcePath = activeFile?.path || '';
// Process the date calculation
const result = processInlineDateCalc(codeContent, app, sourcePath);
if (result) {
// Create a decoration that shows the result after the inline code
const widget = Decoration.widget({
widget: new DateCalcWidget(`${result}`, app),
side: 1
});
builder.add(to, to, widget);
}
}
}
this.decorations = builder.finish();
}
}, {
decorations: v => v.decorations
});
// DateCalc Inline Renderer - following dataview's component-based approach
class DateCalcInlineRenderer extends MarkdownRenderChild {
constructor(
public raw: string,
containerEl: HTMLElement,
public target: HTMLElement,
public app: App,
public sourcePath: string
) {
super(containerEl);
}
async onload() {
await this.render();
}
async render() {
const result = processInlineDateCalc(this.raw, this.app, this.sourcePath);
if (result) {
const span = document.createElement('span');
span.classList.add('date-calc-inline');
span.setAttribute('contenteditable', 'false');
span.textContent = result;
this.target.replaceWith(span);
}
}
}
// Note: Removed CodeMirror live preview plugin to preserve editability
// Following dataview's approach of using only post-processors
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
async onload() {
await this.loadSettings();
// This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (_evt: MouseEvent) => {
// Called when the user clicks the icon.
new Notice('This is a notice!');
});
// Perform additional things with the ribbon
ribbonIconEl.addClass('my-plugin-ribbon-class');
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
const statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText('Status Bar Text');
// This adds a simple command that can be triggered anywhere
this.addCommand({
id: 'open-sample-modal-simple',
name: 'Open sample modal (simple)',
callback: () => {
new SampleModal(this.app).open();
}
});
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({
id: 'sample-editor-command',
name: 'Sample editor command',
editorCallback: (editor: Editor, _view: MarkdownView) => {
console.log(editor.getSelection());
editor.replaceSelection('Sample Editor Command');
}
});
// This adds a complex command that can check whether the current state of the app allows execution of the command
this.addCommand({
id: 'open-sample-modal-complex',
name: 'Open sample modal (complex)',
checkCallback: (checking: boolean) => {
// Conditions to check
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
// If checking is true, we're simply "checking" if the command can be run.
// If checking is false, then we want to actually perform the operation.
if (!checking) {
new SampleModal(this.app).open();
}
// This command will only show up in Command Palette when the check function returns true
return true;
}
}
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this));
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// Using this function will automatically remove the event listener when this plugin is disabled.
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
console.log('click', evt);
});
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
// Register the Live Preview editor extension for inline date-calc processing
this.registerEditorExtension(dateCalcLivePreviewPlugin(this.app));
// Register the `date-calc` code block processor
this.registerMarkdownCodeBlockProcessor('date-calc', (source, el, ctx) => {
let cfg: any = {};
try {
cfg = parseYaml(source) || {};
} catch (e: any) {
el.createEl('pre', { text: `date-calc: Invalid YAML: ${e?.message || e}` });
return;
}
const type = (cfg.type || '').toString().toLowerCase().trim();
const container = el.createDiv({ cls: 'date-calc' });
try {
switch (type) {
case 'birthday': {
// Accept from config or frontmatter fallback
const fileCache = this.app.metadataCache.getCache(ctx.sourcePath);
const fm = fileCache?.frontmatter || {} as any;
const bstr = cfg.birthday || cfg.birthdate || cfg.date || fm?.birthday || fm?.birthdate;
const birthDate = bstr ? new Date(bstr + "T00:00:00") : null;
if (!birthDate) {
container.setText('date-calc: Missing or invalid "birthday" date.');
return;
}
// Get name and gender from config or frontmatter
const name = cfg.name || fm?.name || "Person";
const gender = cfg.gender || fm?.gender || "neutral";
// Configurable label with default
const ageLabel = cfg.label || "Age:";
// Format birthday as "July 6, 1998"
const birthdayFormatted = birthDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
// Determine pronouns based on gender
let pronoun = "their";
let possessive = "their";
if (gender.toLowerCase() === "male" || gender.toLowerCase() === "m") {
pronoun = "his";
possessive = "his";
} else if (gender.toLowerCase() === "female" || gender.toLowerCase() === "f") {
pronoun = "her";
possessive = "her";
}
const today = zeroTime(new Date());
const bd = zeroTime(birthDate);
let age = today.getFullYear() - bd.getFullYear();
const m = today.getMonth() - bd.getMonth();
if (m < 0 || (m === 0 && today.getDate() < bd.getDate())) age--;
let nextBirthday = zeroTime(new Date(today.getFullYear(), bd.getMonth(), bd.getDate()));
if (today > nextBirthday) nextBirthday.setFullYear(today.getFullYear() + 1);
const oneDay = 24 * 60 * 60 * 1000;
const daysUntil = Math.round((nextBirthday.getTime() - today.getTime()) / oneDay);
let msg: string;
if (daysUntil > 31) {
let monthsUntil = (nextBirthday.getFullYear() - today.getFullYear()) * 12 + (nextBirthday.getMonth() - today.getMonth());
if (today.getDate() > nextBirthday.getDate()) monthsUntil--;
const refDate = zeroTime(new Date(nextBirthday.getFullYear(), nextBirthday.getMonth() - monthsUntil, today.getDate()));
const partialMonthDays = Math.round((nextBirthday.getTime() - refDate.getTime()) / oneDay);
if (partialMonthDays > 15) monthsUntil += 0.5;
msg = `next one is in ${monthsUntil} months.`;
} else if (daysUntil === 0) {
msg = `Today! Wish ${pronoun} Happy Birthday!`;
} else if (daysUntil === 1) {
msg = `that's tomorrow!`;
} else {
msg = `next one is in ${daysUntil} days.`;
}
container.setText(`${name !== null ? name + ": " : ageLabel } ${age} years old. Birthday is ${birthdayFormatted}${msg}`);
break;
}
case 'countdown':
case 'until': {
const toStr = cfg.to || cfg.date || cfg.until;
const toDate = parseDate(toStr);
if (!toDate) { container.setText('date-calc: Missing or invalid "to" date.'); return; }
const fromDate = parseDate(cfg.from) || new Date();
const diffMs = toDate.getTime() - fromDate.getTime();
const label = cfg.label ? `${cfg.label}: ` : '';
container.setText(diffMs >= 0 ? `${label}Countdown: ${humanizeDuration(diffMs)}` : `${label}Event passed ${humanizeDuration(-diffMs)} ago`);
break;
}
case 'diff':
case 'difference': {
const fromStr = cfg.from || cfg.start;
const toStr = cfg.to || cfg.end;
const fromD = parseDate(fromStr);
const toD = parseDate(toStr);
if (!fromD || !toD) { container.setText('date-calc: Provide valid "from" and "to" dates.'); return; }
const diffMs = toD.getTime() - fromD.getTime();
container.setText(`Difference: ${humanizeDuration(Math.abs(diffMs))}${diffMs < 0 ? ' (to is before from)' : ''}`);
break;
}
case 'since': {
const sinceStr = cfg.since || cfg.from || cfg.date;
const sinceDate = parseDate(sinceStr);
if (!sinceDate) { container.setText('date-calc: Missing or invalid "since" date.'); return; }
const diffMs = new Date().getTime() - sinceDate.getTime();
container.setText(diffMs >= 0 ? `Since: ${humanizeDuration(diffMs)} ago` : `In: ${humanizeDuration(-diffMs)}`);
break;
}
default: {
container.setText('date-calc: Unknown type. Supported types: birthday, countdown, diff, since');
}
}
} catch (e: any) {
container.setText(`date-calc error: ${e?.message || e}`);
}
});
// Inline code support: `date-calc: key=value ...` or YAML/inline-map
// Following dataview's component-based approach - works in both reading view and live preview
this.registerMarkdownPostProcessor((el, ctx) => {
const codeNodes = el.querySelectorAll('code');
codeNodes.forEach((codeEl) => {
// Skip code inside of pre elements (fenced code blocks)
if (codeEl.parentElement && codeEl.parentElement.nodeName.toLowerCase() === 'pre') return;
const raw = codeEl.textContent?.trim() || '';
if (/^date-calc\s*:/.test(raw)) {
const renderer = new DateCalcInlineRenderer(raw, el, codeEl, this.app, ctx.sourcePath);
ctx.addChild(renderer);
}
});
});
}
onunload() {
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class SampleModal extends Modal {
constructor(app: App) {
super(app);
}
onOpen() {
const {contentEl} = this;
contentEl.setText('Woah!');
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}
class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
containerEl.createEl('h2', {text: 'Date Calc Plugin Syntax'});
containerEl.createEl('p', {text: 'This plugin allows you to embed dynamic date calculations in your notes using code blocks or inline code.'});
// Fenced code block examples
const blockSection = containerEl.createDiv();
blockSection.createEl('h3', {text: 'Fenced Code Block Examples'});
blockSection.createEl('h4', {text: '1. Birthday Calculation'});
blockSection.createEl('pre').createEl('code', {text:
`\`\`\`date-calc
type: birthday
birthday: 1992-08-16
\`\`\``});
blockSection.createEl('p', {text: 'Shows age and time until next birthday. Can also use "birthdate" or "date" field.'});
blockSection.createEl('h4', {text: '2. Countdown to Event'});
blockSection.createEl('pre').createEl('code', {text:
`\`\`\`date-calc
type: countdown
label: New Year
to: 2025-12-31 23:59
\`\`\``});
blockSection.createEl('p', {text: 'Shows time remaining until the target date. Can also use "until" type.'});
blockSection.createEl('h4', {text: '3. Time Since Event'});
blockSection.createEl('pre').createEl('code', {text:
`\`\`\`date-calc
type: since
since: 2024-01-01
\`\`\``});
blockSection.createEl('p', {text: 'Shows how much time has passed since an event.'});
blockSection.createEl('h4', {text: '4. Date Difference'});
blockSection.createEl('pre').createEl('code', {text:
`\`\`\`date-calc
type: diff
from: 2024-01-01
to: 2024-12-31
\`\`\``});
blockSection.createEl('p', {text: 'Shows the difference between two specific dates.'});
// Inline examples
const inlineSection = containerEl.createDiv();
inlineSection.createEl('h3', {text: 'Inline Code Examples'});
inlineSection.createEl('p', {text: 'You can also use inline code for quick calculations:'});
const inlineExamples = inlineSection.createEl('ul');
inlineExamples.createEl('li').innerHTML = '<code>`date-calc: birthday=1992-08-16`</code> → Birthday summary';
inlineExamples.createEl('li').innerHTML = '<code>`date-calc: to=2025-12-31 label="New Year"`</code> → Countdown';
inlineExamples.createEl('li').innerHTML = '<code>`date-calc: since=2024-01-01`</code> → Time since';
inlineExamples.createEl('li').innerHTML = '<code>`date-calc: {type: diff, from: 2024-01-01, to: 2024-12-31}`</code> → YAML format';
// Notes section
const notesSection = containerEl.createDiv();
notesSection.createEl('h3', {text: 'Important Notes'});
const notesList = notesSection.createEl('ul');
notesList.createEl('li', {text: 'Date formats: Use YYYY-MM-DD or YYYY-MM-DD HH:mm format'});
notesList.createEl('li', {text: 'For birthdays: Can use frontmatter properties (birthday/birthdate) if not specified in the block'});
notesList.createEl('li', {text: 'Inline code only renders in Reading view or Live Preview mode'});
notesList.createEl('li', {text: 'All calculations use your local timezone'});
notesList.createEl('li', {text: 'YAML syntax must be valid - the plugin will show errors for invalid syntax'});
// Troubleshooting
const troubleSection = containerEl.createDiv();
troubleSection.createEl('h3', {text: 'Troubleshooting'});
const troubleList = troubleSection.createEl('ul');
troubleList.createEl('li', {text: 'If inline code isn\'t rendering, try switching to Reading view'});
troubleList.createEl('li', {text: 'Use single backticks for inline code (not triple backticks)'});
troubleList.createEl('li', {text: 'If calculations seem wrong, check your date format and timezone'});
troubleList.createEl('li', {text: 'Error messages will appear in place of the calculation if something is wrong'});
}
}