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), ×); 0345 #endif 0346 } 0347 }