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