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 }