Compare commits

...

35 Commits

Author SHA1 Message Date
Dogukan Karatas 685a137531 Merge pull request #204 from specklesystems/dogukan/cnx-2103-filtering-from-other-visuals-doesnt-work-when-conditional
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): remove forcing conditional formatted objects always be visible
2025-09-17 20:30:53 +03:00
Dogukan Karatas 78af91f38a Merge pull request #202 from specklesystems/dogukan/cnx-2515-fallback-to-json-for-scheduled-refresh
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
fallback to json download
2025-09-11 15:02:39 +02:00
Dogukan Karatas 108a406bd5 clearer error message on visual 2025-09-11 15:00:00 +02:00
Mucahit Bilal GOKER d7ede2edcf Merge branch 'main' into dogukan/cnx-2515-fallback-to-json-for-scheduled-refresh 2025-09-11 07:13:22 +03:00
Mucahit Bilal GOKER a25d635ca1 Merge pull request #201 from specklesystems/bilal/cnx-2510-remove-internal-border
Bilal/cnx 2510 remove internal border
2025-09-11 06:57:59 +03:00
Dogukan Karatas 5a9add6d76 fallback to json download 2025-09-10 13:22:41 +02:00
Mucahit Bilal GOKER 89c8005dee remove border from main container 2025-09-09 19:19:21 +03:00
Mucahit Bilal GOKER a384370652 Merge branch 'dev' into bilal/cnx-2510-remove-internal-border 2025-09-09 19:07:56 +03:00
oguzhankoral 5ec90095f0 Merge branch 'dev'
# Conflicts:
#	README.md
2025-09-04 23:29:20 +03:00
Oğuzhan Koral 20fad26fef Merge pull request #200 from specklesystems/oguzhan/cherry-pick-readme
Update README.md (#147)
2025-09-04 23:19:32 +03:00
Jonathon Broughton 03215f79c4 Update README.md (#147)
* Update README.md

* Update README.md

(cherry picked from commit 85f8f72335)

# Conflicts:
#	README.md
2025-09-04 23:15:19 +03:00
Dogukan Karatas 6d17377ca2 Merge pull request #198 from specklesystems/dogukan/remove-access-token-auth
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): remove token and anonymous authentication
2025-09-03 10:09:43 +02:00
Dogukan Karatas 256abaed0c remove token and anon auth 2025-09-02 21:46:35 +02:00
Dogukan Karatas 26409b4ea6 storing logic changes 2025-09-02 14:22:03 +02:00
Dogukan Karatas 865c4c1608 Merge pull request #197 from specklesystems/dogukan/server-url-in-request-data
fix (data): adds server url to exchange data
2025-09-02 12:53:44 +02:00
Dogukan Karatas 67836c2a7f case sensitive change 2025-09-02 11:00:35 +02:00
Dogukan Karatas 95d819f7f3 adds server url to exchange request data 2025-09-02 10:31:01 +02:00
Dogukan Karatas dee3ee6c4d Merge pull request #196 from specklesystems/dogukan/fix-conditional-formatting-card
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): bring back the conditional formatting
2025-08-28 17:17:27 +02:00
Dogukan Karatas 7ed612ec14 revert back for conditional formatting 2025-08-28 16:30:15 +02:00
Dogukan Karatas 4bd7af4c31 Merge pull request #195 from specklesystems/dogukan/online-mode-with-object-loader
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 (visual): dual-mode data loading
2025-08-27 20:40:40 +02:00
Dogukan Karatas 3ed2e977df removed unused function 2025-08-27 16:48:06 +02:00
Dogukan Karatas 788fa1c532 auto internalizing logic 2025-08-27 16:33:09 +02:00
Dogukan Karatas bafb7df6ed integrate two options together 2025-08-26 22:28:11 +02:00
Dogukan Karatas be4e4df983 integreates object loader instead of rest api 2025-08-20 15:25:37 +02:00
Dogukan Karatas b4830c80ab downloader with rest api 2025-08-20 13:16:50 +02:00
Dogukan Karatas a2d97facc5 adds token to the matrix 2025-08-19 17:02:58 +02:00
Dogukan Karatas aea344a46a updated sendtoserver for token exchange 2025-08-18 17:18:29 +02:00
Mucahit Bilal GOKER 13aa65bc2e Merge pull request #191 from specklesystems/bilal/cnx-2200-specklemodelsmaterialquantities-helper-function
Add Models.MaterialQuantities helper function
2025-08-12 10:28:44 +03:00
Mucahit Bilal GOKER 0a307c28e0 Merge pull request #190 from specklesystems/bilal/cnx-2169-properties-add-parent-dict-name-as-a-suffix
Parameter Group as suffix
2025-08-12 10:28:27 +03:00
bimgeek 8f67ef4c84 add optional prefix 2025-07-25 21:37:49 +03:00
bimgeek 2c5f192403 first attempt 2025-07-25 20:25:50 +03:00
bimgeek 0c58789dd6 rename target fields to filterKeys 2025-07-23 16:45:55 +03:00
bimgeek 82acce2abb fix conflict resolution logic 2025-07-23 13:40:07 +03:00
bimgeek d83472c30b add parent name as a suffix 2025-07-23 13:15:13 +03:00
Jonathon Broughton 85f8f72335 Update README.md (#147)
* Update README.md

* Update README.md
2025-04-08 21:32:04 +03:00
17 changed files with 1387 additions and 468 deletions
+7 -9
View File
@@ -150,7 +150,7 @@ shared Speckle.GetWorkspace = Value.ReplaceType(
shared Speckle.Objects.Properties = Value.ReplaceType(
Speckle.LoadFunction("Objects.Properties.pqm"),
type function (inputRecord as record, optional filterKeys as list) as record
type function (inputRecord as any, optional filterKeys as list, optional parentPath as text, optional existingFields as list) as record
);
@@ -184,6 +184,11 @@ shared Speckle.Models.Federate = Value.ReplaceType(
type function (tables as list, optional excludeData as logical) as table
);
shared Speckle.Models.MaterialQuantities = Value.ReplaceType(
Speckle.LoadFunction("Models.MaterialQuantities.pqm"),
type function (inputTable as table, optional addPrefix as logical) as table
);
[DataSource.Kind = "Speckle", Publish="GetByUrl.Publish"]
shared Speckle.GetByUrl = Value.ReplaceType(
Speckle.LoadFunction("GetByUrl.pqm"),
@@ -242,7 +247,7 @@ GetByUrl.Icons = [
Speckle = [
// This is used when running the connector on an on-premises data gateway
TestConnection = (path) => {"Speckle.GetUser", path},
// Authentication strategy
// Authentication strategy - OAuth only
Authentication = [
OAuth = [
Label = "Speckle Account",
@@ -352,13 +357,6 @@ Speckle = [
]
in
result
],
Key = [
KeyLabel = "Personal Access Token",
Label = "Private Project"
],
Implicit = [
Label = "Public Project"
]
],
Label = "Speckle"
@@ -1,7 +1,7 @@
(server as text, optional query as text, optional variables as record) as record =>
let
// Enhanced credential retrieval with OAuth2 support
apiKey = try Extension.CurrentCredential()[Key] otherwise try Extension.CurrentCredential()[access_token] otherwise null,
apiKey = try Extension.CurrentCredential()[access_token] otherwise null,
defaultQuery = "query {
activeUser {
@@ -29,7 +29,7 @@
versionId = parsedUrl[versionId],
// get API key if available
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
apiKey = try Extension.CurrentCredential()[access_token] otherwise null,
// graphql query to get model info including root object id
// includes specific version if provided
@@ -26,7 +26,7 @@ in
parsedUrl = Parser(url),
server = parsedUrl[baseUrl],
apiKey = try Extension.CurrentCredential()[Key] otherwise try Extension.CurrentCredential()[access_token] otherwise "",
apiKey = try Extension.CurrentCredential()[access_token] otherwise "",
query = "query {
activeUser {
@@ -0,0 +1,22 @@
// function for transforming a table to extract and expand Material Quantities data
(inputTable as table, optional addPrefix as logical) as table =>
let
// Default addPrefix to false if not provided
UsePrefix = if addPrefix = null then false else addPrefix,
// Add mq column using existing MaterialQuantities function with list output
AddedMQ = Table.AddColumn(inputTable, "mq", each Speckle.Objects.MaterialQuantities([data], true)),
// Expand the mq list column
ExpandMQ = Table.ExpandListColumn(AddedMQ, "mq"),
// Add MQProperties column using Properties function with error handling
AddedMQProperties = Table.AddColumn(ExpandMQ, "MQ", each try Speckle.Objects.Properties([mq]) otherwise null),
// Expand the MQProperties record using Utils.ExpandRecord
ExpandMQProperties = Speckle.Utils.ExpandRecord(AddedMQProperties, "MQ", null, UsePrefix),
// Remove the temporary mq and MQProperties columns
FinalTable = Table.RemoveColumns(ExpandMQProperties, {"mq", "MQ"}, MissingField.Ignore)
in
FinalTable
@@ -1,196 +1,257 @@
// function for extracting and flattening properties from Speckle objects
(inputRecord as record, optional filterKeys as list) as record =>
(inputRecord as any, optional filterKeys as list, optional parentPath as text, optional existingFields as list) as record =>
let
// auto-extract properties if the input has a "properties" field
ActualInput = if Record.HasFields(inputRecord, {"properties"}) then
inputRecord[properties]
else
inputRecord,
// helper function to check if a key should be included
ShouldIncludeKey = (keyName as text) as logical =>
if filterKeys = null then
true
else
List.Contains(filterKeys, keyName),
// define excluded paths
// Define excluded paths
ExcludedPaths = {
"Composite Structure",
"Material Quantities",
"Parameters.Type Parameters.Structure"
},
IsExcludedPath = (path as text) as logical =>
List.AnyTrue(
List.Transform(
ExcludedPaths,
(excludedPath) => Text.StartsWith(path, excludedPath)
)
),
// Helper function to check if a path should be excluded
IsPathExcluded = (currentPath as text) as logical =>
List.AnyTrue(List.Transform(ExcludedPaths, each Text.Contains(currentPath, _))),
// helper function to handle duplicate keys by adding suffixes
AddUniqueKey = (existingRecord as record, newKey as text, newValue as any) as record =>
// Helper function to resolve naming conflicts
ResolveFieldName = (fieldName as text, parentPathParam as nullable text, existingFieldsParam as nullable list) as text =>
let
originalKey = newKey,
counter = 1,
// Ensure we have valid inputs
parentPath = if parentPathParam = null then "" else parentPathParam,
existingFields = if existingFieldsParam = null then {} else existingFieldsParam,
// find a unique key by adding suffix if needed
FindUniqueKey = (testKey as text, testCounter as number) as text =>
if Record.HasFields(existingRecord, {testKey}) then
@FindUniqueKey(originalKey & "_" & Text.From(testCounter), testCounter + 1)
else
testKey,
// Try original field name first
candidateName = fieldName,
uniqueKey = FindUniqueKey(newKey, counter),
result = Record.AddField(existingRecord, uniqueKey, newValue)
// If no conflict, return original name
finalName = if not List.Contains(existingFields, candidateName) then
candidateName
else if parentPath = "" then
fieldName // No parent path available, keep original
else
let
// Split parent path and try adding parents one by one
pathParts = Text.Split(parentPath, "."),
reversedParts = List.Reverse(pathParts), // Start with immediate parent
// Use iteration instead of recursion
ResolveWithIteration = () =>
let
// Generate all possible candidates
candidates = List.Generate(
() => [depth = 1, candidate = fieldName & "." & List.First(reversedParts)],
each [depth] <= List.Count(reversedParts),
each [
depth = [depth] + 1,
candidate = fieldName & "." & Text.Combine(List.FirstN(reversedParts, [depth]), ".")
],
each [candidate]
),
// Find first non-conflicting candidate
firstNonConflicting = List.First(
List.Select(candidates, each not List.Contains(existingFields, _)),
// If all conflict, use full path
fieldName & "." & Text.Combine(reversedParts, ".")
)
in
firstNonConflicting,
resolvedName = ResolveWithIteration()
in
resolvedName
in
finalName,
// Create the main flattening function with self-reference capability
FlattenRecordImpl = (
flattenFn as function,
inputRecord as any,
filterKeys as nullable list,
parentPathParam as nullable text,
existingFieldsParam as nullable list
) as record =>
let
// Ensure non-null values for internal use
currentParentPath = if parentPathParam = null then "" else parentPathParam,
currentExistingFields = if existingFieldsParam = null then {} else existingFieldsParam,
currentfilterKeys = filterKeys,
// Check if record has "properties" field and use it instead of the root record
recordToProcess = if inputRecord = null then
null
else if Value.Is(inputRecord, type record) and Record.HasFields(inputRecord, {"properties"}) then
Record.Field(inputRecord, "properties")
else
inputRecord,
// Helper function to check if a field should be included
ShouldIncludeField = (fieldName as text) as logical =>
if currentfilterKeys = null then true
else List.Contains(currentfilterKeys, fieldName),
// Handle different input types
result = if recordToProcess = null then
[]
else if Value.Is(recordToProcess, type record) then
let
fieldNames = Record.FieldNames(recordToProcess),
// Process each field
processedFields = List.Accumulate(
fieldNames,
[FlattenedRecord = [], ExistingFieldsList = currentExistingFields],
(state, fieldName) =>
let
fieldValue = Record.Field(recordToProcess, fieldName),
newPath = if currentParentPath = "" then fieldName else currentParentPath & "." & fieldName,
// Skip if path is excluded
shouldProcess = not IsPathExcluded(newPath),
processResult = if not shouldProcess then
state
else
let
// Check if this is a name/value record
hasNameValue = Value.Is(fieldValue, type record) and
Record.HasFields(fieldValue, {"name", "value"}),
finalResult = if hasNameValue then
let
nameField = Record.Field(fieldValue, "name"),
valueField = Record.Field(fieldValue, "value"),
// Check if this name field should be included
shouldInclude = if nameField = null then false else ShouldIncludeField(nameField),
result = if shouldInclude and nameField <> null then
let
resolvedName = ResolveFieldName(nameField, currentParentPath, state[ExistingFieldsList]),
newRecord = Record.AddField(state[FlattenedRecord], resolvedName, valueField),
newFieldsList = state[ExistingFieldsList] & {resolvedName}
in
[FlattenedRecord = newRecord, ExistingFieldsList = newFieldsList]
else
state
in
result
else if fieldValue = null then
let
shouldInclude = ShouldIncludeField(fieldName),
result = if shouldInclude then
let
resolvedName = ResolveFieldName(fieldName, currentParentPath, state[ExistingFieldsList]),
newRecord = Record.AddField(state[FlattenedRecord], resolvedName, null),
newFieldsList = state[ExistingFieldsList] & {resolvedName}
in
[FlattenedRecord = newRecord, ExistingFieldsList = newFieldsList]
else
state
in
result
else if Value.Is(fieldValue, type record) then
let
// Skip empty records
fieldCount = Record.FieldCount(fieldValue),
recursiveResult = if fieldCount = 0 then
state
else
let
// Call the function through the passed reference
// IMPORTANT: Pass the current state's existing fields list
flattened = flattenFn(flattenFn, fieldValue, currentfilterKeys, newPath, state[ExistingFieldsList]),
// Get all field names from the flattened result
flattenedFieldNames = Record.FieldNames(flattened),
// Merge the flattened record with the current state
combinedRecord = flattened & state[FlattenedRecord],
// Update the existing fields list with ALL fields from both records
allFieldNames = List.Distinct(state[ExistingFieldsList] & flattenedFieldNames)
in
[FlattenedRecord = combinedRecord, ExistingFieldsList = allFieldNames]
in
recursiveResult
else if Value.Is(fieldValue, type list) then
let
listLength = List.Count(fieldValue),
// Skip empty lists
listResult = if listLength = 0 then
state
else
List.Accumulate(
List.Positions(fieldValue),
state,
(listState, index) =>
let
listItem = fieldValue{index},
indexSuffix = Text.From(index + 1), // 1-based indexing
listFieldName = fieldName & "." & indexSuffix,
listPath = if currentParentPath = "" then listFieldName else currentParentPath & "." & listFieldName,
itemResult = if Value.Is(listItem, type record) then
let
itemFieldCount = Record.FieldCount(listItem),
itemFlattened = if itemFieldCount = 0 then
listState
else
let
// Call the function through the passed reference
flattened = flattenFn(flattenFn, listItem, currentfilterKeys, listPath, listState[ExistingFieldsList]),
// Get all field names from the flattened result
flattenedFieldNames = Record.FieldNames(flattened),
// Merge the flattened record with the current state
combinedRecord = flattened & listState[FlattenedRecord],
// Update the existing fields list with ALL fields
allFieldNames = List.Distinct(listState[ExistingFieldsList] & flattenedFieldNames)
in
[FlattenedRecord = combinedRecord, ExistingFieldsList = allFieldNames]
in
itemFlattened
else
let
shouldInclude = ShouldIncludeField(listFieldName),
result = if shouldInclude then
let
resolvedName = ResolveFieldName(listFieldName, currentParentPath, listState[ExistingFieldsList]),
newRecord = Record.AddField(listState[FlattenedRecord], resolvedName, listItem),
newFieldsList = listState[ExistingFieldsList] & {resolvedName}
in
[FlattenedRecord = newRecord, ExistingFieldsList = newFieldsList]
else
listState
in
result
in
itemResult
)
in
listResult
else
// Handle primitive values
let
shouldInclude = ShouldIncludeField(fieldName),
result = if shouldInclude then
let
resolvedName = ResolveFieldName(fieldName, currentParentPath, state[ExistingFieldsList]),
newRecord = Record.AddField(state[FlattenedRecord], resolvedName, fieldValue),
newFieldsList = state[ExistingFieldsList] & {resolvedName}
in
[FlattenedRecord = newRecord, ExistingFieldsList = newFieldsList]
else
state
in
result
in
finalResult
in
processResult
)
in
processedFields[FlattenedRecord]
else
// If input is not a record, return it as is in a record wrapper
[Value = recordToProcess]
in
result,
// enhanced combine function that handles duplicates and filtering
SafeRecordCombine = (records as list) as record =>
List.Accumulate(
records,
[],
(state, current) =>
List.Accumulate(
Record.FieldNames(current),
state,
(innerState, fieldName) =>
if ShouldIncludeKey(fieldName) then
AddUniqueKey(innerState, fieldName, Record.Field(current, fieldName))
else
innerState
)
),
// helper function to process name-value objects
ProcessNameValueObject = (obj as record) as record =>
let
// Assert that the object has required fields
_ = if not (Record.HasFields(obj, {"name"}) and Record.HasFields(obj, {"value"})) then
error [
Reason = "Invalid Name-Value Object",
Message = "Object must have both 'name' and 'value' fields"
]
else
null,
BaseName = obj[name],
BaseValue = obj[value],
// only extract name and value if it should be included
Result = if ShouldIncludeKey(BaseName) then
Record.FromList({BaseValue}, {BaseName})
else
[]
in
Result,
// helper function to process direct key-value objects
ProcessDirectKeyValueObject = (obj as record) as record =>
let
// assert that input is a record
_ = if not Value.Is(obj, type record) then
error [
Reason = "Invalid Input Type",
Message = "Expected record for direct key-value processing"
]
else
null,
// extract all primitive key-value pairs
PrimitiveFields = List.Transform(
Record.FieldNames(obj),
(fieldName) =>
let
fieldValue = Record.Field(obj, fieldName)
in
if not Type.Is(Value.Type(fieldValue), type record) and ShouldIncludeKey(fieldName) then
Record.FromList({fieldValue}, {fieldName})
else
[]
),
// filter out empty records and combine using safe combine
FilteredFields = List.Select(PrimitiveFields, (rec) => Record.FieldCount(rec) > 0),
Result = SafeRecordCombine(FilteredFields)
in
Result,
// helper functions for type checking
HasDirectKeyValuePattern = (obj as record) as logical =>
List.AllTrue(
List.Transform(
Record.FieldValues(obj),
(value) => not Type.Is(Value.Type(value), type record)
)
),
IsNestedContainer = (obj as record) as logical =>
List.AnyTrue(
List.Transform(
Record.FieldValues(obj),
(value) => Type.Is(Value.Type(value), type record)
)
),
// main processing function with path tracking
ProcessField = (fieldName as text, fieldValue as any, currentPath as text) as record =>
let
fieldPath = if currentPath = "" then fieldName else currentPath & "." & fieldName
in
if IsExcludedPath(fieldPath) then
[]
else if Type.Is(Value.Type(fieldValue), type record) then
if Record.HasFields(fieldValue, {"name", "value"}) then
ProcessNameValueObject(fieldValue)
else if HasDirectKeyValuePattern(fieldValue) then
ProcessDirectKeyValueObject(fieldValue)
else if IsNestedContainer(fieldValue) then
// recursive call for nested containers
@ProcessRecord(fieldValue, fieldPath)
else
// unknown record type, skip
[]
else
// primitive value - check if should be included
if ShouldIncludeKey(fieldName) then
Record.FromList({fieldValue}, {fieldName})
else
[],
// process entire record recursively
ProcessRecord = (record as record, currentPath as text) as record =>
let
// assert that input is a record
_ = if not Value.Is(record, type record) then
error [
Reason = "Invalid Record Type",
Message = "Expected record for processing, but received: " & Text.From(Value.Type(record))
]
else
null,
ProcessedFields = List.Transform(
Record.FieldNames(record),
(fieldName) => ProcessField(fieldName, Record.Field(record, fieldName), currentPath)
),
FilteredFields = List.Select(ProcessedFields, (rec) => Record.FieldCount(rec) > 0),
CombinedRecord = SafeRecordCombine(FilteredFields)
in
CombinedRecord,
// assert that input is a record before processing
_ = if not Value.Is(ActualInput, type record) then
error [
Reason = "Invalid Input Type",
Message = "Input must be a record, but received: " & Text.From(Value.Type(ActualInput))
]
else
null,
// start processing from root
Result = ProcessRecord(ActualInput, "")
// Call the implementation with self-reference
result = FlattenRecordImpl(FlattenRecordImpl, inputRecord, filterKeys, parentPath, existingFields)
in
Result
result
@@ -1,5 +1,5 @@
(url as text) as list =>
try let
let
// Import required functions
GetModel = Extension.LoadFunction("GetModel.pqm"),
Parser = Extension.LoadFunction("Parser.pqm"),
@@ -24,57 +24,145 @@
Detail = [File = fileName, Error = e]
],
// Get required information
modelInfo = GetModel(url),
parsedUrl = Parser(url),
userInfo = GetUser(url),
apiKey = userInfo[Token],
userEmail = userInfo[UserEmail],
// get version from Speckle.pq - look GetVersion.pqm
connectorVersion = GetVersion(),
workspaceInfo = GetWorkspace(url),
// Prepare request data
requestData = Json.FromValue([
Url = url,
Server = parsedUrl[baseUrl],
Email = userEmail,
ProjectId = parsedUrl[projectId],
ObjectId = modelInfo[rootObjectId],
SourceApplication = modelInfo[sourceApplication],
Token = apiKey,
Version = connectorVersion,
VersionId = parsedUrl[versionId],
WorkspaceId = workspaceInfo[workspaceId],
WorkspaceName = workspaceInfo[workspaceName],
WorkspaceLogo = workspaceInfo[workspaceLogo],
CanHideBranding = workspaceInfo[canHideBranding]
]),
// 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,
// 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"},
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 = () =>
let
// Construct the direct object URL: {baseUrl}/objects/{projectId}/{rootObjectId}
objectUrl = Text.Combine({
parsedUrl[baseUrl],
"/objects/",
parsedUrl[projectId],
"/",
modelInfo[rootObjectId]
}),
// Download JSON directly from Speckle server
Response = Web.Contents(
objectUrl,
[
Headers = [
#"Authorization" = "Bearer " & apiKey,
#"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",
Detail = [
StatusCode = StatusCode,
ObjectUrl = objectUrl,
ProjectId = parsedUrl[projectId],
RootObjectId = modelInfo[rootObjectId]
]
]
in
JsonResponse,
// Check Desktop Service availability and choose approach
DesktopServiceAvailable = IsDesktopServiceAvailable(),
// 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)
FinalResult = if DesktopServiceAvailable then
UseDesktopService()
else
FallbackToDirectDownload()
in
JsonResponse
otherwise
error [
Reason = "Desktop Service Not Available",
Message = "Cannot connect to Speckle Desktop Service. Please ensure the Desktop Service is running and try again.",
Detail = "The Speckle Desktop Service must be running to load data from Speckle. Please start the Desktop Service application and refresh your data connection."
]
FinalResult
+9
View File
@@ -133,6 +133,15 @@
}
}
},
"dataLoading": {
"properties": {
"internalizeData": {
"type": {
"bool": true
}
}
}
},
"color": {
"properties": {
"enabled": {
+334 -12
View File
@@ -13,7 +13,8 @@
"@babel/runtime-corejs3": "^7.21.5",
"@headlessui/vue": "^1.7.13",
"@heroicons/vue": "^2.0.12",
"@speckle/objectloader": "^2.23.8",
"@speckle/objectloader": "^2.25.9",
"@speckle/objectloader2": "^2.25.9",
"@speckle/tailwind-theme": "2.23.2",
"@speckle/ui-components": "2.23.2",
"@speckle/viewer": "2.23.23",
@@ -2826,6 +2827,13 @@
"deprecated": "Use @eslint/object-schema instead",
"dev": true
},
"node_modules/@ioredis/commands": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.3.0.tgz",
"integrity": "sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==",
"license": "MIT",
"peer": true
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2979,6 +2987,90 @@
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
"dev": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true
},
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -3052,12 +3144,13 @@
"peer": true
},
"node_modules/@speckle/objectloader": {
"version": "2.23.23",
"resolved": "https://registry.npmjs.org/@speckle/objectloader/-/objectloader-2.23.23.tgz",
"integrity": "sha512-k0qxk5M0Q57h+fth6GQq8N7SjeJnWHxjDlMDYC56lOZkH8vI0Y2RHG33+DiBvC7iHnTIRuZc0SyTRrJ64Cuhrg==",
"version": "2.25.9",
"resolved": "https://registry.npmjs.org/@speckle/objectloader/-/objectloader-2.25.9.tgz",
"integrity": "sha512-ZSMinqrHm4Hx3x6dth2kfnJO2O1zI0i4E0eE3PS9hg1HAtCx9opIT/eKIzj5fYY/cUyfXxP00k7qXmZ3KAUl7w==",
"license": "Apache-2.0",
"dependencies": {
"@babel/core": "^7.17.9",
"@speckle/shared": "^2.23.23",
"@speckle/shared": "^2.25.9",
"core-js": "^3.21.1",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
@@ -3067,11 +3160,25 @@
"node": ">=18.0.0"
}
},
"node_modules/@speckle/shared": {
"version": "2.23.23",
"resolved": "https://registry.npmjs.org/@speckle/shared/-/shared-2.23.23.tgz",
"integrity": "sha512-5laonEcP7FsG5CPn/EXq0tpkA135Bxq3TndZ+OJGzuaY9L6ZHLtSURltZ6YpjMo6CHmAiFEVG62laP6UHwIL4w==",
"node_modules/@speckle/objectloader2": {
"version": "2.25.9",
"resolved": "https://registry.npmjs.org/@speckle/objectloader2/-/objectloader2-2.25.9.tgz",
"integrity": "sha512-fZYkVGBCNUcVMMZnIljmtqiyXpSlyWiuW+qbpfls5lX6P9ZLP6DC88AUjyJQ6cM9jZ6RGNk3/Sa+MeReTeZIvg==",
"license": "Apache-2.0",
"dependencies": {
"@speckle/shared": "^2.25.9"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@speckle/shared": {
"version": "2.25.9",
"resolved": "https://registry.npmjs.org/@speckle/shared/-/shared-2.25.9.tgz",
"integrity": "sha512-7hK6v55tSu8OTxza9UakettBpzlbyIWq17jtzjf3ZSnqqjiasayhG3O0/uuRI2GoeingoOqUMLvybatjDu4ang==",
"license": "Apache-2.0",
"dependencies": {
"dayjs": "^1.11.13",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"nanoid": "^5.1.5",
@@ -3083,6 +3190,7 @@
},
"peerDependencies": {
"@tiptap/core": "^2.0.0-beta.176",
"bull": "*",
"knex": "*",
"mixpanel": "^0.17.0",
"pino": "^8.7.0",
@@ -5271,6 +5379,38 @@
"integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==",
"dev": true
},
"node_modules/bull": {
"version": "4.16.5",
"resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz",
"integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"cron-parser": "^4.9.0",
"get-port": "^5.1.1",
"ioredis": "^5.3.2",
"lodash": "^4.17.21",
"msgpackr": "^1.11.2",
"semver": "^7.5.2",
"uuid": "^8.3.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/bull/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"license": "ISC",
"peer": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@@ -5526,6 +5666,16 @@
"node": ">=6"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -5845,6 +5995,19 @@
"license": "MIT",
"peer": true
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"luxon": "^3.2.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -6076,6 +6239,12 @@
"node": "*"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"license": "MIT"
},
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@@ -6198,6 +6367,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -6235,6 +6414,17 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
@@ -7741,6 +7931,19 @@
"node": ">=8.0.0"
}
},
"node_modules/get-port": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz",
"integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@@ -8485,6 +8688,31 @@
"node": ">= 0.10"
}
},
"node_modules/ioredis": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.7.0.tgz",
"integrity": "sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@ioredis/commands": "^1.3.0",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ipaddr.js": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
@@ -9262,8 +9490,14 @@
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"dev": true
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT",
"peer": true
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
@@ -9327,6 +9561,16 @@
"yallist": "^3.0.2"
}
},
"node_modules/luxon": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz",
"integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -9653,6 +9897,39 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/msgpackr": {
"version": "1.11.5",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz",
"integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==",
"license": "MIT",
"peer": true,
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
}
},
"node_modules/multicast-dns": {
"version": "7.2.5",
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
@@ -9773,6 +10050,22 @@
"node": ">= 6.13.0"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@@ -12547,6 +12840,29 @@
"node": ">=8"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"peer": true,
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@@ -13383,6 +13699,13 @@
"node": ">= 10.x"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT",
"peer": true
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -14486,7 +14809,6 @@
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"bin": {
"uuid": "dist/bin/uuid"
}
+2 -1
View File
@@ -17,7 +17,8 @@
"@babel/runtime-corejs3": "^7.21.5",
"@headlessui/vue": "^1.7.13",
"@heroicons/vue": "^2.0.12",
"@speckle/objectloader": "^2.23.8",
"@speckle/objectloader": "^2.25.9",
"@speckle/objectloader2": "^2.25.9",
"@speckle/tailwind-theme": "2.23.2",
"@speckle/ui-components": "2.23.2",
"@speckle/viewer": "2.23.23",
@@ -1,5 +1,5 @@
<template>
<div class="border">
<div>
<transition name="slide-fade">
<nav
v-show="!visualStore.isNavbarHidden"
@@ -0,0 +1,103 @@
import { useVisualStore } from '@src/store/visualStore'
import ObjectLoader from '@speckle/objectloader' // Default import for v1
interface SpeckleObject {
id: string
speckle_type?: string
[key: string]: any
}
export class SpeckleApiLoader {
private serverUrl: string
private token: string
private projectId: string
private headers: Record<string, string>
constructor(serverUrl: string, projectId: string, token: string) {
this.serverUrl = serverUrl.replace(/\/$/, '')
this.projectId = projectId
this.token = token
this.headers = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
async downloadObjectsWithChildren(objectId: string, onProgress?: (loaded: number, total: number) => void): Promise<SpeckleObject[]> {
const visualStore = useVisualStore()
visualStore.setLoadingProgress('Initializing object loader', 0)
console.log('Creating ObjectLoader v1 for Power BI environment')
// Create ObjectLoader v1 instance - use 'token' not 'authToken'
const loader = new ObjectLoader({
serverUrl: this.serverUrl,
streamId: this.projectId,
objectId: objectId,
token: this.token,
options: {
enableCaching: false, // Disable caching for Power BI environment
}
})
try {
// Get total count for progress tracking
const totalCount = await loader.getTotalObjectCount()
console.log(`Loading ${totalCount} objects using ObjectLoader v1`)
const objects: SpeckleObject[] = []
let loadedCount = 0
// Stream all objects using the async iterator
for await (const obj of loader.getObjectIterator()) {
objects.push(obj as SpeckleObject) // Type assertion since ObjectLoader v1 has different type
loadedCount++
// Update progress
if (onProgress) {
onProgress(loadedCount, totalCount)
}
const progress = totalCount > 0 ? loadedCount / totalCount : 0
visualStore.setLoadingProgress('🌍 Loading from Speckle', progress)
// Log progress every 100 objects
if (loadedCount % 100 === 0) {
console.log(`Loaded ${loadedCount}/${totalCount} objects`)
}
}
console.log(`Downloaded ${objects.length} objects using ObjectLoader v1`)
visualStore.setLoadingProgress('Download complete', 1)
return objects
} catch (error) {
console.error('Error loading objects:', error)
throw error
} finally {
// ObjectLoader v1 cleanup
if (loader.dispose) {
loader.dispose()
}
}
}
async downloadFromVersionId(versionId: string): Promise<SpeckleObject[]> {
// For version IDs, we can't avoid GraphQL entirely as we need to resolve the referenced object
// However, this method might not be used if we're getting object IDs directly from the data connector
throw new Error('Version ID downloads not supported with weak tokens. Use object IDs directly.')
}
async downloadMultipleModels(objectIds: string[]): Promise<SpeckleObject[][]> {
const allObjects: SpeckleObject[][] = []
for (const objectId of objectIds) {
const objects = await this.downloadObjectsWithChildren(objectId)
allObjects.push(objects)
}
return allObjects
}
}
@@ -0,0 +1,15 @@
import { formattingSettings as fs } from 'powerbi-visuals-utils-formattingmodel'
export class DataLoadingSettings extends fs.SimpleCard {
name = 'dataLoading'
displayName = 'Data Management'
public internalizeData = new fs.ToggleSwitch({
name: 'internalizeData',
displayName: 'Internalize Data',
description: 'When enabled, objects are downloaded and stored in the Power BI file for offline access. When disabled, objects are loaded directly from Speckle servers (online mode).',
value: false
})
slices: fs.Slice[] = [this.internalizeData]
}
@@ -1,17 +1,18 @@
import { formattingSettings as fs } from 'powerbi-visuals-utils-formattingmodel'
import { ColorSelectorSettings, ColorSettings } from 'src/settings/colorSettings'
import { ColorSettings } from 'src/settings/colorSettings'
import { CameraSettings } from 'src/settings/cameraSettings'
import { LightingSettings } from 'src/settings/lightingSettings'
import { DataLoadingSettings } from 'src/settings/dataLoadingSettings'
export class SpeckleVisualSettingsModel extends fs.Model {
// Building my visual formatting settings card
public color: ColorSettings = new ColorSettings()
public colorSelector: ColorSelectorSettings = new ColorSelectorSettings()
public dataLoading: DataLoadingSettings = new DataLoadingSettings()
// public camera: CameraSettings = new CameraSettings()
// public lighting: LightingSettings = new LightingSettings()
cards = [this.color]
cards = [this.color, this.dataLoading]
}
+107 -12
View File
@@ -3,8 +3,8 @@ import { Version } from '@src/composables/useUpdateConnector'
import { ColorBy, IViewerEvents } from '@src/plugins/viewer'
import { SpeckleVisualSettingsModel } from '@src/settings/visualSettingsModel'
import { SpeckleDataInput } from '@src/types'
import { zipModelObjects } from '@src/utils/compression'
import { ReceiveInfo } from '@src/utils/matrixViewUtils'
import { zipModelObjects } from '@src/utils/compression'
import { defineStore } from 'pinia'
import { Vector3 } from 'three'
import { computed, ref, shallowRef } from 'vue'
@@ -26,7 +26,10 @@ export const useVisualStore = defineStore('visualStore', () => {
const host = shallowRef<powerbi.extensibility.visual.IVisualHost>()
const formattingSettings = ref<SpeckleVisualSettingsModel>()
const loadingProgress = ref<LoadingProgress>(undefined)
const objectsFromStore = ref<object[]>(undefined)
const objectsFromStore = ref<object[][]>(undefined)
// State tracking for toggle reset prevention
const previousToggleState = ref<boolean | undefined>(undefined)
const postFileSaveSkipNeeded = ref<boolean>(false)
const postClickSkipNeeded = ref<boolean>(false)
@@ -83,7 +86,19 @@ export const useVisualStore = defineStore('visualStore', () => {
host.value = hostToSet
}
const setReceiveInfo = (newReceiveInfo: ReceiveInfo) => (receiveInfo.value = newReceiveInfo)
const setReceiveInfo = (newReceiveInfo: ReceiveInfo) => {
receiveInfo.value = newReceiveInfo
// Always save receiveInfo to file for credentials persistence (contains token and metadata)
// This ensures weak tokens are available even when desktop service is unavailable
if (formattingSettings.value?.dataLoading.internalizeData.value && objectsFromStore.value) {
// If internalize is ON and we have objects, save both objects and receiveInfo together
writeObjectsToFile(objectsFromStore.value)
} else {
// Otherwise just save receiveInfo alone (credentials only)
writeReceiveInfoToFile()
}
}
const setLatestAvailableVersion = (version: Version | null) => {
latestAvailableVersion.value = version
@@ -113,7 +128,7 @@ export const useVisualStore = defineStore('visualStore', () => {
}
}
const setObjectsFromStore = (newObjectsFromStore: object[]) => {
const setObjectsFromStore = (newObjectsFromStore: object[][]) => {
objectsFromStore.value = newObjectsFromStore
}
@@ -124,6 +139,23 @@ export const useVisualStore = defineStore('visualStore', () => {
}
}
const filterColorByIdsForSelection = (colorByIds: ColorBy[] | null | undefined, selectedIds: string[]): ColorBy[] => {
return colorByIds?.filter(colorGroup => {
const filteredObjectIds = colorGroup.objectIds.filter(objId =>
selectedIds.includes(objId)
)
if (filteredObjectIds.length > 0) {
return { ...colorGroup, objectIds: filteredObjectIds }
}
return false
}).map(colorGroup => ({
...colorGroup,
objectIds: colorGroup.objectIds.filter(objId =>
selectedIds.includes(objId)
)
})) || []
}
const clearLoadingProgress = () => {
loadingProgress.value = undefined
}
@@ -134,17 +166,24 @@ export const useVisualStore = defineStore('visualStore', () => {
}
const loadObjectsFromFile = async (objects: object[][]) => {
console.log('📁 loadObjectsFromFile called with:', objects.length, 'models')
const savedVersionObjectId = objects.map((o) => (o[0] as SpeckleObject).id).join(',')
lastLoadedRootObjectId.value = savedVersionObjectId
viewerReloadNeeded.value = false
console.log(`📦 Loading viewer from cached data with ${lastLoadedRootObjectId.value} id.`)
console.log('📁 About to call viewerEmit loadObjects...')
await viewerEmit.value('loadObjects', objects)
console.log('📁 viewerEmit loadObjects completed')
objectsFromStore.value = objects
isViewerObjectsLoaded.value = true
viewerReloadNeeded.value = false
setIsLoadingFromFile(false)
console.log('📁 loadObjectsFromFile completed successfully')
}
const setIsLoadingFromFile = (newValue: boolean) => (isLoadingFromFile.value = newValue)
/**
* Sets upcoming data input into store to be able to pass it through viewer by evaluating the data.
* @param newValue new data input that user dragged and dropped to the fields in visual
@@ -159,14 +198,24 @@ export const useVisualStore = defineStore('visualStore', () => {
await viewerEmit.value('loadObjects', dataInput.value.modelObjects)
viewerReloadNeeded.value = false
isViewerObjectsLoaded.value = true
setLoadingProgress('Storing objects into file', null)
writeObjectsToFile(dataInput.value.modelObjects)
// Store the model objects for potential internalization
if (dataInput.value.modelObjects && dataInput.value.modelObjects.length > 0) {
console.log('📦 Storing modelObjects in visualStore for internalization:', dataInput.value.modelObjects.length, 'models')
objectsFromStore.value = dataInput.value.modelObjects
}
// Note: Object internalization is now handled by toggle in visual.ts
loadingProgress.value = undefined
}
if (dataInput.value.selectedIds.length > 0) {
isFilterActive.value = true
viewerEmit.value('filterSelection', dataInput.value.selectedIds, isGhostActive.value, isZoomOnFilterActive.value)
// When filtering, only apply colors to the selected/isolated objects
const filteredColorByIds = filterColorByIdsForSelection(dataInput.value.colorByIds, dataInput.value.selectedIds)
viewerEmit.value('colorObjectsByGroup', filteredColorByIds)
} else {
isFilterActive.value = false
latestColorBy.value = dataInput.value.colorByIds
@@ -177,8 +226,9 @@ export const useVisualStore = defineStore('visualStore', () => {
// No object IDs provided - show all objects without any filtering
viewerEmit.value('unIsolateObjects')
}
// When not filtering, apply all colors including conditional formatting
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
}
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
}
const writeObjectsToFile = (modelObjects: object[][]) => {
@@ -192,6 +242,23 @@ export const useVisualStore = defineStore('visualStore', () => {
objectName: 'storedData',
properties: {
speckleObjects: compressedChunks,
receiveInfo: JSON.stringify(receiveInfo.value) // Keep receiveInfo in sync when storing objects
},
selector: null
}
]
})
}
const writeReceiveInfoToFile = () => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
host.value.persistProperties({
merge: [
{
objectName: 'storedData',
properties: {
receiveInfo: JSON.stringify(receiveInfo.value)
},
selector: null
@@ -312,6 +379,22 @@ export const useVisualStore = defineStore('visualStore', () => {
})
}
const writeDataLoadingModeToFile = (internalizeData: boolean) => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
host.value.persistProperties({
merge: [
{
objectName: 'dataLoading',
properties: {
internalizeData: internalizeData
},
selector: null
}
]
})
}
const writeCameraPositionToFile = (position: Vector3, target: Vector3) => {
// NOTE: need skipping the update function, it resets the viewer state unneccessarily.
postFileSaveSkipNeeded.value = true
@@ -338,7 +421,6 @@ export const useVisualStore = defineStore('visualStore', () => {
const clearDataInput = () => (dataInput.value = null)
const setIsLoadingFromFile = (newValue: boolean) => (isLoadingFromFile.value = newValue)
const setViewerReadyToLoad = (newValue: boolean) => (isViewerReadyToLoad.value = newValue)
@@ -392,6 +474,7 @@ export const useVisualStore = defineStore('visualStore', () => {
// No object IDs provided - show all objects without any filtering
viewerEmit.value('unIsolateObjects')
}
// When resetting filters, apply all colors including conditional formatting
if (latestColorBy.value !== null) {
viewerEmit.value('colorObjectsByGroup', latestColorBy.value)
}
@@ -417,6 +500,10 @@ export const useVisualStore = defineStore('visualStore', () => {
if (dataInput.value.selectedIds.length > 0) {
isFilterActive.value = true
viewerEmit.value('filterSelection', dataInput.value.selectedIds, isGhostActive.value, isZoomOnFilterActive.value)
// When filtering, only apply colors to the selected/isolated objects
const filteredColorByIds = filterColorByIdsForSelection(dataInput.value.colorByIds, dataInput.value.selectedIds)
viewerEmit.value('colorObjectsByGroup', filteredColorByIds)
} else {
isFilterActive.value = false
latestColorBy.value = dataInput.value.colorByIds
@@ -427,16 +514,21 @@ export const useVisualStore = defineStore('visualStore', () => {
// No object IDs provided - show all objects without any filtering
viewerEmit.value('unIsolateObjects')
}
// Restore color grouping for all objects when not filtering
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
}
// Restore color grouping
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
}
// Trigger host data refresh to synchronize with Power BI
host.value.refreshHostData()
}
// Toggle state tracking functions
const setPreviousToggleState = (state: boolean) => {
previousToggleState.value = state
}
return {
host,
receiveInfo,
@@ -468,6 +560,7 @@ export const useVisualStore = defineStore('visualStore', () => {
latestAvailableVersion,
isConnectorUpToDate,
commonError,
previousToggleState,
setCommonError,
setLatestAvailableVersion,
setIsOrthoProjection,
@@ -495,6 +588,7 @@ export const useVisualStore = defineStore('visualStore', () => {
writeCameraPositionToFile,
writeHideBrandingToFile,
writeNavbarVisibilityToFile,
writeDataLoadingModeToFile,
toggleBranding,
toggleNavbar,
setViewerEmitter,
@@ -507,6 +601,7 @@ export const useVisualStore = defineStore('visualStore', () => {
setIsLoadingFromFile,
resetFilters,
downloadLatestVersion,
handleObjectsLoadedComplete
handleObjectsLoadedComplete,
setPreviousToggleState
}
})
+199 -178
View File
@@ -11,6 +11,8 @@ import { FieldInputState, useVisualStore } from '@src/store/visualStore'
import { delay } from 'lodash'
import { getSlugFromHostAppNameAndVersion } from './hostAppSlug'
import { useUpdateConnector } from '@src/composables/useUpdateConnector'
import { SpeckleApiLoader } from '@src/loader/SpeckleApiLoader'
import { unzipModelObjects } from './compression'
export class AsyncPause {
private lastPauseTime = 0
@@ -129,7 +131,8 @@ function processObjectNode(
console.log('⚠️ HAS objects', color)
if (color) {
res.color = color
res.shouldColor = true
// Don't override shouldColor for conditional formatting - keep the selection state
// res.shouldColor = true // REMOVED: This was overriding cross-filter selection state
}
}
return res
@@ -158,40 +161,8 @@ export type ReceiveInfo = {
workspaceName?: string
canHideBranding: boolean
version?: string
}
export type PreGetObjects = {
modelExists: boolean
objectCount?: number
}
async function getPreGetObjects(commaSeparatedModelIds: string): Promise<PreGetObjects[]> {
const modelIds = (commaSeparatedModelIds as string).split(',')
const preGetObjects = []
for await (const id of modelIds) {
const res = await getPreGetObjectsForModel(id)
preGetObjects.push(res)
}
return preGetObjects
}
async function getPreGetObjectsForModel(id: string): Promise<PreGetObjects> {
try {
const preGetObjectsRes = await fetch(`http://localhost:29364/pre-get-objects/${id}`)
if (!preGetObjectsRes.body) {
console.log('No response body for pre get objects')
return {
modelExists: false,
objectCount: null
} as PreGetObjects
}
return (await preGetObjectsRes.json()) as PreGetObjects
} catch (error) {
console.log(error)
}
token: string
projectId?: string
}
async function getReceiveInfo(id) {
@@ -200,126 +171,40 @@ async function getReceiveInfo(id) {
const response = await fetch(`http://localhost:29364/user-info/${ids[0]}`)
if (!response.body) {
console.error('No response body')
return
return { desktopServiceError: true }
}
return await response.json()
} catch (error) {
console.log(error)
console.log("User infp couldn't retrieved from local server.")
console.log("User info couldn't retrieved from local server.")
return { desktopServiceError: true }
}
}
async function fetchStreamedData(commaSeparatedModelIds: string, totalObjectCount: number) {
const modelIds = (commaSeparatedModelIds as string).split(',')
async function fetchFromSpeckleApi(
objectIds: string,
serverUrl: string,
projectId: string,
token: string
): Promise<object[][]> {
const ids = objectIds.split(',')
const modelObjects = []
let loadedObjectCount = 0
for await (const id of modelIds) {
const objects = await fetchStreamedDataForModel(id, totalObjectCount, loadedObjectCount)
modelObjects.push(objects)
loadedObjectCount += objects.length
}
return modelObjects
}
async function fetchStreamedDataForModel(
id: string,
totalObjectCount: number,
loadedObjectCount: number
) {
console.log(loadedObjectCount, totalObjectCount)
try {
const visualStore = useVisualStore()
const response = await fetch(`http://localhost:29364/get-objects/${id}`)
if (!response.body) {
console.error('No response body')
return
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
const objects = []
let buffer = ''
const start = performance.now()
console.log('Streaming started...')
for await (const chunk of readStream(reader)) {
// chucks.push(chuck)
buffer += decoder.decode(chunk, { stream: true })
let boundary
while ((boundary = buffer.indexOf('\n')) !== -1) {
const jsonString = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 1)
try {
const obj = JSON.parse(jsonString)
objects.push(obj)
visualStore.setLoadingProgress(
'Loading objects from storage',
(objects.length + loadedObjectCount) / totalObjectCount
)
// console.log('Loading', (objects.length + loadedObjectCount) / totalObjectCount)
// console.log('Received object:', jsonObject)
} catch (e) {
console.error('Invalid JSON chunk:', jsonString)
}
}
}
for (const objectId of ids) {
try {
const obj = JSON.parse(buffer)
objects.push(obj)
// console.log('Received object:', jsonObject)
} catch (e) {
console.error('Invalid JSON chunk:', buffer)
}
const end = performance.now()
console.log(`Objects streamed in: ${(end - start) / 1000} s`)
const startObjectCleanup = performance.now()
// Skips first element
for (let i = 1; i < objects.length; i++) {
const obj = objects[i]
if (obj.speckle_type) {
if (obj.speckle_type.includes('Objects.Data.DataObject')) {
delete obj.properties
}
}
delete obj.__closure
}
const endObjectCleanup = performance.now()
console.log(`Objects cleaned up in: ${(endObjectCleanup - startObjectCleanup) / 1000} s`)
try {
const sizeInBytes = new TextEncoder().encode(JSON.stringify(objects)).length
const sizeInMB = sizeInBytes / (1024 * 1024)
console.log(`Size of objects: ${sizeInMB} MB`)
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`)
} catch (error) {
console.log("Can't calculate the size of the model")
console.log(error)
console.error(`Failed to download objects from Speckle:`, error)
throw error
}
return objects
} catch (error) {
console.log(error)
console.log("Objects couldn't retrieved from local server.")
} finally {
console.log('Streaming finished!')
}
}
async function* readStream(reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
yield value
}
return modelObjects
}
export async function processMatrixView(
@@ -327,7 +212,8 @@ export async function processMatrixView(
host: powerbi.extensibility.visual.IVisualHost,
hasColorFilter: boolean,
settings: SpeckleVisualSettingsModel,
onSelectionPair: (objId: string, selectionId: powerbi.extensibility.ISelectionId) => void
onSelectionPair: (objId: string, selectionId: powerbi.extensibility.ISelectionId) => void,
internalizedData?: string
): Promise<SpeckleDataInput> {
const visualStore = useVisualStore()
const objectIds = [],
@@ -340,10 +226,92 @@ export async function processMatrixView(
const localMatrixView = matrixView.rows.root.children
let id = null
if (hasColorFilter) {
id = localMatrixView[0].children[0].values[0].value as unknown as string
} else {
id = localMatrixView[0].values[0].value as unknown as string
// Safety check for matrix data structure
if (!localMatrixView || localMatrixView.length === 0) {
throw new Error('Matrix view has no data rows')
}
try {
if (hasColorFilter) {
if (
!localMatrixView[0].children ||
localMatrixView[0].children.length === 0 ||
!localMatrixView[0].children[0].values
) {
throw new Error('Matrix view structure is incomplete for color filter mode')
}
id = localMatrixView[0].children[0].values[0].value as unknown as string
} else {
if (!localMatrixView[0].values || !localMatrixView[0].values[0]) {
throw new Error('Matrix view structure is incomplete for normal mode')
}
id = localMatrixView[0].values[0].value as unknown as string
}
} catch (error) {
console.error('Error accessing matrix data:', error)
throw new Error(`Failed to extract root object ID from matrix: ${error.message}`)
}
// Check for internalized data but ONLY if it matches current matrix data
let internalizedModelObjects: object[][] | undefined = undefined
if (settings.dataLoading.internalizeData.value && internalizedData) {
console.log('📁 Checking internalized data in processMatrixView')
try {
internalizedModelObjects = unzipModelObjects(internalizedData)
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'
)
}
}
if (internalizedModelObjects && internalizedModelObjects.length > 0) {
// Set dummy receiveInfo to prevent UI errors
if (!visualStore.receiveInfo) {
visualStore.setReceiveInfo({
userEmail: 'offline@speckle.systems',
serverUrl: 'offline',
sourceApplication: 'PowerBI Offline',
workspaceId: 'offline',
workspaceName: 'Offline Workspace',
workspaceLogo: '',
version: '1.0.0',
canHideBranding: false,
token: 'offline',
projectId: 'offline'
})
}
// 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')
}
visualStore.lastLoadedRootObjectId = id // Set to current ID to skip API calls
} else {
console.error('📁 Failed to unzip internalized data')
}
} catch (error) {
console.error('📁 Error processing internalized data:', error)
}
}
// const id = localMatrixView[0].values[0].value as unknown as string
@@ -352,48 +320,96 @@ export async function processMatrixView(
let modelObjects: object[][] = undefined
if (visualStore.isLoadingFromFile) {
console.log('The data is loading from file, skipping the streaming it.')
}
if (visualStore.lastLoadedRootObjectId !== id && !visualStore.isLoadingFromFile) {
if (
visualStore.lastLoadedRootObjectId !== id &&
!visualStore.isLoadingFromFile &&
!internalizedModelObjects
) {
const start = performance.now()
const getPreGetObjectsRes: PreGetObjects[] = await getPreGetObjects(id)
if (getPreGetObjectsRes.some((preGetObjects) => preGetObjects.modelExists === false)) {
visualStore.setCommonError(
'Version Object ID is not found in storage. Please make sure you placed correct field or consider refreshing your data via data connector.'
)
visualStore.setViewerReadyToLoad(false)
return
}
// Get receive info from desktop service to populate visual store
const receiveInfo = await getReceiveInfo(id)
if (receiveInfo) {
let desktopServiceUnavailable = false
if (receiveInfo && !receiveInfo.desktopServiceError) {
visualStore.setReceiveInfo({
userEmail: receiveInfo.email,
serverUrl: receiveInfo.server,
sourceApplication: getSlugFromHostAppNameAndVersion(receiveInfo.sourceApplication),
workspaceId: receiveInfo.workspaceId,
workspaceName: receiveInfo.workspaceName,
workspaceLogo: receiveInfo.workspaceLogo,
version: receiveInfo.version,
canHideBranding: receiveInfo.canHideBranding
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`, receiveInfo)
console.log(`Receive info retrieved from desktop service - credentials loaded`)
} else {
desktopServiceUnavailable = true
console.log('Desktop service unavailable - cannot retrieve credentials')
}
const totalObjectCount = getPreGetObjectsRes.reduce((sum, obj) => {
return sum + (obj.objectCount ?? 0)
}, 0)
// 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
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.setViewerReadyToLoad(false)
return {
modelObjects: [],
objectIds: [],
selectedIds: [],
colorByIds: null,
objectTooltipData: new Map(),
isFromStore: false
}
}
visualStore.setViewerReadyToLoad(true)
// stream data
modelObjects = await fetchStreamedData(id, totalObjectCount)
console.log('Downloading objects directly from Speckle API...')
console.log(`Server: ${serverUrl}, Project: ${projectId}, Object: ${id}`)
try {
modelObjects = await fetchFromSpeckleApi(id, serverUrl, projectId, token)
console.log('Successfully downloaded from Speckle API')
// Debug: Check what we're passing to the viewer
if (modelObjects && modelObjects.length > 0 && modelObjects[0].length > 0) {
console.log('ModelObjects structure:', {
totalModels: modelObjects.length,
firstModelObjectCount: modelObjects[0].length,
firstObject: modelObjects[0][0]
})
}
} catch (error) {
console.error('Failed to download from Speckle API:', error)
visualStore.setCommonError(`Failed to download objects from Speckle: ${error.message}`)
visualStore.setViewerReadyToLoad(false)
return {
modelObjects: [],
objectIds: [],
selectedIds: [],
colorByIds: null,
objectTooltipData: new Map(),
isFromStore: false
}
}
visualStore.setViewerReloadNeeded() // they should be marked as deferred action bc of update function complexity.
visualStore.setLoadingProgress('Loading objects into viewer', null)
visualStore.setLoadingProgress('🌍 Loading objects into viewer', null)
console.log(`🚀 Upload is completed in ${(performance.now() - start) / 1000} s!`)
}
@@ -459,6 +475,7 @@ export async function processMatrixView(
localMatrixView.forEach((obj) => {
const processedObjectIdLevels = processObjectIdLevel(obj, host, matrixView)
// Apply conditional formatting color if present, regardless of selection state
if (processedObjectIdLevels.color) {
let group = colorByIds.find((g) => g.color === processedObjectIdLevels.color)
if (!group) {
@@ -468,7 +485,11 @@ export async function processMatrixView(
}
colorByIds.push(group)
}
// Always add to color group if color is specified (conditional formatting)
group.objectIds.push(processedObjectIdLevels.id)
} else if (processedObjectIdLevels.shouldColor) {
// Only use shouldColor flag when there's no conditional formatting
// This preserves the original cross-filter coloring behavior
}
objectIds.push(processedObjectIdLevels.id)
@@ -538,11 +559,11 @@ export async function processMatrixView(
previousPalette = host.colorPalette['colorPalette']
return {
modelObjects,
modelObjects: internalizedModelObjects || modelObjects, // Use internalized data if available
objectIds,
selectedIds,
colorByIds: colorByIds.length > 0 ? colorByIds : null,
objectTooltipData,
isFromStore: false
isFromStore: !!internalizedModelObjects // true if loaded from internalized data
}
}
+208 -25
View File
@@ -10,6 +10,7 @@ import { selectionHandlerKey, tooltipHandlerKey } from 'src/injectionKeys'
import { SpeckleDataInput } from './types'
import { processMatrixView, ReceiveInfo, validateMatrixView } from './utils/matrixViewUtils'
import { SpeckleVisualSettingsModel } from './settings/visualSettingsModel'
import { unzipModelObjects } from './utils/compression'
import TooltipHandler from './handlers/tooltipHandler'
import SelectionHandler from './handlers/selectionHandler'
@@ -21,7 +22,6 @@ import ITooltipService = powerbi.extensibility.ITooltipService
import { pinia } from './plugins/pinia'
import { useVisualStore } from './store/visualStore'
import { unzipModelObjects } from './utils/compression'
// noinspection JSUnusedGlobalSymbols
export class Visual implements IVisual {
@@ -88,13 +88,43 @@ export class Visual implements IVisual {
// @ts-ignore
console.log('⤴️ Update type 👉', powerbi.VisualUpdateType[options.type])
this.formattingSettings = this.formattingSettingsService.populateFormattingSettingsModel(
SpeckleVisualSettingsModel,
options.dataViews[0]
)
visualStore.setFormattingSettings(this.formattingSettings)
console.log('Selector colors', this.formattingSettings.colorSelector)
console.log(
'Data Loading - Internalize Data:',
this.formattingSettings.dataLoading.internalizeData.value
)
// Handle toggle state changes
const currentToggleState = this.formattingSettings.dataLoading.internalizeData.value
const previousToggleState = visualStore.previousToggleState
// Detect user toggle changes
if (previousToggleState !== undefined && currentToggleState !== previousToggleState) {
console.log('🔄 User changed toggle from', previousToggleState, 'to', currentToggleState)
if (currentToggleState) {
// Toggle switched ON - internalize via streaming
if (visualStore.isViewerObjectsLoaded && visualStore.lastLoadedRootObjectId) {
console.log('📁 Toggle ON - starting internalization')
await this.internalizeCurrentViewerData()
} else {
console.log('📁 Toggle ON - no active session to internalize')
}
} else {
// Toggle switched OFF - remove internalized data
console.log('🗑️ Toggle OFF - removing internalized data')
this.removeInternalizedData()
}
}
// CRITICAL: Always update the previous state for next comparison
visualStore.setPreviousToggleState(currentToggleState)
try {
const matrixView = options.dataViews[0].matrix
@@ -114,16 +144,10 @@ export class Visual implements IVisual {
return
case powerbi.VisualUpdateType.Data:
try {
// read saved data from file if any
if (
!visualStore.isViewerObjectsLoaded &&
this.isFirstViewerLoad &&
options.dataViews[0].metadata.objects
) {
const chunks = options.dataViews[0].metadata.objects.storedData
?.speckleObjects as string
const objectsFromFile = unzipModelObjects(chunks)
// read saved settings from file if any
console.log('🔍 Checking for other saved settings:')
if (!visualStore.isViewerObjectsLoaded && options.dataViews[0].metadata.objects) {
if (options.dataViews[0].metadata.objects.viewMode?.defaultViewMode as string) {
console.log(
`Default View Mode: ${
@@ -198,7 +222,9 @@ export class Visual implements IVisual {
if (camera && 'zoomOnFilter' in camera) {
console.log(
`Zoom on filter?: ${options.dataViews[0].metadata.objects.camera?.zoomOnFilter as boolean}`
`Zoom on filter?: ${
options.dataViews[0].metadata.objects.camera?.zoomOnFilter as boolean
}`
)
visualStore.setIsZoomOnFilterActive(
@@ -206,31 +232,59 @@ export class Visual implements IVisual {
)
}
// get receive info from file for mixpanel
// Log persisted data loading setting but don't force sync
if (
options.dataViews[0].metadata.objects.dataLoading?.internalizeData !== undefined
) {
console.log(
`Stored Data Loading - Internalize Data: ${
options.dataViews[0].metadata.objects.dataLoading?.internalizeData as boolean
}`
)
}
// get receive info from file for persistence
try {
const receiveInfoFromFile = JSON.parse(
options.dataViews[0].metadata.objects.storedData?.receiveInfo as string
) as ReceiveInfo
visualStore.setReceiveInfo(receiveInfoFromFile)
// Don't call setReceiveInfo here as it would trigger another save
visualStore.receiveInfo = receiveInfoFromFile
} catch (error) {
console.warn(error)
console.log('missing mixpanel info')
}
const savedVersionObjectId = objectsFromFile.map((o) => o[0].id).join(',')
if (visualStore.lastLoadedRootObjectId !== savedVersionObjectId) {
this.tryReadFromFile(objectsFromFile, visualStore)
console.log('missing stored receive info')
}
}
// Check for internalized data
const internalizedData = options.dataViews[0].metadata.objects?.storedData
?.speckleObjects as string
const input = await processMatrixView(
matrixView,
this.host,
validationResult.colorBy,
this.formattingSettings,
(obj, id) => this.selectionHandler.set(obj, id)
(obj, id) => this.selectionHandler.set(obj, id),
internalizedData
)
this.updateViewer(input)
// Auto-internalize new API data if toggle is ON and this is fresh data (not from store)
// Imagine that user has a visual and select internalizing data and changes the data source
// This will automatically internalize the new data
if (
this.formattingSettings.dataLoading.internalizeData.value &&
input.modelObjects &&
input.modelObjects.length > 0 &&
!input.isFromStore
) {
console.log('📦 Auto-internalizing new API data since toggle is ON')
// Trigger internalization after objects are loaded
setTimeout(() => {
this.internalizeCurrentViewerData()
}, 2000) // avoid a race condition (i know)
}
} catch (error) {
console.error('Data update error', error ?? 'Unknown')
}
@@ -258,9 +312,8 @@ export class Visual implements IVisual {
}
public getFormattingModel(): powerbi.visuals.FormattingModel {
console.log('Showing Formatting settings', this.formattingSettings)
console.log('🎨 getFormattingModel called')
const model = this.formattingSettingsService.buildFormattingModel(this.formattingSettings)
console.log('Formatting model was created', model)
return model
}
@@ -276,14 +329,13 @@ export class Visual implements IVisual {
// we should give some time to Vue to render ViewerWrapper component to be able to have proper emitter setup. Happiness level 6/10
setTimeout(() => {
visualStore.setDataInput(input)
// visualStore.writeObjectsToFile(input.objects)
}, 250)
}
}
private tryReadFromFile(objectsFromFile: object[][], visualStore) {
visualStore.setViewerReadyToLoad(true)
visualStore.setIsLoadingFromFile(true) // to block unnecessary streaming data if bg service is running
visualStore.setIsLoadingFromFile(true)
setTimeout(() => {
visualStore.loadObjectsFromFile(objectsFromFile)
this.isFirstViewerLoad = false
@@ -291,6 +343,137 @@ export class Visual implements IVisual {
console.log(`${objectsFromFile.length} objects retrieved from persistent properties!`)
}
private async internalizeCurrentViewerData() {
const visualStore = useVisualStore()
// Get the current root object ID from the last loaded data
if (!visualStore.lastLoadedRootObjectId) {
console.log('📁 No root object ID to internalize')
return
}
try {
console.log('📁 Starting internalization via desktop service streaming...')
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
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 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')
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
const cleanedObj = { ...obj }
// Remove unnecessary properties
if (cleanedObj.speckle_type?.includes('Objects.Data.DataObject')) {
delete cleanedObj.properties
}
delete cleanedObj.__closure
return cleanedObj
})
console.log(`📁 Cleaned objects: ${cleanedObjects.length} total`)
// Wrap in array format expected by viewer (object[][])
const modelObjectsArray = [cleanedObjects]
// Use existing writeObjectsToFile method from visualStore
visualStore.writeObjectsToFile(modelObjectsArray)
// Clear loading message immediately when done
visualStore.clearLoadingProgress()
console.log('📁 Successfully internalized data via desktop service!')
} catch (error) {
console.error('📁 Failed to internalize via desktop service:', error)
// Clear loading message immediately on error
visualStore.clearLoadingProgress()
}
}
private removeInternalizedData() {
const visualStore = useVisualStore()
try {
// Clear stored data from PowerBI file
this.host.persistProperties({
merge: [
{
objectName: 'storedData',
properties: {
speckleObjects: null,
receiveInfo: null
},
selector: null
}
]
})
console.log('🗑️ Successfully removed internalized data from file!')
} catch (error) {
console.error('🗑️ Failed to remove internalized data:', error)
}
}
public async destroy() {
await this.clear()
}