Skip to content

Commit 821fb74

Browse files
authored
Merge pull request #8 from jabashque/nested-folders-fix1
Overhaul group and subgroup creation to better match Bitwarden behavior
2 parents 1750d88 + 1c548da commit 821fb74

File tree

2 files changed

+95
-23
lines changed

2 files changed

+95
-23
lines changed

bitwarden-to-keepass.py

+36-23
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import json
22
import logging
33
import os
4+
import re
45
import subprocess
56

67
from argparse import ArgumentParser
8+
from typing import Dict, List, Optional
79
from shlex import quote
810

911
from pykeepass import PyKeePass, create_database
1012
from pykeepass.exceptions import CredentialsError
13+
from pykeepass.group import Group as KPGroup
1114

15+
import folder as FolderType
1216
from item import Item, Types as ItemTypes
1317

1418
logging.basicConfig(
@@ -17,8 +21,10 @@
1721
datefmt='%Y-%m-%d %H:%M:%S',
1822
)
1923

24+
kp: Optional[PyKeePass] = None
2025

2126
def bitwarden_to_keepass(args):
27+
global kp
2228
try:
2329
kp = PyKeePass(args.database_path, password=args.database_password, keyfile=args.database_keyfile)
2430
except FileNotFoundError:
@@ -30,29 +36,7 @@ def bitwarden_to_keepass(args):
3036

3137
folders = subprocess.check_output(f'{quote(args.bw_path)} list folders --session {quote(args.bw_session)}', shell=True, encoding='utf8')
3238
folders = json.loads(folders)
33-
# sort folders so that in the case of nested folders, the parents would be guaranteed to show up before the children
34-
folders.sort(key=lambda x: x['name'])
35-
groups_by_id = {}
36-
groups_by_name = {}
37-
for folder in folders:
38-
# entries not associated with a folder should go under the root group
39-
if folder['id'] is None:
40-
groups_by_id[folder['id']] = kp.root_group
41-
continue
42-
43-
parent_group = kp.root_group
44-
target_name = folder['name']
45-
46-
# check if this is a nested folder; set appropriate parent group if so
47-
folder_path_split = target_name.rsplit('/', maxsplit=1)
48-
if len(folder_path_split) > 1:
49-
parent_group = groups_by_name[folder_path_split[0]]
50-
target_name = folder_path_split[1]
51-
52-
new_group = kp.add_group(parent_group, target_name)
53-
54-
groups_by_id[folder['id']] = new_group
55-
groups_by_name[folder['name']] = new_group
39+
groups_by_id = load_folders(folders)
5640
logging.info(f'Folders done ({len(groups_by_id)}).')
5741

5842
items = subprocess.check_output(f'{quote(args.bw_path)} list items --session {quote(args.bw_session)}', shell=True, encoding='utf8')
@@ -114,6 +98,35 @@ def bitwarden_to_keepass(args):
11498
kp.save()
11599
logging.info('Export completed.')
116100

101+
def load_folders(folders) -> Dict[str, KPGroup]:
102+
# sort folders so that in the case of nested folders, the parents would be guaranteed to show up before the children
103+
folders.sort(key=lambda x: x['name'])
104+
105+
# dict to store mapping of Bitwarden folder id to keepass group
106+
groups_by_id: Dict[str, KPGroup] = {}
107+
108+
# build up folder tree
109+
folder_root: FolderType.Folder = FolderType.Folder(None)
110+
folder_root.keepass_group = kp.root_group
111+
groups_by_id[None] = kp.root_group
112+
113+
for folder in folders:
114+
if folder['id'] is not None:
115+
new_folder: FolderType.Folder = FolderType.Folder(folder['id'])
116+
# regex lifted from https://github.com/bitwarden/jslib/blob/ecdd08624f61ccff8128b7cb3241f39e664e1c7f/common/src/services/folder.service.ts#L108
117+
folder_name_parts: List[str] = re.sub(r'^\/+|\/+$', '', folder['name']).split('/')
118+
FolderType.nested_traverse_insert(folder_root, folder_name_parts, new_folder, '/')
119+
120+
# create keepass groups based off folder tree
121+
def add_keepass_group(folder: FolderType.Folder):
122+
parent_group: KPGroup = folder.parent.keepass_group
123+
new_group: KPGroup = kp.add_group(parent_group, folder.name)
124+
folder.keepass_group = new_group
125+
groups_by_id[folder.id] = new_group
126+
127+
FolderType.bfs_traverse_execute(folder_root, add_keepass_group)
128+
129+
return groups_by_id
117130

118131
def check_args(args):
119132
if args.database_keyfile:

folder.py

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import collections
2+
from typing import Callable, Deque, List, Optional
3+
4+
from pykeepass.group import Group as KPGroup
5+
6+
class Folder:
7+
id: Optional[str]
8+
name: Optional[str]
9+
children: List['Folder']
10+
parent: Optional['Folder']
11+
keepass_group: Optional[KPGroup]
12+
13+
def __init__(self, id: Optional[str]):
14+
self.id = id
15+
self.name = None
16+
self.children = []
17+
self.parent = None
18+
self.keepass_group = None
19+
20+
def add_child(self, child: 'Folder'):
21+
self.children.append(child)
22+
child.parent = self
23+
24+
# logic was lifted directly from https://github.com/bitwarden/jslib/blob/ecdd08624f61ccff8128b7cb3241f39e664e1c7f/common/src/misc/serviceUtils.ts#L7
25+
def nested_traverse_insert(root: Folder, name_parts: List[str], new_folder: Folder, delimiter: str) -> None:
26+
if len(name_parts) == 0:
27+
return
28+
29+
end: bool = len(name_parts) == 1
30+
part_name: str = name_parts[0]
31+
32+
for child in root.children:
33+
if child.name != part_name:
34+
continue
35+
36+
if end and child.id != new_folder.id:
37+
# Another node with the same name.
38+
new_folder.name = part_name
39+
root.add_child(new_folder)
40+
return
41+
nested_traverse_insert(child, name_parts[1:], new_folder, delimiter)
42+
return
43+
44+
if end:
45+
new_folder.name = part_name
46+
root.add_child(new_folder)
47+
return
48+
new_part_name: str = part_name + delimiter + name_parts[1]
49+
new_name_parts: List[str] = [new_part_name]
50+
new_name_parts.extend(name_parts[2:])
51+
nested_traverse_insert(root, new_name_parts, new_folder, delimiter)
52+
53+
def bfs_traverse_execute(root: Folder, callback: Callable[[Folder], None]) -> None:
54+
queue: Deque[Folder] = collections.deque()
55+
queue.extend(root.children)
56+
while queue:
57+
child: Folder = queue.popleft()
58+
queue.extend(child.children)
59+
callback(child)

0 commit comments

Comments
 (0)