1 | /* |
2 | SPDX-FileCopyrightText: 2014 Aleix Pol Gonzalez <aleixpol@blue-systems.com> |
3 | |
4 | SPDX-License-Identifier: LGPL-2.1-or-later |
5 | */ |
6 | |
7 | #include "alternativesmodel.h" |
8 | #include <QDBusConnection> |
9 | #include <QDBusConnectionInterface> |
10 | #include <QDebug> |
11 | #include <QDirIterator> |
12 | #include <QIcon> |
13 | #include <QJsonArray> |
14 | #include <QMimeDatabase> |
15 | #include <QMimeType> |
16 | #include <QRegularExpression> |
17 | #include <QStandardPaths> |
18 | |
19 | #include <KConfigGroup> |
20 | #include <KJsonUtils> |
21 | #include <KPluginMetaData> |
22 | #include <KSharedConfig> |
23 | |
24 | #include "configuration.h" |
25 | #include "helper.h" |
26 | #include "job.h" |
27 | |
28 | using namespace Purpose; |
29 | |
30 | static const QStringList s_defaultDisabledPlugins = {QStringLiteral("saveasplugin" )}; |
31 | |
32 | typedef bool (*matchFunction)(const QString &constraint, const QJsonValue &value); |
33 | |
34 | static bool defaultMatch(const QString &constraint, const QJsonValue &value) |
35 | { |
36 | return value == QJsonValue(constraint); |
37 | } |
38 | |
39 | static bool mimeTypeMatch(const QString &constraint, const QJsonValue &value) |
40 | { |
41 | if (value.isArray()) { |
42 | const auto array = value.toArray(); |
43 | for (const QJsonValue &val : array) { |
44 | if (mimeTypeMatch(constraint, value: val)) |
45 | return true; |
46 | } |
47 | return false; |
48 | } else if (value.isObject()) { |
49 | for (const QJsonValue &val : value.toObject()) { |
50 | if (mimeTypeMatch(constraint, value: val)) |
51 | return true; |
52 | } |
53 | return false; |
54 | } else if (constraint.contains(c: QLatin1Char('*'))) { |
55 | const QRegularExpression re(QRegularExpression::wildcardToRegularExpression(str: constraint), QRegularExpression::CaseInsensitiveOption); |
56 | return re.match(subject: value.toString()).hasMatch(); |
57 | } else { |
58 | QMimeDatabase db; |
59 | QMimeType mime = db.mimeTypeForName(nameOrAlias: value.toString()); |
60 | return mime.inherits(mimeTypeName: constraint); |
61 | } |
62 | } |
63 | |
64 | static bool dbusMatch(const QString &constraint, const QJsonValue &value) |
65 | { |
66 | Q_UNUSED(value) |
67 | return QDBusConnection::sessionBus().interface()->isServiceRegistered(serviceName: constraint); |
68 | } |
69 | |
70 | static bool executablePresent(const QString &constraint, const QJsonValue &value) |
71 | { |
72 | Q_UNUSED(value) |
73 | return !QStandardPaths::findExecutable(executableName: constraint).isEmpty(); |
74 | } |
75 | |
76 | static bool desktopFilePresent(const QString &constraint, const QJsonValue &value) |
77 | { |
78 | Q_UNUSED(value) |
79 | return !QStandardPaths::locate(type: QStandardPaths::ApplicationsLocation, fileName: constraint).isEmpty(); |
80 | } |
81 | |
82 | static QMap<QString, matchFunction> s_matchFunctions = { |
83 | {QStringLiteral("mimeType" ), mimeTypeMatch}, |
84 | {QStringLiteral("dbus" ), dbusMatch}, |
85 | {QStringLiteral("application" ), desktopFilePresent}, |
86 | {QStringLiteral("exec" ), executablePresent}, |
87 | }; |
88 | |
89 | class Purpose::AlternativesModelPrivate |
90 | { |
91 | public: |
92 | QList<KPluginMetaData> m_plugins; |
93 | QJsonObject m_inputData; |
94 | QString m_pluginType; |
95 | QStringList m_disabledPlugins = s_defaultDisabledPlugins; |
96 | QJsonObject m_pluginTypeData; |
97 | const QRegularExpression constraintRx{QStringLiteral("(\\w+):(.*)" )}; |
98 | |
99 | bool isPluginAcceptable(const KPluginMetaData &meta, const QStringList &disabledPlugins) const |
100 | { |
101 | const QJsonObject obj = meta.rawData(); |
102 | if (!obj.value(key: QLatin1String("X-Purpose-PluginTypes" )).toArray().contains(element: m_pluginType)) { |
103 | // qDebug() << "discarding" << meta.name() << KPluginMetaData::readStringList(meta.rawData(), QStringLiteral("X-Purpose-PluginTypes")); |
104 | return false; |
105 | } |
106 | |
107 | if (disabledPlugins.contains(str: meta.pluginId()) || m_disabledPlugins.contains(str: meta.pluginId())) { |
108 | // qDebug() << "disabled plugin" << meta.name() << meta.pluginId(); |
109 | return false; |
110 | } |
111 | |
112 | // All constraints must match |
113 | const QJsonArray constraints = obj.value(key: QLatin1String("X-Purpose-Constraints" )).toArray(); |
114 | for (const QJsonValue &constraint : constraints) { |
115 | if (!constraintMatches(meta, constraint)) |
116 | return false; |
117 | } |
118 | return true; |
119 | } |
120 | |
121 | bool constraintMatches(const KPluginMetaData &meta, const QJsonValue &constraint) const |
122 | { |
123 | // Treat an array as an OR |
124 | if (constraint.isArray()) { |
125 | const QJsonArray options = constraint.toArray(); |
126 | for (const auto &option : options) { |
127 | if (constraintMatches(meta, constraint: option)) { |
128 | return true; |
129 | } |
130 | } |
131 | return false; |
132 | } |
133 | Q_ASSERT(constraintRx.isValid()); |
134 | QRegularExpressionMatch match = constraintRx.match(subject: constraint.toString()); |
135 | if (!match.isValid() || !match.hasMatch()) { |
136 | qWarning() << "wrong constraint" << constraint.toString(); |
137 | return false; |
138 | } |
139 | const QString propertyName = match.captured(nth: 1); |
140 | const QString constrainedValue = match.captured(nth: 2); |
141 | const bool acceptable = s_matchFunctions.value(key: propertyName, defaultValue: defaultMatch)(constrainedValue, m_inputData.value(key: propertyName)); |
142 | if (!acceptable) { |
143 | // qDebug() << "not accepted" << meta.name() << propertyName << constrainedValue << m_inputData[propertyName]; |
144 | } |
145 | return acceptable; |
146 | } |
147 | }; |
148 | |
149 | AlternativesModel::AlternativesModel(QObject *parent) |
150 | : QAbstractListModel(parent) |
151 | , d_ptr(new AlternativesModelPrivate) |
152 | { |
153 | } |
154 | |
155 | AlternativesModel::~AlternativesModel() |
156 | { |
157 | Q_D(AlternativesModel); |
158 | delete d; |
159 | } |
160 | |
161 | QHash<int, QByteArray> AlternativesModel::roleNames() const |
162 | { |
163 | QHash<int, QByteArray> roles = QAbstractListModel::roleNames(); |
164 | roles.insert(key: IconNameRole, QByteArrayLiteral("iconName" )); |
165 | roles.insert(key: PluginIdRole, QByteArrayLiteral("pluginId" )); |
166 | roles.insert(key: ActionDisplayRole, QByteArrayLiteral("actionDisplay" )); |
167 | return roles; |
168 | } |
169 | |
170 | void AlternativesModel::setInputData(const QJsonObject &input) |
171 | { |
172 | Q_D(AlternativesModel); |
173 | if (input == d->m_inputData) |
174 | return; |
175 | |
176 | d->m_inputData = input; |
177 | initializeModel(); |
178 | |
179 | Q_EMIT inputDataChanged(); |
180 | } |
181 | |
182 | void AlternativesModel::setPluginType(const QString &pluginType) |
183 | { |
184 | Q_D(AlternativesModel); |
185 | if (pluginType == d->m_pluginType) |
186 | return; |
187 | |
188 | d->m_pluginTypeData = Purpose::readPluginType(pluginType); |
189 | d->m_pluginType = pluginType; |
190 | Q_ASSERT(d->m_pluginTypeData.isEmpty() == d->m_pluginType.isEmpty()); |
191 | |
192 | initializeModel(); |
193 | |
194 | Q_EMIT pluginTypeChanged(); |
195 | } |
196 | |
197 | QStringList AlternativesModel::disabledPlugins() const |
198 | { |
199 | Q_D(const AlternativesModel); |
200 | return d->m_disabledPlugins; |
201 | } |
202 | |
203 | void AlternativesModel::setDisabledPlugins(const QStringList &pluginIds) |
204 | { |
205 | Q_D(AlternativesModel); |
206 | if (pluginIds == d->m_disabledPlugins) |
207 | return; |
208 | |
209 | d->m_disabledPlugins = pluginIds; |
210 | |
211 | initializeModel(); |
212 | |
213 | Q_EMIT disabledPluginsChanged(); |
214 | } |
215 | |
216 | QString AlternativesModel::pluginType() const |
217 | { |
218 | Q_D(const AlternativesModel); |
219 | return d->m_pluginType; |
220 | } |
221 | |
222 | QJsonObject AlternativesModel::inputData() const |
223 | { |
224 | Q_D(const AlternativesModel); |
225 | return d->m_inputData; |
226 | } |
227 | |
228 | Purpose::Configuration *AlternativesModel::configureJob(int row) |
229 | { |
230 | Q_D(AlternativesModel); |
231 | const KPluginMetaData pluginData = d->m_plugins.at(i: row); |
232 | return new Configuration(d->m_inputData, d->m_pluginType, d->m_pluginTypeData, pluginData, this); |
233 | } |
234 | |
235 | int AlternativesModel::rowCount(const QModelIndex &parent) const |
236 | { |
237 | Q_D(const AlternativesModel); |
238 | return parent.isValid() ? 0 : d->m_plugins.count(); |
239 | } |
240 | |
241 | QVariant AlternativesModel::data(const QModelIndex &index, int role) const |
242 | { |
243 | Q_D(const AlternativesModel); |
244 | if (!index.isValid() || index.row() > d->m_plugins.count()) |
245 | return QVariant(); |
246 | |
247 | KPluginMetaData data = d->m_plugins[index.row()]; |
248 | switch (role) { |
249 | case Qt::DisplayRole: |
250 | return data.name(); |
251 | case Qt::ToolTip: |
252 | return data.description(); |
253 | case IconNameRole: |
254 | return data.iconName(); |
255 | case Qt::DecorationRole: |
256 | return QIcon::fromTheme(name: data.iconName()); |
257 | case PluginIdRole: |
258 | return data.pluginId(); |
259 | case ActionDisplayRole: { |
260 | const QJsonObject pluginData = data.rawData().value(key: QLatin1String("KPlugin" )).toObject(); |
261 | const QString action = KJsonUtils::readTranslatedString(jo: pluginData, QStringLiteral("X-Purpose-ActionDisplay" )); |
262 | return action.isEmpty() ? data.name() : action; |
263 | } |
264 | } |
265 | return QVariant(); |
266 | } |
267 | |
268 | static QList<KPluginMetaData> findScriptedPackages(std::function<bool(const KPluginMetaData &)> filter) |
269 | { |
270 | QList<KPluginMetaData> ret; |
271 | QSet<QString> addedPlugins; |
272 | const QStringList dirs = |
273 | QStandardPaths::locateAll(type: QStandardPaths::GenericDataLocation, QStringLiteral("kpackage/Purpose" ), options: QStandardPaths::LocateDirectory); |
274 | for (const QString &dir : dirs) { |
275 | QDirIterator dirIt(dir, QDir::Dirs | QDir::NoDotAndDotDot); |
276 | |
277 | for (; dirIt.hasNext();) { |
278 | QDir dir(dirIt.next()); |
279 | Q_ASSERT(dir.exists()); |
280 | if (!dir.exists(QStringLiteral("metadata.json" ))) |
281 | continue; |
282 | |
283 | const KPluginMetaData info = Purpose::createMetaData(file: dir.absoluteFilePath(QStringLiteral("metadata.json" ))); |
284 | if (!addedPlugins.contains(value: info.pluginId()) && filter(info)) { |
285 | addedPlugins << info.pluginId(); |
286 | ret += info; |
287 | } |
288 | } |
289 | } |
290 | |
291 | return ret; |
292 | } |
293 | |
294 | void AlternativesModel::initializeModel() |
295 | { |
296 | Q_D(AlternativesModel); |
297 | if (d->m_pluginType.isEmpty()) { |
298 | return; |
299 | } |
300 | |
301 | const QJsonArray inbound = d->m_pluginTypeData.value(key: QLatin1String("X-Purpose-InboundArguments" )).toArray(); |
302 | for (const QJsonValue &arg : inbound) { |
303 | if (!d->m_inputData.contains(key: arg.toString())) { |
304 | qWarning().nospace() << "Cannot initialize model with data " << d->m_inputData << ". missing: " << arg; |
305 | return; |
306 | } |
307 | } |
308 | |
309 | const auto config = KSharedConfig::openConfig(QStringLiteral("purposerc" )); |
310 | const auto group = config->group(QStringLiteral("plugins" )); |
311 | const QStringList disabledPlugins = group.readEntry(key: "disabled" , aDefault: QStringList()); |
312 | auto pluginAcceptable = [d, disabledPlugins](const KPluginMetaData &meta) { |
313 | return d->isPluginAcceptable(meta, disabledPlugins); |
314 | }; |
315 | |
316 | beginResetModel(); |
317 | d->m_plugins.clear(); |
318 | d->m_plugins << KPluginMetaData::findPlugins(QStringLiteral("kf6/purpose" ), filter: pluginAcceptable); |
319 | d->m_plugins += findScriptedPackages(filter: pluginAcceptable); |
320 | endResetModel(); |
321 | } |
322 | |
323 | #include "moc_alternativesmodel.cpp" |
324 | |