diff --git a/KeePassDiff/app.py b/KeePassDiff/app.py index c57d99c..a4f2b0b 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,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) + 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}" @@ -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( @@ -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") + 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 9c21d2a..f606dcb 100644 --- a/KeePassDiff/components/entry_modal.py +++ b/KeePassDiff/components/entry_modal.py @@ -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") diff --git a/KeePassDiff/utils/comparison.py b/KeePassDiff/utils/comparison.py index 16abcea..c218e8b 100644 --- a/KeePassDiff/utils/comparison.py +++ b/KeePassDiff/utils/comparison.py @@ -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"]), "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 diff --git a/KeePassDiff/utils/database.py b/KeePassDiff/utils/database.py index 2d47afb..2b62073 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 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 @@ -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 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")