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();
} 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 {
@ -493,6 +494,15 @@ QSharedPointer<Totp::Settings> 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());

View File

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

View File

@ -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);

View File

@ -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);

View File

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

View File

@ -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");

View File

@ -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\",\"\",\"\",\"\",\"\"")));
}