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