From aac76ad407465d7c491ac950c775db817271d2d0 Mon Sep 17 00:00:00 2001 From: Jonathan White Date: Sun, 30 Jun 2019 11:11:15 -0400 Subject: [PATCH] Add ability to export database as HTML for printing --- src/CMakeLists.txt | 1 + src/core/Group.cpp | 4 +- src/core/Group.h | 2 +- src/format/HtmlExporter.cpp | 253 ++++++++++++++++++++++++++++++++++ src/format/HtmlExporter.h | 41 ++++++ src/gui/DatabaseTabWidget.cpp | 27 ++++ src/gui/DatabaseTabWidget.h | 1 + src/gui/MainWindow.cpp | 7 + src/gui/MainWindow.ui | 17 ++- 9 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 src/format/HtmlExporter.cpp create mode 100644 src/format/HtmlExporter.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d9cc6f0c9..9b2487335 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -67,6 +67,7 @@ set(keepassx_SOURCES crypto/kdf/AesKdf.cpp crypto/kdf/Argon2Kdf.cpp format/CsvExporter.cpp + format/HtmlExporter.cpp format/KeePass1Reader.cpp format/KeePass2.cpp format/KeePass2RandomStream.cpp diff --git a/src/core/Group.cpp b/src/core/Group.cpp index 9be878785..adbd51473 100644 --- a/src/core/Group.cpp +++ b/src/core/Group.cpp @@ -257,9 +257,9 @@ Entry* Group::lastTopVisibleEntry() const return m_lastTopVisibleEntry; } -bool Group::isRecycled() +bool Group::isRecycled() const { - Group* group = this; + auto group = this; if (!group->database()) { return false; } diff --git a/src/core/Group.h b/src/core/Group.h index 4b1204465..2df00ef50 100644 --- a/src/core/Group.h +++ b/src/core/Group.h @@ -102,7 +102,7 @@ public: bool resolveAutoTypeEnabled() const; Entry* lastTopVisibleEntry() const; bool isExpired() const; - bool isRecycled(); + bool isRecycled() const; CustomData* customData(); const CustomData* customData() const; diff --git a/src/format/HtmlExporter.cpp b/src/format/HtmlExporter.cpp new file mode 100644 index 000000000..152f2933a --- /dev/null +++ b/src/format/HtmlExporter.cpp @@ -0,0 +1,253 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * 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 . + */ + +#include "HtmlExporter.h" + +#include +#include + +#include "core/Database.h" +#include "core/Group.h" +#include "core/Metadata.h" + +namespace +{ + QString PixmapToHTML(const QPixmap& pixmap) + { + if (pixmap.isNull()) + return ""; + + // Based on https://stackoverflow.com/a/6621278 + QByteArray a; + QBuffer buffer(&a); + pixmap.save(&buffer, "PNG"); + return QString(""; + } +} // namespace + +bool HtmlExporter::exportDatabase(const QString& filename, const QSharedPointer& db) +{ + QFile file(filename); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + m_error = file.errorString(); + return false; + } + return exportDatabase(&file, db); +} + +QString HtmlExporter::errorString() const +{ + return m_error; +} + +bool HtmlExporter::exportDatabase(QIODevice* device, const QSharedPointer& db) +{ + const auto meta = db->metadata(); + if (!meta) { + m_error = "Internal error: metadata is NULL"; + return false; + } + + const auto header = QString("" + "" + "" + "" + + meta->name().toHtmlEscaped() + + "" + "" + "\n" + "" + "

" + + meta->name().toHtmlEscaped() + + "

" + "

" + + meta->description().toHtmlEscaped().replace("\n", "
") + + "

" + "

" + + db->filePath().toHtmlEscaped() + "

"); + const auto footer = QString("" + ""); + + if (device->write(header.toUtf8()) == -1) { + m_error = device->errorString(); + return false; + } + + if (db->rootGroup()) { + if (!writeGroup(*device, *db->rootGroup())) { + return false; + } + } + + if (device->write(footer.toUtf8()) == -1) { + m_error = device->errorString(); + return false; + } + + return true; +} + +bool HtmlExporter::writeGroup(QIODevice& device, const Group& group, QString path) +{ + // Don't output the recycle bin + if (&group == group.database()->metadata()->recycleBin()) { + return true; + } + + if (!path.isEmpty()) { + path.append(" → "); + } + path.append(group.name().toHtmlEscaped()); + + // 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()) { + + // Header line + auto header = QString("

"); + header.append(PixmapToHTML(group.iconScaledPixmap())); + header.append(" "); + header.append(path); + header.append("

\n"); + + // Group notes + if (!notes.isEmpty()) { + header.append("

"); + header.append(notes.toHtmlEscaped().replace("\n", "
")); + header.append("

"); + } + + // Output it + if (device.write(header.toUtf8()) == -1) { + m_error = device.errorString(); + return false; + } + } + + // Output the entries in this group + for (const auto entry : entries) { + auto item = QString("

"); + + // Begin formatting this item into HTML + item.append(PixmapToHTML(entry->iconScaledPixmap())); + item.append(" "); + item.append(entry->title().toHtmlEscaped()); + item.append("

\n" + ""); + + // Output the fixed fields + const auto& u = entry->username(); + if (!u.isEmpty()) { + item.append(""); + } + + const auto& p = entry->password(); + if (!p.isEmpty()) { + item.append(""); + } + + const auto& r = entry->url(); + if (!r.isEmpty()) { + item.append(""); + } + + const auto& n = entry->notes(); + if (!n.isEmpty()) { + item.append(""); + } + + // 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(""); + } + } + + // Done with this entry + item.append("
"); + item.append(QObject::tr("User name")); + item.append(""); + item.append(entry->username().toHtmlEscaped()); + item.append("
"); + item.append(QObject::tr("Password")); + item.append(""); + item.append(entry->password().toHtmlEscaped()); + item.append("
"); + item.append(QObject::tr("URL")); + 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("…"); + } + + item.append("
"); + item.append(QObject::tr("Notes")); + item.append(""); + item.append(entry->notes().toHtmlEscaped().replace("\n", "
")); + item.append("
"); + item.append(key.toHtmlEscaped()); + item.append(""); + item.append(attr->value(key).toHtmlEscaped().replace("\n", "
")); + item.append("
\n"); + if (device.write(item.toUtf8()) == -1) { + m_error = device.errorString(); + return false; + } + } + + // Recursively output the child groups + const auto& children = group.children(); + for (const auto child : children) { + if (child && !writeGroup(device, *child, path)) { + return false; + } + } + + return true; +} diff --git a/src/format/HtmlExporter.h b/src/format/HtmlExporter.h new file mode 100644 index 000000000..3a592e54a --- /dev/null +++ b/src/format/HtmlExporter.h @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2019 KeePassXC Team + * + * 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 . + */ + +#ifndef KEEPASSX_HTMLEXPORTER_H +#define KEEPASSX_HTMLEXPORTER_H + +#include +#include + +class Database; +class Group; +class QIODevice; + +class HtmlExporter +{ +public: + bool exportDatabase(const QString& filename, const QSharedPointer& db); + QString errorString() const; + +private: + bool exportDatabase(QIODevice* device, const QSharedPointer& db); + bool writeGroup(QIODevice& device, const Group& group, QString path = QString()); + + QString m_error; +}; + +#endif // KEEPASSX_HTMLEXPORTER_H diff --git a/src/gui/DatabaseTabWidget.cpp b/src/gui/DatabaseTabWidget.cpp index d24196502..84e80e2bb 100644 --- a/src/gui/DatabaseTabWidget.cpp +++ b/src/gui/DatabaseTabWidget.cpp @@ -30,6 +30,7 @@ #include "core/Metadata.h" #include "core/Tools.h" #include "format/CsvExporter.h" +#include "format/HtmlExporter.h" #include "gui/Clipboard.h" #include "gui/DatabaseOpenDialog.h" #include "gui/DatabaseWidget.h" @@ -402,6 +403,32 @@ void DatabaseTabWidget::exportToCsv() } } +void DatabaseTabWidget::exportToHtml() +{ + auto db = databaseWidgetFromIndex(currentIndex())->database(); + if (!db) { + Q_ASSERT(false); + return; + } + + QString fileName = fileDialog()->getSaveFileName(this, + tr("Export database to HTML file"), + QString(), + tr("HTML file").append(" (*.html)"), + nullptr, + nullptr, + "html"); + if (fileName.isEmpty()) { + return; + } + + HtmlExporter htmlExporter; + if (!htmlExporter.exportDatabase(fileName, db)) { + emit messageGlobal(tr("Writing the HTML file failed.").append("\n").append(htmlExporter.errorString()), + MessageWidget::Error); + } +} + void DatabaseTabWidget::changeMasterKey() { currentDatabaseWidget()->switchToMasterKeyChange(); diff --git a/src/gui/DatabaseTabWidget.h b/src/gui/DatabaseTabWidget.h index a39cc6a40..517d9f4e7 100644 --- a/src/gui/DatabaseTabWidget.h +++ b/src/gui/DatabaseTabWidget.h @@ -69,6 +69,7 @@ public slots: bool saveDatabase(int index = -1); bool saveDatabaseAs(int index = -1); void exportToCsv(); + void exportToHtml(); void lockDatabases(); void closeDatabaseFromSender(); diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 327c18efc..c0a4a0400 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -355,6 +355,7 @@ MainWindow::MainWindow() connect(m_ui->actionImportKeePass1, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importKeePass1Database())); connect(m_ui->actionImportOpVault, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importOpVaultDatabase())); connect(m_ui->actionExportCsv, SIGNAL(triggered()), m_ui->tabWidget, SLOT(exportToCsv())); + connect(m_ui->actionExportHtml, SIGNAL(triggered()), m_ui->tabWidget, SLOT(exportToHtml())); connect(m_ui->actionLockDatabases, SIGNAL(triggered()), m_ui->tabWidget, SLOT(lockDatabases())); connect(m_ui->actionQuit, SIGNAL(triggered()), SLOT(appExit())); @@ -606,7 +607,9 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode) m_ui->actionChangeDatabaseSettings->setEnabled(true); m_ui->actionDatabaseSave->setEnabled(m_ui->tabWidget->canSave()); m_ui->actionDatabaseSaveAs->setEnabled(true); + m_ui->menuExport->setEnabled(true); m_ui->actionExportCsv->setEnabled(true); + m_ui->actionExportHtml->setEnabled(true); m_ui->actionDatabaseMerge->setEnabled(m_ui->tabWidget->currentIndex() != -1); m_searchWidgetAction->setEnabled(true); @@ -630,7 +633,9 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode) m_ui->actionChangeDatabaseSettings->setEnabled(false); m_ui->actionDatabaseSave->setEnabled(false); m_ui->actionDatabaseSaveAs->setEnabled(false); + m_ui->menuExport->setEnabled(false); m_ui->actionExportCsv->setEnabled(false); + m_ui->actionExportHtml->setEnabled(false); m_ui->actionDatabaseMerge->setEnabled(false); m_searchWidgetAction->setEnabled(false); @@ -656,7 +661,9 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode) m_ui->actionDatabaseSave->setEnabled(false); m_ui->actionDatabaseSaveAs->setEnabled(false); m_ui->actionDatabaseClose->setEnabled(false); + m_ui->menuExport->setEnabled(false); m_ui->actionExportCsv->setEnabled(false); + m_ui->actionExportHtml->setEnabled(false); m_ui->actionDatabaseMerge->setEnabled(false); m_searchWidgetAction->setEnabled(false); diff --git a/src/gui/MainWindow.ui b/src/gui/MainWindow.ui index 3208f4d58..de2867406 100644 --- a/src/gui/MainWindow.ui +++ b/src/gui/MainWindow.ui @@ -203,6 +203,13 @@ + + + &Export + + + + @@ -215,7 +222,7 @@ - + @@ -620,6 +627,14 @@ &Export to CSV file... + + + false + + + &Export to HTML file... + + KeePass 1 database...