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