Overhaul group and subgroup creation to better match Bitwarden behavior
In Bitwarden, one could make it so their account only has a certain set of folders, like so: - testfolder/a - testfolder/a/b/c - testfolder/a/b/c/d The resulting hierarchy would look like this: testfolder/a `- b/c `- d As shown above, Bitwarden allows one to name a folder something like "foo/bar" even if a folder "foo" doesn't exist. Bitwarden handles this by just putting this folder in the root with the name as-is. In addition, as shown with "testfolder/a/b/c", a folder name may not necessarily be just anything that comes after the last `/` delimiter. Instead, it's dependent on which ever existing folder happens to match the most of the folder's name's prefix. As such, the following references were used to replicate this behavior in bitwarden-to-keepass:ecdd08624f/common/src/services/folder.service.ts (L108)
ecdd08624f/common/src/misc/serviceUtils.ts (L7)
This commit is contained in:
parent
1750d8898b
commit
1c548daaf4
@ -1,14 +1,18 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
|
from typing import Dict, List, Optional
|
||||||
from shlex import quote
|
from shlex import quote
|
||||||
|
|
||||||
from pykeepass import PyKeePass, create_database
|
from pykeepass import PyKeePass, create_database
|
||||||
from pykeepass.exceptions import CredentialsError
|
from pykeepass.exceptions import CredentialsError
|
||||||
|
from pykeepass.group import Group as KPGroup
|
||||||
|
|
||||||
|
import folder as FolderType
|
||||||
from item import Item, Types as ItemTypes
|
from item import Item, Types as ItemTypes
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -17,8 +21,10 @@ logging.basicConfig(
|
|||||||
datefmt='%Y-%m-%d %H:%M:%S',
|
datefmt='%Y-%m-%d %H:%M:%S',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
kp: Optional[PyKeePass] = None
|
||||||
|
|
||||||
def bitwarden_to_keepass(args):
|
def bitwarden_to_keepass(args):
|
||||||
|
global kp
|
||||||
try:
|
try:
|
||||||
kp = PyKeePass(args.database_path, password=args.database_password, keyfile=args.database_keyfile)
|
kp = PyKeePass(args.database_path, password=args.database_password, keyfile=args.database_keyfile)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@ -30,29 +36,7 @@ def bitwarden_to_keepass(args):
|
|||||||
|
|
||||||
folders = subprocess.check_output(f'{quote(args.bw_path)} list folders --session {quote(args.bw_session)}', shell=True, encoding='utf8')
|
folders = subprocess.check_output(f'{quote(args.bw_path)} list folders --session {quote(args.bw_session)}', shell=True, encoding='utf8')
|
||||||
folders = json.loads(folders)
|
folders = json.loads(folders)
|
||||||
# sort folders so that in the case of nested folders, the parents would be guaranteed to show up before the children
|
groups_by_id = load_folders(folders)
|
||||||
folders.sort(key=lambda x: x['name'])
|
|
||||||
groups_by_id = {}
|
|
||||||
groups_by_name = {}
|
|
||||||
for folder in folders:
|
|
||||||
# entries not associated with a folder should go under the root group
|
|
||||||
if folder['id'] is None:
|
|
||||||
groups_by_id[folder['id']] = kp.root_group
|
|
||||||
continue
|
|
||||||
|
|
||||||
parent_group = kp.root_group
|
|
||||||
target_name = folder['name']
|
|
||||||
|
|
||||||
# check if this is a nested folder; set appropriate parent group if so
|
|
||||||
folder_path_split = target_name.rsplit('/', maxsplit=1)
|
|
||||||
if len(folder_path_split) > 1:
|
|
||||||
parent_group = groups_by_name[folder_path_split[0]]
|
|
||||||
target_name = folder_path_split[1]
|
|
||||||
|
|
||||||
new_group = kp.add_group(parent_group, target_name)
|
|
||||||
|
|
||||||
groups_by_id[folder['id']] = new_group
|
|
||||||
groups_by_name[folder['name']] = new_group
|
|
||||||
logging.info(f'Folders done ({len(groups_by_id)}).')
|
logging.info(f'Folders done ({len(groups_by_id)}).')
|
||||||
|
|
||||||
items = subprocess.check_output(f'{quote(args.bw_path)} list items --session {quote(args.bw_session)}', shell=True, encoding='utf8')
|
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):
|
|||||||
kp.save()
|
kp.save()
|
||||||
logging.info('Export completed.')
|
logging.info('Export completed.')
|
||||||
|
|
||||||
|
def load_folders(folders) -> Dict[str, KPGroup]:
|
||||||
|
# sort folders so that in the case of nested folders, the parents would be guaranteed to show up before the children
|
||||||
|
folders.sort(key=lambda x: x['name'])
|
||||||
|
|
||||||
|
# dict to store mapping of Bitwarden folder id to keepass group
|
||||||
|
groups_by_id: Dict[str, KPGroup] = {}
|
||||||
|
|
||||||
|
# build up folder tree
|
||||||
|
folder_root: FolderType.Folder = FolderType.Folder(None)
|
||||||
|
folder_root.keepass_group = kp.root_group
|
||||||
|
groups_by_id[None] = kp.root_group
|
||||||
|
|
||||||
|
for folder in folders:
|
||||||
|
if folder['id'] is not None:
|
||||||
|
new_folder: FolderType.Folder = FolderType.Folder(folder['id'])
|
||||||
|
# regex lifted from https://github.com/bitwarden/jslib/blob/ecdd08624f61ccff8128b7cb3241f39e664e1c7f/common/src/services/folder.service.ts#L108
|
||||||
|
folder_name_parts: List[str] = re.sub(r'^\/+|\/+$', '', folder['name']).split('/')
|
||||||
|
FolderType.nested_traverse_insert(folder_root, folder_name_parts, new_folder, '/')
|
||||||
|
|
||||||
|
# create keepass groups based off folder tree
|
||||||
|
def add_keepass_group(folder: FolderType.Folder):
|
||||||
|
parent_group: KPGroup = folder.parent.keepass_group
|
||||||
|
new_group: KPGroup = kp.add_group(parent_group, folder.name)
|
||||||
|
folder.keepass_group = new_group
|
||||||
|
groups_by_id[folder.id] = new_group
|
||||||
|
|
||||||
|
FolderType.bfs_traverse_execute(folder_root, add_keepass_group)
|
||||||
|
|
||||||
|
return groups_by_id
|
||||||
|
|
||||||
def check_args(args):
|
def check_args(args):
|
||||||
if args.database_keyfile:
|
if args.database_keyfile:
|
||||||
|
59
folder.py
Normal file
59
folder.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import collections
|
||||||
|
from typing import Callable, Deque, List, Optional
|
||||||
|
|
||||||
|
from pykeepass.group import Group as KPGroup
|
||||||
|
|
||||||
|
class Folder:
|
||||||
|
id: Optional[str]
|
||||||
|
name: Optional[str]
|
||||||
|
children: List['Folder']
|
||||||
|
parent: Optional['Folder']
|
||||||
|
keepass_group: Optional[KPGroup]
|
||||||
|
|
||||||
|
def __init__(self, id: Optional[str]):
|
||||||
|
self.id = id
|
||||||
|
self.name = None
|
||||||
|
self.children = []
|
||||||
|
self.parent = None
|
||||||
|
self.keepass_group = None
|
||||||
|
|
||||||
|
def add_child(self, child: 'Folder'):
|
||||||
|
self.children.append(child)
|
||||||
|
child.parent = self
|
||||||
|
|
||||||
|
# logic was lifted directly from https://github.com/bitwarden/jslib/blob/ecdd08624f61ccff8128b7cb3241f39e664e1c7f/common/src/misc/serviceUtils.ts#L7
|
||||||
|
def nested_traverse_insert(root: Folder, name_parts: List[str], new_folder: Folder, delimiter: str) -> None:
|
||||||
|
if len(name_parts) == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
end: bool = len(name_parts) == 1
|
||||||
|
part_name: str = name_parts[0]
|
||||||
|
|
||||||
|
for child in root.children:
|
||||||
|
if child.name != part_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if end and child.id != new_folder.id:
|
||||||
|
# Another node with the same name.
|
||||||
|
new_folder.name = part_name
|
||||||
|
root.add_child(new_folder)
|
||||||
|
return
|
||||||
|
nested_traverse_insert(child, name_parts[1:], new_folder, delimiter)
|
||||||
|
return
|
||||||
|
|
||||||
|
if end:
|
||||||
|
new_folder.name = part_name
|
||||||
|
root.add_child(new_folder)
|
||||||
|
return
|
||||||
|
new_part_name: str = part_name + delimiter + name_parts[1]
|
||||||
|
new_name_parts: List[str] = [new_part_name]
|
||||||
|
new_name_parts.extend(name_parts[2:])
|
||||||
|
nested_traverse_insert(root, new_name_parts, new_folder, delimiter)
|
||||||
|
|
||||||
|
def bfs_traverse_execute(root: Folder, callback: Callable[[Folder], None]) -> None:
|
||||||
|
queue: Deque[Folder] = collections.deque()
|
||||||
|
queue.extend(root.children)
|
||||||
|
while queue:
|
||||||
|
child: Folder = queue.popleft()
|
||||||
|
queue.extend(child.children)
|
||||||
|
callback(child)
|
Loading…
x
Reference in New Issue
Block a user