75af618ca1c79673e5ef832554dce1b3f90d26c0
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.73 2002-02-15 07:08:44 richard Exp $
20 __doc__ = """
21 Template engine.
22 """
24 import os, re, StringIO, urllib, cgi, errno
26 import hyperdb, date, password
27 from i18n import _
29 # This imports the StructureText functionality for the do_stext function
30 # get it from http://dev.zope.org/Members/jim/StructuredTextWiki/NGReleases
31 try:
32 from StructuredText.StructuredText import HTML as StructuredText
33 except ImportError:
34 StructuredText = None
36 class TemplateFunctions:
37 def __init__(self):
38 self.form = None
39 self.nodeid = None
40 self.filterspec = None
41 self.globals = {}
42 for key in TemplateFunctions.__dict__.keys():
43 if key[:3] == 'do_':
44 self.globals[key[3:]] = getattr(self, key)
46 def do_plain(self, property, escape=0):
47 ''' display a String property directly;
49 display a Date property in a specified time zone with an option to
50 omit the time from the date stamp;
52 for a Link or Multilink property, display the key strings of the
53 linked nodes (or the ids if the linked class has no key property)
54 '''
55 if not self.nodeid and self.form is None:
56 return _('[Field: not called from item]')
57 propclass = self.properties[property]
58 if self.nodeid:
59 # make sure the property is a valid one
60 # TODO: this tests, but we should handle the exception
61 prop_test = self.cl.getprops()[property]
63 # get the value for this property
64 try:
65 value = self.cl.get(self.nodeid, property)
66 except KeyError:
67 # a KeyError here means that the node doesn't have a value
68 # for the specified property
69 if isinstance(propclass, hyperdb.Multilink): value = []
70 else: value = ''
71 else:
72 # TODO: pull the value from the form
73 if isinstance(propclass, hyperdb.Multilink): value = []
74 else: value = ''
75 if isinstance(propclass, hyperdb.String):
76 if value is None: value = ''
77 else: value = str(value)
78 elif isinstance(propclass, hyperdb.Password):
79 if value is None: value = ''
80 else: value = _('*encrypted*')
81 elif isinstance(propclass, hyperdb.Date):
82 # this gives "2002-01-17.06:54:39", maybe replace the "." by a " ".
83 value = str(value)
84 elif isinstance(propclass, hyperdb.Interval):
85 value = str(value)
86 elif isinstance(propclass, hyperdb.Link):
87 linkcl = self.db.classes[propclass.classname]
88 k = linkcl.labelprop()
89 if value:
90 value = linkcl.get(value, k)
91 else:
92 value = _('[unselected]')
93 elif isinstance(propclass, hyperdb.Multilink):
94 linkcl = self.db.classes[propclass.classname]
95 k = linkcl.labelprop()
96 value = ', '.join(value)
97 else:
98 s = _('Plain: bad propclass "%(propclass)s"')%locals()
99 if escape:
100 value = cgi.escape(value)
101 return value
103 def do_stext(self, property, escape=0):
104 '''Render as structured text using the StructuredText module
105 (see above for details)
106 '''
107 s = self.do_plain(property, escape=escape)
108 if not StructuredText:
109 return s
110 return StructuredText(s,level=1,header=0)
112 def determine_value(self, property):
113 '''determine the value of a property using the node, form or
114 filterspec
115 '''
116 propclass = self.properties[property]
117 if self.nodeid:
118 value = self.cl.get(self.nodeid, property, None)
119 if isinstance(propclass, hyperdb.Multilink) and value is None:
120 return []
121 return value
122 elif self.filterspec is not None:
123 if isinstance(propclass, hyperdb.Multilink):
124 return self.filterspec.get(property, [])
125 else:
126 return self.filterspec.get(property, '')
127 # TODO: pull the value from the form
128 if isinstance(propclass, hyperdb.Multilink):
129 return []
130 else:
131 return ''
133 def make_sort_function(self, classname):
134 '''Make a sort function for a given class
135 '''
136 linkcl = self.db.classes[classname]
137 if linkcl.getprops().has_key('order'):
138 sort_on = 'order'
139 else:
140 sort_on = linkcl.labelprop()
141 def sortfunc(a, b, linkcl=linkcl, sort_on=sort_on):
142 return cmp(linkcl.get(a, sort_on), linkcl.get(b, sort_on))
143 return sortfunc
145 def do_field(self, property, size=None, showid=0):
146 ''' display a property like the plain displayer, but in a text field
147 to be edited
149 Note: if you would prefer an option list style display for
150 link or multilink editing, use menu().
151 '''
152 if not self.nodeid and self.form is None and self.filterspec is None:
153 return _('[Field: not called from item]')
155 if size is None:
156 size = 30
158 propclass = self.properties[property]
160 # get the value
161 value = self.determine_value(property)
163 # now display
164 if (isinstance(propclass, hyperdb.String) or
165 isinstance(propclass, hyperdb.Date) or
166 isinstance(propclass, hyperdb.Interval)):
167 if value is None:
168 value = ''
169 else:
170 value = cgi.escape(str(value))
171 value = '"'.join(value.split('"'))
172 s = '<input name="%s" value="%s" size="%s">'%(property, value, size)
173 elif isinstance(propclass, hyperdb.Password):
174 s = '<input type="password" name="%s" size="%s">'%(property, size)
175 elif isinstance(propclass, hyperdb.Link):
176 sortfunc = self.make_sort_function(propclass.classname)
177 linkcl = self.db.classes[propclass.classname]
178 options = linkcl.list()
179 options.sort(sortfunc)
180 # TODO: make this a field display, not a menu one!
181 l = ['<select name="%s">'%property]
182 k = linkcl.labelprop()
183 if value is None:
184 s = 'selected '
185 else:
186 s = ''
187 l.append(_('<option %svalue="-1">- no selection -</option>')%s)
188 for optionid in options:
189 option = linkcl.get(optionid, k)
190 s = ''
191 if optionid == value:
192 s = 'selected '
193 if showid:
194 lab = '%s%s: %s'%(propclass.classname, optionid, option)
195 else:
196 lab = option
197 if size is not None and len(lab) > size:
198 lab = lab[:size-3] + '...'
199 lab = cgi.escape(lab)
200 l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
201 l.append('</select>')
202 s = '\n'.join(l)
203 elif isinstance(propclass, hyperdb.Multilink):
204 sortfunc = self.make_sort_function(propclass.classname)
205 linkcl = self.db.classes[propclass.classname]
206 list = linkcl.list()
207 list.sort(sortfunc)
208 l = []
209 # map the id to the label property
210 if not showid:
211 k = linkcl.labelprop()
212 value = [linkcl.get(v, k) for v in value]
213 value = cgi.escape(','.join(value))
214 s = '<input name="%s" size="%s" value="%s">'%(property, size, value)
215 else:
216 s = _('Plain: bad propclass "%(propclass)s"')%locals()
217 return s
219 def do_multiline(self, property, rows=5, cols=40):
220 ''' display a string property in a multiline text edit field
221 '''
222 if not self.nodeid and self.form is None and self.filterspec is None:
223 return _('[Multiline: not called from item]')
225 propclass = self.properties[property]
227 # make sure this is a link property
228 if not isinstance(propclass, hyperdb.String):
229 return _('[Multiline: not a string]')
231 # get the value
232 value = self.determine_value(property)
233 if value is None:
234 value = ''
236 # display
237 return '<textarea name="%s" rows="%s" cols="%s">%s</textarea>'%(
238 property, rows, cols, value)
240 def do_menu(self, property, size=None, height=None, showid=0):
241 ''' for a Link property, display a menu of the available choices
242 '''
243 if not self.nodeid and self.form is None and self.filterspec is None:
244 return _('[Field: not called from item]')
246 propclass = self.properties[property]
248 # make sure this is a link property
249 if not (isinstance(propclass, hyperdb.Link) or
250 isinstance(propclass, hyperdb.Multilink)):
251 return _('[Menu: not a link]')
253 # sort function
254 sortfunc = self.make_sort_function(propclass.classname)
256 # get the value
257 value = self.determine_value(property)
259 # display
260 if isinstance(propclass, hyperdb.Link):
261 linkcl = self.db.classes[propclass.classname]
262 l = ['<select name="%s">'%property]
263 k = linkcl.labelprop()
264 s = ''
265 if value is None:
266 s = 'selected '
267 l.append(_('<option %svalue="-1">- no selection -</option>')%s)
268 options = linkcl.list()
269 options.sort(sortfunc)
270 for optionid in options:
271 option = linkcl.get(optionid, k)
272 s = ''
273 if optionid == value:
274 s = 'selected '
275 if showid:
276 lab = '%s%s: %s'%(propclass.classname, optionid, option)
277 else:
278 lab = option
279 if size is not None and len(lab) > size:
280 lab = lab[:size-3] + '...'
281 lab = cgi.escape(lab)
282 l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
283 l.append('</select>')
284 return '\n'.join(l)
285 if isinstance(propclass, hyperdb.Multilink):
286 linkcl = self.db.classes[propclass.classname]
287 options = linkcl.list()
288 options.sort(sortfunc)
289 height = height or min(len(options), 7)
290 l = ['<select multiple name="%s" size="%s">'%(property, height)]
291 k = linkcl.labelprop()
292 for optionid in options:
293 option = linkcl.get(optionid, k)
294 s = ''
295 if optionid in value:
296 s = 'selected '
297 if showid:
298 lab = '%s%s: %s'%(propclass.classname, optionid, option)
299 else:
300 lab = option
301 if size is not None and len(lab) > size:
302 lab = lab[:size-3] + '...'
303 lab = cgi.escape(lab)
304 l.append('<option %svalue="%s">%s</option>'%(s, optionid,
305 lab))
306 l.append('</select>')
307 return '\n'.join(l)
308 return _('[Menu: not a link]')
310 #XXX deviates from spec
311 def do_link(self, property=None, is_download=0):
312 '''For a Link or Multilink property, display the names of the linked
313 nodes, hyperlinked to the item views on those nodes.
314 For other properties, link to this node with the property as the
315 text.
317 If is_download is true, append the property value to the generated
318 URL so that the link may be used as a download link and the
319 downloaded file name is correct.
320 '''
321 if not self.nodeid and self.form is None:
322 return _('[Link: not called from item]')
324 # get the value
325 value = self.determine_value(property)
326 if not value:
327 return _('[no %(propname)s]')%{'propname':property.capitalize()}
329 propclass = self.properties[property]
330 if isinstance(propclass, hyperdb.Link):
331 linkname = propclass.classname
332 linkcl = self.db.classes[linkname]
333 k = linkcl.labelprop()
334 linkvalue = cgi.escape(linkcl.get(value, k))
335 if is_download:
336 return '<a href="%s%s/%s">%s</a>'%(linkname, value,
337 linkvalue, linkvalue)
338 else:
339 return '<a href="%s%s">%s</a>'%(linkname, value, linkvalue)
340 if isinstance(propclass, hyperdb.Multilink):
341 linkname = propclass.classname
342 linkcl = self.db.classes[linkname]
343 k = linkcl.labelprop()
344 l = []
345 for value in value:
346 linkvalue = cgi.escape(linkcl.get(value, k))
347 if is_download:
348 l.append('<a href="%s%s/%s">%s</a>'%(linkname, value,
349 linkvalue, linkvalue))
350 else:
351 l.append('<a href="%s%s">%s</a>'%(linkname, value,
352 linkvalue))
353 return ', '.join(l)
354 if is_download:
355 return '<a href="%s%s/%s">%s</a>'%(self.classname, self.nodeid,
356 value, value)
357 else:
358 return '<a href="%s%s">%s</a>'%(self.classname, self.nodeid, value)
360 def do_count(self, property, **args):
361 ''' for a Multilink property, display a count of the number of links in
362 the list
363 '''
364 if not self.nodeid:
365 return _('[Count: not called from item]')
367 propclass = self.properties[property]
368 if not isinstance(propclass, hyperdb.Multilink):
369 return _('[Count: not a Multilink]')
371 # figure the length then...
372 value = self.cl.get(self.nodeid, property)
373 return str(len(value))
375 # XXX pretty is definitely new ;)
376 def do_reldate(self, property, pretty=0):
377 ''' display a Date property in terms of an interval relative to the
378 current date (e.g. "+ 3w", "- 2d").
380 with the 'pretty' flag, make it pretty
381 '''
382 if not self.nodeid and self.form is None:
383 return _('[Reldate: not called from item]')
385 propclass = self.properties[property]
386 if not isinstance(propclass, hyperdb.Date):
387 return _('[Reldate: not a Date]')
389 if self.nodeid:
390 value = self.cl.get(self.nodeid, property)
391 else:
392 return ''
393 if not value:
394 return ''
396 # figure the interval
397 interval = value - date.Date('.')
398 if pretty:
399 if not self.nodeid:
400 return _('now')
401 pretty = interval.pretty()
402 if pretty is None:
403 pretty = value.pretty()
404 return pretty
405 return str(interval)
407 def do_download(self, property, **args):
408 ''' show a Link("file") or Multilink("file") property using links that
409 allow you to download files
410 '''
411 if not self.nodeid:
412 return _('[Download: not called from item]')
413 return self.do_link(property, is_download=1)
416 def do_checklist(self, property, **args):
417 ''' for a Link or Multilink property, display checkboxes for the
418 available choices to permit filtering
419 '''
420 propclass = self.properties[property]
421 if (not isinstance(propclass, hyperdb.Link) and not
422 isinstance(propclass, hyperdb.Multilink)):
423 return _('[Checklist: not a link]')
425 # get our current checkbox state
426 if self.nodeid:
427 # get the info from the node - make sure it's a list
428 if isinstance(propclass, hyperdb.Link):
429 value = [self.cl.get(self.nodeid, property)]
430 else:
431 value = self.cl.get(self.nodeid, property)
432 elif self.filterspec is not None:
433 # get the state from the filter specification (always a list)
434 value = self.filterspec.get(property, [])
435 else:
436 # it's a new node, so there's no state
437 value = []
439 # so we can map to the linked node's "lable" property
440 linkcl = self.db.classes[propclass.classname]
441 l = []
442 k = linkcl.labelprop()
443 for optionid in linkcl.list():
444 option = cgi.escape(linkcl.get(optionid, k))
445 if optionid in value or option in value:
446 checked = 'checked'
447 else:
448 checked = ''
449 l.append('%s:<input type="checkbox" %s name="%s" value="%s">'%(
450 option, checked, property, option))
452 # for Links, allow the "unselected" option too
453 if isinstance(propclass, hyperdb.Link):
454 if value is None or '-1' in value:
455 checked = 'checked'
456 else:
457 checked = ''
458 l.append(_('[unselected]:<input type="checkbox" %s name="%s" '
459 'value="-1">')%(checked, property))
460 return '\n'.join(l)
462 def do_note(self, rows=5, cols=80):
463 ''' display a "note" field, which is a text area for entering a note to
464 go along with a change.
465 '''
466 # TODO: pull the value from the form
467 return '<textarea name="__note" wrap="hard" rows=%s cols=%s>'\
468 '</textarea>'%(rows, cols)
470 # XXX new function
471 def do_list(self, property, reverse=0):
472 ''' list the items specified by property using the standard index for
473 the class
474 '''
475 propcl = self.properties[property]
476 if not isinstance(propcl, hyperdb.Multilink):
477 return _('[List: not a Multilink]')
479 value = self.determine_value(property)
480 if not value:
481 return ''
483 # sort, possibly revers and then re-stringify
484 value = map(int, value)
485 value.sort()
486 if reverse:
487 value.reverse()
488 value = map(str, value)
490 # render the sub-index into a string
491 fp = StringIO.StringIO()
492 try:
493 write_save = self.client.write
494 self.client.write = fp.write
495 index = IndexTemplate(self.client, self.templates, propcl.classname)
496 index.render(nodeids=value, show_display_form=0)
497 finally:
498 self.client.write = write_save
500 return fp.getvalue()
502 # XXX new function
503 def do_history(self, direction='descending'):
504 ''' list the history of the item
506 If "direction" is 'descending' then the most recent event will
507 be displayed first. If it is 'ascending' then the oldest event
508 will be displayed first.
509 '''
510 if self.nodeid is None:
511 return _("[History: node doesn't exist]")
513 l = ['<table width=100% border=0 cellspacing=0 cellpadding=2>',
514 '<tr class="list-header">',
515 _('<th align=left><span class="list-item">Date</span></th>'),
516 _('<th align=left><span class="list-item">User</span></th>'),
517 _('<th align=left><span class="list-item">Action</span></th>'),
518 _('<th align=left><span class="list-item">Args</span></th>'),
519 '</tr>']
521 comments = {}
522 history = self.cl.history(self.nodeid)
523 history.sort()
524 if direction == 'descending':
525 history.reverse()
526 for id, evt_date, user, action, args in history:
527 date_s = str(evt_date).replace("."," ")
528 arg_s = ''
529 if action == 'link' and type(args) == type(()):
530 if len(args) == 3:
531 linkcl, linkid, key = args
532 arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
533 linkcl, linkid, key)
534 else:
535 arg_s = str(arg)
537 elif action == 'unlink' and type(args) == type(()):
538 if len(args) == 3:
539 linkcl, linkid, key = args
540 arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
541 linkcl, linkid, key)
542 else:
543 arg_s = str(arg)
545 elif type(args) == type({}):
546 cell = []
547 for k in args.keys():
548 # try to get the relevant property and treat it
549 # specially
550 try:
551 prop = self.properties[k]
552 except:
553 prop = None
554 if prop is not None:
555 if args[k] and (isinstance(prop, hyperdb.Multilink) or
556 isinstance(prop, hyperdb.Link)):
557 # figure what the link class is
558 classname = prop.classname
559 try:
560 linkcl = self.db.classes[classname]
561 except KeyError, message:
562 labelprop = None
563 comments[classname] = _('''The linked class
564 %(classname)s no longer exists''')%locals()
565 labelprop = linkcl.labelprop()
567 if isinstance(prop, hyperdb.Multilink) and \
568 len(args[k]) > 0:
569 ml = []
570 for linkid in args[k]:
571 label = classname + linkid
572 # if we have a label property, try to use it
573 # TODO: test for node existence even when
574 # there's no labelprop!
575 try:
576 if labelprop is not None:
577 label = linkcl.get(linkid, labelprop)
578 except IndexError:
579 comments['no_link'] = _('''<strike>The
580 linked node no longer
581 exists</strike>''')
582 ml.append('<strike>%s</strike>'%label)
583 else:
584 ml.append('<a href="%s%s">%s</a>'%(
585 classname, linkid, label))
586 cell.append('%s:\n %s'%(k, ',\n '.join(ml)))
587 elif isinstance(prop, hyperdb.Link) and args[k]:
588 label = classname + args[k]
589 # if we have a label property, try to use it
590 # TODO: test for node existence even when
591 # there's no labelprop!
592 if labelprop is not None:
593 try:
594 label = linkcl.get(args[k], labelprop)
595 except IndexError:
596 comments['no_link'] = _('''<strike>The
597 linked node no longer
598 exists</strike>''')
599 cell.append(' <strike>%s</strike>,\n'%label)
600 # "flag" this is done .... euwww
601 label = None
602 if label is not None:
603 cell.append('%s: <a href="%s%s">%s</a>\n'%(k,
604 classname, args[k], label))
606 elif isinstance(prop, hyperdb.Date) and args[k]:
607 d = date.Date(args[k])
608 cell.append('%s: %s'%(k, str(d)))
610 elif isinstance(prop, hyperdb.Interval) and args[k]:
611 d = date.Interval(args[k])
612 cell.append('%s: %s'%(k, str(d)))
614 elif not args[k]:
615 cell.append('%s: (no value)\n'%k)
617 else:
618 cell.append('%s: %s\n'%(k, str(args[k])))
619 else:
620 # property no longer exists
621 comments['no_exist'] = _('''<em>The indicated property
622 no longer exists</em>''')
623 cell.append('<em>%s: %s</em>\n'%(k, str(args[k])))
624 arg_s = '<br />'.join(cell)
625 else:
626 # unkown event!!
627 comments['unknown'] = _('''<strong><em>This event is not
628 handled by the history display!</em></strong>''')
629 arg_s = '<strong><em>' + str(args) + '</em></strong>'
630 date_s = date_s.replace(' ', ' ')
631 l.append('<tr><td nowrap valign=top>%s</td><td valign=top>%s</td>'
632 '<td valign=top>%s</td><td valign=top>%s</td></tr>'%(date_s,
633 user, action, arg_s))
634 if comments:
635 l.append(_('<tr><td colspan=4><strong>Note:</strong></td></tr>'))
636 for entry in comments.values():
637 l.append('<tr><td colspan=4>%s</td></tr>'%entry)
638 l.append('</table>')
639 return '\n'.join(l)
641 # XXX new function
642 def do_submit(self):
643 ''' add a submit button for the item
644 '''
645 if self.nodeid:
646 return _('<input type="submit" name="submit" value="Submit Changes">')
647 elif self.form is not None:
648 return _('<input type="submit" name="submit" value="Submit New Entry">')
649 else:
650 return _('[Submit: not called from item]')
653 #
654 # INDEX TEMPLATES
655 #
656 class IndexTemplateReplace:
657 def __init__(self, globals, locals, props):
658 self.globals = globals
659 self.locals = locals
660 self.props = props
662 replace=re.compile(
663 r'((<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
664 r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I|re.S)
665 def go(self, text):
666 return self.replace.sub(self, text)
668 def __call__(self, m, filter=None, columns=None, sort=None, group=None):
669 if m.group('name'):
670 if m.group('name') in self.props:
671 text = m.group('text')
672 replace = IndexTemplateReplace(self.globals, {}, self.props)
673 return replace.go(m.group('text'))
674 else:
675 return ''
676 if m.group('display'):
677 command = m.group('command')
678 return eval(command, self.globals, self.locals)
679 print '*** unhandled match', m.groupdict()
681 class IndexTemplate(TemplateFunctions):
682 def __init__(self, client, templates, classname):
683 self.client = client
684 self.instance = client.instance
685 self.templates = templates
686 self.classname = classname
688 # derived
689 self.db = self.client.db
690 self.cl = self.db.classes[self.classname]
691 self.properties = self.cl.getprops()
693 TemplateFunctions.__init__(self)
695 col_re=re.compile(r'<property\s+name="([^>]+)">')
696 def render(self, filterspec={}, filter=[], columns=[], sort=[], group=[],
697 show_display_form=1, nodeids=None, show_customization=1):
698 self.filterspec = filterspec
700 w = self.client.write
702 # get the filter template
703 try:
704 filter_template = open(os.path.join(self.templates,
705 self.classname+'.filter')).read()
706 all_filters = self.col_re.findall(filter_template)
707 except IOError, error:
708 if error.errno not in (errno.ENOENT, errno.ESRCH): raise
709 filter_template = None
710 all_filters = []
712 # XXX deviate from spec here ...
713 # load the index section template and figure the default columns from it
714 template = open(os.path.join(self.templates,
715 self.classname+'.index')).read()
716 all_columns = self.col_re.findall(template)
717 if not columns:
718 columns = []
719 for name in all_columns:
720 columns.append(name)
721 else:
722 # re-sort columns to be the same order as all_columns
723 l = []
724 for name in all_columns:
725 if name in columns:
726 l.append(name)
727 columns = l
729 # display the filter section
730 if (show_display_form and
731 self.instance.FILTER_POSITION in ('top and bottom', 'top')):
732 w('<form onSubmit="return submit_once()" action="%s">\n'%self.classname)
733 self.filter_section(filter_template, filter, columns, group,
734 all_filters, all_columns, show_customization)
735 # make sure that the sorting doesn't get lost either
736 if sort:
737 w('<input type="hidden" name=":sort" value="%s">'%
738 ','.join(sort))
739 w('</form>\n')
742 # now display the index section
743 w('<table width=100% border=0 cellspacing=0 cellpadding=2>\n')
744 w('<tr class="list-header">\n')
745 for name in columns:
746 cname = name.capitalize()
747 if show_display_form:
748 sb = self.sortby(name, filterspec, columns, filter, group, sort)
749 anchor = "%s?%s"%(self.classname, sb)
750 w('<td><span class="list-header"><a href="%s">%s</a></span></td>\n'%(
751 anchor, cname))
752 else:
753 w('<td><span class="list-header">%s</span></td>\n'%cname)
754 w('</tr>\n')
756 # this stuff is used for group headings - optimise the group names
757 old_group = None
758 group_names = []
759 if group:
760 for name in group:
761 if name[0] == '-': group_names.append(name[1:])
762 else: group_names.append(name)
764 # now actually loop through all the nodes we get from the filter and
765 # apply the template
766 if nodeids is None:
767 nodeids = self.cl.filter(filterspec, sort, group)
768 for nodeid in nodeids:
769 # check for a group heading
770 if group_names:
771 this_group = [self.cl.get(nodeid, name, _('[no value]')) for name in group_names]
772 if this_group != old_group:
773 l = []
774 for name in group_names:
775 prop = self.properties[name]
776 if isinstance(prop, hyperdb.Link):
777 group_cl = self.db.classes[prop.classname]
778 key = group_cl.getkey()
779 value = self.cl.get(nodeid, name)
780 if value is None:
781 l.append(_('[unselected %(classname)s]')%{
782 'classname': prop.classname})
783 else:
784 l.append(group_cl.get(self.cl.get(nodeid,
785 name), key))
786 elif isinstance(prop, hyperdb.Multilink):
787 group_cl = self.db.classes[prop.classname]
788 key = group_cl.getkey()
789 for value in self.cl.get(nodeid, name):
790 l.append(group_cl.get(value, key))
791 else:
792 value = self.cl.get(nodeid, name, _('[no value]'))
793 if value is None:
794 value = _('[empty %(name)s]')%locals()
795 else:
796 value = str(value)
797 l.append(value)
798 w('<tr class="section-bar">'
799 '<td align=middle colspan=%s><strong>%s</strong></td></tr>'%(
800 len(columns), ', '.join(l)))
801 old_group = this_group
803 # display this node's row
804 replace = IndexTemplateReplace(self.globals, locals(), columns)
805 self.nodeid = nodeid
806 w(replace.go(template))
807 self.nodeid = None
809 w('</table>')
811 # display the filter section
812 if (show_display_form and hasattr(self.instance, 'FILTER_POSITION') and
813 self.instance.FILTER_POSITION in ('top and bottom', 'bottom')):
814 w('<form onSubmit="return submit_once()" action="%s">\n'%self.classname)
815 self.filter_section(filter_template, filter, columns, group,
816 all_filters, all_columns, show_customization)
817 # make sure that the sorting doesn't get lost either
818 if sort:
819 w('<input type="hidden" name=":sort" value="%s">'%
820 ','.join(sort))
821 w('</form>\n')
824 def filter_section(self, template, filter, columns, group, all_filters,
825 all_columns, show_customization):
827 w = self.client.write
829 # wrap the template in a single table to ensure the whole widget
830 # is displayed at once
831 w('<table><tr><td>')
833 if template and filter:
834 # display the filter section
835 w('<table width=100% border=0 cellspacing=0 cellpadding=2>')
836 w('<tr class="location-bar">')
837 w(_(' <th align="left" colspan="2">Filter specification...</th>'))
838 w('</tr>')
839 replace = IndexTemplateReplace(self.globals, locals(), filter)
840 w(replace.go(template))
841 w('<tr class="location-bar"><td width="1%%"> </td>')
842 w(_('<td><input type="submit" name="action" value="Redisplay"></td></tr>'))
843 w('</table>')
845 # now add in the filter/columns/group/etc config table form
846 w('<input type="hidden" name="show_customization" value="%s">' %
847 show_customization )
848 w('<table width=100% border=0 cellspacing=0 cellpadding=2>\n')
849 names = []
850 for name in self.properties.keys():
851 if name in all_filters or name in all_columns:
852 names.append(name)
853 if show_customization:
854 action = '-'
855 else:
856 action = '+'
857 # hide the values for filters, columns and grouping in the form
858 # if the customization widget is not visible
859 for name in names:
860 if all_filters and name in filter:
861 w('<input type="hidden" name=":filter" value="%s">' % name)
862 if all_columns and name in columns:
863 w('<input type="hidden" name=":columns" value="%s">' % name)
864 if all_columns and name in group:
865 w('<input type="hidden" name=":group" value="%s">' % name)
867 # TODO: The widget style can go into the stylesheet
868 w(_('<th align="left" colspan=%s>'
869 '<input style="height : 1em; width : 1em; font-size: 12pt" type="submit" name="action" value="%s"> View '
870 'customisation...</th></tr>\n')%(len(names)+1, action))
872 if not show_customization:
873 w('</table>\n')
874 return
876 w('<tr class="location-bar"><th> </th>')
877 for name in names:
878 w('<th>%s</th>'%name.capitalize())
879 w('</tr>\n')
881 # Filter
882 if all_filters:
883 w(_('<tr><th width="1%" align=right class="location-bar">Filters</th>\n'))
884 for name in names:
885 if name not in all_filters:
886 w('<td> </td>')
887 continue
888 if name in filter: checked=' checked'
889 else: checked=''
890 w('<td align=middle>\n')
891 w(' <input type="checkbox" name=":filter" value="%s" '
892 '%s></td>\n'%(name, checked))
893 w('</tr>\n')
895 # Columns
896 if all_columns:
897 w(_('<tr><th width="1%" align=right class="location-bar">Columns</th>\n'))
898 for name in names:
899 if name not in all_columns:
900 w('<td> </td>')
901 continue
902 if name in columns: checked=' checked'
903 else: checked=''
904 w('<td align=middle>\n')
905 w(' <input type="checkbox" name=":columns" value="%s"'
906 '%s></td>\n'%(name, checked))
907 w('</tr>\n')
909 # Grouping
910 w(_('<tr><th width="1%" align=right class="location-bar">Grouping</th>\n'))
911 for name in names:
912 prop = self.properties[name]
913 if name not in all_columns:
914 w('<td> </td>')
915 continue
916 if name in group: checked=' checked'
917 else: checked=''
918 w('<td align=middle>\n')
919 w(' <input type="checkbox" name=":group" value="%s"'
920 '%s></td>\n'%(name, checked))
921 w('</tr>\n')
923 w('<tr class="location-bar"><td width="1%"> </td>')
924 w('<td colspan="%s">'%len(names))
925 w(_('<input type="submit" name="action" value="Redisplay"></td>'))
926 w('</tr>\n')
927 w('</table>\n')
929 # and the outer table
930 w('</td></tr></table>')
933 def sortby(self, sort_name, filterspec, columns, filter, group, sort):
934 l = []
935 w = l.append
936 for k, v in filterspec.items():
937 k = urllib.quote(k)
938 if type(v) == type([]):
939 w('%s=%s'%(k, ','.join(map(urllib.quote, v))))
940 else:
941 w('%s=%s'%(k, urllib.quote(v)))
942 if columns:
943 w(':columns=%s'%','.join(map(urllib.quote, columns)))
944 if filter:
945 w(':filter=%s'%','.join(map(urllib.quote, filter)))
946 if group:
947 w(':group=%s'%','.join(map(urllib.quote, group)))
948 m = []
949 s_dir = ''
950 for name in sort:
951 dir = name[0]
952 if dir == '-':
953 name = name[1:]
954 else:
955 dir = ''
956 if sort_name == name:
957 if dir == '-':
958 s_dir = ''
959 else:
960 s_dir = '-'
961 else:
962 m.append(dir+urllib.quote(name))
963 m.insert(0, s_dir+urllib.quote(sort_name))
964 # so things don't get completely out of hand, limit the sort to
965 # two columns
966 w(':sort=%s'%','.join(m[:2]))
967 return '&'.join(l)
969 #
970 # ITEM TEMPLATES
971 #
972 class ItemTemplateReplace:
973 def __init__(self, globals, locals, cl, nodeid):
974 self.globals = globals
975 self.locals = locals
976 self.cl = cl
977 self.nodeid = nodeid
979 replace=re.compile(
980 r'((<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
981 r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I|re.S)
982 def go(self, text):
983 return self.replace.sub(self, text)
985 def __call__(self, m, filter=None, columns=None, sort=None, group=None):
986 if m.group('name'):
987 if self.nodeid and self.cl.get(self.nodeid, m.group('name')):
988 replace = ItemTemplateReplace(self.globals, {}, self.cl,
989 self.nodeid)
990 return replace.go(m.group('text'))
991 else:
992 return ''
993 if m.group('display'):
994 command = m.group('command')
995 return eval(command, self.globals, self.locals)
996 print '*** unhandled match', m.groupdict()
999 class ItemTemplate(TemplateFunctions):
1000 def __init__(self, client, templates, classname):
1001 self.client = client
1002 self.instance = client.instance
1003 self.templates = templates
1004 self.classname = classname
1006 # derived
1007 self.db = self.client.db
1008 self.cl = self.db.classes[self.classname]
1009 self.properties = self.cl.getprops()
1011 TemplateFunctions.__init__(self)
1013 def render(self, nodeid):
1014 self.nodeid = nodeid
1016 if (self.properties.has_key('type') and
1017 self.properties.has_key('content')):
1018 pass
1019 # XXX we really want to return this as a downloadable...
1020 # currently I handle this at a higher level by detecting 'file'
1021 # designators...
1023 w = self.client.write
1024 w('<form onSubmit="return submit_once()" action="%s%s" method="POST" enctype="multipart/form-data">'%(
1025 self.classname, nodeid))
1026 s = open(os.path.join(self.templates, self.classname+'.item')).read()
1027 replace = ItemTemplateReplace(self.globals, locals(), self.cl, nodeid)
1028 w(replace.go(s))
1029 w('</form>')
1032 class NewItemTemplate(TemplateFunctions):
1033 def __init__(self, client, templates, classname):
1034 self.client = client
1035 self.instance = client.instance
1036 self.templates = templates
1037 self.classname = classname
1039 # derived
1040 self.db = self.client.db
1041 self.cl = self.db.classes[self.classname]
1042 self.properties = self.cl.getprops()
1044 TemplateFunctions.__init__(self)
1046 def render(self, form):
1047 self.form = form
1048 w = self.client.write
1049 c = self.classname
1050 try:
1051 s = open(os.path.join(self.templates, c+'.newitem')).read()
1052 except IOError:
1053 s = open(os.path.join(self.templates, c+'.item')).read()
1054 w('<form onSubmit="return submit_once()" action="new%s" method="POST" enctype="multipart/form-data">'%c)
1055 for key in form.keys():
1056 if key[0] == ':':
1057 value = form[key].value
1058 if type(value) != type([]): value = [value]
1059 for value in value:
1060 w('<input type="hidden" name="%s" value="%s">'%(key, value))
1061 replace = ItemTemplateReplace(self.globals, locals(), None, None)
1062 w(replace.go(s))
1063 w('</form>')
1065 #
1066 # $Log: not supported by cvs2svn $
1067 # Revision 1.72 2002/02/14 23:39:18 richard
1068 # . All forms now have "double-submit" protection when Javascript is enabled
1069 # on the client-side.
1070 #
1071 # Revision 1.71 2002/01/23 06:15:24 richard
1072 # real (non-string, duh) sorting of lists by node id
1073 #
1074 # Revision 1.70 2002/01/23 05:47:57 richard
1075 # more HTML template cleanup and unit tests
1076 #
1077 # Revision 1.69 2002/01/23 05:10:27 richard
1078 # More HTML template cleanup and unit tests.
1079 # - download() now implemented correctly, replacing link(is_download=1) [fixed in the
1080 # templates, but link(is_download=1) will still work for existing templates]
1081 #
1082 # Revision 1.68 2002/01/22 22:55:28 richard
1083 # . htmltemplate list() wasn't sorting...
1084 #
1085 # Revision 1.67 2002/01/22 22:46:22 richard
1086 # more htmltemplate cleanups and unit tests
1087 #
1088 # Revision 1.66 2002/01/22 06:35:40 richard
1089 # more htmltemplate tests and cleanup
1090 #
1091 # Revision 1.65 2002/01/22 00:12:06 richard
1092 # Wrote more unit tests for htmltemplate, and while I was at it, I polished
1093 # off the implementation of some of the functions so they behave sanely.
1094 #
1095 # Revision 1.64 2002/01/21 03:25:59 richard
1096 # oops
1097 #
1098 # Revision 1.63 2002/01/21 02:59:10 richard
1099 # Fixed up the HTML display of history so valid links are actually displayed.
1100 # Oh for some unit tests! :(
1101 #
1102 # Revision 1.62 2002/01/18 08:36:12 grubert
1103 # . add nowrap to history table date cell i.e. <td nowrap ...
1104 #
1105 # Revision 1.61 2002/01/17 23:04:53 richard
1106 # . much nicer history display (actualy real handling of property types etc)
1107 #
1108 # Revision 1.60 2002/01/17 08:48:19 grubert
1109 # . display superseder as html link in history.
1110 #
1111 # Revision 1.59 2002/01/17 07:58:24 grubert
1112 # . display links a html link in history.
1113 #
1114 # Revision 1.58 2002/01/15 00:50:03 richard
1115 # #502949 ] index view for non-issues and redisplay
1116 #
1117 # Revision 1.57 2002/01/14 23:31:21 richard
1118 # reverted the change that had plain() hyperlinking the link displays -
1119 # that's what link() is for!
1120 #
1121 # Revision 1.56 2002/01/14 07:04:36 richard
1122 # . plain rendering of links in the htmltemplate now generate a hyperlink to
1123 # the linked node's page.
1124 # ... this allows a display very similar to bugzilla's where you can actually
1125 # find out information about the linked node.
1126 #
1127 # Revision 1.55 2002/01/14 06:45:03 richard
1128 # . #502953 ] nosy-like treatment of other multilinks
1129 # ... had to revert most of the previous change to the multilink field
1130 # display... not good.
1131 #
1132 # Revision 1.54 2002/01/14 05:16:51 richard
1133 # The submit buttons need a name attribute or mozilla won't submit without a
1134 # file upload. Yeah, that's bloody obscure. Grr.
1135 #
1136 # Revision 1.53 2002/01/14 04:03:32 richard
1137 # How about that ... date fields have never worked ...
1138 #
1139 # Revision 1.52 2002/01/14 02:20:14 richard
1140 # . changed all config accesses so they access either the instance or the
1141 # config attriubute on the db. This means that all config is obtained from
1142 # instance_config instead of the mish-mash of classes. This will make
1143 # switching to a ConfigParser setup easier too, I hope.
1144 #
1145 # At a minimum, this makes migration a _little_ easier (a lot easier in the
1146 # 0.5.0 switch, I hope!)
1147 #
1148 # Revision 1.51 2002/01/10 10:02:15 grubert
1149 # In do_history: replace "." in date by " " so html wraps more sensible.
1150 # Should this be done in date's string converter ?
1151 #
1152 # Revision 1.50 2002/01/05 02:35:10 richard
1153 # I18N'ification
1154 #
1155 # Revision 1.49 2001/12/20 15:43:01 rochecompaan
1156 # Features added:
1157 # . Multilink properties are now displayed as comma separated values in
1158 # a textbox
1159 # . The add user link is now only visible to the admin user
1160 # . Modified the mail gateway to reject submissions from unknown
1161 # addresses if ANONYMOUS_ACCESS is denied
1162 #
1163 # Revision 1.48 2001/12/20 06:13:24 rochecompaan
1164 # Bugs fixed:
1165 # . Exception handling in hyperdb for strings-that-look-like numbers got
1166 # lost somewhere
1167 # . Internet Explorer submits full path for filename - we now strip away
1168 # the path
1169 # Features added:
1170 # . Link and multilink properties are now displayed sorted in the cgi
1171 # interface
1172 #
1173 # Revision 1.47 2001/11/26 22:55:56 richard
1174 # Feature:
1175 # . Added INSTANCE_NAME to configuration - used in web and email to identify
1176 # the instance.
1177 # . Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
1178 # signature info in e-mails.
1179 # . Some more flexibility in the mail gateway and more error handling.
1180 # . Login now takes you to the page you back to the were denied access to.
1181 #
1182 # Fixed:
1183 # . Lots of bugs, thanks Roché and others on the devel mailing list!
1184 #
1185 # Revision 1.46 2001/11/24 00:53:12 jhermann
1186 # "except:" is bad, bad , bad!
1187 #
1188 # Revision 1.45 2001/11/22 15:46:42 jhermann
1189 # Added module docstrings to all modules.
1190 #
1191 # Revision 1.44 2001/11/21 23:35:45 jhermann
1192 # Added globbing for win32, and sample marking in a 2nd file to test it
1193 #
1194 # Revision 1.43 2001/11/21 04:04:43 richard
1195 # *sigh* more missing value handling
1196 #
1197 # Revision 1.42 2001/11/21 03:40:54 richard
1198 # more new property handling
1199 #
1200 # Revision 1.41 2001/11/15 10:26:01 richard
1201 # . missing "return" in filter_section (thanks Roch'e Compaan)
1202 #
1203 # Revision 1.40 2001/11/03 01:56:51 richard
1204 # More HTML compliance fixes. This will probably fix the Netscape problem
1205 # too.
1206 #
1207 # Revision 1.39 2001/11/03 01:43:47 richard
1208 # Ahah! Fixed the lynx problem - there was a hidden input field misplaced.
1209 #
1210 # Revision 1.38 2001/10/31 06:58:51 richard
1211 # Added the wrap="hard" attribute to the textarea of the note field so the
1212 # messages wrap sanely.
1213 #
1214 # Revision 1.37 2001/10/31 06:24:35 richard
1215 # Added do_stext to htmltemplate, thanks Brad Clements.
1216 #
1217 # Revision 1.36 2001/10/28 22:51:38 richard
1218 # Fixed ENOENT/WindowsError thing, thanks Juergen Hermann
1219 #
1220 # Revision 1.35 2001/10/24 00:04:41 richard
1221 # Removed the "infinite authentication loop", thanks Roch'e
1222 #
1223 # Revision 1.34 2001/10/23 22:56:36 richard
1224 # Bugfix in filter "widget" placement, thanks Roch'e
1225 #
1226 # Revision 1.33 2001/10/23 01:00:18 richard
1227 # Re-enabled login and registration access after lopping them off via
1228 # disabling access for anonymous users.
1229 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
1230 # a couple of bugs while I was there. Probably introduced a couple, but
1231 # things seem to work OK at the moment.
1232 #
1233 # Revision 1.32 2001/10/22 03:25:01 richard
1234 # Added configuration for:
1235 # . anonymous user access and registration (deny/allow)
1236 # . filter "widget" location on index page (top, bottom, both)
1237 # Updated some documentation.
1238 #
1239 # Revision 1.31 2001/10/21 07:26:35 richard
1240 # feature #473127: Filenames. I modified the file.index and htmltemplate
1241 # source so that the filename is used in the link and the creation
1242 # information is displayed.
1243 #
1244 # Revision 1.30 2001/10/21 04:44:50 richard
1245 # bug #473124: UI inconsistency with Link fields.
1246 # This also prompted me to fix a fairly long-standing usability issue -
1247 # that of being able to turn off certain filters.
1248 #
1249 # Revision 1.29 2001/10/21 00:17:56 richard
1250 # CGI interface view customisation section may now be hidden (patch from
1251 # Roch'e Compaan.)
1252 #
1253 # Revision 1.28 2001/10/21 00:00:16 richard
1254 # Fixed Checklist function - wasn't always working on a list.
1255 #
1256 # Revision 1.27 2001/10/20 12:13:44 richard
1257 # Fixed grouping of non-str properties (thanks Roch'e Compaan)
1258 #
1259 # Revision 1.26 2001/10/14 10:55:00 richard
1260 # Handle empty strings in HTML template Link function
1261 #
1262 # Revision 1.25 2001/10/09 07:25:59 richard
1263 # Added the Password property type. See "pydoc roundup.password" for
1264 # implementation details. Have updated some of the documentation too.
1265 #
1266 # Revision 1.24 2001/09/27 06:45:58 richard
1267 # *gak* ... xmp is Old Skool apparently. Am using pre again by have the option
1268 # on the plain() template function to escape the text for HTML.
1269 #
1270 # Revision 1.23 2001/09/10 09:47:18 richard
1271 # Fixed bug in the generation of links to Link/Multilink in indexes.
1272 # (thanks Hubert Hoegl)
1273 # Added AssignedTo to the "classic" schema's item page.
1274 #
1275 # Revision 1.22 2001/08/30 06:01:17 richard
1276 # Fixed missing import in mailgw :(
1277 #
1278 # Revision 1.21 2001/08/16 07:34:59 richard
1279 # better CGI text searching - but hidden filter fields are disappearing...
1280 #
1281 # Revision 1.20 2001/08/15 23:43:18 richard
1282 # Fixed some isFooTypes that I missed.
1283 # Refactored some code in the CGI code.
1284 #
1285 # Revision 1.19 2001/08/12 06:32:36 richard
1286 # using isinstance(blah, Foo) now instead of isFooType
1287 #
1288 # Revision 1.18 2001/08/07 00:24:42 richard
1289 # stupid typo
1290 #
1291 # Revision 1.17 2001/08/07 00:15:51 richard
1292 # Added the copyright/license notice to (nearly) all files at request of
1293 # Bizar Software.
1294 #
1295 # Revision 1.16 2001/08/01 03:52:23 richard
1296 # Checklist was using wrong name.
1297 #
1298 # Revision 1.15 2001/07/30 08:12:17 richard
1299 # Added time logging and file uploading to the templates.
1300 #
1301 # Revision 1.14 2001/07/30 06:17:45 richard
1302 # Features:
1303 # . Added ability for cgi newblah forms to indicate that the new node
1304 # should be linked somewhere.
1305 # Fixed:
1306 # . Fixed the agument handling for the roundup-admin find command.
1307 # . Fixed handling of summary when no note supplied for newblah. Again.
1308 # . Fixed detection of no form in htmltemplate Field display.
1309 #
1310 # Revision 1.13 2001/07/30 02:37:53 richard
1311 # Temporary measure until we have decent schema migration.
1312 #
1313 # Revision 1.12 2001/07/30 01:24:33 richard
1314 # Handles new node display now.
1315 #
1316 # Revision 1.11 2001/07/29 09:31:35 richard
1317 # oops
1318 #
1319 # Revision 1.10 2001/07/29 09:28:23 richard
1320 # Fixed sorting by clicking on column headings.
1321 #
1322 # Revision 1.9 2001/07/29 08:27:40 richard
1323 # Fixed handling of passed-in values in form elements (ie. during a
1324 # drill-down)
1325 #
1326 # Revision 1.8 2001/07/29 07:01:39 richard
1327 # Added vim command to all source so that we don't get no steenkin' tabs :)
1328 #
1329 # Revision 1.7 2001/07/29 05:36:14 richard
1330 # Cleanup of the link label generation.
1331 #
1332 # Revision 1.6 2001/07/29 04:06:42 richard
1333 # Fixed problem in link display when Link value is None.
1334 #
1335 # Revision 1.5 2001/07/28 08:17:09 richard
1336 # fixed use of stylesheet
1337 #
1338 # Revision 1.4 2001/07/28 07:59:53 richard
1339 # Replaced errno integers with their module values.
1340 # De-tabbed templatebuilder.py
1341 #
1342 # Revision 1.3 2001/07/25 03:39:47 richard
1343 # Hrm - displaying links to classes that don't specify a key property. I've
1344 # got it defaulting to 'name', then 'title' and then a "random" property (first
1345 # one returned by getprops().keys().
1346 # Needs to be moved onto the Class I think...
1347 #
1348 # Revision 1.2 2001/07/22 12:09:32 richard
1349 # Final commit of Grande Splite
1350 #
1351 # Revision 1.1 2001/07/22 11:58:35 richard
1352 # More Grande Splite
1353 #
1354 #
1355 # vim: set filetype=python ts=4 sw=4 et si