#!/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.
#
# This particular version of the checklist.tcl script has been customized for
# the SQLite website.
#
set DATADIR /checklist  ;# Edit to be the directory holding checklist databases

package require wapp

proc sqlite-header-text {} {
  wapp-content-security-policy "default-src 'self' 'unsafe-inline'"
  wapp-trim {
    <html><head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="content-type" content="text/html; charset=UTF-8">
    <link href="/sqlite.css" rel="stylesheet">
  }
  wapp-trim {
    <style>
    h1 { text-align: center; }
    div.ckcom {
      font-size: 80%;
      font-style: italic;
      white-space: pre;
    }
    span.ckuid {
      font-size: 80%;
      cursor: pointer;
    }
    div.cklstmenu {
      text-align: center;
      border: 1px solid black;
      padding: 2ex;
    }
    div.cklstmenu a {
      margin: 0 1.5ex;
    }
    p.error {
      font-weight: bold;
      color: red;
    }
    #editBox {
      display: none;
      border: 1px solid black;
    }
    </style>
  }
  wapp-trim {
    <title>SQLite Release Checklist</title>
    </head>
    <body>
    <div class=nosearch>
    <a href="index.html">
    <img class="logo" src="/images/sqlite370_banner.gif" alt="SQLite" border="0">
    </a>
    <div><!-- IE hack to prevent disappearing logo --></div>
    <div class="tagline desktoponly">
    Small. Fast. Reliable.<br>Choose any three.
    </div>
    <div class="menu mainmenu">
    <ul>
    <li><a href="index.html">Home</a>
    <li class='mobileonly'><a href="javascript:void(0)" onclick='toggle_div("submenu")'>Menu</a>
    <li class='wideonly'><a href='/about.html'>About</a>
    <li class='desktoponly'><a href="/docs.html">Documentation</a>
    <li class='desktoponly'><a href="/download.html">Download</a>
    <li class='wideonly'><a href='/copyright.html'>License</a>
    <li class='desktoponly'><a href="/support.html">Support</a>
    <li class='desktoponly'><a href="/prosupport.html">Purchase</a>
    <li class='search' id='search_menubutton'>
    <a href="javascript:void(0)" onclick='toggle_search()'>Search</a>
    </ul>
    </div>
    <div class="menu submenu" id="submenu">
    <ul>
    <li><a href='/about.html'>About</a>
    <li><a href='/docs.html'>Documentation</a>
    <li><a href='/download.html'>Download</a>
    <li><a href='/support.html'>Support</a>
    <li><a href='/prosupport.html'>Purchase</a>
    </ul>
    </div>
    <div class="searchmenu" id="searchmenu">
    <form method="GET" action="/search">
    <select name="s" id="searchtype">
    <option value="d">Search Documentation</option>
    <option value="c">Search Changelog</option>
    </select>
    <input type="text" name="q" id="searchbox" value="">
    <input type="submit" value="Go">
    </form>
    </div>
    </div>
    <script>
    function toggle_div(nm) {
    var w = document.getElementById(nm);
    if( w.style.display=="block" ){
    w.style.display = "none";
    }else{
    w.style.display = "block";
    }
    }
    function toggle_search() {
    var w = document.getElementById("searchmenu");
    if( w.style.display=="block" ){
    w.style.display = "none";
    } else {
    w.style.display = "block";
    setTimeout(function(){
    document.getElementById("searchbox").focus()
    }, 30);
    }
    }
    function div_off(nm){document.getElementById(nm).style.display="none";}
    window.onbeforeunload = function(e){div_off("submenu");}
    /* Disable the Search feature if we are not operating from CGI, since */
    /* Search is accomplished using CGI and will not work without it. */
    if( !location.origin.match || !location.origin.match(/http/) ){
    document.getElementById("search_menubutton").style.display = "none";
    }
    /* Used by the Hide/Show button beside syntax diagrams, to toggle the */
    function hideorshow(btn,obj){
    var x = document.getElementById(obj);
    var b = document.getElementById(btn);
    if( x.style.display!='none' ){
    x.style.display = 'none';
    b.innerHTML='show';
    }else{
    x.style.display = '';
    b.innerHTML='hide';
    }
    return false;
    }
    </script>
    </div>
  }

}

# 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
  sqlite-header-text
  wapp-trim {
    <h1 align='center'>Release Checklist Catalog</h1>
    <ol>
  }
  foreach dbfile [lsort -decreasing [glob -nocomplain $DATADIR/*.db]] {
    set name [file rootname [file tail $dbfile]]
    if {[regexp {^3(\d\d)(\d\d)(\d\d)$} $name all v1 v2 v3]} {
      foreach x {v1 v2 v3} {
        set $x [string trimleft [set $x] 0]
        if {[set $x]==""} {set $x 0}
      }
      set dname "Version 3.$v1.$v2"
      if {$v3!="0"} {
        append dname .$v3
      }
    } elseif {$name=="0demo"} {
      set dname "Test Checklist"
    } else {
      continue
    }
    set url [wapp-param BASE_URL]/$name/index
    wapp-subst {<li><a href='%url($url)'>%html($dname)</a>\n}
  }
  wapp-subst {</ol>\n}
}

# Show the CGI environment for testing purposes.
#
proc wapp-page-env {} {
  wapp-subst {<h1>Environment</h1>\n}
  wapp-subst {<pre>%html([wapp-debug-env])</pre>\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 {} {
  set x [wapp-param checklist-login]
  set user {}
  set write 0
  set admin 0
  set u {}
  set p {}
  foreach {u p} [split $x ,] {
    if {[db exists {SELECT 1 FROM config
                     WHERE name=('user-'||$u)
                       AND hex(value)=$p}]} {
      set write 1
      set user $u
      if {[db exists {SELECT 1 FROM config WHERE name=('admin-'||$u)}]} {
        set admin 1
      }
    }
    break;
  }
  wapp-set-param CKLIST_ADMIN $admin
  wapp-set-param CKLIST_WRITE $write
  wapp-set-param CKLIST_USER $user
}

# 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'}]
  sqlite-header-text
  wapp-trim {
    <h1>%html($title)</h1>
  }
  checklist-verify-login
  wapp-subst {<div class="cklstmenu">\n}
  set this [wapp-param PATH_HEAD]
  if {$this!="index"} {
    wapp-subst {<a href='index'>checklist</a>\n}
  }
  set write [wapp-param CKLIST_WRITE 0]
  if {$write==0 && $this!="login"} {
    wapp-subst {<a href='login'>login</a>\n}
  }
  if {$write==1 && $this!="logout"} {
    wapp-subst {<a href='logout'>%html([wapp-param CKLIST_USER])-logout</a>\n}
  }
  set admin [wapp-param CKLIST_ADMIN 0]
  if {$admin} {
    if {$this!="sql"} {
      wapp-subst {<a href='sql'>sql</a>\n}
    }
    if {$this!="cklistedit"} {
      wapp-subst {<a href='cklistedit'>edit-checklist</a>\n}
    }
  }
  wapp-subst {<a href='../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 {</body></html>}
  catch {db close}
}

# Draw the login screen
#
proc wapp-page-login {} {
  if {[checklist-common-header]} return
  if {[string match https:* [wapp-param BASE_URL]]==0
       && [wapp-param REMOTE_ADDR]!="127.0.0.1"} {
    wapp-subst {<p class="error">Login via HTTPS only</p>}
    checklist-common-footer
    return
  }
  if {[wapp-param SAME_ORIGIN]
   && [wapp-param-exists u]
   && [wapp-param-exists p]
  } {
    set u [wapp-param u]
    set p [wapp-param p]
    set px [db one {SELECT hex($p)}]
    set ok [db exists {SELECT 1 FROM config
                        WHERE name=('user-'||$u)
                          AND hex(value)=$px}]
    if {$ok} {
      wapp-set-cookie checklist-login $u,$px
      wapp-redirect index
      return
    }
    wapp-subst {<p class='error'>Invalid username or password</p>\n}
  }
  if {![wapp-param-exists HTTP_REFERER]} {
    wapp-trim {
       <h2>Warning: No "Referer" header</h2>
       <p> As a defense against cross-site request forgeries, this website
       ignores all POST requests that omit the "Referer:" from the header.
       The request that resulted in this page has no "Referer:" entry 
       in the header.
       So, unless something changes, you won't be able to log in.</p>
    }
  }
  wapp-trim {
    <form method='POST' action='login'>
    <table border="0">
    <tr><td align='right'>Login:&nbsp;</td>
        <td><input type='text' name='u' width='20'></td></tr>
    <tr><td align='right'>Password:&nbsp;</td>
        <td><input type='password' name='p' width='20'></td></tr>
    <tr><td><td><input type='submit' value='Login'></td></tr>
    </table></form>
  }
  checklist-common-footer
}

# Draw the logout screen
#
proc wapp-page-logout {} {
  if {[checklist-common-header]} return
  if {![wapp-param CKLIST_WRITE] || [wapp-param-exists logout]} {
    wapp-clear-cookie checklist-login
    wapp-redirect index
    return
  }
  if {[wapp-param-exists cancel]} {
    wapp-redirect index
    return
  }
  set u [wapp-param CKLIST_USER]
  wapp-trim {
    <form method='POST' action='logout'>
    <input type='submit' name='logout' value='%html($u) Logout'>
    <input type='submit' name='cancel' value='Cancel'>
    </form>
  }
  checklist-common-footer
}

# 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}

  wapp-subst {<script src='cklist.js'></script>\n}
  checklist-common-footer
}


# The javascript for the main checklist page goes here
#
proc wapp-page-cklist.js {} {
  wapp-mimetype text/javascript
  wapp-cache-control max-age=86400
  wapp {
    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;
        }
      }
    }
    cklistAjax('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);
      cklistAjax("jstat?itemid="+e.id.substr(5),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("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 checklist-login is a valid login 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 [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" action="sql"><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 {}
    }
  } else {
    # Not a valid database.  Change the method to list all available
    # databases.
    wapp-set-param OBJECT {}
    if {$dbname!="env"} {wapp-set-param PATH_HEAD listing}
  }
}

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