]> git.plutz.net Git - serve0/commitdiff
Merge commit 'b931bbd0c30907b9cc956d3707b26b449bf41f76' master
authorPaul Hänsch <paul@plutz.net>
Sun, 14 Jan 2024 21:21:33 +0000 (22:21 +0100)
committerPaul Hänsch <paul@plutz.net>
Sun, 14 Jan 2024 21:21:33 +0000 (22:21 +0100)
24 files changed:
.gitignore
Makefile [new file with mode: 0644]
advsearch.sh [new file with mode: 0755]
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]
db_meta.sh [new file with mode: 0755]
index.cgi [new file with mode: 0755]
index2post.sh [new file with mode: 0755]
indexmeta.sh [new file with mode: 0755]
list.sh [new file with mode: 0755]
multitag.sh [new file with mode: 0755]
stereoview.js [new file with mode: 0644]
style.css [new file with mode: 0644]
thumbnail.sh [new file with mode: 0755]
view.sh [new file with mode: 0755]
widgets.sh [new file with mode: 0755]

index 5c9950ae1c74eb4a290c3887873f567f85c3de4d..a01ee289f9a3c65287845c5138783d4f3b24a443 100644 (file)
@@ -1,3 +1 @@
-cgilite
-serverkey
-users.db
+.*.swp
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..de179ab
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,10 @@
+.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/advsearch.sh b/advsearch.sh
new file mode 100755 (executable)
index 0000000..24d31c9
--- /dev/null
@@ -0,0 +1,21 @@
+#!/bin/sh
+
+f=''
+order="$(POST order |grep -m1 -xE 'Name|Date|Length|Group' || printf Name)"
+
+for n in 1 2 3 4 5 6 7 8 9 10; do
+  [ "$(POST pol_$n)" = neg ] \
+  && f="$f~"
+  cat="$(POST cat_$n)"
+  for m in $(seq 1 $(POST_COUNT tag_$n)); do
+    tag="$(POST tag_$n $m)"
+    [ ! "${tag##${cat}:*}" ] || [ ! "${tag##-${cat}:*}" ] || [ "$cat" = '*' -a "${tag##*:*}" ] \
+    && f="${f}${tag}|"
+    [ "$cat" = \$ ] && f="${f}\$:${tag}|"
+  done
+  f="${f%[|^]}^"
+done
+f="$(printf '%s' "$f" |sed -E 's;[~|^]+$;;; s;\|\^;^;g;')"
+#f="${f%^}"
+
+REDIRECT "$(URL "${ITEM}")?o=${order}&f=${f}"
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/db_meta.sh b/db_meta.sh
new file mode 100755 (executable)
index 0000000..572ebbd
--- /dev/null
@@ -0,0 +1,208 @@
+#!/bin/sh
+
+[ "$include_dbmeta" ] && return 0
+include_dbmeta="$0"
+
+. "$_EXEC/cgilite/storage.sh"
+
+# == FILE FORMAT ==
+# LENGTH       WIDTH   HEIGHT  TAGS    COMMENT NAME    GROUP
+
+# == GLOBALS ==
+UNSET_META='unset \
+  META_LENGTH META_WIDTH META_HEIGHT META_TAGS META_COMMENT META_NAME META_GROUP
+'
+
+LOCAL_META='local \
+  META_LENGTH META_WIDTH META_HEIGHT META_TAGS META_COMMENT META_NAME META_GROUP
+'
+
+eval "$UNSET_META"
+
+read_meta() {
+  local name="$1" meta_db="$_DATA/.index/meta"
+
+  [ "${name%%/*}" != "$name" ] \
+  && meta_db="$_DATA/${name%%/*}/.index/meta"
+  name="$(STRING "${name##*/}")"
+
+  # Global exports
+  META_LENGTH='' META_WIDTH='' META_HEIGHT='' META_TAGS=''
+  META_COMMENT='' META_NAME='' META_GROUP='' META_GROUPORDER=''
+
+  if [ $# -eq 0 ]; then
+    read -r META_LENGTH META_WIDTH META_HEIGHT META_TAGS META_COMMENT \
+            META_NAME META_GROUP META_GROUPORDER
+  elif [ "$name" -a -f "$meta_db" -a -r "$meta_db" ]; then
+    read -r META_LENGTH META_WIDTH META_HEIGHT META_TAGS META_COMMENT \
+            META_NAME META_GROUP META_GROUPORDER <<-EOF
+       $(grep -F "     ${name}${CR}" "$meta_db" |dbmeta_autogroup)
+       EOF
+  fi
+  if [ "$META_NAME" ]; then
+       META_NAME="$(UNSTRING "${META_NAME%${CR}}")"
+    META_COMMENT="$(UNSTRING "${META_COMMENT#comment=}")"
+       META_TAGS="$(UNSTRING "${META_TAGS#tags=}")"
+      META_GROUP="${META_GROUP#\\}"
+  else
+    eval "$UNSET_META"
+    return 1
+  fi
+}
+
+update_meta(){
+  local name="${1:=${META_NAME}}" tags comment length width height group
+  local LENGTH WIDTH HEIGH TAGS COMMENT NAME GROUP
+  local arg cnt meta_db="$_DATA/.index/meta"
+
+  [ "${name%%/*}" != "$name" ] \
+  && meta_db="$_DATA/${name%%/*}/.index/meta"
+  name="$(STRING "${name##*/}")"
+
+  for arg in "$@"; do case $arg in
+    comment=*) comment="${arg#*=}";;
+       tags=*)    tags="${arg#*=}";;
+     lenght=*)  lenght="${arg#*=}";;
+      width=*)   width="${arg#*=}";;
+     height=*)  height="${arg#*=}";;
+      group=*)   group="${arg#*=}";;
+  esac; done
+
+  if LOCK "$meta_db"; then
+    read -r LENGTH WIDTH HEIGHT TAGS COMMENT NAME GROUP <<-EOF
+       $(grep -F "     ${name}${CR}" "$meta_db")
+       EOF
+    if [ ! "$NAME" ]; then
+      RELEASE "$meta_db"
+      return 1
+    fi
+    printf '%i %i      %i      tags=%s comment=%s      %s\r    %s\n' \
+           "${length:-${length-${LENGTH}}${length+0}}" \
+           "${width:-${width-${WIDTH}}${width+0}}" \
+           "${height:-${height-${HEIGHT}}${height+0}}" \
+           "$(STRING "${tags-$(UNSTRING "${TAGS#tags=}")}")" \
+           "$(STRING "${comment-$(UNSTRING "${COMMENT#comment=}")}")" \
+           "${NAME%${CR}}" "${group:-${GROUP:-\\}}" \
+    >"${meta_db}.$$"
+
+    grep -vF " ${name}${CR}" "$meta_db" >>"${meta_db}.$$"
+
+    mv -- "${meta_db}.$$" "${meta_db}"
+    RELEASE "$meta_db"
+  else
+    return 1
+  fi
+}
+
+new_meta(){
+  local name="$1" meta_db="$_DATA/.index/meta"
+  local LENGTH WIDTH HEIGHT TAGS COMMENT NAME GROUP
+
+  [ "${name%%/*}" != "$name" ] \
+  && meta_db="$_DATA/${name%%/*}/.index/meta"
+  name="$(STRING "${name##*/}")"
+
+  if LOCK "$meta_db"; then
+    while read -r LENGTH WIDTH HEIGHT TAGS COMMENT NAME GROUP; do
+      if [ "$name" = "${NAME%${CR}}" ]; then
+        RELEASE "$vid_db"
+        return 1
+      fi
+    done <"$meta_db"
+    printf '0  0       0       tags=\\ comment=\\      %s\r    \\\n' \
+           "${name}" >>"$meta_db"
+    RELEASE "$meta_db"
+  else
+    return 1
+  fi
+}
+
+delete_meta() {
+  local name="$1" meta_db="$_DATA/.index/meta"
+  local LENGTH WIDTH HEIGHT TAGS COMMENT NAME GROUP
+
+  [ "${name%%/*}" != "$name" ] \
+  && meta_db="$_DATA/${name%%/*}/.index/meta"
+  name="$(STRING "${name##*/}")"
+
+  if LOCK "$meta_db"; then
+    while read -r LENGTH WIDTH HEIGHT TAGS COMMENT NAME GROUP; do
+      [ "$name" = "${NAME%${CR}}" ] \
+      || printf '%i    %i      %i      tags=%s comment=%s      %s\r    %s\n' \
+                "$length" "$width" "$height" "${TAGS#tags=}" \
+                "${COMMENT#comment=}" "${NAME%${CR}}" "${GROUP:-\\}"
+    done <"$meta_db" >"${meta_db}.$$"
+
+    mv -- "${meta_db}.$$" "$meta_db"
+    RELEASE "$meta_db"
+  else
+    return 1
+  fi
+}
+
+list_meta(){
+  local meta pfx
+  local LENGTH WIDTH HEIGHT TAGS COMMENT NAME GROUP
+
+  if [ "$#" -eq 0 ]; then
+    find "$_DATA" -path '*/.index/meta'
+  else
+    printf %s\\n "$@"
+  fi \
+  | while read meta; do
+    pfx="${meta#$_DATA}"
+    pfx="${pfx%/.index/meta}"
+    pfx="$(STRING "${pfx#/}")"
+    [ "$pfx" = '\' ] && pfx='' || pfx="${pfx}/"
+
+    { printf '%s\n' "$pfx"
+      dbmeta_autogroup "$meta"
+    } | sed -E '
+      1{ h; d; }
+      G;
+      s;^([^\t]+       [^\t]+  [^\t]+  [^\t]+  [^\t]+  )([^\n]+)\n(.*)$;\1\3\2;
+    '
+  done
+}
+
+dbmeta_autogroup(){
+  sed -E '
+    # strip empty group field
+    s;\r       \\$;\r;;
+    h;  # save original dataset
+
+    # strip common suffixes of web video sites
+    s;-([0-9a-zA-Z_-]{11}|ph[0-9a-f]{13}|xh[0-9a-zA-Z]{5}|[0-9]{6,})\r;-\r;;
+
+    # perform auto grouping if group id is missing or empty
+    /\r$/bAUTOGROUP;
+  
+    # only perform ordering if manual group id is present
+    /\r        .+$/bAUTOORDER;
+
+    b;  # pass invalid records without processing
+
+    :AUTOORDER
+    # strip all fields but the name
+    s;^([^\t]+ [^\t]+  [^\t]+  [^\t]+  [^\t]+  )([^\r]+)\r     (.+)$;\2;
+
+    # reduce to numerals
+    s;[^0-9]+;;g;
+
+    # append ordering field to dataset
+    H; g; s;\n;\t;;
+    b;
+  
+    :AUTOGROUP
+    # strip all fields but the name
+    s;^[^\t]+  [^\t]+  [^\t]+  [^\t]+  [^\t]+  ;;
+  
+    # replace all numeric parts and append numerals to an ordering field
+    # the group id will be made up of only the non-numeric character frame
+    # the ordering field will hold all numbers from the name
+    :X s;^([^0-9]*)([0-9]+)([^\r]*)\r\t?([0-9]*)$;\1\r\3\r     \4\2;; tX;
+  
+    # append group id and ordering field to dataset
+    H; g; s;\n;\t;;
+  ' "$@"
+}
diff --git a/index.cgi b/index.cgi
new file mode 100755 (executable)
index 0000000..79b9a87
--- /dev/null
+++ b/index.cgi
@@ -0,0 +1,96 @@
+#!/bin/sh
+
+exec 2>/dev/null
+file_pattern='^.*\.(mov|ts|mpg|mpeg|mp4|m4v|avi|mkv|flv|sfv|wmv|ogm|ogv|webm|iso|rmvb)$'
+
+. "${_EXEC:-${0%/*}}/cgilite/cgilite.sh"
+
+FILTER="$(GET f)"
+SEARCH="$(GET s)"
+ORDER="$(GET o |grep -m1 -axE 'Date|Name|Length|Group' || printf Name)"
+LISTSIZE="$(COOKIE pagesize |grep -m1 -axE '[1-9][0-9]*' || printf 60)"
+ITEM="${PATH_INFO%/}"
+ACTION="$(GET a)"
+
+case $ACTION in
+  setprefs)
+    SET_COOKIE +$((86400 * 90))  pagesize="$(POST pagesize |grep -m1 -axE '[1-9][0-9]*' || printf 60)"
+    SET_COOKIE +$((86400 * 90))      mode="$(POST     mode |grep -m1 -axE 'browse|index' || printf browse)"
+    SET_COOKIE +$((86400 * 90))   fakemp4="$(POST  fakemp4 |grep -m1 -axE 'yes' || printf no)"
+    SET_COOKIE +$((86400 * 90)) downscale="$(POST  downscale |grep -m1 -axE 'yes' || printf no)"
+    [ "$(POST index)" = "update" ] && touch -cd @0 "${_DATA}/.index/meta.time"
+    REDIRECT "$(POST ref)"
+  ;;
+  bookmark)
+    bm="$_DATA/.index/bookmarks"
+    . "$_EXEC/cgilite/storage.sh"
+
+    s="$(POST search |STRING)"; f="$(POST filter |STRING)"
+    if LOCK "$bm"; then
+      grep -avF "      search=$s       filter=$f${CR}" "$bm" >"$bm.tmp"
+      [ ! "$(POST delete)" ] \
+      && printf '%s    search=%s       filter=%s\r\n' \
+               "$(POST name |STRING)" "$s" "$f" >>"$bm.tmp"
+      mv "$bm.tmp" "$bm"
+      RELEASE "$bm"
+    fi
+    REDIRECT "$(POST ref)"
+  ;;
+  multitag)
+    . "$_EXEC/multitag.sh"
+    REDIRECT "$(POST ref)"
+  ;;
+  thumbnail) if [ -f "$_DATA/$PATH_INFO" ]; then
+    . "$_EXEC/cgilite/file.sh"
+    index="$_DATA/${PATH_INFO%/*}/.index"
+    thumb="$index/${PATH_INFO##*/}"; thumb="${thumb%.*}.jpg"
+    if [ -d "$index" -a ! -f "$thumb" ] && { printf %s "$PATH_INFO" |grep -qE -e "${file_pattern}" ;}; then
+      . "$_EXEC/thumbnail.sh"
+      gen_thumb "$_DATA/$PATH_INFO" "$thumb"
+    fi
+    FILE "$thumb"
+    return 0
+  fi;;
+  download) if [ -f "$_DATA/$PATH_INFO" ]; then
+    . "$_EXEC/cgilite/file.sh"
+    fakemp4="$(COOKIE fakemp4)"
+    downscale="$(COOKIE downscale)"
+    downfile="$_DATA/${PATH_INFO%/*}/.transcode/${PATH_INFO%.*}.480p.webm"
+    if [ "$downscale" = yes -a -f "$downfile" ]; then
+      FILE "$downfile" "$([ "$fakemp4" = yes ] && printf 'video/mp4')"
+    else
+      FILE "$_DATA/$PATH_INFO" "$([ "$fakemp4" = yes ] && printf 'video/mp4')"
+    fi
+    return 0
+  fi;;
+  delete) if [ -f "$_DATA/$PATH_INFO" ]; then
+    :
+  fi;;
+  advsearch) if [ -d "$_DATA/$PATH_INFO" ]; then
+    . "$_EXEC/advsearch.sh"
+    return 0
+  fi;;
+  spawnindex) if [ -d "$_DATA/$PATH_INFO" ]; then
+    if [ "$(POST recursive)" = yes ]; then
+      find "$_DATA/$PATH_INFO" -depth -type d \! -name .index \
+                          -exec mkdir -p '{}'/.index \;
+    else
+      mkdir -p "$_DATA/$PATH_INFO/.index"
+    fi
+    REDIRECT "$(POST ref)"
+  fi;;
+esac
+
+if [ -f "$_EXEC/$PATH_INFO" ]; then
+  . "$_EXEC/cgilite/file.sh"
+  FILE "$_EXEC/$PATH_INFO"
+  return 0
+elif [ -f "$_DATA/$PATH_INFO" ]; then
+  . "$_EXEC/view.sh"
+  return 0
+elif [ -d "$_DATA/$PATH_INFO" ]; then
+  . "$_EXEC/list.sh"
+  return 0
+else
+  printf 'Status: 404 Not Found\r\nContent-Length 0:\r\n\r\n'
+fi
diff --git a/index2post.sh b/index2post.sh
new file mode 100755 (executable)
index 0000000..5fdf9ca
--- /dev/null
@@ -0,0 +1,12 @@
+#!/bin/sh
+
+REQUEST_METHOD=manual
+. "${_EXEC:-${0%/*}}/cgilite/cgilite.sh"
+. "${_EXEC}/cgilite/storage.sh"
+
+find "${_DATA}" -path '*/.index/meta' -exec cat '{}' + \
+| while read l w h t c n; do
+  printf '?op=add&select=%s' "/$(URL "$(UNSTRING "${n%$CR}")"*)"
+  printf "&tag=%s" $(printf %s "${t#tags=}" |tr , \  )
+  printf \\n
+done
diff --git a/indexmeta.sh b/indexmeta.sh
new file mode 100755 (executable)
index 0000000..87fbd12
--- /dev/null
@@ -0,0 +1,101 @@
+#!/bin/sh
+
+[ -n "$include_indexmeta" ] && return 0
+include_indexmeta="$0"
+
+. "$_EXEC/cgilite/storage.sh"
+file_pattern='^.*\.(mov|ts|mpg|mpeg|mp4|m4v|avi|mkv|flv|sfv|wmv|ogm|ogv|webm|iso|rmvb)$'
+
+meta_name() {
+  local fn="${1##*/}"
+  STRING "${fn%.*}"
+}
+
+meta_line() {
+  local video probe l w h
+  video="$1"
+  [ "${video%.part}" = "$video" -a -s "$video" ] || return 0
+
+  probe="$(printf \\n; ffprobe -show_entries format=duration:stream=width,height "$video" 2>&-)"
+  l="${probe#*duration=}" l="${l%%${BR}*}" l="${l%.*}"
+  w="${probe#*width=}"    w="${w%%${BR}*}"
+  h="${probe#*height=}"   h="${h%%${BR}*}"
+
+  printf '%i   %i      %i      tags=   comment=        %s\r\n' \
+         "${l:-0}" "${w:-0}" "${h:-0}" "$(meta_name "$video")"
+}
+
+meta_file(){
+  local file meta name
+  file="$1"
+  meta="${file%/*}/.index/meta"
+  name="$(meta_name "$file")"
+
+  if [ -d "${meta%/meta}" ] && LOCK "$meta"; then
+    grep -avF "        ${name}${CR}" "$meta" >"$meta.tmp"
+    meta_line "$file" \
+    | tee -a "$meta.tmp"
+    mv "$meta.tmp" "$meta"
+    RELEASE "$meta"
+  fi
+}
+
+meta_purge(){
+  local file meta name
+  file="$1"
+  meta="${file%/*}/.index/meta"
+  name="$(meta_name "$file")"
+
+  if [ -d "${meta%/meta}" ] && LOCK "$meta"; then
+    grep -avF "        ${name}${CR}" "$meta" >"${meta}.tmp"
+    grep -aF " ${name}${CR}" "$meta" >>"${meta}.trash"
+    mv "${meta}.tmp" "$meta"
+    RELEASE "$meta"
+  fi
+}
+
+meta_info(){
+  local file meta
+  file="$1"; meta="${file%/*}/.index/meta"
+
+  if [ -d "${meta%/meta}" ]; then
+    grep -aF " $(meta_name "$file")${CR}" "$meta" \
+    | grep -m1 -axE '[0-9]+    [0-9]+  [0-9]+  tags=[^ ]*      comment=[^      ]*      .+' \
+    || meta_file "$file"
+  else
+    printf '0\t0\t0\ttags=\tcomment=\t%s\r\n' "$(meta_name "$file")"
+  fi
+}
+
+meta_dir(){
+  local dir meta v
+  dir="${1}"
+  meta="${dir}/.index/meta"
+  metat="${dir}/.index/meta.time"
+
+  [ -f "$metat" ] || touch -d @0 "$metat"
+
+  if [ -d "$dir/.index" -a \! -f "$meta" ] && LOCK "$meta"; then
+    touch "$meta"  # preliminary touch to prevent concurrent generators
+    find -L "$dir" -type f -mindepth 1 -maxdepth 1 \
+    | grep -aE "$file_pattern" \
+    | while read -r v; do
+      meta_line "$v"
+    done >"$meta"
+    touch "$metat"
+
+    RELEASE "$meta"
+  elif [ -d "$dir/.index" -a "$dir" -nt "$metat" ] && LOCK "$meta"; then
+    touch "$meta"
+    find -L "$dir" -type f -newer "$metat" \
+         -mindepth 1 -maxdepth 1 \
+    | grep -aE "$file_pattern" \
+    | while read -r v; do
+      grep -qF "       $(meta_name "$v")${CR}" "$meta" \
+      || meta_line "$v"
+    done >>"$meta"
+    touch "$metat"
+
+    RELEASE "$meta"
+  fi
+}
diff --git a/list.sh b/list.sh
new file mode 100755 (executable)
index 0000000..94a9264
--- /dev/null
+++ b/list.sh
@@ -0,0 +1,229 @@
+#!/bin/sh
+
+. "$_EXEC/indexmeta.sh"
+. "$_EXEC/widgets.sh"
+. "$_EXEC/db_meta.sh"
+
+list_item() {
+  local meta file link name
+
+  if [ "${META_NAME%/}" != "${META_NAME}" ]; then
+    printf '[a .list .dir href="%s?%s" . %s]' \
+           "$(URL "${PATH_INFO%/}/${META_NAME}")" "${w_refuri#*\?}" \
+           "$(HTML "${META_NAME%/}")"
+    return 0
+  fi
+
+  file="$_DATA/${PATH_INFO%/}/$(list_fullname "${META_NAME}")"
+  if [ -f "$file" ]; then
+    link="$(URL "${PATH_INFO%/}/${file#${_DATA}/${PATH_INFO}}")"
+    name="$(HTML "${PATH_INFO%/}/${file#${_DATA}/${PATH_INFO}}")"
+    printf '[div .list .file
+              [a href="%s" [img src="%s?a=thumbnail"]][label . %s]
+              [span .time %i:%02imin] [span .dim %ix%i] %s
+              [checkbox "select" "%s" id="select_%s"][label for="select_%s" +]
+            ]' \
+      "$link" "$link" "${name##/}" \
+      "$((META_LENGTH / 60))" "$((META_LENGTH % 60))" \
+      "$META_WIDTH" "$META_HEIGHT" \
+      "$(printf %s\\n "${META_TAGS}" \
+         | sed -r 's;^;,;; s;,+;,;g; s;,$;;;
+                   :X s;,-?([^,]+)(,|$); [span .tag\n \1]\2;; tX;'
+      )" "$name" "$link" "$link"
+  else
+    debug "Canning record for nonexist file: $META_NAME"
+    meta_purge "$_DATA/$ITEM/$META_NAME"
+  fi
+}
+
+[ "$FILTER" ] && list_fex="$(
+  fex='p'
+  STRING "$FILTER^" \
+  | sed -E 's;\^;\n;g; s;[]\/\(\)\\\^\$\?\.\+\*\;\[\{\}];\\&;g' \
+  | while read -r f; do
+    [ "${f##*[A-Z]*}" ] && tl="y;ABCDEFGHIJKLMNOPQRSTUVWXYZ;abcdefghijklmnopqrstuvwxyz;;"
+    case $f in
+      ''|~) continue;;
+      ~\\\$:*) fex="h; ${tl} /${f#~\\\$:}/d; g;${fex}";;
+      \\\$:*) fex="h; ${tl} /${f#\\\$:}/{g;${fex}}";;
+      ~*) fex="/(\ttags=([^\t]*,)?)(${f#\~})((,[^\t]*)?\t)/d; ${fex}";;
+       *) fex="/(\ttags=([^\t]*,)?)(${f})((,[^\t]*)?\t)/{${fex}}";;
+    esac
+    printf '%s\n' "${fex}"
+  done \
+  | tail -n1
+)"
+
+list_fullname(){
+  local short="$1" file
+  file="$(printf %s\\n "$_DATA/$ITEM/$short".*)"
+  file="${file%%${BR}*}"
+  [ -e "$file" ] && printf %s\\n "${file#${_DATA}/${ITEM}/}"
+}
+
+list_filter(){
+  if [ "$FILTER" ]; then
+    debug "FEX:" "$list_fex"
+    sed -nE "$list_fex"
+  elif [ "${SEARCH#!}" != "${SEARCH}" ]; then
+    grep -aviEe "$(STRING "${SEARCH}" \
+                 | sed -E ':x s;((^|[^\\])(\\\\)*)\+;\1 ;g; tx;
+                            s;((^|[^\\])(\\\\)*)\\\+;\1+;g;
+                            s; ;\\+;g;')"
+  elif [ "${SEARCH}" ]; then
+    grep -aiEe "$(STRING "${SEARCH}" \
+                 | sed -E ':x s;((^|[^\\])(\\\\)*)\+;\1 ;g; tx;
+                            s;((^|[^\\])(\\\\)*)\\\+;\1+;g;
+                            s; ;\\+;g;')"
+  else
+    cat
+  fi
+}
+
+list_order(){
+  local fm fn fn al length ln h w t c name group o buffer l
+
+  if [ $ORDER = Name ]; then
+    sort -k6
+  elif [ $ORDER = Group ]; then
+    { sort -n -k8 -k6,6 |sort -s -k7,7 ; echo '0 0 0 tags= comment= _'; } \
+    | while read -r length w h t c name group o; do
+      if [ "${ln%% *}" = "${group}" ]; then
+        al=$((al + length))
+        buffer="${buffer}${BR}$length  $w      $h      $t      $c      $name"
+      else
+       printf '%s\n' "$buffer" |while read -r l; do
+          [ "$l" ] && printf '%i       %s\n' "$al" "$l"
+        done
+        al="$length"
+        buffer="$length        $w      $h      $t      $c      $name"
+      fi
+      ln="$group"
+    done \
+    | sort -s -n -k1,1 |sed -E 's;^[0-9]+\t;;;'
+  elif [ $ORDER = Length ]; then
+    sort -sn -k1
+  elif [ $ORDER = Date ]; then
+    while read -r fm; do
+      fn="${fm%${CR}   *}"
+      fn="$(list_fullname "${fn##*     }")"
+      printf '%i       %s\n' \
+             "$(stat -c %Y "$fn")" "${fm}"
+    done \
+    | sort -srn -k1 |sed -E 's;^[0-9]+\t;;;'
+  fi
+}
+
+list_items() {
+  local mode meta cachename
+  mode="$(COOKIE mode |grep -m1 -axE 'index|browse' || printf index )"
+  
+  cachename="$(printf '%s\n' "$mode" "$FILTER" "$SEARCH" "$ORDER" |sha1sum)"
+  cachename="$_DATA/$ITEM/.index/${cachename%  -}.cache"
+  meta="$_DATA/$ITEM/.index/meta"
+  meta_dir "$_DATA/$ITEM/"
+
+  if [ "$mode" = browse ]; then
+    [ "$ITEM" ] && printf '0   0       0       \       \       ../\n'
+    (cd "$_DATA/$ITEM";
+      find ./ -type d \! -name .index -mindepth 1 -maxdepth 1 \
+    ) | sort |while read dir; do
+      printf '0        0       0       \\      \\      %s\n' "$(STRING "${dir#./}")"
+    done
+
+    if [ "$cachename" -nt "$meta" ]; then
+      cat "$cachename"
+    else
+      list_meta "$meta" \
+      | list_filter \
+      | list_order \
+      | { [ -d "${cachename%/*}" ] && tee "$cachename" || cat; }
+    fi
+
+  elif [ "$mode" = index ]; then
+    if [ -f "$cachename" -a ! "$(find "$_DATA" -path '*/.index/meta' -newer "$cachename")" ]; then
+      cat "$cachename"
+    else
+      list_meta \
+      | list_filter \
+      | list_order \
+      | { [ -d "${cachename%/*}" ] && tee "$cachename" || cat; }
+    fi
+  fi
+}
+
+list_paginate() {
+  local page i c n end qry
+  page="$(GET p |grep -axE '[0-9]+' || printf 1)"; c=1
+  end=$((page + LISTSIZE))
+  eval "$LOCAL_META"  # localize vars from db_meta
+
+  printf '[div .itemlist '
+  while :; do
+    if [ $c -lt $page ]; then
+      read -r discard || break
+    elif [ $c -ge $end ]; then
+      c=$((c + $(wc -l) ))
+      break
+    else
+      read_meta || break
+      list_item
+    fi
+    c=$((c + 1))
+  done
+  printf ']'
+
+  [ $(( c % LISTSIZE )) -gt 0 ] \
+  && end=$((c / LISTSIZE + 1)) \
+  || end=$((c / LISTSIZE))
+
+  printf '[div .pagination'
+  for n in $( seq 1 $end ); do
+    c=$(( (n - 1) * LISTSIZE + 1 ))
+    [ $c = $page ] \
+    && printf '[a .page .current href="?p=%i&%s" %i]' "${c}" "${QUERY_STRING#p=*&}" "$n" \
+    || printf '[a .page          href="?p=%i&%s" %i]' "${c}" "${QUERY_STRING#p=*&}" "$n"
+  done
+  printf ']'
+}
+
+printf 'Content-Type: text/html;charset=utf-8\r\n\r\n'
+
+{ printf '
+[!DOCTYPE HTML]
+[html [head [title '
+  w_bmname
+  printf ' by %s]' "$ORDER"
+  printf '
+  [meta name="viewport" content="width=device-width"]
+  [link rel=stylesheet href="/cgilite/common.css" ]
+  [link rel=stylesheet href="/style.css" ]
+] [body
+  [div #navigation
+    [a #t_bookmarks href="#bookmarks" &#x2605;]'
+    w_search
+    printf '
+    [a #t_prefs href="#prefs" &#x2699;]
+  ]'
+  w_bookmarks
+  w_advsearch
+  w_prefs
+  printf '
+  [form method=POST action="?a=multitag"'
+    list_items \
+    | list_paginate
+    [ -d "$_DATA/$ITEM/.index" ] && { printf '
+    [div #editing'
+      w_tagging
+    printf '
+    ]'; }
+  printf '
+  ]'
+  [ ! -d "$_DATA/$ITEM/.index" ] && { printf '
+  [div #editing'
+    w_index
+  printf '
+  ]'; }
+  printf '
+] ]
+'; } | "$_EXEC/cgilite/html-sh.sed"
diff --git a/multitag.sh b/multitag.sh
new file mode 100755 (executable)
index 0000000..c66ff14
--- /dev/null
@@ -0,0 +1,73 @@
+#!/bin/sh
+
+. "$_EXEC/db_meta.sh"
+. "$_EXEC/cgilite/session.sh" nocookie
+. "$_EXEC/widgets.sh"
+
+# newtags=''  # tags selected in ui
+# tags=''     # taglist from database
+# detag=''    # temporary remove list
+# extags=''   # exclusive tags, tags that preclude others within their categorie
+# group=''    # id for grouping videos (videos in a group get a common group id)
+# op=''       # operation: add | del | flip
+
+newtags=''
+for tn in $(seq 1 $(POST_COUNT tag)); do
+  newtags="$(POST tag $tn)${BR}${newtags}"
+done
+newtags="$(POST newtag |tr -d '\r' |tr , '\n')${BR}${newtags}"
+
+if [ "$(POST makegroup)" = true ]; then
+  group="$(timeid)"
+fi
+
+# strip leading and trailing line breaks
+while [ "${newtags#${BR}}" != "${newtags}" ]; do newtags="${newtags#${BR}}"; done
+while [ "${newtags%${BR}}" != "${newtags}" ]; do newtags="${newtags%${BR}}"; done
+
+op="$(POST op)"
+
+for select in $(seq 1 $(POST_COUNT select)); do
+  file="$(POST select $select)"
+  read_meta "${file%.*}" || continue
+
+  tags="$(printf %s\\n "$META_TAGS" |tr , \\n)"
+
+  if [ "$op" = add ]; then
+    extags="$(printf '%s' "${newtags}" |grep -e "^-" |cut -d: -f1 )"
+    [ "$extags" ] && tags="$(printf %s\\n "$tags" |grep -vwFe "$extags")"
+    if printf %s "${newtags}" |grep -e "^-" |grep -qEe "^-[^:]+$"; then
+      tags="$(printf %s\\n "$tags" |grep -vEe '^-[^:]+$')"
+    fi
+    tags="${tags}${BR}${newtags}"
+
+  elif [ "$op" = del ]; then
+    detag="${newtags}${BR}"; while [ "$detag" ]; do
+      tags="$(printf '%s\n' "$tags" |grep -vxFe "${detag%%${BR}*}")"
+      detag="${detag#*${BR}}"
+    done
+
+  elif [ "$op" = flip ]; then
+    fliptag="${newtags}${BR}"; while [ "$fliptag" ]; do
+      comp="$tags"
+      tags="$(printf '%s\n' "$tags" |grep -vxFe "${fliptag%%${BR}*}")"
+      [ "$comp" = "$tags" ] && tags="${tags}${BR}${fliptag%%${BR}*}"
+      fliptag="${fliptag#*${BR}}"
+    done
+
+  fi
+  tags="$(printf '%s\n' "$tags" |sort -u |tr '\n' ,)"
+  tags="${tags#,}"; tags="${tags%,}"
+
+  if [ "$group" ]; then
+    update_meta "$META_NAME" tags="${tags}" group="${group}"
+  else
+    update_meta "$META_NAME" tags="${tags}"
+  fi
+done
+
+( if [ $(POST_COUNT select) -gt 0 ]; then
+    taglist >"$c_tags.$$"
+    mv -- "$c_tags.$$" "$c_tags"
+  fi &
+) &
diff --git a/stereoview.js b/stereoview.js
new file mode 100644 (file)
index 0000000..0d46a66
--- /dev/null
@@ -0,0 +1,247 @@
+/*  Copyright 2018, 2023 Paul Hänsch
+   
+    This file is part of Serve0
+    
+    Serve0 is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+    
+    Serve0 is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+    
+    You should have received a copy of the GNU Affero General Public License
+    along with Serve0  If not, see <http://www.gnu.org/licenses/>. 
+*/
+
+var contLeft  = document.createElement("div");
+var contRight = document.createElement("div");
+var lv = document.createElement("canvas");
+var rv = document.createElement("canvas");
+var debug = document.createElement("p");
+
+ contLeft.setAttribute("style", "position: fixed; top: 0; left:  0; width: 50%; height: 100%; overflow: hidden; z-index: 100; background-color: #000;");
+contRight.setAttribute("style", "position: fixed; top: 0; right: 0; width: 50%; height: 100%; overflow: hidden; z-index: 100; background-color: #000;");
+       lv.setAttribute("style", "position: absolute; top: 50%; left: calc(100% - 32mm); max-width: unset; max-height: unset;");
+       rv.setAttribute("style", "position: absolute; top: 50%; left:             32mm;  max-width: unset; max-height: unset;");
+    debug.setAttribute("style", "display: none; position: fixed; top: 0; left: 0; z-index: 101; background: #000;");
+
+
+function stereoview(layout, video) {
+  this.layout = layout; this.video = video;
+
+  let w = video.videoWidth; h = video.videoHeight;
+  let render, controlTimeout = 0;
+  let pitch = 0, roll = 0, yaw = 0;
+  let scale, vsf = 1, fov = 90, dist = 32;
+
+  document.body.appendChild(contLeft ).appendChild( lv );
+  document.body.appendChild(contRight).appendChild( rv );
+  document.body.appendChild( debug );
+
+  if ( layout == "180") {
+    lv.width = rv.width = w / 2; lv.height = rv.height = h;
+    scale = contLeft.offsetHeight / h * 2 * fov / 90;
+  } else {
+    lv.width = rv.width = w; lv.height = rv.height = h / 2;
+    scale = contLeft.offsetHeight / h * 4 * fov / 90;
+  }
+
+  lc = lv.getContext("2d"); rc = rv.getContext("2d");
+
+  function draw() {
+    if ( layout == "180" ) {
+      // scale = contLeft.offsetHeight / h * 2 * fov / 90;
+      lc.drawImage(video,      0, 0);
+      rc.drawImage(video, -w / 2, 0);
+      lv.style.transform = rv.style.transform = 
+        "translate(" + (yaw / 180 * -w / 2 - w / 4) + "px, " +
+                       (pitch / 90 * -h / 2 * scale - h / 2) + "px)" +
+        "rotate(" + roll + "deg) " + "scale(" + (scale / vsf) + ", " + scale + ")";
+    } else {
+      // scale = contLeft.offsetHeight / h * 4 * fov / 90;
+      lc.drawImage(video, yaw / 180 * -w / 2, 0);
+      lc.drawImage(video, yaw / 180 * -w / 2 + ((yaw>0) ? w : -w), 0);
+      rc.drawImage(video, yaw / 180 * -w / 2, -h/2);
+      rc.drawImage(video, yaw / 180 * -w / 2 + ((yaw>0) ? w : -w), -h/2);
+      lv.style.transform = rv.style.transform = 
+        "translate(" + (- w / 2) + "px, " + (pitch /  90 * -h / 2 * (scale / 2) - h / 4) + "px) " +
+        "rotate(" + roll + "deg) " + "scale(" + (scale / vsf) + ", " + scale + ")";
+    }
+
+    // debug.textContent = "" + video.currentTime + " " + controlTimeout + " " + tx + " " + ty + " " + tz;
+    debug.textContent = "Pitch: " + pitch.toFixed(2) + " | Yaw: " + yaw.toFixed(2) + " | Roll: " + roll.toFixed(2) +
+                        " | FOV: " + fov + " | Eye Distance: " + (2 * dist) + "mm";
+  
+    requestAnimationFrame(draw);
+
+    gpinput();
+  };
+
+  function gpinput() {
+    let gp = navigator.getGamepads()[0];
+    let date = new Date();
+
+    debug.innerHTML += "<br/><br/>GamePad: " + gp.axes[0].toFixed(3) + " | " + gp.axes[1].toFixed(3)
+    for (cnt = 0; cnt < 16; cnt++) {
+      gp.buttons[cnt].pressed ? debug.textContent += " | B" + cnt + " 1" : debug.textContent += " | B" + cnt + " 0";
+    }
+
+    if ( gp && Date.now() < controlTimeout ) {
+      true;
+    } else if (gp.buttons[0].pressed && gp.buttons[5].pressed) {
+      this.layout = layout = "180";
+      document.body.appendChild(contLeft ).appendChild( lv );
+      document.body.appendChild(contRight).appendChild( rv );
+      lv.width = rv.width = w / 2; lv.height = rv.height = h;
+      scale = contLeft.offsetHeight / h * 2 * fov / 90;
+      video.play();
+      controlTimeout = Date.now() + 600;
+    } else if (gp.buttons[0].pressed && gp.buttons[4].pressed) {
+      this.layout = layout = "360";
+      document.body.appendChild(contLeft ).appendChild( lv );
+      document.body.appendChild(contRight).appendChild( rv );
+      lv.width = rv.width = w; lv.height = rv.height = h / 2;
+      scale = contLeft.offsetHeight / h * 4 * fov / 90;
+      video.play();
+      controlTimeout = Date.now() + 600;
+    } else if (gp.buttons[3].pressed && gp.buttons[4].pressed) {
+      vsf = (vsf == 1) ? 2 : 1;
+      controlTimeout = Date.now() + 300;
+    } else if (gp.buttons[0].pressed) {
+      ( contLeft.parentElement)? contLeft.parentElement.removeChild(contLeft ):{};
+      (contRight.parentElement)?contRight.parentElement.removeChild(contRight):{};
+      video.pause();
+      controlTimeout = Date.now() + 300;
+    } else if (gp.buttons[1].pressed) {
+      (debug.style.display == "block") ? debug.style.display = "none" : debug.style.display = "block";
+      controlTimeout = Date.now() + 300;
+    } else if (gp.buttons[5].pressed) {
+      video.paused ? video.play() : video.pause();
+      controlTimeout = Date.now() + 300;
+    } else if (gp.buttons[4].pressed) {
+      video.currentTime += 1 / 30;
+      controlTimeout = Date.now() + 300;
+    } else if (gp.buttons[3].pressed && gp.axes[0] < -.3) {
+      dist -= .5;
+      lv.style.left = "calc(100% - " + dist + "mm)";
+      rv.style.left = ""             + dist + "mm";
+      date.setTime(date.getTime() + 3 * 365 * 86400 * 1000)
+      document.cookie = "StereoDist=" + dist + "; expires=" + date.toUTCString() + "; path=/";
+      controlTimeout = Date.now() + 300;
+    } else if (gp.buttons[3].pressed && gp.axes[0] >  .3) {
+      dist += .5;
+      lv.style.left = "calc(100% - " + dist + "mm)";
+      rv.style.left = ""             + dist + "mm";
+      date.setTime(date.getTime() + 3 * 365 * 86400 * 1000)
+      document.cookie = "StereoDist=" + dist + "; expires=" + date.toUTCString() + "; path=/";
+      controlTimeout = Date.now() + 300;
+    } else if (gp.buttons[3].pressed && gp.axes[1] < -.3) {
+      fov -= 10;
+      date.setTime(date.getTime() + 3 * 365 * 86400 * 1000)
+      document.cookie = "StereoFOV=" + fov + "; expires=" + date.toUTCString() + "; path=/";
+      controlTimeout = Date.now() + 300;
+    } else if (gp.buttons[3].pressed && gp.axes[1] >  .3) {
+      fov += 10;
+      date.setTime(date.getTime() + 3 * 365 * 86400 * 1000)
+      document.cookie = "StereoFOV=" + fov + "; expires=" + date.toUTCString() + "; path=/";
+      controlTimeout = Date.now() + 300;
+    } else if (gp.buttons[2].pressed && gp.axes[0] < -.3) {
+      yaw -= pitch ? ( Math.cos(pitch * Math.PI / 180) % 360 ) : 1;
+      if ( yaw >  180) yaw -= 360;
+      if ( yaw < -180) yaw += 360;
+      controlTimeout = Date.now() + 30;
+    } else if (gp.buttons[2].pressed && gp.axes[0] >  .3) {
+      yaw += pitch ? ( Math.cos(pitch * Math.PI / 180) % 360 ) : 1;
+      if ( yaw >  180) yaw -= 360;
+      if ( yaw < -180) yaw += 360;
+      controlTimeout = Date.now() + 30;
+    } else if (gp.buttons[2].pressed && gp.axes[1] < -.3) {
+      video.volume += .05;
+      controlTimeout = Date.now() + 300;
+    } else if (gp.buttons[2].pressed && gp.axes[1] >  .3) {
+      video.volume -= .05;
+      controlTimeout = Date.now() + 300;
+    } else if ( gp.axes[0] >  .3) {
+      video.currentTime += 10;
+      controlTimeout = Date.now() + 200;
+    } else if ( gp.axes[0] < -.3) { 
+      video.currentTime -= 10;
+      controlTimeout = Date.now() + 200;
+    } else if ( gp.axes[1] >  .3) {
+      video.currentTime -= 60;
+      controlTimeout = Date.now() + 300;
+    } else if ( gp.axes[1] < -.3) {
+      video.currentTime += 60;
+      controlTimeout = Date.now() + 300;
+    }
+  }
+  
+  // mpuevent = new EventSource("http://localhost:314");
+  // var x = [], y = [], z = [], cnt = -1, inertia = 6;
+
+  // mpuevent.addEventListener("bearing", function(e) {
+  //   bearing = e.data.split(" ");
+  //   yaw = -parseFloat(bearing[0]);
+  // }, false);
+  // mpuevent.addEventListener("motion", function(e) {
+  //   motion = e.data.split(" ");
+
+  //   cnt = (cnt + 1) % inertia;
+  //   x[cnt] = parseFloat(motion[0]);
+  //   y[cnt] = parseFloat(motion[1]);
+  //   z[cnt] = parseFloat(motion[2]);
+
+  //   // tx = 0; x.forEach( function(n, i){ tx += n; } ); tx /= inertia;
+  //   ty = 0; y.forEach( function(n, i){ ty += n; } ); ty /= inertia;
+  //   tz = 0; z.forEach( function(n, i){ tz += n; } ); tz /= inertia;
+
+  //   pitch =   Math.asin((tz / 9.81 > 1)?1:(tz/9.81)) / Math.PI * 180 + 22.5;
+  //   roll  = - Math.asin((ty / 9.81 > 1)?1:(ty/9.81)) / Math.PI * 180;
+  //   // yaw   = (yaw + ty) % 360;
+  // }, false );
+
+  window.addEventListener("devicemotion", (function() {
+    let x = [], y = [], z = [], cnt = -1, inertia = 6;
+    return event => {
+      cnt = (cnt + 1) % inertia;
+
+      x[cnt] = event.accelerationIncludingGravity.x;
+      y[cnt] = event.accelerationIncludingGravity.y;
+      z[cnt] = event.accelerationIncludingGravity.z;
+
+      tx = 0; x.forEach( function(n, i){ tx += n; } ); tx /= inertia;
+      ty = 0; y.forEach( function(n, i){ ty += n; } ); ty /= inertia;
+      tz = 0; z.forEach( function(n, i){ tz += n; } ); tz /= inertia;
+
+      pitch =   Math.asin((tz / 9.81 > 1)?1:(tz/9.81)) / Math.PI * 180;
+      roll  = - Math.asin((ty / 9.81 > 1)?1:(ty/9.81)) / Math.PI * 180;
+      yaw   = pitch ? ( yaw
+              - event.rotationRate.alpha / 1000 * event.interval * Math.cos(pitch * Math.PI / 180)
+              - event.rotationRate.gamma / 1000 * event.interval * Math.sin(pitch * Math.PI / 180)
+            ) % 360 : yaw;
+      if ( yaw >  180) yaw -= 360;
+      if ( yaw < -180) yaw += 360;
+
+      // pitch = (pitch + event.rotationRate.beta  / 1000 * event.interval) % 360;
+      // roll  = (roll  + event.rotationRate.gamma / 1000 * event.interval) % 360;
+      // yaw   = (yaw + ty) % 360;
+    };
+  })());
+
+  window.addEventListener("click", function(event) {
+    ( contLeft.parentElement)? contLeft.parentElement.removeChild(contLeft ):{};
+    (contRight.parentElement)?contRight.parentElement.removeChild(contRight):{};
+    // video.style.display = "block";
+    video.pause();
+  }, true);
+
+  dist = dist ? dist : parseInt(document.getElementById("StereoDist").getAttribute("value"));
+  fov  =  fov ?  fov : parseInt(document.getElementById("StereoFOV" ).getAttribute("value"));
+
+  video.play();
+  // video.style.display = "none";
+  draw();
+};
diff --git a/style.css b/style.css
new file mode 100644 (file)
index 0000000..79ddfe8
--- /dev/null
+++ b/style.css
@@ -0,0 +1,301 @@
+body {
+  color: #EEE;
+  background-color: #000;
+  padding-bottom: 2.5em;
+}
+
+/* ====== TOP CONTROL BAR ====== */
+
+#navigation {
+  text-align: center;
+  margin-bottom: 1em; padding: 0 2em;
+  background-color: #333;
+  box-shadow: .125em .125em .25em #000;
+}
+#navigation > a {
+  position: absolute; bottom: .25em;
+  padding: 0 .125em;
+  font-size: 1.5em;
+  text-decoration: none;
+}
+#navigation > a[href="#bookmarks"] { left: 0; }
+#navigation > a[href="#prefs"] { right: 0; }
+
+#bookmarks, #advsearch, #prefs, #multitag {
+  -display: none;
+  height: 0;
+  overflow: hidden;
+}
+
+#editing {
+  position: fixed;
+  bottom: 0; width: 100%;
+  padding: 0 .5em;
+  background-color: #333;
+}
+
+:target a[href="#"] {
+  position: absolute;
+  top: 0; right: 0;
+  font-size: 1.5em;
+  font-weight: bold;
+  text-decoration: none;
+  padding: 0 .25em;
+  z-index: 1;
+}
+
+/* ====== MAIN LIST VIEW ====== */
+
+.itemlist { text-align: center; }
+.itemlist > * { text-align: left; }
+.itemlist .list {
+  display: inline-block;
+  vertical-align: top;
+  width: 99%;
+  -padding: 0 .25em;
+  margin: 0 .5%;
+  margin-bottom: 1em;
+  overflow: hidden;
+}
+
+.itemlist .list img {
+  -width: 1000%; height: 11em;
+  max-width: unset;
+  background-color: #111;
+  object-fit: cover;
+  transform: translate(-05%, 0);
+  margin-left: 50%;
+}
+.itemlist .list:hover img {
+  animation: thumbscroll 8s steps(10, end) infinite;
+}
+@keyframes thumbscroll {
+  from { transform: translate(-05%, 0);}
+  to   { transform: translate(-105%, 0);}
+}
+
+.itemlist .list label {
+  display: block;
+  font-weight: bolder;
+  word-break: break-word;
+}
+.itemlist .list .time,
+.itemlist .list .dim {
+  position: absolute; top: 9.75em;
+  background-color: rgba(0,0,0,.5);
+  padding: .125em .25em;
+}
+.itemlist .list .time { right: 0; }
+.itemlist .list .dim { left: 0; }
+.itemlist .list input[type=checkbox] { display: none; }
+.itemlist .list .tag,
+.itemlist .list input[type=checkbox] + label {
+  display: inline-block;
+  background-color: #333;
+  margin-top: .125em;
+  margin-left: 0;
+  padding: 0 .25em;
+  border-radius: 1pt;
+}
+.itemlist .list input[type=checkbox]:checked + label {
+  background-color: #383;
+}
+
+
+/* ====== PAGINATION LIST ====== */
+
+.pagination {
+  display: block;
+  font-size: 1.25em;
+  max-width: 98%;
+  margin: 0 auto;
+  padding: .25em 0;
+  background-color: #333;
+  border-radius: 2pt;
+}
+.pagination a {
+  display: inline-block;
+  padding: 0 .5em;
+  margin: 0 .5em;
+  border-radius: 2pt;
+}
+.pagination a.current {
+  background-color: #BBB;
+}
+
+/* ====== BOOKMARK PANEL ====== */
+
+#bookmarks:target,
+#prefs:target {
+  display: block; position: fixed;
+  top: 50%; left: 50%;
+  transform: translate( -50%, -50% );
+  width: 40em; max-width: 90%;
+  height: 30em; max-height: 90vh;
+  background-color: #333;
+  padding: 0 .5em;
+  z-index: 1;
+  box-shadow: .25em .25em .5em #000;
+  overflow-y: auto;
+}
+
+#bookmarks label {
+  display: inline;
+  font-weight: bold;
+  font-size: 1.125em;
+  margin-left: 0;
+  margin-top: .75em;
+  word-break: break-word;
+}
+#bookmarks label:before,
+#bookmarks a.conjunct:after {
+  content: '\0a';
+  white-space: pre;
+}
+#bookmarks label:before {
+  line-height: 2.5em;
+  vertical-align: top;
+}
+
+
+/* ====== ADVSEARCH / FILTER PANEL ====== */
+
+#advsearch:target {
+  display: block; position: fixed;
+  top: 0; width: 100%;
+  height: 30em; max-height: 90vh;
+  background-color: #333;
+  padding: 0 .5em;
+  z-index: 1;
+  box-shadow: .25em .25em .5em #000;
+  overflow-y: auto;
+}
+
+-#advsearch { text-align: center; }
+-#advsearch > * { text-align: left; }
+
+#advsearch .help {
+  width: 95%;
+  margin: 1em auto; padding: 0 .5em;
+  background-color: #444;
+  white-space: pre-line;
+}
+
+#advsearch input.and + label {
+  display: inline-block;
+  vertical-align: top;
+  font-weight: bold;
+}
+#advsearch fieldset.select {
+  display: inline-block;
+  width: 99%;
+  margin: 0 .5%; margin-bottom: .75em; padding: 0 .375em;
+  box-shadow: .125em .125em .25em #000;
+}
+
+#advsearch fieldset.select > label.head {
+  display: none;
+  width: 40%;
+  text-align: right;
+}
+#advsearch fieldset.select > input.cat { display: none; }
+#advsearch fieldset.select > input.cat + label + .catselect { display: none; }
+#advsearch fieldset.select > input.cat + label {
+  display: block;
+  width: 40%;
+  margin: 0; padding: 0 .5em;
+  text-align: right;
+}
+#advsearch fieldset.select > input.cat:checked + label { background-color: #444; }
+#advsearch fieldset.select > input.cat:checked + label + .catselect {
+  display: block; position: absolute;
+  top: 1.5em; bottom: 0; right: 0;
+  width: 60%;
+  padding: 0 .25em;
+  background-color: #444;
+  overflow-y: auto;
+}
+#advsearch fieldset.select > input.cat + label + .catselect > * {
+  display: block;
+  white-space: pre;
+}
+
+#advsearch input.and { display: none; }
+#advsearch input.and + label { display: none; }
+#advsearch input.and + label + fieldset { display: none; }
+#advsearch input.and:checked + label + fieldset,
+#advsearch input.and:first-of-type + label + fieldset { display: inline-block; }
+#advsearch input.and:checked + label + fieldset + input + label,
+#advsearch input.and:first-of-type + label + fieldset + input + label { display: inline-block; }
+#advsearch input.and:checked + label + fieldset + input:checked + label { display: none; }
+#advsearch input.and:first-of-type + label + fieldset + input:checked + label { display: none; }
+
+
+/* ====== MULTITAG DIALOG ====== */
+
+#multitag:target {
+  display: block; position: fixed;
+  bottom: 0; left: 0; width: 100%;
+  height: 30em; max-height: 90vh;
+  background-color: #333;
+  padding: 0 .5em;
+  z-index: 1;
+  box-shadow: .25em .25em .5em #000;
+  overflow-y: auto;
+}
+
+-#multitag { text-align: center; }
+-#multitag > * { text-align: left; }
+
+#multitag fieldset {
+  display: inline-block;
+  width: 99%;
+  margin: 0 .5%; margin-top: 1em;
+}
+
+#multitag fieldset select {
+  width: 100%; height: 10em;
+}
+#multitag fieldset .tagselect {
+  height: 10em;
+  background-color: #444;
+  overflow-y: auto;
+}
+#multitag fieldset .tagselect > label {
+  display: block;
+  white-space: pre;
+}
+#multitag fieldset textarea[name=newtag] + label {
+  display: block;
+}
+
+
+/* ====== VIEW PAGE ====== */
+
+body#view video {
+  display: block;
+  max-height: 80vh;
+  margin: 0 auto;
+}
+
+body#view .tag {
+  display: inline-block;
+  background-color: #333;
+  margin-top: .125em;
+  margin-left: 0;
+  padding: 0 .25em;
+  border-radius: 1pt;
+}
+
+body#view .itemlist {
+  margin-top: 2em;
+}
+
+
+/* ====== SCALE BLOCK ELEMENTS ====== */
+
+@media(min-width:  20em) { .itemlist .list, #advsearch fieldset.select, #multitag fieldset { max-width: 49%; } }
+@media(min-width:  40em) { .itemlist .list, #advsearch fieldset.select, #multitag fieldset { max-width: 32%; } }
+@media(min-width:  60em) { .itemlist .list, #advsearch fieldset.select, #multitag fieldset { max-width: 24%; } }
+@media(min-width:  80em) { .itemlist .list, #advsearch fieldset.select, #multitag fieldset { max-width: 19%; } }
+@media(min-width: 100em) { .itemlist .list, #advsearch fieldset.select, #multitag fieldset { max-width: 19em; } }
diff --git a/thumbnail.sh b/thumbnail.sh
new file mode 100755 (executable)
index 0000000..09f5ab0
--- /dev/null
@@ -0,0 +1,26 @@
+#!/bin/sh
+
+[ -n "$include_thumbnails" ] && return 0
+include_thumbnails="$0"
+
+gen_thumb(){
+  local file="$1" thumb="$2" bgcolor="${3:-#000000}"
+  local tmp="${TMPDIR:-/tmp}/serve0tmp_$$/" lenght n
+
+  if [ "${file%.part}" = "${file}" -a ! -s "$thumb" -a -s "$file" ] && mkdir "$tmp"; then
+    length="$( ffprobe -show_entries format=duration "$file" 2>&- )"
+    length="${length#*duration=}" length="${length%%${BR}*}" length="${length%.*}"
+  
+    # ffmpeg -nostdin -y -i "$file" -vf fps=11/$length,scale=320:-2 -frames 10 "$tmp/thumb_%02d.jpg" 2>&-
+
+    for n in 1 2 3 4 5 6 7 8 9 10; do
+      ffmpeg -nostdin -y -ss "$((n * length / 11))" -i "$file" -frames 1 "$tmp/thumb_$((n - 1)).jpg" 2>&-
+    done
+
+    montage "$tmp"/thumb_[0-9].jpg \
+            -background "$bgcolor" \
+            -tile 10x1 -geometry 320x180+0+0 \
+            -interlace line -quality 85 "$thumb"
+    rm -r -- "${tmp}"
+  fi
+}
diff --git a/view.sh b/view.sh
new file mode 100755 (executable)
index 0000000..7c73592
--- /dev/null
+++ b/view.sh
@@ -0,0 +1,77 @@
+#!/bin/sh
+
+. "$_EXEC/db_meta.sh"
+. "$_EXEC/widgets.sh"
+
+read_meta "${ITEM%.*}"
+
+printf 'Content-Type: text/html;charset=utf-8\r\n\r\n'
+
+{ cat <<-EOF
+       [!DOCTYPE HTML]
+       [html [head
+         [title . $(HTML "${ITEM##*/}")]
+         [meta name="viewport" content="width=device-width"]
+         [link rel=stylesheet href="/cgilite/common.css" ]
+         [link rel=stylesheet href="/style.css" ]
+       ] [body #view
+         [script type="text/javascript" src="/stereoview.js"\n]
+         [div #navigation
+           [a #t_bookmarks href="#bookmarks" &#x2605;]
+           $(w_search)
+           [a #t_prefs href="#prefs" &#x2699;]
+         ]
+       EOF
+  w_bookmarks
+  w_advsearch
+  w_prefs
+  cat <<-EOF
+         [input type=hidden id=StereoFOV  name=StereoFOV  value="$(COOKIE StereoFOV  |grep -xE '[0-9]+' || printf 90)"]
+         [input type=hidden id=StereoDist name=StereoDist value="$(COOKIE StereoDist |grep -xE '[0-9]+' || printf 32)"]
+         [video #mainvideo controls="controls" preload="auto" [source src="?a=download" type="video/mp4"]]
+         [a "?a=download" Download]
+         [label Stereoscopic View:]
+         [a "javascript:stereoview('sbs180', document.getElementById(&#34;mainvideo&#34;));" SBS 180°]
+         [a "javascript:stereoview( 'tb360', document.getElementById(&#34;mainvideo&#34;));" Top/Bottom 360°]
+         [a "javascript:stereoview( 'cu360', document.getElementById(&#34;mainvideo&#34;));" Cubic 360°]
+         [h1 . $(HTML "${ITEM##*/}" |sed -E 's;[^0-9a-zA-Z&#];&[wbr];g')]
+         [span .time $((META_LENGTH / 60)):$(printf %02i $((META_LENGTH % 60)))min] [span .dim ${META_WIDTH}x${META_HEIGHT}]
+       EOF
+  printf %s\\n "$META_TAGS" |tr , \\n |while read tag; do
+    [ "$tag" ] && printf '  [span .tag . %s]\n' "$(HTML "${tag#-}")"
+  done
+
+  if [ "${META_GROUP}" ]; then
+    printf '[div .itemlist'
+    list_meta "$_DATA/${ITEM%/*}/.index/meta" \
+    | grep -F "${CR}   ${META_GROUP}" \
+    | sort -n -k8 -k6,6 \
+    | while read_meta; do for file in "$_DATA/${META_NAME}".*; do
+      [ "/${file#${_DATA}/}" = "$ITEM" ] && continue
+      name="$(HTML "/${file#${_DATA}/}")"
+
+      printf '[div .list .file
+                [a href="%s" [img src="%s?a=thumbnail"]][label . %s]
+                [span .time %i:%02imin] [span .dim %ix%i] %s
+              ]' \
+        "$name" "$name" "${name##/}" \
+        "$((META_LENGTH / 60))" "$((META_LENGTH % 60))" \
+        "$META_WIDTH" "$META_HEIGHT" \
+        "$(printf %s\\n "${META_TAGS}" \
+           | sed -r 's;^;,;; s;,+;,;g; s;,$;;;
+                     :X s;,-?([^,]+)(,|$); [span .tag\n \1]\2;; tX;'
+        )"
+    done; done
+    printf ']'
+  fi
+
+  printf '
+  [div #editing
+    [form method=POST action="/?a=multitag"
+    [hidden "select" "%s"]' "$(HTML "${ITEM}")"
+      [ -d "$_DATA/${ITEM%/*}/.index/" ] && w_tagging
+    printf '
+    ]
+  ]
+] ]
+'; } | "$_EXEC/cgilite/html-sh.sed"
diff --git a/widgets.sh b/widgets.sh
new file mode 100755 (executable)
index 0000000..35058cb
--- /dev/null
@@ -0,0 +1,273 @@
+#!/bin/sh
+
+[ -n "$include_widgets" ] && return 0
+include_widgets="$0"
+
+. "$_EXEC/cgilite/storage.sh"
+. "$_EXEC/db_meta.sh"
+
+w_refuri="$(URL "$PATH_INFO")?$(HTML "$QUERY_STRING")"
+
+w_str_s="$(STRING "$SEARCH")"
+w_str_f="$(STRING "$FILTER")"
+
+taglist(){
+  list_meta |sed -E '
+    s;^.*\ttags=([^\t]*)\t.*$;\1;;
+    s;,;\n;g;
+  ' \
+  | sort |uniq -c |sort -rn |sed -E 's;^ *[0-9]+ ;;;' \
+  | UNSTRING | HTML \
+  | sed -E 's;&#x0A\;;\n;g; s;\n+;\n;g;'
+}
+tagorder(){
+  printf '*\n%s\n$\n' "$w_tagcategories" \
+  | while read -r category; do
+    printf '%s\n' "$w_tags" \
+    | { [ "$category" = '*' ] && grep -avF ':' || grep -awF "${category}"; } \
+    | { sed -u 10q; sort; }
+  done
+}
+
+c_tags="$_DATA/.index/tags.cache";
+if [ ! -s "$c_tags" ]; then
+  taglist >"$c_tags.$$"
+  mv "$c_tags.$$" "$c_tags"
+fi
+w_tags="$(cat "$c_tags")"
+w_tagcategories="$(
+  printf %s "$w_tags" \
+  | sed -rn '/:/s;^-?([^:]+):.*$;\1;p' \
+  |sort -u
+)"
+w_tags="$(tagorder)"
+
+[ "$ORDER" = Name   ] && w_coname=checked
+[ "$ORDER" = Date   ] && w_codate=checked
+[ "$ORDER" = Length ] && w_colength=checked
+[ "$ORDER" = Group  ] && w_cogroup=checked
+
+w_bmname=
+w_bmname(){
+  [ "$w_bmname" ] || w_bmname="$(
+    bm="$_DATA/.index/bookmarks"
+    name="$(grep -m1 -aF "     search=${w_str_s}       filter=${w_str_f}${CR}" "$bm" 2>&-)"
+
+    if [ "$name" ]; then
+      printf '%s' "$name" |cut -f1 |UNSTRING |HTML
+    else
+      printf '%s\t%s' "$SEARCH" "$FILTER" \
+      | sed -r '/^\t$/{  s;\t;All;; q;}
+                /.*\t$/{ s;\t$;;; q;}
+                /^\t.*/{ s;^\t;;;
+                         :x; s;(^|[~^|])([^|^~:]+):;\1;; tx;
+                         s;\^; and ;g; s;\|;,;g; s;~;not ;g; q;}' \
+      | HTML
+    fi
+  )"
+  printf '%s' "$w_bmname"
+}
+
+w_bookmarks(){
+  local bm="$_DATA/.index/bookmarks" name='' search='' filter=''
+  [ ! -d "${bm%/*}" ] && return 0
+  [ ! -f "$bm" ] && touch "$bm"
+
+  grep -qaF "  search=$w_str_s filter=${w_str_f}${CR}" "$bm" && name=Update || name=Add
+
+  cat <<-EOF
+       [form #bookmarks action=?a=bookmark method=POST
+         [a href="#" x]
+         [hidden "ref" "${w_refuri}"]
+         [hidden "search" "$(HTML "$SEARCH")"][hidden "filter" "$(HTML "$FILTER")"]
+         [label Name for current page:]
+         [input name="name" value="$(w_bmname)" placeholder="Name" ]
+         [button type="submit" . ${name}]
+       EOF
+  [ "$name" ] && printf ' [submit "delete" "delete" Delete]'
+
+  sort "$bm" |while read -r name search filter; do
+    search="${search#search=}" filter="${filter#filter=}" filter="${filter%${CR}}"
+    [ "$search" = "${w_str_s}" -a "$filter" = "${w_str_f}" ] && continue
+
+    name="$(UNSTRING "$name")";
+    search="$(UNSTRING "${search}" |URL)";
+    filter="$(UNSTRING "${filter}" |URL)";
+    printf '
+      [label .link . %s][a .conjunct href="?o=%s&s=%s&f=%s" . +]
+      [a .link target=blank href="/?o=Name&s=%s&f=%s" by Name]
+      [a .link target=blank href="/?o=Date&s=%s&f=%s" by Date]
+      [a .link target=blank href="/?o=Length&s=%s&f=%s" by Length]
+      [a .link target=blank href="/?o=Group&s=%s&f=%s" by Group]
+    ' "$(HTML "$name" |sed 's;&#44\;;&[wbr];g;')" \
+      "$ORDER" "${SEARCH:+${SEARCH}} $search" "${FILTER:+${FILTER}^}$filter" \
+      "$search" "$filter"  "$search" "$filter" \
+      "$search" "$filter"  "$search" "$filter"
+  done
+  printf ']'
+}
+
+w_search(){
+  printf '
+    [form #search method=GET action=./?
+      [select name=o size=1
+        [option disabled=disabled Order By]
+        [option value=Name %s Name]
+        [option value=Date %s Date]
+        [option value=Length %s Length]
+        [option value=Group %s Group]
+     ][input type="search" name=s placeholder=Search value="%s"][button .search type=submit Search]
+      [a #t_avsearch href="#advsearch" Advanced]
+    ]' "$w_coname" "$w_codate" "$w_colength" "$w_cogroup" \
+       "$(HTML "$SEARCH")"
+}
+
+w_prefs(){
+  local tm tf td
+
+  tm=''; [ "$(COOKIE mode)" = browse ] && tm=' '
+  tf=''; [ "$(COOKIE fakemp4)" = yes ] && tf=checked
+  td=''; [ "$(COOKIE downscale)" = yes ] && td=checked
+
+  printf '
+  [form #prefs method="POST" action="?a=setprefs"
+    [a href="#" x]
+    [hidden "ref" "%s"]
+    [label for=prefs_ps Pagesize]
+    [input #prefs_ps type=number name=pagesize value="%i"][br]
+    [radio "mode" "browse" %s #prefs_modebrowse] [label for=prefs_modebrowse Browse Folders][br]
+    [radio "mode" "index"  %s #prefs_modeindex ] [label for=prefs_modeindex View Full Index][br]
+    [checkbox "fakemp4" "yes" %s #prefs_fmp4] [label for=prefs_fmp4 Fake .MP4 file type][br]
+    [checkbox "downscale" "yes" %s #prefs_downscale] [label for=prefs_downscale Prefer downscale to 480p][br]
+    [submit "index" "update" Force Index Update][br]
+    [submit "store" "store" Set Cookie]
+  ]' "${w_refuri}" "${LISTSIZE}" "${tm:+checked=checked}" "${tm:-checked=checked}" "$tf" "$td"
+}
+
+w_index(){
+  cat <<-EOF
+       [form #index method="POST" action="?a=spawnindex"
+         [hidden "ref" "${w_refuri}"]
+         [label Set up for Index view: ]
+         [checkbox "recursive" "yes" #spawn_recursive] [label for=spawn_recursive Include subdirectories]
+         [submit "spawn" "spawn" Set up]
+       ]
+       EOF
+  return 0
+}
+
+w_advsearch(){
+  local n lbid tag category filter f t d
+  filter="$(HTML "${FILTER}^")"
+
+  printf '[form #advsearch action=./?a=advsearch method=POST
+          [a href="#" X]
+          [p .help Refine the search further by setting additional search tags using the [strong "+and"] button.]'
+
+  for n in 1 2 3 4 5 6 7 8 9 10; do
+    f="${filter%%^*}"; filter="${filter#*^}"
+    t=''; [ "$f" -a ! "${f%%~*}" ] && t=" "
+    lbid="cat_${n}_(none)"
+
+    printf \
+     '[input .and type=checkbox name=and id="and_%i" %s][label for="and_%i" +and
+      ][fieldset .select
+      [radio "pol_%i" "pos" .pol %s #pol_pos_%i"][label for=pol_pos_%i Any]
+      [radio "pol_%i" "neg" .pol %s #pol_neg_%i"][label for=pol_neg_%i None]
+      [label .head Category:]' \
+      "$n" "${f:+checked=checked}" "$n" \
+      "$n" "${t:-checked=checked}" "$n" "$n" \
+      "$n" "${t:+checked=checked}" "$n" "$n"
+
+    f="|${f#\~}|"
+    printf '%s\n$:\n' "$w_tags" \
+    | while read tctag; do
+      [ "$tctag" ] || continue
+      category="${tctag#-}"; category="${category%%:*}"
+      tag="${tctag#-}"; tag="${tag#*:}"
+      [ "${tag}" = "${tctag#-}" ] \
+      && category="*"
+  
+      if [ "$category" != "$oldcat" ]; then
+        [ "$oldcat" ] && printf '\n]'
+        lbid="cat_${n}_${category}"
+
+        t=''
+        [ "$category"  = '*' -a   "${f%%|${category}:*}" ] && t=checked
+        [ "$category" != '*' -a ! "${f%%|${category}:*}" ] && t=checked
+        [ "$category" != '*' -a ! "${f%%|-${category}:*}" ] && t=checked
+
+        printf '[radio "cat_%i" "%s" .cat %s id="%s"][label for="%s" %s]
+                [div .catselect\n' \
+                $n "$category" "$t" "$lbid" "$lbid" "$category"
+      fi
+
+      if [ "$category" = '$' ]; then
+        tag="${f##*\$:}" tag="${tag%%\|*}"
+        printf '[input name="tag_%i" value="%s"]' "$n" "$(HTML "$tag")"
+      else
+        t=''; [ ! "${f%%*|${tctag}|*}" ] && t=checked
+        printf '[label [checkbox "tag_%s" "%s" %s] %s]' "$n" "$tctag" "$t" "$tag"
+      fi
+
+      oldcat="$category"
+    done
+    [ "$w_tags" ] && printf '\n]'
+    printf ']'
+  done
+
+  printf \
+   '[fieldset .submit [select name=order
+      [option disabled=disabled Order By]
+      [option value=Name %s Name]
+      [option value=Date %s Date]
+      [option value=Length %s Length]
+      [option value=Group %s Group]
+    ][button type=submit Apply Filter]]
+  ]' "$w_coname" "$w_codate" "$w_colength" "$w_cogroup"
+}
+
+w_delete(){
+  printf '[a href="#multitag" Add Tags / Remove Tags]
+          [div #multitag [input type="hidden" name="ref" value="%s"]
+          [a href="#" X]
+          [fieldset [legend New:]
+          [submit "op" "filedelete" Delete Files]
+          ]]' "$w_refuri"
+}
+
+w_tagging(){
+  local tctag oldcat="" category tag
+
+  cat <<-EOF
+       [a href="#multitag" Add Tags / Remove Tags]
+       [div #multitag [input type="hidden" name="ref" value="${w_refuri}"]
+         [a href="#" X]
+       EOF
+
+  printf '%s\n' "$w_tags" \
+  | while read tctag; do
+    [ "$tctag" ] || continue
+    category="${tctag#-}"; category="${category%%:*}"
+    tag="${tctag#-}"; tag="${tag#*:}"
+
+    [ "${tag}" = "${tctag#-}" ] \
+    && category="Tags"
+
+    if [ "$category" != "$oldcat" ]; then
+      [ "$oldcat" ] && printf ']]'
+      printf '[fieldset [legend %s:][div .tagselect\n' "$category"
+    fi
+    printf '[label [checkbox "tag" "%s"] %s]\n' "$tctag" "$tag"
+
+    oldcat="$category"
+  done
+  [ "$w_tags" ] && printf ']]'
+
+  cat <<-EOF
+         [fieldset [legend New:][textarea name=newtag]
+         [label [checkbox "makegroup" "true"] Join selected into group]
+         [submit "op" "del" Remove][submit "op" "flip" Flip][submit "op" "add" Add]
+       ]]
+       EOF
+}