@acuminous/knuff
Version:
Reminds teams of recurring or future work by creating tasks in your issue tracker of choice
154 lines (133 loc) • 5.57 kB
JavaScript
const debug = require('debug')('knuff');
const EventEmitter = require('node:events');
const { AsyncLocalStorage } = require('node:async_hooks');
const { RRule } = require('rrule');
const { DateTime, Settings } = require('luxon');
const Ajv = require('ajv');
const slugify = require('unique-slug');
const schema = require('./schema.json');
const als = new AsyncLocalStorage();
const defaults = {
pretend: false,
progress: 10,
repositories: {},
};
class Knuff extends EventEmitter {
#config;
#drivers;
#ajvValidate;
#stats;
constructor(config, drivers, now) {
super();
this.#config = { ...defaults, ...config };
this.#drivers = drivers;
this.#ajvValidate = new Ajv({ allErrors: true }).compile(schema);
Settings.now = now || Settings.now;
}
async process(reminders) {
this.#stats = { reminders: reminders.length, due: 0, duplicates: 0, created: 0, errors: 0 };
for (let i = 0; i < reminders.length; i++) {
try {
await als.run({}, async () => {
await this.#processReminder({ ...reminders[i] });
});
if ((i + 1) % this.#config.progress === 0) this.emit('progress', { ...this.#stats });
} catch (error) {
this.#stats.errors++;
this.emit('error', error);
}
}
if (this.#stats.errors > 0) throw new Error(`${this.#stats.errors} of ${this.#stats.reminders} reminders could not be processed`);
return this.#stats;
}
async #processReminder(reminder) {
this.#validate(reminder);
this.#ensureId(reminder);
als.getStore().debug("Processing reminder with title='%s'", reminder.title);
this.#setDate(reminder);
if (!reminder.date) return;
await this.#ensureReminder(reminder);
}
#validate(reminder) {
if (this.#ajvValidate(reminder)) return;
const error = new Error(`Reminder '${reminder.id}' is invalid. See error.details for more information`);
Object.assign(error, { details: this.#ajvValidate.errors });
throw error;
}
#ensureId(reminder) {
reminder.id = reminder.id || slugify(reminder.title, { lower: true });
als.getStore().debug = debug.extend(reminder.id);
}
#setDate(reminder) {
const occurrence = this.#getOccurrence(reminder);
if (occurrence) {
als.getStore().debug("Assigning reminder date '%s' and timezone '%s'", occurrence.date.toISOString(), occurrence.timezone);
Object.assign(reminder, occurrence);
}
}
#getOccurrence(reminder) {
const now = DateTime.now().toUTC().toJSDate();
return [].concat(reminder.schedule)
.reduce(toOccurrences(reminder, now), [])
.sort(inAscendingOrder)[0];
}
async #ensureReminder(reminder) {
this.#stats.due++;
for (let i = 0; i < reminder.repositories.length; i++) {
const repository = this.#getRepository(reminder, reminder.repositories[i]);
const driver = this.#drivers[repository.driver];
const isDuplicate = !this.#config.pretend && await driver.hasReminder(repository, reminder);
if (isDuplicate) {
als.getStore().debug("Found duplicate in repository '%s'", reminder.id, reminder.title, reminder.repositories[i]);
this.#stats.duplicates++;
} else {
als.getStore().debug("Creating reminder in repository '%s'", reminder.repositories[i]);
if (!this.#config.pretend) await driver.createReminder(repository, reminder);
this.#stats.created++;
}
}
}
#getRepository(reminder, repositoryId) {
const repository = this.#config.repositories[repositoryId];
if (!repository) throw new Error(`Reminder '${reminder.id}' has an unknown repository '${repositoryId}'`);
return repository;
}
}
function toOccurrences(reminder, now) {
return (occurrences, schedule) => {
try {
const options = RRule.parseString(schedule);
const timezone = options.tzid || 'UTC';
const rule = createRule(options, now);
als.getStore().debug("Schedule is '%s'", schedule.replace(/\n/g, '\\n'));
const startOfDay = getStartOfDay(now, timezone);
als.getStore().debug('Getting occurrences between %s and %s inclusive', DateTime.fromJSDate(startOfDay).toLocaleString(DateTime.DATETIME_HUGE_WITH_SECONDS), DateTime.fromJSDate(now).toLocaleString(DateTime.DATETIME_HUGE_WITH_SECONDS));
const dates = rule.between(startOfDay, now, true);
als.getStore().debug('Found %d occurrences: [%s]', dates.length, dates.map((date) => date.toISOString()).join(', '));
return occurrences.concat(dates.map((date) => ({ date, timezone })));
} catch (cause) {
throw new Error(`Reminder '${reminder.id}' has an invalid schedule '${schedule}'`, { cause });
}
};
}
function createRule(options, now) {
// RRule defaults dtstart to new Date(). Use Luxon created value for now to support stubbing
const dtstart = now;
// RRule is VERY buggy wrt timezones. The only option I've found is to coerce dates into UTC
const overrides = { tzid: 'UTC' };
if (options.tzid) {
if (options.dtstart) overrides.dtstart = forceUTC(options.dtstart, options.tzid);
if (options.until) overrides.until = forceUTC(options.until, options.tzid);
}
return new RRule({ dtstart, ...options, ...overrides });
}
function forceUTC(date, timezone) {
return DateTime.fromJSDate(date).toUTC().setZone(timezone, { keepLocalTime: true }).toJSDate();
}
function getStartOfDay(date, timezone) {
return DateTime.fromJSDate(date).setZone(timezone).startOf('day').toJSDate();
}
function inAscendingOrder(a, b) {
return a.date - b.date;
}
module.exports = Knuff;