Code

Added CVS keywords $Id$ and $Log$ to all python files.
[roundup.git] / template.py
1 # $Id: template.py,v 1.3 2001-07-19 05:52:22 anthonybaxter Exp $
3 import os, re, StringIO, urllib
5 import hyperdb, date
7 class Base:
8     def __init__(self, db, classname, nodeid=None, form=None):
9         self.db, self.classname, self.nodeid = db, classname, nodeid
10         self.form = form
11         self.cl = self.db.classes[self.classname]
12         self.properties = self.cl.getprops()
14 class Plain(Base):
15     ''' display a String property directly;
17         display a Date property in a specified time zone with an option to
18         omit the time from the date stamp;
20         for a Link or Multilink property, display the key strings of the
21         linked nodes (or the ids if the linked class has no key property)
22     '''
23     def __call__(self, property):
24         if not self.nodeid and self.form is None:
25             return '[Field: not called from item]'
26         propclass = self.properties[property]
27         if self.nodeid:
28             value = self.cl.get(self.nodeid, property)
29         else:
30             # TODO: pull the value from the form
31             if propclass.isMultilinkType: value = []
32             else: value = ''
33         if propclass.isStringType:
34             if value is None: value = ''
35             else: value = str(value)
36         elif propclass.isDateType:
37             value = str(value)
38         elif propclass.isIntervalType:
39             value = str(value)
40         elif propclass.isLinkType:
41             linkcl = self.db.classes[propclass.classname]
42             if value: value = str(linkcl.get(value, linkcl.getkey()))
43             else: value = '[unselected]'
44         elif propclass.isMultilinkType:
45             linkcl = self.db.classes[propclass.classname]
46             k = linkcl.getkey()
47             value = ', '.join([linkcl.get(i, k) for i in value])
48         else:
49             s = 'Plain: bad propclass "%s"'%propclass
50         return value
52 class Field(Base):
53     ''' display a property like the plain displayer, but in a text field
54         to be edited
55     '''
56     def __call__(self, property, size=None, height=None, showid=0):
57         if not self.nodeid and self.form is None:
58             return '[Field: not called from item]'
59         propclass = self.properties[property]
60         if self.nodeid:
61             value = self.cl.get(self.nodeid, property)
62         else:
63             # TODO: pull the value from the form
64             if propclass.isMultilinkType: value = []
65             else: value = ''
66         if (propclass.isStringType or propclass.isDateType or
67                 propclass.isIntervalType):
68             size = size or 30
69             if value is None:
70                 value = ''
71             s = '<input name="%s" value="%s" size="%s">'%(property, value, size)
72         elif propclass.isLinkType:
73             linkcl = self.db.classes[propclass.classname]
74             l = ['<select name="%s">'%property]
75             k = linkcl.getkey()
76             for optionid in linkcl.list():
77                 option = linkcl.get(optionid, k)
78                 s = ''
79                 if optionid == value:
80                     s = 'selected '
81                 if showid:
82                     lab = '%s%s: %s'%(propclass.classname, optionid, option)
83                 else:
84                     lab = option
85                 if size is not None and len(lab) > size:
86                     lab = lab[:size-3] + '...'
87                 l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
88             l.append('</select>')
89             s = '\n'.join(l)
90         elif propclass.isMultilinkType:
91             linkcl = self.db.classes[propclass.classname]
92             list = linkcl.list()
93             height = height or min(len(list), 7)
94             l = ['<select multiple name="%s" size="%s">'%(property, height)]
95             k = linkcl.getkey()
96             for optionid in list:
97                 option = linkcl.get(optionid, k)
98                 s = ''
99                 if optionid in value:
100                     s = 'selected '
101                 if showid:
102                     lab = '%s%s: %s'%(propclass.classname, optionid, option)
103                 else:
104                     lab = option
105                 if size is not None and len(lab) > size:
106                     lab = lab[:size-3] + '...'
107                 l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
108             l.append('</select>')
109             s = '\n'.join(l)
110         else:
111             s = 'Plain: bad propclass "%s"'%propclass
112         return s
114 class Menu(Base):
115     ''' for a Link property, display a menu of the available choices
116     '''
117     def __call__(self, property, size=None, height=None, showid=0):
118         propclass = self.properties[property]
119         if self.nodeid:
120             value = self.cl.get(self.nodeid, property)
121         else:
122             # TODO: pull the value from the form
123             if propclass.isMultilinkType: value = []
124             else: value = None
125         if propclass.isLinkType:
126             linkcl = self.db.classes[propclass.classname]
127             l = ['<select name="%s">'%property]
128             k = linkcl.getkey()
129             for optionid in linkcl.list():
130                 option = linkcl.get(optionid, k)
131                 s = ''
132                 if optionid == value:
133                     s = 'selected '
134                 l.append('<option %svalue="%s">%s</option>'%(s, optionid, option))
135             l.append('</select>')
136             return '\n'.join(l)
137         if propclass.isMultilinkType:
138             linkcl = self.db.classes[propclass.classname]
139             list = linkcl.list()
140             height = height or min(len(list), 7)
141             l = ['<select multiple name="%s" size="%s">'%(property, height)]
142             k = linkcl.getkey()
143             for optionid in list:
144                 option = linkcl.get(optionid, k)
145                 s = ''
146                 if optionid in value:
147                     s = 'selected '
148                 if showid:
149                     lab = '%s%s: %s'%(propclass.classname, optionid, option)
150                 else:
151                     lab = option
152                 if size is not None and len(lab) > size:
153                     lab = lab[:size-3] + '...'
154                 l.append('<option %svalue="%s">%s</option>'%(s, optionid, option))
155             l.append('</select>')
156             return '\n'.join(l)
157         return '[Menu: not a link]'
159 #XXX deviates from spec
160 class Link(Base):
161     ''' for a Link or Multilink property, display the names of the linked
162         nodes, hyperlinked to the item views on those nodes
163         for other properties, link to this node with the property as the text
164     '''
165     def __call__(self, property=None, **args):
166         if not self.nodeid and self.form is None:
167             return '[Link: not called from item]'
168         propclass = self.properties[property]
169         if self.nodeid:
170             value = self.cl.get(self.nodeid, property)
171         else:
172             if propclass.isMultilinkType: value = []
173             else: value = ''
174         if propclass.isLinkType:
175             linkcl = self.db.classes[propclass.classname]
176             linkvalue = linkcl.get(value, k)
177             return '<a href="%s%s">%s</a>'%(linkcl, value, linkvalue)
178         if propclass.isMultilinkType:
179             linkcl = self.db.classes[propclass.classname]
180             l = []
181             for value in value:
182                 linkvalue = linkcl.get(value, k)
183                 l.append('<a href="%s%s">%s</a>'%(linkcl, value, linkvalue))
184             return ', '.join(l)
185         return '<a href="%s%s">%s</a>'%(self.classname, self.nodeid, value)
187 class Count(Base):
188     ''' for a Multilink property, display a count of the number of links in
189         the list
190     '''
191     def __call__(self, property, **args):
192         if not self.nodeid:
193             return '[Count: not called from item]'
194         propclass = self.properties[property]
195         value = self.cl.get(self.nodeid, property)
196         if propclass.isMultilinkType:
197             return str(len(value))
198         return '[Count: not a Multilink]'
200 # XXX pretty is definitely new ;)
201 class Reldate(Base):
202     ''' display a Date property in terms of an interval relative to the
203         current date (e.g. "+ 3w", "- 2d").
205         with the 'pretty' flag, make it pretty
206     '''
207     def __call__(self, property, pretty=0):
208         if not self.nodeid and self.form is None:
209             return '[Reldate: not called from item]'
210         propclass = self.properties[property]
211         if not propclass.isDateType:
212             return '[Reldate: not a Date]'
213         if self.nodeid:
214             value = self.cl.get(self.nodeid, property)
215         else:
216             value = date.Date('.')
217         interval = value - date.Date('.')
218         if pretty:
219             if not self.nodeid:
220                 return 'now'
221             pretty = interval.pretty()
222             if pretty is None:
223                 pretty = value.pretty()
224             return pretty
225         return str(interval)
227 class Download(Base):
228     ''' show a Link("file") or Multilink("file") property using links that
229         allow you to download files
230     '''
231     def __call__(self, property, **args):
232         if not self.nodeid:
233             return '[Download: not called from item]'
234         propclass = self.properties[property]
235         value = self.cl.get(self.nodeid, property)
236         if propclass.isLinkType:
237             linkcl = self.db.classes[propclass.classname]
238             linkvalue = linkcl.get(value, k)
239             return '<a href="%s%s">%s</a>'%(linkcl, value, linkvalue)
240         if propclass.isMultilinkType:
241             linkcl = self.db.classes[propclass.classname]
242             l = []
243             for value in value:
244                 linkvalue = linkcl.get(value, k)
245                 l.append('<a href="%s%s">%s</a>'%(linkcl, value, linkvalue))
246             return ', '.join(l)
247         return '[Download: not a link]'
250 class Checklist(Base):
251     ''' for a Link or Multilink property, display checkboxes for the available
252         choices to permit filtering
253     '''
254     def __call__(self, property, **args):
255         propclass = self.properties[property]
256         if self.nodeid:
257             value = self.cl.get(self.nodeid, property)
258         else:
259             value = []
260         if propclass.isLinkType or propclass.isMultilinkType:
261             linkcl = self.db.classes[propclass.classname]
262             l = []
263             k = linkcl.getkey()
264             for optionid in linkcl.list():
265                 option = linkcl.get(optionid, k)
266                 if optionid in value:
267                     checked = 'checked'
268                 else:
269                     checked = ''
270                 l.append('%s:<input type="checkbox" %s name="%s" value="%s">'%(
271                     option, checked, propclass.classname, option))
272             return '\n'.join(l)
273         return '[Checklist: not a link]'
275 class Note(Base):
276     ''' display a "note" field, which is a text area for entering a note to
277         go along with a change. 
278     '''
279     def __call__(self, rows=5, cols=80):
280        # TODO: pull the value from the form
281         return '<textarea name="__note" rows=%s cols=%s></textarea>'%(rows,
282             cols)
284 # XXX new function
285 class List(Base):
286     ''' list the items specified by property using the standard index for
287         the class
288     '''
289     def __call__(self, property, **args):
290         propclass = self.properties[property]
291         if not propclass.isMultilinkType:
292             return '[List: not a Multilink]'
293         fp = StringIO.StringIO()
294         args['show_display_form'] = 0
295         value = self.cl.get(self.nodeid, property)
296         index(fp, self.db, propclass.classname, nodeids=value,
297             show_display_form=0)
298         return fp.getvalue()
300 # XXX new function
301 class History(Base):
302     ''' list the history of the item
303     '''
304     def __call__(self, **args):
305         l = ['<table width=100% border=0 cellspacing=0 cellpadding=2>',
306             '<tr class="list-header">',
307             '<td><span class="list-item"><strong>Date</strong></span></td>',
308             '<td><span class="list-item"><strong>User</strong></span></td>',
309             '<td><span class="list-item"><strong>Action</strong></span></td>',
310             '<td><span class="list-item"><strong>Args</strong></span></td>']
312         for id, date, user, action, args in self.cl.history(self.nodeid):
313             l.append('<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>'%(
314                date, user, action, args))
315         l.append('</table>')
316         return '\n'.join(l)
318 # XXX new function
319 class Submit(Base):
320     ''' add a submit button for the item
321     '''
322     def __call__(self):
323         if self.nodeid:
324             return '<input type="submit" value="Submit Changes">'
325         elif self.form is not None:
326             return '<input type="submit" value="Submit New Entry">'
327         else:
328             return '[Submit: not called from item]'
332 #   INDEX TEMPLATES
334 class IndexTemplateReplace:
335     def __init__(self, globals, locals, props):
336         self.globals = globals
337         self.locals = locals
338         self.props = props
340     def go(self, text, replace=re.compile(
341             r'((<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
342             r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I|re.S)):
343         return replace.sub(self, text)
344         
345     def __call__(self, m, filter=None, columns=None, sort=None, group=None):
346         if m.group('name'):
347             if m.group('name') in self.props:
348                 text = m.group('text')
349                 replace = IndexTemplateReplace(self.globals, {}, self.props)
350                 return replace.go(m.group('text'))
351             else:
352                 return ''
353         if m.group('display'):
354             command = m.group('command')
355             return eval(command, self.globals, self.locals)
356         print '*** unhandled match', m.groupdict()
358 def sortby(sort_name, columns, filter, sort, group, filterspec):
359     l = []
360     w = l.append
361     for k, v in filterspec.items():
362         k = urllib.quote(k)
363         if type(v) == type([]):
364             w('%s=%s'%(k, ','.join(map(urllib.quote, v))))
365         else:
366             w('%s=%s'%(k, urllib.quote(v)))
367     if columns:
368         w(':columns=%s'%','.join(map(urllib.quote, columns)))
369     if filter:
370         w(':filter=%s'%','.join(map(urllib.quote, filter)))
371     if group:
372         w(':group=%s'%','.join(map(urllib.quote, group)))
373     m = []
374     s_dir = ''
375     for name in sort:
376         dir = name[0]
377         if dir == '-':
378             dir = ''
379         else:
380             name = name[1:]
381         if sort_name == name:
382             if dir == '':
383                 s_dir = '-'
384             elif dir == '-':
385                 s_dir = ''
386         else:
387             m.append(dir+urllib.quote(name))
388     m.insert(0, s_dir+urllib.quote(sort_name))
389     # so things don't get completely out of hand, limit the sort to two columns
390     w(':sort=%s'%','.join(m[:2]))
391     return '&'.join(l)
393 def index(fp, db, classname, filterspec={}, filter=[], columns=[], sort=[],
394             group=[], show_display_form=1, nodeids=None,
395             col_re=re.compile(r'<property\s+name="([^>]+)">')):
397     globals = {
398         'plain': Plain(db, classname, form={}),
399         'field': Field(db, classname, form={}),
400         'menu': Menu(db, classname, form={}),
401         'link': Link(db, classname, form={}),
402         'count': Count(db, classname, form={}),
403         'reldate': Reldate(db, classname, form={}),
404         'download': Download(db, classname, form={}),
405         'checklist': Checklist(db, classname, form={}),
406         'list': List(db, classname, form={}),
407         'history': History(db, classname, form={}),
408         'submit': Submit(db, classname, form={}),
409         'note': Note(db, classname, form={})
410     }
411     cl = db.classes[classname]
412     properties = cl.getprops()
413     w = fp.write
415     try:
416         template = open(os.path.join('templates', classname+'.filter')).read()
417         all_filters = col_re.findall(template)
418     except IOError, error:
419         if error.errno != 2: raise
420         template = None
421         all_filters = []
422     if template and filter:
423         # display the filter section
424         w('<form>')
425         w('<table width=100% border=0 cellspacing=0 cellpadding=2>')
426         w('<tr class="location-bar">')
427         w(' <th align="left" colspan="2">Filter specification...</th>')
428         w('</tr>')
429         replace = IndexTemplateReplace(globals, locals(), filter)
430         w(replace.go(template))
431         if columns:
432             w('<input type="hidden" name=":columns" value="%s">'%','.join(columns))
433         if filter:
434             w('<input type="hidden" name=":filter" value="%s">'%','.join(filter))
435         if sort:
436             w('<input type="hidden" name=":sort" value="%s">'%','.join(sort))
437         if group:
438             w('<input type="hidden" name=":group" value="%s">'%','.join(group))
439         for k, v in filterspec.items():
440             if type(v) == type([]): v = ','.join(v)
441             w('<input type="hidden" name="%s" value="%s">'%(k, v))
442         w('<tr class="location-bar"><td width="1%%">&nbsp;</td>')
443         w('<td><input type="submit" value="Redisplay"></td></tr>')
444         w('</table>')
445         w('</form>')
447     # XXX deviate from spec here ...
448     # load the index section template and figure the default columns from it
449     template = open(os.path.join('templates', classname+'.index')).read()
450     all_columns = col_re.findall(template)
451     if not columns:
452         columns = []
453         for name in all_columns:
454             columns.append(name)
455     else:
456         # re-sort columns to be the same order as all_columns
457         l = []
458         for name in all_columns:
459             if name in columns:
460                 l.append(name)
461         columns = l
463     # now display the index section
464     w('<table width=100% border=0 cellspacing=0 cellpadding=2>')
465     w('<tr class="list-header">')
466     for name in columns:
467         cname = name.capitalize()
468         if show_display_form:
469             anchor = "%s?%s"%(classname, sortby(name, columns, filter,
470                 sort, group, filterspec))
471             w('<td><span class="list-item"><a href="%s">%s</a></span></td>'%(
472                 anchor, cname))
473         else:
474             w('<td><span class="list-item">%s</span></td>'%cname)
475     w('</tr>')
477     # this stuff is used for group headings - optimise the group names
478     old_group = None
479     group_names = []
480     if group:
481         for name in group:
482             if name[0] == '-': group_names.append(name[1:])
483             else: group_names.append(name)
485     # now actually loop through all the nodes we get from the filter and
486     # apply the template
487     if nodeids is None:
488         nodeids = cl.filter(filterspec, sort, group)
489     for nodeid in nodeids:
490         # check for a group heading
491         if group_names:
492             this_group = [cl.get(nodeid, name) for name in group_names]
493             if this_group != old_group:
494                 l = []
495                 for name in group_names:
496                     prop = properties[name]
497                     if prop.isLinkType:
498                         group_cl = db.classes[prop.classname]
499                         key = group_cl.getkey()
500                         value = cl.get(nodeid, name)
501                         if value is None:
502                             l.append('[unselected %s]'%prop.classname)
503                         else:
504                             l.append(group_cl.get(cl.get(nodeid, name), key))
505                     elif prop.isMultilinkType:
506                         group_cl = db.classes[prop.classname]
507                         key = group_cl.getkey()
508                         for value in cl.get(nodeid, name):
509                             l.append(group_cl.get(value, key))
510                     else:
511                         value = cl.get(nodeid, name)
512                         if value is None:
513                             value = '[empty %s]'%name
514                         l.append(value)
515                 w('<tr class="list-header">'
516                   '<td align=left colspan=%s><strong>%s</strong></td></tr>'%(
517                     len(columns), ', '.join(l)))
518                 old_group = this_group
520         # display this node's row
521         for value in globals.values():
522             if hasattr(value, 'nodeid'):
523                 value.nodeid = nodeid
524         replace = IndexTemplateReplace(globals, locals(), columns)
525         w(replace.go(template))
527     w('</table>')
529     if not show_display_form:
530         return
532     # now add in the filter/columns/group/etc config table form
533     w('<p><form>')
534     w('<table width=100% border=0 cellspacing=0 cellpadding=2>')
535     for k,v in filterspec.items():
536         if type(v) == type([]): v = ','.join(v)
537         w('<input type="hidden" name="%s" value="%s">'%(k, v))
538     if sort:
539         w('<input type="hidden" name=":sort" value="%s">'%','.join(sort))
540     names = []
541     for name in cl.getprops().keys():
542         if name in all_filters or name in all_columns:
543             names.append(name)
544     w('<tr class="location-bar">')
545     w('<th align="left" colspan=%s>View customisation...</th></tr>'%
546         (len(names)+1))
547     w('<tr class="location-bar"><th>&nbsp;</th>')
548     for name in names:
549         w('<th>%s</th>'%name.capitalize())
550     w('</tr>')
552     # filter
553     if all_filters:
554         w('<tr><th width="1%" align=right class="location-bar">Filters</th>')
555         for name in names:
556             if name not in all_filters:
557                 w('<td>&nbsp;</td>')
558                 continue
559             if name in filter: checked=' checked'
560             else: checked=''
561             w('<td align=middle>')
562             w('<input type="checkbox" name=":filter" value="%s" %s></td>'%(name,
563                 checked))
564         w('</tr>')
566     # columns
567     if all_columns:
568         w('<tr><th width="1%" align=right class="location-bar">Columns</th>')
569         for name in names:
570             if name not in all_columns:
571                 w('<td>&nbsp;</td>')
572                 continue
573             if name in columns: checked=' checked'
574             else: checked=''
575             w('<td align=middle>')
576             w('<input type="checkbox" name=":columns" value="%s" %s></td>'%(
577                 name, checked))
578         w('</tr>')
580         # group
581         w('<tr><th width="1%" align=right class="location-bar">Grouping</th>')
582         for name in names:
583             prop = properties[name]
584             if name not in all_columns:
585                 w('<td>&nbsp;</td>')
586                 continue
587             if name in group: checked=' checked'
588             else: checked=''
589             w('<td align=middle>')
590             w('<input type="checkbox" name=":group" value="%s" %s></td>'%(
591                 name, checked))
592         w('</tr>')
594     w('<tr class="location-bar"><td width="1%">&nbsp;</td>')
595     w('<td colspan="%s">'%len(names))
596     w('<input type="submit" value="Redisplay"></td></tr>')
597     w('</table>')
598     w('</form>')
602 #   ITEM TEMPLATES
604 class ItemTemplateReplace:
605     def __init__(self, globals, locals, cl, nodeid):
606         self.globals = globals
607         self.locals = locals
608         self.cl = cl
609         self.nodeid = nodeid
611     def go(self, text, replace=re.compile(
612             r'((<property\s+name="(?P<name>[^>]+)">(?P<text>.+?)</property>)|'
613             r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I|re.S)):
614         return replace.sub(self, text)
616     def __call__(self, m, filter=None, columns=None, sort=None, group=None):
617         if m.group('name'):
618             if self.nodeid and self.cl.get(self.nodeid, m.group('name')):
619                 replace = ItemTemplateReplace(self.globals, {}, self.cl,
620                     self.nodeid)
621                 return replace.go(m.group('text'))
622             else:
623                 return ''
624         if m.group('display'):
625             command = m.group('command')
626             return eval(command, self.globals, self.locals)
627         print '*** unhandled match', m.groupdict()
629 def item(fp, db, classname, nodeid, replace=re.compile(
630             r'((?P<prop><property\s+name="(?P<propname>[^>]+)">)|'
631             r'(?P<endprop></property>)|'
632             r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I)):
634     globals = {
635         'plain': Plain(db, classname, nodeid),
636         'field': Field(db, classname, nodeid),
637         'menu': Menu(db, classname, nodeid),
638         'link': Link(db, classname, nodeid),
639         'count': Count(db, classname, nodeid),
640         'reldate': Reldate(db, classname, nodeid),
641         'download': Download(db, classname, nodeid),
642         'checklist': Checklist(db, classname, nodeid),
643         'list': List(db, classname, nodeid),
644         'history': History(db, classname, nodeid),
645         'submit': Submit(db, classname, nodeid),
646         'note': Note(db, classname, nodeid)
647     }
649     cl = db.classes[classname]
650     properties = cl.getprops()
652     if properties.has_key('type') and properties.has_key('content'):
653         pass
654         # XXX we really want to return this as a downloadable...
655         #  currently I handle this at a higher level by detecting 'file'
656         #  designators...
658     w = fp.write
659     w('<form action="%s%s">'%(classname, nodeid))
660     s = open(os.path.join('templates', classname+'.item')).read()
661     replace = ItemTemplateReplace(globals, locals(), cl, nodeid)
662     w(replace.go(s))
663     w('</form>')
666 def newitem(fp, db, classname, form, replace=re.compile(
667             r'((?P<prop><property\s+name="(?P<propname>[^>]+)">)|'
668             r'(?P<endprop></property>)|'
669             r'(?P<display><display\s+call="(?P<command>[^"]+)">))', re.I)):
670     globals = {
671         'plain': Plain(db, classname, form=form),
672         'field': Field(db, classname, form=form),
673         'menu': Menu(db, classname, form=form),
674         'link': Link(db, classname, form=form),
675         'count': Count(db, classname, form=form),
676         'reldate': Reldate(db, classname, form=form),
677         'download': Download(db, classname, form=form),
678         'checklist': Checklist(db, classname, form=form),
679         'list': List(db, classname, form=form),
680         'history': History(db, classname, form=form),
681         'submit': Submit(db, classname, form=form),
682         'note': Note(db, classname, form=form)
683     }
685     cl = db.classes[classname]
686     properties = cl.getprops()
688     w = fp.write
689     try:
690         s = open(os.path.join('templates', classname+'.newitem')).read()
691     except:
692         s = open(os.path.join('templates', classname+'.item')).read()
693     w('<form action="new%s">'%classname)
694     replace = ItemTemplateReplace(globals, locals(), None, None)
695     w(replace.go(s))
696     w('</form>')
699 # $Log: not supported by cvs2svn $