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