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