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