From 56c4967e5dbe90713aea5e4acd73ba13fd7e4819 Mon Sep 17 00:00:00 2001 From: Jonathan White Date: Wed, 26 Aug 2020 15:17:31 -0400 Subject: [PATCH] Improve CSV export and import capability * Fixes #3541 * CSV export now includes TOTP settings, Entry Icon (database icon number only), Modified Time, and Created Time. * CSV import properly understands time in ISO 8601 format and Unix Timestamp. * CSV import will set the TOTP settings and entry icon based on the chosen column. --- src/core/Entry.cpp | 12 +- src/core/Entry.h | 1 + src/format/CsvExporter.cpp | 8 ++ src/gui/csvImport/CsvImportWidget.cpp | 43 +++++-- src/gui/csvImport/CsvImportWidget.ui | 154 ++++++++++++++++++-------- tests/TestCli.cpp | 4 +- tests/TestCsvExporter.cpp | 21 ++-- 7 files changed, 178 insertions(+), 65 deletions(-) diff --git a/src/core/Entry.cpp b/src/core/Entry.cpp index 0322d353c5..72b48283ae 100644 --- a/src/core/Entry.cpp +++ b/src/core/Entry.cpp @@ -463,7 +463,8 @@ void Entry::setTotp(QSharedPointer settings) m_data.totpSettings.reset(); } else { m_data.totpSettings = std::move(settings); - auto text = Totp::writeSettings(m_data.totpSettings, title(), username()); + auto text = Totp::writeSettings( + m_data.totpSettings, resolveMultiplePlaceholders(title()), resolveMultiplePlaceholders(username())); if (m_data.totpSettings->format != Totp::StorageFormat::LEGACY) { m_attributes->set(Totp::ATTRIBUTE_OTP, text, true); } else { @@ -491,6 +492,15 @@ QSharedPointer Entry::totpSettings() const return m_data.totpSettings; } +QString Entry::totpSettingsString() const +{ + if (m_data.totpSettings) { + return Totp::writeSettings( + m_data.totpSettings, resolveMultiplePlaceholders(title()), resolveMultiplePlaceholders(username()), true); + } + return {}; +} + void Entry::setUuid(const QUuid& uuid) { Q_ASSERT(!uuid.isNull()); diff --git a/src/core/Entry.h b/src/core/Entry.h index cbaf3e2c03..6fef119404 100644 --- a/src/core/Entry.h +++ b/src/core/Entry.h @@ -106,6 +106,7 @@ class Entry : public QObject QString notes() const; QString attribute(const QString& key) const; QString totp() const; + QString totpSettingsString() const; QSharedPointer totpSettings() const; int size() const; diff --git a/src/format/CsvExporter.cpp b/src/format/CsvExporter.cpp index 98fc6fdc83..281b947616 100644 --- a/src/format/CsvExporter.cpp +++ b/src/format/CsvExporter.cpp @@ -67,6 +67,10 @@ QString CsvExporter::exportHeader() addColumn(header, "Password"); addColumn(header, "URL"); addColumn(header, "Notes"); + addColumn(header, "TOTP"); + addColumn(header, "Icon"); + addColumn(header, "Last Modified"); + addColumn(header, "Created"); return header + QString("\n"); } @@ -88,6 +92,10 @@ QString CsvExporter::exportGroup(const Group* group, QString groupPath) addColumn(line, entry->password()); addColumn(line, entry->url()); addColumn(line, entry->notes()); + addColumn(line, entry->totpSettingsString()); + addColumn(line, QString::number(entry->iconNumber())); + addColumn(line, entry->timeInfo().lastModificationTime().toString(Qt::ISODate)); + addColumn(line, entry->timeInfo().creationTime().toString(Qt::ISODate)); line.append("\n"); response.append(line); diff --git a/src/gui/csvImport/CsvImportWidget.cpp b/src/gui/csvImport/CsvImportWidget.cpp index 01fd5fc89e..e78e9f94a1 100644 --- a/src/gui/csvImport/CsvImportWidget.cpp +++ b/src/gui/csvImport/CsvImportWidget.cpp @@ -27,6 +27,7 @@ #include "format/KeePass2Writer.h" #include "gui/MessageBox.h" #include "gui/MessageWidget.h" +#include "totp/totp.h" // I wanted to make the CSV import GUI future-proof, so if one day you need a new field, // all you have to do is add a field to m_columnHeader, and the GUI will follow: @@ -39,7 +40,8 @@ CsvImportWidget::CsvImportWidget(QWidget* parent) , m_comboModel(new QStringListModel(this)) , m_columnHeader(QStringList() << QObject::tr("Group") << QObject::tr("Title") << QObject::tr("Username") << QObject::tr("Password") << QObject::tr("URL") << QObject::tr("Notes") - << QObject::tr("Last Modified") << QObject::tr("Created")) + << QObject::tr("TOTP") << QObject::tr("Icon") << QObject::tr("Last Modified") + << QObject::tr("Created")) , m_fieldSeparatorList(QStringList() << "," << ";" << "-" @@ -54,7 +56,7 @@ CsvImportWidget::CsvImportWidget(QWidget* parent) m_ui->messageWidget->setHidden(true); m_combos << m_ui->groupCombo << m_ui->titleCombo << m_ui->usernameCombo << m_ui->passwordCombo << m_ui->urlCombo - << m_ui->notesCombo << m_ui->lastModifiedCombo << m_ui->createdCombo; + << m_ui->notesCombo << m_ui->totpCombo << m_ui->iconCombo << m_ui->lastModifiedCombo << m_ui->createdCombo; for (auto combo : m_combos) { combo->setModel(m_comboModel); @@ -206,17 +208,38 @@ void CsvImportWidget::writeDatabase() entry->setUrl(m_parserModel->data(m_parserModel->index(r, 4)).toString()); entry->setNotes(m_parserModel->data(m_parserModel->index(r, 5)).toString()); - TimeInfo timeInfo; if (m_parserModel->data(m_parserModel->index(r, 6)).isValid()) { - qint64 lastModified = m_parserModel->data(m_parserModel->index(r, 6)).toString().toLongLong(); - if (lastModified) { - timeInfo.setLastModificationTime(Clock::datetimeUtc(lastModified * 1000)); + auto totp = Totp::parseSettings(m_parserModel->data(m_parserModel->index(r, 6)).toString()); + entry->setTotp(totp); + } + + bool ok; + int icon = m_parserModel->data(m_parserModel->index(r, 7)).toInt(&ok); + if (ok) { + entry->setIcon(icon); + } + + TimeInfo timeInfo; + if (m_parserModel->data(m_parserModel->index(r, 8)).isValid()) { + auto datetime = m_parserModel->data(m_parserModel->index(r, 8)).toString(); + if (datetime.contains(QRegularExpression("^\\d+$"))) { + timeInfo.setLastModificationTime(Clock::datetimeUtc(datetime.toLongLong() * 1000)); + } else { + auto lastModified = QDateTime::fromString(datetime, Qt::ISODate); + if (lastModified.isValid()) { + timeInfo.setLastModificationTime(lastModified); + } } } - if (m_parserModel->data(m_parserModel->index(r, 7)).isValid()) { - qint64 created = m_parserModel->data(m_parserModel->index(r, 7)).toString().toLongLong(); - if (created) { - timeInfo.setCreationTime(Clock::datetimeUtc(created * 1000)); + if (m_parserModel->data(m_parserModel->index(r, 9)).isValid()) { + auto datetime = m_parserModel->data(m_parserModel->index(r, 9)).toString(); + if (datetime.contains(QRegularExpression("^\\d+$"))) { + timeInfo.setCreationTime(Clock::datetimeUtc(datetime.toLongLong() * 1000)); + } else { + auto created = QDateTime::fromString(datetime, Qt::ISODate); + if (created.isValid()) { + timeInfo.setCreationTime(created); + } } } entry->setTimeInfo(timeInfo); diff --git a/src/gui/csvImport/CsvImportWidget.ui b/src/gui/csvImport/CsvImportWidget.ui index 1c268fd9dd..d3364cb4ca 100644 --- a/src/gui/csvImport/CsvImportWidget.ui +++ b/src/gui/csvImport/CsvImportWidget.ui @@ -96,8 +96,11 @@ - - + + + + + 50 @@ -105,15 +108,18 @@ - Last Modified + Password Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + 2 + - - + + 50 @@ -121,18 +127,24 @@ - Password + Username Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + 2 + - - + + - - + + + + + 50 @@ -140,15 +152,21 @@ - Created + Title Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + 2 + - - + + + + + 50 @@ -156,15 +174,18 @@ - Notes + Group Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + 2 + - - + + 50 @@ -172,18 +193,21 @@ - Title + URL Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + 2 + - - + + - - + + 50 @@ -191,15 +215,21 @@ - Group + Notes Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + 2 + - - + + + + + 50 @@ -207,15 +237,24 @@ - URL + TOTP Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + 2 + - - + + + + + + + + 50 @@ -223,30 +262,59 @@ - Username + Created Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + 2 + - - - - - + + + + + 50 + false + + + + Last Modified + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 2 + + - - + + - - + + + + + 50 + false + + + + Icon + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 2 + + - - - - + @@ -682,10 +750,6 @@ titleCombo usernameCombo passwordCombo - urlCombo - notesCombo - lastModifiedCombo - createdCombo comboBoxCodec comboBoxTextQualifier comboBoxFieldSeparator diff --git a/tests/TestCli.cpp b/tests/TestCli.cpp index 348afb670d..80af58bad1 100644 --- a/tests/TestCli.cpp +++ b/tests/TestCli.cpp @@ -928,10 +928,10 @@ void TestCli::testExport() setInput("a"); execCmd(exportCmd, {"export", "-f", "csv", m_dbFile->fileName()}); QByteArray csvHeader = m_stdout->readLine(); - QCOMPARE(csvHeader, QByteArray("\"Group\",\"Title\",\"Username\",\"Password\",\"URL\",\"Notes\"\n")); + QVERIFY(csvHeader.contains(QByteArray("\"Group\",\"Title\",\"Username\",\"Password\",\"URL\",\"Notes\""))); QByteArray csvData = m_stdout->readAll(); QVERIFY(csvData.contains(QByteArray( - "\"NewDatabase\",\"Sample Entry\",\"User Name\",\"Password\",\"http://www.somesite.com/\",\"Notes\"\n"))); + "\"NewDatabase\",\"Sample Entry\",\"User Name\",\"Password\",\"http://www.somesite.com/\",\"Notes\""))); // test invalid format setInput("a"); diff --git a/tests/TestCsvExporter.cpp b/tests/TestCsvExporter.cpp index 63ba11488c..8e7e6021d1 100644 --- a/tests/TestCsvExporter.cpp +++ b/tests/TestCsvExporter.cpp @@ -23,11 +23,13 @@ #include "crypto/Crypto.h" #include "format/CsvExporter.h" +#include "totp/totp.h" QTEST_GUILESS_MAIN(TestCsvExporter) const QString TestCsvExporter::ExpectedHeaderLine = - QString("\"Group\",\"Title\",\"Username\",\"Password\",\"URL\",\"Notes\"\n"); + QString("\"Group\",\"Title\",\"Username\",\"Password\",\"URL\",\"Notes\",\"TOTP\",\"Icon\",\"Last " + "Modified\",\"Created\"\n"); void TestCsvExporter::init() { @@ -57,17 +59,23 @@ void TestCsvExporter::testExport() entry->setPassword("Test Password"); entry->setUrl("http://test.url"); entry->setNotes("Test Notes"); + entry->setTotp(Totp::createSettings("DFDF", Totp::DEFAULT_DIGITS, Totp::DEFAULT_STEP)); + entry->setIcon(5); QBuffer buffer; QVERIFY(buffer.open(QIODevice::ReadWrite)); m_csvExporter->exportDatabase(&buffer, m_db); + auto exported = QString::fromUtf8(buffer.buffer()); QString expectedResult = QString() .append(ExpectedHeaderLine) .append("\"Passwords/Test Group Name\",\"Test Entry Title\",\"Test Username\",\"Test " - "Password\",\"http://test.url\",\"Test Notes\"\n"); + "Password\",\"http://test.url\",\"Test Notes\""); - QCOMPARE(QString::fromUtf8(buffer.buffer().constData()), expectedResult); + QVERIFY(exported.startsWith(expectedResult)); + exported.remove(expectedResult); + QVERIFY(exported.contains("otpauth://")); + QVERIFY(exported.contains(",\"5\",")); } void TestCsvExporter::testEmptyDatabase() @@ -95,10 +103,9 @@ void TestCsvExporter::testNestedGroups() QBuffer buffer; QVERIFY(buffer.open(QIODevice::ReadWrite)); m_csvExporter->exportDatabase(&buffer, m_db); - - QCOMPARE( - QString::fromUtf8(buffer.buffer().constData()), + auto exported = QString::fromUtf8(buffer.buffer()); + QVERIFY(exported.startsWith( QString() .append(ExpectedHeaderLine) - .append("\"Passwords/Test Group Name/Test Sub Group Name\",\"Test Entry Title\",\"\",\"\",\"\",\"\"\n")); + .append("\"Passwords/Test Group Name/Test Sub Group Name\",\"Test Entry Title\",\"\",\"\",\"\",\"\""))); }