QGIS API Documentation 4.1.0-Master (376402f9aeb)
Loading...
Searching...
No Matches
qgsnewsfeedparser.cpp
Go to the documentation of this file.
1/***************************************************************************
2 qgsnewsfeedparser.cpp
3 -------------------
4 begin : July 2019
5 copyright : (C) 2019 by Nyall Dawson
6 email : nyall dot dawson at gmail dot com
7 ***************************************************************************
8 * *
9 * This program is free software; you can redistribute it and/or modify *
10 * it under the terms of the GNU General Public License as published by *
11 * the Free Software Foundation; either version 2 of the License, or *
12 * (at your option) any later version. *
13 * *
14 ***************************************************************************/
15#include "qgsnewsfeedparser.h"
16
17#include "qgis.h"
18#include "qgsapplication.h"
19#include "qgsjsonutils.h"
20#include "qgsmessagelog.h"
25#include "qgssettings.h"
27
28#include <QDateTime>
29#include <QDir>
30#include <QFile>
31#include <QRegularExpression>
32#include <QString>
33#include <QUrlQuery>
34
35#include "moc_qgsnewsfeedparser.cpp"
36
37using namespace Qt::StringLiterals;
38
39const QgsSettingsEntryBool *QgsNewsFeedParser::settingsFeedDisabled = new QgsSettingsEntryBool( u"disabled"_s, sTreeNewsFeed, false, u"Whether the news feed is disabled"_s );
41 = new QgsSettingsEntryInteger64( u"last-fetch-time"_s, sTreeNewsFeed, 0, u"Feed last fetch time"_s, Qgis::SettingsOptions(), 0 );
42const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedLanguage = new QgsSettingsEntryString( u"lang"_s, sTreeNewsFeed, QString(), u"Feed language"_s );
43const QgsSettingsEntryDouble *QgsNewsFeedParser::settingsFeedLatitude = new QgsSettingsEntryDouble( u"latitude"_s, sTreeNewsFeed, 0.0, u"Feed latitude"_s );
44const QgsSettingsEntryDouble *QgsNewsFeedParser::settingsFeedLongitude = new QgsSettingsEntryDouble( u"longitude"_s, sTreeNewsFeed, 0.0, u"Feed longitude"_s );
45
46
47const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedEntryTitle = new QgsSettingsEntryString( u"title"_s, sTreeNewsFeedEntries, QString(), u"Entry title"_s );
48const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedEntryImageUrl = new QgsSettingsEntryString( u"image-url"_s, sTreeNewsFeedEntries, QString(), u"Entry image URL"_s );
49const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedEntryContent = new QgsSettingsEntryString( u"content"_s, sTreeNewsFeedEntries, QString(), u"Entry content"_s );
50const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedEntryLink = new QgsSettingsEntryString( u"link"_s, sTreeNewsFeedEntries, QString(), u"Entry link"_s );
51const QgsSettingsEntryBool *QgsNewsFeedParser::settingsFeedEntrySticky = new QgsSettingsEntryBool( u"sticky"_s, sTreeNewsFeedEntries, false );
52const QgsSettingsEntryVariant *QgsNewsFeedParser::settingsFeedEntryPublished = new QgsSettingsEntryVariant( u"published"_s, sTreeNewsFeedEntries, QVariant(), u"Publication date"_s );
53const QgsSettingsEntryVariant *QgsNewsFeedParser::settingsFeedEntryExpiry = new QgsSettingsEntryVariant( u"expiry"_s, sTreeNewsFeedEntries, QVariant(), u"Expiry date"_s );
54
55
56QgsNewsFeedParser::QgsNewsFeedParser( const QUrl &feedUrl, const QString &authcfg, QObject *parent )
57 : QObject( parent )
58 , mBaseUrl( feedUrl.toString() )
59 , mFeedUrl( feedUrl )
60 , mAuthCfg( authcfg )
61 , mFeedKey( keyForFeed( mBaseUrl ) )
62{
63 // Synchronize enabled/disabled state
64 mEnabled = !settingsFeedDisabled->value( mFeedKey );
65
66 // first thing we do is populate with existing entries
67 readStoredEntries();
68
69 QUrlQuery query( feedUrl );
70
71 const qint64 after = settingsFeedLastFetchTime->value( mFeedKey );
72 if ( after > 0 )
73 query.addQueryItem( u"after"_s, qgsDoubleToString( after, 0 ) );
74
75 QString feedLanguage = settingsFeedLanguage->value( mFeedKey );
76 if ( feedLanguage.isEmpty() )
77 {
78 feedLanguage = QgsApplication::settingsLocaleUserLocale->valueWithDefaultOverride( u"en"_s );
79 }
80 if ( !feedLanguage.isEmpty() && feedLanguage != "C"_L1 )
81 query.addQueryItem( u"lang"_s, feedLanguage.mid( 0, 2 ) );
82
83 if ( settingsFeedLatitude->exists( mFeedKey ) && settingsFeedLongitude->exists( mFeedKey ) )
84 {
85 const double feedLat = settingsFeedLatitude->value( mFeedKey );
86 const double feedLong = settingsFeedLongitude->value( mFeedKey );
87
88 // hack to allow testing using local files
89 if ( feedUrl.isLocalFile() )
90 {
91 query.addQueryItem( u"lat"_s, QString::number( static_cast< int >( feedLat ) ) );
92 query.addQueryItem( u"lon"_s, QString::number( static_cast< int >( feedLong ) ) );
93 }
94 else
95 {
96 query.addQueryItem( u"lat"_s, qgsDoubleToString( feedLat ) );
97 query.addQueryItem( u"lon"_s, qgsDoubleToString( feedLong ) );
98 }
99 }
100
101 // bit of a hack to allow testing using local files
102 if ( feedUrl.isLocalFile() )
103 {
104 if ( !query.toString().isEmpty() )
105 mFeedUrl = QUrl( mFeedUrl.toString() + '_' + query.toString() );
106 }
107 else
108 {
109 mFeedUrl.setQuery( query ); // doesn't work for local file urls
110 }
111}
112
114{
115 if ( mEnabled == enabled )
116 {
117 return;
118 }
119
120 mEnabled = enabled;
121 emit enabledChanged();
122
123 settingsFeedDisabled->setValue( !mEnabled, { mFeedKey } );
124}
125
126QList<QgsNewsFeedParser::Entry> QgsNewsFeedParser::entries() const
127{
128 return mEntries;
129}
130
132{
133 Entry dismissed;
134 const int beforeSize = mEntries.size();
135 mEntries.erase(
136 std::remove_if(
137 mEntries.begin(),
138 mEntries.end(),
139 [key, &dismissed]( const Entry &entry ) {
140 if ( entry.key == key )
141 {
142 dismissed = entry;
143 return true;
144 }
145 return false;
146 }
147 ),
148 mEntries.end()
149 );
150 if ( beforeSize == mEntries.size() )
151 return; // didn't find matching entry
152
153 try
154 {
155 sTreeNewsFeedEntries->deleteItem( QString::number( key ), { mFeedKey } );
156 }
157 catch ( QgsSettingsException &e )
158 {
159 QgsDebugError( u"Could not dismiss news feed entry: %1"_s.arg( e.what() ) );
160 }
161
162 // also remove preview image, if it exists
163 if ( !dismissed.imageUrl.isEmpty() )
164 {
165 const QString previewDir = u"%1/previewImages"_s.arg( QgsApplication::qgisSettingsDirPath() );
166 const QString imagePath = u"%1/%2.png"_s.arg( previewDir ).arg( key );
167 if ( QFile::exists( imagePath ) )
168 {
169 QFile::remove( imagePath );
170 }
171 }
172
173 if ( !mBlockSignals )
174 emit entryDismissed( dismissed );
175}
176
178{
179 const QList< QgsNewsFeedParser::Entry > entries = mEntries;
180 for ( const Entry &entry : entries )
181 {
182 dismissEntry( entry.key );
183 }
184}
185
187{
188 return mAuthCfg;
189}
190
192{
193 if ( mIsFetching )
194 {
195 return;
196 }
197
198 QNetworkRequest req( mFeedUrl );
199 QgsSetRequestInitiatorClass( req, u"QgsNewsFeedParser"_s );
200
201 mFetchStartTime = QDateTime::currentDateTimeUtc().toSecsSinceEpoch();
202
203 // allow canceling the news fetching without prompts -- it's not crucial if this gets finished or not
205 task->setDescription( tr( "Fetching News Feed" ) );
206 connect( task, &QgsNetworkContentFetcherTask::fetched, this, [this, task] {
207 mIsFetching = false;
208 emit isFetchingChanged();
209
210 QNetworkReply *reply = task->reply();
211 if ( !reply )
212 {
213 // canceled
214 return;
215 }
216
217 if ( reply->error() != QNetworkReply::NoError )
218 {
219 QgsMessageLog::logMessage( tr( "News feed request failed [error: %1]" ).arg( reply->errorString() ) );
220 return;
221 }
222
223 // queue up the handling
224 QMetaObject::invokeMethod( this, "onFetch", Qt::QueuedConnection, Q_ARG( QString, task->contentAsString() ) );
225 } );
226
227 mIsFetching = true;
228 emit isFetchingChanged();
229
231}
232
233void QgsNewsFeedParser::onFetch( const QString &content )
234{
235 settingsFeedLastFetchTime->setValue( mFetchStartTime, { mFeedKey } );
236
237 const QVariant json = QgsJsonUtils::parseJson( content );
238
239 const QVariantList entries = json.toList();
240 QList< QgsNewsFeedParser::Entry > fetchedEntries;
241 fetchedEntries.reserve( entries.size() );
242 for ( const QVariant &e : entries )
243 {
244 Entry incomingEntry;
245 const QVariantMap entryMap = e.toMap();
246 incomingEntry.key = entryMap.value( u"pk"_s ).toInt();
247 incomingEntry.title = entryMap.value( u"title"_s ).toString().trimmed();
248 incomingEntry.imageUrl = entryMap.value( u"image"_s ).toString();
249 incomingEntry.content = entryMap.value( u"content"_s ).toString().trimmed();
250 incomingEntry.link = entryMap.value( u"url"_s ).toString();
251 incomingEntry.sticky = entryMap.value( u"sticky"_s ).toBool();
252 incomingEntry.published.setSecsSinceEpoch( entryMap.value( u"publish_from"_s ).toLongLong() );
253 bool hasExpiry = false;
254 const qlonglong expiry = entryMap.value( u"publish_to"_s ).toLongLong( &hasExpiry );
255 if ( hasExpiry )
256 incomingEntry.expiry.setSecsSinceEpoch( expiry );
257
258 fetchedEntries.append( incomingEntry );
259
260 // We also need to handle the case of modified/expired entries
261 const auto entryIter { std::find_if( mEntries.begin(), mEntries.end(), [incomingEntry]( const QgsNewsFeedParser::Entry &candidate ) { return candidate.key == incomingEntry.key; } ) };
262 const bool entryExists { entryIter != mEntries.end() };
263
264 // case 1: existing entry is now expired, dismiss
265 if ( hasExpiry && expiry < mFetchStartTime )
266 {
267 dismissEntry( incomingEntry.key );
268 }
269 // case 2: existing entry edited
270 else if ( entryExists )
271 {
272 const bool imageNeedsUpdate = ( entryIter->imageUrl != incomingEntry.imageUrl );
273 // also remove preview image, if it exists
274 if ( imageNeedsUpdate && !entryIter->imageUrl.isEmpty() )
275 {
276 const QString previewDir = u"%1/previewImages"_s.arg( QgsApplication::qgisSettingsDirPath() );
277 const QString imagePath = u"%1/%2.png"_s.arg( previewDir ).arg( entryIter->key );
278 if ( QFile::exists( imagePath ) )
279 {
280 QFile::remove( imagePath );
281 }
282 }
283 *entryIter = incomingEntry;
284 if ( imageNeedsUpdate && !incomingEntry.imageUrl.isEmpty() )
285 fetchImageForEntry( incomingEntry );
286
287 sTreeNewsFeedEntries->deleteItem( QString::number( incomingEntry.key ), { mFeedKey } );
288 storeEntryInSettings( incomingEntry );
289 emit entryUpdated( incomingEntry );
290 }
291 // else: new entry, not expired
292 else if ( !hasExpiry || expiry >= mFetchStartTime )
293 {
294 if ( !incomingEntry.imageUrl.isEmpty() )
295 fetchImageForEntry( incomingEntry );
296
297 mEntries.append( incomingEntry );
298 storeEntryInSettings( incomingEntry );
299 emit entryAdded( incomingEntry );
300 }
301 }
302
303 emit fetched( fetchedEntries );
304}
305
306void QgsNewsFeedParser::readStoredEntries()
307{
308 QStringList existing;
309 try
310 {
311 existing = sTreeNewsFeedEntries->items( { mFeedKey } );
312 }
313 catch ( QgsSettingsException &e )
314 {
315 QgsDebugError( u"Could not read news feed entries: %1"_s.arg( e.what() ) );
316 }
317
318 std::sort( existing.begin(), existing.end(), []( const QString &a, const QString &b ) { return a.toInt() < b.toInt(); } );
319 mEntries.reserve( existing.size() );
320 for ( const QString &entry : existing )
321 {
322 const Entry e = readEntryFromSettings( entry.toInt() );
323 if ( !e.expiry.isValid() || e.expiry > QDateTime::currentDateTime() )
324 mEntries.append( e );
325 else
326 {
327 // expired entry, prune it
328 mBlockSignals = true;
329 dismissEntry( e.key );
330 mBlockSignals = false;
331 }
332 }
333}
334
335QgsNewsFeedParser::Entry QgsNewsFeedParser::readEntryFromSettings( const int key )
336{
337 Entry entry;
338 entry.key = key;
339 entry.title = settingsFeedEntryTitle->value( { mFeedKey, QString::number( key ) } );
340 entry.imageUrl = settingsFeedEntryImageUrl->value( { mFeedKey, QString::number( key ) } );
341 entry.content = settingsFeedEntryContent->value( { mFeedKey, QString::number( key ) } );
342 entry.link = settingsFeedEntryLink->value( { mFeedKey, QString::number( key ) } );
343 entry.sticky = settingsFeedEntrySticky->value( { mFeedKey, QString::number( key ) } );
344 entry.published = settingsFeedEntryPublished->value( { mFeedKey, QString::number( key ) } ).toDateTime();
345 entry.expiry = settingsFeedEntryExpiry->value( { mFeedKey, QString::number( key ) } ).toDateTime();
346 if ( !entry.imageUrl.isEmpty() )
347 {
348 const QString previewDir = u"%1/previewImages"_s.arg( QgsApplication::qgisSettingsDirPath() );
349 const QString imagePath = u"%1/%2.png"_s.arg( previewDir ).arg( entry.key );
350 if ( QFile::exists( imagePath ) )
351 {
352 const QImage img( imagePath );
353 entry.image = QPixmap::fromImage( img );
354 }
355 else
356 {
357 fetchImageForEntry( entry );
358 }
359 }
360 return entry;
361}
362
363void QgsNewsFeedParser::storeEntryInSettings( const QgsNewsFeedParser::Entry &entry )
364{
365 settingsFeedEntryTitle->setValue( entry.title, { mFeedKey, QString::number( entry.key ) } );
366 settingsFeedEntryImageUrl->setValue( entry.imageUrl, { mFeedKey, QString::number( entry.key ) } );
367 settingsFeedEntryContent->setValue( entry.content, { mFeedKey, QString::number( entry.key ) } );
368 settingsFeedEntryLink->setValue( entry.link.toString(), { mFeedKey, QString::number( entry.key ) } );
369 settingsFeedEntrySticky->setValue( entry.sticky, { mFeedKey, QString::number( entry.key ) } );
370 settingsFeedEntryPublished->setValue( entry.published, { mFeedKey, QString::number( entry.key ) } );
371 if ( entry.expiry.isValid() )
372 settingsFeedEntryExpiry->setValue( entry.expiry, { mFeedKey, QString::number( entry.key ) } );
373}
374
375void QgsNewsFeedParser::fetchImageForEntry( const QgsNewsFeedParser::Entry &entry )
376{
377 // start fetching image
378 QgsNetworkContentFetcher *fetcher = new QgsNetworkContentFetcher();
379 connect( fetcher, &QgsNetworkContentFetcher::finished, this, [this, fetcher, entry] {
380 const auto findIter = std::find_if( mEntries.begin(), mEntries.end(), [entry]( const QgsNewsFeedParser::Entry &candidate ) { return candidate.key == entry.key; } );
381 if ( findIter != mEntries.end() )
382 {
383 const int entryIndex = static_cast< int >( std::distance( mEntries.begin(), findIter ) );
384
385 QImage img = QImage::fromData( fetcher->reply()->readAll() );
386
387 QSize size = img.size();
388 bool resize = false;
389 if ( size.width() > 250 )
390 {
391 size.setHeight( static_cast< int >( size.height() * static_cast< double >( 250 ) / size.width() ) );
392 size.setWidth( 250 );
393 resize = true;
394 }
395 if ( size.height() > 177 )
396 {
397 size.setWidth( static_cast< int >( size.width() * static_cast< double >( 177 ) / size.height() ) );
398 size.setHeight( 177 );
399 resize = true;
400 }
401 if ( resize )
402 img = img.scaled( size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation );
403
404 //nicely round corners so users don't get paper cuts
405 QImage previewImage( size, QImage::Format_ARGB32 );
406 previewImage.fill( Qt::transparent );
407 QPainter previewPainter( &previewImage );
408 previewPainter.setRenderHint( QPainter::Antialiasing, true );
409 previewPainter.setRenderHint( QPainter::SmoothPixmapTransform, true );
410 previewPainter.setPen( Qt::NoPen );
411 previewPainter.setBrush( Qt::black );
412 previewPainter.drawRoundedRect( 0, 0, size.width(), size.height(), 8, 8 );
413 previewPainter.setCompositionMode( QPainter::CompositionMode_SourceIn );
414 previewPainter.drawImage( 0, 0, img );
415 previewPainter.end();
416
417 // Save image, so we don't have to fetch it next time
418 const QString previewDir = u"%1/previewImages"_s.arg( QgsApplication::qgisSettingsDirPath() );
419 QDir().mkdir( previewDir );
420 const QString imagePath = u"%1/%2.png"_s.arg( previewDir ).arg( entry.key );
421 previewImage.save( imagePath );
422
423 mEntries[entryIndex].image = QPixmap::fromImage( previewImage );
424 this->emit imageFetched( entry.key, mEntries[entryIndex].image );
425 }
426 fetcher->deleteLater();
427 } );
428 fetcher->fetchContent( entry.imageUrl, mAuthCfg );
429}
430
431QString QgsNewsFeedParser::keyForFeed( const QString &baseUrl )
432{
433 static const QRegularExpression sRegexp( u"[^a-zA-Z0-9]"_s );
434 QString res = baseUrl;
435 res = res.replace( sRegexp, QString() );
436 return res;
437}
QFlags< SettingsOption > SettingsOptions
Definition qgis.h:756
static const QgsSettingsEntryString * settingsLocaleUserLocale
Settings entry locale user locale.
static QString qgisSettingsDirPath()
Returns the path to the settings directory in user's home dir.
static QgsTaskManager * taskManager()
Returns the application's task manager, used for managing application wide background task handling.
QString what() const
static QVariant parseJson(const std::string &jsonString)
Converts JSON jsonString to a QVariant, in case of parsing error an invalid QVariant is returned and ...
static void logMessage(const QString &message, const QString &tag=QString(), Qgis::MessageLevel level=Qgis::MessageLevel::Warning, bool notifyUser=true, const char *file=__builtin_FILE(), const char *function=__builtin_FUNCTION(), int line=__builtin_LINE(), Qgis::StringFormat format=Qgis::StringFormat::PlainText)
Adds a message to the log instance (and creates it if necessary).
Handles HTTP network content fetching in a background task.
QString contentAsString() const
Returns the fetched content as a string.
void fetched()
Emitted when the network content has been fetched, regardless of whether the fetch was successful or ...
QNetworkReply * reply()
Returns the network reply.
void fetchContent(const QUrl &url, const QString &authcfg=QString(), const QgsHttpHeaders &headers=QgsHttpHeaders())
Fetches content from a remote URL and handles redirects.
void finished()
Emitted when content has loaded.
QNetworkReply * reply()
Returns a reference to the network reply.
Represents a single entry from a news feed.
QString content
HTML content of news entry.
QDateTime published
Entry publication date.
bool sticky
true if entry is "sticky" and should always be shown at the top
QUrl link
Optional URL link for entry.
QString imageUrl
Optional URL for image associated with entry.
QDateTime expiry
Optional auto-expiry time for entry.
int key
Unique entry identifier.
QString title
Entry title.
static QgsSettingsTreeNamedListNode * sTreeNewsFeedEntries
void enabledChanged()
Emitted when the enabled/disabled state of the feed URL associated to the news parser changes.
static const QgsSettingsEntryString * settingsFeedEntryTitle
static const QgsSettingsEntryBool * settingsFeedDisabled
Q_INVOKABLE void dismissEntry(int key)
Dismisses an entry with matching key.
static const QgsSettingsEntryString * settingsFeedEntryLink
void fetch()
Fetches new entries from the feed's URL.
static const QgsSettingsEntryVariant * settingsFeedEntryPublished
void fetched(const QList< QgsNewsFeedParser::Entry > &entries)
Emitted when entries have been fetched from the feed.
void isFetchingChanged()
Emitted when the news parser's fetching state changes.
static const QgsSettingsEntryString * settingsFeedEntryImageUrl
static const QgsSettingsEntryDouble * settingsFeedLatitude
QString authcfg() const
Returns the authentication configuration for the parser.
static const QgsSettingsEntryInteger64 * settingsFeedLastFetchTime
QgsNewsFeedParser(const QUrl &feedUrl, const QString &authcfg=QString(), QObject *parent=nullptr)
Constructor for QgsNewsFeedParser, parsing the specified feedUrl.
static const QgsSettingsEntryBool * settingsFeedEntrySticky
Q_INVOKABLE void dismissAll()
Dismisses all current news items.
static const QgsSettingsEntryDouble * settingsFeedLongitude
static const QgsSettingsEntryString * settingsFeedLanguage
static const QgsSettingsEntryString * settingsFeedEntryContent
void entryUpdated(const QgsNewsFeedParser::Entry &entry)
Emitted whenever an existing entry is available from the feed (as a result of a call to fetch()).
static const QgsSettingsEntryVariant * settingsFeedEntryExpiry
static QString keyForFeed(const QString &baseUrl)
Returns the settings key used for a feed with the given baseUrl.
void entryAdded(const QgsNewsFeedParser::Entry &entry)
Emitted whenever a new entry is available from the feed (as a result of a call to fetch()).
void imageFetched(int key, const QPixmap &pixmap)
Emitted when the image attached to the entry with the specified key has been fetched and is now avail...
QList< QgsNewsFeedParser::Entry > entries() const
Returns a list of existing entries in the feed.
void setEnabled(bool enabled)
Sets whether the feed URL associated with the news parser is enabled.
bool setValue(const T &value, const QString &dynamicKeyPart=QString()) const
Set settings value.
A boolean settings entry.
A double settings entry.
A 64 bits integer (long long) settings entry.
A string settings entry.
A variant settings entry.
Custom exception class for settings related exceptions.
long addTask(QgsTask *task, int priority=0)
Adds a task to the manager.
@ CanCancel
Task can be canceled.
@ CancelWithoutPrompt
Task can be canceled without any users prompts, e.g. when closing a project or QGIS.
@ Silent
Don't show task updates (such as completion/failure messages) as operating-system level notifications...
void setDescription(const QString &description)
Sets the task's description.
QString qgsDoubleToString(double a, int precision=17)
Returns a string representation of a double.
Definition qgis.h:7052
#define QgsDebugError(str)
Definition qgslogger.h:59
#define QgsSetRequestInitiatorClass(request, _class)