Code

This commit was generated by cvs2svn to compensate for changes in r2,
authorrichard <richard@57a73879-2fb5-44c3-a270-3262357dd7e2>
Thu, 19 Jul 2001 02:16:19 +0000 (02:16 +0000)
committerrichard <richard@57a73879-2fb5-44c3-a270-3262357dd7e2>
Thu, 19 Jul 2001 02:16:19 +0000 (02:16 +0000)
which included commits to RCS files with non-trunk default branches.

git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/trunk@3 57a73879-2fb5-44c3-a270-3262357dd7e2

22 files changed:
CHANGES [new file with mode: 0644]
README [new file with mode: 0644]
cgitb.py [new file with mode: 0644]
config.py [new file with mode: 0644]
date.py [new file with mode: 0644]
hyperdb.py [new file with mode: 0644]
roundup-mailgw.py [new file with mode: 0755]
roundup.cgi [new file with mode: 0755]
roundup.py [new file with mode: 0755]
roundup_cgi.py [new file with mode: 0644]
roundupdb.py [new file with mode: 0644]
server.py [new file with mode: 0755]
style.css [new file with mode: 0644]
template.py [new file with mode: 0644]
templates/file.index [new file with mode: 0644]
templates/issue.filter [new file with mode: 0644]
templates/issue.index [new file with mode: 0644]
templates/issue.item [new file with mode: 0644]
templates/msg.index [new file with mode: 0644]
templates/msg.item [new file with mode: 0644]
templates/user.index [new file with mode: 0644]
templates/user.item [new file with mode: 0644]

diff --git a/CHANGES b/CHANGES
new file mode 100644 (file)
index 0000000..9a35d1c
--- /dev/null
+++ b/CHANGES
@@ -0,0 +1,45 @@
+2001-07-11 - 0.1.0
+ . Needed a bug tracking system. Looked around. Tried to install many
+   Perl-based systems, to no avail. Got tired of waiting for Roundup to be
+   released. Had just finished major product project, so needed something
+   different for a while. Roundup here I come...
+
+
+2001-07-18 - 0.1.1
+ . Initial version release with consent of Roundup spec author, Ka-Ping Yee:
+   "Amazing!  Nice work.  I'll watch for the source code on your website."
+
+
+2001-07-18 - 0.1.2
+ . Set default index to ?:group=priority&:columns=activity,status,title so
+   the priority column isn't displayed.
+ . Thanks Anthony:
+   . added notes to the README about Python prerequisites
+   . added check to roundup.py, roundup.cgi, server.py and roundup-mailgw.py
+     for python 2+ - and made the file itself parseable by 1.5.2 ;)
+   . python 2.0 didn't have the default args for the time module functions.
+   . better handling of db directory in initDB
+ . Sorting on the extra properties defined by roundupdb classes was broken
+   due to the caching used. May now sort on activity and creation
+   properties, etc.
+ . Set the default index to sort on activity
+
+2001-07-XX - 0.1.3
+ . Reldate now takes an argument "pretty" - when true, it pretty-prints the
+   interval generated up to 5 days, then pretty-prints the date of last
+   activity. The issue index and item now use the pretty format.
+ . Classes list for admin user in CGI interface.
+ . Made the view configuration more accessible, neater and more realistic.
+ . Fixed list view grouping handling grouping by a Multilink or String or Link
+   value of None or Date, ...  (mind you, sorting by Date???)
+ . Fixed bug in the plain formatter when a Link was None.
+ . Fixed ordering of list view column headings.
+ . Fixed list view column heading sort links - and limited the number of
+   columns to sort by to 2.
+ . Added searching by glob to StringType filtering -
+    ^text  - search for text at start of fields
+    text$  - search for text at end of fields
+    ^text$ - exactly match text in fields
+    te*xt  - search for text matching "te"<any characters>"xt"
+    te?xt  - search for text matching "te"<any one character>"xt"
+ . Added more fields to the issue.filter and issue.index templates
diff --git a/README b/README
new file mode 100644 (file)
index 0000000..398bcae
--- /dev/null
+++ b/README
@@ -0,0 +1,218 @@
+                                    Roundup
+                                    =======
+
+
+1. License
+==========
+This software is released under the GNU GPL. The copyright is held by Bizar
+Software Pty Ltd (http://www.bizarsoftware.com.au).
+
+The stylesheet included with this package has been copied from the Zope
+management interface and presumably belongs to Digital Creations.
+
+
+
+2. Installation
+===============
+These instructions work on redhat 6.2 and mandrake 8.0 - with the caveat
+that these systems don't come with python 2.0 or newer installed, so you'll
+have to upgrade python before this stuff will work.
+
+Note that most of the following is configurable in the config.py, it's just
+not documented. At a minimum, you'll want to change the email addresses and
+mail host specification in the config.
+
+
+2.0 Prerequisites
+-----------------
+Either:
+ . Python 2.0 with pydoc installed. See http://www.lfw.org/ for pydoc.
+or
+ . Python 2.1
+
+Both need the bsddb module.
+
+
+2.1 Initial Setup
+-----------------
+ 1. Make a directory in /home/httpd/html called 'roundup'.
+ 2. Copy the tar file's contents there.
+ 3. "python roundup.py init" to initialise the database (by default, it
+    goes in a directory called 'db' in the current directory). Choose a
+    sensible admin password.
+ 4. "chmod -R a+rw db"
+
+
+2.2 Mail
+--------
+Set up a mail alias called "issue_tracker" as:
+  "|/usr/bin/python /home/httpd/html/roundup/roundup-mailgw.py"
+
+In some installations (e.g. RedHat 6.2 I think) you'll need to set up smrsh
+so sendmail will accept the pipe command. In that case, symlink
+/etc/smrsh/python to /usr/bin/python and change the command to:
+  "|python /home/httpd/html/roundup/roundup-mailgw.py"
+
+
+2.3 Web Interface
+-----------------
+This software will work through apache or stand-alone.
+
+Stand-alone:
+ 1. Edit server.py at the bottom to set your hostname and a port that is free.
+ 2. "python server.py"
+ 3. Load up the page "/" using the port number you set.
+
+Apache:
+ 1. Make sure roundup.cgi is executable
+ 2. Edit your /etc/httpd/conf/httpd.conf and make sure that the
+    /home/httpd/html/roundup/roundup.cgi script will be treated as a CGI
+    script.
+ 3. Add the following to your /etc/httpd/conf/httpd.conf:
+snip >>>
+RewriteEngine on
+RewriteCond %{HTTP:Authorization} ^(.*)
+RewriteRule ^/roundup/roundup.cgi(.*) /home/httpd/html/roundup/roundup.cgi$1 [e=HTTP_CGI_AUTHORIZATION:%1,t=application/x-httpd-cgi,l]
+<<< snip
+   note: the RewriteRule must be on one line - no breaks
+ 4. Re-start your apache to re-load the config
+ 5. Load up the page "/roundup/roundup.cgi/"
+
+
+3. Usage
+========
+The system is designed to accessed through the command-line, e-mail or web
+interface.
+
+3.1 Command-line
+----------------
+The command-line tool is called "roundup.py" and is used for most low-level
+database manipulations such as:
+ . redefining the list of products ("create" and "retire" commands)
+ . adding users manually, or setting their passwords ("create" and "set")
+ . other stuff - run it with no arguments to get a better description of
+   what it does.
+
+
+3.2 E-mail
+----------
+See the docstring at the start of the roundup-mailgw.py source file.
+
+
+3.3 Web
+-------
+Hopefully, this interface is pretty self-explanatory...
+
+Index views may be modified by the following arguments:
+    :sort    - sort by prop name, optionally preceeded with '-'
+            to give descending or nothing for ascending sorting.
+    :group   - group by prop name, optionally preceeded with '-' or
+            to sort in descending or nothing for ascending order.
+    :filter  - selects which props should be displayed in the filter
+            section. Default is all.
+    :columns - selects the columns that should be displayed.
+            Default is all.
+    propname - selects the values the node properties given by propname
+             must have (very basic search/filter).
+
+
+
+3. Design
+=========
+This software was written according to the specification found at
+ http://software-carpentry.codesourcery.com/entries/second-round/track/Roundup/
+
+... with some modifications. I've marked these in the source with 'XXX'
+comments when I remember to.
+
+In short:
+ Class.find() - may match multiple properties, uses keyword args.
+
+ Class.filter() - isn't in the spec and it's very useful to have at the Class
+    level.
+ CGI interface index view specifier layout part - lose the '+' from the
+    sorting arguments (it's a reserved URL character ;). Just made no
+    prefix mean ascending and '-' prefix descending.
+
+ ItemClass - renamed to IssueClass to better match it only having one
+    hypderdb class "issue". Allowing > 1 hyperdb class breaks the
+    "superseder" multilink (since it can only link to one thing, and we'd
+    want bugs to link to support and vice-versa).
+
+ templates - the call="link()" is handled by special-case mechanisms in my
+    top-level CGI handler. In a nutshell, the handler looks for a method on
+    itself called 'index%s' or 'item%s' where %s is a class. Most items
+    pass on to the templating mechanism, but the file class _always_ does
+    downloading. It'll probably stay this way too...
+
+ template - call="link(property)" may be used to link "the current node"
+    (from an index) - the link text is the property specified.
+
+ template - added functions that I found very useful: List, History and
+    Submit.
+
+ template - items must specify the message lists, history, etc. Having them
+    by default was sometimes not wanted.
+
+ template - index view determines its default columns from the template's
+    <property> tags.
+
+ template - menu() and field() look awfully similar now .... ;)
+
+ roundup.py - the command-line tool has a lot more commands at its disposal
+
+
+4. TODO
+=======
+Most of the TODO items are captured in comments in the code. In summary:
+
+in general:
+  . better error handling (nicer messages for users)
+  . possibly revert the entire damn thing to 1.5.2 ... :(
+hyperdb:
+  . transaction support
+roundupdb:
+  . split the file storage into multiple files
+roundup-mailgw:
+  . errors as attachments
+  . snip signatures?
+server:
+  . check the source file timestamps before reloading
+date:
+  . blue Date.__sub__ needs food, badly
+config
+  . default to blank config in distribution and warn appropriately
+roundup_cgi
+  . searching
+  . keep form fields in form on bad submission - only clear it if all ok
+
+
+
+5. Known Bugs
+=============
+
+http://dirk.adroit/roundup/roundup.cgi/issue?%3Acolumns%3Dactivity%2Cstatus%2Ctitle&%3Asort%3Dtitle%2C-activity&%3Agroup%3Dpriority
+
+date:
+  . date subtraction doesn't work correctly "if the dates cross leap years,
+    phases of the moon, ..."
+
+The software still probably has bugs. Please let me know when you find 'em.
+Patches are nice, but there'll probably be a good chance I've changed the
+code (there's not much to it ;) so a good description will be appreciated
+as well.
+
+
+
+6. Author
+=========
+richard@bizarsoftware.com.au
+
+
+7. Thanks
+=========
+Well, Ping, of course ;)
+
+Anthony Baxter, for some good first-release feedback.
+
diff --git a/cgitb.py b/cgitb.py
new file mode 100644 (file)
index 0000000..5af5ef6
--- /dev/null
+++ b/cgitb.py
@@ -0,0 +1,113 @@
+import sys, os, types, string, keyword, linecache, tokenize, inspect, pydoc
+
+def breaker():
+    return ('<body bgcolor="#f0f0ff">' +
+            '<font color="#f0f0ff" size="-5"> > </font> ' +
+            '</table>' * 5)
+
+def html(context=5):
+    etype, evalue = sys.exc_type, sys.exc_value
+    if type(etype) is types.ClassType:
+        etype = etype.__name__
+    pyver = 'Python ' + string.split(sys.version)[0] + '<br>' + sys.executable
+    head = pydoc.html.heading(
+        '<font size=+1><strong>%s</strong>: %s</font>'%(str(etype), str(evalue)),
+        '#ffffff', '#aa55cc', pyver)
+
+    head = head + ('<p>A problem occurred while running a Python script. '
+                   'Here is the sequence of function calls leading up to '
+                   'the error, with the most recent (innermost) call first.'
+                   'The exception attributes are:')
+
+    indent = '<tt><small>%s</small>&nbsp;</tt>' % ('&nbsp;' * 5)
+    traceback = []
+    for frame, file, lnum, func, lines, index in inspect.trace(context):
+        if file is None:
+            link = '&lt;file is None - probably inside <tt>eval</tt> or <tt>exec</tt>&gt;'
+        else:
+            file = os.path.abspath(file)
+            link = '<a href="file:%s">%s</a>' % (file, pydoc.html.escape(file))
+        args, varargs, varkw, locals = inspect.getargvalues(frame)
+        if func == '?':
+            call = ''
+        else:
+            call = 'in <strong>%s</strong>' % func + inspect.formatargvalues(
+                    args, varargs, varkw, locals,
+                    formatvalue=lambda value: '=' + pydoc.html.repr(value))
+
+        level = '''
+<table width="100%%" bgcolor="#d8bbff" cellspacing=0 cellpadding=2 border=0>
+<tr><td>%s %s</td></tr></table>''' % (link, call)
+
+        if file is None:
+            traceback.append('<p>' + level)
+            continue
+
+        # do a fil inspection
+        names = []
+        def tokeneater(type, token, start, end, line, names=names):
+            if type == tokenize.NAME and token not in keyword.kwlist:
+                if token not in names:
+                    names.append(token)
+            if type == tokenize.NEWLINE: raise IndexError
+        def linereader(file=file, lnum=[lnum]):
+            line = linecache.getline(file, lnum[0])
+            lnum[0] = lnum[0] + 1
+            return line
+
+        try:
+            tokenize.tokenize(linereader, tokeneater)
+        except IndexError: pass
+        lvals = []
+        for name in names:
+            if name in frame.f_code.co_varnames:
+                if locals.has_key(name):
+                    value = pydoc.html.repr(locals[name])
+                else:
+                    value = '<em>undefined</em>'
+                name = '<strong>%s</strong>' % name
+            else:
+                if frame.f_globals.has_key(name):
+                    value = pydoc.html.repr(frame.f_globals[name])
+                else:
+                    value = '<em>undefined</em>'
+                name = '<em>global</em> <strong>%s</strong>' % name
+            lvals.append('%s&nbsp;= %s' % (name, value))
+        if lvals:
+            lvals = string.join(lvals, ', ')
+            lvals = indent + '''
+<small><font color="#909090">%s</font></small><br>''' % lvals
+        else:
+            lvals = ''
+
+        excerpt = []
+        i = lnum - index
+        for line in lines:
+            number = '&nbsp;' * (5-len(str(i))) + str(i)
+            number = '<small><font color="#909090">%s</font></small>' % number
+            line = '<tt>%s&nbsp;%s</tt>' % (number, pydoc.html.preformat(line))
+            if i == lnum:
+                line = '''
+<table width="100%%" bgcolor="#ffccee" cellspacing=0 cellpadding=0 border=0>
+<tr><td>%s</td></tr></table>''' % line
+            excerpt.append('\n' + line)
+            if i == lnum:
+                excerpt.append(lvals)
+            i = i + 1
+        traceback.append('<p>' + level + string.join(excerpt, '\n'))
+
+    traceback.reverse()
+
+    exception = '<p><strong>%s</strong>: %s' % (str(etype), str(evalue))
+    attribs = []
+    if type(evalue) is types.InstanceType:
+        for name in dir(evalue):
+            value = pydoc.html.repr(getattr(evalue, name))
+            attribs.append('<br>%s%s&nbsp;= %s' % (indent, name, value))
+
+    return head + string.join(attribs) + string.join(traceback) + '<p>&nbsp;</p>'
+
+def handler():
+    print breaker()
+    print html()
+
diff --git a/config.py b/config.py
new file mode 100644 (file)
index 0000000..1cc5365
--- /dev/null
+++ b/config.py
@@ -0,0 +1,14 @@
+# This is the directory that the database is going to be stored in
+DATABASE = '/home/httpd/html/roundup/db'
+
+# The email address that mail to roundup should go to
+ISSUE_TRACKER_EMAIL = 'issue_tracker@bizarsoftware.com.au'
+
+# The email address that roundup will complain to if it runs into trouble
+ADMIN_EMAIL = "roundup-admin@bizarsoftware.com.au"
+
+# The SMTP mail host that roundup will use to send mail
+MAILHOST = 'goanna.adroit.net'
+
+# Somewhere for roundup to log stuff internally sent to stdout or stderr
+LOG = '/home/httpd/html/roundup/roundup.log'
diff --git a/date.py b/date.py
new file mode 100644 (file)
index 0000000..47b94db
--- /dev/null
+++ b/date.py
@@ -0,0 +1,342 @@
+import time, re, calendar
+
+class Date:
+    '''
+    As strings, date-and-time stamps are specified with the date in
+    international standard format (yyyy-mm-dd) joined to the time
+    (hh:mm:ss) by a period ("."). Dates in this form can be easily compared
+    and are fairly readable when printed. An example of a valid stamp is
+    "2000-06-24.13:03:59". We'll call this the "full date format". When
+    Timestamp objects are printed as strings, they appear in the full date
+    format with the time always given in GMT. The full date format is
+    always exactly 19 characters long. 
+
+    For user input, some partial forms are also permitted: the whole time
+    or just the seconds may be omitted; and the whole date may be omitted
+    or just the year may be omitted. If the time is given, the time is
+    interpreted in the user's local time zone. The Date constructor takes
+    care of these conversions. In the following examples, suppose that yyyy
+    is the current year, mm is the current month, and dd is the current day
+    of the month; and suppose that the user is on Eastern Standard Time.
+
+      "2000-04-17" means <Date 2000-04-17.00:00:00>
+      "01-25" means <Date yyyy-01-25.00:00:00>
+      "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
+      "08-13.22:13" means <Date yyyy-08-14.03:13:00>
+      "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
+      "14:25" means <Date yyyy-mm-dd.19:25:00>
+      "8:47:11" means <Date yyyy-mm-dd.13:47:11>
+      "." means "right now"
+
+    The Date class should understand simple date expressions of the form
+    stamp + interval and stamp - interval. When adding or subtracting
+    intervals involving months or years, the components are handled
+    separately. For example, when evaluating "2000-06-25 + 1m 10d", we
+    first add one month to get 2000-07-25, then add 10 days to get
+    2000-08-04 (rather than trying to decide whether 1m 10d means 38 or 40
+    or 41 days).
+
+    Example usage:
+        >>> Date(".")
+        <Date 2000-06-26.00:34:02>
+        >>> _.local(-5)
+        "2000-06-25.19:34:02"
+        >>> Date(". + 2d")
+        <Date 2000-06-28.00:34:02>
+        >>> Date("1997-04-17", -5)
+        <Date 1997-04-17.00:00:00>
+        >>> Date("01-25", -5)
+        <Date 2000-01-25.00:00:00>
+        >>> Date("08-13.22:13", -5)
+        <Date 2000-08-14.03:13:00>
+        >>> Date("14:25", -5)
+        <Date 2000-06-25.19:25:00>
+    '''
+    isDate = 1
+
+    def __init__(self, spec='.', offset=0, set=None):
+        """Construct a date given a specification and a time zone offset.
+
+          'spec' is a full date or a partial form, with an optional
+                 added or subtracted interval.
+        'offset' is the local time zone offset from GMT in hours.
+        """
+        if set is None:
+            self.set(spec, offset=offset)
+        else:
+            self.year, self.month, self.day, self.hour, self.minute, \
+                self.second, x, x, x = set
+        self.offset = offset
+
+    def applyInterval(self, interval):
+        ''' Apply the interval to this date
+        '''
+        t = (self.year + interval.year,
+             self.month + interval.month,
+             self.day + interval.day,
+             self.hour + interval.hour,
+             self.minute + interval.minute,
+             self.second + interval.second, 0, 0, 0)
+        self.year, self.month, self.day, self.hour, self.minute, \
+            self.second, x, x, x = time.gmtime(calendar.timegm(t))
+
+    def __add__(self, other):
+        """Add an interval to this date to produce another date."""
+        t = (self.year + other.sign * other.year,
+            self.month + other.sign * other.month,
+            self.day + other.sign * other.day,
+            self.hour + other.sign * other.hour,
+            self.minute + other.sign * other.minute,
+            self.second + other.sign * other.second, 0, 0, 0)
+        return Date(set = time.gmtime(calendar.timegm(t)))
+
+    # XXX deviates from spec to allow subtraction of dates as well
+    def __sub__(self, other):
+        """ Subtract:
+             1. an interval from this date to produce another date.
+             2. a date from this date to produce an interval.
+        """
+        if other.isDate:
+            # TODO this code will fall over laughing if the dates cross
+            # leap years, phases of the moon, ....
+            a = calendar.timegm((self.year, self.month, self.day, self.hour,
+                self.minute, self.second, 0, 0, 0))
+            b = calendar.timegm((other.year, other.month, other.day, other.hour,
+                other.minute, other.second, 0, 0, 0))
+            diff = a - b
+            if diff < 0:
+                sign = -1
+                diff = -diff
+            else:
+                sign = 1
+            S = diff%60
+            M = (diff/60)%60
+            H = (diff/(60*60))%60
+            if H>1: S = 0
+            d = (diff/(24*60*60))%30
+            if d>1: H = S = M = 0
+            m = (diff/(30*24*60*60))%12
+            if m>1: H = S = M = 0
+            y = (diff/(365*24*60*60))
+            if y>1: d = H = S = M = 0
+            return Interval((y, m, d, H, M, S), sign=sign)
+        t = (self.year - other.sign * other.year,
+             self.month - other.sign * other.month,
+             self.day - other.sign * other.day,
+             self.hour - other.sign * other.hour,
+             self.minute - other.sign * other.minute,
+             self.second - other.sign * other.second, 0, 0, 0)
+        return Date(set = time.gmtime(calendar.timegm(t)))
+
+    def __cmp__(self, other):
+        """Compare this date to another date."""
+        for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'):
+            r = cmp(getattr(self, attr), getattr(other, attr))
+            if r: return r
+        return 0
+
+    def __str__(self):
+        """Return this date as a string in the yyyy-mm-dd.hh:mm:ss format."""
+        return time.strftime('%Y-%m-%d.%T', (self.year, self.month,
+            self.day, self.hour, self.minute, self.second, 0, 0, 0))
+
+    def pretty(self):
+        ''' print up the date date using a pretty format...
+        '''
+        return time.strftime('%e %B %Y', (self.year, self.month,
+            self.day, self.hour, self.minute, self.second, 0, 0, 0))
+
+    def set(self, spec, offset=0, date_re=re.compile(r'''
+              (((?P<y>\d\d\d\d)-)?((?P<m>\d\d)-(?P<d>\d\d))?)? # yyyy-mm-dd
+              (?P<n>\.)?                                       # .
+              (((?P<H>\d?\d):(?P<M>\d\d))?(:(?P<S>\d\d))?)?    # hh:mm:ss
+              (?P<o>.+)?                                       # offset
+              ''', re.VERBOSE)):
+        ''' set the date to the value in spec
+        '''
+        m = date_re.match(spec)
+        if not m:
+            raise ValueError, 'Not a date spec: [[yyyy-]mm-dd].[[h]h:mm[:ss]] [offset]'
+        info = m.groupdict()
+
+        # get the current date/time using the offset
+        y,m,d,H,M,S,x,x,x = time.gmtime(time.time())
+        ts = calendar.timegm((y,m,d,H+offset,M,S,0,0,0))
+        self.year, self.month, self.day, self.hour, self.minute, \
+            self.second, x, x, x = time.gmtime(ts)
+
+        if info['m'] is not None and info['d'] is not None:
+            self.month = int(info['m'])
+            self.day = int(info['d'])
+            if info['y'] is not None:
+                self.year = int(info['y'])
+            self.hour = self.minute = self.second = 0
+
+        if info['H'] is not None and info['M'] is not None:
+            self.hour = int(info['H'])
+            self.minute = int(info['M'])
+            if info['S'] is not None:
+                self.second = int(info['S'])
+
+        if info['o']:
+            self.applyInterval(Interval(info['o']))
+
+    def __repr__(self):
+        return '<Date %s>'%self.__str__()
+
+    def local(self, offset):
+        """Return this date as yyyy-mm-dd.hh:mm:ss in a local time zone."""
+        t = (self.year, self.month, self.day, self.hour + offset, self.minute,
+             self.second, 0, 0, 0)
+        self.year, self.month, self.day, self.hour, self.minute, \
+            self.second, x, x, x = time.gmtime(calendar.timegm(t))
+
+
+class Interval:
+    '''
+    Date intervals are specified using the suffixes "y", "m", and "d". The
+    suffix "w" (for "week") means 7 days. Time intervals are specified in
+    hh:mm:ss format (the seconds may be omitted, but the hours and minutes
+    may not).
+
+      "3y" means three years
+      "2y 1m" means two years and one month
+      "1m 25d" means one month and 25 days
+      "2w 3d" means two weeks and three days
+      "1d 2:50" means one day, two hours, and 50 minutes
+      "14:00" means 14 hours
+      "0:04:33" means four minutes and 33 seconds
+
+    Example usage:
+        >>> Interval("  3w  1  d  2:00")
+        <Interval 22d 2:00>
+        >>> Date(". + 2d") - Interval("3w")
+        <Date 2000-06-07.00:34:02>
+    '''
+    isInterval = 1
+
+    def __init__(self, spec, sign=1):
+        """Construct an interval given a specification."""
+        if type(spec) == type(''):
+            self.set(spec)
+        else:
+            self.sign = sign
+            self.year, self.month, self.day, self.hour, self.minute, \
+                self.second = spec
+
+    def __cmp__(self, other):
+        """Compare this interval to another interval."""
+        for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'):
+            r = cmp(getattr(self, attr), getattr(other, attr))
+            if r: return r
+        return 0
+        
+    def __str__(self):
+        """Return this interval as a string."""
+        sign = {1:'+', -1:'-'}[self.sign]
+        l = [sign]
+        if self.year: l.append('%sy'%self.year)
+        if self.month: l.append('%sm'%self.month)
+        if self.day: l.append('%sd'%self.day)
+        if self.second:
+            l.append('%d:%02d:%02d'%(self.hour, self.minute, self.second))
+        elif self.hour or self.minute:
+            l.append('%d:%02d'%(self.hour, self.minute))
+        return ' '.join(l)
+
+    def set(self, spec, interval_re = re.compile('''
+            \s*
+            (?P<s>[-+])?         # + or -
+            \s*
+            ((?P<y>\d+\s*)y)?    # year
+            \s*
+            ((?P<m>\d+\s*)m)?    # month
+            \s*
+            ((?P<w>\d+\s*)w)?    # week
+            \s*
+            ((?P<d>\d+\s*)d)?    # day
+            \s*
+            (((?P<H>\d?\d):(?P<M>\d\d))?(:(?P<S>\d\d))?)?   # time
+            \s*
+            ''', re.VERBOSE)):
+        ''' set the date to the value in spec
+        '''
+        self.year = self.month = self.week = self.day = self.hour = \
+            self.minute = self.second = 0
+        self.sign = 1
+        m = interval_re.match(spec)
+        if not m:
+            raise ValueError, 'Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]'
+
+        info = m.groupdict()
+        for group, attr in {'y':'year', 'm':'month', 'w':'week', 'd':'day',
+                'H':'hour', 'M':'minute', 'S':'second'}.items():
+            if info[group] is not None:
+                setattr(self, attr, int(info[group]))
+
+        if self.week:
+            self.day = self.day + self.week*7
+
+        if info['s'] is not None:
+            self.sign = {'+':1, '-':-1}[info['s']]
+
+    def __repr__(self):
+        return '<Interval %s>'%self.__str__()
+
+    def pretty(self, threshold=('d', 5)):
+        ''' print up the date date using one of these nice formats..
+            < 1 minute
+            < 15 minutes
+            < 30 minutes
+            < 1 hour
+            < 12 hours
+            < 1 day
+            otherwise, return None (so a full date may be displayed)
+        '''
+        if self.year or self.month or self.day > 5:
+            return None
+        if self.day > 1:
+            return '%s days'%self.day
+        if self.day == 1 or self.hour > 12:
+            return 'yesterday'
+        if self.hour > 1:
+            return '%s hours'%self.hour
+        if self.hour == 1:
+            if self.minute < 15:
+                return 'an hour'
+            quart = self.minute/15
+            if quart == 2:
+                return '1 1/2 hours'
+            return '1 %s/4 hours'%quart
+        if self.minute < 1:
+            return 'just now'
+        if self.minute == 1:
+            return '1 minute'
+        if self.minute < 15:
+            return '%s minutes'%self.minute
+        quart = self.minute/15
+        if quart == 2:
+            return '1/2 an hour'
+        return '%s/4 hour'%quart
+
+
+def test():
+    intervals = ("  3w  1  d  2:00", " + 2d", "3w")
+    for interval in intervals:
+        print '>>> Interval("%s")'%interval
+        print `Interval(interval)`
+
+    dates = (".", "2000-06-25.19:34:02", ". + 2d", "1997-04-17", "01-25",
+        "08-13.22:13", "14:25")
+    for date in dates:
+        print '>>> Date("%s")'%date
+        print `Date(date)`
+
+    sums = ((". + 2d", "3w"), (".", "  3w  1  d  2:00"))
+    for date, interval in sums:
+        print '>>> Date("%s") + Interval("%s")'%(date, interval)
+        print `Date(date) + Interval(interval)`
+
+if __name__ == '__main__':
+    test()
+
diff --git a/hyperdb.py b/hyperdb.py
new file mode 100644 (file)
index 0000000..dcd8522
--- /dev/null
@@ -0,0 +1,918 @@
+import bsddb, os, cPickle, re, string
+
+import date
+#
+# Types
+#
+class BaseType:
+    isStringType = 0
+    isDateType = 0
+    isIntervalType = 0
+    isLinkType = 0
+    isMultilinkType = 0
+
+class String(BaseType):
+    def __init__(self):
+        """An object designating a String property."""
+        pass
+    def __repr__(self):
+        return '<%s>'%self.__class__
+    isStringType = 1
+
+class Date(BaseType, String):
+    isDateType = 1
+
+class Interval(BaseType, String):
+    isIntervalType = 1
+
+class Link(BaseType):
+    def __init__(self, classname):
+        """An object designating a Link property that links to
+        nodes in a specified class."""
+        self.classname = classname
+    def __repr__(self):
+        return '<%s to "%s">'%(self.__class__, self.classname)
+    isLinkType = 1
+
+class Multilink(BaseType, Link):
+    """An object designating a Multilink property that links
+       to nodes in a specified class.
+    """
+    isMultilinkType = 1
+
+class DatabaseError(ValueError):
+    pass
+
+#
+# Now the database
+#
+RETIRED_FLAG = '__hyperdb_retired'
+class Database:
+    """A database for storing records containing flexible data types."""
+
+    def __init__(self, storagelocator, journaltag=None):
+        """Open a hyperdatabase given a specifier to some storage.
+
+        The meaning of 'storagelocator' depends on the particular
+        implementation of the hyperdatabase.  It could be a file name,
+        a directory path, a socket descriptor for a connection to a
+        database over the network, etc.
+
+        The 'journaltag' is a token that will be attached to the journal
+        entries for any edits done on the database.  If 'journaltag' is
+        None, the database is opened in read-only mode: the Class.create(),
+        Class.set(), and Class.retire() methods are disabled.
+        """
+        self.dir, self.journaltag = storagelocator, journaltag
+        self.classes = {}
+
+    #
+    # Classes
+    #
+    def __getattr__(self, classname):
+        """A convenient way of calling self.getclass(classname)."""
+        return self.classes[classname]
+
+    def addclass(self, cl):
+        cn = cl.classname
+        if self.classes.has_key(cn):
+            raise ValueError, cn
+        self.classes[cn] = cl
+
+    def getclasses(self):
+        """Return a list of the names of all existing classes."""
+        l = self.classes.keys()
+        l.sort()
+        return l
+
+    def getclass(self, classname):
+        """Get the Class object representing a particular class.
+
+        If 'classname' is not a valid class name, a KeyError is raised.
+        """
+        return self.classes[classname]
+
+    #
+    # Class DBs
+    #
+    def clear(self):
+        for cn in self.classes.keys():
+            db = os.path.join(self.dir, 'nodes.%s'%cn)
+            bsddb.btopen(db, 'n')
+            db = os.path.join(self.dir, 'journals.%s'%cn)
+            bsddb.btopen(db, 'n')
+
+    def getclassdb(self, classname, mode='r'):
+        ''' grab a connection to the class db that will be used for
+            multiple actions
+        '''
+        path = os.path.join(os.getcwd(), self.dir, 'nodes.%s'%classname)
+        return bsddb.btopen(path, mode)
+
+    def addnode(self, classname, nodeid, node):
+        ''' add the specified node to its class's db
+        '''
+        db = self.getclassdb(classname, 'c')
+        db[nodeid] = cPickle.dumps(node, 1)
+        db.close()
+    setnode = addnode
+
+    def getnode(self, classname, nodeid, cldb=None):
+        ''' add the specified node to its class's db
+        '''
+        db = cldb or self.getclassdb(classname)
+        if not db.has_key(nodeid):
+            raise IndexError, nodeid
+        res = cPickle.loads(db[nodeid])
+        if not cldb: db.close()
+        return res
+
+    def hasnode(self, classname, nodeid, cldb=None):
+        ''' add the specified node to its class's db
+        '''
+        db = cldb or self.getclassdb(classname)
+        res = db.has_key(nodeid)
+        if not cldb: db.close()
+        return res
+
+    def countnodes(self, classname, cldb=None):
+        db = cldb or self.getclassdb(classname)
+        return len(db.keys())
+        if not cldb: db.close()
+        return res
+
+    def getnodeids(self, classname, cldb=None):
+        db = cldb or self.getclassdb(classname)
+        res = db.keys()
+        if not cldb: db.close()
+        return res
+
+    #
+    # Journal
+    #
+    def addjournal(self, classname, nodeid, action, params):
+        ''' Journal the Action
+        'action' may be:
+
+            'create' or 'set' -- 'params' is a dictionary of property values
+            'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
+            'retire' -- 'params' is None
+        '''
+        entry = (nodeid, date.Date(), self.journaltag, action, params)
+        db = bsddb.btopen(os.path.join(self.dir, 'journals.%s'%classname), 'c')
+        if db.has_key(nodeid):
+            s = db[nodeid]
+            l = cPickle.loads(db[nodeid])
+            l.append(entry)
+        else:
+            l = [entry]
+        db[nodeid] = cPickle.dumps(l)
+        db.close()
+
+    def getjournal(self, classname, nodeid):
+        ''' get the journal for id
+        '''
+        db = bsddb.btopen(os.path.join(self.dir, 'journals.%s'%classname), 'r')
+        res = cPickle.loads(db[nodeid])
+        db.close()
+        return res
+
+    def close(self):
+        ''' Close the Database - we must release the circular refs so that
+            we can be del'ed and the underlying bsddb connections closed
+            cleanly.
+        '''
+        self.classes = None
+
+
+    #
+    # Basic transaction support
+    #
+    # TODO: well, write these methods (and then use them in other code)
+    def register_action(self):
+        ''' Register an action to the transaction undo log
+        '''
+
+    def commit(self):
+        ''' Commit the current transaction, start a new one
+        '''
+
+    def rollback(self):
+        ''' Reverse all actions from the current transaction
+        '''
+
+
+class Class:
+    """The handle to a particular class of nodes in a hyperdatabase."""
+
+    def __init__(self, db, classname, **properties):
+        """Create a new class with a given name and property specification.
+
+        'classname' must not collide with the name of an existing class,
+        or a ValueError is raised.  The keyword arguments in 'properties'
+        must map names to property objects, or a TypeError is raised.
+        """
+        self.classname = classname
+        self.properties = properties
+        self.db = db
+        self.key = ''
+
+        # do the db-related init stuff
+        db.addclass(self)
+
+    # Editing nodes:
+
+    def create(self, **propvalues):
+        """Create a new node of this class and return its id.
+
+        The keyword arguments in 'propvalues' map property names to values.
+
+        The values of arguments must be acceptable for the types of their
+        corresponding properties or a TypeError is raised.
+        
+        If this class has a key property, it must be present and its value
+        must not collide with other key strings or a ValueError is raised.
+        
+        Any other properties on this class that are missing from the
+        'propvalues' dictionary are set to None.
+        
+        If an id in a link or multilink property does not refer to a valid
+        node, an IndexError is raised.
+        """
+        if self.db.journaltag is None:
+            raise DatabaseError, 'Database open read-only'
+        newid = str(self.count() + 1)
+
+        # validate propvalues
+        num_re = re.compile('^\d+$')
+        for key, value in propvalues.items():
+            if key == self.key:
+                try:
+                    self.lookup(value)
+                except KeyError:
+                    pass
+                else:
+                    raise ValueError, 'node with key "%s" exists'%value
+
+            prop = self.properties[key]
+
+            if prop.isLinkType:
+                value = str(value)
+                link_class = self.properties[key].classname
+                if not num_re.match(value):
+                    try:
+                        value = self.db.classes[link_class].lookup(value)
+                    except:
+                        raise ValueError, 'new property "%s": %s not a %s'%(
+                            key, value, self.properties[key].classname)
+                propvalues[key] = value
+                if not self.db.hasnode(link_class, value):
+                    raise ValueError, '%s has no node %s'%(link_class, value)
+
+                # register the link with the newly linked node
+                self.db.addjournal(link_class, value, 'link',
+                    (self.classname, newid, key))
+
+            elif prop.isMultilinkType:
+                if type(value) != type([]):
+                    raise TypeError, 'new property "%s" not a list of ids'%key
+                link_class = self.properties[key].classname
+                l = []
+                for entry in map(str, value):
+                    if not num_re.match(entry):
+                        try:
+                            entry = self.db.classes[link_class].lookup(entry)
+                        except:
+                            raise ValueError, 'new property "%s": %s not a %s'%(
+                                key, entry, self.properties[key].classname)
+                    l.append(entry)
+                value = l
+                propvalues[key] = value
+
+                # handle additions
+                for id in value:
+                    if not self.db.hasnode(link_class, id):
+                        raise ValueError, '%s has no node %s'%(link_class, id)
+                    # register the link with the newly linked node
+                    self.db.addjournal(link_class, id, 'link',
+                        (self.classname, newid, key))
+
+            elif prop.isStringType:
+                if type(value) != type(''):
+                    raise TypeError, 'new property "%s" not a string'%key
+
+            elif prop.isDateType:
+                if not hasattr(value, 'isDate'):
+                    raise TypeError, 'new property "%s" not a Date'% key
+
+            elif prop.isIntervalType:
+                if not hasattr(value, 'isInterval'):
+                    raise TypeError, 'new property "%s" not an Interval'% key
+
+        for key,prop in self.properties.items():
+            if propvalues.has_key(str(key)):
+                continue
+            if prop.isMultilinkType:
+                propvalues[key] = []
+            else:
+                propvalues[key] = None
+
+        # done
+        self.db.addnode(self.classname, newid, propvalues)
+        self.db.addjournal(self.classname, newid, 'create', propvalues)
+        return newid
+
+    def get(self, nodeid, propname):
+        """Get the value of a property on an existing node of this class.
+
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.  'propname' must be the name of a property
+        of this class or a KeyError is raised.
+        """
+        d = self.db.getnode(self.classname, str(nodeid))
+        return d[propname]
+
+    # XXX not in spec
+    def getnode(self, nodeid):
+        ''' Return a convenience wrapper for the node
+        '''
+        return Node(self, nodeid)
+
+    def set(self, nodeid, **propvalues):
+        """Modify a property on an existing node of this class.
+        
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.
+
+        Each key in 'propvalues' must be the name of a property of this
+        class or a KeyError is raised.
+
+        All values in 'propvalues' must be acceptable types for their
+        corresponding properties or a TypeError is raised.
+
+        If the value of the key property is set, it must not collide with
+        other key strings or a ValueError is raised.
+
+        If the value of a Link or Multilink property contains an invalid
+        node id, a ValueError is raised.
+        """
+        if not propvalues:
+            return
+        if self.db.journaltag is None:
+            raise DatabaseError, 'Database open read-only'
+        nodeid = str(nodeid)
+        node = self.db.getnode(self.classname, nodeid)
+        if node.has_key(RETIRED_FLAG):
+            raise IndexError
+        num_re = re.compile('^\d+$')
+        for key, value in propvalues.items():
+            if not node.has_key(key):
+                raise KeyError, key
+
+            if key == self.key:
+                try:
+                    self.lookup(value)
+                except KeyError:
+                    pass
+                else:
+                    raise ValueError, 'node with key "%s" exists'%value
+
+            prop = self.properties[key]
+
+            if prop.isLinkType:
+                value = str(value)
+                link_class = self.properties[key].classname
+                if not num_re.match(value):
+                    try:
+                        value = self.db.classes[link_class].lookup(value)
+                    except:
+                        raise ValueError, 'new property "%s": %s not a %s'%(
+                            key, value, self.properties[key].classname)
+
+                if not self.db.hasnode(link_class, value):
+                    raise ValueError, '%s has no node %s'%(link_class, value)
+
+                # register the unlink with the old linked node
+                if node[key] is not None:
+                    self.db.addjournal(link_class, node[key], 'unlink',
+                        (self.classname, nodeid, key))
+
+                # register the link with the newly linked node
+                if value is not None:
+                    self.db.addjournal(link_class, value, 'link',
+                        (self.classname, nodeid, key))
+
+            elif prop.isMultilinkType:
+                if type(value) != type([]):
+                    raise TypeError, 'new property "%s" not a list of ids'%key
+                link_class = self.properties[key].classname
+                l = []
+                for entry in map(str, value):
+                    if not num_re.match(entry):
+                        try:
+                            entry = self.db.classes[link_class].lookup(entry)
+                        except:
+                            raise ValueError, 'new property "%s": %s not a %s'%(
+                                key, entry, self.properties[key].classname)
+                    l.append(entry)
+                value = l
+                propvalues[key] = value
+
+                #handle removals
+                l = node[key]
+                for id in l[:]:
+                    if id in value:
+                        continue
+                    # register the unlink with the old linked node
+                    self.db.addjournal(link_class, id, 'unlink',
+                        (self.classname, nodeid, key))
+                    l.remove(id)
+
+                # handle additions
+                for id in value:
+                    if not self.db.hasnode(link_class, id):
+                        raise ValueError, '%s has no node %s'%(link_class, id)
+                    if id in l:
+                        continue
+                    # register the link with the newly linked node
+                    self.db.addjournal(link_class, id, 'link',
+                        (self.classname, nodeid, key))
+                    l.append(id)
+
+            elif prop.isStringType:
+                if value is not None and type(value) != type(''):
+                    raise TypeError, 'new property "%s" not a string'%key
+
+            elif prop.isDateType:
+                if not hasattr(value, 'isDate'):
+                    raise TypeError, 'new property "%s" not a Date'% key
+
+            elif prop.isIntervalType:
+                if not hasattr(value, 'isInterval'):
+                    raise TypeError, 'new property "%s" not an Interval'% key
+
+            node[key] = value
+
+        self.db.setnode(self.classname, nodeid, node)
+        self.db.addjournal(self.classname, nodeid, 'set', propvalues)
+
+    def retire(self, nodeid):
+        """Retire a node.
+        
+        The properties on the node remain available from the get() method,
+        and the node's id is never reused.
+        
+        Retired nodes are not returned by the find(), list(), or lookup()
+        methods, and other nodes may reuse the values of their key properties.
+        """
+        nodeid = str(nodeid)
+        if self.db.journaltag is None:
+            raise DatabaseError, 'Database open read-only'
+        node = self.db.getnode(self.classname, nodeid)
+        node[RETIRED_FLAG] = 1
+        self.db.setnode(self.classname, nodeid, node)
+        self.db.addjournal(self.classname, nodeid, 'retired', None)
+
+    def history(self, nodeid):
+        """Retrieve the journal of edits on a particular node.
+
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.
+
+        The returned list contains tuples of the form
+
+            (date, tag, action, params)
+
+        'date' is a Timestamp object specifying the time of the change and
+        'tag' is the journaltag specified when the database was opened.
+        """
+        return self.db.getjournal(self.classname, nodeid)
+
+    # Locating nodes:
+
+    def setkey(self, propname):
+        """Select a String property of this class to be the key property.
+
+        'propname' must be the name of a String property of this class or
+        None, or a TypeError is raised.  The values of the key property on
+        all existing nodes must be unique or a ValueError is raised.
+        """
+        self.key = propname
+
+    def getkey(self):
+        """Return the name of the key property for this class or None."""
+        return self.key
+
+    # TODO: set up a separate index db file for this? profile?
+    def lookup(self, keyvalue):
+        """Locate a particular node by its key property and return its id.
+
+        If this class has no key property, a TypeError is raised.  If the
+        'keyvalue' matches one of the values for the key property among
+        the nodes in this class, the matching node's id is returned;
+        otherwise a KeyError is raised.
+        """
+        cldb = self.db.getclassdb(self.classname)
+        for nodeid in self.db.getnodeids(self.classname, cldb):
+            node = self.db.getnode(self.classname, nodeid, cldb)
+            if node.has_key(RETIRED_FLAG):
+                continue
+            if node[self.key] == keyvalue:
+                return nodeid
+        cldb.close()
+        raise KeyError, keyvalue
+
+    # XXX: change from spec - allows multiple props to match
+    def find(self, **propspec):
+        """Get the ids of nodes in this class which link to a given node.
+
+        'propspec' consists of keyword args propname=nodeid   
+          'propname' must be the name of a property in this class, or a
+            KeyError is raised.  That property must be a Link or Multilink
+            property, or a TypeError is raised.
+
+          'nodeid' must be the id of an existing node in the class linked
+            to by the given property, or an IndexError is raised.
+        """
+        propspec = propspec.items()
+        for propname, nodeid in propspec:
+            nodeid = str(nodeid)
+            # check the prop is OK
+            prop = self.properties[propname]
+            if not prop.isLinkType and not prop.isMultilinkType:
+                raise TypeError, "'%s' not a Link/Multilink property"%propname
+            if not self.db.hasnode(prop.classname, nodeid):
+                raise ValueError, '%s has no node %s'%(link_class, nodeid)
+
+        # ok, now do the find
+        cldb = self.db.getclassdb(self.classname)
+        l = []
+        for id in self.db.getnodeids(self.classname, cldb):
+            node = self.db.getnode(self.classname, id, cldb)
+            if node.has_key(RETIRED_FLAG):
+                continue
+            for propname, nodeid in propspec:
+                nodeid = str(nodeid)
+                property = node[propname]
+                if prop.isLinkType and nodeid == property:
+                    l.append(id)
+                elif prop.isMultilinkType and nodeid in property:
+                    l.append(id)
+        cldb.close()
+        return l
+
+    def stringFind(self, **requirements):
+        """Locate a particular node by matching a set of its String properties.
+
+        If the property is not a String property, a TypeError is raised.
+        
+        The return is a list of the id of all nodes that match.
+        """
+        for propname in requirements.keys():
+            prop = self.properties[propname]
+            if not prop.isStringType:
+                raise TypeError, "'%s' not a String property"%propname
+        l = []
+        cldb = self.db.getclassdb(self.classname)
+        for nodeid in self.db.getnodeids(self.classname, cldb):
+            node = self.db.getnode(self.classname, nodeid, cldb)
+            if node.has_key(RETIRED_FLAG):
+                continue
+            for key, value in requirements.items():
+                if node[key] != value:
+                    break
+            else:
+                l.append(nodeid)
+        cldb.close()
+        return l
+
+    def list(self):
+        """Return a list of the ids of the active nodes in this class."""
+        l = []
+        cn = self.classname
+        cldb = self.db.getclassdb(cn)
+        for nodeid in self.db.getnodeids(cn, cldb):
+            node = self.db.getnode(cn, nodeid, cldb)
+            if node.has_key(RETIRED_FLAG):
+                continue
+            l.append(nodeid)
+        l.sort()
+        cldb.close()
+        return l
+
+    # XXX not in spec
+    def filter(self, filterspec, sort, group, num_re = re.compile('^\d+$')):
+        ''' Return a list of the ids of the active nodes in this class that
+            match the 'filter' spec, sorted by the group spec and then the
+            sort spec
+        '''
+        cn = self.classname
+
+        # optimise filterspec
+        l = []
+        props = self.getprops()
+        for k, v in filterspec.items():
+            propclass = props[k]
+            if propclass.isLinkType:
+                if type(v) is not type([]):
+                    v = [v]
+                # replace key values with node ids
+                u = []
+                link_class =  self.db.classes[propclass.classname]
+                for entry in v:
+                    if not num_re.match(entry):
+                        try:
+                            entry = link_class.lookup(entry)
+                        except:
+                            raise ValueError, 'new property "%s": %s not a %s'%(
+                                key, entry, self.properties[key].classname)
+                    u.append(entry)
+
+                l.append((0, k, u))
+            elif propclass.isMultilinkType:
+                if type(v) is not type([]):
+                    v = [v]
+                # replace key values with node ids
+                u = []
+                link_class =  self.db.classes[propclass.classname]
+                for entry in v:
+                    if not num_re.match(entry):
+                        try:
+                            entry = link_class.lookup(entry)
+                        except:
+                            raise ValueError, 'new property "%s": %s not a %s'%(
+                                key, entry, self.properties[key].classname)
+                    u.append(entry)
+                l.append((1, k, u))
+            elif propclass.isStringType:
+                v = v[0]
+                if '*' in v or '?' in v:
+                    # simple glob searching
+                    v = v.replace(v, '?', '.')
+                    v = v.replace(v, '*', '.*?')
+                    l.append((2, k, v))
+                elif v[0] == '^':
+                    # start-anchored
+                    if v[-1] == '$':
+                        # _and_ end-anchored
+                        l.append((6, k, v[1:-1]))
+                    l.append((3, k, v[1:]))
+                elif v[-1] == '$':
+                    # end-anchored
+                    l.append((4, k, v[:-1]))
+                else:
+                    # substring
+                    l.append((5, k, v))
+            else:
+                l.append((6, k, v))
+        filterspec = l
+
+        # now, find all the nodes that are active and pass filtering
+        l = []
+        cldb = self.db.getclassdb(cn)
+        for nodeid in self.db.getnodeids(cn, cldb):
+            node = self.db.getnode(cn, nodeid, cldb)
+            if node.has_key(RETIRED_FLAG):
+                continue
+            # apply filter
+            for t, k, v in filterspec:
+                if t == 0 and node[k] not in v:
+                    # link - if this node'd property doesn't appear in the
+                    # filterspec's nodeid list, skip it
+                    break
+                elif t == 1:
+                    # multilink - if any of the nodeids required by the
+                    # filterspec aren't in this node's property, then skip
+                    # it
+                    for value in v:
+                        if value not in node[k]:
+                            break
+                    else:
+                        continue
+                    break
+                elif t == 2 and not v.match(node[k]):
+                    # RE search
+                    break
+                elif t == 3 and node[k][:len(v)] != v:
+                    # start anchored
+                    break
+                elif t == 4 and node[k][-len(v):] != v:
+                    # end anchored
+                    break
+                elif t == 5 and node[k].find(v) == -1:
+                    # substring search
+                    break
+                elif t == 6 and node[k] != v:
+                    # straight value comparison for the other types
+                    break
+            else:
+                l.append((nodeid, node))
+        l.sort()
+        cldb.close()
+
+        # optimise sort
+        m = []
+        for entry in sort:
+            if entry[0] != '-':
+                m.append(('+', entry))
+            else:
+                m.append((entry[0], entry[1:]))
+        sort = m
+
+        # optimise group
+        m = []
+        for entry in group:
+            if entry[0] != '-':
+                m.append(('+', entry))
+            else:
+                m.append((entry[0], entry[1:]))
+        group = m
+
+        # now, sort the result
+        def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
+                db = self.db, cl=self):
+            a_id, an = a
+            b_id, bn = b
+            for list in group, sort:
+                for dir, prop in list:
+                    # handle the properties that might be "faked"
+                    if not an.has_key(prop):
+                        an[prop] = cl.get(a_id, prop)
+                    av = an[prop]
+                    if not bn.has_key(prop):
+                        bn[prop] = cl.get(b_id, prop)
+                    bv = bn[prop]
+
+                    # sorting is class-specific
+                    propclass = properties[prop]
+
+                    # String and Date values are sorted in the natural way
+                    if propclass.isStringType:
+                        # make sure that case doesn't get involved
+                        if av[0] in string.uppercase:
+                            av = an[prop] = av.lower()
+                        if bv[0] in string.uppercase:
+                            bv = bn[prop] = bv.lower()
+                    if propclass.isStringType or propclass.isDateType:
+                        if dir == '+':
+                            r = cmp(av, bv)
+                            if r != 0: return r
+                        elif dir == '-':
+                            r = cmp(bv, av)
+                            if r != 0: return r
+
+                    # Link properties are sorted according to the value of
+                    # the "order" property on the linked nodes if it is
+                    # present; or otherwise on the key string of the linked
+                    # nodes; or finally on  the node ids.
+                    elif propclass.isLinkType:
+                        link = db.classes[propclass.classname]
+                        if link.getprops().has_key('order'):
+                            if dir == '+':
+                                r = cmp(link.get(av, 'order'),
+                                    link.get(bv, 'order'))
+                                if r != 0: return r
+                            elif dir == '-':
+                                r = cmp(link.get(bv, 'order'),
+                                    link.get(av, 'order'))
+                                if r != 0: return r
+                        elif link.getkey():
+                            key = link.getkey()
+                            if dir == '+':
+                                r = cmp(link.get(av, key), link.get(bv, key))
+                                if r != 0: return r
+                            elif dir == '-':
+                                r = cmp(link.get(bv, key), link.get(av, key))
+                                if r != 0: return r
+                        else:
+                            if dir == '+':
+                                r = cmp(av, bv)
+                                if r != 0: return r
+                            elif dir == '-':
+                                r = cmp(bv, av)
+                                if r != 0: return r
+
+                    # Multilink properties are sorted according to how many
+                    # links are present.
+                    elif propclass.isMultilinkType:
+                        if dir == '+':
+                            r = cmp(len(av), len(bv))
+                            if r != 0: return r
+                        elif dir == '-':
+                            r = cmp(len(bv), len(av))
+                            if r != 0: return r
+            return cmp(a[0], b[0])
+        l.sort(sortfun)
+        return [i[0] for i in l]
+
+    def count(self):
+        """Get the number of nodes in this class.
+
+        If the returned integer is 'numnodes', the ids of all the nodes
+        in this class run from 1 to numnodes, and numnodes+1 will be the
+        id of the next node to be created in this class.
+        """
+        return self.db.countnodes(self.classname)
+
+    # Manipulating properties:
+
+    def getprops(self):
+        """Return a dictionary mapping property names to property objects."""
+        return self.properties
+
+    def addprop(self, **properties):
+        """Add properties to this class.
+
+        The keyword arguments in 'properties' must map names to property
+        objects, or a TypeError is raised.  None of the keys in 'properties'
+        may collide with the names of existing properties, or a ValueError
+        is raised before any properties have been added.
+        """
+        for key in properties.keys():
+            if self.properties.has_key(key):
+                raise ValueError, key
+        self.properties.update(properties)
+
+
+# XXX not in spec
+class Node:
+    ''' A convenience wrapper for the given node
+    '''
+    def __init__(self, cl, nodeid):
+        self.__dict__['cl'] = cl
+        self.__dict__['nodeid'] = nodeid
+    def keys(self):
+        return self.cl.getprops().keys()
+    def has_key(self, name):
+        return self.cl.getprops().has_key(name)
+    def __getattr__(self, name):
+        if self.__dict__.has_key(name):
+            return self.__dict__['name']
+        try:
+            return self.cl.get(self.nodeid, name)
+        except KeyError, value:
+            raise AttributeError, str(value)
+    def __getitem__(self, name):
+        return self.cl.get(self.nodeid, name)
+    def __setattr__(self, name, value):
+        try:
+            return self.cl.set(self.nodeid, **{name: value})
+        except KeyError, value:
+            raise AttributeError, str(value)
+    def __setitem__(self, name, value):
+        self.cl.set(self.nodeid, **{name: value})
+    def history(self):
+        return self.cl.history(self.nodeid)
+    def retire(self):
+        return self.cl.retire(self.nodeid)
+
+
+def Choice(name, *options):
+    cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
+    for i in range(len(options)):
+        cl.create(name=option[i], order=i)
+    return hyperdb.Link(name)
+
+
+if __name__ == '__main__':
+    import pprint
+    db = Database("test_db", "richard")
+    status = Class(db, "status", name=String())
+    status.setkey("name")
+    print db.status.create(name="unread")
+    print db.status.create(name="in-progress")
+    print db.status.create(name="testing")
+    print db.status.create(name="resolved")
+    print db.status.count()
+    print db.status.list()
+    print db.status.lookup("in-progress")
+    db.status.retire(3)
+    print db.status.list()
+    issue = Class(db, "issue", title=String(), status=Link("status"))
+    db.issue.create(title="spam", status=1)
+    db.issue.create(title="eggs", status=2)
+    db.issue.create(title="ham", status=4)
+    db.issue.create(title="arguments", status=2)
+    db.issue.create(title="abuse", status=1)
+    user = Class(db, "user", username=String(), password=String())
+    user.setkey("username")
+    db.issue.addprop(fixer=Link("user"))
+    print db.issue.getprops()
+#{"title": <hyperdb.String>, "status": <hyperdb.Link to "status">,
+#"user": <hyperdb.Link to "user">}
+    db.issue.set(5, status=2)
+    print db.issue.get(5, "status")
+    print db.status.get(2, "name")
+    print db.issue.get(5, "title")
+    print db.issue.find(status = db.status.lookup("in-progress"))
+    print db.issue.history(5)
+# [(<Date 2000-06-28.19:09:43>, "ping", "create", {"title": "abuse", "status": 1}),
+# (<Date 2000-06-28.19:11:04>, "ping", "set", {"status": 2})]
+    print db.status.history(1)
+# [(<Date 2000-06-28.19:09:43>, "ping", "link", ("issue", 5, "status")),
+# (<Date 2000-06-28.19:11:04>, "ping", "unlink", ("issue", 5, "status"))]
+    print db.status.history(2)
+# [(<Date 2000-06-28.19:11:04>, "ping", "link", ("issue", 5, "status"))]
+
+    # TODO: set up some filter tests
+
diff --git a/roundup-mailgw.py b/roundup-mailgw.py
new file mode 100755 (executable)
index 0000000..5462016
--- /dev/null
@@ -0,0 +1,273 @@
+#! /usr/bin/python
+'''
+Incoming messages are examined for multiple parts. In a multipart/mixed
+message or part, each subpart is extracted and examined. In a
+multipart/alternative message or part, we look for a text/plain subpart and
+ignore the other parts. The text/plain subparts are assembled to form the
+textual body of the message, to be stored in the file associated with a
+"msg" class node. Any parts of other types are each stored in separate
+files and given "file" class nodes that are linked to the "msg" node. 
+
+The "summary" property on message nodes is taken from the first non-quoting
+section in the message body. The message body is divided into sections by
+blank lines. Sections where the second and all subsequent lines begin with
+a ">" or "|" character are considered "quoting sections". The first line of
+the first non-quoting section becomes the summary of the message. 
+
+All of the addresses in the To: and Cc: headers of the incoming message are
+looked up among the user nodes, and the corresponding users are placed in
+the "recipients" property on the new "msg" node. The address in the From:
+header similarly determines the "author" property of the new "msg"
+node. The default handling for addresses that don't have corresponding
+users is to create new users with no passwords and a username equal to the
+address. (The web interface does not permit logins for users with no
+passwords.) If we prefer to reject mail from outside sources, we can simply
+register an auditor on the "user" class that prevents the creation of user
+nodes with no passwords. 
+
+The subject line of the incoming message is examined to determine whether
+the message is an attempt to create a new item or to discuss an existing
+item. A designator enclosed in square brackets is sought as the first thing
+on the subject line (after skipping any "Fwd:" or "Re:" prefixes). 
+
+If an item designator (class name and id number) is found there, the newly
+created "msg" node is added to the "messages" property for that item, and
+any new "file" nodes are added to the "files" property for the item. 
+
+If just an item class name is found there, we attempt to create a new item
+of that class with its "messages" property initialized to contain the new
+"msg" node and its "files" property initialized to contain any new "file"
+nodes. 
+
+Both cases may trigger detectors (in the first case we are calling the
+set() method to add the message to the item's spool; in the second case we
+are calling the create() method to create a new node). If an auditor raises
+an exception, the original message is bounced back to the sender with the
+explanatory message given in the exception. 
+'''
+
+import sys
+if int(sys.version[0]) < 2:
+    print "Roundup requires Python 2.0 or newer."
+    sys.exit(0)
+
+import string, re, os, mimetools, StringIO, smtplib, socket, binascii, quopri
+import config, date, roundupdb
+
+def getPart(fp, boundary):
+    line = ''
+    s = StringIO.StringIO()
+    while 1:
+        line_n = fp.readline()
+        if not line_n:
+            break
+        line = line_n.strip()
+        if line == '--'+boundary+'--':
+            break
+        if line == '--'+boundary:
+            break
+        s.write(line_n)
+    if not s.getvalue().strip():
+        return None
+    return s
+
+subject_re = re.compile(r'(\[?(fwd|re):\s*)*'
+    r'(\[(?P<classname>[^\d]+)(?P<nodeid>\d+)?\])'
+    r'(?P<title>[^\[]+)(\[(?P<args>.+?)\])?', re.I)
+
+def roundup_mail(db, fp):
+    # ok, figure the subject, author, recipients and content-type
+    message = mimetools.Message(fp)
+    try:
+        handle_message(db, message)
+    except:
+        # send an email to the people who missed out
+        sendto = [message.getaddrlist('from')[0][1]]
+        m = ['Subject: failed issue tracker submission']
+        m.append('')
+        # TODO as attachments?
+        m.append('----  traceback of failure  ----')
+        return
+        s = StringIO.StringIO()
+        import traceback
+        traceback.print_exc(None, s)
+        m.append(s.getvalue())
+        m.append('---- failed message follows ----')
+        try:
+            fp.seek(0)
+        except:
+            pass
+        m.append(fp.read())
+        try:
+            smtp = smtplib.SMTP(config.MAILHOST)
+            smtp.sendmail(config.ADMIN_EMAIL, sendto, '\n'.join(m))
+        except socket.error, value:
+            return "Couldn't send confirmation email: mailhost %s"%value
+        except smtplib.SMTPException, value:
+            return "Couldn't send confirmation email: %s"%value
+
+def handle_message(db, message):
+    # handle the subject line
+    m = subject_re.match(message.getheader('subject'))
+    if not m:
+        raise ValueError, 'No [designator] found in subject "%s"'
+    classname = m.group('classname')
+    nodeid = m.group('nodeid')
+    title = m.group('title').strip()
+    subject_args = m.group('args')
+    cl = db.getclass(classname)
+    properties = cl.getprops()
+    props = {}
+    args = m.group('args')
+    if args:
+        for prop in string.split(m.group('args'), ';'):
+            try:
+                key, value = prop.split('=')
+            except ValueError, message:
+                raise ValueError, 'Args list not of form [arg=value,value,...;arg=value,value,value..]  (specific exception message was "%s")'%message
+            type =  properties[key]
+            if type.isStringType:
+                props[key] = value 
+            elif type.isDateType:
+                props[key] = date.Date(value)
+            elif type.isIntervalType:
+                props[key] = date.Interval(value)
+            elif type.isLinkType:
+                props[key] = value
+            elif type.isMultilinkType:
+                props[key] = value.split(',')
+
+    # handle the users
+    author = db.uidFromAddress(message.getaddrlist('from')[0])
+    recipients = []
+    for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
+        if recipient[1].strip().lower() == config.ISSUE_TRACKER_EMAIL:
+            continue
+        recipients.append(db.uidFromAddress(recipient))
+
+    # now handle the body - find the message
+    content_type =  message.gettype()
+    attachments = []
+    if content_type == 'multipart/mixed':
+        boundary = message.getparam('boundary')
+        # skip over the intro to the first boundary
+        part = getPart(message.fp, boundary)
+        content = None
+        while 1:
+            # get the next part
+            part = getPart(message.fp, boundary)
+            if part is None:
+                break
+            # parse it
+            part.seek(0)
+            submessage = mimetools.Message(part)
+            subtype = submessage.gettype()
+            if subtype == 'text/plain' and not content:
+                # this one's our content
+                content = part.read()
+            elif subtype == 'message/rfc822':
+                i = part.tell()
+                subsubmess = mimetools.Message(part)
+                name = subsubmess.getheader('subject')
+                part.seek(i)
+                attachments.append((name, 'message/rfc822', part.read()))
+            else:
+                # try name on Content-Type
+                name = submessage.getparam('name')
+                # this is just an attachment
+                data = part.read()
+                encoding = submessage.getencoding()
+                if encoding == 'base64':
+                    data = binascii.a2b_base64(data)
+                elif encoding == 'quoted-printable':
+                    data = quopri.decode(data)
+                elif encoding == 'uuencoded':
+                    data = binascii.a2b_uu(data)
+                attachments.append((name, submessage.gettype(), data))
+        if content is None:
+            raise ValueError, 'No text/plain part found'
+
+    elif content_type[:10] == 'multipart/':
+        boundary = message.getparam('boundary')
+        # skip over the intro to the first boundary
+        getPart(message.fp, boundary)
+        content = None
+        while 1:
+            # get the next part
+            part = getPart(message.fp, boundary)
+            if part is None:
+                break
+            # parse it
+            part.seek(0)
+            submessage = mimetools.Message(part)
+            if submessage.gettype() == 'text/plain' and not content:
+                # this one's our content
+                content = part.read()
+        if content is None:
+            raise ValueError, 'No text/plain part found'
+
+    elif content_type != 'text/plain':
+        raise ValueError, 'No text/plain part found'
+
+    else:
+        content = message.fp.read()
+
+    # extract out the summary from the message
+    summary = []
+    for line in content.split('\n'):
+        line = line.strip()
+        if summary and not line:
+            break
+        if not line:
+            summary.append('')
+        elif line[0] not in '>|':
+            summary.append(line)
+    summary = '\n'.join(summary)
+
+    # handle the files
+    files = []
+    for (name, type, data) in attachments:
+        files.append(db.file.create(type=type, name=name, content=data))
+
+    # now handle the db stuff
+    if nodeid:
+        # If an item designator (class name and id number) is found there, the
+        # newly created "msg" node is added to the "messages" property for
+        # that item, and any new "file" nodes are added to the "files" 
+        # property for the item. 
+        message_id = db.msg.create(author=author, recipients=recipients,
+            date=date.Date('.'), summary=summary, content=content,
+            files=files)
+        messages = cl.get(nodeid, 'messages')
+        messages.append(message_id)
+        props['messages'] = messages
+        apply(cl.set, (nodeid, ), props)
+    else:
+        # If just an item class name is found there, we attempt to create a
+        # new item of that class with its "messages" property initialized to
+        # contain the new "msg" node and its "files" property initialized to
+        # contain any new "file" nodes. 
+        message_id = db.msg.create(author=author, recipients=recipients,
+            date=date.Date('.'), summary=summary, content=content,
+            files=files)
+        if not props.has_key('assignedto'):
+            props['assignedto'] = 1             # "admin"
+        if not props.has_key('priority'):
+            props['priority'] = 1               # "bug-fatal"
+        if not props.has_key('status'):
+            props['status'] = 1                 # "unread"
+        if not props.has_key('title'):
+            props['title'] = title
+        props['messages'] = [message_id]
+        props['nosy'] = recipients[:]
+        props['nosy'].append(author)
+        props['nosy'].sort()
+        nodeid = apply(cl.create, (), props)
+
+    return 0
+
+if __name__ == '__main__':
+    db = roundupdb.openDB(config.DATABASE, 'admin', '1')
+    roundup_mail(db, sys.stdin)
+    db.close()
+
diff --git a/roundup.cgi b/roundup.cgi
new file mode 100755 (executable)
index 0000000..6ea7189
--- /dev/null
@@ -0,0 +1,71 @@
+#!/usr/bin/env python
+
+import sys
+if int(sys.version[0]) < 2:
+    print "Content-Type: text/plain\n"
+    print "Roundup requires Python 2.0 or newer."
+
+import os, traceback, StringIO, cgi, binascii
+
+try:
+    import cgitb
+except:
+    print "Content-Type: text/html\n"
+    print "Failed to import cgitb"
+    print "<pre>"
+    s = StringIO.StringIO()
+    traceback.print_exc(None, s)
+    print cgi.escape(s.getvalue())
+    print "</pre>"
+
+# Force import first from the same directory where this script lives.
+dir, name = os.path.split(sys.argv[0])
+sys.path[:0] = [dir or "."]
+
+def main(out):
+    import config, roundupdb, roundup_cgi
+    db = roundupdb.openDB(config.DATABASE, 'admin')
+    auth = os.environ.get("HTTP_CGI_AUTHORIZATION", None)
+    message = 'Unauthorised'
+    if auth:
+        l = binascii.a2b_base64(auth.split(' ')[1]).split(':')
+        user = l[0]
+        password = None
+        if len(l) > 1:
+            password = l[1]
+        try:
+            uid = db.user.lookup(user)
+        except KeyError:
+            auth = None
+            message = 'Username not recognised'
+        else:
+            if password != db.user.get(uid, 'password'):
+                message = 'Incorrect password'
+                auth = None
+    if not auth:
+        out.write('Content-Type: text/html\n')
+        out.write('Status: 401\n')
+        out.write('WWW-Authenticate: basic realm="Roundup"\n\n')
+        keys = os.environ.keys()
+        keys.sort()
+        out.write(message)
+        return
+    client = roundup_cgi.Client(out, os.environ, user)
+    try:
+        client.main()
+    except roundup_cgi.Unauthorised:
+        out.write('Content-Type: text/html\n')
+        out.write('Status: 403\n\n')
+        out.write('Unauthorised')
+
+out, err = sys.stdout, sys.stderr
+try:
+    import config, roundup_cgi
+    sys.stdout = sys.stderr = open(config.LOG, 'a')
+    main(out)
+except:
+    sys.stdout, sys.stderr = out, err
+    out.write('Content-Type: text/html\n\n')
+    cgitb.handler()
+sys.stdout.flush()
+sys.stdout, sys.stderr = out, err
diff --git a/roundup.py b/roundup.py
new file mode 100755 (executable)
index 0000000..32b13f7
--- /dev/null
@@ -0,0 +1,204 @@
+#! /usr/bin/python
+
+import sys
+if int(sys.version[0]) < 2:
+    print 'Roundup requires python 2.0 or later.'
+    sys.exit(1)
+
+import string, os, getpass
+import config, date, roundupdb
+
+def determineLogin(argv):
+    n = 2
+    name = password = ''
+    if sys.argv[2] == '-user':
+        l = sys.argv[3].split(':')
+        name = l[0]
+        if len(l) > 1:
+            password = l[1]
+        n = 4
+    elif os.environ.has_key('ROUNDUP_LOGIN'):
+        l = os.environ['ROUNDUP_LOGIN'].split(':')
+        name = l[0]
+        if len(l) > 1:
+            password = l[1]
+    while not name:
+        name = raw_input('Login name: ')
+    while not password:
+        password = getpass.getpass('  password: ')
+    return n, roundupdb.openDB(config.DATABASE, name, password)
+
+def usage():
+    print '''Usage:
+
+ roundup init
+ roundup spec classname
+ roundup create [-user login] classanme propname=value ...
+ roundup list [-list] classname
+ roundup history [-list] designator
+ roundup get [-list] designator[,designator,...] propname
+ roundup set [-user login] designator[,designator,...] propname=value ...
+ roundup find [-list] classname propname=value ...
+ roundup retire designator[,designator,...]
+
+A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...
+
+Property values are represented as strings in command arguments and in the
+printed results:
+ . Strings are, well, strings.
+ . Date values are printed in the full date format in the local time zone, and
+   accepted in the full format or any of the partial formats explained below.
+ . Link values are printed as node designators. When given as an argument,
+   node designators and key strings are both accepted.
+ . Multilink values are printed as lists of node designators joined by commas.
+   When given as an argument, node designators and key strings are both
+   accepted; an empty string, a single node, or a list of nodes joined by
+   commas is accepted.
+
+When multiple nodes are specified to the roundup get or roundup set
+commands, the specified properties are retrieved or set on all the listed
+nodes. 
+
+When multiple results are returned by the roundup get or roundup find
+commands, they are printed one per line (default) or joined by commas (with
+the -list) option. 
+
+Where the command changes data, a login name/password is required. The
+login may be specified as either "name" or "name:password".
+ . ROUNDUP_LOGIN environment variable
+ . the -user command-line option
+If either the name or password is not supplied, they are obtained from the
+command-line. 
+
+Date format examples:
+  "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
+  "2000-04-17" means <Date 2000-04-17.00:00:00>
+  "01-25" means <Date yyyy-01-25.00:00:00>
+  "08-13.22:13" means <Date yyyy-08-14.03:13:00>
+  "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
+  "14:25" means <Date yyyy-mm-dd.19:25:00>
+  "8:47:11" means <Date yyyy-mm-dd.13:47:11>
+  "." means "right now"
+'''
+
+def main():
+
+    if len(sys.argv) == 1:
+        usage()
+        return 1
+
+    command = sys.argv[1]
+    if command == 'init':
+        password = ''
+        confirm = 'x'
+        while password != confirm:
+            password = getpass.getpass('Admin Password:')
+            confirm = getpass.getpass('       Confirm:')
+        roundupdb.initDB(config.DATABASE, password)
+        return 0
+
+    if command == 'get':
+        db = roundupdb.openDB(config.DATABASE)
+        designators = string.split(sys.argv[2], ',')
+        propname = sys.argv[3]
+        for designator in designators:
+            classname, nodeid = roundupdb.splitDesignator(designator)
+            print db.getclass(classname).get(nodeid, propname)
+
+    elif command == 'set':
+        n, db = determineLogin(sys.argv)
+        designators = string.split(sys.argv[n], ',')
+        props = {}
+        for prop in sys.argv[n+1:]:
+            key, value = prop.split('=')
+            props[key] = value
+        for designator in designators:
+            classname, nodeid = roundupdb.splitDesignator(designator)
+            cl = db.getclass(classname)
+            properties = cl.getprops()
+            for key, value in props.items():
+                type =  properties[key]
+                if type.isStringType:
+                    continue
+                elif type.isDateType:
+                    props[key] = date.Date(value)
+                elif type.isIntervalType:
+                    props[key] = date.Interval(value)
+                elif type.isLinkType:
+                    props[key] = value
+                elif type.isMultilinkType:
+                    props[key] = value.split(',')
+            apply(cl.set, (nodeid, ), props)
+
+    elif command == 'find':
+        db = roundupdb.openDB(config.DATABASE)
+        classname = sys.argv[2]
+        cl = db.getclass(classname)
+
+        # look up the linked-to class and get the nodeid that has the value
+        propname, value = sys.argv[3:].split('=')
+        propcl = cl[propname].classname
+        nodeid = propcl.lookup(value)
+
+        # now do the find
+        print cl.find(propname, nodeid)
+
+    elif command == 'spec':
+        db = roundupdb.openDB(config.DATABASE)
+        classname = sys.argv[2]
+        cl = db.getclass(classname)
+        for key, value in cl.properties.items():
+            print '%s: %s'%(key, value)
+
+    elif command == 'create':
+        n, db = determineLogin(sys.argv)
+        classname = sys.argv[n]
+        cl = db.getclass(classname)
+        props = {}
+        properties = cl.getprops()
+        for prop in sys.argv[n+1:]:
+            key, value = prop.split('=')
+            type =  properties[key]
+            if type.isStringType:
+                props[key] = value 
+            elif type.isDateType:
+                props[key] = date.Date(value)
+            elif type.isIntervalType:
+                props[key] = date.Interval(value)
+            elif type.isLinkType:
+                props[key] = value
+            elif type.isMultilinkType:
+                props[key] = value.split(',')
+        print apply(cl.create, (), props)
+
+    elif command == 'list':
+        db = roundupdb.openDB(config.DATABASE)
+        classname = sys.argv[2]
+        cl = db.getclass(classname)
+        key = cl.getkey() or cl.properties.keys()[0]
+        for nodeid in cl.list():
+            value = cl.get(nodeid, key)
+            print "%4s: %s"%(nodeid, value)
+
+    elif command == 'history':
+        db = roundupdb.openDB(config.DATABASE)
+        classname, nodeid = roundupdb.splitDesignator(sys.argv[2])
+        print db.getclass(classname).history(nodeid)
+
+    elif command == 'retire':
+        n, db = determineLogin(sys.argv)
+        designators = string.split(sys.argv[2], ',')
+        for designator in designators:
+            classname, nodeid = roundupdb.splitDesignator(designator)
+            db.getclass(classname).retire(nodeid)
+
+    else:
+        usage()
+        return 1
+
+    db.close()
+    return 0
+
+if __name__ == '__main__':
+    sys.exit(main())
+
diff --git a/roundup_cgi.py b/roundup_cgi.py
new file mode 100644 (file)
index 0000000..4319833
--- /dev/null
@@ -0,0 +1,479 @@
+import os, cgi, pprint, StringIO, urlparse, re, traceback
+
+import config, roundupdb, template, date
+
+class Unauthorised(ValueError):
+    pass
+
+class Client:
+    def __init__(self, out, env, user):
+        self.out = out
+        self.headers_done = 0
+        self.env = env
+        self.path = env.get("PATH_INFO", '').strip()
+        self.user = user
+        self.form = cgi.FieldStorage(environ=env)
+        self.split_path = self.path.split('/')[1:]
+        self.db = roundupdb.openDB(config.DATABASE, self.user)
+        self.headers_done = 0
+        self.debug = 0
+
+    def header(self, headers={'Content-Type':'text/html'}):
+        if not headers.has_key('Content-Type'):
+            headers['Content-Type'] = 'text/html'
+        for entry in headers.items():
+            self.out.write('%s: %s\n'%entry)
+        self.out.write('\n')
+        self.headers_done = 1
+
+    def pagehead(self, title, message=None):
+        url = self.env['SCRIPT_NAME'] + '/' #self.env.get('PATH_INFO', '/')
+        machine = self.env['SERVER_NAME']
+        port = self.env['SERVER_PORT']
+        if port != '80': machine = machine + ':' + port
+        base = urlparse.urlunparse(('http', machine, url, None, None, None))
+        if message is not None:
+            message = '<div class="system-msg">%s</div>'%message
+        else:
+            message = ''
+        style = open('style.css').read()
+        userid = self.db.user.lookup(self.user)
+        if self.user == 'admin':
+            extras = ' | <a href="list_classes">Class List</a>'
+        else:
+            extras = ''
+        self.write('''<html><head>
+<title>%s</title>
+<style type="text/css">%s</style>
+</head>
+<body bgcolor=#ffffff>
+%s
+<table width=100%% border=0 cellspacing=0 cellpadding=2>
+<tr class="location-bar"><td><big><strong>%s</strong></big></td>
+<td align=right valign=bottom>%s</td></tr>
+<tr class="location-bar">
+<td align=left><a href="issue?:columns=activity,status,title&:group=priority">All issues</a> | 
+<a href="issue?priority=fatal-bug,bug">Bugs</a> | 
+<a href="issue?priority=usability">Support</a> | 
+<a href="issue?priority=feature">Wishlist</a> | 
+<a href="newissue">New Issue</a>
+%s</td>
+<td align=right><a href="user%s">Your Details</a></td>
+</table>
+'''%(title, style, message, title, self.user, extras, userid))
+
+    def pagefoot(self):
+        if self.debug:
+            self.write('<hr><small><dl>')
+            self.write('<dt><b>Path</b></dt>')
+            self.write('<dd>%s</dd>'%(', '.join(map(repr, self.split_path))))
+            keys = self.form.keys()
+            keys.sort()
+            if keys:
+                self.write('<dt><b>Form entries</b></dt>')
+                for k in self.form.keys():
+                    v = str(self.form[k].value)
+                    self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
+            keys = self.env.keys()
+            keys.sort()
+            self.write('<dt><b>CGI environment</b></dt>')
+            for k in keys:
+                v = self.env[k]
+                self.write('<dd><em>%s</em>:%s</dd>'%(k, cgi.escape(v)))
+            self.write('</dl></small>')
+        self.write('</body></html>')
+
+    def write(self, content):
+        if not self.headers_done:
+            self.header()
+        self.out.write(content)
+
+    def index_arg(self, arg):
+        ''' handle the args to index - they might be a list from the form
+            (ie. submitted from a form) or they might be a command-separated
+            single string (ie. manually constructed GET args)
+        '''
+        if self.form.has_key(arg):
+            arg =  self.form[arg]
+            if type(arg) == type([]):
+                return [arg.value for arg in arg]
+            return arg.value.split(',')
+        return []
+
+    def index(self):
+        ''' put up an index
+        '''
+        self.classname = 'issue'
+        if self.form.has_key(':sort'): sort = self.index_arg(':sort')
+        else: sort=['-activity']
+        if self.form.has_key(':group'): group = self.index_arg(':group')
+        else: group=['priority']
+        if self.form.has_key(':filter'): filter = self.index_arg(':filter')
+        else: filter = []
+        if self.form.has_key(':columns'): columns = self.index_arg(':columns')
+        else: columns=['activity','status','title']
+        return self.list(columns=columns, filter=filter, group=group, sort=sort)
+
+    # XXX deviates from spec - loses the '+' (that's a reserved character
+    # in URLS
+    def list(self, sort=None, group=None, filter=None, columns=None):
+        ''' call the template index with the args
+
+            :sort    - sort by prop name, optionally preceeded with '-'
+                     to give descending or nothing for ascending sorting.
+            :group   - group by prop name, optionally preceeded with '-' or
+                     to sort in descending or nothing for ascending order.
+            :filter  - selects which props should be displayed in the filter
+                     section. Default is all.
+            :columns - selects the columns that should be displayed.
+                     Default is all.
+
+        '''
+        cn = self.classname
+        self.pagehead('Index: %s'%cn)
+        if sort is None: sort = self.index_arg(':sort')
+        if group is None: group = self.index_arg(':group')
+        if filter is None: filter = self.index_arg(':filter')
+        if columns is None: columns = self.index_arg(':columns')
+
+        # all the other form args are filters
+        filterspec = {}
+        for key in self.form.keys():
+            if key[0] == ':': continue
+            value = self.form[key]
+            if type(value) == type([]):
+                value = [arg.value for arg in value]
+            else:
+                value = value.value.split(',')
+            l = filterspec.get(key, [])
+            l = l + value
+            filterspec[key] = l
+
+        template.index(self, self.db, cn, filterspec, filter, columns, sort,
+            group)
+        self.pagefoot()
+
+    def showitem(self, message=None):
+        ''' display an item
+        '''
+        cn = self.classname
+        cl = self.db.classes[cn]
+
+        # possibly perform an edit
+        keys = self.form.keys()
+        num_re = re.compile('^\d+$')
+        if keys:
+            changed = []
+            props = {}
+            try:
+                keys = self.form.keys()
+                for key in keys:
+                    if not cl.properties.has_key(key):
+                        continue
+                    proptype = cl.properties[key]
+                    if proptype.isStringType:
+                        value = str(self.form[key].value).strip()
+                    elif proptype.isDateType:
+                        value = date.Date(str(self.form[key].value))
+                    elif proptype.isIntervalType:
+                        value = date.Interval(str(self.form[key].value))
+                    elif proptype.isLinkType:
+                        value = str(self.form[key].value).strip()
+                        # handle key values
+                        link = cl.properties[key].classname
+                        if not num_re.match(value):
+                            try:
+                                value = self.db.classes[link].lookup(value)
+                            except:
+                                raise ValueError, 'property "%s": %s not a %s'%(
+                                    key, value, link)
+                    elif proptype.isMultilinkType:
+                        value = self.form[key]
+                        if type(value) != type([]):
+                            value = [i.strip() for i in str(value.value).split(',')]
+                        else:
+                            value = [str(i.value).strip() for i in value]
+                        link = cl.properties[key].classname
+                        l = []
+                        for entry in map(str, value):
+                            if not num_re.match(entry):
+                                try:
+                                    entry = self.db.classes[link].lookup(entry)
+                                except:
+                                    raise ValueError, \
+                                        'property "%s": %s not a %s'%(key,
+                                        entry, link)
+                            l.append(entry)
+                        l.sort()
+                        value = l
+                    # if changed, set it
+                    if value != cl.get(self.nodeid, key):
+                        changed.append(key)
+                        props[key] = value
+                cl.set(self.nodeid, **props)
+
+                # if this item has messages, 
+                if (cl.getprops().has_key('messages') and
+                        cl.getprops()['messages'].isMultilinkType and
+                        cl.getprops()['messages'].classname == 'msg'):
+                    # generate an edit message - nosyreactor will send it
+                    nid = self.nodeid
+                    m = []
+                    for name, prop in cl.getprops().items():
+                        value = cl.get(nid, name)
+                        if prop.isLinkType:
+                            link = self.db.classes[prop.classname]
+                            key = link.getkey()
+                            if value is not None and key:
+                                value = link.get(value, key)
+                            else:
+                                value = '-'
+                        elif prop.isMultilinkType:
+                            l = []
+                            link = self.db.classes[prop.classname]
+                            for entry in value:
+                                key = link.getkey()
+                                if key:
+                                    l.append(link.get(entry, link.getkey()))
+                                else:
+                                    l.append(entry)
+                            value = ', '.join(l)
+                        if name in changed:
+                            chg = '*'
+                        else:
+                            chg = ' '
+                        m.append('%s %s: %s'%(chg, name, value))
+
+                    # handle the note
+                    if self.form.has_key('__note'):
+                        note = self.form['__note'].value
+                        if '\n' in note:
+                            summary = re.split(r'\n\r?', note)[0]
+                        else:
+                            summary = note
+                        m.append('\n%s\n'%note)
+                    else:
+                        if len(changed) > 1:
+                            plural = 's were'
+                        else:
+                            plural = ' was'
+                        summary = 'This %s has been edited through the web '\
+                            'and the %s value%s changed.'%(cn,
+                            ', '.join(changed), plural)
+                        m.append('\n%s\n'%summary)
+
+                    # now create the message
+                    content = '\n'.join(m)
+                    message_id = self.db.msg.create(author=1, recipients=[],
+                        date=date.Date('.'), summary=summary, content=content)
+                    messages = cl.get(nid, 'messages')
+                    messages.append(message_id)
+                    props = {'messages': messages}
+                    cl.set(nid, **props)
+
+                # and some nice feedback for the user
+                message = '%s edited ok'%', '.join(changed)
+            except:
+                s = StringIO.StringIO()
+                traceback.print_exc(None, s)
+                message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
+
+        # now the display
+        id = self.nodeid
+        if cl.getkey():
+            id = cl.get(id, cl.getkey())
+        self.pagehead('%s: %s'%(self.classname.capitalize(), id), message)
+
+        nodeid = self.nodeid
+
+        # use the template to display the item
+        template.item(self, self.db, self.classname, nodeid)
+        self.pagefoot()
+    showissue = showitem
+    showmsg = showitem
+
+    def newissue(self, message=None):
+        ''' add an issue
+        '''
+        cn = self.classname
+        cl = self.db.classes[cn]
+
+        # possibly perform a create
+        keys = self.form.keys()
+        num_re = re.compile('^\d+$')
+        if keys:
+            props = {}
+            try:
+                keys = self.form.keys()
+                for key in keys:
+                    if not cl.properties.has_key(key):
+                        continue
+                    proptype = cl.properties[key]
+                    if proptype.isStringType:
+                        value = str(self.form[key].value).strip()
+                    elif proptype.isDateType:
+                        value = date.Date(str(self.form[key].value))
+                    elif proptype.isIntervalType:
+                        value = date.Interval(str(self.form[key].value))
+                    elif proptype.isLinkType:
+                        value = str(self.form[key].value).strip()
+                        # handle key values
+                        link = cl.properties[key].classname
+                        if not num_re.match(value):
+                            try:
+                                value = self.db.classes[link].lookup(value)
+                            except:
+                                raise ValueError, 'property "%s": %s not a %s'%(
+                                    key, value, link)
+                    elif proptype.isMultilinkType:
+                        value = self.form[key]
+                        if type(value) != type([]):
+                            value = [i.strip() for i in str(value.value).split(',')]
+                        else:
+                            value = [str(i.value).strip() for i in value]
+                        link = cl.properties[key].classname
+                        l = []
+                        for entry in map(str, value):
+                            if not num_re.match(entry):
+                                try:
+                                    entry = self.db.classes[link].lookup(entry)
+                                except:
+                                    raise ValueError, \
+                                        'property "%s": %s not a %s'%(key,
+                                        entry, link)
+                            l.append(entry)
+                        l.sort()
+                        value = l
+                    props[key] = value
+                nid = cl.create(**props)
+
+                # if this item has messages, 
+                if (cl.getprops().has_key('messages') and
+                        cl.getprops()['messages'].isMultilinkType and
+                        cl.getprops()['messages'].classname == 'msg'):
+                    # generate an edit message - nosyreactor will send it
+                    m = []
+                    for name, prop in cl.getprops().items():
+                        value = cl.get(nid, name)
+                        if prop.isLinkType:
+                            link = self.db.classes[prop.classname]
+                            key = link.getkey()
+                            if value is not None and key:
+                                value = link.get(value, key)
+                            else:
+                                value = '-'
+                        elif prop.isMultilinkType:
+                            l = []
+                            link = self.db.classes[prop.classname]
+                            for entry in value:
+                                key = link.getkey()
+                                if key:
+                                    l.append(link.get(entry, link.getkey()))
+                                else:
+                                    l.append(entry)
+                            value = ', '.join(l)
+                        m.append('%s: %s'%(name, value))
+
+                    # handle the note
+                    if self.form.has_key('__note'):
+                        note = self.form['__note'].value
+                        if '\n' in note:
+                            summary = re.split(r'\n\r?', note)[0]
+                        else:
+                            summary = note
+                        m.append('\n%s\n'%note)
+                    else:
+                        if len(changed) > 1:
+                            plural = 's were'
+                        else:
+                            plural = ' was'
+                        summary = 'This %s has been created through the web.'%cn
+                        m.append('\n%s\n'%summary)
+
+                    # now create the message
+                    content = '\n'.join(m)
+                    message_id = self.db.msg.create(author=1, recipients=[],
+                        date=date.Date('.'), summary=summary, content=content)
+                    messages = cl.get(nid, 'messages')
+                    messages.append(message_id)
+                    props = {'messages': messages}
+                    cl.set(nid, **props)
+
+                # and some nice feedback for the user
+                message = '%s created ok'%cn
+            except:
+                s = StringIO.StringIO()
+                traceback.print_exc(None, s)
+                message = '<pre>%s</pre>'%cgi.escape(s.getvalue())
+        self.pagehead('New %s'%self.classname.capitalize(), message)
+        template.newitem(self, self.db, self.classname, self.form)
+        self.pagefoot()
+
+    def showuser(self, message=None):
+        ''' display an item
+        '''
+        if self.user in ('admin', self.db.user.get(self.nodeid, 'username')):
+            self.showitem(message)
+        else:
+            raise Unauthorised
+
+    def showfile(self):
+        ''' display a file
+        '''
+        nodeid = self.nodeid
+        cl = self.db.file
+        type = cl.get(nodeid, 'type')
+        if type == 'message/rfc822':
+            type = 'text/plain'
+        self.header(headers={'Content-Type': type})
+        self.write(cl.get(nodeid, 'content'))
+
+    def classes(self, message=None):
+        ''' display a list of all the classes in the database
+        '''
+        if self.user == 'admin':
+            self.pagehead('Table of classes', message)
+            classnames = self.db.classes.keys()
+            classnames.sort()
+            self.write('<table border=0 cellspacing=0 cellpadding=2>\n')
+            for cn in classnames:
+                cl = self.db.getclass(cn)
+                self.write('<tr class="list-header"><th colspan=2 align=left>%s</th></tr>'%cn.capitalize())
+                for key, value in cl.properties.items():
+                    if value is None: value = ''
+                    else: value = str(value)
+                    self.write('<tr><th align=left>%s</th><td>%s</td></tr>'%(
+                        key, cgi.escape(value)))
+            self.write('</table>')
+            self.pagefoot()
+        else:
+            raise Unauthorised
+
+    def main(self, dre=re.compile(r'([^\d]+)(\d+)'),
+            nre=re.compile(r'new(\w+)')):
+        path = self.split_path
+        if not path or path[0] in ('', 'index'):
+            self.index()
+        elif len(path) == 1:
+            if path[0] == 'list_classes':
+                self.classes()
+                return
+            m = dre.match(path[0])
+            if m:
+                self.classname = m.group(1)
+                self.nodeid = m.group(2)
+                getattr(self, 'show%s'%self.classname)()
+                return
+            m = nre.match(path[0])
+            if m:
+                self.classname = m.group(1)
+                getattr(self, 'new%s'%self.classname)()
+                return
+            self.classname = path[0]
+            self.list()
+        else:
+            raise 'ValueError', 'Path not understood'
+
+    def __del__(self):
+        self.db.close()
+
diff --git a/roundupdb.py b/roundupdb.py
new file mode 100644 (file)
index 0000000..96e62f8
--- /dev/null
@@ -0,0 +1,371 @@
+import re, os, smtplib, socket
+
+import config, hyperdb, date
+
+def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
+    ''' Take a foo123 and return ('foo', 123)
+    '''
+    m = dre.match(designator)
+    return m.group(1), m.group(2)
+
+class Database(hyperdb.Database):
+    def getuid(self):
+        """Return the id of the "user" node associated with the user
+        that owns this connection to the hyperdatabase."""
+        return self.user.lookup(self.journaltag)
+
+    def uidFromAddress(self, address):
+        ''' address is from the rfc822 module, and therefore is (name, addr)
+        '''
+        (realname, address) = address
+        users = self.user.stringFind(address=address)
+        if users: return users[0]
+        return self.user.create(username=address, address=address,
+            realname=realname)
+
+class Class(hyperdb.Class):
+    # Overridden methods:
+    def __init__(self, db, classname, **properties):
+        hyperdb.Class.__init__(self, db, classname, **properties)
+        self.auditors = {'create': [], 'set': [], 'retire': []}
+        self.reactors = {'create': [], 'set': [], 'retire': []}
+
+    def create(self, **propvalues):
+        """These operations trigger detectors and can be vetoed.  Attempts
+        to modify the "creation" or "activity" properties cause a KeyError.
+        """
+        if propvalues.has_key('creation') or propvalues.has_key('activity'):
+            raise KeyError, '"creation" and "activity" are reserved'
+        for audit in self.auditors['create']:
+            audit(self.db, self, None, propvalues)
+        nodeid = hyperdb.Class.create(self, **propvalues)
+        for react in self.reactors['create']:
+            react(self.db, self, nodeid, None)
+        return nodeid
+
+    def set(self, nodeid, **propvalues):
+        """These operations trigger detectors and can be vetoed.  Attempts
+        to modify the "creation" or "activity" properties cause a KeyError.
+        """
+        if propvalues.has_key('creation') or propvalues.has_key('activity'):
+            raise KeyError, '"creation" and "activity" are reserved'
+        for audit in self.auditors['set']:
+            audit(self.db, self, nodeid, propvalues)
+        oldvalues = self.db.getnode(self.classname, nodeid)
+        hyperdb.Class.set(self, nodeid, **propvalues)
+        for react in self.reactors['set']:
+            react(self.db, self, nodeid, oldvalues)
+
+    def retire(self, nodeid):
+        """These operations trigger detectors and can be vetoed.  Attempts
+        to modify the "creation" or "activity" properties cause a KeyError.
+        """
+        for audit in self.auditors['retire']:
+            audit(self.db, self, nodeid, None)
+        hyperdb.Class.retire(self, nodeid)
+        for react in self.reactors['retire']:
+            react(self.db, self, nodeid, None)
+
+    # New methods:
+
+    def audit(self, event, detector):
+        """Register a detector
+        """
+        self.auditors[event].append(detector)
+
+    def react(self, event, detector):
+        """Register a detector
+        """
+        self.reactors[event].append(detector)
+
+class FileClass(Class):
+    def create(self, **propvalues):
+        ''' snaffle the file propvalue and store in a file
+        '''
+        content = propvalues['content']
+        del propvalues['content']
+        newid = Class.create(self, **propvalues)
+        self.setcontent(self.classname, newid, content)
+        return newid
+
+    def filename(self, classname, nodeid):
+        # TODO: split into multiple files directories
+        return os.path.join(self.db.dir, 'files', '%s%s'%(classname, nodeid))
+
+    def setcontent(self, classname, nodeid, content):
+        ''' set the content file for this file
+        '''
+        open(self.filename(classname, nodeid), 'wb').write(content)
+
+    def getcontent(self, classname, nodeid):
+        ''' get the content file for this file
+        '''
+        return open(self.filename(classname, nodeid), 'rb').read()
+
+    def get(self, nodeid, propname):
+        ''' trap the content propname and get it from the file
+        '''
+        if propname == 'content':
+            return self.getcontent(self.classname, nodeid)
+        return Class.get(self, nodeid, propname)
+
+    def getprops(self):
+        ''' In addition to the actual properties on the node, these methods
+            provide the "content" property.
+        '''
+        d = Class.getprops(self).copy()
+        d['content'] = hyperdb.String()
+        return d
+
+# XXX deviation from spec
+class IssueClass(Class):
+    # Overridden methods:
+
+    def __init__(self, db, classname, **properties):
+        """The newly-created class automatically includes the "messages",
+        "files", "nosy", and "superseder" properties.  If the 'properties'
+        dictionary attempts to specify any of these properties or a
+        "creation" or "activity" property, a ValueError is raised."""
+        if not properties.has_key('title'):
+            properties['title'] = hyperdb.String()
+        if not properties.has_key('messages'):
+            properties['messages'] = hyperdb.Multilink("msg")
+        if not properties.has_key('files'):
+            properties['files'] = hyperdb.Multilink("file")
+        if not properties.has_key('nosy'):
+            properties['nosy'] = hyperdb.Multilink("user")
+        if not properties.has_key('superseder'):
+            properties['superseder'] = hyperdb.Multilink("issue")
+        if (properties.has_key('creation') or properties.has_key('activity')
+                or properties.has_key('creator')):
+            raise ValueError, '"creation", "activity" and "creator" are reserved'
+        Class.__init__(self, db, classname, **properties)
+
+    def get(self, nodeid, propname):
+        if propname == 'creation':
+            return self.db.getjournal(self.classname, nodeid)[0][1]
+        if propname == 'activity':
+            return self.db.getjournal(self.classname, nodeid)[-1][1]
+        if propname == 'creator':
+            name = self.db.getjournal(self.classname, nodeid)[0][2]
+            return self.db.user.lookup(name)
+        return Class.get(self, nodeid, propname)
+
+    def getprops(self):
+        """In addition to the actual properties on the node, these
+        methods provide the "creation" and "activity" properties."""
+        d = Class.getprops(self).copy()
+        d['creation'] = hyperdb.Date()
+        d['activity'] = hyperdb.Date()
+        d['creator'] = hyperdb.Link("user")
+        return d
+
+    # New methods:
+
+    def addmessage(self, nodeid, summary, text):
+        """Add a message to an issue's mail spool.
+
+        A new "msg" node is constructed using the current date, the user that
+        owns the database connection as the author, and the specified summary
+        text.
+
+        The "files" and "recipients" fields are left empty.
+
+        The given text is saved as the body of the message and the node is
+        appended to the "messages" field of the specified issue.
+        """
+
+    def sendmessage(self, nodeid, msgid):
+        """Send a message to the members of an issue's nosy list.
+
+        The message is sent only to users on the nosy list who are not
+        already on the "recipients" list for the message.
+        
+        These users are then added to the message's "recipients" list.
+        """
+        # figure the recipient ids
+        recipients = self.db.msg.get(msgid, 'recipients')
+        r = {}
+        for recipid in recipients:
+            r[recipid] = 1
+        authid = self.db.msg.get(msgid, 'author')
+        r[authid] = 1
+
+        # now figure the nosy people who weren't recipients
+        sendto = []
+        nosy = self.get(nodeid, 'nosy')
+        for nosyid in nosy:
+            if not r.has_key(nosyid):
+                sendto.append(nosyid)
+                recipients.append(nosyid)
+
+        if sendto:
+            # update the message's recipients list
+            self.db.msg.set(msgid, recipients=recipients)
+
+            # send an email to the people who missed out
+            sendto = [self.db.user.get(i, 'address') for i in recipients]
+            cn = self.classname
+            title = self.get(nodeid, 'title') or '%s message copy'%cn
+            m = ['Subject: [%s%s] %s'%(cn, nodeid, title)]
+            m.append('To: %s'%', '.join(sendto))
+            m.append('Reply-To: %s'%config.ISSUE_TRACKER_EMAIL)
+            m.append('')
+            m.append(self.db.msg.get(msgid, 'content'))
+            # TODO attachments
+            try:
+                smtp = smtplib.SMTP(config.MAILHOST)
+                smtp.sendmail(config.ISSUE_TRACKER_EMAIL, sendto, '\n'.join(m))
+            except socket.error, value:
+                return "Couldn't send confirmation email: mailhost %s"%value
+            except smtplib.SMTPException, value:
+                return "Couldn't send confirmation email: %s"%value
+
+def nosyreaction(db, cl, nodeid, oldvalues):
+    ''' A standard detector is provided that watches for additions to the
+        "messages" property.
+        
+        When a new message is added, the detector sends it to all the users on
+        the "nosy" list for the issue that are not already on the "recipients"
+        list of the message.
+        
+        Those users are then appended to the "recipients" property on the
+        message, so multiple copies of a message are never sent to the same
+        user.
+        
+        The journal recorded by the hyperdatabase on the "recipients" property
+        then provides a log of when the message was sent to whom. 
+    '''
+    messages = []
+    if oldvalues is None:
+        # the action was a create, so use all the messages in the create
+        messages = cl.get(nodeid, 'messages')
+    elif oldvalues.has_key('messages'):
+        # the action was a set (so adding new messages to an existing issue)
+        m = {}
+        for msgid in oldvalues['messages']:
+            m[msgid] = 1
+        messages = []
+        # figure which of the messages now on the issue weren't there before
+        for msgid in cl.get(nodeid, 'messages'):
+            if not m.has_key(msgid):
+                messages.append(msgid)
+    if not messages:
+        return
+
+    # send a copy to the nosy list
+    for msgid in messages:
+        cl.sendmessage(nodeid, msgid)
+
+    # update the nosy list with the recipients from the new messages
+    nosy = cl.get(nodeid, 'nosy')
+    n = {}
+    for nosyid in nosy: n[nosyid] = 1
+    change = 0
+    # but don't add admin to the nosy list
+    for msgid in messages:
+        for recipid in db.msg.get(msgid, 'recipients'):
+            if recipid != '1' and not n.has_key(recipid):
+                change = 1
+                nosy.append(recipid)
+        authid = db.msg.get(msgid, 'author')
+        if authid != '1' and not n.has_key(authid):
+            change = 1
+            nosy.append(authid)
+    if change:
+        cl.set(nodeid, nosy=nosy)
+
+def openDB(storagelocator, name=None, password=None):
+    ''' Open the Roundup DB
+
+        ... configs up all the classes etc
+    '''
+    db = Database(storagelocator, name)
+    pri = Class(db, "priority", name=hyperdb.String(), order=hyperdb.String())
+    pri.setkey("name")
+    stat = Class(db, "status", name=hyperdb.String(), order=hyperdb.String())
+    stat.setkey("name")
+    Class(db, "keyword", name=hyperdb.String())
+    user = Class(db, "user", username=hyperdb.String(),
+        password=hyperdb.String(), address=hyperdb.String(),
+        realname=hyperdb.String(), phone=hyperdb.String(),
+        organisation=hyperdb.String())
+    user.setkey("username")
+    msg = FileClass(db, "msg", author=hyperdb.Link("user"),
+        recipients=hyperdb.Multilink("user"), date=hyperdb.Date(),
+        summary=hyperdb.String(), files=hyperdb.Multilink("file"))
+    file = FileClass(db, "file", name=hyperdb.String(), type=hyperdb.String())
+
+    # bugs and support calls etc
+    rate = Class(db, "rate", name=hyperdb.String(), order=hyperdb.String())
+    rate.setkey("name")
+    source = Class(db, "source", name=hyperdb.String(), order=hyperdb.String())
+    source.setkey("name")
+    platform = Class(db, "platform", name=hyperdb.String(), order=hyperdb.String())
+    platform.setkey("name")
+    product = Class(db, "product", name=hyperdb.String(), order=hyperdb.String())
+    product.setkey("name")
+    Class(db, "timelog", date=hyperdb.Date(), time=hyperdb.String(),
+        performedby=hyperdb.Link("user"), description=hyperdb.String())
+    issue = IssueClass(db, "issue", assignedto=hyperdb.Link("user"),
+        priority=hyperdb.Link("priority"), status=hyperdb.Link("status"),
+        rate=hyperdb.Link("rate"), source=hyperdb.Link("source"),
+        product=hyperdb.Link("product"), platform=hyperdb.Multilink("platform"),
+        version=hyperdb.String(),
+        timelog=hyperdb.Multilink("timelog"), customername=hyperdb.String())
+    issue.setkey('title')
+    issue.react('create', nosyreaction)
+    issue.react('set', nosyreaction)
+    return db
+
+def initDB(storagelocator, password):
+    ''' Initialise the Roundup DB for use
+    '''
+    dbdir = os.path.join(storagelocator, 'files')
+    if not os.path.isdir(dbdir):
+        os.makedirs(dbdir)
+    db = openDB(storagelocator, "admin")
+    db.clear()
+    pri = db.getclass('priority')
+    pri.create(name="fatal-bug", order="1")
+    pri.create(name="bug", order="2")
+    pri.create(name="usability", order="3")
+    pri.create(name="feature", order="4")
+
+    stat = db.getclass('status')
+    stat.create(name="unread", order="1")
+    stat.create(name="deferred", order="2")
+    stat.create(name="chatting", order="3")
+    stat.create(name="need-eg", order="4")
+    stat.create(name="in-progress", order="5")
+    stat.create(name="testing", order="6")
+    stat.create(name="done-cbb", order="7")
+    stat.create(name="resolved", order="8")
+
+    rate = db.getclass("rate")
+    rate.create(name='basic', order="1")
+    rate.create(name='premium', order="2")
+    rate.create(name='internal', order="3")
+
+    source = db.getclass("source")
+    source.create(name='phone', order="1")
+    source.create(name='e-mail', order="2")
+    source.create(name='internal', order="3")
+    source.create(name='internal-qa', order="4")
+
+    platform = db.getclass("platform")
+    platform.create(name='linux', order="1")
+    platform.create(name='windows', order="2")
+    platform.create(name='mac', order="3")
+
+    product = db.getclass("product")
+    product.create(name='Bizar Shop', order="1")
+    product.create(name='Bizar Shop Developer', order="2")
+    product.create(name='Bizar Shop Manual', order="3")
+    product.create(name='Bizar Shop Developer Manual', order="4")
+
+    user = db.getclass('user')
+    user.create(username="admin", password=password, address=config.ADMIN_EMAIL)
+
+    db.close()
+
diff --git a/server.py b/server.py
new file mode 100755 (executable)
index 0000000..b9f2dc2
--- /dev/null
+++ b/server.py
@@ -0,0 +1,150 @@
+#!/usr/bin/python
+""" HTTP Server that serves roundup.
+
+Stolen from CGIHTTPServer
+
+"""
+import sys
+if int(sys.version[0]) < 2:
+    print "Content-Type: text/plain\n"
+    print "Roundup requires Python 2.0 or newer."
+
+__version__ = "0.1"
+
+__all__ = ["CGIHTTPRequestHandler"]
+
+import os, urllib, StringIO, traceback, cgi, binascii
+import BaseHTTPServer
+import SimpleHTTPServer
+import date, hyperdb, template, roundupdb, roundup_cgi
+import cgitb
+
+class RoundupRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
+    def send_head(self):
+        """Version of send_head that support CGI scripts"""
+        return self.run_cgi()
+
+    def run_cgi(self):
+        """Execute a CGI script."""
+        rest = self.path
+        i = rest.rfind('?')
+        if i >= 0:
+            rest, query = rest[:i], rest[i+1:]
+        else:
+            query = ''
+
+        # Set up the CGI environment
+        env = {}
+        env['REQUEST_METHOD'] = self.command
+        env['PATH_INFO'] = urllib.unquote(rest)
+        if query:
+            env['QUERY_STRING'] = query
+        host = self.address_string()
+        if self.headers.typeheader is None:
+            env['CONTENT_TYPE'] = self.headers.type
+        else:
+            env['CONTENT_TYPE'] = self.headers.typeheader
+        length = self.headers.getheader('content-length')
+        if length:
+            env['CONTENT_LENGTH'] = length
+        co = filter(None, self.headers.getheaders('cookie'))
+        if co:
+            env['HTTP_COOKIE'] = ', '.join(co)
+        env['SCRIPT_NAME'] = ''
+        env['SERVER_NAME'] = self.server.server_name
+        env['SERVER_PORT'] = str(self.server.server_port)
+
+        decoded_query = query.replace('+', ' ')
+
+        # if root, setuid to nobody
+        if not os.getuid():
+            nobody = nobody_uid()
+            os.setuid(nobody)
+
+        # TODO check for file timestamp changes
+        reload(date)
+        reload(hyperdb)
+        reload(roundupdb)
+        reload(template)
+        reload(roundup_cgi)
+
+        # initialise the roundupdb, check for auth
+        db = roundupdb.openDB('db', 'admin')
+        message = 'Unauthorised'
+        auth = self.headers.getheader('authorization')
+        if auth:
+            l = binascii.a2b_base64(auth.split(' ')[1]).split(':')
+            user = l[0]
+            password = None
+            if len(l) > 1:
+                password = l[1]
+            try:
+                uid = db.user.lookup(user)
+            except KeyError:
+                auth = None
+                message = 'Username not recognised'
+            else:
+                if password != db.user.get(uid, 'password'):
+                    message = 'Incorrect password'
+                    auth = None
+        db.close()
+        del db
+        if not auth:
+            self.send_response(401)
+            self.send_header('Content-Type', 'text/html')
+            self.send_header('WWW-Authenticate', 'basic realm="Roundup"')
+            self.end_headers()
+            self.wfile.write(message)
+            return
+
+        self.send_response(200, "Script output follows")
+
+        # do the roundup thang
+        save_stdin = sys.stdin
+        try:
+            sys.stdin = self.rfile
+            client = roundup_cgi.Client(self.wfile, env, user)
+            client.main()
+        except roundup_cgi.Unauthorised:
+            self.wfile.write('Content-Type: text/html\n')
+            self.wfile.write('Status: 403\n')
+            self.wfile.write('Unauthorised')
+        except:
+            try:
+                reload(cgitb)
+                self.wfile.write(cgitb.breaker())
+                self.wfile.write(cgitb.html())
+            except:
+                self.wfile.write("Content-Type: text/html\n\n")
+                self.wfile.write("<pre>")
+                s = StringIO.StringIO()
+                traceback.print_exc(None, s)
+                self.wfile.write(cgi.escape(s.getvalue()))
+                self.wfile.write("</pre>\n")
+        sys.stdin = save_stdin
+    do_POST = run_cgi
+
+
+nobody = None
+
+def nobody_uid():
+    """Internal routine to get nobody's uid"""
+    global nobody
+    if nobody:
+        return nobody
+    try:
+        import pwd
+    except ImportError:
+        return -1
+    try:
+        nobody = pwd.getpwnam('nobody')[2]
+    except KeyError:
+        nobody = 1 + max(map(lambda x: x[2], pwd.getpwall()))
+    return nobody
+
+if __name__ == '__main__':
+    address = ('dirk.adroit', 9080)
+    httpd = BaseHTTPServer.HTTPServer(address, RoundupRequestHandler)
+    print 'Roundup server started on', address
+    httpd.serve_forever()
+
diff --git a/style.css b/style.css
new file mode 100644 (file)
index 0000000..2316c7c
--- /dev/null
+++ b/style.css
@@ -0,0 +1,163 @@
+h1 {
+  font-family: Verdana, Helvetica, sans-serif; 
+  font-size: 18pt; 
+  font-weight: bold; 
+}
+
+h2 {
+  font-family: Verdana, Helvetica, sans-serif; 
+  font-size: 16pt; 
+  font-weight: bold; 
+}
+
+h3 {
+  font-family: Verdana, Helvetica, sans-serif; 
+  font-size: 12pt; 
+  font-weight: bold; 
+}
+
+a:hover {  
+  font-family: Verdana, Helvetica, sans-serif; 
+  text-decoration: underline;
+  color: #333333; 
+}
+
+a:link {
+  font-family: Verdana, Helvetica, sans-serif; 
+  text-decoration: none;
+  color: #000099;
+}
+
+a {
+  font-family: Verdana, Helvetica, sans-serif; 
+  text-decoration: none;
+  color: #000099;
+}
+
+p {
+  font-family: Verdana, Helvetica, sans-serif;
+  font-size: 10pt;
+  color: #333333;
+}
+
+th {
+  font-family: Verdana, Helvetica, sans-serif; 
+  font-weight: bold;
+  font-size: 10pt; 
+  color: #333333;
+}
+
+.form-help {
+  font-family: Verdana, Helvetica, sans-serif;
+  font-size: 10pt;
+  color: #333333;
+}
+
+.std-text {
+  font-family: Verdana, Helvetica, sans-serif;
+  font-size: 10pt;
+  color: #333333;
+}
+
+.tab-small {
+  font-family: Verdana, Helvetica, sans-serif; 
+  font-size: 8pt; 
+  color: #333333;
+}
+
+.location-bar {
+  background-color: #efefef;
+  border: none;
+}
+
+.strong-header {
+  font-family: Verdana, Helvetica, sans-serif;
+  font-size: 12pt;
+  font-weight: bold;
+  background-color: #000000;
+  color: #ffffff;
+}
+
+.list-header {
+  background-color: #c0c0c0;
+  border: none;
+}
+
+.list-item {
+  font-family: Verdana, Helvetica, sans-serif; 
+  font-size: 10pt; 
+}
+
+.list-nav {
+  font-family: Verdana, Helvetica, sans-serif; 
+  font-size: 10pt; 
+  font-weight: bold;
+}
+
+.row-normal {
+  background-color: #ffffff;
+  border: none;
+
+}
+
+.row-hilite {
+  background-color: #efefef;
+  border: none;
+}
+
+.section-bar {
+  background-color: #c0c0c0;
+  border: none;
+}
+
+.system-msg {
+  font-family: Verdana, Helvetica, sans-serif; 
+  font-size: 10pt; 
+  background-color: #ffffff;
+  border:  1px solid #000000;
+  margin-bottom: 6px;
+  margin-top: 6px;
+  padding: 4px;
+  width: 100%;
+  color: #660033;
+}
+
+.form-title {
+  font-family: Verdana, Helvetica, sans-serif; 
+  font-weight: bold;
+  font-size: 12pt; 
+  color: #333333;
+}
+
+.form-label {
+  font-family: Verdana, Helvetica, sans-serif; 
+  font-weight: bold;
+  font-size: 10pt; 
+  color: #333333;
+}
+
+.form-optional {
+  font-family: Verdana, Helvetica, sans-serif; 
+  font-weight: bold;
+  font-style: italic;
+  font-size: 10pt; 
+  color: #333333;
+}
+
+.form-element {
+  font-family: Verdana, Helvetica, aans-serif;
+  font-size: 10pt;
+  color: #000000;
+}
+
+.form-text {
+  font-family: Verdana, Helvetica, sans-serif;
+  font-size: 10pt;
+  color: #333333;
+}
+
+.form-mono {
+  font-family: monospace;
+  font-size: 12px;
+  text-decoration: none;
+}
diff --git a/template.py b/template.py
new file mode 100644 (file)
index 0000000..8b0e4ca
--- /dev/null
@@ -0,0 +1,692 @@
+import os, re, StringIO, urllib
+
+import hyperdb, date
+
+class Base:
+    def __init__(self, db, classname, nodeid=None, form=None):
+        self.db, self.classname, self.nodeid = db, classname, nodeid
+        self.form = form
+        self.cl = self.db.classes[self.classname]
+        self.properties = self.cl.getprops()
+
+class Plain(Base):
+    ''' display a String property directly;
+
+        display a Date property in a specified time zone with an option to
+        omit the time from the date stamp;
+
+        for a Link or Multilink property, display the key strings of the
+        linked nodes (or the ids if the linked class has no key property)
+    '''
+    def __call__(self, property):
+        if not self.nodeid and self.form is None:
+            return '[Field: not called from item]'
+        propclass = self.properties[property]
+        if self.nodeid:
+            value = self.cl.get(self.nodeid, property)
+        else:
+            # TODO: pull the value from the form
+            if propclass.isMultilinkType: value = []
+            else: value = ''
+        if propclass.isStringType:
+            if value is None: value = ''
+            else: value = str(value)
+        elif propclass.isDateType:
+            value = str(value)
+        elif propclass.isIntervalType:
+            value = str(value)
+        elif propclass.isLinkType:
+            linkcl = self.db.classes[propclass.classname]
+            if value: value = str(linkcl.get(value, linkcl.getkey()))
+            else: value = '[unselected]'
+        elif propclass.isMultilinkType:
+            linkcl = self.db.classes[propclass.classname]
+            k = linkcl.getkey()
+            value = ', '.join([linkcl.get(i, k) for i in value])
+        else:
+            s = 'Plain: bad propclass "%s"'%propclass
+        return value
+
+class Field(Base):
+    ''' display a property like the plain displayer, but in a text field
+        to be edited
+    '''
+    def __call__(self, property, size=None, height=None, showid=0):
+        if not self.nodeid and self.form is None:
+            return '[Field: not called from item]'
+        propclass = self.properties[property]
+        if self.nodeid:
+            value = self.cl.get(self.nodeid, property)
+        else:
+            # TODO: pull the value from the form
+            if propclass.isMultilinkType: value = []
+            else: value = ''
+        if (propclass.isStringType or propclass.isDateType or
+                propclass.isIntervalType):
+            size = size or 30
+            if value is None:
+                value = ''
+            s = '<input name="%s" value="%s" size="%s">'%(property, value, size)
+        elif propclass.isLinkType:
+            linkcl = self.db.classes[propclass.classname]
+            l = ['<select name="%s">'%property]
+            k = linkcl.getkey()
+            for optionid in linkcl.list():
+                option = linkcl.get(optionid, k)
+                s = ''
+                if optionid == value:
+                    s = 'selected '
+                if showid:
+                    lab = '%s%s: %s'%(propclass.classname, optionid, option)
+                else:
+                    lab = option
+                if size is not None and len(lab) > size:
+                    lab = lab[:size-3] + '...'
+                l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
+            l.append('</select>')
+            s = '\n'.join(l)
+        elif propclass.isMultilinkType:
+            linkcl = self.db.classes[propclass.classname]
+            list = linkcl.list()
+            height = height or min(len(list), 7)
+            l = ['<select multiple name="%s" size="%s">'%(property, height)]
+            k = linkcl.getkey()
+            for optionid in list:
+                option = linkcl.get(optionid, k)
+                s = ''
+                if optionid in value:
+                    s = 'selected '
+                if showid:
+                    lab = '%s%s: %s'%(propclass.classname, optionid, option)
+                else:
+                    lab = option
+                if size is not None and len(lab) > size:
+                    lab = lab[:size-3] + '...'
+                l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
+            l.append('</select>')
+            s = '\n'.join(l)
+        else:
+            s = 'Plain: bad propclass "%s"'%propclass
+        return s
+
+class Menu(Base):
+    ''' for a Link property, display a menu of the available choices
+    '''
+    def __call__(self, property, size=None, height=None, showid=0):
+        propclass = self.properties[property]
+        if self.nodeid:
+            value = self.cl.get(self.nodeid, property)
+        else:
+            # TODO: pull the value from the form
+            if propclass.isMultilinkType: value = []
+            else: value = None
+        if propclass.isLinkType:
+            linkcl = self.db.classes[propclass.classname]
+            l = ['<select name="%s">'%property]
+            k = linkcl.getkey()
+            for optionid in linkcl.list():
+                option = linkcl.get(optionid, k)
+                s = ''
+                if optionid == value:
+                    s = 'selected '
+                l.append('<option %svalue="%s">%s</option>'%(s, optionid, option))
+            l.append('</select>')
+            return '\n'.join(l)
+        if propclass.isMultilinkType:
+            linkcl = self.db.classes[propclass.classname]
+            list = linkcl.list()
+            height = height or min(len(list), 7)
+            l = ['<select multiple name="%s" size="%s">'%(property, height)]
+            k = linkcl.getkey()
+            for optionid in list:
+                option = linkcl.get(optionid, k)
+                s = ''
+                if optionid in value:
+                    s = 'selected '
+                if showid:
+                    lab = '%s%s: %s'%(propclass.classname, optionid, option)
+                else:
+                    lab = option
+                if size is not None and len(lab) > size:
+                    lab = lab[:size-3] + '...'
+                l.append('<option %svalue="%s">%s</option>'%(s, optionid, option))
+            l.append('</select>')
+            return '\n'.join(l)
+        return '[Menu: not a link]'
+
+#XXX deviates from spec
+class Link(Base):
+    ''' for a Link or Multilink property, display the names of the linked
+        nodes, hyperlinked to the item views on those nodes
+        for other properties, link to this node with the property as the text
+    '''
+    def __call__(self, property=None, **args):
+        if not self.nodeid and self.form is None:
+            return '[Link: not called from item]'
+        propclass = self.properties[property]
+        if self.nodeid:
+            value = self.cl.get(self.nodeid, property)
+        else:
+            if propclass.isMultilinkType: value = []
+            else: value = ''
+        if propclass.isLinkType:
+            linkcl = self.db.classes[propclass.classname]
+            linkvalue = linkcl.get(value, k)
+            return '<a href="%s%s">%s</a>'%(linkcl, value, linkvalue)
+        if propclass.isMultilinkType:
+            linkcl = self.db.classes[propclass.classname]
+            l = []
+            for value in value:
+                linkvalue = linkcl.get(value, k)
+                l.append('<a href="%s%s">%s</a>'%(linkcl, value, linkvalue))
+            return ', '.join(l)
+        return '<a href="%s%s">%s</a>'%(self.classname, self.nodeid, value)
+
+class Count(Base):
+    ''' for a Multilink property, display a count of the number of links in
+        the list
+    '''
+    def __call__(self, property, **args):
+        if not self.nodeid:
+            return '[Count: not called from item]'
+        propclass = self.properties[property]
+        value = self.cl.get(self.nodeid, property)
+        if propclass.isMultilinkType:
+            return str(len(value))
+        return '[Count: not a Multilink]'
+
+# XXX pretty is definitely new ;)
+class Reldate(Base):
+    ''' display a Date property in terms of an interval relative to the
+        current date (e.g. "+ 3w", "- 2d").
+
+        with the 'pretty' flag, make it pretty
+    '''
+    def __call__(self, property, pretty=0):
+        if not self.nodeid and self.form is None:
+            return '[Reldate: not called from item]'
+        propclass = self.properties[property]
+        if not propclass.isDateType:
+            return '[Reldate: not a Date]'
+        if self.nodeid:
+            value = self.cl.get(self.nodeid, property)
+        else:
+            value = date.Date('.')
+        interval = value - date.Date('.')
+        if pretty:
+            if not self.nodeid:
+                return 'now'
+            pretty = interval.pretty()
+            if pretty is None:
+                pretty = value.pretty()
+            return pretty
+        return str(interval)
+
+class Download(Base):
+    ''' show a Link("file") or Multilink("file") property using links that
+        allow you to download files
+    '''
+    def __call__(self, property, **args):
+        if not self.nodeid:
+            return '[Download: not called from item]'
+        propclass = self.properties[property]
+        value = self.cl.get(self.nodeid, property)
+        if propclass.isLinkType:
+            linkcl = self.db.classes[propclass.classname]
+            linkvalue = linkcl.get(value, k)
+            return '<a href="%s%s">%s</a>'%(linkcl, value, linkvalue)
+        if propclass.isMultilinkType:
+            linkcl = self.db.classes[propclass.classname]
+            l = []
+            for value in value:
+                linkvalue = linkcl.get(value, k)
+                l.append('<a href="%s%s">%s</a>'%(linkcl, value, linkvalue))
+            return ', '.join(l)
+        return '[Download: not a link]'
+
+
+class Checklist(Base):
+    ''' for a Link or Multilink property, display checkboxes for the available
+        choices to permit filtering
+    '''
+    def __call__(self, property, **args):
+        propclass = self.properties[property]
+        if self.nodeid:
+            value = self.cl.get(self.nodeid, property)
+        else:
+            value = []
+        if propclass.isLinkType or propclass.isMultilinkType:
+            linkcl = self.db.classes[propclass.classname]
+            l = []
+            k = linkcl.getkey()
+            for optionid in linkcl.list():
+                option = linkcl.get(optionid, k)
+                if optionid in value:
+                    checked = 'checked'
+                else:
+                    checked = ''
+                l.append('%s:<input type="checkbox" %s name="%s" value="%s">'%(
+                    option, checked, propclass.classname, option))
+            return '\n'.join(l)
+        return '[Checklist: not a link]'
+
+class Note(Base):
+    ''' display a "note" field, which is a text area for entering a note to
+        go along with a change. 
+    '''
+    def __call__(self, rows=5, cols=80):
+       # TODO: pull the value from the form
+        return '<textarea name="__note" rows=%s cols=%s></textarea>'%(rows,
+            cols)
+
+# XXX new function
+class List(Base):
+    ''' list the items specified by property using the standard index for
+        the class
+    '''
+    def __call__(self, property, **args):
+        propclass = self.properties[property]
+        if not propclass.isMultilinkType:
+            return '[List: not a Multilink]'
+        fp = StringIO.StringIO()
+        args['show_display_form'] = 0
+        value = self.cl.get(self.nodeid, property)
+        index(fp, self.db, propclass.classname, nodeids=value,
+            show_display_form=0)
+        return fp.getvalue()
+
+# XXX new function
+class History(Base):
+    ''' list the history of the item
+    '''
+    def __call__(self, **args):
+        l = ['<table width=100% border=0 cellspacing=0 cellpadding=2>',
+            '<tr class="list-header">',
+            '<td><span class="list-item"><strong>Date</strong></span></td>',
+            '<td><span class="list-item"><strong>User</strong></span></td>',
+            '<td><span class="list-item"><strong>Action</strong></span></td>',
+            '<td><span class="list-item"><strong>Args</strong></span></td>']
+
+        for id, date, user, action, args in self.cl.history(self.nodeid):
+            l.append('<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>'%(
+               date, user, action, args))
+        l.append('</table>')
+        return '\n'.join(l)
+
+# XXX new function
+class Submit(Base):
+    ''' add a submit button for the item
+    '''
+    def __call__(self):
+        if self.nodeid:
+            return '<input type="submit" value="Submit Changes">'
+        elif self.form is not None:
+            return '<input type="submit" value="Submit New Entry">'
+        else:
+            return '[Submit: not called from item]'
+
+
+#
+#   INDEX TEMPLATES
+#
+class IndexTemplateReplace:
+    def __init__(self, globals, locals, props):
+        self.globals = globals
+        self.locals = locals
+        self.props = props
+
+    def go(self, text, replace=re.compile(
+            r'((<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
+            r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I|re.S)):
+        return replace.sub(self, text)
+        
+    def __call__(self, m, filter=None, columns=None, sort=None, group=None):
+        if m.group('name'):
+            if m.group('name') in self.props:
+                text = m.group('text')
+                replace = IndexTemplateReplace(self.globals, {}, self.props)
+                return replace.go(m.group('text'))
+            else:
+                return ''
+        if m.group('display'):
+            command = m.group('command')
+            return eval(command, self.globals, self.locals)
+        print '*** unhandled match', m.groupdict()
+
+def sortby(sort_name, columns, filter, sort, group, filterspec):
+    l = []
+    w = l.append
+    for k, v in filterspec.items():
+        k = urllib.quote(k)
+        if type(v) == type([]):
+            w('%s=%s'%(k, ','.join(map(urllib.quote, v))))
+        else:
+            w('%s=%s'%(k, urllib.quote(v)))
+    if columns:
+        w(':columns=%s'%','.join(map(urllib.quote, columns)))
+    if filter:
+        w(':filter=%s'%','.join(map(urllib.quote, filter)))
+    if group:
+        w(':group=%s'%','.join(map(urllib.quote, group)))
+    m = []
+    s_dir = ''
+    for name in sort:
+        dir = name[0]
+        if dir == '-':
+            dir = ''
+        else:
+            name = name[1:]
+        if sort_name == name:
+            if dir == '':
+                s_dir = '-'
+            elif dir == '-':
+                s_dir = ''
+        else:
+            m.append(dir+urllib.quote(name))
+    m.insert(0, s_dir+urllib.quote(sort_name))
+    # so things don't get completely out of hand, limit the sort to two columns
+    w(':sort=%s'%','.join(m[:2]))
+    return '&'.join(l)
+
+def index(fp, db, classname, filterspec={}, filter=[], columns=[], sort=[],
+            group=[], show_display_form=1, nodeids=None,
+            col_re=re.compile(r'<property\s+name="([^>]+)">')):
+
+    globals = {
+        'plain': Plain(db, classname, form={}),
+        'field': Field(db, classname, form={}),
+        'menu': Menu(db, classname, form={}),
+        'link': Link(db, classname, form={}),
+        'count': Count(db, classname, form={}),
+        'reldate': Reldate(db, classname, form={}),
+        'download': Download(db, classname, form={}),
+        'checklist': Checklist(db, classname, form={}),
+        'list': List(db, classname, form={}),
+        'history': History(db, classname, form={}),
+        'submit': Submit(db, classname, form={}),
+        'note': Note(db, classname, form={})
+    }
+    cl = db.classes[classname]
+    properties = cl.getprops()
+    w = fp.write
+
+    try:
+        template = open(os.path.join('templates', classname+'.filter')).read()
+        all_filters = col_re.findall(template)
+    except IOError, error:
+        if error.errno != 2: raise
+        template = None
+        all_filters = []
+    if template and filter:
+        # display the filter section
+        w('<form>')
+        w('<table width=100% border=0 cellspacing=0 cellpadding=2>')
+        w('<tr class="location-bar">')
+        w(' <th align="left" colspan="2">Filter specification...</th>')
+        w('</tr>')
+        replace = IndexTemplateReplace(globals, locals(), filter)
+        w(replace.go(template))
+        if columns:
+            w('<input type="hidden" name=":columns" value="%s">'%','.join(columns))
+        if filter:
+            w('<input type="hidden" name=":filter" value="%s">'%','.join(filter))
+        if sort:
+            w('<input type="hidden" name=":sort" value="%s">'%','.join(sort))
+        if group:
+            w('<input type="hidden" name=":group" value="%s">'%','.join(group))
+        for k, v in filterspec.items():
+            if type(v) == type([]): v = ','.join(v)
+            w('<input type="hidden" name="%s" value="%s">'%(k, v))
+        w('<tr class="location-bar"><td width="1%%">&nbsp;</td>')
+        w('<td><input type="submit" value="Redisplay"></td></tr>')
+        w('</table>')
+        w('</form>')
+
+    # XXX deviate from spec here ...
+    # load the index section template and figure the default columns from it
+    template = open(os.path.join('templates', classname+'.index')).read()
+    all_columns = col_re.findall(template)
+    if not columns:
+        columns = []
+        for name in all_columns:
+            columns.append(name)
+    else:
+        # re-sort columns to be the same order as all_columns
+        l = []
+        for name in all_columns:
+            if name in columns:
+                l.append(name)
+        columns = l
+
+    # now display the index section
+    w('<table width=100% border=0 cellspacing=0 cellpadding=2>')
+    w('<tr class="list-header">')
+    for name in columns:
+        cname = name.capitalize()
+        if show_display_form:
+            anchor = "%s?%s"%(classname, sortby(name, columns, filter,
+                sort, group, filterspec))
+            w('<td><span class="list-item"><a href="%s">%s</a></span></td>'%(
+                anchor, cname))
+        else:
+            w('<td><span class="list-item">%s</span></td>'%cname)
+    w('</tr>')
+
+    # this stuff is used for group headings - optimise the group names
+    old_group = None
+    group_names = []
+    if group:
+        for name in group:
+            if name[0] == '-': group_names.append(name[1:])
+            else: group_names.append(name)
+
+    # now actually loop through all the nodes we get from the filter and
+    # apply the template
+    if nodeids is None:
+        nodeids = cl.filter(filterspec, sort, group)
+    for nodeid in nodeids:
+        # check for a group heading
+        if group_names:
+            this_group = [cl.get(nodeid, name) for name in group_names]
+            if this_group != old_group:
+                l = []
+                for name in group_names:
+                    prop = properties[name]
+                    if prop.isLinkType:
+                        group_cl = db.classes[prop.classname]
+                        key = group_cl.getkey()
+                        value = cl.get(nodeid, name)
+                        if value is None:
+                            l.append('[unselected %s]'%prop.classname)
+                        else:
+                            l.append(group_cl.get(cl.get(nodeid, name), key))
+                    elif prop.isMultilinkType:
+                        group_cl = db.classes[prop.classname]
+                        key = group_cl.getkey()
+                        for value in cl.get(nodeid, name):
+                            l.append(group_cl.get(value, key))
+                    else:
+                        l.append(cl.get(nodeid, name))
+                w('<tr class="list-header">'
+                  '<td align=left colspan=%s><strong>%s</strong></td></tr>'%(
+                    len(columns), ', '.join(l)))
+                old_group = this_group
+
+        # display this node's row
+        for value in globals.values():
+            if hasattr(value, 'nodeid'):
+                value.nodeid = nodeid
+        replace = IndexTemplateReplace(globals, locals(), columns)
+        w(replace.go(template))
+
+    w('</table>')
+
+    if not show_display_form:
+        return
+
+    # now add in the filter/columns/group/etc config table form
+    w('<p><form>')
+    w('<table width=100% border=0 cellspacing=0 cellpadding=2>')
+    for k,v in filterspec.items():
+        if type(v) == type([]): v = ','.join(v)
+        w('<input type="hidden" name="%s" value="%s">'%(k, v))
+    if sort:
+        w('<input type="hidden" name=":sort" value="%s">'%','.join(sort))
+    names = []
+    for name in cl.getprops().keys():
+        if name in all_filters or name in all_columns:
+            names.append(name)
+    w('<tr class="location-bar">')
+    w('<th align="left" colspan=%s>View customisation...</th></tr>'%
+        (len(names)+1))
+    w('<tr class="location-bar"><th>&nbsp;</th>')
+    for name in names:
+        w('<th>%s</th>'%name.capitalize())
+    w('</tr>')
+
+    # filter
+    if all_filters:
+        w('<tr><th width="1%" align=right class="location-bar">Filters</th>')
+        for name in names:
+            if name not in all_filters:
+                w('<td>&nbsp;</td>')
+                continue
+            if name in filter: checked=' checked'
+            else: checked=''
+            w('<td align=middle>')
+            w('<input type="checkbox" name=":filter" value="%s" %s></td>'%(name,
+                checked))
+        w('</tr>')
+
+    # columns
+    if all_columns:
+        w('<tr><th width="1%" align=right class="location-bar">Columns</th>')
+        for name in names:
+            if name not in all_columns:
+                w('<td>&nbsp;</td>')
+                continue
+            if name in columns: checked=' checked'
+            else: checked=''
+            w('<td align=middle>')
+            w('<input type="checkbox" name=":columns" value="%s" %s></td>'%(
+                name, checked))
+        w('</tr>')
+
+        # group
+        w('<tr><th width="1%" align=right class="location-bar">Grouping</th>')
+        for name in names:
+            prop = properties[name]
+            if name not in all_columns:
+                w('<td>&nbsp;</td>')
+                continue
+            if name in group: checked=' checked'
+            else: checked=''
+            w('<td align=middle>')
+            w('<input type="checkbox" name=":group" value="%s" %s></td>'%(
+                name, checked))
+        w('</tr>')
+
+    w('<tr class="location-bar"><td width="1%">&nbsp;</td>')
+    w('<td colspan="%s">'%len(names))
+    w('<input type="submit" value="Redisplay"></td></tr>')
+    w('</table>')
+    w('</form>')
+
+
+#
+#   ITEM TEMPLATES
+#
+class ItemTemplateReplace:
+    def __init__(self, globals, locals, cl, nodeid):
+        self.globals = globals
+        self.locals = locals
+        self.cl = cl
+        self.nodeid = nodeid
+
+    def go(self, text, replace=re.compile(
+            r'((<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
+            r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I|re.S)):
+        return replace.sub(self, text)
+
+    def __call__(self, m, filter=None, columns=None, sort=None, group=None):
+        if m.group('name'):
+            if self.nodeid and self.cl.get(self.nodeid, m.group('name')):
+                replace = ItemTemplateReplace(self.globals, {}, self.cl,
+                    self.nodeid)
+                return replace.go(m.group('text'))
+            else:
+                return ''
+        if m.group('display'):
+            command = m.group('command')
+            return eval(command, self.globals, self.locals)
+        print '*** unhandled match', m.groupdict()
+
+def item(fp, db, classname, nodeid, replace=re.compile(
+            r'((?P<prop><property\s+name="(?P<propname>[^>]+)">)|'
+            r'(?P<endprop></property>)|'
+            r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I)):
+
+    globals = {
+        'plain': Plain(db, classname, nodeid),
+        'field': Field(db, classname, nodeid),
+        'menu': Menu(db, classname, nodeid),
+        'link': Link(db, classname, nodeid),
+        'count': Count(db, classname, nodeid),
+        'reldate': Reldate(db, classname, nodeid),
+        'download': Download(db, classname, nodeid),
+        'checklist': Checklist(db, classname, nodeid),
+        'list': List(db, classname, nodeid),
+        'history': History(db, classname, nodeid),
+        'submit': Submit(db, classname, nodeid),
+        'note': Note(db, classname, nodeid)
+    }
+
+    cl = db.classes[classname]
+    properties = cl.getprops()
+
+    if properties.has_key('type') and properties.has_key('content'):
+        pass
+        # XXX we really want to return this as a downloadable...
+        #  currently I handle this at a higher level by detecting 'file'
+        #  designators...
+
+    w = fp.write
+    w('<form action="%s%s">'%(classname, nodeid))
+    s = open(os.path.join('templates', classname+'.item')).read()
+    replace = ItemTemplateReplace(globals, locals(), cl, nodeid)
+    w(replace.go(s))
+    w('</form>')
+
+
+def newitem(fp, db, classname, form, replace=re.compile(
+            r'((?P<prop><property\s+name="(?P<propname>[^>]+)">)|'
+            r'(?P<endprop></property>)|'
+            r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I)):
+    globals = {
+        'plain': Plain(db, classname, form=form),
+        'field': Field(db, classname, form=form),
+        'menu': Menu(db, classname, form=form),
+        'link': Link(db, classname, form=form),
+        'count': Count(db, classname, form=form),
+        'reldate': Reldate(db, classname, form=form),
+        'download': Download(db, classname, form=form),
+        'checklist': Checklist(db, classname, form=form),
+        'list': List(db, classname, form=form),
+        'history': History(db, classname, form=form),
+        'submit': Submit(db, classname, form=form),
+        'note': Note(db, classname, form=form)
+    }
+
+    cl = db.classes[classname]
+    properties = cl.getprops()
+
+    w = fp.write
+    try:
+        s = open(os.path.join('templates', classname+'.newitem')).read()
+    except:
+        s = open(os.path.join('templates', classname+'.item')).read()
+    w('<form action="new%s">'%classname)
+    replace = ItemTemplateReplace(globals, locals(), None, None)
+    w(replace.go(s))
+    w('</form>')
+
diff --git a/templates/file.index b/templates/file.index
new file mode 100644 (file)
index 0000000..54e23fb
--- /dev/null
@@ -0,0 +1,8 @@
+<tr>
+    <property name="name">
+        <td><display call="link('name')"></td>
+    </property>
+    <property name="type">
+        <td><display call="plain('type')"></td>
+    </property>
+</tr>
diff --git a/templates/issue.filter b/templates/issue.filter
new file mode 100644 (file)
index 0000000..0d03fee
--- /dev/null
@@ -0,0 +1,36 @@
+<property name="title">
+ <tr><th width="1%" align="right" class="location-bar">Title</th>
+ <td><display call="field('title')"></td></tr>
+</property>
+<property name="status">
+ <tr><th width="1%" align="right" class="location-bar">Status</th>
+ <td><display call="checklist('status')"></td></tr>
+</property>
+<property name="priority">
+ <tr><th width="1%" align="right" class="location-bar">Priority</th>
+ <td><display call="checklist('priority')"></td></tr>
+</property>
+<property name="platform">
+ <tr><th width="1%" align="right" class="location-bar">Platform</th>
+ <td><display call="checklist('platform')"></td></tr>
+</property>
+<property name="product">
+ <tr><th width="1%" align="right" class="location-bar">Product</th>
+ <td><display call="checklist('product')"></td></tr>
+</property>
+<property name="version">
+ <tr><th width="1%" align="right" class="location-bar">Version</th>
+ <td><display call="field('version')"></td></tr>
+</property>
+<property name="source">
+ <tr><th width="1%" align="right" class="location-bar">Source</th>
+ <td><display call="checklist('source')"></td></tr>
+</property>
+<property name="assignedto">
+ <tr><th width="1%" align="right" class="location-bar">Assigned&nbsp;to</th>
+ <td><display call="checklist('assignedto')"></td></tr>
+</property>
+<property name="customername">
+ <tr><th width="1%" align="right" class="location-bar">Customer&nbsp;name</th>
+ <td><display call="field('customername')"></td></tr>
+</property>
diff --git a/templates/issue.index b/templates/issue.index
new file mode 100644 (file)
index 0000000..b7848f8
--- /dev/null
@@ -0,0 +1,32 @@
+<tr>
+    <property name="activity">
+        <td valign="top"><display call="reldate('activity', pretty=1)"></td>
+    </property>
+    <property name="priority">
+        <td valign="top"><display call="plain('priority')"></td>
+    </property>
+    <property name="status">
+        <td valign="top"><display call="plain('status')"></td>
+    </property>
+    <property name="title">
+        <td valign="top"><display call="link('title')"></td>
+    </property>
+    <property name="platform">
+        <td valign="top"><display call="plain('platform')"></td>
+    </property>
+    <property name="product">
+        <td valign="top"><display call="plain('product')"></td>
+    </property>
+    <property name="version">
+        <td valign="top"><display call="plain('version')"></td>
+    </property>
+    <property name="source">
+        <td valign="top"><display call="plain('source')"></td>
+    </property>
+    <property name="assignedto">
+        <td valign="top"><display call="plain('assignedto')"></td>
+    </property>
+    <property name="customername">
+        <td valign="top"><display call="plain('customername')"></td>
+    </property>
+</tr>
diff --git a/templates/issue.item b/templates/issue.item
new file mode 100644 (file)
index 0000000..20aa427
--- /dev/null
@@ -0,0 +1,85 @@
+<table border=0 cellspacing=0 cellpadding=2>
+
+<tr class="strong-header">
+  <td colspan=4>Item Information</td>
+</td>
+
+<tr  bgcolor="ffffea">
+    <td width=1% nowrap align=right><span class="form-label">Title</span></td>
+    <td colspan=3 class="form-text"><display call="field('title', size=80)"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+    <td width=1% nowrap align=right><span class="form-label">Product</span></td>
+    <td class="form-text" valign=middle><display call="menu('product')">
+    version:<display call="field('version', 5)"></td>
+    <td width=1% nowrap align=right><span class="form-label">Platform</span></td>
+    <td class="form-text" valign=middle><display call="checklist('platform')"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+    <td width=1% nowrap align=right><span class="form-label">Created</span></td>
+    <td class="form-text"><display call="reldate('creation', pretty=1)">
+        (<display call="plain('creator')">)</td>
+    <td width=1% nowrap align=right><span class="form-label">Last activity</span></td>
+    <td class="form-text"><display call="reldate('activity', pretty=1)"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+    <td width=1% nowrap align=right><span class="form-label">Priority</span></td>
+    <td class="form-text"><display call="field('priority')"></td>
+    <td width=1% nowrap align=right><span class="form-label">Source</span></td>
+    <td class="form-text"><display call="field('source')"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+    <td width=1% nowrap align=right><span class="form-label">Status</span></td>
+    <td class="form-text"><display call="menu('status')"></td>
+    <td width=1% nowrap align=right><span class="form-label">Rate</span></td>
+    <td class="form-text"><display call="field('rate')"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+    <td width=1% nowrap align=right><span class="form-label">Assigned To</span></td>
+    <td class="form-text"><display call="field('assignedto')"></td>
+    <td width=1% nowrap align=right><span class="form-label">Customer Name</span></td>
+    <td class="form-text"><display call="field('customername')"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+    <td width=1% nowrap align=right><span class="form-label">Superseder</span></td>
+    <td class="form-text"><display call="field('superseder', size=40, showid=1)"></td>
+    <td width=1% nowrap align=right><span class="form-label">Nosy List</span></td>
+    <td class="form-text"><display call="field('nosy')"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+    <td width=1% nowrap align=right><span class="form-label">Change Note</span></td>
+    <td colspan=3 class="form-text"><display call="note()"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+    <td>&nbsp;</td>
+    <td colspan=3 class="form-text"><display call="submit()"></td>
+</tr>
+
+<property name="messages">
+<tr class="strong-header">
+    <td colspan=4><b>Messages</b></td>
+</tr>
+<tr>            
+    <td colspan=4><display call="list('messages')"></td>
+</tr>
+</property>
+
+<property name="files">
+ <tr class="strong-header">
+     <td colspan=4><b>Files</b></td>
+ </tr>
+ <tr>            
+     <td colspan=4><display call="list('files')"></td>
+ </tr>
+</property>
+
+</table>
+
diff --git a/templates/msg.index b/templates/msg.index
new file mode 100644 (file)
index 0000000..06f5472
--- /dev/null
@@ -0,0 +1,11 @@
+<tr>
+    <property name="date">
+        <td><display call="link('date')"></td>
+    </property>
+    <property name="author">
+        <td><display call="plain('author')"></td>
+    </property>
+    <property name="summary">
+        <td><display call="plain('summary')"></td>
+    </property>
+</tr>
diff --git a/templates/msg.item b/templates/msg.item
new file mode 100644 (file)
index 0000000..2f9bbeb
--- /dev/null
@@ -0,0 +1,36 @@
+<table border=0 cellspacing=0 cellpadding=2>
+
+<tr class="strong-header">
+  <td colspan=2>Message Information</td>
+</td>
+
+<tr bgcolor="ffffea">
+    <td width=1% nowrap align=right><span class="form-label">Author</span></td>
+    <td class="form-text"><display call="plain('author')"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+    <td width=1% nowrap align=right><span class="form-label">Recipients</span></td>
+    <td class="form-text"><display call="plain('recipients')"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+    <td width=1% nowrap align=right><span class="form-label">Date</span></td>
+    <td class="form-text"><display call="plain('date')"></td>
+</tr>
+
+<tr bgcolor="ffeaff">
+ <td colspan=2 class="form-text">
+  <pre><display call="plain('content')"></pre>
+ </td>
+</tr>
+
+<property name="files">
+<tr class="strong-header"><td colspan=2><b>Files</b></td></tr>
+<tr><td colspan=2><display call="list('files')"></td></tr>
+</property>
+
+<tr class="strong-header"><td colspan=2><b>History</b></td><tr>
+<tr><td colspan=2><display call="history()"></td></tr>
+
+</table>
diff --git a/templates/user.index b/templates/user.index
new file mode 100644 (file)
index 0000000..a8b101f
--- /dev/null
@@ -0,0 +1,17 @@
+<tr>
+    <property name="username">
+        <td><display call="link('username')"></td>
+    </property>
+    <property name="realname">
+        <td><display call="plain('realname')"></td>
+    </property>
+    <property name="organisation">
+        <td><display call="plain('organisation')"></td>
+    </property>
+    <property name="address">
+        <td><display call="plain('address')"></td>
+    </property>
+    <property name="phone">
+        <td><display call="plain('phone')"></td>
+    </property>
+</tr>
diff --git a/templates/user.item b/templates/user.item
new file mode 100644 (file)
index 0000000..53c3775
--- /dev/null
@@ -0,0 +1,45 @@
+<table border=0 cellspacing=0 cellpadding=2>
+
+<tr class="strong-header">
+  <td colspan=2>Your Details</td>
+</td>
+
+<tr  bgcolor="ffffea">
+    <td width=1% nowrap align=right><span class="form-label">Name</span></td>
+    <td class="form-text"><display call="field('realname', size=40)"></td>
+</tr>
+<tr  bgcolor="ffffea">
+    <td width=1% nowrap align=right><span class="form-label">Login Name</span></td>
+    <td class="form-text"><display call="field('username', size=40)"></td>
+</tr>
+<tr  bgcolor="ffffea">
+    <td width=1% nowrap align=right><span class="form-label">Login Password</span></td>
+    <td class="form-text"><display call="field('password', size=10)"></td>
+</tr>
+<tr  bgcolor="ffffea">
+    <td width=1% nowrap align=right><span class="form-label">Phone</span></td>
+    <td class="form-text"><display call="field('phone', size=40)"></td>
+</tr>
+<tr  bgcolor="ffffea">
+    <td width=1% nowrap align=right><span class="form-label">Organisation</span></td>
+    <td class="form-text"><display call="field('organisation', size=40)"></td>
+</tr>
+<tr  bgcolor="ffffea">
+    <td width=1% nowrap align=right><span class="form-label">E-mail address</span></td>
+    <td class="form-text"><display call="field('address', size=40)"></td>
+</tr>
+
+<tr bgcolor="ffffea">
+    <td>&nbsp;</td>
+    <td class="form-text"><display call="submit()"></td>
+</tr>
+
+<tr class="strong-header">
+    <td colspan=2><b>History</b></td>
+</tr>
+<tr>
+    <td colspan=2><display call="history()"></td>
+</tr>
+
+</table>
+