2026-01-22 10:34:44 +00:00
|
|
|
import { MarkdownRenderer, Plugin, moment, setIcon, setTooltip } from 'obsidian';
|
2025-04-01 22:16:40 +00:00
|
|
|
import { SettingsTab } from 'settings-tab';
|
|
|
|
|
|
|
|
|
|
interface PluginSettings {
|
|
|
|
|
dateFormat: string;
|
|
|
|
|
dateTimeFormat: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const defaultSettings: Partial<PluginSettings> = {
|
|
|
|
|
dateFormat: 'YYYY-MM-DD',
|
|
|
|
|
dateTimeFormat: 'YYYY-MM-DD HH:mm',
|
|
|
|
|
};
|
2024-10-07 10:43:52 +00:00
|
|
|
|
2024-10-30 13:47:59 +00:00
|
|
|
declare interface State {
|
|
|
|
|
source: string;
|
|
|
|
|
text: string;
|
|
|
|
|
|
|
|
|
|
swapped: boolean;
|
|
|
|
|
|
|
|
|
|
isActual: boolean;
|
|
|
|
|
isUpcoming: boolean;
|
|
|
|
|
isBefore: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-07 10:43:52 +00:00
|
|
|
export default class EventHighlightPlugin extends Plugin {
|
2025-03-25 11:20:03 +00:00
|
|
|
private attributeStateName = 'data-event-highlight-state';
|
2024-10-30 13:47:59 +00:00
|
|
|
|
2025-04-01 22:16:40 +00:00
|
|
|
settings: PluginSettings;
|
|
|
|
|
|
|
|
|
|
async loadSettings() {
|
|
|
|
|
this.settings = Object.assign({}, defaultSettings, await this.loadData());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async saveSettings() {
|
|
|
|
|
await this.saveData(this.settings);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async onload() {
|
|
|
|
|
await this.loadSettings();
|
|
|
|
|
|
|
|
|
|
this.addSettingTab(new SettingsTab(this.app, this));
|
|
|
|
|
|
2026-01-22 10:34:44 +00:00
|
|
|
this.registerMarkdownCodeBlockProcessor(
|
|
|
|
|
'event-highlight',
|
|
|
|
|
async (source, el, ctx) => {
|
|
|
|
|
await this.renderElement(el, source.trim(), undefined, ctx.sourcePath);
|
2025-04-01 22:16:40 +00:00
|
|
|
|
2026-01-22 10:34:44 +00:00
|
|
|
el.addEventListener('click', async () => {
|
|
|
|
|
const { swapped } = this.loadState(el);
|
2025-04-01 22:16:40 +00:00
|
|
|
|
2026-01-22 10:34:44 +00:00
|
|
|
await this.renderElement(
|
|
|
|
|
el,
|
|
|
|
|
source.trim(),
|
|
|
|
|
{ swapped: !swapped },
|
|
|
|
|
ctx.sourcePath,
|
|
|
|
|
);
|
2025-04-01 22:16:40 +00:00
|
|
|
});
|
2026-01-22 10:34:44 +00:00
|
|
|
},
|
|
|
|
|
);
|
2025-04-01 22:16:40 +00:00
|
|
|
|
|
|
|
|
this.registerEvent(
|
|
|
|
|
this.app.workspace.on('active-leaf-change', () => {
|
2026-01-22 10:34:44 +00:00
|
|
|
void this.updateAllPage();
|
2025-04-01 22:16:40 +00:00
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.registerInterval(
|
|
|
|
|
window.setInterval(() => {
|
2026-01-22 10:34:44 +00:00
|
|
|
void this.updateAllPage();
|
2025-04-01 22:16:40 +00:00
|
|
|
}, 10_000),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-30 13:47:59 +00:00
|
|
|
private loadState(el: Element): State {
|
|
|
|
|
const stateString = el.getAttribute(this.attributeStateName);
|
|
|
|
|
|
|
|
|
|
const [
|
|
|
|
|
source,
|
|
|
|
|
text = '',
|
|
|
|
|
|
|
|
|
|
swapped = '',
|
|
|
|
|
|
|
|
|
|
isActual = '',
|
|
|
|
|
isUpcoming = '',
|
|
|
|
|
isBefore = '',
|
|
|
|
|
] = stateString?.split('|') || [''];
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
source,
|
|
|
|
|
text,
|
|
|
|
|
|
|
|
|
|
swapped: !!swapped,
|
|
|
|
|
|
|
|
|
|
isActual: !!isActual,
|
|
|
|
|
isUpcoming: !!isUpcoming,
|
|
|
|
|
isBefore: !!isBefore,
|
|
|
|
|
};
|
|
|
|
|
}
|
2024-10-07 10:43:52 +00:00
|
|
|
|
2024-10-30 13:47:59 +00:00
|
|
|
private saveState(el: Element, state: State) {
|
|
|
|
|
el.setAttribute(
|
|
|
|
|
this.attributeStateName,
|
|
|
|
|
[
|
|
|
|
|
state.source,
|
|
|
|
|
state.text,
|
|
|
|
|
|
|
|
|
|
state.swapped ? '1' : '',
|
|
|
|
|
|
|
|
|
|
state.isActual ? '1' : '',
|
|
|
|
|
state.isUpcoming ? '1' : '',
|
|
|
|
|
state.isBefore ? '1' : '',
|
|
|
|
|
].join('|'),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private dropState(el: Element) {
|
|
|
|
|
el.removeAttribute(this.attributeStateName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private isSameState(oldState: State, newState: State) {
|
|
|
|
|
return JSON.stringify(oldState) === JSON.stringify(newState);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 10:34:44 +00:00
|
|
|
private async renderElement(
|
|
|
|
|
el: Element,
|
|
|
|
|
source: string,
|
|
|
|
|
overrideState?: Partial<State>,
|
|
|
|
|
sourcePath = '',
|
|
|
|
|
) {
|
|
|
|
|
const { dateSource, extraText } = this.parseSource(source);
|
|
|
|
|
|
|
|
|
|
const parsedDate = moment(dateSource, ['YYYY-MM-DD', 'DD.MM.YYYY'], true);
|
2025-04-01 21:39:41 +00:00
|
|
|
const parsedDateTime = moment(
|
2026-01-22 10:34:44 +00:00
|
|
|
dateSource,
|
2025-04-01 21:39:41 +00:00
|
|
|
['YYYY-MM-DD HH:mm', 'DD.MM.YYYY HH:mm'],
|
|
|
|
|
true,
|
|
|
|
|
);
|
2024-10-07 10:43:52 +00:00
|
|
|
|
|
|
|
|
const isDateTime = parsedDateTime.isValid();
|
|
|
|
|
|
2025-04-01 22:16:40 +00:00
|
|
|
const workFormat = isDateTime
|
|
|
|
|
? this.settings.dateTimeFormat
|
|
|
|
|
: this.settings.dateFormat;
|
2024-10-07 10:43:52 +00:00
|
|
|
const workStamp = isDateTime ? parsedDateTime : parsedDate;
|
|
|
|
|
const workGranularity = isDateTime ? 'hour' : 'day';
|
|
|
|
|
const workMinimalGranularity = isDateTime ? 'minute' : 'day';
|
|
|
|
|
|
|
|
|
|
const now = moment();
|
|
|
|
|
|
2025-04-01 21:59:06 +00:00
|
|
|
const isError = !workStamp.isValid();
|
2024-10-21 22:32:08 +00:00
|
|
|
const isAfter = now.isAfter(
|
2024-10-07 10:43:52 +00:00
|
|
|
moment(workStamp).add(1, 'hour'),
|
|
|
|
|
workMinimalGranularity,
|
|
|
|
|
);
|
|
|
|
|
const isActual = !isAfter && now.isSameOrAfter(workStamp, workMinimalGranularity);
|
2024-10-30 13:47:59 +00:00
|
|
|
const isUpcoming =
|
2024-10-07 10:43:52 +00:00
|
|
|
!isActual &&
|
|
|
|
|
now.isSame(
|
|
|
|
|
isDateTime ? workStamp : moment(workStamp).subtract(1, 'day'),
|
|
|
|
|
'day',
|
|
|
|
|
);
|
2024-10-30 13:47:59 +00:00
|
|
|
const isBefore = !isUpcoming && now.isBefore(workStamp, workGranularity);
|
2024-10-07 10:43:52 +00:00
|
|
|
|
|
|
|
|
const roundingDefault = moment.relativeTimeRounding();
|
|
|
|
|
|
2024-10-31 14:54:52 +00:00
|
|
|
moment.relativeTimeRounding(Math.round);
|
2024-10-07 10:43:52 +00:00
|
|
|
|
|
|
|
|
moment.relativeTimeThreshold('m', 60);
|
|
|
|
|
moment.relativeTimeThreshold('h', 24);
|
|
|
|
|
moment.relativeTimeThreshold('d', 7);
|
|
|
|
|
moment.relativeTimeThreshold('w', 4);
|
|
|
|
|
moment.relativeTimeThreshold('M', 12);
|
|
|
|
|
|
|
|
|
|
const workFormatted = workStamp.format(workFormat);
|
2024-10-31 16:38:06 +00:00
|
|
|
const workFromNow = (() => {
|
|
|
|
|
const thisWeek = !isUpcoming && !isActual && workStamp.diff(now, 'days') < 7;
|
|
|
|
|
|
|
|
|
|
const nextDayFormat = // fragile, but works
|
2025-03-25 11:20:03 +00:00
|
|
|
(
|
|
|
|
|
moment.localeData() as unknown as {
|
|
|
|
|
_calendar: Record<string, string>;
|
|
|
|
|
}
|
|
|
|
|
)._calendar.nextDay;
|
2024-10-31 16:38:06 +00:00
|
|
|
|
|
|
|
|
if (thisWeek) {
|
|
|
|
|
return workStamp
|
|
|
|
|
.calendar({
|
|
|
|
|
nextDay: nextDayFormat.replace('LT', 'HH:mm'),
|
|
|
|
|
nextWeek: 'dddd',
|
|
|
|
|
sameElse: 'dddd',
|
|
|
|
|
})
|
|
|
|
|
.toLocaleLowerCase();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return workStamp.fromNow();
|
|
|
|
|
})();
|
2024-10-07 10:43:52 +00:00
|
|
|
|
|
|
|
|
moment.relativeTimeRounding(roundingDefault);
|
|
|
|
|
|
2024-10-30 13:47:59 +00:00
|
|
|
const oldState = this.loadState(el);
|
|
|
|
|
|
|
|
|
|
const text =
|
|
|
|
|
isAfter || (overrideState || oldState).swapped
|
|
|
|
|
? workFormatted
|
|
|
|
|
: `${workFromNow.slice(0, 1).toLocaleUpperCase()}${workFromNow.slice(1)}`;
|
|
|
|
|
|
|
|
|
|
const state: State = {
|
|
|
|
|
...oldState,
|
|
|
|
|
|
|
|
|
|
source,
|
|
|
|
|
text,
|
|
|
|
|
|
|
|
|
|
isActual,
|
|
|
|
|
isUpcoming,
|
|
|
|
|
isBefore,
|
|
|
|
|
|
|
|
|
|
...overrideState,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (this.isSameState(oldState, state)) return;
|
|
|
|
|
|
|
|
|
|
if (isAfter) {
|
|
|
|
|
this.dropState(el);
|
|
|
|
|
} else {
|
|
|
|
|
this.saveState(el, state);
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-01 21:59:06 +00:00
|
|
|
el.empty();
|
2024-10-30 13:47:59 +00:00
|
|
|
|
2026-01-22 10:34:44 +00:00
|
|
|
const container = el.createEl('div', { cls: 'event-highlight-container' });
|
|
|
|
|
|
|
|
|
|
if (extraText) {
|
|
|
|
|
const textEl = container.createEl('div', { cls: 'event-highlight-text' });
|
|
|
|
|
|
|
|
|
|
await MarkdownRenderer.render(this.app, extraText, textEl, sourcePath, this);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dateSpan = container.createEl('div', { cls: 'event-highlight-badge' });
|
2025-04-01 21:59:06 +00:00
|
|
|
const iconSpan = dateSpan.createEl('div', { cls: 'icon' });
|
2024-10-30 13:47:59 +00:00
|
|
|
|
2026-01-22 10:34:44 +00:00
|
|
|
if (extraText) {
|
|
|
|
|
dateSpan.classList.add('with-text');
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-30 13:47:59 +00:00
|
|
|
dateSpan.createEl('div', { text });
|
2024-10-07 10:43:52 +00:00
|
|
|
|
2025-04-01 21:59:06 +00:00
|
|
|
switch (true) {
|
|
|
|
|
case isError:
|
|
|
|
|
dateSpan.classList.add('error');
|
2026-01-22 10:34:44 +00:00
|
|
|
container.classList.add('error');
|
2024-10-07 10:43:52 +00:00
|
|
|
|
2025-04-01 21:59:06 +00:00
|
|
|
setIcon(iconSpan, 'ban');
|
|
|
|
|
setTooltip(dateSpan, 'Invalid date');
|
2024-10-07 10:43:52 +00:00
|
|
|
|
2025-04-01 21:59:06 +00:00
|
|
|
break;
|
2024-10-07 10:43:52 +00:00
|
|
|
case isAfter:
|
2025-04-01 21:59:06 +00:00
|
|
|
dateSpan.classList.add('after');
|
2026-01-22 10:34:44 +00:00
|
|
|
container.classList.add('after');
|
2024-10-07 10:43:52 +00:00
|
|
|
|
|
|
|
|
setIcon(iconSpan, 'calendar-check-2');
|
2024-10-30 13:47:59 +00:00
|
|
|
setTooltip(dateSpan, 'Past event');
|
2024-10-07 10:43:52 +00:00
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
case isBefore:
|
2025-04-01 21:59:06 +00:00
|
|
|
dateSpan.classList.add('before');
|
2026-01-22 10:34:44 +00:00
|
|
|
container.classList.add('before');
|
2024-10-07 10:43:52 +00:00
|
|
|
|
|
|
|
|
setIcon(iconSpan, 'calendar');
|
|
|
|
|
setTooltip(dateSpan, `Event soon (${workFormatted})`);
|
|
|
|
|
|
|
|
|
|
break;
|
2024-10-30 13:47:59 +00:00
|
|
|
case isUpcoming:
|
2025-04-01 21:59:06 +00:00
|
|
|
dateSpan.classList.add('upcoming');
|
2026-01-22 10:34:44 +00:00
|
|
|
container.classList.add('upcoming');
|
2024-10-07 10:43:52 +00:00
|
|
|
|
|
|
|
|
setIcon(iconSpan, 'calendar-clock');
|
|
|
|
|
setTooltip(dateSpan, `Upcoming event (${workFormatted})`);
|
|
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
case isActual:
|
2025-04-01 21:59:06 +00:00
|
|
|
dateSpan.classList.add('actual');
|
2026-01-22 10:34:44 +00:00
|
|
|
container.classList.add('actual');
|
2024-10-07 10:43:52 +00:00
|
|
|
|
|
|
|
|
setIcon(iconSpan, 'clock');
|
|
|
|
|
setTooltip(dateSpan, `Event started (${workFormatted})`);
|
|
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 10:34:44 +00:00
|
|
|
private async updateAllPage() {
|
2024-10-30 13:47:59 +00:00
|
|
|
const elements = document.querySelectorAll(`[${this.attributeStateName}]`);
|
2024-10-07 10:43:52 +00:00
|
|
|
|
2026-01-22 10:34:44 +00:00
|
|
|
const sourcePath = this.app.workspace.getActiveFile()?.path ?? '';
|
|
|
|
|
|
|
|
|
|
for (const el of Array.from(elements)) {
|
2024-10-30 13:47:59 +00:00
|
|
|
const { source } = this.loadState(el);
|
2024-10-07 10:43:52 +00:00
|
|
|
|
|
|
|
|
if (!source) return;
|
|
|
|
|
|
2026-01-22 10:34:44 +00:00
|
|
|
await this.renderElement(el, source, undefined, sourcePath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private parseSource(source: string) {
|
|
|
|
|
const lines = source.split(/\r?\n/);
|
|
|
|
|
const dateSource = (lines[0] || '').trim();
|
|
|
|
|
const extraText = lines.slice(1).join('\n').trim();
|
|
|
|
|
|
|
|
|
|
return { dateSource, extraText };
|
2024-10-07 10:43:52 +00:00
|
|
|
}
|
|
|
|
|
}
|