]> git.plutz.net Git - webpoll/commitdiff
Merge commit '4f6c45f6bef36f8033bb9f93cfdcff050d3f0b02'
authorPaul Hänsch <paul@plutz.net>
Wed, 27 Jul 2022 12:01:29 +0000 (14:01 +0200)
committerPaul Hänsch <paul@plutz.net>
Wed, 27 Jul 2022 12:01:29 +0000 (14:01 +0200)
21 files changed:
.gitignore
Makefile [new file with mode: 0644]
cgilite/.gitignore [new file with mode: 0644]
cgilite/cgilite.sh [moved from cgilite.sh with 100% similarity]
cgilite/common.css [moved from common.css with 100% similarity]
cgilite/file.sh [moved from file.sh with 100% similarity]
cgilite/html-sh.sed [moved from html-sh.sed with 100% similarity]
cgilite/logging.sh [moved from logging.sh with 100% similarity]
cgilite/markdown.awk [moved from markdown.awk with 100% similarity]
cgilite/session.sh [moved from session.sh with 100% similarity]
cgilite/storage.sh [moved from storage.sh with 100% similarity]
cgilite/users.sh [moved from users.sh with 100% similarity]
comments.sh [new file with mode: 0755]
home.sh [new file with mode: 0755]
index.cgi [new file with mode: 0755]
languages.sh [new file with mode: 0644]
newdate.sh [new file with mode: 0755]
poll.sh [new file with mode: 0644]
webpoll.css [new file with mode: 0644]
widgets.css [new file with mode: 0644]
widgets.sh [new file with mode: 0755]

index 5c9950ae1c74eb4a290c3887873f567f85c3de4d..5f749301ddbd53914aba0b158a74d543320085f0 100644 (file)
@@ -1,3 +1,3 @@
-cgilite
 serverkey
-users.db
+[0-9a-zA-Z:=][0-9a-zA-Z:=][0-9a-zA-Z:=][0-9a-zA-Z:=][0-9a-zA-Z:=][0-9a-zA-Z:=][0-9a-zA-Z:=][0-9a-zA-Z:=][0-9a-zA-Z:=][0-9a-zA-Z:=][0-9a-zA-Z:=][0-9a-zA-Z:=][0-9a-zA-Z:=][0-9a-zA-Z:=][0-9a-zA-Z:=][0-9a-zA-Z:=]
+comments/
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..24781a9
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,9 @@
+.PHONY: _subtrees
+
+_subtrees: _cgilite
+
+cgilite:
+       git subtree add --squash -P $@ https://git.plutz.net/git/$@ master
+
+_cgilite: cgilite
+       git subtree pull --squash -P $< https://git.plutz.net/git/$< master
diff --git a/cgilite/.gitignore b/cgilite/.gitignore
new file mode 100644 (file)
index 0000000..5c9950a
--- /dev/null
@@ -0,0 +1,3 @@
+cgilite
+serverkey
+users.db
similarity index 100%
rename from cgilite.sh
rename to cgilite/cgilite.sh
similarity index 100%
rename from common.css
rename to cgilite/common.css
similarity index 100%
rename from file.sh
rename to cgilite/file.sh
similarity index 100%
rename from html-sh.sed
rename to cgilite/html-sh.sed
similarity index 100%
rename from logging.sh
rename to cgilite/logging.sh
similarity index 100%
rename from markdown.awk
rename to cgilite/markdown.awk
similarity index 100%
rename from session.sh
rename to cgilite/session.sh
similarity index 100%
rename from storage.sh
rename to cgilite/storage.sh
similarity index 100%
rename from users.sh
rename to cgilite/users.sh
diff --git a/comments.sh b/comments.sh
new file mode 100755 (executable)
index 0000000..90fdbb9
--- /dev/null
@@ -0,0 +1,119 @@
+#!/bin/sh
+
+. "${_EXEC}/cgilite/session.sh"
+. "${_EXEC}/cgilite/storage.sh"
+
+comments_file="${_DATA}/comments/${PATH_INFO}.db"
+
+comments_postcomment() {
+  local cuid="$1" username="$2" text="$3"
+  local db="$comments_file"
+
+  [ ! "$cuid" -o ! "$username" -o ! "$text" ] \
+  && REDIRECT "${_BASE}${PATH_INFO}#ERROR_COMMENT_INVALID"
+
+  mkdir -p "${comments_file%/*}" || REDIRECT "${_BASE}${PATH_INFO}#ERROR_COMMENT_NOCREAT"
+  if LOCK "$db"; then
+    if grep -qE "^${cuid}      " "$db"; then
+      RELEASE "$db"
+      REDIRECT "${_BASE}${PATH_INFO}#ERROR_COMMENT_EXISTS"
+    else
+      printf "%s       %s      %s      %s      %s\n" \
+             "$cuid" "$(STRING "$username")" "$SESSION_ID" "$_DATE" "$(STRING "$text")" \
+             >>"$db"
+      RELEASE "$db"
+      REDIRECT "${_BASE}${PATH_INFO}#comment_${cuid}"
+    fi
+  else
+    REDIRECT "${_BASE}${PATH_INFO}#ERROR_COMMENT_NOLOCK"
+  fi
+}
+comments_updatecomment() {
+  local cuid="$1" updatekey="$2" username="$3" text="$4"
+  local db="$comments_file"
+  local ousername sid time otext
+
+  [ ! "$cuid" -o ! "$username" -o ! "$text" ] \
+  && REDIRECT "${_BASE}${PATH_INFO}#ERROR_COMMENT_INVALID"
+
+  mkdir -p "${comments_file%/*}" || REDIRECT "${_BASE}${PATH_INFO}#ERROR_COMMENT_NOCREAT"
+  if LOCK "$db"; then
+    read -r cuid ousername sid time otext <<-EOF
+       $(grep -E "^${cuid}     " "$db")
+       EOF
+    if [ "$sid" = "$SESSION_ID" -a "$(session_mac "${ousername}|${time}|${otext}")" = "$updatekey" ]; then
+      sed -Ei "/^${cuid}       /d" "$db"
+      printf "%s       %s      %s      %s      %s\n" \
+             "$cuid" "$(STRING "$username")" "$SESSION_ID" "${time%,*},$_DATE" "$(STRING "$text")" \
+             >>"$db"
+      RELEASE "$db"
+      REDIRECT "${_BASE}${PATH_INFO}#comment_${cuid}"
+    else
+      RELEASE "$db"
+      REDIRECT "${_BASE}${PATH_INFO}#ERROR_COMMENT_DIVERGE"
+    fi
+  else
+    REDIRECT "${_BASE}${PATH_INFO}#ERROR_COMMENT_NOLOCK"
+  fi
+  
+}
+
+[ "$REQUEST_METHOD" = POST ] && case "$(POST action)" in
+  postcomment)   comments_postcomment \
+                   "$(POST cuid |checkid)" \
+                   "$(POST username |grep -m1 -oE '[^[:space:]].*[^[:space:]]')" \
+                   "$(POST text)";;
+  updatecomment) comments_updatecomment \
+                   "$(POST cuid |checkid)" "$(POST updatekey)" \
+                   "$(POST username |grep -m1 -oE '[^[:space:]].*[^[:space:]]')" \
+                   "$(POST text)";;
+  cancelcommentpost) REDIRECT "${_BASE}${PATH_INFO}#comments";;
+  cancelcommentedit) REDIRECT "${_BASE}${PATH_INFO}#comment_$(POST cuid |checkid)";;
+esac
+
+w_comments() {
+  local db="$comments_file"
+  local edit="$(GET editcomment |checkid)"
+  local cuid username sid time text
+
+  printf '[section #comments'
+  [ -f "$db" ] && grep -qE "^${edit}   [^      ]+      ${SESSION_ID}" "$db" \
+  || cat <<-EOF
+         [h2 Comments]
+         [input type=checkbox #comments_toggle_new][label for="comments_toggle_new" Write a Comment]
+         [form method=POST
+           [hidden "cuid" "$(timeid)"]
+           [input name=username placeholder="Your Name" autocomplete=off]
+            [textarea name=text placeholder="Your Text"]
+           [label .legend You can use Markdown formatting for the comment text. Comments can be edited up to a short time after they have been posted.]
+           [submit "action" "cancelcommentpost" Cancel][submit "action" "postcomment" . Post Comment]
+         ]
+       EOF
+
+  [ -f "$db" ] && sort -r "$db" \
+  | while read -r cuid username sid time text; do
+    if [ "$edit" = "$cuid" -a "$sid" = "$SESSION_ID" ]; then
+      printf '
+        [form .comment .edit #comment_%s method=POST
+          [hidden "cuid" "%s"][hidden "updatekey" "%s"]
+          [input type=text name=username placeholder="Your Name" value="%s" autocomplete=off]
+          [textarea name=text placeholder="Your Text" . %s]
+          [submit "action" "cancelcommentedit" Cancel][submit "action" "updatecomment" . Update Comment]
+        ]' "$cuid" "$cuid" "$(session_mac "${username}|${time}|${text}")" \
+           "$(UNSTRING "$username" |HTML)" "$(UNSTRING "$text" |HTML)"
+    elif [ "$username" -a "$text" ]; then
+      printf '[div .comment #comment_%s [h3 . %s:]' "$cuid" "$(UNSTRING "$username" |HTML)"
+      printf '[span .time [label posted] %s]' "$(date -d "@${time%,*}")"
+      [ "${time}" != "${time%,*}" ] \
+      && printf '[span .update [label updated] %s]' "$(date -d "@${time#*,}")"
+      [ "$edit" = "$cuid" ] \
+      && printf '[span .error You cannot edit this comment]'
+      [ "$sid" = "$SESSION_ID" ] \
+      && printf '[a .edit href="?editcomment=%s#comment_%s" edit]' "$cuid" "$cuid"
+      printf '[div . %s]]' "$(UNSTRING "$text" |markdown)"
+    else
+      printf '[div .comment .deleted #comment_%s [h3 (deleted)]]' "$cuid"
+    fi
+  done
+  printf ']'
+}
diff --git a/home.sh b/home.sh
new file mode 100755 (executable)
index 0000000..b8f8192
--- /dev/null
+++ b/home.sh
@@ -0,0 +1,36 @@
+#!/bin/sh
+
+if [ "$REQUEST_METHOD" = POST ]; then
+  case $(POST start) in
+    date)
+      id="$(randomid)"
+      admin="$(randomid)"
+      file="$_DATA/$id"
+      # touch "$file"
+      DBM "$file" add adminkey "$admin"
+      REDIRECT "$_BASE/$id/$admin"
+      ;;
+    options)
+      id="$(randomid)"
+      touch "$_DATA/$id"
+      REDIRECT "$_BASE/$id/newoptions"
+      ;;
+    *) REDIRECT "$_BASE/";;
+  esac
+else
+  [ "$PATH_INFO" != / ] && printf 'Status: 404 Not Found\r\n'
+  yield_page "Start a Poll" "home" <<-EOF
+       [main [form method=post
+         [submit "start" "date" Start a new poll]
+         $(if [ "$bookmarks" ]; then
+           printf '[h2 Recent Polls][ul .recent'
+           for page in $bookmarks; do
+             page="${page%/*}"
+             [ -f "$_DATA/$(checkid "$page")" ] \
+             && printf '[li [a href="./%s" . %s]]' "$page" "$(pagename "$page" |HTML)"
+           done
+           printf ']'
+         fi)
+       ]]
+       EOF
+fi
diff --git a/index.cgi b/index.cgi
new file mode 100755 (executable)
index 0000000..060d7dc
--- /dev/null
+++ b/index.cgi
@@ -0,0 +1,64 @@
+#!/bin/sh
+
+. "${_EXEC:-${0%/*}}"/cgilite/cgilite.sh
+. "$_EXEC"/cgilite/session.sh
+. "$_EXEC"/cgilite/file.sh
+. "$_EXEC"/cgilite/storage.sh
+. "$_EXEC"/widgets.sh
+
+# PATH_INFO="$(PATH "/${PATH_INFO#${_BASE}}")"
+
+# bookmarks_cookie="$(COOKIE bookmarks |grep -xE '[0-9a-zA-Z:=]{16}(/[0-9a-zA-Z:=]{16})?( [0-9a-zA-Z:=]{16}(/[0-9a-zA-Z:=]{16})?)*')"
+bookmarks=''
+for bm in $(COOKIE bookmarks); do
+  [ -f "${_DATA}/$(checkid "${bm%/*}")" ] && bookmarks="${bookmarks}${bookmarks:+ }${bm}"
+done
+if [ "$bookmarks" ]; then
+  SET_COOKIE +$((182 * 86400)) bookmarks="${bm}" Path="${_BASE}/"
+fi
+
+yield_page(){
+  title="${1:-Webpoll}" page="$2"
+  printf '%s\r\n' 'Content-Type: text/html; charset=utf-8' \
+                  "Content-Security-Policy: script-src 'none'" \
+                  ''
+  { printf '[html
+    [head
+      [meta name="viewport" content="width=device-width"]
+      [link rel="stylesheet" type="text/css" href="%s/common.css"]
+      [link rel="stylesheet" type="text/css" href="%s/widgets.css"]
+      [link rel="stylesheet" type="text/css" href="%s/webpoll.css"]
+      [title %s]
+    ] [body class="%s"
+  ' "$_BASE" "$_BASE" "$_BASE" "$(HTML "$title")" "$page"
+  cat
+  printf '] ]'
+  } |"$_EXEC/cgilite/html-sh.sed" -u
+}
+
+pagename() {
+  local id="$1"
+  local file="$_DATA/$id"
+  if [ -f "$file" ]; then
+    DBM "$file" get title || printf 'Unnamed Page'
+  else
+    return 1;
+  fi
+}
+
+   page_home() { . "$_EXEC"/home.sh; }
+page_newdate() { . "$_EXEC"/newdate.sh; }
+   page_poll() { . "$_EXEC"/poll.sh; }
+
+case ${PATH_INFO} in
+  /favicon.ico) printf '%s\r\n' 'Content-Length: 0' '';;
+  /common.css) FILE "$_EXEC/cgilite/common.css";;
+  /widgets.css|/webpoll.css) FILE "${_EXEC}/${PATH_INFO}";;
+  /) page_home;;
+  /[0-9a-zA-Z:=]???????????????/[0-9a-zA-Z:=]???????????????) page_newdate;;
+  /*/newoptions);;
+  /[0-9a-zA-Z:=]???????????????) page_poll;;
+  /*) page_home;;
+esac
+
+exit 0
diff --git a/languages.sh b/languages.sh
new file mode 100644 (file)
index 0000000..c3649f4
--- /dev/null
@@ -0,0 +1,12 @@
+[ -n "$include_languages" ] && return 0
+include_languages="$0"
+
+language="$(HEADER Accept-Language)"; language="${language%%[;,._-]*}"
+case "$language" in
+  de) export LC_TIME=de_DE.UTF-8
+      table_date="%A <br/> %_d. %B %Y"
+      ;;
+   *) export LC_TIME=C
+      table_date="%A <br/> %B %_d, %Y"
+      ;;
+esac
diff --git a/newdate.sh b/newdate.sh
new file mode 100755 (executable)
index 0000000..83e27b6
--- /dev/null
@@ -0,0 +1,187 @@
+#!/bin/sh
+
+id="${PATH_INFO%/*}"; id="${id#/}"
+admin=${PATH_INFO##*/}
+file="$_DATA/$id"
+
+if [ "$(DBM "$file" get adminkey)" != "$admin" ]; then
+  page_home;
+  return 0
+  # REDIRECT "$_BASE/#ERRO_INVALIDKEY"
+elif expr match "$bookmarks" ".*${id}/${admin}.*" >/dev/null; then
+  :
+elif expr match "$bookmarks" ".*${id}.*" >/dev/null; then
+  bookmarks="$(printf %s "$bookmarks" |tr \  \\n |grep -vxF "${id}")"
+  bookmarks="$(printf %s "$bookmarks" |tr \\n \ )"
+  SET_COOKIE +$((182 * 86400)) bookmarks="${bookmarks}${bookmarks:+ }${id}/${admin}" Path="${_BASE}/"
+elif [ "$bookmarks" ]; then
+  SET_COOKIE +$((182 * 86400)) bookmarks="${bookmarks} ${id}/${admin}" Path="${_BASE}/"
+fi
+
+fs_timeofday() {
+  local todall="$(DBM "$file" get todall)" time c=0
+  cat <<-EOF
+       [fieldset .timeofday
+         [label .todstart Start Time (optional):
+         ]
+         [label .todend   End Time (optional):
+         ]
+         $(for time in ${todall:--}; do
+           c=$((c + 1))
+           printf '
+             <input name="todstart" value="%s" placeholder="HH:MM" list="dlist_timeofday"
+                    pattern="^(0?\[0-9\]|1\[0-9\]|2\[0-3\]):(\[0-5\]\[0-9\])$"/>
+             <input name="todend"   value="%s" placeholder="HH:MM"   list="dlist_timeofday"
+                    pattern="^(0?\[0-9\]|1\[0-9\]|2\[0-3\]):(\[0-5\]\[0-9\])$"/>
+             [submit "todremove" "%i" -]
+           ' "${time%-*}" "${time#*-}" "${c}"
+         done)
+         [submit "addtime" "global" + Add Time Option]
+         [checkbox "none" "none" .splittimes disabled=disabled] [submit "splittimes" "yes" Separate Time Options per Day]
+       ]
+       EOF
+}
+
+fs_splittimes() {
+  local days day times time c
+  days="$(DBM "$file" get dates)"
+  cat <<-EOF
+       [fieldset .splittimes
+         [checkbox "none" "none" .splittimes checked disabled=disabled] [submit "splittimes" "no" Separate Time Options per Day]
+         $([ ! "$days" ] && printf '[p You have not selected any days yet.]\n')
+         $(for day in $days; do 
+            date -d $day +"[h2 . %A - %B %_d, %Y]"
+           times=$(DBM "$file" get "tod_$day")
+           for time in ${times:--}; do
+             c=$((c + 1))
+             printf '
+               <input name="todstart_%s" value="%s" placeholder="HH:MM" list="dlist_timeofday"
+                      pattern="^(0?\[0-9\]|1\[0-9\]|2\[0-3\]):(\[0-5\]\[0-9\])$"/>
+               <input name="todend_%s"   value="%s" placeholder="HH:MM"   list="dlist_timeofday"
+                      pattern="^(0?\[0-9\]|1\[0-9\]|2\[0-3\]):(\[0-5\]\[0-9\])$"/>
+               [submit "todremove_%s" "%i" -]
+             ' "$day" "${time%-*}" "$day" "${time#*-}" "$day" "${c}"
+           done
+           printf '[submit "addtime" "%s" + Add Time Option]' "$day"
+         done)
+       ]
+       EOF
+}
+
+if [ "$REQUEST_METHOD" = POST ]; then
+  month="$(POST month |grep -m 1 -xE '[0-9]{4}-(0[1-9]|1[012])')"
+  todremove="$(POST todremove |grep -m 1 -xE '[0-9]+')"
+  splittimes="$(POST splittimes |grep -m 1 -xE 'yes|no')"
+  addtime="$(POST addtime)"
+
+  if [ "$splittimes" = yes ]; then
+    DBM "$file" set splittimes "$splittimes"
+    splittimes="no"   # receive remainder of todall form
+  elif [ "$splittimes" = no ]; then
+    DBM "$file" set splittimes "$splittimes"
+    splittimes="yes"  # receive remainder of splittimes form
+  else
+    splittimes="$(DBM "$file" get splittimes || printf no)"
+  fi
+
+  DBM "$file" set title "$(POST title)"
+  DBM "$file" set description "$(POST description)"
+  # Store common time options "todall"
+  [ "$splittimes" = no ] && DBM "$file" set todall "$(
+    for todcount in $(seq 1 $(POST_COUNT todstart)); do
+      [ "$todremove" -eq "$todcount" ] 2>&- && continue;
+      todstart="$(POST todstart "$todcount")"
+      todend="$(POST todend "$todcount")"
+      [ "${todstart%:??}" -lt "${todend%:??}" -o "${todstart%:??}" -eq "${todend%:??}" -a "${todstart#*:}" -lt "${todend#*:}" ] \
+      2>&- \
+      && { printf '%02i:%02i-%02i:%02i\n' "${todstart%:??}" "${todstart#*:}" "${todend%:??}" "${todend#*:}"; }\
+      || { [ "${todstart%:??}" -ge 0 -a  "${todstart#*:}" -ge 0 ] 2>&- && printf '%02i:%02i-\n' "${todstart%:??}" "${todstart#*:}"; }
+    done |grep -xE '^([01][0-9]|2[0-3]):([0-5][0-9])-(([01][0-9]|2[0-3]):([0-5][0-9]))?$' |sort -u
+  )"
+  [ "$addtime" = global ] && DBM "$file" append todall "${BR}-"
+
+  # Store per-date time options "tod_YYYY-mm-dd"
+  [ "$splittimes" = yes ] && for date in $(DBM "$file" get dates); do
+    todremove="$(POST todremove_$date |grep -m 1 -xE '[0-9]+')"
+    DBM "$file" set "tod_$date" "$(
+      for todcount in $(seq 1 $(POST_COUNT "todstart_${date}")); do
+        [ "$todremove" -eq "$todcount" ] 2>&- && continue;
+        todstart="$(POST "todstart_${date}" "$todcount")"
+        todend="$(POST "todend_${date}" "$todcount")"
+        [ "${todstart%:??}" -lt "${todend%:??}" -o "${todstart%:??}" -eq "${todend%:??}" -a "${todstart#*:}" -lt "${todend#*:}" ] \
+        2>&- \
+        && { printf '%02i:%02i-%02i:%02i\n' "${todstart%:??}" "${todstart#*:}" "${todend%:??}" "${todend#*:}"; }\
+        || { [ "${todstart%:??}" -ge 0 -a  "${todstart#*:}" -ge 0 ] 2>&- && printf '%02i:%02i-\n' $(dec ${todstart%:??} ${todstart#*:}); }
+      done |grep -xE '^([01][0-9]|2[0-3]):([0-5][0-9])-(([01][0-9]|2[0-3]):([0-5][0-9]))?$' |sort -u
+    )"
+    [ "$addtime" = "$date" ] && DBM "$file" append "tod_${date}" "${BR}-"
+  done
+
+  DBM "$file" set dates "$(
+    for date in $(seq 0 $(POST_COUNT date)); do
+      [ "$date" -eq 0 ] \
+      && POST date_add \
+      || POST date "$date"
+      printf \\n
+    done \
+    | grep -vxF "$(POST date_remove)" \
+    | grep -xE '^[0-9]{4}-((01|03|05|07|08|10|12)-([012][0-9]|3[01])|(04|06|09|11)-([012][0-9]|30)|02-[012][0-9])$' \
+    | sort -u
+  )"
+
+  if [ "$(POST delete)" = delete ]; then
+    if [ "$(POST delconfirm)" -o ! "$(DBM "$file" get participants)" ]; then
+      rm -- "$file"
+      REDIRECT "$_BASE/"
+    else
+      REDIRECT "$_BASE$PATH_INFO${month:+?month=}${month}#ERROR_NEEDCONFIRM"
+    fi
+  elif [ "$(POST post)" = post ]; then
+    REDIRECT "$_BASE${PATH_INFO%/*}"
+  elif [ "$(POST bookmark)" -a ! "$bookmarks" ]; then
+    SET_COOKIE +$((182 * 86400)) bookmarks="${id}/${admin}" Path="${_BASE}/"
+    REDIRECT "$_BASE$PATH_INFO${month:+?month=}${month}"
+  else
+    REDIRECT "$_BASE$PATH_INFO${month:+?month=}${month}"
+  fi
+else
+  month="$(GET month |grep -m1 -xE '[0-9]{4}-(0[1-9]|1[012])' || date +%Y-%m)"
+  Y="${month%-*}"; m="${month#*-}"; Y=${Y#0}; m=${m#0};
+  [ "$m" = 1 ] && prev=$(printf '%04i-%02i' $((Y - 1)) 12) || prev=$(printf '%04i-%02i' $Y $((m - 1)))
+  [ "$m" = 12 ] && next=$(printf '%04i-%02i' $((Y + 1)) 01) || next=$(printf '%04i-%02i' $Y $((m + 1)))
+  dates="$(DBM "$file" get dates)"
+  days="$(printf %s "$dates" |sed -E "/^${month}-/!d; s;^.*-([0-9]{2})$;\1;g")"
+  additional="$(printf %s "$dates" |sed -E "/^${month}-/d;")"
+  splittimes="$(DBM "$file" get splittimes || printf no)"
+
+  yield_page "$(pagename "$id")" "newdate" <<-EOF
+       $(dlist_timeofday)
+       [main [form method=post
+         [input name=title value="$(DBM "$file" get title |HTML)" placeholder="Title" autocomplete=off]
+         [textarea name=description placeholder="Description" . $(DBM "$file" get description |HTML)]
+         $(if [ "$bookmarks" ]; then
+           printf '[section .bookmark This admin page is accessible via the link [a href="%s" . %s]. You must copy this link and keep it safe, so you can modify this poll later! The poll has also been bookmarked and will be listed on the front page.]' \
+           "$(URL "//$(HEADER Host)/${_BASE}/${id}/${admin}")" \
+           "$(HTML "${HTTPS:+https:}${HTTPS:-http:}//$(HEADER Host)$(PATH "/${_BASE}/${id}/${admin}")")"
+          else
+           printf '[section .bookmark This admin page is accessible via the link [a href="%s" . %s]. You must copy this link and keep it safe, so you can modify this poll later! You can also set a Cookie to bookmark all polls you visit, including this admin page. Bookmarked polls will be listed on the frontpage.<br/> [submit "bookmark" "add" Set Cookie]]' \
+           "$(URL "//$(HEADER Host)/${_BASE}/${id}/${admin}")" \
+           "$(HTML "${HTTPS:+https:}${HTTPS:-http:}//$(HEADER Host)$(PATH "/${_BASE}/${id}/${admin}")")"
+         fi)
+         [fieldset .date
+           $(printf '[hidden "date" "%s"]' $additional)
+           [submit "month" "$prev" Previous Month]
+           $([ "$splittimes" = yes ] && w_month submit date "$month" $days || w_month multiple date "$month" $days)
+           [submit "month" "$next" Next Month]
+           [hidden "month" "$month"]
+         ]
+         $([ "$splittimes" = "yes" ] && fs_splittimes || fs_timeofday )
+         $(if [ "$(DBM "$file" get participants)" ]; then
+           printf '[checkbox "delconfirm" "confirm" id="delconfirm"][label for=delconfirm Delete Poll]
+                    [submit "delete" "delete" Delete Poll] [submit "post" "post" Poll page]'
+         else
+           printf '[submit "delete" "delete" Cancel] [submit "post" "post" Post Event]'
+         fi)
+       ]]
+       EOF
+fi
diff --git a/poll.sh b/poll.sh
new file mode 100644 (file)
index 0000000..240a155
--- /dev/null
+++ b/poll.sh
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+. "${_EXEC}/comments.sh"
+. "${_EXEC}/languages.sh"
+
+id="$(checkid "${PATH_INFO#/}")"
+file="${_DATA}/${id}"
+
+#cancel if poll is invalid
+if [ ! "$id" -o ! -f "$file" ]; then
+  page_home;
+  return 0
+fi
+
+if expr match "$bookmarks" ".*${id}.*" >/dev/null; then
+  :
+elif [ "$bookmarks" ]; then
+  SET_COOKIE +$((182 * 86400)) bookmarks="${bookmarks} ${id}" Path="${_BASE}/"
+fi
+
+admin="$(expr match "$bookmarks" ".*$id/\([a-zA-Z0-9:=]\{16\}\).*")"
+admin="$(DBM "$file" get adminkey |grep -xF "$admin")"
+
+tkey() {
+  # convert time stamps for use in POST keys
+  local str="$1" out
+  while [ "$str" ]; do
+    case $str in
+      :*) out="${out}.";;
+      *) out="${out}${str%"${str#?}"}";;
+    esac
+    str="${str#?}"
+  done
+  printf %s "$out"
+}
+
+timelist() {
+  local dates todall splittimes
+  local date tod todsplit
+
+  if [ "$splittimes" = no -a "$dates" -a "$todall" ]; then
+    for date in $dates; do for tod in $todall; do
+      printf %s\\n "${date}_${tod%-}"
+    done ;done
+
+  elif [ "$splittimes" = no -a "$dates" ]; then
+    for date in $dates; do
+      printf %s\\n "${date}"
+    done
+
+  elif [ "$splittimes" = no -a "$todall" ]; then
+    for tod in $todall; do
+      printf %s\\n "${tod%-}"
+    done
+
+  elif [ "$splittimes" = yes ]; then
+    for date in $dates; do
+      todsplit="$(DBM "$file" get "tod_$date")"
+      [ "$todsplit" ] \
+      && for tod in $todsplit; do printf %s\\n "${date}_${tod%-}"; done \
+      || printf %s\\n "${date}"
+    done
+
+  else
+    return 1
+
+  fi
+}
+
+table_poll() {
+  local splittimes="$(DBM "$file" get splittimes || printf no)"
+  local dates="$(DBM "$file" get dates)"
+  local todall="$(DBM "$file" get todall)"
+  local timelist="$(timelist)"
+  local edit="$(GET edit)"
+  local time date span name yes no maybe yc nc mc
+
+  table_date="${table_date+"%A <br/> %B %_d, %Y"}"
+
+  [ "$timelist" ] || return 1
+
+  printf '[table .poll [thead\n'
+  # date header
+  if [ "$dates" ]; then
+    printf '[tr .dates [th]'
+    for date in $dates; do
+      span=0; for time in $timelist; do case $time in
+        ${date}*) span=$((span + 1));;
+      esac; done
+      date -d "$date" +"[th colspan=\"${span}\" . ${table_date}]";
+    done
+    printf '[th]]\n'
+  fi
+  
+  # tod header
+  if [ "$splittimes" = yes -o "$todall" ]; then
+    printf '[tr .tod [th]'
+    for time in $timelist; do
+      case $time in
+       *-*-*_*:*) time="${time#*_}";;
+       *-*-*) time="";;
+       *:*);;  # time="${time}"
+      esac
+      printf '[th . %s]' "${time}"
+    done
+    printf '[th]]\n'
+  fi
+
+  printf '][tbody\n'
+
+  # Vote displays
+  { DBM "$file" get participants; printf \\n; } |while read -r name; do
+    [ "$name" = "$edit" ] && continue
+    maybe="$(DBM "$file" get "reply_maybe_${name}")"
+      yes="$(DBM "$file" get "reply_yes_${name}")"
+       no="$(DBM "$file" get "reply_no_${name}")"
+
+    printf '[tr [th .name . %s]' "$(HTML "$name")"
+    for time in $timelist; do
+      printf %s   "$yes" |grep -qwF "$time" && printf '[td   .yes   Yes]' && continue
+      printf %s    "$no" |grep -qwF "$time" && printf '[td    .no    No]' && continue
+      printf %s "$maybe" |grep -qwF "$time" && printf '[td .maybe Maybe]' && continue
+      printf '[td .missing . ?]'
+    done
+    printf '[td .edit [a href="?edit=%s" Edit]]]\n' "$(URL "$name")"
+  done
+
+  if [ "$edit" ]; then
+    maybe="$(DBM "$file" get "reply_maybe_${edit}")"
+      yes="$(DBM "$file" get "reply_yes_${edit}")"
+       no="$(DBM "$file" get "reply_no_${edit}")"
+
+    printf '[tr .new [th .name [submit "delete" "%s" -] %s]' "$(HTML "$edit")" "$(HTML "$edit")"
+    for time in $timelist; do
+      ktime="$(tkey "$time")"
+      printf '[td  [radio "%s"   "yes"   #yes_%s %s][label   for="yes_%s"   Yes]
+                   [radio "%s"    "no"    #no_%s %s][label    for="no_%s"    No]
+                   [radio "%s" "maybe" #maybe_%s %s][label for="maybe_%s" Maybe]
+              ]' "${ktime}" "${time}" "$(checked "$time"   $yes)" "${time}" \
+                 "${ktime}" "${time}" "$(checked "$time"    $no)" "${time}" \
+                 "${ktime}" "${time}" "$(checked "$time" $maybe)" "${time}"
+    done
+    printf '[td [submit "update" "%s" Update]]]\n' "$(HTML "$edit")"
+  else
+
+    # Vote counts
+    printf '[tr .votecount [td]'
+    for time in $timelist; do
+      yc=0 nc=0 mc=0
+      { DBM "$file" get participants; printf \\n\\n; } |while read -r name; do
+        [ ! "$name" ] && printf '[td %i (%i)]' "$yc" "$((yc + mc))" && break;
+          yes="$(DBM "$file" get "reply_yes_${name}")"
+           no="$(DBM "$file" get "reply_no_${name}")"
+        maybe="$(DBM "$file" get "reply_maybe_${name}")"
+
+        printf %s   "$yes" |grep -qwF "$time" && yc=$((yc + 1)) && continue
+        printf %s "$maybe" |grep -qwF "$time" && mc=$((mc + 1)) && continue
+      done
+    done
+    printf '[td]]\n'
+
+    # Submit line
+    printf '[tr .new [td [input name="name" value="" placeholder="Your Name" autocomplete=off]]'
+    for time in $timelist; do
+      time="$(tkey "$time")"
+      printf '[td  [radio "%s"   "yes"   #yes_%s][label   for="yes_%s"   Yes]
+                   [radio "%s"    "no"    #no_%s][label    for="no_%s"    No]
+                   [radio "%s" "maybe" #maybe_%s][label for="maybe_%s" Maybe]
+              ]' "${time}" "${time}" "${time}" \
+                 "${time}" "${time}" "${time}" \
+                 "${time}" "${time}" "${time}"
+    done
+    printf '[td [submit "new" "new" Submit]]]\n'
+  fi
+
+  printf ']]'
+}
+
+if [ "$REQUEST_METHOD" = POST ]; then
+  local update="$(POST update)" delete="$(POST delete)"
+  local name="$(POST name |grep -m 1 -xE '.*[^         ].*')"
+  local splittimes="$(DBM "$file" get splittimes || printf no)"
+  local dates="$(DBM "$file" get dates)"
+  local todall="$(DBM "$file" get todall)"
+  local timelist="$(timelist)"
+  local time yes no maybe reply
+
+  if [ "$(POST new)" = new -o "$update" ]; then
+    [ "$update" ] && name="$update"
+
+    if [ ! "$name" ]; then
+      REDIRECT "${_BASE}${PATH_INFO}#ERROR_NONAME"
+    elif [ ! "$update" ] && DBM "$file" get participants |grep -qxF "$name"; then
+      REDIRECT "${_BASE}${PATH_INFO}#ERROR_NAMEEXISTS"
+    elif [ "$update" ] && ! DBM "$file" get participants |grep -qxF "$name"; then
+      REDIRECT "${_BASE}${PATH_INFO}#ERROR_NAMENONEXIST"
+    fi
+    if [ ! "$update" ]; then
+      DBM "$file" append participants "${BR}${name}" || DBM "$file" insert participants "${name}" \
+      || REDIRECT "${_BASE}${PATH_INFO}#ERROR_DBACCESS"
+    fi
+
+    for time in $timelist; do reply="$(POST "$(tkey "$time")")"; case $reply in
+        yes)   yes="${yes}${yes:+ }${time}";;
+         no)    no="${no}${no:+ }${time}";;
+      maybe) maybe="${maybe}${maybe:+ }${time}";;
+    esac; done
+    DBM "$file" set "reply_yes_${name}" "$yes"
+    DBM "$file" set "reply_no_${name}" "$no"
+    DBM "$file" set "reply_maybe_${name}" "$maybe"
+
+  elif [ "$delete" ]; then
+    if ! DBM "$file" get participants |grep -qxF "$delete"; then
+      REDIRECT "${_BASE}${PATH_INFO}#ERROR_NAMENONEXIST"
+    fi
+    DBM "$file" set participants "$(DBM "$file" get participants |grep -vxF "$delete")"
+    DBM "$file" delete "reply_yes_${delete}"
+    DBM "$file" delete "reply_no_${delete}"
+    DBM "$file" delete "reply_maybe_${delete}"
+
+  elif [ "$(POST bookmark)" -a ! "$bookmarks" ]; then
+    SET_COOKIE +$((182 * 86400)) bookmarks="${id}" Path="${_BASE}/"
+
+  fi
+  REDIRECT "${_BASE}${PATH_INFO}"
+  
+else
+  pagename="$(pagename "$id")"
+
+  yield_page "$pagename" poll <<-EOF
+       [main
+         [section .description
+           [h1 .title $(HTML "$pagename")]
+           $(DBM "$file" get description |markdown)
+         ]
+         [form method=POST
+           $(table_poll || printf '[p Poll parameters are invalid]')
+
+            $(if [ "$admin" ]; then
+              printf '[section .bookmark You have bookmarked the admin page of this poll: [a href="./%s" modify poll]]' "$(URL ${id}/${admin})"
+            elif [ "$bookmarks" ]; then
+              printf '[section .bookmark This poll has been bookmarked and is accessible via a link on the front page.]'
+            else
+              printf '[section .bookmark Polls are accessible only via their URL. You can set a Cookie to bookmark all polls you visit. Bookmarked polls will be listed on the frontpage.<br/> [submit "bookmark" "add" Set Cookie]]'
+            fi)
+         ]
+
+          $(w_comments)
+       ]
+       EOF
+fi
diff --git a/webpoll.css b/webpoll.css
new file mode 100644 (file)
index 0000000..479d198
--- /dev/null
@@ -0,0 +1,248 @@
+body {
+  background-size: 4pt 4pt;
+  background-image: /* #6AF #6FF */
+    linear-gradient( 0deg, transparent 25%, rgba(102,170,255,.5) 25% 50%, transparent 50% 75%, rgba(102,255,255,.5) 75%),
+    linear-gradient(90deg, transparent 25%, rgba(102,170,255,.5) 25% 50%, transparent 50% 75%, rgba(102,255,255,.5) 75%);
+}
+
+body > main {
+  background-color: rgba(255,255,255,.75) ;
+  padding: 1em; margin: 1em auto 1em auto;
+  border-radius: .5ex;
+  box-shadow: #000 .125em .125em 1em;
+}
+
+body.home main {
+  position: fixed;
+  left: 50%; top: 50%;
+  transform: translate(-50%, -50%);
+}
+
+body.poll main {
+  text-align: center;
+  max-width: 95%;
+}
+body section {
+  max-width: 50em;
+  padding: 1em;
+  margin: 1em auto;
+  background-color: rgba(255,255,255,.5);
+  word-wrap: break-word;
+}
+body.poll .description {
+  margin-top: 0;
+  text-align: left;
+}
+body.poll .description .title {
+  margin-top: 0;
+  text-align: center;
+}
+body.poll table {
+  display: block;
+  max-width 100%;
+  overflow-x: auto;
+
+  background-color: rgba(255,255,255,.5);
+  border-collapse: collapse;
+  margin: auto;
+  box-shadow: #000 .25em .25em .5em;
+  border-radius: 2pt;
+}
+
+body.poll table thead tr.dates th {
+  padding: .25em;
+}
+body.poll table thead tr.tod th {
+  border-width: .5pt;
+  border-style: none solid none solid;
+  padding: .25em;
+}
+body.poll table tbody tr td {
+  text-align: center;
+  border: .5pt solid;
+  padding: 0 .25em;
+  background-clip: padding-box;
+}
+body.poll table tbody tr td:first-child,
+body.poll table tbody tr td:last-child,
+body.poll table thead tr th:first-child,
+body.poll table thead tr th:last-child { border: none; }
+
+body.poll table tbody tr th.name  { padding: .25em .5em; text-align: right; }
+body.poll table tbody tr td.yes   { background-color: #AFA; }
+body.poll table tbody tr td.no    { background-color: #FAA; }
+body.poll table tbody tr td.maybe { background-color: #FFA; }
+body.poll table tbody tr td.edit  { text-align: left; font-size: .875em; }
+
+body.poll table tbody tr.votecount td  { padding: .375em .25em;}
+body.poll table tbody tr th.name button[name=delete]  { font-size: .625em; line-height: 1.5em; background-color: #FAA; }
+
+body.poll table tbody tr.new td:first-child  { text-align: right; }
+body.poll table tbody tr.new td input[name=name]  { min-width: 100%; width: 8em; }
+
+body.poll table td input[type=radio] { display: none; }
+body.poll table td input[type=radio] + label {
+  font-size: .875em;
+  text-decoration: underline;
+  color: #066;
+  padding: .25em;
+  margin: 0;
+}
+body.poll table td input[type=radio]:checked + label {
+  font-weight: bold;
+}
+body.poll table td input[type=radio][value=yes]:checked + label {
+  background-color: #AFA;
+  margin: 0 -1.5pt;
+}
+body.poll table td input[type=radio][value=no]:checked + label {
+  background-color: #FAA;
+  margin: 0 -.75pt;
+}
+body.poll table td input[type=radio][value=maybe]:checked + label {
+  background-color: #FFA;
+  margin: 0 -1.75pt;
+}
+
+body.newdate main {
+  text-align: center;
+  max-width: 100%;
+}
+body.newdate form fieldset.date,
+body.newdate form fieldset.timeofday,
+body.newdate form fieldset.splittimes {
+  display: inline-block;
+  vertical-align: top;
+  margin: .5em 0 1em 0;
+}
+
+body.newdate main { width: 26em; }
+body.newdate form input[name=title],
+body.newdate form textarea[name=description] {
+  width: 100%;
+}
+body.newdate form fieldset.date,
+body.newdate form fieldset.timeofday,
+body.newdate form fieldset.splittimes {
+  width: 100%;
+}
+
+@media(min-width: 50em) {
+  body.newdate main { width: 50em; }
+  body.newdate form input[name=title],
+  body.newdate form textarea[name=description] {
+    width: 100%;
+  }
+  body.newdate form fieldset.date,
+  body.newdate form fieldset.timeofday,
+  body.newdate form fieldset.splittimes {
+    width: 49.5%; width: calc(50% - .375ex);
+  }
+  body.newdate form fieldset.date { padding-right: .75em; }
+  body.newdate form fieldset.timeofday,
+  body.newdate form fieldset.splittimes { padding-left: .75em; }
+  body.poll table { display: table;}
+}
+
+body.newdate form input[name=title],
+body.newdate form textarea[name=description] {
+  display: block;
+  margin-bottom: .75em;
+}
+body.newdate form textarea[name=description] {
+  height: 8em;
+}
+
+body.newdate form .date button[name=month] {
+  display: inline-block;
+  position: absolute;
+  top: 0;
+  height: 2.375em;
+  width: 2em; width: calc(50% - 9em);
+  padding: 0;
+  color: transparent;
+  background-color: transparent;
+  border: none;
+  overflow: hidden;
+  z-index: 1;
+}
+body.newdate form .date button[name=month]:before {
+  display: block;
+  content: '<';
+  font-size: 1.75em;
+  font-weight: bold;
+  color: #666;
+  margin-top: .25em;
+}
+body.newdate form .date table + button[name=month] { right: .75em; }
+body.newdate form .date table + button[name=month]:before {
+  content: '>';
+}
+
+body.newdate form .date table.calendar {
+  background-color: #FFF;
+  font-size: 17pt;
+  -vertical-align: middle;
+}
+
+body.newdate form .timeofday label.todstart,
+body.newdate form .timeofday label.todend {
+  display: inline-block;
+  margin: 0;
+  font-weight: bold;
+  text-align: left;
+  font-size: .75em;
+  width: 49%; width: calc(50% - 2.5pt);
+}
+body.newdate form fieldset > input[name^=todstart],
+body.newdate form fieldset > input[name^=todend] {
+  display: inline-block;
+  margin: 0;
+  width: 49%; width: calc(50% - 2.5pt);
+  text-align: right;
+}
+body.newdate form fieldset > input[name^=todend] {
+  width: 39%; width: calc( 50% - 4.375ex);
+}
+
+body.newdate form fieldset button[name^=addtime] {
+  width: 100%;
+}
+
+body.newdate form fieldset input.splittimes { display: none;}
+body.newdate form fieldset input.splittimes + * { left: 12pt; }
+body.newdate form fieldset input.splittimes + *:before {
+  position: absolute;
+  width: 16pt; height: 16pt;
+  left: -24pt;
+  content: '';
+  text-align: center;
+  font-weight: bold;
+  font-size: 1.75em;
+  border: 1pt solid;
+  border-radius: .25ex;
+  background-color: #FFF;
+}
+body.newdate form fieldset input.splittimes:checked + *:before { content: '\2713'; background-color: #6AF;}
+body.newdate form .timeofday button[name=splittimes] { margin-top: 1.5em;}
+
+body.newdate form .splittimes p { margin-top: 2em; padding: .5em; background: rgba(255,255,255,.5); }
+
+body.newdate form #delconfirm {
+  margin-left: -6em;
+}
+body.newdate form #delconfirm + label:after {
+  content: '\0A';
+  white-space: pre;
+}
+body.newdate form #delconfirm + label + button {
+  pointer-events: none;
+  color: #AAA;
+  border-color: #AAA;
+}
+body.newdate form #delconfirm:checked + label + button {
+  pointer-events: auto;
+  color: inherit;
+  border-color: inherit;
+  background-color: #FDD;
+}
diff --git a/widgets.css b/widgets.css
new file mode 100644 (file)
index 0000000..a93e2a3
--- /dev/null
@@ -0,0 +1,129 @@
+table.calendar {
+  display: inline-block;
+  border-collapse: collapse;
+  vertical-align: top;
+}
+table.calendar td {
+  border: .5pt solid;
+}
+table.calendar th {
+  font-weight: normal;
+}
+
+table.calendar thead tr.monthname {
+  border-style: solid;
+  border-width: .5pt .5pt .5pt .5pt;
+  line-height: 1.5em;
+  font-size: 1.125em;
+}
+table.calendar thead tr.weekday {
+  line-height: 1.5em;
+}
+table.calendar thead tr.weekday th:first-of-type {
+  border-style: solid;
+  border-width: 0pt 0pt 0pt .5pt;
+}
+table.calendar thead tr.weekday th:last-child {
+  border-right: .5pt solid;
+}
+table.calendar tbody tr th {
+  border-left: .5pt solid #000;
+}
+table.calendar tbody tr:last-child th {
+  border-bottom: .5pt solid #000;
+}
+
+table.calendar tbody tr th.weekno {
+  width: 2em;
+  padding: 0 .25em;
+  text-align: right;
+  font-weight: normal;
+  color: #888;
+}
+
+table.calendar input[type=radio],
+table.calendar input[type=checkbox] {
+  display: none;
+}
+table.calendar td label,
+table.calendar td button {
+  display: inline-block;
+  width: 2em;
+  margin: 0; padding: .25em;
+  text-align: right;
+  line-height: 1em;
+  box-shadow: none;
+  border-radius: 0;
+  border: none;
+}
+table.calendar td input:checked + label,
+table.calendar td label[checked],
+table.calendar td button[name$=_remove] {
+  font-weight: bold;
+  line-height: .75em;
+  border: .125em solid;
+}
+
+#comments input#comments_toggle_new,
+#comments input#comments_toggle_new + label + form {
+  display: none;
+  text-align: left;
+}
+#comments input#comments_toggle_new:checked + label + form {
+  display: block;
+}
+#comments input#comments_toggle_new + label {
+  display: block;
+  width: 100%; margin: 0;
+  padding: .25em .75em;
+  background-color: #FFF;
+  text-align: left;
+  border: .5pt solid;
+  border-radius: 2pt;
+}
+#comments input#comments_toggle_new:checked + label {
+  display: none;
+}
+
+#comments form label.legend {
+  display: block;
+}
+
+#comments input[name=username],
+#comments textarea {
+  width: 100%;
+}
+#comments textarea {
+  min-height: 7em;
+  margin: .5em 0;
+}
+
+#comments .comment {
+  text-align: left;
+  margin: .5em 0;
+  padding: 0 .5em .25em .5em;
+  -padding-left: .5em;
+  -border-left: 3pt solid #CCC;
+  box-shadow: #000 .25em .25em .5em;
+}
+
+#comments .comment > h3 {
+  padding-top: .5em;
+  margin-bottom: 0;
+}
+
+#comments .comment > span {
+  display: inline-block;
+  min-width: 50%;
+  font-size: .875em;
+  color: #666;
+}
+#comments .comment > a.edit {
+  display: block;
+  min-width: 50%;
+  font-size: .875em;
+}
+#comments .comment > div {
+  border-top: .5pt solid;
+}
+
diff --git a/widgets.sh b/widgets.sh
new file mode 100755 (executable)
index 0000000..8410a0c
--- /dev/null
@@ -0,0 +1,128 @@
+#!/bin/sh
+
+[ -n "$include_widgets" ] && return 0
+include_widgets="$0"
+
+export MD_HTML="false"
+if [ "$(which awk)" ]; then
+  markdown() { awk -f "$_EXEC/cgilite/markdown.awk"; }
+else
+  markdown() { busybox awk -f "$_EXEC/cgilite/markdown.awk"; }
+fi
+
+dec(){
+  local n
+  for n in "$@"; do
+    while [ "${n}" != "${n#0}" ]; do n="${n#0}"; done
+    printf %i\\n "$n"
+  done
+}
+
+checked(){
+  local check="$1"; shift 1;
+  for comp in "$@"; do
+    if [ "$check" = "$comp" ] || [ "$check" -eq "$comp" ]; then
+      printf 'checked="checked"'
+      break;
+    fi 2>/dev/null
+  done
+}
+selected(){
+  local check="$1"; shift 1;
+  for comp in "$@"; do
+    if [ "$check" = "$comp" ] || [ "$check" -eq "$comp" ]; then
+      printf 'selected="selected"'
+      break;
+    fi 2>/dev/null
+  done
+}
+
+w_month() {
+  # Arguments:
+  # 1. (optional) select, multiple, submit, none - default: select
+  # 2. (optional) Name of form field - default: "date"
+  # 3. (optional) Month to display in format: YYYY-MM - default: current month
+  # 4. (optional, multiple) Days to preselect in format: DD - default: none
+
+  local type="${1:-select}" input="${2:-date}" month="$3"
+  shift 3; local selected="$*"
+  local dow dom days n=1 Y m d V w B
+  if [ $month ]; then
+    read Y m d V w B<<-EOF
+       $(date -d "${month}-01" +"%_Y %_m %_d %_V %w %B")
+       EOF
+  else
+    read Y m d V w <<-EOF
+       $(date +"%Y %m %d %V %w")
+       EOF
+    month="$Y-$m"
+    V="$((V - d / 7))"
+    [ $V -lt 1 ] && V=$((V + 53))
+  fi
+
+  case $m in
+    [13578]|10|12)
+      days="1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31";;
+    [469]|11)
+      days="1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30";;
+    2) if [ $(( Y / 400 )) = 0 ]; then
+        days="1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29";
+      elif [ $(( Y / 100 )) = 0 ]; then
+        days="1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28";
+      elif [ $(( Y /   4 )) = 0 ]; then
+        days="1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29";
+      else
+        days="1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28";
+      fi;;
+  esac
+
+  printf '[table .calendar month=%s [thead
+            [tr .monthname [th colspan=8 . %s]]
+            [tr   .weekday [th][th . %s][th . %s][th . %s][th . %s][th . %s][th . %s][th . %s]]
+          ][tbody
+         ' "$month" "$B $Y" Mo Tu We Th Fr Sa Su
+  for dom in $days; do
+    dow=$(( ( w - d + 35 + dom ) % 7))
+    [ $dow = 1 -o $dom = 1 ] && printf '[tr [th .weekno . %i]' $V
+    [ $dom = 1 ] && while [ $n -lt $(( ($dow + 6) % 7 + 1)) ]; do printf '[td ]'; n=$((n + 1)); done
+    date="$(printf "%04i-%02i-%02i" $Y $m $dom)"
+    case $type in
+      none)
+        printf '[td [label %s . %i]]' \
+               "$(checked $dom $selected)" "$dom"
+        ;;
+      multiple)
+        printf '[td [input type=checkbox id="%s_%s" name="%s" value="%s" %s][label for="%s_%s" . %i]]' \
+               "$input" "$date" "$input" "$date" "$(checked $dom $selected)" "$input" "$date" "$dom"
+        ;;
+      submit)
+        [ "$(checked $dom $selected)" ] \
+        && printf '[td [submit "%s_remove" "%s" . %i][hidden "%s" "%s"]]' "$input" "$date" "$dom" "$input" "$date" \
+        || printf '[td [submit "%s_add"    "%s" . %i]]' "$input" "$date" "$dom"
+        ;;
+      select|*)
+        printf '[td [input type=radio id="%s_%s" name="%s" value="%s" %s][label for="%s_%s" . %i]]' \
+               "$input" "$date" "$input" "$date" "$(checked $dom $selected)" "$input" "$date" "$dom"
+        ;;
+    esac
+    if [ $dow = 0 ]; then
+      printf ']\n'
+      V=$((V + 1))
+      [ $m = 1 -a $V -ge 53 ] && V=1
+    fi
+  done
+  if [ $dow -gt 0 ]; then
+    while [ $dow -le 6 ]; do printf '[td ]'; dow=$((dow + 1)) ; done
+    printf ']\n'
+  fi
+  printf ']]'
+}
+
+dlist_timeofday() {
+  local step="${1:-15}" id="${2:-dlist_timeofday}"
+  printf '[datalist id="%s"\n' $id
+    for h in $(seq 0 23); do for m in $(seq 0 "$step" 59); do
+      printf '[option value="%i:%02i"]\n' $h $m
+    done; done
+  printf ']\n'
+}