view src/main/kotlin/name/blackcap/passman/ImportSubcommand.kt @ 14:4dae7a15ee48

Fix bugs found in additional round of testing.
author David Barts <n5jrn@me.com>
date Tue, 31 Jan 2023 19:07:46 -0800
parents 302d224bbd57
children 7a74ae668665
line wrap: on
line source

package name.blackcap.passman

import com.opencsv.CSVParserBuilder
import com.opencsv.CSVReaderBuilder
import com.opencsv.exceptions.CsvException
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 = ','

    private var line = 0

    override fun run(args: Array<String>) {
        parseArguments(args)
        db = Database.open()
        try {
            doImport()
        } catch (e: IOException) {
            die(e.message ?: "I/O error")
        } catch (e: CsvException) {
            val message = e.message ?: "CSV error"
            die("line $line, $message")
        }
    }

    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)
        }
        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)) {
                line++
                it.skip(1)
            }

            it.iterator().forEach { fields ->
                line++
                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("line $line, 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"

}