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

0001 /***************************************************************************
0002     Copyright (C) 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 "numistafetcher.h"
0026 #include "../collections/coincollection.h"
0027 #include "../entry.h"
0028 #include "../images/imagefactory.h"
0029 #include "../gui/combobox.h"
0030 #include "../utils/guiproxy.h"
0031 #include "../utils/string_utils.h"
0032 #include "../tellico_debug.h"
0033 
0034 #include <KLocalizedString>
0035 #include <KIO/Job>
0036 #include <KJobUiDelegate>
0037 #include <KJobWidgets/KJobWidgets>
0038 #include <KConfigGroup>
0039 
0040 #include <QLabel>
0041 #include <QLineEdit>
0042 #include <QFile>
0043 #include <QTextStream>
0044 #include <QGridLayout>
0045 #include <QUrlQuery>
0046 #include <QJsonDocument>
0047 #include <QJsonArray>
0048 #include <QJsonObject>
0049 #include <QStandardPaths>
0050 
0051 namespace {
0052   static const int NUMISTA_MAX_RETURNS_TOTAL = 20;
0053   static const char* NUMISTA_API_URL = "https://api.numista.com/api/v1";
0054   static const char* NUMISTA_MAGIC_TOKEN = "2e19b8f32c5e8fbd96aeb2c0590d70458ef81d5b0657b1f6741685e1f9cf7a0983d7d0e0a2c69bcca7cfb4c08fde1c5a562e083e2d44a492a5e4b9c3d2a42a7c536a99f8511bfdbca9fb6d29f587fbbf";
0055 }
0056 
0057 using namespace Tellico;
0058 using Tellico::Fetch::NumistaFetcher;
0059 
0060 NumistaFetcher::NumistaFetcher(QObject* parent_)
0061     : Fetcher(parent_)
0062     , m_limit(NUMISTA_MAX_RETURNS_TOTAL)
0063     , m_total(-1)
0064     , m_page(1)
0065     , m_job(nullptr)
0066     , m_locale(QStringLiteral("en"))
0067     , m_started(false) {
0068 }
0069 
0070 NumistaFetcher::~NumistaFetcher() {
0071 }
0072 
0073 QString NumistaFetcher::source() const {
0074   return m_name.isEmpty() ? defaultName() : m_name;
0075 }
0076 
0077 bool NumistaFetcher::canFetch(int type) const {
0078   return type == Data::Collection::Coin;
0079 }
0080 
0081 void NumistaFetcher::readConfigHook(const KConfigGroup& config_) {
0082   QString k = config_.readEntry("API Key");
0083   if(!k.isEmpty()) {
0084     m_apiKey = k;
0085   }
0086   k = config_.readEntry("Locale", "en");
0087   if(!k.isEmpty()) {
0088     m_locale = k.toLower();
0089   }
0090 }
0091 
0092 void NumistaFetcher::setLimit(int limit_) {
0093   m_limit = qBound(1, limit_, NUMISTA_MAX_RETURNS_TOTAL);
0094 }
0095 
0096 void NumistaFetcher::search() {
0097   m_started = true;
0098   m_total = -1;
0099   m_page = 1;
0100   m_year.clear();
0101   doSearch();
0102 }
0103 
0104 void NumistaFetcher::continueSearch() {
0105   m_started = true;
0106   doSearch();
0107 }
0108 
0109 void NumistaFetcher::doSearch() {
0110   QUrl u(QString::fromLatin1(NUMISTA_API_URL));
0111   // all searches are for coins
0112   u.setPath(u.path() + QStringLiteral("/coins"));
0113 
0114   if(m_apiKey.isEmpty()) {
0115     m_apiKey = Tellico::reverseObfuscate(NUMISTA_MAGIC_TOKEN);
0116   }
0117 
0118   // pull out year, keep the regexp a little loose
0119   QRegularExpression yearRX(QStringLiteral("[0-9]{4}"));
0120   QRegularExpressionMatch match = yearRX.match(request().value());
0121   if(match.hasMatch()) {
0122     m_year = match.captured(0);
0123   }
0124 
0125   QString queryString;
0126   switch(request().key()) {
0127     case Keyword:
0128       queryString = request().value();
0129       break;
0130 
0131     default:
0132       myWarning() << "key not recognized: " << request().key();
0133       stop();
0134       return;
0135   }
0136   QUrlQuery q;
0137   q.addQueryItem(QStringLiteral("q"), queryString);
0138   q.addQueryItem(QStringLiteral("count"), QString::number(m_limit));
0139   q.addQueryItem(QStringLiteral("page"), QString::number(m_page));
0140   q.addQueryItem(QStringLiteral("lang"), m_locale);
0141   u.setQuery(q);
0142 //  myDebug() << "url: " << u.url();
0143 
0144   m_job = KIO::storedGet(u, KIO::NoReload, KIO::HideProgressInfo);
0145   m_job->addMetaData(QStringLiteral("customHTTPHeader"), QStringLiteral("Numista-API-Key: ") + m_apiKey);
0146   KJobWidgets::setWindow(m_job, GUI::Proxy::widget());
0147   connect(m_job.data(), &KJob::result,
0148           this, &NumistaFetcher::slotComplete);
0149 }
0150 
0151 void NumistaFetcher::stop() {
0152   if(!m_started) {
0153     return;
0154   }
0155   if(m_job) {
0156     m_job->kill();
0157     m_job = nullptr;
0158   }
0159   m_started = false;
0160   emit signalDone(this);
0161 }
0162 
0163 void NumistaFetcher::slotComplete(KJob* ) {
0164   if(m_job->error()) {
0165     m_job->uiDelegate()->showErrorMessage();
0166     stop();
0167     return;
0168   }
0169 
0170   QByteArray data = m_job->data();
0171   if(data.isEmpty()) {
0172     myDebug() << "no data";
0173     stop();
0174     return;
0175   }
0176   // see bug 319662. If fetcher is cancelled, job is killed
0177   // if the pointer is retained, it gets double-deleted
0178   m_job = nullptr;
0179 
0180 #if 0
0181   myWarning() << "Remove debug from numistafetcher.cpp";
0182   QFile f(QStringLiteral("/tmp/test.json"));
0183   if(f.open(QIODevice::WriteOnly)) {
0184     QTextStream t(&f);
0185     t.setCodec("UTF-8");
0186     t << data;
0187   }
0188   f.close();
0189 #endif
0190 
0191   QJsonParseError jsonError;
0192   QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError);
0193   if(doc.isNull()) {
0194     myDebug() << "null JSON document:" << jsonError.errorString();
0195     message(jsonError.errorString(), MessageHandler::Error);
0196     stop();
0197     return;
0198   }
0199   QJsonObject obj = doc.object();
0200 
0201   // check for error
0202   if(obj.contains(QStringLiteral("error"))) {
0203     const QString msg = obj.value(QStringLiteral("error")).toString();
0204     message(msg, MessageHandler::Error);
0205     myDebug() << "NunmistaFetcher -" << msg;
0206     stop();
0207     return;
0208   }
0209 
0210   m_total = obj.value(QLatin1String("count")).toString().toInt();
0211 
0212   int count = 0;
0213   QJsonArray results = obj.value(QLatin1String("coins")).toArray();
0214   for(QJsonArray::const_iterator i = results.constBegin(); i != results.constEnd(); ++i) {
0215     if(count >= m_limit) {
0216       break;
0217     }
0218     QJsonObject result = (*i).toObject();
0219 
0220     QString desc = result.value(QLatin1String("issuer")).toObject()
0221                          .value(QLatin1String("name")).toString();
0222     const QString minYear = result.value(QLatin1String("minYear")).toString();
0223     if(!minYear.isEmpty()) {
0224       desc += QLatin1Char('/') + minYear + QLatin1Char('-') + result.value(QLatin1String("maxYear")).toString();
0225     }
0226     QString title = result.value(QLatin1String("title")).toString();
0227     // some results include &quot;
0228     title.replace(QLatin1String("&quot;"), QLatin1String("\""));
0229     FetchResult* r = new FetchResult(this, title, desc);
0230     m_matches.insert(r->uid, result.value(QLatin1String("id")).toInt());
0231     emit signalResultFound(r);
0232     ++count;
0233   }
0234 
0235   stop(); // required
0236 }
0237 
0238 Tellico::Data::EntryPtr NumistaFetcher::fetchEntryHook(uint uid_) {
0239   Data::EntryPtr entry = m_entries.value(uid_);
0240   if(entry) {
0241     return entry;
0242   }
0243 
0244   if(!m_matches.contains(uid_)) {
0245     myWarning() << "no matching coin id";
0246     return Data::EntryPtr();
0247   }
0248 
0249   QUrl url(QString::fromLatin1(NUMISTA_API_URL));
0250   url.setPath(url.path() + QLatin1String("/coins/") + QString::number(m_matches[uid_]));
0251 //  myDebug() << url.url();
0252   QPointer<KIO::StoredTransferJob> job = KIO::storedGet(url, KIO::NoReload, KIO::HideProgressInfo);
0253   job->addMetaData(QStringLiteral("customHTTPHeader"), QStringLiteral("Numista-API-Key: ") + m_apiKey);
0254   KJobWidgets::setWindow(job, GUI::Proxy::widget());
0255   if(!job->exec()) {
0256     myDebug() << job->errorString() << url;
0257     return Data::EntryPtr();
0258   }
0259   const QByteArray data = job->data();
0260   if(data.isEmpty()) {
0261     myDebug() << "no data for" << url;
0262     return Data::EntryPtr();
0263   }
0264 #if 0
0265   myWarning() << "Remove debug2 from numistafetcher.cpp";
0266   QFile f(QStringLiteral("/tmp/test2-numista.json"));
0267   if(f.open(QIODevice::WriteOnly)) {
0268     QTextStream t(&f);
0269     t.setCodec("UTF-8");
0270     t << data;
0271   }
0272   f.close();
0273 #endif
0274 
0275   entry = parseEntry(data);
0276   if(!entry) {
0277     myDebug() << "No discernible entry data";
0278     return Data::EntryPtr();
0279   }
0280 
0281   QString image = entry->field(QStringLiteral("obverse"));
0282   if(!image.isEmpty() && optionalFields().contains(QStringLiteral("obverse"))) {
0283     const QString id = ImageFactory::addImage(QUrl::fromUserInput(image), true /* quiet */);
0284     if(id.isEmpty()) {
0285       message(i18n("The cover image could not be loaded."), MessageHandler::Warning);
0286     }
0287     entry->setField(QStringLiteral("obverse"), id);
0288   }
0289   image = entry->field(QStringLiteral("reverse"));
0290   if(!image.isEmpty() && optionalFields().contains(QStringLiteral("reverse"))) {
0291     const QString id = ImageFactory::addImage(QUrl::fromUserInput(image), true /* quiet */);
0292     if(id.isEmpty()) {
0293       message(i18n("The cover image could not be loaded."), MessageHandler::Warning);
0294     }
0295     entry->setField(QStringLiteral("reverse"), id);
0296   }
0297 
0298   return entry;
0299 }
0300 
0301 Tellico::Data::EntryPtr NumistaFetcher::parseEntry(const QByteArray& data_) {
0302   QJsonParseError parseError;
0303   QJsonDocument doc = QJsonDocument::fromJson(data_, &parseError);
0304   if(doc.isNull()) {
0305     myDebug() << "Bad json data:" << parseError.errorString();
0306     return Data::EntryPtr();
0307   }
0308 
0309   Data::CollPtr coll(new Data::CoinCollection(true));
0310   Data::EntryPtr entry(new Data::Entry(coll));
0311   coll->addEntries(entry);
0312 
0313   QVariantMap objectMap = doc.object().toVariantMap();
0314   // for type, try to tease out from title
0315   // use ruler name as a possible fallback
0316   QRegularExpression titleQuote(QStringLiteral("&quot;(.+)&quot;"));
0317   QRegularExpressionMatch quoteMatch = titleQuote.match(mapValue(objectMap, "title"));
0318   if(quoteMatch.hasMatch()) {
0319     entry->setField(QStringLiteral("type"), quoteMatch.captured(1));
0320   } else {
0321     entry->setField(QStringLiteral("type"), mapValue(objectMap, "ruler", "name"));
0322   }
0323 
0324   entry->setField(QStringLiteral("denomination"), mapValue(objectMap, "value", "text"));
0325   entry->setField(QStringLiteral("currency"), mapValue(objectMap, "value", "currency", "name"));
0326   entry->setField(QStringLiteral("country"), mapValue(objectMap, "issuer", "name"));
0327   entry->setField(QStringLiteral("mintmark"), mapValue(objectMap, "mintLetter"));
0328 
0329   // if minyear = maxyear, then set the year of the coin
0330   auto year = objectMap.value(QLatin1String("minYear"));
0331   if(year == objectMap.value(QLatin1String("maxYear"))) {
0332     entry->setField(QStringLiteral("year"), year.toString());
0333   } else if(!m_year.isEmpty()) {
0334     entry->setField(QStringLiteral("year"), m_year);
0335   }
0336 
0337   entry->setField(QStringLiteral("obverse"), mapValue(objectMap, "obverse", "picture"));
0338   entry->setField(QStringLiteral("reverse"), mapValue(objectMap, "reverse", "picture"));
0339 
0340   const QString numista(QStringLiteral("numista"));
0341   if(optionalFields().contains(numista)) {
0342     Data::FieldPtr field(new Data::Field(numista, i18n("Numista Link"), Data::Field::URL));
0343     field->setCategory(i18n("General"));
0344     coll->addField(field);
0345     entry->setField(numista, mapValue(objectMap, "url"));
0346   }
0347 
0348   const QString desc(QStringLiteral("description"));
0349   if(!coll->hasField(desc) && optionalFields().contains(desc)) {
0350     Data::FieldPtr field(new Data::Field(desc, i18n("Description"), Data::Field::Para));
0351     coll->addField(field);
0352     entry->setField(QStringLiteral("description"), mapValue(objectMap, "comments"));
0353   }
0354 
0355   QVariantList refs = objectMap.value(QStringLiteral("references")).toList();
0356   const QString krause(QStringLiteral("km"));
0357   if(!coll->hasField(krause) && optionalFields().contains(krause)) {
0358     Data::FieldPtr field(new Data::Field(krause, allOptionalFields().value(krause)));
0359     field->setCategory(i18n("General"));
0360     coll->addField(field);
0361     foreach(const QVariant& ref, refs) {
0362       QVariantMap refMap = ref.toMap();
0363       if(mapValue(refMap, "catalogue", "code") == QLatin1String("KM")) {
0364         entry->setField(krause, mapValue(refMap, "number"));
0365         // don't break out, there could be multiple KM values and we want the last one
0366       }
0367     }
0368   }
0369 
0370   return entry;
0371 }
0372 
0373 Tellico::Fetch::FetchRequest NumistaFetcher::updateRequest(Data::EntryPtr entry_) {
0374   QString t = entry_->field(QStringLiteral("type"));
0375   QString c = entry_->field(QStringLiteral("country"));
0376   if(!t.isEmpty()) {
0377     return FetchRequest(Fetch::Keyword, t + QLatin1Char(' ') + c);
0378   }
0379 
0380   return FetchRequest();
0381 }
0382 
0383 Tellico::Fetch::ConfigWidget* NumistaFetcher::configWidget(QWidget* parent_) const {
0384   return new NumistaFetcher::ConfigWidget(parent_, this);
0385 }
0386 
0387 QString NumistaFetcher::defaultName() {
0388   return QStringLiteral("Numista"); // no translation
0389 }
0390 
0391 QString NumistaFetcher::defaultIcon() {
0392   return favIcon("https://en.numista.com");
0393 }
0394 
0395 Tellico::StringHash NumistaFetcher::allOptionalFields() {
0396   StringHash hash;
0397   hash[QStringLiteral("numista")] = i18n("Numista Link");
0398   hash[QStringLiteral("description")] = i18n("Description");
0399   // treat images as optional since Numista doesn't break out different images for each year
0400   hash[QStringLiteral("obverse")] = i18n("Obverse");
0401   hash[QStringLiteral("reverse")] = i18n("Reverse");
0402   hash[QStringLiteral("km")] = i18nc("Standard catalog of world coins number", "Krause-Mishler");
0403   return hash;
0404 }
0405 
0406 NumistaFetcher::ConfigWidget::ConfigWidget(QWidget* parent_, const NumistaFetcher* fetcher_)
0407     : Fetch::ConfigWidget(parent_) {
0408   QGridLayout* l = new QGridLayout(optionsWidget());
0409   l->setSpacing(4);
0410   l->setColumnStretch(1, 10);
0411 
0412   int row = -1;
0413 
0414   QLabel* label = new QLabel(i18n("Access key: "), optionsWidget());
0415   l->addWidget(label, ++row, 0);
0416 
0417   m_apiKeyEdit = new QLineEdit(optionsWidget());
0418   connect(m_apiKeyEdit, &QLineEdit::textChanged, this, &ConfigWidget::slotSetModified);
0419   l->addWidget(m_apiKeyEdit, row, 1);
0420   label->setBuddy(m_apiKeyEdit);
0421 
0422   label = new QLabel(i18n("Language: "), optionsWidget());
0423   l->addWidget(label, ++row, 0);
0424   m_langCombo = new GUI::ComboBox(optionsWidget());
0425   QIcon iconUS(QStandardPaths::locate(QStandardPaths::GenericDataLocation,
0426                                       QStringLiteral("kf5/locale/countries/us/flag.png")));
0427   m_langCombo->addItem(iconUS, i18nc("Language", "English"), QLatin1String("en"));
0428   QIcon iconFR(QStandardPaths::locate(QStandardPaths::GenericDataLocation,
0429                                       QStringLiteral("kf5/locale/countries/fr/flag.png")));
0430   m_langCombo->addItem(iconFR, i18nc("Language", "French"), QLatin1String("fr"));
0431   void (GUI::ComboBox::* activatedInt)(int) = &GUI::ComboBox::activated;
0432   connect(m_langCombo, activatedInt, this, &ConfigWidget::slotSetModified);
0433   connect(m_langCombo, activatedInt, this, &ConfigWidget::slotLangChanged);
0434   l->addWidget(m_langCombo, row, 1);
0435   label->setBuddy(m_langCombo);
0436 
0437   l->setRowStretch(++row, 10);
0438 
0439   // now add additional fields widget
0440   addFieldsWidget(NumistaFetcher::allOptionalFields(), fetcher_ ? fetcher_->optionalFields() : QStringList());
0441 
0442   // don't show the default API key
0443   if(fetcher_) {
0444     if(fetcher_->m_apiKey != Tellico::reverseObfuscate(NUMISTA_MAGIC_TOKEN)) {
0445       m_apiKeyEdit->setText(fetcher_->m_apiKey);
0446     }
0447     m_langCombo->setCurrentData(fetcher_->m_locale);
0448   }
0449 }
0450 
0451 void NumistaFetcher::ConfigWidget::saveConfigHook(KConfigGroup& config_) {
0452   const QString apiKey = m_apiKeyEdit->text().trimmed();
0453   if(!apiKey.isEmpty()) {
0454     config_.writeEntry("API Key", apiKey);
0455   }
0456   const QString lang = m_langCombo->currentData().toString();
0457   config_.writeEntry("Locale", lang);
0458 }
0459 
0460 QString NumistaFetcher::ConfigWidget::preferredName() const {
0461   return i18n("Numista (%1)", m_langCombo->currentText());
0462 }
0463 
0464 void NumistaFetcher::ConfigWidget::slotLangChanged() {
0465   emit signalName(preferredName());
0466 }