mirror of
https://github.com/h-sphere/sql-seal-charts.git
synced 2026-07-22 05:37:34 +00:00
feat: initial version
This commit is contained in:
commit
4f65a78103
24 changed files with 4865 additions and 0 deletions
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ko_fi: hypersphere
|
||||||
88
.github/workflows/tag-and-publish.yml
vendored
Normal file
88
.github/workflows/tag-and-publish.yml
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
name: Check Version and Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-and-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "18.x"
|
||||||
|
|
||||||
|
- name: Get package version
|
||||||
|
id: package-version
|
||||||
|
run: |
|
||||||
|
VERSION=$(node -p "require('./package.json').version")
|
||||||
|
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Check if tag exists
|
||||||
|
id: check-tag
|
||||||
|
run: |
|
||||||
|
if git rev-parse "${{ steps.package-version.outputs.VERSION }}" >/dev/null 2>&1; then
|
||||||
|
echo "EXISTS=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "EXISTS=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install packages
|
||||||
|
if: steps.check-tag.outputs.EXISTS == 'false'
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build plugin
|
||||||
|
if: steps.check-tag.outputs.EXISTS == 'false'
|
||||||
|
run: |
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Extract release notes
|
||||||
|
if: steps.check-tag.outputs.EXISTS == 'false'
|
||||||
|
id: release-notes
|
||||||
|
run: |
|
||||||
|
version="${{ steps.package-version.outputs.VERSION }}"
|
||||||
|
|
||||||
|
# Use awk to extract the section for the current version
|
||||||
|
awk -v version="$version" '
|
||||||
|
BEGIN { found=0; content="" }
|
||||||
|
/^# [0-9]+\.[0-9]+\.[0-9]+/ {
|
||||||
|
if (found) { exit }
|
||||||
|
if ($2 ~ "^"version"($| \\()") { found=1; next }
|
||||||
|
}
|
||||||
|
found { content = content $0 "\n" }
|
||||||
|
END { printf "%s", content }
|
||||||
|
' CHANGELOG.md > release_notes.txt
|
||||||
|
|
||||||
|
# Escape multiline output for GitHub Actions
|
||||||
|
echo "NOTES<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
cat release_notes.txt >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create tag and release
|
||||||
|
if: steps.check-tag.outputs.EXISTS == 'false'
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
# Create and push tag
|
||||||
|
git config user.name "GitHub Actions"
|
||||||
|
git config user.email "actions@github.com"
|
||||||
|
git tag -a "${{ steps.package-version.outputs.VERSION }}" -m "Release version ${{ steps.package-version.outputs.VERSION }}"
|
||||||
|
git push origin "${{ steps.package-version.outputs.VERSION }}"
|
||||||
|
|
||||||
|
# Create release
|
||||||
|
gh release create "${{ steps.package-version.outputs.VERSION }}" \
|
||||||
|
--title="${{ steps.package-version.outputs.VERSION }}" \
|
||||||
|
--notes="${{ steps.release-notes.outputs.NOTES }}" \
|
||||||
|
main.js manifest.json styles.css
|
||||||
49
.github/workflows/test.yml
vendored
Normal file
49
.github/workflows/test.yml
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
name: Run Tests on Pull Request
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: pnpm run test
|
||||||
|
typecheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Run typecheck
|
||||||
|
run: pnpm run typecheck
|
||||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# vscode
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
# Intellij
|
||||||
|
*.iml
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# npm
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Don't include the compiled main.js file in the repo.
|
||||||
|
# They should be uploaded to GitHub releases instead.
|
||||||
|
main.js
|
||||||
|
|
||||||
|
# Exclude sourcemaps
|
||||||
|
*.map
|
||||||
|
|
||||||
|
# obsidian
|
||||||
|
data.json
|
||||||
|
|
||||||
|
# Exclude macOS Finder (System Explorer) View States
|
||||||
|
.DS_Store
|
||||||
|
docs/.vitepress/dist
|
||||||
|
docs/.vitepress/cache
|
||||||
|
coverage/
|
||||||
|
dist/
|
||||||
2
CHANGELOG.md
Normal file
2
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# 0.1.0 (2025-01-31)
|
||||||
|
Initial release!
|
||||||
85
README.md
Normal file
85
README.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
# SQLSeal Charts
|
||||||
|
|
||||||
|
SQLSeal Charts is an extension for SQLSeal Obsidian Plugin allowing to visualise your data using charts!
|
||||||
|
|
||||||
|
Powered by [ECharts](https://echarts.apache.org/en/index.html). Check their documentation for more examples of usage.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Support for basic charts: line, bar, pie charts
|
||||||
|
- Support for compex charts like scatterplot, heatmap, etc.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
Install the plugin by downloading latest release and unpacking it in `.obsidian/plugins/sqlseal-charts`. Make sure you have SQLSeal plugin already installed.
|
||||||
|
|
||||||
|
## Example query
|
||||||
|
|
||||||
|
### Pie Chart
|
||||||
|
|
||||||
|
Query below takes the first table in your current markdown tile and displays it as a piechart.
|
||||||
|
|
||||||
|
| Category | Amount |
|
||||||
|
| ------------- | ------ |
|
||||||
|
| Rent | 1500 |
|
||||||
|
| Groceries | 200 |
|
||||||
|
| Entertainment | 150 |
|
||||||
|
| Repairs | 200 |
|
||||||
|
|
||||||
|
```sqlseal
|
||||||
|
TABLE finances = table(0)
|
||||||
|
|
||||||
|
CHART {
|
||||||
|
series: [{
|
||||||
|
type: 'pie'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
SELECT * FROM finances
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Line Chart
|
||||||
|
```sqlseal
|
||||||
|
CHART {
|
||||||
|
xAxis: {
|
||||||
|
type: 'category'
|
||||||
|
},
|
||||||
|
yAxis: {},
|
||||||
|
series: [{
|
||||||
|
type: 'bar',
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
SELECT
|
||||||
|
strftime("%Y-%m-%d", created_at) as created_date,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM files
|
||||||
|
GROUP BY created_date
|
||||||
|
ORDER BY created_date
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## Syntax
|
||||||
|
SQLSeal Charts uses [ECharts](https://echarts.apache.org/en/index.html) under the hood. It automatically exposes data returned by your SQL query as a `data` dataset in ECharts. This means you can generate plenty of charts without worrying too much about how the data is being passed down. For more complex use-cases, you can always refer to the data by column name, it's index or even filter it down to create separate data-sets for different series.
|
||||||
|
To read more about datasets, [check out ECharts documentation](https://apache.github.io/echarts-handbook/en/concepts/dataset/).
|
||||||
|
|
||||||
|
For more advanced transformations, SQLSeal Charts exposes the following data and functions to be used to transform data further.
|
||||||
|
|
||||||
|
### Data
|
||||||
|
| Variable Name | Description |
|
||||||
|
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `data` | Array of objects containing raw data |
|
||||||
|
| `columns` | array of column names |
|
||||||
|
| Object for each column | You can refer to each of the column data by their name, i.e. for `SELECT category, amount FROM data` you can use `category` and `amount` columns |
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
The following functions are exposed in your query to allow you to further process data.
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `column(name: string)` | Returns array of the values for a specified column |
|
||||||
|
| `mean, max, min, uniq, uniqBy` | [Lodash](https://lodash.com/docs/4.17.15) functions to help with data processing |
|
||||||
|
| `array(...arrays)` | creates subarrays from arrays, i.e. `array([1,2], [3,4 ]) == [[1,3], [2,4]]`. Useful when grouping multiple columns together when reshaping the data |
|
||||||
|
| Repairs | 200 |
|
||||||
|
| `assembleObjects(...definitions: { key: string, values: array }[])` | Assembles multiple arrays into array of objects using provided key values |
|
||||||
|
| `assemble(definition: Record<string, array>)` | Assembles multiple arrays into array of objects using a key=>value map |
|
||||||
BIN
docs/line_chart.png
Normal file
BIN
docs/line_chart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
docs/pie_chart.png
Normal file
BIN
docs/pie_chart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
37
esbuild.config.mjs
Normal file
37
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import esbuild from "esbuild";
|
||||||
|
import process from "process";
|
||||||
|
import builtins from "builtin-modules";
|
||||||
|
|
||||||
|
const banner =
|
||||||
|
`/*
|
||||||
|
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||||
|
*/
|
||||||
|
`;
|
||||||
|
|
||||||
|
const prod = (process.argv[2] === "production");
|
||||||
|
|
||||||
|
const context = await esbuild.context({
|
||||||
|
banner: {
|
||||||
|
js: banner,
|
||||||
|
},
|
||||||
|
entryPoints: ["src/main.ts"],
|
||||||
|
bundle: true,
|
||||||
|
external: [
|
||||||
|
"obsidian",
|
||||||
|
"electron",
|
||||||
|
...builtins,
|
||||||
|
],
|
||||||
|
format: "cjs",
|
||||||
|
target: "es2016",
|
||||||
|
logLevel: "info",
|
||||||
|
sourcemap: prod ? false : "inline",
|
||||||
|
treeShaking: true,
|
||||||
|
outfile: "main.js",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prod) {
|
||||||
|
await context.rebuild();
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
await context.watch();
|
||||||
|
}
|
||||||
9
jest.config.js
Normal file
9
jest.config.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': 'ts-jest',
|
||||||
|
},
|
||||||
|
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$',
|
||||||
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||||
|
};
|
||||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"id": "sqlseal-charts",
|
||||||
|
"name": "SQLSeal Charts",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"minAppVersion": "0.15.0",
|
||||||
|
"description": "Charting extension for SQLSeal plugin. Generate pie charts, bar charts, line charts and more using data stored in your vault!",
|
||||||
|
"author": "hypersphere",
|
||||||
|
"authorUrl": "https://hypersphere.blog/",
|
||||||
|
"fundingUrl": "ko-fi.com/hypersphere"
|
||||||
|
}
|
||||||
53
package.json
Normal file
53
package.json
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
{
|
||||||
|
"name": "sqlseal-charts",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Chart extension for SQLSeal Obsidian plugin",
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node esbuild.config.mjs",
|
||||||
|
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||||
|
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||||
|
"typecheck": "tsc -noEmit -skipLibCheck",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"obsidian",
|
||||||
|
"plugin",
|
||||||
|
"sqlseal",
|
||||||
|
"charts",
|
||||||
|
"visualisation"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/h-sphere/sql-seal-charts"
|
||||||
|
},
|
||||||
|
"author": "Kacper Kula",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/acorn": "^6.0.4",
|
||||||
|
"@types/estree": "^1.0.6",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/node": "^22.10.10",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.21.0",
|
||||||
|
"@typescript-eslint/parser": "^8.21.0",
|
||||||
|
"builtin-modules": "^4.0.0",
|
||||||
|
"esbuild": "^0.24.2",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"obsidian": "^1.7.2",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/leaflet": "^1.9.16",
|
||||||
|
"@types/lodash": "^4.17.14",
|
||||||
|
"@vanakat/plugin-api": "^0.2.1",
|
||||||
|
"acorn": "^8.14.0",
|
||||||
|
"echarts": "^5.6.0",
|
||||||
|
"echarts-gl": "^2.0.9",
|
||||||
|
"json5": "^2.2.3",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
3938
pnpm-lock.yaml
Normal file
3938
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
52
scripts/data_generator.js
Normal file
52
scripts/data_generator.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const OUTPUT_FILE = path.join(__dirname, '../../../../obsidianPlayground/My\ Vault/test_data.csv');
|
||||||
|
const TOTAL_RECORDS = 50;
|
||||||
|
const UPDATE_INTERVAL = 1 * 1000; // 10 seconds
|
||||||
|
|
||||||
|
// Generate dates starting from today
|
||||||
|
function generateDates() {
|
||||||
|
const dates = [];
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
for(let i = 0; i < TOTAL_RECORDS; i++) {
|
||||||
|
const date = new Date(today);
|
||||||
|
date.setDate(today.getDate() + i);
|
||||||
|
dates.push(date.toISOString().split('T')[0]); // YYYY-MM-DD format
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random values between 1 and 100
|
||||||
|
function generateValues() {
|
||||||
|
return Array(TOTAL_RECORDS).fill(0)
|
||||||
|
.map(() => Math.floor(Math.random() * 100) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create CSV content
|
||||||
|
function createCSV(dates, values, values2) {
|
||||||
|
const headers = ['date', 'value1', 'value2'];
|
||||||
|
const rows = dates.map((date, index) => `${date},${values[index]},${values2[index]}`);
|
||||||
|
return [headers.join(','), ...rows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main function to update data
|
||||||
|
function updateData() {
|
||||||
|
const dates = generateDates();
|
||||||
|
const values = generateValues();
|
||||||
|
const values2 = generateValues()
|
||||||
|
const csvContent = createCSV(dates, values, values2);
|
||||||
|
|
||||||
|
fs.writeFileSync(OUTPUT_FILE, csvContent, 'utf-8');
|
||||||
|
console.log(`Data updated at ${new Date().toLocaleTimeString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial update
|
||||||
|
updateData();
|
||||||
|
|
||||||
|
// Schedule updates
|
||||||
|
setInterval(updateData, UPDATE_INTERVAL);
|
||||||
|
|
||||||
|
console.log(`Data generator started. Writing to ${OUTPUT_FILE}`);
|
||||||
57
src/chartRenderer.ts
Normal file
57
src/chartRenderer.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { App } from "obsidian";
|
||||||
|
import { parseCode } from "./utils/configParser";
|
||||||
|
import { prepareDataVariables } from "./utils/prepareDataVariables";
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
import type { RendererConfig } from "@hypersphere/sqlseal";
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
config: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChartRenderer implements RendererConfig {
|
||||||
|
|
||||||
|
constructor(private readonly app: App) {
|
||||||
|
}
|
||||||
|
|
||||||
|
get rendererKey() {
|
||||||
|
return 'chart'
|
||||||
|
}
|
||||||
|
|
||||||
|
validateConfig(config: string): Config {
|
||||||
|
return { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
render(config: Config, el: HTMLElement) {
|
||||||
|
let isRendered: boolean = false
|
||||||
|
let chart: echarts.ECharts | null = null
|
||||||
|
return {
|
||||||
|
render: ({ columns, data }: { columns: string[], data: Record<string, unknown>[]}) => {
|
||||||
|
const { functions, variables } = prepareDataVariables({ columns, data })
|
||||||
|
const parsedConfig = parseCode(config.config, functions, variables)
|
||||||
|
const dataset = [{ id: 'data', source: data }, ...(parsedConfig.dataset ?? [])]
|
||||||
|
parsedConfig.dataset = dataset
|
||||||
|
|
||||||
|
if (isRendered) {
|
||||||
|
// Data update
|
||||||
|
chart?.setOption(parsedConfig)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
el.empty()
|
||||||
|
const container = el.createDiv({ cls: 'sqlseal-charts-container' })
|
||||||
|
const chartDiv = container.createDiv()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const box = container.getBoundingClientRect()
|
||||||
|
const width = box.width
|
||||||
|
const height = box.height
|
||||||
|
chart = echarts.init(chartDiv, null, { height: height, width: width, })
|
||||||
|
chart.setOption(parsedConfig)
|
||||||
|
isRendered = true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
error: (error: string) => {
|
||||||
|
return createDiv({ text: error, cls: 'sqlseal-error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/main.ts
Normal file
17
src/main.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Plugin } from 'obsidian';
|
||||||
|
import { ChartRenderer } from './chartRenderer';
|
||||||
|
import { pluginApi } from '@vanakat/plugin-api';
|
||||||
|
import type { SQLSealRegisterApi } from '@hypersphere/sqlseal'
|
||||||
|
|
||||||
|
export default class SQLSealCharts extends Plugin {
|
||||||
|
|
||||||
|
async onload() {
|
||||||
|
this.registerWithSQLSeal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerWithSQLSeal() {
|
||||||
|
const api = pluginApi('sqlseal') as SQLSealRegisterApi
|
||||||
|
const registar = api.registerForPlugin(this)
|
||||||
|
registar.registerView('sqlseal-charts', new ChartRenderer(this.app))
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/utils/configParser.test.ts
Normal file
94
src/utils/configParser.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { parseCode } from "./configParser";
|
||||||
|
|
||||||
|
describe('config parser', () => {
|
||||||
|
it('should properly parse basic data', () => {
|
||||||
|
// Example usage:
|
||||||
|
const dot = (config: any) => ({ type: 'dot', ...config });
|
||||||
|
|
||||||
|
// Test the parser
|
||||||
|
const testCode = `{
|
||||||
|
a: 'test',
|
||||||
|
marks: [
|
||||||
|
dot({ x: 'x', y: 'something' })
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const res = parseCode(testCode, { dot })
|
||||||
|
expect(res).toEqual({
|
||||||
|
a: 'test',
|
||||||
|
marks: [{
|
||||||
|
type: 'dot',
|
||||||
|
x: 'x',
|
||||||
|
y: 'something'
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should properly parse config with data provided', () => {
|
||||||
|
const variables = {
|
||||||
|
data: [1, 2, 3, 4],
|
||||||
|
config: { value: 42 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const functions = {
|
||||||
|
dot: (config: any) => ({ type: 'dot', ...config })
|
||||||
|
};
|
||||||
|
|
||||||
|
// This will work:
|
||||||
|
const code1 = `{
|
||||||
|
a: data[0],
|
||||||
|
b: data[1],
|
||||||
|
marks: [
|
||||||
|
dot({ x: data[2], y: data[3] })
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const res = parseCode(code1, functions, variables)
|
||||||
|
expect(res).toEqual({
|
||||||
|
a: 1,
|
||||||
|
b: 2,
|
||||||
|
marks: [{
|
||||||
|
type: 'dot',
|
||||||
|
x: 3,
|
||||||
|
y: 4
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should properly parse config with arrow functions and template literals', () => {
|
||||||
|
const variables = {
|
||||||
|
data: [1, 2, 3, 4],
|
||||||
|
config: { value: 42 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const functions = {
|
||||||
|
dot: (config: any) => ({ type: 'dot', ...config }),
|
||||||
|
column: (name: string) => ([1,2,3])
|
||||||
|
};
|
||||||
|
|
||||||
|
// This will work:
|
||||||
|
const code1 = `{
|
||||||
|
a: data[0],
|
||||||
|
b: data[1],
|
||||||
|
fn: (x) => \`\${x.a.b[0]} DATA\`,
|
||||||
|
data: column('data'),
|
||||||
|
marks: [
|
||||||
|
dot({ x: data[2], y: data[3] })
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const res = parseCode(code1, functions, variables)
|
||||||
|
expect(res).toEqual({
|
||||||
|
a: 1,
|
||||||
|
b: 2,
|
||||||
|
fn: expect.any(Function),
|
||||||
|
data: [1,2,3],
|
||||||
|
marks: [{
|
||||||
|
type: 'dot',
|
||||||
|
x: 3,
|
||||||
|
y: 4
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
205
src/utils/configParser.ts
Normal file
205
src/utils/configParser.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import * as acorn from 'acorn';
|
||||||
|
import { Node, ObjectExpression, Property, CallExpression, Identifier, Literal,
|
||||||
|
ArrayExpression, MemberExpression, ArrowFunctionExpression, BlockStatement,
|
||||||
|
TemplateLiteral } from 'estree';
|
||||||
|
|
||||||
|
type AllowedFunctions = Record<string, (...args: any[]) => any>;
|
||||||
|
type AllowedVariables = Record<string, any>;
|
||||||
|
|
||||||
|
// Extended to include function scope
|
||||||
|
interface EvaluationContext {
|
||||||
|
allowedFunctions: AllowedFunctions;
|
||||||
|
allowedVariables: AllowedVariables;
|
||||||
|
scope: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCode(
|
||||||
|
code: string,
|
||||||
|
allowedFunctions: AllowedFunctions = {},
|
||||||
|
allowedVariables: AllowedVariables = {}
|
||||||
|
) {
|
||||||
|
const wrappedCode = `(${code})`;
|
||||||
|
|
||||||
|
const ast = acorn.parse(wrappedCode, {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
sourceType: 'module'
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
const context: EvaluationContext = {
|
||||||
|
allowedFunctions,
|
||||||
|
allowedVariables,
|
||||||
|
scope: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
function evaluateNode(node: Node, context: EvaluationContext): any {
|
||||||
|
if (!node) {
|
||||||
|
throw new Error('Null node encountered');
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (node.type) {
|
||||||
|
case 'ObjectExpression':
|
||||||
|
return evaluateObject(node as ObjectExpression, context);
|
||||||
|
case 'ArrayExpression':
|
||||||
|
return evaluateArray(node as ArrayExpression, context);
|
||||||
|
case 'CallExpression':
|
||||||
|
return evaluateCall(node as CallExpression, context);
|
||||||
|
case 'Identifier':
|
||||||
|
return evaluateIdentifier(node as Identifier, context);
|
||||||
|
case 'Literal':
|
||||||
|
return (node as Literal).value;
|
||||||
|
case 'MemberExpression':
|
||||||
|
return evaluateMemberExpression(node as MemberExpression, context);
|
||||||
|
case 'ArrowFunctionExpression':
|
||||||
|
return evaluateArrowFunction(node as ArrowFunctionExpression, context);
|
||||||
|
case 'BlockStatement':
|
||||||
|
return evaluateBlockStatement(node as BlockStatement, context);
|
||||||
|
case 'TemplateLiteral':
|
||||||
|
return evaluateTemplateLiteral(node as any, context);
|
||||||
|
case 'TemplateElement':
|
||||||
|
return (node as any).value.cooked;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported node type: ${node.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateArrowFunction(node: ArrowFunctionExpression, context: EvaluationContext): Function {
|
||||||
|
return (...args: any[]) => {
|
||||||
|
// Create a new scope for the function execution
|
||||||
|
const functionContext: EvaluationContext = {
|
||||||
|
...context,
|
||||||
|
scope: { ...context.scope }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bind parameters to arguments
|
||||||
|
node.params.forEach((param, index) => {
|
||||||
|
if (param.type === 'Identifier') {
|
||||||
|
functionContext.scope[param.name] = args[index];
|
||||||
|
} else {
|
||||||
|
throw new Error('Only simple parameter names are supported');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle both expression and block body
|
||||||
|
if (node.body.type === 'BlockStatement') {
|
||||||
|
return evaluateBlockStatement(node.body, functionContext);
|
||||||
|
} else {
|
||||||
|
return evaluateNode(node.body, functionContext);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateBlockStatement(node: BlockStatement, context: EvaluationContext): any {
|
||||||
|
let result: any;
|
||||||
|
for (const statement of node.body) {
|
||||||
|
result = evaluateNode(statement, context);
|
||||||
|
}
|
||||||
|
return result; // Returns the value of the last statement
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateTemplateLiteral(node: TemplateLiteral, context: EvaluationContext): string {
|
||||||
|
let result = '';
|
||||||
|
const expressions = node.expressions;
|
||||||
|
const quasis = node.quasis;
|
||||||
|
|
||||||
|
for (let i = 0; i < quasis.length; i++) {
|
||||||
|
// Add the template string part
|
||||||
|
result += evaluateNode(quasis[i], context);
|
||||||
|
|
||||||
|
// Add the expression value if there is one
|
||||||
|
if (i < expressions.length) {
|
||||||
|
const value = evaluateNode(expressions[i], context);
|
||||||
|
result += value != null ? String(value) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateIdentifier(node: Identifier, context: EvaluationContext): any {
|
||||||
|
const varName = node.name;
|
||||||
|
// Check scope first, then allowed variables
|
||||||
|
if (context.scope.hasOwnProperty(varName)) {
|
||||||
|
return context.scope[varName];
|
||||||
|
}
|
||||||
|
if (context.allowedVariables.hasOwnProperty(varName)) {
|
||||||
|
return context.allowedVariables[varName];
|
||||||
|
}
|
||||||
|
throw new Error(`Variable "${varName}" is not allowed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateMemberExpression(node: MemberExpression, context: EvaluationContext): any {
|
||||||
|
// Evaluate the object part of the member expression
|
||||||
|
const object = node.object.type === 'MemberExpression'
|
||||||
|
? evaluateMemberExpression(node.object, context)
|
||||||
|
: node.object.type === 'Identifier'
|
||||||
|
? evaluateIdentifier(node.object, context)
|
||||||
|
: evaluateNode(node.object, context);
|
||||||
|
|
||||||
|
if (object == null) {
|
||||||
|
throw new Error('Cannot access properties of null or undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle computed (bracket notation) access
|
||||||
|
if (node.computed) {
|
||||||
|
const propertyValue = evaluateNode(node.property, context);
|
||||||
|
if (typeof propertyValue !== 'number' && typeof propertyValue !== 'string') {
|
||||||
|
throw new Error('Property access must use number or string');
|
||||||
|
}
|
||||||
|
return object[propertyValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dot notation access
|
||||||
|
if (node.property.type === 'Identifier') {
|
||||||
|
return object[node.property.name];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Invalid property access');
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateObject(node: ObjectExpression, context: EvaluationContext): Record<string, any> {
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const prop of node.properties) {
|
||||||
|
const property = prop as Property;
|
||||||
|
const key = property.key.type === 'Identifier' ?
|
||||||
|
property.key.name :
|
||||||
|
(property.key as Literal).value;
|
||||||
|
|
||||||
|
result[key as string] = evaluateNode(property.value, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateArray(node: ArrayExpression, context: EvaluationContext): any[] {
|
||||||
|
return node.elements.map(element =>
|
||||||
|
element ? evaluateNode(element, context) : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateCall(node: CallExpression, context: EvaluationContext): any {
|
||||||
|
let func: Function;
|
||||||
|
|
||||||
|
if (node.callee.type === 'Identifier') {
|
||||||
|
const callee = node.callee.name;
|
||||||
|
if (!context.allowedFunctions[callee]) {
|
||||||
|
throw new Error(`Function "${callee}" is not allowed`);
|
||||||
|
}
|
||||||
|
func = context.allowedFunctions[callee];
|
||||||
|
} else if (node.callee.type === 'ArrowFunctionExpression') {
|
||||||
|
func = evaluateArrowFunction(node.callee as ArrowFunctionExpression, context);
|
||||||
|
} else {
|
||||||
|
throw new Error('Unsupported function call type');
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = node.arguments.map(arg => evaluateNode(arg, context));
|
||||||
|
return func(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expressionStatement = ast.body[0];
|
||||||
|
if (!expressionStatement || expressionStatement.type !== 'ExpressionStatement') {
|
||||||
|
throw new Error('Invalid input structure');
|
||||||
|
}
|
||||||
|
|
||||||
|
return evaluateNode(expressionStatement.expression, context);
|
||||||
|
}
|
||||||
32
src/utils/prepareDataVariables.test.ts
Normal file
32
src/utils/prepareDataVariables.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { prepareDataVariables } from "./prepareDataVariables"
|
||||||
|
|
||||||
|
describe('prepare data variables', () => {
|
||||||
|
it('should properly prepare variables for the dataset', () => {
|
||||||
|
const { functions, variables } = prepareDataVariables({
|
||||||
|
data: [{a: 5, b: 9}, { a: 6, b: 10}],
|
||||||
|
columns: ['a', 'b']
|
||||||
|
})
|
||||||
|
expect(variables.data).toHaveLength(2)
|
||||||
|
expect(variables.columns).toHaveLength(2)
|
||||||
|
expect(variables['a']).toEqual([5, 6])
|
||||||
|
expect(variables['b']).toEqual([9, 10])
|
||||||
|
|
||||||
|
expect(functions.max([1,2,3])).toEqual(3)
|
||||||
|
expect(functions.min([1,2,3])).toEqual(1)
|
||||||
|
|
||||||
|
expect(functions.mean([1, 2, 3])).toEqual(2)
|
||||||
|
expect(functions.column('a')).toEqual([5,6])
|
||||||
|
expect(functions.array(functions.column('a'), functions.column('b'))).toEqual([[5, 9], [6, 10]])
|
||||||
|
|
||||||
|
// Assemble and Assemble Objects
|
||||||
|
const { assemble, assembleObjects } = functions
|
||||||
|
const result = [
|
||||||
|
{ id: 1, value: 50 },
|
||||||
|
{ id: 2, value: 51 },
|
||||||
|
{ id: 3, value: 52 }
|
||||||
|
]
|
||||||
|
expect(assembleObjects({ key: 'id', values: [1,2,3]}, { key: 'value', values: [50, 51, 52 ]})).toEqual(result)
|
||||||
|
|
||||||
|
expect(assemble({ id: [1,2,3], value: [50, 51, 52]})).toEqual(result)
|
||||||
|
})
|
||||||
|
})
|
||||||
58
src/utils/prepareDataVariables.ts
Normal file
58
src/utils/prepareDataVariables.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { min, max, uniq, uniqBy, mean } from 'lodash'
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
data: Record<string, unknown>[];
|
||||||
|
columns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareDataVariables({ data, columns }: Config) {
|
||||||
|
const column = (f: string) => {
|
||||||
|
return data.map((d: any) => d[f])
|
||||||
|
}
|
||||||
|
|
||||||
|
function array<T>(...arrays: T[][]): T[][] {
|
||||||
|
const minLength = Math.min(...arrays.map(arr => arr.length));
|
||||||
|
|
||||||
|
return Array.from({ length: minLength }, (_, i) =>
|
||||||
|
arrays.map(arr => arr[i])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ValueDefinition {
|
||||||
|
key: string;
|
||||||
|
values: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function assembleObjects(...definitions: ValueDefinition[]): Record<string, any>[] {
|
||||||
|
const minLength = Math.min(...definitions.map(def => def.values.length));
|
||||||
|
|
||||||
|
return Array.from({ length: minLength }, (_, index) => {
|
||||||
|
return definitions.reduce((obj, def) => {
|
||||||
|
obj[def.key] = def.values[index];
|
||||||
|
return obj;
|
||||||
|
}, {} as Record<string, any>);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assemble(def: Record<string, any[]>): Record<string, any>[] {
|
||||||
|
const definitions = Object.values(def)
|
||||||
|
const minLength = Math.min(...definitions.map(def => def.length));
|
||||||
|
|
||||||
|
return Array.from({ length: minLength }, (_, index) => {
|
||||||
|
return Object.entries(def).reduce((obj, [key, value]) => {
|
||||||
|
obj[key] = value[index]
|
||||||
|
return obj;
|
||||||
|
}, {} as Record<string, any>);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnsData: Record<string, unknown> = columns.reduce((acc, col) => ({
|
||||||
|
...acc,
|
||||||
|
[col]: column(col)
|
||||||
|
}), {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
functions: { column, array, assembleObjects, min, max, uniq, uniqBy, assemble, mean },
|
||||||
|
variables: { ...columnsData, data, columns } as Record<string, unknown[]>
|
||||||
|
}
|
||||||
|
}
|
||||||
11
styles.css
Normal file
11
styles.css
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
.sqlseal-charts-container {
|
||||||
|
display: grid;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sqlseal-charts-container > div {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"inlineSourceMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"target": "ES6",
|
||||||
|
"allowJs": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"importHelpers": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"lib": [
|
||||||
|
"DOM",
|
||||||
|
"ES5",
|
||||||
|
"ES6",
|
||||||
|
"ES7"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
14
version-bump.mjs
Normal file
14
version-bump.mjs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { readFileSync, writeFileSync } from "fs";
|
||||||
|
|
||||||
|
const targetVersion = process.env.npm_package_version;
|
||||||
|
|
||||||
|
// read minAppVersion from manifest.json and bump version to target version
|
||||||
|
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||||
|
const { minAppVersion } = manifest;
|
||||||
|
manifest.version = targetVersion;
|
||||||
|
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||||
|
|
||||||
|
// update versions.json with target version and minAppVersion from manifest.json
|
||||||
|
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
|
||||||
|
versions[targetVersion] = minAppVersion;
|
||||||
|
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));
|
||||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"0.1.0": "0.15.0"
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue