aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHugo Hörnquist <hugo@lysator.liu.se>2020-11-05 23:45:48 +0100
committerHugo Hörnquist <hugo@lysator.liu.se>2020-11-05 23:45:48 +0100
commit0e429de91e57c2445df4fdf2227f65af3e396d9c (patch)
tree9151ec03d773a70944e363855c5b9c296e6fce17
parentFix tidsrapport --output flag. (diff)
parentAdd comment about freeform fields. (diff)
downloadcalp-0e429de91e57c2445df4fdf2227f65af3e396d9c.tar.gz
calp-0e429de91e57c2445df4fdf2227f65af3e396d9c.tar.xz
Merge branch 'front'
-rw-r--r--config.scm13
-rw-r--r--module/calp/html/components.scm50
-rw-r--r--module/calp/html/vcomponent.scm179
-rw-r--r--module/calp/html/view/calendar.scm27
-rw-r--r--module/calp/main.scm63
-rw-r--r--module/calp/server/routes.scm66
-rw-r--r--module/calp/util/config.scm4
-rw-r--r--module/vcomponent/base.scm8
-rw-r--r--module/vcomponent/ical/output.scm8
-rw-r--r--module/vcomponent/ical/parse.scm2
-rw-r--r--module/vcomponent/search.scm18
-rw-r--r--module/vcomponent/xcal/parse.scm17
-rw-r--r--module/web/http/make-routes.scm7
-rw-r--r--static/input_list.js77
-rw-r--r--static/lib.js33
-rw-r--r--static/script.js619
-rw-r--r--static/style.scss88
-rw-r--r--static/types.js98
18 files changed, 1056 insertions, 321 deletions
diff --git a/config.scm b/config.scm
index 4092ecb3..cb5779f4 100644
--- a/config.scm
+++ b/config.scm
@@ -2,22 +2,13 @@
;;; Currently loaded by main, and requires that `calendar-files`
;;; is set to a list of files (or directories).
-(use-modules (vcomponent))
-
-(use-modules (srfi srfi-88)
- (ice-9 regex)
- ;; (ice-9 rdelim)
+(use-modules (ice-9 regex)
(sxml simple)
- (glob)
-
- (calp util config)
-
- (datetime)
;; TODO this module introduces description-filter. It should be
;; possible to use set-config! before the declaration point is
;; known. But I currently get a config error.
- (vcomponent datetime output)
+ ;; (vcomponent datetime output)
)
(set-config! 'calendar-files (glob "~/.local/var/cal/*"))
diff --git a/module/calp/html/components.scm b/module/calp/html/components.scm
index ebc359b8..03e1cef1 100644
--- a/module/calp/html/components.scm
+++ b/module/calp/html/components.scm
@@ -1,6 +1,8 @@
(define-module (calp html components)
:use-module (calp util)
:use-module (calp util exceptions)
+ :use-module (ice-9 curried-definitions)
+ :use-module (ice-9 match)
:export (xhtml-doc)
)
@@ -112,6 +114,54 @@
,key)
(div (@ (class "content")) ,body)))))
+(define ((set-attribute attr) el)
+ (match el
+ [(tagname ('@ params ...) inner-body ...)
+ `(,tagname (@ ,@(assq-merge params attr))
+ ,@inner-body)]
+ [(tagname inner-body ...)
+ `(,tagname (@ ,attr)
+ ,@inner-body)]))
+
+
+(define-public (with-label lbl . forms)
+
+ (define id (gensym "label"))
+
+ (cons `(label (@ (for ,id)) ,lbl)
+ (let recurse ((forms forms))
+ (map (lambda (form)
+ (cond [(not (list? form)) form]
+ [(null? form) '()]
+ [(eq? 'input (car form))
+ ((set-attribute `((id ,id))) form)]
+ [(list? (car form))
+ (cons (recurse (car form))
+ (recurse (cdr form)))]
+ [else
+ (cons (car form)
+ (recurse (cdr form)))]))
+ forms))))
+
+
+(define-public (form elements)
+ `(form
+ ,@(map (label self
+ (lambda (el)
+ (match el
+ ((name ('@ tags ...) body ...)
+ (let ((id (gensym "formelement")))
+ (cons
+ `(label (@ (for ,id)) ,name)
+ (map
+ (set-attribute `((name ,name)))
+ (cons
+ ((set-attribute `((id ,id))) (car body))
+ (cdr body))))))
+ ((name body ...)
+ (self `(,name (@) ,@body))))))
+ elements)))
+
(define-public (include-css path . extra-attributes)
`(link (@ (type "text/css")
diff --git a/module/calp/html/vcomponent.scm b/module/calp/html/vcomponent.scm
index c4e15374..fbf344b0 100644
--- a/module/calp/html/vcomponent.scm
+++ b/module/calp/html/vcomponent.scm
@@ -5,9 +5,10 @@
:use-module (srfi srfi-41)
:use-module (datetime)
:use-module ((text util) :select (add-enumeration-punctuation))
+ :use-module ((web uri-query) :select (encode-query-parameters))
:use-module (calp html util)
:use-module ((calp html config) :select (edit-mode))
- :use-module ((calp html components) :select (btn tabset))
+ :use-module ((calp html components) :select (btn tabset form with-label))
:use-module ((calp util color) :select (calculate-fg-color))
:use-module ((vcomponent datetime output)
:select (fmt-time-span
@@ -56,18 +57,21 @@
;; (format (current-error-port) "fmt-single-event: ~a~%" (prop ev 'X-HNH-FILENAME))
`(div (@ ,@(assq-merge
attributes
- `((class " eventtext "
+ `((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 "summary")) ,(prop ev 'SUMMARY))))
+ `(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 "dtstart")
+ `(div (time (@ (class "bind dtstart")
+ (data-property "dtstart")
(data-fmt ,(string-append "~L" start))
(datetime ,(datetime->string
(as-datetime (prop ev 'DTSTART))
@@ -76,7 +80,8 @@
(as-datetime (prop ev 'DTSTART))
start)))]
[(start end)
- `(div (time (@ (class "dtstart")
+ `(div (time (@ (class "bind dtstart")
+ (data-property "dtstart")
(data-fmt ,(string-append "~L" start))
(datetime ,(datetime->string
(as-datetime (prop ev 'DTSTART))
@@ -84,31 +89,170 @@
,(datetime->string (as-datetime (prop ev 'DTSTART))
start))
" — "
- (time (@ (class "dtend")
+ (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 "location"))
+ (div (@ (class "bind location") (data-property "location"))
,(string-map (lambda (c) (if (char=? c #\,) #\newline c))
(prop ev 'LOCATION)))))
,(awhen (prop ev 'DESCRIPTION)
- `(span (@ (class "description"))
+ `(div (@ (class "bind description")
+ (data-property "description"))
,(format-description ev it)))
+
+ ;; 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
+ ,(->quoted-string c)
+ (or (prop event 'CATEGORIES)
+ '())))))))
+ ,c))
+ it)))
+
+ ;; TODO bind
,(awhen (prop ev 'RRULE)
- `(span (@ (class "rrule"))
- ,@(format-recurrence-rule ev)))
+ `(div (@ (class "rrule"))
+ ,@(format-recurrence-rule ev)))
+
,(when (prop ev 'LAST-MODIFIED)
- `(span (@ (class "last-modified")) "Senast ändrad "
- ,(datetime->string (prop ev 'LAST-MODIFIED) "~1 ~H:~M"))))
+ `(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 "))
+ (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"))
+
+ (input (@ (type "date")
+ (name "dtstart-date")
+ (style "grid-column:1;grid-row:2")
+ (class "bind")
+ (data-property "--dtstart-date")
+ (value ,(date->string (as-date start)))))
+
+ (input (@ (type "date")
+ (name "dtend-date")
+ (style "grid-column:1;grid-row:3")
+ (class "bind")
+ (data-property "--dtend-date")
+ ,@(when end `((value ,(date->string (as-date end)))))))
+
+ ,@(with-label
+ "Heldag?"
+ `(input (@ (type "checkbox") (style "display:none")
+ (name "wholeday"))))
+
+ (input (@ (type "time")
+ (name "dtstart-time")
+ (class "bind")
+ (data-property "--dtstart-time")
+ (style "grid-column:3;grid-row:2;"
+ ,(when (date? start) "display:none"))
+ (value ,(time->string (as-time start)))))
+
+ (input (@ (type "time")
+ (name "dtend-time")
+ (class "bind")
+ (data-property "--dtend-time")
+ (style "grid-column:3;grid-row:3;"
+ ,(when (date? end) "display:none"))
+ ,@(when end `((value ,(time->string (as-time end)))))
+ ))))
+
+ ,@(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)
+
+
+ (input (@ (type "submit")))
+ )))
+
;; Single event in side bar (text objects)
(define-public (fmt-day day)
@@ -176,10 +320,12 @@
(div (@ (class "event-body"))
,(when (prop ev 'RRULE)
`(span (@ (class "repeating")) "↺"))
- (span (@ (class "summary"))
+ (span (@ (class "bind summary")
+ (data-property "summary"))
,(format-summary ev (prop ev 'SUMMARY)))
,(when (prop ev 'LOCATION)
- `(span (@ (class "location"))
+ `(span (@ (class "bind location")
+ (data-property "location"))
,(string-map (lambda (c) (if (char=? c #\,) #\newline c))
(prop ev 'LOCATION)))))
(div (@ (style "display:none !important;"))
@@ -238,6 +384,10 @@
,(tabset
`(("📅" title: "Översikt"
,(fmt-single-event ev))
+
+ ("📅" title: "Redigera"
+ ,(fmt-for-edit ev))
+
("⤓" title: "Nedladdning"
(div (@ (class "eventtext") (style "font-family:sans"))
(h2 "Ladda ner")
@@ -245,6 +395,7 @@
"som iCal"))
(li (a (@ (href "/calendar/" ,(prop ev 'UID) ".xcs"))
"som xCal")))))
+
,@(when (prop ev 'RRULE)
`(("↺" title: "Upprepningar" class: "repeating"
,(repeat-info ev)))))))))
diff --git a/module/calp/html/view/calendar.scm b/module/calp/html/view/calendar.scm
index a583d82b..0e90e5d4 100644
--- a/module/calp/html/view/calendar.scm
+++ b/module/calp/html/view/calendar.scm
@@ -104,7 +104,9 @@
,(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/input_list.js")))
(script (@ (defer) (src "/static/script.js")))
,(calendar-styles calendars))
@@ -296,4 +298,27 @@
;; 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)))))))))
+ ,(popup event (string-append "popup" (html-id event))))))
+
+ ;; Auto-complets when adding new fields to a component
+ ;; Any string is however still valid.
+ (datalist (@ (id "known-fields"))
+ ,@(map (lambda (f)
+ `(option (@ (value ,f))))
+ '(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 EXDATE
+ RDATE RRULE ACTION REPEAT
+ TRIGGER CREATED DTSTAMP LAST-MODIFIED
+ SEQUENCE REQUEST-STATUS
+ ))))))
diff --git a/module/calp/main.scm b/module/calp/main.scm
index 33da1554..c93ae795 100644
--- a/module/calp/main.scm
+++ b/module/calp/main.scm
@@ -15,6 +15,8 @@
:use-module (ice-9 getopt-long)
:use-module (ice-9 regex)
:use-module ((ice-9 popen) :select (open-input-pipe))
+ :use-module ((ice-9 sandbox) :select
+ (make-sandbox-module all-pure-and-impure-bindings))
:use-module (statprof)
:use-module (calp repl)
@@ -99,13 +101,26 @@
(if (null? a)
b a))
-
(define (wrapped-main args)
(define opts (getopt-long args (getopt-opt options) #:stop-at-first-non-option #t))
(define stprof (option-ref opts 'statprof #f))
(define repl (option-ref opts 'repl #f))
(define altconfig (option-ref opts 'config #f))
+ (define config-file
+ (cond [altconfig
+ (if (file-exists? altconfig)
+ altconfig
+ (throw 'option-error
+ "Configuration file ~a missing" altconfig))]
+ ;; altconfig could be placed in the list below. But I want to raise an error
+ ;; if an explicitly given config is missing.
+ [(find file-exists?
+ (list
+ (path-append (xdg-config-home) "/calp/config.scm")
+ (path-append (xdg-sysconfdir) "/calp/config.scm")))
+ => identity]))
+
(when stprof (statprof-start))
(cond [(eqv? #t repl) (repl-start (format #f "~a/calp-~a"
@@ -113,18 +128,40 @@
(getpid)))]
[repl => repl-start])
- (if altconfig
- (begin
- (if (file-exists? altconfig)
- (primitive-load altconfig)
- (throw 'option-error "Configuration file ~a missing" altconfig)))
- ;; if not altconfig, then regular config
-
- (awhen (find file-exists?
- (list
- (path-append (xdg-config-home) "/calp/config.scm")
- (path-append (xdg-sysconfdir) "/calp/config.scm")))
- (primitive-load it)))
+
+ ;; Load config
+ ;; Sandbox and "stuff" not for security from the user. The config script is
+ ;; assumed to be "safe". Instead it's so we can control the environment in
+ ;; which it is executed.
+ (catch #t
+ (lambda ()
+ (eval
+ `(begin
+ (use-modules (srfi srfi-1)
+ (srfi srfi-88)
+ (datetime)
+ (vcomponent)
+ (calp util config)
+ (glob))
+ ,@(with-input-from-file config-file
+ (lambda ()
+ (let loop ((done '()))
+ (let ((form (read)))
+ (if (eof-object? form)
+ (reverse done)
+ (loop (cons form done))))))))
+ (make-sandbox-module
+ `(((guile) use-modules)
+ ,@all-pure-and-impure-bindings
+ ))
+ ))
+ (lambda args
+ (format (current-error-port)
+ "Failed loading config file ~a~%~s~%"
+ config-file
+ args
+ )))
+
;; NOTE this doesn't stop at first non-option, meaning that -o flags
diff --git a/module/calp/server/routes.scm b/module/calp/server/routes.scm
index 184b4481..276513f5 100644
--- a/module/calp/server/routes.scm
+++ b/module/calp/server/routes.scm
@@ -218,55 +218,43 @@
[(get-event-by-uid global-event-object (prop event 'UID))
=> (lambda (old-event)
- ;; procedure to run after save.
- ;; used as hook to remove old event from disk below
- (define after-save (const #f))
-
- (if (eq? calendar (parent old-event))
- (begin (vcomponent-update! old-event event)
- ;; for save below
- (set! event old-event))
-
- ;; change calendar
- (begin
-
- (format (current-error-port)
- "Calendar change~%")
-
- ;; remove from runtime
- ((@ (vcomponent instance methods) remove-event)
- global-event-object old-event)
-
- ;; Actually puring the old event should be safe,
- ;; since we first make sure we write the new event to disk.
- ;; Currently the whole transaction isn't atomic, so a duplicate
- ;; event can still be created.
- (set! after-save
- ;; remove from disk
- (lambda ()
- (format (current-error-port)
- "Unlinking old event from ~a~%"
- (prop old-event '-X-HNH-FILENAME))
- ((@ (vcomponent vdir save-delete) remove-event) old-event)))
-
- (parameterize ((warnings-are-errors #t))
- (catch 'warning
- (lambda () (add-event global-event-object calendar event))
- (lambda (err fmt args)
- (return (build-response code: 400)
- (format #f "~?~%" fmt args)))))))
+ ;; remove old instance of event from runtime
+ ((@ (vcomponent instance methods) remove-event)
+ global-event-object old-event)
+
+ ;; Add new event to runtime,
+ ;; MUST be done after since the two events SHOULD share UID.
+ (parameterize ((warnings-are-errors #t))
+ (catch 'warning
+ (lambda () (add-event global-event-object calendar event))
+ (lambda (err fmt args)
+ (return (build-response code: 400)
+ (format #f "~?~%" fmt args)))))
(set! (prop event 'LAST-MODIFIED)
(current-datetime))
-
;; NOTE Posibly defer save to a later point.
;; That would allow better asyncronous preformance.
+
+ ;; save-event sets -X-HNH-FILENAME from the UID. This is fine
+ ;; since the two events are guaranteed to have the same UID.
(unless ((@ (vcomponent vdir save-delete) save-event) event)
(return (build-response code: 500)
"Saving event to disk failed."))
- (after-save)
+
+ (unless (eq? calendar (parent old-event))
+ ;; change to a new calendar
+ (format (current-error-port)
+ "Unlinking old event from ~a~%"
+ (prop old-event '-X-HNH-FILENAME))
+ ;; NOTE that this may fail, leading to a duplicate event being
+ ;; created (since we save beforehand). This is just a minor problem
+ ;; which either a better atomic model, or a propper error
+ ;; recovery log would solve.
+ ((@ (vcomponent vdir save-delete) remove-event) old-event))
+
(format (current-error-port)
"Event updated ~a~%" (prop event 'UID)))]
diff --git a/module/calp/util/config.scm b/module/calp/util/config.scm
index 32dabb69..fbe35d59 100644
--- a/module/calp/util/config.scm
+++ b/module/calp/util/config.scm
@@ -106,10 +106,6 @@
(export format-procedure)
-(define (->str any)
- (with-output-to-string
- (lambda () (display any))))
-
(define-public (get-configuration-documentation)
(define groups
(group-by (compose source-module car)
diff --git a/module/vcomponent/base.scm b/module/vcomponent/base.scm
index ae10fe01..34d4416b 100644
--- a/module/vcomponent/base.scm
+++ b/module/vcomponent/base.scm
@@ -169,14 +169,6 @@
(copy-vline value))))
(get-component-properties component)))))
-;; updates target with all fields from source.
-;; fields in target but not in source left unchanged.
-;; parent and children unchanged
-(define-public (vcomponent-update! target source)
- (for key in (property-keys source)
- (set! (prop* target key)
- (prop* source key))))
-
(define-public (extract field)
(lambda (e) (prop e field)))
diff --git a/module/vcomponent/ical/output.scm b/module/vcomponent/ical/output.scm
index a0816679..bcc6bb1d 100644
--- a/module/vcomponent/ical/output.scm
+++ b/module/vcomponent/ical/output.scm
@@ -44,12 +44,16 @@
[(memv key '(FREEBUSY))
(get-writer 'PERIOD)]
+ [(memv key '(CATEGORIES RESOURCES))
+ (lambda (p v)
+ (string-join (map (lambda (v) ((get-writer 'TEXT) p v))
+ v)
+ ","))]
+
[(memv key '(CALSCALE METHOD PRODID COMMENT DESCRIPTION
LOCATION SUMMARY TZID TZNAME
CONTACT RELATED-TO UID
- CATEGORIES RESOURCES
-
VERSION))
(get-writer 'TEXT)]
diff --git a/module/vcomponent/ical/parse.scm b/module/vcomponent/ical/parse.scm
index 9c555bca..8499d289 100644
--- a/module/vcomponent/ical/parse.scm
+++ b/module/vcomponent/ical/parse.scm
@@ -135,7 +135,7 @@
(let ((v ((get-parser 'TEXT) params value)))
(unless (= 1 (length v))
(warning "List in non-list field: ~s" v))
- (car v)))]
+ (string-join v ",")))]
;; TEXT, but allow a list
[(memv key '(CATEGORIES RESOURCES))
diff --git a/module/vcomponent/search.scm b/module/vcomponent/search.scm
index a402bd49..a850fb40 100644
--- a/module/vcomponent/search.scm
+++ b/module/vcomponent/search.scm
@@ -52,6 +52,13 @@
(define-public (prepare-string str)
(call-with-input-string (close-parenthese str) read))
+;; TODO place this in a proper module
+(define (bindings-for module-name)
+ ;; Wrapping list so we can later export sub-modules.
+ (list (cons module-name
+ (module-map (lambda (a . _) a)
+ (resolve-interface module-name)))))
+
;; Evaluates the given expression in a sandbox.
;; NOTE Should maybe be merged inte prepare-query. The argument against is that
;; eval-in-sandbox is possibly slow, and that would prevent easy caching by the
@@ -63,9 +70,9 @@
(eval `(lambda (event) ,@expressions)
(make-sandbox-module
`(
- ((vcomponent base) prop param children type)
+ ((vcomponent base) prop param children type parent)
((ice-9 regex) string-match)
- ;; TODO datetime
+ ,@(bindings-for '(datetime))
,@all-pure-bindings)
)))
@@ -155,8 +162,11 @@
(set-max-page! paginator (max page (get-max-page paginator)))
result))))
(lambda (err proc fmt args data)
- ;; TODO ensure the error actually is index out of range.
- ;; (format (current-error-port) "~?~%" fmt args)
+ ;; NOTE This is mostly a hack to see that we
+ ;; actually check for the correct error.
+ (unless (string=? fmt "beyond end of stream")
+ (scm-error err proc fmt args data))
+
(set-max-page! paginator (get-max-page paginator))
(set-true-max-page! paginator)
(throw 'max-page (get-max-page paginator))
diff --git a/module/vcomponent/xcal/parse.scm b/module/vcomponent/xcal/parse.scm
index 17c684fc..6b877b9f 100644
--- a/module/vcomponent/xcal/parse.scm
+++ b/module/vcomponent/xcal/parse.scm
@@ -25,7 +25,10 @@
;; TODO possibly trim whitespace on text fields
[(cal-address uri text unknown) (car value)]
- [(date) (parse-iso-date (car value))]
+ [(date)
+ ;; TODO this is correct, but ensure remaining types
+ (hashq-set! props 'VALUE "DATE")
+ (parse-iso-date (car value))]
[(date-time) (parse-iso-datetime (car value))]
@@ -108,6 +111,12 @@
data '(AUDIO DISPLAY EMAIL NONE)))
[else data]))
+;; Note
+;; This doesn't verify the inter-field validity of the object,
+;; meaning that value(DTSTART) == DATE and value(DTEND) == DATE-TIME
+;; are possibilities, which other parts of the code will crash on.
+;; TODO
+;; since we are feeding user input into this it really should be fixed.
(define-public (sxcal->vcomponent sxcal)
(define type (symbol-upcase (car sxcal)))
(define component (make-vcomponent type))
@@ -147,7 +156,11 @@
(set! (prop* component tag*)
(make-vline tag*
(handle-tag
- tag (handle-value type params value))
+ tag (let ((v (handle-value type params value)))
+ ;; TODO possibly more list fields
+ (if (eq? tag 'categories)
+ (string-split v #\,)
+ v)))
params)))))])))
;; children
diff --git a/module/web/http/make-routes.scm b/module/web/http/make-routes.scm
index ab5f88a7..4fb5397a 100644
--- a/module/web/http/make-routes.scm
+++ b/module/web/http/make-routes.scm
@@ -71,6 +71,8 @@
(r:port ((@ (web request) request-port) request)))
(let ((r:scheme ((@ (web uri) uri-scheme) r:uri))
(r:userinfo ((@ (web uri) uri-userinfo) r:uri))
+ ;; TODO can sometimes be a pair of host and port
+ ;; '("localhost" . 8080). It shouldn't...
(r:host (or ((@ (web uri) uri-host) r:uri)
((@ (web request) request-host)
request)))
@@ -80,6 +82,11 @@
(r:path ((@ (web uri) uri-path) r:uri))
(r:query ((@ (web uri) uri-query) r:uri))
(r:fragment ((@ (web uri) uri-fragment) r:uri)))
+ ;; TODO propper logging
+ (display (format #f "[~a] ~a ~a/~a?~a~%"
+ (datetime->string (current-datetime))
+ r:method r:host r:path (or r:query ""))
+ (current-error-port))
(call-with-values
(lambda ()
((@ (ice-9 control) call/ec)
diff --git a/static/input_list.js b/static/input_list.js
new file mode 100644
index 00000000..b15162d6
--- /dev/null
+++ b/static/input_list.js
@@ -0,0 +1,77 @@
+/*
+ TODO document 'input-list'.
+
+ ∀ children('.input-list') => 'unit' ∈ classList(child)
+
+ <div class="input-list">
+ <div class="unit"><input/></div>
+ <div class="unit final"><input/></div>
+ </div>
+
+*/
+
+
+function transferListeners(old_unit, new_unit) {
+ for (let [o, n] of zip([old_unit, ...old_unit.querySelectorAll("*")],
+ [new_unit, ...new_unit.querySelectorAll("*")])) {
+ for (const key in o.listeners) {
+ if (! o.listeners.hasOwnProperty(key)) continue;
+ for (let proc of o.listeners[key]) {
+ n.addEventListener(key, proc);
+ }
+ }
+ }
+}
+
+
+function advance_final(input_list) {
+ let old_unit = input_list.unit;
+ let new_unit = old_unit.cloneNode(true);
+ new_unit.classList.add('final');
+ transferListeners(old_unit, new_unit);
+ input_list.appendChild(new_unit);
+}
+
+
+
+function update_inline_list () {
+
+ /* can target self */
+ let unit = this.closest('.unit');
+
+ let lst = this.closest('.input-list');
+
+ if (unit.classList.contains("final")) {
+ if (this.value !== '') {
+ unit.classList.remove('final');
+ advance_final(lst);
+ }
+ } else {
+ /* TODO all significant fields empty, instead of just current */
+ if (this.value === '') {
+ let sibling = unit.previousElementSibling || unit.nextElementSibling;
+ unit.remove();
+ if (sibling.tagName !== 'input')
+ sibling = sibling.querySelector('input');
+ sibling.focus();
+ }
+ }
+}
+
+/* run this from window.onload (or similar) */
+function init_input_list() {
+
+ for (let lst of document.getElementsByClassName('input-list')) {
+
+ for (let el of lst.getElementsByTagName('input')) {
+ el.addEventListener('input', update_inline_list);
+ }
+
+ let oldUnit = lst.querySelector('.final.unit')
+ let unit = oldUnit.cloneNode(true);
+
+ transferListeners(oldUnit, unit);
+
+ lst.unit = unit;
+ }
+}
diff --git a/static/lib.js b/static/lib.js
index 3c11e23f..ab279353 100644
--- a/static/lib.js
+++ b/static/lib.js
@@ -3,6 +3,25 @@
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 ---------------------------- */
/*
@@ -131,4 +150,18 @@ function format_date(date, str) {
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/script.js b/static/script.js
index ce57e5e1..60011cd8 100644
--- a/static/script.js
+++ b/static/script.js
@@ -10,6 +10,9 @@ let parser = new DOMParser();
let start_time = new Date();
let end_time = new Date();
+/*
+ Given the navbar of a popup, make it dragable.
+ */
function bind_popup_control (nav) {
nav.onmousedown = function (e) {
/* Ignore mousedown on children */
@@ -36,20 +39,6 @@ function bind_popup_control (nav) {
});
}
-/*
- * Finds the first element of the DOMTokenList whichs value matches
- * the supplied regexp. Returns a pair of the index and the value.
- */
-DOMTokenList.prototype.find = function (regexp) {
- let entries = this.entries();
- let entry;
- while (! (entry = entries.next()).done) {
- if (entry.value[1].match(regexp)) {
- return entry.value;
- }
- }
-}
-
class EventCreator {
/* dynamicly created event when dragging */
@@ -61,8 +50,15 @@ class EventCreator {
}
create_empty_event () {
- let event = document.getElementById("event-template").firstChild.cloneNode(true);
- let popup = document.getElementById("popup-template").firstChild.cloneNode(true);
+ let event = document.getElementById("event-template")
+ .firstChild.cloneNode(true);
+ let popup = document.getElementById("popup-template")
+ .firstChild.cloneNode(true);
+
+ popup.getElementsByClassName("edit-form")[0].onsubmit = function () {
+ create_event(event);
+ return false; /* stop default */
+ }
let id = gensym ("__js_event");
@@ -156,7 +152,7 @@ class EventCreator {
bind_properties(event, wide_element);
/* requires that dtstart and dtend properties are initialized */
- place_in_edit_mode(event);
+ // place_in_edit_mode(event);
/* ---------------------------------------- */
@@ -217,6 +213,8 @@ class EventCreator {
e.style.pointerEvents = "";
}
+ place_in_edit_mode(that.event);
+
let localevent = that.event;
that.event = null;
@@ -287,12 +285,6 @@ function update_current_time_bar () {
= (new Date).format("~Y-~m-~d") + ".html";
}
-function close_all_popups () {
- for (let popup of document.querySelectorAll(".popup-container.visible")) {
- close_popup(popup);
- }
-}
-
async function create_event (event) {
let xml = event.getElementsByTagName("icalendar")[0].outerHTML
@@ -318,7 +310,7 @@ async function create_event (event) {
let body = await response.text();
- /* servere is assumed to return an XML document on the form
+ /* server is assumed to return an XML document on the form
<properties>
**xcal property** ...
</properties>
@@ -345,142 +337,30 @@ async function create_event (event) {
toggle_popup("popup" + event.id);
}
+/* 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)
- function replace_with_time_input(fieldname, event) {
- let field = popup.getElementsByClassName(fieldname)[0];
-
- let dt = new Date(field.dateTime);
-
- let dateinput = makeElement ('input', {
- type: 'date',
- required: true,
- value: dt.format("~Y-~m-~d"),
-
- onchange: function (e) {
- /* Only update datetime when the input is filled out */
- if (! this.value) return;
- let [year, month, day] = this.value.split("-").map(Number);
- /* retain the hour and second information */
- let d = copyDate(event.properties[fieldname]);
- d.setYear(year);
- d.setMonth(month - 1);
- d.setDate(day);
- event.properties[fieldname] = d;
- }
- });
-
- let timeinput = makeElement ('input', {
- type: "time",
- required: true,
- value: dt.format("~H:~M"),
-
- onchange: function (e) {
- /* Only update datetime when the input is filled out */
- if (! this.value) return;
- let [hour, minute] = this.value.split(":").map(Number);
- /* retain the year, month, and day information */
- let d = copyDate(event.properties[fieldname]);
- d.setHours(hour);
- d.setMinutes(minute);
- event.properties[fieldname] = d;
- }
- });
- let slot = get_property(event, fieldname);
- let idx = slot.findIndex(e => e[0] === field);
- slot.splice(idx, 1, [timeinput, (s, v) => s.value = v.format("~H:~M")])
- slot.splice(idx, 0, [dateinput, (s, v) => s.value = v.format("~Y-~m-~d")])
-
- field.innerHTML = '';
- field.appendChild(dateinput);
- field.appendChild(timeinput);
- // field.replaceWith(timeinput);
-
- }
-
- /* TODO ensure dtstart < dtend */
- replace_with_time_input("dtstart", event);
- replace_with_time_input("dtend", event);
-
- /* ---------------------------------------- */
-
- let summary = popup.getElementsByClassName("summary")[0];
- let input = makeElement('input', {
- name: "summary",
- value: summary.innerText,
- placeholder: "Sammanfattning",
- required: true,
- });
-
- input.oninput = function () {
- event.properties["summary"] = this.value;
- }
-
- let slot = get_property(event, "summary");
- let idx = slot.findIndex(e => e[0] === summary);
- slot.splice(idx, 1, [input, (s, v) => s.value = v])
-
- summary.replaceWith(input);
-
- /* ---------------------------------------- */
-
- /* TODO add elements if the arent't already there
- * Almost all should be direct children of '.event-body' (or
- * '.eventtext'?).
- * Biggest problem is generated fields relative order.
- */
- {
- let descs = popup.getElementsByClassName("description");
- let description;
- if (descs.length === 1) {
- description = descs[0];
- } else {
- let fields = popup.getElementsByClassName("fields")[0]
- description = makeElement('span', {
- class: 'description',
- });
- fields.appendChild(description);
- let slot = get_property(event, "description");
- slot.push([description, (s, v) => s.innerHTML = v]);
- }
-
- let textarea = makeElement('textarea', {
- name: "description",
- placeholder: "Description (optional)",
- innerHTML: description.innerText,
- required: false,
- });
-
- textarea.oninput = function () {
- event.properties["description"] = this.value;
- }
-
- let slot = get_property(event, "description");
- let idx = slot.findIndex(e => e[0] === description);
- slot.splice(idx, 1, [input, (s, v) => s.innerHTML = v])
-
- description.replaceWith(textarea);
- }
-
- /* ---------------------------------------- */
-
- let evtext = popup.getElementsByClassName('eventtext')[0]
+ let 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;
- }
+ 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
@@ -489,32 +369,12 @@ function place_in_edit_mode (event) {
calendar_dropdown.onchange = function () {
event.properties.calendar = this.value;
}
- evtext.prepend(calendar_dropdown);
-
- /* ---------------------------------------- */
-
- let submit = makeElement( 'input', {
- type: 'submit',
- value: 'Skapa event',
- });
-
- let article = popup.getElementsByClassName("eventtext")[0];
- article.appendChild(submit);
-
-
- let wrappingForm = makeElement('form', {
- onsubmit: function (e) {
- create_event(event);
- return false;
- }});
- article.replaceWith(wrappingForm);
- wrappingForm.appendChild(article);
-
- /* this is for existing events.
- * Newly created events aren't in the DOM tree yet, and can
- * therefore not yet be focused */
- input.focus();
+ 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 () {
@@ -522,7 +382,6 @@ window.onload = function () {
end_time.setTime(document.querySelector("meta[name='end-time']").content * 1000)
update_current_time_bar()
- // once a minute for now, could probably be slowed to every 10 minutes
window.setInterval(update_current_time_bar, 1000 * 60)
/* Is event creation active? */
@@ -575,6 +434,13 @@ window.onload = function () {
*/
el.parentElement.removeAttribute("href");
+ /* TODO this doesn't yet apply to newly created events */
+ 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")) {
bind_properties(el, true);
@@ -623,15 +489,212 @@ window.onload = function () {
serializer.serializeToString(xml);
*/
+ /* 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.querySelectorAll('input')]
+ .map(x => x.value)
+ .filter(x => x != '')
+ .join(',');
+
+ };
+
+ for (let inp of lst.querySelectorAll('input')) {
+ inp.addEventListener('input', f);
+ }
+ }
+
+
+ /* ---------------------------------------- */
+
+
+ for (let el of document.getElementsByClassName("newfield")) {
+ let [name, type_selector, value_field] = el.children;
+
+ /* TODO list fields */
+ /* TODO add and remove fields. See update_inline_list */
+
+ function update_value_field (el) {
+ let [name_field, type_selector, value_field] = el.children;
+
+
+ let value = makeElement('input');
+ let values = [value];
+
+
+ switch (name_field.value.toUpperCase()) {
+ case 'GEO':
+ value.type = 'number';
+ values.push(makeElement('input', {
+ type: 'number',
+ }));
+ break;
+
+ case 'CLASS':
+ // Add auto completion
+ break;
+
+ case 'ACTION':
+ // Add auto completion
+ break;
+
+ case 'TRANSP':
+ // Replace with toggle betwen OPAQUE and TRANSPARENT
+ break;
+
+ case 'PERCENT-COMPLETE':
+ value.min = 0;
+ value.max = 100;
+ break;
+
+ case 'PRIORITY':
+ value.min = 0;
+ value.max = 9;
+ break;
+
+ default:
+
+
+ switch (type_selector.options[type_selector.selectedIndex].value) {
+ case 'integer':
+ case 'float':
+ value.type = 'number';
+ break;
+
+ case 'uri':
+ value.type = 'url';
+ break;
+
+ case 'binary':
+ value.type = 'file';
+ break;
+
+ case 'date-time':
+ values.push(makeElement('input', {
+ type: 'time',
+ }));
+ /* fallthrough */
+ case 'date':
+ value.type = 'date';
+ break;
+
+ case 'cal-address':
+ value.type = 'email';
+ break;
+
+ case 'utc-offset':
+ value.type = 'time';
+ let lbl = makeElement('label');
+ let id = gensym();
+
+ lbl.setAttribute('for', id);
+
+ /* TODO make these labels stand out more */
+ lbl.appendChild(makeElement('span', {
+ className: 'plus',
+ innerText: '+',
+ }));
+ lbl.appendChild(makeElement('span', {
+ className: 'minus',
+ innerText: '-',
+ }));
+ values.splice(0,0,lbl);
+ values.splice(0,0, makeElement('input', {
+ type: 'checkbox',
+ style: 'display:none',
+ className: 'plusminuscheck',
+ id: id,
+ }));
+ break;
+
+ case 'boolean':
+ value.type = 'checkbox';
+ break;
+
+ case 'period':
+ value.type = 'text';
+ // TODO validate /P\d*H/ typ
+ break;
+
+ case 'recur':
+ // TODO
+ default:
+ value.type = 'text';
+ }
+ }
+
+
+ value_field.innerHTML = '';
+ for (let v of values) {
+ console.log(v);
+ value_field.appendChild(v);
+ }
+ }
+
+ name.addEventListener('input', function setOptionDropdown () {
+ let types = valid_input_types[this.value.toUpperCase()];
+ let el = this.parentElement;
+ let [_, type_selector, value_field] = el.children;
+
+ type_selector.disabled = false;
+ if (types) {
+ type_selector.innerHTML = '';
+ for (let type of types) {
+ type_selector.appendChild(
+ makeElement('option', { value: type, innerText: type }))
+ }
+ if (types.length == 1) {
+ type_selector.disabled = true;
+ }
+ } else {
+ type_selector.innerHTML = '';
+ for (let type of all_types) {
+ type_selector.appendChild(
+ makeElement('option', { value: type, innerText: type }))
+ }
+ }
+
+ update_value_field(el);
+ });
+
+ type_selector.addEventListener('change', function () {
+ update_value_field(this.parentElement);
+ });
+ }
+
+
+
+ init_input_list();
+
+}
+
+function event_from_popup(popup) {
+ return document.getElementById(popup.id.substr(5))
+}
+
+function popup_from_event(event) {
+ return document.getElementById("popup" + event.id);
}
function close_popup(popup) {
popup.classList.remove("visible");
}
+function close_all_popups () {
+ for (let popup of document.querySelectorAll(".popup-container.visible")) {
+ close_popup(popup);
+ }
+}
+
function open_popup(popup) {
popup.classList.add("visible");
- let element = document.getElementById(popup.id.substr(5))
+ let element = event_from_popup(popup);
// let root = document.body;
let root;
switch (VIEW) {
@@ -674,7 +737,7 @@ function toggle_popup(popup_id) {
default_value - default value when creating
bind_to_ical - should this property be added to the icalendar subtree?
*/
-function get_property(el, field, default_value, bind_to_ical=true) {
+function get_property(el, field, default_value) {
if (! el.properties) {
el.properties = {};
}
@@ -698,24 +761,29 @@ function get_property(el, field, default_value, bind_to_ical=true) {
});
}
- if (bind_to_ical) {
- let ical_properties = el.querySelector("icalendar vevent properties");
- if (! ical_properties.querySelector(field)) {
- let text = document.createElementNS(xcal, 'text');
- let element = document.createElementNS(xcal, field);
- element.appendChild(text);
- if (default_value) {text.innerHTML = default_value;}
+ return el.properties["_slot_" + field];
+}
+
- ical_properties.appendChild(element);
- el.properties["_slot_" + field].push(
- [text, (s, v) => s.innerHTML = v]);
+
+/*
+class display_tab {
+}
+
+class edit_tab {
+}
+
+class vcomponent {
+ set_value(field, value) {
+ if (value === '') {
+ remove_property(field);
}
}
-
- return el.properties["_slot_" + field];
}
+*/
+
/*
Properties are icalendar properties.
@@ -728,39 +796,155 @@ function get_property(el, field, default_value, bind_to_ical=true) {
and binds the value to the slot.
*/
function bind_properties (el, wide_event=false) {
+
el.properties = {}
- let popup = document.getElementById("popup" + el.id);
- let children = el.getElementsByTagName("properties")[0].children;
+ let popup = popup_from_event(el);
+ // let children = el.getElementsByTagName("properties")[0].children;
- for (let child of children) {
- let field = child.tagName;
+ /* actual component (not popup) */
+ for (let e of el.querySelectorAll(".bind")) {
+ let f = ((s, v) => s.innerHTML = v.format(s.dataset && s.dataset.fmt));
+ get_property(el, e.dataset.property).push([e, f]);
+ }
- let lst = get_property(el, field);
+ /* primary display tab */
+
+ for (let e of popup.querySelectorAll(".summary-tab .bind")) {
+ let f = (s, v) => s.innerHTML = v.format(s.dataset && s.dataset.fmt);
+ get_property(el, e.dataset.property).push([e, f]);
+ }
- /* Bind HTML fields for this event */
- for (let s of el.getElementsByClassName(field)) {
- let f = ((s, v) => s.innerHTML = v.format(s.dataset && s.dataset.fmt));
- lst.push([s, f]);
+ /* edit tab */
+ for (let e of popup.querySelectorAll(".edit-tab .bind")) {
+ let p = get_property(el, 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.innerHTML = v;
+ p.push([e, f])
+ break;
+ default:
+ alert("How did you get here??? " + e.tagName)
+ break;
}
- for (let s of popup.getElementsByClassName(field)) {
- let f = ((s, v) => s.innerHTML = v.format(s.dataset && s.dataset.fmt));
- lst.push([s, f]);
+ }
+
+ /* checkbox for whole day */
+ 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 d = el.properties[f];
+ if (! d) continue; /* dtend optional */
+ d.isWholeDay = wholeday.checked;
+ el.properties[f] = d;
+ }
+ });
+
+
+ for (let field of ['dtstart', 'dtend']) {
+
+ get_property(el, `--${field}-time`).push(
+ [el, (el, v) => { let date = el.properties[field];
+ if (v == '') return;
+ let [h,m,s] = v.split(':')
+ date.setHours(Number(h));
+ date.setMinutes(Number(m));
+ date.setSeconds(0);
+ el.properties[field] = date; }])
+ get_property(el, `--${field}-date`).push(
+ [el, (el, v) => { let date = el.properties[field];
+ if (v == '') return;
+ let [y,m,d] = v.split('-')
+ date.setYear(Number(y)/* - 1900*/);
+ date.setMonth(Number(m) - 1);
+ date.setDate(d);
+ el.properties[field] = date; }])
+
+
+ /* Manual fetch of the fields instead of the general method,
+ to avoid an infinite loop of dtstart setting --dtstart-time,
+ and vice versa.
+ NOTE if many more fields require special treatment then a
+ general solution is required.
+ */
+ get_property(el, field).push(
+ [el, (el, v) => { popup
+ .querySelector(`.edit-tab input[name='${field}-time']`)
+ .value = v.format("~H:~M");
+ popup
+ .querySelector(`.edit-tab input[name='${field}-date']`)
+ .value = v.format("~Y-~m-~d");
+ }]);
+ }
+
+
+ /* 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);
+
/* Bind vcomponent fields for this event */
- for (let s of el.querySelectorAll(field + " > :not(parameters)")) {
- switch (s.tagName) {
- case 'date':
- lst.push([s, (s, v) => s.innerHTML = v.format("~Y-~m-~d")]); break;
- case 'date-time':
- lst.push([s, (s, v) => s.innerHTML = v.format("~Y-~m-~dT~H:~M:~S~Z")]); break;
- default:
- lst.push([s, (s, v) => s.innerHTML = v]);
- }
+ for (let s of el.querySelectorAll(`${field} > :not(parameters)`)) {
+ lst.push([s, (s, v) => {
+ if (v instanceof Date) {
+ if (v.isWholeDay) {
+ let str = v.format('~Y-~m-~d');
+ child.innerHTML = `<date>${str}</date>`;
+ } else {
+ let str = v.format('~Y-~m-~dT~H:~M:00~Z');
+ child.innerHTML = `<date-time>${str}</date-time>`;
+ }
+ } else {
+ /* assume that type already is correct */
+ s.innerHTML = v;
+ }
+ }]);
el.properties["_value_" + field] = s.innerHTML;
}
}
+ /* Dynamicly add or remove the <location/> and <description/> elements
+ from the <vevent><properties/> list.
+
+ TODO generalize this to all fields, /especially/ those which are
+ dynamicly added.
+ */
+ for (let field of ['location', 'description', 'categories']) {
+ get_property(el, field).push(
+ [el.querySelector('vevent > properties'),
+ (s, v) => {
+ let slot = s.querySelector(field);
+ if (v === '' && slot) {
+ slot.remove();
+ } else {
+ if (! slot) {
+ /* finns det verkligen inget bättre sätt... */
+ s.innerHTML += `<${field}><text/></${field}>`;
+ }
+ s.querySelector(`${field} > text`).innerHTML = v;
+ }
+ }]);
+ }
+
+ /* set up graphical display changes */
let container = el.closest(".event-container");
if (container === null) {
console.log("No enclosing event container for", el);
@@ -793,7 +977,7 @@ function bind_properties (el, wide_event=false) {
el.dataset.calendar = "Unknown";
}
- let calprop = get_property(el, 'calendar', el.dataset.calendar, false);
+ let calprop = get_property(el, 'calendar', el.dataset.calendar);
const rplcs = (s, v) => {
let [_, calclass] = s.classList.find(/^CAL_/);
@@ -804,4 +988,7 @@ function bind_properties (el, wide_event=false) {
calprop.push([el, rplcs]);
calprop.push([el, (s, v) => s.dataset.calendar = v]);
+
+
+ /* ---------- Calendar ------------------------------ */
}
diff --git a/static/style.scss b/static/style.scss
index 33f55f81..954f84fe 100644
--- a/static/style.scss
+++ b/static/style.scss
@@ -549,6 +549,7 @@ along with their colors.
font-style: italic;
white-space: pre;
}
+
}
.event-body {
@@ -694,13 +695,13 @@ along with their colors.
/* overflow-y: auto; */
max-width: 60ch;
max-height: 60ch;
- min-width: 40ch;
- min-height: 20ch;
+ min-width: 60ch;
+ min-height: 30ch;
&-container {
display: none;
position: absolute;
- z-index: 10;
+ z-index: 1000;
/* ??? */
left: 10px;
@@ -735,6 +736,11 @@ along with their colors.
font-style: initial;
}
+ .category {
+ display: inline-block;
+ margin-right: 1ex;
+ }
+
.popup-control {
display: flex;
flex-direction: column;
@@ -786,7 +792,7 @@ along with their colors.
}
.tab {
- label {
+ > label {
position: absolute;
left: 100%;
@@ -810,13 +816,13 @@ along with their colors.
[type=radio] {
display: none;
&:checked ~ label {
- z-index: 1;
+ z-index: 100;
/* to align all tab */
border-left: 3px solid transparent;
background-color: #dedede;
~ .content {
- z-index: 1;
+ z-index: 100;
}
}
}
@@ -833,6 +839,76 @@ along with their colors.
min-width: 100%;
min-height: 100%;
}
+
+
+ .edit-form {
+ label {
+ display: block;
+ }
+
+ /* REG */
+ input[type='text'], textarea {
+ width: 100%;
+ }
+
+ .newfield {
+ width: 100%;
+ display: flex;
+ }
+
+ .timeinput {
+
+ display: grid;
+ grid-template-columns: 1fr [lbl-start] 1ch 1fr 1ch [lbl-end];
+ grid-template-rows: [lbl-start] 0.7fr 1fr 1fr 0.3fr [lbl-end];
+
+ label {
+ background-color: rgba(10,20,30,0.7);
+ color: white;
+ border-radius: 1em;
+
+ grid-column: lbl-start / lbl-end;
+ grid-row: lbl-start / lbl-end;
+
+ text-align: center;
+
+ user-select: none;
+
+ z-index: 1;
+
+ }
+
+ input {
+ z-index: 2;
+ }
+
+ input:checked ~ input {
+ z-index: 0;
+ }
+ }
+ }
+
+
+}
+
+.plusminuschecked label {
+ color: black;
+}
+
+.plusminuscheck:checked ~ label .plus {
+ color: green;
+}
+
+.plusminuscheck:not(:checked) ~ label .minus {
+ color: red;
+}
+
+.inline-edit {
+ input {
+ /* important since regular spec is much stronger...*/
+ /* [REG] */
+ width: initial !important;
+ }
}
/* Other
diff --git a/static/types.js b/static/types.js
new file mode 100644
index 00000000..cfed8584
--- /dev/null
+++ b/static/types.js
@@ -0,0 +1,98 @@
+
+let all_types = [
+ 'text',
+ 'uri',
+ 'binary',
+ 'float',
+ 'integer',
+ 'date-time',
+ 'date',
+ 'duration',
+ 'period',
+ 'utc-offset',
+ 'cal-address',
+ 'recur',
+ 'boolean',
+]
+
+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'],
+}