Compare commits
253 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9fd34831c | |||
| dbac5c013b | |||
| a73e832816 | |||
| 0b55013a84 | |||
| baa723287b | |||
| 0976597db3 | |||
| 40536a565f | |||
| 34115d9a5d | |||
| 74ac3e3990 | |||
| f9b5e250d8 | |||
| 0befca0200 | |||
| 1a74336e27 | |||
| 9f9b31d9ba | |||
| df3ad118e1 | |||
| ec634be352 | |||
| 0a4ae9340a | |||
| 92bcf4b5c0 | |||
| 2a22bbf0af | |||
| 7b5e5397b6 | |||
| 24eeb44ff7 | |||
| b1f16c4005 | |||
| 2307d87735 | |||
| b80624396d | |||
| 098ef3d112 | |||
| 94fdc7a2c3 | |||
| 525857bd26 | |||
| 959bcaa671 | |||
| 04b3aef829 | |||
| 318dc6dbbe | |||
| 20577a1fdb | |||
| e74bad829e | |||
| dda04e49c2 | |||
| 97983fb8aa | |||
| 1cac02ae61 | |||
| 0a5001987e | |||
| 5ffb3ea1dd | |||
| 3461c48b11 | |||
| 220946a611 | |||
| 53e4cda456 | |||
| 4ca0ae0978 | |||
| 685a137531 | |||
| 78af91f38a | |||
| 108a406bd5 | |||
| d7ede2edcf | |||
| a25d635ca1 | |||
| 5a9add6d76 | |||
| 89c8005dee | |||
| a384370652 | |||
| 5ec90095f0 | |||
| 20fad26fef | |||
| 03215f79c4 | |||
| 6d17377ca2 | |||
| 256abaed0c | |||
| 26409b4ea6 | |||
| 865c4c1608 | |||
| 67836c2a7f | |||
| 95d819f7f3 | |||
| dee3ee6c4d | |||
| 7ed612ec14 | |||
| 4bd7af4c31 | |||
| 3ed2e977df | |||
| 788fa1c532 | |||
| bafb7df6ed | |||
| be4e4df983 | |||
| b4830c80ab | |||
| a2d97facc5 | |||
| aea344a46a | |||
| 13aa65bc2e | |||
| 0a307c28e0 | |||
| e0f4a4c02c | |||
| 29773f9492 | |||
| 8f67ef4c84 | |||
| 2c5f192403 | |||
| 0c58789dd6 | |||
| 82acce2abb | |||
| d83472c30b | |||
| 634df47a25 | |||
| 9ad59bf1d3 | |||
| ffc0d8ef5e | |||
| 94c80857a0 | |||
| c8d858d575 | |||
| 36b9787b66 | |||
| bde7a42c44 | |||
| 1040f4622d | |||
| 91e799d006 | |||
| 8694666874 | |||
| fa6ad8ec40 | |||
| 8e60249291 | |||
| 68d6bf3d55 | |||
| 2a8925c8ef | |||
| f9b3d3db52 | |||
| bfd0c33373 | |||
| d155a4b165 | |||
| 2e9ece856f | |||
| 808e288848 | |||
| 701116c66c | |||
| e73d392013 | |||
| dd7f3fe95d | |||
| fdcc1f2cef | |||
| 5e2f108e49 | |||
| df334e95a2 | |||
| ce733d1ced | |||
| 8345258990 | |||
| dbd0f2f9ce | |||
| c9b4155660 | |||
| c4d094d722 | |||
| 5b003b182b | |||
| 7eabd47f6d | |||
| 822b999be9 | |||
| 2407b8758a | |||
| d6f5e65bd7 | |||
| 183cc36654 | |||
| a740272585 | |||
| 72128a9f4e | |||
| 677c663ef3 | |||
| a077857c66 | |||
| 5897a286bc | |||
| 5b49fb2a9a | |||
| 424404dd11 | |||
| 31312522a7 | |||
| 932198dccf | |||
| 3770502ca4 | |||
| 93e8fcdd9d | |||
| 370052b2be | |||
| aa4a137a0d | |||
| 4acdf30734 | |||
| b531446acd | |||
| de1b2ca39c | |||
| 5642da1e57 | |||
| 276d0c3a76 | |||
| 2d92b85687 | |||
| 82bd109b85 | |||
| 38fb5f7c26 | |||
| dee021f0ef | |||
| 620bd22387 | |||
| d364e096a8 | |||
| 8e03bcf201 | |||
| 1825a5ad4c | |||
| a626cdd45d | |||
| 91871f8615 | |||
| 2da1602986 | |||
| a0372e1970 | |||
| 98f10bb344 | |||
| ee11e47af3 | |||
| f57697d929 | |||
| ac0db18d24 | |||
| 85f8f72335 | |||
| 24d26dc49f | |||
| 1fff59de85 | |||
| 771e34f6b5 | |||
| f0b072d210 | |||
| 97a72c966a | |||
| 058c846770 | |||
| 3bb69f7aa8 | |||
| c1dac7e663 | |||
| 056815dad8 | |||
| 929f06dbb7 | |||
| 8cdfa97f41 | |||
| 28e9c43e5e | |||
| 380c60281b | |||
| c4f7c6082e | |||
| 51a5261f1d | |||
| 5f6a032bf8 | |||
| 8e1fe78af3 | |||
| 6dffa7dfa3 | |||
| f61fae4284 | |||
| f97946a426 | |||
| 8949917f21 | |||
| 0a0abd70df | |||
| 19c9252a4a | |||
| e50b04b305 | |||
| 5cd0fe2b12 | |||
| 59dd7ee9fc | |||
| a6e7ed86d9 | |||
| 573e70df02 | |||
| 52f406f3ac | |||
| fbe34d43b1 | |||
| 5ad8139a4c | |||
| ba43724443 | |||
| 8a34685b00 | |||
| e9c2293736 | |||
| d27adb243f | |||
| e55a382d73 | |||
| 00af5b5abb | |||
| cba5ccf254 | |||
| ae3c480f02 | |||
| 0b716eb2cd | |||
| a88f3323b1 | |||
| 7b06aef1f8 | |||
| a45a67ed38 | |||
| efd07d4ffb | |||
| 33116083e8 | |||
| 12f548bd76 | |||
| 8b9c4b1cf2 | |||
| deb34e9262 | |||
| 4a4a7d70c0 | |||
| 4e1ce9ce71 | |||
| c9c1820e4d | |||
| 62110739fb | |||
| d5527c79b1 | |||
| 79ba5cd365 | |||
| 17c857d138 | |||
| f0538c94c3 | |||
| b1f2c741ad | |||
| 64dc73411a | |||
| 8816430f51 | |||
| d5e8cd8a90 | |||
| 3bc82f6b9e | |||
| dc4f4c19f4 | |||
| 15ebfab50b | |||
| 10cc50a5b8 | |||
| 049fab417f | |||
| efe1c7ea0f | |||
| 7d7f3b04c5 | |||
| 8e03d4464e | |||
| 41161a8739 | |||
| 436d13475d | |||
| 6618e5654b | |||
| ca34cd3adc | |||
| 5168516109 | |||
| 2fa69873d1 | |||
| 4f15db5d9c | |||
| e233aec607 | |||
| 52f4325619 | |||
| 6e92c857a7 | |||
| 8edc01b7d6 | |||
| 6d5f638895 | |||
| ccecf7cb2b | |||
| 156c3a5c3a | |||
| 887dbb2344 | |||
| 556196a45f | |||
| 1bf6e76252 | |||
| b26801ef8a | |||
| 74ab91be3b | |||
| 920b346175 | |||
| 9c42f06ea4 | |||
| 6af64d8499 | |||
| 46ff9acd64 | |||
| 8031c7da4f | |||
| 08554fc864 | |||
| 9168174747 | |||
| fd9054a387 | |||
| 84efce792b | |||
| 0bc4da1864 | |||
| d7ea4f217f | |||
| 3971adafd6 | |||
| 1392070b31 | |||
| 85b9e88fc1 | |||
| 92fc894d4b | |||
| b033cfa82b | |||
| 86cba3ce2b | |||
| c752487f9f | |||
| 95f51b0d32 |
@@ -1,73 +1,16 @@
|
||||
# Use the latest 2.1 version of CircleCI pipeline process engine.
|
||||
# See: https://circleci.com/docs/2.0/configuration-reference
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
win: circleci/windows@5.0
|
||||
|
||||
# Define the jobs we want to run for this project
|
||||
jobs:
|
||||
build-connector:
|
||||
executor:
|
||||
name: win/default
|
||||
shell: powershell.exe
|
||||
steps:
|
||||
- checkout
|
||||
- run:
|
||||
name: "Set connector internal version"
|
||||
command: |
|
||||
$env:VERSION = if([string]::IsNullOrEmpty($env:CIRCLE_TAG)) { "2.0.0.$($env:WORKFLOW_NUM)" } else { $env:CIRCLE_TAG }
|
||||
(Get-Content ./Speckle.pq).replace('[Version = "2.0.0"]', '[Version = "'+$($env:VERSION)+'"]') | Set-Content ./Speckle.pq
|
||||
- run:
|
||||
name: "Build Data Connector"
|
||||
command: "msbuild Speckle.proj /restore /consoleloggerparameters:NoSummary /property:GenerateFullPaths=true"
|
||||
- run:
|
||||
name: Create Innosetup signing cert
|
||||
command: |
|
||||
echo $env:PFX_B64 > "tools\AEC Systems Ltd.txt"
|
||||
certutil -decode "tools\AEC Systems Ltd.txt" "tools\AEC Systems Ltd.pfx"
|
||||
- run:
|
||||
name: Create Signed PFX file
|
||||
command: .\tools\MakePQX\MakePQX.exe pack -mz bin/Speckle.mez -t bin/Speckle.pqx -c "tools\AEC Systems Ltd.pfx" -p $env:PFX_PSW
|
||||
- run:
|
||||
name: Build Installer
|
||||
command: tools\InnoSetup\ISCC.exe tools\powerbi.iss /Sbyparam=$p
|
||||
shell: cmd.exe #does not work in powershell
|
||||
- store_artifacts:
|
||||
path: ./bin
|
||||
- persist_to_workspace:
|
||||
root: ./
|
||||
paths:
|
||||
- bin/*
|
||||
deploy-connector:
|
||||
docker:
|
||||
- image: cibuilds/github:0.10
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: ./
|
||||
- run:
|
||||
name: "Publish Release on GitHub"
|
||||
command: |
|
||||
ghr -t ${GH_TOKEN} -u ${CIRCLE_PROJECT_USERNAME} -r ${CIRCLE_PROJECT_REPONAME} -c ${CIRCLE_SHA1} ${CIRCLE_TAG} ./bin/
|
||||
workflows:
|
||||
build:
|
||||
docker:
|
||||
- image: cimg/base:2023.03
|
||||
steps:
|
||||
- run: echo "so long and thanks for all the fish"
|
||||
|
||||
# Orchestrate our job run sequence
|
||||
workflows:
|
||||
build_and_test:
|
||||
when: false
|
||||
jobs:
|
||||
- build-connector:
|
||||
context: innosetup
|
||||
deploy:
|
||||
jobs:
|
||||
- build-connector:
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/ # For testing only: /ci\/.*/
|
||||
tags:
|
||||
only: /^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-\w{1,10})?$/
|
||||
context: innosetup
|
||||
- deploy-connector:
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/ # For testing only: /ci\/.*/
|
||||
tags:
|
||||
only: /^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-\w{1,10})?$/
|
||||
requires:
|
||||
- build-connector
|
||||
context: github-dev-bot
|
||||
- build
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
name: Build and deploy Connector and Visual
|
||||
on:
|
||||
push:
|
||||
branches: ["installer-test/**"]
|
||||
tags: ["v3.*.*"] # Manual delivery on every 3.x tag
|
||||
jobs:
|
||||
build-connector:
|
||||
runs-on: windows-latest
|
||||
outputs:
|
||||
semver: ${{ steps.set-version.outputs.semver }}
|
||||
file-version: ${{ steps.set-version.outputs.file-version }}
|
||||
env:
|
||||
CertFile: "./speckle.pfx"
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- id: set-version
|
||||
name: Set version to output
|
||||
shell: bash
|
||||
run: |
|
||||
TAG=${{ github.ref_name }}
|
||||
if [[ "${{ github.ref }}" != refs/tags/* ]]; then
|
||||
TAG="v3.0.99"
|
||||
fi
|
||||
SEMVER="${TAG#v}"
|
||||
FILE_VERSION=$(echo "$TAG" | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
|
||||
FILE_VERSION="$FILE_VERSION.${{ github.run_number }}"
|
||||
|
||||
echo "semver=$SEMVER" >> "$GITHUB_OUTPUT"
|
||||
echo "file-version=$FILE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo $SEMVER
|
||||
echo $FILE_VERSION
|
||||
|
||||
- name: Set connector version
|
||||
run: |
|
||||
python patch_version.py ${{steps.set-version.outputs.file-version}}
|
||||
|
||||
- name: Setup MSBuild
|
||||
uses: microsoft/setup-msbuild@v2
|
||||
|
||||
- name: Build Data Connector
|
||||
working-directory: src/powerbi-data-connector
|
||||
run: |
|
||||
msbuild Speckle.proj /restore /consoleloggerparameters:NoSummary /property:GenerateFullPaths=true
|
||||
|
||||
- name: Setup Self-sign certificate
|
||||
run: |
|
||||
echo "${{ secrets.SELF_CERT_FILE_B64 }}" > "certificate.txt"
|
||||
certutil -decode certificate.txt ${{ env.CertFile }}
|
||||
|
||||
- name: Create PQX file
|
||||
run: |
|
||||
.\tools\MakePQX\MakePQX.exe pack --mez src/powerbi-data-connector/bin/Speckle.mez --target src/powerbi-data-connector/bin/Speckle.pqx --certificate ${{env.CertFile}} --password ${{secrets.SELF_CERT_PASSWORD}}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: powerbi-connector
|
||||
path: src/powerbi-data-connector/bin/Speckle.pqx
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-visual:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- id: set-version
|
||||
name: Set version to output
|
||||
shell: bash
|
||||
run: |
|
||||
TAG=${{ github.ref_name }}
|
||||
if [[ "${{ github.ref }}" != refs/tags/* ]]; then
|
||||
TAG="v3.0.99"
|
||||
fi
|
||||
SEMVER="${TAG#v}"
|
||||
FILE_VERSION=$(echo "$TAG" | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
|
||||
FILE_VERSION="$FILE_VERSION.${{ github.run_number }}"
|
||||
|
||||
echo "semver=$SEMVER" >> "$GITHUB_OUTPUT"
|
||||
echo "file-version=$FILE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo $SEMVER
|
||||
echo $FILE_VERSION
|
||||
|
||||
- run: npm ci
|
||||
working-directory: src/powerbi-visual
|
||||
- run: npm version ${{steps.set-version.outputs.semver}} --allow-same-version
|
||||
working-directory: src/powerbi-visual
|
||||
- run: npm run build
|
||||
working-directory: src/powerbi-visual
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: powerbi-visual
|
||||
path: src/powerbi-visual/dist/*.pbiviz
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
deploy-installers:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-connector
|
||||
- build-visual
|
||||
env:
|
||||
IS_TAG_BUILD: ${{ github.ref_type == 'tag' }}
|
||||
steps:
|
||||
- name: download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: powerbi-connector
|
||||
path: artifacts/
|
||||
- name: download artifacts visual
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: powerbi-visual
|
||||
path: artifacts/
|
||||
- name: Zip artifacts
|
||||
run: |
|
||||
cd artifacts && zip -r ../powerbi.zip .
|
||||
- name: upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: output-${{needs.build-connector.outputs.semver}}
|
||||
path: powerbi.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
- name: 🔫 Trigger Build Installer(s)
|
||||
uses: the-actions-org/workflow-dispatch@v4.0.0
|
||||
with:
|
||||
workflow: Build Installers
|
||||
repo: specklesystems/connector-installers
|
||||
token: ${{ secrets.CONNECTORS_GH_TOKEN }}
|
||||
inputs: '{
|
||||
"run_id": "${{ github.run_id }}",
|
||||
"semver": "${{ needs.build-connector.outputs.semver }}",
|
||||
"file_version": "${{ needs.build-connector.outputs.file-version }}",
|
||||
"repo": "${{ github.repository }}",
|
||||
"is_public_release": ${{ env.IS_TAG_BUILD }}
|
||||
}'
|
||||
ref: main
|
||||
wait-for-completion: true
|
||||
wait-for-completion-interval: 10s
|
||||
wait-for-completion-timeout: 10m
|
||||
display-workflow-run-url: true
|
||||
display-workflow-run-url-interval: 10s
|
||||
|
||||
- uses: geekyeggo/delete-artifact@v5
|
||||
with:
|
||||
name: output-*
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Test Build Connector and Visual
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
build-connector:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup MSBuild
|
||||
uses: microsoft/setup-msbuild@v2
|
||||
|
||||
- name: Build Data Connector
|
||||
working-directory: src/powerbi-data-connector
|
||||
run: |
|
||||
msbuild Speckle.proj /restore /consoleloggerparameters:NoSummary /property:GenerateFullPaths=true
|
||||
|
||||
build-visual:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- run: npm ci
|
||||
working-directory: src/powerbi-visual
|
||||
- run: npm run build
|
||||
working-directory: src/powerbi-visual
|
||||
@@ -334,3 +334,13 @@ ASALocalRun/
|
||||
.localhistory/
|
||||
|
||||
**/.DS_Store
|
||||
|
||||
**/dist/
|
||||
**/.tmp/
|
||||
**/webpack.statistics.*.html
|
||||
**/webpack.statistics.html
|
||||
**/Thumbs.db
|
||||
installer/
|
||||
|
||||
localhost.pem
|
||||
localhost-key.pem
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-dotnettools.csharp",
|
||||
"powerquery.vscode-powerquery-sdk"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"powerquery.general.mode": "SDK",
|
||||
"powerquery.sdk.defaultExtension": "${workspaceFolder}\\bin\\Speckle.mez",
|
||||
"powerquery.sdk.defaultQueryFile": "${workspaceFolder}\\Speckle.query.pq"
|
||||
}
|
||||
@@ -1,17 +1,112 @@
|
||||
<h1 align="center">
|
||||
<<h1 align="center">
|
||||
<img src="https://user-images.githubusercontent.com/2679513/131189167-18ea5fe1-c578-47f6-9785-3748178e4312.png" width="150px"/><br/>
|
||||
Speckle | PowerBI
|
||||
Speckle | Power BI
|
||||
</h1>
|
||||
|
||||
<p align="center"><a href="https://twitter.com/SpeckleSystems"><img src="https://img.shields.io/twitter/follow/SpeckleSystems?style=social" alt="Twitter Follow"></a> <a href="https://speckle.community"><img src="https://img.shields.io/discourse/users?server=https%3A%2F%2Fspeckle.community&style=flat-square&logo=discourse&logoColor=white" alt="Community forum users"></a> <a href="https://speckle.systems"><img src="https://img.shields.io/badge/https://-speckle.systems-royalblue?style=flat-square" alt="website"></a> <a href="https://docs.speckle.systems/"><img src="https://img.shields.io/badge/docs-speckle.systems-orange?style=flat-square&logo=read-the-docs&logoColor=white" alt="docs"></a></p>
|
||||
|
||||
> Speckle is the first AEC data hub that connects with your favorite AEC tools. Speckle exists to overcome the challenges of working in a fragmented industry where communication, creative workflows, and the exchange of data are often hindered by siloed software and processes. It is here to make the industry better.
|
||||
|
||||
<h3 align="center">
|
||||
Data Connector for Microsoft's PowerBI platform
|
||||
Expected use case is that this data be used as source for the Speckle Visualization for PowerBI (https://github.com/specklesystems/speckle-powerbi-visuals)
|
||||
Speckle Connector and 3D Visual for Power BI
|
||||
</h3>
|
||||
<p align="center"><b>Speckle</b> is the data infrastructure for the AEC industry.</p><br/>
|
||||
|
||||
<p align="center"><a href="https://twitter.com/SpeckleSystems"><img src="https://img.shields.io/twitter/follow/SpeckleSystems?style=social" alt="Twitter Follow"></a> <a href="https://speckle.community"><img src="https://img.shields.io/discourse/users?server=https%3A%2F%2Fspeckle.community&style=flat-square&logo=discourse&logoColor=white" alt="Community forum users"></a> <a href="https://speckle.systems"><img src="https://img.shields.io/badge/https://-speckle.systems-royalblue?style=flat-square" alt="website"></a> <a href="https://speckle.guide/dev/"><img src="https://img.shields.io/badge/docs-speckle.guide-orange?style=flat-square&logo=read-the-docs&logoColor=white" alt="docs"></a></p>
|
||||
<p align="center"></p>
|
||||
## Features
|
||||
|
||||
# About Speckle
|
||||
Speckle Power BI Data Connector lets you easily get data from Speckle into Power BI reports and visualizations. You can access and analyze data from various AEC apps (like Revit, Archicad, Grasshopper, and more) and open-source files (IFC, STL, OBJ, etc.) into Power BI with ease.
|
||||
|
||||
<p align="center">
|
||||
<div align="center">
|
||||
<a href="https://app.speckle.systems/connectors/">
|
||||
Download Power BI Connector
|
||||
</a>
|
||||
</div>
|
||||
</p>
|
||||
|
||||
Speckle’s connection to Power BI consists of two parts:
|
||||
|
||||
- **Data Connector** fetches the data you uploaded from AEC apps to Speckle.
|
||||
- **3D Visual** allows you to see those models in 3D within Power BI.
|
||||
|
||||

|
||||
|
||||
## Repository Structure
|
||||
|
||||
This repository is home to our Power BI connector. The Speckle Server provides all the web-facing functionality and can be found [here](https://github.com/specklesystems/Server).
|
||||
|
||||
`src/powerbi-data-connector` contains all the code for the Data connector.
|
||||
|
||||
`src/powerbi-visual` contains all the code for 3D Visual.
|
||||
|
||||
## Installation
|
||||
|
||||
Power BI connector installer can be downloaded from the [connectors portal](https://app.speckle.systems/connectors/). Full instructions for [installation](https://docs.speckle.systems/connectors/power-bi#setup) and [configuration](https://docs.speckle.systems/connectors/power-bi#why-dont-i-see-speckle-as-a-data-source-in-power-bi) can be found on our docs.
|
||||
|
||||
### 3D Visual
|
||||
|
||||
3D Visual can be imported as any other Power BI custom visual.
|
||||
|
||||
1. Navigate to the Visualization Pane.
|
||||
2. Click the three dots (…) and select “Import a visual from a file”.
|
||||
3. Go to `Documents/Power BI Desktop/Custom Visuals` and import `Speckle 3D Visual.pbiviz` file.
|
||||
4. Speckle cube will appear in the Visualization pane.
|
||||
|
||||
For more on how to use the visual, [check our docs](https://docs.speckle.systems/connectors/power-bi).
|
||||
|
||||
## Quick Start
|
||||
|
||||
To get started with Power BI connector, please take a look at the [documentation](https://docs.speckle.systems/connectors/power-bi) and extensive [tutorials](https://www.youtube.com/@SpeckleSystems) published.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### For local development of the 3D Visual
|
||||
|
||||
1. **Clone the repository**:
|
||||
```bash
|
||||
git clone https://github.com/specklesystems/speckle-powerbi.git
|
||||
cd speckle-powerbi
|
||||
```
|
||||
|
||||
2. **Navigate to the visual directory**:
|
||||
```bash
|
||||
cd src/powerbi-visual
|
||||
```
|
||||
|
||||
3. **Install dependencies**:
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
yarn install
|
||||
```
|
||||
|
||||
4. **Start development server**:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. **Build the visual**:
|
||||
```bash
|
||||
# Development build
|
||||
npm run build:dev
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
```
|
||||
|
||||
### For local development of the Data Connector
|
||||
|
||||
1. **Install PowerQuery SDK**:
|
||||
Follow the instructions from the [official docs](https://docs.microsoft.com/en-us/power-query/installingsdk)
|
||||
|
||||
2. **Open the project in Visual Studio Code**:
|
||||
- Open `src/powerbi-data-connector/Speckle.proj`
|
||||
- Build the project to generate the `.mez` file
|
||||
|
||||
3. **Testing the connector**:
|
||||
- Visual Studio will automatically copy the `.mez` file to the appropriate location
|
||||
- Restart Power BI Desktop to see the latest changes
|
||||
|
||||
## About Speckle
|
||||
|
||||
What is Speckle? Check our 
|
||||
|
||||
@@ -32,65 +127,10 @@ What is Speckle? Check our ](https://speckle.xyz) ⇒ creating an account at our public server
|
||||
- [](https://marketplace.digitalocean.com/apps/speckle-server?refcode=947a2b5d7dc1) ⇒ deploying an instance in 1 click
|
||||
- [](https://app.speckle.systems) ⇒ creating an account at our public server
|
||||
|
||||
### Resources
|
||||
|
||||
- [](https://speckle.community) for help, feature requests or just to hang with other speckle enthusiasts, check out our community forum!
|
||||
- [](https://speckle.systems) our tutorials portal is full of resources to get you started using Speckle
|
||||
- [](https://speckle.guide/dev/) reference on almost any end-user and developer functionality
|
||||
|
||||

|
||||
|
||||
# Repo structure
|
||||
|
||||
This repo is the home to our Speckle 2.0 PowerBI project. The [Speckle Server](https://github.com/specklesystems/Server) is providing all the web-facing functionality and can be found [here](https://github.com/specklesystems/Server).
|
||||
|
||||
## Install
|
||||
|
||||
Go to the [Releases](https://github.com/specklesystems/speckle-powerbi/releases) page, downlad the `.mez` file of the latest release and copy it into the following folder in your computer:
|
||||
|
||||
```
|
||||
YOUR_USER_FOLDER\Documents\Power BI Desktop\Custom Connectors\
|
||||
```
|
||||
|
||||
### Allow custom extensions to run
|
||||
|
||||
Go to `Settings -> Security -> Data Extensions` and activate the following option:
|
||||
|
||||

|
||||
|
||||
### Checking the connector is loaded
|
||||
|
||||
Now open PowerBI and you should see `Speckle (beta)` appear in the data source.
|
||||
|
||||

|
||||
|
||||
## Usage
|
||||
|
||||
> More detailed instructions on how to use the connector will be added shortly!
|
||||
|
||||
### Current limitations
|
||||
|
||||
Chunked data currently is not automatically de-chunked when received, we are aware of this limitation and are working to resolve it!
|
||||
|
||||
## Developing & Debugging
|
||||
|
||||
We encourage everyone interested to debug / hack / contribute / give feedback to this project.
|
||||
|
||||
### Setup
|
||||
|
||||
#### Install PowerQuery SDK
|
||||
|
||||
Follow the instructions from the [official docs](https://docs.microsoft.com/en-us/power-query/installingsdk)
|
||||
|
||||
#### Build with Visual Studio
|
||||
|
||||
Every time you build the connector, VisualStudio will copy the latest `.mez` connector file to the appropriate location. Just restart PowerBI to see the latest changes.
|
||||
|
||||
#### Debug
|
||||
|
||||
You can start the PowerQuery connector in VisualStudio, this will open a standalone connector you can use for testing purposes.
|
||||
|
||||
We don't know of a way to debug the connector live in PowerBI, but we'd be happy to hear about it.
|
||||
- [](https://docs.speckle.systems) reference on almost any end-user and developer functionality
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
[Version = "2.15.0-rc"]
|
||||
section Speckle;
|
||||
|
||||
AuthAppId = "spklpwerbi";
|
||||
AuthAppSecret = "spklpwerbi";
|
||||
|
||||
// The data source definition, used when connecting to any speckle server
|
||||
Speckle = [
|
||||
// This is used when running the connector on an on-premises data gateway
|
||||
TestConnection = (path) => {"Speckle.Api.GetUser", path},
|
||||
// This is the custom authentication strategy for our Connector
|
||||
Authentication = [
|
||||
OAuth = [
|
||||
Label = "Speckle.xyz",
|
||||
StartLogin = (clientApplication, dataSourcePath, state, display) =>
|
||||
let
|
||||
server = Text.Combine(
|
||||
{Uri.Parts(dataSourcePath)[Scheme], "://", Uri.Parts(dataSourcePath)[Host]}
|
||||
)
|
||||
in
|
||||
[
|
||||
LoginUri = Text.Combine({server, "authn", "verify", AuthAppId, state}, "/"),
|
||||
CallbackUri = "https://oauth.powerbi.com/views/oauthredirect.html",
|
||||
WindowHeight = 800,
|
||||
WindowWidth = 600,
|
||||
Context = null
|
||||
],
|
||||
FinishLogin = (clientApplication, dataSourcePath, context, callbackUri, state) =>
|
||||
let
|
||||
server = Text.Combine(
|
||||
{Uri.Parts(dataSourcePath)[Scheme], "://", Uri.Parts(dataSourcePath)[Host]}
|
||||
),
|
||||
Parts = Uri.Parts(callbackUri)[Query],
|
||||
Source = Web.Contents(
|
||||
Text.Combine({server, "auth", "token"}, "/"),
|
||||
[
|
||||
Headers = [
|
||||
#"Content-Type" = "application/json"
|
||||
],
|
||||
Content = Json.FromValue(
|
||||
[
|
||||
accessCode = Parts[access_code],
|
||||
appId = AuthAppId,
|
||||
appSecret = AuthAppSecret,
|
||||
challenge = state
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
json = Json.Document(Source)
|
||||
in
|
||||
[
|
||||
access_token = json[token],
|
||||
scope = null,
|
||||
token_type = "bearer",
|
||||
refresh_token = json[refreshToken]
|
||||
],
|
||||
Refresh = (dataSourcePath, refreshToken) =>
|
||||
let
|
||||
server = Text.Combine(
|
||||
{Uri.Parts(dataSourcePath)[Scheme], "://", Uri.Parts(dataSourcePath)[Host]}
|
||||
),
|
||||
Source = Web.Contents(
|
||||
Text.Combine({server, "auth", "token"}, "/"),
|
||||
[
|
||||
Headers = [
|
||||
#"Content-Type" = "application/json"
|
||||
],
|
||||
Content = Json.FromValue(
|
||||
[
|
||||
refreshToken = refreshToken,
|
||||
appId = AuthAppId,
|
||||
appSecret = AuthAppSecret
|
||||
]
|
||||
)
|
||||
]
|
||||
),
|
||||
json = Json.Document(Source)
|
||||
in
|
||||
[
|
||||
access_token = json[token],
|
||||
scope = null,
|
||||
token_type = "bearer",
|
||||
refresh_token = json[refreshToken]
|
||||
]
|
||||
],
|
||||
Key = [
|
||||
KeyLabel = "Personal Access Token",
|
||||
Label = "Private stream"
|
||||
],
|
||||
Implicit = [
|
||||
Label = "Public stream"
|
||||
]
|
||||
],
|
||||
Label = "Speckle"
|
||||
];
|
||||
|
||||
// Gets the object referenced by a specific speckle URL
|
||||
[DataSource.Kind = "Speckle", Publish = "Get.ByUrl.Publish"]
|
||||
shared Speckle.GetByUrl.Structured = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Get.ByUrl.pqm"),
|
||||
type function (
|
||||
url as (
|
||||
Uri.Type meta [
|
||||
Documentation.FieldCaption = "Gets a Speckle Object preserving it's structure",
|
||||
Documentation.FieldDescription = "The url of a stream in a Speckle server. You can copy it directly from your browser.",
|
||||
Documentation.SampleValues = {
|
||||
"https://speckle.xyz/streams/23401adf",
|
||||
"https://speckle.xyz/streams/23401adf/branches/main"
|
||||
}
|
||||
]
|
||||
)
|
||||
) as record meta [
|
||||
Documentation.Name = "Speckle - Get Structured Object by URL",
|
||||
Documentation.LongDescription = "Returns the Speckle object the URL points to, while also preserving it's structure.
|
||||
Supports all types of stream url:#(lf)
|
||||
- Stream: will get the latest commit on the 'main' branch (i.e. 'https://speckle.xyz/streams/STREAM_ID')#(lf)
|
||||
- Branch: will get the latest commit on the specified branch (i.e. 'https://speckle.xyz/streams/STREAM_ID/branches/BRANCH_NAME')#(lf)
|
||||
- Commit: will get a specific commit from the stream (i.e. 'https://speckle.xyz/streams/STREAM_ID/commits/COMMIT_ID')
|
||||
"
|
||||
]
|
||||
);
|
||||
|
||||
// [DataSource.Kind = "Speckle", Publish = "NavTable.Publish"]
|
||||
// shared Speckle.GetObjectAsNavTable = Value.ReplaceType(
|
||||
// NavigationTable.Simple, type function (url as Uri.Type) as table
|
||||
// );
|
||||
// Get's a flat list of speckle objects from a URL
|
||||
[DataSource.Kind = "Speckle", Publish = "GetByUrl.Publish"]
|
||||
shared Speckle.GetByUrl = Value.ReplaceType(
|
||||
Speckle.LoadFunction("GetByUrl.pqm"),
|
||||
type function (
|
||||
url as (
|
||||
Uri.Type meta [
|
||||
Documentation.FieldCaption = "Stream URL",
|
||||
Documentation.FieldDescription = "The url of a stream in a Speckle server. You can copy it directly from your browser.",
|
||||
Documentation.SampleValues = {
|
||||
"https://speckle.xyz/streams/23401adf",
|
||||
"https://speckle.xyz/streams/23401adf/branches/main"
|
||||
}
|
||||
]
|
||||
)
|
||||
) as table meta [
|
||||
Documentation.Name = "Speckle - Get stream by URL",
|
||||
Documentation.LongDescription = "Returns a flat list of all objects contained in a specific Speckle stream/branch/commit/object.
|
||||
Supports all types of stream url:#(lf)
|
||||
- Stream: will get the latest commit on the 'main' branch (i.e. 'https://speckle.xyz/streams/STREAM_ID')#(lf)
|
||||
- Branch: will get the latest commit on the specified branch (i.e. 'https://speckle.xyz/streams/STREAM_ID/branches/BRANCH_NAME')#(lf)
|
||||
- Commit: will get a specific commit from the stream (i.e. 'https://speckle.xyz/streams/STREAM_ID/commits/COMMIT_ID')
|
||||
"
|
||||
]
|
||||
);
|
||||
|
||||
// Gets the current authenticated user, if any
|
||||
[DataSource.Kind = "Speckle"]
|
||||
shared Speckle.Api.GetUser = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Api.GetUser.pqm"), type function (url as Uri.Type) as record
|
||||
);
|
||||
|
||||
// Generic fetch function to our GraphQL endpoint
|
||||
[DataSource.Kind = "Speckle"]
|
||||
shared Speckle.Api.Fetch = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Api.Fetch.pqm"),
|
||||
type function (url as Uri.Type, optional query as text, optional variables as record) as record
|
||||
);
|
||||
|
||||
// Parses a stream url and returns a record with the type and values
|
||||
[DataSource.Kind = "Speckle"]
|
||||
shared Speckle.ParseUrl = Value.ReplaceType(
|
||||
Speckle.LoadFunction("ParseStreamUrl.pqm"), type function (url as Uri.Type) as record
|
||||
);
|
||||
|
||||
// [DataSource.Kind = "Speckle"]
|
||||
// shared Speckle.Api.REST.GetObject = Value.ReplaceType(
|
||||
// Speckle.LoadFunction("Api.REST.GetObject.pqm"),
|
||||
// type function (url as Uri.Type, optional streamId as text, optional objectId as text) as list
|
||||
// );
|
||||
Get.ByUrl.Publish = GetPublish("GetStream");
|
||||
|
||||
NavTable.Publish = GetPublish("GetObjectAsNavTable");
|
||||
|
||||
GetByUrl.Publish = GetPublish("GetByUrl");
|
||||
|
||||
GetPublish = Speckle.LoadFunction("GetPublish.pqm");
|
||||
|
||||
// Navigation table utility function
|
||||
Table.ToNavigationTable = Speckle.LoadFunction("Table.ToNavigationTable.pqm");
|
||||
|
||||
// Function to load `pqm` files
|
||||
shared Speckle.LoadFunction = (fileName as text) =>
|
||||
let
|
||||
binary = Extension.Contents(fileName), asText = Text.FromBinary(binary)
|
||||
in
|
||||
try
|
||||
Expression.Evaluate(asText, #shared) catch (e) =>
|
||||
error
|
||||
[
|
||||
Reason = "Speckle.LoadFunction Failure",
|
||||
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
|
||||
Message.Parameters = {fileName, e[Reason], e[Message]},
|
||||
Detail = [File = fileName, Error = e]
|
||||
];
|
||||
|
||||
shared Speckle.Revit.Parameters.ToNameValueRecord = (r as record, optional exclude as list) as record =>
|
||||
let
|
||||
defaultExclude = {"id", "speckle_type", "applicationId", "totalChildrenCount"},
|
||||
fullExclusion = if exclude = null then defaultExclude else List.Union(defaultExclude, exclude),
|
||||
clean = Record.RemoveFields(r, fullExclusion, MissingField.Ignore),
|
||||
recTable = Record.ToTable(clean),
|
||||
cleanTable = Table.RemoveColumns(recTable, "Name"),
|
||||
expanded = Table.ExpandRecordColumn(
|
||||
cleanTable, "Value", {"name", "value", "applicationInternalName"}, {"Name", "Value", "UID"}
|
||||
),
|
||||
joined = Table.AddColumn(expanded, "Combo", each [Name] & " [" & [UID] & "]"),
|
||||
renamed = Table.RenameColumns(joined, {{"Name", "x"}, {"Combo", "Name"}}),
|
||||
result = Record.FromTable(renamed)
|
||||
in
|
||||
result;
|
||||
|
||||
shared Speckle.Utils.DynamicColumnExpand = (tbl as table, col as text) as table =>
|
||||
let
|
||||
uniqueFields = List.Distinct(List.Combine(List.Transform(Table.Column(tbl, col), Record.FieldNames))),
|
||||
expanded = Table.ExpandRecordColumn(tbl, col, uniqueFields)
|
||||
in
|
||||
expanded;
|
||||
@@ -1,45 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"
|
||||
DefaultTargets="BuildMez">
|
||||
<PropertyGroup>
|
||||
<Version Condition="'$(Version)' == ''">2.0.0-wip</Version>
|
||||
<OutputPath Condition="'$(OutputPath)' == ''">$(MSBuildProjectDirectory)\bin\</OutputPath>
|
||||
<IntermediateOutputPath Condition="'$(IntermediateOutputPath)' == ''">
|
||||
$(MSBuildProjectDirectory)\obj\</IntermediateOutputPath>
|
||||
<MezIntermediatePath>$(IntermediateOutputPath)MEZ\</MezIntermediatePath>
|
||||
<MezOutputPath>$(OutputPath)$(MsBuildProjectName).mez</MezOutputPath>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<MezContent Include="Speckle.pq" />
|
||||
<MezContent Include="utilities\**\*.pqm" />
|
||||
<MezContent Include="speckle\**\*.pqm" />
|
||||
<MezContent Include="assets\SpeckleLogo16.png" />
|
||||
<MezContent Include="assets\SpeckleLogo20.png" />
|
||||
<MezContent Include="assets\SpeckleLogo24.png" />
|
||||
<MezContent Include="assets\SpeckleLogo32.png" />
|
||||
<MezContent Include="assets\SpeckleLogo40.png" />
|
||||
<MezContent Include="assets\SpeckleLogo48.png" />
|
||||
<MezContent Include="assets\SpeckleLogo64.png" />
|
||||
<MezContent Include="assets\SpeckleLogo80.png" />
|
||||
<MezContent Include="assets\resources.resx" />
|
||||
</ItemGroup>
|
||||
<Target Name="BuildMez" AfterTargets="Build" Inputs="@(MezContent)" Outputs="$(MezOutputPath)">
|
||||
<RemoveDir Directories="$(MezIntermediatePath)" />
|
||||
<Copy SourceFiles="@(MezContent)" DestinationFolder="$(MezIntermediatePath)" />
|
||||
<MakeDir Directories="$(OutputPath)" Condition="!Exists('$(OutputPath)')" />
|
||||
<ZipDirectory SourceDirectory="$(MezIntermediatePath)" DestinationFile="$(MezOutputPath)"
|
||||
Overwrite="true" />
|
||||
</Target>
|
||||
<Target Name="CopyToConnectors" AfterTargets="BuildMez">
|
||||
<Message
|
||||
Text="Copying .mez file to: $(UserProfile)\Documents\Power BI Desktop\Custom Connectors"
|
||||
Importance="High" />
|
||||
<MakeDir Directories="$(UserProfile)\Documents\Power BI Desktop\Custom Connectors\" />
|
||||
<Copy SourceFiles="$(MezOutputPath)"
|
||||
DestinationFolder="$(UserProfile)\Documents\Power BI Desktop\Custom Connectors\" />
|
||||
</Target>
|
||||
<Target Name="Clean">
|
||||
<RemoveDir Directories="$(MezIntermediatePath)" />
|
||||
<Delete Files="$(MezOutputPath)" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -1,2 +0,0 @@
|
||||
// Use this file to write queries to test your data connector
|
||||
let result = Speckle.GetByUrl("https://app.speckle.systems/projects/e2988234fb/models/60b2300470@b1f31a351a") in result
|
||||
@@ -1,174 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace"/>
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0"/>
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string"/>
|
||||
<xsd:attribute name="type" type="xsd:string"/>
|
||||
<xsd:attribute name="mimetype" type="xsd:string"/>
|
||||
<xsd:attribute ref="xml:space"/>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string"/>
|
||||
<xsd:attribute name="name" type="xsd:string"/>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1"/>
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2"/>
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1"/>
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3"/>
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4"/>
|
||||
<xsd:attribute ref="xml:space"/>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1"/>
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required"/>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="GetByUrl.Help" xml:space="preserve">
|
||||
<value>Connect to Speckle by Stream URL</value>
|
||||
</data>
|
||||
<data name="GetByUrl.Label" xml:space="preserve">
|
||||
<value>Speckle</value>
|
||||
</data>
|
||||
<data name="GetByUrl.Title" xml:space="preserve">
|
||||
<value>Speckle - Get stream by URL</value>
|
||||
</data>
|
||||
<data name="GetObjFromBranch.Help" xml:space="preserve">
|
||||
<value>Connect to Speckle by serer URL, stream ID and branch name</value>
|
||||
</data>
|
||||
<data name="GetObjFromBranch.Label" xml:space="preserve">
|
||||
<value>Get the latest commit from a stream's branch</value>
|
||||
</data>
|
||||
<data name="GetObjFromBranch.Title" xml:space="preserve">
|
||||
<value>Speckle - Get Stream branch</value>
|
||||
</data>
|
||||
<data name="GetObjFromCommit.Help" xml:space="preserve">
|
||||
<value>Connect to Speckle by server URL, stream ID and commit ID</value>
|
||||
</data>
|
||||
<data name="GetObjFromCommit.Label" xml:space="preserve">
|
||||
<value>A label</value>
|
||||
</data>
|
||||
<data name="GetObjFromCommit.Title" xml:space="preserve">
|
||||
<value>Speckle - Get Stream commit</value>
|
||||
</data>
|
||||
<data name="GetStream.Help" xml:space="preserve">
|
||||
<value>Connect to Speckle by server URL and stream ID</value>
|
||||
</data>
|
||||
<data name="GetStream.Label" xml:space="preserve">
|
||||
<value>Speckle</value>
|
||||
</data>
|
||||
<data name="GetStream.Title" xml:space="preserve">
|
||||
<value>Speckle - Get Stream by URL [Structured]</value>
|
||||
</data>
|
||||
<data name="GetObjectAsNavTable.Title" xml:space="preserve">
|
||||
<value>Speckle - Get Object as NavTable</value>
|
||||
</data>
|
||||
<data name="GetObjectAsNavTable.Label" xml:space="preserve">
|
||||
<value>Speckle</value>
|
||||
</data>
|
||||
<data name="GetObjectAsNavTable.Help" xml:space="preserve">
|
||||
<value>Returns a navigation table for a given object</value>
|
||||
</data>
|
||||
<data name="Traverse.Title" xml:space="preserve">
|
||||
<value>Traverse an object and populate refs</value>
|
||||
</data>
|
||||
<data name="Traverse.Label" xml:space="preserve">
|
||||
<value>Traverse</value>
|
||||
</data>
|
||||
<data name="Traverse.Help" xml:space="preserve">
|
||||
<value>Traverse help</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "speckle-powerbi",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import re
|
||||
import sys
|
||||
import os
|
||||
|
||||
|
||||
def sanitize_version(tag):
|
||||
"""Extracts the first three numeric segments from a tag string, because PowerBI is..."""
|
||||
parts = re.findall(r"\d+", tag)
|
||||
return ".".join(parts[:3]) if len(parts) >= 3 else tag
|
||||
|
||||
def patch_connector(tag):
|
||||
"""Patches the connector version within the data connector file"""
|
||||
sanitized_tag = sanitize_version(tag)
|
||||
pq_file = os.path.join(os.path.dirname(__file__), "src", "powerbi-data-connector", "Speckle.pq")
|
||||
|
||||
with open(pq_file, "r") as file:
|
||||
lines = file.readlines()
|
||||
|
||||
for (index, line) in enumerate(lines):
|
||||
if '[Version = "3.0.0"]' in line:
|
||||
lines[index] = f'[Version = "{sanitized_tag}"]\n'
|
||||
print(f"Patched connector version number in {pq_file}")
|
||||
break
|
||||
|
||||
with open(pq_file, "w") as file:
|
||||
file.writelines(lines)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
return
|
||||
|
||||
tag = sys.argv[1]
|
||||
if not re.match(r"([0-9]+)\.([0-9]+)\.([0-9]+)", tag):
|
||||
raise ValueError(f"Invalid tag provided: {tag}")
|
||||
|
||||
print(f"Patching version: {tag}")
|
||||
patch_connector(tag)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"name": "🏠 root",
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"name": "➡️ powerbi-data-connector",
|
||||
"path": "src/powerbi-data-connector"
|
||||
},
|
||||
{
|
||||
"name": "👀 powerbi-visual",
|
||||
"path": "src/powerbi-visual"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"powerquery.general.mode": "SDK",
|
||||
"powerquery.sdk.defaultQueryFile": "${workspaceFolder}\\src\\powerbi-data-connector\\Speckle.query.pq",
|
||||
"powerquery.sdk.defaultExtension": "${workspaceFolder}\\src\\powerbi-data-connector\\bin\\Speckle.mez",
|
||||
"files.eol": "\n",
|
||||
"files.watcherExclude": {
|
||||
"**/.git/objects/**": true,
|
||||
"**/node_modules/**": true,
|
||||
".tmp": true
|
||||
},
|
||||
"editor.formatOnPaste": true,
|
||||
"editor.multiCursorModifier": "ctrlCmd",
|
||||
"editor.snippetSuggestions": "top",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"search.exclude": {
|
||||
".tmp": true,
|
||||
"typings": true,
|
||||
"dist": true,
|
||||
"wepbpack.statistics.dev.html": true,
|
||||
"wepbpack.statistics.html": true
|
||||
},
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["/pbiviz.json"],
|
||||
"url": "./src/powerbi-visual/node_modules/powerbi-visuals-api/schema.pbiviz.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": ["/capabilities.json"],
|
||||
"url": "./src/powerbi-visual/node_modules/powerbi-visuals-api/schema.capabilities.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": ["/dependencies.json"],
|
||||
"url": "./src/powerbi-visual/node_modules/powerbi-visuals-api/schema.dependencies.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"extensions": {
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"ms-dotnettools.csharp",
|
||||
"powerquery.vscode-powerquery-sdk"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
let
|
||||
Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
|
||||
CommitReceived = Extension.LoadFunction("Api.CommitReceived.pqm"),
|
||||
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]
|
||||
]
|
||||
in
|
||||
(server as text, streamId as text, branchName as text, limit as number) as list =>
|
||||
let
|
||||
decodedBranchName = Record.Field(
|
||||
Record.Field(Uri.Parts("http://www.dummy.com?" & Uri.BuildQueryString([A = branchName])), "Query"),
|
||||
"A"
|
||||
),
|
||||
// Hacky way to decode base64 strings: Put them in a url query param and parse the URL
|
||||
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
|
||||
query = "query($streamId: String!, $branchName: String!, $limit: Int!) {
|
||||
stream( id: $streamId ) {
|
||||
branch (name: $branchName ){
|
||||
commits (limit: $limit) {
|
||||
items {
|
||||
id
|
||||
referencedObject
|
||||
sourceApplication
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}",
|
||||
res = Fetch(server, query, [streamId = streamId, branchName = decodedBranchName, limit = limit]),
|
||||
branch = res[stream][branch],
|
||||
commits = branch[commits][items]
|
||||
in
|
||||
if branch = null then
|
||||
error Text.Format("The branch '#{0}' does not exist in stream '#{1}'", {decodedBranchName, streamId})
|
||||
else if List.Count(branch[commits][items]) = 0 then
|
||||
error Text.Format("The branch '#{0}' in stream #{1} has no commits", {decodedBranchName, streamId})
|
||||
else
|
||||
commits
|
||||
@@ -1,47 +0,0 @@
|
||||
let
|
||||
Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
|
||||
Traverse = Extension.LoadFunction("Traverse.pqm"),
|
||||
GetObject = Extension.LoadFunction("Api.GetObject.pqm"),
|
||||
GetStreamCommit = Extension.LoadFunction("Get.StreamCommit.pqm"),
|
||||
GetBranchCommits = Extension.LoadFunction("Get.BranchCommits.pqm"),
|
||||
CommitReceived = Extension.LoadFunction("Api.CommitReceived.pqm"),
|
||||
ParseStreamUrl = Extension.LoadFunction("ParseStreamUrl.pqm"),
|
||||
CleanUpObject = Extension.LoadFunction("CleanUpObject.pqm"),
|
||||
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]
|
||||
]
|
||||
in
|
||||
(url as text) as record =>
|
||||
let
|
||||
// Get server and streamId, and branchName / commitId / objectid from the input url
|
||||
stream = ParseStreamUrl(url),
|
||||
id = stream[id],
|
||||
server = stream[server],
|
||||
commit =
|
||||
if (stream[urlType] = "Stream") then
|
||||
GetBranchCommits(server, id, "main", 1){0}
|
||||
else if (stream[urlType] = "Branch") then
|
||||
GetBranchCommits(server, id, stream[branch], 1){0}
|
||||
else if (stream[urlType] = "Commit") then
|
||||
GetStreamCommit(server, id, stream[commit])
|
||||
else
|
||||
//We deal with object URLs directly
|
||||
[referencedObject = stream[object]],
|
||||
object = GetObject(server, id, commit[referencedObject]),
|
||||
rr = CommitReceived(server, id, commit),
|
||||
result = Traverse(CleanUpObject(object) meta [server = server, stream = id, commit = commit])
|
||||
in
|
||||
if rr then
|
||||
result
|
||||
else
|
||||
result
|
||||
@@ -1,36 +0,0 @@
|
||||
let
|
||||
Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
|
||||
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]
|
||||
]
|
||||
in
|
||||
(server as text, streamId as text, commitId as text) as record =>
|
||||
let
|
||||
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
|
||||
query = "query($streamId: String!, $commitId: String!) {
|
||||
stream( id: $streamId ) {
|
||||
commit (id: $commitId) {
|
||||
id
|
||||
sourceApplication
|
||||
referencedObject
|
||||
}
|
||||
}
|
||||
}",
|
||||
variables = [streamId = streamId, commitId = commitId],
|
||||
#"JSON" = Fetch(server, query, variables),
|
||||
commit = #"JSON"[stream][commit]
|
||||
in
|
||||
if commit = null then
|
||||
error "The commit did not exist on this stream"
|
||||
else
|
||||
commit
|
||||
@@ -1,64 +0,0 @@
|
||||
(appName as text) =>
|
||||
let
|
||||
replaced = Text.Replace(appName, " ", ""), name = Text.Lower(replaced)
|
||||
in
|
||||
if Text.Contains(name, "dynamo") then
|
||||
"dynamo"
|
||||
else if Text.Contains(name, "revit") then
|
||||
"revit"
|
||||
else if Text.Contains(name, "autocad") then
|
||||
"autocad"
|
||||
else if Text.Contains(name, "civil") then
|
||||
"civil"
|
||||
else if Text.Contains(name, "rhino") then
|
||||
"rhino"
|
||||
else if Text.Contains(name, "grasshopper") then
|
||||
"grasshopper"
|
||||
else if Text.Contains(name, "unity") then
|
||||
"unity"
|
||||
else if Text.Contains(name, "gsa") then
|
||||
"gsa"
|
||||
else if Text.Contains(name, "microstation") then
|
||||
"microstation"
|
||||
else if Text.Contains(name, "openroads") then
|
||||
"openroads"
|
||||
else if Text.Contains(name, "openrail") then
|
||||
"openrail"
|
||||
else if Text.Contains(name, "openbuildings") then
|
||||
"openbuildings"
|
||||
else if Text.Contains(name, "etabs") then
|
||||
"etabs"
|
||||
else if Text.Contains(name, "sap") then
|
||||
"sap"
|
||||
else if Text.Contains(name, "csibridge") then
|
||||
"csibridge"
|
||||
else if Text.Contains(name, "safe") then
|
||||
"safe"
|
||||
else if Text.Contains(name, "teklastructures") then
|
||||
"teklastructures"
|
||||
else if Text.Contains(name, "dxf") then
|
||||
"dxf"
|
||||
else if Text.Contains(name, "excel") then
|
||||
"excel"
|
||||
else if Text.Contains(name, "unreal") then
|
||||
"unreal"
|
||||
else if Text.Contains(name, "powerbi") then
|
||||
"powerbi"
|
||||
else if Text.Contains(name, "blender") then
|
||||
"blender"
|
||||
else if Text.Contains(name, "qgis") then
|
||||
"qgis"
|
||||
else if Text.Contains(name, "arcgis") then
|
||||
"arcgis"
|
||||
else if Text.Contains(name, "sketchup") then
|
||||
"sketchup"
|
||||
else if Text.Contains(name, "archicad") then
|
||||
"archicad"
|
||||
else if Text.Contains(name, "topsolid") then
|
||||
"topsolid"
|
||||
else if Text.Contains(name, "python") then
|
||||
"python"
|
||||
else if Text.Contains(name, "net") then
|
||||
"net"
|
||||
else
|
||||
"other"
|
||||
@@ -1,58 +0,0 @@
|
||||
let
|
||||
Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
|
||||
GetAllObjectChildren = Extension.LoadFunction("Api.GetAllObjectChildren.pqm"),
|
||||
GetObjectFromCommit = Extension.LoadFunction("GetObjectFromCommit.pqm"),
|
||||
GetObjectFromBranch = Extension.LoadFunction("GetObjectFromBranch.pqm"),
|
||||
CommitReceived = Extension.LoadFunction("Api.CommitReceived.pqm"),
|
||||
ParseStreamUrl = Extension.LoadFunction("ParseStreamUrl.pqm"),
|
||||
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]
|
||||
]
|
||||
in
|
||||
(url as text) as table =>
|
||||
let
|
||||
// Get server and streamId, and branchName / commitId / objectid from the input url
|
||||
stream = ParseStreamUrl(url),
|
||||
id = stream[id],
|
||||
server = stream[server],
|
||||
commitObjectsTable =
|
||||
if (stream[urlType] = "Commit") then
|
||||
GetObjectFromCommit(server, id, stream[commit])
|
||||
else if (stream[urlType] = "Object") then
|
||||
GetAllObjectChildren(server, id, stream[object])
|
||||
else if (stream[urlType] = "Branch") then
|
||||
GetObjectFromBranch(server, id, stream[branch])
|
||||
else
|
||||
GetObjectFromBranch(server, id, "main"),
|
||||
removeEmpty = Table.RemoveLastN(commitObjectsTable, 1),
|
||||
addStreamUrl = Table.AddColumn(removeEmpty, "Stream URL", each server & "/streams/" & id),
|
||||
addParentObjectId = Table.AddColumn(
|
||||
addStreamUrl, "Commit Object ID", each Value.Metadata(commitObjectsTable)[objectId]
|
||||
),
|
||||
addUrlType = Table.AddColumn(addParentObjectId, "URL Type", each stream[urlType]),
|
||||
addObjectIdCol = Table.AddColumn(addUrlType, "Object ID", each try[data][id] otherwise null),
|
||||
addSpeckleTypeCol = Table.AddColumn(
|
||||
addObjectIdCol, "speckle_type", each try[data][speckle_type] otherwise null
|
||||
),
|
||||
final = Table.ReorderColumns(
|
||||
addSpeckleTypeCol, {
|
||||
"Stream URL",
|
||||
"URL Type",
|
||||
"Commit Object ID",
|
||||
"Object ID",
|
||||
"speckle_type",
|
||||
"data"
|
||||
}
|
||||
)
|
||||
in
|
||||
final
|
||||
@@ -1,55 +0,0 @@
|
||||
let
|
||||
Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
|
||||
GetAllObjectChildren = Extension.LoadFunction("Api.GetAllObjectChildren.pqm"),
|
||||
CommitReceived = Extension.LoadFunction("Api.CommitReceived.pqm"),
|
||||
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]
|
||||
]
|
||||
in
|
||||
(server as text, streamId as text, branchName as text) as table =>
|
||||
let
|
||||
decodedBranchName = Record.Field(
|
||||
Record.Field(Uri.Parts("http://www.dummy.com?" & Uri.BuildQueryString([A = branchName])), "Query"),
|
||||
"A"
|
||||
),
|
||||
// Hacky way to decode base64 strings: Put them in a url query param and parse the URL
|
||||
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
|
||||
query = "query($streamId: String!, $branchName: String!) {
|
||||
stream( id: $streamId ) {
|
||||
branch (name: $branchName ){
|
||||
commits (limit: 1) {
|
||||
items {
|
||||
id
|
||||
referencedObject
|
||||
sourceApplication
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}",
|
||||
res = Fetch(server, query, [streamId = streamId, branchName = decodedBranchName]),
|
||||
branch = res[stream][branch],
|
||||
commit = branch[commits][items]{0},
|
||||
objectsTable = GetAllObjectChildren(server, streamId, commit[referencedObject]),
|
||||
rr = CommitReceived(server, streamId, commit)
|
||||
in
|
||||
if branch = null then
|
||||
error Text.Format("The branch '#{0}' does not exist in stream '#{1}'", {decodedBranchName, streamId})
|
||||
else if List.Count(branch[commits][items]) = 0 then
|
||||
error Text.Format("The branch '#{0}' in stream #{1} has no commits", {decodedBranchName, streamId})
|
||||
else
|
||||
// Force evaluation of read receipt (ideally it should happen after fetching, but can't find a way)
|
||||
if rr then
|
||||
objectsTable
|
||||
else
|
||||
objectsTable
|
||||
@@ -1,43 +0,0 @@
|
||||
let
|
||||
Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
|
||||
GetAllObjectChildren = Extension.LoadFunction("Api.GetAllObjectChildren.pqm"),
|
||||
CommitReceived = Extension.LoadFunction("Api.CommitReceived.pqm"),
|
||||
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]
|
||||
]
|
||||
in
|
||||
(server as text, streamId as text, commitId as text) as table =>
|
||||
let
|
||||
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
|
||||
query = "query($streamId: String!, $commitId: String!) {
|
||||
stream( id: $streamId ) {
|
||||
commit (id: $commitId) {
|
||||
id
|
||||
sourceApplication
|
||||
referencedObject
|
||||
authorId
|
||||
}
|
||||
}
|
||||
}",
|
||||
variables = [streamId = streamId, commitId = commitId],
|
||||
#"JSON" = Fetch(server, query, variables),
|
||||
commit = #"JSON"[stream][commit],
|
||||
objectsTable = GetAllObjectChildren(server, streamId, commit[referencedObject]),
|
||||
rr = CommitReceived(server, streamId, commit)
|
||||
in
|
||||
if commit = null then
|
||||
error "The commit did not exist on this stream"
|
||||
else if rr then
|
||||
objectsTable
|
||||
else
|
||||
objectsTable
|
||||
@@ -1,33 +0,0 @@
|
||||
let
|
||||
Speckle.Api.Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
|
||||
Speckle.LogEvent = Extension.LoadFunction("LogEvent.pqm"),
|
||||
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]
|
||||
]
|
||||
in
|
||||
(server, streamId, commit) =>
|
||||
let
|
||||
query = "mutation($input: CommitReceivedInput!) {
|
||||
commitReceive(input: $input)
|
||||
}",
|
||||
variables = [
|
||||
input = [
|
||||
streamId = streamId,
|
||||
commitId = commit[id],
|
||||
sourceApplication = "PowerBI"
|
||||
]
|
||||
],
|
||||
s = Speckle.LogEvent(server, commit)
|
||||
in
|
||||
// Read receipts should fail gracefully no matter what
|
||||
try Speckle.Api.Fetch(s, query, variables)[commitReceive] otherwise false
|
||||
@@ -1,33 +0,0 @@
|
||||
(server as text, optional query as text, optional variables as record) as record =>
|
||||
let
|
||||
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
|
||||
defaultQuery = "query {
|
||||
activeUser {
|
||||
email
|
||||
name
|
||||
}
|
||||
serverInfo {
|
||||
name
|
||||
company
|
||||
version
|
||||
}
|
||||
}",
|
||||
Source = Web.Contents(
|
||||
Text.Combine({server, "graphql"}, "/"),
|
||||
[
|
||||
Headers = [
|
||||
#"Method" = "POST",
|
||||
#"Content-Type" = "application/json",
|
||||
#"Authorization" = if apiKey = null then "" else Text.Format("Bearer #{0}", {apiKey})
|
||||
],
|
||||
ManualStatusHandling = {400},
|
||||
Content = Json.FromValue([query = Text.From(query ?? defaultQuery), variables = variables])
|
||||
]
|
||||
),
|
||||
#"JSON" = Json.Document(Source)
|
||||
in
|
||||
// Check if response contains errors, if so, return first error.
|
||||
if Record.HasFields(#"JSON", {"errors"}) then
|
||||
error #"JSON"[errors]{0}[message]
|
||||
else
|
||||
#"JSON"[data]
|
||||
@@ -1,35 +0,0 @@
|
||||
let
|
||||
Table.GenerateByPage = Extension.LoadFunction("Table.GenerateByPage.pqm"),
|
||||
Speckle.Api.GetObjectChildren = Extension.LoadFunction("Api.GetObjectChildren.pqm"),
|
||||
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]
|
||||
]
|
||||
in
|
||||
// Read all pages of data.
|
||||
// After every page, we check the "nextCursor" record on the metadata of the previous request.
|
||||
// Table.GenerateByPage will keep asking for more pages until we return null.
|
||||
(server as text, streamId as text, objectId as text, optional cursor as text) as table =>
|
||||
Table.GenerateByPage(
|
||||
(previous) =>
|
||||
let
|
||||
// if previous is null, then this is our first page of data
|
||||
nextCursor = if (previous = null) then cursor else Value.Metadata(previous)[Cursor]?,
|
||||
// if the cursor is null but the prevous page is not, we've reached the end
|
||||
page =
|
||||
if (previous <> null and nextCursor = null) then
|
||||
null
|
||||
else
|
||||
Speckle.Api.GetObjectChildren(server, streamId, objectId, 1000, nextCursor)
|
||||
in
|
||||
page
|
||||
) meta [server = server, streamId = streamId, objectId = objectId]
|
||||
@@ -1,32 +0,0 @@
|
||||
let
|
||||
Speckle.Api.Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
|
||||
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]
|
||||
]
|
||||
in
|
||||
(server as text, projectId as text, modelId as text) =>
|
||||
let
|
||||
query = "query Project($projectId: String!, $modelId: String!) {
|
||||
project(id: $projectId) {
|
||||
model(id: $modelId) {
|
||||
name
|
||||
}
|
||||
}
|
||||
}",
|
||||
variables = [
|
||||
projectId = projectId,
|
||||
modelId = modelId
|
||||
]
|
||||
in
|
||||
// Read receipts should fail gracefully no matter what
|
||||
try Speckle.Api.Fetch(server, query, variables)[project][model] otherwise null
|
||||
@@ -1,28 +0,0 @@
|
||||
let
|
||||
Speckle.Api.Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
|
||||
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]
|
||||
]
|
||||
in
|
||||
(server as text, streamId as text, objectId as text) =>
|
||||
let
|
||||
query = "query($streamId: String!, $objectId: String!) {
|
||||
stream( id: $streamId ) {
|
||||
object (id: $objectId) {
|
||||
data
|
||||
}
|
||||
}
|
||||
}",
|
||||
#"JSON" = Speckle.Api.Fetch(server, query, [streamId = streamId, objectId = objectId])
|
||||
in
|
||||
#"JSON"[stream][object][data]
|
||||
@@ -1,54 +0,0 @@
|
||||
let
|
||||
Speckle.Api.Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
|
||||
Speckle.CleanUpObjects = Extension.LoadFunction("CleanUpObjects.pqm"),
|
||||
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]
|
||||
]
|
||||
in
|
||||
(
|
||||
server as text,
|
||||
streamId as text,
|
||||
objectId as text,
|
||||
optional limit as number,
|
||||
optional cursor as text,
|
||||
optional select as list
|
||||
) =>
|
||||
let
|
||||
query = "query($streamId: String!, $objectId: String!, $limit: Int, $cursor: String, $select: [String]) {
|
||||
stream( id: $streamId ) {
|
||||
object (id: $objectId) {
|
||||
children(select: $select, limit: $limit, cursor: $cursor) {
|
||||
cursor
|
||||
objects {
|
||||
data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}",
|
||||
#"JSON" = Speckle.Api.Fetch(
|
||||
server,
|
||||
query,
|
||||
[
|
||||
streamId = streamId,
|
||||
objectId = objectId,
|
||||
limit = limit,
|
||||
cursor = cursor,
|
||||
select = select
|
||||
]
|
||||
),
|
||||
children = #"JSON"[stream][object][children],
|
||||
nextCursor = children[cursor],
|
||||
clean = Speckle.CleanUpObjects(children[objects])
|
||||
in
|
||||
Table.FromRecords(clean) meta [Cursor = nextCursor]
|
||||
@@ -1,27 +0,0 @@
|
||||
(url as text) =>
|
||||
let
|
||||
userType = type [name = text, email = text, id = text],
|
||||
query = "query {
|
||||
activeUser { name email id }
|
||||
}",
|
||||
// Imports
|
||||
Speckle.Api.Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
|
||||
ParseUrl = Extension.LoadFunction("ParseStreamUrl.pqm"),
|
||||
urlObject = ParseUrl(url),
|
||||
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]
|
||||
],
|
||||
user = Speckle.Api.Fetch(urlObject[server], query)[activeUser]
|
||||
in
|
||||
// Read receipts should fail gracefully no matter what
|
||||
Value.ReplaceType(user, userType)
|
||||
@@ -1,38 +0,0 @@
|
||||
(server as text, optional streamId as text, optional objectId as text) as table =>
|
||||
let
|
||||
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
|
||||
Source = Web.Contents(
|
||||
Text.Combine({server, "objects", streamId, objectId}, "/"),
|
||||
[
|
||||
Headers = [
|
||||
#"Method" = "GET",
|
||||
#"Content-Type" = "application/json",
|
||||
#"Authorization" = if apiKey = null then "" else Text.Format("Bearer #{0}", {apiKey})
|
||||
],
|
||||
ManualStatusHandling = {400}
|
||||
]
|
||||
),
|
||||
json = Json.Document(Source),
|
||||
clean = List.Select(json, each _[speckle_type] <> "Speckle.Core.Models.DataChunk"),
|
||||
t = Table.FromColumns({clean}, {"data"}),
|
||||
addStreamUrl = Table.AddColumn(t, "Stream URL", each server & "/streams/" & streamId),
|
||||
addObjectIdCol = Table.AddColumn(addStreamUrl, "Object ID", each try _[data][id] otherwise null),
|
||||
addSpeckleTypeCol = Table.AddColumn(
|
||||
addObjectIdCol, "speckle_type", each try _[data][speckle_type] otherwise null
|
||||
),
|
||||
Speckle.CleanUpObjects = Extension.LoadFunction("CleanUpObjects.pqm"),
|
||||
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]
|
||||
]
|
||||
in
|
||||
addSpeckleTypeCol
|
||||
@@ -1,7 +0,0 @@
|
||||
(object as record) as record =>
|
||||
let
|
||||
hiddenFields = {"__closure", "totalChildrenCount"},
|
||||
// remove closures from records
|
||||
clean = Record.RemoveFields(object, hiddenFields, MissingField.Ignore)
|
||||
in
|
||||
clean
|
||||
@@ -1,17 +0,0 @@
|
||||
(objects as list) as list =>
|
||||
let
|
||||
// remove closures from records, and remove DataChunk records
|
||||
removeClosureField = List.Transform(
|
||||
objects, each [data = Record.RemoveFields(_[data], "__closure", MissingField.Ignore)]
|
||||
),
|
||||
removeTotals = List.Transform(
|
||||
removeClosureField,
|
||||
each
|
||||
[
|
||||
data = try
|
||||
Record.RemoveFields(_[data], "totalChildrenCount", MissingField.Ignore) otherwise _[data]
|
||||
]
|
||||
),
|
||||
removed = List.Select(removeTotals, each _[data][speckle_type] <> "Speckle.Core.Models.DataChunk")
|
||||
in
|
||||
removed
|
||||
@@ -1,30 +0,0 @@
|
||||
let
|
||||
beta = true,
|
||||
category = "Other",
|
||||
icons = [
|
||||
Icon16 = {
|
||||
Extension.Contents("SpeckleLogo16.png"),
|
||||
Extension.Contents("SpeckleLogo20.png"),
|
||||
Extension.Contents("SpeckleLogo24.png"),
|
||||
Extension.Contents("SpeckleLogo32.png")
|
||||
},
|
||||
Icon32 = {
|
||||
Extension.Contents("SpeckleLogo32.png"),
|
||||
Extension.Contents("SpeckleLogo40.png"),
|
||||
Extension.Contents("SpeckleLogo48.png"),
|
||||
Extension.Contents("SpeckleLogo64.png")
|
||||
}
|
||||
]
|
||||
in
|
||||
(key as text) as record =>
|
||||
[
|
||||
Beta = beta,
|
||||
Category = category,
|
||||
ButtonText = {
|
||||
Extension.LoadString(Text.Format("#{0}.Title", {key})),
|
||||
Extension.LoadString(Text.Format("#{0}.Label", {key}))
|
||||
},
|
||||
LearnMoreUrl = "https://speckle.guide",
|
||||
SourceImage = icons,
|
||||
SourceTypeImage = icons
|
||||
]
|
||||
@@ -1,50 +0,0 @@
|
||||
let
|
||||
GetApplicationSlug = Extension.LoadFunction("GetApplicationSlug.pqm"),
|
||||
GetUser = Extension.LoadFunction("Api.GetUser.pqm"),
|
||||
Hash = Extension.LoadFunction("Hash.pqm"),
|
||||
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]
|
||||
]
|
||||
in
|
||||
(server as text, commit as any) =>
|
||||
let
|
||||
trackUrl = "https://analytics.speckle.systems/track?ip=1",
|
||||
user = GetUser(server),
|
||||
isMultiplayer = user[id] <> commit[authorId],
|
||||
body = [
|
||||
event = "Receive",
|
||||
properties = [
|
||||
server_id = Hash(server),
|
||||
token = "acd87c5a50b56df91a795e999812a3a4",
|
||||
hostApp = "powerbi",
|
||||
sourceHostApp = GetApplicationSlug(commit[sourceApplication]),
|
||||
sourceHostAppVersion = commit[sourceApplication],
|
||||
isMultiplayer = user[id] <> commit[authorId]
|
||||
]
|
||||
],
|
||||
Result = Web.Contents(
|
||||
trackUrl,
|
||||
[
|
||||
Headers = [
|
||||
#"Method" = "POST",
|
||||
#"Accept" = "text/plain",
|
||||
#"Content-Type" = "application/json"
|
||||
],
|
||||
Content = Text.ToBinary(Text.Combine({"data=", Text.FromBinary(Json.FromValue(body))}))
|
||||
]
|
||||
),
|
||||
// Hack to force execution
|
||||
Join = Text.Combine({server, Text.From(Json.Document(Result))}, "_____"),
|
||||
Disjoin = Text.Split(Join, "_____"){0}
|
||||
in
|
||||
Disjoin
|
||||
@@ -1,51 +0,0 @@
|
||||
let
|
||||
GetModel = Extension.LoadFunction("Api.GetModel.pqm"),
|
||||
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]
|
||||
]
|
||||
in
|
||||
(url as text) as record =>
|
||||
let
|
||||
// Get server and streamId, and branchName / commitId / objectid from the input url
|
||||
server = Text.Combine({Uri.Parts(url)[Scheme], "://", Uri.Parts(url)[Host]}),
|
||||
segments = Text.Split(Text.AfterDelimiter(Uri.Parts(url)[Path], "/", 0), "/"),
|
||||
streamId = segments{1},
|
||||
modelList = if (List.Count(segments) = 4 and segments{2} = "models") then segments{3} else null,
|
||||
firstModel = Text.Split(modelList, ","){0},
|
||||
modelAndVersion = Text.Split(firstModel, "@"),
|
||||
modelId = modelAndVersion{0},
|
||||
versionId = if (List.Count(modelAndVersion) > 1) then modelAndVersion{1} else null,
|
||||
model = if (modelId <> null) then GetModel(server, streamId, modelId) else null,
|
||||
branchName = if (List.Count(segments) = 4 and segments{2} = "branches") then segments{3} else null,
|
||||
commitId = if (List.Count(segments) = 4 and segments{2} = "commits") then segments{3} else null,
|
||||
objectId = if (List.Count(segments) = 4 and segments{2} = "objects") then segments{3} else null,
|
||||
modelOrBranchName = if (model <> null) then model[name] else branchName,
|
||||
commitOrVersion = if (versionId <> null) then versionId else commitId,
|
||||
urlType =
|
||||
if (commitOrVersion <> null) then
|
||||
"Commit"
|
||||
else if (objectId <> null) then
|
||||
"Object"
|
||||
else if (modelOrBranchName <> null) then
|
||||
"Branch"
|
||||
else
|
||||
"Stream"
|
||||
in
|
||||
[
|
||||
urlType = urlType,
|
||||
server = server,
|
||||
id = streamId,
|
||||
branch = modelOrBranchName,
|
||||
commit = commitOrVersion,
|
||||
object = objectId
|
||||
]
|
||||
@@ -1,67 +0,0 @@
|
||||
let
|
||||
GetObject = Extension.LoadFunction("Api.GetObject.pqm"),
|
||||
Diagnostics.Log = Extension.LoadFunction("Diagnostics.pqm")[LogValue],
|
||||
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]
|
||||
],
|
||||
//TODO: Not implemented yet
|
||||
TraverseTable = (item as table) as table => item,
|
||||
// Will traverse an undetermined value (list, table, record).
|
||||
TraverseValue = (i as any) as any =>
|
||||
let
|
||||
item = Diagnostics.Log("Traverse value", i) meta Value.Metadata(i)
|
||||
in
|
||||
if Value.Is(item, type list) then
|
||||
// Return a transformed list by traversing all items
|
||||
Diagnostics.Log(
|
||||
"List travered",
|
||||
List.Transform(item, (a) => @TraverseValue(Value.ReplaceMetadata(a, Value.Metadata(i))))
|
||||
)
|
||||
else if Value.Is(item, type record) then
|
||||
// Traverse this record individually
|
||||
TraverseRecord(item)
|
||||
else if Value.Is(item, type table) then
|
||||
// Traverse this table
|
||||
TraverseTable(item)
|
||||
else
|
||||
// If none of the above, assume it's just a primitive type and return it as-is.
|
||||
item,
|
||||
// Traverses a generic record
|
||||
TraverseRecord = (object as record) as any =>
|
||||
let
|
||||
isSpeckle = Diagnostics.Log("Is Speckle", Record.HasFields(object, {"speckle_type"})),
|
||||
isReference = Diagnostics.Log("Is Reference", object[speckle_type] = "reference"),
|
||||
// Get the names of all fields
|
||||
fields = Record.FieldNames(object),
|
||||
// Remove all known fields that don't need traversing
|
||||
cleanFields = List.RemoveItems(fields, {"id", "speckle_type", "applicationId"}),
|
||||
// Transform the list of field names into a set of transform operations
|
||||
transformOps = List.Transform(
|
||||
cleanFields, each {_, (a) => TraverseValue(Value.ReplaceMetadata(a, Value.Metadata(object)))}
|
||||
),
|
||||
// Get the object's metadata (server and stream will be saved in here)
|
||||
info = Value.Metadata(object)
|
||||
in
|
||||
// Transform all fields and return the modified object
|
||||
if (isReference) then
|
||||
// Swap reference for call to GetObject
|
||||
() =>
|
||||
TraverseValue(
|
||||
Value.ReplaceMetadata(
|
||||
GetObject(info[server], info[stream], object[referencedId]), Value.Metadata(object)
|
||||
)
|
||||
)
|
||||
else
|
||||
try Record.TransformFields(object, transformOps, MissingField.Error) otherwise error "oopsies"
|
||||
in
|
||||
TraverseValue
|
||||
@@ -0,0 +1,397 @@
|
||||
[Version = "3.0.0"]
|
||||
section Speckle;
|
||||
|
||||
AuthAppId = "spklpwerbi";
|
||||
AuthAppSecret = "spklpwerbi";
|
||||
|
||||
// PKCE helper functions for enhanced OAuth2 security
|
||||
Base64UrlEncode = (binaryData as binary) =>
|
||||
let
|
||||
// Convert binary to base64
|
||||
base64 = Binary.ToText(binaryData, BinaryEncoding.Base64),
|
||||
// Convert to base64url by replacing characters and removing padding
|
||||
base64url = Text.Replace(Text.Replace(Text.Replace(base64, "+", "-"), "/", "_"), "=", "")
|
||||
in
|
||||
base64url;
|
||||
|
||||
GeneratePKCEVerifier = () =>
|
||||
let
|
||||
// Generate cryptographically secure random string using allowed characters
|
||||
// RFC 7636: [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
|
||||
allowedChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~",
|
||||
|
||||
// Generate multiple GUIDs to create entropy
|
||||
guid1 = Text.Replace(Text.Replace(Text.NewGuid(), "-", ""), "{", ""),
|
||||
guid2 = Text.Replace(Text.Replace(Text.NewGuid(), "-", ""), "}", ""),
|
||||
guid3 = Text.Replace(Text.Replace(Text.NewGuid(), "-", ""), "{", ""),
|
||||
guid4 = Text.Replace(Text.Replace(Text.NewGuid(), "-", ""), "}", ""),
|
||||
|
||||
// Combine and convert to allowed characters
|
||||
combined = guid1 & guid2 & guid3 & guid4,
|
||||
|
||||
// Map hex characters to allowed PKCE characters
|
||||
mapped = Text.Replace(
|
||||
Text.Replace(
|
||||
Text.Replace(
|
||||
Text.Replace(
|
||||
Text.Replace(
|
||||
Text.Replace(combined, "0", "A"),
|
||||
"1", "B"),
|
||||
"2", "C"),
|
||||
"3", "D"),
|
||||
"4", "E"),
|
||||
"5", "F"),
|
||||
|
||||
// Continue mapping remaining hex chars to allowed chars
|
||||
verifier = Text.Replace(
|
||||
Text.Replace(
|
||||
Text.Replace(
|
||||
Text.Replace(
|
||||
Text.Replace(
|
||||
Text.Replace(
|
||||
Text.Replace(
|
||||
Text.Replace(
|
||||
Text.Replace(
|
||||
Text.Replace(mapped, "6", "G"),
|
||||
"7", "H"),
|
||||
"8", "I"),
|
||||
"9", "J"),
|
||||
"a", "K"),
|
||||
"b", "L"),
|
||||
"c", "M"),
|
||||
"d", "N"),
|
||||
"e", "O"),
|
||||
"f", "P"),
|
||||
|
||||
// Ensure length is between 43-128 characters as per RFC 7636
|
||||
finalVerifier = Text.Start(verifier, 43)
|
||||
in
|
||||
finalVerifier;
|
||||
|
||||
GeneratePKCEChallenge = (verifier as text) =>
|
||||
let
|
||||
// Create SHA256 hash of the verifier as required by RFC 7636
|
||||
hash = Crypto.CreateHash(CryptoAlgorithm.SHA256, Text.ToBinary(verifier, TextEncoding.Utf8)),
|
||||
// Convert to base64url encoding
|
||||
challenge = Base64UrlEncode(hash)
|
||||
in
|
||||
challenge;
|
||||
|
||||
// function to load `pqm` files - this is essential and must be kept
|
||||
shared Speckle.LoadFunction = (fileName as text) =>
|
||||
let
|
||||
binary = Extension.Contents(fileName),
|
||||
asText = Text.FromBinary(binary)
|
||||
in
|
||||
try
|
||||
Expression.Evaluate(asText, #shared)
|
||||
catch (e) =>
|
||||
error
|
||||
[
|
||||
Reason = "Speckle.LoadFunction Failure",
|
||||
Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'",
|
||||
Message.Parameters = {fileName, e[Reason], e[Message]},
|
||||
Detail = [File = fileName, Error = e]
|
||||
];
|
||||
|
||||
// here we register the functions to expose them globally
|
||||
[DataSource.Kind = "Speckle"]
|
||||
shared Speckle.Parser = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Parser.pqm"),
|
||||
type function (url as Uri.Type) as record
|
||||
);
|
||||
|
||||
[DataSource.Kind = "Speckle"]
|
||||
shared Speckle.Api.Fetch = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Api.Fetch.pqm"),
|
||||
type function (url as Uri.Type, optional query as text, optional variables as record) as record
|
||||
);
|
||||
|
||||
[DataSource.Kind = "Speckle"]
|
||||
shared Speckle.CheckPermissions = Value.ReplaceType(
|
||||
Speckle.LoadFunction("CheckPermissions.pqm"),
|
||||
type function (url as Uri.Type) as record
|
||||
);
|
||||
|
||||
[DataSource.Kind = "Speckle"]
|
||||
shared Speckle.GetUser = Value.ReplaceType(
|
||||
Speckle.LoadFunction("GetUser.pqm"),
|
||||
type function (url as Uri.Type) as record
|
||||
);
|
||||
|
||||
[DataSource.Kind = "Speckle"]
|
||||
shared Speckle.GetModel = Value.ReplaceType(
|
||||
Speckle.LoadFunction("GetModel.pqm"),
|
||||
type function (url as Uri.Type) as record
|
||||
);
|
||||
|
||||
[DataSource.Kind = "Speckle"]
|
||||
shared Speckle.GetStructuredData = Value.ReplaceType(
|
||||
Speckle.LoadFunction("GetStructuredData.pqm"),
|
||||
type function (url as Uri.Type) as table
|
||||
);
|
||||
|
||||
shared Speckle.GetVersion = Value.ReplaceType(
|
||||
Speckle.LoadFunction("GetVersion.pqm"),
|
||||
type function () as text
|
||||
);
|
||||
|
||||
[DataSource.Kind = "Speckle"]
|
||||
shared Speckle.SendToServer = Value.ReplaceType(
|
||||
Speckle.LoadFunction("SendToServer.pqm"),
|
||||
type function (url as Uri.Type) as table
|
||||
);
|
||||
|
||||
[DataSource.Kind = "Speckle"]
|
||||
shared Speckle.GetWorkspace = Value.ReplaceType(
|
||||
Speckle.LoadFunction("GetWorkspace.pqm"),
|
||||
type function (url as Uri.Type) as record
|
||||
);
|
||||
|
||||
shared Speckle.Objects.Properties = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Objects.Properties.pqm"),
|
||||
type function (inputRecord as any, optional filterKeys as list, optional parentPath as text, optional existingFields as list) as record
|
||||
);
|
||||
|
||||
|
||||
shared Speckle.Utils.ExpandRecord = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Utils.ExpandRecord.pqm"),
|
||||
type function (
|
||||
table as table,
|
||||
columnName as text,
|
||||
optional FieldNames as list,
|
||||
optional UseCombinedNames as logical
|
||||
) as table
|
||||
);
|
||||
|
||||
shared Speckle.Objects.Collections = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Objects.Collections.pqm"),
|
||||
type function (inputData as table) as table
|
||||
);
|
||||
|
||||
shared Speckle.Objects.CompositeStructure = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Objects.CompositeStructure.pqm"),
|
||||
type function (objectRecord as record, optional outputAsList as nullable logical) as any
|
||||
);
|
||||
|
||||
shared Speckle.Objects.MaterialQuantities = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Objects.MaterialQuantities.pqm"),
|
||||
type function (objectRecord as record, optional outputAsList as logical) as any
|
||||
);
|
||||
|
||||
shared Speckle.Models.Federate = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Models.Federate.pqm"),
|
||||
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"]
|
||||
shared Speckle.Project.Issues = Value.ReplaceType(
|
||||
Speckle.LoadFunction("Project.Issues.pqm"),
|
||||
type function (url as Uri.Type, optional getReplies as logical) as table
|
||||
);
|
||||
|
||||
[DataSource.Kind = "Speckle", Publish="GetByUrl.Publish"]
|
||||
shared Speckle.GetByUrl = Value.ReplaceType(
|
||||
Speckle.LoadFunction("GetByUrl.pqm"),
|
||||
type function (
|
||||
url as (
|
||||
Uri.Type meta [
|
||||
Documentation.FieldCaption = "Speckle Model URL",
|
||||
Documentation.FieldDescription = "The URL of a model in a Speckle server project. You can copy it directly from your browser.",
|
||||
Documentation.SampleValues = {"https://app.speckle.systems/projects/7902de1f57/models/7f890a65df"}
|
||||
]
|
||||
),
|
||||
optional ExpandProperties as (
|
||||
type logical meta [
|
||||
Documentation.FieldCaption = "Expand Properties (may slow query)",
|
||||
Documentation.FieldDescription = "Expand the properties column into individual columns for easier analysis. When checked, each property from the 'properties' record column will have its own column. This can slow down the query if you have a lot of properties.",
|
||||
Documentation.AllowedValues = {true, false}
|
||||
]
|
||||
)
|
||||
) as table meta [
|
||||
Documentation.Name = "Speckle - Get Data by URL",
|
||||
Documentation.DisplayName = "Speckle - Get Data by URL",
|
||||
Documentation.LongDescription = "Returns structured data from a Speckle model URL.#(lf)
|
||||
Supports the following URL formats:#(lf)
|
||||
- Model URL: Gets the latest version of the specified model#(lf)
|
||||
(e.g., 'https://app.speckle.systems/projects/PROJECT_ID/models/MODEL_ID')#(lf)
|
||||
- Version URL: Gets a specific version from the project#(lf)
|
||||
(e.g., 'https://app.speckle.systems/projects/PROJECT_ID/models/MODEL_ID@VERSION_ID')"
|
||||
]
|
||||
);
|
||||
|
||||
shared Speckle.Revit.Parameters.ToNameValueRecord = (r as record, optional exclude as list) as record =>
|
||||
let
|
||||
defaultExclude = {"id", "speckle_type", "applicationId", "totalChildrenCount"},
|
||||
fullExclusion = if exclude = null then defaultExclude else List.Union(defaultExclude, exclude),
|
||||
clean = Record.RemoveFields(r, fullExclusion, MissingField.Ignore),
|
||||
recTable = Record.ToTable(clean),
|
||||
cleanTable = Table.RemoveColumns(recTable, "Name"),
|
||||
expanded = Table.ExpandRecordColumn(
|
||||
cleanTable, "Value", {"name", "value", "applicationInternalName"}, {"Name", "Value", "UID"}
|
||||
),
|
||||
joined = Table.AddColumn(expanded, "Combo", each [Name] & " [" & [UID] & "]"),
|
||||
renamed = Table.RenameColumns(joined, {{"Name", "x"}, {"Combo", "Name"}}),
|
||||
result = Record.FromTable(renamed)
|
||||
in
|
||||
result;
|
||||
|
||||
// here we register the GetByUrl function to power bi ui
|
||||
GetByUrl.Publish = [
|
||||
Cateogry = "Other",
|
||||
ButtonText = {"Connect to Speckle"},
|
||||
LearnMoreUrl = "https://speckle.guide/user/powerbi/introduction.html",
|
||||
SourceImage = GetByUrl.Icons,
|
||||
SourceTypeImage = GetByUrl.Icons
|
||||
];
|
||||
|
||||
GetByUrl.Icons = [
|
||||
Icon16 = { Extension.Contents("SpeckleLogo16.png"), Extension.Contents("SpeckleLogo20.png"), Extension.Contents("SpeckleLogo24.png"), Extension.Contents("SpeckleLogo32.png") },
|
||||
Icon32 = { Extension.Contents("SpeckleLogo32.png"), Extension.Contents("SpeckleLogo40.png"), Extension.Contents("SpeckleLogo48.png"), Extension.Contents("SpeckleLogo64.png") }
|
||||
];
|
||||
|
||||
|
||||
// The data source definition
|
||||
Speckle = [
|
||||
// This is used when running the connector on an on-premises data gateway
|
||||
TestConnection = (path) => {"Speckle.GetUser", path},
|
||||
// Authentication strategy - OAuth only
|
||||
Authentication = [
|
||||
OAuth = [
|
||||
Label = "Speckle Account",
|
||||
StartLogin = (clientApplication, dataSourcePath, state, display) =>
|
||||
let
|
||||
server = Text.Combine(
|
||||
{Uri.Parts(dataSourcePath)[Scheme], "://", Uri.Parts(dataSourcePath)[Host]}
|
||||
),
|
||||
// Generate PKCE parameters for enhanced security
|
||||
codeVerifier = GeneratePKCEVerifier(),
|
||||
codeChallenge = GeneratePKCEChallenge(codeVerifier),
|
||||
// Detect if server supports /oauth/token
|
||||
oauthCheck = try Web.Contents(
|
||||
Text.Combine({server, "oauth", "token"}, "/"),
|
||||
[ManualStatusHandling = {400, 401, 403, 404, 405, 500}]
|
||||
) otherwise null,
|
||||
useNewOAuth = oauthCheck <> null and Value.Metadata(oauthCheck)[Response.Status] = 200,
|
||||
// Build auth URL based on server capabilities
|
||||
authUrl = if useNewOAuth then
|
||||
Text.Combine({server, "authn", "verify", AuthAppId, codeChallenge}, "/") &
|
||||
"?code_challenge_method=S256" &
|
||||
"&pbiNew=true"
|
||||
else
|
||||
// Legacy
|
||||
Text.Combine({server, "authn", "verify", AuthAppId, codeVerifier}, "/")
|
||||
in
|
||||
[
|
||||
LoginUri = authUrl,
|
||||
CallbackUri = "https://oauth.powerbi.com/views/oauthredirect.html",
|
||||
WindowHeight = 800,
|
||||
WindowWidth = 600,
|
||||
Context = [code_verifier = codeVerifier, use_new_oauth = useNewOAuth]
|
||||
],
|
||||
FinishLogin = (clientApplication, dataSourcePath, context, callbackUri, state) =>
|
||||
let
|
||||
server = Text.Combine(
|
||||
{Uri.Parts(dataSourcePath)[Scheme], "://", Uri.Parts(dataSourcePath)[Host]}
|
||||
),
|
||||
Parts = Uri.Parts(callbackUri)[Query],
|
||||
codeVerifier = if context <> null then context[code_verifier] else null,
|
||||
useNewOAuth = if context <> null and Record.HasFields(context, "use_new_oauth") then context[use_new_oauth] else false,
|
||||
// Single token exchange call based on server capability
|
||||
Source = if useNewOAuth then
|
||||
Web.Contents(
|
||||
Text.Combine({server, "oauth", "token"}, "/"),
|
||||
[
|
||||
Headers = [#"Content-Type" = "application/json"],
|
||||
Content = Json.FromValue([
|
||||
appId = AuthAppId,
|
||||
accessCode = Parts[access_code],
|
||||
codeVerifier = codeVerifier
|
||||
])
|
||||
]
|
||||
)
|
||||
else
|
||||
// Legacy
|
||||
Web.Contents(
|
||||
Text.Combine({server, "auth", "token"}, "/"),
|
||||
[
|
||||
Headers = [#"Content-Type" = "application/json"],
|
||||
Content = Json.FromValue([
|
||||
appId = AuthAppId,
|
||||
appSecret = AuthAppSecret,
|
||||
accessCode = Parts[access_code],
|
||||
challenge = codeVerifier
|
||||
])
|
||||
]
|
||||
),
|
||||
json = Json.Document(Source)
|
||||
in
|
||||
[
|
||||
access_token = json[token],
|
||||
scope = null,
|
||||
token_type = "bearer",
|
||||
refresh_token = json[refreshToken]
|
||||
],
|
||||
Refresh = (dataSourcePath, refreshToken) =>
|
||||
let
|
||||
server = Text.Combine(
|
||||
{Uri.Parts(dataSourcePath)[Scheme], "://", Uri.Parts(dataSourcePath)[Host]}
|
||||
),
|
||||
// Enhanced refresh with error handling for gateway compatibility
|
||||
Source = try Web.Contents(
|
||||
Text.Combine({server, "auth", "token"}, "/"),
|
||||
[
|
||||
Headers = [
|
||||
#"Content-Type" = "application/json"
|
||||
],
|
||||
Content = Json.FromValue(
|
||||
[
|
||||
refreshToken = refreshToken,
|
||||
appId = AuthAppId,
|
||||
appSecret = AuthAppSecret
|
||||
]
|
||||
),
|
||||
ManualStatusHandling = {400, 401, 403, 500, 502, 503, 504}
|
||||
]
|
||||
) otherwise null,
|
||||
|
||||
// Check if request was successful
|
||||
IsSuccess = Source <> null,
|
||||
|
||||
// If successful, parse the response
|
||||
json = if IsSuccess then
|
||||
try Json.Document(Source) otherwise null
|
||||
else
|
||||
null,
|
||||
|
||||
// Validate the response contains expected fields
|
||||
IsValidResponse = json <> null and Record.HasFields(json, {"token"}),
|
||||
|
||||
// Return result with enhanced error handling
|
||||
result = if IsValidResponse then
|
||||
[
|
||||
access_token = json[token],
|
||||
scope = null,
|
||||
token_type = "bearer",
|
||||
refresh_token = json[refreshToken]
|
||||
]
|
||||
else
|
||||
error [
|
||||
Reason = "TokenRefreshFailed",
|
||||
Message = "Failed to refresh OAuth token - please re-authenticate",
|
||||
Detail = [
|
||||
Server = server,
|
||||
RefreshToken = if refreshToken = null then "null" else "present"
|
||||
]
|
||||
]
|
||||
in
|
||||
result
|
||||
]
|
||||
],
|
||||
Label = "Speckle"
|
||||
];
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"
|
||||
DefaultTargets="BuildMez">
|
||||
<PropertyGroup>
|
||||
<Version Condition="'$(Version)' == ''">2.0.0-wip</Version>
|
||||
<OutputPath Condition="'$(OutputPath)' == ''">$(MSBuildProjectDirectory)\bin\</OutputPath>
|
||||
<IntermediateOutputPath Condition="'$(IntermediateOutputPath)' == ''">
|
||||
$(MSBuildProjectDirectory)\obj\</IntermediateOutputPath>
|
||||
<MezIntermediatePath>$(IntermediateOutputPath)MEZ\</MezIntermediatePath>
|
||||
<MezOutputPath>$(OutputPath)$(MsBuildProjectName).mez</MezOutputPath>
|
||||
<IsContinuousIntegrationBuild>false</IsContinuousIntegrationBuild>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<MezContent Include="Speckle.pq" />
|
||||
<MezContent Include="speckle\**\*.pqm" />
|
||||
<MezContent Include="assets\SpeckleLogo16.png" />
|
||||
<MezContent Include="assets\SpeckleLogo20.png" />
|
||||
<MezContent Include="assets\SpeckleLogo24.png" />
|
||||
<MezContent Include="assets\SpeckleLogo32.png" />
|
||||
<MezContent Include="assets\SpeckleLogo40.png" />
|
||||
<MezContent Include="assets\SpeckleLogo48.png" />
|
||||
<MezContent Include="assets\SpeckleLogo64.png" />
|
||||
<MezContent Include="assets\SpeckleLogo80.png" />
|
||||
<MezContent Include="assets\resources.resx" />
|
||||
</ItemGroup>
|
||||
<Target Name="BuildMez" AfterTargets="Build" Inputs="@(MezContent)" Outputs="$(MezOutputPath)">
|
||||
<RemoveDir Directories="$(MezIntermediatePath)" />
|
||||
<Copy SourceFiles="@(MezContent)" DestinationFolder="$(MezIntermediatePath)" />
|
||||
<MakeDir Directories="$(OutputPath)" Condition="!Exists('$(OutputPath)')" />
|
||||
<ZipDirectory SourceDirectory="$(MezIntermediatePath)" DestinationFile="$(MezOutputPath)"
|
||||
Overwrite="true" />
|
||||
</Target>
|
||||
<Target Name="CopyToConnectors" AfterTargets="BuildMez"
|
||||
Condition="$(IsContinuousIntegrationBuild) == 'false'">
|
||||
<Message
|
||||
Text="Copying .mez file to: $(UserProfile)\Documents\Power BI Desktop\Custom Connectors"
|
||||
Importance="High" />
|
||||
<MakeDir Directories="$(UserProfile)\Documents\Power BI Desktop\Custom Connectors\" />
|
||||
<Copy SourceFiles="$(MezOutputPath)"
|
||||
DestinationFolder="$(UserProfile)\Documents\Power BI Desktop\Custom Connectors\" />
|
||||
</Target>
|
||||
<Target Name="Clean">
|
||||
<RemoveDir Directories="$(MezIntermediatePath)" />
|
||||
<Delete Files="$(MezOutputPath)" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
// use this file to write queries to test your data connector
|
||||
|
||||
// NOTE! for tests, be make sure you put here a model that in private project to make sure all good.
|
||||
let
|
||||
result = Speckle.GetByUrl(
|
||||
"https://app.speckle.systems/projects/b61ab234b0/models/a8166255b5"
|
||||
)
|
||||
in
|
||||
result
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,179 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing"
|
||||
mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework
|
||||
object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0,
|
||||
Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0,
|
||||
Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="GetByUrl.Help" xml:space="preserve">
|
||||
<value>Connect to Speckle by Model URL</value>
|
||||
</data>
|
||||
<data name="GetByUrl.Label" xml:space="preserve">
|
||||
<value>Speckle</value>
|
||||
</data>
|
||||
<data name="GetByUrl.Title" xml:space="preserve">
|
||||
<value>Speckle - Get Model by URL</value>
|
||||
</data>
|
||||
<data name="GetObjFromBranch.Help" xml:space="preserve">
|
||||
<value>Connect to Speckle by server URL, stream ID and branch name</value>
|
||||
</data>
|
||||
<data name="GetObjFromBranch.Label" xml:space="preserve">
|
||||
<value>Get the latest commit from a stream's branch</value>
|
||||
</data>
|
||||
<data name="GetObjFromBranch.Title" xml:space="preserve">
|
||||
<value>Speckle - Get Stream branch</value>
|
||||
</data>
|
||||
<data name="GetObjFromCommit.Help" xml:space="preserve">
|
||||
<value>Connect to Speckle by server URL, stream ID and commit ID</value>
|
||||
</data>
|
||||
<data name="GetObjFromCommit.Label" xml:space="preserve">
|
||||
<value>A label</value>
|
||||
</data>
|
||||
<data name="GetObjFromCommit.Title" xml:space="preserve">
|
||||
<value>Speckle - Get Stream commit</value>
|
||||
</data>
|
||||
<data name="GetStream.Help" xml:space="preserve">
|
||||
<value>Connect to Speckle by server URL and stream ID</value>
|
||||
</data>
|
||||
<data name="GetStream.Label" xml:space="preserve">
|
||||
<value>Speckle</value>
|
||||
</data>
|
||||
<data name="GetStream.Title" xml:space="preserve">
|
||||
<value>Speckle - Get Model by URL [Structured]</value>
|
||||
</data>
|
||||
<data name="GetObjectAsNavTable.Title" xml:space="preserve">
|
||||
<value>Speckle - Get Object as NavTable</value>
|
||||
</data>
|
||||
<data name="GetObjectAsNavTable.Label" xml:space="preserve">
|
||||
<value>Speckle</value>
|
||||
</data>
|
||||
<data name="GetObjectAsNavTable.Help" xml:space="preserve">
|
||||
<value>Returns a navigation table for a given object</value>
|
||||
</data>
|
||||
<data name="Traverse.Title" xml:space="preserve">
|
||||
<value>Traverse an object and populate refs</value>
|
||||
</data>
|
||||
<data name="Traverse.Label" xml:space="preserve">
|
||||
<value>Traverse</value>
|
||||
</data>
|
||||
<data name="Traverse.Help" xml:space="preserve">
|
||||
<value>Traverse help</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -0,0 +1,300 @@
|
||||
(url as text, optional ExpandProperties as logical) as table =>
|
||||
let
|
||||
// set default value for ExpandProperties
|
||||
shouldExpandProperties = if ExpandProperties = null then false else ExpandProperties,
|
||||
|
||||
// import required functions
|
||||
GetStructuredData = Extension.LoadFunction("GetStructuredData.pqm"),
|
||||
SendToServer = Extension.LoadFunction("SendToServer.pqm"),
|
||||
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"),
|
||||
MarkReceived = Extension.LoadFunction("MarkReceived.pqm"),
|
||||
|
||||
// the logic for importing functions from other files
|
||||
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]
|
||||
],
|
||||
|
||||
// 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})",
|
||||
{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],
|
||||
parsedUrl[resourceIdString]
|
||||
),
|
||||
|
||||
// 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
|
||||
if parsedUrl[isFederated] = true then
|
||||
// process each model in the federation
|
||||
let
|
||||
modelsData = List.Transform(
|
||||
parsedUrl[federatedModels],
|
||||
each ProcessSingleModel(
|
||||
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 both columns with combined values
|
||||
transformedData = Table.TransformColumns(
|
||||
combinedData,
|
||||
{
|
||||
{"Version Object ID", each combinedRootIds},
|
||||
{"Model Info", each combinedEncodedUserInfos}
|
||||
}
|
||||
),
|
||||
|
||||
// expand properties column if requested and if it exists
|
||||
finalData = if shouldExpandProperties and Table.HasColumns(transformedData, {"properties"}) then
|
||||
try
|
||||
Speckle.Utils.ExpandRecord(transformedData, "properties")
|
||||
otherwise
|
||||
transformedData // fallback to original data if expansion fails
|
||||
else
|
||||
transformedData
|
||||
in
|
||||
finalData
|
||||
else
|
||||
// use existing functionality for single models
|
||||
let
|
||||
// get model info
|
||||
modelInfo = GetModel(url),
|
||||
modelName = modelInfo[modelName],
|
||||
rootObjectId = modelInfo[rootObjectId],
|
||||
sourceApplication = modelInfo[sourceApplication],
|
||||
versionId = modelInfo[versionId],
|
||||
|
||||
// mark version as received
|
||||
markReceivedResult = MarkReceived(powerfulToken, versionId, parsedUrl[projectId], parsedUrl[baseUrl]),
|
||||
|
||||
// get structured data
|
||||
structuredData = GetStructuredData(url),
|
||||
|
||||
// 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) and (markReceivedResult or not markReceivedResult) then encodedUserInfo else encodedUserInfo}
|
||||
}
|
||||
),
|
||||
|
||||
// expand properties column if requested and if it exists
|
||||
result = if shouldExpandProperties and Table.HasColumns(transformedData, {"properties"}) then
|
||||
try
|
||||
Speckle.Utils.ExpandRecord(transformedData, "properties")
|
||||
otherwise
|
||||
transformedData // fallback to original data if expansion fails
|
||||
else
|
||||
transformedData
|
||||
in
|
||||
result
|
||||
else
|
||||
error Text.Format(
|
||||
"Permission denied: #{0} (Error code: #{1})",
|
||||
{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) =>
|
||||
let
|
||||
// construct a standard URL for the model
|
||||
singleModelUrl = Text.Combine({
|
||||
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],
|
||||
federatedVersionId = if versionId <> null then versionId else modelInfo[versionId],
|
||||
|
||||
// mark version as received (non-blocking, best-effort)
|
||||
markReceivedResult = MarkReceived(powerfulToken, federatedVersionId, projectId, baseUrl),
|
||||
|
||||
// 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 and markReceivedResult to force evaluation
|
||||
result = Table.AddColumn(
|
||||
structuredData,
|
||||
"Source Model",
|
||||
each if versionId <> null then
|
||||
if (markReceivedResult or not markReceivedResult) then Text.Combine({modelName, "-", versionId}) else Text.Combine({modelName, "-", versionId})
|
||||
else if (desktopServiceSent or not desktopServiceSent) and (markReceivedResult or not markReceivedResult) then
|
||||
modelName
|
||||
else
|
||||
modelName,
|
||||
type text
|
||||
)
|
||||
in
|
||||
[
|
||||
Data = result,
|
||||
RootObjectId = rootObjectId,
|
||||
EncodedUserInfo = encodedUserInfo
|
||||
]
|
||||
in
|
||||
results
|
||||
@@ -0,0 +1,79 @@
|
||||
(server as text, optional query as text, optional variables as record) as record =>
|
||||
let
|
||||
// Enhanced credential retrieval with OAuth2 support
|
||||
apiKey = try Extension.CurrentCredential()[access_token] otherwise null,
|
||||
|
||||
defaultQuery = "query {
|
||||
activeUser {
|
||||
email
|
||||
name
|
||||
}
|
||||
serverInfo {
|
||||
name
|
||||
company
|
||||
version
|
||||
}
|
||||
}",
|
||||
|
||||
// Enhanced API call with comprehensive error handling
|
||||
Source = try Web.Contents(
|
||||
Text.Combine({server, "graphql"}, "/"),
|
||||
[
|
||||
Headers = [
|
||||
#"Method" = "POST",
|
||||
#"Content-Type" = "application/json",
|
||||
#"Authorization" = if apiKey = null then "" else Text.Format("Bearer #{0}", {apiKey})
|
||||
],
|
||||
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
|
||||
Content = Json.FromValue([query = Text.From(query ?? defaultQuery), variables = variables])
|
||||
]
|
||||
) otherwise null,
|
||||
|
||||
// Check if the HTTP request was successful
|
||||
IsHttpSuccess = Source <> null,
|
||||
|
||||
// Get HTTP status code for detailed error handling
|
||||
StatusCode = if IsHttpSuccess then Value.Metadata(Source)[Response.Status] else null,
|
||||
|
||||
// Parse JSON response if HTTP request was successful
|
||||
#"JSON" = if IsHttpSuccess then
|
||||
try Json.Document(Source) otherwise null
|
||||
else
|
||||
null,
|
||||
|
||||
// Comprehensive error handling
|
||||
// Comprehensive error handling
|
||||
result = if not IsHttpSuccess then
|
||||
error [
|
||||
Reason = "HttpRequestFailed",
|
||||
Message = "Failed to connect to Speckle server",
|
||||
Detail = [Server = server, StatusCode = StatusCode]
|
||||
]
|
||||
else if StatusCode = 401 then
|
||||
error [
|
||||
Reason = "AuthenticationFailed",
|
||||
Message = "Invalid or expired authentication token",
|
||||
Detail = [Server = server, HasToken = apiKey <> null]
|
||||
]
|
||||
else if StatusCode = 403 then
|
||||
error [
|
||||
Reason = "AuthorizationFailed",
|
||||
Message = "Insufficient permissions for this operation",
|
||||
Detail = [Server = server]
|
||||
]
|
||||
else if #"JSON" = null then
|
||||
error [
|
||||
Reason = "InvalidJsonResponse",
|
||||
Message = "Server returned invalid JSON response",
|
||||
Detail = [Server = server, StatusCode = StatusCode]
|
||||
]
|
||||
else if Record.HasFields(#"JSON", {"errors"}) then
|
||||
error [
|
||||
Reason = "GraphQLError",
|
||||
Message = #"JSON"[errors]{0}[message],
|
||||
Detail = [Server = server, Errors = #"JSON"[errors]]
|
||||
]
|
||||
else
|
||||
#"JSON"[data]
|
||||
in
|
||||
result
|
||||
@@ -0,0 +1,66 @@
|
||||
(url as text) as record =>
|
||||
let
|
||||
ApiFetch = Extension.LoadFunction("Api.Fetch.pqm"),
|
||||
Parser = Extension.LoadFunction("Parser.pqm"),
|
||||
|
||||
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]
|
||||
],
|
||||
|
||||
// parse the URL to extract project id
|
||||
parsedUrl = Parser(url),
|
||||
server = parsedUrl[baseUrl],
|
||||
projectId = parsedUrl[projectId],
|
||||
|
||||
// GraphQL query to check permissions
|
||||
query = "query Project($projectId: String!) {
|
||||
data:project(id: $projectId) {
|
||||
data:permissions {
|
||||
canLoad {
|
||||
authorized
|
||||
code
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}",
|
||||
|
||||
// variables variable for api fetch (i know)
|
||||
variables = [
|
||||
projectId = projectId
|
||||
],
|
||||
|
||||
result = ApiFetch(server, query, variables),
|
||||
|
||||
// check that the result contains the expected structure
|
||||
// this will throw an error if the structure is not as expected
|
||||
structureCheck = if not (Record.HasFields(result, {"data"}) and
|
||||
Record.HasFields(result[data], {"data"}) and
|
||||
Record.HasFields(result[data][data], {"canLoad"}) and
|
||||
Record.HasFields(result[data][data][canLoad], {"authorized", "code", "message"})) then
|
||||
error "Invalid response structure from permission check"
|
||||
else
|
||||
null,
|
||||
|
||||
canLoad = result[data][data][canLoad],
|
||||
|
||||
// return the permission result
|
||||
permissionResult = [
|
||||
authorized = canLoad[authorized],
|
||||
code = canLoad[code],
|
||||
message = canLoad[message]
|
||||
]
|
||||
in
|
||||
permissionResult
|
||||
@@ -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,135 @@
|
||||
// Function to exchange powerful token for weak limited token
|
||||
(powerfulToken as text, scopes as list, projectId as text, serverUrl as text, optional resourceIdString as text) as record =>
|
||||
let
|
||||
// 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,
|
||||
|
||||
// Ensure serverUrl ends with /
|
||||
NormalizedServerUrl = if Text.End(serverUrl, 1) = "/" then
|
||||
serverUrl
|
||||
else
|
||||
serverUrl & "/",
|
||||
|
||||
// New Share Token API mutation with variables
|
||||
NewGraphQLQuery = "mutation CreateEmbedShareToken($input: CreateEmbedShareTokenInput!) {
|
||||
sharingMutations {
|
||||
createEmbedShareToken(input: $input) {
|
||||
token
|
||||
}
|
||||
}
|
||||
}",
|
||||
NewGraphQLVariables = [
|
||||
input = [
|
||||
projectId = projectId,
|
||||
resourceIdString = resourceIdString
|
||||
]
|
||||
],
|
||||
|
||||
// Legacy apiTokenCreate mutation with variables
|
||||
TokenLifespanMs = 10 * 365 * 24 * 3600 * 1000,
|
||||
TokenName = "Limited Power BI Visual Token - " & DateTime.ToText(DateTime.LocalNow(), "yyyy-MM-dd HH:mm"),
|
||||
LegacyGraphQLQuery = "mutation CreateApiToken($token: ApiTokenCreateInput!) {
|
||||
apiTokenCreate(token: $token)
|
||||
}",
|
||||
LegacyGraphQLVariables = [
|
||||
token = [
|
||||
name = TokenName,
|
||||
scopes = scopes,
|
||||
lifespan = TokenLifespanMs,
|
||||
limitResources = {[
|
||||
type = "project",
|
||||
id = projectId
|
||||
]}
|
||||
]
|
||||
],
|
||||
|
||||
// Helper: execute a GraphQL query with variables and extract token
|
||||
ExecuteGraphQL = (query as text, variables as record, extractToken as function) =>
|
||||
let
|
||||
Response = Web.Contents(
|
||||
NormalizedServerUrl & "graphql",
|
||||
[
|
||||
Headers = [
|
||||
#"Method" = "POST",
|
||||
#"Content-Type" = "application/json",
|
||||
#"Authorization" = "Bearer " & powerfulToken
|
||||
],
|
||||
Content = Json.FromValue([
|
||||
query = query,
|
||||
variables = variables
|
||||
]),
|
||||
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
|
||||
Timeout = #duration(0, 0, 0, 10)
|
||||
]
|
||||
),
|
||||
StatusCode = Value.Metadata(Response)[Response.Status],
|
||||
JsonResponse = if StatusCode >= 200 and StatusCode < 300 then
|
||||
Json.Document(Response)
|
||||
else
|
||||
null,
|
||||
HasErrors = JsonResponse <> null and Record.HasFields(JsonResponse, {"errors"}),
|
||||
Token = if JsonResponse <> null and not HasErrors then
|
||||
try extractToken(JsonResponse) otherwise null
|
||||
else
|
||||
null,
|
||||
ErrorMsg = if HasErrors then
|
||||
try JsonResponse[errors]{0}[message] otherwise "GraphQL mutation failed"
|
||||
else if JsonResponse = null then
|
||||
"Request failed with status " & Number.ToText(StatusCode)
|
||||
else
|
||||
null
|
||||
in
|
||||
[Success = Token <> null, Token = Token, ErrorMessage = ErrorMsg],
|
||||
|
||||
// Try new API first, fall back to legacy
|
||||
Result = if ValidationError <> null then
|
||||
[Success = false, Token = null, ErrorMessage = ValidationError]
|
||||
else
|
||||
let
|
||||
newResult = if resourceIdString <> null then
|
||||
try ExecuteGraphQL(
|
||||
NewGraphQLQuery,
|
||||
NewGraphQLVariables,
|
||||
each [data][sharingMutations][createEmbedShareToken][token]
|
||||
) otherwise [Success = false, Token = null, ErrorMessage = "New API request failed"]
|
||||
else
|
||||
[Success = false, Token = null, ErrorMessage = null],
|
||||
|
||||
finalResult = if newResult[Success] then
|
||||
newResult
|
||||
else
|
||||
try ExecuteGraphQL(
|
||||
LegacyGraphQLQuery,
|
||||
LegacyGraphQLVariables,
|
||||
each [data][apiTokenCreate]
|
||||
) otherwise [Success = false, Token = null, ErrorMessage = "Token exchange request failed"]
|
||||
in
|
||||
finalResult
|
||||
in
|
||||
Result
|
||||
@@ -0,0 +1,122 @@
|
||||
// function for getting model information through graphql query
|
||||
(url as text) as record =>
|
||||
let
|
||||
// import the parser function
|
||||
Parser = Extension.LoadFunction("Parser.pqm"),
|
||||
|
||||
// the logic for importing functions from other files
|
||||
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]
|
||||
],
|
||||
|
||||
// parse the url and get necessary fields
|
||||
parsedUrl = Parser(url),
|
||||
server = parsedUrl[baseUrl],
|
||||
projectId = parsedUrl[projectId],
|
||||
modelId = parsedUrl[modelId],
|
||||
versionId = parsedUrl[versionId],
|
||||
|
||||
// get API key if available
|
||||
apiKey = try Extension.CurrentCredential()[access_token] otherwise null,
|
||||
|
||||
// graphql query to get model info including root object id
|
||||
// includes specific version if provided
|
||||
query = if versionId = null then
|
||||
"query ($projectId: String!, $modelId: String!) {
|
||||
project(id: $projectId) {
|
||||
model(id: $modelId) {
|
||||
id
|
||||
name
|
||||
versions {
|
||||
items {
|
||||
id
|
||||
referencedObject
|
||||
sourceApplication
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"
|
||||
else
|
||||
"query ($projectId: String!, $modelId: String!, $versionId: String!) {
|
||||
project(id: $projectId) {
|
||||
model(id: $modelId) {
|
||||
id
|
||||
name
|
||||
version(id: $versionId) {
|
||||
id
|
||||
referencedObject
|
||||
sourceApplication
|
||||
}
|
||||
}
|
||||
}
|
||||
}",
|
||||
|
||||
// include versionId in variables if it exists
|
||||
variables = if versionId = null then
|
||||
[
|
||||
projectId = projectId,
|
||||
modelId = modelId
|
||||
]
|
||||
else
|
||||
[
|
||||
projectId = projectId,
|
||||
modelId = modelId,
|
||||
versionId = versionId
|
||||
],
|
||||
|
||||
// make the api request
|
||||
Source = Web.Contents(
|
||||
Text.Combine({server, "graphql"}, "/"),
|
||||
[
|
||||
Headers = [
|
||||
#"Method" = "POST",
|
||||
#"Content-Type" = "application/json",
|
||||
#"Authorization" = if apiKey = null then "" else Text.Format("Bearer #{0}", {apiKey})
|
||||
],
|
||||
ManualStatusHandling = {400, 401, 403},
|
||||
Content = Json.FromValue([
|
||||
query = query,
|
||||
variables = variables
|
||||
])
|
||||
]
|
||||
),
|
||||
|
||||
// parse the response
|
||||
JsonResponse = Json.Document(Source),
|
||||
|
||||
// extract needed information, now handling both version-specific and latest version cases
|
||||
result = if Record.HasFields(JsonResponse, {"errors"}) then
|
||||
error JsonResponse[errors]{0}[message]
|
||||
else if JsonResponse[data]?[project]?[model] = null then
|
||||
error "Model not found or access denied. Please check your authentication and model ID."
|
||||
else if versionId = null then
|
||||
[
|
||||
modelId = JsonResponse[data][project][model][id],
|
||||
modelName = JsonResponse[data][project][model][name],
|
||||
versionId = JsonResponse[data][project][model][versions][items]{0}[id],
|
||||
rootObjectId = JsonResponse[data][project][model][versions][items]{0}[referencedObject],
|
||||
sourceApplication = JsonResponse[data][project][model][versions][items]{0}[sourceApplication]
|
||||
]
|
||||
else
|
||||
[
|
||||
modelId = JsonResponse[data][project][model][id],
|
||||
modelName = JsonResponse[data][project][model][name],
|
||||
versionId = JsonResponse[data][project][model][version][id],
|
||||
rootObjectId = JsonResponse[data][project][model][version][referencedObject],
|
||||
sourceApplication = JsonResponse[data][project][model][version][sourceApplication]
|
||||
]
|
||||
in
|
||||
result
|
||||
@@ -0,0 +1,118 @@
|
||||
// function for getting structured object data
|
||||
(url as text) as table =>
|
||||
let
|
||||
// import the required functions
|
||||
GetModel = Extension.LoadFunction("GetModel.pqm"),
|
||||
SendToServer = Extension.LoadFunction("SendToServer.pqm"),
|
||||
|
||||
// the logic for importing functions from other files
|
||||
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]
|
||||
],
|
||||
|
||||
// get model info and server data
|
||||
modelInfo = GetModel(url),
|
||||
rootId = modelInfo[rootObjectId],
|
||||
|
||||
// Get the data from SendToServer - this is already a response from the service
|
||||
JsonResponse = SendToServer(url),
|
||||
|
||||
// convert list to table with all columns expanded
|
||||
TableFromList = Table.FromList(
|
||||
JsonResponse,
|
||||
Splitter.SplitByNothing(),
|
||||
null,
|
||||
null,
|
||||
ExtraValues.Error
|
||||
),
|
||||
// fields to remove from data record
|
||||
FieldsToRemove = {"__closure", "totalChildrenCount", "renderMaterialProxies"},
|
||||
|
||||
// create basic table with cleaned data records (no properties column yet)
|
||||
BasicTable = Table.FromRecords(
|
||||
List.Transform(
|
||||
TableFromList[Column1],
|
||||
each let
|
||||
record = _,
|
||||
fieldsToRemoveForThisRecord = List.Select(
|
||||
FieldsToRemove,
|
||||
each Record.HasFields(record, {_})
|
||||
),
|
||||
cleanedRecord = Record.RemoveFields(record, fieldsToRemoveForThisRecord)
|
||||
in
|
||||
[
|
||||
#"Object IDs" = record[id], // Object IDs
|
||||
#"Speckle Type" = record[speckle_type], // Speckle Type
|
||||
#"Version Object ID" = rootId,
|
||||
#"Model Info" = rootId,
|
||||
#"Application ID" = Record.FieldOrDefault(record, "applicationId", null), // Application ID
|
||||
data = cleanedRecord // Data
|
||||
]
|
||||
)
|
||||
),
|
||||
|
||||
// function to check if a row should be excluded based on speckle type
|
||||
ShouldExcludeRow = (row as record) as logical =>
|
||||
let
|
||||
speckleType = Record.FieldOrDefault(row[data], "speckle_type", "")
|
||||
in
|
||||
speckleType = "Speckle.Core.Models.DataChunk" or
|
||||
Text.Contains(speckleType, "Objects.Other.RawEncoding"),
|
||||
|
||||
// Filtering logic here
|
||||
// If model data contains any DataObject -> fetch only data objects (excluding unwanted types)
|
||||
// If there are no data objects in the data -> fetch everything but exclude DataChunks and RawEncoding
|
||||
// Check if model contains any DataObject
|
||||
HasDataObjects = Table.RowCount(
|
||||
Table.SelectRows(
|
||||
BasicTable,
|
||||
each Text.Contains(Record.FieldOrDefault([data], "speckle_type", ""), "DataObject")
|
||||
and not ShouldExcludeRow(_)
|
||||
)
|
||||
) > 0,
|
||||
|
||||
// load the Objects.Properties function only if we have DataObjects
|
||||
ObjectsProperties = if HasDataObjects then Extension.LoadFunction("Objects.Properties.pqm") else null,
|
||||
|
||||
// Add properties column only if model has DataObjects
|
||||
FinalTable = if HasDataObjects then
|
||||
Table.AddColumn(
|
||||
BasicTable,
|
||||
"properties",
|
||||
each let
|
||||
dataRecord = [data],
|
||||
isDataObject = Text.Contains(Record.FieldOrDefault(dataRecord, "speckle_type", ""), "DataObject"),
|
||||
hasProperties = Record.HasFields(dataRecord, {"properties"}),
|
||||
extractedProperties = if hasProperties and isDataObject then
|
||||
try ObjectsProperties(dataRecord) otherwise []
|
||||
else
|
||||
[]
|
||||
in
|
||||
if Record.FieldCount(extractedProperties) > 0 then extractedProperties else null
|
||||
)
|
||||
else
|
||||
BasicTable,
|
||||
|
||||
// Apply the same filtering logic as before
|
||||
FilteredTable = if HasDataObjects then
|
||||
Table.SelectRows(
|
||||
FinalTable,
|
||||
each Text.Contains(Record.FieldOrDefault([data], "speckle_type", ""), "DataObject")
|
||||
and not ShouldExcludeRow(_)
|
||||
)
|
||||
else
|
||||
Table.SelectRows(FinalTable, each not ShouldExcludeRow(_))
|
||||
in
|
||||
FilteredTable
|
||||
@@ -0,0 +1,66 @@
|
||||
// function for getting the user info with graphql query
|
||||
let
|
||||
// import the parser function from Parser.pqm file
|
||||
Parser = Extension.LoadFunction("Parser.pqm"),
|
||||
|
||||
// the logic for importing functions from other files
|
||||
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]
|
||||
]
|
||||
in
|
||||
(url as text) as record =>
|
||||
let
|
||||
// get base server URL using the imported function
|
||||
parsedUrl = Parser(url),
|
||||
server = parsedUrl[baseUrl],
|
||||
|
||||
apiKey = try Extension.CurrentCredential()[access_token] otherwise "",
|
||||
|
||||
query = "query {
|
||||
activeUser {
|
||||
email
|
||||
name
|
||||
}
|
||||
serverInfo {
|
||||
name
|
||||
company
|
||||
version
|
||||
}
|
||||
}",
|
||||
Source = Web.Contents(
|
||||
Text.Combine({server, "graphql"}, "/"),
|
||||
[
|
||||
Headers = [
|
||||
#"Method" = "POST",
|
||||
#"Content-Type" = "application/json",
|
||||
#"Authorization" = if apiKey = "" then "" else Text.Format("Bearer #{0}", {apiKey})
|
||||
],
|
||||
ManualStatusHandling = {400},
|
||||
Content = Json.FromValue([query = query])
|
||||
]
|
||||
),
|
||||
JsonResponse = Json.Document(Source)
|
||||
in
|
||||
if Record.HasFields(JsonResponse, {"errors"}) then
|
||||
error JsonResponse[errors]{0}[message]
|
||||
else
|
||||
[
|
||||
UserEmail = try JsonResponse[data][activeUser][email] otherwise "",
|
||||
UserName = try JsonResponse[data][activeUser][name] otherwise "",
|
||||
ServerName = JsonResponse[data][serverInfo][name],
|
||||
ServerCompany = JsonResponse[data][serverInfo][company],
|
||||
ServerVersion = JsonResponse[data][serverInfo][version],
|
||||
Token = if apiKey = "" then null else apiKey
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
() as text =>
|
||||
let
|
||||
// read the Speckle.pq file
|
||||
specklePqContent = try
|
||||
Text.FromBinary(Extension.Contents("Speckle.pq"))
|
||||
otherwise
|
||||
error "Could not read Speckle.pq file",
|
||||
|
||||
lines = Text.Split(specklePqContent, "#(lf)"),
|
||||
|
||||
versionLine = List.First(
|
||||
List.Select(
|
||||
lines,
|
||||
each Text.Contains(_, "[Version = ")
|
||||
),
|
||||
null
|
||||
),
|
||||
|
||||
version = if versionLine <> null then
|
||||
let
|
||||
// find the start and end positions of the version string
|
||||
startPos = Text.PositionOf(versionLine, """") + 1,
|
||||
tempText = Text.Middle(versionLine, startPos),
|
||||
endPos = Text.PositionOf(tempText, """"),
|
||||
versionText = Text.Middle(tempText, 0, endPos)
|
||||
in
|
||||
versionText
|
||||
else
|
||||
// fallback version if parsing fails
|
||||
"3.0.0",
|
||||
|
||||
// validate version format
|
||||
isValidVersion =
|
||||
let
|
||||
parts = Text.Split(version, "."),
|
||||
isValid = List.Count(parts) = 3 and
|
||||
List.AllTrue(List.Transform(parts, each try Number.From(_) >= 0 otherwise false))
|
||||
in
|
||||
isValid,
|
||||
|
||||
result = if isValidVersion then
|
||||
version
|
||||
else
|
||||
error "Invalid version format found: " & version
|
||||
in
|
||||
result
|
||||
@@ -0,0 +1,76 @@
|
||||
// function for getting workspace information
|
||||
(url as text) as record =>
|
||||
let
|
||||
ApiFetch = Extension.LoadFunction("Api.Fetch.pqm"),
|
||||
Parser = Extension.LoadFunction("Parser.pqm"),
|
||||
|
||||
// the logic for importing functions from other files
|
||||
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]
|
||||
],
|
||||
|
||||
parsedUrl = Parser(url),
|
||||
server = parsedUrl[baseUrl],
|
||||
projectId = parsedUrl[projectId],
|
||||
|
||||
// query to get workspace ID from project
|
||||
projectQuery = "query Project($projectId: String!) {
|
||||
data:project(id: $projectId) {
|
||||
workspaceId
|
||||
}
|
||||
}",
|
||||
|
||||
projectVariables = [
|
||||
projectId = projectId
|
||||
],
|
||||
|
||||
projectResult = ApiFetch(server, projectQuery, projectVariables),
|
||||
workspaceId = projectResult[data][workspaceId],
|
||||
|
||||
// check if workspaceId is null (personal project)
|
||||
workspaceInfo = if workspaceId = null then
|
||||
[
|
||||
workspaceId = null,
|
||||
workspaceLogo = null,
|
||||
workspaceName = null,
|
||||
canHideBranding = false
|
||||
]
|
||||
else
|
||||
// query workspace only if workspaceId exists
|
||||
let
|
||||
workspaceQuery = "query Workspace($workspaceId: String!, $featureName: WorkspaceFeatureName!) {
|
||||
data:workspace(id: $workspaceId) {
|
||||
logo
|
||||
name
|
||||
hasAccessToFeature(featureName: $featureName)
|
||||
}
|
||||
}",
|
||||
|
||||
workspaceVariables = [
|
||||
workspaceId = workspaceId,
|
||||
featureName = "hideSpeckleBranding"
|
||||
],
|
||||
|
||||
workspaceResult = ApiFetch(server, workspaceQuery, workspaceVariables),
|
||||
workspace = workspaceResult[data]
|
||||
in
|
||||
[
|
||||
workspaceId = workspaceId,
|
||||
workspaceLogo = workspace[logo],
|
||||
workspaceName = workspace[name],
|
||||
canHideBranding = workspace[hasAccessToFeature]
|
||||
]
|
||||
in
|
||||
workspaceInfo
|
||||
@@ -0,0 +1,44 @@
|
||||
// Function to mark a version as received via GraphQL mutation
|
||||
// Uses the powerful token
|
||||
(powerfulToken as text, versionId as text, projectId as text, serverUrl as text) as logical =>
|
||||
try
|
||||
let
|
||||
NormalizedServerUrl = if Text.End(serverUrl, 1) = "/" then
|
||||
serverUrl
|
||||
else
|
||||
serverUrl & "/",
|
||||
|
||||
// Build GraphQL
|
||||
GraphQLMutation = "mutation MarkVersionReceived($input: MarkReceivedVersionInput!) { versionMutations { markReceived(input: $input) } }",
|
||||
|
||||
Variables = [
|
||||
input = [
|
||||
versionId = versionId,
|
||||
projectId = projectId,
|
||||
sourceApplication = "powerbi-data"
|
||||
]
|
||||
],
|
||||
|
||||
// Make GraphQL request
|
||||
Response = Web.Contents(
|
||||
NormalizedServerUrl & "graphql",
|
||||
[
|
||||
Headers = [
|
||||
#"Method" = "POST",
|
||||
#"Content-Type" = "application/json",
|
||||
#"Authorization" = "Bearer " & powerfulToken
|
||||
],
|
||||
Content = Json.FromValue([
|
||||
query = GraphQLMutation,
|
||||
variables = Variables
|
||||
]),
|
||||
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504},
|
||||
Timeout = #duration(0, 0, 0, 5)
|
||||
]
|
||||
),
|
||||
|
||||
StatusCode = Value.Metadata(Response)[Response.Status]
|
||||
in
|
||||
StatusCode >= 200 and StatusCode < 300
|
||||
otherwise
|
||||
false
|
||||
@@ -0,0 +1,42 @@
|
||||
// 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", "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
|
||||
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
|
||||
@@ -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
|
||||
@@ -0,0 +1,163 @@
|
||||
// function for mapping collection names to referenced elements in Speckle data
|
||||
(inputData as table) as table =>
|
||||
let
|
||||
// Helper function to safely get field value
|
||||
SafeFieldValue = (record as record, fieldName as text) as any =>
|
||||
if Record.HasFields(record, {fieldName}) then
|
||||
Record.Field(record, fieldName)
|
||||
else
|
||||
null,
|
||||
|
||||
// Helper function to safely get nested field value
|
||||
SafeNestedValue = (record as record, path as list) as any =>
|
||||
List.Accumulate(
|
||||
path,
|
||||
record,
|
||||
(current, fieldName) =>
|
||||
if current <> null and Value.Is(current, type record) and Record.HasFields(current, {fieldName}) then
|
||||
Record.Field(current, fieldName)
|
||||
else
|
||||
null
|
||||
),
|
||||
|
||||
// Step 1: Identify Collection Objects
|
||||
CollectionObjects = Table.SelectRows(
|
||||
inputData,
|
||||
each
|
||||
let
|
||||
speckleType = SafeFieldValue(_, "Speckle Type")
|
||||
in
|
||||
speckleType <> null and Text.Contains(speckleType, "Collection")
|
||||
),
|
||||
|
||||
// Step 2: Extract Collection Metadata
|
||||
CollectionMetadata = Table.AddColumn(
|
||||
CollectionObjects,
|
||||
"CollectionInfo",
|
||||
each
|
||||
let
|
||||
objectId = SafeFieldValue(_, "Object IDs"),
|
||||
collectionName = SafeNestedValue(_, {"data", "name"}),
|
||||
elements = SafeNestedValue(_, {"data", "elements"})
|
||||
in
|
||||
[
|
||||
ObjectId = objectId,
|
||||
CollectionName = if collectionName <> null then collectionName else "Unnamed Collection",
|
||||
Elements = if elements <> null and Value.Is(elements, type list) then elements else {}
|
||||
]
|
||||
),
|
||||
|
||||
// Step 3: Build Collection Hierarchy Mapping
|
||||
CollectionHierarchy = Table.AddColumn(
|
||||
CollectionMetadata,
|
||||
"CollectionReferences",
|
||||
each
|
||||
let
|
||||
info = [CollectionInfo],
|
||||
collectionName = info[CollectionName],
|
||||
elements = info[Elements]
|
||||
in
|
||||
List.Transform(
|
||||
elements,
|
||||
(element) =>
|
||||
let
|
||||
referencedId = if Value.Is(element, type record) and Record.HasFields(element, {"referencedId"}) then
|
||||
element[referencedId]
|
||||
else
|
||||
null
|
||||
in
|
||||
if referencedId <> null then
|
||||
[
|
||||
ReferencedId = referencedId,
|
||||
CollectionName = collectionName,
|
||||
ParentCollectionId = info[ObjectId]
|
||||
]
|
||||
else
|
||||
null
|
||||
)
|
||||
),
|
||||
|
||||
// Step 4: Flatten Reference Mapping
|
||||
FlattenedReferences = Table.SelectRows(
|
||||
Table.ExpandListColumn(
|
||||
Table.SelectColumns(CollectionHierarchy, {"CollectionReferences"}),
|
||||
"CollectionReferences"
|
||||
),
|
||||
each [CollectionReferences] <> null
|
||||
),
|
||||
|
||||
ReferenceTable = Table.ExpandRecordColumn(
|
||||
FlattenedReferences,
|
||||
"CollectionReferences",
|
||||
{"ReferencedId", "CollectionName", "ParentCollectionId"},
|
||||
{"ReferencedId", "CollectionName", "ParentCollectionId"}
|
||||
),
|
||||
|
||||
// Step 5: Build Hierarchical Collection Paths
|
||||
BuildCollectionPath = (objectId as text, visited as list) as text =>
|
||||
let
|
||||
// Prevent infinite loops
|
||||
_ = if List.Contains(visited, objectId) then
|
||||
error "Circular reference detected in collection hierarchy"
|
||||
else
|
||||
null,
|
||||
|
||||
newVisited = List.InsertRange(visited, 0, {objectId}),
|
||||
|
||||
// Find if this object is referenced by any collection
|
||||
parentReferences = Table.SelectRows(ReferenceTable, each [ReferencedId] = objectId),
|
||||
|
||||
result = if Table.RowCount(parentReferences) = 0 then
|
||||
// No parent collection found
|
||||
""
|
||||
else
|
||||
let
|
||||
parentRef = parentReferences{0},
|
||||
parentCollectionId = parentRef[ParentCollectionId],
|
||||
currentCollectionName = parentRef[CollectionName],
|
||||
|
||||
// Recursively get parent path
|
||||
parentPath = @BuildCollectionPath(parentCollectionId, newVisited),
|
||||
|
||||
// Build full path
|
||||
fullPath = if parentPath = "" then
|
||||
currentCollectionName
|
||||
else
|
||||
parentPath & "::" & currentCollectionName
|
||||
in
|
||||
fullPath
|
||||
in
|
||||
result,
|
||||
|
||||
// Step 6: Add Collection Paths to data field
|
||||
FinalData = Table.TransformColumns(
|
||||
inputData,
|
||||
{
|
||||
"data", each
|
||||
let
|
||||
currentData = _,
|
||||
currentRow = Table.SelectRows(inputData, each [data] = currentData){0},
|
||||
objectId = SafeFieldValue(currentRow, "Object IDs"),
|
||||
collectionPath = if objectId <> null then
|
||||
try
|
||||
BuildCollectionPath(objectId, {})
|
||||
otherwise
|
||||
""
|
||||
else
|
||||
"",
|
||||
|
||||
// Add CollectionPath field to the data record, set to null if empty
|
||||
enhancedData = if Value.Is(currentData, type record) then
|
||||
Record.AddField(
|
||||
currentData,
|
||||
"collectionPath",
|
||||
if collectionPath = "" then null else collectionPath
|
||||
)
|
||||
else
|
||||
currentData
|
||||
in
|
||||
enhancedData
|
||||
}
|
||||
)
|
||||
in
|
||||
FinalData
|
||||
@@ -0,0 +1,18 @@
|
||||
(objectRecord as record, optional outputAsList as nullable logical) as any =>
|
||||
let
|
||||
compositeStructure =
|
||||
if Record.HasFields(objectRecord[properties], "Composite Structure") then
|
||||
objectRecord[properties][Composite Structure]
|
||||
else if Record.HasFields(objectRecord[properties], "Parameters") and
|
||||
Record.HasFields(objectRecord[properties][Parameters], "Type Parameters") and
|
||||
Record.HasFields(objectRecord[properties][Parameters][Type Parameters], "Structure") then
|
||||
objectRecord[properties][Parameters][Type Parameters][Structure]
|
||||
else
|
||||
null,
|
||||
result =
|
||||
if outputAsList = true then
|
||||
if compositeStructure <> null then Record.ToList(compositeStructure) else null
|
||||
else
|
||||
compositeStructure
|
||||
in
|
||||
result
|
||||
@@ -0,0 +1,15 @@
|
||||
// Helper function to extract [properties][Material Quantities] and optionally output as list
|
||||
(objectRecord as record, optional outputAsList as logical) as any =>
|
||||
let
|
||||
// Ensure outputAsList is logical and defaults to false if not provided
|
||||
OutputAsList = if outputAsList = null then false else outputAsList,
|
||||
// Check if 'properties' and 'Material Quantities' exist
|
||||
HasMaterialQuantities = Record.HasFields(objectRecord, {"properties"}) and Record.HasFields(Record.Field(objectRecord, "properties"), {"Material Quantities"}),
|
||||
MaterialQuantities = if HasMaterialQuantities then Record.Field(Record.Field(objectRecord, "properties"), "Material Quantities") else null,
|
||||
Result = if MaterialQuantities = null then null else
|
||||
if OutputAsList then
|
||||
Record.ToList(MaterialQuantities)
|
||||
else
|
||||
MaterialQuantities
|
||||
in
|
||||
Result
|
||||
@@ -0,0 +1,257 @@
|
||||
// function for extracting and flattening properties from Speckle objects
|
||||
(inputRecord as any, optional filterKeys as list, optional parentPath as text, optional existingFields as list) as record =>
|
||||
let
|
||||
// Define excluded paths
|
||||
ExcludedPaths = {
|
||||
"Composite Structure",
|
||||
"Material Quantities",
|
||||
"Parameters.Type Parameters.Structure"
|
||||
},
|
||||
|
||||
// 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 resolve naming conflicts
|
||||
ResolveFieldName = (fieldName as text, parentPathParam as nullable text, existingFieldsParam as nullable list) as text =>
|
||||
let
|
||||
// Ensure we have valid inputs
|
||||
parentPath = if parentPathParam = null then "" else parentPathParam,
|
||||
existingFields = if existingFieldsParam = null then {} else existingFieldsParam,
|
||||
|
||||
// Try original field name first
|
||||
candidateName = fieldName,
|
||||
|
||||
// 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,
|
||||
|
||||
// Call the implementation with self-reference
|
||||
result = FlattenRecordImpl(FlattenRecordImpl, inputRecord, filterKeys, parentPath, existingFields)
|
||||
in
|
||||
result
|
||||
@@ -0,0 +1,59 @@
|
||||
// function for parsing the url into base url, project id, model id and version id
|
||||
(url as text) as record =>
|
||||
let
|
||||
urlParts = Uri.Parts(url),
|
||||
baseUrl = Text.Combine({urlParts[Scheme], "://", urlParts[Host]}),
|
||||
|
||||
pathSegments = List.Select(Text.Split(urlParts[Path], "/"), each _ <> ""),
|
||||
|
||||
// extract project ID if it exists
|
||||
projectId = if List.Count(pathSegments) >= 2 and pathSegments{0} = "projects"
|
||||
then pathSegments{1} else null,
|
||||
|
||||
// extract model ID and version ID if they exist
|
||||
rawModelSegment = if List.Count(pathSegments) >= 4 and pathSegments{2} = "models"
|
||||
then pathSegments{3} else "",
|
||||
|
||||
// check if this is a federated model (contains commas)
|
||||
isFederated = Text.Contains(rawModelSegment, ","),
|
||||
|
||||
// if federated, split by comma to get multiple model IDs
|
||||
modelSegments = if isFederated
|
||||
then Text.Split(rawModelSegment, ",")
|
||||
else {rawModelSegment},
|
||||
|
||||
// process each model segment (could be modelID or modelID@versionID)
|
||||
processedModels = List.Transform(
|
||||
modelSegments,
|
||||
each [
|
||||
modelId = if Text.Contains(_, "@")
|
||||
then Text.Split(_, "@"){0}
|
||||
else _,
|
||||
versionId = if Text.Contains(_, "@")
|
||||
then Text.Split(_, "@"){1}
|
||||
else null
|
||||
]
|
||||
),
|
||||
|
||||
// extract model IDs and version IDs into separate lists
|
||||
modelIds = List.Transform(processedModels, each [modelId]),
|
||||
versionIds = List.Transform(processedModels, each [versionId]),
|
||||
|
||||
// validate URL structure
|
||||
isValid = projectId <> null and List.Count(modelIds) > 0 and List.First(modelIds) <> ""
|
||||
in
|
||||
if not isValid then
|
||||
error [
|
||||
Reason = "Invalid URL",
|
||||
Message = "The URL must be in the format 'https://server/projects/PROJECT_ID/models/MODEL_ID' or 'https://server/projects/PROJECT_ID/models/MODEL_ID@VERSION_ID' or 'https://server/projects/PROJECT_ID/models/MODEL_ID1,MODEL_ID2'"
|
||||
]
|
||||
else
|
||||
[
|
||||
baseUrl = baseUrl,
|
||||
projectId = projectId,
|
||||
modelId = if isFederated then null else processedModels{0}[modelId],
|
||||
versionId = if isFederated then null else processedModels{0}[versionId],
|
||||
isFederated = isFederated,
|
||||
federatedModels = if isFederated then processedModels else null,
|
||||
resourceIdString = rawModelSegment
|
||||
]
|
||||
@@ -0,0 +1,173 @@
|
||||
// Function for getting issues from Speckle projects, models, or versions
|
||||
(url as text, optional getReplies as logical) as table =>
|
||||
let
|
||||
// Import required functions
|
||||
Parser = Extension.LoadFunction("Parser.pqm"),
|
||||
ApiFetch = Extension.LoadFunction("Api.Fetch.pqm"),
|
||||
|
||||
// Set default value for getReplies parameter
|
||||
getRepliesValue = if getReplies = null then false else getReplies,
|
||||
|
||||
// Extension.LoadFunction logic for importing functions from other files
|
||||
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]
|
||||
],
|
||||
|
||||
// Parse the URL to get necessary components with fallback for project-only URLs
|
||||
parsedUrl = try Parser(url) otherwise
|
||||
// Custom parsing for project-only URLs
|
||||
let
|
||||
urlParts = Uri.Parts(url),
|
||||
baseUrl = Text.Combine({urlParts[Scheme], "://", urlParts[Host]}),
|
||||
pathSegments = List.Select(Text.Split(urlParts[Path], "/"), each _ <> ""),
|
||||
projectId = if List.Count(pathSegments) >= 2 and pathSegments{0} = "projects"
|
||||
then pathSegments{1} else null
|
||||
in
|
||||
if projectId = null then
|
||||
error [
|
||||
Reason = "Invalid URL",
|
||||
Message = "The URL must be a valid Speckle project URL in the format 'https://server/projects/PROJECT_ID' or include models/versions"
|
||||
]
|
||||
else
|
||||
[
|
||||
baseUrl = baseUrl,
|
||||
projectId = projectId,
|
||||
modelId = null,
|
||||
versionId = null
|
||||
],
|
||||
|
||||
server = parsedUrl[baseUrl],
|
||||
projectId = parsedUrl[projectId],
|
||||
modelId = parsedUrl[modelId],
|
||||
versionId = parsedUrl[versionId],
|
||||
|
||||
// Define the GraphQL query (single query for all scopes)
|
||||
issuesQuery = "query Project($projectId: String!, $input: ProjectIssuesInput" &
|
||||
(if getRepliesValue then ", $repliesInput2: IssueRepliesInput" else "") & ") {
|
||||
project(id: $projectId) {
|
||||
issues(input: $input) {
|
||||
items {
|
||||
identifier
|
||||
title
|
||||
rawDescription
|
||||
status
|
||||
priority
|
||||
assignee {
|
||||
user {
|
||||
name
|
||||
}
|
||||
}
|
||||
dueDate
|
||||
labels {
|
||||
name
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
resourceIdString
|
||||
viewerState
|
||||
id" &
|
||||
(if getRepliesValue then "
|
||||
replies(input: $repliesInput2) {
|
||||
items {
|
||||
issueId
|
||||
id
|
||||
rawDescription
|
||||
createdAt
|
||||
author {
|
||||
user {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}" else "") & "
|
||||
}
|
||||
}
|
||||
}
|
||||
}",
|
||||
|
||||
// Build input variable dynamically based on URL scope
|
||||
inputVariable =
|
||||
if versionId <> null then
|
||||
// Version URL: resourceIdString = "MODEL_ID@VERSION_ID"
|
||||
[
|
||||
limit = 10000,
|
||||
resourceIdString = modelId & "@" & versionId
|
||||
]
|
||||
else if modelId <> null then
|
||||
// Model URL: resourceIdString = MODEL_ID
|
||||
[
|
||||
limit = 10000,
|
||||
resourceIdString = modelId
|
||||
]
|
||||
else
|
||||
// Project URL: no resourceIdString
|
||||
[
|
||||
limit = 10000
|
||||
],
|
||||
|
||||
// Build query variables
|
||||
queryVariables = if getRepliesValue then
|
||||
[
|
||||
projectId = projectId,
|
||||
input = inputVariable,
|
||||
repliesInput2 = [limit = 10000]
|
||||
]
|
||||
else
|
||||
[
|
||||
projectId = projectId,
|
||||
input = inputVariable
|
||||
],
|
||||
|
||||
// Make the API request using ApiFetch
|
||||
result = ApiFetch(server, issuesQuery, queryVariables),
|
||||
|
||||
// Extract issues from the response
|
||||
issues = result[project][issues][items],
|
||||
|
||||
// Transform to table structure with specified columns
|
||||
issuesTable = Table.FromRecords(
|
||||
List.Transform(issues, (issue) =>
|
||||
let
|
||||
// Extract selectedObjectApplicationIds from viewerState (already a record object)
|
||||
viewerState = try issue[viewerState] otherwise null,
|
||||
selectedObjectIds = try viewerState[ui][filters][selectedObjectApplicationIds] otherwise null,
|
||||
objectIds = try Record.FieldNames(selectedObjectIds) otherwise null,
|
||||
applicationIds = try Record.FieldValues(selectedObjectIds) otherwise null,
|
||||
|
||||
baseRecord = [
|
||||
ID = issue[identifier],
|
||||
Title = issue[title],
|
||||
Description = try issue[rawDescription] otherwise null,
|
||||
Status = try issue[status] otherwise null,
|
||||
Priority = try issue[priority] otherwise null,
|
||||
Assignee = try issue[assignee][user][name] otherwise null,
|
||||
#"Due Date" = try DateTime.From(issue[dueDate]) otherwise null,
|
||||
Labels = try List.Transform(issue[labels], each _[name]) otherwise {},
|
||||
#"Created at" = try DateTime.From(issue[createdAt]) otherwise null,
|
||||
#"Updated at" = try DateTime.From(issue[updatedAt]) otherwise null,
|
||||
URL = server & "/projects/" & projectId & "/models/" & issue[resourceIdString] & "#threadId=" & issue[id],
|
||||
#"Object IDs" = objectIds,
|
||||
#"Application IDs" = applicationIds
|
||||
],
|
||||
recordWithReplies = if getRepliesValue then
|
||||
baseRecord & [Replies = try issue[replies][items] otherwise null]
|
||||
else
|
||||
baseRecord
|
||||
in
|
||||
recordWithReplies
|
||||
)
|
||||
)
|
||||
in
|
||||
issuesTable
|
||||
@@ -0,0 +1,100 @@
|
||||
(url as text) as list =>
|
||||
let
|
||||
GetModel = Extension.LoadFunction("GetModel.pqm"),
|
||||
Parser = Extension.LoadFunction("Parser.pqm"),
|
||||
GetUser = Extension.LoadFunction("GetUser.pqm"),
|
||||
ExchangeToken = Extension.LoadFunction("ExchangeToken.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]
|
||||
],
|
||||
|
||||
modelInfo = GetModel(url),
|
||||
parsedUrl = Parser(url),
|
||||
userInfo = GetUser(url),
|
||||
powerfulToken = userInfo[Token],
|
||||
|
||||
// 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],
|
||||
parsedUrl[resourceIdString]
|
||||
),
|
||||
|
||||
// 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]
|
||||
]
|
||||
],
|
||||
|
||||
// downloads data directly from server
|
||||
DirectDownload = (token as text) =>
|
||||
let
|
||||
objectUrl = Text.Combine({
|
||||
parsedUrl[baseUrl],
|
||||
"/objects/",
|
||||
parsedUrl[projectId],
|
||||
"/",
|
||||
modelInfo[rootObjectId]
|
||||
}),
|
||||
|
||||
Response = Web.Contents(
|
||||
objectUrl,
|
||||
[
|
||||
Headers = [
|
||||
#"Authorization" = "Bearer " & token,
|
||||
#"Accept" = "application/json"
|
||||
],
|
||||
ManualStatusHandling = {400, 401, 403, 404, 500, 502, 503, 504}
|
||||
]
|
||||
),
|
||||
|
||||
StatusCode = Value.Metadata(Response)[Response.Status],
|
||||
|
||||
JsonResponse = if StatusCode >= 200 and StatusCode < 300 then
|
||||
Json.Document(Response)
|
||||
else
|
||||
error [
|
||||
Reason = "DirectDownloadFailed",
|
||||
Message.Format = "Failed to download model data from Speckle server (Status: #{0})",
|
||||
Message.Parameters = {Text.From(StatusCode)},
|
||||
Detail = [
|
||||
StatusCode = StatusCode,
|
||||
ObjectUrl = objectUrl,
|
||||
ProjectId = parsedUrl[projectId],
|
||||
RootObjectId = modelInfo[rootObjectId]
|
||||
]
|
||||
]
|
||||
in
|
||||
JsonResponse,
|
||||
|
||||
// download data using the token (weak if exchange succeeded, powerful otherwise)
|
||||
FinalResult = DirectDownload(tokenToUse)
|
||||
|
||||
in
|
||||
FinalResult
|
||||
@@ -0,0 +1,31 @@
|
||||
// Expands a record column in a table, adding new columns for each field in the record.
|
||||
// If UseCombinedNames is true, columns are named as ColumnName.FieldName, otherwise just FieldName.
|
||||
// If FieldNames is provided (list), only those fields are expanded.
|
||||
(table as table, columnName as text, optional FieldNames as list, optional UseCombinedNames as logical) as table =>
|
||||
let
|
||||
useCombined = if UseCombinedNames = null then false else UseCombinedNames,
|
||||
// Determine which field names to expand
|
||||
allFieldNames = if FieldNames <> null then FieldNames else List.Distinct(
|
||||
List.Combine(
|
||||
List.Transform(
|
||||
Table.Column(table, columnName),
|
||||
each if _ is record then Record.FieldNames(_) else {}
|
||||
)
|
||||
)
|
||||
),
|
||||
// Add each field as a new column
|
||||
addColumns = List.Accumulate(
|
||||
allFieldNames,
|
||||
table,
|
||||
(state, field) =>
|
||||
Table.AddColumn(
|
||||
state,
|
||||
if useCombined then columnName & "." & field else field,
|
||||
(row) =>
|
||||
if Record.HasFields(row, columnName) and Record.Field(row, columnName) is record and Record.HasFields(Record.Field(row, columnName), field)
|
||||
then Record.Field(Record.Field(row, columnName), field)
|
||||
else null
|
||||
)
|
||||
)
|
||||
in
|
||||
addColumns
|
||||
@@ -0,0 +1,37 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
const config = {
|
||||
root: true,
|
||||
parser: 'vue-eslint-parser',
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser',
|
||||
requireConfigFile: false,
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module'
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'plugin:@typescript-eslint/eslint-recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'prettier'
|
||||
],
|
||||
env: {
|
||||
node: true,
|
||||
commonjs: true
|
||||
},
|
||||
ignorePatterns: [
|
||||
'node_modules',
|
||||
'dist',
|
||||
'public',
|
||||
'events.json',
|
||||
'.*.{ts,js,vue,tsx,jsx}',
|
||||
'generated/**/*'
|
||||
],
|
||||
rules: {
|
||||
'no-var': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'warn'
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = config
|
||||
@@ -0,0 +1,76 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socio-economic status, nationality, personal
|
||||
appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at hello@speckle.systems. All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see
|
||||
https://www.contributor-covenant.org/faq
|
||||
@@ -0,0 +1,50 @@
|
||||
# Speckle Contribution Guidelines
|
||||
|
||||
## Introduction
|
||||
|
||||
Thank you for reading this! Speckle's a rather wide network of parts that depend on each other, either directly, indirectly or even just cosmetically.
|
||||
|
||||
> **Speckle** is a quite large ecosystem of moving parts. Any changes may have unintended effects, that can cause problems quickly for many people (and processes) that rely on Speckle.
|
||||
|
||||
This means that what might look like a simple quick change in one repo may have a big hidden cost that propagates around other parts of the project. We're all here to help each other, and this guide is meant to help you get started and promote a framework that can untangle all these dependecies through discussion!
|
||||
|
||||
## Bugs & Issues 🐞
|
||||
|
||||
### Found a new bug?
|
||||
|
||||
- First step is to check whether this is a new bug! We encourage you to search through the issues of the project in question **and** associated repos!
|
||||
|
||||
- If you come up with nothing, **open a new issue with a clear title and description**, as much relevant information as possible: system configuration, code samples & steps to reproduce the problem.
|
||||
|
||||
- Can't mention this often enough: tells us how to reproduce the problem! We will ignore or flag as such issues without reproduction steps.
|
||||
|
||||
- Try to reference & note all potentially affected projects.
|
||||
|
||||
### Sending a PR for Bug Fixes
|
||||
|
||||
You fixed something! Great! We hope you logged it first :) Make sure though that you've covered the lateral thinking needed for a bug report, as described above, also in your implementation! If there any tests, make sure they all pass. If there are none, it means they're missing - so add them!
|
||||
|
||||
## New Features 🎉
|
||||
|
||||
The golden rule is to Discuss First!
|
||||
|
||||
- Before embarking on adding a new feature, suggest it first as an issue with the `enhancement` label and/or title - this will allow relevant people to pitch in
|
||||
- We'll now discuss your requirements and see how and if they fit within the Speckle ecosystem.
|
||||
- The last step is to actually start writing code & submit a PR so we can follow along!
|
||||
- All new features should, if and where possible, come with tests. We won't merge without!
|
||||
|
||||
> Many clients may potentially have overlapping scopes, some features might already be in dev somewhere else, or might have been postponed to the next major release due to api instability in that area. For example, adding a delete stream button in the accounts panel in rhino: this feature was planned for speckle admin, and the whole functionality of the accounts panel in rhino is to be greatly reduced!
|
||||
|
||||
## Cosmetic Patches ✨
|
||||
|
||||
Changes that are cosmetic in nature and do not add anything substantial to the stability or functionality of Speckle **will generally not be accepted**.
|
||||
|
||||
Why? However trivial the changes might seem, there might be subtle reasons for the original code to be as it is. Furthermore, there are a lot of potential hidden costs (that even maintainers themselves are not aware of fully!) and they eat up review time unncessarily.
|
||||
|
||||
> **Examples**: modifying the colour of an UI element in one client may have a big hidden cost and need propagation in several other clients that implement a similar ui element. Changing the default port or specifiying `localhost` instead of `0.0.0.0` breaks cross-vm debugging and developing.
|
||||
|
||||
## Wrap up
|
||||
|
||||
Don't worry if you get things wrong. We all do, including project owners: this document should've been here a long time ago. There's plenty of room for discussion on our community [forum](https://discourse.speckle.works).
|
||||
|
||||
🙌❤️💙💚💜🙌
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Help improve Speckle!
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
<!---
|
||||
|
||||
Provide a short summary in the Title above. Examples of good Issue titles:
|
||||
|
||||
* "Bug: Error from server when reticulating splines"
|
||||
* "Bug: Revit crashes when installing connector"
|
||||
|
||||
-->
|
||||
|
||||
## Prerequisites
|
||||
|
||||
<!---
|
||||
|
||||
Please answer the following questions before submitting an issue.
|
||||
|
||||
-->
|
||||
|
||||
- [ ] I read the [contribution guidelines](https://github.com/specklesystems/speckle-server/blob/main/CONTRIBUTING.md)
|
||||
- [ ] I checked the [documentation](https://speckle.guide/) and found no answer.
|
||||
- [ ] I checked [existing issues](../issues?q=is%3Aissue) and found no similar issue. <!-- If you do find an existing issue, please show your support by liking it :+1: instead of creating a new issue -->
|
||||
- [ ] I checked the [community forum](https://speckle.community/) for related discussions and found no answer.
|
||||
- [ ] I'm reporting the issue to the correct repository (see also [speckle-server](https://github.com/specklesystems/speckle-server), [speckle-sharp](https://github.com/specklesystems/speckle-sharp), [specklepy](https://github.com/specklesystems/specklepy), [speckle-docs](https://github.com/specklesystems/speckle-docs), and [others](https://github.com/orgs/specklesystems/repositories))
|
||||
|
||||
## What package are you referring to?
|
||||
|
||||
<!---
|
||||
Is it related to the server (backend) only, or does this bug relate to the frontend, viewer, objectloader or any other package?
|
||||
-->
|
||||
|
||||
## Describe the bug
|
||||
|
||||
<!---
|
||||
A clear and concise description of what the bug is.
|
||||
-->
|
||||
|
||||
## To Reproduce
|
||||
|
||||
<!---
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
-->
|
||||
|
||||
## Expected behavior
|
||||
|
||||
<!---
|
||||
A clear and concise description of what you expected to happen.
|
||||
-->
|
||||
|
||||
## Screenshots
|
||||
<!---
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
-->
|
||||
|
||||
## System Info
|
||||
If applicable, please fill in the below details - they help a lot!
|
||||
|
||||
### Desktop (please complete the following information):
|
||||
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
### Smartphone (please complete the following information):
|
||||
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
## Failure Logs
|
||||
|
||||
<!---
|
||||
Please include any relevant log snippets or files here, or upload as a file.
|
||||
|
||||
If including inline, please use markdown code block syntax. https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks
|
||||
For example:
|
||||
|
||||
```
|
||||
your log output here
|
||||
```
|
||||
-->
|
||||
|
||||
## Additional context
|
||||
|
||||
<!---
|
||||
Add any other context about the problem here.
|
||||
-->
|
||||
|
||||
## Proposed Solution (if any)
|
||||
|
||||
<!---
|
||||
Let us know what how you would solve this.
|
||||
-->
|
||||
|
||||
#### Optional: Affected Projects
|
||||
<!---
|
||||
Does this issue propagate to other dependencies or dependents? If so, list them here with links!
|
||||
-->
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for Speckle!
|
||||
title: ''
|
||||
labels: enhancement, question
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
<!---
|
||||
|
||||
Provide a short summary in the Title above. Examples of good Issue titles:
|
||||
|
||||
* "Enhancement: Connector for Minecraft"
|
||||
* "Enhancement: Web viewer should support tesseracts"
|
||||
|
||||
-->
|
||||
|
||||
## Prerequisites
|
||||
|
||||
<!---
|
||||
|
||||
Please answer the following questions before submitting an issue.
|
||||
|
||||
-->
|
||||
|
||||
- [ ] I read the [contribution guidelines](https://github.com/specklesystems/speckle-server/blob/main/CONTRIBUTING.md)
|
||||
- [ ] I checked the [documentation](https://speckle.guide/) and found no answer.
|
||||
- [ ] I checked [existing issues](../issues?q=is%3Aissue) and found no similar issue. <!-- If you do find an existing issue, please show your support by liking it :+1: instead of creating a new issue -->
|
||||
- [ ] I checked the [community forum](https://speckle.community/) for related discussions and found no answer.
|
||||
- [ ] I'm requesting the feature to the correct repository (see also [speckle-server](https://github.com/specklesystems/speckle-server), [speckle-sharp](https://github.com/specklesystems/speckle-sharp), [specklepy](https://github.com/specklesystems/specklepy), [speckle-docs](https://github.com/specklesystems/speckle-docs), and [others](https://github.com/orgs/specklesystems/repositories))
|
||||
|
||||
## What package are you referring to?
|
||||
<!---
|
||||
Is it related to the server (backend) only, or does this feature request relate to the frontend, viewer, objectloader or any other package?
|
||||
-->
|
||||
|
||||
## Is your feature request related to a problem? Please describe.
|
||||
<!---
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
-->
|
||||
|
||||
## Describe the solution you'd like
|
||||
<!---
|
||||
A clear and concise description of what you want to happen.
|
||||
-->
|
||||
|
||||
## Describe alternatives you've considered
|
||||
<!---
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
-->
|
||||
|
||||
## Additional context
|
||||
<!---
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
Have you seen this feature implemented in any other software? Can you provide screenshots or links to video or documentation?
|
||||
What works well about these existing features in other software? What doesn't work well?
|
||||
-->
|
||||
|
||||
## Related issues or community discussions
|
||||
<!---
|
||||
Is this feature request related to (but sufficiently distinct from) any existing issues?
|
||||
Does this feature request require other features to be available beforehand?
|
||||
Has this feature been discussed in the community forum, please link here? https://speckle.community/
|
||||
-->
|
||||
@@ -0,0 +1,86 @@
|
||||
<!---
|
||||
|
||||
Provide a short summary in the Title above. Examples of good PR titles:
|
||||
|
||||
* "Feature: adds metrics to component"
|
||||
|
||||
* "Fix: resolves duplication in comment thread"
|
||||
|
||||
* "Update: apollo v2.34.0"
|
||||
|
||||
-->
|
||||
|
||||
## Description & motivation
|
||||
|
||||
<!---
|
||||
|
||||
Describe your changes, and why you're making them. What benefit will this have to others?
|
||||
|
||||
Is this linked to an open Github issue, a thread in Speckle community,
|
||||
or another pull request? Link it here.
|
||||
|
||||
If it is related to a Github issue, and resolves it, please link to the issue number, e.g.:
|
||||
Fixes #85, Fixes #22, Fixes username/repo#123
|
||||
Connects #123
|
||||
|
||||
-->
|
||||
|
||||
## Changes:
|
||||
|
||||
<!---
|
||||
|
||||
- Item 1
|
||||
- Item 2
|
||||
|
||||
-->
|
||||
|
||||
## To-do before merge
|
||||
|
||||
<!---
|
||||
|
||||
(Optional -- remove this section if not needed)
|
||||
|
||||
Include any notes about things that need to happen before this PR is merged, e.g.:
|
||||
|
||||
- [ ] Change the base branch
|
||||
|
||||
- [ ] Ensure PR #56 is merged
|
||||
|
||||
-->
|
||||
|
||||
## Screenshots:
|
||||
|
||||
<!---
|
||||
|
||||
Include a screenshot the before and after. This can be a screenshot of a plugin, web frontend, or output in a terminal.
|
||||
|
||||
-->
|
||||
|
||||
## Validation of changes:
|
||||
|
||||
<!---
|
||||
|
||||
Describe what tests have been added or amended, and why these demonstrate it works and will prevent this feature being accidentally broken by future changes.
|
||||
|
||||
-->
|
||||
|
||||
## Checklist:
|
||||
|
||||
<!---
|
||||
|
||||
This checklist is mostly useful as a reminder of small things that can easily be
|
||||
|
||||
forgotten – it is meant as a helpful tool rather than hoops to jump through.
|
||||
|
||||
Put an `x` in all the items that apply, make notes next to any that haven't been
|
||||
|
||||
addressed, and remove any items that are not relevant to this PR.
|
||||
|
||||
-->
|
||||
|
||||
- [ ] My pull request follows the guidelines in the [Contributing guide](https://github.com/specklesystems/speckle-server/blob/main/CONTRIBUTING.md)?
|
||||
- [ ] My pull request does not duplicate any other open [Pull Requests](../../pulls) for the same update/change?
|
||||
- [ ] My commits are related to the pull request and do not amend unrelated code or documentation.
|
||||
- [ ] My code follows a similar style to existing code.
|
||||
- [ ] I have added appropriate tests.
|
||||
- [ ] I have updated or added relevant documentation.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"endOfLine": "auto",
|
||||
"bracketSpacing": true,
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"htmlWhitespaceSensitivity": "ignore",
|
||||
"printWidth": 100,
|
||||
"singleQuote": true
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debugger",
|
||||
"type": "chrome",
|
||||
"request": "attach",
|
||||
"port": 8080,
|
||||
"sourceMaps": true,
|
||||
"webRoot": "${cwd}/"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"editor.insertSpaces": true,
|
||||
"files.eol": "\n",
|
||||
"files.watcherExclude": {
|
||||
"**/.git/objects/**": true,
|
||||
"**/node_modules/**": true,
|
||||
".tmp": true
|
||||
},
|
||||
"files.associations": {
|
||||
"*.resjson": "json"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"search.exclude": {
|
||||
".tmp": true,
|
||||
"typings": true
|
||||
},
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["/pbiviz.json"],
|
||||
"url": "./node_modules/powerbi-visuals-api/schema.pbiviz.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": ["/capabilities.json"],
|
||||
"url": "./node_modules/powerbi-visuals-api/schema.capabilities.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": ["/dependencies.json"],
|
||||
"url": "./node_modules/powerbi-visuals-api/schema.dependencies.json"
|
||||
}
|
||||
],
|
||||
"vue.codeActions.enabled": false
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2020 AEC Systems
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,112 @@
|
||||
<h1 align="center">
|
||||
<img src="https://user-images.githubusercontent.com/2679513/131189167-18ea5fe1-c578-47f6-9785-3748178e4312.png" width="150px"/><br/>
|
||||
Speckle | PowerBI Visuals
|
||||
</h1>
|
||||
<h3 align="center">
|
||||
3D Viewer for PowerBI and more...
|
||||
Expected use case is that the visual displays data pulled from Speckle via the Speckle Data Connector for PowerBI (https://github.com/specklesystems/speckle-powerbi)
|
||||
</h3>
|
||||
|
||||
> ⚠️ This repo is still in very early stages of development, use at your own risk!
|
||||
|
||||
<p align="center"><b>Speckle</b> is data infrastructure for the AEC industry.</p><br/>
|
||||
|
||||
<p align="center"><a href="https://twitter.com/SpeckleSystems"><img src="https://img.shields.io/twitter/follow/SpeckleSystems?style=social" alt="Twitter Follow"></a> <a href="https://speckle.community"><img src="https://img.shields.io/discourse/users?server=https%3A%2F%2Fspeckle.community&style=flat-square&logo=discourse&logoColor=white" alt="Community forum users"></a> <a href="https://speckle.systems"><img src="https://img.shields.io/badge/https://-speckle.systems-royalblue?style=flat-square" alt="website"></a> <a href="https://speckle.guide/dev/"><img src="https://img.shields.io/badge/docs-speckle.guide-orange?style=flat-square&logo=read-the-docs&logoColor=white" alt="docs"></a></p>
|
||||
<p align="center"></p>
|
||||
|
||||
# About Speckle
|
||||
|
||||
What is Speckle? Check our 
|
||||
|
||||
### Features
|
||||
|
||||
- **Object-based:** say goodbye to files! Speckle is the first object based platform for the AEC industry
|
||||
- **Version control:** Speckle is the Git & Hub for geometry and BIM data
|
||||
- **Collaboration:** share your designs collaborate with others
|
||||
- **3D Viewer:** see your CAD and BIM models online, share and embed them anywhere
|
||||
- **Interoperability:** get your CAD and BIM models into other software without exporting or importing
|
||||
- **Real time:** get real time updates and notifications and changes
|
||||
- **GraphQL API:** get what you need anywhere you want it
|
||||
- **Webhooks:** the base for a automation and next-gen pipelines
|
||||
- **Built for developers:** we are building Speckle with developers in mind and got tools for every stack
|
||||
- **Built for the AEC industry:** Speckle connectors are plugins for the most common software used in the industry such as Revit, Rhino, Grasshopper, AutoCAD, Civil 3D, Excel, Unreal Engine, Unity, QGIS, Blender and more!
|
||||
|
||||
### Try Speckle now!
|
||||
|
||||
Give Speckle a try in no time by:
|
||||
|
||||
- [](https://app.speckle.systems) ⇒ creating an account
|
||||
- [](https://marketplace.digitalocean.com/apps/speckle-server?refcode=947a2b5d7dc1) ⇒ deploying an instance in 1 click
|
||||
|
||||
### Resources
|
||||
|
||||
- [](https://speckle.community) for help, feature requests or just to hang with other speckle enthusiasts, check out our community forum!
|
||||
- [](https://speckle.systems) our tutorials portal is full of resources to get you started using Speckle
|
||||
- [](https://speckle.guide/dev/) reference on almost any end-user and developer functionality
|
||||
|
||||

|
||||
|
||||
# Repo structure
|
||||
|
||||
This repo follows the default structure of any Custom PowerBI Visual, generated by the `pbiviz` tool.
|
||||
|
||||
For now, it only contains a single visual -> The Speckle 3D Viewer
|
||||
|
||||
For more information about how a PowerBI visual is structured, you can check out the [official documentation](https://docs.microsoft.com/en-us/power-bi/developer/visuals/visual-project-structure)
|
||||
|
||||
### Other repos
|
||||
|
||||
Make sure to also check and ⭐️ these other Speckle repositories:
|
||||
|
||||
- [`speckle-server`](https://github.com/specklesystems/speckle-server): Server and Web packages
|
||||
- [`specklepy`](https://github.com/specklesystems/specklepy): Python SDK 🐍
|
||||
- [`speckle-excel`](https://github.com/specklesystems/speckle-excel): Excel connector
|
||||
- [`speckle-unity`](https://github.com/specklesystems/speckle-unity): Unity 3D connector
|
||||
- [`speckle-blender`](https://github.com/specklesystems/speckle-blender): Blender connector
|
||||
- [`speckle-unreal`](https://github.com/specklesystems/speckle-unreal): Unreal Engine Connector
|
||||
- [`speckle-qgis`](https://github.com/specklesystems/speckle-qgis): QGIS connectod
|
||||
- [`speckle-powerbi`](https://github.com/specklesystems/speckle-powerbi): PowerBi connector
|
||||
- and more [connectos & tooling](https://github.com/specklesystems/)!
|
||||
|
||||
## Developing and Debugging
|
||||
|
||||
There's a neat guide on setting up your environment for developing visuals [here](https://docs.microsoft.com/en-us/power-bi/developer/visuals/environment-setup)
|
||||
|
||||
You'll need to properly set up the certificate in order to be able to use the hot-reloading feature.
|
||||
|
||||
> Hot Reload will only work on PowerBI Web (**not** on Desktop).
|
||||
|
||||
### Local dev guide (for powerbi-visual)
|
||||
|
||||
1. Cd into `./src/powerbi-visual`
|
||||
1. Run `npm install`
|
||||
1. To ensure proper SSL cert usage
|
||||
1. Ensure [mkcert](https://github.com/FiloSottile/mkcert) is installed
|
||||
1. Run `npm run generate-certs`
|
||||
1. If you're on Windows or WSL2, you'll need to copy over the root CA to the Windows side and install it there as a trusted root CA.
|
||||
1. WSL2: Typically its in `~/.local/share/mkcert/rootCA.pem` on WSL2. From bash, `cd` to that folder and then do `explorer.exe .` to open it in Windows Explorer and then copy the pem file to someplace better accessible.
|
||||
1. Windows: Typically its in `%LOCALAPPDATA%\mkcert\`.
|
||||
1. Open `crtmgr` and install it into **Trusted Root Certification Authorities**. "Certificates - Current User" > "Trusted Root Certification Authorities" > "Certificates" > Right Click "All Tasks" > "Import" > "Local Machine" > "Place all certificates in the following store" > "Trusted Root Certification Authorities". You may have to set the cert filter to "All Files" to see the `.pem` file.
|
||||
1. After the cert is installed you may have to restart your browser & dev server
|
||||
1. Run `npm run dev`
|
||||
1. PowerBI -> Home > New Report > Paste Or manually enter date > Auto-create > Create
|
||||
1. In the report, click on 'Edit' to open edit mode, and add a "Developer Visual" visual
|
||||
|
||||
#### Source map issues
|
||||
|
||||
Make sure you're running the dev build (`npm run dev`) and in your browser's dev tools trigger "Clear source maps cache" and "Enable JavaScript source maps". When everything's working, you should be able to click on the "App mounted" console message's file reference link which will take you to the source-mapped source code in dev tools.
|
||||
|
||||
Its still a bit janky in that it maye show multiple files with the same name in the file tree,
|
||||
but one of those is gonna be the real fully source mapped one.
|
||||
|
||||
### Contributing
|
||||
|
||||
Please make sure you read the [contribution guidelines](.github/CONTRIBUTING.md) for an overview of the best practices we try to follow.
|
||||
|
||||
### Security
|
||||
|
||||
For any security vulnerabilities or concerns, please contact us directly at security[at]speckle.systems.
|
||||
|
||||
### License
|
||||
|
||||
Unless otherwise described, the code in this repository is licensed under the Apache-2.0 License. Please note that some modules, extensions or code herein might be otherwise licensed. This is indicated either in the root of the containing folder under a different license file, or in the respective file's header. If you have any questions, don't hesitate to get in touch with us via [email](mailto:hello@speckle.systems).
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,263 @@
|
||||
{
|
||||
"dataRoles": [
|
||||
{
|
||||
"displayName": "Model Info",
|
||||
"kind": "Measure",
|
||||
"name": "rootObjectId"
|
||||
},
|
||||
{
|
||||
"displayName": "Object IDs",
|
||||
"kind": "Grouping",
|
||||
"name": "objectIds"
|
||||
},
|
||||
{
|
||||
"displayName": "Object Data (Tooltip)",
|
||||
"kind": "Measure",
|
||||
"name": "tooltipData"
|
||||
},
|
||||
{
|
||||
"displayName": "Color By",
|
||||
"kind": "Grouping",
|
||||
"name": "colorBy"
|
||||
}
|
||||
],
|
||||
"dataViewMappings": [
|
||||
{
|
||||
"matrix": {
|
||||
"rows": {
|
||||
"dataReductionAlgorithm": {
|
||||
"top": {
|
||||
"count": 150000
|
||||
}
|
||||
},
|
||||
"select": [
|
||||
{
|
||||
"bind": {
|
||||
"to": "colorBy"
|
||||
}
|
||||
},
|
||||
{
|
||||
"for": {
|
||||
"in": "objectIds"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"values": {
|
||||
"select": [
|
||||
{
|
||||
"for": {
|
||||
"in": "rootObjectId"
|
||||
}
|
||||
},
|
||||
{
|
||||
"for": {
|
||||
"in": "tooltipData"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"colorBy": { "max": 1 },
|
||||
"objectIds": { "max": 1 },
|
||||
"rootObjectId": { "max": 1 }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"objects": {
|
||||
"storedData": {
|
||||
"properties": {
|
||||
"speckleObjects": {
|
||||
"type": { "text": true }
|
||||
},
|
||||
"receiveInfo": {
|
||||
"type": { "text": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"properties": {
|
||||
"brandingHidden": {
|
||||
"type": { "bool": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewMode": {
|
||||
"properties": {
|
||||
"defaultViewMode": {
|
||||
"type": { "text": true }
|
||||
},
|
||||
"navbarHidden": {
|
||||
"type": { "bool": true }
|
||||
},
|
||||
"edgesEnabled": {
|
||||
"type": { "bool": true }
|
||||
},
|
||||
"edgesWeight": {
|
||||
"type": { "numeric": true }
|
||||
},
|
||||
"edgesColor": {
|
||||
"type": { "numeric": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"camera": {
|
||||
"properties": {
|
||||
"defaultView": {
|
||||
"type": { "text": true }
|
||||
},
|
||||
"isOrtho": {
|
||||
"type": { "bool": true }
|
||||
},
|
||||
"isGhost": {
|
||||
"type": { "bool": true }
|
||||
},
|
||||
"zoomOnFilter": {
|
||||
"type": { "bool": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"sectionBox": {
|
||||
"properties": {
|
||||
"boxData": {
|
||||
"type": { "text": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"cameraPosition": {
|
||||
"properties": {
|
||||
"positionX": {
|
||||
"type": { "text": true }
|
||||
},
|
||||
"positionY": {
|
||||
"type": { "text": true }
|
||||
},
|
||||
"positionZ": {
|
||||
"type": { "text": true }
|
||||
},
|
||||
"targetX": {
|
||||
"type": { "text": true }
|
||||
},
|
||||
"targetY": {
|
||||
"type": { "text": true }
|
||||
},
|
||||
"targetZ": {
|
||||
"type": { "text": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"dataLoading": {
|
||||
"properties": {
|
||||
"internalizeData": {
|
||||
"type": {
|
||||
"bool": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"color": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": {
|
||||
"bool": true
|
||||
}
|
||||
},
|
||||
"fill": {
|
||||
"type": {
|
||||
"fill": {
|
||||
"solid": {
|
||||
"color": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"type": {
|
||||
"enumeration": [
|
||||
{
|
||||
"displayName": "Hidden",
|
||||
"value": "hidden"
|
||||
},
|
||||
{
|
||||
"displayName": "Ghosted",
|
||||
"value": "ghosted"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"lighting": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": {
|
||||
"bool": true
|
||||
}
|
||||
},
|
||||
"intensity": {
|
||||
"type": {
|
||||
"numeric": true
|
||||
}
|
||||
},
|
||||
"elevation": {
|
||||
"type": {
|
||||
"numeric": true
|
||||
}
|
||||
},
|
||||
"azimuth": {
|
||||
"type": {
|
||||
"numeric": true
|
||||
}
|
||||
},
|
||||
"indirect": {
|
||||
"type": {
|
||||
"numeric": true
|
||||
}
|
||||
},
|
||||
"shadows": {
|
||||
"type": {
|
||||
"bool": true
|
||||
}
|
||||
},
|
||||
"shadowCatcher": {
|
||||
"type": {
|
||||
"bool": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"privileges": [
|
||||
{
|
||||
"essential": true,
|
||||
"name": "WebAccess",
|
||||
"parameters": ["https://analytics.speckle.systems", "*"]
|
||||
},
|
||||
{
|
||||
"essential": false,
|
||||
"name": "ExportContent"
|
||||
},
|
||||
{
|
||||
"essential": true,
|
||||
"name": "LocalStorage",
|
||||
"parameters": []
|
||||
}
|
||||
],
|
||||
"sorting": {
|
||||
"default": {}
|
||||
},
|
||||
"supportsEmptyDataView": true,
|
||||
"supportsHighlight": true,
|
||||
"supportsKeyboardFocus": true,
|
||||
"supportsLandingPage": true,
|
||||
"keepAllMetadataColumns": true,
|
||||
"supportsMultiVisualSelection": true,
|
||||
"supportsSynchronizingFilterState": true,
|
||||
"suppressDefaultTitle": true,
|
||||
"tooltips": {
|
||||
"supportEnhancedTooltips": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"name": "@specklesystems/powerbi-visual",
|
||||
"description": "A 3D viewer for Speckle Object in PowerBI",
|
||||
"repository": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/specklesystems/speckle-powerbi-visuals"
|
||||
},
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"generate-certs": "mkcert localhost",
|
||||
"build": "webpack --config webpack.config.ts",
|
||||
"build:dev": "webpack --config webpack.config.dev.ts",
|
||||
"dev": "webpack-dev-server --config webpack.config.dev.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.5",
|
||||
"@babel/runtime-corejs3": "^7.21.5",
|
||||
"@headlessui/vue": "^1.7.13",
|
||||
"@heroicons/vue": "^2.0.12",
|
||||
"@speckle/objectloader2": "2.26.7",
|
||||
"@speckle/tailwind-theme": "2.23.2",
|
||||
"@speckle/ui-components": "2.23.2",
|
||||
"@speckle/viewer": "2.26.5",
|
||||
"color-interpolate": "^1.0.5",
|
||||
"core-js": "^3.30.2",
|
||||
"lodash": "^4.17.21",
|
||||
"nanoevents": "^9.1.0",
|
||||
"pako": "^2.1.0",
|
||||
"pinia": "^2.3.0",
|
||||
"postcss-loader": "^7.3.0",
|
||||
"postcss-preset-env": "^8.4.1",
|
||||
"powerbi-visuals-api": "^5.11.0",
|
||||
"powerbi-visuals-utils-colorutils": "^6.0.5",
|
||||
"powerbi-visuals-utils-dataviewutils": "^6.1.0",
|
||||
"powerbi-visuals-utils-formattingmodel": "^6.0.4",
|
||||
"powerbi-visuals-utils-interactivityutils": "^6.0.4",
|
||||
"powerbi-visuals-utils-tooltiputils": "^6.0.4",
|
||||
"regenerator-runtime": "^0.13.11",
|
||||
"vue-tippy": "^6.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.21.8",
|
||||
"@babel/eslint-parser": "^7.21.8",
|
||||
"@babel/preset-env": "^7.21.5",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@types/lodash": "^4.14.194",
|
||||
"@types/node": "^20.1.7",
|
||||
"@types/regenerator-runtime": "^0.13.1",
|
||||
"@types/three": "^0.140.0",
|
||||
"@types/webpack": "^5.28.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.2",
|
||||
"@typescript-eslint/parser": "^5.59.2",
|
||||
"@vueuse/core": "^13.2.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"babel-loader": "^9.1.2",
|
||||
"base64-inline-loader": "^2.0.1",
|
||||
"css-loader": "^6.7.3",
|
||||
"eslint": "^8.40.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-vue": "^9.13.0",
|
||||
"extra-watch-webpack-plugin": "^1.0.3",
|
||||
"html-webpack-plugin": "^5.6.3",
|
||||
"json-loader": "^0.5.7",
|
||||
"mini-css-extract-plugin": "^2.7.5",
|
||||
"postcss": "^8.4.23",
|
||||
"postcss-import": "^15.1.0",
|
||||
"powerbi-visuals-tools": "^5.6.0",
|
||||
"powerbi-visuals-webpack-plugin": "^4.1.0",
|
||||
"prettier": "^2.8.8",
|
||||
"style-loader": "^3.3.2",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"ts-loader": "^9.4.2",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths-webpack-plugin": "^4.0.1",
|
||||
"typescript": "^5.0.4",
|
||||
"user-agent-data-types": "^0.3.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-loader": "^17.4.2",
|
||||
"vue-template-compiler": "^2.7.16",
|
||||
"webpack": "^5.97.1",
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^4.15.2"
|
||||
},
|
||||
"version": "3.0.0",
|
||||
"engines": {
|
||||
"node": "^20.17.0"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1+sha512.f95ce356460e05be48d66401c1ae64ef84d163dd689964962c6888a9810865e39097a5e9de748876c2e0bf89b232d583c33982773e9903ae7a76257270986538"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"visual": {
|
||||
"name": "Speckle PowerBI Viewer",
|
||||
"displayName": "Speckle PowerBI Viewer",
|
||||
"guid": "specklePowerBiVisual",
|
||||
"visualClassName": "Visual",
|
||||
"version": "3.0.0.0",
|
||||
"description": "An interactive 3D viewer for Speckle Data",
|
||||
"supportUrl": "https://speckle.community",
|
||||
"gitHubUrl": "https://github.com/specklesystems/speckle-powerbi-visuals"
|
||||
},
|
||||
"apiVersion": "5.4.0",
|
||||
"author": { "name": "Speckle Systems", "email": "info@speckle.systems" },
|
||||
"assets": { "icon": "assets/logo.png" },
|
||||
"externalJS": [],
|
||||
"style": "style/visual.css",
|
||||
"capabilities": "capabilities.json",
|
||||
"dependencies": null,
|
||||
"stringResources": []
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'postcss-nesting': {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visualStore.loadingProgress"
|
||||
class="absolute top-1/2 left-1/2 w-1/2 -translate-x-1/2 z-50 text-center text-sm"
|
||||
>
|
||||
<!-- Progress Bar -->
|
||||
<LoadingBar :progress="visualStore.loadingProgress"></LoadingBar>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="visualStore.commonError"
|
||||
class="absolute top-11 left-1/2 -translate-x-1/2 z-100 bg-white bg-opacity-70 text-black text-center text-sm px-4 py-1 rounded shadow font-medium cursor-default"
|
||||
>
|
||||
{{ visualStore.commonError }}
|
||||
</div>
|
||||
|
||||
<ViewerView v-if="visualStore.isViewerReadyToLoad" />
|
||||
<HomeView v-else />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HomeView from './views/HomeView.vue'
|
||||
import ViewerView from './views/ViewerView.vue'
|
||||
import { onMounted } from 'vue'
|
||||
import { useVisualStore } from './store/visualStore'
|
||||
import LoadingBar from '@src/components/loading/LoadingBar.vue'
|
||||
|
||||
const visualStore = useVisualStore()
|
||||
|
||||
onMounted(() => {
|
||||
console.log('App mounted')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.tippy-box[data-theme~='custom'] {
|
||||
font-size: 10px;
|
||||
padding: 0px 0px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<ViewerControlsButtonGroup>
|
||||
<!-- Zoom extend -->
|
||||
<ViewerControlsButtonToggle flat tooltip="Zoom extends" @click="onZoomExtentsClicked">
|
||||
<ArrowsPointingOutIcon class="h-4 w-4 md:h-5 md:w-5" />
|
||||
</ViewerControlsButtonToggle>
|
||||
</ViewerControlsButtonGroup>
|
||||
<ViewerControlsButtonGroup>
|
||||
<!-- View Modes Toggle -->
|
||||
<div class="relative">
|
||||
<ViewerControlsButtonToggle
|
||||
flat
|
||||
tooltip="View modes"
|
||||
:active="viewModesOpen"
|
||||
@click="toggleActiveControl('viewModes')"
|
||||
>
|
||||
<ViewModesIcon class="h-5 w-5" />
|
||||
</ViewerControlsButtonToggle>
|
||||
<!-- View Modes Panel (shown when glasses icon is clicked) -->
|
||||
<ViewerViewModesMenu
|
||||
v-if="viewModesOpen"
|
||||
@view-mode-clicked="(viewMode, options) => $emit('view-mode-clicked', viewMode, options)"
|
||||
/>
|
||||
</div>
|
||||
<!-- Camera -->
|
||||
<ViewerCameraMenu
|
||||
:open="cameraOpen"
|
||||
:views="views"
|
||||
@update:open="(value) => toggleActiveControl(value ? 'camera' : 'none')"
|
||||
@view-clicked="(view) => $emit('view-clicked', view)"
|
||||
/>
|
||||
<!-- Section box -->
|
||||
<div class="relative">
|
||||
<ViewerControlsButtonToggle
|
||||
flat
|
||||
tooltip="Section box"
|
||||
@click="$emit('update:sectionBox')"
|
||||
>
|
||||
<ScissorsIcon class="h-4 w-4 md:h-5 md:w-5" />
|
||||
</ViewerControlsButtonToggle>
|
||||
<span
|
||||
v-if="sectionBox"
|
||||
class="absolute top-1 right-1 h-2 w-2 rounded-full bg-primary pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</ViewerControlsButtonGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowsPointingOutIcon, ScissorsIcon } from '@heroicons/vue/24/solid'
|
||||
import { CanonicalView, SpeckleView, ViewMode } from '@speckle/viewer'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useVisualStore } from '@src/store/visualStore'
|
||||
import ViewerControlsButtonGroup from './viewer/controls/ViewerControlsButtonGroup.vue'
|
||||
import ViewerControlsButtonToggle from './viewer/controls/ViewerControlsButtonToggle.vue'
|
||||
|
||||
import ViewerCameraMenu from './viewer/camera/ViewerCameraMenu.vue'
|
||||
import ViewerViewModesMenu from './viewer/view-modes/ViewerViewModesMenu.vue'
|
||||
|
||||
import ViewModesIcon from '../components/global/icon/ViewModes.vue'
|
||||
import type { ViewModeOptions } from '@src/plugins/viewer'
|
||||
|
||||
const visualStore = useVisualStore()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:sectionBox', value: boolean): void
|
||||
(e: 'view-clicked', view: CanonicalView | SpeckleView): void
|
||||
(e: 'clear-palette'): void
|
||||
(e: 'view-mode-clicked', viewMode: ViewMode, options: ViewModeOptions): void
|
||||
}>()
|
||||
withDefaults(defineProps<{ sectionBox: boolean; views: SpeckleView[] }>(), {
|
||||
sectionBox: false
|
||||
})
|
||||
|
||||
type ActiveControl =
|
||||
| 'none'
|
||||
| 'viewModes'
|
||||
| 'camera'
|
||||
| 'sun'
|
||||
| 'projection'
|
||||
| 'sectionBox'
|
||||
| 'explode'
|
||||
| 'settings'
|
||||
|
||||
const activeControl = ref<ActiveControl>('none')
|
||||
|
||||
const onZoomExtentsClicked = (ev: MouseEvent) => {
|
||||
visualStore.viewerEmit('zoomExtends')
|
||||
}
|
||||
|
||||
const toggleActiveControl = (control: ActiveControl) => {
|
||||
activeControl.value = activeControl.value === control ? 'none' : control
|
||||
}
|
||||
|
||||
const viewModesOpen = computed(() => activeControl.value === 'viewModes')
|
||||
const cameraOpen = computed(() => activeControl.value === 'camera')
|
||||
</script>
|
||||
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<div>
|
||||
<transition name="slide-fade">
|
||||
<nav
|
||||
v-show="!visualStore.isNavbarHidden"
|
||||
class="fixed top-0 h-9 flex items-center bg-foundation border border-outline-2 w-full transition z-20 cursor-default"
|
||||
>
|
||||
<div class="flex items-center transition-all justify-between w-full">
|
||||
<div
|
||||
v-if="visualStore.receiveInfo.workspaceName"
|
||||
class="flex items-center gap-2 p-0.5 pr-1.5 hover:bg-highlight-2 rounded ml-2"
|
||||
>
|
||||
<WorkspaceAvatar
|
||||
:name="visualStore.receiveInfo.workspaceName"
|
||||
:logo="visualStore.receiveInfo.workspaceLogo"
|
||||
></WorkspaceAvatar>
|
||||
<div class="min-w-0 truncate flex-grow text-left text-xs">
|
||||
<span>{{ visualStore.receiveInfo.workspaceName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex items-center hover:cursor-pointer" @click="goToSpeckleWebsite">
|
||||
<div class="max-[200px]:hidden block ml-2">
|
||||
<img class="w-6 h-auto ml-1 mr-2 my-1" src="@assets/logo-big.png" />
|
||||
</div>
|
||||
<div class="font-sans font-medium">Speckle</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<FormButton
|
||||
v-if="visualStore.latestAvailableVersion && !visualStore.isConnectorUpToDate && visualStore.isRunningInDesktop"
|
||||
v-tippy="{
|
||||
content: 'New connector version is available.<br>Click to download.',
|
||||
allowHTML: true
|
||||
}"
|
||||
color="outline"
|
||||
size="sm"
|
||||
@click="visualStore.downloadLatestVersion"
|
||||
>
|
||||
Update
|
||||
</FormButton>
|
||||
<div class="font-thin text-xs text-gray-400">
|
||||
v{{ visualStore.receiveInfo.version }}
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-700 transition"
|
||||
title="Hide navbar"
|
||||
@click="visualStore.toggleNavbar()"
|
||||
>
|
||||
<ChevronUpIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</transition>
|
||||
|
||||
<div
|
||||
v-if="!isInteractive"
|
||||
class="absolute left-1/2 -translate-x-1/2 z-20 bg-white bg-opacity-70 text-black text-center text-xs px-4 py-1 rounded shadow font-medium cursor-default transition-all duration-300"
|
||||
:class="visualStore.isNavbarHidden ? 'top-1' : 'top-11'"
|
||||
>
|
||||
<strong>Object IDs</strong>
|
||||
field is needed for interactivity with other visuals.
|
||||
</div>
|
||||
|
||||
<div v-if="visualStore.isNavbarHidden" class="fixed top-4 right-2 z-20">
|
||||
<button
|
||||
class="transition opacity-50 hover:opacity-100"
|
||||
title="Show navbar"
|
||||
@click="visualStore.toggleNavbar()"
|
||||
>
|
||||
<ChevronDownIcon class="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<transition name="slide-left">
|
||||
<ViewerControls
|
||||
v-show="!visualStore.isNavbarHidden"
|
||||
:section-box="sectionBoxEnabled"
|
||||
:views="views"
|
||||
class="fixed top-11 left-2 z-30"
|
||||
@update:section-box="onSectionBoxToggle"
|
||||
@view-clicked="(view) => viewerHandler.setView(view)"
|
||||
@view-mode-clicked="(viewMode, options) => viewerHandler.setViewMode(viewMode, options)"
|
||||
/>
|
||||
</transition>
|
||||
|
||||
<div v-if="visualStore.isFilterActive" class="absolute bottom-5 left-1/2 -translate-x-1/2 z-50">
|
||||
<FormButton size="sm" @click="visualStore.resetFilters(), selectionHandler.reset()">
|
||||
Reset filters
|
||||
</FormButton>
|
||||
</div>
|
||||
|
||||
<div v-if="sectionBoxVisible" class="absolute bottom-5 left-1/2 -translate-x-1/2 z-50 flex gap-2">
|
||||
<FormButton size="sm" color="outline" @click="onSectionBoxReset">Reset</FormButton>
|
||||
<FormButton size="sm" @click="onSectionBoxDone">Done</FormButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute z-10 flex items-center text-xs cursor-pointer"
|
||||
:class="visualStore.isBrandingHidden ? 'bottom-0 right-0' : 'bottom-2 right-2'"
|
||||
@click.stop="goToSpeckleWebsite"
|
||||
>
|
||||
<!-- TODO: fade bottom here as transition -->
|
||||
<transition name="fade-bottom">
|
||||
<div
|
||||
v-if="!visualStore.isBrandingHidden"
|
||||
class="flex items-center justify-center font-thin"
|
||||
>
|
||||
<div class="">Powered by</div>
|
||||
<img class="w-4 h-auto mx-1" src="@assets/logo-big.png" />
|
||||
<div class="font-medium">Speckle</div>
|
||||
</div>
|
||||
</transition>
|
||||
<button
|
||||
v-if="visualStore.receiveInfo && visualStore.receiveInfo.canHideBranding"
|
||||
class="transition opacity-50 hover:opacity-100 ml-1"
|
||||
:title="visualStore.isBrandingHidden ? '' : 'Hide branding'"
|
||||
@click.stop="visualStore.toggleBranding()"
|
||||
>
|
||||
<ChevronUpIcon v-if="visualStore.isBrandingHidden" class="w-4 h-4 text-gray-400" />
|
||||
<ChevronDownIcon v-else class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="container"
|
||||
class="fixed h-full w-full z-0 cursor-default"
|
||||
@click="onCanvasClick"
|
||||
@auxclick="onCanvasAuxClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script async setup lang="ts">
|
||||
import FormButton from '@src/components/form/FormButton.vue'
|
||||
import { computed, inject, onBeforeUnmount, onMounted, Ref, ref } from 'vue'
|
||||
import { currentOS, OS } from '../utils/detectOS'
|
||||
import ViewerControls from 'src/components/ViewerControls.vue'
|
||||
import { SpeckleView } from '@speckle/viewer'
|
||||
import { useClickDragged } from 'src/composables/useClickDragged'
|
||||
import { useVisualStore } from '@src/store/visualStore'
|
||||
import { ViewerHandler } from '@src/plugins/viewer'
|
||||
import { selectionHandlerKey, tooltipHandlerKey } from '@src/injectionKeys'
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/vue/24/outline'
|
||||
import WorkspaceAvatar from './workspace/WorkspaceAvatar.vue'
|
||||
|
||||
const visualStore = useVisualStore()
|
||||
const { dragged } = useClickDragged()
|
||||
|
||||
const selectionHandler = inject(selectionHandlerKey)
|
||||
const tooltipHandler = inject(tooltipHandlerKey)
|
||||
|
||||
let viewerHandler: ViewerHandler = null
|
||||
|
||||
const container = ref<HTMLElement>()
|
||||
type SectionBoxState = 'inactive' | 'editing' | 'applied'
|
||||
const sectionBoxState = ref<SectionBoxState>('inactive')
|
||||
const sectionBoxEnabled = computed(() => sectionBoxState.value !== 'inactive')
|
||||
const sectionBoxVisible = computed(() => sectionBoxState.value === 'editing')
|
||||
const views: Ref<SpeckleView[]> = ref([])
|
||||
|
||||
const isInteractive = computed(
|
||||
() => visualStore.fieldInputState.rootObjectId && visualStore.fieldInputState.objectIds
|
||||
)
|
||||
|
||||
const goToSpeckleWebsite = () => visualStore.host.launchUrl('https://speckle.systems')
|
||||
|
||||
function disableSectionBox() {
|
||||
sectionBoxState.value = 'inactive'
|
||||
viewerHandler.toggleSectionBox(false)
|
||||
visualStore.writeSectionBoxToFile(null)
|
||||
visualStore.setSectionBoxData(null)
|
||||
}
|
||||
|
||||
function onSectionBoxToggle() {
|
||||
switch (sectionBoxState.value) {
|
||||
case 'inactive':
|
||||
sectionBoxState.value = 'editing'
|
||||
viewerHandler.toggleSectionBox(true)
|
||||
break
|
||||
case 'editing':
|
||||
onSectionBoxDone()
|
||||
break
|
||||
case 'applied':
|
||||
sectionBoxState.value = 'editing'
|
||||
viewerHandler.setSectionBoxVisible(true)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function onSectionBoxReset() {
|
||||
disableSectionBox()
|
||||
}
|
||||
|
||||
function onSectionBoxDone() {
|
||||
sectionBoxState.value = 'applied'
|
||||
viewerHandler.setSectionBoxVisible(false)
|
||||
const boxData = viewerHandler.getSectionBoxData()
|
||||
visualStore.setSectionBoxData(boxData)
|
||||
visualStore.writeSectionBoxToFile(boxData)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
console.log('Viewer Wrapper mounted')
|
||||
viewerHandler = new ViewerHandler()
|
||||
await viewerHandler.init(container.value)
|
||||
|
||||
// Set up event listener for object clicks from the FilteredSelectionExtension
|
||||
viewerHandler.emitter.on('objectClicked', handleObjectClicked)
|
||||
|
||||
// Sync section box UI state when restored from file
|
||||
viewerHandler.emitter.on('objectsLoaded', () => {
|
||||
if (visualStore.sectionBoxData) {
|
||||
sectionBoxState.value = 'applied'
|
||||
}
|
||||
})
|
||||
|
||||
visualStore.setViewerEmitter(viewerHandler.emit)
|
||||
})
|
||||
|
||||
onBeforeUnmount(async () => {
|
||||
await viewerHandler.dispose()
|
||||
})
|
||||
|
||||
async function handleObjectClicked(hit: any, isMultiSelect: boolean, mouseEvent?: PointerEvent) {
|
||||
// Skip if dragging occurred
|
||||
if (dragged.value) return
|
||||
|
||||
console.log('🎯 Object clicked in ViewerWrapper:', hit, isMultiSelect)
|
||||
|
||||
if (hit) {
|
||||
visualStore.setPostClickSkipNeeded(true)
|
||||
const id = hit.object.id as string
|
||||
if (isMultiSelect || !selectionHandler.isSelected(id)) {
|
||||
await selectionHandler.select(id, isMultiSelect)
|
||||
}
|
||||
|
||||
// Show tooltip if we have mouse coordinates
|
||||
if (mouseEvent) {
|
||||
tooltipHandler.show(hit, { x: mouseEvent.clientX, y: mouseEvent.clientY })
|
||||
}
|
||||
|
||||
const selection = selectionHandler.getCurrentSelection()
|
||||
const ids = selection.map((s) => s.id)
|
||||
await viewerHandler.selectObjects(ids)
|
||||
} else {
|
||||
visualStore.setPostClickSkipNeeded(false)
|
||||
tooltipHandler.hide()
|
||||
if (!isMultiSelect) {
|
||||
selectionHandler.clear()
|
||||
await viewerHandler.selectObjects(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onCanvasClick(ev: MouseEvent) {
|
||||
// This click handler allows the viewer's built-in input system to handle clicks
|
||||
// The viewer will emit ViewerEvent.ObjectClicked events which the SelectionExtension handles
|
||||
console.log('🖱️ Canvas click detected:', ev.clientX, ev.clientY)
|
||||
|
||||
// Let the event propagate to the viewer's input system
|
||||
// The viewer should handle the click and emit ViewerEvent.ObjectClicked
|
||||
}
|
||||
|
||||
async function onCanvasAuxClick(ev: MouseEvent) {
|
||||
if (ev.button !== 2 || dragged.value) return
|
||||
|
||||
// For right-clicks, we need to get the object at the click position
|
||||
// Since FilteredSelectionExtension doesn't handle right-clicks, we'll ask it for current selection
|
||||
const selectedObjects = viewerHandler.selection.getSelectedObjects()
|
||||
const hit = selectedObjects.length > 0 ? {
|
||||
guid: selectedObjects[0].id,
|
||||
object: selectedObjects[0],
|
||||
point: { x: 0, y: 0, z: 0 } // We don't have exact point for context menu
|
||||
} : null
|
||||
|
||||
await selectionHandler.showContextMenu(ev, hit)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slide-fade-enter-active,
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
.slide-fade-enter-to,
|
||||
.slide-fade-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.slide-left-enter-from,
|
||||
.slide-left-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
.fade-bottom-enter-active,
|
||||
.fade-bottom-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.fade-bottom-enter-from,
|
||||
.fade-bottom-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
.fade-bottom-enter-to,
|
||||
.fade-bottom-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<Component
|
||||
:is="to ? linkComponent : 'button'"
|
||||
:href="to"
|
||||
:to="to"
|
||||
:type="buttonType"
|
||||
:external="external"
|
||||
:class="buttonClasses"
|
||||
:disabled="isDisabled"
|
||||
role="button"
|
||||
:style="
|
||||
color !== 'subtle' && !text
|
||||
? `box-shadow: -1px 1px 4px 0px #0000000a inset; box-shadow: 0px 2px 2px 0px #0000000d;`
|
||||
: ''
|
||||
"
|
||||
@click="onClick"
|
||||
>
|
||||
<Component :is="finalLeftIcon" v-if="finalLeftIcon" :class="iconClasses" />
|
||||
<slot v-if="!hideText">Button</slot>
|
||||
<Component :is="iconRight" v-if="iconRight || !loading" :class="iconClasses" />
|
||||
</Component>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { isObjectLike } from 'lodash'
|
||||
import type { PropAnyComponent } from '../../helpers/common/components'
|
||||
import { computed, resolveDynamicComponent } from 'vue'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import type { FormButtonStyle, FormButtonSize } from '../../helpers/form/button'
|
||||
|
||||
const emit = defineEmits<{
|
||||
/**
|
||||
* Emit MouseEvent on click
|
||||
*/
|
||||
(e: 'click', val: MouseEvent): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
/**
|
||||
* URL to which to navigate - can be a relative (app) path or an absolute link for an external URL
|
||||
*/
|
||||
to?: string
|
||||
/**
|
||||
* Choose from one of 3 button sizes
|
||||
*/
|
||||
size?: FormButtonSize
|
||||
/**
|
||||
* If set, will make the button take up all available space horizontally
|
||||
*/
|
||||
fullWidth?: boolean
|
||||
/**
|
||||
* Similar to "link", but without an underline and possibly in different colors
|
||||
*/
|
||||
text?: boolean
|
||||
/**
|
||||
* Will remove paddings and background. Use for links.
|
||||
*/
|
||||
link?: boolean
|
||||
/**
|
||||
* color:
|
||||
* primary: the default primary blue.
|
||||
* outline: foundation background and outline
|
||||
* subtle: no styling
|
||||
*/
|
||||
color?: FormButtonStyle
|
||||
/**
|
||||
* Should rounded-full be added?:
|
||||
*/
|
||||
rounded?: boolean
|
||||
/**
|
||||
* Whether the target location should be forcefully treated as an external URL
|
||||
* (for relative paths this will likely cause a redirect)
|
||||
*/
|
||||
external?: boolean
|
||||
/**
|
||||
* Whether to disable the button so that it can't be pressed
|
||||
*/
|
||||
disabled?: boolean
|
||||
/**
|
||||
* If set, will have type set to "submit" to enable it to submit any parent forms
|
||||
*/
|
||||
submit?: boolean
|
||||
/**
|
||||
* Add icon to the left from the text
|
||||
*/
|
||||
iconLeft?: Nullable<PropAnyComponent>
|
||||
/**
|
||||
* Add icon to the right from the text
|
||||
*/
|
||||
iconRight?: Nullable<PropAnyComponent>
|
||||
/**
|
||||
* Hide default slot (when you want to show icons only)
|
||||
*/
|
||||
hideText?: boolean
|
||||
/**
|
||||
* Customize component to be used when rendering links.
|
||||
*
|
||||
* The component will try to dynamically resolve NuxtLink and RouterLink and use those, if this is set to null.
|
||||
*/
|
||||
linkComponent?: Nullable<PropAnyComponent>
|
||||
/**
|
||||
* Disables the button and shows a spinning loader
|
||||
*/
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const NuxtLink = resolveDynamicComponent('NuxtLink')
|
||||
const RouterLink = resolveDynamicComponent('RouterLink')
|
||||
|
||||
const linkComponent = computed(() => {
|
||||
if (props.linkComponent) return props.linkComponent
|
||||
if (props.external) return 'a'
|
||||
if (isObjectLike(NuxtLink)) return NuxtLink
|
||||
if (isObjectLike(RouterLink)) return RouterLink
|
||||
return 'a'
|
||||
})
|
||||
|
||||
const buttonType = computed(() => {
|
||||
if (props.to) return undefined
|
||||
if (props.submit) return 'submit'
|
||||
return 'button'
|
||||
})
|
||||
|
||||
const isDisabled = computed(() => props.disabled || props.loading)
|
||||
const finalLeftIcon = computed(() => props.iconLeft)
|
||||
|
||||
const bgAndBorderClasses = computed(() => {
|
||||
const classParts: string[] = []
|
||||
|
||||
const colorsBgBorder = {
|
||||
subtle: [
|
||||
'bg-transparent border-transparent text-foreground font-medium',
|
||||
'hover:bg-primary-muted disabled:hover:bg-transparent focus-visible:border-foundation'
|
||||
],
|
||||
outline: [
|
||||
'bg-foundation border-outline-2 text-foreground font-medium',
|
||||
'hover:bg-primary-muted disabled:hover:bg-foundation focus-visible:border-foundation'
|
||||
],
|
||||
danger: [
|
||||
'bg-danger border-danger-darker text-foundation font-medium',
|
||||
'hover:bg-danger-darker disabled:hover:bg-danger focus-visible:border-foundation'
|
||||
],
|
||||
primary: [
|
||||
'bg-primary border-outline-1 text-foreground-on-primary font-semibold',
|
||||
'hover:bg-primary-focus disabled:hover:bg-primary focus-visible:border-foundation'
|
||||
]
|
||||
}
|
||||
|
||||
if (props.rounded) {
|
||||
classParts.push('!rounded-full')
|
||||
}
|
||||
|
||||
if (props.text || props.link) {
|
||||
switch (props.color) {
|
||||
case 'subtle':
|
||||
classParts.push('text-foreground')
|
||||
break
|
||||
case 'outline':
|
||||
classParts.push('text-foreground')
|
||||
break
|
||||
case 'danger':
|
||||
classParts.push('text-danger')
|
||||
break
|
||||
case 'primary':
|
||||
default:
|
||||
classParts.push('text-primary')
|
||||
break
|
||||
}
|
||||
} else {
|
||||
switch (props.color) {
|
||||
case 'subtle':
|
||||
classParts.push(...colorsBgBorder.subtle)
|
||||
break
|
||||
case 'outline':
|
||||
classParts.push(...colorsBgBorder.outline)
|
||||
break
|
||||
case 'danger':
|
||||
classParts.push(...colorsBgBorder.danger)
|
||||
break
|
||||
case 'primary':
|
||||
default:
|
||||
classParts.push(...colorsBgBorder.primary)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
})
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm':
|
||||
return 'h-6 text-body-2xs'
|
||||
case 'lg':
|
||||
return 'h-10 text-body-sm'
|
||||
default:
|
||||
case 'base':
|
||||
return 'h-8 text-body-xs'
|
||||
}
|
||||
})
|
||||
|
||||
const paddingClasses = computed(() => {
|
||||
if (props.text || props.link) {
|
||||
return 'p-0'
|
||||
}
|
||||
|
||||
const hasIconLeft = !!props.iconLeft
|
||||
const hasIconRight = !!props.iconRight
|
||||
const hideText = props.hideText
|
||||
|
||||
switch (props.size) {
|
||||
case 'sm':
|
||||
if (hideText) return 'w-6'
|
||||
if (hasIconLeft) return 'py-1 pr-2 pl-1'
|
||||
if (hasIconRight) return 'py-1 pl-2 pr-1'
|
||||
return 'px-2 py-1'
|
||||
case 'lg':
|
||||
if (hideText) return 'w-10'
|
||||
if (hasIconLeft) return 'py-2 pr-6 pl-4'
|
||||
if (hasIconRight) return 'py-2 pl-6 pr-4'
|
||||
return 'px-6 py-2'
|
||||
case 'base':
|
||||
default:
|
||||
if (hideText) return 'w-8'
|
||||
if (hasIconLeft) return 'py-0 pr-4 pl-2'
|
||||
if (hasIconRight) return 'py-0 pl-4 pr-2'
|
||||
return 'px-4 py-0'
|
||||
}
|
||||
})
|
||||
|
||||
const generalClasses = computed(() => {
|
||||
const baseClasses = [
|
||||
'inline-flex justify-center items-center',
|
||||
'text-center select-none whitespace-nowrap',
|
||||
'outline outline-2 outline-transparent',
|
||||
'transition duration-200 ease-in-out focus-visible:outline-outline-4'
|
||||
]
|
||||
|
||||
const additionalClasses = []
|
||||
|
||||
if (!props.text && !props.link) {
|
||||
additionalClasses.push('rounded-md border')
|
||||
}
|
||||
|
||||
if (props.fullWidth) {
|
||||
additionalClasses.push('w-full')
|
||||
} else if (!props.hideText) {
|
||||
additionalClasses.push('max-w-max')
|
||||
}
|
||||
if (isDisabled.value) {
|
||||
additionalClasses.push('cursor-not-allowed opacity-60')
|
||||
}
|
||||
|
||||
return [...baseClasses, ...additionalClasses].join(' ')
|
||||
})
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
return [
|
||||
generalClasses.value,
|
||||
sizeClasses.value,
|
||||
bgAndBorderClasses.value,
|
||||
paddingClasses.value
|
||||
].join(' ')
|
||||
})
|
||||
|
||||
const iconClasses = computed(() => {
|
||||
const classParts: string[] = ['shrink-0']
|
||||
|
||||
switch (props.size) {
|
||||
case 'sm':
|
||||
classParts.push('h-4 w-4 p-0.5')
|
||||
break
|
||||
case 'lg':
|
||||
classParts.push('h-6 w-6 p-1')
|
||||
break
|
||||
case 'base':
|
||||
default:
|
||||
classParts.push('h-6 w-6 p-1')
|
||||
break
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
})
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (isDisabled.value) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.stopImmediatePropagation()
|
||||
return
|
||||
}
|
||||
|
||||
emit('click', e)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="w-full flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label
|
||||
:for="name"
|
||||
class="block text-body-2xs text-foreground-2"
|
||||
>
|
||||
{{ label || name }}
|
||||
</label>
|
||||
<span class="text-body-2xs text-foreground-2">{{ displayValue }}</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
:id="name"
|
||||
:name="name"
|
||||
type="range"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:value="currentValue"
|
||||
:disabled="disabled"
|
||||
class="w-full h-1.5 outline-none slider"
|
||||
:class="{
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed': disabled
|
||||
}"
|
||||
:aria-label="label"
|
||||
:aria-valuemin="min"
|
||||
:aria-valuemax="max"
|
||||
:aria-valuenow="currentValue"
|
||||
@input="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
name: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
modelValue?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const currentValue = ref(props.modelValue ?? props.min)
|
||||
|
||||
// Watch for external changes to modelValue
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (newVal !== undefined && newVal !== currentValue.value) {
|
||||
currentValue.value = newVal
|
||||
}
|
||||
})
|
||||
|
||||
const displayValue = computed(() => {
|
||||
// Round to avoid floating point issues
|
||||
return Math.round(currentValue.value * 10) / 10
|
||||
})
|
||||
|
||||
const clampValue = (value: number): number => {
|
||||
return Math.max(props.min, Math.min(props.max, value))
|
||||
}
|
||||
|
||||
const handleInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const value = Number(target.value)
|
||||
const clampedValue = clampValue(value)
|
||||
currentValue.value = clampedValue
|
||||
emit('update:modelValue', clampedValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-runnable-track {
|
||||
@apply h-1.5 rounded-full bg-outline-3;
|
||||
}
|
||||
|
||||
.slider::-moz-range-track {
|
||||
@apply h-1.5 rounded-full bg-outline-3;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
@apply h-2.5 w-2.5 rounded-full cursor-pointer bg-foreground-2;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
@apply h-2.5 w-2.5 rounded-full cursor-pointer border-0 bg-foreground-2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
:id="name"
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:disabled="disabled"
|
||||
class="relative inline-flex flex-shrink-0 h-[18px] w-[30px] rounded-full transition-colors ease-in-out duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:class="modelValue ? 'bg-primary' : 'bg-foreground-3'"
|
||||
@click="toggle"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-3 w-3 rounded-full mt-[3px] ml-[3px] ring-0 transition ease-in-out duration-200 bg-foreground-on-primary"
|
||||
:class="modelValue ? 'translate-x-[12px]' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
<label v-if="showLabel" :for="name" class="block label-light">
|
||||
<span>{{ label || name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
showLabel?: boolean
|
||||
name: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
showLabel: true,
|
||||
modelValue: false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const toggle = () => {
|
||||
if (!props.disabled) {
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 1C6.24288 1 4.81818 2.42334 4.81818 4.18004C4.81818 5.93674 6.24288 7.36008 8 7.36008C9.75712 7.36008 11.1818 5.93674 11.1818 4.18004C11.1818 2.42334 9.75712 1 8 1Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M6.18182 9.17649C4.42465 9.17649 3 10.6005 3 12.3578V14.6281H13V12.3578C13 10.6005 11.5754 9.17649 9.81818 9.17649H6.18182Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13 11.5H15.5V13H13V15.5H11.5V13H9V11.5H11.5V9H13V11.5ZM10.5 0.75C10.9142 0.75 11.25 1.08579 11.25 1.5V2H12C13.6569 2 15 3.34315 15 5V8H13.5V6.75H1.5V12C1.5 12.8284 2.17157 13.5 3 13.5H8V15H3C1.34315 15 0 13.6569 0 12V5C8.05333e-08 3.34315 1.34315 2 3 2H4.75V1.5C4.75 1.08579 5.08579 0.75 5.5 0.75C5.91421 0.75 6.25 1.08579 6.25 1.5V2H9.75V1.5C9.75 1.08579 10.0858 0.75 10.5 0.75ZM3 3.5C2.17157 3.5 1.5 4.17157 1.5 5V5.25H13.5V5C13.5 4.17157 12.8284 3.5 12 3.5H3Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||