1 /***************************************************************************
2     Copyright (C) 2017-2020 Robby Stephenson <robby@periapsis.org>
3  ***************************************************************************/
4 
5 /***************************************************************************
6  *                                                                         *
7  *   This program is free software; you can redistribute it and/or         *
8  *   modify it under the terms of the GNU General Public License as        *
9  *   published by the Free Software Foundation; either version 2 of        *
10  *   the License or (at your option) version 3 or any later version        *
11  *   accepted by the membership of KDE e.V. (or its successor approved     *
12  *   by the membership of KDE e.V.), which shall act as a proxy            *
13  *   defined in Section 14 of version 3 of the license.                    *
14  *                                                                         *
15  *   This program is distributed in the hope that it will be useful,       *
16  *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
17  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
18  *   GNU General Public License for more details.                          *
19  *                                                                         *
20  *   You should have received a copy of the GNU General Public License     *
21  *   along with this program.  If not, see <http://www.gnu.org/licenses/>. *
22  *                                                                         *
23  ***************************************************************************/
24 
25 #include "igdbfetcher.h"
26 #include "../collections/gamecollection.h"
27 #include "../images/imagefactory.h"
28 #include "../core/filehandler.h"
29 #include "../utils/guiproxy.h"
30 #include "../utils/string_utils.h"
31 #include "../utils/tellico_utils.h"
32 #include "../core/tellico_strings.h"
33 #include "../tellico_debug.h"
34 
35 #include <KLocalizedString>
36 #include <KConfigGroup>
37 #include <KJob>
38 #include <KJobUiDelegate>
39 #include <KJobWidgets/KJobWidgets>
40 #include <KIO/StoredTransferJob>
41 
42 #include <QUrl>
43 #include <QUrlQuery>
44 #include <QLabel>
45 #include <QFile>
46 #include <QTextStream>
47 #include <QVBoxLayout>
48 #include <QTextCodec>
49 #include <QJsonDocument>
50 #include <QJsonArray>
51 #include <QJsonObject>
52 #include <QThread>
53 #include <QTimer>
54 
55 namespace {
56   static const int IGDB_MAX_RETURNS_TOTAL = 20;
57   static const char* IGDB_API_URL = "https://api.igdb.com/v4";
58   static const char* IGDB_CLIENT_ID = "hc7jojgdmkcc6divxmz0mxzzt22ehr";
59   static const char* IGDB_TOKEN_URL = "https://api.tellico-project.org/igdb/";
60 }
61 
62 using namespace Tellico;
63 using Tellico::Fetch::IGDBFetcher;
64 
IGDBFetcher(QObject * parent_)65 IGDBFetcher::IGDBFetcher(QObject* parent_)
66     : Fetcher(parent_)
67     , m_started(false) {
68   m_requestTimer.start();
69   // delay reading the platform names from the cache file
70   QTimer::singleShot(0, this, &IGDBFetcher::populateHashes);
71 }
72 
~IGDBFetcher()73 IGDBFetcher::~IGDBFetcher() {
74 }
75 
source() const76 QString IGDBFetcher::source() const {
77   return m_name.isEmpty() ? defaultName() : m_name;
78 }
79 
attribution() const80 QString IGDBFetcher::attribution() const {
81   return i18n(providedBy, QLatin1String("https://igdb.com"), QLatin1String("IGDB.com"));
82 }
83 
canSearch(Fetch::FetchKey k) const84 bool IGDBFetcher::canSearch(Fetch::FetchKey k) const {
85   return k == Keyword;
86 }
87 
canFetch(int type) const88 bool IGDBFetcher::canFetch(int type) const {
89   return type == Data::Collection::Game;
90 }
91 
readConfigHook(const KConfigGroup & config_)92 void IGDBFetcher::readConfigHook(const KConfigGroup& config_) {
93   const QString k = config_.readEntry("Access Token");
94   if(!k.isEmpty()) {
95     m_accessToken = k;
96   }
97   m_accessTokenExpires = config_.readEntry("Access Token Expires", QDateTime());
98 }
99 
saveConfigHook(KConfigGroup & config_)100 void IGDBFetcher::saveConfigHook(KConfigGroup& config_) {
101   config_.writeEntry("Access Token", m_accessToken);
102   config_.writeEntry("Access Token Expires", m_accessTokenExpires);
103 }
104 
search()105 void IGDBFetcher::search() {
106   continueSearch();
107 }
108 
continueSearch()109 void IGDBFetcher::continueSearch() {
110   m_started = true;
111 
112   QUrl u(QString::fromLatin1(IGDB_API_URL));
113   u.setPath(u.path() + QStringLiteral("/games"));
114 
115   QStringList clauseList;
116   switch(request().key()) {
117     case Keyword:
118       clauseList += QString(QStringLiteral("search \"%1\";")).arg(request().value());
119       break;
120 
121     default:
122       myWarning() << "key not recognized:" << request().key();
123       stop();
124       return;
125   }
126   clauseList += QStringLiteral("fields *,cover.url,age_ratings.*,involved_companies.*;");
127   // exclude some of the bigger unused fields
128   clauseList += QStringLiteral("exclude keywords,screenshots,tags;");
129   clauseList += QString(QStringLiteral("limit %1;")).arg(QString::number(IGDB_MAX_RETURNS_TOTAL));
130 //  myDebug() << u << clauseList.join(QStringLiteral(" "));
131 
132   m_job = igdbJob(u, clauseList.join(QStringLiteral(" ")));
133   connect(m_job.data(), &KJob::result, this, &IGDBFetcher::slotComplete);
134   markTime();
135 }
136 
stop()137 void IGDBFetcher::stop() {
138   if(!m_started) {
139     return;
140   }
141   if(m_job) {
142     m_job->kill();
143     m_job = nullptr;
144   }
145   m_started = false;
146   emit signalDone(this);
147 }
148 
fetchEntryHook(uint uid_)149 Tellico::Data::EntryPtr IGDBFetcher::fetchEntryHook(uint uid_) {
150   if(!m_entries.contains(uid_)) {
151     myDebug() << "no entry ptr";
152     return Data::EntryPtr();
153   }
154 
155   Data::EntryPtr entry = m_entries.value(uid_);
156 
157   // image might still be a URL
158   const QString image_id = entry->field(QStringLiteral("cover"));
159   if(image_id.contains(QLatin1Char('/'))) {
160     const QString id = ImageFactory::addImage(QUrl::fromUserInput(image_id), true /* quiet */);
161     if(id.isEmpty()) {
162       message(i18n("The cover image could not be loaded."), MessageHandler::Warning);
163     }
164     // empty image ID is ok
165     entry->setField(QStringLiteral("cover"), id);
166   }
167 
168   return entry;
169 }
170 
updateRequest(Data::EntryPtr entry_)171 Tellico::Fetch::FetchRequest IGDBFetcher::updateRequest(Data::EntryPtr entry_) {
172   QString title = entry_->field(QStringLiteral("title"));
173   if(!title.isEmpty()) {
174     return FetchRequest(Keyword, title);
175   }
176   return FetchRequest();
177 }
178 
slotComplete(KJob * job_)179 void IGDBFetcher::slotComplete(KJob* job_) {
180   KIO::StoredTransferJob* job = static_cast<KIO::StoredTransferJob*>(job_);
181 
182   if(job->error()) {
183     job->uiDelegate()->showErrorMessage();
184     stop();
185     return;
186   }
187 
188   const QByteArray data = job->data();
189   if(data.isEmpty()) {
190     myDebug() << "no data";
191     stop();
192     return;
193   }
194   // see bug 319662. If fetcher is cancelled, job is killed
195   // if the pointer is retained, it gets double-deleted
196   m_job = nullptr;
197 
198 #if 0
199   myWarning() << "Remove debug from igdbfetcher.cpp";
200   QFile file(QStringLiteral("/tmp/test.json"));
201   if(file.open(QIODevice::WriteOnly)) {
202     QTextStream t(&file);
203     t.setCodec("UTF-8");
204     t << data;
205   }
206   file.close();
207 #endif
208 
209   QJsonDocument doc = QJsonDocument::fromJson(data);
210   if(doc.isObject()) {
211     // probably an error message
212     QJsonObject obj = doc.object();
213     const QString msg = obj.value(QLatin1String("message")).toString();
214     myDebug() << "IGDBFetcher -" << msg;
215     message(msg, MessageHandler::Error);
216     stop();
217     return;
218   }
219 
220   Data::CollPtr coll(new Data::GameCollection(true));
221   if(optionalFields().contains(QStringLiteral("pegi"))) {
222     coll->addField(Data::Field::createDefaultField(Data::Field::PegiField));
223   }
224   if(optionalFields().contains(QStringLiteral("igdb"))) {
225     Data::FieldPtr field(new Data::Field(QStringLiteral("igdb"), i18n("IGDB Link"), Data::Field::URL));
226     field->setCategory(i18n("General"));
227     coll->addField(field);
228   }
229 
230   foreach(const QVariant& result, doc.array().toVariantList()) {
231     QVariantMap resultMap = result.toMap();
232     Data::EntryPtr baseEntry(new Data::Entry(coll));
233     populateEntry(baseEntry, resultMap);
234 
235     // for multiple platforms, return a result for each one
236     QVariantList platforms = resultMap.value(QStringLiteral("platforms")).toList();
237     foreach(const QVariant pVariant, platforms) {
238       Data::EntryPtr entry(new Data::Entry(*baseEntry));
239       const int pId = pVariant.toInt();
240       if(!m_platformHash.contains(pId)) {
241         readDataList(Platform);
242       }
243       const QString platform = Data::GameCollection::normalizePlatform(m_platformHash.value(pId));
244       // make the assumption that if the platform name isn't already in the allowed list, it should be added
245       Data::FieldPtr f = coll->fieldByName(QStringLiteral("platform"));
246       if(f && !f->allowed().contains(platform)) {
247         f->setAllowed(QStringList(f->allowed()) << platform);
248       }
249       entry->setField(QStringLiteral("platform"), platform);
250       FetchResult* r = new FetchResult(this, entry);
251       m_entries.insert(r->uid, entry);
252       emit signalResultFound(r);
253     }
254 
255     // also allow case of no platform
256     if(platforms.isEmpty()) {
257       FetchResult* r = new FetchResult(this, baseEntry);
258       m_entries.insert(r->uid, baseEntry);
259       emit signalResultFound(r);
260     }
261   }
262 
263   stop();
264 }
265 
populateEntry(Data::EntryPtr entry_,const QVariantMap & resultMap_)266 void IGDBFetcher::populateEntry(Data::EntryPtr entry_, const QVariantMap& resultMap_) {
267   entry_->setField(QStringLiteral("title"), mapValue(resultMap_, "name"));
268   entry_->setField(QStringLiteral("description"), mapValue(resultMap_, "summary"));
269 
270   QString cover = mapValue(resultMap_, "cover", "url");
271   if(cover.startsWith(QLatin1Char('/'))) {
272     cover.prepend(QStringLiteral("https:"));
273   }
274   entry_->setField(QStringLiteral("cover"), cover);
275 
276   QVariantList genreIDs = resultMap_.value(QStringLiteral("genres")).toList();
277   QStringList genres;
278   foreach(const QVariant& id, genreIDs) {
279     const int genreId = id.toInt();
280     if(!m_genreHash.contains(genreId)) {
281       readDataList(Genre);
282     }
283     const QString genre = m_genreHash.value(genreId);
284     if(!genre.isEmpty()) {
285       genres << genre;
286     }
287   }
288   entry_->setField(QStringLiteral("genre"), genres.join(FieldFormat::delimiterString()));
289 
290   qlonglong release_t = mapValue(resultMap_, "first_release_date").toLongLong();
291   if(release_t > 0) {
292     // could use QDateTime::fromSecsSinceEpoch but that was introduced in Qt 5.8
293     // while I still support Qt 5.6, in theory...
294     QDateTime dt = QDateTime::fromMSecsSinceEpoch(release_t * 1000);
295     entry_->setField(QStringLiteral("year"), QString::number(dt.date().year()));
296   }
297 
298   const QVariantList ageRatingList = resultMap_.value(QStringLiteral("age_ratings")).toList();
299   foreach(const QVariant& ageRating, ageRatingList) {
300     const QVariantMap ratingMap = ageRating.toMap();
301     // per Age Rating Enums, ESRB==1, PEGI==2
302     const int category = ratingMap.value(QStringLiteral("category")).toInt();
303     const int rating = ratingMap.value(QStringLiteral("rating")).toInt();
304     if(category == 1) {
305       if(m_esrbHash.contains(rating)) {
306         entry_->setField(QStringLiteral("certification"), m_esrbHash.value(rating));
307       } else {
308         myDebug() << "No ESRB rating for value =" << rating;
309       }
310     } else if(category == 2 && optionalFields().contains(QStringLiteral("pegi"))) {
311       entry_->setField(QStringLiteral("pegi"), m_pegiHash.value(rating));
312     }
313   }
314 
315   const QVariantList companyList = resultMap_.value(QStringLiteral("involved_companies")).toList();
316 
317   QList<int>companyIdList;
318   foreach(const QVariant& company, companyList) {
319     const QVariantMap companyMap = company.toMap();
320     const int companyId = companyMap.value(QStringLiteral("company")).toInt();
321     if(!m_companyHash.contains(companyId)) {
322       companyIdList += companyId;
323     }
324   }
325   if(!companyIdList.isEmpty()) {
326     readDataList(Company, companyIdList);
327   }
328 
329   QStringList pubs, devs;
330   foreach(const QVariant& company, companyList) {
331     const QVariantMap companyMap = company.toMap();
332     const int companyId = companyMap.value(QStringLiteral("company")).toInt();
333     const QString companyName = m_companyHash.value(companyId);
334     if(companyName.isEmpty()) {
335       continue;
336     }
337     if(companyMap.value(QStringLiteral("publisher")).toBool()) {
338       pubs += companyName;
339     } else if(companyMap.value(QStringLiteral("developer")).toBool()) {
340       devs += companyName;
341     }
342   }
343   entry_->setField(QStringLiteral("publisher"), pubs.join(FieldFormat::delimiterString()));
344   entry_->setField(QStringLiteral("developer"), devs.join(FieldFormat::delimiterString()));
345 
346   if(optionalFields().contains(QStringLiteral("igdb"))) {
347     entry_->setField(QStringLiteral("igdb"), mapValue(resultMap_, "url"));
348   }
349 }
350 
configWidget(QWidget * parent_) const351 Tellico::Fetch::ConfigWidget* IGDBFetcher::configWidget(QWidget* parent_) const {
352   return new IGDBFetcher::ConfigWidget(parent_, this);
353 }
354 
355 // Use member hash for certain field names for now.
356 // Don't expect IGDB values to change. This avoids exponentially multiplying the number of API calls
populateHashes()357 void IGDBFetcher::populateHashes() {
358   QFile genreFile(dataFileName(Genre));
359   if(genreFile.open(QIODevice::ReadOnly)) {
360     updateData(Genre, genreFile.readAll());
361   } else if(genreFile.exists()) { // don't want errors for non-existing file
362     myDebug() << "Failed to read genres from" << genreFile.fileName() << genreFile.errorString();
363   }
364 
365   QFile platformFile(dataFileName(Platform));
366   if(platformFile.open(QIODevice::ReadOnly)) {
367     updateData(Platform, platformFile.readAll());
368   } else if(platformFile.exists()) { // don't want errors for non-existing file
369     myDebug() << "Failed to read from" << platformFile.fileName() << platformFile.errorString();
370   }
371 
372   QFile companyFile(dataFileName(Company));
373   if(companyFile.open(QIODevice::ReadOnly)) {
374     updateData(Company, companyFile.readAll());
375   } else if(companyFile.exists()) { // don't want errors for non-existing file
376     myDebug() << "Failed to read from" << companyFile.fileName() << companyFile.errorString();
377   }
378 
379   // grab i18n values for ESRB from default collection
380   Data::CollPtr c(new Data::GameCollection(true));
381   QStringList esrb = c->fieldByName(QStringLiteral("certification"))->allowed();
382   Q_ASSERT(esrb.size() == 8);
383   while(esrb.size() < 8) {
384     esrb << QString();
385   }
386   // see https://api-docs.igdb.com/#age-rating
387   m_esrbHash.insert(12, esrb.at(1)); // adults only
388   m_esrbHash.insert(11, esrb.at(2)); // mature
389   m_esrbHash.insert(10, esrb.at(3)); // teen
390   m_esrbHash.insert(9,  esrb.at(4)); // e10
391   m_esrbHash.insert(8,  esrb.at(5)); // everyone
392   m_esrbHash.insert(7,  esrb.at(6)); // early childhood
393   m_esrbHash.insert(6,  esrb.at(7)); // pending
394 
395   m_pegiHash.insert(1, QStringLiteral("PEGI 3"));
396   m_pegiHash.insert(2, QStringLiteral("PEGI 7"));
397   m_pegiHash.insert(3, QStringLiteral("PEGI 12"));
398   m_pegiHash.insert(4, QStringLiteral("PEGI 16"));
399   m_pegiHash.insert(5, QStringLiteral("PEGI 18"));
400 }
401 
updateData(IgdbDataType dataType_,const QByteArray & jsonData_)402 void IGDBFetcher::updateData(IgdbDataType dataType_, const QByteArray& jsonData_) {
403   const QString idString(QStringLiteral("id"));
404   const QString nmString(QStringLiteral("name"));
405   QHash<int, QString> dataHash;
406   const QJsonArray array = QJsonDocument::fromJson(jsonData_).array();
407   for(int i = 0; i < array.size(); ++i) {
408     QJsonObject obj = array.at(i).toObject();
409     dataHash.insert(obj.value(idString).toInt(),
410                     obj.value(nmString).toString());
411   }
412 
413   // transfer read data into the correct local variable
414   switch(dataType_) {
415     case Genre:
416       m_genreHash = dataHash;
417       break;
418     case Platform:
419       m_platformHash = dataHash;
420       break;
421     case Company:
422       // company list is bigger than request size, so rather than downloading all names
423       // have to do it in chunks and then merge
424 #if (QT_VERSION < QT_VERSION_CHECK(5, 15, 0))
425       m_companyHash.unite(dataHash);
426 #else
427       m_companyHash.insert(dataHash);
428 #endif
429       break;
430   }
431 }
432 
readDataList(IgdbDataType dataType_,const QList<int> & idList_)433 void IGDBFetcher::readDataList(IgdbDataType dataType_, const QList<int>& idList_) {
434   QUrl u(QString::fromLatin1(IGDB_API_URL));
435   switch(dataType_) {
436     case Genre:
437       u.setPath(u.path() + QStringLiteral("/genres"));
438       break;
439     case Platform:
440       u.setPath(u.path() + QStringLiteral("/platforms"));
441       break;
442     case Company:
443       u.setPath(u.path() + QStringLiteral("/companies"));
444       break;
445   }
446 
447   QStringList clauseList;
448   clauseList += QStringLiteral("fields id,name;");
449   // if the id list is not empty, seach for specific data id values
450   if(!idList_.isEmpty()) {
451     // where id = (8,9,11);
452     QString clause = QStringLiteral("where id = (") + QString::number(idList_.at(0));
453     for(int i = 1; i < idList_.size(); ++i) {
454       clause += (QLatin1String(",") + QString::number(idList_.at(i)));
455     }
456     clause += QLatin1String(");");
457     clauseList += clause;
458   }
459   clauseList += QStringLiteral("limit 500;"); // biggest limit is 500 which should be enough for all
460 
461   QPointer<KIO::StoredTransferJob> job = igdbJob(u, clauseList.join(QStringLiteral(" ")));
462   markTime();
463   if(!job->exec()) {
464     myDebug() << "IGDB: data request failed";
465     myDebug() << job->errorString() << u;
466     return;
467   }
468 
469   const QByteArray data = job->data();
470   updateData(dataType_, data);
471 
472   // now save the date, but instead of just writing the job->data() to the file
473   // since the company data may have been merged, write the full set of hash values
474   QByteArray dataToWrite;
475   if(dataType_ == Company) {
476     QJsonArray array;
477     const QString idString(QStringLiteral("id"));
478     const QString nmString(QStringLiteral("name"));
479     QHashIterator<int, QString> it(m_companyHash);
480     while(it.hasNext()) {
481       it.next();
482       QJsonObject obj;
483       obj.insert(idString, it.key());
484       obj.insert(nmString, it.value());
485       array.append(obj);
486     }
487     dataToWrite = QJsonDocument(array).toJson();
488   } else {
489     dataToWrite = data;
490   }
491 
492   QFile file(dataFileName(dataType_));
493   if(!file.open(QIODevice::WriteOnly) || file.write(dataToWrite) == -1) {
494     myDebug() << "unable to write to" << file.fileName() << file.errorString();
495     return;
496   }
497   file.close();
498 }
499 
markTime() const500 void IGDBFetcher::markTime() const {
501   // rate limit is 4 requests per second
502   if(m_requestTimer.elapsed() < 250) QThread::msleep(250);
503   m_requestTimer.restart();
504 }
505 
checkAccessToken()506 void IGDBFetcher::checkAccessToken() {
507   const QDateTime now = QDateTime::currentDateTimeUtc();
508   if(!m_accessToken.isEmpty() && m_accessTokenExpires > now) {
509     // access token should be fine, nothing to do
510     return;
511   }
512 
513   QUrl u(QString::fromLatin1(IGDB_TOKEN_URL));
514 //  myDebug() << "Downloading IGDN token from" << u.toString();
515   QPointer<KIO::StoredTransferJob> job = KIO::storedHttpPost(QByteArray(), u, KIO::HideProgressInfo);
516   job->addMetaData(QStringLiteral("accept"), QStringLiteral("application/json"));
517   KJobWidgets::setWindow(job, GUI::Proxy::widget());
518   if(!job->exec()) {
519     myDebug() << "IGDB: access token request failed";
520     myDebug() << job->errorString() << u;
521     return;
522   }
523 
524   QJsonDocument doc = QJsonDocument::fromJson(job->data());
525   if(doc.isNull()) {
526     myDebug() << "IGDB: Invalid JSON";
527     return;
528   }
529   QJsonObject response = doc.object();
530   if(response.contains(QLatin1String("message"))) {
531     myDebug() << "IGDB:" << response.value(QLatin1String("message")).toString();
532   }
533   m_accessToken = response.value(QLatin1String("access_token")).toString();
534   const int expires = response.value(QLatin1String("expires_in")).toInt();
535   if(expires > 0) {
536     m_accessTokenExpires = now.addSecs(expires);
537   }
538 //  myDebug() << "Received access token" << m_accessToken << m_accessTokenExpires;
539 }
540 
defaultName()541 QString IGDBFetcher::defaultName() {
542   return i18n("Internet Game Database (IGDB.com)");
543 }
544 
defaultIcon()545 QString IGDBFetcher::defaultIcon() {
546   return favIcon("http://www.igdb.com");
547 }
548 
allOptionalFields()549 Tellico::StringHash IGDBFetcher::allOptionalFields() {
550   StringHash hash;
551   hash[QStringLiteral("pegi")] = i18n("PEGI Rating");
552   hash[QStringLiteral("igdb")] = i18n("IGDB Link");
553   return hash;
554 }
555 
dataFileName(IgdbDataType dataType_)556 QString IGDBFetcher::dataFileName(IgdbDataType dataType_) {
557   const QString dataDir = Tellico::saveLocation(QStringLiteral("igdb-data/"));
558   QString fileName;
559   switch(dataType_) {
560     case Genre:
561       fileName = dataDir + QLatin1String("genres.json");
562       break;
563     case Platform:
564       fileName = dataDir + QLatin1String("platforms.json");
565       break;
566     case Company:
567       fileName = dataDir + QLatin1String("companies.json");
568       break;
569   }
570   return fileName;
571 }
572 
igdbJob(const QUrl & url_,const QString & query_)573 QPointer<KIO::StoredTransferJob> IGDBFetcher::igdbJob(const QUrl& url_, const QString& query_) {
574   checkAccessToken();
575   QPointer<KIO::StoredTransferJob> job = KIO::storedHttpPost(query_.toUtf8(), url_, KIO::HideProgressInfo);
576   QStringList customHeaders;
577   customHeaders += (QStringLiteral("Client-ID: ") + QString::fromLatin1(IGDB_CLIENT_ID));
578   customHeaders += (QStringLiteral("Authorization: ") + QLatin1String("Bearer ") + m_accessToken);
579   job->addMetaData(QStringLiteral("customHTTPHeader"), customHeaders.join(QLatin1String("\r\n")));
580   job->addMetaData(QStringLiteral("accept"), QStringLiteral("application/json"));
581   KJobWidgets::setWindow(job, GUI::Proxy::widget());
582   return job;
583 }
584 
ConfigWidget(QWidget * parent_,const IGDBFetcher * fetcher_)585 IGDBFetcher::ConfigWidget::ConfigWidget(QWidget* parent_, const IGDBFetcher* fetcher_)
586     : Fetch::ConfigWidget(parent_) {
587   QVBoxLayout* l = new QVBoxLayout(optionsWidget());
588   l->addWidget(new QLabel(i18n("This source has no options."), optionsWidget()));
589   l->addStretch();
590 
591   // now add additional fields widget
592   addFieldsWidget(IGDBFetcher::allOptionalFields(), fetcher_ ? fetcher_->optionalFields() : QStringList());
593 }
594 
saveConfigHook(KConfigGroup &)595 void IGDBFetcher::ConfigWidget::saveConfigHook(KConfigGroup&) {
596 }
597 
preferredName() const598 QString IGDBFetcher::ConfigWidget::preferredName() const {
599   return IGDBFetcher::defaultName();
600 }
601