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.110 2002-08-13 20:16:09 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 parsed template (parsing and caching
33 as needed). When the render() method is called, the parse tree is
34 traversed. Each node is either text (immediately output), a Require
35 instance (resulting in a call to _test()), a Property instance (treated
36 differently by .item and .index) or a Diplay instance (resulting in
37 a call to one of the template_funcs.py functions).
39 In a .index list, Property tags are used to determine columns, and
40 disappear before the actual rendering. Note that the template will
41 be rendered many times in a .index.
43 In a .item, Property tags check if the node has the property.
45 Templating is tested by the test_htmltemplate unit test suite. If you add
46 a template function, add a test for all data types or the angry pink bunny
47 will hunt you down.
48 """
49 import weakref, os, types, cgi, sys, urllib, re
50 try:
51 import cPickle as pickle
52 except ImportError:
53 import pickle
54 from template_parser import RoundupTemplate, Display, Property, Require
55 from i18n import _
56 import hyperdb, template_funcs
58 MTIME = os.path.stat.ST_MTIME
60 class MissingTemplateError(ValueError):
61 '''Error raised when a template file is missing
62 '''
63 pass
65 # what a <require> tag results in
66 def _test(attributes, client, classname, nodeid):
67 tests = {}
68 for nm, val in attributes:
69 tests[nm] = val
70 userid = client.db.user.lookup(client.user)
71 security = client.db.security
72 perms = tests.get('permission', None)
73 if perms:
74 del tests['permission']
75 perms = perms.split(',')
76 for value in perms:
77 if security.hasPermission(value, userid, classname):
78 # just passing the permission is OK
79 return 1
80 # try the attr conditions until one is met
81 if nodeid is None:
82 return 0
83 if not tests:
84 return 0
85 for propname, value in tests.items():
86 if value == '$userid':
87 tests[propname] = userid
88 return security.hasNodePermission(classname, nodeid, **tests)
90 # what a <display> tag results in
91 def _display(attributes, client, classname, cl, props, nodeid, filterspec=None):
92 call = attributes[0][1] #eg "field('prop2')"
93 pos = call.find('(')
94 funcnm = call[:pos]
95 func = templatefuncs.get(funcnm, None)
96 if func:
97 argstr = call[pos:]
98 args, kws = eval('splitargs'+argstr)
99 args = (client, classname, cl, props, nodeid, filterspec) + args
100 rslt = func(*args, **kws)
101 else:
102 rslt = _('no template function %s' % funcnm)
103 client.write(rslt)
105 # what a <property> tag results in
106 def _exists(attributes, cl, props, nodeid):
107 nm = attributes[0][1]
108 if nodeid:
109 return cl.get(nodeid, nm)
110 return props.get(nm, 0)
112 class Template:
113 ''' base class of all templates.
115 knows how to compile & load a template.
116 knows how to render one item. '''
117 def __init__(self, client, templates, classname):
118 if isinstance(client, weakref.ProxyType):
119 self.client = client
120 else:
121 self.client = weakref.proxy(client)
122 self.templatedir = templates
123 self.compiledtemplatedir = self.templatedir+'c'
124 self.classname = classname
125 self.cl = self.client.db.getclass(self.classname)
126 self.properties = self.cl.getprops()
127 self.template = self._load()
128 self.filterspec = None
129 self.columns = None
130 self.nodeid = None
131 def _load(self):
132 src = os.path.join(self.templatedir, self.classname + self.extension)
133 if not os.path.exists(src):
134 if hasattr(self, 'fallbackextension'):
135 self.extension = self.fallbackextension
136 return self._load()
137 raise MissingTemplateError, self.classname + self.extension
138 cpl = os.path.join(self.compiledtemplatedir, self.classname + self.extension)
139 if ( not os.path.exists(cpl)
140 or os.stat(cpl)[MTIME] < os.stat(src)[MTIME] ):
141 parser = RoundupTemplate()
142 parser.feed(open(src, 'r').read())
143 tmplt = parser.structure
144 try:
145 if not os.path.exists(self.compiledtemplatedir):
146 os.makedirs(self.compiledtemplatedir)
147 f = open(cpl, 'wb')
148 pickle.dump(tmplt, f)
149 f.close()
150 except Exception, e:
151 print "ouch in pickling: got a %s %r" % (e, e.args)
152 pass
153 else:
154 f = open(cpl, 'rb')
155 tmplt = pickle.load(f)
156 return tmplt
157 def _render(self, tmplt=None, test=_test, display=_display, exists=_exists):
158 if tmplt is None:
159 tmplt = self.template
160 for entry in tmplt:
161 if isinstance(entry, type('')):
162 self.client.write(entry)
163 elif isinstance(entry, Require):
164 if test(entry.attributes, self.client, self.classname, self.nodeid):
165 self._render(entry.ok)
166 elif entry.fail:
167 self._render(entry.fail)
168 elif isinstance(entry, Display):
169 display(entry.attributes, self.client, self.classname, self.cl, self.properties, self.nodeid, self.filterspec)
170 elif isinstance(entry, Property):
171 if self.columns is None: # doing an Item
172 if exists(entry.attributes, self.cl, self.properties, self.nodeid):
173 self._render(entry.ok)
174 #elif entry.attributes[0][1] in self.columns:
175 else:
176 self._render(entry.ok)
178 class IndexTemplate(Template):
179 ''' renders lists of items
181 shows filter form (for new queries / to refine queries)
182 has clickable column headers (sort by this column / sort reversed)
183 has group by lines
184 has full text search match lines '''
185 extension = '.index'
186 def __init__(self, client, templates, classname):
187 Template.__init__(self, client, templates, classname)
188 def render(self, filterspec={}, search_text='', filter=[], columns=[],
189 sort=[], group=[], show_display_form=1, nodeids=None,
190 show_customization=1, show_nodes=1, pagesize=50, startwith=0,
191 simple_search=1, xtracols=None):
193 try:
194 self.filterspec = filterspec
195 w = self.client.write
196 cl = self.cl
197 properties = self.properties
198 if xtracols is None:
199 xtracols = []
201 # XXX deviate from spec here ...
202 # load the index section template and figure the default columns from it
203 displayable_props = []
204 all_columns = []
205 for node in self.template:
206 if isinstance(node, Property):
207 colnm = node.attributes[0][1]
208 if properties.has_key(colnm):
209 displayable_props.append(colnm)
210 all_columns.append(colnm)
211 elif colnm in xtracols:
212 all_columns.append(colnm)
213 if not columns:
214 columns = all_columns
215 else:
216 # re-sort columns to be the same order as displayable_props
217 l = []
218 for name in all_columns:
219 if name in columns:
220 l.append(name)
221 columns = l
222 self.columns = columns
224 # optimize the template
225 self.template = self._optimize(self.template)
227 # display the filter section
228 if (show_display_form and
229 self.client.instance.FILTER_POSITION in ('top and bottom', 'top')):
230 w('<form onSubmit="return submit_once()" action="%s">\n'%self.client.classname)
231 self.filter_section(search_text, filter, columns, group,
232 displayable_props, sort, filterspec, pagesize, startwith, simple_search)
234 # now display the index section
235 w('<table width=100% border=0 cellspacing=0 cellpadding=2>\n')
236 w('<tr class="list-header">\n')
237 for name in columns:
238 cname = name.capitalize()
239 if show_display_form and not cname in xtracols:
240 sb = self.sortby(name, search_text, filterspec, columns, filter,
241 group, sort, pagesize)
242 anchor = "%s?%s"%(self.client.classname, sb)
243 w('<td><span class="list-header"><a href="%s">%s</a>'
244 '</span></td>\n'%(anchor, cname))
245 else:
246 w('<td><span class="list-header">%s</span></td>\n'%cname)
247 w('</tr>\n')
249 # this stuff is used for group headings - optimise the group names
250 old_group = None
251 group_names = []
252 if group:
253 for name in group:
254 if name[0] == '-': group_names.append(name[1:])
255 else: group_names.append(name)
257 # now actually loop through all the nodes we get from the filter and
258 # apply the template
259 if show_nodes:
260 matches = None
261 if nodeids is None:
262 if search_text != '':
263 matches = self.client.db.indexer.search(
264 re.findall(r'\b\w{2,25}\b', search_text), cl)
265 nodeids = cl.filter(matches, filterspec, sort, group)
266 linecount = 0
267 for nodeid in nodeids[startwith:startwith+pagesize]:
268 # check for a group heading
269 if group_names:
270 this_group = [cl.get(nodeid, name, _('[no value]'))
271 for name in group_names]
272 if this_group != old_group:
273 l = []
274 for name in group_names:
275 prop = properties[name]
276 if isinstance(prop, hyperdb.Link):
277 group_cl = self.client.db.getclass(prop.classname)
278 key = group_cl.getkey()
279 if key is None:
280 key = group_cl.labelprop()
281 value = cl.get(nodeid, name)
282 if value is None:
283 l.append(_('[unselected %(classname)s]')%{
284 'classname': prop.classname})
285 else:
286 l.append(group_cl.get(value, key))
287 elif isinstance(prop, hyperdb.Multilink):
288 group_cl = self.client.db.getclass(prop.classname)
289 key = group_cl.getkey()
290 for value in cl.get(nodeid, name):
291 l.append(group_cl.get(value, key))
292 else:
293 value = cl.get(nodeid, name,
294 _('[no value]'))
295 if value is None:
296 value = _('[empty %(name)s]')%locals()
297 else:
298 value = str(value)
299 l.append(value)
300 w('<tr class="section-bar">'
301 '<td align=middle colspan=%s>'
302 '<strong>%s</strong></td></tr>\n'%(
303 len(columns), ', '.join(l)))
304 old_group = this_group
306 # display this node's row
307 self.nodeid = nodeid
308 self._render()
309 if matches:
310 self.node_matches(matches[nodeid], len(columns))
311 self.nodeid = None
313 w('</table>\n')
314 # the previous and next links
315 if nodeids:
316 baseurl = self.buildurl(filterspec, search_text, filter,
317 columns, sort, group, pagesize)
318 if startwith > 0:
319 prevurl = '<a href="%s&:startwith=%s"><< '\
320 'Previous page</a>'%(baseurl, max(0, startwith-pagesize))
321 else:
322 prevurl = ""
323 if startwith + pagesize < len(nodeids):
324 nexturl = '<a href="%s&:startwith=%s">Next page '\
325 '>></a>'%(baseurl, startwith+pagesize)
326 else:
327 nexturl = ""
328 if prevurl or nexturl:
329 w('''<table width="100%%"><tr>
330 <td width="50%%" align="center">%s</td>
331 <td width="50%%" align="center">%s</td>
332 </tr></table>\n'''%(prevurl, nexturl))
334 # display the filter section
335 if (show_display_form and hasattr(self.client.instance, 'FILTER_POSITION') and
336 self.client.instance.FILTER_POSITION in ('top and bottom', 'bottom')):
337 w('<form onSubmit="return submit_once()" action="%s">\n'%
338 self.client.classname)
339 self.filter_section(search_text, filter, columns, group,
340 displayable_props, sort, filterspec, pagesize, startwith, simple_search)
341 finally:
342 self.cl = self.properties = self.client = None
344 def _optimize(self, tmplt):
345 columns = self.columns
346 t = []
347 for entry in tmplt:
348 if isinstance(entry, Property):
349 if entry.attributes[0][1] in columns:
350 t.extend(entry.ok)
351 else:
352 t.append(entry)
353 return t
355 def buildurl(self, filterspec, search_text, filter, columns, sort, group, pagesize):
356 d = {'pagesize':pagesize, 'pagesize':pagesize, 'classname':self.classname}
357 if search_text:
358 d['searchtext'] = 'search_text=%s&' % search_text
359 else:
360 d['searchtext'] = ''
361 d['filter'] = ','.join(map(urllib.quote,filter))
362 d['columns'] = ','.join(map(urllib.quote,columns))
363 d['sort'] = ','.join(map(urllib.quote,sort))
364 d['group'] = ','.join(map(urllib.quote,group))
365 tmp = []
366 for col, vals in filterspec.items():
367 vals = ','.join(map(urllib.quote,vals))
368 tmp.append('%s=%s' % (col, vals))
369 d['filters'] = '&'.join(tmp)
370 return ('%(classname)s?%(searchtext)s%(filters)s&:sort=%(sort)s&:filter=%(filter)s'
371 '&:group=%(group)s&:columns=%(columns)s&:pagesize=%(pagesize)s' % d )
372 def node_matches(self, match, colspan):
373 ''' display the files and messages for a node that matched a
374 full text search
375 '''
376 w = self.client.write
377 db = self.client.db
378 message_links = []
379 file_links = []
380 if match.has_key('messages'):
381 for msgid in match['messages']:
382 k = db.msg.labelprop(1)
383 lab = db.msg.get(msgid, k)
384 msgpath = 'msg%s'%msgid
385 message_links.append('<a href="%(msgpath)s">%(lab)s</a>'
386 %locals())
387 w(_('<tr class="row-hilite"><td colspan="%s">'
388 ' Matched messages: %s</td></tr>\n')%(
389 colspan, ', '.join(message_links)))
391 if match.has_key('files'):
392 for fileid in match['files']:
393 filename = db.file.get(fileid, 'name')
394 filepath = 'file%s/%s'%(fileid, filename)
395 file_links.append('<a href="%(filepath)s">%(filename)s</a>'
396 %locals())
397 w(_('<tr class="row-hilite"><td colspan="%s">'
398 ' Matched files: %s</td></tr>\n')%(
399 colspan, ', '.join(file_links)))
401 def filter_form(self, search_text, filter, columns, group, all_columns,
402 sort, filterspec, pagesize):
403 sortspec = {}
404 for i in range(len(sort)):
405 mod = ''
406 colnm = sort[i]
407 if colnm[0] == '-':
408 mod = '-'
409 colnm = colnm[1:]
410 sortspec[colnm] = '%d%s' % (i+1, mod)
412 startwith = 0
413 rslt = []
414 w = rslt.append
416 # display the filter section
417 w( '<br>')
418 w( '<table border=0 cellspacing=0 cellpadding=1>')
419 w( '<tr class="list-header">')
420 w(_(' <th align="left" colspan="7">Filter specification...</th>'))
421 w( '</tr>')
422 # see if we have any indexed properties
423 if self.client.classname in self.client.db.config.HEADER_SEARCH_LINKS:
424 #if self.properties.has_key('messages') or self.properties.has_key('files'):
425 w( '<tr class="location-bar">')
426 w( ' <td align="right" class="form-label"><b>Search Terms</b></td>')
427 w( ' <td colspan=6 class="form-text"> <input type="text"'
428 'name="search_text" value="%s" size="50"></td>' % search_text)
429 w( '</tr>')
430 w( '<tr class="location-bar">')
431 w( ' <th align="center" width="20%"> </th>')
432 w(_(' <th align="center" width="10%">Show</th>'))
433 w(_(' <th align="center" width="10%">Group</th>'))
434 w(_(' <th align="center" width="10%">Sort</th>'))
435 w(_(' <th colspan="3" align="center">Condition</th>'))
436 w( '</tr>')
438 properties = self.client.db.getclass(self.classname).getprops()
439 all_columns = properties.keys()
440 all_columns.sort()
441 for nm in all_columns:
442 propdescr = properties.get(nm, None)
443 if not propdescr:
444 print "hey sysadmin - %s is not a property of %r" % (nm, self.classname)
445 continue
446 w( '<tr class="location-bar">')
447 w(_(' <td align="right" class="form-label"><b>%s</b></td>' % nm.capitalize()))
448 # show column - can't show multilinks
449 if isinstance(propdescr, hyperdb.Multilink):
450 w(' <td></td>')
451 else:
452 checked = columns and nm in columns or 0
453 checked = ('', 'checked')[checked]
454 w(' <td align="center" class="form-text"><input type="checkbox" name=":columns"'
455 'value="%s" %s></td>' % (nm, checked) )
456 # can only group on Link
457 if isinstance(propdescr, hyperdb.Link):
458 checked = group and nm in group or 0
459 checked = ('', 'checked')[checked]
460 w(' <td align="center" class="form-text"><input type="checkbox" name=":group"'
461 'value="%s" %s></td>' % (nm, checked) )
462 else:
463 w(' <td></td>')
464 # sort - no sort on Multilinks
465 if isinstance(propdescr, hyperdb.Multilink):
466 w('<td></td>')
467 else:
468 val = sortspec.get(nm, '')
469 w('<td align="center" class="form-text"><input type="text" name=":%s_ss" size="3"'
470 'value="%s"></td>' % (nm,val))
471 # condition
472 val = ''
473 if isinstance(propdescr, hyperdb.Link):
474 op = "is in "
475 xtra = '<a href="javascript:help_window(\'classhelp?classname=%s&properties=id,%s\', \'200\', \'400\')"><b>(list)</b></a>' \
476 % (propdescr.classname, self.client.db.getclass(propdescr.classname).labelprop())
477 val = ','.join(filterspec.get(nm, ''))
478 elif isinstance(propdescr, hyperdb.Multilink):
479 op = "contains "
480 xtra = '<a href="javascript:help_window(\'classhelp?classname=%s&properties=id,%s\', \'200\', \'400\')"><b>(list)</b></a>' \
481 % (propdescr.classname, self.client.db.getclass(propdescr.classname).labelprop())
482 val = ','.join(filterspec.get(nm, ''))
483 elif isinstance(propdescr, hyperdb.String) and nm != 'id':
484 op = "equals "
485 xtra = ""
486 val = filterspec.get(nm, '')
487 elif isinstance(propdescr, hyperdb.Boolean):
488 op = "is "
489 xtra = ""
490 val = filterspec.get(nm, None)
491 if val is not None:
492 val = 'True' and val or 'False'
493 else:
494 val = ''
495 elif isinstance(propdescr, hyperdb.Number):
496 op = "equals "
497 xtra = ""
498 val = str(filterspec.get(nm, ''))
499 else:
500 w('<td></td><td></td><td></td></tr>')
501 continue
502 checked = filter and nm in filter or 0
503 checked = ('', 'checked')[checked]
504 w( ' <td class="form-text"><input type="checkbox" name=":filter" value="%s" %s></td>' \
505 % (nm, checked))
506 w(_(' <td class="form-label" nowrap>%s</td><td class="form-text" nowrap>'
507 '<input type="text" name=":%s_fs" value="%s" size=50>%s</td>' % (op, nm, val, xtra)))
508 w( '</tr>')
509 w('<tr class="location-bar">')
510 w(' <td colspan=7><hr></td>')
511 w('</tr>')
512 w('<tr class="location-bar">')
513 w(_(' <td align="right" class="form-label">Pagesize</td>'))
514 w(' <td colspan=2 align="center" class="form-text"><input type="text" name=":pagesize"'
515 'size="3" value="%s"></td>' % pagesize)
516 w(' <td colspan=4></td>')
517 w('</tr>')
518 w('<tr class="location-bar">')
519 w(_(' <td align="right" class="form-label">Start With</td>'))
520 w(' <td colspan=2 align="center" class="form-text"><input type="text" name=":startwith"'
521 'size="3" value="%s"></td>' % startwith)
522 w(' <td colspan=3></td>')
523 w(' <td></td>')
524 w('</tr>')
525 w('<input type=hidden name=":advancedsearch" value="1">')
527 return '\n'.join(rslt)
529 def simple_filter_form(self, search_text, filter, columns, group, all_columns,
530 sort, filterspec, pagesize):
532 startwith = 0
533 rslt = []
534 w = rslt.append
536 # display the filter section
537 w( '<br>')
538 w( '<table border=0 cellspacing=0 cellpadding=1>')
539 w( '<tr class="list-header">')
540 w(_(' <th align="left" colspan="7">Query modifications...</th>'))
541 w( '</tr>')
543 if group:
544 selectedgroup = group[0]
545 groupopts = ['<select name=":group">','<option value="">--no selection--</option>']
546 else:
547 selectedgroup = None
548 groupopts = ['<select name=":group">','<option value="" selected>--no selection--</option>']
549 descending = 0
550 if sort:
551 selectedsort = sort[0]
552 if selectedsort[0] == '-':
553 selectedsort = selectedsort[1:]
554 descending = 1
555 sortopts = ['<select name=":sort">', '<option value="">--no selection--</option>']
556 else:
557 selectedsort = None
558 sortopts = ['<select name=":sort">', '<option value="" selected>--no selection--</option>']
560 for nm in all_columns:
561 propdescr = self.client.db.getclass(self.client.classname).getprops().get(nm, None)
562 if not propdescr:
563 print "hey sysadmin - %s is not a property of %r" % (nm, self.classname)
564 continue
565 if isinstance(propdescr, hyperdb.Link):
566 selected = ''
567 if nm == selectedgroup:
568 selected = 'selected'
569 groupopts.append('<option value="%s" %s>%s</option>' % (nm, selected, nm.capitalize()))
570 selected = ''
571 if nm == selectedsort:
572 selected = 'selected'
573 sortopts.append('<option value="%s" %s>%s</option>' % (nm, selected, nm.capitalize()))
574 if len(groupopts) > 2:
575 groupopts.append('</select>')
576 groupopts = '\n'.join(groupopts)
577 w('<tr class="location-bar">')
578 w(' <td align="right" class="form-label"><b>Group</b></td>')
579 w(' <td class="form-text">%s</td>' % groupopts)
580 w('</tr>')
581 if len(sortopts) > 2:
582 sortopts.append('</select>')
583 sortopts = '\n'.join(sortopts)
584 w('<tr class="location-bar">')
585 w(' <td align="right" class="form-label"><b>Sort</b></td>')
586 checked = descending and 'checked' or ''
587 w(' <td class="form-text">%s <span class="form-label">Descending</span>'
588 '<input type=checkbox name=":descending" value="1" %s></td>' % (sortopts, checked))
589 w('</tr>')
590 w('<input type=hidden name="search_text" value="%s">' % urllib.quote(search_text))
591 w('<input type=hidden name=":filter" value="%s">' % ','.join(filter))
592 w('<input type=hidden name=":columns" value="%s">' % ','.join(columns))
593 for nm in filterspec.keys():
594 w('<input type=hidden name=":%s_fs" value="%s">' % (nm, ','.join(filterspec[nm])))
595 w('<input type=hidden name=":pagesize" value="%s">' % pagesize)
597 return '\n'.join(rslt)
599 def filter_section(self, search_text, filter, columns, group, all_columns,
600 sort, filterspec, pagesize, startwith, simpleform=1):
601 w = self.client.write
602 if simpleform:
603 w(self.simple_filter_form(search_text, filter, columns, group, all_columns,
604 sort, filterspec, pagesize))
605 else:
606 w(self.filter_form(search_text, filter, columns, group, all_columns,
607 sort, filterspec, pagesize))
608 w(' <tr class="location-bar">\n')
609 w(' <td colspan=7><hr></td>\n')
610 w(' </tr>\n')
611 w(' <tr class="location-bar">\n')
612 w(' <td> </td>\n')
613 w(' <td colspan=6><input type="submit" name="Query" value="Redisplay"></td>\n')
614 w(' </tr>\n')
615 if (not simpleform
616 and self.client.db.getclass('user').getprops().has_key('queries')
617 and not self.client.user in (None, "anonymous")):
618 w(' <tr class="location-bar">\n')
619 w(' <td colspan=7><hr></td>\n')
620 w(' </tr>\n')
621 w(' <tr class="location-bar">\n')
622 w(' <td align=right class="form-label">Name</td>\n')
623 w(' <td colspan=2 class="form-text"><input type="text" name=":name" value=""></td>\n')
624 w(' <td colspan=4 rowspan=2 class="form-help">If you give the query a name '
625 'and click <b>Save</b>, it will appear on your menu. Saved queries may be '
626 'edited by going to <b>My Details</b> and clicking on the query name.</td>')
627 w(' </tr>\n')
628 w(' <tr class="location-bar">\n')
629 w(' <td> </td><input type="hidden" name=":classname" value="%s">\n' % self.classname)
630 w(' <td colspan=2><input type="submit" name="Query" value="Save"></td>\n')
631 w(' </tr>\n')
632 w('</table>\n')
634 def sortby(self, sort_name, search_text, filterspec, columns, filter, group, sort,
635 pagesize):
636 ''' Figure the link for a column heading so we can sort by that
637 column
638 '''
639 l = []
640 w = l.append
641 if search_text:
642 w('search_text=%s' % search_text)
643 for k, v in filterspec.items():
644 k = urllib.quote(k)
645 if type(v) == type([]):
646 w('%s=%s'%(k, ','.join(map(urllib.quote, v))))
647 else:
648 w('%s=%s'%(k, urllib.quote(v)))
649 if columns:
650 w(':columns=%s'%','.join(map(urllib.quote, columns)))
651 if filter:
652 w(':filter=%s'%','.join(map(urllib.quote, filter)))
653 if group:
654 w(':group=%s'%','.join(map(urllib.quote, group)))
655 w(':pagesize=%s' % pagesize)
656 w(':startwith=0')
658 # handle the sorting - if we're already sorting by this column,
659 # then reverse the sorting, otherwise set the sorting to be this
660 # column only
661 sorting = None
662 if len(sort) == 1:
663 name = sort[0]
664 dir = name[0]
665 if dir == '-' and name[1:] == sort_name:
666 sorting = ':sort=%s'%sort_name
667 elif name == sort_name:
668 sorting = ':sort=-%s'%sort_name
669 if sorting is None:
670 sorting = ':sort=%s'%sort_name
671 w(sorting)
673 return '&'.join(l)
675 class ItemTemplate(Template):
676 ''' show one node as a form '''
677 extension = '.item'
678 def __init__(self, client, templates, classname):
679 Template.__init__(self, client, templates, classname)
680 self.nodeid = client.nodeid
681 def render(self, nodeid):
682 try:
683 cl = self.cl
684 properties = self.properties
685 if (properties.has_key('type') and
686 properties.has_key('content')):
687 pass
688 # XXX we really want to return this as a downloadable...
689 # currently I handle this at a higher level by detecting 'file'
690 # designators...
692 w = self.client.write
693 w('<form onSubmit="return submit_once()" action="%s%s" method="POST" enctype="multipart/form-data">'%(
694 self.classname, nodeid))
695 try:
696 self._render()
697 except:
698 etype = sys.exc_type
699 if type(etype) is types.ClassType:
700 etype = etype.__name__
701 w('<p class="system-msg">%s: %s</p>'%(etype, sys.exc_value))
702 # make sure we don't commit any changes
703 self.client.db.rollback()
704 w('</form>')
705 finally:
706 self.cl = self.properties = self.client = None
709 class NewItemTemplate(Template):
710 ''' display a form for creating a new node '''
711 extension = '.newitem'
712 fallbackextension = '.item'
713 def __init__(self, client, templates, classname):
714 Template.__init__(self, client, templates, classname)
715 def render(self, form):
716 try:
717 self.form = form
718 w = self.client.write
719 c = self.client.classname
720 w('<form onSubmit="return submit_once()" action="new%s" method="POST" enctype="multipart/form-data">'%c)
721 for key in form.keys():
722 if key[0] == ':':
723 value = form[key].value
724 if type(value) != type([]): value = [value]
725 for value in value:
726 w('<input type="hidden" name="%s" value="%s">'%(key, value))
727 self._render()
728 w('</form>')
729 finally:
730 self.cl = self.properties = self.client = None
732 def splitargs(*args, **kws):
733 return args, kws
734 # [('permission', 'perm2,perm3'), ('assignedto', '$userid'), ('status', 'open')]
736 templatefuncs = {}
737 for nm in template_funcs.__dict__.keys():
738 if nm.startswith('do_'):
739 templatefuncs[nm[3:]] = getattr(template_funcs, nm)
741 #
742 # $Log: not supported by cvs2svn $
743 # Revision 1.109 2002/08/01 15:06:08 gmcm
744 # Use same regex to split search terms as used to index text.
745 # Fix to back_metakit for not changing journaltag on reopen.
746 # Fix htmltemplate's do_link so [No <whatever>] strings are href'd.
747 # Fix bogus "nosy edited ok" msg - the **d syntax does NOT share d between caller and callee.
748 #
749 # Revision 1.108 2002/07/31 22:40:50 gmcm
750 # Fixes to the search form and saving queries.
751 # Fixes to sorting in back_metakit.py.
752 #
753 # Revision 1.107 2002/07/30 05:27:30 richard
754 # nicer error messages, and a bugfix
755 #
756 # Revision 1.106 2002/07/30 02:41:04 richard
757 # Removed the confusing, ugly two-column sorting stuff. Column heading clicks
758 # now only sort on one column. Nice and simple and obvious.
759 #
760 # Revision 1.105 2002/07/26 08:26:59 richard
761 # Very close now. The cgi and mailgw now use the new security API. The two
762 # templates have been migrated to that setup. Lots of unit tests. Still some
763 # issue in the web form for editing Roles assigned to users.
764 #
765 # Revision 1.104 2002/07/25 07:14:05 richard
766 # Bugger it. Here's the current shape of the new security implementation.
767 # Still to do:
768 # . call the security funcs from cgi and mailgw
769 # . change shipped templates to include correct initialisation and remove
770 # the old config vars
771 # ... that seems like a lot. The bulk of the work has been done though. Honest :)
772 #
773 # Revision 1.103 2002/07/20 19:29:10 gmcm
774 # Fixes/improvements to the search form & saved queries.
775 #
776 # Revision 1.102 2002/07/18 23:07:08 richard
777 # Unit tests and a few fixes.
778 #
779 # Revision 1.101 2002/07/18 11:17:30 gmcm
780 # Add Number and Boolean types to hyperdb.
781 # Add conversion cases to web, mail & admin interfaces.
782 # Add storage/serialization cases to back_anydbm & back_metakit.
783 #
784 # Revision 1.100 2002/07/18 07:01:54 richard
785 # minor bugfix
786 #
787 # Revision 1.99 2002/07/17 12:39:10 gmcm
788 # Saving, running & editing queries.
789 #
790 # Revision 1.98 2002/07/10 00:17:46 richard
791 # . added sorting of checklist HTML display
792 #
793 # Revision 1.97 2002/07/09 05:20:09 richard
794 # . added email display function - mangles email addrs so they're not so easily
795 # scraped from the web
796 #
797 # Revision 1.96 2002/07/09 04:19:09 richard
798 # Added reindex command to roundup-admin.
799 # Fixed reindex on first access.
800 # Also fixed reindexing of entries that change.
801 #
802 # Revision 1.95 2002/07/08 15:32:06 gmcm
803 # Pagination of index pages.
804 # New search form.
805 #
806 # Revision 1.94 2002/06/27 15:38:53 gmcm
807 # Fix the cycles (a clear method, called after render, that removes
808 # the bound methods from the globals dict).
809 # Use cl.filter instead of cl.list followed by sortfunc. For some
810 # backends (Metakit), filter can sort at C speeds, cutting >10 secs
811 # off of filling in the <select...> box for assigned_to when you
812 # have 600+ users.
813 #
814 # Revision 1.93 2002/06/27 12:05:25 gmcm
815 # Default labelprops to id.
816 # In history, make sure there's a .item before making a link / multilink into an href.
817 # Also in history, cgi.escape String properties.
818 # Clean up some of the reference cycles.
819 #
820 # Revision 1.92 2002/06/11 04:57:04 richard
821 # Added optional additional property to display in a Multilink form menu.
822 #
823 # Revision 1.91 2002/05/31 00:08:02 richard
824 # can now just display a link/multilink id - useful for stylesheet stuff
825 #
826 # Revision 1.90 2002/05/25 07:16:24 rochecompaan
827 # Merged search_indexing-branch with HEAD
828 #
829 # Revision 1.89 2002/05/15 06:34:47 richard
830 # forgot to fix the templating for last change
831 #
832 # Revision 1.88 2002/04/24 08:34:35 rochecompaan
833 # Sorting was applied to all nodes of the MultiLink class instead of
834 # the nodes that are actually linked to in the "field" template
835 # function. This adds about 20+ seconds in the display of an issue if
836 # your database has a 1000 or more issue in it.
837 #
838 # Revision 1.87 2002/04/03 06:12:46 richard
839 # Fix for date properties as labels.
840 #
841 # Revision 1.86 2002/04/03 05:54:31 richard
842 # Fixed serialisation problem by moving the serialisation step out of the
843 # hyperdb.Class (get, set) into the hyperdb.Database.
844 #
845 # Also fixed htmltemplate after the showid changes I made yesterday.
846 #
847 # Unit tests for all of the above written.
848 #
849 # Revision 1.85 2002/04/02 01:40:58 richard
850 # . link() htmltemplate function now has a "showid" option for links and
851 # multilinks. When true, it only displays the linked node id as the anchor
852 # text. The link value is displayed as a tooltip using the title anchor
853 # attribute.
854 #
855 # Revision 1.84.2.2 2002/04/20 13:23:32 rochecompaan
856 # We now have a separate search page for nodes. Search links for
857 # different classes can be customized in instance_config similar to
858 # index links.
859 #
860 # Revision 1.84.2.1 2002/04/19 19:54:42 rochecompaan
861 # cgi_client.py
862 # removed search link for the time being
863 # moved rendering of matches to htmltemplate
864 # hyperdb.py
865 # filtering of nodes on full text search incorporated in filter method
866 # roundupdb.py
867 # added paramater to call of filter method
868 # roundup_indexer.py
869 # added search method to RoundupIndexer class
870 #
871 # Revision 1.84 2002/03/29 19:41:48 rochecompaan
872 # . Fixed display of mutlilink properties when using the template
873 # functions, menu and plain.
874 #
875 # Revision 1.83 2002/02/27 04:14:31 richard
876 # Ran it through pychecker, made fixes
877 #
878 # Revision 1.82 2002/02/21 23:11:45 richard
879 # . fixed some problems in date calculations (calendar.py doesn't handle over-
880 # and under-flow). Also, hour/minute/second intervals may now be more than
881 # 99 each.
882 #
883 # Revision 1.81 2002/02/21 07:21:38 richard
884 # docco
885 #
886 # Revision 1.80 2002/02/21 07:19:08 richard
887 # ... and label, width and height control for extra flavour!
888 #
889 # Revision 1.79 2002/02/21 06:57:38 richard
890 # . Added popup help for classes using the classhelp html template function.
891 # - add <display call="classhelp('priority', 'id,name,description')">
892 # to an item page, and it generates a link to a popup window which displays
893 # the id, name and description for the priority class. The description
894 # field won't exist in most installations, but it will be added to the
895 # default templates.
896 #
897 # Revision 1.78 2002/02/21 06:23:00 richard
898 # *** empty log message ***
899 #
900 # Revision 1.77 2002/02/20 05:05:29 richard
901 # . Added simple editing for classes that don't define a templated interface.
902 # - access using the admin "class list" interface
903 # - limited to admin-only
904 # - requires the csv module from object-craft (url given if it's missing)
905 #
906 # Revision 1.76 2002/02/16 09:10:52 richard
907 # oops
908 #
909 # Revision 1.75 2002/02/16 08:43:23 richard
910 # . #517906 ] Attribute order in "View customisation"
911 #
912 # Revision 1.74 2002/02/16 08:39:42 richard
913 # . #516854 ] "My Issues" and redisplay
914 #
915 # Revision 1.73 2002/02/15 07:08:44 richard
916 # . Alternate email addresses are now available for users. See the MIGRATION
917 # file for info on how to activate the feature.
918 #
919 # Revision 1.72 2002/02/14 23:39:18 richard
920 # . All forms now have "double-submit" protection when Javascript is enabled
921 # on the client-side.
922 #
923 # Revision 1.71 2002/01/23 06:15:24 richard
924 # real (non-string, duh) sorting of lists by node id
925 #
926 # Revision 1.70 2002/01/23 05:47:57 richard
927 # more HTML template cleanup and unit tests
928 #
929 # Revision 1.69 2002/01/23 05:10:27 richard
930 # More HTML template cleanup and unit tests.
931 # - download() now implemented correctly, replacing link(is_download=1) [fixed in the
932 # templates, but link(is_download=1) will still work for existing templates]
933 #
934 # Revision 1.68 2002/01/22 22:55:28 richard
935 # . htmltemplate list() wasn't sorting...
936 #
937 # Revision 1.67 2002/01/22 22:46:22 richard
938 # more htmltemplate cleanups and unit tests
939 #
940 # Revision 1.66 2002/01/22 06:35:40 richard
941 # more htmltemplate tests and cleanup
942 #
943 # Revision 1.65 2002/01/22 00:12:06 richard
944 # Wrote more unit tests for htmltemplate, and while I was at it, I polished
945 # off the implementation of some of the functions so they behave sanely.
946 #
947 # Revision 1.64 2002/01/21 03:25:59 richard
948 # oops
949 #
950 # Revision 1.63 2002/01/21 02:59:10 richard
951 # Fixed up the HTML display of history so valid links are actually displayed.
952 # Oh for some unit tests! :(
953 #
954 # Revision 1.62 2002/01/18 08:36:12 grubert
955 # . add nowrap to history table date cell i.e. <td nowrap ...
956 #
957 # Revision 1.61 2002/01/17 23:04:53 richard
958 # . much nicer history display (actualy real handling of property types etc)
959 #
960 # Revision 1.60 2002/01/17 08:48:19 grubert
961 # . display superseder as html link in history.
962 #
963 # Revision 1.59 2002/01/17 07:58:24 grubert
964 # . display links a html link in history.
965 #
966 # Revision 1.58 2002/01/15 00:50:03 richard
967 # #502949 ] index view for non-issues and redisplay
968 #
969 # Revision 1.57 2002/01/14 23:31:21 richard
970 # reverted the change that had plain() hyperlinking the link displays -
971 # that's what link() is for!
972 #
973 # Revision 1.56 2002/01/14 07:04:36 richard
974 # . plain rendering of links in the htmltemplate now generate a hyperlink to
975 # the linked node's page.
976 # ... this allows a display very similar to bugzilla's where you can actually
977 # find out information about the linked node.
978 #
979 # Revision 1.55 2002/01/14 06:45:03 richard
980 # . #502953 ] nosy-like treatment of other multilinks
981 # ... had to revert most of the previous change to the multilink field
982 # display... not good.
983 #
984 # Revision 1.54 2002/01/14 05:16:51 richard
985 # The submit buttons need a name attribute or mozilla won't submit without a
986 # file upload. Yeah, that's bloody obscure. Grr.
987 #
988 # Revision 1.53 2002/01/14 04:03:32 richard
989 # How about that ... date fields have never worked ...
990 #
991 # Revision 1.52 2002/01/14 02:20:14 richard
992 # . changed all config accesses so they access either the instance or the
993 # config attriubute on the db. This means that all config is obtained from
994 # instance_config instead of the mish-mash of classes. This will make
995 # switching to a ConfigParser setup easier too, I hope.
996 #
997 # At a minimum, this makes migration a _little_ easier (a lot easier in the
998 # 0.5.0 switch, I hope!)
999 #
1000 # Revision 1.51 2002/01/10 10:02:15 grubert
1001 # In do_history: replace "." in date by " " so html wraps more sensible.
1002 # Should this be done in date's string converter ?
1003 #
1004 # Revision 1.50 2002/01/05 02:35:10 richard
1005 # I18N'ification
1006 #
1007 # Revision 1.49 2001/12/20 15:43:01 rochecompaan
1008 # Features added:
1009 # . Multilink properties are now displayed as comma separated values in
1010 # a textbox
1011 # . The add user link is now only visible to the admin user
1012 # . Modified the mail gateway to reject submissions from unknown
1013 # addresses if ANONYMOUS_ACCESS is denied
1014 #
1015 # Revision 1.48 2001/12/20 06:13:24 rochecompaan
1016 # Bugs fixed:
1017 # . Exception handling in hyperdb for strings-that-look-like numbers got
1018 # lost somewhere
1019 # . Internet Explorer submits full path for filename - we now strip away
1020 # the path
1021 # Features added:
1022 # . Link and multilink properties are now displayed sorted in the cgi
1023 # interface
1024 #
1025 # Revision 1.47 2001/11/26 22:55:56 richard
1026 # Feature:
1027 # . Added INSTANCE_NAME to configuration - used in web and email to identify
1028 # the instance.
1029 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1030 # signature info in e-mails.
1031 # . Some more flexibility in the mail gateway and more error handling.
1032 # . Login now takes you to the page you back to the were denied access to.
1033 #
1034 # Fixed:
1035 # . Lots of bugs, thanks Roché and others on the devel mailing list!
1036 #
1037 # Revision 1.46 2001/11/24 00:53:12 jhermann
1038 # "except:" is bad, bad , bad!
1039 #
1040 # Revision 1.45 2001/11/22 15:46:42 jhermann
1041 # Added module docstrings to all modules.
1042 #
1043 # Revision 1.44 2001/11/21 23:35:45 jhermann
1044 # Added globbing for win32, and sample marking in a 2nd file to test it
1045 #
1046 # Revision 1.43 2001/11/21 04:04:43 richard
1047 # *sigh* more missing value handling
1048 #
1049 # Revision 1.42 2001/11/21 03:40:54 richard
1050 # more new property handling
1051 #
1052 # Revision 1.41 2001/11/15 10:26:01 richard
1053 # . missing "return" in filter_section (thanks Roch'e Compaan)
1054 #
1055 # Revision 1.40 2001/11/03 01:56:51 richard
1056 # More HTML compliance fixes. This will probably fix the Netscape problem
1057 # too.
1058 #
1059 # Revision 1.39 2001/11/03 01:43:47 richard
1060 # Ahah! Fixed the lynx problem - there was a hidden input field misplaced.
1061 #
1062 # Revision 1.38 2001/10/31 06:58:51 richard
1063 # Added the wrap="hard" attribute to the textarea of the note field so the
1064 # messages wrap sanely.
1065 #
1066 # Revision 1.37 2001/10/31 06:24:35 richard
1067 # Added do_stext to htmltemplate, thanks Brad Clements.
1068 #
1069 # Revision 1.36 2001/10/28 22:51:38 richard
1070 # Fixed ENOENT/WindowsError thing, thanks Juergen Hermann
1071 #
1072 # Revision 1.35 2001/10/24 00:04:41 richard
1073 # Removed the "infinite authentication loop", thanks Roch'e
1074 #
1075 # Revision 1.34 2001/10/23 22:56:36 richard
1076 # Bugfix in filter "widget" placement, thanks Roch'e
1077 #
1078 # Revision 1.33 2001/10/23 01:00:18 richard
1079 # Re-enabled login and registration access after lopping them off via
1080 # disabling access for anonymous users.
1081 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1082 # a couple of bugs while I was there. Probably introduced a couple, but
1083 # things seem to work OK at the moment.
1084 #
1085 # Revision 1.32 2001/10/22 03:25:01 richard
1086 # Added configuration for:
1087 # . anonymous user access and registration (deny/allow)
1088 # . filter "widget" location on index page (top, bottom, both)
1089 # Updated some documentation.
1090 #
1091 # Revision 1.31 2001/10/21 07:26:35 richard
1092 # feature #473127: Filenames. I modified the file.index and htmltemplate
1093 # source so that the filename is used in the link and the creation
1094 # information is displayed.
1095 #
1096 # Revision 1.30 2001/10/21 04:44:50 richard
1097 # bug #473124: UI inconsistency with Link fields.
1098 # This also prompted me to fix a fairly long-standing usability issue -
1099 # that of being able to turn off certain filters.
1100 #
1101 # Revision 1.29 2001/10/21 00:17:56 richard
1102 # CGI interface view customisation section may now be hidden (patch from
1103 # Roch'e Compaan.)
1104 #
1105 # Revision 1.28 2001/10/21 00:00:16 richard
1106 # Fixed Checklist function - wasn't always working on a list.
1107 #
1108 # Revision 1.27 2001/10/20 12:13:44 richard
1109 # Fixed grouping of non-str properties (thanks Roch'e Compaan)
1110 #
1111 # Revision 1.26 2001/10/14 10:55:00 richard
1112 # Handle empty strings in HTML template Link function
1113 #
1114 # Revision 1.25 2001/10/09 07:25:59 richard
1115 # Added the Password property type. See "pydoc roundup.password" for
1116 # implementation details. Have updated some of the documentation too.
1117 #
1118 # Revision 1.24 2001/09/27 06:45:58 richard
1119 # *gak* ... xmp is Old Skool apparently. Am using pre again by have the option
1120 # on the plain() template function to escape the text for HTML.
1121 #
1122 # Revision 1.23 2001/09/10 09:47:18 richard
1123 # Fixed bug in the generation of links to Link/Multilink in indexes.
1124 # (thanks Hubert Hoegl)
1125 # Added AssignedTo to the "classic" schema's item page.
1126 #
1127 # Revision 1.22 2001/08/30 06:01:17 richard
1128 # Fixed missing import in mailgw :(
1129 #
1130 # Revision 1.21 2001/08/16 07:34:59 richard
1131 # better CGI text searching - but hidden filter fields are disappearing...
1132 #
1133 # Revision 1.20 2001/08/15 23:43:18 richard
1134 # Fixed some isFooTypes that I missed.
1135 # Refactored some code in the CGI code.
1136 #
1137 # Revision 1.19 2001/08/12 06:32:36 richard
1138 # using isinstance(blah, Foo) now instead of isFooType
1139 #
1140 # Revision 1.18 2001/08/07 00:24:42 richard
1141 # stupid typo
1142 #
1143 # Revision 1.17 2001/08/07 00:15:51 richard
1144 # Added the copyright/license notice to (nearly) all files at request of
1145 # Bizar Software.
1146 #
1147 # Revision 1.16 2001/08/01 03:52:23 richard
1148 # Checklist was using wrong name.
1149 #
1150 # Revision 1.15 2001/07/30 08:12:17 richard
1151 # Added time logging and file uploading to the templates.
1152 #
1153 # Revision 1.14 2001/07/30 06:17:45 richard
1154 # Features:
1155 # . Added ability for cgi newblah forms to indicate that the new node
1156 # should be linked somewhere.
1157 # Fixed:
1158 # . Fixed the agument handling for the roundup-admin find command.
1159 # . Fixed handling of summary when no note supplied for newblah. Again.
1160 # . Fixed detection of no form in htmltemplate Field display.
1161 #
1162 # Revision 1.13 2001/07/30 02:37:53 richard
1163 # Temporary measure until we have decent schema migration.
1164 #
1165 # Revision 1.12 2001/07/30 01:24:33 richard
1166 # Handles new node display now.
1167 #
1168 # Revision 1.11 2001/07/29 09:31:35 richard
1169 # oops
1170 #
1171 # Revision 1.10 2001/07/29 09:28:23 richard
1172 # Fixed sorting by clicking on column headings.
1173 #
1174 # Revision 1.9 2001/07/29 08:27:40 richard
1175 # Fixed handling of passed-in values in form elements (ie. during a
1176 # drill-down)
1177 #
1178 # Revision 1.8 2001/07/29 07:01:39 richard
1179 # Added vim command to all source so that we don't get no steenkin' tabs :)
1180 #
1181 # Revision 1.7 2001/07/29 05:36:14 richard
1182 # Cleanup of the link label generation.
1183 #
1184 # Revision 1.6 2001/07/29 04:06:42 richard
1185 # Fixed problem in link display when Link value is None.
1186 #
1187 # Revision 1.5 2001/07/28 08:17:09 richard
1188 # fixed use of stylesheet
1189 #
1190 # Revision 1.4 2001/07/28 07:59:53 richard
1191 # Replaced errno integers with their module values.
1192 # De-tabbed templatebuilder.py
1193 #
1194 # Revision 1.3 2001/07/25 03:39:47 richard
1195 # Hrm - displaying links to classes that don't specify a key property. I've
1196 # got it defaulting to 'name', then 'title' and then a "random" property (first
1197 # one returned by getprops().keys().
1198 # Needs to be moved onto the Class I think...
1199 #
1200 # Revision 1.2 2001/07/22 12:09:32 richard
1201 # Final commit of Grande Splite
1202 #
1203 # Revision 1.1 2001/07/22 11:58:35 richard
1204 # More Grande Splite
1205 #
1206 #
1207 # vim: set filetype=python ts=4 sw=4 et si