//License: GPL (v2 or later) package org.openstreetmap.josm.plugins.photo_geotagging; import static org.openstreetmap.josm.tools.I18n.tr; import java.awt.Component; import java.awt.Dimension; import java.awt.GridBagLayout; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.text.DecimalFormat; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.UUID; import javax.swing.AbstractAction; import javax.swing.BorderFactory; import javax.swing.DefaultListModel; import javax.swing.JCheckBox; import javax.swing.JComboBox; import javax.swing.JLabel; import javax.swing.JList; import javax.swing.JMenuItem; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.SwingUtilities; import javax.swing.UIManager; import org.openstreetmap.josm.Main; import org.openstreetmap.josm.gui.ExtendedDialog; import org.openstreetmap.josm.gui.PleaseWaitRunnable; import org.openstreetmap.josm.gui.dialogs.LayerListDialog; import org.openstreetmap.josm.gui.layer.Layer; import org.openstreetmap.josm.gui.layer.Layer.LayerAction; import org.openstreetmap.josm.gui.layer.geoimage.GeoImageLayer; import org.openstreetmap.josm.gui.layer.geoimage.ImageEntry; import org.openstreetmap.josm.tools.GBC; import org.openstreetmap.josm.tools.ImageProvider; /** * The action to ask the user for confirmation and then do the tagging. */ class GeotaggingAction extends AbstractAction implements LayerAction { final static boolean debug = false; final static String KEEP_BACKUP = "plugins.photo_geotagging.keep_backup"; final static String CHANGE_MTIME = "plugins.photo_geotagging.change-mtime"; final static String MTIME_MODE = "plugins.photo_geotagging.mtime-mode"; final static int MTIME_MODE_GPS = 1; final static int MTIME_MODE_PREVIOUS_VALUE = 2; public GeotaggingAction() { super(tr("Write coordinates to image header"), ImageProvider.get("geotagging")); } @Override public void actionPerformed(ActionEvent arg0) { GeoImageLayer layer = getLayer(); final List<ImageEntry> images = new ArrayList<>(); for (ImageEntry e : layer.getImages()) { /* Only write lat/lon to the file, if the position is known and the GPS data changed. */ if (e.getPos() != null && e.hasNewGpsData()) { images.add(e); } } final JPanel cont = new JPanel(new GridBagLayout()); cont.add(new JLabel(tr("Write position information into the exif header of the following files:")), GBC.eol()); DefaultListModel<String> listModel = new DefaultListModel<>(); DecimalFormat dFormatter = new DecimalFormat("###0.000000"); for (ImageEntry e : images) { listModel.addElement(e.getFile().getAbsolutePath()+ " ("+dFormatter.format(e.getPos().lat())+","+dFormatter.format(e.getPos().lon())+")"); } JList<String> entryList = new JList<>(listModel); JScrollPane scroll = new JScrollPane(entryList); scroll.setPreferredSize(new Dimension(900, 250)); cont.add(scroll, GBC.eol().fill(GBC.BOTH)); final JPanel settingsPanel = new JPanel(new GridBagLayout()); settingsPanel.setBorder(BorderFactory.createTitledBorder(tr("settings"))); cont.add(settingsPanel, GBC.eol().insets(3,10,3,0)); final JCheckBox backups = new JCheckBox(tr("keep backup files"), Main.pref.getBoolean(KEEP_BACKUP, true)); settingsPanel.add(backups, GBC.eol().insets(3,3,0,0)); final JCheckBox setMTime = new JCheckBox(tr("change file modification time:"), Main.pref.getBoolean(CHANGE_MTIME, false)); settingsPanel.add(setMTime, GBC.std().insets(3,3,5,3)); final String[] mTimeModeArray = {"----", tr("to gps time"), tr("to previous value (unchanged mtime)")}; final JComboBox<String> mTimeMode = new JComboBox<>(mTimeModeArray); { String mTimeModePref = Main.pref.get(MTIME_MODE, null); int mTimeIdx = 0; if ("gps".equals(mTimeModePref)) { mTimeIdx = 1; } else if ("previous".equals(mTimeModePref)) { mTimeIdx = 2; } mTimeMode.setSelectedIndex(setMTime.isSelected() ? mTimeIdx : 0); } settingsPanel.add(mTimeMode, GBC.eol().insets(3,3,3,3)); setMTime.addActionListener(new ActionListener(){ @Override public void actionPerformed(ActionEvent e) { if (setMTime.isSelected()) { mTimeMode.setEnabled(true); } else { mTimeMode.setSelectedIndex(0); mTimeMode.setEnabled(false); } } }); // Toggle the checkbox to fire actionListener setMTime.setSelected(!setMTime.isSelected()); setMTime.doClick(); int result = new ExtendedDialog( Main.parent, tr("Photo Geotagging Plugin"), new String[] {tr("OK"), tr("Cancel")}) .setButtonIcons(new String[] {"ok.png", "cancel.png"}) .setContent(cont) .setCancelButton(2) .setDefaultButton(1) .showDialog() .getValue(); if (result != 1) return; final boolean keep_backup = backups.isSelected(); final boolean change_mtime = setMTime.isSelected(); Main.pref.put(KEEP_BACKUP, keep_backup); Main.pref.put(CHANGE_MTIME, change_mtime); if (change_mtime) { String mTimeModePref; switch (mTimeMode.getSelectedIndex()) { case 1: mTimeModePref = "gps"; break; case 2: mTimeModePref = "previous"; break; default: mTimeModePref = null; } Main.pref.put(MTIME_MODE, mTimeModePref); } Main.worker.execute(new GeoTaggingRunnable(images, keep_backup, mTimeMode.getSelectedIndex())); } static class GeoTaggingRunnable extends PleaseWaitRunnable { final private List<ImageEntry> images; final private boolean keep_backup; final private int mTimeMode; private boolean canceled = false; private Boolean override_backup = null; private File fileFrom; private File fileTo; private File fileDelete; public GeoTaggingRunnable(List<ImageEntry> images, boolean keep_backup, int mTimeMode) { super(tr("Photo Geotagging Plugin")); this.images = images; this.keep_backup = keep_backup; this.mTimeMode = mTimeMode; } @Override protected void realRun() { progressMonitor.subTask(tr("Writing position information to image files...")); progressMonitor.setTicksCount(images.size()); for (int i=0; i<images.size(); ++i) { if (canceled) return; ImageEntry e = images.get(i); if (debug) { System.err.print("i:"+i+" "+e.getFile().getName()+" "); } fileFrom = null; fileTo = null; fileDelete = null; try { if (mTimeMode != 0) { testMTimeReadAndWrite(e.getFile()); } Long mTime = null; if (mTimeMode == MTIME_MODE_GPS) { // check GPS time fields, do nothing if all fails Date time; if (e.hasGpsTime()) { time = e.getGpsTime(); } else { time = e.getExifGpsTime(); } if (time != null) { mTime = time.getTime(); } } if ( mTimeMode == MTIME_MODE_PREVIOUS_VALUE // this is also the fallback if one of the other // modes failed to determine the modification time || (mTimeMode != 0 && mTime == null)) { mTime = e.getFile().lastModified(); if (mTime.equals(0L)) throw new IOException(tr("Could not read mtime.")); } chooseFiles(e.getFile()); if (canceled) return; ExifGPSTagger.setExifGPSTag(fileFrom, fileTo, e.getPos().lat(), e.getPos().lon(), e.getGpsTime(), e.getSpeed(), e.getElevation(), e.getExifImgDir()); if (mTime != null) { if (!fileTo.setLastModified(mTime)) throw new IOException(tr("Could not write mtime.")); } cleanupFiles(); e.unflagNewGpsData(); } catch (final IOException ioe) { ioe.printStackTrace(); // need this so the dialogs don't block SwingUtilities.invokeLater(new Runnable() { @Override public void run() { JOptionPane.showMessageDialog(Main.parent, ioe.getMessage(), tr("Error"), JOptionPane.ERROR_MESSAGE); } }); return; } progressMonitor.worked(1); if (debug) { System.err.println(""); } } } private void chooseFiles(File file) throws IOException { if (debug) { System.err.println("f: "+file.getAbsolutePath()); } if (!keep_backup) { chooseFilesNoBackup(file); return; } File fileBackup = new File(file.getParentFile(),file.getName()+"_"); if (fileBackup.exists()) { confirm_override(); if (canceled) return; if (override_backup) { if (!fileBackup.delete()) throw new IOException(tr("File could not be deleted!")); } else { chooseFilesNoBackup(file); return; } } if (!file.renameTo(fileBackup)) throw new IOException(tr("Could not rename file!")); fileFrom = fileBackup; fileTo = file; fileDelete = null; } private void chooseFilesNoBackup(File file) throws IOException { File fileTmp; //fileTmp = File.createTempFile("img", ".jpg", file.getParentFile()); // on win32, file.renameTo(fileTmp) does not work when the destination file exists // see https://bugs.openjdk.java.net/browse/JDK-4017593 // so we cannot use createTempFile(), which would create that "existing destination file" // instead, let's use new File(), which doesn't actually create a file // for getting a unique file name, we use UUID.randomUUID() do { fileTmp = new File(file.getParentFile(), "img" + UUID.randomUUID() + ".jpg"); } while (fileTmp.exists()); if (debug) { System.err.println("TMP: "+fileTmp.getAbsolutePath()); } try { Files.move(file.toPath(), fileTmp.toPath()); } catch (IOException e) { Main.error(tr("Could not rename file {0} to {1}!", file, fileTmp)); throw e; } fileFrom = fileTmp; fileTo = file; fileDelete = fileTmp; } private void confirm_override() { if (override_backup != null) return; try { SwingUtilities.invokeAndWait(new Runnable() { @Override public void run() { JLabel l = new JLabel(tr("<html><h3>There are old backup files in the image directory!</h3>")); l.setIcon(UIManager.getIcon("OptionPane.warningIcon")); int override = new ExtendedDialog( progressMonitor.getWindowParent(), tr("Override old backup files?"), new String[] {tr("Cancel"), tr("Keep old backups and continue"), tr("Override")}) .setButtonIcons(new String[] {"cancel.png", "ok.png", "dialogs/delete.png"}) .setContent(l) .setCancelButton(1) .setDefaultButton(2) .showDialog() .getValue(); if (override == 2) { override_backup = false; } else if (override == 3) { override_backup = true; } else { canceled = true; } } }); } catch (Exception e) { System.err.println(e); canceled = true; } } private void cleanupFiles() throws IOException { if (fileDelete != null) { if (!fileDelete.delete()) throw new IOException(tr("Could not delete temporary file!")); } } boolean testMTimeReadAndWriteDone = false; private void testMTimeReadAndWrite(File file) throws IOException { if (testMTimeReadAndWriteDone) // do this only once return; File fileTest = File.createTempFile("geo", ".txt", file.getParentFile()); long mTimeTest = fileTest.lastModified(); if (mTimeTest == 0L) throw new IOException(tr("Test failed: Could not read mtime.")); if (!fileTest.setLastModified(mTimeTest)) throw new IOException(tr("Test failed: Could not write mtime.")); if (!fileTest.delete()) throw new IOException(tr("Could not delete temporary file!")); testMTimeReadAndWriteDone = true; } @Override protected void finish() { } @Override protected void cancel() { canceled = true; } } private GeoImageLayer getLayer() { return (GeoImageLayer)LayerListDialog.getInstance().getModel().getSelectedLayers().get(0); } /** * Check if there is any suitable image. */ private boolean enabled(GeoImageLayer layer) { for (ImageEntry e : layer.getImages()) { if (e.getPos() != null && e.hasNewGpsData()) return true; } return false; } @Override public Component createMenuComponent() { JMenuItem geotaggingItem = new JMenuItem(this); geotaggingItem.setEnabled(enabled(getLayer())); return geotaggingItem; } @Override public boolean supportLayers(List<Layer> layers) { return layers.size() == 1 && layers.get(0) instanceof GeoImageLayer; } }