diff options
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) @@ -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
|