Compare commits

...

15 Commits

Author SHA1 Message Date
Dogukan Karatas 0befca0200 Merge pull request #220 from specklesystems/dogukan/delimeter-fix
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
fix (data): federate helper function delimeter fix
2025-12-12 15:59:29 +01:00
Dogukan Karatas 1a74336e27 fixed delimeter 2025-12-12 15:57:05 +01:00
Dogukan Karatas 9f9b31d9ba Merge pull request #219 from specklesystems/dogukan/optional-version-object-id
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
feat (data): keep version object id for backwards compatability
2025-11-14 14:15:58 +01:00
Dogukan Karatas df3ad118e1 backwards compatible 2025-11-14 13:29:25 +01:00
Dogukan Karatas ec634be352 feat: auth flow without desktop service (#218)
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
* first pass

* column renaming

* mark received in visual

* some security measures

* renamed
2025-11-11 13:38:43 +03:00
Dogukan Karatas 0a4ae9340a Merge pull request #217 from specklesystems/dogukan/bump-ol2-version
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
fix: objectloader2 version update
2025-11-05 16:37:17 +01:00
Dogukan Karatas 92bcf4b5c0 bump ol2 version 2025-11-05 16:06:13 +01:00
Dogukan Karatas 2a22bbf0af Merge pull request #216 from specklesystems/dogukan/arrange-buttons
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
fix (visual): button rearrangements
2025-11-03 13:45:28 +01:00
Dogukan Karatas 7b5e5397b6 minor changes 2025-11-03 13:35:15 +01:00
Dogukan Karatas 24eeb44ff7 Merge pull request #215 from specklesystems/dogukan/direct-server-download
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
feat (data): direct server download
2025-10-30 20:41:55 +01:00
Dogukan Karatas b1f16c4005 modifies the download procedure 2025-10-30 17:14:29 +01:00
Jedd Morgan 2307d87735 fix version again (#212) 2025-10-20 17:12:09 +01:00
Jedd Morgan b80624396d Update deploy.yml (#211) 2025-10-20 16:55:42 +01:00
Oğuzhan Koral 098ef3d112 Bump viewer for proxy fix (#210)
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
2025-10-20 10:44:54 +03:00
Oğuzhan Koral 94fdc7a2c3 bump viewer (#209)
Build and deploy Connector and Visual / build-connector (push) Has been cancelled
Build and deploy Connector and Visual / build-visual (push) Has been cancelled
Build and deploy Connector and Visual / deploy-installers (push) Has been cancelled
2025-10-16 17:33:36 +03:00
16 changed files with 718 additions and 322 deletions
+2 -2
View File
@@ -26,7 +26,7 @@ jobs:
run: |
TAG=${{ github.ref_name }}
if [[ "${{ github.ref }}" != refs/tags/* ]]; then
TAG="v3.0.99.${{ github.run_number }}"
TAG="v3.0.99"
fi
SEMVER="${TAG#v}"
FILE_VERSION=$(echo "$TAG" | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
@@ -81,7 +81,7 @@ jobs:
run: |
TAG=${{ github.ref_name }}
if [[ "${{ github.ref }}" != refs/tags/* ]]; then
TAG="v3.0.99.${{ github.run_number }}"
TAG="v3.0.99"
fi
SEMVER="${TAG#v}"
FILE_VERSION=$(echo "$TAG" | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
+161 -31
View File
@@ -9,6 +9,11 @@
GetModel = Extension.LoadFunction("GetModel.pqm"),
Parser = Extension.LoadFunction("Parser.pqm"),
CheckPermissions = Extension.LoadFunction("CheckPermissions.pqm"),
ExchangeToken = Extension.LoadFunction("ExchangeToken.pqm"),
EncodeUserInfo = Extension.LoadFunction("EncodeUserInfo.pqm"),
GetUser = Extension.LoadFunction("GetUser.pqm"),
GetVersion = Extension.LoadFunction("GetVersion.pqm"),
GetWorkspace = Extension.LoadFunction("GetWorkspace.pqm"),
// the logic for importing functions from other files
Extension.LoadFunction = (fileName as text) =>
@@ -29,24 +34,54 @@
// parse the URL to determine if it's a federated model
parsedUrl = Parser(url),
// check if user has permission to load the model
permissionCheck = CheckPermissions(url),
// assert that permission check returned a valid result
permissionAssert = if not Record.HasFields(permissionCheck, {"authorized", "code", "message"}) then
error "Invalid permission check result"
else
null,
// if not authorized, throw an error with the message from the server
authCheck = if not permissionCheck[authorized] then
error Text.Format(
"Permission denied: #{0} (Error code: #{1})",
"Permission denied: #{0} (Error code: #{1})",
{permissionCheck[message], permissionCheck[code]}
)
else
null,
// get user info, connector version, and workspace info for encoding
userInfo = GetUser(url),
powerfulToken = userInfo[Token],
userEmail = userInfo[UserEmail],
connectorVersion = GetVersion(),
workspaceInfo = GetWorkspace(url),
// exchange powerful token for weak token with limited scopes
tokenExchangeResult = ExchangeToken(
powerfulToken,
{"profile:read", "streams:read", "users:read"},
parsedUrl[projectId],
parsedUrl[baseUrl]
),
// throw error if token exchange failed - do NOT use powerful token as fallback
tokenToUse = if tokenExchangeResult[Success] then
tokenExchangeResult[Token]
else
error [
Reason = "TokenExchangeFailed",
Message.Format = "Failed to exchange token for limited scope token: #{0}",
Message.Parameters = {tokenExchangeResult[ErrorMessage]},
Detail = [
ErrorMessage = tokenExchangeResult[ErrorMessage],
ProjectId = parsedUrl[projectId],
ServerUrl = parsedUrl[baseUrl]
]
],
// only proceed if user has permisson to load
results = if permissionCheck[authorized] then
@@ -56,29 +91,39 @@
modelsData = List.Transform(
parsedUrl[federatedModels],
each ProcessSingleModel(
parsedUrl[baseUrl],
parsedUrl[projectId],
[modelId],
parsedUrl[baseUrl],
parsedUrl[projectId],
[modelId],
[versionId]
)
),
// extract all data tables
allTables = List.Transform(modelsData, each [Data]),
// extract all root object IDs
allRootIds = List.Transform(modelsData, each [RootObjectId]),
// extract all encoded userInfo strings
allEncodedUserInfos = List.Transform(modelsData, each [EncodedUserInfo]),
// combine all root object IDs into a comma-separated string
combinedRootIds = Text.Combine(allRootIds, ","),
// combine all encoded userInfo strings with delimiter |||
// (delimiter chosen to avoid conflicts with base64 characters)
combinedEncodedUserInfos = Text.Combine(allEncodedUserInfos, "|||"),
// combine all data tables
combinedData = Table.Combine(allTables),
// replace the "Version Object ID" column with the combined root IDs
// replace both columns with combined values
transformedData = Table.TransformColumns(
combinedData,
{"Version Object ID", each combinedRootIds}
{
{"Version Object ID", each combinedRootIds},
{"Model Info", each combinedEncodedUserInfos}
}
),
// expand properties column if requested and if it exists
@@ -94,25 +139,57 @@
else
// use existing functionality for single models
let
// get model name
// get model info
modelInfo = GetModel(url),
modelName = modelInfo[modelName],
rootObjectId = modelInfo[rootObjectId],
sourceApplication = modelInfo[sourceApplication],
versionId = modelInfo[versionId],
// get structured data
structuredData = GetStructuredData(url),
// rename column based on send status
newColumnName = "Version Object ID",
renamedData = Table.RenameColumns(structuredData, {{"Version Object ID", newColumnName}}),
// build userInfoData record for this model
userInfoData = [
rootObjectId = rootObjectId,
server = parsedUrl[baseUrl],
email = userEmail,
projectId = parsedUrl[projectId],
token = tokenToUse,
workspaceId = workspaceInfo[workspaceId],
workspaceName = workspaceInfo[workspaceName],
workspaceLogo = workspaceInfo[workspaceLogo],
version = connectorVersion,
sourceApplication = sourceApplication,
canHideBranding = workspaceInfo[canHideBranding],
versionId = versionId,
url = url
],
// try to send to desktop service for backward compatibility (non-blocking)
// must be called BEFORE encoding to ensure it executes
desktopServiceSent = TrySendToDesktopService(userInfoData),
// encode userInfoData as base64 JSON string
encodedUserInfo = EncodeUserInfo(userInfoData),
// replace both columns with appropriate values
transformedData = Table.TransformColumns(
structuredData,
{
{"Version Object ID", each rootObjectId},
{"Model Info", each if desktopServiceSent or not desktopServiceSent then encodedUserInfo else encodedUserInfo}
}
),
// expand properties column if requested and if it exists
result = if shouldExpandProperties and Table.HasColumns(renamedData, {"properties"}) then
result = if shouldExpandProperties and Table.HasColumns(transformedData, {"properties"}) then
try
Speckle.Utils.ExpandRecord(renamedData, "properties")
Speckle.Utils.ExpandRecord(transformedData, "properties")
otherwise
renamedData // fallback to original data if expansion fails
transformedData // fallback to original data if expansion fails
else
renamedData
transformedData
in
result
else
@@ -121,33 +198,85 @@
{permissionCheck[message], permissionCheck[code]}
),
// helper function to try sending user info to desktop service for backward compatibility
// returns true if successful, false otherwise (non-blocking)
TrySendToDesktopService = (userInfoData as record) =>
try
let
userInfoJson = Json.FromValue(userInfoData),
response = Web.Contents(
"http://127.0.0.1:29364/store-user-info",
[
Headers = [
#"Content-Type" = "application/json",
#"Method" = "POST"
],
Content = userInfoJson,
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
Timeout = #duration(0, 0, 0, 2)
]
),
statusCode = Value.Metadata(response)[Response.Status]
in
statusCode >= 200 and statusCode < 300
otherwise
false,
// function to process a single model and get its data
ProcessSingleModel = (baseUrl, projectId, modelId, versionId) =>
ProcessSingleModel = (baseUrl, projectId, modelId, versionId) =>
let
// construct a standard URL for the model
singleModelUrl = Text.Combine({
baseUrl,
"/projects/",
projectId,
"/models/",
baseUrl,
"/projects/",
projectId,
"/models/",
modelId,
if versionId <> null then Text.Combine({"@", versionId}) else ""
}),
// get model info
modelInfo = GetModel(singleModelUrl),
rootObjectId = modelInfo[rootObjectId],
modelName = modelInfo[modelName],
sourceApplication = modelInfo[sourceApplication],
// get structured data
structuredData = GetStructuredData(singleModelUrl),
// build userInfoData record for this model
userInfoData = [
rootObjectId = rootObjectId,
server = baseUrl,
email = userEmail,
projectId = projectId,
token = tokenToUse,
workspaceId = workspaceInfo[workspaceId],
workspaceName = workspaceInfo[workspaceName],
workspaceLogo = workspaceInfo[workspaceLogo],
version = connectorVersion,
sourceApplication = sourceApplication,
canHideBranding = workspaceInfo[canHideBranding],
versionId = if versionId <> null then versionId else modelInfo[versionId],
url = singleModelUrl
],
// try to send to desktop service for backward compatibility (non-blocking)
// must be called BEFORE encoding to ensure it executes
desktopServiceSent = TrySendToDesktopService(userInfoData),
// encode userInfoData as base64 JSON string
encodedUserInfo = EncodeUserInfo(userInfoData),
// add the model name as context - with version id if exists
// reference desktopServiceSent to force evaluation
result = Table.AddColumn(
structuredData,
"Source Model",
each if versionId <> null then
Text.Combine({modelName, "-", versionId})
else if desktopServiceSent or not desktopServiceSent then
modelName
else
modelName,
type text
@@ -155,7 +284,8 @@
in
[
Data = result,
RootObjectId = rootObjectId
RootObjectId = rootObjectId,
EncodedUserInfo = encodedUserInfo
]
in
results
@@ -0,0 +1,18 @@
// Function to encode userInfoData as base64-encoded JSON string
(userInfoData as record) as text =>
let
JsonText = Text.FromBinary(
Json.FromValue(userInfoData),
TextEncoding.Utf8
),
// Convert JSON text to binary
JsonBinary = Text.ToBinary(JsonText, TextEncoding.Utf8),
// Encode binary as base64
Base64Encoded = Binary.ToText(JsonBinary, BinaryEncoding.Base64),
// Return base64-encoded string
Result = Base64Encoded
in
Result
@@ -0,0 +1,146 @@
// Function to exchange powerful token for weak limited token
(powerfulToken as text, scopes as list, projectId as text, serverUrl as text) as record =>
let
// Import the parser function for URL handling
Parser = Extension.LoadFunction("Parser.pqm"),
// Helper function to load .pqm modules dynamically
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName),
asText = Text.FromBinary(binary)
in
try
Expression.Evaluate(asText, #shared)
catch (e) =>
error
[
Reason = "Extension.LoadFunction Failure",
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
Message.Parameters = {fileName, e[Reason], e[Message]},
Detail = [File = fileName, Error = e]
],
// Validate inputs
ValidationError = if Text.Length(powerfulToken) = 0 then
"PowerfulToken is required"
else if List.Count(scopes) = 0 then
"Scopes are required"
else if Text.Length(projectId) = 0 then
"ProjectId is required"
else if Text.Length(serverUrl) = 0 then
"ServerUrl is required"
else
null,
// Token lifetime: 10 years (315,360,000,000 milliseconds)
TokenLifespanMs = 10 * 365 * 24 * 3600 * 1000,
// Generate token name with timestamp
TokenName = "Limited Power BI Visual Token - " & DateTime.ToText(DateTime.LocalNow(), "yyyy-MM-dd HH:mm"),
// Build scopes array string for GraphQL (e.g., ["profile:read", "streams:read"])
ScopesArray = Text.Combine(
List.Transform(scopes, each """" & _ & """"),
", "
),
// Build GraphQL mutation
GraphQLMutation = "
mutation {
apiTokenCreate(token: {
name: """ & TokenName & """,
scopes: [" & ScopesArray & "],
lifespan: " & Number.ToText(TokenLifespanMs) & ",
limitResources: [{
type: project,
id: """ & projectId & """
}]
})
}",
// Execute token exchange if validation passes
Result = if ValidationError <> null then
[
Success = false,
Token = null,
ErrorMessage = ValidationError
]
else
try
let
// Ensure serverUrl ends with /
NormalizedServerUrl = if Text.End(serverUrl, 1) = "/" then
serverUrl
else
serverUrl & "/",
// Make GraphQL request
Response = Web.Contents(
NormalizedServerUrl & "graphql",
[
Headers = [
#"Method" = "POST",
#"Content-Type" = "application/json",
#"Authorization" = "Bearer " & powerfulToken
],
Content = Json.FromValue([
query = GraphQLMutation
]),
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
Timeout = #duration(0, 0, 0, 10)
]
),
StatusCode = Value.Metadata(Response)[Response.Status],
// Parse response if successful
ParsedResult = if StatusCode >= 200 and StatusCode < 300 then
let
JsonResponse = Json.Document(Response),
// Check for GraphQL errors
HasErrors = Record.HasFields(JsonResponse, {"errors"}),
// Extract token from response
WeakToken = if not HasErrors then
JsonResponse[data][apiTokenCreate]
else
null,
ErrorMsg = if HasErrors then
try
JsonResponse[errors]{0}[message]
otherwise
"GraphQL mutation failed with unknown error"
else
null
in
if WeakToken <> null then
[
Success = true,
Token = WeakToken,
ErrorMessage = null
]
else
[
Success = false,
Token = null,
ErrorMessage = ErrorMsg
]
else
[
Success = false,
Token = null,
ErrorMessage = "GraphQL request failed with status " & Number.ToText(StatusCode)
]
in
ParsedResult
otherwise
[
Success = false,
Token = null,
ErrorMessage = "Token exchange request failed with exception"
]
in
Result
@@ -56,6 +56,7 @@
#"Object IDs" = record[id], // Object IDs
#"Speckle Type" = record[speckle_type], // Speckle Type
#"Version Object ID" = rootId,
#"Model Info" = rootId,
data = cleanedRecord // Data
]
)
@@ -1,30 +1,42 @@
// function for federating multiple tables by combining them and creating a concatenated Version Object ID
// function for federating multiple tables by combining them and creating concatenated Version Object ID and Model Info fields
(tables as list, optional excludeData as logical) as table =>
let
ViewerOnly = if excludeData = null then false else excludeData,
// filter columns from each table if excludeData is true
ProcessedTables = List.Transform(
tables,
each
if ViewerOnly then
Table.SelectColumns(_, {"Version Object ID", "Object IDs"}, MissingField.Ignore)
else
if ViewerOnly then
Table.SelectColumns(_, {"Version Object ID", "Model Info", "Object IDs"}, MissingField.Ignore)
else
_
),
CombinedTable = Table.Combine(ProcessedTables),
DistinctVersionObjectIDs = List.Distinct(CombinedTable[Version Object ID]),
ConcatenatedVersionObjectIDs = Text.Combine(DistinctVersionObjectIDs, ","),
DistinctModelInfo = List.Distinct(CombinedTable[Model Info]),
ConcatenatedModelInfo = Text.Combine(DistinctModelInfo, "|||"),
// Replace all Version Object ID values with the concatenated string
FederatedTable = Table.ReplaceValue(
CombinedTable,
each [Version Object ID],
ConcatenatedVersionObjectIDs,
Replacer.ReplaceText,
TableWithVersionObjectID = Table.ReplaceValue(
CombinedTable,
each [Version Object ID],
ConcatenatedVersionObjectIDs,
Replacer.ReplaceText,
{"Version Object ID"}
),
// Replace all Model Info values with the concatenated string
FederatedTable = Table.ReplaceValue(
TableWithVersionObjectID,
each [Model Info],
ConcatenatedModelInfo,
Replacer.ReplaceText,
{"Model Info"}
)
in
FederatedTable
@@ -1,13 +1,11 @@
(url as text) as list =>
let
// Import required functions
GetModel = Extension.LoadFunction("GetModel.pqm"),
Parser = Extension.LoadFunction("Parser.pqm"),
GetUser = Extension.LoadFunction("GetUser.pqm"),
GetVersion = Extension.LoadFunction("GetVersion.pqm"),
GetWorkspace = Extension.LoadFunction("GetWorkspace.pqm"),
ExchangeToken = Extension.LoadFunction("ExchangeToken.pqm"),
// the logic for importing functions from other files
// helper function to load .pqm modules dynamically
Extension.LoadFunction = (fileName as text) =>
let
binary = Extension.Contents(fileName),
@@ -24,130 +22,68 @@
Detail = [File = fileName, Error = e]
],
// Get required information
modelInfo = GetModel(url),
parsedUrl = Parser(url),
userInfo = GetUser(url),
apiKey = userInfo[Token],
userEmail = userInfo[UserEmail],
connectorVersion = GetVersion(),
workspaceInfo = GetWorkspace(url),
powerfulToken = userInfo[Token],
// Function to check if Desktop Service is available
IsDesktopServiceAvailable = () =>
try
let
PingResponse = Web.Contents(
"http://127.0.0.1:29364/ping",
[
Headers = [#"Method" = "GET"],
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
Timeout = #duration(0, 0, 0, 2) // 2 second timeout for ping
]
),
StatusCode = Value.Metadata(PingResponse)[Response.Status]
in
StatusCode = 200
otherwise
false,
// exchange powerful token for weak token using GraphQL
// this replaces the desktop service token exchange
tokenExchangeResult = ExchangeToken(
powerfulToken,
{"profile:read", "streams:read", "users:read"},
parsedUrl[projectId],
parsedUrl[baseUrl]
),
// Function to use Desktop Service approach (only called if available)
UseDesktopService = () =>
let
// exchange powerful token for weak token via ds
tokenExchangeData = Json.FromValue([
PowerfulToken = apiKey,
Scopes = {"profile:read", "streams:read", "users:read"},
// throw error if token exchange failed - do NOT use powerful token as fallback
tokenToUse = if tokenExchangeResult[Success] then
tokenExchangeResult[Token]
else
error [
Reason = "TokenExchangeFailed",
Message.Format = "Failed to exchange token for limited scope token: #{0}",
Message.Parameters = {tokenExchangeResult[ErrorMessage]},
Detail = [
ErrorMessage = tokenExchangeResult[ErrorMessage],
ProjectId = parsedUrl[projectId],
ServerUrl = parsedUrl[baseUrl]
]),
tokenExchangeResponse = Web.Contents(
"http://127.0.0.1:29364/auth/exchange-token",
[
Headers = [
#"Content-Type" = "application/json",
#"Method" = "POST"
],
Content = tokenExchangeData,
ManualStatusHandling = {400, 401, 403, 404, 500}
]
),
tokenExchangeJson = Json.Document(tokenExchangeResponse),
weakToken = tokenExchangeJson[token],
]
],
// prepare request data with weak token
requestData = Json.FromValue([
Url = url,
Server = parsedUrl[baseUrl],
Email = userEmail,
ProjectId = parsedUrl[projectId],
RootObjectId = modelInfo[rootObjectId],
SourceApplication = modelInfo[sourceApplication],
Token = weakToken,
Version = connectorVersion,
VersionId = parsedUrl[versionId],
WorkspaceId = workspaceInfo[workspaceId],
WorkspaceName = workspaceInfo[workspaceName],
WorkspaceLogo = workspaceInfo[workspaceLogo],
CanHideBranding = workspaceInfo[canHideBranding]
]),
// Send request to local server
Response = Web.Contents(
"http://127.0.0.1:29364/download",
[
Headers = [
#"Content-Type" = "application/json",
#"Method" = "POST"
],
Content = requestData,
ManualStatusHandling = {400, 401, 403, 404, 500}
]
),
// Parse response
JsonResponse = Json.Document(Response)
in
JsonResponse,
// Function to fallback to direct JSON download from Speckle server
FallbackToDirectDownload = () =>
// downloads data directly from server
DirectDownload = (token as text) =>
let
// Construct the direct object URL: {baseUrl}/objects/{projectId}/{rootObjectId}
objectUrl = Text.Combine({
parsedUrl[baseUrl],
"/objects/",
parsedUrl[projectId],
"/",
parsedUrl[baseUrl],
"/objects/",
parsedUrl[projectId],
"/",
modelInfo[rootObjectId]
}),
// Download JSON directly from Speckle server
Response = Web.Contents(
objectUrl,
[
Headers = [
#"Authorization" = "Bearer " & apiKey,
#"Authorization" = "Bearer " & token,
#"Accept" = "application/json"
],
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504}
]
),
// Check response status
StatusCode = Value.Metadata(Response)[Response.Status],
// Parse JSON response if successful
JsonResponse = if StatusCode >= 200 and StatusCode < 300 then
Json.Document(Response)
else
error [
Reason = "DirectDownloadFailed",
Message = "Failed to download model data directly from Speckle server",
Message.Format = "Failed to download model data from Speckle server (Status: #{0})",
Message.Parameters = {Text.From(StatusCode)},
Detail = [
StatusCode = StatusCode,
StatusCode = StatusCode,
ObjectUrl = objectUrl,
ProjectId = parsedUrl[projectId],
RootObjectId = modelInfo[rootObjectId]
@@ -156,13 +92,8 @@
in
JsonResponse,
// Check Desktop Service availability and choose approach
DesktopServiceAvailable = IsDesktopServiceAvailable(),
FinalResult = if DesktopServiceAvailable then
UseDesktopService()
else
FallbackToDirectDownload()
// download data using the token (weak if exchange succeeded, powerful otherwise)
FinalResult = DirectDownload(tokenToUse)
in
FinalResult
FinalResult
+2 -2
View File
@@ -1,7 +1,7 @@
{
"dataRoles": [
{
"displayName": "Version Object ID",
"displayName": "Model Info",
"kind": "Measure",
"name": "rootObjectId"
},
@@ -218,7 +218,7 @@
{
"essential": true,
"name": "WebAccess",
"parameters": ["https://analytics.speckle.systems", "http://localhost:29364", "*"]
"parameters": ["https://analytics.speckle.systems", "*"]
},
{
"essential": false,
+14 -14
View File
@@ -13,10 +13,10 @@
"@babel/runtime-corejs3": "^7.21.5",
"@headlessui/vue": "^1.7.13",
"@heroicons/vue": "^2.0.12",
"@speckle/objectloader2": "2.26.2",
"@speckle/objectloader2": "2.26.7",
"@speckle/tailwind-theme": "2.23.2",
"@speckle/ui-components": "2.23.2",
"@speckle/viewer": "2.26.1",
"@speckle/viewer": "2.26.5",
"color-interpolate": "^1.0.5",
"core-js": "^3.30.2",
"lodash": "^4.17.21",
@@ -3406,21 +3406,21 @@
"peer": true
},
"node_modules/@speckle/objectloader2": {
"version": "2.26.2",
"resolved": "https://registry.npmjs.org/@speckle/objectloader2/-/objectloader2-2.26.2.tgz",
"integrity": "sha512-sX0Mpi9h54CoWAl58YVCef4JSxWNnB+pFfTjo1XNfBEuyfwL6JBO8j2ho5OYLIyag4VZ1yXu/3MfmSIY4lMq3w==",
"version": "2.26.7",
"resolved": "https://registry.npmjs.org/@speckle/objectloader2/-/objectloader2-2.26.7.tgz",
"integrity": "sha512-bc3hp/83DBnyp2TOaVtKfqRaWz5UKcWouW281JcFcezTn/TZnB+FSF2csClBhOzrWSqmGVns0KfoJVfflETghQ==",
"license": "Apache-2.0",
"dependencies": {
"@speckle/shared": "^2.26.2"
"@speckle/shared": "^2.26.7"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@speckle/shared": {
"version": "2.26.2",
"resolved": "https://registry.npmjs.org/@speckle/shared/-/shared-2.26.2.tgz",
"integrity": "sha512-fvsq8J0riSNEPL9WaExzStl2qyUZIzQPOrFPDe/Biylkgv89GTAJlZdYZ4q1AqfaL3o9wYYQ8tKudjl+cAgFrQ==",
"version": "2.26.7",
"resolved": "https://registry.npmjs.org/@speckle/shared/-/shared-2.26.7.tgz",
"integrity": "sha512-3TJNmC1JFKvrAfnEDqX7A4P8V/P8M26T8+7sNRtsIMaINFAvKbBjTi7KGFzoAwQmzeXdbUsd2jXF4Kt94W3ZKQ==",
"license": "Apache-2.0",
"dependencies": {
"dayjs": "^1.11.13",
@@ -3545,13 +3545,13 @@
}
},
"node_modules/@speckle/viewer": {
"version": "2.26.1",
"resolved": "https://registry.npmjs.org/@speckle/viewer/-/viewer-2.26.1.tgz",
"integrity": "sha512-COqGbk+086GBRpctvvgvP/NhXbq1OeieRUIosmUnXswTq8b6G3PGd7FJXAPs2KI1uhQNda+igzql8sFurn7gNA==",
"version": "2.26.5",
"resolved": "https://registry.npmjs.org/@speckle/viewer/-/viewer-2.26.5.tgz",
"integrity": "sha512-kC/mKYVQZW9f+ek4fcYBEsSEo5fk/cvhBRPFpUzu974gNkaQJAJKWGCziB4U2WHai/twQXJS/yhyrCNOAVBnzQ==",
"license": "Apache-2.0",
"dependencies": {
"@speckle/objectloader2": "^2.26.1",
"@speckle/shared": "^2.26.1",
"@speckle/objectloader2": "^2.26.5",
"@speckle/shared": "^2.26.5",
"@types/flat": "^5.0.2",
"earcut": "3.0.1",
"flat": "^5.0.2",
+2 -2
View File
@@ -17,10 +17,10 @@
"@babel/runtime-corejs3": "^7.21.5",
"@headlessui/vue": "^1.7.13",
"@heroicons/vue": "^2.0.12",
"@speckle/objectloader2": "2.26.2",
"@speckle/objectloader2": "2.26.7",
"@speckle/tailwind-theme": "2.23.2",
"@speckle/ui-components": "2.23.2",
"@speckle/viewer": "2.26.1",
"@speckle/viewer": "2.26.5",
"color-interpolate": "^1.0.5",
"core-js": "^3.30.2",
"lodash": "^4.17.21",
@@ -64,7 +64,7 @@
field is needed for interactivity with other visuals.
</div>
<div v-if="visualStore.isNavbarHidden" class="fixed top-0 right-0 z-20">
<div v-if="visualStore.isNavbarHidden" class="fixed top-4 right-2 z-20">
<button
class="transition opacity-50 hover:opacity-100"
title="Show navbar"
@@ -41,8 +41,12 @@ export function useUpdateConnector() {
return new Date(b.Date).getTime() - new Date(a.Date).getTime()
})
versions.value = sortedVersions
const sanitizedVersion = sanitizeVersion(sortedVersions[0].Number)
latestAvailableVersion.value = { ...sortedVersions[0], Number: sanitizedVersion }
// Filter out prerelease versions
const stableVersions = sortedVersions.filter((v) => !v.Prerelease)
const latestVersion = stableVersions[0]
const sanitizedVersion = sanitizeVersion(latestVersion.Number)
latestAvailableVersion.value = { ...latestVersion, Number: sanitizedVersion }
visualStore.setLatestAvailableVersion(latestAvailableVersion.value)
}
@@ -0,0 +1,97 @@
/**
* Interface for decoded user info data passed from the data connector
* This data is base64-encoded in the "Version Object ID" field and decoded by the visual
*/
export interface DecodedUserInfo {
rootObjectId: string
server: string
email: string
projectId: string
token: string // weak token with limited scopes
workspaceId?: string | null
workspaceName?: string | null
workspaceLogo?: string | null
version?: string
sourceApplication?: string
canHideBranding?: boolean
versionId?: string
url?: string
}
// Decodes a base64-encoded JSON string to extract userInfoData
export function decodeUserInfo(encodedString: string): DecodedUserInfo {
try {
// Base64 decode using browser's atob()
const decodedString = atob(encodedString)
// Parse JSON
const userInfo = JSON.parse(decodedString) as DecodedUserInfo
// Validate required fields
const requiredFields: (keyof DecodedUserInfo)[] = [
'rootObjectId',
'server',
'email',
'projectId',
'token'
]
const missingFields = requiredFields.filter((field) => !userInfo[field])
if (missingFields.length > 0) {
throw new Error(
`Missing required fields in decoded user info: ${missingFields.join(', ')}`
)
}
return userInfo
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to decode user info: ${error.message}`)
}
throw new Error('Failed to decode user info: Unknown error')
}
}
// Decodes multiple base64-encoded userInfo strings (for federated models)
export function decodeMultipleUserInfo(encodedStrings: string): DecodedUserInfo[] {
try {
// Split by delimiter
const segments = encodedStrings.split('|||')
// Decode each segment
return segments.map((segment, index) => {
try {
return decodeUserInfo(segment.trim())
} catch (error) {
throw new Error(
`Failed to decode segment ${index + 1} of federated model data: ${
error instanceof Error ? error.message : 'Unknown error'
}`
)
}
})
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to decode multiple user info: ${error.message}`)
}
throw new Error('Failed to decode multiple user info: Unknown error')
}
}
// Checks if an encoded string contains multiple models (federated)
export function isFederatedEncoding(encodedString: string): boolean {
return encodedString.includes('|||')
}
// Safely decodes userInfo, handling both single and federated models
// Returns an array of DecodedUserInfo (single item for non-federated)
export function decodeUserInfoSafe(encodedString: string): DecodedUserInfo[] {
if (isFederatedEncoding(encodedString)) {
return decodeMultipleUserInfo(encodedString)
} else {
return [decodeUserInfo(encodedString)]
}
}
+175 -76
View File
@@ -13,6 +13,7 @@ import { getSlugFromHostAppNameAndVersion } from './hostAppSlug'
import { useUpdateConnector } from '@src/composables/useUpdateConnector'
import { SpeckleApiLoader } from '@src/loader/SpeckleApiLoader'
import { unzipModelObjects } from './compression'
import { decodeUserInfoSafe, DecodedUserInfo } from './decodeUserInfo'
export class AsyncPause {
private lastPauseTime = 0
@@ -165,20 +166,71 @@ export type ReceiveInfo = {
projectId?: string
}
async function getReceiveInfo(id) {
/**
* Extracts userInfoData from encoded string
* Returns array of DecodedUserInfo for federated models, single item for single models
*/
function decodeUserInfoFromId(encodedId: string): DecodedUserInfo[] {
try {
const ids = (id as string).split(',')
const response = await fetch(`http://localhost:29364/user-info/${ids[0]}`)
if (!response.body) {
console.error('No response body')
return { desktopServiceError: true }
return decodeUserInfoSafe(encodedId)
} catch (error) {
console.error('Failed to decode user info from encoded ID:', error)
throw new Error(`Invalid encoded user info data: ${error.message}`)
}
}
// Mark version as received
async function markVersionAsReceived(
versionId: string,
projectId: string,
serverUrl: string,
token: string
): Promise<void> {
try {
const mutation = `
mutation MarkVersionReceived($input: MarkReceivedVersionInput!) {
versionMutations {
markReceived(input: $input)
}
}
`
const variables = {
input: {
versionId: versionId,
projectId: projectId,
sourceApplication: 'powerbi'
}
}
return await response.json()
const response = await fetch(`${serverUrl}/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
query: mutation,
variables: variables
})
})
if (!response.ok) {
console.warn(
`Failed to mark version as received (status ${response.status}). This is non-critical.`
)
return
}
const result = await response.json()
if (result.errors) {
console.warn('Failed to mark version as received:', result.errors)
} else {
console.log(`✅ Marked version ${versionId} as received in PowerBI`)
}
} catch (error) {
console.log(error)
console.log("User info couldn't retrieved from local server.")
return { desktopServiceError: true }
// Non-critical error - log but don't throw
console.warn('Failed to mark version as received:', error)
}
}
@@ -186,18 +238,25 @@ async function fetchFromSpeckleApi(
objectIds: string,
serverUrl: string,
projectId: string,
token: string
token: string,
versionIds?: string[]
): Promise<object[][]> {
const ids = objectIds.split(',')
const modelObjects = []
for (const objectId of ids) {
for (let i = 0; i < ids.length; i++) {
const objectId = ids[i]
try {
console.log(`Downloading from Speckle API: ${objectId}`)
const loader = new SpeckleApiLoader(serverUrl, projectId, token)
const objects = await loader.downloadObjectsWithChildren(objectId)
modelObjects.push(objects)
console.log(`Downloaded ${objects.length} objects from Speckle`)
// Mark version as received (non-blocking, best effort)
if (versionIds && versionIds[i]) {
markVersionAsReceived(versionIds[i], projectId, serverUrl, token)
}
} catch (error) {
console.error(`Failed to download objects from Speckle:`, error)
throw error
@@ -262,18 +321,27 @@ export async function processMatrixView(
if (internalizedModelObjects && internalizedModelObjects.length > 0) {
// CRITICAL: Validate that internalized data matches current matrix data
const internalizedRootId = (internalizedModelObjects[0][0] as any).id
if (internalizedRootId !== id) {
console.log(
`📁 Internalized data mismatch: stored=${internalizedRootId}, current=${id}. Using fresh data.`
)
internalizedModelObjects = undefined // Clear internalized data - use fresh data instead
} else {
console.log(
'📁 Successfully validated internalized data matches current matrix:',
internalizedModelObjects.length,
'models'
)
// Need to decode id first to get actual root object IDs for comparison
try {
const decodedForCheck = decodeUserInfoFromId(id)
const actualRootIds = decodedForCheck.map((info) => info.rootObjectId).join(',')
const internalizedRootId = (internalizedModelObjects[0][0] as any).id
if (internalizedRootId !== actualRootIds.split(',')[0]) {
console.log(
`📁 Internalized data mismatch: stored=${internalizedRootId}, current=${actualRootIds}. Using fresh data.`
)
internalizedModelObjects = undefined // Clear internalized data - use fresh data instead
} else {
console.log(
'📁 Successfully validated internalized data matches current matrix:',
internalizedModelObjects.length,
'models'
)
}
} catch (error) {
console.error('📁 Failed to decode ID for internalized data check:', error)
internalizedModelObjects = undefined
}
}
@@ -295,17 +363,26 @@ export async function processMatrixView(
}
// Only reload if switching models or not already loaded
const needsReload =
!visualStore.isViewerObjectsLoaded || visualStore.lastLoadedRootObjectId !== id
if (needsReload) {
console.log('🔄 Forcing viewer reload for internalized data (model switch or first load)')
visualStore.setViewerReloadNeeded()
visualStore.setViewerReadyToLoad(true)
visualStore.setLoadingProgress('📁 Loading from file', null)
} else {
console.log('📁 Internalized data already loaded, skipping reload')
// Need to decode to get actual root object ID for comparison
try {
const decodedForReload = decodeUserInfoFromId(id)
const actualRootIds = decodedForReload.map((info) => info.rootObjectId).join(',')
const needsReload =
!visualStore.isViewerObjectsLoaded ||
visualStore.lastLoadedRootObjectId !== actualRootIds
if (needsReload) {
console.log('🔄 Forcing viewer reload for internalized data (model switch or first load)')
visualStore.setViewerReloadNeeded()
visualStore.setViewerReadyToLoad(true)
visualStore.setLoadingProgress('📁 Loading from file', null)
} else {
console.log('📁 Internalized data already loaded, skipping reload')
}
visualStore.lastLoadedRootObjectId = actualRootIds // Set to actual root IDs to skip API calls
} catch (error) {
console.error('📁 Failed to decode ID for reload check:', error)
}
visualStore.lastLoadedRootObjectId = id // Set to current ID to skip API calls
} else {
console.error('📁 Failed to unzip internalized data')
}
@@ -314,59 +391,72 @@ export async function processMatrixView(
}
}
// const id = localMatrixView[0].values[0].value as unknown as string
console.log('🗝️ Root Object Id: ', id)
console.log('Last laoded root object id', visualStore.lastLoadedRootObjectId)
// Extract the encoded string from matrix (id is now the base64 encoded userInfo)
const encodedId = id
console.log('🗝️ Encoded ID: ', encodedId.substring(0, 50) + '...')
console.log('Last loaded root object id', visualStore.lastLoadedRootObjectId)
let modelObjects: object[][] = undefined
// Decode userInfo first to get actual root object IDs for comparison
let decodedUserInfos: DecodedUserInfo[]
let actualRootObjectIds: string
try {
decodedUserInfos = decodeUserInfoFromId(encodedId)
// Build comma-separated list of actual root object IDs
actualRootObjectIds = decodedUserInfos.map((info) => info.rootObjectId).join(',')
console.log(`🔓 Decoded ${decodedUserInfos.length} userInfo(s) - Root IDs: ${actualRootObjectIds}`)
} catch (error) {
console.error('Failed to decode user info:', error)
visualStore.setCommonError(
'Failed to decode user info from data connector. Please refresh the data.'
)
visualStore.setViewerReadyToLoad(false)
return {
modelObjects: [],
objectIds: [],
selectedIds: [],
colorByIds: null,
objectTooltipData: new Map(),
isFromStore: false
}
}
// Check if we need to reload (compare actual root object IDs, not encoded strings)
if (
visualStore.lastLoadedRootObjectId !== id &&
visualStore.lastLoadedRootObjectId !== actualRootObjectIds &&
!visualStore.isLoadingFromFile &&
!internalizedModelObjects
) {
const start = performance.now()
// Get receive info from desktop service to populate visual store
const receiveInfo = await getReceiveInfo(id)
let desktopServiceUnavailable = false
// Use the first decoded userInfo for visual store (for federated, all have same credentials)
const primaryUserInfo = decodedUserInfos[0]
if (receiveInfo && !receiveInfo.desktopServiceError) {
visualStore.setReceiveInfo({
userEmail: receiveInfo.email || receiveInfo.Email,
serverUrl: receiveInfo.server || receiveInfo.Server,
sourceApplication: getSlugFromHostAppNameAndVersion(
receiveInfo.sourceApplication || receiveInfo.SourceApplication
),
workspaceId: receiveInfo.workspaceId || receiveInfo.WorkspaceId,
workspaceName: receiveInfo.workspaceName || receiveInfo.WorkspaceName,
workspaceLogo: receiveInfo.workspaceLogo || receiveInfo.WorkspaceLogo,
version: receiveInfo.version || receiveInfo.Version,
canHideBranding: receiveInfo.canHideBranding ?? receiveInfo.CanHideBranding,
token: receiveInfo.weakToken || receiveInfo.WeakToken,
projectId: receiveInfo.projectId || receiveInfo.ProjectId
})
console.log(`Receive info retrieved from desktop service - credentials loaded`)
} else {
desktopServiceUnavailable = true
console.log('Desktop service unavailable - cannot retrieve credentials')
}
visualStore.setReceiveInfo({
userEmail: primaryUserInfo.email,
serverUrl: primaryUserInfo.server,
sourceApplication: getSlugFromHostAppNameAndVersion(primaryUserInfo.sourceApplication || ''),
workspaceId: primaryUserInfo.workspaceId || undefined,
workspaceName: primaryUserInfo.workspaceName || undefined,
workspaceLogo: primaryUserInfo.workspaceLogo || undefined,
version: primaryUserInfo.version,
canHideBranding: primaryUserInfo.canHideBranding || false,
token: primaryUserInfo.token,
projectId: primaryUserInfo.projectId
})
console.log(`✅ Credentials loaded from encoded data`)
// Now get the data from visual store for Speckle API download
const token = visualStore.receiveInfo?.token
const serverUrl = visualStore.receiveInfo?.serverUrl
const projectId = visualStore.receiveInfo?.projectId
// Get credentials for Speckle API download
const token = primaryUserInfo.token
const serverUrl = primaryUserInfo.server
const projectId = primaryUserInfo.projectId
if (!token || !serverUrl || !projectId) {
if (desktopServiceUnavailable) {
visualStore.setCommonError(
'Speckle Desktop Service is not running. Please start Speckle Desktop Services and refresh data.'
)
} else {
visualStore.setCommonError(
'Missing Speckle credentials. Please refresh the data from the data connector.'
)
}
visualStore.setCommonError(
'Missing required credentials in encoded data. Please refresh the data from the data connector.'
)
visualStore.setViewerReadyToLoad(false)
return {
modelObjects: [],
@@ -381,9 +471,18 @@ export async function processMatrixView(
visualStore.setViewerReadyToLoad(true)
console.log('Downloading objects directly from Speckle API...')
console.log(`Server: ${serverUrl}, Project: ${projectId}, Object: ${id}`)
console.log(`Server: ${serverUrl}, Project: ${projectId}, Objects: ${actualRootObjectIds}`)
try {
modelObjects = await fetchFromSpeckleApi(id, serverUrl, projectId, token)
// Extract versionIds for markAsReceived
const versionIds = decodedUserInfos.map((info) => info.versionId).filter(Boolean) as string[]
modelObjects = await fetchFromSpeckleApi(
actualRootObjectIds,
serverUrl,
projectId,
token,
versionIds.length > 0 ? versionIds : undefined
)
console.log('Successfully downloaded from Speckle API')
// Debug: Check what we're passing to the viewer
+1 -1
View File
@@ -21,7 +21,7 @@
<div class="flex flex-col justify-center space-y-1">
<div class="flex flex-row space-x-2">
<EyeIcon class="w-6"></EyeIcon>
<p><b>Version Object ID</b></p>
<p><b>Model Info</b></p>
<ArrowRightIcon class="w-4"></ArrowRightIcon>
<p>View your model</p>
</div>
+28 -70
View File
@@ -22,6 +22,7 @@ import ITooltipService = powerbi.extensibility.ITooltipService
import { pinia } from './plugins/pinia'
import { useVisualStore } from './store/visualStore'
import { SpeckleApiLoader } from './loader/SpeckleApiLoader'
// noinspection JSUnusedGlobalSymbols
export class Visual implements IVisual {
@@ -353,97 +354,54 @@ export class Visual implements IVisual {
}
try {
console.log('📁 Starting internalization via desktop service streaming...')
console.log('📁 Starting internalization via Speckle API...')
visualStore.setLoadingProgress('📦 Internalizing data...', null)
// Use desktop service for internalization
// TBD: getting objects from viewer caused two issue:
// - Data format -> we need to make an extra operation to match with the offline loader
// - Memory -> need to save data two times so sometimes causes memory issues
const rootObjectIds = visualStore.lastLoadedRootObjectId
// Get credentials from visualStore (already loaded from encoded data)
const token = visualStore.receiveInfo?.token
const serverUrl = visualStore.receiveInfo?.serverUrl
const projectId = visualStore.receiveInfo?.projectId
// Handle federated models by processing each object ID separately
const objectIds = rootObjectIds.split(',')
let allStreamedObjects = []
for (const objectId of objectIds) {
console.log(`📁 Fetching objects for ID: ${objectId}`)
// For federated models, pass project ID explicitly to avoid "project id is not set" error
const url = projectId
? `http://localhost:29364/get-objects/${objectId}?projectId=${projectId}`
: `http://localhost:29364/get-objects/${objectId}`
const response = await fetch(url)
if (!response.body) {
console.error(`📁 No response body from desktop service for ${objectId}`)
continue
}
const rootObjectIds = visualStore.lastLoadedRootObjectId
const reader = response.body.getReader()
const decoder = new TextDecoder()
let allObjectsData = ''
console.log(`📁 Streaming objects from desktop service for ${objectId}...`)
while (true) {
const { done, value } = await reader.read()
if (done) break
allObjectsData += decoder.decode(value, { stream: true })
}
// Parse NDJSON (newline-delimited JSON) format
const lines = allObjectsData.trim().split('\n')
const objectsForThisId = lines.map((line) => JSON.parse(line))
console.log(`📁 Streamed ${objectsForThisId.length} objects for ID ${objectId}`)
allStreamedObjects.push(...objectsForThisId)
}
const streamedObjects = allStreamedObjects
if (streamedObjects.length === 0) {
console.error('📁 No objects retrieved from desktop service')
if (!token || !serverUrl || !projectId) {
console.error('📁 Missing credentials for internalization')
visualStore.clearLoadingProgress()
return
}
console.log(`📁 Retrieved ${streamedObjects.length} total objects from desktop service`)
// Clean up objects to reduce file size (same as desktop service does)
const cleanedObjects = streamedObjects.map((obj: any, index: number) => {
// Skip first object (root), clean others
if (index === 0) return obj
// Handle federated models by processing each object ID separately
const objectIds = rootObjectIds.split(',')
let allObjects = []
const cleanedObj = { ...obj }
for (const objectId of objectIds) {
console.log(`📁 Downloading objects for ID: ${objectId}`)
// Remove unnecessary properties
if (cleanedObj.speckle_type?.includes('Objects.Data.DataObject')) {
delete cleanedObj.properties
}
delete cleanedObj.__closure
const loader = new SpeckleApiLoader(serverUrl, projectId, token)
const objects = await loader.downloadObjectsWithChildren(objectId)
return cleanedObj
})
console.log(`📁 Downloaded ${objects.length} objects for ID ${objectId}`)
allObjects.push(objects)
}
console.log(`📁 Cleaned objects: ${cleanedObjects.length} total`)
if (allObjects.length === 0 || allObjects.every((arr) => arr.length === 0)) {
console.error('📁 No objects retrieved from Speckle API')
visualStore.clearLoadingProgress()
return
}
// Wrap in array format expected by viewer (object[][])
const modelObjectsArray = [cleanedObjects]
console.log(`📁 Retrieved ${allObjects.reduce((sum, arr) => sum + arr.length, 0)} total objects from Speckle API`)
// Use existing writeObjectsToFile method from visualStore
visualStore.writeObjectsToFile(modelObjectsArray)
// allObjects is already in the format object[][] expected by viewer
visualStore.writeObjectsToFile(allObjects)
// Clear loading message immediately when done
visualStore.clearLoadingProgress()
console.log('📁 Successfully internalized data via desktop service!')
console.log('📁 Successfully internalized data via Speckle API!')
} catch (error) {
console.error('📁 Failed to internalize via desktop service:', error)
console.error('📁 Failed to internalize via Speckle API:', error)
// Clear loading message immediately on error
visualStore.clearLoadingProgress()