1 /*
2 SPDX-FileCopyrightText: 2014-2015 Eike Hein <hein@kde.org>
3 SPDX-FileCopyrightText: 2016-2017 Ivan Cukic <ivan.cukic@kde.org>
4
5 SPDX-License-Identifier: GPL-2.0-or-later
6 */
7
8 #include "kastatsfavoritesmodel.h"
9 #include "actionlist.h"
10 #include "appentry.h"
11 #include "contactentry.h"
12 #include "debug.h"
13 #include "fileentry.h"
14
15 #include <QFileInfo>
16 #include <QSortFilterProxyModel>
17 #include <QTimer>
18
19 #include <KConfigGroup>
20 #include <KLocalizedString>
21 #include <KSharedConfig>
22
23 #include <KActivities/Consumer>
24 #include <KActivities/Stats/Query>
25 #include <KActivities/Stats/ResultSet>
26 #include <KActivities/Stats/ResultWatcher>
27 #include <KActivities/Stats/Terms>
28
29 namespace KAStats = KActivities::Stats;
30
31 using namespace KAStats;
32 using namespace KAStats::Terms;
33
34 #define AGENT_APPLICATIONS QStringLiteral("org.kde.plasma.favorites.applications")
35 #define AGENT_CONTACTS QStringLiteral("org.kde.plasma.favorites.contacts")
36 #define AGENT_DOCUMENTS QStringLiteral("org.kde.plasma.favorites.documents")
37
agentForUrl(const QString & url)38 QString agentForUrl(const QString &url)
39 {
40 // clang-format off
41 return url.startsWith(QLatin1String("ktp:"))
42 ? AGENT_CONTACTS
43 : url.startsWith(QLatin1String("preferred:"))
44 ? AGENT_APPLICATIONS
45 : url.startsWith(QLatin1String("applications:"))
46 ? AGENT_APPLICATIONS
47 : (url.startsWith(QLatin1Char('/')) && !url.endsWith(QLatin1String(".desktop")))
48 ? AGENT_DOCUMENTS
49 : (url.startsWith(QLatin1String("file:/")) && !url.endsWith(QLatin1String(".desktop")))
50 ? AGENT_DOCUMENTS
51 // use applications as the default
52 : AGENT_APPLICATIONS;
53 // clang-format on
54 }
55
56 class KAStatsFavoritesModel::Private : public QAbstractListModel
57 {
58 public:
59 class NormalizedId
60 {
61 public:
NormalizedId()62 NormalizedId()
63 {
64 }
65
NormalizedId(const Private * parent,const QString & id)66 NormalizedId(const Private *parent, const QString &id)
67 {
68 if (id.isEmpty())
69 return;
70
71 QSharedPointer<AbstractEntry> entry = nullptr;
72
73 if (parent->m_itemEntries.contains(id)) {
74 entry = parent->m_itemEntries[id];
75 } else {
76 // This entry is not cached - it is temporary,
77 // so let's clean up when we exit this function
78 entry = parent->entryForResource(id);
79 }
80
81 if (!entry || !entry->isValid()) {
82 qWarning() << "Entry is not valid" << id << entry;
83 m_id = id;
84 return;
85 }
86
87 const auto url = entry->url();
88
89 qCDebug(KICKER_DEBUG) << "Original id is: " << id << ", and the url is" << url;
90
91 // Preferred applications need special handling
92 if (entry->id().startsWith(QLatin1String("preferred:"))) {
93 m_id = entry->id();
94 return;
95 }
96
97 // If this is an application, use the applications:-format url
98 auto appEntry = dynamic_cast<AppEntry *>(entry.data());
99 if (appEntry && !appEntry->menuId().isEmpty()) {
100 m_id = QLatin1String("applications:") + appEntry->menuId();
101 return;
102 }
103
104 // We want to resolve symbolic links not to have two paths
105 // refer to the same .desktop file
106 if (url.isLocalFile()) {
107 QFileInfo file(url.toLocalFile());
108
109 if (file.exists()) {
110 m_id = QUrl::fromLocalFile(file.canonicalFilePath()).toString();
111 return;
112 }
113 }
114
115 // If this is a file, we should have already covered it
116 if (url.scheme() == QLatin1String("file")) {
117 return;
118 }
119
120 m_id = url.toString();
121 }
122
value() const123 const QString &value() const
124 {
125 return m_id;
126 }
127
operator ==(const NormalizedId & other) const128 bool operator==(const NormalizedId &other) const
129 {
130 return m_id == other.m_id;
131 }
132
133 private:
134 QString m_id;
135 };
136
normalizedId(const QString & id) const137 NormalizedId normalizedId(const QString &id) const
138 {
139 return NormalizedId(this, id);
140 }
141
entryForResource(const QString & resource) const142 QSharedPointer<AbstractEntry> entryForResource(const QString &resource) const
143 {
144 using SP = QSharedPointer<AbstractEntry>;
145
146 const auto agent = agentForUrl(resource);
147
148 if (agent == AGENT_CONTACTS) {
149 return SP(new ContactEntry(q, resource));
150
151 } else if (agent == AGENT_DOCUMENTS) {
152 if (resource.startsWith(QLatin1String("/"))) {
153 return SP(new FileEntry(q, QUrl::fromLocalFile(resource)));
154 } else {
155 return SP(new FileEntry(q, QUrl(resource)));
156 }
157
158 } else if (agent == AGENT_APPLICATIONS) {
159 if (resource.startsWith(QLatin1String("applications:"))) {
160 return SP(new AppEntry(q, resource.mid(13)));
161 } else {
162 return SP(new AppEntry(q, resource));
163 }
164
165 } else {
166 return {};
167 }
168 }
169
Private(KAStatsFavoritesModel * parent,QString clientId)170 Private(KAStatsFavoritesModel *parent, QString clientId)
171 : q(parent)
172 , m_query(LinkedResources | Agent{AGENT_APPLICATIONS, AGENT_CONTACTS, AGENT_DOCUMENTS} | Type::any() | Activity::current() | Activity::global()
173 | Limit::all())
174 , m_watcher(m_query)
175 , m_clientId(clientId)
176 {
177 // Connecting the watcher
__anon7bd5a3db0102(const QString &resource) 178 connect(&m_watcher, &ResultWatcher::resultLinked, [this](const QString &resource) {
179 addResult(resource, -1);
180 });
181
__anon7bd5a3db0202(const QString &resource) 182 connect(&m_watcher, &ResultWatcher::resultUnlinked, [this](const QString &resource) {
183 removeResult(resource);
184 });
185
186 // Loading the items order
187 const auto cfg = KSharedConfig::openConfig(QStringLiteral("kactivitymanagerd-statsrc"));
188
189 // We want first to check whether we have an ordering for this activity.
190 // If not, we will try to get a global one for this applet
191
192 const QString thisGroupName = QStringLiteral("Favorites-") + clientId + QStringLiteral("-") + m_activities.currentActivity();
193 const QString globalGroupName = QStringLiteral("Favorites-") + clientId + QStringLiteral("-global");
194
195 KConfigGroup thisCfgGroup(cfg, thisGroupName);
196 KConfigGroup globalCfgGroup(cfg, globalGroupName);
197
198 QStringList ordering = thisCfgGroup.readEntry("ordering", QStringList()) + globalCfgGroup.readEntry("ordering", QStringList());
199
200 qCDebug(KICKER_DEBUG) << "Loading the ordering " << ordering;
201
202 // Loading the results without emitting any model signals
203 qCDebug(KICKER_DEBUG) << "Query is" << m_query;
204 ResultSet results(m_query);
205
206 for (const auto &result : results) {
207 qCDebug(KICKER_DEBUG) << "Got " << result.resource() << " -->";
208 addResult(result.resource(), -1, false);
209 }
210
211 // Normalizing all the ids
__anon7bd5a3db0302(const QString &item) 212 std::transform(ordering.begin(), ordering.end(), ordering.begin(), [&](const QString &item) {
213 return normalizedId(item).value();
214 });
215
216 // Sorting the items in the cache
__anon7bd5a3db0402(const NormalizedId &left, const NormalizedId &right) 217 std::sort(m_items.begin(), m_items.end(), [&](const NormalizedId &left, const NormalizedId &right) {
218 auto leftIndex = ordering.indexOf(left.value());
219 auto rightIndex = ordering.indexOf(right.value());
220 // clang-format off
221 return (leftIndex == -1 && rightIndex == -1) ?
222 left.value() < right.value() :
223
224 (leftIndex == -1) ?
225 false :
226
227 (rightIndex == -1) ?
228 true :
229
230 // otherwise
231 leftIndex < rightIndex;
232 // clang-format on
233 });
234
235 // Debugging:
236 QVector<QString> itemStrings(m_items.size());
__anon7bd5a3db0502(const NormalizedId &item) 237 std::transform(m_items.cbegin(), m_items.cend(), itemStrings.begin(), [](const NormalizedId &item) {
238 return item.value();
239 });
240 qCDebug(KICKER_DEBUG) << "After ordering: " << itemStrings;
241 }
242
addResult(const QString & _resource,int index,bool notifyModel=true)243 void addResult(const QString &_resource, int index, bool notifyModel = true)
244 {
245 // We want even files to have a proper URL
246 const auto resource = _resource.startsWith(QLatin1Char('/')) ? QUrl::fromLocalFile(_resource).toString() : _resource;
247
248 qCDebug(KICKER_DEBUG) << "Adding result" << resource << "already present?" << m_itemEntries.contains(resource);
249
250 if (m_itemEntries.contains(resource))
251 return;
252
253 auto entry = entryForResource(resource);
254
255 if (!entry || !entry->isValid()) {
256 qCDebug(KICKER_DEBUG) << "Entry is not valid!";
257 return;
258 }
259
260 if (index == -1) {
261 index = m_items.count();
262 }
263
264 if (notifyModel) {
265 beginInsertRows(QModelIndex(), index, index);
266 }
267
268 auto url = entry->url();
269
270 m_itemEntries[resource] = m_itemEntries[entry->id()] = m_itemEntries[url.toString()] = m_itemEntries[url.toLocalFile()] = entry;
271
272 auto normalized = normalizedId(resource);
273 m_items.insert(index, normalized);
274 m_itemEntries[normalized.value()] = entry;
275
276 if (notifyModel) {
277 endInsertRows();
278 saveOrdering();
279 }
280 }
281
removeResult(const QString & resource)282 void removeResult(const QString &resource)
283 {
284 auto normalized = normalizedId(resource);
285
286 // If we know this item will not really be removed,
287 // but only that activities it is on have changed,
288 // lets leave it
289 if (m_ignoredItems.contains(normalized.value())) {
290 m_ignoredItems.removeAll(normalized.value());
291 return;
292 }
293
294 qCDebug(KICKER_DEBUG) << "Removing result" << resource;
295
296 auto index = m_items.indexOf(normalizedId(resource));
297
298 if (index == -1)
299 return;
300
301 beginRemoveRows(QModelIndex(), index, index);
302 auto entry = m_itemEntries[resource];
303 m_items.removeAt(index);
304
305 // Removing the entry from the cache
306 QMutableHashIterator<QString, QSharedPointer<AbstractEntry>> i(m_itemEntries);
307 while (i.hasNext()) {
308 i.next();
309 if (i.value() == entry) {
310 i.remove();
311 }
312 }
313
314 endRemoveRows();
315 }
316
rowCount(const QModelIndex & parent=QModelIndex ()) const317 int rowCount(const QModelIndex &parent = QModelIndex()) const override
318 {
319 if (parent.isValid())
320 return 0;
321
322 return m_items.count();
323 }
324
data(const QModelIndex & item,int role=Qt::DisplayRole) const325 QVariant data(const QModelIndex &item, int role = Qt::DisplayRole) const override
326 {
327 if (item.parent().isValid())
328 return QVariant();
329
330 const auto index = item.row();
331
332 const auto entry = m_itemEntries[m_items[index].value()];
333 // clang-format off
334 return entry == nullptr ? QVariant()
335 : role == Qt::DisplayRole ? entry->name()
336 : role == Qt::DecorationRole ? entry->icon()
337 : role == Kicker::DescriptionRole ? entry->description()
338 : role == Kicker::FavoriteIdRole ? entry->id()
339 : role == Kicker::UrlRole ? entry->url()
340 : role == Kicker::HasActionListRole ? entry->hasActions()
341 : role == Kicker::ActionListRole ? entry->actions()
342 : QVariant();
343 // clang-format on
344 }
345
trigger(int row,const QString & actionId,const QVariant & argument)346 bool trigger(int row, const QString &actionId, const QVariant &argument)
347 {
348 if (row < 0 || row >= rowCount()) {
349 return false;
350 }
351
352 const QString id = data(index(row, 0), Kicker::UrlRole).toString();
353 if (m_itemEntries.contains(id)) {
354 return m_itemEntries[id]->run(actionId, argument);
355 }
356 // Entries with preferred:// can be changed by the user, BUG: 416161
357 // then the list of entries could be out of sync
358 const auto entry = m_itemEntries[m_items[row].value()];
359 if (QUrl(entry->id()).scheme() == QLatin1String("preferred")) {
360 return entry->run(actionId, argument);
361 }
362 return false;
363 }
364
move(int from,int to)365 void move(int from, int to)
366 {
367 if (from < 0)
368 return;
369 if (from >= m_items.count())
370 return;
371 if (to < 0)
372 return;
373 if (to >= m_items.count())
374 return;
375
376 if (from == to)
377 return;
378
379 const int modelTo = to + (to > from ? 1 : 0);
380
381 if (q->beginMoveRows(QModelIndex(), from, from, QModelIndex(), modelTo)) {
382 m_items.move(from, to);
383 q->endMoveRows();
384
385 qCDebug(KICKER_DEBUG) << "Save ordering (from Private::move) -->";
386 saveOrdering();
387 }
388 }
389
saveOrdering()390 void saveOrdering()
391 {
392 QStringList ids;
393
394 for (const auto &item : qAsConst(m_items)) {
395 ids << item.value();
396 }
397
398 qCDebug(KICKER_DEBUG) << "Save ordering (from Private::saveOrdering) -->";
399 saveOrdering(ids, m_clientId, m_activities.currentActivity());
400 }
401
saveOrdering(const QStringList & ids,const QString & clientId,const QString & currentActivity)402 static void saveOrdering(const QStringList &ids, const QString &clientId, const QString ¤tActivity)
403 {
404 const auto cfg = KSharedConfig::openConfig(QStringLiteral("kactivitymanagerd-statsrc"));
405
406 QStringList activities{currentActivity, QStringLiteral("global")};
407
408 qCDebug(KICKER_DEBUG) << "Saving ordering for" << currentActivity << "and global" << ids;
409
410 for (const auto &activity : activities) {
411 const QString groupName = QStringLiteral("Favorites-") + clientId + QStringLiteral("-") + activity;
412
413 KConfigGroup cfgGroup(cfg, groupName);
414
415 cfgGroup.writeEntry("ordering", ids);
416 }
417
418 cfg->sync();
419 }
420
421 KAStatsFavoritesModel *const q;
422 KActivities::Consumer m_activities;
423 Query m_query;
424 ResultWatcher m_watcher;
425 QString m_clientId;
426
427 QVector<NormalizedId> m_items;
428 QHash<QString, QSharedPointer<AbstractEntry>> m_itemEntries;
429 QStringList m_ignoredItems;
430 };
431
KAStatsFavoritesModel(QObject * parent)432 KAStatsFavoritesModel::KAStatsFavoritesModel(QObject *parent)
433 : PlaceholderModel(parent)
434 , d(nullptr) // we have no client id yet
435 , m_enabled(true)
436 , m_maxFavorites(-1)
437 , m_activities(new KActivities::Consumer(this))
438 {
439 connect(m_activities, &KActivities::Consumer::currentActivityChanged, this, [&](const QString ¤tActivity) {
440 qCDebug(KICKER_DEBUG) << "Activity just got changed to" << currentActivity;
441 Q_UNUSED(currentActivity);
442 if (d) {
443 auto clientId = d->m_clientId;
444 initForClient(clientId);
445 }
446 });
447 }
448
~KAStatsFavoritesModel()449 KAStatsFavoritesModel::~KAStatsFavoritesModel()
450 {
451 delete d;
452 }
453
initForClient(const QString & clientId)454 void KAStatsFavoritesModel::initForClient(const QString &clientId)
455 {
456 qCDebug(KICKER_DEBUG) << "initForClient" << clientId;
457
458 setSourceModel(nullptr);
459 delete d;
460 d = new Private(this, clientId);
461
462 setSourceModel(d);
463 }
464
description() const465 QString KAStatsFavoritesModel::description() const
466 {
467 return i18n("Favorites");
468 }
469
trigger(int row,const QString & actionId,const QVariant & argument)470 bool KAStatsFavoritesModel::trigger(int row, const QString &actionId, const QVariant &argument)
471 {
472 return d && d->trigger(row, actionId, argument);
473 }
474
enabled() const475 bool KAStatsFavoritesModel::enabled() const
476 {
477 return m_enabled;
478 }
479
maxFavorites() const480 int KAStatsFavoritesModel::maxFavorites() const
481 {
482 return m_maxFavorites;
483 }
484
setMaxFavorites(int max)485 void KAStatsFavoritesModel::setMaxFavorites(int max)
486 {
487 Q_UNUSED(max);
488 }
489
setEnabled(bool enable)490 void KAStatsFavoritesModel::setEnabled(bool enable)
491 {
492 if (m_enabled != enable) {
493 m_enabled = enable;
494
495 emit enabledChanged();
496 }
497 }
498
favorites() const499 QStringList KAStatsFavoritesModel::favorites() const
500 {
501 qWarning() << "KAStatsFavoritesModel::favorites returns nothing, it is here just to keep the API backwards-compatible";
502 return QStringList();
503 }
504
setFavorites(const QStringList & favorites)505 void KAStatsFavoritesModel::setFavorites(const QStringList &favorites)
506 {
507 Q_UNUSED(favorites);
508 qWarning() << "KAStatsFavoritesModel::setFavorites is ignored";
509 }
510
isFavorite(const QString & id) const511 bool KAStatsFavoritesModel::isFavorite(const QString &id) const
512 {
513 return d && d->m_itemEntries.contains(id);
514 }
515
portOldFavorites(const QStringList & ids)516 void KAStatsFavoritesModel::portOldFavorites(const QStringList &ids)
517 {
518 if (!d)
519 return;
520 qCDebug(KICKER_DEBUG) << "portOldFavorites" << ids;
521
522 const QString activityId = QStringLiteral(":global");
523 std::for_each(ids.begin(), ids.end(), [&](const QString &id) {
524 addFavoriteTo(id, activityId);
525 });
526
527 // Resetting the model
528 auto clientId = d->m_clientId;
529 setSourceModel(nullptr);
530 delete d;
531 d = nullptr;
532
533 qCDebug(KICKER_DEBUG) << "Save ordering (from portOldFavorites) -->";
534 Private::saveOrdering(ids, clientId, m_activities->currentActivity());
535
536 QTimer::singleShot(500, this, std::bind(&KAStatsFavoritesModel::initForClient, this, clientId));
537 }
538
addFavorite(const QString & id,int index)539 void KAStatsFavoritesModel::addFavorite(const QString &id, int index)
540 {
541 qCDebug(KICKER_DEBUG) << "addFavorite" << id << index << " -->";
542 addFavoriteTo(id, QStringLiteral(":global"), index);
543 }
544
removeFavorite(const QString & id)545 void KAStatsFavoritesModel::removeFavorite(const QString &id)
546 {
547 qCDebug(KICKER_DEBUG) << "removeFavorite" << id << " -->";
548 removeFavoriteFrom(id, QStringLiteral(":any"));
549 }
550
addFavoriteTo(const QString & id,const QString & activityId,int index)551 void KAStatsFavoritesModel::addFavoriteTo(const QString &id, const QString &activityId, int index)
552 {
553 qCDebug(KICKER_DEBUG) << "addFavoriteTo" << id << activityId << index << " -->";
554 addFavoriteTo(id, Activity(activityId), index);
555 }
556
removeFavoriteFrom(const QString & id,const QString & activityId)557 void KAStatsFavoritesModel::removeFavoriteFrom(const QString &id, const QString &activityId)
558 {
559 qCDebug(KICKER_DEBUG) << "removeFavoriteFrom" << id << activityId << " -->";
560 removeFavoriteFrom(id, Activity(activityId));
561 }
562
addFavoriteTo(const QString & id,const Activity & activity,int index)563 void KAStatsFavoritesModel::addFavoriteTo(const QString &id, const Activity &activity, int index)
564 {
565 if (!d || id.isEmpty())
566 return;
567
568 Q_ASSERT(!activity.values.isEmpty());
569
570 setDropPlaceholderIndex(-1);
571
572 QStringList matchers{d->m_activities.currentActivity(), QStringLiteral(":global"), QStringLiteral(":current")};
573 if (std::find_first_of(activity.values.cbegin(), activity.values.cend(), matchers.cbegin(), matchers.cend()) != activity.values.cend()) {
574 d->addResult(id, index);
575 }
576
577 const auto url = d->normalizedId(id).value();
578
579 qCDebug(KICKER_DEBUG) << "addFavoriteTo" << id << activity << index << url << " (actual)";
580
581 if (url.isEmpty())
582 return;
583
584 d->m_watcher.linkToActivity(QUrl(url), activity, Agent(agentForUrl(url)));
585 }
586
removeFavoriteFrom(const QString & id,const Activity & activity)587 void KAStatsFavoritesModel::removeFavoriteFrom(const QString &id, const Activity &activity)
588 {
589 if (!d || id.isEmpty())
590 return;
591
592 const auto url = d->normalizedId(id).value();
593
594 Q_ASSERT(!activity.values.isEmpty());
595
596 qCDebug(KICKER_DEBUG) << "addFavoriteTo" << id << activity << url << " (actual)";
597
598 if (url.isEmpty())
599 return;
600
601 d->m_watcher.unlinkFromActivity(QUrl(url), activity, Agent(agentForUrl(url)));
602 }
603
setFavoriteOn(const QString & id,const QString & activityId)604 void KAStatsFavoritesModel::setFavoriteOn(const QString &id, const QString &activityId)
605 {
606 if (!d || id.isEmpty())
607 return;
608
609 const auto url = d->normalizedId(id).value();
610
611 qCDebug(KICKER_DEBUG) << "setFavoriteOn" << id << activityId << url << " (actual)";
612
613 qCDebug(KICKER_DEBUG) << "%%%%%%%%%%% Activity is" << activityId;
614 if (activityId.isEmpty() || activityId == QLatin1String(":any") || activityId == QLatin1String(":global")
615 || activityId == m_activities->currentActivity()) {
616 d->m_ignoredItems << url;
617 }
618
619 d->m_watcher.unlinkFromActivity(QUrl(url), Activity::any(), Agent(agentForUrl(url)));
620 d->m_watcher.linkToActivity(QUrl(url), activityId, Agent(agentForUrl(url)));
621 }
622
moveRow(int from,int to)623 void KAStatsFavoritesModel::moveRow(int from, int to)
624 {
625 if (!d)
626 return;
627
628 d->move(from, to);
629 }
630
favoritesModel()631 AbstractModel *KAStatsFavoritesModel::favoritesModel()
632 {
633 return this;
634 }
635
refresh()636 void KAStatsFavoritesModel::refresh()
637 {
638 }
639
activities() const640 QObject *KAStatsFavoritesModel::activities() const
641 {
642 return m_activities;
643 }
644
activityNameForId(const QString & activityId) const645 QString KAStatsFavoritesModel::activityNameForId(const QString &activityId) const
646 {
647 // It is safe to use a short-lived object here,
648 // we are always synced with KAMD in plasma
649 KActivities::Info info(activityId);
650 return info.name();
651 }
652
linkedActivitiesFor(const QString & id) const653 QStringList KAStatsFavoritesModel::linkedActivitiesFor(const QString &id) const
654 {
655 if (!d) {
656 qCDebug(KICKER_DEBUG) << "Linked for" << id << "is empty, no Private instance";
657 return {};
658 }
659
660 auto url = d->normalizedId(id).value();
661
662 if (url.startsWith(QLatin1String("file:"))) {
663 url = QUrl(url).toLocalFile();
664 }
665
666 if (url.isEmpty()) {
667 qCDebug(KICKER_DEBUG) << "The url for" << id << "is empty";
668 return {};
669 }
670
671 auto query = LinkedResources | Agent{AGENT_APPLICATIONS, AGENT_CONTACTS, AGENT_DOCUMENTS} | Type::any() | Activity::any() | Url(url) | Limit::all();
672
673 ResultSet results(query);
674
675 for (const auto &result : results) {
676 qCDebug(KICKER_DEBUG) << "Returning" << result.linkedActivities() << "for" << id << url;
677 return result.linkedActivities();
678 }
679
680 qCDebug(KICKER_DEBUG) << "Returning empty list of activities for" << id << url;
681 return {};
682 }
683