File indexing completed on 2024-05-12 16:45:42

0001 /***************************************************************************
0002     Copyright (C) 2004-2020 Robby Stephenson <robby@periapsis.org>
0003  ***************************************************************************/
0004 
0005 /***************************************************************************
0006  *                                                                         *
0007  *   This program is free software; you can redistribute it and/or         *
0008  *   modify it under the terms of the GNU General Public License as        *
0009  *   published by the Free Software Foundation; either version 2 of        *
0010  *   the License or (at your option) version 3 or any later version        *
0011  *   accepted by the membership of KDE e.V. (or its successor approved     *
0012  *   by the membership of KDE e.V.), which shall act as a proxy            *
0013  *   defined in Section 14 of version 3 of the license.                    *
0014  *                                                                         *
0015  *   This program is distributed in the hope that it will be useful,       *
0016  *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
0017  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
0018  *   GNU General Public License for more details.                          *
0019  *                                                                         *
0020  *   You should have received a copy of the GNU General Public License     *
0021  *   along with this program.  If not, see <http://www.gnu.org/licenses/>. *
0022  *                                                                         *
0023  ***************************************************************************/
0024 
0025 #include <config.h>
0026 
0027 #include "amazonfetcher.h"
0028 #include "amazonrequest.h"
0029 #include "../collectionfactory.h"
0030 #include "../images/imagefactory.h"
0031 #include "../utils/guiproxy.h"
0032 #include "../collection.h"
0033 #include "../entry.h"
0034 #include "../field.h"
0035 #include "../fieldformat.h"
0036 #include "../utils/string_utils.h"
0037 #include "../utils/isbnvalidator.h"
0038 #include "../gui/combobox.h"
0039 #include "../tellico_debug.h"
0040 
0041 #include <KLocalizedString>
0042 #include <KIO/Job>
0043 #include <KIO/JobUiDelegate>
0044 #include <KSeparator>
0045 #include <KComboBox>
0046 #include <KAcceleratorManager>
0047 #include <KConfigGroup>
0048 #include <KJobWidgets/KJobWidgets>
0049 
0050 #include <QLineEdit>
0051 #include <QLabel>
0052 #include <QCheckBox>
0053 #include <QFile>
0054 #include <QDir>
0055 #include <QTextStream>
0056 #include <QTextCodec>
0057 #include <QGridLayout>
0058 #include <QStandardPaths>
0059 #include <QJsonDocument>
0060 #include <QJsonObject>
0061 #include <QJsonArray>
0062 #include <QTemporaryFile>
0063 
0064 namespace {
0065   static const int AMAZON_RETURNS_PER_REQUEST = 10;
0066   static const int AMAZON_MAX_RETURNS_TOTAL = 20;
0067   static const char* AMAZON_ASSOC_TOKEN = "tellico-20";
0068 }
0069 
0070 using namespace Tellico;
0071 using Tellico::Fetch::AmazonFetcher;
0072 
0073 // static
0074 // see https://webservices.amazon.com/paapi5/documentation/common-request-parameters.html#host-and-region
0075 const AmazonFetcher::SiteData& AmazonFetcher::siteData(int site_) {
0076   Q_ASSERT(site_ >= 0);
0077   Q_ASSERT(site_ < XX);
0078   static SiteData dataVector[16] = {
0079     {
0080       i18n("Amazon (US)"),
0081       "webservices.amazon.com",
0082       "us-east-1",
0083       QLatin1String("us"),
0084       i18n("United States")
0085     }, {
0086       i18n("Amazon (UK)"),
0087       "webservices.amazon.co.uk",
0088       "eu-west-1",
0089       QLatin1String("gb"),
0090       i18n("United Kingdom")
0091     }, {
0092       i18n("Amazon (Germany)"),
0093       "webservices.amazon.de",
0094       "eu-west-1",
0095       QLatin1String("de"),
0096       i18n("Germany")
0097     }, {
0098       i18n("Amazon (Japan)"),
0099       "webservices.amazon.co.jp",
0100       "us-west-2",
0101       QLatin1String("jp"),
0102       i18n("Japan")
0103     }, {
0104       i18n("Amazon (France)"),
0105       "webservices.amazon.fr",
0106       "eu-west-1",
0107       QLatin1String("fr"),
0108       i18n("France")
0109     }, {
0110       i18n("Amazon (Canada)"),
0111       "webservices.amazon.ca",
0112       "us-east-1",
0113       QLatin1String("ca"),
0114       i18n("Canada")
0115     }, {
0116       // TODO: no chinese in PAAPI-5 yet?
0117       i18n("Amazon (China)"),
0118       "webservices.amazon.cn",
0119       "us-west-2",
0120       QLatin1String("ch"),
0121       i18n("China")
0122     }, {
0123       i18n("Amazon (Spain)"),
0124       "webservices.amazon.es",
0125       "eu-west-1",
0126       QLatin1String("es"),
0127       i18n("Spain")
0128     }, {
0129       i18n("Amazon (Italy)"),
0130       "webservices.amazon.it",
0131       "eu-west-1",
0132       QLatin1String("it"),
0133       i18n("Italy")
0134     }, {
0135       i18n("Amazon (Brazil)"),
0136       "webservices.amazon.com.br",
0137       "us-east-1",
0138       QLatin1String("br"),
0139       i18n("Brazil")
0140     }, {
0141       i18n("Amazon (Australia)"),
0142       "webservices.amazon.com.au",
0143       "us-west-2",
0144       QLatin1String("au"),
0145       i18n("Australia")
0146     }, {
0147       i18n("Amazon (India)"),
0148       "webservices.amazon.in",
0149       "eu-west-1",
0150       QLatin1String("in"),
0151       i18n("India")
0152     }, {
0153       i18n("Amazon (Mexico)"),
0154       "webservices.amazon.com.mx",
0155       "us-east-1",
0156       QLatin1String("mx"),
0157       i18n("Mexico")
0158     }, {
0159       i18n("Amazon (Turkey)"),
0160       "webservices.amazon.com.tr",
0161       "eu-west-1",
0162       QLatin1String("tr"),
0163       i18n("Turkey")
0164     }, {
0165       i18n("Amazon (Singapore)"),
0166       "webservices.amazon.sg",
0167       "us-west-2",
0168       QLatin1String("sg"),
0169       i18n("Singapore")
0170     }, {
0171       i18n("Amazon (UAE)"),
0172       "webservices.amazon.ae",
0173       "eu-west-1",
0174       QLatin1String("ae"),
0175       i18n("United Arab Emirates")
0176     }
0177   };
0178 
0179   return dataVector[qBound(0, site_, static_cast<int>(sizeof(dataVector)/sizeof(SiteData)))];
0180 }
0181 
0182 AmazonFetcher::AmazonFetcher(QObject* parent_)
0183     : Fetcher(parent_), m_site(Unknown), m_imageSize(MediumImage),
0184       m_assoc(QLatin1String(AMAZON_ASSOC_TOKEN)), m_limit(AMAZON_MAX_RETURNS_TOTAL),
0185       m_countOffset(0), m_page(1), m_total(-1), m_numResults(0), m_job(nullptr), m_started(false) {
0186   // to facilitate transition to Amazon PAAPI5, allow users to enable logging the Amazon
0187   // results so they can be shared for debugging
0188   const QByteArray enableLog = qgetenv("TELLICO_ENABLE_AMAZON_LOG").trimmed().toLower();
0189   m_enableLog = (enableLog == "true" || enableLog == "1");
0190 }
0191 
0192 AmazonFetcher::~AmazonFetcher() {
0193 }
0194 
0195 QString AmazonFetcher::source() const {
0196   return m_name.isEmpty() ? defaultName() : m_name;
0197 }
0198 
0199 QString AmazonFetcher::attribution() const {
0200   return i18n("This data is licensed under <a href=""%1"">specific terms</a>.",
0201               QLatin1String("https://affiliate-program.amazon.com/gp/advertising/api/detail/agreement.html"));
0202 }
0203 
0204 bool AmazonFetcher::canFetch(int type) const {
0205   return type == Data::Collection::Book
0206          || type == Data::Collection::ComicBook
0207          || type == Data::Collection::Bibtex
0208          || type == Data::Collection::Album
0209          || type == Data::Collection::Video
0210          || type == Data::Collection::Game
0211          || type == Data::Collection::BoardGame;
0212 }
0213 
0214 bool AmazonFetcher::canSearch(Fetch::FetchKey k) const {
0215   // no UPC in Canada
0216   return k == Title
0217       || k == Person
0218       || k == ISBN
0219       || k == UPC
0220       || k == Keyword;
0221 }
0222 
0223 void AmazonFetcher::readConfigHook(const KConfigGroup& config_) {
0224   const int site = config_.readEntry("Site", int(Unknown));
0225   Q_ASSERT(site != Unknown);
0226   m_site = static_cast<Site>(site);
0227   if(m_name.isEmpty()) {
0228     m_name = siteData(m_site).title;
0229   }
0230   QString s = config_.readEntry("AccessKey");
0231   if(!s.isEmpty()) {
0232     m_accessKey = s;
0233   } else {
0234     myWarning() << "No Amazon access key";
0235   }
0236   s = config_.readEntry("AssocToken");
0237   if(!s.isEmpty()) {
0238     m_assoc = s;
0239   }
0240   s = config_.readEntry("SecretKey");
0241   if(!s.isEmpty()) {
0242     m_secretKey = s;
0243   } else {
0244     myWarning() << "No Amazon secret key";
0245   }
0246   int imageSize = config_.readEntry("Image Size", -1);
0247   if(imageSize > -1) {
0248     m_imageSize = static_cast<ImageSize>(imageSize);
0249   }
0250 }
0251 
0252 void AmazonFetcher::search() {
0253   m_started = true;
0254   m_page = 1;
0255   m_total = -1;
0256   m_countOffset = 0;
0257   m_numResults = 0;
0258   doSearch();
0259 }
0260 
0261 void AmazonFetcher::continueSearch() {
0262   m_started = true;
0263   m_limit += AMAZON_MAX_RETURNS_TOTAL;
0264   doSearch();
0265 }
0266 
0267 void AmazonFetcher::doSearch() {
0268   if(m_secretKey.isEmpty() || m_accessKey.isEmpty()) {
0269     // this message is split in two since the first half is reused later
0270     message(i18n("Access to data from Amazon.com requires an AWS Access Key ID and a Secret Key.") +
0271             QLatin1Char(' ') +
0272             i18n("Those values must be entered in the data source settings."), MessageHandler::Error);
0273     stop();
0274     return;
0275   }
0276 
0277   const QByteArray payload = requestPayload(request());
0278   if(payload.isEmpty()) {
0279     stop();
0280     return;
0281   }
0282 
0283   QString path(QStringLiteral("/paapi5/searchitems"));
0284 
0285   AmazonRequest request(m_accessKey, m_secretKey);
0286   request.setHost(siteData(m_site).host);
0287   request.setRegion(siteData(m_site).region);
0288   request.setPath(path.toUtf8());
0289 
0290   // debugging check
0291   if(m_testResultsFile.isEmpty()) {
0292     QUrl u;
0293     u.setScheme(QLatin1String("https"));
0294     u.setHost(QString::fromUtf8(siteData(m_site).host));
0295     u.setPath(path);
0296     m_job = KIO::storedHttpPost(payload, u, KIO::HideProgressInfo);
0297     QStringList customHeaders;
0298     QMapIterator<QByteArray, QByteArray> i(request.headers(payload));
0299     while(i.hasNext()) {
0300       i.next();
0301       customHeaders += QString::fromUtf8(i.key() + ": " + i.value());
0302     }
0303     m_job->addMetaData(QStringLiteral("customHTTPHeader"), customHeaders.join(QLatin1String("\r\n")));
0304   } else {
0305     myDebug() << "Reading" << m_testResultsFile;
0306     m_job = KIO::storedGet(QUrl::fromLocalFile(m_testResultsFile), KIO::NoReload, KIO::HideProgressInfo);
0307   }
0308   KJobWidgets::setWindow(m_job, GUI::Proxy::widget());
0309   connect(m_job.data(), &KJob::result,
0310           this, &AmazonFetcher::slotComplete);
0311 }
0312 
0313 void AmazonFetcher::stop() {
0314   if(!m_started) {
0315     return;
0316   }
0317   if(m_job) {
0318     m_job->kill();
0319     m_job = nullptr;
0320   }
0321   m_started = false;
0322   emit signalDone(this);
0323 }
0324 
0325 void AmazonFetcher::slotComplete(KJob*) {
0326   if(m_job->error()) {
0327     myDebug() << m_job->errorString() << m_job->data();
0328     myDebug() << "Response code is" << m_job->metaData().value(QStringLiteral("responsecode"));
0329     m_job->uiDelegate()->showErrorMessage();
0330     stop();
0331     return;
0332   }
0333 
0334   const QByteArray data = m_job->data();
0335   if(data.isEmpty()) {
0336     myDebug() << "no data";
0337     stop();
0338     return;
0339   }
0340 
0341   // since the fetch is done, don't worry about holding the job pointer
0342   m_job = nullptr;
0343 
0344   if(m_enableLog) {
0345     QTemporaryFile logFile(QDir::tempPath() + QStringLiteral("/amazon-search-items-XXXXXX.json"));
0346     logFile.setAutoRemove(false);
0347     if(logFile.open()) {
0348       QTextStream t(&logFile);
0349       t.setCodec("UTF-8");
0350       t << data;
0351       myLog() << "Writing Amazon data output to" << logFile.fileName();
0352     }
0353   }
0354 #if 0
0355   myWarning() << "Remove debug from amazonfetcher.cpp";
0356   QFile f(QString::fromLatin1("/tmp/test%1.json").arg(m_page));
0357   if(f.open(QIODevice::WriteOnly)) {
0358     QTextStream t(&f);
0359     t.setCodec("UTF-8");
0360     t << data;
0361   }
0362   f.close();
0363 #endif
0364 
0365   QJsonParseError jsonError;
0366   QJsonObject databject = QJsonDocument::fromJson(data, &jsonError).object();
0367   if(jsonError.error != QJsonParseError::NoError) {
0368     myDebug() << "AmazonFetcher: JSON error -" << jsonError.errorString();
0369     message(jsonError.errorString(), MessageHandler::Error);
0370     stop();
0371     return;
0372   }
0373   QJsonObject resultObject = databject.value(QStringLiteral("SearchResult")).toObject();
0374   if(resultObject.isEmpty()) {
0375     resultObject = databject.value(QStringLiteral("ItemsResult")).toObject();
0376   }
0377 
0378   if(m_total == -1) {
0379     int totalResults = resultObject.value(QStringLiteral("TotalResultCount")).toInt();
0380     if(totalResults > 0) {
0381       m_total = totalResults;
0382 //      myDebug() << "Total results is" << totalResults;
0383     }
0384   }
0385 
0386   QStringList errors;
0387   QJsonValue errorValue = databject.value(QLatin1String("Errors"));
0388   if(!errorValue.isNull()) {
0389     foreach(const QJsonValue& error, errorValue.toArray()) {
0390       errors += error.toObject().value(QLatin1String("Message")).toString();
0391     }
0392   }
0393   if(!errors.isEmpty()) {
0394     for(QStringList::ConstIterator it = errors.constBegin(); it != errors.constEnd(); ++it) {
0395       myDebug() << "AmazonFetcher::" << *it;
0396     }
0397     message(errors[0], MessageHandler::Error);
0398     stop();
0399     return;
0400   }
0401 
0402   Data::CollPtr coll = createCollection();
0403   if(!coll) {
0404     myDebug() << "no collection pointer";
0405     stop();
0406     return;
0407   }
0408 
0409   int count = -1;
0410   foreach(const QJsonValue& item, resultObject.value(QLatin1String("Items")).toArray()) {
0411     ++count;
0412     if(m_numResults >= m_limit) {
0413       break;
0414     }
0415     if(!m_started) {
0416       // might get aborted
0417       break;
0418     }
0419     Data::EntryPtr entry(new Data::Entry(coll));
0420     populateEntry(entry, item.toObject());
0421 
0422     // special case book author
0423     // amazon is really bad about not putting spaces after periods
0424     if(coll->type() == Data::Collection::Book) {
0425       QRegExp rx(QLatin1String("\\.([^\\s])"));
0426       QStringList values = FieldFormat::splitValue(entry->field(QStringLiteral("author")));
0427       for(QStringList::Iterator it = values.begin(); it != values.end(); ++it) {
0428         (*it).replace(rx, QStringLiteral(". \\1"));
0429       }
0430       entry->setField(QStringLiteral("author"), values.join(FieldFormat::delimiterString()));
0431     }
0432 
0433     // UK puts the year in the title for some reason
0434     if(m_site == UK && coll->type() == Data::Collection::Video) {
0435       QRegExp rx(QLatin1String("\\[(\\d{4})\\]"));
0436       QString t = entry->title();
0437       if(rx.indexIn(t) > -1) {
0438         QString y = rx.cap(1);
0439         t = t.remove(rx).simplified();
0440         entry->setField(QStringLiteral("title"), t);
0441         if(entry->field(QStringLiteral("year")).isEmpty()) {
0442           entry->setField(QStringLiteral("year"), y);
0443         }
0444       }
0445     }
0446 
0447 //    myDebug() << entry->title();
0448     FetchResult* r = new FetchResult(this, entry);
0449     m_entries.insert(r->uid, entry);
0450     emit signalResultFound(r);
0451     ++m_numResults;
0452   }
0453 
0454   // we might have gotten aborted
0455   if(!m_started) {
0456     return;
0457   }
0458 
0459   // are there any additional results to get?
0460   m_hasMoreResults = m_testResultsFile.isEmpty() && (m_page * AMAZON_RETURNS_PER_REQUEST < m_total);
0461 
0462   const int currentTotal = qMin(m_total, m_limit);
0463   if(m_testResultsFile.isEmpty() && (m_page * AMAZON_RETURNS_PER_REQUEST < currentTotal)) {
0464     int foundCount = (m_page-1) * AMAZON_RETURNS_PER_REQUEST + coll->entryCount();
0465     message(i18n("Results from %1: %2/%3", source(), foundCount, m_total), MessageHandler::Status);
0466     ++m_page;
0467     m_countOffset = 0;
0468     doSearch();
0469   } else if(request().value().count(QLatin1Char(';')) > 9) {
0470     // start new request after cutting off first 10 isbn values
0471     FetchRequest newRequest(request().collectionType(),
0472                             request().key(),
0473                             request().value().section(QLatin1Char(';'), 10));
0474     startSearch(newRequest);
0475   } else {
0476     m_countOffset = m_entries.count() % AMAZON_RETURNS_PER_REQUEST;
0477     if(m_countOffset == 0) {
0478       ++m_page; // need to go to next page
0479     }
0480     stop();
0481   }
0482 }
0483 
0484 Tellico::Data::EntryPtr AmazonFetcher::fetchEntryHook(uint uid_) {
0485   Data::EntryPtr entry = m_entries[uid_];
0486   if(!entry) {
0487     myWarning() << "no entry in dict";
0488     return entry;
0489   }
0490 
0491   // do what we can to remove useless keywords
0492   const int type = collectionType();
0493   switch(type) {
0494     case Data::Collection::Book:
0495     case Data::Collection::ComicBook:
0496     case Data::Collection::Bibtex:
0497       if(optionalFields().contains(QStringLiteral("keyword"))) {
0498         QStringList newWords;
0499         const QStringList keywords = FieldFormat::splitValue(entry->field(QStringLiteral("keyword")));
0500         foreach(const QString& keyword, keywords) {
0501           if(keyword == QLatin1String("General") ||
0502              keyword == QLatin1String("Subjects") ||
0503              keyword == QLatin1String("Par prix") || // french stuff
0504              keyword == QLatin1String("Divers") || // french stuff
0505              keyword.startsWith(QLatin1Char('(')) ||
0506              keyword.startsWith(QLatin1String("Authors"))) {
0507             continue;
0508           }
0509           newWords += keyword;
0510         }
0511         newWords.removeDuplicates();
0512         entry->setField(QStringLiteral("keyword"), newWords.join(FieldFormat::delimiterString()));
0513       }
0514       entry->setField(QStringLiteral("comments"), Tellico::decodeHTML(entry->field(QStringLiteral("comments"))));
0515       break;
0516 
0517     case Data::Collection::Video:
0518       {
0519         const QString genres = QStringLiteral("genre");
0520         QStringList oldWords = FieldFormat::splitValue(entry->field(genres));
0521         QStringList newWords;
0522         // only care about genres that have "Genres" in the amazon response
0523         // and take the first word after that
0524         for(QStringList::Iterator it = oldWords.begin(); it != oldWords.end(); ++it) {
0525           if((*it).indexOf(QLatin1String("Genres")) == -1) {
0526             continue;
0527           }
0528 
0529           // the amazon2tellico stylesheet separates words with '/'
0530           QStringList nodes = (*it).split(QLatin1Char('/'));
0531           for(QStringList::Iterator it2 = nodes.begin(); it2 != nodes.end(); ++it2) {
0532             if(*it2 != QLatin1String("Genres")) {
0533               continue;
0534             }
0535             ++it2;
0536             if(it2 != nodes.end() && *it2 != QLatin1String("General")) {
0537               newWords += *it2;
0538             }
0539             break; // we're done
0540           }
0541         }
0542         newWords.removeDuplicates();
0543         entry->setField(genres, newWords.join(FieldFormat::delimiterString()));
0544         // language tracks get duplicated, too
0545         newWords = FieldFormat::splitValue(entry->field(QStringLiteral("language")));
0546         newWords.removeDuplicates();
0547         entry->setField(QStringLiteral("language"), newWords.join(FieldFormat::delimiterString()));
0548       }
0549       entry->setField(QStringLiteral("plot"), Tellico::decodeHTML(entry->field(QStringLiteral("plot"))));
0550       break;
0551 
0552     case Data::Collection::Album:
0553       {
0554         const QString genres = QStringLiteral("genre");
0555         QStringList oldWords = FieldFormat::splitValue(entry->field(genres));
0556         QStringList newWords;
0557         // only care about genres that have "Styles" in the amazon response
0558         // and take the first word after that
0559         for(QStringList::Iterator it = oldWords.begin(); it != oldWords.end(); ++it) {
0560           if((*it).indexOf(QLatin1String("Styles")) == -1) {
0561             continue;
0562           }
0563 
0564           // the amazon2tellico stylesheet separates words with '/'
0565           QStringList nodes = (*it).split(QLatin1Char('/'));
0566           bool isStyle = false;
0567           for(QStringList::Iterator it2 = nodes.begin(); it2 != nodes.end(); ++it2) {
0568             if(!isStyle) {
0569               if(*it2 == QLatin1String("Styles")) {
0570                 isStyle = true;
0571               }
0572               continue;
0573             }
0574             if(*it2 != QLatin1String("General")) {
0575               newWords += *it2;
0576             }
0577           }
0578         }
0579         newWords.removeDuplicates();
0580         entry->setField(genres, newWords.join(FieldFormat::delimiterString()));
0581       }
0582       entry->setField(QStringLiteral("comments"), Tellico::decodeHTML(entry->field(QStringLiteral("comments"))));
0583       break;
0584 
0585     case Data::Collection::Game:
0586       entry->setField(QStringLiteral("description"), Tellico::decodeHTML(entry->field(QStringLiteral("description"))));
0587       break;
0588   }
0589 
0590   // clean up the title
0591   parseTitle(entry);
0592 
0593   // also sometimes table fields have rows but no values
0594   Data::FieldList fields = entry->collection()->fields();
0595   QRegExp blank(QLatin1String("[\\s") +
0596                 FieldFormat::columnDelimiterString() +
0597                 FieldFormat::delimiterString() +
0598                 QLatin1String("]+")); // only white space, column separators and value separators
0599   foreach(Data::FieldPtr fIt, fields) {
0600     if(fIt->type() != Data::Field::Table) {
0601       continue;
0602     }
0603     if(blank.exactMatch(entry->field(fIt))) {
0604       entry->setField(fIt, QString());
0605     }
0606   }
0607 
0608   // don't want to show image urls in the fetch dialog
0609   // so clear them after reading the URL
0610   QString imageURL;
0611   switch(m_imageSize) {
0612     case SmallImage:
0613       imageURL = entry->field(QStringLiteral("small-image"));
0614       entry->setField(QStringLiteral("small-image"),  QString());
0615       break;
0616     case MediumImage:
0617       imageURL = entry->field(QStringLiteral("medium-image"));
0618       entry->setField(QStringLiteral("medium-image"),  QString());
0619       break;
0620     case LargeImage:
0621       imageURL = entry->field(QStringLiteral("large-image"));
0622       entry->setField(QStringLiteral("large-image"),  QString());
0623       break;
0624     case NoImage:
0625     default:
0626       break;
0627   }
0628 
0629   if(!imageURL.isEmpty()) {
0630 //    myDebug() << "grabbing " << imageURL;
0631     QString id = ImageFactory::addImage(QUrl::fromUserInput(imageURL), true);
0632     if(id.isEmpty()) {
0633       message(i18n("The cover image could not be loaded."), MessageHandler::Warning);
0634     } else { // amazon serves up 1x1 gifs occasionally, but that's caught in the image constructor
0635       // all relevant collection types have cover fields
0636       entry->setField(QStringLiteral("cover"), id);
0637     }
0638   }
0639 
0640   return entry;
0641 }
0642 
0643 Tellico::Fetch::FetchRequest AmazonFetcher::updateRequest(Data::EntryPtr entry_) {
0644   const int type = entry_->collection()->type();
0645   const QString t = entry_->field(QStringLiteral("title"));
0646   if(type == Data::Collection::Book || type == Data::Collection::ComicBook || type == Data::Collection::Bibtex) {
0647     const QString isbn = entry_->field(QStringLiteral("isbn"));
0648     if(!isbn.isEmpty()) {
0649       return FetchRequest(Fetch::ISBN, isbn);
0650     }
0651     const QString a = entry_->field(QStringLiteral("author"));
0652     if(!a.isEmpty()) {
0653       return t.isEmpty() ? FetchRequest(Fetch::Person, a)
0654                          : FetchRequest(Fetch::Keyword, t + QLatin1Char('-') + a);
0655     }
0656   } else if(type == Data::Collection::Album) {
0657     const QString a = entry_->field(QStringLiteral("artist"));
0658     if(!a.isEmpty()) {
0659       return t.isEmpty() ? FetchRequest(Fetch::Person, a)
0660                          : FetchRequest(Fetch::Keyword, t + QLatin1Char('-') + a);
0661     }
0662   }
0663 
0664   // optimistically try searching for title and rely on Collection::sameEntry() to figure things out
0665   if(!t.isEmpty()) {
0666     return FetchRequest(Fetch::Title, t);
0667   }
0668 
0669   return FetchRequest();
0670 }
0671 
0672 QByteArray AmazonFetcher::requestPayload(Fetch::FetchRequest request_) {
0673   QJsonObject payload;
0674   payload.insert(QLatin1String("PartnerTag"), m_assoc);
0675   payload.insert(QLatin1String("PartnerType"), QLatin1String("Associates"));
0676   payload.insert(QLatin1String("Service"), QLatin1String("ProductAdvertisingAPIv1"));
0677   payload.insert(QLatin1String("Operation"), QLatin1String("SearchItems"));
0678   payload.insert(QLatin1String("SortBy"), QLatin1String("Relevance"));
0679   // not mandatory
0680 //  payload.insert(QLatin1String("Marketplace"), QLatin1String(siteData(m_site).host));
0681   if(m_page > 1) {
0682     payload.insert(QLatin1String("ItemPage"), m_page);
0683   }
0684 
0685   QJsonArray resources;
0686   resources.append(QLatin1String("ItemInfo.Title"));
0687   resources.append(QLatin1String("ItemInfo.ContentInfo"));
0688   resources.append(QLatin1String("ItemInfo.ByLineInfo"));
0689   resources.append(QLatin1String("ItemInfo.TechnicalInfo"));
0690 
0691   const int type = request_.collectionType();
0692   switch(type) {
0693     case Data::Collection::Book:
0694     case Data::Collection::ComicBook:
0695     case Data::Collection::Bibtex:
0696       payload.insert(QLatin1String("SearchIndex"), QLatin1String("Books"));
0697       resources.append(QLatin1String("ItemInfo.ExternalIds"));
0698       resources.append(QLatin1String("ItemInfo.ManufactureInfo"));
0699       break;
0700 
0701     case Data::Collection::Album:
0702       payload.insert(QLatin1String("SearchIndex"), QLatin1String("Music"));
0703       break;
0704 
0705     case Data::Collection::Video:
0706       // CA and JP appear to have a bug where Video only returns VHS or Music results
0707       // DVD will return DVD, Blu-ray, etc. so just ignore VHS for those users
0708       payload.insert(QLatin1String("SearchIndex"), QLatin1String("MoviesAndTV"));
0709       if(m_site == CA || m_site == JP || m_site == IT || m_site == ES) {
0710         payload.insert(QStringLiteral("SearchIndex"), QStringLiteral("DVD"));
0711       } else {
0712         payload.insert(QStringLiteral("SearchIndex"), QStringLiteral("Video"));
0713       }
0714 //      params.insert(QStringLiteral("SortIndex"), QStringLiteral("relevancerank"));
0715       resources.append(QLatin1String("ItemInfo.ContentRating"));
0716       break;
0717 
0718     case Data::Collection::Game:
0719       payload.insert(QLatin1String("SearchIndex"), QLatin1String("VideoGames"));
0720       break;
0721 
0722     case Data::Collection::BoardGame:
0723       payload.insert(QLatin1String("SearchIndex"), QLatin1String("ToysAndGames"));
0724 //      params.insert(QStringLiteral("SortIndex"), QStringLiteral("relevancerank"));
0725       break;
0726 
0727     case Data::Collection::Coin:
0728     case Data::Collection::Stamp:
0729     case Data::Collection::Wine:
0730     case Data::Collection::Base:
0731     case Data::Collection::Card:
0732       myDebug() << "can't fetch this type:" << collectionType();
0733       return QByteArray();
0734   }
0735 
0736   switch(request_.key()) {
0737     case Title:
0738       payload.insert(QLatin1String("Title"), request_.value());
0739       break;
0740 
0741     case Person:
0742       if(type == Data::Collection::Video) {
0743         payload.insert(QStringLiteral("Actor"), request_.value());
0744 //        payload.insert(QStringLiteral("Director"), request_.value());
0745       } else if(type == Data::Collection::Album) {
0746         payload.insert(QStringLiteral("Artist"), request_.value());
0747       } else if(type == Data::Collection::Book) {
0748         payload.insert(QLatin1String("Author"), request_.value());
0749       } else {
0750         payload.insert(QLatin1String("Keywords"), request_.value());
0751       }
0752       break;
0753 
0754     case ISBN:
0755       {
0756         QString cleanValue = request_.value();
0757         cleanValue.remove(QLatin1Char('-'));
0758         // ISBN only get digits or 'X'
0759         QStringList isbns = FieldFormat::splitValue(cleanValue);
0760         // Amazon isbn13 search is still very flaky, so if possible, we're going to convert
0761         // all of them to isbn10. If we run into a 979 isbn13, then we're forced to do an
0762         // isbn13 search
0763         bool isbn13 = false;
0764         for(QStringList::Iterator it = isbns.begin(); it != isbns.end(); ) {
0765           if((*it).startsWith(QLatin1String("979"))) {
0766             isbn13 = true;
0767             break;
0768           }
0769           ++it;
0770         }
0771         // if we want isbn10, then convert all
0772         if(!isbn13) {
0773           for(QStringList::Iterator it = isbns.begin(); it != isbns.end(); ++it) {
0774             if((*it).length() > 12) {
0775               (*it) = ISBNValidator::isbn10(*it);
0776               (*it).remove(QLatin1Char('-'));
0777             }
0778           }
0779         }
0780         // limit to first 10
0781         while(isbns.size() > 10) {
0782           isbns.pop_back();
0783         }
0784         payload.insert(QLatin1String("Keywords"), isbns.join(QLatin1String("|")));
0785         if(isbn13) {
0786 //          params.insert(QStringLiteral("IdType"), QStringLiteral("EAN"));
0787         }
0788       }
0789       break;
0790 
0791     case UPC:
0792       {
0793         QString cleanValue = request_.value();
0794         cleanValue.remove(QLatin1Char('-'));
0795         // for EAN values, add 0 to beginning if not 13 characters
0796         // in order to assume US country code from UPC value
0797         QStringList values;
0798         foreach(const QString& splitValue, cleanValue.split(FieldFormat::delimiterString())) {
0799           QString tmpValue = splitValue;
0800           if(m_site != US && tmpValue.length() == 12) {
0801             tmpValue.prepend(QLatin1Char('0'));
0802           }
0803           values << tmpValue;
0804           // limit to first 10 values
0805           if(values.length() >= 10) {
0806             break;
0807           }
0808         }
0809 
0810         payload.insert(QLatin1String("Keywords"), values.join(QLatin1String("|")));
0811       }
0812       break;
0813 
0814     case Keyword:
0815       payload.insert(QLatin1String("Keywords"), request_.value());
0816       break;
0817 
0818     case Raw:
0819       {
0820         QString key = request_.value().section(QLatin1Char('='), 0, 0).trimmed();
0821         QString str = request_.value().section(QLatin1Char('='), 1).trimmed();
0822         payload.insert(key, str);
0823       }
0824       break;
0825 
0826     default:
0827       myWarning() << "key not recognized: " << request().key();
0828       return QByteArray();
0829   }
0830 
0831   switch(m_imageSize) {
0832     case SmallImage:  resources.append(QLatin1String("Images.Primary.Small")); break;
0833     case MediumImage: resources.append(QLatin1String("Images.Primary.Medium")); break;
0834     case LargeImage:  resources.append(QLatin1String("Images.Primary.Large")); break;
0835     case NoImage: break;
0836   }
0837 
0838   payload.insert(QLatin1String("Resources"), resources);
0839   return QJsonDocument(payload).toJson(QJsonDocument::Compact);
0840 }
0841 
0842 Tellico::Data::CollPtr AmazonFetcher::createCollection() {
0843   Data::CollPtr coll = CollectionFactory::collection(collectionType(), true);
0844   if(!coll) {
0845     return coll;
0846   }
0847 
0848   QString imageFieldName;
0849   switch(m_imageSize) {
0850     case SmallImage:  imageFieldName = QStringLiteral("small-image"); break;
0851     case MediumImage: imageFieldName = QStringLiteral("medium-image"); break;
0852     case LargeImage:  imageFieldName = QStringLiteral("large-image"); break;
0853     case NoImage: break;
0854   }
0855 
0856   if(!imageFieldName.isEmpty()) {
0857     Data::FieldPtr field(new Data::Field(imageFieldName, QString(), Data::Field::URL));
0858     coll->addField(field);
0859   }
0860 
0861   if(optionalFields().contains(QStringLiteral("amazon"))) {
0862     Data::FieldPtr field(new Data::Field(QStringLiteral("amazon"), i18n("Amazon Link"), Data::Field::URL));
0863     field->setCategory(i18n("General"));
0864     coll->addField(field);
0865   }
0866 
0867   return coll;
0868 }
0869 
0870 void AmazonFetcher::populateEntry(Data::EntryPtr entry_, const QJsonObject& info_) {
0871   QVariantMap itemMap = info_.value(QLatin1String("ItemInfo")).toObject().toVariantMap();
0872   entry_->setField(QStringLiteral("title"), mapValue(itemMap, "Title", "DisplayValue"));
0873   const QString isbn = mapValue(itemMap, "ExternalIds", "ISBNs", "DisplayValues");
0874   if(!isbn.isEmpty()) {
0875     // could be duplicate isbn10 and isbn13 values
0876     QStringList isbns = FieldFormat::splitValue(isbn, FieldFormat::StringSplit);
0877     for(QStringList::Iterator it = isbns.begin(); it != isbns.end(); ++it) {
0878       if((*it).length() > 12) {
0879         (*it) = ISBNValidator::isbn10(*it);
0880         (*it).remove(QLatin1Char('-'));
0881       }
0882     }
0883     isbns.removeDuplicates();
0884     entry_->setField(QStringLiteral("isbn"), isbns.join(FieldFormat::delimiterString()));
0885   }
0886 
0887   QStringList actors, artists, authors, illustrators, publishers;
0888   QVariantMap byLineMap = itemMap.value(QLatin1String("ByLineInfo")).toMap();
0889   QVariantList contribArray = byLineMap.value(QLatin1String("Contributors")).toList();
0890   foreach(const QVariant& v, contribArray) {
0891     const QVariantMap contribMap = v.toMap();
0892     const QString role = contribMap.value(QLatin1String("Role")).toString();
0893     const QString name = contribMap.value(QLatin1String("Name")).toString();
0894     if(role == QLatin1String("Actor")) {
0895       actors += name;
0896     } else if(role == QLatin1String("Artist")) {
0897       artists += name;
0898     } else if(role == QLatin1String("Author")) {
0899       authors += name;
0900     } else if(role == QLatin1String("Illustrator")) {
0901       illustrators += name;
0902     } else if(role == QLatin1String("Publisher")) {
0903       publishers += name;
0904     }
0905   }
0906   // assume for books that the manufacturer is the publishers
0907   if(collectionType() == Data::Collection::Book ||
0908      collectionType() == Data::Collection::Bibtex ||
0909      collectionType() == Data::Collection::ComicBook) {
0910     const QString manufacturer = byLineMap.value(QLatin1String("Manufacturer")).toMap()
0911                                           .value(QLatin1String("DisplayValue")).toString();
0912     publishers += manufacturer;
0913   }
0914 
0915   actors.removeDuplicates();
0916   artists.removeDuplicates();
0917   authors.removeDuplicates();
0918   illustrators.removeDuplicates();
0919   publishers.removeDuplicates();
0920 
0921   if(!actors.isEmpty()) {
0922     entry_->setField(QStringLiteral("cast"), actors.join(FieldFormat::delimiterString()));
0923   }
0924   if(!artists.isEmpty()) {
0925     entry_->setField(QStringLiteral("artist"), artists.join(FieldFormat::delimiterString()));
0926   }
0927   if(!authors.isEmpty()) {
0928     entry_->setField(QStringLiteral("author"), authors.join(FieldFormat::delimiterString()));
0929   }
0930   if(!illustrators.isEmpty()) {
0931     entry_->setField(QStringLiteral("illustrator"), illustrators.join(FieldFormat::delimiterString()));
0932   }
0933   if(!publishers.isEmpty()) {
0934     entry_->setField(QStringLiteral("publisher"), publishers.join(FieldFormat::delimiterString()));
0935   }
0936 
0937   QVariantMap contentMap = itemMap.value(QLatin1String("ContentInfo")).toMap();
0938   entry_->setField(QStringLiteral("edition"), mapValue(contentMap, "Edition", "DisplayValue"));
0939   entry_->setField(QStringLiteral("pages"), mapValue(contentMap, "PagesCount", "DisplayValue"));
0940   const QString pubDate = mapValue(contentMap, "PublicationDate", "DisplayValue");
0941   if(!pubDate.isEmpty()) {
0942     entry_->setField(QStringLiteral("pub_year"), pubDate.left(4));
0943   }
0944   QVariantList langArray = itemMap.value(QLatin1String("ContentInfo")).toMap()
0945                                   .value(QStringLiteral("Languages")).toMap()
0946                                   .value(QStringLiteral("DisplayValues")).toList();
0947   QStringList langs;
0948   foreach(const QVariant& v, langArray) {
0949     langs += mapValue(v.toMap(), "DisplayValue");
0950   }
0951   langs.removeDuplicates();
0952   langs.removeAll(QString());
0953   entry_->setField(QStringLiteral("language"), langs.join(FieldFormat::delimiterString()));
0954 
0955   if(collectionType() == Data::Collection::Book ||
0956      collectionType() == Data::Collection::Bibtex ||
0957      collectionType() == Data::Collection::ComicBook) {
0958     QVariantMap classificationsMap = itemMap.value(QLatin1String("Classifications")).toMap();
0959     QVariantMap technicalMap = itemMap.value(QLatin1String("TechnicalInfo")).toMap();
0960     QString binding = mapValue(classificationsMap, "Binding", "DisplayValue");
0961     if(binding.isEmpty()) {
0962       binding = mapValue(technicalMap, "Formats", "DisplayValues");
0963     }
0964     if(binding.contains(QStringLiteral("Paperback")) && binding != QStringLiteral("Trade Paperback")) {
0965       binding = i18n("Paperback");
0966     } else if(binding.contains(QStringLiteral("Hard"))) { // could be Hardcover or Hardback
0967       binding = i18n("Hardback");
0968     }
0969     entry_->setField(QStringLiteral("binding"), binding);
0970   }
0971 
0972   QVariantMap imagesMap = info_.value(QLatin1String("Images")).toObject().toVariantMap();
0973   switch(m_imageSize) {
0974     case SmallImage:
0975       entry_->setField(QStringLiteral("small-image"), mapValue(imagesMap, "Primary", "Small", "URL"));
0976       break;
0977     case MediumImage:
0978       entry_->setField(QStringLiteral("medium-image"), mapValue(imagesMap, "Primary", "Medium", "URL"));
0979       break;
0980     case LargeImage:
0981       entry_->setField(QStringLiteral("large-image"), mapValue(imagesMap, "Primary", "Large", "URL"));
0982       break;
0983     case NoImage:
0984       break;
0985   }
0986 
0987   if(optionalFields().contains(QStringLiteral("amazon"))) {
0988     entry_->setField(QStringLiteral("amazon"), mapValue(info_.toVariantMap(), "DetailPageURL"));
0989   }
0990 }
0991 
0992 void AmazonFetcher::parseTitle(Tellico::Data::EntryPtr entry_) {
0993   // assume that everything in brackets or parentheses is extra
0994   static const QRegularExpression rx(QLatin1String("[\\(\\[](.*?)[\\)\\]]"));
0995   QString title = entry_->field(QStringLiteral("title"));
0996   int pos = 0;
0997   QRegularExpressionMatch match = rx.match(title, pos);
0998   while(match.hasMatch()) {
0999     pos = match.capturedStart();
1000     if(parseTitleToken(entry_, match.captured(1))) {
1001       title.remove(match.capturedStart(), match.capturedLength());
1002       --pos; // search again there
1003     }
1004     match = rx.match(title, pos+1);
1005   }
1006   entry_->setField(QStringLiteral("title"), title.simplified());
1007 }
1008 
1009 bool AmazonFetcher::parseTitleToken(Tellico::Data::EntryPtr entry_, const QString& token_) {
1010 //  myDebug() << "title token:" << token_;
1011   // if res = true, then the token gets removed from the title
1012   bool res = false;
1013   if(token_.indexOf(QLatin1String("widescreen"), 0, Qt::CaseInsensitive) > -1 ||
1014      token_.indexOf(i18n("Widescreen"), 0, Qt::CaseInsensitive) > -1) {
1015     entry_->setField(QStringLiteral("widescreen"), QStringLiteral("true"));
1016     // res = true; leave it in the title
1017   } else if(token_.indexOf(QLatin1String("full screen"), 0, Qt::CaseInsensitive) > -1) {
1018     // skip, but go ahead and remove from title
1019     res = true;
1020   } else if(token_.indexOf(QLatin1String("import"), 0, Qt::CaseInsensitive) > -1) {
1021     // skip, but go ahead and remove from title
1022     res = true;
1023   }
1024   if(token_.indexOf(QLatin1String("blu-ray"), 0, Qt::CaseInsensitive) > -1) {
1025     entry_->setField(QStringLiteral("medium"), i18n("Blu-ray"));
1026     res = true;
1027   } else if(token_.indexOf(QLatin1String("hd dvd"), 0, Qt::CaseInsensitive) > -1) {
1028     entry_->setField(QStringLiteral("medium"), i18n("HD DVD"));
1029     res = true;
1030   } else if(token_.indexOf(QLatin1String("vhs"), 0, Qt::CaseInsensitive) > -1) {
1031     entry_->setField(QStringLiteral("medium"), i18n("VHS"));
1032     res = true;
1033   }
1034   if(token_.indexOf(QLatin1String("director's cut"), 0, Qt::CaseInsensitive) > -1 ||
1035      token_.indexOf(i18n("Director's Cut"), 0, Qt::CaseInsensitive) > -1) {
1036     entry_->setField(QStringLiteral("directors-cut"), QStringLiteral("true"));
1037     // res = true; leave it in the title
1038   }
1039   const QString tokenLower = token_.toLower();
1040   if(tokenLower == QLatin1String("ntsc")) {
1041     entry_->setField(QStringLiteral("format"), i18n("NTSC"));
1042     res = true;
1043   }
1044   if(tokenLower == QLatin1String("dvd")) {
1045     entry_->setField(QStringLiteral("medium"), i18n("DVD"));
1046     res = true;
1047   }
1048   if(token_.indexOf(QLatin1String("series"), 0, Qt::CaseInsensitive) > -1) {
1049     entry_->setField(QStringLiteral("series"), token_);
1050     res = true;
1051   }
1052   static const QRegularExpression regionRx(QLatin1String("Region [1-9]"));
1053   QRegularExpressionMatch match = regionRx.match(token_);
1054   if(match.hasMatch()) {
1055     entry_->setField(QStringLiteral("region"), i18n(match.captured().toUtf8().constData()));
1056     res = true;
1057   }
1058   if(entry_->collection()->type() == Data::Collection::Game) {
1059     Data::FieldPtr f = entry_->collection()->fieldByName(QStringLiteral("platform"));
1060     if(f && f->allowed().contains(token_)) {
1061       res = true;
1062     }
1063   }
1064   return res;
1065 }
1066 
1067 //static
1068 QString AmazonFetcher::defaultName() {
1069   return i18n("Amazon.com Web Services");
1070 }
1071 
1072 QString AmazonFetcher::defaultIcon() {
1073   return favIcon("http://www.amazon.com");
1074 }
1075 
1076 Tellico::StringHash AmazonFetcher::allOptionalFields() {
1077   StringHash hash;
1078   hash[QStringLiteral("keyword")] = i18n("Keywords");
1079   hash[QStringLiteral("amazon")] = i18n("Amazon Link");
1080   return hash;
1081 }
1082 
1083 Tellico::Fetch::ConfigWidget* AmazonFetcher::configWidget(QWidget* parent_) const {
1084   return new AmazonFetcher::ConfigWidget(parent_, this);
1085 }
1086 
1087 AmazonFetcher::ConfigWidget::ConfigWidget(QWidget* parent_, const AmazonFetcher* fetcher_/*=0*/)
1088     : Fetch::ConfigWidget(parent_) {
1089   QGridLayout* l = new QGridLayout(optionsWidget());
1090   l->setSpacing(4);
1091   l->setColumnStretch(1, 10);
1092 
1093   int row = -1;
1094 
1095   QLabel* al = new QLabel(i18n("Registration is required for accessing this data source. "
1096                                "If you agree to the terms and conditions, <a href='%1'>sign "
1097                                "up for an account</a>, and enter your information below.",
1098                                 QLatin1String("https://affiliate-program.amazon.com/gp/flex/advertising/api/sign-in.html")),
1099                           optionsWidget());
1100   al->setOpenExternalLinks(true);
1101   al->setWordWrap(true);
1102   ++row;
1103   l->addWidget(al, row, 0, 1, 2);
1104   // richtext gets weird with size
1105   al->setMinimumWidth(al->sizeHint().width());
1106 
1107   QLabel* label = new QLabel(i18n("Access key: "), optionsWidget());
1108   l->addWidget(label, ++row, 0);
1109   m_accessEdit = new QLineEdit(optionsWidget());
1110   connect(m_accessEdit, &QLineEdit::textChanged, this, &ConfigWidget::slotSetModified);
1111   l->addWidget(m_accessEdit, row, 1);
1112   QString w = i18n("Access to data from Amazon.com requires an AWS Access Key ID and a Secret Key.");
1113   label->setWhatsThis(w);
1114   m_accessEdit->setWhatsThis(w);
1115   label->setBuddy(m_accessEdit);
1116 
1117   label = new QLabel(i18n("Secret key: "), optionsWidget());
1118   l->addWidget(label, ++row, 0);
1119   m_secretKeyEdit = new QLineEdit(optionsWidget());
1120 //  m_secretKeyEdit->setEchoMode(QLineEdit::PasswordEchoOnEdit);
1121   connect(m_secretKeyEdit, &QLineEdit::textChanged, this, &ConfigWidget::slotSetModified);
1122   l->addWidget(m_secretKeyEdit, row, 1);
1123   label->setWhatsThis(w);
1124   m_secretKeyEdit->setWhatsThis(w);
1125   label->setBuddy(m_secretKeyEdit);
1126 
1127   label = new QLabel(i18n("Country: "), optionsWidget());
1128   l->addWidget(label, ++row, 0);
1129   m_siteCombo = new GUI::ComboBox(optionsWidget());
1130   for(int i = 0; i < XX; ++i) {
1131     const AmazonFetcher::SiteData& siteData = AmazonFetcher::siteData(i);
1132     QIcon icon(QStandardPaths::locate(QStandardPaths::GenericDataLocation,
1133                                       QStringLiteral("kf5/locale/countries/%1/flag.png").arg(siteData.country)));
1134     m_siteCombo->addItem(icon, siteData.countryName, i);
1135     m_siteCombo->model()->sort(0);
1136   }
1137 
1138   void (GUI::ComboBox::* activatedInt)(int) = &GUI::ComboBox::activated;
1139   connect(m_siteCombo, activatedInt, this, &ConfigWidget::slotSetModified);
1140   connect(m_siteCombo, activatedInt, this, &ConfigWidget::slotSiteChanged);
1141   l->addWidget(m_siteCombo, row, 1);
1142   w = i18n("Amazon.com provides data from several different localized sites. Choose the one "
1143            "you wish to use for this data source.");
1144   label->setWhatsThis(w);
1145   m_siteCombo->setWhatsThis(w);
1146   label->setBuddy(m_siteCombo);
1147 
1148   label = new QLabel(i18n("&Image size: "), optionsWidget());
1149   l->addWidget(label, ++row, 0);
1150   m_imageCombo = new GUI::ComboBox(optionsWidget());
1151   m_imageCombo->addItem(i18n("Small Image"), SmallImage);
1152   m_imageCombo->addItem(i18n("Medium Image"), MediumImage);
1153   m_imageCombo->addItem(i18n("Large Image"), LargeImage);
1154   m_imageCombo->addItem(i18n("No Image"), NoImage);
1155   connect(m_imageCombo, activatedInt, this, &ConfigWidget::slotSetModified);
1156   l->addWidget(m_imageCombo, row, 1);
1157   w = i18n("The cover image may be downloaded as well. However, too many large images in the "
1158            "collection may degrade performance.");
1159   label->setWhatsThis(w);
1160   m_imageCombo->setWhatsThis(w);
1161   label->setBuddy(m_imageCombo);
1162 
1163   label = new QLabel(i18n("&Associate's ID: "), optionsWidget());
1164   l->addWidget(label, ++row, 0);
1165   m_assocEdit = new QLineEdit(optionsWidget());
1166   void (QLineEdit::* textChanged)(const QString&) = &QLineEdit::textChanged;
1167   connect(m_assocEdit, textChanged, this, &ConfigWidget::slotSetModified);
1168   l->addWidget(m_assocEdit, row, 1);
1169   w = i18n("The associate's id identifies the person accessing the Amazon.com Web Services, and is included "
1170            "in any links to the Amazon.com site.");
1171   label->setWhatsThis(w);
1172   m_assocEdit->setWhatsThis(w);
1173   label->setBuddy(m_assocEdit);
1174 
1175   l->setRowStretch(++row, 10);
1176 
1177   if(fetcher_) {
1178     m_siteCombo->setCurrentData(fetcher_->m_site);
1179     m_accessEdit->setText(fetcher_->m_accessKey);
1180     m_secretKeyEdit->setText(fetcher_->m_secretKey);
1181     m_assocEdit->setText(fetcher_->m_assoc);
1182     m_imageCombo->setCurrentData(fetcher_->m_imageSize);
1183   } else { // defaults
1184     m_assocEdit->setText(QLatin1String(AMAZON_ASSOC_TOKEN));
1185     m_imageCombo->setCurrentData(MediumImage);
1186   }
1187 
1188   addFieldsWidget(AmazonFetcher::allOptionalFields(), fetcher_ ? fetcher_->optionalFields() : QStringList());
1189 
1190   KAcceleratorManager::manage(optionsWidget());
1191 }
1192 
1193 void AmazonFetcher::ConfigWidget::saveConfigHook(KConfigGroup& config_) {
1194   int n = m_siteCombo->currentData().toInt();
1195   config_.writeEntry("Site", n);
1196   QString s = m_accessEdit->text().trimmed();
1197   if(!s.isEmpty()) {
1198     config_.writeEntry("AccessKey", s);
1199   }
1200   s = m_secretKeyEdit->text().trimmed();
1201   if(!s.isEmpty()) {
1202     config_.writeEntry("SecretKey", s);
1203   }
1204   s = m_assocEdit->text().trimmed();
1205   if(!s.isEmpty()) {
1206     config_.writeEntry("AssocToken", s);
1207   }
1208   n = m_imageCombo->currentData().toInt();
1209   config_.writeEntry("Image Size", n);
1210 }
1211 
1212 QString AmazonFetcher::ConfigWidget::preferredName() const {
1213   return AmazonFetcher::siteData(m_siteCombo->currentData().toInt()).title;
1214 }
1215 
1216 void AmazonFetcher::ConfigWidget::slotSiteChanged() {
1217   emit signalName(preferredName());
1218 }