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