mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
Improve Autodesk connector browsing and progress UI
Sort hubs, projects, folders and files alphabetically. Allow multi-select when adding models so several can be pulled at once. Rework the progress dialog into a fixed-shape two-line layout that shows percent and byte counts, middle-eliding long filenames so the window never reflows. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -250,7 +250,7 @@ class ApsClient:
|
||||
|
||||
def list_hubs(self) -> list[dict[str, Any]]:
|
||||
payload = self._get_json("https://developer.api.autodesk.com/project/v1/hubs")
|
||||
return [
|
||||
hubs = [
|
||||
{
|
||||
"id": item["id"],
|
||||
"name": item["attributes"]["name"],
|
||||
@@ -258,6 +258,8 @@ class ApsClient:
|
||||
}
|
||||
for item in payload.get("data", [])
|
||||
]
|
||||
hubs.sort(key=lambda h: (h["name"] or "").casefold())
|
||||
return hubs
|
||||
|
||||
def list_projects(self, hub_id: str) -> list[dict[str, Any]]:
|
||||
url = f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects"
|
||||
@@ -274,13 +276,16 @@ class ApsClient:
|
||||
}
|
||||
)
|
||||
url = payload.get("links", {}).get("next", {}).get("href", "") or ""
|
||||
projects.sort(key=lambda p: (p["name"] or "").casefold())
|
||||
return projects
|
||||
|
||||
def list_top_folders(self, hub_id: str, project_id: str) -> list[dict[str, Any]]:
|
||||
payload = self._get_json(
|
||||
f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects/{project_id}/topFolders"
|
||||
)
|
||||
return [self._entry(item) for item in payload.get("data", [])]
|
||||
folders = [self._entry(item) for item in payload.get("data", [])]
|
||||
folders.sort(key=lambda e: (e.get("display_name") or "").casefold())
|
||||
return folders
|
||||
|
||||
def list_folder_contents(
|
||||
self,
|
||||
@@ -303,6 +308,7 @@ class ApsClient:
|
||||
continue
|
||||
entries.append(entry)
|
||||
url = payload.get("links", {}).get("next", {}).get("href", "") or ""
|
||||
entries.sort(key=lambda e: (e.get("display_name") or "").casefold())
|
||||
return entries
|
||||
|
||||
def get_item(self, project_id: str, item_id: str) -> dict[str, Any]:
|
||||
@@ -364,7 +370,7 @@ class ApsClient:
|
||||
storage_id: str,
|
||||
destination_path: Path,
|
||||
*,
|
||||
progress: Callable[[str, int | None], None] | None = None,
|
||||
progress: Callable[[str, int | None, int | None, int | None], None] | None = None,
|
||||
) -> None:
|
||||
bucket_key, object_key = self._parse_storage_id(storage_id)
|
||||
signed_url = self._get_signed_download_url(bucket_key, object_key)
|
||||
@@ -377,7 +383,7 @@ class ApsClient:
|
||||
local_path: Path,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
progress: Callable[[str, int | None], None] | None = None,
|
||||
progress: Callable[[str, int | None, int | None, int | None], None] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not local_path.exists():
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Local file '{local_path}' does not exist.")
|
||||
@@ -439,12 +445,12 @@ class ApsClient:
|
||||
self,
|
||||
url: str,
|
||||
destination_path: Path,
|
||||
progress: Callable[[str, int | None], None] | None,
|
||||
progress: Callable[[str, int | None, int | None, int | None], None] | None,
|
||||
) -> None:
|
||||
try:
|
||||
with self.http.stream("GET", url) as response:
|
||||
response.raise_for_status()
|
||||
total_bytes = None
|
||||
total_bytes: int | None = None
|
||||
header_value = response.headers.get("Content-Length")
|
||||
if header_value and header_value.isdigit():
|
||||
total_bytes = int(header_value)
|
||||
@@ -455,9 +461,9 @@ class ApsClient:
|
||||
downloaded_bytes += len(chunk)
|
||||
if progress and total_bytes:
|
||||
percent = min(100, int((downloaded_bytes / total_bytes) * 100))
|
||||
progress(destination_path.name, percent)
|
||||
progress(destination_path.name, percent, downloaded_bytes, total_bytes)
|
||||
elif progress:
|
||||
progress(destination_path.name, None)
|
||||
progress(destination_path.name, None, downloaded_bytes, total_bytes)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = exc.response.text.strip()
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, body or f"HTTP {exc.response.status_code}") from exc
|
||||
@@ -486,7 +492,7 @@ class ApsClient:
|
||||
bucket_key: str,
|
||||
object_key: str,
|
||||
local_path: Path,
|
||||
progress: Callable[[str, int | None], None] | None,
|
||||
progress: Callable[[str, int | None, int | None, int | None], None] | None,
|
||||
) -> None:
|
||||
file_size = local_path.stat().st_size
|
||||
chunk_size = 5 * 1024 * 1024
|
||||
@@ -522,7 +528,7 @@ class ApsClient:
|
||||
bytes_uploaded += len(chunk)
|
||||
if progress:
|
||||
percent = 100 if file_size == 0 else min(100, int((bytes_uploaded / file_size) * 100))
|
||||
progress(local_path.name, percent)
|
||||
progress(local_path.name, percent, bytes_uploaded, file_size)
|
||||
|
||||
if not upload_key:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "Upload did not return an upload key.")
|
||||
|
||||
@@ -11,25 +11,51 @@ from bonsaiviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, JSONRPC_INVALID_PA
|
||||
from bonsaiviewer_autodesk.ui import BrowseDialog, SettingsDialog, progress_dialog, prompt_for_filename
|
||||
|
||||
|
||||
ApsProgress = Callable[[str, "int | None"], None]
|
||||
Report = Callable[[str, str, "int | None"], None]
|
||||
ApsProgress = Callable[[str, "int | None", "int | None", "int | None"], None]
|
||||
Report = Callable[[str, str, "int | None", "str | None"], None]
|
||||
|
||||
|
||||
def _format_bytes(value: int) -> str:
|
||||
"""Render a byte count as a short human-readable string (e.g. '3.4 MB')."""
|
||||
if value < 1024:
|
||||
return f"{value} B"
|
||||
scaled = float(value)
|
||||
for unit in ("KB", "MB", "GB", "TB"):
|
||||
scaled /= 1024.0
|
||||
if scaled < 1024 or unit == "TB":
|
||||
return f"{scaled:.1f} {unit}"
|
||||
return f"{value} B"
|
||||
|
||||
|
||||
def _progress_detail(percent: int | None, done: int | None, total: int | None) -> str:
|
||||
"""Build the stats line shown beneath the filename, e.g. '45%, 4.5 MB / 10.0 MB'."""
|
||||
parts: list[str] = []
|
||||
if percent is not None:
|
||||
parts.append(f"{percent}%")
|
||||
if done is not None and total:
|
||||
parts.append(f"{_format_bytes(done)} / {_format_bytes(total)}")
|
||||
elif done is not None:
|
||||
parts.append(_format_bytes(done))
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def _download_callback(report: Report, index: int = 0, total: int = 0) -> ApsProgress:
|
||||
"""Adapt ProgressDialog.report (3-arg) to the APS download callback (2-arg).
|
||||
"""Adapt ProgressDialog.report to the APS download callback.
|
||||
|
||||
index/total render "(i/N)" suffix when batching; pass 0 (the default) for
|
||||
single-file downloads to omit the suffix.
|
||||
"""
|
||||
def cb(name: str, percent: int | None) -> None:
|
||||
def cb(name: str, percent: int | None, bytes_done: int | None, bytes_total: int | None) -> None:
|
||||
suffix = f" ({index}/{total})" if total else ""
|
||||
report("download", f"Downloading {name}{suffix}", percent)
|
||||
detail = _progress_detail(percent, bytes_done, bytes_total)
|
||||
report("download", f"Downloading {name}{suffix}", percent, detail)
|
||||
return cb
|
||||
|
||||
|
||||
def _upload_callback(report: Report) -> ApsProgress:
|
||||
def cb(name: str, percent: int | None) -> None:
|
||||
report("upload", f"Uploading {name}", percent)
|
||||
def cb(name: str, percent: int | None, bytes_done: int | None, bytes_total: int | None) -> None:
|
||||
detail = _progress_detail(percent, bytes_done, bytes_total)
|
||||
report("upload", f"Uploading {name}", percent, detail)
|
||||
return cb
|
||||
|
||||
|
||||
@@ -95,7 +121,7 @@ class AutodeskConnector:
|
||||
chosen = BrowseDialog(auth=auth, aps=aps, mode="ifcfed").run()
|
||||
hub = chosen["hub"]
|
||||
project = chosen["project"]
|
||||
entry = chosen["entry"]
|
||||
entry = chosen["entries"][0]
|
||||
with progress_dialog("Downloading project") as report:
|
||||
path = self._download_ifcfed(
|
||||
aps=aps,
|
||||
@@ -230,10 +256,37 @@ class AutodeskConnector:
|
||||
chosen = BrowseDialog(auth=auth, aps=aps, mode="model").run()
|
||||
hub = chosen["hub"]
|
||||
project = chosen["project"]
|
||||
entry = chosen["entry"]
|
||||
entries = chosen["entries"]
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
total = len(entries)
|
||||
with progress_dialog("Downloading models") as report:
|
||||
for index, entry in enumerate(entries):
|
||||
callback = _download_callback(report, index=index + 1, total=total)
|
||||
try:
|
||||
result = self._download_picked_model(aps, hub, project, entry, callback)
|
||||
except RpcError as exc:
|
||||
print(f"pull_models_interactive[{index}] skipped: {exc.message}", file=sys.stderr)
|
||||
continue
|
||||
except Exception as exc:
|
||||
print(f"pull_models_interactive[{index}] skipped: {exc}", file=sys.stderr)
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
continue
|
||||
if result is not None:
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
def _download_picked_model(
|
||||
self,
|
||||
aps: ApsClient,
|
||||
hub: dict[str, Any],
|
||||
project: dict[str, Any],
|
||||
entry: dict[str, Any],
|
||||
progress: ApsProgress,
|
||||
) -> dict[str, Any] | None:
|
||||
item = aps.get_item(project["id"], entry["id"])
|
||||
if item["hidden"]:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "The selected Autodesk item has been deleted.")
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{entry['id']}' has been deleted.")
|
||||
storage_id = item["storage_id"]
|
||||
if not isinstance(storage_id, str):
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{entry['id']}' has no downloadable storage.")
|
||||
@@ -245,24 +298,19 @@ class AutodeskConnector:
|
||||
model_path = directory / file_name
|
||||
if not model_path.exists():
|
||||
cache.prepare_sole_child_dir(directory)
|
||||
with progress_dialog("Downloading model") as report:
|
||||
aps.download_storage_to_file(
|
||||
storage_id, model_path, progress=_download_callback(report)
|
||||
)
|
||||
aps.download_storage_to_file(storage_id, model_path, progress=progress)
|
||||
|
||||
return [
|
||||
{
|
||||
"display_name": file_name,
|
||||
"source": {
|
||||
"connector": CONNECTOR_ID,
|
||||
"hub_id": hub["id"],
|
||||
"project_id": project["id"],
|
||||
"item_id": entry["id"],
|
||||
},
|
||||
"path": str(model_path),
|
||||
"metadata": _build_metadata(item),
|
||||
}
|
||||
]
|
||||
return {
|
||||
"display_name": file_name,
|
||||
"source": {
|
||||
"connector": CONNECTOR_ID,
|
||||
"hub_id": hub["id"],
|
||||
"project_id": project["id"],
|
||||
"item_id": entry["id"],
|
||||
},
|
||||
"path": str(model_path),
|
||||
"metadata": _build_metadata(item),
|
||||
}
|
||||
|
||||
# ---- push_ifcfed_interactive --------------------------------------------
|
||||
|
||||
@@ -278,7 +326,7 @@ class AutodeskConnector:
|
||||
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
|
||||
hub = chosen["hub"]
|
||||
project = chosen["project"]
|
||||
folder = chosen["entry"]
|
||||
folder = chosen["entries"][0]
|
||||
|
||||
file_name = prompt_for_filename(
|
||||
title="Save Project",
|
||||
@@ -378,7 +426,7 @@ class AutodeskConnector:
|
||||
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
|
||||
hub = chosen["hub"]
|
||||
project = chosen["project"]
|
||||
folder = chosen["entry"]
|
||||
folder = chosen["entries"][0]
|
||||
|
||||
file_name = prompt_for_filename(
|
||||
title="Save Model",
|
||||
|
||||
@@ -110,28 +110,57 @@ class _BaseDialog(ctk.CTkToplevel):
|
||||
|
||||
|
||||
class ProgressDialog(_BaseDialog):
|
||||
"""Fixed-size progress dialog: a title line, a stats line, and a bar.
|
||||
|
||||
Both text lines are single-line and middle-elided with '…' so a long
|
||||
filename can never reflow the layout or resize the window.
|
||||
"""
|
||||
|
||||
_WIDTH = 560
|
||||
|
||||
def __init__(self, title: str = "Working", parent: tk.Misc | None = None) -> None:
|
||||
super().__init__(title, size=(440, 130), resizable=False)
|
||||
super().__init__(title, size=(self._WIDTH, 160), resizable=False)
|
||||
|
||||
self._text_font = ctk.CTkFont()
|
||||
|
||||
body = ctk.CTkFrame(self)
|
||||
body.pack(fill="both", expand=True, padx=20, pady=20)
|
||||
|
||||
self.message = ctk.CTkLabel(body, text="Working…", anchor="w")
|
||||
self.message.pack(fill="x", anchor="w")
|
||||
self.title_label = ctk.CTkLabel(body, text="Working…", anchor="w", font=self._text_font)
|
||||
self.title_label.pack(fill="x", padx=16, pady=(16, 0))
|
||||
|
||||
# Initialised with a space so the line reserves its height before the
|
||||
# first report(); a single-line label never grows taller than this.
|
||||
self.detail_label = ctk.CTkLabel(body, text=" ", anchor="w", font=self._text_font)
|
||||
self.detail_label.pack(fill="x", padx=16, pady=(2, 0))
|
||||
|
||||
self.bar = ctk.CTkProgressBar(body, mode="indeterminate")
|
||||
self.bar.pack(fill="x", pady=(12, 0))
|
||||
self.bar.pack(fill="x", padx=16, pady=(14, 16))
|
||||
self.bar.start()
|
||||
self._determinate = False
|
||||
|
||||
# Derive the height from the laid-out content (font-driven) rather than
|
||||
# hardcoding it, then lock it. Single-line labels keep it stable no
|
||||
# matter how long the text is.
|
||||
self.update_idletasks()
|
||||
self.geometry(f"{self._WIDTH}x{self.winfo_reqheight()}")
|
||||
|
||||
self._center_on_screen()
|
||||
self.deiconify()
|
||||
self.lift()
|
||||
self.update()
|
||||
|
||||
def report(self, _phase: str, message: str, percent: int | None = None) -> None:
|
||||
def report(
|
||||
self,
|
||||
_phase: str,
|
||||
message: str,
|
||||
percent: int | None = None,
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
self.message.configure(text=message)
|
||||
width = self._text_area_width()
|
||||
self.title_label.configure(text=self._elide_middle(message, width))
|
||||
self.detail_label.configure(text=self._elide_middle(detail or " ", width))
|
||||
if percent is None:
|
||||
if self._determinate:
|
||||
self.bar.configure(mode="indeterminate")
|
||||
@@ -147,6 +176,29 @@ class ProgressDialog(_BaseDialog):
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
def _text_area_width(self) -> int:
|
||||
"""Pixels available for label text, in the same scaled space as the font."""
|
||||
width = self.title_label.winfo_width()
|
||||
if width <= 1: # not laid out yet
|
||||
return self._WIDTH - 2 * 20 - 2 * 16
|
||||
return max(40, width - 6)
|
||||
|
||||
def _elide_middle(self, text: str, max_width: int) -> str:
|
||||
"""Middle-truncate text with '…' so it fits max_width without wrapping."""
|
||||
font = self._text_font
|
||||
if font.measure(text) <= max_width:
|
||||
return text
|
||||
ellipsis = "…"
|
||||
keep = len(text) - 1
|
||||
while keep > 0:
|
||||
head = (keep + 1) // 2
|
||||
tail = keep - head
|
||||
candidate = text[:head] + ellipsis + (text[-tail:] if tail else "")
|
||||
if font.measure(candidate) <= max_width:
|
||||
return candidate
|
||||
keep -= 1
|
||||
return ellipsis
|
||||
|
||||
|
||||
class _ProgressContext:
|
||||
def __init__(self, parent: tk.Misc | None, message: str) -> None:
|
||||
@@ -154,7 +206,7 @@ class _ProgressContext:
|
||||
self.message = message
|
||||
self.dialog: ProgressDialog | None = None
|
||||
|
||||
def __enter__(self) -> Callable[[str, str, int | None], None]:
|
||||
def __enter__(self) -> Callable[..., None]:
|
||||
self.dialog = ProgressDialog(self.message, self.parent)
|
||||
return self.dialog.report
|
||||
|
||||
@@ -197,9 +249,10 @@ class BrowseDialog(_BaseDialog):
|
||||
self.auth = auth
|
||||
self.aps = aps
|
||||
self.mode: Mode = mode
|
||||
self.multi_select = mode == "model"
|
||||
self.selected_hub: dict[str, Any] | None = None
|
||||
self.selected_project: dict[str, Any] | None = None
|
||||
self.selected_entry: dict[str, Any] | None = None
|
||||
self.selected_entries: list[dict[str, Any]] = []
|
||||
self._tree_entries: dict[str, dict[str, Any]] = {}
|
||||
self._project_entries: dict[str, dict[str, Any]] = {}
|
||||
|
||||
@@ -241,7 +294,8 @@ class BrowseDialog(_BaseDialog):
|
||||
self.projects = self._make_treeview(self.projects_frame, "PROJECTS")
|
||||
self.projects.bind("<<TreeviewSelect>>", lambda _e: self._project_changed())
|
||||
|
||||
self.tree = self._make_treeview(self.tree_frame, "FOLDERS")
|
||||
tree_selectmode = "extended" if self.multi_select else "browse"
|
||||
self.tree = self._make_treeview(self.tree_frame, "FOLDERS", selectmode=tree_selectmode)
|
||||
self.tree.bind("<<TreeviewSelect>>", lambda _e: self._tree_selection_changed())
|
||||
self.tree.bind("<<TreeviewOpen>>", self._on_tree_open)
|
||||
|
||||
@@ -257,14 +311,14 @@ class BrowseDialog(_BaseDialog):
|
||||
self.action_button.grid(row=0, column=2)
|
||||
self.action_button.configure(state="disabled")
|
||||
|
||||
def _make_treeview(self, parent: ctk.CTkFrame, header: str) -> ttk.Treeview:
|
||||
def _make_treeview(self, parent: ctk.CTkFrame, header: str, selectmode: str = "browse") -> ttk.Treeview:
|
||||
ctk.CTkLabel(parent, text=header, anchor="w").pack(fill="x", padx=12, pady=(8, 0))
|
||||
body = ctk.CTkFrame(parent, fg_color="transparent")
|
||||
body.pack(fill="both", expand=True, padx=8, pady=8)
|
||||
body.grid_rowconfigure(0, weight=1)
|
||||
body.grid_columnconfigure(0, weight=1)
|
||||
|
||||
tree = ttk.Treeview(body, show="tree", selectmode="browse")
|
||||
tree = ttk.Treeview(body, show="tree", selectmode=selectmode)
|
||||
tree.grid(row=0, column=0, sticky="nsew")
|
||||
scrollbar = ctk.CTkScrollbar(body, orientation="vertical", command=tree.yview)
|
||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||
@@ -336,7 +390,7 @@ class BrowseDialog(_BaseDialog):
|
||||
if not isinstance(project, dict):
|
||||
return
|
||||
self.selected_project = project
|
||||
self.selected_entry = None
|
||||
self.selected_entries = []
|
||||
self._refresh_action_button()
|
||||
self._clear_tree()
|
||||
with self._with_progress("Loading top folders"):
|
||||
@@ -400,41 +454,53 @@ class BrowseDialog(_BaseDialog):
|
||||
|
||||
def _tree_selection_changed(self) -> None:
|
||||
selection = self.tree.selection()
|
||||
entry = self._tree_entries.get(selection[0]) if selection else None
|
||||
if isinstance(entry, dict) and not entry.get("__placeholder__"):
|
||||
self.selected_entry = entry
|
||||
entries: list[dict[str, Any]] = []
|
||||
for iid in selection:
|
||||
entry = self._tree_entries.get(iid)
|
||||
if isinstance(entry, dict) and not entry.get("__placeholder__"):
|
||||
entries.append(entry)
|
||||
self.selected_entries = entries
|
||||
|
||||
if not entries:
|
||||
pass
|
||||
elif len(entries) == 1:
|
||||
entry = entries[0]
|
||||
name = entry.get("display_name") or entry.get("name") or entry.get("id", "?")
|
||||
kind = entry.get("type", "entry")
|
||||
self.status.configure(text=f"Selected {kind}: {name}")
|
||||
else:
|
||||
self.selected_entry = None
|
||||
valid_count = sum(1 for e in entries if self._is_valid_selection(e))
|
||||
self.status.configure(text=f"Selected {valid_count} of {len(entries)} items.")
|
||||
self._refresh_action_button()
|
||||
|
||||
def _is_valid_selection(self, entry: dict[str, Any]) -> bool:
|
||||
if self.mode == "destination":
|
||||
return entry.get("type") == "folders"
|
||||
if self.mode == "ifcfed":
|
||||
return (
|
||||
entry.get("type") == "items"
|
||||
and (entry.get("display_name") or "").lower().endswith(".ifcfed")
|
||||
)
|
||||
return (
|
||||
entry.get("type") == "items"
|
||||
and (entry.get("display_name") or "").lower().endswith(MODEL_EXTENSIONS)
|
||||
)
|
||||
|
||||
def _valid_entries(self) -> list[dict[str, Any]]:
|
||||
return [e for e in self.selected_entries if self._is_valid_selection(e)]
|
||||
|
||||
def _refresh_action_button(self) -> None:
|
||||
enabled = self.selected_project is not None and self.selected_entry is not None
|
||||
if enabled:
|
||||
assert self.selected_entry is not None
|
||||
if self.mode == "destination":
|
||||
enabled = self.selected_entry.get("type") == "folders"
|
||||
elif self.mode == "ifcfed":
|
||||
enabled = (
|
||||
self.selected_entry.get("type") == "items"
|
||||
and (self.selected_entry.get("display_name") or "").lower().endswith(".ifcfed")
|
||||
)
|
||||
else:
|
||||
enabled = (
|
||||
self.selected_entry.get("type") == "items"
|
||||
and (self.selected_entry.get("display_name") or "").lower().endswith(MODEL_EXTENSIONS)
|
||||
)
|
||||
enabled = self.selected_project is not None and bool(self._valid_entries())
|
||||
self.action_button.configure(state="normal" if enabled else "disabled")
|
||||
|
||||
def _confirm(self) -> None:
|
||||
if not self.selected_hub or not self.selected_project or not self.selected_entry:
|
||||
valid = self._valid_entries()
|
||||
if not self.selected_hub or not self.selected_project or not valid:
|
||||
return
|
||||
self.result = {
|
||||
"hub": self.selected_hub,
|
||||
"project": self.selected_project,
|
||||
"entry": self.selected_entry,
|
||||
"entries": valid,
|
||||
}
|
||||
self._on_close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user