source: josm/trunk/src/org/openstreetmap/josm/actions/SessionSaveAction.java@ 18833

Last change on this file since 18833 was 18833, checked in by taylor.smock, 18 months ago

Fix #17052: Allow plugins to save state to session file

The primary feature request was for the TODO plugin to save the list elements for
a future session.

This allows plugins to register via ServiceLoader classes which need to be
called to save or restore their state.

In addition, this fixes an ordering issue with tests whereby the OsmApi cache
would be cleared, but the FakeOsmApi class would not recache itself when called.

File size: 24.4 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.actions;
3
4import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
5import static org.openstreetmap.josm.tools.I18n.tr;
6import static org.openstreetmap.josm.tools.I18n.trn;
7
8import java.awt.Component;
9import java.awt.Dimension;
10import java.awt.GridBagLayout;
11import java.awt.event.ActionEvent;
12import java.awt.event.KeyEvent;
13import java.io.File;
14import java.io.IOException;
15import java.lang.ref.WeakReference;
16import java.nio.file.Files;
17import java.util.ArrayList;
18import java.util.Arrays;
19import java.util.Collection;
20import java.util.EnumSet;
21import java.util.HashMap;
22import java.util.HashSet;
23import java.util.List;
24import java.util.Map;
25import java.util.Objects;
26import java.util.Set;
27import java.util.stream.Collectors;
28import java.util.stream.Stream;
29
30import javax.swing.BorderFactory;
31import javax.swing.JCheckBox;
32import javax.swing.JFileChooser;
33import javax.swing.JLabel;
34import javax.swing.JOptionPane;
35import javax.swing.JPanel;
36import javax.swing.JScrollPane;
37import javax.swing.JTabbedPane;
38import javax.swing.SwingConstants;
39import javax.swing.border.EtchedBorder;
40import javax.swing.filechooser.FileFilter;
41
42import org.openstreetmap.josm.data.PreferencesUtils;
43import org.openstreetmap.josm.data.preferences.BooleanProperty;
44import org.openstreetmap.josm.gui.ConditionalOptionPaneUtil;
45import org.openstreetmap.josm.gui.ExtendedDialog;
46import org.openstreetmap.josm.gui.HelpAwareOptionPane;
47import org.openstreetmap.josm.gui.MainApplication;
48import org.openstreetmap.josm.gui.MapFrame;
49import org.openstreetmap.josm.gui.MapFrameListener;
50import org.openstreetmap.josm.gui.Notification;
51import org.openstreetmap.josm.gui.layer.AbstractModifiableLayer;
52import org.openstreetmap.josm.gui.layer.Layer;
53import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent;
54import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener;
55import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent;
56import org.openstreetmap.josm.gui.layer.LayerManager.LayerRemoveEvent;
57import org.openstreetmap.josm.gui.util.GuiHelper;
58import org.openstreetmap.josm.gui.util.WindowGeometry;
59import org.openstreetmap.josm.gui.widgets.AbstractFileChooser;
60import org.openstreetmap.josm.io.session.PluginSessionExporter;
61import org.openstreetmap.josm.io.session.SessionLayerExporter;
62import org.openstreetmap.josm.io.session.SessionWriter;
63import org.openstreetmap.josm.plugins.PluginHandler;
64import org.openstreetmap.josm.spi.preferences.Config;
65import org.openstreetmap.josm.tools.GBC;
66import org.openstreetmap.josm.tools.JosmRuntimeException;
67import org.openstreetmap.josm.tools.Logging;
68import org.openstreetmap.josm.tools.MultiMap;
69import org.openstreetmap.josm.tools.Shortcut;
70import org.openstreetmap.josm.tools.UserCancelException;
71import org.openstreetmap.josm.tools.Utils;
72
73/**
74 * Saves a JOSM session
75 * @since 18466
76 */
77public class SessionSaveAction extends DiskAccessAction implements MapFrameListener, LayerChangeListener {
78
79 private transient List<Layer> layers;
80 private transient Map<Layer, SessionLayerExporter> exporters;
81 private transient MultiMap<Layer, Layer> dependencies;
82
83 private static final BooleanProperty SAVE_LOCAL_FILES_PROPERTY = new BooleanProperty("session.savelocal", true);
84 private static final BooleanProperty SAVE_PLUGIN_INFORMATION_PROPERTY = new BooleanProperty("session.saveplugins", false);
85 private static final String TOOLTIP_DEFAULT = tr("Save the current session.");
86
87 protected transient FileFilter joz = new ExtensionFileFilter("joz", "joz", tr("Session file (archive) (*.joz)"));
88 protected transient FileFilter jos = new ExtensionFileFilter("jos", "jos", tr("Session file (*.jos)"));
89
90 private File removeFileOnSuccess;
91
92 private static String tooltip = TOOLTIP_DEFAULT;
93 static File sessionFile;
94 static boolean isZipSessionFile;
95 private static boolean pluginData;
96 static List<WeakReference<Layer>> layersInSessionFile;
97
98 private static final SessionSaveAction instance = new SessionSaveAction();
99
100 /**
101 * Returns the instance
102 * @return the instance
103 */
104 public static SessionSaveAction getInstance() {
105 return instance;
106 }
107
108 /**
109 * Constructs a new {@code SessionSaveAction}.
110 */
111 public SessionSaveAction() {
112 this(true, false);
113 updateEnabledState();
114 }
115
116 /**
117 * Constructs a new {@code SessionSaveAction}.
118 * @param toolbar Register this action for the toolbar preferences?
119 * @param installAdapters False, if you don't want to install layer changed and selection changed adapters
120 */
121 protected SessionSaveAction(boolean toolbar, boolean installAdapters) {
122 this(tr("Save Session"), "session", TOOLTIP_DEFAULT,
123 Shortcut.registerShortcut("system:savesession", tr("File: {0}", tr("Save Session...")), KeyEvent.VK_S, Shortcut.ALT_CTRL),
124 toolbar, "save-session", installAdapters);
125 setHelpId(ht("/Action/SessionSave"));
126 }
127
128 protected SessionSaveAction(String name, String iconName, String tooltip,
129 Shortcut shortcut, boolean register, String toolbarId, boolean installAdapters) {
130
131 super(name, iconName, tooltip, shortcut, register, toolbarId, installAdapters);
132 addListeners();
133 }
134
135 @Override
136 public void actionPerformed(ActionEvent e) {
137 try {
138 saveSession(false, false);
139 } catch (UserCancelException exception) {
140 Logging.trace(exception);
141 }
142 }
143
144 @Override
145 public void destroy() {
146 removeListeners();
147 super.destroy();
148 }
149
150 /**
151 * Attempts to save the session.
152 * @param saveAs true shows the dialog
153 * @param forceSaveAll saves all layers
154 * @return if the session and all layers were successfully saved
155 * @throws UserCancelException when the user has cancelled the save process
156 */
157 public boolean saveSession(boolean saveAs, boolean forceSaveAll) throws UserCancelException {
158 if (!isEnabled()) {
159 return false;
160 }
161
162 removeFileOnSuccess = null;
163
164 SessionSaveAsDialog dlg = new SessionSaveAsDialog();
165 if (saveAs) {
166 dlg.showDialog();
167 if (dlg.getValue() != 1) {
168 throw new UserCancelException();
169 }
170 }
171
172 // TODO: resolve dependencies for layers excluded by the user
173 List<Layer> layersOut = layers.stream()
174 .filter(layer -> exporters.get(layer) != null && exporters.get(layer).shallExport())
175 .collect(Collectors.toList());
176
177 boolean zipRequired = layersOut.stream().map(l -> exporters.get(l))
178 .anyMatch(ex -> ex != null && ex.requiresZip()) || pluginsWantToSave();
179
180 saveAs = !doGetFile(saveAs, zipRequired);
181
182 String fn = sessionFile.getName();
183
184 if (!saveAs && layersInSessionFile != null) {
185 List<String> missingLayers = layersInSessionFile.stream()
186 .map(WeakReference::get)
187 .filter(Objects::nonNull)
188 .filter(l -> !layersOut.contains(l))
189 .map(Layer::getName)
190 .collect(Collectors.toList());
191
192 if (!missingLayers.isEmpty() &&
193 !ConditionalOptionPaneUtil.showConfirmationDialog(
194 "savesession_layerremoved",
195 null,
196 new JLabel("<html>"
197 + trn("The following layer has been removed since the session was last saved:",
198 "The following layers have been removed since the session was last saved:", missingLayers.size())
199 + "<ul><li>"
200 + String.join("<li>", missingLayers)
201 + "</ul><br>"
202 + tr("You are about to overwrite the session file \"{0}\". Would you like to proceed?", fn)),
203 tr("Layers removed"), JOptionPane.OK_CANCEL_OPTION, JOptionPane.WARNING_MESSAGE,
204 JOptionPane.OK_OPTION)) {
205 throw new UserCancelException();
206 }
207 }
208 setCurrentLayers(layersOut);
209
210 updateSessionFile(fn);
211
212 Stream<Layer> layersToSaveStream = layersOut.stream()
213 .filter(layer -> layer.isSavable()
214 && layer instanceof AbstractModifiableLayer
215 && ((AbstractModifiableLayer) layer).requiresSaveToFile()
216 && exporters.get(layer) != null
217 && !exporters.get(layer).requiresZip());
218
219 boolean success = true;
220 if (forceSaveAll || Boolean.TRUE.equals(SAVE_LOCAL_FILES_PROPERTY.get())) {
221 // individual files must be saved before the session file as the location may change
222 if (layersToSaveStream
223 .map(layer -> SaveAction.getInstance().doSave(layer, true))
224 .collect(Collectors.toList()) // force evaluation of all elements
225 .contains(false)) {
226
227 new Notification(tr("Not all local files referenced by the session file could be saved."
228 + "<br>Make sure you save them before closing JOSM."))
229 .setIcon(JOptionPane.WARNING_MESSAGE)
230 .setDuration(Notification.TIME_LONG)
231 .show();
232 success = false;
233 }
234 } else if (layersToSaveStream.anyMatch(l -> true)) {
235 new Notification(tr("Not all local files referenced by the session file are saved yet."
236 + "<br>Make sure you save them before closing JOSM."))
237 .setIcon(JOptionPane.INFORMATION_MESSAGE)
238 .setDuration(Notification.TIME_LONG)
239 .show();
240 }
241
242 int active = -1;
243 Layer activeLayer = getLayerManager().getActiveLayer();
244 if (activeLayer != null) {
245 active = layersOut.indexOf(activeLayer);
246 }
247
248 final EnumSet<SessionWriter.SessionWriterFlags> flags = EnumSet.noneOf(SessionWriter.SessionWriterFlags.class);
249 if (pluginData || (Boolean.TRUE.equals(SAVE_PLUGIN_INFORMATION_PROPERTY.get()) && pluginsWantToSave())) {
250 flags.add(SessionWriter.SessionWriterFlags.SAVE_PLUGIN_INFORMATION);
251 }
252 if (isZipSessionFile) {
253 flags.add(SessionWriter.SessionWriterFlags.IS_ZIP);
254 }
255 SessionWriter sw = new SessionWriter(layersOut, active, exporters, dependencies, flags.toArray(new SessionWriter.SessionWriterFlags[0]));
256 try {
257 Notification savingNotification = showSavingNotification(sessionFile.getName());
258 sw.write(sessionFile);
259 SaveActionBase.addToFileOpenHistory(sessionFile);
260 if (removeFileOnSuccess != null) {
261 PreferencesUtils.removeFromList(Config.getPref(), "file-open.history", removeFileOnSuccess.getCanonicalPath());
262 Files.deleteIfExists(removeFileOnSuccess.toPath());
263 removeFileOnSuccess = null;
264 }
265 showSavedNotification(savingNotification, sessionFile.getName());
266 } catch (SecurityException ex) {
267 Logging.error(ex);
268 if (removeFileOnSuccess != null) {
269 final String path = removeFileOnSuccess.getPath();
270 GuiHelper.runInEDT(() -> {
271 Notification notification = new Notification(tr("Could not delete file: {0}<br>{1}", path, ex.getMessage()));
272 notification.setIcon(JOptionPane.WARNING_MESSAGE);
273 notification.show();
274 });
275 } else {
276 // We should never hit this, unless something changes in the try block.
277 throw new JosmRuntimeException(ex);
278 }
279 } catch (IOException ex) {
280 Logging.error(ex);
281 HelpAwareOptionPane.showMessageDialogInEDT(
282 MainApplication.getMainFrame(),
283 tr("<html>Could not save session file ''{0}''.<br>Error is:<br>{1}</html>",
284 sessionFile.getName(), Utils.escapeReservedCharactersHTML(ex.getMessage())),
285 tr("IO Error"),
286 JOptionPane.ERROR_MESSAGE,
287 null
288 );
289 success = false;
290 }
291 return success;
292 }
293
294 /**
295 * Sets the current session file. Asks the user if necessary
296 * @param saveAs always ask the user
297 * @param zipRequired zip
298 * @return if the user was asked
299 * @throws UserCancelException when the user has cancelled the save process
300 */
301 protected boolean doGetFile(boolean saveAs, boolean zipRequired) throws UserCancelException {
302 if (!saveAs && sessionFile != null) {
303
304 if (isZipSessionFile || !zipRequired)
305 return true;
306
307 Logging.info("Converting *.jos to *.joz because a new layer has been added that requires zip format");
308 String oldPath = sessionFile.getAbsolutePath();
309 int i = oldPath.lastIndexOf('.');
310 File jozFile = new File(i < 0 ? oldPath : oldPath.substring(0, i) + ".joz");
311 if (!jozFile.exists()) {
312 removeFileOnSuccess = sessionFile;
313 setCurrentSession(jozFile, true);
314 return true;
315 }
316 Logging.warn("Asking user to choose a new location for the *.joz file because it already exists");
317 }
318
319 doGetFileChooser(zipRequired);
320 return false;
321 }
322
323 protected void doGetFileChooser(boolean zipRequired) throws UserCancelException {
324 AbstractFileChooser fc;
325
326 if (zipRequired) {
327 fc = createAndOpenFileChooser(false, false, tr("Save Session"), joz, JFileChooser.FILES_ONLY, "lastDirectory");
328 } else {
329 fc = createAndOpenFileChooser(false, false, tr("Save Session"), Arrays.asList(jos, joz), jos,
330 JFileChooser.FILES_ONLY, "lastDirectory");
331 }
332
333 if (fc == null) {
334 throw new UserCancelException();
335 }
336
337 File f = fc.getSelectedFile();
338 FileFilter ff = fc.getFileFilter();
339 boolean zip;
340
341 if (zipRequired || joz.equals(ff)) {
342 zip = true;
343 } else if (jos.equals(ff)) {
344 zip = false;
345 } else {
346 zip = Utils.hasExtension(f.getName(), "joz");
347 }
348 setCurrentSession(f, zip);
349 }
350
351 /**
352 * The "Save Session" dialog
353 */
354 public class SessionSaveAsDialog extends ExtendedDialog {
355
356 /**
357 * Constructs a new {@code SessionSaveAsDialog}.
358 */
359 public SessionSaveAsDialog() {
360 super(MainApplication.getMainFrame(), tr("Save Session"), tr("Save As"), tr("Cancel"));
361 configureContextsensitiveHelp("Action/SessionSaveAs", true /* show help button */);
362 initialize();
363 setButtonIcons("save_as", "cancel");
364 setDefaultButton(1);
365 setRememberWindowGeometry(getClass().getName() + ".geometry",
366 WindowGeometry.centerInWindow(MainApplication.getMainFrame(), new Dimension(450, 450)));
367 setContent(build(), false);
368 }
369
370 /**
371 * Initializes action.
372 */
373 public final void initialize() {
374 layers = new ArrayList<>(getLayerManager().getLayers());
375 exporters = new HashMap<>();
376 dependencies = new MultiMap<>();
377
378 Set<Layer> noExporter = new HashSet<>();
379
380 for (Layer layer : layers) {
381 SessionLayerExporter exporter = null;
382 try {
383 exporter = SessionWriter.getSessionLayerExporter(layer);
384 } catch (IllegalArgumentException | JosmRuntimeException e) {
385 Logging.error(e);
386 }
387 if (exporter != null) {
388 exporters.put(layer, exporter);
389 Collection<Layer> deps = exporter.getDependencies();
390 if (deps != null) {
391 dependencies.putAll(layer, deps);
392 } else {
393 dependencies.putVoid(layer);
394 }
395 } else {
396 noExporter.add(layer);
397 exporters.put(layer, null);
398 }
399 }
400
401 int numNoExporter = 0;
402 while (numNoExporter != noExporter.size()) {
403 numNoExporter = noExporter.size();
404 updateExporters(noExporter);
405 }
406 }
407
408 private void updateExporters(Collection<Layer> noExporter) {
409 for (Layer layer : layers) {
410 if (noExporter.contains(layer)) continue;
411 for (Layer depLayer : dependencies.get(layer)) {
412 if (noExporter.contains(depLayer)) {
413 noExporter.add(layer);
414 exporters.put(layer, null);
415 return;
416 }
417 }
418 }
419 }
420
421 protected final Component build() {
422 JPanel op = new JPanel(new GridBagLayout());
423 JPanel ip = new JPanel(new GridBagLayout());
424 for (Layer layer : layers) {
425 Component exportPanel;
426 SessionLayerExporter exporter = exporters.get(layer);
427 if (exporter == null) {
428 if (!exporters.containsKey(layer)) throw new AssertionError();
429 exportPanel = getDisabledExportPanel(layer);
430 } else {
431 exportPanel = exporter.getExportPanel();
432 }
433 if (exportPanel == null) continue;
434 JPanel wrapper = new JPanel(new GridBagLayout());
435 wrapper.setBorder(BorderFactory.createEtchedBorder(EtchedBorder.RAISED));
436 wrapper.add(exportPanel, GBC.std().fill(GBC.HORIZONTAL));
437 ip.add(wrapper, GBC.eol().fill(GBC.HORIZONTAL).insets(2, 2, 4, 2));
438 }
439 ip.add(GBC.glue(0, 1), GBC.eol().fill(GBC.VERTICAL));
440 JScrollPane sp = new JScrollPane(ip);
441 sp.setBorder(BorderFactory.createEmptyBorder());
442 JPanel p = new JPanel(new GridBagLayout());
443 p.add(sp, GBC.eol().fill());
444 final JTabbedPane tabs = new JTabbedPane();
445 tabs.addTab(tr("Layers"), p);
446 op.add(tabs, GBC.eol().fill());
447 JCheckBox chkSaveLocal = new JCheckBox(tr("Save all local files to disk"), SAVE_LOCAL_FILES_PROPERTY.get());
448 chkSaveLocal.addChangeListener(l -> SAVE_LOCAL_FILES_PROPERTY.put(chkSaveLocal.isSelected()));
449 op.add(chkSaveLocal, GBC.eol());
450 if (pluginsWantToSave()) {
451 JCheckBox chkSavePlugins = new JCheckBox(tr("Save plugin information to disk"), SAVE_PLUGIN_INFORMATION_PROPERTY.get());
452 chkSavePlugins.addChangeListener(l -> SAVE_PLUGIN_INFORMATION_PROPERTY.put(chkSavePlugins.isSelected()));
453 chkSavePlugins.setToolTipText(tr("Plugins may have additional information that can be saved"));
454 op.add(chkSavePlugins, GBC.eol());
455 }
456 return op;
457 }
458
459 protected final Component getDisabledExportPanel(Layer layer) {
460 JPanel p = new JPanel(new GridBagLayout());
461 JCheckBox include = new JCheckBox();
462 include.setEnabled(false);
463 JLabel lbl = new JLabel(layer.getName(), layer.getIcon(), SwingConstants.LEADING);
464 lbl.setToolTipText(tr("No exporter for this layer"));
465 lbl.setLabelFor(include);
466 lbl.setEnabled(false);
467 p.add(include, GBC.std());
468 p.add(lbl, GBC.std());
469 p.add(GBC.glue(1, 0), GBC.std().fill(GBC.HORIZONTAL));
470 return p;
471 }
472 }
473
474 protected void addListeners() {
475 MainApplication.addMapFrameListener(this);
476 MainApplication.getLayerManager().addLayerChangeListener(this);
477 }
478
479 protected void removeListeners() {
480 MainApplication.removeMapFrameListener(this);
481 MainApplication.getLayerManager().removeLayerChangeListener(this);
482 }
483
484 @Override
485 protected void updateEnabledState() {
486 setEnabled(MainApplication.isDisplayingMapView());
487 }
488
489 @Override
490 public void mapFrameInitialized(MapFrame oldFrame, MapFrame newFrame) {
491 updateEnabledState();
492 }
493
494 @Override
495 public void layerAdded(LayerAddEvent e) {
496 // not used
497 }
498
499 @Override
500 public void layerRemoving(LayerRemoveEvent e) {
501 if (e.isLastLayer()) {
502 setCurrentSession(null, false);
503 }
504 }
505
506 @Override
507 public void layerOrderChanged(LayerOrderChangeEvent e) {
508 // not used
509 }
510
511 /**
512 * Update the session file
513 * @param fileName The filename to use. If there are no periods in the file, we update the extension.
514 * @throws UserCancelException If the user does not want to overwrite a previously existing file.
515 */
516 private static void updateSessionFile(String fileName) throws UserCancelException {
517 if (fileName.indexOf('.') == -1) {
518 sessionFile = new File(sessionFile.getPath() + (isZipSessionFile ? ".joz" : ".jos"));
519 if (!SaveActionBase.confirmOverwrite(sessionFile)) {
520 throw new UserCancelException();
521 }
522 }
523 }
524
525 /**
526 * Sets the current session file and the layers included in that file
527 * @param file file
528 * @param zip if it is a zip session file
529 * @param layers layers that are currently represented in the session file
530 * @deprecated since 18833, use {@link #setCurrentSession(File, List, SessionWriter.SessionWriterFlags...)} instead
531 */
532 @Deprecated
533 public static void setCurrentSession(File file, boolean zip, List<Layer> layers) {
534 if (zip) {
535 setCurrentSession(file, layers, SessionWriter.SessionWriterFlags.IS_ZIP);
536 } else {
537 setCurrentSession(file, layers);
538 }
539 }
540
541 /**
542 * Sets the current session file and the layers included in that file
543 * @param file file
544 * @param layers layers that are currently represented in the session file
545 * @param flags The flags for the current session
546 * @since 18833
547 */
548 public static void setCurrentSession(File file, List<Layer> layers, SessionWriter.SessionWriterFlags... flags) {
549 final EnumSet<SessionWriter.SessionWriterFlags> flagSet = EnumSet.noneOf(SessionWriter.SessionWriterFlags.class);
550 flagSet.addAll(Arrays.asList(flags));
551 setCurrentSession(file, layers, flagSet);
552 }
553
554 /**
555 * Sets the current session file and the layers included in that file
556 * @param file file
557 * @param layers layers that are currently represented in the session file
558 * @param flags The flags for the current session
559 * @since 18833
560 */
561 public static void setCurrentSession(File file, List<Layer> layers, Set<SessionWriter.SessionWriterFlags> flags) {
562 setCurrentLayers(layers);
563 setCurrentSession(file, flags.contains(SessionWriter.SessionWriterFlags.IS_ZIP));
564 pluginData = flags.contains(SessionWriter.SessionWriterFlags.SAVE_PLUGIN_INFORMATION);
565 }
566
567 /**
568 * Sets the current session file
569 * @param file file
570 * @param zip if it is a zip session file
571 */
572 public static void setCurrentSession(File file, boolean zip) {
573 sessionFile = file;
574 isZipSessionFile = zip;
575 if (file == null) {
576 tooltip = TOOLTIP_DEFAULT;
577 } else {
578 tooltip = tr("Save the current session file \"{0}\".", file.getName());
579 }
580 getInstance().setTooltip(tooltip);
581 }
582
583 /**
584 * Sets the layers that are currently represented in the session file
585 * @param layers layers
586 */
587 public static void setCurrentLayers(List<Layer> layers) {
588 layersInSessionFile = layers.stream()
589 .filter(AbstractModifiableLayer.class::isInstance)
590 .map(WeakReference::new)
591 .collect(Collectors.toList());
592 }
593
594 /**
595 * Returns the tooltip for the component
596 * @return the tooltip for the component
597 */
598 public static String getTooltip() {
599 return tooltip;
600 }
601
602 /**
603 * Check to see if any plugins want to save their state
604 * @return {@code true} if the plugin wants to save their state
605 */
606 private static boolean pluginsWantToSave() {
607 for (PluginSessionExporter exporter : PluginHandler.load(PluginSessionExporter.class)) {
608 if (exporter.requiresSaving()) {
609 return true;
610 }
611 }
612 return false;
613 }
614
615}
Note: See TracBrowser for help on using the repository browser.