Commit 44d38d36 authored by Grégory Oestreicher's avatar Grégory Oestreicher

Add simple conflict handling

The conflict dialog will only be shown for local update / remote update
conflicts. For cases where one side deleted the item while it was updated
on the other, the deleting side loses the conflict automatically to prevent
data loss.

BUG: 335090
BUG: 338570
FIXED-IN: 4.14.10
parent ab0e8110
......@@ -18,6 +18,7 @@
#include "davitemdeletejob.h"
#include "davitemfetchjob.h"
#include "davmanager.h"
#include <kio/deletejob.h>
......@@ -39,6 +40,16 @@ void DavItemDeleteJob::start()
connect( job, SIGNAL(result(KJob*)), this, SLOT(davJobFinished(KJob*)) );
}
DavItem DavItemDeleteJob::freshItem() const
{
return mFreshItem;
}
int DavItemDeleteJob::freshResponseCode() const
{
return mFreshResponseCode;
}
void DavItemDeleteJob::davJobFinished( KJob *job )
{
KIO::DeleteJob *deleteJob = qobject_cast<KIO::DeleteJob*>( job );
......@@ -60,6 +71,25 @@ void DavItemDeleteJob::davJobFinished( KJob *job )
setErrorText( i18n( "There was a problem with the request. The item has not been deleted from the server.\n"
"%1 (%2).", err, responseCode ) );
}
if ( hasConflict() ) {
DavItemFetchJob *fetchJob = new DavItemFetchJob( mUrl, mItem );
connect( fetchJob, SIGNAL(result(KJob*)), this, SLOT(conflictingItemFetched(KJob*)) );
fetchJob->start();
return;
}
}
emitResult();
}
void DavItemDeleteJob::conflictingItemFetched( KJob *job )
{
DavItemFetchJob *fetchJob = qobject_cast<DavItemFetchJob*>( job );
mFreshResponseCode = fetchJob->latestResponseCode();
if ( !job->error() ) {
mFreshItem = fetchJob->item();
}
emitResult();
......
......@@ -45,12 +45,25 @@ class DavItemDeleteJob : public DavJobBase
*/
virtual void start();
/**
* Returns the item that triggered the conflict, if any.
*/
DavItem freshItem() const;
/**
* Returns the response code we got when fetching the fresh item.
*/
int freshResponseCode() const;
private Q_SLOTS:
void davJobFinished( KJob* );
void conflictingItemFetched( KJob* );
private:
DavUtils::DavUrl mUrl;
DavItem mItem;
DavItem mFreshItem;
int mFreshResponseCode;
};
#endif
......@@ -41,7 +41,7 @@ static QString etagFromHeaders( const QString &headers )
DavItemFetchJob::DavItemFetchJob( const DavUtils::DavUrl &url, const DavItem &item, QObject *parent )
: KJob( parent ), mUrl( url ), mItem( item )
: DavJobBase( parent ), mUrl( url ), mItem( item )
{
}
......@@ -67,11 +67,12 @@ DavItem DavItemFetchJob::item() const
void DavItemFetchJob::davJobFinished( KJob *job )
{
KIO::StoredTransferJob *storedJob = qobject_cast<KIO::StoredTransferJob*>( job );
const int responseCode = storedJob->queryMetaData( QLatin1String("responsecode") ).isEmpty() ?
0 :
storedJob->queryMetaData( QLatin1String("responsecode") ).toInt();
setLatestResponseCode( responseCode );
if ( storedJob->error() ) {
const int responseCode = storedJob->queryMetaData( QLatin1String("responsecode") ).isEmpty() ?
0 :
storedJob->queryMetaData( QLatin1String("responsecode") ).toInt();
QString err;
if ( storedJob->error() != KIO::ERR_SLAVE_DEFINED )
......
......@@ -20,14 +20,13 @@
#define DAVITEMFETCHJOB_H
#include "davitem.h"
#include "davjobbase.h"
#include "davutils.h"
#include <kjob.h>
/**
* @short A job that fetches a DAV item from the DAV server.
*/
class DavItemFetchJob : public KJob
class DavItemFetchJob : public DavJobBase
{
Q_OBJECT
......
......@@ -25,7 +25,7 @@
#include <klocale.h>
DavItemModifyJob::DavItemModifyJob( const DavUtils::DavUrl &url, const DavItem &item, QObject *parent )
: DavJobBase( parent ), mUrl( url ), mItem( item )
: DavJobBase( parent ), mUrl( url ), mItem( item ), mFreshResponseCode( 0 )
{
}
......@@ -50,6 +50,16 @@ DavItem DavItemModifyJob::item() const
return mItem;
}
DavItem DavItemModifyJob::freshItem() const
{
return mFreshItem;
}
int DavItemModifyJob::freshResponseCode() const
{
return mFreshResponseCode;
}
void DavItemModifyJob::davJobFinished( KJob *job )
{
KIO::StoredTransferJob *storedJob = qobject_cast<KIO::StoredTransferJob*>( job );
......@@ -70,7 +80,15 @@ void DavItemModifyJob::davJobFinished( KJob *job )
setErrorText( i18n( "There was a problem with the request. The item was not modified on the server.\n"
"%1 (%2).", err, responseCode ) );
emitResult();
if ( hasConflict() ) {
DavItemFetchJob *fetchJob = new DavItemFetchJob( mUrl, mItem );
connect( fetchJob, SIGNAL(result(KJob*)), this, SLOT(conflictingItemFetched(KJob*)) );
fetchJob->start();
}
else {
emitResult();
}
return;
}
......@@ -111,3 +129,15 @@ void DavItemModifyJob::itemRefreshed( KJob *job )
emitResult();
}
void DavItemModifyJob::conflictingItemFetched( KJob *job )
{
DavItemFetchJob *fetchJob = qobject_cast<DavItemFetchJob*>( job );
mFreshResponseCode = fetchJob->latestResponseCode();
if ( !job->error() ) {
mFreshItem = fetchJob->item();
}
emitResult();
}
......@@ -50,13 +50,26 @@ class DavItemModifyJob : public DavJobBase
*/
DavItem item() const;
/**
* Returns the item that triggered the conflict, if any.
*/
DavItem freshItem() const;
/**
* Returns the response code we got when fetching the fresh item.
*/
int freshResponseCode() const;
private Q_SLOTS:
void davJobFinished( KJob* );
void itemRefreshed( KJob* );
void conflictingItemFetched( KJob* );
private:
DavUtils::DavUrl mUrl;
DavItem mItem;
DavItem mFreshItem;
int mFreshResponseCode;
};
#endif
......@@ -78,6 +78,11 @@ bool DavJobBase::canRetryLater() const
return ret;
}
bool DavJobBase::hasConflict() const
{
return latestResponseCode() == 412;
}
void DavJobBase::setLatestResponseCode( unsigned int code )
{
mLatestResponseCode = code;
......
......@@ -60,6 +60,11 @@ class DavJobBase : public KJob
*/
bool canRetryLater() const;
/**
* Check if the job failed because of a conflict
*/
bool hasConflict() const;
protected:
/**
* Sets the latest response code received.
......
......@@ -423,7 +423,7 @@ void DavGroupwareResource::itemRemoved( const Akonadi::Item &item )
extraItems << extraItem;
}
}
if ( extraItems.isEmpty() ) {
// Urrrr?
// Well, just delete the item.
......@@ -892,12 +892,17 @@ void DavGroupwareResource::onItemChangedFinished( KJob *job )
if ( modifyJob->error() ) {
kError() << "Error when uploading item:" << modifyJob->error() << modifyJob->errorString();
if ( modifyJob->canRetryLater() ) {
if ( modifyJob->hasConflict() ) {
handleConflict( item, dependentItems, modifyJob->freshItem(), isRemoval, modifyJob->freshResponseCode() );
}
else if ( modifyJob->canRetryLater() ) {
retryAfterFailure(modifyJob->errorString());
}
else {
cancelTask( i18n( "Unable to change item: %1", modifyJob->errorString() ) );
}
return;
}
......@@ -928,7 +933,38 @@ void DavGroupwareResource::onItemChangedFinished( KJob *job )
dependentItems[i].setRemoteRevision( davItem.etag() );
mEtagCache.setEtag( dependentItems.at( i ).remoteId(), davItem.etag() );
}
Akonadi::ItemModifyJob *j = new Akonadi::ItemModifyJob( dependentItems );
j->setIgnorePayload( true );
}
}
}
void DavGroupwareResource::onDeletedItemRecreated(KJob* job)
{
const DavItemCreateJob *createJob = qobject_cast<DavItemCreateJob*>( job );
const DavItem davItem = createJob->item();
Akonadi::Item item = createJob->property( "item" ).value<Akonadi::Item>();
Akonadi::Item::List dependentItems = createJob->property( "dependentItems" ).value<Akonadi::Item::List>();
if ( davItem.etag().isEmpty() ) {
const DavUtils::DavUrl davUrl = Settings::self()->davUrlFromCollectionUrl( item.parentCollection().remoteId(), item.remoteId() );
DavItemFetchJob *fetchJob = new DavItemFetchJob( davUrl, davItem );
fetchJob->setProperty( "item", QVariant::fromValue( item ) );
fetchJob->setProperty( "dependentItems", QVariant::fromValue( dependentItems ) );
connect( fetchJob, SIGNAL(result(KJob*)), SLOT(onItemRefreshed(KJob*)) );
fetchJob->start();
} else {
item.setRemoteRevision( davItem.etag() );
mEtagCache.setEtag( davItem.url(), davItem.etag() );
changeCommitted( item );
if ( !dependentItems.isEmpty() ) {
for ( int i = 0; i < dependentItems.size(); ++i ) {
dependentItems[i].setRemoteRevision( davItem.etag() );
mEtagCache.setEtag( dependentItems.at( i ).remoteId(), davItem.etag() );
}
Akonadi::ItemModifyJob *j = new Akonadi::ItemModifyJob( dependentItems );
j->setIgnorePayload( true );
}
......@@ -940,14 +976,15 @@ void DavGroupwareResource::onItemRemovedFinished( KJob *job )
if ( job->error() ) {
const DavItemDeleteJob *deleteJob = qobject_cast<DavItemDeleteJob*>( job );
if ( deleteJob->canRetryLater() ) {
if ( deleteJob->hasConflict() ) {
// Use a shortcut here as we don't show a conflict dialog to the user.
handleConflict( Akonadi::Item(), Akonadi::Item::List(), deleteJob->freshItem(), true, 0 );
} else if ( deleteJob->canRetryLater() ) {
retryAfterFailure(job->errorString());
}
else {
} else {
cancelTask( i18n( "Unable to remove item: %1", job->errorString() ) );
}
}
else {
} else {
Akonadi::Item item = job->property( "item" ).value<Akonadi::Item>();
Akonadi::Collection collection = job->property( "collection" ).value<Akonadi::Collection>();
mItemsRidCache[collection.remoteId()].remove( item.remoteId() );
......@@ -966,6 +1003,129 @@ void DavGroupwareResource::onEtagChanged(const QString& itemUrl, const QString&
mEtagCache.setEtag( itemUrl, etag );
}
void DavGroupwareResource::handleConflict( const Item& lI, const Item::List& localDependentItems, const DavItem& rI, bool isLocalRemoval, int responseCode )
{
Akonadi::Item localItem( lI );
Akonadi::Item remoteItem, tmpRemoteItem; // The tmp* vars are here to store the result of the parseDavData() call
Akonadi::Item::List remoteDependentItems, tmpRemoteDependentItems; // as we have no idea which item triggered the conflict.
kDebug() << "Fresh response code is" << responseCode;
bool isRemoteRemoval = ( responseCode == 404 || responseCode == 410 );
if ( !isRemoteRemoval ) {
if ( !DavUtils::parseDavData( rI, tmpRemoteItem, tmpRemoteDependentItems ) ) {
// TODO: set a more correct error message here
cancelTask( i18n( "Unable to change item: %1", QLatin1String( "conflict resolution failed" ) ) );
return;
// TODO: we can end up here if the remote item was deleted
}
// Now try to find the item that really triggered the conflict
Akonadi::Item::List allRemoteItems; allRemoteItems << tmpRemoteItem << tmpRemoteDependentItems;
foreach ( const Akonadi::Item &tmpItem, allRemoteItems ) {
if ( tmpItem.payloadData() != localItem.payloadData() ) {
if ( remoteItem.isValid() ) {
// Oops, we can only manage one changed item at this stage, sorry...
// TODO: make this translatable
cancelTask( i18n( "Unable to change item: %1", QLatin1String( "more than one item was changed in the backend" ) ) );
return;
}
remoteItem = tmpItem;
} else {
remoteDependentItems << tmpItem;
}
}
}
if ( isLocalRemoval ) {
// TODO: implement with the configurable strategy
/*
* Here by default we don't delete an event that was modified in the backend, and
* instead we just abort the current task.
* Also, trigger an immediate sync to refresh the item.
*/
kDebug() << "Local removal conflict";
// TODO: make this translatable
cancelTask( i18n( "Unable to remove item: %1", QLatin1String( "it was changed in the backend in the meantime" ) ) );
synchronize();
} else if ( isRemoteRemoval ) {
// TODO: implement with the configurable strategy
/*
* Here also it is a bit tricky to clear the item in the local cache as the resource
* will not get notified if the user chooses to delete the item and abandon the local
* modification. For the time being let's just re-upload the changed item.
*/
kDebug() << "Remote removal conflict";
Akonadi::Collection collection = localItem.parentCollection();
DavItem davItem = DavUtils::createDavItem( localItem, collection, localDependentItems );
QString urlStr = localItem.remoteId();
if ( urlStr.contains( QChar( '#' ) ) )
urlStr.truncate( urlStr.indexOf( QChar( '#' ) ) );
davItem.setUrl( urlStr );
const DavUtils::DavUrl davUrl = Settings::self()->davUrlFromCollectionUrl( collection.remoteId(), urlStr );
DavItemCreateJob *job = new DavItemCreateJob( davUrl, davItem );
job->setProperty( "item", QVariant::fromValue( localItem ) );
job->setProperty( "dependentItems", QVariant::fromValue( localDependentItems ) );
connect( job, SIGNAL(result(KJob*)), SLOT(onDeletedItemRecreated(KJob*)) );
job->start();
} else {
const QString remoteEtag = rI.etag();
localItem.setRemoteRevision( remoteEtag );
changeCommitted( localItem );
// Update the ETag cache in all cases as the new ETag will have to be used
// later for any update or deletion
mEtagCache.setEtag( rI.url(), remoteEtag );
// The first step is to fire a first modify job that will replace the item currently
// in the local cache with the one that was found in the backend.
Akonadi::Item updatedItem( localItem );
updatedItem.setPayloadFromData( remoteItem.payloadData() );
updatedItem.setRemoteRevision( remoteEtag );
Akonadi::ItemModifyJob *j = new Akonadi::ItemModifyJob( updatedItem );
j->setIgnorePayload( false );
j->start();
// So now we have in the cache what's in the backend but the user is not aware
// that behind the scenes something terrible is happening. Well, nearly...
// To notify him of this, and due to the way the conflict handler works, we have
// to re-attempt a modification to revert the modify job that was just fired.
// So yes, we are effectively re-submitting the client-provided content, but
// with a revision that will trigger the conflict dialog.
// The only problem is that the user will see that we update the item before
// the conflict dialog has time to display (if it's not behind the application
// window).
localItem.setRevision( 0 );
j = new Akonadi::ItemModifyJob( localItem );
j->setIgnorePayload( false );
connect( j, SIGNAL(result(KJob*)), this, SLOT(onConflictModifyJobFinished(KJob*)) );
j->start();
// Hopefully for the dependent items everything will be fine. Right?
// Not so sure in fact.
if ( !remoteDependentItems.isEmpty() ) {
for ( int i = 0; i < remoteDependentItems.size(); ++i ) {
remoteDependentItems[i].setRemoteRevision( remoteEtag );
mEtagCache.setEtag( remoteDependentItems.at( i ).remoteId(), remoteEtag );
}
Akonadi::ItemModifyJob *j = new Akonadi::ItemModifyJob( remoteDependentItems );
j->setIgnorePayload( true );
}
}
}
void DavGroupwareResource::onConflictModifyJobFinished( KJob *job )
{
Akonadi::ItemModifyJob *j = qobject_cast<Akonadi::ItemModifyJob*>( job );
if ( j->error() ) {
kError() << "Conflict update failed: " << job->errorText();
// TODO: what do we do now? We just committed an item that's in a weird state...
}
}
bool DavGroupwareResource::configurationIsValid()
{
if ( Settings::self()->configuredDavUrls().empty() ) {
......
......@@ -101,10 +101,18 @@ class DavGroupwareResource : public Akonadi::ResourceBase,
void onCollectionDiscovered( int protocol, const QString &collectionUrl, const QString &configuredUrl );
void onEtagChanged( const QString &itemUrl, const QString &etag );
void onConflictModifyJobFinished( KJob *job );
void onDeletedItemRecreated( KJob *job );
private:
void doItemChange( const Akonadi::Item &item, const Akonadi::Item::List &dependentItems = Akonadi::Item::List() );
void doItemRemoval( const Akonadi::Item &item );
void handleConflict( const Akonadi::Item &localItem,
const Akonadi::Item::List &localDependentItems,
const DavItem &remoteItem,
bool isLocalRemoval,
int responseCode
);
bool configurationIsValid();
void retryAfterFailure(const QString &errorMessage);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment