diff --git a/src/core/qfieldcloud/qfieldcloudconnection.h b/src/core/qfieldcloud/qfieldcloudconnection.h index 1de2573ddb..e27fb978e5 100644 --- a/src/core/qfieldcloud/qfieldcloudconnection.h +++ b/src/core/qfieldcloud/qfieldcloudconnection.h @@ -231,6 +231,7 @@ class QFieldCloudConnection : public QObject void providerConfigurationChanged(); void userInformationChanged(); void pendingAttachmentsUploadFinished(); + void pendingAttachmentsAdded(); void error(); void loginFailed( const QString &reason ); diff --git a/src/core/utils/qfieldcloudutils.cpp b/src/core/utils/qfieldcloudutils.cpp index fe704fbc3c..27d28d22fd 100644 --- a/src/core/utils/qfieldcloudutils.cpp +++ b/src/core/utils/qfieldcloudutils.cpp @@ -40,6 +40,11 @@ const QString QFieldCloudUtils::localCloudDirectory() QString cloudDirectoryPath = sLocalCloudDirectory.isNull() ? PlatformUtilities::instance()->systemLocalDataLocation( QStringLiteral( "cloud_projects" ) ) : sLocalCloudDirectory; + // Remove trailing '/' or '\' if present + while ( !cloudDirectoryPath.isEmpty() && ( cloudDirectoryPath.endsWith( '/' ) || cloudDirectoryPath.endsWith( '\\' ) ) ) + { + cloudDirectoryPath.chop( 1 ); + } return cloudDirectoryPath; } @@ -68,13 +73,27 @@ bool QFieldCloudUtils::isCloudAction( const QgsMapLayer *layer ) const QString QFieldCloudUtils::getProjectId( const QString &fileName ) { - QFileInfo fi( fileName ); - QDir baseDir = fi.isDir() ? fi.canonicalFilePath() : fi.canonicalPath(); - QString basePath = QFileInfo( baseDir.path() ).canonicalFilePath(); - QString cloudPath = QFileInfo( localCloudDirectory() ).canonicalFilePath(); + if ( fileName.isEmpty() ) + return QString(); + + const QString path = QFileInfo( fileName ).canonicalFilePath(); + if ( path.isEmpty() ) + return QString(); - if ( !cloudPath.isEmpty() && basePath.startsWith( cloudPath ) ) - return baseDir.dirName(); + const QString cloudPath = QFieldCloudUtils::localCloudDirectory(); + if ( cloudPath.isEmpty() || !path.startsWith( cloudPath ) ) + return QString(); + + const QRegularExpression re( + QStringLiteral( "^%1[/\\\\][^/\\\\]+[/\\\\]([^/\\\\]+)" ) + .arg( QRegularExpression::escape( cloudPath ) ) ); + const QRegularExpressionMatch match = re.match( path, 0, + QRegularExpression::NormalMatch, QRegularExpression::AnchorAtOffsetMatchOption ); + + if ( match.hasMatch() ) + { + return match.captured( 1 ); + } return QString(); } @@ -233,7 +252,7 @@ void QFieldCloudUtils::addPendingAttachments( const QString &username, const QSt params.insert( "skip_metadata", 1 ); NetworkReply *reply = cloudConnection->get( QStringLiteral( "/api/v1/files/%1/" ).arg( projectId ), params ); - connect( reply, &NetworkReply::finished, reply, [reply, username, projectId, fileNames, checkSumCheck]() { + connect( reply, &NetworkReply::finished, reply, [reply, username, projectId, fileNames, checkSumCheck, cloudConnection]() { QNetworkReply *rawReply = reply->currentRawReply(); reply->deleteLater(); @@ -254,16 +273,16 @@ void QFieldCloudUtils::addPendingAttachments( const QString &username, const QSt fileChecksumMap.insert( fileName, cloudEtag ); } - QFieldCloudUtils::writeToAttachmentsFile( username, projectId, fileNames, &fileChecksumMap, checkSumCheck ); + writeToAttachmentsFile( username, projectId, fileNames, &fileChecksumMap, checkSumCheck, cloudConnection ); } ); } else { - writeToAttachmentsFile( username, projectId, fileNames, nullptr, false ); + writeToAttachmentsFile( username, projectId, fileNames, nullptr, false, cloudConnection ); } } -void QFieldCloudUtils::writeToAttachmentsFile( const QString &username, const QString &projectId, const QStringList &fileNames, const QHash *fileChecksumMap, const bool &checkSumCheck ) +void QFieldCloudUtils::writeToAttachmentsFile( const QString &username, const QString &projectId, const QStringList &fileNames, const QHash *fileChecksumMap, const bool &checkSumCheck, QFieldCloudConnection *cloudConnection ) { const QString localCloudUSerDirectory = QLatin1String( "%1/%2/" ).arg( QFieldCloudUtils::localCloudDirectory(), username ); QLockFile attachmentsLock( QStringLiteral( "%1/attachments.lock" ).arg( localCloudUSerDirectory ) ); @@ -285,8 +304,10 @@ void QFieldCloudUtils::writeToAttachmentsFile( const QString &username, const QS writeFileDetails( fileName, projectId, fileChecksumMap, checkSumCheck, attachmentsStream ); } } - attachmentsFile.close(); + + if ( cloudConnection ) + emit cloudConnection->pendingAttachmentsAdded(); } } diff --git a/src/core/utils/qfieldcloudutils.h b/src/core/utils/qfieldcloudutils.h index 2e88f1ae8c..ea653338c2 100644 --- a/src/core/utils/qfieldcloudutils.h +++ b/src/core/utils/qfieldcloudutils.h @@ -90,6 +90,7 @@ class QFieldCloudUtils : public QObject /** * Returns the path to the local cloud directory. * By default inside the user profile unless overwritten with setLocalCloudDirectory + * \note The returned path will never have have a trailing '/' or '\' . */ static const QString localCloudDirectory(); @@ -107,10 +108,14 @@ class QFieldCloudUtils : public QObject static bool isCloudAction( const QgsMapLayer *layer ); /** - * Returns the cloud project id. + * Returns the cloud project ID for a given file path. * - * @param fileName file name of the project to be checked - * @return const QString either UUID-like string or a null string in case of failure + * This function checks if the given file path is under the QField local cloud + * directory. If it is, it extracts and returns the project ID (UUID-like string) + * from the path. Otherwise, it returns a null QString. + * + * @param fileName Full path to a file or directory inside a cloud project + * @return QString Project ID if found; otherwise, an empty string */ Q_INVOKABLE static const QString getProjectId( const QString &fileName ); @@ -159,7 +164,7 @@ class QFieldCloudUtils : public QObject private: static inline const QString errorCodeOverQuota { QStringLiteral( "over_quota" ) }; - static void writeToAttachmentsFile( const QString &username, const QString &projectId, const QStringList &fileNames, const QHash *fileChecksumMap, const bool &checkSumCheck ); + static void writeToAttachmentsFile( const QString &username, const QString &projectId, const QStringList &fileNames, const QHash *fileChecksumMap, const bool &checkSumCheck, QFieldCloudConnection *cloudConnection = nullptr ); static void writeFilesFromDirectory( const QString &dirPath, const QString &projectId, const QHash *fileChecksumMap, const bool &checkSumCheck, QTextStream &attachmentsStream ); diff --git a/src/qml/QFieldCloudScreen.qml b/src/qml/QFieldCloudScreen.qml index 3cf04d16c1..3481215283 100644 --- a/src/qml/QFieldCloudScreen.qml +++ b/src/qml/QFieldCloudScreen.qml @@ -12,6 +12,7 @@ Page { id: qfieldCloudScreen signal finished + signal viewProjectFolder(string projectPath) property LayerObserver layerObserver property string requestedProjectDetails: "" @@ -471,6 +472,7 @@ Page { projectActions.projectLocalPath = LocalPath; downloadProject.visible = LocalPath === '' && Status !== QFieldCloudProject.ProjectStatus.Downloading; openProject.visible = LocalPath !== ''; + viewProjectFolder.visible = LocalPath !== ''; removeProject.visible = LocalPath !== ''; cancelDownloadProject.visible = Status === QFieldCloudProject.ProjectStatus.Downloading; projectActions.popup(gc.x + width - projectActions.width, gc.y - height); @@ -942,6 +944,20 @@ Page { } } + MenuItem { + id: viewProjectFolder + + font: Theme.defaultFont + width: parent.width + height: visible ? 48 : 0 + leftPadding: Theme.menuItemLeftPadding + + text: qsTr("View Project Folder") + onTriggered: { + qfieldCloudScreen.viewProjectFolder(projectActions.projectLocalPath); + } + } + MenuItem { id: removeProject @@ -952,12 +968,7 @@ Page { text: qsTr("Remove Stored Project") onTriggered: { - cloudProjectsModel.removeLocalProject(projectActions.projectId); - iface.removeRecentProject(projectActions.projectLocalPath); - welcomeScreen.model.reloadModel(); - if (projectActions.projectLocalPath === qgisProject.fileName) { - iface.clearProject(); - } + confirmRemoveDialog.open(); } } @@ -974,6 +985,28 @@ Page { } } + QfDialog { + id: confirmRemoveDialog + parent: mainWindow.contentItem + title: removeProject.text + Label { + width: parent.width + wrapMode: Text.WordWrap + text: qsTr("Are you sure you want to remove `%1`?").arg(projectActions.projectName) + } + onAccepted: { + cloudProjectsModel.removeLocalProject(projectActions.projectId); + iface.removeRecentProject(projectActions.projectLocalPath); + welcomeScreen.model.reloadModel(); + if (projectActions.projectLocalPath === qgisProject.fileName) { + iface.clearProject(); + } + } + onRejected: { + visible = false; + } + } + Connections { id: codeReaderConnection target: codeReader diff --git a/src/qml/QFieldLocalDataPickerScreen.qml b/src/qml/QFieldLocalDataPickerScreen.qml index 924de73780..83cd7fb67b 100644 --- a/src/qml/QFieldLocalDataPickerScreen.qml +++ b/src/qml/QFieldLocalDataPickerScreen.qml @@ -445,6 +445,23 @@ Page { bottomMargin: sceneBottomMargin paddingMultiplier: 2 + MenuItem { + id: viewFile + + enabled: itemMenu.itemMetaType != LocalFilesModel.Folder + visible: enabled + + font: Theme.defaultFont + width: parent.width + height: enabled ? 48 : 0 + leftPadding: Theme.menuItemLeftPadding + + text: qsTr("View file") + onTriggered: { + platformUtilities.open(itemMenu.itemPath); + } + } + // File items MenuItem { id: sendDatasetTo @@ -464,7 +481,7 @@ Page { MenuItem { id: pushDatasetToCloud - enabled: (itemMenu.itemMetaType == LocalFilesModel.Dataset && itemMenu.itemType == LocalFilesModel.RasterDataset && cloudProjectsModel.currentProjectId) || (itemMenu.itemMetaType == LocalFilesModel.Folder && itemMenu.itemWithinQFieldCloudProjectFolder) + enabled: (itemMenu.itemMetaType == LocalFilesModel.File) || (itemMenu.itemMetaType == LocalFilesModel.Dataset && itemMenu.itemType == LocalFilesModel.RasterDataset && cloudProjectsModel.currentProjectId) || (itemMenu.itemMetaType == LocalFilesModel.Folder && itemMenu.itemWithinQFieldCloudProjectFolder) visible: enabled font: Theme.defaultFont @@ -474,9 +491,9 @@ Page { text: qsTr("Push to QFieldCloud") onTriggered: { - QFieldCloudUtils.addPendingAttachments(projectInfo.cloudUserInformation.username, cloudProjectsModel.currentProjectId, [itemMenu.itemPath], cloudConnection, true); - platformUtilities.uploadPendingAttachments(cloudConnection); - displayToast(qsTr("‘%1’ is being uploaded to QFieldCloud").arg(FileUtils.fileName(itemMenu.itemPath))); + pushFilesToQFieldCloudConnection.enabled = true; + pushFilesToQFieldCloudConnection.sendingMultiple = true; + QFieldCloudUtils.addPendingAttachments(cloudConnection.userInformation.username, QFieldCloudUtils.getProjectId(table.model.currentPath), [itemMenu.itemPath], cloudConnection, true); } } @@ -862,9 +879,8 @@ Page { } } if (fileNames.length > 0) { - QFieldCloudUtils.addPendingAttachments(projectInfo.cloudUserInformation.username, cloudProjectsModel.currentProjectId, fileNames, cloudConnection, true); - platformUtilities.uploadPendingAttachments(cloudConnection); - localFilesModel.clearSelection(); + pushFilesToQFieldCloudConnection.enabled = true; + QFieldCloudUtils.addPendingAttachments(cloudConnection.userInformation.username, QFieldCloudUtils.getProjectId(table.model.currentPath), fileNames, cloudConnection, true); } else { displayToast(qsTr("Please select one or more files to push to QFieldCloud.")); } @@ -873,6 +889,26 @@ Page { } } + Connections { + id: pushFilesToQFieldCloudConnection + enabled: false + target: cloudConnection + + property bool sendingMultiple: false + + function onPendingAttachmentsAdded() { + platformUtilities.uploadPendingAttachments(cloudConnection); + if (pushFilesToQFieldCloudConnection.sendingMultiple) { + displayToast(qsTr("‘%1’ is being uploaded to QFieldCloud").arg(FileUtils.fileName(itemMenu.itemPath))); + pushFilesToQFieldCloudConnection.sendingMultiple = false; + } else { + localFilesModel.clearSelection(); + displayToast(qsTr("Items being uploaded to QFieldCloud")); + } + pushFilesToQFieldCloudConnection.enabled = false; + } + } + QfDialog { id: importUrlDialog title: qsTr("Import URL") diff --git a/src/qml/qgismobileapp.qml b/src/qml/qgismobileapp.qml index 8620ca9678..dbc40bc40c 100644 --- a/src/qml/qgismobileapp.qml +++ b/src/qml/qgismobileapp.qml @@ -4359,6 +4359,12 @@ ApplicationWindow { } Component.onCompleted: focusstack.addFocusTaker(this) + + onViewProjectFolder: projectPath => { + qfieldLocalDataPickerScreen.projectFolderView = true; + qfieldLocalDataPickerScreen.model.resetToPath(projectPath); + qfieldLocalDataPickerScreen.visible = true; + } } QFieldCloudPopup {