[office/tellico] /: Add initial work to fetch from OPDS catalogs

Robby Stephenson null at kde.org
Wed Jun 14 03:05:58 BST 2023


Git commit 299a4e35adafe274ed7cec43e2c35be3f6b95ecd by Robby Stephenson.
Committed on 14/06/2023 at 02:05.
Pushed by rstephenson into branch 'master'.

Add initial work to fetch from OPDS catalogs

Assumes the catalog has a search description url with
a search template.

M  +4    -0    ChangeLog
M  +10   -0    doc/configuration.docbook
M  +1    -0    src/fetch/CMakeLists.txt
M  +2    -1    src/fetch/arxivfetcher.cpp
M  +2    -1    src/fetch/fetch.h
M  +2    -0    src/fetch/fetcherinitializer.cpp
A  +432  -0    src/fetch/opdsfetcher.cpp     [License: GPL (v2/3)]
A  +123  -0    src/fetch/opdsfetcher.h     [License: GPL (v2/3)]
M  +0    -1    src/fetch/srufetcher.cpp
M  +0    -1    src/fetch/z3950fetcher.cpp
M  +6    -0    src/tests/CMakeLists.txt
A  +95   -0    src/tests/opdsfetchertest.cpp     [License: GPL (v2/3)]
C  +13   -26   src/tests/opdsfetchertest.h [from: src/translators/tellico_xml.h - 066% similarity]
M  +2    -0    src/translators/tellico_xml.cpp
M  +2    -0    src/translators/tellico_xml.h
M  +1    -0    xslt/CMakeLists.txt
A  +100  -0    xslt/atom2tellico.xsl

https://invent.kde.org/office/tellico/-/commit/299a4e35adafe274ed7cec43e2c35be3f6b95ecd

diff --git a/ChangeLog b/ChangeLog
index 54bf361a..07a907be 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,3 +1,7 @@
+2023-06-13  Robby Stephenson  <robby at periapsis.org>
+
+	* Added OPDS catalogs as a data source.
+
 2023-05-29  Robby Stephenson  <robby at periapsis.org>
 
 	* Added support for reading images from data URLs.
diff --git a/doc/configuration.docbook b/doc/configuration.docbook
index cf626efd..fa8bd355 100644
--- a/doc/configuration.docbook
+++ b/doc/configuration.docbook
@@ -163,6 +163,7 @@ while the full list is <ulink url="https://tellico-project.org/data-sources">ava
 <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="openlibrary">OpenLibrary.org</link>,</simpara></listitem>
+<listitem><simpara><link linkend="opds">OPDS catalogs</link>,</simpara></listitem>
 <!-- movies -->
 <listitem><simpara>the <link linkend="imdb">Internet Movie Database</link>,</simpara></listitem>
 <listitem><simpara><link linkend="allocine">AlloCiné</link>,</simpara></listitem>
@@ -304,6 +305,15 @@ the best known.
 </para>
 </sect3>
 
+<sect3 id="opds">
+<title>OPDS Catalogs</title>
+<para>
+<ulink url="https://en.wikipedia.org/wiki/Open_Publication_Distribution_System">OPDS catalogs</ulink> provide a means for searching (and distributing) digital books.
+&tellico; can use many OPDS catalogs as a data source, such as <ulink url="https://wiki.mobileread.com/wiki/OPDS">Project Gutenberg</ulink>. Enter the link to the catalog
+and verify the access and format to confirm &tellico; can read the link.
+</para>
+</sect3>
+
 </sect2>
 <!-- end of books -->
 
diff --git a/src/fetch/CMakeLists.txt b/src/fetch/CMakeLists.txt
index 9d7abe28..ce7b6e77 100644
--- a/src/fetch/CMakeLists.txt
+++ b/src/fetch/CMakeLists.txt
@@ -56,6 +56,7 @@ SET(fetch_STAT_SRCS
    musicbrainzfetcher.cpp
    numistafetcher.cpp
    omdbfetcher.cpp
+   opdsfetcher.cpp
    openlibraryfetcher.cpp
    rpggeekfetcher.cpp
    springerfetcher.cpp
diff --git a/src/fetch/arxivfetcher.cpp b/src/fetch/arxivfetcher.cpp
index 5e21a1ed..4920ee17 100644
--- a/src/fetch/arxivfetcher.cpp
+++ b/src/fetch/arxivfetcher.cpp
@@ -25,6 +25,7 @@
 #include "arxivfetcher.h"
 #include "../translators/xslthandler.h"
 #include "../translators/tellicoimporter.h"
+#include "../translators/tellico_xml.h"
 #include "../utils/guiproxy.h"
 #include "../utils/string_utils.h"
 #include "../utils/datafileregistry.h"
@@ -164,7 +165,7 @@ void ArxivFetcher::slotComplete(KJob*) {
       return;
     }
     // total is top level element, with attribute totalResultsAvailable
-    QDomNodeList list = dom.elementsByTagNameNS(QStringLiteral("http://a9.com/-/spec/opensearch/1.1/"),
+    QDomNodeList list = dom.elementsByTagNameNS(XML::nsOpenSearch,
                                                 QStringLiteral("totalResults"));
     if(list.count() > 0) {
       m_total = list.item(0).toElement().text().toInt();
diff --git a/src/fetch/fetch.h b/src/fetch/fetch.h
index e048001c..28eed10d 100644
--- a/src/fetch/fetch.h
+++ b/src/fetch/fetch.h
@@ -112,7 +112,8 @@ enum Type {
   RPGGeek,
   GamingHistory,
   FilmAffinity,
-  Itunes
+  Itunes,
+  OPDS
 };
 
   }
diff --git a/src/fetch/fetcherinitializer.cpp b/src/fetch/fetcherinitializer.cpp
index 1fc726a9..9c4a6eac 100644
--- a/src/fetch/fetcherinitializer.cpp
+++ b/src/fetch/fetcherinitializer.cpp
@@ -79,6 +79,7 @@
 #include "gaminghistoryfetcher.h"
 #include "filmaffinityfetcher.h"
 #include "itunesfetcher.h"
+#include "opdsfetcher.h"
 
 /**
  * Ideally, I'd like these initializations to be in each cpp file for each collection type
@@ -136,6 +137,7 @@ Tellico::Fetch::FetcherInitializer::FetcherInitializer() {
   RegisterFetcher<Fetch::GamingHistoryFetcher> registerGamingHistory(GamingHistory);
   RegisterFetcher<Fetch::FilmAffinityFetcher> registerFilmAffinity(FilmAffinity);
   RegisterFetcher<Fetch::ItunesFetcher> registerItunes(Itunes);
+  RegisterFetcher<Fetch::OPDSFetcher> registerOPDS(OPDS);
 
 // these data sources depend on being able to import bibtex
 #ifdef ENABLE_BTPARSE
diff --git a/src/fetch/opdsfetcher.cpp b/src/fetch/opdsfetcher.cpp
new file mode 100644
index 00000000..b5ebd3dc
--- /dev/null
+++ b/src/fetch/opdsfetcher.cpp
@@ -0,0 +1,432 @@
+/***************************************************************************
+    Copyright (C) 2023 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 "opdsfetcher.h"
+#include "../fieldformat.h"
+#include "../collection.h"
+#include "../translators/xslthandler.h"
+#include "../translators/tellicoimporter.h"
+#include "../gui/lineedit.h"
+#include "../core/filehandler.h"
+#include "../utils/datafileregistry.h"
+#include "../utils/guiproxy.h"
+#include "../utils/isbnvalidator.h"
+#include "../translators/tellico_xml.h"
+#include "../tellico_debug.h"
+
+#include <KLocalizedString>
+#include <KIO/Job>
+#include <KJobUiDelegate>
+#include <KJobWidgets/KJobWidgets>
+#include <KAcceleratorManager>
+
+#include <QLabel>
+#include <QGridLayout>
+#include <QXmlStreamReader>
+#include <QPushButton>
+
+using namespace Tellico;
+using Tellico::Fetch::OPDSFetcher;
+
+// utility class for reading the OPDS catalog and finding the search information
+class OPDSReader {
+public:
+  OPDSReader(const QUrl& catalog_) : catalog(catalog_) {}
+
+  // read the catalog file and return the search description url
+  QString readSearchUrl() {
+    const QByteArray opdsText = FileHandler::readDataFile(catalog);
+    QXmlStreamReader xml(opdsText);
+    int depth = 0;
+    while(xml.readNext() != QXmlStreamReader::Invalid) {
+      switch(xml.tokenType()) {
+        case QXmlStreamReader::StartElement:
+          ++depth;
+          if(depth == 2 &&
+             xml.name() == QLatin1String("link") &&
+             xml.namespaceUri() == Tellico::XML::nsAtom) {
+            auto attributes = xml.attributes();
+            if(attributes.value(QStringLiteral("rel")) == QLatin1String("search")) {
+              // found the search url
+              return attributes.value(QStringLiteral("href")).toString();
+            }
+          }
+          break;
+        case QXmlStreamReader::EndElement:
+          --depth;
+          break;
+        default:
+          break;
+      }
+    }
+    // nothing found
+    return QString();
+  }
+
+  bool readSearchTemplate() {
+//    myDebug() << "Reading catalog:" << catalog;
+    QString searchDescriptionUrl = readSearchUrl();
+    if(searchDescriptionUrl.isEmpty()) return false;
+//    myDebug() << "Reading search description:" << searchDescriptionUrl;
+    // read the search description and find the search template
+    const QByteArray descText = FileHandler::readDataFile(QUrl(searchDescriptionUrl));
+    QXmlStreamReader xml(descText);
+    int depth = 0;
+    QString text, shortName, longName;
+    while(xml.readNext() != QXmlStreamReader::Invalid) {
+      switch(xml.tokenType()) {
+        case QXmlStreamReader::StartElement:
+          ++depth;
+          if(depth == 2) {
+            if(xml.name() == QLatin1String("Url") &&
+               xml.namespaceUri() == XML::nsOpenSearch) {
+              auto attributes = xml.attributes();
+              if(attributes.value(QLatin1String("type")) == QLatin1String("application/atom+xml")) {
+                searchTemplate = attributes.value(QStringLiteral("template")).toString();
+              }
+            }
+          }
+          break;
+        case QXmlStreamReader::EndElement:
+          if(depth == 2 && xml.name() == QLatin1String("LongName")) {
+            longName = text.simplified();
+          } else if(depth == 2 && xml.name() == QLatin1String("ShortName")) {
+            shortName = text.simplified();
+          } else if(depth == 2 && xml.name() == QLatin1String("Image")) {
+            icon = text.simplified();
+          } else if(depth == 2 && xml.name() == QLatin1String("Attribution")) {
+            attribution = text.simplified();
+          }
+          --depth;
+          text.clear();
+          break;
+        case QXmlStreamReader::Characters:
+          text += xml.text();
+          break;
+        default:
+          break;
+      }
+    }
+    name = longName.isEmpty() ? shortName : longName;
+    return !searchTemplate.isEmpty();
+  }
+
+  QUrl catalog;
+  QString searchTemplate;
+  QString name;
+  QString icon;
+  QString attribution;
+};
+
+OPDSFetcher::OPDSFetcher(QObject* parent_)
+    : Fetcher(parent_), m_xsltHandler(nullptr), m_started(false) {
+}
+
+OPDSFetcher::~OPDSFetcher() {
+  delete m_xsltHandler;
+  m_xsltHandler = nullptr;
+}
+
+QString OPDSFetcher::source() const {
+  return m_name.isEmpty() ? defaultName() : m_name;
+}
+
+QString OPDSFetcher::attribution() const {
+  return m_attribution;
+}
+
+QString OPDSFetcher::icon() const {
+  return favIcon(QUrl(m_icon));
+}
+
+bool OPDSFetcher::canSearch(Fetch::FetchKey k) const {
+  return k == Title || k == Keyword || k == ISBN;
+}
+
+bool OPDSFetcher::canFetch(int type) const {
+  return type == Data::Collection::Book || type == Data::Collection::Bibtex;
+}
+
+void OPDSFetcher::readConfigHook(const KConfigGroup& config_) {
+  m_catalog = config_.readEntry("Catalog");
+  m_searchTemplate = config_.readEntry("SearchTemplate");
+  m_icon = config_.readEntry("Icon");
+  m_attribution = config_.readEntry("Attribution");
+}
+
+void OPDSFetcher::saveConfigHook(KConfigGroup& config_) {
+  if(!m_searchTemplate.isEmpty()) {
+    config_.writeEntry("SearchTemplate", m_searchTemplate);
+  }
+  if(!m_icon.isEmpty()) {
+    config_.writeEntry("Icon", m_icon);
+  }
+  if(!m_attribution.isEmpty()) {
+    config_.writeEntry("Attribution", m_attribution);
+  }
+}
+
+void OPDSFetcher::search() {
+  m_started = true;
+  if(m_catalog.isEmpty()) {
+    myDebug() << source() << "- url is not set";
+    stop();
+    return;
+  }
+
+  OPDSReader reader(QUrl::fromUserInput(m_catalog));
+  if(m_searchTemplate.isEmpty() && !reader.readSearchTemplate()) {
+    myDebug() << source() << "- no search template";
+    message(i18n("Tellico is unable to read the search descripion in the OPDS catalog."), MessageHandler::Error);
+    stop();
+    return;
+  }
+  if(m_searchTemplate.isEmpty()) {
+    m_searchTemplate = reader.searchTemplate;
+    m_icon = reader.icon;
+    m_attribution = reader.attribution;
+  }
+
+  QString searchTerm;
+  switch(request().key()) {
+    case Title:
+    case Keyword:
+      searchTerm = request().value();
+      break;
+
+    case ISBN:
+      {
+        QString isbn = request().value().section(QLatin1Char(';'), 0);
+        isbn.remove(QLatin1Char('-'));
+        searchTerm = isbn;
+      }
+      break;
+
+    default:
+      myWarning() << "key not recognized: " << request().key();
+      stop();
+      break;
+  }
+
+  QString searchUrl = m_searchTemplate;
+  searchUrl.replace(QStringLiteral("{searchTerms}"), searchTerm);
+  QUrl u(searchUrl);
+//  myDebug() << u.url();
+
+  m_job = KIO::storedGet(u, KIO::NoReload, KIO::HideProgressInfo);
+  KJobWidgets::setWindow(m_job, GUI::Proxy::widget());
+  connect(m_job.data(), &KJob::result,
+          this, &OPDSFetcher::slotComplete);
+}
+
+void OPDSFetcher::stop() {
+  if(!m_started) {
+    return;
+  }
+  if(m_job) {
+    m_job->kill();
+    m_job = nullptr;
+  }
+
+  m_started = false;
+  emit signalDone(this);
+}
+
+void OPDSFetcher::slotComplete(KJob*) {
+  if(m_job->error()) {
+    m_job->uiDelegate()->showErrorMessage();
+    stop();
+    return;
+  }
+
+  QByteArray data = m_job->data();
+  if(data.isEmpty()) {
+    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 opdsfetcher.cpp";
+  QFile f(QString::fromLatin1("/tmp/test.xml"));
+  if(f.open(QIODevice::WriteOnly)) {
+    QTextStream t(&f);
+    t.setCodec("UTF-8");
+    t << data;
+  }
+  f.close();
+#endif
+
+  if(!m_xsltHandler) {
+    initXSLTHandler();
+    if(!m_xsltHandler) { // probably an error somewhere in the stylesheet loading
+      stop();
+      return;
+    }
+  }
+
+  // assume result is always utf-8
+  QString str = m_xsltHandler->applyStylesheet(QString::fromUtf8(data.constData(), data.size()));
+  Import::TellicoImporter imp(str);
+  Data::CollPtr coll = imp.collection();
+
+  if(!coll) {
+    myDebug() << source() << " - no collection pointer";
+    stop();
+    return;
+  }
+
+  foreach(Data::EntryPtr entry, coll->entries()) {
+    FetchResult* r = new FetchResult(this, entry);
+    m_entries.insert(r->uid, entry);
+    emit signalResultFound(r);
+  }
+  stop();
+}
+
+Tellico::Data::EntryPtr OPDSFetcher::fetchEntryHook(uint uid_) {
+  return m_entries[uid_];
+}
+
+
+void OPDSFetcher::initXSLTHandler() {
+  QString xsltfile = DataFileRegistry::self()->locate(QStringLiteral("atom2tellico.xsl"));
+  if(xsltfile.isEmpty()) {
+    myWarning() << "can not locate atom2tellico.xsl.";
+    return;
+  }
+
+  QUrl u = QUrl::fromLocalFile(xsltfile);
+
+  delete m_xsltHandler;
+  m_xsltHandler = new XSLTHandler(u);
+  if(!m_xsltHandler->isValid()) {
+    myWarning() << "error in atom2tellico.xsl.";
+    delete m_xsltHandler;
+    m_xsltHandler = nullptr;
+  }
+}
+
+Tellico::Fetch::FetchRequest OPDSFetcher::updateRequest(Data::EntryPtr entry_) {
+  QString t = entry_->field(QStringLiteral("title"));
+  if(!t.isEmpty()) {
+    return FetchRequest(Fetch::Title, t);
+  }
+  return FetchRequest();
+}
+
+QString OPDSFetcher::defaultName() {
+  return i18n("OPDS Catalog");
+}
+
+QString OPDSFetcher::defaultIcon() {
+  return QStringLiteral("folder-book");
+}
+
+// static
+Tellico::StringHash OPDSFetcher::allOptionalFields() {
+  StringHash hash;
+  hash[QStringLiteral("url")] = i18n("URL");
+  return hash;
+}
+
+Tellico::Fetch::ConfigWidget* OPDSFetcher::configWidget(QWidget* parent_) const {
+  return new ConfigWidget(parent_, this);
+}
+
+OPDSFetcher::ConfigWidget::ConfigWidget(QWidget* parent_, const OPDSFetcher* fetcher_ /*=0*/)
+    : Fetch::ConfigWidget(parent_) {
+  QGridLayout* l = new QGridLayout(optionsWidget());
+  l->setSpacing(4);
+  l->setColumnStretch(1, 10);
+
+  int row = -1;
+  QLabel* label = new QLabel(i18n("Catalog: "), optionsWidget());
+  l->addWidget(label, ++row, 0);
+  m_catalogEdit = new GUI::LineEdit(optionsWidget());
+  connect(m_catalogEdit, &QLineEdit::textChanged, this, &ConfigWidget::slotSetModified);
+  l->addWidget(m_catalogEdit, row, 1);
+  QString w = i18n("Enter the link to the OPDS server.");
+  label->setWhatsThis(w);
+  m_catalogEdit->setWhatsThis(w);
+  label->setBuddy(m_catalogEdit);
+
+  auto verifyButton = new QPushButton(i18n("&Verify Catalog"), optionsWidget());
+  connect(verifyButton, &QPushButton::clicked,
+          this, &ConfigWidget::verifyCatalog);
+  l->addWidget(verifyButton, ++row, 0);
+  m_statusLabel = new QLabel(optionsWidget());
+  l->addWidget(m_statusLabel, row, 1);
+
+  l->setRowStretch(++row, 1);
+
+  // now add additional fields widget
+  addFieldsWidget(OPDSFetcher::allOptionalFields(), fetcher_ ? fetcher_->optionalFields() : QStringList());
+
+  if(fetcher_) {
+    m_catalogEdit->setText(fetcher_->m_catalog);
+    m_searchTemplate = fetcher_->m_searchTemplate;
+    m_icon = fetcher_->m_icon;
+    m_attribution = fetcher_->m_attribution;
+  }
+  KAcceleratorManager::manage(optionsWidget());
+}
+
+void OPDSFetcher::ConfigWidget::saveConfigHook(KConfigGroup& config_) {
+  QString s = m_catalogEdit->text().trimmed();
+  if(!s.isEmpty()) {
+    config_.writeEntry("Catalog", s);
+    config_.writeEntry("SearchTemplate", m_searchTemplate);
+    config_.writeEntry("Icon", m_icon);
+    config_.writeEntry("Attribution", m_attribution);
+  }
+}
+
+QString OPDSFetcher::ConfigWidget::preferredName() const {
+  QString s = m_catalogEdit->text();
+  return s.isEmpty() ? OPDSFetcher::defaultName() : s;
+}
+
+void OPDSFetcher::ConfigWidget::verifyCatalog() {
+  OPDSReader reader(QUrl::fromUserInput(m_catalogEdit->text()));
+  if(reader.readSearchTemplate()) {
+    const int imgSize = 0.8*m_statusLabel->height();
+    m_statusLabel->setPixmap(QIcon::fromTheme(QStringLiteral("emblem-checked")).pixmap(imgSize, imgSize));
+    slotSetModified();
+    if(!reader.name.isEmpty()) {
+      emit signalName(reader.name);
+    }
+    m_searchTemplate = reader.searchTemplate;
+    m_icon = reader.icon;
+    m_attribution = reader.attribution;
+  } else {
+    const int imgSize = 0.8*m_statusLabel->height();
+    m_statusLabel->setPixmap(QIcon::fromTheme(QStringLiteral("emblem-error")).pixmap(imgSize, imgSize));
+    m_searchTemplate.clear();
+    m_icon.clear();
+    m_attribution.clear();
+  }
+}
diff --git a/src/fetch/opdsfetcher.h b/src/fetch/opdsfetcher.h
new file mode 100644
index 00000000..a315e1bb
--- /dev/null
+++ b/src/fetch/opdsfetcher.h
@@ -0,0 +1,123 @@
+/***************************************************************************
+    Copyright (C) 2023 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_OPDSFETCHER_H
+#define TELLICO_OPDSFETCHER_H
+
+#include "fetcher.h"
+#include "configwidget.h"
+
+#include <QPointer>
+
+class QLabel;
+class KJob;
+namespace KIO {
+  class StoredTransferJob;
+}
+
+namespace Tellico {
+  class XSLTHandler;
+  namespace GUI {
+    class LineEdit;
+  }
+  namespace Fetch {
+
+/**
+ * @author Robby Stephenson
+ */
+class OPDSFetcher : public Fetcher {
+Q_OBJECT
+
+public:
+  /**
+   */
+  OPDSFetcher(QObject* parent);
+  /**
+   */
+  virtual ~OPDSFetcher();
+
+  /**
+   */
+  virtual QString source() const Q_DECL_OVERRIDE;
+  virtual QString attribution() const Q_DECL_OVERRIDE;
+  virtual QString icon() 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 OPDS; }
+  virtual bool canFetch(int type) const Q_DECL_OVERRIDE;
+  virtual void readConfigHook(const KConfigGroup& config) Q_DECL_OVERRIDE;
+  virtual void saveConfigHook(KConfigGroup& config) Q_DECL_OVERRIDE;
+
+  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 initXSLTHandler();
+
+  QString m_catalog;
+  QString m_searchTemplate;
+  QString m_icon;
+  QString m_attribution;
+  XSLTHandler* m_xsltHandler;
+
+  QHash<uint, Data::EntryPtr> m_entries;
+  QPointer<KIO::StoredTransferJob> m_job;
+  bool m_started;
+};
+
+class OPDSFetcher::ConfigWidget : public Fetch::ConfigWidget {
+Q_OBJECT
+
+public:
+  explicit ConfigWidget(QWidget* parent_, const OPDSFetcher* fetcher = nullptr);
+  virtual void saveConfigHook(KConfigGroup& config) Q_DECL_OVERRIDE;
+  virtual QString preferredName() const Q_DECL_OVERRIDE;
+
+private Q_SLOTS:
+  void verifyCatalog();
+
+private:
+  GUI::LineEdit* m_catalogEdit;
+  QLabel* m_statusLabel;
+  QString m_searchTemplate;
+  QString m_icon;
+  QString m_attribution;
+};
+
+  } // end namespace
+} // end namespace
+#endif
diff --git a/src/fetch/srufetcher.cpp b/src/fetch/srufetcher.cpp
index 5f5fe5e7..d1ba258e 100644
--- a/src/fetch/srufetcher.cpp
+++ b/src/fetch/srufetcher.cpp
@@ -498,7 +498,6 @@ QString SRUFetcher::defaultName() {
 }
 
 QString SRUFetcher::defaultIcon() {
-//  return QLatin1String("network-workgroup"); // just to be different than z3950
   return QStringLiteral(":/icons/sru");
 }
 
diff --git a/src/fetch/z3950fetcher.cpp b/src/fetch/z3950fetcher.cpp
index e936eacc..6df61dca 100644
--- a/src/fetch/z3950fetcher.cpp
+++ b/src/fetch/z3950fetcher.cpp
@@ -567,7 +567,6 @@ QString Z3950Fetcher::defaultName() {
 }
 
 QString Z3950Fetcher::defaultIcon() {
-//  return QLatin1String("network-server"); // rather arbitrary
   return QStringLiteral("network-server-database"); // rather arbitrary
 }
 
diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt
index b77f0846..22bb66da 100644
--- a/src/tests/CMakeLists.txt
+++ b/src/tests/CMakeLists.txt
@@ -773,6 +773,12 @@ ecm_add_test(numistafetchertest.cpp
     LINK_LIBRARIES fetcherstest ${TELLICO_TEST_LIBS}
 )
 
+ecm_add_test(opdsfetchertest.cpp
+             ../fetch/opdsfetcher.cpp
+    TEST_NAME opdsfetchertest
+    LINK_LIBRARIES fetcherstest ${TELLICO_TEST_LIBS}
+)
+
 ecm_add_test(openlibraryfetchertest.cpp
              ../fetch/openlibraryfetcher.cpp
     TEST_NAME openlibraryfetchertest
diff --git a/src/tests/opdsfetchertest.cpp b/src/tests/opdsfetchertest.cpp
new file mode 100644
index 00000000..0195232a
--- /dev/null
+++ b/src/tests/opdsfetchertest.cpp
@@ -0,0 +1,95 @@
+/***************************************************************************
+    Copyright (C) 2023 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 "opdsfetchertest.h"
+
+#include "../fetch/opdsfetcher.h"
+#include "../collections/bookcollection.h"
+#include "../collectionfactory.h"
+#include "../entry.h"
+#include "../images/imagefactory.h"
+#include "../utils/datafileregistry.h"
+
+#include <KSharedConfig>
+#include <KConfigGroup>
+
+#include <QTest>
+
+QTEST_GUILESS_MAIN( OPDSFetcherTest )
+
+OPDSFetcherTest::OPDSFetcherTest() : AbstractFetcherTest() {
+  QStandardPaths::setTestModeEnabled(true);
+}
+
+void OPDSFetcherTest::initTestCase() {
+  Tellico::RegisterCollection<Tellico::Data::BookCollection> registerBook(Tellico::Data::Collection::Book, "book");
+  Tellico::DataFileRegistry::self()->addDataLocation(QFINDTESTDATA("../../xslt/atom2tellico.xsl"));
+  Tellico::ImageFactory::init();
+}
+
+void OPDSFetcherTest::testFeedbooksSearch() {
+  KConfigGroup cg = KSharedConfig::openConfig(QString(), KConfig::SimpleConfig)->group("Feedbooks");
+  cg.writeEntry("Catalog", "https://www.feedbooks.com/catalog.atom");
+  cg.writeEntry("Custom Fields", "url");
+
+  Tellico::Fetch::FetchRequest request(Tellico::Data::Collection::Book, Tellico::Fetch::ISBN,
+                                       "9781773231341");
+  Tellico::Fetch::Fetcher::Ptr fetcher(new Tellico::Fetch::OPDSFetcher(this));
+  fetcher->readConfig(cg);
+
+  Tellico::Data::EntryList results = DO_FETCH(fetcher, request);
+
+  QCOMPARE(results.size(), 1);
+
+  Tellico::Data::EntryPtr entry = results.at(0);
+  QCOMPARE(entry->field("title"), "First Lensman");
+  QCOMPARE(entry->field("author"), "E. E. Smith");
+  QCOMPARE(entry->field("isbn"), "978-1-77323-134-1");
+  QCOMPARE(entry->field("pub_year"), "2018");
+  QCOMPARE(entry->field("publisher"), "Reading Essentials");
+  QCOMPARE(entry->field("genre"), "Fiction; Science fiction; Space opera and planet opera");
+  QCOMPARE(entry->field("pages"), "226");
+  QCOMPARE(entry->field("url"), "https://www.feedbooks.com/item/2971293");
+  QVERIFY(!entry->field("cover").isEmpty());
+  QVERIFY(!entry->field("cover").contains(QLatin1Char('/')));
+  QVERIFY(!entry->field("plot").isEmpty());
+}
+
+void OPDSFetcherTest::testEmptyGutenberg() {
+  KConfigGroup cg = KSharedConfig::openConfig(QString(), KConfig::SimpleConfig)->group("Feedbooks");
+  cg.writeEntry("Catalog", "https://m.gutenberg.org/ebooks.opds/");
+
+  Tellico::Fetch::FetchRequest request(Tellico::Data::Collection::Book, Tellico::Fetch::Title,
+                                       "XXXXXXXXXXXX");
+  Tellico::Fetch::Fetcher::Ptr fetcher(new Tellico::Fetch::OPDSFetcher(this));
+  fetcher->readConfig(cg);
+
+  Tellico::Data::EntryList results = DO_FETCH(fetcher, request);
+
+  // should be no results
+  QVERIFY(results.isEmpty());
+  QVERIFY(!fetcher->attribution().isEmpty());
+}
diff --git a/src/translators/tellico_xml.h b/src/tests/opdsfetchertest.h
similarity index 66%
copy from src/translators/tellico_xml.h
copy to src/tests/opdsfetchertest.h
index 886f43a2..08276772 100644
--- a/src/translators/tellico_xml.h
+++ b/src/tests/opdsfetchertest.h
@@ -1,5 +1,5 @@
 /***************************************************************************
-    Copyright (C) 2003-2009 Robby Stephenson <robby at periapsis.org>
+    Copyright (C) 2023 Robby Stephenson <robby at periapsis.org>
  ***************************************************************************/
 
 /***************************************************************************
@@ -22,33 +22,20 @@
  *                                                                         *
  ***************************************************************************/
 
-#ifndef TELLICO_XML_H
-#define TELLICO_XML_H
+#ifndef OPDSFETCHERTEST_H
+#define OPDSFETCHERTEST_H
 
-#include <QString>
+#include "abstractfetchertest.h"
 
-namespace Tellico {
-  namespace XML {
-    extern const QString nsXSL;
-    extern const QString nsBibtexml;
-    extern const QString dtdBibtexml;
+class OPDSFetcherTest : public AbstractFetcherTest {
+Q_OBJECT
+public:
+  OPDSFetcherTest();
 
-    extern const uint syntaxVersion;
-    extern const QString nsTellico;
-
-    QString pubTellico(int version = syntaxVersion);
-    QString dtdTellico(int version = syntaxVersion);
-
-    extern const QString nsBookcase;
-    extern const QString nsDublinCore;
-    extern const QString nsZing;
-    extern const QString nsZingDiag;
-
-    bool validXMLElementName(const QString& name);
-    QString elementName(const QString& name);
-    QByteArray recoverFromBadXMLName(const QByteArray& data);
-    QByteArray removeInvalidXml(const QByteArray& data);
-  }
-}
+private Q_SLOTS:
+  void initTestCase();
+  void testFeedbooksSearch();
+  void testEmptyGutenberg();
+};
 
 #endif
diff --git a/src/translators/tellico_xml.cpp b/src/translators/tellico_xml.cpp
index bebdefa0..a8854769 100644
--- a/src/translators/tellico_xml.cpp
+++ b/src/translators/tellico_xml.cpp
@@ -70,6 +70,8 @@ const QString Tellico::XML::nsBookcase = QStringLiteral("http://periapsis.org/bo
 const QString Tellico::XML::nsDublinCore = QStringLiteral("http://purl.org/dc/elements/1.1/");
 const QString Tellico::XML::nsZing = QStringLiteral("http://www.loc.gov/zing/srw/");
 const QString Tellico::XML::nsZingDiag = QStringLiteral("http://www.loc.gov/zing/srw/diagnostic/");
+const QString Tellico::XML::nsAtom = QStringLiteral("http://www.w3.org/2005/Atom");
+const QString Tellico::XML::nsOpenSearch = QStringLiteral("http://a9.com/-/spec/opensearch/1.1/");
 
 QString Tellico::XML::pubTellico(int version) {
  return QStringLiteral("-//Robby Stephenson/DTD Tellico V%1.0//EN").arg(version);
diff --git a/src/translators/tellico_xml.h b/src/translators/tellico_xml.h
index 886f43a2..40b52cfc 100644
--- a/src/translators/tellico_xml.h
+++ b/src/translators/tellico_xml.h
@@ -43,6 +43,8 @@ namespace Tellico {
     extern const QString nsDublinCore;
     extern const QString nsZing;
     extern const QString nsZingDiag;
+    extern const QString nsAtom;
+    extern const QString nsOpenSearch;
 
     bool validXMLElementName(const QString& name);
     QString elementName(const QString& name);
diff --git a/xslt/CMakeLists.txt b/xslt/CMakeLists.txt
index edfe496e..46259e4c 100644
--- a/xslt/CMakeLists.txt
+++ b/xslt/CMakeLists.txt
@@ -5,6 +5,7 @@ ADD_SUBDIRECTORY( report-templates )
 
 SET(XSLT_FILES
     arxiv2tellico.xsl
+    atom2tellico.xsl
     biblioshare2tellico.xsl
     bibtexml2tellico.xsl
     bluray-logo.png
diff --git a/xslt/atom2tellico.xsl b/xslt/atom2tellico.xsl
new file mode 100644
index 00000000..c843bead
--- /dev/null
+++ b/xslt/atom2tellico.xsl
@@ -0,0 +1,100 @@
+<?xml version="1.0"?>
+<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+                xmlns="http://periapsis.org/tellico/"
+                xmlns:atom="http://www.w3.org/2005/Atom"
+                xmlns:dcterms="http://purl.org/dc/terms/"
+                xmlns:schema="http://schema.org"
+                xmlns:str="http://exslt.org/strings"
+                xmlns:exsl="http://exslt.org/common"
+                exclude-result-prefixes="atom dcterms schema"
+                extension-element-prefixes="str exsl"
+                version="1.0">
+
+<!--
+   ===================================================================
+   Tellico XSLT file - used for importing data from an atom feed
+
+   Copyright (C) 2023 Robby Stephenson <robby at periapsis.org>
+
+   This XSLT stylesheet is designed to be used with the 'Tellico'
+   application, which can be found at http://tellico-project.org
+
+   ===================================================================
+-->
+
+<xsl:output method="xml" version="1.0" encoding="UTF-8" indent="yes"
+            doctype-public="-//Robby Stephenson/DTD Tellico V11.0//EN"
+            doctype-system="http://periapsis.org/tellico/dtd/v11/tellico.dtd"/>
+
+<xsl:template match="/">
+ <tellico syntaxVersion="11">
+  <collection title="Atom Search" type="2">
+   <fields>
+    <field name="_default"/>
+    <field flags="0" title="URL" category="General" format="4" type="7" name="url" i18n="true"/>
+   </fields>
+   <!-- Project Gutenberg returns an entry without author when there are no search results -->
+   <xsl:apply-templates select="atom:feed/atom:entry[atom:author]"/>
+  </collection>
+ </tellico>
+</xsl:template>
+
+<xsl:template match="atom:entry">
+ <entry>
+
+  <title>
+   <xsl:value-of select="normalize-space(atom:title)"/>
+  </title>
+
+  <authors>
+   <xsl:for-each select="atom:author">
+    <author>
+     <xsl:value-of select="normalize-space(atom:name)"/>
+    </author>
+   </xsl:for-each>
+  </authors>
+
+  <publishers>
+   <xsl:for-each select="dcterms:publisher">
+    <publisher>
+     <xsl:value-of select="normalize-space(.)"/>
+    </publisher>
+   </xsl:for-each>
+  </publishers>
+
+  <url>
+   <xsl:value-of select="atom:id[starts-with(.,'http')]"/>
+  </url>
+
+  <isbn>
+   <xsl:value-of select="substring-after(dcterms:identifier[starts-with(.,'urn:ISBN')],'ISBN:')"/>
+  </isbn>
+
+  <pub_year>
+   <xsl:value-of select="substring(dcterms:issued,1,4)"/>
+  </pub_year>
+
+  <pages>
+   <xsl:value-of select="schema:numberOfPages"/>
+  </pages>
+
+  <plot>
+   <xsl:value-of select="normalize-space(atom:summary)"/>
+  </plot>
+
+  <cover>
+   <xsl:value-of select="(atom:link[@rel='http://opds-spec.org/image']/@href |
+                          atom:link[@rel='http://opds-spec.org/image/thumbnail']/@href)[1]"/>
+  </cover>
+
+  <genres>
+   <xsl:for-each select="atom:category">
+    <genre>
+     <xsl:value-of select="@label"/>
+    </genre>
+   </xsl:for-each>
+  </genres>
+ </entry>
+</xsl:template>
+
+</xsl:stylesheet>


More information about the kde-doc-english mailing list