Add sorting of HTML export

- Closes #6164
- Implement sorting support in HtmlExporter
- Add ExportDialog class and UI, which allows to configure export options.
This commit is contained in:
Patrick Sean Klein 2021-10-10 16:36:19 +02:00 committed by Jonathan White
parent d3b28f8651
commit 296cbf0df7
9 changed files with 369 additions and 89 deletions

View File

@ -2217,14 +2217,6 @@ This is definitely a bug, please report it to the developers.</translation>
<source>Failed to open %1. It either does not exist or is not accessible.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Export database to HTML file</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>HTML file</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Writing the HTML file failed.</source>
<translation type="unfinished"></translation>
@ -3836,6 +3828,47 @@ Would you like to overwrite the existing attachment?</source>
<translation>Reset to defaults</translation>
</message>
</context>
<context>
<name>ExportDialog</name>
<message>
<source>Export options</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>You are about to export your database to an unencrypted file.
This will leave your passwords and sensitive information vulnerable!
</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Export database to HTML file</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>HTML file</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>database order</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>name (ascending)</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>name (descending)</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Sort entries by...</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>unknown</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>FdoSecrets::DBusMgr</name>
<message>

View File

@ -145,6 +145,7 @@ set(keepassx_SOURCES
gui/entry/EntryHistoryModel.cpp
gui/entry/EntryModel.cpp
gui/entry/EntryView.cpp
gui/export/ExportDialog.cpp
gui/group/EditGroupWidget.cpp
gui/group/GroupModel.cpp
gui/group/GroupView.cpp

View File

@ -30,6 +30,7 @@
#include "gui/FileDialog.h"
#include "gui/HtmlExporter.h"
#include "gui/MessageBox.h"
#include "gui/export/ExportDialog.h"
#ifdef Q_OS_MACOS
#include "gui/osutils/macutils/MacUtils.h"
#endif
@ -440,6 +441,11 @@ void DatabaseTabWidget::exportToCsv()
}
}
void DatabaseTabWidget::handleExportError(const QString& reason)
{
emit messageGlobal(tr("Writing the HTML file failed.").append("\n").append(reason), MessageWidget::Error);
}
void DatabaseTabWidget::exportToHtml()
{
auto db = databaseWidgetFromIndex(currentIndex())->database();
@ -448,23 +454,9 @@ void DatabaseTabWidget::exportToHtml()
return;
}
if (!warnOnExport()) {
return;
}
const QString fileName = fileDialog()->getSaveFileName(
this, tr("Export database to HTML file"), FileDialog::getLastDir("html"), tr("HTML file").append(" (*.html)"));
if (fileName.isEmpty()) {
return;
}
FileDialog::saveLastDir("html", fileName, true);
HtmlExporter htmlExporter;
if (!htmlExporter.exportDatabase(fileName, db)) {
emit messageGlobal(tr("Writing the HTML file failed.").append("\n").append(htmlExporter.errorString()),
MessageWidget::Error);
}
auto exportDialog = new ExportDialog(db, this);
connect(exportDialog, SIGNAL(exportFailed(QString)), SLOT(handleExportError(const QString&)));
exportDialog->exec();
}
bool DatabaseTabWidget::warnOnExport()

View File

@ -100,6 +100,7 @@ private slots:
void emitActiveDatabaseChanged();
void emitDatabaseLockChanged();
void handleDatabaseUnlockDialogFinished(bool accepted, DatabaseWidget* dbWidget);
void handleExportError(const QString& reason);
private:
QSharedPointer<Database> execNewDatabaseWizard();

View File

@ -39,82 +39,87 @@ namespace
return QString("<img src=\"data:image/png;base64,") + a.toBase64() + "\"/>";
}
QString formatHTML(const QString& value)
{
return value.toHtmlEscaped().replace(" ", "&nbsp;").replace('\n', "<br>");
}
QString formatAttribute(const QString& key,
const QString& value,
const QString& classname,
const QString& templt = QString("<tr><th>%1</th><td class=\"%2\">%3</td></tr>"))
{
const auto& formatted_attribute = templt;
if (!value.isEmpty()) {
// Format key as well -> Translations into other languages may have non-standard chars
return formatted_attribute.arg(formatHTML(key), classname, formatHTML(value));
}
return {};
}
QString formatAttribute(const Entry& entry,
const QString& key,
const QString& value,
const QString& classname,
const QString& templt = QString("<tr><th>%1</th><td class=\"%2\">%3</td></tr>"))
{
if (value.isEmpty())
return {};
return formatAttribute(key, entry.resolveMultiplePlaceholders(value), classname, templt);
}
QString formatEntry(const Entry& entry)
{
// Here we collect the table rows with this entry's data fields
QString item;
// Output the fixed fields
item.append(formatAttribute(entry, QObject::tr("User name"), entry.username(), "username"));
item.append(formatAttribute(entry, QObject::tr("Password"), entry.password(), "password"));
if (!entry.url().isEmpty()) {
constexpr auto maxlen = 100;
QString displayedURL(formatHTML(entry.url()).mid(0, maxlen));
if (displayedURL.size() == maxlen) {
displayedURL.append("&hellip;");
}
item.append(formatAttribute(entry,
QObject::tr("URL"),
entry.url(),
"url",
R"(<tr><th>%1</th><td class="%2"><a href="%3">%4</a></td></tr>)")
.arg(entry.resolveMultiplePlaceholders(displayedURL)));
const auto& u = entry.username();
if (!u.isEmpty()) {
item.append("<tr><th>");
item.append(QObject::tr("User name"));
item.append("</th><td class=\"username\">");
item.append(entry.username().toHtmlEscaped());
item.append("</td></tr>");
}
item.append(formatAttribute(entry, QObject::tr("Notes"), entry.notes(), "notes"));
const auto& p = entry.password();
if (!p.isEmpty()) {
item.append("<tr><th>");
item.append(QObject::tr("Password"));
item.append("</th><td class=\"password\">");
item.append(entry.password().toHtmlEscaped());
item.append("</td></tr>");
}
const auto& r = entry.url();
if (!r.isEmpty()) {
item.append("<tr><th>");
item.append(QObject::tr("URL"));
item.append("</th><td class=\"url\"><a href=\"");
item.append(r.toHtmlEscaped());
item.append("\">");
// Restrict the length of what we display of the URL -
// even from a paper backup, nobody will every type in
// more than 100 characters of a URL
constexpr auto maxlen = 100;
if (r.size() <= maxlen) {
item.append(r.toHtmlEscaped());
} else {
item.append(r.mid(0, maxlen).toHtmlEscaped());
item.append("&hellip;");
}
item.append("</a></td></tr>");
}
const auto& n = entry.notes();
if (!n.isEmpty()) {
item.append("<tr><th>");
item.append(QObject::tr("Notes"));
item.append("</th><td class=\"notes\">");
item.append(entry.notes().toHtmlEscaped().replace("\n", "<br>"));
item.append("</td></tr>");
}
// Now add the attributes (if there are any)
const auto* const attr = entry.attributes();
if (attr && !attr->customKeys().isEmpty()) {
for (const auto& key : attr->customKeys()) {
item.append(formatAttribute(entry, key, attr->value(key), "attr"));
item.append("<tr><th>");
item.append(key.toHtmlEscaped());
item.append("</th><td class=\"attr\">");
item.append(attr->value(key).toHtmlEscaped().replace(" ", "&nbsp;").replace("\n", "<br>"));
item.append("</td></tr>");
}
}
return item;
}
} // namespace
bool HtmlExporter::exportDatabase(const QString& filename, const QSharedPointer<const Database>& db)
bool HtmlExporter::exportDatabase(const QString& filename,
const QSharedPointer<const Database>& db,
bool sorted,
bool ascending)
{
QFile file(filename);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
m_error = file.errorString();
return false;
}
return exportDatabase(&file, db);
return exportDatabase(&file, db, sorted, ascending);
}
QString HtmlExporter::errorString() const
@ -122,7 +127,10 @@ QString HtmlExporter::errorString() const
return m_error;
}
bool HtmlExporter::exportDatabase(QIODevice* device, const QSharedPointer<const Database>& db)
bool HtmlExporter::exportDatabase(QIODevice* device,
const QSharedPointer<const Database>& db,
bool sorted,
bool ascending)
{
const auto meta = db->metadata();
if (!meta) {
@ -171,7 +179,7 @@ bool HtmlExporter::exportDatabase(QIODevice* device, const QSharedPointer<const
}
if (db->rootGroup()) {
if (!writeGroup(*device, *db->rootGroup())) {
if (!writeGroup(*device, *db->rootGroup(), QString(), sorted, ascending)) {
return false;
}
}
@ -184,7 +192,7 @@ bool HtmlExporter::exportDatabase(QIODevice* device, const QSharedPointer<const
return true;
}
bool HtmlExporter::writeGroup(QIODevice& device, const Group& group, QString path)
bool HtmlExporter::writeGroup(QIODevice& device, const Group& group, QString path, bool sorted, bool ascending)
{
// Don't output the recycle bin
if (&group == group.database()->metadata()->recycleBin()) {
@ -199,10 +207,8 @@ bool HtmlExporter::writeGroup(QIODevice& device, const Group& group, QString pat
// Output the header for this group (but only if there are
// any notes or entries in this group, otherwise we'd get
// a header with nothing after it, which looks stupid)
const auto& entries = group.entries();
const auto notes = group.notes();
if (!entries.empty() || !notes.isEmpty()) {
if (!group.entries().empty() || !notes.isEmpty()) {
// Header line
auto header = QString("<hr><h2>");
header.append(PixmapToHTML(Icons::groupIconPixmap(&group, IconSize::Medium)));
@ -227,8 +233,16 @@ bool HtmlExporter::writeGroup(QIODevice& device, const Group& group, QString pat
// Begin the table for the entries in this group
auto table = QString("<table width=\"100%\">");
auto entries = group.entries();
if (sorted) {
std::sort(entries.begin(), entries.end(), [&](Entry* lhs, Entry* rhs) {
int cmp = lhs->title().compare(rhs->title(), Qt::CaseInsensitive);
return ascending ? cmp < 0 : cmp > 0;
});
}
// Output the entries in this group
for (const auto entry : entries) {
for (const auto* entry : entries) {
auto formatted_entry = formatEntry(*entry);
if (formatted_entry.isEmpty())
@ -252,10 +266,17 @@ bool HtmlExporter::writeGroup(QIODevice& device, const Group& group, QString pat
return false;
}
auto children = group.children();
if (sorted) {
std::sort(children.begin(), children.end(), [&](Group* lhs, Group* rhs) {
int cmp = lhs->name().compare(rhs->name(), Qt::CaseInsensitive);
return ascending ? cmp < 0 : cmp > 0;
});
}
// Recursively output the child groups
const auto& children = group.children();
for (const auto child : children) {
if (child && !writeGroup(device, *child, path)) {
for (const auto* child : children) {
if (child && !writeGroup(device, *child, path, sorted, ascending)) {
return false;
}
}

View File

@ -28,12 +28,22 @@ class QIODevice;
class HtmlExporter
{
public:
bool exportDatabase(const QString& filename, const QSharedPointer<const Database>& db);
bool exportDatabase(const QString& filename,
const QSharedPointer<const Database>& db,
bool sorted = true,
bool ascending = true);
QString errorString() const;
private:
bool exportDatabase(QIODevice* device, const QSharedPointer<const Database>& db);
bool writeGroup(QIODevice& device, const Group& group, QString path = QString());
bool exportDatabase(QIODevice* device,
const QSharedPointer<const Database>& db,
bool sorted = true,
bool ascending = true);
bool writeGroup(QIODevice& device,
const Group& group,
QString path = QString(),
bool sorted = true,
bool ascending = true);
QString m_error;
};

View File

@ -0,0 +1,85 @@
/*
* Copyright (C) 2021 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "ExportDialog.h"
#include "ui_ExportDialog.h"
#include "gui/FileDialog.h"
#include "gui/HtmlExporter.h"
ExportDialog::ExportDialog(QSharedPointer<const Database> db, DatabaseTabWidget* parent)
: QDialog(parent)
, m_ui(new Ui::ExportDialog())
, m_db(std::move(db))
{
m_ui->setupUi(this);
setAttribute(Qt::WA_DeleteOnClose);
connect(m_ui->buttonBox, SIGNAL(rejected()), SLOT(close()));
connect(m_ui->buttonBox, SIGNAL(accepted()), SLOT(exportDatabase()));
m_ui->sortingStrategy->addItem(getStrategyName(BY_NAME_ASC), BY_NAME_ASC);
m_ui->sortingStrategy->addItem(getStrategyName(BY_NAME_DESC), BY_NAME_DESC);
m_ui->sortingStrategy->addItem(getStrategyName(BY_DATABASE_ORDER), BY_DATABASE_ORDER);
m_ui->messageWidget->setCloseButtonVisible(false);
m_ui->messageWidget->setAutoHideTimeout(-1);
m_ui->messageWidget->showMessage(tr("You are about to export your database to an unencrypted file.\n"
"This will leave your passwords and sensitive information vulnerable!\n"),
MessageWidget::Warning);
}
ExportDialog::~ExportDialog()
{
}
QString ExportDialog::getStrategyName(ExportSortingStrategy strategy)
{
switch (strategy) {
case ExportSortingStrategy::BY_DATABASE_ORDER:
return tr("database order");
case ExportSortingStrategy::BY_NAME_ASC:
return tr("name (ascending)");
case ExportSortingStrategy::BY_NAME_DESC:
return tr("name (descending)");
}
return tr("unknown");
}
void ExportDialog::exportDatabase()
{
auto sortBy = m_ui->sortingStrategy->currentData().toInt();
bool ascendingOrder = sortBy == ExportSortingStrategy::BY_NAME_ASC;
const QString fileName = fileDialog()->getSaveFileName(
this, tr("Export database to HTML file"), FileDialog::getLastDir("html"), tr("HTML file").append(" (*.html)"));
if (fileName.isEmpty()) {
return;
}
FileDialog::saveLastDir("html", fileName, true);
HtmlExporter htmlExporter;
if (!htmlExporter.exportDatabase(
fileName, m_db, sortBy != ExportSortingStrategy::BY_DATABASE_ORDER, ascendingOrder)) {
emit exportFailed(htmlExporter.errorString());
reject();
}
accept();
}

View File

@ -0,0 +1,58 @@
/*
* Copyright (C) 2021 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef KEEPASSXC_EXPORTDIALOG_H
#define KEEPASSXC_EXPORTDIALOG_H
#include "core/Database.h"
#include "gui/DatabaseTabWidget.h"
#include <QDialog>
namespace Ui
{
class ExportDialog;
}
class ExportDialog : public QDialog
{
Q_OBJECT
public:
explicit ExportDialog(QSharedPointer<const Database> db, DatabaseTabWidget* parent = nullptr);
~ExportDialog() override;
enum ExportSortingStrategy
{
BY_DATABASE_ORDER = 0,
BY_NAME_ASC = 1,
BY_NAME_DESC = 2
};
signals:
void exportFailed(QString reason);
private slots:
void exportDatabase();
private:
QString getStrategyName(ExportSortingStrategy strategy);
QScopedPointer<Ui::ExportDialog> m_ui;
QSharedPointer<const Database> m_db;
};
#endif // KEEPASSXC_EXPORTDIALOG_H

View File

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ExportDialog</class>
<widget class="QDialog" name="ExportDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>186</width>
<height>164</height>
</rect>
</property>
<property name="windowTitle">
<string>Export options</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="MessageWidget" name="messageWidget" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="sortingStrategyLabel">
<property name="text">
<string>Sort entries by...</string>
</property>
<property name="buddy">
<cstring>sortingStrategy</cstring>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="sortingStrategy">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>MessageWidget</class>
<extends>QWidget</extends>
<header>gui/MessageWidget.h</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>