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