File indexing completed on 2024-05-12 05:12:41

0001 /*
0002  * Copyright (C) 2017 Jonathan Marten <jjm@keelhaul.me.uk>
0003  *
0004  * This program is free software; you can redistribute it and/or modify
0005  * it under the terms of the GNU General Public License as published by
0006  * the Free Software Foundation; either version 2 of the License, or
0007  * (at your option) any later version.
0008  *
0009  * This program is distributed in the hope that it will be useful,
0010  * but WITHOUT ANY WARRANTY; without even the implied warranty of
0011  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
0012  * GNU General Public License for more details.
0013  *
0014  * You should have received a copy of the GNU General Public License along
0015  * with this program; if not, write to the Free Software Foundation, Inc.,
0016  * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
0017  *
0018  */
0019 
0020 #include "dumpcommand.h"
0021 
0022 #include <iostream>
0023 #ifdef Q_OS_UNIX
0024 #include <utime.h>
0025 #endif
0026 
0027 #include <qdir.h>
0028 #include <qfile.h>
0029 #include <qmimetype.h>
0030 #include <qmimedatabase.h>
0031 
0032 #include <klocalizedstring.h>
0033 
0034 #include <Akonadi/ItemFetchScope>
0035 #include <Akonadi/RecursiveItemFetchJob>
0036 #include <Akonadi/TagFetchJob>
0037 #include <Akonadi/TagFetchScope>
0038 
0039 #include "commandfactory.h"
0040 #include "errorreporter.h"
0041 #include "collectionpathjob.h"
0042 #include "collectionresolvejob.h"
0043 
0044 using namespace Akonadi;
0045 
0046 
0047 DEFINE_COMMAND("dump", DumpCommand, I18N_NOOP("Dump a collection to a directory structure"));
0048 
0049 
0050 DumpCommand::DumpCommand(QObject *parent)
0051     : AbstractCommand(parent)
0052 {
0053 }
0054 
0055 
0056 void DumpCommand::setupCommandOptions(QCommandLineParser *parser)
0057 {
0058     addOptionsOption(parser);
0059     parser->addOption(QCommandLineOption((QStringList() << "m" << "maildir"), i18n("Dump email messages in maildir directory structure")));
0060     parser->addOption(QCommandLineOption((QStringList() << "a" << "akonadi-categories"), i18n("Dump items with Akonadi category URLs, otherwise text names")));
0061     parser->addOption(QCommandLineOption((QStringList() << "f" << "force"), i18n("Operate even if destination directory is not empty")));
0062     addDryRunOption(parser);
0063 
0064     parser->addPositionalArgument("collection", i18nc("@info:shell", "The collection to dump: an ID, path or Akonadi URL"));
0065     parser->addPositionalArgument("directory", i18nc("@info:shell", "The destination directory to dump to"));
0066 }
0067 
0068 
0069 int DumpCommand::initCommand(QCommandLineParser *parser)
0070 {
0071     const QStringList args = parser->positionalArguments();
0072     if (!checkArgCount(args, 1, i18nc("@info:shell", "No collection specified"))) return InvalidUsage;
0073     if (!checkArgCount(args, 2, i18nc("@info:shell", "No destination directory specified"))) return InvalidUsage;
0074 
0075     if (!getCommonOptions(parser)) return InvalidUsage;
0076     mMaildir = parser->isSet("maildir");
0077     mAkonadiCategories = parser->isSet("akonadi-categories");
0078 
0079     const QString collectionArg = args.at(0);
0080     if (!getResolveJob(collectionArg)) return (InvalidUsage);
0081 
0082     mDirectoryArg = args.at(1);
0083     QDir dir(mDirectoryArg);
0084     if (!dir.exists())
0085     {
0086         emit error(i18nc("@info:shell", "Directory '%1' not found or not a directory", mDirectoryArg));
0087         return (InvalidUsage);
0088     }
0089 
0090     mDirectoryArg = dir.canonicalPath();
0091     if (!parser->isSet("force") &&
0092         !dir.entryList(QDir::AllEntries|QDir::Hidden|QDir::System|QDir::NoDotAndDotDot).isEmpty())
0093     {
0094         emit error(i18nc("@info:shell", "Directory '%1' is not empty (use '-f' to force operation)", mDirectoryArg));
0095         return (InvalidUsage);
0096     }
0097 
0098     return (NoError);
0099 }
0100 
0101 
0102 void DumpCommand::start()
0103 {
0104     connect(resolveJob(), &KJob::result, this, &DumpCommand::onCollectionFetched);
0105     resolveJob()->start();
0106 }
0107 
0108 
0109 void DumpCommand::onCollectionFetched(KJob *job)
0110 {
0111     if (!checkJobResult(job)) return;
0112     CollectionResolveJob *res = resolveJob();
0113     Q_ASSERT(job==res);
0114 
0115     // only attempt item listing if collection has non-collection content MIME types
0116     QStringList contentMimeTypes = res->collection().contentMimeTypes();
0117     contentMimeTypes.removeAll(Collection::mimeType());
0118     if (contentMimeTypes.isEmpty())
0119     {
0120         ErrorReporter::fatal(i18nc("@info:shell", "Collection %1 cannot contain items",
0121                                    res->formattedCollectionName()));
0122         return;
0123     }
0124 
0125     RecursiveItemFetchJob *fetchJob = new RecursiveItemFetchJob(res->collection(), QStringList(), this);
0126     fetchJob->fetchScope().setFetchModificationTime(true);
0127     fetchJob->fetchScope().fetchAllAttributes(false);
0128     fetchJob->fetchScope().fetchFullPayload(true);
0129     // Need this so that parentCollection() will be valid
0130     fetchJob->fetchScope().setAncestorRetrieval(ItemFetchScope::Parent);
0131 
0132     connect(fetchJob, &KJob::result, this, &DumpCommand::onItemsFetched);
0133     fetchJob->start();
0134 }
0135 
0136 
0137 void DumpCommand::onItemsFetched(KJob *job)
0138 {
0139     if (!checkJobResult(job)) return;
0140     RecursiveItemFetchJob *fetchJob = qobject_cast<RecursiveItemFetchJob *>(job);
0141     Q_ASSERT(fetchJob!=NULL);
0142 
0143     Item::List items = fetchJob->items();
0144     if (items.isEmpty())
0145     {
0146         ErrorReporter::fatal(i18nc("@info:shell", "Collection %1 contains no items",
0147                                    resolveJob()->formattedCollectionName()));
0148         emit finished(RuntimeError);
0149     }
0150 
0151     std::sort(items.begin(), items.end());      // for predictable ordering
0152     mItemList = items;                  // save items as fetched
0153 
0154     TagFetchJob *tagJob = new TagFetchJob(this);
0155     tagJob->fetchScope().setFetchIdOnly(false);
0156 
0157     connect(tagJob, &KJob::result, this, &DumpCommand::onTagsFetched);
0158     tagJob->start();
0159 }
0160 
0161 
0162 void DumpCommand::onTagsFetched(KJob *job)
0163 {
0164     if (!checkJobResult(job)) return;
0165     TagFetchJob *tagJob = qobject_cast<TagFetchJob *>(job);
0166     Q_ASSERT(tagJob!=NULL);
0167 
0168     mTagList = tagJob->tags();              // save tags as fetched
0169                             // now can start processing
0170     QMetaObject::invokeMethod(this, "processNextItem", Qt::QueuedConnection);
0171 }
0172 
0173 
0174 void DumpCommand::processNextItem()
0175 {
0176     if (mItemList.isEmpty())                // everything done
0177     {
0178         emit finished(NoError);
0179         return;
0180     }
0181 
0182     CollectionPathJob *job = new CollectionPathJob(mItemList.first().parentCollection());
0183     connect(job, &KJob::result, this, &DumpCommand::onParentPathFetched);
0184     job->start();
0185 }
0186 
0187 
0188 void DumpCommand::onParentPathFetched(KJob *job)
0189 {
0190     if (!checkJobResult(job)) return;
0191     CollectionPathJob *pathJob = qobject_cast<CollectionPathJob *>(job);
0192     Q_ASSERT(pathJob!=NULL);
0193 
0194     writeItem(mItemList.first(), pathJob->collectionPath());
0195     mItemList.removeFirst();
0196 
0197     QMetaObject::invokeMethod(this, "processNextItem", Qt::QueuedConnection);
0198 }
0199 
0200 
0201 void DumpCommand::writeItem(const Akonadi::Item &item, const QString &parent)
0202 {
0203     if (!item.hasPayload())
0204     {
0205         ErrorReporter::warning(i18nc("@info:shell", "Item '%1' has no payload", item.id()));
0206         return;
0207     }
0208 
0209     const QString mimeType = item.mimeType();
0210     QMimeDatabase db;
0211     QMimeType mime = db.mimeTypeForName(mimeType);
0212     QString ext = mime.preferredSuffix();
0213     // No extension is registered for contact groups
0214     if (ext.isEmpty() && mimeType=="application/x-vnd.kde.contactgroup") ext = "group";
0215 
0216     QString destDir = mDirectoryArg+"/";
0217     if (mMaildir && mimeType=="message/rfc822")     // an email message,
0218     {                           // replicate maildir structure
0219         QStringList dirs = parent.split('/');
0220         Q_ASSERT(!dirs.isEmpty());
0221         foreach (const QString &dir, dirs)
0222         {
0223             destDir += "."+dir+".directory/";
0224         }
0225         destDir += "cur";
0226     }
0227     else                        // not an email message
0228     {                           // just use plain directories
0229         destDir += parent;
0230     }
0231 
0232     QDir dir(destDir);                  // containing directory
0233     if (!dir.exists())                  // ensure that it exists
0234     {
0235         if (!mCreatedDirs.contains(destDir))        // if not already reported
0236         {                       // in dry run mode
0237             std::cout << "mkdir       " << qPrintable(destDir) << std::endl;
0238         }
0239 
0240         if (isDryRun())                 // not actually doing anything
0241         {
0242             mCreatedDirs.append(destDir);       // so that only reported once
0243         }
0244         else
0245         {
0246             const QString dirName = dir.dirName();  // this is awkward, why doesn't
0247             QDir parentDir(destDir+"/..");      // Qt just have QDir::mkpath()
0248             if (!parentDir.mkpath(dirName))     // with no subdirectory name?
0249             {
0250                 ErrorReporter::fatal(i18nc("@info:shell", "Cannot create directory '%1/%2'",
0251                                            parentDir.canonicalPath(), dirName));
0252                 emit finished(RuntimeError);
0253                 return;
0254             }
0255         }
0256     }
0257 
0258     QString destPath = destDir+"/";         // make path of item file
0259     destPath += QString("%1").arg(item.id(), 8, 10, QLatin1Char('0'));
0260     if (!ext.isEmpty()) destPath += '.'+ext;
0261 
0262     std::cout << qPrintable(QString("%1").arg(item.id(), -8)) << " -> " << qPrintable(destPath) << std::endl;
0263     if (!isDryRun())
0264     {
0265         QByteArray data = item.payloadData();       // the raw item payload
0266         if (mimeType=="text/directory" || mimeType=="text/vcard")
0267         {                       // need to fix up tags?
0268             data.replace('\r', "");         // remove trailing CR from lines
0269 
0270             // Rewrite the "CATGEORIES" line to use the external tag names
0271             // as opposed to the internal Akonadi URLs.  Also hide any
0272             // "UID" lines so as not to confuse the receiver.
0273 
0274             bool changed = false;           // not yet, anyway
0275             const QList<QByteArray> oldLines = data.split('\n');
0276             QStringList newLines;
0277             foreach (const QByteArray &line, oldLines)
0278             {
0279                 if (line.startsWith("UID:"))        // hide internal details
0280                 {
0281                     newLines.append(QByteArray("X-AKONADI-")+line);
0282                     newLines.append(QString("X-AKONADI-ITEM:%1").arg(item.id()));
0283                     changed = true;
0284                     continue;
0285                 }
0286                             // ignore old data from these
0287                 if (line.startsWith("X-AKONADI-UID:")) continue;
0288                 if (line.startsWith("X-AKONADI-ITEM:")) continue;
0289 
0290                 if (!line.startsWith("CATEGORIES:"))
0291                 {                   // not interested in this line
0292                     newLines.append(QString::fromUtf8(line));
0293                     continue;
0294                 }
0295 
0296                 if (mAkonadiCategories)         // don't want to rewrite
0297                 {
0298                     newLines.append(QString::fromUtf8(line));
0299                     continue;
0300                 }
0301 
0302                 QStringList oldCats = QString::fromUtf8(line.constData()).mid(11).split(',');
0303                 QStringList newCats;
0304                 foreach (const QString &cat, oldCats)
0305                 {
0306                     QString newCat = cat;
0307                     const Akonadi::Tag::Id catId = Akonadi::Tag::fromUrl(QUrl(cat)).id();
0308                             // category tag ID from URL
0309                     foreach (const Akonadi::Tag &tag, mTagList)
0310                     {
0311                         if (tag.id()==catId)
0312                         {
0313                             newCat = tag.name();
0314                             changed = true;
0315                             break;
0316                         }
0317                     }
0318 
0319                     newCats.append(newCat);
0320                 }
0321 
0322                 QString newLine = QString("CATEGORIES:")+newCats.join(",");
0323                 newLines.append(newLine);       // format new "CATGEORIES" line
0324             }
0325                             // only if something changed
0326             if (changed) data = newLines.join("\n").toUtf8();
0327         }
0328 
0329         QFile file(destPath);
0330         if (!file.open(QIODevice::WriteOnly|QIODevice::Truncate))
0331         {
0332             ErrorReporter::fatal(i18nc("@info:shell", "Cannot save file '%1'", destPath));
0333             emit finished(RuntimeError);
0334             return;
0335         }
0336 
0337         file.write(data);               // output the raw payload
0338         file.close();
0339 
0340 #ifdef Q_OS_UNIX
0341         struct utimbuf times;               // stamp with item modification time
0342         times.modtime = item.modificationTime().toTime_t();
0343         times.actime = time(NULL);
0344         utime(QFile::encodeName(destPath), &times);
0345 #endif
0346     }
0347 }