# HG changeset patch # User David Barts # Date 1579039639 28800 # Node ID be282c48010a88594e8cfc6c40b0d0491872cd28 Incomplete; checking it in as a backup. diff -r 000000000000 -r be282c48010a .hgignore --- /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/ diff -r 000000000000 -r be282c48010a build.xml --- /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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + You can use the following targets: + + help : (default) Prints this message + all : Cleans, compiles, and stages application + clean : Deletes work directories + compile : Compiles servlets into class files + jar : Make JAR file. + + For example, to clean, compile, and package all at once, run: + prompt> ant all + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff -r 000000000000 -r be282c48010a lib/jsoup-1.12.1.jar Binary file lib/jsoup-1.12.1.jar has changed diff -r 000000000000 -r be282c48010a setup.sh --- /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 diff -r 000000000000 -r be282c48010a src/name/blackcap/clipman/Files.kt --- /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 + } +} diff -r 000000000000 -r be282c48010a src/name/blackcap/clipman/Main.kt --- /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) { + 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) +} diff -r 000000000000 -r be282c48010a src/name/blackcap/clipman/Pasteboard.kt --- /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 + private val flavors: Array + + init { + _data = HashMap().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(count()) { elementAt(it) } + } + } + + override fun getTransferData(flavor: DataFlavor): Any { + return _data.get(flavor) ?: throw UnsupportedFlavorException(flavor) + } + + override fun getTransferDataFlavors(): Array = 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 + } + } + } +} diff -r 000000000000 -r be282c48010a src/name/blackcap/clipman/PasteboardQueue.kt --- /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() + 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) diff -r 000000000000 -r be282c48010a src/name/blackcap/clipman/RtfToHtml.kt --- /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 { + 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 { + 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 { + val sb = StringBuilder(e::class.simpleName) + val message = e.message ?: "" + if (!message.isEmpty()) { + sb.append(": ") + sb.append(e.message) + } + return Pair(null, sb.toString()) +}