content: package tim.prune.save;
  
  import java.awt.BorderLayout;
  import java.awt.Dimension;
  import java.awt.FlowLayout;
  import java.awt.Frame;
  import java.io.File;
  
  import javax.swing.BorderFactory;
  import javax.swing.BoxLayout;
  import javax.swing.JButton;
  import javax.swing.JCheckBox;
  import javax.swing.JDialog;
  import javax.swing.JLabel;
  import javax.swing.JOptionPane;
  import javax.swing.JPanel;
  import javax.swing.JProgressBar;
  import javax.swing.JScrollPane;
  import javax.swing.JTable;
  
  import tim.prune.ExternalTools;
  import tim.prune.I18nManager;
  import tim.prune.UpdateMessageBroker;
  import tim.prune.data.Coordinate;
  import tim.prune.data.DataPoint;
  import tim.prune.data.MediaList;
  import tim.prune.data.Photo;
  
  /**
   * Class to call Exiftool to save coordinate information in jpg files
   */
  public class ExifSaver implements Runnable
  {
  	private final Frame _parentFrame;
  	private final String _exiftoolCommand;
  	private JDialog _dialog = null;
  	private JButton _okButton = null;
  	private JCheckBox _overwriteCheckbox = null;
  	private JCheckBox _forceCheckbox = null;
  	private JProgressBar _progressBar = null;
  	private PhotoTableModel _photoTableModel = null;
  	private boolean _saveCancelled = false;
  
  
  	// To preserve timestamps of file use parameter -P
  	// To overwrite file (careful!) use parameter -overwrite_original_in_place
  
  	// To read all GPS tags,   use -GPS:All
  	// To delete all GPS tags, use -GPS:All=
  
  	// To set Altitude, use -GPSAltitude= and -GPSAltitudeRef=
  	// To set Latitude, use -GPSLatitude= and -GPSLatitudeRef=
  
  	// To delete all tags with overwrite: exiftool -P -overwrite_original_in_place -GPS:All= <filename>
  
  	// To set altitude with overwrite: exiftool -P -overwrite_original_in_place -GPSAltitude=1234 -GPSAltitudeRef='Above Sea Level' <filename>
  	// (setting altitude ref to 0 doesn't work)
  	// To set latitude with overwrite: exiftool -P -overwrite_original_in_place -GPSLatitude='12 34 56.78' -GPSLatitudeRef=N <filename>
  	// (latitude as space-separated deg min sec, reference as either N or S)
  	// Same for longitude, reference E or W
  
  
  	/**
  	 * Constructor
  	 * @param inParentFrame parent frame
  	 * @param inCommand exiftool command including path
  	 */
  	public ExifSaver(Frame inParentFrame, String inCommand)
  	{
  		_parentFrame = inParentFrame;
  		_exiftoolCommand = inCommand;
  	}
  
  
  	/**
  	 * Save exif information to all photos in the list
  	 * whose coordinate information has changed since loading
  	 * @param inPhotoList list of photos to save
  	 * @return true if saved
  	 */
  	public boolean saveExifInformation(MediaList<Photo> inPhotoList)
  	{
  		// Check if external exif tool can be called
  		boolean exifToolInstalled = ExternalTools.isToolInstalled(ExternalTools.TOOL_EXIFTOOL, _exiftoolCommand);
  		if (!exifToolInstalled)
  		{
  			// show warning
  			int answer = JOptionPane.showConfirmDialog(_dialog, I18nManager.getText("dialog.saveexif.noexiftool"),
  				I18nManager.getText("dialog.saveexif.title"),
  				JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE);
  			if (answer == JOptionPane.NO_OPTION || answer == JOptionPane.CLOSED_OPTION)
  			{
  				return false;
  			}
  		}
  		// Make model and add all photos to it
  		_photoTableModel = new PhotoTableModel(inPhotoList.getCount());
  		for (int i=0; i<inPhotoList.getCount(); i++)
  		{
  			Photo photo = inPhotoList.get(i);
  			PhotoTableEntry entry = new PhotoTableEntry(photo);
  			_photoTableModel.addPhotoInfo(entry);
  		}
  		// Check if there are any modified photos to save
  		if (_photoTableModel.getNumSaveablePhotos() < 1)
  		{
  			JOptionPane.showMessageDialog(_parentFrame, I18nManager.getText("dialog.saveexif.nothingtosave"),
  				I18nManager.getText("dialog.saveexif.title"), JOptionPane.WARNING_MESSAGE);
  			return false;
  		}
  		// Construct dialog
  		_dialog = new JDialog(_parentFrame, I18nManager.getText("dialog.saveexif.title"), true);
  		_dialog.setLocationRelativeTo(_parentFrame);
  		_dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
  		_dialog.getContentPane().add(makeDialogComponents());
  		_dialog.pack();
  		// set progress bar and show dialog
  		_progressBar.setVisible(false);
  		_dialog.setVisible(true);
  		return true;
  	}
  
  
  	/**
  	 * Put together the dialog components for adding to the gui
  	 * @return panel containing all gui components
  	 */
  	private JPanel makeDialogComponents()
  	{
  		JPanel panel = new JPanel();
  		panel.setLayout(new BorderLayout());
  		// Label at top
  		JLabel topLabel = new JLabel(I18nManager.getText("dialog.saveexif.intro"));
  		topLabel.setBorder(BorderFactory.createEmptyBorder(8, 6, 5, 6));
  		panel.add(topLabel, BorderLayout.NORTH);
  		// centre panel with most controls
  		JPanel centrePanel = new JPanel();
  		centrePanel.setLayout(new BorderLayout());
  		// table panel with table and checkbox
  		JPanel tablePanel = new JPanel();
  		tablePanel.setLayout(new BorderLayout());
  		JTable photoTable = new JTable(_photoTableModel);
  		JScrollPane scrollPane = new JScrollPane(photoTable);
  		scrollPane.setPreferredSize(new Dimension(300, 160));
  		tablePanel.add(scrollPane, BorderLayout.CENTER);
  		// Pair of checkboxes
  		JPanel checkPanel = new JPanel();
  		checkPanel.setLayout(new BoxLayout(checkPanel, BoxLayout.Y_AXIS));
  		_overwriteCheckbox = new JCheckBox(I18nManager.getText("dialog.saveexif.overwrite"));
  		_overwriteCheckbox.setSelected(false);
  		checkPanel.add(_overwriteCheckbox);
  		_forceCheckbox = new JCheckBox(I18nManager.getText("dialog.saveexif.force"));
  		_forceCheckbox.setSelected(false);
  		checkPanel.add(_forceCheckbox);
  		tablePanel.add(checkPanel, BorderLayout.SOUTH);
  		centrePanel.add(tablePanel, BorderLayout.CENTER);
  		// progress bar below main controls
  		_progressBar = new JProgressBar(0, 100);
  		centrePanel.add(_progressBar, BorderLayout.SOUTH);
  		panel.add(centrePanel, BorderLayout.CENTER);
  		// Right-hand panel with select all, none buttons
  		JPanel rightPanel = new JPanel();
  		rightPanel.setLayout(new BoxLayout(rightPanel, BoxLayout.Y_AXIS));
  		JButton selectAllButton = new JButton(I18nManager.getText("button.selectall"));
  		selectAllButton.addActionListener(e -> selectPhotos(true));
  		rightPanel.add(selectAllButton);
  		JButton selectNoneButton = new JButton(I18nManager.getText("button.selectnone"));
  		selectNoneButton.addActionListener(e -> selectPhotos(false));
  		rightPanel.add(selectNoneButton);
  		panel.add(rightPanel, BorderLayout.EAST);
  		// Lower panel with ok and cancel buttons
  		JPanel buttonPanel = new JPanel();
  		buttonPanel.setLayout(new FlowLayout(FlowLayout.RIGHT));
  		_okButton = new JButton(I18nManager.getText("button.ok"));
  		_okButton.addActionListener(e -> {
  			// disable ok button
  			_okButton.setEnabled(false);
  			// start new thread to do save
  			new Thread(ExifSaver.this).start();
  		});
  		buttonPanel.add(_okButton);
  		JButton cancelButton = new JButton(I18nManager.getText("button.cancel"));
  		cancelButton.addActionListener(e -> {
  			_saveCancelled = true;
  			_dialog.dispose();
  		});
  		buttonPanel.add(cancelButton);
  		panel.add(buttonPanel, BorderLayout.SOUTH);
  		return panel;
  	}
  
  
  	/**
  	 * Select all or select none
  	 * @param inSelected true to select all photos, false to deselect all
  	 */
  	private void selectPhotos(boolean inSelected)
  	{
  		int numPhotos = _photoTableModel.getRowCount();
  		for (int i=0; i<numPhotos; i++) {
  			_photoTableModel.getPhotoTableEntry(i).setSaveFlag(inSelected);
  		}
  		_photoTableModel.fireTableDataChanged();
  	}
  
  
  	/**
  	 * Run method for saving in separate thread
  	 */
  	public void run()
  	{
  		_saveCancelled = false;
  		PhotoTableEntry entry = null;
  		Photo photo = null;
  		int numPhotos = _photoTableModel.getRowCount();
  		_progressBar.setMaximum(numPhotos);
  		_progressBar.setValue(0);
  		_progressBar.setVisible(true);
  		boolean overwriteFlag = _overwriteCheckbox.isSelected();
  		int numSaved = 0, numFailed = 0, numForced = 0;
  		// Loop over all photos in list
  		for (int i=0; i<numPhotos; i++)
  		{
  			entry = _photoTableModel.getPhotoTableEntry(i);
  			if (entry != null && entry.getSaveFlag() && !_saveCancelled)
  			{
  				// Only look at photos which are selected and whose status has changed since load
  				photo = entry.getPhoto();
  				if (photo != null && photo.isModified())
  				{
  					// Increment counter if save successful
  					if (savePhoto(photo, overwriteFlag, false)) {
  						numSaved++;
  					}
  					else if (_forceCheckbox.isSelected() && savePhoto(photo, overwriteFlag, true))
  					{
  						numForced++;
  					}
  					else {
  						numFailed++;
  					}
  				}
  			}
  			// update progress bar
  			_progressBar.setValue(i + 1);
  		}
  		_progressBar.setVisible(false);
  		// Show confirmation
  		UpdateMessageBroker.informSubscribers(I18nManager.getTextWithNumber("confirm.saveexif.ok", numSaved));
  		if (numFailed > 0)
  		{
  			JOptionPane.showMessageDialog(_parentFrame,
  				I18nManager.getTextWithNumber("error.saveexif.failed", numFailed),
  				I18nManager.getText("dialog.saveexif.title"), JOptionPane.ERROR_MESSAGE);
  		}
  		if (numForced > 0)
  		{
  			JOptionPane.showMessageDialog(_parentFrame,
  				I18nManager.getTextWithNumber("error.saveexif.forced", numForced),
  				I18nManager.getText("dialog.saveexif.title"), JOptionPane.WARNING_MESSAGE);
  		}
  		// close dialog, all finished
  		_dialog.dispose();
  	}
  
  
  	/**
  	 * Save the details for the given photo
  	 * @param inPhoto Photo object
  	 * @param inOverwriteFlag true to overwrite file, false otherwise
  	 * @param inForceFlag true to force write, ignoring minor errors
  	 * @return true if details saved ok
  	 */
  	private boolean savePhoto(Photo inPhoto, boolean inOverwriteFlag, boolean inForceFlag)
  	{
  		// If photos don't have a file, then can't save them
  		if (inPhoto.getFile() == null) {
  			return false;
  		}
  		// Check whether photo file still exists
  		if (!inPhoto.getFile().exists())
  		{
  			// photo file doesn't exist any more
  			JOptionPane.showMessageDialog(_parentFrame,
  				I18nManager.getText("error.saveexif.filenotfound") + " : " + inPhoto.getFile().getAbsolutePath(),
  				I18nManager.getText("dialog.saveexif.title"), JOptionPane.ERROR_MESSAGE);
  			return false;
  		}
  		// Warn if file read-only and selected to overwrite
  		if (inOverwriteFlag && !inPhoto.getFile().canWrite())
  		{
  			// eek, can't overwrite file
  			int answer = JOptionPane.showConfirmDialog(_parentFrame,
  				I18nManager.getText("error.saveexif.cannotoverwrite", inPhoto.getFile().getAbsolutePath()),
  				I18nManager.getText("dialog.saveexif.title"),
  				JOptionPane.YES_NO_OPTION, JOptionPane.ERROR_MESSAGE);
  			if (answer == JOptionPane.YES_OPTION)
  			{
  				// don't overwrite this image but write to copy
  				inOverwriteFlag = false;
  			}
  			else
  			{
  				// don't do anything with this file
  				return false;
  			}
  		}
  		String[] command = null;
  		if (inPhoto.getCurrentStatus() == Photo.Status.NOT_CONNECTED)
  		{
  			// Photo is no longer connected, so delete gps tags
  			command = getDeleteGpsExifTagsCommand(inPhoto.getFile(), inOverwriteFlag);
  		}
  		else
  		{
  			// Photo is now connected, so write new gps tags
  			command = getWriteGpsExifTagsCommand(inPhoto.getFile(), inPhoto.getDataPoint(), inOverwriteFlag, inForceFlag);
  		}
  		// Execute exif command
  		boolean saved = false;
  		try
  		{
  			Process process = Runtime.getRuntime().exec(command);
  			// Wait for process to finish so not too many run in parallel
  			try {
  				process.waitFor();
  			}
  			catch (InterruptedException ie) {}
  			saved = (process.exitValue() == 0);
  		}
  		catch (Exception e)
  		{
  			// show error message
  			JOptionPane.showMessageDialog(_parentFrame, "Exception: '" + e.getClass().getName() + "' : "
  				+ e.getMessage(), I18nManager.getText("dialog.saveexif.title"), JOptionPane.ERROR_MESSAGE);
  		}
  		return saved;
  	}
  
  
  	/**
  	 * Create the command to delete the gps exif tags from the specified file
  	 * @param inFile file from which to delete tags
  	 * @param inOverwrite true to overwrite file, false to create copy
  	 * @return external command to delete gps tags
  	 */
  	private String[] getDeleteGpsExifTagsCommand(File inFile, boolean inOverwrite)
  	{
  		// Make a string array to construct the command and its parameters
  		String[] result = new String[inOverwrite ? 5 : 4];
  		result[0] = _exiftoolCommand;
  		result[1] = "-P";
  		if (inOverwrite) {result[2] = " -overwrite_original_in_place";}
  		// remove all gps tags
  		int paramOffset = inOverwrite?3:2;
  		result[paramOffset] = "-GPS:All=";
  		result[paramOffset + 1] = inFile.getAbsolutePath();
  		return result;
  	}
  
  
  	/**
  	 * Create the comand to write the gps exif tags to the specified file
  	 * @param inFile file to which to write the tags
  	 * @param inPoint DataPoint object containing coordinate information
  	 * @param inOverwrite true to overwrite file, false to create copy
  	 * @param inForce true to force write, ignoring minor errors
  	 * @return external command to write gps tags
  	 */
  	private String[] getWriteGpsExifTagsCommand(File inFile, DataPoint inPoint,
  		boolean inOverwrite, boolean inForce)
  	{
  		// Make a string array to construct the command and its parameters
  		String[] result = new String[(inOverwrite?10:9) + (inForce?1:0)];
  		result[0] = _exiftoolCommand;
  		result[1] = "-P";
  		if (inOverwrite) {result[2] = "-overwrite_original_in_place";}
  		int paramOffset = inOverwrite?3:2;
  		if (inForce) {
  			result[paramOffset] = "-m";
  			paramOffset++;
  		}
  		// To set latitude : -GPSLatitude='12 34 56.78' -GPSLatitudeRef='N'
  		// (latitude as space-separated deg min sec, reference as either N or S)
  		result[paramOffset] = "-GPSLatitude='" + inPoint.getLatitude().output(Coordinate.Format.DEG_MIN_SEC_WITH_SPACES)
  		 + "'";
  		result[paramOffset + 1] = "-GPSLatitudeRef=" + inPoint.getLatitude().output(Coordinate.Format.JUST_CARDINAL);
  		// same for longitude with space-separated deg min sec, reference as either E or W
  		result[paramOffset + 2] = "-GPSLongitude='" + inPoint.getLongitude().output(Coordinate.Format.DEG_MIN_SEC_WITH_SPACES)
  		 + "'";
  		result[paramOffset + 3] = "-GPSLongitudeRef=" + inPoint.getLongitude().output(Coordinate.Format.JUST_CARDINAL);
  		// add altitude if it has it
  		result[paramOffset + 4] = "-GPSAltitude="
  		 + (inPoint.hasAltitude()?inPoint.getAltitude().getMetricValue():0);
  		result[paramOffset + 5] = "-GPSAltitudeRef='Above Sea Level'";
  		// add the filename to modify
  		result[paramOffset + 6] = inFile.getAbsolutePath();
  		return result;
  	}
  }
  