#!/usr/bin/wapptclsh
#
# This script implements a release-checklist web application.  Installation
# steps:
#
#   (1) Put the wapptclsh framework binary in /usr/bin (or equivalent)
#   (2) Create a directory to store checklist databases.  Edit this
#       script to store the database directory in DATADIR
#   (3) Install at least one template database.  Perhaps use one of the
#       testing databases found in the source code repository for this
#       script.  The details of the checklist, logins and passwords, and
#       so forth can be edited after the application is running.
#   (4) Activate the server by one of the following techniques:
#       (4a) Run "wapptclsh checklist.tcl" for a pop-up instance on the
#            local machine.
#       (4b) Run "wapptclsh checklist.tcl --server 8080" for an HTTP server.
#       (4c) Make this script a CGI script according to however CGI works
#            on your web server
#       (4d) Run "wapptclsh checklist.tcl --scgi 9000" to start an SCGI
#            server, then configure Nginx to relay requests to TCP port 9000.
#
# Update 2019-07-26:
# The script has been revised to run as a Fossil extension.  It now uses
# Fossil for login and user credentials.  Anyone with check-in privilege
# can edit.  Anyone with Setup privilege can be a checklist admin.
#
set DATADIR /checklist  ;# Edit to be the directory holding checklist databases

package require wapp

# Any unknown URL dispatches to this routine.  List all available
# checklists.
#
proc wapp-default {} {
  wapp-page-listing
}

# List all available checklists.
#
proc wapp-page-listing {} {
  global DATADIR
  wapp-trim {
    <div class='fossil-doc' data-title='Available Checklists'>
  }
  wapp-subst {<ol>\n}
  foreach dbfile [lsort -decreasing [glob -nocomplain $DATADIR/*.db]] {
    set name [file rootname [file tail $dbfile]]
    set url [wapp-param BASE_URL]/$name/index
    wapp-subst {<li><a href='%url($url)'>%html($name)</a>\n}
  }
  wapp-subst {</ol>\n</html>\n}
}

# Show the CGI environment for testing purposes.
#
proc wapp-page-env {} {
  sqlite3 db :memory:
  set v [db one {SELECT sqlite_source_id()}]
  checklist-verify-login
  wapp-trim {
     <div class='fossil-doc' data-title='Wapp Environment'>
     <pre>%html([wapp-debug-env])}
  global env
  foreach e [array names env] {
    if {![string match FOSSIL* $e]} continue
    set txt "$e = [list $env($e)]\n"
    wapp-subst {%html($txt)}
  }
  wapp-subst {SQLite = %html($v)</pre>\n</div>\n}
}

# Check user permissions by looking at the login/password in the
# checklist-login cookie.  Set the following environment variables:
#
#     CKLIST_USER      Name of the user.  Empty string if not logged in
#     CKLIST_WRITE     True if the user is allowed to make updates
#     CKLIST_ADMIN     True if the user is an administrator.
#
# The database should already be open.
#
proc checklist-verify-login {} {
  global env
  if {[info exists env(FOSSIL_USER)]} {
    wapp-set-param CKLIST_USER $env(FOSSIL_USER)
  } else {
    wapp-set-param CKLIST_USER {}
  }
  if {[info exists env(FOSSIL_CAPABILITIES)]} {
    set perm $env(FOSSIL_CAPABILITIES)
  } else {
    set perm {}
  }
  wapp-set-param CKLIST_WRITE [string match {*i*} $perm]
  wapp-set-param CKLIST_ADMIN [string match {*[as]*} $perm]
}

# Print the common header shown on all pages
#
# Return 1 to abort.  Return 0 to continue with page generation.
#
proc checklist-common-header {} {
  if {![wapp-param-exists OBJECT] || [set dbfile [wapp-param OBJECT]]==""} {
    wapp-redirect listing
    return 1
  }
  sqlite3 db $dbfile -create 0
  db timeout 1000
  db eval BEGIN
  set title [db one {SELECT value FROM config WHERE name='title'}]
  wapp-trim {
    <div class='fossil-doc' data-title='%html($title)'>
  }
  checklist-verify-login
  wapp-subst {<div class="submenu">\n}
  set base [wapp-param BASE]
  set this [wapp-param PATH_HEAD]
  if {$this!="index"} {
    wapp-subst {<a href='%html($base/index)'>checklist</a>\n}
  }
  set admin [wapp-param CKLIST_ADMIN 0]
  if {$admin} {
    if {$this!="sql"} {
      wapp-subst {<a href='%html($base/sql)'>sql</a>\n}
    }
    if {$this!="cklistedit"} {
      wapp-subst {<a href='%html($base/cklistedit)'>edit-checklist</a>\n}
    }
  }
  wapp-subst {<a href='%html([file dir $base]/listing)'>catalog</a>\n}
  wapp-subst {</div>\n}
  return 0
}

# Close out a web page.  Close the database connection that was opened
# by checklist-common-header.
#
proc checklist-common-footer {} {
  wapp-subst {</div>}
  catch {db close}
}

# Show the main checklist page
#
proc wapp-page-index {} {
  if {[checklist-common-header]} return
  set level 0
  db eval {SELECT seq, printf('%016llx',itemid) AS itemid, txt
           FROM checklist ORDER BY seq} {
    if {$seq%100==0} {
      set newlevel 1
    } else {
      set newlevel 2
    }
    while {$newlevel>$level} {
      if {$level==0} {
        wapp-subst {<ol id="mainCklist" type='1'>\n}
      } else {
        wapp-subst {<p><ol type='a'>\n}
      }
      incr level
    }
    while {$newlevel<$level} {
      wapp-subst {</ol>\n}
      incr level -1
    }
    if {$level==1} {wapp-subst {<p>}}
    wapp-trim {
      <li class='ckitem' id='item-%unsafe($itemid)'><span>%unsafe($txt)</span>
      <span class='ckuid' id='stat-%unsafe($itemid)'></span>
      <div class='ckcom' id='com-%unsafe($itemid)'></div></li>\n
    }
  }
  while {$level>0} {
    wapp-subst {</ol>\n}
    incr level -1
  }

  # Render the edit dialog box. CSS sets "display: none;" on this so that
  # it does not appear.  Javascript will turn it on and position it on
  # the correct element following any click on the checklist.
  #
  if {![wapp-param WRITE 0]} {
    wapp-trim {
      <div id="editBox">
      <form id="editForm" method="POST">
      <table border="0">
      <tr>
      <td align="right">Status:&nbsp;
      <td><select id="editStatus" name="stat" size="1">
      <option value="ok">ok</option>
      <option value="prelim">prelim</option>
      <option value="fail">fail</option>
      <option value="review">review</option>
      <option value="pending">pending</option>
      <option value="retest">retest</option>
      <option value="---">---</option>
      </select>
      <tr>
      <td align="right" valign="top">Comments:&nbsp;
      <td><textarea id="editCom" name="com" cols="80" rows="2"></textarea>
      <tr>
      <td>
      <td><button id="applyBtn">Apply</button>
      <button id="cancelBtn">Cancel</button>
      </table>
      </form>
      </div>
    }
  }
    
  # The cklistUser object is JSON that contains information about the
  # login user and the capabilities of the login user, which the
  # javascript code needs to know in order to activate various features.
  #
  wapp-subst {<script id='cklistUser' type='application/json'>}
  if {![wapp-param CKLIST_WRITE]} {
    wapp-subst {{"user":"","canWrite":0,"isAdmin":0}}
  } else {
    set u [wapp-param CKLIST_USER]
    set ia [wapp-param CKLIST_ADMIN]
    wapp-subst {{"user":"%string($u)","canWrite":1,"isAdmin":%qp($ia)}}
  }
  wapp-subst {</script>\n}
  set base [wapp-param BASE]
  wapp-subst {<script src='%html($base/cklist.js)'></script>\n}
  checklist-common-footer
}

# The CSS for the main checklist page goes here
#
proc wapp-page-style.css {} {
  wapp-mimetype text/css
  wapp-cache-control max-age=86400
  wapp {
    h1 { text-align: center; }
    div.ckcom {
      font-size: 80%;
      font-style: italic;
      white-space: pre;
    }
    span.ckuid {
      font-size: 80%;
      cursor: pointer;
    }
    div.mainmenu {
      text-align: center;
      border: 1px solid black;
      padding: 2ex;
    }
    div.mainmenu a {
      margin: 0 1.5ex;
    }
    p.error {
      font-weight: bold;
      color: red;
    }
    #editBox {
      display: none;
      border: 1px solid black;
    }
  }
}

# The javascript for the main checklist page goes here
#
proc wapp-page-cklist.js {} {
  wapp-mimetype text/javascript
  wapp-cache-control max-age=86400
  set base [wapp-param BASE]
  wapp-trim {
    function cklistAjax(uri,data,callback){
      var xhttp = new XMLHttpRequest();
      xhttp.onreadystatechange = function(){
        if(xhttp.readyState!=4) return
        if(!xhttp.responseText) return
        var jx = JSON.parse(xhttp.responseText);
        callback(jx);
      }
      if(data){
        xhttp.open("POST",uri,true);
        xhttp.setRequestHeader("Content-Type",
                               "application/x-www-form-urlencoded");
        xhttp.send(data)
      }else{
        xhttp.open("GET",uri,true);
        xhttp.send();
      }
    }
    function cklistClr(stat){
      stat = stat.replace(/\\++/g,'');
      if(stat=="ok") return '#00a000';
      if(stat=="prelim") return '#0080ff';
      if(stat=="fail") return '#a00028';
      if(stat=="review") return '#007088';
      if(stat=="pending") return '#4f0080';
      if(stat=="retest") return '#904800';
      return '#000000';
    }
    function cklistApplyJstat(jx){
      var i;
      var n = jx.length;
      for(i=0; i<n; i++){
        var x = jx[i];
        var name = "item-"+x.itemid
        var e = document.getElementById(name);
        if(!e) continue
        e.style.color = cklistClr(x.status);
        e = document.getElementById("stat-"+x.itemid);
        if(!e) continue;
        var s = "(" + x.status + " " + x.owner
        if( x.chngcnt>1 ){
          s += " " + x.chngcnt + "x)"
        }else{
          s += ")"
        }
        e.innerHTML = s
        if( x.comment && x.comment.length>0 ){
          e = document.getElementById("com-"+x.itemid);
          e.innerHTML = x.comment;
        }
        if( editItem && editItem.id==name ){
          document.getElementById("editStatus").value = x.status;
          document.getElementById("editCom").value = x.comment;
        }
      }
    }
    function clearEditBox(){
      document.getElementById("editStatus").value = 'ok';
      document.getElementById("editCom").value = '';
    }
    cklistAjax("%string($base/jstat)",null,cklistApplyJstat);
    var userNode = document.getElementById("cklistUser");
    var userInfo = JSON.parse(userNode.textContent||userNode.innerText);
    if(userInfo.canWrite){
      var allItem = document.getElementsByClassName("ckitem");
      for(var i=0; i<allItem.length; i++){
        allItem[i].style.cursor = "pointer";
      }
    }
    function historyOff(itemid){ 
      var e = document.getElementById("hist-"+itemid);
      if(e) e.parentNode.removeChild(e);
    }
    function historyOn(itemid){
      var req = new XMLHttpRequest
      req.open("GET","history?itemid="+itemid,true);
      req.onreadystatechange = function(){
        if(req.readyState!=4) return
        var lx = document.getElementById("item-"+itemid);
        var tx = document.createElement("DIV");
        tx.id = "hist-"+itemid;
        tx.style.borderWidth = 1
        tx.style.borderColor = "black"
        tx.style.borderStyle = "solid"
        tx.innerHTML = req.responseText;
        lx.appendChild(tx);
      }
      req.send();
    }
    var editItem = null
    var editBox = document.getElementById("editBox");
    document.getElementById("mainCklist").onclick = function(event){
      var e = document.elementFromPoint(event.clientX,event.clientY);
      while(e && e.tagName!="LI"){
        if(e.id){
          if(e.id=="editForm") return;
          if(e.id.substr(0,5)=="stat-"){
            var id = e.id.substr(5);
            if( document.getElementById("hist-"+id) ){
              historyOff(id)
            }else{
              historyOn(id)
            }
            return;
          }
        }
        if(e==editBox) return;
        e = e.parentNode;
      }
      if(!userInfo.canWrite) return
      if(!e) return
      if(editItem) editItem.removeChild(editBox);
      if(e==editItem){
        editItem = null;
        return;
      }
      editBox.style.display = "block";
      editItem = e;
      historyOff(e.id.substr(5))
      editItem.appendChild(editBox);
      clearEditBox();
      var u = "%string($base)/jstat?itemid=" + e.id.substr(5);
      cklistAjax(u,null,cklistApplyJstat);
      document.getElementById("cancelBtn").onclick = function(event){
        event.stopPropagation();
        editItem.removeChild(editBox);
        editItem = null;
      }
      document.getElementById("applyBtn").onclick = function(event){
        var data = "update=" + editItem.id.substr(5);
        var e = document.getElementById("editStatus");
        data += "&status=" + escape(e.value);
        e = document.getElementById("editCom");
        data += "&comment=" + escape(e.value);
        cklistAjax("%string($base)/jstat",data,cklistApplyJstat);
        editItem.removeChild(editBox);
        editItem = null;
        event.stopPropagation();
      }
      document.getElementById("editForm").onsubmit = function(){
        return false;
      }
    }
  }
  # wapp-subst {window.alert("Javascript loaded");\n}
}

# The /jstat page returns JSON that describes the current
# status of all elements of the checklist.
#
# If the update query parameter exists and is not an empty string,
# and if the login is valid for a writer, then revise
# the ckitem entry where itemid=$update using query parameters
# {update->itemid,status,comment} and with owner set to the login user,
# before returning the results.
#
# If the itemid query parameter exists and is not an empty string,
# then return only the status to that one checklist item.  Otherwise,
# return the status of all checklist items.
#
# The update and itemid parameters come in as hex.  They must be
# converted to decimal before being used for queries.
#
proc wapp-page-jstat {} {
  if {![wapp-param-exists OBJECT] || [set dbfile [wapp-param OBJECT]]==""} {
    wapp-redirect listing
    return
  }
  wapp-mimetype text/json
  sqlite3 db $dbfile
  db eval BEGIN
  set update [wapp-param update]
  if {$update!=""} {
    checklist-verify-login
    if {[wapp-param CKLIST_WRITE 0] && [scan $update %x update]==1} {
      set status [wapp-param status]
      set comment [string trim [wapp-param comment]]
      set owner [wapp-param CKLIST_USER]
      db eval {
         REPLACE INTO ckitem(itemid,mtime,status,owner,comment)
          VALUES($update,julianday('now'),$status,$owner,$comment);
         INSERT INTO history(itemid,mtime,status,owner,comment)
          VALUES($update,julianday('now'),$status,$owner,$comment);
      }
    }
  }
  set itemid [wapp-param itemid]
  if {$itemid!="" && [scan $itemid %x itemid]==1} {
    set sql {
      SELECT json_group_array(
        json_object('itemid', printf('%016llx',itemid),
                    'mtime', strftime('%s',mtime)+0,
                    'status', rtrim(status,'+'),
                    'owner', owner,
                    'comment', comment,
                    'chngcnt', (SELECT count(*) FROM history
                                WHERE itemid=$itemid)))
      FROM ckitem WHERE itemid=$itemid
    }
  } else {
    set sql {
      WITH chngcnt(cnt,itemid) AS (
         SELECT count(*), itemid FROM history GROUP BY itemid
      )
      SELECT json_group_array(
        json_object('itemid', printf('%016llx',itemid),
                    'mtime', strftime('%s',mtime)+0,
                    'status', rtrim(status,'+'),
                    'owner', owner,
                    'comment', comment,
                    'chngcnt', COALESCE(chngcnt.cnt,0))
        )
        FROM ckitem LEFT JOIN chngcnt USING(itemid)
    }
  }
  wapp-unsafe [db one $sql]
  db eval COMMIT
  db close
  # puts "jstat from $dbfile"
}

# The /history page returns an HTML table that shows the history of
# changes to a single checklist item.
#
#
proc wapp-page-history {} {
  set dbfile [wapp-param OBJECT]
  set itemid [wapp-param itemid]
  if {$dbfile=="" || $itemid=="" || [scan $itemid %x itemid]!=1} return
  wapp-mimetype text/text
  sqlite3 db $dbfile
  db eval BEGIN
  wapp-subst {<table border="0" cellspacing="4">\n}
  set date {}
  db eval {SELECT date(mtime) as dx, strftime('%H:%M',mtime) as tx,
                  owner, rtrim(status,'+') AS status, comment FROM history
                  WHERE itemid=$itemid
                  ORDER BY julianday(mtime) DESC} {
     if {$dx!=$date} {
       wapp-subst {<tr><td>%html($dx)<td><td>\n}
       set date $dx
     }
     wapp-trim {
        <tr><td align="right" valign="top">%html($tx)
            <td valign="top">%html($status) %html($owner)
            <td>%html($comment)</tr>\n
     }
  }
  wapp-subst {</table>\n}
}


# The /sql page for doing arbitrary SQL on the database.
# This page is accessible to the administrator only.
#
proc wapp-page-sql {} {
  if {[checklist-common-header]} return
  if {![wapp-param CKLIST_ADMIN 0]} {
    wapp-redirect index
    return
  }
  set sql [string trimright [wapp-param sql]]
  wapp-trim {
    <form method="POST"><table border="0">
    <tr><td valign="top">SQL:&nbsp;
    <td><textarea name="sql" rows="5" cols="60">%html($sql)</textarea>
    <tr><td><td><input type="submit" value="Run">
    </table></form>
  }
  if {$sql!=""} { 
    set i 0
    wapp-subst {<hr><table border="1">\n}
    set rc [catch {
      db eval $sql x {
        if {$i==0} {
          wapp-subst {<tr>\n}
          foreach c $x(*) {
            wapp-subst {<th>%html($c)\n}
          }
          wapp-subst {</tr>\n}
          incr i
        }
        wapp-subst {<tr>\n}
        foreach c $x(*) {
          set v [set x($c)]
          wapp-subst {<td>%html($v)\n}
        }
        wapp-subst {</tr>}
      }
    } msg]
    if {$rc} {
      wapp-subst {<tr><td>ERROR: %html($msg)\n}
    }
    wapp-subst {</table>}
  }
  db eval COMMIT
  checklist-common-footer 
}

# Generate a text encoding of the checklist table
#
#    # (hash) top level item
#    ## (hash) second-level item
#    ## (hash) another second-level
#    # (hash) another top-level
#
proc checklist-as-text {} {
  set out {}
  db eval {SELECT seq, itemid, txt FROM checklist ORDER BY seq} {
    set id [format %x $itemid]
    regsub -all {\s+} [string trim $txt] { } txt
    if {($seq%100)==0} {
      append out "# ($id) $txt\n"
    } else {
      append out "## ($id) $txt\n"
    }
  }
  return $out
}

# Replace the content of the checklist table with a decoding
# of the text string given in the argument.  Throw an error and
# rollback the change if anything doesn't look right.
#
proc checklist-rebuild-from-text {txt} {
  set re {^(\#\#?) (\([0-9a-fA-F]+\) )?(.+)$}
  db transaction {
    db eval {DELETE FROM checklist}
    set i 0
    foreach line [split $txt \n] {
      set line [string trimright $line]
      if {$line==""} continue
      if {[regexp $re $line all a h t]} {
        if {$h==""} {unset h} {scan $h (%x) h}
        if {$a=="#"} {
          set i [expr {(int($i/100)+1)*100}]
        } elseif {$a=="##"} {
          if {$i==0} {error "\"##\" before any \"#\""}
          incr i
        } else {
          error "unknown line prefix: \"$a\""
        }
        db eval {INSERT INTO checklist(seq,itemid,txt)
                 VALUES($i,COALESCE($h,abs(random())),$t)}
      } else {
        error "illegal checklist line: \"$line\""
      }
    }
  }
}

# The /cklistedit page allows the administrator to edit the items on
# the checklist.
#
proc wapp-page-cklistedit {} {
  if {[checklist-common-header]} return
  if {![wapp-param CKLIST_ADMIN 0]} {
    wapp-redirect index
    return
  }
  set cklist [string trim [wapp-param cklist]]
  if {$cklist!=""} {
    checklist-rebuild-from-text $cklist
  }
  set x [checklist-as-text]
  wapp-trim {
    <form method="POST" action="cklistedit">
    <p>Edit checklist: <input type="submit" value="Install"><br>
    <textarea name="cklist" rows="40" cols="120">%html($x)</textarea>
    <br><input type="submit" value="Install">
    </form>
    </p>
  }
  catch {db eval COMMIT}
  checklist-common-footer 
}

# This dispatch hook checks to see if the first element of the PATH_INFO
# is the name of a checklist database.  If it is, it makes that database
# the OBJECT and shifts a new method name out of PATH_INFO and into
# PATH_HEAD for dispatch.
#
# If the first element of PATH_INFO is not a valid checklist database name,
# then change PATH_HEAD to be the database listing method.
#
proc wapp-before-dispatch-hook {} {
  global DATADIR
  set dbname [wapp-param PATH_HEAD]
  wapp-set-param ROOT_URL [wapp-param BASE_URL]
  if {[file readable $DATADIR/$dbname.db]} {
    # an appropriate database has been found
    wapp-set-param OBJECT $DATADIR/$dbname.db
    if {[regexp {^([^/]+)(.*)$} [wapp-param PATH_TAIL] all head tail]} {
      wapp-set-param PATH_HEAD $head
      wapp-set-param PATH_TAIL [string trimleft $tail /]
      wapp-set-param SELF_URL /$head
    } else {
      wapp-set-param PATH_HEAD {}
      wapp-set-param PATH_TAIL {}
    }
    wapp-set-param BASE [wapp-param BASE_URL]/$dbname
  } else {
    # Not a valid database.  Change the method to list all available
    # databases.
    wapp-set-param OBJECT {}
    wapp-set-param BASE [wapp-param SCRIPT_NAME]
    if {$dbname!="env"} {wapp-set-param PATH_HEAD listing}
  }
}

# Start up the web-server
wapp-start $::argv
