changeset 0:be282c48010a

Incomplete; checking it in as a backup.
author David Barts <n5jrn@me.com>
date Tue, 14 Jan 2020 14:07:19 -0800
parents
children fb224c3aebdf
files .hgignore build.xml lib/jsoup-1.12.1.jar setup.sh src/name/blackcap/clipman/Files.kt src/name/blackcap/clipman/Main.kt src/name/blackcap/clipman/Pasteboard.kt src/name/blackcap/clipman/PasteboardQueue.kt src/name/blackcap/clipman/RtfToHtml.kt
diffstat 9 files changed, 651 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.hgignore	Tue Jan 14 14:07:19 2020 -0800
@@ -0,0 +1,3 @@
+~$
+\.bak$
+^work/
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/build.xml	Tue Jan 14 14:07:19 2020 -0800
@@ -0,0 +1,95 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project name="ClipMan" default="help" basedir=".">
+  <!-- import all environment variables as env.* -->
+  <property environment="env"/>
+
+  <!-- ensure required environment variables are set -->
+  <macrodef name="env-require">
+    <attribute name="name"/>
+    <sequential>
+      <fail message="Environment variable @{name} not set!">
+        <condition>
+          <not><isset property="env.@{name}"/></not>
+        </condition>
+      </fail>
+    </sequential>
+  </macrodef>
+  <env-require name="JRE_HOME"/>
+  <env-require name="KOTLIN_HOME"/>
+
+  <!-- define the kotlin task -->
+  <property name="kotlin.lib" value="${env.KOTLIN_HOME}/libexec/lib"/>
+  <typedef resource="org/jetbrains/kotlin/ant/antlib.xml" classpath="${kotlin.lib}/kotlin-ant.jar"/>
+
+  <!-- cribbed from https://stackoverflow.com/questions/7129672/uppercase-lowercase-capitalize-an-ant-property -->
+  <scriptdef language="javascript" name="toLowerCase">
+    <attribute name="value" />
+    <attribute name="target" />
+    <![CDATA[
+      project.setProperty( attributes.get( "target" ),
+                           attributes.get( "value" ).toLowerCase() );
+    ]]>
+  </scriptdef>
+
+  <!-- Define the properties used by the build -->
+  <property name="app.name"      value="${ant.project.name}"/>
+  <toLowerCase target="lc.app.name" value="${app.name}"/>
+  <property name="jar.name"      value="${basedir}/${lc.app.name}.jar"/>
+  <property name="work.jar"     value="${basedir}/work.jar"/>
+  <property name="lib.home"      value="${basedir}/lib"/>
+  <property name="src.home"      value="${basedir}/src"/>
+
+  <!-- help message -->
+  <target name="help">
+    <echo>You can use the following targets:</echo>
+    <echo> </echo>
+    <echo>  help    : (default) Prints this message </echo>
+    <echo>  all     : Cleans, compiles, and stages application</echo>
+    <echo>  clean   : Deletes work directories</echo>
+    <echo>  compile : Compiles servlets into class files</echo>
+    <echo>  jar     : Make JAR file.</echo>
+    <echo> </echo>
+    <echo>For example, to clean, compile, and package all at once, run:</echo>
+    <echo>prompt> ant all </echo>
+  </target>
+
+  <!-- Define the CLASSPATH -->
+  <target name="classpath">
+    <path id="std.classpath">
+      <fileset dir="${lib.home}">
+        <include name="*.jar"/>
+      </fileset>
+    </path>
+    <path id="compile.classpath">
+      <path refid="std.classpath"/>
+      <pathelement location="${src.home}"/>
+    </path>
+    <path id="test.classpath">
+      <path refid="std.classpath"/>
+      <pathelement location="${work.home}"/>
+    </path>
+  </target>
+
+  <!-- do everything but install -->
+  <target name="all" depends="jar"
+          description="Clean work dirs, compile, make JAR."/>
+
+  <!-- compile *.kt to *.class -->
+  <target name="compile" depends="classpath"
+          description="Compile Java sources to ${work.home}">
+    <kotlinc src="${src.home}" output="${work.jar}"
+             classpathref="compile.classpath"/>
+  </target>
+
+  <!-- make .jar file -->
+  <target name="jar" depends="compile" description="Create JAR file.">
+    <jar destfile="${jar.name}">
+      <manifest>
+        <attribute name="Main-Class" value="name.blackcap.clipman.MainKt"/>
+      </manifest>
+      <zipgroupfileset dir="${lib.home}" includes="*.jar"/>
+      <zipfileset src="${work.jar}"/>
+    </jar>
+  </target>
+
+</project>
Binary file lib/jsoup-1.12.1.jar has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/setup.sh	Tue Jan 14 14:07:19 2020 -0800
@@ -0,0 +1,10 @@
+#!/bin/bash
+
+export JRE_HOME="$(/usr/libexec/java_home)"
+export KOTLIN_HOME="/usr/local/Cellar/kotlin/1.3.61"
+
+export ANT_HOME="$HOME/java/apache-ant-1.10.1"
+if [[ "$PATH" != *$ANT_HOME/bin* ]]
+then
+    export PATH="$ANT_HOME/bin:$PATH"
+fi
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/name/blackcap/clipman/Files.kt	Tue Jan 14 14:07:19 2020 -0800
@@ -0,0 +1,81 @@
+/*
+ * For dealing with files.
+ * BUG: does not ensure directories exist
+ */
+package name.blackcap.clipman
+
+import java.io.BufferedReader
+import java.io.File
+import java.io.FileReader
+import java.util.Properties
+import java.util.logging.FileHandler
+import java.util.logging.Level
+import java.util.logging.Logger
+import java.util.logging.SimpleFormatter
+
+/* OS Type */
+
+enum class OS {
+    MAC, UNIX, WINDOWS, OTHER;
+    companion object {
+        private val rawType = System.getProperty("os.name")?.toLowerCase()
+        val type = if (rawType == null) {
+                OTHER
+            } else if (rawType.contains("win")) {
+                WINDOWS
+            } else if (rawType.contains("mac")) {
+                MAC
+            } else if (rawType.contains("nix") || rawType.contains("nux") || rawType.contains("aix") || rawType.contains("sunos")) {
+                UNIX
+            } else {
+                OTHER
+            }
+    }
+}
+
+/* joins path name components to java.io.File */
+
+fun joinPath(base: String, vararg rest: String) = rest.fold(File(base), ::File)
+
+/* file names */
+
+private val SHORTNAME = "clipman"
+private val LONGNAME = "name.blackcap." + SHORTNAME
+private val HOME = System.getenv("HOME")
+private val APPDATA = System.getenv("APPDATA")
+private val PF_DIR = when (OS.type) {
+    OS.MAC -> joinPath(HOME, "Library", "Application Support", LONGNAME)
+    OS.WINDOWS -> joinPath(APPDATA, "Roaming", LONGNAME)
+    else -> joinPath(HOME, "." + SHORTNAME)
+}
+private val LF_DIR = when (OS.type) {
+    OS.MAC -> joinPath(HOME, "Library", "Application Support", LONGNAME)
+    OS.WINDOWS -> joinPath(APPDATA, "Local", LONGNAME)
+    else -> joinPath(HOME, "." + SHORTNAME)
+}
+val PROP_FILE = File(PF_DIR, SHORTNAME + ".properties")
+val LOG_FILE = File(LF_DIR, SHORTNAME + ".log")
+
+/* make some needed directories */
+
+private fun File.makeIfNeeded() = if (exists()) { true } else { mkdirs() }
+
+/* make some usable objects */
+
+val PROPERTIES = run {
+    PF_DIR.makeIfNeeded()
+    PROP_FILE.createNewFile()
+    Properties().apply {
+        BufferedReader(FileReader(PROP_FILE)).use { load(it) }
+    }
+}
+
+val LOGGER = run {
+    LF_DIR.makeIfNeeded()
+    Logger.getLogger(LONGNAME).apply {
+        addHandler(FileHandler(LOG_FILE.toString()).apply {
+            formatter = SimpleFormatter() })
+        level = Level.CONFIG
+        useParentHandlers = false
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/name/blackcap/clipman/Main.kt	Tue Jan 14 14:07:19 2020 -0800
@@ -0,0 +1,147 @@
+/*
+ * The entry point and most of the view logic is here.
+ */
+package name.blackcap.clipman
+
+import java.awt.BorderLayout
+import java.awt.Color
+import java.awt.Container
+import java.awt.Dimension
+import java.awt.Font
+import java.awt.Toolkit;
+import java.awt.datatransfer.*
+import java.awt.event.WindowEvent
+import java.awt.event.WindowListener
+import java.util.Date
+import java.util.logging.Level
+import java.util.logging.Logger
+import javax.swing.*
+import javax.swing.border.CompoundBorder
+import javax.swing.border.EmptyBorder
+import javax.swing.border.LineBorder
+import javax.swing.text.JTextComponent
+import kotlin.concurrent.thread
+import org.jsoup.Jsoup
+import org.jsoup.nodes.*
+
+
+/* kills the updating thread (and does a system exit) when needed */
+class KillIt(val thr: Thread) : 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) {
+        thr.run { interrupt(); join() }
+        System.exit(0)
+    }
+}
+
+/* the updating thread */
+class UpdateIt(val queue: PasteboardQueue, val interval: Int): Thread() {
+    @Volatile var enabled = true
+    private val stdBorder =
+        CompoundBorder(EmptyBorder(5, 10, 5, 10), LineBorder(Color.GRAY, 1))
+
+    override fun run() {
+        var oldContents = ""
+        var newContents = ""
+        while (true) {
+            if (enabled) {
+                var contents = PasteboardItem.read()
+                if (contents == null) {
+                    LOGGER.log(Level.WARNING, "unable to read clipboard")
+                    continue
+                }
+                newContents = when (contents) {
+                    is PasteboardItem.Plain -> contents.plain
+                    is PasteboardItem.HTML -> contents.plain
+                }
+                if (oldContents != newContents) {
+                    var widget: JComponent = when(contents) {
+                        is PasteboardItem.Plain -> JTextPane().apply {
+                            contentType = "text/plain"
+                            toolTipText = "Plain text"
+                            text = contents.plain
+                            font = Font(Font.MONOSPACED, Font.PLAIN, 14)
+                            border = stdBorder
+                            autoSize(600)
+                            setEditable(false)
+                        }
+                        is PasteboardItem.HTML -> JTextPane().apply {
+                            contentType = "text/html"
+                            toolTipText = "Styled text"
+                            text = scrub(contents.html)
+                            border = stdBorder
+                            autoSize(600)
+                            setEditable(false)
+                        }
+                    }
+                    queue.add(QueueItem(widget, contents))
+                    oldContents = newContents
+                }
+            }
+            if (Thread.interrupted()) {
+                return
+            }
+            try {
+                Thread.sleep(interval - System.currentTimeMillis() % interval)
+            } catch (e: InterruptedException) {
+                return
+            }
+        }
+    }
+
+    private fun scrub(html: String): String {
+        return Jsoup.parse(html).run {
+            select(":root>head>meta").remove()
+            outputSettings()
+                .charset(CHARSET_NAME)
+                .syntax(Document.OutputSettings.Syntax.xml)
+            outerHtml()
+        }
+    }
+}
+
+fun main(args: Array<String>) {
+    LOGGER.log(Level.INFO, "beginning execution")
+    val con = Container().apply {
+        layout = BoxLayout(this, BoxLayout.Y_AXIS)
+    }
+    var frame: JFrame? = null
+    inSwingThread {
+        frame = JFrame("ClipMan").apply {
+            preferredSize = Dimension(640, 480)
+            contentPane.add(
+                JScrollPane(con).apply {
+                    verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
+                    horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
+                    preferredSize = Dimension(640, 480)
+                }, BorderLayout.CENTER)
+            pack()
+            setVisible(true)
+        }
+    }
+    val queue = PasteboardQueue(con, 10)
+    val updater = UpdateIt(queue, 1000).apply { start() }
+    inSwingThread { frame?.addWindowListener(KillIt(updater)) }
+    LOGGER.log(Level.INFO, "execution complete")
+}
+
+fun inSwingThread(block: () -> Unit) {
+    SwingUtilities.invokeLater(Runnable(block))
+}
+
+fun JTextComponent.autoSize(width: Int): Unit {
+    val SLOP = 10
+    val dim = Dimension(width, width)
+    preferredSize = dim
+    size = dim
+    val r = modelToView(document.length)
+    preferredSize = Dimension(width, r.y + r.height + SLOP)
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/name/blackcap/clipman/Pasteboard.kt	Tue Jan 14 14:07:19 2020 -0800
@@ -0,0 +1,139 @@
+/*
+ * We call the clipboard a "pasteboard" for our internal class name, not
+ * because I prefer that term (I don't) but so as to not clash with the
+ * AWT's Clipboard class.
+ */
+package name.blackcap.clipman
+
+import java.awt.Toolkit
+import java.awt.datatransfer.Clipboard
+import java.awt.datatransfer.ClipboardOwner
+import java.awt.datatransfer.DataFlavor
+import java.awt.datatransfer.Transferable
+import java.awt.datatransfer.UnsupportedFlavorException
+import java.io.IOException
+import java.io.InputStream
+import java.nio.charset.Charset
+import java.util.logging.Level
+import java.util.logging.Logger
+import kotlin.collections.HashMap
+
+/* Constants, etc. */
+val CHARSET_NAME = "UTF-8"
+
+/*
+ * Represents an error dealing with pasteboard items.
+ */
+class PasteboardError(): Exception()
+
+/**
+ * Represents an item of data in the clipboard and how to read and
+ * write it.
+ */
+sealed class PasteboardItem {
+    data class Plain(val plain: String): PasteboardItem()
+    data class HTML(val plain: String, val html: String): PasteboardItem()
+
+    private class PasteboardData(val item: PasteboardItem):
+    Transferable, ClipboardOwner {
+        private val CHARSET = Charset.forName(CHARSET_NAME)
+        private val HTML_FLAVOR = DataFlavor("text/html; document=all; class=\"[B\"; charset=" + CHARSET_NAME)
+        private val _data: HashMap<DataFlavor, Any>
+        private val flavors: Array<DataFlavor>
+
+        init {
+            _data = HashMap<DataFlavor, Any>().apply {
+                when (item) {
+                    is Plain ->  put(DataFlavor.stringFlavor, item.plain as Any)
+                    is HTML -> {
+                        put(DataFlavor.stringFlavor, item.plain as Any)
+                        put(HTML_FLAVOR, item.html as Any)
+                    }
+                }
+            }
+            _data.keys.asIterable().run {
+                flavors = Array<DataFlavor>(count()) { elementAt(it) }
+            }
+        }
+
+        override fun getTransferData(flavor: DataFlavor): Any {
+            return _data.get(flavor) ?: throw UnsupportedFlavorException(flavor)
+        }
+
+        override fun getTransferDataFlavors(): Array<DataFlavor> = flavors
+        override fun isDataFlavorSupported(flavor: DataFlavor) = _data.containsKey(flavor)
+        override fun lostOwnership(clipboard: Clipboard, contents: Transferable) {}
+    }
+
+    companion object {
+        private val CLIPBOARD = Toolkit.getDefaultToolkit().systemClipboard
+
+        /**
+         * Read the item in the pasteboard.
+         * @return a PasteboardItem? object, null if nothing could be read
+         */
+        fun read() : PasteboardItem? {
+            check()
+            var plain = getClipboardData(DataFlavor.stringFlavor)
+            if (plain == null) {
+                return null
+            }
+            var html = getClipboardData(DataFlavor.allHtmlFlavor)
+            if (html == null) {
+                html = htmlFromRTF()
+            }
+            return if (html == null) { Plain(plain) } else { HTML(plain, html) }
+        }
+
+        /**
+         * Write an item to the pasteboard.
+         * @param item a PasteboardItem to write
+         */
+        fun write(item: PasteboardItem) {
+            check()
+            val pbdata = PasteboardData(item)
+            CLIPBOARD.setContents(pbdata, pbdata)
+        }
+
+        private fun check() {
+            if (CLIPBOARD == null) {
+                throw RuntimeException("no clipboard available!")
+            }
+        }
+
+        private fun getClipboardData(flavor: DataFlavor): String? {
+            try {
+                return CLIPBOARD.getData(flavor) as String?
+            } catch (e: IOException) {
+                return null
+            } catch (e: UnsupportedFlavorException) {
+                return null
+            }
+        }
+
+        private fun htmlFromRTF(): String? {
+            /* see if there's an appropriate flavor */
+            var rtf: DataFlavor? = null
+            for (flavor in CLIPBOARD.availableDataFlavors) {
+                if (flavor.isRepresentationClassInputStream() &&
+                    "text".equals(flavor.primaryType ?: "", ignoreCase=true) &&
+                    "rtf".equals(flavor.subType ?: "", ignoreCase=true)) {
+                        rtf = flavor
+                        break
+                }
+            }
+            if (rtf == null) {
+                return null
+            }
+
+            (CLIPBOARD.getData(rtf) as InputStream).use {
+                val (html, errors) = rtfToHtml(it)
+                if (errors != null) {
+                    LOGGER.log(Level.WARNING, errors)
+                    return null
+                }
+                return html
+            }
+        }
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/name/blackcap/clipman/PasteboardQueue.kt	Tue Jan 14 14:07:19 2020 -0800
@@ -0,0 +1,65 @@
+/*
+ * The queue of pasteboard items we manage. New stuff gets added to the
+ * tail, and old stuff truncated off the head.
+ */
+package name.blackcap.clipman
+
+import java.awt.Container
+import java.util.Collections
+import java.util.LinkedList
+import javax.swing.*
+
+/**
+ * A queue that tracks the data we display and the widgets used to
+ * display them. We never explicitly remove stuff from the queue,
+ * though items will get silently discarded to prevent the queue from
+ * exceeding the specified maximum size.
+ */
+class PasteboardQueue(val parent: Container, maxSize: Int) {
+    private val queue = LinkedList<QueueItem>()
+    private var _maxSize = maxSize
+
+    /**
+     * The maximum allowed size of this queue. Attempts to make the queue
+     * larger than this size, or specifying a size smaller than the current
+     * size, will result in the oldest item(s) being discarded. A size less
+     * than or equal to zero means an unlimited size.
+     */
+    var maxSize: Int
+        get() { return _maxSize }
+        @Synchronized set(value) {
+            _maxSize = value
+            truncate(false)
+        }
+
+    /**
+     * Add a JComponent to the end of the queue.
+     * @param item JComponent to add
+     */
+    @Synchronized fun add(item: QueueItem) {
+        inSwingThread { parent.add(item.component) }
+        queue.addLast(item)
+        truncate(true)
+    }
+
+    private fun truncate(forceValidate: Boolean) {
+        if (_maxSize > 0) {
+            var size = queue.size
+            var dirty = forceValidate
+            while (size > _maxSize) {
+                var extra = queue.removeFirst().component
+                inSwingThread { parent.remove(extra) }
+                dirty = true
+                size -= 1
+            }
+            if (dirty) {
+                inSwingThread { parent.validate() }
+            }
+        }
+    }
+}
+
+/**
+ * An item in the above queue.
+ */
+data class QueueItem(val component: JComponent, val contents: PasteboardItem)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/name/blackcap/clipman/RtfToHtml.kt	Tue Jan 14 14:07:19 2020 -0800
@@ -0,0 +1,111 @@
+/*
+ * Because Java (and by implication Kotlin) sucks at processing RTF data,
+ * we deal with such data by invoking an external program to convert it
+ * to HTML.
+ */
+package name.blackcap.clipman
+
+import java.io.BufferedInputStream
+import java.io.BufferedOutputStream
+import java.io.ByteArrayOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.io.UnsupportedEncodingException
+
+private val RTF_CHARSET_NAME = "UTF-8"
+private val LANG = "en_US." + RTF_CHARSET_NAME
+private val UNRTF = System.getenv("UNRTF")
+
+private class Consumer(val source: InputStream): Thread() {
+    val target = ByteArrayOutputStream()
+
+    override fun run() {
+        source.use { it.copyTo(target) }
+    }
+
+    val output: String
+        @Synchronized get() {
+            if (isAlive()) {
+                throw IllegalThreadStateException("consumer not finished!")
+            } else {
+                return target.toString(RTF_CHARSET_NAME)
+            }
+        }
+}
+
+/**
+ * Convert an InputStream of RTF bytes to a String containing an HTML
+ * document.
+ * @param rtfStream stream containing the RTF document
+ * @return a Pair. On success, the first element contains the HTML and
+ *         the second is null. On failure, the first is null and the
+ *         second contains an error message.
+ */
+public fun rtfToHtml(rtfStream: InputStream): Pair<String?, String?> {
+    if (OS.type == OS.MAC && UNRTF == null) {
+        return _rtfToHtml(rtfStream, ProcessBuilder("textutil", "-format",
+            "rtf", "-convert", "html", "-stdin", "-stdout"))
+    } else {
+        return _rtfToHtml(rtfStream, ProcessBuilder(
+            if (UNRTF == null) { "unrtf" } else { UNRTF },
+            "--html", "--nopict"))
+    }
+}
+
+private fun _rtfToHtml(rtfStream: InputStream, pb: ProcessBuilder): Pair<String?, String?> {
+    var job: Process? = null
+    try {
+        /* set the Posix locale to force UTF-8 I/O */
+        pb.environment().run {
+            put("LANG", LANG)
+            put("LC_ALL", LANG)
+        }
+
+        /* start the process */
+        job = pb.start()
+
+        /* start consuming its output and error streams */
+        val outputConsumer = Consumer(job.inputStream).apply { start() }
+        val errorConsumer = Consumer(job.errorStream).apply { start() }
+
+        /* feed it input */
+        job.outputStream.use { rtfStream.copyTo(it) }
+
+        /* wait for it to exit */
+        val exitStatus = job.waitFor()
+
+        /* after it exits, wait for our data consumers to exit */
+        outputConsumer.join();
+        errorConsumer.join();
+
+        /* if it barfed, return an error, else return the HTML */
+        if (exitStatus != 0) {
+            val errors = errorConsumer.output
+            if (errors.isEmpty()) {
+                return Pair(null, "converter exited with status " + exitStatus)
+            } else {
+                return Pair(null, errors)
+            }
+        }
+        return Pair(outputConsumer.output, null)
+    } catch (e: IOException) {
+        return barfed(e)
+    } catch (e: InterruptedException) {
+        if (job != null && job.isAlive()) {
+            job.destroy()
+            job.waitFor()
+        }
+        return barfed(e)
+    }
+}
+
+private fun barfed(e: Exception): Pair<String?, String?> {
+    val sb = StringBuilder(e::class.simpleName)
+    val message = e.message ?: ""
+    if (!message.isEmpty()) {
+        sb.append(": ")
+        sb.append(e.message)
+    }
+    return Pair(null, sb.toString())
+}