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