diff --git a/REUSE.toml b/REUSE.toml index 7bd192ed0570c..be2cdfaf2ed0f 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -127,7 +127,7 @@ SPDX-FileCopyrightText = "2016 Nextcloud GmbH" SPDX-License-Identifier = "LicenseRef-NextcloudTrademarks" [[annotations]] -path = ["theme/white/error.svg", "theme/black/error.svg","theme/white/warning.svg", "theme/black/warning.svg", "theme/white/info.svg", "theme/black/info.svg", "theme/info.svg", "theme/white/nextcloud/*.svg", "theme/black/nextcloud/*.svg", "theme/colored/nextcloud/*.svg", "theme/black/label.svg", "theme/white/label.svg", "theme/colored/user-status-*.svg", "theme/account.svg","theme/add.svg","theme/change.svg","theme/chevron-double-up.svg","theme/close.svg","theme/confirm.svg","theme/copy.svg","theme/delete.svg","theme/external.svg","theme/files.svg","theme/lock-broken.svg","theme/lock-http.svg","theme/lock-https.svg","theme/lock.svg","theme/magnifying-glass.svg","theme/more.svg","theme/network.svg","theme/public.svg","theme/reply.svg","theme/send.svg","theme/settings.svg","theme/share.svg","theme/sync-arrow.svg", "theme/black/account-group.svg","theme/black/clear.svg","theme/black/expand-less-black.svg","theme/black/folder-group.svg","theme/black/search.svg", "theme/colored/add-bordered.svg", "theme/colored/change-bordered.svg", "theme/colored/delete-bordered.svg", "theme/colored/delete.svg", "theme/*/activity.svg", "theme/*/add.svg","theme/*/bell.svg","theme/*/calendar.svg","theme/*/caret-down.svg","theme/*/change.svg","theme/*/close.svg","theme/*/comment.svg","theme/*/confirm.svg","theme/*/control-next.svg","theme/*/control-prev.svg","theme/*/edit.svg","theme/*/email.svg","theme/*/external.png","theme/*/external.svg","theme/*/external@2x.png","theme/*/folder.png","theme/*/folder.svg","theme/*/folder@2x.png","theme/*/more-apps.svg","theme/*/nc-assistant-app.svg","theme/*/settings.svg","theme/*/user.svg","theme/*/wizard-files.png","theme/*/wizard-files.svg","theme/*/wizard-files@2x.png","theme/*/wizard-groupware.png","theme/*/wizard-groupware.svg","theme/*/wizard-groupware@2x.png", "theme/cfapishellext_custom_states/0-locked.svg","theme/cfapishellext_custom_states/1-shared.svg","theme/cfapishellext_custom_states/1024-0-locked.png","theme/cfapishellext_custom_states/1024-1-shared.png","theme/cfapishellext_custom_states/128-0-locked.png","theme/cfapishellext_custom_states/128-1-shared.png","theme/cfapishellext_custom_states/24-0-locked.png","theme/cfapishellext_custom_states/24-1-shared.png","theme/cfapishellext_custom_states/256-0-locked.png","theme/cfapishellext_custom_states/256-1-shared.png","theme/cfapishellext_custom_states/32-0-locked.png","theme/cfapishellext_custom_states/32-1-shared.png","theme/cfapishellext_custom_states/40-0-locked.png","theme/cfapishellext_custom_states/40-1-shared.png","theme/cfapishellext_custom_states/48-0-locked.png","theme/cfapishellext_custom_states/48-1-shared.png","theme/cfapishellext_custom_states/512-0-locked.png","theme/cfapishellext_custom_states/512-1-shared.png","theme/cfapishellext_custom_states/64-0-locked.png","theme/cfapishellext_custom_states/64-1-shared.png"] +path = ["theme/white/error.svg", "theme/black/error.svg","theme/white/warning.svg", "theme/black/warning.svg", "theme/white/info.svg", "theme/black/info.svg", "theme/info.svg", "theme/white/nextcloud/*.svg", "theme/black/nextcloud/*.svg", "theme/colored/nextcloud/*.svg", "theme/black/label.svg", "theme/white/label.svg", "theme/colored/user-status-*.svg", "theme/account.svg","theme/add.svg","theme/change.svg","theme/chevron-double-up.svg","theme/close.svg","theme/confirm.svg","theme/copy.svg","theme/delete.svg","theme/external.svg","theme/files.svg","theme/lock-broken.svg","theme/lock-http.svg","theme/lock-https.svg","theme/lock.svg","theme/magnifying-glass.svg","theme/more.svg","theme/network.svg","theme/public.svg","theme/reply.svg","theme/send.svg","theme/settings.svg","theme/share.svg","theme/sync-arrow.svg", "theme/black/account-group.svg","theme/black/clear.svg","theme/black/expand-less-black.svg","theme/black/folder-group.svg","theme/black/search.svg", "theme/colored/add-bordered.svg", "theme/colored/change-bordered.svg", "theme/colored/delete-bordered.svg", "theme/colored/delete.svg", "theme/*/activity.svg", "theme/*/add.svg","theme/*/bell.svg","theme/*/calendar.svg","theme/*/caret-down.svg","theme/*/change.svg","theme/*/close.svg","theme/*/comment.svg","theme/*/confirm.svg","theme/*/control-next.svg","theme/*/control-prev.svg","theme/*/edit.svg","theme/*/email.svg","theme/*/external.png","theme/*/external.svg","theme/*/external@2x.png","theme/*/folder.png","theme/*/folder.svg","theme/*/folder@2x.png","theme/*/more-apps.svg","theme/*/nc-assistant-app.svg","theme/*/settings.svg","theme/*/user.svg","theme/*/wizard-files.png","theme/*/wizard-files.svg","theme/*/wizard-files@2x.png","theme/*/wizard-groupware.png","theme/*/wizard-groupware.svg","theme/*/wizard-groupware@2x.png", "theme/cfapishellext_custom_states/0-locked.svg","theme/cfapishellext_custom_states/1-shared.svg","theme/cfapishellext_custom_states/1024-0-locked.png","theme/cfapishellext_custom_states/1024-1-shared.png","theme/cfapishellext_custom_states/128-0-locked.png","theme/cfapishellext_custom_states/128-1-shared.png","theme/cfapishellext_custom_states/24-0-locked.png","theme/cfapishellext_custom_states/24-1-shared.png","theme/cfapishellext_custom_states/256-0-locked.png","theme/cfapishellext_custom_states/256-1-shared.png","theme/cfapishellext_custom_states/32-0-locked.png","theme/cfapishellext_custom_states/32-1-shared.png","theme/cfapishellext_custom_states/40-0-locked.png","theme/cfapishellext_custom_states/40-1-shared.png","theme/cfapishellext_custom_states/48-0-locked.png","theme/cfapishellext_custom_states/48-1-shared.png","theme/cfapishellext_custom_states/512-0-locked.png","theme/cfapishellext_custom_states/512-1-shared.png","theme/cfapishellext_custom_states/64-0-locked.png","theme/cfapishellext_custom_states/64-1-shared.png", "theme/backup.svg", "theme/convert_to_text.svg", "theme/file-open.svg"] precedence = "aggregate" SPDX-FileCopyrightText = "2018-2025 Google LLC" SPDX-License-Identifier = "Apache-2.0" diff --git a/resources.qrc b/resources.qrc index 2681104929615..f26f90192acfe 100644 --- a/resources.qrc +++ b/resources.qrc @@ -61,5 +61,6 @@ src/gui/ConflictItemFileInfo.qml src/gui/macOS/ui/FileProviderSettings.qml src/gui/macOS/ui/FileProviderFileDelegate.qml + src/gui/integration/FileActionsWindow.qml diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index b6d636f47ac1c..40f11aeb961e0 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -258,6 +258,8 @@ set(client_SRCS wizard/linklabel.cpp wizard/wizardproxysettingsdialog.h wizard/wizardproxysettingsdialog.cpp + integration/fileactionsmodel.h + integration/fileactionsmodel.cpp ) if (NOT DISABLE_ACCOUNT_MIGRATION) diff --git a/src/gui/application.cpp b/src/gui/application.cpp index ae14bc11a3ff5..db3ad8a3ec064 100644 --- a/src/gui/application.cpp +++ b/src/gui/application.cpp @@ -432,6 +432,9 @@ Application::Application(int &argc, char **argv) connect(FolderMan::instance()->socketApi(), &SocketApi::fileActivityCommandReceived, _gui.data(), &ownCloudGui::slotShowFileActivityDialog); + connect(FolderMan::instance()->socketApi(), &SocketApi::fileActionsCommandReceived, + _gui.data(), &ownCloudGui::slotShowFileActionsDialog); + // startup procedure. connect(&_checkConnectionTimer, &QTimer::timeout, this, &Application::slotCheckConnection); _checkConnectionTimer.setInterval(ConnectionValidator::DefaultCallingIntervalMsec); // check for connection every 32 seconds. diff --git a/src/gui/integration/FileActionsWindow.qml b/src/gui/integration/FileActionsWindow.qml new file mode 100644 index 0000000000000..e526fbff6dc25 --- /dev/null +++ b/src/gui/integration/FileActionsWindow.qml @@ -0,0 +1,261 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Window +import QtQuick.Layouts +import QtQuick.Controls +import Qt5Compat.GraphicalEffects +import com.nextcloud.desktopclient +import Style +import "./../tray" + +ApplicationWindow { + id: root + height: Style.filesActionsHeight + width: Style.filesActionsWidth + flags: Systray.useNormalWindow ? Qt.Window : Qt.Dialog | Qt.FramelessWindowHint + visible: true + color: "transparent" + + property var accountState: ({}) + property string localPath: "" + property string shortLocalPath: "" + + readonly property int windowRadius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius + + title: qsTr("File actions for %1").arg(root.shortLocalPath) + + FileActionsModel { + id: fileActionModel + accountState: root.accountState + localPath: root.localPath + } + + background: Rectangle { + id: maskSource + radius: root.windowRadius + border.width: Style.trayWindowBorderWidth + border.color: palette.dark + color: palette.window + } + + OpacityMask { + anchors.fill: parent + anchors.margins: Style.trayWindowBorderWidth + source: maskSourceItem + maskSource: maskSource + } + + Rectangle { + id: maskSourceItem + anchors.fill: parent + anchors.margins: Style.standardSpacing + radius: root.windowRadius + clip: true + color: Style.colorWithoutTransparency(palette.base) + + ColumnLayout { + id: windowContent + anchors.fill: parent + anchors.margins: Style.standardSpacing + + RowLayout { + id: windowHeader + Layout.fillWidth: true + spacing: Style.standardSpacing + + Image { + source: fileActionModel.fileIcon + palette.windowText + Layout.preferredWidth: Style.minimumActivityItemHeight + Layout.preferredHeight: Style.minimumActivityItemHeight + Layout.alignment: Qt.AlignVCenter + Layout.margins: Style.extraSmallSpacing + } + + EnforcedPlainTextLabel { + id: headerLocalPath + text: root.shortLocalPath + elide: Text.ElideRight + font.bold: true + font.pixelSize: Style.pixelSize + color: palette.text + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + } + + Button { + id: closeButton + flat: true + padding: Style.extraSmallSpacing + spacing: 0 + icon.source: "image://svgimage-custom-color/close.svg/" + palette.windowText + icon.width: Style.extraSmallIconSize + icon.height: Style.extraSmallIconSize + Layout.alignment: Qt.AlignTop | Qt.AlignRight + Layout.rightMargin: Style.extraSmallSpacing + Layout.topMargin: Style.extraSmallSpacing + onClicked: root.close() + background: Rectangle { + color: "transparent" + radius: root.windowRadius + border.width: closeButton.hovered ? Style.trayWindowBorderWidth : 0 + border.color: palette.dark + anchors.fill: parent + Layout.margins: Style.extraSmallSpacing + } + } + } + + Rectangle { + id: lineTop + Layout.fillWidth: true + Layout.minimumHeight: Style.extraExtraSmallSpacing + color: palette.dark + } + + ListView { + id: fileActionsView + model: fileActionModel + clip: true + spacing: Style.trayHorizontalMargin + Layout.fillWidth: true + Layout.fillHeight: true + delegate: fileActionsDelegate + } + + Button { + id: responseButton + visible: responseText.text !== "" + flat: true + Layout.fillWidth: true + implicitHeight: responseContent.implicitHeight + + padding: Style.standardSpacing + leftPadding: Style.standardSpacing + rightPadding: Style.standardSpacing + spacing: Style.standardSpacing + + background: Rectangle { + id: responseBorder + radius: root.windowRadius + border.width: Style.trayWindowBorderWidth + border.color: palette.dark + color: palette.window + Layout.fillWidth: true + } + + contentItem: RowLayout { + id: responseContent + anchors.fill: parent + anchors.margins: Style.smallSpacing + spacing: Style.standardSpacing + Layout.fillWidth: true + Layout.minimumHeight: Style.accountAvatarStateIndicatorSize + + Image { + source: "image://svgimage-custom-color/backup.svg/" + palette.windowText + Layout.preferredWidth: Style.minimumActivityItemHeight + Layout.preferredHeight: Style.minimumActivityItemHeight + Layout.alignment: Qt.AlignLeft + Layout.bottomMargin: Style.standardSpacing + } + + Text { + id: responseText + text: fileActionModel.responseLabel + textFormat: Text.RichText + color: palette.text + font.pointSize: Style.pixelSize + font.underline: true + wrapMode: Text.WordWrap + Layout.fillWidth: true + bottomPadding: Style.standardSpacing + } + } + + MouseArea { + id: responseArea + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Qt.openUrlExternally(fileActionModel.responseUrl) + } + } + } + } + + Component { + id: fileActionsDelegate + + RowLayout { + id: fileAction + Layout.fillWidth: true + height: implicitHeight + width: parent.width + + required property string name + required property int index + required property string icon + + Button { + id: fileActionButton + flat: true + Layout.fillWidth: true + implicitHeight: Style.activityListButtonHeight + + padding: Style.standardSpacing + + contentItem: Row { + id: fileActionsContent + anchors.fill: parent + anchors.topMargin: Style.standardSpacing + anchors.rightMargin: Style.standardSpacing + anchors.bottomMargin: Style.standardSpacing + anchors.leftMargin: Style.extraSmallSpacing + spacing: Style.standardSpacing + Layout.fillWidth: true + + Image { + source: fileAction.icon + palette.windowText + width: Style.minimumActivityItemHeight + height: Style.minimumActivityItemHeight + fillMode: Image.PreserveAspectFit + Layout.preferredWidth: Style.minimumActivityItemHeight + Layout.preferredHeight: Style.minimumActivityItemHeight + anchors.verticalCenter: parent.verticalCenter + } + + EnforcedPlainTextLabel { + text: fileAction.name + color: palette.text + font.pixelSize: Style.defaultFontPtSize + verticalAlignment: Text.AlignVCenter + anchors.verticalCenter: parent.verticalCenter + } + } + + background: Rectangle { + color: "transparent" + radius: root.windowRadius + border.width: fileActionButton.hovered ? Style.trayWindowBorderWidth : 0 + border.color: palette.dark + anchors.margins: Style.standardSpacing + height: parent.height + width: parent.width + } + + MouseArea { + id: fileActionMouseArea + anchors.fill: parent + anchors.margins: Style.standardSpacing + cursorShape: Qt.PointingHandCursor + onClicked: fileActionModel.createRequest(fileAction.index) + } + } + } + } +} diff --git a/src/gui/integration/fileactionsmodel.cpp b/src/gui/integration/fileactionsmodel.cpp new file mode 100644 index 0000000000000..1d8d913ca0c2e --- /dev/null +++ b/src/gui/integration/fileactionsmodel.cpp @@ -0,0 +1,324 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "fileactionsmodel.h" +#include "networkjobs.h" +#include "account.h" +#include "folderman.h" +#include "common/utility.h" +#include "tray/activitydata.h" + +namespace OCC { + +Q_LOGGING_CATEGORY(lcFileActions, "nextcloud.gui.fileactions", QtInfoMsg) + +FileActionsModel::FileActionsModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +QVariant FileActionsModel::data(const QModelIndex &index, int role) const +{ + Q_ASSERT(checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)); + const auto row = index.row(); + switch (role) { + case FileActionIconRole: + return _fileActions.at(row).icon; // deck.svg + case FileActionNameRole: + return _fileActions.at(row).name; // Convert file + case FileActionUrlRole: + return _fileActions.at(row).url; // /ocs/v2.php/apps/declarativetest/newDeckBoard + case FileActionMethodRole: + return _fileActions.at(row).method; // GET + case FileActionParamsRole: + return QVariant::fromValue(_fileActions.at(row).params); + case FileActionResponseLabelRole: + return _response.label; + case FileActionResponseUrlRole: + return _response.url; + default: + return QVariant(); + } + + return {}; +} + +int FileActionsModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + + return _fileActions.size(); +} + +QHash FileActionsModel::roleNames() const +{ + auto roles = QAbstractListModel::roleNames(); + roles[FileActionIconRole] = "icon"; + roles[FileActionNameRole] = "name"; + roles[FileActionUrlRole] = "url"; + roles[FileActionMethodRole] = "method"; + roles[FileActionParamsRole] = "params"; + roles[FileActionResponseLabelRole] = "responseLabel"; + roles[FileActionResponseUrlRole] = "responseUrl"; + + return roles; +} + +AccountState *FileActionsModel::accountState() const +{ + return _accountState; +} + +void FileActionsModel::setAccountState(AccountState *accountState) +{ + if (accountState == nullptr) { + return; + } + + if (accountState == _accountState) { + return; + } + + _accountState = accountState; + _accountUrl = _accountState->account()->url().toString(); + Q_EMIT accountStateChanged(); +} + +QString FileActionsModel::localPath() const +{ + return _localPath; +} + +void FileActionsModel::setLocalPath(const QString &localPath) +{ + if (localPath.isEmpty()) { + return; + } + + if (localPath == _localPath) { + return; + } + + _localPath = localPath; + + setupFileProperties(); + parseEndpoints(); + + Q_EMIT fileChanged(); +} + +QByteArray FileActionsModel::fileId() const +{ + return _fileId; +} + +void FileActionsModel::setupFileProperties() +{ + const auto folderForPath = FolderMan::instance()->folderForPath(_localPath); + _filePath = _localPath.mid(folderForPath->cleanPath().length() + 1); + SyncJournalFileRecord fileRecord; + if (!folderForPath->journalDb()->getFileRecord(_filePath, &fileRecord)) { + qCWarning(lcFileActions) << "Invalid file record for path:" << _localPath; + return; + } + + _fileId = fileRecord._fileId; + + const auto mimeMatchMode = fileRecord.isVirtualFile() ? QMimeDatabase::MatchExtension + : QMimeDatabase::MatchDefault; + QMimeDatabase mimeDb; + const auto mimeType = mimeDb.mimeTypeForFile(_localPath, mimeMatchMode); + _mimeType = mimeType; + + _fileIcon = _accountUrl + Activity::relativeServerFileTypeIconPath(_mimeType); +} + +QMimeType FileActionsModel::mimeType() const +{ + return _mimeType; +} + +QString FileActionsModel::fileIcon() const +{ + return _fileIcon; +} + +QString FileActionsModel::responseLabel() const +{ + return _response.label; +} + +void FileActionsModel::setResponseLabel(const QString &label) +{ + _response.label = label; +} + +QString FileActionsModel::responseUrl() const +{ + return _response.url; +} + +void FileActionsModel::setResponseUrl(const QString &url) +{ + _response.url = url; +} + +void FileActionsModel::setResponse(const Response &response) +{ + _response = response; + Q_EMIT responseChanged(); +} + +void FileActionsModel::parseEndpoints() +{ + if (!_accountState->isConnected()) { + qCWarning(lcFileActions) << "The account is not connected" + << _accountUrl; + setResponse({ tr("Your account is offline %1.", "account url").arg(_accountUrl), + _accountUrl }); + return; + } + + if (_fileId.isEmpty()) { + qCWarning(lcFileActions) << "The file id is empty, not initialized" + << _localPath; + setResponse({ tr("The file id is empty for %1.", "file name").arg(_localPath), + _accountUrl }); + return; + } + + if (!_mimeType.isValid()) { + qCWarning(lcFileActions) << "The mime type found for the file is not valid" + << _localPath; + setResponse({ tr("The file type for %1 is not valid.", "file name").arg(_localPath), + _accountUrl }); + return; + } + + const auto contextMenuList = _accountState->account()->capabilities().fileActionsByMimeType(_mimeType); + if (contextMenuList.isEmpty()) { + qCWarning(lcFileActions) << "contextMenuByMimeType is empty, nothing was returned by capabilities" + << _localPath; + setResponse({ tr("No file actions were returned by the server for %1 files.", "file mymetype") + .arg(_mimeType.filterString()), + _accountUrl }); + return; + } + + for (const auto &contextMenu : contextMenuList) { + QueryList queryList; + const auto paramsMap = contextMenu.value("params").toMap(); + for (auto param = paramsMap.cbegin(), end = paramsMap.cend(); param != end; ++param) { + const auto name = param.key(); + QByteArray value; + if (name == fileIdC) { + value = _fileId; + } + + if (param.key() == filePathC) { + value = _filePath.toUtf8(); + } + + if (!value.isEmpty()) { + queryList.append( QueryItem{ name, value } ); + } + } + + _fileActions.append({ parseIcon(contextMenu.value("icon").toString()), + contextMenu.value("name").toString(), + contextMenu.value("url").toString(), + contextMenu.value("method").toString(), + queryList }); + } + + qCDebug(lcFileActions) << "File" << _localPath << "has" + << _fileActions.size() + << "actions available."; + Q_EMIT fileActionModelChanged(); +} + +QString FileActionsModel::parseUrl(const QString &url) const +{ + auto parsedUrl = url; + parsedUrl.replace(fileIdUrlC, _fileId); + return parsedUrl; +} + +QString FileActionsModel::parseIcon(const QString &icon) const +{ + if (icon.isEmpty()) { + return QStringLiteral("image://svgimage-custom-color/convert_to_text.svg/"); + } + + return _accountUrl + icon; +} + +void FileActionsModel::createRequest(const int row) +{ + if (!_accountState) { + qCWarning(lcFileActions) << "No account state for" + << _localPath; + return; + } + + const auto requesturl = parseUrl(_fileActions.at(row).url); + auto job = new JsonApiJob(_accountState->account(), + requesturl, + this); + connect(job, &JsonApiJob::jsonReceived, + this, &FileActionsModel::processRequest); + for (const auto ¶m : std::as_const(_fileActions.at(row).params)) { + QUrlQuery query; + query.addQueryItem(param.name, param.value); + job->addQueryParams(query); + } + const auto verb = job->stringToVerb(_fileActions.at(row).method); + job->setVerb(verb); + job->setProperty(rowC, row); + job->start(); +} + +void FileActionsModel::processRequest(const QJsonDocument &json, int statusCode) +{ + const auto row = sender()->property(rowC).toInt(); + const auto fileAction = _fileActions.at(row).name; + const auto errorMessage = tr("%1 did not succeed, please try again later. " + "If you need help, contact your server administrator.", + "file action error message").arg(fileAction); + if (statusCode != 200) { + qCWarning(lcFileActions) << "File action did not succeed for" << _localPath; + setResponse({ errorMessage, _accountUrl }); + return; + } + + const auto root = json.object().value(QStringLiteral("root")).toObject(); + const auto folderForPath = FolderMan::instance()->folderForPath(_localPath); + const auto remoteFolderPath = _accountUrl + folderForPath->remotePath(); + const auto successMessage = tr("%1 done.", "file action success message").arg(fileAction); + if (root.empty()) { + setResponse({ successMessage, remoteFolderPath }); + return; + } + + const auto rows = root.value(QStringLiteral("rows")).toArray(); + if (rows.empty()) { + setResponse({ successMessage, remoteFolderPath }); + return; + } + + for (const auto &rowValue : rows) { + const auto row = rowValue.toObject(); + const auto children = row.value("children").toArray(); + for (const auto &childValue : children) { + const auto child = childValue.toObject(); + setResponse({ child.value(QStringLiteral("element")).toString(), + _accountUrl + child.value(QStringLiteral("url")).toString() }); + } + } +} + +} // namespace OCC diff --git a/src/gui/integration/fileactionsmodel.h b/src/gui/integration/fileactionsmodel.h new file mode 100644 index 0000000000000..222478a62479d --- /dev/null +++ b/src/gui/integration/fileactionsmodel.h @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include +#include + +#include "accountstate.h" + +namespace OCC { + +Q_DECLARE_LOGGING_CATEGORY(lcFileActions) + +class FileActionsModel : public QAbstractListModel { + Q_OBJECT + + Q_PROPERTY(AccountState* accountState READ accountState WRITE setAccountState NOTIFY accountStateChanged) + Q_PROPERTY(QString localPath READ localPath WRITE setLocalPath NOTIFY fileChanged) + Q_PROPERTY(QString fileIcon READ fileIcon NOTIFY fileChanged) + Q_PROPERTY(QString responseLabel READ responseLabel WRITE setResponseLabel NOTIFY responseChanged) + Q_PROPERTY(QString responseUrl READ responseUrl WRITE setResponseUrl NOTIFY responseChanged) + +public: + explicit FileActionsModel(QObject *const parent = nullptr); + [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] QHash roleNames() const override; + + enum DataRole { + FileActionIconRole = Qt::UserRole + 1, + FileActionNameRole, + FileActionUrlRole, + FileActionMethodRole, + FileActionParamsRole, + FileActionResponseLabelRole, + FileActionResponseUrlRole + }; + Q_ENUM(DataRole) + + struct Response { + QString label; + QString url; + }; + + struct QueryItem { + QString name; + QByteArray value; + }; + using QueryList = QList; + + struct FileAction { + QString icon; + QString name; + QString url; + QString method; + QueryList params; + }; + using ActionList = QList; + + [[nodiscard]] AccountState *accountState() const; + void setAccountState(AccountState *accountState); + + [[nodiscard]] QString localPath() const; + void setLocalPath(const QString &localPath); + + [[nodiscard]] QByteArray fileId() const; + [[nodiscard]] QMimeType mimeType() const; + [[nodiscard]] QString fileIcon() const; + void setupFileProperties(); + + [[nodiscard]] QString responseLabel() const; + void setResponseLabel(const QString &label); + + [[nodiscard]] QString responseUrl() const; + void setResponseUrl(const QString &url); + + void setResponse(const Response &response); + + void parseEndpoints(); + [[nodiscard]] QString parseUrl(const QString &url) const; + [[nodiscard]] QString parseIcon(const QString &icon) const; + +signals: + void accountStateChanged(); + void fileChanged(); + void responseChanged(); + void fileActionModelChanged(); + +public slots: + void createRequest(const int row); + void processRequest(const QJsonDocument &json, int statusCode); + +private: + Response _response; + ActionList _fileActions; + AccountState *_accountState; + QString _localPath; + QByteArray _fileId; + QMimeType _mimeType; + QString _filePath; + QString _accountUrl; + QString _fileIcon; + + static constexpr char fileIdUrlC[] = "{fileId}"; + static constexpr char fileIdC[] = "fileId"; + static constexpr char filePathC[] = "filePath"; + static constexpr char rowC[] = "row"; +}; +} + +Q_DECLARE_METATYPE(OCC::FileActionsModel::QueryList) +Q_DECLARE_METATYPE(OCC::FileActionsModel::ActionList) diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index 68432164f2b1a..1fe337b4433d4 100644 --- a/src/gui/owncloudgui.cpp +++ b/src/gui/owncloudgui.cpp @@ -33,6 +33,7 @@ #include "tray/sortedactivitylistmodel.h" #include "tray/syncstatussummary.h" #include "tray/unifiedsearchresultslistmodel.h" +#include "integration/fileactionsmodel.h" #include "filesystem.h" #ifdef WITH_LIBCLOUDPROVIDERS @@ -135,6 +136,7 @@ ownCloudGui::ownCloudGui(Application *parent) qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "ShareeModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "SortedShareModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "SyncConflictsModel"); + qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "FileActionsModel"); qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "QAbstractItemModel", "QAbstractItemModel"); qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "activity", "Activity"); @@ -716,4 +718,9 @@ void ownCloudGui::slotShowFileActivityDialog(const QString &localPath) const _tray->createFileActivityDialog(localPath); } +void ownCloudGui::slotShowFileActionsDialog(const QString &localPath) const +{ + _tray->showFileActionsDialog(localPath); +} + } // end namespace diff --git a/src/gui/owncloudgui.h b/src/gui/owncloudgui.h index 40006a905c190..187fbbee0271e 100644 --- a/src/gui/owncloudgui.h +++ b/src/gui/owncloudgui.h @@ -95,6 +95,7 @@ public slots: */ void slotShowShareDialog(const QString &localPath) const; void slotShowFileActivityDialog(const QString &localPath) const; + void slotShowFileActionsDialog(const QString &localPath) const; void slotNewAccountWizard(); private slots: diff --git a/src/gui/socketapi/socketapi.cpp b/src/gui/socketapi/socketapi.cpp index 58efdddaa7c69..c2d2120944b07 100644 --- a/src/gui/socketapi/socketapi.cpp +++ b/src/gui/socketapi/socketapi.cpp @@ -615,6 +615,12 @@ void SocketApi::processLeaveShareRequest(const QString &localFile, SocketListene FolderMan::instance()->leaveShare(QDir::fromNativeSeparators(localFile)); } +void SocketApi::processFileActionsRequest(const QString &localFile) +{ + const auto fileData = FileData::get(localFile); + emit fileActionsCommandReceived(fileData.localPath); +} + void SocketApi::broadcastStatusPushMessage(const QString &systemPath, SyncFileStatus fileStatus) { QString msg = buildMessage(QLatin1String("STATUS"), systemPath, fileStatus.toSocketAPIString()); @@ -723,6 +729,13 @@ void SocketApi::command_EDIT(const QString &localFile, SocketListener *listener) job->start(); } +void SocketApi::command_FILE_ACTIONS(const QString &localFile, SocketListener *listener) +{ + Q_UNUSED(listener); + + processFileActionsRequest(localFile); +} + // don't pull the share manager into socketapi unittests #ifndef OWNCLOUD_TEST @@ -1114,8 +1127,9 @@ void OCC::SocketApi::openPrivateLink(const QString &link) void SocketApi::command_GET_STRINGS(const QString &argument, SocketListener *listener) { - static std::array, 6> strings { { + static std::array, 7> strings { { { "SHARE_MENU_TITLE", tr("Share options") }, + { "FILE_ACTIONS_MENU_TITLE", tr("File actions") }, { "FILE_ACTIVITY_MENU_TITLE", tr("Activity") }, { "CONTEXT_MENU_TITLE", Theme::instance()->appNameGUI() }, { "COPY_PRIVATE_LINK_MENU_TITLE", tr("Copy private link to clipboard") }, @@ -1164,6 +1178,23 @@ void SocketApi::sendSharingContextMenuOptions(const FileData &fileData, SocketLi //listener->sendMessage(QLatin1String("MENU_ITEM:EMAIL_PRIVATE_LINK") + flagString + tr("Send private link by email …")); } +void SocketApi::sendFileActionsContextMenuOptions(const FileData &fileData, SocketListener *listener) +{ + const auto record = fileData.journalRecord(); + const auto isOnTheServer = record.isValid(); + auto serverHasIntegration = false; + if (const auto folder = fileData.folder;folder) { + if (const auto accountState = folder->accountState(); + accountState && accountState->account()) { + serverHasIntegration = accountState->account()->serverHasIntegration(); + } + } + + const auto flagString = isOnTheServer && serverHasIntegration ? QLatin1String("::") + : QLatin1String(":d:"); + listener->sendMessage(QLatin1String("MENU_ITEM:FILE_ACTIONS") + flagString + tr("File actions")); +} + void SocketApi::sendEncryptFolderCommandMenuEntries(const QFileInfo &fileInfo, const FileData &fileData, const bool isE2eEncryptedPath, @@ -1348,6 +1379,7 @@ void SocketApi::command_GET_MENU_ITEMS(const QString &argument, OCC::SocketListe const auto itemEncryptionFlag = isE2eEncryptedPath ? SharingContextItemEncryptedFlag::EncryptedItem : SharingContextItemEncryptedFlag::NotEncryptedItem; const auto rootE2eeFolderFlag = isE2eEncryptedRootFolder ? SharingContextItemRootEncryptedFolderFlag::RootEncryptedFolder : SharingContextItemRootEncryptedFolderFlag::NonRootEncryptedFolder; sendSharingContextMenuOptions(fileData, listener, itemEncryptionFlag, rootE2eeFolderFlag); + sendFileActionsContextMenuOptions(fileData, listener); // Conflict files get conflict resolution actions bool isConflict = Utility::isConflictFile(fileData.folderRelativePath); diff --git a/src/gui/socketapi/socketapi.h b/src/gui/socketapi/socketapi.h index 5dcf6900e8c04..98c86b659abdc 100644 --- a/src/gui/socketapi/socketapi.h +++ b/src/gui/socketapi/socketapi.h @@ -64,6 +64,7 @@ public slots: signals: void shareCommandReceived(const QString &localPath); void fileActivityCommandReceived(const QString &localPath); + void fileActionsCommandReceived(const QString &localPath); private slots: void slotNewConnection(); @@ -107,6 +108,7 @@ private slots: void processLeaveShareRequest(const QString &localFile, SocketListener *listener); void processFileActivityRequest(const QString &localFile); void processEncryptRequest(const QString &localFile); + void processFileActionsRequest(const QString &localFile); Q_INVOKABLE void command_RETRIEVE_FOLDER_STATUS(const QString &argument, OCC::SocketListener *listener); Q_INVOKABLE void command_RETRIEVE_FILE_STATUS(const QString &argument, OCC::SocketListener *listener); @@ -131,6 +133,7 @@ private slots: Q_INVOKABLE void command_MOVE_ITEM(const QString &localFile, OCC::SocketListener *listener); Q_INVOKABLE void command_LOCK_FILE(const QString &localFile, OCC::SocketListener *listener); Q_INVOKABLE void command_UNLOCK_FILE(const QString &localFile, OCC::SocketListener *listener); + Q_INVOKABLE void command_FILE_ACTIONS(const QString &localFile, OCC::SocketListener *listener); void setFileLock(const QString &localFile, const SyncFileItem::LockStatus lockState) const; @@ -149,7 +152,7 @@ private slots: // Sends the context menu options relating to sharing to listener void sendSharingContextMenuOptions(const FileData &fileData, SocketListener *listener, SharingContextItemEncryptedFlag itemEncryptionFlag, SharingContextItemRootEncryptedFolderFlag rootE2eeFolderFlag); - + void sendFileActionsContextMenuOptions(const FileData &fileData, SocketListener *listener); void sendEncryptFolderCommandMenuEntries(const QFileInfo &fileInfo, const FileData &fileData, const bool isE2eEncryptedPath, diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index e9fc7564b75b4..39ba5a42b66e4 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -436,6 +436,51 @@ void Systray::createFileActivityDialog(const QString &localPath) Q_EMIT showFileDetailsPage(localPath, FileDetailsPage::Activity); } +void Systray::showFileActionsDialog(const QString &localPath) +{ + createFileActionsDialog(localPath); +} + +void Systray::createFileActionsDialog(const QString &localPath) +{ + if (!_trayEngine) { + qCWarning(lcSystray) << "Could not open file actions dialog for" << localPath << "as no tray engine was available"; + return; + } + + const auto folder = FolderMan::instance()->folderForPath(localPath); + if (!folder) { + qCWarning(lcSystray) << "Could not open file actions dialog for" << localPath << "no responsible folder found"; + return; + } + + QQmlComponent fileActionsQml(trayEngine(), QStringLiteral("qrc:/qml/src/gui/integration/FileActionsWindow.qml")); + if (fileActionsQml.isError()) { + qCWarning(lcSystray) << fileActionsQml.errorString(); + qCWarning(lcSystray) << fileActionsQml.errors(); + return; + } + + QFileInfo localFile{localPath}; + const auto shortLocalPath = localFile.fileName(); + const QVariantMap initialProperties{ + {"accountState", QVariant::fromValue(folder->accountState())}, + {"shortLocalPath", shortLocalPath}, + {"localPath", localPath}, + }; + + const auto fileActionsDialog = fileActionsQml.createWithInitialProperties(initialProperties); + const auto dialog = qobject_cast(fileActionsDialog); + if (!dialog) { + qCWarning(lcSystray) << "File Actions dialog window resulted in creation of object that was not a window!"; + return; + } + + dialog->show(); + dialog->raise(); + dialog->requestActivate(); +} + void Systray::presentShareViewInTray(const QString &localPath) { const auto folder = FolderMan::instance()->folderForPath(localPath); @@ -448,6 +493,12 @@ void Systray::presentShareViewInTray(const QString &localPath) Q_EMIT showFileDetails(folder->accountState(), localPath, FileDetailsPage::Sharing); } +void Systray::presentFileActionsViewInSystray(const QString &localPath) +{ + qCDebug(lcSystray) << "Opening file actions view in tray for " << localPath; + createFileActionsDialog(localPath); +} + void Systray::slotCurrentUserChanged() { if (_trayEngine) { diff --git a/src/gui/systray.h b/src/gui/systray.h index 87f4a6b196ccd..4cc3e4b0a2b65 100644 --- a/src/gui/systray.h +++ b/src/gui/systray.h @@ -146,8 +146,10 @@ public slots: void createShareDialog(const QString &localPath); void createFileActivityDialog(const QString &localPath); + void showFileActionsDialog(const QString &localPath); void presentShareViewInTray(const QString &localPath); + void presentFileActionsViewInSystray(const QString &localPath); private slots: void slotUpdateSyncPausedState(); @@ -165,6 +167,7 @@ private slots: void setupContextMenu(); void createFileDetailsDialog(const QString &localPath); + void createFileActionsDialog(const QString &localPath); [[nodiscard]] QScreen *currentScreen() const; [[nodiscard]] QRect currentScreenRect() const; diff --git a/src/gui/tray/ActivityItemContent.qml b/src/gui/tray/ActivityItemContent.qml index 2260ed8e6e296..f1b09c765e0ef 100644 --- a/src/gui/tray/ActivityItemContent.qml +++ b/src/gui/tray/ActivityItemContent.qml @@ -185,7 +185,29 @@ RowLayout { display: Button.IconOnly visible: model.showFileDetails - onClicked: Systray.presentShareViewInTray(model.openablePath) + onClicked: fileMoreButtonMenu.visible ? fileMoreButtonMenu.close() : fileMoreButtonMenu.popup() + + AutoSizingMenu { + id: fileMoreButtonMenu + closePolicy: Menu.CloseOnPressOutsideParent | Menu.CloseOnEscape + + MenuItem { + height: visible ? implicitHeight : 0 + text: qsTr("File details") + font.pixelSize: Style.topLinePixelSize + hoverEnabled: true + onClicked: Systray.presentShareViewInTray(model.openablePath) + } + + MenuItem { + visible: model.serverHasIntegration + height: visible ? implicitHeight : 0 + text: qsTr("File actions") + font.pixelSize: Style.topLinePixelSize + hoverEnabled: true + onClicked: Systray.presentFileActionsViewInSystray(model.openablePath) + } + } } Button { diff --git a/src/gui/tray/activitydata.cpp b/src/gui/tray/activitydata.cpp index c17b8db4ab75f..aeaad62a3a08b 100644 --- a/src/gui/tray/activitydata.cpp +++ b/src/gui/tray/activitydata.cpp @@ -177,50 +177,75 @@ OCC::Activity Activity::fromActivityJson(const QJsonObject &json, const AccountP QString Activity::relativeServerFileTypeIconPath(const QMimeType &mimeType) { - if(mimeType.isValid() && mimeType.inherits("text/plain")) { - return QStringLiteral("/index.php/apps/theming/img/core/filetypes/text.svg"); - } else if (mimeType.isValid() && mimeType.name().startsWith("image")) { - return QStringLiteral("/index.php/apps/theming/img/core/filetypes/image.svg"); - } else if (mimeType.isValid() && mimeType.name().startsWith("audio")) { - return QStringLiteral("/index.php/apps/theming/img/core/filetypes/audio.svg"); - } else if (mimeType.isValid() && mimeType.name().startsWith("video")) { - return QStringLiteral("/index.php/apps/theming/img/core/filetypes/video.svg"); - } else if (mimeType.isValid() && (mimeType.inherits("application/vnd.oasis.opendocument.text") || - mimeType.inherits("application/msword") || - mimeType.inherits("application/vnd.openxmlformats-officedocument.wordprocessingml.document") || - mimeType.inherits("application/vnd.openxmlformats-officedocument.wordprocessingml.template")|| - mimeType.inherits("application/vnd.ms-word.document.macroEnabled.12") || - mimeType.inherits("application/vnd.ms-word.template.macroEnabled.12") || - mimeType.inherits("application/vnd.apple.pages"))) { - return QStringLiteral("/index.php/apps/theming/img/core/filetypes/x-office-document.svg"); - } else if (mimeType.isValid() && mimeType.inherits("application/vnd.oasis.opendocument.graphics")) { - return QStringLiteral("/index.php/apps/theming/img/core/filetypes/x-office-drawing.svg"); - } else if (mimeType.isValid() && (mimeType.inherits("application/vnd.oasis.opendocument.presentation") || - mimeType.inherits("application/vnd.ms-powerpoint") || - mimeType.inherits("application/vnd.openxmlformats-officedocument.presentationml.presentation") || - mimeType.inherits("application/vnd.openxmlformats-officedocument.presentationml.template") || - mimeType.inherits("application/vnd.openxmlformats-officedocument.presentationml.slideshow") || - mimeType.inherits("application/vnd.ms-powerpoint.addin.macroEnabled.12") || - mimeType.inherits("application/vnd.ms-powerpoint.presentation.macroEnabled.12") || - mimeType.inherits("application/vnd.ms-powerpoint.template.macroEnabled.12") || - mimeType.inherits("application/vnd.ms-powerpoint.slideshow.macroEnabled.12") || - mimeType.inherits("application/vnd.apple.keynote"))) { - return QStringLiteral("/index.php/apps/theming/img/core/filetypes/x-office-presentation.svg"); - } else if (mimeType.isValid() && (mimeType.inherits("application/vnd.oasis.opendocument.spreadsheet") || - mimeType.inherits("application/vnd.ms-excel") || - mimeType.inherits("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") || - mimeType.inherits("application/vnd.openxmlformats-officedocument.spreadsheetml.template") || - mimeType.inherits("application/vnd.ms-excel.sheet.macroEnabled.12") || - mimeType.inherits("application/vnd.ms-excel.template.macroEnabled.12") || - mimeType.inherits("application/vnd.ms-excel.addin.macroEnabled.12") || - mimeType.inherits("application/vnd.ms-excel.sheet.binary.macroEnabled.12") || - mimeType.inherits("application/vnd.apple.numbers"))) { - return QStringLiteral("/index.php/apps/theming/img/core/filetypes/x-office-document.svg"); - } else if (mimeType.isValid() && mimeType.inherits("application/pdf")) { - return QStringLiteral("/index.php/apps/theming/img/core/filetypes/application-pdf.svg"); - } else { - return QStringLiteral("/index.php/apps/theming/img/core/filetypes/file.svg"); + const auto iconPath = QStringLiteral("/index.php/apps/theming/img/core/filetypes/"); + const auto defaultIcon = iconPath + QStringLiteral("file.svg"); + if (!mimeType.isValid()) { + return defaultIcon; } + + if (mimeType.inherits("text/plain")) { + return iconPath + QStringLiteral("text.svg"); + } + + if (mimeType.name().startsWith("image")) { + return iconPath + QStringLiteral("image.svg"); + } + + if (mimeType.name().startsWith("audio")) { + return iconPath + QStringLiteral("audio.svg"); + } + + if (mimeType.name().startsWith("video")) { + return iconPath + QStringLiteral("video.svg"); + } + + const auto isDocument = mimeType.inherits("application/vnd.oasis.opendocument.text") || + mimeType.inherits("application/msword") || + mimeType.inherits("application/vnd.openxmlformats-officedocument.wordprocessingml.document") || + mimeType.inherits("application/vnd.openxmlformats-officedocument.wordprocessingml.template") || + mimeType.inherits("application/vnd.ms-word.document.macroEnabled.12")|| + mimeType.inherits("application/vnd.ms-word.template.macroEnabled.12") || + mimeType.inherits("application/vnd.apple.pages"); + if (isDocument) { + return iconPath + QStringLiteral("x-office-document.svg"); + } + + if (mimeType.inherits("application/vnd.oasis.opendocument.graphics")) { + return iconPath + QStringLiteral("x-office-drawing.svg"); + } + + const auto isPresentation = mimeType.inherits("application/vnd.oasis.opendocument.presentation") || + mimeType.inherits("application/vnd.ms-powerpoint") || + mimeType.inherits("application/vnd.openxmlformats-officedocument.presentationml.presentation") || + mimeType.inherits("application/vnd.openxmlformats-officedocument.presentationml.template") || + mimeType.inherits("application/vnd.openxmlformats-officedocument.presentationml.slideshow") || + mimeType.inherits("application/vnd.ms-powerpoint.addin.macroEnabled.12") || + mimeType.inherits("application/vnd.ms-powerpoint.presentation.macroEnabled.12") || + mimeType.inherits("application/vnd.ms-powerpoint.template.macroEnabled.12") || + mimeType.inherits("application/vnd.ms-powerpoint.slideshow.macroEnabled.12") || + mimeType.inherits("application/vnd.apple.keynote"); + if (isPresentation) { + return iconPath + QStringLiteral("x-office-presentation.svg"); + } + + const auto isSpreadsheet = mimeType.inherits("application/vnd.oasis.opendocument.spreadsheet") || + mimeType.inherits("application/vnd.ms-excel") || + mimeType.inherits("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") || + mimeType.inherits("application/vnd.openxmlformats-officedocument.spreadsheetml.template") || + mimeType.inherits("application/vnd.ms-excel.sheet.macroEnabled.12") || + mimeType.inherits("application/vnd.ms-excel.template.macroEnabled.12") || + mimeType.inherits("application/vnd.ms-excel.addin.macroEnabled.12") || + mimeType.inherits("application/vnd.ms-excel.sheet.binary.macroEnabled.12") || + mimeType.inherits("application/vnd.apple.numbers"); + if (isSpreadsheet) { + return iconPath + QStringLiteral("x-office-document.svg"); + } + + if (mimeType.inherits("application/pdf")) { + return iconPath + QStringLiteral("application-pdf.svg"); + } + + return defaultIcon; } } diff --git a/src/gui/tray/activitylistmodel.cpp b/src/gui/tray/activitylistmodel.cpp index 45f319b5e1931..20ece0431aeb6 100644 --- a/src/gui/tray/activitylistmodel.cpp +++ b/src/gui/tray/activitylistmodel.cpp @@ -83,6 +83,7 @@ QHash ActivityListModel::roleNames() const roles[TalkNotificationUserAvatarRole] = "userAvatar"; roles[ActivityIndexRole] = "activityIndex"; roles[ActivityRole] = "activity"; + roles[ActivityIntegrationRole] = "serverHasIntegration"; return roles; } @@ -140,19 +141,21 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const { Q_ASSERT(checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid | QAbstractItemModel::CheckIndexOption::ParentIsInvalid)); - const auto a = _finalList.at(index.row()); - AccountStatePtr ast = AccountManager::instance()->account(a._accName); - if (!ast && _accountState != ast.data()) + const auto activity = _finalList.at(index.row()); + const auto accountState = AccountManager::instance()->account(activity._accName); + if (!accountState && _accountState != accountState.data()) { return QVariant(); - + } const auto getFilePath = [&]() { - const auto fileName = a._fileAction == QStringLiteral("file_renamed") ? a._renamedFile : a._file; + const auto fileName = activity._fileAction == QStringLiteral("file_renamed") ? activity._renamedFile + : activity._file; if (!fileName.isEmpty()) { - const auto folder = FolderMan::instance()->folder(a._folder); + const auto folder = FolderMan::instance()->folder(activity._folder); - const QString relPath = folder ? folder->remotePathTrailingSlash() + fileName : fileName; + const QString relPath = folder ? folder->remotePathTrailingSlash() + fileName + : fileName; - const auto localFiles = FolderMan::instance()->findFileInLocalFolders(relPath, ast->account()); + const auto localFiles = FolderMan::instance()->findFileInLocalFolders(relPath, accountState->account()); if (localFiles.isEmpty()) { return QString(); @@ -171,16 +174,17 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const return localFiles.constFirst(); } + return QString(); }; - const auto getDisplayPath = [&a, &ast]() { - if (!a._file.isEmpty()) { - const auto folder = FolderMan::instance()->folder(a._folder); + const auto getDisplayPath = [&activity, &accountState]() { + if (!activity._file.isEmpty()) { + const auto folder = FolderMan::instance()->folder(activity._folder); - QString relPath = folder ? folder->remotePathTrailingSlash() + a._file : a._file; + QString relPath = folder ? folder->remotePathTrailingSlash() + activity._file : activity._file; - const auto localFiles = FolderMan::instance()->findFileInLocalFolders(relPath, ast->account()); + const auto localFiles = FolderMan::instance()->findFileInLocalFolders(relPath, accountState->account()); if (localFiles.count() > 0) { if (relPath.startsWith('/') || relPath.startsWith('\\')) { @@ -221,44 +225,47 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const const auto generateIconPath = [&]() { auto colorIconPath = QStringLiteral("image://svgimage-custom-color/%1"); - if (a._type == Activity::NotificationType && !a._talkNotificationData.userAvatar.isEmpty()) { + if (activity._type == Activity::NotificationType && !activity._talkNotificationData.userAvatar.isEmpty()) { return QStringLiteral("image://svgimage-custom-color/talk-bordered.svg"); - } else if (a._type == Activity::SyncResultType) { + } else if (activity._type == Activity::SyncResultType) { return colorIconPath.arg("error.svg"); - } else if (a._type == Activity::SyncFileItemType) { - if (a._syncFileItemStatus == SyncFileItem::NormalError - || a._syncFileItemStatus == SyncFileItem::FatalError - || a._syncFileItemStatus == SyncFileItem::DetailError - || a._syncFileItemStatus == SyncFileItem::BlacklistedError) { + } else if (activity._type == Activity::SyncFileItemType) { + if (activity._syncFileItemStatus == SyncFileItem::NormalError + || activity._syncFileItemStatus == SyncFileItem::FatalError + || activity._syncFileItemStatus == SyncFileItem::DetailError + || activity._syncFileItemStatus == SyncFileItem::BlacklistedError) { return colorIconPath.arg("error.svg"); - } else if (a._syncFileItemStatus == SyncFileItem::SoftError - || a._syncFileItemStatus == SyncFileItem::Conflict - || a._syncFileItemStatus == SyncFileItem::Restoration - || a._syncFileItemStatus == SyncFileItem::FileLocked - || a._syncFileItemStatus == SyncFileItem::FileNameInvalid - || a._syncFileItemStatus == SyncFileItem::FileNameInvalidOnServer - || a._syncFileItemStatus == SyncFileItem::FileNameClash) { + } else if (activity._syncFileItemStatus == SyncFileItem::SoftError + || activity._syncFileItemStatus == SyncFileItem::Conflict + || activity._syncFileItemStatus == SyncFileItem::Restoration + || activity._syncFileItemStatus == SyncFileItem::FileLocked + || activity._syncFileItemStatus == SyncFileItem::FileNameInvalid + || activity._syncFileItemStatus == SyncFileItem::FileNameInvalidOnServer + || activity._syncFileItemStatus == SyncFileItem::FileNameClash) { return colorIconPath.arg("warning.svg"); - } else if (a._syncFileItemStatus == SyncFileItem::FileIgnored) { + } else if (activity._syncFileItemStatus == SyncFileItem::FileIgnored) { return colorIconPath.arg("info.svg"); } else { // File sync successful - if (a._fileAction == "file_created") { - return a._previews.empty() ? colorIconPath.arg("add.svg") : colorIconPath.arg("add-bordered.svg"); - } else if (a._fileAction == "file_deleted") { - return a._previews.empty() ? colorIconPath.arg("delete.svg") : colorIconPath.arg("delete-bordered.svg"); + if (activity._fileAction == "file_created") { + return activity._previews.empty() ? colorIconPath.arg("add.svg") + : colorIconPath.arg("add-bordered.svg"); + } else if (activity._fileAction == "file_deleted") { + return activity._previews.empty() ? colorIconPath.arg("delete.svg") + : colorIconPath.arg("delete-bordered.svg"); } else { - return a._previews.empty() ? colorIconPath.arg("change.svg") : colorIconPath.arg("change-bordered.svg"); + return activity._previews.empty() ? colorIconPath.arg("change.svg") + : colorIconPath.arg("change-bordered.svg"); } } } else { // We have an activity - if (a._icon.isEmpty()) { + if (activity._icon.isEmpty()) { return colorIconPath.arg("activity.svg"); } // using tray-image-provider here as it can read from URLs - return QStringLiteral("image://tray-image-provider/%1").arg(a._icon); + return QStringLiteral("image://tray-image-provider/%1").arg(activity._icon); } }; @@ -273,30 +280,30 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const return displayLocation(); case ActionsLinksRole: { QList customList; - for (const auto &activityLink : std::as_const(a._links)) { + for (const auto &activityLink : std::as_const(activity._links)) { customList << QVariant::fromValue(activityLink); } return customList; } case ActionsLinksContextMenuRole: { - return ActivityListModel::convertLinksToMenuEntries(a); + return ActivityListModel::convertLinksToMenuEntries(activity); } case ActionsLinksForActionButtonsRole: { - return ActivityListModel::convertLinksToActionButtons(a); + return ActivityListModel::convertLinksToActionButtons(activity); } case IconRole: return generateIconPath(); case ObjectTypeRole: - return a._objectType; + return activity._objectType; case ObjectIdRole: - return a._objectId; + return activity._objectId; case ObjectNameRole: - return a._objectName; + return activity._objectName; case ActionRole: { - switch (a._type) { + switch (activity._type) { case Activity::ActivityType: case Activity::DummyFetchingActivityType: case Activity::DummyMoreActivitiesAvailableType: @@ -312,71 +319,73 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const break; } case ActionTextRole: - if(a._subjectDisplay.isEmpty()) { - return a._subject; + if(activity._subjectDisplay.isEmpty()) { + return activity._subject; } - return a._subjectDisplay; + return activity._subjectDisplay; case ActionTextColorRole: - return a._id == -1 ? QLatin1String("#808080") : QLatin1String("#222"); // FIXME: This is a temporary workaround for _showMoreActivitiesAvailableEntry + return activity._id == -1 ? QLatin1String("#808080") : QLatin1String("#222"); // FIXME: This is a temporary workaround for _showMoreActivitiesAvailableEntry case MessageRole: - return a._message; + return activity._message; case LinkRole: { - if (a._link.isEmpty()) { + if (activity._link.isEmpty()) { return ""; } else { - return a._link.toString(); + return activity._link.toString(); } } case AccountRole: - return a._accName; + return activity._accName; case PointInTimeRole: //return a._id == -1 ? "" : QString("%1 - %2").arg(Utility::timeAgoInWords(a._dateTime.toLocalTime()), a._dateTime.toLocalTime().toString(Qt::DefaultLocaleShortDate)); - return a._id == -1 ? "" : Utility::timeAgoInWords(a._dateTime.toLocalTime()); + return activity._id == -1 ? "" : Utility::timeAgoInWords(activity._dateTime.toLocalTime()); case AccountConnectedRole: - return (ast && ast->isConnected()); + return (accountState && accountState->isConnected()); case DisplayActions: return _displayActions; case ShowFileDetailsRole: return _displayActions && - a._objectType == QStringLiteral("files") && - a._fileAction != "file_deleted" && - a._syncFileItemStatus != SyncFileItem::FileIgnored && + activity._objectType == QStringLiteral("files") && + activity._fileAction != "file_deleted" && + activity._syncFileItemStatus != SyncFileItem::FileIgnored && !data(index, OpenablePathRole).toString().isEmpty(); case DismissableRole: // Do not allow dismissal of things requiring user input regarding syncing - return !a._links.isEmpty() && - a._syncFileItemStatus != SyncFileItem::FileNameClash && - a._syncFileItemStatus != SyncFileItem::Conflict && - a._syncFileItemStatus != SyncFileItem::FileNameInvalid && - a._syncFileItemStatus != SyncFileItem::FileNameInvalidOnServer; + return !activity._links.isEmpty() && + activity._syncFileItemStatus != SyncFileItem::FileNameClash && + activity._syncFileItemStatus != SyncFileItem::Conflict && + activity._syncFileItemStatus != SyncFileItem::FileNameInvalid && + activity._syncFileItemStatus != SyncFileItem::FileNameInvalidOnServer; case IsCurrentUserFileActivityRole: - return a._isCurrentUserFileActivity; + return activity._isCurrentUserFileActivity; case ThumbnailRole: { - if ((a._type == Activity::NotificationType || a._type == Activity::OpenSettingsNotificationType) && - !a._talkNotificationData.userAvatar.isEmpty()) { - return generateAvatarThumbnailMap(a._talkNotificationData.userAvatar); + if ((activity._type == Activity::NotificationType || activity._type == Activity::OpenSettingsNotificationType) && + !activity._talkNotificationData.userAvatar.isEmpty()) { + return generateAvatarThumbnailMap(activity._talkNotificationData.userAvatar); } - if(a._previews.empty()) { + if(activity._previews.empty()) { return {}; } - const auto preview = a._previews[0]; + const auto preview = activity._previews[0]; return(generatePreviewMap(preview)); } case TalkNotificationConversationTokenRole: - return a._talkNotificationData.conversationToken; + return activity._talkNotificationData.conversationToken; case TalkNotificationMessageIdRole: - return a._talkNotificationData.messageId; + return activity._talkNotificationData.messageId; case TalkNotificationMessageSentRole: - return replyMessageSent(a); + return replyMessageSent(activity); case TalkNotificationUserAvatarRole: - return a._talkNotificationData.userAvatar; + return activity._talkNotificationData.userAvatar; case ActivityIndexRole: return index.row(); case ActivityRole: - return QVariant::fromValue(a); + return QVariant::fromValue(activity); + case ActivityIntegrationRole: + return accountState->account()->serverHasIntegration(); } return {}; diff --git a/src/gui/tray/activitylistmodel.h b/src/gui/tray/activitylistmodel.h index d200baa6ed669..cd8ad12ad2a62 100644 --- a/src/gui/tray/activitylistmodel.h +++ b/src/gui/tray/activitylistmodel.h @@ -74,6 +74,7 @@ class ActivityListModel : public QAbstractListModel TalkNotificationUserAvatarRole, ActivityIndexRole, ActivityRole, + ActivityIntegrationRole }; Q_ENUM(DataRole) diff --git a/src/libsync/account.cpp b/src/libsync/account.cpp index d2d513ce53de7..0a7ad51ea8c2b 100644 --- a/src/libsync/account.cpp +++ b/src/libsync/account.cpp @@ -748,6 +748,7 @@ void Account::setCapabilities(const QVariantMap &caps) updateServerColors(); updateServerSubcription(); updateDesktopEnterpriseChannel(); + updateServerHasIntegration(); emit capabilitiesChanged(); @@ -1551,4 +1552,14 @@ void Account::setDownloadLimit(const unsigned int limit) emit downloadLimitChanged(); } +bool Account::serverHasIntegration() const +{ + return _serverHasIntegration; +} + +void Account::updateServerHasIntegration() +{ + _serverHasIntegration = capabilities().serverHasClientIntegration(); +} + } // namespace OCC diff --git a/src/libsync/account.h b/src/libsync/account.h index 41bf120cf7b28..a7aa5b46b9004 100644 --- a/src/libsync/account.h +++ b/src/libsync/account.h @@ -438,6 +438,8 @@ class OWNCLOUDSYNC_EXPORT Account : public QObject void setLastRootETag(const QByteArray &etag); #endif + [[nodiscard]] bool serverHasIntegration() const; + public slots: /// Used when forgetting credentials void clearQNAMCache(); @@ -508,6 +510,7 @@ public slots: void userCertificateNeedsMigrationChanged(); void rootFolderQuotaChanged(const int64_t &usedBytes, const int64_t &availableBytes); + protected Q_SLOTS: void slotCredentialsFetched(); void slotCredentialsAsked(); @@ -604,6 +607,9 @@ private slots: QByteArray _lastRootETag; // Runtime-only, not persisted #endif + void updateServerHasIntegration(); + bool _serverHasIntegration; + /* IMPORTANT - remove later - FIXME MS@2019-12-07 --> * TODO: For "Log out" & "Remove account": Remove client CA certs and KEY! * diff --git a/src/libsync/capabilities.cpp b/src/libsync/capabilities.cpp index 92bfa5d6d73ea..73b796fa2b212 100644 --- a/src/libsync/capabilities.cpp +++ b/src/libsync/capabilities.cpp @@ -437,6 +437,74 @@ QStringList Capabilities::forbiddenFilenameExtensions() const return _capabilities["files"].toMap()["forbidden_filename_extensions"].toStringList(); } +bool Capabilities::serverHasClientIntegration() const +{ + return !_capabilities[QStringLiteral("client_integration")].toMap().isEmpty(); +} + +QList Capabilities::fileActionsByMimeType(const QMimeType &fileMimeType) const +{ + const auto fileActionsMap = _capabilities.value("client_integration").toMap(); + QVariantList contextMenuMapList; + for (const auto &fileContextMenu : fileActionsMap) { + const auto fileContextMenuMap = fileContextMenu.toMap(); + if (!fileContextMenuMap.contains("context-menu")) { + continue; + } + + contextMenuMapList.append(fileContextMenuMap.value("context-menu").toList()); + } + + if (contextMenuMapList.empty()) { + qCDebug(lcServerCapabilities) << "There is no context menu available in the capabilities."; + return {}; + } + + const auto fileMimeTypeName = fileMimeType.name(); + qCDebug(lcServerCapabilities) << "Filtering file actions by mimeType:" << fileMimeTypeName; + + QList fileActionsByMimeType; + for (const auto &contextMenu : std::as_const(contextMenuMapList)) { + const auto contextMenuMap = contextMenu.toMap(); + const auto mimetypeFilters = contextMenuMap.value("mimetype_filters").toString(); + const auto filesMimeTypeFilterList = mimetypeFilters.split(",", Qt::SkipEmptyParts); + + if (filesMimeTypeFilterList.isEmpty()) { + qCDebug(lcServerCapabilities) << "Found file action for all mimetypes:" << contextMenuMap; + fileActionsByMimeType.append(contextMenuMap); + continue; + } + + for (const auto &mimeType : filesMimeTypeFilterList) { + const auto capabilitiesMimeType = mimeType.trimmed(); + QString mimeTypeAlias; + if (const auto capabilitiesMimeTypeSplit = capabilitiesMimeType.split("/"); + !capabilitiesMimeTypeSplit.isEmpty()){ + mimeTypeAlias = capabilitiesMimeTypeSplit.last(); + } + + qCDebug(lcServerCapabilities) << "Context menu for mimeType:" << mimeTypeAlias << capabilitiesMimeType; + qCDebug(lcServerCapabilities) << fileMimeTypeName << "inherits" << capabilitiesMimeType << "?" + << fileMimeType.inherits(capabilitiesMimeType); + + if (!capabilitiesMimeType.isEmpty() + && !fileMimeTypeName.startsWith(capabilitiesMimeType) + && !fileMimeTypeName.contains(capabilitiesMimeType) + && !fileMimeType.inherits(capabilitiesMimeType) && + !fileMimeTypeName.startsWith(mimeTypeAlias) + && !fileMimeTypeName.contains(mimeTypeAlias) + && !fileMimeType.inherits(mimeTypeAlias)) { + continue; + } + + qCDebug(lcServerCapabilities) << "Found file action:" << contextMenuMap; + fileActionsByMimeType.append(contextMenuMap); + } + } + + return fileActionsByMimeType; +} + /*-------------------------------------------------------------------------------------*/ // Direct Editing @@ -466,6 +534,7 @@ DirectEditor* Capabilities::getDirectEditorForOptionalMimetype(const QMimeType & return nullptr; } + /*-------------------------------------------------------------------------------------*/ DirectEditor::DirectEditor(const QString &id, const QString &name, QObject* parent) diff --git a/src/libsync/capabilities.h b/src/libsync/capabilities.h index 68569ab228d44..a06d4a1036761 100644 --- a/src/libsync/capabilities.h +++ b/src/libsync/capabilities.h @@ -175,6 +175,9 @@ class OWNCLOUDSYNC_EXPORT Capabilities [[nodiscard]] bool serverHasValidSubscription() const; [[nodiscard]] QString desktopEnterpriseChannel() const; + [[nodiscard]] bool serverHasClientIntegration() const; + [[nodiscard]] QList fileActionsByMimeType(const QMimeType &fileMimeType) const; + // Direct Editing void addDirectEditor(DirectEditor* directEditor); DirectEditor* getDirectEditorForMimetype(const QMimeType &mimeType); @@ -184,7 +187,6 @@ class OWNCLOUDSYNC_EXPORT Capabilities [[nodiscard]] QMap serverThemingMap() const; QVariantMap _capabilities; - QList _directEditors; }; diff --git a/src/libsync/networkjobs.cpp b/src/libsync/networkjobs.cpp index 2b994774f9e3b..4d3b5403e24bb 100644 --- a/src/libsync/networkjobs.cpp +++ b/src/libsync/networkjobs.cpp @@ -1464,6 +1464,23 @@ QByteArray SimpleApiJob::verbToString() const return "GET"; } +SimpleApiJob::Verb SimpleApiJob::stringToVerb(const QString &verb) const +{ + if (verb == QStringLiteral("POST")) { + return Verb::Post; + } + + if (verb == QStringLiteral("PUT")) { + return Verb::Put; + } + + if (verb == QStringLiteral("DELETE")) { + return Verb::Delete; + } + + return Verb::Get; +} + void SimpleApiJob::start() { addRawHeader("OCS-APIREQUEST", "true"); diff --git a/src/libsync/networkjobs.h b/src/libsync/networkjobs.h index 1134d49ecb246..4e5b9c485be9c 100644 --- a/src/libsync/networkjobs.h +++ b/src/libsync/networkjobs.h @@ -439,6 +439,8 @@ class OWNCLOUDSYNC_EXPORT SimpleApiJob : public AbstractNetworkJob void setVerb(Verb value); + [[nodiscard]] Verb stringToVerb(const QString &verb) const; + /** * @brief addQueryParams - add more parameters to the ocs call * @param params: list pairs of strings containing the parameter name and the value. diff --git a/theme.qrc.in b/theme.qrc.in index 4bf77d90807bc..15581d0db340e 100644 --- a/theme.qrc.in +++ b/theme.qrc.in @@ -291,5 +291,8 @@ theme/chevron-double-up.svg theme/call-notification.wav theme/info.svg + theme/file-open.svg + theme/backup.svg + theme/convert_to_text.svg diff --git a/theme/Style/Style.qml b/theme/Style/Style.qml index 98856f8c8e126..07d80f19e53f7 100644 --- a/theme/Style/Style.qml +++ b/theme/Style/Style.qml @@ -50,6 +50,8 @@ QtObject { property int trayHorizontalMargin: 10 property int trayModalWidth: 380 property int trayModalHeight: 490 + property int filesActionsWidth: 380 + property int filesActionsHeight: 350 property int trayListItemIconSize: accountAvatarSize property int trayDrawerMargin: trayWindowHeaderHeight property real thumbnailImageSizeReduction: 0.2 // We reserve some space within the thumbnail "item", here about 20%. diff --git a/theme/backup.svg b/theme/backup.svg new file mode 100644 index 0000000000000..696aaba720e95 --- /dev/null +++ b/theme/backup.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/theme/convert_to_text.svg b/theme/convert_to_text.svg new file mode 100644 index 0000000000000..62427bc8f8068 --- /dev/null +++ b/theme/convert_to_text.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/theme/file-open.svg b/theme/file-open.svg new file mode 100644 index 0000000000000..871c7217e7137 --- /dev/null +++ b/theme/file-open.svg @@ -0,0 +1 @@ + \ No newline at end of file