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

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