File indexing completed on 2024-10-06 06:41:09

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 }