diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 8e6773829..e4af0e33a 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -186,6 +186,8 @@ set(client_SRCS userstatusselectormodel.cpp emojimodel.h emojimodel.cpp + syncconflictsmodel.h + syncconflictsmodel.cpp fileactivitylistmodel.h fileactivitylistmodel.cpp filedetails/filedetails.h diff --git a/src/gui/ResolveConflictsDialog.qml b/src/gui/ResolveConflictsDialog.qml index 643b92f95..5234b8a8a 100644 --- a/src/gui/ResolveConflictsDialog.qml +++ b/src/gui/ResolveConflictsDialog.qml @@ -110,6 +110,10 @@ Window { height: 1 } + SyncConflictsModel { + id: realModel + } + ScrollView { Layout.fillWidth: true Layout.fillHeight: true diff --git a/src/gui/folderman.h b/src/gui/folderman.h index 6b9186b72..933954b17 100644 --- a/src/gui/folderman.h +++ b/src/gui/folderman.h @@ -30,6 +30,7 @@ class TestCfApiShellExtensionsIPC; class TestShareModel; class ShareTestHelper; class EndToEndTestHelper; +class TestSyncConflictsModel; namespace OCC { @@ -391,6 +392,7 @@ private: explicit FolderMan(QObject *parent = nullptr); friend class OCC::Application; friend class ::TestFolderMan; + friend class ::TestSyncConflictsModel; friend class ::TestCfApiShellExtensionsIPC; friend class ::ShareTestHelper; friend class ::EndToEndTestHelper; diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index a43363879..20d15b9f4 100644 --- a/src/gui/owncloudgui.cpp +++ b/src/gui/owncloudgui.cpp @@ -31,6 +31,7 @@ #include "settingsdialog.h" #include "theme.h" #include "wheelhandler.h" +#include "syncconflictsmodel.h" #include "filedetails/filedetails.h" #include "filedetails/shareemodel.h" #include "filedetails/sharemodel.h" @@ -125,6 +126,7 @@ ownCloudGui::ownCloudGui(Application *parent) qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "ShareModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "ShareeModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "SortedShareModel"); + qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "SyncConflictsModel"); qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "UnifiedSearchResultsListModel", "UnifiedSearchResultsListModel"); qmlRegisterUncreatableType("com.nextcloud.desktopclient", 1, 0, "UserStatus", "Access to Status enum"); diff --git a/src/gui/syncconflictsmodel.cpp b/src/gui/syncconflictsmodel.cpp new file mode 100644 index 000000000..872619b7a --- /dev/null +++ b/src/gui/syncconflictsmodel.cpp @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2023 by Matthieu Gallien + * + * 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 of the License, or + * (at your option) any later version. + * + * 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. + */ + +#include "syncconflictsmodel.h" +#include "folderman.h" + +#include + +namespace OCC { + +Q_LOGGING_CATEGORY(lcSyncConflictsModel, "nextcloud.syncconflictsmodel", QtInfoMsg) + +SyncConflictsModel::SyncConflictsModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int SyncConflictsModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + + return mData.size(); +} + +QVariant SyncConflictsModel::data(const QModelIndex &index, int role) const +{ + auto result = QVariant{}; + + Q_ASSERT(checkIndex(index, CheckIndexOption::IndexIsValid | CheckIndexOption::ParentIsInvalid)); + + if (index.parent().isValid()) { + return result; + } + + if (role >= static_cast(SyncConflictRoles::ExistingFileName) && role <= static_cast(SyncConflictRoles::ConflictPreviewUrl)) { + auto convertedRole = static_cast(role); + + switch (convertedRole) { + case SyncConflictRoles::ExistingFileName: + result = mConflictData[index.row()].mExistingFileName; + break; + case SyncConflictRoles::ExistingSize: + result = mConflictData[index.row()].mExistingSize; + break; + case SyncConflictRoles::ConflictSize: + result = mConflictData[index.row()].mConflictSize; + break; + case SyncConflictRoles::ExistingDate: + result = mConflictData[index.row()].mExistingDate; + break; + case SyncConflictRoles::ConflictDate: + result = mConflictData[index.row()].mConflictDate; + break; + case SyncConflictRoles::ExistingSelected: + result = mConflictData[index.row()].mExistingSelected; + break; + case SyncConflictRoles::ConflictSelected: + result = mConflictData[index.row()].mConflictSelected; + break; + case SyncConflictRoles::ExistingPreviewUrl: + result = mConflictData[index.row()].mExistingPreviewUrl; + break; + case SyncConflictRoles::ConflictPreviewUrl: + result = mConflictData[index.row()].mConflictPreviewUrl; + break; + } + } + + return result; +} + +QHash SyncConflictsModel::roleNames() const +{ + auto result = QAbstractListModel::roleNames(); + + result[static_cast(SyncConflictRoles::ExistingFileName)] = "existingFileName"; + result[static_cast(SyncConflictRoles::ExistingSize)] = "existingSize"; + result[static_cast(SyncConflictRoles::ConflictSize)] = "conflictSize"; + result[static_cast(SyncConflictRoles::ExistingDate)] = "existingDate"; + result[static_cast(SyncConflictRoles::ConflictDate)] = "conflictDate"; + result[static_cast(SyncConflictRoles::ExistingSelected)] = "existingSelected"; + result[static_cast(SyncConflictRoles::ConflictSelected)] = "conflictSelected"; + result[static_cast(SyncConflictRoles::ExistingPreviewUrl)] = "existingPreviewUrl"; + result[static_cast(SyncConflictRoles::ConflictPreviewUrl)] = "conflictPreviewUrl"; + + return result; +} + +ActivityList SyncConflictsModel::conflictActivities() const +{ + return mData; +} + +void SyncConflictsModel::setConflictActivities(ActivityList conflicts) +{ + if (mData == conflicts) { + return; + } + + beginResetModel(); + + mData = conflicts; + emit conflictActivitiesChanged(); + + updateConflictsData(); + + endResetModel(); +} + +void SyncConflictsModel::updateConflictsData() +{ + mConflictData.clear(); + mConflictData.reserve(mData.size()); + + for (const auto &oneConflict : qAsConst(mData)) { + if (!FolderMan::instance()) { + qCWarning(lcSyncConflictsModel) << "no FolderMan instance"; + mConflictData.push_back({}); + continue; + } + const auto folder = FolderMan::instance()->folder(oneConflict._folder); + if (!folder) { + qCWarning(lcSyncConflictsModel) << "no Folder instance for" << oneConflict._folder; + mConflictData.push_back({}); + continue; + } + + const auto conflictedRelativePath = oneConflict._file; + const auto dbRecord = folder->journalDb(); + const auto baseRelativePath = dbRecord ? dbRecord->conflictFileBaseName(conflictedRelativePath.toUtf8()) : QString{}; + + const auto dir = QDir(folder->path()); + const auto conflictedPath = dir.filePath(conflictedRelativePath); + const auto basePath = dir.filePath(baseRelativePath); + + qCInfo(lcSyncConflictsModel()) << "conflictedPath" << conflictedPath << "basePath" << basePath; + + const auto existingFileInfo = QFileInfo(basePath); + const auto conflictFileInfo = QFileInfo(conflictedPath); + + const auto existingMimeType = mMimeDb.mimeTypeForFile(existingFileInfo.fileName()); + const auto conflictMimeType = mMimeDb.mimeTypeForFile(conflictFileInfo.fileName()); + + auto newConflictData = ConflictInfo{ + existingFileInfo.fileName(), + mLocale.formattedDataSize(existingFileInfo.size()), + mLocale.formattedDataSize(conflictFileInfo.size()), + existingFileInfo.lastModified().toString(), + conflictFileInfo.lastModified().toString(), + QIcon::hasThemeIcon(existingMimeType.iconName()) ? QUrl{} : QUrl{":/qt-project.org/styles/commonstyle/images/file-128.png"}, + QIcon::hasThemeIcon(conflictMimeType.iconName()) ? QUrl{} : QUrl{":/qt-project.org/styles/commonstyle/images/file-128.png"}, + false, + false, + }; + + mConflictData.push_back(std::move(newConflictData)); + } +} + +} diff --git a/src/gui/syncconflictsmodel.h b/src/gui/syncconflictsmodel.h new file mode 100644 index 000000000..d7ca61961 --- /dev/null +++ b/src/gui/syncconflictsmodel.h @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2023 by Matthieu Gallien + * + * 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 of the License, or + * (at your option) any later version. + * + * 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. + */ + +#ifndef SYNCCONFLICTSMODEL_H +#define SYNCCONFLICTSMODEL_H + +#include "tray/activitydata.h" + +#include +#include +#include + +namespace OCC { + +class SyncConflictsModel : public QAbstractListModel +{ + Q_OBJECT + + Q_PROPERTY(OCC::ActivityList conflictActivities READ conflictActivities WRITE setConflictActivities NOTIFY conflictActivitiesChanged) + + struct ConflictInfo { + QString mExistingFileName; + QString mExistingSize; + QString mConflictSize; + QString mExistingDate; + QString mConflictDate; + QUrl mExistingPreviewUrl; + QUrl mConflictPreviewUrl; + bool mExistingSelected = false; + bool mConflictSelected = false; + }; + +public: + enum class SyncConflictRoles : int { + ExistingFileName = Qt::UserRole, + ExistingSize, + ConflictSize, + ExistingDate, + ConflictDate, + ExistingSelected, + ConflictSelected, + ExistingPreviewUrl, + ConflictPreviewUrl, + }; + + Q_ENUM(SyncConflictRoles) + + explicit SyncConflictsModel(QObject *parent = nullptr); + + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + [[nodiscard]] QHash roleNames() const override; + + [[nodiscard]] OCC::ActivityList conflictActivities() const; + +public slots: + void setConflictActivities(OCC::ActivityList conflicts); + +signals: + void conflictActivitiesChanged(); + +private: + void updateConflictsData(); + + OCC::ActivityList mData; + + QVector mConflictData; + + QMimeDatabase mMimeDb; + + QLocale mLocale; +}; + +} + +#endif // SYNCCONFLICTSMODEL_H diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d6c27a130..2dbb5a9b7 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -71,6 +71,7 @@ nextcloud_add_test(ShareeModel) nextcloud_add_test(SortedShareModel) nextcloud_add_test(SecureFileDrop) nextcloud_add_test(FileTagModel) +nextcloud_add_test(SyncConflictsModel) target_link_libraries(SecureFileDropTest PRIVATE Nextcloud::sync) configure_file(fake2eelocksucceeded.json "${PROJECT_BINARY_DIR}/bin/fake2eelocksucceeded.json" COPYONLY) diff --git a/test/testsyncconflictsmodel.cpp b/test/testsyncconflictsmodel.cpp new file mode 100644 index 000000000..a235562b4 --- /dev/null +++ b/test/testsyncconflictsmodel.cpp @@ -0,0 +1,116 @@ +/* + * Copyright (C) by Claudio Cambra + * + * 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 of the License, or + * (at your option) any later version. + * + * 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. + */ + +#include "gui/syncconflictsmodel.h" +#include "folderman.h" +#include "accountstate.h" +#include "configfile.h" +#include "syncfileitem.h" + +#include "syncenginetestutils.h" +#include "testhelper.h" + +#include +#include +#include + +namespace { + +QStringList findConflicts(const FileInfo &dir) +{ + QStringList conflicts; + for (const auto &item : dir.children) { + if (item.name.contains("(conflicted copy")) { + conflicts.append(item.path()); + } + } + return conflicts; +} + +} + +using namespace OCC; + +class TestSyncConflictsModel : public QObject +{ + Q_OBJECT + +private: + +private slots: + void initTestCase() + { + } + + void testSuccessfulFetchShares() + { + auto dir = QTemporaryDir {}; + ConfigFile::setConfDir(dir.path()); // we don't want to pollute the user's config file + + FolderMan fm; + + auto account = Account::create(); + auto url = QUrl{"http://example.de"}; + auto cred = new HttpCredentialsTest("testuser", "secret"); + account->setCredentials(cred); + account->setUrl(url); + url.setUserName(cred->user()); + + auto newAccountState{AccountStatePtr{ new AccountState{account}}}; + auto folderman = FolderMan::instance(); + QCOMPARE(folderman, &fm); + + auto fakeFolder = FakeFolder{FileInfo::A12_B12_C12_S12()}; + + QVERIFY(folderman->addFolder(newAccountState.data(), folderDefinition(fakeFolder.localPath()))); + + QVERIFY(fakeFolder.syncOnce()); + + fakeFolder.localModifier().appendByte("A/a2"); + fakeFolder.remoteModifier().appendByte("A/a2"); + fakeFolder.remoteModifier().appendByte("A/a2"); + + QVERIFY(fakeFolder.syncOnce()); + + OCC::ActivityList allConflicts; + + const auto conflicts = findConflicts(fakeFolder.currentLocalState().children["A"]); + for (const auto &conflict : conflicts) { + auto conflictActivity = OCC::Activity{}; + conflictActivity._file = fakeFolder.localPath() + conflict; + conflictActivity._folder = fakeFolder.localPath(); + allConflicts.push_back(std::move(conflictActivity)); + } + + SyncConflictsModel model; + QAbstractItemModelTester modelTester(&model); + + model.setConflictActivities(allConflicts); + + QCOMPARE(model.rowCount(), 1); + QCOMPARE(model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ExistingFileName)), QString{"a2"}); + QCOMPARE(model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ExistingSize)), QString{"6 bytes"}); + QCOMPARE(model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ConflictSize)), QString{"5 bytes"}); + QVERIFY(!model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ExistingDate)).toString().isEmpty()); + QVERIFY(!model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ConflictDate)).toString().isEmpty()); + QCOMPARE(model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ExistingPreviewUrl)), QUrl{":/qt-project.org/styles/commonstyle/images/file-128.png"}); + QCOMPARE(model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ConflictPreviewUrl)), QUrl{":/qt-project.org/styles/commonstyle/images/file-128.png"}); + QCOMPARE(model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ExistingSelected)), false); + QCOMPARE(model.data(model.index(0), static_cast(SyncConflictsModel::SyncConflictRoles::ConflictSelected)), false); + } + +}; + +QTEST_GUILESS_MAIN(TestSyncConflictsModel) +#include "testsyncconflictsmodel.moc"