'use strict'; let parser = new DOMParser(); /* ----- 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. */ 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) /* start and end time for calendar page */ let start_time = new Date(); let end_time = new Date(); function bind_popup_control (nav) { 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 = ""; }); } /* * 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; } } } 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 () { let event = document.getElementById("event-template").firstChild.cloneNode(true); let popup = document.getElementById("popup-template").firstChild.cloneNode(true); 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'. */ bind_properties(event, wide_element); /* requires that dtstart and dtend properties are initialized */ place_in_edit_mode(event); /* ---------------------------------------- */ /* 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) 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 = ""; } let localevent = that.event; that.event = null; callback (localevent); } } } async function remove_event (element) { let uid = element.querySelector("icalendar uid text").innerHTML; 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(); } } var bar_object = false var current_cell = false function update_current_time_bar () { var now = new Date() if (! (start_time <= now.getTime() && now.getTime() < end_time)) return; var event_area = document.getElementById(now.format("~Y-~m-~d")) if (event_area) { if (bar_object) { bar_object.parentNode.removeChild(bar_object) } else { bar_object = makeElement ('div', { id: 'bar', className: 'eventlike current-time', }); } bar_object.style.top = date_to_percent(now) + "%"; event_area.append(bar_object) } /* */ if (current_cell) { current_cell.style.border = ""; } current_cell = document.querySelector( ".small-calendar time[datetime='" + now.format("~Y-~m-~d") + "']"); current_cell.style.border = "1px solid black"; /* Update [today] button */ document.getElementById("today-button").href = (new Date).format("~Y-~m-~d") + ".html"; } function setVar(str, val) { document.documentElement.style.setProperty("--" + str, val); } function close_all_popups () { for (let popup of document.querySelectorAll(".popup-container.visible")) { close_popup(popup); } } async function create_event (event) { let xml = event.getElementsByTagName("icalendar")[0].outerHTML let calendar = event.properties.calendar; console.log(calendar, xml); let data = new URLSearchParams(); data.append("cal", calendar); data.append("data", xml); 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(); /* servere is assumed to return an XML document on the form **xcal property** ... parse that, and update our own vevent with the data. */ let properties = parser .parseFromString(body, 'text/xml') .children[0]; let child; while ((child = 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); } function place_in_edit_mode (event) { let popup = document.getElementById("popup" + event.id) function replace_with_time_input(fieldname, event) { let field = popup.getElementsByClassName(fieldname)[0]; let dt = new Date(field.dateTime); let dateinput = makeElement ('input', { type: 'date', required: true, value: dt.format("~Y-~m-~d"), onchange: function (e) { /* Only update datetime when the input is filled out */ if (! this.value) return; let [year, month, day] = this.value.split("-").map(Number); /* retain the hour and second information */ let d = copyDate(event.properties[fieldname]); d.setYear(year); d.setMonth(month - 1); d.setDate(day); event.properties[fieldname] = d; } }); let timeinput = makeElement ('input', { type: "time", required: true, value: dt.format("~H:~M"), onchange: function (e) { /* Only update datetime when the input is filled out */ if (! this.value) return; let [hour, minute] = this.value.split(":").map(Number); /* retain the year, month, and day information */ let d = copyDate(event.properties[fieldname]); d.setHours(hour); d.setMinutes(minute); event.properties[fieldname] = d; } }); let slot = event.properties["_slot_" + fieldname] let idx = slot.findIndex(e => e[0] === field); slot.splice(idx, 1, [timeinput, (s, v) => s.value = v.format("~H:~M")]) slot.splice(idx, 0, [dateinput, (s, v) => s.value = v.format("~Y-~m-~d")]) field.innerHTML = ''; field.appendChild(dateinput); field.appendChild(timeinput); // field.replaceWith(timeinput); } /* TODO ensure dtstart < dtend */ replace_with_time_input("dtstart", event); replace_with_time_input("dtend", event); /* ---------------------------------------- */ let summary = popup.getElementsByClassName("summary")[0]; let input = makeElement('input', { name: "summary", value: summary.innerText, placeholder: "Sammanfattning", required: true, }); input.oninput = function () { event.properties["summary"] = this.value; } let slot = event.properties["_slot_summary"] let idx = slot.findIndex(e => e[0] === summary); slot.splice(idx, 1, [input, (s, v) => s.value = v]) summary.replaceWith(input); /* ---------------------------------------- */ /* TODO add elements if the arent't already there * Almost all should be direct children of '.event-body' (or * '.eventtext'?). * Biggest problem is generated fields relative order. */ let descs = popup.getElementsByClassName("description"); if (descs.length === 1) { let description = descs[0]; let textarea = makeElement('textarea', { name: "description", placeholder: "Description (optional)", innerHTML: description.innerText, required: false, }); textarea.oninput = function () { event.properties["description"] = this.value; } let slot = event.properties["_slot_description"] let idx = slot.findIndex(e => e[0] === description); slot.splice(idx, 1, [input, (s, v) => s.innerHTML = v]) description.replaceWith(textarea); } /* ---------------------------------------- */ let evtext = popup.getElementsByClassName('eventtext')[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