QGIS API Documentation 3.99.0-Master (09f76ad7019)
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 QgsSettingsEntryInteger64 *QgsNewsFeedParser::settingsFeedLastFetchTime = new QgsSettingsEntryInteger64( u"last-fetch-time"_s, sTreeNewsFeed, 0, u"Feed last fetch time"_s, Qgis::SettingsOptions(), 0 );
40const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedLanguage = new QgsSettingsEntryString( u"lang"_s, sTreeNewsFeed, QString(), u"Feed language"_s );
41const QgsSettingsEntryDouble *QgsNewsFeedParser::settingsFeedLatitude = new QgsSettingsEntryDouble( u"latitude"_s, sTreeNewsFeed, 0.0, u"Feed latitude"_s );
42const QgsSettingsEntryDouble *QgsNewsFeedParser::settingsFeedLongitude = new QgsSettingsEntryDouble( u"longitude"_s, sTreeNewsFeed, 0.0, u"Feed longitude"_s );
43
44
45const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedEntryTitle = new QgsSettingsEntryString( u"title"_s, sTreeNewsFeedEntries, QString(), u"Entry title"_s );
46const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedEntryImageUrl = new QgsSettingsEntryString( u"image-url"_s, sTreeNewsFeedEntries, QString(), u"Entry image URL"_s );
47const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedEntryContent = new QgsSettingsEntryString( u"content"_s, sTreeNewsFeedEntries, QString(), u"Entry content"_s );
48const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedEntryLink = new QgsSettingsEntryString( u"link"_s, sTreeNewsFeedEntries, QString(), u"Entry link"_s );
49const QgsSettingsEntryBool *QgsNewsFeedParser::settingsFeedEntrySticky = new QgsSettingsEntryBool( u"sticky"_s, sTreeNewsFeedEntries, false );
50const QgsSettingsEntryVariant *QgsNewsFeedParser::settingsFeedEntryExpiry = new QgsSettingsEntryVariant( u"expiry"_s, sTreeNewsFeedEntries, QVariant(), u"Expiry date"_s );
51
52
53
54QgsNewsFeedParser::QgsNewsFeedParser( const QUrl &feedUrl, const QString &authcfg, QObject *parent )
55 : QObject( parent )
56 , mBaseUrl( feedUrl.toString() )
57 , mFeedUrl( feedUrl )
58 , mAuthCfg( authcfg )
59 , mFeedKey( keyForFeed( mBaseUrl ) )
60{
61 // Synchronize enabled/disabled state
62 mEnabled = !QgsSettings().value( u"%1/disabled"_s.arg( mFeedKey ), false, QgsSettings::Core ).toBool();
63
64 // first thing we do is populate with existing entries
65 readStoredEntries();
66
67 QUrlQuery query( feedUrl );
68
69 const qint64 after = settingsFeedLastFetchTime->value( mFeedKey );
70 if ( after > 0 )
71 query.addQueryItem( u"after"_s, qgsDoubleToString( after, 0 ) );
72
73 QString feedLanguage = settingsFeedLanguage->value( mFeedKey );
74 if ( feedLanguage.isEmpty() )
75 {
76 feedLanguage = QgsApplication::settingsLocaleUserLocale->valueWithDefaultOverride( u"en"_s );
77 }
78 if ( !feedLanguage.isEmpty() && feedLanguage != "C"_L1 )
79 query.addQueryItem( u"lang"_s, feedLanguage.mid( 0, 2 ) );
80
81 if ( settingsFeedLatitude->exists( mFeedKey ) && settingsFeedLongitude->exists( mFeedKey ) )
82 {
83 const double feedLat = settingsFeedLatitude->value( mFeedKey );
84 const double feedLong = settingsFeedLongitude->value( mFeedKey );
85
86 // hack to allow testing using local files
87 if ( feedUrl.isLocalFile() )
88 {
89 query.addQueryItem( u"lat"_s, QString::number( static_cast< int >( feedLat ) ) );
90 query.addQueryItem( u"lon"_s, QString::number( static_cast< int >( feedLong ) ) );
91 }
92 else
93 {
94 query.addQueryItem( u"lat"_s, qgsDoubleToString( feedLat ) );
95 query.addQueryItem( u"lon"_s, qgsDoubleToString( feedLong ) );
96 }
97 }
98
99 // bit of a hack to allow testing using local files
100 if ( feedUrl.isLocalFile() )
101 {
102 if ( !query.toString().isEmpty() )
103 mFeedUrl = QUrl( mFeedUrl.toString() + '_' + query.toString() );
104 }
105 else
106 {
107 mFeedUrl.setQuery( query ); // doesn't work for local file urls
108 }
109}
110
112{
113 if ( mEnabled == enabled )
114 {
115 return;
116 }
117
118 mEnabled = enabled;
119 emit enabledChanged();
120
121 QgsSettings().setValue( u"%1/disabled"_s.arg( mFeedKey ), !mEnabled, QgsSettings::Core );
122}
123
124QList<QgsNewsFeedParser::Entry> QgsNewsFeedParser::entries() const
125{
126 return mEntries;
127}
128
130{
131 Entry dismissed;
132 const int beforeSize = mEntries.size();
133 mEntries.erase( std::remove_if( mEntries.begin(), mEntries.end(),
134 [key, &dismissed]( const Entry & entry )
135 {
136 if ( entry.key == key )
137 {
138 dismissed = entry;
139 return true;
140 }
141 return false;
142 } ), mEntries.end() );
143 if ( beforeSize == mEntries.size() )
144 return; // didn't find matching entry
145
146 try
147 {
148 sTreeNewsFeedEntries->deleteItem( QString::number( key ), {mFeedKey} );
149 }
150 catch ( QgsSettingsException &e )
151 {
152 QgsDebugError( u"Could not dismiss news feed entry: %1"_s.arg( e.what( ) ) );
153 }
154
155 // also remove preview image, if it exists
156 if ( !dismissed.imageUrl.isEmpty() )
157 {
158 const QString previewDir = u"%1/previewImages"_s.arg( QgsApplication::qgisSettingsDirPath() );
159 const QString imagePath = u"%1/%2.png"_s.arg( previewDir ).arg( key );
160 if ( QFile::exists( imagePath ) )
161 {
162 QFile::remove( imagePath );
163 }
164 }
165
166 if ( !mBlockSignals )
167 emit entryDismissed( dismissed );
168}
169
171{
172 const QList< QgsNewsFeedParser::Entry > entries = mEntries;
173 for ( const Entry &entry : entries )
174 {
175 dismissEntry( entry.key );
176 }
177}
178
180{
181 return mAuthCfg;
182}
183
185{
186 if ( mIsFetching )
187 {
188 return;
189 }
190
191 QNetworkRequest req( mFeedUrl );
192 QgsSetRequestInitiatorClass( req, u"QgsNewsFeedParser"_s );
193
194 mFetchStartTime = QDateTime::currentDateTimeUtc().toSecsSinceEpoch();
195
196 // allow canceling the news fetching without prompts -- it's not crucial if this gets finished or not
198 task->setDescription( tr( "Fetching News Feed" ) );
199 connect( task, &QgsNetworkContentFetcherTask::fetched, this, [this, task]
200 {
201 mIsFetching = false;
202 emit isFetchingChanged();
203
204 QNetworkReply *reply = task->reply();
205 if ( !reply )
206 {
207 // canceled
208 return;
209 }
210
211 if ( reply->error() != QNetworkReply::NoError )
212 {
213 QgsMessageLog::logMessage( tr( "News feed request failed [error: %1]" ).arg( reply->errorString() ) );
214 return;
215 }
216
217 // queue up the handling
218 QMetaObject::invokeMethod( this, "onFetch", Qt::QueuedConnection, Q_ARG( QString, task->contentAsString() ) );
219 } );
220
221 mIsFetching = true;
222 emit isFetchingChanged();
223
225}
226
227void QgsNewsFeedParser::onFetch( const QString &content )
228{
229 settingsFeedLastFetchTime->setValue( mFetchStartTime, {mFeedKey} );
230
231 const QVariant json = QgsJsonUtils::parseJson( content );
232
233 const QVariantList entries = json.toList();
234 QList< QgsNewsFeedParser::Entry > fetchedEntries;
235 fetchedEntries.reserve( entries.size() );
236 for ( const QVariant &e : entries )
237 {
238 Entry incomingEntry;
239 const QVariantMap entryMap = e.toMap();
240 incomingEntry.key = entryMap.value( u"pk"_s ).toInt();
241 incomingEntry.title = entryMap.value( u"title"_s ).toString();
242 incomingEntry.imageUrl = entryMap.value( u"image"_s ).toString();
243 incomingEntry.content = entryMap.value( u"content"_s ).toString();
244 incomingEntry.link = entryMap.value( u"url"_s ).toString();
245 incomingEntry.sticky = entryMap.value( u"sticky"_s ).toBool();
246 bool hasExpiry = false;
247 const qlonglong expiry = entryMap.value( u"publish_to"_s ).toLongLong( &hasExpiry );
248 if ( hasExpiry )
249 incomingEntry.expiry.setSecsSinceEpoch( expiry );
250
251 fetchedEntries.append( incomingEntry );
252
253 // We also need to handle the case of modified/expired entries
254 const auto entryIter { std::find_if( mEntries.begin(), mEntries.end(), [incomingEntry]( const QgsNewsFeedParser::Entry & candidate )
255 {
256 return candidate.key == incomingEntry.key;
257 } )};
258 const bool entryExists { entryIter != mEntries.end() };
259
260 // case 1: existing entry is now expired, dismiss
261 if ( hasExpiry && expiry < mFetchStartTime )
262 {
263 dismissEntry( incomingEntry.key );
264 }
265 // case 2: existing entry edited
266 else if ( entryExists )
267 {
268 const bool imageNeedsUpdate = ( entryIter->imageUrl != incomingEntry.imageUrl );
269 // also remove preview image, if it exists
270 if ( imageNeedsUpdate && ! entryIter->imageUrl.isEmpty() )
271 {
272 const QString previewDir = u"%1/previewImages"_s.arg( QgsApplication::qgisSettingsDirPath() );
273 const QString imagePath = u"%1/%2.png"_s.arg( previewDir ).arg( entryIter->key );
274 if ( QFile::exists( imagePath ) )
275 {
276 QFile::remove( imagePath );
277 }
278 }
279 *entryIter = incomingEntry;
280 if ( imageNeedsUpdate && ! incomingEntry.imageUrl.isEmpty() )
281 fetchImageForEntry( incomingEntry );
282
283 sTreeNewsFeedEntries->deleteItem( QString::number( incomingEntry.key ), {mFeedKey} );
284 storeEntryInSettings( incomingEntry );
285 emit entryUpdated( incomingEntry );
286 }
287 // else: new entry, not expired
288 else if ( !hasExpiry || expiry >= mFetchStartTime )
289 {
290 if ( !incomingEntry.imageUrl.isEmpty() )
291 fetchImageForEntry( incomingEntry );
292
293 mEntries.append( incomingEntry );
294 storeEntryInSettings( incomingEntry );
295 emit entryAdded( incomingEntry );
296 }
297
298 }
299
300 emit fetched( fetchedEntries );
301}
302
303void QgsNewsFeedParser::readStoredEntries()
304{
305 QStringList existing;
306 try
307 {
308 existing = sTreeNewsFeedEntries->items( {mFeedKey} );
309 }
310 catch ( QgsSettingsException &e )
311 {
312 QgsDebugError( u"Could not read news feed entries: %1"_s.arg( e.what( ) ) );
313 }
314
315 std::sort( existing.begin(), existing.end(), []( const QString & a, const QString & b )
316 {
317 return a.toInt() < b.toInt();
318 } );
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.expiry = settingsFeedEntryExpiry->value( {mFeedKey, QString::number( key )} ).toDateTime();
345 if ( !entry.imageUrl.isEmpty() )
346 {
347 const QString previewDir = u"%1/previewImages"_s.arg( QgsApplication::qgisSettingsDirPath() );
348 const QString imagePath = u"%1/%2.png"_s.arg( previewDir ).arg( entry.key );
349 if ( QFile::exists( imagePath ) )
350 {
351 const QImage img( imagePath );
352 entry.image = QPixmap::fromImage( img );
353 }
354 else
355 {
356 fetchImageForEntry( entry );
357 }
358 }
359 return entry;
360}
361
362void QgsNewsFeedParser::storeEntryInSettings( const QgsNewsFeedParser::Entry &entry )
363{
364 settingsFeedEntryTitle->setValue( entry.title, {mFeedKey, QString::number( entry.key )} );
365 settingsFeedEntryImageUrl->setValue( entry.imageUrl, {mFeedKey, QString::number( entry.key )} );
366 settingsFeedEntryContent->setValue( entry.content, {mFeedKey, QString::number( entry.key )} );
367 settingsFeedEntryLink->setValue( entry.link.toString(), {mFeedKey, QString::number( entry.key )} );
368 settingsFeedEntrySticky->setValue( entry.sticky, {mFeedKey, QString::number( entry.key )} );
369 if ( entry.expiry.isValid() )
370 settingsFeedEntryExpiry->setValue( entry.expiry, {mFeedKey, QString::number( entry.key )} );
371}
372
373void QgsNewsFeedParser::fetchImageForEntry( const QgsNewsFeedParser::Entry &entry )
374{
375 // start fetching image
376 QgsNetworkContentFetcher *fetcher = new QgsNetworkContentFetcher();
377 connect( fetcher, &QgsNetworkContentFetcher::finished, this, [this, fetcher, entry]
378 {
379 const auto findIter = std::find_if( mEntries.begin(), mEntries.end(), [entry]( const QgsNewsFeedParser::Entry & candidate )
380 {
381 return candidate.key == entry.key;
382 } );
383 if ( findIter != mEntries.end() )
384 {
385 const int entryIndex = static_cast< int >( std::distance( mEntries.begin(), findIter ) );
386
387 QImage img = QImage::fromData( fetcher->reply()->readAll() );
388
389 QSize size = img.size();
390 bool resize = false;
391 if ( size.width() > 250 )
392 {
393 size.setHeight( static_cast< int >( size.height() * static_cast< double >( 250 ) / size.width() ) );
394 size.setWidth( 250 );
395 resize = true;
396 }
397 if ( size.height() > 177 )
398 {
399 size.setWidth( static_cast< int >( size.width() * static_cast< double >( 177 ) / size.height() ) );
400 size.setHeight( 177 );
401 resize = true;
402 }
403 if ( resize )
404 img = img.scaled( size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation );
405
406 //nicely round corners so users don't get paper cuts
407 QImage previewImage( size, QImage::Format_ARGB32 );
408 previewImage.fill( Qt::transparent );
409 QPainter previewPainter( &previewImage );
410 previewPainter.setRenderHint( QPainter::Antialiasing, true );
411 previewPainter.setRenderHint( QPainter::SmoothPixmapTransform, true );
412 previewPainter.setPen( Qt::NoPen );
413 previewPainter.setBrush( Qt::black );
414 previewPainter.drawRoundedRect( 0, 0, size.width(), size.height(), 8, 8 );
415 previewPainter.setCompositionMode( QPainter::CompositionMode_SourceIn );
416 previewPainter.drawImage( 0, 0, img );
417 previewPainter.end();
418
419 // Save image, so we don't have to fetch it next time
420 const QString previewDir = u"%1/previewImages"_s.arg( QgsApplication::qgisSettingsDirPath() );
421 QDir().mkdir( previewDir );
422 const QString imagePath = u"%1/%2.png"_s.arg( previewDir ).arg( entry.key );
423 previewImage.save( imagePath );
424
425 mEntries[ entryIndex ].image = QPixmap::fromImage( previewImage );
426 this->emit imageFetched( entry.key, mEntries[ entryIndex ].image );
427 }
428 fetcher->deleteLater();
429 } );
430 fetcher->fetchContent( entry.imageUrl, mAuthCfg );
431}
432
433QString QgsNewsFeedParser::keyForFeed( const QString &baseUrl )
434{
435 static const QRegularExpression sRegexp( u"[^a-zA-Z0-9]"_s );
436 QString res = baseUrl;
437 res = res.replace( sRegexp, QString() );
438 return res;
439}
QFlags< SettingsOption > SettingsOptions
Definition qgis.h:748
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())
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.
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
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.
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.
Stores settings for use within QGIS.
Definition qgssettings.h:68
QVariant value(const QString &key, const QVariant &defaultValue=QVariant(), Section section=NoSection) const
Returns the value for setting key.
void setValue(const QString &key, const QVariant &value, QgsSettings::Section section=QgsSettings::NoSection)
Sets the value of setting key to value.
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:6852
#define QgsDebugError(str)
Definition qgslogger.h:59
#define QgsSetRequestInitiatorClass(request, _class)