|
#!/usr/bin/env -S uv run --script |
|
# /// script |
|
# requires-python = ">=3.13" |
|
# dependencies = [ |
|
# "cyclopts", |
|
# ] |
|
# /// |
|
|
|
import logging |
|
import sys |
|
import xml.etree.ElementTree as ET |
|
from typing import Iterable |
|
|
|
import cyclopts |
|
|
|
app = cyclopts.App() |
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
def read_node_title(node: ET.Element) -> str: |
|
"""Get the title of a node. |
|
|
|
Args: |
|
node: The XML node. |
|
|
|
Returns: |
|
The title of the node. |
|
""" |
|
for ele in node.findall("String"): |
|
if ele.find("Key").text == "Title": |
|
return ele.find("Value").text |
|
return "UNKNOWN" |
|
|
|
|
|
def add_tag_field(node: ET.Element): |
|
"""Traverses all entries and adds a string field with the tags. |
|
|
|
Args: |
|
node: The XML node. |
|
""" |
|
for entry in node.findall(".//Entry"): |
|
tags = entry.find("Tags") |
|
if tags is not None and tags.text: |
|
logger.info("Entry '%s' has tags '%s'", read_node_title(entry), tags.text) |
|
ele = ET.Element("String") |
|
ET.SubElement(ele, "Key").text = "Tags" |
|
ET.SubElement(ele, "Value").text = tags.text |
|
entry.insert(0, ele) # Insert at the beginning of the entry |
|
|
|
|
|
def rename_url_fields(node: ET.Element) -> None: |
|
"""Traverses all entries and renames the additional URL fields. |
|
|
|
Args: |
|
node: The XML node. |
|
""" |
|
raise NotImplementedError("This function is not implemented correctly.") |
|
|
|
for entry in node.findall(".//Entry"): |
|
sub_strings = entry.findall("String") |
|
for sub_string in sub_strings: |
|
if sub_string.find("Key").text.startswith("KP2A_URL"): |
|
logger.info( |
|
"Entry '%s' has extra URL '%s'.", |
|
read_node_title(entry), |
|
sub_string.find("Value").text, |
|
) |
|
sub_string.find("Key").text = "URL" |
|
|
|
|
|
def add_group_field(node: ET.Element, path: str = "") -> None: |
|
"""Traverses the group hierarchy and adds a string field with the group path. |
|
|
|
Args: |
|
node: The XML node. |
|
path: The recursive path to the group. |
|
""" |
|
sub_groups = node.findall("Group") |
|
for group in sub_groups: |
|
group_name = group.find("Name").text |
|
sub_path = " / ".join([path, group_name]).strip(" / ") |
|
add_group_field(group, sub_path) |
|
for entry in group.findall("Entry") + group.findall("Entry/History/Entry"): |
|
logger.info("Adjust entry '%s / %s'.", sub_path, read_node_title(entry)) |
|
ele = ET.Element("String") |
|
ET.SubElement(ele, "Key").text = "Group" |
|
ET.SubElement(ele, "Value").text = sub_path |
|
entry.insert(0, ele) # Insert at the beginning of the entry |
|
|
|
|
|
def keep_groups(node: ET.Element, group_paths: Iterable[str], path: str = "") -> None: |
|
"""Keep only the specified groups in the KeePass database. |
|
|
|
Args: |
|
node: The XML node. |
|
group_paths: Paths to the groups to be kept. |
|
path: The recursive path to the group. |
|
""" |
|
group_paths_list = list(group_paths) |
|
sub_groups = node.findall("Group") |
|
for group in sub_groups: |
|
group_name = group.find("Name").text |
|
sub_path = " / ".join([path, group_name]).strip(" / ") |
|
if sub_path in group_paths_list: |
|
logger.debug("Keeping group '%s'.", sub_path) |
|
keep_groups(group, group_paths_list, sub_path) |
|
group_paths_list.remove(sub_path) |
|
elif any(sub_path.startswith(f"{s} / ") for s in group_paths_list): |
|
logger.debug("Keeping group '%s' by recursion.", sub_path) |
|
keep_groups(group, group_paths_list, sub_path) |
|
else: |
|
logger.info("Removing group '%s'.", sub_path) |
|
node.remove(group) |
|
if not path: |
|
for group_path in group_paths_list: |
|
logger.warning("Group '%s' not found.", group_path) |
|
|
|
|
|
def remove_groups(node: ET.Element, group_paths: Iterable[str], path: str = "") -> None: |
|
"""Remove unwanted groups from the KeePass database. |
|
|
|
Args: |
|
node: The XML node. |
|
group_paths: Paths to the groups to be removed. |
|
path: The recursive path to the group. |
|
""" |
|
group_paths_list = list(group_paths) |
|
sub_groups = node.findall("Group") |
|
for group in sub_groups: |
|
group_name = group.find("Name").text |
|
sub_path = " / ".join([path, group_name]).strip(" / ") |
|
if sub_path in group_paths_list: |
|
logger.info("Removing group '%s'.", sub_path) |
|
node.remove(group) |
|
group_paths_list.remove(sub_path) |
|
for group_path in group_paths_list: |
|
logger.warning("Group '%s' not found.", group_path) |
|
|
|
|
|
@app.default |
|
def main( |
|
kdbx: cyclopts.types.ExistingPath, |
|
*, |
|
exclude_group: list[str] = [], |
|
only_group: list[str] = [], |
|
) -> int: |
|
"""Transcribe tags and groups for a KeePassXC xml-export into string fields. |
|
|
|
Args: |
|
kdbx: Path to the KeePassXC xml-export file. |
|
exclude_group: List of groups to be excluded. |
|
only_group: List of groups to consider. |
|
|
|
Returns: |
|
Zero on success, non-zero on failure. |
|
""" |
|
logger.info("Read the KeePassXC XML file.") |
|
content = kdbx.read_text(encoding="utf-8") |
|
kdbx_out = kdbx.with_stem(kdbx.stem + "_converted") |
|
|
|
logger.info("Parse the XML content.") |
|
try: |
|
root = ET.fromstring(content) |
|
root_group = root.find("Root/Group") |
|
except Exception: |
|
logger.error("Failed parsing XML content.") |
|
return 1 |
|
|
|
if only_group: |
|
logger.info("Keep specified groups.") |
|
keep_groups(root_group, only_group) |
|
|
|
if exclude_group: |
|
logger.info("Exclude specified groups.") |
|
remove_groups(root_group, exclude_group) |
|
|
|
logger.info("Transcribe tags into string field.") |
|
add_tag_field(root_group) |
|
|
|
# logger.info("Rename extra URL fields.") |
|
# rename_url_fields(root_group) |
|
|
|
logger.info("Transcribe group hierarchy into string field.") |
|
add_group_field(root_group) |
|
|
|
logger.info("Write the XML content to a new file.") |
|
kdbx_out.write_text( |
|
ET.tostring(root, encoding="utf-8").decode("utf-8"), |
|
encoding="utf-8", |
|
) |
|
return 0 |
|
|
|
|
|
if __name__ == "__main__": |
|
logging.basicConfig( |
|
level=logging.INFO, |
|
format="%(asctime)s | %(levelname)-8s | %(message)s", |
|
) |
|
sys.exit(app()) |