From 8674e70683400ce8b03df477b7bba3c10daf85c9 Mon Sep 17 00:00:00 2001 From: Jan-Florian Hilgenberg Date: Mon, 23 Mar 2026 14:02:42 +0100 Subject: [PATCH] Introduce Partial Delete: improved remoteDelete propagation Implements a safety mechanism to prevent dataloss when deleting folders that contain selective sync exclusions. When a folder containing unsynced descendants is deleted: - Instead of deleting the whole folder on the server, only the synced content is deleted - i.e. content the user was able to see. - The folder itself and any unsynced content remains on the server. - This preserves data that would otherwise be lost. Changes: - common/syncjournaldb.cpp: - added hasSelectiveSyncDescendants() to check if a path has unsynced descendants in the selective sync blacklist. - added getSyncedDescendants() to get all synced items within a folder path. - libsync/propagateremotedelete.cpp: modified start() to detect when a folder deletion would affect unsynced content. Implements partial deletion mode that deletes synced items individually while preserving the folder and unsynced content. - libsync/owncloudpropagator.cpp: added selectiveSyncList management Fixes #6948 Signed-off-by: Jan-Florian Hilgenberg --- src/common/syncjournaldb.cpp | 85 ++++++++++++++++++ src/common/syncjournaldb.h | 20 +++++ src/libsync/owncloudpropagator.cpp | 37 ++++++++ src/libsync/owncloudpropagator.h | 3 + src/libsync/propagateremotedelete.cpp | 121 ++++++++++++++++++++++++++ src/libsync/propagateremotedelete.h | 9 ++ 6 files changed, 275 insertions(+) diff --git a/src/common/syncjournaldb.cpp b/src/common/syncjournaldb.cpp index ab99460a0f2a6..788aee54c8e26 100644 --- a/src/common/syncjournaldb.cpp +++ b/src/common/syncjournaldb.cpp @@ -2476,6 +2476,91 @@ QStringList SyncJournalDb::removeSelectiveSyncLists(SelectiveSyncListType type, return blackList; } +bool SyncJournalDb::hasSelectiveSyncDescendants(const QString &path) +{ + QMutexLocker locker(&_mutex); + + if (!checkConnect()) { + return false; + } + + // Build the query to check if there are any selective sync blacklist entries + // that are descendants of the given path + QString pathPrefix = path; + if (!pathPrefix.endsWith(QLatin1Char('/'))) { + pathPrefix += QLatin1Char('/'); + } + + const QString sql = QStringLiteral("SELECT 1 FROM selectivesync WHERE path LIKE ? ESCAPE '\\' AND type == ? LIMIT 1"); + // const QString sql = QStringLiteral("SELECT 1 FROM selectivesync WHERE path LIKE ? LIMIT 1"); + SqlQuery query(_db); + query.prepare(sql.toUtf8()); + + // Escape special LIKE characters + QString escapedPath = pathPrefix; + escapedPath.replace(QLatin1String("\\"), QLatin1String("\\\\")); + escapedPath.replace(QLatin1String("%"), QLatin1String("\\%")); + escapedPath.replace(QLatin1String("_"), QLatin1String("\\_")); + escapedPath.replace(QLatin1String("["), QLatin1String("\\[")); + + query.bindValue(1, QString(escapedPath + QLatin1Char('%'))); + query.bindValue(2, static_cast(SelectiveSyncBlackList)); + + if (!query.exec()) { + qCWarning(lcDb) << "Error checking selective sync descendants for" << path << ":" << query.error(); + return false; + } + auto result = query.next(); + return result.ok && result.hasData; +} + +QStringList SyncJournalDb::getSyncedDescendants(const QString &path) +{ + QMutexLocker locker(&_mutex); + QStringList result; + + if (!checkConnect()) { + return result; + } + + // Get all files from the metadata that are descendants of the given path + // These are the files that were synced and need to be deleted + QString pathPrefix = path; + if (!pathPrefix.endsWith(QLatin1Char('/'))) { + pathPrefix += QLatin1Char('/'); + } + + const QString sql = QStringLiteral("SELECT path FROM metadata WHERE path LIKE ? ESCAPE '\\' ORDER BY path DESC"); + SqlQuery query(_db); + query.prepare(sql.toUtf8()); + + // Escape special LIKE characters + QString escapedPath = pathPrefix; + escapedPath.replace(QLatin1String("\\"), QLatin1String("\\\\")); + escapedPath.replace(QLatin1String("%"), QLatin1String("\\%")); + escapedPath.replace(QLatin1String("_"), QLatin1String("\\_")); + escapedPath.replace(QLatin1String("["), QLatin1String("\\[")); + + QString likePath = QString(escapedPath + QLatin1Char('%')); + query.bindValue(1, likePath); + + if (!query.exec()) { + qCWarning(lcDb) << "Error getting synced descendants for" << path << ":" << query.error(); + return result; + } + + while (query.next().hasData) { + QString filePath = query.stringValue(0); + // Exclude the exact path itself + if (filePath != path) { + // todo: only append folders that are fullySynced and files that are direct descendant + result.append(filePath); + } + } + + return result; +} + void SyncJournalDb::avoidRenamesOnNextSync(const QByteArray &path) { QMutexLocker locker(&_mutex); diff --git a/src/common/syncjournaldb.h b/src/common/syncjournaldb.h index 8cd868e1f0b71..d9f1ffebd8b5a 100644 --- a/src/common/syncjournaldb.h +++ b/src/common/syncjournaldb.h @@ -179,6 +179,26 @@ class OCSYNC_EXPORT SyncJournalDb : public QObject QStringList removeSelectiveSyncLists(SelectiveSyncListType type, const QString &path); + /** + * @brief Check if a path has descendants that are in selective sync blacklist + * @param path The folder path to check + * @return true if there are unsynced descendants, false otherwise + * + * This is used to prevent data loss when deleting folders that contain + * unsynced content (selective sync exclusions). + */ + [[nodiscard]] bool hasSelectiveSyncDescendants(const QString &path); + + /** + * @brief Get all synced file paths that are descendants of the given path + * @param path The folder path to get synced descendants for + * @return List of synced file paths + * + * This is used for partial deletion - we only want to delete synced content, + * keeping unsynced content on the server. + */ + [[nodiscard]] QStringList getSyncedDescendants(const QString &path); + /** * Make sure that on the next sync fileName and its parents are discovered from the server. * diff --git a/src/libsync/owncloudpropagator.cpp b/src/libsync/owncloudpropagator.cpp index 0172e73ac4132..4f059bf1e1e92 100644 --- a/src/libsync/owncloudpropagator.cpp +++ b/src/libsync/owncloudpropagator.cpp @@ -215,6 +215,43 @@ void blacklistUpdate(SyncJournalDb *journal, SyncFileItem &item) } } +bool PropagateItemJob::addPathToSelectiveSync(SyncJournalDb *journal, const QString &folder_) +{ + bool ok = false; + QStringList list = journal->getSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, &ok); + if (!ok) + return false; + + ASSERT(!folder_.endsWith(QLatin1String("/"))); + QString folder = folder_ + QLatin1String("/"); + + list.append(folder); + + journal->setSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, list); + return true; +} + +bool PropagateItemJob::removePathFromSelectiveSyncRecursively(SyncJournalDb *journal, const QString &folder_) +{ + bool ok = false; + QStringList list = journal->getSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, &ok); + if (!ok) + return false; + + ASSERT(!folder_.endsWith(QLatin1String("/"))); + QString folder = folder_ + QLatin1String("/"); + + for (auto &s : list) { + if (s.startsWith(folder)) { + const int item = list.indexOf(s); + list.removeAt(item); + } + } + + journal->setSelectiveSyncList(SyncJournalDb::SelectiveSyncBlackList, list); + return true; +} + void PropagateItemJob::done(const SyncFileItem::Status statusArg, const QString &errorString, const ErrorCategory category) { // Duplicate calls to done() are a logic error diff --git a/src/libsync/owncloudpropagator.h b/src/libsync/owncloudpropagator.h index 48326406d08f7..cc990bd530e79 100644 --- a/src/libsync/owncloudpropagator.h +++ b/src/libsync/owncloudpropagator.h @@ -205,6 +205,9 @@ protected slots: } ~PropagateItemJob() override; + static bool removePathFromSelectiveSyncRecursively(SyncJournalDb *journal, const QString &folder); + static bool addPathToSelectiveSync(SyncJournalDb *journal, const QString &folder); + bool scheduleSelfOrChild() override { if (_state != NotYetStarted) { diff --git a/src/libsync/propagateremotedelete.cpp b/src/libsync/propagateremotedelete.cpp index fc158dacda52d..b5f608721f045 100644 --- a/src/libsync/propagateremotedelete.cpp +++ b/src/libsync/propagateremotedelete.cpp @@ -26,6 +26,64 @@ void PropagateRemoteDelete::start() if (propagator()->_abortRequested) return; + // Partial Delete Logic: Check if this folder has unsynced descendants + // This prevents data loss when deleting folders that contain selective sync exclusions + if (_item->isDirectory() && propagator() && propagator()->_journal && + propagator()->_journal->hasSelectiveSyncDescendants(_item->_file)) + { + qCInfo(lcPropagateRemoteDelete) << "Folder" << _item->_file + << "has unsynced descendants. Performing partial deletion..."; + + // Get all synced descendants that need to be deleted + _syncedItemsToDelete = propagator()->_journal->getSyncedDescendants(_item->_file); + _syncedItemsToDelete.append(_item->_file); + _syncedItemsToKeep = _syncedItemsToDelete; + _syncedItemsToDelete.removeIf([this](const QString &path){return propagator()->_journal->hasSelectiveSyncDescendants(path);}); + _syncedItemsToDelete.removeIf([this](const QString &path) { + SyncJournalFileRecord rec; + assert(propagator()->_journal->getFileRecord(path,&rec)); + int endpos = path.lastIndexOf("/"); + QString parentPath = path.left(endpos); + if ( rec.isDirectory() + || propagator()->_journal->hasSelectiveSyncDescendants(parentPath)) {return false;}; + return _syncedItemsToDelete.contains(parentPath); + }); + + // add folders for selectivesync blacklist, required to imitate a proper deletion to the user + _syncedItemsToKeep.removeIf([this](const QString &path) { + bool selectiveSynced = propagator()->_journal->hasSelectiveSyncDescendants(path); + SyncJournalFileRecord rec; + propagator()->_journal->getFileRecord(path,&rec); + if (not selectiveSynced || not rec.isDirectory()) { + return true; + } + int endpos = path.lastIndexOf("/"); + QString parentPath = path.left(endpos); + return _syncedItemsToKeep.contains(parentPath); // deduplicates blacklist entries + }); + for (QString path: _syncedItemsToKeep) { + if (!PropagateItemJob::removePathFromSelectiveSyncRecursively(propagator()->_journal, path)) { + return; + } + if (!PropagateItemJob::addPathToSelectiveSync(propagator()->_journal, path)) { + return; + } + } + if (!_syncedItemsToDelete.isEmpty()) { + qCInfo(lcPropagateRemoteDelete) << "Partial deletion: deleting" << _syncedItemsToDelete.size() + << "synced items while keeping unsynced content"; + + // Start partial deletion process + deleteNextSyncedItem(); + return; + } + // No synced children to delete, just skip this operation + // The folder was never synced locally, so nothing to delete + qCInfo(lcPropagateRemoteDelete) << "No synced items to delete in folder with unsynced descendants. Skipping."; + done(SyncFileItem::Success, {}, ErrorCategory::NoError); + return; + } + if (!_item->_encryptedFileName.isEmpty() || _item->isEncrypted()) { if (!_item->_encryptedFileName.isEmpty()) { _deleteEncryptedHelper = new PropagateRemoteDeleteEncrypted(propagator(), _item, this); @@ -129,4 +187,67 @@ void PropagateRemoteDelete::slotDeleteJobFinished() done(SyncFileItem::Success, {}, ErrorCategory::NoError); } + +void PropagateRemoteDelete::deleteNextSyncedItem() +{ + if (_currentDeleteIndex >= _syncedItemsToDelete.size()) { + // All synced items have been deleted successfully + // The unsynced content remains on the server + qCInfo(lcPropagateRemoteDelete) << "Partial deletion complete. Deleted" << _syncedItemsToDelete.size() + << "synced items while preserving unsynced content."; + done(SyncFileItem::Success, {}, ErrorCategory::NoError); + return; + } + + QString itemPath = _syncedItemsToDelete[_currentDeleteIndex]; + + qCInfo(lcPropagateRemoteDelete) << "Partial deletion: deleting item" << (_currentDeleteIndex + 1) + << "of" << _syncedItemsToDelete.size() << ":" << itemPath; + + createPartialDeleteJob(itemPath); +} + +void PropagateRemoteDelete::createPartialDeleteJob(const QString &remoteFilename) +{ + Q_ASSERT(propagator()); + + auto headers = QMap{}; + if (_item->_locked == SyncFileItem::LockStatus::LockedItem) { + headers[QByteArrayLiteral("If")] = (QLatin1String("<") + propagator()->account()->davUrl().toString() + _item->_file + "> (_lockToken.toUtf8() + ">)").toUtf8(); + } + + _job = new DeleteJob(propagator()->account(), propagator()->fullRemotePath(remoteFilename), headers, this); + _job->setSkipTrashbin(_item->_wantsSpecificActions == SyncFileItem::SynchronizationOptions::WantsPermanentDeletion); + connect(_job.data(), &DeleteJob::finishedSignal, this, &PropagateRemoteDelete::slotPartialDeleteJobFinished); + propagator()->_activeJobList.append(this); + _job->start(); +} + +void PropagateRemoteDelete::slotPartialDeleteJobFinished() +{ + propagator()->_activeJobList.removeOne(this); + + ASSERT(_job); + + QNetworkReply::NetworkError err = _job->reply()->error(); + const int httpStatus = _job->reply()->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check if it was already deleted (404 is ok for partial deletion) + if (err != QNetworkReply::NoError && err != QNetworkReply::ContentNotFoundError) { + qCWarning(lcPropagateRemoteDelete) << "Partial deletion failed for item:" << _currentDeleteIndex << err; + done(SyncFileItem::SoftError, _job->errorString(), ErrorCategory::GenericError); + return; + } + + // Delete the file record from the local database + QString itemPath = _syncedItemsToDelete[_currentDeleteIndex]; + // todo: do I need an if(isDirectory) then recursively = true else false? + if (!propagator()->_journal->deleteFileRecord(itemPath, false)) { + qCWarning(lcPropagateRemoteDelete) << "Could not delete file record from local DB:" << itemPath; + } + + // Move to next item + _currentDeleteIndex++; + deleteNextSyncedItem(); +} } diff --git a/src/libsync/propagateremotedelete.h b/src/libsync/propagateremotedelete.h index c8e7b4e074995..64bb8c7816850 100644 --- a/src/libsync/propagateremotedelete.h +++ b/src/libsync/propagateremotedelete.h @@ -37,5 +37,14 @@ class PropagateRemoteDelete : public PropagateItemJob private slots: void slotDeleteJobFinished(); + void slotPartialDeleteJobFinished(); + +private: + void deleteNextSyncedItem(); + void createPartialDeleteJob(const QString &filename); + + QStringList _syncedItemsToDelete; + QStringList _syncedItemsToKeep; + int _currentDeleteIndex = 0; }; }