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