diff src/main/kotlin/name/blackcap/passman/ImportSubcommand.kt @ 12:a38a2a1036c3

Add import subcommand.
author David Barts <n5jrn@me.com>
date Sun, 22 Jan 2023 09:22:53 -0800
parents
children 302d224bbd57
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/name/blackcap/passman/ImportSubcommand.kt	Sun Jan 22 09:22:53 2023 -0800
@@ -0,0 +1,151 @@
+package name.blackcap.passman
+
+import com.opencsv.CSVParserBuilder
+import com.opencsv.CSVReaderBuilder
+import org.apache.commons.cli.*
+import java.io.FileReader
+import java.io.IOException
+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 db: Database
+
+    /* default option values */
+    private var escape = '\\'
+    private var quote = '"'
+    private var separator = ','
+
+    override fun run(args: Array<String>) {
+        parseArguments(args)
+        db = Database.open()
+        try {
+            doImport()
+        } catch (e: IOException) {
+            die(e.message ?: "I/O error")
+        }
+    }
+
+    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 merge [options] csv_file", options)
+            exitProcess(0)
+        }
+        if (commandLine.args.isEmpty()) {
+            die("expecting other CSV file name", 2)
+        }
+        if (commandLine.args.size > 1) {
+            die("unexpected trailing arguments", 2)
+        }
+        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))
+            .build()
+
+        val csvReader = CSVReaderBuilder(FileReader(commandLine.args[0]))
+            .withCSVParser(csvParser)
+            .build()
+
+        csvReader.use {
+            if (commandLine.hasOption(ImportSubcommand.SKIP)) {
+                it.skip(1)
+            }
+            it.iterator().forEach { fields ->
+                val importedEntry = fromCsv(fields)
+                val thisEntry = Entry.fromDatabase(db, importedEntry.name)
+                try {
+                    if (okToChange(thisEntry, importedEntry)) {
+                        if (thisEntry == null) {
+                            importedEntry.insert(db)
+                        } else {
+                            importedEntry.update(db)
+                        }
+                    }
+                } finally {
+                    thisEntry?.password?.clear()
+                }
+            }
+        }
+    }
+
+    private fun fromCsv(fields: Array<String>): Entry {
+        if (fields.size != NFIELDS) {
+            die("expected $NFIELDS fields but got ${fields.size}")
+        }
+        return Entry(
+            name = fields[0],
+            username = fields[1],
+            password = fields[2].toCharArray(),
+            notes = if (saysNull(fields[3])) null else fields[3],
+            created = parseCsvTime(fields[4]) ?: Date(),
+            modified = parseCsvTime(fields[5]),
+            accessed = parseCsvTime(fields[6])
+        )
+    }
+
+    private fun parseCsvTime(unparsed: String): Date? {
+        if (saysNull(unparsed)) {
+            return null
+        }
+        try {
+            return CSV_DATE_FORMAT.parse(unparsed)
+        } catch (e: ParseException) {
+            die("${see(unparsed)} - invalid date/time string")
+            throw e /* kotlin is too stupid to realize this never happens */
+        }
+    }
+
+    private fun okToChange(thisEntry: Entry?, otherEntry: Entry): Boolean =
+        thisEntry == null || commandLine.hasOption(FORCE) || askUserIfOkToOverwrite(thisEntry, otherEntry)
+
+    private fun saysNull(string: String) = string.lowercase() == "null"
+
+}
\ No newline at end of file