From 37f2b131be153de7b93a2565f9bc60e558f84644 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 1 Jun 2026 00:57:01 +0100 Subject: [PATCH 1/6] refactor: enhance type hints and improve entry details handling --- KeePassDiff/components/entry_modal.py | 14 +++-- KeePassDiff/utils/comparison.py | 4 +- KeePassDiff/utils/database.py | 88 ++++++++++++++++----------- 3 files changed, 65 insertions(+), 41 deletions(-) diff --git a/KeePassDiff/components/entry_modal.py b/KeePassDiff/components/entry_modal.py index 9c21d2a..bbd1b5e 100644 --- a/KeePassDiff/components/entry_modal.py +++ b/KeePassDiff/components/entry_modal.py @@ -1,9 +1,12 @@ -from typing import Dict, Optional - import streamlit as st +from KeePassDiff.utils.database import EntryDetails + -def show_entry_details(entry_details: Optional[Dict] = None, key: str = None): +def show_entry_details( + entry_details: EntryDetails | None = None, + key: str = "", +): if entry_details: st.markdown("### Entry Details") st.markdown(f"**Title:** {entry_details['title']}") @@ -12,11 +15,12 @@ def show_entry_details(entry_details: Optional[Dict] = None, key: str = None): st.markdown(f"**URL:** {entry_details['url']}") st.markdown("**Notes:**") st.text_area( - "", + "notes", + label_visibility="collapsed", value=entry_details["notes"], height=100, disabled=True, - key=f"notes_{key}" if key else None, + key=f"notes_{key}", ) st.markdown(f"**Created:** {entry_details['created']}") st.markdown(f"**Modified:** {entry_details['modified']}") diff --git a/KeePassDiff/utils/comparison.py b/KeePassDiff/utils/comparison.py index 16abcea..3e3826b 100644 --- a/KeePassDiff/utils/comparison.py +++ b/KeePassDiff/utils/comparison.py @@ -1,9 +1,9 @@ -from typing import Dict, Set +from typing import Dict, Set, List 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"]), diff --git a/KeePassDiff/utils/database.py b/KeePassDiff/utils/database.py index 2d47afb..4430bbc 100644 --- a/KeePassDiff/utils/database.py +++ b/KeePassDiff/utils/database.py @@ -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]]: @@ -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 group != "Root": + 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 @@ -87,16 +102,21 @@ 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 + for group_name in group_path: + if current_group != Group: + return False + next_group = next((g for g in current_group.subgroups if g == group_name), None) if not next_group: next_group = target_kp.add_group(current_group, group_name) From eff1d556fcd1321a2f9726405b18760c00ee6163 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 1 Jun 2026 01:25:58 +0100 Subject: [PATCH 2/6] refactor: enhance typing - apply review suggestions --- KeePassDiff/components/entry_modal.py | 2 +- KeePassDiff/utils/database.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/KeePassDiff/components/entry_modal.py b/KeePassDiff/components/entry_modal.py index bbd1b5e..721760b 100644 --- a/KeePassDiff/components/entry_modal.py +++ b/KeePassDiff/components/entry_modal.py @@ -5,7 +5,7 @@ def show_entry_details( entry_details: EntryDetails | None = None, - key: str = "", + key: str | None = None, ): if entry_details: st.markdown("### Entry Details") diff --git a/KeePassDiff/utils/database.py b/KeePassDiff/utils/database.py index 4430bbc..76c508a 100644 --- a/KeePassDiff/utils/database.py +++ b/KeePassDiff/utils/database.py @@ -113,11 +113,13 @@ def merge_entry(source_kp: PyKeePass, target_kp: PyKeePass, entry_path: str) -> current_group = target_kp.root_group - for group_name in group_path: - if current_group != Group: - return False + if not isinstance(current_group, Group): + return False - next_group = next((g for g in current_group.subgroups if g == group_name), None) + for group_name in group_path: + 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 From d52a9ca56fb727402b6b18856fe7d6b805737b1e Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 1 Jun 2026 21:30:44 +0100 Subject: [PATCH 3/6] feat: add better common/conflicting details and display in UI --- KeePassDiff/app.py | 82 +++++++++++++++++++++++---- KeePassDiff/components/entry_modal.py | 53 ++++++++++------- KeePassDiff/utils/comparison.py | 20 +++++++ KeePassDiff/utils/database.py | 2 +- 4 files changed, 125 insertions(+), 32 deletions(-) diff --git a/KeePassDiff/app.py b/KeePassDiff/app.py index c57d99c..bdde7c8 100644 --- a/KeePassDiff/app.py +++ b/KeePassDiff/app.py @@ -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, @@ -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) @@ -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() @@ -135,11 +144,18 @@ 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"]: + conflicts = False for entry in differences["common_entries"]: + + entry1_details = get_entry_details(kp1, entry) + entry2_details = get_entry_details(kp2, entry) + if compare_entries(entry1_details, entry2_details) == 1: + continue + conflicts = True + col1, col2 = st.columns([1, 1]) with col1: view_key = f"view1_common_{entry}" @@ -151,8 +167,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( @@ -163,19 +178,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 conflicts: + 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") + 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) diff --git a/KeePassDiff/components/entry_modal.py b/KeePassDiff/components/entry_modal.py index 721760b..f606dcb 100644 --- a/KeePassDiff/components/entry_modal.py +++ b/KeePassDiff/components/entry_modal.py @@ -1,3 +1,5 @@ +from typing import List + import streamlit as st from KeePassDiff.utils.database import EntryDetails @@ -6,24 +8,35 @@ def show_entry_details( entry_details: EntryDetails | None = None, key: str | None = None, + conflicts: List[str] | None = 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( - "notes", - label_visibility="collapsed", - value=entry_details["notes"], - height=100, - disabled=True, - key=f"notes_{key}", - ) - 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") + 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") diff --git a/KeePassDiff/utils/comparison.py b/KeePassDiff/utils/comparison.py index 3e3826b..5a0104a 100644 --- a/KeePassDiff/utils/comparison.py +++ b/KeePassDiff/utils/comparison.py @@ -1,5 +1,9 @@ from typing import Dict, Set, List +from pykeepass import Group + +from KeePassDiff.utils.database import EntryDetails + def compare_databases( db1_data: Dict[str, Set[str]], db2_data: Dict[str, Set[str]] @@ -12,3 +16,19 @@ def compare_databases( "groups_only_in_db2": sorted(db2_data["groups"] - db2_data["groups"]), "common_groups": sorted(db1_data["groups"] & db2_data["groups"]), } + + +def compare_entries(entry1: EntryDetails | None, entry2: EntryDetails | None): + + if entry1 == None or entry2 == None: + return [] + if entry1 == entry2: + return [] + + conflicts = [] + + for key in entry1.keys(): + if entry1[key] != entry2[key]: + conflicts.append(key) + + return conflicts diff --git a/KeePassDiff/utils/database.py b/KeePassDiff/utils/database.py index 76c508a..2b62073 100644 --- a/KeePassDiff/utils/database.py +++ b/KeePassDiff/utils/database.py @@ -65,7 +65,7 @@ def get_entries_set(kp: PyKeePass) -> Dict[str, Set[str]]: if kp.groups: for group in kp.groups: - if group != "Root": + 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) From 9d6d6d67d01ede1f0b61514ef3231148e26ffb78 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 1 Jun 2026 21:46:37 +0100 Subject: [PATCH 4/6] update tests and clean up --- KeePassDiff/utils/comparison.py | 2 -- tests/test_keepassdiff.py | 22 +++++++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/KeePassDiff/utils/comparison.py b/KeePassDiff/utils/comparison.py index 5a0104a..4c370bf 100644 --- a/KeePassDiff/utils/comparison.py +++ b/KeePassDiff/utils/comparison.py @@ -1,7 +1,5 @@ from typing import Dict, Set, List -from pykeepass import Group - from KeePassDiff.utils.database import EntryDetails diff --git a/tests/test_keepassdiff.py b/tests/test_keepassdiff.py index 9703d1f..cb8a378 100644 --- a/tests/test_keepassdiff.py +++ b/tests/test_keepassdiff.py @@ -97,6 +97,10 @@ class DummyGroup: def __init__(self): self.path = [None, "group"] + @property + def is_root_group(self): + return False + class DummyKP: entries = [DummyEntry()] groups = [DummyGroup()] @@ -148,15 +152,15 @@ def test_diff_and_merge_on_programmatic_db(): expected_db2_entries = db2_data["entries"] assert expected_db1_entries == {"a", "b"}, f"db1 entries: {expected_db1_entries}" assert expected_db2_entries == {"b", "c"}, f"db2 entries: {expected_db2_entries}" - assert set(differences["entries_only_in_db1"]) == {"a"}, ( - f"entries_only_in_db1: {differences['entries_only_in_db1']}" - ) - assert set(differences["entries_only_in_db2"]) == {"c"}, ( - f"entries_only_in_db2: {differences['entries_only_in_db2']}" - ) - assert set(differences["common_entries"]) == {"b"}, ( - f"common_entries: {differences['common_entries']}" - ) + assert set(differences["entries_only_in_db1"]) == { + "a" + }, f"entries_only_in_db1: {differences['entries_only_in_db1']}" + assert set(differences["entries_only_in_db2"]) == { + "c" + }, f"entries_only_in_db2: {differences['entries_only_in_db2']}" + assert set(differences["common_entries"]) == { + "b" + }, f"common_entries: {differences['common_entries']}" # Test merge assert merge_entry(kp2, kp1, "c") From 70bb1ce6ad12ec3284802abb2ba19dd9cb30828c Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 1 Jun 2026 22:02:27 +0100 Subject: [PATCH 5/6] refactor: improve common entry conflict detection and enhance compare_entries typing --- KeePassDiff/app.py | 10 ++++++---- KeePassDiff/utils/comparison.py | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/KeePassDiff/app.py b/KeePassDiff/app.py index bdde7c8..a4f2b0b 100644 --- a/KeePassDiff/app.py +++ b/KeePassDiff/app.py @@ -147,14 +147,16 @@ def toggle_entry(key): with tabs[1]: st.subheader("Common Entries") if differences["common_entries"]: - conflicts = False + any_common = False for entry in differences["common_entries"]: entry1_details = get_entry_details(kp1, entry) entry2_details = get_entry_details(kp2, entry) - if compare_entries(entry1_details, entry2_details) == 1: + + if len(compare_entries(entry1_details, entry2_details)) > 0: continue - conflicts = True + + any_common = True col1, col2 = st.columns([1, 1]) with col1: @@ -179,7 +181,7 @@ def toggle_entry(key): if view_key in st.session_state["expanded_entries"]: int_key = view_key + "_common_2" show_entry_details(entry2_details, key=int_key) - if not conflicts: + if not any_common: st.write("All common entries conflict") else: st.write("No common entries found") diff --git a/KeePassDiff/utils/comparison.py b/KeePassDiff/utils/comparison.py index 4c370bf..547028e 100644 --- a/KeePassDiff/utils/comparison.py +++ b/KeePassDiff/utils/comparison.py @@ -16,9 +16,11 @@ def compare_databases( } -def compare_entries(entry1: EntryDetails | None, entry2: EntryDetails | None): +def compare_entries( + entry1: EntryDetails | None, entry2: EntryDetails | None +) -> List[str]: - if entry1 == None or entry2 == None: + if entry1 is None or entry2 is None: return [] if entry1 == entry2: return [] From a21aea3a4ac1ea7e2ec8cb754f93188302321f80 Mon Sep 17 00:00:00 2001 From: Felix Date: Tue, 2 Jun 2026 08:24:07 +0100 Subject: [PATCH 6/6] Update comparison.py --- KeePassDiff/utils/comparison.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KeePassDiff/utils/comparison.py b/KeePassDiff/utils/comparison.py index 547028e..c218e8b 100644 --- a/KeePassDiff/utils/comparison.py +++ b/KeePassDiff/utils/comparison.py @@ -11,7 +11,7 @@ def compare_databases( "entries_only_in_db2": sorted(db2_data["entries"] - db1_data["entries"]), "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"]), }