Git commit bb38ab07f6041e4ea05281285cf578ab2f38fdea by Robby Stephenson.
Committed on 03/09/2026 at 01:57.
Pushed by rstephenson into branch 'master'.

Add Hardcover data source

M  +12   -4    doc/configuration.docbook
M  +2    -0    src/fetch/CMakeLists.txt
M  +2    -1    src/fetch/fetch.h
M  +2    -0    src/fetch/fetcherinitializer.cpp
M  +2    -2    src/fetch/goodreadsfetcher.h
A  +377  -0    src/fetch/hardcoverfetcher.cpp     [License: GPL (v2/3)]
C  +44   -22   src/fetch/hardcoverfetcher.h [from: src/fetch/goodreadsfetcher.h 
- 066% similarity]
C  +49   -105  src/fetch/hardcoverfetcher_query.cpp [from: src/fetch/fetch.h - 
051% similarity]
M  +7    -0    src/tests/CMakeLists.txt
A  +74   -0    src/tests/hardcoverfetchertest.cpp     [License: GPL (v2/3)]
C  +16   -104  src/tests/hardcoverfetchertest.h [from: src/fetch/fetch.h - 051% 
similarity]

https://invent.kde.org/office/tellico/-/commit/bb38ab07f6041e4ea05281285cf578ab2f38fdea

diff --git a/doc/configuration.docbook b/doc/configuration.docbook
index b549c62f3..3daa88fb3 100644
--- a/doc/configuration.docbook
+++ b/doc/configuration.docbook
@@ -162,6 +162,7 @@ while the full list is <ulink 
url="https://tellico-project.org/data-sources";>ava
 <!-- books -->
 <listitem><simpara><link linkend="amazon-web-services">Amazon.com Web 
Services</link>,</simpara></listitem>
 <listitem><simpara><link 
linkend="isbndb">ISBNdb.com</link>,</simpara></listitem>
+<listitem><simpara><link 
linkend="hardcover">Hardcover</link>,</simpara></listitem>
 <listitem><simpara><link 
linkend="openlibrary">OpenLibrary.org</link>,</simpara></listitem>
 <listitem><simpara><link linkend="opds">OPDS 
catalogs</link>,</simpara></listitem>
 <!-- movies -->
@@ -291,10 +292,17 @@ SRU request by the POST method, if 
<userinput>x-tellico-method</userinput>=<user
 </sect3>
 
 <sect3 id="isbndb">
-<title>ISBNdb.com</title>
-<para>
-<ulink url="http://isbndb.com";>ISBNdb.com</ulink> is an online book database, 
from libraries around the world.
-</para>
+  <title>ISBNdb.com</title>
+  <para>
+    <ulink url="http://isbndb.com";>ISBNdb.com</ulink> is an online book 
database, from libraries around the world.
+  </para>
+</sect3>
+
+<sect3 id="hardcover">
+  <title>Hardcover</title>
+  <para>
+    <ulink url="https://hardcover.app";>Hardcover</ulink> is a social network 
and reading tracker.
+  </para>
 </sect3>
 
 <sect3 id="openlibrary">
diff --git a/src/fetch/CMakeLists.txt b/src/fetch/CMakeLists.txt
index 14e07a2f8..648be3a2e 100644
--- a/src/fetch/CMakeLists.txt
+++ b/src/fetch/CMakeLists.txt
@@ -39,6 +39,8 @@ set(fetch_STAT_SRCS
     goodreadsfetcher.cpp
     googlebookfetcher.cpp
     googlescholarfetcher.cpp
+    hardcoverfetcher.cpp
+    hardcoverfetcher_query.cpp
     hathitrustfetcher.cpp
     ibsfetcher.cpp
     igdbfetcher.cpp
diff --git a/src/fetch/fetch.h b/src/fetch/fetch.h
index 0bdbaa878..24667fefa 100644
--- a/src/fetch/fetch.h
+++ b/src/fetch/fetch.h
@@ -124,7 +124,8 @@ enum Type {
   ISFDB,
   Metron,
   DOIorg,
-  Goodreads
+  Goodreads,
+  Hardcover
 };
 
   }
diff --git a/src/fetch/fetcherinitializer.cpp b/src/fetch/fetcherinitializer.cpp
index eb13ca92b..207f53e94 100644
--- a/src/fetch/fetcherinitializer.cpp
+++ b/src/fetch/fetcherinitializer.cpp
@@ -84,6 +84,7 @@
 #include "metronfetcher.h"
 #include "doifetcher.h"
 #include "goodreadsfetcher.h"
+#include "hardcoverfetcher.h"
 
 /**
  * Ideally, I'd like these initializations to be in each cpp file for each 
collection type
@@ -145,6 +146,7 @@ Tellico::Fetch::FetcherInitializer::FetcherInitializer() {
   RegisterFetcher<Fetch::ISFDBFetcher> registerISFDB(ISFDB);
   RegisterFetcher<Fetch::MetronFetcher> registerMetron(Metron);
   RegisterFetcher<Fetch::GoodreadsFetcher> registerGoodreads(Goodreads);
+  RegisterFetcher<Fetch::HardcoverFetcher> registerHardcover(Hardcover);
 
 // these data sources depend on being able to import bibtex
 #ifdef ENABLE_BTPARSE
diff --git a/src/fetch/goodreadsfetcher.h b/src/fetch/goodreadsfetcher.h
index 24a992cb4..6f23d9169 100644
--- a/src/fetch/goodreadsfetcher.h
+++ b/src/fetch/goodreadsfetcher.h
@@ -22,8 +22,8 @@
  *                                                                         *
  ***************************************************************************/
 
-#ifndef TELLICO_GoodreadsFetcher_H
-#define TELLICO_GoodreadsFetcher_H
+#ifndef TELLICO_GOODREADSFETCHER_H
+#define TELLICO_GOODREADSFETCHER_H
 
 #include "xmlfetcher.h"
 #include "configwidget.h"
diff --git a/src/fetch/hardcoverfetcher.cpp b/src/fetch/hardcoverfetcher.cpp
new file mode 100644
index 000000000..910b54390
--- /dev/null
+++ b/src/fetch/hardcoverfetcher.cpp
@@ -0,0 +1,377 @@
+/***************************************************************************
+    Copyright (C) 2026 Robby Stephenson <[email protected]>
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   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 "hardcoverfetcher.h"
+#include "../collections/bookcollection.h"
+#include "../images/imagefactory.h"
+#include "../utils/guiproxy.h"
+#include "../utils/string_utils.h"
+#include "../utils/tellico_utils.h"
+#include "../utils/objvalue.h"
+#include "../utils/isbnvalidator.h"
+#include "../core/tellico_strings.h"
+#include "../tellico_debug.h"
+
+#include <KLocalizedString>
+#include <KConfigGroup>
+#include <KJob>
+#include <KJobUiDelegate>
+#include <KJobWidgets>
+#include <KIO/StoredTransferJob>
+#include <KLanguageName>
+
+#include <QLabel>
+#include <QFile>
+#include <QTextStream>
+#include <QVBoxLayout>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QJsonArray>
+#include <QUrlQuery>
+#include <QLineEdit>
+
+namespace {
+  static const int HARDCOVER_MAX_RETURNS_TOTAL = 20;
+  static const char* HARDCOVER_API_URL = 
"https://api.hardcover.app/v1/graphql";;
+}
+
+using namespace Tellico;
+using Tellico::Fetch::HardcoverFetcher;
+using namespace Qt::Literals::StringLiterals;
+
+HardcoverFetcher::HardcoverFetcher(QObject* parent_)
+    : Fetcher(parent_)
+    , m_started(false) {
+}
+
+HardcoverFetcher::~HardcoverFetcher() = default;
+
+QString HardcoverFetcher::source() const {
+  return m_name.isEmpty() ? defaultName() : m_name;
+}
+
+QString HardcoverFetcher::attribution() const {
+  return TC_I18N3(providedBy, QStringLiteral("https://hardcover.app";), 
defaultName());
+}
+
+bool HardcoverFetcher::canSearch(Fetch::FetchKey k) const {
+  return k == ISBN;
+}
+
+bool HardcoverFetcher::canFetch(int type) const {
+  return type == Data::Collection::Book;
+}
+
+void HardcoverFetcher::readConfigHook(const KConfigGroup& config_) {
+  m_apiKey = config_.readEntry("API Key");
+}
+
+void HardcoverFetcher::search() {
+  continueSearch();
+}
+
+void HardcoverFetcher::continueSearch() {
+  m_started = true;
+
+  if(m_apiKey.isEmpty()) {
+    myDebug() << source() << "- empty API key";
+    message(i18n("An access key is required to use this data source.")
+            + QLatin1Char(' ') +
+            i18n("Those values must be entered in the data source settings."), 
MessageHandler::Error);
+    stop();
+    return;
+  }
+
+  QString operationName, query;
+  QJsonObject vars;
+  switch(request().key()) {
+    case ISBN:
+      operationName = QStringLiteral("GetBookByISBN");
+      query = isbnQuery();
+      {
+        QJsonArray isbns;
+        const auto valueList = FieldFormat::splitValue(request().value());
+        for(auto value : valueList) {
+          value.remove(QLatin1Char('-'));
+          isbns += value;
+        }
+        vars.insert(QLatin1String("isbns"), isbns);
+      }
+      break;
+
+    case Raw:
+    default:
+      myWarning() << source() << "- key not recognized:" << request().key();
+      stop();
+      return;
+  }
+
+  QJsonObject payload;
+  payload.insert(QLatin1String("operationName"), operationName);
+  payload.insert(QLatin1String("query"), query);
+  payload.insert(QLatin1String("variables"), vars);
+
+  m_job = KIO::storedHttpPost(QJsonDocument(payload).toJson(),
+                              QUrl(QString::fromLatin1(HARDCOVER_API_URL)),
+                              KIO::HideProgressInfo);
+  configureJob(m_job);
+  connect(m_job.data(), &KJob::result, this, &HardcoverFetcher::slotComplete);
+}
+
+void HardcoverFetcher::stop() {
+  if(!m_started) {
+    return;
+  }
+  if(m_job) {
+    m_job->kill();
+    m_job = nullptr;
+  }
+  m_started = false;
+  Q_EMIT signalDone(this);
+}
+
+Tellico::Fetch::FetchRequest HardcoverFetcher::updateRequest(Data::EntryPtr 
entry_) {
+  const QString isbn = entry_->field(QStringLiteral("isbn"));
+  if(!isbn.isEmpty()) {
+    return FetchRequest(ISBN, isbn);
+  }
+  return FetchRequest();
+}
+
+void HardcoverFetcher::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() << "Hardcover: 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 1
+  myWarning() << "Remove debug from hardcoverfetcher.cpp";
+  QFile f(QStringLiteral("/tmp/test-hardcover.json"));
+  if(f.open(QIODevice::WriteOnly)) {
+    QTextStream t(&f);
+    t << data;
+  }
+  f.close();
+#endif
+
+  Data::CollPtr coll(new Data::BookCollection(true));
+  QJsonDocument doc = QJsonDocument::fromJson(data);
+
+  if(doc.isNull()) {
+    myDebug() << job->errorString();
+    message(job->errorString(), MessageHandler::Error);
+    stop();
+  }
+  const auto docObj = doc.object();
+  const auto results = docObj["data"_L1]["editions"_L1].toArray();
+  const auto errors = docObj["errors"_L1].toArray();
+
+  if(results.isEmpty()) {
+    if(errors.isEmpty()) {
+      myLog() << "No results";
+    } else {
+      QStringList msgList;
+      for(const QJsonValue& error : errors) {
+        msgList += error["message"_L1].toString();
+        myDebug() << error["message"_L1].toString();
+      }
+      if(!msgList.isEmpty()) {
+        message(msgList.join("\n"_L1), MessageHandler::Error);
+      }
+    }
+    stop();
+    return;
+  }
+
+  int count = 0;
+  for(const QJsonValue& result : results) {
+    Data::EntryPtr entry(new Data::Entry(coll));
+    populateEntry(entry, result.toObject());
+
+    FetchResult* r = new FetchResult(this, entry);
+    m_entries.insert(r->uid, entry);
+    Q_EMIT signalResultFound(r);
+    ++count;
+    if(count >= HARDCOVER_MAX_RETURNS_TOTAL) {
+      break;
+    }
+  }
+
+  stop();
+}
+
+Tellico::Data::EntryPtr HardcoverFetcher::fetchEntryHook(uint uid_) {
+  Data::EntryPtr entry = m_entries.value(uid_);
+  if(!entry) {
+    myWarning() << "no entry in dict";
+    return Data::EntryPtr();
+  }
+
+  const QString isbnName = QStringLiteral("isbn");
+  QString isbn = entry->field(isbnName);
+  ISBNValidator val;
+  val.fixup(isbn);
+  entry->setField(isbnName, isbn);
+
+  // image might still be a URL
+  const QString image_id = entry->field(QStringLiteral("cover"));
+  if(image_id.contains(QLatin1Char('/'))) {
+    const QString id = ImageFactory::addImage(QUrl::fromUserInput(image_id), 
true /* quiet */);
+    if(id.isEmpty()) {
+      message(i18n("The cover image could not be loaded."), 
MessageHandler::Warning);
+    }
+    // empty image ID is ok
+    entry->setField(QStringLiteral("cover"), id);
+  }
+  return entry;
+}
+
+void HardcoverFetcher::populateEntry(Data::EntryPtr entry_, const QJsonObject& 
obj_) {
+  entry_->setField(QStringLiteral("title"), objValue(obj_, "title"));
+  entry_->setField(QStringLiteral("subtitle"), objValue(obj_, "subtitle"));
+  entry_->setField(QStringLiteral("pub_year"), objValue(obj_, 
"release_date").left(4));
+  entry_->setField(QStringLiteral("pages"), objValue(obj_, "pages"));
+  entry_->setField(QStringLiteral("language"), objValue(obj_, "language", 
"language"));
+  QString binding = objValue(obj_, "edition_format");
+  if(binding.contains(QLatin1StringView("Paperback"))) {
+    binding = i18n("Paperback");
+  } else if(binding == QLatin1StringView("Hardcover")) {
+    binding = i18n("Hardback");
+  } else {
+    binding = i18n(binding.toUtf8().constData());
+  }
+  entry_->setField(QStringLiteral("binding"), binding);
+  entry_->setField(QStringLiteral("publisher"), objValue(obj_, "publisher", 
"name"));
+
+  QString isbn = objValue(obj_, "isbn_10");
+  if(isbn.isEmpty()) {
+    isbn = objValue(obj_, "isbn_13");
+  }
+  entry_->setField(QStringLiteral("isbn"), isbn);
+
+  QString cover = objValue(obj_, "image", "url");
+  if(cover.isEmpty()) {
+    cover = objValue(obj_, "book", "image", "url");
+  }
+  entry_->setField(QStringLiteral("cover"), cover);
+
+  QStringList authors;
+  auto list = obj_["book"_L1]["contributions"_L1].toArray();
+  for(const auto& person: std::as_const(list)) {
+    auto obj = person.toObject();
+    const auto role = objValue(obj, "contribution");
+    if(role.isEmpty() || role == "Author"_L1) {
+      authors += objValue(obj, "author", "name");
+    }
+  }
+  entry_->setField(QStringLiteral("author"), 
authors.join(FieldFormat::delimiterString()));
+
+  entry_->setField(QStringLiteral("comments"), objValue(obj_, "book", 
"description"));
+}
+
+void HardcoverFetcher::configureJob(QPointer<KIO::StoredTransferJob> job_) {
+  KJobWidgets::setWindow(job_, GUI::Proxy::widget());
+  job_->addMetaData(QStringLiteral("accept"), 
QStringLiteral("application/json"));
+  job_->addMetaData(QStringLiteral("customHTTPHeader"), 
QStringLiteral("Authorization: Bearer ") + m_apiKey);
+  Tellico::addUserAgent(job_);
+}
+
+Tellico::Fetch::ConfigWidget* HardcoverFetcher::configWidget(QWidget* parent_) 
const {
+  return new HardcoverFetcher::ConfigWidget(parent_, this);
+}
+
+QString HardcoverFetcher::defaultName() {
+  return QStringLiteral("Hardcover");
+}
+
+QString HardcoverFetcher::defaultIcon() {
+  return favIcon("https://hardcover.app";);
+}
+
+Tellico::StringHash HardcoverFetcher::allOptionalFields() {
+  StringHash hash;
+  return hash;
+}
+
+HardcoverFetcher::ConfigWidget::ConfigWidget(QWidget* parent_, const 
HardcoverFetcher* fetcher_)
+    : Fetch::ConfigWidget(parent_) {
+  QGridLayout* l = new QGridLayout(optionsWidget());
+  l->setSpacing(4);
+  l->setColumnStretch(1, 10);
+
+  int row = -1;
+
+  QLabel* al = new QLabel(i18n("Registration is required for accessing this 
data source. "
+                               "If you agree to the terms and conditions, <a 
href='%1'>sign "
+                               "up for an account</a>, and enter your 
information below.",
+                               
QLatin1String("https://hardcover.app/account/api/keys/new?scope=read%3Acatalog";)),
+                          optionsWidget());
+  al->setOpenExternalLinks(true);
+  al->setWordWrap(true);
+  ++row;
+  l->addWidget(al, row, 0, 1, 2);
+  // richtext gets weird with size
+  al->setMinimumWidth(al->sizeHint().width());
+
+  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);
+
+  l->setRowStretch(++row, 10);
+
+  // now add additional fields widget
+  addFieldsWidget(HardcoverFetcher::allOptionalFields(), fetcher_ ? 
fetcher_->optionalFields() : QStringList());
+
+  if(fetcher_) {
+    m_apiKeyEdit->setText(fetcher_->m_apiKey);
+  }
+}
+
+QString HardcoverFetcher::ConfigWidget::preferredName() const {
+  return HardcoverFetcher::defaultName();
+}
+
+void HardcoverFetcher::ConfigWidget::saveConfigHook(KConfigGroup& config_) {
+  const QString apiKey = m_apiKeyEdit->text().trimmed();
+  if(!apiKey.isEmpty()) {
+    config_.writeEntry("API Key", apiKey);
+  }
+}
diff --git a/src/fetch/goodreadsfetcher.h b/src/fetch/hardcoverfetcher.h
similarity index 66%
copy from src/fetch/goodreadsfetcher.h
copy to src/fetch/hardcoverfetcher.h
index 24a992cb4..270a1edb9 100644
--- a/src/fetch/goodreadsfetcher.h
+++ b/src/fetch/hardcoverfetcher.h
@@ -22,72 +22,94 @@
  *                                                                         *
  ***************************************************************************/
 
-#ifndef TELLICO_GoodreadsFetcher_H
-#define TELLICO_GoodreadsFetcher_H
+#ifndef TELLICO_HARDCOVERFETCHER_H
+#define TELLICO_HARDCOVERFETCHER_H
 
-#include "xmlfetcher.h"
+#include "fetcher.h"
 #include "configwidget.h"
 #include "../datavectors.h"
 
+#include <QPointer>
+
 class QLineEdit;
+class KJob;
+namespace KIO {
+  class StoredTransferJob;
+}
 
 namespace Tellico {
-
   namespace Fetch {
 
 /**
- * A fetcher for goodreads.com
+ * A fetcher for thetvdb.com
  *
  * @author Robby Stephenson
  */
-class GoodreadsFetcher : public XMLFetcher {
+class HardcoverFetcher : public Fetcher {
 Q_OBJECT
 
 public:
   /**
    */
-  GoodreadsFetcher(QObject* parent);
+  HardcoverFetcher(QObject* parent);
   /**
    */
-  virtual ~GoodreadsFetcher();
+  virtual ~HardcoverFetcher();
 
   /**
    */
   virtual QString source() const override;
   virtual QString attribution() const override;
+  virtual bool isSearching() const override { return m_started; }
   virtual bool canSearch(FetchKey k) const override;
-  virtual bool canSearchMultiple() const override;
-  virtual Type type() const override { return Goodreads; }
+  virtual void stop() override;
+  virtual Data::EntryPtr fetchEntryHook(uint uid) override;
+  virtual Type type() const override { return Hardcover; }
   virtual bool canFetch(int type) const override;
   virtual void readConfigHook(const KConfigGroup& config) override;
+//  virtual void saveConfigHook(KConfigGroup& config) override;
+  virtual void continueSearch() override;
 
   /**
    * Returns a widget for modifying the fetcher's config.
    */
   virtual Fetch::ConfigWidget* configWidget(QWidget* parent) const override;
 
-  class ConfigWidget : public Fetch::ConfigWidget {
-  public:
-    explicit ConfigWidget(QWidget* parent_, const GoodreadsFetcher* fetcher = 
nullptr);
-    virtual void saveConfigHook(KConfigGroup&) override {}
-    virtual QString preferredName() const override;
-  private:
-    QLineEdit* m_apiKeyEdit;
-  };
+  class ConfigWidget;
   friend class ConfigWidget;
 
   static QString defaultName();
   static QString defaultIcon();
   static StringHash allOptionalFields();
 
+private Q_SLOTS:
+  void slotComplete(KJob* job);
+
 private:
+  static QString isbnQuery();
+
+  virtual void search() override;
   virtual FetchRequest updateRequest(Data::EntryPtr entry) override;
-  virtual void resetSearch() override {}
-  virtual QUrl searchUrl() override;
-  virtual void parseData(QByteArray&) override {}
-  virtual Data::EntryPtr fetchEntryHookData(Data::EntryPtr entry) override;
+  void populateEntry(Data::EntryPtr entry, const QJsonObject& result);
+
+  void configureJob(QPointer<KIO::StoredTransferJob> job);
+
+  bool m_started;
 
   QString m_apiKey;
+  QHash<uint, Data::EntryPtr> m_entries;
+  QPointer<KIO::StoredTransferJob> m_job;
+};
+
+class HardcoverFetcher::ConfigWidget : public Fetch::ConfigWidget {
+
+public:
+  explicit ConfigWidget(QWidget* parent_, const HardcoverFetcher* fetcher = 
nullptr);
+  virtual void saveConfigHook(KConfigGroup&) override;
+  virtual QString preferredName() const override;
+
+private:
+  QLineEdit* m_apiKeyEdit;
 };
 
   } // end namespace
diff --git a/src/fetch/fetch.h b/src/fetch/hardcoverfetcher_query.cpp
similarity index 51%
copy from src/fetch/fetch.h
copy to src/fetch/hardcoverfetcher_query.cpp
index 0bdbaa878..269439d5e 100644
--- a/src/fetch/fetch.h
+++ b/src/fetch/hardcoverfetcher_query.cpp
@@ -1,5 +1,5 @@
 /***************************************************************************
-    Copyright (C) 2003-2009 Robby Stephenson <[email protected]>
+    Copyright (C) 2026 Robby Stephenson <[email protected]>
  ***************************************************************************/
 
 /***************************************************************************
@@ -22,112 +22,56 @@
  *                                                                         *
  ***************************************************************************/
 
-#ifndef TELLICO_FETCH_H
-#define TELLICO_FETCH_H
+#include "hardcoverfetcher.h"
 
-namespace Tellico {
-  namespace Fetch {
-
-/**
- * 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,
-  User1, // 10: all user-entered key names must be consecutive before Raw
-  User2,
-  User3,
-  User4,
-  User5,
-  Raw,
-  ExecUpdate,
-  FetchLast
-};
+namespace {
+  static const uint HARDCOVER_MAX_RESULTS = 20;
+}
 
-// real ones must start at 0!
-enum Type {
-  Unknown = -1,
-  Amazon = 0,
-  IMDB,
-  Z3950,
-  SRU,
-  Entrez,
-  ExecExternal,
-  Yahoo, // Removed
-  AnimeNfo, // Removed
-  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, // Removed
-  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,
-  Numista,
-  TVmaze,
-  UPCItemDb,
-  TheTVDB,
-  RPGGeek,
-  GamingHistory,
-  FilmAffinity,
-  Itunes,
-  OPDS,
-  ADS,
-  VGCollect,
-  ISFDB,
-  Metron,
-  DOIorg,
-  Goodreads
-};
+using Tellico::Fetch::HardcoverFetcher;
 
+QString HardcoverFetcher::isbnQuery() {
+  static const auto query(QStringLiteral(R"(
+query GetBookByISBN($isbns: [String!]!) {
+  editions(where: {
+    _or: [
+      { isbn_10: { _in: $isbns } },
+      { isbn_13: { _in: $isbns } }
+    ]
+  }) {
+    id
+    title
+    subtitle
+    isbn_10
+    isbn_13
+    edition_format
+    pages
+    release_date
+    language {
+      language
+    }
+    image {
+      url
+    }
+    publisher {
+      name
+    }
+    book {
+      id
+      title
+      description
+      contributions {
+        contribution
+        author {
+          name
+        }
+      }
+      image {
+        url
+      }
+    }
   }
 }
-
-#endif
+)"));
+  return query;
+}
diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt
index 3932a1ac6..491cef9de 100644
--- a/src/tests/CMakeLists.txt
+++ b/src/tests/CMakeLists.txt
@@ -745,6 +745,13 @@ ecm_add_test(googlescholarfetchertest.cpp
     LINK_LIBRARIES fetcherstest translatorstest ${TELLICO_BTPARSE_LIBS} 
${TELLICO_TEST_LIBS}
 )
 
+ecm_add_test(hardcoverfetchertest.cpp
+    ../fetch/hardcoverfetcher.cpp
+    ../fetch/hardcoverfetcher_query.cpp
+    TEST_NAME hardcoverfetchertest
+    LINK_LIBRARIES fetcherstest ${TELLICO_TEST_LIBS}
+)
+
 ecm_add_test(hathitrustfetchertest.cpp
     ../fetch/hathitrustfetcher.cpp
     TEST_NAME hathitrustfetchertest
diff --git a/src/tests/hardcoverfetchertest.cpp 
b/src/tests/hardcoverfetchertest.cpp
new file mode 100644
index 000000000..bde30c1a6
--- /dev/null
+++ b/src/tests/hardcoverfetchertest.cpp
@@ -0,0 +1,74 @@
+/***************************************************************************
+    Copyright (C) 2026 Robby Stephenson <[email protected]>
+ ***************************************************************************/
+
+/***************************************************************************
+ *                                                                         *
+ *   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 "hardcoverfetchertest.h"
+
+#include "../fetch/hardcoverfetcher.h"
+#include "../collections/bookcollection.h"
+#include "../entry.h"
+#include "../images/imagefactory.h"
+
+#include <KSharedConfig>
+
+#include <QTest>
+
+QTEST_GUILESS_MAIN( HardcoverFetcherTest )
+
+HardcoverFetcherTest::HardcoverFetcherTest() : AbstractFetcherTest() {
+}
+
+void HardcoverFetcherTest::initTestCase() {
+  Tellico::ImageFactory::init();
+
+  auto configFile = QFINDTESTDATA("tellicotest_private.config");
+  if(QFile::exists(configFile)) {
+    m_config = KSharedConfig::openConfig(configFile, 
KConfig::SimpleConfig)->group(QStringLiteral("Hardcover"));
+  }
+}
+
+void HardcoverFetcherTest::testIsbn() {
+  Tellico::Fetch::FetchRequest request(Tellico::Data::Collection::Book,
+                                       Tellico::Fetch::ISBN,
+                                       
QStringLiteral("0441172717;0-393339912"));
+  Tellico::Fetch::Fetcher::Ptr fetcher(new 
Tellico::Fetch::HardcoverFetcher(this));
+  fetcher->readConfig(m_config);
+  QVERIFY(fetcher->canSearch(request.key()));
+
+  Tellico::Data::EntryList results = DO_FETCH1(fetcher, request, 2);
+
+  QCOMPARE(results.size(), 2);
+
+  Tellico::Data::EntryPtr entry = results.at(1);
+  QCOMPARE(entry->field(QStringLiteral("title")), QStringLiteral("Dune"));
+  QCOMPARE(entry->field(QStringLiteral("author")), QStringLiteral("Frank 
Herbert"));
+  QCOMPARE(entry->field(QStringLiteral("isbn")), 
QStringLiteral("0-441-17271-7"));
+  QCOMPARE(entry->field(QStringLiteral("binding")), 
QStringLiteral("Paperback"));
+  QCOMPARE(entry->field(QStringLiteral("publisher")), 
QStringLiteral("Penguin"));
+  QCOMPARE(entry->field(QStringLiteral("pub_year")), QStringLiteral("1965"));
+  QCOMPARE(entry->field(QStringLiteral("pages")), QStringLiteral("704"));
+  QCOMPARE(entry->field(QStringLiteral("language")), 
QStringLiteral("English"));
+  QVERIFY(!entry->field(QStringLiteral("comments")).isEmpty());
+  QVERIFY(!entry->field(QStringLiteral("cover")).isEmpty());
+  QVERIFY(!entry->field(QStringLiteral("cover")).contains(QLatin1Char('/')));
+}
diff --git a/src/fetch/fetch.h b/src/tests/hardcoverfetchertest.h
similarity index 51%
copy from src/fetch/fetch.h
copy to src/tests/hardcoverfetchertest.h
index 0bdbaa878..f5dd65d83 100644
--- a/src/fetch/fetch.h
+++ b/src/tests/hardcoverfetchertest.h
@@ -1,5 +1,5 @@
 /***************************************************************************
-    Copyright (C) 2003-2009 Robby Stephenson <[email protected]>
+    Copyright (C) 2026 Robby Stephenson <[email protected]>
  ***************************************************************************/
 
 /***************************************************************************
@@ -22,112 +22,24 @@
  *                                                                         *
  ***************************************************************************/
 
-#ifndef TELLICO_FETCH_H
-#define TELLICO_FETCH_H
+#ifndef HARDCOVERFETCHERTEST_H
+#define HARDCOVERFETCHERTEST_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,
-  User1, // 10: all user-entered key names must be consecutive before Raw
-  User2,
-  User3,
-  User4,
-  User5,
-  Raw,
-  ExecUpdate,
-  FetchLast
-};
+#include <KConfigGroup>
 
-// real ones must start at 0!
-enum Type {
-  Unknown = -1,
-  Amazon = 0,
-  IMDB,
-  Z3950,
-  SRU,
-  Entrez,
-  ExecExternal,
-  Yahoo, // Removed
-  AnimeNfo, // Removed
-  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, // Removed
-  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,
-  Numista,
-  TVmaze,
-  UPCItemDb,
-  TheTVDB,
-  RPGGeek,
-  GamingHistory,
-  FilmAffinity,
-  Itunes,
-  OPDS,
-  ADS,
-  VGCollect,
-  ISFDB,
-  Metron,
-  DOIorg,
-  Goodreads
-};
+class HardcoverFetcherTest : public AbstractFetcherTest {
+Q_OBJECT
+public:
+  HardcoverFetcherTest();
 
-  }
-}
+private Q_SLOTS:
+  void initTestCase();
+  void testIsbn();
+
+private:
+  KConfigGroup m_config;
+};
 
 #endif

Reply via email to