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
84 changes: 73 additions & 11 deletions KeePassDiff/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from components.entry_modal import show_entry_details
from pykeepass import PyKeePass

from KeePassDiff.utils.comparison import compare_databases
from KeePassDiff.utils.comparison import compare_databases, compare_entries
from KeePassDiff.utils.database import (
get_entries_set,
get_entry_details,
Expand Down Expand Up @@ -68,7 +68,14 @@ def toggle_entry(key):

st.header("Diff Results")

tabs = st.tabs(["Exclusive Entries", "Conflicting Items", "Merge & Export"])
tabs = st.tabs(
[
"Exclusive Items",
"Common Items",
"Conflicting Items",
"Merge & Export",
]
)
with tabs[0]:
col1, col2 = st.columns(2)

Expand Down Expand Up @@ -122,7 +129,9 @@ def toggle_entry(key):
entry_details = get_entry_details(kp2, entry)
show_entry_details(entry_details, key=view_key)
with col2_2:
if st.button("⬅️ Merge left", key=f"merge_left_{entry}"):
if st.button(
"⬅️ Merge left", key=f"merge_left_{entry}"
):
if merge_entry(kp2, kp1, entry):
st.success(f"Merged {entry} to first database")
kp1.save()
Expand All @@ -135,11 +144,20 @@ def toggle_entry(key):
st.warning(group)
else:
st.write("None")

with tabs[1]:
st.subheader("Conflicting Entries")
st.subheader("Common Entries")
if differences["common_entries"]:
any_common = False
for entry in differences["common_entries"]:

entry1_details = get_entry_details(kp1, entry)
Comment thread
FriendlyUser1 marked this conversation as resolved.
entry2_details = get_entry_details(kp2, entry)

if len(compare_entries(entry1_details, entry2_details)) > 0:
continue

any_common = True

col1, col2 = st.columns([1, 1])
with col1:
view_key = f"view1_common_{entry}"
Expand All @@ -151,8 +169,7 @@ def toggle_entry(key):
)
if view_key in st.session_state["expanded_entries"]:
int_key = view_key + "_common_1"
entry_details = get_entry_details(kp1, entry)
show_entry_details(entry_details, key=int_key)
show_entry_details(entry1_details, key=int_key)
with col2:
view_key = f"view2_common_{entry}"
st.button(
Expand All @@ -163,19 +180,64 @@ def toggle_entry(key):
)
if view_key in st.session_state["expanded_entries"]:
int_key = view_key + "_common_2"
entry_details = get_entry_details(kp2, entry)
show_entry_details(entry_details, key=int_key)
show_entry_details(entry2_details, key=int_key)
if not any_common:
st.write("All common entries conflict")
else:
st.write("No common entries found")

st.subheader("Conflicting Groups")
st.subheader("Common Groups")
if differences["common_groups"]:
for group in differences["common_groups"]:
st.success(group)
else:
st.write("No common groups found")

with tabs[2]:
st.subheader("Conflicting Entries")
Comment thread
tomlin7 marked this conversation as resolved.
if differences["common_entries"]:
conflicts = []
for entry in differences["common_entries"]:
entry1_details = get_entry_details(kp1, entry)
entry2_details = get_entry_details(kp2, entry)

conflicts = compare_entries(entry1_details, entry2_details)

if len(conflicts) == 0:
continue

col1, col2 = st.columns([1, 1])
with col1:
view_key = f"view1_conflict_{entry}"
st.button(
f"📝 View in DB1: {entry}",
key=view_key,
on_click=toggle_entry,
args=(view_key,),
)
if view_key in st.session_state["expanded_entries"]:
int_key = view_key + "_conflict_1"
show_entry_details(
entry1_details, key=int_key, conflicts=conflicts
)
with col2:
view_key = f"view2_conflict_{entry}"
st.button(
f"📝 View in DB2: {entry}",
key=view_key,
on_click=toggle_entry,
args=(view_key,),
)
if view_key in st.session_state["expanded_entries"]:
int_key = view_key + "_conflict_2"
show_entry_details(
entry2_details, key=int_key, conflicts=conflicts
)
if len(conflicts) == 0:
st.write("No conflicting entries found")
else:
st.write("No common entries found")

with tabs[3]:
st.subheader("Export Merged Database")
col1, col2 = st.columns(2)

Expand Down
59 changes: 38 additions & 21 deletions KeePassDiff/components/entry_modal.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,42 @@
from typing import Dict, Optional
from typing import List

import streamlit as st

from KeePassDiff.utils.database import EntryDetails

def show_entry_details(entry_details: Optional[Dict] = None, key: str = None):
if entry_details:
st.markdown("### Entry Details")
st.markdown(f"**Title:** {entry_details['title']}")
st.markdown(f"**Username:** {entry_details['username']}")
st.markdown(f"**Password:** {entry_details['password']}")
st.markdown(f"**URL:** {entry_details['url']}")
st.markdown("**Notes:**")
st.text_area(
"",
value=entry_details["notes"],
height=100,
disabled=True,
key=f"notes_{key}" if key else None,
)
st.markdown(f"**Created:** {entry_details['created']}")
st.markdown(f"**Modified:** {entry_details['modified']}")
st.markdown(f"**Path:** {entry_details['path']}")
else:
st.warning("Entry details not found")

def show_entry_details(
entry_details: EntryDetails | None = None,
key: str | None = None,
conflicts: List[str] | None = None,
):
if not entry_details:
return st.warning("Entry details not found")

def markdown(md_string: str, inner_key: str | None = None):
if not inner_key:
return st.markdown(md_string)

md_string = md_string.replace("{}", str(entry_details[inner_key]))
if conflicts and inner_key in conflicts:
md_string = f":red-background[{md_string}]"

st.markdown(md_string)

markdown("### Entry Details")
markdown("**Title:** {}", "title")
markdown("**Username:** {}", "username")
markdown("**Password:** {}", "password")
markdown("**URL:** {}", "url")
markdown("**Notes:**")
st.text_area(
"notes",
label_visibility="collapsed",
value=entry_details["notes"],
height=100,
disabled=True,
key=f"notes_{key}",
)
markdown("**Created:** {}", "created")
markdown("**Modified:** {}", "modified")
markdown("**Path:** {}", "path")
26 changes: 23 additions & 3 deletions KeePassDiff/utils/comparison.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,34 @@
from typing import Dict, Set
from typing import Dict, Set, List

from KeePassDiff.utils.database import EntryDetails


def compare_databases(
db1_data: Dict[str, Set[str]], db2_data: Dict[str, Set[str]]
) -> Dict:
) -> Dict[str, List[str]]:
return {
"entries_only_in_db1": sorted(db1_data["entries"] - db2_data["entries"]),
"entries_only_in_db2": sorted(db2_data["entries"] - db1_data["entries"]),
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
"common_entries": sorted(db1_data["entries"] & db2_data["entries"]),
"groups_only_in_db1": sorted(db1_data["groups"] - db2_data["groups"]),
"groups_only_in_db2": sorted(db2_data["groups"] - db2_data["groups"]),
"groups_only_in_db2": sorted(db2_data["groups"] - db1_data["groups"]),
"common_groups": sorted(db1_data["groups"] & db2_data["groups"]),
}


def compare_entries(
entry1: EntryDetails | None, entry2: EntryDetails | None
) -> List[str]:

if entry1 is None or entry2 is None:
return []
if entry1 == entry2:
return []

conflicts = []

for key in entry1.keys():
if entry1[key] != entry2[key]:
conflicts.append(key)

return conflicts
92 changes: 57 additions & 35 deletions KeePassDiff/utils/database.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import datetime
import tempfile
from typing import Dict, Optional, Set, Tuple
from typing import TypedDict, Dict, Optional, Set, Tuple

from pykeepass import PyKeePass
from pykeepass import Group, PyKeePass


class EntryDetails(TypedDict):
title: str | None
username: str | None
password: str | None
url: str | None
notes: str | None
created: datetime.datetime | None
modified: datetime.datetime | None
path: str


def save_temp_database(db_file, keyfile=None) -> Tuple[str, Optional[str]]:
Expand Down Expand Up @@ -42,42 +54,45 @@ def get_entries_set(kp: PyKeePass) -> Dict[str, Set[str]]:
entries = set()
groups = set()

for entry in kp.entries:
# Ensure all elements are strings and not None
entry_path = "/".join(
[str(g) for g in entry.path[:-1] if g is not None]
+ [str(entry.title) if entry.title is not None else ""]
)
entries.add(entry_path)

for group in kp.groups:
if group != "Root":
group_path = "/".join([str(g) for g in group.path if g is not None])
groups.add(group_path)
if kp.entries:
for entry in kp.entries:
# Ensure all elements are strings and not None
entry_path = "/".join(
[str(g) for g in entry.path[:-1] if g is not None]
+ [str(entry.title) if entry.title is not None else ""]
)
entries.add(entry_path)

if kp.groups:
for group in kp.groups:
if not group.is_root_group:
group_path = "/".join([str(g) for g in group.path if g is not None])
groups.add(group_path)

return {"entries": entries, "groups": groups}


def get_entry_details(kp: PyKeePass, entry_path: str) -> Dict:
def get_entry_details(kp: PyKeePass, entry_path: str) -> EntryDetails | None:
path_parts = entry_path.split("/")
title = path_parts[-1]
group_path = path_parts[:-1]

for entry in kp.entries:
if (
entry.title == title
and [str(g) for g in entry.path[:-1] if g is not None] == group_path
):
return {
"title": entry.title,
"username": entry.username,
"password": entry.password,
"url": entry.url,
"notes": entry.notes,
"created": entry.ctime,
"modified": entry.mtime,
"path": "/".join(group_path),
}
if kp.entries:
for entry in kp.entries:
if (
entry.title == title
and [str(g) for g in entry.path[:-1] if g is not None] == group_path
):
return {
"title": entry.title,
"username": entry.username,
"password": entry.password,
"url": entry.url,
"notes": entry.notes,
"created": entry.ctime,
"modified": entry.mtime,
"path": "/".join(group_path),
}
return None


Expand All @@ -87,17 +102,24 @@ def merge_entry(source_kp: PyKeePass, target_kp: PyKeePass, entry_path: str) ->
group_path = path_parts[:-1]

source_entry = None
for entry in source_kp.entries:
if entry.title == title and [g for g in entry.path[:-1]] == group_path:
source_entry = entry
break
if source_kp.entries:
for entry in source_kp.entries:
if entry.title == title and [g for g in entry.path[:-1]] == group_path:
source_entry = entry
break

if not source_entry:
return False

current_group = target_kp.root_group

if not isinstance(current_group, Group):
return False

for group_name in group_path:
next_group = next((g for g in current_group.subgroups if g == group_name), None)
next_group = next(
(g for g in current_group.subgroups if g.name == group_name), None
)
if not next_group:
next_group = target_kp.add_group(current_group, group_name)
current_group = next_group
Expand Down
Loading