1 /*
2   This file is part of KOrganizer.
3   SPDX-FileCopyrightText: 2001 Cornelius Schumacher <schumacher@kde.org>
4   SPDX-FileCopyrightText: 2007 Loïc Corbasson <loic.corbasson@gmail.com>
5   SPDX-FileCopyrightText: 2021 Friedrich W. H. Kossebau <kossebau@kde.org>
6 
7   SPDX-License-Identifier: GPL-2.0-or-later
8 */
9 
10 #include "element.h"
11 #include "picoftheday.h"
12 
13 #include "korganizer_picoftheday_plugin_debug.h"
14 
15 #include <KIO/Scheduler>
16 #include <KIO/StoredTransferJob>
17 #include <KLocalizedString>
18 
19 #include <QJsonArray>
20 #include <QJsonDocument>
21 #include <QUrlQuery>
22 
23 #include <chrono>
24 
25 using namespace std::chrono_literals;
26 
27 constexpr auto updateDelay = 1s;
28 
updateFetchedThumbSize()29 void ElementData::updateFetchedThumbSize()
30 {
31     int thumbWidth = mThumbSize.width();
32     int thumbHeight = static_cast<int>(thumbWidth * mPictureHWRatio);
33     if (mThumbSize.height() < thumbHeight) {
34         /* if the requested height is less than the requested width * ratio
35            we would download too much, as the downloaded picture would be
36            taller than requested, so we adjust the width of the picture to
37            be downloaded in consequence */
38         thumbWidth /= (thumbHeight / static_cast<float>(mThumbSize.height()));
39         thumbHeight = static_cast<int>(thumbWidth * mPictureHWRatio);
40     }
41     mFetchedThumbSize = QSize(thumbWidth, thumbHeight);
42 }
43 
POTDElement(const QString & id,QDate date,ElementData * data)44 POTDElement::POTDElement(const QString &id, QDate date, ElementData *data)
45     : Element(id)
46     , mDate(date)
47     , mData(data)
48     , mThumbImageGetDelayTimer(new QTimer(this))
49 {
50     mThumbImageGetDelayTimer->setSingleShot(true);
51     mThumbImageGetDelayTimer->setInterval(updateDelay);
52     connect(mThumbImageGetDelayTimer, &QTimer::timeout, this, &POTDElement::queryThumbImageInfoJson);
53 
54     // wait a bit to avoid data queries in case of quick paging through views
55     QTimer::singleShot(updateDelay, this, &POTDElement::completeMissingData);
56 }
57 
~POTDElement()58 POTDElement::~POTDElement()
59 {
60     // reset thumb update state
61     if (mData->mState > DataLoaded) {
62         mData->mState = DataLoaded;
63     }
64     Picoftheday::cacheData(mDate, mData);
65 }
66 
completeMissingData()67 void POTDElement::completeMissingData()
68 {
69     if (mData->mState <= NeedingPageData) {
70         queryImagesJson();
71     } else if (mData->mState <= NeedingBasicImageInfo) {
72         queryBasicImageInfoJson();
73     } else if (mData->mState <= NeedingFirstThumbImage) {
74         queryThumbImageInfoJson();
75     }
76 }
77 
createJsonQueryJob(const QString & property,const QString & title,const QList<QueryItem> & otherQueryItems)78 KIO::SimpleJob *POTDElement::createJsonQueryJob(const QString &property, const QString &title, const QList<QueryItem> &otherQueryItems)
79 {
80     QUrl url(QStringLiteral("https://en.wikipedia.org/w/api.php"));
81 
82     QUrlQuery urlQuery{
83         {QStringLiteral("action"), QStringLiteral("query")},
84         {QStringLiteral("format"), QStringLiteral("json")},
85         {QStringLiteral("prop"), property},
86         {QStringLiteral("titles"), title},
87     };
88     for (const auto &item : otherQueryItems) {
89         urlQuery.addQueryItem(item.key, item.value);
90     }
91     url.setQuery(urlQuery);
92 
93     auto job = KIO::storedGet(url, KIO::NoReload, KIO::HideProgressInfo);
94     KIO::Scheduler::setJobPriority(job, 1);
95 
96     return job;
97 }
98 
createImagesJsonQueryJob(PageProtectionState state)99 KIO::SimpleJob *POTDElement::createImagesJsonQueryJob(PageProtectionState state)
100 {
101     const char *const templatePagePrefix = (state == ProtectedPage) ? "Template:POTD_protected/" : "Template:POTD/";
102     const QString templatePageName = QLatin1String(templatePagePrefix) + mDate.toString(Qt::ISODate);
103     const QList<QueryItem> otherQueryItems{
104         // TODO: unsure if formatversion is needed, used by https://www.mediawiki.org/wiki/API:Picture_of_the_day_viewer in October 2021
105         {QStringLiteral("formatversion"), QStringLiteral("2")},
106     };
107 
108     return createJsonQueryJob(QStringLiteral("images"), templatePageName, otherQueryItems);
109 }
110 
queryImagesJson()111 void POTDElement::queryImagesJson()
112 {
113     auto queryImagesJob = createImagesJsonQueryJob(ProtectedPage);
114 
115     connect(queryImagesJob, &KIO::SimpleJob::result, this, &POTDElement::handleProtectedImagesJsonResponse);
116 }
117 
handleImagesJsonResponse(KJob * job,PageProtectionState pageProtectionState)118 void POTDElement::handleImagesJsonResponse(KJob *job, PageProtectionState pageProtectionState)
119 {
120     if (job->error()) {
121         qCWarning(KORGANIZERPICOFTHEDAYPLUGIN_LOG) << mDate << ": could not get POTD file name:" << job->errorString();
122         setLoadingFailed();
123         return;
124     }
125 
126     auto const transferJob = static_cast<KIO::StoredTransferJob *>(job);
127 
128     const auto json = QJsonDocument::fromJson(transferJob->data());
129 
130     const auto pageObject = json.object().value(QLatin1String("query")).toObject().value(QLatin1String("pages")).toArray().at(0).toObject();
131 
132     auto missingIt = pageObject.find(QLatin1String("missing"));
133     if ((missingIt != pageObject.end()) && missingIt.value().toBool(false)) {
134         // fallback to unprotected variant in case there is no protected variant
135         if (pageProtectionState == ProtectedPage) {
136             qCDebug(KORGANIZERPICOFTHEDAYPLUGIN_LOG) << mDate << ": protected page reported as missing, trying unprocteded now.";
137             auto queryImagesJob = createImagesJsonQueryJob(UnprotectedPage);
138 
139             connect(queryImagesJob, &KIO::SimpleJob::result, this, &POTDElement::handleUnprotectedImagesJsonResponse);
140             return;
141         }
142 
143         // no POTD set
144         qCDebug(KORGANIZERPICOFTHEDAYPLUGIN_LOG) << mDate << ": also unprotected page reported as missing, Seems no POTD is declared.";
145         setLoadingFailed();
146         return;
147     }
148 
149     const auto imageObject = pageObject.value(QLatin1String("images")).toArray().at(0).toObject();
150     const QString imageFile = imageObject.value(QLatin1String("title")).toString();
151     if (imageFile.isEmpty()) {
152         qCWarning(KORGANIZERPICOFTHEDAYPLUGIN_LOG) << mDate << ": missing images data in reply:" << json;
153         setLoadingFailed();
154         return;
155     }
156 
157     // store data
158     mData->mPictureName = imageFile;
159     mData->mState = NeedingBasicImageInfo;
160 
161     queryBasicImageInfoJson();
162 }
163 
handleUnprotectedImagesJsonResponse(KJob * job)164 void POTDElement::handleUnprotectedImagesJsonResponse(KJob *job)
165 {
166     handleImagesJsonResponse(job, UnprotectedPage);
167 }
168 
handleProtectedImagesJsonResponse(KJob * job)169 void POTDElement::handleProtectedImagesJsonResponse(KJob *job)
170 {
171     handleImagesJsonResponse(job, ProtectedPage);
172 }
173 
queryBasicImageInfoJson()174 void POTDElement::queryBasicImageInfoJson()
175 {
176     const QList<QueryItem> otherQueryItems{
177         {QStringLiteral("iiprop"), QStringLiteral("url|size|canonicaltitle")},
178     };
179     auto queryBasicImageInfoJob = createJsonQueryJob(QStringLiteral("imageinfo"), mData->mPictureName, otherQueryItems);
180 
181     connect(queryBasicImageInfoJob, &KIO::SimpleJob::result, this, &POTDElement::handleBasicImageInfoJsonResponse);
182 }
183 
handleBasicImageInfoJsonResponse(KJob * job)184 void POTDElement::handleBasicImageInfoJsonResponse(KJob *job)
185 {
186     if (job->error()) {
187         qCWarning(KORGANIZERPICOFTHEDAYPLUGIN_LOG) << mDate << ": could not get POTD file name:" << job->errorString();
188         setLoadingFailed();
189         return;
190     }
191 
192     auto const transferJob = static_cast<KIO::StoredTransferJob *>(job);
193 
194     const auto json = QJsonDocument::fromJson(transferJob->data());
195 
196     const auto pagesObject = json.object().value(QLatin1String("query")).toObject().value(QLatin1String("pages")).toObject();
197     const auto pageObject = pagesObject.isEmpty() ? QJsonObject() : pagesObject.begin()->toObject();
198     const auto imageInfo = pageObject.value(QLatin1String("imageinfo")).toArray().at(0).toObject();
199 
200     const QString url = imageInfo.value(QLatin1String("url")).toString();
201     if (url.isEmpty()) {
202         qCWarning(KORGANIZERPICOFTHEDAYPLUGIN_LOG) << mDate << ": missing imageinfo data in reply:" << json;
203         setLoadingFailed();
204         return;
205     }
206 
207     const QString descriptionUrl = imageInfo.value(QLatin1String("descriptionurl")).toString();
208     mData->mAboutPageUrl = QUrl(descriptionUrl);
209 
210     const QString description = imageInfo.value(QLatin1String("canonicaltitle")).toString();
211     mData->mTitle = i18n("Wikipedia POTD: %1", description);
212 
213     const int width = imageInfo.value(QLatin1String("width")).toInt();
214     const int height = imageInfo.value(QLatin1String("height")).toInt();
215     mData->mPictureHWRatio = ((width != 0) && (height != 0)) ? height / static_cast<float>(width) : 1.0;
216     qCDebug(KORGANIZERPICOFTHEDAYPLUGIN_LOG) << mDate << ": thumb width" << width << " thumb height" << height << "ratio" << mData->mPictureHWRatio;
217     mData->updateFetchedThumbSize();
218     mData->mState = NeedingFirstThumbImageInfo;
219 
220     queryThumbImageInfoJson();
221 }
222 
queryThumbImageInfoJson()223 void POTDElement::queryThumbImageInfoJson()
224 {
225     qCDebug(KORGANIZERPICOFTHEDAYPLUGIN_LOG) << mDate << ": thumb size" << mData->mThumbSize << " adapted size" << mData->mFetchedThumbSize;
226 
227     const QList<QueryItem> otherQueryItems{
228         {QStringLiteral("iiprop"), QStringLiteral("url")},
229         {QStringLiteral("iiurlwidth"), QString::number(mData->mFetchedThumbSize.width())},
230         {QStringLiteral("iiurlheight"), QString::number(mData->mFetchedThumbSize.height())},
231     };
232     mQueryThumbImageInfoJob = createJsonQueryJob(QStringLiteral("imageinfo"), mData->mPictureName, otherQueryItems);
233 
234     connect(mQueryThumbImageInfoJob, &KIO::SimpleJob::result, this, &POTDElement::handleThumbImageInfoJsonResponse);
235 }
236 
handleThumbImageInfoJsonResponse(KJob * job)237 void POTDElement::handleThumbImageInfoJsonResponse(KJob *job)
238 {
239     mQueryThumbImageInfoJob = nullptr;
240 
241     if (job->error()) {
242         qCWarning(KORGANIZERPICOFTHEDAYPLUGIN_LOG) << mDate << ": could not get thumb info:" << job->errorString();
243         if (mData->mState == NeedingFirstThumbImageInfo) {
244             setLoadingFailed();
245         }
246         return;
247     }
248 
249     auto const transferJob = static_cast<KIO::StoredTransferJob *>(job);
250 
251     const auto json = QJsonDocument::fromJson(transferJob->data());
252     auto pagesObject = json.object().value(QLatin1String("query")).toObject().value(QLatin1String("pages")).toObject();
253     auto pageObject = pagesObject.isEmpty() ? QJsonObject() : pagesObject.begin()->toObject();
254     auto imageInfo = pageObject.value(QLatin1String("imageinfo")).toArray().at(0).toObject();
255 
256     const QString thumbUrl = imageInfo.value(QStringLiteral("thumburl")).toString();
257     if (thumbUrl.isEmpty()) {
258         qCWarning(KORGANIZERPICOFTHEDAYPLUGIN_LOG) << mDate << ": missing imageinfo data in reply:" << json;
259         return;
260     }
261 
262     mData->mState = (mData->mState == NeedingFirstThumbImageInfo) ? NeedingFirstThumbImage : NeedingNextThumbImage;
263 
264     getThumbImage(QUrl(thumbUrl));
265 }
266 
getThumbImage(const QUrl & thumbUrl)267 void POTDElement::getThumbImage(const QUrl &thumbUrl)
268 {
269     if (mGetThumbImageJob) {
270         mGetThumbImageJob->kill();
271     }
272 
273     qCDebug(KORGANIZERPICOFTHEDAYPLUGIN_LOG) << mDate << ": fetching POTD thumbnail:" << thumbUrl;
274 
275     mGetThumbImageJob = KIO::storedGet(thumbUrl, KIO::NoReload, KIO::HideProgressInfo);
276     KIO::Scheduler::setJobPriority(mGetThumbImageJob, 1);
277 
278     connect(mGetThumbImageJob, &KIO::SimpleJob::result, this, &POTDElement::handleGetThumbImageResponse);
279 }
280 
handleGetThumbImageResponse(KJob * job)281 void POTDElement::handleGetThumbImageResponse(KJob *job)
282 {
283     mGetThumbImageJob = nullptr;
284 
285     const bool isAboutFirstThumbImage = (mData->mState == NeedingFirstThumbImage);
286 
287     if (job->error()) {
288         qCWarning(KORGANIZERPICOFTHEDAYPLUGIN_LOG) << mDate << ": could not get POTD thumb:" << job->errorString();
289         if (isAboutFirstThumbImage) {
290             setLoadingFailed();
291         }
292         return;
293     }
294 
295     // Last step completed: we get the pixmap from the transfer job's data
296     auto const transferJob = static_cast<KIO::StoredTransferJob *>(job);
297     if (!mData->mThumbnail.loadFromData(transferJob->data())) {
298         qCWarning(KORGANIZERPICOFTHEDAYPLUGIN_LOG) << mDate << ": could not load POTD thumb data.";
299         if (isAboutFirstThumbImage) {
300             setLoadingFailed();
301         }
302         return;
303     }
304 
305     mData->mState = DataLoaded;
306 
307     if (isAboutFirstThumbImage) {
308         // update other properties
309         Q_EMIT gotNewShortText(shortText());
310         Q_EMIT gotNewLongText(mData->mTitle);
311         Q_EMIT gotNewUrl(mData->mAboutPageUrl);
312     }
313 
314     if (!mRequestedThumbSize.isNull()) {
315         Q_EMIT gotNewPixmap(mData->mThumbnail.scaled(mRequestedThumbSize, Qt::KeepAspectRatio, Qt::SmoothTransformation));
316     }
317 }
318 
setLoadingFailed()319 void POTDElement::setLoadingFailed()
320 {
321     mData->mState = LoadingFailed;
322 
323     Q_EMIT gotNewShortText(QString());
324     Q_EMIT gotNewLongText(QString());
325 }
326 
shortText() const327 QString POTDElement::shortText() const
328 {
329     return (mData->mState >= DataLoaded) ? i18n("Picture Page") : (mData->mState >= NeedingPageData) ? i18n("Loading...") : QString();
330 }
331 
longText() const332 QString POTDElement::longText() const
333 {
334     return (mData->mState >= DataLoaded)     ? mData->mTitle
335         : (mData->mState >= NeedingPageData) ? i18n("<qt>Loading <i>Picture of the Day</i>...</qt>")
336                                              : QString();
337 }
338 
url() const339 QUrl POTDElement::url() const
340 {
341     return (mData->mState >= DataLoaded) ? mData->mAboutPageUrl : QUrl();
342 }
343 
newPixmap(const QSize & size)344 QPixmap POTDElement::newPixmap(const QSize &size)
345 {
346     mRequestedThumbSize = size;
347 
348     if ((mData->mThumbSize.width() < size.width()) || (mData->mThumbSize.height() < size.height())) {
349         qCDebug(KORGANIZERPICOFTHEDAYPLUGIN_LOG) << mDate << ": called for a new pixmap size (" << size << "instead of" << mData->mThumbSize
350                                                  << ", stored pixmap:" << mData->mThumbnail.size() << ")";
351         mData->mThumbSize = size;
352 
353         if (mData->mState >= NeedingFirstThumbImageInfo) {
354             mData->updateFetchedThumbSize();
355 
356             if ((mData->mFetchedThumbSize.width() < size.width()) || (mData->mFetchedThumbSize.height() < size.height())) {
357                 // only if there is already an initial pixmap to show at least something,
358                 // kill current update and trigger new delayed update
359                 if (mData->mState >= DataLoaded) {
360                     if (mQueryThumbImageInfoJob) {
361                         mQueryThumbImageInfoJob->kill();
362                         mQueryThumbImageInfoJob = nullptr;
363                     }
364                     if (mGetThumbImageJob) {
365                         mGetThumbImageJob->kill();
366                         mGetThumbImageJob = nullptr;
367                     }
368                     mData->mState = NeedingNextThumbImageInfo;
369                 }
370 
371                 // We start a new thumbnail download a little later; the following code
372                 // is to avoid too frequent transfers e.g. when resizing
373                 mThumbImageGetDelayTimer->start();
374             }
375         }
376     }
377 
378     /* else, either we already got a sufficiently big pixmap (stored in mData->mThumbnail),
379        or we will get one anytime soon (we are downloading it already) and we will
380        actualize what we return here later via gotNewPixmap */
381     if (mData->mThumbnail.isNull()) {
382         return QPixmap();
383     }
384     return mData->mThumbnail.scaled(mRequestedThumbSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
385 }
386