source: josm/trunk/src/org/openstreetmap/josm/io/OsmReader.java@ 10228

Last change on this file since 10228 was 10223, checked in by Don-vip, 8 years ago

findbugs: DP_DO_INSIDE_DO_PRIVILEGED + UWF_UNWRITTEN_FIELD + RC_REF_COMPARISON + OBL_UNSATISFIED_OBLIGATION_EXCEPTION_EDGE

  • Property svn:eol-style set to native
File size: 26.0 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.io;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5
6import java.io.IOException;
7import java.io.InputStream;
8import java.io.InputStreamReader;
9import java.text.MessageFormat;
10import java.util.ArrayList;
11import java.util.Collection;
12import java.util.List;
13import java.util.Objects;
14import java.util.regex.Matcher;
15import java.util.regex.Pattern;
16
17import javax.xml.stream.Location;
18import javax.xml.stream.XMLInputFactory;
19import javax.xml.stream.XMLStreamConstants;
20import javax.xml.stream.XMLStreamException;
21import javax.xml.stream.XMLStreamReader;
22
23import org.openstreetmap.josm.Main;
24import org.openstreetmap.josm.data.Bounds;
25import org.openstreetmap.josm.data.DataSource;
26import org.openstreetmap.josm.data.coor.LatLon;
27import org.openstreetmap.josm.data.osm.Changeset;
28import org.openstreetmap.josm.data.osm.DataSet;
29import org.openstreetmap.josm.data.osm.Node;
30import org.openstreetmap.josm.data.osm.NodeData;
31import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
32import org.openstreetmap.josm.data.osm.PrimitiveData;
33import org.openstreetmap.josm.data.osm.Relation;
34import org.openstreetmap.josm.data.osm.RelationData;
35import org.openstreetmap.josm.data.osm.RelationMemberData;
36import org.openstreetmap.josm.data.osm.Tagged;
37import org.openstreetmap.josm.data.osm.User;
38import org.openstreetmap.josm.data.osm.Way;
39import org.openstreetmap.josm.data.osm.WayData;
40import org.openstreetmap.josm.gui.progress.NullProgressMonitor;
41import org.openstreetmap.josm.gui.progress.ProgressMonitor;
42import org.openstreetmap.josm.tools.CheckParameterUtil;
43import org.openstreetmap.josm.tools.date.DateUtils;
44
45/**
46 * Parser for the Osm Api. Read from an input stream and construct a dataset out of it.
47 *
48 * For each xml element, there is a dedicated method.
49 * The XMLStreamReader cursor points to the start of the element, when the method is
50 * entered, and it must point to the end of the same element, when it is exited.
51 */
52public class OsmReader extends AbstractReader {
53
54 protected XMLStreamReader parser;
55
56 protected boolean cancel;
57
58 /** Used by plugins to register themselves as data postprocessors. */
59 private static volatile List<OsmServerReadPostprocessor> postprocessors;
60
61 /** Register a new postprocessor.
62 * @param pp postprocessor
63 * @see #deregisterPostprocessor
64 */
65 public static void registerPostprocessor(OsmServerReadPostprocessor pp) {
66 if (postprocessors == null) {
67 postprocessors = new ArrayList<>();
68 }
69 postprocessors.add(pp);
70 }
71
72 /**
73 * Deregister a postprocessor previously registered with {@link #registerPostprocessor}.
74 * @param pp postprocessor
75 * @see #registerPostprocessor
76 */
77 public static void deregisterPostprocessor(OsmServerReadPostprocessor pp) {
78 if (postprocessors != null) {
79 postprocessors.remove(pp);
80 }
81 }
82
83 /**
84 * constructor (for private and subclasses use only)
85 *
86 * @see #parseDataSet(InputStream, ProgressMonitor)
87 */
88 protected OsmReader() {
89 // Restricts visibility
90 }
91
92 protected void setParser(XMLStreamReader parser) {
93 this.parser = parser;
94 }
95
96 protected void throwException(String msg, Throwable th) throws XMLStreamException {
97 throw new OsmParsingException(msg, parser.getLocation(), th);
98 }
99
100 protected void throwException(String msg) throws XMLStreamException {
101 throw new OsmParsingException(msg, parser.getLocation());
102 }
103
104 protected void parse() throws XMLStreamException {
105 int event = parser.getEventType();
106 while (true) {
107 if (event == XMLStreamConstants.START_ELEMENT) {
108 parseRoot();
109 } else if (event == XMLStreamConstants.END_ELEMENT)
110 return;
111 if (parser.hasNext()) {
112 event = parser.next();
113 } else {
114 break;
115 }
116 }
117 parser.close();
118 }
119
120 protected void parseRoot() throws XMLStreamException {
121 if ("osm".equals(parser.getLocalName())) {
122 parseOsm();
123 } else {
124 parseUnknown();
125 }
126 }
127
128 private void parseOsm() throws XMLStreamException {
129 String v = parser.getAttributeValue(null, "version");
130 if (v == null) {
131 throwException(tr("Missing mandatory attribute ''{0}''.", "version"));
132 }
133 if (!"0.6".equals(v)) {
134 throwException(tr("Unsupported version: {0}", v));
135 }
136 ds.setVersion(v);
137 String upload = parser.getAttributeValue(null, "upload");
138 if (upload != null) {
139 ds.setUploadDiscouraged(!Boolean.parseBoolean(upload));
140 }
141 String generator = parser.getAttributeValue(null, "generator");
142 Long uploadChangesetId = null;
143 if (parser.getAttributeValue(null, "upload-changeset") != null) {
144 uploadChangesetId = getLong("upload-changeset");
145 }
146 while (true) {
147 int event = parser.next();
148
149 if (cancel) {
150 cancel = false;
151 throw new OsmParsingCanceledException(tr("Reading was canceled"), parser.getLocation());
152 }
153
154 if (event == XMLStreamConstants.START_ELEMENT) {
155 switch (parser.getLocalName()) {
156 case "bounds":
157 parseBounds(generator);
158 break;
159 case "node":
160 parseNode();
161 break;
162 case "way":
163 parseWay();
164 break;
165 case "relation":
166 parseRelation();
167 break;
168 case "changeset":
169 parseChangeset(uploadChangesetId);
170 break;
171 default:
172 parseUnknown();
173 }
174 } else if (event == XMLStreamConstants.END_ELEMENT)
175 return;
176 }
177 }
178
179 private void parseBounds(String generator) throws XMLStreamException {
180 String minlon = parser.getAttributeValue(null, "minlon");
181 String minlat = parser.getAttributeValue(null, "minlat");
182 String maxlon = parser.getAttributeValue(null, "maxlon");
183 String maxlat = parser.getAttributeValue(null, "maxlat");
184 String origin = parser.getAttributeValue(null, "origin");
185 if (minlon != null && maxlon != null && minlat != null && maxlat != null) {
186 if (origin == null) {
187 origin = generator;
188 }
189 Bounds bounds = new Bounds(
190 Double.parseDouble(minlat), Double.parseDouble(minlon),
191 Double.parseDouble(maxlat), Double.parseDouble(maxlon));
192 if (bounds.isOutOfTheWorld()) {
193 Bounds copy = new Bounds(bounds);
194 bounds.normalize();
195 Main.info("Bbox " + copy + " is out of the world, normalized to " + bounds);
196 }
197 DataSource src = new DataSource(bounds, origin);
198 ds.dataSources.add(src);
199 } else {
200 throwException(tr("Missing mandatory attributes on element ''bounds''. " +
201 "Got minlon=''{0}'',minlat=''{1}'',maxlon=''{3}'',maxlat=''{4}'', origin=''{5}''.",
202 minlon, minlat, maxlon, maxlat, origin
203 ));
204 }
205 jumpToEnd();
206 }
207
208 protected Node parseNode() throws XMLStreamException {
209 NodeData nd = new NodeData();
210 String lat = parser.getAttributeValue(null, "lat");
211 String lon = parser.getAttributeValue(null, "lon");
212 if (lat != null && lon != null) {
213 nd.setCoor(new LatLon(Double.parseDouble(lat), Double.parseDouble(lon)));
214 }
215 readCommon(nd);
216 Node n = new Node(nd.getId(), nd.getVersion());
217 n.setVisible(nd.isVisible());
218 n.load(nd);
219 externalIdMap.put(nd.getPrimitiveId(), n);
220 while (true) {
221 int event = parser.next();
222 if (event == XMLStreamConstants.START_ELEMENT) {
223 if ("tag".equals(parser.getLocalName())) {
224 parseTag(n);
225 } else {
226 parseUnknown();
227 }
228 } else if (event == XMLStreamConstants.END_ELEMENT)
229 return n;
230 }
231 }
232
233 protected Way parseWay() throws XMLStreamException {
234 WayData wd = new WayData();
235 readCommon(wd);
236 Way w = new Way(wd.getId(), wd.getVersion());
237 w.setVisible(wd.isVisible());
238 w.load(wd);
239 externalIdMap.put(wd.getPrimitiveId(), w);
240
241 Collection<Long> nodeIds = new ArrayList<>();
242 while (true) {
243 int event = parser.next();
244 if (event == XMLStreamConstants.START_ELEMENT) {
245 switch (parser.getLocalName()) {
246 case "nd":
247 nodeIds.add(parseWayNode(w));
248 break;
249 case "tag":
250 parseTag(w);
251 break;
252 default:
253 parseUnknown();
254 }
255 } else if (event == XMLStreamConstants.END_ELEMENT) {
256 break;
257 }
258 }
259 if (w.isDeleted() && !nodeIds.isEmpty()) {
260 Main.info(tr("Deleted way {0} contains nodes", w.getUniqueId()));
261 nodeIds = new ArrayList<>();
262 }
263 ways.put(wd.getUniqueId(), nodeIds);
264 return w;
265 }
266
267 private long parseWayNode(Way w) throws XMLStreamException {
268 if (parser.getAttributeValue(null, "ref") == null) {
269 throwException(
270 tr("Missing mandatory attribute ''{0}'' on <nd> of way {1}.", "ref", w.getUniqueId())
271 );
272 }
273 long id = getLong("ref");
274 if (id == 0) {
275 throwException(
276 tr("Illegal value of attribute ''ref'' of element <nd>. Got {0}.", id)
277 );
278 }
279 jumpToEnd();
280 return id;
281 }
282
283 protected Relation parseRelation() throws XMLStreamException {
284 RelationData rd = new RelationData();
285 readCommon(rd);
286 Relation r = new Relation(rd.getId(), rd.getVersion());
287 r.setVisible(rd.isVisible());
288 r.load(rd);
289 externalIdMap.put(rd.getPrimitiveId(), r);
290
291 Collection<RelationMemberData> members = new ArrayList<>();
292 while (true) {
293 int event = parser.next();
294 if (event == XMLStreamConstants.START_ELEMENT) {
295 switch (parser.getLocalName()) {
296 case "member":
297 members.add(parseRelationMember(r));
298 break;
299 case "tag":
300 parseTag(r);
301 break;
302 default:
303 parseUnknown();
304 }
305 } else if (event == XMLStreamConstants.END_ELEMENT) {
306 break;
307 }
308 }
309 if (r.isDeleted() && !members.isEmpty()) {
310 Main.info(tr("Deleted relation {0} contains members", r.getUniqueId()));
311 members = new ArrayList<>();
312 }
313 relations.put(rd.getUniqueId(), members);
314 return r;
315 }
316
317 private RelationMemberData parseRelationMember(Relation r) throws XMLStreamException {
318 String role = null;
319 OsmPrimitiveType type = null;
320 long id = 0;
321 String value = parser.getAttributeValue(null, "ref");
322 if (value == null) {
323 throwException(tr("Missing attribute ''ref'' on member in relation {0}.", r.getUniqueId()));
324 }
325 try {
326 id = Long.parseLong(value);
327 } catch (NumberFormatException e) {
328 throwException(tr("Illegal value for attribute ''ref'' on member in relation {0}. Got {1}", Long.toString(r.getUniqueId()),
329 value), e);
330 }
331 value = parser.getAttributeValue(null, "type");
332 if (value == null) {
333 throwException(tr("Missing attribute ''type'' on member {0} in relation {1}.", Long.toString(id), Long.toString(r.getUniqueId())));
334 }
335 try {
336 type = OsmPrimitiveType.fromApiTypeName(value);
337 } catch (IllegalArgumentException e) {
338 throwException(tr("Illegal value for attribute ''type'' on member {0} in relation {1}. Got {2}.",
339 Long.toString(id), Long.toString(r.getUniqueId()), value), e);
340 }
341 value = parser.getAttributeValue(null, "role");
342 role = value;
343
344 if (id == 0) {
345 throwException(tr("Incomplete <member> specification with ref=0"));
346 }
347 jumpToEnd();
348 return new RelationMemberData(role, type, id);
349 }
350
351 private void parseChangeset(Long uploadChangesetId) throws XMLStreamException {
352
353 Long id = null;
354 if (parser.getAttributeValue(null, "id") != null) {
355 id = getLong("id");
356 }
357 // Read changeset info if neither upload-changeset nor id are set, or if they are both set to the same value
358 if (Objects.equals(id, uploadChangesetId)) {
359 uploadChangeset = new Changeset(id != null ? id.intValue() : 0);
360 while (true) {
361 int event = parser.next();
362 if (event == XMLStreamConstants.START_ELEMENT) {
363 if ("tag".equals(parser.getLocalName())) {
364 parseTag(uploadChangeset);
365 } else {
366 parseUnknown();
367 }
368 } else if (event == XMLStreamConstants.END_ELEMENT)
369 return;
370 }
371 } else {
372 jumpToEnd(false);
373 }
374 }
375
376 private void parseTag(Tagged t) throws XMLStreamException {
377 String key = parser.getAttributeValue(null, "k");
378 String value = parser.getAttributeValue(null, "v");
379 if (key == null || value == null) {
380 throwException(tr("Missing key or value attribute in tag."));
381 } else {
382 t.put(key.intern(), value.intern());
383 }
384 jumpToEnd();
385 }
386
387 protected void parseUnknown(boolean printWarning) throws XMLStreamException {
388 final String element = parser.getLocalName();
389 if (printWarning && ("note".equals(element) || "meta".equals(element))) {
390 // we know that Overpass API returns those elements
391 Main.debug(tr("Undefined element ''{0}'' found in input stream. Skipping.", element));
392 } else if (printWarning) {
393 Main.info(tr("Undefined element ''{0}'' found in input stream. Skipping.", element));
394 }
395 while (true) {
396 int event = parser.next();
397 if (event == XMLStreamConstants.START_ELEMENT) {
398 parseUnknown(false); /* no more warning for inner elements */
399 } else if (event == XMLStreamConstants.END_ELEMENT)
400 return;
401 }
402 }
403
404 protected void parseUnknown() throws XMLStreamException {
405 parseUnknown(true);
406 }
407
408 /**
409 * When cursor is at the start of an element, moves it to the end tag of that element.
410 * Nested content is skipped.
411 *
412 * This is basically the same code as parseUnknown(), except for the warnings, which
413 * are displayed for inner elements and not at top level.
414 * @param printWarning if {@code true}, a warning message will be printed if an unknown element is met
415 * @throws XMLStreamException if there is an error processing the underlying XML source
416 */
417 private void jumpToEnd(boolean printWarning) throws XMLStreamException {
418 while (true) {
419 int event = parser.next();
420 if (event == XMLStreamConstants.START_ELEMENT) {
421 parseUnknown(printWarning);
422 } else if (event == XMLStreamConstants.END_ELEMENT)
423 return;
424 }
425 }
426
427 private void jumpToEnd() throws XMLStreamException {
428 jumpToEnd(true);
429 }
430
431 private User createUser(String uid, String name) throws XMLStreamException {
432 if (uid == null) {
433 if (name == null)
434 return null;
435 return User.createLocalUser(name);
436 }
437 try {
438 long id = Long.parseLong(uid);
439 return User.createOsmUser(id, name);
440 } catch (NumberFormatException e) {
441 throwException(MessageFormat.format("Illegal value for attribute ''uid''. Got ''{0}''.", uid), e);
442 }
443 return null;
444 }
445
446 /**
447 * Read out the common attributes and put them into current OsmPrimitive.
448 * @param current primitive to update
449 * @throws XMLStreamException if there is an error processing the underlying XML source
450 */
451 private void readCommon(PrimitiveData current) throws XMLStreamException {
452 current.setId(getLong("id"));
453 if (current.getUniqueId() == 0) {
454 throwException(tr("Illegal object with ID=0."));
455 }
456
457 String time = parser.getAttributeValue(null, "timestamp");
458 if (time != null && !time.isEmpty()) {
459 current.setRawTimestamp((int) (DateUtils.tsFromString(time)/1000));
460 }
461
462 String user = parser.getAttributeValue(null, "user");
463 String uid = parser.getAttributeValue(null, "uid");
464 current.setUser(createUser(uid, user));
465
466 String visible = parser.getAttributeValue(null, "visible");
467 if (visible != null) {
468 current.setVisible(Boolean.parseBoolean(visible));
469 }
470
471 String versionString = parser.getAttributeValue(null, "version");
472 int version = 0;
473 if (versionString != null) {
474 try {
475 version = Integer.parseInt(versionString);
476 } catch (NumberFormatException e) {
477 throwException(tr("Illegal value for attribute ''version'' on OSM primitive with ID {0}. Got {1}.",
478 Long.toString(current.getUniqueId()), versionString), e);
479 }
480 switch (ds.getVersion()) {
481 case "0.6":
482 if (version <= 0 && !current.isNew()) {
483 throwException(tr("Illegal value for attribute ''version'' on OSM primitive with ID {0}. Got {1}.",
484 Long.toString(current.getUniqueId()), versionString));
485 } else if (version < 0 && current.isNew()) {
486 Main.warn(tr("Normalizing value of attribute ''version'' of element {0} to {2}, API version is ''{3}''. Got {1}.",
487 current.getUniqueId(), version, 0, "0.6"));
488 version = 0;
489 }
490 break;
491 default:
492 // should not happen. API version has been checked before
493 throwException(tr("Unknown or unsupported API version. Got {0}.", ds.getVersion()));
494 }
495 } else {
496 // version expected for OSM primitives with an id assigned by the server (id > 0), since API 0.6
497 if (!current.isNew() && ds.getVersion() != null && "0.6".equals(ds.getVersion())) {
498 throwException(tr("Missing attribute ''version'' on OSM primitive with ID {0}.", Long.toString(current.getUniqueId())));
499 }
500 }
501 current.setVersion(version);
502
503 String action = parser.getAttributeValue(null, "action");
504 if (action == null) {
505 // do nothing
506 } else if ("delete".equals(action)) {
507 current.setDeleted(true);
508 current.setModified(current.isVisible());
509 } else if ("modify".equals(action)) {
510 current.setModified(true);
511 }
512
513 String v = parser.getAttributeValue(null, "changeset");
514 if (v == null) {
515 current.setChangesetId(0);
516 } else {
517 try {
518 current.setChangesetId(Integer.parseInt(v));
519 } catch (IllegalArgumentException e) {
520 Main.debug(e.getMessage());
521 if (current.isNew()) {
522 // for a new primitive we just log a warning
523 Main.info(tr("Illegal value for attribute ''changeset'' on new object {1}. Got {0}. Resetting to 0.",
524 v, current.getUniqueId()));
525 current.setChangesetId(0);
526 } else {
527 // for an existing primitive this is a problem
528 throwException(tr("Illegal value for attribute ''changeset''. Got {0}.", v), e);
529 }
530 } catch (IllegalStateException e) {
531 // thrown for positive changeset id on new primitives
532 Main.info(e.getMessage());
533 current.setChangesetId(0);
534 }
535 if (current.getChangesetId() <= 0) {
536 if (current.isNew()) {
537 // for a new primitive we just log a warning
538 Main.info(tr("Illegal value for attribute ''changeset'' on new object {1}. Got {0}. Resetting to 0.",
539 v, current.getUniqueId()));
540 current.setChangesetId(0);
541 } else {
542 // for an existing primitive this is a problem
543 throwException(tr("Illegal value for attribute ''changeset''. Got {0}.", v));
544 }
545 }
546 }
547 }
548
549 private long getLong(String name) throws XMLStreamException {
550 String value = parser.getAttributeValue(null, name);
551 if (value == null) {
552 throwException(tr("Missing required attribute ''{0}''.", name));
553 }
554 try {
555 return Long.parseLong(value);
556 } catch (NumberFormatException e) {
557 throwException(tr("Illegal long value for attribute ''{0}''. Got ''{1}''.", name, value), e);
558 }
559 return 0; // should not happen
560 }
561
562 private static class OsmParsingException extends XMLStreamException {
563
564 OsmParsingException(String msg, Location location) {
565 super(msg); /* cannot use super(msg, location) because it messes with the message preventing localization */
566 this.location = location;
567 }
568
569 OsmParsingException(String msg, Location location, Throwable th) {
570 super(msg, th);
571 this.location = location;
572 }
573
574 @Override
575 public String getMessage() {
576 String msg = super.getMessage();
577 if (msg == null) {
578 msg = getClass().getName();
579 }
580 if (getLocation() == null)
581 return msg;
582 msg += ' ' + tr("(at line {0}, column {1})", getLocation().getLineNumber(), getLocation().getColumnNumber());
583 int offset = getLocation().getCharacterOffset();
584 if (offset > -1) {
585 msg += ". "+ tr("{0} bytes have been read", offset);
586 }
587 return msg;
588 }
589 }
590
591 /**
592 * Exception thrown after user cancelation.
593 */
594 private static final class OsmParsingCanceledException extends OsmParsingException implements ImportCancelException {
595 /**
596 * Constructs a new {@code OsmParsingCanceledException}.
597 * @param msg The error message
598 * @param location The parser location
599 */
600 OsmParsingCanceledException(String msg, Location location) {
601 super(msg, location);
602 }
603 }
604
605 protected DataSet doParseDataSet(InputStream source, ProgressMonitor progressMonitor) throws IllegalDataException {
606 if (progressMonitor == null) {
607 progressMonitor = NullProgressMonitor.INSTANCE;
608 }
609 ProgressMonitor.CancelListener cancelListener = new ProgressMonitor.CancelListener() {
610 @Override public void operationCanceled() {
611 cancel = true;
612 }
613 };
614 progressMonitor.addCancelListener(cancelListener);
615 CheckParameterUtil.ensureParameterNotNull(source, "source");
616 try {
617 progressMonitor.beginTask(tr("Prepare OSM data...", 2));
618 progressMonitor.indeterminateSubTask(tr("Parsing OSM data..."));
619
620 try (InputStreamReader ir = UTFInputStreamReader.create(source)) {
621 XMLStreamReader parser = XMLInputFactory.newInstance().createXMLStreamReader(ir);
622 setParser(parser);
623 parse();
624 }
625 progressMonitor.worked(1);
626
627 progressMonitor.indeterminateSubTask(tr("Preparing data set..."));
628 prepareDataSet();
629 progressMonitor.worked(1);
630
631 // iterate over registered postprocessors and give them each a chance
632 // to modify the dataset we have just loaded.
633 if (postprocessors != null) {
634 for (OsmServerReadPostprocessor pp : postprocessors) {
635 pp.postprocessDataSet(getDataSet(), progressMonitor);
636 }
637 }
638 return getDataSet();
639 } catch (IllegalDataException e) {
640 throw e;
641 } catch (OsmParsingException e) {
642 throw new IllegalDataException(e.getMessage(), e);
643 } catch (XMLStreamException e) {
644 String msg = e.getMessage();
645 Pattern p = Pattern.compile("Message: (.+)");
646 Matcher m = p.matcher(msg);
647 if (m.find()) {
648 msg = m.group(1);
649 }
650 if (e.getLocation() != null)
651 throw new IllegalDataException(tr("Line {0} column {1}: ",
652 e.getLocation().getLineNumber(), e.getLocation().getColumnNumber()) + msg, e);
653 else
654 throw new IllegalDataException(msg, e);
655 } catch (IOException e) {
656 throw new IllegalDataException(e);
657 } finally {
658 progressMonitor.finishTask();
659 progressMonitor.removeCancelListener(cancelListener);
660 }
661 }
662
663 /**
664 * Parse the given input source and return the dataset.
665 *
666 * @param source the source input stream. Must not be null.
667 * @param progressMonitor the progress monitor. If null, {@link NullProgressMonitor#INSTANCE} is assumed
668 *
669 * @return the dataset with the parsed data
670 * @throws IllegalDataException if an error was found while parsing the data from the source
671 * @throws IllegalArgumentException if source is null
672 */
673 public static DataSet parseDataSet(InputStream source, ProgressMonitor progressMonitor) throws IllegalDataException {
674 return new OsmReader().doParseDataSet(source, progressMonitor);
675 }
676}
Note: See TracBrowser for help on using the repository browser.