source: josm/trunk/src/org/openstreetmap/josm/gui/dialogs/changeset/ChangesetCacheManager.java@ 8126

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

fix Sonar issue squid:S2444 - Lazy initialization of "static" fields should be "synchronized"

  • Property svn:eol-style set to native
File size: 23.6 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.gui.dialogs.changeset;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5
6import java.awt.BorderLayout;
7import java.awt.Container;
8import java.awt.Dimension;
9import java.awt.FlowLayout;
10import java.awt.event.ActionEvent;
11import java.awt.event.KeyEvent;
12import java.awt.event.MouseEvent;
13import java.awt.event.WindowAdapter;
14import java.awt.event.WindowEvent;
15import java.util.Collection;
16import java.util.HashSet;
17import java.util.List;
18import java.util.Set;
19
20import javax.swing.AbstractAction;
21import javax.swing.DefaultListSelectionModel;
22import javax.swing.ImageIcon;
23import javax.swing.JComponent;
24import javax.swing.JFrame;
25import javax.swing.JOptionPane;
26import javax.swing.JPanel;
27import javax.swing.JPopupMenu;
28import javax.swing.JScrollPane;
29import javax.swing.JSplitPane;
30import javax.swing.JTabbedPane;
31import javax.swing.JTable;
32import javax.swing.JToolBar;
33import javax.swing.KeyStroke;
34import javax.swing.ListSelectionModel;
35import javax.swing.event.ListSelectionEvent;
36import javax.swing.event.ListSelectionListener;
37
38import org.openstreetmap.josm.Main;
39import org.openstreetmap.josm.data.osm.Changeset;
40import org.openstreetmap.josm.data.osm.ChangesetCache;
41import org.openstreetmap.josm.gui.HelpAwareOptionPane;
42import org.openstreetmap.josm.gui.JosmUserIdentityManager;
43import org.openstreetmap.josm.gui.SideButton;
44import org.openstreetmap.josm.gui.dialogs.changeset.query.ChangesetQueryDialog;
45import org.openstreetmap.josm.gui.dialogs.changeset.query.ChangesetQueryTask;
46import org.openstreetmap.josm.gui.help.ContextSensitiveHelpAction;
47import org.openstreetmap.josm.gui.help.HelpUtil;
48import org.openstreetmap.josm.gui.io.CloseChangesetTask;
49import org.openstreetmap.josm.gui.widgets.PopupMenuLauncher;
50import org.openstreetmap.josm.io.ChangesetQuery;
51import org.openstreetmap.josm.io.OnlineResource;
52import org.openstreetmap.josm.tools.ImageProvider;
53import org.openstreetmap.josm.tools.WindowGeometry;
54
55/**
56 * ChangesetCacheManager manages the local cache of changesets
57 * retrieved from the OSM API. It displays both a table of the locally cached changesets
58 * and detail information about an individual changeset. It also provides actions for
59 * downloading, querying, closing changesets, in addition to removing changesets from
60 * the local cache.
61 *
62 */
63public class ChangesetCacheManager extends JFrame {
64
65 /** The changeset download icon **/
66 public static final ImageIcon DOWNLOAD_CONTENT_ICON = ImageProvider.get("dialogs/changeset", "downloadchangesetcontent");
67 /** The changeset update icon **/
68 public static final ImageIcon UPDATE_CONTENT_ICON = ImageProvider.get("dialogs/changeset", "updatechangesetcontent");
69
70 /** the unique instance of the cache manager */
71 private static volatile ChangesetCacheManager instance;
72
73 /**
74 * Replies the unique instance of the changeset cache manager
75 *
76 * @return the unique instance of the changeset cache manager
77 */
78 public static ChangesetCacheManager getInstance() {
79 if (instance == null) {
80 instance = new ChangesetCacheManager();
81 }
82 return instance;
83 }
84
85 /**
86 * Hides and destroys the unique instance of the changeset cache
87 * manager.
88 *
89 */
90 public static void destroyInstance() {
91 if (instance != null) {
92 instance.setVisible(true);
93 instance.dispose();
94 instance = null;
95 }
96 }
97
98 private ChangesetCacheManagerModel model;
99 private JSplitPane spContent;
100 private boolean needsSplitPaneAdjustment;
101
102 private RemoveFromCacheAction actRemoveFromCacheAction;
103 private CloseSelectedChangesetsAction actCloseSelectedChangesetsAction;
104 private DownloadSelectedChangesetsAction actDownloadSelectedChangesets;
105 private DownloadSelectedChangesetContentAction actDownloadSelectedContent;
106 private JTable tblChangesets;
107
108 /**
109 * Creates the various models required
110 */
111 protected void buildModel() {
112 DefaultListSelectionModel selectionModel = new DefaultListSelectionModel();
113 selectionModel.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
114 model = new ChangesetCacheManagerModel(selectionModel);
115
116 actRemoveFromCacheAction = new RemoveFromCacheAction();
117 actCloseSelectedChangesetsAction = new CloseSelectedChangesetsAction();
118 actDownloadSelectedChangesets = new DownloadSelectedChangesetsAction();
119 actDownloadSelectedContent = new DownloadSelectedChangesetContentAction();
120 }
121
122 /**
123 * builds the toolbar panel in the heading of the dialog
124 *
125 * @return the toolbar panel
126 */
127 protected JPanel buildToolbarPanel() {
128 JPanel pnl = new JPanel(new FlowLayout(FlowLayout.LEFT));
129
130 SideButton btn = new SideButton(new QueryAction());
131 pnl.add(btn);
132 pnl.add(new SingleChangesetDownloadPanel());
133 pnl.add(new SideButton(new DownloadMyChangesets()));
134
135 return pnl;
136 }
137
138 /**
139 * builds the button panel in the footer of the dialog
140 *
141 * @return the button row pane
142 */
143 protected JPanel buildButtonPanel() {
144 JPanel pnl = new JPanel(new FlowLayout(FlowLayout.CENTER));
145
146 //-- cancel and close action
147 pnl.add(new SideButton(new CancelAction()));
148
149 //-- help action
150 pnl.add(new SideButton(
151 new ContextSensitiveHelpAction(
152 HelpUtil.ht("/Dialog/ChangesetManager"))
153 )
154 );
155
156 return pnl;
157 }
158
159 /**
160 * Builds the panel with the changeset details
161 *
162 * @return the panel with the changeset details
163 */
164 protected JPanel buildChangesetDetailPanel() {
165 JPanel pnl = new JPanel(new BorderLayout());
166 JTabbedPane tp = new JTabbedPane();
167
168 // -- add the details panel
169 ChangesetDetailPanel pnlChangesetDetail = new ChangesetDetailPanel();
170 tp.add(pnlChangesetDetail);
171 model.addPropertyChangeListener(pnlChangesetDetail);
172
173 // -- add the tags panel
174 ChangesetTagsPanel pnlChangesetTags = new ChangesetTagsPanel();
175 tp.add(pnlChangesetTags);
176 model.addPropertyChangeListener(pnlChangesetTags);
177
178 // -- add the panel for the changeset content
179 ChangesetContentPanel pnlChangesetContent = new ChangesetContentPanel();
180 tp.add(pnlChangesetContent);
181 model.addPropertyChangeListener(pnlChangesetContent);
182
183 // -- add the panel for the changeset discussion
184 ChangesetDiscussionPanel pnlChangesetDiscussion = new ChangesetDiscussionPanel();
185 tp.add(pnlChangesetDiscussion);
186 model.addPropertyChangeListener(pnlChangesetDiscussion);
187
188 tp.setTitleAt(0, tr("Properties"));
189 tp.setToolTipTextAt(0, tr("Display the basic properties of the changeset"));
190 tp.setTitleAt(1, tr("Tags"));
191 tp.setToolTipTextAt(1, tr("Display the tags of the changeset"));
192 tp.setTitleAt(2, tr("Content"));
193 tp.setToolTipTextAt(2, tr("Display the objects created, updated, and deleted by the changeset"));
194 tp.setTitleAt(3, tr("Discussion"));
195 tp.setToolTipTextAt(3, tr("Display the public discussion around this changeset"));
196
197 pnl.add(tp, BorderLayout.CENTER);
198 return pnl;
199 }
200
201 /**
202 * builds the content panel of the dialog
203 *
204 * @return the content panel
205 */
206 protected JPanel buildContentPanel() {
207 JPanel pnl = new JPanel(new BorderLayout());
208
209 spContent = new JSplitPane(JSplitPane.VERTICAL_SPLIT);
210 spContent.setLeftComponent(buildChangesetTablePanel());
211 spContent.setRightComponent(buildChangesetDetailPanel());
212 spContent.setOneTouchExpandable(true);
213 spContent.setDividerLocation(0.5);
214
215 pnl.add(spContent, BorderLayout.CENTER);
216 return pnl;
217 }
218
219 /**
220 * Builds the table with actions which can be applied to the currently visible changesets
221 * in the changeset table.
222 *
223 * @return changset actions panel
224 */
225 protected JPanel buildChangesetTableActionPanel() {
226 JPanel pnl = new JPanel(new BorderLayout());
227
228 JToolBar tb = new JToolBar(JToolBar.VERTICAL);
229 tb.setFloatable(false);
230
231 // -- remove from cache action
232 model.getSelectionModel().addListSelectionListener(actRemoveFromCacheAction);
233 tb.add(actRemoveFromCacheAction);
234
235 // -- close selected changesets action
236 model.getSelectionModel().addListSelectionListener(actCloseSelectedChangesetsAction);
237 tb.add(actCloseSelectedChangesetsAction);
238
239 // -- download selected changesets
240 model.getSelectionModel().addListSelectionListener(actDownloadSelectedChangesets);
241 tb.add(actDownloadSelectedChangesets);
242
243 // -- download the content of the selected changesets
244 model.getSelectionModel().addListSelectionListener(actDownloadSelectedContent);
245 tb.add(actDownloadSelectedContent);
246
247 pnl.add(tb, BorderLayout.CENTER);
248 return pnl;
249 }
250
251 /**
252 * Builds the panel with the table of changesets
253 *
254 * @return the panel with the table of changesets
255 */
256 protected JPanel buildChangesetTablePanel() {
257 JPanel pnl = new JPanel(new BorderLayout());
258 tblChangesets = new JTable(
259 model,
260 new ChangesetCacheTableColumnModel(),
261 model.getSelectionModel()
262 );
263 tblChangesets.addMouseListener(new MouseEventHandler());
264 tblChangesets.getInputMap(JComponent.WHEN_FOCUSED).put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER,0), "showDetails");
265 tblChangesets.getActionMap().put("showDetails", new ShowDetailAction());
266 model.getSelectionModel().addListSelectionListener(new ChangesetDetailViewSynchronizer());
267
268 // activate DEL on the table
269 tblChangesets.getInputMap(JComponent.WHEN_FOCUSED).put(KeyStroke.getKeyStroke(KeyEvent.VK_DELETE,0), "removeFromCache");
270 tblChangesets.getActionMap().put("removeFromCache", actRemoveFromCacheAction);
271
272 pnl.add(new JScrollPane(tblChangesets), BorderLayout.CENTER);
273 pnl.add(buildChangesetTableActionPanel(), BorderLayout.WEST);
274 return pnl;
275 }
276
277 protected void build() {
278 setTitle(tr("Changeset Management Dialog"));
279 setIconImage(ImageProvider.get("dialogs/changeset", "changesetmanager").getImage());
280 Container cp = getContentPane();
281
282 cp.setLayout(new BorderLayout());
283
284 buildModel();
285 cp.add(buildToolbarPanel(), BorderLayout.NORTH);
286 cp.add(buildContentPanel(), BorderLayout.CENTER);
287 cp.add(buildButtonPanel(), BorderLayout.SOUTH);
288
289 // the help context
290 HelpUtil.setHelpContext(getRootPane(), HelpUtil.ht("/Dialog/ChangesetManager"));
291
292 // make the dialog respond to ESC
293 getRootPane().getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE,0), "cancelAndClose");
294 getRootPane().getActionMap().put("cancelAndClose", new CancelAction());
295
296 // install a window event handler
297 addWindowListener(new WindowEventHandler());
298 }
299
300 /**
301 * Constructs a new {@code ChangesetCacheManager}.
302 */
303 public ChangesetCacheManager() {
304 build();
305 }
306
307 @Override
308 public void setVisible(boolean visible) {
309 if (visible) {
310 new WindowGeometry(
311 getClass().getName() + ".geometry",
312 WindowGeometry.centerInWindow(
313 getParent(),
314 new Dimension(1000,600)
315 )
316 ).applySafe(this);
317 needsSplitPaneAdjustment = true;
318 model.init();
319
320 } else if (isShowing()) { // Avoid IllegalComponentStateException like in #8775
321 model.tearDown();
322 new WindowGeometry(this).remember(getClass().getName() + ".geometry");
323 }
324 super.setVisible(visible);
325 }
326
327 /**
328 * Handler for window events
329 *
330 */
331 class WindowEventHandler extends WindowAdapter {
332 @Override
333 public void windowClosing(WindowEvent e) {
334 new CancelAction().cancelAndClose();
335 }
336
337 @Override
338 public void windowActivated(WindowEvent arg0) {
339 if (needsSplitPaneAdjustment) {
340 spContent.setDividerLocation(0.5);
341 needsSplitPaneAdjustment = false;
342 }
343 }
344 }
345
346 /**
347 * the cancel / close action
348 */
349 static class CancelAction extends AbstractAction {
350 public CancelAction() {
351 putValue(NAME, tr("Close"));
352 putValue(SMALL_ICON, ImageProvider.get("cancel"));
353 putValue(SHORT_DESCRIPTION, tr("Close the dialog"));
354 }
355
356 public void cancelAndClose() {
357 destroyInstance();
358 }
359
360 @Override
361 public void actionPerformed(ActionEvent arg0) {
362 cancelAndClose();
363 }
364 }
365
366 /**
367 * The action to query and download changesets
368 */
369 class QueryAction extends AbstractAction {
370 public QueryAction() {
371 putValue(NAME, tr("Query"));
372 putValue(SMALL_ICON, ImageProvider.get("dialogs","search"));
373 putValue(SHORT_DESCRIPTION, tr("Launch the dialog for querying changesets"));
374 setEnabled(!Main.isOffline(OnlineResource.OSM_API));
375 }
376
377 @Override
378 public void actionPerformed(ActionEvent evt) {
379 ChangesetQueryDialog dialog = new ChangesetQueryDialog(ChangesetCacheManager.this);
380 dialog.initForUserInput();
381 dialog.setVisible(true);
382 if (dialog.isCanceled())
383 return;
384
385 try {
386 ChangesetQuery query = dialog.getChangesetQuery();
387 if (query == null) return;
388 ChangesetQueryTask task = new ChangesetQueryTask(ChangesetCacheManager.this, query);
389 ChangesetCacheManager.getInstance().runDownloadTask(task);
390 } catch (IllegalStateException e) {
391 JOptionPane.showMessageDialog(ChangesetCacheManager.this, e.getMessage(), tr("Error"), JOptionPane.ERROR_MESSAGE);
392 }
393 }
394 }
395
396 /**
397 * Removes the selected changesets from the local changeset cache
398 *
399 */
400 class RemoveFromCacheAction extends AbstractAction implements ListSelectionListener{
401 public RemoveFromCacheAction() {
402 putValue(NAME, tr("Remove from cache"));
403 putValue(SMALL_ICON, ImageProvider.get("dialogs", "delete"));
404 putValue(SHORT_DESCRIPTION, tr("Remove the selected changesets from the local cache"));
405 updateEnabledState();
406 }
407
408 @Override
409 public void actionPerformed(ActionEvent arg0) {
410 List<Changeset> selected = model.getSelectedChangesets();
411 ChangesetCache.getInstance().remove(selected);
412 }
413
414 protected void updateEnabledState() {
415 setEnabled(model.hasSelectedChangesets());
416 }
417
418 @Override
419 public void valueChanged(ListSelectionEvent e) {
420 updateEnabledState();
421
422 }
423 }
424
425 /**
426 * Closes the selected changesets
427 *
428 */
429 class CloseSelectedChangesetsAction extends AbstractAction implements ListSelectionListener{
430 public CloseSelectedChangesetsAction() {
431 putValue(NAME, tr("Close"));
432 putValue(SMALL_ICON, ImageProvider.get("closechangeset"));
433 putValue(SHORT_DESCRIPTION, tr("Close the selected changesets"));
434 updateEnabledState();
435 }
436
437 @Override
438 public void actionPerformed(ActionEvent arg0) {
439 List<Changeset> selected = model.getSelectedChangesets();
440 Main.worker.submit(new CloseChangesetTask(selected));
441 }
442
443 protected void updateEnabledState() {
444 List<Changeset> selected = model.getSelectedChangesets();
445 JosmUserIdentityManager im = JosmUserIdentityManager.getInstance();
446 for (Changeset cs: selected) {
447 if (cs.isOpen()) {
448 if (im.isPartiallyIdentified() && cs.getUser() != null && cs.getUser().getName().equals(im.getUserName())) {
449 setEnabled(true);
450 return;
451 }
452 if (im.isFullyIdentified() && cs.getUser() != null && cs.getUser().getId() == im.getUserId()) {
453 setEnabled(true);
454 return;
455 }
456 }
457 }
458 setEnabled(false);
459 }
460
461 @Override
462 public void valueChanged(ListSelectionEvent e) {
463 updateEnabledState();
464 }
465 }
466
467 /**
468 * Downloads the selected changesets
469 *
470 */
471 class DownloadSelectedChangesetsAction extends AbstractAction implements ListSelectionListener{
472 public DownloadSelectedChangesetsAction() {
473 putValue(NAME, tr("Update changeset"));
474 putValue(SMALL_ICON, ImageProvider.get("dialogs/changeset", "updatechangeset"));
475 putValue(SHORT_DESCRIPTION, tr("Updates the selected changesets with current data from the OSM server"));
476 updateEnabledState();
477 }
478
479 @Override
480 public void actionPerformed(ActionEvent arg0) {
481 List<Changeset> selected = model.getSelectedChangesets();
482 ChangesetHeaderDownloadTask task =ChangesetHeaderDownloadTask.buildTaskForChangesets(ChangesetCacheManager.this,selected);
483 ChangesetCacheManager.getInstance().runDownloadTask(task);
484 }
485
486 protected void updateEnabledState() {
487 setEnabled(model.hasSelectedChangesets() && !Main.isOffline(OnlineResource.OSM_API));
488 }
489
490 @Override
491 public void valueChanged(ListSelectionEvent e) {
492 updateEnabledState();
493 }
494 }
495
496 /**
497 * Downloads the content of selected changesets from the OSM server
498 *
499 */
500 class DownloadSelectedChangesetContentAction extends AbstractAction implements ListSelectionListener{
501 public DownloadSelectedChangesetContentAction() {
502 putValue(NAME, tr("Download changeset content"));
503 putValue(SMALL_ICON, DOWNLOAD_CONTENT_ICON);
504 putValue(SHORT_DESCRIPTION, tr("Download the content of the selected changesets from the server"));
505 updateEnabledState();
506 }
507
508 @Override
509 public void actionPerformed(ActionEvent arg0) {
510 ChangesetContentDownloadTask task = new ChangesetContentDownloadTask(ChangesetCacheManager.this,model.getSelectedChangesetIds());
511 ChangesetCacheManager.getInstance().runDownloadTask(task);
512 }
513
514 protected void updateEnabledState() {
515 setEnabled(model.hasSelectedChangesets() && !Main.isOffline(OnlineResource.OSM_API));
516 }
517
518 @Override
519 public void valueChanged(ListSelectionEvent e) {
520 updateEnabledState();
521 }
522 }
523
524 class ShowDetailAction extends AbstractAction {
525
526 public void showDetails() {
527 List<Changeset> selected = model.getSelectedChangesets();
528 if (selected.size() != 1) return;
529 model.setChangesetInDetailView(selected.get(0));
530 }
531
532 @Override
533 public void actionPerformed(ActionEvent arg0) {
534 showDetails();
535 }
536 }
537
538 class DownloadMyChangesets extends AbstractAction {
539 public DownloadMyChangesets() {
540 putValue(NAME, tr("My changesets"));
541 putValue(SMALL_ICON, ImageProvider.get("dialogs/changeset", "downloadchangeset"));
542 putValue(SHORT_DESCRIPTION, tr("Download my changesets from the OSM server (max. 100 changesets)"));
543 setEnabled(!Main.isOffline(OnlineResource.OSM_API));
544 }
545
546 protected void alertAnonymousUser() {
547 HelpAwareOptionPane.showOptionDialog(
548 ChangesetCacheManager.this,
549 tr("<html>JOSM is currently running with an anonymous user. It cannot download<br>"
550 + "your changesets from the OSM server unless you enter your OSM user name<br>"
551 + "in the JOSM preferences.</html>"
552 ),
553 tr("Warning"),
554 JOptionPane.WARNING_MESSAGE,
555 HelpUtil.ht("/Dialog/ChangesetManager#CanDownloadMyChangesets")
556 );
557 }
558
559 @Override
560 public void actionPerformed(ActionEvent arg0) {
561 JosmUserIdentityManager im = JosmUserIdentityManager.getInstance();
562 if (im.isAnonymous()) {
563 alertAnonymousUser();
564 return;
565 }
566 ChangesetQuery query = new ChangesetQuery();
567 if (im.isFullyIdentified()) {
568 query = query.forUser(im.getUserId());
569 } else {
570 query = query.forUser(im.getUserName());
571 }
572 ChangesetQueryTask task = new ChangesetQueryTask(ChangesetCacheManager.this, query);
573 ChangesetCacheManager.getInstance().runDownloadTask(task);
574 }
575 }
576
577 class MouseEventHandler extends PopupMenuLauncher {
578
579 public MouseEventHandler() {
580 super(new ChangesetTablePopupMenu());
581 }
582
583 @Override
584 public void mouseClicked(MouseEvent evt) {
585 if (isDoubleClick(evt)) {
586 new ShowDetailAction().showDetails();
587 }
588 }
589 }
590
591 class ChangesetTablePopupMenu extends JPopupMenu {
592 public ChangesetTablePopupMenu() {
593 add(actRemoveFromCacheAction);
594 add(actCloseSelectedChangesetsAction);
595 add(actDownloadSelectedChangesets);
596 add(actDownloadSelectedContent);
597 }
598 }
599
600 class ChangesetDetailViewSynchronizer implements ListSelectionListener {
601 @Override
602 public void valueChanged(ListSelectionEvent e) {
603 List<Changeset> selected = model.getSelectedChangesets();
604 if (selected.size() == 1) {
605 model.setChangesetInDetailView(selected.get(0));
606 } else {
607 model.setChangesetInDetailView(null);
608 }
609 }
610 }
611
612 /**
613 * Selects the changesets in <code>changests</code>, provided the
614 * respective changesets are already present in the local changeset cache.
615 *
616 * @param changesets the collection of changesets. If {@code null}, the
617 * selection is cleared.
618 */
619 public void setSelectedChangesets(Collection<Changeset> changesets) {
620 model.setSelectedChangesets(changesets);
621 int idx = model.getSelectionModel().getMinSelectionIndex();
622 if (idx < 0) return;
623 tblChangesets.scrollRectToVisible(tblChangesets.getCellRect(idx, 0, true));
624 repaint();
625 }
626
627 /**
628 * Selects the changesets with the ids in <code>ids</code>, provided the
629 * respective changesets are already present in the local changeset cache.
630 *
631 * @param ids the collection of ids. If null, the selection is cleared.
632 */
633 public void setSelectedChangesetsById(Collection<Integer> ids) {
634 if (ids == null) {
635 setSelectedChangesets(null);
636 return;
637 }
638 Set<Changeset> toSelect = new HashSet<>();
639 ChangesetCache cc = ChangesetCache.getInstance();
640 for (int id: ids) {
641 if (cc.contains(id)) {
642 toSelect.add(cc.get(id));
643 }
644 }
645 setSelectedChangesets(toSelect);
646 }
647
648 /**
649 * Runs the given changeset download task.
650 * @param task The changeset download task to run
651 */
652 public void runDownloadTask(final ChangesetDownloadTask task) {
653 Main.worker.submit(task);
654 Main.worker.submit(new Runnable() {
655 @Override
656 public void run() {
657 if (task.isCanceled() || task.isFailed()) return;
658 setSelectedChangesets(task.getDownloadedChangesets());
659 }
660 });
661 }
662}
Note: See TracBrowser for help on using the repository browser.