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

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      by Joerg Lohse <joergmlpts at gmail dot com>
0010  * SPDX-FileCopyrightText: 2005-2008 by Vardhman Jain <vardhman at gmail dot com>
0011  * SPDX-FileCopyrightText: 2008-2024 by Gilles Caulier <caulier dot gilles at gmail dot com>
0012  * SPDX-FileCopyrightText: 2009      by Luka Renko <lure at kubuntu dot org>
0013  *
0014  * SPDX-License-Identifier: GPL-2.0-or-later
0015  *
0016  * ============================================================ */
0017 
0018 #include "inatwindow.h"
0019 
0020 // Qt includes
0021 
0022 #include <QPushButton>
0023 #include <QProgressDialog>
0024 #include <QPicture>
0025 #include <QPixmap>
0026 #include <QCheckBox>
0027 #include <QStringList>
0028 #include <QSpinBox>
0029 #include <QPointer>
0030 #include <QApplication>
0031 #include <QMenu>
0032 #include <QMessageBox>
0033 #include <QWindow>
0034 #include <QCloseEvent>
0035 #include <QTimeZone>
0036 #include <QJsonValue>
0037 #include <QJsonObject>
0038 #include <QJsonDocument>
0039 
0040 // KDE includes
0041 
0042 #include <klocalizedstring.h>
0043 #include <ksharedconfig.h>
0044 #include <kconfiggroup.h>
0045 
0046 // Local includes
0047 
0048 #include "dprogresswdg.h"
0049 #include "ditemslist.h"
0050 #include "dmetadata.h"
0051 #include "dtextedit.h"
0052 #include "wsselectuserdlg.h"
0053 #include "digikam_debug.h"
0054 #include "previewloadthread.h"
0055 #include "inatwidget_p.h"
0056 #include "inatbrowserdlg.h"
0057 #include "inatutils.h"
0058 
0059 namespace DigikamGenericINatPlugin
0060 {
0061 
0062 /**
0063  * iNaturalist.org allows up to 20 photos for each observation and
0064  * scales down photos to 2048 pixels.
0065  */
0066 enum
0067 {
0068     MAX_OBSERVATION_PHOTOS = 20,
0069     MAX_DIMENSION          = 2048,
0070     MAX_EDITED_PLACES      = 5
0071 };
0072 
0073 static const QString xmpNameSpaceURI    = QLatin1String("https://inaturalist.org/ns/1.0/");
0074 static const QString xmpNameSpacePrefix = QLatin1String("iNaturalist");
0075 
0076 
0077 class Q_DECL_HIDDEN INatWindow::Private
0078 {
0079 public:
0080 
0081     explicit Private()
0082         : changeUserButton        (nullptr),
0083           accountIcon             (nullptr),
0084           removeAccount           (nullptr),
0085           resizeCheckBox          (nullptr),
0086           dimensionSpinBox        (nullptr),
0087           imageQualitySpinBox     (nullptr),
0088           userNameDisplayLabel    (nullptr),
0089           authProgressDlg         (nullptr),
0090           identificationImage     (nullptr),
0091           identificationLabel     (nullptr),
0092           identificationFromVision(false),
0093           closestKnownObservation (nullptr),
0094           observationDescription  (nullptr),
0095           identificationEdit      (nullptr),
0096           taxonPopup              (nullptr),
0097           placesComboBox          (nullptr),
0098           moreOptionsButton       (nullptr),
0099           moreOptionsWidget       (nullptr),
0100           photoMaxTimeDiffSpB     (nullptr),
0101           photoMaxDistanceSpB     (nullptr),
0102           closestObservationMaxSpB(nullptr),
0103           widget                  (nullptr),
0104           talker                  (nullptr),
0105           imglst                  (nullptr),
0106           latLonValid             (false),
0107           latitude                (0.0),
0108           longitude               (0.0),
0109           inCancel                (false),
0110           xmpNameSpace            (false),
0111           selectUser              (nullptr),
0112           iface                   (nullptr)
0113     {
0114     }
0115 
0116     QString                 serviceName;
0117 
0118     QPushButton*            changeUserButton;
0119     QLabel*                 accountIcon;
0120     QPushButton*            removeAccount;
0121 
0122     QCheckBox*              resizeCheckBox;
0123 
0124     QSpinBox*               dimensionSpinBox;
0125     QSpinBox*               imageQualitySpinBox;
0126 
0127     // account info
0128 
0129     QString                 username;
0130     QString                 name;
0131     QUrl                    iconUrl;
0132     QTimer                  apiTokenExpiresTimer;
0133     QLabel*                 userNameDisplayLabel;
0134     QProgressDialog*        authProgressDlg;
0135 
0136     // identification
0137 
0138     QLabel*                 identificationImage;
0139     QLabel*                 identificationLabel;
0140     bool                    identificationFromVision;
0141     QLabel*                 closestKnownObservation;
0142     DPlainTextEdit*         observationDescription;
0143     TaxonEdit*              identificationEdit;
0144     SuggestTaxonCompletion* taxonPopup;
0145     QComboBox*              placesComboBox;
0146 
0147     // additional options
0148 
0149     QPushButton*            moreOptionsButton;
0150     QWidget*                moreOptionsWidget;
0151     QSpinBox*               photoMaxTimeDiffSpB;
0152     QSpinBox*               photoMaxDistanceSpB;
0153     QSpinBox*               closestObservationMaxSpB;
0154 
0155     INatWidget*             widget;
0156     INatTalker*             talker;
0157 
0158     DItemsList*             imglst;
0159 
0160     // observation
0161 
0162     Taxon                   identification;
0163     bool                    latLonValid;
0164     double                  latitude;
0165     double                  longitude;
0166     QDateTime               observationDateTime;
0167 
0168     QList<QString>          editedPlaces;
0169     bool                    inCancel;
0170     bool                    xmpNameSpace;
0171     WSSelectUserDlg*        selectUser;
0172     DInfoInterface*         iface;
0173 };
0174 
0175 INatWindow::INatWindow(DInfoInterface* const iface,
0176                        QWidget* const /*parent*/,
0177                        const QString& serviceName)
0178     : WSToolDialog(nullptr, QString::fromLatin1("%1 Export Dialog").
0179                    arg(serviceName)),
0180        d(new Private)
0181 {
0182     d->iface       = iface;
0183     d->serviceName = serviceName;
0184     setWindowTitle(i18nc("@title:window", "Export as %1 Observation", d->serviceName));
0185     setModal(false);
0186 
0187     KSharedConfigPtr config = KSharedConfig::openConfig();
0188     KConfigGroup grp        = config->group(QString::fromLatin1("%1 Export Settings").
0189                                             arg(d->serviceName));
0190 
0191     if (grp.exists())
0192     {
0193         qCDebug(DIGIKAM_WEBSERVICES_LOG) << QString::fromLatin1("%1 Export Settings").arg(d->serviceName)
0194                                          << "exists, deleting it.";
0195         grp.deleteGroup();
0196     }
0197 
0198     d->selectUser  = new WSSelectUserDlg(nullptr, serviceName);
0199     d->widget      = new INatWidget(this, iface, serviceName);
0200 
0201     // Account
0202 
0203     d->userNameDisplayLabel     = d->widget->getUserNameLabel();
0204     d->changeUserButton         = d->widget->getChangeUserBtn();
0205     d->removeAccount            = d->widget->d->removeAccount;
0206     d->accountIcon              = d->widget->d->accountIcon;
0207 
0208     // Options
0209 
0210     d->resizeCheckBox           = d->widget->getResizeCheckBox();
0211     d->dimensionSpinBox         = d->widget->getDimensionSpB();
0212     d->imageQualitySpinBox      = d->widget->getImgQualitySpB();
0213 
0214     // Observation & identification
0215 
0216     d->identificationImage      = d->widget->d->identificationImage;
0217     d->identificationLabel      = d->widget->d->identificationLabel;
0218     d->closestKnownObservation  = d->widget->d->closestKnownObservation;
0219     d->observationDescription   = d->widget->d->observationDescription;
0220     d->identificationEdit       = d->widget->d->identificationEdit;
0221     d->taxonPopup               = d->widget->d->taxonPopup;
0222     d->placesComboBox           = d->widget->d->placesComboBox;
0223     d->moreOptionsButton        = d->widget->d->moreOptionsButton;
0224     d->moreOptionsWidget        = d->widget->d->moreOptionsWidget;
0225     d->photoMaxTimeDiffSpB      = d->widget->d->photoMaxTimeDiffSpB;
0226     d->photoMaxDistanceSpB      = d->widget->d->photoMaxDistanceSpB;
0227     d->closestObservationMaxSpB = d->widget->d->closestObservationMaxSpB;
0228 
0229     // Image list
0230 
0231     d->imglst                   = d->widget->d->imglst;
0232 
0233     // max dimension supported by iNaturalist.org
0234 
0235     d->dimensionSpinBox->setMaximum(MAX_DIMENSION);
0236     d->dimensionSpinBox->setValue(MAX_DIMENSION);
0237 
0238     startButton()->setText(i18nc("@action:button", "Start Uploading"));
0239     startButton()->setToolTip(QString());
0240 
0241     setMainWidget(d->widget);
0242     d->widget->setMinimumSize(800, 600);
0243 
0244     connect(d->imglst, SIGNAL(signalImageListChanged()),
0245             this, SLOT(slotImageListChanged()));
0246 
0247     connect(d->photoMaxTimeDiffSpB, SIGNAL(valueChanged(int)),
0248             this, SLOT(slotValueChanged(int)));
0249 
0250     connect(d->photoMaxDistanceSpB, SIGNAL(valueChanged(int)),
0251             this, SLOT(slotValueChanged(int)));
0252 
0253     connect(d->closestObservationMaxSpB, SIGNAL(valueChanged(int)),
0254             this, SLOT(slotClosestChanged(int)));
0255 
0256     connect(d->moreOptionsButton, SIGNAL(toggled(bool)),
0257             this, SLOT(slotMoreOptionsButton(bool)));
0258 
0259     d->apiTokenExpiresTimer.setSingleShot(true);
0260 
0261     connect(&d->apiTokenExpiresTimer, SIGNAL(timeout()),
0262             this, SLOT(slotApiTokenExpires()));
0263 
0264     // -------------------------------------------------------------------------
0265 
0266     d->talker = new INatTalker(this, serviceName, d->iface);
0267 
0268     d->taxonPopup->setTalker(d->talker);
0269 
0270     connect(d->talker, SIGNAL(signalBusy(bool)),
0271             this, SLOT(slotBusy(bool)));
0272 
0273     connect(d->talker, SIGNAL(signalLinkingSucceeded(QString,QString,QUrl)),
0274             this, SLOT(slotLinkingSucceeded(QString,QString,QUrl)));
0275 
0276     connect(d->talker, SIGNAL(signalLinkingFailed(QString)),
0277             this, SLOT(slotLinkingFailed(QString)));
0278 
0279     connect(d->talker, SIGNAL(signalLoadUrlSucceeded(QUrl,QByteArray)),
0280             this, SLOT(slotLoadUrlSucceeded(QUrl,QByteArray)));
0281 
0282     connect(d->talker, SIGNAL(signalNearbyPlaces(QStringList)),
0283             this, SLOT(slotNearbyPlaces(QStringList)));
0284 
0285     connect(d->talker, SIGNAL(signalNearbyObservation(INatTalker::NearbyObservation)),
0286             this, SLOT(slotNearbyObservation(INatTalker::NearbyObservation)));
0287 
0288     connect(d->talker, SIGNAL(signalObservationCreated(INatTalker::PhotoUploadRequest)),
0289             this, SLOT(slotObservationCreated(INatTalker::PhotoUploadRequest)));
0290 
0291     connect(d->talker, SIGNAL(signalPhotoUploaded(INatTalker::PhotoUploadResult)),
0292             this, SLOT(slotPhotoUploaded(INatTalker::PhotoUploadResult)));
0293 
0294     connect(d->talker, SIGNAL(signalObservationDeleted(int)),
0295             this, SLOT(slotObservationDeleted(int)));
0296 
0297     // -------------------------------------------------------------------------
0298 
0299     connect(d->changeUserButton, SIGNAL(clicked()),
0300             this, SLOT(slotUserChangeRequest()));
0301 
0302     connect(d->removeAccount, SIGNAL(clicked()),
0303             this, SLOT(slotRemoveAccount()));
0304 
0305     // -------------------------------------------------------------------------
0306 
0307     d->authProgressDlg = new QProgressDialog(this);
0308     d->authProgressDlg->setModal(true);
0309     d->authProgressDlg->setAutoReset(true);
0310     d->authProgressDlg->setAutoClose(true);
0311     d->authProgressDlg->setMaximum(0);
0312     d->authProgressDlg->reset();
0313 
0314     connect(d->authProgressDlg, SIGNAL(canceled()),
0315             this, SLOT(slotAuthCancel()));
0316 
0317     d->talker->m_authProgressDlg = d->authProgressDlg;
0318 
0319     // -------------------------------------------------------------------------
0320 
0321     connect(this, &QDialog::finished,
0322             this, &INatWindow::slotFinished);
0323 
0324     connect(this, SIGNAL(cancelClicked()),
0325             this, SLOT(slotCancelClicked()));
0326 
0327     connect(startButton(), &QPushButton::clicked,
0328             this, &INatWindow::slotUser1);
0329 
0330     connect(d->taxonPopup, SIGNAL(signalTaxonSelected(Taxon,bool)),
0331             this, SLOT(slotTaxonSelected(Taxon,bool)));
0332 
0333     connect(d->taxonPopup, SIGNAL(signalTaxonDeselected()),
0334             this, SLOT(slotTaxonDeselected()));
0335 
0336     connect(d->taxonPopup, SIGNAL(signalComputerVision()),
0337             this, SLOT(slotComputerVision()));
0338 
0339     d->selectUser->reactivate();
0340     switchUser();
0341 }
0342 
0343 INatWindow::~INatWindow()
0344 {
0345     delete d->selectUser;
0346     delete d->authProgressDlg;
0347     delete d->talker;
0348     delete d->widget;
0349 
0350     if (d->xmpNameSpace)
0351     {
0352         DMetadata::unregisterXmpNameSpace(xmpNameSpaceURI);
0353     }
0354 
0355     delete d;
0356 }
0357 
0358 void INatWindow::switchUser(bool restoreToken)
0359 {
0360     QString               userName = d->username;
0361     QList<QNetworkCookie> cookies;
0362 
0363     d->apiTokenExpiresTimer.stop();
0364     d->talker->unLink();
0365     d->username                    = QString();
0366     d->name                        = QString();
0367     d->iconUrl                     = QUrl();
0368     d->widget->updateLabels(QString());
0369 
0370     // User gets to select a username unless the timer calls us because
0371     // our token has expired.
0372 
0373     if (restoreToken)
0374     {
0375         userName = d->selectUser->getUserName();
0376     }
0377 
0378     // If we have a username, restore api token and cookies.
0379 
0380     if (!userName.isEmpty() &&
0381         d->talker->restoreApiToken(userName, cookies, restoreToken))
0382     {
0383         // Done if api token has been restored, browser is not called anymore.
0384 
0385         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Login skipped; restored api_token "
0386                                             "for user" << userName;
0387         return;
0388     }
0389 
0390     // Pass cookies to browser; if "remember me" is checked on iNaturalist
0391     // website, the browser will re-login for 14 days without user interaction.
0392 
0393     QPointer<INatBrowserDlg> dlg = new INatBrowserDlg(userName, cookies, this);
0394 
0395     connect(dlg, SIGNAL(signalApiToken(QString,QList<QNetworkCookie>)),
0396             d->talker, SLOT(slotApiToken(QString,QList<QNetworkCookie>)));
0397 
0398     (void)dlg->exec();
0399 }
0400 
0401 void INatWindow::slotApiTokenExpires()
0402 {
0403     switchUser(false);
0404 }
0405 
0406 void INatWindow::setItemsList(const QList<QUrl>& urls)
0407 {
0408     d->widget->imagesList()->slotAddImages(urls);
0409 }
0410 
0411 void INatWindow::closeEvent(QCloseEvent* e)
0412 {
0413     if (!e)
0414     {
0415         return;
0416     }
0417 
0418     slotFinished();
0419     e->accept();
0420 }
0421 
0422 void INatWindow::slotFinished()
0423 {
0424     writeSettings();
0425     d->imglst->listView()->clear();
0426 }
0427 
0428 void INatWindow::setUiInProgressState(bool inProgress)
0429 {
0430     setRejectButtonMode(inProgress ? QDialogButtonBox::Cancel
0431                                    : QDialogButtonBox::Close);
0432 
0433     if (inProgress)
0434     {
0435         d->widget->progressBar()->show();
0436     }
0437     else
0438     {
0439         d->widget->progressBar()->hide();
0440         d->widget->progressBar()->progressCompleted();
0441     }
0442 }
0443 
0444 void INatWindow::slotCancelClicked()
0445 {
0446     if (d->talker->stillUploading())
0447     {
0448         d->inCancel = true;
0449         slotBusy(true);
0450         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Cancel clicked; deleting "
0451                                             "observation(s) being uploaded.";
0452         return;
0453     }
0454 
0455     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Cancel clicked; not uploading.";
0456 
0457     d->talker->cancel();
0458     setUiInProgressState(false);
0459 }
0460 
0461 void INatWindow::reactivate()
0462 {
0463     d->userNameDisplayLabel->setText(QString());
0464     switchUser();
0465 
0466     d->widget->d->imglst->loadImagesFromCurrentSelection();
0467     show();
0468 }
0469 
0470 static const char SETTING_RESIZE[]    = "Resize";
0471 static const char SETTING_DIM[]       = "Maximum Width";
0472 static const char SETTING_QUALITY[]   = "Image Quality";
0473 static const char SETTING_INAT_IDS[]  = "Write iNat Ids";
0474 static const char SETTING_TIME_DIFF[] = "Max Time Diff";
0475 static const char SETTING_DISTANCE[]  = "Max Distance";
0476 static const char SETTING_CLOSEST[]   = "Closest Observation";
0477 static const char SETTING_EXTENDED[]  = "Extended Options";
0478 
0479 void INatWindow::readSettings(const QString& uname)
0480 {
0481     KSharedConfigPtr config = KSharedConfig::openConfig();
0482     QString groupName       = QString::fromLatin1("%1 %2 Export Settings").
0483                               arg(d->serviceName, uname);
0484     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Group name is:" << groupName;
0485     KConfigGroup grp        = config->group(groupName);
0486 
0487     d->resizeCheckBox->setChecked(grp.readEntry(SETTING_RESIZE, true));
0488     d->dimensionSpinBox->setValue(grp.readEntry(SETTING_DIM, 2048));
0489     d->imageQualitySpinBox->setValue(grp.readEntry(SETTING_QUALITY, 90));
0490     d->widget->getPhotoIdCheckBox()->setChecked(grp.readEntry(SETTING_INAT_IDS, false));
0491     d->photoMaxTimeDiffSpB->setValue(grp.readEntry(SETTING_TIME_DIFF, 5));
0492     d->photoMaxDistanceSpB->setValue(grp.readEntry(SETTING_DISTANCE, 15));
0493     d->closestObservationMaxSpB->setValue(grp.readEntry(SETTING_CLOSEST, 500));
0494     d->moreOptionsButton->setChecked(grp.readEntry(SETTING_EXTENDED, false));
0495     slotMoreOptionsButton(d->moreOptionsButton->isChecked());
0496 }
0497 
0498 void INatWindow::writeSettings()
0499 {
0500     KSharedConfigPtr config = KSharedConfig::openConfig();
0501     QString groupName       = QString::fromLatin1("%1 %2 Export Settings").arg(d->serviceName, d->username);
0502 
0503     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Group name is:" << groupName;
0504 
0505     if (QString::compare(QString::fromLatin1("%1 Export Settings").arg(d->serviceName), groupName) == 0)
0506     {
0507         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Not writing entry of group" << groupName;
0508         return;
0509     }
0510 
0511     KConfigGroup grp = config->group(groupName);
0512     grp.writeEntry("username",        d->username);
0513     grp.writeEntry(SETTING_RESIZE,    d->resizeCheckBox->isChecked());
0514     grp.writeEntry(SETTING_DIM,       d->dimensionSpinBox->value());
0515     grp.writeEntry(SETTING_QUALITY,   d->imageQualitySpinBox->value());
0516     grp.writeEntry(SETTING_INAT_IDS,  d->widget->getPhotoIdCheckBox()->isChecked());
0517     grp.writeEntry(SETTING_TIME_DIFF, d->photoMaxTimeDiffSpB->value());
0518     grp.writeEntry(SETTING_DISTANCE,  d->photoMaxDistanceSpB->value());
0519     grp.writeEntry(SETTING_CLOSEST,   d->closestObservationMaxSpB->value());
0520     grp.writeEntry(SETTING_EXTENDED,  d->moreOptionsButton->isChecked());
0521 
0522     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Entry of group" << groupName << "written";
0523 }
0524 
0525 void INatWindow::slotMoreOptionsButton(bool setting)
0526 {
0527     if (setting)
0528     {
0529         d->moreOptionsButton->setText(i18n("Fewer options"));
0530         d->observationDescription->show();
0531         d->moreOptionsWidget->show();
0532     }
0533     else
0534     {
0535         d->moreOptionsButton->setText(i18n("More options"));
0536         d->observationDescription->hide();
0537         d->moreOptionsWidget->hide();
0538     }
0539 }
0540 
0541 void INatWindow::slotLinkingSucceeded(const QString& username,
0542                                       const QString& name, const QUrl& iconUrl)
0543 {
0544     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Linking succeeded for user"
0545                                      << username;
0546     d->username = username;
0547     d->name     = name;
0548     d->iconUrl  = iconUrl;
0549     d->apiTokenExpiresTimer.start(1000 * std::max(d->talker->apiTokenExpiresIn(), 1));
0550 
0551     if (!d->name.isEmpty() && (d->name != d->username))
0552     {
0553         d->userNameDisplayLabel->setText(QString::fromLatin1("<b>%1 (%2)</b>").
0554                                          arg(d->username, d->name));
0555     }
0556     else
0557     {
0558         d->userNameDisplayLabel->setText(QString::fromLatin1("<b>%1</b>").
0559                                          arg(d->username));
0560     }
0561 
0562     d->widget->updateLabels(username);
0563     KSharedConfigPtr config = KSharedConfig::openConfig();
0564 
0565     Q_FOREACH (const QString& group, config->groupList())
0566     {
0567         if (!(group.contains(d->serviceName)))
0568         {
0569             continue;
0570         }
0571 
0572         KConfigGroup grp = config->group(group);
0573 
0574         if (group.contains(d->username))
0575         {
0576             readSettings(d->username);
0577             break;
0578         }
0579     }
0580 
0581     writeSettings();
0582 
0583     if (!d->iconUrl.isEmpty())
0584     {
0585         d->talker->loadUrl(d->iconUrl);
0586     }
0587 }
0588 
0589 void INatWindow::slotLinkingFailed(const QString& error)
0590 {
0591     d->apiTokenExpiresTimer.stop();
0592     d->accountIcon->hide();
0593     d->userNameDisplayLabel->setText(i18n("<i>login <b>failed</b></i>"));
0594     d->widget->updateLabels(QString());
0595     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Linking failed with error" << error;
0596 }
0597 
0598 void INatWindow::slotBusy(bool val)
0599 {
0600     setCursor(val ? Qt::WaitCursor : Qt::ArrowCursor);
0601 }
0602 
0603 void INatWindow::slotError(const QString& msg)
0604 {
0605     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Error" << msg;
0606     QMessageBox::critical(this, i18nc("@title:window", "Error"), msg);
0607 }
0608 
0609 void INatWindow::slotUserChangeRequest()
0610 {
0611     d->apiTokenExpiresTimer.stop();
0612     writeSettings();
0613     d->userNameDisplayLabel->setText(QString());
0614     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Slot Change User Request";
0615     d->selectUser->reactivate();
0616     switchUser();
0617 }
0618 
0619 void INatWindow::slotRemoveAccount()
0620 {
0621     d->apiTokenExpiresTimer.stop();
0622     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Removing user" << d->username;
0623 
0624     if (d->username.isEmpty())
0625     {
0626         return;
0627     }
0628 
0629     KSharedConfigPtr config = KSharedConfig::openConfig();
0630     QString groupName       = QString::fromLatin1("%1 %2 Export Settings").arg(d->serviceName, d->username);
0631     KConfigGroup grp        = config->group(groupName);
0632 
0633     if (grp.exists())
0634     {
0635         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Removing Account having group" << groupName;
0636         grp.deleteGroup();
0637     }
0638 
0639     d->talker->unLink();
0640     d->talker->removeUserName(d->serviceName + d->username);
0641 
0642     d->accountIcon->hide();
0643     d->userNameDisplayLabel->setText(QString());
0644     d->username = QString();
0645     d->name     = QString();
0646     d->iconUrl  = QUrl();
0647 }
0648 
0649 void INatWindow::slotAuthCancel()
0650 {
0651     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Authorization canceled.";
0652     d->apiTokenExpiresTimer.stop();
0653     d->talker->cancel();
0654     d->authProgressDlg->hide();
0655     d->accountIcon->hide();
0656     d->userNameDisplayLabel->setText(i18n("<i>login <b>canceled</b></i>"));
0657 }
0658 
0659 void INatWindow::slotTaxonSelected(const Taxon& taxon, bool fromVision)
0660 {
0661     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Taxon" << taxon.name() << "selected"
0662                                      << (fromVision ? "from vision."
0663                                                     : "from auto-completion.");
0664 
0665     if (d->identification != taxon)
0666     {
0667         d->identification = taxon;
0668         QString name      = QLatin1String("<h3>") + taxon.htmlName();
0669 
0670         if (!taxon.commonName().isEmpty())
0671         {
0672             name += QLatin1String(" (") + taxon.commonName() +
0673                     QLatin1String(")");
0674         }
0675 
0676         name += QLatin1String("</h3>");
0677         d->identificationLabel->setText(name);
0678         d->talker->loadUrl(taxon.squareUrl());
0679 
0680         startButton()->setEnabled(d->observationDateTime.isValid() &&
0681                                   d->latLonValid && !d->inCancel &&
0682                                   (d->imglst->imageUrls().count() <= MAX_OBSERVATION_PHOTOS));
0683 
0684         if (d->latLonValid)
0685         {
0686             d->talker->closestObservation(taxon.id(),
0687                                           d->latitude, d->longitude);
0688         }
0689     }
0690 
0691     d->identificationFromVision = fromVision;
0692 }
0693 
0694 void INatWindow::slotComputerVision()
0695 {
0696     const QList<QUrl>& imageUrls = d->imglst->imageUrls();
0697 
0698     if (imageUrls.count())
0699     {
0700         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Requesting computer-vision id for"
0701                                          << imageUrls[0].toLocalFile();
0702         d->talker->computerVision(imageUrls[0]);
0703     }
0704 }
0705 
0706 void INatWindow::slotTaxonDeselected()
0707 {
0708     if (d->identification != Taxon())
0709     {
0710         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Taxon deselected.";
0711         d->identificationFromVision = false;
0712         d->identification           = Taxon();
0713         d->identificationLabel->setText(i18n("<i>no valid identification</i>"));
0714         d->identificationImage->hide();
0715         slotNearbyObservation(INatTalker::NearbyObservation());
0716         startButton()->setEnabled(false);
0717     }
0718 }
0719 
0720 void INatWindow::slotLoadUrlSucceeded(const QUrl& url, const QByteArray& data)
0721 {
0722     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Image" << url << "received.";
0723 
0724     if      (url == d->identification.squareUrl())
0725     {
0726         QImage image;
0727         image.loadFromData(data);
0728         d->identificationImage->setPixmap(QPixmap::fromImage(image));
0729         d->identificationImage->show();
0730     }
0731     else if (url == d->iconUrl)
0732     {
0733         QImage image;
0734         image.loadFromData(data);
0735         d->accountIcon->setPixmap(QPixmap::fromImage(image));
0736         d->accountIcon->show();
0737     }
0738 }
0739 
0740 void INatWindow::updateProgressBarValue(unsigned diff)
0741 {
0742     int value = d->widget->progressBar()->value();
0743     value    += diff;
0744     d->widget->progressBar()->setValue(value);
0745 
0746     if (value == d->widget->progressBar()->maximum())
0747     {
0748         d->widget->progressBar()->reset();
0749         setUiInProgressState(false);
0750     }
0751 }
0752 
0753 void INatWindow::updateProgressBarMaximum(unsigned diff)
0754 {
0755     if (d->widget->progressBar()->isHidden())
0756     {
0757         d->widget->progressBar()->setMaximum(diff);
0758         d->widget->progressBar()->setValue(0);
0759         setUiInProgressState(true);
0760         d->widget->progressBar()->progressScheduled(i18n("iNaturalist Export"), true, true);
0761         d->widget->progressBar()->progressThumbnailChanged(QIcon::fromTheme(QLatin1String("dk-inat")).pixmap(22, 22));
0762     }
0763     else
0764     {
0765         int maximum = d->widget->progressBar()->maximum();
0766         d->widget->progressBar()->setMaximum(maximum + diff);
0767     }
0768 }
0769 
0770 /**
0771  * This slot is called when 'Start Uploading' button is pressed.
0772  */
0773 void INatWindow::slotUser1()
0774 {
0775     if (d->imglst->imageUrls().isEmpty()                          ||
0776         !d->latLonValid                                           ||
0777         d->inCancel                                               ||
0778         (d->imglst->imageUrls().count() > MAX_OBSERVATION_PHOTOS) ||
0779         !d->observationDateTime.isValid()                         ||
0780         !d->identification.isValid())
0781     {
0782         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "NOT uploading observation.";
0783         return;
0784     }
0785 
0786     startButton()->setEnabled(false);
0787 
0788     // Create an observation.
0789 
0790     QString obsDateTime = d->observationDateTime.toString(Qt::ISODate);
0791     QJsonObject params;
0792     params.insert(QLatin1String("observed_on_string"), QJsonValue(obsDateTime));
0793     params.insert(QLatin1String("time_zone"),
0794                   QJsonValue(QLatin1String(QTimeZone::systemTimeZoneId())));
0795     params.insert(QLatin1String("latitude"), QJsonValue(d->latitude));
0796     params.insert(QLatin1String("longitude"),QJsonValue(d->longitude));
0797     params.insert(QLatin1String("taxon_id"),
0798                   QJsonValue(d->identification.id()));
0799 
0800     QString description = d->observationDescription->toPlainText().trimmed();
0801 
0802     if (!description.isEmpty())
0803     {
0804         params.insert(QLatin1String("description"), QJsonValue(description));
0805     }
0806 
0807     QString placeName = d->placesComboBox->currentText().simplified();
0808     if (placeName != d->placesComboBox->currentText())
0809     {
0810         d->placesComboBox->setEditText(placeName);
0811     }
0812 
0813     if (!placeName.isEmpty())
0814     {
0815         params.insert(QLatin1String("place_guess"), QJsonValue(placeName));
0816         saveEditedPlaceName(placeName);
0817     }
0818 
0819     params.insert(QLatin1String("owners_identification_from_vision"),
0820                   QJsonValue(d->identificationFromVision));
0821 
0822     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Creating observation of"
0823                                      << d->identification.name() << "from"
0824                                      << obsDateTime << "with"
0825                                      << d->imglst->imageUrls().count()
0826                                      << ((d->imglst->imageUrls().count() == 1) ? "picture."
0827                                                                                : "pictures.");
0828     QJsonObject jsonObservation;
0829     jsonObservation.insert(QLatin1String("observation"), QJsonValue(params));
0830     updateProgressBarMaximum(1 + d->imglst->imageUrls().count());
0831 
0832     INatTalker::PhotoUploadRequest request(d->imglst->imageUrls(),
0833                                            d->widget->getPhotoIdCheckBox()->
0834                                            isChecked(),
0835                                            d->resizeCheckBox->isChecked(),
0836                                            d->dimensionSpinBox->value(),
0837                                            d->imageQualitySpinBox->value(),
0838                                            d->username);
0839     d->talker->createObservation(QJsonDocument(jsonObservation).toJson(),
0840                                  request);
0841 
0842     // Clear data, user can start working on the next observation right away.
0843 
0844     d->imglst->listView()->clear();
0845     slotTaxonDeselected();
0846     d->identificationEdit->clear();
0847     d->observationDescription->clear();
0848 }
0849 
0850 void INatWindow::cancelUpload(const INatTalker::PhotoUploadRequest& request)
0851 {
0852     updateProgressBarMaximum(1);
0853     updateProgressBarValue(request.m_images.count());
0854     d->talker->deleteObservation(request.m_observationId, request.m_apiKey);
0855 
0856     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Upload canceled, deleting observation"
0857                                      << request.m_observationId;
0858 }
0859 
0860 void INatWindow::slotObservationCreated(const INatTalker::PhotoUploadRequest&
0861                                         request)
0862 {
0863     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Observation"
0864                                      << request.m_observationId
0865                                      << "created, uploading first picture.";
0866     updateProgressBarValue(1);
0867 
0868     if      (d->inCancel)
0869     {
0870         cancelUpload(request);
0871     }
0872     else if (!request.m_images.isEmpty())
0873     {
0874         d->talker->uploadNextPhoto(request);
0875     }
0876 }
0877 
0878 void INatWindow::slotPhotoUploaded(const INatTalker::PhotoUploadResult& result)
0879 {
0880     updateProgressBarValue(1);
0881 
0882     INatTalker::PhotoUploadRequest request(result.m_request);
0883 
0884     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Photo"
0885                                      << request.m_images.front().toLocalFile()
0886                                      << "uploaded, observation"
0887                                      << request.m_observationId
0888                                      << "observation photo"
0889                                      << result.m_observationPhotoId
0890                                      << "photo" << result.m_photoId;
0891 
0892     if (d->inCancel)
0893     {
0894         request.m_images.pop_front();
0895         cancelUpload(request);
0896         return;
0897     }
0898 
0899     if (request.m_updateIds)
0900     {
0901         const QUrl& fileUrl = request.m_images.front();
0902         DMetadata meta;
0903 
0904         if (meta.supportXmp()                       &&
0905             meta.canWriteXmp(fileUrl.toLocalFile()) &&
0906             meta.load(fileUrl.toLocalFile()))
0907         {
0908             if (!d->xmpNameSpace)
0909             {
0910                 meta.registerXmpNameSpace(xmpNameSpaceURI, xmpNameSpacePrefix);
0911                 d->xmpNameSpace = true;
0912             }
0913 
0914             meta.setXmpTagString("Xmp.iNaturalist.observation",
0915                                  QString::number(request.m_observationId));
0916             meta.setXmpTagString("Xmp.iNaturalist.observationPhoto",
0917                                  QString::number(result.m_observationPhotoId));
0918             meta.setXmpTagString("Xmp.iNaturalist.photo",
0919                                  QString::number(result.m_photoId));
0920             meta.save(fileUrl.toLocalFile());
0921         }
0922     }
0923 
0924     request.m_images.pop_front();
0925 
0926     if (!request.m_images.isEmpty())
0927     {
0928         qCDebug(DIGIKAM_WEBSERVICES_LOG)
0929                 << "Uploading next photo" << request.m_images.front().toLocalFile()
0930                 << "to observation" << request.m_observationId;
0931         d->talker->uploadNextPhoto(request);
0932     }
0933 }
0934 
0935 void INatWindow::slotObservationDeleted(int observationId)
0936 {
0937     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Observation" << observationId
0938                                      << "deleted.";
0939     updateProgressBarValue(1);
0940 
0941     if (!d->talker->stillUploading())
0942     {
0943         d->inCancel = false;
0944         slotBusy(false);
0945     }
0946 }
0947 
0948 void INatWindow::slotNearbyPlaces(const QStringList& places)
0949 {
0950     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Received" << places.count() <<
0951         "nearby places," << d->editedPlaces.count() << "edited places.";
0952 
0953     QString selected = d->placesComboBox->currentText();
0954     d->placesComboBox->clear();
0955 
0956     for (auto& place : d->editedPlaces)
0957     {
0958         d->placesComboBox->addItem(place);
0959 
0960         if (place == selected)
0961         {
0962             // Keep previous selection if it is still an option.
0963 
0964             d->placesComboBox->setCurrentText(selected);
0965         }
0966     }
0967 
0968     for (auto& place : places)
0969     {
0970         d->placesComboBox->addItem(place);
0971 
0972         if (place == selected)
0973         {
0974             // Keep previous selection if it is still an option.
0975 
0976             d->placesComboBox->setCurrentText(selected);
0977         }
0978     }
0979 }
0980 
0981 void INatWindow::slotNearbyObservation(const INatTalker::NearbyObservation&
0982                                        nearbyObservation)
0983 {
0984     if (!nearbyObservation.isValid())
0985     {
0986         qCDebug(DIGIKAM_WEBSERVICES_LOG) << "No valid nearby observation.";
0987 
0988         d->closestKnownObservation->clear();
0989         d->closestKnownObservation->hide();
0990 
0991         return;
0992     }
0993 
0994     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Received nearby observation.";
0995 
0996     QString red1;
0997     QString red2;
0998 
0999     if (nearbyObservation.m_distanceMeters > d->closestObservationMaxSpB->value())
1000     {
1001         red1 = QLatin1String("<font color=\"red\">");
1002         red2 = QLatin1String("</font>");
1003     }
1004 
1005     QString distance(red1 + localizedDistance(nearbyObservation.
1006                      m_distanceMeters, 'f', 1) + red2);
1007 
1008     QString observation(QString(QLatin1String("<a href=\"https://www.inatura"
1009                                               "list.org/observations/%1\">")).
1010                         arg(nearbyObservation.m_observationId) +
1011                         i18n("observation") + QLatin1String("</a>"));
1012 
1013     QString obscured;
1014 
1015     if (nearbyObservation.m_obscured)
1016     {
1017         obscured = QLatin1String("<em>") + i18nc("location", "obscured") +
1018                    QLatin1String("</em> ");
1019     }
1020 
1021     QString text(i18n("Closest %1research-grade %2 reported in %3.",
1022                       obscured, observation, distance));
1023     d->closestKnownObservation->setText(text);
1024     d->closestKnownObservation->show();
1025 }
1026 
1027 void INatWindow::slotImageListChanged()
1028 {
1029     static const QLatin1Char lf('\n');
1030 
1031     // number of digits printed for latitude and longitude
1032 
1033     enum
1034     {
1035         COORD_PRECISION = 5
1036     };
1037 
1038     qCDebug(DIGIKAM_WEBSERVICES_LOG) << "Updating image list.";
1039 
1040     bool      latLonValid = false;
1041     double    latitude    = 0.0;
1042     double    longitude   = 0.0;
1043     QDateTime observationTime;
1044 
1045     DItemsListView* const listView = d->widget->d->imglst->listView();
1046 
1047     for (auto& url : d->imglst->imageUrls())
1048     {
1049         if (url.isEmpty())
1050         {
1051             continue;
1052         }
1053 
1054         DItemInfo info(d->iface->itemInfo(url));
1055         DItemsListViewItem* const item = listView->findItem(url);
1056         QDateTime dateTime             = info.dateTime();
1057         QString dt;
1058 
1059         if (dateTime.isValid())
1060         {
1061             // show date and time of photo, our observation
1062 
1063             if (!observationTime.isValid())
1064             {
1065                 observationTime = dateTime;
1066                 dt              = QLocale().toString(dateTime, QLocale::ShortFormat) + lf +
1067                                   i18n("observation time");
1068                 QBrush brush(Qt::black);
1069                 item->setForeground(ItemDate, brush);
1070             }
1071             else
1072             {
1073                 // show time difference from observation
1074 
1075                 int difference = int(qAbs(dateTime.secsTo(observationTime)));
1076                 dt             = localizedTimeDifference(difference) + lf +
1077                                  i18n("from observation");
1078                 QBrush brush((difference > 60 * d->photoMaxTimeDiffSpB->value()) ? Qt::red : Qt::black);
1079                 item->setForeground(ItemDate, brush);
1080             }
1081         }
1082         else
1083         {
1084             dt         = i18n("not available");
1085             QFont font = item->font(ItemDate);
1086             font.setItalic(true);
1087             item->setFont(ItemDate, font);
1088         }
1089 
1090         item->setText(ItemDate, dt);
1091 
1092         QString gps;
1093 
1094         if (info.hasGeolocationInfo())
1095         {
1096             if (latLonValid)
1097             {
1098                 // show distance from observation coordinates
1099 
1100                 double distance = distanceBetween(latitude, longitude,
1101                                                   info.latitude(),
1102                                                   info.longitude());
1103                 gps             = localizedDistance(distance,'f', 0) + lf +
1104                                   i18n("from observation");
1105                 QBrush brush((distance > d->photoMaxDistanceSpB->value()) ? Qt::red : Qt::black);
1106                 item->setForeground(ItemLocation, brush);
1107             }
1108             else
1109             {
1110                 // show gps coordinates of photo, our observation coordinates
1111 
1112                 latLonValid = true;
1113                 latitude    = info.latitude();
1114                 longitude   = info.longitude();
1115                 gps         = localizedLocation(latitude, longitude, COORD_PRECISION) +
1116                               lf + i18n("observation location");
1117                 QBrush brush(Qt::black);
1118                 item->setForeground(ItemLocation, brush);
1119             }
1120         }
1121         else
1122         {
1123             // gps coordinates not available
1124 
1125             gps        = i18n("not available");
1126             QFont font = item->font(ItemLocation);
1127             font.setItalic(true);
1128             item->setFont(ItemLocation, font);
1129         }
1130 
1131         item->setText(ItemLocation, gps);
1132     }
1133 
1134     if ((d->latLonValid != latLonValid) ||
1135         (d->latitude != latitude)       ||
1136         (d->longitude != longitude))
1137     {
1138         if (latLonValid)
1139         {
1140             d->talker->nearbyPlaces(latitude, longitude);
1141         }
1142         else
1143         {
1144             slotNearbyPlaces(QStringList());
1145         }
1146     }
1147 
1148     d->latLonValid         = latLonValid;
1149     d->latitude            = latitude;
1150     d->longitude           = longitude;
1151     d->observationDateTime = observationTime;
1152 
1153     startButton()->setEnabled(observationTime.isValid() && latLonValid &&
1154                               d->identification.isValid() && !d->inCancel &&
1155                               (d->imglst->imageUrls().count() <= MAX_OBSERVATION_PHOTOS));
1156 }
1157 
1158 void INatWindow::slotValueChanged(int)
1159 {
1160     // Called upon change to d->photoMaxDistanceSpB or d->photoMaxTimeDiffSpB.
1161 
1162     slotImageListChanged();
1163 }
1164 
1165 void INatWindow::slotClosestChanged(int)
1166 {
1167     // Called upon change to d->closestObservationMaxSpB
1168 
1169     if (d->latLonValid && d->identification.isValid())
1170     {
1171         d->talker->closestObservation(d->identification.id(),
1172                                       d->latitude, d->longitude);
1173     }
1174     else
1175     {
1176         d->closestKnownObservation->clear();
1177     }
1178 }
1179 
1180 void INatWindow::saveEditedPlaceName(const QString& text)
1181 {
1182     if (!d->editedPlaces.contains(text))
1183     {
1184         for (int i = 0; i < d->placesComboBox->count(); ++i)
1185         {
1186             if (d->placesComboBox->itemText(i) == text)
1187             {
1188                 return;
1189             }
1190         }
1191     }
1192 
1193     d->editedPlaces.removeOne(text);
1194     d->editedPlaces.push_front(text);
1195 
1196     if (d->editedPlaces.count() > MAX_EDITED_PLACES)
1197     {
1198         d->editedPlaces.removeLast();
1199     }
1200 }
1201 
1202 } // namespace DigikamGenericINatPlugin
1203 
1204 #include "moc_inatwindow.cpp"