QGIS API Documentation 4.0.0-Norrköping (1ddcee3d0e4)
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
40 = new QgsSettingsEntryInteger64( u"last-fetch-time"_s, sTreeNewsFeed, 0, u"Feed last fetch time"_s, Qgis::SettingsOptions(), 0 );
41const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedLanguage = new QgsSettingsEntryString( u"lang"_s, sTreeNewsFeed, QString(), u"Feed language"_s );
42const QgsSettingsEntryDouble *QgsNewsFeedParser::settingsFeedLatitude = new QgsSettingsEntryDouble( u"latitude"_s, sTreeNewsFeed, 0.0, u"Feed latitude"_s );
43const QgsSettingsEntryDouble *QgsNewsFeedParser::settingsFeedLongitude = new QgsSettingsEntryDouble( u"longitude"_s, sTreeNewsFeed, 0.0, u"Feed longitude"_s );
44
45
46const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedEntryTitle = new QgsSettingsEntryString( u"title"_s, sTreeNewsFeedEntries, QString(), u"Entry title"_s );
47const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedEntryImageUrl = new QgsSettingsEntryString( u"image-url"_s, sTreeNewsFeedEntries, QString(), u"Entry image URL"_s );
48const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedEntryContent = new QgsSettingsEntryString( u"content"_s, sTreeNewsFeedEntries, QString(), u"Entry content"_s );
49const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedEntryLink = new QgsSettingsEntryString( u"link"_s, sTreeNewsFeedEntries, QString(), u"Entry link"_s );
50const QgsSettingsEntryBool *QgsNewsFeedParser::settingsFeedEntrySticky = new QgsSettingsEntryBool( u"sticky"_s, sTreeNewsFeedEntries, false );
51const QgsSettingsEntryVariant *QgsNewsFeedParser::settingsFeedEntryExpiry = new QgsSettingsEntryVariant( u"expiry"_s, sTreeNewsFeedEntries, QVariant(), u"Expiry date"_s );
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(
134 std::remove_if(
135 mEntries.begin(),
136 mEntries.end(),
137 [key, &dismissed]( const Entry &entry ) {
138 if ( entry.key == key )
139 {
140 dismissed = entry;
141 return true;
142 }
143 return false;
144 }
145 ),
146 mEntries.end()
147 );
148 if ( beforeSize == mEntries.size() )
149 return; // didn't find matching entry
150
151 try
152 {
153 sTreeNewsFeedEntries->deleteItem( QString::number( key ), { mFeedKey } );
154 }
155 catch ( QgsSettingsException &e )
156 {
157 QgsDebugError( u"Could not dismiss news feed entry: %1"_s.arg( e.what() ) );
158 }
159
160 // also remove preview image, if it exists
161 if ( !dismissed.imageUrl.isEmpty() )
162 {
163 const QString previewDir = u"%1/previewImages"_s.arg( QgsApplication::qgisSettingsDirPath() );
164 const QString imagePath = u"%1/%2.png"_s.arg( previewDir ).arg( key );
165 if ( QFile::exists( imagePath ) )
166 {
167 QFile::remove( imagePath );
168 }
169 }
170
171 if ( !mBlockSignals )
172 emit entryDismissed( dismissed );
173}
174
176{
177 const QList< QgsNewsFeedParser::Entry > entries = mEntries;
178 for ( const Entry &entry : entries )
179 {
180 dismissEntry( entry.key );
181 }
182}
183
185{
186 return mAuthCfg;
187}
188
190{
191 if ( mIsFetching )
192 {
193 return;
194 }
195
196 QNetworkRequest req( mFeedUrl );
197 QgsSetRequestInitiatorClass( req, u"QgsNewsFeedParser"_s );
198
199 mFetchStartTime = QDateTime::currentDateTimeUtc().toSecsSinceEpoch();
200
201 // allow canceling the news fetching without prompts -- it's not crucial if this gets finished or not
203 task->setDescription( tr( "Fetching News Feed" ) );
204 connect( task, &QgsNetworkContentFetcherTask::fetched, this, [this, task] {
205 mIsFetching = false;
206 emit isFetchingChanged();
207
208 QNetworkReply *reply = task->reply();
209 if ( !reply )
210 {
211 // canceled
212 return;
213 }
214
215 if ( reply->error() != QNetworkReply::NoError )
216 {
217 QgsMessageLog::logMessage( tr( "News feed request failed [error: %1]" ).arg( reply->errorString() ) );
218 return;
219 }
220
221 // queue up the handling
222 QMetaObject::invokeMethod( this, "onFetch", Qt::QueuedConnection, Q_ARG( QString, task->contentAsString() ) );
223 } );
224
225 mIsFetching = true;
226 emit isFetchingChanged();
227
229}
230
231void QgsNewsFeedParser::onFetch( const QString &content )
232{
233 settingsFeedLastFetchTime->setValue( mFetchStartTime, { mFeedKey } );
234
235 const QVariant json = QgsJsonUtils::parseJson( content );
236
237 const QVariantList entries = json.toList();
238 QList< QgsNewsFeedParser::Entry > fetchedEntries;
239 fetchedEntries.reserve( entries.size() );
240 for ( const QVariant &e : entries )
241 {
242 Entry incomingEntry;
243 const QVariantMap entryMap = e.toMap();
244 incomingEntry.key = entryMap.value( u"pk"_s ).toInt();
245 incomingEntry.title = entryMap.value( u"title"_s ).toString();
246 incomingEntry.imageUrl = entryMap.value( u"image"_s ).toString();
247 incomingEntry.content = entryMap.value( u"content"_s ).toString();
248 incomingEntry.link = entryMap.value( u"url"_s ).toString();
249 incomingEntry.sticky = entryMap.value( u"sticky"_s ).toBool();
250 bool hasExpiry = false;
251 const qlonglong expiry = entryMap.value( u"publish_to"_s ).toLongLong( &hasExpiry );
252 if ( hasExpiry )
253 incomingEntry.expiry.setSecsSinceEpoch( expiry );
254
255 fetchedEntries.append( incomingEntry );
256
257 // We also need to handle the case of modified/expired entries
258 const auto entryIter { std::find_if( mEntries.begin(), mEntries.end(), [incomingEntry]( const QgsNewsFeedParser::Entry &candidate ) { return candidate.key == incomingEntry.key; } ) };
259 const bool entryExists { entryIter != mEntries.end() };
260
261 // case 1: existing entry is now expired, dismiss
262 if ( hasExpiry && expiry < mFetchStartTime )
263 {
264 dismissEntry( incomingEntry.key );
265 }
266 // case 2: existing entry edited
267 else if ( entryExists )
268 {
269 const bool imageNeedsUpdate = ( entryIter->imageUrl != incomingEntry.imageUrl );
270 // also remove preview image, if it exists
271 if ( imageNeedsUpdate && !entryIter->imageUrl.isEmpty() )
272 {
273 const QString previewDir = u"%1/previewImages"_s.arg( QgsApplication::qgisSettingsDirPath() );
274 const QString imagePath = u"%1/%2.png"_s.arg( previewDir ).arg( entryIter->key );
275 if ( QFile::exists( imagePath ) )
276 {
277 QFile::remove( imagePath );
278 }
279 }
280 *entryIter = incomingEntry;
281 if ( imageNeedsUpdate && !incomingEntry.imageUrl.isEmpty() )
282 fetchImageForEntry( incomingEntry );
283
284 sTreeNewsFeedEntries->deleteItem( QString::number( incomingEntry.key ), { mFeedKey } );
285 storeEntryInSettings( incomingEntry );
286 emit entryUpdated( incomingEntry );
287 }
288 // else: new entry, not expired
289 else if ( !hasExpiry || expiry >= mFetchStartTime )
290 {
291 if ( !incomingEntry.imageUrl.isEmpty() )
292 fetchImageForEntry( incomingEntry );
293
294 mEntries.append( incomingEntry );
295 storeEntryInSettings( incomingEntry );
296 emit entryAdded( incomingEntry );
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 ) { return a.toInt() < b.toInt(); } );
316 mEntries.reserve( existing.size() );
317 for ( const QString &entry : existing )
318 {
319 const Entry e = readEntryFromSettings( entry.toInt() );
320 if ( !e.expiry.isValid() || e.expiry > QDateTime::currentDateTime() )
321 mEntries.append( e );
322 else
323 {
324 // expired entry, prune it
325 mBlockSignals = true;
326 dismissEntry( e.key );
327 mBlockSignals = false;
328 }
329 }
330}
331
332QgsNewsFeedParser::Entry QgsNewsFeedParser::readEntryFromSettings( const int key )
333{
334 Entry entry;
335 entry.key = key;
336 entry.title = settingsFeedEntryTitle->value( { mFeedKey, QString::number( key ) } );
337 entry.imageUrl = settingsFeedEntryImageUrl->value( { mFeedKey, QString::number( key ) } );
338 entry.content = settingsFeedEntryContent->value( { mFeedKey, QString::number( key ) } );
339 entry.link = settingsFeedEntryLink->value( { mFeedKey, QString::number( key ) } );
340 entry.sticky = settingsFeedEntrySticky->value( { mFeedKey, QString::number( key ) } );
341 entry.expiry = settingsFeedEntryExpiry->value( { mFeedKey, QString::number( key ) } ).toDateTime();
342 if ( !entry.imageUrl.isEmpty() )
343 {
344 const QString previewDir = u"%1/previewImages"_s.arg( QgsApplication::qgisSettingsDirPath() );
345 const QString imagePath = u"%1/%2.png"_s.arg( previewDir ).arg( entry.key );
346 if ( QFile::exists( imagePath ) )
347 {
348 const QImage img( imagePath );
349 entry.image = QPixmap::fromImage( img );
350 }
351 else
352 {
353 fetchImageForEntry( entry );
354 }
355 }
356 return entry;
357}
358
359void QgsNewsFeedParser::storeEntryInSettings( const QgsNewsFeedParser::Entry &entry )
360{
361 settingsFeedEntryTitle->setValue( entry.title, { mFeedKey, QString::number( entry.key ) } );
362 settingsFeedEntryImageUrl->setValue( entry.imageUrl, { mFeedKey, QString::number( entry.key ) } );
363 settingsFeedEntryContent->setValue( entry.content, { mFeedKey, QString::number( entry.key ) } );
364 settingsFeedEntryLink->setValue( entry.link.toString(), { mFeedKey, QString::number( entry.key ) } );
365 settingsFeedEntrySticky->setValue( entry.sticky, { mFeedKey, QString::number( entry.key ) } );
366 if ( entry.expiry.isValid() )
367 settingsFeedEntryExpiry->setValue( entry.expiry, { mFeedKey, QString::number( entry.key ) } );
368}
369
370void QgsNewsFeedParser::fetchImageForEntry( const QgsNewsFeedParser::Entry &entry )
371{
372 // start fetching image
373 QgsNetworkContentFetcher *fetcher = new QgsNetworkContentFetcher();
374 connect( fetcher, &QgsNetworkContentFetcher::finished, this, [this, fetcher, entry] {
375 const auto findIter = std::find_if( mEntries.begin(), mEntries.end(), [entry]( const QgsNewsFeedParser::Entry &candidate ) { return candidate.key == entry.key; } );
376 if ( findIter != mEntries.end() )
377 {
378 const int entryIndex = static_cast< int >( std::distance( mEntries.begin(), findIter ) );
379
380 QImage img = QImage::fromData( fetcher->reply()->readAll() );
381
382 QSize size = img.size();
383 bool resize = false;
384 if ( size.width() > 250 )
385 {
386 size.setHeight( static_cast< int >( size.height() * static_cast< double >( 250 ) / size.width() ) );
387 size.setWidth( 250 );
388 resize = true;
389 }
390 if ( size.height() > 177 )
391 {
392 size.setWidth( static_cast< int >( size.width() * static_cast< double >( 177 ) / size.height() ) );
393 size.setHeight( 177 );
394 resize = true;
395 }
396 if ( resize )
397 img = img.scaled( size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation );
398
399 //nicely round corners so users don't get paper cuts
400 QImage previewImage( size, QImage::Format_ARGB32 );
401 previewImage.fill( Qt::transparent );
402 QPainter previewPainter( &previewImage );
403 previewPainter.setRenderHint( QPainter::Antialiasing, true );
404 previewPainter.setRenderHint( QPainter::SmoothPixmapTransform, true );
405 previewPainter.setPen( Qt::NoPen );
406 previewPainter.setBrush( Qt::black );
407 previewPainter.drawRoundedRect( 0, 0, size.width(), size.height(), 8, 8 );
408 previewPainter.setCompositionMode( QPainter::CompositionMode_SourceIn );
409 previewPainter.drawImage( 0, 0, img );
410 previewPainter.end();
411
412 // Save image, so we don't have to fetch it next time
413 const QString previewDir = u"%1/previewImages"_s.arg( QgsApplication::qgisSettingsDirPath() );
414 QDir().mkdir( previewDir );
415 const QString imagePath = u"%1/%2.png"_s.arg( previewDir ).arg( entry.key );
416 previewImage.save( imagePath );
417
418 mEntries[entryIndex].image = QPixmap::fromImage( previewImage );
419 this->emit imageFetched( entry.key, mEntries[entryIndex].image );
420 }
421 fetcher->deleteLater();
422 } );
423 fetcher->fetchContent( entry.imageUrl, mAuthCfg );
424}
425
426QString QgsNewsFeedParser::keyForFeed( const QString &baseUrl )
427{
428 static const QRegularExpression sRegexp( u"[^a-zA-Z0-9]"_s );
429 QString res = baseUrl;
430 res = res.replace( sRegexp, QString() );
431 return res;
432}
QFlags< SettingsOption > SettingsOptions
Definition qgis.h:755
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.
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:6893
#define QgsDebugError(str)
Definition qgslogger.h:59
#define QgsSetRequestInitiatorClass(request, _class)