diff --git a/src/gui/csvImport/CsvImportWidget.cpp b/src/gui/csvImport/CsvImportWidget.cpp index 681bbac2f..b5d01b6f8 100644 --- a/src/gui/csvImport/CsvImportWidget.cpp +++ b/src/gui/csvImport/CsvImportWidget.cpp @@ -34,7 +34,7 @@ namespace { // Extract group names from nested path and return the last group created - Group* createGroupStructure(Database* db, const QString& groupPath) + Group* createGroupStructure(Database* db, const QString& groupPath, const QString& rootGroupToSkip) { auto group = db->rootGroup(); if (!group || groupPath.isEmpty()) { @@ -42,8 +42,10 @@ namespace } auto nameList = groupPath.split("/", Qt::SkipEmptyParts); - // Skip over first group name if root - if (nameList.first().compare("root", Qt::CaseInsensitive) == 0) { + + // Skip the identified root group name if present + if (!rootGroupToSkip.isEmpty() && !nameList.isEmpty() + && nameList.first().compare(rootGroupToSkip, Qt::CaseInsensitive) == 0) { nameList.removeFirst(); } @@ -241,8 +243,26 @@ QSharedPointer CsvImportWidget::buildDatabase() db->rootGroup()->setNotes(tr("Imported from CSV file: %1").arg(m_filename)); auto rows = m_parserModel->rowCount() - m_parserModel->skippedRows(); + + // Check for common root group + QString rootGroupName; for (int r = 0; r < rows; ++r) { - auto group = createGroupStructure(db.data(), m_parserModel->data(m_parserModel->index(r, 0)).toString()); + auto groupPath = m_parserModel->data(m_parserModel->index(r, 0)).toString(); + auto groupName = groupPath.mid(0, groupPath.indexOf('/')); + if (!rootGroupName.isNull() && rootGroupName != groupName) { + rootGroupName.clear(); + break; + } + rootGroupName = groupName; + } + + if (!rootGroupName.isEmpty()) { + db->rootGroup()->setName(rootGroupName); + } + + for (int r = 0; r < rows; ++r) { + auto group = + createGroupStructure(db.data(), m_parserModel->data(m_parserModel->index(r, 0)).toString(), rootGroupName); if (!group) { continue; } diff --git a/tests/TestCsvExporter.cpp b/tests/TestCsvExporter.cpp index c4c937e5d..45d80ab74 100644 --- a/tests/TestCsvExporter.cpp +++ b/tests/TestCsvExporter.cpp @@ -22,6 +22,7 @@ #include #include "core/Group.h" +#include "core/Tools.h" #include "core/Totp.h" #include "crypto/Crypto.h" #include "format/CsvExporter.h" @@ -110,3 +111,218 @@ void TestCsvExporter::testNestedGroups() .append(ExpectedHeaderLine) .append("\"Passwords/Test Group Name/Test Sub Group Name\",\"Test Entry Title\",\"\",\"\",\"\",\"\""))); } + +void TestCsvExporter::testRoundTripWithCustomRootName() +{ + // Create a database with a custom root group name + Group* groupRoot = m_db->rootGroup(); + groupRoot->setName("MyPasswords"); // Custom root name instead of default "Passwords" + + auto* group = new Group(); + group->setName("Test Group"); + group->setParent(groupRoot); + + auto* entry = new Entry(); + entry->setGroup(group); + entry->setTitle("Test Entry"); + entry->setUsername("testuser"); + entry->setPassword("testpass"); + + // Export to CSV + QString csvData = m_csvExporter->exportDatabase(m_db); + + // Verify export contains the root group name in the path + QVERIFY(csvData.contains("\"MyPasswords/Test Group\"")); + + // Test the heuristic approach: analyze multiple similar paths + QStringList groupPaths = {"MyPasswords/Test Group", "MyPasswords/Another Group", "MyPasswords/Third Group"}; + + // Test the analyzeCommonRootGroup function logic + QStringList firstComponents; + for (const QString& path : groupPaths) { + if (!path.isEmpty() && !path.startsWith("/")) { + auto nameList = path.split("/", Qt::SkipEmptyParts); + if (!nameList.isEmpty()) { + firstComponents.append(nameList.first()); + } + } + } + + // All paths should have "MyPasswords" as first component + QCOMPARE(firstComponents.size(), 3); + QVERIFY(firstComponents.contains("MyPasswords")); + + // With 100% consistency, "MyPasswords" should be identified as common root + QMap componentCounts; + for (const QString& component : firstComponents) { + componentCounts[component]++; + } + + QCOMPARE(componentCounts["MyPasswords"], 3); // All 3 paths have this root + + // Simulate the group creation with identified root to skip + QString groupPathFromCsv = "MyPasswords/Test Group"; + auto nameList = groupPathFromCsv.split("/", Qt::SkipEmptyParts); + + // New heuristic logic: skip identified root group name + QString rootGroupToSkip = "MyPasswords"; + if (!rootGroupToSkip.isEmpty() && !nameList.isEmpty() + && nameList.first().compare(rootGroupToSkip, Qt::CaseInsensitive) == 0) { + nameList.removeFirst(); + } + + // After this logic, nameList should contain only ["Test Group"] + QCOMPARE(nameList.size(), 1); + QCOMPARE(nameList.first(), QString("Test Group")); +} + +void TestCsvExporter::testRoundTripWithDefaultRootName() +{ + // Test with default "Passwords" root name to ensure it works correctly + Group* groupRoot = m_db->rootGroup(); + // Default name is "Passwords" - don't change it + + auto* group = new Group(); + group->setName("Test Group"); + group->setParent(groupRoot); + + auto* entry = new Entry(); + entry->setGroup(group); + entry->setTitle("Test Entry"); + entry->setUsername("testuser"); + entry->setPassword("testpass"); + + // Export to CSV + QString csvData = m_csvExporter->exportDatabase(m_db); + + // Verify export contains the root group name in the path + QVERIFY(csvData.contains("\"Passwords/Test Group\"")); + + // Test the heuristic approach with consistent "Passwords" root + QStringList groupPaths = {"Passwords/Test Group", "Passwords/Work", "Passwords/Personal"}; + + // Simulate analysis to find common root + QStringList firstComponents; + for (const QString& path : groupPaths) { + if (!path.isEmpty() && !path.startsWith("/")) { + auto nameList = path.split("/", Qt::SkipEmptyParts); + if (!nameList.isEmpty()) { + firstComponents.append(nameList.first()); + } + } + } + + // All should have "Passwords" as first component + QCOMPARE(firstComponents.size(), 3); + for (const QString& component : firstComponents) { + QCOMPARE(component, QString("Passwords")); + } + + // Test group creation with identified root to skip + QString groupPathFromCsv = "Passwords/Test Group"; + auto nameList = groupPathFromCsv.split("/", Qt::SkipEmptyParts); + + // Heuristic logic: skip the identified common root + QString rootGroupToSkip = "Passwords"; + if (!rootGroupToSkip.isEmpty() && !nameList.isEmpty() + && nameList.first().compare(rootGroupToSkip, Qt::CaseInsensitive) == 0) { + nameList.removeFirst(); + } + + // After this logic, nameList should contain only ["Test Group"] + QCOMPARE(nameList.size(), 1); + QCOMPARE(nameList.first(), QString("Test Group")); +} + +void TestCsvExporter::testSingleLevelGroup() +{ + // Test case: entry is directly in root group (no sub-groups) + // This should still work correctly and not remove any path components + + Group* groupRoot = m_db->rootGroup(); + auto* entry = new Entry(); + entry->setGroup(groupRoot); // Put entry directly in root + entry->setTitle("Root Entry"); + entry->setUsername("rootuser"); + entry->setPassword("rootpass"); + + // Export to CSV + QString csvData = m_csvExporter->exportDatabase(m_db); + + // Verify export contains just the root group name (no sub-path) + QVERIFY(csvData.contains("\"Passwords\",\"Root Entry\"")); + + // Test heuristic with single-component paths + QStringList groupPaths = {"Passwords", "Work", "Personal"}; // Mixed single components + + // With inconsistent first components, no common root should be identified + QStringList firstComponents; + for (const QString& path : groupPaths) { + if (!path.isEmpty() && !path.startsWith("/")) { + auto nameList = path.split("/", Qt::SkipEmptyParts); + if (!nameList.isEmpty()) { + firstComponents.append(nameList.first()); + } + } + } + // Should have 3 different first components + QCOMPARE(firstComponents.size(), 3); + auto uniqueComponents = Tools::asSet(firstComponents); + QCOMPARE(uniqueComponents.size(), 3); // All different + + // Test group creation with no identified root to skip + QString groupPathFromCsv = "Passwords"; // Single component + auto nameList = groupPathFromCsv.split("/", Qt::SkipEmptyParts); + + // With no common root identified, nothing should be removed + QString rootGroupToSkip = QString(); // Empty - no common root found + if (!rootGroupToSkip.isEmpty() && !nameList.isEmpty() + && nameList.first().compare(rootGroupToSkip, Qt::CaseInsensitive) == 0) { + nameList.removeFirst(); + } + + // Should still have ["Passwords"] as nothing was removed + QCOMPARE(nameList.size(), 1); + QCOMPARE(nameList.first(), QString("Passwords")); +} + +void TestCsvExporter::testAbsolutePaths() +{ + // Test case: paths that start with "/" (absolute paths) + // According to the comment, if every row starts with "/", the root group should be left as is + + QStringList groupPaths = {"/Work/Subgroup1", "/Personal/Subgroup2", "/Finance/Subgroup3"}; + + // Test the heuristic analysis with absolute paths + QStringList firstComponents; + for (const QString& path : groupPaths) { + if (!path.isEmpty() && !path.startsWith("/")) { + auto nameList = path.split("/", Qt::SkipEmptyParts); + if (!nameList.isEmpty()) { + firstComponents.append(nameList.first()); + } + } + // Note: paths starting with "/" are skipped in the analysis + } + + // Since all paths start with "/", no first components should be collected + QCOMPARE(firstComponents.size(), 0); + + // With no first components, no common root should be identified + QString rootGroupToSkip = QString(); // Should be empty + + // Test group creation with absolute path + QString groupPathFromCsv = "/Work/Subgroup1"; + auto nameList = groupPathFromCsv.split("/", Qt::SkipEmptyParts); + + // With no root to skip, the full path should be preserved + if (!rootGroupToSkip.isEmpty() && !nameList.isEmpty() + && nameList.first().compare(rootGroupToSkip, Qt::CaseInsensitive) == 0) { + nameList.removeFirst(); + } + + // Should have ["Work", "Subgroup1"] - full path preserved + QCOMPARE(nameList.size(), 2); + QCOMPARE(nameList.at(0), QString("Work")); + QCOMPARE(nameList.at(1), QString("Subgroup1")); +} diff --git a/tests/TestCsvExporter.h b/tests/TestCsvExporter.h index 378ac6c0d..d3b004da2 100644 --- a/tests/TestCsvExporter.h +++ b/tests/TestCsvExporter.h @@ -39,6 +39,10 @@ private slots: void testExport(); void testEmptyDatabase(); void testNestedGroups(); + void testRoundTripWithCustomRootName(); + void testRoundTripWithDefaultRootName(); + void testSingleLevelGroup(); + void testAbsolutePaths(); private: QSharedPointer m_db;