diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 89c843e16e..5a596428e8 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -119,7 +119,8 @@ set(QFIELD_CORE_SRCS valuemapmodel.cpp valuemapmodelbase.cpp vertexmodel.cpp - viewstatus.cpp) + viewstatus.cpp + webdavconnection.cpp) set(QFIELD_CORE_HDRS platforms/platformutilities.h @@ -244,6 +245,7 @@ set(QFIELD_CORE_HDRS valuemapmodelbase.h vertexmodel.h viewstatus.h + webdavconnection.h ${CMAKE_CURRENT_BINARY_DIR}/qfield.h) list(APPEND QFIELD_CORE_SRCS permissions.cpp) @@ -288,6 +290,7 @@ endif() find_package(SQLite3 REQUIRED) find_package(ZXing REQUIRED) +find_package(QtWebDAV REQUIRED) add_library(qfield_core STATIC ${QFIELD_CORE_SRCS} ${QFIELD_CORE_HDRS}) @@ -367,7 +370,8 @@ target_link_libraries( GDAL::GDAL SQLite::SQLite3 Qca::qca - libzip::zip) + libzip::zip + QtWebDAV::QtWebDAV) if(WITH_BLUETOOTH) find_package( diff --git a/src/core/focusstack.cpp b/src/core/focusstack.cpp index a0ab75ae30..e4f2f4cd2c 100644 --- a/src/core/focusstack.cpp +++ b/src/core/focusstack.cpp @@ -61,6 +61,7 @@ void FocusStack::setFocused( QObject *object ) { mStackList.removeAll( object ); mStackList.append( object ); + QMetaObject::invokeMethod( object, "forceActiveFocus", Qt::DirectConnection ); } void FocusStack::setUnfocused( QObject *object ) diff --git a/src/core/localfilesmodel.cpp b/src/core/localfilesmodel.cpp index cd7f57ccc4..d3632f2d48 100644 --- a/src/core/localfilesmodel.cpp +++ b/src/core/localfilesmodel.cpp @@ -18,6 +18,7 @@ #include "platformutilities.h" #include "qfieldcloudutils.h" #include "qgismobileapp.h" +#include "webdavconnection.h" #include #include @@ -59,6 +60,7 @@ QHash LocalFilesModel::roleNames() const roles[ItemSizeRole] = "ItemSize"; roles[ItemHasThumbnailRole] = "ItemHasThumbnail"; roles[ItemIsFavoriteRole] = "ItemIsFavorite"; + roles[ItemHasWebdavConfigurationRole] = "ItemHasWebdavConfiguration"; return roles; } @@ -177,14 +179,14 @@ bool LocalFilesModel::isDeletedAllowedInCurrentPath() const { const QString path = currentPath(); const QString applicationDirectory = PlatformUtilities::instance()->applicationDirectory(); - if ( !applicationDirectory.isEmpty() && path.startsWith( applicationDirectory + QDir::separator() ) ) + if ( !applicationDirectory.isEmpty() && path.startsWith( applicationDirectory ) ) { return true; } else { const QStringList additionalApplicationDirectories = PlatformUtilities::instance()->additionalApplicationDirectories(); - if ( std::any_of( additionalApplicationDirectories.begin(), additionalApplicationDirectories.end(), [&path]( const QString &directory ) { return ( !directory.isEmpty() && path.startsWith( directory + QDir::separator() ) ); } ) ) + if ( std::any_of( additionalApplicationDirectories.begin(), additionalApplicationDirectories.end(), [&path]( const QString &directory ) { return ( !directory.isEmpty() && path.startsWith( directory ) ); } ) ) { return true; } @@ -237,10 +239,17 @@ void LocalFilesModel::reloadModel() } const QStringList favorites = QSettings().value( QStringLiteral( "qfieldFavorites" ), QStringList() ).toStringList(); + QList favoriteItems; for ( const QString &item : favorites ) { - mItems << Item( ItemMetaType::Favorite, ItemType::SimpleFolder, getCurrentTitleFromPath( item ), QString(), item ); + if ( QFileInfo::exists( item ) ) + { + favoriteItems << Item( ItemMetaType::Favorite, ItemType::SimpleFolder, getCurrentTitleFromPath( item ), QString(), item ); + } } + + std::sort( favoriteItems.begin(), favoriteItems.end(), []( const Item &a, const Item &b ) { return a.title < b.title; } ); + mItems.append( favoriteItems ); } else { @@ -332,6 +341,11 @@ QVariant LocalFilesModel::data( const QModelIndex &index, int role ) const case ItemIsFavoriteRole: return mFavorites.contains( mItems[index.row()].path ); + + case ItemHasWebdavConfigurationRole: + { + return WebdavConnection::hasWebdavConfiguration( mItems[index.row()].path ); + } } return QVariant(); diff --git a/src/core/localfilesmodel.h b/src/core/localfilesmodel.h index d44fe64a32..7aab08821f 100644 --- a/src/core/localfilesmodel.h +++ b/src/core/localfilesmodel.h @@ -86,6 +86,7 @@ class LocalFilesModel : public QAbstractListModel ItemSizeRole, ItemHasThumbnailRole, ItemIsFavoriteRole, + ItemHasWebdavConfigurationRole, }; Q_ENUM( Role ) diff --git a/src/core/platforms/platformutilities.cpp b/src/core/platforms/platformutilities.cpp index 800f4b0066..5d172fe706 100644 --- a/src/core/platforms/platformutilities.cpp +++ b/src/core/platforms/platformutilities.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -97,14 +98,18 @@ void PlatformUtilities::afterUpdate() const QStringList dirs = appDataDirs(); for ( const QString &dir : dirs ) { - QDir appDir( dir ); - appDir.mkpath( QStringLiteral( "proj" ) ); - appDir.mkpath( QStringLiteral( "auth" ) ); - appDir.mkpath( QStringLiteral( "fonts" ) ); - appDir.mkpath( QStringLiteral( "basemaps" ) ); - appDir.mkpath( QStringLiteral( "logs" ) ); - appDir.mkpath( QStringLiteral( "plugins" ) ); + QDir appDataDir( dir ); + appDataDir.mkpath( QStringLiteral( "proj" ) ); + appDataDir.mkpath( QStringLiteral( "auth" ) ); + appDataDir.mkpath( QStringLiteral( "fonts" ) ); + appDataDir.mkpath( QStringLiteral( "basemaps" ) ); + appDataDir.mkpath( QStringLiteral( "logs" ) ); + appDataDir.mkpath( QStringLiteral( "plugins" ) ); } + + QDir applicationDir( applicationDirectory() ); + applicationDir.mkpath( QStringLiteral( "Imported Projects" ) ); + applicationDir.mkpath( QStringLiteral( "Imported Datasets" ) ); } QString PlatformUtilities::systemSharedDataLocation() const @@ -153,7 +158,7 @@ void PlatformUtilities::loadQgsProject() const QStringList PlatformUtilities::appDataDirs() const { - return QStringList() << QStandardPaths::standardLocations( QStandardPaths::DocumentsLocation ).first() + QStringLiteral( "/QField/" ); + return QStringList() << QStandardPaths::standardLocations( QStandardPaths::DocumentsLocation ).first() + QStringLiteral( "/QField Documents/QField/" ); } QStringList PlatformUtilities::availableGrids() const @@ -208,7 +213,7 @@ bool PlatformUtilities::renameFile( const QString &oldFilePath, const QString &n QString PlatformUtilities::applicationDirectory() const { - return QStandardPaths::standardLocations( QStandardPaths::DocumentsLocation ).first() + QStringLiteral( "/QField/" ); + return QStandardPaths::standardLocations( QStandardPaths::DocumentsLocation ).first() + QStringLiteral( "/QField Documents/" ); } QStringList PlatformUtilities::additionalApplicationDirectories() const @@ -218,7 +223,19 @@ QStringList PlatformUtilities::additionalApplicationDirectories() const QStringList PlatformUtilities::rootDirectories() const { - return QStringList() << QString(); + QStringList rootDirectories; + rootDirectories << QDir::homePath(); + for ( const QStorageInfo &volume : QStorageInfo::mountedVolumes() ) + { + if ( volume.isReady() && !volume.isReadOnly() ) + { + if ( volume.fileSystemType() != QLatin1String( "tmpfs" ) && !volume.rootPath().startsWith( QLatin1String( "/boot" ) ) ) + { + rootDirectories << volume.rootPath(); + } + } + } + return rootDirectories; } void PlatformUtilities::importProjectFolder() const diff --git a/src/core/platforms/platformutilities.h b/src/core/platforms/platformutilities.h index c14cff2a83..f52b65fe91 100644 --- a/src/core/platforms/platformutilities.h +++ b/src/core/platforms/platformutilities.h @@ -122,7 +122,7 @@ class QFIELD_CORE_EXPORT PlatformUtilities : public QObject /** * The main application directory within which projects and datasets can be imported. */ - virtual QString applicationDirectory() const; + Q_INVOKABLE virtual QString applicationDirectory() const; /** * Secondary application directories which can be used by individual platforms. diff --git a/src/core/qgismobileapp.cpp b/src/core/qgismobileapp.cpp index 70ca03bb77..cb277f427b 100644 --- a/src/core/qgismobileapp.cpp +++ b/src/core/qgismobileapp.cpp @@ -130,6 +130,7 @@ #include "urlutils.h" #include "valuemapmodel.h" #include "vertexmodel.h" +#include "webdavconnection.h" #include #include @@ -511,6 +512,7 @@ void QgisMobileapp::initDeclarative( QQmlEngine *engine ) qmlRegisterType( "org.qfield", 1, 0, "Positioning" ); qmlRegisterType( "org.qfield", 1, 0, "PositioningInformationModel" ); qmlRegisterType( "org.qfield", 1, 0, "PositioningDeviceModel" ); + qmlRegisterType( "org.qfield", 1, 0, "WebdavConnection" ); qmlRegisterType( "org.qfield", 1, 0, "AudioRecorder" ); qmlRegisterType( "org.qfield", 1, 0, "BarcodeDecoder" ); qmlRegisterType( "org.qfield", 1, 0, "QfCameraPermission" ); diff --git a/src/core/utils/fileutils.cpp b/src/core/utils/fileutils.cpp index e2908e2f19..072e68a341 100644 --- a/src/core/utils/fileutils.cpp +++ b/src/core/utils/fileutils.cpp @@ -50,6 +50,12 @@ bool FileUtils::isImageMimeTypeSupported( const QString &mimeType ) return QImageReader::supportedMimeTypes().contains( mimeType.toLatin1() ); } +QString FileUtils::absolutePath( const QString &filePath ) +{ + QFileInfo fileInfo( filePath ); + return fileInfo.absolutePath(); +} + QString FileUtils::fileName( const QString &filePath ) { QFileInfo fileInfo( filePath ); diff --git a/src/core/utils/fileutils.h b/src/core/utils/fileutils.h index bb979dfb98..248ae8c9e8 100644 --- a/src/core/utils/fileutils.h +++ b/src/core/utils/fileutils.h @@ -40,14 +40,16 @@ class QFIELD_CORE_EXPORT FileUtils : public QObject Q_INVOKABLE static QString mimeTypeName( const QString &filePath ); //! Returns TRUE if the provided mimetype is a supported image Q_INVOKABLE static bool isImageMimeTypeSupported( const QString &mimeType ); - //! Returns the filename of a filepath - if no file name exists it's empty + //! Returns the filename of a \a filePath - if no file name exists it's empty Q_INVOKABLE static QString fileName( const QString &filePath ); - //! Returns true if the file exists (false if it's a directory) + //! Returns true if the \a filePath exists (false if it's a directory) Q_INVOKABLE static bool fileExists( const QString &filePath ); //! Returns the suffix (extension) Q_INVOKABLE static QString fileSuffix( const QString &filePath ); //! Returns a human-friendly size from bytes Q_INVOKABLE static QString representFileSize( qint64 bytes ); + //! Returns the absolute path of tghe folder containing the \a filePath. + Q_INVOKABLE static QString absolutePath( const QString &filePath ); /** * Insures that a given image's width and height are restricted to a maximum size. diff --git a/src/core/webdavconnection.cpp b/src/core/webdavconnection.cpp new file mode 100644 index 0000000000..9310b15d39 --- /dev/null +++ b/src/core/webdavconnection.cpp @@ -0,0 +1,689 @@ +/*************************************************************************** + webdavconnection.cpp + ------------------- + begin : January 2025 + copyright : (C) 2025 by Mathieu Pellerin + email : mathieu@opengis.ch +***************************************************************************/ + +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + + +#include "webdavconnection.h" + +#include +#include +#include +#include + +WebdavConnection::WebdavConnection( QObject *parent ) + : QObject( parent ) +{ + connect( &mWebdavConnection, &QWebdav::errorChanged, this, &WebdavConnection::processConnectionError ); + connect( &mWebdavDirParser, &QWebdavDirParser::errorChanged, this, &WebdavConnection::processDirParserError ); + connect( &mWebdavDirParser, &QWebdavDirParser::finished, this, &WebdavConnection::processDirParserFinished ); +} + +void WebdavConnection::setUrl( const QString &url ) +{ + if ( mUrl == url.trimmed() ) + return; + + mUrl = url.trimmed(); + emit urlChanged(); + + if ( !mAvailablePaths.isEmpty() ) + { + mAvailablePaths.clear(); + emit availablePathsChanged(); + } + + checkStoredPassword(); +} + +void WebdavConnection::setUsername( const QString &username ) +{ + if ( mUsername == username ) + return; + + mUsername = username; + emit usernameChanged(); + + if ( !mAvailablePaths.isEmpty() ) + { + mAvailablePaths.clear(); + emit availablePathsChanged(); + } + + mWebdavConnection.clearAccessCache(); + checkStoredPassword(); +} + +void WebdavConnection::setPassword( const QString &password ) +{ + if ( mPassword == password ) + return; + + mPassword = password; + emit passwordChanged(); + + if ( !mAvailablePaths.isEmpty() ) + { + mAvailablePaths.clear(); + emit availablePathsChanged(); + } + + mWebdavConnection.clearAccessCache(); +} + +void WebdavConnection::setStorePassword( bool storePassword ) +{ + if ( mStorePassword == storePassword ) + return; + + mStorePassword = storePassword; + emit storePasswordChanged(); +} + +void WebdavConnection::checkStoredPassword() +{ + mStoredPassword.clear(); + + if ( !mUrl.isEmpty() && !mUsername.isEmpty() ) + { + QgsAuthManager *authManager = QgsApplication::instance()->authManager(); + QgsAuthMethodConfigsMap configs = authManager->availableAuthMethodConfigs(); + for ( QgsAuthMethodConfig &config : configs ) + { + if ( config.uri() == mUrl ) + { + authManager->loadAuthenticationConfig( config.id(), config, true ); + if ( config.config( QStringLiteral( "username" ) ) == mUsername ) + { + mStoredPassword = config.config( QStringLiteral( "password" ) ); + } + } + } + } + + emit isPasswordStoredChanged(); +} + +void WebdavConnection::applyStoredPassword() +{ + QgsAuthManager *authManager = QgsApplication::instance()->authManager(); + QgsAuthMethodConfigsMap configs = authManager->availableAuthMethodConfigs(); + if ( mStorePassword ) + { + if ( !mPassword.isEmpty() ) + { + bool found = false; + for ( QgsAuthMethodConfig &config : configs ) + { + if ( config.uri() == mUrl ) + { + authManager->loadAuthenticationConfig( config.id(), config, true ); + if ( config.config( QStringLiteral( "username" ) ) == mUsername ) + { + if ( config.config( QStringLiteral( "password" ) ) != mPassword ) + { + config.setConfig( "password", mPassword ); + authManager->updateAuthenticationConfig( config ); + + mStoredPassword = mPassword; + emit isPasswordStoredChanged(); + } + + found = true; + break; + } + } + } + + if ( !found ) + { + QgsAuthMethodConfig config( QStringLiteral( "Basic" ) ); + config.setName( QStringLiteral( "WebDAV created on %1" ).arg( QDateTime::currentDateTime().toString() ) ); + config.setUri( mUrl ); + config.setConfig( "username", mUsername ); + config.setConfig( "password", mPassword ); + authManager->storeAuthenticationConfig( config ); + + mStoredPassword = mPassword; + emit isPasswordStoredChanged(); + } + } + } + else + { + for ( QgsAuthMethodConfig &config : configs ) + { + if ( config.uri() == mUrl ) + { + authManager->loadAuthenticationConfig( config.id(), config, true ); + if ( config.config( QStringLiteral( "username" ) ) == mUsername ) + { + authManager->removeAuthenticationConfig( config.id() ); + } + } + } + + if ( !mStoredPassword.isEmpty() ) + { + mStoredPassword.clear(); + emit isPasswordStoredChanged(); + } + } +} + +void WebdavConnection::setupConnection() +{ + QUrl connectionUrl( mUrl ); + bool isHttps = connectionUrl.scheme() == QStringLiteral( "https" ); + mWebdavConnection.setConnectionSettings( isHttps ? QWebdav::HTTPS : QWebdav::HTTP, connectionUrl.host(), connectionUrl.path( QUrl::FullyEncoded ), mUsername, !mPassword.isEmpty() ? mPassword : mStoredPassword ); +} + +void WebdavConnection::fetchAvailablePaths() +{ + if ( mUrl.isEmpty() || mUsername.isEmpty() || ( mPassword.isEmpty() && mStoredPassword.isEmpty() ) ) + return; + + mAvailablePaths.clear(); + emit availablePathsChanged(); + + setupConnection(); + + mIsFetchingAvailablePaths = true; + emit isFetchingAvailablePathsChanged(); + + mWebdavDirParser.listDirectory( &mWebdavConnection, QStringLiteral( "/" ), true ); +} + +void WebdavConnection::processDirParserFinished() +{ + const QList list = mWebdavDirParser.getList(); + if ( mIsFetchingAvailablePaths ) + { + if ( !list.isEmpty() ) + { + applyStoredPassword(); + + mAvailablePaths << QStringLiteral( "/" ); + for ( const QWebdavItem &item : list ) + { + if ( item.isDir() ) + { + mAvailablePaths << item.path(); + } + } + } + + mIsFetchingAvailablePaths = false; + emit isFetchingAvailablePathsChanged(); + + mAvailablePaths.sort(); + emit availablePathsChanged(); + } + else if ( mIsImportingPath || mIsDownloadingPath ) + { + if ( !list.isEmpty() ) + { + applyStoredPassword(); + + QDir localDir( mProcessLocalPath ); + for ( const QWebdavItem &item : list ) + { + if ( item.isDir() ) + { + localDir.mkpath( item.path().mid( mProcessRemotePath.size() ) ); + } + else + { + if ( mIsDownloadingPath ) + { + QFileInfo fileInfo( mProcessLocalPath + item.path().mid( mProcessRemotePath.size() ) ); + if ( !fileInfo.exists() || ( fileInfo.fileTime( QFileDevice::FileModificationTime ) != item.lastModified() ) ) + { + mWebdavItems << item; + mBytesTotal += item.size(); + } + } + else + { + mWebdavItems << item; + mBytesTotal += item.size(); + } + } + } + emit progressChanged(); + } + + getWebdavItems(); + } + else if ( mIsUploadingPath ) + { + if ( !mWebdavLastModified.isEmpty() ) + { + // Adjust modified date to match upload files + for ( const QWebdavItem &item : list ) + { + if ( mWebdavLastModified.contains( item.path() ) ) + { + QFile file( mProcessLocalPath + item.path().mid( mProcessRemotePath.size() ) ); + if ( file.exists() ) + { + // The local file should always exist at this stage, just playing safe + file.open( QFile::Append ); + file.setFileTime( item.lastModified(), QFileDevice::FileModificationTime ); + file.setFileTime( item.lastModified(), QFileDevice::FileAccessTime ); + file.close(); + } + } + } + mWebdavLastModified.clear(); + + mIsUploadingPath = false; + emit isUploadingPathChanged(); + } + else + { + // Filter files to upload + applyStoredPassword(); + + QStringList remoteDirs; + for ( const QWebdavItem &item : list ) + { + if ( item.isDir() ) + { + remoteDirs << item.path(); + } + else + { + QFileInfo fileInfo( mProcessLocalPath + item.path().mid( mProcessRemotePath.size() ) ); + if ( fileInfo.exists() ) + { + auto localFileInfo = std::find_if( mLocalItems.begin(), mLocalItems.end(), [&fileInfo]( const QFileInfo &entry ) { + return entry.absoluteFilePath() == fileInfo.absoluteFilePath(); + } ); + + if ( localFileInfo != mLocalItems.end() ) + { + if ( localFileInfo->fileTime( QFileDevice::FileModificationTime ) == item.lastModified() ) + { + mLocalItems.remove( localFileInfo - mLocalItems.begin(), 1 ); + } + } + } + } + } + + mWebdavMkDirs.clear(); + for ( const QFileInfo &fileInfo : mLocalItems ) + { + // Insure the path exists remotely + QString remoteDir = mProcessRemotePath + fileInfo.absolutePath().mid( mProcessLocalPath.size() ).replace( QDir::separator(), "/" ); + if ( !remoteDirs.contains( remoteDir ) && !mWebdavMkDirs.contains( remoteDir ) ) + { + const QStringList remoteDirParts = remoteDir.mid( mProcessRemotePath.size() ).split( "/", Qt::SkipEmptyParts ); + remoteDir = mProcessRemotePath; + for ( const QString &part : remoteDirParts ) + { + remoteDir += part + "/"; + if ( !remoteDirs.contains( remoteDir ) && !mWebdavMkDirs.contains( remoteDir ) ) + { + mWebdavMkDirs << remoteDir; + } + } + } + + mBytesTotal += fileInfo.size(); + } + emit progressChanged(); + + putLocalItems(); + } + } +} + +void WebdavConnection::getWebdavItems() +{ + if ( !mWebdavItems.isEmpty() ) + { + const QString itemPath = mWebdavItems.first().path(); + const QDateTime itemLastModified = mWebdavItems.first().lastModified(); + QNetworkReply *reply = mWebdavConnection.get( itemPath ); + QTemporaryFile *temporaryFile = new QTemporaryFile( reply ); + temporaryFile->setFileTemplate( QStringLiteral( "%1%2.XXXXXXXXXXXX" ).arg( mProcessLocalPath, itemPath.mid( mProcessRemotePath.size() ) ) ); + temporaryFile->open(); + + connect( reply, &QNetworkReply::downloadProgress, this, [=]( int bytesReceived, int bytesTotal ) { + mCurrentBytesProcessed = bytesReceived; + emit progressChanged(); + + temporaryFile->write( reply->readAll() ); + } ); + + connect( reply, &QNetworkReply::finished, this, [=]() { + mBytesProcessed += mCurrentBytesProcessed; + mCurrentBytesProcessed = 0; + if ( reply->error() == QNetworkReply::NoError ) + { + QFile file( mProcessLocalPath + itemPath.mid( mProcessRemotePath.size() ) ); + if ( file.exists() ) + { + // Remove pre-existing file + file.remove(); + } + + temporaryFile->write( reply->readAll() ); + temporaryFile->setAutoRemove( false ); + temporaryFile->rename( mProcessLocalPath + itemPath.mid( mProcessRemotePath.size() ) ); + temporaryFile->close(); + delete temporaryFile; + + // Attach last modified date value coming from the server (cannot be done via QTemporaryFile) + file.open( QFile::Append ); + file.setFileTime( itemLastModified, QFileDevice::FileModificationTime ); + file.setFileTime( itemLastModified, QFileDevice::FileAccessTime ); + file.close(); + } + else + { + mLastError = tr( "Failed to download file %1 due to network error (%2)" ).arg( itemPath ).arg( reply->error() ); + } + + mWebdavItems.removeFirst(); + getWebdavItems(); + reply->deleteLater(); + } ); + } + else + { + if ( mIsImportingPath ) + { + QVariantMap webdavConfiguration; + webdavConfiguration[QStringLiteral( "url" )] = mUrl; + webdavConfiguration[QStringLiteral( "username" )] = mUsername; + webdavConfiguration[QStringLiteral( "remote_path" )] = mProcessRemotePath; + + QJsonDocument jsonDocument = QJsonDocument::fromVariant( webdavConfiguration ); + QFile jsonFile( QStringLiteral( "%1qfield_webdav_configuration.json" ).arg( mProcessLocalPath ) ); + jsonFile.open( QFile::WriteOnly ); + jsonFile.write( jsonDocument.toJson() ); + jsonFile.close(); + + mIsImportingPath = false; + emit isImportingPathChanged(); + emit importSuccessful( mProcessLocalPath ); + } + else if ( mIsDownloadingPath ) + { + mIsDownloadingPath = false; + emit isDownloadingPathChanged(); + } + } +} + +void WebdavConnection::putLocalItems() +{ + if ( !mWebdavMkDirs.isEmpty() ) + { + const QString dirPath = mWebdavMkDirs.first(); + + QNetworkReply *reply = mWebdavConnection.mkdir( dirPath ); + + connect( reply, &QNetworkReply::finished, this, [=]() { + mBytesProcessed += mCurrentBytesProcessed; + mCurrentBytesProcessed = 0; + emit progressChanged(); + if ( reply->error() != QNetworkReply::NoError ) + { + mLastError = tr( "Failed to upload file %1 due to network error (%2)" ).arg( dirPath ).arg( reply->error() ); + } + + mWebdavMkDirs.removeFirst(); + putLocalItems(); + } ); + } + else if ( !mLocalItems.isEmpty() ) + { + const QString itemPath = mLocalItems.first().absoluteFilePath(); + const QString remoteItemPath = mProcessRemotePath + itemPath.mid( mProcessLocalPath.size() ).replace( QDir::separator(), "/" ); + + QFile *file = new QFile( itemPath ); + file->open( QFile::ReadOnly ); + QNetworkReply *reply = mWebdavConnection.put( remoteItemPath, file ); + file->setParent( reply ); + + connect( reply, &QNetworkReply::uploadProgress, this, [=]( int bytesSent, int bytesTotal ) { + mCurrentBytesProcessed = bytesSent; + emit progressChanged(); + } ); + + connect( reply, &QNetworkReply::finished, this, [=]() { + mBytesProcessed += mCurrentBytesProcessed; + mCurrentBytesProcessed = 0; + emit progressChanged(); + if ( reply->error() != QNetworkReply::NoError ) + { + mLastError = tr( "Failed to upload file %1 due to network error (%2)" ).arg( remoteItemPath ).arg( reply->error() ); + } + + mWebdavLastModified << remoteItemPath; + + mLocalItems.removeFirst(); + putLocalItems(); + reply->deleteLater(); + } ); + } + else + { + if ( mIsUploadingPath ) + { + if ( !mWebdavLastModified.isEmpty() ) + { + mWebdavDirParser.listDirectory( &mWebdavConnection, mProcessRemotePath, true ); + } + else + { + mIsUploadingPath = false; + emit isUploadingPathChanged(); + } + } + } +} + +void WebdavConnection::importPath( const QString &remotePath, const QString &localPath ) +{ + if ( mUrl.isEmpty() || mUsername.isEmpty() || ( mPassword.isEmpty() && mStoredPassword.isEmpty() ) ) + return; + + setupConnection(); + + QString localFolder = QStringLiteral( "%1 - %2 - %3" ).arg( mWebdavConnection.hostname(), mWebdavConnection.username(), remotePath ); + localFolder.replace( QRegularExpression( "[\\\\\\/\\<\\>\\:\\|\\?\\*\\\"]" ), QString( "_" ) ); + + QDir localDir( localPath ); + localDir.mkpath( localFolder ); + + mProcessRemotePath = remotePath; + mProcessLocalPath = QDir::cleanPath( localPath + QDir::separator() + localFolder ) + QDir::separator(); + + mWebdavItems.clear(); + mBytesProcessed = 0; + mBytesTotal = 0; + emit progressChanged(); + + mIsImportingPath = true; + emit isImportingPathChanged(); + + mWebdavDirParser.listDirectory( &mWebdavConnection, mProcessRemotePath, true ); +} + +void WebdavConnection::downloadPath( const QString &localPath ) +{ + QDir dir( localPath ); + bool webdavConfigurationExists = dir.exists( "qfield_webdav_configuration.json" ); + QStringList remoteChildrenPath; + while ( !webdavConfigurationExists ) + { + remoteChildrenPath.prepend( dir.dirName() ); + if ( !dir.cdUp() ) + break; + + webdavConfigurationExists = dir.exists( "qfield_webdav_configuration.json" ); + } + + if ( webdavConfigurationExists ) + { + QFile webdavConfigurationFile( dir.absolutePath() + QDir::separator() + QStringLiteral( "qfield_webdav_configuration.json" ) ); + webdavConfigurationFile.open( QFile::ReadOnly ); + QJsonDocument jsonDocument = QJsonDocument::fromJson( webdavConfigurationFile.readAll() ); + if ( !jsonDocument.isEmpty() ) + { + QVariantMap webdavConfiguration = jsonDocument.toVariant().toMap(); + setUrl( webdavConfiguration["url"].toString() ); + setUsername( webdavConfiguration["username"].toString() ); + setStorePassword( isPasswordStored() ); + + mProcessRemotePath = webdavConfiguration["remote_path"].toString(); + if ( !remoteChildrenPath.isEmpty() ) + { + mProcessRemotePath = mProcessRemotePath + remoteChildrenPath.join( "/" ) + QStringLiteral( "/" ); + } + mProcessLocalPath = QDir::cleanPath( localPath ) + QDir::separator(); + + mWebdavItems.clear(); + mBytesProcessed = 0; + mBytesTotal = 0; + emit progressChanged(); + + mIsDownloadingPath = true; + emit isDownloadingPathChanged(); + const QUrl url( mUrl ); + emit confirmationRequested( url.host(), mUsername ); + } + } +} + +void WebdavConnection::uploadPath( const QString &localPath ) +{ + QDir dir( localPath ); + bool webdavConfigurationExists = dir.exists( "qfield_webdav_configuration.json" ); + QStringList remoteChildrenPath; + while ( !webdavConfigurationExists ) + { + remoteChildrenPath.prepend( dir.dirName() ); + if ( !dir.cdUp() ) + break; + + webdavConfigurationExists = dir.exists( "qfield_webdav_configuration.json" ); + } + + if ( webdavConfigurationExists ) + { + QFile webdavConfigurationFile( dir.absolutePath() + QDir::separator() + QStringLiteral( "qfield_webdav_configuration.json" ) ); + webdavConfigurationFile.open( QFile::ReadOnly ); + QJsonDocument jsonDocument = QJsonDocument::fromJson( webdavConfigurationFile.readAll() ); + if ( !jsonDocument.isEmpty() ) + { + QVariantMap webdavConfiguration = jsonDocument.toVariant().toMap(); + setUrl( webdavConfiguration["url"].toString() ); + setUsername( webdavConfiguration["username"].toString() ); + setStorePassword( isPasswordStored() ); + + mProcessRemotePath = webdavConfiguration["remote_path"].toString(); + if ( !remoteChildrenPath.isEmpty() ) + { + mProcessRemotePath = mProcessRemotePath + remoteChildrenPath.join( "/" ) + QStringLiteral( "/" ); + } + mProcessLocalPath = QDir::cleanPath( localPath ) + QDir::separator(); + + mWebdavLastModified.clear(); + + mLocalItems.clear(); + QDirIterator it( mProcessLocalPath, QDir::Files | QDir::NoDotAndDotDot, QDirIterator::Subdirectories ); + while ( it.hasNext() ) + { + it.next(); + if ( it.fileName() != QStringLiteral( "qfield_webdav_configuration.json" ) ) + { + mLocalItems << it.fileInfo(); + } + } + + mBytesProcessed = 0; + mBytesTotal = 0; + emit progressChanged(); + + mIsUploadingPath = true; + emit isUploadingPathChanged(); + const QUrl url( mUrl ); + emit confirmationRequested( url.host(), mUsername ); + } + } +} + +void WebdavConnection::confirmRequest() +{ + if ( mIsDownloadingPath || mIsUploadingPath ) + { + setupConnection(); + mWebdavDirParser.listDirectory( &mWebdavConnection, mProcessRemotePath, true ); + } +} + +void WebdavConnection::cancelRequest() +{ + if ( mIsDownloadingPath ) + { + mIsDownloadingPath = false; + emit isDownloadingPathChanged(); + } + else if ( mIsUploadingPath ) + { + mIsUploadingPath = false; + emit isUploadingPathChanged(); + } +} + +double WebdavConnection::progress() const +{ + if ( ( mIsImportingPath || mIsDownloadingPath || mIsUploadingPath ) && mBytesTotal > 0 ) + { + return static_cast( mBytesProcessed + mCurrentBytesProcessed ) / mBytesTotal; + } + + return 0; +} + +void WebdavConnection::processConnectionError( const QString &error ) +{ + mLastError = error; + emit lastErrorChanged(); +} + +void WebdavConnection::processDirParserError( const QString &error ) +{ + mLastError = error; + emit lastErrorChanged(); +} + +bool WebdavConnection::hasWebdavConfiguration( const QString &path ) +{ + const QFileInfo fileInfo( path ); + QDir dir( fileInfo.isFile() ? fileInfo.absolutePath() : fileInfo.absoluteFilePath() ); + bool webdavConfigurationExists = dir.exists( "qfield_webdav_configuration.json" ); + while ( !webdavConfigurationExists && dir.cdUp() ) + { + webdavConfigurationExists = dir.exists( "qfield_webdav_configuration.json" ); + } + return webdavConfigurationExists; +} diff --git a/src/core/webdavconnection.h b/src/core/webdavconnection.h new file mode 100644 index 0000000000..601aa10aae --- /dev/null +++ b/src/core/webdavconnection.h @@ -0,0 +1,157 @@ +/*************************************************************************** + webdavconnection.h + ------------------- + begin : January 2025 + copyright : (C) 2025 by Mathieu Pellerin + email : mathieu@opengis.ch +***************************************************************************/ + +/*************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef WEBDAVCONNECTION_H +#define WEBDAVCONNECTION_H + +#include +#include +#include + +/** + * The webdav connection objects allows for connection to and push/pull + * operations of content. + * \ingroup core + */ +class WebdavConnection : public QObject +{ + Q_OBJECT + + Q_PROPERTY( QString url READ url WRITE setUrl NOTIFY urlChanged ); + Q_PROPERTY( QString username READ username WRITE setUsername NOTIFY usernameChanged ) + Q_PROPERTY( QString password READ password WRITE setPassword NOTIFY passwordChanged ) + + Q_PROPERTY( bool storePassword READ storePassword WRITE setStorePassword NOTIFY storePasswordChanged ) + + Q_PROPERTY( bool isPasswordStored READ isPasswordStored NOTIFY isPasswordStoredChanged ) + Q_PROPERTY( bool isFetchingAvailablePaths READ isFetchingAvailablePaths NOTIFY isFetchingAvailablePathsChanged ) + Q_PROPERTY( bool isImportingPath READ isImportingPath NOTIFY isImportingPathChanged ) + Q_PROPERTY( bool isDownloadingPath READ isDownloadingPath NOTIFY isDownloadingPathChanged ) + Q_PROPERTY( bool isUploadingPath READ isUploadingPath NOTIFY isUploadingPathChanged ) + + Q_PROPERTY( QStringList availablePaths READ availablePaths NOTIFY availablePathsChanged ) + Q_PROPERTY( double progress READ progress NOTIFY progressChanged ) + Q_PROPERTY( QString lastError READ lastError NOTIFY lastErrorChanged ) + + public: + explicit WebdavConnection( QObject *parent = nullptr ); + ~WebdavConnection() = default; + + QString url() const { return mUrl; } + + void setUrl( const QString &url ); + + QString username() const { return mUsername; } + + void setUsername( const QString &username ); + + QString password() const { return mPassword; } + + void setPassword( const QString &password ); + + bool storePassword() const { return mStorePassword; } + + void setStorePassword( bool storePassword ); + + bool isPasswordStored() const { return !mStoredPassword.isEmpty(); } + + QStringList availablePaths() const { return mIsFetchingAvailablePaths ? QStringList() : mAvailablePaths; } + + bool isFetchingAvailablePaths() const { return mIsFetchingAvailablePaths; } + + bool isImportingPath() const { return mIsImportingPath; } + + bool isDownloadingPath() const { return mIsDownloadingPath; } + + bool isUploadingPath() const { return mIsUploadingPath; } + + double progress() const; + + QString lastError() const { return mLastError; } + + Q_INVOKABLE void fetchAvailablePaths(); + + Q_INVOKABLE void importPath( const QString &remotePath, const QString &localPath ); + Q_INVOKABLE void downloadPath( const QString &localPath ); + Q_INVOKABLE void uploadPath( const QString &localPath ); + + Q_INVOKABLE void confirmRequest(); + Q_INVOKABLE void cancelRequest(); + + Q_INVOKABLE static bool hasWebdavConfiguration( const QString &path ); + + signals: + void urlChanged(); + void usernameChanged(); + void passwordChanged(); + void storePasswordChanged(); + void isPasswordStoredChanged(); + void isFetchingAvailablePathsChanged(); + void isImportingPathChanged(); + void isDownloadingPathChanged(); + void isUploadingPathChanged(); + void availablePathsChanged(); + void progressChanged(); + void lastErrorChanged(); + + void confirmationRequested( const QString &host, const QString &username ); + + void importSuccessful( const QString &path ); + + private slots: + void processDirParserFinished(); + void processConnectionError( const QString &error ); + void processDirParserError( const QString &error ); + + private: + void checkStoredPassword(); + void applyStoredPassword(); + void setupConnection(); + void getWebdavItems(); + void putLocalItems(); + + QString mUrl; + QString mUsername; + QString mPassword; + + bool mStorePassword = false; + QString mStoredPassword; + + bool mIsFetchingAvailablePaths = false; + QStringList mAvailablePaths; + + bool mIsImportingPath = false; + bool mIsDownloadingPath = false; + bool mIsUploadingPath = false; + + QList mWebdavItems; + QList mWebdavMkDirs; + QList mLocalItems; + QList mWebdavLastModified; + + QString mProcessRemotePath; + QString mProcessLocalPath; + qint64 mCurrentBytesProcessed = 0; + qint64 mBytesProcessed = 0; + qint64 mBytesTotal = 0; + + QWebdav mWebdavConnection; + QWebdavDirParser mWebdavDirParser; + QString mLastError; +}; + +#endif // WEBDAVCONNECTION_H diff --git a/src/qml/DashBoard.qml b/src/qml/DashBoard.qml index cfbdc7f900..fc70d48439 100644 --- a/src/qml/DashBoard.qml +++ b/src/qml/DashBoard.qml @@ -47,7 +47,7 @@ Drawer { focus: visible clip: true - onShowMenu: mainMenu.popup(menuButton.x + menuButton.width - mainMenu.width - 2, mainWindow.sceneTopMargin + menuButton.y - 2) + onShowMenu: mainMenu.popup(menuButton.x + menuButton.width - mainMenu.width - 2, menuButton.y - 2) onShowCloudMenu: qfieldCloudPopup.show() onActiveLayerChanged: { diff --git a/src/qml/MessageLog.qml b/src/qml/MessageLog.qml index d28b2c2c36..3fc460c7b0 100644 --- a/src/qml/MessageLog.qml +++ b/src/qml/MessageLog.qml @@ -179,7 +179,7 @@ Page { color: Theme.mainTextColor } - QfTextField { + TextField { id: appliationLogInput width: applicationLogLabel.width placeholderText: qsTr("Type optional details") diff --git a/src/qml/QFieldLocalDataPickerScreen.qml b/src/qml/QFieldLocalDataPickerScreen.qml index b07bfeaef6..ba20851aaa 100644 --- a/src/qml/QFieldLocalDataPickerScreen.qml +++ b/src/qml/QFieldLocalDataPickerScreen.qml @@ -11,6 +11,7 @@ import Theme Page { id: qfieldLocalDataPickerScreen + property bool openedOnce: false property bool projectFolderView: false property alias model: table.model @@ -18,6 +19,12 @@ Page { focus: visible + onVisibleChanged: { + if (visible) { + openedOnce = true; + } + } + header: QfPageHeader { title: projectFolderView ? qsTr("Project Folder") : qsTr("Local Projects & Datasets") @@ -255,6 +262,7 @@ Page { itemMenu.itemType = ItemType; itemMenu.itemPath = ItemPath; itemMenu.itemIsFavorite = ItemIsFavorite; + itemMenu.itemHasWebdavConfiguration = ItemHasWebdavConfiguration; itemMenu.popup(gc.x + width - itemMenu.width, gc.y - height); } } @@ -322,7 +330,8 @@ Page { round: true // Since the project menu only has one action for now, hide if PlatformUtilities.UpdateProjectFromArchive is missing - property bool isLocalProject: qgisProject && QFieldCloudUtils.getProjectId(qgisProject.fileName) === '' && (projectInfo.filePath.endsWith('.qgs') || projectInfo.filePath.endsWith('.qgz')) && platformUtilities.capabilities & PlatformUtilities.UpdateProjectFromArchive + property bool isLocalProject: qgisProject && QFieldCloudUtils.getProjectId(qgisProject.fileName) === '' && (projectInfo.filePath.endsWith('.qgs') || projectInfo.filePath.endsWith('.qgz')) + property bool isLocalProjectActionAvailable: updateProjectFromArchive.enabled || uploadProjectToWebdav.enabled visible: (projectFolderView && isLocalProject && table.model.currentDepth === 1) || table.model.currentPath === 'root' anchors.bottom: parent.bottom @@ -351,6 +360,7 @@ Page { property int itemType: 0 property string itemPath: '' property bool itemIsFavorite: false + property bool itemHasWebdavConfiguration: false title: qsTr('Item Actions') @@ -368,6 +378,7 @@ Page { topMargin: sceneTopMargin bottomMargin: sceneBottomMargin + // File items MenuItem { id: sendDatasetTo enabled: itemMenu.itemMetaType === LocalFilesModel.File || (platformUtilities.capabilities & PlatformUtilities.CustomSend && itemMenu.itemMetaType == LocalFilesModel.Dataset) @@ -394,7 +405,7 @@ Page { height: enabled ? undefined : 0 leftPadding: Theme.menuItemLeftPadding - text: qsTr("Push to QFieldCloud...") + text: qsTr("Push to QFieldCloud") onTriggered: { QFieldCloudUtils.addPendingAttachment(cloudProjectsModel.currentProjectId, itemMenu.itemPath); platformUtilities.uploadPendingAttachments(cloudConnection); @@ -418,9 +429,10 @@ Page { } } + // Folder items MenuItem { - id: removeDataset - enabled: itemMenu.itemMetaType == LocalFilesModel.Dataset && !qfieldLocalDataPickerScreen.projectFolderView && table.model.isDeletedAllowedInCurrentPath + id: toggleFavoriteState + enabled: itemMenu.itemMetaType == LocalFilesModel.Folder && localFilesModel.isPathFavoriteEditable(itemMenu.itemPath) visible: enabled font: Theme.defaultFont @@ -428,12 +440,23 @@ Page { height: enabled ? undefined : 0 leftPadding: Theme.menuItemLeftPadding - text: qsTr("Remove dataset") + text: !itemMenu.itemIsFavorite ? qsTr("Add to favorites") : qsTr("Remove from favorites") onTriggered: { - platformUtilities.removeDataset(itemMenu.itemPath); + if (!itemMenu.itemIsFavorite) { + localFilesModel.addToFavorites(itemMenu.itemPath); + } else { + localFilesModel.removeFromFavorites(itemMenu.itemPath); + } } } + MenuSeparator { + enabled: toggleFavoriteState.visible + visible: enabled + width: parent.width + height: enabled ? undefined : 0 + } + MenuItem { id: exportFolderTo enabled: platformUtilities.capabilities & PlatformUtilities.CustomExport && itemMenu.itemMetaType == LocalFilesModel.Folder @@ -451,8 +474,8 @@ Page { } MenuItem { - id: toggleFavoriteState - enabled: itemMenu.itemMetaType == LocalFilesModel.Folder && localFilesModel.isPathFavoriteEditable(itemMenu.itemPath) + id: sendCompressedFolderTo + enabled: platformUtilities.capabilities & PlatformUtilities.CustomSend && itemMenu.itemMetaType == LocalFilesModel.Folder visible: enabled font: Theme.defaultFont @@ -460,19 +483,33 @@ Page { height: enabled ? undefined : 0 leftPadding: Theme.menuItemLeftPadding - text: !itemMenu.itemIsFavorite ? qsTr("Add to favorites") : qsTr("Remove from favorites") + text: qsTr("Send compressed folder to...") onTriggered: { - if (!itemMenu.itemIsFavorite) { - localFilesModel.addToFavorites(itemMenu.itemPath); - } else { - localFilesModel.removeFromFavorites(itemMenu.itemPath); + platformUtilities.sendCompressedFolderTo(itemMenu.itemPath); + } + } + + MenuItem { + id: uploadFolderToWebdav + enabled: itemMenu.itemHasWebdavConfiguration + visible: enabled + + font: Theme.defaultFont + width: parent.width + height: enabled ? undefined : 0 + leftPadding: Theme.menuItemLeftPadding + + text: qsTr("Upload folder to WebDAV server") + onTriggered: { + if (webdavConnectionLoader.item) { + webdavConnectionLoader.item.uploadPath(itemMenu.itemPath); } } } MenuItem { - id: sendCompressedFolderTo - enabled: platformUtilities.capabilities & PlatformUtilities.CustomSend && itemMenu.itemMetaType == LocalFilesModel.Folder + id: downloadFolderFromWebdav + enabled: itemMenu.itemHasWebdavConfiguration visible: enabled font: Theme.defaultFont @@ -480,9 +517,34 @@ Page { height: enabled ? undefined : 0 leftPadding: Theme.menuItemLeftPadding - text: qsTr("Send compressed folder to...") + text: qsTr("Download folder from WebDAV server") onTriggered: { - platformUtilities.sendCompressedFolderTo(itemMenu.itemPath); + if (webdavConnectionLoader.item) { + webdavConnectionLoader.item.downloadPath(itemMenu.itemPath); + } + } + } + + MenuSeparator { + enabled: removeProjectFolder.visible + visible: enabled + width: parent.width + height: enabled ? undefined : 0 + } + + MenuItem { + id: removeDataset + enabled: itemMenu.itemMetaType == LocalFilesModel.Dataset && !qfieldLocalDataPickerScreen.projectFolderView && table.model.isDeletedAllowedInCurrentPath + visible: enabled + + font: Theme.defaultFont + width: parent.width + height: enabled ? undefined : 0 + leftPadding: Theme.menuItemLeftPadding + + text: qsTr("Remove dataset") + onTriggered: { + platformUtilities.removeDataset(itemMenu.itemPath); } } @@ -496,7 +558,7 @@ Page { height: enabled ? undefined : 0 leftPadding: Theme.menuItemLeftPadding - text: qsTr("Remove project folder") + text: qsTr("Remove folder") onTriggered: { platformUtilities.removeFolder(itemMenu.itemPath); } @@ -591,6 +653,20 @@ Page { } } + MenuItem { + id: importWebdav + + font: Theme.defaultFont + width: parent.width + leftPadding: Theme.menuItemLeftPadding + + text: qsTr("Import WebDAV folder") + onTriggered: { + importWebdavDialog.open(); + importWebdavUrlInput.focus = true; + } + } + MenuSeparator { width: parent.width } @@ -643,13 +719,51 @@ Page { platformUtilities.updateProjectFromArchive(projectInfo.filePath); } } + + MenuItem { + id: uploadProjectToWebdav + + enabled: webdavConnectionLoader.item ? webdavConnectionLoader.item.hasWebdavConfiguration(FileUtils.absolutePath(projectInfo.filePath)) : false + visible: enabled + font: Theme.defaultFont + width: parent.width + height: enabled ? undefined : 0 + leftPadding: Theme.menuItemLeftPadding + + text: qsTr("Upload project to WebDAV") + onTriggered: { + if (webdavConnectionLoader.item) { + webdavConnectionLoader.item.uploadPath(FileUtils.absolutePath(projectInfo.filePath)); + } + } + } + + MenuItem { + id: downloadProjectToWebdav + + enabled: uploadProjectToWebdav.enabled + visible: enabled + font: Theme.defaultFont + width: parent.width + height: enabled ? undefined : 0 + leftPadding: Theme.menuItemLeftPadding + + text: qsTr("Download project from WebDAV") + onTriggered: { + if (webdavConnectionLoader.item) { + webdavConnectionLoader.item.openedProjectPath = projectInfo.filePath; + iface.clearProject(); + webdavConnectionLoader.item.downloadPath(FileUtils.absolutePath(projectInfo.filePath)); + } + } + } } } QfDialog { id: importUrlDialog - title: "Import URL" - focus: true + title: qsTr("Import URL") + focus: visible y: (mainWindow.height - height - 80) / 2 onAboutToShow: { @@ -687,6 +801,348 @@ Page { } } + Loader { + id: webdavConnectionLoader + active: qfieldLocalDataPickerScreen.openedOnce + sourceComponent: Component { + WebdavConnection { + id: webdavConnection + + property string openedProjectPath: "" + + onIsImportingPathChanged: { + if (isImportingPath) { + busyOverlay.text = qsTr("Importing WebDAV folder"); + busyOverlay.progress = 0; + busyOverlay.state = "visible"; + } else { + busyOverlay.state = "hidden"; + } + } + + onIsDownloadingPathChanged: { + if (isDownloadingPath) { + busyOverlay.text = qsTr("Downloading WebDAV folder"); + busyOverlay.progress = 0; + busyOverlay.state = "visible"; + } else { + busyOverlay.state = "hidden"; + if (openedProjectPath) { + iface.loadFile(openedProjectPath); + openedProjectPath = ""; + } + } + } + + onIsUploadingPathChanged: { + if (isUploadingPath) { + busyOverlay.text = qsTr("Uploading WebDAV folder"); + busyOverlay.progress = 0; + busyOverlay.state = "visible"; + } else { + busyOverlay.state = "hidden"; + } + } + + onProgressChanged: { + if (isImportingPath || isDownloadingPath || isUploadingPath) { + busyOverlay.progress = progress; + } + } + + onLastErrorChanged: { + displayToast(qsTr("WebDAV error: ") + lastError); + } + + onConfirmationRequested: (host, username) => { + downloadUploadWebdavDialog.isUploadingPath = isUploadingPath; + downloadUploadWebdavDialog.host = host; + downloadUploadWebdavDialog.username = username; + downloadUploadWebdavDialog.open(); + } + + onImportSuccessful: path => { + table.model.currentPath = path; + } + } + } + } + + QfDialog { + id: downloadUploadWebdavDialog + title: isUploadingPath ? qsTr("WebDAV upload") : qsTr("WebDAV download") + focus: true + y: (mainWindow.height - height - 80) / 2 + + property bool isUploadingPath: false + property string host: "" + property string username: "" + + onAboutToShow: { + if (webdavConnectionLoader.item) { + webdavConnectionLoader.item.password = downloadUploadWebdavPasswordInput.text; + webdavConnectionLoader.item.storePassword = downloadUploadWebdavPasswordCheck.checked; + } + } + + Column { + width: childrenRect.width + height: childrenRect.height + spacing: 10 + + TextMetrics { + id: downloadUploadWebdavIntroMetrics + font: downloadUploadWebdavIntroLabel.font + text: downloadUploadWebdavIntroLabel.text + } + + Label { + id: downloadUploadWebdavIntroLabel + width: mainWindow.width - 60 < downloadUploadWebdavIntroMetrics.width ? mainWindow.width - 60 : downloadUploadWebdavIntroMetrics.width + text: downloadUploadWebdavDialog.isUploadingPath ? qsTr("You are about to upload modified content into %1 using user %2.

This operation will overwrite data stored remotely, make sure this is what you want to do.").arg(downloadUploadWebdavDialog.host).arg(downloadUploadWebdavDialog.username) : qsTr("You are about to download modified content from %1 using user %2.

This operation will overwrite data stored locally, make sure this is what you want to do.").arg(downloadUploadWebdavDialog.host).arg(downloadUploadWebdavDialog.username) + wrapMode: Text.WordWrap + font: Theme.defaultFont + color: Theme.mainTextColor + } + + TextField { + id: downloadUploadWebdavPasswordInput + enabled: !webdavConnectionLoader.item || !webdavConnectionLoader.item.isFetchingAvailablePaths + width: downloadUploadWebdavIntroLabel.width + rightPadding: leftPadding + (downloadUploadWebdavShowPasswordInput.width - leftPadding) + placeholderText: text === "" && webdavConnectionLoader.item && webdavConnectionLoader.item.isPasswordStored ? qsTr("Password (leave empty to use remembered)") : qsTr("Password") + echoMode: TextInput.Password + + onDisplayTextChanged: { + if (webdavConnectionLoader.item) { + webdavConnectionLoader.item.password = text; + } + } + + QfToolButton { + id: downloadUploadWebdavShowPasswordInput + + property int originalEchoMode: TextInput.Normal + + visible: (!!parent.echoMode && parent.echoMode !== TextInput.Normal) || originalEchoMode !== TextInput.Normal + iconSource: parent.echoMode === TextInput.Normal ? Theme.getThemeVectorIcon('ic_hide_green_48dp') : Theme.getThemeVectorIcon('ic_show_green_48dp') + iconColor: Theme.mainColor + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + opacity: parent.text.length > 0 ? 1 : 0.25 + z: 1 + + onClicked: { + if (parent.echoMode !== TextInput.Normal) { + originalEchoMode = parent.echoMode; + parent.echoMode = TextInput.Normal; + } else { + parent.echoMode = originalEchoMode; + } + } + } + } + + CheckBox { + id: downloadUploadWebdavPasswordCheck + width: downloadUploadWebdavIntroLabel.width + enabled: !webdavConnectionLoader.item || !webdavConnectionLoader.item.isFetchingAvailablePaths + text: qsTr('Remember password') + font: Theme.defaultFont + checked: true + } + } + + onAccepted: { + if (webdavConnectionLoader.item) { + webdavConnectionLoader.item.confirmRequest(); + } + } + + onRejected: { + if (webdavConnectionLoader.item) { + webdavConnectionLoader.item.cancelRequest(); + } + } + } + + QfDialog { + id: importWebdavDialog + title: qsTr("Import WebDAV folder") + focus: visible + y: (mainWindow.height - height - 80) / 2 + + onAboutToShow: { + if (webdavConnectionLoader.item) { + webdavConnectionLoader.item.url = importWebdavUrlInput.text; + webdavConnectionLoader.item.username = importWebdavUserInput.text; + webdavConnectionLoader.item.password = importWebdavPasswordInput.text; + webdavConnectionLoader.item.storePassword = importWebdavStorePasswordCheck.checked; + } + } + + Column { + width: childrenRect.width + height: childrenRect.height + spacing: 10 + + TextMetrics { + id: importWebdavUrlLabelMetrics + font: importWebdavUrlLabel.font + text: importWebdavUrlLabel.text + } + + Label { + id: importWebdavUrlLabel + width: mainWindow.width - 60 < importWebdavUrlLabelMetrics.width ? mainWindow.width - 60 : importWebdavUrlLabelMetrics.width + text: qsTr("Type the WebDAV details below to import a remote folder:") + wrapMode: Text.WordWrap + font: Theme.defaultFont + color: Theme.mainTextColor + } + + TextField { + id: importWebdavUrlInput + enabled: !webdavConnectionLoader.item || !webdavConnectionLoader.item.isFetchingAvailablePaths + width: importWebdavUrlLabel.width + placeholderText: qsTr("WebDAV server URL") + + onDisplayTextChanged: { + if (webdavConnectionLoader.item) { + webdavConnectionLoader.item.url = displayText; + } + } + } + + TextField { + id: importWebdavUserInput + enabled: !webdavConnectionLoader.item || !webdavConnectionLoader.item.isFetchingAvailablePaths + width: importWebdavUrlLabel.width + placeholderText: qsTr("User") + + onDisplayTextChanged: { + if (webdavConnectionLoader.item) { + webdavConnectionLoader.item.username = displayText; + } + } + } + + TextField { + id: importWebdavPasswordInput + enabled: !webdavConnectionLoader.item || !webdavConnectionLoader.item.isFetchingAvailablePaths + width: importWebdavUrlLabel.width + rightPadding: leftPadding + (importWebdavShowPasswordInput.width - leftPadding) + placeholderText: text === "" && webdavConnectionLoader.item && webdavConnectionLoader.item.isPasswordStored ? qsTr("Password (leave empty to use remembered)") : qsTr("Password") + echoMode: TextInput.Password + + onDisplayTextChanged: { + if (webdavConnectionLoader.item) { + webdavConnectionLoader.item.password = text; + } + } + + QfToolButton { + id: importWebdavShowPasswordInput + + property int originalEchoMode: TextInput.Normal + + visible: (!!parent.echoMode && parent.echoMode !== TextInput.Normal) || originalEchoMode !== TextInput.Normal + iconSource: parent.echoMode === TextInput.Normal ? Theme.getThemeVectorIcon('ic_hide_green_48dp') : Theme.getThemeVectorIcon('ic_show_green_48dp') + iconColor: Theme.mainColor + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + opacity: parent.text.length > 0 ? 1 : 0.25 + z: 1 + + onClicked: { + if (parent.echoMode !== TextInput.Normal) { + originalEchoMode = parent.echoMode; + parent.echoMode = TextInput.Normal; + } else { + parent.echoMode = originalEchoMode; + } + } + } + } + + CheckBox { + id: importWebdavStorePasswordCheck + width: importWebdavUrlLabel.width + enabled: !webdavConnectionLoader.item || !webdavConnectionLoader.item.isFetchingAvailablePaths + text: qsTr('Remember password') + font: Theme.defaultFont + checked: true + } + + Label { + width: importWebdavUrlLabel.width + visible: importWebdavPathInput.visible + text: qsTr("Select the remote folder to import:") + wrapMode: Text.WordWrap + font: Theme.defaultFont + color: Theme.mainTextColor + } + + Row { + spacing: 5 + + QfButton { + id: importWebdavFetchFoldersButton + anchors.verticalCenter: importWebdavPathInput.verticalCenter + visible: !webdavConnectionLoader.item || webdavConnectionLoader.item.availablePaths.length === 0 + enabled: !webdavConnectionLoader.item || !webdavConnectionLoader.item.isFetchingAvailablePaths + width: importWebdavUrlLabel.width - (importWebdavFetchFoldersIndicator.visible ? importWebdavFetchFoldersIndicator.width + 5 : 0) + text: !enabled ? qsTr("Fetching remote folders") : qsTr("Fetch remote folders") + + onClicked: { + webdavConnectionLoader.item.fetchAvailablePaths(); + } + } + + ComboBox { + id: importWebdavPathInput + width: importWebdavUrlLabel.width - (importWebdavRefetchFoldersButton.width + 5) - (importWebdavFetchFoldersIndicator.visible ? importWebdavFetchFoldersIndicator.width + 5 : 0) + visible: webdavConnectionLoader.item && webdavConnectionLoader.item.availablePaths.length > 0 + enabled: !webdavConnectionLoader.item || !webdavConnectionLoader.item.isFetchingAvailablePaths + model: [''].concat(webdavConnectionLoader.item ? webdavConnectionLoader.item.availablePaths : []) + } + + QfToolButton { + id: importWebdavRefetchFoldersButton + anchors.verticalCenter: importWebdavPathInput.verticalCenter + visible: importWebdavPathInput.visible + enabled: !webdavConnectionLoader.item || !webdavConnectionLoader.item.isFetchingAvailablePaths + bgcolor: "transparent" + iconSource: Theme.getThemeVectorIcon("refresh_24dp") + iconColor: enabled ? Theme.mainTextColor : Theme.mainTextDisabledColor + + onClicked: { + webdavConnectionLoader.item.fetchAvailablePaths(); + } + } + + BusyIndicator { + id: importWebdavFetchFoldersIndicator + anchors.verticalCenter: importWebdavPathInput.verticalCenter + width: 48 + height: 48 + visible: webdavConnectionLoader.item && webdavConnectionLoader.item.isFetchingAvailablePaths + running: visible + } + } + } + + onAccepted: { + if (importWebdavPathInput.displayText !== '' && webdavConnectionLoader.item) { + webdavConnectionLoader.item.url = importWebdavUrlInput.text; + webdavConnectionLoader.item.username = importWebdavUserInput.text; + webdavConnectionLoader.item.password = importWebdavPasswordInput.text; + webdavConnectionLoader.item.storePassword = importWebdavStorePasswordCheck.checked; + webdavConnectionLoader.item.importPath(importWebdavPathInput.displayText, platformUtilities.applicationDirectory() + "Imported Projects/"); + } + } + } + Connections { target: iface diff --git a/src/qml/imports/Theme/QfTextField.qml b/src/qml/imports/Theme/QfTextField.qml index 7453406172..f5aecdbd5c 100644 --- a/src/qml/imports/Theme/QfTextField.qml +++ b/src/qml/imports/Theme/QfTextField.qml @@ -7,7 +7,7 @@ TextField { font: Theme.defaultFont placeholderTextColor: Theme.accentLightColor rightPadding: showPasswordButton.visible ? showPasswordButton.width : 0 - leftPadding: rightPadding + leftPadding: showPasswordButton.visible && horizontalAlignment !== Text.AlignLeft ? rightPadding : 0 topPadding: 10 bottomPadding: 10 inputMethodHints: Qt.ImhNone @@ -20,7 +20,7 @@ TextField { y: textField.height - height - textField.bottomPadding / 2 width: textField.width height: textField.activeFocus ? 2 : 1 - color: textField.activeFocus ? Theme.accentColor : Theme.accentLightColor + color: textField.activeFocus ? Theme.mainColor : textField.hovered ? textField.color : Theme.secondaryTextColor } } diff --git a/src/qml/qgismobileapp.qml b/src/qml/qgismobileapp.qml index 5726a68727..ecc8a0bfee 100644 --- a/src/qml/qgismobileapp.qml +++ b/src/qml/qgismobileapp.qml @@ -3883,6 +3883,36 @@ ApplicationWindow { parent: Overlay.overlay } + WelcomeScreen { + id: welcomeScreen + objectName: "welcomeScreen" + visible: !iface.hasProjectOnLaunch() + + model: RecentProjectListModel { + id: recentProjectListModel + } + property ProjectSource __projectSource + + anchors.fill: parent + + onOpenLocalDataPicker: { + qfieldLocalDataPickerScreen.projectFolderView = false; + qfieldLocalDataPickerScreen.model.resetToRoot(); + qfieldLocalDataPickerScreen.visible = true; + } + + onShowQFieldCloudScreen: { + qfieldCloudScreen.visible = true; + } + + onShowSettings: { + qfieldSettings.reset(); + qfieldSettings.visible = true; + } + + Component.onCompleted: focusstack.addFocusTaker(this) + } + QFieldCloudScreen { id: qfieldCloudScreen @@ -3892,7 +3922,6 @@ ApplicationWindow { onFinished: { visible = false; - welcomeScreen.visible = true; } Component.onCompleted: focusstack.addFocusTaker(this) @@ -3924,47 +3953,8 @@ ApplicationWindow { visible: false focus: visible - onFinished: { + onFinished: loading => { visible = false; - if (model.currentPath === 'root') { - welcomeScreen.visible = loading ? false : true; - } - } - - Component.onCompleted: focusstack.addFocusTaker(this) - } - - WelcomeScreen { - id: welcomeScreen - objectName: "welcomeScreen" - visible: !iface.hasProjectOnLaunch() - - model: RecentProjectListModel { - id: recentProjectListModel - } - property ProjectSource __projectSource - - anchors.fill: parent - - onOpenLocalDataPicker: { - if (platformUtilities.capabilities & PlatformUtilities.CustomLocalDataPicker) { - welcomeScreen.visible = false; - qfieldLocalDataPickerScreen.projectFolderView = false; - qfieldLocalDataPickerScreen.model.resetToRoot(); - qfieldLocalDataPickerScreen.visible = true; - } else { - __projectSource = platformUtilities.openProject(this); - } - } - - onShowQFieldCloudScreen: { - welcomeScreen.visible = false; - qfieldCloudScreen.visible = true; - } - - onShowSettings: { - qfieldSettings.reset(); - qfieldSettings.visible = true; } Component.onCompleted: focusstack.addFocusTaker(this) diff --git a/vcpkg.json b/vcpkg.json index c9a77539ec..dba87ba99c 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -101,6 +101,7 @@ "host": true, "default-features": false }, + "qtwebdav", { "name": "qtwebsockets", "features": [ diff --git a/vcpkg/ports/qtwebdav/portfile.cmake b/vcpkg/ports/qtwebdav/portfile.cmake new file mode 100644 index 0000000000..71bd6d8a1a --- /dev/null +++ b/vcpkg/ports/qtwebdav/portfile.cmake @@ -0,0 +1,22 @@ +vcpkg_from_github( + OUT_SOURCE_PATH SOURCE_PATH + REPO m-kuhn/QtWebDAV + REF "v${VERSION}" + SHA512 a4025a36090fb84647eae8acea83df82cdb46a5254f7cd0dcbbf9dccbf9bdeb68c4cec47ae3c34f5a67590553a7c6c05bf0e632e44ab6a6d16435a6f6b0d6a74 + HEAD_REF main +) + +list(APPEND QTWEBDAV_OPTIONS -DBUILD_WITH_QT6=True) +if(VCPKG_CROSSCOMPILING) + list(APPEND QTWEBDAV_OPTIONS -DQT_HOST_PATH=${CURRENT_HOST_INSTALLED_DIR}) + list(APPEND QTWEBDAV_OPTIONS -DQT_HOST_PATH_CMAKE_DIR:PATH=${CURRENT_HOST_INSTALLED_DIR}/share) +endif() + +vcpkg_cmake_configure( + SOURCE_PATH "${SOURCE_PATH}" + OPTIONS ${QTWEBDAV_OPTIONS} +) + +vcpkg_cmake_install() + +file(INSTALL "${SOURCE_PATH}/LICENSE" DESTINATION "${CURRENT_PACKAGES_DIR}/share/${PORT}" RENAME copyright) diff --git a/vcpkg/ports/qtwebdav/vcpkg.json b/vcpkg/ports/qtwebdav/vcpkg.json new file mode 100644 index 0000000000..869da951fa --- /dev/null +++ b/vcpkg/ports/qtwebdav/vcpkg.json @@ -0,0 +1,16 @@ +{ + "name": "qtwebdav", + "version-string": "2.1", + "description": " Qt library for WebDAV with support for HTTP/HTTPS.", + "homepage": "https://github.com/PikachuHy/QtWebDAV", + "dependencies": [ + { + "name": "qtbase", + "default-features": false + }, + { + "name": "vcpkg-cmake", + "host": true + } + ] +}