1/*
2 SPDX-FileCopyrightText: 2009-2010 Frederik Gladhorn <gladhorn@kde.org>
3
4 SPDX-License-Identifier: LGPL-2.1-or-later
5*/
6
7#include "atticaprovider_p.h"
8
9#include "commentsmodel.h"
10#include "entry_p.h"
11#include "question.h"
12#include "tagsfilterchecker.h"
13
14#include <KFormat>
15#include <KLocalizedString>
16#include <QCollator>
17#include <QDomDocument>
18#include <QTimer>
19#include <knewstuffcore_debug.h>
20
21#include <attica/accountbalance.h>
22#include <attica/config.h>
23#include <attica/content.h>
24#include <attica/downloaditem.h>
25#include <attica/listjob.h>
26#include <attica/person.h>
27#include <attica/provider.h>
28#include <attica/providermanager.h>
29
30#include "atticarequester_p.h"
31#include "categorymetadata.h"
32#include "categorymetadata_p.h"
33
34using namespace Attica;
35
36namespace KNSCore
37{
38AtticaProvider::AtticaProvider(const QStringList &categories, const QString &additionalAgentInformation)
39 : mInitialized(false)
40{
41 // init categories map with invalid categories
42 for (const QString &category : categories) {
43 mCategoryMap.insert(key: category, value: Attica::Category());
44 }
45
46 connect(sender: &m_providerManager, signal: &ProviderManager::providerAdded, context: this, slot: [this, additionalAgentInformation](const Attica::Provider &provider) {
47 providerLoaded(provider);
48 m_provider.setAdditionalAgentInformation(additionalAgentInformation);
49 });
50 connect(sender: &m_providerManager, signal: &ProviderManager::authenticationCredentialsMissing, context: this, slot: &AtticaProvider::onAuthenticationCredentialsMissing);
51}
52
53AtticaProvider::AtticaProvider(const Attica::Provider &provider, const QStringList &categories, const QString &additionalAgentInformation)
54 : mInitialized(false)
55{
56 // init categories map with invalid categories
57 for (const QString &category : categories) {
58 mCategoryMap.insert(key: category, value: Attica::Category());
59 }
60 providerLoaded(provider);
61 m_provider.setAdditionalAgentInformation(additionalAgentInformation);
62}
63
64QString AtticaProvider::id() const
65{
66 return m_providerId;
67}
68
69void AtticaProvider::onAuthenticationCredentialsMissing(const Attica::Provider &)
70{
71 qCDebug(KNEWSTUFFCORE) << "Authentication missing!";
72 // FIXME Show authentication dialog
73}
74
75bool AtticaProvider::setProviderXML(const QDomElement &xmldata)
76{
77 if (xmldata.tagName() != QLatin1String("provider")) {
78 return false;
79 }
80
81 // FIXME this is quite ugly, repackaging the xml into a string
82 QDomDocument doc(QStringLiteral("temp"));
83 qCDebug(KNEWSTUFFCORE) << "setting provider xml" << doc.toString();
84
85 doc.appendChild(newChild: xmldata.cloneNode(deep: true));
86 m_providerManager.addProviderFromXml(providerXml: doc.toString());
87
88 if (!m_providerManager.providers().isEmpty()) {
89 qCDebug(KNEWSTUFFCORE) << "base url of attica provider:" << m_providerManager.providers().constLast().baseUrl().toString();
90 } else {
91 qCCritical(KNEWSTUFFCORE) << "Could not load provider.";
92 return false;
93 }
94 return true;
95}
96
97void AtticaProvider::setCachedEntries(const KNSCore::Entry::List &cachedEntries)
98{
99 mCachedEntries = cachedEntries;
100}
101
102void AtticaProvider::providerLoaded(const Attica::Provider &provider)
103{
104 m_name = provider.name();
105 m_icon = provider.icon();
106 qCDebug(KNEWSTUFFCORE) << "Added provider: " << provider.name();
107
108 m_provider = provider;
109 m_provider.setAdditionalAgentInformation(name());
110 m_providerId = provider.baseUrl().host();
111
112 Attica::ListJob<Attica::Category> *job = m_provider.requestCategories();
113 connect(sender: job, signal: &BaseJob::finished, context: this, slot: &AtticaProvider::listOfCategoriesLoaded);
114 job->start();
115}
116
117void AtticaProvider::listOfCategoriesLoaded(Attica::BaseJob *listJob)
118{
119 if (!jobSuccess(job: listJob)) {
120 return;
121 }
122
123 qCDebug(KNEWSTUFFCORE) << "loading categories: " << mCategoryMap.keys();
124
125 auto *job = static_cast<Attica::ListJob<Attica::Category> *>(listJob);
126 const Category::List categoryList = job->itemList();
127
128 QList<CategoryMetadata> categoryMetadataList;
129 for (const Category &category : categoryList) {
130 if (mCategoryMap.contains(key: category.name())) {
131 qCDebug(KNEWSTUFFCORE) << "Adding category: " << category.name() << category.displayName();
132 // If there is only the placeholder category, replace it
133 if (mCategoryMap.contains(key: category.name()) && !mCategoryMap.value(key: category.name()).isValid()) {
134 mCategoryMap.replace(key: category.name(), value: category);
135 } else {
136 mCategoryMap.insert(key: category.name(), value: category);
137 }
138
139 categoryMetadataList << CategoryMetadata(new CategoryMetadataPrivate{
140 .id = category.id(),
141 .name = category.name(),
142 .displayName = category.displayName(),
143 });
144 }
145 }
146 std::sort(first: categoryMetadataList.begin(), last: categoryMetadataList.end(), comp: [](const auto &i, const auto &j) -> bool {
147 const QString a(i.displayName().isEmpty() ? i.name() : i.displayName());
148 const QString b(j.displayName().isEmpty() ? j.name() : j.displayName());
149
150 return (QCollator().compare(s1: a, s2: b) < 0);
151 });
152
153 bool correct = false;
154 for (auto it = mCategoryMap.cbegin(), itEnd = mCategoryMap.cend(); it != itEnd; ++it) {
155 if (!it.value().isValid()) {
156 qCWarning(KNEWSTUFFCORE) << "Could not find category" << it.key();
157 } else {
158 correct = true;
159 }
160 }
161
162 if (correct) {
163 mInitialized = true;
164 Q_EMIT providerInitialized(this);
165 Q_EMIT categoriesMetadataLoaded(categories: categoryMetadataList);
166 } else {
167 Q_EMIT signalErrorCode(errorCode: KNSCore::ErrorCode::ConfigFileError, i18n("All categories are missing"), metadata: QVariant());
168 }
169}
170
171bool AtticaProvider::isInitialized() const
172{
173 return mInitialized;
174}
175
176void AtticaProvider::loadEntries(const KNSCore::SearchRequest &request)
177{
178 auto requester = new AtticaRequester(request, this, this);
179 connect(sender: requester, signal: &AtticaRequester::entryDetailsLoaded, context: this, slot: &AtticaProvider::entryDetailsLoaded);
180 connect(sender: requester, signal: &AtticaRequester::entriesLoaded, context: this, slot: [this, requester](const KNSCore::Entry::List &list) {
181 Q_EMIT entriesLoaded(requester->request(), list);
182 });
183 connect(sender: requester, signal: &AtticaRequester::loadingDone, context: this, slot: [this, requester] {
184 Q_EMIT loadingDone(requester->request());
185 });
186 connect(sender: requester, signal: &AtticaRequester::loadingFailed, context: this, slot: [this, requester] {
187 Q_EMIT loadingFailed(requester->request());
188 });
189 requester->start();
190}
191
192void AtticaProvider::loadEntryDetails(const KNSCore::Entry &entry)
193{
194 ItemJob<Content> *job = m_provider.requestContent(contentId: entry.uniqueId());
195 connect(sender: job, signal: &BaseJob::finished, context: this, slot: [this, entry] {
196 Q_EMIT entryDetailsLoaded(entry);
197 });
198 job->start();
199}
200
201void AtticaProvider::loadPayloadLink(const KNSCore::Entry &entry, int linkId)
202{
203 Attica::Content content = mCachedContent.value(key: entry.uniqueId());
204 const DownloadDescription desc = content.downloadUrlDescription(number: linkId);
205
206 if (desc.hasPrice()) {
207 // Ask for balance, then show information...
208 ItemJob<AccountBalance> *job = m_provider.requestAccountBalance();
209 connect(sender: job, signal: &BaseJob::finished, context: this, slot: &AtticaProvider::accountBalanceLoaded);
210 mDownloadLinkJobs[job] = qMakePair(value1: entry, value2&: linkId);
211 job->start();
212
213 qCDebug(KNEWSTUFFCORE) << "get account balance";
214 } else {
215 ItemJob<DownloadItem> *job = m_provider.downloadLink(contentId: entry.uniqueId(), itemId: QString::number(linkId));
216 connect(sender: job, signal: &BaseJob::finished, context: this, slot: &AtticaProvider::downloadItemLoaded);
217 mDownloadLinkJobs[job] = qMakePair(value1: entry, value2&: linkId);
218 job->start();
219
220 qCDebug(KNEWSTUFFCORE) << " link for " << entry.uniqueId();
221 }
222}
223
224void AtticaProvider::loadComments(const Entry &entry, int commentsPerPage, int page)
225{
226 ListJob<Attica::Comment> *job = m_provider.requestComments(commentType: Attica::Comment::ContentComment, id: entry.uniqueId(), QStringLiteral("0"), page, pageSize: commentsPerPage);
227 connect(sender: job, signal: &BaseJob::finished, context: this, slot: &AtticaProvider::loadedComments);
228 job->start();
229}
230
231QList<std::shared_ptr<KNSCore::Comment>> getCommentsList(const Attica::Comment::List &comments, std::shared_ptr<KNSCore::Comment> parent)
232{
233 QList<std::shared_ptr<KNSCore::Comment>> knsComments;
234 for (const Attica::Comment &comment : comments) {
235 qCDebug(KNEWSTUFFCORE) << "Appending comment with id" << comment.id() << ", which has" << comment.childCount() << "children";
236 auto knsComment = std::make_shared<KNSCore::Comment>();
237 knsComment->id = comment.id();
238 knsComment->subject = comment.subject();
239 knsComment->text = comment.text();
240 knsComment->childCount = comment.childCount();
241 knsComment->username = comment.user();
242 knsComment->date = comment.date();
243 knsComment->score = comment.score();
244 knsComment->parent = parent;
245 knsComments << knsComment;
246 if (comment.childCount() > 0) {
247 qCDebug(KNEWSTUFFCORE) << "Getting more comments, as this one has children, and we currently have this number of comments:" << knsComments.count();
248 knsComments << getCommentsList(comments: comment.children(), parent: knsComment);
249 qCDebug(KNEWSTUFFCORE) << "After getting the children, we now have the following number of comments:" << knsComments.count();
250 }
251 }
252 return knsComments;
253}
254
255void AtticaProvider::loadedComments(Attica::BaseJob *baseJob)
256{
257 if (!jobSuccess(job: baseJob)) {
258 return;
259 }
260
261 auto *job = static_cast<ListJob<Attica::Comment> *>(baseJob);
262 Attica::Comment::List comments = job->itemList();
263
264 QList<std::shared_ptr<KNSCore::Comment>> receivedComments = getCommentsList(comments, parent: nullptr);
265 Q_EMIT commentsLoaded(comments: receivedComments);
266}
267
268void AtticaProvider::loadPerson(const QString &username)
269{
270 if (m_provider.hasPersonService()) {
271 ItemJob<Attica::Person> *job = m_provider.requestPerson(id: username);
272 job->setProperty(name: "username", value: username);
273 connect(sender: job, signal: &BaseJob::finished, context: this, slot: &AtticaProvider::loadedPerson);
274 job->start();
275 }
276}
277
278void AtticaProvider::loadedPerson(Attica::BaseJob *baseJob)
279{
280 if (!jobSuccess(job: baseJob)) {
281 return;
282 }
283
284 auto *job = static_cast<ItemJob<Attica::Person> *>(baseJob);
285 Attica::Person person = job->result();
286
287 auto author = std::make_shared<KNSCore::Author>();
288 // This is a touch hack-like, but it ensures we actually have the data in case it is not returned by the server
289 author->setId(job->property(name: "username").toString());
290 author->setName(QStringLiteral("%1 %2").arg(args: person.firstName(), args: person.lastName()).trimmed());
291 author->setHomepage(person.homepage());
292 author->setProfilepage(person.extendedAttribute(QStringLiteral("profilepage")));
293 author->setAvatarUrl(person.avatarUrl());
294 author->setDescription(person.extendedAttribute(QStringLiteral("description")));
295 Q_EMIT personLoaded(author);
296}
297
298void AtticaProvider::loadedConfig(Attica::BaseJob *baseJob)
299{
300 if (!jobSuccess(job: baseJob)) {
301 return;
302 }
303
304 auto *job = dynamic_cast<ItemJob<Attica::Config> *>(baseJob);
305 Attica::Config config = job->result();
306 m_version = config.version();
307 m_supportsSsl = config.ssl();
308 m_contactEmail = config.contact();
309 const auto protocol = [&config] {
310 QString protocol{QStringLiteral("http")};
311 if (config.ssl()) {
312 protocol = QStringLiteral("https");
313 }
314 return protocol;
315 }();
316 m_website = [&config, &protocol] {
317 // There is usually no protocol in the website and host, but in case
318 // there is, trust what's there
319 if (config.website().contains(s: QLatin1String("://"))) {
320 return QUrl(config.website());
321 }
322 return QUrl(QLatin1String("%1://%2").arg(args: protocol).arg(a: config.website()));
323 }();
324 m_host = [&config, &protocol] {
325 if (config.host().contains(s: QLatin1String("://"))) {
326 return QUrl(config.host());
327 }
328 return QUrl(QLatin1String("%1://%2").arg(args: protocol).arg(a: config.host()));
329 }();
330
331 Q_EMIT basicsLoaded();
332}
333
334void AtticaProvider::accountBalanceLoaded(Attica::BaseJob *baseJob)
335{
336 if (!jobSuccess(job: baseJob)) {
337 return;
338 }
339
340 auto *job = static_cast<ItemJob<AccountBalance> *>(baseJob);
341 AccountBalance item = job->result();
342
343 QPair<Entry, int> pair = mDownloadLinkJobs.take(key: job);
344 Entry entry(pair.first);
345 Content content = mCachedContent.value(key: entry.uniqueId());
346 if (content.downloadUrlDescription(number: pair.second).priceAmount() < item.balance()) {
347 qCDebug(KNEWSTUFFCORE) << "Your balance is greater than the price." << content.downloadUrlDescription(number: pair.second).priceAmount()
348 << " balance: " << item.balance();
349 Question question;
350 question.setEntry(entry);
351 question.setQuestion(i18nc("the price of a download item, parameter 1 is the currency, 2 is the price",
352 "This item costs %1 %2.\nDo you want to buy it?",
353 item.currency(),
354 content.downloadUrlDescription(pair.second).priceAmount()));
355 if (question.ask() == Question::YesResponse) {
356 ItemJob<DownloadItem> *job = m_provider.downloadLink(contentId: entry.uniqueId(), itemId: QString::number(pair.second));
357 connect(sender: job, signal: &BaseJob::finished, context: this, slot: &AtticaProvider::downloadItemLoaded);
358 mDownloadLinkJobs[job] = qMakePair(value1&: entry, value2&: pair.second);
359 job->start();
360 } else {
361 return;
362 }
363 } else {
364 qCDebug(KNEWSTUFFCORE) << "You don't have enough money on your account!" << content.downloadUrlDescription(number: 0).priceAmount()
365 << " balance: " << item.balance();
366 Q_EMIT signalInformation(i18n("Your account balance is too low:\nYour balance: %1\nPrice: %2", //
367 item.balance(),
368 content.downloadUrlDescription(0).priceAmount()));
369 }
370}
371
372void AtticaProvider::downloadItemLoaded(BaseJob *baseJob)
373{
374 if (!jobSuccess(job: baseJob)) {
375 return;
376 }
377
378 auto *job = static_cast<ItemJob<DownloadItem> *>(baseJob);
379 DownloadItem item = job->result();
380
381 Entry entry = mDownloadLinkJobs.take(key: job).first;
382 entry.setPayload(QString(item.url().toString()));
383 Q_EMIT payloadLinkLoaded(entry);
384}
385
386void AtticaProvider::vote(const Entry &entry, uint rating)
387{
388 PostJob *job = m_provider.voteForContent(contentId: entry.uniqueId(), rating);
389 connect(sender: job, signal: &BaseJob::finished, context: this, slot: &AtticaProvider::votingFinished);
390 job->start();
391}
392
393void AtticaProvider::votingFinished(Attica::BaseJob *job)
394{
395 if (!jobSuccess(job)) {
396 return;
397 }
398 Q_EMIT signalInformation(i18nc("voting for an item (good/bad)", "Your vote was recorded."));
399}
400
401void AtticaProvider::becomeFan(const Entry &entry)
402{
403 PostJob *job = m_provider.becomeFan(contentId: entry.uniqueId());
404 connect(sender: job, signal: &BaseJob::finished, context: this, slot: &AtticaProvider::becomeFanFinished);
405 job->start();
406}
407
408void AtticaProvider::becomeFanFinished(Attica::BaseJob *job)
409{
410 if (!jobSuccess(job)) {
411 return;
412 }
413 Q_EMIT signalInformation(i18n("You are now a fan."));
414}
415
416bool AtticaProvider::jobSuccess(Attica::BaseJob *job)
417{
418 if (job->metadata().error() == Attica::Metadata::NoError) {
419 return true;
420 }
421 qCDebug(KNEWSTUFFCORE) << "job error: " << job->metadata().error() << " status code: " << job->metadata().statusCode() << job->metadata().message();
422
423 if (job->metadata().error() == Attica::Metadata::NetworkError) {
424 if (job->metadata().statusCode() == 503) {
425 QDateTime retryAfter;
426 static const QByteArray retryAfterKey{"Retry-After"};
427 for (const QNetworkReply::RawHeaderPair &headerPair : job->metadata().headers()) {
428 if (headerPair.first == retryAfterKey) {
429 // Retry-After is not a known header, so we need to do a bit of running around to make that work
430 // Also, the fromHttpDate function is in the private qnetworkrequest header, so we can't use that
431 // So, simple workaround, just pass it through a dummy request and get a formatted date out (the
432 // cost is sufficiently low here, given we've just done a bunch of i/o heavy things, so...)
433 QNetworkRequest dummyRequest;
434 dummyRequest.setRawHeader(headerName: QByteArray{"Last-Modified"}, value: headerPair.second);
435 retryAfter = dummyRequest.header(header: QNetworkRequest::LastModifiedHeader).toDateTime();
436 break;
437 }
438 }
439 static const KFormat formatter;
440 Q_EMIT signalErrorCode(errorCode: KNSCore::ErrorCode::TryAgainLaterError,
441 i18n("The service is currently undergoing maintenance and is expected to be back in %1.",
442 formatter.formatSpelloutDuration(retryAfter.toMSecsSinceEpoch() - QDateTime::currentMSecsSinceEpoch())),
443 metadata: {retryAfter});
444 } else {
445 Q_EMIT signalErrorCode(errorCode: KNSCore::ErrorCode::NetworkError,
446 i18n("Network error %1: %2", job->metadata().statusCode(), job->metadata().statusString()),
447 metadata: job->metadata().statusCode());
448 }
449 }
450 if (job->metadata().error() == Attica::Metadata::OcsError) {
451 if (job->metadata().statusCode() == 200) {
452 Q_EMIT signalErrorCode(errorCode: KNSCore::ErrorCode::OcsError,
453 i18n("Too many requests to server. Please try again in a few minutes."),
454 metadata: job->metadata().statusCode());
455 } else if (job->metadata().statusCode() == 405) {
456 Q_EMIT signalErrorCode(errorCode: KNSCore::ErrorCode::OcsError,
457 i18n("The Open Collaboration Services instance %1 does not support the attempted function.", name()),
458 metadata: job->metadata().statusCode());
459 } else {
460 Q_EMIT signalErrorCode(errorCode: KNSCore::ErrorCode::OcsError,
461 i18n("Unknown Open Collaboration Service API error. (%1)", job->metadata().statusCode()),
462 metadata: job->metadata().statusCode());
463 }
464 }
465
466 if (auto searchRequestVar = job->property(name: "searchRequest"); searchRequestVar.isValid()) {
467 auto req = searchRequestVar.value<SearchRequest>();
468 Q_EMIT loadingFailed(req);
469 }
470 return false;
471}
472
473void AtticaProvider::updateOnFirstBasicsGet()
474{
475 if (!m_basicsGot) {
476 m_basicsGot = true;
477 QTimer::singleShot(interval: 0, receiver: this, slot: [this] {
478 Attica::ItemJob<Attica::Config> *configJob = m_provider.requestConfig();
479 connect(sender: configJob, signal: &BaseJob::finished, context: this, slot: &AtticaProvider::loadedConfig);
480 configJob->start();
481 });
482 }
483};
484
485QString AtticaProvider::name() const
486{
487 return m_name;
488}
489
490QUrl AtticaProvider::icon() const
491{
492 return m_icon;
493}
494
495QString AtticaProvider::version()
496{
497 updateOnFirstBasicsGet();
498 return m_version;
499}
500
501QUrl AtticaProvider::website()
502{
503 updateOnFirstBasicsGet();
504 return m_website;
505}
506
507QUrl AtticaProvider::host()
508{
509 updateOnFirstBasicsGet();
510 return m_host;
511}
512
513QString AtticaProvider::contactEmail()
514{
515 updateOnFirstBasicsGet();
516 return m_contactEmail;
517}
518
519bool AtticaProvider::supportsSsl()
520{
521 updateOnFirstBasicsGet();
522 return m_supportsSsl;
523}
524
525} // namespace KNSCore
526
527#include "moc_atticaprovider_p.cpp"
528

source code of knewstuff/src/attica/atticaprovider.cpp