[office/tellico] /: Add Numista data source

Robby Stephenson null at kde.org
Sun Sep 20 01:53:27 BST 2020


Git commit cd21106a0da2b222656ef2ff95945a3808432c37 by Robby Stephenson.
Committed on 20/09/2020 at 00:52.
Pushed by rstephenson into branch 'master'.

Add Numista data source

M  +4    -0    ChangeLog
M  +9    -0    doc/configuration.docbook
M  +1    -0    src/fetch/CMakeLists.txt
M  +2    -1    src/fetch/fetch.h
M  +3    -1    src/fetch/fetcherinitializer.cpp
M  +1    -0    src/fetch/fetchmanager.cpp
A  +466  -0    src/fetch/numistafetcher.cpp     [License: GPL (v2/3)]
A  +131  -0    src/fetch/numistafetcher.h     [License: GPL (v2/3)]
M  +8    -0    src/tests/CMakeLists.txt
A  +98   -0    src/tests/numistafetchertest.cpp     [License: GPL (v2/3)]
C  +12   -84   src/tests/numistafetchertest.h [from: src/fetch/fetch.h - 057% similarity]
M  +3    -0    src/tests/tellicotest.config

https://invent.kde.org/office/tellico/commit/cd21106a0da2b222656ef2ff95945a3808432c37

diff --git a/ChangeLog b/ChangeLog
index 3c969582..5ef04b38 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,3 +1,7 @@
+2020-09-17  Robby Stephenson  <robby at periapsis.org>
+
+	* Added data source for Numista.com.
+
 2020-09-16  Robby Stephenson  <robby at periapsis.org>
 
 	* Enabled general keyword search for MusicBrainz data source (Bug 426560).
diff --git a/doc/configuration.docbook b/doc/configuration.docbook
index 08ad65fe..800d38d2 100644
--- a/doc/configuration.docbook
+++ b/doc/configuration.docbook
@@ -189,6 +189,7 @@ while the full list is <ulink url="https://tellico-project.org/data-sources">ava
 <listitem><simpara><link linkend="sru">SRU servers</link>,</simpara></listitem>
 <!-- coins -->
 <listitem><simpara><link linkend="colnect">Colnect</link>,</simpara></listitem>
+<listitem><simpara><link linkend="numista">Numista</link>,</simpara></listitem>
 <!-- others -->
 <listitem><simpara><link linkend="externalexec">other external scripts or applications</link>, and</simpara></listitem>
 <listitem><simpara><link linkend="multiple-sources">combinations of any of the above sources</link>.</simpara></listitem>
@@ -465,6 +466,14 @@ The <ulink url="http://www.imdb.com">Internet Movie Database</ulink> provides in
 </para>
 </sect3>
 
+<sect3 id="numista">
+<title>Numista</title>
+<para>
+<ulink url="https://numista.com">Numista</ulink> is a world coin catalog which grows thanks to member contributions, offering
+online collection management, tools to easily exchange with other collectors, and a forum.
+</para>
+</sect3>
+
 </sect2>
 
 <sect2 id="variety-type-sources">
diff --git a/src/fetch/CMakeLists.txt b/src/fetch/CMakeLists.txt
index bf04e0bf..9f34e0ab 100644
--- a/src/fetch/CMakeLists.txt
+++ b/src/fetch/CMakeLists.txt
@@ -50,6 +50,7 @@ SET(fetch_STAT_SRCS
    mrlookupfetcher.cpp
    multifetcher.cpp
    musicbrainzfetcher.cpp
+   numistafetcher.cpp
    omdbfetcher.cpp
    openlibraryfetcher.cpp
    springerfetcher.cpp
diff --git a/src/fetch/fetch.h b/src/fetch/fetch.h
index 22d0878b..9d6abe05 100644
--- a/src/fetch/fetch.h
+++ b/src/fetch/fetch.h
@@ -104,7 +104,8 @@ enum Type {
   MobyGames,
   ComicVine,
   KinoTeatr,
-  Colnect
+  Colnect,
+  Numista
 };
 
   }
diff --git a/src/fetch/fetcherinitializer.cpp b/src/fetch/fetcherinitializer.cpp
index 71ae2533..e902f862 100644
--- a/src/fetch/fetcherinitializer.cpp
+++ b/src/fetch/fetcherinitializer.cpp
@@ -1,5 +1,5 @@
 /***************************************************************************
-    Copyright (C) 2009-2011 Robby Stephenson <robby at periapsis.org>
+    Copyright (C) 2009-2020 Robby Stephenson <robby at periapsis.org>
  ***************************************************************************/
 
 /***************************************************************************
@@ -72,6 +72,7 @@
 #include "comicvinefetcher.h"
 #include "kinoteatrfetcher.h"
 #include "colnectfetcher.h"
+#include "numistafetcher.h"
 
 /**
  * Ideally, I'd like these initializations to be in each cpp file for each collection type
@@ -125,6 +126,7 @@ Tellico::Fetch::FetcherInitializer::FetcherInitializer() {
   RegisterFetcher<Fetch::ComicVineFetcher> registerComicVine(ComicVine);
   RegisterFetcher<Fetch::KinoTeatrFetcher> registerTeatr(KinoTeatr);
   RegisterFetcher<Fetch::ColnectFetcher> registerColnect(Colnect);
+  RegisterFetcher<Fetch::NumistaFetcher> registerNumista(Numista);
 
   Fetch::Manager::self()->loadFetchers();
 }
diff --git a/src/fetch/fetchmanager.cpp b/src/fetch/fetchmanager.cpp
index a6895c7a..eb61e43d 100644
--- a/src/fetch/fetchmanager.cpp
+++ b/src/fetch/fetchmanager.cpp
@@ -324,6 +324,7 @@ Tellico::Fetch::FetcherVec Manager::defaultFetchers() {
   FETCHER_ADD(IMDB);
 // coins and stamps
   FETCHER_ADD(Colnect);
+  FETCHER_ADD(Numista);
   QStringList langs = QLocale().uiLanguages();
   if(langs.first().contains(QLatin1Char('-'))) {
     // I'm not sure QT always include two-letter locale codes
diff --git a/src/fetch/numistafetcher.cpp b/src/fetch/numistafetcher.cpp
new file mode 100644
index 00000000..af27fdb3
--- /dev/null
+++ b/src/fetch/numistafetcher.cpp
@@ -0,0 +1,466 @@
+/***************************************************************************
+    Copyright (C) 2020 Robby Stephenson <robby at periapsis.org>
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or         *
+ *   modify it under the terms of the GNU General Public License as        *
+ *   published by the Free Software Foundation; either version 2 of        *
+ *   the License or (at your option) version 3 or any later version        *
+ *   accepted by the membership of KDE e.V. (or its successor approved     *
+ *   by the membership of KDE e.V.), which shall act as a proxy            *
+ *   defined in Section 14 of version 3 of the license.                    *
+ *                                                                         *
+ *   This program is distributed in the hope that it will be useful,       *
+ *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
+ *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
+ *   GNU General Public License for more details.                          *
+ *                                                                         *
+ *   You should have received a copy of the GNU General Public License     *
+ *   along with this program.  If not, see <http://www.gnu.org/licenses/>. *
+ *                                                                         *
+ ***************************************************************************/
+
+#include "numistafetcher.h"
+#include "../collections/coincollection.h"
+#include "../entry.h"
+#include "../images/imagefactory.h"
+#include "../gui/combobox.h"
+#include "../utils/guiproxy.h"
+#include "../utils/string_utils.h"
+#include "../tellico_debug.h"
+
+#include <KLocalizedString>
+#include <KIO/Job>
+#include <KJobUiDelegate>
+#include <KJobWidgets/KJobWidgets>
+#include <KConfigGroup>
+
+#include <QLabel>
+#include <QLineEdit>
+#include <QFile>
+#include <QTextStream>
+#include <QGridLayout>
+#include <QUrlQuery>
+#include <QJsonDocument>
+#include <QJsonArray>
+#include <QJsonObject>
+#include <QStandardPaths>
+
+namespace {
+  static const int NUMISTA_MAX_RETURNS_TOTAL = 20;
+  static const char* NUMISTA_API_URL = "https://api.numista.com/api/v1";
+  static const char* NUMISTA_MAGIC_TOKEN = "2e19b8f32c5e8fbd96aeb2c0590d70458ef81d5b0657b1f6741685e1f9cf7a0983d7d0e0a2c69bcca7cfb4c08fde1c5a562e083e2d44a492a5e4b9c3d2a42a7c536a99f8511bfdbca9fb6d29f587fbbf";
+}
+
+using namespace Tellico;
+using Tellico::Fetch::NumistaFetcher;
+
+NumistaFetcher::NumistaFetcher(QObject* parent_)
+    : Fetcher(parent_)
+    , m_limit(NUMISTA_MAX_RETURNS_TOTAL)
+    , m_total(-1)
+    , m_page(1)
+    , m_job(nullptr)
+    , m_locale(QStringLiteral("en"))
+    , m_started(false) {
+}
+
+NumistaFetcher::~NumistaFetcher() {
+}
+
+QString NumistaFetcher::source() const {
+  return m_name.isEmpty() ? defaultName() : m_name;
+}
+
+bool NumistaFetcher::canFetch(int type) const {
+  return type == Data::Collection::Coin;
+}
+
+void NumistaFetcher::readConfigHook(const KConfigGroup& config_) {
+  QString k = config_.readEntry("API Key");
+  if(!k.isEmpty()) {
+    m_apiKey = k;
+  }
+  k = config_.readEntry("Locale", "en");
+  if(!k.isEmpty()) {
+    m_locale = k.toLower();
+  }
+}
+
+void NumistaFetcher::setLimit(int limit_) {
+  m_limit = qBound(1, limit_, NUMISTA_MAX_RETURNS_TOTAL);
+}
+
+void NumistaFetcher::search() {
+  m_started = true;
+  m_total = -1;
+  m_page = 1;
+  m_year.clear();
+  doSearch();
+}
+
+void NumistaFetcher::continueSearch() {
+  m_started = true;
+  doSearch();
+}
+
+void NumistaFetcher::doSearch() {
+  QUrl u(QString::fromLatin1(NUMISTA_API_URL));
+  // all searches are for coins
+  u.setPath(u.path() + QStringLiteral("/coins"));
+
+  if(m_apiKey.isEmpty()) {
+    m_apiKey = Tellico::reverseObfuscate(NUMISTA_MAGIC_TOKEN);
+  }
+
+  // pull out year, keep the regexp a little loose
+  QRegularExpression yearRX(QStringLiteral("[0-9]{4}"));
+  QRegularExpressionMatch match = yearRX.match(request().value);
+  if(match.hasMatch()) {
+    m_year = match.captured(0);
+  }
+
+  QString queryString;
+  switch(request().key) {
+    case Keyword:
+      queryString = request().value;
+      break;
+
+    default:
+      myWarning() << "key not recognized: " << request().key;
+      stop();
+      return;
+  }
+  QUrlQuery q;
+  q.addQueryItem(QStringLiteral("q"), queryString);
+  q.addQueryItem(QStringLiteral("count"), QString::number(m_limit));
+  q.addQueryItem(QStringLiteral("page"), QString::number(m_page));
+  q.addQueryItem(QStringLiteral("lang"), m_locale);
+  u.setQuery(q);
+//  myDebug() << "url: " << u.url();
+
+  m_job = KIO::storedGet(u, KIO::NoReload, KIO::HideProgressInfo);
+  m_job->addMetaData(QStringLiteral("customHTTPHeader"), QStringLiteral("Numista-API-Key: ") + m_apiKey);
+  KJobWidgets::setWindow(m_job, GUI::Proxy::widget());
+  connect(m_job.data(), &KJob::result,
+          this, &NumistaFetcher::slotComplete);
+}
+
+void NumistaFetcher::stop() {
+  if(!m_started) {
+    return;
+  }
+  if(m_job) {
+    m_job->kill();
+    m_job = nullptr;
+  }
+  m_started = false;
+  emit signalDone(this);
+}
+
+void NumistaFetcher::slotComplete(KJob* ) {
+  if(m_job->error()) {
+    m_job->uiDelegate()->showErrorMessage();
+    stop();
+    return;
+  }
+
+  QByteArray data = m_job->data();
+  if(data.isEmpty()) {
+    myDebug() << "no data";
+    stop();
+    return;
+  }
+  // see bug 319662. If fetcher is cancelled, job is killed
+  // if the pointer is retained, it gets double-deleted
+  m_job = nullptr;
+
+#if 0
+  myWarning() << "Remove debug from numistafetcher.cpp";
+  QFile f(QStringLiteral("/tmp/test.json"));
+  if(f.open(QIODevice::WriteOnly)) {
+    QTextStream t(&f);
+    t.setCodec("UTF-8");
+    t << data;
+  }
+  f.close();
+#endif
+
+  QJsonParseError jsonError;
+  QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError);
+  if(doc.isNull()) {
+    myDebug() << "null JSON document:" << jsonError.errorString();
+    message(jsonError.errorString(), MessageHandler::Error);
+    stop();
+    return;
+  }
+  QJsonObject obj = doc.object();
+
+  // check for error
+  if(obj.contains(QStringLiteral("error"))) {
+    const QString msg = obj.value(QStringLiteral("error")).toString();
+    message(msg, MessageHandler::Error);
+    myDebug() << "NunmistaFetcher -" << msg;
+    stop();
+    return;
+  }
+
+  m_total = obj.value(QLatin1String("count")).toString().toInt();
+
+  int count = 0;
+  QJsonArray results = obj.value(QLatin1String("coins")).toArray();
+  for(QJsonArray::const_iterator i = results.constBegin(); i != results.constEnd(); ++i) {
+    if(count >= m_limit) {
+      break;
+    }
+    QJsonObject result = (*i).toObject();
+
+    QString desc = result.value(QLatin1String("issuer")).toObject()
+                         .value(QLatin1String("name")).toString();
+    desc += QLatin1Char('/');
+    const QString minYear = result.value(QLatin1String("minYear")).toString();
+    if(!minYear.isEmpty()) {
+      desc += minYear + QLatin1Char('-') + result.value(QLatin1String("maxYear")).toString();
+    }
+    FetchResult* r = new FetchResult(Fetcher::Ptr(this),
+                                     result.value(QLatin1String("title")).toString(),
+                                     desc);
+    m_matches.insert(r->uid, result.value(QLatin1String("id")).toInt());
+    emit signalResultFound(r);
+    ++count;
+  }
+
+  stop(); // required
+}
+
+Tellico::Data::EntryPtr NumistaFetcher::fetchEntryHook(uint uid_) {
+  Data::EntryPtr entry = m_entries.value(uid_);
+  if(entry) {
+    return entry;
+  }
+
+  if(!m_matches.contains(uid_)) {
+    myWarning() << "no matching coin id";
+    return Data::EntryPtr();
+  }
+
+  QUrl url(QString::fromLatin1(NUMISTA_API_URL));
+  url.setPath(url.path() + QLatin1String("/coins/") + QString::number(m_matches[uid_]));
+//  myDebug() << url.url();
+  QPointer<KIO::StoredTransferJob> job = KIO::storedGet(url, KIO::NoReload, KIO::HideProgressInfo);
+  job->addMetaData(QStringLiteral("customHTTPHeader"), QStringLiteral("Numista-API-Key: ") + m_apiKey);
+  KJobWidgets::setWindow(job, GUI::Proxy::widget());
+  if(!job->exec()) {
+    myDebug() << job->errorString() << url;
+    return Data::EntryPtr();
+  }
+  const QByteArray data = job->data();
+  if(data.isEmpty()) {
+    myDebug() << "no data for" << url;
+    return Data::EntryPtr();
+  }
+#if 0
+  myWarning() << "Remove debug2 from numistafetcher.cpp";
+  QFile f(QStringLiteral("/tmp/test2-numista.json"));
+  if(f.open(QIODevice::WriteOnly)) {
+    QTextStream t(&f);
+    t.setCodec("UTF-8");
+    t << data;
+  }
+  f.close();
+#endif
+
+  entry = parseEntry(data);
+  if(!entry) {
+    myDebug() << "No discernible entry data";
+    return Data::EntryPtr();
+  }
+
+  QString image = entry->field(QStringLiteral("obverse"));
+  if(!image.isEmpty() && optionalFields().contains(QStringLiteral("obverse"))) {
+    const QString id = ImageFactory::addImage(QUrl::fromUserInput(image), true /* quiet */);
+    if(id.isEmpty()) {
+      message(i18n("The cover image could not be loaded."), MessageHandler::Warning);
+    }
+    entry->setField(QStringLiteral("obverse"), id);
+  }
+  image = entry->field(QStringLiteral("reverse"));
+  if(!image.isEmpty() && optionalFields().contains(QStringLiteral("reverse"))) {
+    const QString id = ImageFactory::addImage(QUrl::fromUserInput(image), true /* quiet */);
+    if(id.isEmpty()) {
+      message(i18n("The cover image could not be loaded."), MessageHandler::Warning);
+    }
+    entry->setField(QStringLiteral("reverse"), id);
+  }
+
+  return entry;
+}
+
+Tellico::Data::EntryPtr NumistaFetcher::parseEntry(const QByteArray& data_) {
+  QJsonParseError parseError;
+  QJsonDocument doc = QJsonDocument::fromJson(data_, &parseError);
+  if(doc.isNull()) {
+    myDebug() << "Bad json data:" << parseError.errorString();
+    return Data::EntryPtr();
+  }
+
+  Data::CollPtr coll(new Data::CoinCollection(true));
+  Data::EntryPtr entry(new Data::Entry(coll));
+  coll->addEntries(entry);
+
+  QVariantMap objectMap = doc.object().toVariantMap();
+  // for type, try to tease out from title
+  // use ruler name as a possible fallback
+  QRegularExpression titleQuote(QStringLiteral(""(.+)""));
+  QRegularExpressionMatch quoteMatch = titleQuote.match(mapValue(objectMap, "title"));
+  if(quoteMatch.hasMatch()) {
+    entry->setField(QStringLiteral("type"), quoteMatch.captured(1));
+  } else {
+    entry->setField(QStringLiteral("type"), mapValue(objectMap, "ruler", "name"));
+  }
+
+  entry->setField(QStringLiteral("denomination"), mapValue(objectMap, "value", "text"));
+  entry->setField(QStringLiteral("currency"), mapValue(objectMap, "value", "currency", "name"));
+  entry->setField(QStringLiteral("country"), mapValue(objectMap, "issuer", "name"));
+  entry->setField(QStringLiteral("mintmark"), mapValue(objectMap, "mintLetter"));
+
+  // if minyear = maxyear, then set the year of the coin
+  auto year = objectMap.value(QLatin1String("minYear"));
+  if(year == objectMap.value(QLatin1String("maxYear"))) {
+    entry->setField(QStringLiteral("year"), year.toString());
+  } else if(!m_year.isEmpty()) {
+    entry->setField(QStringLiteral("year"), m_year);
+  }
+
+  entry->setField(QStringLiteral("obverse"), mapValue(objectMap, "obverse", "picture"));
+  entry->setField(QStringLiteral("reverse"), mapValue(objectMap, "reverse", "picture"));
+
+  const QString numista(QStringLiteral("numista"));
+  if(optionalFields().contains(numista)) {
+    Data::FieldPtr field(new Data::Field(numista, i18n("Numista Link"), Data::Field::URL));
+    field->setCategory(i18n("General"));
+    coll->addField(field);
+    entry->setField(numista, mapValue(objectMap, "url"));
+  }
+
+  const QString desc(QStringLiteral("description"));
+  if(!coll->hasField(desc) && optionalFields().contains(desc)) {
+    Data::FieldPtr field(new Data::Field(desc, i18n("Description"), Data::Field::Para));
+    coll->addField(field);
+    entry->setField(QStringLiteral("description"), mapValue(objectMap, "comments"));
+  }
+
+  QVariantList refs = objectMap.value(QStringLiteral("references")).toList();
+  const QString krause(QStringLiteral("km"));
+  if(!coll->hasField(krause) && optionalFields().contains(krause)) {
+    Data::FieldPtr field(new Data::Field(krause, allOptionalFields().value(krause)));
+    field->setCategory(i18n("General"));
+    coll->addField(field);
+    foreach(const QVariant& ref, refs) {
+      QVariantMap refMap = ref.toMap();
+      if(mapValue(refMap, "catalogue", "code") == QLatin1String("KM")) {
+        entry->setField(krause, mapValue(refMap, "number"));
+        // don't break out, there could be multiple KM values and we want the last one
+      }
+    }
+  }
+
+  return entry;
+}
+
+Tellico::Fetch::FetchRequest NumistaFetcher::updateRequest(Data::EntryPtr entry_) {
+  QString t = entry_->field(QStringLiteral("type"));
+  QString c = entry_->field(QStringLiteral("country"));
+  if(!t.isEmpty()) {
+    return FetchRequest(Fetch::Keyword, t + QLatin1Char(' ') + c);
+  }
+
+  return FetchRequest();
+}
+
+Tellico::Fetch::ConfigWidget* NumistaFetcher::configWidget(QWidget* parent_) const {
+  return new NumistaFetcher::ConfigWidget(parent_, this);
+}
+
+QString NumistaFetcher::defaultName() {
+  return QStringLiteral("Numista"); // no translation
+}
+
+QString NumistaFetcher::defaultIcon() {
+  return favIcon("https://en.numista.com");
+}
+
+Tellico::StringHash NumistaFetcher::allOptionalFields() {
+  StringHash hash;
+  hash[QStringLiteral("numista")] = i18n("Numista Link");
+  hash[QStringLiteral("description")] = i18n("Description");
+  // treat images as optional since Numista doesn't break out different images for each year
+  hash[QStringLiteral("obverse")] = i18n("Obverse");
+  hash[QStringLiteral("reverse")] = i18n("Reverse");
+  hash[QStringLiteral("km")] = i18nc("Standard catalog of world coins number", "Krause-Mishler");
+  return hash;
+}
+
+NumistaFetcher::ConfigWidget::ConfigWidget(QWidget* parent_, const NumistaFetcher* fetcher_)
+    : Fetch::ConfigWidget(parent_) {
+  QGridLayout* l = new QGridLayout(optionsWidget());
+  l->setSpacing(4);
+  l->setColumnStretch(1, 10);
+
+  int row = -1;
+
+  QLabel* label = new QLabel(i18n("Access key: "), optionsWidget());
+  l->addWidget(label, ++row, 0);
+
+  m_apiKeyEdit = new QLineEdit(optionsWidget());
+  connect(m_apiKeyEdit, &QLineEdit::textChanged, this, &ConfigWidget::slotSetModified);
+  l->addWidget(m_apiKeyEdit, row, 1);
+  label->setBuddy(m_apiKeyEdit);
+
+  label = new QLabel(i18n("Language: "), optionsWidget());
+  l->addWidget(label, ++row, 0);
+  m_langCombo = new GUI::ComboBox(optionsWidget());
+  QIcon iconUS(QStandardPaths::locate(QStandardPaths::GenericDataLocation,
+                                      QStringLiteral("kf5/locale/countries/us/flag.png")));
+  m_langCombo->addItem(iconUS, i18nc("Language", "English"), QLatin1String("en"));
+  QIcon iconFR(QStandardPaths::locate(QStandardPaths::GenericDataLocation,
+                                      QStringLiteral("kf5/locale/countries/fr/flag.png")));
+  m_langCombo->addItem(iconFR, i18nc("Language", "French"), QLatin1String("fr"));
+  void (GUI::ComboBox::* activatedInt)(int) = &GUI::ComboBox::activated;
+  connect(m_langCombo, activatedInt, this, &ConfigWidget::slotSetModified);
+  connect(m_langCombo, activatedInt, this, &ConfigWidget::slotLangChanged);
+  l->addWidget(m_langCombo, row, 1);
+  label->setBuddy(m_langCombo);
+
+  l->setRowStretch(++row, 10);
+
+  // now add additional fields widget
+  addFieldsWidget(NumistaFetcher::allOptionalFields(), fetcher_ ? fetcher_->optionalFields() : QStringList());
+
+  // don't show the default API key
+  if(fetcher_) {
+    if(fetcher_->m_apiKey != Tellico::reverseObfuscate(NUMISTA_MAGIC_TOKEN)) {
+      m_apiKeyEdit->setText(fetcher_->m_apiKey);
+    }
+    m_langCombo->setCurrentData(fetcher_->m_locale);
+  }
+}
+
+void NumistaFetcher::ConfigWidget::saveConfigHook(KConfigGroup& config_) {
+  const QString apiKey = m_apiKeyEdit->text().trimmed();
+  if(!apiKey.isEmpty()) {
+    config_.writeEntry("API Key", apiKey);
+  }
+  const QString lang = m_langCombo->currentData().toString();
+  config_.writeEntry("Locale", lang);
+}
+
+QString NumistaFetcher::ConfigWidget::preferredName() const {
+  return i18n("Numista (%1)", m_langCombo->currentText());
+}
+
+void NumistaFetcher::ConfigWidget::slotLangChanged() {
+  emit signalName(preferredName());
+}
diff --git a/src/fetch/numistafetcher.h b/src/fetch/numistafetcher.h
new file mode 100644
index 00000000..27634842
--- /dev/null
+++ b/src/fetch/numistafetcher.h
@@ -0,0 +1,131 @@
+/***************************************************************************
+    Copyright (C) 2020 Robby Stephenson <robby at periapsis.org>
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or         *
+ *   modify it under the terms of the GNU General Public License as        *
+ *   published by the Free Software Foundation; either version 2 of        *
+ *   the License or (at your option) version 3 or any later version        *
+ *   accepted by the membership of KDE e.V. (or its successor approved     *
+ *   by the membership of KDE e.V.), which shall act as a proxy            *
+ *   defined in Section 14 of version 3 of the license.                    *
+ *                                                                         *
+ *   This program is distributed in the hope that it will be useful,       *
+ *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
+ *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
+ *   GNU General Public License for more details.                          *
+ *                                                                         *
+ *   You should have received a copy of the GNU General Public License     *
+ *   along with this program.  If not, see <http://www.gnu.org/licenses/>. *
+ *                                                                         *
+ ***************************************************************************/
+
+#ifndef TELLICO_NUMISTAFETCHER_H
+#define TELLICO_NUMISTAFETCHER_H
+
+#include "fetcher.h"
+#include "configwidget.h"
+#include "../datavectors.h"
+
+#include <QPointer>
+
+class QLineEdit;
+
+class KJob;
+namespace KIO {
+  class StoredTransferJob;
+}
+
+namespace Tellico {
+  namespace GUI {
+    class ComboBox;
+  }
+
+  namespace Fetch {
+
+/**
+ * A fetcher for numista.com
+ *
+ * @author Robby Stephenson
+ */
+class NumistaFetcher : public Fetcher {
+Q_OBJECT
+
+public:
+  /**
+   */
+  NumistaFetcher(QObject* parent);
+  /**
+   */
+  virtual ~NumistaFetcher();
+
+  /**
+   */
+  virtual QString source() const Q_DECL_OVERRIDE;
+  virtual bool isSearching() const Q_DECL_OVERRIDE { return m_started; }
+  virtual void continueSearch() Q_DECL_OVERRIDE;
+  // amazon can search title or person
+  virtual bool canSearch(FetchKey k) const Q_DECL_OVERRIDE { return k == Keyword; }
+  virtual void stop() Q_DECL_OVERRIDE;
+  virtual Data::EntryPtr fetchEntryHook(uint uid) Q_DECL_OVERRIDE;
+  virtual Type type() const Q_DECL_OVERRIDE { return Numista; }
+  virtual bool canFetch(int type) const Q_DECL_OVERRIDE;
+  virtual void readConfigHook(const KConfigGroup& config) Q_DECL_OVERRIDE;
+  void setLimit(int limit);
+
+  /**
+   * Returns a widget for modifying the fetcher's config.
+   */
+  virtual Fetch::ConfigWidget* configWidget(QWidget* parent) const Q_DECL_OVERRIDE;
+
+  class ConfigWidget;
+  friend class ConfigWidget;
+
+  static QString defaultName();
+  static QString defaultIcon();
+  static StringHash allOptionalFields();
+
+private Q_SLOTS:
+  void slotComplete(KJob* job);
+
+private:
+  virtual void search() Q_DECL_OVERRIDE;
+  virtual FetchRequest updateRequest(Data::EntryPtr entry) Q_DECL_OVERRIDE;
+  void doSearch();
+  Data::EntryPtr parseEntry(const QByteArray& data);
+
+  int m_limit;
+  int m_total;
+  int m_page;
+
+  QHash<uint, int> m_matches; // search result id to coin id
+  QHash<uint, Data::EntryPtr> m_entries;
+  QPointer<KIO::StoredTransferJob> m_job;
+  QString m_apiKey;
+  QString m_locale;
+  QString m_year;
+
+  bool m_started;
+};
+
+class NumistaFetcher::ConfigWidget : public Fetch::ConfigWidget {
+Q_OBJECT
+
+public:
+  explicit ConfigWidget(QWidget* parent_, const NumistaFetcher* fetcher = nullptr);
+  virtual void saveConfigHook(KConfigGroup&) Q_DECL_OVERRIDE;
+  virtual QString preferredName() const Q_DECL_OVERRIDE;
+
+private Q_SLOTS:
+  void slotLangChanged();
+
+private:
+  QLineEdit* m_apiKeyEdit;
+  GUI::ComboBox* m_langCombo;
+};
+
+  } // end namespace
+} // end namespace
+#endif
diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt
index b3e2a82e..c5fccb74 100644
--- a/src/tests/CMakeLists.txt
+++ b/src/tests/CMakeLists.txt
@@ -802,6 +802,14 @@ add_test(musicbrainzfetchertest musicbrainzfetchertest)
 ecm_mark_as_test(musicbrainzfetchertest)
 TARGET_LINK_LIBRARIES(musicbrainzfetchertest fetcherstest ${TELLICO_TEST_LIBS})
 
+add_executable(numistafetchertest numistafetchertest.cpp abstractfetchertest.cpp
+  ../fetch/numistafetcher.cpp
+)
+ecm_mark_nongui_executable(numistafetchertest)
+add_test(numistafetchertest numistafetchertest)
+ecm_mark_as_test(numistafetchertest)
+TARGET_LINK_LIBRARIES(numistafetchertest fetcherstest ${TELLICO_TEST_LIBS})
+
 add_executable(openlibraryfetchertest openlibraryfetchertest.cpp abstractfetchertest.cpp
   ../fetch/openlibraryfetcher.cpp
 )
diff --git a/src/tests/numistafetchertest.cpp b/src/tests/numistafetchertest.cpp
new file mode 100644
index 00000000..8cab6781
--- /dev/null
+++ b/src/tests/numistafetchertest.cpp
@@ -0,0 +1,98 @@
+/***************************************************************************
+    Copyright (C) 2020 Robby Stephenson <robby at periapsis.org>
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   This program is free software; you can redistribute it and/or         *
+ *   modify it under the terms of the GNU General Public License as        *
+ *   published by the Free Software Foundation; either version 2 of        *
+ *   the License or (at your option) version 3 or any later version        *
+ *   accepted by the membership of KDE e.V. (or its successor approved     *
+ *   by the membership of KDE e.V.), which shall act as a proxy            *
+ *   defined in Section 14 of version 3 of the license.                    *
+ *                                                                         *
+ *   This program is distributed in the hope that it will be useful,       *
+ *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
+ *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
+ *   GNU General Public License for more details.                          *
+ *                                                                         *
+ *   You should have received a copy of the GNU General Public License     *
+ *   along with this program.  If not, see <http://www.gnu.org/licenses/>. *
+ *                                                                         *
+ ***************************************************************************/
+
+#undef QT_NO_CAST_FROM_ASCII
+
+#include "numistafetchertest.h"
+
+#include "../fetch/numistafetcher.h"
+#include "../collections/coincollection.h"
+#include "../entry.h"
+#include "../images/imagefactory.h"
+
+#include <KConfig>
+#include <KConfigGroup>
+
+#include <QTest>
+
+QTEST_GUILESS_MAIN( NumistaFetcherTest )
+
+NumistaFetcherTest::NumistaFetcherTest() : AbstractFetcherTest() {
+}
+
+void NumistaFetcherTest::initTestCase() {
+  Tellico::ImageFactory::init();
+}
+
+void NumistaFetcherTest::testSacagawea() {
+  KConfig config(QFINDTESTDATA("tellicotest.config"), KConfig::SimpleConfig);
+  QString groupName = QStringLiteral("numista");
+  if(!config.hasGroup(groupName)) {
+    QSKIP("This test requires a config file.", SkipAll);
+  }
+  KConfigGroup cg(&config, groupName);
+
+  Tellico::Fetch::FetchRequest request(Tellico::Data::Collection::Coin,
+                                       Tellico::Fetch::Keyword,
+                                       QStringLiteral("2019 Sacagawea"));
+  Tellico::Fetch::Fetcher::Ptr fetcher(new Tellico::Fetch::NumistaFetcher(this));
+  fetcher->readConfig(cg, cg.name());
+
+  static_cast<Tellico::Fetch::NumistaFetcher*>(fetcher.data())->setLimit(1);
+  Tellico::Data::EntryList results = DO_FETCH1(fetcher, request, 1);
+
+  QCOMPARE(results.size(), 1);
+  Tellico::Data::EntryPtr entry = results.at(0);
+
+  QCOMPARE(entry->field(QStringLiteral("type")), QStringLiteral("Native American Dollar"));
+  QCOMPARE(entry->field(QStringLiteral("year")), QStringLiteral("2019"));
+  QCOMPARE(entry->field(QStringLiteral("country")), QStringLiteral("United States"));
+  QCOMPARE(entry->field(QStringLiteral("denomination")), QStringLiteral("1 Dollar"));
+  QCOMPARE(entry->field(QStringLiteral("currency")), QStringLiteral("Dollar"));
+  QCOMPARE(entry->field(QStringLiteral("numista")), QStringLiteral("https://en.numista.com/catalogue/pieces155679.html"));
+  QVERIFY(!entry->field(QStringLiteral("description")).isEmpty());
+  QVERIFY(!entry->field(QStringLiteral("obverse")).isEmpty());
+  QVERIFY(!entry->field(QStringLiteral("obverse")).contains(QLatin1Char('/')));
+  QVERIFY(!entry->field(QStringLiteral("reverse")).isEmpty());
+  QVERIFY(!entry->field(QStringLiteral("reverse")).contains(QLatin1Char('/')));
+}
+
+void NumistaFetcherTest::testJefferson() {
+  Tellico::Fetch::FetchRequest request(Tellico::Data::Collection::Coin,
+                                       Tellico::Fetch::Keyword,
+                                       QStringLiteral("1974 jefferson nickel"));
+  Tellico::Fetch::Fetcher::Ptr fetcher(new Tellico::Fetch::NumistaFetcher(this));
+
+  Tellico::Data::EntryList results = DO_FETCH(fetcher, request);
+
+  QVERIFY(!results.isEmpty());
+  Tellico::Data::EntryPtr entry = results.at(0);
+  QVERIFY(entry);
+
+  QCOMPARE(entry->field(QStringLiteral("type")), QStringLiteral("Jefferson Nickel"));
+  QCOMPARE(entry->field(QStringLiteral("year")), QStringLiteral("1974"));
+  QCOMPARE(entry->field(QStringLiteral("country")), QStringLiteral("United States"));
+  QCOMPARE(entry->field(QStringLiteral("denomination")), QStringLiteral("5 Cents"));
+  QCOMPARE(entry->field(QStringLiteral("currency")), QStringLiteral("Dollar"));
+}
diff --git a/src/fetch/fetch.h b/src/tests/numistafetchertest.h
similarity index 57%
copy from src/fetch/fetch.h
copy to src/tests/numistafetchertest.h
index 22d0878b..5614dd2b 100644
--- a/src/fetch/fetch.h
+++ b/src/tests/numistafetchertest.h
@@ -1,5 +1,5 @@
 /***************************************************************************
-    Copyright (C) 2003-2009 Robby Stephenson <robby at periapsis.org>
+    Copyright (C) 2020 Robby Stephenson <robby at periapsis.org>
  ***************************************************************************/
 
 /***************************************************************************
@@ -22,92 +22,20 @@
  *                                                                         *
  ***************************************************************************/
 
-#ifndef TELLICO_FETCH_H
-#define TELLICO_FETCH_H
+#ifndef NUMISTAFETCHERTEST_H
+#define NUMISTAFETCHERTEST_H
 
-namespace Tellico {
-  namespace Fetch {
+#include "abstractfetchertest.h"
 
-/**
- * FetchFirst must be first, and the rest must follow consecutively in value.
- * FetchLast must be last!
- */
-enum FetchKey {
-  FetchFirst = 0,
-  Title,
-  Person,
-  ISBN,
-  UPC,
-  Keyword,
-  DOI,
-  ArxivID,
-  PubmedID,
-  LCCN,
-  Raw,
-  ExecUpdate,
-  FetchLast
-};
+class NumistaFetcherTest : public AbstractFetcherTest {
+Q_OBJECT
+public:
+  NumistaFetcherTest();
 
-// real ones must start at 0!
-enum Type {
-  Unknown = -1,
-  Amazon = 0,
-  IMDB,
-  Z3950,
-  SRU,
-  Entrez,
-  ExecExternal,
-  Yahoo, // Removed
-  AnimeNfo,
-  IBS,
-  ISBNdb,
-  GCstarPlugin,
-  CrossRef,
-  Citebase, // Removed
-  Arxiv,
-  Bibsonomy,
-  GoogleScholar,
-  Discogs,
-  WineCom,
-  TheMovieDB,
-  MusicBrainz,
-  GiantBomb,
-  OpenLibrary,
-  Multiple,
-  Freebase, // Removed
-  DVDFr,
-  Filmaster,
-  Douban,
-  BiblioShare,
-  MovieMeter,
-  GoogleBook,
-  MAS, // Removed
-  Springer,
-  Allocine,
-  ScreenRush, // Removed
-  FilmStarts, // Removed
-  SensaCine, // Removed
-  Beyazperde, // Removed
-  HathiTrust,
-  TheGamesDB,
-  DBLP,
-  VNDB,
-  MRLookup,
-  BoardGameGeek,
-  Bedetheque,
-  OMDB,
-  KinoPoisk,
-  VideoGameGeek,
-  DBC,
-  IGDB,
-  Kino,
-  MobyGames,
-  ComicVine,
-  KinoTeatr,
-  Colnect
+private Q_SLOTS:
+  void initTestCase();
+  void testSacagawea();
+  void testJefferson();
 };
 
-  }
-}
-
 #endif
diff --git a/src/tests/tellicotest.config b/src/tests/tellicotest.config
index cf4d5f87..bde4a544 100644
--- a/src/tests/tellicotest.config
+++ b/src/tests/tellicotest.config
@@ -81,3 +81,6 @@ Custom Fields=obverse,reverse,series,mintage,description
 
 [colnect stamps]
 Custom Fields=image,series,description,stanley-gibbons,michel
+
+[numista]
+Custom Fields=numista,description,obverse,reverse,km


More information about the kde-doc-english mailing list