[tellico] /: Add Colnect data source

Robby Stephenson null at kde.org
Wed Jan 1 02:00:17 GMT 2020


Git commit a4207de7ba5d1cfb5ec7c397e2dd161e1e509fd3 by Robby Stephenson.
Committed on 01/01/2020 at 01:55.
Pushed by rstephenson into branch 'master'.

Add Colnect data source

M  +4    -0    ChangeLog
M  +1    -1    TODO
M  +18   -1    doc/configuration.docbook
M  +1    -0    src/fetch/CMakeLists.txt
A  +548  -0    src/fetch/colnectfetcher.cpp     [License: GPL (v2/3)]
A  +132  -0    src/fetch/colnectfetcher.h     [License: GPL (v2/3)]
M  +2    -1    src/fetch/fetch.h
M  +2    -0    src/fetch/fetcherinitializer.cpp
M  +5    -0    src/fetch/fetchresult.cpp
M  +8    -0    src/tests/CMakeLists.txt
A  +130  -0    src/tests/colnectfetchertest.cpp     [License: GPL (v2/3)]
C  +14   -83   src/tests/colnectfetchertest.h [from: src/fetch/fetch.h - 057% similarity]
M  +3    -0    src/tests/tellicotest.config

https://commits.kde.org/tellico/a4207de7ba5d1cfb5ec7c397e2dd161e1e509fd3

diff --git a/ChangeLog b/ChangeLog
index fe2f8e5c..e2703f4a 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,3 +1,7 @@
+2019-12-10  Robby Stephenson  <robby at periapsis.org>
+
+	* Added fetcher for colnect.
+
 2019-11-25  Robby Stephenson  <robby at periapsis.org>
 
 	* Fixed performance regression when loading data file.
diff --git a/TODO b/TODO
index 2185539b..bfd864d5 100644
--- a/TODO
+++ b/TODO
@@ -23,6 +23,6 @@ Replace most of the toggle actions in the view settings with KDualAction
 
 Update the video game XSLT data sources to somehow use the platform normalization
 
-Colnect API https://colnect.com
+Add stamps search from Colnect
 Investigate using KINO-Teatr.ua API, https://api.kino-teatr.ua
 Use KRatingWidget
diff --git a/doc/configuration.docbook b/doc/configuration.docbook
index ff57cdcf..71585153 100644
--- a/doc/configuration.docbook
+++ b/doc/configuration.docbook
@@ -182,11 +182,14 @@ while the full list is <ulink url="http://tellico-project.org/data-sources">avai
 <listitem><simpara><link linkend="videogamegeek">VideoGameGeek</link>,</simpara></listitem>
 <!-- board games -->
 <listitem><simpara><link linkend="boardgamegeek">BoardGameGeek</link>,</simpara></listitem>
-<!-- bibliographic and others -->
+<!-- bibliographic -->
 <listitem><simpara><link linkend="bib-sources">arxiv.org</link>,</simpara></listitem>
 <listitem><simpara><link linkend="entrez">Entrez (PubMed) databases</link>,</simpara></listitem>
 <listitem><simpara><link linkend="z3950">z39.50 servers</link>,</simpara></listitem>
 <listitem><simpara><link linkend="sru">SRU servers</link>,</simpara></listitem>
+<!-- coins -->
+<listitem><simpara><link linkend="colnect">Colnect</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>
 </itemizedlist>
@@ -450,6 +453,20 @@ The <ulink url="http://www.imdb.com">Internet Movie Database</ulink> provides in
 
 </sect2>
 
+<!-- start of coin sources -->
+<sect2 id="coin-sources">
+<title>Coin Data Sources</title>
+
+<sect3 id="colnect">
+<title>Colnect</title>
+<para>
+<ulink url="https://colnect.com">Colnect</ulink> is an online community for collectibles providing personal collection management.
+&tellico; can search Colnect for coin information.
+</para>
+</sect3>
+
+</sect2>
+
 <sect2 id="variety-type-sources">
 <title>Data Sources for Multiple Collection Types</title>
 
diff --git a/src/fetch/CMakeLists.txt b/src/fetch/CMakeLists.txt
index 6005766b..17ad8343 100644
--- a/src/fetch/CMakeLists.txt
+++ b/src/fetch/CMakeLists.txt
@@ -12,6 +12,7 @@ SET(fetch_STAT_SRCS
    bibliosharefetcher.cpp
    bibsonomyfetcher.cpp
    boardgamegeekfetcher.cpp
+   colnectfetcher.cpp
    comicvinefetcher.cpp
    configwidget.cpp
    crossreffetcher.cpp
diff --git a/src/fetch/colnectfetcher.cpp b/src/fetch/colnectfetcher.cpp
new file mode 100644
index 00000000..b09bc0b4
--- /dev/null
+++ b/src/fetch/colnectfetcher.cpp
@@ -0,0 +1,548 @@
+/***************************************************************************
+    Copyright (C) 2019 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 "colnectfetcher.h"
+#include "../collections/coincollection.h"
+#include "../images/imagefactory.h"
+#include "../gui/combobox.h"
+#include "../utils/guiproxy.h"
+#include "../utils/string_utils.h"
+#include "../entry.h"
+#include "../fieldformat.h"
+#include "../core/filehandler.h"
+#include "../tellico_debug.h"
+
+#include <KLocalizedString>
+#include <KConfigGroup>
+#include <KJob>
+#include <KJobUiDelegate>
+#include <KJobWidgets/KJobWidgets>
+#include <KIO/StoredTransferJob>
+
+#include <QLabel>
+#include <QFile>
+#include <QTextStream>
+#include <QGridLayout>
+#include <QTextCodec>
+#include <QJsonDocument>
+#include <QJsonArray>
+#include <QJsonValue>
+#include <QRegularExpression>
+#include <QStandardPaths>
+
+namespace {
+  static const char* COLNECT_API_URL = "https://api.tellico-project.org/colnect";
+//  static const char* COLNECT_API_URL = "https://api.colnect.net";
+  static const char* COLNECT_IMAGE_URL = "https://i.colnect.net";
+}
+
+using namespace Tellico;
+using Tellico::Fetch::ColnectFetcher;
+
+ColnectFetcher::ColnectFetcher(QObject* parent_)
+    : Fetcher(parent_)
+    , m_started(false)
+    , m_locale(QStringLiteral("en")) {
+}
+
+ColnectFetcher::~ColnectFetcher() {
+}
+
+QString ColnectFetcher::source() const {
+  return m_name.isEmpty() ? defaultName() : m_name;
+}
+
+QString ColnectFetcher::attribution() const {
+  return QStringLiteral("Catalog information courtesy of Colnect, an online collectors community.");
+}
+
+bool ColnectFetcher::canSearch(FetchKey k) const {
+  return k == Keyword;
+}
+
+bool ColnectFetcher::canFetch(int type) const {
+  return type == Data::Collection::Coin;
+}
+
+void ColnectFetcher::readConfigHook(const KConfigGroup& config_) {
+  QString k = config_.readEntry("Locale", "en");
+  if(!k.isEmpty()) {
+    m_locale = k.toLower();
+  }
+  Q_ASSERT_X(m_locale.length() == 2, "ColnectFetcher::readConfigHook", "lang should be 2 char short iso");
+}
+
+void ColnectFetcher::search() {
+  m_started = true;
+  m_year.clear();
+
+  QUrl u(QString::fromLatin1(COLNECT_API_URL));
+  // Colnect API calls are encoded as a path
+  QString query(QLatin1Char('/') + m_locale);
+
+  QString value = request().value;
+  switch(request().key) {
+    case Keyword:
+      {
+        query += QStringLiteral("/list/cat/coins");
+        // pull out mint year, keep the regexp a little loose
+        QRegularExpression yearRX(QStringLiteral("[0-9]{4}"));
+        QRegularExpressionMatch match = yearRX.match(value);
+        if(match.hasMatch()) {
+          m_year = match.captured(0);
+          query += QStringLiteral("/mint_year/") + m_year;
+          value = value.remove(yearRX);
+        }
+      }
+      // everything left is for the item description
+      query += QStringLiteral("/description/") + value.simplified();
+      break;
+
+    case Raw:
+      query += QStringLiteral("/item/cat/coins/id/") + value;
+      break;
+
+    default:
+      myWarning() << "key not recognized:" << request().key;
+      stop();
+      return;
+  }
+
+  u.setPath(u.path() + query);
+//  myDebug() << "url:" << u;
+
+  m_job = KIO::storedGet(u, KIO::NoReload, KIO::HideProgressInfo);
+  KJobWidgets::setWindow(m_job, GUI::Proxy::widget());
+  connect(m_job.data(), &KJob::result, this, &ColnectFetcher::slotComplete);
+}
+
+void ColnectFetcher::stop() {
+  if(!m_started) {
+    return;
+  }
+  if(m_job) {
+    m_job->kill();
+    m_job = nullptr;
+  }
+  m_started = false;
+  emit signalDone(this);
+}
+
+Tellico::Data::EntryPtr ColnectFetcher::fetchEntryHook(uint uid_) {
+  Data::EntryPtr entry = m_entries.value(uid_);
+  if(!entry) {
+    myWarning() << "no entry in dict";
+    return Data::EntryPtr();
+  }
+
+  // if there's a colnect-id in the entry, need to fetch all the data
+  const QString id = entry->field(QStringLiteral("colnect-id"));
+  if(!id.isEmpty()) {
+    QUrl u(QString::fromLatin1(COLNECT_API_URL));
+    QString query(QLatin1Char('/') + m_locale + QStringLiteral("/item/cat/coins/id/") + id);
+    u.setPath(u.path() + query);
+//    myDebug() << "Reading item data from url:" << u;
+
+    QPointer<KIO::StoredTransferJob> job = KIO::storedGet(u, KIO::NoReload, KIO::HideProgressInfo);
+    KJobWidgets::setWindow(job, GUI::Proxy::widget());
+    if(!job->exec()) {
+      myDebug() << "Colnect item data:" << job->errorString() << u;
+      return entry;
+    }
+    const QByteArray data = job->data();
+    if(data.isEmpty()) {
+      myDebug() << "no colnect item data for" << u;
+      return entry;
+    }
+#if 0
+    myWarning() << "Remove item debug from colnectfetcher.cpp";
+    QFile file(QStringLiteral("/tmp/colnectitemtest.json"));
+    if(file.open(QIODevice::WriteOnly)) {
+      QTextStream t(&file);
+      t.setCodec("UTF-8");
+      t << data;
+    }
+    file.close();
+#endif
+    QJsonParseError jsonError;
+    QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError);
+    Q_ASSERT_X(!doc.isNull(), "colnect", jsonError.errorString().toUtf8().constData());
+    const QVariantList resultList = doc.array().toVariantList();
+    Q_ASSERT_X(!resultList.isEmpty(), "colnect", "no item results");
+    Q_ASSERT_X(static_cast<QMetaType::Type>(resultList.at(0).type()) == QMetaType::QString, "colnect",
+               "Weird single item result, first value is not a string");
+    populateEntry(entry, resultList);
+  }
+
+  // image might still be a URL only
+  QString image = entry->field(QStringLiteral("obverse"));
+  if(image.contains(QLatin1Char('/'))) {
+    const QString id = ImageFactory::addImage(QUrl::fromUserInput(image), true /* quiet */);
+    if(id.isEmpty()) {
+      message(i18n("The cover image could not be loaded."), MessageHandler::Warning);
+    }
+    // empty image ID is ok
+    entry->setField(QStringLiteral("obverse"), id);
+  }
+  // now the reverse image
+  image = entry->field(QStringLiteral("reverse"));
+  if(image.contains(QLatin1Char('/'))) {
+    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);
+  }
+
+  // don't want to include id
+  entry->setField(QStringLiteral("colnect-id"), QString());
+  return entry;
+}
+
+Tellico::Fetch::FetchRequest ColnectFetcher::updateRequest(Data::EntryPtr entry_) {
+  const QString title = entry_->field(QStringLiteral("title"));
+  if(!title.isEmpty()) {
+    return FetchRequest(Keyword, title);
+  }
+  return FetchRequest();
+}
+
+void ColnectFetcher::slotComplete(KJob* job_) {
+  KIO::StoredTransferJob* job = static_cast<KIO::StoredTransferJob*>(job_);
+
+  if(job->error()) {
+    job->uiDelegate()->showErrorMessage();
+    stop();
+    return;
+  }
+
+  const QByteArray data = 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 colnectfetcher.cpp";
+  QFile f(QStringLiteral("/tmp/colnecttest.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;
+  }
+  QVariantList resultList = doc.array().toVariantList();
+  if(resultList.isEmpty()) {
+    myDebug() << "no results";
+    stop();
+    return;
+  }
+
+  m_hasMoreResults = false; // for now, no continued searches
+
+  Data::CollPtr coll(new Data::CoinCollection(true));
+  // placeholder for colnect id, to be removed later
+  Data::FieldPtr f1(new Data::Field(QStringLiteral("colnect-id"), QString()));
+  coll->addField(f1);
+
+  const QString series(QStringLiteral("series"));
+  if(!coll->hasField(series) && optionalFields().contains(series)) {
+    Data::FieldPtr field(new Data::Field(series, i18n("Series")));
+    field->setCategory(i18n("General"));
+    coll->addField(field);
+  }
+
+  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);
+  }
+
+  const QString mintage(QStringLiteral("mintage"));
+  if(!coll->hasField(mintage) && optionalFields().contains(mintage)) {
+    Data::FieldPtr field(new Data::Field(mintage, i18n("Mintage"), Data::Field::Number));
+    field->setCategory(i18n("General"));
+    coll->addField(field);
+  }
+
+  // if the first item in the array is a string, probably a single item result, possibly from a Raw query
+  if(!resultList.isEmpty() &&
+     static_cast<QMetaType::Type>(resultList.at(0).type()) == QMetaType::QString) {
+    Data::EntryPtr entry(new Data::Entry(coll));
+    populateEntry(entry, resultList);
+
+    FetchResult* r = new FetchResult(Fetcher::Ptr(this), entry);
+    m_entries.insert(r->uid, entry);
+    emit signalResultFound(r);
+
+    stop();
+    return;
+  }
+
+  // here, we have multiple results to loop through
+//  myDebug() << "Reading" << resultList.size() << "results";
+  foreach(const QVariant& result, resultList) {
+    // be sure to check that the fetcher has not been stopped
+    // crashes can occur if not
+    if(!m_started) {
+      break;
+    }
+
+    Data::EntryPtr entry(new Data::Entry(coll));
+    //list action - returns array of [item_id,series_id,producer_id,front_picture_id, back_picture_id,item_description,catalog_codes,item_name]
+    const QVariantList values = result.toJsonArray().toVariantList();
+    entry->setField(QStringLiteral("colnect-id"), values.first().toString());
+    entry->setField(QStringLiteral("description"), values.last().toString());
+    entry->setField(QStringLiteral("year"), m_year);
+
+    FetchResult* r = new FetchResult(Fetcher::Ptr(this), entry);
+    m_entries.insert(r->uid, entry);
+    emit signalResultFound(r);
+  }
+
+  stop();
+}
+
+void ColnectFetcher::populateEntry(Data::EntryPtr entry_, const QVariantList& resultList_) {
+  if(m_colnectFields.isEmpty()) {
+    readDataList();
+    // set minimum size of list here
+    if(m_colnectFields.count() < 26) {
+      return;
+    }
+  }
+  if(resultList_.count() != m_colnectFields.count()) {
+    myDebug() << "field count mismatch! Got" << resultList_.count() << ", expected" << m_colnectFields.count();
+    return;
+  }
+
+  // lookup the field name for the list index
+  int idx = m_colnectFields.value(QStringLiteral("Issued on"), -1);
+
+  // the year may have already been set in the query term
+  if(m_year.isEmpty() && idx > -1) {
+    entry_->setField(QStringLiteral("year"), resultList_.at(idx).toString());
+  }
+
+  idx = m_colnectFields.value(QStringLiteral("Country"), -1);
+  if(idx > -1) {
+    entry_->setField(QStringLiteral("country"), resultList_.at(idx).toString());
+  }
+
+  idx = m_colnectFields.value(QStringLiteral("Currency"), -1);
+  if(idx > -1) {
+    entry_->setField(QStringLiteral("currency"), resultList_.at(idx).toString());
+    idx = m_colnectFields.value(QStringLiteral("FaceValue"), -1);
+    if(idx > -1) {
+      // bad assumption, but go with it. First char is currency symbol
+      QString currency = entry_->field(QStringLiteral("currency"));
+      if(!currency.isEmpty()) currency.truncate(1);
+      const double value = resultList_.at(idx).toDouble();
+      entry_->setField(QStringLiteral("denomination"),
+                       QLocale::system().toCurrencyString(value, currency));
+    }
+  }
+
+  idx = m_colnectFields.value(QStringLiteral("Series"), -1);
+  static const QString series(QStringLiteral("series"));
+  if(idx > -1 && optionalFields().contains(series)) {
+    entry_->setField(series, resultList_.at(idx).toString());
+  }
+
+  idx = m_colnectFields.value(QStringLiteral("Known mintage"), -1);
+  static const QString mintage(QStringLiteral("mintage"));
+  if(idx > -1 && optionalFields().contains(mintage)) {
+    entry_->setField(mintage, resultList_.at(idx).toString());
+  }
+
+  idx = m_colnectFields.value(QStringLiteral("Description"), -1);
+  static const QString desc(QStringLiteral("description"));
+  if(idx > -1 && optionalFields().contains(desc)) {
+    entry_->setField(desc, resultList_.at(idx).toString());
+  }
+
+  idx = m_colnectFields.value(QStringLiteral("FrontPicture"), -1);
+  if(idx  > -1 && optionalFields().contains(QStringLiteral("obverse"))) {
+    entry_->setField(QStringLiteral("obverse"),
+                     imageUrl(resultList_.at(0).toString(),
+                              resultList_.at(idx).toString()));
+  }
+
+  idx = m_colnectFields.value(QStringLiteral("BackPicture"), -1);
+  if(idx  > -1 && optionalFields().contains(QStringLiteral("reverse"))) {
+    entry_->setField(QStringLiteral("reverse"),
+                     imageUrl(resultList_.at(0).toString(),
+                              resultList_.at(idx).toString()));
+  }
+}
+
+Tellico::Fetch::ConfigWidget* ColnectFetcher::configWidget(QWidget* parent_) const {
+  return new ColnectFetcher::ConfigWidget(parent_, this);
+}
+
+QString ColnectFetcher::defaultName() {
+  return QStringLiteral("Colnect"); // no translation
+}
+
+QString ColnectFetcher::defaultIcon() {
+  return favIcon("https://colnect.com");
+}
+
+Tellico::StringHash ColnectFetcher::allOptionalFields() {
+  StringHash hash;
+  // treat images as optional since Colnect doesn't break out different images for each year
+  hash[QStringLiteral("obverse")] = i18n("Obverse");
+  hash[QStringLiteral("reverse")] = i18n("Reverse");
+  hash[QStringLiteral("series")] = i18n("Series");
+  /* TRANSLATORS: Mintage refers to the number of coins minted */
+  hash[QStringLiteral("mintage")] = i18n("Mintage");
+  hash[QStringLiteral("description")] = i18n("Description");
+  return hash;
+}
+
+// Colnect specific method of turning name text into a slug
+//  $str = html_entity_decode($str, ENT_QUOTES, 'UTF-8');
+//  $str = preg_replace('/&[^;]+;/', '_', $str); # change HTML elements to underscore
+//  $str = str_replace(array('.', '"', '>', '<', '\\', ':', '/', '?', '#', '[', ']', '@', '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '='), '', $str);
+//  $str = preg_replace('/[\s_]+/', '_', $str); # any space sequence becomes a single underscore
+//  $str = trim($str, '_'); # trim underscores
+QString ColnectFetcher::URLize(const QString& name_) {
+  QString slug = name_;
+  static const QString underscore(QStringLiteral("_"));
+  static const QRegularExpression htmlElements(QStringLiteral("&[^;]+;"));
+  static const QRegularExpression toRemove(QStringLiteral("[.\"><\\:/?#\\[\\]@!$&'()*+,;=]"));
+  static const QRegularExpression spaces(QStringLiteral("\\s"));
+  slug.replace(htmlElements, underscore);
+  slug.remove(toRemove);
+  slug.replace(spaces, underscore);
+  while(slug.startsWith(underscore)) slug = slug.mid(1);
+  while(slug.endsWith(underscore)) slug.chop(1);
+  return slug;
+}
+
+QString ColnectFetcher::imageUrl(const QString& name_, const QString& id_) {
+  const QString nameSlug = URLize(name_);
+  const int id = id_.toInt();
+  QUrl u(QString::fromLatin1(COLNECT_IMAGE_URL));
+  // uses 't' for thumbnail, use 'f' for full-size
+  u.setPath(QString::fromLatin1("/t/%1/%2/%3.jpg")
+                           .arg(id / 1000)
+                           .arg(id % 1000, 3, 10, QLatin1Char('0'))
+                           .arg(nameSlug));
+//  myDebug() << "Image url:" << u;
+  return u.toString();
+}
+
+void ColnectFetcher::readDataList() {
+//  myDebug() << "Reading Colnect fields";
+  QUrl u(QString::fromLatin1(COLNECT_API_URL));
+  // Colnect API calls are encoded as a path
+  QString query(QLatin1Char('/') + m_locale + QStringLiteral("/fields/cat/coins/"));
+  u.setPath(u.path() + query);
+
+//  myDebug() << "Reading" << u;
+  const QByteArray data = FileHandler::readDataFile(u, true);
+  QJsonDocument doc = QJsonDocument::fromJson(data);
+  if(doc.isNull()) {
+    myDebug() << "null JSON document in colnect fields";
+    return;
+  }
+  QVariantList resultList = doc.array().toVariantList();
+  if(resultList.isEmpty()) {
+    myDebug() << "no colnect field results";
+    return;
+  }
+  m_colnectFields.clear();
+  for(int i = 0; i < resultList.size(); ++i) {
+    m_colnectFields.insert(resultList.at(i).toString(), i);
+//    if(i == 5) myDebug() << m_colnectFields;
+  }
+//  myDebug() << "Number of Colnect fields:" << m_colnectFields.count();
+}
+
+ColnectFetcher::ConfigWidget::ConfigWidget(QWidget* parent_, const ColnectFetcher* fetcher_)
+    : Fetch::ConfigWidget(parent_) {
+  QGridLayout* l = new QGridLayout(optionsWidget());
+  l->setSpacing(4);
+  l->setColumnStretch(1, 10);
+
+  int row = -1;
+
+  QLabel* label = new QLabel(i18n("Language: "), optionsWidget());
+  l->addWidget(label, ++row, 0);
+  m_langCombo = new GUI::ComboBox(optionsWidget());
+
+#define LANG_ITEM(NAME, CY, ISO) \
+  m_langCombo->addItem(QIcon(QStandardPaths::locate(QStandardPaths::GenericDataLocation,                       \
+                                                    QStringLiteral("kf5/locale/countries/" CY "/flag.png"))), \
+                       i18nc("Language", NAME),                                                                \
+                       QLatin1String(ISO));
+  LANG_ITEM("English", "us", "en");
+  LANG_ITEM("French",  "fr", "fr");
+  LANG_ITEM("German",  "de", "de");
+  LANG_ITEM("Spanish", "es", "es");
+#undef LANG_ITEM
+
+  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(ColnectFetcher::allOptionalFields(), fetcher_ ? fetcher_->optionalFields() : QStringList());
+
+  if(fetcher_) {
+    m_langCombo->setCurrentData(fetcher_->m_locale);
+  }
+}
+
+void ColnectFetcher::ConfigWidget::saveConfigHook(KConfigGroup& config_) {
+  const QString lang = m_langCombo->currentData().toString();
+  config_.writeEntry("Locale", lang);
+}
+
+QString ColnectFetcher::ConfigWidget::preferredName() const {
+  return QString::fromLatin1("Colnect (%1)").arg(m_langCombo->currentText());
+}
+
+void ColnectFetcher::ConfigWidget::slotLangChanged() {
+  emit signalName(preferredName());
+}
diff --git a/src/fetch/colnectfetcher.h b/src/fetch/colnectfetcher.h
new file mode 100644
index 00000000..19764f4b
--- /dev/null
+++ b/src/fetch/colnectfetcher.h
@@ -0,0 +1,132 @@
+/***************************************************************************
+    Copyright (C) 2019 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_COLNECTFETCHER_H
+#define TELLICO_COLNECTFETCHER_H
+
+#include "fetcher.h"
+#include "configwidget.h"
+#include "../datavectors.h"
+
+#include <QPointer>
+#include <QVariantMap>
+
+class KJob;
+namespace KIO {
+  class StoredTransferJob;
+}
+
+class ColnectFetcherTest;
+namespace Tellico {
+
+  namespace GUI {
+    class ComboBox;
+  }
+
+  namespace Fetch {
+
+/**
+ * A fetcher for colnect.org
+ *
+ * @author Robby Stephenson
+ */
+class ColnectFetcher : public Fetcher {
+Q_OBJECT
+
+friend class ::ColnectFetcherTest;
+
+public:
+  /**
+   */
+  ColnectFetcher(QObject* parent);
+  /**
+   */
+  virtual ~ColnectFetcher();
+
+  /**
+   */
+  virtual QString source() const Q_DECL_OVERRIDE;
+  virtual QString attribution() const Q_DECL_OVERRIDE;
+  virtual bool isSearching() const Q_DECL_OVERRIDE { return m_started; }
+  virtual bool canSearch(FetchKey k) const Q_DECL_OVERRIDE;
+  virtual void stop() Q_DECL_OVERRIDE;
+  virtual Data::EntryPtr fetchEntryHook(uint uid) Q_DECL_OVERRIDE;
+  virtual Type type() const Q_DECL_OVERRIDE { return Colnect; }
+  virtual bool canFetch(int type) const Q_DECL_OVERRIDE;
+  virtual void readConfigHook(const KConfigGroup& config) Q_DECL_OVERRIDE;
+
+  /**
+   * 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:
+  static QString URLize(const QString& name);
+  static QString imageUrl(const QString& name, const QString& id);
+
+  virtual void search() Q_DECL_OVERRIDE;
+  virtual FetchRequest updateRequest(Data::EntryPtr entry) Q_DECL_OVERRIDE;
+  void populateEntry(Data::EntryPtr entry, const QVariantList& resultList);
+
+  void readDataList();
+
+  QHash<uint, Data::EntryPtr> m_entries;
+
+  bool m_started;
+  QString m_locale;
+  QPointer<KIO::StoredTransferJob> m_job;
+  QString m_year;
+
+  // map from field name to position in result list
+  QHash<QString, int> m_colnectFields;
+};
+
+class ColnectFetcher::ConfigWidget : public Fetch::ConfigWidget {
+Q_OBJECT
+
+public:
+  explicit ConfigWidget(QWidget* parent_, const ColnectFetcher* fetcher = nullptr);
+  virtual void saveConfigHook(KConfigGroup&) Q_DECL_OVERRIDE;
+  virtual QString preferredName() const Q_DECL_OVERRIDE;
+
+private Q_SLOTS:
+  void slotLangChanged();
+
+private:
+  GUI::ComboBox* m_langCombo;
+};
+
+  } // end namespace
+} // end namespace
+#endif
diff --git a/src/fetch/fetch.h b/src/fetch/fetch.h
index de679d59..22d0878b 100644
--- a/src/fetch/fetch.h
+++ b/src/fetch/fetch.h
@@ -103,7 +103,8 @@ enum Type {
   Kino,
   MobyGames,
   ComicVine,
-  KinoTeatr
+  KinoTeatr,
+  Colnect
 };
 
   }
diff --git a/src/fetch/fetcherinitializer.cpp b/src/fetch/fetcherinitializer.cpp
index 21edf629..d46ca09a 100644
--- a/src/fetch/fetcherinitializer.cpp
+++ b/src/fetch/fetcherinitializer.cpp
@@ -71,6 +71,7 @@
 #include "mobygamesfetcher.h"
 #include "comicvinefetcher.h"
 #include "kinoteatrfetcher.h"
+#include "colnectfetcher.h"
 
 /**
  * Ideally, I'd like these initializations to be in each cpp file for each collection type
@@ -127,6 +128,7 @@ Tellico::Fetch::FetcherInitializer::FetcherInitializer() {
   RegisterFetcher<Fetch::MobyGamesFetcher> registerMobyGames(MobyGames);
   RegisterFetcher<Fetch::ComicVineFetcher> registerComicVine(ComicVine);
   RegisterFetcher<Fetch::KinoTeatrFetcher> registerTeatr(KinoTeatr);
+  RegisterFetcher<Fetch::ColnectFetcher> registerColnect(Colnect);
 
   Fetch::Manager::self()->loadFetchers();
 }
diff --git a/src/fetch/fetchresult.cpp b/src/fetch/fetchresult.cpp
index 1f720a8e..df76ff47 100644
--- a/src/fetch/fetchresult.cpp
+++ b/src/fetch/fetchresult.cpp
@@ -115,6 +115,11 @@ QString FetchResult::makeDescription(Data::EntryPtr entry) {
       append(desc, entry, "appellation");
       break;
 
+    case Data::Collection::Coin:
+      append(desc, entry, "country");
+      append(desc, entry, "description");
+      break;
+
     default:
       myDebug() << "no description for collection type =" << entry->collection()->type();
       break;
diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt
index 394a2b68..12c1fbe2 100644
--- a/src/tests/CMakeLists.txt
+++ b/src/tests/CMakeLists.txt
@@ -518,6 +518,14 @@ add_test(comicvinefetchertest comicvinefetchertest)
 ecm_mark_as_test(comicvinefetchertest)
 TARGET_LINK_LIBRARIES(comicvinefetchertest fetcherstest ${TELLICO_TEST_LIBS})
 
+add_executable(colnectfetchertest colnectfetchertest.cpp abstractfetchertest.cpp
+  ../fetch/colnectfetcher.cpp
+)
+ecm_mark_nongui_executable(colnectfetchertest)
+add_test(colnectfetchertest colnectfetchertest)
+ecm_mark_as_test(colnectfetchertest)
+TARGET_LINK_LIBRARIES(colnectfetchertest fetcherstest ${TELLICO_TEST_LIBS})
+
 add_executable(crossreffetchertest crossreffetchertest.cpp abstractfetchertest.cpp
   ../fetch/crossreffetcher.cpp
 )
diff --git a/src/tests/colnectfetchertest.cpp b/src/tests/colnectfetchertest.cpp
new file mode 100644
index 00000000..bdf7db88
--- /dev/null
+++ b/src/tests/colnectfetchertest.cpp
@@ -0,0 +1,130 @@
+/***************************************************************************
+    Copyright (C) 2019 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 "colnectfetchertest.h"
+
+#include "../fetch/colnectfetcher.h"
+#include "../entry.h"
+#include "../collections/coincollection.h"
+#include "../collectionfactory.h"
+#include "../images/imagefactory.h"
+#include "../fieldformat.h"
+#include "../fetch/fetcherjob.h"
+
+#include <KConfig>
+#include <KConfigGroup>
+
+#include <QTest>
+
+QTEST_GUILESS_MAIN( ColnectFetcherTest )
+
+ColnectFetcherTest::ColnectFetcherTest() : AbstractFetcherTest() {
+}
+
+void ColnectFetcherTest::initTestCase() {
+  Tellico::ImageFactory::init();
+  Tellico::RegisterCollection<Tellico::Data::CoinCollection> registerMe(Tellico::Data::Collection::Coin, "coin");
+}
+
+void ColnectFetcherTest::testSlug() {
+  // test the implementation of the Colnect slug derivation
+  QFETCH(QString, input);
+  QFETCH(QString, slug);
+
+  QCOMPARE(Tellico::Fetch::ColnectFetcher::URLize(input), slug);
+}
+
+void ColnectFetcherTest::testSlug_data() {
+  QTest::addColumn<QString>("input");
+  QTest::addColumn<QString>("slug");
+
+  QTest::newRow("basic") << QStringLiteral("input") << QStringLiteral("input");
+  QTest::newRow("Aus1$") << QStringLiteral("1 Dollar (50 Years Moonlanding)") << QStringLiteral("1_Dollar_50_Years_Moonlanding");
+}
+
+void ColnectFetcherTest::testRaw() {
+  KConfig config(QFINDTESTDATA("tellicotest.config"), KConfig::SimpleConfig);
+  QString groupName = QStringLiteral("colnect");
+  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::Raw,
+                                       QStringLiteral("147558"));
+  Tellico::Fetch::Fetcher::Ptr fetcher(new Tellico::Fetch::ColnectFetcher(this));
+  fetcher->readConfig(cg, cg.name());
+
+  Tellico::Data::EntryList results = DO_FETCH1(fetcher, request, 1);
+
+  QCOMPARE(results.size(), 1);
+  Tellico::Data::EntryPtr entry = results.at(0);
+
+  QCOMPARE(entry->field(QStringLiteral("year")), QStringLiteral("2019"));
+  QCOMPARE(entry->field(QStringLiteral("country")), QStringLiteral("Australia"));
+  QCOMPARE(entry->field(QStringLiteral("denomination")), QStringLiteral("$1.00"));
+  QCOMPARE(entry->field(QStringLiteral("currency")), QStringLiteral("$ - Australian dollar"));
+  QCOMPARE(entry->field(QStringLiteral("series")), QStringLiteral("1952~Today - Elizabeth II"));
+  QCOMPARE(entry->field(QStringLiteral("mintage")), QStringLiteral("25000"));
+  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 ColnectFetcherTest::testSacagawea() {
+  KConfig config(QFINDTESTDATA("tellicotest.config"), KConfig::SimpleConfig);
+  QString groupName = QStringLiteral("colnect");
+  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("2007 Sacagawea"));
+  Tellico::Fetch::Fetcher::Ptr fetcher(new Tellico::Fetch::ColnectFetcher(this));
+  fetcher->readConfig(cg, cg.name());
+
+  Tellico::Data::EntryList results = DO_FETCH1(fetcher, request, 1);
+
+  QCOMPARE(results.size(), 1);
+  Tellico::Data::EntryPtr entry = results.at(0);
+
+  QCOMPARE(entry->field(QStringLiteral("year")), QStringLiteral("2007"));
+  QCOMPARE(entry->field(QStringLiteral("country")), QStringLiteral("United States of America"));
+  QCOMPARE(entry->field(QStringLiteral("denomination")), QStringLiteral("$1.00"));
+  QCOMPARE(entry->field(QStringLiteral("currency")), QStringLiteral("$ - United States dollar"));
+  QCOMPARE(entry->field(QStringLiteral("series")), QStringLiteral("B06a - Eisenhower, Anthony & Sacagawea Dollar"));
+  QCOMPARE(entry->field(QStringLiteral("mintage")), QStringLiteral("1497251077"));
+  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('/')));
+}
diff --git a/src/fetch/fetch.h b/src/tests/colnectfetchertest.h
similarity index 57%
copy from src/fetch/fetch.h
copy to src/tests/colnectfetchertest.h
index de679d59..40a27436 100644
--- a/src/fetch/fetch.h
+++ b/src/tests/colnectfetchertest.h
@@ -1,5 +1,5 @@
 /***************************************************************************
-    Copyright (C) 2003-2009 Robby Stephenson <robby at periapsis.org>
+    Copyright (C) 2019 Robby Stephenson <robby at periapsis.org>
  ***************************************************************************/
 
 /***************************************************************************
@@ -22,91 +22,22 @@
  *                                                                         *
  ***************************************************************************/
 
-#ifndef TELLICO_FETCH_H
-#define TELLICO_FETCH_H
+#ifndef COLNECTFETCHERTEST_H
+#define COLNECTFETCHERTEST_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 ColnectFetcherTest : public AbstractFetcherTest {
+Q_OBJECT
+public:
+  ColnectFetcherTest();
 
-// 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
+private Q_SLOTS:
+  void initTestCase();
+  void testSlug();
+  void testSlug_data();
+  void testRaw();
+  void testSacagawea();
 };
 
-  }
-}
-
 #endif
diff --git a/src/tests/tellicotest.config b/src/tests/tellicotest.config
index 61f85822..c742360d 100644
--- a/src/tests/tellicotest.config
+++ b/src/tests/tellicotest.config
@@ -75,3 +75,6 @@ Custom Fields=comicvine,colorist
 
 [kinoteatr]
 Custom Fields=origtitle,kinoteatr
+
+[colnect]
+Custom Fields=obverse,reverse,series,mintage,description


More information about the kde-doc-english mailing list