UNPKG

timeld-caldav

Version:

timeld - CalDAV connector

150 lines (141 loc) 5.11 kB
import ScrapeGoat from 'scrapegoat'; import { durationFromInterval, Entry, Session } from 'timeld-common'; import { concatMap, filter, interval, skipWhile } from 'rxjs'; import LOG from 'loglevel'; /** * @typedef {object} CalDavConfig * @property {string} uri API URL e.g. https://p116-caldav.icloud.com/151157399/calendars/work * @property {string} [owner] Calendar owner IRI * @property {object} [auth] CalDAV authorisation * @property {object} [auth.user] WebDAV user * @property {object} [auth.pass] WebDAV password * @property {number} [pollInterval] CalDAV polling interval millis */ /** * @implements Connector */ export default class CalDavConnector { // noinspection JSUnusedGlobalSymbols used to provide the configuration static configKey = 'caldav'; // noinspection JSUnusedGlobalSymbols used to negotiate content types static contentType = 'text/calendar'; /** * Construct with configuration parameters * @param {CalDavConfig} config * @param {GraphSubject} ext * @param {function(new:Calendar, config: CalDavConfig)} Calendar */ constructor( config, ext, Calendar = ScrapeGoat ) { const missingConfig = ['uri'].filter(k => !config[k]); if (missingConfig.length) throw new Error(`Missing CalDAV config: ${missingConfig.join(', ')}`); this.uri = new URL(config.uri); this.owner = config.owner; this.calendar = new Calendar(config); this.pollInterval = config.pollInterval || 10000; this.ext = ext; } async syncTimesheet(tsId, state) { const session = new Session(); if (state) { const events = /**@type {object[]}*/await this.calendar.getAllEvents(); await state.write({ '@insert': this.eventsInsert(tsId, events, session) }); } let running = false; // To prevent buffer overflow in concatMap return interval(this.pollInterval).pipe( skipWhile(() => running), concatMap(async () => { running = true; try { const eventIdsToDelete = new Set(Object.keys(this.ext) .filter(prop => prop.startsWith(this.uri.origin))); const eventsToLoad = (await this.calendar.getEtags()) .filter(eventMeta => { const eventId = this.getEventId(eventMeta); eventIdsToDelete.delete(eventId); return this.ext[eventId] !== eventMeta.etag; }) .map(eventMeta => ({ ics: eventMeta.ics })); if (eventsToLoad.length > 0 || eventIdsToDelete.size > 0) { let uid = eventsToLoad.length; // No conflict with events to re-load const update = { '@delete': [...eventIdsToDelete].map(eventId => this.eventDelete(eventId, uid++, true)), '@insert': [] }; if (eventsToLoad.length > 0) { const events = /**@type {object[]}*/await this.calendar.getEvents(eventsToLoad); update['@delete'].push(...events.map((event, i) => this.eventDelete(this.getEventId(event), i))); update['@insert'].push(...this.eventsInsert(tsId, events, session)); } return update; } } catch (e) { LOG.warn('CalDAV update failed', e); } finally { running = false; } }), filter(update => update) ); } /** * @param {string} eventId * @param {*} uid unique token for the deletion variable * @param {boolean} permanent `true` if the event will not be re-inserted */ eventDelete(eventId, uid, permanent = false) { if (permanent) this.ext[eventId] = []; return ({ // Note re-use of ?event variable in eventsInsert when an update '@id': `?event${uid}`, [`?p${uid}`]: `?o${uid}`, external: { '@id': eventId } }); } /** * @param {AccountOwnedId} tsId * @param {object[]} events * @param {Session} session * @returns {*[]} */ eventsInsert(tsId, events, session) { return [ session.toJSON(), ...events.map((event, i) => { const start = new Date(event.data.start); const end = new Date(event.data.end); const eventId = this.getEventId(event); const activity = [event.data.title, event.data.description].filter(s => s).join(': '); LOG.debug('Saving entry', activity); const src = new Entry({ seqNo: session.claimEntryId(), sessionId: session.id, activity, start, duration: durationFromInterval(start, end), providerId: this.owner || tsId.ownerIri(), externalId: eventId }).toJSON(); if (this.ext[eventId]) // We already have it, so it's an update src['@id'] = `?event${i}`; // See eventDelete this.ext[eventId] = event.etag; return src; }) ]; } getEventId(eventMeta) { return new URL(eventMeta.ics, this.uri).toString(); } entryUpdate(tsId, update, state) { // This connector is inbound-only } reportTimesheet(tsId, state) { // TODO: Would be a useful feature in future! } }