changeset 3:19c381c536ec

Code to make it a proper Mac GUI app. Untested!
author David Barts <n5jrn@me.com>
date Wed, 08 Apr 2020 20:29:12 -0700
parents efd9fe2d70d7
children ba5dc14652da
files src/name/blackcap/exifwasher/Files.kt src/name/blackcap/exifwasher/Main.kt src/name/blackcap/exifwasher/MainFrame.kt src/name/blackcap/exifwasher/Menus.kt src/name/blackcap/exifwasher/Misc.kt src/name/blackcap/exifwasher/Osdep.kt src/name/blackcap/exifwasher/SettingsDialog.kt src/name/blackcap/exifwasher/ShowDialog.kt src/name/blackcap/exifwasher/WashDialog.kt src/name/blackcap/exifwasher/Whitelist.kt src/name/blackcap/exifwasher/default.properties
diffstat 11 files changed, 1051 insertions(+), 8 deletions(-) [+]
line wrap: on
line diff
--- 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 {
--- /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<MainFrame>()
+    var settingsDialog: SettingsDialog by SetOnce<SettingsDialog>()
+
+    fun initialize() {
+        UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
+        mainFrame = MainFrame() /* must be created first */
+        settingsDialog = SettingsDialog()
+        mainFrame.setVisible(true)
+    }
+}
+
+fun main(args: Array<String>) {
+    LOGGER.log(Level.INFO, "beginning execution")
+    if (OS.type == OS.MAC) {
+        System.setProperty("apple.laf.useScreenMenuBar", "true")
+    }
+    inSwingThread {
+        Application.initialize()
+    }
+}
--- /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<File>
+            } 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())
+    }
+}
+
--- /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)
+}
--- /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<T: Any>: ReadWriteProperty<Any?,T> {
+    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<T>: SwingWorker<T,Unit>() {
+    private var inBackgroundLambda: (SwingWorkerBuilder.() -> T)? = null
+    private var whenDoneLambda: (SwingWorkerBuilder.() -> Unit)? = null
+
+    private fun setOnce<U>(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<T>(::inBackgroundLambda, lambda)
+    }
+
+    /**
+     * Define the whenDone task.
+     */
+    fun whenDone(lambda: SwingWorkerBuilder.() -> Unit): Unit {
+        setOnce<Unit>(::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<T>(initializer: SwingWorkerBuilder.() -> Unit): Unit {
+    SwingWorkerBuilder<T>().run {
+        initializer()
+        execute()
+    }
+}
+
+/**
+ * Close a dialog (don't just hide it).
+ */
+fun JDialog.close() {
+    setVisible(false)
+    dispose()
+}
--- /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) }))
+    }
+}
--- /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<String>): ListModel<String> {
+        private val storage = ArrayList<String>(basedOn).apply { sort() }
+        private val listeners = mutableListOf<ListDataListener>()
+
+        /* 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<String>): 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<String> = 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
+    }
+}
--- /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<Array<Array<String>>?> {
+            inBackground {
+                try {
+                    val image = Image(image.absolutePath)
+                    val meta = image.meta
+                    val keys = meta.keys
+                    keys.sort()
+                    Array<Array<String>>(keys.size) {
+                        val key = keys[it]
+                        val value = meta[key]
+                        arrayOf<String>(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()
+    }
+}
--- /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<Array<Array<Any>>?> {
+            inBackground {
+                try {
+                    val image = Image(dirty.canonicalPath)
+                    val meta = image.meta
+                    val keys = meta.keys
+                    keys.sort()
+                    Array<Array<String>>(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<Boolean> {
+            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<String, String> {
+        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()
+    }
+}
--- 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<String>()
     private val prefixes = mutableListOf<String>()
 
@@ -29,17 +29,18 @@
 
     fun contains(s: String) = entire.contains(s) || prefixes.find { s.startsWith(it) } != null
 
-    fun toList(): List<String> = mutableListOf<String>().also {
-        it.addAll(entire)
-        it.addAll(prefixes)
-        it.sort()
-    }
+    fun toList(): List<String> = 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)
             }
--- 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,\