File indexing completed on 2025-01-19 03:53:03

0001 /* ============================================================
0002  *
0003  * This file is a part of digiKam project
0004  * https://www.digikam.org
0005  *
0006  * Date        : 2021-03-20
0007  * Description : a tool to export images to iNaturalist web service
0008  *
0009  * SPDX-FileCopyrightText: 2021-2022 by Joerg Lohse <joergmlpts at gmail dot com>
0010  *
0011  * SPDX-License-Identifier: GPL-2.0-or-later
0012  *
0013  * ============================================================ */
0014 
0015 #include "inattalker.h"
0016 
0017 // Qt includes
0018 
0019 #include <QByteArray>
0020 #include <QMap>
0021 #include <QUrlQuery>
0022 #include <QHttpMultiPart>
0023 #include <QStringList>
0024 #include <QMessageBox>
0025 #include <QApplication>
0026 #include <QProgressDialog>
0027 #include <QJsonDocument>
0028 #include <QJsonObject>
0029 #include <QJsonArray>
0030 #include <QTimer>
0031 
0032 // KDE includes
0033 
0034 #include <klocalizedstring.h>
0035 
0036 // Local includes
0037 
0038 #include "wstoolutils.h"
0039 #include "inatutils.h"
0040 #include "digikam_debug.h"
0041 #include "digikam_config.h"
0042 #include "digikam_version.h"
0043 #include "previewloadthread.h"
0044 #include "inatbrowserdlg.h"
0045 #include "networkmanager.h"
0046 
0047 // OAuth2 library includes
0048 
0049 #if defined(Q_CC_CLANG)
0050 #   pragma clang diagnostic push
0051 #   pragma clang diagnostic ignored "-Wextra-semi"
0052 #endif
0053 
0054 #include "o0globals.h"
0055 #include "o0settingsstore.h"
0056 
0057 #if defined(Q_CC_CLANG)
0058 #   pragma clang diagnostic pop
0059 #endif
0060 
0061 namespace DigikamGenericINatPlugin
0062 {
0063 
0064 static const char COOKIE_SEPARATOR          = '\n';
0065 
0066 enum
0067 {
0068     INAT_API_TOKEN_EXPIRATION               = 86000, ///< api tokens are valid for 24 hours
0069     GEOLOCATION_PRECISION                   =     8, ///< # digits after decimal point
0070     RADIUS_PRECISION                        =     6, ///< # digits after decimal point
0071     EARTH_RADIUS_KM                         =  6371, ///< Earth radius in kilometers
0072     TIMEOUT_TIMER_RESOLUTION_SECS           =    30, ///< timeout check every 30 seconds
0073     RESPONSE_TIMEOUT_SECS                   =   300, ///< network timeout after this many seconds
0074     MAX_RETRIES                             =     5  ///< retry network requests this many times
0075 };
0076 
0077 /**
0078  * Fields in server responses received from iNaturalist.
0079  */
0080 static const QString API_TOKEN              = QLatin1String("api_token");
0081 static const QString TOTAL_RESULTS          = QLatin1String("total_results");
0082 static const QString PAGE                   = QLatin1String("page");
0083 static const QString PER_PAGE               = QLatin1String("per_page");
0084 static const QString LOCALE                 = QLatin1String("locale");
0085 static const QString RESULTS                = QLatin1String("results");
0086 static const QString NAME                   = QLatin1String("name");
0087 static const QString TAXON                  = QLatin1String("taxon");
0088 static const QString TAXON_ID               = QLatin1String("taxon_id");
0089 static const QString ID                     = QLatin1String("id");
0090 static const QString PARENT_ID              = QLatin1String("parent_id");
0091 static const QString RANK                   = QLatin1String("rank");
0092 static const QString RANK_LEVEL             = QLatin1String("rank_level");
0093 static const QString PREFERRED_COMMON_NAME  = QLatin1String("preferred_common_name");
0094 static const QString ENGLISH_COMMON_NAME    = QLatin1String("english_common_name");
0095 static const QString MATCHED_TERM           = QLatin1String("matched_term");
0096 static const QString DEFAULT_PHOTO          = QLatin1String("default_photo");
0097 static const QString SQUARE_URL             = QLatin1String("square_url");
0098 static const QString ANCESTORS              = QLatin1String("ancestors");
0099 static const QString OBSCURED               = QLatin1String("obscured");
0100 static const QString GEOJSON                = QLatin1String("geojson");
0101 static const QString COORDINATES            = QLatin1String("coordinates");
0102 static const QString LOGIN                  = QLatin1String("login");
0103 static const QString ICON                   = QLatin1String("icon");
0104 static const QString OBSERVATION            = QLatin1String("observation");
0105 static const QString OBSERVATIONS           = QLatin1String("observations");
0106 static const QString OBSERVED_ON            = QLatin1String("observed_on");
0107 static const QString OBSERVED_ON_STRING     = QLatin1String("observed_on_string");
0108 static const QString OBSERVATION_PHOTOS     = QLatin1String("observation_photos");
0109 static const QString PHOTO                  = QLatin1String("photo");
0110 
0111 static QJsonObject parseJsonResponse(const QByteArray& data)
0112 {
0113     QJsonParseError err;
0114     QJsonDocument doc = QJsonDocument::fromJson(data, &err);
0115 
0116     if (err.error != QJsonParseError::NoError)
0117     {
0118         qCWarning(DIGIKAM_WEBSERVICES_LOG) << "parseJsonResponse: Failed to parse json response:"
0119                                            << err.errorString();
0120 
0121         return QJsonObject();
0122     }
0123 
0124     if (!doc.isObject())
0125     {
0126         qCWarning(DIGIKAM_WEBSERVICES_LOG) << "parseJsonResponse: Json response is not an object!";
0127 
0128         return QJsonObject();
0129     }
0130 
0131     return doc.object();
0132 }
0133 
0134 static Taxon parseTaxon(const QJsonObject& taxon)
0135 {
0136     int id           = -1;
0137     int parentId     = -1;
0138     double rankLevel = -1.0;
0139     QString name;
0140     QString rank;
0141     QString commonName;
0142     QString matchedTerm;
0143     QUrl squareUrl;
0144     QList<Taxon> ancestors;
0145 
0146     if (taxon.contains(NAME))
0147     {
0148         name = taxon[NAME].toString();
0149     }
0150 
0151     if (taxon.contains(ID))
0152     {
0153         id = taxon[ID].toInt();
0154     }
0155 
0156     if (taxon.contains(PARENT_ID))
0157     {
0158         parentId = taxon[PARENT_ID].toInt();
0159     }
0160 
0161     if (taxon.contains(RANK))
0162     {
0163         rank = taxon[RANK].toString();
0164     }
0165 
0166     if (taxon.contains(RANK_LEVEL))
0167     {
0168         rankLevel = taxon[RANK_LEVEL].toDouble();
0169     }
0170 
0171     if      (taxon.contains(PREFERRED_COMMON_NAME))
0172     {
0173         commonName = taxon[PREFERRED_COMMON_NAME].toString();
0174     }
0175     else if (isEnglish && taxon.contains(ENGLISH_COMMON_NAME))
0176     {
0177         commonName = taxon[ENGLISH_COMMON_NAME].toString();
0178     }
0179 
0180     if (taxon.contains(MATCHED_TERM))
0181     {
0182         matchedTerm = taxon[MATCHED_TERM].toString();
0183     }
0184 
0185     if (taxon.contains(DEFAULT_PHOTO) &&
0186         taxon[DEFAULT_PHOTO].toObject().contains(SQUARE_URL))
0187     {
0188         squareUrl = QUrl(taxon[DEFAULT_PHOTO].toObject()
0189                          [SQUARE_URL].toString());
0190     }
0191 
0192     if (taxon.contains(ANCESTORS))
0193     {
0194         for (const auto& ancestorTaxon : taxon[ANCESTORS].toArray())
0195         {
0196             ancestors << parseTaxon(ancestorTaxon.toObject());
0197         }
0198     }
0199 
0200     return Taxon(id, parentId, name, rank, rankLevel, commonName,
0201                  matchedTerm, squareUrl, ancestors);
0202 }
0203 
0204 // ------------------------------------------------------------------------------------------
0205 
0206 /**
0207  * A request consists in a state and a function that is called with the response.
0208  */
0209 class Q_DECL_HIDDEN Request
0210 {
0211 public:
0212 
0213     Request()
0214         : m_startTime(QDateTime::currentMSecsSinceEpoch())
0215     {
0216     }
0217 
0218     virtual ~Request()
0219     {
0220     }
0221 
0222     static bool networkErrorRetry(QNetworkReply::NetworkError code)
0223     {
0224         return (code == QNetworkReply::ConnectionRefusedError       ||
0225                 code == QNetworkReply::RemoteHostClosedError        ||
0226                 code == QNetworkReply::HostNotFoundError            ||
0227                 code == QNetworkReply::TimeoutError                 ||
0228                 code == QNetworkReply::TemporaryNetworkFailureError ||
0229                 code == QNetworkReply::NetworkSessionFailedError    ||
0230                 code == QNetworkReply::InternalServerError          ||
0231                 code == QNetworkReply::ServiceUnavailableError      ||
0232                 code == QNetworkReply::UnknownServerError);
0233     }
0234 
0235     virtual void reportError(INatTalker&, QNetworkReply::NetworkError,
0236                              const QString& errorString) const = 0;
0237 
0238     virtual void parseResponse(INatTalker& talker,
0239                                const QByteArray& data) const = 0;
0240 
0241     // Has operation timed out?
0242 
0243     bool isTimeout() const
0244     {
0245         return (durationMilliSecs() > 1000 * RESPONSE_TIMEOUT_SECS);
0246     }
0247 
0248     // How long did it take?
0249 
0250     qint64 durationMilliSecs() const
0251     {
0252         return (QDateTime::currentMSecsSinceEpoch() - m_startTime);
0253     }
0254 
0255 private:
0256 
0257     qint64 m_startTime;
0258 
0259 private:
0260 
0261     Q_DISABLE_COPY(Request)
0262 };
0263 
0264 // --------------------------------------------------------------------------------
0265 
0266 class Q_DECL_HIDDEN INatTalker::Private
0267 {
0268 public:
0269 
0270     explicit Private()
0271         : parent          (nullptr),
0272           netMngr         (nullptr),
0273           timer           (nullptr),
0274           settings        (nullptr),
0275           iface           (nullptr),
0276           store           (nullptr),
0277           apiTokenExpires (0)
0278     {
0279         QString cryptId(QLatin1String("119b0b8a57644341fe03eca486a341"));
0280 
0281         apiUrl     = QLatin1String("https://api.inaturalist.org/v1/");
0282         keyToken   = QString(QLatin1String(O2_KEY_TOKEN)).arg(cryptId);
0283         keyExpires = QString(QLatin1String(O2_KEY_EXPIRES)).arg(cryptId);
0284         keyCookies = QString(QLatin1String("cookies.%1")).arg(cryptId);
0285     }
0286 
0287     void clear()
0288     {
0289         apiTokenExpires = 0;
0290         apiToken        = QString();
0291     }
0292 
0293     QWidget*                          parent;
0294     QNetworkAccessManager*            netMngr;
0295     QTimer*                           timer;
0296     QSettings*                        settings;
0297     DInfoInterface*                   iface;
0298     O0SettingsStore*                  store;
0299 
0300     QString                           serviceName;
0301     QString                           apiUrl;
0302 
0303     /// keys used in O0SettingsStore
0304     QString                           keyToken;
0305     QString                           keyExpires;
0306     QString                           keyCookies;
0307 
0308     /// the api token and its expiration time in seconds since January 1st, 1970
0309     QString                           apiToken;
0310     uint                              apiTokenExpires;
0311 
0312     /// this hash table allows us to serve multiple requests concurrently
0313     QHash<QNetworkReply*, Request*>   pendingRequests;
0314 
0315     /// cached api call results
0316     QHash<QString, AutoCompletions>   cachedAutoCompletions;
0317     QHash<QUrl, QByteArray>           cachedLoadUrls;
0318     QHash<QString, ImageScores>       cachedImageScores;
0319     QHash<QString, QStringList>       cachedNearbyPlaces;
0320     QHash<QString, NearbyObservation> cachedNearbyObservations;
0321 };
0322 
0323 INatTalker::INatTalker(QWidget* const parent, const QString& serviceName,
0324                        DInfoInterface* const iface)
0325     : d(new Private)
0326 {
0327     d->parent          = parent;
0328     d->serviceName     = serviceName;
0329     d->iface           = iface;
0330     m_authProgressDlg  = nullptr;
0331 
0332     d->netMngr         = NetworkManager::instance()->getNetworkManager(this);
0333     d->timer           = new QTimer(this);
0334 
0335     connect(d->netMngr, SIGNAL(finished(QNetworkReply*)),
0336             this, SLOT(slotFinished(QNetworkReply*)));
0337 
0338     connect(d->timer, SIGNAL(timeout()),
0339             this, SLOT(slotTimeout()));
0340 
0341     d->settings = WSToolUtils::getOauthSettings(this);
0342     d->store    = new O0SettingsStore(d->settings,
0343                                       QLatin1String(O2_ENCRYPTION_KEY), this);
0344     d->store->setGroupKey(d->serviceName);
0345     d->timer->start(TIMEOUT_TIMER_RESOLUTION_SECS * 1000);
0346 }
0347 
0348 INatTalker::~INatTalker()
0349 {
0350     d->timer->stop();
0351     d->clear();
0352     WSToolUtils::removeTemporaryDir(d->serviceName.toLatin1().constData());
0353 
0354     delete d;
0355 }
0356 
0357 /**
0358  * Try to restore a valid API token; they are good for 24 hours.
0359  */
0360 bool INatTalker::restoreApiToken(const QString& username,
0361                                  QList<QNetworkCookie>& cookies,
0362                                  bool emitSignal)
0363 {
0364     cookies.clear();
0365 
0366     if (username.isEmpty())
0367     {
0368         return false;
0369     }
0370 
0371     d->store->setGroupKey(d->serviceName + username);
0372     d->apiToken        = d->store->value(d->keyToken);
0373     d->apiTokenExpires = d->store->value(d->keyExpires,
0374                                          QString::number(0)).toInt();
0375     QString cookiesStr = d->store->value(d->keyCookies);
0376 
0377     if (!cookiesStr.isEmpty())
0378     {
0379         QDateTime now(QDateTime::currentDateTime());
0380 
0381         for (const auto& str : cookiesStr.split(QLatin1Char(COOKIE_SEPARATOR)))
0382         {
0383             QList<QNetworkCookie> lst(QNetworkCookie::parseCookies(str.toUtf8()));
0384 
0385             Q_ASSERT(lst.count() == 1);
0386 
0387             for (const auto& cookie : lst)
0388             {
0389                 if (INatBrowserDlg::filterCookie(cookie, true, now))
0390                 {
0391                     cookies << cookie;
0392                 }
0393             }
0394         }
0395     }
0396 
0397     bool valid = (emitSignal && (apiTokenExpiresIn() > 0));
0398 
0399     if (valid)
0400     {
0401         userInfo(cookies);
0402     }
0403 
0404     return valid;
0405 }
0406 
0407 bool INatTalker::stillUploading() const
0408 {
0409     return d->pendingRequests.count();
0410 }
0411 
0412 void INatTalker::unLink()
0413 {
0414     d->clear();
0415 }
0416 
0417 void INatTalker::removeUserName(const QString& userName)
0418 {
0419     if (userName.startsWith(d->serviceName))
0420     {
0421         d->settings->beginGroup(userName);
0422         d->settings->remove(QString());
0423         d->settings->endGroup();
0424     }
0425 }
0426 
0427 int INatTalker::apiTokenExpiresIn() const
0428 {
0429     if (d->apiToken.isEmpty())
0430     {
0431         return -1;
0432     }
0433 
0434     uint time = (uint)(QDateTime::currentMSecsSinceEpoch() / 1000);
0435 
0436     return (
0437             (d->apiTokenExpires <= time) ? -1
0438                                          : (d->apiTokenExpires - time)
0439            );
0440 }
0441 
0442 /**
0443  * We received an api token from a web browser login.
0444  */
0445 void INatTalker::slotApiToken(const QString& apiToken,
0446                               const QList<QNetworkCookie>& cookies)
0447 {
0448     d->apiToken = apiToken;
0449 
0450     if (apiToken.isEmpty())
0451     {
0452         Q_EMIT signalLinkingFailed(QLatin1String("no api token"));
0453         return;
0454     }
0455     else
0456     {
0457         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "API token received; "
0458                                             "querying user info.";
0459 
0460         d->apiTokenExpires = (uint)(QDateTime::currentMSecsSinceEpoch() / 1000 +
0461                                     INAT_API_TOKEN_EXPIRATION);
0462         userInfo(cookies);
0463     }
0464 }
0465 
0466 // ------------------------------------------------------------------------------------------
0467 
0468 /**
0469  * Get login, name, and icon of authorized user.
0470  */
0471 class Q_DECL_HIDDEN UserRequest : public Request
0472 {
0473 public:
0474 
0475     explicit UserRequest(const QList<QNetworkCookie>& cookies)
0476         : m_cookies(cookies)
0477     {
0478     }
0479 
0480     void reportError(INatTalker& talker, QNetworkReply::NetworkError,
0481                      const QString& errorString) const override
0482     {
0483         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "users/me error" << errorString
0484                                          << "after" << durationMilliSecs() << "msecs.";
0485 
0486         Q_EMIT talker.signalLinkingFailed(QLatin1String("user-info request "
0487                                                       "failed"));
0488     }
0489 
0490     void parseResponse(INatTalker& talker,
0491                        const QByteArray& data) const override
0492     {
0493         QJsonObject json = parseJsonResponse(data);
0494 
0495         if (json.contains(RESULTS) && (json[RESULTS].toArray().count() == 1))
0496         {
0497             QJsonObject result = json[RESULTS].toArray()[0].toObject();
0498             QString username(result[LOGIN].toString());
0499             Q_EMIT talker.signalLinkingSucceeded(username,
0500                                                result[NAME].toString(),
0501                                                QUrl(result[ICON].toString()));
0502             talker.d->store->setGroupKey(talker.d->serviceName + username);
0503 
0504             // save api token
0505 
0506             talker.d->store->setValue(talker.d->keyToken, talker.d->apiToken);
0507 
0508             // save api token expiration
0509 
0510             talker.d->store->setValue(talker.d->keyExpires, QString::number(talker.d->apiTokenExpires));
0511 
0512             // save cookies
0513 
0514             QDateTime now(QDateTime::currentDateTime());
0515             QByteArray saveCookies;
0516 
0517             for (const auto& cookie : m_cookies)
0518             {
0519                 if (!INatBrowserDlg::filterCookie(cookie, true, now))
0520                 {
0521                     continue;
0522                 }
0523 
0524                 if (!saveCookies.isEmpty())
0525                 {
0526                     saveCookies += COOKIE_SEPARATOR;
0527                 }
0528 
0529                 saveCookies += cookie.toRawForm();
0530             }
0531 
0532             talker.d->store->setValue(talker.d->keyCookies,
0533                                       QString::fromUtf8(saveCookies));
0534         }
0535         else
0536         {
0537             Q_EMIT talker.signalLinkingFailed(QLatin1String("user-info request "
0538                                             "failed"));
0539         }
0540 
0541         if (talker.m_authProgressDlg)
0542         {
0543             talker.m_authProgressDlg->setValue(2);
0544             talker.m_authProgressDlg->hide();
0545         }
0546 
0547         Q_EMIT talker.signalBusy(false);
0548     }
0549 
0550 private:
0551 
0552     QList<QNetworkCookie> m_cookies;
0553 };
0554 
0555 void INatTalker::userInfo(const QList<QNetworkCookie>& cookies)
0556 {
0557     if (d->apiToken.isEmpty())
0558     {
0559         return;
0560     }
0561 
0562     Q_EMIT signalBusy(true);
0563 
0564     if (m_authProgressDlg)
0565     {
0566         m_authProgressDlg->setLabelText(QLatin1String("<font color=\"#74ac00\">") +
0567                                         i18n("iNaturalist")                       +
0568                                         QLatin1String("</font> ")                 +
0569                                         i18n("Login"));
0570         m_authProgressDlg->setMaximum(2);
0571         m_authProgressDlg->setValue(1);
0572         m_authProgressDlg->show();
0573     }
0574 
0575     QUrl url(d->apiUrl + QLatin1String("users/me"));
0576     QNetworkRequest netRequest(url);
0577     netRequest.setHeader(QNetworkRequest::ContentTypeHeader,
0578                          QLatin1String(O2_MIME_TYPE_JSON));
0579     netRequest.setRawHeader("Authorization", d->apiToken.toLatin1());
0580     d->pendingRequests.insert(d->netMngr->get(netRequest),
0581                               new UserRequest(cookies));
0582 }
0583 
0584 // ------------------------------------------------------------------------------------------
0585 
0586 /**
0587  * Load a URL and return result as QByteArry; used for images (icons).
0588  */
0589 class Q_DECL_HIDDEN LoadUrlRequest : public Request
0590 {
0591 public:
0592 
0593     LoadUrlRequest(const QUrl& url, int retries)
0594         : m_url    (url),
0595           m_retries(retries)
0596     {
0597     }
0598 
0599     void reportError(INatTalker& talker, QNetworkReply::NetworkError code,
0600                      const QString& errorString) const override
0601     {
0602         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Url" << m_url << "error" << errorString
0603                                          << "after" << durationMilliSecs() << "msecs.";
0604 
0605         if      (networkErrorRetry(code) && (m_retries < MAX_RETRIES))
0606         {
0607             qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Attempting to load" << m_url << "again, retry"
0608                                              << m_retries + 1 << "of" << MAX_RETRIES;
0609 
0610             talker.loadUrl(m_url, m_retries + 1);
0611         }
0612         else if (talker.d->cachedLoadUrls.contains(m_url))
0613         {
0614             talker.d->cachedLoadUrls.remove(m_url);
0615         }
0616     }
0617 
0618     void parseResponse(INatTalker& talker,
0619                        const QByteArray& data) const override
0620     {
0621         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Url" << m_url << "loaded in"
0622                                          << durationMilliSecs() << "msecs.";
0623 
0624         talker.d->cachedLoadUrls.insert(m_url, data);
0625 
0626         Q_EMIT talker.signalLoadUrlSucceeded(m_url, data);
0627     }
0628 
0629 private:
0630 
0631     QUrl m_url;
0632     int  m_retries;
0633 
0634 private:
0635 
0636     Q_DISABLE_COPY(LoadUrlRequest)
0637 };
0638 
0639 void INatTalker::loadUrl(const QUrl& imgUrl, int retries)
0640 {
0641     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Requesting url" << imgUrl.url();
0642 
0643     if (imgUrl.isEmpty() || imgUrl.isLocalFile() || imgUrl.isRelative())
0644     {
0645         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Ignoring; NOT loading url"
0646                                          << imgUrl;
0647         return;
0648     }
0649 
0650     if (d->cachedLoadUrls.contains(imgUrl))
0651     {
0652         const QByteArray& result = d->cachedLoadUrls.value(imgUrl);
0653 
0654         if (!result.isEmpty())
0655         {
0656             qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Url" << imgUrl
0657                                              << "found in cache.";
0658 
0659             Q_EMIT signalLoadUrlSucceeded(imgUrl, result);
0660         }
0661         else
0662         {
0663             qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Url load of" << imgUrl
0664                                              << "already in progress; ignoring request.";
0665         }
0666 
0667         return;
0668     }
0669 
0670     // empty cache entry means load in progress
0671 
0672     d->cachedLoadUrls.insert(imgUrl, QByteArray());
0673 
0674     QNetworkRequest netRequest(imgUrl);
0675     d->pendingRequests.insert(d->netMngr->get(netRequest),
0676                               new LoadUrlRequest(imgUrl, retries));
0677 }
0678 
0679 // ------------------------------------------------------------------------------------------
0680 
0681 /**
0682  *  taxon auto-completion
0683  */
0684 class Q_DECL_HIDDEN AutoCompletionRequest : public Request
0685 {
0686 public:
0687 
0688     explicit AutoCompletionRequest(const QString& name)
0689         : m_partialName(name)
0690     {
0691     }
0692 
0693     void reportError(INatTalker&, QNetworkReply::NetworkError,
0694                      const QString& errorString) const override
0695     {
0696         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Taxon auto-completion" << m_partialName
0697                                          << "error" << errorString
0698                                          << "after" << durationMilliSecs() << "msecs.";
0699     }
0700 
0701     void parseResponse(INatTalker& talker,
0702                        const QByteArray& data) const override
0703     {
0704         QJsonObject json = parseJsonResponse(data);
0705 
0706         if (json.contains(RESULTS))
0707         {
0708             QJsonArray results = json[RESULTS].toArray();
0709             QList<Taxon> taxa;
0710 
0711             for (const auto& result : results)
0712             {
0713                 taxa << parseTaxon(result.toObject());
0714             }
0715 
0716             QPair<QString, QList<Taxon> > result(m_partialName, taxa);
0717             talker.d->cachedAutoCompletions.insert(m_partialName, result);
0718 
0719             Q_EMIT talker.signalTaxonAutoCompletions(result);
0720         }
0721     }
0722 
0723 private:
0724 
0725     QString m_partialName;
0726 
0727 private:
0728 
0729     Q_DISABLE_COPY(AutoCompletionRequest)
0730 };
0731 
0732 void INatTalker::taxonAutoCompletions(const QString& partialName)
0733 {
0734     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Requesting taxon auto-completions for"
0735                                      <<  partialName;
0736 
0737     if (d->cachedAutoCompletions.contains(partialName))
0738     {
0739         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Taxon auto-completions for"
0740                                          <<  partialName << "found in cache.";
0741 
0742         Q_EMIT signalTaxonAutoCompletions(d->cachedAutoCompletions.
0743                                         value(partialName));
0744         return;
0745     }
0746 
0747     QUrl url(d->apiUrl + QLatin1String("taxa/autocomplete"));
0748     QUrlQuery query;
0749     query.addQueryItem(QLatin1String("q"),         partialName);
0750     query.addQueryItem(QLatin1String("is_active"), QLatin1String("true"));
0751     query.addQueryItem(PER_PAGE,                   QString::number(12));
0752     query.addQueryItem(LOCALE,                     locale.name());
0753     url.setQuery(query.query());
0754 
0755     QNetworkRequest netRequest(url);
0756     netRequest.setHeader(QNetworkRequest::ContentTypeHeader,
0757                          QLatin1String(O2_MIME_TYPE_JSON));
0758 
0759     d->pendingRequests.insert(d->netMngr->get(netRequest),
0760                               new AutoCompletionRequest(partialName));
0761 }
0762 
0763 // ------------------------------------------------------------------------------------------
0764 
0765 /**
0766  * get nearby places
0767  */
0768 class Q_DECL_HIDDEN NearbyPlacesRequest : public Request
0769 {
0770 
0771 public:
0772 
0773     NearbyPlacesRequest(double latitude, double longitude, const QString& query)
0774         : m_latitude (latitude),
0775           m_longitude(longitude),
0776           m_query    (query)
0777     {
0778         Q_UNUSED(m_latitude);
0779         Q_UNUSED(m_longitude);
0780     }
0781 
0782     void parseResponse(INatTalker& talker,
0783                        const QByteArray& data) const override
0784     {
0785         QJsonObject json = parseJsonResponse(data);
0786 
0787         if (json.contains(RESULTS))
0788         {
0789             static const QString BBOX_AREA    = QLatin1String("bbox_area");
0790             static const QString DISPLAY_NAME = QLatin1String("display_name");
0791             QJsonObject results               = json[RESULTS].toObject();
0792             QList<Place> places;
0793 
0794             for (const auto& key : results.keys())
0795             {
0796                 for (const auto& placeValue : results.value(key).toArray())
0797                 {
0798                     QJsonObject place = placeValue.toObject();
0799                     places.push_front(Place(place[DISPLAY_NAME].toString(),
0800                                             place[BBOX_AREA].toDouble()));
0801                 }
0802             }
0803 
0804             std::sort(places.begin(), places.end());
0805             QStringList placesStrList;
0806 
0807             for (const auto& place : places)
0808             {
0809                 placesStrList << place.m_name;
0810             }
0811 
0812             talker.d->cachedNearbyPlaces.insert(m_query, placesStrList);
0813 
0814             Q_EMIT talker.signalNearbyPlaces(placesStrList);
0815         }
0816     }
0817 
0818     void reportError(INatTalker&, QNetworkReply::NetworkError,
0819                      const QString& errorString) const override
0820     {
0821         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Nearby places error" << errorString
0822                                          << "after" << durationMilliSecs() << "msecs.";
0823     }
0824 
0825 private:
0826 
0827     struct Place
0828     {
0829         QString m_name;
0830         double  m_bboxArea;
0831 
0832         Place()
0833             : m_bboxArea(0.0)
0834         {
0835         }
0836 
0837         Place(const QString& n, double ba)
0838             : m_name    (n),
0839               m_bboxArea(ba)
0840         {
0841         }
0842 
0843         bool operator <(const Place& other) const
0844         {
0845             return (m_bboxArea < other.m_bboxArea);
0846         }
0847     };
0848 
0849 private:
0850 
0851     double  m_latitude;
0852     double  m_longitude;
0853     QString m_query;
0854 
0855 private:
0856 
0857     Q_DISABLE_COPY(NearbyPlacesRequest)
0858 };
0859 
0860 void INatTalker::nearbyPlaces(double latitude, double longitude)
0861 {
0862     QUrl url(d->apiUrl + QLatin1String("places/nearby"));
0863 
0864     QString lat = QString::number(latitude,  'f', GEOLOCATION_PRECISION);
0865     QString lng = QString::number(longitude, 'f', GEOLOCATION_PRECISION);
0866 
0867     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Requesting nearby places for lat"
0868                                      << lat << "lon" << lng;
0869     QUrlQuery query;
0870     query.addQueryItem(QLatin1String("nelat"), lat);
0871     query.addQueryItem(QLatin1String("nelng"), lng);
0872     query.addQueryItem(QLatin1String("swlat"), lat);
0873     query.addQueryItem(QLatin1String("swlng"), lng);
0874     query.addQueryItem(PER_PAGE, QString::number(100));
0875     url.setQuery(query.query());
0876 
0877     if (d->cachedNearbyPlaces.contains(query.query()))
0878     {
0879         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Nearby places for lat" << lat
0880                                          << "lon" << lng << "found in cache.";
0881 
0882         Q_EMIT signalNearbyPlaces(d->cachedNearbyPlaces.value(query.query()));
0883 
0884         return;
0885     }
0886 
0887     QNetworkRequest netRequest(url);
0888     netRequest.setHeader(QNetworkRequest::ContentTypeHeader,
0889                          QLatin1String(O2_MIME_TYPE_JSON));
0890 
0891     d->pendingRequests.insert(d->netMngr->get(netRequest),
0892                               new NearbyPlacesRequest(latitude, longitude,
0893                                                       query.query()));
0894 }
0895 
0896 // ------------------------------------------------------------------------------------------
0897 
0898 /**
0899  * Find the closest observation; used as a sanity check for identifications:
0900  * when the closest known observation is hundreds of kilometers away, we
0901  * have likely misidentified the organism in our photo.
0902  */
0903 class Q_DECL_HIDDEN NearbyObservationRequest : public Request
0904 {
0905 
0906 public:
0907 
0908     NearbyObservationRequest(uint taxon, double latitude, double longitude,
0909                              double km, const QString& query)
0910         : m_taxon    (taxon),
0911           m_latitude (latitude),
0912           m_longitude(longitude),
0913           m_radiusKm (km),
0914           m_query    (query)
0915     {
0916     }
0917 
0918     void parseResponse(INatTalker& talker,
0919                        const QByteArray& data) const override
0920     {
0921         QJsonObject json = parseJsonResponse(data);
0922 
0923         if (json.contains(TOTAL_RESULTS))
0924         {
0925             static const double MAX_DISTANGE_KM = EARTH_RADIUS_KM * M_PI;
0926             int total_results                   = json[TOTAL_RESULTS].toInt();
0927 
0928             if (total_results == 0)
0929             {
0930                 if (m_radiusKm < MAX_DISTANGE_KM)
0931                 {
0932                     // Try again, double the radius.
0933 
0934                     talker.closestObservation(m_taxon, m_latitude, m_longitude,
0935                                               2 * m_radiusKm, m_query);
0936                 }
0937                 else
0938                 {
0939                     // The entire Earth searched, no observation found.
0940 
0941                     talker.d->cachedNearbyObservations.insert(m_query,
0942                             INatTalker::NearbyObservation());
0943 
0944                     Q_EMIT talker.signalNearbyObservation(INatTalker::
0945                                                         NearbyObservation());
0946                 }
0947             }
0948             else
0949             {
0950                 INatTalker::NearbyObservation closestObscured
0951                 (
0952                     -1,
0953                     0.0,
0954                     0.0,
0955                     MAX_DISTANGE_KM * 1000.0,
0956                     true,
0957                     m_taxon,
0958                     m_latitude,
0959                     m_longitude
0960                 );
0961 
0962                 INatTalker::NearbyObservation closestOpen
0963                 (
0964                     -1,
0965                     0.0,
0966                     0.0,
0967                     MAX_DISTANGE_KM * 1000.0,
0968                     false,
0969                     m_taxon,
0970                     m_latitude,
0971                     m_longitude
0972                 );
0973 
0974                 QJsonArray results = json[RESULTS].toArray();
0975 
0976                 for (const auto& resultValue : results)
0977                 {
0978                     QJsonObject result = resultValue.toObject();
0979 
0980                     if (!result.contains(GEOJSON))
0981                     {
0982                         continue;
0983                     }
0984 
0985                     int observationId     = result[ID].toInt();
0986                     QJsonArray coordinates(result[GEOJSON].toObject()
0987                                            [COORDINATES].toArray());
0988                     double latitude       = coordinates[1].toDouble();
0989                     double longitude      = coordinates[0].toDouble();
0990                     bool obscured         = result[OBSCURED].toBool();
0991                     double distanceMeters = distanceBetween(m_latitude,
0992                                                             m_longitude,
0993                                                             latitude,
0994                                                             longitude);
0995 
0996                     if      (obscured)
0997                     {
0998                         if (distanceMeters < closestObscured.m_distanceMeters)
0999                         {
1000                             closestObscured.updateObservation(observationId,
1001                                                               latitude,
1002                                                               longitude,
1003                                                               distanceMeters);
1004                         }
1005                     }
1006                     else if (distanceMeters < closestOpen.m_distanceMeters)
1007                     {
1008                         closestOpen.updateObservation(observationId,
1009                                                       latitude, longitude,
1010                                                       distanceMeters);
1011                     }
1012                 }
1013 
1014                 double newDistanceMeters = closestOpen.isValid() ? closestOpen.m_distanceMeters
1015                                                                  : closestObscured.m_distanceMeters;
1016 
1017                 if ((results.count() < total_results) && (newDistanceMeters > 10.0))
1018                 {
1019                     // There are additional observations that we have not
1020                     // downloaded; request closer ones.
1021 
1022                     talker.closestObservation(m_taxon, m_latitude, m_longitude,
1023                                               newDistanceMeters / 999.0,
1024                                               m_query);
1025                 }
1026                 else
1027                 {
1028                     talker.d->cachedNearbyObservations.insert(m_query,
1029                             closestOpen.isValid() ? closestOpen
1030                                                   : closestObscured);
1031 
1032                     Q_EMIT talker.signalNearbyObservation(closestOpen.isValid()
1033                                                         ? closestOpen
1034                                                         : closestObscured);
1035                 }
1036             }
1037         }
1038     }
1039 
1040     void reportError(INatTalker&, QNetworkReply::NetworkError,
1041                      const QString& errorString) const override
1042     {
1043         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Closest observation error" << errorString
1044                                          << "after" << durationMilliSecs() << "msecs.";
1045     }
1046 
1047 private:
1048 
1049     uint    m_taxon;
1050     double  m_latitude;
1051     double  m_longitude;
1052     double  m_radiusKm;
1053     QString m_query;
1054 
1055 private:
1056 
1057     Q_DISABLE_COPY(NearbyObservationRequest)
1058 };
1059 
1060 void INatTalker::closestObservation(uint taxon, double latitude,
1061                                     double longitude, double radiusKm,
1062                                     const QString& origQuery)
1063 {
1064     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Requesting closest observation of"
1065                                      << taxon << "to" << latitude << longitude
1066                                      << "with radius" << radiusKm << "km.";
1067 
1068     QUrl url(d->apiUrl + OBSERVATIONS);
1069 
1070     QUrlQuery query;
1071     query.addQueryItem(QLatin1String("geo"), QLatin1String("true"));
1072     query.addQueryItem(TAXON_ID, QString::number(taxon));
1073     query.addQueryItem(QLatin1String("lat"), QString::number(latitude, 'f',
1074                        GEOLOCATION_PRECISION));
1075     query.addQueryItem(QLatin1String("lng"), QString::number(longitude, 'f',
1076                        GEOLOCATION_PRECISION));
1077     query.addQueryItem(QLatin1String("radius"), QString::number(radiusKm, 'f',
1078                        RADIUS_PRECISION));
1079     query.addQueryItem(QLatin1String("quality_grade"),
1080                        QLatin1String("research"));
1081     query.addQueryItem(LOCALE, locale.name());
1082     query.addQueryItem(PER_PAGE, QString::number(100));
1083     url.setQuery(query.query());
1084 
1085     if (d->cachedNearbyObservations.contains(query.query()))
1086     {
1087         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Closest observation of"
1088                                          << taxon << "at" << latitude
1089                                          << longitude << "with radius"
1090                                          << radiusKm << "km found in cache.";
1091 
1092         Q_EMIT signalNearbyObservation(d->cachedNearbyObservations.value(
1093                                          query.query()));
1094         return;
1095     }
1096 
1097     QNetworkRequest netRequest(url);
1098     netRequest.setHeader(QNetworkRequest::ContentTypeHeader,
1099                          QLatin1String(O2_MIME_TYPE_JSON));
1100 
1101     d->pendingRequests.insert(d->netMngr->get(netRequest),
1102                               new NearbyObservationRequest(taxon, latitude, longitude, radiusKm,
1103                                                            origQuery.isEmpty() ? query.query()
1104                                                                                : origQuery));
1105 }
1106 
1107 // ------------------------------------------------------------------------------------------
1108 
1109 /**
1110  *  get taxon suggestions for an image
1111  */
1112 class Q_DECL_HIDDEN ComputerVisionRequest : public Request
1113 {
1114 
1115 public:
1116 
1117     ComputerVisionRequest(const QString& imgPath, const QString& tmpFile)
1118         : m_imagePath  (imgPath),
1119           m_tmpFilePath(tmpFile)
1120     {
1121     }
1122 
1123     ~ComputerVisionRequest()
1124     {
1125         if (!m_tmpFilePath.isEmpty() && QFile::exists(m_tmpFilePath))
1126         {
1127             QFile::remove(m_tmpFilePath);
1128         }
1129     }
1130 
1131     void parseScore(const QJsonObject& json, QList<ComputerVisionScore>& scores) const
1132     {
1133         static const QString FREQUENCY_SCORE = QLatin1String("frequency_score");
1134         static const QString VISION_SCORE    = QLatin1String("vision_score");
1135         static const QString COMBINED_SCORE  = QLatin1String("combined_score");
1136 
1137         double frequency_score               = 0.0;
1138         double vision_score                  = 0.0;
1139         double combined_score                = 0.0;
1140         Taxon  taxon;
1141 
1142         if (json.contains(FREQUENCY_SCORE))
1143         {
1144             frequency_score = json[FREQUENCY_SCORE].toDouble();
1145         }
1146 
1147         if (json.contains(VISION_SCORE))
1148         {
1149             vision_score = json[VISION_SCORE].toDouble();
1150         }
1151 
1152         if (json.contains(COMBINED_SCORE))
1153         {
1154             combined_score = json[COMBINED_SCORE].toDouble();
1155         }
1156 
1157         if (json.contains(TAXON))
1158         {
1159             taxon = parseTaxon(json[TAXON].toObject());
1160         }
1161 
1162         scores << ComputerVisionScore(frequency_score, vision_score,
1163                                       combined_score, taxon);
1164     }
1165 
1166     void parseResponse(INatTalker& talker,
1167                        const QByteArray& data) const override
1168     {
1169         static const QString COMMON_ANCESTOR = QLatin1String("common_ancestor");
1170 
1171         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Computer vision for" << m_imagePath
1172                                          << "took" << durationMilliSecs() << "msecs.";
1173 
1174         QJsonObject json = parseJsonResponse(data);
1175         QList<ComputerVisionScore> scores;
1176 
1177         if (json.contains(COMMON_ANCESTOR))
1178         {
1179             parseScore(json[COMMON_ANCESTOR].toObject(), scores);
1180         }
1181 
1182         if (json.contains(RESULTS))
1183         {
1184             for (const auto& result : json[RESULTS].toArray())
1185             {
1186                 parseScore(result.toObject(), scores);
1187             }
1188         }
1189 
1190         ImageScores result(m_imagePath, scores);
1191         talker.d->cachedImageScores.insert(m_imagePath, result);
1192         Q_EMIT talker.signalComputerVisionResults(result);
1193     }
1194 
1195     void reportError(INatTalker&, QNetworkReply::NetworkError,
1196                      const QString& errorString) const override
1197     {
1198         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Computer vision error" << errorString
1199                                          << "after" << durationMilliSecs() << "msecs.";
1200     }
1201 
1202 private:
1203 
1204     QString m_imagePath;
1205     QString m_tmpFilePath;
1206 
1207 private:
1208 
1209     Q_DISABLE_COPY(ComputerVisionRequest)
1210 };
1211 
1212 void INatTalker::computerVision(const QUrl& localImage)
1213 {
1214     if (localImage.isEmpty() || (apiTokenExpiresIn() <= 0))
1215     {
1216         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Computer-vision API not called:"
1217                                          << (localImage.isEmpty() ? "No image."
1218                                                                   : "Not logged in.");
1219         return;
1220     }
1221 
1222     enum
1223     {
1224         HEIGHT = 299,
1225         WIDTH  = 299
1226     };
1227 
1228     QString path = localImage.toLocalFile();
1229 
1230     if (d->cachedImageScores.contains(path))
1231     {
1232         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Vision identification for"
1233                                          << localImage.toLocalFile()
1234                                          << "found in cache.";
1235 
1236         Q_EMIT signalComputerVisionResults(d->cachedImageScores.value(path));
1237 
1238         return;
1239     }
1240 
1241     QImage image = PreviewLoadThread::loadHighQualitySynchronously(path).
1242                    copyQImage();
1243 
1244     if (image.isNull())
1245     {
1246         image.load(path);
1247     }
1248 
1249     path  = tmpFileName(path);
1250     image = image.scaled(WIDTH, HEIGHT);
1251     image.save(path, "JPEG");
1252 
1253     QList<Parameter> parameters;
1254 
1255     DItemInfo info(d->iface->itemInfo(localImage));
1256 
1257     if (info.hasGeolocationInfo())
1258     {
1259         static const QString lat = QLatin1String("lat");
1260         parameters << Parameter(lat, QString::number(info.latitude(), 'f',
1261                                 GEOLOCATION_PRECISION));
1262         static const QString lng = QLatin1String("lng");
1263         parameters << Parameter(lng, QString::number(info.longitude(), 'f',
1264                                 GEOLOCATION_PRECISION));
1265     }
1266 
1267     QDateTime dateTime = info.dateTime();
1268 
1269     if (dateTime.isValid())
1270     {
1271         parameters << Parameter(OBSERVED_ON,
1272                                 dateTime.date().toString(Qt::ISODate));
1273     }
1274 
1275     parameters << Parameter(LOCALE, locale.name());
1276 
1277     QHttpMultiPart* const multiPart = getMultiPart(parameters,
1278                                                    QLatin1String("image"),
1279                                                    QFileInfo(path).fileName(),
1280                                                    path);
1281 
1282     QUrl url(d->apiUrl + QLatin1String("computervision/score_image"));
1283     QNetworkRequest netRequest(url);
1284     netRequest.setRawHeader("Authorization", d->apiToken.toLatin1());
1285 
1286     QNetworkReply* const reply = d->netMngr->post(netRequest, multiPart);
1287     multiPart->setParent(reply);
1288     d->pendingRequests.insert(reply, new ComputerVisionRequest(localImage.toLocalFile(), path));
1289 }
1290 
1291 QString INatTalker::tmpFileName(const QString& path)
1292 {
1293     QString suffix;
1294 
1295     for ( ; ; )
1296     {
1297         QString tmpFn = WSToolUtils::makeTemporaryDir(d->serviceName.toLatin1().constData()).
1298             filePath(QFileInfo(path).baseName().trimmed() + suffix + QLatin1String(".jpg"));
1299 
1300         if (!QFile::exists(tmpFn))
1301         {
1302             return tmpFn;
1303         }
1304 
1305         suffix += QLatin1String("_");
1306     }
1307 }
1308 
1309 // ------------------------------------------------------------------------------------------
1310 
1311 class Q_DECL_HIDDEN VerifyCreateObservationRequest : public Request
1312 {
1313 public:
1314 
1315     VerifyCreateObservationRequest(const QByteArray& params,
1316                                    const INatTalker::PhotoUploadRequest& req,
1317                                    const QString& observed_on,
1318                                    int taxon_id, int retries)
1319       : m_parameters(params),
1320         m_uploadRequest(req),
1321         m_observed_on(observed_on),
1322         m_taxon_id(taxon_id),
1323         m_retries(retries)
1324     {
1325     }
1326 
1327     void reportError(INatTalker& talker, QNetworkReply::NetworkError code,
1328                      const QString& errorString) const override
1329     {
1330         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "VerifyCreateObservation: " << errorString
1331                                          << "after" << durationMilliSecs() << "msecs.";
1332 
1333         if (networkErrorRetry(code) && (m_retries < MAX_RETRIES))
1334         {
1335             qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Attempting to call "
1336                                                 "VerifyCreateObservation again, retry"
1337                                              << m_retries+1 << "of" << MAX_RETRIES;
1338 
1339             talker.verifyCreateObservation(m_parameters, m_uploadRequest, 1,
1340                                            m_retries+1);
1341         }
1342         else
1343         {
1344             QMessageBox::critical(QApplication::activeWindow(),
1345                                   i18nc("@title:window", "ERROR While Creating Observation"),
1346                                   errorString);
1347         }
1348     }
1349 
1350     void parseResponse(INatTalker& talker,
1351                        const QByteArray& data) const override
1352     {
1353         QJsonObject json          = parseJsonResponse(data);
1354         int         observationId = -1;
1355 
1356         if (json.contains(TOTAL_RESULTS) &&
1357             json.contains(PER_PAGE)      &&
1358             json.contains(RESULTS)       &&
1359             json.contains(PAGE))
1360         {
1361             int        totalResults = json[TOTAL_RESULTS].toInt();
1362             int        perPage      = json[PER_PAGE].toInt();
1363             QJsonArray results      = json[RESULTS].toArray();
1364 
1365             qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Observation check:" << results.count()
1366                                              << "results of" << totalResults
1367                                              << "received in" << durationMilliSecs() << "msecs.";
1368 
1369             for (int i = 0 ; i < results.count() ; ++i)
1370             {
1371                 QJsonObject result = results[i].toObject();
1372 
1373                 if (result.contains(OBSERVED_ON_STRING)                    &&
1374                     result.contains(TAXON)                                 &&
1375                     result[OBSERVED_ON_STRING].toString() == m_observed_on &&
1376                     result[TAXON].toObject()[ID].toInt()  == m_taxon_id)
1377                 {
1378                     observationId = result[ID].toInt();
1379                     break;
1380                 }
1381             }
1382 
1383             // Not found. If the server has more results request them.
1384 
1385             if ((observationId < 0) && (results.count() >= perPage))
1386             {
1387                 talker.verifyCreateObservation(m_parameters, m_uploadRequest,
1388                                                json[PAGE].toInt()+1, m_retries);
1389                 return;
1390             }
1391         }
1392 
1393         if (observationId > 0)
1394         {
1395             qCDebug(DIGIKAM_WEBSERVICES_LOG) << "VerifyCreateObservation: "
1396                                                 "observation" << observationId
1397                                              << "of taxon_id" << m_taxon_id
1398                                              << "of" << m_observed_on
1399                                              << "found; uploading photos.";
1400 
1401             INatTalker::PhotoUploadRequest request(m_uploadRequest);
1402             request.m_observationId = observationId;
1403 
1404             Q_EMIT talker.signalObservationCreated(request);
1405         }
1406         else
1407         {
1408             qCDebug(DIGIKAM_WEBSERVICES_LOG) << "VerifyCreateObservation: "
1409                                                 "observation of taxon_id"
1410                                              << m_taxon_id << "at" << m_observed_on
1411                                              << "not found on server; uploading again.";
1412 
1413             talker.createObservation(m_parameters, m_uploadRequest);
1414         }
1415     }
1416 
1417 private:
1418 
1419     QByteArray                     m_parameters;
1420     INatTalker::PhotoUploadRequest m_uploadRequest;
1421     QString                        m_observed_on;
1422     int                            m_taxon_id;
1423     int                            m_retries;
1424 
1425 private:
1426 
1427     Q_DISABLE_COPY(VerifyCreateObservationRequest)
1428 };
1429 
1430 void INatTalker::verifyCreateObservation(const QByteArray& parameters,
1431                                          const PhotoUploadRequest& photoUpload,
1432                                          int page, int retries)
1433 {
1434     QJsonObject json = parseJsonResponse(parameters);
1435 
1436     QUrl url(d->apiUrl + OBSERVATIONS);
1437     QUrlQuery query;
1438     query.addQueryItem(QLatin1String("user_login"), photoUpload.m_user);
1439     query.addQueryItem(QLatin1String("photos"), QLatin1String("false"));
1440     query.addQueryItem(PER_PAGE, QString::number(200));
1441     query.addQueryItem(PAGE, QString::number(page));
1442 
1443     int     taxon_id = 0;
1444     QString observed_on;
1445 
1446     if (json.contains(OBSERVATION))
1447     {
1448         QJsonObject parms = json[OBSERVATION].toObject();
1449 
1450         if (parms.contains(OBSERVED_ON_STRING))
1451         {
1452             observed_on = parms[OBSERVED_ON_STRING].toString();
1453             query.addQueryItem(OBSERVED_ON, observed_on.left(10));
1454         }
1455 
1456         if (parms.contains(TAXON_ID))
1457         {
1458             taxon_id = parms[TAXON_ID].toInt();
1459             query.addQueryItem(TAXON_ID, QString::number(taxon_id));
1460         }
1461     }
1462 
1463     url.setQuery(query.query());
1464 
1465     QNetworkRequest netRequest(url);
1466     netRequest.setHeader(QNetworkRequest::ContentTypeHeader,
1467                          QLatin1String(O2_MIME_TYPE_JSON));
1468     netRequest.setRawHeader("Authorization", d->apiToken.toLatin1());
1469 
1470     PhotoUploadRequest upload(photoUpload);
1471     upload.m_apiKey = d->apiToken;
1472     d->pendingRequests.insert(d->netMngr->get(netRequest),
1473                               new VerifyCreateObservationRequest(parameters,
1474                                                                  upload,
1475                                                                  observed_on,
1476                                                                  taxon_id,
1477                                                                  retries));
1478 }
1479 
1480 // ------------------------------------------------------------------------------------------
1481 
1482 class Q_DECL_HIDDEN CreateObservationRequest : public Request
1483 {
1484 public:
1485 
1486     CreateObservationRequest(const QByteArray& params,
1487                              const INatTalker::PhotoUploadRequest& req)
1488         : m_parameters(params),
1489           m_uploadRequest(req)
1490     {
1491     }
1492 
1493     void reportError(INatTalker& talker, QNetworkReply::NetworkError code,
1494                      const QString& errorString) const override
1495     {
1496         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Observation not created due to "
1497                                             "network error" << errorString
1498                                          << "after" << durationMilliSecs() << "msecs.";
1499 
1500         if (networkErrorRetry(code))
1501         {
1502             talker.verifyCreateObservation(m_parameters, m_uploadRequest, 1, 0);
1503         }
1504         else
1505         {
1506             QMessageBox::critical(QApplication::activeWindow(),
1507                                   i18nc("@title:window", "ERROR While Creating Observation"),
1508                                   errorString);
1509         }
1510     }
1511 
1512     void parseResponse(INatTalker& talker,
1513                        const QByteArray& data) const override
1514     {
1515         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Observation created in"
1516                                          << durationMilliSecs() << "msecs.";
1517 
1518         QJsonObject json = parseJsonResponse(data);
1519 
1520         if (json.contains(ID))
1521         {
1522             INatTalker::PhotoUploadRequest request(m_uploadRequest);
1523             request.m_observationId = json[ID].toInt();
1524 
1525             Q_EMIT talker.signalObservationCreated(request);
1526         }
1527     }
1528 
1529 private:
1530 
1531     QByteArray                     m_parameters;
1532     INatTalker::PhotoUploadRequest m_uploadRequest;
1533 
1534 private:
1535 
1536     Q_DISABLE_COPY(CreateObservationRequest)
1537 };
1538 
1539 void INatTalker::createObservation(const QByteArray& parameters,
1540                                    const PhotoUploadRequest& photoUpload)
1541 {
1542     QUrl url(d->apiUrl + OBSERVATIONS);
1543 
1544     QNetworkRequest netRequest(url);
1545     netRequest.setHeader(QNetworkRequest::ContentTypeHeader,
1546                          QLatin1String(O2_MIME_TYPE_JSON));
1547     netRequest.setRawHeader("Authorization", d->apiToken.toLatin1());
1548 
1549     PhotoUploadRequest upload(photoUpload);
1550     upload.m_apiKey = d->apiToken;
1551     d->pendingRequests.insert(d->netMngr->post(netRequest, parameters),
1552                               new CreateObservationRequest(parameters, upload));
1553 }
1554 
1555 // ------------------------------------------------------------------------------------------
1556 
1557 class Q_DECL_HIDDEN VerifyUploadPhotoRequest : public Request
1558 {
1559 public:
1560 
1561     VerifyUploadPhotoRequest(const INatTalker::PhotoUploadRequest& req,
1562                              int retries)
1563         : m_request(req),
1564           m_retries(retries)
1565     {
1566     }
1567 
1568     void reportError(INatTalker& talker, QNetworkReply::NetworkError code,
1569                      const QString& errorString) const override
1570     {
1571         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "VerifyPhotoUploadNextPhoto: " << errorString
1572                                          << "after" << durationMilliSecs() << "msecs.";
1573 
1574         if (networkErrorRetry(code) && m_retries < MAX_RETRIES)
1575         {
1576             qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Attempting to call "
1577                                                 "VerifyPhotoUploadNextPhoto again, retry"
1578                                              << m_retries + 1 << "of" << MAX_RETRIES;
1579 
1580             talker.verifyUploadNextPhoto(m_request, m_retries+1);
1581         }
1582         else
1583         {
1584             QMessageBox::critical(QApplication::activeWindow(),
1585                                   i18nc("@title:window", "ERROR While Uploading Photo"),
1586                                   errorString);
1587         }
1588     }
1589 
1590     void parseResponse(INatTalker& talker,
1591                        const QByteArray& data) const override
1592     {
1593         QJsonObject json = parseJsonResponse(data);
1594 
1595         int noPhotos               =  0;
1596         int lastObservationPhotoId = -1;
1597         int lastPhotoId            = -1;
1598 
1599         if (json.contains(TOTAL_RESULTS)     &&
1600             json.contains(RESULTS)           &&
1601             (json[TOTAL_RESULTS].toInt() == 1))
1602         {
1603             QJsonObject result = json[RESULTS].toArray()[0].toObject();
1604 
1605             if (result.contains(OBSERVATION_PHOTOS))
1606             {
1607                 noPhotos = result[OBSERVATION_PHOTOS].toArray().count();
1608 
1609                 if (noPhotos >= 1)
1610                 {
1611                     QJsonObject obsPhoto = result[OBSERVATION_PHOTOS].
1612                                            toArray()[noPhotos-1].toObject();
1613                     lastObservationPhotoId = obsPhoto[ID].toInt();
1614                     lastPhotoId = obsPhoto[PHOTO].toObject()[ID].toInt();
1615                 }
1616             }
1617 
1618             qCDebug(DIGIKAM_WEBSERVICES_LOG) << "VerifyUploadNextPhoto:" << noPhotos
1619                                              << "photos on server," << m_request.m_images.count()
1620                                              << "photos to upload," << m_request.m_totalImages
1621                                              << "total photos, checked in" << durationMilliSecs() << "msecs.";
1622 
1623             if      ((noPhotos + m_request.m_images.count()) ==
1624                      m_request.m_totalImages)
1625             {
1626                 talker.uploadNextPhoto(m_request);
1627             }
1628             else if ((noPhotos + m_request.m_images.count()) ==
1629                      (m_request.m_totalImages + 1))
1630             {
1631                 INatTalker::PhotoUploadResult uploadResult(m_request,
1632                                                            lastObservationPhotoId,
1633                                                            lastPhotoId);
1634                 Q_EMIT talker.signalPhotoUploaded(uploadResult);
1635             }
1636         }
1637         else
1638         {
1639             qCDebug(DIGIKAM_WEBSERVICES_LOG) << "VerifyPhotoUploadNextPhoto: "
1640                                                 "observation" << m_request.m_observationId
1641                                              << "NOT FOUND in" << durationMilliSecs() << "msecs.";
1642         }
1643     }
1644 
1645 private:
1646 
1647     INatTalker::PhotoUploadRequest m_request;
1648     int                            m_retries;
1649 
1650 private:
1651 
1652     Q_DISABLE_COPY(VerifyUploadPhotoRequest)
1653 };
1654 
1655 void INatTalker::verifyUploadNextPhoto(const PhotoUploadRequest& request,
1656                                        int retries)
1657 {
1658     QUrl url(d->apiUrl + QLatin1String("observations/") +
1659              QString::number(request.m_observationId));
1660     QNetworkRequest netRequest(url);
1661     netRequest.setHeader(QNetworkRequest::ContentTypeHeader,
1662                          QLatin1String(O2_MIME_TYPE_JSON));
1663     netRequest.setRawHeader("Authorization", request.m_apiKey.toLatin1());
1664     d->pendingRequests.insert(d->netMngr->get(netRequest),
1665                               new VerifyUploadPhotoRequest(request, retries));
1666 }
1667 
1668 // ------------------------------------------------------------------------------------------
1669 
1670 class Q_DECL_HIDDEN UploadPhotoRequest : public Request
1671 {
1672 public:
1673 
1674     UploadPhotoRequest(const INatTalker::PhotoUploadRequest& req,
1675                        const QString& tmpImg)
1676         : m_request (req),
1677           m_tmpImage(tmpImg)
1678     {
1679     }
1680 
1681     ~UploadPhotoRequest()
1682     {
1683         if (!m_tmpImage.isEmpty() && QFile::exists(m_tmpImage))
1684         {
1685             QFile::remove(m_tmpImage);
1686         }
1687     }
1688 
1689     void reportError(INatTalker& talker, QNetworkReply::NetworkError code,
1690                      const QString& errorString) const override
1691     {
1692         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Photo not uploaded due to "
1693                                             "network error" << errorString
1694                                          << "after" << durationMilliSecs() << "msecs.";
1695 
1696         if (networkErrorRetry(code))
1697         {
1698             talker.verifyUploadNextPhoto(m_request, 0);
1699         }
1700         else
1701         {
1702             QMessageBox::critical(QApplication::activeWindow(),
1703                                   i18nc("@title:window", "ERROR While Uploading Photo"),
1704                                   errorString);
1705         }
1706     }
1707 
1708     void parseResponse(INatTalker& talker,
1709                        const QByteArray& data) const override
1710     {
1711         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Photo" << m_request.m_images.front().toLocalFile()
1712                                          << "to observation" << m_request.m_observationId
1713                                          << "uploaded in" << durationMilliSecs() << "msecs.";
1714 
1715         static const QString PHOTO_ID = QLatin1String("photo_id");
1716         QJsonObject json              = parseJsonResponse(data);
1717 
1718         if (json.contains(PHOTO_ID))
1719         {
1720             INatTalker::PhotoUploadResult result(m_request, json[ID].toInt(),
1721                                                  json[PHOTO_ID].toInt());
1722             Q_EMIT talker.signalPhotoUploaded(result);
1723         }
1724     }
1725 
1726 private:
1727 
1728     INatTalker::PhotoUploadRequest m_request;
1729     QString                        m_tmpImage;
1730 
1731 private:
1732 
1733     Q_DISABLE_COPY(UploadPhotoRequest)
1734 };
1735 
1736 void INatTalker::uploadNextPhoto(const PhotoUploadRequest& request)
1737 {
1738     QList<Parameter> parameters;
1739     parameters << Parameter(QLatin1String("observation_photo[observation_id]"),
1740                             QString::number(request.m_observationId));
1741     QString tmpImage;
1742     QString path = request.m_images.front().toLocalFile();
1743 
1744     bool isJpeg = (path.endsWith(QLatin1String(".jpg"), Qt::CaseInsensitive) ||
1745                    path.endsWith(QLatin1String(".jpeg"), Qt::CaseInsensitive));
1746 
1747     if (request.m_rescale || !isJpeg)
1748     {
1749         QImage image = PreviewLoadThread::loadHighQualitySynchronously(path).
1750                        copyQImage();
1751 
1752         if (image.isNull())
1753         {
1754             image.load(path);
1755         }
1756 
1757         if (!image.isNull())
1758         {
1759             tmpImage = tmpFileName(path);
1760 
1761             if ((image.width()  > request.m_maxDim) ||
1762                 (image.height() > request.m_maxDim))
1763             {
1764                 image = image.scaled(request.m_maxDim, request.m_maxDim,
1765                                      Qt::KeepAspectRatio,
1766                                      Qt::SmoothTransformation);
1767             }
1768 
1769             image.save(tmpImage, "JPEG", request.m_quality);
1770 
1771             if (!isJpeg)
1772             {
1773                 path += QLatin1String(".jpeg");
1774             }
1775         }
1776     }
1777 
1778     QHttpMultiPart* const multiPart  = getMultiPart(parameters,
1779                                                     QLatin1String("file"),
1780                                                     QFileInfo(path).fileName(),
1781                                                     tmpImage.isEmpty() ? path
1782                                                                        : tmpImage);
1783     QUrl url(d->apiUrl + OBSERVATION_PHOTOS);
1784     QNetworkRequest netRequest(url);
1785     netRequest.setRawHeader("Authorization", request.m_apiKey.toLatin1());
1786     QNetworkReply* const reply = d->netMngr->post(netRequest, multiPart);
1787     multiPart->setParent(reply);
1788     d->pendingRequests.insert(reply, new UploadPhotoRequest(request, tmpImage));
1789 }
1790 
1791 // ------------------------------------------------------------------------------------------
1792 
1793 class Q_DECL_HIDDEN DeleteObservationRequest : public Request
1794 {
1795 public:
1796 
1797     DeleteObservationRequest(const QString& apiKey, int id, int retries)
1798         : m_apiKey       (apiKey),
1799           m_observationId(id),
1800           m_retries      (retries)
1801     {
1802     }
1803 
1804     void reportError(INatTalker& talker, QNetworkReply::NetworkError code,
1805                      const QString& errorString) const override
1806     {
1807         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Delete observation failed with "
1808                                             "error" << errorString
1809                                          << "after" << durationMilliSecs() << "msecs.";
1810 
1811         if (networkErrorRetry(code) && m_retries < MAX_RETRIES)
1812         {
1813             qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Attempting to delete "
1814                                                 "observation" << m_observationId
1815                                              << "again, retry" << m_retries + 1
1816                                              << "of" << MAX_RETRIES;
1817 
1818             talker.deleteObservation(m_observationId, m_apiKey, m_retries + 1);
1819         }
1820         else
1821         {
1822             QMessageBox::critical(QApplication::activeWindow(),
1823                                   i18nc("@title:window", "ERROR While Deleting Observation"),
1824                                   errorString);
1825         }
1826     }
1827 
1828     void parseResponse(INatTalker& talker, const QByteArray&) const override
1829     {
1830         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Observation" << m_observationId
1831                                          << "deleted in" << durationMilliSecs() << "msecs.";
1832 
1833         Q_EMIT talker.signalObservationDeleted(m_observationId);
1834     }
1835 
1836 private:
1837 
1838     QString m_apiKey;
1839     int     m_observationId;
1840     int     m_retries;
1841 
1842 private:
1843 
1844     Q_DISABLE_COPY(DeleteObservationRequest)
1845 };
1846 
1847 /**
1848  * Delete an observation; called when canceling uploads.
1849  */
1850 void INatTalker::deleteObservation(int id, const QString& apiKey, int retries)
1851 {
1852     QUrl url(d->apiUrl + QLatin1String("observations/") + QString::number(id));
1853     QNetworkRequest netRequest(url);
1854     netRequest.setHeader(QNetworkRequest::ContentTypeHeader,
1855                          QLatin1String(O2_MIME_TYPE_JSON));
1856     netRequest.setRawHeader("Authorization", apiKey.toLatin1());
1857     d->pendingRequests.insert(d->netMngr->deleteResource(netRequest),
1858                               new DeleteObservationRequest(apiKey, id,
1859                                                            retries));
1860 }
1861 
1862 void INatTalker::cancel()
1863 {
1864     if (m_authProgressDlg && !m_authProgressDlg->isHidden())
1865     {
1866         m_authProgressDlg->hide();
1867     }
1868 
1869     d->clear();
1870     Q_EMIT signalBusy(false);
1871 }
1872 
1873 void INatTalker::slotFinished(QNetworkReply* reply)
1874 {
1875     // ignore unexpected response
1876 
1877     if (!d->pendingRequests.contains(reply))
1878     {
1879         return;
1880     }
1881 
1882     Request* const request = d->pendingRequests.take(reply);
1883 
1884     if (reply->error() == QNetworkReply::NoError)
1885     {
1886         request->parseResponse(*this, reply->readAll());
1887     }
1888     else
1889     {
1890         request->reportError(*this, reply->error(), reply->errorString());
1891     }
1892 
1893     delete request;
1894     reply->deleteLater();
1895 }
1896 
1897 void INatTalker::slotTimeout()
1898 {
1899     QList<QPair<QNetworkReply*, Request*> > timeoutList;
1900     QHash<QNetworkReply*, Request*>::const_iterator it;
1901 
1902     for (it = d->pendingRequests.constBegin() ;
1903          it != d->pendingRequests.constEnd() ; ++it)
1904     {
1905         Request* const request = it.value();
1906 
1907         if (request->isTimeout())
1908         {
1909             timeoutList.append(qMakePair(it.key(), request));
1910         }
1911     }
1912 
1913     for (const auto& pair : timeoutList)
1914     {
1915         QNetworkReply* const reply            = pair.first;
1916         d->pendingRequests.remove(reply);
1917 
1918         QNetworkReply::NetworkError errorCode = reply->error();
1919         QString errorString                   = reply->errorString();
1920 
1921         reply->abort();
1922         delete reply;
1923 
1924         if (errorCode == QNetworkReply::NoError)
1925         {
1926             errorCode   = QNetworkReply::TimeoutError;
1927             errorString = i18n("Timeout after exceeding %1 seconds",
1928                                RESPONSE_TIMEOUT_SECS);
1929         }
1930 
1931         Request* const request = pair.second;
1932         request->reportError(*this, errorCode, errorString);
1933         delete request;
1934     }
1935 }
1936 
1937 } // namespace DigikamGenericINatPlugin
1938 
1939 #include "moc_inattalker.cpp"