aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHugo Hörnquist <hugo@lysator.liu.se>2021-12-20 22:09:57 +0100
committerHugo Hörnquist <hugo@lysator.liu.se>2021-12-20 22:09:57 +0100
commitd75ebbab2a414fe1a9a09d703a3bc7be782f1f1e (patch)
tree0de4f1c17afd6fbefbafc3a0a8a91bc85cb30355
parentDocument testrunner syntax. (diff)
parentDocumentation updates for util. (diff)
downloadcalp-d75ebbab2a414fe1a9a09d703a3bc7be782f1f1e.tar.gz
calp-d75ebbab2a414fe1a9a09d703a3bc7be782f1f1e.tar.xz
Merge Javascript rewrite.
-rw-r--r--doc/ref/calp.texi5
-rw-r--r--doc/ref/guile.texi2
-rw-r--r--doc/ref/javascript.texi55
-rw-r--r--doc/ref/javascript/arbitary_kv.texi3
-rw-r--r--doc/ref/javascript/binders.texi57
-rw-r--r--doc/ref/javascript/clock.texi10
-rw-r--r--doc/ref/javascript/components/changelog.texi10
-rw-r--r--doc/ref/javascript/components/date_time_input.texi34
-rw-r--r--doc/ref/javascript/components/edit_rrule.texi10
-rw-r--r--doc/ref/javascript/components/input_list.texi16
-rw-r--r--doc/ref/javascript/components/popup_element.texi40
-rw-r--r--doc/ref/javascript/components/tab_group_element.texi46
-rw-r--r--doc/ref/javascript/components/vevent.texi23
-rw-r--r--doc/ref/javascript/components/vevent_block.texi10
-rw-r--r--doc/ref/javascript/components/vevent_description.texi10
-rw-r--r--doc/ref/javascript/components/vevent_dl.texi11
-rw-r--r--doc/ref/javascript/components/vevent_edit.texi9
-rw-r--r--doc/ref/javascript/date_time.texi41
-rw-r--r--doc/ref/javascript/draggable.texi24
-rw-r--r--doc/ref/javascript/eventCreator.texi15
-rw-r--r--doc/ref/javascript/globals.texi41
-rw-r--r--doc/ref/javascript/input_list.texi51
-rw-r--r--doc/ref/javascript/jcal.texi5
-rw-r--r--doc/ref/javascript/lib.texi9
-rw-r--r--doc/ref/javascript/popup.texi5
-rw-r--r--doc/ref/javascript/rrule.texi4
-rw-r--r--doc/ref/javascript/script.texi60
-rw-r--r--doc/ref/javascript/server_connect.texi20
-rw-r--r--doc/ref/javascript/types.texi63
-rw-r--r--doc/ref/javascript/vevent.texi108
-rw-r--r--module/calp/html/components.scm2
-rw-r--r--module/calp/html/util.scm31
-rw-r--r--module/calp/html/vcomponent.scm974
-rw-r--r--module/calp/html/view/calendar.scm214
-rw-r--r--module/calp/html/view/calendar/month.scm27
-rw-r--r--module/calp/html/view/calendar/week.scm61
-rw-r--r--module/calp/server/routes.scm14
-rw-r--r--module/calp/util.scm40
-rw-r--r--module/datetime.scm18
-rw-r--r--module/vcomponent.scm6
-rw-r--r--module/vcomponent/base.scm11
-rw-r--r--module/vcomponent/recurrence/generate.scm2
-rw-r--r--module/vcomponent/vdir/parse.scm1
-rw-r--r--module/vcomponent/vdir/save-delete.scm2
-rw-r--r--module/vcomponent/xcal/output.scm5
-rw-r--r--module/vcomponent/xcal/parse.scm139
-rw-r--r--module/vulgar/termios.scm5
-rwxr-xr-xstart28
-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
-rw-r--r--tests/annoying-events.scm59
-rw-r--r--tests/recurrence-simple.scm27
90 files changed, 4864 insertions, 2966 deletions
diff --git a/doc/ref/calp.texi b/doc/ref/calp.texi
index e5c4baab..474ad3e2 100644
--- a/doc/ref/calp.texi
+++ b/doc/ref/calp.texi
@@ -65,7 +65,12 @@ text @footnote{Improvements welcome}
@unnumbered Index
@printindex cp
@printindex fn
+@printindex ky
+@printindex pg
@printindex tp
@printindex vr
+@unnumbered Web Components
+@printindex wc
+
@bye
diff --git a/doc/ref/guile.texi b/doc/ref/guile.texi
index 8468021e..b21850bd 100644
--- a/doc/ref/guile.texi
+++ b/doc/ref/guile.texi
@@ -1,6 +1,8 @@
@node Guile
@chapter Guile
+@include guile/util.texi
+
@c TODO
This chapter will probably in the future be replaced by a proper
system overview in the future.
diff --git a/doc/ref/javascript.texi b/doc/ref/javascript.texi
index 6fbd7cdc..7510e4f5 100644
--- a/doc/ref/javascript.texi
+++ b/doc/ref/javascript.texi
@@ -1,35 +1,36 @@
@node Javascript
@chapter Javascript
-@node Concepts
-@section Concepts
+@c web components
+@defindex wc
-@subsection ``Componenents''
+@c done
+@node General Stuff
+@section General stuff
+The frontend code has its entry-point in @code{script.ts}
-@deftp {} date_time
-@cindex date-time
-
-@ref{date_time}
-@end deftp
-
-@deftp {} draggable
-@end deftp
-
-@deftp {} input_list
-@end deftp
-
-@node Reference
-@section Reference
-@include javascript/arbitary_kv.texi
-@include javascript/binders.texi
+All elements are initialized in elements.ts
@include javascript/clock.texi
-@include javascript/date_time.texi
-@include javascript/draggable.texi
-@include javascript/input_list.texi
-@include javascript/jcal.texi
@include javascript/lib.texi
-@include javascript/popup.texi
-@include javascript/rrule.texi
-@include javascript/script.texi
-@include javascript/server_connect.texi
+@include javascript/eventCreator.texi
@include javascript/types.texi
+@include javascript/vevent.texi
+@include javascript/globals.texi
+@include javascript/server_connect.texi
+
+@node General Components
+@section General Components
+@include javascript/components/date_time_input.texi
+@include javascript/components/input_list.texi
+
+@node VEvent Components
+@section VEvent Components
+@include javascript/components/vevent.texi
+@include javascript/components/changelog.texi
+@include javascript/components/edit_rrule.texi
+@include javascript/components/popup_element.texi
+@include javascript/components/tab_group_element.texi
+@include javascript/components/vevent_block.texi
+@include javascript/components/vevent_description.texi
+@include javascript/components/vevent_dl.texi
+@include javascript/components/vevent_edit.texi
diff --git a/doc/ref/javascript/arbitary_kv.texi b/doc/ref/javascript/arbitary_kv.texi
deleted file mode 100644
index b28c8b92..00000000
--- a/doc/ref/javascript/arbitary_kv.texi
+++ /dev/null
@@ -1,3 +0,0 @@
-@node arbitary_kv
-@subsection arbitary_kv.js
-
diff --git a/doc/ref/javascript/binders.texi b/doc/ref/javascript/binders.texi
deleted file mode 100644
index 0e38411b..00000000
--- a/doc/ref/javascript/binders.texi
+++ /dev/null
@@ -1,57 +0,0 @@
-
-@node binders
-@cindex binder
-@cindex binders
-@subsection binders.js
-
-The bind system allows HTML-elements to specify that they want to be
-updated whenever its corresponding (vcalendar) object changes.
-The bind system is currently set up in
-@code{bind_properties} (@pxref{bind_properties})
-(which at the time of writing is (badly) located in @ref{script}).
-
-All (HTML) components with the class @code{bind} are bound. By default
-the (HTML) attribute @code{data-property} is checked for a property
-name, and @code{object.innerHTML} is set whenever that property field
-changes.
-Alternatively an (HTML) component may specify a specific binder
-through the HTML attribute @code{data-bindby}, which should be the
-name of a JavaScript function taking two arguments, an @TODO{event
-component}
-@footnote{Root ``root'' HTML component of a given calendar event
-(something which @code{get_property} can be called on},
-and the component in question.
-
-@c Also sets up event listeners, which most doesn't do.
-
-Binder functions are generally placed in @file{binders.js}, and
-shouldn't be called manually.
-
-@defun bind_recur el e
-Handles recurrence rules.
-Uses a sub-binder system on components with class containing
-``bind-rr''.
-@end defun
-
-@defun bind_edit el e
-Cases for @code{input} and @code{textarea} elements @TODO{(should also
-handle @code{select}s?)}
-@end defun
-
-@defun bind_view el e
-The same as the default binder????
-@end defun
-
-@defun bind_wholeday el e
-Binder for the wholeday toggle button.
-While CSS would suffice, this sets the disabled flags on the time
-inputs, giving a better user experience.
-@end defun
-
-@defun bind_date_time el e
-@anchor{bind_date_time}
-For @code{date_time} dummy component. Propagates gets and sets to
-underlying input fields.
-
-Note: @emph{Must} be called @emph{after} @code{init_date_time}.
-@end defun
diff --git a/doc/ref/javascript/clock.texi b/doc/ref/javascript/clock.texi
index 5c2bd954..10ab7d4e 100644
--- a/doc/ref/javascript/clock.texi
+++ b/doc/ref/javascript/clock.texi
@@ -1,21 +1,15 @@
@node clock
@subsection clock.js
-@deftp {(abstract) class} Clock
+@deftp {abstract class} Clock
Interface for ``things'' which wants to get updated on a human timescale.
@defmethod Clock update now
+@c abstract method
Called every now and then, with @var{now} being the current time.
@end defmethod
-
-All instances are expected to implement @code{update}, but are free to
-implement any other methods they see fit.
@end deftp
-Below, only the methods (including @code{constructor} and
-@code{update} which do something of note (excluding the expected))
-are noted.
-
@deftp {class} Timebar @extends{Clock}
The (blue) vertical line which show the current time in the current day.
diff --git a/doc/ref/javascript/components/changelog.texi b/doc/ref/javascript/components/changelog.texi
new file mode 100644
index 00000000..d14fb84e
--- /dev/null
+++ b/doc/ref/javascript/components/changelog.texi
@@ -0,0 +1,10 @@
+@subsection Changelog
+
+@deftp {Web Component for VEvent} VEventChangelog
+@wcindex <vevent-changelog>
+@wcindex vevent-changelog
+@anchor{VEventChangelog}
+@code{<vevent-changelog>}
+
+Display of a VEvents changelog. @ref{ChangeLogEntry}
+@end deftp
diff --git a/doc/ref/javascript/components/date_time_input.texi b/doc/ref/javascript/components/date_time_input.texi
new file mode 100644
index 00000000..f26627d2
--- /dev/null
+++ b/doc/ref/javascript/components/date_time_input.texi
@@ -0,0 +1,34 @@
+@subsection date-time-input
+
+@deftp {Web Component} DateTimeInput
+@wcindex <date-time-input>
+@wcindex date-time-input
+@code {<date-time-input>}
+
+An element for input for date-times. Similar to
+@example
+<input type="date"/>
+<input type="time"/>
+@end example
+But as a single unit.
+
+@deftypeivar DateTimeInput boolean dateonly
+Setting this to true disabled the time part of the input, and makes
+any output only have date components (alternativly, the time component
+set to zero).
+@end deftypeivar
+
+@defcv {Attribute} DateTimeInput dateonly
+Same data as the field dateonly, but as an attribute. Present means
+true, absent means false.
+@end defcv
+
+@deftypeivar DateTimeInput Date value
+Returns current value as a Date object.
+@end deftypeivar
+
+@deftypeivar DateTimeInput string stringValue
+Returns current value as an ISO-8601 formatted string.
+@end deftypeivar
+
+@end deftp
diff --git a/doc/ref/javascript/components/edit_rrule.texi b/doc/ref/javascript/components/edit_rrule.texi
new file mode 100644
index 00000000..21437863
--- /dev/null
+++ b/doc/ref/javascript/components/edit_rrule.texi
@@ -0,0 +1,10 @@
+@subsection Edit RRule
+
+@deftp {Web Component for VEvent} EditRRule
+@wcindex <vevent-edit-rrule>
+@wcindex vevent-edit-rrule
+@code{<vevent-edit-rrule>}
+
+An edit form for a recurrence rule. Searches its template for elements
+with @code{[name="<<field name>>"]}, and binds to those.
+@end deftp
diff --git a/doc/ref/javascript/components/input_list.texi b/doc/ref/javascript/components/input_list.texi
new file mode 100644
index 00000000..bdc00ecb
--- /dev/null
+++ b/doc/ref/javascript/components/input_list.texi
@@ -0,0 +1,16 @@
+@subsection input_list.js
+
+@deftp {Web Component} InputList
+@wcindex <input-list>
+@wcindex input-list
+@code{<input-list>}
+
+A list of identical input fields, which forms a group. For example
+useful to handle keywords.
+
+@deftypeivar DateTimeInput {any[]} value
+The value from each element, except the last which should always be empty.
+Has an unspecified type, since children:s value field might give non-strings.
+@end deftypeivar
+
+@end deftp
diff --git a/doc/ref/javascript/components/popup_element.texi b/doc/ref/javascript/components/popup_element.texi
new file mode 100644
index 00000000..2b76b347
--- /dev/null
+++ b/doc/ref/javascript/components/popup_element.texi
@@ -0,0 +1,40 @@
+@subsection Popup
+
+@deftp {Web Component for VEvent} PopupElement
+@wcindex <popup-element>
+@wcindex popup-element
+@code{<popup-element>}
+
+A (small) floating window containing information, which can be dragged
+arround. Consists of a navigation bar with a few buttons for
+controlling the window, which also works as a drag handle, along with
+an area for contents, which can be resized by the user.
+
+Currently tightly coupled to VEvent's, since their color
+profile is derived from their owning events calendar, and they have
+action buttons for the event in their navigation bar.
+
+@deftypecv {Static Member} PopupElement {PopupElement?} activePopup
+The popup which was most recently interacted with by the user. Used to
+move it on top of all others, as well as sending relevant key events there.
+@end deftypecv
+
+@defcv {Attribute} PopupElement visible
+Present is the popup is currently visible, absent otherwise.
+@end defcv
+
+@deftypeivar PopupElement boolean visible
+See the attribute of the same name.
+@end deftypeivar
+
+@defmethod PopupElement maximize
+Resize the popup window to fill the current viewport (mostly). Is
+probably bonud to the maximize button in the navigation bar.
+@end defmethod
+@end deftp
+
+@deftypefun PopupElement setup_popup_element VEvent
+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.
+@end deftypefun
diff --git a/doc/ref/javascript/components/tab_group_element.texi b/doc/ref/javascript/components/tab_group_element.texi
new file mode 100644
index 00000000..7d9ca412
--- /dev/null
+++ b/doc/ref/javascript/components/tab_group_element.texi
@@ -0,0 +1,46 @@
+@subsection Tab Group Element
+
+@deftp {Web Component for VEvent} TabGroupElement
+@wcindex <tab-group>
+@wcindex tab-group
+@code{<tab-group>}
+
+A group of tabs, where only one can be visible at a time.
+
+@c TODO which form does the HTML document have? For CSS purposes
+
+Each tab consists of two parts, a label which is used for selecting
+it, and a tab-element, which contains the actual content. These two
+should refer to each other as follows:
+
+@verbatim
++---------------+ +----------------+
+| TabLabel | | Tab |
++---------------+ +----------------+
+| id |<----| aria-labeledby |
+| aria-controls |---->| id |
++---------------+ +----------------+
+@end verbatim
+
+Further information about tabs in HTML can be found here:
+@url{https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/Tab_Role}
+
+@defvr {CSS Variable} {--tabcount}
+Each tab element has the style property @code{--tabcount} set to how
+many tabs it has. This is mostly useful to make sure the tab context
+is large enough to fit all tab labels without overflowing.
+@end defvr
+
+@deftypemethod TabGroupElement void addTab {HTMLElement} {label: string?} {title: string?}
+Adds a new tab to the group. The first parameter will make up the body
+of the tab. The label is whath should be shown in the tab selector,
+but defaults to the first letter of the text content of the body node.
+Title is the hoover text of the label.
+@end deftypemethod
+
+@deftypemethod TabGroupElement void removeTab {HTMLElement}
+HTMLElement must be one of the tab bodies in this group. This method
+removes it, along with its TabLabel.
+@end deftypemethod
+
+@end deftp
diff --git a/doc/ref/javascript/components/vevent.texi b/doc/ref/javascript/components/vevent.texi
new file mode 100644
index 00000000..be53a46e
--- /dev/null
+++ b/doc/ref/javascript/components/vevent.texi
@@ -0,0 +1,23 @@
+@subsection vevent
+
+@deftp {Abstract Web Component} ComponentVEvent {uid: string?}
+
+@c TODO what is done in the default constructor,
+@c and the default connectedCallback
+
+This registeres itself, but doesn't redraw
+We do however redraw in connectedCallback
+
+@deftypeivar ComponentVEvent uid uid
+@end deftypeivar
+
+@deftypeivar ComponentVEvent {HTMLTemplateElement?} template
+@end deftypeivar
+
+@deftypemethod ComponentVEvent void redraw (data: VEvent)
+While abstract for this, @emph{must} be overridden for everyone else
+@end deftypemethod
+@end deftp
+
+Note that many of these assume that their initial children are
+configured specifically, that is however not completely documented.
diff --git a/doc/ref/javascript/components/vevent_block.texi b/doc/ref/javascript/components/vevent_block.texi
new file mode 100644
index 00000000..1a0ef160
--- /dev/null
+++ b/doc/ref/javascript/components/vevent_block.texi
@@ -0,0 +1,10 @@
+@subsection VEvent Block
+
+@deftp {Web Component for VEvent} ComponentBlock
+@wcindex <vevent-block>
+@wcindex vevent-block
+@code{<vevent-block>}
+A block in our graphical view.
+
+Unique in that it works quite differently between the week and month view.
+@end deftp
diff --git a/doc/ref/javascript/components/vevent_description.texi b/doc/ref/javascript/components/vevent_description.texi
new file mode 100644
index 00000000..492c8dff
--- /dev/null
+++ b/doc/ref/javascript/components/vevent_description.texi
@@ -0,0 +1,10 @@
+@subsection VEvent Description
+
+@deftp {Web Component for VEvent} ComponentDescription
+@wcindex <vevent-description>
+@wcindex vevent-description
+@code{<vevent-description>}
+
+A text representation of a VEvent. Used as the summary tab of our
+popup windows, and in the sidebar.
+@end deftp
diff --git a/doc/ref/javascript/components/vevent_dl.texi b/doc/ref/javascript/components/vevent_dl.texi
new file mode 100644
index 00000000..26bc8fd4
--- /dev/null
+++ b/doc/ref/javascript/components/vevent_dl.texi
@@ -0,0 +1,11 @@
+@subsection VEvent Description List
+
+@deftp {Web Component for VEvent} VEventDL
+@wcindex <vevent-dl>
+@wcindex vevent-dl
+@code{<vevent-dl>}
+A description list of a vevent, used for debugging.
+
+No guarantees are given about the contents of the data fields, more
+than that they are related to the value in question.
+@end deftp
diff --git a/doc/ref/javascript/components/vevent_edit.texi b/doc/ref/javascript/components/vevent_edit.texi
new file mode 100644
index 00000000..67e9f6b3
--- /dev/null
+++ b/doc/ref/javascript/components/vevent_edit.texi
@@ -0,0 +1,9 @@
+@subsection VEvent Edit
+
+@deftp {Web Component for VEvent} ComponentEdit
+@wcindex <vevent-edit>
+@wcindex vevent-edit
+@code{<vevent-edit>}
+Edit form for a vevent, designed for useful human interaction (and
+thereby not being all-encompassing).
+@end deftp
diff --git a/doc/ref/javascript/date_time.texi b/doc/ref/javascript/date_time.texi
deleted file mode 100644
index b2c5db92..00000000
--- a/doc/ref/javascript/date_time.texi
+++ /dev/null
@@ -1,41 +0,0 @@
-@node date_time
-@subsection date_time.js
-
-@defun init_date_time
-@c possibly have special index for these
-@cindex dummy component
-Procedure which initializes the dummy component for date-time input.
-When called, finds all elements with class ``date-time'', and makes
-them date-time inputs.
-
-@c <input type='date-time'/>
-
-The expected HTML form is
-@example
-<div class="date-time" name="@var{name}">
- <input type="date"/>
- <input type="time"/>
-</div>
-@end example
-
-Each date-time gets the following fields:
-
-@defivar date_time value
-The current date-time value as a string,
-on the form @code{YYYY-mm-ddTHH:MM[:SS]}
-(@code{SS} if the underlying time input has it).
-
-A new date-time can also be set to the field, the same format as above
-is expected.
-@end defivar
-
-@defivar date_time name
-The ``name'' field of the date-time input. Since @code{name} note that
-this is an addition, since name is actually invalid on non-input
-components. We nevertheless use it here since we are emulating an
-input element.
-@end defivar
-
-See also @pxref{bind_date_time}
-
-@end defun
diff --git a/doc/ref/javascript/draggable.texi b/doc/ref/javascript/draggable.texi
deleted file mode 100644
index d1851ec4..00000000
--- a/doc/ref/javascript/draggable.texi
+++ /dev/null
@@ -1,24 +0,0 @@
-@node dragable
-@subsection dragable.js
-
-@c TODO This text is just yanked from the old org file, along with the
-@c source codes header. It should probably be rewritten.
-
-Manually apply =bind_popup_control= to the statusbar of a floating
-"window". Nothing is required from the component, but the "window"
-must have 'popup-container' ∈ =class=
-
-@defun bind_popup_control nav
-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
-@code{popup-container}.
-
-@example
-<div class='popup-container'>
- ...
- <nav />
- ...
-</div>
-@end example
-@end defun
diff --git a/doc/ref/javascript/eventCreator.texi b/doc/ref/javascript/eventCreator.texi
new file mode 100644
index 00000000..164d1335
--- /dev/null
+++ b/doc/ref/javascript/eventCreator.texi
@@ -0,0 +1,15 @@
+@deftp {class} EventCreator
+
+@defmethod EventCreator create_empty_event
+@end defmethod
+
+@defmethod EventCreator create_event_down intended_target
+@end defmethod
+
+@defmethod EventCreator create_event_move pos_in [round=1] [wide_element=false]
+@end defmethod
+
+@defmethod EventCreator create_event_finisher callback
+@end defmethod
+
+@end deftp
diff --git a/doc/ref/javascript/globals.texi b/doc/ref/javascript/globals.texi
new file mode 100644
index 00000000..5ef7a43b
--- /dev/null
+++ b/doc/ref/javascript/globals.texi
@@ -0,0 +1,41 @@
+@node globals
+@subsection globals.ts
+
+Different variables and values which for different reasons needs to be
+global. Window Value's are those that are bound to the @code{window}
+context in JavaScript, so is really always available, no opt out.
+
+@deftypevar {Map<uid, VEvent>} vcal_objects
+All VEvent objects on current page, indexed by their unique identifiers.
+
+A global object store.
+@end deftypevar
+
+@deftypevar {Map<uid, string>} event_calendar_mapping
+Mapping from VEvent unique identifier, to name of its calendar. Should
+probably not be global, so refrain from using it.
+@end deftypevar
+
+@deftypevr {Window Value} {Map<uid, VEvent>} vcal_objects
+The exact same object store as the regular variable of the same
+name. Mostly here for human debugability.
+@end deftypevr
+
+@deftypevr {Window Value} {@code{'month'} | @code{'string'}} VIEW
+How the calendar is currently formatted. Should be set by the backend
+through a simple @code{script}-tag.
+@end deftypevr
+
+@deftypevr {Window Value} {boolean} EDIT_MODE
+However editing of events is enabled or not.
+Should be set by the backend through a simple @code{script}-tag.
+@end deftypevr
+
+@deftypevr {Window Value} {string} default_calendar
+Name of the calendar to assume when creating new events.
+Should be set by the backend through a simple @code{script}-tag.
+@end deftypevr
+
+@c TODO addNewEvent
+@c @deftypevr {Window Value} {string} default_calendar
+@c @end deftypevr
diff --git a/doc/ref/javascript/input_list.texi b/doc/ref/javascript/input_list.texi
deleted file mode 100644
index 65db81a4..00000000
--- a/doc/ref/javascript/input_list.texi
+++ /dev/null
@@ -1,51 +0,0 @@
-@node input_list
-@subsection input_list.js
-@cindex dummy component
-
-All elements with the class @code{input-list} are treated as a
-collection of input fields. Uses including setting tags on calendar
-entries.
-
-All direct children of the ``input-list'' @emph{must} have the class
-@code{unit}, and one direct child @code{unit} have the class @code{final}.
-
-@c All elements having 'input-list' ∈ =class=
-
-@c Direct children must all have 'unit' ∈ =class=
-@c One direct child must have 'final' ∈ =class=
-
-@defmethod input_list get_value
-
-@example
-querySelectorAll('input')
- .map(x => x.value)
- .join(@var{joinby})
-@end example
-@end defmethod
-
-@defivar input_list [data-]joinby
- Alternative character to join by
-@end defivar
-
-@defivar input_list [data-]bindby
- replacement for get_value
-@end defivar
-
-binds =get_value= on instances, by default returning the value
-of all =<input/>= tags joined by =,=. This can be overwritten with
-
-TODO: instead, override value?
-
-=addEventList('input',= is overwritten, registering the listener for all input
-elements.
-
-
- ∀ children('.input-list') => 'unit' ∈ classList(child)
-
- <div class="input-list">
- <div class="unit"><input/></div>
- <div class="unit final"><input/></div>
- </div>
-
-@defun init_input_list
-@end defun
diff --git a/doc/ref/javascript/jcal.texi b/doc/ref/javascript/jcal.texi
index 4be8d33b..997b4d59 100644
--- a/doc/ref/javascript/jcal.texi
+++ b/doc/ref/javascript/jcal.texi
@@ -1,4 +1,7 @@
-
@node jcal
@subsection jcal.js
+@deftypefun Document jcal_to_xcal {JCal ...}
+A document with the xcal namespace, and @code{icalendar} as its root
+element. Each child is a valid xcal representation of our JCal object.
+@end deftypefun
diff --git a/doc/ref/javascript/lib.texi b/doc/ref/javascript/lib.texi
index 8adb8621..e5b13383 100644
--- a/doc/ref/javascript/lib.texi
+++ b/doc/ref/javascript/lib.texi
@@ -4,11 +4,6 @@
General procedures which in theory could be used anywhere.
-@defvar xcal
-The xml namespace name for xcalendar, which is
-``urn:ietf:params:xml:ns:icalendar-2.0''.
-@end defvar
-
@node Default prototype extensions
@subsubsection Default prototype extensions
@@ -76,10 +71,6 @@ Generates a new string which is (hopefully) globally unique.
Compare with @code{gensym} from Lisp.
@end defun
-@defun setVar str val
-Set the CSS var @var{str} to @var{val} on the root element.
-@end defun
-
@defun asList thing
Ensures that @var{thing} is a list. Returning it outright if it
already is one, otherwise wrapping it in a list.
diff --git a/doc/ref/javascript/popup.texi b/doc/ref/javascript/popup.texi
deleted file mode 100644
index 2dd8f48f..00000000
--- a/doc/ref/javascript/popup.texi
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-@node popup
-@subsection popup.js
-
diff --git a/doc/ref/javascript/rrule.texi b/doc/ref/javascript/rrule.texi
deleted file mode 100644
index 5d7a7576..00000000
--- a/doc/ref/javascript/rrule.texi
+++ /dev/null
@@ -1,4 +0,0 @@
-
-@node rrule
-@subsection rrule.js
-
diff --git a/doc/ref/javascript/script.texi b/doc/ref/javascript/script.texi
deleted file mode 100644
index a60343e4..00000000
--- a/doc/ref/javascript/script.texi
+++ /dev/null
@@ -1,60 +0,0 @@
-
-@node script
-@subsection script.js
-
-@dfn{Main} for my javascript, and also currently dumping ground for stuff.
-
-@deftp {class} EventCreator
-
-@defmethod EventCreator create_empty_event
-@end defmethod
-
-@defmethod EventCreator create_event_down intended_target
-@end defmethod
-
-@defmethod EventCreator create_event_move pos_in [round=1] [wide_element=false]
-@end defmethod
-
-@defmethod EventCreator create_event_finisher callback
-@end defmethod
-
-@end deftp
-
-@defun place_in_edit_mode event
-@end defun
-
-@c window.onload is here in source file
-
-@defun get_property event field default_value
-Returns the @emph{value} slot of given field in @var{event}, creating it if needed.
-
-@itemize
-@item
-@var{el}: the event to work on
-
-@item
-@var{field}: name of the field
-
-@item
-@var{default_value}: default value when creating
-
-@item
-@var{bind_to_ical} should this property be added to the icalendar subtree?
-@end itemize
-@end defun
-
-@defun bind_properties el [wide_event=false]
-@anchor{bind_properties}
-@ref{binders}
- 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.
-@end defun
-
-
diff --git a/doc/ref/javascript/server_connect.texi b/doc/ref/javascript/server_connect.texi
index 2f50f02d..c67f47ff 100644
--- a/doc/ref/javascript/server_connect.texi
+++ b/doc/ref/javascript/server_connect.texi
@@ -1,2 +1,22 @@
@node server_connect
@subsection server_connect.js
+
+Procedures for interfacing with the backend server.
+
+@deftypefn {Async Function} void create_event {event: VEvent}
+Packs up the given event and sends it to the server to either be
+created, or simply be updated in the persistant database.
+
+Also does some minor updates registered components, to show that the
+event is actually created.
+@end deftypefn
+
+@deftypefn {Async Function} void remove_event {uid: uid}
+Requests that the server permanently remove the event with the given
+unique id from its persistant storage.
+
+If the server responds with a success also delete it from our local
+store (@code{vcal_objects}).
+
+@c TODO link to our backend flow here
+@end deftypefn
diff --git a/doc/ref/javascript/types.texi b/doc/ref/javascript/types.texi
index 73a58550..b9e6dbbf 100644
--- a/doc/ref/javascript/types.texi
+++ b/doc/ref/javascript/types.texi
@@ -10,12 +10,16 @@ Name of all valid icalendar types.
period, utc-offset, cal-address, recur, boolean,
@end defvar
+@deftp {Data Type} ical_type
+The union of all elements in @var{all_types}.
+@end deftp
+
@defvar property_names
All known names properties (top level keys) can have.
Such as ``calscale'', ``dtstart'', ...
@end defvar
-@defvar valid_fields
+@deftypevar {Map<string, string[]>} valid_fields
Which property fields each component can hold.
@verbatim
@@ -23,9 +27,9 @@ Which property fields each component can hold.
...
}
@end verbatim
-@end defvar
+@end deftypevar
-@defvar valid_input_types
+@deftypevar {Map<string, Array<ical_type | ical_type[]>>} valid_input_types
Which types are valid to store under each property.
If multiple values are an option for that property, then
the list of possibilities will contain a sub-list (see example).
@@ -36,4 +40,57 @@ the list of possibilities will contain a sub-list (see example).
...
}
@end verbatim
+@end deftypevar
+
+@deftp {Data Type} tagname
+Alias of (@code{'vevent'} | @code{'string'}).
+@end deftp
+
+@deftp {Data Type} uid
+Alias of @code{'string'}.
+@end deftp
+
+@c TODO link to the RFC
+@c - RFC 7265 (jCal)
+
+@deftp {Data Type} JCalProperty
+Alias for a record consisting of
+@itemize @bullet
+@item the name of the type, as a string
+@item All parameters of the object, as a @code{Record<string, any}@
+ @footnote{Which is simply a regular javascript object, mapping
+ strings to anything}.
+@item An @code{ical_type} value, noting the type of the final field(s)
+@item And one or more values of the type specified by the third field.
+@end itemize
+@end deftp
+
+@deftp {Data Type} JCal
+A record consisting of a @code{tagname}, a list of
+@code{JCalProperties}, and a list of other @code{JCal} objects.
+@end deftp
+
+@defvar xcal
+The xml namespace name for xcalendar, which is
+``urn:ietf:params:xml:ns:icalendar-2.0''.
@end defvar
+
+
+@deftp {Interface} ChangeLogEntry
+@anchor{ChangeLogEntry}
+
+@ref{VEventChangelog}
+
+@deftypecv {Interface Field} ChangeLogEntry {(@code{'calendar'} | @code{'property'})} type
+@end deftypecv
+
+@deftypecv {Interface Field} ChangeLogEntry {string} name
+@end deftypecv
+
+@deftypecv {Interface Field} ChangeLogEntry {string?} from
+@end deftypecv
+
+@deftypecv {Interface Field} ChangeLogEntry {string?} to
+@end deftypecv
+
+@end deftp
diff --git a/doc/ref/javascript/vevent.texi b/doc/ref/javascript/vevent.texi
new file mode 100644
index 00000000..ae54cfd4
--- /dev/null
+++ b/doc/ref/javascript/vevent.texi
@@ -0,0 +1,108 @@
+@node vevent
+@subsection vevent.js
+
+@deftp {Interface} Redrawable
+@deftypeop {Interface Field} Redrawable void redraw VEvent
+@end deftypeop
+@end deftp
+
+@deffn {Type Predicate} isRedrawable element
+Checks if the given element is an instance of Redrawable.
+@end deffn
+
+
+@deftp {class} VEventValue {type: ical_type} {value: any} {parameters: Map<string, any>}
+
+@deftypemethod VEventValue {[Record<string, any>, ical_type, any]} @
+ to_jcal {}
+The return value is @emph{almost} a @code{JCalProperty}, just without
+the field name.
+@end deftypemethod
+
+@end deftp
+
+@deftp VEvent {properties: Map<string, VEventValue | VEventValue[]>} @
+ {components: VEvent[]}
+
+Component for a single instance of a calendar event. Almost all data
+access should go through @code{getProperty} and @code{setProperty},
+with the exception of the current calendar (which is accessed directly
+through @code{calendar}). Almost all changes through these interfaces
+are logged, and can be viewed in @var{_changelog}.
+
+@deftypemethod VEvent {any?} getProperty {key: string}
+Returns the value of the given property if set, or undefined otherwise.
+
+For the keys
+@itemize
+@item @code{'CATEGORIES'},
+@item @code{'RESOURCES'},
+@item @code{'FREEBUSY'},
+@item @code{'EXDATE'}, and
+@item @code{'RDATE'}
+@end itemize
+instead returns a list list of values.
+@end deftypemethod
+
+
+@deftypemethod VEvent void setProperty {key: string} {value: any} {type: ical_type?}
+Sets the given property to the given value. If type is given it's
+stored alongside the value, possibly updating what is already
+there. Do however note that no validation between the given type and
+the type of the value is done.
+
+@var{value} may also be a list, but should only be so for the keys
+mentioned in @var{getProperty}.
+
+After the value is set, @var{redraw} is called on all registered
+objects, notifying them of the change.
+@end deftypemethod
+
+@deftypemethod VEvent void setProperties {[string, any, ical_type?][]}
+Equivalent to running @var{setProperty} for each element in the input
+list, but only calls @var{redraw} once at the end.
+@end deftypemethod
+
+@deftypemethod VEvent {IteratableIterator<string>} boundProperties
+Returns an iterator of all our properties.
+@end deftypemethod
+
+@deftypeivar VEvent {ChangeLogEntry[]} {_changelog}
+Every write through getProperty gets logged here, and can be
+consumed. Hopefully this will one day turn into an undo system.
+@ref{ChangeLogEntry}.
+@end deftypeivar
+
+@deftypeivar VEvent {string?} calendar
+The name of the calendar which this event belongs to.
+@end deftypeivar
+
+@deftypemethod VEvent void register {htmlNode: Redrawable}
+Register something redrawable, which will be notified whenever this
+VEvents data is updated.
+@end deftypemethod
+
+@deftypemethod VEvent void unregister {htmlNode: Redrawable}
+Stop recieving redraw events on the given component.
+@end deftypemethod
+
+@deftypemethod VEvent JCal to_jcal
+Converts the object to JCal data.
+@end deftypemethod
+
+@end deftp
+
+
+@deftp {class} RecurrenceRule
+@deftypemethod RecurrenceRule {Record<string, any>} to_jcal
+Converts ourselves to JCal data.
+@end deftypemethod
+@end deftp
+
+@deftypefun RecurrencRule xml_to_recurrence_rule {Element}
+Parse a XCAL recurrence rule into a RecurrenceRule object.
+@end deftypefun
+
+@deftypefun VEvent xml_to_vcal {Element}
+Parse a complete XCAL object into a JS VEvent object.
+@end deftypefun
diff --git a/module/calp/html/components.scm b/module/calp/html/components.scm
index 816975e7..1d677c0d 100644
--- a/module/calp/html/components.scm
+++ b/module/calp/html/components.scm
@@ -79,7 +79,7 @@
[else
(set! body (car rem))
(loop (cdr rem))])))
- (div ,body))))
+ ,body)))
;; Creates a group of tabs from a given specification. The specification
diff --git a/module/calp/html/util.scm b/module/calp/html/util.scm
index cd5aaeab..40852279 100644
--- a/module/calp/html/util.scm
+++ b/module/calp/html/util.scm
@@ -1,42 +1,11 @@
(define-module (calp html util)
- :use-module ((base64) :select (base64encode base64decode))
:use-module (calp util))
-;;; @var{html-attr} & @var{html-unattr} used to just strip any
-;;; attributes not valid in css. That allowed a human reader to
-;;; quickly see what data it was. The downside was that it was one
-;;; way. The new base64 based system supports both an encode and a
-;;; decode without problem.
-;;;
-;;; The encoded string substitutes { + => å, / => ä, = => ö } to be
-;;; valid CSS selector names.
-
-;; Retuns an HTML-safe version of @var{str}.
-(define-public (html-attr str)
- (string-map (lambda (c)
- (case c
- ((#\+) #\å)
- ((#\/) #\ä)
- ((#\=) #\ö)
- (else c)))
- (base64encode str)))
-
-(define-public (html-unattr str)
- (base64decode
- (string-map (lambda (c)
- (case c
- ((#\å) #\+)
- ((#\ä) #\/)
- ((#\ö) #\=)
- (else c)))
- str)))
-
(define-public (date-link date)
((@ (datetime) date->string) date "~Y-~m-~d"))
-
;; Generate an html id for an event.
;; TODO? same event placed multiple times, when spanning multiple cells
(define-public html-id
diff --git a/module/calp/html/vcomponent.scm b/module/calp/html/vcomponent.scm
index 105c6cc5..3e7cc4dc 100644
--- a/module/calp/html/vcomponent.scm
+++ b/module/calp/html/vcomponent.scm
@@ -1,5 +1,6 @@
(define-module (calp html vcomponent)
:use-module (calp util)
+ :use-module ((calp util exceptions) :select (warning))
:use-module (vcomponent)
:use-module (srfi srfi-1)
:use-module (srfi srfi-26)
@@ -14,6 +15,7 @@
:use-module ((calp util color) :select (calculate-fg-color))
:use-module ((crypto) :select (sha256 checksum->string))
:use-module ((xdg basedir) :prefix xdg-)
+ :use-module ((vcomponent recurrence) :select (repeating?))
:use-module ((vcomponent recurrence internal) :prefix #{rrule:}#)
:use-module ((vcomponent datetime output)
:select (fmt-time-span
@@ -21,8 +23,11 @@
format-summary
format-recurrence-rule
))
+ :use-module ((calp util config) :select (get-config))
+ :use-module ((base64) :select (base64encode))
)
+;; used by search view
(define-public (compact-event-list list)
(define calendars
@@ -33,11 +38,12 @@
(define (summary event)
`(summary (div (@ (class "summary-line "))
- (span (@ (class "square CAL_"
- ,(html-attr
- (or (prop (parent event)
- 'NAME)
- "unknown")))))
+ (span (@ (class "square")
+ (data-calendar
+ ,(base64encode
+ (or (prop (parent event)
+ 'NAME)
+ "unknown")))))
(time ,(let ((dt (prop event 'DTSTART)))
(if (datetime? dt)
(datetime->string dt "~Y-~m-~d ~H:~M")
@@ -58,259 +64,144 @@
;; - sidebar
;; - popup overwiew tab
;; - search result (event details)
+;; Note that the <vevent-description/> tag is bound as a JS custem element, which
+;; will re-render all this, through description-template. This also means that
+;; the procedures output is intended to be static, and to NOT be changed by JavaScript.
(define*-public (fmt-single-event ev
optional: (attributes '())
key: (fmt-header list))
;; (format (current-error-port) "fmt-single-event: ~a~%" (prop ev 'X-HNH-FILENAME))
- `(div (@ ,@(assq-merge
- attributes
- `((data-bindby "bind_view")
- (class " eventtext summary-tab "
- ,(when (and (prop ev 'PARTSTAT)
- (eq? 'TENTATIVE (prop ev 'PARTSTAT)))
- " tentative ")))))
- (h3 ,(fmt-header
- (when (prop ev 'RRULE)
- `(span (@ (class "repeating")) "↺"))
- `(span (@ (class "bind summary")
- (data-property "summary"))
- ,(prop ev 'SUMMARY))))
- (div
- ,(call-with-values (lambda () (fmt-time-span ev))
- (case-lambda [(start)
- `(div (time (@ (class "bind dtstart")
- (data-property "dtstart")
- (data-fmt ,(string-append "~L" start))
- (datetime ,(datetime->string
- (as-datetime (prop ev 'DTSTART))
- "~1T~3")))
- ,(datetime->string
- (as-datetime (prop ev 'DTSTART))
- start)))]
- [(start end)
- `(div (time (@ (class "bind dtstart")
- (data-property "dtstart")
- (data-fmt ,(string-append "~L" start))
- (datetime ,(datetime->string
- (as-datetime (prop ev 'DTSTART))
- "~1T~3")))
- ,(datetime->string (as-datetime (prop ev 'DTSTART))
- start))
- " — "
- (time (@ (class "bind dtend")
- (data-property "dtend")
- (data-fmt ,(string-append "~L" end))
- (datetime ,(datetime->string
- (as-datetime (prop ev 'DTSTART))
- "~1T~3")))
- ,(datetime->string (as-datetime (prop ev 'DTEND))
- end)))]))
-
- ;; TODO add optional fields when added in frontend
- ;; Possibly by always having them here, just hidden.
-
- (div (@ (class "fields"))
- ,(when (and=> (prop ev 'LOCATION) (negate string-null?))
- `(div (b "Plats: ")
- (div (@ (class "bind location") (data-property "location"))
- ,(string-map (lambda (c) (if (char=? c #\,) #\newline c))
- (prop ev 'LOCATION)))))
- ,(awhen (prop ev 'DESCRIPTION)
- `(div (@ (class "bind description")
- (data-property "description"))
- ,(format-description ev it)))
-
- ,@(awhen (prop* ev 'ATTACH)
- ;; attach satisfies @code{vline?}
- (for attach in it
- (if (and=> (param attach 'VALUE)
- (lambda (p) (string=? "BINARY" (car p))))
- ;; Binary data
- ;; TODO guess datatype if FMTTYPE is missing
- (awhen (and=> (param attach 'FMTTYPE)
- (lambda (it) (string-split
- (car it) #\/)))
- ;; TODO other file formats
- (when (string=? "image" (car it))
- (let* ((chk (-> (value attach)
- sha256
- checksum->string))
- (dname
- (path-append (xdg-runtime-dir)
- "calp-data" "images"))
- (filename (-> dname
- (path-append chk)
- ;; TODO second part of mimetypes
- ;; doesn't always result in a valid
- ;; file extension.
- ;; Take a look in mime.types.
- (string-append "." (cadr it)))))
- (unless (file-exists? filename)
- ;; TODO handle tmp directory globaly
- (mkdir (dirname dname))
- (mkdir dname)
- (call-with-output-file filename
- (lambda (port)
- (put-bytevector port (value attach)))))
- (let ((link (path-append
- "/tmpfiles"
- ;; TODO better mimetype to extension
- (string-append chk "." (cadr it)))))
- `(a (@ (href ,link))
- (img (@ (class "attach")
- (src ,link))))))))
- ;; URI
- (cond ((and=> (param attach 'FMTTYPE)
- (compose (cut string= <> "image" 0 5) car))
- `(img (@ (class "attach")
- (src ,(value attach)))))
- (else `(a (@ (class "attach")
- (href ,(value attach)))
- ,(value attach)))))))
-
- ;; TODO add bind once I figure out how to bind lists
- ,(awhen (prop ev 'CATEGORIES)
- `(div (@ (class "categories"))
- ,@(map (lambda (c)
- `(a (@ (class "category")
- ;; TODO centralize search terms
- (href
- "/search/?"
- ,(encode-query-parameters
- `((q . (member
- ,(->string c)
- (or (prop event 'CATEGORIES)
- '())))))))
- ,c))
- it)))
-
- ;; TODO bind
- ,(awhen (prop ev 'RRULE)
- `(div (@ (class "rrule"))
- ,@(format-recurrence-rule ev)))
-
- ,(when (prop ev 'LAST-MODIFIED)
- `(div (@ (class "last-modified")) "Senast ändrad "
- ,(datetime->string (prop ev 'LAST-MODIFIED) "~1 ~H:~M"))))
-
- )))
-
-(define*-public (fmt-for-edit ev
- optional: (attributes '())
- key: (fmt-header list))
- `(div (@ (class " eventtext edit-tab ")
- (data-bindby "bind_edit"))
- (form (@ (class "edit-form"))
- (div (@ (class "dropdown-goes-here")))
- (h3 (input (@ (type "text")
- (placeholder "Sammanfattning")
- (name "summary") (required)
- (class "bind") (data-property "summary")
- (value ,(prop ev 'SUMMARY)))))
-
- ,(let ((start (prop ev 'DTSTART))
- (end (prop ev 'DTEND)))
- `(div (@ (class "timeinput"))
-
- ,@(with-label
- "Starttid"
- `(div (@ (class "date-time bind")
- (data-bindby "bind_date_time")
- (name "dtstart"))
- (input (@ (type "date")
- (value ,(date->string (as-date start)))))
- (input (@ (type "time")
- (value ,(time->string (as-time start) "~H:~M"))
- ,@(when (date? start) '((disabled)))
- ))))
-
- ;; TODO some way to add an endtime if missing beforehand
- ;; TODO, actually proper support for event without end times
- ,@(when end
- (with-label
- "Sluttid"
- `(div (@ (class "date-time bind")
- (data-bindby "bind_date_time")
- (name "dtend"))
- (input (@ (type "date")
- (value ,(date->string (as-date end)))))
- (input (@ (type "time")
- (value ,(time->string (as-time end) "~H:~M"))
- ,@(when (date? end) '((disabled))))))))
-
- (div
- ,@(with-label
- "Heldag?"
- `(input (@ (type "checkbox")
- (class "bind")
- (data-bindby "bind_wholeday")
- (name "wholeday")
- ,@(when (date? start) '((checked)))))))
-
- ))
-
- ,@(with-label
- "Plats"
- `(input (@ (placeholder "Plats")
- (name "location")
- (type "text")
- (class "bind") (data-property "location")
- (value ,(or (prop ev 'LOCATION) "")))))
-
- ,@(with-label
- "Beskrivning"
- `(textarea (@ (placeholder "Beskrivning")
- (class "bind") (data-property "description")
- (name "description"))
- ,(prop ev 'DESCRIPTION)))
-
- ,@(with-label
- "Kategorier"
- ;; It would be better if these input-list's worked on the same
- ;; class=bind system as the fields above. The problem with that
- ;; is however that each input-list requires different search
- ;; and join procedures. Currently this is bound in the JS, see
- ;; [CATEGORIES_BIND].
- ;; It matches on ".input-list[data-property='categories']".
- `(div (@ (class "input-list")
- (data-property "categories"))
- ,@(awhen (prop ev 'CATEGORIES)
- (map (lambda (c)
- `(input (@ (size 2)
- (class "unit")
- (value ,c))))
- it))
-
- (input (@ (class "unit final")
- (size 2)
- (type "text")
- ))))
-
- (hr)
-
- ;; For custom user fields
- ;; TODO these are currently not bound to anything, so entering data
- ;; here does nothing. Bigest hurdle to overcome is supporting arbitrary
- ;; fields which will come and go in the JavaScript.
- ;; TODO also, all (most? maybe not LAST-MODIFIED) remaining properties
- ;; should be exposed here.
- (div (@ (class "input-list"))
- (div (@ (class "unit final newfield"))
- (input (@ (type "text")
- (list "known-fields")
- (placeholder "Nytt fält")))
- (select (@ (name "TYPE"))
- (option (@ (value "TEXT")) "Text"))
- (span
- (input (@ (type "text")
- (placeholder "Värde"))))))
-
- (hr)
+ `(vevent-description
+ (@ ,@(assq-merge
+ attributes
+ `(
+ (class ,(when (and (prop ev 'PARTSTAT)
+ (eq? 'TENTATIVE (prop ev 'PARTSTAT)))
+ " tentative "))
+ (data-uid ,(output-uid ev)))))
+ (div (@ (class "vevent eventtext summary-tab"))
+ (h3 ,(fmt-header
+ (when (prop ev 'RRULE)
+ `(span (@ (class "repeating")) "↺"))
+ `(span (@ (class "summary")
+ (data-property "summary"))
+ ,(prop ev 'SUMMARY))))
+ (div
+ ,(call-with-values (lambda () (fmt-time-span ev))
+ (case-lambda [(start)
+ `(div (time (@ (class "dtstart")
+ (data-property "dtstart")
+ (data-fmt ,(string-append "~L" start))
+ (datetime ,(datetime->string
+ (as-datetime (prop ev 'DTSTART))
+ "~1T~3")))
+ ,(datetime->string
+ (as-datetime (prop ev 'DTSTART))
+ start)))]
+ [(start end)
+ `(div (time (@ (class "dtstart")
+ (data-property "dtstart")
+ (data-fmt ,(string-append "~L" start))
+ (datetime ,(datetime->string
+ (as-datetime (prop ev 'DTSTART))
+ "~1T~3")))
+ ,(datetime->string (as-datetime (prop ev 'DTSTART))
+ start))
+ " — "
+ (time (@ (class "dtend")
+ (data-property "dtend")
+ (data-fmt ,(string-append "~L" end))
+ (datetime ,(datetime->string
+ (as-datetime (prop ev 'DTSTART))
+ "~1T~3")))
+ ,(datetime->string (as-datetime (prop ev 'DTEND))
+ end)))]))
+
+ (div (@ (class "fields"))
+ ,(when (and=> (prop ev 'LOCATION) (negate string-null?))
+ `(div (b "Plats: ")
+ (div (@ (class "location") (data-property "location"))
+ ,(string-map (lambda (c) (if (char=? c #\,) #\newline c))
+ (prop ev 'LOCATION)))))
+ ,(awhen (prop ev 'DESCRIPTION)
+ `(div (@ (class "description")
+ (data-property "description"))
+ ,(format-description ev it)))
+
+ ,@(awhen (prop* ev 'ATTACH)
+ ;; attach satisfies @code{vline?}
+ (for attach in it
+ (if (and=> (param attach 'VALUE)
+ (lambda (p) (string=? "BINARY" (car p))))
+ ;; Binary data
+ ;; TODO guess datatype if FMTTYPE is missing
+ (awhen (and=> (param attach 'FMTTYPE)
+ (lambda (it) (string-split
+ (car it) #\/)))
+ ;; TODO other file formats
+ (when (string=? "image" (car it))
+ (let* ((chk (-> (value attach)
+ sha256
+ checksum->string))
+ (dname
+ (path-append (xdg-runtime-dir)
+ "calp-data" "images"))
+ (filename (-> dname
+ (path-append chk)
+ ;; TODO second part of mimetypes
+ ;; doesn't always result in a valid
+ ;; file extension.
+ ;; Take a look in mime.types.
+ (string-append "." (cadr it)))))
+ (unless (file-exists? filename)
+ ;; TODO handle tmp directory globaly
+ (mkdir (dirname dname))
+ (mkdir dname)
+ (call-with-output-file filename
+ (lambda (port)
+ (put-bytevector port (value attach)))))
+ (let ((link (path-append
+ "/tmpfiles"
+ ;; TODO better mimetype to extension
+ (string-append chk "." (cadr it)))))
+ `(a (@ (href ,link))
+ (img (@ (class "attach")
+ (src ,link))))))))
+ ;; URI
+ (cond ((and=> (param attach 'FMTTYPE)
+ (compose (cut string= <> "image" 0 5) car))
+ `(img (@ (class "attach")
+ (src ,(value attach)))))
+ (else `(a (@ (class "attach")
+ (href ,(value attach)))
+ ,(value attach)))))))
+
+ ,(awhen (prop ev 'CATEGORIES)
+ `(div (@ (class "categories"))
+ ,@(map (lambda (c)
+ `(a (@ (class "category")
+ ;; TODO centralize search terms
+ (href
+ "/search/?"
+ ,(encode-query-parameters
+ `((q . (member
+ ,(->string c)
+ (or (prop event 'CATEGORIES)
+ '())))))))
+ ,c))
+ it)))
+
+ ,(awhen (prop ev 'RRULE)
+ `(div (@ (class "rrule"))
+ ,@(format-recurrence-rule ev)))
+
+ ,(when (prop ev 'LAST-MODIFIED)
+ `(div (@ (class "last-modified")) "Senast ändrad "
+ ,(datetime->string (prop ev 'LAST-MODIFIED) "~1 ~H:~M"))))
+
+ ))))
- (input (@ (type "submit")))
- )))
-
;; Single event in side bar (text objects)
(define-public (fmt-day day)
@@ -324,7 +215,7 @@
(lambda (ev)
(fmt-single-event
ev `((id ,(html-id ev))
- (class "CAL_" ,(html-attr (or (prop (parent ev) 'NAME) "unknown"))))
+ (data-calendar ,(base64encode (or (prop (parent ev) 'NAME) "unknown"))))
fmt-header:
(lambda body
`(a (@ (href "#" ,(html-id ev) #; (date-link (as-date (prop ev 'DTSTART)))
@@ -341,60 +232,61 @@
events))))))
+;; Specific styles for each calendar.
+;; TODO only emit the CSS here, requiring the caller to handle the context,
+;; since that would allow us to use this in other contexts.
(define-public (calendar-styles calendars)
`(style
- ,(format #f "~:{.CAL_~a { --color: ~a; --complement: ~a }~%~}"
- (map (lambda (c)
- (let* ((name (html-attr (prop c 'NAME)))
- (bg-color (prop c 'COLOR))
- (fg-color (and=> (prop c 'COLOR)
- calculate-fg-color)))
- (list name (or bg-color 'white) (or fg-color 'black))))
- calendars))))
+ ,(lambda () (format #t "~:{ [data-calendar=\"~a\"] { --color: ~a; --complement: ~a }~%~}"
+ (map (lambda (c)
+ (let* ((name (base64encode (prop c 'NAME)))
+ (bg-color (prop c 'COLOR))
+ (fg-color (and=> (prop c 'COLOR)
+ calculate-fg-color)))
+ (list name (or bg-color 'white) (or fg-color 'black))))
+ calendars)))))
;; "Physical" block in calendar view
(define*-public (make-block ev optional: (extra-attributes '()))
+ ;; surrounding <a /> element which allows something to happen when an element
+ ;; is clicked with JS turned off. Our JS disables this, and handles clicks itself.
`((a (@ (href "#" ,(html-id ev))
(class "hidelink"))
- (div (@ ,@(assq-merge
- extra-attributes
- `((id ,(html-id ev))
- (data-calendar ,(html-attr (or (prop (parent ev) 'NAME) "unknown")))
- ;; (data-bindon "bind_view")
- (class "event CAL_" ,(html-attr (or (prop (parent ev) 'NAME)
- "unknown"))
- ,(when (and (prop ev 'PARTSTAT)
- (eq? 'TENTATIVE (prop ev 'PARTSTAT)))
- " tentative")
- ,(when (and (prop ev 'TRANSP)
- (eq? 'TRANSPARENT (prop ev 'TRANSP)))
- " transparent")
- )
- (onclick "toggle_popup('popup' + this.id)")
- )))
- ;; Inner div to prevent overflow. Previously "overflow: none"
- ;; was set on the surounding div, but the popup /needs/ to
- ;; overflow (for the tabs?).
- (div (@ (class "event-body"))
- ,(when (prop ev 'RRULE)
- `(span (@ (class "repeating")) "↺"))
- (span (@ (class "bind summary")
- (data-property "summary"))
- ,(format-summary ev (prop ev 'SUMMARY)))
- ,(when (prop ev 'LOCATION)
- `(span (@ (class "bind location")
- (data-property "location"))
- ,(string-map (lambda (c) (if (char=? c #\,) #\newline c))
- (prop ev 'LOCATION))))
- ;; Document symbol when we have text
- ,(when (and=> (prop ev 'DESCRIPTION) (negate string-null?))
- `(span (@ (class "description"))
- "🗎")))
- (div (@ (style "display:none !important;"))
- ,((@ (vcomponent xcal output) ns-wrap)
- ((@ (vcomponent xcal output) vcomponent->sxcal)
- ev)))))))
+ (vevent-block (@ ,@(assq-merge
+ extra-attributes
+ `((id ,(html-id ev))
+ (data-calendar ,(base64encode (or (prop (parent ev) 'NAME) "unknown")))
+ (data-uid ,(output-uid ev))
+
+ (class "vevent event"
+ ,(when (and (prop ev 'PARTSTAT)
+ (eq? 'TENTATIVE (prop ev 'PARTSTAT)))
+ " tentative")
+ ,(when (and (prop ev 'TRANSP)
+ (eq? 'TRANSPARENT (prop ev 'TRANSP)))
+ " transparent")
+ ))))
+ ;; Inner div to prevent overflow. Previously "overflow: none"
+ ;; was set on the surounding div, but the popup /needs/ to
+ ;; overflow (for the tabs?).
+ ;; TODO the above comment is no longer valid. Popups are now stored
+ ;; separately from the block.
+ (div (@ (class "event-body"))
+ ,(when (prop ev 'RRULE)
+ `(span (@ (class "repeating")) "↺"))
+ (span (@ (class "summary")
+ (data-property "summary"))
+ ,(format-summary ev (prop ev 'SUMMARY)))
+ ,(when (prop ev 'LOCATION)
+ `(span (@ (class "location")
+ (data-property "location"))
+ ,(string-map (lambda (c) (if (char=? c #\,) #\newline c))
+ (prop ev 'LOCATION))))
+ ;; Document symbol when we have text
+ ,(when (and=> (prop ev 'DESCRIPTION) (negate string-null?))
+ `(span (@ (class "description"))
+ "🗎")))))))
(define (repeat-info event)
@@ -421,208 +313,284 @@
(else (->string value))))))
(prop event 'RRULE)))))
-;; TODO bind this into the xcal
-(define (editable-repeat-info event)
- `(div (@ (class "eventtext"))
- (h2 "Upprepningar")
- ,@(when (debug)
- '((button (@ (style "position:absolute;right:1ex;top:1ex")
- (onclick "console.log(event_from_popup(this.closest('.popup-container')).properties.rrule.asJcal());"))
- "js")))
- (table (@ (class "recur-components bind")
- (name "rrule")
- (data-bindby "bind_recur"))
- ,@(map ; (@@ (vcomponent recurrence internal) map-fields)
- (lambda (key )
- `(tr (@ (class ,key)) (th ,key)
- (td
- ,(case key
- ((freq)
- `(select (@ (class "bind-rr") (name "freq"))
- (option "-")
- ,@(map (lambda (x) `(option (@ (value ,x)
- ,@(awhen (prop event 'RRULE)
- (awhen (rrule:freq it)
- (awhen (eq? it x)
- '((selected))))))
- ,(string-titlecase
- (symbol->string x))))
- '(SECONDLY MINUTELY HOURLY
- DAILY WEEKLY
- MONTHLY YEARLY))))
- ((until)
- (if (date? (prop event 'DTSTART))
- `(input (@ (type "date")
- (name "until")
- (class "bind-rr")
- (value ,(awhen (prop event 'RRULE)
- (awhen (rrule:until it)
- (date->string it))))))
- `(span (@ (class "bind-rr date-time")
- (name "until"))
- (input (@ (type "date")
- (value ,(awhen (prop event 'RRULE)
- (awhen (rrule:until it)
- (date->string
- (as-date it)))))))
- (input (@ (type "time")
- (value ,(awhen (prop event 'RRULE)
- (awhen (rrule:until it)
- (time->string
- (as-time it))))))))))
- ((count)
- `(input (@ (type number) (min 0) (size 4)
- (value ,(awhen (prop event 'RRULE)
- (or (rrule:count it) "")))
- (name "count")
- (class "bind-rr")
- )))
- ((interval)
- `(input (@ (type number) (min 0) (size 4)
- (value ,(awhen (prop event 'RRULE)
- (or (rrule:interval it) "")))
- (name "interval")
- (class "bind-rr"))))
- ((wkst)
- `(select (@ (name "wkst") (class "bind-rr"))
- (option "-")
- ,@(map (lambda (i)
- `(option (@ (value ,i)
- ,@(awhen (prop event 'RRULE)
- (awhen (rrule:wkst it)
- (awhen (eqv? it i)
- '((selected))))))
- ,(week-day-name i)))
- (iota 7))))
-
- ((byday)
- (let ((input (lambda* (optional: (byday '(#f . #f)) key: final?)
- `(div (@ (class "unit" ,(if final? " final" "")))
- ;; TODO make this thiner, and clearer that
- ;; it belongs to the following dropdown
- (input (@ (type number)
- (value ,(awhen (car byday) it))))
- (select (option "-")
- ,@(map (lambda (i)
- `(option (@ (value ,i)
- ,@(if (eqv? i (cdr byday))
- '((selected)) '()))
- ,(week-day-name i)))
- (iota 7)))))))
- ;; TODO how does this bind?
- `(div (@ (class "bind-rr input-list"))
- ,@(cond ((and=> (prop event 'RRULE)
- rrule:byday)
- => (lambda (it) (map input it)))
- (else '()))
-
- ,(input final?: #t))))
-
- ((bysecond byminute byhour
- bymonthday byyearday
- byweekno bymonth bysetpos)
- (let ((input
- (lambda* (value optional: (final ""))
- `(input (@ (class "unit " ,final)
- (type "number")
- (size 2)
- (value ,value)
- (min ,(case key
- ((bysecond byminute byhour) 0)
- ((bymonthday) -31)
- ((byyearday) -366)
- ((byweekno) -53)
- ((bymonth) -12)
- ((bysetpos) -366)
- ))
- (max ,(case key
- ((bysecond) 60)
- ((byminute) 59)
- ((byhour) 23)
- ((bymonthday) 31)
- ((byyearday) 366)
- ((byweekno) 53)
- ((bymonth) 12)
- ((bysetpos) 366))))))))
- `(div (@ (name ,key)
- (class "bind-rr input-list"))
- ,@(map input
- (awhen (prop event 'RRULE)
- (or ((case key
- ((bysecond) rrule:bysecond)
- ((byminute) rrule:byminute)
- ((byhour) rrule:byhour)
- ((bymonthday) rrule:bymonthday)
- ((byyearday) rrule:byyearday)
- ((byweekno) rrule:byweekno)
- ((bymonth) rrule:bymonth)
- ((bysetpos) rrule:bysetpos))
- it)
- '())))
- ,(input '() "final"))))
- (else (error "Unknown field, " key))))
-
- ;; TODO enable this button
- (td (button (@ (class "clear-input") (title "Rensa input")) "🗙"))
+
+;; Return a unique identifier for a specific instance of an event.
+;; Allows us to reference each instance of a repeating event separately
+;; from any other
+(define-public (output-uid event)
+ (string-concatenate
+ (cons
+ (prop event 'UID)
+ (when (repeating? event)
+ ;; TODO this will break if a UID already looks like this...
+ ;; Just using a pre-generated unique string would solve it,
+ ;; until someone wants to break us. Therefore, we just give
+ ;; up for now, until a proper solution can be devised.
+ (list "---"
+ ;; TODO Will this give us a unique identifier?
+ ;; Or can two events share UID along with start time
+ (datetime->string
+ (as-datetime (or
+ ;; TODO What happens if the parameter RANGE=THISANDFUTURE is set?
+ (prop event 'RECURRENCE-ID)
+ (prop event 'DTSTART)))
+ "~Y-~m-~dT~H:~M:~S"))))))
+
+
+(define (week-day-select args)
+ `(select (@ ,@args)
+ (option "-")
+ ,@(map (lambda (x) `(option (@ (value ,(car x))) ,(cadr x)))
+ '((MO "Monday")
+ (TU "Tuesday")
+ (WE "Wednesday")
+ (TH "Thursday")
+ (FR "Friday")
+ (SA "Saturday")
+ (SU "Sunday")))))
+
+
+;;; Templates
+
+
+;; edit tab of popup
+(define-public (edit-template calendars)
+ `(template
+ (@ (id "vevent-edit"))
+ (div (@ (class " eventtext edit-tab "))
+ (form (@ (class "edit-form"))
+ (select (@ (class "calendar-selection"))
+ (option "- Choose a Calendar -")
+ ,@(let ((dflt (get-config 'default-calendar)))
+ (map (lambda (calendar)
+ (define name (prop calendar 'NAME))
+ `(option (@ (value ,(base64encode name))
+ ,@(when (string=? name dflt)
+ '((selected))))
+ ,name))
+ calendars)))
+ (h3 (input (@ (type "text")
+ (placeholder "Sammanfattning")
+ (name "summary") (required)
+ (data-property "summary")
+ ; (value ,(prop ev 'SUMMARY))
+ )))
+
+ (div (@ (class "timeinput"))
+
+ ,@(with-label
+ "Starttid"
+ '(date-time-input (@ (name "dtstart")
+ (data-property "dtstart")
+ )))
+
+ ,@(with-label
+ "Sluttid"
+ '(date-time-input (@ (name "dtend")
+ (data-property "dtend"))))
+
+ (div (@ (class "checkboxes"))
+ ,@(with-label
+ "Heldag?"
+ `(input (@ (type "checkbox")
+ (name "wholeday")
+ )))
+ ,@(with-label
+ "Upprepande?"
+ `(input (@ (type "checkbox")
+ (name "has_repeats")
+ ))))
+
+ )
+
+ ,@(with-label
+ "Plats"
+ `(input (@ (placeholder "Plats")
+ (name "location")
+ (type "text")
+ (data-property "location")
+ ; (value ,(or (prop ev 'LOCATION) ""))
+ )))
+
+ ,@(with-label
+ "Beskrivning"
+ `(textarea (@ (placeholder "Beskrivning")
+ (data-property "description")
+ (name "description"))
+ ; ,(prop ev 'DESCRIPTION)
+ ))
+
+ ,@(with-label
+ "Kategorier"
+ `(input-list
+ (@ (name "categories")
+ (data-property "categories"))
+ (input (@ (type "text")
+ (placeholder "Kattegori")))))
+
+ ;; TODO This should be a "list" where any field can be edited
+ ;; directly. Major thing holding us back currently is that
+ ;; <input-list /> doesn't supported advanced inputs
+ ;; (div (@ (class "input-list"))
+ ;; (div (@ (class "unit final newfield"))
+ ;; (input (@ (type "text")
+ ;; (list "known-fields")
+ ;; (placeholder "Nytt fält")))
+ ;; (select (@ (name "TYPE"))
+ ;; (option (@ (value "TEXT")) "Text"))
+ ;; (span
+ ;; (input (@ (type "text")
+ ;; (placeholder "Värde"))))))
+
+ ;; (hr)
+
+
+ (input (@ (type "submit")))
+ ))))
+
+;; description in sidebar / tab of popup
+;; Template data for <vevent-description />
+(define-public (description-template)
+ '(template
+ (@ (id "vevent-description"))
+ (div (@ (class " vevent eventtext summary-tab " ()))
+ (h3 ((span (@ (class "repeating"))
+ "↺")
+ (span (@ (class "summary")
+ (data-property "summary")))))
+ (div (div (time (@ (class "dtstart")
+ (data-property "dtstart")
+ (data-fmt "~L~H:~M")
+ (datetime ; "2021-09-29T19:56:46"
+ ))
+ ; "19:56"
+ )
+ "\xa0—\xa0"
+ (time (@ (class "dtend")
+ (data-property "dtend")
+ (data-fmt "~L~H:~M")
+ (datetime ; "2021-09-29T19:56:46"
+ ))
+ ; "20:56"
))
- '(freq until count interval bysecond byminute byhour
- byday bymonthday byyearday byweekno bymonth bysetpos
- wkst)
- ; (prop event 'RRULE)
- ))))
-
-
-(define-public (popup ev id)
- `(div (@ (id ,id) (class "popup-container CAL_"
- ,(html-attr (or (prop (parent ev) 'NAME)
- "unknown")))
- (onclick "event.stopPropagation()"))
- ;; TODO all (?) code uses .popup-container as the popup, while .popup sits and does nothing.
- ;; Do something about this?
- (div (@ (class "popup"))
- (nav (@ (class "popup-control"))
- ,(btn "×"
- title: "Stäng"
- onclick: "close_popup(document.getElementById(this.closest('.popup-container').id))"
- class: '("close-tooltip"))
- ,(when (edit-mode)
- (list
- (btn "🖊️"
- title: "Redigera"
- onclick: "place_in_edit_mode(event_from_popup(this.closest('.popup-container')))")
- (btn "🗑"
- title: "Ta bort"
- onclick: "remove_event(event_from_popup(this.closest('.popup-container')))"))))
-
- ,(tabset
- `(("📅" title: "Översikt"
- ,(fmt-single-event ev))
-
- ,@(when (edit-mode)
- `(("📅" title: "Redigera"
- ,(fmt-for-edit ev))))
-
- ,@(when (debug)
- `(("🐸" title: "Debug"
- (div
- (pre ,(prop ev 'UID))))))
-
- ("⤓" title: "Nedladdning"
- (div (@ (class "eventtext") (style "font-family:sans"))
- (h2 "Ladda ner")
- (div (@ (class "side-by-side"))
- (ul (li (a (@ (href "/calendar/" ,(prop ev 'UID) ".ics"))
- "som iCal"))
- (li (a (@ (href "/calendar/" ,(prop ev 'UID) ".xcs"))
- "som xCal")))
- ,@(when (debug)
- `((ul
- (li (button (@ (onclick "console.log(event_to_jcal(event_from_popup(this.closest('.popup-container'))));")) "js"))
- (li (button (@ (onclick "console.log(jcal_to_xcal(event_to_jcal(event_from_popup(this.closest('.popup-container')))));")) "xml"))
- (li (button (@ (onclick "console.log(event_from_popup(this.closest('.popup-container')))")) "this"))
- ))))
- ))
-
- ,@(when (prop ev 'RRULE)
- `(("↺" title: "Upprepningar" class: "repeating"
- ,(editable-repeat-info ev)))))))))
+ (div (@ (class "fields"))
+ (div (b "Plats: ")
+ (div (@ (class "location")
+ (data-property "location"))
+ ; "Alsättersgatan 13"
+ ))
+ (div (@ (class "description")
+ (data-property "description"))
+ ; "With a description"
+ )
+
+ (div (@ (class "categories")
+ (data-property "categories")))
+ ;; (div (@ (class "categories"))
+ ;; (a (@ (class "category")
+ ;; (href "/search/?"
+ ;; "q=%28member%20%22test%22%20%28or%20%28prop%20event%20%28quote%20CATEGORIES%29%29%20%28quote%20%28%29%29%29%29"))
+ ;; test))
+ ;; (div (@ (class "rrule"))
+ ;; "Upprepas "
+ ;; "varje vecka"
+ ;; ".")
+ (div (@ (class "last-modified"))
+ "Senast ändrad -"
+ ; "2021-09-29 19:56"
+ ))))))
+
+(define-public (vevent-edit-rrule-template)
+ `(template
+ (@ (id "vevent-edit-rrule"))
+ (div (@ (class "eventtext"))
+ (h2 "Upprepningar")
+ (dl
+ (dt "Frequency")
+ (dd (select (@ (name "freq"))
+ (option "-")
+ ,@(map (lambda (x) `(option (@ (value ,x)) ,(string-titlecase (symbol->string x))))
+ '(SECONDLY MINUTELY HOURLY DAILY WEEKLY MONTHLY YEARLY))))
+
+ (dt "Until")
+ (dd (date-time-input (@ (name "until"))))
+
+ (dt "Conut")
+ (dd (input (@ (type "number") (name "count") (min 0))))
+
+ (dt "Interval")
+ (dd (input (@ (type "number") (name "interval") ; min and max depend on FREQ
+ )))
+
+ ,@(concatenate
+ (map (lambda (pair)
+ (define name (list-ref pair 0))
+ (define pretty-name (list-ref pair 1))
+ (define min (list-ref pair 2))
+ (define max (list-ref pair 3))
+ `((dt ,pretty-name)
+ (dd (input-list (@ (name ,name))
+ (input (@ (type "number")
+ (min ,min) (max ,max)))))))
+ '((bysecond "By Second" 0 60)
+ (byminute "By Minute" 0 59)
+ (byhour "By Hour" 0 23)
+ (bymonthday "By Month Day" -31 31) ; except 0
+ (byyearday "By Year Day" -366 366) ; except 0
+ (byweekno "By Week Number" -53 53) ; except 0
+ (bymonth "By Month" 1 12)
+ (bysetpos "By Set Position" -366 366) ; except 0
+ )))
+
+ ;; (dt "By Week Day")
+ ;; (dd (input-list (@ (name "byweekday"))
+ ;; (input (@ (type number)
+ ;; (min -53) (max 53) ; except 0
+ ;; ))
+ ;; ,(week-day-select '())
+ ;; ))
+
+ (dt "Weekstart")
+ (dd ,(week-day-select '((name "wkst")))))))
+ )
+
+
+;; Based on popup:s output
+(define-public (popup-template)
+ `(template
+ (@ (id "popup-template"))
+ ;; becomes the direct child of <popup-element/>
+ (div (@ (class "popup-root window")
+ (onclick "event.stopPropagation()"))
+
+ (nav (@ (class "popup-control"))
+ (button (@ (class "close-button")
+ (title "Stäng")
+ (aria-label "Close"))
+ "×")
+ (button (@ (class "maximize-button")
+ (title "Fullskärm")
+ ;; (aria-label "")
+ )
+ "🗖")
+ (button (@ (class "remove-button")
+ (title "Ta Bort"))
+ "🗑"))
+
+ (tab-group (@ (class "window-body"))
+ (vevent-description
+ (@ (data-label "📅") (data-title "Översikt")
+ (class "vevent")))
+
+ (vevent-edit
+ (@ (data-label "🖊") (data-title "Redigera")))
+
+ ;; (vevent-edit-rrule
+ ;; (@ (data-label "↺") (data-title "Upprepningar")))
+
+ (vevent-changelog
+ (@ (data-label "📒") (date-title "Changelog")))
+
+ ,@(when (debug)
+ '((vevent-dl
+ (@ (data-label "🐸") (data-title "Debug")))))))))
diff --git a/module/calp/html/view/calendar.scm b/module/calp/html/view/calendar.scm
index 4574f517..aa311fcb 100644
--- a/module/calp/html/view/calendar.scm
+++ b/module/calp/html/view/calendar.scm
@@ -8,11 +8,11 @@
:use-module (datetime)
:use-module (calp html components)
:use-module ((calp html vcomponent)
- :select (popup
- calendar-styles
+ :select (calendar-styles
fmt-day
make-block
fmt-single-event
+ output-uid
))
:use-module (calp html config)
:use-module (calp html util)
@@ -25,8 +25,10 @@
:use-module (srfi srfi-41)
:use-module (srfi srfi-41 util)
+ :use-module ((vcomponent recurrence) :select (repeating? generate-recurrence-set))
:use-module ((vcomponent group)
:select (group-stream get-groups-between))
+ :use-module ((base64) :select (base64encode))
)
@@ -48,7 +50,10 @@
(define*-public (html-generate
key:
(intervaltype 'all) ; 'week | 'month | 'all
- calendars events start-date end-date
+ calendars ; All calendars to work on, probably (get-calendars global-event-object)
+ events ; All events which can be worked on, probably (get-event-set global-event-object)
+ start-date ; First date in interval to show
+ end-date ; Last date in interval to show
render-calendar ; (bunch of kv args) → (list sxml)
next-start ; date → date
prev-start ; date → date
@@ -93,7 +98,14 @@
(meta (@ (name end-time)
(content ,(date->string (date+ end-date (date day: 1)) "~s"))))
- (script "EDIT_MODE=" ,(if (edit-mode) "true" "false") ";")
+ (script
+ ,(format #f
+ "
+EDIT_MODE=~:[false~;true~];
+window.default_calendar='~a';"
+ (edit-mode)
+ (base64encode (get-config 'default-calendar))))
+
(style ,(format #f "html {
--editmode: 1.0;
@@ -104,19 +116,8 @@
,(include-alt-css "/static/dark.css" '(title "Dark"))
,(include-alt-css "/static/light.css" '(title "Light"))
- (script (@ (defer) (src "/static/types.js")))
- (script (@ (defer) (src "/static/lib.js")))
- (script (@ (defer) (src "/static/jcal.js")))
- (script (@ (defer) (src "/static/dragable.js")))
- (script (@ (defer) (src "/static/clock.js")))
- (script (@ (defer) (src "/static/popup.js")))
- (script (@ (defer) (src "/static/rrule.js")))
- (script (@ (defer) (src "/static/binders.js")))
- (script (@ (defer) (src "/static/server_connect.js")))
- (script (@ (defer) (src "/static/input_list.js")))
- (script (@ (defer) (src "/static/date_time.js")))
- (script (@ (defer) (src "/static/vcal.js")))
- (script (@ (defer) (src "/static/script.js")))
+ (script (@ (src "/static/script.out.js")))
+
,(calendar-styles calendars)
,@(when (debug)
@@ -136,6 +137,10 @@
next-start: next-start
prev-start: prev-start
)
+
+ ,(btn onclick: "addNewEvent()"
+ "+")
+
;; Popups used to be here, but was moved into render-calendar so each
;; sub-view can itself decide where to put them. This is important
;; since they need to be placed as children to the scrolling
@@ -146,6 +151,7 @@
(footer
(@ (style "grid-area: footer"))
(span "Page generated " ,(date->string (current-date)))
+ (span "Current time " (current-time (@ (interval 1))))
(span (a (@ (href ,(repo-url)))
"Source Code")))
@@ -162,13 +168,14 @@
,(btn href: (date->string (set (day start-date) 1) "/month/~1.html")
"månadsvy")
- ,(btn id: "today-button"
- href: (string-append
- "/today?" (case intervaltype
- [(month) "view=month"]
- [(week) "view=week"]
- [else ""]))
- "idag"))
+ (today-button
+ (a (@ (class "btn")
+ (href ,(string-append
+ "/today?" (case intervaltype
+ [(month) "view=month"]
+ [(week) "view=week"]
+ [else ""]))))
+ "idag")))
(div (@ (id "jump-to"))
;; Firefox's accessability complain about each date
@@ -248,32 +255,22 @@
(summary "Calendar list")
(ul ,@(map
(lambda (calendar)
- `(li (@ (class "CAL_"
- ,(html-attr (prop calendar 'NAME))))
+ `(li (@ (data-calendar ,(base64encode (prop calendar 'NAME))))
(a (@ (href "/search?"
- ,((@ (web uri-query) encode-query-parameters)
- `((q . (and (date/-time<=?
- ,(current-datetime)
- (prop event 'DTSTART))
- ;; TODO this seems to miss some calendars,
- ;; I belive it's due to some setting X-WR-CALNAME,
- ;; which is only transfered /sometimes/ into NAME.
- (string=? ,(->string (prop calendar 'NAME))
- (or (prop (parent event) 'NAME) ""))))))))
+ ,((@ (web uri-query) encode-query-parameters)
+ `((q . (and (date/-time<=?
+ ,(current-datetime)
+ (prop event 'DTSTART))
+ ;; TODO this seems to miss some calendars,
+ ;; I belive it's due to some setting X-WR-CALNAME,
+ ;; which is only transfered /sometimes/ into NAME.
+ (string=? ,(->string (prop calendar 'NAME))
+ (or (prop (parent event) 'NAME) ""))))))))
,(prop calendar 'NAME))))
calendars))
- (div (@ (id "calendar-dropdown-template") (class "template"))
- (select
- (option "- Choose a Calendar -")
- ,@(let ((dflt (get-config 'default-calendar)))
- (map (lambda (calendar)
- (define name (prop calendar 'NAME))
- `(option (@ (value ,(html-attr name))
- ,@(when (string=? name dflt)
- '((selected))))
- ,name))
- calendars)))
- )))
+ ;; (div (@ (id "calendar-dropdown-template") (class "template"))
+ ;; )
+ ))
;; List of events
(div (@ (class "eventlist")
@@ -286,7 +283,11 @@
;; Figure out way to merge it with the below call.
,@(stream->list
(stream-map
- fmt-single-event
+ (lambda (ev)
+ (fmt-single-event
+ ev `((id ,(html-id ev))
+ (data-calendar ,(base64encode (or (prop (parent ev) 'NAME)
+ "unknown"))))))
(stream-take-while
(compose (cut date/-time<? <> start-date)
(extract 'DTSTART))
@@ -296,32 +297,40 @@
;; This would idealy be a <template> element, but there is some
;; form of special case with those in xhtml, but I can't find
;; the documentation for it.
- ,@(let* ((cal (vcalendar
- name: "Generated"
- children: (list (vevent
- ;; The event template SHOULD lack
- ;; a UID, to stop potential problems
- ;; with conflicts when multiple it's
- ;; cloned mulitple times.
- dtstart: (datetime)
- dtend: (datetime)
- summary: ""
- ;; force a description field,
- ;; but don't put anything in
- ;; it.
- description: ""))))
- (event (car (children cal))))
- `((div (@ (class "template event-container") (id "event-template")
- ;; Only needed to create a duration. So actual dates
- ;; dosen't matter
- (data-start "2020-01-01")
- (data-end "2020-01-02"))
- ,(caddar ; strip <a> tag
- (make-block event `((class " generated ")))))
- ;; TODO merge this into the event-set, add attribute
- ;; for non-displaying elements.
- (div (@ (class "template") (id "popup-template"))
- ,(popup event (string-append "popup" (html-id event))))))
+ ;; ,@(let* ((cal (vcalendar
+ ;; name: "Generated"
+ ;; children: (list (vevent
+ ;; ;; The event template SHOULD lack
+ ;; ;; a UID, to stop potential problems
+ ;; ;; with conflicts when multiple it's
+ ;; ;; cloned mulitple times.
+ ;; dtstart: (datetime)
+ ;; dtend: (datetime)
+ ;; summary: ""
+ ;; ;; force a description field,
+ ;; ;; but don't put anything in
+ ;; ;; it.
+ ;; description: ""))))
+ ;; (event (car (children cal))))
+ ;; `(
+ ;; ;; (div (@ (class "template event-container") (id "event-template")
+ ;; ;; ;; Only needed to create a duration. So actual dates
+ ;; ;; ;; dosen't matter
+ ;; ;; (data-start "2020-01-01")
+ ;; ;; (data-end "2020-01-02"))
+ ;; ;; ,(caddar ; strip <a> tag
+ ;; ;; (make-block event `((class " generated ")))))
+ ;; ;; TODO merge this into the event-set, add attribute
+ ;; ;; for non-displaying elements.
+ ;; ;; (div (@ (class "template") (id "popup-template"))
+ ;; ;; ,(popup event (string-append "popup" (html-id event))))
+ ;; ))
+
+ ;;; Templates used by our custom components
+ ,((@ (calp html vcomponent) edit-template) calendars)
+ ,((@ (calp html vcomponent) description-template))
+ ,((@ (calp html vcomponent) vevent-edit-rrule-template))
+ ,((@ (calp html vcomponent) popup-template))
;; Auto-complets when adding new fields to a component
;; Any string is however still valid.
@@ -344,4 +353,59 @@
RDATE RRULE ACTION REPEAT
TRIGGER CREATED DTSTAMP LAST-MODIFIED
SEQUENCE REQUEST-STATUS
- ))))))
+ )))
+
+ ,@(let* (
+ (flat-events
+ ;; A simple filter-sorted-stream on event-overlaps? here fails.
+ ;; See tests/annoying-events.scm
+ (stream->list
+ (stream-filter
+ (lambda (ev)
+ ((@ (vcomponent datetime) event-overlaps?)
+ ev pre-start
+ (date+ post-end (date day: 1))))
+ (stream-take-while (lambda (ev) (date<
+ (as-date (prop ev 'DTSTART))
+ (date+ post-end (date day: 1))))
+ events))))
+ (repeating% regular (partition repeating? flat-events))
+ (repeating
+ (for ev in repeating%
+ (define instance (copy-vcomponent ev))
+
+ (set! (prop instance 'UID) (output-uid instance))
+ (delete-parameter! (prop* instance 'DTSTART) '-X-HNH-ORIGINAL)
+ (delete-parameter! (prop* instance 'DTEND) '-X-HNH-ORIGINAL)
+
+ instance)))
+
+ `(
+ ;; Mapping showing which events belongs to which calendar,
+ ;; on the form
+ ;; (calendar (@ (key ,(base64-encode calendar-name)))
+ ;; (li ,event-uid) ...)
+ (div (@ (style "display:none !important;")
+ (id "calendar-event-mapping"))
+ ,(let ((ht (make-hash-table)))
+ (for-each (lambda (event)
+ (define name (prop (parent event) 'NAME))
+ (hash-set! ht name
+ (cons (prop event 'UID)
+ (hash-ref ht name '()))))
+ (append regular repeating))
+
+ (hash-map->list
+ (lambda (key values)
+ `(calendar (@ (key ,(base64encode key)))
+ ,@(map (lambda (uid) `(li ,uid))
+ values)))
+ ht)))
+
+ ;; Calendar data for all events in current interval,
+ ;; rendered as xcal.
+ (div (@ (style "display:none !important;")
+ (id "xcal-data"))
+ ,((@ (vcomponent xcal output) ns-wrap)
+ (map (@ (vcomponent xcal output) vcomponent->sxcal)
+ (append regular repeating)))))))))
diff --git a/module/calp/html/view/calendar/month.scm b/module/calp/html/view/calendar/month.scm
index 0ac69292..02689fd5 100644
--- a/module/calp/html/view/calendar/month.scm
+++ b/module/calp/html/view/calendar/month.scm
@@ -11,7 +11,7 @@
:select (really-long-event?
events-between))
:use-module ((calp html vcomponent)
- :select (make-block))
+ :select (make-block output-uid))
:use-module ((vcomponent group)
:select (group-stream get-groups-between))
)
@@ -35,7 +35,7 @@
(events-between s e (list->stream long-events)))))
(date-range pre-start post-end (date day: 7))))
- `((script "const VIEW='month';")
+ `((script "window.VIEW='month';")
(header (@ (class "table-head"))
,(string-titlecase (date->string start-date "~B ~Y")))
(div (@ (class "caltable")
@@ -77,11 +77,26 @@
(repeating-naturals 1 7)
)))
- ;; These popups are relative the document root. Can thus be placed anywhere in the DOM.
+ ;; These popups are relative the document root.
+ ;; Can thus be placed anywhere in the DOM.
,@(for event in (stream->list
- (events-between start-date end-date events))
- ((@ (calp html vcomponent) popup) event
- (string-append "popup" ((@ (calp html util) html-id) event))))
+ (events-between pre-start post-end events))
+ `(popup-element
+ (@ (class "vevent")
+ (data-uid ,(output-uid event)))))
+
+ (template
+ (@ (id "vevent-block"))
+ ;; TODO this is more or less copied verbatim from week's
+ ;; version, warts and all. Figure out what should and shouldn't
+ ;; be shared between the two.
+ (div (@ (data-calendar "unknown"))
+ (div (@ (class "event-body"))
+ (span (@ (class "repeating")))
+ (span (@ (class "summary")
+ (data-property "summary")))
+ (span (@ (class "location")
+ (data-property "location"))))))
))
diff --git a/module/calp/html/view/calendar/week.scm b/module/calp/html/view/calendar/week.scm
index 556c3d85..499de1d6 100644
--- a/module/calp/html/view/calendar/week.scm
+++ b/module/calp/html/view/calendar/week.scm
@@ -2,6 +2,7 @@
:use-module (calp util)
:use-module (srfi srfi-1)
:use-module (srfi srfi-41)
+ :use-module (rnrs records syntactic)
:use-module (datetime)
:use-module (calp html view calendar shared)
:use-module (calp html config)
@@ -13,16 +14,18 @@
event-zero-length?
events-between))
:use-module ((calp html vcomponent)
- :select (make-block) )
+ :select (make-block output-uid) )
+ ;; :use-module ((calp html components)
+ ;; :select ())
:use-module ((vcomponent group)
:select (group-stream get-groups-between))
)
-(define*-public (render-calendar key: events start-date end-date #:allow-other-keys)
+(define*-public (render-calendar key: calendars events start-date end-date #:allow-other-keys)
(let* ((long-events short-events (partition long-event? (stream->list (events-between start-date end-date events))))
(range (date-range start-date end-date)))
- `((script "const VIEW='week';")
+ `((script "window.VIEW='week';")
(div (@ (class "calendar"))
(div (@ (class "days"))
;; Top left area
@@ -52,10 +55,54 @@
,@(for event in (stream->list
(events-between start-date end-date events))
- ((@ (calp html vcomponent ) popup) event (string-append "popup" (html-id event))))
-
- )))))
-
+ `(popup-element
+ (@ (class "vevent")
+ (data-uid ,(output-uid event)))))))
+
+
+ ;; This template is here, instead of in (calp html calendar) since it only
+ ;; applies to this specific view. (calp html calendar month) is assumed to
+ ;; have its own variant of it.
+ (template (@ (id "vevent-block"))
+ ,(block-template)
+ )
+
+
+)))
+
+
+;; "physical" block
+(define (block-template)
+ `(div (@ ; (id ,(html-id ev))
+ (data-calendar "unknown")
+ #;
+ (class " CAL_unknown"
+ ;; ,(when (and (prop ev 'PARTSTAT)
+ ;; (eq? 'TENTATIVE (prop ev 'PARTSTAT)))
+ ;; " tentative")
+ ;; ,(when (and (prop ev 'TRANSP)
+ ;; (eq? 'TRANSPARENT (prop ev 'TRANSP)))
+ ;; " transparent")
+ )
+ ; (onclick "toggle_popup('popup' + this.id)")
+ )
+ ;; Inner div to prevent overflow. Previously "overflow: none"
+ ;; was set on the surounding div, but the popup /needs/ to
+ ;; overflow (for the tabs?).
+ (div (@ (class "event-body"))
+ (span (@ (class "repeating")) ; "↺"
+ )
+ (span (@ (class "summary")
+ (data-property "summary"))
+ ; ,(format-summary ev (prop ev 'SUMMARY))
+ )
+ (span (@ (class "location")
+ (data-property "location")))
+ ;; Document symbol when we have text
+ (span (@ (class "description"))
+ ; "🗎"
+ ))
+ ) )
(define (time-marker-div)
diff --git a/module/calp/server/routes.scm b/module/calp/server/routes.scm
index b024ed4f..08e48714 100644
--- a/module/calp/server/routes.scm
+++ b/module/calp/server/routes.scm
@@ -20,7 +20,7 @@
:use-module ((rnrs io ports) :select (get-bytevector-all))
:use-module ((xdg basedir) :prefix xdg-)
- :use-module ((calp html util) :select (html-unattr))
+ :use-module ((base64) :select (base64decode))
:use-module (web http make-routes)
@@ -58,7 +58,12 @@
[else "🙃"]))
(td (a (@ (href "/" ,dir "/" ,k)) ,k))
(td ,(number->string (stat:perms stat) 8)))))
- (cdr (scandir dir))))))
+ (cdr (or (scandir dir)
+ (scm-error
+ 'misc-error
+ "directory-table"
+ "Scandir argument invalid or not directory: ~a"
+ (list dir) '())))))))
@@ -162,8 +167,7 @@
(format #f "No event with UID '~a'" uid))))
;; TODO this fails when dtstart is <date>.
- ;; @var{cal} should be the name of the calendar encoded with
- ;; modified base64. See (calp html util).
+ ;; @var{cal} should be the name of the calendar encoded in base64.
(POST "/insert" (cal data)
(unless (and cal data)
@@ -174,7 +178,7 @@
;; NOTE that this leaks which calendar exists,
;; but you can only query for existance.
;; also, the calendar view already show all calendars.
- (let* ((calendar-name (html-unattr cal))
+ (let* ((calendar-name (base64decode cal))
(calendar
(find (lambda (c) (string=? calendar-name (prop c 'NAME)))
(get-calendars global-event-object))))
diff --git a/module/calp/util.scm b/module/calp/util.scm
index 06767658..96ca2f01 100644
--- a/module/calp/util.scm
+++ b/module/calp/util.scm
@@ -9,7 +9,6 @@
set/r!
catch-multiple
quote?
- re-export-modules
-> ->> set set-> aif awhen
let-lazy let-env
case* define-many
@@ -298,19 +297,10 @@
(define-public (as-symb s)
(if (string? s) (string->symbol s) s))
-
-
(define-public (enumerate lst)
(zip (iota (length lst))
lst))
-;; Map with index
-(define-syntax-rule (map-each proc lst)
- (map (lambda (x i) (proc x i))
- lst (iota (length lst))))
-
-(export map-each)
-
;; Takes a procedure returning multiple values, and returns a function which
;; takes the same arguments as the original procedure, but only returns one of
;; the procedures. Which procedure can be sent as an additional parameter.
@@ -339,14 +329,6 @@
(cons (proc (car dotted-list))
(map/dotted proc (cdr dotted-list))))))
-(define-syntax re-export-modules
- (syntax-rules ()
- ((_ (mod ...) ...)
- (begin
- (module-use! (module-public-interface (current-module))
- (resolve-interface '(mod ...)))
- ...))))
-
;; Merges two association lists, comparing with eq.
;; The cdrs in all pairs in both lists should be lists,
;; If a key is present in both then the contents of b is
@@ -380,7 +362,7 @@
;; NOTE changing this list to cons allows the output to work with assq-merge.
(hash-map->list list h)))
-;; (group-by '(0 1 2 3 4 2 5 6) 2)
+;; (split-by '(0 1 2 3 4 2 5 6) 2)
;; ⇒ ((0 1) (3 4) (5 6))
(define-public (split-by list item)
(let loop ((done '())
@@ -523,6 +505,21 @@
(call-with-values (lambda () (apply proc args)) list))
lists)))
+(define (ass%-ref-all alist key =)
+ (map cdr (filter (lambda (pair) (= key (car pair)))
+ alist)))
+
+;; Equivalent to assoc-ref (and family), but works on association lists with
+;; non-unique keys, returning all mathing records (instead of just the first).
+;; @begin lisp
+;; (assoc-ref-all '((a . 1) (b . 2) (a . 3)) 'a)
+;; ⇒ (1 3)
+;; @end
+(define-public (assoc-ref-all alist key) (ass%-ref-all alist key equal?))
+(define-public (assq-ref-all alist key) (ass%-ref-all alist key eq?))
+(define-public (assv-ref-all alist key) (ass%-ref-all alist key eqv?))
+
+
(define-public (vector-last v)
@@ -536,6 +533,10 @@
(define-public (->quoted-string any)
(with-output-to-string (lambda () (write any))))
+
+
+
+;; TODO shouldn't this use `file-name-separator-string'?
(define-public (path-append . strings)
(fold (lambda (s done)
(string-append
@@ -554,6 +555,7 @@
+;;; TODO shouldn't this use dynamic-wind? To handle non-local exits?
(define-syntax let-env
(syntax-rules ()
[(_ ((name value) ...)
diff --git a/module/datetime.scm b/module/datetime.scm
index c1fae3ce..50817084 100644
--- a/module/datetime.scm
+++ b/module/datetime.scm
@@ -73,7 +73,8 @@
(catch 'misc-error
(lambda () (display (date->string r "#~Y-~m-~d") p))
(lambda (err _ fmt args . rest)
- (format p "BAD~s-~s-~s" (year r) (month r) (day r))))))
+ (format p "#<<date> BAD year=~s month=~s day=~s>"
+ (year r) (month r) (day r))))))
;;; TIME
@@ -91,8 +92,8 @@
(lambda (r p)
(catch 'misc-error
(lambda () (display (time->string r "#~H:~M:~S") p))
- (lambda (err _ fmt args rest)
- (format p "BAD~s:~s:~s"
+ (lambda (err _ fmt args rest)
+ (format p "#<<time> hour=~s minute=~s second=~s>"
(hour r) (minute r) (second r))))))
@@ -124,9 +125,14 @@
(set-record-type-printer!
<datetime>
(lambda (r p)
- (if (and (tz r) (not (string=? "UTC" (tz r))))
- (write (datetime->sexp r) p)
- (display (datetime->string r "#~1T~3~Z") p))))
+ (catch 'misc-error
+ (lambda ()
+ (if (and (tz r) (not (string=? "UTC" (tz r))))
+ (write (datetime->sexp r) p)
+ (display (datetime->string r "#~1T~3~Z") p)))
+ (lambda (err _ fmt args . rest)
+ (format p "#<<datetime> BAD date=~s time=~s tz=~s>"
+ (get-date r) (get-time% r) (tz r))))))
diff --git a/module/vcomponent.scm b/module/vcomponent.scm
index a53523c0..226b740f 100644
--- a/module/vcomponent.scm
+++ b/module/vcomponent.scm
@@ -7,8 +7,10 @@
:re-export (make-vcomponent
parse-cal-path parse-calendar))
-(re-export-modules (vcomponent base)
- (vcomponent instance methods))
+(define cm (module-public-interface (current-module)))
+(module-use! cm (resolve-interface '(vcomponent base)))
+(module-use! cm (resolve-interface '(vcomponent instance methods)))
+
(define-config calendar-files '()
description: "Which files to parse. Takes a list of paths or a single string which will be globbed."
diff --git a/module/vcomponent/base.scm b/module/vcomponent/base.scm
index 9066b257..ab2121a2 100644
--- a/module/vcomponent/base.scm
+++ b/module/vcomponent/base.scm
@@ -110,6 +110,11 @@
get-prop*
set-prop*!))
+(define-public (delete-property! component key)
+ (hashq-remove! (get-component-properties component)
+ (as-symb key)))
+
+
;; vcomponent x (or str symb) → value
(define (get-prop component key)
(let ((props (get-prop* component key)))
@@ -139,6 +144,12 @@
(hashq-set! (get-vline-parameters vline)
(as-symb parameter-key) val))))
+
+(define-public (delete-parameter! vline parameter-key)
+ (hashq-remove! (get-vline-parameters vline)
+ (as-symb parameter-key)))
+
+
;; Returns the parameters of a property as an assoc list.
;; @code{(map car <>)} leads to available parameters.
(define-public (parameters vline)
diff --git a/module/vcomponent/recurrence/generate.scm b/module/vcomponent/recurrence/generate.scm
index 3b0f7083..1d262202 100644
--- a/module/vcomponent/recurrence/generate.scm
+++ b/module/vcomponent/recurrence/generate.scm
@@ -364,9 +364,9 @@
#f))
+;; <vevent> -> (stream <vevent>)
(define-public (generate-recurrence-set base-event)
-
(define duration
;; NOTE DTEND is an optional field.
(let ((end (prop base-event 'DTEND)))
diff --git a/module/vcomponent/vdir/parse.scm b/module/vcomponent/vdir/parse.scm
index 7b10af07..6bbd1329 100644
--- a/module/vcomponent/vdir/parse.scm
+++ b/module/vcomponent/vdir/parse.scm
@@ -25,6 +25,7 @@
;; themselves. Therefore, a simple comparison should work,
;; and then the TZOFFSETTO properties can be subtd.
(define-public (parse-vdir path)
+ ;; TODO empty files here cause "#<eof>" to appear in the output XML, which is *really* bad.
(let ((color
(catch 'system-error
(lambda () (call-with-input-file (path-append path "color") read-line))
diff --git a/module/vcomponent/vdir/save-delete.scm b/module/vcomponent/vdir/save-delete.scm
index d17b595e..b3c7f9c5 100644
--- a/module/vcomponent/vdir/save-delete.scm
+++ b/module/vcomponent/vdir/save-delete.scm
@@ -11,7 +11,7 @@
(define-module (vcomponent vdir save-delete)
:use-module (calp util)
- :use-module ((calp util exceptions) :select (assert))
+ :use-module ((calp util exceptions) :select (assert))
:use-module (vcomponent ical output)
:use-module (vcomponent)
:use-module ((calp util io) :select (with-atomic-output-to-file))
diff --git a/module/vcomponent/xcal/output.scm b/module/vcomponent/xcal/output.scm
index 692b3ec2..70288cba 100644
--- a/module/vcomponent/xcal/output.scm
+++ b/module/vcomponent/xcal/output.scm
@@ -121,7 +121,10 @@
,(vline->value-tag vline)))])
(properties component))))
(unless (null? props)
- `(properties ,@props)))
+ `(properties
+ ;; NOTE
+ ;; (x-hnh-calendar-name (text ,(prop (parent component) 'NAME)))
+ ,@props)))
,(unless (null? (children component))
`(components ,@(map vcomponent->sxcal (children component)))))))
diff --git a/module/vcomponent/xcal/parse.scm b/module/vcomponent/xcal/parse.scm
index 124a91f4..c6a2122f 100644
--- a/module/vcomponent/xcal/parse.scm
+++ b/module/vcomponent/xcal/parse.scm
@@ -49,40 +49,68 @@
((@ (vcomponent duration) parse-duration) duration))])]
[(recur)
- (apply (@ (vcomponent recurrence internal) make-recur-rule)
- (concatenate
- (for (k v) in value
- (list (symbol->keyword k)
- (case k
- ((wkst)
- ((@ (vcomponent recurrence parse)
- rfc->datetime-weekday)
- (string->symbol v)))
- ((freq) (string->symbol v))
- ((until)
- ;; RFC 6321 (xcal), p. 30 specifies type-until as
- ;; type-until = element until {
- ;; type-date |
- ;; type-date-time
- ;; }
- ;; but doesn't bother defining type-date[-time]...
- ;; This is acknowledged in errata 3315 [1], but
- ;; it lacks a solution...
- ;; Seeing as RFC 7265 (jcal) in Example 2 (p. 16)
- ;; show the date as a direct string we will roll
- ;; with that here to.
- ;; [1]: https://www.rfc-editor.org/errata/eid3315
- (string->date/-time v))
- ((byday) #|TODO|#
- (throw 'not-yet-implemented))
- ((count interval bysecond bymunite byhour
- bymonthday byyearday byweekno
- bymonth bysetpos)
- (string->number v))
- (else (throw
- 'key-error
- "Invalid key ~a, with value ~a"
- k v)))))))]
+ ;; RFC6221 (xcal) Appendix A 3.3.10 specifies that all components should
+ ;; come in a specified order, and by extension that all components of the
+ ;; same type should follow each other. Actually checking that is harder
+ ;; than to just accept anything in any order. It would also make us less
+ ;; robust for other implementations with other ideas.
+ (let ((parse-value-of-that-type
+ (lambda (type value)
+ (case type
+ ((wkst)
+ ((@ (vcomponent recurrence parse)
+ rfc->datetime-weekday)
+ (string->symbol value)))
+ ((freq) (string->symbol value))
+ ((until)
+ ;; RFC 6321 (xcal), p. 30 specifies type-until as
+ ;; type-until = element until {
+ ;; type-date |
+ ;; type-date-time
+ ;; }
+ ;; but doesn't bother defining type-date[-time]...
+ ;; This is acknowledged in errata 3315 [1], but
+ ;; it lacks a solution...
+ ;; Seeing as RFC 7265 (jcal) in Example 2 (p. 16)
+ ;; show the date as a direct string we will roll
+ ;; with that here to.
+ ;; [1]: https://www.rfc-editor.org/errata/eid3315
+ (string->date/-time value))
+ ((byday) ((@@ (vcomponent recurrence parse) parse-day-spec) value))
+ ((count interval bysecond bymunite byhour
+ bymonthday byyearday byweekno
+ bymonth bysetpos)
+ (string->number value))
+ (else (throw
+ 'key-error
+ "Invalid type ~a, with value ~a"
+ type value))))))
+
+ ;; freq until count interval wkst
+
+ (apply (@ (vcomponent recurrence internal) make-recur-rule)
+ (concatenate
+ (filter identity
+ (for key in '(bysecond byminute byhour byday bymonthday
+ byyearday byweekno bymonth bysetpos
+ freq until count interval wkst)
+ (define values (assoc-ref-all value key))
+ (if (null? values)
+ #f
+ (case key
+ ;; These fields all have zero or one value
+ ((freq until count interval wkst)
+ (list (symbol->keyword key)
+ (parse-value-of-that-type
+ key (car (map car values)))))
+ ;; these fields take lists
+ ((bysecond byminute byhour byday bymonthday
+ byyearday byweekno bymonth bysetpos)
+ (list (symbol->keyword key)
+ (map (lambda (v) (parse-value-of-that-type key v))
+ (map car values)))
+ )
+ (else (throw 'error)))))))))]
[(time) (parse-iso-time (car value))]
@@ -171,11 +199,25 @@
;; ignore empty fields
;; mostly for <text/>
(unless (null? value)
- (set! (prop* component tag*)
- (make-vline tag*
- (handle-tag
- tag (handle-value type params value))
- params)))))]
+ (let ()
+ (define vline
+ (make-vline tag*
+ (handle-tag
+ tag (handle-value type params value))
+ params))
+ (if (memv tag* '(ATTACH ATTENDEE CATEGORIES
+ COMMENT CONTACT EXDATE
+ REQUEST-STATUS RELATED-TO
+ RESOURCES RDATE
+ ;; x-prop
+ ;; iana-prop
+ ))
+ (aif (prop* component tag*)
+ (set! (prop* component tag*) (cons vline it))
+ (set! (prop* component tag*) (list vline)))
+ ;; else
+ (set! (prop* component tag*) vline))
+ ))))]
[(tag (type value ...) ...)
(for (type value) in (zip type value)
@@ -184,7 +226,7 @@
(unless (null? value)
(let ((params (make-hash-table))
(tag* (symbol-upcase tag)))
- (set! (prop* component tag*)
+ (define vline
(make-vline tag*
(handle-tag
tag (let ((v (handle-value type params value)))
@@ -192,7 +234,22 @@
(if (eq? tag 'categories)
(string-split v #\,)
v)))
- params)))))])))
+ params))
+ ;;
+
+ (if (memv tag* '(ATTACH ATTENDEE CATEGORIES
+ COMMENT CONTACT EXDATE
+ REQUEST-STATUS RELATED-TO
+ RESOURCES RDATE
+ ;; x-prop
+ ;; iana-prop
+ ))
+ (aif (prop* component tag*)
+ (set! (prop* component tag*) (cons vline it))
+ (set! (prop* component tag*) (list vline)))
+ ;; else
+ (set! (prop* component tag*) vline))
+ )))])))
;; children
(awhen (assoc-ref sxcal 'components)
diff --git a/module/vulgar/termios.scm b/module/vulgar/termios.scm
index 75181ff3..f88882c9 100644
--- a/module/vulgar/termios.scm
+++ b/module/vulgar/termios.scm
@@ -102,7 +102,7 @@
;; Macro for creating accessor bindings for slots in a list, which are wrapped
;; inside a <termios> record. Called exactly once below.
(define-macro (create-bindings! . symbols)
- `(begin ,@(map-each
+ `(begin ,@(map
(lambda (symb i)
`(define-public ,symb
(make-procedure-with-setter
@@ -110,7 +110,8 @@
(lambda (t v) (let ((lst (as-list t)))
(list-set! lst ,i v)
(set-list! t lst))))))
- symbols)))
+ symbols
+ (iota (length symbols)))))
(create-bindings! ; accessors
iflag oflag cflag lflag line cc ispeed ospeed)
diff --git a/start b/start
new file mode 100755
index 00000000..b8d48cc4
--- /dev/null
+++ b/start
@@ -0,0 +1,28 @@
+#!/bin/sh
+
+# Simple script for starting the HTTP server in debug mode
+# on some apropriate port.
+# Only built for development use.
+find_port() {
+ for p in "$@"; do
+ echo 2>/dev/null >/dev/tcp/localhost/$p
+ if [ $? -eq 1 ]; then
+ echo $p
+ return
+ fi
+ done
+ echo "No port available"
+ exit 1
+}
+
+port=`find_port {8080..9000}`
+
+echo "Starting on $port"
+
+$(dirname $(realpath $0))/main \
+ -o debug=#t \
+ -o edit-mode=#t \
+ --repl=$XDG_RUNTIME_DIR/calp \
+ server \
+ --port "$port" \
+ --sigusr
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)
+}
diff --git a/tests/annoying-events.scm b/tests/annoying-events.scm
new file mode 100644
index 00000000..ba93b9c9
--- /dev/null
+++ b/tests/annoying-events.scm
@@ -0,0 +1,59 @@
+(((srfi srfi-41 util) filter-sorted-stream)
+ ((srfi srfi-41) stream stream->list stream-filter stream-take-while)
+ ((vcomponent base) extract prop make-vcomponent)
+ ((vcomponent datetime) event-overlaps?)
+ ((datetime) date date+ date<)
+ ((calp util) set!))
+
+(define* (event key: summary dtstart dtend)
+ (define ev (make-vcomponent 'VEVENT))
+ (set! (prop ev 'SUMMARY) summary
+ (prop ev 'DTSTART) dtstart
+ (prop ev 'DTEND) dtend)
+ ev)
+
+(define start #2021-11-01)
+(define end (date+ start (date day: 8)))
+
+(define ev-set
+ (stream
+ (event ; should be part of the result
+ summary: "A"
+ dtstart: #2021-10-01
+ dtend: #2021-12-01)
+ (event ; should NOT be part of the result
+ summary: "B"
+ dtstart: #2021-10-10
+ dtend: #2021-10-11)
+ (event ; should also be part of the result
+ summary: "C"
+ dtstart: #2021-11-02
+ dtend: #2021-11-03)))
+
+;; (if (and (date< (prop ev 'DTSTART) start-date)
+;; (date<= (prop ev 'DTEND) end-date))
+;; ;; event will be picked, but next event might have
+;; (and (date< start-date (prop ev 'DTSTART))
+;; (date< end-date (prop ev 'DTEND)))
+;; ;; meaning that it wont be added, stopping filter-sorted-stream
+;; )
+
+;; The naïve way to get all events in an interval. Misses C due to B being "in the way"
+
+(test-equal "incorrect handling of non-contigious"
+ '("A" #; "C")
+ (map (extract 'SUMMARY)
+ (stream->list
+ (filter-sorted-stream
+ (lambda (ev) (event-overlaps? ev start (date+ start (date day: 8))))
+ ev-set))))
+
+;; A correct way
+
+(test-equal "correct handling of non-contigious"
+ '("A" "C")
+ (map (extract 'SUMMARY)
+ (stream->list
+ (stream-filter (lambda (ev) (event-overlaps? ev start end))
+ (stream-take-while (lambda (ev) (date< (prop ev 'DTSTART) end))
+ ev-set)))))
diff --git a/tests/recurrence-simple.scm b/tests/recurrence-simple.scm
index 166fa349..bbe6dd9d 100644
--- a/tests/recurrence-simple.scm
+++ b/tests/recurrence-simple.scm
@@ -9,7 +9,7 @@
((vcomponent base) extract prop)
((calp util exceptions) warnings-are-errors warning-handler)
- ((guile) format)
+ ((guile) format @@)
((vcomponent) parse-calendar)
((vcomponent xcal parse) sxcal->vcomponent)
@@ -242,6 +242,13 @@ END:VCALENDAR"
;;; Earlier I failed to actually parse the recurrence parts, in short, 1 ≠ "1".
+(test-assert "Test that xcal recur rules are parseable"
+ ((@@ (vcomponent xcal parse) handle-value)
+ 'recur 'props-are-unused-for-recur
+ '((freq "WEEKLY")
+ (interval "1")
+ (wkst "MO"))))
+
(define ev
(sxcal->vcomponent
'(vevent
@@ -260,3 +267,21 @@ END:VCALENDAR"
(test-assert
"Check that recurrence rule commint from xcal also works"
(generate-recurrence-set ev))
+
+;;; TODO test here, for byday parsing, and multiple byday instances in one recur element
+;;; TODO which should also test serializing and deserializing to xcal.
+;;; For example, the following rules specify every workday
+
+;; BEGIN:VCALENDAR
+;; PRODID:-//hugo//calp 0.6.1//EN
+;; VERSION:2.0
+;; CALSCALE:GREGORIAN
+;; BEGIN:VEVENT
+;; SUMMARY:Lunch
+;; DTSTART:20211129T133000
+;; DTEND:20211129T150000
+;; LAST-MODIFIED:20211204T220944Z
+;; UID:3d82c73c-6cdb-4799-beba-5f1d20d55347
+;; RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR
+;; END:VEVENT
+;; END:VCALENDAR