mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-20 23:36:20 +00:00
Add Autodesk cloud sync connector
Initial implementation of the ifcviewer-autodesk connector — a separate process that bridges the IfcViewer to Autodesk APS (BIM 360 / ACC). Speaks JSON-RPC 2.0 over stdio per CLOUD_SYNC_PROTOCOL.md (also added). PKCE OAuth with keyring-backed token storage, customtkinter browse/picker UI, and PyInstaller packaging. Implements both interactive and non-interactive variants of each push/pull (pull_ifcfed[_interactive], pull_models[_interactive], push_ifcfed[_interactive], push_model[_interactive]) so the viewer can offer both "Save"/"Open from Cloud" and "Save As"/"Add Model from Cloud" entry points. File transfers report progress through a dialog with per-byte updates; pull_models shows "(i/N)" for batches. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from ifcviewer_autodesk.connector import AutodeskConnector
|
||||
from ifcviewer_autodesk.rpc import JsonRpcHost
|
||||
from ifcviewer_autodesk.ui import ensure_tk_app
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ensure_tk_app()
|
||||
connector = AutodeskConnector()
|
||||
host = JsonRpcHost(connector.handlers(), stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr)
|
||||
return host.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,717 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import secrets
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
from dataclasses import asdict, dataclass
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
import keyring
|
||||
import keyring.errors
|
||||
|
||||
from ifcviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, RpcError
|
||||
|
||||
|
||||
Progress = Callable[[str, str, "int | None"], None]
|
||||
|
||||
|
||||
def _no_keyring_error() -> RpcError:
|
||||
return RpcError(
|
||||
JSONRPC_INTERNAL_ERROR,
|
||||
"No secure keyring backend is available. On macOS, use Keychain; "
|
||||
"on Windows, use Credential Manager; on Linux, install a Secret Service "
|
||||
"backend such as gnome-keyring or KWallet.",
|
||||
)
|
||||
|
||||
|
||||
class KeyringTokenStore:
|
||||
def __init__(self, *, service_name: str, username: str) -> None:
|
||||
self.service_name = service_name
|
||||
self.username = username
|
||||
|
||||
def load(self) -> dict[str, Any] | None:
|
||||
try:
|
||||
raw = keyring.get_password(self.service_name, self.username)
|
||||
except keyring.errors.NoKeyringError as exc:
|
||||
raise _no_keyring_error() from exc
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
def save(self, value: dict[str, Any]) -> None:
|
||||
try:
|
||||
keyring.set_password(self.service_name, self.username, json.dumps(value))
|
||||
except keyring.errors.NoKeyringError as exc:
|
||||
raise _no_keyring_error() from exc
|
||||
|
||||
def delete(self) -> None:
|
||||
try:
|
||||
keyring.delete_password(self.service_name, self.username)
|
||||
except keyring.errors.PasswordDeleteError:
|
||||
pass
|
||||
except keyring.errors.NoKeyringError as exc:
|
||||
raise _no_keyring_error() from exc
|
||||
|
||||
|
||||
def _base64url(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def generate_code_verifier() -> str:
|
||||
return _base64url(secrets.token_bytes(48))
|
||||
|
||||
|
||||
def generate_code_challenge(verifier: str) -> str:
|
||||
return _base64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoredToken:
|
||||
client_id: str
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
access_token_expires_at_utc: str
|
||||
refresh_token_expires_at_utc: str
|
||||
scope: str
|
||||
|
||||
@property
|
||||
def access_token_expires_at(self) -> dt.datetime:
|
||||
return dt.datetime.fromisoformat(self.access_token_expires_at_utc)
|
||||
|
||||
@property
|
||||
def refresh_token_expires_at(self) -> dt.datetime:
|
||||
return dt.datetime.fromisoformat(self.refresh_token_expires_at_utc)
|
||||
|
||||
|
||||
def _noop_progress(_phase: str, _message: str, _percent: int | None = None) -> None:
|
||||
return
|
||||
|
||||
|
||||
class AuthSessionService:
|
||||
authorize_endpoint = "https://developer.api.autodesk.com/authentication/v2/authorize"
|
||||
token_endpoint = "https://developer.api.autodesk.com/authentication/v2/token"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client_id: str,
|
||||
callback_url: str,
|
||||
scope: str,
|
||||
token_store: KeyringTokenStore,
|
||||
) -> None:
|
||||
self.client_id = client_id
|
||||
self.callback_url = callback_url
|
||||
self.scope = scope
|
||||
self.token_store = token_store
|
||||
self.http = httpx.Client(timeout=60)
|
||||
|
||||
def get_token(self) -> StoredToken | None:
|
||||
raw = self.token_store.load()
|
||||
return StoredToken(**raw) if raw else None
|
||||
|
||||
def ensure_access_token(self, progress: Progress = _noop_progress) -> str:
|
||||
token = self.get_token()
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
if token and token.access_token_expires_at > now + dt.timedelta(minutes=1):
|
||||
return token.access_token
|
||||
if token and token.refresh_token_expires_at > now + dt.timedelta(minutes=1):
|
||||
return self._refresh(token, progress).access_token
|
||||
return self.login_interactive(progress).access_token
|
||||
|
||||
def login_interactive(self, progress: Progress = _noop_progress) -> StoredToken:
|
||||
progress("auth", "Preparing Autodesk sign-in", None)
|
||||
verifier = generate_code_verifier()
|
||||
challenge = generate_code_challenge(verifier)
|
||||
state = secrets.token_hex(16)
|
||||
callback = urllib.parse.urlparse(self.callback_url)
|
||||
if callback.scheme != "http" or callback.hostname not in {"127.0.0.1", "localhost"}:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "Callback URL must be http://localhost or http://127.0.0.1.")
|
||||
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"response_type": "code",
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": self.callback_url,
|
||||
"scope": self.scope,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": state,
|
||||
}
|
||||
)
|
||||
authorize_url = f"{self.authorize_endpoint}?{query}"
|
||||
|
||||
progress("auth", "Opening browser for Autodesk sign-in", None)
|
||||
webbrowser.open(authorize_url)
|
||||
code = self._wait_for_callback(
|
||||
callback.hostname or "127.0.0.1",
|
||||
callback.port or 80,
|
||||
callback.path or "/",
|
||||
state,
|
||||
)
|
||||
|
||||
progress("auth", "Exchanging authorization code for token", None)
|
||||
response = self.http.post(
|
||||
self.token_endpoint,
|
||||
data={
|
||||
"client_id": self.client_id,
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"code_verifier": verifier,
|
||||
"redirect_uri": self.callback_url,
|
||||
},
|
||||
)
|
||||
if response.is_error:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Token exchange failed: {response.text}")
|
||||
token = self._token_from_payload(response.json())
|
||||
self.token_store.save(asdict(token))
|
||||
progress("auth", "Signed in to Autodesk", 100)
|
||||
return token
|
||||
|
||||
def _refresh(self, token: StoredToken, progress: Progress) -> StoredToken:
|
||||
progress("auth", "Refreshing Autodesk session", None)
|
||||
response = self.http.post(
|
||||
self.token_endpoint,
|
||||
data={
|
||||
"client_id": self.client_id,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": token.refresh_token,
|
||||
"scope": self.scope,
|
||||
},
|
||||
)
|
||||
if response.is_error:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Token refresh failed: {response.text}")
|
||||
refreshed = self._token_from_payload(response.json())
|
||||
self.token_store.save(asdict(refreshed))
|
||||
progress("auth", "Session refreshed", 100)
|
||||
return refreshed
|
||||
|
||||
def _token_from_payload(self, payload: dict[str, Any]) -> StoredToken:
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
refresh_ttl = int(payload.get("refresh_token_expires_in", 15 * 24 * 60 * 60))
|
||||
return StoredToken(
|
||||
client_id=self.client_id,
|
||||
access_token=payload["access_token"],
|
||||
refresh_token=payload["refresh_token"],
|
||||
access_token_expires_at_utc=(now + dt.timedelta(seconds=int(payload["expires_in"]) - 30)).isoformat(),
|
||||
refresh_token_expires_at_utc=(now + dt.timedelta(seconds=refresh_ttl - 30)).isoformat(),
|
||||
scope=self.scope,
|
||||
)
|
||||
|
||||
def _wait_for_callback(self, host: str, port: int, path: str, expected_state: str) -> str:
|
||||
result: dict[str, str] = {}
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
if parsed.path != path:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
query = urllib.parse.parse_qs(parsed.query)
|
||||
result["state"] = query.get("state", [""])[0]
|
||||
result["code"] = query.get("code", [""])[0]
|
||||
result["error"] = query.get("error", [""])[0]
|
||||
body = b"<html><body><h2>Authentication complete. You can close this window.</h2></body></html>"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
server = HTTPServer((host, port), Handler)
|
||||
server.handle_request()
|
||||
server.server_close()
|
||||
|
||||
if result.get("error"):
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk returned OAuth error '{result['error']}'.")
|
||||
if result.get("state") != expected_state:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth state mismatch.")
|
||||
code = result.get("code", "")
|
||||
if not code:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "OAuth callback did not return an authorization code.")
|
||||
return code
|
||||
|
||||
|
||||
class ApsClient:
|
||||
def __init__(self, auth: AuthSessionService) -> None:
|
||||
self.auth = auth
|
||||
self.http = httpx.Client(timeout=120)
|
||||
|
||||
# Browsing -----------------------------------------------------------------
|
||||
|
||||
def list_hubs(self) -> list[dict[str, Any]]:
|
||||
payload = self._get_json("https://developer.api.autodesk.com/project/v1/hubs")
|
||||
return [
|
||||
{
|
||||
"id": item["id"],
|
||||
"name": item["attributes"]["name"],
|
||||
"extension_type": item["attributes"]["extension"]["type"],
|
||||
}
|
||||
for item in payload.get("data", [])
|
||||
]
|
||||
|
||||
def list_projects(self, hub_id: str) -> list[dict[str, Any]]:
|
||||
url = f"https://developer.api.autodesk.com/project/v1/hubs/{hub_id}/projects"
|
||||
projects: list[dict[str, Any]] = []
|
||||
while url:
|
||||
payload = self._get_json(url)
|
||||
for item in payload.get("data", []):
|
||||
projects.append(
|
||||
{
|
||||
"id": item["id"],
|
||||
"name": item["attributes"]["name"],
|
||||
"extension_type": item["attributes"]["extension"]["type"],
|
||||
"root_folder_id": item["relationships"]["rootFolder"]["data"]["id"],
|
||||
}
|
||||
)
|
||||
url = payload.get("links", {}).get("next", {}).get("href", "") or ""
|
||||
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", [])]
|
||||
|
||||
def list_folder_contents(
|
||||
self,
|
||||
project_id: str,
|
||||
folder_id: str,
|
||||
*,
|
||||
object_types: list[str] | None = None,
|
||||
extension_filter: Callable[[dict[str, Any]], bool] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
url = f"https://developer.api.autodesk.com/data/v1/projects/{project_id}/folders/{folder_id}/contents"
|
||||
if object_types:
|
||||
query = [("filter[type]", value) for value in object_types]
|
||||
url = f"{url}?{urllib.parse.urlencode(query, doseq=True)}"
|
||||
entries: list[dict[str, Any]] = []
|
||||
while url:
|
||||
payload = self._get_json(url)
|
||||
for item in payload.get("data", []):
|
||||
entry = self._entry(item)
|
||||
if extension_filter and entry["type"] == "items" and not extension_filter(entry):
|
||||
continue
|
||||
entries.append(entry)
|
||||
url = payload.get("links", {}).get("next", {}).get("href", "") or ""
|
||||
return entries
|
||||
|
||||
def get_item(self, project_id: str, item_id: str) -> dict[str, Any]:
|
||||
"""Return the item plus its current tip in a single request.
|
||||
|
||||
``hidden`` reflects the item's soft-delete state (BIM 360 / ACC mark
|
||||
deleted items as ``hidden: true``; the storage URL may still resolve
|
||||
to a stale copy, so callers must check this before downloading).
|
||||
"""
|
||||
payload = self._get_json(
|
||||
f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}"
|
||||
f"/items/{urllib.parse.quote(item_id, safe='')}?include=tip"
|
||||
)
|
||||
item = payload["data"]
|
||||
item_attributes = item.get("attributes", {})
|
||||
parent_folder_id = self._relationship_id(item, "parent")
|
||||
tip_id = self._relationship_id(item, "tip")
|
||||
tip: dict[str, Any] | None = None
|
||||
for included in payload.get("included", []):
|
||||
if included.get("type") == "versions" and included.get("id") == tip_id:
|
||||
tip = included
|
||||
break
|
||||
|
||||
if tip is None:
|
||||
return {
|
||||
"id": item["id"],
|
||||
"display_name": item_attributes.get("displayName")
|
||||
or item_attributes.get("name")
|
||||
or item["id"],
|
||||
"hidden": True,
|
||||
"version_id": None,
|
||||
"storage_id": None,
|
||||
"version_number": None,
|
||||
"last_modified_time_utc": None,
|
||||
"last_modified_user_name": None,
|
||||
"parent_folder_id": parent_folder_id,
|
||||
}
|
||||
|
||||
tip_attributes = tip.get("attributes", {})
|
||||
return {
|
||||
"id": item["id"],
|
||||
"display_name": tip_attributes.get("displayName")
|
||||
or tip_attributes.get("name")
|
||||
or item_attributes.get("displayName")
|
||||
or item["id"],
|
||||
"hidden": bool(item_attributes.get("hidden", False)),
|
||||
"version_id": tip["id"],
|
||||
"storage_id": self._relationship_id(tip, "storage"),
|
||||
"version_number": tip_attributes.get("versionNumber"),
|
||||
"last_modified_time_utc": tip_attributes.get("lastModifiedTime"),
|
||||
"last_modified_user_name": tip_attributes.get("lastModifiedUserName"),
|
||||
"parent_folder_id": parent_folder_id,
|
||||
}
|
||||
|
||||
# Download / upload --------------------------------------------------------
|
||||
|
||||
def download_storage_to_file(
|
||||
self,
|
||||
storage_id: str,
|
||||
destination_path: Path,
|
||||
*,
|
||||
progress: Callable[[str, 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)
|
||||
self._download_to_file(signed_url, destination_path, progress)
|
||||
|
||||
def upload_file_to_folder(
|
||||
self,
|
||||
project_id: str,
|
||||
folder_id: str,
|
||||
local_path: Path,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
progress: Callable[[str, 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.")
|
||||
file_name = display_name or local_path.name
|
||||
storage_id = self._create_storage(project_id, folder_id, file_name)
|
||||
bucket_key, object_key = self._parse_storage_id(storage_id)
|
||||
self._upload_local_file_to_oss(bucket_key, object_key, local_path, progress)
|
||||
existing_item = self._find_item_in_folder(project_id, folder_id, file_name)
|
||||
if existing_item is not None:
|
||||
return self._create_version(project_id, existing_item["id"], file_name, storage_id)
|
||||
return self._create_item(project_id, folder_id, file_name, storage_id)
|
||||
|
||||
# HTTP helpers -------------------------------------------------------------
|
||||
|
||||
def _get_json(self, url: str) -> dict[str, Any]:
|
||||
token = self.auth.ensure_access_token()
|
||||
try:
|
||||
response = self.http.get(url, headers={"Authorization": f"Bearer {token}"})
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
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
|
||||
except httpx.HTTPError as exc:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
|
||||
|
||||
def _post_json(self, url: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
token = self.auth.ensure_access_token()
|
||||
try:
|
||||
response = self.http.post(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
"Accept": "application/vnd.api+json",
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
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
|
||||
except httpx.HTTPError as exc:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
|
||||
|
||||
def _get_signed_download_url(self, bucket_key: str, object_key: str) -> str:
|
||||
payload = self._get_json(
|
||||
"https://developer.api.autodesk.com/oss/v2/buckets/"
|
||||
f"{urllib.parse.quote(bucket_key, safe='')}/objects/"
|
||||
f"{urllib.parse.quote(object_key, safe='')}/signeds3download"
|
||||
)
|
||||
url = payload.get("url")
|
||||
if not isinstance(url, str) or not url:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "Signed download URL response did not contain a URL.")
|
||||
return url
|
||||
|
||||
def _download_to_file(
|
||||
self,
|
||||
url: str,
|
||||
destination_path: Path,
|
||||
progress: Callable[[str, int | None], None] | None,
|
||||
) -> None:
|
||||
try:
|
||||
with self.http.stream("GET", url) as response:
|
||||
response.raise_for_status()
|
||||
total_bytes = None
|
||||
header_value = response.headers.get("Content-Length")
|
||||
if header_value and header_value.isdigit():
|
||||
total_bytes = int(header_value)
|
||||
downloaded_bytes = 0
|
||||
with open(destination_path, "wb") as handle:
|
||||
for chunk in response.iter_bytes():
|
||||
handle.write(chunk)
|
||||
downloaded_bytes += len(chunk)
|
||||
if progress and total_bytes:
|
||||
percent = min(100, int((downloaded_bytes / total_bytes) * 100))
|
||||
progress(destination_path.name, percent)
|
||||
elif progress:
|
||||
progress(destination_path.name, None)
|
||||
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
|
||||
except httpx.HTTPError as exc:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
|
||||
|
||||
def _create_storage(self, project_id: str, folder_id: str, file_name: str) -> str:
|
||||
payload = self._post_json(
|
||||
f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}/storage",
|
||||
{
|
||||
"jsonapi": {"version": "1.0"},
|
||||
"data": {
|
||||
"type": "objects",
|
||||
"attributes": {"name": file_name},
|
||||
"relationships": {"target": {"data": {"type": "folders", "id": folder_id}}},
|
||||
},
|
||||
},
|
||||
)
|
||||
storage_id = payload.get("data", {}).get("id")
|
||||
if not isinstance(storage_id, str) or not storage_id:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "Storage creation did not return an object id.")
|
||||
return storage_id
|
||||
|
||||
def _upload_local_file_to_oss(
|
||||
self,
|
||||
bucket_key: str,
|
||||
object_key: str,
|
||||
local_path: Path,
|
||||
progress: Callable[[str, int | None], None] | None,
|
||||
) -> None:
|
||||
file_size = local_path.stat().st_size
|
||||
chunk_size = 5 * 1024 * 1024
|
||||
total_parts = max(1, math.ceil(file_size / chunk_size))
|
||||
upload_key: str | None = None
|
||||
parts_uploaded = 0
|
||||
bytes_uploaded = 0
|
||||
|
||||
with open(local_path, "rb") as handle:
|
||||
while parts_uploaded < total_parts:
|
||||
parts_to_request = min(total_parts - parts_uploaded, 5)
|
||||
first_part = parts_uploaded + 1
|
||||
signed = self._get_signed_upload_urls(
|
||||
bucket_key,
|
||||
object_key,
|
||||
upload_key=upload_key,
|
||||
first_part=first_part,
|
||||
parts=parts_to_request,
|
||||
)
|
||||
if upload_key is None:
|
||||
upload_key = signed.get("uploadKey")
|
||||
urls = signed.get("urls", [])
|
||||
if not isinstance(urls, list) or not urls:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "Upload URL response did not contain upload URLs.")
|
||||
for url in urls:
|
||||
if parts_uploaded >= total_parts:
|
||||
break
|
||||
chunk = handle.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
self._put_bytes(str(url), chunk)
|
||||
parts_uploaded += 1
|
||||
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)
|
||||
|
||||
if not upload_key:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "Upload did not return an upload key.")
|
||||
self._complete_signed_upload(bucket_key, object_key, upload_key)
|
||||
|
||||
def _get_signed_upload_urls(
|
||||
self,
|
||||
bucket_key: str,
|
||||
object_key: str,
|
||||
*,
|
||||
upload_key: str | None,
|
||||
first_part: int,
|
||||
parts: int,
|
||||
) -> dict[str, Any]:
|
||||
token = self.auth.ensure_access_token()
|
||||
params: dict[str, Any] = {"minutesExpiration": 10, "firstPart": first_part, "parts": parts}
|
||||
if upload_key:
|
||||
params["uploadKey"] = upload_key
|
||||
try:
|
||||
response = self.http.get(
|
||||
"https://developer.api.autodesk.com/oss/v2/buckets/"
|
||||
f"{urllib.parse.quote(bucket_key, safe='')}/objects/"
|
||||
f"{urllib.parse.quote(object_key, safe='')}/signeds3upload",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
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
|
||||
except httpx.HTTPError as exc:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
|
||||
|
||||
def _complete_signed_upload(self, bucket_key: str, object_key: str, upload_key: str) -> None:
|
||||
token = self.auth.ensure_access_token()
|
||||
try:
|
||||
response = self.http.post(
|
||||
"https://developer.api.autodesk.com/oss/v2/buckets/"
|
||||
f"{urllib.parse.quote(bucket_key, safe='')}/objects/"
|
||||
f"{urllib.parse.quote(object_key, safe='')}/signeds3upload",
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
json={"uploadKey": upload_key},
|
||||
)
|
||||
response.raise_for_status()
|
||||
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
|
||||
except httpx.HTTPError as exc:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
|
||||
|
||||
def _put_bytes(self, url: str, content: bytes) -> None:
|
||||
try:
|
||||
response = self.http.put(url, content=content, headers={"Content-Type": "application/octet-stream"})
|
||||
response.raise_for_status()
|
||||
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
|
||||
except httpx.HTTPError as exc:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, str(exc)) from exc
|
||||
|
||||
def _find_item_in_folder(self, project_id: str, folder_id: str, file_name: str) -> dict[str, Any] | None:
|
||||
children = self.list_folder_contents(project_id, folder_id, object_types=["items"])
|
||||
return next(
|
||||
(
|
||||
child for child in children
|
||||
if self._entry_name_matches(child, file_name)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _create_version(self, project_id: str, item_id: str, file_name: str, storage_id: str) -> dict[str, Any]:
|
||||
payload = self._post_json(
|
||||
f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}/versions",
|
||||
{
|
||||
"jsonapi": {"version": "1.0"},
|
||||
"data": {
|
||||
"type": "versions",
|
||||
"attributes": {
|
||||
"name": file_name,
|
||||
"extension": {"type": "versions:autodesk.bim360:File", "version": "1.0"},
|
||||
},
|
||||
"relationships": {
|
||||
"item": {"data": {"type": "items", "id": item_id}},
|
||||
"storage": {"data": {"type": "objects", "id": storage_id}},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
version = payload["data"]
|
||||
attributes = version.get("attributes", {})
|
||||
return {
|
||||
"item_id": item_id,
|
||||
"version_id": version["id"],
|
||||
"display_name": file_name,
|
||||
"version_number": attributes.get("versionNumber"),
|
||||
"last_modified_time_utc": attributes.get("lastModifiedTime"),
|
||||
"last_modified_user_name": attributes.get("lastModifiedUserName"),
|
||||
}
|
||||
|
||||
def _create_item(self, project_id: str, folder_id: str, file_name: str, storage_id: str) -> dict[str, Any]:
|
||||
payload = self._post_json(
|
||||
f"https://developer.api.autodesk.com/data/v1/projects/{urllib.parse.quote(project_id, safe='')}/items",
|
||||
{
|
||||
"jsonapi": {"version": "1.0"},
|
||||
"data": {
|
||||
"type": "items",
|
||||
"attributes": {
|
||||
"displayName": file_name,
|
||||
"extension": {"type": "items:autodesk.bim360:File", "version": "1.0"},
|
||||
},
|
||||
"relationships": {
|
||||
"tip": {"data": {"type": "versions", "id": "1"}},
|
||||
"parent": {"data": {"type": "folders", "id": folder_id}},
|
||||
},
|
||||
},
|
||||
"included": [
|
||||
{
|
||||
"type": "versions",
|
||||
"id": "1",
|
||||
"attributes": {
|
||||
"name": file_name,
|
||||
"extension": {"type": "versions:autodesk.bim360:File", "version": "1.0"},
|
||||
},
|
||||
"relationships": {"storage": {"data": {"type": "objects", "id": storage_id}}},
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
item = payload["data"]
|
||||
version_id = "1"
|
||||
version_number: Any = 1
|
||||
last_modified_time: Any = None
|
||||
last_modified_user: Any = None
|
||||
for included in payload.get("included", []):
|
||||
if included.get("type") == "versions":
|
||||
version_id = included.get("id") or version_id
|
||||
attributes = included.get("attributes", {})
|
||||
version_number = attributes.get("versionNumber", version_number)
|
||||
last_modified_time = attributes.get("lastModifiedTime")
|
||||
last_modified_user = attributes.get("lastModifiedUserName")
|
||||
break
|
||||
return {
|
||||
"item_id": item["id"],
|
||||
"version_id": version_id,
|
||||
"display_name": file_name,
|
||||
"version_number": version_number,
|
||||
"last_modified_time_utc": last_modified_time,
|
||||
"last_modified_user_name": last_modified_user,
|
||||
}
|
||||
|
||||
# Static helpers -----------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _parse_storage_id(storage_id: str) -> tuple[str, str]:
|
||||
marker = "urn:adsk.objects:os.object:"
|
||||
if not storage_id.startswith(marker):
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Unsupported storage identifier '{storage_id}'.")
|
||||
path = storage_id[len(marker):]
|
||||
slash = path.find("/")
|
||||
if slash <= 0 or slash == len(path) - 1:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Malformed storage identifier '{storage_id}'.")
|
||||
return path[:slash], path[slash + 1 :]
|
||||
|
||||
@staticmethod
|
||||
def _entry(item: dict[str, Any]) -> dict[str, Any]:
|
||||
attributes = item["attributes"]
|
||||
return {
|
||||
"id": item["id"],
|
||||
"type": item["type"],
|
||||
"display_name": attributes.get("displayName") or attributes.get("name") or "",
|
||||
"name": attributes.get("name"),
|
||||
"extension_type": attributes.get("extension", {}).get("type", ""),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _relationship_id(data: dict[str, Any], name: str) -> str | None:
|
||||
rel_data = data.get("relationships", {}).get(name, {}).get("data")
|
||||
if isinstance(rel_data, dict):
|
||||
rel_id = rel_data.get("id")
|
||||
return rel_id if isinstance(rel_id, str) and rel_id else None
|
||||
if isinstance(rel_data, list) and rel_data:
|
||||
rel_id = rel_data[0].get("id")
|
||||
return rel_id if isinstance(rel_id, str) and rel_id else None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _entry_name_matches(entry: dict[str, Any], expected_name: str) -> bool:
|
||||
display_name = str(entry.get("display_name") or "").lower()
|
||||
raw_name = str(entry.get("name") or "").lower()
|
||||
expected = expected_name.lower()
|
||||
return display_name == expected or raw_name == expected
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def cache_root() -> Path:
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
|
||||
root = Path(base) / "ifcviewer-autodesk" / "Cache"
|
||||
elif system == "Darwin":
|
||||
root = Path.home() / "Library" / "Caches" / "ifcviewer-autodesk"
|
||||
else:
|
||||
base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
|
||||
root = Path(base) / "ifcviewer-autodesk"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _short_hash(*parts: str) -> str:
|
||||
joined = "\x1f".join(parts)
|
||||
return hashlib.sha256(joined.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def ifcfed_dir(project_id: str, item_id: str) -> Path:
|
||||
"""Stable directory for an .ifcfed. Re-downloads overwrite in place so the
|
||||
viewer's open path remains valid across sync operations."""
|
||||
return cache_root() / "ifcfeds" / _short_hash(project_id, item_id)
|
||||
|
||||
|
||||
def model_dir(project_id: str, item_id: str, version_id: str) -> Path:
|
||||
"""Per-version directory for a model. A new resolved version → a new
|
||||
directory, satisfying the spec's invariant that sidecars regenerate when
|
||||
the model file changes."""
|
||||
return cache_root() / "models" / _short_hash(project_id, item_id, version_id)
|
||||
|
||||
|
||||
def prepare_sole_child_dir(directory: Path) -> Path:
|
||||
"""Clear the directory so the file we write is the only child."""
|
||||
if directory.exists():
|
||||
shutil.rmtree(directory)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return directory
|
||||
|
||||
|
||||
def write_manifest(ifcfed_path: Path, manifest: dict[str, Any]) -> Path:
|
||||
manifest_path = ifcfed_path.with_name(ifcfed_path.name + ".manifest")
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
return manifest_path
|
||||
|
||||
|
||||
def read_manifest(ifcfed_path: Path) -> dict[str, Any] | None:
|
||||
manifest_path = ifcfed_path.with_name(ifcfed_path.name + ".manifest")
|
||||
if not manifest_path.exists():
|
||||
return None
|
||||
return json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
@@ -0,0 +1,498 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from ifcviewer_autodesk import cache, settings
|
||||
from ifcviewer_autodesk.autodesk import ApsClient, AuthSessionService, KeyringTokenStore
|
||||
from ifcviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, JSONRPC_INVALID_PARAMS, RpcError
|
||||
from ifcviewer_autodesk.ui import BrowseDialog, SettingsDialog, progress_dialog, prompt_for_filename
|
||||
|
||||
|
||||
ApsProgress = Callable[[str, "int | None"], None]
|
||||
Report = Callable[[str, str, "int | None"], None]
|
||||
|
||||
|
||||
def _download_callback(report: Report, index: int = 0, total: int = 0) -> ApsProgress:
|
||||
"""Adapt ProgressDialog.report (3-arg) to the APS download callback (2-arg).
|
||||
|
||||
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:
|
||||
suffix = f" ({index}/{total})" if total else ""
|
||||
report("download", f"Downloading {name}{suffix}", percent)
|
||||
return cb
|
||||
|
||||
|
||||
def _upload_callback(report: Report) -> ApsProgress:
|
||||
def cb(name: str, percent: int | None) -> None:
|
||||
report("upload", f"Uploading {name}", percent)
|
||||
return cb
|
||||
|
||||
|
||||
CONNECTOR_ID = "autodesk"
|
||||
KEYRING_SERVICE = "ifcviewer-autodesk"
|
||||
DEFAULT_CALLBACK_URL = "http://localhost:8080/"
|
||||
DEFAULT_SCOPE = "data:read data:write data:create"
|
||||
|
||||
|
||||
class AutodeskConnector:
|
||||
def __init__(self) -> None:
|
||||
self.auth: AuthSessionService | None = None
|
||||
self.aps: ApsClient | None = None
|
||||
self.reload_credentials()
|
||||
|
||||
def reload_credentials(self) -> None:
|
||||
"""Rebuild auth + APS from current settings. Safe to call any time."""
|
||||
client_id = settings.load_client_id()
|
||||
if not client_id:
|
||||
self.auth = None
|
||||
self.aps = None
|
||||
return
|
||||
token_store = KeyringTokenStore(service_name=KEYRING_SERVICE, username=client_id)
|
||||
self.auth = AuthSessionService(
|
||||
client_id=client_id,
|
||||
callback_url=DEFAULT_CALLBACK_URL,
|
||||
scope=DEFAULT_SCOPE,
|
||||
token_store=token_store,
|
||||
)
|
||||
self.aps = ApsClient(self.auth)
|
||||
|
||||
def _require_aps(self) -> tuple[AuthSessionService, ApsClient]:
|
||||
if self.auth is None or self.aps is None:
|
||||
raise RpcError(
|
||||
JSONRPC_INTERNAL_ERROR,
|
||||
"Autodesk client id is not configured. Open the connector settings to set it.",
|
||||
)
|
||||
return self.auth, self.aps
|
||||
|
||||
def handlers(self) -> dict[str, Any]:
|
||||
return {
|
||||
"pull_ifcfed_interactive": self.pull_ifcfed_interactive,
|
||||
"pull_ifcfed": self.pull_ifcfed,
|
||||
"pull_models": self.pull_models,
|
||||
"pull_models_interactive": self.pull_models_interactive,
|
||||
"push_ifcfed_interactive": self.push_ifcfed_interactive,
|
||||
"push_ifcfed": self.push_ifcfed,
|
||||
"push_model_interactive": self.push_model_interactive,
|
||||
"push_model": self.push_model,
|
||||
"open_settings": self.open_settings,
|
||||
}
|
||||
|
||||
# ---- open_settings ------------------------------------------------------
|
||||
|
||||
def open_settings(self, _params: Any) -> dict[str, Any]:
|
||||
SettingsDialog(connector=self).run()
|
||||
return {}
|
||||
|
||||
# ---- pull_ifcfed_interactive --------------------------------------------
|
||||
|
||||
def pull_ifcfed_interactive(self, _params: Any) -> dict[str, Any]:
|
||||
auth, aps = self._require_aps()
|
||||
chosen = BrowseDialog(auth=auth, aps=aps, mode="ifcfed").run()
|
||||
hub = chosen["hub"]
|
||||
project = chosen["project"]
|
||||
entry = chosen["entry"]
|
||||
with progress_dialog("Downloading project") as report:
|
||||
path = self._download_ifcfed(
|
||||
aps=aps,
|
||||
hub_id=hub["id"],
|
||||
project_id=project["id"],
|
||||
item_id=entry["id"],
|
||||
display_name=entry["display_name"],
|
||||
progress=_download_callback(report),
|
||||
)
|
||||
return {"path": str(path)}
|
||||
|
||||
# ---- pull_ifcfed --------------------------------------------------------
|
||||
|
||||
def pull_ifcfed(self, params: Any) -> dict[str, Any]:
|
||||
_, aps = self._require_aps()
|
||||
manifest = _require_object(params, "params")
|
||||
hub_id = _require_string(manifest, "hub_id")
|
||||
project_id = _require_string(manifest, "project_id")
|
||||
item_id = _require_string(manifest, "item_id")
|
||||
display_name = manifest.get("display_name") or item_id
|
||||
with progress_dialog("Downloading project") as report:
|
||||
path = self._download_ifcfed(
|
||||
aps=aps,
|
||||
hub_id=hub_id,
|
||||
project_id=project_id,
|
||||
item_id=item_id,
|
||||
display_name=display_name,
|
||||
progress=_download_callback(report),
|
||||
)
|
||||
return {"path": str(path)}
|
||||
|
||||
def _download_ifcfed(
|
||||
self,
|
||||
*,
|
||||
aps: ApsClient,
|
||||
hub_id: str,
|
||||
project_id: str,
|
||||
item_id: str,
|
||||
display_name: str,
|
||||
progress: ApsProgress | None = None,
|
||||
) -> Path:
|
||||
item = aps.get_item(project_id, item_id)
|
||||
if item["hidden"]:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has been deleted.")
|
||||
storage_id = item["storage_id"]
|
||||
if not isinstance(storage_id, str):
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has no downloadable storage.")
|
||||
file_name = item["display_name"] or display_name or item_id
|
||||
if not file_name.lower().endswith(".ifcfed"):
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Item '{file_name}' is not an .ifcfed file.")
|
||||
|
||||
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir(project_id, item_id))
|
||||
ifcfed_path = directory / file_name
|
||||
aps.download_storage_to_file(storage_id, ifcfed_path, progress=progress)
|
||||
cache.write_manifest(
|
||||
ifcfed_path,
|
||||
{
|
||||
"connector": CONNECTOR_ID,
|
||||
"hub_id": hub_id,
|
||||
"project_id": project_id,
|
||||
"item_id": item_id,
|
||||
"display_name": file_name,
|
||||
},
|
||||
)
|
||||
return ifcfed_path
|
||||
|
||||
# ---- pull_models --------------------------------------------------------
|
||||
|
||||
def pull_models(self, params: Any) -> list[dict[str, Any] | None]:
|
||||
_, aps = self._require_aps()
|
||||
models = _require_array(params, "params")
|
||||
results: list[dict[str, Any] | None] = []
|
||||
total = len(models)
|
||||
with progress_dialog("Downloading models") as report:
|
||||
for index, model in enumerate(models):
|
||||
callback = _download_callback(report, index=index + 1, total=total)
|
||||
try:
|
||||
results.append(self._resolve_model(aps, model, progress=callback))
|
||||
except RpcError as exc:
|
||||
print(f"pull_models[{index}] skipped: {exc.message}", file=sys.stderr)
|
||||
results.append(None)
|
||||
except Exception as exc:
|
||||
print(f"pull_models[{index}] skipped: {exc}", file=sys.stderr)
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
results.append(None)
|
||||
return results
|
||||
|
||||
def _resolve_model(
|
||||
self,
|
||||
aps: ApsClient,
|
||||
model: Any,
|
||||
*,
|
||||
progress: ApsProgress | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if not isinstance(model, dict):
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, "Each model entry must be an object.")
|
||||
source = model.get("source")
|
||||
if not isinstance(source, dict):
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, "Each model entry must have a 'source' object.")
|
||||
if source.get("connector") != CONNECTOR_ID:
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"Source connector is not '{CONNECTOR_ID}'.")
|
||||
project_id = _require_string(source, "project_id")
|
||||
item_id = _require_string(source, "item_id")
|
||||
display_name_hint = model.get("display_name") or item_id
|
||||
|
||||
item = aps.get_item(project_id, item_id)
|
||||
if item["hidden"]:
|
||||
print(f"Autodesk item '{item_id}' is hidden/deleted; returning null.", file=sys.stderr)
|
||||
return None
|
||||
|
||||
storage_id = item["storage_id"]
|
||||
if not isinstance(storage_id, str):
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has no downloadable storage.")
|
||||
file_name = item["display_name"] or display_name_hint
|
||||
version_id = item["version_id"]
|
||||
|
||||
directory = cache.model_dir(project_id, item_id, version_id)
|
||||
model_path = directory / file_name
|
||||
if not model_path.exists():
|
||||
cache.prepare_sole_child_dir(directory)
|
||||
aps.download_storage_to_file(storage_id, model_path, progress=progress)
|
||||
|
||||
return {
|
||||
"path": str(model_path),
|
||||
"metadata": _build_metadata(item),
|
||||
}
|
||||
|
||||
# ---- pull_models_interactive --------------------------------------------
|
||||
|
||||
def pull_models_interactive(self, _params: Any) -> list[dict[str, Any]]:
|
||||
auth, aps = self._require_aps()
|
||||
chosen = BrowseDialog(auth=auth, aps=aps, mode="model").run()
|
||||
hub = chosen["hub"]
|
||||
project = chosen["project"]
|
||||
entry = chosen["entry"]
|
||||
item = aps.get_item(project["id"], entry["id"])
|
||||
if item["hidden"]:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "The selected Autodesk item 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.")
|
||||
|
||||
file_name = item["display_name"] or entry["display_name"] or entry["id"]
|
||||
version_id = item["version_id"]
|
||||
|
||||
directory = cache.model_dir(project["id"], entry["id"], version_id)
|
||||
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)
|
||||
)
|
||||
|
||||
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 --------------------------------------------
|
||||
|
||||
def push_ifcfed_interactive(self, params: Any) -> dict[str, Any]:
|
||||
auth, aps = self._require_aps()
|
||||
params_obj = _require_object(params, "params")
|
||||
local_path = Path(_require_string(params_obj, "path"))
|
||||
if not local_path.exists():
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
|
||||
if not local_path.name.lower().endswith(".ifcfed"):
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, "push_ifcfed_interactive expects an .ifcfed file.")
|
||||
|
||||
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
|
||||
hub = chosen["hub"]
|
||||
project = chosen["project"]
|
||||
folder = chosen["entry"]
|
||||
|
||||
file_name = prompt_for_filename(
|
||||
title="Save Project",
|
||||
label="Save .ifcfed as:",
|
||||
default=local_path.name,
|
||||
)
|
||||
if not file_name:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "User cancelled save to cloud.")
|
||||
if not file_name.lower().endswith(".ifcfed"):
|
||||
file_name = file_name + ".ifcfed"
|
||||
|
||||
with progress_dialog("Uploading project") as report:
|
||||
uploaded = aps.upload_file_to_folder(
|
||||
project["id"],
|
||||
folder["id"],
|
||||
local_path,
|
||||
display_name=file_name,
|
||||
progress=_upload_callback(report),
|
||||
)
|
||||
|
||||
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir(project["id"], uploaded["item_id"]))
|
||||
cached_path = directory / file_name
|
||||
cached_path.write_bytes(local_path.read_bytes())
|
||||
cache.write_manifest(
|
||||
cached_path,
|
||||
{
|
||||
"connector": CONNECTOR_ID,
|
||||
"hub_id": hub["id"],
|
||||
"project_id": project["id"],
|
||||
"item_id": uploaded["item_id"],
|
||||
"display_name": file_name,
|
||||
},
|
||||
)
|
||||
return {"path": str(cached_path)}
|
||||
|
||||
# ---- push_ifcfed --------------------------------------------------------
|
||||
|
||||
def push_ifcfed(self, params: Any) -> dict[str, Any]:
|
||||
_, aps = self._require_aps()
|
||||
params_obj = _require_object(params, "params")
|
||||
local_path = Path(_require_string(params_obj, "path"))
|
||||
if not local_path.exists():
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
|
||||
if not local_path.name.lower().endswith(".ifcfed"):
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, "push_ifcfed expects an .ifcfed file.")
|
||||
|
||||
manifest = params_obj.get("manifest")
|
||||
if not isinstance(manifest, dict):
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, "'manifest' must be a JSON object.")
|
||||
if manifest.get("connector") != CONNECTOR_ID:
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"Manifest connector is not '{CONNECTOR_ID}'.")
|
||||
hub_id = _require_string(manifest, "hub_id")
|
||||
project_id = _require_string(manifest, "project_id")
|
||||
item_id = _require_string(manifest, "item_id")
|
||||
|
||||
item = aps.get_item(project_id, item_id)
|
||||
if item["hidden"]:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has been deleted.")
|
||||
folder_id = item.get("parent_folder_id")
|
||||
if not isinstance(folder_id, str) or not folder_id:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Cannot resolve parent folder for item '{item_id}'.")
|
||||
file_name = manifest.get("display_name") or item.get("display_name") or local_path.name
|
||||
|
||||
with progress_dialog("Uploading project") as report:
|
||||
uploaded = aps.upload_file_to_folder(
|
||||
project_id,
|
||||
folder_id,
|
||||
local_path,
|
||||
display_name=file_name,
|
||||
progress=_upload_callback(report),
|
||||
)
|
||||
|
||||
directory = cache.prepare_sole_child_dir(cache.ifcfed_dir(project_id, uploaded["item_id"]))
|
||||
cached_path = directory / file_name
|
||||
cached_path.write_bytes(local_path.read_bytes())
|
||||
cache.write_manifest(
|
||||
cached_path,
|
||||
{
|
||||
"connector": CONNECTOR_ID,
|
||||
"hub_id": hub_id,
|
||||
"project_id": project_id,
|
||||
"item_id": uploaded["item_id"],
|
||||
"display_name": file_name,
|
||||
},
|
||||
)
|
||||
return {"path": str(cached_path)}
|
||||
|
||||
# ---- push_model_interactive ---------------------------------------------
|
||||
|
||||
def push_model_interactive(self, params: Any) -> dict[str, Any]:
|
||||
auth, aps = self._require_aps()
|
||||
params_obj = _require_object(params, "params")
|
||||
local_path = Path(_require_string(params_obj, "path"))
|
||||
if not local_path.exists():
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
|
||||
|
||||
chosen = BrowseDialog(auth=auth, aps=aps, mode="destination").run()
|
||||
hub = chosen["hub"]
|
||||
project = chosen["project"]
|
||||
folder = chosen["entry"]
|
||||
|
||||
file_name = prompt_for_filename(
|
||||
title="Save Model",
|
||||
label="Save model as:",
|
||||
default=local_path.name,
|
||||
)
|
||||
if not file_name:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "User cancelled save to cloud.")
|
||||
|
||||
with progress_dialog("Uploading model") as report:
|
||||
uploaded = aps.upload_file_to_folder(
|
||||
project["id"],
|
||||
folder["id"],
|
||||
local_path,
|
||||
display_name=file_name,
|
||||
progress=_upload_callback(report),
|
||||
)
|
||||
|
||||
directory = cache.model_dir(project["id"], uploaded["item_id"], uploaded["version_id"])
|
||||
cache.prepare_sole_child_dir(directory)
|
||||
cached_path = directory / file_name
|
||||
cached_path.write_bytes(local_path.read_bytes())
|
||||
|
||||
return {
|
||||
"display_name": file_name,
|
||||
"path": str(cached_path),
|
||||
"source": {
|
||||
"connector": CONNECTOR_ID,
|
||||
"hub_id": hub["id"],
|
||||
"project_id": project["id"],
|
||||
"item_id": uploaded["item_id"],
|
||||
},
|
||||
"metadata": _build_metadata(uploaded),
|
||||
}
|
||||
|
||||
# ---- push_model ---------------------------------------------------------
|
||||
|
||||
def push_model(self, params: Any) -> dict[str, Any]:
|
||||
_, aps = self._require_aps()
|
||||
params_obj = _require_object(params, "params")
|
||||
local_path = Path(_require_string(params_obj, "path"))
|
||||
if not local_path.exists():
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"Local file '{local_path}' does not exist.")
|
||||
|
||||
source = params_obj.get("source")
|
||||
if not isinstance(source, dict):
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, "'source' must be a JSON object.")
|
||||
if source.get("connector") != CONNECTOR_ID:
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"Source connector is not '{CONNECTOR_ID}'.")
|
||||
hub_id = _require_string(source, "hub_id")
|
||||
project_id = _require_string(source, "project_id")
|
||||
item_id = _require_string(source, "item_id")
|
||||
|
||||
item = aps.get_item(project_id, item_id)
|
||||
if item["hidden"]:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Autodesk item '{item_id}' has been deleted.")
|
||||
folder_id = item.get("parent_folder_id")
|
||||
if not isinstance(folder_id, str) or not folder_id:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, f"Cannot resolve parent folder for item '{item_id}'.")
|
||||
file_name = item.get("display_name") or local_path.name
|
||||
|
||||
with progress_dialog("Uploading model") as report:
|
||||
uploaded = aps.upload_file_to_folder(
|
||||
project_id,
|
||||
folder_id,
|
||||
local_path,
|
||||
display_name=file_name,
|
||||
progress=_upload_callback(report),
|
||||
)
|
||||
|
||||
directory = cache.model_dir(project_id, uploaded["item_id"], uploaded["version_id"])
|
||||
cache.prepare_sole_child_dir(directory)
|
||||
cached_path = directory / file_name
|
||||
cached_path.write_bytes(local_path.read_bytes())
|
||||
|
||||
return {
|
||||
"source": {
|
||||
"connector": CONNECTOR_ID,
|
||||
"hub_id": hub_id,
|
||||
"project_id": project_id,
|
||||
"item_id": uploaded["item_id"],
|
||||
},
|
||||
"metadata": _build_metadata(uploaded),
|
||||
}
|
||||
|
||||
|
||||
def _require_object(params: Any, name: str) -> dict[str, Any]:
|
||||
if not isinstance(params, dict):
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"'{name}' must be a JSON object.")
|
||||
return params
|
||||
|
||||
|
||||
def _require_array(params: Any, name: str) -> list[Any]:
|
||||
if not isinstance(params, list):
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"'{name}' must be a JSON array.")
|
||||
return params
|
||||
|
||||
|
||||
def _require_string(obj: dict[str, Any], key: str) -> str:
|
||||
value = obj.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise RpcError(JSONRPC_INVALID_PARAMS, f"Missing required string field '{key}'.")
|
||||
return value
|
||||
|
||||
|
||||
def _build_metadata(version_info: dict[str, Any]) -> dict[str, Any]:
|
||||
metadata: dict[str, Any] = {}
|
||||
version_number = version_info.get("version_number")
|
||||
if version_number is not None:
|
||||
metadata["revision"] = f"v{version_number}"
|
||||
last_modified = version_info.get("last_modified_time_utc")
|
||||
if isinstance(last_modified, str) and last_modified:
|
||||
metadata["date"] = last_modified
|
||||
author = version_info.get("last_modified_user_name")
|
||||
if isinstance(author, str) and author:
|
||||
metadata["author"] = author
|
||||
return metadata
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
from typing import Any, Callable, TextIO
|
||||
|
||||
|
||||
JSONRPC_PARSE_ERROR = -32700
|
||||
JSONRPC_INVALID_REQUEST = -32600
|
||||
JSONRPC_METHOD_NOT_FOUND = -32601
|
||||
JSONRPC_INVALID_PARAMS = -32602
|
||||
JSONRPC_INTERNAL_ERROR = -32603
|
||||
|
||||
|
||||
class RpcError(Exception):
|
||||
def __init__(self, code: int, message: str, data: Any | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.data = data
|
||||
|
||||
|
||||
Handler = Callable[[Any], Any]
|
||||
|
||||
|
||||
class JsonRpcHost:
|
||||
def __init__(
|
||||
self,
|
||||
handlers: dict[str, Handler],
|
||||
*,
|
||||
stdin: TextIO = sys.stdin,
|
||||
stdout: TextIO = sys.stdout,
|
||||
stderr: TextIO = sys.stderr,
|
||||
) -> None:
|
||||
self.handlers = handlers
|
||||
self.stdin = stdin
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
def run(self) -> int:
|
||||
for line in self.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
self._handle_line(line)
|
||||
return 0
|
||||
|
||||
def _handle_line(self, line: str) -> None:
|
||||
message_id: Any = None
|
||||
try:
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
self._respond_error(None, JSONRPC_PARSE_ERROR, f"Parse error: {exc}")
|
||||
return
|
||||
|
||||
if not isinstance(message, dict):
|
||||
self._respond_error(None, JSONRPC_INVALID_REQUEST, "Request must be a JSON object")
|
||||
return
|
||||
if message.get("jsonrpc") != "2.0":
|
||||
self._respond_error(message.get("id"), JSONRPC_INVALID_REQUEST, "Missing or wrong 'jsonrpc' version")
|
||||
return
|
||||
|
||||
message_id = message.get("id")
|
||||
method = message.get("method")
|
||||
if not isinstance(method, str):
|
||||
self._respond_error(message_id, JSONRPC_INVALID_REQUEST, "Missing 'method' string")
|
||||
return
|
||||
|
||||
params = message.get("params", None)
|
||||
if params is not None and not isinstance(params, (dict, list)):
|
||||
self._respond_error(message_id, JSONRPC_INVALID_PARAMS, "'params' must be a JSON object or array")
|
||||
return
|
||||
|
||||
handler = self.handlers.get(method)
|
||||
if handler is None:
|
||||
self._respond_error(message_id, JSONRPC_METHOD_NOT_FOUND, f"Unknown method '{method}'")
|
||||
return
|
||||
|
||||
try:
|
||||
result = handler(params)
|
||||
except RpcError as exc:
|
||||
self._respond_error(message_id, exc.code, exc.message, exc.data)
|
||||
return
|
||||
except Exception as exc:
|
||||
print(f"Handler '{method}' raised: {exc}", file=self.stderr)
|
||||
traceback.print_exc(file=self.stderr)
|
||||
self._respond_error(message_id, JSONRPC_INTERNAL_ERROR, str(exc))
|
||||
return
|
||||
|
||||
if message_id is not None:
|
||||
self._respond_result(message_id, result)
|
||||
except Exception as exc:
|
||||
print(f"Unhandled host error: {exc}", file=self.stderr)
|
||||
traceback.print_exc(file=self.stderr)
|
||||
self._respond_error(message_id, JSONRPC_INTERNAL_ERROR, str(exc))
|
||||
|
||||
def _respond_result(self, message_id: Any, result: Any) -> None:
|
||||
self._write({"jsonrpc": "2.0", "id": message_id, "result": result})
|
||||
|
||||
def _respond_error(self, message_id: Any, code: int, message: str, data: Any | None = None) -> None:
|
||||
error: dict[str, Any] = {"code": code, "message": message}
|
||||
if data is not None:
|
||||
error["data"] = data
|
||||
self._write({"jsonrpc": "2.0", "id": message_id, "error": error})
|
||||
|
||||
def _write(self, payload: dict[str, Any]) -> None:
|
||||
line = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||
self.stdout.write(line + "\n")
|
||||
self.stdout.flush()
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def config_root() -> Path:
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
base = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||||
root = Path(base) / "ifcviewer-autodesk"
|
||||
elif system == "Darwin":
|
||||
root = Path.home() / "Library" / "Application Support" / "ifcviewer-autodesk"
|
||||
else:
|
||||
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
||||
root = Path(base) / "ifcviewer-autodesk"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _settings_path() -> Path:
|
||||
return config_root() / "settings.json"
|
||||
|
||||
|
||||
def _read() -> dict[str, Any]:
|
||||
path = _settings_path()
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _write(data: dict[str, Any]) -> None:
|
||||
_settings_path().write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def stored_client_id() -> str:
|
||||
"""Whatever is persisted in settings.json — ignores the env var."""
|
||||
return str(_read().get("client_id", "")).strip()
|
||||
|
||||
|
||||
def env_client_id() -> str:
|
||||
"""Whatever APS_CLIENT_ID currently has — ignores settings.json."""
|
||||
return os.environ.get("APS_CLIENT_ID", "").strip()
|
||||
|
||||
|
||||
def load_client_id() -> str:
|
||||
"""The effective value: env var wins, so dev overrides keep working."""
|
||||
return env_client_id() or stored_client_id()
|
||||
|
||||
|
||||
def save_client_id(client_id: str) -> None:
|
||||
data = _read()
|
||||
data["client_id"] = client_id.strip()
|
||||
_write(data)
|
||||
@@ -0,0 +1,637 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from typing import TYPE_CHECKING, Any, Callable, Literal
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from ifcviewer_autodesk import settings
|
||||
from ifcviewer_autodesk.autodesk import ApsClient, AuthSessionService, KeyringTokenStore
|
||||
from ifcviewer_autodesk.rpc import JSONRPC_INTERNAL_ERROR, RpcError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ifcviewer_autodesk.connector import AutodeskConnector
|
||||
|
||||
|
||||
MODEL_EXTENSIONS = (".ifc", ".ifcview", ".rdb", ".rdbview")
|
||||
Mode = Literal["ifcfed", "model", "destination"]
|
||||
|
||||
|
||||
# --- root + Treeview style ---------------------------------------------------
|
||||
# Tk's default ttk.Treeview looks like Windows 95 in any theme; force-style it
|
||||
# to match the surrounding CTk dark theme. Every other widget uses CTk defaults.
|
||||
|
||||
_root: ctk.CTk | None = None
|
||||
|
||||
|
||||
def ensure_tk_app() -> ctk.CTk:
|
||||
global _root
|
||||
if _root is None:
|
||||
ctk.set_appearance_mode("Dark")
|
||||
ctk.set_default_color_theme("blue")
|
||||
_root = ctk.CTk()
|
||||
_root.withdraw()
|
||||
_apply_treeview_style()
|
||||
return _root
|
||||
|
||||
|
||||
def _apply_treeview_style() -> None:
|
||||
style = ttk.Style()
|
||||
try:
|
||||
style.theme_use("clam")
|
||||
except tk.TclError:
|
||||
pass
|
||||
style.configure(
|
||||
"Treeview",
|
||||
background="#2b2b2b",
|
||||
foreground="#dce4ee",
|
||||
fieldbackground="#2b2b2b",
|
||||
borderwidth=0,
|
||||
rowheight=26,
|
||||
)
|
||||
style.map(
|
||||
"Treeview",
|
||||
background=[("selected", "#1f6aa5")],
|
||||
foreground=[("selected", "#ffffff")],
|
||||
)
|
||||
style.layout("Treeview", [("Treeview.treearea", {"sticky": "nswe"})])
|
||||
|
||||
|
||||
# --- base modal --------------------------------------------------------------
|
||||
|
||||
|
||||
class _BaseDialog(ctk.CTkToplevel):
|
||||
def __init__(self, title: str, *, size: tuple[int, int], resizable: bool = True) -> None:
|
||||
super().__init__(ensure_tk_app())
|
||||
self.title(title)
|
||||
self.geometry(f"{size[0]}x{size[1]}")
|
||||
if not resizable:
|
||||
self.resizable(False, False)
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
self.result: Any = None
|
||||
self.withdraw()
|
||||
|
||||
def _on_close(self) -> None:
|
||||
try:
|
||||
self.grab_release()
|
||||
except tk.TclError:
|
||||
pass
|
||||
self.destroy()
|
||||
|
||||
def _center_on_screen(self) -> None:
|
||||
self.update_idletasks()
|
||||
w = self.winfo_width()
|
||||
h = self.winfo_height()
|
||||
x = (self.winfo_screenwidth() - w) // 2
|
||||
y = (self.winfo_screenheight() - h) // 2
|
||||
self.geometry(f"+{x}+{y}")
|
||||
|
||||
def run(self) -> Any:
|
||||
root = ensure_tk_app()
|
||||
self._center_on_screen()
|
||||
self.deiconify()
|
||||
self.lift()
|
||||
self.focus_force()
|
||||
try:
|
||||
self.grab_set()
|
||||
except tk.TclError:
|
||||
pass
|
||||
self.wait_window()
|
||||
try:
|
||||
root.update()
|
||||
root.update_idletasks()
|
||||
except tk.TclError:
|
||||
pass
|
||||
return self.result
|
||||
|
||||
|
||||
# --- progress ----------------------------------------------------------------
|
||||
|
||||
|
||||
class ProgressDialog(_BaseDialog):
|
||||
def __init__(self, title: str = "Working", parent: tk.Misc | None = None) -> None:
|
||||
super().__init__(title, size=(440, 130), resizable=False)
|
||||
|
||||
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.bar = ctk.CTkProgressBar(body, mode="indeterminate")
|
||||
self.bar.pack(fill="x", pady=(12, 0))
|
||||
self.bar.start()
|
||||
self._determinate = False
|
||||
|
||||
self._center_on_screen()
|
||||
self.deiconify()
|
||||
self.lift()
|
||||
self.update()
|
||||
|
||||
def report(self, _phase: str, message: str, percent: int | None = None) -> None:
|
||||
try:
|
||||
self.message.configure(text=message)
|
||||
if percent is None:
|
||||
if self._determinate:
|
||||
self.bar.configure(mode="indeterminate")
|
||||
self.bar.start()
|
||||
self._determinate = False
|
||||
else:
|
||||
if not self._determinate:
|
||||
self.bar.stop()
|
||||
self.bar.configure(mode="determinate")
|
||||
self._determinate = True
|
||||
self.bar.set(max(0.0, min(1.0, percent / 100.0)))
|
||||
self.update()
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
|
||||
class _ProgressContext:
|
||||
def __init__(self, parent: tk.Misc | None, message: str) -> None:
|
||||
self.parent = parent
|
||||
self.message = message
|
||||
self.dialog: ProgressDialog | None = None
|
||||
|
||||
def __enter__(self) -> Callable[[str, str, int | None], None]:
|
||||
self.dialog = ProgressDialog(self.message, self.parent)
|
||||
return self.dialog.report
|
||||
|
||||
def __exit__(self, *_exc: object) -> None:
|
||||
if self.dialog is not None:
|
||||
try:
|
||||
self.dialog.destroy()
|
||||
except tk.TclError:
|
||||
pass
|
||||
self.dialog = None
|
||||
|
||||
|
||||
def progress_dialog(message: str) -> _ProgressContext:
|
||||
"""Standalone progress dialog usable outside the browse picker."""
|
||||
return _ProgressContext(None, message)
|
||||
|
||||
|
||||
# --- browse ------------------------------------------------------------------
|
||||
|
||||
|
||||
class BrowseDialog(_BaseDialog):
|
||||
"""Hub → project → folder tree → file/folder picker."""
|
||||
|
||||
def __init__(self, *, auth: AuthSessionService, aps: ApsClient, mode: Mode) -> None:
|
||||
titles = {
|
||||
"ifcfed": ("Open Project From Autodesk", "Open"),
|
||||
"model": ("Add Model From Autodesk", "Add"),
|
||||
"destination": ("Choose Autodesk Destination", "Select"),
|
||||
}
|
||||
title, action_label = titles[mode]
|
||||
super().__init__(title, size=(920, 620))
|
||||
|
||||
self.auth = auth
|
||||
self.aps = aps
|
||||
self.mode: Mode = mode
|
||||
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._tree_entries: dict[str, dict[str, Any]] = {}
|
||||
self._project_entries: dict[str, dict[str, Any]] = {}
|
||||
|
||||
self._build_ui(action_label)
|
||||
|
||||
def _build_ui(self, action_label: str) -> None:
|
||||
root = ctk.CTkFrame(self, fg_color="transparent")
|
||||
root.pack(fill="both", expand=True, padx=16, pady=16)
|
||||
root.grid_rowconfigure(1, weight=1)
|
||||
root.grid_columnconfigure(0, weight=1)
|
||||
|
||||
top = ctk.CTkFrame(root, fg_color="transparent")
|
||||
top.grid(row=0, column=0, sticky="ew", pady=(0, 12))
|
||||
top.grid_columnconfigure(1, weight=1)
|
||||
|
||||
self.sign_in_button = ctk.CTkButton(top, text="Sign In", command=self._sign_in)
|
||||
self.sign_in_button.grid(row=0, column=0, padx=(0, 8), sticky="w")
|
||||
|
||||
self.hub_combo = ctk.CTkOptionMenu(
|
||||
top,
|
||||
values=["Select hub"],
|
||||
command=self._hub_changed,
|
||||
anchor="w",
|
||||
)
|
||||
self.hub_combo.grid(row=0, column=1, sticky="ew")
|
||||
self.hub_combo.configure(state="disabled")
|
||||
|
||||
split = ctk.CTkFrame(root, fg_color="transparent")
|
||||
split.grid(row=1, column=0, sticky="nsew")
|
||||
split.grid_rowconfigure(0, weight=1)
|
||||
split.grid_columnconfigure(0, weight=3, uniform="col")
|
||||
split.grid_columnconfigure(1, weight=7, uniform="col")
|
||||
|
||||
self.projects_frame = ctk.CTkFrame(split)
|
||||
self.projects_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
|
||||
self.tree_frame = ctk.CTkFrame(split)
|
||||
self.tree_frame.grid(row=0, column=1, sticky="nsew")
|
||||
|
||||
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")
|
||||
self.tree.bind("<<TreeviewSelect>>", lambda _e: self._tree_selection_changed())
|
||||
self.tree.bind("<<TreeviewOpen>>", self._on_tree_open)
|
||||
|
||||
self.status = ctk.CTkLabel(root, text="Sign in to browse Autodesk projects.", anchor="w")
|
||||
self.status.grid(row=2, column=0, sticky="ew", pady=(12, 12))
|
||||
|
||||
actions = ctk.CTkFrame(root, fg_color="transparent")
|
||||
actions.grid(row=3, column=0, sticky="ew")
|
||||
actions.grid_columnconfigure(0, weight=1)
|
||||
self.cancel_button = ctk.CTkButton(actions, text="Cancel", command=self._on_close, fg_color="transparent", border_width=1)
|
||||
self.cancel_button.grid(row=0, column=1, padx=(0, 8))
|
||||
self.action_button = ctk.CTkButton(actions, text=action_label, command=self._confirm)
|
||||
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:
|
||||
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.grid(row=0, column=0, sticky="nsew")
|
||||
scrollbar = ctk.CTkScrollbar(body, orientation="vertical", command=tree.yview)
|
||||
scrollbar.grid(row=0, column=1, sticky="ns")
|
||||
tree.configure(yscrollcommand=scrollbar.set)
|
||||
return tree
|
||||
|
||||
# --- sign-in & population ------------------------------------------------
|
||||
|
||||
def run(self) -> dict[str, Any]:
|
||||
if self.auth.get_token() is not None:
|
||||
self._populate_hubs_silently()
|
||||
outcome = super().run()
|
||||
if outcome is None:
|
||||
raise RpcError(JSONRPC_INTERNAL_ERROR, "User cancelled the Autodesk picker.")
|
||||
return outcome
|
||||
|
||||
def _populate_hubs_silently(self) -> None:
|
||||
try:
|
||||
hubs = self.aps.list_hubs()
|
||||
self._fill_hubs(hubs)
|
||||
self.status.configure(text="Signed in. Select a hub.")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _sign_in(self) -> None:
|
||||
with self._with_progress("Signing in to Autodesk") as report:
|
||||
try:
|
||||
self.auth.login_interactive(report)
|
||||
hubs = self.aps.list_hubs()
|
||||
self._fill_hubs(hubs)
|
||||
self.status.configure(text="Signed in. Select a hub.")
|
||||
except Exception as exc:
|
||||
show_error(title="Sign In Failed", message=str(exc))
|
||||
|
||||
def _fill_hubs(self, hubs: list[dict[str, Any]]) -> None:
|
||||
self._hubs_by_name = {hub["name"]: hub for hub in hubs}
|
||||
values = ["Select hub"] + list(self._hubs_by_name.keys())
|
||||
self.hub_combo.configure(values=values, state="normal")
|
||||
self.hub_combo.set("Select hub")
|
||||
|
||||
def _hub_changed(self, label: str) -> None:
|
||||
if label == "Select hub":
|
||||
return
|
||||
hub = getattr(self, "_hubs_by_name", {}).get(label)
|
||||
if not isinstance(hub, dict):
|
||||
return
|
||||
self.selected_hub = hub
|
||||
self.selected_project = None
|
||||
self.selected_entry = None
|
||||
self._clear_projects()
|
||||
self._clear_tree()
|
||||
self._refresh_action_button()
|
||||
with self._with_progress("Loading Autodesk projects"):
|
||||
try:
|
||||
projects = self.aps.list_projects(hub["id"])
|
||||
self._project_entries = {}
|
||||
for project in projects:
|
||||
iid = self.projects.insert("", "end", text=project["name"])
|
||||
self._project_entries[iid] = project
|
||||
self.status.configure(text=f"Hub: {hub['name']}. Select a project.")
|
||||
except Exception as exc:
|
||||
show_error(title="Load Projects Failed", message=str(exc))
|
||||
|
||||
def _project_changed(self) -> None:
|
||||
selection = self.projects.selection()
|
||||
if not selection or self.selected_hub is None:
|
||||
return
|
||||
project = self._project_entries.get(selection[0])
|
||||
if not isinstance(project, dict):
|
||||
return
|
||||
self.selected_project = project
|
||||
self.selected_entry = None
|
||||
self._refresh_action_button()
|
||||
self._clear_tree()
|
||||
with self._with_progress("Loading top folders"):
|
||||
try:
|
||||
top_folders = self.aps.list_top_folders(self.selected_hub["id"], project["id"])
|
||||
for entry in top_folders:
|
||||
self._insert_tree_entry("", entry)
|
||||
if self.mode == "destination":
|
||||
self.status.configure(text=f"Project: {project['name']}. Browse folders and choose a destination.")
|
||||
else:
|
||||
self.status.configure(text=f"Project: {project['name']}. Browse folders and pick a file.")
|
||||
except Exception as exc:
|
||||
show_error(title="Load Project Failed", message=str(exc))
|
||||
|
||||
def _insert_tree_entry(self, parent_iid: str, entry: dict[str, Any]) -> str:
|
||||
label = entry.get("display_name") or entry.get("name") or entry.get("id", "?")
|
||||
iid = self.tree.insert(parent_iid, "end", text=label)
|
||||
self._tree_entries[iid] = entry
|
||||
if entry.get("type") == "folders":
|
||||
placeholder = self.tree.insert(iid, "end", text="Loading…")
|
||||
self._tree_entries[placeholder] = {"__placeholder__": True}
|
||||
return iid
|
||||
|
||||
def _on_tree_open(self, _event: tk.Event) -> None:
|
||||
selection = self.tree.focus()
|
||||
if not selection:
|
||||
return
|
||||
entry = self._tree_entries.get(selection)
|
||||
if not isinstance(entry, dict) or entry.get("type") != "folders" or self.selected_project is None:
|
||||
return
|
||||
children = self.tree.get_children(selection)
|
||||
if len(children) != 1:
|
||||
return
|
||||
only = self._tree_entries.get(children[0])
|
||||
if not (isinstance(only, dict) and only.get("__placeholder__")):
|
||||
return
|
||||
|
||||
self.tree.delete(children[0])
|
||||
self._tree_entries.pop(children[0], None)
|
||||
|
||||
with self._with_progress("Loading folder contents"):
|
||||
try:
|
||||
object_types = ["folders"] if self.mode == "destination" else ["folders", "items"]
|
||||
contents = self.aps.list_folder_contents(
|
||||
self.selected_project["id"],
|
||||
entry["id"],
|
||||
object_types=object_types,
|
||||
extension_filter=self._extension_filter(),
|
||||
)
|
||||
for child in contents:
|
||||
self._insert_tree_entry(selection, child)
|
||||
except Exception as exc:
|
||||
show_error(title="Load Folder Failed", message=str(exc))
|
||||
|
||||
def _extension_filter(self) -> Callable[[dict[str, Any]], bool] | None:
|
||||
if self.mode == "ifcfed":
|
||||
return lambda entry: (entry.get("display_name") or "").lower().endswith(".ifcfed")
|
||||
if self.mode == "model":
|
||||
return lambda entry: (entry.get("display_name") or "").lower().endswith(MODEL_EXTENSIONS)
|
||||
return None
|
||||
|
||||
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
|
||||
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
|
||||
self._refresh_action_button()
|
||||
|
||||
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)
|
||||
)
|
||||
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:
|
||||
return
|
||||
self.result = {
|
||||
"hub": self.selected_hub,
|
||||
"project": self.selected_project,
|
||||
"entry": self.selected_entry,
|
||||
}
|
||||
self._on_close()
|
||||
|
||||
def _clear_projects(self) -> None:
|
||||
for iid in self.projects.get_children():
|
||||
self.projects.delete(iid)
|
||||
self._project_entries.clear()
|
||||
|
||||
def _clear_tree(self) -> None:
|
||||
for iid in self.tree.get_children():
|
||||
self.tree.delete(iid)
|
||||
self._tree_entries.clear()
|
||||
|
||||
def _with_progress(self, message: str) -> _ProgressContext:
|
||||
return _ProgressContext(self, message)
|
||||
|
||||
|
||||
# --- filename prompt ---------------------------------------------------------
|
||||
|
||||
|
||||
class _FilenamePrompt(_BaseDialog):
|
||||
def __init__(self, *, title: str, label: str, default: str) -> None:
|
||||
super().__init__(title, size=(440, 170), resizable=False)
|
||||
body = ctk.CTkFrame(self, fg_color="transparent")
|
||||
body.pack(fill="both", expand=True, padx=20, pady=20)
|
||||
|
||||
ctk.CTkLabel(body, text=label, anchor="w").pack(fill="x")
|
||||
self.entry = ctk.CTkEntry(body)
|
||||
self.entry.pack(fill="x", pady=(8, 16))
|
||||
self.entry.insert(0, default)
|
||||
self.entry.select_range(0, "end")
|
||||
self.entry.focus_set()
|
||||
|
||||
buttons = ctk.CTkFrame(body, fg_color="transparent")
|
||||
buttons.pack(fill="x")
|
||||
buttons.grid_columnconfigure(0, weight=1)
|
||||
ctk.CTkButton(buttons, text="Cancel", command=self._on_close, fg_color="transparent", border_width=1).grid(row=0, column=1, padx=(0, 8))
|
||||
ctk.CTkButton(buttons, text="OK", command=self._confirm).grid(row=0, column=2)
|
||||
|
||||
self.bind("<Return>", lambda _e: self._confirm())
|
||||
self.bind("<Escape>", lambda _e: self._on_close())
|
||||
|
||||
def _confirm(self) -> None:
|
||||
value = self.entry.get().strip()
|
||||
self.result = value or None
|
||||
self._on_close()
|
||||
|
||||
|
||||
def prompt_for_filename(*, title: str, label: str, default: str) -> str | None:
|
||||
return _FilenamePrompt(title=title, label=label, default=default).run()
|
||||
|
||||
|
||||
# --- message dialogs (CTk-styled replacements for tkinter.messagebox) --------
|
||||
|
||||
|
||||
class _ConfirmDialog(_BaseDialog):
|
||||
def __init__(self, *, title: str, message: str) -> None:
|
||||
super().__init__(title, size=(440, 180), resizable=False)
|
||||
body = ctk.CTkFrame(self, fg_color="transparent")
|
||||
body.pack(fill="both", expand=True, padx=20, pady=20)
|
||||
|
||||
ctk.CTkLabel(body, text=message, anchor="w", wraplength=380, justify="left").pack(fill="x", pady=(0, 20))
|
||||
|
||||
buttons = ctk.CTkFrame(body, fg_color="transparent")
|
||||
buttons.pack(fill="x")
|
||||
buttons.grid_columnconfigure(0, weight=1)
|
||||
ctk.CTkButton(buttons, text="No", command=self._on_close, fg_color="transparent", border_width=1).grid(row=0, column=1, padx=(0, 8))
|
||||
ctk.CTkButton(buttons, text="Yes", command=self._confirm).grid(row=0, column=2)
|
||||
|
||||
self.bind("<Return>", lambda _e: self._confirm())
|
||||
self.bind("<Escape>", lambda _e: self._on_close())
|
||||
|
||||
def _confirm(self) -> None:
|
||||
self.result = True
|
||||
self._on_close()
|
||||
|
||||
|
||||
class _AlertDialog(_BaseDialog):
|
||||
def __init__(self, *, title: str, message: str) -> None:
|
||||
super().__init__(title, size=(440, 180), resizable=False)
|
||||
body = ctk.CTkFrame(self, fg_color="transparent")
|
||||
body.pack(fill="both", expand=True, padx=20, pady=20)
|
||||
|
||||
ctk.CTkLabel(body, text=message, anchor="w", wraplength=380, justify="left").pack(fill="x", pady=(0, 20))
|
||||
|
||||
buttons = ctk.CTkFrame(body, fg_color="transparent")
|
||||
buttons.pack(fill="x")
|
||||
buttons.grid_columnconfigure(0, weight=1)
|
||||
ctk.CTkButton(buttons, text="OK", command=self._on_close).grid(row=0, column=1)
|
||||
|
||||
self.bind("<Return>", lambda _e: self._on_close())
|
||||
self.bind("<Escape>", lambda _e: self._on_close())
|
||||
|
||||
|
||||
def confirm(*, title: str, message: str) -> bool:
|
||||
return bool(_ConfirmDialog(title=title, message=message).run())
|
||||
|
||||
|
||||
def show_error(*, title: str, message: str) -> None:
|
||||
_AlertDialog(title=title, message=message).run()
|
||||
|
||||
|
||||
# --- settings ----------------------------------------------------------------
|
||||
|
||||
|
||||
class SettingsDialog(_BaseDialog):
|
||||
"""Edit the APS client id and sign out."""
|
||||
|
||||
def __init__(self, *, connector: "AutodeskConnector") -> None:
|
||||
super().__init__("Autodesk Connector Settings", size=(520, 400), resizable=False)
|
||||
self.connector = connector
|
||||
|
||||
env_override = settings.env_client_id()
|
||||
stored = settings.stored_client_id()
|
||||
effective = env_override or stored
|
||||
|
||||
body = ctk.CTkFrame(self, fg_color="transparent")
|
||||
body.pack(fill="both", expand=True, padx=24, pady=24)
|
||||
|
||||
ctk.CTkLabel(
|
||||
body,
|
||||
text="Autodesk Platform Services",
|
||||
anchor="w",
|
||||
font=ctk.CTkFont(size=14, weight="bold"),
|
||||
).pack(fill="x")
|
||||
ctk.CTkLabel(
|
||||
body,
|
||||
text="The connector signs in to Autodesk using a PKCE flow. The client id below comes from your APS application.",
|
||||
anchor="w",
|
||||
wraplength=460,
|
||||
justify="left",
|
||||
).pack(fill="x", pady=(2, 16))
|
||||
|
||||
ctk.CTkLabel(body, text="APS client id", anchor="w").pack(fill="x")
|
||||
self.client_id_entry = ctk.CTkEntry(body, placeholder_text="Paste your APS client id")
|
||||
self.client_id_entry.pack(fill="x", pady=(6, 8))
|
||||
self.client_id_entry.insert(0, stored)
|
||||
|
||||
if env_override:
|
||||
ctk.CTkLabel(
|
||||
body,
|
||||
text=f"APS_CLIENT_ID environment variable is set ({env_override}) and overrides the saved value.",
|
||||
anchor="w",
|
||||
wraplength=460,
|
||||
justify="left",
|
||||
text_color=("#b45309", "#f59e0b"),
|
||||
).pack(fill="x", pady=(0, 8))
|
||||
|
||||
self.status_label = ctk.CTkLabel(
|
||||
body,
|
||||
text=f"Signed in as {effective}" if effective else "No client id configured.",
|
||||
anchor="w",
|
||||
wraplength=460,
|
||||
justify="left",
|
||||
)
|
||||
self.status_label.pack(fill="x", pady=(0, 16))
|
||||
|
||||
buttons = ctk.CTkFrame(body, fg_color="transparent")
|
||||
buttons.pack(fill="x")
|
||||
buttons.grid_columnconfigure(1, weight=1)
|
||||
|
||||
self.signout_button = ctk.CTkButton(
|
||||
buttons,
|
||||
text="Sign Out",
|
||||
command=self._sign_out,
|
||||
fg_color="transparent",
|
||||
border_width=1,
|
||||
)
|
||||
self.signout_button.grid(row=0, column=0, sticky="w")
|
||||
if not effective:
|
||||
self.signout_button.configure(state="disabled")
|
||||
|
||||
ctk.CTkButton(
|
||||
buttons,
|
||||
text="Close",
|
||||
command=self._on_close,
|
||||
fg_color="transparent",
|
||||
border_width=1,
|
||||
).grid(row=0, column=2, padx=(0, 8))
|
||||
ctk.CTkButton(buttons, text="Save", command=self._save).grid(row=0, column=3)
|
||||
|
||||
def _save(self) -> None:
|
||||
new_id = self.client_id_entry.get().strip()
|
||||
settings.save_client_id(new_id)
|
||||
try:
|
||||
self.connector.reload_credentials()
|
||||
except Exception as exc:
|
||||
show_error(title="Reload Failed", message=str(exc))
|
||||
return
|
||||
self._on_close()
|
||||
|
||||
def _sign_out(self) -> None:
|
||||
client_id = settings.env_client_id() or settings.stored_client_id()
|
||||
if not client_id:
|
||||
return
|
||||
if not confirm(
|
||||
title="Sign Out",
|
||||
message=f"Forget the stored Autodesk session for {client_id}?",
|
||||
):
|
||||
return
|
||||
try:
|
||||
KeyringTokenStore(service_name="ifcviewer-autodesk", username=client_id).delete()
|
||||
except RpcError as exc:
|
||||
show_error(title="Sign Out Failed", message=exc.message)
|
||||
return
|
||||
self.status_label.configure(text="Signed out. Next operation will prompt for sign-in.")
|
||||
self.signout_button.configure(state="disabled")
|
||||
Reference in New Issue
Block a user