playmean_obsidian-event-hig.../main.ts
2026-01-22 13:34:44 +03:00

312 lines
8.8 KiB
TypeScript

import { MarkdownRenderer, Plugin, moment, setIcon, setTooltip } from 'obsidian';
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',
};
declare interface State {
source: string;
text: string;
swapped: boolean;
isActual: boolean;
isUpcoming: boolean;
isBefore: boolean;
}
export default class EventHighlightPlugin extends Plugin {
private attributeStateName = 'data-event-highlight-state';
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));
this.registerMarkdownCodeBlockProcessor(
'event-highlight',
async (source, el, ctx) => {
await this.renderElement(el, source.trim(), undefined, ctx.sourcePath);
el.addEventListener('click', async () => {
const { swapped } = this.loadState(el);
await this.renderElement(
el,
source.trim(),
{ swapped: !swapped },
ctx.sourcePath,
);
});
},
);
this.registerEvent(
this.app.workspace.on('active-leaf-change', () => {
void this.updateAllPage();
}),
);
this.registerInterval(
window.setInterval(() => {
void this.updateAllPage();
}, 10_000),
);
}
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,
};
}
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);
}
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);
const parsedDateTime = moment(
dateSource,
['YYYY-MM-DD HH:mm', 'DD.MM.YYYY HH:mm'],
true,
);
const isDateTime = parsedDateTime.isValid();
const workFormat = isDateTime
? this.settings.dateTimeFormat
: this.settings.dateFormat;
const workStamp = isDateTime ? parsedDateTime : parsedDate;
const workGranularity = isDateTime ? 'hour' : 'day';
const workMinimalGranularity = isDateTime ? 'minute' : 'day';
const now = moment();
const isError = !workStamp.isValid();
const isAfter = now.isAfter(
moment(workStamp).add(1, 'hour'),
workMinimalGranularity,
);
const isActual = !isAfter && now.isSameOrAfter(workStamp, workMinimalGranularity);
const isUpcoming =
!isActual &&
now.isSame(
isDateTime ? workStamp : moment(workStamp).subtract(1, 'day'),
'day',
);
const isBefore = !isUpcoming && now.isBefore(workStamp, workGranularity);
const roundingDefault = moment.relativeTimeRounding();
moment.relativeTimeRounding(Math.round);
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);
const workFromNow = (() => {
const thisWeek = !isUpcoming && !isActual && workStamp.diff(now, 'days') < 7;
const nextDayFormat = // fragile, but works
(
moment.localeData() as unknown as {
_calendar: Record<string, string>;
}
)._calendar.nextDay;
if (thisWeek) {
return workStamp
.calendar({
nextDay: nextDayFormat.replace('LT', 'HH:mm'),
nextWeek: 'dddd',
sameElse: 'dddd',
})
.toLocaleLowerCase();
}
return workStamp.fromNow();
})();
moment.relativeTimeRounding(roundingDefault);
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);
}
el.empty();
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' });
const iconSpan = dateSpan.createEl('div', { cls: 'icon' });
if (extraText) {
dateSpan.classList.add('with-text');
}
dateSpan.createEl('div', { text });
switch (true) {
case isError:
dateSpan.classList.add('error');
container.classList.add('error');
setIcon(iconSpan, 'ban');
setTooltip(dateSpan, 'Invalid date');
break;
case isAfter:
dateSpan.classList.add('after');
container.classList.add('after');
setIcon(iconSpan, 'calendar-check-2');
setTooltip(dateSpan, 'Past event');
break;
case isBefore:
dateSpan.classList.add('before');
container.classList.add('before');
setIcon(iconSpan, 'calendar');
setTooltip(dateSpan, `Event soon (${workFormatted})`);
break;
case isUpcoming:
dateSpan.classList.add('upcoming');
container.classList.add('upcoming');
setIcon(iconSpan, 'calendar-clock');
setTooltip(dateSpan, `Upcoming event (${workFormatted})`);
break;
case isActual:
dateSpan.classList.add('actual');
container.classList.add('actual');
setIcon(iconSpan, 'clock');
setTooltip(dateSpan, `Event started (${workFormatted})`);
break;
}
}
private async updateAllPage() {
const elements = document.querySelectorAll(`[${this.attributeStateName}]`);
const sourcePath = this.app.workspace.getActiveFile()?.path ?? '';
for (const el of Array.from(elements)) {
const { source } = this.loadState(el);
if (!source) return;
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 };
}
}