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.
This commit is contained in:
Jonathan White 2020-08-26 15:17:31 -04:00
parent 3f7e79cdf3
commit 7ac651763c
7 changed files with 189 additions and 76 deletions

View File

@ -465,7 +465,8 @@ void Entry::setTotp(QSharedPointer<Totp::Settings> settings)
m_data.totpSettings.reset(); m_data.totpSettings.reset();
} else { } else {
m_data.totpSettings = std::move(settings); 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) { if (m_data.totpSettings->format != Totp::StorageFormat::LEGACY) {
m_attributes->set(Totp::ATTRIBUTE_OTP, text, true); m_attributes->set(Totp::ATTRIBUTE_OTP, text, true);
} else { } else {
@ -493,6 +494,15 @@ QSharedPointer<Totp::Settings> Entry::totpSettings() const
return m_data.totpSettings; 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) void Entry::setUuid(const QUuid& uuid)
{ {
Q_ASSERT(!uuid.isNull()); Q_ASSERT(!uuid.isNull());

View File

@ -106,6 +106,7 @@ public:
QString notes() const; QString notes() const;
QString attribute(const QString& key) const; QString attribute(const QString& key) const;
QString totp() const; QString totp() const;
QString totpSettingsString() const;
QSharedPointer<Totp::Settings> totpSettings() const; QSharedPointer<Totp::Settings> totpSettings() const;
int size() const; int size() const;

View File

@ -67,6 +67,10 @@ QString CsvExporter::exportHeader()
addColumn(header, "Password"); addColumn(header, "Password");
addColumn(header, "URL"); addColumn(header, "URL");
addColumn(header, "Notes"); addColumn(header, "Notes");
addColumn(header, "TOTP");
addColumn(header, "Icon");
addColumn(header, "Last Modified");
addColumn(header, "Created");
return header + QString("\n"); return header + QString("\n");
} }
@ -88,6 +92,10 @@ QString CsvExporter::exportGroup(const Group* group, QString groupPath)
addColumn(line, entry->password()); addColumn(line, entry->password());
addColumn(line, entry->url()); addColumn(line, entry->url());
addColumn(line, entry->notes()); 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"); line.append("\n");
response.append(line); response.append(line);

View File

@ -27,6 +27,7 @@
#include "format/KeePass2Writer.h" #include "format/KeePass2Writer.h"
#include "gui/MessageBox.h" #include "gui/MessageBox.h"
#include "gui/MessageWidget.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, // 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: // 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_comboModel(new QStringListModel(this))
, m_columnHeader(QStringList() << QObject::tr("Group") << QObject::tr("Title") << QObject::tr("Username") , m_columnHeader(QStringList() << QObject::tr("Group") << QObject::tr("Title") << QObject::tr("Username")
<< QObject::tr("Password") << QObject::tr("URL") << QObject::tr("Notes") << 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() << "," , m_fieldSeparatorList(QStringList() << ","
<< ";" << ";"
<< "-" << "-"
@ -54,7 +56,7 @@ CsvImportWidget::CsvImportWidget(QWidget* parent)
m_ui->messageWidget->setHidden(true); m_ui->messageWidget->setHidden(true);
m_combos << m_ui->groupCombo << m_ui->titleCombo << m_ui->usernameCombo << m_ui->passwordCombo << m_ui->urlCombo 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) { for (auto combo : m_combos) {
combo->setModel(m_comboModel); combo->setModel(m_comboModel);
@ -206,17 +208,38 @@ void CsvImportWidget::writeDatabase()
entry->setUrl(m_parserModel->data(m_parserModel->index(r, 4)).toString()); entry->setUrl(m_parserModel->data(m_parserModel->index(r, 4)).toString());
entry->setNotes(m_parserModel->data(m_parserModel->index(r, 5)).toString()); entry->setNotes(m_parserModel->data(m_parserModel->index(r, 5)).toString());
TimeInfo timeInfo;
if (m_parserModel->data(m_parserModel->index(r, 6)).isValid()) { if (m_parserModel->data(m_parserModel->index(r, 6)).isValid()) {
qint64 lastModified = m_parserModel->data(m_parserModel->index(r, 6)).toString().toLongLong(); auto totp = Totp::parseSettings(m_parserModel->data(m_parserModel->index(r, 6)).toString());
if (lastModified) { entry->setTotp(totp);
timeInfo.setLastModificationTime(Clock::datetimeUtc(lastModified * 1000)); }
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()) { if (m_parserModel->data(m_parserModel->index(r, 9)).isValid()) {
qint64 created = m_parserModel->data(m_parserModel->index(r, 7)).toString().toLongLong(); auto datetime = m_parserModel->data(m_parserModel->index(r, 9)).toString();
if (created) { if (datetime.contains(QRegularExpression("^\\d+$"))) {
timeInfo.setCreationTime(Clock::datetimeUtc(created * 1000)); timeInfo.setCreationTime(Clock::datetimeUtc(datetime.toLongLong() * 1000));
} else {
auto created = QDateTime::fromString(datetime, Qt::ISODate);
if (created.isValid()) {
timeInfo.setCreationTime(created);
}
} }
} }
entry->setTimeInfo(timeInfo); entry->setTimeInfo(timeInfo);

View File

@ -96,21 +96,8 @@
<layout class="QVBoxLayout" name="verticalLayout_4"> <layout class="QVBoxLayout" name="verticalLayout_4">
<item> <item>
<layout class="QGridLayout" name="gridLayout_combos"> <layout class="QGridLayout" name="gridLayout_combos">
<item row="2" column="2"> <item row="3" column="1">
<widget class="QLabel" name="lastModifiedLabel"> <widget class="QComboBox" name="passwordCombo"/>
<property name="font">
<font>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>Last Modified</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item> </item>
<item row="3" column="0"> <item row="3" column="0">
<widget class="QLabel" name="passwordLabel"> <widget class="QLabel" name="passwordLabel">
@ -126,13 +113,13 @@
<property name="alignment"> <property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property> </property>
<property name="indent">
<number>2</number>
</property>
</widget> </widget>
</item> </item>
<item row="3" column="1"> <item row="2" column="0">
<widget class="QComboBox" name="passwordCombo"/> <widget class="QLabel" name="usernameLabel">
</item>
<item row="3" column="2">
<widget class="QLabel" name="createdLabel">
<property name="font"> <property name="font">
<font> <font>
<weight>50</weight> <weight>50</weight>
@ -140,28 +127,21 @@
</font> </font>
</property> </property>
<property name="text"> <property name="text">
<string>Created</string> <string>Username</string>
</property> </property>
<property name="alignment"> <property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property> </property>
<property name="indent">
<number>2</number>
</property>
</widget> </widget>
</item> </item>
<item row="1" column="2"> <item row="2" column="1">
<widget class="QLabel" name="notesLabel"> <widget class="QComboBox" name="usernameCombo"/>
<property name="font"> </item>
<font> <item row="1" column="1">
<weight>50</weight> <widget class="QComboBox" name="titleCombo"/>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>Notes</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item> </item>
<item row="1" column="0"> <item row="1" column="0">
<widget class="QLabel" name="titleLabel"> <widget class="QLabel" name="titleLabel">
@ -177,6 +157,9 @@
<property name="alignment"> <property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property> </property>
<property name="indent">
<number>2</number>
</property>
</widget> </widget>
</item> </item>
<item row="0" column="1"> <item row="0" column="1">
@ -196,9 +179,12 @@
<property name="alignment"> <property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property> </property>
<property name="indent">
<number>2</number>
</property>
</widget> </widget>
</item> </item>
<item row="0" column="2"> <item row="4" column="0">
<widget class="QLabel" name="urlLabel"> <widget class="QLabel" name="urlLabel">
<property name="font"> <property name="font">
<font> <font>
@ -212,10 +198,16 @@
<property name="alignment"> <property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property> </property>
<property name="indent">
<number>2</number>
</property>
</widget> </widget>
</item> </item>
<item row="2" column="0"> <item row="4" column="1">
<widget class="QLabel" name="usernameLabel"> <widget class="QComboBox" name="urlCombo"/>
</item>
<item row="0" column="2">
<widget class="QLabel" name="notesLabel">
<property name="font"> <property name="font">
<font> <font>
<weight>50</weight> <weight>50</weight>
@ -223,30 +215,106 @@
</font> </font>
</property> </property>
<property name="text"> <property name="text">
<string>Username</string> <string>Notes</string>
</property> </property>
<property name="alignment"> <property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set> <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property> </property>
<property name="indent">
<number>2</number>
</property>
</widget> </widget>
</item> </item>
<item row="2" column="1">
<widget class="QComboBox" name="usernameCombo"/>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="titleCombo"/>
</item>
<item row="0" column="3"> <item row="0" column="3">
<widget class="QComboBox" name="urlCombo"/>
</item>
<item row="1" column="3">
<widget class="QComboBox" name="notesCombo"/> <widget class="QComboBox" name="notesCombo"/>
</item> </item>
<item row="2" column="3"> <item row="1" column="2">
<widget class="QComboBox" name="lastModifiedCombo"/> <widget class="QLabel" name="totpLabel">
<property name="font">
<font>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>TOTP</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="indent">
<number>2</number>
</property>
</widget>
</item>
<item row="1" column="3">
<widget class="QComboBox" name="totpCombo"/>
</item>
<item row="4" column="3">
<widget class="QComboBox" name="createdCombo"/>
</item>
<item row="4" column="2">
<widget class="QLabel" name="createdLabel">
<property name="font">
<font>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>Created</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="indent">
<number>2</number>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="QLabel" name="lastModifiedLabel">
<property name="font">
<font>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>Last Modified</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="indent">
<number>2</number>
</property>
</widget>
</item> </item>
<item row="3" column="3"> <item row="3" column="3">
<widget class="QComboBox" name="createdCombo"/> <widget class="QComboBox" name="lastModifiedCombo"/>
</item>
<item row="2" column="2">
<widget class="QLabel" name="iconLabel">
<property name="font">
<font>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>Icon</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
<property name="indent">
<number>2</number>
</property>
</widget>
</item>
<item row="2" column="3">
<widget class="QComboBox" name="iconCombo"/>
</item> </item>
</layout> </layout>
</item> </item>
@ -682,10 +750,6 @@
<tabstop>titleCombo</tabstop> <tabstop>titleCombo</tabstop>
<tabstop>usernameCombo</tabstop> <tabstop>usernameCombo</tabstop>
<tabstop>passwordCombo</tabstop> <tabstop>passwordCombo</tabstop>
<tabstop>urlCombo</tabstop>
<tabstop>notesCombo</tabstop>
<tabstop>lastModifiedCombo</tabstop>
<tabstop>createdCombo</tabstop>
<tabstop>comboBoxCodec</tabstop> <tabstop>comboBoxCodec</tabstop>
<tabstop>comboBoxTextQualifier</tabstop> <tabstop>comboBoxTextQualifier</tabstop>
<tabstop>comboBoxFieldSeparator</tabstop> <tabstop>comboBoxFieldSeparator</tabstop>

View File

@ -928,10 +928,10 @@ void TestCli::testExport()
setInput("a"); setInput("a");
execCmd(exportCmd, {"export", "-f", "csv", m_dbFile->fileName()}); execCmd(exportCmd, {"export", "-f", "csv", m_dbFile->fileName()});
QByteArray csvHeader = m_stdout->readLine(); 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(); QByteArray csvData = m_stdout->readAll();
QVERIFY(csvData.contains(QByteArray( 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 // test invalid format
setInput("a"); setInput("a");

View File

@ -23,11 +23,13 @@
#include "crypto/Crypto.h" #include "crypto/Crypto.h"
#include "format/CsvExporter.h" #include "format/CsvExporter.h"
#include "totp/totp.h"
QTEST_GUILESS_MAIN(TestCsvExporter) QTEST_GUILESS_MAIN(TestCsvExporter)
const QString TestCsvExporter::ExpectedHeaderLine = 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() void TestCsvExporter::init()
{ {
@ -57,17 +59,23 @@ void TestCsvExporter::testExport()
entry->setPassword("Test Password"); entry->setPassword("Test Password");
entry->setUrl("http://test.url"); entry->setUrl("http://test.url");
entry->setNotes("Test Notes"); entry->setNotes("Test Notes");
entry->setTotp(Totp::createSettings("DFDF", Totp::DEFAULT_DIGITS, Totp::DEFAULT_STEP));
entry->setIcon(5);
QBuffer buffer; QBuffer buffer;
QVERIFY(buffer.open(QIODevice::ReadWrite)); QVERIFY(buffer.open(QIODevice::ReadWrite));
m_csvExporter->exportDatabase(&buffer, m_db); m_csvExporter->exportDatabase(&buffer, m_db);
auto exported = QString::fromUtf8(buffer.buffer());
QString expectedResult = QString() QString expectedResult = QString()
.append(ExpectedHeaderLine) .append(ExpectedHeaderLine)
.append("\"Passwords/Test Group Name\",\"Test Entry Title\",\"Test Username\",\"Test " .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() void TestCsvExporter::testEmptyDatabase()
@ -95,10 +103,9 @@ void TestCsvExporter::testNestedGroups()
QBuffer buffer; QBuffer buffer;
QVERIFY(buffer.open(QIODevice::ReadWrite)); QVERIFY(buffer.open(QIODevice::ReadWrite));
m_csvExporter->exportDatabase(&buffer, m_db); m_csvExporter->exportDatabase(&buffer, m_db);
auto exported = QString::fromUtf8(buffer.buffer());
QCOMPARE( QVERIFY(exported.startsWith(
QString::fromUtf8(buffer.buffer().constData()),
QString() QString()
.append(ExpectedHeaderLine) .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\",\"\",\"\",\"\",\"\"")));
} }