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 }