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