# HG changeset patch # User David Barts # Date 1675623039 28800 # Node ID 7a74ae668665f6d4a7cc29825d2cd4bc7f270a3b # Parent 0fc90892a3aee048e24c5c40bc0e6a476c7b37fe Add export subcommand. diff -r 0fc90892a3ae -r 7a74ae668665 src/main/kotlin/name/blackcap/passman/Arguments.kt --- /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()!! + 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, val into: Any) { + private companion object { + val BOOLEAN_TYPE = typeOf() + val STRING_TYPE = typeOf() + val CHAR_TYPE = typeOf() + } + + val annotated = into::class.members.filter { it is KMutableProperty<*> && it.hasAnnotation() } + val options = Options() + lateinit var commandLine: CommandLine + + fun parse(): Array { + 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 { + 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>.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, into: Any): Array = + AnnotationArgumentParser(name, args, into).parse() diff -r 0fc90892a3ae -r 7a74ae668665 src/main/kotlin/name/blackcap/passman/Database.kt --- 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) } } + diff -r 0fc90892a3ae -r 7a74ae668665 src/main/kotlin/name/blackcap/passman/ExportSubcommand.kt --- /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) { + parseArguments(args) + db = Database.open() + try { + doExport() + } catch (e: IOException) { + die(e.message ?: "I/O error") + } + } + + private fun parseArguments(args: Array) { + 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( + 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 diff -r 0fc90892a3ae -r 7a74ae668665 src/main/kotlin/name/blackcap/passman/HelpSubcommand.kt --- 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.") diff -r 0fc90892a3ae -r 7a74ae668665 src/main/kotlin/name/blackcap/passman/ImportExportArguments.kt --- /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 +} diff -r 0fc90892a3ae -r 7a74ae668665 src/main/kotlin/name/blackcap/passman/ImportSubcommand.kt --- 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) { @@ -50,58 +34,32 @@ } private fun parseArguments(args: Array) { - 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"