Feature: HTML export from CLI tool

This commit introduces support for exporting a KeePassXC database in
HTML format via the CLI tool. The key changes include:
- Refactoring HtmlExporter:
  - Moved HtmlExporter to the format directory and made its API
    compatible with CsvExporter.
  - Since the original HtmlExporter had a direct dependency on the
    gui/Icons functions and indirect dependencies on the
    gui/DatabaseIcons class, only the non-GUI parts were moved to
    format/HtmlExporter.
  - All icon-related functionality was encapsulated in a new child
    class, gui/HtmlGuiExporter.
    - The gui/HtmlGuiExporter retains the original functionality of the
      HtmlExporter class.
    - The format/HtmlExporter now generates HTML export without icons.
      Adding icon support to format/HtmlExporter would require moving
      icon management logic to the core, which could have broader
      implications.
- CLI integration:
  - Updated cli/Export to use format/HtmlExporter.
- GUI Integration:
  - Updated gui/export/ExportDialog to use gui/HtmlGuiExporter.
- Build System Updates:
  - Updated CMakeLists.txt to build HtmlExporter as part of core_SOURCES
    and HtmlGuiExporter as part of gui_SOURCES.
- Testing:
  - Updated TestCli to automatically verify the output of the HTML
    export.

Signed-off-by: AdriandMartin <adriandmartin@protonmail.com>
This commit is contained in:
AdriandMartin 2024-12-23 18:02:37 +01:00 committed by AdriandMartin
parent 0cb0373f85
commit c040dd0371
8 changed files with 194 additions and 67 deletions

View File

@ -71,6 +71,7 @@ set(core_SOURCES
format/BitwardenReader.cpp format/BitwardenReader.cpp
format/CsvExporter.cpp format/CsvExporter.cpp
format/CsvParser.cpp format/CsvParser.cpp
format/HtmlExporter.cpp
format/KeePass1Reader.cpp format/KeePass1Reader.cpp
format/KeePass2.cpp format/KeePass2.cpp
format/KeePass2RandomStream.cpp format/KeePass2RandomStream.cpp
@ -127,7 +128,7 @@ set(gui_SOURCES
gui/FileDialog.cpp gui/FileDialog.cpp
gui/Font.cpp gui/Font.cpp
gui/GuiTools.cpp gui/GuiTools.cpp
gui/HtmlExporter.cpp gui/HtmlGuiExporter.cpp
gui/IconModels.cpp gui/IconModels.cpp
gui/KMessageWidget.cpp gui/KMessageWidget.cpp
gui/MainWindow.cpp gui/MainWindow.cpp

View File

@ -21,13 +21,14 @@
#include "Utils.h" #include "Utils.h"
#include "core/Global.h" #include "core/Global.h"
#include "format/CsvExporter.h" #include "format/CsvExporter.h"
#include "format/HtmlExporter.h"
#include <QCommandLineParser> #include <QCommandLineParser>
const QCommandLineOption Export::FormatOption = QCommandLineOption( const QCommandLineOption Export::FormatOption = QCommandLineOption(
QStringList() << "f" << "format", QStringList() << "f" << "format",
QObject::tr("Format to use when exporting. Available choices are 'xml' or 'csv'. Defaults to 'xml'."), QObject::tr("Format to use when exporting. Available choices are 'xml', 'csv' or 'html'. Defaults to 'xml'."),
QStringLiteral("xml|csv")); QStringLiteral("xml|csv|html"));
Export::Export() Export::Export()
{ {
@ -53,6 +54,9 @@ int Export::executeWithDatabase(QSharedPointer<Database> database, QSharedPointe
} else if (format.startsWith(QStringLiteral("csv"), Qt::CaseInsensitive)) { } else if (format.startsWith(QStringLiteral("csv"), Qt::CaseInsensitive)) {
CsvExporter csvExporter; CsvExporter csvExporter;
out << csvExporter.exportDatabase(database); out << csvExporter.exportDatabase(database);
} else if (format.startsWith(QStringLiteral("html"), Qt::CaseInsensitive)) {
HtmlExporter htmlExporter;
out << htmlExporter.exportDatabase(database);
} else { } else {
err << QObject::tr("Unsupported format %1").arg(format) << Qt::endl; err << QObject::tr("Unsupported format %1").arg(format) << Qt::endl;
return EXIT_FAILURE; return EXIT_FAILURE;

View File

@ -17,28 +17,13 @@
#include "HtmlExporter.h" #include "HtmlExporter.h"
#include <QBuffer>
#include <QFile> #include <QFile>
#include "core/Group.h" #include "core/Group.h"
#include "core/Metadata.h" #include "core/Metadata.h"
#include "gui/Icons.h"
namespace 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("<img src=\"data:image/png;base64,") + a.toBase64() + "\"/>";
}
QString formatEntry(const Entry& entry) QString formatEntry(const Entry& entry)
{ {
// Here we collect the table rows with this entry's data fields // Here we collect the table rows with this entry's data fields
@ -127,15 +112,62 @@ QString HtmlExporter::errorString() const
return m_error; return m_error;
} }
QString HtmlExporter::groupIconToHtml(const Group* /* group */) {
return "";
}
QString HtmlExporter::entryIconToHtml(const Entry* /* entry */) {
return "";
}
bool HtmlExporter::exportDatabase(QIODevice* device, bool HtmlExporter::exportDatabase(QIODevice* device,
const QSharedPointer<const Database>& db, const QSharedPointer<const Database>& db,
bool sorted, bool sorted,
bool ascending) bool ascending)
{
if (device->write(exportHeader(db).toUtf8()) == -1) {
m_error = device->errorString();
return false;
}
if (db->rootGroup()) {
if (device->write(exportGroup(*db->rootGroup(), QString(), sorted, ascending).toUtf8()) == -1) {
m_error = device->errorString();
return false;
}
}
if (device->write(exportFooter().toUtf8()) == -1) {
m_error = device->errorString();
return false;
}
return true;
}
QString HtmlExporter::exportDatabase(const QSharedPointer<const Database>& db,
bool sorted,
bool ascending)
{
QString response;
response = exportHeader(db);
if (!response.isEmpty()) {
if (db->rootGroup()) {
response.append(exportGroup(*db->rootGroup(), QString(), sorted, ascending));
}
response.append(exportFooter());
}
return response;
}
QString HtmlExporter::exportHeader(const QSharedPointer<const Database>& db)
{ {
const auto meta = db->metadata(); const auto meta = db->metadata();
if (!meta) { if (!meta) {
m_error = "Internal error: metadata is NULL"; m_error = "Internal error: metadata is NULL";
return false; return "";
} }
const auto header = QString("<html>" const auto header = QString("<html>"
@ -171,33 +203,23 @@ bool HtmlExporter::exportDatabase(QIODevice* device,
+ "</p>" + "</p>"
"<p><code>" "<p><code>"
+ db->filePath().toHtmlEscaped() + "</code></p>"); + db->filePath().toHtmlEscaped() + "</code></p>");
return header;
}
QString HtmlExporter::exportFooter()
{
const auto footer = QString("</body>" const auto footer = QString("</body>"
"</html>"); "</html>");
return footer;
if (device->write(header.toUtf8()) == -1) {
m_error = device->errorString();
return false;
} }
if (db->rootGroup()) { QString HtmlExporter::exportGroup(const Group& group, QString path, bool sorted, bool ascending)
if (!writeGroup(*device, *db->rootGroup(), QString(), sorted, ascending)) {
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, bool sorted, bool ascending)
{ {
QString response = "";
// Don't output the recycle bin // Don't output the recycle bin
if (&group == group.database()->metadata()->recycleBin()) { if (&group == group.database()->metadata()->recycleBin()) {
return true; return response;
} }
if (!path.isEmpty()) { if (!path.isEmpty()) {
@ -212,8 +234,11 @@ bool HtmlExporter::writeGroup(QIODevice& device, const Group& group, QString pat
if (!group.entries().empty() || !notes.isEmpty()) { if (!group.entries().empty() || !notes.isEmpty()) {
// Header line // Header line
auto header = QString("<hr><h2>"); auto header = QString("<hr><h2>");
header.append(PixmapToHTML(Icons::groupIconPixmap(&group, IconSize::Medium))); auto group_icon = this->groupIconToHtml(&group);
if (!group_icon.isEmpty()) {
header.append(group_icon);
header.append("&nbsp;"); header.append("&nbsp;");
}
header.append(path); header.append(path);
header.append("</h2>\n"); header.append("</h2>\n");
@ -224,11 +249,8 @@ bool HtmlExporter::writeGroup(QIODevice& device, const Group& group, QString pat
header.append("</p>"); header.append("</p>");
} }
// Output it // Append it to the output
if (device.write(header.toUtf8()) == -1) { response.append(header);
m_error = device.errorString();
return false;
}
} }
// Begin the table for the entries in this group // Begin the table for the entries in this group
@ -252,7 +274,10 @@ bool HtmlExporter::writeGroup(QIODevice& device, const Group& group, QString pat
// Output it into our table. First the left side with // Output it into our table. First the left side with
// icon and entry title ... // icon and entry title ...
table += "<tr>"; table += "<tr>";
table += "<td width=\"1%\">" + PixmapToHTML(Icons::entryIconPixmap(entry, IconSize::Medium)) + "</td>"; auto entry_icon = this->entryIconToHtml(entry);
if (!entry_icon.isEmpty()) {
table += "<td width=\"1%\">" + entry_icon + "</td>";
}
auto caption = "<caption>" + entry->title().toHtmlEscaped() + "</caption>"; auto caption = "<caption>" + entry->title().toHtmlEscaped() + "</caption>";
// ... then the right side with the data fields // ... then the right side with the data fields
@ -261,12 +286,9 @@ bool HtmlExporter::writeGroup(QIODevice& device, const Group& group, QString pat
table += "</tr>"; table += "</tr>";
} }
// Output the complete table of this group // Append the complete table of this group to the output
table.append("</table>\n"); table.append("</table>\n");
if (device.write(table.toUtf8()) == -1) { response.append(table);
m_error = device.errorString();
return false;
}
auto children = group.children(); auto children = group.children();
if (sorted) { if (sorted) {
@ -278,10 +300,10 @@ bool HtmlExporter::writeGroup(QIODevice& device, const Group& group, QString pat
// Recursively output the child groups // Recursively output the child groups
for (const auto* child : children) { for (const auto* child : children) {
if (child && !writeGroup(device, *child, path, sorted, ascending)) { if (child) {
return false; response.append(exportGroup(*child, path, sorted, ascending));
} }
} }
return true; return response;
} }

View File

@ -21,6 +21,8 @@
#include <QSharedPointer> #include <QSharedPointer>
#include <QString> #include <QString>
#include "core/Group.h"
class Database; class Database;
class Group; class Group;
class QIODevice; class QIODevice;
@ -32,18 +34,28 @@ public:
const QSharedPointer<const Database>& db, const QSharedPointer<const Database>& db,
bool sorted = true, bool sorted = true,
bool ascending = true); bool ascending = true);
QString errorString() const;
private:
bool exportDatabase(QIODevice* device, bool exportDatabase(QIODevice* device,
const QSharedPointer<const Database>& db, const QSharedPointer<const Database>& db,
bool sorted = true, bool sorted = true,
bool ascending = true); bool ascending = true);
bool writeGroup(QIODevice& device, QString exportDatabase(const QSharedPointer<const Database>& db,
const Group& group, bool sorted = true,
bool ascending = true);
QString errorString() const;
virtual ~HtmlExporter() = default;
protected:
virtual QString groupIconToHtml(const Group* group);
virtual QString entryIconToHtml(const Entry* entry);
private:
QString exportGroup(const Group& group,
QString path = QString(), QString path = QString(),
bool sorted = true, bool sorted = true,
bool ascending = true); bool ascending = true);
QString exportHeader(const QSharedPointer<const Database>& db);
QString exportFooter();
QString m_error; QString m_error;
}; };

View File

@ -0,0 +1,46 @@
/*
* Copyright (C) 2019 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 "HtmlGuiExporter.h"
#include <QBuffer>
#include "gui/Icons.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("<img src=\"data:image/png;base64,") + a.toBase64() + "\"/>";
}
} // namespace
QString HtmlGuiExporter::groupIconToHtml(const Group* group) {
return PixmapToHTML(Icons::groupIconPixmap(group, IconSize::Medium));
}
QString HtmlGuiExporter::entryIconToHtml(const Entry* entry) {
return PixmapToHTML(Icons::entryIconPixmap(entry, IconSize::Medium));
}

30
src/gui/HtmlGuiExporter.h Normal file
View File

@ -0,0 +1,30 @@
/*
* Copyright (C) 2019 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 KEEPASSX_HTMLGUIEXPORTER_H
#define KEEPASSX_HTMLGUIEXPORTER_H
#include "format/HtmlExporter.h"
class HtmlGuiExporter : public HtmlExporter
{
protected:
QString groupIconToHtml(const Group* group) override;
QString entryIconToHtml(const Entry* entry) override;
};
#endif // KEEPASSX_HTMLGUIEXPORTER_H

View File

@ -19,7 +19,7 @@
#include "ui_ExportDialog.h" #include "ui_ExportDialog.h"
#include "gui/FileDialog.h" #include "gui/FileDialog.h"
#include "gui/HtmlExporter.h" #include "gui/HtmlGuiExporter.h"
ExportDialog::ExportDialog(QSharedPointer<const Database> db, DatabaseTabWidget* parent) ExportDialog::ExportDialog(QSharedPointer<const Database> db, DatabaseTabWidget* parent)
: QDialog(parent) : QDialog(parent)
@ -72,7 +72,7 @@ void ExportDialog::exportDatabase()
FileDialog::saveLastDir("html", fileName, true); FileDialog::saveLastDir("html", fileName, true);
HtmlExporter htmlExporter; HtmlGuiExporter htmlExporter;
if (!htmlExporter.exportDatabase( if (!htmlExporter.exportDatabase(
fileName, m_db, sortBy != ExportSortingStrategy::BY_DATABASE_ORDER, ascendingOrder)) { fileName, m_db, sortBy != ExportSortingStrategy::BY_DATABASE_ORDER, ascendingOrder)) {
emit exportFailed(htmlExporter.errorString()); emit exportFailed(htmlExporter.errorString());

View File

@ -1311,6 +1311,18 @@ void TestCli::testExport()
QVERIFY(csvData.contains(QByteArray( QVERIFY(csvData.contains(QByteArray(
"\"NewDatabase\",\"Sample Entry\",\"User Name\",\"Password\",\"http://www.somesite.com/\",\"Notes\""))); "\"NewDatabase\",\"Sample Entry\",\"User Name\",\"Password\",\"http://www.somesite.com/\",\"Notes\"")));
// HTML exporting
setInput("a");
execCmd(exportCmd, {"export", "-f", "html", m_dbFile->fileName()});
QByteArray htmlHeader = m_stdout->readLine();
QVERIFY(htmlHeader.contains(QByteArray("<meta charset=\"UTF-8\"><title></title>")));
QByteArray htmlBody = m_stdout->readAll();
QVERIFY(htmlBody.contains(QByteArray("<h2>NewDatabase</h2>")));
QVERIFY(htmlBody.contains(QByteArray(
"<caption>Sample Entry</caption>"
"<tr><th>User name</th><td class=\"username\">User Name</td></tr>"
"<tr><th>Password</th><td class=\"password\">Password</td></tr>"
"<tr><th>URL</th><td class=\"url\"><a href=\"http://www.somesite.com/\">http://www.somesite.com/</a></td></tr>")));
// test invalid format // test invalid format
setInput("a"); setInput("a");
execCmd(exportCmd, {"export", "-f", "yaml", m_dbFile->fileName()}); execCmd(exportCmd, {"export", "-f", "yaml", m_dbFile->fileName()});