1/* -*- c++ -*-
2 SPDX-FileCopyrightText: 2000 Daniel M. Duley <mosfet@kde.org>
3 SPDX-FileCopyrightText: 2021 Martin Tobias Holmedahl Sandsmark
4 SPDX-FileCopyrightText: 2022 Méven Car <meven.car@kdemail.net>
5
6 SPDX-License-Identifier: BSD-2-Clause
7*/
8
9#include "krecentdocument.h"
10
11#include "kiocoredebug.h"
12
13#include <QCoreApplication>
14#include <QDir>
15#include <QDomDocument>
16#include <QLockFile>
17#include <QMimeDatabase>
18#include <QSaveFile>
19#include <QXmlStreamWriter>
20
21#include <KConfigGroup>
22#include <KService>
23#include <KSharedConfig>
24
25static QString xbelPath()
26{
27 return QStandardPaths::writableLocation(type: QStandardPaths::GenericDataLocation) + QLatin1String("/recently-used.xbel");
28}
29
30static inline QString stringForRecentDocumentGroup(int val)
31{
32 switch (val) {
33 case KRecentDocument::RecentDocumentGroup::Development:
34 return QStringLiteral("Development");
35 case KRecentDocument::RecentDocumentGroup::Office:
36 return QStringLiteral("Office");
37 case KRecentDocument::RecentDocumentGroup::Database:
38 return QStringLiteral("Database");
39 case KRecentDocument::RecentDocumentGroup::Email:
40 return QStringLiteral("Email");
41 case KRecentDocument::RecentDocumentGroup::Presentation:
42 return QStringLiteral("Presentation");
43 case KRecentDocument::RecentDocumentGroup::Spreadsheet:
44 return QStringLiteral("Spreadsheet");
45 case KRecentDocument::RecentDocumentGroup::WordProcessor:
46 return QStringLiteral("WordProcessor");
47 case KRecentDocument::RecentDocumentGroup::Graphics:
48 return QStringLiteral("Graphics");
49 case KRecentDocument::RecentDocumentGroup::TextEditor:
50 return QStringLiteral("TextEditor");
51 case KRecentDocument::RecentDocumentGroup::Viewer:
52 return QStringLiteral("Viewer");
53 case KRecentDocument::RecentDocumentGroup::Archive:
54 return QStringLiteral("Archive");
55 case KRecentDocument::RecentDocumentGroup::Multimedia:
56 return QStringLiteral("Multimedia");
57 case KRecentDocument::RecentDocumentGroup::Audio:
58 return QStringLiteral("Audio");
59 case KRecentDocument::RecentDocumentGroup::Video:
60 return QStringLiteral("Video");
61 case KRecentDocument::RecentDocumentGroup::Photo:
62 return QStringLiteral("Photo");
63 case KRecentDocument::RecentDocumentGroup::Application:
64 return QStringLiteral("Application");
65 };
66 Q_UNREACHABLE();
67}
68
69static KRecentDocument::RecentDocumentGroups groupsForMimeType(const QString mimeType)
70{
71 // simple heuristics, feel free to expand as needed
72 if (mimeType.startsWith(QStringLiteral("image/"))) {
73 return KRecentDocument::RecentDocumentGroups{KRecentDocument::RecentDocumentGroup::Graphics};
74 }
75 if (mimeType.startsWith(QStringLiteral("video/"))) {
76 return KRecentDocument::RecentDocumentGroups{KRecentDocument::RecentDocumentGroup::Video};
77 }
78 if (mimeType.startsWith(QStringLiteral("audio/"))) {
79 return KRecentDocument::RecentDocumentGroups{KRecentDocument::RecentDocumentGroup::Audio};
80 }
81 return KRecentDocument::RecentDocumentGroups{};
82}
83
84// Marginally more readable to avoid all the QStringLiteral() spam below
85static const QLatin1String xbelTag("xbel");
86static const QLatin1String versionAttribute("version");
87static const QLatin1String expectedVersion("1.0");
88
89static const QLatin1String applicationsBookmarkTag("bookmark:applications");
90static const QLatin1String applicationBookmarkTag("bookmark:application");
91static const QLatin1String bookmarkTag("bookmark");
92static const QLatin1String infoTag("info");
93static const QLatin1String metadataTag("metadata");
94static const QLatin1String mimeTypeTag("mime:mime-type");
95static const QLatin1String bookmarkGroups("bookmark:groups");
96static const QLatin1String bookmarkGroup("bookmark:group");
97
98static const QLatin1String nameAttribute("name");
99static const QLatin1String countAttribute("count");
100static const QLatin1String modifiedAttribute("modified");
101static const QLatin1String visitedAttribute("visited");
102static const QLatin1String hrefAttribute("href");
103static const QLatin1String addedAttribute("added");
104static const QLatin1String execAttribute("exec");
105static const QLatin1String ownerAttribute("owner");
106static const QLatin1String ownerValue("http://freedesktop.org");
107static const QLatin1String typeAttribute("type");
108
109static bool removeOldestEntries(int &maxEntries)
110{
111 QFile input(xbelPath());
112 if (!input.exists()) {
113 return true;
114 }
115
116 // Won't help for GTK applications and whatnot, but we can be good citizens ourselves
117 QLockFile lockFile(xbelPath() + QLatin1String(".lock"));
118 lockFile.setStaleLockTime(0);
119 if (!lockFile.tryLock(timeout: 100)) { // give it 100ms
120 qCWarning(KIO_CORE) << "Failed to lock recently used";
121 return false;
122 }
123
124 if (!input.open(flags: QIODevice::ReadOnly)) {
125 qCWarning(KIO_CORE) << "Failed to open existing recently used" << input.errorString();
126 return false;
127 }
128
129 QDomDocument document;
130 document.setContent(device: &input);
131 input.close();
132
133 auto xbelTags = document.elementsByTagName(tagname: xbelTag);
134 if (xbelTags.length() != 1) {
135 qCWarning(KIO_CORE) << "Invalid Xbel file" << input.errorString();
136 return false;
137 }
138 auto xbelElement = document.elementsByTagName(tagname: xbelTag).item(index: 0);
139 auto bookmarkList = xbelElement.childNodes();
140 if (bookmarkList.length() <= maxEntries) {
141 return true;
142 }
143
144 QMultiMap<QDateTime, QDomNode> bookmarksByModifiedDate;
145 for (int i = 0; i < bookmarkList.length(); ++i) {
146 const auto node = bookmarkList.item(index: i);
147 const auto modifiedString = node.attributes().namedItem(name: modifiedAttribute);
148 const auto modifiedTime = QDateTime::fromString(string: modifiedString.nodeValue(), format: Qt::ISODate);
149
150 bookmarksByModifiedDate.insert(key: modifiedTime, value: node);
151 }
152
153 int i = 0;
154 // entries are traversed in ascending key order
155 for (auto entry = bookmarksByModifiedDate.keyValueBegin(); entry != bookmarksByModifiedDate.keyValueEnd(); ++entry) {
156 // only keep the maxEntries last nodes
157 if (bookmarksByModifiedDate.size() - i > maxEntries) {
158 xbelElement.removeChild(oldChild: entry->second);
159 }
160 ++i;
161 }
162
163 if (input.open(flags: QIODevice::WriteOnly) && input.write(data: document.toByteArray(2)) != -1) {
164 input.close();
165 return true;
166 }
167 input.close();
168 return false;
169}
170
171static bool addToXbel(const QUrl &url, const QString &desktopEntryName, KRecentDocument::RecentDocumentGroups groups, int maxEntries, bool ignoreHidden)
172{
173 if (!QDir().mkpath(dirPath: QStandardPaths::writableLocation(type: QStandardPaths::GenericDataLocation))) {
174 qCWarning(KIO_CORE) << "Could not create GenericDataLocation";
175 return false;
176 }
177
178 // Won't help for GTK applications and whatnot, but we can be good citizens ourselves
179 QLockFile lockFile(xbelPath() + QLatin1String(".lock"));
180 lockFile.setStaleLockTime(0);
181 if (!lockFile.tryLock(timeout: 100)) { // give it 100ms
182 qCWarning(KIO_CORE) << "Failed to lock recently used";
183 return false;
184 }
185
186 QByteArray existingContent;
187 QFile input(xbelPath());
188 if (input.open(flags: QIODevice::ReadOnly)) {
189 existingContent = input.readAll();
190 } else if (!input.exists()) { // That it doesn't exist is a very uncommon case
191 qCDebug(KIO_CORE) << input.fileName() << "does not exist, creating new";
192 } else {
193 qCWarning(KIO_CORE) << "Failed to open existing recently used" << input.errorString();
194 return false;
195 }
196
197 QXmlStreamReader xml(existingContent);
198
199 xml.readNextStartElement();
200 if (!existingContent.isEmpty()) {
201 if (xml.name().isEmpty() || xml.name() != xbelTag || !xml.attributes().hasAttribute(qualifiedName: versionAttribute)) {
202 qCDebug(KIO_CORE) << "The recently-used.xbel is not an XBEL file, overwriting.";
203 } else if (xml.attributes().value(qualifiedName: versionAttribute) != expectedVersion) {
204 qCDebug(KIO_CORE) << "The recently-used.xbel is not an XBEL version 1.0 file but has version: " << xml.attributes().value(qualifiedName: versionAttribute)
205 << ", overwriting.";
206 }
207 }
208
209 QSaveFile outputFile(xbelPath());
210 if (!outputFile.open(flags: QIODevice::WriteOnly)) {
211 qCWarning(KIO_CORE) << "Failed to recently-used.xbel for writing:" << outputFile.errorString();
212 return false;
213 }
214
215 QXmlStreamWriter output(&outputFile);
216 output.setAutoFormatting(true);
217 output.setAutoFormattingIndent(2);
218 output.writeStartDocument();
219 output.writeStartElement(qualifiedName: xbelTag);
220
221 output.writeAttribute(qualifiedName: versionAttribute, value: expectedVersion);
222 output.writeNamespace(QStringLiteral("http://www.freedesktop.org/standards/desktop-bookmarks"), QStringLiteral("bookmark"));
223 output.writeNamespace(QStringLiteral("http://www.freedesktop.org/standards/shared-mime-info"), QStringLiteral("mime"));
224
225 const QString newUrl = QString::fromLatin1(ba: url.toEncoded());
226 const QString currentTimestamp = QDateTime::currentDateTimeUtc().toString(format: Qt::ISODateWithMs).chopped(n: 1) + QStringLiteral("000Z");
227
228 auto addApplicationTag = [&output, desktopEntryName, currentTimestamp, url]() {
229 output.writeEmptyElement(qualifiedName: applicationBookmarkTag);
230 output.writeAttribute(qualifiedName: nameAttribute, value: desktopEntryName);
231 auto service = KService::serviceByDesktopName(name: desktopEntryName);
232 QString exec;
233 bool shouldAddParameter = true;
234 if (service) {
235 exec = service->exec();
236 exec.replace(before: QLatin1String(" %U"), after: QLatin1String(" %u"));
237 exec.replace(before: QLatin1String(" %F"), after: QLatin1String(" %f"));
238 shouldAddParameter = !exec.contains(s: QLatin1String(" %u")) && !exec.contains(s: QLatin1String(" %f"));
239 } else {
240 exec = QCoreApplication::instance()->applicationName();
241 }
242 if (shouldAddParameter) {
243 if (url.isLocalFile()) {
244 exec += QLatin1String(" %f");
245 } else {
246 exec += QLatin1String(" %u");
247 }
248 }
249 output.writeAttribute(qualifiedName: execAttribute, value: exec);
250 output.writeAttribute(qualifiedName: modifiedAttribute, value: currentTimestamp);
251 output.writeAttribute(qualifiedName: countAttribute, QStringLiteral("1"));
252 };
253
254 bool foundExistingApp = false;
255 bool inRightBookmark = false;
256 bool foundMatchingBookmark = false;
257 bool firstBookmark = true;
258 int nbEntries = 0;
259 while (!xml.atEnd() && !xml.hasError()) {
260 if (xml.readNext() == QXmlStreamReader::EndElement && xml.name() == xbelTag) {
261 break;
262 }
263 switch (xml.tokenType()) {
264 case QXmlStreamReader::StartElement: {
265 const QStringView tagName = xml.qualifiedName();
266 QXmlStreamAttributes attributes = xml.attributes();
267
268 if (tagName == bookmarkTag) {
269 foundExistingApp = false;
270 firstBookmark = false;
271
272 const QStringView hrefValue = attributes.value(qualifiedName: hrefAttribute);
273 inRightBookmark = hrefValue == newUrl;
274
275 // remove hidden files if some were added by GTK
276 if (ignoreHidden && hrefValue.contains(s: QLatin1String("/."))) {
277 xml.skipCurrentElement();
278 break;
279 }
280
281 if (inRightBookmark) {
282 foundMatchingBookmark = true;
283
284 QXmlStreamAttributes newAttributes;
285 for (const QXmlStreamAttribute &old : attributes) {
286 if (old.name() == modifiedAttribute) {
287 continue;
288 }
289 if (old.name() == visitedAttribute) {
290 continue;
291 }
292 newAttributes.append(t: old);
293 }
294 newAttributes.append(qualifiedName: modifiedAttribute, value: currentTimestamp);
295 newAttributes.append(qualifiedName: visitedAttribute, value: currentTimestamp);
296 attributes = newAttributes;
297 }
298
299 nbEntries += 1;
300 }
301
302 else if (inRightBookmark && tagName == applicationBookmarkTag && attributes.value(qualifiedName: nameAttribute) == desktopEntryName) {
303 // case found right bookmark and same application
304 const int count = attributes.value(qualifiedName: countAttribute).toInt();
305
306 QXmlStreamAttributes newAttributes;
307 for (const QXmlStreamAttribute &old : std::as_const(t&: attributes)) {
308 if (old.name() == countAttribute) {
309 continue;
310 }
311 if (old.name() == modifiedAttribute) {
312 continue;
313 }
314 newAttributes.append(t: old);
315 }
316 newAttributes.append(qualifiedName: modifiedAttribute, value: currentTimestamp);
317 newAttributes.append(qualifiedName: countAttribute, value: QString::number(count + 1));
318 attributes = newAttributes;
319
320 foundExistingApp = true;
321 }
322
323 output.writeStartElement(qualifiedName: tagName.toString());
324 output.writeAttributes(attributes);
325 break;
326 }
327 case QXmlStreamReader::EndElement: {
328 const QStringView tagName = xml.qualifiedName();
329 if (tagName == applicationsBookmarkTag && inRightBookmark && !foundExistingApp) {
330 // add an application to the applications already known for the bookmark
331 addApplicationTag();
332 }
333 output.writeEndElement();
334 break;
335 }
336 case QXmlStreamReader::Characters:
337 if (xml.isCDATA()) {
338 output.writeCDATA(text: xml.text().toString());
339 } else {
340 output.writeCharacters(text: xml.text().toString());
341 }
342 break;
343 case QXmlStreamReader::Comment:
344 output.writeComment(text: xml.text().toString());
345 break;
346 case QXmlStreamReader::EndDocument:
347 qCWarning(KIO_CORE) << "Malformed, got end document before end of xbel" << xml.tokenString() << url;
348 return false;
349 default:
350 qCWarning(KIO_CORE) << "unhandled token" << xml.tokenString() << url;
351 break;
352 }
353 }
354
355 if (!foundMatchingBookmark) {
356 // must create new bookmark tag
357 if (firstBookmark) {
358 output.writeCharacters(QStringLiteral("\n"));
359 }
360 output.writeCharacters(QStringLiteral(" "));
361 output.writeStartElement(qualifiedName: bookmarkTag);
362
363 output.writeAttribute(qualifiedName: hrefAttribute, value: newUrl);
364 output.writeAttribute(qualifiedName: addedAttribute, value: currentTimestamp);
365 output.writeAttribute(qualifiedName: modifiedAttribute, value: currentTimestamp);
366 output.writeAttribute(qualifiedName: visitedAttribute, value: currentTimestamp);
367
368 {
369 QMimeDatabase mimeDb;
370 const auto fileMime = mimeDb.mimeTypeForUrl(url).name();
371
372 output.writeStartElement(qualifiedName: infoTag);
373 output.writeStartElement(qualifiedName: metadataTag);
374 output.writeAttribute(qualifiedName: ownerAttribute, value: ownerValue);
375
376 output.writeEmptyElement(qualifiedName: mimeTypeTag);
377 output.writeAttribute(qualifiedName: typeAttribute, value: fileMime);
378
379 // write groups metadata
380 if (groups.isEmpty()) {
381 groups = groupsForMimeType(mimeType: fileMime);
382 }
383 if (!groups.isEmpty()) {
384 output.writeStartElement(qualifiedName: bookmarkGroups);
385 for (const auto &group : std::as_const(t&: groups)) {
386 output.writeTextElement(qualifiedName: bookmarkGroup, text: stringForRecentDocumentGroup(val: group));
387 }
388 // bookmarkGroups
389 output.writeEndElement();
390 }
391
392 {
393 output.writeStartElement(qualifiedName: applicationsBookmarkTag);
394 addApplicationTag();
395 // end applicationsBookmarkTag
396 output.writeEndElement();
397 }
398
399 // end infoTag
400 output.writeEndElement();
401 // end metadataTag
402 output.writeEndElement();
403 }
404
405 // end bookmarkTag
406 output.writeEndElement();
407 }
408
409 // end xbelTag
410 output.writeEndElement();
411
412 // end document
413 output.writeEndDocument();
414
415 if (outputFile.commit()) {
416 lockFile.unlock();
417 // tolerate 10 more entries than threshold to limit overhead of cleaning old data
418 return nbEntries - maxEntries > 10 || removeOldestEntries(maxEntries);
419 }
420 return false;
421}
422
423static QMap<QUrl, QDateTime> xbelRecentlyUsedList()
424{
425 QMap<QUrl, QDateTime> ret;
426 QFile input(xbelPath());
427 if (!input.open(flags: QIODevice::ReadOnly)) {
428 qCWarning(KIO_CORE) << "Failed to open" << input.fileName() << input.errorString();
429 return ret;
430 }
431
432 QXmlStreamReader xml(&input);
433 xml.readNextStartElement();
434 if (xml.name() != QLatin1String("xbel") || xml.attributes().value(qualifiedName: QLatin1String("version")) != QLatin1String("1.0")) {
435 qCWarning(KIO_CORE) << "The file is not an XBEL version 1.0 file.";
436 return ret;
437 }
438
439 while (!xml.atEnd() && !xml.hasError()) {
440 if (xml.readNext() != QXmlStreamReader::StartElement || xml.name() != QLatin1String("bookmark")) {
441 continue;
442 }
443
444 const auto urlString = xml.attributes().value(qualifiedName: QLatin1String("href"));
445 if (urlString.isEmpty()) {
446 qCInfo(KIO_CORE) << "Invalid bookmark in" << input.fileName();
447 continue;
448 }
449 const QUrl url = QUrl::fromEncoded(url: urlString.toLatin1());
450 if (url.isLocalFile() && !QFile(url.toLocalFile()).exists()) {
451 continue;
452 }
453 const auto attributes = xml.attributes();
454 const QDateTime modified = QDateTime::fromString(string: attributes.value(qualifiedName: QLatin1String("modified")).toString(), format: Qt::ISODate);
455 const QDateTime visited = QDateTime::fromString(string: attributes.value(qualifiedName: QLatin1String("visited")).toString(), format: Qt::ISODate);
456 const QDateTime added = QDateTime::fromString(string: attributes.value(qualifiedName: QLatin1String("added")).toString(), format: Qt::ISODate);
457 if (modified > visited && modified > added) {
458 ret[url] = modified;
459 } else if (visited > added) {
460 ret[url] = visited;
461 } else {
462 ret[url] = added;
463 }
464 }
465
466 if (xml.hasError()) {
467 qCWarning(KIO_CORE) << "Failed to read" << input.fileName() << xml.errorString();
468 }
469
470 return ret;
471}
472
473QList<QUrl> KRecentDocument::recentUrls()
474{
475 QMap<QUrl, QDateTime> documents = xbelRecentlyUsedList();
476
477 QList<QUrl> ret = documents.keys();
478 std::sort(first: ret.begin(), last: ret.end(), comp: [&](const QUrl &doc1, const QUrl &doc2) {
479 return documents.value(key: doc1) < documents.value(key: doc2);
480 });
481
482 return ret;
483}
484
485void KRecentDocument::add(const QUrl &url)
486{
487 add(url, groups: RecentDocumentGroups());
488}
489
490void KRecentDocument::add(const QUrl &url, KRecentDocument::RecentDocumentGroups groups)
491{
492 // desktopFileName is in QGuiApplication but we're in KIO Core here
493 QString desktopEntryName = QCoreApplication::instance()->property(name: "desktopFileName").toString();
494 if (desktopEntryName.isEmpty()) {
495 desktopEntryName = QCoreApplication::applicationName();
496 }
497 add(url, desktopEntryName, groups);
498}
499
500void KRecentDocument::add(const QUrl &url, const QString &desktopEntryName)
501{
502 add(url, desktopEntryName, groups: RecentDocumentGroups());
503}
504
505void KRecentDocument::add(const QUrl &url, const QString &desktopEntryName, KRecentDocument::RecentDocumentGroups groups)
506{
507 if (url.isLocalFile() && url.toLocalFile().startsWith(s: QDir::tempPath())) {
508 return; // inside tmp resource, do not save
509 }
510
511 // qDebug() << "KRecentDocument::add for " << openStr;
512 KConfigGroup config = KSharedConfig::openConfig()->group(QStringLiteral("RecentDocuments"));
513 bool useRecent = config.readEntry(QStringLiteral("UseRecent"), aDefault: true);
514 int maxEntries = config.readEntry(QStringLiteral("MaxEntries"), aDefault: 300);
515 bool ignoreHidden = config.readEntry(QStringLiteral("IgnoreHidden"), aDefault: true);
516
517 if (!useRecent || maxEntries == 0) {
518 clear();
519 return;
520 }
521 if (ignoreHidden && url.toLocalFile().contains(s: QLatin1String("/."))) {
522 return;
523 }
524
525 if (!addToXbel(url, desktopEntryName, groups, maxEntries, ignoreHidden)) {
526 qCWarning(KIO_CORE) << "Failed to add to recently used bookmark file";
527 }
528}
529
530void KRecentDocument::clear()
531{
532 QFile(xbelPath()).remove();
533}
534
535int KRecentDocument::maximumItems()
536{
537 KConfigGroup cg(KSharedConfig::openConfig(), QStringLiteral("RecentDocuments"));
538 return cg.readEntry(QStringLiteral("MaxEntries"), aDefault: 10);
539}
540

source code of kio/src/core/krecentdocument.cpp