aboutsummaryrefslogtreecommitdiff
path: root/static
diff options
context:
space:
mode:
Diffstat (limited to 'static')
-rw-r--r--static/.gitignore4
-rw-r--r--static/Makefile26
-rw-r--r--static/_global.scss11
-rw-r--r--static/binders.js150
-rw-r--r--static/clock.js74
-rw-r--r--static/clock.ts115
-rw-r--r--static/components/changelog.ts49
-rw-r--r--static/components/date-time-input.ts121
-rw-r--r--static/components/edit-rrule.ts75
-rw-r--r--static/components/input-list.ts122
-rw-r--r--static/components/popup-element.ts198
-rw-r--r--static/components/tab-group-element.ts178
-rw-r--r--static/components/vevent-block.ts99
-rw-r--r--static/components/vevent-description.ts59
-rw-r--r--static/components/vevent-dl.ts35
-rw-r--r--static/components/vevent-edit.ts167
-rw-r--r--static/components/vevent.ts70
-rw-r--r--static/date_time.js36
-rw-r--r--static/dragable.js41
-rw-r--r--static/elements.ts36
-rw-r--r--static/event-creator.ts181
-rw-r--r--static/globals.ts58
-rw-r--r--static/jcal-tests.js32
-rw-r--r--static/jcal.js174
-rw-r--r--static/jcal.ts192
-rw-r--r--static/lib.js179
-rw-r--r--static/lib.ts233
-rw-r--r--static/package.json13
-rw-r--r--static/popup.js103
-rw-r--r--static/rrule.ts.disabled (renamed from static/rrule.js)40
-rw-r--r--static/script.js417
-rw-r--r--static/script.ts218
-rw-r--r--static/server_connect.js108
-rw-r--r--static/server_connect.ts132
-rw-r--r--static/style.scss344
-rw-r--r--static/tsconfig.json34
-rw-r--r--static/types.js109
-rw-r--r--static/types.ts208
-rw-r--r--static/vcal.js378
-rw-r--r--static/vevent.ts548
40 files changed, 3396 insertions, 1971 deletions
diff --git a/static/.gitignore b/static/.gitignore
index 735b5dce..91b7c2f6 100644
--- a/static/.gitignore
+++ b/static/.gitignore
@@ -1,3 +1,7 @@
*.css
.*-cache
*.map
+deps.svg
+*.js
+!arbitary_kv.js
+!input_list.js
diff --git a/static/Makefile b/static/Makefile
index 821489bc..b85422a3 100644
--- a/static/Makefile
+++ b/static/Makefile
@@ -1,12 +1,34 @@
.PHONY: all clean watch
-TARGETS := style.css smallcal.css
+TARGETS := style.css smallcal.css script.out.js
WATCH=
+# script explicitly named, since that is our entry point
+TS_FILES = script.ts $(shell find . -type f -name \*.ts -not -path */node_modules/*)
+
+export PATH := $(shell npm bin):$(PATH)
+
all: $(TARGETS)
+%.map.json: %.out.js
+ tail -n1 $< | tail -c+65 | base64 --decode | jq '.' > $@
+
+# r!browserify --list script.ts -p tsify | xargs -L1 basename | tac
+script.out.js: $(TS_FILES)
+ browserify $< -p tsify --noImplicitAny --debug -o $@
+
+deps.svg: $(TS_FILES)
+ madge --image $@ $^
+
+# Note that 'tsc --watch' doesn't provide the files we are using. It's
+# just here for debug.
watch:
- $(MAKE) WATCH=--watch all
+ tmux \
+ new-session "scss --watch -I. style.scss:style.css" \; \
+ split-window "tsc --watch" \; \
+ rename-session "calp watch" \; \
+ select-layout even-vertical
+
clean:
rm $(TARGETS)
diff --git a/static/_global.scss b/static/_global.scss
index 8a5bee83..41f426f9 100644
--- a/static/_global.scss
+++ b/static/_global.scss
@@ -1,5 +1,16 @@
$gray: #757575;
$blue: #3399ff;
+/* TODO rename this */
$btn-height: 0.5ex;
+$tablabel-height: 5ex;
+$tablabel-margin: 0;
+// "left" or "top"
+$popup-style: "left";
+
+:root {
+ /* Each popup can have a different amoutn of tabs.
+ Override this as appropriate */
+ --tabcount: 4;
+}
diff --git a/static/binders.js b/static/binders.js
deleted file mode 100644
index a6e37189..00000000
--- a/static/binders.js
+++ /dev/null
@@ -1,150 +0,0 @@
-/*
- bind (event_component, field_to_bind)
-*/
-
-/* vcalendar element */
-
-function bind_recur(el, e) {
- /* todo bind default slots of rrule */
-
- let p = el.properties.get_callback_list('rrule');
- // let rrule = el.rrule;
-
- /* add listeners to bind-rr tags */
- for (let rr of e.querySelectorAll('.bind-rr')) {
- /* TODO handle byday */
- if (rr.classList.contains('input-list')) {
- rr.addEventListener('input', function () {
- let name = rr.attributes.name.value;
- el.properties.rrule[name] = this.get_value();
- });
- } else if (rr.tagName === 'input' || rr.classList.contains('date-time')) {
- rr.addEventListener('input', function () {
- console.log(this);
- el.properties.rrule[rr.name] = this.value;
- });
- } else if (rr.tagName === 'select') {
- rr.addEventListener('change', function () {
- let opt = this.options[this.selectedIndex];
- let v = opt.value;
- // console.log(v);
- el.properties.rrule[rr.name] = v;
- });
- }
- }
-
- p.push([e, function (s, v) {
- /* v is an rrule object */
- for (let f of v.fields) {
- let input_field = s.querySelector(`[name=${f}]`);
- switch (input_field.tagName) {
- case 'input':
- input_field.value = v;
- break;
- case 'select':
- /* TODO */
- console.log("Implement me!");
- break;
- default:
- if (input_field.classList.contains('date-time')) {
- let date = input_field.querySelector('input[type=date]');
- let time = input_field.querySelector('input[type=time]');
- } else if (input_field.classList.contains('input-list')) {
- } else {
- console.log(input_field);
- throw Error();
- }
- }
- }
- }]);
-}
-
-function bind_edit(el, e) {
- let p = el.properties.get_callback_list(e.dataset.property);
- e.addEventListener('input', function () {
- el.properties[e.dataset.property] = this.value;
- });
- let f;
- switch (e.tagName) {
- case 'input':
- switch (e.type) {
- case 'time': f = (s, v) => s.value = v.format("~H:~M"); break;
- case 'date': f = (s, v) => s.value = v.format("~Y-~m-~d"); break;
- // TODO remaining types cases
- default: f = (s, v) => s.value = v;
- }
- p.push([e, f])
- break;
- case 'textarea':
- f = (s, v) => s.textContent = v;
- p.push([e, f])
- break;
- default:
- alert("How did you get here??? " + e.tagName)
- break;
- }
-
-}
-
-function bind_view(el, e) {
- let f = (s, v) => s.innerText = v.format(s.dataset && s.dataset.fmt);
- el.properties.get_callback_list(e.dataset.property).push([e, f]);
-}
-
-
-function bind_wholeday(el, e) {
- let popup = popup_from_event(el);
- let wholeday = popup.querySelector("input[name='wholeday']");
- wholeday.addEventListener('click', function (event) {
- for (let f of popup.querySelectorAll("input[type='time']")) {
- f.disabled = wholeday.checked;
- }
-
- for (let f of ['dtstart', 'dtend']) {
- let param = el.properties[f];
- if (! param) continue; /* dtend optional */
- let d = param.value;
- if (wholeday.checked) {
- param.type = 'date';
- } else {
- param.type = 'date-time';
- }
- d.isWholeDay = wholeday.checked;
- el.properties[f] = d;
- }
- });
-}
-
-
-/* used for dtstart and dtend input boxes
- init_date_time MUST be called beforehand
-*/
-function bind_date_time(el, e) {
- e.addEventListener('input', function () {
- let dt = el.properties[e.name].value;
- if (e.value == '') return;
- let y, m, d, h, s;
- switch (this.type) {
- case 'date':
- [y,m,d] = this.value.split('-')
- dt.setYear(Number(y)/* - 1900 */);
- dt.setMonth(Number(m) - 1);
- dt.setDate(d);
- break;
- case 'time':
- [h,m,s] = this.value.split(':')
- dt.setHours(Number(h));
- dt.setMinutes(Number(m));
- dt.setSeconds(0);
- break;
- default:
- console.log("How did you get here??? ", e);
- }
-
- el.properties[e.name] = dt;
- });
-
- el.properties.get_callback_list(e.name).push(
- [e, (s, v) => s.value = v.format("~Y-~m-~dT~H:~M")]);
-
-}
diff --git a/static/clock.js b/static/clock.js
deleted file mode 100644
index 9642ebaf..00000000
--- a/static/clock.js
+++ /dev/null
@@ -1,74 +0,0 @@
-
-class Clock {
- update(now) {
- }
-}
-
-
-class Timebar extends Clock {
-
- constructor(start_time, end_time) {
- super();
- this.start_time = start_time;
- this.end_time = end_time;
- this.bar_object = false
- }
-
-
- update(now) {
- // 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) {
- 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 {
- constructor(small_cal) {
- super();
- this.small_cal = small_cal;
- this.current_cell = false
- }
-
- update(now) {
- 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";
- }
- }
-}
-
-/* Update [today] button */
-class ButtonUpdater extends Clock {
- constructor(el, proc) {
- super();
- this.el = el;
- this.proc = proc;
- }
-
- update(now) {
- this.proc(this.el, now);
- }
-}
diff --git a/static/clock.ts b/static/clock.ts
new file mode 100644
index 00000000..b0ddae00
--- /dev/null
+++ b/static/clock.ts
@@ -0,0 +1,115 @@
+export { SmallcalCellHighlight, Timebar }
+
+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(now: Date) { /* noop */ }
+}
+
+class TodayButton extends ClockElement {
+ update(now: Date) {
+ (this.querySelector('a') as any).href = now.format("~Y-~m-~d.html")
+ }
+}
+customElements.define('today-button', TodayButton)
+
+
+class CurrentTime extends ClockElement {
+ update(now: Date) {
+ this.textContent = now.format('~H:~M:~S')
+ }
+}
+customElements.define('current-time', CurrentTime)
diff --git a/static/components/changelog.ts b/static/components/changelog.ts
new file mode 100644
index 00000000..831e4ced
--- /dev/null
+++ b/static/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/components/date-time-input.ts b/static/components/date-time-input.ts
new file mode 100644
index 00000000..a6d5df18
--- /dev/null
+++ b/static/components/date-time-input.ts
@@ -0,0 +1,121 @@
+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:~S").split('T');
+ // console.log(d, 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) {
+ // console.log('Setting date');
+ 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/components/edit-rrule.ts b/static/components/edit-rrule.ts
new file mode 100644
index 00000000..a361bdee
--- /dev/null
+++ b/static/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/components/input-list.ts b/static/components/input-list.ts
new file mode 100644
index 00000000..c31066da
--- /dev/null
+++ b/static/components/input-list.ts
@@ -0,0 +1,122 @@
+export { InputList }
+
+/* This file replaces input_list.js */
+
+/*
+ TODO allow each item to be a larger unit, possibly containing multiple input
+ fields.
+*/
+class InputList extends HTMLElement {
+
+ el: HTMLInputElement;
+
+ private _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/components/popup-element.ts b/static/components/popup-element.ts
new file mode 100644
index 00000000..35c966ac
--- /dev/null
+++ b/static/components/popup-element.ts
@@ -0,0 +1,198 @@
+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, oldValue?: string, newValue?: string) {
+ switch (name) {
+ case 'visible':
+ 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() {
+
+ /* 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"][title="Redigera"]') 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/components/tab-group-element.ts b/static/components/tab-group-element.ts
new file mode 100644
index 00000000..05cac7d2
--- /dev/null
+++ b/static/components/tab-group-element.ts
@@ -0,0 +1,178 @@
+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('menu', {}, {
+ 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 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,
+ })
+
+ let tabContainer = makeElement('article', {}, {
+ id: tab_id,
+ role: 'tabpanel',
+ tabindex: 0,
+ hidden: 'hidden',
+ 'aria-labeledby': 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-labeledby')!
+ 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', 'true');
+ }
+ /* 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! as HTMLElement).tagName.toLowerCase() === 'vevent-edit-rrule') {
+ return child;
+ }
+ }
+ return false;
+ }
+
+}
diff --git a/static/components/vevent-block.ts b/static/components/vevent-block.ts
new file mode 100644
index 00000000..8cf61d30
--- /dev/null
+++ b/static/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 && rep.length !== 0) {
+ (rep[0] as HTMLElement).innerText = '↺'
+ }
+ }
+ }
+}
diff --git a/static/components/vevent-description.ts b/static/components/vevent-description.ts
new file mode 100644
index 00000000..4d81d6b3
--- /dev/null
+++ b/static/components/vevent-description.ts
@@ -0,0 +1,59 @@
+export { ComponentDescription }
+
+import { VEvent } from '../vevent'
+import { ComponentVEvent } from './vevent'
+import { makeElement } from '../lib'
+
+/*
+ <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;
+ let p = el.dataset.property!;
+ let d, fmt;
+ if ((d = data.getProperty(p))) {
+ switch (p.toLowerCase()) {
+ case 'categories':
+ 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}`,
+ }))
+ }
+ break;
+ default:
+ if ((fmt = el.dataset.fmt)) {
+ el.textContent = d.format(fmt);
+ } else {
+ el.textContent = d;
+ }
+ }
+ }
+ }
+
+ 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/components/vevent-dl.ts b/static/components/vevent-dl.ts
new file mode 100644
index 00000000..a792c07f
--- /dev/null
+++ b/static/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/components/vevent-edit.ts b/static/components/vevent-edit.ts
new file mode 100644
index 00000000..ee368296
--- /dev/null
+++ b/static/components/vevent-edit.ts
@@ -0,0 +1,167 @@
+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 } 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);
+ }
+
+ 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}.`
+ }
+
+
+ // return;
+
+ /* Handle calendar dropdown */
+ for (let el of this.getElementsByClassName('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;
+ });
+ }
+
+ this.redraw(data);
+
+ // for (let el of this.getElementsByClassName("interactive")) {
+ for (let el of this.querySelectorAll("[data-property]")) {
+ // console.log(el);
+ el.addEventListener('input', (e) => {
+ let obj = vcal_objects.get(this.uid)
+ // console.log(el, e);
+ 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;
+ }
+ // console.log(`obj[${el.dataset.property!}] = `, el.value);
+ 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;
+ });
+ }
+
+ 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/components/vevent.ts b/static/components/vevent.ts
new file mode 100644
index 00000000..b72cda90
--- /dev/null
+++ b/static/components/vevent.ts
@@ -0,0 +1,70 @@
+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 | null
+ uid: string
+
+ constructor(uid?: string) {
+ super();
+ this.template = document.getElementById(this.tagName) as HTMLTemplateElement | null
+
+ let real_uid;
+
+ // console.log(this.tagName);
+ 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/date_time.js b/static/date_time.js
deleted file mode 100644
index 8b7249dd..00000000
--- a/static/date_time.js
+++ /dev/null
@@ -1,36 +0,0 @@
-function init_date_time_single(dt) {
- dt.time = dt.querySelector('[type=time]');
- dt.date = dt.querySelector('[type=date]');
-
- Object.defineProperty(dt, 'value', {
- get: () => (dt.date.value && dt.time.value)
- // TODO wrapping <date-time/> tag
- ? dt.date.value + "T" + dt.time.value
- : "",
- set: (v) => [dt.date.value, dt.time.value] = v.split("T"),
- });
-
- Object.defineProperty(dt, 'name', {
- get: () => dt.attributes.name.value
- });
-
- dt._addEventListener = dt.addEventListener;
- dt.addEventListener = function (field, proc) {
- /* input events are propagated to children
- other events target ourselves */
- switch (field) {
- case 'input':
- dt.time.addEventListener(field, proc);
- dt.date.addEventListener(field, proc);
- break;
- default:
- dt._addEventListener(field, proc);
- }
- }
-}
-
-function init_date_time() {
- for (let dt of document.getElementsByClassName("date-time")) {
- init_date_time_single(dt);
- }
-}
diff --git a/static/dragable.js b/static/dragable.js
deleted file mode 100644
index 41895760..00000000
--- a/static/dragable.js
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- Apply to a given component to make it draggable.
- Drag area (usually a title bar) should be be the only argument.
- It is REQUIRED that the object which should be moved have the class
- 'popup-container';
-*/
-
-
-/*
- Given the navbar of a popup, make it dragable.
- */
-function bind_popup_control (nav) {
-
- if (! nav.closest('.popup-container')) {
- throw TypeError('not a popup container');
- }
-
- nav.onmousedown = 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");
- 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");
-
- 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/elements.ts b/static/elements.ts
new file mode 100644
index 00000000..199839f6
--- /dev/null
+++ b/static/elements.ts
@@ -0,0 +1,36 @@
+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'
+
+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);
+
+ /* 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/event-creator.ts b/static/event-creator.ts
new file mode 100644
index 00000000..3459dba1
--- /dev/null
+++ b/static/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 | null = null;
+
+ /* Graphical block for event. Only here so we can find its siblings,
+ and update pointer events accordingly */
+ event: Element | null = null;
+
+ 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) => any) {
+ 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(e: 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 = null
+ that.event = null;
+
+ callback(localevent);
+
+ }
+ }
+}
diff --git a/static/globals.ts b/static/globals.ts
new file mode 100644
index 00000000..eb7488c0
--- /dev/null
+++ b/static/globals.ts
@@ -0,0 +1,58 @@
+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: ((e: any) => void);
+ }
+}
+window.vcal_objects = vcal_objects;
+
+
+window.addNewEvent = () => {
+ let ev = new VEvent();
+ let uid = uuid()
+ let now = new Date()
+ 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.tagName === 'vevent-block') {
+ return el as ComponentBlock;
+ }
+ }
+ // throw 'Popup not fonud';
+ return null;
+}
diff --git a/static/jcal-tests.js b/static/jcal-tests.js
deleted file mode 100644
index c84d9bd1..00000000
--- a/static/jcal-tests.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/* "Test cases" for jcal.js.
- ideally we would actually have runnable tests, but
- `document' is only available in the browser.
-*/
-
-let doc = document.implementation.createDocument(xcal, 'icalendar');
-
-jcal = ['key', {}, 'text', 'value'];
-
-jcal_property_to_xcal_property(doc, jcal);
-
-
-
-jcal_to_xcal(['vcalendar', [], [['vevent', [['key', {}, 'text', 'value']], []]]]).childNodes[0].outerHTML
-
-/* returns (except not pretty printee)
-<icalendar xmlns="urn:ietf:params:xml:ns:icalendar-2.0">
- <vcalendar>
- <properties/>
- <components>
- <vevent>
- <properties>
- <key>
- <text>value</text>
- </key>
- </properties>
- <components/>
- </vevent>
- </components>
- </vcalendar>
-</icalendar>
-*/
diff --git a/static/jcal.js b/static/jcal.js
deleted file mode 100644
index 003294d1..00000000
--- a/static/jcal.js
+++ /dev/null
@@ -1,174 +0,0 @@
-function jcal_type_to_xcal(doc, type, value) {
- 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;
- 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;
- 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, jcal) {
- 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[key])) {
- let text = doc.createElementNS(xcal, 'text');
- text.textContent = '' + v;
- el.appendChild(text);
- }
-
- paramEl.appendChild(el);
- }
-
- if (paramEl.childCount > 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;
- 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) {
- 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, 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/jcal.ts b/static/jcal.ts
new file mode 100644
index 00000000..41f33db4
--- /dev/null
+++ b/static/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/lib.js b/static/lib.js
deleted file mode 100644
index 1d42100c..00000000
--- a/static/lib.js
+++ /dev/null
@@ -1,179 +0,0 @@
-'use strict';
-/*
- General procedures which in theory could be used anywhere.
- */
-
-HTMLElement.prototype._addEventListener = HTMLElement.prototype.addEventListener;
-HTMLElement.prototype.addEventListener = function (name, proc) {
- if (! this.listeners) this.listeners = {};
- if (! this.listeners[name]) this.listeners[name] = [];
- this.listeners[name].push(proc);
- return this._addEventListener(name, proc);
-};
-
-
-
-/* list of lists -> list of tuples */
-function zip(...args) {
- // console.log(args);
- if (args === []) return [];
- return [...Array(Math.min(...args.map(x => x.length))).keys()]
- .map((_, i) => args.map(lst => lst[i]));
-}
-
-
-/* ----- 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) {
- let year, month, day, hour=false, minute, second=0, utc;
-
- 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 'Bad argument';
- }
-
- 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 copyDate(date) {
- let d = new Date(date);
- d.utc = date.utc;
- d.dateonly = date.dateonly;
- return d;
-}
-
-function to_local(date) {
- if (! date.utc) return date;
-
- return new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
-}
-
-/* -------------------------------------------------- */
-
-function makeElement (name, attr={}) {
- let element = document.createElement(name);
- for (let [key, value] of Object.entries(attr)) {
- element[key] = value;
- }
- return element;
-}
-
-function round_time (time, fraction) {
- 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) {
- 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 setVar(str, val) {
- document.documentElement.style.setProperty("--" + str, val);
-}
-
-
-function asList(thing) {
- if (thing instanceof Array) {
- return thing;
- } else {
- return [thing];
- }
-}
-
-
-/* internal */
-function datepad(thing, width=2) {
- return (thing + "").padStart(width, "0");
-}
-
-function format_date(date, str) {
- 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 () { return "" + this; } /* any number of arguments */
-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;
- }
- }
-}
-
-const xcal = "urn:ietf:params:xml:ns:icalendar-2.0";
diff --git a/static/lib.ts b/static/lib.ts
new file mode 100644
index 00000000..2ef5b596
--- /dev/null
+++ b/static/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/package.json b/static/package.json
new file mode 100644
index 00000000..27ea218a
--- /dev/null
+++ b/static/package.json
@@ -0,0 +1,13 @@
+{
+ "dependencies": {
+ "browserify": "^17.0.0",
+ "tsify": "^5.0.4"
+ },
+ "devDependencies": {
+ "@types/uuid": "^8.3.1",
+ "uuid": "^8.3.2"
+ },
+ "optionalDependencies": {
+ "madge": "^5.0.1"
+ }
+}
diff --git a/static/popup.js b/static/popup.js
deleted file mode 100644
index e19db6f2..00000000
--- a/static/popup.js
+++ /dev/null
@@ -1,103 +0,0 @@
-
-
-/* event component => coresponding popup component */
-function event_from_popup(popup) {
- return document.getElementById(popup.id.substr(5))
-}
-
-/* popup component => coresponding event component */
-function popup_from_event(event) {
- return document.getElementById("popup" + event.id);
-}
-
-/* hides given popup */
-function close_popup(popup) {
- popup.classList.remove("visible");
-}
-
-/* hides all popups */
-function close_all_popups () {
- for (let popup of document.querySelectorAll(".popup-container.visible")) {
- close_popup(popup);
- }
-}
-
-/* open given popup */
-function open_popup(popup) {
- popup.classList.add("visible");
- let element = event_from_popup(popup);
- // let root = document.body;
- let root;
- switch (VIEW) {
- case 'week':
- root = document.getElementsByClassName("days")[0];
- break;
- case 'month':
- default:
- root = document.body;
- break;
- }
- /* 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) {
- offsetX += element.offsetLeft;
- offsetY += element.offsetTop;
- element = element.offsetParent;
- }
- popup.style.left = offsetX + "px";
- popup.style.top = offsetY + "px";
-}
-
-/* toggles open/closed status of popup given by id */
-function toggle_popup(popup_id) {
- let popup = document.getElementById(popup_id);
- if (popup.classList.contains("visible")) {
- close_popup(popup);
- } else {
- open_popup(popup);
- }
-}
-
-/* Code for managing "selected" popup */
-/* Makes the popup last hovered over the selected popup, moving it to
- * the top, and allowing global keyboard bindings to affect it. */
-
-let activePopup;
-
-for (let popup of document.querySelectorAll('.popup-container')) {
- /* TODO possibly only change "active" element after a fraction of
- * a second, for example when moving between tabs */
- popup.addEventListener('mouseover', function () {
- /* This is ever so slightly inefficient,
- but it really dosen't mammet */
- for (let other of
- document.querySelectorAll('.popup-container'))
- {
- /* TODO get this from somewhere */
- /* Currently it's manually copied from the stylesheet */
- other.style['z-index'] = 1000;
- }
- popup.style['z-index'] += 1;
- activePopup = popup;
- });
-}
-
-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,
- })[event.code];
- if (i === undefined) return
- if (! activePopup) return;
- let element = activePopup.querySelectorAll(".tab > label")[i];
- if (! element) return;
- element.click();
-});
-
-/* END Code for managing "selected" popup */
diff --git a/static/rrule.js b/static/rrule.ts.disabled
index e7377370..f210ee77 100644
--- a/static/rrule.js
+++ b/static/rrule.ts.disabled
@@ -23,11 +23,11 @@ class RRule {
be listeners */
fields = ['freq', 'until', 'count', 'interval',
- 'bysecond', 'byminute', 'byhour',
- 'bymonthday', 'byyearday', 'byweekno',
- 'bymonth', 'bysetpos', 'wkst',
- 'byday'
- ]
+ 'bysecond', 'byminute', 'byhour',
+ 'bymonthday', 'byyearday', 'byweekno',
+ 'bymonth', 'bysetpos', 'wkst',
+ 'byday'
+ ]
constructor() {
@@ -37,20 +37,20 @@ class RRule {
this[f] = false;
Object.defineProperty(
this, f, {
- /*
- TODO many of the fields should be wrapped
- in type tags. e.g. <until> elements are either
- <date> or <date-time>, NOT a raw date.
- by* fields should be wrapped with multiple values.
- */
- get: () => this['_' + f],
- set: (v) => {
- this['_' + f] = v
- for (let l of this.listeners[f]) {
- l(v);
- }
+ /*
+ TODO many of the fields should be wrapped
+ in type tags. e.g. <until> elements are either
+ <date> or <date-time>, NOT a raw date.
+ by* fields should be wrapped with multiple values.
+ */
+ get: () => this['_' + f],
+ set: (v) => {
+ this['_' + f] = v
+ for (let l of this.listeners[f]) {
+ l(v);
}
- });
+ }
+ });
this.listeners[f] = [];
}
}
@@ -68,7 +68,7 @@ class RRule {
let root = doc.createElementNS(xcal, 'recur');
for (let f of this.fields) {
let v = this.fields[f];
- if (! v) continue;
+ if (!v) continue;
let tag = doc.createElementNS(xcal, f);
/* TODO type formatting */
tag.textContent = `${v}`;
@@ -81,7 +81,7 @@ class RRule {
let obj = {};
for (let f of this.fields) {
let v = this[f];
- if (! v) continue;
+ if (!v) continue;
/* TODO special formatting for some types */
obj[f] = v;
}
diff --git a/static/script.js b/static/script.js
deleted file mode 100644
index a0d58c27..00000000
--- a/static/script.js
+++ /dev/null
@@ -1,417 +0,0 @@
-'use strict';
-
-/*
- calp specific stuff
-*/
-
-class EventCreator {
-
- /* dynamicly created event when dragging */
- constructor() {
- this.event = false;
- this.event_start = { x: NaN, y: NaN };
- this.down_on_event = false;
- }
-
- create_empty_event () {
- /* TODO this doesn't clone JS attributes */
-
- let event = document.getElementById("event-template")
- .firstChild.cloneNode(true);
- let popup = document.getElementById("popup-template")
- .firstChild.cloneNode(true);
-
- /* -------------------- */
- /* Manually transfer or recreate attributes which we still need */
- /* TODO shouldn't these use transferListeners (or similar)?
- See input_list.js:transferListeners */
-
- for (let dt of popup.getElementsByClassName("date-time")) {
- init_date_time_single(dt);
- }
-
- popup.getElementsByClassName("edit-form")[0].onsubmit = function () {
- create_event(event);
- return false; /* stop default */
- }
-
- /* -------------------- */
- /* Fix tabs for newly created popup */
-
- let id = gensym ("__js_event");
-
- // TODO remove button?
- // $("button 2??").onclick = `remove_event(document.getElementById('${id}'))`
-
- let tabgroup_id = gensym();
- for (let tab of popup.querySelectorAll(".tabgroup .tab")) {
- let new_id = gensym();
- let input = tab.querySelector("input");
- input.id = new_id;
- input.name = tabgroup_id;
- tab.querySelector("label").setAttribute('for', new_id);
- }
-
- let nav = popup.getElementsByClassName("popup-control")[0];
- bind_popup_control(nav);
-
- /* -------------------- */
-
- // TODO download links
-
- /* -------------------- */
-
- event.id = id;
- popup.id = "popup" + id;
-
- return [popup, event];
- }
-
- create_event_down (intended_target) {
- let that = this;
- return function (e) {
- /* 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 fractions
- 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, round_to=1, wide_element=false) {
- let that = this;
- return function (e) {
- if (e.buttons != 1 || ! that.down_on_event) return;
-
- /* Create event when we start moving the mouse. */
- if (! that.event) {
- /* 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;
-
- /* 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.
- */
- let time = round_time(pos_in(this, e), round_to);
-
- event.dataset.time1 = time;
- event.dataset.time2 = time;
-
- /* ---------------------------------------- */
-
- this.appendChild(event);
-
- /* 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.style.pointerEvents = "none";
- }
-
- }
-
- let time1 = Number(that.event.dataset.time1);
- let time2 = that.event.dataset.time2 =
- round_time(pos_in(that.event.parentElement, e),
- round_to);
-
- /* ---------------------------------------- */
-
- let event_container = that.event.closest(".event-container");
-
- /* 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 - container_start;
-
- let start_in_duration = duration * Math.min(time1,time2);
- let end_in_duration = duration * Math.max(time1,time2);
-
- /* 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)
-
- // console.log(that.event);
- console.log(d1, d2);
- that.event.properties.dtstart = d1;
- that.event.properties.dtend = d2;
- }
- }
-
- create_event_finisher (callback) {
- let that = this;
- return function create_event_up (e) {
- if (! that.event) return;
-
- /* Restore pointer events for all existing events.
- Allow pointer events on our new event
- */
- for (let e of that.event.parentElement.children) {
- e.style.pointerEvents = "";
- }
-
- place_in_edit_mode(that.event);
-
- let localevent = that.event;
- that.event = null;
-
- callback (localevent);
-
- }
- }
-}
-
-
-
-/* This incarnation of this function only adds the calendar switcher dropdown.
- All events are already editable by switching to that tab.
-
- TODO stop requiring a weird button press to change calendar.
-*/
-function place_in_edit_mode (event) {
- let popup = document.getElementById("popup" + event.id)
- let container = popup.getElementsByClassName('dropdown-goes-here')[0]
- let calendar_dropdown = document.getElementById('calendar-dropdown-template').firstChild.cloneNode(true);
-
- let [_, calclass] = popup.classList.find(/^CAL_/);
- label: {
- for (let [i, option] of calendar_dropdown.childNodes.entries()) {
- if (option.value === calclass.substr(4)) {
- calendar_dropdown.selectedIndex = i;
- break label;
- }
- }
- /* no match, try find default calendar */
- let t;
- if ((t = calendar_dropdown.querySelector("[selected]"))) {
- event.properties.calendar = t.value;
- }
- }
-
-
- /* Instant change while user is stepping through would be
- * preferable. But I believe that <option> first gives us the
- * input once selected */
- calendar_dropdown.onchange = function () {
- event.properties.calendar = this.value;
- }
- container.appendChild(calendar_dropdown);
-
- let tab = popup.getElementsByClassName("tab")[1];
- let radio = tab.getElementsByTagName("input")[0];
- radio.click();
- tab.querySelector("input[name='summary']").focus();
-}
-
-
-
-window.onload = function () {
- // let start_time = document.querySelector("meta[name='start-time']").content;
- // let end_time = document.querySelector("meta[name='end-time']").content;
-
- const button_updater = new ButtonUpdater(
- document.getElementById("today-button"),
- (e, d) => e.href = d.format('~Y-~m-~d') + ".html"
- );
-
- 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);
- button_updater.update(d);
- sch.update(d);
- }, 1000 * 60);
-
- init_date_time();
-
- /* Is event creation active? */
- if (EDIT_MODE) {
- let eventCreator = new EventCreator;
- for (let c of document.getElementsByClassName("events")) {
- c.onmousedown = eventCreator.create_event_down(c);
- c.onmousemove = eventCreator.create_event_move(
- (c,e) => e.offsetY / c.clientHeight,
- /* every quarter, every hour */
- 1/(24*4), false
- );
- c.onmouseup = eventCreator.create_event_finisher(
- function (event) {
- let popupElement = document.getElementById("popup" + event.id);
- open_popup(popupElement);
-
- popupElement.querySelector("input[name='summary']").focus();
-
- });
- }
-
- for (let c of document.getElementsByClassName("longevents")) {
- 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 (event) {
- let popupElement = document.getElementById("popup" + event.id);
- open_popup(popupElement);
-
- popupElement.querySelector("input[name='summary']").focus();
-
- /* This assumes that it's unchecked beforehand.
- Preferably we would just ensure that it's checked here,
- But we also need to make sure that the proper handlers
- are run then */
- popupElement.querySelector("input[name='wholeday']").click();
-
- });
- }
- }
-
- for (let nav of document.getElementsByClassName("popup-control")) {
- bind_popup_control(nav);
- }
-
- 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");
-
- let popup = document.getElementById("popup" + el.id);
- popup.getElementsByClassName("edit-form")[0].onsubmit = function () {
- create_event(el);
- return false; /* stop default */
- }
-
- /* Bind all vcomponent properties into javascript. */
- if (el.closest(".longevents")) {
- new VComponent(el, true);
- } else {
- new VComponent(el, false);
- }
-
- }
-
- document.onkeydown = function (evt) {
- evt = evt || window.event;
- if (! evt.key) return;
- if (evt.key.startsWith("Esc")) {
- close_all_popups();
- }
- }
-
-
- /* 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.
- */
-
- let gotodatebtn = document.querySelector("#jump-to .btn");
- let target_href = (new Date).format("~Y-~m-~d") + ".html";
- let golink = makeElement('a', {
- className: 'btn',
- href: target_href,
- innerHTML: gotodatebtn.innerHTML,
- });
- document.getElementById("today-button").href = target_href;
- gotodatebtn.replaceWith(golink);
-
- document.querySelector("#jump-to input[name='date']").onchange = function () {
- let date = this.valueAsDate.format("~Y-~m-~d");
- console.log(date);
- golink.href = date + ".html";
- }
-
- /* ---------------------------------------- */
-
- /* needs to be called AFTER bind_properties, but BEFORE init_input_list
- After bind_properties since that initializes categories to a possible field
- Before init_input_list since we need this listener to be propagated to clones.
- [CATEGORIES_BIND]
- */
- for (let lst of document.querySelectorAll(".input-list[data-property='categories']")) {
- let f = function () {
- console.log(lst, lst.closest('.popup-container'));
- let event = event_from_popup(lst.closest('.popup-container'))
- event.properties.categories = lst.get_value();
- };
-
- for (let inp of lst.querySelectorAll('input')) {
- inp.addEventListener('input', f);
- }
- }
-
- // init_arbitary_kv();
-
- init_input_list();
-
-
- document.addEventListener('keydown', function (event) {
- if (event.key == '/') {
- let searchbox = document.querySelector('.simplesearch [name=q]')
- // focuses the input, and selects all the text in it
- searchbox.select();
- event.preventDefault();
- }
- });
-}
-
diff --git a/static/script.ts b/static/script.ts
new file mode 100644
index 00000000..895b0081
--- /dev/null
+++ b/static/script.ts
@@ -0,0 +1,218 @@
+import { VEvent, xml_to_vcal } from './vevent'
+import { SmallcalCellHighlight, Timebar } from './clock'
+import { makeElement } from './lib'
+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 './elements'
+
+/*
+ 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_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");
+
+ let popup = document.getElementById("popup" + el.id);
+ // popup.getElementsByClassName("edit-form")[0].onsubmit = function () {
+ // create_event(el);
+ // return false; /* stop default */
+ // }
+
+ /* Bind all vcomponent properties into javascript. */
+ // if (el.closest(".longevents")) {
+ // new VComponent(el, true);
+ // } else {
+ // new VComponent(el, false);
+ // }
+
+ }
+
+ 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')
+ }
+ }
+ }
+
+
+ /* 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.
+ */
+
+ let gotodatebtn = document.querySelector("#jump-to .btn")!;
+ let target_href = (new Date).format("~Y-~m-~d") + ".html";
+ let golink = makeElement('a', {
+ className: 'btn',
+ href: target_href,
+ innerHTML: gotodatebtn.innerHTML,
+ }) as HTMLAnchorElement
+ gotodatebtn.replaceWith(golink);
+
+ (document.querySelector("#jump-to input[name='date']") as HTMLInputElement)
+ .onchange = function() {
+ let date = (this as HTMLInputElement).valueAsDate!.format("~Y-~m-~d");
+ console.log(date);
+ golink.href = date + ".html";
+ }
+
+ /* ---------------------------------------- */
+
+ /* needs to be called AFTER bind_properties, but BEFORE init_input_list
+ After bind_properties since that initializes categories to a possible field
+ Before init_input_list since we need this listener to be propagated to clones.
+ [CATEGORIES_BIND]
+ */
+ // TODO fix this
+ // for (let lst of document.querySelectorAll(".input-list[data-property='categories']")) {
+ // let f = function() {
+ // console.log(lst, lst.closest('.popup-container'));
+ // let event = event_from_popup(lst.closest('.popup-container'))
+ // event.properties.categories = lst.get_value();
+ // };
+
+ // for (let inp of lst.querySelectorAll('input')) {
+ // inp.addEventListener('input', f);
+ // }
+ // }
+
+ // init_arbitary_kv();
+
+ // init_input_list();
+
+ 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/server_connect.js b/static/server_connect.js
deleted file mode 100644
index ef5de5a9..00000000
--- a/static/server_connect.js
+++ /dev/null
@@ -1,108 +0,0 @@
-
-async function remove_event (element) {
- let uid = element.querySelector("icalendar uid text").textContent;
-
- let data = new URLSearchParams();
- data.append('uid', uid);
-
- let response = await fetch ( '/remove', {
- method: 'POST',
- body: data
- });
-
- console.log(response);
- toggle_popup("popup" + element.id);
-
- if (response.status < 200 || response.status >= 300) {
- let body = await response.text();
- alert(`HTTP error ${response.status}\n${body}`)
- } else {
- element.remove();
- }
-}
-
-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) {
-
- // let xml = event.getElementsByTagName("icalendar")[0].outerHTML
- let calendar = event.properties.calendar.value;
-
- console.log('calendar=', calendar/*, xml*/);
-
- let data = new URLSearchParams();
- data.append("cal", calendar);
- // data.append("data", xml);
-
- console.log(event);
-
- let jcal = event_to_jcal(event);
-
-
-
- let doc = jcal_to_xcal(jcal);
- console.log(doc);
- let str = doc.documentElement.outerHTML;
- console.log(str);
- data.append("data", str);
-
- // console.log(event.properties);
-
- // return;
-
- 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;
- }
-
- 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);
- }
- }
-
- event.classList.remove("generated");
- toggle_popup("popup" + event.id);
-}
diff --git a/static/server_connect.ts b/static/server_connect.ts
new file mode 100644
index 00000000..61eb4f30
--- /dev/null
+++ b/static/server_connect.ts
@@ -0,0 +1,132 @@
+export { create_event, remove_event }
+
+import { jcal_to_xcal } from './jcal'
+import { VEvent } from './vevent'
+import { uid } from './types'
+import { vcal_objects } from './globals'
+
+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=', 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.tagName.toLowerCase() === 'popup-element') {
+ console.log(r);
+ r.removeAttribute('visible');
+ }
+ }
+}
diff --git a/static/style.scss b/static/style.scss
index a29bb24b..efe8291d 100644
--- a/static/style.scss
+++ b/static/style.scss
@@ -24,7 +24,10 @@ html, body {
/* main the graphical portion
of both the wide and the table
view */
- main {
+ > main {
+ /* these allow the main area to shrink, so that all areas will fit the
+ screen. It will however not shrink the elements, leading to our
+ (desired) scrollbar */
min-width: 0; /* for wide */
min-height: 0; /* for tall */
@@ -42,6 +45,19 @@ html, body {
-1em 1em 0.5em gold;
z-index: 1;
}
+
+ /* For new event button */
+ position: relative;
+
+ /* add new event button */
+ > button {
+ position: absolute;
+ right: 2mm;
+ bottom: 5mm;
+
+ height: 1cm;
+ width: 1cm;
+ }
}
@@ -85,6 +101,10 @@ html, body {
text-decoration: none;
}
+.hidden {
+ display: none;
+}
+
/* Change View
----------------------------------------
@@ -128,8 +148,6 @@ html, body {
*/
.btn {
- padding: 0;
-
/* if a */
text-decoration: none;
@@ -137,29 +155,21 @@ html, body {
border: none;
background-color: inherit;
- > div {
- padding: 0.5em;
- background-color: $blue;
- color: white;
+ /* --- */
- box-sizing: border-box;
- width: 100%;
- height: 100%;
-
- display: flex;
- justify-content: center;
- align-items: center;
+ box-sizing: border-box;
+ padding: 0.5em;
- /* shouldn't be needed, but otherwise wont align with a text input
- inside a shared flex-container.
- It however seems to make <a> and <button> tag refuse to be the same height?
- */
- height: 2.5em;
+ background-color: $blue;
+ color: white;
- box-shadow: $btn-height $btn-height gray;
- }
+ display: flex;
+ justify-content: center;
+ align-items: center;
- &:active > div {
+ /* move button slightly, to give illusion of 3D */
+ box-shadow: $btn-height $btn-height gray;
+ &:active {
transform: translate($btn-height, $btn-height);
box-shadow: none;
}
@@ -329,12 +339,15 @@ along with their colors.
.cal-cell {
overflow-y: auto;
- .event {
+ .event, vevent-block {
font-size: 8pt;
border-radius: 5px;
padding: 2px;
- margin: 2px;
+ margin-top: 2px;
+ margin-bottom: 2px;
font-family: arial;
+
+ box-sizing: border-box;
}
}
@@ -501,7 +514,8 @@ along with their colors.
* This makes the borders of the object be part of the size.
* Making placing it on the correct line much simpler.
*/
-.clock, .days .event, .eventlike {
+.clock, .eventlike,
+.days vevent-block {
position: absolute;
box-sizing: border-box;
margin: 0;
@@ -535,13 +549,21 @@ along with their colors.
}
/* graphical block in the calendar */
-.event {
+vevent-block, .event {
transition: 0.3s;
font-size: var(--event-font-size);
- overflow: visible;
+ overflow: hidden;
background-color: var(--color);
color: var(--complement);
+ // position: absolute;
+ display: block;
+
+ width: 100%;
+ min-height: 1em;
+ border: 1px solid black;
+ /* backgroudn: blue; */
+
/* Event is not confirmed to happen */
&.tentative {
border: 3px dashed black;
@@ -562,6 +584,8 @@ along with their colors.
&.generated {
opacity: 40%;
+ /* TODO only disable transitions for top/botom, and only
+ * when dragging (not when updating through other means) */
transition: none;
}
@@ -651,6 +675,18 @@ along with their colors.
color: $gray;
padding-right: 1em;
}
+
+ .categories > a::after {
+ content: ","
+ }
+
+ .categories > a:last-child::after {
+ content: ""
+ }
+
+ .categories > a {
+ margin-right: 1ch;
+ }
}
.attach {
@@ -734,7 +770,7 @@ along with their colors.
.error {
border: 3px solid red;
background-color: pink;
-
+
pre {
padding: 1em;
}
@@ -744,167 +780,153 @@ along with their colors.
----------------------------------------
*/
+popup-element {
+ display: none;
+ position: absolute;
+ z-index: 1000;
-.popup {
- display: flex;
- background-color: #dedede;
- color: black;
- font-size: 80%;
-
- /* overflow-y: auto; */
- max-width: 60ch;
- max-height: 60ch;
- min-width: 60ch;
- min-height: 30ch;
-
- &-container {
- display: none;
- position: absolute;
- z-index: 1000;
+ &[active] {
+ z-index: 1001;
+ }
- /* ??? */
- left: 10px;
- top: -50px;
+ /* ??? */
+ left: 10px;
+ top: -50px;
- box-shadow: gray 10px 10px 10px;
+ box-shadow: gray 10px 10px 10px;
- &.visible {
- display: block;
- }
+ &[visible] {
+ display: block;
}
- input {
- white-space: initial;
- border: 1px solid gray;
- max-width: 100%;
- }
+ > * {
+ resize: both;
+ /* This overflow: auto gives us the correct resize handle */
+ overflow: auto;
- .eventtext {
- /* makes the text in the popup scroll, but not the sidebar */
- overflow-y: auto;
- padding: 1em;
- word-break: break-word;
+ /* TODO this doesn't work, since tabcount is sepparate fronm
+ * popup... */
+ min-height: calc(var(--tabcount) * #{$tablabel-margin + $tablabel-height});
- table {
- word-break: initial;
- font-size: 65%;
- }
+ /* some form of sensible minimi and default size for the popup (s main area). */
+ min-width: 150px;
+ width: 350px;
+ height: 250px;
}
+}
- .location {
- font-style: initial;
- }
+.popup-control {
+ cursor: grab;
+ background-color: var(--color);
- .category {
- display: inline-block;
- margin-right: 1ex;
- }
+ display: flex;
- .popup-control {
- display: flex;
+ @if $popup-style == "left" {
flex-direction: column;
+ padding: 1.2ex;
+ } @else {
+ flex-direction: row-reverse;
+ padding: 1ex;
+ }
- /* not needed, but the icons aren't text
- and should therefor not be copied */
- user-select: none;
-
- cursor: grab;
- background-color: var(--color);
- /* Transition for background color
- * Matches that of '.event'.
- * TODO break out to common place */
- transition: 0.3s;
-
- .btn {
- max-width: 2em;
- max-height: 2em;
- margin: 1em;
- display: flex;
- align-items: center;
- justify-content: center;
+ button {
+ display: block;
+ background: $blue;
+ color: white;
+ border: none;
+ box-shadow: $btn-height $btn-height gray;
- font-size: 150%;
+ &:active {
+ transform: translate($btn-height, $btn-height);
+ box-shadow: none;
}
+ @if $popup-style == "left" {
+ width: 9mm;
+ height: 9mm;
+ margin-bottom: 2mm;
+ } @else {
+ width: 7mm;
+ height: 7mm;
+ margin-left: 1mm;
+ }
}
}
+.popup-root {
+ display: flex;
-#bar {
- width: calc(100% + 2em);
- height: 4px;
- background: blue;
- border-color: blue;
- left: -1em;
+ @if $popup-style == "left" {
+ flex-direction: row;
+ } @else {
+ flex-direction: column;
+ }
}
-/* Tabs
-----------------------------------------
-*/
+tab-group {
+ background-color: #dedede;
+ color: black;
-.tabgroup {
- position: relative;
width: 100%;
- resize: both;
- --tab-size: 6ex;
-}
+ height: 100%;
+ /* This overflow: auto gives us the correct rendering of the content */
+ overflow: auto;
-.tab {
- > label {
- position: absolute;
+ [role="tabpanel"] {
+ padding: 1em;
+ }
+ [role="tablist"] {
+ display: flex;
+ flex-direction: column;
+ position: absolute;
left: 100%;
- top: 0;
- display: block;
+ margin: 0;
+ padding: 0;
- max-height: 5ex;
- min-height: 5ex;
+ [role="tab"] {
+ height: $tablabel-height;
+ margin-bottom: $tablabel-margin;
- min-width: 5ex;
- width: 5ex;
+ width: 5ex;
+ &:hover {
+ width: 10ex;
+ }
- transition: width 0.1s ease-in-out;
- &:hover {
- width: 10ex;
+ transition: width 0.1s ease-in-out;
+ border: 1px solid #ccc;
+ border-radius: 0 5px 5px 0;
+ background-color: #aeaeae;
}
- border: 1px solid #ccc;
- border-radius: 0 5px 5px 0;
- background-color: #aeaeae;
-
- display: flex;
- justify-content: center;
- align-items: center;
- }
-
- [type=radio] {
- display: none;
- &:checked ~ label {
- z-index: 100;
- /* to align all tab */
- border-left: 3px solid transparent;
+ [aria-selected="true"] {
+ border-left: none;
background-color: #dedede;
-
- ~ .content {
- z-index: 100;
- }
}
}
+}
- .content {
- position: absolute;
- top: 0;
- left: 0;
- background-color: #dedede;
- right: 0;
- bottom: 0;
- overflow: auto;
+vevent-edit {
+
+ select {
+ max-width: 100%;
+ }
- min-width: 100%;
- min-height: 100%;
+ input {
+ white-space: initial;
+ border: 1px solid gray;
+ max-width: 100%;
}
+ .eventtext {
+ word-break: break-word;
+
+ table {
+ word-break: initial;
+ font-size: 65%;
+ }
+ }
.edit-form {
label {
@@ -924,10 +946,36 @@ along with their colors.
.timeinput {
}
}
+}
+
+.checkboxes {
+ display: grid;
+ grid-template-rows: 1fr 1fr;
+ justify-items: baseline;
+ label {grid-row: 1;}
+ input {grid-row: 2;}
+}
+
+vevent-dl {
+ font-size: 80%;
+ dl {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ }
}
+
+#bar {
+ width: calc(100% + 2em);
+ height: 4px;
+ background: blue;
+ border-color: blue;
+ left: -1em;
+}
+
+
.plusminuschecked label {
color: black;
}
diff --git a/static/tsconfig.json b/static/tsconfig.json
new file mode 100644
index 00000000..82359e01
--- /dev/null
+++ b/static/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ /* Visit https://aka.ms/tsconfig.json to read more about this file */
+
+ /* Projects */
+
+ /* Language and Environment */
+ "target": "es2017", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
+
+ /* Modules */
+ "module": "CommonJS", /* Specify what module code is generated. */
+
+ /* JavaScript Support */
+ "allowJs": false, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
+
+ /* Emit */
+ // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
+ // "declarationMap": true, /* Create sourcemaps for d.ts files. */
+ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
+ "sourceMap": true, /* Create source map files for emitted JavaScript files. */
+ "newLine": "lf", /* Set the newline character for emitting files. */
+ "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
+
+ /* Interop Constraints */
+ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
+ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
+
+ /* Type Checking */
+ "strict": true, /* Enable all strict type-checking options. */
+
+ /* Completeness */
+ "skipLibCheck": true /* Skip type checking all .d.ts files. */
+ }
+}
diff --git a/static/types.js b/static/types.js
deleted file mode 100644
index 02ae2261..00000000
--- a/static/types.js
+++ /dev/null
@@ -1,109 +0,0 @@
-
-let all_types = [
- 'text',
- 'uri',
- 'binary',
- 'float', /* Number.type = 'float' */
- 'integer', /* Number.type = 'integer' */
- 'date-time', /* Date */
- 'date', /* Date.dateonly = true */
- 'duration',
- 'period',
- 'utc-offset',
- 'cal-address',
- 'recur', /* RRule */
- 'boolean', /* boolean */
-]
-
-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 = {
- '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['DAYLIGHT'] = valid_fields['STANDARD'];
-
-
-let valid_input_types = {
- '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']],
- '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'],
- 'URL': ['uri'],
- 'VERSION': ['text'],
-}
diff --git a/static/types.ts b/static/types.ts
new file mode 100644
index 00000000..64e2c709
--- /dev/null
+++ b/static/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/vcal.js b/static/vcal.js
deleted file mode 100644
index 93cfc028..00000000
--- a/static/vcal.js
+++ /dev/null
@@ -1,378 +0,0 @@
-/*
- Properties are icalendar properties.
-
- p['name'] to get and set value (also updates any connected slots)
-
- p['_value_name'] for raw value
- p['_slot_name'] for connected slots, Vector of pairs, where the
- car should be a reference to the slot, and the
- cdr a procedure which takes a slot and a value
- and binds the value to the slot.
- */
-class VComponent {
-
- constructor(el, wide_event=false) {
- el.properties = this;
- this.html_element = el;
-
- /*
- List of field listeners, which are all notified whenever
- the listened for field is updated.
- - keys are field names
- - values MUST be a pair of
- + a javascript object to update
- + a prodecude taking that object, and the new value
- */
- this._slots = {}
-
- /* VCalParameter objects */
- this._values = {}
-
- /*
- All properties on this object which are part of the vcomponent.
- Ideally simply looping through all javascript fields would be nice,
- but we only want to export some.
-
- Popuplated by iCalendars built in types per default, and extended
- */
- this.ical_properties = new Set();
-
- let popup = popup_from_event(el);
- // let children = el.getElementsByTagName("properties")[0].children;
-
- /* actual component (not popup) */
- /*
- for (let e of el.querySelectorAll(".bind")) {
- }
- */
-
- /* bind_recur */
-
- /* primary display tab */
-
- let p;
- let lst = [...popup.querySelectorAll(".bind"),
- ...el.querySelectorAll('.bind')];
- for (let e of lst) {
- // if (e.classList.contains('summary')) {
- // console.log(e, e.closest('[data-bindby]'));
- // }
- if ((p = e.closest('[data-bindby]'))) {
- // console.log(p);
- // console.log(p.dataset.bindby);
- eval(p.dataset.bindby)(el, e);
- } else {
- // if (e.classList.contains('summary')) {
- // /* TODO transfer data from backend to frontend in a better manner */
- // console.log (this.get_callback_list(e.dataset.property));
- // }
- let f = (s, v) => {
- console.log(s, v);
- s.innerText = v.format(s.dataset && s.dataset.fmt);
- };
- this.get_callback_list(e.dataset.property).push([e, f]);
- // if (e.classList.contains('summary')) {
- // console.log (this.get_callback_list(e.dataset.property));
- // }
- // console.log("registreing", e, e.dataset.property, this);
- }
- }
-
- /* checkbox for whole day */
-
- /* Popuplate default types, see types.js for property_names */
- for (let property of property_names) {
- this.ical_properties.add(property)
- // console.log("prop", property)
-
- this.create_property(property);
- }
-
- /* icalendar properties */
- for (let child of el.querySelector("vevent > properties").children) {
- /* child ≡ <dtstart><date-time>...</date-time></dtstart> */
-
- let field = child.tagName;
- // // let lst = get_property(el, field);
- // let lst = this.get(field);
-
- this.ical_properties.add(field)
-
- /* Bind vcomponent fields for this event */
- for (let s of el.querySelectorAll(`${field} > :not(parameters)`)) {
- /* s ≡ <date-time>...</date-time> */
-
- /* Binds value from XML-tree to javascript object
- [parsedate]
-
- TODO capture xcal type here, to enable us to output it to jcal later.
- */
- let parsedValue;
- let type = s.tagName.toLowerCase();
- switch (type) {
- case 'float':
- case 'integer':
- parsedValue = Number(s.textContent);
- break;
-
- case 'date-time':
- case 'date':
- parsedValue = parseDate(s.textContent);
- break;
-
- /* TODO */
- case 'duration':
- let start = s.getElementsByTagName('start');
- let end = s.getElementsByTagName('end, duration');
- if (end.tagName === 'period') {
- parsePeriod(end.textContent);
- }
- break;
- /* TODO */
- case 'period':
- parsedValue = parsePeriod(s.textContent);
- break;
- /* TODO */
- case 'utc-offset':
- break;
-
- case 'recur':
- parsedValue = recur_xml_to_rrule(s);
- break;
-
- case 'boolean':
- switch (s.textContent) {
- case 'true': parsedValue = true; break;
- case 'false': parsedValue = false; break;
- default: throw "Value error"
- }
- break;
-
-
- case 'binary':
- /* Binary is going to be BASE64 decoded, allowing us to ignore
- it and handle it as a string for the time being */
- case 'cal-address':
- case 'text':
- case 'uri':
- parsedValue = s.textContent;
- // parsedValue.type = type;
- break;
-
- default:
- parsedValue = s.textContent;
- }
-
-
- // this['_value_rrule'] = new VCalParameter(type, parsedValue);
- // console.log("set", field, type, parsedValue);
- this._values[field] = new VCalParameter(type, parsedValue);
- if (! this._slots[field]) {
- this._slots[field] = [];
- }
- }
- }
-
- /* set up graphical display changes */
- let container = el.closest(".event-container");
- if (container === null) {
- console.log("No enclosing event container for", el);
- return;
- }
- let start = parseDate(container.dataset.start);
- let end = parseDate(container.dataset.end);
-
- if (this.dtstart) {
- /* [parsedate] */
- // el.properties.dtstart = parseDate(el.properties.dtstart);
- this.get_callback_list('dtstart').push(
- [el.style, (s, v) => {
- console.log(v);
- s[wide_event?'left':'top'] = 100 * (to_local(v) - start)/(end - start) + "%";
- } ]);
- }
-
-
- if (this.dtend) {
- // el.properties.dtend = parseDate(el.properties.dtend);
- this.get_callback_list('dtend').push(
- // TODO right and bottom only works if used from the start. However,
- // events from the backend instead use top/left and width/height.
- // Normalize so all use the same, or find a way to convert between.
- [el.style,
- (s, v) => s[wide_event?'right':'bottom'] = 100 * (1 - (to_local(v)-start)/(end-start)) + "%"]);
- }
-
-
- /* ---------- Calendar ------------------------------ */
-
- if (! el.dataset.calendar) {
- el.dataset.calendar = "Unknown";
- }
-
- let calprop = this.get_callback_list('calendar');
- this.create_property('calendar');
- this._values['calendar'] =
- new VCalParameter('INVALID', el.dataset.calendar);
-
- const rplcs = (s, v) => {
- let [_, calclass] = s.classList.find(/^CAL_/);
- s.classList.replace(calclass, "CAL_" + v);
- }
-
- calprop.push([popup, rplcs]);
- calprop.push([el, rplcs]);
- calprop.push([el, (s, v) => s.dataset.calendar = v]);
-
-
-
- /* ---------- /Calendar ------------------------------ */
- }
-
-
- /*
- Returns the _value_ slot of given field in event, creating it if needed .
- el - the event to work on
- field - name of the field
- default_value - default value when creating
- bind_to_ical - should this property be added to the icalendar subtree?
- */
- get_callback_list(field) {
- // let el = this.html_element;
- if (! this._slots[field]) {
- this._slots[field] = [];
- }
-
- // console.log("get", field);
- return this._slots[field];
- }
-
- to_jcal() {
- let properties = [];
-
- /* ??? */
- // for (let prop of event.properties.ical_properties) {
- for (let prop of this.ical_properties) {
- // console.log(prop);
- let v = this[prop];
- if (v !== undefined) {
- let sub = v.to_jcal();
- sub.unshift(prop);
- properties.push(sub);
- }
- }
-
- return ['vevent', properties, [/* alarms go here */]]
- }
-
- create_property(property_name) {
- Object.defineProperty(
- this, property_name,
- {
- /* TODO there is an assymetry here with .value needing to be called for
- get:ed stuff, but set MUST be an unwrapped item.
- Fix this.
- */
- get: function() {
- return this._values[property_name];
- },
- set: function (value) {
- console.log("set", property_name, value);
- /* Semi dirty hack to add properties which are missing.
- Since we initialize without a type just guess depending
- on the field name */
- if (! this._values[property_name]) {
- let type_arr
- = valid_input_types[property_name.toUpperCase()]
- || ['unknown'];
- let type = type_arr[0];
- /* Types which can take arrays are interesting */
- if (type instanceof Array) {
- type = type[0];
- }
- this._values[property_name]
- = new VCalParameter(type, value)
- } else {
- this._values[property_name].value = value;
- }
- console.log(this._slots[property_name].length,
- this._slots[property_name]);
- /* TODO validate type */
- /* See valid_input_types and all_types */
- for (let [slot,updater] of this._slots[property_name]) {
- // console.log(updater, slot);
- updater(slot, value);
- }
- },
- });
- }
-
-}
-
-
-
-/* "Body" of a vcomponent field.
- For example, given the JCal
- ["dtstamp", {}, "date-time", "2006-02-06T00:11:21Z"],
- this class would have
- VCalParameter {
- type = "date-time",
- properties = {},
- _value = new Date(2006,1,6,0,11,21)
- }
- And returns [{}, "date-time", "2006-02-06T00:11:21Z"]
- when serialized
- */
-class VCalParameter {
- constructor (type, value, properties={}) {
- this.type = type;
- this._value = value;
- this.properties = properties;
- }
-
- get value() {
- return this._value;
- }
-
- set value(v) {
- this._value = v;
- }
-
- to_jcal() {
- let value;
- let v = this._value;
- switch (this.type) {
- case 'binary':
- /* TOOD */
- 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 */
- break;
- case 'period':
- /* TODO */
- break;
- case 'utc-offset':
- /* TODO */
- break;
- case 'recur':
- value = v.asJcal();
- break;
-
- case 'float':
- case 'integer':
- case 'text':
- case 'uri':
- case 'cal-address':
- case 'boolean':
- value = v;
- }
- return [this.properties, this.type, value];
- }
-}
diff --git a/static/vevent.ts b/static/vevent.ts
new file mode 100644
index 00000000..cee26727
--- /dev/null
+++ b/static/vevent.ts
@@ -0,0 +1,548 @@
+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':
+ /* TOOD */
+ 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]
+ }
+}
+
+/* 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[] = []
+
+ 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 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':
+ case 'interval':
+ rr.count = 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) {
+ 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 == 'parameters') {
+ parameters = /* TODO handle parameters */ {};
+ continue value_loop;
+ } else switch (tag.tagName) {
+ /* 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, 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)
+}