File indexing completed on 2024-05-12 05:09:44
0001 /*************************************************************************** 0002 Copyright (C) 2021 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 "upcitemdbfetcher.h" 0026 #include "../collectionfactory.h" 0027 #include "../images/imagefactory.h" 0028 #include "../utils/guiproxy.h" 0029 #include "../utils/mapvalue.h" 0030 #include "../utils/isbnvalidator.h" 0031 #include "../tellico_debug.h" 0032 0033 #include <KLocalizedString> 0034 #include <KConfigGroup> 0035 #include <KJob> 0036 #include <KJobUiDelegate> 0037 #include <KJobWidgets/KJobWidgets> 0038 #include <KIO/StoredTransferJob> 0039 0040 #include <QLabel> 0041 #include <QFile> 0042 #include <QTextStream> 0043 #include <QVBoxLayout> 0044 #include <QTextCodec> 0045 #include <QJsonDocument> 0046 #include <QJsonObject> 0047 #include <QJsonArray> 0048 #include <QUrlQuery> 0049 0050 namespace { 0051 static const int UPCITEMDB_MAX_RETURNS_TOTAL = 20; 0052 static const char* UPCITEMDB_API_URL = "https://api.upcitemdb.com/prod/trial"; 0053 } 0054 0055 using namespace Tellico; 0056 using Tellico::Fetch::UPCItemDbFetcher; 0057 0058 UPCItemDbFetcher::UPCItemDbFetcher(QObject* parent_) 0059 : Fetcher(parent_) 0060 , m_started(false) { 0061 } 0062 0063 UPCItemDbFetcher::~UPCItemDbFetcher() { 0064 } 0065 0066 QString UPCItemDbFetcher::source() const { 0067 return m_name.isEmpty() ? defaultName() : m_name; 0068 } 0069 0070 bool UPCItemDbFetcher::canSearch(Fetch::FetchKey k) const { 0071 return k == UPC || k == ISBN; 0072 } 0073 0074 bool UPCItemDbFetcher::canFetch(int type) const { 0075 return type == Data::Collection::Video || 0076 type == Data::Collection::Book || 0077 type == Data::Collection::Album || 0078 type == Data::Collection::Game || 0079 type == Data::Collection::BoardGame; 0080 } 0081 0082 void UPCItemDbFetcher::readConfigHook(const KConfigGroup& config_) { 0083 Q_UNUSED(config_) 0084 } 0085 0086 void UPCItemDbFetcher::saveConfigHook(KConfigGroup& config_) { 0087 Q_UNUSED(config_) 0088 } 0089 0090 void UPCItemDbFetcher::search() { 0091 continueSearch(); 0092 } 0093 0094 void UPCItemDbFetcher::continueSearch() { 0095 m_started = true; 0096 0097 QUrl u(QString::fromLatin1(UPCITEMDB_API_URL)); 0098 u = u.adjusted(QUrl::StripTrailingSlash); 0099 u.setPath(u.path() + QLatin1String("/lookup")); 0100 QUrlQuery q; 0101 switch(request().key()) { 0102 case ISBN: 0103 // do a upc search by 13-digit isbn 0104 { 0105 // only grab first value 0106 QString isbn = request().value().section(QLatin1Char(';'), 0); 0107 isbn = ISBNValidator::isbn13(isbn); 0108 isbn.remove(QLatin1Char('-')); 0109 q.addQueryItem(QStringLiteral("upc"), isbn); 0110 } 0111 break; 0112 0113 case UPC: 0114 q.addQueryItem(QStringLiteral("upc"), request().value()); 0115 break; 0116 0117 default: 0118 myWarning() << source() << "- key not recognized:" << request().key(); 0119 stop(); 0120 return; 0121 } 0122 u.setQuery(q); 0123 0124 myLog() << "Reading" << u.toDisplayString(); 0125 m_job = KIO::storedGet(u, KIO::NoReload, KIO::HideProgressInfo); 0126 KJobWidgets::setWindow(m_job, GUI::Proxy::widget()); 0127 connect(m_job.data(), &KJob::result, this, &UPCItemDbFetcher::slotComplete); 0128 } 0129 0130 void UPCItemDbFetcher::stop() { 0131 if(!m_started) { 0132 return; 0133 } 0134 if(m_job) { 0135 m_job->kill(); 0136 m_job = nullptr; 0137 } 0138 m_started = false; 0139 emit signalDone(this); 0140 } 0141 0142 Tellico::Fetch::FetchRequest UPCItemDbFetcher::updateRequest(Data::EntryPtr entry_) { 0143 const QString isbn = entry_->field(QStringLiteral("isbn")); 0144 if(!isbn.isEmpty()) { 0145 return FetchRequest(ISBN, isbn); 0146 } 0147 0148 const QString upc = entry_->field(QStringLiteral("upc")); 0149 if(!upc.isEmpty()) { 0150 return FetchRequest(UPC, upc); 0151 } 0152 0153 const QString barcode = entry_->field(QStringLiteral("barcode")); 0154 if(!barcode.isEmpty()) { 0155 return FetchRequest(UPC, barcode); 0156 } 0157 0158 return FetchRequest(); 0159 } 0160 0161 void UPCItemDbFetcher::slotComplete(KJob* job_) { 0162 KIO::StoredTransferJob* job = static_cast<KIO::StoredTransferJob*>(job_); 0163 0164 if(job->error()) { 0165 job->uiDelegate()->showErrorMessage(); 0166 stop(); 0167 return; 0168 } 0169 0170 const QByteArray data = job->data(); 0171 if(data.isEmpty()) { 0172 myDebug() << "No data"; 0173 stop(); 0174 return; 0175 } 0176 // see bug 319662. If fetcher is cancelled, job is killed 0177 // if the pointer is retained, it gets double-deleted 0178 m_job = nullptr; 0179 0180 #if 0 0181 myWarning() << "Remove debug from upcitemdbfetcher.cpp"; 0182 QFile f(QStringLiteral("/tmp/test-upcitemdb.json")); 0183 if(f.open(QIODevice::WriteOnly)) { 0184 QTextStream t(&f); 0185 t.setCodec("UTF-8"); 0186 t << data; 0187 } 0188 f.close(); 0189 #endif 0190 0191 QJsonDocument doc = QJsonDocument::fromJson(data); 0192 if(doc.isNull()) { 0193 myDebug() << "null JSON document"; 0194 stop(); 0195 return; 0196 } 0197 const auto obj = doc.object(); 0198 // check for error 0199 if(obj.value(QStringLiteral("code")) == QLatin1String("TOO_FAST")) { 0200 const QString msg = obj.value(QStringLiteral("message")).toString(); 0201 message(msg, MessageHandler::Error); 0202 myDebug() << "UPCItemDbFetcher -" << msg; 0203 stop(); 0204 return; 0205 } 0206 0207 Data::CollPtr coll = CollectionFactory::collection(collectionType(), true); 0208 if(!coll) { 0209 stop(); 0210 return; 0211 } 0212 0213 if(optionalFields().contains(QStringLiteral("barcode"))) { 0214 Data::FieldPtr field(new Data::Field(QStringLiteral("barcode"), i18n("Barcode"))); 0215 field->setCategory(i18n("General")); 0216 coll->addField(field); 0217 } 0218 0219 QJsonArray results = obj.value(QLatin1String("items")).toArray(); 0220 if(results.isEmpty()) { 0221 myLog() << "No results"; 0222 stop(); 0223 return; 0224 } 0225 0226 int count = 0; 0227 foreach(const QJsonValue& result, results) { 0228 // myDebug() << "found result:" << result; 0229 0230 Data::EntryPtr entry(new Data::Entry(coll)); 0231 populateEntry(entry, result.toObject().toVariantMap()); 0232 0233 FetchResult* r = new FetchResult(this, entry); 0234 m_entries.insert(r->uid, entry); 0235 emit signalResultFound(r); 0236 ++count; 0237 if(count >= UPCITEMDB_MAX_RETURNS_TOTAL) { 0238 break; 0239 } 0240 } 0241 0242 stop(); 0243 } 0244 0245 Tellico::Data::EntryPtr UPCItemDbFetcher::fetchEntryHook(uint uid_) { 0246 Data::EntryPtr entry = m_entries.value(uid_); 0247 if(!entry) { 0248 myWarning() << "no entry in dict"; 0249 return Data::EntryPtr(); 0250 } 0251 0252 // image might still be a URL 0253 const QString cover(QStringLiteral("cover")); 0254 const QString image_id = entry->field(cover); 0255 if(image_id.contains(QLatin1Char('/'))) { 0256 const QUrl imageUrl = QUrl::fromUserInput(image_id); 0257 const QString id = ImageFactory::addImage(imageUrl, false /* quiet */, imageUrl.adjusted(QUrl::RemovePath)); 0258 if(id.isEmpty()) { 0259 myDebug() << "image id is empty"; 0260 message(i18n("The cover image could not be loaded."), MessageHandler::Warning); 0261 } 0262 // empty image ID is ok 0263 entry->setField(cover, id); 0264 } 0265 0266 return entry; 0267 } 0268 0269 void UPCItemDbFetcher::populateEntry(Data::EntryPtr entry_, const QVariantMap& resultMap_) { 0270 entry_->setField(QStringLiteral("title"), mapValue(resultMap_, "title")); 0271 parseTitle(entry_); 0272 // entry_->setField(QStringLiteral("year"), mapValue(resultMap_, "premiered").left(4)); 0273 const QString barcode = QStringLiteral("barcode"); 0274 if(optionalFields().contains(barcode)) { 0275 entry_->setField(barcode, mapValue(resultMap_, "upc")); 0276 } 0277 0278 // take the first cover 0279 const auto imageList = resultMap_.value(QLatin1String("images")).toList(); 0280 for(const auto& imageValue : imageList) { 0281 // skip booksamillion images 0282 const QString image = imageValue.toString(); 0283 if(!image.isEmpty() && !image.contains(QLatin1String("booksamillion.com"))) { 0284 entry_->setField(QStringLiteral("cover"), image); 0285 break; 0286 } 0287 } 0288 0289 switch(collectionType()) { 0290 case Data::Collection::Video: 0291 entry_->setField(QStringLiteral("studio"), mapValue(resultMap_, "brand")); 0292 entry_->setField(QStringLiteral("plot"), mapValue(resultMap_, "description")); 0293 break; 0294 0295 case Data::Collection::Book: 0296 entry_->setField(QStringLiteral("publisher"), mapValue(resultMap_, "publisher")); 0297 entry_->setField(QStringLiteral("isbn"), mapValue(resultMap_, "isbn")); 0298 break; 0299 0300 case Data::Collection::Album: 0301 entry_->setField(QStringLiteral("label"), mapValue(resultMap_, "brand")); 0302 { 0303 const QString cat = mapValue(resultMap_, "category"); 0304 if(cat.contains(QStringLiteral("Music CDs"))) { 0305 entry_->setField(QStringLiteral("medium"), i18n("Compact Disc")); 0306 } 0307 } 0308 break; 0309 0310 case Data::Collection::Game: 0311 case Data::Collection::BoardGame: 0312 entry_->setField(QStringLiteral("publisher"), mapValue(resultMap_, "brand")); 0313 entry_->setField(QStringLiteral("description"), mapValue(resultMap_, "description")); 0314 break; 0315 0316 default: 0317 break; 0318 } 0319 0320 // do this after all other parsing 0321 parseTitle(entry_); 0322 } 0323 0324 void UPCItemDbFetcher::parseTitle(Tellico::Data::EntryPtr entry_) { 0325 // assume that everything in brackets or parentheses is extra 0326 static const QRegularExpression rx(QLatin1String("[\\(\\[](.*?)[\\)\\]]")); 0327 QString title = entry_->field(QStringLiteral("title")); 0328 int pos = 0; 0329 QRegularExpressionMatch match = rx.match(title, pos); 0330 while(match.hasMatch()) { 0331 pos = match.capturedStart(); 0332 if(parseTitleToken(entry_, match.captured(1))) { 0333 title.remove(match.capturedStart(), match.capturedLength()); 0334 --pos; // search again there 0335 } 0336 match = rx.match(title, pos+1); 0337 } 0338 // look for "word1 - word2" 0339 static const QRegularExpression dashWords(QLatin1String("(.+) - (.+)")); 0340 QRegularExpressionMatch dashMatch = dashWords.match(title); 0341 if(dashMatch.hasMatch()) { 0342 switch(collectionType()) { 0343 case Data::Collection::Book: 0344 title = dashMatch.captured(1); 0345 { 0346 static const QRegularExpression byAuthor(QLatin1String("by (.+)")); 0347 QRegularExpressionMatch authorMatch = byAuthor.match(dashMatch.captured(2)); 0348 if(authorMatch.hasMatch()) { 0349 entry_->setField(QStringLiteral("author"), authorMatch.captured(1).simplified()); 0350 } 0351 } 0352 break; 0353 0354 case Data::Collection::Album: 0355 entry_->setField(QStringLiteral("artist"), dashMatch.captured(1).simplified()); 0356 title = dashMatch.captured(2); 0357 break; 0358 0359 case Data::Collection::Game: 0360 title = dashMatch.captured(1); 0361 { 0362 const QString platform = QStringLiteral("platform"); 0363 const QString maybe = i18n(dashMatch.captured(2).simplified().toUtf8().constData()); 0364 Data::FieldPtr f = entry_->collection()->fieldByName(platform); 0365 if(f && f->allowed().contains(maybe)) { 0366 entry_->setField(platform, maybe); 0367 } 0368 } 0369 break; 0370 } 0371 } 0372 entry_->setField(QStringLiteral("title"), title.simplified()); 0373 } 0374 0375 // mostly taken from amazonfetcher 0376 bool UPCItemDbFetcher::parseTitleToken(Tellico::Data::EntryPtr entry_, const QString& token_) { 0377 // myDebug() << "title token:" << token_; 0378 // if res = true, then the token gets removed from the title 0379 bool res = false; 0380 static const QRegularExpression yearRx(QLatin1String("\\d{4}")); 0381 QRegularExpressionMatch yearMatch = yearRx.match(token_); 0382 if(yearMatch.hasMatch()) { 0383 entry_->setField(QStringLiteral("year"), yearMatch.captured()); 0384 res = true; 0385 } 0386 if(token_.indexOf(QLatin1String("widescreen"), 0, Qt::CaseInsensitive) > -1 || 0387 token_.indexOf(i18n("Widescreen"), 0, Qt::CaseInsensitive) > -1) { 0388 entry_->setField(QStringLiteral("widescreen"), QStringLiteral("true")); 0389 // res = true; leave it in the title 0390 } else if(token_.indexOf(QLatin1String("full screen"), 0, Qt::CaseInsensitive) > -1) { 0391 // skip, but go ahead and remove from title 0392 res = true; 0393 } else if(token_.indexOf(QLatin1String("standard edition"), 0, Qt::CaseInsensitive) > -1) { 0394 // skip, but go ahead and remove from title 0395 res = true; 0396 } else if(token_.indexOf(QLatin1String("import"), 0, Qt::CaseInsensitive) > -1) { 0397 // skip, but go ahead and remove from title 0398 res = true; 0399 } 0400 if(token_.indexOf(QLatin1String("blu-ray"), 0, Qt::CaseInsensitive) > -1) { 0401 entry_->setField(QStringLiteral("medium"), i18n("Blu-ray")); 0402 res = true; 0403 } else if(token_.indexOf(QLatin1String("hd dvd"), 0, Qt::CaseInsensitive) > -1) { 0404 entry_->setField(QStringLiteral("medium"), i18n("HD DVD")); 0405 res = true; 0406 } else if(token_.indexOf(QLatin1String("vhs"), 0, Qt::CaseInsensitive) > -1) { 0407 entry_->setField(QStringLiteral("medium"), i18n("VHS")); 0408 res = true; 0409 } 0410 if(token_.indexOf(QLatin1String("director's cut"), 0, Qt::CaseInsensitive) > -1 || 0411 token_.indexOf(i18n("Director's Cut"), 0, Qt::CaseInsensitive) > -1) { 0412 entry_->setField(QStringLiteral("directors-cut"), QStringLiteral("true")); 0413 // res = true; leave it in the title 0414 } 0415 const QString tokenLower = token_.toLower(); 0416 if(tokenLower == QLatin1String("ntsc")) { 0417 entry_->setField(QStringLiteral("format"), i18n("NTSC")); 0418 res = true; 0419 } 0420 if(tokenLower == QLatin1String("dvd")) { 0421 entry_->setField(QStringLiteral("medium"), i18n("DVD")); 0422 res = true; 0423 } 0424 if(tokenLower == QLatin1String("cd") && collectionType() == Data::Collection::Album) { 0425 entry_->setField(QStringLiteral("medium"), i18n("Compact Disc")); 0426 res = true; 0427 } 0428 if(tokenLower == QLatin1String("dvd")) { 0429 entry_->setField(QStringLiteral("medium"), i18n("DVD")); 0430 res = true; 0431 } 0432 if(token_.indexOf(QLatin1String("series"), 0, Qt::CaseInsensitive) > -1) { 0433 entry_->setField(QStringLiteral("series"), token_); 0434 res = true; 0435 } 0436 static const QRegularExpression regionRx(QLatin1String("Region [1-9]")); 0437 QRegularExpressionMatch match = regionRx.match(token_); 0438 if(match.hasMatch()) { 0439 entry_->setField(QStringLiteral("region"), i18n(match.captured().toUtf8().constData())); 0440 res = true; 0441 } 0442 if(collectionType() == Data::Collection::Game) { 0443 Data::FieldPtr f = entry_->collection()->fieldByName(QStringLiteral("platform")); 0444 if(f && f->allowed().contains(token_)) { 0445 res = true; 0446 } 0447 } else if(collectionType() == Data::Collection::Book) { 0448 const QString binding = QStringLiteral("binding"); 0449 Data::FieldPtr f = entry_->collection()->fieldByName(binding); 0450 const QString maybe = i18n(token_.toUtf8().constData()); 0451 if(f && f->allowed().contains(maybe)) { 0452 entry_->setField(binding, maybe); 0453 res = true; 0454 } 0455 } 0456 return res; 0457 } 0458 0459 Tellico::Fetch::ConfigWidget* UPCItemDbFetcher::configWidget(QWidget* parent_) const { 0460 return new UPCItemDbFetcher::ConfigWidget(parent_, this); 0461 } 0462 0463 QString UPCItemDbFetcher::defaultName() { 0464 return QStringLiteral("UPCitemdb"); // this is the capitalization they use on their site 0465 } 0466 0467 QString UPCItemDbFetcher::defaultIcon() { 0468 return favIcon("https://www.upcitemdb.com"); 0469 } 0470 0471 Tellico::StringHash UPCItemDbFetcher::allOptionalFields() { 0472 StringHash hash; 0473 hash[QStringLiteral("barcode")] = i18n("Barcode"); 0474 return hash; 0475 } 0476 0477 UPCItemDbFetcher::ConfigWidget::ConfigWidget(QWidget* parent_, const UPCItemDbFetcher* fetcher_) 0478 : Fetch::ConfigWidget(parent_) { 0479 QVBoxLayout* l = new QVBoxLayout(optionsWidget()); 0480 l->addWidget(new QLabel(i18n("This source has no options."), optionsWidget())); 0481 l->addStretch(); 0482 0483 // now add additional fields widget 0484 addFieldsWidget(UPCItemDbFetcher::allOptionalFields(), fetcher_ ? fetcher_->optionalFields() : QStringList()); 0485 } 0486 0487 QString UPCItemDbFetcher::ConfigWidget::preferredName() const { 0488 return UPCItemDbFetcher::defaultName(); 0489 }