Compare commits
2
Commits
b286b208da
...
b9a6428200
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9a6428200 | ||
|
|
01dadea610 |
+1
-1
@@ -110,7 +110,7 @@ def save_preferences():
|
|||||||
|
|
||||||
def base_window_title():
|
def base_window_title():
|
||||||
"""The window title with no file context, which handlers extend with their own suffix."""
|
"""The window title with no file context, which handlers extend with their own suffix."""
|
||||||
return f"Sedona SAX Tree View Viewer & Program Editor [v{VERSION}]"
|
return f"Sedona SAX Offline Program Editor [v{VERSION}]"
|
||||||
|
|
||||||
|
|
||||||
def set_status(message, warn=False):
|
def set_status(message, warn=False):
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import tkinter as tk
|
|||||||
from tkinter import filedialog, messagebox, ttk
|
from tkinter import filedialog, messagebox, ttk
|
||||||
|
|
||||||
import app_state
|
import app_state
|
||||||
|
import manage_kits
|
||||||
import palette
|
import palette
|
||||||
import sax_file
|
import sax_file
|
||||||
import schema
|
import schema
|
||||||
@@ -768,6 +769,13 @@ menu_edit.add_separator()
|
|||||||
menu_edit.add_command(label="Delete Component", command=delete_selected_component, accelerator="Del")
|
menu_edit.add_command(label="Delete Component", command=delete_selected_component, accelerator="Del")
|
||||||
menu_bar.add_cascade(label="Edit", menu=menu_edit)
|
menu_bar.add_cascade(label="Edit", menu=menu_edit)
|
||||||
|
|
||||||
|
menu_kits = tk.Menu(menu_bar, tearoff=0)
|
||||||
|
menu_kits.add_command(
|
||||||
|
label="Manage Kits...",
|
||||||
|
command=lambda: manage_kits.open_dialog(on_applied=refresh_palette_kits)
|
||||||
|
)
|
||||||
|
menu_bar.add_cascade(label="Kits", menu=menu_kits)
|
||||||
|
|
||||||
menu_layout = tk.Menu(menu_bar, tearoff=0)
|
menu_layout = tk.Menu(menu_bar, tearoff=0)
|
||||||
menu_layout.add_command(label="Tidy Layout", command=do_tidy_layout)
|
menu_layout.add_command(label="Tidy Layout", command=do_tidy_layout)
|
||||||
menu_bar.add_cascade(label="Layout", menu=menu_layout)
|
menu_bar.add_cascade(label="Layout", menu=menu_layout)
|
||||||
|
|||||||
+480
@@ -0,0 +1,480 @@
|
|||||||
|
"""The Manage Kits dialog: what the app pins, what Sedona home offers, and repinning.
|
||||||
|
|
||||||
|
The table answers two questions per kit — which build the app is pinned to, and which
|
||||||
|
builds exist — and the Action column is where a different build is chosen. Nothing is
|
||||||
|
written to the file until one of the buttons is pressed, so the whole table can be set up
|
||||||
|
first and reviewed as a batch.
|
||||||
|
|
||||||
|
Rows come in three groups, in this order: kits the app needs — named by a component or
|
||||||
|
depended on by a kit that is — then kits the schema declares without needing, then
|
||||||
|
everything else installed in Sedona home.
|
||||||
|
|
||||||
|
The tick in the first column means "the schema declares this kit". A kit the app needs is
|
||||||
|
ticked and greyed, because removing it would stop the app running; one that is merely
|
||||||
|
declared can be unticked, which turns its Action into REMOVE. An installed kit that is not
|
||||||
|
declared starts unticked, and ticking it adds it at its newest build.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import messagebox, ttk
|
||||||
|
|
||||||
|
import app_state
|
||||||
|
import palette
|
||||||
|
import sax_file
|
||||||
|
|
||||||
|
NOT_DECLARED = "—" # em dash, for a column with nothing to report
|
||||||
|
NO_CHECKSUM = "(no checksum)"
|
||||||
|
REMOVE_ACTION = "REMOVE from app"
|
||||||
|
|
||||||
|
SECTION_TAG = "section"
|
||||||
|
|
||||||
|
CHECKBOX_SIZE = 13
|
||||||
|
|
||||||
|
# The tick, as filled 2x2 blocks: a short down-stroke into a long up-stroke.
|
||||||
|
TICK_BLOCKS = [
|
||||||
|
(2, 5), (3, 6), (4, 7), (5, 6), (6, 5), (7, 4), (8, 3), (9, 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def make_checkbox_image(checked, enabled):
|
||||||
|
"""A checkbox drawn pixel by pixel, so a locked tick can be greyed out.
|
||||||
|
|
||||||
|
Tk has no disabled state for a cell, and a row tag would grey the whole row including
|
||||||
|
an Action the user can still change — so the box itself carries the distinction.
|
||||||
|
"""
|
||||||
|
image = tk.PhotoImage(width=CHECKBOX_SIZE, height=CHECKBOX_SIZE)
|
||||||
|
|
||||||
|
face = "#ffffff" if enabled else "#f0f0f0"
|
||||||
|
border = "#555555" if enabled else "#a8a8a8"
|
||||||
|
ink = "#1a1a1a" if enabled else "#909090"
|
||||||
|
|
||||||
|
image.put(face, to=(0, 0, CHECKBOX_SIZE, CHECKBOX_SIZE))
|
||||||
|
image.put(border, to=(0, 0, CHECKBOX_SIZE, 1))
|
||||||
|
image.put(border, to=(0, CHECKBOX_SIZE - 1, CHECKBOX_SIZE, CHECKBOX_SIZE))
|
||||||
|
image.put(border, to=(0, 0, 1, CHECKBOX_SIZE))
|
||||||
|
image.put(border, to=(CHECKBOX_SIZE - 1, 0, CHECKBOX_SIZE, CHECKBOX_SIZE))
|
||||||
|
|
||||||
|
if checked:
|
||||||
|
for x, y in TICK_BLOCKS:
|
||||||
|
image.put(ink, to=(x, y, x + 2, y + 2))
|
||||||
|
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def build_label(version, checksum):
|
||||||
|
"""How a build is named in a sentence: its version, or its checksum when it has none."""
|
||||||
|
return version if version else f"checksum {checksum}"
|
||||||
|
|
||||||
|
|
||||||
|
def column_label(version, checksum):
|
||||||
|
"""How a build is named in the Installed and Latest columns, which are narrow."""
|
||||||
|
return version if version else checksum
|
||||||
|
|
||||||
|
|
||||||
|
def action_options(kit_name, current_checksum, is_declared, builds):
|
||||||
|
"""The Action dropdown for one kit: (label, checksum to apply) pairs, no-op first.
|
||||||
|
|
||||||
|
The no-op comes first so a row does nothing until the user picks something, and the
|
||||||
|
remaining entries read as upgrade or downgrade relative to the pinned build — which is
|
||||||
|
what makes the column scannable when a kit ships thirty builds.
|
||||||
|
"""
|
||||||
|
installed_version = None
|
||||||
|
for version, checksum in builds:
|
||||||
|
if checksum == current_checksum:
|
||||||
|
installed_version = version
|
||||||
|
break
|
||||||
|
|
||||||
|
if not is_declared:
|
||||||
|
options = [("Not in schema", None)]
|
||||||
|
elif current_checksum is None:
|
||||||
|
options = [("Keep with no checksum", None)]
|
||||||
|
elif installed_version is None:
|
||||||
|
# The file pins a build this machine does not hold, so there is nothing to name it
|
||||||
|
# by and no way to tell whether the other builds are newer or older than it.
|
||||||
|
options = [(f"Keep at checksum {current_checksum}", None)]
|
||||||
|
else:
|
||||||
|
options = [(f"Keep at {build_label(installed_version, current_checksum)}", None)]
|
||||||
|
|
||||||
|
installed_rank = palette.version_sort_key(installed_version) if installed_version else None
|
||||||
|
|
||||||
|
for version, checksum in builds:
|
||||||
|
if is_declared and checksum == current_checksum:
|
||||||
|
continue
|
||||||
|
|
||||||
|
label = build_label(version, checksum)
|
||||||
|
if not is_declared:
|
||||||
|
verb = "Add at"
|
||||||
|
elif installed_rank is None or not version:
|
||||||
|
verb = "Set to"
|
||||||
|
elif palette.version_sort_key(version) > installed_rank:
|
||||||
|
verb = "Upgrade to"
|
||||||
|
elif palette.version_sort_key(version) < installed_rank:
|
||||||
|
verb = "Downgrade to"
|
||||||
|
else:
|
||||||
|
verb = "Set to"
|
||||||
|
options.append((f"{verb} {label}", checksum))
|
||||||
|
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def collect_rows():
|
||||||
|
"""Every kit worth listing, as row dicts in the order they should appear.
|
||||||
|
|
||||||
|
A row carries what the table paints and what the buttons act on, so the dialog never
|
||||||
|
has to re-derive a kit's state from the DOM while it is open.
|
||||||
|
"""
|
||||||
|
declared = sax_file.schema_kit_checksums()
|
||||||
|
declared_names = [name for name, _ in declared]
|
||||||
|
required = sax_file.required_kit_names()
|
||||||
|
|
||||||
|
installed = palette.list_kits()
|
||||||
|
# A kit the schema declares but Sedona home does not hold still gets a row: it is the
|
||||||
|
# clearest place to see that the file points at something missing.
|
||||||
|
others = [name for name in installed if name not in declared_names]
|
||||||
|
|
||||||
|
groups = [
|
||||||
|
("Installed kits", [
|
||||||
|
(name, checksum) for name, checksum in declared if name in required
|
||||||
|
]),
|
||||||
|
("Installed but not used kits", [
|
||||||
|
(name, checksum) for name, checksum in declared if name not in required
|
||||||
|
]),
|
||||||
|
("Kits to be installed", [(name, None) for name in others]),
|
||||||
|
]
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for title, entries in groups:
|
||||||
|
if not entries:
|
||||||
|
continue
|
||||||
|
rows.append({"section": title})
|
||||||
|
for name, checksum in entries:
|
||||||
|
builds = palette.kit_builds(name)
|
||||||
|
latest = builds[0] if builds else None
|
||||||
|
is_declared = name in declared_names
|
||||||
|
options = action_options(name, checksum, is_declared, builds)
|
||||||
|
|
||||||
|
if not is_declared:
|
||||||
|
installed_text = NOT_DECLARED
|
||||||
|
elif checksum is None:
|
||||||
|
installed_text = NO_CHECKSUM
|
||||||
|
else:
|
||||||
|
installed_text = column_label(
|
||||||
|
next((v for v, c in builds if c == checksum), ""), checksum
|
||||||
|
)
|
||||||
|
|
||||||
|
rows.append({
|
||||||
|
"kit": name,
|
||||||
|
"declared": is_declared,
|
||||||
|
"required": name in required,
|
||||||
|
"checksum": checksum,
|
||||||
|
"builds": builds,
|
||||||
|
"latest": latest,
|
||||||
|
"installed_text": installed_text,
|
||||||
|
"latest_text": column_label(*latest) if latest else NOT_DECLARED,
|
||||||
|
"options": options,
|
||||||
|
"choice": 0,
|
||||||
|
# The tick reflects the file as it stands: declared kits start ticked.
|
||||||
|
"checked": is_declared,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def describe_changes(changes):
|
||||||
|
"""The applied-changes report, one kit per line, old build to new."""
|
||||||
|
lines = []
|
||||||
|
for kit_name, old_checksum, new_checksum in changes:
|
||||||
|
old_label = (
|
||||||
|
build_label(palette.version_for_checksum(kit_name, old_checksum), old_checksum)
|
||||||
|
if old_checksum else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_checksum is None:
|
||||||
|
was = f" (was {old_label})" if old_label else ""
|
||||||
|
lines.append(f" • {kit_name}: removed from the schema{was}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_label = build_label(
|
||||||
|
palette.version_for_checksum(kit_name, new_checksum), new_checksum
|
||||||
|
)
|
||||||
|
if old_checksum is None:
|
||||||
|
lines.append(f" • {kit_name}: added at {new_label}")
|
||||||
|
else:
|
||||||
|
lines.append(f" • {kit_name}: {old_label} → {new_label}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def open_dialog(on_applied=None):
|
||||||
|
"""Opens the kit table. ``on_applied`` is called after the schema really changed."""
|
||||||
|
window = app_state.window
|
||||||
|
|
||||||
|
if app_state.xml_root_element is None:
|
||||||
|
messagebox.showinfo("Manage Kits", "Open a SAX file first.")
|
||||||
|
return
|
||||||
|
if app_state.xml_root_element.find("schema") is None:
|
||||||
|
messagebox.showwarning("Manage Kits", "This file has no <schema> block to manage.")
|
||||||
|
return
|
||||||
|
|
||||||
|
dialog = tk.Toplevel(window)
|
||||||
|
dialog.title("Kits")
|
||||||
|
dialog.transient(window)
|
||||||
|
dialog.grab_set()
|
||||||
|
dialog.geometry(f"820x520+{window.winfo_x() + 60}+{window.winfo_y() + 40}")
|
||||||
|
dialog.minsize(640, 320)
|
||||||
|
|
||||||
|
frame = ttk.Frame(dialog, padding=10)
|
||||||
|
frame.pack(fill=tk.BOTH, expand=True)
|
||||||
|
|
||||||
|
COLUMNS = ("name", "installed", "latest", "action")
|
||||||
|
# 'tree headings' keeps the icon column, which is the only place a real checkbox image
|
||||||
|
# can live; the data columns follow it.
|
||||||
|
table = ttk.Treeview(frame, columns=COLUMNS, show="tree headings", selectmode="browse")
|
||||||
|
table.heading("#0", text="")
|
||||||
|
table.column("#0", width=32, minwidth=32, stretch=False, anchor="center")
|
||||||
|
for column, heading, width, anchor in [
|
||||||
|
("name", "Kit name", 200, "w"),
|
||||||
|
("installed", "Installed", 105, "w"),
|
||||||
|
("latest", "Latest", 105, "w"),
|
||||||
|
("action", "Action", 300, "w"),
|
||||||
|
]:
|
||||||
|
table.heading(column, text=heading, anchor=anchor)
|
||||||
|
table.column(column, width=width, anchor=anchor, stretch=(column == "action"))
|
||||||
|
|
||||||
|
scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=table.yview)
|
||||||
|
table.configure(yscrollcommand=scrollbar.set)
|
||||||
|
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||||
|
table.pack(fill=tk.BOTH, expand=True)
|
||||||
|
|
||||||
|
table.tag_configure(SECTION_TAG, background="#dcdcdc")
|
||||||
|
|
||||||
|
# Held on the dialog: a PhotoImage is dropped as soon as nothing references it.
|
||||||
|
boxes = {
|
||||||
|
(True, True): make_checkbox_image(True, True),
|
||||||
|
(False, True): make_checkbox_image(False, True),
|
||||||
|
(True, False): make_checkbox_image(True, False),
|
||||||
|
}
|
||||||
|
|
||||||
|
hint = ttk.Label(
|
||||||
|
frame,
|
||||||
|
text="A tick means the schema declares the kit. Grey ticks are needed by the app "
|
||||||
|
"and cannot be removed; untick another to remove it. Click the Action cell to "
|
||||||
|
"choose a build. Nothing reaches the file until you save it.",
|
||||||
|
foreground="#666666",
|
||||||
|
wraplength=780,
|
||||||
|
)
|
||||||
|
hint.pack(anchor="w", pady=(8, 0))
|
||||||
|
|
||||||
|
button_row = ttk.Frame(frame)
|
||||||
|
button_row.pack(fill=tk.X, pady=(10, 0))
|
||||||
|
|
||||||
|
rows = [] # row dicts, table order
|
||||||
|
row_by_item = {} # Treeview item id -> row dict
|
||||||
|
editor = {"widget": None}
|
||||||
|
|
||||||
|
def close_editor(_event=None):
|
||||||
|
if editor["widget"] is not None:
|
||||||
|
editor["widget"].destroy()
|
||||||
|
editor["widget"] = None
|
||||||
|
|
||||||
|
def action_text(row):
|
||||||
|
"""What the Action cell says, which an untick overrides with REMOVE."""
|
||||||
|
if row["declared"] and not row["checked"]:
|
||||||
|
return REMOVE_ACTION
|
||||||
|
return row["options"][row["choice"]][0]
|
||||||
|
|
||||||
|
def row_values(row):
|
||||||
|
return (row["kit"], row["installed_text"], row["latest_text"], action_text(row))
|
||||||
|
|
||||||
|
def row_image(row):
|
||||||
|
return boxes[(row["checked"], not row["required"])]
|
||||||
|
|
||||||
|
def rebuild_table():
|
||||||
|
close_editor()
|
||||||
|
for item in table.get_children():
|
||||||
|
table.delete(item)
|
||||||
|
row_by_item.clear()
|
||||||
|
|
||||||
|
rows[:] = collect_rows()
|
||||||
|
for row in rows:
|
||||||
|
if "section" in row:
|
||||||
|
table.insert("", "end", values=(row["section"], "", "", ""), tags=(SECTION_TAG,))
|
||||||
|
continue
|
||||||
|
item = table.insert("", "end", image=row_image(row), values=row_values(row))
|
||||||
|
row_by_item[item] = row
|
||||||
|
|
||||||
|
def refresh_row(item):
|
||||||
|
row = row_by_item[item]
|
||||||
|
table.item(item, image=row_image(row), values=row_values(row))
|
||||||
|
|
||||||
|
def set_choice(item, index):
|
||||||
|
row = row_by_item[item]
|
||||||
|
row["choice"] = index
|
||||||
|
|
||||||
|
# Naming a build is the user saying they want the kit at it, so the row ticks
|
||||||
|
# itself — and for a declared row that also cancels a pending removal.
|
||||||
|
if row["declared"] or row["options"][index][1] is not None:
|
||||||
|
row["checked"] = True
|
||||||
|
refresh_row(item)
|
||||||
|
|
||||||
|
def toggle_check(item):
|
||||||
|
row = row_by_item[item]
|
||||||
|
if row["required"]:
|
||||||
|
# Needed by the app: the tick is fixed, and the box is drawn greyed to say so.
|
||||||
|
return
|
||||||
|
|
||||||
|
row["checked"] = not row["checked"]
|
||||||
|
if not row["declared"]:
|
||||||
|
# Ticking a kit that is not in the schema means adding it, at its newest build
|
||||||
|
# unless the user picks another; unticking puts the row back to doing nothing.
|
||||||
|
row["choice"] = 1 if (row["checked"] and len(row["options"]) > 1) else 0
|
||||||
|
if row["checked"] and len(row["options"]) < 2:
|
||||||
|
row["checked"] = False
|
||||||
|
messagebox.showwarning(
|
||||||
|
"Manage Kits",
|
||||||
|
f"Sedona home holds no manifest for '{row['kit']}', so there is no "
|
||||||
|
"build to add it at.",
|
||||||
|
parent=dialog
|
||||||
|
)
|
||||||
|
refresh_row(item)
|
||||||
|
|
||||||
|
def edit_action(item):
|
||||||
|
row = row_by_item[item]
|
||||||
|
cell = table.bbox(item, "action")
|
||||||
|
if not cell:
|
||||||
|
return
|
||||||
|
|
||||||
|
labels = [label for label, _ in row["options"]]
|
||||||
|
current = action_text(row)
|
||||||
|
variable = tk.StringVar(value=current if current in labels else labels[row["choice"]])
|
||||||
|
combo = ttk.Combobox(table, textvariable=variable, values=labels, state="readonly")
|
||||||
|
combo.place(x=cell[0], y=cell[1], width=cell[2], height=cell[3])
|
||||||
|
editor["widget"] = combo
|
||||||
|
|
||||||
|
def commit(_event=None):
|
||||||
|
if variable.get() in labels:
|
||||||
|
set_choice(item, labels.index(variable.get()))
|
||||||
|
close_editor()
|
||||||
|
|
||||||
|
combo.bind("<<ComboboxSelected>>", commit)
|
||||||
|
combo.bind("<Escape>", lambda event: close_editor())
|
||||||
|
combo.focus_set()
|
||||||
|
# A single click on the cell should show the builds, not just a collapsed box.
|
||||||
|
combo.after_idle(lambda: combo.event_generate("<Down>"))
|
||||||
|
|
||||||
|
def on_click(event):
|
||||||
|
close_editor()
|
||||||
|
if table.identify_region(event.x, event.y) not in ("tree", "cell"):
|
||||||
|
return
|
||||||
|
item = table.identify_row(event.y)
|
||||||
|
if item not in row_by_item:
|
||||||
|
return
|
||||||
|
|
||||||
|
column = table.identify_column(event.x)
|
||||||
|
if column == "#0":
|
||||||
|
toggle_check(item)
|
||||||
|
elif column == "#4":
|
||||||
|
edit_action(item)
|
||||||
|
|
||||||
|
table.bind("<Button-1>", on_click)
|
||||||
|
table.bind("<MouseWheel>", close_editor, add="+")
|
||||||
|
table.bind("<Configure>", close_editor, add="+")
|
||||||
|
|
||||||
|
def apply_changes(selections, removals, label, nothing_message, note=""):
|
||||||
|
if not selections and not removals:
|
||||||
|
messagebox.showinfo("Manage Kits", nothing_message, parent=dialog)
|
||||||
|
return
|
||||||
|
|
||||||
|
changes, error = sax_file.apply_kit_selections(selections, removals, label=label)
|
||||||
|
if error:
|
||||||
|
messagebox.showerror("Kits unchanged", error, parent=dialog)
|
||||||
|
rebuild_table()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not changes:
|
||||||
|
messagebox.showinfo(
|
||||||
|
"Manage Kits", "The schema already reads that way.", parent=dialog
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
rebuild_table()
|
||||||
|
if on_applied is not None:
|
||||||
|
on_applied()
|
||||||
|
|
||||||
|
app_state.set_status(
|
||||||
|
f"schema kits changed ({len(changes)}) — save the file to keep it.",
|
||||||
|
warn=True
|
||||||
|
)
|
||||||
|
messagebox.showinfo(
|
||||||
|
"Kits updated",
|
||||||
|
f"The schema block now reads:\n\n{describe_changes(changes)}\n\n"
|
||||||
|
+ (note + "\n\n" if note else "")
|
||||||
|
+ "Save the file to write this to disk. The device's scode must hold these "
|
||||||
|
"builds or the application will not run.",
|
||||||
|
parent=dialog
|
||||||
|
)
|
||||||
|
|
||||||
|
def upgrade_all():
|
||||||
|
"""Every kit the schema declares, moved to the newest build Sedona home holds."""
|
||||||
|
selections = []
|
||||||
|
for row in rows:
|
||||||
|
if "section" in row or not row["declared"] or not row["latest"]:
|
||||||
|
continue
|
||||||
|
latest_checksum = row["latest"][1]
|
||||||
|
if latest_checksum != row["checksum"]:
|
||||||
|
selections.append((row["kit"], latest_checksum))
|
||||||
|
|
||||||
|
if selections:
|
||||||
|
preview = "\n".join(
|
||||||
|
f" • {kit}: {build_label(palette.version_for_checksum(kit, checksum), checksum)}"
|
||||||
|
for kit, checksum in selections
|
||||||
|
)
|
||||||
|
proceed = messagebox.askokcancel(
|
||||||
|
"Upgrade all kits",
|
||||||
|
f"{len(selections)} kit{'' if len(selections) == 1 else 's'} in this app's "
|
||||||
|
f"schema will move to their latest installed build:\n\n{preview}\n\nContinue?",
|
||||||
|
icon=messagebox.WARNING,
|
||||||
|
parent=dialog
|
||||||
|
)
|
||||||
|
if not proceed:
|
||||||
|
return
|
||||||
|
|
||||||
|
apply_changes(
|
||||||
|
selections, [],
|
||||||
|
"upgrade all kits",
|
||||||
|
"Every kit in the schema is already at its latest installed build."
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_marked():
|
||||||
|
"""The ticks and the Action column, applied: repins, additions and removals."""
|
||||||
|
selections = []
|
||||||
|
removals = []
|
||||||
|
for row in row_by_item.values():
|
||||||
|
if row["declared"] and not row["checked"]:
|
||||||
|
removals.append(row["kit"])
|
||||||
|
continue
|
||||||
|
if not row["checked"]:
|
||||||
|
continue
|
||||||
|
checksum = row["options"][row["choice"]][1]
|
||||||
|
if checksum:
|
||||||
|
selections.append((row["kit"], checksum))
|
||||||
|
|
||||||
|
note = ""
|
||||||
|
if removals:
|
||||||
|
note = (
|
||||||
|
"Removed kits are gone from the schema only. Any build of them still "
|
||||||
|
"installed in Sedona home can be added back here."
|
||||||
|
)
|
||||||
|
|
||||||
|
apply_changes(
|
||||||
|
selections, removals,
|
||||||
|
"update kits",
|
||||||
|
"Nothing is marked for a change. Pick a build in a kit's Action column, tick "
|
||||||
|
"a kit to add it, or untick one to remove it."
|
||||||
|
)
|
||||||
|
|
||||||
|
ttk.Button(
|
||||||
|
button_row, text="Upgrade all kits to latest version", command=upgrade_all
|
||||||
|
).pack(side=tk.LEFT)
|
||||||
|
ttk.Button(button_row, text="Update kits", command=update_marked).pack(side=tk.LEFT, padx=6)
|
||||||
|
ttk.Button(button_row, text="Close", command=dialog.destroy).pack(side=tk.RIGHT)
|
||||||
|
|
||||||
|
rebuild_table()
|
||||||
+40
-3
@@ -19,6 +19,7 @@ import app_state
|
|||||||
_versions_cache = {} # kit name -> {checksum: version string}
|
_versions_cache = {} # kit name -> {checksum: version string}
|
||||||
_manifest_cache = {} # kit name -> (version string, {type name: element})
|
_manifest_cache = {} # kit name -> (version string, {type name: element})
|
||||||
_component_cache = {} # 'kit::Type' -> bool
|
_component_cache = {} # 'kit::Type' -> bool
|
||||||
|
_builds_cache = {} # kit name -> [(version string, checksum)], newest first
|
||||||
|
|
||||||
|
|
||||||
def _manifests_dir():
|
def _manifests_dir():
|
||||||
@@ -40,7 +41,7 @@ def list_kits():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _version_sort_key(version_string):
|
def version_sort_key(version_string):
|
||||||
numbers = [int(part) for part in re.findall(r"\d+", version_string)]
|
numbers = [int(part) for part in re.findall(r"\d+", version_string)]
|
||||||
return numbers or [0]
|
return numbers or [0]
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ def resolve_manifest_path(kit_name):
|
|||||||
for entry in manifests:
|
for entry in manifests:
|
||||||
checksum = entry[len(kit_name) + 1:-4]
|
checksum = entry[len(kit_name) + 1:-4]
|
||||||
if checksum in versions:
|
if checksum in versions:
|
||||||
available.append((_version_sort_key(versions[checksum]), entry, versions[checksum]))
|
available.append((version_sort_key(versions[checksum]), entry, versions[checksum]))
|
||||||
|
|
||||||
if available:
|
if available:
|
||||||
available.sort()
|
available.sort()
|
||||||
@@ -106,6 +107,41 @@ def resolve_manifest_path(kit_name):
|
|||||||
return os.path.join(kit_dir, entry), versions.get(checksum, "")
|
return os.path.join(kit_dir, entry), versions.get(checksum, "")
|
||||||
|
|
||||||
|
|
||||||
|
def kit_builds(kit_name):
|
||||||
|
"""Every build of a kit this Sedona home can describe, newest version first.
|
||||||
|
|
||||||
|
One entry per manifest under manifests/<kit>, as (version string, checksum). A
|
||||||
|
manifest with no matching .kit file has no version to report, so its version is empty
|
||||||
|
and such builds sort last — there is nothing to order them by.
|
||||||
|
"""
|
||||||
|
if kit_name in _builds_cache:
|
||||||
|
return _builds_cache[kit_name]
|
||||||
|
|
||||||
|
kit_dir = os.path.join(_manifests_dir(), kit_name)
|
||||||
|
builds = []
|
||||||
|
if os.path.isdir(kit_dir):
|
||||||
|
versions = kit_versions(kit_name)
|
||||||
|
for entry in sorted(os.listdir(kit_dir)):
|
||||||
|
if not (entry.startswith(f"{kit_name}-") and entry.endswith(".xml")):
|
||||||
|
continue
|
||||||
|
checksum = entry[len(kit_name) + 1:-4]
|
||||||
|
builds.append((versions.get(checksum, ""), checksum))
|
||||||
|
|
||||||
|
builds.sort(
|
||||||
|
key=lambda build: (bool(build[0]), version_sort_key(build[0])),
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
_builds_cache[kit_name] = builds
|
||||||
|
return builds
|
||||||
|
|
||||||
|
|
||||||
|
def latest_build(kit_name):
|
||||||
|
"""The newest build of a kit, as (version string, checksum), or None if it has none."""
|
||||||
|
builds = kit_builds(kit_name)
|
||||||
|
return builds[0] if builds else None
|
||||||
|
|
||||||
|
|
||||||
def _load_kit(kit_name):
|
def _load_kit(kit_name):
|
||||||
"""Parses a kit's chosen manifest into (version label, {type name: element})."""
|
"""Parses a kit's chosen manifest into (version label, {type name: element})."""
|
||||||
if kit_name in _manifest_cache:
|
if kit_name in _manifest_cache:
|
||||||
@@ -201,7 +237,8 @@ def list_component_types(kit_name):
|
|||||||
|
|
||||||
|
|
||||||
def invalidate():
|
def invalidate():
|
||||||
"""Clears every cache, for when Sedona home changes in Preferences."""
|
"""Clears every cache, for when Sedona home changes or a kit is repinned."""
|
||||||
_versions_cache.clear()
|
_versions_cache.clear()
|
||||||
_manifest_cache.clear()
|
_manifest_cache.clear()
|
||||||
_component_cache.clear()
|
_component_cache.clear()
|
||||||
|
_builds_cache.clear()
|
||||||
|
|||||||
+20
@@ -3,9 +3,29 @@
|
|||||||
Pure XML and bit work, deliberately free of Tk so the rules stay testable headless.
|
Pure XML and bit work, deliberately free of Tk so the rules stay testable headless.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_children(element):
|
||||||
|
"""Copies an element's children and leading text, so it can be put back wholesale.
|
||||||
|
|
||||||
|
Used for the <schema> block, which is short enough that copying it whole is easier to
|
||||||
|
trust than tracking which attribute moved and which entry was inserted where.
|
||||||
|
"""
|
||||||
|
return [copy.deepcopy(child) for child in element], element.text
|
||||||
|
|
||||||
|
|
||||||
|
def restore_children(element, snapshot):
|
||||||
|
"""Puts back what snapshot_children captured, whitespace and all."""
|
||||||
|
children, text = snapshot
|
||||||
|
for child in list(element):
|
||||||
|
element.remove(child)
|
||||||
|
element.text = text
|
||||||
|
for child in children:
|
||||||
|
element.append(copy.deepcopy(child))
|
||||||
|
|
||||||
|
|
||||||
def read_meta_property(meta_str):
|
def read_meta_property(meta_str):
|
||||||
"""Parses 32-bit meta property integer fields via structured byte extraction rules."""
|
"""Parses 32-bit meta property integer fields via structured byte extraction rules."""
|
||||||
if meta_str is None or not meta_str:
|
if meta_str is None or not meta_str:
|
||||||
|
|||||||
+228
-40
@@ -10,7 +10,13 @@ import palette
|
|||||||
import schema
|
import schema
|
||||||
import undo
|
import undo
|
||||||
import wiresheet
|
import wiresheet
|
||||||
from sax_dom import find_parent_element, get_component_path, update_meta_property
|
from sax_dom import (
|
||||||
|
find_parent_element,
|
||||||
|
get_component_path,
|
||||||
|
restore_children,
|
||||||
|
snapshot_children,
|
||||||
|
update_meta_property,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_xml_to_tree(treeview, parent_node, xml_element):
|
def parse_xml_to_tree(treeview, parent_node, xml_element):
|
||||||
@@ -361,53 +367,62 @@ def used_kit_names():
|
|||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def required_kit_names():
|
||||||
|
"""Every kit this app cannot run without: those its components name, plus their
|
||||||
|
dependencies, and the dependencies of those, all the way down.
|
||||||
|
|
||||||
|
A kit's manifest records the kits it was built against, and the device's scode needs
|
||||||
|
those just as much as the one a component names — so as far as the schema block is
|
||||||
|
concerned, being depended on by a used kit is the same as being used.
|
||||||
|
"""
|
||||||
|
unvisited = list(used_kit_names())
|
||||||
|
required = set(unvisited)
|
||||||
|
|
||||||
|
while unvisited:
|
||||||
|
kit_name = unvisited.pop()
|
||||||
|
checksum = (app_state.active_schema_kits.get(kit_name)
|
||||||
|
or app_state.pending_schema_kits.get(kit_name))
|
||||||
|
for dependency in schema.read_kit_dependencies(kit_name, checksum):
|
||||||
|
if dependency not in required:
|
||||||
|
required.add(dependency)
|
||||||
|
unvisited.append(dependency)
|
||||||
|
|
||||||
|
return required
|
||||||
|
|
||||||
|
|
||||||
def declare_used_kits():
|
def declare_used_kits():
|
||||||
"""Adds a <kit> entry for every used kit the <schema> block does not list.
|
"""Adds a <kit> entry for every kit the app needs that the <schema> block does not list.
|
||||||
|
|
||||||
The DOM is the authority on what is used, not the pending list, so a placement that
|
The DOM is the authority on what is used, not the pending list, so a placement that
|
||||||
was later undone cannot leave a kit declared for nothing. Pending entries only supply
|
was later undone cannot leave a kit declared for nothing. Pending entries only supply
|
||||||
the checksum of the manifest the palette resolved when the component was placed.
|
the checksum of the manifest the palette resolved when the component was placed.
|
||||||
|
|
||||||
|
A kit that a used kit depends on counts as needed too, so placing one component can
|
||||||
|
bring in more than one entry.
|
||||||
|
|
||||||
Entries already in the file are left as they are — a missing checksum there is a
|
Entries already in the file are left as they are — a missing checksum there is a
|
||||||
deliberate tolerance on load, not something to backfill.
|
deliberate tolerance on load, not something to backfill.
|
||||||
|
|
||||||
Returns (added elements in the order written, whitespace to put back if the caller
|
Returns the <kit> elements that were added, in the order they were written.
|
||||||
rolls the insertion out).
|
|
||||||
"""
|
"""
|
||||||
if app_state.xml_root_element is None:
|
if app_state.xml_root_element is None:
|
||||||
return [], []
|
return []
|
||||||
|
|
||||||
schema_elem = app_state.xml_root_element.find("schema")
|
schema_elem = app_state.xml_root_element.find("schema")
|
||||||
if schema_elem is None:
|
if schema_elem is None:
|
||||||
return [], []
|
return []
|
||||||
|
|
||||||
declared = {kit.get("name") for kit in schema_elem.findall("kit")}
|
declared = {kit.get("name") for kit in schema_elem.findall("kit")}
|
||||||
|
|
||||||
added = []
|
added = []
|
||||||
tail_restores = []
|
for name in sorted(required_kit_names() - declared):
|
||||||
for name in sorted(used_kit_names() - declared):
|
|
||||||
kit_element = ET.Element("kit")
|
|
||||||
kit_element.set("name", name)
|
|
||||||
|
|
||||||
checksum = app_state.pending_schema_kits.get(name) or palette.resolve_checksum(name)
|
checksum = app_state.pending_schema_kits.get(name) or palette.resolve_checksum(name)
|
||||||
if checksum:
|
kit_element = insert_kit_element(schema_elem, name, 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)
|
|
||||||
restore = indent_kit_entry(schema_elem, kit_element)
|
|
||||||
if restore is not None:
|
|
||||||
tail_restores.append(restore)
|
|
||||||
|
|
||||||
app_state.active_schema_kits[name] = checksum
|
app_state.active_schema_kits[name] = checksum
|
||||||
added.append(kit_element)
|
added.append(kit_element)
|
||||||
|
|
||||||
return added, tail_restores
|
return added
|
||||||
|
|
||||||
|
|
||||||
def indent_kit_entry(schema_elem, kit_element):
|
def indent_kit_entry(schema_elem, kit_element):
|
||||||
@@ -418,9 +433,8 @@ def indent_kit_entry(schema_elem, kit_element):
|
|||||||
the tail that used to close the block, and the entry before it takes the tail that
|
the tail that used to close the block, and the entry before it takes the tail that
|
||||||
separates two kits.
|
separates two kits.
|
||||||
|
|
||||||
Handing a tail on means writing to a neighbour, so the pair needed to put that
|
Callers that may have to undo the insertion snapshot the whole block instead of
|
||||||
neighbour back is returned for a caller that may have to undo the insertion. It is
|
tracking these tails, so nothing is reported back.
|
||||||
(element, tail) for a block that already held kits, and None for one that did not.
|
|
||||||
"""
|
"""
|
||||||
kits = [child for child in schema_elem if child.tag == "kit" and child is not kit_element]
|
kits = [child for child in schema_elem if child.tag == "kit" and child is not kit_element]
|
||||||
|
|
||||||
@@ -430,13 +444,50 @@ def indent_kit_entry(schema_elem, kit_element):
|
|||||||
existing_text = schema_elem.text or ""
|
existing_text = schema_elem.text or ""
|
||||||
kit_element.tail = existing_text if existing_text.strip() == "" and existing_text else "\n"
|
kit_element.tail = existing_text if existing_text.strip() == "" and existing_text else "\n"
|
||||||
schema_elem.text = "\n "
|
schema_elem.text = "\n "
|
||||||
return None
|
return
|
||||||
|
|
||||||
previous = kits[-1]
|
previous = kits[-1]
|
||||||
restore = (previous, previous.tail)
|
|
||||||
kit_element.tail = previous.tail
|
kit_element.tail = previous.tail
|
||||||
previous.tail = kits[-2].tail if len(kits) >= 2 else "\n "
|
previous.tail = kits[-2].tail if len(kits) >= 2 else "\n "
|
||||||
return restore
|
|
||||||
|
|
||||||
|
def insert_kit_element(schema_elem, kit_name, checksum):
|
||||||
|
"""Builds a <kit> entry, places it after the last existing one, and returns it.
|
||||||
|
|
||||||
|
The block is not alphabetical — sys comes first by convention — so appending is safer
|
||||||
|
than guessing a sort position.
|
||||||
|
"""
|
||||||
|
kit_element = ET.Element("kit")
|
||||||
|
kit_element.set("name", kit_name)
|
||||||
|
if checksum:
|
||||||
|
kit_element.set("checksum", checksum)
|
||||||
|
|
||||||
|
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)
|
||||||
|
indent_kit_entry(schema_elem, kit_element)
|
||||||
|
return kit_element
|
||||||
|
|
||||||
|
|
||||||
|
def remove_kit_element(schema_elem, kit_element):
|
||||||
|
"""Takes a <kit> entry out of the block, leaving its line breaks tidy.
|
||||||
|
|
||||||
|
Whitespace lives in the tail of the element before it, so the entry that closes the
|
||||||
|
block is the one carrying the break that puts </schema> on its own line: when that is
|
||||||
|
the entry going away, its tail passes back to whatever now ends the block.
|
||||||
|
"""
|
||||||
|
kits = [child for child in schema_elem if child.tag == "kit"]
|
||||||
|
|
||||||
|
if kits and kits[-1] is kit_element:
|
||||||
|
if len(kits) >= 2:
|
||||||
|
kits[-2].tail = kit_element.tail
|
||||||
|
else:
|
||||||
|
schema_elem.text = kit_element.tail
|
||||||
|
|
||||||
|
schema_elem.remove(kit_element)
|
||||||
|
|
||||||
|
|
||||||
def format_kit_row(marker, kit_name, checksum):
|
def format_kit_row(marker, kit_name, checksum):
|
||||||
@@ -456,8 +507,8 @@ def describe_schema_kits(added_elements):
|
|||||||
|
|
||||||
kit_word = "kit is" if len(added_names) == 1 else "kits are"
|
kit_word = "kit is" if len(added_names) == 1 else "kits are"
|
||||||
lines = [
|
lines = [
|
||||||
f"The following {kit_word} used by a component but not declared in this app's "
|
f"The following {kit_word} needed by this app — by a component, or by a kit one "
|
||||||
"schema, and has been added to it:",
|
"of them depends on — but not declared in its schema, and has been added to it:",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
lines.extend(
|
lines.extend(
|
||||||
@@ -484,13 +535,155 @@ def describe_schema_kits(added_elements):
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def schema_kit_checksums():
|
||||||
|
"""The file's declared kits as an ordered list of (name, checksum), checksum may be None."""
|
||||||
|
if app_state.xml_root_element is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
schema_elem = app_state.xml_root_element.find("schema")
|
||||||
|
if schema_elem is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
(kit.get("name"), kit.get("checksum"))
|
||||||
|
for kit in schema_elem.findall("kit")
|
||||||
|
if kit.get("name")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def unresolved_component_types():
|
||||||
|
"""Types placed in the app that the current registry cannot describe, sorted.
|
||||||
|
|
||||||
|
Repinning a kit to another build can drop a type the app still uses, which would leave
|
||||||
|
those components on the sheet with no slots. Checked after every schema change so the
|
||||||
|
change can be refused rather than silently emptying a box.
|
||||||
|
"""
|
||||||
|
if app_state.xml_root_element is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
missing = set()
|
||||||
|
for comp in app_state.xml_root_element.iter("comp"):
|
||||||
|
type_str = comp.get("type")
|
||||||
|
if type_str and type_str not in app_state.final_components_registry:
|
||||||
|
missing.add(type_str)
|
||||||
|
return sorted(missing)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_kit_selections(selections, removals=(), label="update kits"):
|
||||||
|
"""Repins, adds and drops <kit> entries in one go.
|
||||||
|
|
||||||
|
``selections`` is an iterable of (kit name, checksum) the block should end up
|
||||||
|
declaring; ``removals`` is kit names it should stop declaring. Entries that already
|
||||||
|
match are skipped, so the return value describes only what really moved.
|
||||||
|
|
||||||
|
The whole set is applied, then the registry is recompiled once and checked: if a
|
||||||
|
manifest is missing, or a type the app uses no longer resolves, the block goes back to
|
||||||
|
how it was and the reason is returned instead. Nothing is half-applied.
|
||||||
|
|
||||||
|
Returns (changes, error) where changes is a list of (kit name, old checksum, new
|
||||||
|
checksum) — a new checksum of None meaning the entry was dropped — and error is None
|
||||||
|
on success.
|
||||||
|
"""
|
||||||
|
if app_state.xml_root_element is None:
|
||||||
|
return [], "No file is open."
|
||||||
|
|
||||||
|
schema_elem = app_state.xml_root_element.find("schema")
|
||||||
|
if schema_elem is None:
|
||||||
|
return [], "This file has no <schema> block to record kits in."
|
||||||
|
|
||||||
|
before = snapshot_children(schema_elem)
|
||||||
|
# Whatever the file could not describe already is not this change's doing, and holding
|
||||||
|
# it against the change would lock the kit dialog on the very file that needs it.
|
||||||
|
before_unresolved = set(unresolved_component_types())
|
||||||
|
|
||||||
|
# A kit some component needs, directly or through another kit's dependencies, cannot
|
||||||
|
# be dropped whatever the caller asks: the app would not run without it.
|
||||||
|
protected = sorted(set(removals) & required_kit_names())
|
||||||
|
if protected:
|
||||||
|
return [], (
|
||||||
|
"No kit was changed.\n\nThese kits cannot be removed, because this app "
|
||||||
|
"needs them:\n\n"
|
||||||
|
+ "\n".join(f" • {name}" for name in protected)
|
||||||
|
)
|
||||||
|
|
||||||
|
changes = []
|
||||||
|
for kit_name in removals:
|
||||||
|
for kit in schema_elem.findall("kit"):
|
||||||
|
if kit.get("name") == kit_name:
|
||||||
|
changes.append((kit_name, kit.get("checksum"), None))
|
||||||
|
remove_kit_element(schema_elem, kit)
|
||||||
|
app_state.active_schema_kits.pop(kit_name, None)
|
||||||
|
break
|
||||||
|
|
||||||
|
for kit_name, checksum in selections:
|
||||||
|
if not kit_name or not checksum:
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = None
|
||||||
|
for kit in schema_elem.findall("kit"):
|
||||||
|
if kit.get("name") == kit_name:
|
||||||
|
existing = kit
|
||||||
|
break
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
insert_kit_element(schema_elem, kit_name, checksum)
|
||||||
|
changes.append((kit_name, None, checksum))
|
||||||
|
elif existing.get("checksum") != checksum:
|
||||||
|
old_checksum = existing.get("checksum")
|
||||||
|
existing.set("checksum", checksum)
|
||||||
|
changes.append((kit_name, old_checksum, checksum))
|
||||||
|
|
||||||
|
if not changes:
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
def abandon(reason):
|
||||||
|
restore_children(schema_elem, before)
|
||||||
|
undo.reload_schema_registry()
|
||||||
|
return [], reason
|
||||||
|
|
||||||
|
# The palette resolves a kit through the checksum the schema pins, so its cached
|
||||||
|
# manifests describe the builds that were pinned a moment ago.
|
||||||
|
palette.invalidate()
|
||||||
|
try:
|
||||||
|
schema.load_schema_kits_and_manifests()
|
||||||
|
except schema.MissingManifestError as missing:
|
||||||
|
return abandon(
|
||||||
|
"No kit was changed.\n\n" + schema.describe_missing_manifests(missing)
|
||||||
|
)
|
||||||
|
|
||||||
|
unresolved = sorted(set(unresolved_component_types()) - before_unresolved)
|
||||||
|
if unresolved:
|
||||||
|
type_list = "\n".join(f" • {name}" for name in unresolved)
|
||||||
|
return abandon(
|
||||||
|
"No kit was changed.\n\nIllegal components:\n\n"
|
||||||
|
f"{type_list}\n\nFor correcting:\n\n- Delete those components first\nor\n"
|
||||||
|
"- Add related kits."
|
||||||
|
)
|
||||||
|
|
||||||
|
for kit_name, _, checksum in changes:
|
||||||
|
app_state.pending_schema_kits.pop(kit_name, None)
|
||||||
|
|
||||||
|
undo.push(undo.SchemaBlockCommand(
|
||||||
|
schema_elem, before, snapshot_children(schema_elem), label=label
|
||||||
|
))
|
||||||
|
|
||||||
|
app_state.mark_dirty()
|
||||||
|
refresh_treeview_from_dom(app_state.xml_root_element)
|
||||||
|
wiresheet.render_current_sheet()
|
||||||
|
|
||||||
|
return changes, None
|
||||||
|
|
||||||
|
|
||||||
def reconcile_schema_kits_before_save():
|
def reconcile_schema_kits_before_save():
|
||||||
"""Brings <schema> in step with the components, warning before anything is written.
|
"""Brings <schema> in step with the components, warning before anything is written.
|
||||||
|
|
||||||
Returns False when the user backs out, in which case the added entries are taken back
|
Returns False when the user backs out, in which case the added entries are taken back
|
||||||
out and the save is abandoned.
|
out and the save is abandoned.
|
||||||
"""
|
"""
|
||||||
added, tail_restores = declare_used_kits()
|
schema_elem = app_state.xml_root_element.find("schema")
|
||||||
|
before = snapshot_children(schema_elem) if schema_elem is not None else None
|
||||||
|
|
||||||
|
added = declare_used_kits()
|
||||||
if not added:
|
if not added:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -501,14 +694,9 @@ def reconcile_schema_kits_before_save():
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not proceed:
|
if not proceed:
|
||||||
schema_elem = app_state.xml_root_element.find("schema")
|
restore_children(schema_elem, before)
|
||||||
for kit_element in added:
|
for kit_element in added:
|
||||||
if schema_elem is not None and kit_element in list(schema_elem):
|
|
||||||
schema_elem.remove(kit_element)
|
|
||||||
app_state.active_schema_kits.pop(kit_element.get("name"), None)
|
app_state.active_schema_kits.pop(kit_element.get("name"), None)
|
||||||
# Oldest first, so the entry that ends the block gets the last word on its tail.
|
|
||||||
for element, tail in reversed(tail_restores):
|
|
||||||
element.tail = tail
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Declared in the file now, so nothing about them is pending any more.
|
# Declared in the file now, so nothing about them is pending any more.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import xml.etree.ElementTree as ET
|
|||||||
|
|
||||||
import app_state
|
import app_state
|
||||||
|
|
||||||
|
_dependency_cache = {} # manifest path -> tuple of kit names it depends on
|
||||||
|
|
||||||
|
|
||||||
class MissingManifestError(Exception):
|
class MissingManifestError(Exception):
|
||||||
"""A kit the file needs has no manifest under the Sedona home folder.
|
"""A kit the file needs has no manifest under the Sedona home folder.
|
||||||
@@ -42,6 +44,38 @@ def describe_missing_manifests(error):
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def read_kit_dependencies(kit_name, checksum=None):
|
||||||
|
"""The kits a manifest declares its kit was built against, by name only.
|
||||||
|
|
||||||
|
A manifest lists them as <depend on="sys 1+"/>; the version bound is dropped here,
|
||||||
|
since the schema block pins a build by checksum and has nowhere to record a range.
|
||||||
|
|
||||||
|
An unreadable or absent manifest yields no dependencies rather than an error: the
|
||||||
|
callers use this to widen a list, and a kit whose manifest is missing is already
|
||||||
|
reported by whatever needed its types.
|
||||||
|
"""
|
||||||
|
manifest_path = find_manifest_path(kit_name, checksum)
|
||||||
|
if not manifest_path:
|
||||||
|
return ()
|
||||||
|
if manifest_path in _dependency_cache:
|
||||||
|
return _dependency_cache[manifest_path]
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
try:
|
||||||
|
root = ET.parse(manifest_path).getroot()
|
||||||
|
depends_element = root.find("depends")
|
||||||
|
if depends_element is not None:
|
||||||
|
for depend in depends_element.findall("depend"):
|
||||||
|
on = (depend.get("on") or "").strip()
|
||||||
|
if on:
|
||||||
|
dependencies.append(on.split()[0])
|
||||||
|
except Exception as error:
|
||||||
|
print(f"Cannot read dependencies of {manifest_path}: {error}")
|
||||||
|
|
||||||
|
_dependency_cache[manifest_path] = tuple(dependencies)
|
||||||
|
return _dependency_cache[manifest_path]
|
||||||
|
|
||||||
|
|
||||||
def read_summary_facet(slot_element):
|
def read_summary_facet(slot_element):
|
||||||
"""Reads a slot's '@summary' facet. Absent means summary, matching manifest convention."""
|
"""Reads a slot's '@summary' facet. Absent means summary, matching manifest convention."""
|
||||||
facets = slot_element.find("facets")
|
facets = slot_element.find("facets")
|
||||||
|
|||||||
+19
-17
@@ -1,22 +1,23 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<sedonaApp>
|
<sedonaApp>
|
||||||
<schema>
|
<schema>
|
||||||
<kit name="sys" />
|
<kit name="sys" checksum="d3984c51" />
|
||||||
<kit name="control" />
|
<kit name="control" checksum="808b7db3" />
|
||||||
<kit name="datetime" />
|
<kit name="datetime" checksum="3a280dce" />
|
||||||
<kit name="driver" />
|
<kit name="driver" checksum="b4cc82ce" />
|
||||||
<kit name="inet" />
|
<kit name="inet" checksum="25648ba7" />
|
||||||
<kit name="ontrolCalc" />
|
<kit name="ontrolCalc" checksum="ba6a7e02" />
|
||||||
<kit name="ontrolControl" />
|
<kit name="ontrolControl" checksum="254170a5" />
|
||||||
<kit name="ontrolDatetime" />
|
<kit name="ontrolDatetime" checksum="948972be" />
|
||||||
<kit name="ontrolDatetimeStd" />
|
<kit name="ontrolDatetimeStd" checksum="190a44b4" />
|
||||||
<kit name="ontrolModbusMaster" />
|
<kit name="ontrolModbusMaster" checksum="45d95f96" />
|
||||||
<kit name="ontrolPion" />
|
<kit name="ontrolPion" checksum="931bda2d" />
|
||||||
<kit name="ontrolPlatform" />
|
<kit name="ontrolPlatform" checksum="5c12ff9d" />
|
||||||
<kit name="ontrolSchedule" />
|
<kit name="ontrolSchedule" checksum="d9c85cf5" />
|
||||||
<kit name="serial" />
|
<kit name="serial" checksum="87314b6e" />
|
||||||
<kit name="sox" />
|
<kit name="sox" checksum="397a84dd" />
|
||||||
<kit name="web" />
|
<kit name="web" checksum="0462d43e" />
|
||||||
|
<kit name="ontrolModbusSlaveSmart" checksum="929218bf" />
|
||||||
</schema>
|
</schema>
|
||||||
<app>
|
<app>
|
||||||
<prop name="deviceName" val="DDC_8" />
|
<prop name="deviceName" val="DDC_8" />
|
||||||
@@ -279,7 +280,8 @@
|
|||||||
</comp>
|
</comp>
|
||||||
</comp>
|
</comp>
|
||||||
</comp>
|
</comp>
|
||||||
<comp name="ModbusS" id="27" type="ontrolModbusSlaveSmart::ModbusSlaveSmart"><prop name="meta" val="438042625" /></comp></comp>
|
<comp name="ModbusS" id="27" type="ontrolModbusSlaveSmart::ModbusSlaveSmart"><prop name="meta" val="438042625" />
|
||||||
|
</comp></comp>
|
||||||
|
|
||||||
<comp name="AHU20_1" id="70" type="sys::Folder">
|
<comp name="AHU20_1" id="70" type="sys::Folder">
|
||||||
<prop name="meta" val="17367041" />
|
<prop name="meta" val="17367041" />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@ import graph free of cycles (sax_file and wiresheet both push commands here).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import app_state
|
import app_state
|
||||||
from sax_dom import update_meta_property
|
import palette
|
||||||
|
import schema
|
||||||
|
from sax_dom import restore_children, update_meta_property
|
||||||
|
|
||||||
_undo_stack = []
|
_undo_stack = []
|
||||||
_redo_stack = []
|
_redo_stack = []
|
||||||
@@ -79,6 +81,43 @@ def reset():
|
|||||||
_saved_depth = 0
|
_saved_depth = 0
|
||||||
|
|
||||||
|
|
||||||
|
def reload_schema_registry():
|
||||||
|
"""Recompiles the slot registry after the <schema> block changed.
|
||||||
|
|
||||||
|
A failure is swallowed: undo and redo only ever land on a state that resolved once
|
||||||
|
already, so a missing manifest here means Sedona home moved underneath the session,
|
||||||
|
which the next action reports in its own words.
|
||||||
|
"""
|
||||||
|
palette.invalidate()
|
||||||
|
try:
|
||||||
|
schema.load_schema_kits_and_manifests()
|
||||||
|
except schema.MissingManifestError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaBlockCommand:
|
||||||
|
"""A change to the file's <schema> block, held as a before/after snapshot.
|
||||||
|
|
||||||
|
Kit entries decide which manifest describes which type, so the slot registry is
|
||||||
|
recompiled in both directions — a repinned kit can change a component's slots.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, schema_element, before, after, label="update kits"):
|
||||||
|
# before/after: snapshots from sax_dom.snapshot_children
|
||||||
|
self.schema_element = schema_element
|
||||||
|
self.before = before
|
||||||
|
self.after = after
|
||||||
|
self.label = label
|
||||||
|
|
||||||
|
def undo(self):
|
||||||
|
restore_children(self.schema_element, self.before)
|
||||||
|
reload_schema_registry()
|
||||||
|
|
||||||
|
def redo(self):
|
||||||
|
restore_children(self.schema_element, self.after)
|
||||||
|
reload_schema_registry()
|
||||||
|
|
||||||
|
|
||||||
class MetaMoveCommand:
|
class MetaMoveCommand:
|
||||||
"""One or many components changing position, i.e. their packed meta x/y."""
|
"""One or many components changing position, i.e. their packed meta x/y."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user