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 " 0228 title.replace(QLatin1String("""), 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(""(.+)"")); 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 }