Check-in [62f37c9722]
Not logged in
Overview

SHA1 Hash:62f37c972204304cb0412b7f61a0754fb8c1fd38
Date: 2007-11-26 01:33:50
User: drh
Comment:Begin porting the CVSTrac ticket reporting code over to fossil. The new code is not yet connected into the system. The port is incomplete.
Timelines: ancestors | descendants | both | trunk
Other Links: files | ZIP archive | manifest

Tags And Properties
Changes
[hide diffs]

Modified src/rebuild.c from [d7213a8b18] to [d164c66a47].

@@ -26,10 +26,37 @@
 #include "config.h"
 #include "rebuild.h"
 #include <assert.h>
 
 /*
+** Schema changes
+*/
+static const char zSchemaUpdates[] =
+@ -- Index on the delta table
+@ --
+@ CREATE INDEX IF NOT EXISTS delta_i1 ON delta(srcid);"
+@
+@ -- Artifacts that should not be processed are identified in the
+@ -- "shun" table.  Artifacts that are control-file forgeries or
+@ -- spam can be shunned in order to prevent them from contaminating
+@ -- the repository.
+@ --
+@ CREATE TABLE IF NOT EXISTS shun(uuid UNIQUE);
+@
+@ -- An entry in this table describes a database query that generates a
+@ -- table of tickets.
+@ --
+@ CREATE TABLE IF NOT EXISTS reportfmt(
+@    rn integer primary key,  -- Report number
+@    owner text,              -- Owner of this report format (not used)
+@    title text,              -- Title of this report
+@    cols text,               -- A color-key specification
+@    sqlcode text             -- An SQL SELECT statement for this report
+@ );
+;
+
+/*
 ** Core function to rebuild the infomration in the derived tables of a
 ** fossil repository from the blobs. This function is shared between
 ** 'rebuild_database' ('rebuild') and 'reconstruct_cmd'
 ** ('reconstruct'), both of which have to regenerate this information
 ** from scratch.
@@ -43,14 +70,11 @@
   Stmt s;
   int errCnt = 0;
   char *zTable;
   int cnt = 0;
 
-  db_multi_exec(
-    "CREATE INDEX IF NOT EXISTS delta_i1 ON delta(srcid);"
-    "CREATE TABLE IF NOT EXISTS shun(uuid UNIQUE);"
-  );
+  db_multi_exec(zSchemaUpdates);
   for(;;){
     zTable = db_text(0,
        "SELECT name FROM sqlite_master"
        " WHERE type='table'"
        " AND name NOT IN ('blob','delta','rcvfrom','user','config','shun')");

Added src/report.c version [3412e750ff]

@@ -1,1 +1,992 @@
+/*
+** Copyright (c) 2007 D. Richard Hipp
+**
+** This program is free software; you can redistribute it and/or
+** modify it under the terms of the GNU General Public
+** License as published by the Free Software Foundation; either
+** version 2 of the License, or (at your option) any later version.
+**
+** This program 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
+** General Public License for more details.
+**
+** You should have received a copy of the GNU General Public
+** License along with this library; if not, write to the
+** Free Software Foundation, Inc., 59 Temple Place - Suite 330,
+** Boston, MA  02111-1307, USA.
+**
+** Author contact information:
+**   drh@hwaci.com
+**   http://www.hwaci.com/drh/
+**
+*******************************************************************************
+**
+** Code to generate the bug report listings
+*/
+#include "config.h"
+#include "report.h"
+#include <assert.h>
+
+/* Forward references to static routines */
+static void report_format_hints(void);
+
+/*
+** WEBPAGE: /reportlist
+*/
+void view_list(void){
+  Stmt q;
+
+  login_check_credentials();
+  if( !g.okRdTkt ){ login_needed(); return; }
+  style_header("Available Report Formats");
+  db_prepare(&q, "SELECT rn, title, owner FROM reportfmt ORDER BY title");
+  @ <p>Choose a report format from the following list:</p>
+  @ <ol>
+  while( db_step(&q)==SQLITE_ROW ){
+    int rn = db_column_int(&q, 0);
+    const char *zTitle = db_column_text(&q, 1);
+    const char *zOwner = db_column_text(&q, 2);
+    @ <li><a href="rptview?rn=%d(rn)"
+    @        rel="nofollow">%h(zTitle)</a>&nbsp;&nbsp;&nbsp;
+    if( g.okWrite && zOwner && zOwner[0] ){
+      @ (by <i>%h(zOwner)</i>)
+    }
+    if( g.okWrTkt ){
+      @ [<a href="rptedit?rn=%d(rn)&amp;copy=1" rel="nofollow">copy</a>]
+    }
+    if( g.okAdmin || (g.okWrTkt && zOwner && strcmp(g.zLogin,zOwner)==0) ){
+      @ [<a href="rptedit?rn=%d(rn)" rel="nofollow">edit</a>]
+    }
+    @ [<a href="rptsql?rn=%d(rn)" rel="nofollow">sql</a>]
+    @ </li>
+  }
+  if( g.okWrTkt ){
+    @ <li><a href="rptnew">Create a new report format</a></li>
+  }
+  @ </ol>
+  common_footer();
+}
+
+/*
+** Remove whitespace from both ends of a string.
+*/
+char *trim_string(const char *zOrig){
+  int i;
+  while( isspace(*zOrig) ){ zOrig++; }
+  i = strlen(zOrig);
+  while( i>0 && isspace(zOrig[i-1]) ){ i--; }
+  return mprintf("%.*s", i, zOrig);
+}
+
+/*
+** Extract a numeric (integer) value from a string.
+*/
+char *extract_integer(const char *zOrig){
+  if( zOrig == NULL || zOrig[0] == 0 ) return "";
+  while( *zOrig && !isdigit(*zOrig) ){ zOrig++; }
+  if( *zOrig ){
+    /* we have a digit. atoi() will get as much of the number as it
+    ** can. We'll run it through mprintf() to get a string. Not
+    ** an efficient way to do it, but effective.
+    */
+    return mprintf("%d", atoi(zOrig));
+  }
+  return "";
+}
+
+/*
+** Remove blank lines from the beginning of a string and
+** all whitespace from the end. Removes whitespace preceeding a NL,
+** which also converts any CRNL sequence into a single NL.
+*/
+char *remove_blank_lines(const char *zOrig){
+  int i, j, n;
+  char *z;
+  for(i=j=0; isspace(zOrig[i]); i++){ if( zOrig[i]=='\n' ) j = i+1; }
+  n = strlen(&zOrig[j]);
+  while( n>0 && isspace(zOrig[j+n-1]) ){ n--; }
+  z = mprintf("%.*s", n, &zOrig[j]);
+  for(i=j=0; z[i]; i++){
+    if( z[i+1]=='\n' && z[i]!='\n' && isspace(z[i]) ){
+      z[j] = z[i];
+      while(isspace(z[j]) && z[j] != '\n' ){ j--; }
+      j++;
+      continue;
+    }
+
+    z[j++] = z[i];
+  }
+  z[j] = 0;
+  return z;
+}
+
+
+/*********************************************************************/
+
+/*
+** This is the SQLite authorizer callback used to make sure that the
+** SQL statements entered by users do not try to do anything untoward.
+** If anything suspicious is tried, set *(char**)pError to an error
+** message obtained from malloc.
+*/
+static int report_query_authorizer(
+  void *pError,
+  int code,
+  const char *zArg1,
+  const char *zArg2,
+  const char *zArg3,
+  const char *zArg4
+){
+  char *zError = *(char**)pError;
+  if( zError ){
+    /* We've already seen an error.  No need to continue. */
+    return SQLITE_OK;
+  }
+  switch( code ){
+    case SQLITE_SELECT:
+    case SQLITE_FUNCTION: {
+      break;
+    }
+    case SQLITE_READ: {
+      static const char *azAllowed[] = {
+         "ticket",
+         "blob",
+         "filename",
+         "mlink",
+         "plink",
+         "event",
+         "tag",
+         "tagxref",
+      }
+      int i;
+      for(i=0; i<sizeof(azAllowed)/sizeof(azAllowed[0]); i++){
+        if( strcasecmp(zArg1, azAllowed[i])==0 ) break;
+      }
+      if( i>=sizeof(azAllowed)/sizeof(azAllowed[0]) ){
+        zError = mprintf("cannot access table %s", zArg1);
+      }
+      break;
+    }
+    default: {
+      zError = mprintf("only SELECT statements are allowed");
+      break;
+    }
+  }
+  return SQLITE_OK;
+}
+
+
+/*
+** Check the given SQL to see if is a valid query that does not
+** attempt to do anything dangerous.  Return 0 on success and a
+** pointer to an error message string (obtained from malloc) if
+** there is a problem.
+*/
+char *verify_sql_statement(char *zSql){
+  int i;
+  char *zErr1 = 0;
+  char *zErr2 = 0;
+  char *zTail;
+
+  /* First make sure the SQL is a single query command by verifying that
+  ** the first token is "SELECT" and that there are no unquoted semicolons.
+  */
+  for(i=0; isspace(zSql[i]); i++){}
+  if( strncasecmp(&zSql[i],"select",6)!=0 ){
+    return mprintf("The SQL must be a SELECT statement");
+  }
+  for(i=0; zSql[i]; i++){
+    if( zSql[i]==';' ){
+      int bad;
+      int c = zSql[i+1];
+      zSql[i+1] = 0;
+      bad = sqlite3_complete(zSql);
+      zSql[i+1] = c;
+      if( bad ){
+        /* A complete statement basically means that an unquoted semi-colon
+        ** was found. We don't actually check what's after that.
+        */
+        return mprintf("Semi-colon detected! "
+                       "Only a single SQL statement is allowed");
+      }
+    }
+  }
+
+  /* Compile the statement and check for illegal accesses or syntax errors. */
+  sqlite3_set_authorizer(g.db, report_query_authorizer, (void*)&zErr);
+  rc = sqlite3_prepare(g.db, zSql, -1, &pStmt, &zTail);
+  if( rc!=SQLITE_OK ){
+    free(zErr);
+    zErr = mprintf("Syntax error: %s", sqlite3_errmsg(g.db));
+  }
+  if( pStmt ){
+    sqlite3_finalize(pStmt);
+  }
+  sqlite3_set_authorizer(g.db, 0, 0);
+  return zErr;
+}
+
+/*
+** WEBPAGE: /rptsql
+*/
+void view_see_sql(void){
+  int rn, rc;
+  char *zTitle;
+  char *zSQL;
+  char *zOwner;
+  char *zClrKey;
+  Stmt q;
+
+  login_check_credentials();
+  if( !g.okQuery ){
+    login_needed();
+    return;
+  }
+  rn = atoi(PD("rn","0"));
+  db_prepare(&q, "SELECT title, sqlcode, owner, cols "
+                   "FROM reportfmt WHERE rn=%d",rn);
+  style_header("SQL For Report Format Number %d", rn);
+  if( db_step(&q)!=SQLITE_ROW ){
+    @ <p>Unknown report number: %d(rn)</p>
+    style_footer();
+    return;
+  }
+  zTitle = db_column_text(&q, 0);
+  zSQL = db_column_text(&q, 1);
+  zOwner = db_column_text(&q, 2);
+  zClrKey = db_column_text(&q, 3);
+  @ <table cellpadding=0 cellspacing=0 border=0>
+  @ <tr><td valign="top" align="right">Title:</td><td width=15></td>
+  @ <td colspan=3>%h(zTitle)</td></tr>
+  @ <tr><td valign="top" align="right">Owner:</td><td></td>
+  @ <td colspan=3>%h(zOwner)</td></tr>
+  @ <tr><td valign="top" align="right">SQL:</td><td></td>
+  @ <td valign="top"><pre>
+  @ %h(zSQL)
+  @ </pre></td>
+  @ <td width=15></td><td valign="top">
+  output_color_key(zClrKey, 0, "border=0 cellspacing=0 cellpadding=3");
+  @ </td>
+  @ </tr></table>
+  report_format_hints();
+  style_footer();
+}
+
+/*
+** WEBPAGE: /rptnew
+** WEBPAGE: /rptedit
+*/
+void view_edit(void){
+  int rn;
+  const char *zTitle;
+  const char *z;
+  const char *zOwner;
+  char *zClrKey;
+  char *zSQL;
+  char *zErr = 0;
+
+  login_check_credentials();
+  if( !g.okQuery ){
+    login_needed();
+    return;
+  }
+  view_add_functions(0);
+  rn = atoi(PD("rn","0"));
+  zTitle = P("t");
+  zOwner = PD("w",g.zLogin);
+  z = P("s");
+  zSQL = z ? trim_string(z) : 0;
+  zClrKey = trim_string(PD("k",""));
+  if( rn>0 && P("del2") ){
+    db_multi_exec("DELETE FROM reportfmt WHERE rn=%d", rn);
+    cgi_redirect("reportlist");
+    return;
+  }else if( rn>0 && P("del1") ){
+    zTitle = db_text(0, "SELECT title FROM reportfmt "
+                         "WHERE rn=%d", rn);
+    if( zTitle==0 ) cgi_redirect("reportlist");
+
+    style_header("Are You Sure?");
+    @ <form action="rptedit" method="POST">
+    @ <p>You are about to delete all traces of the report
+    @ <strong>%h(zTitle)</strong> from
+    @ the database.  This is an irreversible operation.  All records
+    @ related to this report will be removed and cannot be recovered.</p>
+    @
+    @ <input type="hidden" name="rn" value="%d(rn)">
+    @ <input type="submit" name="del2" value="Delete The Report">
+    @ <input type="submit" name="can" value="Cancel">
+    @ </form>
+    style_footer();
+    return;
+  }else if( P("can") ){
+    /* user cancelled */
+    cgi_redirect("reportlist");
+    return;
+  }
+  if( zTitle && zSQL ){
+    if( zSQL[0]==0 ){
+      zErr = "Please supply an SQL query statement";
+    }else if( (zTitle = trim_string(zTitle))[0]==0 ){
+      zErr = "Please supply a title";
+    }else{
+      zErr = verify_sql_statement(zSQL);
+    }
+    if( zErr==0 ){
+      if( rn>0 ){
+        db_multi_exec("UPDATE reportfmt SET title=%Q, sqlcode=%Q,"
+                      " owner=%Q, cols=%Q WHERE rn=%d",
+           zTitle, zSQL, zOwner, zClrKey, rn);
+      }else{
+        db_multi_exec("INSERT INTO reportfmt(title,sqlcode,owner,cols) "
+           "VALUES(%Q,%Q,%Q,%Q)",
+           zTitle, zSQL, zOwner, zClrKey);
+        rn = db_last_insert_rowid();
+      }
+      cgi_redirect(mprintf("rptview?rn=%d", rn));
+      return;
+    }
+  }else if( rn==0 ){
+    zTitle = "";
+    zSQL =
+      @ SELECT
+      @   CASE WHEN status IN ('new','active') THEN '#f2dcdc'
+      @        WHEN status='review' THEN '#e8e8bd'
+      @        WHEN status='fixed' THEN '#cfe8bd'
+      @        WHEN status='tested' THEN '#bde5d6'
+      @        WHEN status='defer' THEN '#cacae5'
+      @        ELSE '#c8c8c8' END AS 'bgcolor',
+      @   tn AS '#',
+      @   type AS 'Type',
+      @   status AS 'Status',
+      @   sdate(origtime) AS 'Created',
+      @   owner AS 'By',
+      @   subsystem AS 'Subsys',
+      @   sdate(changetime) AS 'Changed',
+      @   assignedto AS 'Assigned',
+      @   severity AS 'Svr',
+      @   priority AS 'Pri',
+      @   title AS 'Title'
+      @ FROM ticket
+    ;
+    zClrKey =
+      @ #ffffff Key:
+      @ #f2dcdc Active
+      @ #e8e8e8 Review
+      @ #cfe8bd Fixed
+      @ #bde5d6 Tested
+      @ #cacae5 Deferred
+      @ #c8c8c8 Closed
+    ;
+  }else{
+    db_prepare(&q, "SELECT title, sqlcode, owner, cols "
+                     "FROM reportfmt WHERE rn=%d",rn);
+    if( db_step(&q)==SQLITE_ROW ){
+      zTitle = db_column_malloc(&q, 0);
+      zSQL = db_column_malloc(&q, 1);
+      zOwner = db_column_malloc(&q, 2);
+      zClrKey = db_column_malloc(&q, 3);
+    }
+    if( P("copy") ){
+      rn = 0;
+      zTitle = mprintf("Copy Of %s", zTitle);
+      zOwner = g.zLogin;
+    }
+  }
+  if( zOwner==0 ) zOwner = g.zLogin;
+  style_submenu_element("Cancel", "Cancel", "reportlist");
+  if( rn>0 ){
+    style_submenu_element("Delete", "Delete", "rptedit?rn=%d&del1=1", rn);
+  }
+  style_header(rn>0 ? "Edit Report Format":"Create New Report Format");
+  if( zErr ){
+    @ <blockquote><font color="#ff0000"><b>%h(zErr)</b></font></blockquote>
+  }
+  @ <form action="rptedit" method="POST">
+  @ <input type="hidden" name="rn" value="%d(rn)">
+  @ <p>Report Title:<br>
+  @ <input type="text" name="t" value="%h(zTitle)" size="60"></p>
+  @ <p>Enter a complete SQL query statement against the "TICKET" table:<br>
+  @ <textarea name="s" rows="20" cols="80">%h(zSQL)</textarea>
+  @ </p>
+  if( g.okAdmin ){
+    @ <p>Report owner:
+    @ <input type="text" name="w" size="20" value="%h(zOwner)">
+    @ </p>
+  } else {
+    @ <input type="hidden" name="w" value="%h(zOwner)">
+  }
+  @ <p>Enter an optional color key in the following box.  (If blank, no
+  @ color key is displayed.)  Each line contains the text for a single
+  @ entry in the key.  The first token of each line is the background
+  @ color for that line.<br>
+  @ <textarea name="k" rows="8" cols="50">%h(zClrKey)</textarea>
+  @ </p>
+  if( !g.okAdmin && strcmp(zOwner,g.zLogin)!=0 ){
+    @ <p>This report format is owned by %h(zOwner).  You are not allowed
+    @ to change it.</p>
+    @ </form>
+    report_format_hints();
+    style_footer();
+    return;
+  }
+  @ <input type="submit" value="Apply Changes">
+  if( rn>0 ){
+    @ <input type="submit" value="Delete This Report" name="del1">
+  }
+  @ </form>
+  report_format_hints();
+  style_footer();
+}
+
+/*
+** Output a bunch of text that provides information about report
+** formats
+*/
+static void report_format_hints(void){
+  char *zSchema;
+  zSchema = db_text(0,"SELECT sql FROM sqlite_master WHERE name='ticket'");
+  @ <hr><h3>TICKET Schema</h3>
+  @ <blockquote><pre>
+  @ %h(zSchema)
+  @ </pre></blockquote>
+  @ <h3>Notes</h3>
+  @ <ul>
+  @ <li><p>The SQL must consist of a single SELECT statement</p></li>
+  @
+  @ <li><p>If a column of the result set is named "#" then that column
+  @ is assumed to hold a ticket number.  A hyperlink will be created from
+  @ that column to a detailed view of the ticket.</p></li>
+  @
+  @ <li><p>If a column of the result set is named "bgcolor" then the content
+  @ of that column determines the background color of the row.</p></li>
+  @
+  @ <li><p>The <b>user()</b> SQL function returns a string
+  @ which is the login of the current user.</p></li>
+  @
+  @ <li><p>The first column whose name begins with underscore ("_") and all
+  @ subsequent columns are shown on their own rows in the table.  This might
+  @ be useful for displaying the description of tickets.
+  @ </p></li>
+  @
+  @ <li><p>The <b>aux()</b> SQL function takes a parameter name as an argument
+  @ and returns the value that the user enters in the resulting HTML form. A
+  @ second optional parameter provides a default value for the field.</p></li>
+  @
+  @ <li><p>The <b>option()</b> SQL function takes a parameter name
+  @ and a quoted SELECT statement as parameters. The query results are
+  @ presented as an HTML dropdown menu and the function returns
+  @ the currently selected value. Results may be a single value column or
+  @ two <b>value,description</b> columns. The first row is the default.</p></li>
+  @
+  @ <li><p>The <b>cgi()</b> SQL function takes a parameter name as an argument
+  @ and returns the value of a corresponding CGI query value. If the CGI
+  @ parameter doesn't exist, an optional second argument will be returned
+  @ instead.</p></li>
+  @
+  @ <li><p>If a column is wrapped by the <b>wiki()</b> SQL function, it will
+  @ be rendered as wiki formatted content.</p></li>
+  @
+  @ <li><p>If a column is wrapped by the <b>tkt()</b> SQL function, it will
+  @ be shown as a ticket id with a link to the appropriate page</p></li>
+  @
+  @ <li><p>If a column is wrapped by the <b>chng()</b> SQL function, it will
+  @ be shown as a baseline id with a link to the appropriate page.</p></li>
+  @
+  @ <li><p>The <b>search()</b> SQL function takes a keyword pattern and
+  @ a search text. The function returns an integer score which is
+  @ higher depending on how well the search went.</p></li>
+  @
+  @ <li><p>The query can join other tables in the database besides TICKET.
+  @ </p></li>
+  @ </ul>
+  @
+  @ <h3>Examples</h3>
+  @ <p>In this example, the first column in the result set is named
+  @ "bgcolor".  The value of this column is not displayed.  Instead, it
+  @ selects the background color of each row based on the TICKET.STATUS
+  @ field of the database.  The color key at the right shows the various
+  @ color codes.</p>
+  @ <table align="right" style="margin: 0 5px;" border=1 cellspacing=0 width=125>
+  @ <tr bgcolor="#f2dcdc"><td align="center">new or active</td></tr>
+  @ <tr bgcolor="#e8e8bd"><td align="center">review</td></tr>
+  @ <tr bgcolor="#cfe8bd"><td align="center">fixed</td></tr>
+  @ <tr bgcolor="#bde5d6"><td align="center">tested</td></tr>
+  @ <tr bgcolor="#cacae5"><td align="center">defer</td></tr>
+  @ <tr bgcolor="#c8c8c8"><td align="center">closed</td></tr>
+  @ </table>
+  @ <blockquote><pre>
+  @ SELECT
+  @   CASE WHEN status IN ('new','active') THEN '#f2dcdc'
+  @        WHEN status='review' THEN '#e8e8bd'
+  @        WHEN status='fixed' THEN '#cfe8bd'
+  @        WHEN status='tested' THEN '#bde5d6'
+  @        WHEN status='defer' THEN '#cacae5'
+  @        ELSE '#c8c8c8' END as 'bgcolor',
+  @   tn AS '#',
+  @   type AS 'Type',
+  @   status AS 'Status',
+  @   sdate(origtime) AS 'Created',
+  @   owner AS 'By',
+  @   subsystem AS 'Subsys',
+  @   sdate(changetime) AS 'Changed',
+  @   assignedto AS 'Assigned',
+  @   severity AS 'Svr',
+  @   priority AS 'Pri',
+  @   title AS 'Title'
+  @ FROM ticket
+  @ </pre></blockquote>
+  @ <p>To base the background color on the TICKET.PRIORITY or
+  @ TICKET.SEVERITY fields, substitute the following code for the
+  @ first column of the query:</p>
+  @ <table align="right" style="margin: 0 5px;" border=1 cellspacing=0 width=125>
+  @ <tr bgcolor="#f2dcdc"><td align="center">1</td></tr>
+  @ <tr bgcolor="#e8e8bd"><td align="center">2</td></tr>
+  @ <tr bgcolor="#cfe8bd"><td align="center">3</td></tr>
+  @ <tr bgcolor="#cacae5"><td align="center">4</td></tr>
+  @ <tr bgcolor="#c8c8c8"><td align="center">5</td></tr>
+  @ </table>
+  @ <blockquote><pre>
+  @ SELECT
+  @   CASE priority WHEN 1 THEN '#f2dcdc'
+  @        WHEN 2 THEN '#e8e8bd'
+  @        WHEN 3 THEN '#cfe8bd'
+  @        WHEN 4 THEN '#cacae5'
+  @        ELSE '#c8c8c8' END as 'bgcolor',
+  @ ...
+  @ FROM ticket
+  @ </pre></blockquote>
+#if 0
+  @ <p>You can, of course, substitute different colors if you choose.
+  @ Here is a palette of suggested background colors:</p>
+  @ <blockquote>
+  @ <table border=1 cellspacing=0 width=300>
+  @ <tr><td align="center" bgcolor="#ffbdbd">#ffbdbd</td>
+  @     <td align="center" bgcolor="#f2dcdc">#f2dcdc</td></tr>
+  @ <tr><td align="center" bgcolor="#ffffbd">#ffffbd</td>
+  @     <td align="center" bgcolor="#e8e8bd">#e8e8bd</td></tr>
+  @ <tr><td align="center" bgcolor="#c0ebc0">#c0ebc0</td>
+  @     <td align="center" bgcolor="#cfe8bd">#cfe8bd</td></tr>
+  @ <tr><td align="center" bgcolor="#c0c0f4">#c0c0f4</td>
+  @     <td align="center" bgcolor="#d6d6e8">#d6d6e8</td></tr>
+  @ <tr><td align="center" bgcolor="#d0b1ff">#d0b1ff</td>
+  @     <td align="center" bgcolor="#d2c0db">#d2c0db</td></tr>
+  @ <tr><td align="center" bgcolor="#bbbbbb">#bbbbbb</td>
+  @     <td align="center" bgcolor="#d0d0d0">#d0d0d0</td></tr>
+  @ </table>
+  @ </blockquote>
+#endif
+  @ <p>To see the TICKET.DESCRIPTION and TICKET.REMARKS fields, include
+  @ them as the last two columns of the result set and given them names
+  @ that begin with an underscore.  Like this:</p>
+  @ <blockquote><pre>
+  @  SELECT
+  @    tn AS '#',
+  @    type AS 'Type',
+  @    status AS 'Status',
+  @    sdate(origtime) AS 'Created',
+  @    owner AS 'By',
+  @    subsystem AS 'Subsys',
+  @    sdate(changetime) AS 'Changed',
+  @    assignedto AS 'Assigned',
+  @    severity AS 'Svr',
+  @    priority AS 'Pri',
+  @    title AS 'Title',
+  @    description AS '_Description',   -- When the column name begins with '_'
+  @    remarks AS '_Remarks'            -- the data is shown on a separate row.
+  @  FROM ticket
+  @ </pre></blockquote>
+  @
+  @ <p>Or, to see part of the description on the same row, use the
+  @ <b>wiki()</b> function with some string manipulation. Using the
+  @ <b>tkt()</b> function on the ticket number will also generate a linked
+  @ field, but without the extra <i>edit</i> column:
+  @ </p>
+  @ <blockquote><pre>
+  @  SELECT
+  @    tkt(tn) AS '',
+  @    title AS 'Title',
+  @    wiki(substr(description,0,80)) AS 'Description'
+  @  FROM ticket
+  @ </pre></blockquote>
+  @
+}
+
+/*********************************************************************/
+static void output_report_field(const char *zData,int rn){
+  const char *zWkey = wiki_key();
+  const char *zTkey = tkt_key();
+  const char *zCkey = chng_key();
+
+  if( !strncmp(zData,zWkey,strlen(zWkey)) ){
+    output_formatted(&zData[strlen(zWkey)],0);
+  }else if( !strncmp(zData,zTkey,strlen(zTkey)) ){
+    output_ticket(atoi(&zData[strlen(zTkey)]),rn);
+  }else if( !strncmp(zData,zCkey,strlen(zCkey)) ){
+    output_chng(atoi(&zData[strlen(zCkey)]));
+  }else{
+    @ %h(zData)
+  }
+}
+
+static void column_header(int rn,const char *zCol, int nCol, int nSorted,
+    const char *zDirection, const char *zExtra
+){
+  int set = (nCol==nSorted);
+  int desc = !strcmp(zDirection,"DESC");
+
+  /*
+  ** Clicking same column header 3 times in a row resets any sorting.
+  ** Note that we link to rptview, which means embedded reports will get
+  ** sent to the actual report view page as soon as a user tries to do
+  ** any sorting. I don't see that as a Bad Thing.
+  */
+  if(set && desc){
+    @ <th bgcolor="%s(BG1)" class="bkgnd1">
+    @   <a href="rptview?rn=%d(rn)%s(zExtra)">%h(zCol)</a></th>
+  }else{
+    if(set){
+      @ <th bgcolor="%s(BG1)" class="bkgnd1"><a
+    }else{
+      @ <th><a
+    }
+    @ href="rptview?rn=%d(rn)&amp;order_by=%d(nCol)&amp;\
+    @ order_dir=%s(desc?"ASC":"DESC")\
+    @ %s(zExtra)">%h(zCol)</a></th>
+  }
+}
+
+/*********************************************************************/
+struct GenerateHTML {
+  int rn;
+  int nCount;
+};
+
+/*
+** The callback function for db_query
+*/
+static int generate_html(
+  void* pUser,     /* Pointer to output state */
+  int nArg,        /* Number of columns in this result row */
+  char **azArg,    /* Text of data in all columns */
+  char **azName    /* Names of the columns */
+){
+  struct GenerateHTML* pState = (struct GenerateHTML*)pUser;
+  int i;
+  int tn;            /* Ticket number.  (value of column named '#') */
+  int rn;            /* Report number */
+  int ncol;          /* Number of columns in the table */
+  int multirow;      /* True if multiple table rows per line of data */
+  int newrowidx;     /* Index of first column that goes on a separate row */
+  int iBg = -1;      /* Index of column that determines background color */
+  char *zBg = 0;     /* Use this background color */
+  char zPage[30];    /* Text version of the ticket number */
+
+  /* Get the report number
+  */
+  rn = pState->rn;
+
+  /* Figure out the number of columns, the column that determines background
+  ** color, and whether or not this row of data is represented by multiple
+  ** rows in the table.
+  */
+  ncol = 0;
+  multirow = 0;
+  newrowidx = -1;
+  for(i=0; i<nArg; i++){
+    if( azName[i][0]=='b' && strcmp(azName[i],"bgcolor")==0 ){
+      zBg = azArg ? azArg[i] : 0;
+      iBg = i;
+      continue;
+    }
+    if( g.okWrite && azName[i][0]=='#' ){
+      ncol++;
+    }
+    if( !multirow ){
+      if( azName[i][0]=='_' ){
+        multirow = 1;
+        newrowidx = i;
+      }else{
+        ncol++;
+      }
+    }
+  }
+
+  /* The first time this routine is called, output a table header
+  */
+  if( pState->nCount==0 ){
+    char zExtra[2000];
+    int nField = atoi(PD("order_by","0"));
+    const char* zDir = PD("order_dir","");
+    zDir = !strcmp("ASC",zDir) ? "ASC" : "DESC";
+    zExtra[0] = 0;
+
+    if( g.nAux ){
+      @ <tr>
+      @ <td colspan=%d(ncol)><form action="rptview" method="GET">
+      @ <input type="hidden" name="rn" value="%d(rn)">
+      for(i=0; i<g.nAux; i++){
+        const char *zN = g.azAuxName[i];
+        const char *zP = g.azAuxParam[i];
+        if( g.azAuxVal[i] && g.azAuxVal[i][0] ){
+          appendf(zExtra,0,sizeof(zExtra),
+                  "&amp;%t=%t",g.azAuxParam[i],g.azAuxVal[i]);
+        }
+        if( g.azAuxOpt[i] ){
+          @ %h(zN):
+          if( g.anAuxCols[i]==1 ) {
+            cgi_v_optionmenu( 0, zP, g.azAuxVal[i], g.azAuxOpt[i] );
+          }else if( g.anAuxCols[i]==2 ){
+            cgi_v_optionmenu2( 0, zP, g.azAuxVal[i], g.azAuxOpt[i] );
+          }
+        }else{
+          @ %h(zN): <input type="text" name="%h(zP)" value="%h(g.azAuxVal[i])">
+        }
+      }
+      @ <input type="submit" value="Go">
+      @ </form></td></tr>
+    }
+    @ <tr>
+    tn = -1;
+    for(i=0; i<nArg; i++){
+      char *zName = azName[i];
+      if( i==iBg ) continue;
+      if( newrowidx>=0 && i>=newrowidx ){
+        if( g.okWrite && tn>=0 ){
+          @ <th>&nbsp;</th>
+          tn = -1;
+        }
+        if( zName[0]=='_' ) zName++;
+        @ </tr><tr><th colspan=%d(ncol)>%h(zName)</th>
+      }else{
+        if( zName[0]=='#' ){
+          tn = i;
+        }
+        /*
+        ** This handles any sorting related stuff. Note that we don't
+        ** bother trying to sort on the "wiki format" columns. I don't
+        ** think it makes much sense, visually.
+        */
+        column_header(rn,azName[i],i+1,nField,zDir,zExtra);
+      }
+    }
+    if( g.okWrite && tn>=0 ){
+      @ <th>&nbsp;</th>
+    }
+    @ </tr>
+  }
+  if( azArg==0 ){
+    @ <tr><td colspan="%d(ncol)">
+    @ <i>No records match the report criteria</i>
+    @ </td></tr>
+    return 0;
+  }
+  ++pState->nCount;
+
+  /* Output the separator above each entry in a table which has multiple lines
+  ** per database entry.
+  */
+  if( newrowidx>=0 ){
+    @ <tr><td colspan=%d(ncol)><font size=1>&nbsp;</font></td></tr>
+  }
+
+  /* Output the data for this entry from the database
+  */
+  if( zBg==0 ) zBg = "white";
+  @ <tr bgcolor="%h(zBg)">
+  tn = 0;
+  zPage[0] = 0;
+  for(i=0; i<nArg; i++){
+    char *zData;
+    if( i==iBg ) continue;
+    zData = azArg[i];
+    if( zData==0 ) zData = "";
+    if( newrowidx>=0 && i>=newrowidx ){
+      if( tn>0 && g.okWrite ){
+        @ <td valign="top"><a href="tktedit?tn=%d(tn),%d(rn)">edit</a></td>
+        tn = 0;
+      }
+      if( zData[0] ){
+        @ </tr><tr bgcolor="%h(zBg)"><td colspan=%d(ncol)>
+        output_formatted(zData, zPage[0] ? zPage : 0);
+      }
+    }else if( azName[i][0]=='#' ){
+      tn = atoi(zData);
+      if( tn>0 ) bprintf(zPage, sizeof(zPage), "%d", tn);
+      @ <td valign="top"><a href="tktview?tn=%d(tn),%d(rn)">%h(zData)</a></td>
+    }else if( zData[0]==0 ){
+      @ <td valign="top">&nbsp;</td>
+    }else{
+      @ <td valign="top">
+      output_report_field(zData,rn);
+      @ </td>
+    }
+  }
+  if( tn>0 && g.okWrite ){
+    @ <td valign="top"><a href="tktedit?tn=%d(tn),%d(rn)">edit</a></td>
+  }
+  @ </tr>
+  return 0;
+}
+
+/*
+** Output the text given in the argument.  Convert tabs and newlines into
+** spaces.
+*/
+static void output_no_tabs(const char *z){
+  while( z && z[0] ){
+    int i, j;
+    for(i=0; z[i] && (!isspace(z[i]) || z[i]==' '); i++){}
+    if( i>0 ){
+      cgi_printf("%.*s", i, z);
+    }
+    for(j=i; isspace(z[j]); j++){}
+    if( j>i ){
+      cgi_printf("%*s", j-i, "");
+    }
+    z += j;
+  }
+}
+
+/*
+** Output a row as a tab-separated line of text.
+*/
+static int output_tab_separated(
+  void *pUser,     /* Pointer to row-count integer */
+  int nArg,        /* Number of columns in this result row */
+  char **azArg,    /* Text of data in all columns */
+  char **azName    /* Names of the columns */
+){
+  int *pCount = (int*)pUser;
+  int i;
+
+  if( *pCount==0 ){
+    for(i=0; i<nArg; i++){
+      output_no_tabs(azName[i]);
+      cgi_printf("%c", i<nArg-1 ? '\t' : '\n');
+    }
+  }
+  ++*pCount;
+  for(i=0; i<nArg; i++){
+    output_no_tabs(azArg[i]);
+    cgi_printf("%c", i<nArg-1 ? '\t' : '\n');
+  }
+  return 0;
+}
+
+/*
+** Generate HTML that describes a color key.
+*/
+void output_color_key(const char *zClrKey, int horiz, char *zTabArgs){
+  int i, j, k;
+  char *zSafeKey, *zToFree;
+  while( isspace(*zClrKey) ) zClrKey++;
+  if( zClrKey[0]==0 ) return;
+  @ <table %s(zTabArgs)>
+  if( horiz ){
+    @ <tr>
+  }
+  zToFree = zSafeKey = mprintf("%h", zClrKey);
+  while( zSafeKey[0] ){
+    while( isspace(*zSafeKey) ) zSafeKey++;
+    for(i=0; zSafeKey[i] && !isspace(zSafeKey[i]); i++){}
+    for(j=i; isspace(zSafeKey[j]); j++){}
+    for(k=j; zSafeKey[k] && zSafeKey[k]!='\n' && zSafeKey[k]!='\r'; k++){}
+    if( !horiz ){
+      cgi_printf("<tr bgcolor=\"%.*s\"><td>%.*s</td></tr>\n",
+        i, zSafeKey, k-j, &zSafeKey[j]);
+    }else{
+      cgi_printf("<td bgcolor=\"%.*s\">%.*s</td>\n",
+        i, zSafeKey, k-j, &zSafeKey[j]);
+    }
+    zSafeKey += k;
+  }
+  free(zToFree);
+  if( horiz ){
+    @ </tr>
+  }
+  @ </table>
+}
+
+
+/*
+** WEBPAGE: /rptview
+**
+** Generate a report.  The rn query parameter is the report number
+** corresponding to REPORTFMT.RN.  If the tablist query parameter exists,
+** then the output consists of lines of tab-separated fields instead of
+** an HTML table.
+*/
+void view_view(void){
+  int count = 0;
+  int rn;
+  char *zSql;
+  char *zTitle;
+  char *zOwner;
+  char *zClrKey;
+  int tabs;
+  Stmt q;
+
+  login_check_credentials();
+  if( !g.okRead ){ login_needed(); return; }
+  rn = atoi(PD("rn","0"));
+  if( rn==0 ){
+    cgi_redirect("reportlist");
+    return;
+  }
+  tabs = P("tablist")!=0;
+  view_add_functions(tabs);
+  db_prepare(&q,
+    "SELECT title, sqlcode, owner, cols FROM reportfmt WHERE rn=%d", rn);
+  if( db_step(&q)!=SQLITE_ROW ){
+    cgi_redirect("reportlist");
+    return;
+  }
+  zTitle = db_column_malloc(&q, 0);
+  zSql = db_column_malloc(&q, 1);
+  zOwner = db_column_malloc(&q, 2);
+  zClrKey = db_column_malloc(&q, 3);
+  db_finalize(&q);
+
+  if( P("order_by") ){
+    /*
+    ** If the user wants to do a column sort, wrap the query into a sub
+    ** query and then sort the results. This is a whole lot easier than
+    ** trying to insert an ORDER BY into the query itself, especially
+    ** if the query is already ordered.
+    */
+    int nField = atoi(P("order_by"));
+    if( nField > 0 ){
+      const char* zDir = PD("order_dir","");
+      zDir = !strcmp("ASC",zDir) ? "ASC" : "DESC";
+      zSql = mprintf("SELECT * FROM (%s) ORDER BY %d %s", zSql, nField, zDir);
+    }
+  }
+
+  count = 0;
+  if( !tabs ){
+    struct GenerateHTML sState;
 
+    db_execute("PRAGMA empty_result_callbacks=ON");
+    style_submenu_element("Raw", "Raw",
+      "rptview?tablist=1&%s", P("QUERY_STRING",""));
+    if( g.okAdmin
+       || (g.okQuery && g.zLogin && zOwner && strcmp(g.zLogin,zOwner)==0) ){
+      style_submentu_element("Edit", "Edit", "rptedit?rn=%d", rn);
+    }
+    style_submenu_element("SQL", "SQL", "rptsql?rn=%d",rn);
+    style_header(zTitle);
+    output_color_key(zClrKey, 1,
+        "border=0 cellpadding=3 cellspacing=0 class=\"report\"");
+    @ <table border=1 cellpadding=2 cellspacing=0 class="report">
+    sState.rn = rn;
+    sState.nCount = 0;
+    sqlite3_exec(g.db, zSql, generate_html, &sState, 0);
+    @ </table>
+    style_footer();
+  }else{
+    sqlite3_exec(g.db, zSql, output_tab_separated, &count, 0);
+    cgi_set_content_type("text/plain");
+  }
+}

Modified src/schema.c from [3e36e323dc] to [384e115ad0].

@@ -122,11 +122,23 @@
 @ -- "shun" table.  Artifacts that are control-file forgeries or
 @ -- spam can be shunned in order to prevent them from contaminating
 @ -- the repository.
 @ --
 @ CREATE TABLE shun(uuid UNIQUE);
+@
+@ -- An entry in this table describes a database query that generates a
+@ -- table of tickets.
+@ --
+@ CREATE TABLE reportfmt(
+@    rn integer primary key,  -- Report number
+@    owner text,              -- Owner of this report format (not used)
+@    title text,              -- Title of this report
+@    cols text,               -- A color-key specification
+@    sqlcode text             -- An SQL SELECT statement for this report
+@ );
 ;
+
 const char zRepositorySchema2[] =
 @ -- Filenames
 @ --
 @ CREATE TABLE filename(
 @   fnid INTEGER PRIMARY KEY,    -- Filename ID