1 /***************************************************************************
2     Copyright (C) 2012-2019 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 "thegamesdbfetcher.h"
26 #include "../collections/gamecollection.h"
27 #include "../images/imagefactory.h"
28 #include "../gui/combobox.h"
29 #include "../core/filehandler.h"
30 #include "../utils/guiproxy.h"
31 #include "../utils/string_utils.h"
32 #include "../utils/tellico_utils.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 <QLabel>
43 #include <QLineEdit>
44 #include <QFile>
45 #include <QTextStream>
46 #include <QGridLayout>
47 #include <QTextCodec>
48 #include <QJsonDocument>
49 #include <QJsonArray>
50 #include <QJsonObject>
51 #include <QUrlQuery>
52 #include <QTimer>
53 
54 namespace {
55   static const int THEGAMESDB_MAX_RETURNS_TOTAL = 20;
56   static const char* THEGAMESDB_API_URL = "https://api.thegamesdb.net";
57   static const char* THEGAMESDB_API_VERSION = "1"; // krazy:exclude=doublequote_chars
58   static const char* THEGAMESDB_MAGIC_TOKEN = "f7c4fd9c5d6d4a2fcefe3157192f87e260038abe86b0f3977716596edaebdbb82315586e98fc88b0fb9ff4c01576e4d47b4e556d487a4325221abbddfac36f59d7e114753b5fa6c77a1e73423d5f72460f3b526bcbae4f2be0d86a5854600436784e3a5c5d6bc1a3e2d395f798fb35073051f2c232014023e9dda99edfea5767";
59 }
60 
61 using namespace Tellico;
62 using Tellico::Fetch::TheGamesDBFetcher;
63 
TheGamesDBFetcher(QObject * parent_)64 TheGamesDBFetcher::TheGamesDBFetcher(QObject* parent_)
65     : Fetcher(parent_)
66     , m_started(false)
67     , m_imageSize(SmallImage) {
68   m_apiKey = Tellico::reverseObfuscate(THEGAMESDB_MAGIC_TOKEN);
69   // delay reading the platform names from the cache file
70   QTimer::singleShot(0, this, &TheGamesDBFetcher::loadCachedData);
71 }
72 
~TheGamesDBFetcher()73 TheGamesDBFetcher::~TheGamesDBFetcher() {
74 }
75 
source() const76 QString TheGamesDBFetcher::source() const {
77   return m_name.isEmpty() ? defaultName() : m_name;
78 }
79 
canSearch(Fetch::FetchKey k) const80 bool TheGamesDBFetcher::canSearch(Fetch::FetchKey k) const {
81   return k == Title;
82 }
83 
canFetch(int type) const84 bool TheGamesDBFetcher::canFetch(int type) const {
85   return type == Data::Collection::Game;
86 }
87 
readConfigHook(const KConfigGroup & config_)88 void TheGamesDBFetcher::readConfigHook(const KConfigGroup& config_) {
89   const QString k = config_.readEntry("API Key");
90   if(!k.isEmpty()) {
91     m_apiKey = k;
92   }
93   const int imageSize = config_.readEntry("Image Size", -1);
94   if(imageSize > -1) {
95     m_imageSize = static_cast<ImageSize>(imageSize);
96   }
97 }
98 
search()99 void TheGamesDBFetcher::search() {
100   m_started = true;
101 
102   if(m_apiKey.isEmpty()) {
103     myDebug() << "empty API key";
104     message(i18n("An access key is required to use this data source.")
105             + QLatin1Char(' ') +
106             i18n("Those values must be entered in the data source settings."), MessageHandler::Error);
107     stop();
108     return;
109   }
110 
111   QUrl u(QString::fromLatin1(THEGAMESDB_API_URL));
112   u.setPath(QLatin1String("/v") + QLatin1String(THEGAMESDB_API_VERSION));
113 
114   switch(request().key()) {
115     case Title:
116       u = u.adjusted(QUrl::StripTrailingSlash);
117       u.setPath(u.path() + QLatin1String("/Games/ByGameName"));
118       {
119         QUrlQuery q;
120         q.addQueryItem(QStringLiteral("apikey"), m_apiKey);
121         if(optionalFields().contains(QStringLiteral("num-player"))) {
122           q.addQueryItem(QStringLiteral("fields"), QStringLiteral("players,rating,publishers,genres,overview,platform"));
123         } else {
124           q.addQueryItem(QStringLiteral("fields"), QStringLiteral("rating,publishers,genres,overview,platform"));
125         }
126         q.addQueryItem(QStringLiteral("include"), QStringLiteral("platform,boxart"));
127         q.addQueryItem(QStringLiteral("name"), request().value());
128         if(!request().data().isEmpty()) {
129           q.addQueryItem(QStringLiteral("filter[platform]"), request().data());
130         }
131         u.setQuery(q);
132       }
133       break;
134 
135     default:
136       myWarning() << "key not recognized:" << request().key();
137       stop();
138       return;
139   }
140 //  u = QUrl::fromLocalFile(QStringLiteral("/tmp/test-tgdb.json"));
141 //  myDebug() << u;
142 
143   m_job = KIO::storedGet(u, KIO::NoReload, KIO::HideProgressInfo);
144   KJobWidgets::setWindow(m_job, GUI::Proxy::widget());
145   connect(m_job.data(), &KJob::result, this, &TheGamesDBFetcher::slotComplete);
146 }
147 
stop()148 void TheGamesDBFetcher::stop() {
149   if(!m_started) {
150     return;
151   }
152   if(m_job) {
153     m_job->kill();
154     m_job = nullptr;
155   }
156   m_started = false;
157   emit signalDone(this);
158 }
159 
fetchEntryHook(uint uid_)160 Tellico::Data::EntryPtr TheGamesDBFetcher::fetchEntryHook(uint uid_) {
161   Data::EntryPtr entry = m_entries.value(uid_);
162   if(!entry) {
163     myWarning() << "no entry in dict";
164     return Data::EntryPtr();
165   }
166 
167   // image might still be a URL
168   const QString image_id = entry->field(QStringLiteral("cover"));
169   if(image_id.contains(QLatin1Char('/'))) {
170     const QString id = ImageFactory::addImage(QUrl::fromUserInput(image_id), true /* quiet */);
171     if(id.isEmpty()) {
172       message(i18n("The cover image could not be loaded."), MessageHandler::Warning);
173     }
174     // empty image ID is ok
175     entry->setField(QStringLiteral("cover"), id);
176   }
177 
178   // don't want to include TGDb ID field
179   entry->setField(QStringLiteral("tgdb-id"), QString());
180 
181   return entry;
182 }
183 
updateRequest(Data::EntryPtr entry_)184 Tellico::Fetch::FetchRequest TheGamesDBFetcher::updateRequest(Data::EntryPtr entry_) {
185   const QString platform = entry_->field(QStringLiteral("platform"));
186   int platformId = -1;
187   // if the platform id is available, it can be used to filter the update search
188   if(!platform.isEmpty()) {
189     for(auto i = m_platforms.constBegin(); i != m_platforms.constEnd(); ++i) {
190       if(i.value() == platform) {
191         platformId = i.key();
192         break;
193       }
194     }
195   }
196 
197   const QString title = entry_->field(QStringLiteral("title"));
198   if(!title.isEmpty()) {
199     FetchRequest req(Title, title);
200     if(platformId > -1) {
201       req.setData(QString::number(platformId));
202     }
203     return req;
204   }
205   return FetchRequest();
206 }
207 
slotComplete(KJob * job_)208 void TheGamesDBFetcher::slotComplete(KJob* job_) {
209   KIO::StoredTransferJob* job = static_cast<KIO::StoredTransferJob*>(job_);
210 
211   if(job->error()) {
212     job->uiDelegate()->showErrorMessage();
213     stop();
214     return;
215   }
216 
217   const QByteArray data = job->data();
218   if(data.isEmpty()) {
219     myDebug() << "no data";
220     stop();
221     return;
222   }
223   // see bug 319662. If fetcher is cancelled, job is killed
224   // if the pointer is retained, it gets double-deleted
225   m_job = nullptr;
226 
227 #if 0
228   myWarning() << "Remove debug from thegamesdbfetcher.cpp";
229   QFile f(QStringLiteral("/tmp/test-tgdb.json"));
230   if(f.open(QIODevice::WriteOnly)) {
231     QTextStream t(&f);
232     t.setCodec("UTF-8");
233     t << data;
234   }
235   f.close();
236 #endif
237 
238   Data::CollPtr coll(new Data::GameCollection(true));
239   // always add the tgdb-id for fetchEntryHook
240   Data::FieldPtr field(new Data::Field(QStringLiteral("tgdb-id"), QStringLiteral("TGDb ID"), Data::Field::Line));
241   field->setCategory(i18n("General"));
242   coll->addField(field);
243 
244   if(optionalFields().contains(QStringLiteral("num-player"))) {
245     Data::FieldPtr field(new Data::Field(QStringLiteral("num-player"), i18n("Number of Players"), Data::Field::Number));
246     field->setCategory(i18n("General"));
247     field->setFlags(Data::Field::AllowMultiple | Data::Field::AllowGrouped);
248     coll->addField(field);
249   }
250 
251   QVariantMap topLevelMap = QJsonDocument::fromJson(data).object().toVariantMap();
252   if(!topLevelMap.contains(QStringLiteral("data"))) {
253     myDebug() << "No data in result!";
254   }
255   readPlatformList(topLevelMap.value(QStringLiteral("include")).toMap()
256                               .value(QStringLiteral("platform")).toMap());
257   readCoverList(topLevelMap.value(QStringLiteral("include")).toMap()
258                            .value(QStringLiteral("boxart")).toMap());
259 
260   QVariantList resultList = topLevelMap.value(QStringLiteral("data")).toMap()
261                                        .value(QStringLiteral("games")).toList();
262   if(resultList.isEmpty()) {
263     myDebug() << "no results";
264     stop();
265     return;
266   }
267 
268   int count = 0;
269   foreach(const QVariant& result, resultList) {
270     Data::EntryPtr entry(new Data::Entry(coll));
271     populateEntry(entry, result.toMap());
272 
273     FetchResult* r = new FetchResult(this, entry);
274     m_entries.insert(r->uid, entry);
275     emit signalResultFound(r);
276     ++count;
277     if(count >= THEGAMESDB_MAX_RETURNS_TOTAL) {
278       break;
279     }
280   }
281 
282   stop();
283 }
284 
populateEntry(Data::EntryPtr entry_,const QVariantMap & resultMap_)285 void TheGamesDBFetcher::populateEntry(Data::EntryPtr entry_, const QVariantMap& resultMap_) {
286   entry_->setField(QStringLiteral("tgdb-id"), mapValue(resultMap_, "id"));
287   entry_->setField(QStringLiteral("title"), mapValue(resultMap_, "game_title"));
288   entry_->setField(QStringLiteral("year"),  mapValue(resultMap_, "release_date").left(4));
289   entry_->setField(QStringLiteral("description"), mapValue(resultMap_, "overview"));
290 
291   const int platformId = mapValue(resultMap_, "platform").toInt();
292   if(m_platforms.contains(platformId)) {
293     const QString platform = m_platforms[platformId];
294     // make the assumption that if the platform name isn't already in the allowed list, it should be added
295     Data::FieldPtr f = entry_->collection()->fieldByName(QStringLiteral("platform"));
296     if(f && !f->allowed().contains(platform)) {
297       f->setAllowed(QStringList(f->allowed()) << platform);
298     }
299     entry_->setField(QStringLiteral("platform"), platform);
300   }
301 
302   const QString esrb = mapValue(resultMap_, "rating")
303                        .section(QLatin1Char('-'), 1, 1)
304                        .trimmed(); // value is like "T - Teen"
305   if(!esrb.isEmpty()) {
306     entry_->setField(QStringLiteral("certification"), i18n(esrb.toUtf8().constData()));
307   }
308   const QString coverUrl = m_covers.value(mapValue(resultMap_, "id"));
309   if(m_imageSize != NoImage) {
310     entry_->setField(QStringLiteral("cover"), coverUrl);
311   }
312 
313   QStringList genres, pubs, devs;
314 
315   bool alreadyAttemptedLoad = false;
316   QVariantList genreIdList = resultMap_.value(QStringLiteral("genres")).toList();
317   foreach(const QVariant& v, genreIdList) {
318     const int id = v.toInt();
319     if(!m_genres.contains(id) && !alreadyAttemptedLoad) {
320       readDataList(Genre);
321       alreadyAttemptedLoad = true;
322     }
323     if(m_genres.contains(id)) {
324       genres << m_genres[id];
325     }
326   }
327 
328   alreadyAttemptedLoad = false;
329   QVariantList pubList = resultMap_.value(QStringLiteral("publishers")).toList();
330   foreach(const QVariant& v, pubList) {
331     const int id = v.toInt();
332     if(!m_publishers.contains(id) && !alreadyAttemptedLoad) {
333       readDataList(Publisher);
334       alreadyAttemptedLoad = true;
335     }
336     if(m_publishers.contains(id)) {
337       pubs << m_publishers[id];
338     }
339   }
340 
341   alreadyAttemptedLoad = false;
342   QVariantList devList = resultMap_.value(QStringLiteral("developers")).toList();
343   foreach(const QVariant& v, devList) {
344     const int id = v.toInt();
345     if(!m_developers.contains(id) && !alreadyAttemptedLoad) {
346       readDataList(Developer);
347       alreadyAttemptedLoad = true;
348     }
349     if(m_developers.contains(id)) {
350       devs << m_developers[id];
351     }
352   }
353 
354   entry_->setField(QStringLiteral("genre"), genres.join(FieldFormat::delimiterString()));
355   entry_->setField(QStringLiteral("publisher"), pubs.join(FieldFormat::delimiterString()));
356   entry_->setField(QStringLiteral("developer"), devs.join(FieldFormat::delimiterString()));
357 
358 if(entry_->collection()->hasField(QStringLiteral("num-player"))) {
359     entry_->setField(QStringLiteral("num-player"), mapValue(resultMap_, "players"));
360   }
361 }
362 
readPlatformList(const QVariantMap & platformMap_)363 void TheGamesDBFetcher::readPlatformList(const QVariantMap& platformMap_) {
364   QMapIterator<QString, QVariant> i(platformMap_);
365   while(i.hasNext()) {
366     i.next();
367     const QVariantMap map = i.value().toMap();
368     const QString name = map.value(QStringLiteral("name")).toString();
369     m_platforms.insert(i.key().toInt(), Data::GameCollection::normalizePlatform(name));
370   }
371 
372   // now write it to cache again
373   const QString id = QStringLiteral("id");
374   const QString name = QStringLiteral("name");
375   QJsonObject platformObj;
376   for(auto ii = m_platforms.constBegin(); ii != m_platforms.constEnd(); ++ii) {
377     QJsonObject iObj;
378     iObj.insert(id, ii.key());
379     iObj.insert(name, ii.value());
380     platformObj.insert(QString::number(ii.key()), iObj);
381   }
382   QJsonObject dataObj;
383   dataObj.insert(QStringLiteral("platforms"), platformObj);
384   QJsonObject docObj;
385   docObj.insert(QStringLiteral("data"), dataObj);
386   QJsonDocument doc;
387   doc.setObject(docObj);
388   writeDataList(Platform, doc.toJson());
389 }
390 
readCoverList(const QVariantMap & coverDataMap_)391 void TheGamesDBFetcher::readCoverList(const QVariantMap& coverDataMap_) {
392   // first, get the base url
393   QString imageBase;
394   switch(m_imageSize) {
395     case SmallImage:
396       // this is the default size, using the thumb. Not the small size
397       imageBase = QStringLiteral("thumb");
398       break;
399     case MediumImage:
400       imageBase = QStringLiteral("medium");
401       break;
402     case LargeImage:
403       imageBase = QStringLiteral("large");
404       break;
405     case NoImage:
406       m_covers.clear();
407       return; // no need to read anything
408       break;
409   }
410 
411   QString baseUrl =  coverDataMap_.value(QStringLiteral("base_url")).toMap()
412                                   .value(imageBase).toString();
413 
414   QVariantMap coverMap = coverDataMap_.value(QStringLiteral("data")).toMap();
415   QMapIterator<QString, QVariant> i(coverMap);
416   while(i.hasNext()) {
417     i.next();
418     foreach(QVariant v, i.value().toList()) {
419       QVariantMap map = v.toMap();
420       if(map.value(QStringLiteral("type")) == QLatin1String("boxart") &&
421          map.value(QStringLiteral("side")) == QLatin1String("front")) {
422         m_covers.insert(i.key(), baseUrl + mapValue(map, "filename"));
423         break;
424       }
425     }
426   }
427 }
428 
loadCachedData()429 void TheGamesDBFetcher::loadCachedData() {
430   // The lists of genres, publishers, and developers are separate, with TGDB requesting that
431   // the data be cached heavily and only updated when necessary
432   // read the three cached JSON data file for genres, publishers, and developers
433   // the platform info is sent with each request response, so it doesn't necessarily need
434   // to be cache. But if an update request is used, having the cached platform id is helpful
435 
436   QFile genreFile(dataFileName(Genre));
437   if(genreFile.open(QIODevice::ReadOnly)) {
438     updateData(Genre, genreFile.readAll());
439   }
440 
441   QFile publisherFile(dataFileName(Publisher));
442   if(publisherFile.open(QIODevice::ReadOnly)) {
443     updateData(Publisher, publisherFile.readAll());
444   }
445 
446   QFile developerFile(dataFileName(Developer));
447   if(developerFile.open(QIODevice::ReadOnly)) {
448     updateData(Developer, developerFile.readAll());
449   }
450 
451   QFile platformFile(dataFileName(Platform));
452   if(platformFile.open(QIODevice::ReadOnly)) {
453     updateData(Platform, platformFile.readAll());
454   }
455 }
456 
updateData(TgdbDataType dataType_,const QByteArray & jsonData_)457 void TheGamesDBFetcher::updateData(TgdbDataType dataType_, const QByteArray& jsonData_) {
458   QString dataName;
459   switch(dataType_) {
460     case Genre:
461       dataName = QStringLiteral("genres");
462       break;
463     case Publisher:
464       dataName = QStringLiteral("publishers");
465       break;
466     case Developer:
467       dataName = QStringLiteral("developers");
468       break;
469     case Platform:
470       dataName = QStringLiteral("platforms");
471       break;
472   }
473 
474   QHash<int, QString> dataHash;
475   const QVariantMap topMap = QJsonDocument::fromJson(jsonData_).object().toVariantMap();
476   const QVariantMap resultMap = topMap.value(QStringLiteral("data")).toMap()
477                                       .value(dataName).toMap();
478   for(QMapIterator<QString, QVariant> i(resultMap); i.hasNext(); ) {
479     i.next();
480     const QVariantMap m = i.value().toMap();
481     dataHash.insert(m.value(QStringLiteral("id")).toInt(), mapValue(m, "name"));
482   }
483 
484   // transfer read data into the correct local variable
485   switch(dataType_) {
486     case Genre:
487       m_genres = dataHash;
488       break;
489     case Publisher:
490       m_publishers = dataHash;
491       break;
492     case Developer:
493       m_developers = dataHash;
494       break;
495     case Platform:
496       m_platforms = dataHash;
497       break;
498   }
499 }
500 
readDataList(TgdbDataType dataType_)501 void TheGamesDBFetcher::readDataList(TgdbDataType dataType_) {
502   QUrl u(QString::fromLatin1(THEGAMESDB_API_URL));
503   u.setPath(QLatin1String("/v") + QLatin1String(THEGAMESDB_API_VERSION));
504   switch(dataType_) {
505     case Genre:
506       u.setPath(u.path() + QLatin1String("/Genres"));
507       break;
508     case Publisher:
509       u.setPath(u.path() + QLatin1String("/Publishers"));
510       break;
511     case Developer:
512       u.setPath(u.path() + QLatin1String("/Developers"));
513       break;
514     case Platform:
515       myDebug() << "not trying to read platforms";
516       // platforms are not read independently, and are only cached
517       return;
518   }
519   QUrlQuery q;
520   q.addQueryItem(QStringLiteral("apikey"), m_apiKey);
521   u.setQuery(q);
522 
523 //  u = QUrl::fromLocalFile(dataFileName(dataType_)); // for testing
524 //  myDebug() << "Reading" << u;
525   const QByteArray data = FileHandler::readDataFile(u, true);
526   writeDataList(dataType_, data);
527   updateData(dataType_, data);
528 }
529 
writeDataList(TgdbDataType dataType_,const QByteArray & data_)530 void TheGamesDBFetcher::writeDataList(TgdbDataType dataType_, const QByteArray& data_) {
531   QFile file(dataFileName(dataType_));
532   if(!file.open(QIODevice::WriteOnly) || file.write(data_) == -1) {
533     myDebug() << "unable to write to" << file.fileName() << file.errorString();
534     return;
535   }
536   file.close();
537 }
538 
configWidget(QWidget * parent_) const539 Tellico::Fetch::ConfigWidget* TheGamesDBFetcher::configWidget(QWidget* parent_) const {
540   return new TheGamesDBFetcher::ConfigWidget(parent_, this);
541 }
542 
defaultName()543 QString TheGamesDBFetcher::defaultName() {
544   return QStringLiteral("TheGamesDB");
545 }
546 
defaultIcon()547 QString TheGamesDBFetcher::defaultIcon() {
548   return favIcon("https://thegamesdb.net");
549 }
550 
allOptionalFields()551 Tellico::StringHash TheGamesDBFetcher::allOptionalFields() {
552   StringHash hash;
553   hash[QStringLiteral("num-player")] = i18n("Number of Players");
554   return hash;
555 }
556 
dataFileName(TgdbDataType dataType_)557 QString TheGamesDBFetcher::dataFileName(TgdbDataType dataType_) {
558   const QString dataDir = Tellico::saveLocation(QStringLiteral("thegamesdb-data/"));
559   QString fileName;
560   switch(dataType_) {
561     case Genre:
562       fileName = dataDir + QLatin1String("genres.json");
563       break;
564     case Publisher:
565       fileName = dataDir + QLatin1String("publishers.json");
566       break;
567     case Developer:
568       fileName = dataDir + QLatin1String("developers.json");
569       break;
570     case Platform:
571       fileName = dataDir + QLatin1String("platforms.json");
572       break;
573   }
574   return fileName;
575 }
576 
ConfigWidget(QWidget * parent_,const TheGamesDBFetcher * fetcher_)577 TheGamesDBFetcher::ConfigWidget::ConfigWidget(QWidget* parent_, const TheGamesDBFetcher* fetcher_)
578     : Fetch::ConfigWidget(parent_) {
579   QGridLayout* l = new QGridLayout(optionsWidget());
580   l->setSpacing(4);
581   l->setColumnStretch(1, 10);
582 
583   int row = -1;
584   QLabel* al = new QLabel(i18n("Registration is required for accessing this data source. "
585                                "If you agree to the terms and conditions, <a href='%1'>sign "
586                                "up for an account</a>, and enter your information below.",
587                                 QLatin1String("https://forums.thegamesdb.net/viewforum.php?f=10")),
588                           optionsWidget());
589   al->setOpenExternalLinks(true);
590   al->setWordWrap(true);
591   ++row;
592   l->addWidget(al, row, 0, 1, 2);
593   // richtext gets weird with size
594   al->setMinimumWidth(al->sizeHint().width());
595 
596   QLabel* label = new QLabel(i18n("Access key: "), optionsWidget());
597   l->addWidget(label, ++row, 0);
598 
599   m_apiKeyEdit = new QLineEdit(optionsWidget());
600   connect(m_apiKeyEdit, &QLineEdit::textChanged, this, &ConfigWidget::slotSetModified);
601   l->addWidget(m_apiKeyEdit, row, 1);
602   label->setBuddy(m_apiKeyEdit);
603 
604   label = new QLabel(i18n("&Image size: "), optionsWidget());
605   l->addWidget(label, ++row, 0);
606   m_imageCombo = new GUI::ComboBox(optionsWidget());
607   m_imageCombo->addItem(i18n("Small Image"), SmallImage);
608   m_imageCombo->addItem(i18n("Medium Image"), MediumImage);
609   m_imageCombo->addItem(i18n("Large Image"), LargeImage);
610   m_imageCombo->addItem(i18n("No Image"), NoImage);
611   void (GUI::ComboBox::* activatedInt)(int) = &GUI::ComboBox::activated;
612   connect(m_imageCombo, activatedInt, this, &ConfigWidget::slotSetModified);
613   l->addWidget(m_imageCombo, row, 1);
614   QString w = i18n("The cover image may be downloaded as well. However, too many large images in the "
615                    "collection may degrade performance.");
616   label->setWhatsThis(w);
617   m_imageCombo->setWhatsThis(w);
618   label->setBuddy(m_imageCombo);
619 
620   l->setRowStretch(++row, 10);
621 
622   if(fetcher_) {
623     m_apiKeyEdit->setText(fetcher_->m_apiKey);
624     m_imageCombo->setCurrentData(fetcher_->m_imageSize);
625   } else { // defaults
626     m_apiKeyEdit->setText(Tellico::reverseObfuscate(THEGAMESDB_MAGIC_TOKEN));
627     m_imageCombo->setCurrentData(SmallImage);
628   }
629 
630   // now add additional fields widget
631   addFieldsWidget(TheGamesDBFetcher::allOptionalFields(), fetcher_ ? fetcher_->optionalFields() : QStringList());
632 }
633 
saveConfigHook(KConfigGroup & config_)634 void TheGamesDBFetcher::ConfigWidget::saveConfigHook(KConfigGroup& config_) {
635   const QString apiKey = m_apiKeyEdit->text().trimmed();
636   if(!apiKey.isEmpty() && apiKey != Tellico::reverseObfuscate(THEGAMESDB_MAGIC_TOKEN)) {
637     config_.writeEntry("API Key", apiKey);
638   }
639   const int n = m_imageCombo->currentData().toInt();
640   config_.writeEntry("Image Size", n);
641 }
642 
preferredName() const643 QString TheGamesDBFetcher::ConfigWidget::preferredName() const {
644   return TheGamesDBFetcher::defaultName();
645 }
646