keepassxc/src/gui/entry/EntryAttachmentsWidget.cpp

457 lines
16 KiB
C++
Raw Normal View History

/*
* 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 "EntryAttachmentsWidget.h"
#include "ui_EntryAttachmentsWidget.h"
#include <QDir>
#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"
#include "gui/MessageBox.h"
2018-03-31 20:01:30 +00:00
EntryAttachmentsWidget::EntryAttachmentsWidget(QWidget* parent)
: QWidget(parent)
, m_ui(new Ui::EntryAttachmentsWidget)
, m_entryAttachments(nullptr)
, m_attachmentsModel(new EntryAttachmentsModel(this))
, m_readOnly(false)
, m_buttonsVisible(true)
{
m_ui->setupUi(this);
m_ui->attachmentsView->setAcceptDrops(false);
m_ui->attachmentsView->viewport()->setAcceptDrops(true);
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);
connect(this, SIGNAL(buttonsVisibleChanged(bool)), this, SLOT(updateButtonsVisible()));
connect(this, SIGNAL(readOnlyChanged(bool)), SLOT(updateButtonsEnabled()));
connect(m_attachmentsModel, SIGNAL(modelReset()), SLOT(updateButtonsEnabled()));
// clang-format off
2018-03-31 20:01:30 +00:00
connect(m_ui->attachmentsView->selectionModel(),
SIGNAL(selectionChanged(QItemSelection,QItemSelection)),
SLOT(updateButtonsEnabled()));
// 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->saveAttachmentButton, SIGNAL(clicked()), SLOT(saveSelectedAttachments()));
connect(m_ui->openAttachmentButton, SIGNAL(clicked()), SLOT(openSelectedAttachments()));
connect(m_ui->addAttachmentButton, SIGNAL(clicked()), SLOT(insertAttachments()));
connect(m_ui->removeAttachmentButton, SIGNAL(clicked()), SLOT(removeSelectedAttachments()));
connect(m_ui->renameAttachmentButton, SIGNAL(clicked()), SLOT(renameSelectedAttachments()));
updateButtonsVisible();
updateButtonsEnabled();
}
EntryAttachmentsWidget::~EntryAttachmentsWidget()
{
}
const EntryAttachments* EntryAttachmentsWidget::attachments() const
{
return m_entryAttachments;
}
bool EntryAttachmentsWidget::isReadOnly() const
{
return m_readOnly;
}
bool EntryAttachmentsWidget::isButtonsVisible() const
{
return m_buttonsVisible;
}
void EntryAttachmentsWidget::linkAttachments(EntryAttachments* attachments)
{
unlinkAttachments();
m_entryAttachments = attachments;
m_attachmentsModel->setEntryAttachments(m_entryAttachments);
if (m_entryAttachments) {
connect(m_entryAttachments,
SIGNAL(valueModifiedExternally(QString, QString)),
this,
SLOT(attachmentModifiedExternally(QString, QString)));
connect(m_entryAttachments, SIGNAL(modified()), this, SIGNAL(widgetUpdated()));
}
}
void EntryAttachmentsWidget::unlinkAttachments()
{
if (m_entryAttachments) {
m_entryAttachments->disconnect(this);
m_entryAttachments = nullptr;
m_attachmentsModel->setEntryAttachments(nullptr);
}
}
void EntryAttachmentsWidget::setReadOnly(bool readOnly)
{
if (m_readOnly == readOnly) {
return;
}
m_readOnly = readOnly;
emit readOnlyChanged(m_readOnly);
}
void EntryAttachmentsWidget::setButtonsVisible(bool buttonsVisible)
{
if (m_buttonsVisible == buttonsVisible) {
return;
}
m_buttonsVisible = buttonsVisible;
emit buttonsVisibleChanged(m_buttonsVisible);
}
void EntryAttachmentsWidget::insertAttachments()
{
Q_ASSERT(m_entryAttachments);
Q_ASSERT(!isReadOnly());
if (isReadOnly()) {
return;
}
QString defaultDirPath = FileDialog::getLastDir("attachments");
const auto filenames = fileDialog()->getOpenFileNames(this, tr("Select files"), defaultDirPath);
if (filenames.isEmpty()) {
return;
}
const auto confirmedFileNames = confirmAttachmentSelection(filenames);
if (confirmedFileNames.isEmpty()) {
return;
}
// Save path to first filename
FileDialog::saveLastDir("attachments", filenames[0]);
QString errorMessage;
if (!insertAttachments(confirmedFileNames, errorMessage)) {
errorOccurred(errorMessage);
}
Grey out Apply button when there are no changes Resolves #1313 What this commit does: * Whenever the Apply button is pressed, and if the save was successful, then the Apply button is disabled. * Each subwidget used by EditEntryWidget has now a signal called `widgetUpdated` that is emitted when the widgets' internal content changes. The EditEntryWidget subscribes to that signal to know when to enable the Apply button (by calling `entryUpdated()`). * There are some views that are not isolated in their own widgets (`m_advancedUi`, for example) so in those cases I invoked `entryUpdated()` directly whenever I detected an update: * some updates occur directly in a Qt widget like when editing the text of a QLineItem, so in that case I connected the widget's signals directly to the `entryUpdated()` slot. * some updates occur in EditEntryWidget, so in those cases the invocation to `entryUpdated()` is made as soon as the change is detected (for example when the user has confirmed an action in a dialog). A known problem: there are some situations when the Apply button will get enabled even if there are no changes, this is because the app changes the value of a field by itself so it's considered an update (for example, clicking on the "Reveal" button changes the text shown in a text field). The solution to this can be a bit complicated: disabling temporarily the `entryUpdated()` whenever the app is going to do an action with such side-effects. So I preferred to let the Apply button get enabled in those cases.
2018-03-11 03:31:43 +00:00
emit widgetUpdated();
}
void EntryAttachmentsWidget::removeSelectedAttachments()
{
Q_ASSERT(m_entryAttachments);
Q_ASSERT(!isReadOnly());
if (isReadOnly()) {
return;
}
const QModelIndexList indexes = m_ui->attachmentsView->selectionModel()->selectedRows(0);
if (indexes.isEmpty()) {
return;
}
Customize buttons on MessageBox and confirm before recycling (#2376) * Add confirmation prompt before moving groups to the recycling bin Spawn a yes/no QMessage box when "Delete Group" is selected on a group that is not already in the recycle bin (note: the prompt for deletion from the recycle bin was already implemented). This follows the same pattern and language as entry deletion. Fixes #2125 * Make prompts for destructive operations use action words on buttons Replace yes/no, yes/cancel (and other such buttons on prompts that cause data to be destroyed) use language that indicates the action that it is going to take. This makes destructive/unsafe and/or irreversible operations more clear to the user. Address feedback on PR #2376 * Refactor MessageBox class to allow for custom buttons Replaces arguments and return values of type QMessageBox::StandardButton(s) with MessageBox::Button(s), which reimplements the entire set of QMessageBox::StandardButton and allows for custom KeePassXC buttons, such as "Skip". Modifies all calls to MessageBox functions to use MessageBox::Button(s). Addresses feedback on #2376 * Remove MessageBox::addButton in favor of map lookup Replaced the switch statement mechanism in MessageBox::addButton with a map lookup to address CodeFactor Complex Method issue. This has a side-effect of a small performance/cleanliness increase, as an extra QPushButton is no longer created/destroyed (to obtain it's label text) everytime a MessageBox button based on QMessageBox::StandardButton is created; now the text is obtained once, at application start up.
2018-12-20 04:14:11 +00:00
auto result = MessageBox::question(this,
tr("Confirm remove"),
tr("Are you sure you want to remove %n attachment(s)?", "", indexes.count()),
MessageBox::Remove | MessageBox::Cancel,
MessageBox::Cancel);
if (result == MessageBox::Remove) {
QStringList keys;
2018-03-31 20:01:30 +00:00
for (const QModelIndex& index : indexes) {
keys.append(m_attachmentsModel->keyByIndex(index));
}
m_entryAttachments->remove(keys);
Grey out Apply button when there are no changes Resolves #1313 What this commit does: * Whenever the Apply button is pressed, and if the save was successful, then the Apply button is disabled. * Each subwidget used by EditEntryWidget has now a signal called `widgetUpdated` that is emitted when the widgets' internal content changes. The EditEntryWidget subscribes to that signal to know when to enable the Apply button (by calling `entryUpdated()`). * There are some views that are not isolated in their own widgets (`m_advancedUi`, for example) so in those cases I invoked `entryUpdated()` directly whenever I detected an update: * some updates occur directly in a Qt widget like when editing the text of a QLineItem, so in that case I connected the widget's signals directly to the `entryUpdated()` slot. * some updates occur in EditEntryWidget, so in those cases the invocation to `entryUpdated()` is made as soon as the change is detected (for example when the user has confirmed an action in a dialog). A known problem: there are some situations when the Apply button will get enabled even if there are no changes, this is because the app changes the value of a field by itself so it's considered an update (for example, clicking on the "Reveal" button changes the text shown in a text field). The solution to this can be a bit complicated: disabling temporarily the `entryUpdated()` whenever the app is going to do an action with such side-effects. So I preferred to let the Apply button get enabled in those cases.
2018-03-11 03:31:43 +00:00
emit widgetUpdated();
}
}
void EntryAttachmentsWidget::renameSelectedAttachments()
{
Q_ASSERT(m_entryAttachments);
m_ui->attachmentsView->edit(m_ui->attachmentsView->selectionModel()->selectedIndexes().first());
}
void EntryAttachmentsWidget::saveSelectedAttachments()
{
Q_ASSERT(m_entryAttachments);
const QModelIndexList indexes = m_ui->attachmentsView->selectionModel()->selectedRows(0);
if (indexes.isEmpty()) {
return;
}
QString defaultDirPath = FileDialog::getLastDir("attachments");
const QString saveDirPath = fileDialog()->getExistingDirectory(this, tr("Save attachments"), defaultDirPath);
if (saveDirPath.isEmpty()) {
return;
}
QDir saveDir(saveDirPath);
if (!saveDir.exists()) {
if (saveDir.mkpath(saveDir.absolutePath())) {
errorOccurred(tr("Unable to create directory:\n%1").arg(saveDir.absolutePath()));
return;
}
}
FileDialog::saveLastDir("attachments", saveDirPath);
QStringList errors;
2018-03-31 20:01:30 +00:00
for (const QModelIndex& index : indexes) {
const QString filename = m_attachmentsModel->keyByIndex(index);
const QString attachmentPath = saveDir.absoluteFilePath(filename);
if (QFileInfo::exists(attachmentPath)) {
Customize buttons on MessageBox and confirm before recycling (#2376) * Add confirmation prompt before moving groups to the recycling bin Spawn a yes/no QMessage box when "Delete Group" is selected on a group that is not already in the recycle bin (note: the prompt for deletion from the recycle bin was already implemented). This follows the same pattern and language as entry deletion. Fixes #2125 * Make prompts for destructive operations use action words on buttons Replace yes/no, yes/cancel (and other such buttons on prompts that cause data to be destroyed) use language that indicates the action that it is going to take. This makes destructive/unsafe and/or irreversible operations more clear to the user. Address feedback on PR #2376 * Refactor MessageBox class to allow for custom buttons Replaces arguments and return values of type QMessageBox::StandardButton(s) with MessageBox::Button(s), which reimplements the entire set of QMessageBox::StandardButton and allows for custom KeePassXC buttons, such as "Skip". Modifies all calls to MessageBox functions to use MessageBox::Button(s). Addresses feedback on #2376 * Remove MessageBox::addButton in favor of map lookup Replaced the switch statement mechanism in MessageBox::addButton with a map lookup to address CodeFactor Complex Method issue. This has a side-effect of a small performance/cleanliness increase, as an extra QPushButton is no longer created/destroyed (to obtain it's label text) everytime a MessageBox button based on QMessageBox::StandardButton is created; now the text is obtained once, at application start up.
2018-12-20 04:14:11 +00:00
MessageBox::Buttons buttons = MessageBox::Overwrite | MessageBox::Cancel;
if (indexes.length() > 1) {
buttons |= MessageBox::Skip;
}
const QString questionText(
2018-03-31 20:01:30 +00:00
tr("Are you sure you want to overwrite the existing file \"%1\" with the attachment?"));
Customize buttons on MessageBox and confirm before recycling (#2376) * Add confirmation prompt before moving groups to the recycling bin Spawn a yes/no QMessage box when "Delete Group" is selected on a group that is not already in the recycle bin (note: the prompt for deletion from the recycle bin was already implemented). This follows the same pattern and language as entry deletion. Fixes #2125 * Make prompts for destructive operations use action words on buttons Replace yes/no, yes/cancel (and other such buttons on prompts that cause data to be destroyed) use language that indicates the action that it is going to take. This makes destructive/unsafe and/or irreversible operations more clear to the user. Address feedback on PR #2376 * Refactor MessageBox class to allow for custom buttons Replaces arguments and return values of type QMessageBox::StandardButton(s) with MessageBox::Button(s), which reimplements the entire set of QMessageBox::StandardButton and allows for custom KeePassXC buttons, such as "Skip". Modifies all calls to MessageBox functions to use MessageBox::Button(s). Addresses feedback on #2376 * Remove MessageBox::addButton in favor of map lookup Replaced the switch statement mechanism in MessageBox::addButton with a map lookup to address CodeFactor Complex Method issue. This has a side-effect of a small performance/cleanliness increase, as an extra QPushButton is no longer created/destroyed (to obtain it's label text) everytime a MessageBox button based on QMessageBox::StandardButton is created; now the text is obtained once, at application start up.
2018-12-20 04:14:11 +00:00
auto result = MessageBox::question(
this, tr("Confirm overwrite"), questionText.arg(filename), buttons, MessageBox::Cancel);
Customize buttons on MessageBox and confirm before recycling (#2376) * Add confirmation prompt before moving groups to the recycling bin Spawn a yes/no QMessage box when "Delete Group" is selected on a group that is not already in the recycle bin (note: the prompt for deletion from the recycle bin was already implemented). This follows the same pattern and language as entry deletion. Fixes #2125 * Make prompts for destructive operations use action words on buttons Replace yes/no, yes/cancel (and other such buttons on prompts that cause data to be destroyed) use language that indicates the action that it is going to take. This makes destructive/unsafe and/or irreversible operations more clear to the user. Address feedback on PR #2376 * Refactor MessageBox class to allow for custom buttons Replaces arguments and return values of type QMessageBox::StandardButton(s) with MessageBox::Button(s), which reimplements the entire set of QMessageBox::StandardButton and allows for custom KeePassXC buttons, such as "Skip". Modifies all calls to MessageBox functions to use MessageBox::Button(s). Addresses feedback on #2376 * Remove MessageBox::addButton in favor of map lookup Replaced the switch statement mechanism in MessageBox::addButton with a map lookup to address CodeFactor Complex Method issue. This has a side-effect of a small performance/cleanliness increase, as an extra QPushButton is no longer created/destroyed (to obtain it's label text) everytime a MessageBox button based on QMessageBox::StandardButton is created; now the text is obtained once, at application start up.
2018-12-20 04:14:11 +00:00
if (result == MessageBox::Skip) {
continue;
Customize buttons on MessageBox and confirm before recycling (#2376) * Add confirmation prompt before moving groups to the recycling bin Spawn a yes/no QMessage box when "Delete Group" is selected on a group that is not already in the recycle bin (note: the prompt for deletion from the recycle bin was already implemented). This follows the same pattern and language as entry deletion. Fixes #2125 * Make prompts for destructive operations use action words on buttons Replace yes/no, yes/cancel (and other such buttons on prompts that cause data to be destroyed) use language that indicates the action that it is going to take. This makes destructive/unsafe and/or irreversible operations more clear to the user. Address feedback on PR #2376 * Refactor MessageBox class to allow for custom buttons Replaces arguments and return values of type QMessageBox::StandardButton(s) with MessageBox::Button(s), which reimplements the entire set of QMessageBox::StandardButton and allows for custom KeePassXC buttons, such as "Skip". Modifies all calls to MessageBox functions to use MessageBox::Button(s). Addresses feedback on #2376 * Remove MessageBox::addButton in favor of map lookup Replaced the switch statement mechanism in MessageBox::addButton with a map lookup to address CodeFactor Complex Method issue. This has a side-effect of a small performance/cleanliness increase, as an extra QPushButton is no longer created/destroyed (to obtain it's label text) everytime a MessageBox button based on QMessageBox::StandardButton is created; now the text is obtained once, at application start up.
2018-12-20 04:14:11 +00:00
} else if (result == MessageBox::Cancel) {
return;
}
}
QFile file(attachmentPath);
const QByteArray attachmentData = m_entryAttachments->value(filename);
const bool saveOk = file.open(QIODevice::WriteOnly) && file.setPermissions(QFile::ReadUser | QFile::WriteUser)
&& file.write(attachmentData) == attachmentData.size();
if (!saveOk) {
errors.append(QString("%1 - %2").arg(filename, file.errorString()));
}
}
if (!errors.isEmpty()) {
errorOccurred(tr("Unable to save attachments:\n%1").arg(errors.join('\n')));
}
}
void EntryAttachmentsWidget::openAttachment(const QModelIndex& index)
{
Q_ASSERT(index.isValid());
if (!index.isValid()) {
return;
}
QString errorMessage;
if (!m_entryAttachments->openAttachment(m_attachmentsModel->keyByIndex(index), &errorMessage)) {
errorOccurred(tr("Unable to open attachment:\n%1").arg(errorMessage));
}
}
void EntryAttachmentsWidget::openSelectedAttachments()
{
const QModelIndexList indexes = m_ui->attachmentsView->selectionModel()->selectedRows(0);
if (indexes.isEmpty()) {
return;
}
QStringList errors;
2018-03-31 20:01:30 +00:00
for (const QModelIndex& index : indexes) {
QString errorMessage;
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()) {
errorOccurred(tr("Unable to open attachments:\n%1").arg(errors.join('\n')));
}
}
void EntryAttachmentsWidget::updateButtonsEnabled()
{
const bool hasSelection = m_ui->attachmentsView->selectionModel()->hasSelection();
m_ui->addAttachmentButton->setEnabled(!m_readOnly);
m_ui->removeAttachmentButton->setEnabled(hasSelection && !m_readOnly);
m_ui->renameAttachmentButton->setEnabled(hasSelection && !m_readOnly);
m_ui->saveAttachmentButton->setEnabled(hasSelection);
m_ui->openAttachmentButton->setEnabled(hasSelection);
}
void EntryAttachmentsWidget::updateButtonsVisible()
{
m_ui->addAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly);
m_ui->removeAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly);
m_ui->renameAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly);
}
bool EntryAttachmentsWidget::insertAttachments(const QStringList& filenames, QString& errorMessage)
{
Q_ASSERT(!isReadOnly());
if (isReadOnly()) {
return false;
}
QStringList errors;
2018-03-31 20:01:30 +00:00
for (const QString& filename : filenames) {
QByteArray data;
QFile file(filename);
const QFileInfo fInfo(filename);
const bool readOk = file.open(QIODevice::ReadOnly) && Tools::readAllFromDevice(&file, data);
if (readOk) {
m_entryAttachments->set(fInfo.fileName(), data);
} else {
errors.append(QString("%1 - %2").arg(fInfo.fileName(), file.errorString()));
}
}
if (!errors.isEmpty()) {
errorMessage = tr("Unable to open file(s):\n%1", "", errors.size()).arg(errors.join('\n'));
}
return errors.isEmpty();
}
QStringList EntryAttachmentsWidget::confirmAttachmentSelection(const QStringList& filenames)
{
QStringList confirmedFileNames;
for (const auto& file : filenames) {
const QFileInfo fileInfo(file);
auto fileName = fileInfo.fileName();
// Ask for confirmation if overwriting an existing attachment
if (m_entryAttachments->hasKey(fileName)) {
auto result = MessageBox::question(this,
tr("Confirm Overwrite Attachment"),
tr("Attachment \"%1\" already exists. \n"
"Would you like to overwrite the existing attachment?")
.arg(fileName),
MessageBox::Overwrite | MessageBox::No,
MessageBox::No);
if (result == MessageBox::No) {
continue;
}
}
// Ask for confirmation before adding files over 5 MB in size
double size = fileInfo.size() / (1024.0 * 1024.0);
if (size > 5.0) {
auto result =
MessageBox::question(this,
tr("Confirm Attachment"),
tr("%1 is a big file (%2 MB).\nYour database may get very large and reduce "
"performance.\n\nAre you sure to add this file?")
.arg(fileName, QString::number(size, 'f', 1)),
MessageBox::Yes | MessageBox::No,
MessageBox::No);
if (result == MessageBox::No) {
continue;
}
}
confirmedFileNames << file;
}
return confirmedFileNames;
}
bool EntryAttachmentsWidget::eventFilter(QObject* watched, QEvent* e)
{
if (watched == m_ui->attachmentsView->viewport() && !isReadOnly()) {
const QEvent::Type eventType = e->type();
if (eventType == QEvent::DragEnter || eventType == QEvent::DragMove) {
QDropEvent* dropEv = static_cast<QDropEvent*>(e);
const QMimeData* mimeData = dropEv->mimeData();
if (mimeData->hasUrls()) {
dropEv->acceptProposedAction();
return true;
}
} else if (eventType == QEvent::Drop) {
QDropEvent* dropEv = static_cast<QDropEvent*>(e);
const QMimeData* mimeData = dropEv->mimeData();
if (mimeData->hasUrls()) {
dropEv->acceptProposedAction();
QStringList filenames;
const QList<QUrl> urls = mimeData->urls();
2018-03-31 20:01:30 +00:00
for (const QUrl& url : urls) {
const QFileInfo fInfo(url.toLocalFile());
if (fInfo.isFile()) {
filenames.append(fInfo.absoluteFilePath());
}
}
QString errorMessage;
if (!insertAttachments(filenames, errorMessage)) {
errorOccurred(errorMessage);
}
return true;
}
}
}
return QWidget::eventFilter(watched, e);
}
void EntryAttachmentsWidget::attachmentModifiedExternally(const QString& key, const QString& filePath)
{
if (m_pendingChanges.contains(filePath)) {
return;
}
m_pendingChanges << filePath;
auto result = MessageBox::question(
this,
tr("Attachment modified"),
tr("The attachment '%1' was modified.\nDo you want to save the changes to your database?").arg(key),
MessageBox::Save | MessageBox::Discard,
MessageBox::Save);
if (result == MessageBox::Save) {
QFile f(filePath);
if (f.open(QFile::ReadOnly)) {
m_entryAttachments->set(key, f.readAll());
f.close();
emit widgetUpdated();
} else {
MessageBox::critical(this,
tr("Saving attachment failed"),
tr("Saving updated attachment failed.\nError: %1").arg(f.errorString()));
}
}
m_pendingChanges.removeAll(filePath);
}