File indexing completed on 2024-05-12 05:09:26

0001 /***************************************************************************
0002     Copyright (C) 2023 Robby Stephenson <robby@periapsis.org>
0003  ***************************************************************************/
0004 
0005 /***************************************************************************
0006  *                                                                         *
0007  *   This program is free software; you can redistribute it and/or         *
0008  *   modify it under the terms of the GNU General Public License as        *
0009  *   published by the Free Software Foundation; either version 2 of        *
0010  *   the License or (at your option) version 3 or any later version        *
0011  *   accepted by the membership of KDE e.V. (or its successor approved     *
0012  *   by the membership of KDE e.V.), which shall act as a proxy            *
0013  *   defined in Section 14 of version 3 of the license.                    *
0014  *                                                                         *
0015  *   This program is distributed in the hope that it will be useful,       *
0016  *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
0017  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
0018  *   GNU General Public License for more details.                          *
0019  *                                                                         *
0020  *   You should have received a copy of the GNU General Public License     *
0021  *   along with this program.  If not, see <http://www.gnu.org/licenses/>. *
0022  *                                                                         *
0023  ***************************************************************************/
0024 
0025 #include "adsfetcher.h"
0026 #include "../core/filehandler.h"
0027 #include "../collections/bibtexcollection.h"
0028 #include "../translators/risimporter.h"
0029 #include "../entry.h"
0030 #include "../utils/string_utils.h"
0031 #include "../utils/guiproxy.h"
0032 #include "../tellico_debug.h"
0033 
0034 #include <KLocalizedString>
0035 #include <KConfigGroup>
0036 #include <KIO/Job>
0037 #include <KIO/JobUiDelegate>
0038 #include <KJobWidgets/KJobWidgets>
0039 
0040 #include <QLabel>
0041 #include <QGridLayout>
0042 #include <QLineEdit>
0043 #include <QFile>
0044 #include <QTextCodec>
0045 #include <QJsonDocument>
0046 #include <QJsonObject>
0047 #include <QJsonArray>
0048 #include <QUrlQuery>
0049 
0050 namespace {
0051   static const int ADS_RETURNS_PER_REQUEST = 20;
0052   static const char* ADS_BASE_URL = "https://api.adsabs.harvard.edu/v1";
0053   static const char* ADS_API_KEY = "7b374b31c4b297d969245069dea91a517c35a3e899e16406de98d09271347645c0a86553c7b4f395de9299f0640f2d490f673c09c0aacabc8efa743e3e5b3e74d8eb2c753f6c4708132abcf1492cfb8f";
0054 }
0055 
0056 using namespace Tellico;
0057 using Tellico::Fetch::ADSFetcher;
0058 
0059 ADSFetcher::ADSFetcher(QObject* parent_)
0060     : Fetcher(parent_)
0061     , m_job(nullptr), m_started(false), m_start(0), m_total(-1) {
0062   m_apiKey = Tellico::reverseObfuscate(ADS_API_KEY);
0063 }
0064 
0065 ADSFetcher::~ADSFetcher() {
0066 }
0067 
0068 QString ADSFetcher::source() const {
0069   return m_name.isEmpty() ? defaultName() : m_name;
0070 }
0071 
0072 bool ADSFetcher::canSearch(Fetch::FetchKey k) const {
0073   return k == Title || k == Person || k == Keyword || k == DOI;
0074 }
0075 
0076 bool ADSFetcher::canFetch(int type) const {
0077   return type == Data::Collection::Bibtex;
0078 }
0079 
0080 void ADSFetcher::readConfigHook(const KConfigGroup& config_) {
0081   QString k = config_.readEntry("API Key");
0082   if(!k.isEmpty()) {
0083     // the API key used to be saved in the config
0084     // now in API v4, the API Key is unique to the application and the API PIN is user-specific
0085     // the name of the config option was kept the same
0086     m_apiKey = k;
0087   }
0088 }
0089 
0090 void ADSFetcher::search() {
0091   m_started = true;
0092   m_start = 0;
0093   m_total = -1;
0094   doSearch();
0095 }
0096 
0097 void ADSFetcher::continueSearch() {
0098   m_started = true;
0099   doSearch();
0100 }
0101 
0102 void ADSFetcher::doSearch() {
0103   QUrl u(QString::fromLatin1(ADS_BASE_URL));
0104   u.setPath(u.path() + QLatin1String("/search/query"));
0105   QUrlQuery q;
0106   q.addQueryItem(QStringLiteral("row"), QString::number(ADS_RETURNS_PER_REQUEST));
0107   q.addQueryItem(QStringLiteral("start"), QString::number(m_start));
0108   q.addQueryItem(QStringLiteral("fl"), QLatin1String("bibcode,first_author,title,year"));
0109 
0110   auto queryValue = QUrl::toPercentEncoding(request().value());
0111   if(!queryValue.startsWith('"')) {
0112     queryValue.prepend('"');
0113   }
0114   if(!queryValue.endsWith('"')) {
0115     queryValue.append('"');
0116   }
0117   const QString value = QString::fromUtf8(queryValue);
0118   switch(request().key()) {
0119     case Title:
0120       q.addQueryItem(QStringLiteral("q"), QStringLiteral("title:%1").arg(value));
0121       break;
0122 
0123     case Keyword:
0124       q.addQueryItem(QStringLiteral("q"), value);
0125       break;
0126 
0127     case Person:
0128       q.addQueryItem(QStringLiteral("q"), QStringLiteral("author:%1").arg(value));
0129       break;
0130 
0131     case DOI:
0132       q.addQueryItem(QStringLiteral("q"), QStringLiteral("doi:%1").arg(value));
0133       break;
0134 
0135     default:
0136       myWarning() << source() << "- key not recognized:" << request().key();
0137       stop();
0138       return;
0139   }
0140   u.setQuery(q);
0141 //  myDebug() << "search url: " << u.url();
0142 
0143   m_job = getJob(u);
0144   connect(m_job.data(), &KJob::result,
0145           this, &ADSFetcher::slotComplete);
0146 }
0147 
0148 void ADSFetcher::stop() {
0149   if(!m_started) {
0150     return;
0151   }
0152   if(m_job) {
0153     m_job->kill();
0154     m_job = nullptr;
0155   }
0156   m_started = false;
0157   emit signalDone(this);
0158 }
0159 
0160 void ADSFetcher::slotComplete(KJob*) {
0161   if(m_job->error()) {
0162     m_job->uiDelegate()->showErrorMessage();
0163     stop();
0164     return;
0165   }
0166 
0167   QByteArray data = m_job->data();
0168   if(data.isEmpty()) {
0169     myDebug() << "ADS - no data";
0170     stop();
0171     return;
0172   }
0173   // see bug 319662. If fetcher is cancelled, job is killed
0174   // if the pointer is retained, it gets double-deleted
0175   m_job = nullptr;
0176 
0177 #if 0
0178   myWarning() << "Remove debug from adsfetcher.cpp";
0179   QFile f(QString::fromLatin1("/tmp/test-ads.json"));
0180   if(f.open(QIODevice::WriteOnly)) {
0181     QTextStream t(&f);
0182     t.setCodec("UTF-8");
0183     t << QString::fromUtf8(data.constData(), data.size());
0184   }
0185   f.close();
0186 #endif
0187 
0188   QJsonParseError jsonError;
0189   QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError);
0190   if(doc.isNull()) {
0191     myDebug() << "null JSON document:" << jsonError.errorString();
0192     message(jsonError.errorString(), MessageHandler::Error);
0193     stop();
0194     return;
0195   }
0196   const auto obj = doc.object();
0197   const auto response = obj.value(QLatin1String("response")).toObject();
0198   if(response.isEmpty()) {
0199     const auto errorMsg = obj[QLatin1String("error")][QLatin1String("msg")].toString();
0200     myDebug() << "No response:" << errorMsg;
0201     message(errorMsg, MessageHandler::Error);
0202     stop();
0203     return;
0204   }
0205   m_total = response.value(QLatin1String("numFound")).toInt();
0206 
0207   QJsonArray results = response.value(QLatin1String("docs")).toArray();
0208   for(QJsonArray::const_iterator i = results.constBegin(); i != results.constEnd(); ++i) {
0209     if(!m_started) {
0210       // might get aborted
0211       break;
0212     }
0213     QJsonObject result = (*i).toObject();
0214     const QString title = result.value(QLatin1String("title")).toArray().at(0).toString();
0215     FetchResult* r = new FetchResult(this,
0216                                      title,
0217                                      result.value(QLatin1String("first_author")).toString()
0218                                      + QLatin1Char('/')
0219                                      + result.value(QLatin1String("year")).toString());
0220     m_results.insert(r->uid, result.value(QLatin1String("bibcode")).toString());
0221     emit signalResultFound(r);
0222 //    myDebug() << "found" << title;
0223   }
0224   m_start = m_results.count();
0225   m_hasMoreResults = (m_start > 0 && m_start <= m_total);
0226 //  myDebug() << "start:" << m_start << "; total:" << m_total;
0227 
0228   stop(); // required
0229 }
0230 
0231 Tellico::Data::EntryPtr ADSFetcher::fetchEntryHook(uint uid_) {
0232   Data::EntryPtr entry = m_entries.value(uid_);
0233   if(entry) {
0234     return entry;
0235   }
0236 
0237   if(!m_results.contains(uid_)) {
0238     myWarning() << "no matching bibcode";
0239     return Data::EntryPtr();
0240   }
0241 
0242   QUrl u(QString::fromLatin1(ADS_BASE_URL));
0243   // prefer RIS to Bibtex since Tellico isn't always compiled with Bibtex support
0244   u.setPath(u.path() + QLatin1String("/export/ris"));
0245   QJsonArray codes;
0246   codes += m_results.value(uid_);
0247   QJsonObject obj;
0248   obj.insert(QLatin1String("bibcode"), codes);
0249   const QByteArray payload = QJsonDocument(obj).toJson();
0250 
0251   QPointer<KIO::StoredTransferJob> job = KIO::storedHttpPost(payload, u, KIO::HideProgressInfo);
0252   job->addMetaData(QStringLiteral("content-type"), QStringLiteral("Content-Type: application/json"));
0253   job->addMetaData(QStringLiteral("accept"), QStringLiteral("application/json"));
0254   job->addMetaData(QStringLiteral("customHTTPHeader"), QStringLiteral("Authorization: Bearer ") + m_apiKey);
0255   KJobWidgets::setWindow(job, GUI::Proxy::widget());
0256   if(!job->exec()) {
0257     myDebug() << "ADS: export failure";
0258     myDebug() << job->errorString() << u;
0259     return Data::EntryPtr();
0260   }
0261 
0262   auto data = job->data();
0263 #if 0
0264   myWarning() << "Remove debug2 from adsfetcher.cpp";
0265   QFile f(QString::fromLatin1("/tmp/test-ads-export.json"));
0266   if(f.open(QIODevice::WriteOnly)) {
0267     QTextStream t(&f);
0268     t.setCodec("UTF-8");
0269     t << QString::fromUtf8(data.constData(), data.size());
0270   }
0271   f.close();
0272 #endif
0273 
0274   QJsonParseError jsonError;
0275   QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError);
0276   if(doc.isNull()) {
0277     myDebug() << "null JSON document:" << jsonError.errorString();
0278     message(jsonError.errorString(), MessageHandler::Error);
0279     return Data::EntryPtr();
0280   }
0281   Import::RISImporter imp(doc.object().value(QLatin1String("export")).toString());
0282   auto coll = imp.collection();
0283 
0284   if(coll->entryCount() == 0) {
0285     return Data::EntryPtr();
0286   }
0287   m_entries.insert(uid_, coll->entries().at(0));
0288   return coll->entries().at(0);
0289 }
0290 
0291 Tellico::Fetch::FetchRequest ADSFetcher::updateRequest(Data::EntryPtr entry_) {
0292   const QString doi = entry_->field(QStringLiteral("doi"));
0293   if(!doi.isEmpty()) {
0294     return FetchRequest(DOI, doi);
0295   }
0296   const QString title = entry_->field(QStringLiteral("title"));
0297   if(!title.isEmpty()) {
0298     return FetchRequest(Title, title);
0299   }
0300   return FetchRequest();
0301 }
0302 
0303 QPointer<KIO::StoredTransferJob> ADSFetcher::getJob(const QUrl& url_) {
0304   QPointer<KIO::StoredTransferJob> job = KIO::storedGet(url_, KIO::NoReload, KIO::HideProgressInfo);
0305   job->addMetaData(QStringLiteral("accept"), QStringLiteral("application/json"));
0306   job->addMetaData(QStringLiteral("customHTTPHeader"), QStringLiteral("Authorization: Bearer ") + m_apiKey);
0307   KJobWidgets::setWindow(job, GUI::Proxy::widget());
0308   return job;
0309 }
0310 
0311 Tellico::Fetch::ConfigWidget* ADSFetcher::configWidget(QWidget* parent_) const {
0312   return new ADSFetcher::ConfigWidget(parent_, this);
0313 }
0314 
0315 QString ADSFetcher::defaultName() {
0316   return i18n("SAO/NASA Astrophysics Data System");
0317 }
0318 
0319 QString ADSFetcher::defaultIcon() {
0320   return favIcon("https://ui.adsabs.harvard.edu");
0321 }
0322 
0323 ADSFetcher::ConfigWidget::ConfigWidget(QWidget* parent_, const ADSFetcher* fetcher_)
0324     : Fetch::ConfigWidget(parent_) {
0325   QGridLayout* l = new QGridLayout(optionsWidget());
0326   l->setSpacing(4);
0327   l->setColumnStretch(1, 10);
0328 
0329   int row = -1;
0330 
0331   QLabel* al = new QLabel(i18n("Registration is required for accessing this data source. "
0332                                "If you agree to the terms and conditions, <a href='%1'>sign "
0333                                "up for an account</a>, and enter your information below.",
0334                                 QLatin1String("https://ui.adsabs.harvard.edu/user/settings/token")),
0335                           optionsWidget());
0336   al->setOpenExternalLinks(true);
0337   al->setWordWrap(true);
0338   ++row;
0339   l->addWidget(al, row, 0, 1, 2);
0340   // richtext gets weird with size
0341   al->setMinimumWidth(al->sizeHint().width());
0342 
0343   QLabel* label = new QLabel(i18n("Access key: "), optionsWidget());
0344   l->addWidget(label, ++row, 0);
0345 
0346   m_apiKeyEdit = new QLineEdit(optionsWidget());
0347   connect(m_apiKeyEdit, &QLineEdit::textChanged, this, &ConfigWidget::slotSetModified);
0348   l->addWidget(m_apiKeyEdit, row, 1);
0349   QString w = i18n("The default Tellico key may be used, but searching may fail due to reaching access limits.");
0350   label->setWhatsThis(w);
0351   m_apiKeyEdit->setWhatsThis(w);
0352   label->setBuddy(m_apiKeyEdit);
0353 
0354   l->setRowStretch(++row, 10);
0355 
0356   // now add additional fields widget
0357   addFieldsWidget(ADSFetcher::allOptionalFields(), fetcher_ ? fetcher_->optionalFields() : QStringList());
0358 
0359   if(fetcher_) {
0360     m_apiKeyEdit->setText(fetcher_->m_apiKey);
0361   }
0362 }
0363 
0364 QString ADSFetcher::ConfigWidget::preferredName() const {
0365   return ADSFetcher::defaultName();
0366 }
0367 
0368 void ADSFetcher::ConfigWidget::saveConfigHook(KConfigGroup& config_) {
0369   const QString apiKey = m_apiKeyEdit->text().trimmed();
0370   if(!apiKey.isEmpty()) {
0371     config_.writeEntry("API Key", apiKey);
0372   }
0373 }