source: src/mlx/rpc.py@ 895:929448cde3f5

Last change on this file since 895:929448cde3f5 was 895:929448cde3f5, checked in by István Váradi <ivaradi@…>, 6 years ago

The Ilyushin Il-62 is supported to some degree.

File size: 25.9 KB
Line 
1import const
2import rpccommon
3
4from common import MAVA_BASE_URL
5
6import jsonrpclib
7import hashlib
8import datetime
9import calendar
10import sys
11
12#---------------------------------------------------------------------------------------
13
14class RPCObject(object):
15 """Base class for objects read from RPC calls.
16
17 It is possible to construct it from a dictionary."""
18 def __init__(self, value, instructions = {}):
19 """Construct the object.
20
21 value is the dictionary returned by the call.
22
23 info is a mapping from names to 'instructions' on what to do with the
24 corresponding values. If the instruction is None, it will be ignored.
25 If the instruction is a function, the value will be passed to it and
26 the return value will be stored in the object.
27
28 For all other names, the value will be stored as the same-named
29 attribute."""
30 for (key, value) in value.iteritems():
31 if key in instructions:
32 instruction = instructions[key]
33 if instruction is None:
34 continue
35
36 try:
37 value = instruction(value)
38 except:
39 print >> sys.stderr, "Failed to convert value '%s' of attribute '%s':" % \
40 (value, key)
41 import traceback
42 traceback.print_exc()
43 setattr(self, key, value)
44
45#---------------------------------------------------------------------------------------
46
47class Reply(RPCObject):
48 """The generic reply structure."""
49
50#---------------------------------------------------------------------------------------
51
52class ScheduledFlight(RPCObject):
53 """A scheduled flight in the time table."""
54 # The instructions for the construction
55 # Type: normal flight
56 TYPE_NORMAL = 0
57
58 # Type: VIP flight
59 TYPE_VIP = 1
60
61 _instructions = {
62 "id" : int,
63 "pairID": int,
64 "typeCode": lambda value: BookedFlight._decodeAircraftType(value),
65 "departureTime": lambda value: ScheduledFlight._decodeTime(value),
66 "arrivalTime": lambda value: ScheduledFlight._decodeTime(value),
67 "duration": lambda value: ScheduledFlight._decodeDuration(value),
68 "spec": int,
69 "validFrom": lambda value: ScheduledFlight._decodeDate(value),
70 "validTo": lambda value: ScheduledFlight._decodeDate(value),
71 "date": lambda value: ScheduledFlight._decodeDate(value)
72 }
73
74 @staticmethod
75 def _decodeTime(value):
76 """Decode the given value as a time value."""
77 return datetime.datetime.strptime(value, "%H:%M:%S").time()
78
79 @staticmethod
80 def _decodeDate(value):
81 """Decode the given value as a date value."""
82 if not value or value=="0000-00-00":
83 return const.defaultDate
84 else:
85 return datetime.datetime.strptime(value, "%Y-%m-%d").date()
86
87 @staticmethod
88 def _decodeDuration(value):
89 """Decode the given value as a duration.
90
91 A number of seconds will be returned."""
92 t = datetime.datetime.strptime(value, "%H:%M:%S")
93 return (t.hour*60 + t.minute) * 60 + t.second
94
95 def __init__(self, value):
96 """Construct the scheduled flight object from the given JSON value."""
97 super(ScheduledFlight, self).__init__(value,
98 ScheduledFlight._instructions)
99 self.aircraftType = self.typeCode
100 del self.typeCode
101
102 self.type = ScheduledFlight.TYPE_VIP if self.spec==1 \
103 else ScheduledFlight.TYPE_NORMAL
104 del self.spec
105
106 def compareBy(self, other, name):
107 """Compare this flight with the other one according to the given
108 attribute name."""
109 if name=="callsign":
110 try:
111 cs1 = int(self.callsign[2:])
112 cs2 = int(other.callsign[2:])
113 return cmp(cs1, cs2)
114 except:
115 return cmp(self.callsign, other.callsign)
116 else:
117 return cmp(getattr(self, name), getattr(other, name))
118
119 def __repr__(self):
120 return "ScheduledFlight<%d, %d, %s, %s (%s) - %s (%s) -> %d, %d>" % \
121 (self.id, self.pairID, BookedFlight.TYPE2TYPECODE[self.aircraftType],
122 self.departureICAO, str(self.departureTime),
123 self.arrivalICAO, str(self.arrivalTime),
124 self.duration, self.type)
125
126#---------------------------------------------------------------------------------------
127
128class ScheduledFlightPair(object):
129 """A pair of scheduled flights.
130
131 Occasionally, one of the flights may be missing."""
132 @staticmethod
133 def scheduledFlights2Pairs(scheduledFlights, date):
134 """Convert the given list of scheduled flights into a list of flight
135 pairs."""
136 weekday = str(date.weekday()+1)
137
138 flights = {}
139 weekdayFlights = {}
140 for flight in scheduledFlights:
141 flights[flight.id] = flight
142 if (flight.type==ScheduledFlight.TYPE_NORMAL and
143 flight.arrivalICAO!="LHBP" and weekday in flight.days and
144 flight.validFrom<=date and flight.validTo>=date) or \
145 flight.type==ScheduledFlight.TYPE_VIP:
146 weekdayFlights[flight.id] = flight
147
148 flightPairs = []
149
150 while weekdayFlights:
151 (id, flight) = weekdayFlights.popitem()
152 if flight.type==ScheduledFlight.TYPE_NORMAL:
153 pairID = flight.pairID
154 if pairID in flights:
155 pairFlight = flights[pairID]
156 if flight.departureICAO=="LHBP" or \
157 (pairFlight.departureICAO!="LHBP" and
158 flight.callsign<pairFlight.callsign):
159 flightPairs.append(ScheduledFlightPair(flight, pairFlight))
160 else:
161 flightPairs.append(ScheduledFlightPair(pairFlight, flight))
162 del flights[pairID]
163 if pairID in weekdayFlights:
164 del weekdayFlights[pairID]
165 elif flight.type==ScheduledFlight.TYPE_VIP:
166 flightPairs.append(ScheduledFlightPair(flight))
167
168 flightPairs.sort(cmp = lambda pair1, pair2:
169 cmp(pair1.flight0.date, pair2.flight0.date))
170
171 return flightPairs
172
173 def __init__(self, flight0, flight1 = None):
174 """Construct the pair with the given flights."""
175 self.flight0 = flight0
176 self.flight1 = flight1
177
178 def compareBy(self, other, name):
179 """Compare this flight pair with the other one according to the given
180 attribute name, considering the first flights."""
181 return self.flight0.compareBy(other.flight0, name)
182
183 def __repr__(self):
184 return "ScheduledFlightPair<%s, %s, %s>" % \
185 (self.flight0.callsign, self.flight0.departureICAO,
186 self.flight0.arrivalICAO)
187
188#---------------------------------------------------------------------------------------
189
190class BookedFlight(RPCObject):
191 """A booked flight."""
192 # FIXME: copied from web.BookedFlight
193 TYPECODE2TYPE = { "736" : const.AIRCRAFT_B736,
194 "73G" : const.AIRCRAFT_B737,
195 "738" : const.AIRCRAFT_B738,
196 "73H" : const.AIRCRAFT_B738C,
197 "732" : const.AIRCRAFT_B732,
198 "733" : const.AIRCRAFT_B733,
199 "734" : const.AIRCRAFT_B734,
200 "735" : const.AIRCRAFT_B735,
201 "DH4" : const.AIRCRAFT_DH8D,
202 "762" : const.AIRCRAFT_B762,
203 "763" : const.AIRCRAFT_B763,
204 "CR2" : const.AIRCRAFT_CRJ2,
205 "F70" : const.AIRCRAFT_F70,
206 "LI2" : const.AIRCRAFT_DC3,
207 "TU3" : const.AIRCRAFT_T134,
208 "TU5" : const.AIRCRAFT_T154,
209 "YK4" : const.AIRCRAFT_YK40,
210 "146" : const.AIRCRAFT_B462,
211 "IL6" : const.AIRCRAFT_IL62 }
212
213 # FIXME: copied from web.BookedFlight
214 TYPE2TYPECODE = { const.AIRCRAFT_B736 : "736",
215 const.AIRCRAFT_B737 : "73G",
216 const.AIRCRAFT_B738 : "738",
217 const.AIRCRAFT_B738C : "73H",
218 const.AIRCRAFT_B732 : "732",
219 const.AIRCRAFT_B733 : "733",
220 const.AIRCRAFT_B734 : "734",
221 const.AIRCRAFT_B735 : "735",
222 const.AIRCRAFT_DH8D : "DH4",
223 const.AIRCRAFT_B762 : "762",
224 const.AIRCRAFT_B763 : "763",
225 const.AIRCRAFT_CRJ2 : "CR2",
226 const.AIRCRAFT_F70 : "F70",
227 const.AIRCRAFT_DC3 : "LI2",
228 const.AIRCRAFT_T134 : "TU3",
229 const.AIRCRAFT_T154 : "TU5",
230 const.AIRCRAFT_YK40 : "YK4",
231 const.AIRCRAFT_B462 : "146",
232 const.AIRCRAFT_IL62 : "IL6" }
233
234 # FIXME: copied from web.BookedFlight
235 @staticmethod
236 def _decodeAircraftType(typeCode):
237 """Decode the aircraft type from the given typeCode."""
238 if typeCode in BookedFlight.TYPECODE2TYPE:
239 return BookedFlight.TYPECODE2TYPE[typeCode]
240 else:
241 raise Exception("Invalid aircraft type code: '" + typeCode + "'")
242
243 @staticmethod
244 def _decodeStatus(status):
245 """Decode the status from the status string."""
246 if status=="booked":
247 return BookedFlight.STATUS_BOOKED
248 elif status=="reported":
249 return BookedFlight.STATUS_REPORTED
250 elif status=="accepted":
251 return BookedFlight.STATUS_ACCEPTED
252 elif status=="rejected":
253 return BookedFlight.STATUS_REJECTED
254 else:
255 raise Exception("Invalid flight status code: '" + status + "'")
256
257 # FIXME: copied from web.BookedFlight
258 @staticmethod
259 def getDateTime(date, time):
260 """Get a datetime object from the given textual date and time."""
261 return datetime.datetime.strptime(date + " " + time,
262 "%Y-%m-%d %H:%M:%S")
263
264 # FIXME: copied from web.BookedFlight
265 STATUS_BOOKED = 1
266
267 # FIXME: copied from web.BookedFlight
268 STATUS_REPORTED = 2
269
270 # FIXME: copied from web.BookedFlight
271 STATUS_ACCEPTED = 3
272
273 # FIXME: copied from web.BookedFlight
274 STATUS_REJECTED = 4
275
276 # The instructions for the construction
277 _instructions = {
278 "numPassengers" : int,
279 "numCrew" : int,
280 "bagWeight" : int,
281 "cargoWeight" : int,
282 "mailWeight" : int,
283 "aircraftType" : lambda value: BookedFlight._decodeAircraftType(value),
284 "status" : lambda value: BookedFlight._decodeStatus(value)
285 }
286
287 def __init__(self, value):
288 """Construct the booked flight object from the given RPC result
289 value."""
290 self.status = BookedFlight.STATUS_BOOKED
291 super(BookedFlight, self).__init__(value, BookedFlight._instructions)
292 self.departureTime = \
293 BookedFlight.getDateTime(self.date, self.departureTime)
294 self.arrivalTime = \
295 BookedFlight.getDateTime(self.date, self.arrivalTime)
296 if self.arrivalTime<self.departureTime:
297 self.arrivalTime += datetime.timedelta(days = 1)
298
299 def writeIntoFile(self, f):
300 """Write the flight into a file."""
301 print >> f, "callsign=%s" % (self.callsign,)
302 date = self.departureTime.date()
303 print >> f, "date=%04d-%02d-%0d" % (date.year, date.month, date.day)
304 print >> f, "dep_airport=%s" % (self.departureICAO,)
305 print >> f, "dest_airport=%s" % (self.arrivalICAO,)
306 print >> f, "planecode=%s" % \
307 (BookedFlight.TYPE2TYPECODE[self.aircraftType],)
308 print >> f, "planetype=%s" % (self.aircraftTypeName,)
309 print >> f, "tail_nr=%s" % (self.tailNumber,)
310 print >> f, "passenger=%d" % (self.numPassengers,)
311 print >> f, "crew=%d" % (self.numCrew,)
312 print >> f, "bag=%d" % (self.bagWeight,)
313 print >> f, "cargo=%d" % (self.cargoWeight,)
314 print >> f, "mail=%d" % (self.mailWeight,)
315 print >> f, "flight_route=%s" % (self.route,)
316 departureTime = self.departureTime
317 print >> f, "departure_time=%02d\\:%02d\\:%02d" % \
318 (departureTime.hour, departureTime.minute, departureTime.second)
319 arrivalTime = self.arrivalTime
320 print >> f, "arrival_time=%02d\\:%02d\\:%02d" % \
321 (arrivalTime.hour, arrivalTime.minute, arrivalTime.second)
322 print >> f, "foglalas_id=%s" % ("0" if self.id is None else self.id,)
323
324#---------------------------------------------------------------------------------------
325
326class AcceptedFlight(RPCObject):
327 """A flight that has been already accepted."""
328 # The instructions for the construction
329 @staticmethod
330 def parseTimestamp(s):
331 """Parse the given RPC timestamp."""
332 dt = datetime.datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
333 return calendar.timegm(dt.utctimetuple())
334
335 _instructions = {
336 "bookedFlight" : lambda value: BookedFlight(value),
337 "numPassengers" : int,
338 "fuelUsed" : int,
339 "rating" : lambda value: float(value) if value else 0.0
340 }
341
342 def __init__(self, value):
343 """Construct the booked flight object from the given RPC result
344 value."""
345 super(AcceptedFlight, self).__init__(value, AcceptedFlight._instructions)
346 self.flightTimeStart = \
347 AcceptedFlight.parseTimestamp(self.flightDate + " " +
348 self.flightTimeStart)
349 self.flightTimeEnd = \
350 AcceptedFlight.parseTimestamp(self.flightDate + " " +
351 self.flightTimeEnd)
352 if self.flightTimeEnd<self.flightTimeStart:
353 self.flightTimeEnd += 24*60*60
354
355#---------------------------------------------------------------------------------------
356
357class Plane(rpccommon.Plane, RPCObject):
358 """An airplane in the fleet."""
359 _instructions = {
360 "status" : lambda value: rpccommon.Plane.str2status(value),
361 "gateNumber" : lambda value: value if value else None,
362 "typeCode": lambda value: BookedFlight._decodeAircraftType(value)
363 }
364
365 def __init__(self, value):
366 """Construct the plane."""
367 RPCObject.__init__(self, value, instructions = Plane._instructions)
368 self.aircraftType = self.typeCode
369 del self.typeCode
370
371#---------------------------------------------------------------------------------------
372
373class Fleet(rpccommon.Fleet):
374 """The fleet."""
375 def __init__(self, value):
376 """Construct the fleet."""
377 super(Fleet, self).__init__()
378 for planeValue in value:
379 self._addPlane(Plane(planeValue))
380
381#---------------------------------------------------------------------------------------
382
383class Registration(object):
384 """Data for registration."""
385 def __init__(self, surName, firstName, nameOrder,
386 yearOfBirth, emailAddress, emailAddressPublic,
387 vatsimID, ivaoID, phoneNumber, nationality, password):
388 """Construct the registration data."""
389 self.surName = surName
390 self.firstName = firstName
391 self.nameOrder = nameOrder
392 self.yearOfBirth = yearOfBirth
393 self.emailAddress = emailAddress
394 self.emailAddressPublic = 1 if emailAddressPublic is True else \
395 0 if emailAddressPublic is False else emailAddressPublic
396 self.vatsimID = "" if vatsimID is None else vatsimID
397 self.ivaoID = "" if ivaoID is None else ivaoID
398 self.phoneNumber = phoneNumber
399 self.nationality = nationality
400 self.password = password
401
402#---------------------------------------------------------------------------------------
403
404class RPCException(Exception):
405 """An exception thrown by RPC operations."""
406 def __init__(self, result, message = None):
407 """Construct the exception."""
408 self._result = result
409 if message is None:
410 message = "RPC call failed with result code: %d" % (result,)
411 super(RPCException, self).__init__(message)
412
413 @property
414 def result(self):
415 """Get the result code."""
416 return self._result
417
418#---------------------------------------------------------------------------------------
419
420class Client(object):
421 """The RPC client interface."""
422 # The client protocol version
423 VERSION = 2
424
425 # Result code: OK
426 RESULT_OK = 0
427
428 # Result code: the login has failed
429 RESULT_LOGIN_FAILED = 1
430
431 # Result code: the given session ID is unknown (it might have expired).
432 RESULT_SESSION_INVALID = 2
433
434 # Result code: some database error
435 RESULT_DATABASE_ERROR = 3
436
437 # Result code: invalid data
438 RESULT_INVALID_DATA = 4
439
440 # Result code: the flight does not exist
441 RESULT_FLIGHT_NOT_EXISTS = 101
442
443 # Result code: the flight has already been reported.
444 RESULT_FLIGHT_ALREADY_REPORTED = 102
445
446 # Result code: a user with the given e-mail address already exists
447 RESULT_EMAIL_ALREADY_REGISTERED = 103
448
449 def __init__(self, getCredentialsFn):
450 """Construct the client."""
451 self._getCredentialsFn = getCredentialsFn
452
453 self._server = jsonrpclib.Server(MAVA_BASE_URL + "/jsonrpc.php")
454
455 self._userName = None
456 self._passwordHash = None
457 self._sessionID = None
458 self._loginCount = 0
459
460 @property
461 def valid(self):
462 """Determine if the client is valid, i.e. there is a session ID
463 stored."""
464 return self._sessionID is not None
465
466 def setCredentials(self, userName, password):
467 """Set the credentials for future logins."""
468
469 self._userName = userName
470
471 md5 = hashlib.md5()
472 md5.update(password)
473 self._passwordHash = md5.hexdigest()
474
475 self._sessionID = None
476
477 def register(self, registrationData):
478 """Register with the given data.
479
480 Returns a tuple of:
481 - the error code,
482 - the PID if there is no error."""
483 reply = Reply(self._server.register(registrationData))
484
485 return (reply.result,
486 reply.value["pid"] if reply.result==Client.RESULT_OK else None)
487
488 def login(self):
489 """Login using the given previously set credentials.
490
491 The session ID is stored in the object and used for later calls.
492
493 Returns the name of the pilot on success, or None on error."""
494 self._sessionID = None
495
496 reply = Reply(self._server.login(self._userName, self._passwordHash,
497 Client.VERSION))
498 if reply.result == Client.RESULT_OK:
499 self._loginCount += 1
500 self._sessionID = reply.value["sessionID"]
501
502 types = [BookedFlight.TYPECODE2TYPE[typeCode]
503 for typeCode in reply.value["typeCodes"]]
504
505 return (reply.value["name"], reply.value["rank"], types)
506 else:
507 return None
508
509 def getFlights(self):
510 """Get the flights available for performing."""
511 bookedFlights = []
512 reportedFlights = []
513 rejectedFlights = []
514
515 value = self._performCall(lambda sessionID:
516 self._server.getFlights(sessionID))
517 for flightData in value:
518 flight = BookedFlight(flightData)
519 if flight.status == BookedFlight.STATUS_BOOKED:
520 bookedFlights.append(flight)
521 elif flight.status == BookedFlight.STATUS_REPORTED:
522 reportedFlights.append(flight)
523 elif flight.status == BookedFlight.STATUS_REJECTED:
524 rejectedFlights.append(flight)
525
526 for flights in [bookedFlights, reportedFlights, rejectedFlights]:
527 flights.sort(cmp = lambda flight1, flight2:
528 cmp(flight1.departureTime, flight2.departureTime))
529
530 return (bookedFlights, reportedFlights, rejectedFlights)
531
532 def getAcceptedFlights(self):
533 """Get the flights that are already accepted."""
534 value = self._performCall(lambda sessionID:
535 self._server.getAcceptedFlights(sessionID))
536 flights = []
537 for flight in value:
538 flights.append(AcceptedFlight(flight))
539 return flights
540
541 def getEntryExamStatus(self):
542 """Get the status of the exams needed for joining MAVA."""
543 value = self._performCall(lambda sessionID:
544 self._server.getEntryExamStatus(sessionID))
545 return (value["entryExamPassed"], value["entryExamLink"],
546 value["checkFlightStatus"], value["madeFO"])
547
548 def getFleet(self):
549 """Query and return the fleet."""
550 value = self._performCall(lambda sessionID:
551 self._server.getFleet(sessionID))
552
553 return Fleet(value)
554
555 def updatePlane(self, tailNumber, status, gateNumber):
556 """Update the state and position of the plane with the given tail
557 number."""
558 status = rpccommon.Plane.status2str(status)
559 self._performCall(lambda sessionID:
560 self._server.updatePlane(sessionID, tailNumber,
561 status, gateNumber))
562
563 def addPIREP(self, flightID, pirep, update = False):
564 """Add the PIREP for the given flight."""
565 (result, _value) = \
566 self._performCall(lambda sessionID:
567 self._server.addPIREP(sessionID, flightID, pirep,
568 update),
569 acceptResults = [Client.RESULT_FLIGHT_ALREADY_REPORTED,
570 Client.RESULT_FLIGHT_NOT_EXISTS])
571 return result
572
573 def updateOnlineACARS(self, acars):
574 """Update the online ACARS from the given data."""
575 self._performCall(lambda sessionID:
576 self._server.updateOnlineACARS(sessionID, acars))
577
578 def setCheckFlightPassed(self, type):
579 """Mark the check flight of the user passed with the given type."""
580 self._performCall(lambda sessionID:
581 self._server.setCheckFlightPassed(sessionID, type))
582
583 def getPIREP(self, flightID):
584 """Get the PIREP data for the flight with the given ID."""
585 value = self._performCall(lambda sessionID:
586 self._server.getPIREP(sessionID, flightID))
587 return value
588
589 def reflyFlights(self, flightIDs):
590 """Mark the flights with the given IDs for reflying."""
591 self._performCall(lambda sessionID:
592 self._server.reflyFlights(sessionID, flightIDs))
593
594 def deleteFlights(self, flightIDs):
595 """Delete the flights with the given IDs."""
596 self._performCall(lambda sessionID:
597 self._server.deleteFlights(sessionID, flightIDs))
598
599 def getTimetable(self, date, types = None):
600 """Get the time table for the given date restricted to the given list
601 of type codes, if any."""
602 typeCodes = None if types is None else \
603 [BookedFlight.TYPE2TYPECODE[type] for type in types]
604
605 values = self._performCall(lambda sessionID:
606 self._server.getTimetable(sessionID,
607 date.strftime("%Y-%m-%d"),
608 date.weekday()+1,
609 typeCodes))
610 return ScheduledFlightPair.scheduledFlights2Pairs([ScheduledFlight(value)
611 for value in values],
612 date)
613
614 def bookFlights(self, flightIDs, date, tailNumber):
615 """Book the flights with the given IDs on the given date to be flown
616 with the plane of the given tail number."""
617 values = self._performCall(lambda sessionID:
618 self._server.bookFlights(sessionID,
619 flightIDs,
620 date.strftime("%Y-%m-%d"),
621 tailNumber))
622 return [BookedFlight(value) for value in values]
623
624 def _performCall(self, callFn, acceptResults = []):
625 """Perform a call using the given call function.
626
627 acceptResults should be a list of result codes that should be accepted
628 besides RESULT_OK. If this list is not empty, the returned value is a
629 tuple of the result code and the corresponding value. Otherwise only
630 RESULT_OK is accepted, and the value is returned.
631
632 All other error codes are converted to exceptions."""
633 numAttempts = 0
634 while True:
635 reply = Reply(callFn(self._ensureSession()))
636 numAttempts += 1
637 result = reply.result
638 if result==Client.RESULT_SESSION_INVALID:
639 self._sessionID = None
640 if numAttempts==3:
641 raise RPCException(result)
642 elif result!=Client.RESULT_OK and result not in acceptResults:
643 raise RPCException(result)
644 elif acceptResults:
645 return (result, reply.value)
646 else:
647 return reply.value
648
649 def _ensureSession(self):
650 """Ensure that there is a valid session ID."""
651 while self._sessionID is None:
652 if self._userName is not None and self._passwordHash is not None:
653 if not self.login():
654 self._userName = self._passwordHash = None
655
656 if self._userName is None or self._passwordHash is None:
657 (self._userName, password) = self._getCredentialsFn()
658 if self._userName is None:
659 raise RPCException(Client.RESULT_LOGIN_FAILED)
660
661 md5 = hashlib.md5()
662 md5.update(password)
663 self._passwordHash = md5.hexdigest()
664
665 return self._sessionID
666
667#---------------------------------------------------------------------------------------
Note: See TracBrowser for help on using the repository browser.