001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.layer.geoimage; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005import static org.openstreetmap.josm.tools.I18n.trn; 006 007import java.awt.BorderLayout; 008import java.awt.Component; 009import java.awt.Dimension; 010import java.awt.GridBagConstraints; 011import java.awt.GridBagLayout; 012import java.awt.event.ActionEvent; 013import java.awt.event.KeyEvent; 014import java.awt.event.WindowEvent; 015import java.text.DateFormat; 016import java.text.SimpleDateFormat; 017import java.util.Collections; 018import java.util.List; 019import java.util.Optional; 020 021import javax.swing.Box; 022import javax.swing.JButton; 023import javax.swing.JLabel; 024import javax.swing.JOptionPane; 025import javax.swing.JPanel; 026import javax.swing.JToggleButton; 027import javax.swing.SwingConstants; 028 029import org.openstreetmap.josm.actions.JosmAction; 030import org.openstreetmap.josm.data.ImageData; 031import org.openstreetmap.josm.data.ImageData.ImageDataUpdateListener; 032import org.openstreetmap.josm.gui.ExtendedDialog; 033import org.openstreetmap.josm.gui.MainApplication; 034import org.openstreetmap.josm.gui.datatransfer.ClipboardUtils; 035import org.openstreetmap.josm.gui.dialogs.DialogsPanel.Action; 036import org.openstreetmap.josm.gui.dialogs.ToggleDialog; 037import org.openstreetmap.josm.gui.layer.Layer; 038import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent; 039import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener; 040import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent; 041import org.openstreetmap.josm.gui.layer.LayerManager.LayerRemoveEvent; 042import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeEvent; 043import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeListener; 044import org.openstreetmap.josm.tools.ImageProvider; 045import org.openstreetmap.josm.tools.Logging; 046import org.openstreetmap.josm.tools.Shortcut; 047import org.openstreetmap.josm.tools.Utils; 048import org.openstreetmap.josm.tools.date.DateUtils; 049 050/** 051 * Dialog to view and manipulate geo-tagged images from a {@link GeoImageLayer}. 052 */ 053public final class ImageViewerDialog extends ToggleDialog implements LayerChangeListener, ActiveLayerChangeListener, ImageDataUpdateListener { 054 055 private final ImageZoomAction imageZoomAction = new ImageZoomAction(); 056 private final ImageCenterViewAction imageCenterViewAction = new ImageCenterViewAction(); 057 private final ImageNextAction imageNextAction = new ImageNextAction(); 058 private final ImageRemoveAction imageRemoveAction = new ImageRemoveAction(); 059 private final ImageRemoveFromDiskAction imageRemoveFromDiskAction = new ImageRemoveFromDiskAction(); 060 private final ImagePreviousAction imagePreviousAction = new ImagePreviousAction(); 061 private final ImageCollapseAction imageCollapseAction = new ImageCollapseAction(); 062 private final ImageFirstAction imageFirstAction = new ImageFirstAction(); 063 private final ImageLastAction imageLastAction = new ImageLastAction(); 064 private final ImageCopyPathAction imageCopyPathAction = new ImageCopyPathAction(); 065 066 private final ImageDisplay imgDisplay = new ImageDisplay(); 067 private boolean centerView; 068 069 // Only one instance of that class is present at one time 070 private static volatile ImageViewerDialog dialog; 071 072 private boolean collapseButtonClicked; 073 074 static void createInstance() { 075 if (dialog != null) 076 throw new IllegalStateException("ImageViewerDialog instance was already created"); 077 dialog = new ImageViewerDialog(); 078 } 079 080 /** 081 * Replies the unique instance of this dialog 082 * @return the unique instance 083 */ 084 public static ImageViewerDialog getInstance() { 085 if (dialog == null) 086 throw new AssertionError("a new instance needs to be created first"); 087 return dialog; 088 } 089 090 private JButton btnLast; 091 private JButton btnNext; 092 private JButton btnPrevious; 093 private JButton btnFirst; 094 private JButton btnCollapse; 095 private JButton btnDelete; 096 private JButton btnCopyPath; 097 private JButton btnDeleteFromDisk; 098 private JToggleButton tbCentre; 099 100 private ImageViewerDialog() { 101 super(tr("Geotagged Images"), "geoimage", tr("Display geotagged images"), Shortcut.registerShortcut("tools:geotagged", 102 tr("Tool: {0}", tr("Display geotagged images")), KeyEvent.VK_Y, Shortcut.DIRECT), 200); 103 build(); 104 MainApplication.getLayerManager().addActiveLayerChangeListener(this); 105 MainApplication.getLayerManager().addLayerChangeListener(this); 106 for (Layer l: MainApplication.getLayerManager().getLayers()) { 107 registerOnLayer(l); 108 } 109 } 110 111 private static JButton createNavigationButton(JosmAction action, Dimension buttonDim) { 112 JButton btn = new JButton(action); 113 btn.setPreferredSize(buttonDim); 114 btn.setEnabled(false); 115 return btn; 116 } 117 118 private void build() { 119 JPanel content = new JPanel(new BorderLayout()); 120 121 content.add(imgDisplay, BorderLayout.CENTER); 122 123 Dimension buttonDim = new Dimension(26, 26); 124 125 btnFirst = createNavigationButton(imageFirstAction, buttonDim); 126 btnPrevious = createNavigationButton(imagePreviousAction, buttonDim); 127 128 btnDelete = new JButton(imageRemoveAction); 129 btnDelete.setPreferredSize(buttonDim); 130 131 btnDeleteFromDisk = new JButton(imageRemoveFromDiskAction); 132 btnDeleteFromDisk.setPreferredSize(buttonDim); 133 134 btnCopyPath = new JButton(imageCopyPathAction); 135 btnCopyPath.setPreferredSize(buttonDim); 136 137 btnNext = createNavigationButton(imageNextAction, buttonDim); 138 btnLast = createNavigationButton(imageLastAction, buttonDim); 139 140 tbCentre = new JToggleButton(imageCenterViewAction); 141 tbCentre.setPreferredSize(buttonDim); 142 143 JButton btnZoomBestFit = new JButton(imageZoomAction); 144 btnZoomBestFit.setPreferredSize(buttonDim); 145 146 btnCollapse = new JButton(imageCollapseAction); 147 btnCollapse.setPreferredSize(new Dimension(20, 20)); 148 btnCollapse.setAlignmentY(Component.TOP_ALIGNMENT); 149 150 JPanel buttons = new JPanel(); 151 buttons.add(btnFirst); 152 buttons.add(btnPrevious); 153 buttons.add(btnNext); 154 buttons.add(btnLast); 155 buttons.add(Box.createRigidArea(new Dimension(7, 0))); 156 buttons.add(tbCentre); 157 buttons.add(btnZoomBestFit); 158 buttons.add(Box.createRigidArea(new Dimension(7, 0))); 159 buttons.add(btnDelete); 160 buttons.add(btnDeleteFromDisk); 161 buttons.add(Box.createRigidArea(new Dimension(7, 0))); 162 buttons.add(btnCopyPath); 163 164 JPanel bottomPane = new JPanel(new GridBagLayout()); 165 GridBagConstraints gc = new GridBagConstraints(); 166 gc.gridx = 0; 167 gc.gridy = 0; 168 gc.anchor = GridBagConstraints.CENTER; 169 gc.weightx = 1; 170 bottomPane.add(buttons, gc); 171 172 gc.gridx = 1; 173 gc.gridy = 0; 174 gc.anchor = GridBagConstraints.PAGE_END; 175 gc.weightx = 0; 176 bottomPane.add(btnCollapse, gc); 177 178 content.add(bottomPane, BorderLayout.SOUTH); 179 180 createLayout(content, false, null); 181 } 182 183 @Override 184 public void destroy() { 185 MainApplication.getLayerManager().removeActiveLayerChangeListener(this); 186 MainApplication.getLayerManager().removeLayerChangeListener(this); 187 // Manually destroy actions until JButtons are replaced by standard SideButtons 188 imageFirstAction.destroy(); 189 imageLastAction.destroy(); 190 imagePreviousAction.destroy(); 191 imageNextAction.destroy(); 192 imageCenterViewAction.destroy(); 193 imageCollapseAction.destroy(); 194 imageCopyPathAction.destroy(); 195 imageRemoveAction.destroy(); 196 imageRemoveFromDiskAction.destroy(); 197 imageZoomAction.destroy(); 198 super.destroy(); 199 dialog = null; 200 } 201 202 private class ImageNextAction extends JosmAction { 203 ImageNextAction() { 204 super(null, new ImageProvider("dialogs", "next"), tr("Next"), Shortcut.registerShortcut( 205 "geoimage:next", tr("Geoimage: {0}", tr("Show next Image")), KeyEvent.VK_PAGE_DOWN, Shortcut.DIRECT), 206 false, null, false); 207 } 208 209 @Override 210 public void actionPerformed(ActionEvent e) { 211 if (currentData != null) { 212 currentData.selectNextImage(); 213 } 214 } 215 } 216 217 private class ImagePreviousAction extends JosmAction { 218 ImagePreviousAction() { 219 super(null, new ImageProvider("dialogs", "previous"), tr("Previous"), Shortcut.registerShortcut( 220 "geoimage:previous", tr("Geoimage: {0}", tr("Show previous Image")), KeyEvent.VK_PAGE_UP, Shortcut.DIRECT), 221 false, null, false); 222 } 223 224 @Override 225 public void actionPerformed(ActionEvent e) { 226 if (currentData != null) { 227 currentData.selectPreviousImage(); 228 } 229 } 230 } 231 232 private class ImageFirstAction extends JosmAction { 233 ImageFirstAction() { 234 super(null, new ImageProvider("dialogs", "first"), tr("First"), Shortcut.registerShortcut( 235 "geoimage:first", tr("Geoimage: {0}", tr("Show first Image")), KeyEvent.VK_HOME, Shortcut.DIRECT), 236 false, null, false); 237 } 238 239 @Override 240 public void actionPerformed(ActionEvent e) { 241 if (currentData != null) { 242 currentData.selectFirstImage(); 243 } 244 } 245 } 246 247 private class ImageLastAction extends JosmAction { 248 ImageLastAction() { 249 super(null, new ImageProvider("dialogs", "last"), tr("Last"), Shortcut.registerShortcut( 250 "geoimage:last", tr("Geoimage: {0}", tr("Show last Image")), KeyEvent.VK_END, Shortcut.DIRECT), 251 false, null, false); 252 } 253 254 @Override 255 public void actionPerformed(ActionEvent e) { 256 if (currentData != null) { 257 currentData.selectLastImage(); 258 } 259 } 260 } 261 262 private class ImageCenterViewAction extends JosmAction { 263 ImageCenterViewAction() { 264 super(null, new ImageProvider("dialogs", "centreview"), tr("Center view"), null, 265 false, null, false); 266 } 267 268 @Override 269 public void actionPerformed(ActionEvent e) { 270 final JToggleButton button = (JToggleButton) e.getSource(); 271 centerView = button.isEnabled() && button.isSelected(); 272 if (centerView && currentEntry != null && currentEntry.getPos() != null) { 273 MainApplication.getMap().mapView.zoomTo(currentEntry.getPos()); 274 } 275 } 276 } 277 278 private class ImageZoomAction extends JosmAction { 279 ImageZoomAction() { 280 super(null, new ImageProvider("dialogs", "zoom-best-fit"), tr("Zoom best fit and 1:1"), null, 281 false, null, false); 282 } 283 284 @Override 285 public void actionPerformed(ActionEvent e) { 286 imgDisplay.zoomBestFitOrOne(); 287 } 288 } 289 290 private class ImageRemoveAction extends JosmAction { 291 ImageRemoveAction() { 292 super(null, new ImageProvider("dialogs", "delete"), tr("Remove photo(s) from layer"), Shortcut.registerShortcut( 293 "geoimage:deleteimagefromlayer", tr("Geoimage: {0}", tr("Remove photo(s) from layer")), KeyEvent.VK_DELETE, Shortcut.SHIFT), 294 false, null, false); 295 } 296 297 @Override 298 public void actionPerformed(ActionEvent e) { 299 if (currentData != null) { 300 currentData.removeSelectedImages(); 301 } 302 } 303 } 304 305 private class ImageRemoveFromDiskAction extends JosmAction { 306 ImageRemoveFromDiskAction() { 307 super(null, new ImageProvider("dialogs", "geoimage/deletefromdisk"), tr("Delete photo file(s) from disk"), 308 Shortcut.registerShortcut( 309 "geoimage:deletefilefromdisk", tr("Geoimage: {0}", tr("Delete file(s) from disk")), KeyEvent.VK_DELETE, Shortcut.CTRL_SHIFT), 310 false, null, false); 311 } 312 313 @Override 314 public void actionPerformed(ActionEvent e) { 315 if (currentData != null && currentData.getSelectedImage() != null) { 316 List<ImageEntry> toDelete = currentData.getSelectedImages(); 317 int size = toDelete.size(); 318 319 int result = new ExtendedDialog( 320 MainApplication.getMainFrame(), 321 tr("Delete image file from disk"), 322 tr("Cancel"), tr("Delete")) 323 .setButtonIcons("cancel", "dialogs/delete") 324 .setContent(new JLabel("<html><h3>" 325 + trn("Delete the file from disk?", 326 "Delete the {0} files from disk?", size, size) 327 + "<p>" + trn("The image file will be permanently lost!", 328 "The images files will be permanently lost!", size) + "</h3></html>", 329 ImageProvider.get("dialogs/geoimage/deletefromdisk"), SwingConstants.LEFT)) 330 .toggleEnable("geoimage.deleteimagefromdisk") 331 .setCancelButton(1) 332 .setDefaultButton(2) 333 .showDialog() 334 .getValue(); 335 336 if (result == 2) { 337 currentData.removeSelectedImages(); 338 for (ImageEntry delete : toDelete) { 339 if (Utils.deleteFile(delete.getFile())) { 340 Logging.info("File " + delete.getFile() + " deleted."); 341 } else { 342 JOptionPane.showMessageDialog( 343 MainApplication.getMainFrame(), 344 tr("Image file could not be deleted."), 345 tr("Error"), 346 JOptionPane.ERROR_MESSAGE 347 ); 348 } 349 } 350 } 351 } 352 } 353 } 354 355 private class ImageCopyPathAction extends JosmAction { 356 ImageCopyPathAction() { 357 super(null, new ImageProvider("copy"), tr("Copy image path"), Shortcut.registerShortcut( 358 "geoimage:copypath", tr("Geoimage: {0}", tr("Copy image path")), KeyEvent.VK_C, Shortcut.ALT_CTRL_SHIFT), 359 false, null, false); 360 } 361 362 @Override 363 public void actionPerformed(ActionEvent e) { 364 if (currentData != null) { 365 ClipboardUtils.copyString(currentData.getSelectedImage().getFile().toString()); 366 } 367 } 368 } 369 370 private class ImageCollapseAction extends JosmAction { 371 ImageCollapseAction() { 372 super(null, new ImageProvider("dialogs", "collapse"), tr("Move dialog to the side pane"), null, 373 false, null, false); 374 } 375 376 @Override 377 public void actionPerformed(ActionEvent e) { 378 collapseButtonClicked = true; 379 detachedDialog.getToolkit().getSystemEventQueue().postEvent(new WindowEvent(detachedDialog, WindowEvent.WINDOW_CLOSING)); 380 } 381 } 382 383 /** 384 * Enables (or disables) the "Previous" button. 385 * @param value {@code true} to enable the button, {@code false} otherwise 386 */ 387 public void setPreviousEnabled(boolean value) { 388 btnFirst.setEnabled(value); 389 btnPrevious.setEnabled(value); 390 } 391 392 /** 393 * Enables (or disables) the "Next" button. 394 * @param value {@code true} to enable the button, {@code false} otherwise 395 */ 396 public void setNextEnabled(boolean value) { 397 btnNext.setEnabled(value); 398 btnLast.setEnabled(value); 399 } 400 401 /** 402 * Enables (or disables) the "Center view" button. 403 * @param value {@code true} to enable the button, {@code false} otherwise 404 * @return the old enabled value. Can be used to restore the original enable state 405 */ 406 public static synchronized boolean setCentreEnabled(boolean value) { 407 final ImageViewerDialog instance = getInstance(); 408 final boolean wasEnabled = instance.tbCentre.isEnabled(); 409 instance.tbCentre.setEnabled(value); 410 instance.tbCentre.getAction().actionPerformed(new ActionEvent(instance.tbCentre, 0, null)); 411 return wasEnabled; 412 } 413 414 private transient ImageData currentData; 415 private transient ImageEntry currentEntry; 416 417 /** 418 * Displays a single image for the given layer. 419 * @param data the image data 420 * @param entry image entry 421 * @see #displayImages 422 */ 423 public void displayImage(ImageData data, ImageEntry entry) { 424 displayImages(data, Collections.singletonList(entry)); 425 } 426 427 /** 428 * Displays images for the given layer. 429 * @param data the image data 430 * @param entries image entries 431 * @since 15333 432 */ 433 public void displayImages(ImageData data, List<ImageEntry> entries) { 434 boolean imageChanged; 435 ImageEntry entry = entries != null && entries.size() == 1 ? entries.get(0) : null; 436 437 synchronized (this) { 438 // TODO: pop up image dialog but don't load image again 439 440 imageChanged = currentEntry != entry; 441 442 if (centerView && entry != null && MainApplication.isDisplayingMapView() && entry.getPos() != null) { 443 MainApplication.getMap().mapView.zoomTo(entry.getPos()); 444 } 445 446 currentData = data; 447 currentEntry = entry; 448 } 449 450 if (entry != null) { 451 setNextEnabled(data.hasNextImage()); 452 setPreviousEnabled(data.hasPreviousImage()); 453 btnDelete.setEnabled(true); 454 btnDeleteFromDisk.setEnabled(true); 455 btnCopyPath.setEnabled(true); 456 457 if (imageChanged) { 458 // Set only if the image is new to preserve zoom and position if the same image is redisplayed 459 // (e.g. to update the OSD). 460 imgDisplay.setImage(entry); 461 } 462 setTitle(tr("Geotagged Images") + (entry.getFile() != null ? " - " + entry.getFile().getName() : "")); 463 StringBuilder osd = new StringBuilder(entry.getFile() != null ? entry.getFile().getName() : ""); 464 if (entry.getElevation() != null) { 465 osd.append(tr("\nAltitude: {0} m", Math.round(entry.getElevation()))); 466 } 467 if (entry.getSpeed() != null) { 468 osd.append(tr("\nSpeed: {0} km/h", Math.round(entry.getSpeed()))); 469 } 470 if (entry.getExifImgDir() != null) { 471 osd.append(tr("\nDirection {0}\u00b0", Math.round(entry.getExifImgDir()))); 472 } 473 DateFormat dtf = DateUtils.getDateTimeFormat(DateFormat.SHORT, DateFormat.MEDIUM); 474 // Make sure date/time format includes milliseconds 475 if (dtf instanceof SimpleDateFormat) { 476 String pattern = ((SimpleDateFormat) dtf).toPattern(); 477 if (!pattern.contains(".SSS")) { 478 dtf = new SimpleDateFormat(pattern.replace(":ss", ":ss.SSS")); 479 } 480 } 481 if (entry.hasExifTime()) { 482 osd.append(tr("\nEXIF time: {0}", dtf.format(entry.getExifTime()))); 483 } 484 if (entry.hasGpsTime()) { 485 osd.append(tr("\nGPS time: {0}", dtf.format(entry.getGpsTime()))); 486 } 487 Optional.ofNullable(entry.getIptcCaption()).map(s -> tr("\nCaption: {0}", s)).ifPresent(osd::append); 488 Optional.ofNullable(entry.getIptcHeadline()).map(s -> tr("\nHeadline: {0}", s)).ifPresent(osd::append); 489 Optional.ofNullable(entry.getIptcKeywords()).map(s -> tr("\nKeywords: {0}", s)).ifPresent(osd::append); 490 Optional.ofNullable(entry.getIptcObjectName()).map(s -> tr("\nObject name: {0}", s)).ifPresent(osd::append); 491 492 imgDisplay.setOsdText(osd.toString()); 493 } else { 494 boolean hasMultipleImages = entries != null && entries.size() > 1; 495 // if this method is called to reinitialize dialog content with a blank image, 496 // do not actually show the dialog again with a blank image if currently hidden (fix #10672) 497 setTitle(tr("Geotagged Images")); 498 imgDisplay.setImage(null); 499 imgDisplay.setOsdText(""); 500 setNextEnabled(false); 501 setPreviousEnabled(false); 502 btnDelete.setEnabled(hasMultipleImages); 503 btnDeleteFromDisk.setEnabled(hasMultipleImages); 504 btnCopyPath.setEnabled(false); 505 if (hasMultipleImages) { 506 imgDisplay.setEmptyText(tr("Multiple images selected")); 507 btnFirst.setEnabled(!isFirstImageSelected(data)); 508 btnLast.setEnabled(!isLastImageSelected(data)); 509 } 510 imgDisplay.setImage(null); 511 imgDisplay.setOsdText(""); 512 return; 513 } 514 if (!isDialogShowing()) { 515 setIsDocked(false); // always open a detached window when an image is clicked and dialog is closed 516 showDialog(); 517 } else { 518 if (isDocked && isCollapsed) { 519 expand(); 520 dialogsPanel.reconstruct(Action.COLLAPSED_TO_DEFAULT, this); 521 } 522 } 523 } 524 525 private static boolean isLastImageSelected(ImageData data) { 526 return data.isImageSelected(data.getImages().get(data.getImages().size() - 1)); 527 } 528 529 private static boolean isFirstImageSelected(ImageData data) { 530 return data.isImageSelected(data.getImages().get(0)); 531 } 532 533 /** 534 * When an image is closed, really close it and do not pop 535 * up the side dialog. 536 */ 537 @Override 538 protected boolean dockWhenClosingDetachedDlg() { 539 if (collapseButtonClicked) { 540 collapseButtonClicked = false; 541 return super.dockWhenClosingDetachedDlg(); 542 } 543 return false; 544 } 545 546 @Override 547 protected void stateChanged() { 548 super.stateChanged(); 549 if (btnCollapse != null) { 550 btnCollapse.setVisible(!isDocked); 551 } 552 } 553 554 /** 555 * Returns whether an image is currently displayed 556 * @return If image is currently displayed 557 */ 558 public boolean hasImage() { 559 return currentEntry != null; 560 } 561 562 /** 563 * Returns the currently displayed image. 564 * @return Currently displayed image or {@code null} 565 * @since 6392 566 */ 567 public static ImageEntry getCurrentImage() { 568 return getInstance().currentEntry; 569 } 570 571 /** 572 * Returns whether the center view is currently active. 573 * @return {@code true} if the center view is active, {@code false} otherwise 574 * @since 9416 575 */ 576 public static boolean isCenterView() { 577 return getInstance().centerView; 578 } 579 580 @Override 581 public void layerAdded(LayerAddEvent e) { 582 registerOnLayer(e.getAddedLayer()); 583 showLayer(e.getAddedLayer()); 584 } 585 586 @Override 587 public void layerRemoving(LayerRemoveEvent e) { 588 if (e.getRemovedLayer() instanceof GeoImageLayer) { 589 ImageData removedData = ((GeoImageLayer) e.getRemovedLayer()).getImageData(); 590 if (removedData == currentData) { 591 displayImages(null, null); 592 } 593 removedData.removeImageDataUpdateListener(this); 594 } 595 } 596 597 @Override 598 public void layerOrderChanged(LayerOrderChangeEvent e) { 599 // ignored 600 } 601 602 @Override 603 public void activeOrEditLayerChanged(ActiveLayerChangeEvent e) { 604 showLayer(e.getSource().getActiveLayer()); 605 } 606 607 private void registerOnLayer(Layer layer) { 608 if (layer instanceof GeoImageLayer) { 609 ((GeoImageLayer) layer).getImageData().addImageDataUpdateListener(this); 610 } 611 } 612 613 private void showLayer(Layer newLayer) { 614 if (currentData == null && newLayer instanceof GeoImageLayer) { 615 ((GeoImageLayer) newLayer).getImageData().selectFirstImage(); 616 } 617 } 618 619 @Override 620 public void selectedImageChanged(ImageData data) { 621 displayImages(data, data.getSelectedImages()); 622 } 623 624 @Override 625 public void imageDataUpdated(ImageData data) { 626 displayImages(data, data.getSelectedImages()); 627 } 628}