Skip to content

Commit df4de58

Browse files
Copilotdroidmonkey
andcommitted
Implement nested folder support for Bitwarden import
Co-authored-by: droidmonkey <[email protected]>
1 parent a9e0de3 commit df4de58

File tree

4 files changed

+204
-7
lines changed

4 files changed

+204
-7
lines changed

src/format/BitwardenReader.cpp

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,77 @@ namespace
211211
return entry.take();
212212
}
213213

214+
/*!
215+
* Create nested folder hierarchy from a path string.
216+
* For example, "Socials/Forums" creates a "Socials" group with a "Forums" child group.
217+
* Returns the deepest (leaf) group in the hierarchy.
218+
*/
219+
Group* createNestedFolderHierarchy(const QString& folderPath, Group* rootGroup, QMap<QString, Group*>& createdGroups)
220+
{
221+
if (folderPath.isEmpty()) {
222+
return rootGroup;
223+
}
224+
225+
// Check if we've already created this exact path
226+
if (createdGroups.contains(folderPath)) {
227+
return createdGroups.value(folderPath);
228+
}
229+
230+
// Split the path by forward slashes
231+
QStringList pathParts = folderPath.split('/', Qt::SkipEmptyParts);
232+
if (pathParts.isEmpty()) {
233+
return rootGroup;
234+
}
235+
236+
Group* currentParent = rootGroup;
237+
QString currentPath;
238+
239+
// Create each level of the hierarchy
240+
for (int i = 0; i < pathParts.size(); ++i) {
241+
const QString& partName = pathParts[i];
242+
243+
// Build the current path (e.g., "Socials", then "Socials/Forums")
244+
if (currentPath.isEmpty()) {
245+
currentPath = partName;
246+
} else {
247+
currentPath += "/" + partName;
248+
}
249+
250+
// Check if this level already exists
251+
Group* existingGroup = createdGroups.value(currentPath);
252+
if (existingGroup) {
253+
currentParent = existingGroup;
254+
continue;
255+
}
256+
257+
// Find existing child group with this name
258+
existingGroup = nullptr;
259+
for (Group* child : currentParent->children()) {
260+
if (child->name() == partName) {
261+
existingGroup = child;
262+
break;
263+
}
264+
}
265+
266+
if (existingGroup) {
267+
// Use existing group
268+
createdGroups.insert(currentPath, existingGroup);
269+
currentParent = existingGroup;
270+
} else {
271+
// Create new group
272+
auto newGroup = new Group();
273+
newGroup->setUuid(QUuid::createUuid());
274+
newGroup->setName(partName);
275+
newGroup->setParent(currentParent);
276+
277+
createdGroups.insert(currentPath, newGroup);
278+
currentParent = newGroup;
279+
}
280+
}
281+
282+
return currentParent;
283+
}
284+
214285
void writeVaultToDatabase(const QJsonObject& vault, QSharedPointer<Database> db)
215286
{
216287
auto folderField = QString("folders");
@@ -224,15 +295,19 @@ namespace
224295
return;
225296
}
226297

227-
// Create groups from folders and store a temporary map of id -> uuid
298+
// Create groups from folders and store a temporary map of id -> group
228299
QMap<QString, Group*> folderMap;
229-
for (const auto& folder : vault.value(folderField).toArray()) {
230-
auto group = new Group();
231-
group->setUuid(QUuid::createUuid());
232-
group->setName(folder.toObject().value("name").toString());
233-
group->setParent(db->rootGroup());
300+
QMap<QString, Group*> createdGroups; // Track created groups by path to avoid duplicates
234301

235-
folderMap.insert(folder.toObject().value("id").toString(), group);
302+
for (const auto& folder : vault.value(folderField).toArray()) {
303+
const QString folderName = folder.toObject().value("name").toString();
304+
const QString folderId = folder.toObject().value("id").toString();
305+
306+
// Create the nested folder hierarchy
307+
Group* targetGroup = createNestedFolderHierarchy(folderName, db->rootGroup(), createdGroups);
308+
309+
// Map the folder ID to the target group
310+
folderMap.insert(folderId, targetGroup);
236311
}
237312

238313
QString folderId;

tests/TestImports.cpp

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,55 @@ void TestImports::testBitwardenPasskey()
317317
QStringLiteral("aTFtdmFnOHYtS2dxVEJ0by1rSFpLWGg0enlTVC1iUVJReDZ5czJXa3c2aw"));
318318
}
319319

320+
void TestImports::testBitwardenNestedFolders()
321+
{
322+
auto bitwardenPath = QStringLiteral("%1/%2").arg(KEEPASSX_TEST_DATA_DIR, QStringLiteral("/bitwarden_nested_export.json"));
323+
324+
BitwardenReader reader;
325+
auto db = reader.convert(bitwardenPath);
326+
QVERIFY2(!reader.hasError(), qPrintable(reader.errorString()));
327+
QVERIFY(db);
328+
329+
// Test nested folder structure: "Socials/Forums" should create Socials -> Forums hierarchy
330+
auto entry = db->rootGroup()->findEntryByPath("/Socials/Forums/Reddit Account");
331+
QVERIFY(entry);
332+
QCOMPARE(entry->title(), QStringLiteral("Reddit Account"));
333+
QCOMPARE(entry->username(), QStringLiteral("myuser"));
334+
335+
// Test deeper nesting: "Work/Projects/Client A"
336+
entry = db->rootGroup()->findEntryByPath("/Work/Projects/Client A/Client Portal");
337+
QVERIFY(entry);
338+
QCOMPARE(entry->title(), QStringLiteral("Client Portal"));
339+
QCOMPARE(entry->username(), QStringLiteral("clientuser"));
340+
341+
// Test simple folder (no nesting): "Personal"
342+
entry = db->rootGroup()->findEntryByPath("/Personal/Personal Email");
343+
QVERIFY(entry);
344+
QCOMPARE(entry->title(), QStringLiteral("Personal Email"));
345+
QCOMPARE(entry->username(), QStringLiteral("[email protected]"));
346+
347+
// Verify the folder hierarchy exists
348+
auto socialsGroup = db->rootGroup()->findGroupByPath("/Socials");
349+
QVERIFY(socialsGroup);
350+
QCOMPARE(socialsGroup->name(), QStringLiteral("Socials"));
351+
352+
auto forumsGroup = socialsGroup->findGroupByPath("Forums");
353+
QVERIFY(forumsGroup);
354+
QCOMPARE(forumsGroup->name(), QStringLiteral("Forums"));
355+
356+
auto workGroup = db->rootGroup()->findGroupByPath("/Work");
357+
QVERIFY(workGroup);
358+
QCOMPARE(workGroup->name(), QStringLiteral("Work"));
359+
360+
auto projectsGroup = workGroup->findGroupByPath("Projects");
361+
QVERIFY(projectsGroup);
362+
QCOMPARE(projectsGroup->name(), QStringLiteral("Projects"));
363+
364+
auto clientAGroup = projectsGroup->findGroupByPath("Client A");
365+
QVERIFY(clientAGroup);
366+
QCOMPARE(clientAGroup->name(), QStringLiteral("Client A"));
367+
}
368+
320369
void TestImports::testProtonPass()
321370
{
322371
auto protonPassPath =

tests/TestImports.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ private slots:
3131
void testBitwarden();
3232
void testBitwardenEncrypted();
3333
void testBitwardenPasskey();
34+
void testBitwardenNestedFolders();
3435
void testProtonPass();
3536
};
3637

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
{
2+
"folders": [
3+
{
4+
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
5+
"name": "Socials/Forums"
6+
},
7+
{
8+
"id": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
9+
"name": "Work/Projects/Client A"
10+
},
11+
{
12+
"id": "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
13+
"name": "Personal"
14+
}
15+
],
16+
"items": [
17+
{
18+
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa",
19+
"organizationId": null,
20+
"folderId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
21+
"type": 1,
22+
"name": "Reddit Account",
23+
"notes": "My reddit login",
24+
"favorite": false,
25+
"login": {
26+
"username": "myuser",
27+
"password": "mypass",
28+
"uris": [
29+
{
30+
"uri": "https://reddit.com"
31+
}
32+
]
33+
}
34+
},
35+
{
36+
"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
37+
"organizationId": null,
38+
"folderId": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
39+
"type": 1,
40+
"name": "Client Portal",
41+
"notes": "Client A portal login",
42+
"favorite": false,
43+
"login": {
44+
"username": "clientuser",
45+
"password": "clientpass",
46+
"uris": [
47+
{
48+
"uri": "https://clienta.com"
49+
}
50+
]
51+
}
52+
},
53+
{
54+
"id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
55+
"organizationId": null,
56+
"folderId": "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
57+
"type": 1,
58+
"name": "Personal Email",
59+
"notes": "My personal email",
60+
"favorite": false,
61+
"login": {
62+
"username": "[email protected]",
63+
"password": "personalpass",
64+
"uris": [
65+
{
66+
"uri": "https://mail.provider.com"
67+
}
68+
]
69+
}
70+
}
71+
]
72+
}

0 commit comments

Comments
 (0)