File indexing completed on 2023-12-03 10:57:12
0001 /* -*- c++ -*- 0002 SPDX-FileCopyrightText: 2000 Daniel M. Duley <mosfet@kde.org> 0003 SPDX-FileCopyrightText: 2021 Martin Tobias Holmedahl Sandsmark 0004 SPDX-FileCopyrightText: 2022 Méven Car <meven.car@kdemail.net> 0005 0006 SPDX-License-Identifier: BSD-2-Clause 0007 */ 0008 0009 #include "krecentdocument.h" 0010 0011 #include "kiocoredebug.h" 0012 0013 #include <QCoreApplication> 0014 #include <QDir> 0015 #include <QDomDocument> 0016 #include <QLockFile> 0017 #include <QMimeDatabase> 0018 #include <QRegularExpression> 0019 #include <QSaveFile> 0020 #include <QXmlStreamWriter> 0021 0022 #include <KConfigGroup> 0023 #include <KService> 0024 #include <KSharedConfig> 0025 0026 static QString xbelPath() 0027 { 0028 return QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + QLatin1String("/recently-used.xbel"); 0029 } 0030 0031 static inline QString stringForRecentDocumentGroup(int val) 0032 { 0033 switch (val) { 0034 case KRecentDocument::RecentDocumentGroup::Development: 0035 return QStringLiteral("Development"); 0036 case KRecentDocument::RecentDocumentGroup::Office: 0037 return QStringLiteral("Office"); 0038 case KRecentDocument::RecentDocumentGroup::Database: 0039 return QStringLiteral("Database"); 0040 case KRecentDocument::RecentDocumentGroup::Email: 0041 return QStringLiteral("Email"); 0042 case KRecentDocument::RecentDocumentGroup::Presentation: 0043 return QStringLiteral("Presentation"); 0044 case KRecentDocument::RecentDocumentGroup::Spreadsheet: 0045 return QStringLiteral("Spreadsheet"); 0046 case KRecentDocument::RecentDocumentGroup::WordProcessor: 0047 return QStringLiteral("WordProcessor"); 0048 case KRecentDocument::RecentDocumentGroup::Graphics: 0049 return QStringLiteral("Graphics"); 0050 case KRecentDocument::RecentDocumentGroup::TextEditor: 0051 return QStringLiteral("TextEditor"); 0052 case KRecentDocument::RecentDocumentGroup::Viewer: 0053 return QStringLiteral("Viewer"); 0054 case KRecentDocument::RecentDocumentGroup::Archive: 0055 return QStringLiteral("Archive"); 0056 case KRecentDocument::RecentDocumentGroup::Multimedia: 0057 return QStringLiteral("Multimedia"); 0058 case KRecentDocument::RecentDocumentGroup::Audio: 0059 return QStringLiteral("Audio"); 0060 case KRecentDocument::RecentDocumentGroup::Video: 0061 return QStringLiteral("Video"); 0062 case KRecentDocument::RecentDocumentGroup::Photo: 0063 return QStringLiteral("Photo"); 0064 case KRecentDocument::RecentDocumentGroup::Application: 0065 return QStringLiteral("Application"); 0066 }; 0067 Q_UNREACHABLE(); 0068 } 0069 0070 static KRecentDocument::RecentDocumentGroups groupsForMimeType(const QString mimeType) 0071 { 0072 // simple heuristics, feel free to expand as needed 0073 if (mimeType.startsWith(QStringLiteral("image/"))) { 0074 return KRecentDocument::RecentDocumentGroups{KRecentDocument::RecentDocumentGroup::Graphics}; 0075 } 0076 if (mimeType.startsWith(QStringLiteral("video/"))) { 0077 return KRecentDocument::RecentDocumentGroups{KRecentDocument::RecentDocumentGroup::Video}; 0078 } 0079 if (mimeType.startsWith(QStringLiteral("audio/"))) { 0080 return KRecentDocument::RecentDocumentGroups{KRecentDocument::RecentDocumentGroup::Audio}; 0081 } 0082 return KRecentDocument::RecentDocumentGroups{}; 0083 } 0084 0085 // Marginally more readable to avoid all the QStringLiteral() spam below 0086 static const QLatin1String xbelTag("xbel"); 0087 static const QLatin1String versionAttribute("version"); 0088 static const QLatin1String expectedVersion("1.0"); 0089 0090 static const QLatin1String applicationsBookmarkTag("bookmark:applications"); 0091 static const QLatin1String applicationBookmarkTag("bookmark:application"); 0092 static const QLatin1String bookmarkTag("bookmark"); 0093 static const QLatin1String infoTag("info"); 0094 static const QLatin1String metadataTag("metadata"); 0095 static const QLatin1String mimeTypeTag("mime:mime-type"); 0096 static const QLatin1String bookmarkGroups("bookmark:groups"); 0097 static const QLatin1String bookmarkGroup("bookmark:group"); 0098 0099 static const QLatin1String nameAttribute("name"); 0100 static const QLatin1String countAttribute("count"); 0101 static const QLatin1String modifiedAttribute("modified"); 0102 static const QLatin1String visitedAttribute("visited"); 0103 static const QLatin1String hrefAttribute("href"); 0104 static const QLatin1String addedAttribute("added"); 0105 static const QLatin1String execAttribute("exec"); 0106 static const QLatin1String ownerAttribute("owner"); 0107 static const QLatin1String ownerValue("http://freedesktop.org"); 0108 static const QLatin1String typeAttribute("type"); 0109 0110 static bool removeOldestEntries(int &maxEntries) 0111 { 0112 QFile input(xbelPath()); 0113 if (!input.exists()) { 0114 return true; 0115 } 0116 0117 // Won't help for GTK applications and whatnot, but we can be good citizens ourselves 0118 QLockFile lockFile(xbelPath() + QLatin1String(".lock")); 0119 lockFile.setStaleLockTime(0); 0120 if (!lockFile.tryLock(100)) { // give it 100ms 0121 qCWarning(KIO_CORE) << "Failed to lock recently used"; 0122 return false; 0123 } 0124 0125 if (!input.open(QIODevice::ReadOnly)) { 0126 qCWarning(KIO_CORE) << "Failed to open existing recently used" << input.errorString(); 0127 return false; 0128 } 0129 0130 QDomDocument document; 0131 document.setContent(&input); 0132 input.close(); 0133 0134 auto xbelTags = document.elementsByTagName(xbelTag); 0135 if (xbelTags.length() != 1) { 0136 qCWarning(KIO_CORE) << "Invalid Xbel file" << input.errorString(); 0137 return false; 0138 } 0139 auto xbelElement = document.elementsByTagName(xbelTag).item(0); 0140 auto bookmarkList = xbelElement.childNodes(); 0141 if (bookmarkList.length() <= maxEntries) { 0142 return true; 0143 } 0144 0145 QMultiMap<QDateTime, QDomNode> bookmarksByModifiedDate; 0146 for (int i = 0; i < bookmarkList.length(); ++i) { 0147 const auto node = bookmarkList.item(i); 0148 const auto modifiedString = node.attributes().namedItem(modifiedAttribute); 0149 const auto modifiedTime = QDateTime::fromString(modifiedString.nodeValue(), Qt::ISODate); 0150 0151 bookmarksByModifiedDate.insert(modifiedTime, node); 0152 } 0153 0154 int i = 0; 0155 // entries are traversed in ascending key order 0156 for (auto entry = bookmarksByModifiedDate.keyValueBegin(); entry != bookmarksByModifiedDate.keyValueEnd(); ++entry) { 0157 // only keep the maxEntries last nodes 0158 if (bookmarksByModifiedDate.size() - i > maxEntries) { 0159 xbelElement.removeChild(entry->second); 0160 } 0161 ++i; 0162 } 0163 0164 if (input.open(QIODevice::WriteOnly) && input.write(document.toByteArray(2)) != -1) { 0165 input.close(); 0166 return true; 0167 } 0168 input.close(); 0169 return false; 0170 } 0171 0172 static bool addToXbel(const QUrl &url, const QString &desktopEntryName, KRecentDocument::RecentDocumentGroups groups, int maxEntries, bool ignoreHidden) 0173 { 0174 if (!QDir().mkpath(QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation))) { 0175 qCWarning(KIO_CORE) << "Could not create GenericDataLocation"; 0176 return false; 0177 } 0178 0179 // Won't help for GTK applications and whatnot, but we can be good citizens ourselves 0180 QLockFile lockFile(xbelPath() + QLatin1String(".lock")); 0181 lockFile.setStaleLockTime(0); 0182 if (!lockFile.tryLock(100)) { // give it 100ms 0183 qCWarning(KIO_CORE) << "Failed to lock recently used"; 0184 return false; 0185 } 0186 0187 QByteArray existingContent; 0188 QFile input(xbelPath()); 0189 if (input.open(QIODevice::ReadOnly)) { 0190 existingContent = input.readAll(); 0191 } else if (!input.exists()) { // That it doesn't exist is a very uncommon case 0192 qCDebug(KIO_CORE) << input.fileName() << "does not exist, creating new"; 0193 } else { 0194 qCWarning(KIO_CORE) << "Failed to open existing recently used" << input.errorString(); 0195 return false; 0196 } 0197 0198 QXmlStreamReader xml(existingContent); 0199 0200 xml.readNextStartElement(); 0201 if (!existingContent.isEmpty()) { 0202 if (xml.name().isEmpty() || xml.name() != xbelTag || !xml.attributes().hasAttribute(versionAttribute)) { 0203 qCDebug(KIO_CORE) << "The recently-used.xbel is not an XBEL file, overwriting."; 0204 } else if (xml.attributes().value(versionAttribute) != expectedVersion) { 0205 qCDebug(KIO_CORE) << "The recently-used.xbel is not an XBEL version 1.0 file but has version: " << xml.attributes().value(versionAttribute) 0206 << ", overwriting."; 0207 } 0208 } 0209 0210 QSaveFile outputFile(xbelPath()); 0211 if (!outputFile.open(QIODevice::WriteOnly)) { 0212 qCWarning(KIO_CORE) << "Failed to recently-used.xbel for writing:" << outputFile.errorString(); 0213 return false; 0214 } 0215 0216 QXmlStreamWriter output(&outputFile); 0217 output.setAutoFormatting(true); 0218 output.setAutoFormattingIndent(2); 0219 output.writeStartDocument(); 0220 output.writeStartElement(xbelTag); 0221 0222 output.writeAttribute(versionAttribute, expectedVersion); 0223 output.writeNamespace(QStringLiteral("http://www.freedesktop.org/standards/desktop-bookmarks"), QStringLiteral("bookmark")); 0224 output.writeNamespace(QStringLiteral("http://www.freedesktop.org/standards/shared-mime-info"), QStringLiteral("mime")); 0225 0226 const QString newUrl = QString::fromLatin1(url.toEncoded()); 0227 const QString currentTimestamp = QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs).chopped(1) + QStringLiteral("000Z"); 0228 0229 auto addApplicationTag = [&output, desktopEntryName, currentTimestamp, url]() { 0230 output.writeEmptyElement(applicationBookmarkTag); 0231 output.writeAttribute(nameAttribute, desktopEntryName); 0232 auto service = KService::serviceByDesktopName(desktopEntryName); 0233 QString exec; 0234 bool shouldAddParameter = true; 0235 if (service) { 0236 exec = service->exec(); 0237 exec.replace(QLatin1String(" %U"), QLatin1String(" %u")); 0238 exec.replace(QLatin1String(" %F"), QLatin1String(" %f")); 0239 shouldAddParameter = !exec.contains(QLatin1String(" %u")) && !exec.contains(QLatin1String(" %f")); 0240 } else { 0241 exec = QCoreApplication::instance()->applicationName(); 0242 } 0243 if (shouldAddParameter) { 0244 if (url.isLocalFile()) { 0245 exec += QLatin1String(" %f"); 0246 } else { 0247 exec += QLatin1String(" %u"); 0248 } 0249 } 0250 output.writeAttribute(execAttribute, exec); 0251 output.writeAttribute(modifiedAttribute, currentTimestamp); 0252 output.writeAttribute(countAttribute, QStringLiteral("1")); 0253 }; 0254 0255 bool foundExistingApp = false; 0256 bool inRightBookmark = false; 0257 bool foundMatchingBookmark = false; 0258 bool firstBookmark = true; 0259 int nbEntries = 0; 0260 while (!xml.atEnd() && !xml.hasError()) { 0261 if (xml.readNext() == QXmlStreamReader::EndElement && xml.name() == xbelTag) { 0262 break; 0263 } 0264 switch (xml.tokenType()) { 0265 case QXmlStreamReader::StartElement: { 0266 const QStringView tagName = xml.qualifiedName(); 0267 QXmlStreamAttributes attributes = xml.attributes(); 0268 0269 if (tagName == bookmarkTag) { 0270 foundExistingApp = false; 0271 firstBookmark = false; 0272 0273 const auto hrefValue = attributes.value(hrefAttribute); 0274 inRightBookmark = hrefValue == newUrl; 0275 0276 // remove hidden files if some were added by GTK 0277 if (ignoreHidden && QRegularExpression(QStringLiteral("/\\.")).match(hrefValue).hasMatch()) { 0278 xml.skipCurrentElement(); 0279 break; 0280 } 0281 0282 if (inRightBookmark) { 0283 foundMatchingBookmark = true; 0284 0285 QXmlStreamAttributes newAttributes; 0286 for (const QXmlStreamAttribute &old : attributes) { 0287 if (old.name() == modifiedAttribute) { 0288 continue; 0289 } 0290 if (old.name() == visitedAttribute) { 0291 continue; 0292 } 0293 newAttributes.append(old); 0294 } 0295 newAttributes.append(modifiedAttribute, currentTimestamp); 0296 newAttributes.append(visitedAttribute, currentTimestamp); 0297 attributes = newAttributes; 0298 } 0299 0300 nbEntries += 1; 0301 } 0302 0303 else if (inRightBookmark && tagName == applicationBookmarkTag && attributes.value(nameAttribute) == desktopEntryName) { 0304 // case found right bookmark and same application 0305 const int count = attributes.value(countAttribute).toInt(); 0306 0307 QXmlStreamAttributes newAttributes; 0308 for (const QXmlStreamAttribute &old : std::as_const(attributes)) { 0309 if (old.name() == countAttribute) { 0310 continue; 0311 } 0312 if (old.name() == modifiedAttribute) { 0313 continue; 0314 } 0315 newAttributes.append(old); 0316 } 0317 newAttributes.append(modifiedAttribute, currentTimestamp); 0318 newAttributes.append(countAttribute, QString::number(count + 1)); 0319 attributes = newAttributes; 0320 0321 foundExistingApp = true; 0322 } 0323 0324 output.writeStartElement(tagName.toString()); 0325 output.writeAttributes(attributes); 0326 break; 0327 } 0328 case QXmlStreamReader::EndElement: { 0329 const QStringView tagName = xml.qualifiedName(); 0330 if (tagName == applicationsBookmarkTag && inRightBookmark && !foundExistingApp) { 0331 // add an application to the applications already known for the bookmark 0332 addApplicationTag(); 0333 } 0334 output.writeEndElement(); 0335 break; 0336 } 0337 case QXmlStreamReader::Characters: 0338 if (xml.isCDATA()) { 0339 output.writeCDATA(xml.text().toString()); 0340 } else { 0341 output.writeCharacters(xml.text().toString()); 0342 } 0343 break; 0344 case QXmlStreamReader::Comment: 0345 output.writeComment(xml.text().toString()); 0346 break; 0347 case QXmlStreamReader::EndDocument: 0348 qCWarning(KIO_CORE) << "Malformed, got end document before end of xbel" << xml.tokenString() << url; 0349 return false; 0350 default: 0351 qCWarning(KIO_CORE) << "unhandled token" << xml.tokenString() << url; 0352 break; 0353 } 0354 } 0355 0356 if (!foundMatchingBookmark) { 0357 // must create new bookmark tag 0358 if (firstBookmark) { 0359 output.writeCharacters(QStringLiteral("\n")); 0360 } 0361 output.writeCharacters(QStringLiteral(" ")); 0362 output.writeStartElement(bookmarkTag); 0363 0364 output.writeAttribute(hrefAttribute, newUrl); 0365 output.writeAttribute(addedAttribute, currentTimestamp); 0366 output.writeAttribute(modifiedAttribute, currentTimestamp); 0367 output.writeAttribute(visitedAttribute, currentTimestamp); 0368 0369 { 0370 QMimeDatabase mimeDb; 0371 const auto fileMime = mimeDb.mimeTypeForUrl(url).name(); 0372 0373 output.writeStartElement(infoTag); 0374 output.writeStartElement(metadataTag); 0375 output.writeAttribute(ownerAttribute, ownerValue); 0376 0377 output.writeEmptyElement(mimeTypeTag); 0378 output.writeAttribute(typeAttribute, fileMime); 0379 0380 // write groups metadata 0381 if (groups.isEmpty()) { 0382 groups = groupsForMimeType(fileMime); 0383 } 0384 if (!groups.isEmpty()) { 0385 output.writeStartElement(bookmarkGroups); 0386 for (const auto &group : std::as_const(groups)) { 0387 output.writeTextElement(bookmarkGroup, stringForRecentDocumentGroup(group)); 0388 } 0389 // bookmarkGroups 0390 output.writeEndElement(); 0391 } 0392 0393 { 0394 output.writeStartElement(applicationsBookmarkTag); 0395 addApplicationTag(); 0396 // end applicationsBookmarkTag 0397 output.writeEndElement(); 0398 } 0399 0400 // end infoTag 0401 output.writeEndElement(); 0402 // end metadataTag 0403 output.writeEndElement(); 0404 } 0405 0406 // end bookmarkTag 0407 output.writeEndElement(); 0408 } 0409 0410 // end xbelTag 0411 output.writeEndElement(); 0412 0413 // end document 0414 output.writeEndDocument(); 0415 0416 if (outputFile.commit()) { 0417 lockFile.unlock(); 0418 // tolerate 10 more entries than threshold to limit overhead of cleaning old data 0419 return nbEntries - maxEntries > 10 || removeOldestEntries(maxEntries); 0420 } 0421 return false; 0422 } 0423 0424 static QMap<QUrl, QDateTime> xbelRecentlyUsedList() 0425 { 0426 QMap<QUrl, QDateTime> ret; 0427 QFile input(xbelPath()); 0428 if (!input.open(QIODevice::ReadOnly)) { 0429 qCWarning(KIO_CORE) << "Failed to open" << input.fileName() << input.errorString(); 0430 return ret; 0431 } 0432 0433 QXmlStreamReader xml(&input); 0434 xml.readNextStartElement(); 0435 if (xml.name() != QLatin1String("xbel") || xml.attributes().value(QLatin1String("version")) != QLatin1String("1.0")) { 0436 qCWarning(KIO_CORE) << "The file is not an XBEL version 1.0 file."; 0437 return ret; 0438 } 0439 0440 while (!xml.atEnd() && !xml.hasError()) { 0441 if (xml.readNext() != QXmlStreamReader::StartElement || xml.name() != QLatin1String("bookmark")) { 0442 continue; 0443 } 0444 0445 const auto urlString = xml.attributes().value(QLatin1String("href")); 0446 if (urlString.isEmpty()) { 0447 qCInfo(KIO_CORE) << "Invalid bookmark in" << input.fileName(); 0448 continue; 0449 } 0450 const QUrl url = QUrl::fromEncoded(urlString.toLatin1()); 0451 if (url.isLocalFile() && !QFile(url.toLocalFile()).exists()) { 0452 continue; 0453 } 0454 const auto attributes = xml.attributes(); 0455 const QDateTime modified = QDateTime::fromString(attributes.value(QLatin1String("modified")).toString(), Qt::ISODate); 0456 const QDateTime visited = QDateTime::fromString(attributes.value(QLatin1String("visited")).toString(), Qt::ISODate); 0457 const QDateTime added = QDateTime::fromString(attributes.value(QLatin1String("added")).toString(), Qt::ISODate); 0458 if (modified > visited && modified > added) { 0459 ret[url] = modified; 0460 } else if (visited > added) { 0461 ret[url] = visited; 0462 } else { 0463 ret[url] = added; 0464 } 0465 } 0466 0467 if (xml.hasError()) { 0468 qCWarning(KIO_CORE) << "Failed to read" << input.fileName() << xml.errorString(); 0469 } 0470 0471 return ret; 0472 } 0473 0474 QList<QUrl> KRecentDocument::recentUrls() 0475 { 0476 QMap<QUrl, QDateTime> documents = xbelRecentlyUsedList(); 0477 0478 QList<QUrl> ret = documents.keys(); 0479 std::sort(ret.begin(), ret.end(), [&](const QUrl &doc1, const QUrl &doc2) { 0480 return documents.value(doc1) < documents.value(doc2); 0481 }); 0482 0483 return ret; 0484 } 0485 0486 void KRecentDocument::add(const QUrl &url) 0487 { 0488 add(url, RecentDocumentGroups()); 0489 } 0490 0491 void KRecentDocument::add(const QUrl &url, KRecentDocument::RecentDocumentGroups groups) 0492 { 0493 // desktopFileName is in QGuiApplication but we're in KIO Core here 0494 QString desktopEntryName = QCoreApplication::instance()->property("desktopFileName").toString(); 0495 if (desktopEntryName.isEmpty()) { 0496 desktopEntryName = QCoreApplication::applicationName(); 0497 } 0498 add(url, desktopEntryName, groups); 0499 } 0500 0501 void KRecentDocument::add(const QUrl &url, const QString &desktopEntryName) 0502 { 0503 add(url, desktopEntryName, RecentDocumentGroups()); 0504 } 0505 0506 void KRecentDocument::add(const QUrl &url, const QString &desktopEntryName, KRecentDocument::RecentDocumentGroups groups) 0507 { 0508 if (url.isLocalFile() && url.toLocalFile().startsWith(QDir::tempPath())) { 0509 return; // inside tmp resource, do not save 0510 } 0511 0512 // qDebug() << "KRecentDocument::add for " << openStr; 0513 KConfigGroup config = KSharedConfig::openConfig()->group(QByteArray("RecentDocuments")); 0514 bool useRecent = config.readEntry(QStringLiteral("UseRecent"), true); 0515 int maxEntries = config.readEntry(QStringLiteral("MaxEntries"), 300); 0516 bool ignoreHidden = config.readEntry(QStringLiteral("IgnoreHidden"), true); 0517 0518 if (!useRecent || maxEntries == 0) { 0519 clear(); 0520 return; 0521 } 0522 if (ignoreHidden && QRegularExpression(QStringLiteral("/\\.")).match(url.toLocalFile()).hasMatch()) { 0523 return; 0524 } 0525 0526 if (!addToXbel(url, desktopEntryName, groups, maxEntries, ignoreHidden)) { 0527 qCWarning(KIO_CORE) << "Failed to add to recently used bookmark file"; 0528 } 0529 } 0530 0531 void KRecentDocument::clear() 0532 { 0533 QFile(xbelPath()).remove(); 0534 } 0535 0536 int KRecentDocument::maximumItems() 0537 { 0538 KConfigGroup cg(KSharedConfig::openConfig(), QStringLiteral("RecentDocuments")); 0539 return cg.readEntry(QStringLiteral("MaxEntries"), 10); 0540 }