Add New/Preview Entry Attachments dialog and functionality (#11637)

Closes #11506
Closes #3383

* This change adds a new opportunity to add attachments that don’t require a real file in the file system.
* Add a new dialog window to add and preview attachments and integrate it into the EntryAttachmentsWidget.
* Attachment preview support for images and plain text files.

Additional enhancements:
* Fix sizing of attachment columns
* Add padding to attachment table items
* Fix targeting of preview widget styling to not impact unintended children
This commit is contained in:
Kuznetsov Oleg 2025-01-12 07:08:19 +03:00 committed by Jonathan White
parent ef2b5e7c26
commit 99c8936568
No known key found for this signature in database
GPG Key ID: 440FC65F2E0C6E01
16 changed files with 717 additions and 47 deletions

View File

@ -3697,6 +3697,21 @@ This may cause the affected plugins to malfunction.</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>EntryAttachmentsDialog</name>
<message>
<source>Form</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>File name</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>File contents...</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>EntryAttachmentsModel</name>
<message>
@ -3734,14 +3749,6 @@ This may cause the affected plugins to malfunction.</source>
<source>Remove</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Rename selected attachment</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Rename</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Open selected attachment</source>
<translation type="unfinished"></translation>
@ -3851,6 +3858,18 @@ Error: %1</source>
Would you like to overwrite the existing attachment?</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>New</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Preview</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Failed to preview an attachment: Attachment not found</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>EntryAttributesModel</name>
@ -6039,6 +6058,25 @@ We recommend you use the AppImage available on our downloads page.</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>NewEntryAttachmentsDialog</name>
<message>
<source>Attachment name cannot be empty</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Attachment with the same name already exists</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Save attachment</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>New entry attachment</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>NixUtils</name>
<message>
@ -6785,6 +6823,21 @@ Do you want to overwrite it?</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>PreviewEntryAttachmentsDialog</name>
<message>
<source>Preview entry attachment</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>No preview available</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Image format not supported</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>QMessageBox</name>
<message>

View File

@ -130,6 +130,8 @@ set(keepassx_SOURCES
gui/entry/EntryAttachmentsModel.cpp
gui/entry/EntryAttachmentsWidget.cpp
gui/entry/EntryAttributesModel.cpp
gui/entry/NewEntryAttachmentsDialog.cpp
gui/entry/PreviewEntryAttachmentsDialog.cpp
gui/entry/EntryHistoryModel.cpp
gui/entry/EntryModel.cpp
gui/entry/EntryView.cpp

View File

@ -477,4 +477,32 @@ namespace Tools
return pattern;
}
MimeType toMimeType(const QString& mimeName)
{
static QStringList textFormats = {
"text/",
"application/json",
"application/xml",
"application/soap+xml",
"application/x-yaml",
"application/protobuf",
};
static QStringList imageFormats = {"image/"};
static auto isCompatible = [](const QString& format, const QStringList& list) {
return std::any_of(
list.cbegin(), list.cend(), [&format](const auto& item) { return format.startsWith(item); });
};
if (isCompatible(mimeName, imageFormats)) {
return MimeType::Image;
}
if (isCompatible(mimeName, textFormats)) {
return MimeType::PlainText;
}
return MimeType::Unknown;
}
} // namespace Tools

View File

@ -118,6 +118,15 @@ namespace Tools
QVariantMap qo2qvm(const QObject* object, const QStringList& ignoredProperties = {"objectName"});
QString substituteBackupFilePath(QString pattern, const QString& databasePath);
enum class MimeType : uint8_t
{
Image,
PlainText,
Unknown
};
MimeType toMimeType(const QString& mimeName);
} // namespace Tools
#endif // KEEPASSX_TOOLS_H

View File

@ -288,6 +288,9 @@
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
</property>
<property name="blendIn" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
@ -325,6 +328,9 @@
<property name="textInteractionFlags">
<set>Qt::TextBrowserInteraction</set>
</property>
<property name="blendIn" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item row="0" column="3">
@ -409,6 +415,9 @@
<property name="readOnly">
<bool>true</bool>
</property>
<property name="blendIn" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
@ -482,6 +491,9 @@
<property name="readOnly">
<bool>true</bool>
</property>
<property name="blendIn" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
@ -494,6 +506,9 @@
<property name="accessibleName">
<string>Tags list</string>
</property>
<property name="blendIn" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="5">
@ -516,6 +531,9 @@
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
</property>
<property name="blendIn" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="3">
@ -1109,6 +1127,9 @@
<property name="readOnly">
<bool>true</bool>
</property>
<property name="blendIn" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
</layout>

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>EntryAttachmentsDialog</class>
<widget class="QDialog" name="EntryAttachmentsDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>402</width>
<height>300</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLineEdit" name="titleEdit">
<property name="placeholderText">
<string>File name</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="errorLabel">
<property name="enabled">
<bool>true</bool>
</property>
<property name="styleSheet">
<string notr="true">color: #FF9696</string>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QTextEdit" name="attachmentTextEdit">
<property name="placeholderText">
<string>File contents...</string>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="dialogButtons">
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -16,16 +16,19 @@
*/
#include "EntryAttachmentsWidget.h"
#include "EntryAttachmentsModel.h"
#include "NewEntryAttachmentsDialog.h"
#include "PreviewEntryAttachmentsDialog.h"
#include "ui_EntryAttachmentsWidget.h"
#include <QDir>
#include <QDebug>
#include <QDropEvent>
#include <QMimeData>
#include <QStandardPaths>
#include <QTemporaryFile>
#include "EntryAttachmentsModel.h"
#include "core/Config.h"
#include "core/EntryAttachments.h"
#include "core/Tools.h"
#include "gui/FileDialog.h"
@ -46,12 +49,12 @@ EntryAttachmentsWidget::EntryAttachmentsWidget(QWidget* parent)
m_ui->attachmentsView->viewport()->installEventFilter(this);
m_ui->attachmentsView->setModel(m_attachmentsModel);
m_ui->attachmentsView->verticalHeader()->hide();
m_ui->attachmentsView->horizontalHeader()->setStretchLastSection(true);
m_ui->attachmentsView->horizontalHeader()->resizeSection(EntryAttachmentsModel::NameColumn, 400);
m_ui->attachmentsView->setSelectionBehavior(QAbstractItemView::SelectRows);
m_ui->attachmentsView->setSelectionMode(QAbstractItemView::ExtendedSelection);
m_ui->attachmentsView->setEditTriggers(QAbstractItemView::SelectedClicked);
m_ui->attachmentsView->horizontalHeader()->setMinimumSectionSize(70);
m_ui->attachmentsView->horizontalHeader()->setSectionResizeMode(EntryAttachmentsModel::NameColumn,
QHeaderView::Stretch);
m_ui->attachmentsView->horizontalHeader()->setSectionResizeMode(EntryAttachmentsModel::SizeColumn,
QHeaderView::ResizeToContents);
m_ui->attachmentsView->verticalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
connect(this, SIGNAL(buttonsVisibleChanged(bool)), this, SLOT(updateButtonsVisible()));
connect(this, SIGNAL(readOnlyChanged(bool)), SLOT(updateButtonsEnabled()));
@ -64,12 +67,13 @@ EntryAttachmentsWidget::EntryAttachmentsWidget(QWidget* parent)
// clang-format on
connect(this, SIGNAL(readOnlyChanged(bool)), m_attachmentsModel, SLOT(setReadOnly(bool)));
connect(m_ui->attachmentsView, SIGNAL(doubleClicked(QModelIndex)), SLOT(openAttachment(QModelIndex)));
connect(m_ui->attachmentsView, SIGNAL(doubleClicked(QModelIndex)), SLOT(previewSelectedAttachment()));
connect(m_ui->saveAttachmentButton, SIGNAL(clicked()), SLOT(saveSelectedAttachments()));
connect(m_ui->openAttachmentButton, SIGNAL(clicked()), SLOT(openSelectedAttachments()));
connect(m_ui->addAttachmentButton, SIGNAL(clicked()), SLOT(insertAttachments()));
connect(m_ui->newAttachmentButton, SIGNAL(clicked()), SLOT(newAttachments()));
connect(m_ui->previewAttachmentButton, SIGNAL(clicked()), SLOT(previewSelectedAttachment()));
connect(m_ui->removeAttachmentButton, SIGNAL(clicked()), SLOT(removeSelectedAttachments()));
connect(m_ui->renameAttachmentButton, SIGNAL(clicked()), SLOT(renameSelectedAttachments()));
updateButtonsVisible();
updateButtonsEnabled();
@ -165,6 +169,57 @@ void EntryAttachmentsWidget::insertAttachments()
emit widgetUpdated();
}
void EntryAttachmentsWidget::newAttachments()
{
Q_ASSERT(m_entryAttachments);
Q_ASSERT(!isReadOnly());
if (isReadOnly()) {
return;
}
NewEntryAttachmentsDialog newEntryDialog(m_entryAttachments, this);
if (newEntryDialog.exec() == QDialog::Accepted) {
emit widgetUpdated();
}
}
void EntryAttachmentsWidget::previewSelectedAttachment()
{
Q_ASSERT(m_entryAttachments);
const auto index = m_ui->attachmentsView->selectionModel()->selectedIndexes().first();
if (!index.isValid()) {
qWarning() << tr("Failed to preview an attachment: Attachment not found");
return;
}
// Set selection to the first
m_ui->attachmentsView->setCurrentIndex(index);
auto name = m_attachmentsModel->keyByIndex(index);
auto data = m_entryAttachments->value(name);
PreviewEntryAttachmentsDialog previewDialog(this);
previewDialog.setAttachment(name, data);
connect(&previewDialog, SIGNAL(openAttachment(QString)), SLOT(openSelectedAttachments()));
connect(&previewDialog, SIGNAL(saveAttachment(QString)), SLOT(saveSelectedAttachments()));
// Refresh the preview if the attachment changes
connect(m_entryAttachments,
&EntryAttachments::keyModified,
&previewDialog,
[&previewDialog, &name, this](const QString& key) {
if (key == name) {
previewDialog.setAttachment(name, m_entryAttachments->value(name));
}
});
previewDialog.exec();
// Set focus back to the widget to allow keyboard navigation
setFocus();
}
void EntryAttachmentsWidget::removeSelectedAttachments()
{
Q_ASSERT(m_entryAttachments);
@ -194,12 +249,6 @@ void EntryAttachmentsWidget::removeSelectedAttachments()
}
}
void EntryAttachmentsWidget::renameSelectedAttachments()
{
Q_ASSERT(m_entryAttachments);
m_ui->attachmentsView->edit(m_ui->attachmentsView->selectionModel()->selectedIndexes().first());
}
void EntryAttachmentsWidget::saveSelectedAttachments()
{
Q_ASSERT(m_entryAttachments);
@ -289,7 +338,7 @@ void EntryAttachmentsWidget::openSelectedAttachments()
if (!m_entryAttachments->openAttachment(m_attachmentsModel->keyByIndex(index), &errorMessage)) {
const QString filename = m_attachmentsModel->keyByIndex(index);
errors.append(QString("%1 - %2").arg(filename, errorMessage));
};
}
}
if (!errors.isEmpty()) {
@ -302,18 +351,32 @@ void EntryAttachmentsWidget::updateButtonsEnabled()
const bool hasSelection = m_ui->attachmentsView->selectionModel()->hasSelection();
m_ui->addAttachmentButton->setEnabled(!m_readOnly);
m_ui->newAttachmentButton->setEnabled(!m_readOnly);
m_ui->removeAttachmentButton->setEnabled(hasSelection && !m_readOnly);
m_ui->renameAttachmentButton->setEnabled(hasSelection && !m_readOnly);
m_ui->saveAttachmentButton->setEnabled(hasSelection);
m_ui->previewAttachmentButton->setEnabled(hasSelection);
m_ui->openAttachmentButton->setEnabled(hasSelection);
updateSpacers();
}
void EntryAttachmentsWidget::updateSpacers()
{
if (m_buttonsVisible && !m_readOnly) {
m_ui->previewVSpacer->changeSize(20, 40, QSizePolicy::Fixed, QSizePolicy::Expanding);
} else {
m_ui->previewVSpacer->changeSize(0, 0, QSizePolicy::Fixed, QSizePolicy::Fixed);
}
}
void EntryAttachmentsWidget::updateButtonsVisible()
{
m_ui->addAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly);
m_ui->newAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly);
m_ui->removeAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly);
m_ui->renameAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly);
updateSpacers();
}
bool EntryAttachmentsWidget::insertAttachments(const QStringList& filenames, QString& errorMessage)

View File

@ -57,8 +57,9 @@ signals:
private slots:
void insertAttachments();
void newAttachments();
void previewSelectedAttachment();
void removeSelectedAttachments();
void renameSelectedAttachments();
void saveSelectedAttachments();
void openAttachment(const QModelIndex& index);
void openSelectedAttachments();
@ -67,6 +68,8 @@ private slots:
void attachmentModifiedExternally(const QString& key, const QString& filePath);
private:
void updateSpacers();
bool insertAttachments(const QStringList& fileNames, QString& errorMessage);
QStringList confirmAttachmentSelection(const QStringList& filenames);

View File

@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>337</width>
<height>289</height>
<height>258</height>
</rect>
</property>
<property name="windowTitle">
@ -34,11 +34,20 @@
<property name="editTriggers">
<set>QAbstractItemView::EditKeyPressed|QAbstractItemView::SelectedClicked</set>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="cornerButtonEnabled">
<bool>false</bool>
</property>
<attribute name="verticalHeaderVisible">
<bool>false</bool>
</attribute>
</widget>
</item>
<item>
<widget class="QWidget" name="actionsWidget" native="true">
<layout class="QVBoxLayout" name="verticalLayout">
<layout class="QVBoxLayout" name="verticalLayout" stretch="0,0,1,0,0,0,1,0,0">
<property name="leftMargin">
<number>0</number>
</property>
@ -51,6 +60,16 @@
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QPushButton" name="newAttachmentButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>New</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="addAttachmentButton">
<property name="enabled">
@ -65,28 +84,25 @@
</widget>
</item>
<item>
<widget class="QPushButton" name="removeAttachmentButton">
<property name="enabled">
<bool>false</bool>
<spacer name="previewVSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="accessibleName">
<string>Remove selected attachment</string>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
<property name="text">
<string>Remove</string>
</property>
</widget>
</spacer>
</item>
<item>
<widget class="QPushButton" name="renameAttachmentButton">
<widget class="QPushButton" name="previewAttachmentButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="accessibleName">
<string>Rename selected attachment</string>
</property>
<property name="text">
<string>Rename</string>
<string>Preview</string>
</property>
</widget>
</item>
@ -129,6 +145,35 @@
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="removeAttachmentButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="accessibleName">
<string>Remove selected attachment</string>
</property>
<property name="text">
<string>Remove</string>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Minimum</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>0</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>

View File

@ -0,0 +1,92 @@
/*
* Copyright (C) 2025 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 "NewEntryAttachmentsDialog.h"
#include "core/EntryAttachments.h"
#include "ui_EntryAttachmentsDialog.h"
#include <QMessageBox>
#include <QPushButton>
NewEntryAttachmentsDialog::NewEntryAttachmentsDialog(QPointer<EntryAttachments> attachments, QWidget* parent)
: QDialog(parent)
, m_attachments(std::move(attachments))
, m_ui(new Ui::EntryAttachmentsDialog)
{
Q_ASSERT(m_attachments);
m_ui->setupUi(this);
setWindowTitle(tr("New entry attachment"));
m_ui->dialogButtons->clear();
m_ui->dialogButtons->addButton(QDialogButtonBox::Ok);
m_ui->dialogButtons->addButton(QDialogButtonBox::Cancel);
connect(m_ui->dialogButtons, SIGNAL(accepted()), this, SLOT(saveAttachment()));
connect(m_ui->dialogButtons, SIGNAL(rejected()), this, SLOT(reject()));
connect(m_ui->titleEdit, SIGNAL(textChanged(const QString&)), this, SLOT(fileNameTextChanged(const QString&)));
fileNameTextChanged(m_ui->titleEdit->text());
}
NewEntryAttachmentsDialog::~NewEntryAttachmentsDialog() = default;
bool NewEntryAttachmentsDialog::validateFileName(const QString& fileName, QString& error) const
{
if (fileName.isEmpty()) {
error = tr("Attachment name cannot be empty");
return false;
}
if (m_attachments->hasKey(fileName)) {
error = tr("Attachment with the same name already exists");
return false;
}
return true;
}
void NewEntryAttachmentsDialog::saveAttachment()
{
auto fileName = m_ui->titleEdit->text();
auto text = m_ui->attachmentTextEdit->toPlainText().toUtf8();
QString error;
if (validateFileName(fileName, error)) {
QMessageBox::warning(this, tr("Save attachment"), error);
return;
}
m_attachments->set(fileName, text);
accept();
}
void NewEntryAttachmentsDialog::fileNameTextChanged(const QString& fileName)
{
QString error;
bool valid = validateFileName(fileName, error);
m_ui->errorLabel->setText(error);
m_ui->errorLabel->setVisible(!valid);
auto okButton = m_ui->dialogButtons->button(QDialogButtonBox::Ok);
if (okButton) {
okButton->setDisabled(!valid);
}
}

View File

@ -0,0 +1,48 @@
/*
* Copyright (C) 2025 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/>.
*/
#pragma once
#include <QDialog>
#include <QPointer>
namespace Ui
{
class EntryAttachmentsDialog;
}
class QByteArray;
class EntryAttachments;
class NewEntryAttachmentsDialog : public QDialog
{
Q_OBJECT
public:
explicit NewEntryAttachmentsDialog(QPointer<EntryAttachments> attachments, QWidget* parent = nullptr);
~NewEntryAttachmentsDialog() override;
private slots:
void saveAttachment();
void fileNameTextChanged(const QString& fileName);
private:
bool validateFileName(const QString& fileName, QString& error) const;
QPointer<EntryAttachments> m_attachments;
QScopedPointer<Ui::EntryAttachmentsDialog> m_ui;
};

View File

@ -0,0 +1,123 @@
/*
* Copyright (C) 2025 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 "PreviewEntryAttachmentsDialog.h"
#include "ui_EntryAttachmentsDialog.h"
#include <QDialogButtonBox>
#include <QMimeDatabase>
#include <QPushButton>
#include <QTextCursor>
#include <QtDebug>
PreviewEntryAttachmentsDialog::PreviewEntryAttachmentsDialog(QWidget* parent)
: QDialog(parent)
, m_ui(new Ui::EntryAttachmentsDialog)
{
m_ui->setupUi(this);
setWindowTitle(tr("Preview entry attachment"));
// Disable the help button in the title bar
setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
// Set to read-only
m_ui->titleEdit->setReadOnly(true);
m_ui->attachmentTextEdit->setReadOnly(true);
m_ui->errorLabel->setVisible(false);
// Initialize dialog buttons
m_ui->dialogButtons->setStandardButtons(QDialogButtonBox::Close | QDialogButtonBox::Open | QDialogButtonBox::Save);
auto closeButton = m_ui->dialogButtons->button(QDialogButtonBox::Close);
closeButton->setDefault(true);
connect(m_ui->dialogButtons, SIGNAL(rejected()), this, SLOT(reject()));
connect(m_ui->dialogButtons, &QDialogButtonBox::clicked, [this](QAbstractButton* button) {
auto pressedButton = m_ui->dialogButtons->standardButton(button);
if (pressedButton == QDialogButtonBox::Open) {
emit openAttachment(m_name);
} else if (pressedButton == QDialogButtonBox::Save) {
emit saveAttachment(m_name);
}
});
}
PreviewEntryAttachmentsDialog::~PreviewEntryAttachmentsDialog() = default;
void PreviewEntryAttachmentsDialog::setAttachment(const QString& name, const QByteArray& data)
{
m_name = name;
m_ui->titleEdit->setText(m_name);
m_type = attachmentType(data);
m_data = data;
update();
}
void PreviewEntryAttachmentsDialog::update()
{
if (m_type == Tools::MimeType::Unknown) {
updateTextAttachment(tr("No preview available").toUtf8());
} else if (m_type == Tools::MimeType::Image) {
updateImageAttachment(m_data);
} else if (m_type == Tools::MimeType::PlainText) {
updateTextAttachment(m_data);
}
}
void PreviewEntryAttachmentsDialog::updateTextAttachment(const QByteArray& data)
{
m_ui->attachmentTextEdit->setPlainText(QString::fromUtf8(data));
}
void PreviewEntryAttachmentsDialog::updateImageAttachment(const QByteArray& data)
{
QImage image{};
if (!image.loadFromData(data)) {
updateTextAttachment(tr("Image format not supported").toUtf8());
return;
}
m_ui->attachmentTextEdit->clear();
auto cursor = m_ui->attachmentTextEdit->textCursor();
// Scale the image to the contents rect minus another set of margins to avoid scrollbars
auto margins = m_ui->attachmentTextEdit->contentsMargins();
auto size = m_ui->attachmentTextEdit->contentsRect().size();
size.setWidth(size.width() - margins.left() - margins.right());
size.setHeight(size.height() - margins.top() - margins.bottom());
image = image.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation);
cursor.insertImage(image);
}
Tools::MimeType PreviewEntryAttachmentsDialog::attachmentType(const QByteArray& data) const
{
QMimeDatabase mimeDb{};
const auto mime = mimeDb.mimeTypeForData(data);
return Tools::toMimeType(mime.name());
}
void PreviewEntryAttachmentsDialog::resizeEvent(QResizeEvent* event)
{
QDialog::resizeEvent(event);
if (m_type == Tools::MimeType::Image) {
update();
}
}

View File

@ -0,0 +1,59 @@
/*
* Copyright (C) 2025 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/>.
*/
#pragma once
#include "core/Tools.h"
#include <QDialog>
#include <QPointer>
namespace Ui
{
class EntryAttachmentsDialog;
}
class PreviewEntryAttachmentsDialog : public QDialog
{
Q_OBJECT
public:
explicit PreviewEntryAttachmentsDialog(QWidget* parent = nullptr);
~PreviewEntryAttachmentsDialog() override;
void setAttachment(const QString& name, const QByteArray& data);
signals:
void openAttachment(const QString& name);
void saveAttachment(const QString& name);
protected:
void resizeEvent(QResizeEvent* event) override;
private:
Tools::MimeType attachmentType(const QByteArray& data) const;
void update();
void updateTextAttachment(const QByteArray& data);
void updateImageAttachment(const QByteArray& data);
QScopedPointer<Ui::EntryAttachmentsDialog> m_ui;
QString m_name;
QByteArray m_data;
Tools::MimeType m_type{Tools::MimeType::Unknown};
};

View File

@ -21,7 +21,9 @@ QCheckBox, QRadioButton {
spacing: 10px;
}
ReportsDialog QTableView::item {
ReportsDialog QTableView::item,
EntryAttachmentsWidget QTableView::item
{
padding: 4px;
}
@ -30,8 +32,7 @@ DatabaseWidget, DatabaseWidget #groupView, DatabaseWidget #tagView {
border: none;
}
EntryPreviewWidget QLineEdit, EntryPreviewWidget QTextEdit,
EntryPreviewWidget TagsEdit
EntryPreviewWidget *[blendIn="true"]
{
background-color: palette(window);
border: none;

View File

@ -272,3 +272,70 @@ void TestTools::testArrayContainsValues()
const auto result3 = Tools::getMissingValuesFromList<int>(numberValues, QList<int>({6, 7, 8}));
QCOMPARE(result3.length(), 3);
}
void TestTools::testMimeTypes()
{
const QStringList TextMimeTypes = {
"text/plain", // Plain text
"text/html", // HTML documents
"text/css", // CSS stylesheets
"text/javascript", // JavaScript files
"text/markdown", // Markdown documents
"text/xml", // XML documents
"text/rtf", // Rich Text Format
"text/vcard", // vCard files
"text/tab-separated-values", // Tab-separated values
"application/json", // JSON data
"application/xml", // XML data
"application/soap+xml", // SOAP messages
"application/x-yaml", // YAML data
"application/protobuf", // Protocol Buffers
};
const QStringList ImageMimeTypes = {
"image/jpeg", // JPEG images
"image/png", // PNG images
"image/gif", // GIF images
"image/bmp", // BMP images
"image/webp", // WEBP images
"image/svg+xml" // SVG images
};
const QStringList UnknownMimeTypes = {
"audio/mpeg", // MPEG audio files
"video/mp4", // MP4 video files
"application/pdf", // PDF documents
"application/zip", // ZIP archives
"application/x-tar", // TAR archives
"application/x-rar-compressed", // RAR archives
"application/x-7z-compressed", // 7z archives
"application/x-shockwave-flash", // Adobe Flash files
"application/vnd.ms-excel", // Microsoft Excel files
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // Microsoft Excel (OpenXML) files
"application/vnd.ms-powerpoint", // Microsoft PowerPoint files
"application/vnd.openxmlformats-officedocument.presentationml.presentation", // Microsoft PowerPoint (OpenXML)
// files
"application/msword", // Microsoft Word files
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // Microsoft Word (OpenXML) files
"application/vnd.oasis.opendocument.text", // OpenDocument Text
"application/vnd.oasis.opendocument.spreadsheet", // OpenDocument Spreadsheet
"application/vnd.oasis.opendocument.presentation", // OpenDocument Presentation
"application/x-httpd-php", // PHP files
"application/x-perl", // Perl scripts
"application/x-python", // Python scripts
"application/x-ruby", // Ruby scripts
"application/x-shellscript", // Shell scripts
};
for (const auto& mime : TextMimeTypes) {
QCOMPARE(Tools::toMimeType(mime), Tools::MimeType::PlainText);
}
for (const auto& mime : ImageMimeTypes) {
QCOMPARE(Tools::toMimeType(mime), Tools::MimeType::Image);
}
for (const auto& mime : UnknownMimeTypes) {
QCOMPARE(Tools::toMimeType(mime), Tools::MimeType::Unknown);
}
}

View File

@ -37,6 +37,7 @@ private slots:
void testConvertToRegex();
void testConvertToRegex_data();
void testArrayContainsValues();
void testMimeTypes();
};
#endif // KEEPASSX_TESTTOOLS_H