QGIS API Documentation 3.37.0-Master (fdefdf9c27f)
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#include "qgis.h"
21#include "qgsjsonutils.h"
22#include "qgsmessagelog.h"
23#include "qgsapplication.h"
25
26#include <QDateTime>
27#include <QUrlQuery>
28#include <QFile>
29#include <QDir>
30#include <QRegularExpression>
31
32
33const QgsSettingsEntryInteger64 *QgsNewsFeedParser::settingsFeedLastFetchTime = new QgsSettingsEntryInteger64( QStringLiteral( "last-fetch-time" ), sTreeNewsFeed, 0, QStringLiteral( "Feed last fetch time" ), Qgis::SettingsOptions(), 0 );
34const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedLanguage = new QgsSettingsEntryString( QStringLiteral( "lang" ), sTreeNewsFeed, QString(), QStringLiteral( "Feed language" ) );
35const QgsSettingsEntryDouble *QgsNewsFeedParser::settingsFeedLatitude = new QgsSettingsEntryDouble( QStringLiteral( "latitude" ), sTreeNewsFeed, 0.0, QStringLiteral( "Feed latitude" ) );
36const QgsSettingsEntryDouble *QgsNewsFeedParser::settingsFeedLongitude = new QgsSettingsEntryDouble( QStringLiteral( "longitude" ), sTreeNewsFeed, 0.0, QStringLiteral( "Feed longitude" ) );
37
38
39const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedEntryTitle = new QgsSettingsEntryString( QStringLiteral( "title" ), sTreeNewsFeedEntries, QString(), QStringLiteral( "Entry title" ) );
40const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedEntryImageUrl = new QgsSettingsEntryString( QStringLiteral( "image-url" ), sTreeNewsFeedEntries, QString(), QStringLiteral( "Entry image URL" ) );
41const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedEntryContent = new QgsSettingsEntryString( QStringLiteral( "content" ), sTreeNewsFeedEntries, QString(), QStringLiteral( "Entry content" ) );
42const QgsSettingsEntryString *QgsNewsFeedParser::settingsFeedEntryLink = new QgsSettingsEntryString( QStringLiteral( "link" ), sTreeNewsFeedEntries, QString(), QStringLiteral( "Entry link" ) );
43const QgsSettingsEntryBool *QgsNewsFeedParser::settingsFeedEntrySticky = new QgsSettingsEntryBool( QStringLiteral( "sticky" ), sTreeNewsFeedEntries, false );
44const QgsSettingsEntryVariant *QgsNewsFeedParser::settingsFeedEntryExpiry = new QgsSettingsEntryVariant( QStringLiteral( "expiry" ), sTreeNewsFeedEntries, QVariant(), QStringLiteral( "Expiry date" ) );
45
46
47
48QgsNewsFeedParser::QgsNewsFeedParser( const QUrl &feedUrl, const QString &authcfg, QObject *parent )
49 : QObject( parent )
50 , mBaseUrl( feedUrl.toString() )
51 , mFeedUrl( feedUrl )
52 , mAuthCfg( authcfg )
53 , mFeedKey( keyForFeed( mBaseUrl ) )
54{
55 // first thing we do is populate with existing entries
56 readStoredEntries();
57
58 QUrlQuery query( feedUrl );
59
60 const qint64 after = settingsFeedLastFetchTime->value( mFeedKey );
61 if ( after > 0 )
62 query.addQueryItem( QStringLiteral( "after" ), qgsDoubleToString( after, 0 ) );
63
64 QString feedLanguage = settingsFeedLanguage->value( mFeedKey );
65 if ( feedLanguage.isEmpty() )
66 {
67 feedLanguage = QgsApplication::settingsLocaleUserLocale->valueWithDefaultOverride( QStringLiteral( "en" ) );
68 }
69 if ( !feedLanguage.isEmpty() && feedLanguage != QLatin1String( "C" ) )
70 query.addQueryItem( QStringLiteral( "lang" ), feedLanguage.mid( 0, 2 ) );
71
72 if ( settingsFeedLatitude->exists( mFeedKey ) && settingsFeedLongitude->exists( mFeedKey ) )
73 {
74 const double feedLat = settingsFeedLatitude->value( mFeedKey );
75 const double feedLong = settingsFeedLongitude->value( mFeedKey );
76
77 // hack to allow testing using local files
78 if ( feedUrl.isLocalFile() )
79 {
80 query.addQueryItem( QStringLiteral( "lat" ), QString::number( static_cast< int >( feedLat ) ) );
81 query.addQueryItem( QStringLiteral( "lon" ), QString::number( static_cast< int >( feedLong ) ) );
82 }
83 else
84 {
85 query.addQueryItem( QStringLiteral( "lat" ), qgsDoubleToString( feedLat ) );
86 query.addQueryItem( QStringLiteral( "lon" ), qgsDoubleToString( feedLong ) );
87 }
88 }
89
90 // bit of a hack to allow testing using local files
91 if ( feedUrl.isLocalFile() )
92 {
93 if ( !query.toString().isEmpty() )
94 mFeedUrl = QUrl( mFeedUrl.toString() + '_' + query.toString() );
95 }
96 else
97 {
98 mFeedUrl.setQuery( query ); // doesn't work for local file urls
99 }
100}
101
102QList<QgsNewsFeedParser::Entry> QgsNewsFeedParser::entries() const
103{
104 return mEntries;
105}
106
108{
109 Entry dismissed;
110 const int beforeSize = mEntries.size();
111 mEntries.erase( std::remove_if( mEntries.begin(), mEntries.end(),
112 [key, &dismissed]( const Entry & entry )
113 {
114 if ( entry.key == key )
115 {
116 dismissed = entry;
117 return true;
118 }
119 return false;
120 } ), mEntries.end() );
121 if ( beforeSize == mEntries.size() )
122 return; // didn't find matching entry
123
124 sTreeNewsFeedEntries->deleteItem( QString::number( key ), {mFeedKey} );
125
126 // also remove preview image, if it exists
127 if ( !dismissed.imageUrl.isEmpty() )
128 {
129 const QString previewDir = QStringLiteral( "%1/previewImages" ).arg( QgsApplication::qgisSettingsDirPath() );
130 const QString imagePath = QStringLiteral( "%1/%2.png" ).arg( previewDir ).arg( key );
131 if ( QFile::exists( imagePath ) )
132 {
133 QFile::remove( imagePath );
134 }
135 }
136
137 if ( !mBlockSignals )
138 emit entryDismissed( dismissed );
139}
140
142{
143 const QList< QgsNewsFeedParser::Entry > entries = mEntries;
144 for ( const Entry &entry : entries )
145 {
146 dismissEntry( entry.key );
147 }
148}
149
151{
152 return mAuthCfg;
153}
154
156{
157 QNetworkRequest req( mFeedUrl );
158 QgsSetRequestInitiatorClass( req, QStringLiteral( "QgsNewsFeedParser" ) );
159
160 mFetchStartTime = QDateTime::currentDateTimeUtc().toSecsSinceEpoch();
161
162 // allow canceling the news fetching without prompts -- it's not crucial if this gets finished or not
164 task->setDescription( tr( "Fetching News Feed" ) );
165 connect( task, &QgsNetworkContentFetcherTask::fetched, this, [this, task]
166 {
167 QNetworkReply *reply = task->reply();
168 if ( !reply )
169 {
170 // canceled
171 return;
172 }
173
174 if ( reply->error() != QNetworkReply::NoError )
175 {
176 QgsMessageLog::logMessage( tr( "News feed request failed [error: %1]" ).arg( reply->errorString() ) );
177 return;
178 }
179
180 // queue up the handling
181 QMetaObject::invokeMethod( this, "onFetch", Qt::QueuedConnection, Q_ARG( QString, task->contentAsString() ) );
182 } );
183
185}
186
187void QgsNewsFeedParser::onFetch( const QString &content )
188{
189 settingsFeedLastFetchTime->setValue( mFetchStartTime, {mFeedKey} );
190
191 const QVariant json = QgsJsonUtils::parseJson( content );
192
193 const QVariantList entries = json.toList();
194 QList< QgsNewsFeedParser::Entry > fetchedEntries;
195 fetchedEntries.reserve( entries.size() );
196 for ( const QVariant &e : entries )
197 {
198 Entry incomingEntry;
199 const QVariantMap entryMap = e.toMap();
200 incomingEntry.key = entryMap.value( QStringLiteral( "pk" ) ).toInt();
201 incomingEntry.title = entryMap.value( QStringLiteral( "title" ) ).toString();
202 incomingEntry.imageUrl = entryMap.value( QStringLiteral( "image" ) ).toString();
203 incomingEntry.content = entryMap.value( QStringLiteral( "content" ) ).toString();
204 incomingEntry.link = entryMap.value( QStringLiteral( "url" ) ).toString();
205 incomingEntry.sticky = entryMap.value( QStringLiteral( "sticky" ) ).toBool();
206 bool hasExpiry = false;
207 const qlonglong expiry = entryMap.value( QStringLiteral( "publish_to" ) ).toLongLong( &hasExpiry );
208 if ( hasExpiry )
209 incomingEntry.expiry.setSecsSinceEpoch( expiry );
210
211 fetchedEntries.append( incomingEntry );
212
213 // We also need to handle the case of modified/expired entries
214 const auto entryIter { std::find_if( mEntries.begin(), mEntries.end(), [incomingEntry]( const QgsNewsFeedParser::Entry & candidate )
215 {
216 return candidate.key == incomingEntry.key;
217 } )};
218 const bool entryExists { entryIter != mEntries.end() };
219
220 // case 1: existing entry is now expired, dismiss
221 if ( hasExpiry && expiry < mFetchStartTime )
222 {
223 dismissEntry( incomingEntry.key );
224 }
225 // case 2: existing entry edited
226 else if ( entryExists )
227 {
228 const bool imageNeedsUpdate = ( entryIter->imageUrl != incomingEntry.imageUrl );
229 // also remove preview image, if it exists
230 if ( imageNeedsUpdate && ! entryIter->imageUrl.isEmpty() )
231 {
232 const QString previewDir = QStringLiteral( "%1/previewImages" ).arg( QgsApplication::qgisSettingsDirPath() );
233 const QString imagePath = QStringLiteral( "%1/%2.png" ).arg( previewDir ).arg( entryIter->key );
234 if ( QFile::exists( imagePath ) )
235 {
236 QFile::remove( imagePath );
237 }
238 }
239 *entryIter = incomingEntry;
240 if ( imageNeedsUpdate && ! incomingEntry.imageUrl.isEmpty() )
241 fetchImageForEntry( incomingEntry );
242
243 sTreeNewsFeedEntries->deleteItem( QString::number( incomingEntry.key ), {mFeedKey} );
244 storeEntryInSettings( incomingEntry );
245 emit entryUpdated( incomingEntry );
246 }
247 // else: new entry, not expired
248 else if ( !hasExpiry || expiry >= mFetchStartTime )
249 {
250 if ( !incomingEntry.imageUrl.isEmpty() )
251 fetchImageForEntry( incomingEntry );
252
253 mEntries.append( incomingEntry );
254 storeEntryInSettings( incomingEntry );
255 emit entryAdded( incomingEntry );
256 }
257
258 }
259
260 emit fetched( fetchedEntries );
261}
262
263void QgsNewsFeedParser::readStoredEntries()
264{
265 QStringList existing = sTreeNewsFeedEntries->items( {mFeedKey} );
266 std::sort( existing.begin(), existing.end(), []( const QString & a, const QString & b )
267 {
268 return a.toInt() < b.toInt();
269 } );
270 mEntries.reserve( existing.size() );
271 for ( const QString &entry : existing )
272 {
273 const Entry e = readEntryFromSettings( entry.toInt() );
274 if ( !e.expiry.isValid() || e.expiry > QDateTime::currentDateTime() )
275 mEntries.append( e );
276 else
277 {
278 // expired entry, prune it
279 mBlockSignals = true;
280 dismissEntry( e.key );
281 mBlockSignals = false;
282 }
283 }
284}
285
286QgsNewsFeedParser::Entry QgsNewsFeedParser::readEntryFromSettings( const int key )
287{
288 Entry entry;
289 entry.key = key;
290 entry.title = settingsFeedEntryTitle->value( {mFeedKey, QString::number( key )} );
291 entry.imageUrl = settingsFeedEntryImageUrl->value( {mFeedKey, QString::number( key )} );
292 entry.content = settingsFeedEntryContent->value( {mFeedKey, QString::number( key )} );
293 entry.link = settingsFeedEntryLink->value( {mFeedKey, QString::number( key )} );
294 entry.sticky = settingsFeedEntrySticky->value( {mFeedKey, QString::number( key )} );
295 entry.expiry = settingsFeedEntryExpiry->value( {mFeedKey, QString::number( key )} ).toDateTime();
296 if ( !entry.imageUrl.isEmpty() )
297 {
298 const QString previewDir = QStringLiteral( "%1/previewImages" ).arg( QgsApplication::qgisSettingsDirPath() );
299 const QString imagePath = QStringLiteral( "%1/%2.png" ).arg( previewDir ).arg( entry.key );
300 if ( QFile::exists( imagePath ) )
301 {
302 const QImage img( imagePath );
303 entry.image = QPixmap::fromImage( img );
304 }
305 else
306 {
307 fetchImageForEntry( entry );
308 }
309 }
310 return entry;
311}
312
313void QgsNewsFeedParser::storeEntryInSettings( const QgsNewsFeedParser::Entry &entry )
314{
315 settingsFeedEntryTitle->setValue( entry.title, {mFeedKey, QString::number( entry.key )} );
316 settingsFeedEntryImageUrl->setValue( entry.imageUrl, {mFeedKey, QString::number( entry.key )} );
317 settingsFeedEntryContent->setValue( entry.content, {mFeedKey, QString::number( entry.key )} );
318 settingsFeedEntryLink->setValue( entry.link.toString(), {mFeedKey, QString::number( entry.key )} );
319 settingsFeedEntrySticky->setValue( entry.sticky, {mFeedKey, QString::number( entry.key )} );
320 if ( entry.expiry.isValid() )
321 settingsFeedEntryExpiry->setValue( entry.expiry, {mFeedKey, QString::number( entry.key )} );
322}
323
324void QgsNewsFeedParser::fetchImageForEntry( const QgsNewsFeedParser::Entry &entry )
325{
326 // start fetching image
328 connect( fetcher, &QgsNetworkContentFetcher::finished, this, [this, fetcher, entry]
329 {
330 const auto findIter = std::find_if( mEntries.begin(), mEntries.end(), [entry]( const QgsNewsFeedParser::Entry & candidate )
331 {
332 return candidate.key == entry.key;
333 } );
334 if ( findIter != mEntries.end() )
335 {
336 const int entryIndex = static_cast< int >( std::distance( mEntries.begin(), findIter ) );
337
338 QImage img = QImage::fromData( fetcher->reply()->readAll() );
339
340 QSize size = img.size();
341 bool resize = false;
342 if ( size.width() > 250 )
343 {
344 size.setHeight( static_cast< int >( size.height() * static_cast< double >( 250 ) / size.width() ) );
345 size.setWidth( 250 );
346 resize = true;
347 }
348 if ( size.height() > 177 )
349 {
350 size.setWidth( static_cast< int >( size.width() * static_cast< double >( 177 ) / size.height() ) );
351 size.setHeight( 177 );
352 resize = true;
353 }
354 if ( resize )
355 img = img.scaled( size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation );
356
357 //nicely round corners so users don't get paper cuts
358 QImage previewImage( size, QImage::Format_ARGB32 );
359 previewImage.fill( Qt::transparent );
360 QPainter previewPainter( &previewImage );
361 previewPainter.setRenderHint( QPainter::Antialiasing, true );
362 previewPainter.setRenderHint( QPainter::SmoothPixmapTransform, true );
363 previewPainter.setPen( Qt::NoPen );
364 previewPainter.setBrush( Qt::black );
365 previewPainter.drawRoundedRect( 0, 0, size.width(), size.height(), 8, 8 );
366 previewPainter.setCompositionMode( QPainter::CompositionMode_SourceIn );
367 previewPainter.drawImage( 0, 0, img );
368 previewPainter.end();
369
370 // Save image, so we don't have to fetch it next time
371 const QString previewDir = QStringLiteral( "%1/previewImages" ).arg( QgsApplication::qgisSettingsDirPath() );
372 QDir().mkdir( previewDir );
373 const QString imagePath = QStringLiteral( "%1/%2.png" ).arg( previewDir ).arg( entry.key );
374 previewImage.save( imagePath );
375
376 mEntries[ entryIndex ].image = QPixmap::fromImage( previewImage );
377 this->emit imageFetched( entry.key, mEntries[ entryIndex ].image );
378 }
379 fetcher->deleteLater();
380 } );
381 fetcher->fetchContent( entry.imageUrl, mAuthCfg );
382}
383
384QString QgsNewsFeedParser::keyForFeed( const QString &baseUrl )
385{
386 static const QRegularExpression sRegexp( QStringLiteral( "[^a-zA-Z0-9]" ) );
387 QString res = baseUrl;
388 res = res.replace( sRegexp, QString() );
389 return res;
390}
QFlags< SettingsOption > SettingsOptions
Definition: qgis.h:520
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.
static QVariant parseJson(const std::string &jsonString)
Converts JSON jsonString to a QVariant, in case of parsing error an invalid QVariant is returned and ...
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.
HTTP network content fetcher.
void finished()
Emitted when content has loaded.
QNetworkReply * reply()
Returns a reference to the network reply.
void fetchContent(const QUrl &url, const QString &authcfg=QString())
Fetches content from a remote URL and handles redirects.
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
static const QgsSettingsEntryString * settingsFeedEntryTitle
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.
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
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.
T valueWithDefaultOverride(const T &defaultValueOverride, const QString &dynamicKeyPart=QString()) const
Returns the settings value with a defaultValueOverride and with an optional dynamicKeyPart.
T value(const QString &dynamicKeyPart=QString()) const
Returns settings value.
bool setValue(const T &value, const QString &dynamicKeyPart=QString()) const
Set settings value.
bool exists(const QString &dynamicKeyPart=QString()) const
Returns true if the settings is contained in the underlying QSettings.
A boolean settings entry.
A double settings entry.
A 64 bits integer (long long) settings entry.
A string settings entry.
A variant settings entry.
void deleteItem(const QString &item, const QStringList &parentsNamedItems=QStringList())
Deletes a named item from the named list node.
QStringList items(const QStringList &parentsNamedItems=QStringList()) const
Returns the list of items.
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:5124
#define QgsSetRequestInitiatorClass(request, _class)