# HG changeset patch # User David Barts # Date 1586402952 25200 # Node ID 19c381c536ec2ad07316405d7aa88c1bade2ca9a # Parent efd9fe2d70d7fe33904082e6061d89a1b8b7b364 Code to make it a proper Mac GUI app. Untested! diff -r efd9fe2d70d7 -r 19c381c536ec src/name/blackcap/exifwasher/Files.kt --- a/src/name/blackcap/exifwasher/Files.kt Wed Apr 01 14:23:54 2020 -0700 +++ b/src/name/blackcap/exifwasher/Files.kt Wed Apr 08 20:29:12 2020 -0700 @@ -62,6 +62,8 @@ /* make some usable objects */ +val CHARSET = "UTF-8" + val DPROPERTIES = Properties().apply { OS::class.java.getResourceAsStream("default.properties").use { load(it) } } @@ -69,11 +71,13 @@ val PROPERTIES = Properties(DPROPERTIES).apply { PF_DIR.makeIfNeeded() PROP_FILE.createNewFile() - BufferedReader(InputStreamReader(FileInputStream(PROP_FILE), "UTF-8")).use { + BufferedReader(InputStreamReader(FileInputStream(PROP_FILE), CHARSET)).use { load(it) } } +System.setProperty("java.util.logging.SimpleFormatter.format", + "%1$tFT%1$tT%1$tz %2$s%n%4$s: %5$s%6$s%n") val LOGGER = run { LF_DIR.makeIfNeeded() Logger.getLogger(LONGNAME).apply { diff -r efd9fe2d70d7 -r 19c381c536ec src/name/blackcap/exifwasher/Main.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/name/blackcap/exifwasher/Main.kt Wed Apr 08 20:29:12 2020 -0700 @@ -0,0 +1,32 @@ +/* + * Entry point, etc. + */ +package name.blackcap.exifwasher + +import javax.swing.UIManager + +object Application { + /* name we call ourselves */ + val MYNAME = "ExifWasher" + + /* global UI objects */ + var mainFrame: MainFrame by SetOnce() + var settingsDialog: SettingsDialog by SetOnce() + + fun initialize() { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + mainFrame = MainFrame() /* must be created first */ + settingsDialog = SettingsDialog() + mainFrame.setVisible(true) + } +} + +fun main(args: Array) { + LOGGER.log(Level.INFO, "beginning execution") + if (OS.type == OS.MAC) { + System.setProperty("apple.laf.useScreenMenuBar", "true") + } + inSwingThread { + Application.initialize() + } +} diff -r efd9fe2d70d7 -r 19c381c536ec src/name/blackcap/exifwasher/MainFrame.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/name/blackcap/exifwasher/MainFrame.kt Wed Apr 08 20:29:12 2020 -0700 @@ -0,0 +1,76 @@ +/* + * The main application window. + */ +package name.blackcap.exifwasher + +import java.awt.Dimension +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.Transferable +import java.awt.datatransfer.UnsupportedFlavorException +import java.io.File +import java.io.IOException +import javax.swing.JFrame +import javax.swing.TransferHandler + +class MainFrame: JFrame { + +/* the main frame itself */ +class MainFrame : JFrame(Application.MYNAME) { + /* default size */ + val WIDTH = 512 + val HEIGHT = 384 + + /* does a system exit when needed */ + private class KillIt() : WindowListener { + /* events we don't care about */ + override fun windowActivated(e: WindowEvent) {} + override fun windowClosed(e: WindowEvent) {} + override fun windowDeactivated(e: WindowEvent) {} + override fun windowDeiconified(e: WindowEvent) {} + override fun windowIconified(e: WindowEvent) {} + override fun windowOpened(e: WindowEvent) {} + + /* and the one we do */ + override fun windowClosing(e: WindowEvent) { + LOGGER.log(Level.INFO, "execution complete") + System.exit(0) + } + } + + /* acts on dragged files */ + private class MyTransferHandler : TransferHandler { + override fun canImport(support: TransferHandler.TransferSupport): Boolean { + return support.isDataFlavorSupported(DataFlavor.javaFileListFlavor) + } + + override fun importData(support: TransferHandler.TransferSupport): Boolean { + if (!canImport(support)) { + return false + } + val files = try { + support.transferable.getTransferData(DataFlavor.javaFileListFlavor) as java.util.List + } catch (e: UnsupportedFlavorException) { + return false + } catch (e: IOException) { + return false + } + for (file in files) { + WashDialog().wash(file) + } + return true + } + } + + init { + contentPane.add( + JLabel("Drag image files into this window or choose File… Open from menu.").apply { + horizontalAlignment = JLabel.CENTER + verticalAlignment = JLabel.CENTER + }) + preferredSize = Dimension(WIDTH, HEIGHT) + transferHandler = MyTransferHandler() + pack() + addWindowListener(KillIt()) + } +} + diff -r efd9fe2d70d7 -r 19c381c536ec src/name/blackcap/exifwasher/Menus.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/name/blackcap/exifwasher/Menus.kt Wed Apr 08 20:29:12 2020 -0700 @@ -0,0 +1,76 @@ +/* + * Menus. + */ +package name.blackcap.exifwasher + +import java.awt.event.ActionEvent +import java.awt.event.ActionListener +import java.awt.event.KeyEvent +import javax.swing.* + +/** + * Our menu bar. What we display depends somewhat on the system type, as + * the Mac gives us a gratuitous menu bar entry for handling some stuff. + */ +class MyMenuBar: JMenuBar() { + init { + add(JMenu("File").apply { + add(JMenuItem("Wash…").apply { + addActionListener(ActionListener { doWash() }) + makeShortcut(KeyEvent.VK_W) + }) + if (OS.type != OS.MAC) { + add(JMenuItem("Preferences…").apply { + addActionListener(ActionListener { + Application.settingsDialog.setVisible(true) + }) + makeShortcut(KeyEvent.VK_COMMA) + }) + add(JMenuItem("Quit").apply { + addActionListener(ActionListener { System.exit(0) }) + makeShortcut(KeyEvent.VK_Q) + }) + } + }) + if (OS.type != OS.MAC) { + add(JMenu("Help").apply { + add(JMenuItem("About ClipMan…").apply { + addActionListener(ActionListener { showAboutDialog() }) + }) + }) + } + } + + fun getMenu(name: String): JMenu? { + subElements.forEach { + val jmenu = it.component as? JMenu + if (jmenu?.text == name) { + return jmenu + } + } + return null + } + + fun doWash() { + val fc = JFileChooser().apply { + setMultiSelectionEnabled(true) + } + val status = fc.showOpenDialog(Application.mainFrame) + if (status == JFileChooser.APPROVE_OPTION) { + for (file in fc.getSelectedFiles()) { + WashDialog().wash(file) + } + } + } +} + +/** + * Show an About dialog. + */ +fun showAboutDialog() { + JOptionPane.showMessageDialog(frame.v, + "ExifWasher—Privacy for your photos.\n" + + "© MMXX, David W. Barts", + "About ExifWasher", + JOptionPane.PLAIN_MESSAGE) +} diff -r efd9fe2d70d7 -r 19c381c536ec src/name/blackcap/exifwasher/Misc.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/name/blackcap/exifwasher/Misc.kt Wed Apr 08 20:29:12 2020 -0700 @@ -0,0 +1,163 @@ +/* + * Miscellaneous utility stuff. + */ +package name.blackcap.exifwasher + +import java.awt.Component +import java.awt.Cursor +import java.awt.Toolkit +import javax.swing.* +import kotlin.annotation.* +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +/** + * Delegate that makes a var that can only be set once. This is commonly + * needed in Swing, because some vars inevitably need to be declared at + * outer levels but initialized in the Swing event dispatch thread. + */ +class SetOnce: ReadWriteProperty { + private var value: T? = null + + override operator fun getValue(thisRef: Any?, property: KProperty<*>): T { + if (value == null) { + throw RuntimeException("${property.name} has not been initialized") + } else { + return value!! + } + } + + @Synchronized + override operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: T): Unit { + if (value != null) { + throw RuntimeException("${property.name} has already been initialized") + } + value = newValue + } +} + +/** + * Run something in the Swing thread, asynchronously. + * @param block lambda containing code to run + */ +fun inSwingThread(block: () -> Unit) { + SwingUtilities.invokeLater(Runnable(block)) +} + +/** + * Run something in the Swing thread, synchronously. + * @param block lambda containing code to run + */ +fun inSynSwingThread(block: () -> Unit) { + SwingUtilities.invokeAndWait(Runnable(block)) +} + +/** + * Make a shortcut for a menu item, using the standard combining key + * (control, command, etc.) for the system we're on. + * @param key KeyEvent constant describing the key + */ +fun JMenuItem.makeShortcut(key: Int): Unit { + val SC_KEY_MASK = Toolkit.getDefaultToolkit().menuShortcutKeyMask + setAccelerator(KeyStroke.getKeyStroke(key, SC_KEY_MASK)) +} + +/** + * Given a MenuElement object, get the item whose text matches the + * specified text. + * @param text to match + * @return first matched element, null if no match found + */ +fun MenuElement.getItem(name: String) : JMenuItem? { + subElements.forEach { + val jMenuItem = it.component as? JMenuItem + if (jMenuItem?.text == name) { + return jMenuItem + } + } + return null +} + +/** + * Change to the standard wait cursor. + */ +fun Component.useWaitCursor() { + this.cursor = Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR)) +} + +/** + * Return back to the normal cursor(). + */ +fun Component.useNormalCursor() { + this.cursor = Cursor.defaultCursor +} + +/** + * Thrown if the programmer botches something in our DSL. + */ +class SwingWorkerException(message: String): Exception(message) { } + +/** + * A simplified SwingWorker DSL. It does not support intermediate + * results. Just lets one define a background task and something + * to execute when complete. + * + * @param T Type returned by inBackground (Java doInBackground) task. + */ +class SwingWorkerBuilder: SwingWorker() { + private var inBackgroundLambda: (SwingWorkerBuilder.() -> T)? = null + private var whenDoneLambda: (SwingWorkerBuilder.() -> Unit)? = null + + private fun setOnce(prop: KMutableProperty<(SwingWorkerBuilder.() -> U)?>, value: SwingWorkerBuilder.() -> U) { + if (prop.get() != null) { + throw SwingWorkerException(prop.name.removeSuffix("Lambda") + " already defined!") + } + prop.set(value) + } + + /** + * Define the inBackground task. + */ + fun inBackground(lambda: SwingWorkerBuilder.() -> T): Unit { + setOnce(::inBackgroundLambda, lambda) + } + + /** + * Define the whenDone task. + */ + fun whenDone(lambda: SwingWorkerBuilder.() -> Unit): Unit { + setOnce(::whenDoneLambda, lambda) + } + + /* standard overrides for SwingWorker follow */ + + override fun doInBackground(): T = inBackgroundLambda?.invoke(this) + + override fun done(): Unit = whenDoneLambda?.invoke(this) + + override fun execute(): Unit { + if (inBackgroundLambda == null) { + throw SwingWorkerException("inBackground not defined!") + } else { + super.execute() + } + } +} + +/** + * Provides for an outer swingWorker block to contain the DSL. + */ +fun swingWorker(initializer: SwingWorkerBuilder.() -> Unit): Unit { + SwingWorkerBuilder().run { + initializer() + execute() + } +} + +/** + * Close a dialog (don't just hide it). + */ +fun JDialog.close() { + setVisible(false) + dispose() +} diff -r efd9fe2d70d7 -r 19c381c536ec src/name/blackcap/exifwasher/Osdep.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/name/blackcap/exifwasher/Osdep.kt Wed Apr 08 20:29:12 2020 -0700 @@ -0,0 +1,16 @@ +/* + * OS-dependent code, Mac version. + */ +package name.blackcap.clipman + +import com.apple.eawt.AboutHandler +import com.apple.eawt.Application +import com.apple.eawt.PreferencesHandler + +fun setMacMenus() { + Application.getApplication().run { + setAboutHandler(AboutHandler({ showAboutDialog() })) + setPreferencesHandler( + PreferencesHandler({ settingsDialog.setVisible(true) })) + } +} diff -r efd9fe2d70d7 -r 19c381c536ec src/name/blackcap/exifwasher/SettingsDialog.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/name/blackcap/exifwasher/SettingsDialog.kt Wed Apr 08 20:29:12 2020 -0700 @@ -0,0 +1,336 @@ +/* + * The dialog that controls our settings. + */ +package name.blackcap.exifwasher + +import java.awt.Toolkit +import java.awt.event.ActionEvent +import java.awt.event.ActionListener +import java.io.File +import java.io.IOException +import java.util.logging.Level +import java.util.logging.Logger +import javax.swing.* +import javax.swing.event.ListDataEvent +import javax.swing.event.ListDataListener +import kotlin.text.toBoolean + +import name.blackcap.exifwasher.exiv2.* + +class SettingsDialog : JDialog(Application.mainFrame) { + private val BW = 9 + private val BW2 = BW * 2 + + /* where to send output, if not outputToInputDir */ + private var _outputTo = PROPERTIES.getProperty("outputTo") + private var _dOutputTo = DPROPERTIES.getProperty("outputTo") + val outputTo: String + get() = _outputTo + + /* make output where input was found */ + private var _outputToInputDir = (PROPERTIES.getProperty("outputToInputDir") ?: "false").toBoolean() + private var _dOutputToInputDir = (DPROPERTIES.getProperty("outputToInputDir") ?: "false").toBoolean() + val outputToInputDir: Boolean + get() = _outputToInputDir + + /* the whitelist of allowed Exif tags */ + private var _whitelist = Whitelist.parse(PROPERTIES.getProperty("whitelist") :? "") + val whitelist: Whitelist + get() = _whitelist + + /* the default whitelist, for factory resets */ + private val _oWhitelist = _whitelist.clone() + private val _dWhitelist = Whitelist.parse(DPROPERTIES.getProperty("whitelist") :? "") + + /* radio buttons to choose output directory policy */ + private val outputToButton = JRadioButton("Output to:", !outputToInputDir).apply { + addActionListener(ActionListener { setOutputOpts(isSelected()) }) + } + private val outputToInputButton = JRadioButton( + "Output to directory containing input file.", outputToInputDir).apply { + addActionListener(ActionListener { setOutputOpts(!isSelected()) }) + } + private val buttonGroup = ButtonGroup().apply { + add(outputToButton) + add(outputToInputButton) + } + private fun setOutputOpts(toSpecific: Boolean) { + _outputToInputDir = !toSpecific + changeOutputTo.setEnabled(toSpecific) + } + + /* displays the OutputTo directory */ + private val outputToText = JTextField(outputTo, 50).apply { + setEditable(false) + } + + /* pops up to change the above directory */ + private val outputToChooser = JFileChooser(outputToText.text).apply { + fileSelectionMode = DIRECTORIES_ONLY + } + + /* requests the OutputTo directory be changed */ + private val changeOutputTo = JButton("Change").also { + it.addActionListener(ActionListener { + val status = outputToChooser.showOpenDialog(this) + if (status == JFileChooser.APPROVE_OPTION) { + _outputTo = outputToChooser.selectedFile.canonicalPath + outputToText.text = _outputTo + } + }) + it.setEnabled(!outputToInputDir) + } + + /* bottom buttons to restore defaults, cancel, save */ + private val restoreButton = JButton("Restore All Defaults").also { + addActionListener(ActionListener { + restore(_dOutputToInputDir, _dOutputTo, _dWhitelist) + } + } + private val cancelButton = JButton("Cancel").also { + addActionListener(ActionListener { + setVisible(false) + restore(outputToInputDir, outputTo, whitelist) + }) + } + private val saveButton = JButton("Save").also { + addActionListener(ActionListener { + setVisible(false) + writeProperties() + }) + } + + private fun writeProperties() { + PROPERTIES.run { + setProperty("outputTo", outputTo) + setProperty("outputToInputDir", outputToInputDir.toString()) + setProperty("whitelist", whitelist.toString()) + } + try { + BufferedWriter(OutputStreamWriter(FileOutputStream(PROP_FILE), CHARSET)).use { + PROPERTIES.store(it, null) + } + } catch (e: IOException) { + LOGGER.log(Level.SEVERE, "unable to write settings", e) + JOptionPane.showMessageDialog(Application.mainFrame, + "Unable to write settings.", + "Error", + JOptionPane.ERROR_MESSAGE) + } + } + + private fun restore(outputToInput: Boolean, output: String, wl: Whitelist) { + outputToButton.setSelected(!outputToInput) + changeOutputTo.setEnabled(!outputToInput) + outputToText.text = output + outputToInputButton.setSelected(outputToInput) + wlSelectorModel.reset(wl.toList()) + } + + /* so we can present a list of strings that is always sorted */ + private class WhiteListModel(basedOn: Collection): ListModel { + private val storage = ArrayList(basedOn).apply { sort() } + private val listeners = mutableListOf() + + /* so we can mutate the list */ + + fun add(newItem: String): Unit { + var index = storage.binarySearch(newItem) + if (index < 0) { + index = -(index + 1) + } + storage.add(index, newItem) + val event = ListDataEvent(this, ListDataEvent.INTERVAL_ADDED, index, index) + listeners.forEach { it.intervalAdded(event) } + } + + fun removeAt(index: Int): Unit { + if (storage.removeAt(index)) { + val event = ListDataEvent(this, ListDataEvent.INTERVAL_REMOVED, index, index) + listeners.forEach { it.intervalRemoved(event) } + } + } + + fun remove(oldItem: String): Unit { + val index = basedOn.binarySearch(oldItem) + if (index < 0) { + return + } + var start = index + while (start > 0 && storage[start] == oldItem) { + start -= 1 + } + var end = index + var max = storage.size - 1 + while (end < max && storage[end] == oldItem) { + end += 1 + } + storage.removeRange(start, end+1) + val event = ListDataEvent(this, ListDataEvent.INTERVAL_REMOVED, start, end) + listeners.forEach { it.intervalRemoved(event) } + } + + fun reset(basedOn: Collection): Unit { + val removeEvent = ListDataEvent(this, ListDataEvent.INTERVAL_REMOVED, 0, storage.size) + storage.clear() + storage.addAll(basedOn) + storage.sort() + val addEvent = ListDataEvent(this, ListDataEvent.INTERVAL_ADDED, 0, storage.size) + listeners.forEach { + it.contentsChanged(removeEvent) + it.contentsChanged(addEvent) + } + } + + fun toList(): List = storage + + /* so we are a proper ListModel */ + + override fun addListDataListener(l: ListDataListener): Unit { + listeners.add(l) + } + + override fun removeListDataListener(l: ListDataListener): Unit { + listeners.remove(l) + } + + override fun getElementAt(index: Int): String = storage[index] + + override fun getSize(): Int = storage.size + } + + private class WLAddDialog(parent: SettingsDialog): JDialog(parent) { + JTextField text = JTextField(40).apply { + alignmentX = CENTER_ALIGNMENT + border = BorderFactory.createEmptyBorder(BW, BW, BW, BW) + } + + JButton cancelButton = JButton("Cancel").apply { + addActionListener(ActionListener { + text.text = "" + setVisible(false) + }) + } + + JButton addButton = JButton("Add").apply { + addActionListener(ActionListener { + val newItem = text.text?.trim() + if (newItem.isNullOrEmpty()) { + Toolkit.getDefaultToolkit().beep() + } else { + wlSelectorModel.add(newItem) + } + text.text = "" + setVisible(false) + } + }) + } + + init { + title = "Add Item to Whitelist" + contentPane.apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(text) + add(Box(BoxLayout.X_AXIS).apply { + alignmentX = CENTER_ALIGNMENT + border = BorderFactory.createEmptyBorder(BW, BW, BW, BW) + add(Box.createHorizontalGlue()) + add(cancelButton) + add(Box.createHorizontalGlue()) + add(addButton) + add(Box.createHorizontalGlue()) + }) + } + pack() + setResizable(false) + } + } + + /* the JList that holds our whitelist */ + private val wlSelectorModel = WhiteListModel(whitelist.toList()) + private val wlSelector = JList().apply { + visibleRowCount = -1 + model = wlSelectorModel + clearSelection() + addListSelectionListener(ListSelectionListener { + wlDeleteButton.setEnabled(!isSelectionEmpty()) + } + } + + /* buttons for managing the whitelist */ + private val wlAddButton = JButton("Add").apply { + addActionListener(ActionListener { wlAddDialog.setVisible(true) }) + } + private val wlDeleteButton = JButton("Delete").apply { + addActionListener(ActionListener { + wlSelector.selectedIndices.forEach { wlSelectorModel.removeAt(it) } + }) + setEnabled(false) + } + + /* the dialog that the Add button pops up */ + private val wlAddDialog = WLAddDialog(this) + + init { + if (_outputTo.isNullOrEmpty()) { + _outputTo = System.getProperty("user.dir") + } + title = "Settings" + contentPane.apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(JTabbedPane().apply { + addTab("Folders", JPanel().apply { + border = BorderFactory.createEmptyBorder(BW, BW, BW, BW) + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(outputToInputButton.apply { + alignmentX = LEFT_ALIGNMENT + border = BorderFactory.createEmptyBorder(BW, BW, BW, BW) + }) + add(Box(BoxLayout.X_AXIS).apply { + alignmentX = LEFT_ALIGNMENT + border = BorderFactory.createEmptyBorder(BW, BW, BW, BW) + add(outputToButton) + add(Box.createHorizontalStrut(BW2)) + add(outputToText) + add(Box.createHorizontalGlue()) + }) + add(Box(BoxLayout.X_AXIS).apply { + alignmentX = LEFT_ALIGNMENT + border = BorderFactory.createEmptyBorder(0, BW, BW, BW) + add(Box.createHorizontalGlue()) + add(changeOutputTo) + }) + }) + addTab("Whitelist", JPanel().apply { + border = BorderFactory.createEmptyBorder(BW, BW, BW, BW) + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(JScrollPane(wlSelector).apply { + alignmentX = CENTER_ALIGNMENT + border = BorderFactory.createEmptyBorder(BW, BW, BW, BW) + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + }) + add(Box(BoxLayout.X_AXIS).apply { + alignmentX = CENTER_ALIGNMENT + border = BorderFactory.createEmptyBorder(BW, BW, BW, BW) + add(Box.createHorizontalGlue()) + add(wlAddButton) + add(Box.createHorizontalGlue()) + add(wlDeleteButton) + add(Box.createHorizontalGlue()) + }) + }) + }) + add(Box(BoxLayout.X_AXIS).apply { + border = BorderFactory.createEmptyBorder(BW, BW2, BW2, BW2) + add(restoreButton) + add(Box.createHorizontalGlue()) + add(cancelButton) + add(Box.createHorizontalStrut(BW2)) + add(saveButton) + }) + } + pack() + minimumSize = size + } +} diff -r efd9fe2d70d7 -r 19c381c536ec src/name/blackcap/exifwasher/ShowDialog.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/name/blackcap/exifwasher/ShowDialog.kt Wed Apr 08 20:29:12 2020 -0700 @@ -0,0 +1,109 @@ +/* + * The dialog that displays the Exif data in a single file (display only, + * no changing). We do this after washing. + */ +package name.blackcap.exifwasher + +import java.awt.event.ActionEvent +import java.awt.event.ActionListener +import java.io.File +import java.io.IOException +import java.util.logging.Level +import java.util.logging.Logger +import javax.swing.* +import javax.swing.table.DefaultTableModel +import javax.swing.table.TableColumn +import javax.swing.table.TableColumnModel + +import name.blackcap.exifwasher.exiv2.* + +class ShowDialog : JDialog(Application.mainFrame) { + private val BW = 9 + private val BW2 = BW * 2 + private val WIDTH = 640 + private val HEIGHT = 480 + + private val myTable = JTable().apply { + autoCreateRowSorter = false + rowSorter = null + columnModel.run { + getColumn(0).preferredWidth = 25 /* key name */ + getColumn(1).preferredWidth = 15 /* type */ + getColumn(2).preferredWidth = 100 /* value */ + } + } + + /* deliberately not the default, because this changes a file */ + private val dismissButton = JButton("Dismiss").also { + it.addActionListener(ActionListener { close() }) + it.border = BorderFactory.createEmptyBorder(0, BW, 0, BW) + } + + /* controls the washing of the Exif data */ + fun show(image: File) { + title = "Washed: ${image.name}" + useWaitCursor() + swingWorker>?> { + inBackground { + try { + val image = Image(image.absolutePath) + val meta = image.meta + val keys = meta.keys + keys.sort() + Array>(keys.size) { + val key = keys[it] + val value = meta[key] + arrayOf(key, value.type, value.value) + } + } catch (e: Exiv2Exception) { + LOGGER.log(Level.SEVERE, "unable to read metadata", e) + null + } + } + whenDone { + useNormalCursor() + val tableData = get() + if (tableData == null) { + JOptionPane.showMessageDialog(Application.mainFrame, + "Unable to read metadata.", + "Error", JOptionPane.ERROR_MESSAGE) + } else { + val colNames = arrayOf("Key", "Type", "Value") + myTable.apply { + model = MyTableModel(tableData, colNames) + validate() + } + setVisible(true) + } + } + } + } + + private class MyTableModel : DefaultTableModel { + override fun isCellEditable(row: Int, col: Int) = false + override fun getColumnClass(col: Int) = java.lang.String::class.java + } + + init { + defaultCloseOperation = JDialog.DISPOSE_ON_CLOSE /* delete if reusing */ + title = "Untitled" + contentPane.apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(JScrollPane(myTable).apply { + alignmentX = JScrollPane.CENTER_ALIGNMENT + border = BorderFactory.createEmptyBorder(BW2, BW2, BW, BW2) + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + preferredSize = Dimension(WIDTH, HEIGHT) + background = Application.mainFrame.background + }) + add(Box(BoxLayout.X_AXIS).apply { + alignmentX = Box.CENTER_ALIGNMENT + border = BorderFactory.createEmptyBorder(BW, BW, BW2, BW) + add(Box.createHorizontalGlue()) + add(dismissButton) + }) + } + pack() + } +} diff -r efd9fe2d70d7 -r 19c381c536ec src/name/blackcap/exifwasher/WashDialog.kt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/name/blackcap/exifwasher/WashDialog.kt Wed Apr 08 20:29:12 2020 -0700 @@ -0,0 +1,220 @@ +/* + * The dialog that controls washing a single file. + */ +package name.blackcap.exifwasher + +import java.awt.event.ActionEvent +import java.awt.event.ActionListener +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.util.logging.Level +import java.util.logging.Logger +import javax.swing.* +import javax.swing.table.DefaultTableModel +import javax.swing.table.TableColumn +import javax.swing.table.TableColumnModel + +import name.blackcap.exifwasher.exiv2.* + +class WashDialog : JDialog(Application.mainFrame) { + private val BW = 9 + private val BW2 = BW * 2 + private val WIDTH = 640 + private val HEIGHT = 480 + + private val myTable = JTable().apply { + autoCreateRowSorter = false + rowSorter = null + columnModel.run { + getColumn(0).preferredWidth = 10 /* checkbox */ + getColumn(1).preferredWidth = 25 /* key name */ + getColumn(2).preferredWidth = 15 /* type */ + getColumn(3).preferredWidth = 100 /* value */ + } + } + + private val selectAll = JCheckBox("Select all for deletion", false) + + private val resetButton = JButton("Reset").also { + it.addActionListener(ActionListener { doReset() }) + it.border = BorderFactory.createEmptyBorder(0, BW, 0, BW) + } + + private val cancelButton = JButton("Cancel").also { + it.addActionListener(ActionListener { close() }) + it.border = BorderFactory.createEmptyBorder(0, BW, 0, BW) + } + + /* deliberately not the default action, because it changes a file */ + private val washButton = JButton("Wash").also { + it.addActionListener(ActionListener { doWash() }) + it.border = BorderFactory.createEmptyBorder(0, BW, 0, BW) + } + + private lateinit var washing: File + + /* initiates the washing of the Exif data */ + fun wash(dirty: File) { + title = "Washing: ${image.name}" + selectAll.setSelected(false) + washing = dirty + useWaitCursor() + swingWorker>?> { + inBackground { + try { + val image = Image(dirty.canonicalPath) + val meta = image.meta + val keys = meta.keys + keys.sort() + Array>(keys.size) { + val key = keys[it] + val value = meta[key] + arrayOf(!settingsDialog.whitelist.contains(key), key, value.type, value.value) + } + } catch (e: Exiv2Exception) { + LOGGER.log(Level.SEVERE, "unable to read metadata", e) + null + } + } + whenDone { + useNormalCursor() + val tableData = get() + if (tableData == null) { + JOptionPane.showMessageDialog(Application.mainFrame, + "Unable to read metadata.", + "Error", JOptionPane.ERROR_MESSAGE) + } else { + val colNames = arrayOf("Delete?", "Key", "Type", "Value") + myTable.apply { + model = MyTableModel(tableData, colNames) + validate() + } + setVisible(true) + } + } + } + } + + private class MyTableModel : DefaultTableModel { + override fun isCellEditable(row: Int, col: Int) = col == 0 + override fun getColumnClass(col: Int) = if (col == 0) { + Boolean + } else { + String + } + } + + private fun doReset() { + myTable.model.run { + for (i in 0 .. rowCount - 1) { + val key = getValueAt(i, 1) as String + setValueAt(!settingsDialog.whitelist.contains(key), i, 0) + } + } + myTable.validate() + } + + private fun doWash() { + setVisible(false) + + /* get path to the directory we create */ + val outDir = if (settingsDialog.outputToInputDir) { + washing.canonicalFile.parent + } else { + settingsDialog.outputTo + } + + /* get new file name */ + val (name, ext) = splitext(washing.name) + var newFile = File(outDir, "${name}_washed${ext}") + + /* copy the file, then edit the Exif in the copy */ + useWaitCursor() + swingWorker { + inBackground { + try { + FileInputStream(washing).use { source -> + FileOutputStream(newFile).use { target -> + source.copyTo(target) + } + } + val image = Image(newFile.canonicalPath) + val meta = image.meta + meta.keys.forEach { + if (!settingsDialog.whitelist.contains(it)) { + meta.erase(it) + } + } + image.store() + true + } catch (e: IOException) { + LOGGER.log(Level.SEVERE, "unable to copy input", e) + false + } catch (e: Exiv2Exception) { + LOGGER.log(Level.SEVERE, "unable to edit metadata", e) + false + } + } + whenDone { + useNormalCursor() + close() + /* if all went well, show the Exif in the new file */ + if (get()) { + ShowDialog().show(newFile) + } else { + try { + if (newFile.exists()) { newFile.delete() } + } catch (e: IOException) { + LOGGER.log(Level.SEVERE, "unable to delete", e) + } + JOptionPane.showMessageDialog(Application.mainFrame, + "Error\nUnable to wash: ${washing.canonicalPath}\nTo: ${newFile.canonicalPath}", + "Error", JOptionPane.ERROR_MESSAGE) + } + } + } + } + + private fun splitext(s: String): Pair { + val pos = s.lastIndexOf('.') + if (pos == -1) { + return Pair(s, "") + } + return Pair(s.substring(0, pos), s.substring(pos)) + } + + init { + defaultCloseOperation = JDialog.DISPOSE_ON_CLOSE /* delete if reusing */ + title = "Untitled" + contentPane.apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + add(Box(BoxLayout.Y_AXIS).apply { + alignmentX = Box.CENTER_ALIGNMENT + border = BorderFactory.createEmptyBorder(BW, BW, BW, BW) + add(JScrollPane(myTable).apply { + alignmentX = JScrollPane.LEFT_ALIGNMENT + border = BorderFactory.createEmptyBorder(BW, BW, BW, BW) + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + preferredSize = Dimension(WIDTH, HEIGHT) + background = Application.mainFrame.background + }) + add(selectAll.apply { + alignmentX = JCheckBox.LEFT_ALIGNMENT + border = BorderFactory.createEmptyBorder(BW, BW, 0, BW) + }) + }) + add(Box(BoxLayout.X_AXIS).apply { + alignmentX = Box.CENTER_ALIGNMENT + border = BorderFactory.createEmptyBorder(BW, BW, BW2, BW) + add(resetButton) + add(Box.createHorizontalGlue()) + add(cancelButton) + add(washButton) + }) + } + pack() + } +} diff -r efd9fe2d70d7 -r 19c381c536ec src/name/blackcap/exifwasher/Whitelist.kt --- a/src/name/blackcap/exifwasher/Whitelist.kt Wed Apr 01 14:23:54 2020 -0700 +++ b/src/name/blackcap/exifwasher/Whitelist.kt Wed Apr 08 20:29:12 2020 -0700 @@ -7,7 +7,7 @@ import kotlin.collections.mutableSetOf import kotlin.collections.mutableListOf -class Whitelist { +class Whitelist: Cloneable { private val entire = mutableSetOf() private val prefixes = mutableListOf() @@ -29,17 +29,18 @@ fun contains(s: String) = entire.contains(s) || prefixes.find { s.startsWith(it) } != null - fun toList(): List = mutableListOf().also { - it.addAll(entire) - it.addAll(prefixes) - it.sort() - } + fun toList(): List = prefixes + entire override fun toString(): String = toList().joinToString(",") + override public fun clone() = this().also { new -> + entire.forEach { new.addEntire(it) } + prefixes.forEach { new.addPrefix(it) } + } + companion object { private val SPLITTER = Pattern.compile(",\\s*") - fun parse(raw: String) = Whitelist().also { + fun parse(raw: String) = this().also { for (s in raw.split(SPLITTER)) { it.add(s) } diff -r efd9fe2d70d7 -r 19c381c536ec src/name/blackcap/exifwasher/default.properties --- a/src/name/blackcap/exifwasher/default.properties Wed Apr 01 14:23:54 2020 -0700 +++ b/src/name/blackcap/exifwasher/default.properties Wed Apr 08 20:29:12 2020 -0700 @@ -1,6 +1,16 @@ +outputTo= +outputToInputDir=true whitelist=\ + Exif.Image.ColorMap,\ + Exif.Image.Indexed,\ + Exif.Image.InterColorProfile,\ Exif.Image.Orientation,\ + Exif.Image.PreviewColorSpace,\ + Exif.Image.PrimaryChromaticities,\ Exif.Image.ResolutionUnit,\ + Exif.Image.SamplesPerPixel,\ + Exif.Image.TransferFunction,\ + Exif.Image.WhitePoint,\ Exif.Image.XResolution,\ Exif.Image.YCbCrCoefficients,\ Exif.Image.YCbCrPositioning,\