• Skip to content
  • Skip to link menu
KDE 4.4 API Reference
  • KDE API Reference
  • KDE-PIM Libraries
  • Sitemap
  • Contact Us
 

akonadi

specialcollectionshelperjobs.cpp

00001 /*
00002     Copyright (c) 2009 Constantin Berzan <exit3219@gmail.com>
00003 
00004     This library is free software; you can redistribute it and/or modify it
00005     under the terms of the GNU Library General Public License as published by
00006     the Free Software Foundation; either version 2 of the License, or (at your
00007     option) any later version.
00008 
00009     This library is distributed in the hope that it will be useful, but WITHOUT
00010     ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
00011     FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Library General Public
00012     License for more details.
00013 
00014     You should have received a copy of the GNU Library General Public License
00015     along with this library; see the file COPYING.LIB.  If not, write to the
00016     Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
00017     02110-1301, USA.
00018 */
00019 
00020 #include "specialcollectionshelperjobs_p.h"
00021 
00022 #include "specialcollectionattribute_p.h"
00023 #include "specialcollections.h"
00024 
00025 #include <akonadi/agentinstance.h>
00026 #include <akonadi/agentinstancecreatejob.h>
00027 #include <akonadi/agentmanager.h>
00028 #include <akonadi/collectionfetchjob.h>
00029 #include <akonadi/collectionfetchscope.h>
00030 #include <akonadi/collectionmodifyjob.h>
00031 #include <akonadi/entitydisplayattribute.h>
00032 #include <akonadi/resourcesynchronizationjob.h>
00033 
00034 #include <KDebug>
00035 #include <KLocalizedString>
00036 #include <KStandardDirs>
00037 #include <kcoreconfigskeleton.h>
00038 
00039 #include <QtDBus/QDBusConnectionInterface>
00040 #include <QtDBus/QDBusInterface>
00041 #include <QtCore/QMetaMethod>
00042 #include <QtCore/QTime>
00043 #include <QtCore/QTimer>
00044 
00045 #define DBUS_SERVICE_NAME QLatin1String( "org.kde.pim.SpecialCollections" )
00046 #define LOCK_WAIT_TIMEOUT_SECONDS 3
00047 
00048 using namespace Akonadi;
00049 
00050 // convenient methods to get/set the default resource id
00051 static void setDefaultResourceId( KCoreConfigSkeleton *settings, const QString &value )
00052 {
00053   KConfigSkeletonItem *item = settings->findItem( QLatin1String( "DefaultResourceId" ) );
00054   Q_ASSERT( item );
00055   item->setProperty( value );
00056 }
00057 
00058 static QString defaultResourceId( KCoreConfigSkeleton *settings )
00059 {
00060   const KConfigSkeletonItem *item = settings->findItem( QLatin1String( "DefaultResourceId" ) );
00061   Q_ASSERT( item );
00062   return item->property().toString();
00063 }
00064 
00065 static QVariant::Type argumentType( const QMetaObject *mo, const QString &method )
00066 {
00067   QMetaMethod m;
00068   for ( int i = 0; i < mo->methodCount(); ++i ) {
00069     const QString signature = QString::fromLatin1( mo->method( i ).signature() );
00070     if ( signature.startsWith( method ) )
00071       m = mo->method( i );
00072   }
00073 
00074   if ( !m.signature() )
00075     return QVariant::Invalid;
00076 
00077   const QList<QByteArray> argTypes = m.parameterTypes();
00078   if ( argTypes.count() != 1 )
00079     return QVariant::Invalid;
00080 
00081   return QVariant::nameToType( argTypes.first() );
00082 }
00083 
00084 // ===================== ResourceScanJob ============================
00085 
00089 class Akonadi::ResourceScanJob::Private
00090 {
00091   public:
00092     Private( KCoreConfigSkeleton *settings, ResourceScanJob *qq );
00093 
00094     void fetchResult( KJob *job ); // slot
00095 
00096     ResourceScanJob *const q;
00097 
00098     // Input:
00099     QString mResourceId;
00100     KCoreConfigSkeleton *mSettings;
00101 
00102     // Output:
00103     Collection mRootCollection;
00104     Collection::List mSpecialCollections;
00105 };
00106 
00107 ResourceScanJob::Private::Private( KCoreConfigSkeleton *settings, ResourceScanJob *qq )
00108   : q( qq ), mSettings( settings )
00109 {
00110 }
00111 
00112 void ResourceScanJob::Private::fetchResult( KJob *job )
00113 {
00114   if ( job->error() ) {
00115     kWarning() << job->errorText();
00116     return;
00117   }
00118 
00119   CollectionFetchJob *fetchJob = qobject_cast<CollectionFetchJob *>( job );
00120   Q_ASSERT( fetchJob );
00121 
00122   Q_ASSERT( !mRootCollection.isValid() );
00123   Q_ASSERT( mSpecialCollections.isEmpty() );
00124   foreach ( const Collection &collection, fetchJob->collections() ) {
00125     if ( collection.parentCollection() == Collection::root() ) {
00126       if ( mRootCollection.isValid() )
00127         kWarning() << "Resource has more than one root collection. I don't know what to do.";
00128       else
00129         mRootCollection = collection;
00130     }
00131 
00132     if ( collection.hasAttribute<SpecialCollectionAttribute>() )
00133       mSpecialCollections.append( collection );
00134   }
00135 
00136   kDebug() << "Fetched root collection" << mRootCollection.id()
00137            << "and" << mSpecialCollections.count() << "local folders"
00138            << "(total" << fetchJob->collections().count() << "collections).";
00139 
00140   if ( !mRootCollection.isValid() ) {
00141     q->setError( Unknown );
00142     q->setErrorText( i18n( "Could not fetch root collection of resource %1.", mResourceId ) );
00143     q->commit();
00144     return;
00145   }
00146 
00147   // We are done!
00148   q->commit();
00149 }
00150 
00151 
00152 
00153 ResourceScanJob::ResourceScanJob( const QString &resourceId, KCoreConfigSkeleton *settings, QObject *parent )
00154   : TransactionSequence( parent ),
00155     d( new Private( settings, this ) )
00156 {
00157   setResourceId( resourceId );
00158 }
00159 
00160 ResourceScanJob::~ResourceScanJob()
00161 {
00162   delete d;
00163 }
00164 
00165 QString ResourceScanJob::resourceId() const
00166 {
00167   return d->mResourceId;
00168 }
00169 
00170 void ResourceScanJob::setResourceId( const QString &resourceId )
00171 {
00172   d->mResourceId = resourceId;
00173 }
00174 
00175 Collection ResourceScanJob::rootResourceCollection() const
00176 {
00177   return d->mRootCollection;
00178 }
00179 
00180 Collection::List ResourceScanJob::specialCollections() const
00181 {
00182   return d->mSpecialCollections;
00183 }
00184 
00185 void ResourceScanJob::doStart()
00186 {
00187   if ( d->mResourceId.isEmpty() ) {
00188     kError() << "No resource ID given.";
00189     setError( Job::Unknown );
00190     setErrorText( i18n( "No resource ID given." ) );
00191     emitResult();
00192     TransactionSequence::doStart(); // HACK: probable misuse of TransactionSequence API.
00193                                     // Calling commit() here hangs :-/
00194     return;
00195   }
00196 
00197   CollectionFetchJob *fetchJob = new CollectionFetchJob( Collection::root(),
00198                                                          CollectionFetchJob::Recursive, this );
00199   fetchJob->fetchScope().setResource( d->mResourceId );
00200   connect( fetchJob, SIGNAL( result( KJob* ) ), this, SLOT( fetchResult( KJob* ) ) );
00201 }
00202 
00203 
00204 // ===================== DefaultResourceJob ============================
00205 
00209 class Akonadi::DefaultResourceJobPrivate
00210 {
00211   public:
00212     DefaultResourceJobPrivate( KCoreConfigSkeleton *settings, DefaultResourceJob *qq );
00213 
00214     void tryFetchResource();
00215     void resourceCreateResult( KJob *job ); // slot
00216     void resourceSyncResult( KJob *job ); // slot
00217     void collectionFetchResult( KJob *job ); // slot
00218     void collectionModifyResult( KJob *job ); // slot
00219 
00220     DefaultResourceJob *const q;
00221     KCoreConfigSkeleton *mSettings;
00222     bool mResourceWasPreexisting;
00223     int mPendingModifyJobs;
00224     QString mDefaultResourceType;
00225     QVariantMap mDefaultResourceOptions;
00226     QList<QByteArray> mKnownTypes;
00227     QMap<QByteArray, QString> mNameForTypeMap;
00228     QMap<QByteArray, QString> mIconForTypeMap;
00229 };
00230 
00231 DefaultResourceJobPrivate::DefaultResourceJobPrivate( KCoreConfigSkeleton *settings, DefaultResourceJob *qq )
00232   : q( qq ),
00233     mSettings( settings ),
00234     mResourceWasPreexisting( true /* for safety, so as not to accidentally delete data */ ),
00235     mPendingModifyJobs( 0 )
00236 {
00237 }
00238 
00239 void DefaultResourceJobPrivate::tryFetchResource()
00240 {
00241   // Get the resourceId from config. Another instance might have changed it in the meantime.
00242   mSettings->readConfig();
00243 
00244   const QString resourceId = defaultResourceId( mSettings );
00245 
00246   kDebug() << "Read defaultResourceId" << resourceId << "from config.";
00247 
00248   const AgentInstance resource = AgentManager::self()->instance( resourceId );
00249   if ( resource.isValid() ) {
00250     // The resource exists; scan it.
00251     mResourceWasPreexisting = true;
00252     kDebug() << "Found resource" << resourceId;
00253     q->setResourceId( resourceId );
00254     q->ResourceScanJob::doStart();
00255   } else {
00256     // Create the resource.
00257     mResourceWasPreexisting = false;
00258     kDebug() << "Creating maildir resource.";
00259     const AgentType type = AgentManager::self()->type( mDefaultResourceType );
00260     AgentInstanceCreateJob *job = new AgentInstanceCreateJob( type, q );
00261     QObject::connect( job, SIGNAL( result( KJob* ) ), q, SLOT( resourceCreateResult( KJob* ) ) );
00262     job->start(); // non-Akonadi::Job
00263   }
00264 }
00265 
00266 void DefaultResourceJobPrivate::resourceCreateResult( KJob *job )
00267 {
00268   if ( job->error() ) {
00269     kWarning() << job->errorText();
00270     //fail( i18n( "Failed to create the default resource (%1).", job->errorString() ) );
00271     q->setError( job->error() );
00272     q->setErrorText( job->errorText() );
00273     q->emitResult();
00274     return;
00275   }
00276 
00277   AgentInstance agent;
00278 
00279   // Get the resource instance.
00280   {
00281     AgentInstanceCreateJob *createJob = qobject_cast<AgentInstanceCreateJob*>( job );
00282     Q_ASSERT( createJob );
00283     agent = createJob->instance();
00284     setDefaultResourceId( mSettings, agent.identifier() );
00285     kDebug() << "Created maildir resource with id" << defaultResourceId( mSettings );
00286   }
00287 
00288   const QString defaultId = defaultResourceId( mSettings );
00289 
00290   // Configure the resource.
00291   {
00292     agent.setName( mDefaultResourceOptions.value( QLatin1String( "Name" ) ).toString() );
00293 
00294     QDBusInterface conf( QString::fromLatin1( "org.freedesktop.Akonadi.Resource." ) + defaultId,
00295                          QString::fromLatin1( "/Settings" ), QString() );
00296 
00297     if( ! conf.isValid() ) {
00298       q->setError( -1 );
00299       q->setErrorText( i18n("Invalid resource identifier '%1'", defaultId) );
00300       q->emitResult();
00301       return;
00302     }
00303                          
00304     QMapIterator<QString, QVariant> it( mDefaultResourceOptions );
00305     while ( it.hasNext() ) {
00306       it.next();
00307 
00308       if ( it.key() == QLatin1String( "Name" ) )
00309         continue;
00310 
00311       const QString methodName = QString::fromLatin1( "set%1" ).arg( it.key() );
00312       const QVariant::Type argType = argumentType( conf.metaObject(), methodName );
00313       if ( argType == QVariant::Invalid ) {
00314         q->setError( Job::Unknown );
00315         q->setErrorText( i18n( "Failed to configure default resource via D-Bus." ) );
00316         q->commit();
00317         return;
00318       }
00319 
00320       QDBusReply<void> reply = conf.call( methodName, it.value() );
00321       if ( !reply.isValid() ) {
00322         q->setError( Job::Unknown );
00323         q->setErrorText( i18n( "Failed to configure default resource via D-Bus." ) );
00324         q->commit();
00325         return;
00326       }
00327     }
00328 
00329     agent.reconfigure();
00330   }
00331 
00332   // Sync the resource.
00333   {
00334     ResourceSynchronizationJob *syncJob = new ResourceSynchronizationJob( agent, q );
00335     QObject::connect( syncJob, SIGNAL( result( KJob* ) ), q, SLOT( resourceSyncResult( KJob* ) ) );
00336     syncJob->start(); // non-Akonadi
00337   }
00338 }
00339 
00340 void DefaultResourceJobPrivate::resourceSyncResult( KJob *job )
00341 {
00342   if ( job->error() ) {
00343     kWarning() << job->errorText();
00344     //fail( i18n( "ResourceSynchronizationJob failed (%1).", job->errorString() ) );
00345     return;
00346   }
00347 
00348   // Fetch the collections of the resource.
00349   kDebug() << "Fetching maildir collections.";
00350   CollectionFetchJob *fetchJob = new CollectionFetchJob( Collection::root(), CollectionFetchJob::Recursive, q );
00351   fetchJob->fetchScope().setResource( defaultResourceId( mSettings ) );
00352   QObject::connect( fetchJob, SIGNAL( result( KJob* ) ), q, SLOT( collectionFetchResult( KJob* ) ) );
00353 }
00354 
00355 void DefaultResourceJobPrivate::collectionFetchResult( KJob *job )
00356 {
00357   if ( job->error() ) {
00358     kWarning() << job->errorText();
00359     //fail( i18n( "Failed to fetch the root maildir collection (%1).", job->errorString() ) );
00360     return;
00361   }
00362 
00363   CollectionFetchJob *fetchJob = qobject_cast<CollectionFetchJob *>( job );
00364   Q_ASSERT( fetchJob );
00365 
00366   const Collection::List collections = fetchJob->collections();
00367   kDebug() << "Fetched" << collections.count() << "collections.";
00368 
00369   // Find the root maildir collection.
00370   Collection::List toRecover;
00371   Collection resourceCollection;
00372   foreach ( const Collection &collection, collections ) {
00373     if ( collection.parentCollection() == Collection::root() ) {
00374       resourceCollection = collection;
00375       toRecover.append( collection );
00376       break;
00377     }
00378   }
00379 
00380   if ( !resourceCollection.isValid() ) {
00381     q->setError( Job::Unknown );
00382     q->setErrorText( i18n( "Failed to fetch the resource collection." ) );
00383     q->commit();
00384     return;
00385   }
00386 
00387   // Find all children of the resource collection.
00388   foreach ( const Collection &collection, collections ) {
00389     if ( collection.parentCollection() == resourceCollection ) {
00390       toRecover.append( collection );
00391     }
00392   }
00393 
00394   QHash<QString, QByteArray> typeForName;
00395   foreach ( const QByteArray &type, mKnownTypes ) {
00396     const QString displayName = mNameForTypeMap.value( type );
00397     typeForName[ displayName ] = type;
00398   }
00399 
00400   // These collections have been created by the maildir resource, when it
00401   // found the folders on disk. So give them the necessary attributes now.
00402   Q_ASSERT( mPendingModifyJobs == 0 );
00403   foreach ( Collection collection, toRecover ) {          // krazy:exclude=foreach
00404 
00405     // Find the type for the collection.
00406     QByteArray type;
00407     QString name = collection.name();
00408     if ( collection.hasAttribute<EntityDisplayAttribute>() )
00409       name = collection.attribute<EntityDisplayAttribute>()->displayName();
00410     if ( typeForName.contains( name ) )
00411       type = typeForName[ name ];
00412 
00413     if ( !type.isEmpty() ) {
00414       kDebug() << "Recovering collection" << name;
00415       setCollectionAttributes( collection, type, mNameForTypeMap, mIconForTypeMap );
00416 
00417       CollectionModifyJob *modifyJob = new CollectionModifyJob( collection, q );
00418       QObject::connect( modifyJob, SIGNAL( result( KJob* ) ), q, SLOT( collectionModifyResult( KJob* ) ) );
00419       mPendingModifyJobs++;
00420     } else {
00421       kDebug() << "Searching for names: " << typeForName.keys();
00422       kDebug() << "Unknown collection name" << name << "-- not recovering.";
00423     }
00424   }
00425 
00426   if ( mPendingModifyJobs == 0 )
00427     q->commit();
00428 }
00429 
00430 void DefaultResourceJobPrivate::collectionModifyResult( KJob *job )
00431 {
00432   if ( job->error() ) {
00433     kWarning() << job->errorText();
00434     //fail( i18n( "Failed to modify the root maildir collection (%1).", job->errorString() ) );
00435     return;
00436   }
00437 
00438   Q_ASSERT( mPendingModifyJobs > 0 );
00439   mPendingModifyJobs--;
00440   kDebug() << "pendingModifyJobs now" << mPendingModifyJobs;
00441   if ( mPendingModifyJobs == 0 ) {
00442     // Write the updated config.
00443     kDebug() << "Writing defaultResourceId" << defaultResourceId( mSettings ) << "to config.";
00444     mSettings->writeConfig();
00445 
00446     // Scan the resource.
00447     q->setResourceId( defaultResourceId( mSettings ) );
00448     q->ResourceScanJob::doStart();
00449   }
00450 }
00451 
00452 
00453 
00454 DefaultResourceJob::DefaultResourceJob( KCoreConfigSkeleton *settings, QObject *parent )
00455   : ResourceScanJob( QString(), settings, parent ),
00456     d( new DefaultResourceJobPrivate( settings, this ) )
00457 {
00458 }
00459 
00460 DefaultResourceJob::~DefaultResourceJob()
00461 {
00462   delete d;
00463 }
00464 
00465 void DefaultResourceJob::setDefaultResourceType( const QString &type )
00466 {
00467   d->mDefaultResourceType = type;
00468 }
00469 
00470 void DefaultResourceJob::setDefaultResourceOptions( const QVariantMap &options )
00471 {
00472   d->mDefaultResourceOptions = options;
00473 }
00474 
00475 void DefaultResourceJob::setTypes( const QList<QByteArray> &types )
00476 {
00477   d->mKnownTypes = types;
00478 }
00479 
00480 void DefaultResourceJob::setNameForTypeMap( const QMap<QByteArray, QString> &map )
00481 {
00482   d->mNameForTypeMap = map;
00483 }
00484 
00485 void DefaultResourceJob::setIconForTypeMap( const QMap<QByteArray, QString> &map )
00486 {
00487   d->mIconForTypeMap = map;
00488 }
00489 
00490 void DefaultResourceJob::doStart()
00491 {
00492   d->tryFetchResource();
00493 }
00494 
00495 void DefaultResourceJob::slotResult( KJob *job )
00496 {
00497   if ( job->error() ) {
00498     kWarning() << job->errorText();
00499     // Do some cleanup.
00500     if ( !d->mResourceWasPreexisting ) {
00501       // We only removed the resource instance if we have created it.
00502       // Otherwise we might lose the user's data.
00503       const AgentInstance resource = AgentManager::self()->instance( defaultResourceId( d->mSettings ) );
00504       kDebug() << "Removing resource" << resource.identifier();
00505       AgentManager::self()->removeInstance( resource );
00506     }
00507   }
00508 
00509   TransactionSequence::slotResult( job );
00510 }
00511 
00512 // ===================== GetLockJob ============================
00513 
00514 class Akonadi::GetLockJob::Private
00515 {
00516   public:
00517     Private( GetLockJob *qq );
00518 
00519     void doStart(); // slot
00520     void serviceOwnerChanged( const QString &name, const QString &oldOwner,
00521                               const QString &newOwner ); // slot
00522     void timeout(); // slot
00523 
00524     GetLockJob *const q;
00525     QTimer *mSafetyTimer;
00526 };
00527 
00528 GetLockJob::Private::Private( GetLockJob *qq )
00529   : q( qq ),
00530     mSafetyTimer( 0 )
00531 {
00532 }
00533 
00534 void GetLockJob::Private::doStart()
00535 {
00536   // Just doing registerService() and checking its return value is not sufficient,
00537   // since we may *already* own the name, and then registerService() returns true.
00538 
00539   QDBusConnection bus = QDBusConnection::sessionBus();
00540   const bool alreadyLocked = bus.interface()->isServiceRegistered( DBUS_SERVICE_NAME );
00541   const bool gotIt = bus.registerService( DBUS_SERVICE_NAME );
00542 
00543   if ( gotIt && !alreadyLocked ) {
00544     //kDebug() << "Got lock immediately.";
00545     q->emitResult();
00546   } else {
00547     //kDebug() << "Waiting for lock.";
00548     connect( QDBusConnection::sessionBus().interface(), SIGNAL( serviceOwnerChanged( QString, QString, QString ) ),
00549              q, SLOT( serviceOwnerChanged( QString, QString, QString ) ) );
00550 
00551     mSafetyTimer = new QTimer( q );
00552     mSafetyTimer->setSingleShot( true );
00553     mSafetyTimer->setInterval( LOCK_WAIT_TIMEOUT_SECONDS * 1000 );
00554     mSafetyTimer->start();
00555     connect( mSafetyTimer, SIGNAL( timeout() ), q, SLOT( timeout() ) );
00556   }
00557 }
00558 
00559 void GetLockJob::Private::serviceOwnerChanged( const QString &name, const QString &oldOwner, const QString &newOwner )
00560 {
00561   Q_UNUSED( oldOwner );
00562 
00563   if ( name == DBUS_SERVICE_NAME && newOwner.isEmpty() ) {
00564     const bool gotIt = QDBusConnection::sessionBus().registerService( DBUS_SERVICE_NAME );
00565     if ( gotIt ) {
00566       mSafetyTimer->stop();
00567       q->emitResult();
00568     }
00569   }
00570 }
00571 
00572 void GetLockJob::Private::timeout()
00573 {
00574   kWarning() << "Timeout trying to get lock.";
00575   q->setError( Job::Unknown );
00576   q->setErrorText( i18n( "Timeout trying to get lock." ) );
00577   q->emitResult();
00578 }
00579 
00580 
00581 GetLockJob::GetLockJob( QObject *parent )
00582   : KJob( parent ),
00583     d( new Private( this ) )
00584 {
00585 }
00586 
00587 GetLockJob::~GetLockJob()
00588 {
00589   delete d;
00590 }
00591 
00592 void GetLockJob::start()
00593 {
00594   QTimer::singleShot( 0, this, SLOT( doStart() ) );
00595 }
00596 
00597 void Akonadi::setCollectionAttributes( Akonadi::Collection &collection, const QByteArray &type,
00598                                        const QMap<QByteArray, QString> &nameForType,
00599                                        const QMap<QByteArray, QString> &iconForType )
00600 {
00601   {
00602     EntityDisplayAttribute *attr = new EntityDisplayAttribute;
00603     attr->setIconName( iconForType.value( type ) );
00604     attr->setDisplayName( nameForType.value( type ) );
00605     collection.addAttribute( attr );
00606   }
00607 
00608   {
00609     SpecialCollectionAttribute *attr = new SpecialCollectionAttribute;
00610     attr->setCollectionType( type );
00611     collection.addAttribute( attr );
00612   }
00613 }
00614 
00615 bool Akonadi::releaseLock()
00616 {
00617   return QDBusConnection::sessionBus().unregisterService( DBUS_SERVICE_NAME );
00618 }
00619 
00620 #include "specialcollectionshelperjobs_p.moc"

akonadi

Skip menu "akonadi"
  • Main Page
  • Modules
  • Namespace List
  • Class Hierarchy
  • Alphabetical List
  • Class List
  • File List
  • Namespace Members
  • Class Members
  • Related Pages

KDE-PIM Libraries

Skip menu "KDE-PIM Libraries"
  • akonadi
  •   contact
  •   kmime
  • kabc
  • kblog
  • kcal
  • kholidays
  • kimap
  • kioslave
  •   imap4
  •   mbox
  •   nntp
  • kldap
  • kmime
  • kontactinterface
  • kpimidentities
  • kpimtextedit
  •   richtextbuilders
  • kpimutils
  • kresources
  • ktnef
  • kxmlrpcclient
  • mailtransport
  • microblog
  • qgpgme
  • syndication
  •   atom
  •   rdf
  •   rss2
Generated for KDE-PIM Libraries by doxygen 1.6.1
This website is maintained by Adriaan de Groot and Allen Winter.
KDE® and the K Desktop Environment® logo are registered trademarks of KDE e.V. | Legal