source: josm/trunk/src/org/openstreetmap/josm/gui/layer/markerlayer/MarkerLayer.java@ 18287

Last change on this file since 18287 was 18287, checked in by Don-vip, 3 years ago

fix #20913 - fix handling of GPX files in sessions (patch by Bjoeni)

  • revert r17659 (except for unit tests) - see #20233
    • don't save GPX track and marker colors in session file anymore, but in the GPX files as extensions (like before)
    • don't ask to save unmodified GPX file
    • don't override global color settings when individual track doesn't have a color
  • ask user to save changes to GPX file when any drawing settings or marker colors have changed (currently only happens for track colors)
  • save marker color values to session even when corresponding GPX layer has already been deleted
  • save alpha values for GPX marker colors
  • added explanation to the "overwrite GPX file" dialog
  • inform user if not all files referenced by the session file are saved yet
  • allow user to save all files that are not included in the *.jos/*.joz but are only referenced in the session file
  • display * next to GPX layers that need to be saved (move isDirty() logic from OsmDataLayer to AbstractModifiableLayer)
  • Property svn:eol-style set to native
File size: 24.0 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.gui.layer.markerlayer;
3
4import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
5import static org.openstreetmap.josm.tools.I18n.marktr;
6import static org.openstreetmap.josm.tools.I18n.tr;
7import static org.openstreetmap.josm.tools.I18n.trn;
8
9import java.awt.BasicStroke;
10import java.awt.Color;
11import java.awt.Component;
12import java.awt.Graphics2D;
13import java.awt.Point;
14import java.awt.event.ActionEvent;
15import java.awt.event.MouseAdapter;
16import java.awt.event.MouseEvent;
17import java.io.File;
18import java.net.URI;
19import java.net.URISyntaxException;
20import java.util.ArrayList;
21import java.util.Collection;
22import java.util.Comparator;
23import java.util.HashMap;
24import java.util.List;
25import java.util.Map;
26import java.util.Optional;
27
28import javax.swing.AbstractAction;
29import javax.swing.Action;
30import javax.swing.Icon;
31import javax.swing.JCheckBoxMenuItem;
32import javax.swing.JOptionPane;
33
34import org.openstreetmap.josm.actions.AutoScaleAction;
35import org.openstreetmap.josm.actions.RenameLayerAction;
36import org.openstreetmap.josm.data.Bounds;
37import org.openstreetmap.josm.data.coor.LatLon;
38import org.openstreetmap.josm.data.gpx.GpxConstants;
39import org.openstreetmap.josm.data.gpx.GpxData;
40import org.openstreetmap.josm.data.gpx.GpxExtension;
41import org.openstreetmap.josm.data.gpx.GpxLink;
42import org.openstreetmap.josm.data.gpx.IGpxLayerPrefs;
43import org.openstreetmap.josm.data.gpx.WayPoint;
44import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
45import org.openstreetmap.josm.data.preferences.IntegerProperty;
46import org.openstreetmap.josm.data.preferences.NamedColorProperty;
47import org.openstreetmap.josm.data.preferences.StrokeProperty;
48import org.openstreetmap.josm.gui.MainApplication;
49import org.openstreetmap.josm.gui.MapView;
50import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
51import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
52import org.openstreetmap.josm.gui.layer.CustomizeColor;
53import org.openstreetmap.josm.gui.layer.GpxLayer;
54import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToMarkerLayer;
55import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToNextMarker;
56import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToPreviousMarker;
57import org.openstreetmap.josm.gui.layer.Layer;
58import org.openstreetmap.josm.gui.layer.gpx.ConvertFromMarkerLayerAction;
59import org.openstreetmap.josm.gui.preferences.display.GPXSettingsPanel;
60import org.openstreetmap.josm.io.audio.AudioPlayer;
61import org.openstreetmap.josm.spi.preferences.Config;
62import org.openstreetmap.josm.tools.ColorHelper;
63import org.openstreetmap.josm.tools.ImageProvider;
64import org.openstreetmap.josm.tools.Logging;
65import org.openstreetmap.josm.tools.Utils;
66
67/**
68 * A layer holding markers.
69 *
70 * Markers are GPS points with a name and, optionally, a symbol code attached;
71 * marker layers can be created from waypoints when importing raw GPS data,
72 * but they may also come from other sources.
73 *
74 * The symbol code is for future use.
75 *
76 * The data is read only.
77 */
78public class MarkerLayer extends Layer implements JumpToMarkerLayer {
79
80 /**
81 * A list of markers.
82 */
83 public final MarkerData data;
84 private boolean mousePressed;
85 public GpxLayer fromLayer;
86 private Marker currentMarker;
87 public AudioMarker syncAudioMarker;
88 private Color color, realcolor;
89 final int markerSize = new IntegerProperty("draw.rawgps.markers.size", 4).get();
90 final BasicStroke markerStroke = new StrokeProperty("draw.rawgps.markers.stroke", "1").get();
91
92 /**
93 * The default color that is used for drawing markers.
94 */
95 public static final NamedColorProperty DEFAULT_COLOR_PROPERTY = new NamedColorProperty(marktr("gps marker"), Color.magenta);
96
97 /**
98 * Constructs a new {@code MarkerLayer}.
99 * @param indata The GPX data for this layer
100 * @param name The marker layer name
101 * @param associatedFile The associated GPX file
102 * @param fromLayer The associated GPX layer
103 */
104 public MarkerLayer(GpxData indata, String name, File associatedFile, GpxLayer fromLayer) {
105 super(name);
106 this.setAssociatedFile(associatedFile);
107 this.data = new MarkerData();
108 this.fromLayer = fromLayer;
109 double firstTime = -1.0;
110 String lastLinkedFile = "";
111
112 if (fromLayer == null || fromLayer.data == null) {
113 data.ownLayerPrefs = indata.getLayerPrefs();
114 }
115
116 String cs = GPXSettingsPanel.tryGetDataPrefLocal(data, "markers.color");
117 Color c = null;
118 if (cs != null) {
119 c = ColorHelper.html2color(cs);
120 if (c == null) {
121 Logging.warn("Could not read marker color: " + cs);
122 }
123 }
124 setPrivateColors(c);
125
126 for (WayPoint wpt : indata.waypoints) {
127 /* calculate time differences in waypoints */
128 double time = wpt.getTime();
129 boolean wptHasLink = wpt.attr.containsKey(GpxConstants.META_LINKS);
130 if (firstTime < 0 && wptHasLink) {
131 firstTime = time;
132 for (GpxLink oneLink : wpt.<GpxLink>getCollection(GpxConstants.META_LINKS)) {
133 lastLinkedFile = oneLink.uri;
134 break;
135 }
136 }
137 if (wptHasLink) {
138 for (GpxLink oneLink : wpt.<GpxLink>getCollection(GpxConstants.META_LINKS)) {
139 String uri = oneLink.uri;
140 if (uri != null) {
141 if (!uri.equals(lastLinkedFile)) {
142 firstTime = time;
143 }
144 lastLinkedFile = uri;
145 break;
146 }
147 }
148 }
149 Double offset = null;
150 // If we have an explicit offset, take it.
151 // Otherwise, for a group of markers with the same Link-URI (e.g. an
152 // audio file) calculate the offset relative to the first marker of
153 // that group. This way the user can jump to the corresponding
154 // playback positions in a long audio track.
155 GpxExtension offsetExt = wpt.getExtensions().get("josm", "offset");
156 if (offsetExt != null && offsetExt.getValue() != null) {
157 try {
158 offset = Double.valueOf(offsetExt.getValue());
159 } catch (NumberFormatException nfe) {
160 Logging.warn(nfe);
161 }
162 }
163 if (offset == null) {
164 offset = time - firstTime;
165 }
166 final Collection<Marker> markers = Marker.createMarkers(wpt, indata.storageFile, this, time, offset);
167 if (markers != null) {
168 data.addAll(markers);
169 }
170 }
171 }
172
173 @Override
174 public synchronized void destroy() {
175 if (data.contains(AudioMarker.recentlyPlayedMarker())) {
176 AudioMarker.resetRecentlyPlayedMarker();
177 }
178 syncAudioMarker = null;
179 currentMarker = null;
180 fromLayer = null;
181 data.forEach(Marker::destroy);
182 data.clear();
183 super.destroy();
184 }
185
186 @Override
187 public LayerPainter attachToMapView(MapViewEvent event) {
188 event.getMapView().addMouseListener(new MarkerMouseAdapter());
189
190 if (event.getMapView().playHeadMarker == null) {
191 event.getMapView().playHeadMarker = PlayHeadMarker.create();
192 }
193
194 return super.attachToMapView(event);
195 }
196
197 /**
198 * Return a static icon.
199 */
200 @Override
201 public Icon getIcon() {
202 return ImageProvider.get("layer", "marker_small");
203 }
204
205 @Override
206 public void paint(Graphics2D g, MapView mv, Bounds box) {
207 boolean showTextOrIcon = isTextOrIconShown();
208 g.setColor(realcolor);
209 if (mousePressed) {
210 boolean mousePressedTmp = mousePressed;
211 Point mousePos = mv.getMousePosition(); // Get mouse position only when necessary (it's the slowest part of marker layer painting)
212 for (Marker mkr : data) {
213 if (mousePos != null && mkr.containsPoint(mousePos)) {
214 mkr.paint(g, mv, mousePressedTmp, showTextOrIcon);
215 mousePressedTmp = false;
216 }
217 }
218 } else {
219 for (Marker mkr : data) {
220 mkr.paint(g, mv, false, showTextOrIcon);
221 }
222 }
223 }
224
225 @Override
226 public String getToolTipText() {
227 return Integer.toString(data.size())+' '+trn("marker", "markers", data.size());
228 }
229
230 @Override
231 public void mergeFrom(Layer from) {
232 if (from instanceof MarkerLayer) {
233 data.addAll(((MarkerLayer) from).data);
234 data.sort(Comparator.comparingDouble(o -> o.time));
235 }
236 }
237
238 @Override public boolean isMergable(Layer other) {
239 return other instanceof MarkerLayer;
240 }
241
242 @Override public void visitBoundingBox(BoundingXYVisitor v) {
243 for (Marker mkr : data) {
244 v.visit(mkr);
245 }
246 }
247
248 @Override public Object getInfoComponent() {
249 return "<html>"+trn("{0} consists of {1} marker", "{0} consists of {1} markers",
250 data.size(), Utils.escapeReservedCharactersHTML(getName()), data.size()) + "</html>";
251 }
252
253 @Override public Action[] getMenuEntries() {
254 Collection<Action> components = new ArrayList<>();
255 components.add(LayerListDialog.getInstance().createShowHideLayerAction());
256 components.add(new ShowHideMarkerText(this));
257 components.add(LayerListDialog.getInstance().createDeleteLayerAction());
258 components.add(MainApplication.getMenu().autoScaleActions.get(AutoScaleAction.AutoScaleMode.LAYER));
259 components.add(LayerListDialog.getInstance().createMergeLayerAction(this));
260 components.add(SeparatorLayerAction.INSTANCE);
261 components.add(new CustomizeColor(this));
262 components.add(SeparatorLayerAction.INSTANCE);
263 components.add(new SynchronizeAudio());
264 if (Config.getPref().getBoolean("marker.traceaudio", true)) {
265 components.add(new MoveAudio());
266 }
267 components.add(new JumpToNextMarker(this));
268 components.add(new JumpToPreviousMarker(this));
269 components.add(new ConvertFromMarkerLayerAction(this));
270 components.add(new RenameLayerAction(getAssociatedFile(), this));
271 components.add(SeparatorLayerAction.INSTANCE);
272 components.add(new LayerListPopup.InfoAction(this));
273 return components.toArray(new Action[0]);
274 }
275
276 public boolean synchronizeAudioMarkers(final AudioMarker startMarker) {
277 syncAudioMarker = startMarker;
278 if (syncAudioMarker != null && !data.contains(syncAudioMarker)) {
279 syncAudioMarker = null;
280 }
281 if (syncAudioMarker == null) {
282 // find the first audioMarker in this layer
283 syncAudioMarker = Utils.filteredCollection(data, AudioMarker.class).stream()
284 .findFirst().orElse(syncAudioMarker);
285 }
286 if (syncAudioMarker == null)
287 return false;
288
289 // apply adjustment to all subsequent audio markers in the layer
290 double adjustment = AudioPlayer.position() - syncAudioMarker.offset; // in seconds
291 boolean seenStart = false;
292 try {
293 URI uri = syncAudioMarker.url().toURI();
294 for (Marker m : data) {
295 if (m == syncAudioMarker) {
296 seenStart = true;
297 }
298 if (seenStart && m instanceof AudioMarker) {
299 AudioMarker ma = (AudioMarker) m;
300 // Do not ever call URL.equals but use URI.equals instead to avoid Internet connection
301 // See http://michaelscharf.blogspot.fr/2006/11/javaneturlequals-and-hashcode-make.html for details
302 if (ma.url().toURI().equals(uri)) {
303 ma.adjustOffset(adjustment);
304 }
305 }
306 }
307 } catch (URISyntaxException e) {
308 Logging.warn(e);
309 }
310 return true;
311 }
312
313 public AudioMarker addAudioMarker(double time, LatLon coor) {
314 // find first audio marker to get absolute start time
315 double offset = 0.0;
316 AudioMarker am = null;
317 for (Marker m : data) {
318 if (m.getClass() == AudioMarker.class) {
319 am = (AudioMarker) m;
320 offset = time - am.time;
321 break;
322 }
323 }
324 if (am == null) {
325 JOptionPane.showMessageDialog(
326 MainApplication.getMainFrame(),
327 tr("No existing audio markers in this layer to offset from."),
328 tr("Error"),
329 JOptionPane.ERROR_MESSAGE
330 );
331 return null;
332 }
333
334 // make our new marker
335 AudioMarker newAudioMarker = new AudioMarker(coor,
336 null, AudioPlayer.url(), this, time, offset);
337
338 // insert it at the right place in a copy the collection
339 Collection<Marker> newData = new ArrayList<>();
340 am = null;
341 AudioMarker ret = newAudioMarker; // save to have return value
342 for (Marker m : data) {
343 if (m.getClass() == AudioMarker.class) {
344 am = (AudioMarker) m;
345 if (newAudioMarker != null && offset < am.offset) {
346 newAudioMarker.adjustOffset(am.syncOffset()); // i.e. same as predecessor
347 newData.add(newAudioMarker);
348 newAudioMarker = null;
349 }
350 }
351 newData.add(m);
352 }
353
354 if (newAudioMarker != null) {
355 if (am != null) {
356 newAudioMarker.adjustOffset(am.syncOffset()); // i.e. same as predecessor
357 }
358 newData.add(newAudioMarker); // insert at end
359 }
360
361 // replace the collection
362 data.clear();
363 data.addAll(newData);
364 return ret;
365 }
366
367 @Override
368 public void jumpToNextMarker() {
369 if (currentMarker == null) {
370 currentMarker = data.get(0);
371 } else {
372 boolean foundCurrent = false;
373 for (Marker m: data) {
374 if (foundCurrent) {
375 currentMarker = m;
376 break;
377 } else if (currentMarker == m) {
378 foundCurrent = true;
379 }
380 }
381 }
382 MainApplication.getMap().mapView.zoomTo(currentMarker);
383 }
384
385 @Override
386 public void jumpToPreviousMarker() {
387 if (currentMarker == null) {
388 currentMarker = data.get(data.size() - 1);
389 } else {
390 boolean foundCurrent = false;
391 for (int i = data.size() - 1; i >= 0; i--) {
392 Marker m = data.get(i);
393 if (foundCurrent) {
394 currentMarker = m;
395 break;
396 } else if (currentMarker == m) {
397 foundCurrent = true;
398 }
399 }
400 }
401 MainApplication.getMap().mapView.zoomTo(currentMarker);
402 }
403
404 public static void playAudio() {
405 playAdjacentMarker(null, true);
406 }
407
408 public static void playNextMarker() {
409 playAdjacentMarker(AudioMarker.recentlyPlayedMarker(), true);
410 }
411
412 public static void playPreviousMarker() {
413 playAdjacentMarker(AudioMarker.recentlyPlayedMarker(), false);
414 }
415
416 private static Marker getAdjacentMarker(Marker startMarker, boolean next, Layer layer) {
417 Marker previousMarker = null;
418 boolean nextTime = false;
419 if (layer.getClass() == MarkerLayer.class) {
420 MarkerLayer markerLayer = (MarkerLayer) layer;
421 for (Marker marker : markerLayer.data) {
422 if (marker == startMarker) {
423 if (next) {
424 nextTime = true;
425 } else {
426 if (previousMarker == null) {
427 previousMarker = startMarker; // if no previous one, play the first one again
428 }
429 return previousMarker;
430 }
431 } else if (marker.getClass() == AudioMarker.class) {
432 if (nextTime || startMarker == null)
433 return marker;
434 previousMarker = marker;
435 }
436 }
437 if (nextTime) // there was no next marker in that layer, so play the last one again
438 return startMarker;
439 }
440 return null;
441 }
442
443 private static void playAdjacentMarker(Marker startMarker, boolean next) {
444 if (!MainApplication.isDisplayingMapView())
445 return;
446 Marker m = null;
447 Layer l = MainApplication.getLayerManager().getActiveLayer();
448 if (l != null) {
449 m = getAdjacentMarker(startMarker, next, l);
450 }
451 if (m == null) {
452 for (Layer layer : MainApplication.getLayerManager().getLayers()) {
453 m = getAdjacentMarker(startMarker, next, layer);
454 if (m != null) {
455 break;
456 }
457 }
458 }
459 if (m != null) {
460 ((AudioMarker) m).play();
461 }
462 }
463
464 /**
465 * Get state of text display.
466 * @return <code>true</code> if text should be shown, <code>false</code> otherwise.
467 */
468 private boolean isTextOrIconShown() {
469 return Boolean.parseBoolean(GPXSettingsPanel.getDataPref(data, "markers.show-text"));
470 }
471
472 @Override
473 public boolean hasColor() {
474 return true;
475 }
476
477 @Override
478 public Color getColor() {
479 return color;
480 }
481
482 @Override
483 public void setColor(Color color) {
484 setPrivateColors(color);
485 String cs = null;
486 if (color != null) {
487 cs = ColorHelper.color2html(color);
488 }
489 GPXSettingsPanel.putDataPrefLocal(data, "markers.color", cs);
490 invalidate();
491 }
492
493 private void setPrivateColors(Color color) {
494 this.color = color;
495 this.realcolor = Optional.ofNullable(color).orElse(DEFAULT_COLOR_PROPERTY.get());
496 }
497
498 private final class MarkerMouseAdapter extends MouseAdapter {
499 @Override
500 public void mousePressed(MouseEvent e) {
501 if (e.getButton() != MouseEvent.BUTTON1)
502 return;
503 boolean mousePressedInButton = data.stream().anyMatch(mkr -> mkr.containsPoint(e.getPoint()));
504 if (!mousePressedInButton)
505 return;
506 mousePressed = true;
507 if (isVisible()) {
508 invalidate();
509 }
510 }
511
512 @Override
513 public void mouseReleased(MouseEvent ev) {
514 if (ev.getButton() != MouseEvent.BUTTON1 || !mousePressed)
515 return;
516 mousePressed = false;
517 if (!isVisible())
518 return;
519 for (Marker mkr : data) {
520 if (mkr.containsPoint(ev.getPoint())) {
521 mkr.actionPerformed(new ActionEvent(this, 0, null));
522 }
523 }
524 invalidate();
525 }
526 }
527
528 public static final class ShowHideMarkerText extends AbstractAction implements LayerAction {
529 private final transient MarkerLayer layer;
530
531 public ShowHideMarkerText(MarkerLayer layer) {
532 super(tr("Show Text/Icons"));
533 new ImageProvider("dialogs", "showhide").getResource().attachImageIcon(this, true);
534 putValue(SHORT_DESCRIPTION, tr("Toggle visible state of the marker text and icons."));
535 putValue("help", ht("/Action/ShowHideTextIcons"));
536 this.layer = layer;
537 }
538
539 @Override
540 public void actionPerformed(ActionEvent e) {
541 GPXSettingsPanel.putDataPrefLocal(layer.data, "markers.show-text", Boolean.toString(!layer.isTextOrIconShown()));
542 layer.invalidate();
543 }
544
545 @Override
546 public Component createMenuComponent() {
547 JCheckBoxMenuItem showMarkerTextItem = new JCheckBoxMenuItem(this);
548 showMarkerTextItem.setState(layer.isTextOrIconShown());
549 return showMarkerTextItem;
550 }
551
552 @Override
553 public boolean supportLayers(List<Layer> layers) {
554 return layers.size() == 1 && layers.get(0) instanceof MarkerLayer;
555 }
556 }
557
558 private class SynchronizeAudio extends AbstractAction {
559
560 /**
561 * Constructs a new {@code SynchronizeAudio} action.
562 */
563 SynchronizeAudio() {
564 super(tr("Synchronize Audio"));
565 new ImageProvider("audio-sync").getResource().attachImageIcon(this, true);
566 putValue("help", ht("/Action/SynchronizeAudio"));
567 }
568
569 @Override
570 public void actionPerformed(ActionEvent e) {
571 if (!AudioPlayer.paused()) {
572 JOptionPane.showMessageDialog(
573 MainApplication.getMainFrame(),
574 tr("You need to pause audio at the moment when you hear your synchronization cue."),
575 tr("Warning"),
576 JOptionPane.WARNING_MESSAGE
577 );
578 return;
579 }
580 AudioMarker recent = AudioMarker.recentlyPlayedMarker();
581 if (synchronizeAudioMarkers(recent)) {
582 JOptionPane.showMessageDialog(
583 MainApplication.getMainFrame(),
584 tr("Audio synchronized at point {0}.", syncAudioMarker.getText()),
585 tr("Information"),
586 JOptionPane.INFORMATION_MESSAGE
587 );
588 } else {
589 JOptionPane.showMessageDialog(
590 MainApplication.getMainFrame(),
591 tr("Unable to synchronize in layer being played."),
592 tr("Error"),
593 JOptionPane.ERROR_MESSAGE
594 );
595 }
596 }
597 }
598
599 private class MoveAudio extends AbstractAction {
600
601 MoveAudio() {
602 super(tr("Make Audio Marker at Play Head"));
603 new ImageProvider("addmarkers").getResource().attachImageIcon(this, true);
604 putValue("help", ht("/Action/MakeAudioMarkerAtPlayHead"));
605 }
606
607 @Override
608 public void actionPerformed(ActionEvent e) {
609 if (!AudioPlayer.paused()) {
610 JOptionPane.showMessageDialog(
611 MainApplication.getMainFrame(),
612 tr("You need to have paused audio at the point on the track where you want the marker."),
613 tr("Warning"),
614 JOptionPane.WARNING_MESSAGE
615 );
616 return;
617 }
618 PlayHeadMarker playHeadMarker = MainApplication.getMap().mapView.playHeadMarker;
619 if (playHeadMarker == null)
620 return;
621 addAudioMarker(playHeadMarker.time, playHeadMarker.getCoor());
622 invalidate();
623 }
624 }
625
626 /**
627 * the data of a MarkerLayer
628 * @since 18287
629 */
630 public class MarkerData extends ArrayList<Marker> implements IGpxLayerPrefs {
631
632 private Map<String, String> ownLayerPrefs;
633
634 @Override
635 public Map<String, String> getLayerPrefs() {
636 if (ownLayerPrefs == null && fromLayer != null && fromLayer.data != null) {
637 return fromLayer.data.getLayerPrefs();
638 }
639 // fallback to own layerPrefs if the corresponding gpxLayer has already been deleted
640 // by the user or never existed when loaded from a session file
641 if (ownLayerPrefs == null) {
642 ownLayerPrefs = new HashMap<>();
643 }
644 return ownLayerPrefs;
645 }
646
647 /**
648 * Transfers the layerPrefs from the GpxData to MarkerData (when GpxData is deleted)
649 * @param gpxLayerPrefs the layerPrefs from the GpxData object
650 */
651 public void transferLayerPrefs(Map<String, String> gpxLayerPrefs) {
652 ownLayerPrefs = new HashMap<>(gpxLayerPrefs);
653 }
654
655 @Override
656 public void setModified(boolean value) {
657 if (fromLayer != null && fromLayer.data != null) {
658 fromLayer.data.setModified(value);
659 }
660 }
661 }
662}
Note: See TracBrowser for help on using the repository browser.