Code

much nicer error messages when there's a templating error
[roundup.git] / roundup / cgi / templating.py
index a01fa7c768b2740d13e176a2e2eb12b0e22989f2..d29f31d6600aac87eae1d8eae92f55a511c90a2b 100644 (file)
-import sys, cgi, urllib, os
+import sys, cgi, urllib, os, re, os.path, time, errno
 
 from roundup import hyperdb, date
 from roundup.i18n import _
 
 
 from roundup import hyperdb, date
 from roundup.i18n import _
 
-
+try:
+    import cPickle as pickle
+except ImportError:
+    import pickle
+try:
+    import cStringIO as StringIO
+except ImportError:
+    import StringIO
 try:
     import StructuredText
 except ImportError:
     StructuredText = None
 
 try:
     import StructuredText
 except ImportError:
     StructuredText = None
 
-# Make sure these modules are loaded
-# I need these to run PageTemplates outside of Zope :(
-# If we're running in a Zope environment, these modules will be loaded
-# already...
-if not sys.modules.has_key('zLOG'):
-    import zLOG
-    sys.modules['zLOG'] = zLOG
-if not sys.modules.has_key('MultiMapping'):
-    import MultiMapping
-    sys.modules['MultiMapping'] = MultiMapping
-if not sys.modules.has_key('ComputedAttribute'):
-    import ComputedAttribute
-    sys.modules['ComputedAttribute'] = ComputedAttribute
-if not sys.modules.has_key('ExtensionClass'):
-    import ExtensionClass
-    sys.modules['ExtensionClass'] = ExtensionClass
-if not sys.modules.has_key('Acquisition'):
-    import Acquisition
-    sys.modules['Acquisition'] = Acquisition
-
-# now it's safe to import PageTemplates and ZTUtils
-from PageTemplates import PageTemplate
-import ZTUtils
+# bring in the templating support
+from roundup.cgi.PageTemplates import PageTemplate
+from roundup.cgi.PageTemplates.Expressions import getEngine
+from roundup.cgi.TAL.TALInterpreter import TALInterpreter
+from roundup.cgi import ZTUtils
+
+# XXX WAH pagetemplates aren't pickleable :(
+#def getTemplate(dir, name, classname=None, request=None):
+#    ''' Interface to get a template, possibly loading a compiled template.
+#    '''
+#    # source
+#    src = os.path.join(dir, name)
+#
+#    # see if we can get a compile from the template"c" directory (most
+#    # likely is "htmlc"
+#    split = list(os.path.split(dir))
+#    split[-1] = split[-1] + 'c'
+#    cdir = os.path.join(*split)
+#    split.append(name)
+#    cpl = os.path.join(*split)
+#
+#    # ok, now see if the source is newer than the compiled (or if the
+#    # compiled even exists)
+#    MTIME = os.path.stat.ST_MTIME
+#    if (not os.path.exists(cpl) or os.stat(cpl)[MTIME] < os.stat(src)[MTIME]):
+#        # nope, we need to compile
+#        pt = RoundupPageTemplate()
+#        pt.write(open(src).read())
+#        pt.id = name
+#
+#        # save off the compiled template
+#        if not os.path.exists(cdir):
+#            os.makedirs(cdir)
+#        f = open(cpl, 'wb')
+#        pickle.dump(pt, f)
+#        f.close()
+#    else:
+#        # yay, use the compiled template
+#        f = open(cpl, 'rb')
+#        pt = pickle.load(f)
+#    return pt
+
+templates = {}
+
+def getTemplate(dir, name, extension, classname=None, request=None):
+    ''' Interface to get a template, possibly loading a compiled template.
+
+        "name" and "extension" indicate the template we're after, which in
+        most cases will be "name.extension". If "extension" is None, then
+        we look for a template just called "name" with no extension.
+
+        If the file "name.extension" doesn't exist, we look for
+        "_generic.extension" as a fallback.
+    '''
+    # default the name to "home"
+    if name is None:
+        name = 'home'
+
+    # find the source, figure the time it was last modified
+    if extension:
+        filename = '%s.%s'%(name, extension)
+    else:
+        filename = name
+    src = os.path.join(dir, filename)
+    try:
+        stime = os.stat(src)[os.path.stat.ST_MTIME]
+    except os.error, error:
+        if error.errno != errno.ENOENT or not extension:
+            raise
+        # try for a generic template
+        filename = '_generic.%s'%extension
+        src = os.path.join(dir, filename)
+        stime = os.stat(src)[os.path.stat.ST_MTIME]
+
+    key = (dir, filename)
+    if templates.has_key(key) and stime < templates[key].mtime:
+        # compiled template is up to date
+        return templates[key]
+
+    # compile the template
+    templates[key] = pt = RoundupPageTemplate()
+    pt.write(open(src).read())
+    pt.id = filename
+    pt.mtime = time.time()
+    return pt
 
 class RoundupPageTemplate(PageTemplate.PageTemplate):
     ''' A Roundup-specific PageTemplate.
 
 class RoundupPageTemplate(PageTemplate.PageTemplate):
     ''' A Roundup-specific PageTemplate.
@@ -77,36 +146,46 @@ class RoundupPageTemplate(PageTemplate.PageTemplate):
           python modules made available (XXX: not sure what's actually in
           there tho)
     '''
           python modules made available (XXX: not sure what's actually in
           there tho)
     '''
-    def __init__(self, client, classname=None, request=None):
-        ''' Extract the vars from the client and install in the context.
-        '''
-        self.client = client
-        self.classname = classname or self.client.classname
-        self.request = request or HTMLRequest(self.client)
-
-    def pt_getContext(self):
+    def getContext(self, client, classname, request):
         c = {
         c = {
-             'klass': HTMLClass(self.client, self.classname),
+             'klass': HTMLClass(client, classname),
              'options': {},
              'nothing': None,
              'options': {},
              'nothing': None,
-             'request': self.request,
-             'content': self.client.content,
-             'db': HTMLDatabase(self.client),
-             'instance': self.client.instance
+             'request': request,
+             'content': client.content,
+             'db': HTMLDatabase(client),
+             'instance': client.instance
         }
         # add in the item if there is one
         }
         # add in the item if there is one
-        if self.client.nodeid:
-            c['item'] = HTMLItem(self.client.db, self.classname,
-                self.client.nodeid)
-            c[self.classname] = c['item']
+        if client.nodeid:
+            c['item'] = HTMLItem(client.db, classname, client.nodeid)
+            c[classname] = c['item']
         else:
         else:
-            c[self.classname] = c['klass']
+            c[classname] = c['klass']
         return c
         return c
-   
-    def render(self, *args, **kwargs):
-        if not kwargs.has_key('args'):
-            kwargs['args'] = args
-        return self.pt_render(extra_context={'options': kwargs})
+
+    def render(self, client, classname, request, **options):
+        """Render this Page Template"""
+
+        if not self._v_cooked:
+            self._cook()
+
+        __traceback_supplement__ = (PageTemplate.PageTemplateTracebackSupplement, self)
+
+        if self._v_errors:
+            raise PTRuntimeError, 'Page Template %s has errors.' % self.id
+
+        # figure the context
+        classname = classname or client.classname
+        request = request or HTMLRequest(client)
+        c = self.getContext(client, classname, request)
+        c.update({'options': options})
+
+        # and go
+        output = StringIO.StringIO()
+        TALInterpreter(self._v_program, self._v_macros,
+            getEngine().getContext(c), output, tal=1, strictinsert=0)()
+        return output.getvalue()
 
 class HTMLDatabase:
     ''' Return HTMLClasses for valid class fetches
 
 class HTMLDatabase:
     ''' Return HTMLClasses for valid class fetches
@@ -115,7 +194,10 @@ class HTMLDatabase:
         self.client = client
         self.config = client.db.config
     def __getattr__(self, attr):
         self.client = client
         self.config = client.db.config
     def __getattr__(self, attr):
-        self.client.db.getclass(attr)
+        try:
+            self.client.db.getclass(attr)
+        except KeyError:
+            raise AttributeError, attr
         return HTMLClass(self.client, attr)
     def classes(self):
         l = self.client.db.classes.keys()
         return HTMLClass(self.client, attr)
     def classes(self):
         l = self.client.db.classes.keys()
@@ -136,15 +218,15 @@ class HTMLClass:
     def __repr__(self):
         return '<HTMLClass(0x%x) %s>'%(id(self), self.classname)
 
     def __repr__(self):
         return '<HTMLClass(0x%x) %s>'%(id(self), self.classname)
 
-    def __getattr__(self, attr):
+    def __getitem__(self, item):
         ''' return an HTMLItem instance'''
         ''' return an HTMLItem instance'''
-        #print 'getattr', (self, attr)
-        if attr == 'creator':
+        #print 'getitem', (self, attr)
+        if item == 'creator':
             return HTMLUser(self.client)
 
             return HTMLUser(self.client)
 
-        if not self.props.has_key(attr):
-            raise AttributeError, attr
-        prop = self.props[attr]
+        if not self.props.has_key(item):
+            raise KeyError, item
+        prop = self.props[item]
 
         # look up the correct HTMLProperty class
         for klass, htmlklass in propclasses:
 
         # look up the correct HTMLProperty class
         for klass, htmlklass in propclasses:
@@ -153,10 +235,17 @@ class HTMLClass:
             else:
                 value = None
             if isinstance(prop, klass):
             else:
                 value = None
             if isinstance(prop, klass):
-                return htmlklass(self.db, '', prop, attr, value)
+                return htmlklass(self.db, '', prop, item, value)
 
         # no good
 
         # no good
-        raise AttributeError, attr
+        raise KeyError, item
+
+    def __getattr__(self, attr):
+        ''' convenience access '''
+        try:
+            return self[attr]
+        except KeyError:
+            raise AttributeError, attr
 
     def properties(self):
         ''' Return HTMLProperty for all props
 
     def properties(self):
         ''' Return HTMLProperty for all props
@@ -176,6 +265,40 @@ class HTMLClass:
         l = [HTMLItem(self.db, self.classname, x) for x in self.klass.list()]
         return l
 
         l = [HTMLItem(self.db, self.classname, x) for x in self.klass.list()]
         return l
 
+    def csv(self):
+        ''' Return the items of this class as a chunk of CSV text.
+        '''
+        # get the CSV module
+        try:
+            import csv
+        except ImportError:
+            return 'Sorry, you need the csv module to use this function.\n'\
+                'Get it from: http://www.object-craft.com.au/projects/csv/'
+
+        props = self.propnames()
+        p = csv.parser()
+        s = StringIO.StringIO()
+        s.write(p.join(props) + '\n')
+        for nodeid in self.klass.list():
+            l = []
+            for name in props:
+                value = self.klass.get(nodeid, name)
+                if value is None:
+                    l.append('')
+                elif isinstance(value, type([])):
+                    l.append(':'.join(map(str, value)))
+                else:
+                    l.append(str(self.klass.get(nodeid, name)))
+            s.write(p.join(l) + '\n')
+        return s.getvalue()
+
+    def propnames(self):
+        ''' Return the list of the names of the properties of this class.
+        '''
+        idlessprops = self.klass.getprops(protected=0).keys()
+        idlessprops.sort()
+        return ['id'] + idlessprops
+
     def filter(self, request=None):
         ''' Return a list of items from this class, filtered and sorted
             by the current requested filterspec/filter/sort/group args
     def filter(self, request=None):
         ''' Return a list of items from this class, filtered and sorted
             by the current requested filterspec/filter/sort/group args
@@ -199,9 +322,9 @@ class HTMLClass:
            You may optionally override the label displayed, the width and
            height. The popup window will be resizable and scrollable.
         '''
            You may optionally override the label displayed, the width and
            height. The popup window will be resizable and scrollable.
         '''
-        return '<a href="javascript:help_window(\'classhelp?classname=%s&' \
-            'properties=%s\', \'%s\', \'%s\')"><b>(%s)</b></a>'%(self.classname,
-            properties, width, height, label)
+        return '<a href="javascript:help_window(\'%s?:template=help&' \
+            ':contentonly=1&properties=%s\', \'%s\', \'%s\')"><b>'\
+            '(%s)</b></a>'%(self.classname, properties, width, height, label)
 
     def submit(self, label="Submit New Entry"):
         ''' Generate a submit button (and action hidden element)
 
     def submit(self, label="Submit New Entry"):
         ''' Generate a submit button (and action hidden element)
@@ -218,19 +341,15 @@ class HTMLClass:
         # create a new request and override the specified args
         req = HTMLRequest(self.client)
         req.classname = self.classname
         # create a new request and override the specified args
         req = HTMLRequest(self.client)
         req.classname = self.classname
-        req.__dict__.update(kwargs)
+        req.update(kwargs)
 
         # new template, using the specified classname and request
 
         # new template, using the specified classname and request
-        pt = RoundupPageTemplate(self.client, self.classname, req)
-
-        # use the specified template
-        name = self.classname + '.' + name
-        pt.write(open('/tmp/test/html/%s'%name).read())
-        pt.id = name
+        pt = getTemplate(self.db.config.TEMPLATES, self.classname, name)
 
         # XXX handle PT rendering errors here nicely
         try:
 
         # XXX handle PT rendering errors here nicely
         try:
-            return pt.render()
+            # use our fabricated request
+            return pt.render(self.client, self.classname, req)
         except PageTemplate.PTRuntimeError, message:
             return '<strong>%s</strong><ol>%s</ol>'%(message,
                 cgi.escape('<li>'.join(pt._v_errors)))
         except PageTemplate.PTRuntimeError, message:
             return '<strong>%s</strong><ol>%s</ol>'%(message,
                 cgi.escape('<li>'.join(pt._v_errors)))
@@ -248,29 +367,33 @@ class HTMLItem:
     def __repr__(self):
         return '<HTMLItem(0x%x) %s %s>'%(id(self), self.classname, self.nodeid)
 
     def __repr__(self):
         return '<HTMLItem(0x%x) %s %s>'%(id(self), self.classname, self.nodeid)
 
-    def __getattr__(self, attr):
+    def __getitem__(self, item):
         ''' return an HTMLItem instance'''
         ''' return an HTMLItem instance'''
-        #print 'getattr', (self, attr)
-        if attr == 'id':
+        if item == 'id':
             return self.nodeid
             return self.nodeid
-
-        if not self.props.has_key(attr):
-            raise AttributeError, attr
-        prop = self.props[attr]
+        if not self.props.has_key(item):
+            raise KeyError, item
+        prop = self.props[item]
 
         # get the value, handling missing values
 
         # get the value, handling missing values
-        value = self.klass.get(self.nodeid, attr, None)
+        value = self.klass.get(self.nodeid, item, None)
         if value is None:
         if value is None:
-            if isinstance(self.props[attr], hyperdb.Multilink):
+            if isinstance(self.props[item], hyperdb.Multilink):
                 value = []
 
         # look up the correct HTMLProperty class
         for klass, htmlklass in propclasses:
             if isinstance(prop, klass):
                 value = []
 
         # look up the correct HTMLProperty class
         for klass, htmlklass in propclasses:
             if isinstance(prop, klass):
-                return htmlklass(self.db, self.nodeid, prop, attr, value)
+                return htmlklass(self.db, self.nodeid, prop, item, value)
 
 
-        # no good
-        raise AttributeError, attr
+        raise KeyErorr, item
+
+    def __getattr__(self, attr):
+        ''' convenience access to properties '''
+        try:
+            return self[attr]
+        except KeyError:
+            raise AttributeError, attr
     
     def submit(self, label="Submit Changes"):
         ''' Generate a submit button (and action hidden element)
     
     def submit(self, label="Submit Changes"):
         ''' Generate a submit button (and action hidden element)
@@ -613,7 +736,7 @@ class LinkHTMLProperty(HTMLProperty):
     def plain(self, escape=0):
         if self.value is None:
             return _('[unselected]')
     def plain(self, escape=0):
         if self.value is None:
             return _('[unselected]')
-        linkcl = self.db.classes[self.klass.classname]
+        linkcl = self.db.classes[self.prop.classname]
         k = linkcl.labelprop(1)
         value = str(linkcl.get(self.value, k))
         if escape:
         k = linkcl.labelprop(1)
         value = str(linkcl.get(self.value, k))
         if escape:
@@ -688,10 +811,10 @@ class LinkHTMLProperty(HTMLProperty):
             s = 'selected '
         l.append(_('<option %svalue="-1">- no selection -</option>')%s)
         if linkcl.getprops().has_key('order'):  
             s = 'selected '
         l.append(_('<option %svalue="-1">- no selection -</option>')%s)
         if linkcl.getprops().has_key('order'):  
-            sort_on = 'order'  
+            sort_on = ('+', 'order')
         else:  
         else:  
-            sort_on = linkcl.labelprop() 
-        options = linkcl.filter(None, conditions, [sort_on], []) 
+            sort_on = ('+', linkcl.labelprop())
+        options = linkcl.filter(None, conditions, sort_on, (None, None))
         for optionid in options:
             option = linkcl.get(optionid, k)
             s = ''
         for optionid in options:
             option = linkcl.get(optionid, k)
             s = ''
@@ -735,6 +858,12 @@ class MultilinkHTMLProperty(HTMLProperty):
         value = self.value[num]
         return HTMLItem(self.db, self.prop.classname, value)
 
         value = self.value[num]
         return HTMLItem(self.db, self.prop.classname, value)
 
+    def reverse(self):
+        ''' return the list in reverse order '''
+        l = self.value[:]
+        l.reverse()
+        return [HTMLItem(self.db, self.prop.classname, value) for value in l]
+
     def plain(self, escape=0):
         linkcl = self.db.classes[self.prop.classname]
         k = linkcl.labelprop(1)
     def plain(self, escape=0):
         linkcl = self.db.classes[self.prop.classname]
         k = linkcl.labelprop(1)
@@ -772,10 +901,10 @@ class MultilinkHTMLProperty(HTMLProperty):
 
         linkcl = self.db.getclass(self.prop.classname)
         if linkcl.getprops().has_key('order'):  
 
         linkcl = self.db.getclass(self.prop.classname)
         if linkcl.getprops().has_key('order'):  
-            sort_on = 'order'  
+            sort_on = ('+', 'order')
         else:  
         else:  
-            sort_on = linkcl.labelprop()
-        options = linkcl.filter(None, conditions, [sort_on], []
+            sort_on = ('+', linkcl.labelprop())
+        options = linkcl.filter(None, conditions, sort_on, (None,None)
         height = height or min(len(options), 7)
         l = ['<select multiple name="%s" size="%s">'%(self.name, height)]
         k = linkcl.labelprop(1)
         height = height or min(len(options), 7)
         l = ['<select multiple name="%s" size="%s">'%(self.name, height)]
         k = linkcl.labelprop(1)
@@ -834,11 +963,37 @@ def handleListCGIValue(value):
     else:
         return value.value.split(',')
 
     else:
         return value.value.split(',')
 
-# XXX This is starting to look a lot (in data terms) like the client object
-# itself!
+class ShowDict:
+    ''' A convenience access to the :columns index parameters
+    '''
+    def __init__(self, columns):
+        self.columns = {}
+        for col in columns:
+            self.columns[col] = 1
+    def __getitem__(self, name):
+        return self.columns.has_key(name)
+
 class HTMLRequest:
     ''' The *request*, holding the CGI form and environment.
 
 class HTMLRequest:
     ''' The *request*, holding the CGI form and environment.
 
+        "form" the CGI form as a cgi.FieldStorage
+        "env" the CGI environment variables
+        "url" the current URL path for this request
+        "base" the base URL for this instance
+        "user" a HTMLUser instance for this user
+        "classname" the current classname (possibly None)
+        "template" the current template (suffix, also possibly None)
+
+        Index args:
+        "columns" dictionary of the columns to display in an index page
+        "show" a convenience access to columns - request/show/colname will
+               be true if the columns should be displayed, false otherwise
+        "sort" index sort column (direction, column name)
+        "group" index grouping property (direction, column name)
+        "filter" properties to filter the index on
+        "filterspec" values to filter the index on
+        "search_text" text to perform a full-text search on for an index
+
     '''
     def __init__(self, client):
         self.client = client
     '''
     def __init__(self, client):
         self.client = client
@@ -847,30 +1002,91 @@ class HTMLRequest:
         self.form = client.form
         self.env = client.env
         self.base = client.base
         self.form = client.form
         self.env = client.env
         self.base = client.base
+        self.url = client.url
         self.user = HTMLUser(client)
 
         # store the current class name and action
         self.classname = client.classname
         self.user = HTMLUser(client)
 
         # store the current class name and action
         self.classname = client.classname
-        self.template_type = client.template_type
+        self.template = client.template
 
         # extract the index display information from the form
 
         # extract the index display information from the form
-        self.columns = {}
+        self.columns = []
         if self.form.has_key(':columns'):
         if self.form.has_key(':columns'):
-            for entry in handleListCGIValue(self.form[':columns']):
-                self.columns[entry] = 1
-        self.sort = []
+            self.columns = handleListCGIValue(self.form[':columns'])
+        self.show = ShowDict(self.columns)
+
+        # sorting
+        self.sort = (None, None)
         if self.form.has_key(':sort'):
         if self.form.has_key(':sort'):
-            self.sort = handleListCGIValue(self.form[':sort'])
-        self.group = []
+            sort = self.form[':sort'].value
+            if sort.startswith('-'):
+                self.sort = ('-', sort[1:])
+            else:
+                self.sort = ('+', sort)
+        if self.form.has_key(':sortdir'):
+            self.sort = ('-', self.sort[1])
+
+        # grouping
+        self.group = (None, None)
         if self.form.has_key(':group'):
         if self.form.has_key(':group'):
-            self.group = handleListCGIValue(self.form[':group'])
+            group = self.form[':group'].value
+            if group.startswith('-'):
+                self.group = ('-', group[1:])
+            else:
+                self.group = ('+', group)
+        if self.form.has_key(':groupdir'):
+            self.group = ('-', self.group[1])
+
+        # filtering
         self.filter = []
         if self.form.has_key(':filter'):
             self.filter = handleListCGIValue(self.form[':filter'])
         self.filterspec = {}
         self.filter = []
         if self.form.has_key(':filter'):
             self.filter = handleListCGIValue(self.form[':filter'])
         self.filterspec = {}
-        for name in self.filter:
-            if self.form.has_key(name):
-                self.filterspec[name]=handleListCGIValue(self.form[name])
+        if self.classname is not None:
+            props = self.client.db.getclass(self.classname).getprops()
+            for name in self.filter:
+                if self.form.has_key(name):
+                    prop = props[name]
+                    fv = self.form[name]
+                    if (isinstance(prop, hyperdb.Link) or
+                            isinstance(prop, hyperdb.Multilink)):
+                        self.filterspec[name] = handleListCGIValue(fv)
+                    else:
+                        self.filterspec[name] = fv.value
+
+        # full-text search argument
+        self.search_text = None
+        if self.form.has_key(':search_text'):
+            self.search_text = self.form[':search_text'].value
+
+        # pagination - size and start index
+        # figure batch args
+        if self.form.has_key(':pagesize'):
+            self.pagesize = int(self.form[':pagesize'].value)
+        else:
+            self.pagesize = 50
+        if self.form.has_key(':startwith'):
+            self.startwith = int(self.form[':startwith'].value)
+        else:
+            self.startwith = 0
+
+    def update(self, kwargs):
+        self.__dict__.update(kwargs)
+        if kwargs.has_key('columns'):
+            self.show = ShowDict(self.columns)
+
+    def description(self):
+        ''' Return a description of the request - handle for the page title.
+        '''
+        s = [self.client.db.config.INSTANCE_NAME]
+        if self.classname:
+            if self.client.nodeid:
+                s.append('- %s%s'%(self.classname, self.client.nodeid))
+            else:
+                s.append('- index of '+self.classname)
+        else:
+            s.append('- home')
+        return ' '.join(s)
 
     def __str__(self):
         d = {}
 
     def __str__(self):
         d = {}
@@ -885,45 +1101,78 @@ class HTMLRequest:
         d['env'] = e
         return '''
 form: %(form)s
         d['env'] = e
         return '''
 form: %(form)s
+url: %(url)r
 base: %(base)r
 classname: %(classname)r
 base: %(base)r
 classname: %(classname)r
-template_type: %(template_type)r
+template: %(template)r
 columns: %(columns)r
 sort: %(sort)r
 group: %(group)r
 filter: %(filter)r
 columns: %(columns)r
 sort: %(sort)r
 group: %(group)r
 filter: %(filter)r
-filterspec: %(filterspec)r
+search_text: %(search_text)r
+pagesize: %(pagesize)r
+startwith: %(startwith)r
 env: %(env)s
 '''%d
 
 env: %(env)s
 '''%d
 
-    def indexargs_form(self):
+    def indexargs_form(self, columns=1, sort=1, group=1, filter=1,
+            filterspec=1):
         ''' return the current index args as form elements '''
         l = []
         s = '<input type="hidden" name="%s" value="%s">'
         ''' return the current index args as form elements '''
         l = []
         s = '<input type="hidden" name="%s" value="%s">'
-        if self.columns:
-            l.append(s%(':columns', ','.join(self.columns.keys())))
-        if self.sort:
-            l.append(s%(':sort', ','.join(self.sort)))
-        if self.group:
-            l.append(s%(':group', ','.join(self.group)))
-        if self.filter:
+        if columns and self.columns:
+            l.append(s%(':columns', ','.join(self.columns)))
+        if sort and self.sort[1] is not None:
+            if self.sort[0] == '-':
+                val = '-'+self.sort[1]
+            else:
+                val = self.sort[1]
+            l.append(s%(':sort', val))
+        if group and self.group[1] is not None:
+            if self.group[0] == '-':
+                val = '-'+self.group[1]
+            else:
+                val = self.group[1]
+            l.append(s%(':group', val))
+        if filter and self.filter:
             l.append(s%(':filter', ','.join(self.filter)))
             l.append(s%(':filter', ','.join(self.filter)))
-        for k,v in self.filterspec.items():
-            l.append(s%(k, ','.join(v)))
+        if filterspec:
+            for k,v in self.filterspec.items():
+                l.append(s%(k, ','.join(v)))
+        if self.search_text:
+            l.append(s%(':search_text', self.search_text))
+        l.append(s%(':pagesize', self.pagesize))
+        l.append(s%(':startwith', self.startwith))
         return '\n'.join(l)
 
     def indexargs_href(self, url, args):
         return '\n'.join(l)
 
     def indexargs_href(self, url, args):
+        ''' embed the current index args in a URL '''
         l = ['%s=%s'%(k,v) for k,v in args.items()]
         l = ['%s=%s'%(k,v) for k,v in args.items()]
-        if self.columns:
-            l.append(':columns=%s'%(','.join(self.columns.keys())))
-        if self.sort:
-            l.append(':sort=%s'%(','.join(self.sort)))
-        if self.group:
-            l.append(':group=%s'%(','.join(self.group)))
-        if self.filter:
+        if self.columns and not args.has_key(':columns'):
+            l.append(':columns=%s'%(','.join(self.columns)))
+        if self.sort[1] is not None and not args.has_key(':sort'):
+            if self.sort[0] == '-':
+                val = '-'+self.sort[1]
+            else:
+                val = self.sort[1]
+            l.append(':sort=%s'%val)
+        if self.group[1] is not None and not args.has_key(':group'):
+            if self.group[0] == '-':
+                val = '-'+self.group[1]
+            else:
+                val = self.group[1]
+            l.append(':group=%s'%val)
+        if self.filter and not args.has_key(':columns'):
             l.append(':filter=%s'%(','.join(self.filter)))
         for k,v in self.filterspec.items():
             l.append(':filter=%s'%(','.join(self.filter)))
         for k,v in self.filterspec.items():
-            l.append('%s=%s'%(k, ','.join(v)))
+            if not args.has_key(k):
+                l.append('%s=%s'%(k, ','.join(v)))
+        if self.search_text and not args.has_key(':search_text'):
+            l.append(':search_text=%s'%self.search_text)
+        if not args.has_key(':pagesize'):
+            l.append(':pagesize=%s'%self.pagesize)
+        if not args.has_key(':startwith'):
+            l.append(':startwith=%s'%self.startwith)
         return '%s?%s'%(url, '&'.join(l))
 
     def base_javascript(self):
         return '%s?%s'%(url, '&'.join(l))
 
     def base_javascript(self):
@@ -954,25 +1203,26 @@ function help_window(helpurl, width, height) {
 
         # get the list of ids we're batching over
         klass = self.client.db.getclass(self.classname)
 
         # get the list of ids we're batching over
         klass = self.client.db.getclass(self.classname)
-        l = klass.filter(None, filterspec, sort, group)
-
-        # figure batch args
-        if self.form.has_key(':pagesize'):
-            size = int(self.form[':pagesize'].value)
-        else:
-            size = 50
-        if self.form.has_key(':startwith'):
-            start = int(self.form[':startwith'].value)
+        if self.search_text:
+            matches = self.client.db.indexer.search(
+                re.findall(r'\b\w{2,25}\b', self.search_text), klass)
         else:
         else:
-            start = 0
+            matches = None
+        l = klass.filter(matches, filterspec, sort, group)
 
         # return the batch object
 
         # return the batch object
-        return Batch(self.client, self.classname, l, size, start)
+        return Batch(self.client, self.classname, l, self.pagesize,
+            self.startwith)
+
 
 
+# extend the standard ZTUtils Batch object to remove dependency on
+# Acquisition and add a couple of useful methods
 class Batch(ZTUtils.Batch):
     def __init__(self, client, classname, l, size, start, end=0, orphan=0, overlap=0):
         self.client = client
         self.classname = classname
 class Batch(ZTUtils.Batch):
     def __init__(self, client, classname, l, size, start, end=0, orphan=0, overlap=0):
         self.client = client
         self.classname = classname
+        self.last_index = self.last_item = None
+        self.current_item = None
         ZTUtils.Batch.__init__(self, l, size, start, end, orphan, overlap)
 
     # overwrite so we can late-instantiate the HTMLItem instance
         ZTUtils.Batch.__init__(self, l, size, start, end, orphan, overlap)
 
     # overwrite so we can late-instantiate the HTMLItem instance
@@ -983,13 +1233,28 @@ class Batch(ZTUtils.Batch):
         
         if index >= self.length: raise IndexError, index
 
         
         if index >= self.length: raise IndexError, index
 
+        # move the last_item along - but only if the fetched index changes
+        # (for some reason, index 0 is fetched twice)
+        if index != self.last_index:
+            self.last_item = self.current_item
+            self.last_index = index
+
         # wrap the return in an HTMLItem
         # wrap the return in an HTMLItem
-        return HTMLItem(self.client.db, self.classname,
+        self.current_item = HTMLItem(self.client.db, self.classname,
             self._sequence[index+self.first])
             self._sequence[index+self.first])
+        return self.current_item
+
+    def propchanged(self, property):
+        ''' Detect if the property marked as being the group property
+            changed in the last iteration fetch
+        '''
+        if (self.last_item is None or
+                self.last_item[property] != self.current_item[property]):
+            return 1
+        return 0
 
     # override these 'cos we don't have access to acquisition
     def previous(self):
 
     # override these 'cos we don't have access to acquisition
     def previous(self):
-        print self.start
         if self.start == 1:
             return None
         return Batch(self.client, self.classname, self._sequence, self._size,
         if self.start == 1:
             return None
         return Batch(self.client, self.classname, self._sequence, self._size,