/[thuban]/branches/WIP-pyshapelib-bramz/Thuban/Model/load.py
ViewVC logotype

Contents of /branches/WIP-pyshapelib-bramz/Thuban/Model/load.py

Parent Directory Parent Directory | Revision Log Revision Log


Revision 2446 - (show annotations)
Mon Dec 13 11:52:34 2004 UTC (20 years, 2 months ago) by frank
Original Path: trunk/thuban/Thuban/Model/load.py
File MIME type: text/x-python
File size: 26937 byte(s)
Alternative Path feature:
	* test/test_load.py (TestAltPath): New, tests for alternative path feature
	in load_session()
	(Shapefile_CallBack): Helper, implements controllable callback.

	* Thuban/UI/application.py (ThubanApplication.OnInit):
	Added "alt_path" to self.path
	(ThubanApplication.OpenSession): Added shapefile_callback as second
	callback similar to db_connection_callback.
	(ThubanApplication.run_alt_path_dialog): New, implementaion of
	shapefile_callback. In addition to raising the dialog the control of
	self.path('alt_path') is implemented here.

	* Thuban/Model/load.py (SessionLoader.__init__): Added shapefile_callback.
	(SessionLoader.open_shapefile): Open shapefile, eventually with
	alternative path. This wrapps the "theSession.OpenShapefile(filename)"
	formerly used in start_fileshapesource()/start_layer().
	(SessionLoader.start_fileshapesource): Call open_shapefile().
	(SessionLoader.start_layer): Call open_shapefile().
	(load_session): Added shapefile_callback.

	* Thuban/UI/altpathdialog.py: New, implements dialogs for alternative path
	feature (search / check).

1 # Copyright (C) 2001, 2002, 2003, 2004 by Intevation GmbH
2 # Authors:
3 # Jan-Oliver Wagner <[email protected]>
4 # Bernhard Herzog <[email protected]>
5 # Jonathan Coles <[email protected]>
6 # Frank Koormann <[email protected]>
7 #
8 # This program is free software under the GPL (>=v2)
9 # Read the file COPYING coming with GRASS for details.
10
11 """
12 Parser for thuban session files.
13 """
14
15 __version__ = "$Revision$"
16
17 import string, os
18
19 import xml.sax
20 import xml.sax.handler
21 from xml.sax import make_parser, ErrorHandler, SAXNotRecognizedException
22
23 from Thuban import _
24
25 from Thuban.Model.table import FIELDTYPE_INT, FIELDTYPE_DOUBLE, \
26 FIELDTYPE_STRING
27
28 from Thuban.Model.color import Color, Transparent
29
30 from Thuban.Model.session import Session
31 from Thuban.Model.map import Map
32 from Thuban.Model.layer import Layer, RasterLayer
33 from Thuban.Model.proj import Projection
34 from Thuban.Model.range import Range
35 from Thuban.Model.classification import Classification, \
36 ClassGroupDefault, ClassGroupSingleton, ClassGroupRange, ClassGroupMap, \
37 ClassGroupProperties
38 from Thuban.Model.data import DerivedShapeStore, ShapefileStore
39 from Thuban.Model.table import DBFTable
40 from Thuban.Model.transientdb import TransientJoinedTable
41
42 from Thuban.Model.xmlreader import XMLReader
43 import resource
44
45 import postgisdb
46
47 class LoadError(Exception):
48
49 """Exception raised when the thuban file is corrupted
50
51 Not all cases of corrupted thuban files will lead to this exception
52 but those that are found by checks in the loading code itself are.
53 """
54
55
56 class LoadCancelled(Exception):
57
58 """Exception raised to indicate that loading was interrupted by the user"""
59
60
61 def parse_color(color):
62 """Return the color object for the string color.
63
64 Color may be either 'None' or of the form '#RRGGBB' in the usual
65 HTML color notation
66 """
67 color = string.strip(color)
68 if color == "None":
69 result = Transparent
70 elif color[0] == '#':
71 if len(color) == 7:
72 r = string.atoi(color[1:3], 16) / 255.0
73 g = string.atoi(color[3:5], 16) / 255.0
74 b = string.atoi(color[5:7], 16) / 255.0
75 result = Color(r, g, b)
76 else:
77 raise ValueError(_("Invalid hexadecimal color specification %s")
78 % color)
79 else:
80 raise ValueError(_("Invalid color specification %s") % color)
81 return result
82
83 class AttrDesc:
84
85 def __init__(self, name, required = False, default = "",
86 conversion = None):
87 if not isinstance(name, tuple):
88 fullname = (None, name)
89 else:
90 fullname = name
91 name = name[1]
92 self.name = name
93 self.fullname = fullname
94 self.required = required
95 self.default = default
96 self.conversion = conversion
97
98 # set by the SessionLoader's check_attrs method
99 self.value = None
100
101
102 class SessionLoader(XMLReader):
103
104 def __init__(self, db_connection_callback = None,
105 shapefile_callback = None):
106 """Inititialize the Sax handler."""
107 XMLReader.__init__(self)
108
109 self.db_connection_callback = db_connection_callback
110 self.shapefile_callback = shapefile_callback
111 self.theSession = None
112 self.aMap = None
113 self.aLayer = None
114
115 # Map ids used in the thuban file to the corresponding objects
116 # in the session
117 self.idmap = {}
118
119 dispatchers = {
120 'session' : ("start_session", "end_session"),
121
122 'dbconnection': ("start_dbconnection", None),
123
124 'dbshapesource': ("start_dbshapesource", None),
125 'fileshapesource': ("start_fileshapesource", None),
126 'derivedshapesource': ("start_derivedshapesource", None),
127 'filetable': ("start_filetable", None),
128 'jointable': ("start_jointable", None),
129
130 'map' : ("start_map", "end_map"),
131 'projection' : ("start_projection", "end_projection"),
132 'parameter' : ("start_parameter", None),
133 'layer' : ("start_layer", "end_layer"),
134 'rasterlayer' : ("start_rasterlayer", "end_rasterlayer"),
135 'classification': ("start_classification", "end_classification"),
136 'clnull' : ("start_clnull", "end_clnull"),
137 'clpoint' : ("start_clpoint", "end_clpoint"),
138 'clrange' : ("start_clrange", "end_clrange"),
139 'cldata' : ("start_cldata", "end_cldata"),
140 'table' : ("start_table", "end_table"),
141 'labellayer' : ("start_labellayer", None),
142 'label' : ("start_label", None)}
143
144 # all dispatchers should be used for the 0.8 and 0.9 namespaces too
145 for xmlns in ("http://thuban.intevation.org/dtds/thuban-0.8.dtd",
146 "http://thuban.intevation.org/dtds/thuban-0.9-dev.dtd",
147 "http://thuban.intevation.org/dtds/thuban-0.9.dtd",
148 "http://thuban.intevation.org/dtds/thuban-1.0-dev.dtd",
149 "http://thuban.intevation.org/dtds/thuban-1.0rc1.dtd",
150 "http://thuban.intevation.org/dtds/thuban-1.0.0.dtd",
151 "http://thuban.intevation.org/dtds/thuban-1.1-dev.dtd"):
152 for key, value in dispatchers.items():
153 dispatchers[(xmlns, key)] = value
154
155 XMLReader.AddDispatchers(self, dispatchers)
156
157 def Destroy(self):
158 """Clear all instance variables to cut cyclic references.
159
160 The GC would have collected the loader eventually but it can
161 happen that it doesn't run at all until Thuban is closed (2.3
162 but not 2.2 tries a bit harder and forces a collection when the
163 interpreter terminates)
164 """
165 self.__dict__.clear()
166
167 def start_session(self, name, qname, attrs):
168 self.theSession = Session(self.encode(attrs.get((None, 'title'),
169 None)))
170
171 def end_session(self, name, qname):
172 pass
173
174 def check_attrs(self, element, attrs, descr):
175 """Check and convert some of the attributes of an element
176
177 Parameters:
178 element -- The element name
179 attrs -- The attrs mapping as passed to the start_* methods
180 descr -- Sequence of attribute descriptions (AttrDesc instances)
181
182 Return a dictionary containig normalized versions of the
183 attributes described in descr. The keys of that dictionary are
184 the name attributes of the attribute descriptions. The attrs
185 dictionary will not be modified.
186
187 If the attribute is required, i.e. the 'required' attribute of
188 the descrtiption is true, but it is not in attrs, raise a
189 LoadError.
190
191 If the attribute has a default value and it is not present in
192 attrs, use that default value as the value in the returned dict.
193
194 The value is converted before putting it into the returned dict.
195 The following conversions are available:
196
197 'filename' -- The attribute is a filename.
198
199 If the filename is a relative name, interpret
200 it relative to the directory containing the
201 .thuban file and make it an absolute name
202
203 'shapestore' -- The attribute is the ID of a shapestore
204 defined earlier in the .thuban file. Look it
205 up self.idmap
206
207 'table' -- The attribute is the ID of a table or shapestore
208 defined earlier in the .thuban file. Look it up
209 self.idmap. If it's the ID of a shapestore the
210 value will be the table of the shapestore.
211
212 'idref' -- The attribute is the id of an object defined
213 earlier in the .thuban file. Look it up self.idmap
214
215 'ascii' -- The attribute is converted to a bytestring with
216 ascii encoding.
217
218 a callable -- The attribute value is passed to the callable
219 and the return value is used as the converted
220 value
221
222 If no conversion is specified for an attribute it is converted
223 with self.encode.
224 """
225 normalized = {}
226
227 for d in descr:
228 if d.required and not attrs.has_key(d.fullname):
229 raise LoadError("Element %s requires an attribute %r"
230 % (element, d.name))
231 value = attrs.get(d.fullname, d.default)
232
233 if d.conversion in ("idref", "shapesource"):
234 if value in self.idmap:
235 value = self.idmap[value]
236 else:
237 raise LoadError("Element %s requires an already defined ID"
238 " in attribute %r"
239 % (element, d.name))
240 elif d.conversion == "table":
241 if value in self.idmap:
242 value = self.idmap[value]
243 if isinstance(value, ShapefileStore):
244 value = value.Table()
245 else:
246 raise LoadError("Element %s requires an already defined ID"
247 " in attribute %r"
248 % (element, d.name))
249 elif d.conversion == "filename":
250 value = os.path.abspath(os.path.join(self.GetDirectory(),
251 value))
252 elif d.conversion == "ascii":
253 value = value.encode("ascii")
254 elif d.conversion:
255 # Assume it's a callable
256 value = d.conversion(value)
257 else:
258 value = self.encode(value)
259
260 normalized[d.name] = value
261 return normalized
262
263 def open_shapefile(self, filename):
264 """Open shapefile, eventually with alternative path."""
265 from_list = 0
266 while 1:
267 try:
268 store = self.theSession.OpenShapefile(filename)
269 if from_list:
270 # The correct? path has been guessed from a list
271 # Let the user confirm - or select an alternative.
272 filename, from_list = self.shapefile_callback(
273 filename, "check")
274 if filename is None:
275 # Selection cancelled
276 raise LoadCancelled
277 elif store.FileName() == filename:
278 # Proposed file has been accepted
279 break
280 else:
281 # the filename has been changed, try the new file
282 pass
283 else:
284 break
285 except IOError:
286 if self.shapefile_callback is not None:
287 filename, from_list = self.shapefile_callback(
288 filename,
289 mode = "search",
290 second_try = from_list)
291 if filename is None:
292 raise LoadCancelled
293 print filename
294 else:
295 raise
296 return store
297
298 def start_dbconnection(self, name, qname, attrs):
299 attrs = self.check_attrs(name, attrs,
300 [AttrDesc("id", True),
301 AttrDesc("dbtype", True),
302 AttrDesc("host", False, ""),
303 AttrDesc("port", False, ""),
304 AttrDesc("user", False, ""),
305 AttrDesc("dbname", True)])
306 ID = attrs["id"]
307 dbtype = attrs["dbtype"]
308 if dbtype != "postgis":
309 raise LoadError("dbtype %r not supported" % filetype)
310
311 del attrs["id"]
312 del attrs["dbtype"]
313
314 # Try to open the connection and if it fails ask the user for
315 # the correct parameters repeatedly.
316 # FIXME: it would be better not to insist on getting a
317 # connection here. We should handle this more like the raster
318 # images where the layers etc still are created but are not
319 # drawn in case Thuban can't use the data for various reasons
320 while 1:
321 try:
322 conn = postgisdb.PostGISConnection(**attrs)
323 break
324 except postgisdb.ConnectionError, val:
325 if self.db_connection_callback is not None:
326 attrs = self.db_connection_callback(attrs, str(val))
327 if attrs is None:
328 raise LoadCancelled
329 else:
330 raise
331
332 self.idmap[ID] = conn
333 self.theSession.AddDBConnection(conn)
334
335 def start_dbshapesource(self, name, qname, attrs):
336 attrs = self.check_attrs(name, attrs,
337 [AttrDesc("id", True),
338 AttrDesc("dbconn", True,
339 conversion = "idref"),
340 AttrDesc("tablename", True,
341 conversion = "ascii"),
342 # id_column and geometry_column were
343 # newly introduced with thuban-1.1.dtd
344 # where they're required. Since we
345 # support the older formats too we
346 # have them optional here.
347 AttrDesc("id_column", False, "gid",
348 conversion = "ascii"),
349 AttrDesc("geometry_column", False,
350 conversion = "ascii")])
351 # The default value of geometry_column to use when instantiating
352 # the db shapestore is None which we currently can't easily use
353 # in check_attrs
354 geometry_column = attrs["geometry_column"]
355 if not geometry_column:
356 geometry_column = None
357 dbopen = self.theSession.OpenDBShapeStore
358 self.idmap[attrs["id"]] = dbopen(attrs["dbconn"], attrs["tablename"],
359 id_column = attrs["id_column"],
360 geometry_column=geometry_column)
361
362 def start_fileshapesource(self, name, qname, attrs):
363 attrs = self.check_attrs(name, attrs,
364 [AttrDesc("id", True),
365 AttrDesc("filename", True,
366 conversion = "filename"),
367 AttrDesc("filetype", True)])
368 ID = attrs["id"]
369 filename = attrs["filename"]
370 filetype = attrs["filetype"]
371 if filetype != "shapefile":
372 raise LoadError("shapesource filetype %r not supported" % filetype)
373 self.idmap[ID] = self.open_shapefile(filename)
374
375 def start_derivedshapesource(self, name, qname, attrs):
376 attrs = self.check_attrs(name, attrs,
377 [AttrDesc("id", True),
378 AttrDesc("shapesource", True,
379 conversion = "shapesource"),
380 AttrDesc("table", True, conversion="table")])
381 store = DerivedShapeStore(attrs["shapesource"], attrs["table"])
382 self.theSession.AddShapeStore(store)
383 self.idmap[attrs["id"]] = store
384
385 def start_filetable(self, name, qname, attrs):
386 attrs = self.check_attrs(name, attrs,
387 [AttrDesc("id", True),
388 AttrDesc("title", True),
389 AttrDesc("filename", True,
390 conversion = "filename"),
391 AttrDesc("filetype")])
392 filetype = attrs["filetype"]
393 if filetype != "DBF":
394 raise LoadError("shapesource filetype %r not supported" % filetype)
395 table = DBFTable(attrs["filename"])
396 table.SetTitle(attrs["title"])
397 self.idmap[attrs["id"]] = self.theSession.AddTable(table)
398
399 def start_jointable(self, name, qname, attrs):
400 attrs = self.check_attrs(name, attrs,
401 [AttrDesc("id", True),
402 AttrDesc("title", True),
403 AttrDesc("left", True, conversion="table"),
404 AttrDesc("leftcolumn", True),
405 AttrDesc("right", True, conversion="table"),
406 AttrDesc("rightcolumn", True),
407
408 # jointype is required for file
409 # version 0.9 but this attribute
410 # wasn't in the 0.8 version because of
411 # an oversight so we assume it's
412 # optional since we want to handle
413 # both file format versions here.
414 AttrDesc("jointype", False,
415 default="INNER")])
416
417 jointype = attrs["jointype"]
418 if jointype == "LEFT OUTER":
419 outer_join = True
420 elif jointype == "INNER":
421 outer_join = False
422 else:
423 raise LoadError("jointype %r not supported" % jointype )
424 table = TransientJoinedTable(self.theSession.TransientDB(),
425 attrs["left"], attrs["leftcolumn"],
426 attrs["right"], attrs["rightcolumn"],
427 outer_join = outer_join)
428 table.SetTitle(attrs["title"])
429 self.idmap[attrs["id"]] = self.theSession.AddTable(table)
430
431 def start_map(self, name, qname, attrs):
432 """Start a map."""
433 self.aMap = Map(self.encode(attrs.get((None, 'title'), None)))
434
435 def end_map(self, name, qname):
436 self.theSession.AddMap(self.aMap)
437 self.aMap = None
438
439 def start_projection(self, name, qname, attrs):
440 attrs = self.check_attrs(name, attrs,
441 [AttrDesc("name", conversion=self.encode),
442 AttrDesc("epsg", default=None,
443 conversion=self.encode)])
444 self.projection_name = attrs["name"]
445 self.projection_epsg = attrs["epsg"]
446 self.projection_params = [ ]
447
448 def end_projection(self, name, qname):
449 if self.aLayer is not None:
450 obj = self.aLayer
451 elif self.aMap is not None:
452 obj = self.aMap
453 else:
454 assert False, "projection tag out of context"
455 pass
456
457 obj.SetProjection(Projection(self.projection_params,
458 self.projection_name,
459 epsg = self.projection_epsg))
460
461 def start_parameter(self, name, qname, attrs):
462 s = attrs.get((None, 'value'))
463 s = str(s) # we can't handle unicode in proj
464 self.projection_params.append(s)
465
466 def start_layer(self, name, qname, attrs, layer_class = Layer):
467 """Start a layer
468
469 Instantiate a layer of class layer_class from the attributes in
470 attrs which may be a dictionary as well as the normal SAX attrs
471 object and bind it to self.aLayer.
472 """
473 title = self.encode(attrs.get((None, 'title'), ""))
474 filename = attrs.get((None, 'filename'), "")
475 filename = os.path.join(self.GetDirectory(), filename)
476 filename = self.encode(filename)
477 visible = self.encode(attrs.get((None, 'visible'), "true")) != "false"
478 fill = parse_color(attrs.get((None, 'fill'), "None"))
479 stroke = parse_color(attrs.get((None, 'stroke'), "#000000"))
480 stroke_width = int(attrs.get((None, 'stroke_width'), "1"))
481 if attrs.has_key((None, "shapestore")):
482 store = self.idmap[attrs[(None, "shapestore")]]
483 else:
484 store = self.open_shapefile(filename)
485
486 self.aLayer = layer_class(title, store,
487 fill = fill, stroke = stroke,
488 lineWidth = stroke_width,
489 visible = visible)
490
491 def end_layer(self, name, qname):
492 self.aMap.AddLayer(self.aLayer)
493 self.aLayer = None
494
495 def start_rasterlayer(self, name, qname, attrs, layer_class = RasterLayer):
496 title = self.encode(attrs.get((None, 'title'), ""))
497 filename = attrs.get((None, 'filename'), "")
498 filename = os.path.join(self.GetDirectory(), filename)
499 filename = self.encode(filename)
500 visible = self.encode(attrs.get((None, 'visible'), "true")) != "false"
501
502 self.aLayer = layer_class(title, filename, visible = visible)
503
504 def end_rasterlayer(self, name, qname):
505 self.aMap.AddLayer(self.aLayer)
506 self.aLayer = None
507
508 def start_classification(self, name, qname, attrs):
509 attrs = self.check_attrs(name, attrs,
510 [AttrDesc("field", True),
511 AttrDesc("field_type", True)])
512 field = attrs["field"]
513 fieldType = attrs["field_type"]
514
515 dbFieldType = self.aLayer.GetFieldType(field)
516
517 if fieldType != dbFieldType:
518 raise ValueError(_("xml field type differs from database!"))
519
520 # setup conversion routines depending on the kind of data
521 # we will be seeing later on
522 if fieldType == FIELDTYPE_STRING:
523 self.conv = str
524 elif fieldType == FIELDTYPE_INT:
525 self.conv = lambda p: int(float(p))
526 elif fieldType == FIELDTYPE_DOUBLE:
527 self.conv = float
528
529 self.aLayer.SetClassificationColumn(field)
530
531 def end_classification(self, name, qname):
532 pass
533
534 def start_clnull(self, name, qname, attrs):
535 self.cl_group = ClassGroupDefault()
536 self.cl_group.SetLabel(self.encode(attrs.get((None, 'label'), "")))
537 self.cl_prop = ClassGroupProperties()
538
539 def end_clnull(self, name, qname):
540 self.cl_group.SetProperties(self.cl_prop)
541 self.aLayer.GetClassification().SetDefaultGroup(self.cl_group)
542 del self.cl_group, self.cl_prop
543
544 def start_clpoint(self, name, qname, attrs):
545 attrib_value = attrs.get((None, 'value'), "0")
546
547 field = self.aLayer.GetClassificationColumn()
548 if self.aLayer.GetFieldType(field) == FIELDTYPE_STRING:
549 value = self.encode(attrib_value)
550 else:
551 value = self.conv(attrib_value)
552 self.cl_group = ClassGroupSingleton(value)
553 self.cl_group.SetLabel(self.encode(attrs.get((None, 'label'), "")))
554 self.cl_prop = ClassGroupProperties()
555
556
557 def end_clpoint(self, name, qname):
558 self.cl_group.SetProperties(self.cl_prop)
559 self.aLayer.GetClassification().AppendGroup(self.cl_group)
560 del self.cl_group, self.cl_prop
561
562 def start_clrange(self, name, qname, attrs):
563
564 range = attrs.get((None, 'range'), None)
565 # for backward compatibility (min/max are not saved)
566 min = attrs.get((None, 'min'), None)
567 max = attrs.get((None, 'max'), None)
568
569 try:
570 if range is not None:
571 self.cl_group = ClassGroupRange(Range(range))
572 elif min is not None and max is not None:
573 self.cl_group = ClassGroupRange((self.conv(min),
574 self.conv(max)))
575 else:
576 self.cl_group = ClassGroupRange(Range(None))
577
578 except ValueError:
579 raise ValueError(_("Classification range is not a number!"))
580
581 self.cl_group.SetLabel(attrs.get((None, 'label'), ""))
582 self.cl_prop = ClassGroupProperties()
583
584
585 def end_clrange(self, name, qname):
586 self.cl_group.SetProperties(self.cl_prop)
587 self.aLayer.GetClassification().AppendGroup(self.cl_group)
588 del self.cl_group, self.cl_prop
589
590 def start_cldata(self, name, qname, attrs):
591 self.cl_prop.SetLineColor(
592 parse_color(attrs.get((None, 'stroke'), "None")))
593 self.cl_prop.SetLineWidth(
594 int(attrs.get((None, 'stroke_width'), "0")))
595 self.cl_prop.SetSize(int(attrs.get((None, 'size'), "5")))
596 self.cl_prop.SetFill(parse_color(attrs.get((None, 'fill'), "None")))
597
598 def end_cldata(self, name, qname):
599 pass
600
601 def start_labellayer(self, name, qname, attrs):
602 self.aLayer = self.aMap.LabelLayer()
603
604 def start_label(self, name, qname, attrs):
605 attrs = self.check_attrs(name, attrs,
606 [AttrDesc("x", True, conversion = float),
607 AttrDesc("y", True, conversion = float),
608 AttrDesc("text", True),
609 AttrDesc("halign", True,
610 conversion = "ascii"),
611 AttrDesc("valign", True,
612 conversion = "ascii")])
613 x = attrs['x']
614 y = attrs['y']
615 text = attrs['text']
616 halign = attrs['halign']
617 valign = attrs['valign']
618 if halign not in ("left", "center", "right"):
619 raise LoadError("Unsupported halign value %r" % halign)
620 if valign not in ("top", "center", "bottom"):
621 raise LoadError("Unsupported valign value %r" % valign)
622 self.aLayer.AddLabel(x, y, text, halign = halign, valign = valign)
623
624 def characters(self, chars):
625 pass
626
627
628 def load_session(filename, db_connection_callback = None,
629 shapefile_callback = None):
630 """Load a Thuban session from the file object file
631
632 The db_connection_callback, if given should be a callable object
633 that can be called like this:
634 db_connection_callback(params, message)
635
636 where params is a dictionary containing the known connection
637 parameters and message is a string with a message why the connection
638 failed. db_connection_callback should return a new dictionary with
639 corrected and perhaps additional parameters like a password or None
640 to indicate that the user cancelled.
641 """
642 handler = SessionLoader(db_connection_callback, shapefile_callback)
643 handler.read(filename)
644
645 session = handler.theSession
646 # Newly loaded session aren't modified
647 session.UnsetModified()
648
649 handler.Destroy()
650
651 return session
652

Properties

Name Value
svn:eol-style native
svn:keywords Author Date Id Revision

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26