File indexing completed on 2024-05-12 05:09:27

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/mapvalue.h"
0038 #include "../utils/isbnvalidator.h"
0039 #include "../gui/combobox.h"
0040 #include "../tellico_debug.h"
0041 
0042 #include <KLocalizedString>
0043 #include <KIO/Job>
0044 #include <KIO/JobUiDelegate>
0045 #include <KSeparator>
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   foreach(const QJsonValue& item, resultObject.value(QLatin1String("Items")).toArray()) {
0410     if(m_numResults >= m_limit) {
0411       break;
0412     }
0413     if(!m_started) {
0414       // might get aborted
0415       break;
0416     }
0417     Data::EntryPtr entry(new Data::Entry(coll));
0418     populateEntry(entry, item.toObject());
0419 
0420     // special case book author
0421     // amazon is really bad about not putting spaces after periods
0422     if(coll->type() == Data::Collection::Book) {
0423       static const QRegularExpression rx(QLatin1String("\\.([^\\s])"));
0424       QStringList values = FieldFormat::splitValue(entry->field(QStringLiteral("author")));
0425       for(QStringList::Iterator it = values.begin(); it != values.end(); ++it) {
0426         (*it).replace(rx, QStringLiteral(". \\1"));
0427       }
0428       entry->setField(QStringLiteral("author"), values.join(FieldFormat::delimiterString()));
0429     }
0430 
0431     // UK puts the year in the title for some reason
0432     if(m_site == UK && coll->type() == Data::Collection::Video) {
0433       static const QRegularExpression rx(QLatin1String("\\[(\\d{4})\\]"));
0434       const QString titleString(QStringLiteral("title"));
0435       QString t = entry->field(titleString);
0436       auto match = rx.match(t);
0437       if(match.hasMatch()) {
0438         t = t.remove(rx).simplified();
0439         entry->setField(titleString, t);
0440         const QString yearString(QStringLiteral("year"));
0441         if(entry->field(yearString).isEmpty()) {
0442           entry->setField(yearString, match.captured(1));
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   static const QRegularExpression 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     auto blankMatch = blank.match(entry->field(fIt));
0604     if(blankMatch.hasMatch()) {
0605       entry->setField(fIt, QString());
0606     }
0607   }
0608 
0609   // don't want to show image urls in the fetch dialog
0610   // so clear them after reading the URL
0611   QString imageURL;
0612   switch(m_imageSize) {
0613     case SmallImage:
0614       imageURL = entry->field(QStringLiteral("small-image"));
0615       entry->setField(QStringLiteral("small-image"),  QString());
0616       break;
0617     case MediumImage:
0618       imageURL = entry->field(QStringLiteral("medium-image"));
0619       entry->setField(QStringLiteral("medium-image"),  QString());
0620       break;
0621     case LargeImage:
0622       imageURL = entry->field(QStringLiteral("large-image"));
0623       entry->setField(QStringLiteral("large-image"),  QString());
0624       break;
0625     case NoImage:
0626     default:
0627       break;
0628   }
0629 
0630   if(!imageURL.isEmpty()) {
0631 //    myDebug() << "grabbing " << imageURL;
0632     QString id = ImageFactory::addImage(QUrl::fromUserInput(imageURL), true);
0633     if(id.isEmpty()) {
0634       message(i18n("The cover image could not be loaded."), MessageHandler::Warning);
0635     } else { // amazon serves up 1x1 gifs occasionally, but that's caught in the image constructor
0636       // all relevant collection types have cover fields
0637       entry->setField(QStringLiteral("cover"), id);
0638     }
0639   }
0640 
0641   return entry;
0642 }
0643 
0644 Tellico::Fetch::FetchRequest AmazonFetcher::updateRequest(Data::EntryPtr entry_) {
0645   const int type = entry_->collection()->type();
0646   const QString t = entry_->field(QStringLiteral("title"));
0647   if(type == Data::Collection::Book || type == Data::Collection::ComicBook || type == Data::Collection::Bibtex) {
0648     const QString isbn = entry_->field(QStringLiteral("isbn"));
0649     if(!isbn.isEmpty()) {
0650       return FetchRequest(Fetch::ISBN, isbn);
0651     }
0652     const QString a = entry_->field(QStringLiteral("author"));
0653     if(!a.isEmpty()) {
0654       return t.isEmpty() ? FetchRequest(Fetch::Person, a)
0655                          : FetchRequest(Fetch::Keyword, t + QLatin1Char('-') + a);
0656     }
0657   } else if(type == Data::Collection::Album) {
0658     const QString a = entry_->field(QStringLiteral("artist"));
0659     if(!a.isEmpty()) {
0660       return t.isEmpty() ? FetchRequest(Fetch::Person, a)
0661                          : FetchRequest(Fetch::Keyword, t + QLatin1Char('-') + a);
0662     }
0663   }
0664 
0665   // optimistically try searching for title and rely on Collection::sameEntry() to figure things out
0666   if(!t.isEmpty()) {
0667     return FetchRequest(Fetch::Title, t);
0668   }
0669 
0670   return FetchRequest();
0671 }
0672 
0673 QByteArray AmazonFetcher::requestPayload(const Fetch::FetchRequest& request_) {
0674   QJsonObject payload;
0675   payload.insert(QLatin1String("PartnerTag"), m_assoc);
0676   payload.insert(QLatin1String("PartnerType"), QLatin1String("Associates"));
0677   payload.insert(QLatin1String("Service"), QLatin1String("ProductAdvertisingAPIv1"));
0678   payload.insert(QLatin1String("Operation"), QLatin1String("SearchItems"));
0679   payload.insert(QLatin1String("SortBy"), QLatin1String("Relevance"));
0680   // not mandatory
0681 //  payload.insert(QLatin1String("Marketplace"), QLatin1String(siteData(m_site).host));
0682   if(m_page > 1) {
0683     payload.insert(QLatin1String("ItemPage"), m_page);
0684   }
0685 
0686   QJsonArray resources;
0687   resources.append(QLatin1String("ItemInfo.Title"));
0688   resources.append(QLatin1String("ItemInfo.ContentInfo"));
0689   resources.append(QLatin1String("ItemInfo.ByLineInfo"));
0690   resources.append(QLatin1String("ItemInfo.TechnicalInfo"));
0691 
0692   const int type = request_.collectionType();
0693   switch(type) {
0694     case Data::Collection::Book:
0695     case Data::Collection::ComicBook:
0696     case Data::Collection::Bibtex:
0697       payload.insert(QLatin1String("SearchIndex"), QLatin1String("Books"));
0698       resources.append(QLatin1String("ItemInfo.ExternalIds"));
0699       resources.append(QLatin1String("ItemInfo.ManufactureInfo"));
0700       break;
0701 
0702     case Data::Collection::Album:
0703       payload.insert(QLatin1String("SearchIndex"), QLatin1String("Music"));
0704       break;
0705 
0706     case Data::Collection::Video:
0707       // CA and JP appear to have a bug where Video only returns VHS or Music results
0708       // DVD will return DVD, Blu-ray, etc. so just ignore VHS for those users
0709       payload.insert(QLatin1String("SearchIndex"), QLatin1String("MoviesAndTV"));
0710       if(m_site == CA || m_site == JP || m_site == IT || m_site == ES) {
0711         payload.insert(QStringLiteral("SearchIndex"), QStringLiteral("DVD"));
0712       } else {
0713         payload.insert(QStringLiteral("SearchIndex"), QStringLiteral("Video"));
0714       }
0715 //      params.insert(QStringLiteral("SortIndex"), QStringLiteral("relevancerank"));
0716       resources.append(QLatin1String("ItemInfo.ContentRating"));
0717       break;
0718 
0719     case Data::Collection::Game:
0720       payload.insert(QLatin1String("SearchIndex"), QLatin1String("VideoGames"));
0721       break;
0722 
0723     case Data::Collection::BoardGame:
0724       payload.insert(QLatin1String("SearchIndex"), QLatin1String("ToysAndGames"));
0725 //      params.insert(QStringLiteral("SortIndex"), QStringLiteral("relevancerank"));
0726       break;
0727 
0728     case Data::Collection::Coin:
0729     case Data::Collection::Stamp:
0730     case Data::Collection::Wine:
0731     case Data::Collection::Base:
0732     case Data::Collection::Card:
0733       myDebug() << "can't fetch this type:" << collectionType();
0734       return QByteArray();
0735   }
0736 
0737   switch(request_.key()) {
0738     case Title:
0739       payload.insert(QLatin1String("Title"), request_.value());
0740       break;
0741 
0742     case Person:
0743       if(type == Data::Collection::Video) {
0744         payload.insert(QStringLiteral("Actor"), request_.value());
0745 //        payload.insert(QStringLiteral("Director"), request_.value());
0746       } else if(type == Data::Collection::Album) {
0747         payload.insert(QStringLiteral("Artist"), request_.value());
0748       } else if(type == Data::Collection::Book) {
0749         payload.insert(QLatin1String("Author"), request_.value());
0750       } else {
0751         payload.insert(QLatin1String("Keywords"), request_.value());
0752       }
0753       break;
0754 
0755     case ISBN:
0756       {
0757         QString cleanValue = request_.value();
0758         cleanValue.remove(QLatin1Char('-'));
0759         // ISBN only get digits or 'X'
0760         QStringList isbns = FieldFormat::splitValue(cleanValue);
0761         // Amazon isbn13 search is still very flaky, so if possible, we're going to convert
0762         // all of them to isbn10. If we run into a 979 isbn13, then we're forced to do an
0763         // isbn13 search
0764         bool isbn13 = false;
0765         for(QStringList::Iterator it = isbns.begin(); it != isbns.end(); ) {
0766           if((*it).startsWith(QLatin1String("979"))) {
0767             isbn13 = true;
0768             break;
0769           }
0770           ++it;
0771         }
0772         // if we want isbn10, then convert all
0773         if(!isbn13) {
0774           for(QStringList::Iterator it = isbns.begin(); it != isbns.end(); ++it) {
0775             if((*it).length() > 12) {
0776               (*it) = ISBNValidator::isbn10(*it);
0777               (*it).remove(QLatin1Char('-'));
0778             }
0779           }
0780         }
0781         // limit to first 10
0782         while(isbns.size() > 10) {
0783           isbns.pop_back();
0784         }
0785         payload.insert(QLatin1String("Keywords"), isbns.join(QLatin1String("|")));
0786         if(isbn13) {
0787 //          params.insert(QStringLiteral("IdType"), QStringLiteral("EAN"));
0788         }
0789       }
0790       break;
0791 
0792     case UPC:
0793       {
0794         QString cleanValue = request_.value();
0795         cleanValue.remove(QLatin1Char('-'));
0796         // for EAN values, add 0 to beginning if not 13 characters
0797         // in order to assume US country code from UPC value
0798         QStringList values;
0799         foreach(const QString& splitValue, cleanValue.split(FieldFormat::delimiterString())) {
0800           QString tmpValue = splitValue;
0801           if(m_site != US && tmpValue.length() == 12) {
0802             tmpValue.prepend(QLatin1Char('0'));
0803           }
0804           values << tmpValue;
0805           // limit to first 10 values
0806           if(values.length() >= 10) {
0807             break;
0808           }
0809         }
0810 
0811         payload.insert(QLatin1String("Keywords"), values.join(QLatin1String("|")));
0812       }
0813       break;
0814 
0815     case Keyword:
0816       payload.insert(QLatin1String("Keywords"), request_.value());
0817       break;
0818 
0819     case Raw:
0820       {
0821         QString key = request_.value().section(QLatin1Char('='), 0, 0).trimmed();
0822         QString str = request_.value().section(QLatin1Char('='), 1).trimmed();
0823         payload.insert(key, str);
0824       }
0825       break;
0826 
0827     default:
0828       myWarning() << source() << "- key not recognized:" << request().key();
0829       return QByteArray();
0830   }
0831 
0832   switch(m_imageSize) {
0833     case SmallImage:  resources.append(QLatin1String("Images.Primary.Small")); break;
0834     case MediumImage: resources.append(QLatin1String("Images.Primary.Medium")); break;
0835     case LargeImage:  resources.append(QLatin1String("Images.Primary.Large")); break;
0836     case NoImage: break;
0837   }
0838 
0839   payload.insert(QLatin1String("Resources"), resources);
0840   return QJsonDocument(payload).toJson(QJsonDocument::Compact);
0841 }
0842 
0843 Tellico::Data::CollPtr AmazonFetcher::createCollection() {
0844   Data::CollPtr coll = CollectionFactory::collection(collectionType(), true);
0845   if(!coll) {
0846     return coll;
0847   }
0848 
0849   QString imageFieldName;
0850   switch(m_imageSize) {
0851     case SmallImage:  imageFieldName = QStringLiteral("small-image"); break;
0852     case MediumImage: imageFieldName = QStringLiteral("medium-image"); break;
0853     case LargeImage:  imageFieldName = QStringLiteral("large-image"); break;
0854     case NoImage: break;
0855   }
0856 
0857   if(!imageFieldName.isEmpty()) {
0858     Data::FieldPtr field(new Data::Field(imageFieldName, QString(), Data::Field::URL));
0859     coll->addField(field);
0860   }
0861 
0862   if(optionalFields().contains(QStringLiteral("amazon"))) {
0863     Data::FieldPtr field(new Data::Field(QStringLiteral("amazon"), i18n("Amazon Link"), Data::Field::URL));
0864     field->setCategory(i18n("General"));
0865     coll->addField(field);
0866   }
0867 
0868   return coll;
0869 }
0870 
0871 void AmazonFetcher::populateEntry(Data::EntryPtr entry_, const QJsonObject& info_) {
0872   QVariantMap itemMap = info_.value(QLatin1String("ItemInfo")).toObject().toVariantMap();
0873   entry_->setField(QStringLiteral("title"), mapValue(itemMap, "Title", "DisplayValue"));
0874   const QString isbn = mapValue(itemMap, "ExternalIds", "ISBNs", "DisplayValues");
0875   if(!isbn.isEmpty()) {
0876     // could be duplicate isbn10 and isbn13 values
0877     QStringList isbns = FieldFormat::splitValue(isbn, FieldFormat::StringSplit);
0878     for(QStringList::Iterator it = isbns.begin(); it != isbns.end(); ++it) {
0879       if((*it).length() > 12) {
0880         (*it) = ISBNValidator::isbn10(*it);
0881         (*it).remove(QLatin1Char('-'));
0882       }
0883     }
0884     isbns.removeDuplicates();
0885     entry_->setField(QStringLiteral("isbn"), isbns.join(FieldFormat::delimiterString()));
0886   }
0887 
0888   QStringList actors, artists, authors, illustrators, publishers;
0889   QVariantMap byLineMap = itemMap.value(QLatin1String("ByLineInfo")).toMap();
0890   QVariantList contribArray = byLineMap.value(QLatin1String("Contributors")).toList();
0891   foreach(const QVariant& v, contribArray) {
0892     const QVariantMap contribMap = v.toMap();
0893     const QString role = contribMap.value(QLatin1String("Role")).toString();
0894     const QString name = contribMap.value(QLatin1String("Name")).toString();
0895     if(role == QLatin1String("Actor")) {
0896       actors += name;
0897     } else if(role == QLatin1String("Artist")) {
0898       artists += name;
0899     } else if(role == QLatin1String("Author")) {
0900       authors += name;
0901     } else if(role == QLatin1String("Illustrator")) {
0902       illustrators += name;
0903     } else if(role == QLatin1String("Publisher")) {
0904       publishers += name;
0905     }
0906   }
0907   // assume for books that the manufacturer is the publishers
0908   if(collectionType() == Data::Collection::Book ||
0909      collectionType() == Data::Collection::Bibtex ||
0910      collectionType() == Data::Collection::ComicBook) {
0911     const QString manufacturer = byLineMap.value(QLatin1String("Manufacturer")).toMap()
0912                                           .value(QLatin1String("DisplayValue")).toString();
0913     publishers += manufacturer;
0914   }
0915 
0916   actors.removeDuplicates();
0917   artists.removeDuplicates();
0918   authors.removeDuplicates();
0919   illustrators.removeDuplicates();
0920   publishers.removeDuplicates();
0921 
0922   if(!actors.isEmpty()) {
0923     entry_->setField(QStringLiteral("cast"), actors.join(FieldFormat::delimiterString()));
0924   }
0925   if(!artists.isEmpty()) {
0926     entry_->setField(QStringLiteral("artist"), artists.join(FieldFormat::delimiterString()));
0927   }
0928   if(!authors.isEmpty()) {
0929     entry_->setField(QStringLiteral("author"), authors.join(FieldFormat::delimiterString()));
0930   }
0931   if(!illustrators.isEmpty()) {
0932     entry_->setField(QStringLiteral("illustrator"), illustrators.join(FieldFormat::delimiterString()));
0933   }
0934   if(!publishers.isEmpty()) {
0935     entry_->setField(QStringLiteral("publisher"), publishers.join(FieldFormat::delimiterString()));
0936   }
0937 
0938   QVariantMap contentMap = itemMap.value(QLatin1String("ContentInfo")).toMap();
0939   entry_->setField(QStringLiteral("edition"), mapValue(contentMap, "Edition", "DisplayValue"));
0940   entry_->setField(QStringLiteral("pages"), mapValue(contentMap, "PagesCount", "DisplayValue"));
0941   const QString pubDate = mapValue(contentMap, "PublicationDate", "DisplayValue");
0942   if(!pubDate.isEmpty()) {
0943     entry_->setField(QStringLiteral("pub_year"), pubDate.left(4));
0944   }
0945   QVariantList langArray = itemMap.value(QLatin1String("ContentInfo")).toMap()
0946                                   .value(QStringLiteral("Languages")).toMap()
0947                                   .value(QStringLiteral("DisplayValues")).toList();
0948   QStringList langs;
0949   foreach(const QVariant& v, langArray) {
0950     langs += mapValue(v.toMap(), "DisplayValue");
0951   }
0952   langs.removeDuplicates();
0953   langs.removeAll(QString());
0954   entry_->setField(QStringLiteral("language"), langs.join(FieldFormat::delimiterString()));
0955 
0956   if(collectionType() == Data::Collection::Book ||
0957      collectionType() == Data::Collection::Bibtex ||
0958      collectionType() == Data::Collection::ComicBook) {
0959     QVariantMap classificationsMap = itemMap.value(QLatin1String("Classifications")).toMap();
0960     QString binding = mapValue(classificationsMap, "Binding", "DisplayValue");
0961     if(binding.isEmpty()) {
0962       QVariantMap technicalMap = itemMap.value(QLatin1String("TechnicalInfo")).toMap();
0963       binding = mapValue(technicalMap, "Formats", "DisplayValues");
0964     }
0965     if(binding.contains(QStringLiteral("Paperback")) && binding != QStringLiteral("Trade Paperback")) {
0966       binding = i18n("Paperback");
0967     } else if(binding.contains(QStringLiteral("Hard"))) { // could be Hardcover or Hardback
0968       binding = i18n("Hardback");
0969     }
0970     entry_->setField(QStringLiteral("binding"), binding);
0971   }
0972 
0973   QVariantMap imagesMap = info_.value(QLatin1String("Images")).toObject().toVariantMap();
0974   switch(m_imageSize) {
0975     case SmallImage:
0976       entry_->setField(QStringLiteral("small-image"), mapValue(imagesMap, "Primary", "Small", "URL"));
0977       break;
0978     case MediumImage:
0979       entry_->setField(QStringLiteral("medium-image"), mapValue(imagesMap, "Primary", "Medium", "URL"));
0980       break;
0981     case LargeImage:
0982       entry_->setField(QStringLiteral("large-image"), mapValue(imagesMap, "Primary", "Large", "URL"));
0983       break;
0984     case NoImage:
0985       break;
0986   }
0987 
0988   if(optionalFields().contains(QStringLiteral("amazon"))) {
0989     entry_->setField(QStringLiteral("amazon"), mapValue(info_.toVariantMap(), "DetailPageURL"));
0990   }
0991 }
0992 
0993 void AmazonFetcher::parseTitle(Tellico::Data::EntryPtr entry_) {
0994   // assume that everything in brackets or parentheses is extra
0995   static const QRegularExpression rx(QLatin1String("[\\(\\[](.*?)[\\)\\]]"));
0996   QString title = entry_->field(QStringLiteral("title"));
0997   int pos = 0;
0998   QRegularExpressionMatch match = rx.match(title, pos);
0999   while(match.hasMatch()) {
1000     pos = match.capturedStart();
1001     if(parseTitleToken(entry_, match.captured(1))) {
1002       title.remove(match.capturedStart(), match.capturedLength());
1003       --pos; // search again there
1004     }
1005     match = rx.match(title, pos+1);
1006   }
1007   entry_->setField(QStringLiteral("title"), title.simplified());
1008 }
1009 
1010 bool AmazonFetcher::parseTitleToken(Tellico::Data::EntryPtr entry_, const QString& token_) {
1011 //  myDebug() << "title token:" << token_;
1012   // if res = true, then the token gets removed from the title
1013   bool res = false;
1014   if(token_.indexOf(QLatin1String("widescreen"), 0, Qt::CaseInsensitive) > -1 ||
1015      token_.indexOf(i18n("Widescreen"), 0, Qt::CaseInsensitive) > -1) {
1016     entry_->setField(QStringLiteral("widescreen"), QStringLiteral("true"));
1017     // res = true; leave it in the title
1018   } else if(token_.indexOf(QLatin1String("full screen"), 0, Qt::CaseInsensitive) > -1) {
1019     // skip, but go ahead and remove from title
1020     res = true;
1021   } else if(token_.indexOf(QLatin1String("import"), 0, Qt::CaseInsensitive) > -1) {
1022     // skip, but go ahead and remove from title
1023     res = true;
1024   }
1025   if(token_.indexOf(QLatin1String("blu-ray"), 0, Qt::CaseInsensitive) > -1) {
1026     entry_->setField(QStringLiteral("medium"), i18n("Blu-ray"));
1027     res = true;
1028   } else if(token_.indexOf(QLatin1String("hd dvd"), 0, Qt::CaseInsensitive) > -1) {
1029     entry_->setField(QStringLiteral("medium"), i18n("HD DVD"));
1030     res = true;
1031   } else if(token_.indexOf(QLatin1String("vhs"), 0, Qt::CaseInsensitive) > -1) {
1032     entry_->setField(QStringLiteral("medium"), i18n("VHS"));
1033     res = true;
1034   }
1035   if(token_.indexOf(QLatin1String("director's cut"), 0, Qt::CaseInsensitive) > -1 ||
1036      token_.indexOf(i18n("Director's Cut"), 0, Qt::CaseInsensitive) > -1) {
1037     entry_->setField(QStringLiteral("directors-cut"), QStringLiteral("true"));
1038     // res = true; leave it in the title
1039   }
1040   const QString tokenLower = token_.toLower();
1041   if(tokenLower == QLatin1String("ntsc")) {
1042     entry_->setField(QStringLiteral("format"), i18n("NTSC"));
1043     res = true;
1044   }
1045   if(tokenLower == QLatin1String("dvd")) {
1046     entry_->setField(QStringLiteral("medium"), i18n("DVD"));
1047     res = true;
1048   }
1049   if(token_.indexOf(QLatin1String("series"), 0, Qt::CaseInsensitive) > -1) {
1050     entry_->setField(QStringLiteral("series"), token_);
1051     res = true;
1052   }
1053   static const QRegularExpression regionRx(QLatin1String("Region [1-9]"));
1054   QRegularExpressionMatch match = regionRx.match(token_);
1055   if(match.hasMatch()) {
1056     entry_->setField(QStringLiteral("region"), i18n(match.captured().toUtf8().constData()));
1057     res = true;
1058   }
1059   if(entry_->collection()->type() == Data::Collection::Game) {
1060     Data::FieldPtr f = entry_->collection()->fieldByName(QStringLiteral("platform"));
1061     if(f && f->allowed().contains(token_)) {
1062       res = true;
1063     }
1064   }
1065   return res;
1066 }
1067 
1068 //static
1069 QString AmazonFetcher::defaultName() {
1070   return i18n("Amazon.com Web Services");
1071 }
1072 
1073 QString AmazonFetcher::defaultIcon() {
1074   return favIcon("http://www.amazon.com");
1075 }
1076 
1077 Tellico::StringHash AmazonFetcher::allOptionalFields() {
1078   StringHash hash;
1079   hash[QStringLiteral("keyword")] = i18n("Keywords");
1080   hash[QStringLiteral("amazon")] = i18n("Amazon Link");
1081   return hash;
1082 }
1083 
1084 Tellico::Fetch::ConfigWidget* AmazonFetcher::configWidget(QWidget* parent_) const {
1085   return new AmazonFetcher::ConfigWidget(parent_, this);
1086 }
1087 
1088 AmazonFetcher::ConfigWidget::ConfigWidget(QWidget* parent_, const AmazonFetcher* fetcher_/*=0*/)
1089     : Fetch::ConfigWidget(parent_) {
1090   QGridLayout* l = new QGridLayout(optionsWidget());
1091   l->setSpacing(4);
1092   l->setColumnStretch(1, 10);
1093 
1094   int row = -1;
1095 
1096   QLabel* al = new QLabel(i18n("Registration is required for accessing this data source. "
1097                                "If you agree to the terms and conditions, <a href='%1'>sign "
1098                                "up for an account</a>, and enter your information below.",
1099                                 QLatin1String("https://affiliate-program.amazon.com/gp/flex/advertising/api/sign-in.html")),
1100                           optionsWidget());
1101   al->setOpenExternalLinks(true);
1102   al->setWordWrap(true);
1103   ++row;
1104   l->addWidget(al, row, 0, 1, 2);
1105   // richtext gets weird with size
1106   al->setMinimumWidth(al->sizeHint().width());
1107 
1108   QLabel* label = new QLabel(i18n("Access key: "), optionsWidget());
1109   l->addWidget(label, ++row, 0);
1110   m_accessEdit = new QLineEdit(optionsWidget());
1111   connect(m_accessEdit, &QLineEdit::textChanged, this, &ConfigWidget::slotSetModified);
1112   l->addWidget(m_accessEdit, row, 1);
1113   QString w = i18n("Access to data from Amazon.com requires an AWS Access Key ID and a Secret Key.");
1114   label->setWhatsThis(w);
1115   m_accessEdit->setWhatsThis(w);
1116   label->setBuddy(m_accessEdit);
1117 
1118   label = new QLabel(i18n("Secret key: "), optionsWidget());
1119   l->addWidget(label, ++row, 0);
1120   m_secretKeyEdit = new QLineEdit(optionsWidget());
1121 //  m_secretKeyEdit->setEchoMode(QLineEdit::PasswordEchoOnEdit);
1122   connect(m_secretKeyEdit, &QLineEdit::textChanged, this, &ConfigWidget::slotSetModified);
1123   l->addWidget(m_secretKeyEdit, row, 1);
1124   label->setWhatsThis(w);
1125   m_secretKeyEdit->setWhatsThis(w);
1126   label->setBuddy(m_secretKeyEdit);
1127 
1128   label = new QLabel(i18n("Country: "), optionsWidget());
1129   l->addWidget(label, ++row, 0);
1130   m_siteCombo = new GUI::ComboBox(optionsWidget());
1131   for(int i = 0; i < XX; ++i) {
1132     const AmazonFetcher::SiteData& siteData = AmazonFetcher::siteData(i);
1133     QIcon icon(QStandardPaths::locate(QStandardPaths::GenericDataLocation,
1134                                       QStringLiteral("kf5/locale/countries/%1/flag.png").arg(siteData.country)));
1135     m_siteCombo->addItem(icon, siteData.countryName, i);
1136     m_siteCombo->model()->sort(0);
1137   }
1138 
1139   void (GUI::ComboBox::* activatedInt)(int) = &GUI::ComboBox::activated;
1140   connect(m_siteCombo, activatedInt, this, &ConfigWidget::slotSetModified);
1141   connect(m_siteCombo, activatedInt, this, &ConfigWidget::slotSiteChanged);
1142   l->addWidget(m_siteCombo, row, 1);
1143   w = i18n("Amazon.com provides data from several different localized sites. Choose the one "
1144            "you wish to use for this data source.");
1145   label->setWhatsThis(w);
1146   m_siteCombo->setWhatsThis(w);
1147   label->setBuddy(m_siteCombo);
1148 
1149   label = new QLabel(i18n("&Image size: "), optionsWidget());
1150   l->addWidget(label, ++row, 0);
1151   m_imageCombo = new GUI::ComboBox(optionsWidget());
1152   m_imageCombo->addItem(i18n("Small Image"), SmallImage);
1153   m_imageCombo->addItem(i18n("Medium Image"), MediumImage);
1154   m_imageCombo->addItem(i18n("Large Image"), LargeImage);
1155   m_imageCombo->addItem(i18n("No Image"), NoImage);
1156   connect(m_imageCombo, activatedInt, this, &ConfigWidget::slotSetModified);
1157   l->addWidget(m_imageCombo, row, 1);
1158   w = i18n("The cover image may be downloaded as well. However, too many large images in the "
1159            "collection may degrade performance.");
1160   label->setWhatsThis(w);
1161   m_imageCombo->setWhatsThis(w);
1162   label->setBuddy(m_imageCombo);
1163 
1164   label = new QLabel(i18n("&Associate's ID: "), optionsWidget());
1165   l->addWidget(label, ++row, 0);
1166   m_assocEdit = new QLineEdit(optionsWidget());
1167   void (QLineEdit::* textChanged)(const QString&) = &QLineEdit::textChanged;
1168   connect(m_assocEdit, textChanged, this, &ConfigWidget::slotSetModified);
1169   l->addWidget(m_assocEdit, row, 1);
1170   w = i18n("The associate's id identifies the person accessing the Amazon.com Web Services, and is included "
1171            "in any links to the Amazon.com site.");
1172   label->setWhatsThis(w);
1173   m_assocEdit->setWhatsThis(w);
1174   label->setBuddy(m_assocEdit);
1175 
1176   l->setRowStretch(++row, 10);
1177 
1178   if(fetcher_) {
1179     m_siteCombo->setCurrentData(fetcher_->m_site);
1180     m_accessEdit->setText(fetcher_->m_accessKey);
1181     m_secretKeyEdit->setText(fetcher_->m_secretKey);
1182     m_assocEdit->setText(fetcher_->m_assoc);
1183     m_imageCombo->setCurrentData(fetcher_->m_imageSize);
1184   } else { // defaults
1185     m_assocEdit->setText(QLatin1String(AMAZON_ASSOC_TOKEN));
1186     m_imageCombo->setCurrentData(MediumImage);
1187   }
1188 
1189   addFieldsWidget(AmazonFetcher::allOptionalFields(), fetcher_ ? fetcher_->optionalFields() : QStringList());
1190 
1191   KAcceleratorManager::manage(optionsWidget());
1192 }
1193 
1194 void AmazonFetcher::ConfigWidget::saveConfigHook(KConfigGroup& config_) {
1195   int n = m_siteCombo->currentData().toInt();
1196   config_.writeEntry("Site", n);
1197   QString s = m_accessEdit->text().trimmed();
1198   if(!s.isEmpty()) {
1199     config_.writeEntry("AccessKey", s);
1200   }
1201   s = m_secretKeyEdit->text().trimmed();
1202   if(!s.isEmpty()) {
1203     config_.writeEntry("SecretKey", s);
1204   }
1205   s = m_assocEdit->text().trimmed();
1206   if(!s.isEmpty()) {
1207     config_.writeEntry("AssocToken", s);
1208   }
1209   n = m_imageCombo->currentData().toInt();
1210   config_.writeEntry("Image Size", n);
1211 }
1212 
1213 QString AmazonFetcher::ConfigWidget::preferredName() const {
1214   return AmazonFetcher::siteData(m_siteCombo->currentData().toInt()).title;
1215 }
1216 
1217 void AmazonFetcher::ConfigWidget::slotSiteChanged() {
1218   emit signalName(preferredName());
1219 }