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: hyperdb.py,v 1.18 2001-08-16 07:34:59 richard Exp $
20 # standard python modules
21 import cPickle, re, string
23 # roundup modules
24 import date
27 #
28 # Types
29 #
30 class String:
31 """An object designating a String property."""
32 def __repr__(self):
33 return '<%s>'%self.__class__
35 class Date:
36 """An object designating a Date property."""
37 def __repr__(self):
38 return '<%s>'%self.__class__
40 class Interval:
41 """An object designating an Interval property."""
42 def __repr__(self):
43 return '<%s>'%self.__class__
45 class Link:
46 """An object designating a Link property that links to a
47 node in a specified class."""
48 def __init__(self, classname):
49 self.classname = classname
50 def __repr__(self):
51 return '<%s to "%s">'%(self.__class__, self.classname)
53 class Multilink:
54 """An object designating a Multilink property that links
55 to nodes in a specified class.
56 """
57 def __init__(self, classname):
58 self.classname = classname
59 def __repr__(self):
60 return '<%s to "%s">'%(self.__class__, self.classname)
62 class DatabaseError(ValueError):
63 pass
66 #
67 # the base Database class
68 #
69 class Database:
70 # flag to set on retired entries
71 RETIRED_FLAG = '__hyperdb_retired'
74 _marker = []
75 #
76 # The base Class class
77 #
78 class Class:
79 """The handle to a particular class of nodes in a hyperdatabase."""
81 def __init__(self, db, classname, **properties):
82 """Create a new class with a given name and property specification.
84 'classname' must not collide with the name of an existing class,
85 or a ValueError is raised. The keyword arguments in 'properties'
86 must map names to property objects, or a TypeError is raised.
87 """
88 self.classname = classname
89 self.properties = properties
90 self.db = db
91 self.key = ''
93 # do the db-related init stuff
94 db.addclass(self)
96 # Editing nodes:
98 def create(self, **propvalues):
99 """Create a new node of this class and return its id.
101 The keyword arguments in 'propvalues' map property names to values.
103 The values of arguments must be acceptable for the types of their
104 corresponding properties or a TypeError is raised.
106 If this class has a key property, it must be present and its value
107 must not collide with other key strings or a ValueError is raised.
109 Any other properties on this class that are missing from the
110 'propvalues' dictionary are set to None.
112 If an id in a link or multilink property does not refer to a valid
113 node, an IndexError is raised.
114 """
115 if propvalues.has_key('id'):
116 raise KeyError, '"id" is reserved'
118 if self.db.journaltag is None:
119 raise DatabaseError, 'Database open read-only'
121 # new node's id
122 newid = str(self.count() + 1)
124 # validate propvalues
125 num_re = re.compile('^\d+$')
126 for key, value in propvalues.items():
127 if key == self.key:
128 try:
129 self.lookup(value)
130 except KeyError:
131 pass
132 else:
133 raise ValueError, 'node with key "%s" exists'%value
135 # try to handle this property
136 try:
137 prop = self.properties[key]
138 except KeyError:
139 raise KeyError, '"%s" has no property "%s"'%(self.classname,
140 key)
142 if isinstance(prop, Link):
143 if type(value) != type(''):
144 raise ValueError, 'link value must be String'
145 link_class = self.properties[key].classname
146 # if it isn't a number, it's a key
147 if not num_re.match(value):
148 try:
149 value = self.db.classes[link_class].lookup(value)
150 except:
151 raise IndexError, 'new property "%s": %s not a %s'%(
152 key, value, self.properties[key].classname)
153 propvalues[key] = value
154 if not self.db.hasnode(link_class, value):
155 raise IndexError, '%s has no node %s'%(link_class, value)
157 # register the link with the newly linked node
158 self.db.addjournal(link_class, value, 'link',
159 (self.classname, newid, key))
161 elif isinstance(prop, Multilink):
162 if type(value) != type([]):
163 raise TypeError, 'new property "%s" not a list of ids'%key
164 link_class = self.properties[key].classname
165 l = []
166 for entry in value:
167 if type(entry) != type(''):
168 raise ValueError, 'link value must be String'
169 # if it isn't a number, it's a key
170 if not num_re.match(entry):
171 try:
172 entry = self.db.classes[link_class].lookup(entry)
173 except:
174 raise IndexError, 'new property "%s": %s not a %s'%(
175 key, entry, self.properties[key].classname)
176 l.append(entry)
177 value = l
178 propvalues[key] = value
180 # handle additions
181 for id in value:
182 if not self.db.hasnode(link_class, id):
183 raise IndexError, '%s has no node %s'%(link_class, id)
184 # register the link with the newly linked node
185 self.db.addjournal(link_class, id, 'link',
186 (self.classname, newid, key))
188 elif isinstance(prop, String):
189 if type(value) != type(''):
190 raise TypeError, 'new property "%s" not a string'%key
192 elif isinstance(prop, Date):
193 if not isinstance(value, date.Date):
194 raise TypeError, 'new property "%s" not a Date'% key
196 elif isinstance(prop, Interval):
197 if not isinstance(value, date.Interval):
198 raise TypeError, 'new property "%s" not an Interval'% key
200 for key, prop in self.properties.items():
201 if propvalues.has_key(key):
202 continue
203 if isinstance(prop, Multilink):
204 propvalues[key] = []
205 else:
206 propvalues[key] = None
208 # done
209 self.db.addnode(self.classname, newid, propvalues)
210 self.db.addjournal(self.classname, newid, 'create', propvalues)
211 return newid
213 def get(self, nodeid, propname, default=_marker):
214 """Get the value of a property on an existing node of this class.
216 'nodeid' must be the id of an existing node of this class or an
217 IndexError is raised. 'propname' must be the name of a property
218 of this class or a KeyError is raised.
219 """
220 if propname == 'id':
221 return nodeid
222 d = self.db.getnode(self.classname, nodeid)
223 if not d.has_key(propname) and default is not _marker:
224 return default
225 return d[propname]
227 # XXX not in spec
228 def getnode(self, nodeid):
229 ''' Return a convenience wrapper for the node
230 '''
231 return Node(self, nodeid)
233 def set(self, nodeid, **propvalues):
234 """Modify a property on an existing node of this class.
236 'nodeid' must be the id of an existing node of this class or an
237 IndexError is raised.
239 Each key in 'propvalues' must be the name of a property of this
240 class or a KeyError is raised.
242 All values in 'propvalues' must be acceptable types for their
243 corresponding properties or a TypeError is raised.
245 If the value of the key property is set, it must not collide with
246 other key strings or a ValueError is raised.
248 If the value of a Link or Multilink property contains an invalid
249 node id, a ValueError is raised.
250 """
251 if not propvalues:
252 return
254 if propvalues.has_key('id'):
255 raise KeyError, '"id" is reserved'
257 if self.db.journaltag is None:
258 raise DatabaseError, 'Database open read-only'
260 node = self.db.getnode(self.classname, nodeid)
261 if node.has_key(self.db.RETIRED_FLAG):
262 raise IndexError
263 num_re = re.compile('^\d+$')
264 for key, value in propvalues.items():
265 if not node.has_key(key):
266 raise KeyError, key
268 if key == self.key:
269 try:
270 self.lookup(value)
271 except KeyError:
272 pass
273 else:
274 raise ValueError, 'node with key "%s" exists'%value
276 prop = self.properties[key]
278 if isinstance(prop, Link):
279 link_class = self.properties[key].classname
280 # if it isn't a number, it's a key
281 if type(value) != type(''):
282 raise ValueError, 'link value must be String'
283 if not num_re.match(value):
284 try:
285 value = self.db.classes[link_class].lookup(value)
286 except:
287 raise IndexError, 'new property "%s": %s not a %s'%(
288 key, value, self.properties[key].classname)
290 if not self.db.hasnode(link_class, value):
291 raise IndexError, '%s has no node %s'%(link_class, value)
293 # register the unlink with the old linked node
294 if node[key] is not None:
295 self.db.addjournal(link_class, node[key], 'unlink',
296 (self.classname, nodeid, key))
298 # register the link with the newly linked node
299 if value is not None:
300 self.db.addjournal(link_class, value, 'link',
301 (self.classname, nodeid, key))
303 elif isinstance(prop, Multilink):
304 if type(value) != type([]):
305 raise TypeError, 'new property "%s" not a list of ids'%key
306 link_class = self.properties[key].classname
307 l = []
308 for entry in value:
309 # if it isn't a number, it's a key
310 if type(entry) != type(''):
311 raise ValueError, 'link value must be String'
312 if not num_re.match(entry):
313 try:
314 entry = self.db.classes[link_class].lookup(entry)
315 except:
316 raise IndexError, 'new property "%s": %s not a %s'%(
317 key, entry, self.properties[key].classname)
318 l.append(entry)
319 value = l
320 propvalues[key] = value
322 #handle removals
323 l = node[key]
324 for id in l[:]:
325 if id in value:
326 continue
327 # register the unlink with the old linked node
328 self.db.addjournal(link_class, id, 'unlink',
329 (self.classname, nodeid, key))
330 l.remove(id)
332 # handle additions
333 for id in value:
334 if not self.db.hasnode(link_class, id):
335 raise IndexError, '%s has no node %s'%(link_class, id)
336 if id in l:
337 continue
338 # register the link with the newly linked node
339 self.db.addjournal(link_class, id, 'link',
340 (self.classname, nodeid, key))
341 l.append(id)
343 elif isinstance(prop, String):
344 if value is not None and type(value) != type(''):
345 raise TypeError, 'new property "%s" not a string'%key
347 elif isinstance(prop, Date):
348 if not isinstance(value, date.Date):
349 raise TypeError, 'new property "%s" not a Date'% key
351 elif isinstance(prop, Interval):
352 if not isinstance(value, date.Interval):
353 raise TypeError, 'new property "%s" not an Interval'% key
355 node[key] = value
357 self.db.setnode(self.classname, nodeid, node)
358 self.db.addjournal(self.classname, nodeid, 'set', propvalues)
360 def retire(self, nodeid):
361 """Retire a node.
363 The properties on the node remain available from the get() method,
364 and the node's id is never reused.
366 Retired nodes are not returned by the find(), list(), or lookup()
367 methods, and other nodes may reuse the values of their key properties.
368 """
369 if self.db.journaltag is None:
370 raise DatabaseError, 'Database open read-only'
371 node = self.db.getnode(self.classname, nodeid)
372 node[self.db.RETIRED_FLAG] = 1
373 self.db.setnode(self.classname, nodeid, node)
374 self.db.addjournal(self.classname, nodeid, 'retired', None)
376 def history(self, nodeid):
377 """Retrieve the journal of edits on a particular node.
379 'nodeid' must be the id of an existing node of this class or an
380 IndexError is raised.
382 The returned list contains tuples of the form
384 (date, tag, action, params)
386 'date' is a Timestamp object specifying the time of the change and
387 'tag' is the journaltag specified when the database was opened.
388 """
389 return self.db.getjournal(self.classname, nodeid)
391 # Locating nodes:
393 def setkey(self, propname):
394 """Select a String property of this class to be the key property.
396 'propname' must be the name of a String property of this class or
397 None, or a TypeError is raised. The values of the key property on
398 all existing nodes must be unique or a ValueError is raised.
399 """
400 self.key = propname
402 def getkey(self):
403 """Return the name of the key property for this class or None."""
404 return self.key
406 def labelprop(self, default_to_id=0):
407 ''' Return the property name for a label for the given node.
409 This method attempts to generate a consistent label for the node.
410 It tries the following in order:
411 1. key property
412 2. "name" property
413 3. "title" property
414 4. first property from the sorted property name list
415 '''
416 k = self.getkey()
417 if k:
418 return k
419 props = self.getprops()
420 if props.has_key('name'):
421 return 'name'
422 elif props.has_key('title'):
423 return 'title'
424 if default_to_id:
425 return 'id'
426 props = props.keys()
427 props.sort()
428 return props[0]
430 # TODO: set up a separate index db file for this? profile?
431 def lookup(self, keyvalue):
432 """Locate a particular node by its key property and return its id.
434 If this class has no key property, a TypeError is raised. If the
435 'keyvalue' matches one of the values for the key property among
436 the nodes in this class, the matching node's id is returned;
437 otherwise a KeyError is raised.
438 """
439 cldb = self.db.getclassdb(self.classname)
440 for nodeid in self.db.getnodeids(self.classname, cldb):
441 node = self.db.getnode(self.classname, nodeid, cldb)
442 if node.has_key(self.db.RETIRED_FLAG):
443 continue
444 if node[self.key] == keyvalue:
445 return nodeid
446 cldb.close()
447 raise KeyError, keyvalue
449 # XXX: change from spec - allows multiple props to match
450 def find(self, **propspec):
451 """Get the ids of nodes in this class which link to a given node.
453 'propspec' consists of keyword args propname=nodeid
454 'propname' must be the name of a property in this class, or a
455 KeyError is raised. That property must be a Link or Multilink
456 property, or a TypeError is raised.
458 'nodeid' must be the id of an existing node in the class linked
459 to by the given property, or an IndexError is raised.
460 """
461 propspec = propspec.items()
462 for propname, nodeid in propspec:
463 # check the prop is OK
464 prop = self.properties[propname]
465 if not isinstance(prop, Link) and not isinstance(prop, Multilink):
466 raise TypeError, "'%s' not a Link/Multilink property"%propname
467 if not self.db.hasnode(prop.classname, nodeid):
468 raise ValueError, '%s has no node %s'%(link_class, nodeid)
470 # ok, now do the find
471 cldb = self.db.getclassdb(self.classname)
472 l = []
473 for id in self.db.getnodeids(self.classname, cldb):
474 node = self.db.getnode(self.classname, id, cldb)
475 if node.has_key(self.db.RETIRED_FLAG):
476 continue
477 for propname, nodeid in propspec:
478 property = node[propname]
479 if isinstance(prop, Link) and nodeid == property:
480 l.append(id)
481 elif isinstance(prop, Multilink) and nodeid in property:
482 l.append(id)
483 cldb.close()
484 return l
486 def stringFind(self, **requirements):
487 """Locate a particular node by matching a set of its String properties.
489 If the property is not a String property, a TypeError is raised.
491 The return is a list of the id of all nodes that match.
492 """
493 for propname in requirements.keys():
494 prop = self.properties[propname]
495 if isinstance(not prop, String):
496 raise TypeError, "'%s' not a String property"%propname
497 l = []
498 cldb = self.db.getclassdb(self.classname)
499 for nodeid in self.db.getnodeids(self.classname, cldb):
500 node = self.db.getnode(self.classname, nodeid, cldb)
501 if node.has_key(self.db.RETIRED_FLAG):
502 continue
503 for key, value in requirements.items():
504 if node[key] != value:
505 break
506 else:
507 l.append(nodeid)
508 cldb.close()
509 return l
511 def list(self):
512 """Return a list of the ids of the active nodes in this class."""
513 l = []
514 cn = self.classname
515 cldb = self.db.getclassdb(cn)
516 for nodeid in self.db.getnodeids(cn, cldb):
517 node = self.db.getnode(cn, nodeid, cldb)
518 if node.has_key(self.db.RETIRED_FLAG):
519 continue
520 l.append(nodeid)
521 l.sort()
522 cldb.close()
523 return l
525 # XXX not in spec
526 def filter(self, filterspec, sort, group, num_re = re.compile('^\d+$')):
527 ''' Return a list of the ids of the active nodes in this class that
528 match the 'filter' spec, sorted by the group spec and then the
529 sort spec
530 '''
531 cn = self.classname
533 # optimise filterspec
534 l = []
535 props = self.getprops()
536 for k, v in filterspec.items():
537 propclass = props[k]
538 if isinstance(propclass, Link):
539 if type(v) is not type([]):
540 v = [v]
541 # replace key values with node ids
542 u = []
543 link_class = self.db.classes[propclass.classname]
544 for entry in v:
545 if not num_re.match(entry):
546 try:
547 entry = link_class.lookup(entry)
548 except:
549 raise ValueError, 'new property "%s": %s not a %s'%(
550 k, entry, self.properties[k].classname)
551 u.append(entry)
553 l.append((0, k, u))
554 elif isinstance(propclass, Multilink):
555 if type(v) is not type([]):
556 v = [v]
557 # replace key values with node ids
558 u = []
559 link_class = self.db.classes[propclass.classname]
560 for entry in v:
561 if not num_re.match(entry):
562 try:
563 entry = link_class.lookup(entry)
564 except:
565 raise ValueError, 'new property "%s": %s not a %s'%(
566 k, entry, self.properties[k].classname)
567 u.append(entry)
568 l.append((1, k, u))
569 elif isinstance(propclass, String):
570 # simple glob searching
571 v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
572 v = v.replace('?', '.')
573 v = v.replace('*', '.*?')
574 l.append((2, k, re.compile(v, re.I)))
575 else:
576 l.append((6, k, v))
577 filterspec = l
579 # now, find all the nodes that are active and pass filtering
580 l = []
581 cldb = self.db.getclassdb(cn)
582 for nodeid in self.db.getnodeids(cn, cldb):
583 node = self.db.getnode(cn, nodeid, cldb)
584 if node.has_key(self.db.RETIRED_FLAG):
585 continue
586 # apply filter
587 for t, k, v in filterspec:
588 if t == 0 and node[k] not in v:
589 # link - if this node'd property doesn't appear in the
590 # filterspec's nodeid list, skip it
591 break
592 elif t == 1:
593 # multilink - if any of the nodeids required by the
594 # filterspec aren't in this node's property, then skip
595 # it
596 for value in v:
597 if value not in node[k]:
598 break
599 else:
600 continue
601 break
602 elif t == 2 and not v.search(node[k]):
603 # RE search
604 break
605 # elif t == 3 and node[k][:len(v)] != v:
606 # # start anchored
607 # break
608 # elif t == 4 and node[k][-len(v):] != v:
609 # # end anchored
610 # break
611 # elif t == 5 and node[k].find(v) == -1:
612 # # substring search
613 # break
614 elif t == 6 and node[k] != v:
615 # straight value comparison for the other types
616 break
617 else:
618 l.append((nodeid, node))
619 l.sort()
620 cldb.close()
622 # optimise sort
623 m = []
624 for entry in sort:
625 if entry[0] != '-':
626 m.append(('+', entry))
627 else:
628 m.append((entry[0], entry[1:]))
629 sort = m
631 # optimise group
632 m = []
633 for entry in group:
634 if entry[0] != '-':
635 m.append(('+', entry))
636 else:
637 m.append((entry[0], entry[1:]))
638 group = m
639 # now, sort the result
640 def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
641 db = self.db, cl=self):
642 a_id, an = a
643 b_id, bn = b
644 # sort by group and then sort
645 for list in group, sort:
646 for dir, prop in list:
647 # handle the properties that might be "faked"
648 if not an.has_key(prop):
649 an[prop] = cl.get(a_id, prop)
650 av = an[prop]
651 if not bn.has_key(prop):
652 bn[prop] = cl.get(b_id, prop)
653 bv = bn[prop]
655 # sorting is class-specific
656 propclass = properties[prop]
658 # String and Date values are sorted in the natural way
659 if isinstance(propclass, String):
660 # clean up the strings
661 if av and av[0] in string.uppercase:
662 av = an[prop] = av.lower()
663 if bv and bv[0] in string.uppercase:
664 bv = bn[prop] = bv.lower()
665 if (isinstance(propclass, String) or
666 isinstance(propclass, Date)):
667 if dir == '+':
668 r = cmp(av, bv)
669 if r != 0: return r
670 elif dir == '-':
671 r = cmp(bv, av)
672 if r != 0: return r
674 # Link properties are sorted according to the value of
675 # the "order" property on the linked nodes if it is
676 # present; or otherwise on the key string of the linked
677 # nodes; or finally on the node ids.
678 elif isinstance(propclass, Link):
679 link = db.classes[propclass.classname]
680 if av is None and bv is not None: return -1
681 if av is not None and bv is None: return 1
682 if av is None and bv is None: return 0
683 if link.getprops().has_key('order'):
684 if dir == '+':
685 r = cmp(link.get(av, 'order'),
686 link.get(bv, 'order'))
687 if r != 0: return r
688 elif dir == '-':
689 r = cmp(link.get(bv, 'order'),
690 link.get(av, 'order'))
691 if r != 0: return r
692 elif link.getkey():
693 key = link.getkey()
694 if dir == '+':
695 r = cmp(link.get(av, key), link.get(bv, key))
696 if r != 0: return r
697 elif dir == '-':
698 r = cmp(link.get(bv, key), link.get(av, key))
699 if r != 0: return r
700 else:
701 if dir == '+':
702 r = cmp(av, bv)
703 if r != 0: return r
704 elif dir == '-':
705 r = cmp(bv, av)
706 if r != 0: return r
708 # Multilink properties are sorted according to how many
709 # links are present.
710 elif isinstance(propclass, Multilink):
711 if dir == '+':
712 r = cmp(len(av), len(bv))
713 if r != 0: return r
714 elif dir == '-':
715 r = cmp(len(bv), len(av))
716 if r != 0: return r
717 # end for dir, prop in list:
718 # end for list in sort, group:
719 # if all else fails, compare the ids
720 return cmp(a[0], b[0])
722 l.sort(sortfun)
723 return [i[0] for i in l]
725 def count(self):
726 """Get the number of nodes in this class.
728 If the returned integer is 'numnodes', the ids of all the nodes
729 in this class run from 1 to numnodes, and numnodes+1 will be the
730 id of the next node to be created in this class.
731 """
732 return self.db.countnodes(self.classname)
734 # Manipulating properties:
736 def getprops(self):
737 """Return a dictionary mapping property names to property objects."""
738 d = self.properties.copy()
739 d['id'] = String()
740 return d
742 def addprop(self, **properties):
743 """Add properties to this class.
745 The keyword arguments in 'properties' must map names to property
746 objects, or a TypeError is raised. None of the keys in 'properties'
747 may collide with the names of existing properties, or a ValueError
748 is raised before any properties have been added.
749 """
750 for key in properties.keys():
751 if self.properties.has_key(key):
752 raise ValueError, key
753 self.properties.update(properties)
756 # XXX not in spec
757 class Node:
758 ''' A convenience wrapper for the given node
759 '''
760 def __init__(self, cl, nodeid):
761 self.__dict__['cl'] = cl
762 self.__dict__['nodeid'] = nodeid
763 def keys(self):
764 return self.cl.getprops().keys()
765 def has_key(self, name):
766 return self.cl.getprops().has_key(name)
767 def __getattr__(self, name):
768 if self.__dict__.has_key(name):
769 return self.__dict__['name']
770 try:
771 return self.cl.get(self.nodeid, name)
772 except KeyError, value:
773 raise AttributeError, str(value)
774 def __getitem__(self, name):
775 return self.cl.get(self.nodeid, name)
776 def __setattr__(self, name, value):
777 try:
778 return self.cl.set(self.nodeid, **{name: value})
779 except KeyError, value:
780 raise AttributeError, str(value)
781 def __setitem__(self, name, value):
782 self.cl.set(self.nodeid, **{name: value})
783 def history(self):
784 return self.cl.history(self.nodeid)
785 def retire(self):
786 return self.cl.retire(self.nodeid)
789 def Choice(name, *options):
790 cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
791 for i in range(len(options)):
792 cl.create(name=option[i], order=i)
793 return hyperdb.Link(name)
795 #
796 # $Log: not supported by cvs2svn $
797 # Revision 1.17 2001/08/16 06:59:58 richard
798 # all searches use re now - and they're all case insensitive
799 #
800 # Revision 1.16 2001/08/15 23:43:18 richard
801 # Fixed some isFooTypes that I missed.
802 # Refactored some code in the CGI code.
803 #
804 # Revision 1.15 2001/08/12 06:32:36 richard
805 # using isinstance(blah, Foo) now instead of isFooType
806 #
807 # Revision 1.14 2001/08/07 00:24:42 richard
808 # stupid typo
809 #
810 # Revision 1.13 2001/08/07 00:15:51 richard
811 # Added the copyright/license notice to (nearly) all files at request of
812 # Bizar Software.
813 #
814 # Revision 1.12 2001/08/02 06:38:17 richard
815 # Roundupdb now appends "mailing list" information to its messages which
816 # include the e-mail address and web interface address. Templates may
817 # override this in their db classes to include specific information (support
818 # instructions, etc).
819 #
820 # Revision 1.11 2001/08/01 04:24:21 richard
821 # mailgw was assuming certain properties existed on the issues being created.
822 #
823 # Revision 1.10 2001/07/30 02:38:31 richard
824 # get() now has a default arg - for migration only.
825 #
826 # Revision 1.9 2001/07/29 09:28:23 richard
827 # Fixed sorting by clicking on column headings.
828 #
829 # Revision 1.8 2001/07/29 08:27:40 richard
830 # Fixed handling of passed-in values in form elements (ie. during a
831 # drill-down)
832 #
833 # Revision 1.7 2001/07/29 07:01:39 richard
834 # Added vim command to all source so that we don't get no steenkin' tabs :)
835 #
836 # Revision 1.6 2001/07/29 05:36:14 richard
837 # Cleanup of the link label generation.
838 #
839 # Revision 1.5 2001/07/29 04:05:37 richard
840 # Added the fabricated property "id".
841 #
842 # Revision 1.4 2001/07/27 06:25:35 richard
843 # Fixed some of the exceptions so they're the right type.
844 # Removed the str()-ification of node ids so we don't mask oopsy errors any
845 # more.
846 #
847 # Revision 1.3 2001/07/27 05:17:14 richard
848 # just some comments
849 #
850 # Revision 1.2 2001/07/22 12:09:32 richard
851 # Final commit of Grande Splite
852 #
853 # Revision 1.1 2001/07/22 11:58:35 richard
854 # More Grande Splite
855 #
856 #
857 # vim: set filetype=python ts=4 sw=4 et si