diff options
author | Hugo Hörnquist <hugo@lysator.liu.se> | 2020-11-05 23:45:48 +0100 |
---|---|---|
committer | Hugo Hörnquist <hugo@lysator.liu.se> | 2020-11-05 23:45:48 +0100 |
commit | 0e429de91e57c2445df4fdf2227f65af3e396d9c (patch) | |
tree | 9151ec03d773a70944e363855c5b9c296e6fce17 | |
parent | Fix tidsrapport --output flag. (diff) | |
parent | Add comment about freeform fields. (diff) | |
download | calp-0e429de91e57c2445df4fdf2227f65af3e396d9c.tar.gz calp-0e429de91e57c2445df4fdf2227f65af3e396d9c.tar.xz |
Merge branch 'front'
Diffstat (limited to '')
-rw-r--r-- | config.scm | 13 | ||||
-rw-r--r-- | module/calp/html/components.scm | 50 | ||||
-rw-r--r-- | module/calp/html/vcomponent.scm | 179 | ||||
-rw-r--r-- | module/calp/html/view/calendar.scm | 27 | ||||
-rw-r--r-- | module/calp/main.scm | 63 | ||||
-rw-r--r-- | module/calp/server/routes.scm | 66 | ||||
-rw-r--r-- | module/calp/util/config.scm | 4 | ||||
-rw-r--r-- | module/vcomponent/base.scm | 8 | ||||
-rw-r--r-- | module/vcomponent/ical/output.scm | 8 | ||||
-rw-r--r-- | module/vcomponent/ical/parse.scm | 2 | ||||
-rw-r--r-- | module/vcomponent/search.scm | 18 | ||||
-rw-r--r-- | module/vcomponent/xcal/parse.scm | 17 | ||||
-rw-r--r-- | module/web/http/make-routes.scm | 7 | ||||
-rw-r--r-- | static/input_list.js | 77 | ||||
-rw-r--r-- | static/lib.js | 33 | ||||
-rw-r--r-- | static/script.js | 619 | ||||
-rw-r--r-- | static/style.scss | 88 | ||||
-rw-r--r-- | static/types.js | 98 |
18 files changed, 1056 insertions, 321 deletions
@@ -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'], +} |