diff options
Diffstat (limited to 'static/ts')
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) +} |