QGIS API Documentation  3.8.0-Zanzibar (11aff65)
qgsgeonoderequest.cpp
Go to the documentation of this file.
1 /***************************************************************************
2  qgsgeonoderequest.h
3  ---------------------
4  begin : Jul 2017
5  copyright : (C) 2017 by Muhammad Yarjuna Rohmat, Ismail Sunni
6  email : rohmat at kartoza dot com, ismail at kartoza 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 
17 #include "qgssettings.h"
18 #include "qgsmessagelog.h"
19 #include "qgslogger.h"
20 #include "qgsgeonoderequest.h"
21 
22 #include <QEventLoop>
23 #include <QNetworkCacheMetaData>
24 #include <QByteArray>
25 #include <QJsonDocument>
26 #include <QJsonObject>
27 #include <QUrl>
28 #include <QDomDocument>
29 #include <QRegularExpression>
30 
31 QgsGeoNodeRequest::QgsGeoNodeRequest( const QString &baseUrl, bool forceRefresh, QObject *parent )
32  : QObject( parent )
33  , mBaseUrl( baseUrl )
34  , mForceRefresh( forceRefresh )
35 {
36 
37 }
38 
40 {
41  abort();
42 }
43 
45 {
46  mIsAborted = true;
47  if ( mGeoNodeReply )
48  {
49  mGeoNodeReply->deleteLater();
50  mGeoNodeReply = nullptr;
51  }
52 }
53 
55 {
56  request( QStringLiteral( "/api/layers/" ) );
57  QObject *obj = new QObject( this );
58 
59  connect( this, &QgsGeoNodeRequest::requestFinished, obj, [obj, this ]
60  {
61  QList<QgsGeoNodeRequest::ServiceLayerDetail> layers;
62  if ( mError.isEmpty() )
63  {
64  layers = parseLayers( this->lastResponse() );
65  }
66  emit layersFetched( layers );
67 
68  obj->deleteLater();
69  } );
70 }
71 
72 QList<QgsGeoNodeRequest::ServiceLayerDetail> QgsGeoNodeRequest::fetchLayersBlocking()
73 {
74  QList<QgsGeoNodeRequest::ServiceLayerDetail> layers;
75 
76  QEventLoop loop;
77  connect( this, &QgsGeoNodeRequest::requestFinished, &loop, &QEventLoop::quit );
78  QObject *obj = new QObject( this );
79  connect( this, &QgsGeoNodeRequest::layersFetched, obj, [&]( const QList<QgsGeoNodeRequest::ServiceLayerDetail> &fetched )
80  {
81  layers = fetched;
82  } );
83  fetchLayers();
84  loop.exec( QEventLoop::ExcludeUserInputEvents );
85  delete obj;
86  return layers;
87 }
88 
90 {
91  QgsGeoNodeStyle defaultStyle;
92  bool success = requestBlocking( QStringLiteral( "/api/layers?name=" ) + layerName );
93  if ( !success )
94  {
95  return defaultStyle;
96  }
97 
98  const QJsonDocument jsonDocument = QJsonDocument::fromJson( this->lastResponse() );
99  const QJsonObject jsonObject = jsonDocument.object();
100  const QList<QVariant> layers = jsonObject.toVariantMap().value( QStringLiteral( "objects" ) ).toList();
101  if ( layers.count() < 1 )
102  {
103  return defaultStyle;
104  }
105  QString defaultStyleUrl = layers.at( 0 ).toMap().value( QStringLiteral( "default_style" ) ).toString();
106 
107  defaultStyle = retrieveStyle( defaultStyleUrl );
108 
109  return defaultStyle;
110 
111 }
112 
113 QList<QgsGeoNodeStyle> QgsGeoNodeRequest::fetchStylesBlocking( const QString &layerName )
114 {
115  QList<QgsGeoNodeStyle> geoNodeStyles;
116  bool success = requestBlocking( QStringLiteral( "/api/styles?layer__name=" ) + layerName );
117  if ( !success )
118  {
119  return geoNodeStyles;
120  }
121 
122  const QJsonDocument jsonDocument = QJsonDocument::fromJson( this->lastResponse() );
123  const QJsonObject jsobObject = jsonDocument.object();
124  const QList<QVariant> styles = jsobObject.toVariantMap().value( QStringLiteral( "objects" ) ).toList();
125 
126  for ( const QVariant &style : styles )
127  {
128  const QVariantMap styleMap = style.toMap();
129  QString styleUrl = styleMap.value( QStringLiteral( "resource_uri" ) ).toString();
130  QgsGeoNodeStyle geoNodeStyle = retrieveStyle( styleUrl );
131  if ( !geoNodeStyle.name.isEmpty() )
132  {
133  geoNodeStyles.append( geoNodeStyle );
134  }
135  }
136 
137  return geoNodeStyles;
138 
139 }
140 
142 {
143  QString endPoint = QStringLiteral( "/api/styles/" ) + styleId;
144 
145  return retrieveStyle( endPoint );
146 }
147 
148 void QgsGeoNodeRequest::replyProgress( qint64 bytesReceived, qint64 bytesTotal )
149 {
150  QString msg = tr( "%1 of %2 bytes of request downloaded." ).arg( bytesReceived ).arg( bytesTotal < 0 ? QStringLiteral( "unknown number of" ) : QString::number( bytesTotal ) );
151  QgsDebugMsgLevel( msg, 3 );
152  emit statusChanged( msg );
153 }
154 
156 {
157  return mProtocol;
158 }
159 
161 {
162  mProtocol = protocol;
163 }
164 
165 void QgsGeoNodeRequest::replyFinished()
166 {
167  QgsDebugMsg( QStringLiteral( "Reply finished" ) );
168  if ( !mIsAborted && mGeoNodeReply )
169  {
170  if ( mGeoNodeReply->error() == QNetworkReply::NoError )
171  {
172  QgsDebugMsg( QStringLiteral( "reply OK" ) );
173  QVariant redirect = mGeoNodeReply->attribute( QNetworkRequest::RedirectionTargetAttribute );
174  if ( !redirect.isNull() )
175  {
176 
177  emit statusChanged( QStringLiteral( "GeoNode request redirected." ) );
178 
179  const QUrl &toUrl = redirect.toUrl();
180  if ( toUrl == mGeoNodeReply->url() )
181  {
182  mError = tr( "Redirect loop detected: %1" ).arg( toUrl.toString() );
183  QgsMessageLog::logMessage( mError, tr( "GeoNode" ) );
184  mHttpGeoNodeResponse.clear();
185  }
186  else
187  {
188  QNetworkRequest request( toUrl );
189  QgsSetRequestInitiatorClass( request, QStringLiteral( "QgsGeoNodeRequest" ) );
190  request.setAttribute( QNetworkRequest::CacheLoadControlAttribute, mForceRefresh ? QNetworkRequest::AlwaysNetwork : QNetworkRequest::PreferCache );
191  request.setAttribute( QNetworkRequest::CacheSaveControlAttribute, true );
192 
193  mGeoNodeReply->deleteLater();
194  mGeoNodeReply = nullptr;
195 
196  QgsDebugMsgLevel( QStringLiteral( "redirected getcapabilities: %1 forceRefresh=%2" ).arg( redirect.toString() ).arg( mForceRefresh ), 3 );
197  mGeoNodeReply = QgsNetworkAccessManager::instance()->get( request );
198 
199  connect( mGeoNodeReply, &QNetworkReply::finished, this, &QgsGeoNodeRequest::replyFinished, Qt::DirectConnection );
200  connect( mGeoNodeReply, &QNetworkReply::downloadProgress, this, &QgsGeoNodeRequest::replyProgress, Qt::DirectConnection );
201  return;
202  }
203  }
204  else
205  {
207 
208  if ( nam->cache() )
209  {
210  QNetworkCacheMetaData cmd = nam->cache()->metaData( mGeoNodeReply->request().url() );
211 
212  QNetworkCacheMetaData::RawHeaderList hl;
213  const QNetworkCacheMetaData::RawHeaderList cmdHeaders = cmd.rawHeaders();
214  for ( const QNetworkCacheMetaData::RawHeader &h : cmdHeaders )
215  {
216  if ( h.first != QStringLiteral( "Cache-Control" ) )
217  hl.append( h );
218  }
219  cmd.setRawHeaders( hl );
220 
221  QgsDebugMsg( QStringLiteral( "expirationDate:%1" ).arg( cmd.expirationDate().toString() ) );
222  if ( cmd.expirationDate().isNull() )
223  {
224  QgsSettings settings;
225  cmd.setExpirationDate( QDateTime::currentDateTime().addSecs( settings.value( QStringLiteral( "qgis/defaultCapabilitiesExpiry" ), "24", QgsSettings::Providers ).toInt() * 60 * 60 ) );
226  }
227 
228  nam->cache()->updateMetaData( cmd );
229  }
230  else
231  {
232  QgsDebugMsg( QStringLiteral( "No cache for capabilities!" ) );
233  }
234 
235  mHttpGeoNodeResponse = mGeoNodeReply->readAll();
236 
237  if ( mHttpGeoNodeResponse.isEmpty() )
238  {
239  mError = tr( "Empty capabilities: %1" ).arg( mGeoNodeReply->errorString() );
240  }
241  }
242  }
243  else
244  {
245  mError = tr( "Request failed: %1" ).arg( mGeoNodeReply->errorString() );
246  QgsMessageLog::logMessage( mError, tr( "GeoNode" ) );
247  mHttpGeoNodeResponse.clear();
248  }
249  }
250 
251  if ( mGeoNodeReply )
252  {
253  mGeoNodeReply->deleteLater();
254  mGeoNodeReply = nullptr;
255  }
256 
257  emit requestFinished();
258 }
259 
260 QList<QgsGeoNodeRequest::ServiceLayerDetail> QgsGeoNodeRequest::parseLayers( const QByteArray &layerResponse )
261 {
262  QList<QgsGeoNodeRequest::ServiceLayerDetail> layers;
263  if ( layerResponse.isEmpty() )
264  {
265  return layers;
266  }
267 
268  const QJsonDocument jsonDocument = QJsonDocument::fromJson( layerResponse );
269  const QJsonObject jsonObject = jsonDocument.object();
270  const QVariantMap jsonVariantMap = jsonObject.toVariantMap();
271  const QVariantList layerList = jsonVariantMap.value( QStringLiteral( "objects" ) ).toList();
272  qint16 majorVersion;
273  qint16 minorVersion;
274  if ( jsonVariantMap.contains( QStringLiteral( "geonode_version" ) ) )
275  {
276  QRegularExpression re( "((\\d+)(\\.\\d+))" );
277  QRegularExpressionMatch match = re.match( jsonVariantMap.value( QStringLiteral( "geonode_version" ) ).toString() );
278  if ( match.hasMatch() )
279  {
280  const QStringList geonodeVersionSplit = match.captured( 0 ).split( '.' );
281  majorVersion = geonodeVersionSplit.at( 0 ).toInt();
282  minorVersion = geonodeVersionSplit.at( 1 ).toInt();
283  }
284  else
285  {
286  return layers;
287  }
288  }
289  else
290  {
291  majorVersion = 2;
292  minorVersion = 6;
293  }
294 
295  if ( majorVersion == 2 && minorVersion == 6 )
296  {
297  for ( const QVariant &layer : qgis::as_const( layerList ) )
298  {
300  const QVariantMap layerMap = layer.toMap();
301  // Find WMS and WFS. XYZ is not available
302  // Trick to get layer's typename from distribution_url or detail_url
303  QString layerTypeName = layerMap.value( QStringLiteral( "detail_url" ) ).toString().split( '/' ).last();
304  if ( layerTypeName.isEmpty() )
305  {
306  layerTypeName = layerMap.value( QStringLiteral( "distribution_url" ) ).toString().split( '/' ).last();
307  }
308  // On this step, layerTypeName is in WORKSPACE%3ALAYERNAME or WORKSPACE:LAYERNAME format
309  if ( layerTypeName.contains( QStringLiteral( "%3A" ) ) )
310  {
311  layerTypeName.replace( QStringLiteral( "%3A" ), QStringLiteral( ":" ) );
312  }
313  // On this step, layerTypeName is in WORKSPACE:LAYERNAME format
314  const QStringList splitURL = layerTypeName.split( ':' );
315  QString layerWorkspace = splitURL.at( 0 );
316  QString layerName = splitURL.at( 1 );
317 
318  layerStruct.name = layerName;
319  layerStruct.typeName = layerTypeName;
320  layerStruct.uuid = layerMap.value( QStringLiteral( "uuid" ) ).toString();
321  layerStruct.title = layerMap.value( QStringLiteral( "title" ) ).toString();
322 
323  // WMS url : BASE_URI/geoserver/WORKSPACE/wms
324  layerStruct.wmsURL = mBaseUrl + QStringLiteral( "/geoserver/" ) + layerWorkspace + QStringLiteral( "/wms" );
325  // WFS url : BASE_URI/geoserver/WORKSPACE/wfs
326  layerStruct.wfsURL = mBaseUrl + QStringLiteral( "/geoserver/" ) + layerWorkspace + QStringLiteral( "/wfs" );
327  // XYZ url : set to empty string
328  layerStruct.xyzURL.clear();
329 
330  layers.append( layerStruct );
331  }
332  }
333  // Geonode version 2.7 or newer
334  else if ( ( majorVersion == 2 && minorVersion >= 7 ) || ( majorVersion >= 3 ) )
335  {
336  for ( const QVariant &layer : qgis::as_const( layerList ) )
337  {
339  const QVariantMap layerMap = layer.toMap();
340  // Find WMS, WFS, and XYZ link
341  const QVariantList layerLinks = layerMap.value( QStringLiteral( "links" ) ).toList();
342  for ( const QVariant &link : layerLinks )
343  {
344  const QVariantMap linkMap = link.toMap();
345  if ( linkMap.contains( QStringLiteral( "link_type" ) ) )
346  {
347  if ( linkMap.value( QStringLiteral( "link_type" ) ) == QStringLiteral( "OGC:WMS" ) )
348  {
349  layerStruct.wmsURL = linkMap.value( QStringLiteral( "url" ) ).toString();
350  }
351  else if ( linkMap.value( QStringLiteral( "link_type" ) ) == QStringLiteral( "OGC:WFS" ) )
352  {
353  layerStruct.wfsURL = linkMap.value( QStringLiteral( "url" ) ).toString();
354  }
355  else if ( linkMap.value( QStringLiteral( "link_type" ) ) == QStringLiteral( "image" ) )
356  {
357  if ( linkMap.contains( QStringLiteral( "name" ) ) && linkMap.value( QStringLiteral( "name" ) ) == QStringLiteral( "Tiles" ) )
358  {
359  layerStruct.xyzURL = linkMap.value( QStringLiteral( "url" ) ).toString();
360  }
361  }
362  }
363  }
364  if ( layerMap.value( QStringLiteral( "typename" ) ).toString().isEmpty() )
365  {
366  const QStringList splitURL = layerMap.value( QStringLiteral( "detail_url" ) ).toString().split( '/' );
367  layerStruct.typeName = splitURL.at( splitURL.length() - 1 );
368  }
369  layerStruct.uuid = layerMap.value( QStringLiteral( "uuid" ) ).toString();
370  layerStruct.name = layerMap.value( QStringLiteral( "name" ) ).toString();
371  layerStruct.typeName = layerMap.value( QStringLiteral( "typename" ) ).toString();
372  layerStruct.title = layerMap.value( QStringLiteral( "title" ) ).toString();
373  layers.append( layerStruct );
374  }
375  }
376  return layers;
377 }
378 
379 QgsGeoNodeStyle QgsGeoNodeRequest::retrieveStyle( const QString &styleUrl )
380 {
381  QgsGeoNodeStyle geoNodeStyle;
382 
383  bool success = requestBlocking( styleUrl );
384  if ( !success )
385  {
386  return geoNodeStyle;
387  }
388  const QJsonDocument jsonDocument = QJsonDocument::fromJson( this->lastResponse() );
389  const QJsonObject jsonObject = jsonDocument.object();
390 
391  const QVariantMap jsonMap = jsonObject.toVariantMap();
392  geoNodeStyle.id = jsonMap.value( QStringLiteral( "id" ) ).toString();
393  geoNodeStyle.name = jsonMap.value( QStringLiteral( "name" ) ).toString();
394  geoNodeStyle.title = jsonMap.value( QStringLiteral( "title" ) ).toString();
395  geoNodeStyle.styleUrl = jsonMap.value( QStringLiteral( "style_url" ) ).toString();
396 
397  success = requestBlocking( geoNodeStyle.styleUrl );
398  if ( !success )
399  {
400  return geoNodeStyle;
401  }
402 
403  success = geoNodeStyle.body.setContent( this->lastResponse() );
404  if ( !success )
405  {
406  return geoNodeStyle;
407  }
408 
409  return geoNodeStyle;
410 }
411 
412 QStringList QgsGeoNodeRequest::fetchServiceUrlsBlocking( const QString &serviceType )
413 {
414  QStringList urls;
415 
416  const QList<QgsGeoNodeRequest::ServiceLayerDetail> layers = fetchLayersBlocking();
417 
418  if ( layers.empty() )
419  {
420  return urls;
421  }
422 
423  for ( const QgsGeoNodeRequest::ServiceLayerDetail &layer : layers )
424  {
425  QString url;
426  if ( QString::compare( serviceType, QStringLiteral( "wms" ), Qt::CaseInsensitive ) == 0 )
427  {
428  url = layer.wmsURL;
429  }
430  else if ( QString::compare( serviceType, QStringLiteral( "wfs" ), Qt::CaseInsensitive ) == 0 )
431  {
432  url = layer.wfsURL;
433  }
434  else if ( QString::compare( serviceType, QStringLiteral( "xyz" ), Qt::CaseInsensitive ) == 0 )
435  {
436  url = layer.xyzURL;
437  }
438 
439  if ( url.isEmpty() )
440  continue;
441 
442  if ( !url.contains( QLatin1String( "://" ) ) )
443  {
444  url.prepend( protocol() );
445  }
446  if ( !urls.contains( url ) )
447  {
448  urls.append( url );
449  }
450  }
451 
452  return urls;
453 }
454 
456 {
457  QgsStringMap urls;
458 
459  const QList<QgsGeoNodeRequest::ServiceLayerDetail> layers = fetchLayersBlocking();
460 
461  if ( layers.empty() )
462  {
463  return urls;
464  }
465 
466  for ( const QgsGeoNodeRequest::ServiceLayerDetail &layer : layers )
467  {
468  QString url;
469 
470  if ( QString::compare( serviceType, QStringLiteral( "wms" ), Qt::CaseInsensitive ) == 0 )
471  {
472  url = layer.wmsURL;
473  }
474  else if ( QString::compare( serviceType, QStringLiteral( "wfs" ), Qt::CaseInsensitive ) == 0 )
475  {
476  url = layer.wfsURL;
477  }
478  else if ( QString::compare( serviceType, QStringLiteral( "xyz" ), Qt::CaseInsensitive ) == 0 )
479  {
480  url = layer.xyzURL;
481  }
482 
483  if ( url.isEmpty() )
484  continue;
485 
486  QString layerName = layer.name;
487  if ( !url.contains( QLatin1String( "://" ) ) )
488  {
489  url.prepend( protocol() );
490  }
491  if ( !urls.contains( url ) )
492  {
493  urls.insert( layerName, url );
494  }
495  }
496 
497  return urls;
498 }
499 
500 void QgsGeoNodeRequest::request( const QString &endPoint )
501 {
502  abort();
503  mIsAborted = false;
504  // Handle case where the endpoint is full url
505  QString url = endPoint.startsWith( mBaseUrl ) ? endPoint : mBaseUrl + endPoint;
506  QgsDebugMsg( "Requesting to " + url );
507  setProtocol( url.split( QStringLiteral( "://" ) ).at( 0 ) );
508  QUrl layerUrl( url );
509  layerUrl.setScheme( protocol() );
510 
511  mError.clear();
512 
513  mGeoNodeReply = requestUrl( url );
514  connect( mGeoNodeReply, &QNetworkReply::finished, this, &QgsGeoNodeRequest::replyFinished, Qt::DirectConnection );
515  connect( mGeoNodeReply, &QNetworkReply::downloadProgress, this, &QgsGeoNodeRequest::replyProgress, Qt::DirectConnection );
516 }
517 
518 bool QgsGeoNodeRequest::requestBlocking( const QString &endPoint )
519 {
520  request( endPoint );
521 
522  QEventLoop loop;
523  connect( this, &QgsGeoNodeRequest::requestFinished, &loop, &QEventLoop::quit );
524  loop.exec( QEventLoop::ExcludeUserInputEvents );
525 
526  return mError.isEmpty();
527 }
528 
529 QNetworkReply *QgsGeoNodeRequest::requestUrl( const QString &url )
530 {
531  QNetworkRequest request( url );
532  QgsSetRequestInitiatorClass( request, QStringLiteral( "QgsGeoNodeRequest" ) );
533  // Add authentication check here
534 
535  request.setAttribute( QNetworkRequest::CacheLoadControlAttribute, mForceRefresh ? QNetworkRequest::AlwaysNetwork : QNetworkRequest::PreferCache );
536  request.setAttribute( QNetworkRequest::CacheSaveControlAttribute, true );
537 
538  return QgsNetworkAccessManager::instance()->get( request );
539 }
540 
541 
void fetchLayers()
Triggers a new request to fetch the list of available layers from the server.
#define QgsSetRequestInitiatorClass(request, _class)
void layersFetched(const QList< QgsGeoNodeRequest::ServiceLayerDetail > &layers)
Emitted when the result of a fetchLayers call has been received and processed.
Encapsulates information about a GeoNode layer style.
void setProtocol(const QString &protocol)
Sets the network protocol (e.g.
This class is a composition of two QSettings instances:
Definition: qgssettings.h:58
QVariant value(const QString &key, const QVariant &defaultValue=QVariant(), Section section=NoSection) const
Returns the value for setting key.
#define QgsDebugMsg(str)
Definition: qgslogger.h:38
~QgsGeoNodeRequest() override
QString styleUrl
Associated URL.
Service layer details for an individual layer from a GeoNode connection.
QByteArray lastResponse() const
Returns the most recent response obtained from the server.
void request(const QString &endPoint)
Triggers a new request to the GeoNode server, with the requested endPoint.
QString wmsURL
WMS URL for layer.
QgsStringMap fetchServiceUrlDataBlocking(const QString &serviceType)
Obtains a map of layer name to URL for available services with matching serviceType from the server...
QList< QgsGeoNodeStyle > fetchStylesBlocking(const QString &layerName)
Requests the list of available styles for the layer with matching layerName from the server...
QMap< QString, QString > QgsStringMap
Definition: qgis.h:587
QString layerTypeName(const QgsMapLayer *layer)
Returns typename from vector layer.
Definition: qgswfsutils.cpp:71
QDomDocument body
DOM documenting containing style.
void statusChanged(const QString &statusQString)
Emitted when the status of an ongoing request is changed.
QgsGeoNodeStyle fetchDefaultStyleBlocking(const QString &layerName)
Requests the default style for the layer with matching layerName from the server. ...
#define QgsDebugMsgLevel(str, level)
Definition: qgslogger.h:39
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).
QString xyzURL
XYZ tileserver URL for layer.
static QgsNetworkAccessManager * instance(Qt::ConnectionType connectionType=Qt::BlockingQueuedConnection)
Returns a pointer to the active QgsNetworkAccessManager for the current thread.
QList< QgsGeoNodeRequest::ServiceLayerDetail > fetchLayersBlocking()
Requests the list of available layers from the server.
QString wfsURL
WFS URL for layer.
QgsGeoNodeRequest(const QString &baseUrl, bool forceRefresh, QObject *parent=nullptr)
Constructor for QgsGeoNodeRequest.
void requestFinished()
Emitted when the existing request has been completed.
QString id
Unique style ID.
QString protocol() const
Returns the network protocol (e.g.
QString name
Style name.
bool requestBlocking(const QString &endPoint)
Triggers a new request to the GeoNode server, with the requested endPoint.
QUuid uuid
Unique identifier (generate on the client side, not at the GeoNode server)
void abort()
Aborts any active network request immediately.
network access manager for QGISThis class implements the QGIS network access manager.
QgsGeoNodeStyle fetchStyleBlocking(const QString &styleId)
Requests the details for the style with matching styleId from the server.
QStringList fetchServiceUrlsBlocking(const QString &serviceType)
Requests the list of unique URLs for available services with matching serviceType from the server...
QString title
Style title.