diff --git a/python/PyQt6/core/auto_generated/layertree/qgslayertreegroup.sip.in b/python/PyQt6/core/auto_generated/layertree/qgslayertreegroup.sip.in index 2ede729a59fe..6dbbe88f7f6a 100644 --- a/python/PyQt6/core/auto_generated/layertree/qgslayertreegroup.sip.in +++ b/python/PyQt6/core/auto_generated/layertree/qgslayertreegroup.sip.in @@ -254,6 +254,20 @@ Find custom node IDs. Searches recursively the whole sub-tree. %Docstring Find group node with specified name. Searches recursively the whole sub-tree. +%End + + QgsLayerTreeGroup *findGroupByPath( const QList> &path ); +%Docstring +Find a group node by navigating a hierarchical path from this node. + +Each element in the ``path`` is a pair of (group name, occurrence index) +where the occurrence index is the 0-based position among same-named +non-embedded sibling groups at each level. This allows unambiguous +identification of groups even when multiple groups share the same name. + +Returns ``None`` if the path cannot be resolved. + +.. versionadded:: 3.44 %End QList findGroups( bool recursive = false ) const; diff --git a/python/PyQt6/core/auto_generated/project/qgsproject.sip.in b/python/PyQt6/core/auto_generated/project/qgsproject.sip.in index 8ab94e661f8f..69c305803a80 100644 --- a/python/PyQt6/core/auto_generated/project/qgsproject.sip.in +++ b/python/PyQt6/core/auto_generated/project/qgsproject.sip.in @@ -793,6 +793,23 @@ Create layer group instance defined in an arbitrary project file. The optional ``flags`` argument can be used to control layer reading behavior. +%End + + std::unique_ptr< QgsLayerTreeGroup > createEmbeddedGroup( const QList> &groupPath, const QString &projectFilePath, const QStringList &invisibleLayers, Qgis::ProjectReadFlags flags = Qgis::ProjectReadFlags() ); +%Docstring +Create layer group instance defined in an arbitrary project file, +identified by a hierarchical path. + +The ``groupPath`` is a list of (group name, occurrence index) pairs that +describes the path from the root of the layer tree to the target group. +The occurrence index is the 0-based position among same-named +non-embedded sibling groups at each level. This allows unambiguous +identification of groups even when multiple groups share the same name. + +The optional ``flags`` argument can be used to control layer reading +behavior. + +.. versionadded:: 3.44 %End void setTopologicalEditing( bool enabled ); diff --git a/python/PyQt6/core/class_map.yaml b/python/PyQt6/core/class_map.yaml index c7f7fc800d27..aa64a5415699 100644 --- a/python/PyQt6/core/class_map.yaml +++ b/python/PyQt6/core/class_map.yaml @@ -6732,6 +6732,7 @@ QgsLayerTreeGroup.dump: src/core/layertree/qgslayertreegroup.h#L298 QgsLayerTreeGroup.findCustomNode: src/core/layertree/qgslayertreegroup.h#L188 QgsLayerTreeGroup.findCustomNodeIds: src/core/layertree/qgslayertreegroup.h#L260 QgsLayerTreeGroup.findGroup: src/core/layertree/qgslayertreegroup.h#L265 +QgsLayerTreeGroup.findGroupByPath: src/core/layertree/qgslayertreegroup.h#L279 QgsLayerTreeGroup.findLayer: src/core/layertree/qgslayertreegroup.h#L169 QgsLayerTreeGroup.findLayer: src/core/layertree/qgslayertreegroup.h#L174 QgsLayerTreeGroup.findLayerIds: src/core/layertree/qgslayertreegroup.h#L255 @@ -14002,6 +14003,8 @@ QgsProject.cleared: src/core/project/qgsproject.h#L1863 QgsProject.commitChanges: src/core/project/qgsproject.h#L2408 QgsProject.count: src/core/project/qgsproject.h#L1229 QgsProject.createAttachedFile: src/core/project/qgsproject.h#L1623 +QgsProject.createEmbeddedGroup: src/core/project/qgsproject.h#L765 +QgsProject.createEmbeddedGroup: src/core/project/qgsproject.h#L781 QgsProject.createEmbeddedLayer: src/core/project/qgsproject.h#L756 QgsProject.createExpressionContext: src/core/project/qgsproject.h#L1154 QgsProject.createExpressionContextScope: src/core/project/qgsproject.h#L1155 diff --git a/src/app/qgisapp.cpp b/src/app/qgisapp.cpp index 7460da91b559..0ff16c73ca2a 100644 --- a/src/app/qgisapp.cpp +++ b/src/app/qgisapp.cpp @@ -13358,7 +13358,23 @@ void QgisApp::embedLayers() QgsProjectLayerGroupDialog d( this ); if ( d.exec() == QDialog::Accepted && d.isValid() ) { - addEmbeddedItems( d.selectedProjectFile(), d.selectedGroups(), d.selectedLayerIds() ); + const QString projectFile = d.selectedProjectFile(); + const QList>> groupPaths = d.selectedGroupPaths(); + const QStringList layerIds = d.selectedLayerIds(); + + QgsCanvasRefreshBlocker refreshBlocker; + + // embed groups using paths for accurate identification + for ( const QList> &path : groupPaths ) + { + std::unique_ptr< QgsLayerTreeGroup > newGroup = QgsProject::instance()->createEmbeddedGroup( path, projectFile, QStringList() ); + if ( newGroup ) + QgsProject::instance()->layerTreeRoot()->addChildNode( newGroup.release() ); + } + + // embed layers + if ( !layerIds.isEmpty() ) + addEmbeddedItems( projectFile, QStringList(), layerIds ); } } diff --git a/src/app/qgsprojectlayergroupdialog.cpp b/src/app/qgsprojectlayergroupdialog.cpp index 4382503a3c39..3c4600ac6e7d 100644 --- a/src/app/qgsprojectlayergroupdialog.cpp +++ b/src/app/qgsprojectlayergroupdialog.cpp @@ -129,6 +129,42 @@ QStringList QgsProjectLayerGroupDialog::selectedGroups() const return groups; } +QList>> QgsProjectLayerGroupDialog::selectedGroupPaths() const +{ + QList>> paths; + const auto constSelectedIndexes = mTreeView->selectionModel()->selectedIndexes(); + for ( const QModelIndex &index : constSelectedIndexes ) + { + QgsLayerTreeNode *node = mTreeView->index2node( index ); + if ( QgsLayerTree::isGroup( node ) ) + { + QList> path; + QgsLayerTreeNode *current = node; + while ( current && current->parent() ) + { + if ( QgsLayerTree::isGroup( current ) ) + { + const QString name = QgsLayerTree::toGroup( current )->name(); + // Count how many same-named siblings appear before this node + int occurrence = 0; + QgsLayerTreeNode *parent = current->parent(); + for ( QgsLayerTreeNode *sibling : parent->children() ) + { + if ( sibling == current ) + break; + if ( QgsLayerTree::isGroup( sibling ) && QgsLayerTree::toGroup( sibling )->name() == name ) + occurrence++; + } + path.prepend( qMakePair( name, occurrence ) ); + } + current = current->parent(); + } + paths.append( path ); + } + } + return paths; +} + QStringList QgsProjectLayerGroupDialog::selectedLayerIds() const { QStringList layerIds; diff --git a/src/app/qgsprojectlayergroupdialog.h b/src/app/qgsprojectlayergroupdialog.h index 662ba846f926..3262ae92c59a 100644 --- a/src/app/qgsprojectlayergroupdialog.h +++ b/src/app/qgsprojectlayergroupdialog.h @@ -74,6 +74,19 @@ class APP_EXPORT QgsProjectLayerGroupDialog : public QDialog, private Ui::QgsPro ~QgsProjectLayerGroupDialog() override; QStringList selectedGroups() const; + + /** + * Returns the hierarchical paths of the selected groups. + * + * Each path is a list of (group name, occurrence index) pairs describing + * the navigation from the root of the layer tree to the selected group. + * The occurrence index is the 0-based position among same-named sibling + * groups at each level. + * + * \since QGIS 3.44 + */ + QList>> selectedGroupPaths() const; + QStringList selectedLayerIds() const; QStringList selectedLayerNames() const; QString selectedProjectFile() const; diff --git a/src/core/layertree/qgslayertreegroup.cpp b/src/core/layertree/qgslayertreegroup.cpp index 3fcacadb246d..fc3671567d44 100644 --- a/src/core/layertree/qgslayertreegroup.cpp +++ b/src/core/layertree/qgslayertreegroup.cpp @@ -474,6 +474,41 @@ QgsLayerTreeGroup *QgsLayerTreeGroup::findGroup( const QString &name ) return nullptr; } +QgsLayerTreeGroup *QgsLayerTreeGroup::findGroupByPath( const QList> &path ) +{ + if ( path.isEmpty() ) + return nullptr; + + QgsLayerTreeGroup *current = this; + for ( const auto &[name, occurrence] : path ) + { + int matchCount = 0; + QgsLayerTreeGroup *found = nullptr; + for ( QgsLayerTreeNode *child : std::as_const( current->mChildren ) ) + { + if ( QgsLayerTree::isGroup( child ) ) + { + QgsLayerTreeGroup *childGroup = QgsLayerTree::toGroup( child ); + if ( childGroup->customProperty( u"embedded"_s ).toInt() ) + continue; + if ( childGroup->name() == name ) + { + if ( matchCount == occurrence ) + { + found = childGroup; + break; + } + matchCount++; + } + } + } + if ( !found ) + return nullptr; + current = found; + } + return current; +} + QList QgsLayerTreeGroup::findGroups( bool recursive ) const { QList list; diff --git a/src/core/layertree/qgslayertreegroup.h b/src/core/layertree/qgslayertreegroup.h index 9b8e45e91e55..30b855d46ceb 100644 --- a/src/core/layertree/qgslayertreegroup.h +++ b/src/core/layertree/qgslayertreegroup.h @@ -264,6 +264,20 @@ class CORE_EXPORT QgsLayerTreeGroup : public QgsLayerTreeNode */ QgsLayerTreeGroup *findGroup( const QString &name ); + /** + * Find a group node by navigating a hierarchical path from this node. + * + * Each element in the \a path is a pair of (group name, occurrence index) where + * the occurrence index is the 0-based position among same-named non-embedded + * sibling groups at each level. This allows unambiguous identification of groups + * even when multiple groups share the same name. + * + * Returns NULLPTR if the path cannot be resolved. + * + * \since QGIS 3.44 + */ + QgsLayerTreeGroup *findGroupByPath( const QList> &path ); + /** * Find group layer nodes. Searches recursively the whole sub-tree, if recursive is set. */ diff --git a/src/core/project/qgsproject.cpp b/src/core/project/qgsproject.cpp index 18b3dc77ad55..d34919e521ac 100644 --- a/src/core/project/qgsproject.cpp +++ b/src/core/project/qgsproject.cpp @@ -4207,6 +4207,82 @@ std::unique_ptr QgsProject::createEmbeddedGroup( const QStrin return newGroup; } +std::unique_ptr QgsProject::createEmbeddedGroup( const QList> &groupPath, const QString &projectFilePath, const QStringList &invisibleLayers, Qgis::ProjectReadFlags flags ) +{ + QGIS_PROTECT_QOBJECT_THREAD_ACCESS + + QString qgsProjectFile = projectFilePath; + QgsProjectArchive archive; + if ( projectFilePath.endsWith( ".qgz"_L1, Qt::CaseInsensitive ) ) + { + archive.unzip( projectFilePath ); + qgsProjectFile = archive.projectFile(); + } + + // open project file, get layer ids in group, add the layers + QFile projectFile( qgsProjectFile ); + if ( !projectFile.open( QIODevice::ReadOnly ) ) + { + return nullptr; + } + + QDomDocument projectDocument; + if ( !projectDocument.setContent( &projectFile ) ) + { + return nullptr; + } + + QgsReadWriteContext context; + context.setPathResolver( pathResolver() ); + context.setProjectTranslator( this ); + context.setTransformContext( transformContext() ); + + auto root = std::make_unique< QgsLayerTreeGroup >(); + + QDomElement layerTreeElem = projectDocument.documentElement().firstChildElement( u"layer-tree-group"_s ); + if ( !layerTreeElem.isNull() ) + { + root->readChildrenFromXml( layerTreeElem, context ); + } + else + { + QgsLayerTreeUtils::readOldLegend( root.get(), projectDocument.documentElement().firstChildElement( u"legend"_s ) ); + } + + QgsLayerTreeGroup *group = root->findGroupByPath( groupPath ); + if ( !group || group->customProperty( u"embedded"_s ).toBool() ) + { + // embedded groups cannot be embedded again + return nullptr; + } + + // clone the group sub-tree (it is used already in a tree, we cannot just tear it off) + std::unique_ptr< QgsLayerTreeGroup > newGroup( QgsLayerTree::toGroup( group->clone() ) ); + root.reset(); + + newGroup->setCustomProperty( u"embedded"_s, 1 ); + newGroup->setCustomProperty( u"embedded_project"_s, projectFilePath ); + + // set "embedded" to all children + load embedded layers + mLayerTreeRegistryBridge->setEnabled( false ); + initializeEmbeddedSubtree( projectFilePath, newGroup.get(), flags ); + mLayerTreeRegistryBridge->setEnabled( true ); + + // consider the layers might be identify disabled in its project + const QStringList constFindLayerIds = newGroup->findLayerIds(); + for ( const QString &layerId : constFindLayerIds ) + { + QgsLayerTreeLayer *layer = newGroup->findLayer( layerId ); + if ( layer ) + { + layer->resolveReferences( this ); + layer->setItemVisibilityChecked( !invisibleLayers.contains( layerId ) ); + } + } + + return newGroup; +} + void QgsProject::initializeEmbeddedSubtree( const QString &projectFilePath, QgsLayerTreeGroup *group, Qgis::ProjectReadFlags flags ) { QGIS_PROTECT_QOBJECT_THREAD_ACCESS diff --git a/src/core/project/qgsproject.h b/src/core/project/qgsproject.h index e3c50c599130..204b0e48b9f2 100644 --- a/src/core/project/qgsproject.h +++ b/src/core/project/qgsproject.h @@ -764,6 +764,22 @@ class CORE_EXPORT QgsProject : public QObject, public QgsExpressionContextGenera */ std::unique_ptr< QgsLayerTreeGroup > createEmbeddedGroup( const QString &groupName, const QString &projectFilePath, const QStringList &invisibleLayers, Qgis::ProjectReadFlags flags = Qgis::ProjectReadFlags() ); + /** + * Create layer group instance defined in an arbitrary project file, + * identified by a hierarchical path. + * + * The \a groupPath is a list of (group name, occurrence index) pairs that + * describes the path from the root of the layer tree to the target group. + * The occurrence index is the 0-based position among same-named non-embedded + * sibling groups at each level. This allows unambiguous identification of + * groups even when multiple groups share the same name. + * + * The optional \a flags argument can be used to control layer reading behavior. + * + * \since QGIS 3.44 + */ + std::unique_ptr< QgsLayerTreeGroup > createEmbeddedGroup( const QList> &groupPath, const QString &projectFilePath, const QStringList &invisibleLayers, Qgis::ProjectReadFlags flags = Qgis::ProjectReadFlags() ); + //! Convenience function to set topological editing void setTopologicalEditing( bool enabled ); diff --git a/tests/src/core/testqgslayertree.cpp b/tests/src/core/testqgslayertree.cpp index 228672ad86f1..7648bd9695b3 100644 --- a/tests/src/core/testqgslayertree.cpp +++ b/tests/src/core/testqgslayertree.cpp @@ -67,6 +67,7 @@ class TestQgsLayerTree : public QObject void testLayerDeleted(); void testFindGroups(); void testFindNestedGroups(); + void testFindGroupByPath(); void testCustomNodes(); void testCustomNodeOrderAndFinding(); void testCustomNodeDeleted(); @@ -835,6 +836,51 @@ void TestQgsLayerTree::testFindNestedGroups() QVERIFY( all.contains( group3 ) ); } +void TestQgsLayerTree::testFindGroupByPath() +{ + // root + // ├─ A + // │ ├─ B + // │ └─ B (second occurrence) + // └─ A (second occurrence) + // └─ C + const QgsProject project; + QgsLayerTreeGroup *a0 = project.layerTreeRoot()->addGroup( u"A"_s ); + QgsLayerTreeGroup *b0 = a0->addGroup( u"B"_s ); + QgsLayerTreeGroup *b1 = a0->addGroup( u"B"_s ); + QgsLayerTreeGroup *a1 = project.layerTreeRoot()->addGroup( u"A"_s ); + QgsLayerTreeGroup *c = a1->addGroup( u"C"_s ); + + // single-level path + QList> path; + path << qMakePair( u"A"_s, 0 ); + QCOMPARE( project.layerTreeRoot()->findGroupByPath( path ), a0 ); + + path.clear(); + path << qMakePair( u"A"_s, 1 ); + QCOMPARE( project.layerTreeRoot()->findGroupByPath( path ), a1 ); + + // multi-level path with same-named groups + path.clear(); + path << qMakePair( u"A"_s, 0 ) << qMakePair( u"B"_s, 1 ); + QCOMPARE( project.layerTreeRoot()->findGroupByPath( path ), b1 ); + + path.clear(); + path << qMakePair( u"A"_s, 1 ) << qMakePair( u"C"_s, 0 ); + QCOMPARE( project.layerTreeRoot()->findGroupByPath( path ), c ); + + // invalid paths + QVERIFY( !project.layerTreeRoot()->findGroupByPath( {} ) ); + + path.clear(); + path << qMakePair( u"X"_s, 0 ); + QVERIFY( !project.layerTreeRoot()->findGroupByPath( path ) ); + + path.clear(); + path << qMakePair( u"A"_s, 2 ); // only two A's + QVERIFY( !project.layerTreeRoot()->findGroupByPath( path ) ); +} + void TestQgsLayerTree::testCustomNodes() { auto custom = std::make_unique< QgsLayerTreeCustomNode >( u"custom-id"_s ); diff --git a/tests/src/core/testqgsproject.cpp b/tests/src/core/testqgsproject.cpp index bb5ff4c4429f..16546e664f59 100644 --- a/tests/src/core/testqgsproject.cpp +++ b/tests/src/core/testqgsproject.cpp @@ -68,6 +68,7 @@ class TestQgsProject : public QObject void testAttachmentsQgz(); void testAttachmentIdentifier(); void testEmbeddedGroupWithJoins(); + void testEmbeddedGroupByPath(); void testAsynchronousLayerLoading(); void testSymlinks1LayerRasterChange(); void testSymlinks2LayerFolder(); @@ -997,6 +998,43 @@ void TestQgsProject::testEmbeddedGroupWithJoins() QCOMPARE( vl->fields().count(), 5 ); } +void TestQgsProject::testEmbeddedGroupByPath() +{ + const QString dataDir( TEST_DATA_DIR ); + const QString layerPath = dataDir + u"/points.shp"_s; + + const QTemporaryDir dir; + QVERIFY( dir.isValid() ); + const QString dirPath = QFileInfo( dir.path() ).canonicalFilePath(); + const QString projectFilename = dirPath + u"/project.qgs"_s; + + // build source project with two same-named groups + // ├─ MyGroup (layers: points 1) + // └─ MyGroup (layers: points 2) + QgsVectorLayer *layer1 = new QgsVectorLayer( layerPath, u"points 1"_s, u"ogr"_s ); + QgsVectorLayer *layer2 = new QgsVectorLayer( layerPath, u"points 2"_s, u"ogr"_s ); + QVERIFY( layer1->isValid() ); + + QgsProject source; + source.addMapLayers( { layer1, layer2 }, false ); + QgsLayerTreeGroup *grp0 = source.layerTreeRoot()->addGroup( u"MyGroup"_s ); + grp0->addLayer( layer1 ); + QgsLayerTreeGroup *grp1 = source.layerTreeRoot()->addGroup( u"MyGroup"_s ); + grp1->addLayer( layer2 ); + source.write( projectFilename ); + + // embed the second "MyGroup" using path + QList> path; + path << qMakePair( u"MyGroup"_s, 1 ); + + QgsProject target; + std::unique_ptr< QgsLayerTreeGroup > embedded = target.createEmbeddedGroup( path, projectFilename, QStringList() ); + QVERIFY( embedded ); + QCOMPARE( embedded->name(), u"MyGroup"_s ); + QCOMPARE( embedded->children().size(), 1 ); + QVERIFY( QgsLayerTree::toLayer( embedded->children().at( 0 ) )->layer() ); +} + void TestQgsProject::testAsynchronousLayerLoading() { auto project = std::make_unique();