changeset 16:7a74ae668665

Add export subcommand.
author David Barts <n5jrn@me.com>
date Sun, 05 Feb 2023 10:50:39 -0800
parents 0fc90892a3ae
children 4427199eb218
files src/main/kotlin/name/blackcap/passman/Arguments.kt src/main/kotlin/name/blackcap/passman/Database.kt src/main/kotlin/name/blackcap/passman/ExportSubcommand.kt src/main/kotlin/name/blackcap/passman/HelpSubcommand.kt src/main/kotlin/name/blackcap/passman/ImportExportArguments.kt src/main/kotlin/name/blackcap/passman/ImportSubcommand.kt
diffstat 6 files changed, 237 insertions(+), 64 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/name/blackcap/passman/Arguments.kt	Sun Feb 05 10:50:39 2023 -0800
@@ -0,0 +1,104 @@
+package name.blackcap.passman
+
+import org.apache.commons.cli.*
+import kotlin.reflect.KCallable
+import kotlin.reflect.KMutableProperty
+import kotlin.reflect.full.findAnnotation
+import kotlin.reflect.full.hasAnnotation
+import kotlin.reflect.full.isSubtypeOf
+import kotlin.reflect.typeOf
+import kotlin.system.exitProcess
+
+@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY)
+annotation class Argument(
+    val shortName: Char = AnnotationArgumentInfo.UNSPEC_SHORT,
+    val longName: String = AnnotationArgumentInfo.UNSPEC_LONG,
+    val description: String);
+
+private class AnnotationArgumentInfo(val property: KMutableProperty<*>) {
+    companion object {
+        const val UNSPEC_SHORT: Char = '\u0000'
+        const val UNSPEC_LONG: String = ""
+    }
+    val type = property.returnType
+    val annotation = property.findAnnotation<Argument>()!!
+    val longName = if (annotation.longName == UNSPEC_LONG) { property.name } else { annotation.longName }
+    val shortName = if (annotation.shortName == UNSPEC_SHORT) { longName.first() } else { annotation.shortName }
+}
+
+private class AnnotationArgumentParser(val name: String, val args: Array<String>, val into: Any) {
+    private companion object {
+        val BOOLEAN_TYPE = typeOf<Boolean>()
+        val STRING_TYPE = typeOf<String>()
+        val CHAR_TYPE = typeOf<Char>()
+    }
+
+    val annotated = into::class.members.filter { it is KMutableProperty<*> && it.hasAnnotation<Argument>() }
+    val options = Options()
+    lateinit var commandLine: CommandLine
+
+    fun parse(): Array<String> {
+        build()
+        doParse()
+        return extract()
+    }
+
+    private fun build() {
+        annotated.iterate { _, info ->
+            when {
+                info.type.isSubtypeOf(BOOLEAN_TYPE) ->
+                    options.addOption(info.shortName.toString(), info.longName, false, info.annotation.description)
+
+                info.type.isSubtypeOf(STRING_TYPE) || info.type.isSubtypeOf(CHAR_TYPE) ->
+                    options.addOption(info.shortName.toString(), info.longName, true, info.annotation.description)
+            }
+        }
+    }
+
+    private fun doParse() {
+        commandLine = try {
+            DefaultParser().parse(options, args)
+        } catch (e: ParseException) {
+            die(e.message ?: "syntax error", 2)
+            throw RuntimeException("this will never happen")
+        }
+        if (commandLine.hasOption("help")) {
+            HelpFormatter().printHelp("$SHORTNAME $name [options] csv_file", options)
+            exitProcess(0)
+        }
+    }
+
+    private fun extract(): Array<String> {
+        annotated.iterate { annotated, info ->
+            if (commandLine.hasOption(info.longName)) {
+                when {
+                    info.type.isSubtypeOf(BOOLEAN_TYPE) ->
+                        annotated.setter.call(true)
+                    info.type.isSubtypeOf(STRING_TYPE) ->
+                        annotated.setter.call(commandLine.getOptionValue(info.longName))
+                    info.type.isSubtypeOf(CHAR_TYPE) ->
+                        annotated.setter.call(commandLine.getCharOptionValue(info.longName))
+                }
+            }
+        }
+        return commandLine.args
+    }
+
+    private fun Collection<KCallable<*>>.iterate(block: (KMutableProperty<*>, AnnotationArgumentInfo) -> Unit) =
+        forEach {
+            block(it as KMutableProperty<*>, AnnotationArgumentInfo(it))
+        }
+
+    private fun CommandLine.getCharOptionValue(name: String): Char {
+        val optionValue = getOptionValue(name)
+        when (optionValue.length) {
+            0 -> die("--$name value must not be empty")
+            1 -> return optionValue[0]
+            else -> die("--$name value must be a single character")
+        }
+        throw RuntimeException("this will never happen")
+    }
+}
+
+fun parseInto(name: String, args: Array<String>, into: Any): Array<String> =
+    AnnotationArgumentParser(name, args, into).parse()
--- a/src/main/kotlin/name/blackcap/passman/Database.kt	Fri Feb 03 18:48:13 2023 -0800
+++ b/src/main/kotlin/name/blackcap/passman/Database.kt	Sun Feb 05 10:50:39 2023 -0800
@@ -157,3 +157,4 @@
         setLong(parameterIndex, value)
     }
 }
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/name/blackcap/passman/ExportSubcommand.kt	Sun Feb 05 10:50:39 2023 -0800
@@ -0,0 +1,77 @@
+package name.blackcap.passman
+
+import com.opencsv.CSVWriterBuilder
+import com.opencsv.ICSVWriter
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.OutputStreamWriter
+import java.sql.ResultSet
+import java.text.SimpleDateFormat
+import java.util.*
+
+class ExportSubcommand() : Subcommand() {
+    private companion object {
+        const val NULL = "null"
+    }
+    private lateinit var csvDateFormat: SimpleDateFormat
+    private lateinit var db: Database
+    private val options = ImportExportArguments()
+    private lateinit var csvFile: String
+
+    override fun run(args: Array<String>) {
+        parseArguments(args)
+        db = Database.open()
+        try {
+            doExport()
+        } catch (e: IOException) {
+            die(e.message ?: "I/O error")
+        }
+    }
+
+    private fun parseArguments(args: Array<String>) {
+        val params = parseInto("export", args, options)
+        when (params.size) {
+            0 -> die("expecting CSV file name", 2)
+            1 -> csvFile = params[0]
+            else -> die("unexpected trailing arguments", 2)
+        }
+        csvDateFormat = SimpleDateFormat(options.format).apply {
+            timeZone = TimeZone.getTimeZone(options.zone)
+            isLenient = false
+        }
+    }
+
+    private fun doExport() {
+        val csvWriter = CSVWriterBuilder(OutputStreamWriter(FileOutputStream(csvFile), options.charset))
+            .withEscapeChar(options.escape)
+            .withQuoteChar(options.quote)
+            .withSeparator(options.separator)
+            .withLineEnd(ICSVWriter.RFC4180_LINE_END)
+            .build()
+
+        try {
+            db.connection.prepareStatement("select name, username, password, notes, created, modified, accessed from passwords").use {
+                val results = it.executeQuery()
+                while (results.next()) {
+                    val entry = arrayOf<String>(
+                        results.getDecryptedString(1, db.encryption)!!,
+                        results.getDecryptedString(2, db.encryption)!!,
+                        results.getDecryptedString(3, db.encryption)!!,
+                        results.getDecryptedString(4, db.encryption) ?: NULL,
+                        results.getTimeString(5),
+                        results.getTimeString(6),
+                        results.getTimeString(7)
+                        )
+                    csvWriter.writeNext(entry)
+                }
+            }
+        } finally {
+            csvWriter.close()
+        }
+    }
+
+    private fun ResultSet.getTimeString(columnIndex: Int): String {
+        val value = getDate(columnIndex)
+        return if (value == null) NULL else csvDateFormat.format(value)
+    }
+}
\ No newline at end of file
--- a/src/main/kotlin/name/blackcap/passman/HelpSubcommand.kt	Fri Feb 03 18:48:13 2023 -0800
+++ b/src/main/kotlin/name/blackcap/passman/HelpSubcommand.kt	Sun Feb 05 10:50:39 2023 -0800
@@ -7,6 +7,7 @@
         println("create       Create a new username/password pair.")
         println("delete       Delete existing record.")
         println("help         Print this message.")
+        println("export       Export to CSV file.")
         println("import       Import from CSV file.")
         println("list         List records.")
         println("merge        Merge passwords in from another PassMan database.")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/name/blackcap/passman/ImportExportArguments.kt	Sun Feb 05 10:50:39 2023 -0800
@@ -0,0 +1,32 @@
+package name.blackcap.passman
+
+class ImportExportArguments {
+    private companion object {
+        const val D_CHARSET = "UTF-8"
+        const val D_ESCAPE = '\\'
+        const val D_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
+        const val D_QUOTE = '"'
+        const val D_SEPARATOR = ','
+        const val D_ZONE = "UTC"
+    }
+    @Argument(description = "Character set of CSV file (default $D_CHARSET).")
+    var charset: String = D_CHARSET
+    @Argument(description = "CSV escape character (default $D_ESCAPE).")
+    var escape: Char = D_ESCAPE
+    @Argument(description = "Do not ask before overwriting.")
+    var force: Boolean = false
+    @Argument(description = "Time format (default $D_FORMAT).")
+    var format: String = D_FORMAT
+    @Argument(description = "Print this help message.")
+    var help: Boolean = false
+    @Argument(description = "Ignore white space before quoted strings.")
+    var ignore: Boolean = false
+    @Argument(description = "CSV string-quoting character (default $D_QUOTE).")
+    var quote: Char = D_QUOTE
+    @Argument(description = "CSV separator character (default $D_SEPARATOR).")
+    var separator: Char = D_SEPARATOR
+    @Argument(description = "Skip first line of input", shortName = 'k')
+    var skip: Boolean = false
+    @Argument(description = "Time zone (default $D_ZONE).")
+    var zone: String = D_ZONE
+}
--- a/src/main/kotlin/name/blackcap/passman/ImportSubcommand.kt	Fri Feb 03 18:48:13 2023 -0800
+++ b/src/main/kotlin/name/blackcap/passman/ImportSubcommand.kt	Sun Feb 05 10:50:39 2023 -0800
@@ -3,37 +3,21 @@
 import com.opencsv.CSVParserBuilder
 import com.opencsv.CSVReaderBuilder
 import com.opencsv.exceptions.CsvException
-import org.apache.commons.cli.*
-import java.io.FileReader
+import org.apache.commons.cli.ParseException
+import java.io.FileInputStream
 import java.io.IOException
+import java.io.InputStreamReader
 import java.text.SimpleDateFormat
 import java.util.*
-import kotlin.system.exitProcess
 
 class ImportSubcommand(): Subcommand() {
     private companion object {
-        val CSV_DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").apply {
-            timeZone = TimeZone.getTimeZone("UTC")
-            isLenient = false
-        }
-        const val ESCAPE = "escape"
-        const val FORCE = "force"
-        const val HELP = "help"
-        const val IGNORE = "ignore"
-        const val QUOTE = "quote"
-        const val SEPARATOR = "separator"
-        const val SKIP = "skip"
         const val NFIELDS = 7
-
     }
-    private lateinit var commandLine: CommandLine
+    private lateinit var csvDateFormat: SimpleDateFormat
     private lateinit var db: Database
-
-    /* default option values */
-    private var escape = '\\'
-    private var quote = '"'
-    private var separator = ','
-
+    private val options = ImportExportArguments()
+    private lateinit var csvFile: String
     private var line = 0
 
     override fun run(args: Array<String>) {
@@ -50,58 +34,32 @@
     }
 
     private fun parseArguments(args: Array<String>) {
-        val options = Options().apply {
-            addOption("e", ImportSubcommand.ESCAPE, true, "CSV escape character (default $escape).")
-            addOption("f", ImportSubcommand.FORCE, false, "Do not ask before overwriting.")
-            addOption("h", ImportSubcommand.HELP, false, "Print this help message.")
-            addOption("i", ImportSubcommand.IGNORE, false, "Ignore white space before quoted strings.")
-            addOption("q", ImportSubcommand.QUOTE, true, "CSV string-quoting character (default $quote).")
-            addOption("s", ImportSubcommand.SEPARATOR, true, "CSV separator character (default $separator).")
-            addOption("k", ImportSubcommand.SKIP, false, "Skip first line of input.")
-        }
-        try {
-            commandLine = DefaultParser().parse(options, args)
-        } catch (e: ParseException) {
-            die(e.message ?: "syntax error", 2)
-        }
-        if (commandLine.hasOption(ImportSubcommand.HELP)) {
-            HelpFormatter().printHelp("$SHORTNAME import [options] csv_file", options)
-            exitProcess(0)
+        val params = parseInto("import", args, options)
+        when (params.size) {
+            0 -> die("expecting CSV file name", 2)
+            1 -> csvFile = params[0]
+            else -> die("unexpected trailing arguments", 2)
         }
-        if (commandLine.args.isEmpty()) {
-            die("expecting other CSV file name", 2)
-        }
-        if (commandLine.args.size > 1) {
-            die("unexpected trailing arguments", 2)
+        csvDateFormat = SimpleDateFormat(options.format).apply {
+            timeZone = TimeZone.getTimeZone(options.zone)
+            isLenient = false
         }
-        escape = getOptionChar(ImportSubcommand.ESCAPE, escape)
-        quote = getOptionChar(ImportSubcommand.QUOTE, quote)
-        separator = getOptionChar(ImportSubcommand.SEPARATOR, separator)
-    }
-
-    private fun getOptionChar(optionName: String, defaultValue: Char): Char {
-        val optionValue = commandLine.getOptionValue(optionName) ?: return defaultValue
-        val ret = optionValue.firstOrNull()
-        if (ret == null) {
-            die("--$optionName value must not be empty")
-        }
-        return ret!!
     }
 
     private fun doImport() {
         val csvParser = CSVParserBuilder()
-            .withEscapeChar(escape)
-            .withQuoteChar(quote)
-            .withSeparator(separator)
-            .withIgnoreLeadingWhiteSpace(commandLine.hasOption(ImportSubcommand.IGNORE))
+            .withEscapeChar(options.escape)
+            .withQuoteChar(options.quote)
+            .withSeparator(options.separator)
+            .withIgnoreLeadingWhiteSpace(options.ignore)
             .build()
 
-        val csvReader = CSVReaderBuilder(FileReader(commandLine.args[0]))
+        val csvReader = CSVReaderBuilder(InputStreamReader(FileInputStream(csvFile), options.charset))
             .withCSVParser(csvParser)
             .build()
 
         csvReader.use {
-            if (commandLine.hasOption(ImportSubcommand.SKIP)) {
+            if (options.skip) {
                 line++
                 it.skip(1)
             }
@@ -145,7 +103,7 @@
             return null
         }
         try {
-            return CSV_DATE_FORMAT.parse(unparsed)
+            return csvDateFormat.parse(unparsed)
         } catch (e: ParseException) {
             die("${see(unparsed)} - invalid date/time string")
             throw e /* kotlin is too stupid to realize this never happens */
@@ -153,7 +111,7 @@
     }
 
     private fun okToChange(thisEntry: Entry?, otherEntry: Entry): Boolean =
-        thisEntry == null || commandLine.hasOption(FORCE) || askUserIfOkToOverwrite(thisEntry, otherEntry)
+        thisEntry == null || options.force || askUserIfOkToOverwrite(thisEntry, otherEntry)
 
     private fun saysNull(string: String) = string.lowercase() == "null"