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

0001 /***************************************************************************
0002     Copyright (C) 2019-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 "colnectfetcher.h"
0026 #include "../collections/coincollection.h"
0027 #include "../collections/stampcollection.h"
0028 #include "../collections/comicbookcollection.h"
0029 #include "../collections/cardcollection.h"
0030 #include "../collections/gamecollection.h"
0031 #include "../images/imagefactory.h"
0032 #include "../gui/combobox.h"
0033 #include "../utils/guiproxy.h"
0034 #include "../entry.h"
0035 #include "../fieldformat.h"
0036 #include "../core/filehandler.h"
0037 #include "../tellico_debug.h"
0038 
0039 #include <KLocalizedString>
0040 #include <KConfigGroup>
0041 #include <KJob>
0042 #include <KJobUiDelegate>
0043 #include <KJobWidgets/KJobWidgets>
0044 #include <KIO/StoredTransferJob>
0045 
0046 #include <QLabel>
0047 #include <QFile>
0048 #include <QTextStream>
0049 #include <QGridLayout>
0050 #include <QTextCodec>
0051 #include <QJsonDocument>
0052 #include <QJsonArray>
0053 #include <QJsonValue>
0054 #include <QRegularExpression>
0055 #include <QStandardPaths>
0056 
0057 namespace {
0058   static const char* COLNECT_API_URL = "https://api.tellico-project.org/colnect";
0059 //  static const char* COLNECT_API_URL = "https://api.colnect.net";
0060   static const char* COLNECT_IMAGE_URL = "https://i.colnect.net";
0061   static const char* COLNECT_LINK_URL = "https://colnect.com";
0062 }
0063 
0064 using namespace Tellico;
0065 using Tellico::Fetch::ColnectFetcher;
0066 
0067 ColnectFetcher::ColnectFetcher(QObject* parent_)
0068     : Fetcher(parent_)
0069     , m_started(false)
0070     , m_locale(QStringLiteral("en"))
0071     , m_imageSize(LargeImage)
0072     , m_lastCollType(-1) {
0073 }
0074 
0075 ColnectFetcher::~ColnectFetcher() {
0076 }
0077 
0078 QString ColnectFetcher::source() const {
0079   return m_name.isEmpty() ? defaultName() : m_name;
0080 }
0081 
0082 QString ColnectFetcher::attribution() const {
0083   return QStringLiteral("Catalog information courtesy of Colnect, an online collectors community.");
0084 }
0085 
0086 bool ColnectFetcher::canSearch(Fetch::FetchKey k) const {
0087   return k == Title || k == Keyword;
0088 }
0089 
0090 bool ColnectFetcher::canFetch(int type) const {
0091   return type == Data::Collection::Coin
0092       || type == Data::Collection::Stamp
0093       || type == Data::Collection::Card
0094       || type == Data::Collection::ComicBook
0095       || type == Data::Collection::Game;
0096 }
0097 
0098 void ColnectFetcher::readConfigHook(const KConfigGroup& config_) {
0099   QString k = config_.readEntry("Locale", "en");
0100   if(!k.isEmpty()) {
0101     m_locale = k.toLower();
0102   }
0103   Q_ASSERT_X(m_locale.length() == 2, "ColnectFetcher::readConfigHook", "lang should be 2 char short iso");
0104   const int imageSize = config_.readEntry("Image Size", -1);
0105   if(imageSize > -1) {
0106     m_imageSize = static_cast<ImageSize>(imageSize);
0107   }
0108 }
0109 
0110 void ColnectFetcher::search() {
0111   m_started = true;
0112   m_year.clear();
0113 
0114   QUrl u(QString::fromLatin1(COLNECT_API_URL));
0115   // Colnect API calls are encoded as a path
0116   QString query(QLatin1Char('/') + m_locale);
0117 
0118   switch(collectionType()) {
0119     case Data::Collection::Coin:
0120       m_category = QStringLiteral("coins");
0121       break;
0122     case Data::Collection::Stamp:
0123       m_category = QStringLiteral("stamps");
0124       break;
0125     case Data::Collection::ComicBook:
0126       m_category = QStringLiteral("comics");
0127       break;
0128     case Data::Collection::Card:
0129       m_category = QStringLiteral("sports_cards");
0130       break;
0131     case Data::Collection::Game:
0132       m_category = QStringLiteral("video_games");
0133       break;
0134     default:
0135       myWarning() << "Colnect category type not available for" << collectionType();
0136       stop();
0137       return;
0138   }
0139 
0140   QString value = request().value();
0141   switch(request().key()) {
0142     case Title:
0143       {
0144         query += QStringLiteral("/list/cat/") + m_category;
0145         // pull out year, keep the regexp a little loose
0146         QRegularExpression yearRX(QStringLiteral("[0-9]{4}"));
0147         QRegularExpressionMatch match = yearRX.match(value);
0148         if(match.hasMatch()) {
0149           m_year = match.captured(0);
0150           if(collectionType() == Data::Collection::Coin) {
0151             query += QStringLiteral("/mint_year/");
0152           } else {
0153             query += QStringLiteral("/year/");
0154           }
0155           query += m_year;
0156           value = value.remove(yearRX);
0157         }
0158       }
0159       // everything left is for the item description
0160       query += QStringLiteral("/item_name/") + value.simplified();
0161       break;
0162 
0163     case Keyword:
0164       {
0165         query += QStringLiteral("/list/cat/") + m_category;
0166         // pull out year, keep the regexp a little loose
0167         QRegularExpression yearRX(QStringLiteral("[0-9]{4}"));
0168         QRegularExpressionMatch match = yearRX.match(value);
0169         if(match.hasMatch()) {
0170           m_year = match.captured(0);
0171           if(collectionType() == Data::Collection::Coin) {
0172             query += QStringLiteral("/mint_year/");
0173           } else {
0174             query += QStringLiteral("/year/");
0175           }
0176           query += m_year;
0177           value = value.remove(yearRX);
0178         }
0179       }
0180       // everything left is for the item description
0181       query += QStringLiteral("/description/") + value.simplified();
0182       break;
0183 
0184     case Raw:
0185       query += QStringLiteral("/item/cat/") + m_category + QStringLiteral("/id/") + value;
0186       break;
0187 
0188     default:
0189       myWarning() << source() << "- key not recognized:" << request().key();
0190       stop();
0191       return;
0192   }
0193 
0194   u.setPath(u.path() + query);
0195   myLog() << "Reading" << u.toDisplayString();
0196 
0197   m_job = KIO::storedGet(u, KIO::NoReload, KIO::HideProgressInfo);
0198   KJobWidgets::setWindow(m_job, GUI::Proxy::widget());
0199   connect(m_job.data(), &KJob::result, this, &ColnectFetcher::slotComplete);
0200 }
0201 
0202 void ColnectFetcher::stop() {
0203   if(!m_started) {
0204     return;
0205   }
0206   if(m_job) {
0207     m_job->kill();
0208     m_job = nullptr;
0209   }
0210   m_started = false;
0211   emit signalDone(this);
0212 }
0213 
0214 Tellico::Data::EntryPtr ColnectFetcher::fetchEntryHook(uint uid_) {
0215   Data::EntryPtr entry = m_entries.value(uid_);
0216   if(!entry) {
0217     myWarning() << "no entry in dict";
0218     return Data::EntryPtr();
0219   }
0220 
0221   // if there's a colnect-id in the entry, need to fetch all the data
0222   const QString id = entry->field(QStringLiteral("colnect-id"));
0223   if(!id.isEmpty()) {
0224     QUrl u(QString::fromLatin1(COLNECT_API_URL));
0225     QString query(QLatin1Char('/') + m_locale + QStringLiteral("/item/cat/")
0226                   + m_category + QStringLiteral("/id/") + id);
0227     u.setPath(u.path() + query);
0228     myLog() << "Reading" << u.toDisplayString();
0229 
0230     QPointer<KIO::StoredTransferJob> job = KIO::storedGet(u, KIO::NoReload, KIO::HideProgressInfo);
0231     KJobWidgets::setWindow(job, GUI::Proxy::widget());
0232     if(!job->exec()) {
0233       myDebug() << "Colnect item data:" << job->errorString() << u;
0234       return entry;
0235     }
0236     const QByteArray data = job->data();
0237     if(data.isEmpty()) {
0238       myDebug() << "no colnect item data for" << u;
0239       return entry;
0240     }
0241 #if 0
0242     myWarning() << "Remove item debug from colnectfetcher.cpp [colnectitemtest.json]";
0243     QFile file(QStringLiteral("/tmp/colnectitemtest.json"));
0244     if(file.open(QIODevice::WriteOnly)) {
0245       QTextStream t(&file);
0246       t.setCodec("UTF-8");
0247       t << data;
0248     }
0249     file.close();
0250 #endif
0251     QJsonParseError jsonError;
0252     QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError);
0253     Q_ASSERT_X(!doc.isNull(), "colnect", jsonError.errorString().toUtf8().constData());
0254     const QVariantList resultList = doc.array().toVariantList();
0255     Q_ASSERT_X(!resultList.isEmpty(), "colnect", "no item results");
0256     Q_ASSERT_X(static_cast<QMetaType::Type>(resultList.at(0).type()) == QMetaType::QString, "colnect",
0257                "Weird single item result, first value is not a string");
0258     populateEntry(entry, resultList);
0259   }
0260 
0261   // image might still be a URL only
0262   loadImage(entry, QStringLiteral("obverse"));
0263   loadImage(entry, QStringLiteral("reverse"));
0264   loadImage(entry, QStringLiteral("image")); // stamp image
0265   loadImage(entry, QStringLiteral("cover"));
0266   loadImage(entry, QStringLiteral("front"));
0267   loadImage(entry, QStringLiteral("back"));
0268 
0269   // don't want to include id
0270   entry->setField(QStringLiteral("colnect-id"), QString());
0271   return entry;
0272 }
0273 
0274 Tellico::Fetch::FetchRequest ColnectFetcher::updateRequest(Data::EntryPtr entry_) {
0275   const QString title = entry_->field(QStringLiteral("title"));
0276   if(!title.isEmpty()) {
0277     return FetchRequest(Keyword, title);
0278   }
0279   return FetchRequest();
0280 }
0281 
0282 void ColnectFetcher::slotComplete(KJob* job_) {
0283   KIO::StoredTransferJob* job = static_cast<KIO::StoredTransferJob*>(job_);
0284 
0285   if(job->error()) {
0286     job->uiDelegate()->showErrorMessage();
0287     stop();
0288     return;
0289   }
0290 
0291   const QByteArray data = job->data();
0292   if(data.isEmpty()) {
0293     myDebug() << "no data";
0294     stop();
0295     return;
0296   }
0297   // see bug 319662. If fetcher is canceled, job is killed
0298   // if the pointer is retained, it gets double-deleted
0299   m_job = nullptr;
0300 
0301 #if 0
0302   myWarning() << "Remove debug from colnectfetcher.cpp [colnecttest.json]";
0303   QFile f(QStringLiteral("/tmp/colnecttest.json"));
0304   if(f.open(QIODevice::WriteOnly)) {
0305     QTextStream t(&f);
0306     t.setCodec("UTF-8");
0307     t << data;
0308   }
0309   f.close();
0310 #endif
0311 
0312   QJsonParseError jsonError;
0313   QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError);
0314   if(doc.isNull()) {
0315     myDebug() << "null JSON document:" << jsonError.errorString();
0316     message(jsonError.errorString(), MessageHandler::Error);
0317     stop();
0318     return;
0319   }
0320   QVariantList resultList = doc.array().toVariantList();
0321   if(resultList.isEmpty()) {
0322     myLog() << "No results";
0323     stop();
0324     return;
0325   }
0326 
0327   m_hasMoreResults = false; // for now, no continued searches
0328 
0329   Data::CollPtr coll;
0330   switch(collectionType()) {
0331     case Data::Collection::Coin:
0332       coll = new Data::CoinCollection(true);
0333       break;
0334     case Data::Collection::Stamp:
0335       coll = new Data::StampCollection(true);
0336       break;
0337     case Data::Collection::ComicBook:
0338       coll = new Data::ComicBookCollection(true);
0339       break;
0340     case Data::Collection::Card:
0341       coll = new Data::CardCollection(true);
0342       break;
0343     case Data::Collection::Game:
0344       coll = new Data::GameCollection(true);
0345       break;
0346     default:
0347       myWarning() << "no collection pointer for type" << collectionType();
0348       break;
0349   }
0350   Q_ASSERT(coll);
0351   if(!coll) {
0352     stop();
0353     return;
0354   }
0355   // placeholder for colnect id, to be removed later
0356   Data::FieldPtr f1(new Data::Field(QStringLiteral("colnect-id"), QString()));
0357   coll->addField(f1);
0358 
0359   const QString series(QStringLiteral("series"));
0360   if(!coll->hasField(series) && optionalFields().contains(series)) {
0361     Data::FieldPtr field(new Data::Field(series, i18n("Series")));
0362     field->setCategory(i18n("General"));
0363     coll->addField(field);
0364   }
0365 
0366   const QString desc(QStringLiteral("description"));
0367   if(!coll->hasField(desc) && optionalFields().contains(desc)) {
0368     Data::FieldPtr field(new Data::Field(desc, i18n("Description"), Data::Field::Para));
0369     coll->addField(field);
0370   }
0371 
0372   // if the first item in the array is a string, probably a single item result, possibly from a Raw query
0373   if(!resultList.isEmpty() &&
0374      static_cast<QMetaType::Type>(resultList.at(0).type()) == QMetaType::QString) {
0375     Data::EntryPtr entry(new Data::Entry(coll));
0376     populateEntry(entry, resultList);
0377 
0378     FetchResult* r = new FetchResult(this, entry);
0379     m_entries.insert(r->uid, entry);
0380     emit signalResultFound(r);
0381 
0382     stop();
0383     return;
0384   }
0385 
0386   // here, we have multiple results to loop through
0387   myLog() << "Reading" << resultList.size() << "results";
0388   foreach(const QVariant& result, resultList) {
0389     // be sure to check that the fetcher has not been stopped
0390     // crashes can occur if not
0391     if(!m_started) {
0392       break;
0393     }
0394 
0395     Data::EntryPtr entry(new Data::Entry(coll));
0396     //list action - returns array of [item_id,series_id,producer_id,front_picture_id, back_picture_id,item_description,catalog_codes,item_name]
0397     // comics, title is field #7
0398     const QVariantList values = result.toJsonArray().toVariantList();
0399     entry->setField(QStringLiteral("colnect-id"), values.first().toString());
0400     if(optionalFields().contains(desc)) {
0401       entry->setField(desc, values.last().toString());
0402     }
0403     // since card collection use a dependent field for title, fake a description with the series field
0404     if(collectionType() == Data::Collection::Card) {
0405       entry->setField(QStringLiteral("series"), values.at(7).toString());
0406     } else {
0407       entry->setField(QStringLiteral("title"), values.at(7).toString());
0408     }
0409     if(collectionType() == Data::Collection::ComicBook) {
0410       entry->setField(QStringLiteral("pub_year"), m_year);
0411     } else {
0412       entry->setField(QStringLiteral("year"), m_year);
0413     }
0414 
0415     FetchResult* r = new FetchResult(this, entry);
0416     m_entries.insert(r->uid, entry);
0417     emit signalResultFound(r);
0418   }
0419 
0420   stop();
0421 }
0422 
0423 #define READ_AND_SET(NAME, FIELD) \
0424   { \
0425     const int idx = m_colnectFields.value(QStringLiteral(NAME), -1); \
0426     if(idx > -1) { \
0427       entry_->setField(QStringLiteral(FIELD), resultList_.at(idx).toString()); \
0428     } \
0429   }
0430 #define READ_AND_SET_OPTIONAL(NAME, FIELD) \
0431   { \
0432     const QString fieldName = QStringLiteral(FIELD); \
0433     const int idx = m_colnectFields.value(QStringLiteral(NAME), -1); \
0434     if(idx > -1 && optionalFields().contains(fieldName)) { \
0435       entry_->setField(fieldName, resultList_.at(idx).toString()); \
0436     } \
0437   }
0438 #define READ_AND_SET_IMAGE(NAME, FIELD) \
0439   { \
0440     const int idx = m_colnectFields.value(QStringLiteral(NAME), -1); \
0441     if(idx > -1) { \
0442       entry_->setField(QStringLiteral(FIELD), \
0443                        imageUrl(resultList_.at(0).toString(), resultList_.at(idx).toString())); \
0444     } \
0445   }
0446 #define READ_AND_SET_OPTIONAL_IMAGE(NAME, FIELD) \
0447   { \
0448     const QString fieldName = QStringLiteral(FIELD); \
0449     const int idx = m_colnectFields.value(QStringLiteral(NAME), -1); \
0450     if(idx > -1 && optionalFields().contains(fieldName)) { \
0451       entry_->setField(fieldName, \
0452                        imageUrl(resultList_.at(0).toString(), resultList_.at(idx).toString())); \
0453     } \
0454   }
0455 
0456 void ColnectFetcher::populateEntry(Data::EntryPtr entry_, const QVariantList& resultList_) {
0457   if(m_colnectFields.isEmpty() || m_lastCollType != collectionType()) {
0458     m_lastCollType = collectionType();
0459     readDataList();
0460     // set minimum size of list here (cards are 23)
0461     if(m_colnectFields.count() < 23) {
0462       myDebug() << "below minimum field count," << m_colnectFields.count();
0463       return;
0464     }
0465   }
0466   if(resultList_.count() != m_colnectFields.count()) {
0467     myDebug() << "field count mismatch! Got" << resultList_.count() << ", expected" << m_colnectFields.count();
0468     return;
0469   }
0470 
0471 #if 0
0472   auto i = m_colnectFields.constBegin();
0473   while(i != m_colnectFields.constEnd()) {
0474     if(!resultList_.at(i.value()).toString().isEmpty())
0475       myDebug() << i.key() << ": " << resultList_.at(i.value()).toString();
0476     ++i;
0477   }
0478 #endif
0479 
0480   READ_AND_SET_OPTIONAL("Series", "series");
0481 
0482   int idx = m_colnectFields.value(QStringLiteral("Description"), -1);
0483   static const QString desc(QStringLiteral("description"));
0484   if(idx > -1 && optionalFields().contains(desc)) {
0485     static const QString name(QStringLiteral("Name"));
0486     auto idxName = m_colnectFields.value(name, -1);
0487     QString s = resultList_.at(idx).toString().trimmed();
0488     // use the name as the description for stamps since the title includes it
0489     // put the description text into the comments
0490     if(collectionType() == Data::Collection::Stamp) {
0491       if(idxName > -1) {
0492         entry_->setField(desc, resultList_.at(idxName).toString());
0493       }
0494       entry_->setField(QStringLiteral("comments"), s);
0495     } else {
0496       // if description is empty, just use the name
0497       if(s.isEmpty() && idxName > -1) {
0498         entry_->setField(desc, resultList_.at(idxName).toString());
0499       } else {
0500         entry_->setField(desc, s);
0501       }
0502     }
0503   }
0504   switch(collectionType()) {
0505     case Data::Collection::Coin:
0506       populateCoinEntry(entry_, resultList_);
0507       break;
0508     case Data::Collection::Stamp:
0509       populateStampEntry(entry_, resultList_);
0510       break;
0511     case Data::Collection::ComicBook:
0512       populateComicEntry(entry_, resultList_);
0513       break;
0514     case Data::Collection::Card:
0515       populateCardEntry(entry_, resultList_);
0516       break;
0517     case Data::Collection::Game:
0518       populateGameEntry(entry_, resultList_);
0519       break;
0520   }
0521 
0522   static const QString colnect(QStringLiteral("colnect"));
0523   if(optionalFields().contains(colnect)) {
0524     if(!entry_->collection()->hasField(colnect)) {
0525       Data::FieldPtr field(new Data::Field(colnect, i18n("Colnect Link"), Data::Field::URL));
0526       field->setCategory(i18n("General"));
0527       entry_->collection()->addField(field);
0528     }
0529     QUrl link(QString::fromLatin1(COLNECT_LINK_URL));
0530     const QString path(QLatin1Char('/') + m_locale +
0531                        QLatin1Char('/') + m_category +
0532                        QLatin1Char('/') + m_category.chopped(1) +
0533                        QLatin1Char('/') + entry_->field(QStringLiteral("colnect-id")) +
0534                        QLatin1Char('-') + URLize(resultList_.at(0).toString()));
0535     link.setPath(link.path() + path);
0536     entry_->setField(colnect, link.url());
0537   }
0538 }
0539 
0540 void ColnectFetcher::populateCoinEntry(Data::EntryPtr entry_, const QVariantList& resultList_) {
0541   auto coll = entry_->collection();
0542   const QString mintage(QStringLiteral("mintage"));
0543   if(!coll->hasField(mintage) && optionalFields().contains(mintage)) {
0544     Data::FieldPtr field(new Data::Field(mintage, i18n("Mintage"), Data::Field::Number));
0545     field->setCategory(i18n("General"));
0546     coll->addField(field);
0547   }
0548 
0549   int idx = m_colnectFields.value(QStringLiteral("Issued on"), -1);
0550   // the year may have already been set in the query term
0551   if(m_year.isEmpty() && idx > -1) {
0552     entry_->setField(QStringLiteral("year"), resultList_.at(idx).toString().left(4));
0553   }
0554 
0555   idx = m_colnectFields.value(QStringLiteral("Currency"), -1);
0556   if(idx > -1) {
0557     entry_->setField(QStringLiteral("currency"), resultList_.at(idx).toString());
0558     idx = m_colnectFields.value(QStringLiteral("FaceValue"), -1);
0559     if(idx > -1) {
0560       // bad assumption, but go with it. First char is currency symbol
0561       QString currency = entry_->field(QStringLiteral("currency"));
0562       if(!currency.isEmpty()) currency.truncate(1);
0563       const double value = resultList_.at(idx).toDouble();
0564       // don't assume the value is in system currency
0565       entry_->setField(QStringLiteral("denomination"),
0566                        QLocale::system().toCurrencyString(value, currency));
0567     }
0568   }
0569 
0570   READ_AND_SET("Country", "country");
0571   READ_AND_SET_OPTIONAL("Known mintage", "mintage");
0572   READ_AND_SET_OPTIONAL_IMAGE("FrontPicture", "obverse");
0573   READ_AND_SET_OPTIONAL_IMAGE("BackPicture", "reverse");
0574 }
0575 
0576 void ColnectFetcher::populateStampEntry(Data::EntryPtr entry_, const QVariantList& resultList_) {
0577   auto coll = entry_->collection();
0578   const QString stanleygibbons(QStringLiteral("stanley-gibbons"));
0579   if(!coll->hasField(stanleygibbons) && optionalFields().contains(stanleygibbons)) {
0580     Data::FieldPtr field(new Data::Field(stanleygibbons, i18nc("Stanley Gibbons stamp catalog code", "Stanley Gibbons")));
0581     field->setCategory(i18n("General"));
0582     coll->addField(field);
0583   }
0584 
0585   const QString michel(QStringLiteral("michel"));
0586   if(!coll->hasField(michel) && optionalFields().contains(michel)) {
0587     Data::FieldPtr field(new Data::Field(michel, i18nc("Michel stamp catalog code", "Michel")));
0588     field->setCategory(i18n("General"));
0589     coll->addField(field);
0590   }
0591 
0592   int idx = m_colnectFields.value(QStringLiteral("Issued on"), -1);
0593   // the year may have already been set in the query term
0594   if(m_year.isEmpty() && idx > -1) {
0595     entry_->setField(QStringLiteral("year"), resultList_.at(idx).toString().left(4));
0596   }
0597 
0598   idx = m_colnectFields.value(QStringLiteral("Currency"), -1);
0599   if(idx > -1) {
0600     entry_->setField(QStringLiteral("currency"), resultList_.at(idx).toString());
0601     idx = m_colnectFields.value(QStringLiteral("FaceValue"), -1);
0602     if(idx > -1) {
0603       // bad assumption, but go with it. First char is currency symbol
0604       QString currency = entry_->field(QStringLiteral("currency"));
0605       if(!currency.isEmpty()) currency.truncate(1);
0606       const double value = resultList_.at(idx).toDouble();
0607       // don't assume the value is in system currency
0608       entry_->setField(QStringLiteral("denomination"),
0609                        QLocale::system().toCurrencyString(value, currency));
0610     }
0611   }
0612 
0613   READ_AND_SET("Gum", "gummed");
0614   READ_AND_SET("Country", "country");
0615 
0616   idx = m_colnectFields.value(QStringLiteral("Colors"), -1);
0617   if(idx > -1) {
0618     const int colorId = resultList_.at(idx).toInt();
0619     if(colorId > 0) {
0620       if(!m_itemNames.contains("colors")) {
0621         readItemNames("colors");
0622       }
0623       entry_->setField(QStringLiteral("color"), m_itemNames.value("colors").value(colorId));
0624     }
0625   }
0626 
0627   // catalog codes
0628   idx = m_colnectFields.value(QStringLiteral("Catalog Codes"), -1);
0629   if(idx > -1) {
0630     // split by comma, look for prefix
0631     QStringList codes = resultList_.at(idx).toString().split(QLatin1Char(','));
0632     Q_FOREACH(const QString& code, codes) {
0633       const QString prefix = code.section(QLatin1Char(':'), 0, 0).trimmed();
0634       const QString value = code.section(QLatin1Char(':'), 1, 1).trimmed();
0635       // 'SG' for Stanley Gibbons, 'Sc' for Scott, 'Mi' for Michel and 'Yv' for Yvert & Tellier.
0636       if(prefix == QLatin1String("Sc")) {
0637         entry_->setField(QStringLiteral("scott"), value);
0638       } else if(prefix == QLatin1String("Sg") && optionalFields().contains(QStringLiteral("stanley-gibbons"))) {
0639         entry_->setField(QStringLiteral("stanley-gibbons"), value);
0640       } else if(prefix == QLatin1String("Mi") && optionalFields().contains(QStringLiteral("michel"))) {
0641         entry_->setField(QStringLiteral("michel"), value);
0642       }
0643     }
0644   }
0645 
0646   READ_AND_SET_IMAGE("FrontPicture", "image");
0647 }
0648 
0649 void ColnectFetcher::populateComicEntry(Data::EntryPtr entry_, const QVariantList& resultList_) {
0650   READ_AND_SET("Name", "title");
0651 
0652   int idx = m_colnectFields.value(QStringLiteral("Issued on"), -1);
0653   // the year may have already been set in the query term
0654   if(m_year.isEmpty() && idx > -1) {
0655     entry_->setField(QStringLiteral("pub_year"), resultList_.at(idx).toString().left(4));
0656   }
0657 
0658   static const QRegularExpression spaceCommaRx(QLatin1String("\\s*,\\s*"));
0659   idx = m_colnectFields.value(QStringLiteral("Writer"), -1);
0660   if(idx > -1) {
0661     QString writer = resultList_.at(idx).toString();
0662     writer.replace(spaceCommaRx, FieldFormat::delimiterString());
0663     entry_->setField(QStringLiteral("writer"), writer);
0664   }
0665 
0666   idx = m_colnectFields.value(QStringLiteral("CoverArtist"), -1);
0667   if(idx > -1) {
0668     QString artist = resultList_.at(idx).toString();
0669     artist.replace(spaceCommaRx, FieldFormat::delimiterString());
0670     entry_->setField(QStringLiteral("artist"), artist);
0671   }
0672 
0673   READ_AND_SET("Publisher", "publisher");
0674   READ_AND_SET("IssuingNumber", "issue");
0675   READ_AND_SET("Edition", "edition");
0676   READ_AND_SET("Genre", "genre");
0677   READ_AND_SET_IMAGE("FrontPicture", "cover");
0678 }
0679 
0680 void ColnectFetcher::populateCardEntry(Data::EntryPtr entry_, const QVariantList& resultList_) {
0681   READ_AND_SET("Type", "series");
0682   READ_AND_SET("Brand", "brand");
0683   READ_AND_SET("Number", "number");
0684   READ_AND_SET("League", "type");
0685 
0686   int idx = m_colnectFields.value(QStringLiteral("Issued on"), -1);
0687   // the year may have already been set in the query term
0688   if(m_year.isEmpty() && idx > -1) {
0689     entry_->setField(QStringLiteral("year"), resultList_.at(idx).toString().left(4));
0690   }
0691 
0692   idx = m_colnectFields.value(QStringLiteral("ZscCardPlayer"), -1);
0693   if(idx > -1) {
0694     const int playerId = resultList_.at(idx).toInt();
0695     if(playerId > 0) {
0696       if(!m_itemNames.contains("players")) {
0697         readItemNames("players");
0698       }
0699       entry_->setField(QStringLiteral("player"), m_itemNames.value("players").value(playerId));
0700     }
0701   }
0702 
0703   idx = m_colnectFields.value(QStringLiteral("ZscCardTeam"), -1);
0704   if(idx > -1) {
0705     const int teamId = resultList_.at(idx).toInt();
0706     if(teamId > 0) {
0707       if(!m_itemNames.contains("teams")) {
0708         readItemNames("teams");
0709       }
0710       entry_->setField(QStringLiteral("team"), m_itemNames.value("teams").value(teamId));
0711     }
0712   }
0713 
0714   READ_AND_SET_IMAGE("FrontPicture", "front");
0715   READ_AND_SET_IMAGE("BackPicture", "back");
0716 }
0717 
0718 void ColnectFetcher::populateGameEntry(Data::EntryPtr entry_, const QVariantList& resultList_) {
0719   auto coll = entry_->collection();
0720   const QString pegi(QStringLiteral("pegi"));
0721   if(!coll->hasField(pegi) && optionalFields().contains(pegi)) {
0722     coll->addField(Data::Field::createDefaultField(Data::Field::PegiField));
0723   }
0724 
0725   READ_AND_SET("Name", "title");
0726   READ_AND_SET("Publisher", "publisher");
0727   READ_AND_SET("Description", "description");
0728   READ_AND_SET("Genre", "genre");
0729   READ_AND_SET_IMAGE("FrontPicture", "cover");
0730 
0731   int idx = m_colnectFields.value(QStringLiteral("Console"), -1);
0732   if(idx > -1) {
0733     entry_->setField(QStringLiteral("platform"),
0734                      Data::GameCollection::normalizePlatform(resultList_.at(idx).toString()));
0735   }
0736 
0737   idx = m_colnectFields.value(QStringLiteral("Issued on"), -1);
0738   // the year may have already been set in the query term
0739   if(m_year.isEmpty() && idx > -1) {
0740     entry_->setField(QStringLiteral("year"), resultList_.at(idx).toString().left(4));
0741   }
0742 
0743   idx = m_colnectFields.value(QStringLiteral("Rating"), -1);
0744   if(idx > -1) {
0745     // can have both esrb and pegi rating
0746     QStringList ratings = resultList_.at(idx).toString().split(QLatin1String("/"));
0747     Q_FOREACH(QString rating, ratings) {
0748       rating = rating.simplified();
0749       if(rating.startsWith(QLatin1String("ESRB"))) {
0750         rating = rating.mid(5);
0751         Data::GameCollection::EsrbRating esrb = Data::GameCollection::UnknownEsrb;
0752         if(rating == QLatin1String("U"))         esrb = Data::GameCollection::Unrated;
0753         else if(rating == QLatin1String("T"))    esrb = Data::GameCollection::Teen;
0754         else if(rating == QLatin1String("E"))    esrb = Data::GameCollection::Everyone;
0755         else if(rating == QLatin1String("E10+")) esrb = Data::GameCollection::Everyone10;
0756         else if(rating == QLatin1String("EC"))   esrb = Data::GameCollection::EarlyChildhood;
0757         else if(rating == QLatin1String("A"))    esrb = Data::GameCollection::Adults;
0758         else if(rating == QLatin1String("M"))    esrb = Data::GameCollection::Mature;
0759         else if(rating == QLatin1String("RP"))   esrb = Data::GameCollection::Pending;
0760         if(rating != Data::GameCollection::UnknownEsrb) {
0761           entry_->setField(QStringLiteral("certification"), Data::GameCollection::esrbRating(esrb));
0762         }
0763       } else if(rating.startsWith(QLatin1String("PEGI")) && optionalFields().contains(QStringLiteral("pegi"))) {
0764         static const QRegularExpression pegiRx(QStringLiteral("^PEGI \\d\\d?"));
0765         auto pegiMatch = pegiRx.match(rating);
0766         if(pegiMatch.hasMatch()) {
0767           entry_->setField(QStringLiteral("pegi"), pegiMatch.captured(0));
0768         }
0769       }
0770     }
0771   }
0772 }
0773 
0774 #undef READ_AND_SET
0775 #undef READ_AND_SET_OPTIONAL
0776 #undef READ_AND_SET_IMAGE
0777 #undef READ_AND_SET_OPTIONAL_IMAGE
0778 
0779 void ColnectFetcher::loadImage(Data::EntryPtr entry_, const QString& fieldName_) {
0780   const QString image = entry_->field(fieldName_);
0781   if(image.contains(QLatin1Char('/'))) {
0782     const QString id = ImageFactory::addImage(QUrl::fromUserInput(image), true /* quiet */);
0783     if(id.isEmpty()) {
0784       myLog() << "Failed to load" << image;
0785       message(i18n("The cover image could not be loaded."), MessageHandler::Warning);
0786     }
0787     // empty image ID is ok
0788     entry_->setField(fieldName_, id);
0789   }
0790 }
0791 
0792 Tellico::Fetch::ConfigWidget* ColnectFetcher::configWidget(QWidget* parent_) const {
0793   return new ColnectFetcher::ConfigWidget(parent_, this);
0794 }
0795 
0796 QString ColnectFetcher::defaultName() {
0797   return QStringLiteral("Colnect"); // no translation
0798 }
0799 
0800 QString ColnectFetcher::defaultIcon() {
0801   return favIcon("https://colnect.com");
0802 }
0803 
0804 Tellico::StringHash ColnectFetcher::allOptionalFields() {
0805   StringHash hash;
0806   hash[QStringLiteral("colnect")] = i18n("Colnect Link");
0807   // treat images as optional since Colnect doesn't break out different images for each year
0808   hash[QStringLiteral("obverse")] = i18n("Obverse");
0809   hash[QStringLiteral("reverse")] = i18n("Reverse");
0810   hash[QStringLiteral("series")] = i18n("Series");
0811   /* TRANSLATORS: Mintage refers to the number of coins minted */
0812   hash[QStringLiteral("mintage")] = i18n("Mintage");
0813   hash[QStringLiteral("description")] = i18n("Description");
0814   hash[QStringLiteral("stanley-gibbons")] = i18nc("Stanley Gibbons stamp catalog code", "Stanley Gibbons");
0815   hash[QStringLiteral("michel")] = i18nc("Michel stamp catalog code", "Michel");
0816   hash[QStringLiteral("pegi")] = i18n("PEGI Rating");
0817   return hash;
0818 }
0819 
0820 // Colnect specific method of turning name text into a slug
0821 //  $str = html_entity_decode($str, ENT_QUOTES, 'UTF-8');
0822 //  $str = preg_replace('/&[^;]+;/', '_', $str); # change HTML elements to underscore
0823 //  $str = str_replace(array('.', '"', '>', '<', '\\', ':', '/', '?', '#', '[', ']', '@', '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '='), '', $str);
0824 //  $str = preg_replace('/[\s_]+/', '_', $str); # any space sequence becomes a single underscore
0825 //  $str = trim($str, '_'); # trim underscores
0826 QString ColnectFetcher::URLize(const QString& name_) {
0827   QString slug = name_;
0828   static const QString underscore(QStringLiteral("_"));
0829   static const QRegularExpression htmlElements(QStringLiteral("&[^;]+;"));
0830   static const QRegularExpression toRemove(QStringLiteral("[.\"><\\:/?#\\[\\]@!$&'()*+,;=]"));
0831   static const QRegularExpression spaces(QStringLiteral("\\s"));
0832   slug.replace(htmlElements, underscore);
0833   slug.remove(toRemove);
0834   slug.replace(spaces, underscore);
0835   while(slug.startsWith(underscore)) slug = slug.mid(1);
0836   while(slug.endsWith(underscore)) slug.chop(1);
0837   return slug;
0838 }
0839 
0840 QString ColnectFetcher::imageUrl(const QString& name_, const QString& id_) {
0841   if(m_imageSize == NoImage) return QString();
0842   const QString nameSlug = URLize(name_);
0843   const int id = id_.toInt();
0844   QUrl u(QString::fromLatin1(COLNECT_IMAGE_URL));
0845   // uses 't' for thumbnail, use 'f' for full-size
0846   u.setPath(QString::fromLatin1("/%1/%2/%3/%4.jpg")
0847                            .arg(m_imageSize == SmallImage ? QLatin1Char('t') : QLatin1Char('f'))
0848                            .arg(id / 1000)
0849                            .arg(id % 1000, 3, 10, QLatin1Char('0'))
0850                            .arg(nameSlug));
0851 //  myDebug() << "Image url:" << u;
0852   return u.toString();
0853 }
0854 
0855 void ColnectFetcher::readDataList() {
0856   QUrl u(QString::fromLatin1(COLNECT_API_URL));
0857   // Colnect API calls are encoded as a path
0858   QString query(QLatin1Char('/') + m_locale + QStringLiteral("/fields/cat/") + m_category + QLatin1Char('/'));
0859   u.setPath(u.path() + query);
0860   myLog() << "Reading Colnect fields from" << u.toDisplayString();
0861 
0862   const QByteArray data = FileHandler::readDataFile(u, true);
0863   QJsonDocument doc = QJsonDocument::fromJson(data);
0864   if(doc.isNull()) {
0865     myDebug() << "null JSON document in colnect fields";
0866     return;
0867   }
0868   QVariantList resultList = doc.array().toVariantList();
0869   if(resultList.isEmpty()) {
0870     myDebug() << "no colnect field results";
0871     return;
0872   }
0873   m_colnectFields.clear();
0874   for(int i = 0; i < resultList.size(); ++i) {
0875     m_colnectFields.insert(resultList.at(i).toString(), i);
0876   }
0877 //  myDebug() << "Colnect fields:" << m_colnectFields;
0878 }
0879 
0880 void ColnectFetcher::readItemNames(const QByteArray& item_) {
0881   QUrl u(QString::fromLatin1(COLNECT_API_URL));
0882   // Colnect API calls are encoded as a path
0883   QString query(QLatin1Char('/') + m_locale + QLatin1Char('/') + QLatin1String(item_) + QStringLiteral("/cat/") + m_category + QLatin1Char('/'));
0884   u.setPath(u.path() + query);
0885   myLog() << "Reading item names from" << u.toDisplayString();
0886 
0887   const QByteArray data = FileHandler::readDataFile(u, true);
0888   QJsonDocument doc = QJsonDocument::fromJson(data);
0889   if(doc.isNull()) {
0890     myDebug() << "null JSON document in colnect results";
0891     return;
0892   }
0893   QJsonArray resultList = doc.array();
0894   if(resultList.isEmpty()) {
0895     myDebug() << "no item results";
0896     return;
0897   }
0898   QHash<int, QString> itemNames;
0899   for(int i = 0; i < resultList.size(); ++i) {
0900     // an array of arrays, first value is id, second is name
0901     const QJsonArray values = resultList.at(i).toArray();
0902     if(values.size() > 2) {
0903       itemNames.insert(values.at(0).toInt(), values.at(1).toString());
0904     }
0905   }
0906   m_itemNames.insert(item_, itemNames);
0907 }
0908 
0909 ColnectFetcher::ConfigWidget::ConfigWidget(QWidget* parent_, const ColnectFetcher* fetcher_)
0910     : Fetch::ConfigWidget(parent_) {
0911   QGridLayout* l = new QGridLayout(optionsWidget());
0912   l->setSpacing(4);
0913   l->setColumnStretch(1, 10);
0914 
0915   int row = -1;
0916 
0917   QLabel* label = new QLabel(i18n("Language: "), optionsWidget());
0918   l->addWidget(label, ++row, 0);
0919   m_langCombo = new GUI::ComboBox(optionsWidget());
0920 
0921 #define LANG_ITEM(NAME, CY, ISO) \
0922   m_langCombo->addItem(QIcon(QStandardPaths::locate(QStandardPaths::GenericDataLocation,                       \
0923                                                     QStringLiteral("kf5/locale/countries/" CY "/flag.png"))), \
0924                        i18nc("Language", NAME),                                                                \
0925                        QLatin1String(ISO));
0926   LANG_ITEM("English", "us", "en");
0927   LANG_ITEM("French",  "fr", "fr");
0928   LANG_ITEM("German",  "de", "de");
0929   LANG_ITEM("Spanish", "es", "es");
0930 #undef LANG_ITEM
0931 
0932   // instead of trying to include all possible languages offered by Colnect
0933   // allow the user to enter it
0934   m_langCombo->setEditable(true);
0935   QRegularExpression rx(QLatin1String("\\w\\w")); // only 2 characters
0936   QRegularExpressionValidator* val = new QRegularExpressionValidator(rx, this);
0937   m_langCombo->setValidator(val);
0938 
0939   void (GUI::ComboBox::* activatedInt)(int) = &GUI::ComboBox::activated;
0940   connect(m_langCombo, activatedInt, this, &ConfigWidget::slotSetModified);
0941   connect(m_langCombo, activatedInt, this, &ConfigWidget::slotLangChanged);
0942   l->addWidget(m_langCombo, row, 1);
0943   label->setBuddy(m_langCombo);
0944 
0945   label = new QLabel(i18n("&Image size: "), optionsWidget());
0946   l->addWidget(label, ++row, 0);
0947   m_imageCombo = new GUI::ComboBox(optionsWidget());
0948   m_imageCombo->addItem(i18n("No Image"), NoImage);
0949   m_imageCombo->addItem(i18n("Small Image"), SmallImage);
0950   m_imageCombo->addItem(i18n("Large Image"), LargeImage);
0951   connect(m_imageCombo, activatedInt, this, &ConfigWidget::slotSetModified);
0952   l->addWidget(m_imageCombo, row, 1);
0953   label->setBuddy(m_imageCombo);
0954 
0955   l->setRowStretch(++row, 10);
0956 
0957   // now add additional fields widget
0958   addFieldsWidget(ColnectFetcher::allOptionalFields(), fetcher_ ? fetcher_->optionalFields() : QStringList());
0959 
0960   if(fetcher_) {
0961     bool success = m_langCombo->setCurrentData(fetcher_->m_locale);
0962     // a user-entered iso code might not be in the data list, insert it if not
0963     if(!success) {
0964       m_langCombo->addItem(fetcher_->m_locale, fetcher_->m_locale);
0965       m_langCombo->setCurrentIndex(m_langCombo->count()-1);
0966     }
0967     m_imageCombo->setCurrentData(fetcher_->m_imageSize);
0968   }
0969 }
0970 
0971 void ColnectFetcher::ConfigWidget::saveConfigHook(KConfigGroup& config_) {
0972   QString lang = m_langCombo->currentData().toString();
0973   if(lang.isEmpty()) {
0974     // might be user-entered
0975     lang = m_langCombo->currentText();
0976   }
0977   config_.writeEntry("Locale", lang);
0978 
0979   const int n = m_imageCombo->currentData().toInt();
0980   config_.writeEntry("Image Size", n);
0981 }
0982 
0983 QString ColnectFetcher::ConfigWidget::preferredName() const {
0984   return QString::fromLatin1("Colnect (%1)").arg(m_langCombo->currentText());
0985 }
0986 
0987 void ColnectFetcher::ConfigWidget::slotLangChanged() {
0988   emit signalName(preferredName());
0989 }