File indexing completed on 2024-05-19 05:38:23

0001 /*
0002     SPDX-FileCopyrightText: 2007 Matthew Woehlke <mw_triad@users.sourceforge.net>
0003     SPDX-FileCopyrightText: 2007 Jeremy Whiting <jpwhiting@kde.org>
0004     SPDX-FileCopyrightText: 2016 Olivier Churlaud <olivier@churlaud.com>
0005     SPDX-FileCopyrightText: 2019 Kai Uwe Broulik <kde@privat.broulik.de>
0006     SPDX-FileCopyrightText: 2023 Ismael Asensio <isma.af@gmail.com>
0007 
0008     SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
0009 */
0010 
0011 #include "sourcesmodel.h"
0012 
0013 #include <QCollator>
0014 #include <QDir>
0015 #include <QRegularExpression>
0016 #include <QStandardPaths>
0017 #include <QStringList>
0018 
0019 #include <KApplicationTrader>
0020 #include <KConfig>
0021 #include <KConfigGroup>
0022 #include <KLocalizedString>
0023 #include <KService>
0024 #include <KSharedConfig>
0025 
0026 #include <algorithm>
0027 
0028 using namespace Qt::StringLiterals;
0029 
0030 static const QString s_plasmaWorkspaceNotifyRcName = QStringLiteral("plasma_workspace");
0031 static const QRegularExpression s_eventGroupRegExp(QStringLiteral("^Event/([^/]*)$"));
0032 
0033 SourcesModel::SourcesModel(QObject *parent)
0034     : QAbstractItemModel(parent)
0035 {
0036 }
0037 
0038 SourcesModel::~SourcesModel() = default;
0039 
0040 QPersistentModelIndex SourcesModel::makePersistentModelIndex(const QModelIndex &idx) const
0041 {
0042     return QPersistentModelIndex(idx);
0043 }
0044 
0045 QPersistentModelIndex SourcesModel::persistentIndexForDesktopEntry(const QString &desktopEntry) const
0046 {
0047     if (desktopEntry.isEmpty()) {
0048         return QPersistentModelIndex();
0049     }
0050     const auto matches = match(index(0, 0), SourcesModel::DesktopEntryRole, desktopEntry, 1, Qt::MatchFixedString);
0051     if (matches.isEmpty()) {
0052         return QPersistentModelIndex();
0053     }
0054     return QPersistentModelIndex(matches.first());
0055 }
0056 
0057 QPersistentModelIndex SourcesModel::persistentIndexForNotifyRcName(const QString &notifyRcName) const
0058 {
0059     if (notifyRcName.isEmpty()) {
0060         return QPersistentModelIndex();
0061     }
0062     const auto matches = match(index(0, 0), SourcesModel::NotifyRcNameRole, notifyRcName, 1, Qt::MatchFixedString);
0063     if (matches.isEmpty()) {
0064         return QPersistentModelIndex();
0065     }
0066     return QPersistentModelIndex(matches.first());
0067 }
0068 
0069 int SourcesModel::columnCount(const QModelIndex &parent) const
0070 {
0071     Q_UNUSED(parent);
0072     return 1;
0073 }
0074 
0075 int SourcesModel::rowCount(const QModelIndex &parent) const
0076 {
0077     if (parent.column() > 0) {
0078         return 0;
0079     }
0080 
0081     if (!parent.isValid()) {
0082         return m_data.count();
0083     }
0084 
0085     if (parent.internalId()) {
0086         return 0;
0087     }
0088 
0089     return m_data.at(parent.row()).events.count();
0090 }
0091 
0092 QVariant SourcesModel::data(const QModelIndex &index, int role) const
0093 {
0094     if (!index.isValid()) {
0095         return QVariant();
0096     }
0097 
0098     if (index.internalId()) { // event
0099         const auto &events = m_data.at(index.internalId() - 1).events;
0100         const auto event = events.at(index.row());
0101 
0102         switch (role) {
0103         case Qt::DisplayRole:
0104             return event->name();
0105         case Qt::DecorationRole:
0106             return event->iconName();
0107         case CommentRole:
0108             return event->comment();
0109         case ActionsRole:
0110             return event->action().split(QLatin1Char('|'), Qt::SkipEmptyParts);
0111         case SoundRole:
0112             return event->sound();
0113         case DefaultActionsRole: {
0114             // Weird KConfigSkeleton API to get the cascaded default values
0115             event->useDefaults(true);
0116             const QStringList defaultActions = event->action().split(QLatin1Char('|'), Qt::SkipEmptyParts);
0117             event->useDefaults(false);
0118             return defaultActions;
0119         }
0120         case DefaultSoundRole: {
0121             // Weird KConfigSkeleton API to get the cascaded default values
0122             event->useDefaults(true);
0123             const QString defaultSound = event->sound();
0124             event->useDefaults(false);
0125             return defaultSound;
0126         }
0127         case IsDefaultRole:
0128             return event->isDefaults();
0129         case ShowIconsRole:
0130             // We show the icons when at least one of the events specifies an icon name
0131             return std::any_of(events.cbegin(), events.cend(), [](auto *event) {
0132                 return !event->iconName().isEmpty();
0133             });
0134         }
0135 
0136         return QVariant();
0137     }
0138 
0139     const auto &source = m_data.at(index.row());
0140 
0141     switch (role) {
0142     case Qt::DisplayRole:
0143         return source.display();
0144     case Qt::DecorationRole:
0145         return source.iconName;
0146     case SourceTypeRole:
0147         return source.desktopEntry.isEmpty() ? ServiceType : ApplicationType;
0148     case NotifyRcNameRole:
0149         return source.notifyRcName;
0150     case DesktopEntryRole:
0151         return source.desktopEntry;
0152     case IsDefaultRole:
0153         return source.isDefault && std::all_of(source.events.cbegin(), source.events.cend(), [](auto event) {
0154                    return event->isDefaults();
0155                });
0156     }
0157 
0158     return QVariant();
0159 }
0160 
0161 bool SourcesModel::setData(const QModelIndex &index, const QVariant &value, int role)
0162 {
0163     if (!index.isValid()) {
0164         return false;
0165     }
0166 
0167     if (!index.internalId()) { // source
0168         auto &source = m_data[index.row()];
0169 
0170         switch (role) {
0171         case IsDefaultRole: {
0172             if (source.isDefault != value.toBool()) {
0173                 source.isDefault = value.toBool();
0174                 Q_EMIT dataChanged(index, index, {role});
0175                 return true;
0176             }
0177             break;
0178         }
0179         }
0180         return false;
0181     }
0182 
0183     NotificationManager::EventSettings *event = m_data[index.internalId() - 1].events[index.row()];
0184 
0185     const bool wasDefault = event->isDefaults();
0186     QList<int> changedRoles;
0187 
0188     switch (role) {
0189     case ActionsRole: {
0190         const QString newAction = value.toStringList().join(QLatin1Char('|'));
0191         if (event->action() != newAction) {
0192             event->setAction(newAction);
0193             changedRoles << role;
0194         }
0195         break;
0196     }
0197     case SoundRole: {
0198         const QString newSound = value.toString();
0199         if (event->sound() != newSound) {
0200             event->setSound(newSound);
0201             changedRoles << role;
0202         }
0203         break;
0204     }
0205     }
0206 
0207     if (event->isDefaults() != wasDefault) {
0208         changedRoles << IsDefaultRole;
0209     }
0210 
0211     if (changedRoles.isEmpty()) {
0212         return false;
0213     }
0214 
0215     Q_EMIT dataChanged(index, index, changedRoles);
0216     // Also notify the possible defaults change in the parent source index
0217     if (changedRoles.contains(IsDefaultRole)) {
0218         const QModelIndex sourceIndex = this->index(index.internalId() - 1, 0, QModelIndex());
0219         Q_EMIT dataChanged(sourceIndex, sourceIndex, {IsDefaultRole});
0220     }
0221     return true;
0222 }
0223 
0224 QModelIndex SourcesModel::index(int row, int column, const QModelIndex &parent) const
0225 {
0226     if (row < 0 || column != 0) {
0227         return QModelIndex();
0228     }
0229 
0230     if (parent.isValid()) {
0231         const auto events = m_data.at(parent.row()).events;
0232         if (row < events.count()) {
0233             return createIndex(row, column, parent.row() + 1);
0234         }
0235 
0236         return QModelIndex();
0237     }
0238 
0239     if (row < m_data.count()) {
0240         return createIndex(row, column, nullptr);
0241     }
0242 
0243     return QModelIndex();
0244 }
0245 
0246 QModelIndex SourcesModel::parent(const QModelIndex &child) const
0247 {
0248     if (child.internalId()) {
0249         return createIndex(child.internalId() - 1, 0, nullptr);
0250     }
0251 
0252     return QModelIndex();
0253 }
0254 
0255 QHash<int, QByteArray> SourcesModel::roleNames() const
0256 {
0257     return {
0258         {Qt::DisplayRole, QByteArrayLiteral("display")},
0259         {Qt::DecorationRole, QByteArrayLiteral("decoration")},
0260         {SourceTypeRole, QByteArrayLiteral("sourceType")},
0261         {NotifyRcNameRole, QByteArrayLiteral("notifyRcName")},
0262         {DesktopEntryRole, QByteArrayLiteral("desktopEntry")},
0263         {IsDefaultRole, QByteArrayLiteral("isDefault")},
0264         {CommentRole, QByteArrayLiteral("comment")},
0265         {ShowIconsRole, QByteArrayLiteral("showIcons")},
0266         {ActionsRole, QByteArrayLiteral("actions")},
0267         {SoundRole, QByteArrayLiteral("sound")},
0268         {DefaultActionsRole, QByteArrayLiteral("defaultActions")},
0269         {DefaultSoundRole, QByteArrayLiteral("defaultSound")},
0270     };
0271 }
0272 
0273 void SourcesModel::load()
0274 {
0275     beginResetModel();
0276 
0277     m_data.clear();
0278 
0279     QCollator collator;
0280 
0281     QList<SourceData> appsData;
0282     QList<SourceData> servicesData;
0283 
0284     QStringList notifyRcFiles;
0285     QStringList desktopEntries;
0286 
0287     // Search for notifyrc files in `/knotifications6` folders first, but also in `/knotifications5` for compatibility with KF5 applications
0288     const QStringList dirs = QStandardPaths::locateAll(QStandardPaths::GenericDataLocation, QStringLiteral("knotifications6"), QStandardPaths::LocateDirectory)
0289         + QStandardPaths::locateAll(QStandardPaths::GenericDataLocation, QStringLiteral("knotifications5"), QStandardPaths::LocateDirectory);
0290     for (const QString &dir : dirs) {
0291         const QDir dirInfo(dir);
0292         const QStringList fileNames = dirInfo.entryList(QStringList() << QStringLiteral("*.notifyrc"));
0293         for (const QString &file : fileNames) {
0294             if (notifyRcFiles.contains(file)) {
0295                 continue;
0296             }
0297             notifyRcFiles.append(file);
0298 
0299             KSharedConfig::Ptr config = KSharedConfig::openConfig(file, KConfig::NoGlobals);
0300             config->addConfigSources(QStandardPaths::locateAll(QStandardPaths::GenericDataLocation, QStringLiteral("%2/%1").arg(file).arg(dirInfo.dirName())));
0301 
0302             KConfigGroup globalGroup(config, QLatin1String("Global"));
0303 
0304             const QString notifyRcName = file.section(QLatin1Char('.'), 0, -2);
0305             const QString desktopEntry = globalGroup.readEntry(QStringLiteral("DesktopEntry"));
0306             if (!desktopEntry.isEmpty()) {
0307                 if (desktopEntries.contains(desktopEntry)) {
0308                     continue;
0309                 }
0310                 desktopEntries.append(desktopEntry);
0311             }
0312 
0313             SourceData source{
0314                 // The old KCM read the Name and Comment from global settings disregarding
0315                 // any user settings and just used user-specific files for actions config
0316                 // I'm pretty sure there's a readEntry equivalent that does that without
0317                 // reading the config stuff twice, assuming we care about this to begin with
0318                 .name = globalGroup.readEntry(QStringLiteral("Name")),
0319                 .comment = globalGroup.readEntry(QStringLiteral("Comment")),
0320                 .iconName = globalGroup.readEntry(QStringLiteral("IconName")),
0321                 .isDefault = true,
0322                 .notifyRcName = notifyRcName,
0323                 .desktopEntry = desktopEntry,
0324                 .events = {},
0325             };
0326 
0327             // Add events
0328             const QStringList groups = config->groupList().filter(s_eventGroupRegExp);
0329 
0330             QList<NotificationManager::EventSettings *> events;
0331             events.reserve(groups.size());
0332             for (const QString &group : groups) {
0333                 const QString eventId = s_eventGroupRegExp.match(group).captured(1);
0334                 events.append(new NotificationManager::EventSettings(config, eventId, this));
0335             }
0336             std::sort(events.begin(), events.end(), [&collator](NotificationManager::EventSettings *a, NotificationManager::EventSettings *b) {
0337                 return collator.compare(a->name(), b->name()) < 0;
0338             });
0339             source.events = events;
0340 
0341             if (!source.desktopEntry.isEmpty()) {
0342                 appsData.append(source);
0343             } else {
0344                 servicesData.append(source);
0345             }
0346         }
0347     }
0348 
0349     const auto services = KApplicationTrader::query([desktopEntries](const KService::Ptr &app) {
0350         if (app->noDisplay()) {
0351             return false;
0352         }
0353 
0354         if (desktopEntries.contains(app->desktopEntryName())) {
0355             return false;
0356         }
0357 
0358         if (!app->property<bool>(QStringLiteral("X-GNOME-UsesNotifications"))) {
0359             return false;
0360         }
0361 
0362         return true;
0363     });
0364 
0365     for (const auto &service : services) {
0366         appsData.append(SourceData::fromService(service));
0367         desktopEntries.append(service->desktopEntryName());
0368     }
0369 
0370     KSharedConfig::Ptr plasmanotifyrc = KSharedConfig::openConfig(u"plasmanotifyrc"_s);
0371     KConfigGroup applicationsGroup = plasmanotifyrc->group(u"Applications"_s);
0372     const QStringList seenApps = applicationsGroup.groupList();
0373     for (const QString &app : seenApps) {
0374         if (desktopEntries.contains(app)) {
0375             continue;
0376         }
0377 
0378         KService::Ptr service = KService::serviceByDesktopName(app);
0379         if (!service || service->noDisplay()) {
0380             continue;
0381         }
0382 
0383         appsData.append(SourceData::fromService(service));
0384         desktopEntries.append(service->desktopEntryName());
0385     }
0386 
0387     std::sort(appsData.begin(), appsData.end(), [&collator](const SourceData &a, const SourceData &b) {
0388         return collator.compare(a.display(), b.display()) < 0;
0389     });
0390 
0391     // Fake entry for configuring non-identifyable applications
0392     appsData << SourceData{
0393         .name = i18n("Other Applications"),
0394         .comment = {},
0395         .iconName = QStringLiteral("applications-other"),
0396         .isDefault = true,
0397         .notifyRcName = {},
0398         .desktopEntry = QStringLiteral("@other"),
0399         .events = {},
0400     };
0401 
0402     // Sort and make sure plasma_workspace is at the beginning of the list
0403     std::sort(servicesData.begin(), servicesData.end(), [&collator](const SourceData &a, const SourceData &b) {
0404         if (a.notifyRcName == s_plasmaWorkspaceNotifyRcName) {
0405             return true;
0406         }
0407         if (b.notifyRcName == s_plasmaWorkspaceNotifyRcName) {
0408             return false;
0409         }
0410         return collator.compare(a.display(), b.display()) < 0;
0411     });
0412 
0413     m_data << appsData << servicesData;
0414 
0415     endResetModel();
0416 }
0417 
0418 void SourcesModel::loadEvents()
0419 {
0420     beginResetModel();
0421 
0422     for (const SourceData &source : std::as_const(m_data)) {
0423         for (auto &event : source.events) {
0424             event->load();
0425         }
0426     }
0427 
0428     endResetModel();
0429 }
0430 
0431 void SourcesModel::saveEvents()
0432 {
0433     for (const SourceData &source : std::as_const(m_data)) {
0434         for (auto &event : source.events) {
0435             event->save();
0436         }
0437     }
0438 }
0439 
0440 bool SourcesModel::isEventDefaults() const
0441 {
0442     for (const SourceData &source : std::as_const(m_data)) {
0443         for (const auto &event : source.events) {
0444             if (!event->isDefaults()) {
0445                 return false;
0446             }
0447         }
0448     }
0449     return true;
0450 }
0451 
0452 bool SourcesModel::isEventSaveNeeded() const
0453 {
0454     for (const SourceData &source : std::as_const(m_data)) {
0455         for (const auto &event : source.events) {
0456             if (event->isSaveNeeded()) {
0457                 return true;
0458             }
0459         }
0460     }
0461     return false;
0462 }
0463 
0464 void SourcesModel::setEventDefaults()
0465 {
0466     beginResetModel();
0467 
0468     for (const SourceData &source : std::as_const(m_data)) {
0469         for (auto &event : source.events) {
0470             event->setDefaults();
0471         }
0472     }
0473 
0474     endResetModel();
0475 }
0476 
0477 SourceData SourceData::fromService(KService::Ptr service)
0478 {
0479     return SourceData{
0480         .name = service->name(),
0481         .comment = service->comment(),
0482         .iconName = service->icon(),
0483         .isDefault = true,
0484         .notifyRcName = {},
0485         .desktopEntry = service->desktopEntryName(),
0486         .events = {},
0487     };
0488 }