mirror of
https://github.com/toya-co/tasknotes-agenda-wrapper.git
synced 2026-07-22 08:33:07 +00:00
349 lines
15 KiB
JavaScript
349 lines
15 KiB
JavaScript
'use strict';
|
|
|
|
const obsidian = require('obsidian');
|
|
const { Plugin, MarkdownRenderChild, ItemView, Notice, setIcon } = obsidian;
|
|
const moment = obsidian.moment || window.moment;
|
|
|
|
const VIEW_TYPE_AGENDA = 'tasknotes-agenda-view';
|
|
|
|
// Fallbacks — used only if TaskNotes settings can't be read at runtime.
|
|
const DEFAULT_FIELDS = {
|
|
title: 'title', status: 'status', priority: 'priority', due: 'due',
|
|
scheduled: 'scheduled', completedDate: 'completedDate',
|
|
dateCreated: 'dateCreated', dateModified: 'dateModified', archiveTag: 'archived',
|
|
};
|
|
const DEFAULT_STATUSES = [
|
|
{ value: 'none', label: 'None', color: '#cccccc', isCompleted: false },
|
|
{ value: 'open', label: 'Open', color: '#808080', isCompleted: false },
|
|
{ value: 'in-progress', label: 'In progress', color: '#0066cc', isCompleted: false },
|
|
{ value: 'done', label: 'Done', color: '#00aa00', isCompleted: true },
|
|
];
|
|
const DEFAULT_PRIORITIES = [
|
|
{ value: 'none', label: 'None', color: '#cccccc', weight: 0 },
|
|
{ value: 'low', label: 'Low', color: '#00aa00', weight: 1 },
|
|
{ value: 'normal', label: 'Normal', color: '#ffaa00', weight: 2 },
|
|
{ value: 'high', label: 'High', color: '#ff0000', weight: 3 },
|
|
];
|
|
|
|
function debounce(fn, ms) {
|
|
let t;
|
|
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
|
|
}
|
|
|
|
function collectTags(fm, cache) {
|
|
const tags = new Set();
|
|
const add = (t) => { if (t) tags.add(String(t).replace(/^#/, '')); };
|
|
if (fm) {
|
|
if (Array.isArray(fm.tags)) fm.tags.forEach(add);
|
|
else if (typeof fm.tags === 'string') fm.tags.split(/[,\s]+/).forEach(add);
|
|
}
|
|
if (cache && cache.tags) cache.tags.forEach((x) => add(x.tag));
|
|
return tags;
|
|
}
|
|
|
|
function normDate(v) {
|
|
if (v == null || v === '') return null;
|
|
if (v instanceof Date) return moment(v).format('YYYY-MM-DD');
|
|
return String(v);
|
|
}
|
|
|
|
function parseOptions(source) {
|
|
const opts = { title: "Today's Agenda", days: 14 };
|
|
(source || '').split('\n').forEach((line) => {
|
|
const m = line.match(/^\s*([a-zA-Z]+)\s*:\s*(.+?)\s*$/);
|
|
if (!m) return;
|
|
const k = m[1].toLowerCase();
|
|
if (k === 'title') opts.title = m[2];
|
|
else if (k === 'days') opts.days = Math.max(1, parseInt(m[2], 10) || 14);
|
|
});
|
|
return opts;
|
|
}
|
|
|
|
module.exports = class TaskNotesAgendaWrapper extends Plugin {
|
|
async onload() {
|
|
this.controllers = new Set();
|
|
|
|
this.registerView(VIEW_TYPE_AGENDA, (leaf) => new AgendaPane(leaf, this));
|
|
this.addRibbonIcon('calendar-clock', "TaskNotes agenda", () => this.activateAgenda());
|
|
this.addCommand({ id: 'open-agenda', name: "Open Today's Agenda", callback: () => this.activateAgenda() });
|
|
|
|
this.registerMarkdownCodeBlockProcessor('tasknotes-agenda', (source, el, ctx) => {
|
|
ctx.addChild(new AgendaBlock(this, el, parseOptions(source)));
|
|
});
|
|
|
|
const refresh = debounce(() => this.controllers.forEach((c) => c.render()), 500);
|
|
this.registerEvent(this.app.metadataCache.on('resolved', refresh));
|
|
this.registerEvent(this.app.metadataCache.on('changed', refresh));
|
|
this.registerEvent(this.app.vault.on('rename', refresh));
|
|
this.registerEvent(this.app.vault.on('delete', refresh));
|
|
this.registerInterval(window.setInterval(() => this.controllers.forEach((c) => c.render()), 5 * 60 * 1000));
|
|
}
|
|
|
|
async activateAgenda() {
|
|
const { workspace } = this.app;
|
|
let leaf = workspace.getLeavesOfType(VIEW_TYPE_AGENDA)[0];
|
|
if (!leaf) {
|
|
leaf = workspace.getRightLeaf(false);
|
|
await leaf.setViewState({ type: VIEW_TYPE_AGENDA, active: true });
|
|
}
|
|
workspace.revealLeaf(leaf);
|
|
}
|
|
|
|
getConfig() {
|
|
const tn = this.app.plugins.plugins.tasknotes;
|
|
const s = (tn && tn.settings) || {};
|
|
const fields = Object.assign({}, DEFAULT_FIELDS, s.fieldMapping || {});
|
|
const statuses = (s.customStatuses && s.customStatuses.length) ? s.customStatuses : DEFAULT_STATUSES;
|
|
const priorities = (s.customPriorities && s.customPriorities.length) ? s.customPriorities : DEFAULT_PRIORITIES;
|
|
const statusMap = {}; statuses.forEach((x) => { statusMap[x.value] = x; });
|
|
const prioMap = {}; priorities.forEach((x) => { prioMap[x.value] = x; });
|
|
const doneStatus = (statuses.find((x) => x.isCompleted) || { value: 'done' }).value;
|
|
return {
|
|
taskTag: (s.taskTag || 'task').replace(/^#/, ''),
|
|
tasksFolder: s.tasksFolder || 'TaskNotes/Tasks',
|
|
defaultStatus: s.defaultTaskStatus || 'open',
|
|
fields, statusMap, prioMap, doneStatus,
|
|
};
|
|
}
|
|
|
|
getTasks(cfg) {
|
|
const out = [];
|
|
for (const f of this.app.vault.getMarkdownFiles()) {
|
|
const cache = this.app.metadataCache.getFileCache(f);
|
|
if (!cache) continue;
|
|
const fm = cache.frontmatter || {};
|
|
if (!collectTags(fm, cache).has(cfg.taskTag)) continue;
|
|
const F = cfg.fields;
|
|
if (fm[F.archiveTag]) continue;
|
|
const status = fm[F.status] || cfg.defaultStatus;
|
|
out.push({
|
|
file: f,
|
|
title: fm[F.title] != null ? String(fm[F.title]) : f.basename,
|
|
status,
|
|
priority: fm[F.priority] || 'none',
|
|
due: normDate(fm[F.due]),
|
|
scheduled: normDate(fm[F.scheduled]),
|
|
done: !!(cfg.statusMap[status] && cfg.statusMap[status].isCompleted),
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async toggleStatus(task, cfg) {
|
|
const F = cfg.fields;
|
|
const nowDone = !task.done;
|
|
await this.app.fileManager.processFrontMatter(task.file, (fm) => {
|
|
fm[F.status] = nowDone ? cfg.doneStatus : cfg.defaultStatus;
|
|
if (nowDone) fm[F.completedDate] = moment().format('YYYY-MM-DD');
|
|
else delete fm[F.completedDate];
|
|
fm[F.dateModified] = moment().format();
|
|
});
|
|
}
|
|
|
|
async createTask(rawTitle, cfg) {
|
|
const title = (rawTitle || '').trim();
|
|
if (!title) return;
|
|
const folder = cfg.tasksFolder;
|
|
try {
|
|
if (!this.app.vault.getAbstractFileByPath(folder)) await this.app.vault.createFolder(folder);
|
|
} catch (e) { /* already exists */ }
|
|
const safe = title.replace(/[\\/:*?"<>|#^[\]]/g, '-').slice(0, 120).trim() || 'Untitled task';
|
|
let path = `${folder}/${safe}.md`, n = 1;
|
|
while (this.app.vault.getAbstractFileByPath(path)) path = `${folder}/${safe} ${++n}.md`;
|
|
const F = cfg.fields;
|
|
const now = moment().format();
|
|
const body = [
|
|
'---', 'tags:', ` - ${cfg.taskTag}`,
|
|
`${F.title}: ${JSON.stringify(title)}`,
|
|
`${F.status}: ${cfg.defaultStatus}`,
|
|
`${F.dateCreated}: ${now}`,
|
|
`${F.dateModified}: ${now}`,
|
|
'---', '',
|
|
].join('\n');
|
|
await this.app.vault.create(path, body);
|
|
new Notice(`Task created: ${title}`);
|
|
}
|
|
};
|
|
|
|
/* Shared renderer — used by both the pane and the code block. */
|
|
class AgendaController {
|
|
constructor(plugin, containerEl, opts) {
|
|
this.plugin = plugin;
|
|
this.containerEl = containerEl;
|
|
this.opts = opts;
|
|
this.filter = null; // null | 'todo' | 'overdue' | 'unplanned'
|
|
this.collapsed = new Set(); // section labels the user has collapsed
|
|
}
|
|
|
|
relDate(dateStr) {
|
|
const d = moment(dateStr, ['YYYY-MM-DD', moment.ISO_8601]);
|
|
const today = moment().startOf('day');
|
|
const diff = d.clone().startOf('day').diff(today, 'days');
|
|
if (diff === 0) return 'Today';
|
|
if (diff === 1) return 'Tomorrow';
|
|
if (diff === -1) return 'Yesterday';
|
|
return d.year() === today.year() ? d.format('MMM D') : d.format('MMM D, YYYY');
|
|
}
|
|
|
|
sameDay(dateStr, m) {
|
|
return dateStr && moment(dateStr, ['YYYY-MM-DD', moment.ISO_8601]).isSame(m, 'day');
|
|
}
|
|
|
|
empty(root, text) { root.createDiv({ cls: 'fw-agenda__empty', text }); }
|
|
|
|
render() {
|
|
const cfg = this.plugin.getConfig();
|
|
const active = this.plugin.getTasks(cfg).filter((t) => !t.done);
|
|
const today = moment().startOf('day');
|
|
|
|
const overdue = active.filter((t) => t.due && moment(t.due, ['YYYY-MM-DD', moment.ISO_8601]).isBefore(today, 'day'));
|
|
const unplanned = active.filter((t) => !t.due && !t.scheduled);
|
|
const todoToday = active.filter((t) => this.sameDay(t.scheduled, today) || this.sameDay(t.due, today));
|
|
|
|
const el = this.containerEl;
|
|
el.empty();
|
|
const root = el.createDiv({ cls: 'fw-agenda' });
|
|
|
|
const now = moment();
|
|
const dl = root.createDiv({ cls: 'fw-agenda__dateline' });
|
|
dl.createSpan({ text: now.format('MMMM') });
|
|
dl.createSpan({ cls: 'fw-sep', text: '•' });
|
|
dl.createSpan({ text: now.format('D') });
|
|
dl.createSpan({ cls: 'fw-sep', text: '•' });
|
|
dl.createSpan({ text: now.format('YYYY') });
|
|
root.createDiv({ cls: 'fw-agenda__title', text: this.opts.title });
|
|
|
|
// clickable, colored stat tiles that filter the list
|
|
const stats = root.createDiv({ cls: 'fw-agenda__stats' });
|
|
const tile = (num, label, key) => {
|
|
const t = stats.createDiv({ cls: `fw-stat fw-stat--${key}` + (this.filter === key ? ' is-active' : '') });
|
|
t.createDiv({ cls: 'fw-stat__num', text: String(num) });
|
|
t.createDiv({ cls: 'fw-stat__label', text: label });
|
|
t.setAttribute('aria-label', `Show ${label.toLowerCase()}`);
|
|
t.addEventListener('click', () => { this.filter = this.filter === key ? null : key; this.render(); });
|
|
};
|
|
tile(todoToday.length, 'Todo', 'todo');
|
|
tile(overdue.length, 'Overdue', 'overdue');
|
|
tile(unplanned.length, 'Unplanned', 'unplanned');
|
|
|
|
// new-task input
|
|
const inputWrap = root.createDiv({ cls: 'fw-agenda__input' });
|
|
inputWrap.createDiv({ cls: 'fw-agenda__input-label', text: 'New task' });
|
|
const inputRow = inputWrap.createDiv({ cls: 'fw-agenda__input-row' });
|
|
const input = inputRow.createEl('input', { cls: 'fw-agenda__input-field', attr: { type: 'text', placeholder: 'Enter your task here' } });
|
|
const submit = async () => {
|
|
const v = input.value;
|
|
if (!v.trim()) return;
|
|
input.value = '';
|
|
await this.plugin.createTask(v, cfg);
|
|
this.render();
|
|
};
|
|
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } });
|
|
const enterBtn = inputRow.createDiv({ cls: 'fw-agenda__input-enter', attr: { 'aria-label': 'Add task' } });
|
|
setIcon(enterBtn, 'corner-down-left');
|
|
enterBtn.addEventListener('click', submit);
|
|
|
|
// list — filtered by the active tile, or the full agenda
|
|
if (this.filter === 'todo') {
|
|
todoToday.length ? this.renderSection(root, 'Today', todoToday, cfg, false) : this.empty(root, 'No tasks for today.');
|
|
} else if (this.filter === 'overdue') {
|
|
overdue.length ? this.renderSection(root, 'Overdue', overdue, cfg, true) : this.empty(root, 'Nothing overdue.');
|
|
} else if (this.filter === 'unplanned') {
|
|
unplanned.length ? this.renderSection(root, 'Unplanned', unplanned, cfg, false, true) : this.empty(root, 'No unplanned tasks.');
|
|
} else {
|
|
let any = false;
|
|
if (unplanned.length) { this.renderSection(root, 'Unplanned', unplanned, cfg, false, true); any = true; }
|
|
if (overdue.length) { this.renderSection(root, 'Overdue', overdue, cfg, true); any = true; }
|
|
for (let i = 0; i < this.opts.days; i++) {
|
|
const day = today.clone().add(i, 'days');
|
|
const items = active.filter((t) => this.sameDay(t.scheduled, day) || this.sameDay(t.due, day));
|
|
if (!items.length) continue;
|
|
this.renderSection(root, day.format('dddd, MMM D'), items, cfg, false);
|
|
any = true;
|
|
}
|
|
if (!any) this.empty(root, 'Nothing scheduled. Enjoy the quiet.');
|
|
}
|
|
}
|
|
|
|
renderSection(root, label, items, cfg, isOverdue, isUnplanned) {
|
|
const collapsed = this.collapsed.has(label);
|
|
const head = root.createDiv({
|
|
cls: 'fw-agenda__dayhead' + (isOverdue ? ' fw-agenda__dayhead--overdue' : '') + (isUnplanned ? ' fw-agenda__dayhead--unplanned' : '') + (collapsed ? ' is-collapsed' : ''),
|
|
});
|
|
const left = head.createDiv({ cls: 'fw-agenda__dayhead-left' });
|
|
setIcon(left.createSpan({ cls: 'fw-agenda__chevron' }), 'chevron-down');
|
|
left.createSpan({ cls: 'fw-agenda__dayhead-label', text: label });
|
|
head.createSpan({ cls: 'fw-agenda__dayhead-count', text: String(items.length) });
|
|
head.addEventListener('click', () => {
|
|
if (this.collapsed.has(label)) this.collapsed.delete(label);
|
|
else this.collapsed.add(label);
|
|
this.render();
|
|
});
|
|
if (collapsed) return;
|
|
const wt = (p) => (cfg.prioMap[p] && cfg.prioMap[p].weight) || 0;
|
|
items.sort((a, b) => wt(b.priority) - wt(a.priority) || a.title.localeCompare(b.title));
|
|
for (const task of items) this.renderTask(root, task, cfg);
|
|
}
|
|
|
|
renderTask(root, task, cfg) {
|
|
const row = root.createDiv({ cls: 'fw-task' });
|
|
const dots = row.createDiv({ cls: 'fw-task__dots' });
|
|
const statusColor = (cfg.statusMap[task.status] && cfg.statusMap[task.status].color) || '#808080';
|
|
const prioColor = (cfg.prioMap[task.priority] && cfg.prioMap[task.priority].color) || '#cccccc';
|
|
const statusEl = dots.createDiv({ cls: 'fw-task__status' });
|
|
statusEl.style.borderColor = statusColor;
|
|
statusEl.setAttribute('aria-label', 'Toggle done');
|
|
statusEl.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
await this.plugin.toggleStatus(task, cfg);
|
|
this.render();
|
|
});
|
|
if (task.priority && task.priority !== 'none') {
|
|
dots.createDiv({ cls: 'fw-task__priority' }).style.background = prioColor;
|
|
}
|
|
|
|
const body = row.createDiv({ cls: 'fw-task__body' });
|
|
body.createDiv({ cls: 'fw-task__title', text: task.title })
|
|
.addEventListener('click', () => this.plugin.app.workspace.getLeaf(false).openFile(task.file));
|
|
|
|
const meta = body.createDiv({ cls: 'fw-task__meta' });
|
|
const part = (label, val) => {
|
|
if (val == null) return;
|
|
meta.createSpan({ cls: 'fw-task__meta-key', text: label + ': ' });
|
|
meta.createSpan({ text: val });
|
|
meta.createSpan({ cls: 'fw-sep', text: '·' });
|
|
};
|
|
if (task.due) part('due', this.relDate(task.due));
|
|
if (task.scheduled) part('scheduled', this.relDate(task.scheduled));
|
|
if (task.priority && task.priority !== 'none') {
|
|
part('priority', (cfg.prioMap[task.priority] && cfg.prioMap[task.priority].label) || task.priority);
|
|
}
|
|
meta.createSpan({ cls: 'fw-task__tag', text: cfg.taskTag });
|
|
}
|
|
}
|
|
|
|
/* Code-block embed (in a note). */
|
|
class AgendaBlock extends MarkdownRenderChild {
|
|
constructor(plugin, el, opts) { super(el); this.plugin = plugin; this.opts = opts; }
|
|
onload() {
|
|
this.ctrl = new AgendaController(this.plugin, this.containerEl, this.opts);
|
|
this.plugin.controllers.add(this.ctrl);
|
|
this.ctrl.render();
|
|
}
|
|
onunload() { this.plugin.controllers.delete(this.ctrl); }
|
|
}
|
|
|
|
/* Workspace pane (sidebar or main). */
|
|
class AgendaPane extends ItemView {
|
|
constructor(leaf, plugin) { super(leaf); this.plugin = plugin; }
|
|
getViewType() { return VIEW_TYPE_AGENDA; }
|
|
getDisplayText() { return "Today's Agenda"; }
|
|
getIcon() { return 'calendar-clock'; }
|
|
async onOpen() {
|
|
this.contentEl.addClass('fw-agenda-view');
|
|
this.ctrl = new AgendaController(this.plugin, this.contentEl, { title: "Today's Agenda", days: 14 });
|
|
this.plugin.controllers.add(this.ctrl);
|
|
this.ctrl.render();
|
|
}
|
|
async onClose() { if (this.ctrl) this.plugin.controllers.delete(this.ctrl); }
|
|
}
|