A kit with no manifest under the Sedona home folder called sys.exit(1), tearing down the whole app over one unopenable file. schema now raises MissingManifestError instead, collecting every unresolved kit first so one dialog names them all rather than the user fixing them one reopen at a time. describe_missing_manifests builds the body, pointing at the configured Sedona home and at Preferences. open_file catches it, reports it, and calls the new reset_to_startup_state to return to the just-launched state: no file, empty tree, empty sheet, cleared registries and undo stack. The generic except now resets too, since a half-parsed file previously left current_file_path and a stale root behind. Placing a palette component hits the same loader, where a full reset would be too destructive: it rolls back the <kit> entry it just added, rebuilds the previous registry, and abandons only that placement. schema no longer imports sys or tkinter, so it reports nothing itself and stays testable without a display. base_window_title moves to app_state so the reset can restore the title without duplicating it. README: document the new behaviour, drop the fixed gap, and correct the stale "no undo, no keyboard shortcuts for save" claim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
495 lines
18 KiB
Python
495 lines
18 KiB
Python
"""Opening, saving and structural editing of a Sedona app file, plus the navigation tree."""
|
|
|
|
from datetime import datetime
|
|
import os
|
|
from tkinter import filedialog, messagebox
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import app_state
|
|
import palette
|
|
import schema
|
|
import undo
|
|
import wiresheet
|
|
from sax_dom import find_parent_element, get_component_path, update_meta_property
|
|
|
|
|
|
def parse_xml_to_tree(treeview, parent_node, xml_element):
|
|
"""Recursively traverses XML elements and adds <comp> tags to the Treeview."""
|
|
for child in xml_element:
|
|
if child.tag == "comp":
|
|
comp_name = child.get("name", "Unknown")
|
|
comp_id = child.get("id")
|
|
|
|
if comp_id is not None:
|
|
display_text = f"{comp_name} [id: {comp_id}]"
|
|
else:
|
|
display_text = comp_name
|
|
|
|
node_id = treeview.insert(
|
|
parent_node, "end", text=display_text, open=False
|
|
)
|
|
app_state.tree_element_map[node_id] = child
|
|
parse_xml_to_tree(treeview, node_id, child)
|
|
|
|
|
|
def refresh_treeview_from_dom(xml_root):
|
|
"""Rebuilds navigation tree structures using current XML DOM trees."""
|
|
tree = app_state.tree
|
|
app_state.tree_element_map.clear()
|
|
|
|
for item in tree.get_children():
|
|
tree.delete(item)
|
|
|
|
if not app_state.current_file_path or xml_root is None:
|
|
return
|
|
|
|
file_name = os.path.basename(app_state.current_file_path)
|
|
root_node = tree.insert("", "end", text=file_name, open=True)
|
|
|
|
app_element = xml_root.find("app")
|
|
if app_element is not None:
|
|
parse_xml_to_tree(tree, root_node, app_element)
|
|
else:
|
|
parse_xml_to_tree(tree, root_node, xml_root)
|
|
|
|
|
|
def reset_to_startup_state():
|
|
"""Returns the app to its just-launched state: no file, empty tree, empty sheet.
|
|
|
|
A load that fails part way has already cleared the previous file, so leaving the
|
|
half-built state on screen would show a tree and a DOM that no longer agree. This
|
|
is the one place that unwinds all of it.
|
|
"""
|
|
app_state.current_file_path = None
|
|
app_state.xml_root_element = None
|
|
app_state.current_sheet_parent = None
|
|
app_state.selected_canvas_tag = None
|
|
app_state.selected_tree_item_id = None
|
|
|
|
app_state.active_components_list.clear()
|
|
app_state.tree_element_map.clear()
|
|
app_state.canvas_comp_map.clear()
|
|
app_state.canvas_tag_by_element.clear()
|
|
app_state.component_slot_offsets.clear()
|
|
app_state.final_components_registry.clear()
|
|
app_state.active_schema_kits.clear()
|
|
|
|
undo.reset()
|
|
app_state.mark_clean()
|
|
|
|
if app_state.tree is not None:
|
|
for item in app_state.tree.get_children():
|
|
app_state.tree.delete(item)
|
|
|
|
if app_state.canvas is not None:
|
|
app_state.canvas.delete("all")
|
|
wiresheet.draw_grid()
|
|
|
|
if app_state.window is not None:
|
|
app_state.window.title(app_state.base_window_title())
|
|
|
|
|
|
def open_file():
|
|
if not app_state.SEDONA_HOME:
|
|
messagebox.showwarning("Action Blocked", "Cannot load SAX profiles because 'Sedona home folder' configuration is missing or empty.")
|
|
return
|
|
|
|
file_path = filedialog.askopenfilename(
|
|
title="Select SAX File",
|
|
filetypes=[("SAX Files", "*.sax"), ("XML Files", "*.xml"), ("All Files", "*.*")],
|
|
)
|
|
if not file_path:
|
|
return
|
|
|
|
canvas = app_state.canvas
|
|
tree = app_state.tree
|
|
|
|
try:
|
|
app_state.current_file_path = file_path
|
|
app_state.active_components_list.clear()
|
|
app_state.selected_canvas_tag = None
|
|
app_state.current_sheet_parent = None
|
|
undo.reset()
|
|
app_state.mark_clean()
|
|
|
|
app_state.tree_element_map.clear()
|
|
app_state.canvas_comp_map.clear()
|
|
app_state.canvas_tag_by_element.clear()
|
|
for item in tree.get_children():
|
|
tree.delete(item)
|
|
canvas.delete("all")
|
|
wiresheet.draw_grid()
|
|
|
|
xml_tree = ET.parse(file_path)
|
|
app_state.xml_root_element = xml_tree.getroot()
|
|
|
|
schema.load_schema_kits_and_manifests()
|
|
|
|
file_name = os.path.basename(file_path)
|
|
root_node = tree.insert("", "end", text=file_name, open=True)
|
|
|
|
app_element = app_state.xml_root_element.find("app")
|
|
if app_element is not None:
|
|
parse_xml_to_tree(tree, root_node, app_element)
|
|
else:
|
|
parse_xml_to_tree(tree, root_node, app_state.xml_root_element)
|
|
|
|
except schema.MissingManifestError as missing:
|
|
messagebox.showerror(
|
|
"Kit manifest missing",
|
|
f"'{os.path.basename(file_path)}' was not opened.\n\n"
|
|
+ schema.describe_missing_manifests(missing)
|
|
+ "\n\nFix the Sedona home folder, then open the file again."
|
|
)
|
|
reset_to_startup_state()
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to parse the file:\n{str(e)}")
|
|
reset_to_startup_state()
|
|
|
|
|
|
def sync_live_metadata_to_dom():
|
|
"""Internal helper to clean up deleted elements and sync coordinates into the root XML object."""
|
|
if app_state.xml_root_element is None:
|
|
return
|
|
|
|
live_elements_meta = {}
|
|
for cached_tag, cached_element in app_state.canvas_comp_map.items():
|
|
comp_id = cached_element.get("id")
|
|
comp_name = cached_element.get("name")
|
|
meta_prop = cached_element.find("prop[@name='meta']")
|
|
if meta_prop is not None:
|
|
live_elements_meta[(comp_id, comp_name)] = meta_prop.get("val")
|
|
|
|
def sync_and_prune_tree(target_xml_parent):
|
|
children_to_examine = list(target_xml_parent)
|
|
for child in children_to_examine:
|
|
if child.tag == "comp":
|
|
comp_id = child.get("id")
|
|
comp_name = child.get("name")
|
|
|
|
is_element_alive = False
|
|
for c_elem in app_state.active_components_list:
|
|
if c_elem.get("id") == comp_id and c_elem.get("name") == comp_name:
|
|
is_element_alive = True
|
|
break
|
|
|
|
is_previously_rendered = any(
|
|
elem.get("id") == comp_id and elem.get("name") == comp_name
|
|
for elem in app_state.canvas_comp_map.values()
|
|
) or (comp_id, comp_name) in live_elements_meta
|
|
|
|
if is_previously_rendered and not is_element_alive:
|
|
target_xml_parent.remove(child)
|
|
continue
|
|
|
|
if (comp_id, comp_name) in live_elements_meta:
|
|
tgt_meta = child.find("prop[@name='meta']")
|
|
if tgt_meta is None:
|
|
tgt_meta = ET.SubElement(child, "prop", name="meta")
|
|
tgt_meta.set("val", live_elements_meta[(comp_id, comp_name)])
|
|
|
|
sync_and_prune_tree(child)
|
|
|
|
sync_and_prune_tree(app_state.xml_root_element)
|
|
|
|
|
|
def execute_direct_overwrite_save():
|
|
"""Overwrites the existing file directly without adding timestamp formats or prompts."""
|
|
if not app_state.current_file_path or app_state.xml_root_element is None:
|
|
return
|
|
|
|
try:
|
|
sync_live_metadata_to_dom()
|
|
|
|
with open(app_state.current_file_path, "wb") as f:
|
|
f.write(b'<?xml version="1.0" encoding="UTF-8"?>\n')
|
|
ET.ElementTree(app_state.xml_root_element).write(f, encoding="utf-8", xml_declaration=False)
|
|
|
|
app_state.mark_clean()
|
|
undo.mark_saved()
|
|
refresh_treeview_from_dom(app_state.xml_root_element)
|
|
|
|
messagebox.showinfo("Success", f"Direct changes saved onto:\n{os.path.basename(app_state.current_file_path)}")
|
|
except Exception as e:
|
|
messagebox.showerror("Save Error", f"Failed to write direct update stream:\n{str(e)}")
|
|
|
|
|
|
def save_file_as():
|
|
"""Saves the tracking tree document hierarchy targeting an explicit location choice dialog."""
|
|
if not app_state.current_file_path or app_state.xml_root_element is None:
|
|
messagebox.showinfo("Save Info", "No active profile has been opened yet.")
|
|
return
|
|
|
|
try:
|
|
sync_live_metadata_to_dom()
|
|
|
|
if app_state.ADD_TIMESTAMP:
|
|
base_dir = os.path.dirname(app_state.current_file_path)
|
|
orig_filename = os.path.basename(app_state.current_file_path)
|
|
name_part, ext_part = os.path.splitext(orig_filename)
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
suggested_name = f"{name_part}_{timestamp}{ext_part}"
|
|
|
|
target_path = filedialog.asksaveasfilename(
|
|
initialdir=base_dir,
|
|
initialfile=suggested_name,
|
|
title="Save Timestamps Layout As",
|
|
filetypes=[
|
|
("SAX Files", "*.sax"),
|
|
("XML Files", "*.xml"),
|
|
("All Files", "*.*"),
|
|
]
|
|
)
|
|
|
|
if not target_path:
|
|
return
|
|
else:
|
|
target_path = app_state.current_file_path
|
|
|
|
with open(target_path, "wb") as f:
|
|
f.write(b'<?xml version="1.0" encoding="UTF-8"?>\n')
|
|
ET.ElementTree(app_state.xml_root_element).write(f, encoding="utf-8", xml_declaration=False)
|
|
|
|
app_state.mark_clean()
|
|
undo.mark_saved()
|
|
|
|
refresh_treeview_from_dom(app_state.xml_root_element)
|
|
|
|
folder_name = os.path.basename(os.path.dirname(target_path))
|
|
file_name = os.path.basename(target_path)
|
|
|
|
messagebox.showinfo("Success", f"File Saved\nFolder: {folder_name}\nFile: {file_name}")
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Save Error", f"An anomaly prevented saving the XML file stream:\n{str(e)}")
|
|
|
|
|
|
MAX_NAME_LENGTH = 7 # every component name in a Sedona app observes this
|
|
|
|
|
|
def allocate_component_id():
|
|
"""Lowest unused id, matching how this app's ids are dense with no gaps."""
|
|
used = set()
|
|
if app_state.xml_root_element is not None:
|
|
for element in app_state.xml_root_element.iter("comp"):
|
|
try:
|
|
used.add(int(element.get("id")))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
candidate = 1
|
|
while candidate in used:
|
|
candidate += 1
|
|
return candidate
|
|
|
|
|
|
def unique_child_name(parent, base_name):
|
|
"""Trims to the 7-character limit and disambiguates against existing siblings."""
|
|
existing = {child.get("name") for child in parent if child.tag == "comp"}
|
|
|
|
candidate = base_name[:MAX_NAME_LENGTH]
|
|
if candidate and candidate not in existing:
|
|
return candidate
|
|
|
|
for counter in range(1, 10000):
|
|
suffix = str(counter)
|
|
stem = base_name[:max(1, MAX_NAME_LENGTH - len(suffix))]
|
|
candidate = f"{stem}{suffix}"[:MAX_NAME_LENGTH]
|
|
if candidate not in existing:
|
|
return candidate
|
|
|
|
return base_name[:MAX_NAME_LENGTH]
|
|
|
|
|
|
def current_sheet_container():
|
|
"""The element a newly placed component belongs to: the open folder, or the app."""
|
|
if app_state.xml_root_element is None:
|
|
return None
|
|
if app_state.current_sheet_parent is not None:
|
|
return app_state.current_sheet_parent
|
|
|
|
app_element = app_state.xml_root_element.find("app")
|
|
return app_element if app_element is not None else app_state.xml_root_element
|
|
|
|
|
|
def ensure_kit_declared(kit_name):
|
|
"""Adds a <kit> entry to <schema> when placing a component from an undeclared kit.
|
|
|
|
The new entry carries the checksum of the manifest the palette resolved, so the file
|
|
records exactly which kit build the component was authored against. Entries already in
|
|
the file are left as they are — a missing checksum there is a deliberate tolerance on
|
|
load, not something to backfill.
|
|
|
|
Returns the element that was added, or None when the kit was already declared. A kit
|
|
absent from the schema also has to exist in the device's scode, so the caller warns.
|
|
"""
|
|
if app_state.xml_root_element is None:
|
|
return None
|
|
|
|
schema_elem = app_state.xml_root_element.find("schema")
|
|
if schema_elem is None:
|
|
return None
|
|
|
|
for kit in schema_elem.findall("kit"):
|
|
if kit.get("name") == kit_name:
|
|
return None
|
|
|
|
kit_element = ET.Element("kit")
|
|
kit_element.set("name", kit_name)
|
|
|
|
checksum = palette.resolve_checksum(kit_name)
|
|
if checksum:
|
|
kit_element.set("checksum", checksum)
|
|
|
|
# Added after the last existing kit. The block is not alphabetical — sys comes first
|
|
# by convention — so appending is safer than guessing a sort position.
|
|
insert_at = len(list(schema_elem))
|
|
for index, existing in enumerate(list(schema_elem)):
|
|
if existing.tag == "kit":
|
|
insert_at = index + 1
|
|
schema_elem.insert(insert_at, kit_element)
|
|
|
|
return kit_element
|
|
|
|
|
|
def add_component(kit_name, type_name, grid_x, grid_y):
|
|
"""Places a new component of kit::type at a grid position. Returns the element."""
|
|
parent = current_sheet_container()
|
|
if parent is None:
|
|
messagebox.showwarning("Add Component", "Open a SAX file first.")
|
|
return None
|
|
|
|
kit_added = ensure_kit_declared(kit_name)
|
|
if kit_added is not None:
|
|
proceed = messagebox.askokcancel(
|
|
"Kit not in schema",
|
|
f"'{kit_name}' is not declared in this app's schema.\n\n"
|
|
f"It will be added, but the device's scode must also contain {kit_name} "
|
|
"or the application will not run.\n\nContinue?"
|
|
)
|
|
if not proceed:
|
|
schema_elem = app_state.xml_root_element.find("schema")
|
|
if schema_elem is not None:
|
|
schema_elem.remove(kit_added)
|
|
return None
|
|
|
|
# New kit means new types to resolve before the box can be painted.
|
|
try:
|
|
schema.load_schema_kits_and_manifests()
|
|
except schema.MissingManifestError as missing:
|
|
messagebox.showerror(
|
|
"Kit manifest missing",
|
|
f"'{kit_name}' was not added.\n\n"
|
|
+ schema.describe_missing_manifests(missing)
|
|
)
|
|
# The kit entry goes back out and the registry is rebuilt from the kits that
|
|
# were resolvable a moment ago, so only this placement is abandoned.
|
|
schema_elem = app_state.xml_root_element.find("schema")
|
|
if schema_elem is not None:
|
|
schema_elem.remove(kit_added)
|
|
try:
|
|
schema.load_schema_kits_and_manifests()
|
|
except schema.MissingManifestError:
|
|
reset_to_startup_state()
|
|
return None
|
|
|
|
comp_id = allocate_component_id()
|
|
comp_name = unique_child_name(parent, type_name)
|
|
|
|
element = ET.Element("comp")
|
|
element.set("name", comp_name)
|
|
element.set("id", str(comp_id))
|
|
element.set("type", f"{kit_name}::{type_name}")
|
|
|
|
meta_prop = ET.SubElement(element, "prop")
|
|
meta_prop.set("name", "meta")
|
|
update_meta_property(element, None, grid_x, grid_y)
|
|
|
|
index = len(list(parent))
|
|
parent.append(element)
|
|
|
|
undo.push(undo.AddComponentCommand(
|
|
parent, index, element,
|
|
schema_parent=app_state.xml_root_element.find("schema"),
|
|
kit_element=kit_added,
|
|
label=f"add {comp_name}"
|
|
))
|
|
|
|
app_state.mark_dirty()
|
|
refresh_treeview_from_dom(app_state.xml_root_element)
|
|
wiresheet.render_current_sheet()
|
|
|
|
return element
|
|
|
|
|
|
def execute_cascading_deletion(comp_element):
|
|
"""Shared application logic to wipe an element, its children, and related links from DOM and runtime lists."""
|
|
comp_name = comp_element.get("name", "Unknown")
|
|
comp_id = comp_element.get("id", "??")
|
|
|
|
descendant_comps = [c for c in comp_element.iter() if c.tag == "comp" and c != comp_element]
|
|
descendant_count = len(descendant_comps)
|
|
|
|
warning_msg = f"Are you sure you want to remove component '{comp_name}' [id: {comp_id}]?"
|
|
if descendant_count > 0:
|
|
warning_msg += f"\n\n⚠️ WARNING: This component contains {descendant_count} nested sub-component(s) under it. Deleting it will purge ALL of them recursively."
|
|
|
|
confirm = messagebox.askyesno("Confirm Cascading Delete", warning_msg)
|
|
if not confirm:
|
|
return False
|
|
|
|
comp_path = get_component_path(comp_element, app_state.xml_root_element)
|
|
|
|
all_elements_to_prune = [comp_element] + descendant_comps
|
|
for elem in all_elements_to_prune:
|
|
if elem in app_state.active_components_list:
|
|
app_state.active_components_list.remove(elem)
|
|
|
|
# Positions are recorded before anything is removed, so the delete can be reversed.
|
|
link_records = []
|
|
if app_state.xml_root_element is not None:
|
|
links_elem = app_state.xml_root_element.find("links")
|
|
if links_elem is not None:
|
|
for index, link in enumerate(list(links_elem)):
|
|
if link.tag != "link":
|
|
continue
|
|
l_from = link.get("from", "")
|
|
l_to = link.get("to", "")
|
|
|
|
from_match = l_from.startswith(comp_path + ".") or l_from.startswith(comp_path + "/")
|
|
to_match = l_to.startswith(comp_path + ".") or l_to.startswith(comp_path + "/")
|
|
|
|
if from_match or to_match:
|
|
link_records.append((links_elem, index, link))
|
|
|
|
for links_parent, _, link in link_records:
|
|
links_parent.remove(link)
|
|
|
|
xml_parent = None
|
|
comp_index = 0
|
|
if app_state.xml_root_element is not None:
|
|
xml_parent = find_parent_element(app_state.xml_root_element, comp_element)
|
|
if xml_parent is not None:
|
|
comp_index = list(xml_parent).index(comp_element)
|
|
xml_parent.remove(comp_element)
|
|
|
|
if xml_parent is not None:
|
|
undo.push(undo.DeleteComponentCommand(
|
|
xml_parent, comp_index, comp_element, link_records,
|
|
label=f"delete {comp_name}"
|
|
))
|
|
|
|
target_canvas_tag = app_state.canvas_tag_by_element.get(comp_element)
|
|
if target_canvas_tag and target_canvas_tag == app_state.selected_canvas_tag:
|
|
app_state.selected_canvas_tag = None
|
|
|
|
if target_canvas_tag:
|
|
app_state.canvas.delete(target_canvas_tag)
|
|
|
|
app_state.mark_dirty()
|
|
|
|
refresh_treeview_from_dom(app_state.xml_root_element)
|
|
wiresheet.render_components(app_state.active_components_list)
|
|
return True
|