Compare commits

...

36 Commits

Author SHA1 Message Date
Oğuzhan Koral 62110739fb bump service version to 0.3.0 (#108)
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
2025-02-02 21:33:53 +03:00
Oğuzhan Koral d5527c79b1 bump service tag (#107)
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
2025-01-31 12:27:54 +03:00
Dogukan Karatas 79ba5cd365 Merge pull request #106 from specklesystems/dogukan/get-user-info
feat: send to local server
2025-01-31 10:26:13 +01:00
Jedd Morgan 17c857d138 Pass service as artifact (#105) 2025-01-31 10:50:16 +03:00
oguzhankoral f0538c94c3 Proper mixpanel track 2025-01-29 22:27:43 +03:00
Dogukan Karatas b1f2c741ad gets user info 2025-01-29 18:57:58 +01:00
Oğuzhan Koral 64dc73411a Feat(service): viewer data from local server (#104)
* Join strings

* Join strings

* POC from local viewer data

* WIP

* more WIP

* Align states

* aligns with new visual

* sends json to server

* Remove unused references

* POC

* local server data added

* updated nav table

* status updated

* added root object id

* status hidden

* Have rootObjectId as field

* Extract writeObjectsToFile

* Working state of interactivity

* Fix load from file vs. from local server

* TODO note

* Enable camera views

* sendStatus added

* cleaner structure

* Align port number with service

* Remove handle file change input

* Remove unused function in viewer

* Update capabilities for port

---------

Co-authored-by: Dogukan Karatas <karatasdogukan@gmail.com>
2025-01-29 18:02:24 +03:00
Oğuzhan Koral 8816430f51 Remove menu buttons from viewer (#102)
build_powerbi / build-connector (push) Has been cancelled
build_powerbi / build-visual (push) Has been cancelled
build_powerbi / deploy-installers (push) Has been cancelled
2025-01-16 20:18:33 +03:00
Oğuzhan Koral d5e8cd8a90 Fix: opening a file that has a visual with tooltip (#101)
* WIP

* Remove speckle server urls from webaccess

* Offline support identifier for mixpanel tracks

* Do not select if null

* Remove throttle

* Simplfy logic

* Increase row limit to 150000 for pro users
2025-01-15 14:34:01 +03:00
Dogukan Karatas 3bc82f6b9e updates build yaml file (#100) 2025-01-14 18:51:24 +03:00
Dogukan Karatas dc4f4c19f4 Merge pull request #99 from specklesystems/dogukan/labeling-releases
fix: update version label
2025-01-10 10:25:58 +01:00
Dogukan Karatas 15ebfab50b update gitversion.yml 2025-01-10 10:18:53 +01:00
Dogukan Karatas 10cc50a5b8 creates gitversion.yml (#98) 2025-01-09 18:33:43 +03:00
Dogukan Karatas 049fab417f feat: github action installer (#97)
* rename data column

* create yaml

* update yaml

* gives directory to action

* adds connector to yml

* update yml

* update yml again

* update version

* changes the ref

* adds shell

* fixes typo

* zip the artifacts

* updates version

* adds fetch depth

* updates yml branch

* adds tags
2025-01-09 17:53:15 +03:00
Oğuzhan Koral efe1c7ea0f Capture hits for isolated objects (#96) 2025-01-08 21:32:55 +03:00
Dogukan Karatas 7d7f3b04c5 rename data column (#95) 2025-01-08 18:40:57 +03:00
Dogukan Karatas 8e03d4464e fix: align column and input names (#94)
* rename columns

* updates query
2025-01-08 17:10:27 +03:00
Oğuzhan Koral 41161a8739 Feat(colors): CNX-964 coloring by in power bi visual (#93)
* Group objects by color and with their id

* Have colors back

* Fix color by
2025-01-08 16:23:42 +03:00
Oğuzhan Koral 436d13475d Fix(camera): CNX-969 camera resets when tooltip input is setunset (#92)
* Fix reseting camera on set/unset toolip

* Remove console log

* Remove unnecessary store props methods
2025-01-06 23:43:27 +03:00
Dogukan Karatas 6618e5654b fixes the structure of data connector (#91) 2025-01-06 18:10:49 +03:00
Oğuzhan Koral ca34cd3adc Fix(selection): CNX-963 deselecting does not work (#90)
* Clear selection if no hit

* Remove console log
2025-01-06 17:44:56 +03:00
Oğuzhan Koral 5168516109 Unload objects whenever we load new model (#89) 2025-01-06 14:47:18 +03:00
Dogukan Karatas 2fa69873d1 feat: new data model (#88)
* gets user info

* gets structured data

* gets by url

* publish the function

* cleanup the fields

* creates navigation table

* improves publishing

* builds relation between two table

* expands records dynamically

* folder structure reorder

* adds ordered chunks

* viewer data alternatives

* adds model name to the table name

* adds support for versioned urls

* adds relationship
2024-12-19 12:37:37 +03:00
Oğuzhan Koral 4f15db5d9c Feat: offline support (#87)
* disable redundant steps for POC

* Fixed the getInstances issue. Changed the webpack devsserver port so  it doesn't conflict with the pbviz process

* speckle offline loader tests

* gets user info

* gets structured data

* gets by url

* publish the function

* cleanup the fields

* creates navigation table

* improves publishing

* builds relation between two table

* wip

* some log cleanup and notes

* get sorted data from powerbi

* aling R_ raw data

* WIP

* WIP

* Handle row raw data per object

* introduce pinia

* bump speckle libraries to 2.23.2

* Cleanup on injections

* remove vuex dependency

* extract out button classes

* Revamp store and viewer handler

* Detect reload needed

* Bump the version to 3

* Bump node project to 3

* prettier settings on save and co

* comments

* Enable zoom extends

* Node packages version bumps and selection handler saga

* Cleanup viewer wrapper as MVP

* bump viewer version for offline loader

* correct warning logs

---------

Co-authored-by: AlexandruPopovici <alexandrupopoviciioan@gmail.com>
Co-authored-by: Dogukan Karatas <karatasdogukan@gmail.com>
2024-12-19 12:27:34 +03:00
Kristaps Fabians Geikins e233aec607 chore: add extra instructions for straight up windows (#86) 2024-12-04 15:44:25 +03:00
Kristaps Fabians Geikins 52f4325619 chore: various DX improvements like working source maps (#85)
* init changes

* sourcemaps work

* eff off pbiviz

* more fixes

* moar fixes

* prod build fix

* remove yarn field

* minor scripts change

* moar readme stuff
2024-12-03 22:35:18 +03:00
Alan Rynne 6e92c857a7 Merge pull request #84 from specklesystems/dev
Update `main` with changes from `dev`
2024-11-05 11:53:21 +01:00
Alan Rynne 8edc01b7d6 fix: Update viewer to version 2.21.0 (#82) 2024-11-05 11:26:09 +01:00
Claire Kuang 6d5f638895 Merge pull request #83 from specklesystems/claire/cnx-699-update-github-links-to-point-to-v3
Update README.md to align with main github page
2024-11-01 18:51:47 +00:00
Claire Kuang ccecf7cb2b Update README.md to align with main github page 2024-11-01 18:49:03 +00:00
Mucahit Bilal GOKER 156c3a5c3a return model id instead of name (#81) 2024-10-01 18:02:00 +02:00
Alan Rynne 887dbb2344 Merge branch 'main' into dev 2024-09-09 16:19:13 +02:00
Mucahit Bilal GOKER 556196a45f fixed extension path (#79) 2024-09-05 15:38:02 +02:00
Jedd Morgan 1bf6e76252 Merge pull request #78 from specklesystems/dev
Merge dev -> main
2024-08-08 14:41:41 +01:00
Mucahit Bilal GOKER b26801ef8a Update README.md (#76) 2024-08-08 14:40:35 +01:00
Iain Sproat 74ab91be3b chore(domains): update speckle.xyz to app.speckle.systems (#77)
- remove reference to DO 1-click as this is deprecated
2024-08-08 14:39:07 +01:00
80 changed files with 7596 additions and 6552 deletions
-4
View File
@@ -46,10 +46,6 @@ jobs:
name: "npm run build"
command: "npm run build"
working_directory: src/powerbi-visual
- run:
name: "npm run pack"
command: "npm run pack"
working_directory: src/powerbi-visual
- store_artifacts:
path: dist/*.pbiviz
- persist_to_workspace:
+128
View File
@@ -0,0 +1,128 @@
name: build_powerbi
on:
push:
branches: ["dev"]
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-info-version.outputs.file-version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install GitVersion
uses: gittools/actions/gitversion/setup@v3.0.0
with:
versionSpec: 6.0.5 # github actions doesnt like 6.1.0 onwards https://github.com/GitTools/actions/blob/main/docs/versions.md
- name: Determine Version
id: gitversion
uses: gittools/actions/gitversion/execute@v3.0.0
- 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: Create PQX file
run: |
./tools/MakePQX/MakePQX.exe pack -mz src/powerbi-data-connector/bin/Speckle.mez -t src/powerbi-data-connector/bin/Speckle.pqx
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: powerbi-connector
path: src/powerbi-data-connector/bin/Speckle.pqx
retention-days: 5
- id: set-version
name: Set version to output
run: echo "semver=${{steps.gitversion.outputs.semVer}}" >> "$GITHUB_OUTPUT"
shell: bash
- id: set-info-version
name: Set version to output
run: echo "file-version=${{steps.gitversion.outputs.AssemblySemVer}}" >> "$GITHUB_OUTPUT"
shell: bash
build-visual:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install GitVersion
uses: gittools/actions/gitversion/setup@v3.0.0
with:
versionSpec: 6.0.5 # github actions doesnt like 6.1.0 onwards https://github.com/GitTools/actions/blob/main/docs/versions.md
- name: Determine Version
id: gitversion
uses: gittools/actions/gitversion/execute@v3.0.0
- run: npm ci
working-directory: src/powerbi-visual
- run: npm version ${{steps.gitversion.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
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: pull powerbi-service
run: |
curl --location --output powerbi-service.zip https://releases.speckle.dev/services/powerbi-service/powerbi-service-0.3.0.zip # bump this version as needed
unzip powerbi-service.zip -d 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
- name: 🔫 Trigger Build Installers
uses: ALEEF02/workflow-dispatch@v3.0.0
with:
workflow: Build PowerBI
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 }}", "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
+3
View File
@@ -341,3 +341,6 @@ ASALocalRun/
**/webpack.statistics.html
**/Thumbs.db
installer/
localhost.pem
localhost-key.pem
+11
View File
@@ -0,0 +1,11 @@
workflow: GitFlow/v1
next-version: 3.0.0
mode: ManualDeployment
branches:
main:
label: rc
develop:
regex: ^dev$
label: beta
unknown:
increment: None
+68 -62
View File
@@ -1,14 +1,74 @@
<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>
<h3 align="center">
Data Connector and 3D Viewer Visual for PowerBI platform
</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&amp;style=flat-square&amp;logo=discourse&amp;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&amp;logo=read-the-docs&amp;logoColor=white" alt="docs"></a></p>
<p align="center"></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">
Speckle Connector and 3D Viewer Visual for Power BI
</h3>
# Features
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.
Speckles 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.
![Desktop - 1 (1)](https://github.com/specklesystems/speckle-powerbi/assets/51519350/6d2c5224-965f-4eae-b869-be26cb48c6b2)
# Repo Structure
This repo 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
Speckle connector can be installed directly from [Manager for Speckle](https://speckle.systems/download/). Full instructions for [installation](https://speckle.guide/user/powerbi/installation.html) and [configuration](https://speckle.guide/user/powerbi/configuration.html) can be found on our docs.
# Using 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://speckle.guide/user/powerbi-visual/introduction.html).
# Usage
To get started with Power BI connectors, please take a look at the [documentation](https://speckle.guide/user/powerbi/introduction.html) and extensive [tutorials](https://www.youtube.com/playlist?list=PLlI5Dyt2HaEsZHG2WJ75WIM0Brx6VHT2S) published.
# **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.
# About Speckle
@@ -31,8 +91,7 @@ What is Speckle? Check our ![YouTube Video Views](https://img.shields.io/youtube
Give Speckle a try in no time by:
- [![speckle XYZ](https://img.shields.io/badge/https://-speckle.xyz-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](https://speckle.xyz) ⇒ creating an account at our public server
- [![create a droplet](https://img.shields.io/badge/Create%20a%20Droplet-0069ff?style=flat-square&logo=digitalocean&logoColor=white)](https://marketplace.digitalocean.com/apps/speckle-server?refcode=947a2b5d7dc1) ⇒ deploying an instance in 1 click
- [![app.speckle.systems](https://img.shields.io/badge/https://-speckle.xyz-0069ff?style=flat-square&logo=hackthebox&logoColor=white)](https://app.speckle.systems) ⇒ creating an account at our public server
### Resources
@@ -41,56 +100,3 @@ Give Speckle a try in no time by:
- [![docs](https://img.shields.io/badge/docs-speckle.guide-orange?style=for-the-badge&logo=read-the-docs&logoColor=white)](https://speckle.guide/dev/) reference on almost any end-user and developer functionality
![Untitled](https://user-images.githubusercontent.com/2679513/132021739-15140299-624d-4410-98dc-b6ae6d9027ab.png)
# 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\
```
If the folder doesn't exist, create it.
### Allow custom extensions to run
Go to `Settings -> Security -> Data Extensions` and activate the following option:
![Allow extensions to run](https://user-images.githubusercontent.com/2316535/130931149-074cf6a8-1910-41f1-99c7-b8b08168f473.png)
### Checking the connector is loaded
Now open PowerBI and you should see `Speckle (beta)` appear in the data source.
![PowerBI](https://user-images.githubusercontent.com/2316535/129580913-02e5e662-f344-419c-9894-e97055930c58.png)
## 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.
+6
View File
@@ -0,0 +1,6 @@
{
"name": "speckle-powerbi",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+16 -7
View File
@@ -1,34 +1,42 @@
{
"folders": [
{
"name": "root",
"name": "🏠 root",
"path": "."
},
{
"name": "DataConnector",
"name": "➡️ powerbi-data-connector",
"path": "src/powerbi-data-connector"
},
{
"name": "Visual",
"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}\\bin\\${workspaceFolderBasename}.mez",
"powerquery.sdk.defaultExtension": "${workspaceFolder}\\src\\powerbi-data-connector\\bin\\Speckle.mez",
"files.eol": "\n",
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/node_modules/**": true,
".tmp": true
},
"files.exclude": {
".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
"typings": true,
"dist": true,
"wepbpack.statistics.dev.html": true,
"wepbpack.statistics.html": true
},
"json.schemas": [
{
@@ -47,6 +55,7 @@
},
"extensions": {
"recommendations": [
"esbenp.prettier-vscode",
"ms-dotnettools.csharp",
"powerquery.vscode-powerquery-sdk"
]
+91 -125
View File
@@ -1,14 +1,101 @@
[Version = "2.0.0"]
[Version = "3.0.0"]
section Speckle;
AuthAppId = "spklpwerbi";
AuthAppSecret = "spklpwerbi";
// The data source definition, used when connecting to any speckle server
// 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.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
);
[DataSource.Kind = "Speckle"]
shared Speckle.SendToServer = Value.ReplaceType(
Speckle.LoadFunction("SendToServer.pqm"),
type function (url as Uri.Type) 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"}
]
)
) 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')"
]
);
// here we register the GetByUrl function to power bi ui
GetByUrl.Publish = [
Beta = true,
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.Api.GetUser", path},
// This is the custom authentication strategy for our Connector
TestConnection = (path) => {"Speckle.GetUser", path},
// Authentication strategy
Authentication = [
OAuth = [
Label = "Speckle Account",
@@ -94,124 +181,3 @@ Speckle = [
],
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 model in a Speckle server project. You can copy it directly from your browser.",
Documentation.SampleValues = {"https://app.speckle.systems/projects/23401adf/models/1234568"}
]
)
) 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 model url:#(lf)
- Model: will get the latest version of the specified model (i.e. 'https://app.speckle.systems/projects/PROJECT_ID/models/MODEL_ID')#(lf)
- Version: will get a specific version from the project (i.e. 'https://app.speckle.systems/projects/PROJECT_ID/models/MODEL_ID@VERSION_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 = "Model URL",
Documentation.FieldDescription = "The url of a model in a Speckle server. You can copy it directly from your browser.",
Documentation.SampleValues = {"https://app.speckle.systems/projects/23401adf/models/1234568"}
]
)
) as table meta [
Documentation.Name = "Speckle - Get Model by URL",
Documentation.LongDescription = "Returns a flat list of all objects contained in a Speckle model/version of a specific a project.
Supports all types of model url:#(lf)
- Model: will get the latest version of the specified model (i.e. 'https://app.speckle.systems/projects/PROJECT_ID/models/MODEL_ID')#(lf)
- Version: will get a specific version from the project (i.e. 'https://app.speckle.systems/projects/PROJECT_ID/models/MODEL_ID@VERSION_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
View File
@@ -12,7 +12,6 @@
</PropertyGroup>
<ItemGroup>
<MezContent Include="Speckle.pq" />
<MezContent Include="utilities\**\*.pqm" />
<MezContent Include="speckle\**\*.pqm" />
<MezContent Include="assets\SpeckleLogo16.png" />
<MezContent Include="assets\SpeckleLogo20.png" />
+4 -2
View File
@@ -1,7 +1,9 @@
// Use this file to write queries to test your data connector
// use this file to write queries to test your data connector
let
result = Speckle.GetByUrl(
"https://app.speckle.systems/projects/e2988234fb/models/60b2300470@b1f31a351a,60b2300470"
"https://latest.speckle.systems/projects/126cd4b7bb/models/85c44d39c6"
)
in
result
@@ -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"
+50 -51
View File
@@ -1,58 +1,57 @@
let
Fetch = Extension.LoadFunction("Api.Fetch.pqm"),
GetObject = Extension.LoadFunction("Api.GetObject.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) =>
(url as text) as table =>
let
// import required functions
GetStructuredData = Extension.LoadFunction("GetStructuredData.pqm"),
SendToServer = Extension.LoadFunction("SendToServer.pqm"),
GetModel = Extension.LoadFunction("GetModel.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 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"),
addStreamUrl = Table.AddColumn(commitObjectsTable, "Model URL", each server & "/streams/" & id),
addParentObjectId = Table.AddColumn(
addStreamUrl, "Version 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, {
"Model URL",
"URL Type",
"Version Object ID",
"Object ID",
"speckle_type",
"data"
],
// get model name
modelInfo = GetModel(url),
modelName = modelInfo[modelName],
// get sending status
SendStatus = SendToServer(url),
// get structured data
StructuredData = GetStructuredData(url),
// create navigation table
source = #table(
{"Name", "Data"},
{
{
"Structured Data - " & modelName & SendStatus,
StructuredData
}
)
in
final
}
),
// add navigation table metadata
tableType = Value.Type(source),
newTableType = Type.AddTableKey(tableType, {"Name"}, true) meta [
NavigationTable.NameColumn = "Name",
NavigationTable.DataColumn = "Data",
Documentation.Name = "Speckle Model"
],
// convert to navigation table
navTable = Value.ReplaceType(source, newTableType)
in
navTable
@@ -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,46 +0,0 @@
let
Table.GenerateByPage = Extension.LoadFunction("Table.GenerateByPage.pqm"),
Speckle.Api.GetObjectChildren = Extension.LoadFunction("Api.GetObjectChildren.pqm"),
Speckle.Api.GetObject = Extension.LoadFunction("Api.GetObject.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 =>
let
parentObject = Speckle.Api.GetObject(server, streamId, objectId),
childrenTable = 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
),
parentTable = Table.FromRecords({[data = parentObject]}),
resultTable =
if (Table.ColumnCount(childrenTable) = 0) then
parentTable
else
Table.Combine({parentTable, childrenTable})
in
resultTable 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
@@ -0,0 +1,87 @@
// 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],
// get API key if available
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
// graphql query to get model info including root object id
query = "query ($projectId: String!, $modelId: String!) {
project(id: $projectId) {
model(id: $modelId) {
id
name
versions {
items {
id
referencedObject
}
}
}
}
}",
variables = [
projectId = projectId,
modelId = modelId
],
// 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
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
[
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]
]
in
result
@@ -0,0 +1,83 @@
// function for getting structured object data
(url as text) as table =>
let
// import the parser function and getModel
Parser = Extension.LoadFunction("Parser.pqm"),
GetModel = Extension.LoadFunction("GetModel.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 parsed URL components and model info
parsedUrl = Parser(url),
server = parsedUrl[baseUrl],
rootId = modelInfo[rootObjectId],
modelInfo = GetModel(url),
// get API key if available
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
// make the API request to objects endpoint
Source = Web.Contents(
Text.Combine({server, "objects", parsedUrl[projectId], modelInfo[rootObjectId]}, "/"),
[
Headers = [
#"Authorization" = if apiKey = null then "" else Text.Format("Bearer #{0}", {apiKey})
],
ManualStatusHandling = {400, 401, 403}
]
),
// parse the JSON response
JsonResponse = Json.Document(Source),
// 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 the final table with cleaned data records
FinalTable = Table.FromRecords(
List.Transform(
TableFromList[Column1],
each let
record = _,
fieldsToRemoveForThisRecord = List.Select(
FieldsToRemove,
each Record.HasFields(record, {_})
)
in
[
#"Object IDs" = record[id], // Object IDs
#"Speckle Type" = record[speckle_type], // Speckle Type
#"Root Object ID" = rootId,
data = Record.RemoveFields(record, fieldsToRemoveForThisRecord) // Data
]
)
),
// filter out rows where applicationId is null
// we can discuss this further
FilteredTable = Table.SelectRows(FinalTable, each Record.FieldOrDefault(([data]), "applicationId", null) <> null)
in
FilteredTable
@@ -0,0 +1,65 @@
// 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()[Key] otherwise null,
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 = null 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 = JsonResponse[data][activeUser][email],
UserName = JsonResponse[data][activeUser][name],
ServerName = JsonResponse[data][serverInfo][name],
ServerCompany = JsonResponse[data][serverInfo][company],
ServerVersion = JsonResponse[data][serverInfo][version]
]
@@ -0,0 +1,39 @@
// 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
modelAndVersion = if List.Count(pathSegments) >= 4 and pathSegments{2} = "models"
then Text.Split(pathSegments{3}, "@") else {},
// separate model ID from version ID
modelId = if List.Count(modelAndVersion) > 0
then modelAndVersion{0} else null,
// get version ID if it exists
versionId = if List.Count(modelAndVersion) > 1
then modelAndVersion{1} else null,
// validate URL structure
isValid = projectId <> null and modelId <> null
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'"
]
else
[
baseUrl = baseUrl,
projectId = projectId,
modelId = modelId,
versionId = versionId
]
@@ -0,0 +1,77 @@
(url as text) as text =>
let
// import required functions
GetModel = Extension.LoadFunction("GetModel.pqm"),
Parser = Extension.LoadFunction("Parser.pqm"),
GetUser = Extension.LoadFunction("GetUser.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 parsed URL
modelInfo = GetModel(url),
modelName = modelInfo[modelName],
rootId = modelInfo[rootObjectId],
parsedUrl = Parser(url),
userInfo = GetUser(url),
userEmail = userInfo[UserEmail],
serverUrl = parsedUrl[baseUrl],
// get API key if available
apiKey = try Extension.CurrentCredential()[Key] otherwise null,
// make the API request to objects endpoint
Source = Web.Contents(
Text.Combine({parsedUrl[baseUrl], "objects", parsedUrl[projectId], modelInfo[rootObjectId]}, "/"),
[
Headers = [
#"Authorization" = if apiKey = null then "" else Text.Format("Bearer #{0}", {apiKey})
],
ManualStatusHandling = {400, 401, 403}
]
),
// parse the JSON response
JsonResponse = Json.Document(Source),
JsonText = Json.FromValue([
userInfo = [
userEmail = userEmail,
serverUrl = serverUrl
],
objects = JsonResponse
]),
// Send to localhost
SendOperation = Web.Contents(
"http://127.0.0.1:49161/send-data",
[
Headers = [
#"Content-Type" = "application/json",
#"Method" = "POST"
],
Content = JsonText,
ManualStatusHandling = {400, 401, 403, 404, 500}
]
),
// Get sending status
SendStatus = if Value.Metadata(SendOperation)[Response.Status] = 200
then " - "
else " ? "
in
SendStatus
@@ -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
try removed otherwise objects
@@ -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,81 +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]
],
IsFe2Url = (segments as list) as logical => List.Count(segments) = 4 and segments{2} = "models",
GetUrlType = (branchName as nullable text, commitId as nullable text, objectId as nullable text) as text =>
if (commitId <> null) then
"Commit"
else if (objectId <> null) then
"Object"
else if (branchName <> null) then
"Branch"
else
"Stream",
ParseFe1Url = (server as text, segments as list) as record =>
let
streamId = segments{1},
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,
urlType = GetUrlType(branchName, commitId, objectId)
in
[
urlType = urlType,
server = server as text,
id = streamId as nullable text,
branch = branchName as nullable text,
commit = commitId as nullable text,
object = objectId as nullable text
],
ParseFe2Url = (server as text, segments as list) as record =>
let
streamId = segments{1},
modelList = segments{3},
isMultimodel = Text.Contains(modelList, ","),
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,
urlType = GetUrlType(model[name], versionId, null)
in
if isMultimodel then
error
Error.Record(
"NotSupported",
"Multi-model URLs are not supported.",
"Try to select just one single model in the web app and paste that in."
)
else
[
urlType = urlType,
server = server,
id = streamId,
branch = model[name],
commit = versionId,
object = null
]
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), "/"),
isFe2 = IsFe2Url(segments)
in
if (isFe2) then
ParseFe2Url(server, segments)
else
ParseFe1Url(server, segments)
@@ -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
@@ -1,2 +0,0 @@
// Use this file to write queries to test your data connector
let result = Speckle.Api.Fetch("https://latest.speckle.dev") in Record.ToTable(result)
@@ -1,7 +0,0 @@
// Use this file to write queries to test your data connector
let
result = Speckle.Api.REST.GetObject(
"https://latest.speckle.dev", "5f284e5c70", "85e5f250fe591ea74d8d5dc1137a9341"
)
in
result
@@ -1,30 +0,0 @@
section UnitTestingUnitTests;
UT = Speckle.LoadFunction("Facts.pqm");
Fact = UT[Fact];
Facts.Summarize = UT[SummarizeFacts];
shared Speckle.UnitTest = [
// Put any common variables here if you only want them to be evaluated once
// Fact(<Name of the Test>, <Expected Value>, <Actual Value>)
// <Expected Value> and <Actual Value> can be a literal or let statement
facts = {
Fact(
"Check that this function returns 'ABC'",
// name of the test
"ABC",
// expected value
UnitTesting.ReturnsABC()
// expression to evaluate (let or single statement)
),
Fact("Check that this function returns '123'", "123", UnitTesting.Returns123()),
Fact("Result should contain 5 rows", 5, Table.RowCount(UnitTesting.ReturnsTableWithFiveRows())),
Fact("Values should be equal (using a let statement)", "Hello World", let a = "Hello World" in a)
},
report = Facts.Summarize(facts)
][report];
shared UnitTesting.ReturnsABC = () => "ABC";
shared UnitTesting.Returns123 = () => "123";
shared UnitTesting.ReturnsTableWithFiveRows = () => Table.Repeat(#table({"a"}, {{1}}), 5);
@@ -1,2 +0,0 @@
// Use this file to write queries to test your data connector
let result = Speckle.Get.ByUrl("https://latest.speckle.dev/streams/3d25474a18") in Record.ToTable(result)
@@ -1,2 +0,0 @@
// Use this file to write queries to test your data connector
let result = Speckle.GetByUrl("https://latest.speckle.dev/streams/5f284e5c70/objects/85e5f250fe591ea74d8d5dc1137a9341") in result
@@ -1,376 +0,0 @@
let
Diagnostics.LogValue = (prefix, value) =>
Diagnostics.Trace(
TraceLevel.Information,
prefix & ": " & (try Diagnostics.ValueToText(value) otherwise "<error getting value>"),
value
),
Diagnostics.LogValue2 = (prefix, value, result, optional delayed) =>
Diagnostics.Trace(TraceLevel.Information, prefix & ": " & Diagnostics.ValueToText(value), result, delayed),
Diagnostics.LogFailure = (text, function) =>
let
result = try function()
in
if result[HasError] then
Diagnostics.LogValue2(text, result[Error], () => error result[Error], true)
else
result[Value],
Diagnostics.WrapFunctionResult = (innerFunction as function, outerFunction as function) as function =>
Function.From(Value.Type(innerFunction), (list) => outerFunction(() => Function.Invoke(innerFunction, list))),
Diagnostics.WrapHandlers = (handlers as record) as record =>
Record.FromList(
List.Transform(
Record.FieldNames(handlers),
(h) =>
Diagnostics.WrapFunctionResult(Record.Field(handlers, h), (fn) => Diagnostics.LogFailure(h, fn))
),
Record.FieldNames(handlers)
),
Diagnostics.ValueToText = (value) =>
let
_canBeIdentifier = (x) =>
let
keywords = {
"and",
"as",
"each",
"else",
"error",
"false",
"if",
"in",
"is",
"let",
"meta",
"not",
"otherwise",
"or",
"section",
"shared",
"then",
"true",
"try",
"type"
},
charAlpha = (c as number) => (c >= 65 and c <= 90) or (c >= 97 and c <= 122) or c = 95,
charDigit = (c as number) => c >= 48 and c <= 57
in
try
charAlpha(Character.ToNumber(Text.At(x, 0)))
and List.MatchesAll(
Text.ToList(x), (c) => let num = Character.ToNumber(c) in charAlpha(num)
or charDigit(num)
)
and not List.MatchesAny(keywords, (li) => li = x) otherwise false,
Serialize.Binary = (x) => "#binary(" & Serialize(Binary.ToList(x)) & ") ",
Serialize.Date = (x) =>
"#date(" & Text.From(Date.Year(x)) & ", " & Text.From(Date.Month(x)) & ", " & Text.From(Date.Day(x))
& ") ",
Serialize.Datetime = (x) =>
"#datetime("
& Text.From(Date.Year(DateTime.Date(x)))
& ", "
& Text.From(Date.Month(DateTime.Date(x)))
& ", "
& Text.From(Date.Day(DateTime.Date(x)))
& ", "
& Text.From(Time.Hour(DateTime.Time(x)))
& ", "
& Text.From(Time.Minute(DateTime.Time(x)))
& ", "
& Text.From(Time.Second(DateTime.Time(x)))
& ") ",
Serialize.Datetimezone = (x) =>
let
dtz = DateTimeZone.ToRecord(x)
in
"#datetimezone("
& Text.From(dtz[Year])
& ", "
& Text.From(dtz[Month])
& ", "
& Text.From(dtz[Day])
& ", "
& Text.From(dtz[Hour])
& ", "
& Text.From(dtz[Minute])
& ", "
& Text.From(dtz[Second])
& ", "
& Text.From(dtz[ZoneHours])
& ", "
& Text.From(dtz[ZoneMinutes])
& ") ",
Serialize.Duration = (x) =>
let
dur = Duration.ToRecord(x)
in
"#duration("
& Text.From(dur[Days])
& ", "
& Text.From(dur[Hours])
& ", "
& Text.From(dur[Minutes])
& ", "
& Text.From(dur[Seconds])
& ") ",
Serialize.Function = (x) =>
_serialize_function_param_type(
Type.FunctionParameters(Value.Type(x)), Type.FunctionRequiredParameters(Value.Type(x))
)
& " as "
& _serialize_function_return_type(Value.Type(x))
& " => (...) ",
Serialize.List = (x) =>
"{"
& List.Accumulate(
x, "", (seed, item) => if seed = "" then Serialize(item) else seed & ", " & Serialize(item)
)
& "} ",
Serialize.Logical = (x) => Text.From(x),
Serialize.Null = (x) => "null",
Serialize.Number = (x) =>
let
Text.From = (i as number) as text =>
if Number.IsNaN(i) then
"#nan"
else if i = Number.PositiveInfinity then
"#infinity"
else if i = Number.NegativeInfinity then
"-#infinity"
else
Text.From(i)
in
Text.From(x),
Serialize.Record = (x) =>
"[ "
& List.Accumulate(
Record.FieldNames(x),
"",
(seed, item) =>
(if seed = "" then Serialize.Identifier(item) else seed & ", " & Serialize.Identifier(
item
))
& " = "
& Serialize(Record.Field(x, item))
)
& " ] ",
Serialize.Table = (x) =>
"#table( type " & _serialize_table_type(Value.Type(x)) & ", " & Serialize(Table.ToRows(x)) & ") ",
Serialize.Text = (x) => """" & _serialize_text_content(x) & """",
_serialize_text_content = (x) =>
let
escapeText = (n as number) as text =>
"#(#)(" & Text.PadStart(Number.ToText(n, "X", "en-US"), 4, "0") & ")"
in
List.Accumulate(
List.Transform(
Text.ToList(x),
(c) =>
let
n = Character.ToNumber(c)
in
if n = 9 then
"#(#)(tab)"
else if n = 10 then
"#(#)(lf)"
else if n = 13 then
"#(#)(cr)"
else if n = 34 then
""""""
else if n = 35 then
"#(#)(#)"
else if n < 32 then
escapeText(n)
else if n < 127 then
Character.FromNumber(n)
else
escapeText(n)
),
"",
(s, i) => s & i
),
Serialize.Identifier = (x) => if _canBeIdentifier(x) then x else "#""" & _serialize_text_content(x) & """",
Serialize.Time = (x) =>
"#time("
& Text.From(Time.Hour(x))
& ", "
& Text.From(Time.Minute(x))
& ", "
& Text.From(Time.Second(x))
& ") ",
Serialize.Type = (x) => "type " & _serialize_typename(x),
_serialize_typename = (x, optional funtype as logical) =>
/* Optional parameter: Is this being used as part of a function signature? */ let
isFunctionType = (x as type) =>
try if Type.FunctionReturn(x) is type then true else false otherwise false,
isTableType = (x as type) =>
try if Type.TableSchema(x) is table then true else false otherwise false,
isRecordType = (x as type) =>
try if Type.ClosedRecord(x) is type then true else false otherwise false,
isListType = (x as type) => try if Type.ListItem(x) is type then true else false otherwise false
in
if funtype = null and isTableType(x) then
_serialize_table_type(x)
else if funtype = null and isListType(x) then
"{ " & @_serialize_typename(Type.ListItem(x)) & " }"
else if funtype = null and isFunctionType(x) then
"function " & _serialize_function_type(x)
else if funtype = null and isRecordType(x) then
_serialize_record_type(x)
else if x = type any then
"any"
else
let
base = Type.NonNullable(x)
in
(if Type.IsNullable(x) then "nullable " else "")
& (
if base = type anynonnull then
"anynonnull"
else if base = type binary then
"binary"
else if base = type date then
"date"
else if base = type datetime then
"datetime"
else if base = type datetimezone then
"datetimezone"
else if base = type duration then
"duration"
else if base = type logical then
"logical"
else if base = type none then
"none"
else if base = type null then
"null"
else if base = type number then
"number"
else if base = type text then
"text"
else if base = type time then
"time"
else if base = type type then
"type"
else /* Abstract types: */ if base = type function then
"function"
else if base = type table then
"table"
else if base = type record then
"record"
else if base = type list then
"list"
else
"any /*Actually unknown type*/"
),
_serialize_table_type = (x) =>
let
schema = Type.TableSchema(x)
in
"table "
& (
if Table.IsEmpty(schema) then
""
else
"["
& List.Accumulate(
List.Transform(
Table.ToRecords(Table.Sort(schema, "Position")),
each Serialize.Identifier(_[Name]) & " = " & _[Kind]
),
"",
(seed, item) => (if seed = "" then item else seed & ", " & item)
)
& "] "
),
_serialize_record_type = (x) =>
let
flds = Type.RecordFields(x)
in
if Record.FieldCount(flds) = 0 then
"record"
else
"["
& List.Accumulate(
Record.FieldNames(flds),
"",
(seed, item) =>
seed
& (if seed <> "" then ", " else "")
& (
Serialize.Identifier(item)
& "="
& _serialize_typename(Record.Field(flds, item)[Type])
)
)
& (if Type.IsOpenRecord(x) then ",..." else "")
& "]",
_serialize_function_type = (x) =>
_serialize_function_param_type(Type.FunctionParameters(x), Type.FunctionRequiredParameters(x))
& " as "
& _serialize_function_return_type(x),
_serialize_function_param_type = (t, n) =>
let
funsig = Table.ToRecords(
Table.TransformColumns(
Table.AddIndexColumn(Record.ToTable(t), "isOptional", 1), {"isOptional", (x) => x > n}
)
)
in
"("
& List.Accumulate(
funsig,
"",
(seed, item) =>
(if seed = "" then "" else seed & ", ")
& (if item[isOptional] then "optional " else "")
& Serialize.Identifier(item[Name])
& " as "
& _serialize_typename(item[Value], true)
)
& ")",
_serialize_function_return_type = (x) => _serialize_typename(Type.FunctionReturn(x), true),
Serialize = (x) as text =>
if x is binary then
try Serialize.Binary(x) otherwise "null /*serialize failed*/"
else if x is date then
try Serialize.Date(x) otherwise "null /*serialize failed*/"
else if x is datetime then
try Serialize.Datetime(x) otherwise "null /*serialize failed*/"
else if x is datetimezone then
try Serialize.Datetimezone(x) otherwise "null /*serialize failed*/"
else if x is duration then
try Serialize.Duration(x) otherwise "null /*serialize failed*/"
else if x is function then
try Serialize.Function(x) otherwise "null /*serialize failed*/"
else if x is list then
try Serialize.List(x) otherwise "null /*serialize failed*/"
else if x is logical then
try Serialize.Logical(x) otherwise "null /*serialize failed*/"
else if x is null then
try Serialize.Null(x) otherwise "null /*serialize failed*/"
else if x is number then
try Serialize.Number(x) otherwise "null /*serialize failed*/"
else if x is record then
try Serialize.Record(x) otherwise "null /*serialize failed*/"
else if x is table then
try Serialize.Table(x) otherwise "null /*serialize failed*/"
else if x is text then
try Serialize.Text(x) otherwise "null /*serialize failed*/"
else if x is time then
try Serialize.Time(x) otherwise "null /*serialize failed*/"
else if x is type then
try Serialize.Type(x) otherwise "null /*serialize failed*/"
else
"[#_unable_to_serialize_#]"
in
try Serialize(value) otherwise "<serialization failed>"
in
[
LogValue = Diagnostics.LogValue,
LogValue2 = Diagnostics.LogValue2,
LogFailure = Diagnostics.LogFailure,
WrapFunctionResult = Diagnostics.WrapFunctionResult,
WrapHandlers = Diagnostics.WrapHandlers,
ValueToText = Diagnostics.ValueToText
]
@@ -1,17 +0,0 @@
// This is here as reference for copy/pasting wherever there is need for importing pqm files.
let
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
Extension.LoadFunction
@@ -1,231 +0,0 @@
let
/// COMMON UNIT TESTING CODE
Fact = (_subject as text, _expected, _actual) as record =>
[
expected = try _expected,
safeExpected = if expected[HasError] then "Expected : " & @ValueToText(expected[Error]) else expected[
Value
],
actual = try _actual,
safeActual = if actual[HasError] then "Actual : " & @ValueToText(actual[Error]) else actual[Value],
attempt = try safeExpected = safeActual,
result = if attempt[HasError] or not attempt[Value] then "Failure" else "Success",
resultOp = if result = "Success" then " = " else " <> ",
addendumEvalAttempt = if attempt[HasError] then @ValueToText(attempt[Error]) else "",
addendumEvalExpected = try @ValueToText(safeExpected) otherwise "...",
addendumEvalActual = try @ValueToText(safeActual) otherwise "...",
fact = [
Result = result & " " & addendumEvalAttempt,
Notes = _subject,
Details = " (" & addendumEvalExpected & resultOp & addendumEvalActual & ")"
]
][fact],
Facts = (_subject as text, _predicates as list) => List.Transform(_predicates, each Fact(_subject, _{0}, _{1})),
Facts.Summarize = (_facts as list) as table =>
[
Fact.CountSuccesses = (count, i) =>
[
result = try i[Result],
sum = if result[HasError] or not Text.StartsWith(result[Value], "Success") then count else count + 1
][sum],
passed = List.Accumulate(_facts, 0, Fact.CountSuccesses),
total = List.Count(_facts),
format = if passed = total then "All #{0} Passed !!!" else "#{0} Passed - #{1} Failed",
result = if passed = total then "Success" else "Failed",
rate = Number.IntegerDivide(100 * passed, total),
header = [
Result = result,
Notes = Text.Format(format, {passed, total - passed}),
Details = Text.Format("#{0}% success rate", {rate})
],
report = Table.FromRecords(List.Combine({{header}, _facts}))
][report],
ValueToText = (value, optional depth) =>
let
List.TransformAndCombine = (list, transform, separator) =>
Text.Combine(List.Transform(list, transform), separator),
Serialize.Binary = (x) => "#binary(" & Serialize(Binary.ToList(x)) & ") ",
Serialize.Function = (x) =>
_serialize_function_param_type(
Type.FunctionParameters(Value.Type(x)), Type.FunctionRequiredParameters(Value.Type(x))
)
& " as "
& _serialize_function_return_type(Value.Type(x))
& " => (...) ",
Serialize.List = (x) => "{" & List.TransformAndCombine(x, Serialize, ", ") & "} ",
Serialize.Record = (x) =>
"[ "
& List.TransformAndCombine(
Record.FieldNames(x),
(item) => Serialize.Identifier(item) & " = " & Serialize(Record.Field(x, item)),
", "
)
& " ] ",
Serialize.Table = (x) =>
"#table( type " & _serialize_table_type(Value.Type(x)) & ", " & Serialize(Table.ToRows(x)) & ") ",
Serialize.Identifier = Expression.Identifier,
Serialize.Type = (x) => "type " & _serialize_typename(x),
_serialize_typename = (x, optional funtype as logical) =>
/* Optional parameter: Is this being used as part of a function signature? */ let
isFunctionType = (x as type) =>
try if Type.FunctionReturn(x) is type then true else false otherwise false,
isTableType = (x as type) =>
try if Type.TableSchema(x) is table then true else false otherwise false,
isRecordType = (x as type) =>
try if Type.ClosedRecord(x) is type then true else false otherwise false,
isListType = (x as type) => try if Type.ListItem(x) is type then true else false otherwise false
in
if funtype = null and isTableType(x) then
_serialize_table_type(x)
else if funtype = null and isListType(x) then
"{ " & @_serialize_typename(Type.ListItem(x)) & " }"
else if funtype = null and isFunctionType(x) then
"function " & _serialize_function_type(x)
else if funtype = null and isRecordType(x) then
_serialize_record_type(x)
else if x = type any then
"any"
else
let
base = Type.NonNullable(x)
in
(if Type.IsNullable(x) then "nullable " else "")
& (
if base = type anynonnull then
"anynonnull"
else if base = type binary then
"binary"
else if base = type date then
"date"
else if base = type datetime then
"datetime"
else if base = type datetimezone then
"datetimezone"
else if base = type duration then
"duration"
else if base = type logical then
"logical"
else if base = type none then
"none"
else if base = type null then
"null"
else if base = type number then
"number"
else if base = type text then
"text"
else if base = type time then
"time"
else if base = type type then
"type"
else /* Abstract types: */ if base = type function then
"function"
else if base = type table then
"table"
else if base = type record then
"record"
else if base = type list then
"list"
else
"any /*Actually unknown type*/"
),
_serialize_table_type = (x) =>
let
schema = Type.TableSchema(x)
in
"table "
& (
if Table.IsEmpty(schema) then
""
else
"["
& List.TransformAndCombine(
Table.ToRecords(Table.Sort(schema, "Position")),
each Serialize.Identifier(_[Name]) & " = " & _[Kind],
", "
)
& "] "
),
_serialize_record_type = (x) =>
let
flds = Type.RecordFields(x)
in
if Record.FieldCount(flds) = 0 then
"record"
else
"["
& List.TransformAndCombine(
Record.FieldNames(flds),
(item) =>
Serialize.Identifier(item)
& "="
& _serialize_typename(Record.Field(flds, item)[Type]),
", "
)
& (if Type.IsOpenRecord(x) then ", ..." else "")
& "]",
_serialize_function_type = (x) =>
_serialize_function_param_type(Type.FunctionParameters(x), Type.FunctionRequiredParameters(x))
& " as "
& _serialize_function_return_type(x),
_serialize_function_param_type = (t, n) =>
let
funsig = Table.ToRecords(
Table.TransformColumns(
Table.AddIndexColumn(Record.ToTable(t), "isOptional", 1), {"isOptional", (x) => x > n}
)
)
in
"("
& List.TransformAndCombine(
funsig,
(item) =>
(if item[isOptional] then "optional " else "")
& Serialize.Identifier(item[Name])
& " as "
& _serialize_typename(item[Value], true),
", "
)
& ")",
_serialize_function_return_type = (x) => _serialize_typename(Type.FunctionReturn(x), true),
Serialize = (x) as text =>
if x is binary then
try Serialize.Binary(x) otherwise "null /*serialize failed*/"
else if x is date then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is datetime then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is datetimezone then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is duration then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is function then
try Serialize.Function(x) otherwise "null /*serialize failed*/"
else if x is list then
try Serialize.List(x) otherwise "null /*serialize failed*/"
else if x is logical then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is null then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is number then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is record then
try Serialize.Record(x) otherwise "null /*serialize failed*/"
else if x is table then
try Serialize.Table(x) otherwise "null /*serialize failed*/"
else if x is text then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is time then
try Expression.Constant(x) otherwise "null /*serialize failed*/"
else if x is type then
try Serialize.Type(x) otherwise "null /*serialize failed*/"
else
"[#_unable_to_serialize_#]"
in
try Serialize(value) otherwise "<serialization failed>"
in
[
Fact = Fact,
Facts = Facts,
SummarizeFacts = Facts.Summarize,
ValueToText = ValueToText
]
@@ -1,12 +0,0 @@
(Value as text) =>
let
Solution = Binary.ToText(
Binary.FromList(
Binary.ToList(Binary.Compress(Text.ToBinary(Value, BinaryEncoding.Base64), Compression.GZip))
)
)
in
if Value = null then
null
else
Solution
@@ -1,23 +0,0 @@
(getNextPage as function) as table =>
let
listOfPages = List.Generate(
() => getNextPage(null),
// get the first page of data
(lastPage) => lastPage <> null,
// stop when the function returns null
(lastPage) => getNextPage(lastPage)
// pass the previous page to the next function call
),
// concatenate the pages together
tableOfPages = Table.FromList(listOfPages, Splitter.SplitByNothing(), {"Column1"}),
firstRow = tableOfPages{0} ?
in
// if we didn't get back any pages of data, return an empty table
// otherwise set the table type based on the columns of the first page
if (firstRow = null) then
Table.FromRows({})
else
Value.ReplaceType(
Table.ExpandTableColumn(tableOfPages, "Column1", Table.ColumnNames(firstRow[Column1])),
Value.Type(firstRow[Column1])
)
@@ -1,21 +0,0 @@
(
table as table,
keyColumns as list,
nameColumn as text,
dataColumn as text,
itemKindColumn as text,
itemNameColumn as text,
isLeafColumn as text
) as table =>
let
tableType = Value.Type(table),
newTableType = Type.AddTableKey(tableType, keyColumns, true) meta [
NavigationTable.NameColumn = nameColumn,
NavigationTable.DataColumn = dataColumn,
NavigationTable.ItemKindColumn = itemKindColumn,
Preview.DelayColumn = itemNameColumn,
NavigationTable.IsLeafColumn = isLeafColumn
],
navigationTable = Value.ReplaceType(table, newTableType)
in
navigationTable
@@ -1,14 +0,0 @@
(producer as function, interval as function, optional count as number) as any =>
let
list = List.Generate(
() => {0, null},
(state) => state{0} <> null and (count = null or state{0} < count),
(state) =>
if state{1} <> null then
{null, state{1}}
else
{1 + state{0}, Function.InvokeAfter(() => producer(state{0}), interval(state{0}))},
(state) => state{1}
)
in
List.Last(list)
+2 -4
View File
@@ -1,5 +1,5 @@
{
"editor.tabSize": 4,
"editor.tabSize": 2,
"editor.insertSpaces": true,
"files.eol": "\n",
"files.watcherExclude": {
@@ -7,12 +7,10 @@
"**/node_modules/**": true,
".tmp": true
},
"files.exclude": {
".tmp": true
},
"files.associations": {
"*.resjson": "json"
},
"editor.formatOnSave": true,
"search.exclude": {
".tmp": true,
"typings": true
+23
View File
@@ -76,6 +76,29 @@ You'll need to properly set up the certificate in order to be able to use the ho
> 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.
+27 -28
View File
@@ -1,19 +1,14 @@
{
"dataRoles": [
{
"displayName": "Model URL",
"displayName": "Root Object ID",
"kind": "Grouping",
"name": "stream"
"name": "rootObjectId"
},
{
"displayName": "Version Object ID",
"displayName": "Object IDs",
"kind": "Grouping",
"name": "parentObject"
},
{
"displayName": "Object ID",
"kind": "Grouping",
"name": "object"
"name": "objectIds"
},
{
"displayName": "Color By",
@@ -23,7 +18,7 @@
{
"displayName": "Tooltip Data",
"kind": "Measure",
"name": "objectData"
"name": "tooltipData"
}
],
"dataViewMappings": [
@@ -32,18 +27,13 @@
"rows": {
"dataReductionAlgorithm": {
"top": {
"count": 30000
"count": 150000
}
},
"select": [
{
"bind": {
"to": "stream"
}
},
{
"bind": {
"to": "parentObject"
"to": "rootObjectId"
}
},
{
@@ -53,7 +43,7 @@
},
{
"for": {
"in": "object"
"in": "objectIds"
}
}
]
@@ -62,15 +52,31 @@
"select": [
{
"bind": {
"to": "objectData"
"to": "tooltipData"
}
}
]
}
}
},
"conditions": [
{
"objectIds": { "max": 1 },
"rootObjectId": { "max": 1 }
}
]
}
],
"objects": {
"storedData": {
"properties": {
"speckleObjects": {
"type": { "text": true }
},
"userInfo": {
"type": { "text": true }
}
}
},
"camera": {
"properties": {
"defaultView": {
@@ -205,14 +211,7 @@
{
"essential": true,
"name": "WebAccess",
"parameters": [
"https://speckle.xyz",
"https://*.speckle.xyz",
"https://latest.speckle.dev",
"https://*.speckle.dev",
"https://analytics.speckle.systems",
"*"
]
"parameters": ["https://analytics.speckle.systems", "http://localhost:49161", "*"]
},
{
"essential": false,
+5524 -3709
View File
File diff suppressed because it is too large Load Diff
+30 -26
View File
@@ -7,32 +7,33 @@
},
"license": "MIT",
"scripts": {
"pbiviz": "pbiviz",
"pack": "webpack --config webpack.config.ts",
"build": "webpack --config webpack.config.dev.ts",
"serve": "webpack-dev-server --config webpack.config.dev.ts"
"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/tailwind-theme": "2.14.7",
"@speckle/ui-components": "2.14.7",
"@speckle/viewer": "^2.18.14",
"@speckle/tailwind-theme": "2.23.2",
"@speckle/ui-components": "2.23.2",
"@speckle/viewer": "2.23.4",
"color-interpolate": "^1.0.5",
"core-js": "^3.30.2",
"lodash": "^4.17.21",
"nanoevents": "^9.1.0",
"pinia": "^2.3.0",
"postcss-loader": "^7.3.0",
"postcss-preset-env": "^8.4.1",
"powerbi-visuals-api": "~5.4.0",
"powerbi-visuals-utils-colorutils": "^6.0.1",
"powerbi-visuals-utils-dataviewutils": "^6.0.1",
"powerbi-visuals-utils-formattingmodel": "^5.0.0",
"powerbi-visuals-utils-interactivityutils": "^6.0.2",
"powerbi-visuals-utils-tooltiputils": "^6.0.1",
"regenerator-runtime": "^0.13.11",
"vuex": "^4.1.0"
"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"
},
"devDependencies": {
"@babel/core": "^7.21.8",
@@ -42,7 +43,7 @@
"@types/lodash": "^4.14.194",
"@types/node": "^20.1.7",
"@types/regenerator-runtime": "^0.13.1",
"@types/three": "^0.152.0",
"@types/three": "^0.140.0",
"@types/webpack": "^5.28.1",
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
@@ -58,8 +59,8 @@
"mini-css-extract-plugin": "^2.7.5",
"postcss": "^8.4.23",
"postcss-import": "^15.1.0",
"powerbi-visuals-tools": "^5.4.3",
"powerbi-visuals-webpack-plugin": "^4.0.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",
@@ -68,13 +69,16 @@
"tsconfig-paths-webpack-plugin": "^4.0.1",
"typescript": "^5.0.4",
"user-agent-data-types": "^0.3.1",
"vue": "^3.3.4",
"vue-loader": "^17.1.1",
"vue-template-compiler": "^2.7.14",
"webpack": "^5.83.0",
"webpack-bundle-analyzer": "^4.8.0",
"webpack-cli": "^5.1.1",
"webpack-dev-server": "^4.15.0"
"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": "2.0.0"
"version": "3.0.0",
"engines": {
"node": "^20.17.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
"displayName": "Speckle PowerBI Viewer",
"guid": "specklePowerBiVisual",
"visualClassName": "Visual",
"version": "2.0.0",
"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"
+13 -15
View File
@@ -1,19 +1,17 @@
<script setup lang="ts">
import HomeView from './views/HomeView.vue'
import ViewerView from './views/ViewerView.vue'
import { computed } from 'vue'
import { useStore } from 'vuex'
import { storeKey } from 'src/injectionKeys'
let store = useStore(storeKey)
let status = computed(() => {
return store.state.status
})
</script>
<template>
<ViewerView v-if="status == 'valid'" />
<ViewerView v-if="visualStore.isViewerReadyToLoad" />
<HomeView v-else />
</template>
<style scoped></style>
<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'
const visualStore = useVisualStore()
onMounted(() => {
console.log('App mounted')
})
</script>
@@ -1,45 +1,3 @@
<script setup lang="ts">
import {
VideoCameraIcon,
CubeIcon,
ArrowsPointingOutIcon,
PaintBrushIcon
} from '@heroicons/vue/24/solid'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { CanonicalView, SpeckleView } from '@speckle/viewer'
import ButtonToggle from 'src/components/controls/ButtonToggle.vue'
import ButtonGroup from 'src/components/controls/ButtonGroup.vue'
import ButtonSimple from 'src/components/controls/ButtonSimple.vue'
import { inject, watch } from 'vue'
import { hostKey, viewerHandlerKey } from 'src/injectionKeys'
import { resetPalette } from 'src/utils/matrixViewUtils'
const emits = defineEmits(['update:sectionBox', 'view-clicked', 'clear-palette'])
const props = withDefaults(defineProps<{ sectionBox: boolean; views: SpeckleView[] }>(), {
sectionBox: false,
views: () => []
})
const viewerHandler = inject(viewerHandlerKey)
const canonicalViews = [
{ name: 'Top' },
{ name: 'Front' },
{ name: 'Left' },
{ name: 'Back' },
{ name: 'Right' }
]
const onZoomExtentsClicked = (ev: MouseEvent) => {
console.log('Zoom extents clicked', viewerHandler)
viewerHandler.zoomExtents()
}
const host = inject(hostKey)
const onClearPalletteClicked = (ev: MouseEvent) => {
console.log('Clear pallette clicked')
resetPalette()
emits('clear-palette')
}
</script>
<template>
<ButtonGroup>
<ButtonSimple flat secondary @click="onZoomExtentsClicked">
@@ -60,7 +18,7 @@ const onClearPalletteClicked = (ev: MouseEvent) => {
leave-to-class="opacity-0"
>
<MenuItems
class="absolute w-60 left-2 -translate-y-8 bottom-2 bg-foundation max-h-64 simple-scrollbar overflow-y-auto outline outline-2 outline-primary-muted rounded-lg shadow-lg overflow-hidden flex flex-col"
class="absolute w-24 left-2 mb-8 bottom-2 bg-foundation max-h-64 simple-scrollbar overflow-y-auto outline outline-2 outline-primary-muted rounded-lg shadow-lg overflow-hidden flex flex-col"
>
<MenuItem
v-for="view in canonicalViews"
@@ -94,6 +52,7 @@ const onClearPalletteClicked = (ev: MouseEvent) => {
</MenuItems>
</Transition>
</Menu>
<!--
<ButtonToggle
flat
secondary
@@ -104,8 +63,49 @@ const onClearPalletteClicked = (ev: MouseEvent) => {
</ButtonToggle>
<ButtonSimple flat secondary @click="onClearPalletteClicked">
<PaintBrushIcon class="h-5 w-5" />
</ButtonSimple>
</ButtonSimple> -->
</ButtonGroup>
</template>
<style scoped></style>
<script setup lang="ts">
import {
VideoCameraIcon,
CubeIcon,
ArrowsPointingOutIcon,
PaintBrushIcon
} from '@heroicons/vue/24/solid'
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
import { CanonicalView, SpeckleView } from '@speckle/viewer'
import ButtonToggle from 'src/components/controls/ButtonToggle.vue'
import ButtonGroup from 'src/components/controls/ButtonGroup.vue'
import ButtonSimple from 'src/components/controls/ButtonSimple.vue'
import { inject, watch } from 'vue'
import { resetPalette } from 'src/utils/matrixViewUtils'
import { useVisualStore } from '@src/store/visualStore'
const visualStore = useVisualStore()
const emits = defineEmits(['update:sectionBox', 'view-clicked', 'clear-palette'])
const props = withDefaults(defineProps<{ sectionBox: boolean; views: SpeckleView[] }>(), {
sectionBox: false,
views: () => []
})
const canonicalViews = [
{ name: 'Top' },
{ name: 'Front' },
{ name: 'Left' },
{ name: 'Back' },
{ name: 'Right' }
]
const onZoomExtentsClicked = (ev: MouseEvent) => {
visualStore.viewerEmit('zoomExtends')
}
const onClearPalletteClicked = (ev: MouseEvent) => {
console.log('Clear pallette clicked')
resetPalette()
emits('clear-palette')
}
</script>
@@ -1,141 +1,75 @@
<template>
<div class="flex flex-col justify-center items-center">
<div
ref="container"
class="fixed h-full w-full z-0"
@click="onCanvasClick"
@auxclick="onCanvasAuxClick"
/>
<!-- <div class="z-30 w-1/2 px-10">
<common-loading-bar :loading="isLoading" />
</div> -->
<viewer-controls
v-model:section-box="bboxActive"
:views="views"
class="fixed bottom-6"
@view-clicked="(view) => viewerHandler.setView(view)"
/>
</div>
</template>
<script async setup lang="ts">
import {
computed,
inject,
onBeforeUnmount,
onMounted,
provide,
Ref,
ref,
watch,
watchEffect
} from 'vue'
import { useStore } from 'vuex'
import { inject, onBeforeUnmount, onMounted, Ref, ref } from 'vue'
import { currentOS, OS } from '../utils/detectOS'
import ViewerControls from 'src/components/ViewerControls.vue'
import { CanonicalView, SpeckleView } from '@speckle/viewer'
import { CommonLoadingBar } from '@speckle/ui-components'
import ViewerHandler from 'src/handlers/viewerHandler'
import { useClickDragged } from 'src/composables/useClickDragged'
import { isMultiSelect } from 'src/utils/isMultiSelect'
import {
selectionHandlerKey,
storeKey,
tooltipHandlerKey,
viewerHandlerKey
} from 'src/injectionKeys'
import { SpeckleDataInput } from 'src/types'
import { debounce, throttle } from 'lodash'
import { ContextOption } from 'src/settings/colorSettings'
import { useVisualStore } from '@src/store/visualStore'
import { ViewerHandler } from '@src/plugins/viewer'
import { selectionHandlerKey, tooltipHandlerKey } from '@src/injectionKeys'
const visualStore = useVisualStore()
const { dragged } = useClickDragged()
const selectionHandler = inject(selectionHandlerKey)
const tooltipHandler = inject(tooltipHandlerKey)
const store = useStore(storeKey)
const { dragged } = useClickDragged()
let viewerHandler: ViewerHandler = null
let ac = new AbortController()
const container = ref<HTMLElement>()
let bboxActive = ref(false)
let views: Ref<SpeckleView[]> = ref([])
let updateTask: Ref<Promise<void>> = ref(null)
let setupTask: Promise<void> = null
const isLoading = computed(() => updateTask.value != null)
const input = computed(() => store.state.input)
const settings = computed(() => store.state.settings)
const onCameraMoved = throttle((_) => {
const pos = tooltipHandler.currentTooltip?.worldPos
if (!pos) return
const screenPos = viewerHandler.getScreenPosition(pos)
tooltipHandler.move(screenPos)
}, 50)
onMounted(() => {
viewerHandler = new ViewerHandler(container.value)
provide<ViewerHandler>(viewerHandlerKey, viewerHandler)
setupTask = viewerHandler
.init()
.then(() => viewerHandler.addCameraUpdateEventListener(onCameraMoved))
.finally(async () => {
if (input.value) await cancelAndHandleDataUpdate()
viewerHandler.updateSettings(settings.value)
})
onMounted(async () => {
console.log('Viewer Wrapper mounted')
viewerHandler = new ViewerHandler()
await viewerHandler.init(container.value)
visualStore.setViewerEmitter(viewerHandler.emit)
})
onBeforeUnmount(async () => {
await viewerHandler.dispose()
})
const debounceUpdate = throttle(cancelAndHandleDataUpdate, 500)
const debounceSettingsUpdate = throttle(() => viewerHandler.updateSettings(settings.value), 500)
watch(input, debounceUpdate)
watch(settings, debounceSettingsUpdate)
watchEffect(() => {
if (!isLoading.value) viewerHandler?.setSectionBox(bboxActive.value, input.value.objectIds)
})
function handleDataUpdate(input: Ref<SpeckleDataInput>, signal: AbortSignal) {
updateTask.value = setupTask
.then(async () => {
signal.throwIfAborted()
// Clear previous selection
await viewerHandler.selectObjects(null)
// Load
await viewerHandler.loadObjectsWithAutoUnload(
input.value.objectsToLoad,
console.log,
console.error,
signal
)
// Color
await viewerHandler.colorObjectsByGroup(input.value.colorByIds)
await viewerHandler.unIsolateObjects()
const objectsToIsolate =
input.value.selectedIds.length == 0 ? input.value.objectIds : input.value.selectedIds
if (settings.value.color.context.value != ContextOption.show)
await viewerHandler.isolateObjects(
objectsToIsolate,
settings.value.color.context.value === ContextOption.ghosted
)
if (settings.value.camera.zoomOnDataChange.value) viewerHandler.zoom(objectsToIsolate)
// Update available views
views.value = viewerHandler.getViews()
})
.catch((e: Error) => {
console.log('Loading operation was aborted', e)
})
.finally(() => {
updateTask.value = null
})
}
async function cancelAndHandleDataUpdate() {
console.log('Input has changed', input.value)
if (updateTask.value) {
ac.abort('New input is available')
console.log('Cancelling previous load job')
await updateTask.value
ac = new AbortController()
}
const signal = ac.signal
handleDataUpdate(input, signal)
function isMultiSelect(e: MouseEvent) {
if (!e) return false
if (currentOS === OS.MacOS) return e.metaKey || e.shiftKey
else return e.ctrlKey || e.shiftKey
}
async function onCanvasClick(ev: MouseEvent) {
if (dragged.value) return
const intersectResult = await viewerHandler.intersect({ x: ev.clientX, y: ev.clientY })
const multi = isMultiSelect(ev)
const hit = intersectResult?.hit
if (hit) {
const id = hit.object.id as string
if (multi || !selectionHandler.isSelected(id)) await selectionHandler.select(id, multi)
if (multi || !selectionHandler.isSelected(id)) {
await selectionHandler.select(id, multi)
}
tooltipHandler.show(hit, { x: ev.clientX, y: ev.clientY })
const selection = selectionHandler.getCurrentSelection()
const ids = selection.map((s) => s.id)
@@ -154,31 +88,4 @@ async function onCanvasAuxClick(ev: MouseEvent) {
const intersectResult = await viewerHandler.intersect({ x: ev.clientX, y: ev.clientY })
await selectionHandler.showContextMenu(ev, intersectResult?.hit)
}
function onClearPalette() {
cancelAndHandleDataUpdate()
}
</script>
<template>
<div class="flex flex-col justify-center items-center">
<div
ref="container"
class="fixed h-full w-full z-0"
@click="onCanvasClick"
@auxclick="onCanvasAuxClick"
/>
<div class="z-30 w-1/2 px-10">
<common-loading-bar :loading="isLoading" />
</div>
<viewer-controls
v-if="!isLoading"
v-model:section-box="bboxActive"
:views="views"
class="fixed bottom-6"
@view-clicked="(view) => viewerHandler.setView(view)"
@clearPalette="onClearPalette"
/>
</div>
</template>
<style scoped></style>
@@ -20,18 +20,23 @@ export default class SelectionHandler {
})
}
public set(url: string, data: powerbi.extensibility.ISelectionId) {
this.selectionIdMap.set(url, data)
public set(objectId: string, data: powerbi.extensibility.ISelectionId) {
this.selectionIdMap.set(objectId, data)
}
public async select(url: string, multi = false) {
public async select(objectId: string, multi = false) {
const selectionId = this.selectionIdMap.get(objectId)
if (multi) {
await this.selectionManager.select(this.selectionIdMap.get(url), true)
if (this.currentSelection.has(url)) this.currentSelection.delete(url)
else this.currentSelection.add(url)
await this.selectionManager.select(selectionId, true)
if (this.currentSelection.has(objectId)) {
this.currentSelection.delete(objectId)
} else {
this.currentSelection.add(objectId)
}
} else {
await this.selectionManager.select(this.selectionIdMap.get(url), false)
await this.selectionManager.select(selectionId, false)
this.currentSelection.clear()
this.currentSelection.add(url)
this.currentSelection.add(objectId)
}
}
@@ -4,13 +4,19 @@ import {
LegacyViewer,
IntersectionQuery,
DefaultViewerParams,
Box3,
SpeckleView,
CameraController
CameraController,
CameraEvent,
SpeckleOfflineLoader
} from '@speckle/viewer'
import { pickViewableHit, projectToScreen } from '../utils/viewerUtils'
import _ from 'lodash'
import { SpeckleVisualSettingsModel } from 'src/settings/visualSettingsModel'
import { PerspectiveCamera, OrthographicCamera, Box3 } from 'three'
/**
* @deprecated
*/
export default class ViewerHandler {
private viewer: LegacyViewer
private readonly parent: HTMLElement
@@ -38,10 +44,9 @@ export default class ViewerHandler {
break
}
this.viewer.getExtension(CameraController).controls.maxPolarAngle = settings.camera
.allowCameraUnder.value
? Math.PI
: Math.PI / 2
var camController = this.viewer.getExtension(CameraController)
var angle = settings.camera.allowCameraUnder.value ? Math.PI : Math.PI / 2
camController.options = { maximumPolarAngle: angle }
// Lighting settings
const newConfig = settings.lighting.getViewerConfiguration()
@@ -59,10 +64,10 @@ export default class ViewerHandler {
if (this.currentSectionBox === null) {
const bbox = this.viewer.getSectionBoxFromObjects(objectIds)
this.viewer.setSectionBox(bbox)
this.currentSectionBox = bbox
this.currentSectionBox = bbox as unknown as Box3
} else {
const bbox = this.viewer.getCurrentSectionBox()
if (bbox) this.currentSectionBox = bbox
if (bbox) this.currentSectionBox = bbox as unknown as Box3
}
this.viewer.sectionBoxOn()
} else {
@@ -72,7 +77,7 @@ export default class ViewerHandler {
}
public addCameraUpdateEventListener(listener: (ev) => void) {
this.viewer.getExtension(CameraController).controls.addEventListener('update', listener)
this.viewer.getExtension(CameraController).on(CameraEvent.LateFrameUpdate, listener)
}
public constructor(parent: HTMLElement) {
@@ -86,6 +91,7 @@ export default class ViewerHandler {
viewerSettings.verbose = false
const viewer = new LegacyViewer(this.parent, viewerSettings)
await viewer.init()
console.log('Viewer initialized', viewer)
this.viewer = viewer
}
@@ -107,52 +113,27 @@ export default class ViewerHandler {
}
public async loadObjectsWithAutoUnload(
objectUrls: string[],
objects: object[],
onLoad: (url: string, index: number) => void,
onError: (url: string, error: Error) => void,
signal: AbortSignal
) {
var objectsToUnload = _.difference([...this.loadedObjectsCache], objectUrls)
await this.unloadObjects(objectsToUnload, signal)
await this.loadObjects(objectUrls, onLoad, onError, signal)
// var objectsToUnload = _.difference([...this.loadedObjectsCache], rootObject)
// await this.unloadObjects(objectsToUnload, signal)
// await this.loadObjects(obj, onLoad, onError) // TODO: pass root object
await this.loadObjects(objects, onLoad, onError)
}
public async loadObjects(
objectUrls: string[],
objects: object[],
onLoad: (url: string, index: number) => void,
onError: (url: string, error: Error) => void,
signal: AbortSignal
onError: (url: string, error: Error) => void
) {
try {
let index = 0
let promises = []
for (const url of objectUrls) {
signal.throwIfAborted()
console.log('Attempting to load', url)
if (!this.loadedObjectsCache.has(url)) {
console.log('Object is not in cache')
const promise = this.viewer
.loadObjectAsync(url, this.config.authToken, false)
.then(() => onLoad(url, index++))
.catch((e: Error) => onError(url, e))
.finally(() => {
if (!this.loadedObjectsCache.has(url)) this.loadedObjectsCache.add(url)
})
promises.push(promise)
if (promises.length == this.config.batchSize) {
//this.promises.push(Promise.resolve(this.later(1000)))
await Promise.all(promises)
promises = []
}
} else {
console.log('Object was already in cache')
}
}
await Promise.all(promises)
} catch (error) {
if (error.name === 'AbortError') return
throw new Error(`Load objects failed: ${error}`)
}
const stringifiedObject = JSON.stringify(objects)
const loader = new SpeckleOfflineLoader(this.viewer.getWorldTree(), stringifiedObject)
void this.viewer.unloadAll()
void this.viewer.loadObject(loader, true)
}
public async intersect(coords: { x: number; y: number }) {
@@ -213,13 +194,15 @@ export default class ViewerHandler {
public getScreenPosition(worldPosition): { x: number; y: number } {
return projectToScreen(
this.viewer.getExtension(CameraController).controls.camera,
this.viewer.getExtension(CameraController).renderingCamera as unknown as
| PerspectiveCamera
| OrthographicCamera,
worldPosition
)
}
public dispose() {
this.viewer.getExtension(CameraController).controls.removeAllEventListeners()
this.viewer.getExtension(CameraController).dispose()
this.viewer.dispose()
this.viewer = null
}
-6
View File
@@ -1,12 +1,6 @@
import { InjectionKey } from 'vue'
import SelectionHandler from 'src/handlers/selectionHandler'
import TooltipHandler from 'src/handlers/tooltipHandler'
import { Store } from 'vuex'
import { SpeckleVisualState } from 'src/store'
import ViewerHandler from 'src/handlers/viewerHandler'
export const selectionHandlerKey: InjectionKey<SelectionHandler> = Symbol()
export const tooltipHandlerKey: InjectionKey<TooltipHandler> = Symbol()
export const hostKey: InjectionKey<powerbi.extensibility.visual.IVisualHost> = Symbol()
export const storeKey: InjectionKey<Store<SpeckleVisualState>> = Symbol()
export const viewerHandlerKey: InjectionKey<ViewerHandler> = Symbol()
+3
View File
@@ -0,0 +1,3 @@
import { createPinia } from 'pinia'
export const pinia = createPinia()
+183
View File
@@ -0,0 +1,183 @@
import {
LegacyViewer,
DefaultViewerParams,
SpeckleOfflineLoader,
FilteringState,
IntersectionQuery,
CameraController,
CanonicalView
} from '@speckle/viewer'
import { SpeckleDataInput } from '@src/types'
import { Tracker } from '@src/utils/mixpanel'
import { createNanoEvents, Emitter } from 'nanoevents'
import { ColorPicker } from 'powerbi-visuals-utils-formattingmodel/lib/FormattingSettingsComponents'
import { toRaw } from 'vue'
export interface IViewer {
/**
* Events sent over from the host application.
*/
on: <E extends keyof IViewerEvents>(event: E, callback: IViewerEvents[E]) => void
}
export interface Hit {
guid: string
object?: Record<string, unknown>
point: { x: number; y: number; z: number }
}
export interface IViewerEvents {
ping: (message: string) => void
setSelection: (objectIds: string[]) => void
colorObjectsByGroup: (
colorById: {
objectIds: string[]
slice: ColorPicker
color: string
}[]
) => void
isolateObjects: (objectIds: string[]) => void
forceViewerUpdate: () => void
unIsolateObjects: () => void
zoomExtends: () => void
loadObjects: (objects: object[]) => void
}
export class ViewerHandler {
public emitter: Emitter
public viewer: LegacyViewer
private parent: HTMLElement
private filteringState: FilteringState
constructor() {
this.emitter = createNanoEvents()
this.emit = this.emit.bind(this)
this.emitter.on('ping', this.handlePing)
this.emitter.on('setSelection', this.selectObjects)
this.emitter.on('colorObjectsByGroup', this.colorObjectsByGroup)
this.emitter.on('isolateObjects', this.isolateObjects)
this.emitter.on('unIsolateObjects', this.unIsolateObjects)
this.emitter.on('zoomExtends', this.zoomExtends)
this.emitter.on('zoomObjects', this.zoomObjects)
this.emitter.on('loadObjects', this.loadObjects)
}
async init(parent: HTMLElement) {
this.viewer = await createViewer(parent)
this.parent = parent
}
emit<E extends keyof IViewerEvents>(event: E, ...payload: Parameters<IViewerEvents[E]>): void {
this.emitter.emit(event, ...payload)
}
public zoomObjects = (objectIds: string[]) => {
this.viewer.zoom(objectIds)
}
public zoomExtends = () => this.viewer.zoom()
public setView = (view: CanonicalView) => this.viewer.setView(view)
public setSectionBox = (bboxActive: boolean, objectIds: string[]) => {
// TODO
return
}
public selectObjects = async (objectIds: string[]) => {
console.log('🔗 Handling setSelection inside ViewerHandler:', objectIds)
if (objectIds) {
await this.viewer.selectObjects(objectIds)
}
}
public colorObjectsByGroup = async (
colorByIds: {
objectIds: string[]
color: string
}[]
) => {
this.filteringState = await this.viewer.setUserObjectColors(colorByIds ?? [])
}
public isolateObjects = async (objectIds: string[], ghost: boolean) => {
await this.unIsolateObjects()
this.filteringState = await this.viewer.isolateObjects(objectIds, 'powerbi', true, ghost)
}
public unIsolateObjects = async () => {
if (this.filteringState && this.filteringState.isolatedObjects) {
this.filteringState = await this.viewer.unIsolateObjects(
this.filteringState.isolatedObjects,
'powerbi',
true
)
}
}
public intersect = (coords: { x: number; y: number }) => {
const point = this.viewer.Utils.screenToNDC(
coords.x,
coords.y,
this.parent.clientWidth,
this.parent.clientHeight
)
const intQuery: IntersectionQuery = {
operation: 'Pick',
point
}
const res = this.viewer.query(intQuery)
// console.log(res, 'pick objects')
if (!res) {
this.viewer.selectObjects([])
return
}
return {
hit: this.pickViewableHit(res.objects),
objects: res.objects
}
}
public loadObjects = async (objects: object[]) => {
await this.viewer.unloadAll()
const stringifiedObject = JSON.stringify(objects)
const loader = new SpeckleOfflineLoader(this.viewer.getWorldTree(), stringifiedObject)
await this.viewer.loadObject(loader, true)
Tracker.dataLoaded()
}
private handlePing = (message: string) => {
console.log(message)
}
private pickViewableHit(hits: Hit[]): Hit | null {
// let hit = null
// if (this.filteringState.isolatedObjects) {
// // Find the first hit contained in the isolated objects
// hit = hits.find((hit) => {
// const hitId = hit.object.id as string
// return this.filteringState.isolatedObjects.includes(hitId)
// })
// }
const hit = hits.find((h) => this.filteringState.isolatedObjects.includes(h.guid))
return hit
}
public dispose() {
this.viewer.getExtension(CameraController).dispose()
this.viewer.dispose()
this.viewer = null
}
}
const createViewer = async (parent: HTMLElement): Promise<LegacyViewer> => {
const viewerSettings = DefaultViewerParams
viewerSettings.showStats = false
viewerSettings.verbose = false
const viewer = new LegacyViewer(parent, viewerSettings)
await viewer.init()
console.log('🎥 Viewer is created!')
return viewer
}
@@ -1,6 +1,6 @@
import { formattingSettings as fs } from 'powerbi-visuals-utils-formattingmodel'
export class CameraSettings extends fs.Card {
export class CameraSettings extends fs.SimpleCard {
public defaultView: fs.SimpleSlice = new fs.AutoDropdown({
name: 'defaultView',
displayName: 'Default View',
@@ -10,12 +10,11 @@ export enum ContextOption {
ghosted = 'ghosted',
show = 'show'
}
export class ColorSettings extends fs.Card {
export class ColorSettings extends fs.SimpleCard {
public enabled = new fs.ToggleSwitch({
name: 'enabled',
displayName: 'Enabled',
value: true,
topLevelToggle: true
value: true
})
public fill = new fs.ColorPicker({
@@ -44,7 +43,7 @@ export class ColorSettings extends fs.Card {
slices: fs.Slice[] = [this.context, this.fill]
}
export class ColorSelectorSettings extends fs.Card {
export class ColorSelectorSettings extends fs.SimpleCard {
name = 'colorSelector'
displayName = 'Color Selector'
slices = []
@@ -2,15 +2,14 @@ import { formattingSettings as fs } from 'powerbi-visuals-utils-formattingmodel'
import ValidatorType = powerbi.visuals.ValidatorType
import { SunLightConfiguration } from '@speckle/viewer'
export class LightingSettings extends fs.Card {
export class LightingSettings extends fs.SimpleCard {
name = 'lighting'
displayName = 'Lighting'
public enabled = new fs.ToggleSwitch({
name: 'enabled',
displayName: 'Enabled',
value: true,
topLevelToggle: true
value: true
})
public intensity = new fs.Slider({
-41
View File
@@ -1,41 +0,0 @@
import { createStore } from 'vuex'
import { SpeckleDataInput } from 'src/types'
import { SpeckleVisualSettingsModel } from 'src/settings/visualSettingsModel'
export type InputState = 'valid' | 'incomplete' | 'invalid'
export interface SpeckleVisualState {
input?: SpeckleDataInput
status: InputState
settings: SpeckleVisualSettingsModel
}
// Create a new store instance.
export const store = createStore<SpeckleVisualState>({
state() {
return {
input: null,
status: 'incomplete',
settings: null
}
},
mutations: {
setInput(state, input?: SpeckleDataInput) {
state.input = input
},
setStatus(state, status: InputState) {
state.status = status ?? 'invalid'
},
setSettings(state, settings: SpeckleVisualSettingsModel) {
state.settings = settings
},
clearInput(state) {
state.input = null
}
},
actions: {
update(context, status: InputState, input?: SpeckleDataInput) {
context.commit('setInput', input)
context.commit('setStatus', status)
}
}
})
+162
View File
@@ -0,0 +1,162 @@
import { IViewerEvents } from '@src/plugins/viewer'
import { SpeckleDataInput } from '@src/types'
import { UserInfo } from '@src/utils/matrixViewUtils'
import { defineStore } from 'pinia'
import { ref, shallowRef } from 'vue'
export type InputState = 'valid' | 'incomplete' | 'invalid'
export type FieldInputState = {
rootObjectId: boolean
objectIds: boolean
colorBy: boolean
tooltipData: boolean
}
export const useVisualStore = defineStore('visualStore', () => {
const host = shallowRef<powerbi.extensibility.visual.IVisualHost>()
const objectsFromStore = ref<object[]>(undefined)
const isViewerInitialized = ref<boolean>(false)
const isViewerReadyToLoad = ref<boolean>(false)
const isViewerObjectsLoaded = ref<boolean>(false)
const viewerReloadNeeded = ref<boolean>(false)
const userInfo = ref<UserInfo>(undefined)
const fieldInputState = ref<FieldInputState>({
rootObjectId: false,
objectIds: false,
colorBy: false,
tooltipData: false
})
const lastLoadedRootObjectId = ref<string>()
// callback mechanism to viewer to be able to manage input data accordingly.
// Note: storing whole viewer in store is not make sense and also pinia ts complains about it for serialization issues.
// Error was and you can not/should not compress: 👇
// `The inferred type of this node exceeds the maximum length the compiler will serialize. An explicit type annotation is needed.ts(7056)`
const viewerEmit =
ref<
<E extends keyof IViewerEvents>(event: E, ...payload: Parameters<IViewerEvents[E]>) => void
>()
// TODO: investigate about shallow ref? https://vuejs.org/api/reactivity-advanced.html#shallowref
const dataInput = shallowRef<SpeckleDataInput | null>()
const dataInputStatus = ref<InputState>('incomplete')
/**
* Ideally one time setup on initialization.
* @param hostToSet interaction layer with powerbi host. it is useful when you wanna trigger `launchUrl` kind functions. TODO: need more understanding.
*/
const setHost = (hostToSet: powerbi.extensibility.visual.IVisualHost) => {
host.value = hostToSet
}
const setUserInfo = (newUserInfo: UserInfo) => (userInfo.value = newUserInfo)
/**
* Ideally one time set when onMounted of `ViewerWrapper.vue` component
* @param emit picky emit function to trigger events under `IViewerEvents` interface
*/
const setViewerEmitter = (
emit: <E extends keyof IViewerEvents>(
event: E,
...payload: Parameters<IViewerEvents[E]>
) => void
) => {
if (emit) {
viewerEmit.value = emit
viewerEmit.value('ping', '✅ Emitter successfully attached to the store.')
isViewerInitialized.value = true // this is needed to be delay first load at the visual.ts file
}
}
const setObjectsFromStore = (newObjectsFromStore: object[]) => {
objectsFromStore.value = newObjectsFromStore
}
// MAKE TS HAPPY
type SpeckleObject = {
id: string
}
const loadObjectsFromFile = async (objects: object[]) => {
lastLoadedRootObjectId.value = (objects[0] as SpeckleObject).id
viewerReloadNeeded.value = false
console.log(`📦 Loading viewer from cached data with ${lastLoadedRootObjectId.value} id.`)
await viewerEmit.value('loadObjects', objects)
objectsFromStore.value = objects
isViewerObjectsLoaded.value = true
}
/**
* Sets upcoming data input into store to be able to pass it through viewer by evaluating the data.
* @param newValue new data input that user dragged and dropped to the fields in visual
*/
const setDataInput = async (newValue: SpeckleDataInput) => {
dataInput.value = newValue
if (viewerReloadNeeded.value) {
lastLoadedRootObjectId.value = (dataInput.value.objects[0] as SpeckleObject).id
console.log(`🔄 Forcing viewer re-render for new root object id.`)
await viewerEmit.value('loadObjects', dataInput.value.objects)
viewerReloadNeeded.value = false
writeObjectsToFile(dataInput.value.objects)
}
if (dataInput.value.selectedIds.length > 0) {
viewerEmit.value('isolateObjects', dataInput.value.selectedIds)
} else {
viewerEmit.value('isolateObjects', dataInput.value.objectIds)
}
viewerEmit.value('colorObjectsByGroup', dataInput.value.colorByIds)
}
const writeObjectsToFile = (objects: object[]) => {
host.value.persistProperties({
merge: [
{
objectName: 'storedData',
properties: {
speckleObjects: JSON.stringify(objects),
userInfo: JSON.stringify(userInfo.value)
},
selector: null
}
]
})
}
const setFieldInputState = (newFieldInputState: FieldInputState) =>
(fieldInputState.value = newFieldInputState)
const clearDataInput = () => (dataInput.value = null)
const setViewerReadyToLoad = () => (isViewerReadyToLoad.value = true)
const setViewerReloadNeeded = () => (viewerReloadNeeded.value = true)
return {
host,
userInfo,
objectsFromStore,
isViewerInitialized,
isViewerReadyToLoad,
isViewerObjectsLoaded,
viewerReloadNeeded,
dataInput,
dataInputStatus,
viewerEmit,
fieldInputState,
lastLoadedRootObjectId,
loadObjectsFromFile,
setHost,
setUserInfo,
setViewerReloadNeeded,
setObjectsFromStore,
writeObjectsToFile,
setViewerEmitter,
setDataInput,
setFieldInputState,
clearDataInput,
setViewerReadyToLoad
}
})
+2 -1
View File
@@ -10,10 +10,11 @@ export interface IViewerTooltip {
}
export interface SpeckleDataInput {
objectsToLoad: string[]
objects: object[]
objectIds: string[]
selectedIds: string[]
colorByIds: { objectIds: string[]; slice: fs.ColorPicker; color: string }[]
objectTooltipData: Map<string, IViewerTooltip>
view: powerbi.DataViewMatrix
isFromStore: boolean
}
@@ -1,7 +0,0 @@
import { currentOS, OS } from './detectOS'
export function isMultiSelect(e: MouseEvent) {
if (!e) return false
if (currentOS === OS.MacOS) return e.metaKey || e.shiftKey
else return e.ctrlKey || e.shiftKey
}
+107 -96
View File
@@ -7,34 +7,35 @@ import {
} from 'powerbi-visuals-utils-dataviewutils/lib/dataViewWildcard'
import VisualUpdateOptions = powerbi.extensibility.visual.VisualUpdateOptions
import { SpeckleVisualSettingsModel } from 'src/settings/visualSettingsModel'
import { FieldInputState, useVisualStore } from '@src/store/visualStore'
export function validateMatrixView(options: VisualUpdateOptions): {
hasColorFilter: boolean
view: powerbi.DataViewMatrix
} {
export function validateMatrixView(options: VisualUpdateOptions): FieldInputState {
const matrixVew = options.dataViews[0].matrix
if (!matrixVew) throw new Error('Data does not contain a matrix data view')
let hasStream = false,
hasParentObject = false,
hasObject = false,
hasColorFilter = false
let hasRootObjectId = false,
hasObjectIds = false,
hasColorFilter = false,
hasTooltipData = false
matrixVew.rows.levels.forEach((level) => {
level.sources.forEach((source) => {
if (!hasStream) hasStream = source.roles['stream'] != undefined
if (!hasParentObject) hasParentObject = source.roles['parentObject'] != undefined
if (!hasObject) hasObject = source.roles['object'] != undefined
if (!hasRootObjectId) hasRootObjectId = source.roles['rootObjectId'] != undefined
if (!hasObjectIds) hasObjectIds = source.roles['objectIds'] != undefined
if (!hasColorFilter) hasColorFilter = source.roles['objectColorBy'] != undefined
})
})
if (!hasStream) throw new Error('Missing Stream ID input')
if (!hasParentObject) throw new Error('Missing Commit Object ID input')
if (!hasObject) throw new Error('Missing Object Id input')
matrixVew.columns.levels.forEach((level) => {
level.sources.forEach((source) => {
if (!hasTooltipData) hasTooltipData = source.roles['tooltipData'] != undefined
})
})
return {
hasColorFilter,
view: matrixVew
rootObjectId: hasRootObjectId,
objectIds: hasObjectIds,
colorBy: hasColorFilter,
tooltipData: hasTooltipData
}
}
@@ -109,9 +110,7 @@ function processObjectIdLevel(
host: powerbi.extensibility.visual.IVisualHost,
matrixView: powerbi.DataViewMatrix
) {
return parentObjectIdChild.children?.map((objectIdChild) =>
processObjectNode(objectIdChild, host, matrixView)
)
return processObjectNode(parentObjectIdChild, host, matrixView)
}
export let previousPalette = null
@@ -119,105 +118,117 @@ export let previousPalette = null
export function resetPalette() {
previousPalette = null
}
export function processMatrixView(
export type UserInfo = {
userEmail: string
serverUrl: string
}
export async function processMatrixView(
matrixView: powerbi.DataViewMatrix,
host: powerbi.extensibility.visual.IVisualHost,
hasColorFilter: boolean,
settings: SpeckleVisualSettingsModel,
onSelectionPair: (objId: string, selectionId: powerbi.extensibility.ISelectionId) => void
): SpeckleDataInput {
const objectUrlsToLoad = [],
objectIds = [],
): Promise<SpeckleDataInput> {
const visualStore = useVisualStore()
const objectIds = [],
selectedIds = [],
colorByIds = [],
objectTooltipData = new Map<string, IViewerTooltip>()
matrixView.rows.root.children.forEach((streamUrlChild) => {
const url = streamUrlChild.value
console.log('🪜 Processing Matrix View', matrixView)
streamUrlChild.children?.forEach((parentObjectIdChild) => {
const parentId = parentObjectIdChild.value
objectUrlsToLoad.push(`${url}/objects/${parentId}`)
const localMatrixView = matrixView.rows.root.children[0]
const id = localMatrixView.value as unknown as string
console.log('🗝️ Root Object Id: ', id)
console.log('Last laoded root object id', visualStore.lastLoadedRootObjectId)
if (!hasColorFilter) {
processObjectIdLevel(parentObjectIdChild, host, matrixView).forEach((objRes) => {
objectIds.push(objRes.id)
onSelectionPair(objRes.id, objRes.selectionId)
if (objRes.shouldSelect) selectedIds.push(objRes.id)
if (objRes.color) {
let group = colorByIds.find((g) => g.color === objRes.color)
if (!group) {
group = {
color: objRes.color,
objectIds: []
}
colorByIds.push(group)
}
group.objectIds.push(objRes.id)
}
objectTooltipData.set(objRes.id, {
selectionId: objRes.selectionId,
data: objRes.data
})
})
} else {
if (previousPalette) host.colorPalette['colorPalette'] = previousPalette
parentObjectIdChild.children?.forEach((colorByChild) => {
const colorSelectionId = host
.createSelectionIdBuilder()
.withMatrixNode(colorByChild, matrixView.rows.levels)
.createSelectionId()
type Data = {
userInfo: UserInfo
objects: object[]
}
const color = host.colorPalette.getColor(colorByChild.value as string)
if (colorByChild.objects) {
console.log(
'⚠️COLOR NODE HAS objects',
colorByChild.objects,
colorByChild.objects.color?.fill
)
}
let objects: object[] = undefined
if (visualStore.lastLoadedRootObjectId !== id) {
try {
const res = await fetch(`http://localhost:49161/get-data/${id}`)
const data = (await res.json()) as unknown as Data
objects = data.objects
visualStore.setUserInfo(data.userInfo)
visualStore.setViewerReloadNeeded() // they should be marked as deferred action bc of update function complexity.
} catch (error) {
// TODO: global toast notification to throw message for local server (manager)
console.log("Objects couldn't retrieved from local server.")
}
}
const colorSlice = new fs.ColorPicker({
name: 'selectorFill',
displayName: colorByChild.value.toString(),
value: {
value: color.value
},
selector: colorSelectionId.getSelector()
})
// NOTE: matrix view gave us already filtered out rows from tooltip data if it is assigned
localMatrixView.children?.forEach((obj) => {
// otherwise there is no point to collect objects
const processedObjectIdLevels = processObjectIdLevel(obj, host, matrixView)
const colorGroup = {
color: color.value,
slice: colorSlice,
objectIds: []
}
processObjectIdLevel(colorByChild, host, matrixView).forEach((objRes) => {
objectIds.push(objRes.id)
onSelectionPair(objRes.id, objRes.selectionId)
if (objRes.shouldSelect) selectedIds.push(objRes.id)
if (objRes.shouldColor) {
colorGroup.objectIds.push(objRes.id)
}
objectTooltipData.set(objRes.id, {
selectionId: objRes.selectionId,
data: objRes.data
})
})
if (colorGroup.objectIds.length > 0) colorByIds.push(colorGroup)
})
}
objectIds.push(processedObjectIdLevels.id)
onSelectionPair(processedObjectIdLevels.id, processedObjectIdLevels.selectionId)
if (processedObjectIdLevels.shouldSelect) {
selectedIds.push(processedObjectIdLevels.id)
}
objectTooltipData.set(processedObjectIdLevels.id, {
selectionId: processedObjectIdLevels.selectionId,
data: processedObjectIdLevels.data
})
if (hasColorFilter) {
obj.children.forEach((child) => {
const colorSelectionId = host
.createSelectionIdBuilder()
.withMatrixNode(child, matrixView.rows.levels)
.createSelectionId()
const color = host.colorPalette.getColor(child.value as string)
const colorSlice = new fs.ColorPicker({
name: 'selectorFill',
displayName: child.value?.toString(),
value: {
value: color.value
},
selector: colorSelectionId.getSelector()
})
const colorGroup = {
color: color.value,
slice: colorSlice,
objectIds: []
}
const processedObjectIdLevels = processObjectIdLevel(child, host, matrixView)
objectIds.push(processedObjectIdLevels.id)
onSelectionPair(processedObjectIdLevels.id, processedObjectIdLevels.selectionId)
if (processedObjectIdLevels.shouldSelect) selectedIds.push(processedObjectIdLevels.id)
if (processedObjectIdLevels.shouldColor) {
colorGroup.objectIds.push(processedObjectIdLevels.id)
}
objectTooltipData.set(processedObjectIdLevels.id, {
selectionId: processedObjectIdLevels.selectionId,
data: processedObjectIdLevels.data
})
if (colorGroup.objectIds.length > 0) colorByIds.push(colorGroup)
})
}
})
previousPalette = host.colorPalette['colorPalette']
return {
objectsToLoad: objectUrlsToLoad,
objects,
objectIds,
selectedIds,
colorByIds: colorByIds.length > 0 ? colorByIds : null,
objectTooltipData,
view: matrixView
view: matrixView,
isFromStore: false
}
}
+181
View File
@@ -0,0 +1,181 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Lightweight MD5 implementation.
* @see http://www.myersdaily.org/joseph/javascript/md5-text.html
*/
function md5cycle(x: any, k: any) {
let a = x[0],
b = x[1],
c = x[2],
d = x[3]
a = ff(a, b, c, d, k[0], 7, -680876936)
d = ff(d, a, b, c, k[1], 12, -389564586)
c = ff(c, d, a, b, k[2], 17, 606105819)
b = ff(b, c, d, a, k[3], 22, -1044525330)
a = ff(a, b, c, d, k[4], 7, -176418897)
d = ff(d, a, b, c, k[5], 12, 1200080426)
c = ff(c, d, a, b, k[6], 17, -1473231341)
b = ff(b, c, d, a, k[7], 22, -45705983)
a = ff(a, b, c, d, k[8], 7, 1770035416)
d = ff(d, a, b, c, k[9], 12, -1958414417)
c = ff(c, d, a, b, k[10], 17, -42063)
b = ff(b, c, d, a, k[11], 22, -1990404162)
a = ff(a, b, c, d, k[12], 7, 1804603682)
d = ff(d, a, b, c, k[13], 12, -40341101)
c = ff(c, d, a, b, k[14], 17, -1502002290)
b = ff(b, c, d, a, k[15], 22, 1236535329)
a = gg(a, b, c, d, k[1], 5, -165796510)
d = gg(d, a, b, c, k[6], 9, -1069501632)
c = gg(c, d, a, b, k[11], 14, 643717713)
b = gg(b, c, d, a, k[0], 20, -373897302)
a = gg(a, b, c, d, k[5], 5, -701558691)
d = gg(d, a, b, c, k[10], 9, 38016083)
c = gg(c, d, a, b, k[15], 14, -660478335)
b = gg(b, c, d, a, k[4], 20, -405537848)
a = gg(a, b, c, d, k[9], 5, 568446438)
d = gg(d, a, b, c, k[14], 9, -1019803690)
c = gg(c, d, a, b, k[3], 14, -187363961)
b = gg(b, c, d, a, k[8], 20, 1163531501)
a = gg(a, b, c, d, k[13], 5, -1444681467)
d = gg(d, a, b, c, k[2], 9, -51403784)
c = gg(c, d, a, b, k[7], 14, 1735328473)
b = gg(b, c, d, a, k[12], 20, -1926607734)
a = hh(a, b, c, d, k[5], 4, -378558)
d = hh(d, a, b, c, k[8], 11, -2022574463)
c = hh(c, d, a, b, k[11], 16, 1839030562)
b = hh(b, c, d, a, k[14], 23, -35309556)
a = hh(a, b, c, d, k[1], 4, -1530992060)
d = hh(d, a, b, c, k[4], 11, 1272893353)
c = hh(c, d, a, b, k[7], 16, -155497632)
b = hh(b, c, d, a, k[10], 23, -1094730640)
a = hh(a, b, c, d, k[13], 4, 681279174)
d = hh(d, a, b, c, k[0], 11, -358537222)
c = hh(c, d, a, b, k[3], 16, -722521979)
b = hh(b, c, d, a, k[6], 23, 76029189)
a = hh(a, b, c, d, k[9], 4, -640364487)
d = hh(d, a, b, c, k[12], 11, -421815835)
c = hh(c, d, a, b, k[15], 16, 530742520)
b = hh(b, c, d, a, k[2], 23, -995338651)
a = ii(a, b, c, d, k[0], 6, -198630844)
d = ii(d, a, b, c, k[7], 10, 1126891415)
c = ii(c, d, a, b, k[14], 15, -1416354905)
b = ii(b, c, d, a, k[5], 21, -57434055)
a = ii(a, b, c, d, k[12], 6, 1700485571)
d = ii(d, a, b, c, k[3], 10, -1894986606)
c = ii(c, d, a, b, k[10], 15, -1051523)
b = ii(b, c, d, a, k[1], 21, -2054922799)
a = ii(a, b, c, d, k[8], 6, 1873313359)
d = ii(d, a, b, c, k[15], 10, -30611744)
c = ii(c, d, a, b, k[6], 15, -1560198380)
b = ii(b, c, d, a, k[13], 21, 1309151649)
a = ii(a, b, c, d, k[4], 6, -145523070)
d = ii(d, a, b, c, k[11], 10, -1120210379)
c = ii(c, d, a, b, k[2], 15, 718787259)
b = ii(b, c, d, a, k[9], 21, -343485551)
x[0] = add32(a, x[0])
x[1] = add32(b, x[1])
x[2] = add32(c, x[2])
x[3] = add32(d, x[3])
}
function cmn(q: any, a: any, b: any, x: any, s: any, t: any) {
a = add32(add32(a, q), add32(x, t))
return add32((a << s) | (a >>> (32 - s)), b)
}
function ff(a: any, b: any, c: any, d: any, x: any, s: any, t: any) {
return cmn((b & c) | (~b & d), a, b, x, s, t)
}
function gg(a: any, b: any, c: any, d: any, x: any, s: any, t: any) {
return cmn((b & d) | (c & ~d), a, b, x, s, t)
}
function hh(a: any, b: any, c: any, d: any, x: any, s: any, t: any) {
return cmn(b ^ c ^ d, a, b, x, s, t)
}
function ii(a: any, b: any, c: any, d: any, x: any, s: any, t: any) {
return cmn(c ^ (b | ~d), a, b, x, s, t)
}
function md51(s: any) {
const n = s.length,
state = [1732584193, -271733879, -1732584194, 271733878]
let i
for (i = 64; i <= s.length; i += 64) {
md5cycle(state, md5blk(s.substring(i - 64, i)))
}
s = s.substring(i - 64)
const tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
for (i = 0; i < s.length; i++) tail[i >> 2] |= s.charCodeAt(i) << (i % 4 << 3)
tail[i >> 2] |= 0x80 << (i % 4 << 3)
if (i > 55) {
md5cycle(state, tail)
for (i = 0; i < 16; i++) tail[i] = 0
}
tail[14] = n * 8
md5cycle(state, tail)
return state
}
function md5blk(s: any) {
/* I figured global was faster. */
const md5blks = []
let i
for (i = 0; i < 64; i += 4) {
md5blks[i >> 2] =
s.charCodeAt(i) +
(s.charCodeAt(i + 1) << 8) +
(s.charCodeAt(i + 2) << 16) +
(s.charCodeAt(i + 3) << 24)
}
return md5blks
}
const HEX_CHR = '0123456789abcdef'.split('')
function rhex(n: any) {
let s = '',
j = 0
for (; j < 4; j++) s += HEX_CHR[(n >> (j * 8 + 4)) & 0x0f] + HEX_CHR[(n >> (j * 8)) & 0x0f]
return s
}
function hex(x: any) {
for (let i = 0; i < x.length; i++) x[i] = rhex(x[i])
return x.join('')
}
let add32 = (a: any, b: any) => {
return (a + b) & 0xffffffff
}
/**
* Generate an MD5 hash for an input string
* @param {string} s input string
* @returns {string} md5 hash
*/
export function md5(s: string): string {
return hex(md51(s))
}
if (md5('hello') !== '5d41402abc4b2a76b9719d911017c592') {
add32 = (x, y) => {
const lsw = (x & 0xffff) + (y & 0xffff),
msw = (x >> 16) + (y >> 16) + (lsw >> 16)
return (msw << 16) | (lsw & 0xffff)
}
}
+31 -20
View File
@@ -1,12 +1,13 @@
// TBD NOTE: if we decide to tackle certification on visual and deploy it on microsoft marketplace, we would need to remove this logic
// since we enable webaccess privile for the sake of mixpanel for now.
import { useVisualStore } from '@src/store/visualStore'
import { md5 } from './md5'
const TRACK_URL = 'https://analytics.speckle.systems/track?ip=1'
const MIXPANEL_TOKEN = 'acd87c5a50b56df91a795e999812a3a4'
const HOST_APP_NAME = 'powerbi-visual'
export enum Event {
Create = 'Create',
Reload = 'Reload',
Settings = 'Settings'
}
const IS_OFFLINE_SUPPORT = true
export enum SettingsChangedType {
Gradient = 'Gradient',
@@ -15,16 +16,32 @@ export enum SettingsChangedType {
}
export class Tracker {
public static async track(event: Event, properties: any = {}) {
public static async track(event: string, properties: any = {}) {
const visualStore = useVisualStore()
const userInfo = visualStore.userInfo
let tempProperties = properties
if (userInfo) {
const hashedEmail = '@' + md5(userInfo.userEmail.toLowerCase() as string).toUpperCase()
const hashedServer = md5(
new URL(userInfo.serverUrl).hostname.toLowerCase() as string
).toUpperCase()
tempProperties = {
...tempProperties, // eslint-disable-next-line camelcase
distinct_id: hashedEmail,
// eslint-disable-next-line camelcase
server_id: hashedServer
}
}
return this.trackEvents([
{
event,
properties
properties: tempProperties
}
])
}
private static async trackEvents(events: Array<{ event: Event; properties: any }>) {
private static async trackEvents(events: Array<{ event: string; properties: any }>) {
try {
await fetch(TRACK_URL, {
method: 'POST',
@@ -34,7 +51,9 @@ export class Tracker {
events.map((e) => {
Object.assign(e.properties, {
token: MIXPANEL_TOKEN,
hostApp: HOST_APP_NAME
hostApp: HOST_APP_NAME,
offlineSupport: IS_OFFLINE_SUPPORT,
type: 'action'
})
return e
})
@@ -45,15 +64,7 @@ export class Tracker {
}
}
public static loaded() {
return this.track(Event.Create)
}
public static dataReload() {
return this.track(Event.Reload)
}
public static settingsChanged(type: SettingsChangedType) {
return this.track(Event.Settings, { type })
public static dataLoaded() {
return this.track('PowerBI Action')
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { FilteringState } from '@speckle/viewer'
import { OrthographicCamera, PerspectiveCamera } from 'three'
export function projectToScreen(cam, loc) {
export function projectToScreen(cam: OrthographicCamera | PerspectiveCamera, loc) {
cam.updateProjectionMatrix()
const copy = loc.clone()
copy.project(cam)
+23 -19
View File
@@ -1,19 +1,3 @@
<script setup lang="ts">
import { FormButton } from '@speckle/ui-components'
import { inject } from 'vue'
import { hostKey } from 'src/injectionKeys'
const host = inject(hostKey)
function goToForum() {
host.launchUrl('https://speckle.community/tag/powerbi')
}
function goToGuide() {
host.launchUrl('https://speckle.guide/user/powerbi')
}
</script>
<template>
<div
id="speckle-home-view"
@@ -24,10 +8,30 @@ function goToGuide() {
</div>
<p class="heading">Speckle PowerBI 3D Visual</p>
<div class="flex justify-center mt-2 gap-1">
<FormButton color="invert" @click="goToForum">Help</FormButton>
<FormButton color="invert" @click="goToGuide">Getting started</FormButton>
<button :class="buttonClass" @click="goToForum">Help</button>
<button :class="buttonClass" @click="goToGuide">Getting started</button>
<!-- TODO: dependency issue need to be resolved to be able to use ui-components library-->
<!-- <FormButton color="subtle" @click="goToForum">Help</FormButton>
<FormButton color="subtle" @click="goToGuide">Getting started</FormButton> -->
</div>
<!-- <CommonLoadingBar :loading="true"/> -->
</div>
</template>
<style scoped></style>
<script setup lang="ts">
// import { FormButton } from '@speckle/ui-components'
// import { CommonLoadingBar } from '@speckle/ui-components';
import { useVisualStore } from '../store/visualStore'
const visualStore = useVisualStore()
const buttonClass = `btn p-2 rounded-md bg-transparent border-transparent text-white font-medium hover:bg-primary-muted hover:text-foreground disabled:hover:bg-transparent focus-visible:border-foundation`
function goToForum() {
visualStore.host.launchUrl('https://speckle.community/tag/powerbi')
}
function goToGuide() {
visualStore.host.launchUrl('https://speckle.guide/user/powerbi')
}
</script>
+44 -28
View File
@@ -1,35 +1,51 @@
<script setup lang="ts">
import ViewerWrapper from 'src/components/ViewerWrapper.vue'
import { inject } from 'vue'
import { hostKey } from 'src/injectionKeys'
const host = inject(hostKey)
const goToSpeckleWebsite = () => host.launchUrl('https://speckle.systems')
</script>
<template>
<div id="overlay">
<img class="watermark" src="@assets/powered-by-speckle.png" @click="goToSpeckleWebsite" />
<div class="absolute top-0 left-0 z-10" @click="goToSpeckleWebsite">
<img class="w-16 h-auto mt-1 mr-1 cursor-pointer" src="@assets/powered-by-speckle.png" />
</div>
<div
v-if="isInteractive"
class="absolute top-2 left-1/2 -translate-x-1/2 z-20 bg-white bg-opacity-70 text-black text-center text-sm px-4 py-2 rounded shadow"
>
<div v-if="bothFieldsMissing">
<strong>Object IDs</strong>
and
<strong>Tooltip Data</strong>
fields are needed for interactivity.
</div>
<div v-else-if="onlyObjectIdsMissing">
<strong>Object IDs</strong>
field is needed for interactivity.
</div>
<div v-else-if="onlyTooltipDataMissing">
<strong>Tooltip Data</strong>
field is needed for interactivity.
</div>
</div>
<viewer-wrapper id="speckle-3d-view" class="h-full w-full"></viewer-wrapper>
</template>
<style scoped>
.watermark {
height: auto;
width: 60pt;
margin-top: 3pt;
margin-right: 3pt;
}
<script setup lang="ts">
import ViewerWrapper from 'src/components/ViewerWrapper.vue'
import { useVisualStore } from '../store/visualStore'
import { computed } from 'vue'
.watermark:hover {
cursor: pointer;
}
const visualStore = useVisualStore()
#overlay {
position: absolute;
width: 100%;
z-index: 100;
}
</style>
const onlyObjectIdsMissing = computed(
() => !visualStore.fieldInputState.objectIds && visualStore.fieldInputState.tooltipData
)
const onlyTooltipDataMissing = computed(
() => visualStore.fieldInputState.objectIds && !visualStore.fieldInputState.tooltipData
)
const bothFieldsMissing = computed(
() => !visualStore.fieldInputState.objectIds && !visualStore.fieldInputState.tooltipData
)
const isInteractive = computed(
() => !visualStore.fieldInputState.objectIds || !visualStore.fieldInputState.tooltipData
)
const goToSpeckleWebsite = () => visualStore.host.launchUrl('https://speckle.systems')
</script>
+99 -43
View File
@@ -1,16 +1,15 @@
import 'core-js/stable'
import 'regenerator-runtime/runtime'
import '../style/visual.css'
import * as _ from 'lodash'
import { FormattingSettingsService } from 'powerbi-visuals-utils-formattingmodel'
import { createApp } from 'vue'
import App from './App.vue'
import { store } from 'src/store'
import { hostKey, selectionHandlerKey, tooltipHandlerKey, storeKey } from 'src/injectionKeys'
// import { store } from 'src/store'
import { selectionHandlerKey, tooltipHandlerKey } from 'src/injectionKeys'
import { Tracker } from './utils/mixpanel'
import { SpeckleDataInput } from './types'
import { processMatrixView, validateMatrixView } from './utils/matrixViewUtils'
import { processMatrixView, UserInfo, validateMatrixView } from './utils/matrixViewUtils'
import { SpeckleVisualSettingsModel } from './settings/visualSettingsModel'
import TooltipHandler from './handlers/tooltipHandler'
@@ -26,18 +25,23 @@ import {
} from 'powerbi-visuals-utils-dataviewutils/lib/dataViewWildcard'
import { ColorSelectorSettings } from 'src/settings/colorSettings'
import { pinia } from './plugins/pinia'
import { FieldInputState, useVisualStore } from './store/visualStore'
// noinspection JSUnusedGlobalSymbols
export class Visual implements IVisual {
private readonly host: powerbi.extensibility.visual.IVisualHost
private selectionHandler: SelectionHandler
private tooltipHandler: TooltipHandler
private isFirstViewerLoad: boolean
private formattingSettings: SpeckleVisualSettingsModel
private formattingSettingsService: FormattingSettingsService
// noinspection JSUnusedGlobalSymbols
public constructor(options: VisualConstructorOptions) {
Tracker.loaded()
this.isFirstViewerLoad = true
// Tracker.loaded()
this.host = options.host
this.formattingSettingsService = new FormattingSettingsService()
@@ -47,66 +51,99 @@ export class Visual implements IVisual {
console.log('🚀 Init Vue App')
createApp(App)
.use(store, storeKey)
.use(pinia)
// .use(store, storeKey)
.provide(selectionHandlerKey, this.selectionHandler)
.provide(tooltipHandlerKey, this.tooltipHandler)
.provide(hostKey, options.host)
.mount(options.element)
// set `host` to visual store to be able use later in other components if needed
const visualStore = useVisualStore()
visualStore.setHost(this.host)
this.host.refreshHostData() // to be able to trigger `update` function after constructor! by this way i was able to trigger viewer load objects from properties store
}
private async clear() {
this.selectionHandler.clear()
}
public update(options: VisualUpdateOptions) {
public async update(options: VisualUpdateOptions) {
const visualStore = useVisualStore()
// @ts-ignore
console.log('⤴️ Update type 👉', powerbi.VisualUpdateType[options.type])
this.formattingSettings = this.formattingSettingsService.populateFormattingSettingsModel(
SpeckleVisualSettingsModel,
options.dataViews
options.dataViews[0]
)
console.log('Selector colors', this.formattingSettings.colorSelector)
let validationResult: { hasColorFilter: boolean; view: powerbi.DataViewMatrix } = null
try {
console.log('🔍 Validating input...', options)
validationResult = validateMatrixView(options)
console.log('✅Input valid', validationResult)
const matrixView = options.dataViews[0].matrix
if (!matrixView) throw new Error('Data does not contain a matrix data view') // TODO: Could be toast notificiation too!
// we first need to check which inputs user provided to decide our strategy
const validationResult = validateMatrixView(options)
visualStore.setFieldInputState(validationResult)
console.log('❓Field inputs', validationResult)
switch (options.type) {
case powerbi.VisualUpdateType.Resize:
case powerbi.VisualUpdateType.ResizeEnd:
case powerbi.VisualUpdateType.Style:
case powerbi.VisualUpdateType.ViewMode:
case powerbi.VisualUpdateType.Resize + powerbi.VisualUpdateType.ResizeEnd:
return
case powerbi.VisualUpdateType.Data:
try {
// read saved data from file if any
if (this.isFirstViewerLoad && options.dataViews[0].metadata.objects) {
const objectsFromFile = JSON.parse(
options.dataViews[0].metadata.objects.storedData?.speckleObjects as string
)
const userInfoFromFile = JSON.parse(
options.dataViews[0].metadata.objects.storedData?.userInfo as string
) as UserInfo
visualStore.setUserInfo(userInfoFromFile)
if (visualStore.lastLoadedRootObjectId !== objectsFromFile[0].id) {
this.tryReadFromFile(objectsFromFile, visualStore)
}
}
const input = await processMatrixView(
matrixView,
this.host,
validationResult.colorBy,
this.formattingSettings,
(obj, id) => this.selectionHandler.set(obj, id)
)
this.updateViewer(input)
} catch (error) {
console.error('Data update error', error ?? 'Unknown')
}
break
default:
return
}
} catch (e) {
console.log('❌Input not valid:', (e as Error).message)
this.host.displayWarningIcon(
`Incomplete data input.`,
`"Model URL", "Version Object ID" and "Object ID" data inputs are mandatory. If your data connector does not output all these columns, please update it.`
`"Viewer Data" and "Object IDs" data inputs are mandatory. If your data connector does not output all these columns, please update it.`
)
console.warn(
`Incomplete data input. "Model URL", "Version Object ID" and "Object ID" data inputs are mandatory. If your data connector does not output all these columns, please update it.`
`Incomplete data input. "Viewer Data", "Object IDs" data inputs are mandatory. If your data connector does not output all these columns, please update it.`
)
store.commit('setStatus', 'incomplete')
visualStore.setFieldInputState({
rootObjectId: false,
objectIds: false,
colorBy: false,
tooltipData: false
})
return
}
switch (options.type) {
case powerbi.VisualUpdateType.Resize:
case powerbi.VisualUpdateType.ResizeEnd:
case powerbi.VisualUpdateType.Style:
case powerbi.VisualUpdateType.ViewMode:
case powerbi.VisualUpdateType.Resize + powerbi.VisualUpdateType.ResizeEnd:
return
default:
try {
const input = processMatrixView(
validationResult.view,
this.host,
validationResult.hasColorFilter,
this.formattingSettings,
(obj, id) => this.selectionHandler.set(obj, id)
)
this.throttleUpdate(input)
} catch (error) {
console.error('Data update error', error ?? 'Unknown')
}
}
}
public getFormattingModel(): powerbi.visuals.FormattingModel {
console.log('Showing Formatting settings', this.formattingSettings)
const model = this.formattingSettingsService.buildFormattingModel(this.formattingSettings)
@@ -114,12 +151,31 @@ export class Visual implements IVisual {
return model
}
private throttleUpdate = _.throttle((input: SpeckleDataInput) => {
private updateViewer(input: SpeckleDataInput) {
const visualStore = useVisualStore()
this.tooltipHandler.setup(input.objectTooltipData)
store.commit('setInput', input)
store.commit('setStatus', 'valid')
store.commit('setSettings', this.formattingSettings)
}, 500)
visualStore.setViewerReadyToLoad()
if (visualStore.isViewerInitialized && !visualStore.viewerReloadNeeded) {
visualStore.setDataInput(input)
} else {
// we should give some time to Vue to render ViewerWrapper component to be able to have proper emitter setup. Happiness level 6/10
setTimeout(() => {
visualStore.setDataInput(input)
visualStore.writeObjectsToFile(input.objects)
}, 250)
}
}
private tryReadFromFile(objectsFromFile: object[], visualStore) {
visualStore.setViewerReadyToLoad()
setTimeout(() => {
visualStore.loadObjectsFromFile(objectsFromFile)
this.isFirstViewerLoad = false
}, 250)
console.log(`${objectsFromFile.length} objects retrieved from persistent properties!`)
}
public async destroy() {
await this.clear()
+1
View File
@@ -7,6 +7,7 @@
"sourceMap": true,
"outDir": "./.tmp/build/",
"moduleResolution": "node",
"skipLibCheck": true,
"declaration": true,
"lib": ["es2020", "dom"],
"allowSyntheticDefaultImports": true,
+269
View File
@@ -0,0 +1,269 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import path from 'path'
// api configuration
import powerbi from 'powerbi-visuals-api'
import ExtraWatchWebpackPlugin from 'extra-watch-webpack-plugin'
import { BundleAnalyzerPlugin as Visualizer } from 'webpack-bundle-analyzer'
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
import { PowerBICustomVisualsWebpackPlugin } from 'powerbi-visuals-webpack-plugin'
import webpack from 'webpack'
import fs from 'fs'
import { WebpackConfiguration } from 'webpack-cli'
import { VueLoaderPlugin } from 'vue-loader'
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin'
/**
* MAIN CONSTS
*/
const devServerPort = 8080
const pbivizPath = './pbiviz.json'
const capabilitiesPath = './capabilities.json'
const pluginLocation = './.tmp/precompile/visualPlugin.ts' // path to visual plugin file, the file generates by the plugin
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const powerbiApi: any = powerbi // Types for PowerBI seem to be off, so I'm instead forcing it to `any`
// visual configuration json path
const pbivizFile = require(path.join(__dirname, pbivizPath))
const packageJsonFile = require(path.join(__dirname, 'package.json'))
pbivizFile.visual.version = packageJsonFile.version
// the visual capabilities content
const capabilitiesFile = require(path.join(__dirname, capabilitiesPath))
// string resources
const resourcesFolder = path.join('.', 'stringResources')
const localizationFolders = fs.existsSync(resourcesFolder) && fs.readdirSync(resourcesFolder)
const statsLocation = '../../webpack.statistics.html'
// babel options to support IE11
const babelOptions = {
presets: [
[
'@babel/preset-env',
{
targets: {
ie: '11'
},
useBuiltIns: 'entry',
corejs: 3,
modules: false
}
]
],
plugins: [],
sourceType: 'unambiguous', // tell to babel that the project can contain different module types, not only es2015 modules
cacheDirectory: path.join('.tmp', 'babelCache') // path for cache files
}
export const buildConfig = (params: { mode: 'dev' | 'prod' }) => {
const isProd = params.mode === 'prod'
const loadCert = () => {
const keyPath = path.resolve(__dirname, 'localhost-key.pem')
const certPath = path.resolve(__dirname, 'localhost.pem')
if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) {
console.log('Unable to locate localhost certs, skipping...')
return undefined
}
console.log(
'Using locally generated localhost certs, make sure the CA cert is installed & trusted!'
)
return {
key: fs.readFileSync(keyPath),
cert: fs.readFileSync(certPath)
}
}
const certInfo = isProd ? undefined : loadCert()
const config: WebpackConfiguration = {
entry: {
visual: pluginLocation
},
optimization: {
concatenateModules: false,
minimize: isProd // enable minimization for create *.pbiviz file less than 2 Mb, can be disabled for dev mode
},
devtool: isProd ? false : 'inline-source-map',
mode: isProd ? 'production' : 'development',
module: {
rules: [
{
test: /\.vue$/,
use: ['vue-loader']
},
{
parser: {
amd: false
}
},
{
test: /(\.ts)x|\.ts$/,
use: [
{
loader: 'babel-loader',
options: {
presets: [
// '@babel/react',
'@babel/env'
]
}
},
{
loader: 'ts-loader',
options: {
transpileOnly: false,
experimentalWatchApi: false,
appendTsSuffixTo: [/\.vue$/]
}
}
],
exclude: [/node_modules/],
include: /.tmp|powerbi-visuals-|src|precompile\\visualPlugin.ts/
},
{
test: /(\.js)x|\.js$/,
use: [
{
loader: 'babel-loader',
options: babelOptions
}
],
exclude: [/node_modules/]
},
{
test: /\.json$/,
loader: 'json-loader',
type: 'javascript/auto'
},
{
test: /\.(css|scss)?$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']
},
{
test: /\.(woff|ttf|ico|woff2|jpg|jpeg|png|webp|svg)$/i,
use: ['base64-inline-loader']
}
]
},
resolve: {
extensions: ['.tsx', '.ts', '.jsx', '.js', '.css'],
alias: {
src: path.resolve(__dirname, 'src/'),
assets: path.resolve(__dirname, 'assets/')
},
plugins: [new TsconfigPathsPlugin()]
},
output: {
publicPath: '/assets',
path: path.join(__dirname, '/.tmp', 'drop'),
library: +powerbiApi.version.replace(/\./g, '') >= 320 ? pbivizFile.visual.guid : undefined,
libraryTarget: +powerbiApi.version.replace(/\./g, '') >= 320 ? 'var' : undefined
},
...(isProd
? {}
: {
devServer: {
static: {
directory: path.join(__dirname, '.tmp', 'drop'), // path with assets for dev server, they are generated by webpack plugin
publicPath: '/assets'
},
compress: true,
port: devServerPort, // dev server port
hot: false,
...(certInfo
? {
server: {
type: 'https',
options: {
...certInfo
}
}
}
: {
https: {}
}),
liveReload: false,
webSocketServer: false,
headers: {
'access-control-allow-origin': '*',
'cache-control': 'public, max-age=0'
}
}
}),
externals:
powerbiApi.version.replace(/\./g, '') >= 320
? {
'powerbi-visuals-api': 'null',
fakeDefine: 'false'
}
: {
'powerbi-visuals-api': 'null',
fakeDefine: 'false',
corePowerbiObject: "Function('return this.powerbi')()",
realWindow: "Function('return this')()"
},
plugins: [
new webpack.DefinePlugin({
__VUE_OPTIONS_API__: JSON.stringify(true),
__VUE_PROD_DEVTOOLS__: JSON.stringify(false)
}),
new VueLoaderPlugin(),
new MiniCssExtractPlugin({
filename: 'visual.css',
chunkFilename: '[id].css'
}),
new Visualizer({
reportFilename: statsLocation,
openAnalyzer: false,
analyzerMode: `static`
}),
// visual plugin regenerates with the visual source, but it does not require relaunching dev server
new webpack.WatchIgnorePlugin({
paths: [path.join(__dirname, pluginLocation), './.tmp/**/*.*']
}),
// custom visuals plugin instance with options
new PowerBICustomVisualsWebpackPlugin({
...pbivizFile,
compression: isProd ? 9 : 0,
capabilities: capabilitiesFile,
stringResources:
localizationFolders &&
localizationFolders.map((localization) =>
path.join(resourcesFolder, localization, 'resources.resjson')
),
apiVersion: powerbiApi.version,
capabilitiesSchema: powerbiApi.schemas.capabilities,
pbivizSchema: powerbiApi.schemas.pbiviz,
stringResourcesSchema: powerbiApi.schemas.stringResources,
dependenciesSchema: powerbiApi.schemas.dependencies,
devMode: false,
generatePbiviz: isProd,
generateResources: true,
minifyJS: isProd,
minify: isProd,
modules: true,
visualSourceLocation: '../../src/visual',
pluginLocation: pluginLocation,
packageOutPath: path.join(__dirname, 'dist')
}),
new ExtraWatchWebpackPlugin({
files: [pbivizPath, capabilitiesPath]
}),
powerbiApi.version.replace(/\./g, '') >= 320
? new webpack.ProvidePlugin({
define: 'fakeDefine'
})
: new webpack.ProvidePlugin({
window: 'realWindow',
define: 'fakeDefine',
powerbi: 'corePowerbiObject'
})
]
}
return config
}
+2 -227
View File
@@ -1,228 +1,3 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import path from 'path'
import { buildConfig } from './webpack.config.base'
// api configuration
import powerbi from 'powerbi-visuals-api'
import ExtraWatchWebpackPlugin from 'extra-watch-webpack-plugin'
import { BundleAnalyzerPlugin as Visualizer } from 'webpack-bundle-analyzer'
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
import { PowerBICustomVisualsWebpackPlugin } from 'powerbi-visuals-webpack-plugin'
import webpack from 'webpack'
import fs from 'fs'
import { WebpackConfiguration } from 'webpack-cli'
import { VueLoaderPlugin } from 'vue-loader'
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const powerbiApi: any = powerbi // Types for PowerBI seem to be off, so I'm instead forcing it to `any`
// visual configuration json path
const pbivizPath = './pbiviz.json'
const pbivizFile = require(path.join(__dirname, pbivizPath))
const packageJsonFile = require(path.join(__dirname, 'package.json'))
pbivizFile.visual.version = packageJsonFile.version
// the visual capabilities content
const capabilitiesPath = './capabilities.json'
const capabilitiesFile = require(path.join(__dirname, capabilitiesPath))
const pluginLocation = './.tmp/precompile/visualPlugin.ts' // path to visual plugin file, the file generates by the plugin
// string resources
const resourcesFolder = path.join('.', 'stringResources')
const localizationFolders = fs.existsSync(resourcesFolder) && fs.readdirSync(resourcesFolder)
const statsLocation = '../../webpack.statistics.html'
// babel options to support IE11
const babelOptions = {
presets: [
[
'@babel/preset-env',
{
targets: {
ie: '11'
},
useBuiltIns: 'entry',
corejs: 3,
modules: false
}
]
],
plugins: [],
sourceType: 'unambiguous', // tell to babel that the project can contain different module types, not only es2015 modules
cacheDirectory: path.join('.tmp', 'babelCache') // path for cache files
}
const config: WebpackConfiguration = {
entry: {
visual: pluginLocation
},
optimization: {
concatenateModules: false,
minimize: false // enable minimization for create *.pbiviz file less than 2 Mb, can be disabled for dev mode
},
devtool: 'source-map',
mode: 'development',
module: {
rules: [
{
test: /\.vue$/,
use: ['vue-loader']
},
{
parser: {
amd: false
}
},
{
test: /(\.ts)x|\.ts$/,
use: [
{
loader: 'babel-loader',
options: {
presets: [
// '@babel/react',
'@babel/env'
]
}
},
{
loader: 'ts-loader',
options: {
transpileOnly: false,
experimentalWatchApi: false,
appendTsSuffixTo: [/\.vue$/]
}
}
],
exclude: [/node_modules/],
include: /.tmp|powerbi-visuals-|src|precompile\\visualPlugin.ts/
},
{
test: /(\.js)x|\.js$/,
use: [
{
loader: 'babel-loader',
options: babelOptions
}
],
exclude: [/node_modules/]
},
{
test: /\.json$/,
loader: 'json-loader',
type: 'javascript/auto'
},
{
test: /\.(css|scss)?$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']
},
{
test: /\.(woff|ttf|ico|woff2|jpg|jpeg|png|webp|svg)$/i,
use: ['base64-inline-loader']
}
]
},
resolve: {
extensions: ['.tsx', '.ts', '.jsx', '.js', '.css'],
alias: {
src: path.resolve(__dirname, 'src/'),
assets: path.resolve(__dirname, 'assets/')
},
plugins: [new TsconfigPathsPlugin()]
},
output: {
publicPath: '/assets',
path: path.join(__dirname, '/.tmp', 'drop'),
library: +powerbiApi.version.replace(/\./g, '') >= 320 ? pbivizFile.visual.guid : undefined,
libraryTarget: +powerbiApi.version.replace(/\./g, '') >= 320 ? 'var' : undefined
},
devServer: {
static: {
directory: path.join(__dirname, '.tmp', 'drop'), // path with assets for dev server, they are generated by webpack plugin
publicPath: '/assets'
},
compress: true,
port: 8080, // dev server port
hot: false,
https: {},
liveReload: false,
webSocketServer: false,
headers: {
'access-control-allow-origin': '*',
'cache-control': 'public, max-age=0'
}
},
externals:
powerbiApi.version.replace(/\./g, '') >= 320
? {
'powerbi-visuals-api': 'null',
fakeDefine: 'false'
}
: {
'powerbi-visuals-api': 'null',
fakeDefine: 'false',
corePowerbiObject: "Function('return this.powerbi')()",
realWindow: "Function('return this')()"
},
plugins: [
new webpack.DefinePlugin({
__VUE_OPTIONS_API__: JSON.stringify(true),
__VUE_PROD_DEVTOOLS__: JSON.stringify(false)
}),
new VueLoaderPlugin(),
new MiniCssExtractPlugin({
filename: 'visual.css',
chunkFilename: '[id].css'
}),
new Visualizer({
reportFilename: statsLocation,
openAnalyzer: false,
analyzerMode: `static`
}),
// visual plugin regenerates with the visual source, but it does not require relaunching dev server
new webpack.WatchIgnorePlugin({
paths: [path.join(__dirname, pluginLocation), './.tmp/**/*.*']
}),
// custom visuals plugin instance with options
new PowerBICustomVisualsWebpackPlugin({
...pbivizFile,
compression: 0,
capabilities: capabilitiesFile,
stringResources:
localizationFolders &&
localizationFolders.map((localization) =>
path.join(resourcesFolder, localization, 'resources.resjson')
),
apiVersion: powerbiApi.version,
capabilitiesSchema: powerbiApi.schemas.capabilities,
pbivizSchema: powerbiApi.schemas.pbiviz,
stringResourcesSchema: powerbiApi.schemas.stringResources,
dependenciesSchema: powerbiApi.schemas.dependencies,
devMode: false,
generatePbiviz: false,
generateResources: true,
minifyJS: false,
minify: false,
modules: true,
visualSourceLocation: '../../src/visual',
pluginLocation: pluginLocation,
packageOutPath: path.join(__dirname, 'dist')
}),
new ExtraWatchWebpackPlugin({
files: [pbivizPath, capabilitiesPath]
}),
powerbiApi.version.replace(/\./g, '') >= 320
? new webpack.ProvidePlugin({
define: 'fakeDefine'
})
: new webpack.ProvidePlugin({
window: 'realWindow',
define: 'fakeDefine',
powerbi: 'corePowerbiObject'
})
]
}
export default config
export default buildConfig({ mode: 'dev' })
+2 -211
View File
@@ -1,212 +1,3 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import path from 'path'
import { buildConfig } from './webpack.config.base'
// api configuration
import powerbi from 'powerbi-visuals-api'
import ExtraWatchWebpackPlugin from 'extra-watch-webpack-plugin'
import { BundleAnalyzerPlugin as Visualizer } from 'webpack-bundle-analyzer'
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
import { PowerBICustomVisualsWebpackPlugin } from 'powerbi-visuals-webpack-plugin'
import webpack from 'webpack'
import fs from 'fs'
import { WebpackConfiguration } from 'webpack-cli'
import { VueLoaderPlugin } from 'vue-loader'
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const powerbiApi: any = powerbi // Types for PowerBI seem to be off, so I'm instead forcing it to `any`
// visual configuration json path
const pbivizPath = './pbiviz.json'
const pbivizFile = require(path.join(__dirname, pbivizPath))
const packageJsonFile = require(path.join(__dirname, 'package.json'))
pbivizFile.visual.version = packageJsonFile.version
// the visual capabilities content
const capabilitiesPath = './capabilities.json'
const capabilitiesFile = require(path.join(__dirname, capabilitiesPath))
const pluginLocation = './.tmp/precompile/visualPlugin.ts' // path to visual plugin file, the file generates by the plugin
// string resources
const resourcesFolder = path.join('.', 'stringResources')
const localizationFolders = fs.existsSync(resourcesFolder) && fs.readdirSync(resourcesFolder)
const statsLocation = '../../webpack.statistics.html'
// babel options to support IE11
const babelOptions = {
presets: [
[
'@babel/preset-env',
{
targets: {
ie: '11'
},
useBuiltIns: 'entry',
corejs: 3,
modules: false
}
]
],
plugins: [],
sourceType: 'unambiguous', // tell to babel that the project can contain different module types, not only es2015 modules
cacheDirectory: path.join('.tmp', 'babelCache') // path for cache files
}
const config: WebpackConfiguration = {
entry: {
visual: pluginLocation
},
optimization: {
concatenateModules: false,
minimize: true // enable minimization for create *.pbiviz file less than 2 Mb, can be disabled for dev mode
},
devtool: 'source-map',
mode: 'production',
module: {
rules: [
{
test: /\.vue$/,
use: ['vue-loader']
},
{
parser: {
amd: false
}
},
{
test: /(\.ts)x|\.ts$/,
use: [
{
loader: 'babel-loader',
options: {
presets: [
// '@babel/react',
'@babel/env'
]
}
},
{
loader: 'ts-loader',
options: {
transpileOnly: false,
experimentalWatchApi: false,
appendTsSuffixTo: [/\.vue$/]
}
}
],
exclude: [/node_modules/],
include: /.tmp|powerbi-visuals-|src|precompile\\visualPlugin.ts/
},
{
test: /(\.js)x|\.js$/,
use: [
{
loader: 'babel-loader',
options: babelOptions
}
],
exclude: [/node_modules/]
},
{
test: /\.json$/,
loader: 'json-loader',
type: 'javascript/auto'
},
{
test: /\.(css|scss)?$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader']
},
{
test: /\.(woff|ttf|ico|woff2|jpg|jpeg|png|webp|svg)$/i,
use: ['base64-inline-loader']
}
]
},
resolve: {
extensions: ['.tsx', '.ts', '.jsx', '.js', '.css'],
alias: {
src: path.resolve(__dirname, 'src/'),
assets: path.resolve(__dirname, 'assets/')
},
plugins: [new TsconfigPathsPlugin()]
},
output: {
publicPath: '/assets',
path: path.join(__dirname, '/.tmp', 'drop'),
library: +powerbiApi.version.replace(/\./g, '') >= 320 ? pbivizFile.visual.guid : undefined,
libraryTarget: +powerbiApi.version.replace(/\./g, '') >= 320 ? 'var' : undefined
},
externals:
powerbiApi.version.replace(/\./g, '') >= 320
? {
'powerbi-visuals-api': 'null',
fakeDefine: 'false'
}
: {
'powerbi-visuals-api': 'null',
fakeDefine: 'false',
corePowerbiObject: "Function('return this.powerbi')()",
realWindow: "Function('return this')()"
},
plugins: [
new webpack.DefinePlugin({
__VUE_OPTIONS_API__: JSON.stringify(true),
__VUE_PROD_DEVTOOLS__: JSON.stringify(false)
}),
new VueLoaderPlugin(),
new MiniCssExtractPlugin({
filename: 'visual.css',
chunkFilename: '[id].css'
}),
new Visualizer({
reportFilename: statsLocation,
openAnalyzer: false,
analyzerMode: `static`
}),
// visual plugin regenerates with the visual source, but it does not require relaunching dev server
new webpack.WatchIgnorePlugin({
paths: [path.join(__dirname, pluginLocation), './.tmp/**/*.*']
}),
// custom visuals plugin instance with options
new PowerBICustomVisualsWebpackPlugin({
...pbivizFile,
compression: 9,
capabilities: capabilitiesFile,
stringResources:
localizationFolders &&
localizationFolders.map((localization) =>
path.join(resourcesFolder, localization, 'resources.resjson')
),
apiVersion: powerbiApi.version,
capabilitiesSchema: powerbiApi.schemas.capabilities,
pbivizSchema: powerbiApi.schemas.pbiviz,
stringResourcesSchema: powerbiApi.schemas.stringResources,
dependenciesSchema: powerbiApi.schemas.dependencies,
devMode: false,
generatePbiviz: true,
generateResources: true,
minifyJS: true,
minify: true,
modules: true,
visualSourceLocation: '../../src/visual',
pluginLocation: pluginLocation,
packageOutPath: path.join(__dirname, 'dist')
}),
new ExtraWatchWebpackPlugin({
files: [pbivizPath, capabilitiesPath]
}),
powerbiApi.version.replace(/\./g, '') >= 320
? new webpack.ProvidePlugin({
define: 'fakeDefine'
})
: new webpack.ProvidePlugin({
window: 'realWindow',
define: 'fakeDefine',
powerbi: 'corePowerbiObject'
})
]
}
export default config
export default buildConfig({ mode: 'prod' })