aboutsummaryrefslogtreecommitdiff
path: root/static/ts/vevent.ts
diff options
context:
space:
mode:
Diffstat (limited to 'static/ts/vevent.ts')
-rw-r--r--static/ts/vevent.ts557
1 files changed, 557 insertions, 0 deletions
diff --git a/static/ts/vevent.ts b/static/ts/vevent.ts
new file mode 100644
index 00000000..f3606f70
--- /dev/null
+++ b/static/ts/vevent.ts
@@ -0,0 +1,557 @@
+import { ical_type, valid_input_types, JCal, JCalProperty, ChangeLogEntry } from './types'
+import { parseDate } from './lib'
+
+export {
+ VEvent, xml_to_vcal,
+ RecurrenceRule,
+ isRedrawable,
+}
+
+/* Something which can be redrawn */
+interface Redrawable extends HTMLElement {
+ redraw(data: VEvent): void
+}
+
+function isRedrawable(x: HTMLElement): x is Redrawable {
+ return 'redraw' in x
+}
+
+
+class VEventValue {
+
+ type: ical_type
+
+ /* value should NEVER be a list, since multi-valued properties should
+ be split into multiple VEventValue objects! */
+ value: any
+ parameters: Map<string, any>
+
+ constructor(type: ical_type, value: any, parameters = new Map) {
+ this.type = type;
+ this.value = value;
+ this.parameters = parameters;
+ }
+
+ to_jcal(): [Record<string, any>, ical_type, any] {
+ let value;
+ let v = this.value;
+ switch (this.type) {
+ case 'binary':
+ /* TODO */
+ value = 'BINARY DATA GOES HERE';
+ break;
+ case 'date-time':
+ value = v.format("~Y-~m-~dT~H:~M:~S");
+ // TODO TZ
+ break;
+ case 'date':
+ value = v.format("~Y-~m-~d");
+ break;
+ case 'duration':
+ /* TODO */
+ value = 'DURATION GOES HERE';
+ break;
+ case 'period':
+ /* TODO */
+ value = 'PERIOD GOES HERE';
+ break;
+ case 'utc-offset':
+ /* TODO */
+ value = 'UTC-OFFSET GOES HERE';
+ break;
+ case 'recur':
+ value = v.to_jcal();
+ break;
+
+ case 'float':
+ case 'integer':
+ case 'text':
+ case 'uri':
+ case 'cal-address':
+ case 'boolean':
+ value = v;
+ }
+
+ return [this.parameters, this.type, value]
+ }
+}
+
+/* TODO maybe ... */
+class VEventDuration extends VEventValue {
+}
+
+type list_values
+ = 'categories' | 'resources' | 'freebusy' | 'exdate' | 'rdate'
+ | 'CATEGORIES' | 'RESOURCES' | 'FREEBUSY' | 'EXDATE' | 'RDATE';
+
+/*
+ Abstract representation of a calendar event (or similar).
+All "live" calendar data in the frontend should live in an object of this type.
+ */
+class VEvent {
+
+ /* Calendar properties */
+ private properties: Map<string, VEventValue | VEventValue[]>
+
+ /* Children (such as alarms for events) */
+ components: VEvent[]
+
+ /* HTMLElements which wants to be redrawn when this object changes.
+ Elements can be registered with the @code{register} method.
+ */
+ registered: Redrawable[]
+
+ #calendar: string | null = null;
+
+ #changelog: ChangeLogEntry[] = []
+
+ /* Iterator instead of direct return to ensure the receiver doesn't
+ modify the array */
+ get changelog(): IterableIterator<[number, ChangeLogEntry]> {
+ return this.#changelog.entries();
+ }
+
+ addlog(entry: ChangeLogEntry) {
+ let len = this.#changelog.length
+ let last = this.#changelog[len - 1]
+
+ // console.log('entry = ', entry, ', last = ', last);
+
+ if (!last) {
+ // console.log('Adding new entry', entry, this.getProperty('uid'));
+ this.#changelog.push(entry);
+ return;
+ }
+
+ if (entry.type === last.type
+ && entry.name === last.name
+ && entry.from === last.to) {
+ this.#changelog.pop();
+ entry.from = last.from
+ // console.log('Changing old entry', entry, this.getProperty('uid'));
+ this.#changelog.push(entry)
+ } else {
+ this.#changelog.push(entry)
+ }
+ }
+
+ constructor(
+ properties: Map<string, VEventValue | VEventValue[]> = new Map(),
+ components: VEvent[] = []
+ ) {
+ this.components = components;
+ this.registered = [];
+ /* Re-normalize all given keys to upper case. We could require
+ * that beforehand, this is much more reliable, for only a
+ * marginal performance hit.
+ */
+ this.properties = new Map;
+ for (const [key, value] of properties) {
+ this.properties.set(key.toUpperCase(), value);
+ }
+ }
+
+ getProperty(key: list_values): any[] | undefined;
+ getProperty(key: string): any | undefined;
+
+ // getProperty(key: 'categories'): string[] | undefined
+
+ getProperty(key: string): any | any[] | undefined {
+ key = key.toUpperCase()
+ let e = this.properties.get(key);
+ if (!e) return e;
+ if (Array.isArray(e)) {
+ return e.map(ee => ee.value)
+ }
+ return e.value;
+ }
+
+ get boundProperties(): IterableIterator<string> {
+ return this.properties.keys()
+ }
+
+ private setPropertyInternal(key: string, value: any, type?: ical_type) {
+ function resolve_type(key: string, type?: ical_type): ical_type {
+ if (type) {
+ return type;
+ } else {
+ let type_options = valid_input_types.get(key)
+ if (type_options === undefined) {
+ return 'unknown'
+ } else if (type_options.length == 0) {
+ return 'unknown'
+ } else {
+ if (Array.isArray(type_options[0])) {
+ return type_options[0][0]
+ } else {
+ return type_options[0]
+ }
+ }
+ }
+ }
+
+ key = key.toUpperCase();
+
+ /*
+ To is mostly for the user. From is to allow an undo button
+ */
+ let entry: ChangeLogEntry = {
+ type: 'property',
+ name: key,
+ from: this.getProperty(key), // TODO what happens if getProperty returns a weird type
+ to: '' + value,
+ }
+ // console.log('Logging ', entry);
+ this.addlog(entry);
+
+
+ if (Array.isArray(value)) {
+ this.properties.set(key,
+ value.map(el => new VEventValue(resolve_type(key, type), el)))
+ return;
+ }
+ let current = this.properties.get(key);
+ if (current) {
+ if (Array.isArray(current)) {
+ /* TODO something here? */
+ } else {
+ if (type) { current.type = type; }
+ current.value = value;
+ return;
+ }
+ }
+ type = resolve_type(key, type);
+ let new_value = new VEventValue(type, value)
+ this.properties.set(key, new_value);
+ }
+
+ setProperty(key: list_values, value: any[], type?: ical_type): void;
+ setProperty(key: string, value: any, type?: ical_type): void;
+
+ setProperty(key: string, value: any, type?: ical_type) {
+ this.setPropertyInternal(key, value, type);
+
+ for (let el of this.registered) {
+ el.redraw(this);
+ }
+ }
+
+ setProperties(pairs: [string, any, ical_type?][]) {
+ for (let pair of pairs) {
+ this.setPropertyInternal(...pair);
+ }
+ for (let el of this.registered) {
+ el.redraw(this);
+ }
+ }
+
+
+ set calendar(calendar: string | null) {
+ this.addlog({
+ type: 'calendar',
+ name: '',
+ from: this.#calendar,
+ to: calendar,
+ });
+ this.#calendar = calendar;
+ for (let el of this.registered) {
+ el.redraw(this);
+ }
+ }
+
+ get calendar(): string | null {
+ return this.#calendar;
+ }
+
+ register(htmlNode: Redrawable) {
+ this.registered.push(htmlNode);
+ }
+
+ unregister(htmlNode: Redrawable) {
+ this.registered = this.registered.filter(node => node !== htmlNode)
+ }
+
+ to_jcal(): JCal {
+ let out_properties: JCalProperty[] = []
+ console.log(this.properties);
+ for (let [key, value] of this.properties) {
+ console.log("key = ", key, ", value = ", value);
+ if (Array.isArray(value)) {
+ if (value.length == 0) continue;
+ let mostly = value.map(v => v.to_jcal())
+ let values = mostly.map(x => x[2])
+ console.log("mostly", mostly)
+ out_properties.push([
+ key.toLowerCase(),
+ mostly[0][0],
+ mostly[0][1],
+ ...values
+ ])
+ } else {
+ let prop: JCalProperty = [
+ key.toLowerCase(),
+ ...value.to_jcal(),
+ ]
+ out_properties.push(prop);
+ }
+ }
+
+ return ['vevent', out_properties, [/* alarms go here*/]]
+ }
+}
+
+function make_vevent_value(value_tag: Element): VEventValue {
+ /* TODO parameters */
+ return new VEventValue(
+ /* TODO error on invalid type? */
+ value_tag.tagName.toLowerCase() as ical_type,
+ make_vevent_value_(value_tag));
+}
+
+
+//
+
+
+
+type freqType = 'SECONDLY' | 'MINUTELY' | 'HOURLY' | 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'
+type weekday = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU'
+
+class RecurrenceRule {
+ freq?: freqType
+ until?: Date
+ count?: number
+ interval?: number
+ bysecond?: number[]
+ byminute?: number[]
+ byhour?: number[]
+ byday?: (weekday | [number, weekday])[]
+ bymonthday?: number[]
+ byyearday?: number[]
+ byweekno?: number[]
+ bymonth?: number[]
+ bysetpos?: number[]
+ wkst?: weekday
+
+ to_jcal(): Record<string, any> {
+ let obj: any = {}
+ if (this.freq) obj['freq'] = this.freq;
+ if (this.until) obj['until'] = this.until.format(this.until.dateonly
+ ? '~Y-~M~D'
+ : '~Y-~M~DT~H:~M:~S');
+ if (this.count) obj['count'] = this.count;
+ if (this.interval) obj['interval'] = this.interval;
+ if (this.bysecond) obj['bysecond'] = this.bysecond;
+ if (this.byminute) obj['byminute'] = this.byminute;
+ if (this.byhour) obj['byhour'] = this.byhour;
+ if (this.bymonthday) obj['bymonthday'] = this.bymonthday;
+ if (this.byyearday) obj['byyearday'] = this.byyearday;
+ if (this.byweekno) obj['byweekno'] = this.byweekno;
+ if (this.bymonth) obj['bymonth'] = this.bymonth;
+ if (this.bysetpos) obj['bysetpos'] = this.bysetpos;
+
+ if (this.byday) {
+ let outarr: string[] = []
+ for (let byday of this.byday) {
+ if (byday instanceof Array) {
+ let [num, day] = byday;
+ outarr.push(`${num}${day}`)
+ } else {
+ outarr.push(byday)
+ }
+ }
+ obj['byday'] = outarr
+ }
+
+ if (this.wkst) obj['wkst'] = this.wkst;
+
+ return obj;
+ }
+}
+
+function xml_to_recurrence_rule(xml: Element): RecurrenceRule {
+ let rr = new RecurrenceRule;
+
+ if (xml.tagName.toLowerCase() !== 'recur') {
+ throw new TypeError();
+ }
+ let by = new Map<string, any>([
+ ['bysecond', []],
+ ['byminute', []],
+ ['byhour', []],
+ ['bymonthday', []],
+ ['byyearday', []],
+ ['byweekno', []],
+ ['bymonth', []],
+ ['bysetpos', []],
+ ['byday', []],
+ ]);
+
+
+ for (let child of xml.children) {
+ /* see appendix a 3.3.10 RECUR of RFC 6321 */
+ let t = child.textContent || '';
+ let tn = child.tagName.toLowerCase()
+
+ switch (tn) {
+ case 'freq':
+ rr.freq = t as freqType
+ break;
+
+ case 'until':
+ rr.until = parseDate(t);
+ break;
+
+ case 'count':
+ rr.count = Number(t)
+ break;
+
+ case 'interval':
+ rr.interval = Number(t)
+ break;
+
+ case 'bysecond':
+ case 'byminute':
+ case 'byhour':
+ case 'bymonthday':
+ case 'byyearday':
+ case 'byweekno':
+ case 'bymonth':
+ case 'bysetpos':
+ by.get(tn)!.push(Number(t));
+ break;
+
+ case 'byday':
+ // xsd:integer? type-weekday
+ let m = t.match(/([+-]?[0-9]*)([A-Z]{2})/)
+ if (m == null) throw new TypeError()
+ else if (m[1] === '') by.get('byday')!.push(m[2] as weekday)
+ else by.get('byday')!.push([Number(m[1]), m[2] as weekday])
+ break;
+
+ case 'wkst':
+ rr.wkst = t as weekday
+ break;
+ }
+ }
+
+ for (let [key, value] of by) {
+ if (!value || value.length == 0) continue;
+ (rr as any)[key] = value;
+ }
+
+ return rr;
+}
+
+//
+
+
+function make_vevent_value_(value_tag: Element): string | boolean | Date | number | RecurrenceRule {
+ /* RFC6321 3.6. */
+ switch (value_tag.tagName.toLowerCase()) {
+ case 'binary':
+ /* Base64 to binary
+ Seems to handle inline whitespace, which xCal standard reqires
+ */
+ return atob(value_tag.textContent || '')
+
+ case 'boolean':
+ switch (value_tag.textContent) {
+ case 'true': return true;
+ case 'false': return false;
+ default:
+ console.warn(`Bad boolean ${value_tag.textContent}, defaulting with !!`)
+ return !!value_tag.textContent;
+ }
+
+ case 'time':
+ case 'date':
+ case 'date-time':
+ return parseDate(value_tag.textContent || '');
+
+ case 'duration':
+ /* TODO duration parser here 'P1D' */
+ return value_tag.textContent || '';
+
+ case 'float':
+ case 'integer':
+ return Number(value_tag.textContent);
+
+ case 'period':
+ /* TODO has sub components, meaning that a string wont do */
+ let start = value_tag.getElementsByTagName('start')[0]
+ parseDate(start.textContent || '');
+ let other;
+ if ((other = value_tag.getElementsByTagName('end')[0])) {
+ return parseDate(other.textContent || '')
+ } else if ((other = value_tag.getElementsByTagName('duration')[0])) {
+ /* TODO parse duration */
+ return other.textContent || ''
+ } else {
+ console.warn('Invalid end to period, defaulting to 1H');
+ return new Date(3600);
+ }
+
+ case 'recur':
+ return xml_to_recurrence_rule(value_tag);
+
+ case 'utc-offset':
+ /* TODO parse */
+ return "";
+
+ default:
+ console.warn(`Unknown type '${value_tag.tagName}', defaulting to string`)
+ case 'cal-address':
+ case 'uri':
+ case 'text':
+ return value_tag.textContent || '';
+ }
+}
+
+function xml_to_vcal(xml: Element): VEvent {
+ /* xml MUST have a VEVENT (or equivalent) as its root */
+ let properties = xml.getElementsByTagName('properties')[0];
+ let components = xml.getElementsByTagName('components')[0];
+
+ let property_map: Map<string, VEventValue | VEventValue[]> = new Map;
+ if (properties) {
+ /* property_loop: */
+ for (var i = 0; i < properties.childElementCount; i++) {
+ let tag = properties.childNodes[i];
+ if (!(tag instanceof Element)) continue;
+ let parameters = {};
+ let value: VEventValue | VEventValue[] = [];
+ value_loop:
+ for (var j = 0; j < tag.childElementCount; j++) {
+ let child = tag.childNodes[j];
+ if (!(child instanceof Element)) continue;
+ if (child.tagName.toLowerCase() == 'parameters') {
+ parameters = /* TODO handle parameters */ {};
+ continue value_loop;
+ } else switch (tag.tagName.toLowerCase()) {
+ /* These can contain multiple value tags, per
+ RFC6321 3.4.1.1. */
+ case 'categories':
+ case 'resources':
+ case 'freebusy':
+ case 'exdate':
+ case 'rdate':
+ (value as VEventValue[]).push(make_vevent_value(child));
+ break;
+ default:
+ value = make_vevent_value(child);
+ }
+ }
+ property_map.set(tag.tagName.toLowerCase(), value);
+ }
+ }
+
+ let component_list = []
+ if (components) {
+ for (let child of components.childNodes) {
+ if (!(child instanceof Element)) continue;
+ component_list.push(xml_to_vcal(child))
+ }
+ }
+
+ return new VEvent(property_map, component_list)
+}