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