From b9f31f91d769d79a642cd425598064a5c6af59d4 Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Fri, 5 Jun 2026 13:53:07 +0200 Subject: [PATCH] Add text conflict automerge --- cmake/modules/FindLibGit2.cmake | 37 ++++ src/gui/generalsettings.cpp | 5 + src/gui/generalsettings.ui | 8 + src/libsync/CMakeLists.txt | 8 + src/libsync/configfile.cpp | 11 + src/libsync/configfile.h | 4 + src/libsync/conflictautomerge.cpp | 355 ++++++++++++++++++++++++++++++ src/libsync/conflictautomerge.h | 65 ++++++ src/libsync/propagatedownload.cpp | 87 +++++++- src/libsync/propagatedownload.h | 8 + translations/desktop_ar.ts | 5 + translations/desktop_ca.ts | 5 + translations/desktop_de.ts | 5 + translations/desktop_el.ts | 7 +- translations/desktop_en.ts | 5 + translations/desktop_fr.ts | 5 + translations/desktop_he.ts | 5 + translations/desktop_hu.ts | 5 + translations/desktop_it.ts | 5 + translations/desktop_ja.ts | 5 + translations/desktop_ko.ts | 5 + translations/desktop_lo.ts | 5 + translations/desktop_nl.ts | 5 + translations/desktop_pl.ts | 5 + translations/desktop_pt.ts | 5 + translations/desktop_ru.ts | 5 + translations/desktop_sv.ts | 5 + 27 files changed, 673 insertions(+), 2 deletions(-) create mode 100644 cmake/modules/FindLibGit2.cmake create mode 100644 src/libsync/conflictautomerge.cpp create mode 100644 src/libsync/conflictautomerge.h diff --git a/cmake/modules/FindLibGit2.cmake b/cmake/modules/FindLibGit2.cmake new file mode 100644 index 0000000000..06561a9062 --- /dev/null +++ b/cmake/modules/FindLibGit2.cmake @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: BSD-3-Clause + +find_package(PkgConfig QUIET) +pkg_check_modules(PC_LibGit2 QUIET libgit2) + +find_path(LibGit2_INCLUDE_DIR + NAMES git2.h + HINTS ${PC_LibGit2_INCLUDE_DIRS} +) + +find_library(LibGit2_LIBRARY + NAMES git2 libgit2 + HINTS ${PC_LibGit2_LIBRARY_DIRS} +) + +if(LibGit2_INCLUDE_DIR) + file(STRINGS "${LibGit2_INCLUDE_DIR}/git2/version.h" _libgit2_version_line + REGEX "#define LIBGIT2_VERSION \"[^\"]+\"" + ) + string(REGEX REPLACE ".*\"([^\"]+)\".*" "\\1" LibGit2_VERSION "${_libgit2_version_line}") +endif() + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(LibGit2 + REQUIRED_VARS LibGit2_LIBRARY LibGit2_INCLUDE_DIR + VERSION_VAR LibGit2_VERSION +) + +if(LibGit2_FOUND AND NOT TARGET LibGit2::LibGit2) + add_library(LibGit2::LibGit2 UNKNOWN IMPORTED) + set_target_properties(LibGit2::LibGit2 PROPERTIES + IMPORTED_LOCATION "${LibGit2_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${LibGit2_INCLUDE_DIR}" + ) +endif() + +mark_as_advanced(LibGit2_INCLUDE_DIR LibGit2_LIBRARY) diff --git a/src/gui/generalsettings.cpp b/src/gui/generalsettings.cpp index 20943e030b..64b1a751c7 100644 --- a/src/gui/generalsettings.cpp +++ b/src/gui/generalsettings.cpp @@ -67,6 +67,10 @@ GeneralSettings::GeneralSettings(QWidget *parent) ConfigFile().setMoveToTrash(checked); Q_EMIT syncOptionsChanged(); }); + connect(_ui->autoMergeTextConflictsCheckBox, &QCheckBox::toggled, this, [this](bool checked) { + ConfigFile().setAutoMergeTextConflicts(checked); + Q_EMIT syncOptionsChanged(); + }); connect(_ui->ignoredFilesButton, &QAbstractButton::clicked, this, &GeneralSettings::slotIgnoreFilesEditor); connect(_ui->logSettingsButton, &QPushButton::clicked, this, [] { @@ -136,6 +140,7 @@ void GeneralSettings::reloadConfig() { _ui->syncHiddenFilesCheckBox->setChecked(!FolderMan::instance()->ignoreHiddenFiles()); _ui->moveToTrashCheckBox->setChecked(ConfigFile().moveToTrash()); + _ui->autoMergeTextConflictsCheckBox->setChecked(ConfigFile().autoMergeTextConflicts()); if (Utility::isWindows() && Utility::isInstalledByStore()) { _ui->autostartCheckBox->setVisible(false); } else { diff --git a/src/gui/generalsettings.ui b/src/gui/generalsettings.ui index 8117d64282..671b490982 100644 --- a/src/gui/generalsettings.ui +++ b/src/gui/generalsettings.ui @@ -119,6 +119,13 @@ + + + + Automatically merge text file conflicts when possible + + + @@ -242,6 +249,7 @@ syncHiddenFilesCheckBox crashreporterCheckBox moveToTrashCheckBox + autoMergeTextConflictsCheckBox ignoredFilesButton logSettingsButton widget diff --git a/src/libsync/CMakeLists.txt b/src/libsync/CMakeLists.txt index 4c4dbe4022..ca69d1d11c 100644 --- a/src/libsync/CMakeLists.txt +++ b/src/libsync/CMakeLists.txt @@ -1,9 +1,15 @@ find_package(LibreGraphAPI 1.0.4 REQUIRED) +find_package(LibGit2 REQUIRED) set_package_properties(LibreGraphAPI PROPERTIES URL https://github.com/owncloud/libre-graph-api-cpp-qt-client.git DESCRIPTION "Libre Graph is a free API for cloud collaboration inspired by the MS Graph API" TYPE REQUIRED ) +set_package_properties(LibGit2 PROPERTIES + URL https://libgit2.org + DESCRIPTION "Portable Git library used for three-way text conflict automerge" + TYPE REQUIRED +) configure_file(config.h.in ${CMAKE_CURRENT_BINARY_DIR}/config.h) @@ -23,6 +29,7 @@ add_library(libsync SHARED logger.cpp accessmanager.cpp configfile.cpp + conflictautomerge.cpp globalconfig.cpp abstractnetworkjob.cpp networkjobs.cpp @@ -101,6 +108,7 @@ target_link_libraries(libsync PRIVATE Qt::Concurrent ZLIB::ZLIB + LibGit2::LibGit2 Qt6Keychain::Qt6Keychain SQLite::SQLite3 ) diff --git a/src/libsync/configfile.cpp b/src/libsync/configfile.cpp index cfd644a007..53dfd83f23 100644 --- a/src/libsync/configfile.cpp +++ b/src/libsync/configfile.cpp @@ -87,6 +87,7 @@ const QString pauseSyncWhenMeteredC() return QStringLiteral("pauseWhenMetered"); } const QString moveToTrashC() { return QStringLiteral("moveToTrash"); } +const QString autoMergeTextConflictsC() { return QStringLiteral("autoMergeTextConflicts"); } const QString issuesWidgetFilterC() { @@ -432,6 +433,16 @@ void ConfigFile::setMoveToTrash(bool isChecked) setValue(moveToTrashC(), isChecked); } +bool ConfigFile::autoMergeTextConflicts() const +{ + return getValue(autoMergeTextConflictsC(), false).toBool(); +} + +void ConfigFile::setAutoMergeTextConflicts(bool isChecked) +{ + setValue(autoMergeTextConflictsC(), isChecked); +} + bool ConfigFile::crashReporter() const { auto settings = makeQSettings(); diff --git a/src/libsync/configfile.h b/src/libsync/configfile.h index a54dc69bf1..ecf3b754a0 100644 --- a/src/libsync/configfile.h +++ b/src/libsync/configfile.h @@ -115,6 +115,10 @@ class OPENCLOUD_SYNC_EXPORT ConfigFile bool moveToTrash() const; void setMoveToTrash(bool); + /** Whether text file conflicts should be automatically merged when possible. */ + bool autoMergeTextConflicts() const; + void setAutoMergeTextConflicts(bool); + /// Used for testing, so we do not change the user's config file. static bool setConfDir(const QString &value); diff --git a/src/libsync/conflictautomerge.cpp b/src/libsync/conflictautomerge.cpp new file mode 100644 index 0000000000..22cb90106c --- /dev/null +++ b/src/libsync/conflictautomerge.cpp @@ -0,0 +1,355 @@ +/* + * Copyright (C) by OpenCloud GmbH + * + * 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. + */ + +#include "libsync/conflictautomerge.h" + +#include "libsync/account.h" +#include "libsync/configfile.h" +#include "libsync/filesystem.h" + +#include "common/utility.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace OCC { + +Q_LOGGING_CATEGORY(lcConflictAutoMerge, "sync.conflictautomerge", QtInfoMsg) + +namespace { +constexpr qint64 MaxAutoMergeFileSize = 1024 * 1024; + +QString versionNameFromHref(const QString &href) +{ + const auto path = href.endsWith(QLatin1Char('/')) ? href.left(href.size() - 1) : href; + const auto slash = path.lastIndexOf(QLatin1Char('/')); + return slash >= 0 ? path.mid(slash + 1) : path; +} + +QByteArray readAll(const QString &fileName, QIODevice::OpenMode mode = {}) +{ + QFile file(fileName); + if (!file.open(QIODevice::ReadOnly | mode)) { + return {}; + } + return file.readAll(); +} + +bool writeAll(const QString &fileName, const QByteArray &data, QIODevice::OpenMode mode = {}) +{ + QFile file(fileName); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | mode)) { + return false; + } + return file.write(data) == data.size(); +} + +bool containsNulByte(const QByteArray &data) +{ + return data.contains('\0'); +} + +bool mergeFiles(const QString &baseFileName, const QString &localFileName, const QString &remoteFileName, const QString &mergedFileName) +{ + const auto base = readAll(baseFileName, QIODevice::Text); + const auto local = readAll(localFileName, QIODevice::Text); + const auto remote = readAll(remoteFileName, QIODevice::Text); + if (base.isEmpty() && QFileInfo(baseFileName).size() > 0) { + return false; + } + if (local.isEmpty() && QFileInfo(localFileName).size() > 0) { + return false; + } + if (remote.isEmpty() && QFileInfo(remoteFileName).size() > 0) { + return false; + } + + git_libgit2_init(); + + git_merge_file_input ancestor = GIT_MERGE_FILE_INPUT_INIT; + ancestor.ptr = base.constData(); + ancestor.size = static_cast(base.size()); + ancestor.path = "base"; + + git_merge_file_input ours = GIT_MERGE_FILE_INPUT_INIT; + ours.ptr = local.constData(); + ours.size = static_cast(local.size()); + ours.path = "local"; + + git_merge_file_input theirs = GIT_MERGE_FILE_INPUT_INIT; + theirs.ptr = remote.constData(); + theirs.size = static_cast(remote.size()); + theirs.path = "remote"; + + git_merge_file_options options = GIT_MERGE_FILE_OPTIONS_INIT; + options.favor = GIT_MERGE_FILE_FAVOR_NORMAL; + options.flags = GIT_MERGE_FILE_STYLE_MERGE; + + git_merge_file_result result = {}; + const int rc = git_merge_file(&result, &ancestor, &ours, &theirs, &options); + const bool hasResult = result.ptr || result.len == 0; + const bool merged = rc == 0 && result.automergeable && hasResult + && writeAll(mergedFileName, QByteArray(result.ptr, static_cast(result.len)), QIODevice::Text); + if (!merged) { + qCInfo(lcConflictAutoMerge) << "libgit2 merge failed" + << "rc" << rc + << "automergeable" << result.automergeable + << "hasResult" << hasResult; + } + git_merge_file_result_free(&result); + git_libgit2_shutdown(); + return merged; +} + +SyncJournalFileRecord baseRecordFor(OwncloudPropagator *propagator, const SyncFileItemPtr &item) +{ + auto baseRecord = propagator->_journal->getFileRecord(item->_originalFile); + if (!baseRecord.isValid() && item->_originalFile != item->localName()) { + baseRecord = propagator->_journal->getFileRecord(item->localName()); + } + return baseRecord; +} +} + +ConflictAutoMerge::ConflictAutoMerge(OwncloudPropagator *propagator, const SyncFileItemPtr &item, const QString &localFile, const QString &remoteFile, + QObject *parent) + : QObject(parent) + , _propagator(propagator) + , _item(item) + , _localFile(localFile) + , _remoteFile(remoteFile) + , _baseRecord(baseRecordFor(propagator, item)) +{ +} + +bool ConflictAutoMerge::canStart() const +{ + const auto enabled = ConfigFile().autoMergeTextConflicts(); + const auto conflict = _item->instruction() == CSYNC_INSTRUCTION_CONFLICT; + const auto file = !_item->isDirectory(); + const auto versioning = _propagator->account()->capabilities().versioningEnabled(); + const auto baseValid = _baseRecord.isValid(); + const auto hasBaseFileId = !_baseRecord.fileId().isEmpty(); + const auto hasBaseEtag = !_baseRecord.etag().isEmpty(); + const auto localText = isTextMergeCandidate(_localFile); + const auto remoteText = isTextMergeCandidate(_remoteFile); + + const auto canStart = enabled && conflict && file && versioning && baseValid && hasBaseFileId && hasBaseEtag && localText && remoteText; + if (conflict && !canStart) { + qCInfo(lcConflictAutoMerge) << "Text conflict automerge not started" + << _item->localName() + << "enabled" << enabled + << "file" << file + << "versioning" << versioning + << "baseValid" << baseValid + << "hasBaseFileId" << hasBaseFileId + << "hasBaseEtag" << hasBaseEtag + << "localText" << localText + << "remoteText" << remoteText + << "originalFile" << _item->_originalFile + << "localFile" << _localFile + << "remoteFile" << _remoteFile + << "basePath" << _baseRecord.path(); + } + return canStart; +} + +void ConflictAutoMerge::start() +{ + if (!canStart()) { + emitNotMerged(); + return; + } + + QNetworkRequest request; + request.setRawHeader("Depth", "1"); + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("text/xml; charset=utf-8")); + + QByteArray body = QByteArrayLiteral( + "" + "\n"); + _versionsJob = new SimpleNetworkJob(_propagator->account(), _propagator->account()->url(), + QStringLiteral("dav/meta/%1/v/").arg(QString::fromUtf8(_baseRecord.fileId())), QByteArrayLiteral("PROPFIND"), std::move(body), request, this); + + connect(_versionsJob, &SimpleNetworkJob::finishedSignal, this, [this] { + if (!_versionsJob || _versionsJob->reply()->error() != QNetworkReply::NoError || _versionsJob->httpStatusCode() / 100 != 2) { + qCInfo(lcConflictAutoMerge) << "Could not list base versions" << _item->localName() << _versionsJob->errorString(); + emitNotMerged(); + return; + } + + const auto xml = _versionsJob->reply()->readAll(); + QXmlStreamReader reader(xml); + VersionInfo version; + bool inResponse = false; + while (!reader.atEnd()) { + reader.readNext(); + if (reader.isStartElement() && reader.name() == QLatin1String("response")) { + version = {}; + inResponse = true; + } else if (reader.isEndElement() && reader.name() == QLatin1String("response")) { + if (!version.name.isEmpty()) { + _versions.append(version); + } + inResponse = false; + } else if (inResponse && reader.isStartElement() && reader.name() == QLatin1String("href")) { + const auto href = reader.readElementText(); + if (href.contains(QLatin1String("/v/"))) { + version.name = versionNameFromHref(href); + } + } else if (inResponse && reader.isStartElement() && reader.name() == QLatin1String("getetag")) { + version.etag = Utility::normalizeEtag(reader.readElementText()); + } else if (inResponse && reader.isStartElement() && reader.name() == QLatin1String("getlastmodified")) { + const auto lastModified = reader.readElementText(); + if (!lastModified.isEmpty()) { + version.mtime = Utility::qDateTimeToTime_t(Utility::parseRFC1123Date(lastModified)); + } + } + } + if (reader.hasError()) { + qCInfo(lcConflictAutoMerge) << "Could not parse base versions" << _item->localName() << reader.errorString(); + emitNotMerged(); + return; + } + versionsListed(); + }); + _versionsJob->start(); +} + +bool ConflictAutoMerge::isTextMergeCandidate(const QString &fileName) const +{ + const QFileInfo info(fileName); + if (!info.isFile() || info.size() > MaxAutoMergeFileSize) { + return false; + } + if (info.size() == 0) { + return true; + } + + QFile file(fileName); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + const auto data = file.read(MaxAutoMergeFileSize + 1); + if (data.size() > MaxAutoMergeFileSize || containsNulByte(data)) { + return false; + } + + QMimeDatabase mimeDb; + QBuffer buffer; + buffer.setData(data); + buffer.open(QIODevice::ReadOnly); + const auto mimeType = mimeDb.mimeTypeForFileNameAndData(fileName, &buffer); + return mimeType.inherits(QStringLiteral("text/plain")) + || mimeType.name().startsWith(QStringLiteral("text/")) + || mimeType.name() == QLatin1String("application/json") + || mimeType.name() == QLatin1String("application/xml") + || mimeType.name() == QLatin1String("application/x-yaml") + || mimeType.name() == QLatin1String("application/javascript"); +} + +void ConflictAutoMerge::versionsListed() +{ + const auto versionName = matchingVersionName(); + if (versionName.isEmpty()) { + qCInfo(lcConflictAutoMerge) << u"No matching base version for" << _item->localName(); + emitNotMerged(); + return; + } + + _baseJob = new SimpleNetworkJob(_propagator->account(), _propagator->account()->url(), + QStringLiteral("dav/meta/%1/v/%2").arg(QString::fromUtf8(_baseRecord.fileId()), versionName), QByteArrayLiteral("GET"), this); + connect(_baseJob, &SimpleNetworkJob::finishedSignal, this, &ConflictAutoMerge::baseDownloaded); + _baseJob->start(); +} + +void ConflictAutoMerge::baseDownloaded() +{ + if (!_baseJob || _baseJob->reply()->error() != QNetworkReply::NoError || _baseJob->httpStatusCode() / 100 != 2) { + emitNotMerged(); + return; + } + + const auto baseData = _baseJob->reply()->readAll(); + if (baseData.size() > MaxAutoMergeFileSize || containsNulByte(baseData) || !_baseFile.open()) { + qCInfo(lcConflictAutoMerge) << "Downloaded base version is not mergeable" << _item->localName(); + emitNotMerged(); + return; + } + if (_baseFile.write(baseData) != baseData.size()) { + qCInfo(lcConflictAutoMerge) << "Could not write base version for automerge" << _item->localName(); + emitNotMerged(); + return; + } + _baseFile.close(); + + runMerge(); +} + +void ConflictAutoMerge::emitNotMerged() +{ + Q_EMIT finished(false, {}); +} + +void ConflictAutoMerge::runMerge() +{ + QString mergedFileName; + { + QTemporaryFile mergedFile(QDir::tempPath() + QStringLiteral("/opencloud-automerge-XXXXXX")); + mergedFile.setAutoRemove(false); + if (!mergedFile.open()) { + emitNotMerged(); + return; + } + mergedFileName = mergedFile.fileName(); + mergedFile.close(); + } + + if (!mergeFiles(_baseFile.fileName(), _localFile, _remoteFile, mergedFileName)) { + qCInfo(lcConflictAutoMerge) << "Could not automatically merge text conflict" << _item->localName(); + QFile::remove(mergedFileName); + emitNotMerged(); + return; + } + + qCInfo(lcConflictAutoMerge) << u"Automatically merged text conflict" << _item->localName(); + Q_EMIT finished(true, mergedFileName); +} + +QString ConflictAutoMerge::matchingVersionName() const +{ + const auto baseEtag = Utility::normalizeEtag(_baseRecord.etag()); + for (const auto &version : _versions) { + if (!version.etag.isEmpty() && version.etag == baseEtag) { + return version.name; + } + } + + if (_baseRecord.modtime() <= 0) { + return {}; + } + for (const auto &version : _versions) { + if (version.mtime == _baseRecord.modtime()) { + return version.name; + } + } + + return {}; +} + +} diff --git a/src/libsync/conflictautomerge.h b/src/libsync/conflictautomerge.h new file mode 100644 index 0000000000..b1560b4cac --- /dev/null +++ b/src/libsync/conflictautomerge.h @@ -0,0 +1,65 @@ +/* + * Copyright (C) by OpenCloud GmbH + * + * 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. + */ + +#pragma once + +#include "libsync/networkjobs.h" +#include "libsync/networkjobs/simplenetworkjob.h" +#include "libsync/owncloudpropagator.h" +#include "libsync/common/syncjournalfilerecord.h" + +#include +#include +#include +#include + +#include + +namespace OCC { + +class ConflictAutoMerge : public QObject +{ + Q_OBJECT +public: + explicit ConflictAutoMerge(OwncloudPropagator *propagator, const SyncFileItemPtr &item, const QString &localFile, const QString &remoteFile, + QObject *parent = nullptr); + + bool canStart() const; + void start(); + +Q_SIGNALS: + void finished(bool merged, const QString &mergedFileName); + +private: + struct VersionInfo + { + QString name; + QString etag; + time_t mtime = 0; + }; + + bool isTextMergeCandidate(const QString &fileName) const; + void versionsListed(); + void baseDownloaded(); + void emitNotMerged(); + void runMerge(); + QString matchingVersionName() const; + + OwncloudPropagator *_propagator = nullptr; + SyncFileItemPtr _item; + QString _localFile; + QString _remoteFile; + SyncJournalFileRecord _baseRecord; + QList _versions; + QPointer _versionsJob; + QPointer _baseJob; + QTemporaryFile _baseFile; +}; + +} diff --git a/src/libsync/propagatedownload.cpp b/src/libsync/propagatedownload.cpp index cb8217cfde..f0c191e01d 100644 --- a/src/libsync/propagatedownload.cpp +++ b/src/libsync/propagatedownload.cpp @@ -14,6 +14,7 @@ #include "propagatedownload.h" #include "account.h" +#include "conflictautomerge.h" #include "filesystem.h" #include "libsync/networkjobs/getfilejob.h" #include "networkjobs.h" @@ -604,6 +605,42 @@ void PropagateDownloadFile::contentChecksumComputed(CheckSums::Algorithm checksu { _item->_checksumHeader = ChecksumHeader(checksumType, checksum).makeChecksumHeader(); + if (startAutoMerge()) { + return; + } + + downloadFinished(); +} + +bool PropagateDownloadFile::startAutoMerge() +{ + auto autoMerge = new ConflictAutoMerge(propagator(), _item, propagator()->fullLocalPath(_item->localName()), _tmpFile.fileName(), this); + if (!autoMerge->canStart()) { + autoMerge->deleteLater(); + return false; + } + + qCInfo(lcPropagateDownload) << "Starting text conflict automerge" << _item->localName(); + _autoMergeJob = autoMerge; + propagator()->_activeJobList.append(this); + connect(autoMerge, &ConflictAutoMerge::finished, this, &PropagateDownloadFile::autoMergeFinished); + autoMerge->start(); + return true; +} + +void PropagateDownloadFile::autoMergeFinished(bool merged, const QString &mergedFileName) +{ + propagator()->_activeJobList.removeOne(this); + if (_autoMergeJob) { + _autoMergeJob->deleteLater(); + _autoMergeJob.clear(); + } + + if (merged) { + _autoMergeResolved = true; + _autoMergedFileName = mergedFileName; + } + downloadFinished(); } @@ -615,6 +652,7 @@ void PropagateDownloadFile::downloadFinished() // In case of file name clash, report an error // This can happen if another parallel download saved a clashing file. if (auto clash = propagator()->localFileNameClash(_item->localName())) { + removeAutoMergedFile(); done(SyncFileItem::NormalError, tr("The file »%1« cannot be saved because of a local file name clash with »%2«!") .arg(QDir::toNativeSeparators(_item->localName()), QDir::toNativeSeparators(clash.get()))); @@ -638,16 +676,21 @@ void PropagateDownloadFile::downloadFinished() // Make the file a hydrated placeholder if possible const auto result = propagator()->updatePlaceholder(*_item, _tmpFile.fileName(), fn); if (!result) { + removeAutoMergedFile(); done(SyncFileItem::NormalError, result.error()); return; } else if (result.get() == Vfs::ConvertToPlaceholderResult::Locked) { + removeAutoMergedFile(); done(SyncFileItem::SoftError, tr("The file »%1« is currently in use").arg(_item->localName())); return; } } bool isConflict = _item->instruction() == CSYNC_INSTRUCTION_CONFLICT && (QFileInfo(fn).isDir() || !FileSystem::fileEquals(fn, _tmpFile.fileName())); - if (isConflict) { + if (isConflict && _autoMergeResolved) { + isConflict = false; + previousFileExists = false; + } else if (isConflict) { QString error; if (!propagator()->createConflict(_item, _associatedComposite, &error)) { done(SyncFileItem::SoftError, error); @@ -662,6 +705,7 @@ void PropagateDownloadFile::downloadFinished() // is necessary to avoid overwriting user changes that happened between // the discovery phase and now. if (FileSystem::fileChanged(FileSystem::toFilesystemPath(fn), FileSystem::FileChangedInfo::fromSyncFileItemPrevious(_item.data()))) { + removeAutoMergedFile(); propagator()->_anotherSyncNeeded = true; done(SyncFileItem::SoftError, tr("The file has changed since discovery")); return; @@ -670,15 +714,25 @@ void PropagateDownloadFile::downloadFinished() // If the file is locked, we want to retry this sync when it // becomes available again if (FileSystem::isFileLocked(fn, FileSystem::LockMode::Exclusive)) { + removeAutoMergedFile(); Q_EMIT propagator()->seenLockedFile(fn, FileSystem::LockMode::Exclusive); done(SyncFileItem::SoftError, tr("The file »%1« is currently in use").arg(fn)); return; } + if (_autoMergeResolved) { + // Record the downloaded remote metadata, then keep the merged file as a local change for the next sync. + _item->_size = FileSystem::getSize(FileSystem::toFilesystemPath(_tmpFile.fileName())); + FileSystem::remove(_tmpFile.fileName()); + updateMetadata(false); + return; + } + QString error; // The fileChanged() check is done above to generate better error messages. if (!FileSystem::uncheckedRenameReplace(_tmpFile.fileName(), fn, &error)) { qCWarning(lcPropagateDownload) << u"Rename failed:" << _tmpFile.fileName() << u"=>" << fn << u"with error:" << error; + removeAutoMergedFile(); propagator()->_anotherSyncNeeded = true; done(SyncFileItem::SoftError, error); return; @@ -703,15 +757,32 @@ void PropagateDownloadFile::updateMetadata(bool isConflict) { const auto result = propagator()->updateMetadata(*_item); if (!result) { + removeAutoMergedFile(); done(SyncFileItem::FatalError, tr("Error updating metadata: %1").arg(result.error())); return; } else if (result.get() == Vfs::ConvertToPlaceholderResult::Locked) { + removeAutoMergedFile(); done(SyncFileItem::SoftError, tr("The file »%1« is currently in use").arg(_item->localName())); return; } propagator()->_journal->setDownloadInfo(_item->localName(), SyncJournalDb::DownloadInfo()); propagator()->_journal->commit(QStringLiteral("download file start2")); + if (_autoMergeResolved) { + const auto fn = propagator()->fullLocalPath(_item->localName()); + QString error; + if (!FileSystem::uncheckedRenameReplace(_autoMergedFileName, fn, &error)) { + qCWarning(lcPropagateDownload) << u"Installing automerged file failed:" << _autoMergedFileName << u"=>" << fn << u"with error:" << error; + removeAutoMergedFile(); + propagator()->_anotherSyncNeeded = true; + done(SyncFileItem::SoftError, error); + return; + } + _autoMergedFileName.clear(); + FileSystem::setFileHidden(fn, false); + propagator()->_anotherSyncNeeded = true; + } + done(isConflict ? SyncFileItem::Conflict : SyncFileItem::Success); // handle the special recall file @@ -730,6 +801,14 @@ void PropagateDownloadFile::updateMetadata(bool isConflict) } } +void PropagateDownloadFile::removeAutoMergedFile() +{ + if (!_autoMergedFileName.isEmpty()) { + QFile::remove(_autoMergedFileName); + _autoMergedFileName.clear(); + } +} + void PropagateDownloadFile::slotDownloadProgress(qint64 received, qint64) { if (!_job) @@ -745,6 +824,12 @@ void PropagateDownloadFile::abort(PropagatorJob::AbortType abortType) if (_job) { _job->abort(); } + if (_autoMergeJob) { + propagator()->_activeJobList.removeOne(this); + _autoMergeJob->deleteLater(); + _autoMergeJob.clear(); + } + removeAutoMergedFile(); if (abortType == AbortType::Asynchronous) { Q_EMIT abortFinished(); } diff --git a/src/libsync/propagatedownload.h b/src/libsync/propagatedownload.h index 0da3d4bcbb..b6f81bf33d 100644 --- a/src/libsync/propagatedownload.h +++ b/src/libsync/propagatedownload.h @@ -22,6 +22,8 @@ namespace OCC { +class ConflictAutoMerge; + /** * @brief The PropagateDownloadFile class * @ingroup libsync @@ -108,6 +110,7 @@ private Q_SLOTS: void transmissionChecksumValidated(CheckSums::Algorithm checksumType, const QByteArray &checksum); /// Called when the download's checksum computation is done void contentChecksumComputed(CheckSums::Algorithm checksumType, const QByteArray &checksum); + void autoMergeFinished(bool merged, const QString &mergedFileName); void downloadFinished(); /// Called when it's time to update the db metadata void updateMetadata(bool isConflict); @@ -118,12 +121,17 @@ private Q_SLOTS: private: void deleteExistingFolder(); + bool startAutoMerge(); + void removeAutoMergedFile(); qint64 _resumeStart; qint64 _downloadProgress; QPointer _job; + QPointer _autoMergeJob; QFile _tmpFile; bool _deleteExisting; + bool _autoMergeResolved = false; + QString _autoMergedFileName; ConflictRecord _conflictRecord; QElapsedTimer _stopwatch; diff --git a/translations/desktop_ar.ts b/translations/desktop_ar.ts index c4a5ab3401..df3bb5758b 100644 --- a/translations/desktop_ar.ts +++ b/translations/desktop_ar.ts @@ -1282,6 +1282,11 @@ Please consider removing this folder from the account and adding it again.Move remotely deleted files to the local trash bin instead of deleting them نقل الملفات المحذوفة عن بُعد إلى سلة المهملات المحلية بدلاً من حذفها + + + Automatically merge text file conflicts when possible + دمج تعارضات الملفات النصية تلقائيًا عند الإمكان + Edit Ignored Files diff --git a/translations/desktop_ca.ts b/translations/desktop_ca.ts index 41d9d304df..12e9116f43 100644 --- a/translations/desktop_ca.ts +++ b/translations/desktop_ca.ts @@ -1282,6 +1282,11 @@ Si us plau, considereu eliminar aquesta carpeta del compte i afegir-la de nou.Move remotely deleted files to the local trash bin instead of deleting them Mou els fitxers eliminats remotament a la paperera local en comptes d’eliminar-los definitivament. + + + Automatically merge text file conflicts when possible + Fusiona automàticament els conflictes de fitxers de text quan sigui possible + Edit Ignored Files diff --git a/translations/desktop_de.ts b/translations/desktop_de.ts index d5430109ba..a5141de627 100644 --- a/translations/desktop_de.ts +++ b/translations/desktop_de.ts @@ -1282,6 +1282,11 @@ Bitte entfernen Sie diesen Ordner aus dem Konto und legen Sie ihn erneut an.Move remotely deleted files to the local trash bin instead of deleting them Auf anderen Geräten gelöschte Dateien in den lokalen Papierkorb verschieben, anstatt sie zu löschen + + + Automatically merge text file conflicts when possible + Textdateikonflikte nach Möglichkeit automatisch zusammenführen + Edit Ignored Files diff --git a/translations/desktop_el.ts b/translations/desktop_el.ts index ec1723a212..02808e0ef4 100644 --- a/translations/desktop_el.ts +++ b/translations/desktop_el.ts @@ -1282,6 +1282,11 @@ Please consider removing this folder from the account and adding it again.Move remotely deleted files to the local trash bin instead of deleting them Μετακίνηση των απομακρυσμένα διαγραμμένων αρχείων στον τοπικό κάδο ανακύκλωσης αντί για διαγραφή τους + + + Automatically merge text file conflicts when possible + Αυτόματη συγχώνευση διενέξεων αρχείων κειμένου όταν είναι δυνατό + Edit Ignored Files @@ -3457,4 +3462,4 @@ Note that using any logging command line options will override the settings.Ορισμένες ρυθμίσεις διαμορφώθηκαν σε νεότερες εκδόσεις αυτής της εφαρμογής και χρησιμοποιούν λειτουργίες που δεν είναι διαθέσιμες σε αυτή την έκδοση - \ No newline at end of file + diff --git a/translations/desktop_en.ts b/translations/desktop_en.ts index 7950520f30..eba95deee9 100644 --- a/translations/desktop_en.ts +++ b/translations/desktop_en.ts @@ -1297,6 +1297,11 @@ Please consider removing this folder from the account and adding it again.Move remotely deleted files to the local trash bin instead of deleting them + + + Automatically merge text file conflicts when possible + Automatically merge text file conflicts when possible + Edit Ignored Files diff --git a/translations/desktop_fr.ts b/translations/desktop_fr.ts index 5c55c54913..53620cdb08 100644 --- a/translations/desktop_fr.ts +++ b/translations/desktop_fr.ts @@ -1282,6 +1282,11 @@ Veuillez envisager de supprimer ce dossier du compte et de l'ajouter à nou Move remotely deleted files to the local trash bin instead of deleting them Déplacer les fichiers distants qui ont été supprimés vers la corbeille locale au lieu de les supprimer définitivement + + + Automatically merge text file conflicts when possible + Fusionner automatiquement les conflits de fichiers texte lorsque possible + Edit Ignored Files diff --git a/translations/desktop_he.ts b/translations/desktop_he.ts index 7bffd5fedb..0be18ab71e 100644 --- a/translations/desktop_he.ts +++ b/translations/desktop_he.ts @@ -1282,6 +1282,11 @@ Please consider removing this folder from the account and adding it again.Move remotely deleted files to the local trash bin instead of deleting them העברת קבצים שנמחקו מרחוק לסל המיחזור המקומי במקום למחוק אותם + + + Automatically merge text file conflicts when possible + מיזוג אוטומטי של התנגשויות בקובצי טקסט כשאפשר + Edit Ignored Files diff --git a/translations/desktop_hu.ts b/translations/desktop_hu.ts index 2cd5d03f49..13d15a7a4c 100644 --- a/translations/desktop_hu.ts +++ b/translations/desktop_hu.ts @@ -1282,6 +1282,11 @@ Kérjük vegye fontolóra a mappa eltávolítását a fiókból és újbóli hoz Move remotely deleted files to the local trash bin instead of deleting them A távolról törölt fájlok áthelyezése a helyi lomtárba törlés helyett + + + Automatically merge text file conflicts when possible + Szövegfájl-ütközések automatikus egyesítése, amikor lehetséges + Edit Ignored Files diff --git a/translations/desktop_it.ts b/translations/desktop_it.ts index 5f60890e08..9a207f1f45 100644 --- a/translations/desktop_it.ts +++ b/translations/desktop_it.ts @@ -1284,6 +1284,11 @@ Si consiglia di rimuovere questa cartella dall'account e di aggiungerla nuo Move remotely deleted files to the local trash bin instead of deleting them Sposta i file cancellati da remoto nel cestino locale invece di eliminarli. + + + Automatically merge text file conflicts when possible + Unisci automaticamente i conflitti dei file di testo quando possibile + Edit Ignored Files diff --git a/translations/desktop_ja.ts b/translations/desktop_ja.ts index db94712093..ef09197cca 100644 --- a/translations/desktop_ja.ts +++ b/translations/desktop_ja.ts @@ -1282,6 +1282,11 @@ Please consider removing this folder from the account and adding it again.Move remotely deleted files to the local trash bin instead of deleting them リモートで削除されたファイルを、直接削除せずローカルのゴミ箱に移動する + + + Automatically merge text file conflicts when possible + 可能な場合はテキストファイルの競合を自動的にマージする + Edit Ignored Files diff --git a/translations/desktop_ko.ts b/translations/desktop_ko.ts index b83d8636c4..dec43b561d 100644 --- a/translations/desktop_ko.ts +++ b/translations/desktop_ko.ts @@ -1283,6 +1283,11 @@ Please consider removing this folder from the account and adding it again.Move remotely deleted files to the local trash bin instead of deleting them 원격에서 삭제된 파일을 삭제하는 대신 로컬 휴지통으로 이동 + + + Automatically merge text file conflicts when possible + 가능한 경우 텍스트 파일 충돌을 자동으로 병합 + Edit Ignored Files diff --git a/translations/desktop_lo.ts b/translations/desktop_lo.ts index 2390df52be..ca95ecec2b 100644 --- a/translations/desktop_lo.ts +++ b/translations/desktop_lo.ts @@ -1282,6 +1282,11 @@ Please consider removing this folder from the account and adding it again.Move remotely deleted files to the local trash bin instead of deleting them ຍ້າຍໄຟລ໌ທີ່ຖືກລຶບຈາກເຊີບເວີໄປໄວ້ໃນຖັງຂີ້ເຫຍື້ອໃນເຄື່ອງ ແທນທີ່ຈະລຶບຖິ້ມທັນທີ + + + Automatically merge text file conflicts when possible + ຮວມຂໍ້ຂັດແຍ່ງຂອງໄຟລ໌ຂໍ້ຄວາມໂດຍອັດຕະໂນມັດເມື່ອເປັນໄປໄດ້ + Edit Ignored Files diff --git a/translations/desktop_nl.ts b/translations/desktop_nl.ts index 4389b68583..dc9470737e 100644 --- a/translations/desktop_nl.ts +++ b/translations/desktop_nl.ts @@ -1282,6 +1282,11 @@ Overweeg om deze map uit het account te verwijderen en deze opnieuw toe te voege Move remotely deleted files to the local trash bin instead of deleting them Op afstand verwijderde bestanden naar de lokale prullenbak verplaatsen in plaats van ze te verwijderen + + + Automatically merge text file conflicts when possible + Tekstbestandsconflicten automatisch samenvoegen wanneer mogelijk + Edit Ignored Files diff --git a/translations/desktop_pl.ts b/translations/desktop_pl.ts index 4a71454703..874ebf600f 100644 --- a/translations/desktop_pl.ts +++ b/translations/desktop_pl.ts @@ -1280,6 +1280,11 @@ Rozważ usunięcie tego folderu z konta i dodanie go ponownie. Move remotely deleted files to the local trash bin instead of deleting them Przenoś zdalnie usunięte pliki do lokalnego kosza zamiast je usuwać + + + Automatically merge text file conflicts when possible + Automatycznie scalaj konflikty plików tekstowych, gdy to możliwe + Edit Ignored Files diff --git a/translations/desktop_pt.ts b/translations/desktop_pt.ts index a935356fa6..b8e7153219 100644 --- a/translations/desktop_pt.ts +++ b/translations/desktop_pt.ts @@ -1282,6 +1282,11 @@ Considere remover esta pasta da conta e adicioná-la novamente. Move remotely deleted files to the local trash bin instead of deleting them Mover ficheiros eliminados remotamente para o lixo local em vez de os eliminar + + + Automatically merge text file conflicts when possible + Unir automaticamente conflitos de ficheiros de texto quando possível + Edit Ignored Files diff --git a/translations/desktop_ru.ts b/translations/desktop_ru.ts index 806a460a8f..2cdf3c8e4b 100644 --- a/translations/desktop_ru.ts +++ b/translations/desktop_ru.ts @@ -1282,6 +1282,11 @@ Please consider removing this folder from the account and adding it again.Move remotely deleted files to the local trash bin instead of deleting them Перемещать файлы, удаленные через серверный доступ, в локальную корзину вместо их полного удаления + + + Automatically merge text file conflicts when possible + Автоматически объединять конфликты текстовых файлов, когда это возможно + Edit Ignored Files diff --git a/translations/desktop_sv.ts b/translations/desktop_sv.ts index 338d2dc6a5..c7cc7ddd54 100644 --- a/translations/desktop_sv.ts +++ b/translations/desktop_sv.ts @@ -1271,6 +1271,11 @@ Please consider removing this folder from the account and adding it again.Move remotely deleted files to the local trash bin instead of deleting them Flytta fjärrraderade filer till den lokala papperskorgen istället för att radera dem + + + Automatically merge text file conflicts when possible + Sammanfoga konflikter i textfiler automatiskt när det är möjligt + Edit Ignored Files