Code

Use same regex to split search terms as used to index text.
[roundup.git] / roundup / htmltemplate.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17
18 # $Id: htmltemplate.py,v 1.109 2002-08-01 15:06:08 gmcm Exp $
20 __doc__ = """
21 Template engine.
23 Three types of template files exist:
24   .index           used by IndexTemplate
25   .item            used by ItemTemplate and NewItemTemplate
26   .filter          used by IndexTemplate
28 Templating works by instantiating one of the *Template classes above,
29 passing in a handle to the cgi client, identifying the class and the
30 template source directory.
32 The *Template class reads in the appropriate template text, and when the
33 render() method is called, the template text is fed to an re.sub which
34 calls the subfunc and then all the funky do_* methods as required.
36 Templating is tested by the test_htmltemplate unit test suite. If you add
37 a template function, add a test for all data types or the angry pink bunny
38 will hunt you down.
39 """
41 import sys, os, re, StringIO, urllib, cgi, errno, types, urllib
43 import hyperdb, date
44 from i18n import _
46 # This imports the StructureText functionality for the do_stext function
47 # get it from http://dev.zope.org/Members/jim/StructuredTextWiki/NGReleases
48 try:
49     from StructuredText.StructuredText import HTML as StructuredText
50 except ImportError:
51     StructuredText = None
53 class MissingTemplateError(ValueError):
54     '''Error raised when a template file is missing
55     '''
56     pass
58 class TemplateFunctions:
59     '''Defines the templating functions that are used in the HTML templates
60        of the roundup web interface.
61     '''
62     def __init__(self):
63         self.form = None
64         self.nodeid = None
65         self.filterspec = None
66         self.globals = {}
67         for key in TemplateFunctions.__dict__.keys():
68             if key[:3] == 'do_':
69                 self.globals[key[3:]] = getattr(self, key)
71         # These are added by the subclass where appropriate
72         self.client = None
73         self.instance = None
74         self.templates = None
75         self.classname = None
76         self.db = None
77         self.cl = None
78         self.properties = None
80     def clear(self):
81         for key in TemplateFunctions.__dict__.keys():
82             if key[:3] == 'do_':
83                 del self.globals[key[3:]]
85     def do_plain(self, property, escape=0, lookup=1):
86         ''' display a String property directly;
88             display a Date property in a specified time zone with an option to
89             omit the time from the date stamp;
91             for a Link or Multilink property, display the key strings of the
92             linked nodes (or the ids if the linked class has no key property)
93             when the lookup argument is true, otherwise just return the
94             linked ids
95         '''
96         if not self.nodeid and self.form is None:
97             return _('[Field: not called from item]')
98         propclass = self.properties[property]
99         if self.nodeid:
100             # make sure the property is a valid one
101             # TODO: this tests, but we should handle the exception
102             dummy = self.cl.getprops()[property]
104             # get the value for this property
105             try:
106                 value = self.cl.get(self.nodeid, property)
107             except KeyError:
108                 # a KeyError here means that the node doesn't have a value
109                 # for the specified property
110                 if isinstance(propclass, hyperdb.Multilink): value = []
111                 else: value = ''
112         else:
113             # TODO: pull the value from the form
114             if isinstance(propclass, hyperdb.Multilink): value = []
115             else: value = ''
116         if isinstance(propclass, hyperdb.String):
117             if value is None: value = ''
118             else: value = str(value)
119         elif isinstance(propclass, hyperdb.Password):
120             if value is None: value = ''
121             else: value = _('*encrypted*')
122         elif isinstance(propclass, hyperdb.Date):
123             # this gives "2002-01-17.06:54:39", maybe replace the "." by a " ".
124             value = str(value)
125         elif isinstance(propclass, hyperdb.Interval):
126             value = str(value)
127         elif isinstance(propclass, hyperdb.Number):
128             value = str(value)
129         elif isinstance(propclass, hyperdb.Boolean):
130             value = value and "Yes" or "No"
131         elif isinstance(propclass, hyperdb.Link):
132             if value:
133                 if lookup:
134                     linkcl = self.db.classes[propclass.classname]
135                     k = linkcl.labelprop(1)
136                     value = linkcl.get(value, k)
137             else:
138                 value = _('[unselected]')
139         elif isinstance(propclass, hyperdb.Multilink):
140             if lookup:
141                 linkcl = self.db.classes[propclass.classname]
142                 k = linkcl.labelprop(1)
143                 labels = []
144                 for v in value:
145                     labels.append(linkcl.get(v, k))
146                 value = ', '.join(labels)
147             else:
148                 value = ', '.join(value)
149         else:
150             value = _('Plain: bad propclass "%(propclass)s"')%locals()
151         if escape:
152             value = cgi.escape(value)
153         return value
155     def do_stext(self, property, escape=0):
156         '''Render as structured text using the StructuredText module
157            (see above for details)
158         '''
159         s = self.do_plain(property, escape=escape)
160         if not StructuredText:
161             return s
162         return StructuredText(s,level=1,header=0)
164     def determine_value(self, property):
165         '''determine the value of a property using the node, form or
166            filterspec
167         '''
168         propclass = self.properties[property]
169         if self.nodeid:
170             value = self.cl.get(self.nodeid, property, None)
171             if isinstance(propclass, hyperdb.Multilink) and value is None:
172                 return []
173             return value
174         elif self.filterspec is not None:
175             if isinstance(propclass, hyperdb.Multilink):
176                 return self.filterspec.get(property, [])
177             else:
178                 return self.filterspec.get(property, '')
179         # TODO: pull the value from the form
180         if isinstance(propclass, hyperdb.Multilink):
181             return []
182         else:
183             return ''
185     def make_sort_function(self, classname):
186         '''Make a sort function for a given class
187         '''
188         linkcl = self.db.classes[classname]
189         if linkcl.getprops().has_key('order'):
190             sort_on = 'order'
191         else:
192             sort_on = linkcl.labelprop()
193         def sortfunc(a, b, linkcl=linkcl, sort_on=sort_on):
194             return cmp(linkcl.get(a, sort_on), linkcl.get(b, sort_on))
195         return sortfunc
197     def do_field(self, property, size=None, showid=0):
198         ''' display a property like the plain displayer, but in a text field
199             to be edited
201             Note: if you would prefer an option list style display for
202             link or multilink editing, use menu().
203         '''
204         if not self.nodeid and self.form is None and self.filterspec is None:
205             return _('[Field: not called from item]')
206         if size is None:
207             size = 30
209         propclass = self.properties[property]
211         # get the value
212         value = self.determine_value(property)
213         # now display
214         if (isinstance(propclass, hyperdb.String) or
215                 isinstance(propclass, hyperdb.Date) or
216                 isinstance(propclass, hyperdb.Interval)):
217             if value is None:
218                 value = ''
219             else:
220                 value = cgi.escape(str(value))
221                 value = '"'.join(value.split('"'))
222             s = '<input name="%s" value="%s" size="%s">'%(property, value, size)
223         elif isinstance(propclass, hyperdb.Boolean):
224             checked = value and "checked" or ""
225             s = '<input type="checkbox" name="%s" %s>'%(property, checked)
226         elif isinstance(propclass, hyperdb.Number):
227             s = '<input name="%s" value="%s" size="%s">'%(property, value, size)
228         elif isinstance(propclass, hyperdb.Password):
229             s = '<input type="password" name="%s" size="%s">'%(property, size)
230         elif isinstance(propclass, hyperdb.Link):
231             linkcl = self.db.classes[propclass.classname]
232             if linkcl.getprops().has_key('order'):  
233                 sort_on = 'order'  
234             else:  
235                 sort_on = linkcl.labelprop()  
236             options = linkcl.filter(None, {}, [sort_on], []) 
237             # TODO: make this a field display, not a menu one!
238             l = ['<select name="%s">'%property]
239             k = linkcl.labelprop(1)
240             if value is None:
241                 s = 'selected '
242             else:
243                 s = ''
244             l.append(_('<option %svalue="-1">- no selection -</option>')%s)
245             for optionid in options:
246                 option = linkcl.get(optionid, k)
247                 s = ''
248                 if optionid == value:
249                     s = 'selected '
250                 if showid:
251                     lab = '%s%s: %s'%(propclass.classname, optionid, option)
252                 else:
253                     lab = option
254                 if size is not None and len(lab) > size:
255                     lab = lab[:size-3] + '...'
256                 lab = cgi.escape(lab)
257                 l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
258             l.append('</select>')
259             s = '\n'.join(l)
260         elif isinstance(propclass, hyperdb.Multilink):
261             sortfunc = self.make_sort_function(propclass.classname)
262             linkcl = self.db.classes[propclass.classname]
263             if value:
264                 value.sort(sortfunc)
265             # map the id to the label property
266             if not showid:
267                 k = linkcl.labelprop(1)
268                 value = [linkcl.get(v, k) for v in value]
269             value = cgi.escape(','.join(value))
270             s = '<input name="%s" size="%s" value="%s">'%(property, size, value)
271         else:
272             s = _('Plain: bad propclass "%(propclass)s"')%locals()
273         return s
275     def do_multiline(self, property, rows=5, cols=40):
276         ''' display a string property in a multiline text edit field
277         '''
278         if not self.nodeid and self.form is None and self.filterspec is None:
279             return _('[Multiline: not called from item]')
281         propclass = self.properties[property]
283         # make sure this is a link property
284         if not isinstance(propclass, hyperdb.String):
285             return _('[Multiline: not a string]')
287         # get the value
288         value = self.determine_value(property)
289         if value is None:
290             value = ''
292         # display
293         return '<textarea name="%s" rows="%s" cols="%s">%s</textarea>'%(
294             property, rows, cols, value)
296     def do_menu(self, property, size=None, height=None, showid=0,
297             additional=[], **conditions):
298         ''' For a Link/Multilink property, display a menu of the available
299             choices
301             If the additional properties are specified, they will be
302             included in the text of each option in (brackets, with, commas).
303         '''
304         if not self.nodeid and self.form is None and self.filterspec is None:
305             return _('[Field: not called from item]')
307         propclass = self.properties[property]
309         # make sure this is a link property
310         if not (isinstance(propclass, hyperdb.Link) or
311                 isinstance(propclass, hyperdb.Multilink)):
312             return _('[Menu: not a link]')
314         # sort function
315         sortfunc = self.make_sort_function(propclass.classname)
317         # get the value
318         value = self.determine_value(property)
320         # display
321         if isinstance(propclass, hyperdb.Multilink):
322             linkcl = self.db.classes[propclass.classname]
323             if linkcl.getprops().has_key('order'):  
324                 sort_on = 'order'  
325             else:  
326                 sort_on = linkcl.labelprop()
327             options = linkcl.filter(None, conditions, [sort_on], []) 
328             height = height or min(len(options), 7)
329             l = ['<select multiple name="%s" size="%s">'%(property, height)]
330             k = linkcl.labelprop(1)
331             for optionid in options:
332                 option = linkcl.get(optionid, k)
333                 s = ''
334                 if optionid in value or option in value:
335                     s = 'selected '
336                 if showid:
337                     lab = '%s%s: %s'%(propclass.classname, optionid, option)
338                 else:
339                     lab = option
340                 if size is not None and len(lab) > size:
341                     lab = lab[:size-3] + '...'
342                 if additional:
343                     m = []
344                     for propname in additional:
345                         m.append(linkcl.get(optionid, propname))
346                     lab = lab + ' (%s)'%', '.join(m)
347                 lab = cgi.escape(lab)
348                 l.append('<option %svalue="%s">%s</option>'%(s, optionid,
349                     lab))
350             l.append('</select>')
351             return '\n'.join(l)
352         if isinstance(propclass, hyperdb.Link):
353             # force the value to be a single choice
354             if type(value) is types.ListType:
355                 value = value[0]
356             linkcl = self.db.classes[propclass.classname]
357             l = ['<select name="%s">'%property]
358             k = linkcl.labelprop(1)
359             s = ''
360             if value is None:
361                 s = 'selected '
362             l.append(_('<option %svalue="-1">- no selection -</option>')%s)
363             if linkcl.getprops().has_key('order'):  
364                 sort_on = 'order'  
365             else:  
366                 sort_on = linkcl.labelprop() 
367             options = linkcl.filter(None, conditions, [sort_on], []) 
368             for optionid in options:
369                 option = linkcl.get(optionid, k)
370                 s = ''
371                 if value in [optionid, option]:
372                     s = 'selected '
373                 if showid:
374                     lab = '%s%s: %s'%(propclass.classname, optionid, option)
375                 else:
376                     lab = option
377                 if size is not None and len(lab) > size:
378                     lab = lab[:size-3] + '...'
379                 if additional:
380                     m = []
381                     for propname in additional:
382                         m.append(linkcl.get(optionid, propname))
383                     lab = lab + ' (%s)'%', '.join(map(str, m))
384                 lab = cgi.escape(lab)
385                 l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
386             l.append('</select>')
387             return '\n'.join(l)
388         return _('[Menu: not a link]')
390     #XXX deviates from spec
391     def do_link(self, property=None, is_download=0, showid=0):
392         '''For a Link or Multilink property, display the names of the linked
393            nodes, hyperlinked to the item views on those nodes.
394            For other properties, link to this node with the property as the
395            text.
397            If is_download is true, append the property value to the generated
398            URL so that the link may be used as a download link and the
399            downloaded file name is correct.
400         '''
401         if not self.nodeid and self.form is None:
402             return _('[Link: not called from item]')
404         # get the value
405         value = self.determine_value(property)
407         propclass = self.properties[property]
408         if isinstance(propclass, hyperdb.Boolean):
409             value = value and "Yes" or "No"
410         elif isinstance(propclass, hyperdb.Link):
411             if value in ('', None, []):
412                 return _('[no %(propname)s]')%{'propname':property.capitalize()}
413             linkname = propclass.classname
414             linkcl = self.db.classes[linkname]
415             k = linkcl.labelprop(1)
416             linkvalue = cgi.escape(str(linkcl.get(value, k)))
417             if showid:
418                 label = value
419                 title = ' title="%s"'%linkvalue
420                 # note ... this should be urllib.quote(linkcl.get(value, k))
421             else:
422                 label = linkvalue
423                 title = ''
424             if is_download:
425                 return '<a href="%s%s/%s"%s>%s</a>'%(linkname, value,
426                     linkvalue, title, label)
427             else:
428                 return '<a href="%s%s"%s>%s</a>'%(linkname, value, title, label)
429         elif isinstance(propclass, hyperdb.Multilink):
430             if value in ('', None, []):
431                 return _('[no %(propname)s]')%{'propname':property.capitalize()}
432             linkname = propclass.classname
433             linkcl = self.db.classes[linkname]
434             k = linkcl.labelprop(1)
435             l = []
436             for value in value:
437                 linkvalue = cgi.escape(str(linkcl.get(value, k)))
438                 if showid:
439                     label = value
440                     title = ' title="%s"'%linkvalue
441                     # note ... this should be urllib.quote(linkcl.get(value, k))
442                 else:
443                     label = linkvalue
444                     title = ''
445                 if is_download:
446                     l.append('<a href="%s%s/%s"%s>%s</a>'%(linkname, value,
447                         linkvalue, title, label))
448                 else:
449                     l.append('<a href="%s%s"%s>%s</a>'%(linkname, value,
450                         title, label))
451             return ', '.join(l)
452         if is_download:
453             if value in ('', None, []):
454                 return _('[no %(propname)s]')%{'propname':property.capitalize()}
455             return '<a href="%s%s/%s">%s</a>'%(self.classname, self.nodeid,
456                 value, value)
457         else:
458             if value in ('', None, []):
459                 value =  _('[no %(propname)s]')%{'propname':property.capitalize()}
460             return '<a href="%s%s">%s</a>'%(self.classname, self.nodeid, value)
462     def do_count(self, property, **args):
463         ''' for a Multilink property, display a count of the number of links in
464             the list
465         '''
466         if not self.nodeid:
467             return _('[Count: not called from item]')
469         propclass = self.properties[property]
470         if not isinstance(propclass, hyperdb.Multilink):
471             return _('[Count: not a Multilink]')
473         # figure the length then...
474         value = self.cl.get(self.nodeid, property)
475         return str(len(value))
477     # XXX pretty is definitely new ;)
478     def do_reldate(self, property, pretty=0):
479         ''' display a Date property in terms of an interval relative to the
480             current date (e.g. "+ 3w", "- 2d").
482             with the 'pretty' flag, make it pretty
483         '''
484         if not self.nodeid and self.form is None:
485             return _('[Reldate: not called from item]')
487         propclass = self.properties[property]
488         if not isinstance(propclass, hyperdb.Date):
489             return _('[Reldate: not a Date]')
491         if self.nodeid:
492             value = self.cl.get(self.nodeid, property)
493         else:
494             return ''
495         if not value:
496             return ''
498         # figure the interval
499         interval = date.Date('.') - value
500         if pretty:
501             if not self.nodeid:
502                 return _('now')
503             return interval.pretty()
504         return str(interval)
506     def do_download(self, property, **args):
507         ''' show a Link("file") or Multilink("file") property using links that
508             allow you to download files
509         '''
510         if not self.nodeid:
511             return _('[Download: not called from item]')
512         return self.do_link(property, is_download=1)
515     def do_checklist(self, property, sortby=None):
516         ''' for a Link or Multilink property, display checkboxes for the
517             available choices to permit filtering
519             sort the checklist by the argument (+/- property name)
520         '''
521         propclass = self.properties[property]
522         if (not isinstance(propclass, hyperdb.Link) and not
523                 isinstance(propclass, hyperdb.Multilink)):
524             return _('[Checklist: not a link]')
526         # get our current checkbox state
527         if self.nodeid:
528             # get the info from the node - make sure it's a list
529             if isinstance(propclass, hyperdb.Link):
530                 value = [self.cl.get(self.nodeid, property)]
531             else:
532                 value = self.cl.get(self.nodeid, property)
533         elif self.filterspec is not None:
534             # get the state from the filter specification (always a list)
535             value = self.filterspec.get(property, [])
536         else:
537             # it's a new node, so there's no state
538             value = []
540         # so we can map to the linked node's "lable" property
541         linkcl = self.db.classes[propclass.classname]
542         l = []
543         k = linkcl.labelprop(1)
545         # build list of options and then sort it, either
546         # by id + label or <sortby>-value + label;
547         # a minus reverses the sort order, while + or no
548         # prefix sort in increasing order
549         reversed = 0
550         if sortby:
551             if sortby[0] == '-':
552                 reversed = 1
553                 sortby = sortby[1:]
554             elif sortby[0] == '+':
555                 sortby = sortby[1:]
556         options = []
557         for optionid in linkcl.list():
558             if sortby:
559                 sortval = linkcl.get(optionid, sortby)
560             else:
561                 sortval = int(optionid)
562             option = cgi.escape(str(linkcl.get(optionid, k)))
563             options.append((sortval, option, optionid))
564         options.sort()
565         if reversed:
566             options.reverse()
568         # build checkboxes
569         for sortval, option, optionid in options:
570             if optionid in value or option in value:
571                 checked = 'checked'
572             else:
573                 checked = ''
574             l.append('%s:<input type="checkbox" %s name="%s" value="%s">'%(
575                 option, checked, property, option))
577         # for Links, allow the "unselected" option too
578         if isinstance(propclass, hyperdb.Link):
579             if value is None or '-1' in value:
580                 checked = 'checked'
581             else:
582                 checked = ''
583             l.append(_('[unselected]:<input type="checkbox" %s name="%s" '
584                 'value="-1">')%(checked, property))
585         return '\n'.join(l)
587     def do_note(self, rows=5, cols=80):
588         ''' display a "note" field, which is a text area for entering a note to
589             go along with a change. 
590         '''
591         # TODO: pull the value from the form
592         return '<textarea name="__note" wrap="hard" rows=%s cols=%s>'\
593             '</textarea>'%(rows, cols)
595     # XXX new function
596     def do_list(self, property, reverse=0):
597         ''' list the items specified by property using the standard index for
598             the class
599         '''
600         propcl = self.properties[property]
601         if not isinstance(propcl, hyperdb.Multilink):
602             return _('[List: not a Multilink]')
604         value = self.determine_value(property)
605         if not value:
606             return ''
608         # sort, possibly revers and then re-stringify
609         value = map(int, value)
610         value.sort()
611         if reverse:
612             value.reverse()
613         value = map(str, value)
615         # render the sub-index into a string
616         fp = StringIO.StringIO()
617         try:
618             write_save = self.client.write
619             self.client.write = fp.write
620             index = IndexTemplate(self.client, self.templates, propcl.classname)
621             index.render(nodeids=value, show_display_form=0)
622         finally:
623             self.client.write = write_save
625         return fp.getvalue()
627     # XXX new function
628     def do_history(self, direction='descending'):
629         ''' list the history of the item
631             If "direction" is 'descending' then the most recent event will
632             be displayed first. If it is 'ascending' then the oldest event
633             will be displayed first.
634         '''
635         if self.nodeid is None:
636             return _("[History: node doesn't exist]")
638         l = ['<table width=100% border=0 cellspacing=0 cellpadding=2>',
639             '<tr class="list-header">',
640             _('<th align=left><span class="list-item">Date</span></th>'),
641             _('<th align=left><span class="list-item">User</span></th>'),
642             _('<th align=left><span class="list-item">Action</span></th>'),
643             _('<th align=left><span class="list-item">Args</span></th>'),
644             '</tr>']
646         comments = {}
647         history = self.cl.history(self.nodeid)
648         history.sort()
649         if direction == 'descending':
650             history.reverse()
651         for id, evt_date, user, action, args in history:
652             date_s = str(evt_date).replace("."," ")
653             arg_s = ''
654             if action == 'link' and type(args) == type(()):
655                 if len(args) == 3:
656                     linkcl, linkid, key = args
657                     arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
658                         linkcl, linkid, key)
659                 else:
660                     arg_s = str(args)
662             elif action == 'unlink' and type(args) == type(()):
663                 if len(args) == 3:
664                     linkcl, linkid, key = args
665                     arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
666                         linkcl, linkid, key)
667                 else:
668                     arg_s = str(args)
670             elif type(args) == type({}):
671                 cell = []
672                 for k in args.keys():
673                     # try to get the relevant property and treat it
674                     # specially
675                     try:
676                         prop = self.properties[k]
677                     except:
678                         prop = None
679                     if prop is not None:
680                         if args[k] and (isinstance(prop, hyperdb.Multilink) or
681                                 isinstance(prop, hyperdb.Link)):
682                             # figure what the link class is
683                             classname = prop.classname
684                             try:
685                                 linkcl = self.db.classes[classname]
686                             except KeyError:
687                                 labelprop = None
688                                 comments[classname] = _('''The linked class
689                                     %(classname)s no longer exists''')%locals()
690                             labelprop = linkcl.labelprop(1)
691                             hrefable = os.path.exists(
692                                 os.path.join(self.templates, classname+'.item'))
694                         if isinstance(prop, hyperdb.Multilink) and \
695                                 len(args[k]) > 0:
696                             ml = []
697                             for linkid in args[k]:
698                                 label = classname + linkid
699                                 # if we have a label property, try to use it
700                                 # TODO: test for node existence even when
701                                 # there's no labelprop!
702                                 try:
703                                     if labelprop is not None:
704                                         label = linkcl.get(linkid, labelprop)
705                                 except IndexError:
706                                     comments['no_link'] = _('''<strike>The
707                                         linked node no longer
708                                         exists</strike>''')
709                                     ml.append('<strike>%s</strike>'%label)
710                                 else:
711                                     if hrefable:
712                                         ml.append('<a href="%s%s">%s</a>'%(
713                                             classname, linkid, label))
714                                     else:
715                                         ml.append(label)
716                             cell.append('%s:\n  %s'%(k, ',\n  '.join(ml)))
717                         elif isinstance(prop, hyperdb.Link) and args[k]:
718                             label = classname + args[k]
719                             # if we have a label property, try to use it
720                             # TODO: test for node existence even when
721                             # there's no labelprop!
722                             if labelprop is not None:
723                                 try:
724                                     label = linkcl.get(args[k], labelprop)
725                                 except IndexError:
726                                     comments['no_link'] = _('''<strike>The
727                                         linked node no longer
728                                         exists</strike>''')
729                                     cell.append(' <strike>%s</strike>,\n'%label)
730                                     # "flag" this is done .... euwww
731                                     label = None
732                             if label is not None:
733                                 if hrefable:
734                                     cell.append('%s: <a href="%s%s">%s</a>\n'%(k,
735                                         classname, args[k], label))
736                                 else:
737                                     cell.append('%s: %s' % (k,label))
739                         elif isinstance(prop, hyperdb.Date) and args[k]:
740                             d = date.Date(args[k])
741                             cell.append('%s: %s'%(k, str(d)))
743                         elif isinstance(prop, hyperdb.Interval) and args[k]:
744                             d = date.Interval(args[k])
745                             cell.append('%s: %s'%(k, str(d)))
747                         elif isinstance(prop, hyperdb.String) and args[k]:
748                             cell.append('%s: %s'%(k, cgi.escape(args[k])))
750                         elif not args[k]:
751                             cell.append('%s: (no value)\n'%k)
753                         else:
754                             cell.append('%s: %s\n'%(k, str(args[k])))
755                     else:
756                         # property no longer exists
757                         comments['no_exist'] = _('''<em>The indicated property
758                             no longer exists</em>''')
759                         cell.append('<em>%s: %s</em>\n'%(k, str(args[k])))
760                 arg_s = '<br />'.join(cell)
761             else:
762                 # unkown event!!
763                 comments['unknown'] = _('''<strong><em>This event is not
764                     handled by the history display!</em></strong>''')
765                 arg_s = '<strong><em>' + str(args) + '</em></strong>'
766             date_s = date_s.replace(' ', '&nbsp;')
767             l.append('<tr><td nowrap valign=top>%s</td><td valign=top>%s</td>'
768                 '<td valign=top>%s</td><td valign=top>%s</td></tr>'%(date_s,
769                 user, action, arg_s))
770         if comments:
771             l.append(_('<tr><td colspan=4><strong>Note:</strong></td></tr>'))
772         for entry in comments.values():
773             l.append('<tr><td colspan=4>%s</td></tr>'%entry)
774         l.append('</table>')
775         return '\n'.join(l)
777     # XXX new function
778     def do_submit(self):
779         ''' add a submit button for the item
780         '''
781         if self.nodeid:
782             return _('<input type="submit" name="submit" value="Submit Changes">')
783         elif self.form is not None:
784             return _('<input type="submit" name="submit" value="Submit New Entry">')
785         else:
786             return _('[Submit: not called from item]')
788     def do_classhelp(self, classname, properties, label='?', width='400',
789             height='400'):
790         '''pop up a javascript window with class help
792            This generates a link to a popup window which displays the 
793            properties indicated by "properties" of the class named by
794            "classname". The "properties" should be a comma-separated list
795            (eg. 'id,name,description').
797            You may optionally override the label displayed, the width and
798            height. The popup window will be resizable and scrollable.
799         '''
800         return '<a href="javascript:help_window(\'classhelp?classname=%s&' \
801             'properties=%s\', \'%s\', \'%s\')"><b>(%s)</b></a>'%(classname,
802             properties, width, height, label)
804     def do_email(self, property, escape=0):
805         '''display the property as one or more "fudged" email addrs
806         '''
807         if not self.nodeid and self.form is None:
808             return _('[Email: not called from item]')
809         propclass = self.properties[property]
810         if self.nodeid:
811             # get the value for this property
812             try:
813                 value = self.cl.get(self.nodeid, property)
814             except KeyError:
815                 # a KeyError here means that the node doesn't have a value
816                 # for the specified property
817                 value = ''
818         else:
819             value = ''
820         if isinstance(propclass, hyperdb.String):
821             if value is None: value = ''
822             else: value = str(value)
823             value = value.replace('@', ' at ')
824             value = value.replace('.', ' ')
825         else:
826             value = _('[Email: not a string]')%locals()
827         if escape:
828             value = cgi.escape(value)
829         return value
831     def do_filterspec(self, classprop, urlprop):
832         cl = self.db.getclass(self.classname)
833         qs = cl.get(self.nodeid, urlprop)
834         classname = cl.get(self.nodeid, classprop)
835         all_columns = self.db.getclass(classname).getprops().keys()
836         filterspec = {}
837         query = cgi.parse_qs(qs)
838         for k,v in query.items():
839             query[k] = v[0].split(',')
840         pagesize = query.get(':pagesize',['25'])[0]
841         search_text = query.get('search_text', [''])[0]
842         search_text = urllib.unquote(search_text)
843         for k,v in query.items():
844             if k[0] != ':':
845                 filterspec[k] = v
846         ixtmplt = IndexTemplate(self.client, self.templates, classname)
847         qform = '<form onSubmit="return submit_once()" action="%s%s">\n'%(
848             self.classname,self.nodeid)
849         qform += ixtmplt.filter_form(search_text,
850                                      query.get(':filter', []),
851                                      query.get(':columns', []),
852                                      query.get(':group', []),
853                                      all_columns,
854                                      query.get(':sort',[]),
855                                      filterspec,
856                                      pagesize)
857         ixtmplt.clear()
858         return qform + '</table>\n'
860     # 
861     # templating subtitution methods
862     #
863     def execute_template(self, text):
864         ''' do the replacement of the template stuff with useful
865             information
866         '''
867         replace = re.compile(
868             r'((<require\s+(?P<cond>.+?)>(?P<ok>.+?)'
869                 r'(<else>(?P<fail>.*?))?</require>)|'
870             r'(<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
871             r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I|re.S)
872         return replace.sub(self.subfunc, text)
874     #
875     # secutiry <require> tag handling
876     #
877     condre = re.compile('(\w+?)\s*=\s*"([^"]+?)"')
878     def handle_require(self, condition, ok, fail):
879         userid = self.db.user.lookup(self.client.user)
880         security = self.db.security
882         # get the conditions
883         l = self.condre.findall(condition)
884         d = {}
885         for k,v in l:
886             d[k] = v
888         # see if one of the permissions are available
889         if d.has_key('permission'):
890             l.remove(('permission', d['permission']))
891             for value in d['permission'].split(','):
892                 if security.hasPermission(value, userid, self.classname):
893                     # just passing the permission is OK
894                     return self.execute_template(ok)
896         # try the attr conditions until one is met
897         for propname, value in d.items():
898             if propname == 'permission':
899                 continue
900             if not security.hasNodePermission(self.classname, self.nodeid,
901                     **{value: userid}):
902                 break
903         else:
904             if l:
905                 # there were tests, and we didn't fail any of them so we're OK
906                 if ok:
907                     return self.execute_template(ok)
908                 else:
909                     return ''
911         # nope, fail
912         if fail:
913             return self.execute_template(fail)
914         else:
915             return ''
918 #   INDEX TEMPLATES
920 class IndexTemplate(TemplateFunctions):
921     '''Templating functionality specifically for index pages
922     '''
923     def __init__(self, client, templates, classname):
924         TemplateFunctions.__init__(self)
925         self.globals['handle_require'] = self.handle_require
926         self.client = client
927         self.instance = client.instance
928         self.templates = templates
929         self.classname = classname
931         # derived
932         self.db = self.client.db
933         self.cl = self.db.classes[self.classname]
934         self.properties = self.cl.getprops()
936     def clear(self):
937         self.db = self.cl = self.properties = None
938         del self.globals['handle_require']
939         TemplateFunctions.clear(self)
941     def buildurl(self, filterspec, search_text, filter, columns, sort, group, pagesize):
942         d = {'pagesize':pagesize, 'pagesize':pagesize, 'classname':self.classname}
943         if search_text:
944             d['searchtext'] = 'search_text=%s&' % search_text
945         else:
946             d['searchtext'] = ''
947         d['filter'] = ','.join(map(urllib.quote,filter))
948         d['columns'] = ','.join(map(urllib.quote,columns))
949         d['sort'] = ','.join(map(urllib.quote,sort))
950         d['group'] = ','.join(map(urllib.quote,group))
951         tmp = []
952         for col, vals in filterspec.items():
953             vals = ','.join(map(urllib.quote,vals))
954             tmp.append('%s=%s' % (col, vals))
955         d['filters'] = '&'.join(tmp)
956         return '%(classname)s?%(searchtext)s%(filters)s&:sort=%(sort)s&:filter=%(filter)s&:group=%(group)s&:columns=%(columns)s&:pagesize=%(pagesize)s' % d
957     
958     col_re=re.compile(r'<property\s+name="([^>]+)">')
959     def render(self, filterspec={}, search_text='', filter=[], columns=[], 
960             sort=[], group=[], show_display_form=1, nodeids=None,
961             show_customization=1, show_nodes=1, pagesize=50, startwith=0):
962         
963         self.filterspec = filterspec
965         w = self.client.write
967         # XXX deviate from spec here ...
968         # load the index section template and figure the default columns from it
969         try:
970             template = open(os.path.join(self.templates,
971                 self.classname+'.index')).read()
972         except IOError, error:
973             if error.errno not in (errno.ENOENT, errno.ESRCH): raise
974             raise MissingTemplateError, self.classname+'.index'
975         all_columns = self.col_re.findall(template)
976         if not columns:
977             columns = []
978             for name in all_columns:
979                 columns.append(name)
980         else:
981             # re-sort columns to be the same order as all_columns
982             l = []
983             for name in all_columns:
984                 if name in columns:
985                     l.append(name)
986             columns = l
988         # TODO this is for the RE replacer func, and could probably be done
989         # better
990         self.props = columns
992         # display the filter section
993         if (show_display_form and 
994                 self.instance.FILTER_POSITION in ('top and bottom', 'top')):
995             w('<form onSubmit="return submit_once()" action="%s">\n'%self.classname)
996             self.filter_section(search_text, filter, columns, group,
997                 all_columns, sort, filterspec, pagesize, startwith)
999         # now display the index section
1000         w('<table width=100% border=0 cellspacing=0 cellpadding=2>\n')
1001         w('<tr class="list-header">\n')
1002         for name in columns:
1003             cname = name.capitalize()
1004             if show_display_form:
1005                 sb = self.sortby(name, search_text, filterspec, columns, filter, 
1006                         group, sort, pagesize)
1007                 anchor = "%s?%s"%(self.classname, sb)
1008                 w('<td><span class="list-header"><a href="%s">%s</a>'
1009                     '</span></td>\n'%(anchor, cname))
1010             else:
1011                 w('<td><span class="list-header">%s</span></td>\n'%cname)
1012         w('</tr>\n')
1014         # this stuff is used for group headings - optimise the group names
1015         old_group = None
1016         group_names = []
1017         if group:
1018             for name in group:
1019                 if name[0] == '-': group_names.append(name[1:])
1020                 else: group_names.append(name)
1022         # now actually loop through all the nodes we get from the filter and
1023         # apply the template
1024         if show_nodes:
1025             matches = None
1026             if nodeids is None:
1027                 if search_text != '':
1028                     matches = self.db.indexer.search(
1029                         re.findall(r'\b\w{2,25}\b', search_text), self.cl)
1030                         #search_text.split(' '), self.cl)
1031                 nodeids = self.cl.filter(matches, filterspec, sort, group)
1032             for nodeid in nodeids[startwith:startwith+pagesize]:
1033                 # check for a group heading
1034                 if group_names:
1035                     this_group = [self.cl.get(nodeid, name, _('[no value]'))
1036                         for name in group_names]
1037                     if this_group != old_group:
1038                         l = []
1039                         for name in group_names:
1040                             prop = self.properties[name]
1041                             if isinstance(prop, hyperdb.Link):
1042                                 group_cl = self.db.classes[prop.classname]
1043                                 key = group_cl.getkey()
1044                                 if key is None:
1045                                     key = group_cl.labelprop()
1046                                 value = self.cl.get(nodeid, name)
1047                                 if value is None:
1048                                     l.append(_('[unselected %(classname)s]')%{
1049                                         'classname': prop.classname})
1050                                 else:
1051                                     l.append(group_cl.get(value, key))
1052                             elif isinstance(prop, hyperdb.Multilink):
1053                                 group_cl = self.db.classes[prop.classname]
1054                                 key = group_cl.getkey()
1055                                 for value in self.cl.get(nodeid, name):
1056                                     l.append(group_cl.get(value, key))
1057                             else:
1058                                 value = self.cl.get(nodeid, name, 
1059                                     _('[no value]'))
1060                                 if value is None:
1061                                     value = _('[empty %(name)s]')%locals()
1062                                 else:
1063                                     value = str(value)
1064                                 l.append(value)
1065                         w('<tr class="section-bar">'
1066                         '<td align=middle colspan=%s>'
1067                         '<strong>%s</strong></td></tr>\n'%(
1068                             len(columns), ', '.join(l)))
1069                         old_group = this_group
1071                 # display this node's row
1072                 self.nodeid = nodeid
1073                 w(self.execute_template(template))
1074                 if matches:
1075                     self.node_matches(matches[nodeid], len(columns))
1076                 self.nodeid = None
1078         w('</table>\n')
1079         # the previous and next links
1080         if nodeids:
1081             baseurl = self.buildurl(filterspec, search_text, filter,
1082                 columns, sort, group, pagesize)
1083             if startwith > 0:
1084                 prevurl = '<a href="%s&:startwith=%s">&lt;&lt; '\
1085                     'Previous page</a>'%(baseurl, max(0, startwith-pagesize)) 
1086             else:
1087                 prevurl = "" 
1088             if startwith + pagesize < len(nodeids):
1089                 nexturl = '<a href="%s&:startwith=%s">Next page '\
1090                     '&gt;&gt;</a>'%(baseurl, startwith+pagesize)
1091             else:
1092                 nexturl = ""
1093             if prevurl or nexturl:
1094                 w('''<table width="100%%"><tr>
1095                       <td width="50%%" align="center">%s</td>
1096                       <td width="50%%" align="center">%s</td>
1097                      </tr></table>\n'''%(prevurl, nexturl))
1099         # display the filter section
1100         if (show_display_form and hasattr(self.instance, 'FILTER_POSITION') and
1101                 self.instance.FILTER_POSITION in ('top and bottom', 'bottom')):
1102             w('<form onSubmit="return submit_once()" action="%s">\n'%
1103                 self.classname)
1104             self.filter_section(search_text, filter, columns, group,
1105                 all_columns, sort, filterspec, pagesize, startwith)
1106         self.clear()
1108     def subfunc(self, m, search_text=None, filter=None, columns=None,
1109             sort=None, group=None):
1110         ''' called as part of the template replacement
1111         '''
1112         if m.group('cond'):
1113             # call the template handler for require
1114             require = self.globals['handle_require']
1115             return self.handle_require(m.group('cond'), m.group('ok'),
1116                 m.group('fail'))
1117         if m.group('name'):
1118             if m.group('name') in self.props:
1119                 text = m.group('text')
1120                 return self.execute_template(text)
1121             else:
1122                 return ''
1123         if m.group('display'):
1124             command = m.group('command')
1125             return eval(command, self.globals, {})
1126         return '*** unhandled match: %s'%str(m.groupdict())
1128     def node_matches(self, match, colspan):
1129         ''' display the files and messages for a node that matched a
1130             full text search
1131         '''
1132         w = self.client.write
1134         message_links = []
1135         file_links = []
1136         if match.has_key('messages'):
1137             for msgid in match['messages']:
1138                 k = self.db.msg.labelprop(1)
1139                 lab = self.db.msg.get(msgid, k)
1140                 msgpath = 'msg%s'%msgid
1141                 message_links.append('<a href="%(msgpath)s">%(lab)s</a>'
1142                     %locals())
1143             w(_('<tr class="row-hilite"><td colspan="%s">'
1144                 '&nbsp;&nbsp;Matched messages: %s</td></tr>\n')%(
1145                     colspan, ', '.join(message_links)))
1147         if match.has_key('files'):
1148             for fileid in match['files']:
1149                 filename = self.db.file.get(fileid, 'name')
1150                 filepath = 'file%s/%s'%(fileid, filename)
1151                 file_links.append('<a href="%(filepath)s">%(filename)s</a>'
1152                     %locals())
1153             w(_('<tr class="row-hilite"><td colspan="%s">'
1154                 '&nbsp;&nbsp;Matched files: %s</td></tr>\n')%(
1155                     colspan, ', '.join(file_links)))
1157     def filter_form(self, search_text, filter, columns, group, all_columns,
1158             sort, filterspec, pagesize):
1159         sortspec = {}
1160         for i in range(len(sort)):
1161             mod = ''
1162             colnm = sort[i]
1163             if colnm[0] == '-':
1164                 mod = '-'
1165                 colnm = colnm[1:]
1166             sortspec[colnm] = '%d%s' % (i+1, mod)
1167             
1168         startwith = 0
1169         rslt = []
1170         w = rslt.append
1172         # display the filter section
1173         w(  '<br>')
1174         w(  '<table border=0 cellspacing=0 cellpadding=1>')
1175         w(  '<tr class="list-header">')
1176         w(_(' <th align="left" colspan="7">Filter specification...</th>'))
1177         w(  '</tr>')
1178         # see if we have any indexed properties
1179         if self.classname in self.db.config.HEADER_SEARCH_LINKS:
1180         #if self.properties.has_key('messages') or self.properties.has_key('files'):
1181             w(  '<tr class="location-bar">')
1182             w(  ' <td align="right" class="form-label"><b>Search Terms</b></td>')
1183             w(  ' <td colspan=6 class="form-text">&nbsp;&nbsp;&nbsp;<input type="text" name="search_text" value="%s" size="50"></td>' % search_text)
1184             w(  '</tr>')
1185         w(  '<tr class="location-bar">')
1186         w(  ' <th align="center" width="20%">&nbsp;</th>')
1187         w(_(' <th align="center" width="10%">Show</th>'))
1188         w(_(' <th align="center" width="10%">Group</th>'))
1189         w(_(' <th align="center" width="10%">Sort</th>'))
1190         w(_(' <th colspan="3" align="center">Condition</th>'))
1191         w(  '</tr>')
1192         
1193         for nm in all_columns:
1194             propdescr = self.properties.get(nm, None)
1195             if not propdescr:
1196                 print "hey sysadmin - %s is not a property of %r" % (nm, self.classname)
1197                 continue
1198             w(  '<tr class="location-bar">')
1199             w(_(' <td align="right" class="form-label"><b>%s</b></td>' % nm.capitalize()))
1200             # show column - can't show multilinks
1201             if isinstance(propdescr, hyperdb.Multilink):
1202                 w(' <td></td>')
1203             else:
1204                 checked = columns and nm in columns or 0
1205                 checked = ('', 'checked')[checked]
1206                 w(' <td align="center" class="form-text"><input type="checkbox" name=":columns" value="%s" %s></td>' % (nm, checked) )
1207             # can only group on Link 
1208             if isinstance(propdescr, hyperdb.Link):
1209                 checked = group and nm in group or 0
1210                 checked = ('', 'checked')[checked]
1211                 w(' <td align="center" class="form-text"><input type="checkbox" name=":group" value="%s" %s></td>' % (nm, checked) )
1212             else:
1213                 w(' <td></td>')
1214             # sort - no sort on Multilinks
1215             if isinstance(propdescr, hyperdb.Multilink):
1216                 w('<td></td>')
1217             else:
1218                 val = sortspec.get(nm, '')
1219                 w('<td align="center" class="form-text"><input type="text" name=":%s_ss" size="3" value="%s"></td>' % (nm,val))
1220             # condition
1221             val = ''
1222             if isinstance(propdescr, hyperdb.Link):
1223                 op = "is in&nbsp;"
1224                 xtra = '<a href="javascript:help_window(\'classhelp?classname=%s&properties=id,%s\', \'200\', \'400\')"><b>(list)</b></a>'\
1225                        % (propdescr.classname, self.db.getclass(propdescr.classname).labelprop())
1226                 val = ','.join(filterspec.get(nm, ''))
1227             elif isinstance(propdescr, hyperdb.Multilink):
1228                 op = "contains&nbsp;"
1229                 xtra = '<a href="javascript:help_window(\'classhelp?classname=%s&properties=id,%s\', \'200\', \'400\')"><b>(list)</b></a>'\
1230                        % (propdescr.classname, self.db.getclass(propdescr.classname).labelprop())
1231                 val = ','.join(filterspec.get(nm, ''))
1232             elif isinstance(propdescr, hyperdb.String) and nm != 'id':
1233                 op = "equals&nbsp;"
1234                 xtra = ""
1235                 val = filterspec.get(nm, '')
1236             elif isinstance(propdescr, hyperdb.Boolean):
1237                 op = "is&nbsp;"
1238                 xtra = ""
1239                 val = filterspec.get(nm, None)
1240                 if val is not None:
1241                     val = 'True' and val or 'False'
1242                 else:
1243                     val = ''
1244             elif isinstance(propdescr, hyperdb.Number):
1245                 op = "equals&nbsp;"
1246                 xtra = ""
1247                 val = str(filterspec.get(nm, ''))
1248             else:
1249                 w('<td></td><td></td><td></td></tr>')
1250                 continue
1251             checked = filter and nm in filter or 0
1252             checked = ('', 'checked')[checked]
1253             w(  ' <td class="form-text"><input type="checkbox" name=":filter" value="%s" %s></td>' % (nm, checked))
1254             w(_(' <td class="form-label" nowrap>%s</td><td class="form-text" nowrap><input type="text" name=":%s_fs" value="%s" size=50>%s</td>' % (op, nm, val, xtra)))
1255             w(  '</tr>')
1256         w('<tr class="location-bar">')
1257         w(' <td colspan=7><hr></td>')
1258         w('</tr>')
1259         w('<tr class="location-bar">')
1260         w(_(' <td align="right" class="form-label">Pagesize</td>'))
1261         w(' <td colspan=2 align="center" class="form-text"><input type="text" name=":pagesize" size="3" value="%s"></td>' % pagesize)
1262         w(' <td colspan=4></td>')
1263         w('</tr>')
1264         w('<tr class="location-bar">')
1265         w(_(' <td align="right" class="form-label">Start With</td>'))
1266         w(' <td colspan=2 align="center" class="form-text"><input type="text" name=":startwith" size="3" value="%s"></td>' % startwith)
1267         w(' <td colspan=3></td>')
1268         w(' <td></td>')
1269         w('</tr>')
1271         return '\n'.join(rslt)
1272     
1273     def filter_section(self, search_text, filter, columns, group, all_columns,
1274             sort, filterspec, pagesize, startwith):
1275         w = self.client.write        
1276         w(self.filter_form(search_text, filter, columns, group, all_columns,
1277                            sort, filterspec, pagesize))
1278         w(' <tr class="location-bar">\n')
1279         w('  <td colspan=7><hr></td>\n')
1280         w(' </tr>\n')
1281         w(' <tr class="location-bar">\n')
1282         w('  <td>&nbsp;</td>\n')
1283         w('  <td colspan=6><input type="submit" name="Query" value="Redisplay"></td>\n')
1284         w(' </tr>\n')
1285         if (self.db.getclass('user').getprops().has_key('queries')
1286             and not self.client.user in (None, "anonymous")):
1287             w(' <tr class="location-bar">\n')
1288             w('  <td colspan=7><hr></td>\n')
1289             w(' </tr>\n')
1290             w(' <tr class="location-bar">\n')
1291             w('  <td align=right class="form-label">Name</td>\n')
1292             w('  <td colspan=2 class="form-text"><input type="text" name=":name" value=""></td>\n')
1293             w('  <td colspan=4 rowspan=2 class="form-help">If you give the query a name '
1294               'and click <b>Save</b>, it will appear on your menu. Saved queries may be '
1295               'edited by going to <b>My Details</b> and clicking on the query name.</td>')
1296             w(' </tr>\n')
1297             w(' <tr class="location-bar">\n')
1298             w('  <td>&nbsp;</td><input type="hidden" name=":classname" value="%s">\n' % self.classname)
1299             w('  <td colspan=2><input type="submit" name="Query" value="Save"></td>\n')
1300             w(' </tr>\n')
1301         w('</table>\n')
1303     def sortby(self, sort_name, search_text, filterspec, columns, filter, group, sort,
1304             pagesize):
1305         ''' Figure the link for a column heading so we can sort by that
1306             column
1307         '''
1308         l = []
1309         w = l.append
1310         if search_text:
1311             w('search_text=%s' % search_text)
1312         for k, v in filterspec.items():
1313             k = urllib.quote(k)
1314             if type(v) == type([]):
1315                 w('%s=%s'%(k, ','.join(map(urllib.quote, v))))
1316             else:
1317                 w('%s=%s'%(k, urllib.quote(v)))
1318         if columns:
1319             w(':columns=%s'%','.join(map(urllib.quote, columns)))
1320         if filter:
1321             w(':filter=%s'%','.join(map(urllib.quote, filter)))
1322         if group:
1323             w(':group=%s'%','.join(map(urllib.quote, group)))
1324         w(':pagesize=%s' % pagesize)
1325         w(':startwith=0')
1327         # handle the sorting - if we're already sorting by this column,
1328         # then reverse the sorting, otherwise set the sorting to be this
1329         # column only
1330         sorting = None
1331         if len(sort) == 1:
1332             name = sort[0]
1333             dir = name[0]
1334             if dir == '-' and name[1:] == sort_name:
1335                 sorting = ':sort=%s'%sort_name
1336             elif name == sort_name:
1337                 sorting = ':sort=-%s'%sort_name
1338         if sorting is None:
1339             sorting = ':sort=%s'%sort_name
1340         w(sorting)
1342         return '&'.join(l)
1344 class ItemTemplate(TemplateFunctions):
1345     '''Templating functionality specifically for item (node) display
1346     '''
1347     def __init__(self, client, templates, classname):
1348         TemplateFunctions.__init__(self)
1349         self.globals['handle_require'] = self.handle_require
1350         self.client = client
1351         self.instance = client.instance
1352         self.templates = templates
1353         self.classname = classname
1355         # derived
1356         self.db = self.client.db
1357         self.cl = self.db.classes[self.classname]
1358         self.properties = self.cl.getprops()
1360     def clear(self):
1361         self.db = self.cl = self.properties = None
1362         del self.globals['handle_require']
1363         TemplateFunctions.clear(self)
1364         
1365     def render(self, nodeid):
1366         self.nodeid = nodeid
1367         
1368         if (self.properties.has_key('type') and
1369                 self.properties.has_key('content')):
1370             pass
1371             # XXX we really want to return this as a downloadable...
1372             #  currently I handle this at a higher level by detecting 'file'
1373             #  designators...
1375         w = self.client.write
1376         w('<form onSubmit="return submit_once()" action="%s%s" method="POST" enctype="multipart/form-data">'%(
1377             self.classname, nodeid))
1378         s = open(os.path.join(self.templates, self.classname+'.item')).read()
1379         try:
1380             w(self.execute_template(s))
1381         except:
1382             etype = sys.exc_type
1383             if type(etype) is types.ClassType:
1384                 etype = etype.__name__
1385             w('<p class="system-msg">%s: %s</p>'%(etype, sys.exc_value))
1386             # make sure we don't commit any changes
1387             self.db.rollback()
1388         w('</form>')
1389         
1390         self.clear()
1392     def subfunc(self, m, search_text=None, filter=None, columns=None,
1393             sort=None, group=None):
1394         ''' called as part of the template replacement
1395         '''
1396         if m.group('cond'):
1397             # call the template handler for require
1398             require = self.globals['handle_require']
1399             return self.handle_require(m.group('cond'), m.group('ok'),
1400                 m.group('fail'))
1401         if m.group('name'):
1402             if self.nodeid and self.cl.get(self.nodeid, m.group('name')):
1403                 return self.execute_template(m.group('text'))
1404             else:
1405                 return ''
1406         if m.group('display'):
1407             command = m.group('command')
1408             return eval(command, self.globals, {})
1409         return '*** unhandled match: %s'%str(m.groupdict())
1411 class NewItemTemplate(ItemTemplate):
1412     '''Templating functionality specifically for NEW item (node) display
1413     '''
1414     def __init__(self, client, templates, classname):
1415         TemplateFunctions.__init__(self)
1416         self.globals['handle_require'] = self.handle_require
1417         self.client = client
1418         self.instance = client.instance
1419         self.templates = templates
1420         self.classname = classname
1422         # derived
1423         self.db = self.client.db
1424         self.cl = self.db.classes[self.classname]
1425         self.properties = self.cl.getprops()
1427     def clear(self):
1428         self.db = self.cl = None
1429         TemplateFunctions.clear(self)
1430         
1431     def render(self, form):
1432         self.form = form
1433         w = self.client.write
1434         c = self.classname
1435         try:
1436             s = open(os.path.join(self.templates, c+'.newitem')).read()
1437         except IOError:
1438             s = open(os.path.join(self.templates, c+'.item')).read()
1439         w('<form onSubmit="return submit_once()" action="new%s" method="POST" enctype="multipart/form-data">'%c)
1440         for key in form.keys():
1441             if key[0] == ':':
1442                 value = form[key].value
1443                 if type(value) != type([]): value = [value]
1444                 for value in value:
1445                     w('<input type="hidden" name="%s" value="%s">'%(key, value))
1446         w(self.execute_template(s))
1447         w('</form>')
1448         
1449         self.clear()
1452 # $Log: not supported by cvs2svn $
1453 # Revision 1.108  2002/07/31 22:40:50  gmcm
1454 # Fixes to the search form and saving queries.
1455 # Fixes to  sorting in back_metakit.py.
1457 # Revision 1.107  2002/07/30 05:27:30  richard
1458 # nicer error messages, and a bugfix
1460 # Revision 1.106  2002/07/30 02:41:04  richard
1461 # Removed the confusing, ugly two-column sorting stuff. Column heading clicks
1462 # now only sort on one column. Nice and simple and obvious.
1464 # Revision 1.105  2002/07/26 08:26:59  richard
1465 # Very close now. The cgi and mailgw now use the new security API. The two
1466 # templates have been migrated to that setup. Lots of unit tests. Still some
1467 # issue in the web form for editing Roles assigned to users.
1469 # Revision 1.104  2002/07/25 07:14:05  richard
1470 # Bugger it. Here's the current shape of the new security implementation.
1471 # Still to do:
1472 #  . call the security funcs from cgi and mailgw
1473 #  . change shipped templates to include correct initialisation and remove
1474 #    the old config vars
1475 # ... that seems like a lot. The bulk of the work has been done though. Honest :)
1477 # Revision 1.103  2002/07/20 19:29:10  gmcm
1478 # Fixes/improvements to the search form & saved queries.
1480 # Revision 1.102  2002/07/18 23:07:08  richard
1481 # Unit tests and a few fixes.
1483 # Revision 1.101  2002/07/18 11:17:30  gmcm
1484 # Add Number and Boolean types to hyperdb.
1485 # Add conversion cases to web, mail & admin interfaces.
1486 # Add storage/serialization cases to back_anydbm & back_metakit.
1488 # Revision 1.100  2002/07/18 07:01:54  richard
1489 # minor bugfix
1491 # Revision 1.99  2002/07/17 12:39:10  gmcm
1492 # Saving, running & editing queries.
1494 # Revision 1.98  2002/07/10 00:17:46  richard
1495 #  . added sorting of checklist HTML display
1497 # Revision 1.97  2002/07/09 05:20:09  richard
1498 #  . added email display function - mangles email addrs so they're not so easily
1499 #    scraped from the web
1501 # Revision 1.96  2002/07/09 04:19:09  richard
1502 # Added reindex command to roundup-admin.
1503 # Fixed reindex on first access.
1504 # Also fixed reindexing of entries that change.
1506 # Revision 1.95  2002/07/08 15:32:06  gmcm
1507 # Pagination of index pages.
1508 # New search form.
1510 # Revision 1.94  2002/06/27 15:38:53  gmcm
1511 # Fix the cycles (a clear method, called after render, that removes
1512 # the bound methods from the globals dict).
1513 # Use cl.filter instead of cl.list followed by sortfunc. For some
1514 # backends (Metakit), filter can sort at C speeds, cutting >10 secs
1515 # off of filling in the <select...> box for assigned_to when you
1516 # have 600+ users.
1518 # Revision 1.93  2002/06/27 12:05:25  gmcm
1519 # Default labelprops to id.
1520 # In history, make sure there's a .item before making a link / multilink into an href.
1521 # Also in history, cgi.escape String properties.
1522 # Clean up some of the reference cycles.
1524 # Revision 1.92  2002/06/11 04:57:04  richard
1525 # Added optional additional property to display in a Multilink form menu.
1527 # Revision 1.91  2002/05/31 00:08:02  richard
1528 # can now just display a link/multilink id - useful for stylesheet stuff
1530 # Revision 1.90  2002/05/25 07:16:24  rochecompaan
1531 # Merged search_indexing-branch with HEAD
1533 # Revision 1.89  2002/05/15 06:34:47  richard
1534 # forgot to fix the templating for last change
1536 # Revision 1.88  2002/04/24 08:34:35  rochecompaan
1537 # Sorting was applied to all nodes of the MultiLink class instead of
1538 # the nodes that are actually linked to in the "field" template
1539 # function.  This adds about 20+ seconds in the display of an issue if
1540 # your database has a 1000 or more issue in it.
1542 # Revision 1.87  2002/04/03 06:12:46  richard
1543 # Fix for date properties as labels.
1545 # Revision 1.86  2002/04/03 05:54:31  richard
1546 # Fixed serialisation problem by moving the serialisation step out of the
1547 # hyperdb.Class (get, set) into the hyperdb.Database.
1549 # Also fixed htmltemplate after the showid changes I made yesterday.
1551 # Unit tests for all of the above written.
1553 # Revision 1.85  2002/04/02 01:40:58  richard
1554 #  . link() htmltemplate function now has a "showid" option for links and
1555 #    multilinks. When true, it only displays the linked node id as the anchor
1556 #    text. The link value is displayed as a tooltip using the title anchor
1557 #    attribute.
1559 # Revision 1.84.2.2  2002/04/20 13:23:32  rochecompaan
1560 # We now have a separate search page for nodes.  Search links for
1561 # different classes can be customized in instance_config similar to
1562 # index links.
1564 # Revision 1.84.2.1  2002/04/19 19:54:42  rochecompaan
1565 # cgi_client.py
1566 #     removed search link for the time being
1567 #     moved rendering of matches to htmltemplate
1568 # hyperdb.py
1569 #     filtering of nodes on full text search incorporated in filter method
1570 # roundupdb.py
1571 #     added paramater to call of filter method
1572 # roundup_indexer.py
1573 #     added search method to RoundupIndexer class
1575 # Revision 1.84  2002/03/29 19:41:48  rochecompaan
1576 #  . Fixed display of mutlilink properties when using the template
1577 #    functions, menu and plain.
1579 # Revision 1.83  2002/02/27 04:14:31  richard
1580 # Ran it through pychecker, made fixes
1582 # Revision 1.82  2002/02/21 23:11:45  richard
1583 #  . fixed some problems in date calculations (calendar.py doesn't handle over-
1584 #    and under-flow). Also, hour/minute/second intervals may now be more than
1585 #    99 each.
1587 # Revision 1.81  2002/02/21 07:21:38  richard
1588 # docco
1590 # Revision 1.80  2002/02/21 07:19:08  richard
1591 # ... and label, width and height control for extra flavour!
1593 # Revision 1.79  2002/02/21 06:57:38  richard
1594 #  . Added popup help for classes using the classhelp html template function.
1595 #    - add <display call="classhelp('priority', 'id,name,description')">
1596 #      to an item page, and it generates a link to a popup window which displays
1597 #      the id, name and description for the priority class. The description
1598 #      field won't exist in most installations, but it will be added to the
1599 #      default templates.
1601 # Revision 1.78  2002/02/21 06:23:00  richard
1602 # *** empty log message ***
1604 # Revision 1.77  2002/02/20 05:05:29  richard
1605 #  . Added simple editing for classes that don't define a templated interface.
1606 #    - access using the admin "class list" interface
1607 #    - limited to admin-only
1608 #    - requires the csv module from object-craft (url given if it's missing)
1610 # Revision 1.76  2002/02/16 09:10:52  richard
1611 # oops
1613 # Revision 1.75  2002/02/16 08:43:23  richard
1614 #  . #517906 ] Attribute order in "View customisation"
1616 # Revision 1.74  2002/02/16 08:39:42  richard
1617 #  . #516854 ] "My Issues" and redisplay
1619 # Revision 1.73  2002/02/15 07:08:44  richard
1620 #  . Alternate email addresses are now available for users. See the MIGRATION
1621 #    file for info on how to activate the feature.
1623 # Revision 1.72  2002/02/14 23:39:18  richard
1624 # . All forms now have "double-submit" protection when Javascript is enabled
1625 #   on the client-side.
1627 # Revision 1.71  2002/01/23 06:15:24  richard
1628 # real (non-string, duh) sorting of lists by node id
1630 # Revision 1.70  2002/01/23 05:47:57  richard
1631 # more HTML template cleanup and unit tests
1633 # Revision 1.69  2002/01/23 05:10:27  richard
1634 # More HTML template cleanup and unit tests.
1635 #  - download() now implemented correctly, replacing link(is_download=1) [fixed in the
1636 #    templates, but link(is_download=1) will still work for existing templates]
1638 # Revision 1.68  2002/01/22 22:55:28  richard
1639 #  . htmltemplate list() wasn't sorting...
1641 # Revision 1.67  2002/01/22 22:46:22  richard
1642 # more htmltemplate cleanups and unit tests
1644 # Revision 1.66  2002/01/22 06:35:40  richard
1645 # more htmltemplate tests and cleanup
1647 # Revision 1.65  2002/01/22 00:12:06  richard
1648 # Wrote more unit tests for htmltemplate, and while I was at it, I polished
1649 # off the implementation of some of the functions so they behave sanely.
1651 # Revision 1.64  2002/01/21 03:25:59  richard
1652 # oops
1654 # Revision 1.63  2002/01/21 02:59:10  richard
1655 # Fixed up the HTML display of history so valid links are actually displayed.
1656 # Oh for some unit tests! :(
1658 # Revision 1.62  2002/01/18 08:36:12  grubert
1659 #  . add nowrap to history table date cell i.e. <td nowrap ...
1661 # Revision 1.61  2002/01/17 23:04:53  richard
1662 #  . much nicer history display (actualy real handling of property types etc)
1664 # Revision 1.60  2002/01/17 08:48:19  grubert
1665 #  . display superseder as html link in history.
1667 # Revision 1.59  2002/01/17 07:58:24  grubert
1668 #  . display links a html link in history.
1670 # Revision 1.58  2002/01/15 00:50:03  richard
1671 # #502949 ] index view for non-issues and redisplay
1673 # Revision 1.57  2002/01/14 23:31:21  richard
1674 # reverted the change that had plain() hyperlinking the link displays -
1675 # that's what link() is for!
1677 # Revision 1.56  2002/01/14 07:04:36  richard
1678 #  . plain rendering of links in the htmltemplate now generate a hyperlink to
1679 #    the linked node's page.
1680 #    ... this allows a display very similar to bugzilla's where you can actually
1681 #    find out information about the linked node.
1683 # Revision 1.55  2002/01/14 06:45:03  richard
1684 #  . #502953 ] nosy-like treatment of other multilinks
1685 #    ... had to revert most of the previous change to the multilink field
1686 #    display... not good.
1688 # Revision 1.54  2002/01/14 05:16:51  richard
1689 # The submit buttons need a name attribute or mozilla won't submit without a
1690 # file upload. Yeah, that's bloody obscure. Grr.
1692 # Revision 1.53  2002/01/14 04:03:32  richard
1693 # How about that ... date fields have never worked ...
1695 # Revision 1.52  2002/01/14 02:20:14  richard
1696 #  . changed all config accesses so they access either the instance or the
1697 #    config attriubute on the db. This means that all config is obtained from
1698 #    instance_config instead of the mish-mash of classes. This will make
1699 #    switching to a ConfigParser setup easier too, I hope.
1701 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1702 # 0.5.0 switch, I hope!)
1704 # Revision 1.51  2002/01/10 10:02:15  grubert
1705 # In do_history: replace "." in date by " " so html wraps more sensible.
1706 # Should this be done in date's string converter ?
1708 # Revision 1.50  2002/01/05 02:35:10  richard
1709 # I18N'ification
1711 # Revision 1.49  2001/12/20 15:43:01  rochecompaan
1712 # Features added:
1713 #  .  Multilink properties are now displayed as comma separated values in
1714 #     a textbox
1715 #  .  The add user link is now only visible to the admin user
1716 #  .  Modified the mail gateway to reject submissions from unknown
1717 #     addresses if ANONYMOUS_ACCESS is denied
1719 # Revision 1.48  2001/12/20 06:13:24  rochecompaan
1720 # Bugs fixed:
1721 #   . Exception handling in hyperdb for strings-that-look-like numbers got
1722 #     lost somewhere
1723 #   . Internet Explorer submits full path for filename - we now strip away
1724 #     the path
1725 # Features added:
1726 #   . Link and multilink properties are now displayed sorted in the cgi
1727 #     interface
1729 # Revision 1.47  2001/11/26 22:55:56  richard
1730 # Feature:
1731 #  . Added INSTANCE_NAME to configuration - used in web and email to identify
1732 #    the instance.
1733 #  . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1734 #    signature info in e-mails.
1735 #  . Some more flexibility in the mail gateway and more error handling.
1736 #  . Login now takes you to the page you back to the were denied access to.
1738 # Fixed:
1739 #  . Lots of bugs, thanks Roché and others on the devel mailing list!
1741 # Revision 1.46  2001/11/24 00:53:12  jhermann
1742 # "except:" is bad, bad , bad!
1744 # Revision 1.45  2001/11/22 15:46:42  jhermann
1745 # Added module docstrings to all modules.
1747 # Revision 1.44  2001/11/21 23:35:45  jhermann
1748 # Added globbing for win32, and sample marking in a 2nd file to test it
1750 # Revision 1.43  2001/11/21 04:04:43  richard
1751 # *sigh* more missing value handling
1753 # Revision 1.42  2001/11/21 03:40:54  richard
1754 # more new property handling
1756 # Revision 1.41  2001/11/15 10:26:01  richard
1757 #  . missing "return" in filter_section (thanks Roch'e Compaan)
1759 # Revision 1.40  2001/11/03 01:56:51  richard
1760 # More HTML compliance fixes. This will probably fix the Netscape problem
1761 # too.
1763 # Revision 1.39  2001/11/03 01:43:47  richard
1764 # Ahah! Fixed the lynx problem - there was a hidden input field misplaced.
1766 # Revision 1.38  2001/10/31 06:58:51  richard
1767 # Added the wrap="hard" attribute to the textarea of the note field so the
1768 # messages wrap sanely.
1770 # Revision 1.37  2001/10/31 06:24:35  richard
1771 # Added do_stext to htmltemplate, thanks Brad Clements.
1773 # Revision 1.36  2001/10/28 22:51:38  richard
1774 # Fixed ENOENT/WindowsError thing, thanks Juergen Hermann
1776 # Revision 1.35  2001/10/24 00:04:41  richard
1777 # Removed the "infinite authentication loop", thanks Roch'e
1779 # Revision 1.34  2001/10/23 22:56:36  richard
1780 # Bugfix in filter "widget" placement, thanks Roch'e
1782 # Revision 1.33  2001/10/23 01:00:18  richard
1783 # Re-enabled login and registration access after lopping them off via
1784 # disabling access for anonymous users.
1785 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1786 # a couple of bugs while I was there. Probably introduced a couple, but
1787 # things seem to work OK at the moment.
1789 # Revision 1.32  2001/10/22 03:25:01  richard
1790 # Added configuration for:
1791 #  . anonymous user access and registration (deny/allow)
1792 #  . filter "widget" location on index page (top, bottom, both)
1793 # Updated some documentation.
1795 # Revision 1.31  2001/10/21 07:26:35  richard
1796 # feature #473127: Filenames. I modified the file.index and htmltemplate
1797 #  source so that the filename is used in the link and the creation
1798 #  information is displayed.
1800 # Revision 1.30  2001/10/21 04:44:50  richard
1801 # bug #473124: UI inconsistency with Link fields.
1802 #    This also prompted me to fix a fairly long-standing usability issue -
1803 #    that of being able to turn off certain filters.
1805 # Revision 1.29  2001/10/21 00:17:56  richard
1806 # CGI interface view customisation section may now be hidden (patch from
1807 #  Roch'e Compaan.)
1809 # Revision 1.28  2001/10/21 00:00:16  richard
1810 # Fixed Checklist function - wasn't always working on a list.
1812 # Revision 1.27  2001/10/20 12:13:44  richard
1813 # Fixed grouping of non-str properties (thanks Roch'e Compaan)
1815 # Revision 1.26  2001/10/14 10:55:00  richard
1816 # Handle empty strings in HTML template Link function
1818 # Revision 1.25  2001/10/09 07:25:59  richard
1819 # Added the Password property type. See "pydoc roundup.password" for
1820 # implementation details. Have updated some of the documentation too.
1822 # Revision 1.24  2001/09/27 06:45:58  richard
1823 # *gak* ... xmp is Old Skool apparently. Am using pre again by have the option
1824 # on the plain() template function to escape the text for HTML.
1826 # Revision 1.23  2001/09/10 09:47:18  richard
1827 # Fixed bug in the generation of links to Link/Multilink in indexes.
1828 #   (thanks Hubert Hoegl)
1829 # Added AssignedTo to the "classic" schema's item page.
1831 # Revision 1.22  2001/08/30 06:01:17  richard
1832 # Fixed missing import in mailgw :(
1834 # Revision 1.21  2001/08/16 07:34:59  richard
1835 # better CGI text searching - but hidden filter fields are disappearing...
1837 # Revision 1.20  2001/08/15 23:43:18  richard
1838 # Fixed some isFooTypes that I missed.
1839 # Refactored some code in the CGI code.
1841 # Revision 1.19  2001/08/12 06:32:36  richard
1842 # using isinstance(blah, Foo) now instead of isFooType
1844 # Revision 1.18  2001/08/07 00:24:42  richard
1845 # stupid typo
1847 # Revision 1.17  2001/08/07 00:15:51  richard
1848 # Added the copyright/license notice to (nearly) all files at request of
1849 # Bizar Software.
1851 # Revision 1.16  2001/08/01 03:52:23  richard
1852 # Checklist was using wrong name.
1854 # Revision 1.15  2001/07/30 08:12:17  richard
1855 # Added time logging and file uploading to the templates.
1857 # Revision 1.14  2001/07/30 06:17:45  richard
1858 # Features:
1859 #  . Added ability for cgi newblah forms to indicate that the new node
1860 #    should be linked somewhere.
1861 # Fixed:
1862 #  . Fixed the agument handling for the roundup-admin find command.
1863 #  . Fixed handling of summary when no note supplied for newblah. Again.
1864 #  . Fixed detection of no form in htmltemplate Field display.
1866 # Revision 1.13  2001/07/30 02:37:53  richard
1867 # Temporary measure until we have decent schema migration.
1869 # Revision 1.12  2001/07/30 01:24:33  richard
1870 # Handles new node display now.
1872 # Revision 1.11  2001/07/29 09:31:35  richard
1873 # oops
1875 # Revision 1.10  2001/07/29 09:28:23  richard
1876 # Fixed sorting by clicking on column headings.
1878 # Revision 1.9  2001/07/29 08:27:40  richard
1879 # Fixed handling of passed-in values in form elements (ie. during a
1880 # drill-down)
1882 # Revision 1.8  2001/07/29 07:01:39  richard
1883 # Added vim command to all source so that we don't get no steenkin' tabs :)
1885 # Revision 1.7  2001/07/29 05:36:14  richard
1886 # Cleanup of the link label generation.
1888 # Revision 1.6  2001/07/29 04:06:42  richard
1889 # Fixed problem in link display when Link value is None.
1891 # Revision 1.5  2001/07/28 08:17:09  richard
1892 # fixed use of stylesheet
1894 # Revision 1.4  2001/07/28 07:59:53  richard
1895 # Replaced errno integers with their module values.
1896 # De-tabbed templatebuilder.py
1898 # Revision 1.3  2001/07/25 03:39:47  richard
1899 # Hrm - displaying links to classes that don't specify a key property. I've
1900 # got it defaulting to 'name', then 'title' and then a "random" property (first
1901 # one returned by getprops().keys().
1902 # Needs to be moved onto the Class I think...
1904 # Revision 1.2  2001/07/22 12:09:32  richard
1905 # Final commit of Grande Splite
1907 # Revision 1.1  2001/07/22 11:58:35  richard
1908 # More Grande Splite
1911 # vim: set filetype=python ts=4 sw=4 et si