Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions src/gui/csvImport/CsvImportWidget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,18 @@
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()) {
return group;
}

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();
}

Expand Down Expand Up @@ -241,8 +243,26 @@ QSharedPointer<Database> 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 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());
auto group =
createGroupStructure(db.data(), m_parserModel->data(m_parserModel->index(r, 0)).toString(), rootGroupName);
if (!group) {
continue;
}
Expand Down
216 changes: 216 additions & 0 deletions tests/TestCsvExporter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
#include <QTest>

#include "core/Group.h"
#include "core/Tools.h"
#include "core/Totp.h"
#include "crypto/Crypto.h"
#include "format/CsvExporter.h"
Expand Down Expand Up @@ -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<QString, int> 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"));
}
4 changes: 4 additions & 0 deletions tests/TestCsvExporter.h
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ private slots:
void testExport();
void testEmptyDatabase();
void testNestedGroups();
void testRoundTripWithCustomRootName();
void testRoundTripWithDefaultRootName();
void testSingleLevelGroup();
void testAbsolutePaths();

private:
QSharedPointer<Database> m_db;
Expand Down
Loading