aboutsummaryrefslogtreecommitdiff
path: root/static/ts
diff options
context:
space:
mode:
Diffstat (limited to 'static/ts')
-rw-r--r--static/ts/clock.ts136
-rw-r--r--static/ts/components.ts40
-rw-r--r--static/ts/components/changelog.ts49
-rw-r--r--static/ts/components/date-jump.ts40
-rw-r--r--static/ts/components/date-time-input.ts119
-rw-r--r--static/ts/components/edit-rrule.ts75
-rw-r--r--static/ts/components/input-list.ts120
-rw-r--r--static/ts/components/popup-element.ts201
-rw-r--r--static/ts/components/slider.ts101
-rw-r--r--static/ts/components/tab-group-element.ts184
-rw-r--r--static/ts/components/vevent-block.ts99
-rw-r--r--static/ts/components/vevent-description.ts38
-rw-r--r--static/ts/components/vevent-dl.ts35
-rw-r--r--static/ts/components/vevent-edit.ts179
-rw-r--r--static/ts/components/vevent.ts69
-rw-r--r--static/ts/event-creator.ts181
-rw-r--r--static/ts/formatters.ts75
-rw-r--r--static/ts/globals.ts60
-rw-r--r--static/ts/jcal.ts192
-rw-r--r--static/ts/lib.ts233
-rw-r--r--static/ts/script.ts162
-rw-r--r--static/ts/server_connect.ts133
-rw-r--r--static/ts/types.ts208
-rw-r--r--static/ts/vevent.ts557
24 files changed, 3286 insertions, 0 deletions
diff --git a/static/ts/clock.ts b/static/ts/clock.ts
new file mode 100644
index 00000000..bbd15de0
--- /dev/null
+++ b/static/ts/clock.ts
@@ -0,0 +1,136 @@
+export {
+ SmallcalCellHighlight, Timebar,
+ initialize_clock_components
+}
+
+import { makeElement, date_to_percent } from './lib'
+
+abstract class Clock {
+ abstract update(now: Date): void;
+}
+
+
+class Timebar extends Clock {
+
+ // start_time: Date
+ // end_time: Date
+ bar_object: HTMLElement | null
+
+ constructor(/*start_time: Date, end_time: Date*/) {
+ super();
+ // this.start_time = start_time;
+ // this.end_time = end_time;
+ this.bar_object = null
+ }
+
+
+ update(now: Date) {
+ // if (! (this.start_time <= now.getTime() && now.getTime() < this.end_time))
+ // return;
+
+ var event_area = document.getElementById(now.format("~Y-~m-~d"))
+
+ if (event_area) {
+ if (this.bar_object !== null && this.bar_object.parentNode !== null) {
+ this.bar_object.parentNode.removeChild(this.bar_object)
+ } else {
+ this.bar_object = makeElement('div', {
+ id: 'bar',
+ className: 'eventlike current-time',
+ });
+ }
+
+ this.bar_object.style.top = date_to_percent(now) + "%";
+ event_area.append(this.bar_object)
+ }
+ }
+}
+
+class SmallcalCellHighlight extends Clock {
+
+ small_cal: HTMLElement
+ current_cell: HTMLElement | null
+
+ constructor(small_cal: HTMLElement) {
+ super();
+ this.small_cal = small_cal;
+ this.current_cell = null
+ }
+
+ update(now: Date) {
+ if (this.current_cell) {
+ this.current_cell.style.border = "";
+ }
+
+ /* This is expeced to fail if the current date is not
+ currently on screen. */
+ this.current_cell = this.small_cal.querySelector(
+ "time[datetime='" + now.format("~Y-~m-~d") + "']");
+
+ if (this.current_cell) {
+ this.current_cell.style.border = "1px solid black";
+ }
+ }
+}
+
+/* -------------------------------------------------- */
+
+class ClockElement extends HTMLElement {
+
+ timer_id: number
+
+ constructor() {
+ super();
+
+ this.timer_id = 0
+ }
+
+ connectedCallback() {
+ let interval = this.hasAttribute('interval')
+ ? +(this.getAttribute('interval') as string)
+ : 60;
+ interval *= 1000 /* ms */
+
+ this.timer_id = window.setInterval(() => this.update(new Date), interval)
+ this.update(new Date)
+ }
+
+ static get observedAttributes() {
+ return ['timer_id']
+ }
+
+ update(_: Date) { /* noop */ }
+}
+
+
+class TodayButton extends ClockElement {
+ a: HTMLAnchorElement;
+
+ constructor() {
+ super();
+ this.a = document.createElement('a');
+ this.a.textContent = 'Idag';
+ this.a.classList.add('btn');
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.replaceChildren(this.a);
+ }
+
+ update(now: Date) {
+ this.a.href = now.format("~Y-~m-~d.html")
+ }
+}
+
+
+class CurrentTime extends ClockElement {
+ update(now: Date) {
+ this.textContent = now.format('~H:~M:~S')
+ }
+}
+
+function initialize_clock_components() {
+ customElements.define('today-button', TodayButton)
+ customElements.define('current-time', CurrentTime)
+}
diff --git a/static/ts/components.ts b/static/ts/components.ts
new file mode 100644
index 00000000..e5fabba6
--- /dev/null
+++ b/static/ts/components.ts
@@ -0,0 +1,40 @@
+import { ComponentDescription } from './components/vevent-description'
+import { ComponentEdit } from './components/vevent-edit'
+import { VEventDL } from './components/vevent-dl'
+import { ComponentBlock } from './components/vevent-block'
+import { DateTimeInput } from './components/date-time-input'
+import { PopupElement } from './components/popup-element'
+import { InputList } from './components/input-list'
+import { EditRRule } from './components/edit-rrule'
+import { TabGroupElement } from './components/tab-group-element'
+import { VEventChangelog } from './components/changelog'
+import { SliderInput } from './components/slider'
+import { DateJump } from './components/date-jump'
+
+export { initialize_components }
+
+function initialize_components() {
+
+
+ /* These MUST be created AFTER vcal_objcets and event_calendar_mapping are
+ inistialized, since their constructors assume that that piece of global
+ state is available */
+ customElements.define('vevent-description', ComponentDescription);
+ customElements.define('vevent-edit', ComponentEdit);
+ customElements.define('vevent-dl', VEventDL);
+ customElements.define('vevent-block', ComponentBlock);
+ customElements.define('vevent-edit-rrule', EditRRule);
+
+ /* date-time-input should be instansiatable any time, but we do it here
+ becouse why not */
+
+ customElements.define('date-time-input', DateTimeInput /*, { extends: 'input' } */)
+ customElements.define('input-list', InputList);
+ customElements.define('slider-input', SliderInput);
+ customElements.define('date-jump', DateJump);
+
+ /* These maybe also require that the global maps are initialized */
+ customElements.define('popup-element', PopupElement)
+ customElements.define('tab-group', TabGroupElement)
+ customElements.define('vevent-changelog', VEventChangelog);
+}
diff --git a/static/ts/components/changelog.ts b/static/ts/components/changelog.ts
new file mode 100644
index 00000000..d08f7cb3
--- /dev/null
+++ b/static/ts/components/changelog.ts
@@ -0,0 +1,49 @@
+import { makeElement } from '../lib'
+import { ComponentVEvent } from './vevent'
+import { VEvent } from '../vevent'
+
+export { VEventChangelog }
+
+class VEventChangelog extends ComponentVEvent {
+
+ readonly ul: HTMLElement
+
+ constructor(uid?: string) {
+ super(uid);
+
+ this.ul = makeElement('ul');
+ }
+
+ connectedCallback() {
+ this.replaceChildren(this.ul);
+ }
+
+ redraw(data: VEvent) {
+ /* TODO only redraw what is needed */
+ let children = []
+ for (let [_, el] of data.changelog) {
+ let msg = '';
+ switch (el.type) {
+ case 'property':
+ msg += `change ${el.name}: `
+ msg += `from "${el.from}" to "${el.to}"`
+ break;
+ case 'calendar':
+ if (el.from === null && el.to === null) {
+ msg += '???'
+ } else if (el.from === null) {
+ msg += `set calendar to "${atob(el.to!)}"`
+ } else if (el.to === null) {
+ msg += `Remove calendar "${atob(el.from)}"`
+ } else {
+ msg += `Change calendar from "${atob(el.from)}" to "${atob(el.to)}"`
+ }
+ break;
+ }
+
+ children.push(makeElement('li', { textContent: msg }));
+ }
+
+ this.ul.replaceChildren(...children)
+ }
+}
diff --git a/static/ts/components/date-jump.ts b/static/ts/components/date-jump.ts
new file mode 100644
index 00000000..fd3908ae
--- /dev/null
+++ b/static/ts/components/date-jump.ts
@@ -0,0 +1,40 @@
+export { DateJump }
+
+/* Replace backend-driven [today] link with frontend, with one that
+ gets correctly set in the frontend. Similarly, update the go to
+ specific date button into a link which updates wheneven the date
+ form updates.
+*/
+class DateJump extends HTMLElement {
+
+ readonly golink: HTMLAnchorElement;
+ readonly input: HTMLInputElement;
+
+ constructor() {
+ super();
+
+ this.golink = document.createElement('a')
+ this.golink.classList.add('btn');
+ this.golink.textContent = "➔"
+ this.input = document.createElement('input')
+ this.input.type = 'date';
+ }
+
+ connectedCallback() {
+
+ /* Form is just here so the css works out */
+ let form = document.createElement('form');
+ form.replaceChildren(this.input, this.golink);
+ this.replaceChildren(form);
+
+ this.input.onchange = () => {
+ let date = this.input.valueAsDate!.format('~Y-~m-~d');
+ this.golink.href = `${date}.html`
+ }
+
+ let now = (new Date).format("~Y-~m-~d")
+ this.input.value = now;
+ /* onchange isn't triggered by manually setting the value */
+ this.golink.href = `${now}.html`
+ }
+}
diff --git a/static/ts/components/date-time-input.ts b/static/ts/components/date-time-input.ts
new file mode 100644
index 00000000..20e9a505
--- /dev/null
+++ b/static/ts/components/date-time-input.ts
@@ -0,0 +1,119 @@
+export { DateTimeInput }
+
+import { makeElement, parseDate } from '../lib'
+
+
+/* '<date-time-input />' */
+class DateTimeInput extends /* HTMLInputElement */ HTMLElement {
+
+ readonly time: HTMLInputElement;
+ readonly date: HTMLInputElement;
+
+ constructor() {
+ super();
+
+ this.date = makeElement('input', {
+ type: 'date'
+ }) as HTMLInputElement
+
+ this.time = makeElement('input', {
+ type: 'time',
+ disabled: this.dateonly
+ }) as HTMLInputElement
+ }
+
+ connectedCallback() {
+ /* This can be in the constructor for chromium, but NOT firefox...
+ Vivaldi 4.3.2439.63 stable
+ Mozilla Firefox 94.0.1
+ */
+ /*
+ https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#boolean_attributes
+ https://developer.mozilla.org/en-US/docs/Web/API/Element/getAttribute
+ */
+ this.replaceChildren(this.date, this.time)
+ }
+
+ static get observedAttributes() {
+ return ['dateonly']
+ }
+
+ attributeChangedCallback(name: string, _: string | null, to: string | null): void {
+ switch (name) {
+ case 'dateonly':
+ if (to == null) {
+ this.time.disabled = false
+ } else {
+ if (to == '' || to == name) {
+ this.time.disabled = true;
+ } else {
+ throw new TypeError(`Invalid value for attribute dateonly: ${to}`)
+ }
+ }
+ break;
+ }
+ }
+
+ get dateonly(): boolean {
+ return this.hasAttribute('dateonly');
+ }
+
+ set dateonly(b: boolean) {
+ if (b) {
+ this.setAttribute('dateonly', "");
+ } else {
+ this.removeAttribute('dateonly');
+ }
+ }
+
+ set value(date: Date) {
+ let [d, t] = date.format("~L~Y-~m-~dT~H:~M").split('T');
+ this.date.value = d;
+ this.time.value = t;
+
+ this.dateonly = date.dateonly;
+ }
+
+ get value(): Date {
+ let dt;
+ let date = this.date.value;
+ if (this.dateonly) {
+ dt = parseDate(date);
+ dt.dateonly = true;
+ } else {
+ let time = this.time.value;
+ dt = parseDate(date + 'T' + time)
+ dt.dateonly = false;
+ }
+ return dt;
+ }
+
+ get stringValue(): string {
+ if (this.dateonly) {
+ return this.value.format("~Y-~m-~d")
+ } else {
+ return this.value.format("~Y-~m-~dT~H:~M:~S")
+ }
+ }
+
+ set stringValue(new_value: Date | string) {
+ let date, time, dateonly = false;
+ if (new_value instanceof Date) {
+ date = new_value.format("~L~Y-~m-~d");
+ time = new_value.format("~L~H:~M:~S");
+ dateonly = new_value.dateonly;
+ } else {
+ [date, time] = new_value.split('T')
+ }
+ this.dateonly = dateonly;
+ this.date.value = date;
+ this.time.value = time;
+ }
+
+ addEventListener(type: string, proc: ((e: Event) => void)) {
+ if (type != 'input') throw "Only input supported";
+
+ this.date.addEventListener(type, proc);
+ this.time.addEventListener(type, proc);
+ }
+}
diff --git a/static/ts/components/edit-rrule.ts b/static/ts/components/edit-rrule.ts
new file mode 100644
index 00000000..a361bdee
--- /dev/null
+++ b/static/ts/components/edit-rrule.ts
@@ -0,0 +1,75 @@
+export { EditRRule }
+
+import { ComponentVEvent } from './vevent'
+import { VEvent } from '../vevent'
+import { vcal_objects } from '../globals'
+
+import { RecurrenceRule } from '../vevent'
+
+/* <vevent-edit-rrule/>
+ Tab for editing the recurrence rule of a component
+*/
+class EditRRule extends ComponentVEvent {
+
+ constructor(uid?: string) {
+ super(uid);
+
+ if (!this.template) {
+ throw 'vevent-edit-rrule template required';
+ }
+
+ let frag = this.template.content.cloneNode(true) as DocumentFragment
+ let body = frag.firstElementChild!
+ this.replaceChildren(body);
+
+ for (let el of this.querySelectorAll('[name]')) {
+ el.addEventListener('input', () => {
+ // console.log(this);
+ let data = vcal_objects.get(this.uid)!;
+ let rrule = data.getProperty('rrule')
+ if (!rrule) {
+ console.warn('RRUle missing from object');
+ return;
+ }
+ rrule = rrule as RecurrenceRule
+
+ console.log(el.getAttribute('name'), (el as any).value);
+ rrule[el.getAttribute('name')!] = (el as any).value;
+ data.setProperty('rrule', rrule);
+
+ });
+ }
+ }
+
+ connectedCallback() {
+ this.redraw(vcal_objects.get(this.uid)!)
+ }
+
+ redraw(data: VEvent) {
+
+ let rrule = data.getProperty('rrule')
+ if (!rrule) return;
+ rrule = rrule as RecurrenceRule
+
+ for (let el of this.querySelectorAll('[name]')) {
+
+ /*
+ el ought to be one of the tag types:
+ <input/>, <input-list/>, <select/>, and <date-time-input/>
+ Which all have `name` and `value` fields, allowing the code
+ below to work.
+ */
+
+ let name = el.getAttribute('name')
+ if (!name) {
+ console.warn(`Input without name, ${el}`)
+ continue
+ }
+
+ let value: any = rrule[name];
+ if (value)
+ (el as any).value = value;
+ }
+ }
+
+}
diff --git a/static/ts/components/input-list.ts b/static/ts/components/input-list.ts
new file mode 100644
index 00000000..0afd4999
--- /dev/null
+++ b/static/ts/components/input-list.ts
@@ -0,0 +1,120 @@
+export { InputList }
+
+/*
+ TODO allow each item to be a larger unit, possibly containing multiple input
+ fields.
+*/
+class InputList extends HTMLElement {
+
+ el: HTMLInputElement;
+
+ #listeners: [string, (e: Event) => void][] = [];
+
+ constructor() {
+ super();
+ this.el = this.children[0].cloneNode(true) as HTMLInputElement;
+ }
+
+ connectedCallback() {
+ for (let child of this.children) {
+ child.remove();
+ }
+ this.addInstance();
+ }
+
+ createInstance(): HTMLInputElement {
+ let new_el = this.el.cloneNode(true) as HTMLInputElement
+ let that = this;
+ new_el.addEventListener('input', function() {
+ /* TODO .value is empty both if it's actually empty, but also
+ for invalid input. Check new_el.validity, and new_el.validationMessage
+ */
+ if (new_el.value === '') {
+ let sibling = (this.previousElementSibling || this.nextElementSibling)
+ /* Only remove ourselves if we have siblings
+ Otherwise we just linger */
+ if (sibling) {
+ this.remove();
+ (sibling as HTMLInputElement).focus();
+ }
+ } else {
+ if (!this.nextElementSibling) {
+ that.addInstance();
+ // window.setTimeout(() => this.focus())
+ this.focus();
+ }
+ }
+ });
+
+ for (let [type, proc] of this.#listeners) {
+ new_el.addEventListener(type, proc);
+ }
+
+ return new_el;
+ }
+
+ addInstance() {
+ let new_el = this.createInstance();
+ this.appendChild(new_el);
+ }
+
+ get value(): any[] {
+ let value_list = []
+ for (let child of this.children) {
+ value_list.push((child as any).value);
+ }
+ if (value_list[value_list.length - 1] === '') {
+ value_list.pop();
+ }
+ return value_list
+ }
+
+ set value(new_value: any[]) {
+
+ let all_equal = true;
+ for (let i = 0; i < this.children.length; i++) {
+ let sv = (this.children[i] as any).value
+ all_equal
+ &&= (sv == new_value[i])
+ || (sv === '' && new_value[i] == undefined)
+ }
+ if (all_equal) return;
+
+ /* Copy our current input elements into a dictionary.
+ This allows us to only create new elements where needed
+ */
+ let values = new Map;
+ for (let child of this.children) {
+ values.set((child as HTMLInputElement).value, child);
+ }
+
+ let output_list: HTMLInputElement[] = []
+ for (let value of new_value) {
+ let element;
+ /* Only create element if needed */
+ if ((element = values.get(value))) {
+ output_list.push(element)
+ /* clear dictionary */
+ values.set(value, false);
+ } else {
+ let new_el = this.createInstance();
+ new_el.value = value;
+ output_list.push(new_el);
+ }
+ }
+ /* final, trailing, element */
+ output_list.push(this.createInstance());
+
+ this.replaceChildren(...output_list);
+ }
+
+ addEventListener(type: string, proc: ((e: Event) => void)) {
+ // if (type != 'input') throw "Only input supported";
+
+ this.#listeners.push([type, proc])
+
+ for (let child of this.children) {
+ child.addEventListener(type, proc);
+ }
+ }
+}
diff --git a/static/ts/components/popup-element.ts b/static/ts/components/popup-element.ts
new file mode 100644
index 00000000..458f543c
--- /dev/null
+++ b/static/ts/components/popup-element.ts
@@ -0,0 +1,201 @@
+export { PopupElement, setup_popup_element }
+
+import { VEvent } from '../vevent'
+import { find_block, vcal_objects } from '../globals'
+
+import { ComponentVEvent } from './vevent'
+
+import { remove_event } from '../server_connect'
+
+/* <popup-element /> */
+class PopupElement extends ComponentVEvent {
+
+ /* The popup which is the "selected" popup.
+ /* Makes the popup last hovered over the selected popup, moving it to
+ * the top, and allowing global keyboard bindings to affect it. */
+ static activePopup: PopupElement | null = null;
+
+ constructor(uid?: string) {
+ super(uid);
+
+ /* TODO populate remaining (??) */
+
+ let obj = vcal_objects.get(this.uid);
+ if (obj && obj.calendar) {
+ this.dataset.calendar = obj.calendar;
+ }
+
+ /* Makes us the active popup */
+ this.addEventListener('mouseover', () => {
+ if (PopupElement.activePopup) {
+ PopupElement.activePopup.removeAttribute('active');
+ }
+ PopupElement.activePopup = this;
+ this.setAttribute('active', 'active');
+ })
+ }
+
+ redraw(data: VEvent) {
+ if (data.calendar) {
+ /* The CSS has hooks on [data-calendar], meaning that this can
+ (and will) change stuff */
+ this.dataset.calendar = data.calendar;
+ }
+
+ }
+
+ connectedCallback() {
+ let template = document.getElementById('popup-template') as HTMLTemplateElement
+ let body = (template.content.cloneNode(true) as DocumentFragment).firstElementChild!;
+
+ let uid = this.uid;
+
+ /* nav bar */
+ let nav = body.getElementsByClassName("popup-control")[0] as HTMLElement;
+ bind_popup_control(nav);
+
+ let close_btn = body.querySelector('.popup-control .close-button') as HTMLButtonElement
+ close_btn.addEventListener('click', () => this.visible = false);
+
+ let maximize_btn = body.querySelector('.popup-control .maximize-button') as HTMLButtonElement
+ maximize_btn.addEventListener('click', () => this.maximize());
+
+ let remove_btn = body.querySelector('.popup-control .remove-button') as HTMLButtonElement
+ remove_btn.addEventListener('click', () => remove_event(uid));
+ /* end nav bar */
+
+ this.replaceChildren(body);
+ }
+
+ static get observedAttributes() {
+ return ['visible'];
+ }
+
+ attributeChangedCallback(name: string, _?: string, newValue?: string) {
+ switch (name) {
+ case 'visible':
+ if (newValue !== null)
+ /* Only run resize code when showing the popup */
+ this.onVisibilityChange()
+ break;
+ }
+ }
+
+ get visible(): boolean {
+ return this.hasAttribute('visible');
+ }
+
+ set visible(isVisible: boolean) {
+ if (isVisible) {
+ this.setAttribute('visible', 'visible');
+ } else {
+ this.removeAttribute('visible');
+ }
+ }
+
+ private onVisibilityChange() {
+ console.log('here');
+
+ /* TODO better way to find root */
+ let root;
+ switch (window.VIEW) {
+ case 'week':
+ root = document.getElementsByClassName("days")[0];
+ break;
+ case 'month':
+ default:
+ root = document.body;
+ break;
+ }
+
+ let element = find_block(this.uid) as HTMLElement | null
+ /* start <X, Y> sets offset between top left corner
+ of event in calendar and popup. 10, 10 soo old
+ event is still visible */
+ let offsetX = 10, offsetY = 10;
+ while (element !== root && element !== null) {
+ offsetX += element.offsetLeft;
+ offsetY += element.offsetTop;
+ element = element.offsetParent as HTMLElement;
+ }
+ this.style.left = offsetX + "px";
+ this.style.top = offsetY + "px";
+
+ /* Reset width and height to initial, to save user if they have resized
+ it to something weird */
+ let el = this.firstElementChild as HTMLElement;
+ el.style.removeProperty('width');
+ el.style.removeProperty('height');
+ }
+
+ maximize() {
+ /* TODO this assumes that popups are direct decendant of their parent,
+ which they really ought to be */
+ let parent = this.parentElement!;
+ let el = this.firstElementChild as HTMLElement
+ /* TODO offsetParent.scrollLeft places us "fullscreen" according to the currently
+ scrolled viewport. But is this the correct way to do it? How does it work for
+ month views */
+ this.style.left = `${this.offsetParent!.scrollLeft + 10}px`;
+ this.style.top = '10px';
+ /* 5ex is width of tab labels */
+ el.style.width = `calc(${parent.clientWidth - 20}px - 5ex)`
+ el.style.height = `${parent.clientHeight - 20}px`
+ }
+}
+
+/* Create a new popup element for the given VEvent, and ready it for editing the
+ event. Used when creating event (through the frontend).
+ The return value can safely be ignored.
+*/
+function setup_popup_element(ev: VEvent): PopupElement {
+ let uid = ev.getProperty('uid');
+ let popup = new PopupElement(uid);
+ ev.register(popup);
+ /* TODO propper way to find popup container */
+ (document.querySelector('.days') as Element).appendChild(popup);
+ let tabBtn = popup.querySelector('[role="tab"][data-originaltitle="Edit"]') as HTMLButtonElement
+ tabBtn.click()
+ let tab = document.getElementById(tabBtn.getAttribute('aria-controls')!)!
+ let input = tab.querySelector('input[name="summary"]') as HTMLInputElement
+ popup.visible = true;
+ input.select();
+ return popup;
+}
+
+/*
+ Given the navbar of a popup, make it dragable.
+ */
+function bind_popup_control(nav: HTMLElement) {
+
+ // if (!nav.closest('popup-element')) {
+ // console.log(nav);
+ // throw TypeError('not a popup container');
+ // }
+
+ nav.addEventListener('mousedown', function(e) {
+ /* Ignore mousedown on children */
+ if (e.target != nav) return;
+ nav.style.cursor = "grabbing";
+ nav.dataset.grabbed = "true";
+ nav.dataset.grabPoint = e.clientX + ";" + e.clientY;
+ // let popup = nav.closest(".popup-container");
+ let popup = nav.closest("popup-element") as HTMLElement;
+ nav.dataset.startPoint = popup.offsetLeft + ";" + popup.offsetTop;
+ })
+ window.addEventListener('mousemove', function(e) {
+ if (nav.dataset.grabbed) {
+ let [x, y] = nav.dataset.grabPoint!.split(";").map(Number);
+ let [startX, startY] = nav.dataset.startPoint!.split(";").map(Number);
+ // let popup = nav.closest(".popup-container");
+ let popup = nav.closest("popup-element") as HTMLElement;
+
+ popup.style.left = startX + (e.clientX - x) + "px";
+ popup.style.top = startY + (e.clientY - y) + "px";
+ }
+ });
+ window.addEventListener('mouseup', function() {
+ nav.dataset.grabbed = "";
+ nav.style.cursor = "";
+ });
+}
diff --git a/static/ts/components/slider.ts b/static/ts/components/slider.ts
new file mode 100644
index 00000000..48abc91b
--- /dev/null
+++ b/static/ts/components/slider.ts
@@ -0,0 +1,101 @@
+export { SliderInput }
+
+import { makeElement } from '../lib'
+
+const dflt = {
+ min: 0,
+ max: 100,
+ step: 1,
+}
+
+type Attribute = 'min' | 'max' | 'step'
+
+class SliderInput extends HTMLElement {
+
+ /* value a string since javascript kind of expects that */
+ #value = "0";
+ min = 0;
+ max = 100;
+ step = 1;
+
+ readonly slider: HTMLInputElement;
+ readonly textIn: HTMLInputElement;
+
+ constructor(min?: number, max?: number, step?: number, value?: number) {
+ super();
+
+ this.min = min || parseFloat(this.getAttribute('min') || "" + dflt['min']);
+ this.max = max || parseFloat(this.getAttribute('max') || "" + dflt['max']);
+ this.step = step || parseFloat(this.getAttribute('step') || "" + dflt['step']);
+ // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/range#value
+ const defaultValue
+ = (this.max < this.min)
+ ? this.min
+ : this.min + (this.max - this.min) / 2;
+
+ this.slider = makeElement('input', {
+ type: 'range',
+ min: this.min,
+ max: this.max,
+ step: this.step,
+ value: this.value,
+ }) as HTMLInputElement
+ this.textIn = makeElement('input', {
+ type: 'number',
+ min: this.min,
+ max: this.max,
+ step: this.step,
+ value: this.value,
+ }) as HTMLInputElement
+
+ this.slider.addEventListener('input', e => this.propagate(e));
+ this.textIn.addEventListener('input', e => this.propagate(e));
+
+ /* MUST be after sub components are bound */
+ this.value = "" + (value || this.getAttribute('value') || defaultValue);
+ }
+
+ connectedCallback() {
+ this.replaceChildren(this.slider, this.textIn);
+ }
+
+
+ static get observedAttributes(): Attribute[] {
+ return ['min', 'max', 'step']
+ }
+
+ attributeChangedCallback(name: Attribute, _?: string, to?: string): void {
+ if (to) {
+ this.slider.setAttribute(name, to);
+ this.textIn.setAttribute(name, to);
+ } else {
+ this.slider.removeAttribute(name);
+ this.textIn.removeAttribute(name);
+ }
+ this[name] = parseFloat(to || "" + dflt[name])
+ }
+
+ propagate(e: Event) {
+ this.value = (e.target as HTMLInputElement).value;
+ if (e instanceof InputEvent && this.oninput) {
+ this.oninput(e);
+ }
+ }
+
+ set value(value: string) {
+ this.slider.value = value;
+ this.textIn.value = value;
+ this.#value = value;
+ }
+
+ get value(): string {
+ return this.#value;
+ }
+
+ /* TODO do we want to implement this?
+ * oninput directly on the component already works
+ * /
+ addEventListener(type: string, proc: ((e: Event) => void)) {
+ }
+ */
+}
diff --git a/static/ts/components/tab-group-element.ts b/static/ts/components/tab-group-element.ts
new file mode 100644
index 00000000..e90997e9
--- /dev/null
+++ b/static/ts/components/tab-group-element.ts
@@ -0,0 +1,184 @@
+import { ComponentVEvent } from './vevent'
+import { makeElement, gensym } from '../lib'
+import { EditRRule } from './edit-rrule'
+import { VEvent, isRedrawable } from '../vevent'
+import { vcal_objects } from '../globals'
+
+export { TabGroupElement }
+
+/* Lacks a template, since it's trivial
+ The initial children of this element all becomes tabs, each child may have
+ the datapropertys 'label' and 'title' set, where label is what is shown in
+ the tab bar, and title is the hower text.
+
+ All additions and removals of tabs MUST go through addTab and removeTab!
+
+ Information about how tabs should work from an accesability standpoint can be
+ found here:
+ https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/Tab_Role
+
+ <tab-group/>
+*/
+class TabGroupElement extends ComponentVEvent {
+
+ readonly menu: HTMLElement;
+
+ tabs: HTMLElement[] = [];
+ tabLabels: HTMLElement[] = [];
+
+ constructor(uid?: string) {
+ super(uid);
+
+ this.menu = makeElement('div', {}, {
+ role: 'tablist',
+ 'aria-label': 'Simple Tabs',
+ })
+ }
+
+ redraw(data: VEvent) {
+ /* Update our tabset to match data:s having or not having of rrule,
+ but do nothing if we already match */
+ let rrule_tab = this.has_rrule_tab()
+ if (data.getProperty('rrule')) {
+ if (!this.has_rrule_tab()) {
+ /* Note that EditRRule register itself to be updated on changes
+ to the event */
+ this.addTab(new EditRRule(data.getProperty('uid')),
+ "↺", "Upprepningar");
+ }
+ } else {
+ if (rrule_tab) this.removeTab(rrule_tab as HTMLElement);
+ }
+
+ /* TODO is there any case where we want to propagate the draw to any of
+ our tabs? or are all our tabs independent? */
+ }
+
+ connectedCallback() {
+ /* All pre-added children should become tabs, but start with removing
+ them and storing them for later */
+ let originalChildren: HTMLElement[] = [];
+ while (this.firstChild) {
+ originalChildren.push(this.removeChild(this.firstChild) as HTMLElement);
+ }
+
+ /* Add our tab label menu */
+ this.appendChild(this.menu);
+
+ /* Re-add our initial children, but as proper tab elements */
+ for (let child of originalChildren) {
+ this.addTab(child);
+ }
+
+ /* redraw might add or remove tabs depending on our data, so call it here */
+ this.redraw(vcal_objects.get(this.uid)!);
+
+ /* All tabs should now be ready, focus the first one */
+ if (this.tabLabels.length > 0) {
+ this.tabLabels[0].setAttribute('tabindex', '0');
+ this.tabLabels[0].click();
+ }
+
+ } /* end connectedCallback */
+
+ addTab(child: HTMLElement, label?: string, title?: string) {
+
+ /* First character of text is a good a guess as any for our label,
+ but still defaut to '?' if no text is found */
+ label = label || child.dataset.label || (child.textContent + '?')[0];
+ title = title || child.dataset.title || '';
+ let extra_attributes = {};
+ /* Used to target a tab by name */
+ if (child.dataset.originaltitle) {
+ extra_attributes = { 'data-originaltitle': child.dataset.originaltitle }
+ }
+
+ let tab_id = gensym('tab_content_');
+ let label_id = gensym('tab_label_');
+
+ let tabLabel = makeElement('button', {
+ textContent: label,
+ }, {
+ role: 'tab',
+ id: label_id,
+ tabindex: -1,
+ title: title,
+ 'aria-selected': false,
+ 'aria-controls': tab_id,
+ ...extra_attributes,
+ })
+
+ let tabContainer = makeElement('div', {}, {
+ id: tab_id,
+ role: 'tabpanel',
+ tabindex: 0,
+ hidden: 'hidden',
+ 'aria-labelledby': label_id,
+ })
+
+ tabContainer.replaceChildren(child);
+ this.tabs.push(tabContainer);
+ this.appendChild(tabContainer);
+
+ this.tabLabels.push(tabLabel);
+ this.menu.appendChild(tabLabel);
+
+ tabLabel.addEventListener('click', () => this.tabClickedCallback(tabLabel));
+
+ this.style.setProperty('--tabcount', '' + this.tabs.length);
+ }
+
+ removeTab(tab: HTMLElement) {
+ let id = tab.getAttribute('aria-labelledby')!
+ let label = document.getElementById(id)
+ if (label) {
+ if (label.ariaSelected === 'true') {
+ this.tabLabels[0].click();
+ }
+ this.tabLabels = this.tabLabels.filter(el => el !== label)
+ label.remove();
+ }
+ /* remove tab */
+ this.tabs = this.tabs.filter(el => el !== tab)
+ this.removeChild(tab);
+ if (tab.firstChild) {
+ let child = tab.firstChild as HTMLElement;
+ if (isRedrawable(child)) {
+ vcal_objects.get(this.uid)?.unregister(child)
+ }
+ }
+
+ this.style.setProperty('--tabcount', '' + this.tabs.length);
+ }
+
+ /* TODO replace querySelectors here with our already saved references */
+ tabClickedCallback(tab: Element) {
+
+ /* hide all tab panels */
+ for (let tabcontent of this.querySelectorAll('[role="tabpanel"]')) {
+ tabcontent.setAttribute('hidden', 'hidden');
+ }
+ /* unselect all (selected) tab handles */
+ for (let item of this.querySelectorAll('[aria-selected="true"]')) {
+ item.setAttribute('aria-selected', 'false');
+ }
+ /* re-select ourselves */
+ tab.setAttribute('aria-selected', 'true');
+
+ /* unhide our target tab */
+ this.querySelector('#' + tab.getAttribute('aria-controls'))!
+ .removeAttribute('hidden')
+ }
+
+
+ /* returns our rrule tab if we have one */
+ has_rrule_tab(): Element | false {
+ for (let child of this.children) {
+ if (child.firstChild! instanceof EditRRule) {
+ return child;
+ }
+ }
+ return false;
+ }
+
+}
diff --git a/static/ts/components/vevent-block.ts b/static/ts/components/vevent-block.ts
new file mode 100644
index 00000000..9bbb8e7e
--- /dev/null
+++ b/static/ts/components/vevent-block.ts
@@ -0,0 +1,99 @@
+export { ComponentBlock }
+
+import { ComponentVEvent } from './vevent'
+import { VEvent } from '../vevent'
+import { parseDate, to_local } from '../lib'
+
+
+/* <vevent-block />
+
+ A grahpical block in the week view.
+*/
+class ComponentBlock extends ComponentVEvent {
+ constructor(uid?: string) {
+ super(uid);
+
+ if (!this.template) {
+ throw 'vevent-block template required';
+ }
+
+ this.addEventListener('click', () => {
+ let uid = this.uid
+ /* TODO is it better to find the popup through a query selector, or
+ by looking through all registered components of a VEvent? */
+ let popup = document.querySelector(`popup-element[data-uid="${uid}"]`)
+ if (popup === null) throw new Error('no popup for uid ' + uid);
+ popup.toggleAttribute('visible');
+ });
+ }
+
+ redraw(data: VEvent) {
+ let body = (this.template!.content.cloneNode(true) as DocumentFragment).firstElementChild!;
+
+ for (let el of body.querySelectorAll('[data-property]')) {
+ if (!(el instanceof HTMLElement)) continue;
+ let p = el.dataset.property!;
+ let d, fmt;
+ if ((d = data.getProperty(p))) {
+ if ((fmt = el.dataset.fmt)) {
+ el.textContent = d.format(fmt);
+ } else {
+ el.textContent = d;
+ }
+ } else switch (p.toLowerCase()) {
+ /* We lack that property, but might want to set a default here */
+ case 'summary':
+ el.textContent = 'Ny händelse'
+ break;
+ }
+ }
+
+ this.replaceChildren(body);
+
+ /* -------------------------------------------------- */
+
+ if (window.VIEW === 'week') {
+ let p;
+ if ((p = data.getProperty('dtstart'))) {
+ let c = this.closest('.event-container') as HTMLElement
+ let start = parseDate(c.dataset.start!).getTime()
+ let end = parseDate(c.dataset.end!).getTime();
+ // console.log(p);
+ let pp = to_local(p).getTime()
+ let result = 100 * (Math.min(end, Math.max(start, pp)) - start) / (end - start) + "%"
+ if (c.classList.contains('longevents')) {
+ this.style.left = result
+ } else {
+ this.style.top = result
+ }
+ // console.log('dtstart', p);
+ }
+ if ((p = data.getProperty('dtend'))) {
+ // console.log('dtend', p);
+ let c = this.closest('.event-container') as HTMLElement
+ let start = parseDate(c.dataset.start!).getTime()
+ let end = parseDate(c.dataset.end!).getTime();
+ let pp = to_local(p).getTime()
+ let result = 100 - (100 * (Math.min(end, Math.max(start, pp)) - start) / (end - start)) + "%"
+ if (c.classList.contains('longevents')) {
+ this.style.width = 'unset';
+ this.style.right = result;
+ } else {
+ this.style.height = 'unset';
+ this.style.bottom = result;
+ }
+ }
+ }
+
+ if (data.calendar) {
+ this.dataset.calendar = data.calendar;
+ }
+
+ if (data.getProperty('rrule') !== undefined) {
+ let rep = this.getElementsByClassName('repeating')
+ if (rep.length !== 0) {
+ (rep[0] as HTMLElement).innerText = '↺'
+ }
+ }
+ }
+}
diff --git a/static/ts/components/vevent-description.ts b/static/ts/components/vevent-description.ts
new file mode 100644
index 00000000..b44185e7
--- /dev/null
+++ b/static/ts/components/vevent-description.ts
@@ -0,0 +1,38 @@
+export { ComponentDescription }
+
+import { VEvent } from '../vevent'
+import { ComponentVEvent } from './vevent'
+import { format } from '../formatters'
+
+/*
+ <vevent-description />
+*/
+class ComponentDescription extends ComponentVEvent {
+
+ constructor(uid?: string) {
+ super(uid);
+ if (!this.template) {
+ throw 'vevent-description template required';
+ }
+ }
+
+ redraw(data: VEvent) {
+ // update ourselves from template
+
+ let body = (this.template!.content.cloneNode(true) as DocumentFragment).firstElementChild!;
+
+ for (let el of body.querySelectorAll('[data-property]')) {
+ if (!(el instanceof HTMLElement)) continue;
+ format(el, data, el.dataset.property!);
+ }
+
+ let repeating = body.getElementsByClassName('repeating')[0] as HTMLElement
+ if (data.getProperty('rrule')) {
+ repeating.classList.remove('hidden');
+ } else {
+ repeating.classList.add('hidden');
+ }
+
+ this.replaceChildren(body);
+ }
+}
diff --git a/static/ts/components/vevent-dl.ts b/static/ts/components/vevent-dl.ts
new file mode 100644
index 00000000..a792c07f
--- /dev/null
+++ b/static/ts/components/vevent-dl.ts
@@ -0,0 +1,35 @@
+export { VEventDL }
+
+import { ComponentVEvent } from './vevent'
+import { VEvent } from '../vevent'
+import { makeElement } from '../lib'
+
+import { RecurrenceRule } from '../vevent'
+
+/* <vevent-dl /> */
+class VEventDL extends ComponentVEvent {
+ redraw(obj: VEvent) {
+ let dl = buildDescriptionList(
+ Array.from(obj.boundProperties)
+ .map(key => [key, obj.getProperty(key)]))
+ this.replaceChildren(dl);
+ }
+}
+
+function buildDescriptionList(data: [string, any][]): HTMLElement {
+ let dl = document.createElement('dl');
+ for (let [key, val] of data) {
+ dl.appendChild(makeElement('dt', { textContent: key }))
+ let fmtVal: string = val;
+ if (val instanceof Date) {
+ fmtVal = val.format(
+ val.dateonly
+ ? '~Y-~m-~d'
+ : '~Y-~m-~dT~H:~M:~S');
+ } else if (val instanceof RecurrenceRule) {
+ fmtVal = JSON.stringify(val.to_jcal())
+ }
+ dl.appendChild(makeElement('dd', { textContent: fmtVal }))
+ }
+ return dl;
+}
diff --git a/static/ts/components/vevent-edit.ts b/static/ts/components/vevent-edit.ts
new file mode 100644
index 00000000..e3b5d105
--- /dev/null
+++ b/static/ts/components/vevent-edit.ts
@@ -0,0 +1,179 @@
+export { ComponentEdit }
+
+import { ComponentVEvent } from './vevent'
+import { InputList } from './input-list'
+import { DateTimeInput } from './date-time-input'
+
+import { vcal_objects } from '../globals'
+import { VEvent, RecurrenceRule } from '../vevent'
+import { create_event } from '../server_connect'
+import { to_boolean, gensym } from '../lib'
+
+/* <vevent-edit />
+ Edit form for a given VEvent. Used as the edit tab of popups.
+*/
+class ComponentEdit extends ComponentVEvent {
+
+ constructor(uid?: string) {
+ super(uid);
+
+ if (!this.template) {
+ throw 'vevent-edit template required';
+ }
+
+ let frag = this.template.content.cloneNode(true) as DocumentFragment
+ let body = frag.firstElementChild!
+ this.replaceChildren(body);
+
+ let data = vcal_objects.get(this.uid)
+ if (!data) {
+ throw `Data missing for uid ${this.dataset.uid}.`
+ }
+
+ for (let el of this.querySelectorAll('[data-label]')) {
+ let label = document.createElement('label');
+ let id = el.id || gensym('input');
+ el.id = id;
+ label.htmlFor = id;
+ label.textContent = (el as HTMLElement).dataset.label!;
+ el.parentElement!.insertBefore(label, el);
+ }
+
+ /* Handle calendar dropdown */
+ for (let el of this.querySelectorAll('select.calendar-selection')) {
+ for (let opt of el.getElementsByTagName('option')) {
+ opt.selected = false;
+ }
+ if (data.calendar) {
+ (el as HTMLSelectElement).value = data.calendar;
+ }
+
+ el.addEventListener('change', e => {
+ let v = (e.target as HTMLSelectElement).selectedOptions[0].value
+ let obj = vcal_objects.get(this.uid)!
+ obj.calendar = v;
+ });
+ }
+
+
+ // for (let el of this.getElementsByClassName("interactive")) {
+ for (let el of this.querySelectorAll("[data-property]")) {
+ // console.log(el);
+ el.addEventListener('input', () => {
+ let obj = vcal_objects.get(this.uid)
+ if (obj === undefined) {
+ throw 'No object with uid ' + this.uid
+ }
+ if (!(el instanceof HTMLInputElement
+ || el instanceof DateTimeInput
+ || el instanceof HTMLTextAreaElement
+ || el instanceof InputList
+ )) {
+ console.log(el, 'not an HTMLInputElement');
+ return;
+ }
+ obj.setProperty(
+ el.dataset.property!,
+ el.value)
+ });
+ }
+
+ let wholeday_ = this.querySelector('[name="wholeday"]')
+ if (wholeday_) {
+ let wholeday = wholeday_ as HTMLInputElement
+
+ if (data.getProperty('dtstart')?.dateonly) {
+ wholeday.checked = true;
+ }
+
+ wholeday.addEventListener('click', () => {
+ let chk = wholeday.checked
+ let start = data!.getProperty('dtstart')
+ let end = data!.getProperty('dtend')
+ start.dateonly = chk
+ end.dateonly = chk
+ data!.setProperty('dtstart', start);
+ data!.setProperty('dtend', end);
+ });
+ }
+
+ let has_repeats_ = this.querySelector('[name="has_repeats"]')
+ if (has_repeats_) {
+ let has_repeats = has_repeats_ as HTMLInputElement;
+
+ has_repeats.addEventListener('click', () => {
+ /* TODO unselecting and reselecting this checkbox deletes all entered data.
+ Cache it somewhere */
+ if (has_repeats.checked) {
+ vcal_objects.get(this.uid)!.setProperty('rrule', new RecurrenceRule())
+ } else {
+ /* TODO is this a good way to remove a property ? */
+ vcal_objects.get(this.uid)!.setProperty('rrule', undefined)
+ }
+ })
+ }
+
+ let submit = this.querySelector('form') as HTMLFormElement
+ submit.addEventListener('submit', (e) => {
+ console.log(submit, e);
+ create_event(vcal_objects.get(this.uid)!);
+
+ e.preventDefault();
+ return false;
+ });
+ }
+
+ connectedCallback() {
+
+ /* Edit tab is rendered here. It's left blank server-side, since
+ it only makes sense to have something here if we have javascript */
+
+ let data = vcal_objects.get(this.uid)
+
+ if (!data) {
+ throw `Data missing for uid ${this.dataset.uid}.`
+ }
+
+ this.redraw(data);
+
+ // return;
+ }
+
+ redraw(data: VEvent) {
+ /* We only update our fields, instead of reinstansiating
+ ourselves from the template, in hope that it's faster */
+
+
+ for (let el of this.querySelectorAll("[data-property]")) {
+ if (!(el instanceof HTMLElement)) continue;
+ let p = el.dataset.property!;
+ let d: any;
+ if ((d = data.getProperty(p))) {
+ /*
+ https://stackoverflow.com/questions/57157830/how-can-i-specify-the-sequence-of-running-nested-web-components-constructors
+ */
+ window.setTimeout(() => {
+ /* NOTE Some specific types might require special formatting
+ here. But due to my custom components implementing custom
+ `.value' procedures, we might not need any special cases
+ here */
+ /* Technically we just want to cast to HTMLElement with
+ value field here, but multiple types implement it
+ sepparately, and no common interface exist */
+ (el as HTMLInputElement).value = d;
+ });
+ }
+ }
+
+ let el = this.querySelector('[name="has_repeats"]')
+ if (el) {
+ (el as HTMLInputElement).checked = to_boolean(data.getProperty('rrule'))
+ }
+
+ if (data.calendar) {
+ for (let el of this.getElementsByClassName('calendar-selection')) {
+ (el as HTMLSelectElement).value = data.calendar;
+ }
+ }
+ }
+}
diff --git a/static/ts/components/vevent.ts b/static/ts/components/vevent.ts
new file mode 100644
index 00000000..7487cbb6
--- /dev/null
+++ b/static/ts/components/vevent.ts
@@ -0,0 +1,69 @@
+export { ComponentVEvent }
+
+import { vcal_objects } from '../globals'
+import { VEvent } from '../vevent'
+
+/* Root component for all events which content is closely linked to a
+@code{VEvent} object
+
+Lacks an accompaning tag, and shouldn't be directly instanciated.
+*/
+abstract class ComponentVEvent extends HTMLElement {
+
+ template?: HTMLTemplateElement
+ uid: string
+
+ constructor(uid?: string) {
+ super();
+ this.template = document.getElementById(this.tagName.toLowerCase()) as HTMLTemplateElement | undefined
+
+ let real_uid;
+
+ if (uid) {
+ // console.log('Got UID directly');
+ real_uid = uid;
+ } else {
+ /* I know that this case is redundant, it's here if we don't want to
+ look up the tree later */
+ if (this.dataset.uid) {
+ // console.log('Had UID as direct attribute');
+ real_uid = this.dataset.uid;
+ } else {
+ let el = this.closest('[data-uid]')
+ if (el) {
+ // console.log('Found UID higher up in the tree');
+ real_uid = (el as HTMLElement).dataset.uid
+ } else {
+ throw "No parent with [data-uid] set"
+ }
+ }
+ }
+
+ if (!real_uid) {
+ console.warn(this.outerHTML);
+ throw `UID required`
+ }
+
+ // console.log(real_uid);
+ this.uid = real_uid;
+ this.dataset.uid = real_uid;
+
+ vcal_objects.get(this.uid)?.register(this);
+
+ /* We DON'T have a redraw here in the general case, since the
+ HTML rendered server-side should be fine enough for us.
+ Those that need a direct rerendering (such as the edit tabs)
+ should take care of that some other way */
+ }
+
+ connectedCallback() {
+ let uid = this.dataset.uid
+ if (uid) {
+ let v = vcal_objects.get(uid)
+ if (v) this.redraw(v);
+ }
+ }
+
+ abstract redraw(data: VEvent): void
+
+}
diff --git a/static/ts/event-creator.ts b/static/ts/event-creator.ts
new file mode 100644
index 00000000..5e55e64e
--- /dev/null
+++ b/static/ts/event-creator.ts
@@ -0,0 +1,181 @@
+export { EventCreator }
+
+import { VEvent } from './vevent'
+import { v4 as uuid } from 'uuid'
+import { ComponentBlock } from './components/vevent-block'
+import { round_time, parseDate } from './lib'
+import { ical_type } from './types'
+
+class EventCreator {
+
+ /* Event which we are trying to create */
+ ev?: VEvent
+
+ /* Graphical block for event. Only here so we can find its siblings,
+ and update pointer events accordingly */
+ event?: Element
+
+ event_start: { x: number, y: number } = { x: NaN, y: NaN }
+ down_on_event: boolean = false
+ timeStart: number = 0
+
+ create_event_down(intended_target: HTMLElement): (e: MouseEvent) => any {
+ let that = this;
+ return function(e: MouseEvent) {
+ /* Only trigger event creation stuff on actuall events background,
+ NOT on its children */
+ that.down_on_event = false;
+ if (e.target != intended_target) return;
+ that.down_on_event = true;
+
+ that.event_start.x = e.clientX;
+ that.event_start.y = e.clientY;
+ }
+ }
+
+ /*
+ round_to: what start and end times should round to when dragging, in fractionsb
+ of the width of the containing container.
+
+ TODO limit this to only continue when on the intended event_container.
+
+ (event → [0, 1)), 𝐑, bool → event → ()
+ */
+ create_event_move(
+ pos_in: ((c: HTMLElement, e: MouseEvent) => number),
+ round_to: number = 1,
+ wide_element: boolean = false
+ ): ((e: MouseEvent) => void) {
+ let that = this;
+ return function(this: HTMLElement, e: MouseEvent) {
+ if (e.buttons != 1 || !that.down_on_event) return;
+
+ /* Create event when we start moving the mouse. */
+ if (!that.ev) {
+ /* Small deadzone so tiny click and drags aren't registered */
+ if (Math.abs(that.event_start.x - e.clientX) < 10
+ && Math.abs(that.event_start.y - e.clientY) < 5) { return; }
+
+ /* only allow start of dragging on background */
+ if (e.target !== this) return;
+
+ /* only on left click */
+ if (e.buttons != 1) return;
+
+ // let [popup, event] = that.create_empty_event();
+ // that.event = event;
+ that.ev = new VEvent();
+ that.ev.setProperty('uid', uuid())
+ that.ev.calendar = window.default_calendar;
+
+ // let ev_block = document.createElement('vevent-block') as ComponentBlock;
+ let ev_block = new ComponentBlock(that.ev.getProperty('uid'));
+ ev_block.classList.add('generated');
+ that.event = ev_block;
+ that.ev.register(ev_block);
+
+ /* TODO better solution to add popup to DOM */
+ // document.getElementsByTagName("main")[0].append(popup);
+
+ /* [0, 1) -- where are we in the container */
+ /* Ronud to force steps of quarters */
+ /* NOTE for in-day events a floor here work better, while for
+ all day events I want a round, but which has the tip over point
+ around 0.7 instead of 0.5.
+ It might also be an idea to subtract a tiny bit from the short events
+ mouse position, since I feel I always get to late starts.
+ */
+
+ // that.event.dataset.time1 = '' + time;
+ // that.event.dataset.time2 = '' + time;
+
+ /* ---------------------------------------- */
+
+ this.appendChild(ev_block);
+
+ /* requires that event is child of an '.event-container'. */
+ // new VComponent(
+ // event,
+ // wide_element=wide_element);
+ // bind_properties(event, wide_element);
+
+ /* requires that dtstart and dtend properties are initialized */
+
+ /* ---------------------------------------- */
+
+ /* Makes all current events transparent when dragging over them.
+ Without this weird stuff happens when moving over them
+
+ This includes ourselves.
+ */
+ for (let e of this.children) {
+ (e as HTMLElement).style.pointerEvents = "none";
+ }
+
+ that.timeStart = round_time(pos_in(this, e), round_to);
+ }
+
+ let time = round_time(pos_in(this, e), round_to);
+
+ // let time1 = Number(that.event.dataset.time1);
+ // let time2 = round_time(
+ // pos_in(that.event.parentElement!, e),
+ // round_to);
+ // that.event.dataset.time2 = '' + time2
+
+ /* ---------------------------------------- */
+
+ let event_container = this.closest(".event-container") as HTMLElement;
+
+ /* These two are in UTC */
+ let container_start = parseDate(event_container.dataset.start!);
+ let container_end = parseDate(event_container.dataset.end!);
+
+ /* ---------------------------------------- */
+
+ /* ms */
+ let duration = container_end.valueOf() - container_start.valueOf();
+
+ let start_in_duration = duration * Math.min(that.timeStart, time);
+ let end_in_duration = duration * Math.max(that.timeStart, time);
+
+ /* Notice that these are converted to UTC, since the intervals are given
+ in utc, and I only really care about local time (which a specific local
+ timezone doesn't give me)
+ */
+ /* TODO Should these inherit UTC from container_*? */
+ let d1 = new Date(container_start.getTime() + start_in_duration)
+ let d2 = new Date(container_start.getTime() + end_in_duration)
+
+ let type: ical_type = wide_element ? 'date' : 'date-time';
+ that.ev.setProperties([
+ ['dtstart', d1, type],
+ ['dtend', d2, type],
+ ]);
+
+ // console.log(that.event);
+ // console.log(d1.format("~L~H:~M"), d2.format("~L~H:~M"));
+ }
+ }
+
+ create_event_finisher(callback: ((ev: VEvent) => void)) {
+ let that = this;
+ return function create_event_up(_: MouseEvent) {
+ if (!that.ev) return;
+
+ /* Restore pointer events for all existing events.
+ Allow pointer events on our new event
+ */
+ for (let e of (that.event as Element).parentElement!.children) {
+ (e as HTMLElement).style.pointerEvents = "";
+ }
+
+ let localevent = that.ev;
+ that.ev = undefined
+ that.event = undefined;
+
+ callback(localevent);
+
+ }
+ }
+}
diff --git a/static/ts/formatters.ts b/static/ts/formatters.ts
new file mode 100644
index 00000000..e0018278
--- /dev/null
+++ b/static/ts/formatters.ts
@@ -0,0 +1,75 @@
+export {
+ format
+}
+
+import { makeElement } from './lib'
+import { VEvent } from './vevent'
+
+type formatter = (e: HTMLElement, d: VEvent, s: any) => Promise<void>
+
+declare global {
+ interface Window {
+ formatters: Map<string, formatter>;
+ }
+}
+
+let formatters: Map<string, formatter>;
+formatters = window.formatters = new Map();
+
+async function format(targetElement: HTMLElement, data: VEvent, key: string): Promise<void> {
+ let d = data.getProperty(key);
+ if (!d) return
+ let formatter = formatters.get(key.toLowerCase());
+ if (formatter) {
+ try {
+ await formatter(targetElement, data, d);
+ } catch (error) {
+ console.warn('Formatter failed')
+ console.warn(error);
+ formatters.get('default')!(targetElement, data, d);
+ }
+ } else {
+ formatters.get('default')!(targetElement, data, d);
+ }
+}
+
+formatters.set('categories', async (el, _, d) => {
+ for (let item of d) {
+ let q = encodeURIComponent(
+ `(member "${item}" (or (prop event (quote CATEGORIES)) (quote ())))`)
+ el.appendChild(makeElement('a', {
+ textContent: item,
+ href: `/search/?q=${q}`,
+ }))
+ }
+})
+
+async function format_time_tag(el: HTMLElement, ev: VEvent, d: any): Promise<void> {
+ if (el instanceof HTMLTimeElement) {
+ if (d instanceof Date) {
+ let fmt = '';
+ if (!d.utc) {
+ fmt += '~L';
+ }
+ fmt += '~Y-~m-~d'
+ if (!d.dateonly) {
+ fmt += 'T~H:~M:~S'
+ }
+ el.dateTime = d.format(fmt);
+ }
+ }
+
+ formatters.get('default')!(el, ev, d);
+}
+
+formatters.set('dtstart', format_time_tag)
+formatters.set('dtend', format_time_tag)
+
+formatters.set('default', async (el, _, d) => {
+ let fmt;
+ if ((fmt = el.dataset.fmt)) {
+ el.textContent = d.format(fmt);
+ } else {
+ el.textContent = d;
+ }
+})
diff --git a/static/ts/globals.ts b/static/ts/globals.ts
new file mode 100644
index 00000000..243e15e4
--- /dev/null
+++ b/static/ts/globals.ts
@@ -0,0 +1,60 @@
+export {
+ find_block,
+ vcal_objects, event_calendar_mapping
+}
+
+import { VEvent } from './vevent'
+import { uid } from './types'
+import { ComponentBlock } from './components/vevent-block'
+
+import { v4 as uuid } from 'uuid'
+import { setup_popup_element } from './components/popup-element'
+
+const vcal_objects: Map<uid, VEvent> = new Map;
+const event_calendar_mapping: Map<uid, string> = new Map;
+
+declare global {
+ interface Window {
+ vcal_objects: Map<uid, VEvent>;
+ VIEW: 'month' | 'week';
+ EDIT_MODE: boolean;
+ default_calendar: string;
+
+ addNewEvent(): void;
+ }
+}
+window.vcal_objects = vcal_objects;
+
+
+window.addNewEvent = () => {
+ let ev = new VEvent();
+ let uid = uuid()
+ let now = new Date()
+ /* Round seconds to 0, since time inputs wants exact seconds */
+ now.setUTCSeconds(0);
+ ev.setProperties([
+ ['uid', uid],
+ ['dtstart', now, 'date-time'],
+ ['dtend', new Date(now.getTime() + 3600 * 1000), 'date-time'],
+ ])
+ ev.calendar = window.default_calendar;
+
+ vcal_objects.set(uid, ev);
+
+ let popup = setup_popup_element(ev);
+ popup.maximize();
+}
+
+function find_block(uid: uid): ComponentBlock | null {
+ let obj = vcal_objects.get(uid)
+ if (obj === undefined) {
+ return null;
+ }
+ for (let el of obj.registered) {
+ if (el instanceof ComponentBlock) {
+ return el;
+ }
+ }
+ // throw 'Popup not fonud';
+ return null;
+}
diff --git a/static/ts/jcal.ts b/static/ts/jcal.ts
new file mode 100644
index 00000000..41f33db4
--- /dev/null
+++ b/static/ts/jcal.ts
@@ -0,0 +1,192 @@
+export { jcal_to_xcal }
+
+import { xcal, ical_type, JCalProperty, JCal } from './types'
+import { asList } from './lib'
+
+function jcal_type_to_xcal(doc: Document, type: ical_type, value: any): Element {
+ let el = doc.createElementNS(xcal, type);
+ switch (type) {
+ case 'boolean':
+ el.textContent = value ? "true" : "false";
+ break;
+
+ case 'float':
+ case 'integer':
+ el.textContent = '' + value;
+ break;
+
+ case 'period':
+ let [start, end] = value;
+ let startEl = doc.createElementNS(xcal, 'start');
+ startEl.textContent = start;
+ let endEl: Element;
+ if (end.find('P')) {
+ endEl = doc.createElementNS(xcal, 'duration');
+ } else {
+ endEl = doc.createElementNS(xcal, 'end');
+ }
+ endEl.textContent = end;
+ el.appendChild(startEl);
+ el.appendChild(endEl);
+ break;
+
+ case 'recur':
+ for (var key in value) {
+ if (!value.hasOwnProperty(key)) continue;
+ if (key === 'byday') {
+ for (let v of value[key]) {
+ let e = doc.createElementNS(xcal, key);
+ e.textContent = v;
+ el.appendChild(e);
+ }
+ } else {
+ let e = doc.createElementNS(xcal, key);
+ e.textContent = value[key];
+ el.appendChild(e);
+ }
+ }
+ break;
+
+ case 'date':
+ // case 'time':
+ case 'date-time':
+
+ case 'duration':
+
+ case 'binary':
+ case 'text':
+ case 'uri':
+ case 'cal-address':
+ case 'utc-offset':
+ el.textContent = value;
+ break;
+
+ default:
+ /* TODO error */
+ }
+ return el;
+}
+
+function jcal_property_to_xcal_property(
+ doc: Document,
+ jcal: JCalProperty
+): Element {
+ let [propertyName, params, type, ...values] = jcal;
+
+ let tag = doc.createElementNS(xcal, propertyName);
+
+ /* setup parameters */
+ let paramEl = doc.createElementNS(xcal, 'params');
+ for (var key in params) {
+ /* Check if the key actually belongs to us.
+ At least (my) format also appears when iterating
+ over the parameters. Probably a case of builtins
+ vs user defined.
+
+ This is also the reason we can't check if params
+ is empty beforehand, and instead check the
+ number of children of paramEl below.
+ */
+ if (!params.hasOwnProperty(key)) continue;
+
+ let el = doc.createElementNS(xcal, key);
+
+ for (let v of asList(params.get(key))) {
+ let text = doc.createElementNS(xcal, 'text');
+ text.textContent = '' + v;
+ el.appendChild(text);
+ }
+
+ paramEl.appendChild(el);
+ }
+
+ if (paramEl.childElementCount > 0) {
+ tag.appendChild(paramEl);
+ }
+
+ /* setup value (and type) */
+ // let typeEl = doc.createElementNS(xcal, type);
+
+ switch (propertyName) {
+ case 'geo':
+ if (type == 'float') {
+ // assert values[0] == [x, y]
+ let [x, y] = values[0];
+ let lat = doc.createElementNS(xcal, 'latitude')
+ let lon = doc.createElementNS(xcal, 'longitude')
+ lat.textContent = x;
+ lon.textContent = y;
+ tag.appendChild(lat);
+ tag.appendChild(lon);
+ } else {
+ /* TODO, error */
+ }
+ break;
+ /* TODO reenable this
+ case 'request-status':
+ if (type == 'text') {
+ // assert values[0] instanceof Array
+ let [code, desc, ...data] = values[0];
+ let codeEl = doc.createElementNS(xcal, 'code')
+ code.textContent = code;
+ tag.appendChild(codeEl);
+
+
+ let descEl = doc.createElementNS(xcal, 'description')
+ desc.textContent = desc;
+ tag.appendChild(descEl);
+
+ if (data !== []) {
+ data = data[0];
+ let dataEl = doc.createElementNS(xcal, 'data')
+ data.textContent = data;
+ tag.appendChild(dataEl);
+ }
+ } else {
+ /* TODO, error * /
+ }
+ break;
+ */
+ default:
+ for (let value of values) {
+ tag.appendChild(jcal_type_to_xcal(doc, type, value))
+ }
+ }
+
+ return tag;
+}
+
+
+function jcal_to_xcal(...jcals: JCal[]): Document {
+ let doc = document.implementation.createDocument(xcal, 'icalendar');
+ for (let jcal of jcals) {
+ doc.documentElement.appendChild(jcal_to_xcal_inner(doc, jcal));
+ }
+ return doc;
+}
+
+function jcal_to_xcal_inner(doc: Document, jcal: JCal) {
+ let [tagname, properties, components] = jcal;
+
+ let xcal_tag = doc.createElementNS(xcal, tagname);
+
+ /* I'm not sure if the properties and components tag should be left out
+ when empty. It should however NOT be an error to leave them in.
+ */
+
+ let xcal_properties = doc.createElementNS(xcal, 'properties');
+ for (let property of properties) {
+ xcal_properties.appendChild(jcal_property_to_xcal_property(doc, property));
+ }
+
+ let xcal_children = doc.createElementNS(xcal, 'components');
+ for (let child of components) {
+ xcal_children.appendChild(jcal_to_xcal_inner(doc, child));
+ }
+
+ xcal_tag.appendChild(xcal_properties);
+ xcal_tag.appendChild(xcal_children);
+
+ return xcal_tag;
+
+}
diff --git a/static/ts/lib.ts b/static/ts/lib.ts
new file mode 100644
index 00000000..2ef5b596
--- /dev/null
+++ b/static/ts/lib.ts
@@ -0,0 +1,233 @@
+export {
+ makeElement, date_to_percent,
+ parseDate, gensym, to_local, to_boolean,
+ asList, round_time
+}
+
+/*
+ General procedures which in theory could be used anywhere.
+ */
+
+/*
+ * https://www.typescriptlang.org/docs/handbook/declaration-merging.html
+ */
+declare global {
+ interface Object {
+ format: (fmt: string) => string
+ }
+
+ interface HTMLElement {
+ _addEventListener: (name: string, proc: (e: Event) => void) => void
+ listeners: Map<string, ((e: Event) => void)[]>
+ getListeners: () => Map<string, ((e: Event) => void)[]>
+ }
+
+ interface Date {
+ format: (fmt: string) => string
+ utc: boolean
+ dateonly: boolean
+ // type: 'date' | 'date-time'
+ }
+
+ interface DOMTokenList {
+ find: (regex: string) => [number, string] | undefined
+ }
+
+ interface HTMLCollection {
+ forEach: (proc: ((el: Element) => void)) => void
+ }
+
+ interface HTMLCollectionOf<T> {
+ forEach: (proc: ((el: T) => void)) => void
+ }
+}
+
+HTMLElement.prototype._addEventListener = HTMLElement.prototype.addEventListener;
+HTMLElement.prototype.addEventListener = function(name: string, proc: (e: Event) => void) {
+ if (!this.listeners) this.listeners = new Map
+ if (!this.listeners.get(name)) this.listeners.set(name, []);
+ /* Force since we ensure a value just above */
+ this.listeners.get(name)!.push(proc);
+ return this._addEventListener(name, proc);
+};
+HTMLElement.prototype.getListeners = function() {
+ return this.listeners;
+}
+
+
+/* ----- Date Extensions ---------------------------- */
+
+/*
+ Extensions to Javascript's Date to allow representing times
+ with different timezones. Currently only UTC and local time
+ are supported, but more should be able to be added.
+
+ NOTE that only the raw `get' (and NOT the `getUTC') methods
+ should be used on these objects, and that the reported timezone
+ is quite often wrong.
+
+ TODO The years between 0 and 100 (inclusive) gives dates in the twentieth
+ century, due to how javascript works (...).
+ */
+
+function parseDate(str: string): Date {
+ let year: number;
+ let month: number;
+ let day: number;
+ let hour: number | false = false;
+ let minute: number = 0;
+ let second: number = 0;
+ let utc: boolean = false;
+
+ let end = str.length - 1;
+ if (str[end] == 'Z') {
+ utc = true;
+ str = str.substring(0, end);
+ };
+
+ switch (str.length) {
+ case '2020-01-01T13:37:00'.length:
+ second = +str.substr(17, 2);
+ case '2020-01-01T13:37'.length:
+ hour = +str.substr(11, 2);
+ minute = +str.substr(14, 2);
+ case '2020-01-01'.length:
+ year = +str.substr(0, 4);
+ month = +str.substr(5, 2) - 1;
+ day = +str.substr(8, 2);
+ break;
+ default:
+ throw `"${str}" doesn't look like a date/-time string`
+ }
+
+ let date;
+ if (hour) {
+ date = new Date(year, month, day, hour, minute, second);
+ date.utc = utc;
+ date.dateonly = false;
+ } else {
+ date = new Date(year, month, day);
+ date.dateonly = true;
+ }
+ return date;
+}
+
+function to_local(date: Date): Date {
+ if (!date.utc) return date;
+
+ return new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
+}
+
+/* -------------------------------------------------- */
+
+function makeElement(name: string, attr = {}, actualAttr = {}): HTMLElement {
+ let element: HTMLElement = document.createElement(name);
+ for (let [key, value] of Object.entries(attr)) {
+ (element as any)[key] = value;
+ }
+ for (let [key, value] of Object.entries(actualAttr)) {
+ element.setAttribute(key, '' + value);
+ }
+ return element;
+}
+
+function round_time(time: number, fraction: number): number {
+ let scale = 1 / fraction;
+ return Math.round(time * scale) / scale;
+}
+
+/* only used by the bar.
+ Events use the start and end time of their container, but since the bar
+ is moving between containers that is clumsy.
+ Just doing (new Date()/(86400*1000)) would be nice, but there's no good
+ way to get the time in the current day.
+ */
+function date_to_percent(date: Date): number /* in 0, 100 */ {
+ return (date.getHours() + (date.getMinutes() / 60)) * 100 / 24;
+}
+
+/* if only there was such a thing as a let to wrap around my lambda... */
+/* js infix to not collide with stuff generated backend */
+const gensym = (counter => (prefix = "gensym") => prefix + "js" + ++counter)(0)
+
+function asList<T>(thing: Array<T> | T): Array<T> {
+ if (thing instanceof Array) {
+ return thing;
+ } else {
+ return [thing];
+ }
+}
+
+
+function to_boolean(value: any): boolean {
+ switch (typeof value) {
+ case 'string':
+ switch (value) {
+ case 'true': return true;
+ case 'false': return false;
+ case '': return false;
+ default: return true;
+ }
+ case 'boolean':
+ return value;
+ default:
+ return !!value;
+ }
+}
+
+
+
+/* internal */
+function datepad(thing: number | string, width = 2): string {
+ return (thing + "").padStart(width, "0");
+}
+
+function format_date(date: Date, str: string): string {
+ let fmtmode = false;
+ let outstr = "";
+ for (var i = 0; i < str.length; i++) {
+ if (fmtmode) {
+ switch (str[i]) {
+ /* Moves the date into local time. */
+ case 'L': date = to_local(date); break;
+ case 'Y': outstr += datepad(date.getFullYear(), 4); break;
+ case 'm': outstr += datepad(date.getMonth() + 1); break;
+ case 'd': outstr += datepad(date.getDate()); break;
+ case 'H': outstr += datepad(date.getHours()); break;
+ case 'M': outstr += datepad(date.getMinutes()); break;
+ case 'S': outstr += datepad(date.getSeconds()); break;
+ case 'Z': if (date.utc) outstr += 'Z'; break;
+ }
+ fmtmode = false;
+ } else if (str[i] == '~') {
+ fmtmode = true;
+ } else {
+ outstr += str[i];
+ }
+ }
+ return outstr;
+}
+
+Object.prototype.format = function(/* any number of arguments */) { return "" + this; }
+Date.prototype.format = function(str) { return format_date(this, str); }
+
+/*
+ * Finds the first element of the DOMTokenList whichs value matches
+ * the supplied regexp. Returns a pair of the index and the value.
+ */
+DOMTokenList.prototype.find = function(regexp) {
+ let entries = this.entries();
+ let entry;
+ while (!(entry = entries.next()).done) {
+ if (entry.value[1].match(regexp)) {
+ return entry.value;
+ }
+ }
+}
+
+/* HTMLCollection is the result of a querySelectorAll */
+HTMLCollection.prototype.forEach = function(proc) {
+ for (let el of this) {
+ proc(el);
+ }
+}
diff --git a/static/ts/script.ts b/static/ts/script.ts
new file mode 100644
index 00000000..9238d834
--- /dev/null
+++ b/static/ts/script.ts
@@ -0,0 +1,162 @@
+import { VEvent, xml_to_vcal } from './vevent'
+import {
+ SmallcalCellHighlight, Timebar,
+ initialize_clock_components
+} from './clock'
+import { vcal_objects, event_calendar_mapping } from './globals'
+import { EventCreator } from './event-creator'
+import { PopupElement, setup_popup_element } from './components/popup-element'
+import { initialize_components } from './components'
+
+/*
+ calp specific stuff
+*/
+
+window.addEventListener('load', function() {
+
+ /*
+ TODO possibly check here that both window.EDIT_MODE and window.VIEW have
+ defined values.
+ */
+
+ // let json_objects_el = document.getElementById('json-objects');
+ let div = document.getElementById('xcal-data')!;
+ let vevents = div.firstElementChild!.children;
+
+ for (let vevent of vevents) {
+ let ev = xml_to_vcal(vevent);
+ vcal_objects.set(ev.getProperty('uid'), ev)
+ }
+
+
+ let div2 = document.getElementById('calendar-event-mapping')!;
+ for (let calendar of div2.children) {
+ let calendar_name = calendar.getAttribute('key')!;
+ for (let child of calendar.children) {
+ let uid = child.textContent;
+ if (!uid) {
+ throw "UID required"
+ }
+ event_calendar_mapping.set(uid, calendar_name);
+ let obj = vcal_objects.get(uid);
+ if (obj) obj.calendar = calendar_name
+ }
+ }
+
+ initialize_clock_components();
+ initialize_components();
+
+ /* A full redraw here is WAY to slow */
+ // for (let [_, obj] of vcal_objects) {
+ // for (let registered of obj.registered) {
+ // registered.redraw(obj);
+ // }
+ // }
+
+
+
+ // let start_time = document.querySelector("meta[name='start-time']").content;
+ // let end_time = document.querySelector("meta[name='end-time']").content;
+
+ const sch = new SmallcalCellHighlight(
+ document.querySelector('.small-calendar')!)
+
+ const timebar = new Timebar(/*start_time, end_time*/);
+
+ timebar.update(new Date);
+ sch.update(new Date);
+ window.setInterval(() => {
+ let d = new Date;
+ timebar.update(d);
+ sch.update(d);
+ }, 1000 * 60);
+
+ /* Is event creation active? */
+ if (true && window.EDIT_MODE) {
+ let eventCreator = new EventCreator;
+ for (let c of document.getElementsByClassName("events")) {
+ if (!(c instanceof HTMLElement)) continue;
+ c.addEventListener('mousedown', eventCreator.create_event_down(c));
+ c.addEventListener('mousemove', eventCreator.create_event_move(
+ (c, e) => e.offsetY / c.clientHeight,
+ /* every quarter, every hour */
+ 1 / (24 * 4), false
+ ));
+ c.addEventListener('mouseup', eventCreator.create_event_finisher(
+ function(ev: VEvent) {
+ let uid = ev.getProperty('uid');
+ vcal_objects.set(uid, ev);
+ setup_popup_element(ev);
+ }));
+ }
+
+ for (let c of document.getElementsByClassName("longevents")) {
+ if (!(c instanceof HTMLElement)) continue;
+ c.onmousedown = eventCreator.create_event_down(c);
+ c.onmousemove = eventCreator.create_event_move(
+ (c, e) => e.offsetX / c.clientWidth,
+ /* every day, NOTE should be changed to check
+ interval of longevents */
+ 1 / 7, true
+ );
+ c.onmouseup = eventCreator.create_event_finisher(
+ function(ev: VEvent) {
+ let uid = ev.getProperty('uid');
+ vcal_objects.set(uid, ev);
+ setup_popup_element(ev);
+ });
+ }
+ }
+
+ for (let el of document.getElementsByClassName("event")) {
+ /* Popup script replaces need for anchors to events.
+ On mobile they also have the problem that they make
+ the whole page scroll there.
+ */
+ el.parentElement!.removeAttribute("href");
+ }
+
+ document.onkeydown = function(evt) {
+ evt = evt || window.event;
+ if (!evt.key) return;
+ if (evt.key.startsWith("Esc")) {
+ for (let popup of document.querySelectorAll("popup-element[visible]")) {
+ popup.removeAttribute('visible')
+ }
+ }
+ }
+
+ /* ---------------------------------------- */
+
+ document.addEventListener('keydown', function(event) {
+ /* Physical key position, names are what that key would
+ be in QWERTY */
+ let i = ({
+ 'KeyQ': 0,
+ 'KeyW': 1,
+ 'KeyE': 2,
+ 'KeyR': 3,
+ 'KeyT': 4,
+ 'KeyY': 5,
+ })[event.code];
+ if (i === undefined) return
+ if (!PopupElement.activePopup) return;
+ let element = PopupElement
+ .activePopup
+ .querySelectorAll("[role=tab]")[i] as HTMLInputElement | undefined
+ if (!element) return;
+ /* don't switch tab if event was fired while writing */
+ if ('value' in (event.target as any)) return;
+ element.click();
+ });
+
+ document.addEventListener('keydown', function(event) {
+ if (event.key !== '/') return;
+ if ('value' in (event.target as any)) return;
+
+ let searchbox = document.querySelector('.simplesearch [name=q]') as HTMLInputElement
+ // focuses the input, and selects all the text in it
+ searchbox.select();
+ event.preventDefault();
+ });
+})
diff --git a/static/ts/server_connect.ts b/static/ts/server_connect.ts
new file mode 100644
index 00000000..29f5bab2
--- /dev/null
+++ b/static/ts/server_connect.ts
@@ -0,0 +1,133 @@
+export { create_event, remove_event }
+
+import { jcal_to_xcal } from './jcal'
+import { VEvent } from './vevent'
+import { uid } from './types'
+import { vcal_objects } from './globals'
+import { PopupElement } from './components/popup-element'
+
+async function remove_event(uid: uid) {
+ let element = vcal_objects.get(uid);
+ if (!element) {
+ console.error(`No VEvent with that uid = '${uid}', giving up`)
+ return;
+ }
+
+ let data = new URLSearchParams();
+ data.append('uid', uid);
+
+ let response = await fetch('/remove', {
+ method: 'POST',
+ body: data
+ });
+
+ console.log(response);
+ // toggle_popup(popup_from_event(element));
+
+ if (response.status < 200 || response.status >= 300) {
+ let body = await response.text();
+ alert(`HTTP error ${response.status}\n${body}`)
+ } else {
+ /* Remove all HTML components which belong to this vevent */
+ for (let component of element.registered) {
+ component.remove();
+ }
+ /* remove the vevent from our global store,
+ hopefully also freeing it for garbace collection */
+ vcal_objects.delete(uid);
+ }
+}
+
+// function event_to_jcal(event) {
+// /* encapsulate event in a shim calendar, to ensure that
+// we always send correct stuff */
+// return ['vcalendar',
+// [
+// /*
+// 'prodid' and 'version' are technically both required (RFC 5545,
+// 3.6 Calendar Components).
+// */
+// ],
+// [
+// /* vtimezone goes here */
+// event.properties.to_jcal()
+// ]
+// ];
+// }
+
+async function create_event(event: VEvent) {
+
+ // let xml = event.getElementsByTagName("icalendar")[0].outerHTML
+ let calendar = event.calendar;
+ if (!calendar) {
+ console.error("Can't create event without calendar")
+ return;
+ }
+
+ console.log('calendar =', atob(calendar)/*, xml*/);
+
+ let data = new URLSearchParams();
+ data.append("cal", calendar);
+ // data.append("data", xml);
+
+ // console.log(event);
+
+ let jcal = event.to_jcal();
+ // console.log(jcal);
+
+ let doc: Document = jcal_to_xcal(jcal);
+ // console.log(doc);
+ let str = doc.documentElement.outerHTML;
+ console.log(str);
+ data.append("data", str);
+
+ // console.log(event.properties);
+
+ let response = await fetch('/insert', {
+ method: 'POST',
+ body: data
+ });
+
+ console.log(response);
+ if (response.status < 200 || response.status >= 300) {
+ let body = await response.text();
+ alert(`HTTP error ${response.status}\n${body}`)
+ return;
+ }
+
+ /* response from here on is good */
+
+ // let body = await response.text();
+
+ /* server is assumed to return an XML document on the form
+ <properties>
+ **xcal property** ...
+ </properties>
+ parse that, and update our own vevent with the data.
+ */
+
+ // let parser = new DOMParser();
+ // let return_properties = parser
+ // .parseFromString(body, 'text/xml')
+ // .children[0];
+
+ // let child;
+ // while ((child = return_properties.firstChild)) {
+ // let target = event.querySelector(
+ // "vevent properties " + child.tagName);
+ // if (target) {
+ // target.replaceWith(child);
+ // } else {
+ // event.querySelector("vevent properties")
+ // .appendChild(child);
+ // }
+ // }
+
+ for (let r of event.registered) {
+ r.classList.remove('generated');
+ if (r instanceof PopupElement) {
+ console.log(r);
+ r.removeAttribute('visible');
+ }
+ }
+}
diff --git a/static/ts/types.ts b/static/ts/types.ts
new file mode 100644
index 00000000..64e2c709
--- /dev/null
+++ b/static/ts/types.ts
@@ -0,0 +1,208 @@
+export {
+ ical_type,
+ valid_input_types,
+ JCalProperty, JCal,
+ xcal, uid,
+ ChangeLogEntry
+}
+
+let all_types = [
+ 'text',
+ 'uri',
+ 'binary',
+ 'float', /* Number.type = 'float' */
+ 'integer', /* Number.type = 'integer' */
+ 'date-time', /* Date */
+ 'date', /* Date.dateonly = true */
+ 'duration', /* TODO */
+ 'period', /* TODO */
+ 'utc-offset', /* TODO */
+ 'cal-address',
+ 'recur', /* RRule */
+ 'boolean', /* boolean */
+]
+
+
+type ical_type
+ = 'text'
+ | 'uri'
+ | 'binary'
+ | 'float'
+ | 'integer'
+ | 'date-time'
+ | 'date'
+ | 'duration'
+ | 'period'
+ | 'utc-offset'
+ | 'cal-address'
+ | 'recur'
+ | 'boolean'
+ | 'unknown'
+
+let property_names = [
+ 'calscale', 'method', 'prodid', 'version', 'attach', 'categories',
+ 'class', 'comment', 'description', 'geo', 'location', 'percent-complete',
+ 'priority', 'resources', 'status', 'summary', 'completed', 'dtend', 'due',
+ 'dtstart', 'duration', 'freebusy', 'transp', 'tzid', 'tzname', 'tzoffsetfrom',
+ 'tzoffsetto', 'tzurl', 'attendee', 'contact', 'organizer', 'recurrence-id',
+ 'related-to', 'url', 'uid', 'exdate', 'exrule', 'rdate', 'rrule', 'action',
+ 'repeat', 'trigger', 'created', 'dtstamp', 'last-modified', 'sequence', 'request-status'
+];
+
+
+let valid_fields: Map<string, string[]> = new Map([
+ ['VCALENDAR', ['PRODID', 'VERSION', 'CALSCALE', 'METHOD']],
+ ['VEVENT', ['DTSTAMP', 'UID', 'DTSTART', 'CLASS', 'CREATED',
+ 'DESCRIPTION', 'GEO', 'LAST-MODIFIED', 'LOCATION',
+ 'ORGANIZER', 'PRIORITY', 'SEQUENCE', 'STATUS',
+ 'SUMMARY', 'TRANSP', 'URL', 'RECURRENCE-ID',
+ 'RRULE', 'DTEND', 'DURATION', 'ATTACH', 'ATTENDEE',
+ 'CATEGORIES', 'COMMENT', 'CONTACT', 'EXDATE',
+ 'REQUEST-STATUS', 'RELATED-TO', 'RESOURCES', 'RDATE']],
+ ['VTODO', ['DTSTAMP', 'UID', 'CLASS', 'COMPLETED', 'CREATED',
+ 'DESCRIPTION', 'DTSTART', 'GEO', 'LAST-MODIFIED',
+ 'LOCATION', 'ORGANIZER', 'PERCENT-COMPLETE', 'PRIORITY',
+ 'RECURRENCE-ID', 'SEQUENCE', 'STATUS', 'SUMMARY', 'URL',
+ 'RRULE', 'DUE', 'DURATION', 'ATTACH', 'ATTENDEE', 'CATEGORIES',
+ 'COMMENT', 'CONTACT', 'EXDATE', 'REQUEST-STATUS', 'RELATED-TO',
+ 'RESOURCES', 'RDATE',]],
+ ['VJOURNAL', ['DTSTAMP', 'UID', 'CLASS', 'CREATED', 'DTSTART', 'LAST-MODIFIED',
+ 'ORGANIZER', 'RECURRENCE-ID', 'SEQUENCE', 'STATUS', 'SUMMARY',
+ 'URL', 'RRULE', 'ATTACH', 'ATTENDEE', 'CATEGORIES', 'COMMENT',
+ 'CONTACT', 'DESCRIPTION', 'EXDATE', 'RELATED-TO', 'RDATE',
+ 'REQUEST-STATUS']],
+ ['VFREEBUSY', ['DTSTAMP', 'UID', 'CONTACT', 'DTSTART', 'DTEND',
+ 'ORGANIZER', 'URL', 'ATTENDEE', 'COMMENT', 'FREEBUSY',
+ 'REQUEST-STATUS']],
+ ['VTIMEZONE', ['TZID', 'LAST-MODIFIED', 'TZURL']],
+ ['VALARM', ['ACTION', 'TRIGGER', 'DURATION', 'REPEAT', 'ATTACH',
+ 'DESCRIPTION', 'SUMMARY', 'ATTENDEE']],
+ ['STANDARD', ['DTSTART', 'TZOFFSETFROM', 'TZOFFSETTO', 'RRULE',
+ 'COMMENT', 'RDATE', 'TZNAME']],
+])
+
+valid_fields.set('DAYLIGHT', valid_fields.get('STANDARD')!);
+
+type known_ical_types
+ = 'ACTION'
+ | 'ATTACH'
+ | 'ATTENDEE'
+ | 'CALSCALE'
+ | 'CATEGORIES'
+ | 'CLASS'
+ | 'COMMENT'
+ | 'COMPLETED'
+ | 'CONTACT'
+ | 'CREATED'
+ | 'DESCRIPTION'
+ | 'DTEND'
+ | 'DTSTAMP'
+ | 'DTSTART'
+ | 'DUE'
+ | 'DURATION'
+ | 'EXDATE'
+ | 'FREEBUSY'
+ | 'GEO'
+ | 'LAST-MODIFIED'
+ | 'LOCATION'
+ | 'METHOD'
+ | 'ORGANIZER'
+ | 'PERCENT-COMPLETE'
+ | 'PRIORITY'
+ | 'PRODID'
+ | 'RDATE'
+ | 'RECURRENCE-ID'
+ | 'RELATED-TO'
+ | 'REPEAT'
+ | 'REQUEST-STATUS'
+ | 'RESOURCES'
+ | 'RRULE'
+ | 'SEQUENCE'
+ | 'STATUS'
+ | 'SUMMARY'
+ | 'TRANSP'
+ | 'TRIGGER'
+ | 'TZID'
+ | 'TZNAME'
+ | 'TZOFFSETFROM'
+ | 'TZOFFSETTO'
+ | 'TZURL'
+ | 'URL'
+ | 'VERSION'
+
+let valid_input_types: Map<string, Array<ical_type | ical_type[]>> =
+ new Map([
+ ['ACTION', ['text']], // AUDIO|DISPLAY|EMAIL|*other*
+ ['ATTACH', ['uri', 'binary']],
+ ['ATTENDEE', ['cal-address']],
+ ['CALSCALE', ['text']],
+ ['CATEGORIES', [['text']]],
+ ['CLASS', ['text']], // PUBLIC|PRIVATE|CONFIDENTIAL|*other*
+ ['COMMENT', ['text']],
+ ['COMPLETED', ['date-time']],
+ ['CONTACT', ['text']],
+ ['CREATED', ['date-time']],
+ ['DESCRIPTION', ['text']],
+ ['DTEND', ['date', 'date-time']],
+ ['DTSTAMP', ['date-time']],
+ ['DTSTART', ['date', 'date-time']],
+ ['DUE', ['date', 'date-time']],
+ ['DURATION', ['duration']],
+ ['EXDATE', [['date', 'date-time']]],
+ ['EXRULE', []], /* deprecated */
+ ['FREEBUSY', [['period']]],
+ ['GEO', ['float']], // pair of floats
+ ['LAST-MODIFIED', ['date-time']],
+ ['LOCATION', ['text']],
+ ['METHOD', ['text']],
+ ['ORGANIZER', ['cal-address']],
+ ['PERCENT-COMPLETE', ['integer']], // 0-100
+ ['PRIORITY', ['integer']], // 0-9
+ ['PRODID', ['text']],
+ ['RDATE', [['date', 'date-time', 'period']]],
+ ['RECURRENCE-ID', ['date', 'date-time']],
+ ['RELATED-TO', ['text']],
+ ['REPEAT', ['integer']],
+ ['REQUEST-STATUS', ['text']],
+ ['RESOURCES', [['text']]],
+ ['RRULE', ['recur']],
+ ['SEQUENCE', ['integer']],
+ ['STATUS', ['text']], // see 3.8.1.11
+ ['SUMMARY', ['text']],
+ ['TRANSP', ['text']], // OPAQUE|TRANSPARENT
+ ['TRIGGER', ['duration', 'date-time']],
+ ['TZID', ['text']],
+ ['TZNAME', ['text']],
+ ['TZOFFSETFROM', ['utc-offset']],
+ ['TZOFFSETTO', ['utc-offset']],
+ ['TZURL', ['uri']],
+ ['UID', ['text']],
+ ['URL', ['uri']],
+ ['VERSION', ['text']],
+ ])
+
+// type JCalLine {
+// }
+
+type tagname = 'vevent' | string
+
+type uid = string
+
+/* TODO is this type correct?
+ What really are valid values for any? Does that depend on ical_type? Why is the tail a list?
+ What really is the type for the parameter map?
+*/
+type JCalProperty
+ = [string, Record<string, any>, ical_type, any]
+ | [string, Record<string, any>, ical_type, ...any[]]
+
+type JCal = [tagname, JCalProperty[], JCal[]]
+
+const xcal = "urn:ietf:params:xml:ns:icalendar-2.0";
+
+interface ChangeLogEntry {
+ type: 'calendar' | 'property',
+ name: string,
+ from: string | null,
+ to: string | null,
+}
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)
+}