Compare commits

...

3 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
cd22010288 Apply code formatting and fix style issues
Co-authored-by: droidmonkey <2809491+droidmonkey@users.noreply.github.com>
2025-06-19 14:37:35 +00:00
copilot-swe-agent[bot]
df4de58541 Implement nested folder support for Bitwarden import
Co-authored-by: droidmonkey <2809491+droidmonkey@users.noreply.github.com>
2025-06-19 10:24:37 -04:00
copilot-swe-agent[bot]
a9e0de34d1 Initial plan for issue 2025-06-19 10:24:36 -04:00
4 changed files with 206 additions and 7 deletions

View File

@@ -211,6 +211,78 @@ namespace
return entry.take(); return entry.take();
} }
/*!
* Create nested folder hierarchy from a path string.
* For example, "Socials/Forums" creates a "Socials" group with a "Forums" child group.
* Returns the deepest (leaf) group in the hierarchy.
*/
Group*
createNestedFolderHierarchy(const QString& folderPath, Group* rootGroup, QMap<QString, Group*>& createdGroups)
{
if (folderPath.isEmpty()) {
return rootGroup;
}
// Check if we've already created this exact path
if (createdGroups.contains(folderPath)) {
return createdGroups.value(folderPath);
}
// Split the path by forward slashes
QStringList pathParts = folderPath.split('/', Qt::SkipEmptyParts);
if (pathParts.isEmpty()) {
return rootGroup;
}
Group* currentParent = rootGroup;
QString currentPath;
// Create each level of the hierarchy
for (int i = 0; i < pathParts.size(); ++i) {
const QString& partName = pathParts[i];
// Build the current path (e.g., "Socials", then "Socials/Forums")
if (currentPath.isEmpty()) {
currentPath = partName;
} else {
currentPath += "/" + partName;
}
// Check if this level already exists
Group* existingGroup = createdGroups.value(currentPath);
if (existingGroup) {
currentParent = existingGroup;
continue;
}
// Find existing child group with this name
existingGroup = nullptr;
for (Group* child : currentParent->children()) {
if (child->name() == partName) {
existingGroup = child;
break;
}
}
if (existingGroup) {
// Use existing group
createdGroups.insert(currentPath, existingGroup);
currentParent = existingGroup;
} else {
// Create new group
auto newGroup = new Group();
newGroup->setUuid(QUuid::createUuid());
newGroup->setName(partName);
newGroup->setParent(currentParent);
createdGroups.insert(currentPath, newGroup);
currentParent = newGroup;
}
}
return currentParent;
}
void writeVaultToDatabase(const QJsonObject& vault, QSharedPointer<Database> db) void writeVaultToDatabase(const QJsonObject& vault, QSharedPointer<Database> db)
{ {
auto folderField = QString("folders"); auto folderField = QString("folders");
@@ -224,15 +296,19 @@ namespace
return; return;
} }
// Create groups from folders and store a temporary map of id -> uuid // Create groups from folders and store a temporary map of id -> group
QMap<QString, Group*> folderMap; QMap<QString, Group*> folderMap;
for (const auto& folder : vault.value(folderField).toArray()) { QMap<QString, Group*> createdGroups; // Track created groups by path to avoid duplicates
auto group = new Group();
group->setUuid(QUuid::createUuid());
group->setName(folder.toObject().value("name").toString());
group->setParent(db->rootGroup());
folderMap.insert(folder.toObject().value("id").toString(), group); for (const auto& folder : vault.value(folderField).toArray()) {
const QString folderName = folder.toObject().value("name").toString();
const QString folderId = folder.toObject().value("id").toString();
// Create the nested folder hierarchy
Group* targetGroup = createNestedFolderHierarchy(folderName, db->rootGroup(), createdGroups);
// Map the folder ID to the target group
folderMap.insert(folderId, targetGroup);
} }
QString folderId; QString folderId;

View File

@@ -317,6 +317,56 @@ void TestImports::testBitwardenPasskey()
QStringLiteral("aTFtdmFnOHYtS2dxVEJ0by1rSFpLWGg0enlTVC1iUVJReDZ5czJXa3c2aw")); QStringLiteral("aTFtdmFnOHYtS2dxVEJ0by1rSFpLWGg0enlTVC1iUVJReDZ5czJXa3c2aw"));
} }
void TestImports::testBitwardenNestedFolders()
{
auto bitwardenPath =
QStringLiteral("%1/%2").arg(KEEPASSX_TEST_DATA_DIR, QStringLiteral("/bitwarden_nested_export.json"));
BitwardenReader reader;
auto db = reader.convert(bitwardenPath);
QVERIFY2(!reader.hasError(), qPrintable(reader.errorString()));
QVERIFY(db);
// Test nested folder structure: "Socials/Forums" should create Socials -> Forums hierarchy
auto entry = db->rootGroup()->findEntryByPath("/Socials/Forums/Reddit Account");
QVERIFY(entry);
QCOMPARE(entry->title(), QStringLiteral("Reddit Account"));
QCOMPARE(entry->username(), QStringLiteral("myuser"));
// Test deeper nesting: "Work/Projects/Client A"
entry = db->rootGroup()->findEntryByPath("/Work/Projects/Client A/Client Portal");
QVERIFY(entry);
QCOMPARE(entry->title(), QStringLiteral("Client Portal"));
QCOMPARE(entry->username(), QStringLiteral("clientuser"));
// Test simple folder (no nesting): "Personal"
entry = db->rootGroup()->findEntryByPath("/Personal/Personal Email");
QVERIFY(entry);
QCOMPARE(entry->title(), QStringLiteral("Personal Email"));
QCOMPARE(entry->username(), QStringLiteral("personal@email.com"));
// Verify the folder hierarchy exists
auto socialsGroup = db->rootGroup()->findGroupByPath("/Socials");
QVERIFY(socialsGroup);
QCOMPARE(socialsGroup->name(), QStringLiteral("Socials"));
auto forumsGroup = socialsGroup->findGroupByPath("Forums");
QVERIFY(forumsGroup);
QCOMPARE(forumsGroup->name(), QStringLiteral("Forums"));
auto workGroup = db->rootGroup()->findGroupByPath("/Work");
QVERIFY(workGroup);
QCOMPARE(workGroup->name(), QStringLiteral("Work"));
auto projectsGroup = workGroup->findGroupByPath("Projects");
QVERIFY(projectsGroup);
QCOMPARE(projectsGroup->name(), QStringLiteral("Projects"));
auto clientAGroup = projectsGroup->findGroupByPath("Client A");
QVERIFY(clientAGroup);
QCOMPARE(clientAGroup->name(), QStringLiteral("Client A"));
}
void TestImports::testProtonPass() void TestImports::testProtonPass()
{ {
auto protonPassPath = auto protonPassPath =

View File

@@ -31,6 +31,7 @@ private slots:
void testBitwarden(); void testBitwarden();
void testBitwardenEncrypted(); void testBitwardenEncrypted();
void testBitwardenPasskey(); void testBitwardenPasskey();
void testBitwardenNestedFolders();
void testProtonPass(); void testProtonPass();
}; };

View File

@@ -0,0 +1,72 @@
{
"folders": [
{
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"name": "Socials/Forums"
},
{
"id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
"name": "Work/Projects/Client A"
},
{
"id": "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
"name": "Personal"
}
],
"items": [
{
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa",
"organizationId": null,
"folderId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"type": 1,
"name": "Reddit Account",
"notes": "My reddit login",
"favorite": false,
"login": {
"username": "myuser",
"password": "mypass",
"uris": [
{
"uri": "https://reddit.com"
}
]
}
},
{
"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"organizationId": null,
"folderId": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
"type": 1,
"name": "Client Portal",
"notes": "Client A portal login",
"favorite": false,
"login": {
"username": "clientuser",
"password": "clientpass",
"uris": [
{
"uri": "https://clienta.com"
}
]
}
},
{
"id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
"organizationId": null,
"folderId": "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
"type": 1,
"name": "Personal Email",
"notes": "My personal email",
"favorite": false,
"login": {
"username": "personal@email.com",
"password": "personalpass",
"uris": [
{
"uri": "https://mail.provider.com"
}
]
}
}
]
}