mirror of
https://github.com/pooyash1998/chartspark.git
synced 2026-07-22 06:53:29 +00:00
rendering works
This commit is contained in:
parent
8a1e7908ab
commit
a5676428f9
40 changed files with 6044 additions and 190 deletions
36
.github/workflows/release.yml
vendored
Normal file
36
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
main.js
|
||||
manifest.json
|
||||
styles.css
|
||||
135
README.md
135
README.md
|
|
@ -1,90 +1,77 @@
|
|||
# Obsidian Sample Plugin
|
||||
# ChartSpark ⚡
|
||||
|
||||
This is a sample plugin for Obsidian (https://obsidian.md).
|
||||
Instantly turn selected text in your Obsidian notes into beautiful, interactive charts — no copy-paste, no external tools.
|
||||
|
||||
This project uses TypeScript to provide type checking and documentation.
|
||||
The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does.
|
||||
## Features
|
||||
|
||||
This sample plugin demonstrates some of the basic functionality the plugin API can do.
|
||||
- Adds a ribbon icon, which shows a Notice when clicked.
|
||||
- Adds a command "Open modal (simple)" which opens a Modal.
|
||||
- Adds a plugin setting tab to the settings page.
|
||||
- Registers a global click event and output 'click' to the console.
|
||||
- Registers a global interval which logs 'setInterval' to the console.
|
||||
- **Select → Chart** — select a checkbox list, markdown table, JSON block, or key:value list, right-click (or run a command), and get an interactive chart inserted in seconds
|
||||
- **4 chart types** — pie, doughnut, bar, line with a live preview picker
|
||||
- **Auto-refresh** — charts re-render when their source note is modified
|
||||
- **Vault scan** — aggregate data across your entire vault (or a specific folder) into one chart
|
||||
- **Export PNG** — download any chart with one click
|
||||
- **Theme-aware** — respects Obsidian's light/dark theme via CSS variables
|
||||
|
||||
## First time developing plugins?
|
||||
## Supported input formats
|
||||
|
||||
Quick starting guide for new plugin devs:
|
||||
| Format | Example |
|
||||
|--------|---------|
|
||||
| Checkbox list | `- [x] Task 1` / `- [ ] Task 2` |
|
||||
| Markdown table | `\| Item \| Value \|` |
|
||||
| JSON object | `{"Sales": 120, "Returns": 20}` |
|
||||
| JSON array | `[{"name":"A","count":5}]` |
|
||||
| Key-value list | `Revenue: 500` / `Cost: 300` |
|
||||
|
||||
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with.
|
||||
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
|
||||
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
|
||||
- Install NodeJS, then run `npm i` in the command line under your repo folder.
|
||||
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
|
||||
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
|
||||
- Reload Obsidian to load the new version of your plugin.
|
||||
- Enable plugin in settings window.
|
||||
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
|
||||
## Quick start
|
||||
|
||||
## Releasing new releases
|
||||
1. Select text containing a recognizable pattern
|
||||
2. Right-click → **Generate chart from selection**
|
||||
_or_ open the command palette → **ChartSpark: Generate chart from selection**
|
||||
3. Pick a chart type in the preview modal
|
||||
4. Click **Insert chart** — a `chartspark` code block is added to your note
|
||||
|
||||
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
|
||||
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
|
||||
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
|
||||
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
|
||||
- Publish the release.
|
||||
## Manual chart block
|
||||
|
||||
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
|
||||
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
|
||||
You can write or edit a `chartspark` code block directly:
|
||||
|
||||
## Adding your plugin to the community plugin list
|
||||
|
||||
- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines).
|
||||
- Publish an initial version.
|
||||
- Make sure you have a `README.md` file in the root of your repo.
|
||||
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
|
||||
|
||||
## How to use
|
||||
|
||||
- Clone this repo.
|
||||
- Make sure your NodeJS is at least v16 (`node --version`).
|
||||
- `npm i` or `yarn` to install dependencies.
|
||||
- `npm run dev` to start compilation in watch mode.
|
||||
|
||||
## Manually installing the plugin
|
||||
|
||||
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
|
||||
|
||||
## Improve code quality with eslint
|
||||
- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code.
|
||||
- This project already has eslint preconfigured, you can invoke a check by running`npm run lint`
|
||||
- Together with a custom eslint [plugin](https://github.com/eslint-plugin) for Obsidan specific code guidelines.
|
||||
- A GitHub action is preconfigured to automatically lint every commit on all branches.
|
||||
|
||||
## Funding URL
|
||||
|
||||
You can include funding URLs where people who use your plugin can financially support it.
|
||||
|
||||
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
|
||||
|
||||
```json
|
||||
````markdown
|
||||
```chartspark
|
||||
{
|
||||
"fundingUrl": "https://buymeacoffee.com"
|
||||
"type": "pie",
|
||||
"data": {
|
||||
"labels": ["Completed", "Remaining"],
|
||||
"datasets": [{"label": "Tasks", "data": [7, 3]}]
|
||||
}
|
||||
}
|
||||
```
|
||||
````
|
||||
|
||||
If you have multiple URLs, you can also do:
|
||||
## Commands
|
||||
|
||||
```json
|
||||
{
|
||||
"fundingUrl": {
|
||||
"Buy Me a Coffee": "https://buymeacoffee.com",
|
||||
"GitHub Sponsor": "https://github.com/sponsors",
|
||||
"Patreon": "https://www.patreon.com/"
|
||||
}
|
||||
}
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `Generate chart from selection` | Convert selected text to a chart |
|
||||
| `Scan vault and generate chart` | Aggregate data from all notes |
|
||||
| `Export active chart as PNG` | Download the visible chart |
|
||||
|
||||
## Settings
|
||||
|
||||
- **Default chart type** — pie / doughnut / bar / line
|
||||
- **Auto-refresh charts** — re-render on note modification
|
||||
- **Max chart width** — 200–1200 px
|
||||
- **Show refresh button** — manual ↻ button on each chart
|
||||
|
||||
## Installation (manual)
|
||||
|
||||
1. `npm install && npm run build`
|
||||
2. Copy `main.js`, `manifest.json`, `styles.css` to
|
||||
`<Vault>/.obsidian/plugins/chartspark/`
|
||||
3. Reload Obsidian and enable **ChartSpark** in **Settings → Community plugins**
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # watch mode
|
||||
npm test # run unit tests
|
||||
npm run build # production build
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
See https://docs.obsidian.md
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
{
|
||||
"id": "sample-plugin",
|
||||
"name": "Sample Plugin",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Demonstrates some of the capabilities of the Obsidian API.",
|
||||
"author": "Obsidian",
|
||||
"authorUrl": "https://obsidian.md",
|
||||
"fundingUrl": "https://obsidian.md/pricing",
|
||||
"id": "chartspark",
|
||||
"name": "ChartSpark",
|
||||
"version": "0.1.0",
|
||||
"minAppVersion": "1.0.0",
|
||||
"description": "Instantly turn selected checkboxes, tables, and JSON into beautiful charts.",
|
||||
"author": "Pooyash1998",
|
||||
"authorUrl": "https://github.com/Pooyash1998",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
|
|
|
|||
4292
package-lock.json
generated
4292
package-lock.json
generated
File diff suppressed because it is too large
Load diff
23
package.json
23
package.json
|
|
@ -8,22 +8,37 @@
|
|||
"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",
|
||||
"lint": "eslint ."
|
||||
"lint": "eslint .",
|
||||
"test": "jest"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "ts-jest",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"obsidian": "<rootDir>/tests/__mocks__/obsidian.ts"
|
||||
},
|
||||
"transform": {
|
||||
"^.+\\.tsx?$": ["ts-jest", {"useESM": false}]
|
||||
}
|
||||
},
|
||||
"keywords": [],
|
||||
"license": "0-BSD",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.30.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^16.11.6",
|
||||
"esbuild": "0.25.5",
|
||||
"eslint-plugin-obsidianmd": "0.1.9",
|
||||
"globals": "14.0.0",
|
||||
"jest": "^30.3.0",
|
||||
"jiti": "2.6.1",
|
||||
"ts-jest": "^29.4.9",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "8.35.1",
|
||||
"@eslint/js": "9.30.1",
|
||||
"jiti": "2.6.1"
|
||||
"typescript-eslint": "8.35.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"obsidian": "latest"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
src/commands/exportChart.ts
Normal file
25
src/commands/exportChart.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Notice } from 'obsidian';
|
||||
import ChartSparkPlugin from '../main';
|
||||
|
||||
export function registerExportChartCommand(plugin: ChartSparkPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: 'export-active-chart',
|
||||
name: 'Export active chart as PNG',
|
||||
checkCallback: (checking: boolean) => {
|
||||
const canvas = document.querySelector<HTMLCanvasElement>('.chartspark-canvas');
|
||||
if (!canvas) return false;
|
||||
if (!checking) {
|
||||
try {
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
const a = document.createElement('a');
|
||||
a.href = dataUrl;
|
||||
a.download = 'chartspark-export.png';
|
||||
a.click();
|
||||
} catch {
|
||||
new Notice('ChartSpark: Export failed.');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
50
src/commands/generateChart.ts
Normal file
50
src/commands/generateChart.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { Editor, Notice } from 'obsidian';
|
||||
import ChartSparkPlugin from '../main';
|
||||
import { ChartConfig } from '../types';
|
||||
import { ChartPreviewModal } from '../ui/chartPreviewModal';
|
||||
|
||||
export function registerGenerateChartCommand(plugin: ChartSparkPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: 'generate-chart-from-selection',
|
||||
name: 'Generate chart from selection',
|
||||
editorCallback: (editor: Editor) => {
|
||||
const selection = editor.getSelection().trim();
|
||||
if (!selection) {
|
||||
new Notice('ChartSpark: Select some text first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const parser = plugin.registry.findParser(selection);
|
||||
if (!parser) {
|
||||
new Notice('ChartSpark: No recognizable pattern in selection (try a checkbox list, table, or JSON).');
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture source location before the modal opens
|
||||
const startLine = editor.getCursor('from').line;
|
||||
const endLine = editor.getCursor('to').line;
|
||||
const sourceFile = plugin.app.workspace.getActiveFile()?.path ?? '';
|
||||
|
||||
const parsed = parser.parse(selection);
|
||||
|
||||
new ChartPreviewModal(
|
||||
plugin.app,
|
||||
parsed,
|
||||
plugin.settings.defaultChartType,
|
||||
(config: ChartConfig) => {
|
||||
config.meta = {
|
||||
...config.meta,
|
||||
source: { file: sourceFile, startLine, endLine },
|
||||
};
|
||||
insertChartBlock(editor, config);
|
||||
},
|
||||
).open();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function insertChartBlock(editor: Editor, config: ChartConfig): void {
|
||||
const cursor = editor.getCursor('to');
|
||||
const block = `\n\`\`\`chartspark\n${JSON.stringify(config, null, 2)}\n\`\`\`\n`;
|
||||
editor.replaceRange(block, cursor);
|
||||
}
|
||||
44
src/commands/insertTemplate.ts
Normal file
44
src/commands/insertTemplate.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { App, FuzzySuggestModal, Editor, Notice } from 'obsidian';
|
||||
import ChartSparkPlugin from '../main';
|
||||
import { CHART_TEMPLATES, ChartTemplate } from '../templates/chartTemplates';
|
||||
import { ChartConfig } from '../types';
|
||||
|
||||
class TemplatePicker extends FuzzySuggestModal<ChartTemplate> {
|
||||
constructor(app: App, private onPick: (t: ChartTemplate) => void) {
|
||||
super(app);
|
||||
this.setPlaceholder('Pick a chart template…');
|
||||
}
|
||||
|
||||
getItems(): ChartTemplate[] {
|
||||
return CHART_TEMPLATES;
|
||||
}
|
||||
|
||||
getItemText(item: ChartTemplate): string {
|
||||
return `${item.name} — ${item.description}`;
|
||||
}
|
||||
|
||||
onChooseItem(item: ChartTemplate): void {
|
||||
this.onPick(item);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerInsertTemplateCommand(plugin: ChartSparkPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: 'insert-chart-template',
|
||||
name: 'Insert chart template',
|
||||
editorCallback: (editor: Editor) => {
|
||||
new TemplatePicker(plugin.app, (template: ChartTemplate) => {
|
||||
const config: ChartConfig = {
|
||||
...template.config,
|
||||
meta: { generated: new Date().toISOString() },
|
||||
};
|
||||
const cursor = editor.getCursor('to');
|
||||
editor.replaceRange(
|
||||
`\n\`\`\`chartspark\n${JSON.stringify(config, null, 2)}\n\`\`\`\n`,
|
||||
cursor,
|
||||
);
|
||||
new Notice(`ChartSpark: Inserted "${template.name}" template.`);
|
||||
}).open();
|
||||
},
|
||||
});
|
||||
}
|
||||
41
src/commands/quickChart.ts
Normal file
41
src/commands/quickChart.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Editor, Notice } from 'obsidian';
|
||||
import ChartSparkPlugin from '../main';
|
||||
import { ChartTypeModal } from '../ui/chartTypeModal';
|
||||
import { KPICalculator } from '../kpi/calculator';
|
||||
import { ChartType } from '../types';
|
||||
import { insertChartBlock } from './generateChart';
|
||||
|
||||
export function registerQuickChartCommand(plugin: ChartSparkPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: 'quick-chart',
|
||||
name: 'Quick chart from selection (pick type, no preview)',
|
||||
hotkeys: [{ modifiers: ['Mod', 'Shift'], key: 'c' }],
|
||||
editorCallback: (editor: Editor) => {
|
||||
const selection = editor.getSelection().trim();
|
||||
if (!selection) {
|
||||
new Notice('ChartSpark: Select some text first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const parser = plugin.registry.findParser(selection);
|
||||
if (!parser) {
|
||||
new Notice('ChartSpark: No recognizable pattern in selection.');
|
||||
return;
|
||||
}
|
||||
|
||||
const startLine = editor.getCursor('from').line;
|
||||
const endLine = editor.getCursor('to').line;
|
||||
const sourceFile = plugin.app.workspace.getActiveFile()?.path ?? '';
|
||||
const parsed = parser.parse(selection);
|
||||
|
||||
new ChartTypeModal(plugin.app, (type: ChartType) => {
|
||||
const config = new KPICalculator().buildChartConfig(parsed, type);
|
||||
config.meta = {
|
||||
...config.meta,
|
||||
source: { file: sourceFile, startLine, endLine },
|
||||
};
|
||||
insertChartBlock(editor, config);
|
||||
}).open();
|
||||
},
|
||||
});
|
||||
}
|
||||
50
src/commands/scanVault.ts
Normal file
50
src/commands/scanVault.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { Notice } from 'obsidian';
|
||||
import ChartSparkPlugin from '../main';
|
||||
import { ChartConfig } from '../types';
|
||||
import { VaultScanner } from '../scanner/vaultScanner';
|
||||
import { DataAggregator } from '../scanner/dataAggregator';
|
||||
import { ChartPreviewModal } from '../ui/chartPreviewModal';
|
||||
|
||||
export function registerScanVaultCommand(plugin: ChartSparkPlugin): void {
|
||||
plugin.addCommand({
|
||||
id: 'scan-vault-for-chart',
|
||||
name: 'Scan vault and generate chart',
|
||||
callback: async () => {
|
||||
const notice = new Notice('ChartSpark: Scanning vault…', 0);
|
||||
try {
|
||||
const scanner = new VaultScanner(plugin.app, plugin.registry);
|
||||
const matches = await scanner.scan();
|
||||
notice.hide();
|
||||
|
||||
if (matches.length === 0) {
|
||||
new Notice('ChartSpark: No recognizable data found in vault.');
|
||||
return;
|
||||
}
|
||||
|
||||
const aggregated = new DataAggregator().aggregate(matches);
|
||||
|
||||
new ChartPreviewModal(
|
||||
plugin.app,
|
||||
aggregated,
|
||||
plugin.settings.defaultChartType,
|
||||
(config: ChartConfig) => {
|
||||
// Insert at active cursor, or show as new note
|
||||
const editor = plugin.app.workspace.activeEditor?.editor;
|
||||
if (editor) {
|
||||
const cursor = editor.getCursor('to');
|
||||
editor.replaceRange(
|
||||
`\n\`\`\`chartspark\n${JSON.stringify(config, null, 2)}\n\`\`\`\n`,
|
||||
cursor,
|
||||
);
|
||||
} else {
|
||||
new Notice('ChartSpark: Open a note first to insert the chart.');
|
||||
}
|
||||
},
|
||||
).open();
|
||||
} catch (e) {
|
||||
notice.hide();
|
||||
new Notice(`ChartSpark: Scan failed — ${String(e)}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
47
src/kpi/calculator.ts
Normal file
47
src/kpi/calculator.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { ParsedData, ChartConfig, ChartType } from '../types';
|
||||
import { getColors, withOpacity } from '../utils/colors';
|
||||
|
||||
export class KPICalculator {
|
||||
buildChartConfig(parsed: ParsedData, chartType: ChartType): ChartConfig {
|
||||
const colors = getColors(parsed.labels.length);
|
||||
const borderColors = colors;
|
||||
const bgColors = chartType === 'line'
|
||||
? colors.map(c => withOpacity(c, 0.3))
|
||||
: colors.map(c => withOpacity(c, 0.85));
|
||||
|
||||
const dataset = {
|
||||
label: parsed.metadata.title ?? parsed.type,
|
||||
data: parsed.values,
|
||||
backgroundColor: bgColors,
|
||||
borderColor: borderColors,
|
||||
borderWidth: 2,
|
||||
...(chartType === 'line' ? { fill: true, tension: 0.4 } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
type: chartType,
|
||||
data: {
|
||||
labels: parsed.labels,
|
||||
datasets: [dataset],
|
||||
},
|
||||
options: this.defaultOptions(chartType),
|
||||
meta: {
|
||||
generated: new Date().toISOString(),
|
||||
patternType: parsed.type,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private defaultOptions(type: ChartType): Record<string, unknown> {
|
||||
return {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
title: { display: false },
|
||||
},
|
||||
...(type === 'bar' || type === 'line'
|
||||
? { scales: { y: { beginAtZero: true } } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
170
src/main.ts
170
src/main.ts
|
|
@ -1,99 +1,115 @@
|
|||
import {App, Editor, MarkdownView, Modal, Notice, Plugin} from 'obsidian';
|
||||
import {DEFAULT_SETTINGS, MyPluginSettings, SampleSettingTab} from "./settings";
|
||||
import { Plugin, MarkdownPostProcessorContext, Notice } from 'obsidian';
|
||||
import { ChartSparkSettings, DEFAULT_SETTINGS, ChartSparkSettingTab } from './settings';
|
||||
import { ParserRegistry } from './parsers/parserRegistry';
|
||||
import { ChartRenderer } from './renderer/chartRenderer';
|
||||
import { ChartBlockComponent } from './renderer/chartBlockComponent';
|
||||
import { registerGenerateChartCommand, insertChartBlock } from './commands/generateChart';
|
||||
import { registerQuickChartCommand } from './commands/quickChart';
|
||||
import { registerScanVaultCommand } from './commands/scanVault';
|
||||
import { registerExportChartCommand } from './commands/exportChart';
|
||||
import { registerInsertTemplateCommand } from './commands/insertTemplate';
|
||||
import { ChartConfig } from './types';
|
||||
import { ChartPreviewModal } from './ui/chartPreviewModal';
|
||||
|
||||
// Remember to rename these classes and interfaces!
|
||||
export default class ChartSparkPlugin extends Plugin {
|
||||
settings!: ChartSparkSettings;
|
||||
registry!: ParserRegistry;
|
||||
renderer!: ChartRenderer;
|
||||
|
||||
export default class MyPlugin extends Plugin {
|
||||
settings: MyPluginSettings;
|
||||
|
||||
async onload() {
|
||||
async onload(): Promise<void> {
|
||||
await this.loadSettings();
|
||||
|
||||
// This creates an icon in the left ribbon.
|
||||
this.addRibbonIcon('dice', 'Sample', (evt: MouseEvent) => {
|
||||
// Called when the user clicks the icon.
|
||||
new Notice('This is a notice!');
|
||||
});
|
||||
this.registry = new ParserRegistry();
|
||||
this.renderer = new ChartRenderer();
|
||||
|
||||
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
|
||||
const statusBarItemEl = this.addStatusBarItem();
|
||||
statusBarItemEl.setText('Status bar text');
|
||||
this.addSettingTab(new ChartSparkSettingTab(this.app, this));
|
||||
|
||||
// This adds a simple command that can be triggered anywhere
|
||||
this.addCommand({
|
||||
id: 'open-modal-simple',
|
||||
name: 'Open modal (simple)',
|
||||
callback: () => {
|
||||
new SampleModal(this.app).open();
|
||||
}
|
||||
});
|
||||
// This adds an editor command that can perform some operation on the current editor instance
|
||||
this.addCommand({
|
||||
id: 'replace-selected',
|
||||
name: 'Replace selected content',
|
||||
editorCallback: (editor: Editor, view: MarkdownView) => {
|
||||
editor.replaceSelection('Sample editor command');
|
||||
}
|
||||
});
|
||||
// This adds a complex command that can check whether the current state of the app allows execution of the command
|
||||
this.addCommand({
|
||||
id: 'open-modal-complex',
|
||||
name: 'Open modal (complex)',
|
||||
checkCallback: (checking: boolean) => {
|
||||
// Conditions to check
|
||||
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (markdownView) {
|
||||
// If checking is true, we're simply "checking" if the command can be run.
|
||||
// If checking is false, then we want to actually perform the operation.
|
||||
if (!checking) {
|
||||
new SampleModal(this.app).open();
|
||||
}
|
||||
this.registerMarkdownCodeBlockProcessor('chartspark', this.processChartBlock.bind(this));
|
||||
|
||||
// This command will only show up in Command Palette when the check function returns true
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
registerGenerateChartCommand(this);
|
||||
registerQuickChartCommand(this);
|
||||
registerScanVaultCommand(this);
|
||||
registerExportChartCommand(this);
|
||||
registerInsertTemplateCommand(this);
|
||||
|
||||
// This adds a settings tab so the user can configure various aspects of the plugin
|
||||
this.addSettingTab(new SampleSettingTab(this.app, this));
|
||||
this.registerEvent(
|
||||
this.app.workspace.on('editor-menu', (menu, editor) => {
|
||||
const selection = editor.getSelection().trim();
|
||||
if (!selection) return;
|
||||
|
||||
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
|
||||
// Using this function will automatically remove the event listener when this plugin is disabled.
|
||||
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
|
||||
new Notice("Click");
|
||||
});
|
||||
menu.addItem(item =>
|
||||
item
|
||||
.setTitle('Generate chart from selection')
|
||||
.setIcon('bar-chart')
|
||||
.onClick(() => {
|
||||
const parser = this.registry.findParser(selection);
|
||||
if (!parser) {
|
||||
new Notice(
|
||||
'ChartSpark: Selection not recognized.\nSupported: checkbox list, markdown table, JSON, or key: value pairs.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
|
||||
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
|
||||
const startLine = editor.getCursor('from').line;
|
||||
const endLine = editor.getCursor('to').line;
|
||||
const sourceFile = this.app.workspace.getActiveFile()?.path ?? '';
|
||||
const parsed = parser.parse(selection);
|
||||
|
||||
new ChartPreviewModal(
|
||||
this.app,
|
||||
parsed,
|
||||
this.settings.defaultChartType,
|
||||
(config: ChartConfig) => {
|
||||
config.meta = {
|
||||
...config.meta,
|
||||
source: { file: sourceFile, startLine, endLine },
|
||||
};
|
||||
insertChartBlock(editor, config);
|
||||
},
|
||||
).open();
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onunload() {
|
||||
onunload(): void {}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<ChartSparkSettings>);
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<MyPluginSettings>);
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
|
||||
class SampleModal extends Modal {
|
||||
constructor(app: App) {
|
||||
super(app);
|
||||
}
|
||||
private processChartBlock(source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext): void {
|
||||
let config: ChartConfig;
|
||||
try {
|
||||
config = JSON.parse(source) as ChartConfig;
|
||||
} catch {
|
||||
this.renderer.renderError('Invalid JSON in chartspark block.', el);
|
||||
return;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
let {contentEl} = this;
|
||||
contentEl.setText('Woah!');
|
||||
}
|
||||
if (!config.type || !config.data) {
|
||||
this.renderer.renderError('Missing "type" or "data" in chartspark config.', el);
|
||||
return;
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const {contentEl} = this;
|
||||
contentEl.empty();
|
||||
// Render immediately — synchronous, guaranteed before any lifecycle hooks fire
|
||||
const chartInstance = this.renderer.render(config, el);
|
||||
|
||||
// Component handles vault listener, delete button, and cleanup only
|
||||
ctx.addChild(new ChartBlockComponent(
|
||||
this.app,
|
||||
config,
|
||||
source,
|
||||
el,
|
||||
chartInstance,
|
||||
this.renderer,
|
||||
this.registry,
|
||||
this.settings.showRefreshButton,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
src/parsers/checkboxParser.ts
Normal file
25
src/parsers/checkboxParser.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Parser, ParsedData } from '../types';
|
||||
|
||||
export class CheckboxParser implements Parser {
|
||||
name = 'checkbox';
|
||||
|
||||
canParse(text: string): boolean {
|
||||
return /^- \[(x|X| )\]/m.test(text);
|
||||
}
|
||||
|
||||
parse(text: string): ParsedData {
|
||||
const lines = text.split('\n').filter(l => /- \[(x|X| )\]/.test(l));
|
||||
const completed = lines.filter(l => /- \[(x|X)\]/.test(l));
|
||||
const remaining = lines.length - completed.length;
|
||||
|
||||
return {
|
||||
type: 'checkbox',
|
||||
labels: ['Completed', 'Remaining'],
|
||||
values: [completed.length, remaining],
|
||||
metadata: {
|
||||
totalItems: lines.length,
|
||||
title: 'Task Completion',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
63
src/parsers/jsonParser.ts
Normal file
63
src/parsers/jsonParser.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { Parser, ParsedData } from '../types';
|
||||
|
||||
export class JsonParser implements Parser {
|
||||
name = 'json';
|
||||
|
||||
canParse(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return false;
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
parse(text: string): ParsedData {
|
||||
const parsed = JSON.parse(text.trim()) as unknown;
|
||||
if (Array.isArray(parsed)) return this.parseArray(parsed);
|
||||
return this.parseObject(parsed as Record<string, unknown>);
|
||||
}
|
||||
|
||||
private parseArray(arr: unknown[]): ParsedData {
|
||||
if (arr.length > 0 && typeof arr[0] === 'object' && arr[0] !== null) {
|
||||
const items = arr as Record<string, unknown>[];
|
||||
const first = items[0] as Record<string, unknown>;
|
||||
const keys = Object.keys(first);
|
||||
const labelKey = keys.find(k => typeof first[k] === 'string') ?? keys[0] ?? 'label';
|
||||
const valueKey = keys.find(k => typeof first[k] === 'number') ?? keys[1] ?? 'value';
|
||||
return {
|
||||
type: 'json',
|
||||
labels: items.map(i => String(i[labelKey] ?? '')),
|
||||
values: items.map(i => Number(i[valueKey] ?? 0)),
|
||||
metadata: { totalItems: items.length, title: valueKey },
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'json',
|
||||
labels: arr.map((_, i) => `Item ${i + 1}`),
|
||||
values: arr.map(v => Number(v) || 0),
|
||||
metadata: { totalItems: arr.length },
|
||||
};
|
||||
}
|
||||
|
||||
private parseObject(obj: Record<string, unknown>): ParsedData {
|
||||
const entries = Object.entries(obj).filter(([, v]) => typeof v === 'number');
|
||||
if (entries.length === 0) {
|
||||
const allEntries = Object.entries(obj);
|
||||
return {
|
||||
type: 'json',
|
||||
labels: allEntries.map(([k]) => k),
|
||||
values: allEntries.map(([, v]) => Number(v) || 0),
|
||||
metadata: { totalItems: allEntries.length },
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'json',
|
||||
labels: entries.map(([k]) => k),
|
||||
values: entries.map(([, v]) => Number(v)),
|
||||
metadata: { totalItems: entries.length },
|
||||
};
|
||||
}
|
||||
}
|
||||
29
src/parsers/kvParser.ts
Normal file
29
src/parsers/kvParser.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Parser, ParsedData } from '../types';
|
||||
|
||||
export class KVParser implements Parser {
|
||||
name = 'keyvalue';
|
||||
|
||||
canParse(text: string): boolean {
|
||||
const lines = text.trim().split('\n').filter(l => l.trim());
|
||||
const kvLines = lines.filter(l => /^[\w\s]+[:=]\s*[\d.]+/.test(l));
|
||||
return kvLines.length >= 2 && kvLines.length >= lines.length * 0.5;
|
||||
}
|
||||
|
||||
parse(text: string): ParsedData {
|
||||
const lines = text.trim().split('\n').filter(l => l.trim());
|
||||
const entries = lines
|
||||
.map(l => {
|
||||
const match = l.match(/^(.+?)[:=]\s*([\d.]+)/);
|
||||
if (!match || match[1] === undefined || match[2] === undefined) return null;
|
||||
return { label: match[1].trim(), value: parseFloat(match[2]) };
|
||||
})
|
||||
.filter((e): e is { label: string; value: number } => e !== null);
|
||||
|
||||
return {
|
||||
type: 'keyvalue',
|
||||
labels: entries.map(e => e.label),
|
||||
values: entries.map(e => e.value),
|
||||
metadata: { totalItems: entries.length },
|
||||
};
|
||||
}
|
||||
}
|
||||
31
src/parsers/parserRegistry.ts
Normal file
31
src/parsers/parserRegistry.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Parser } from '../types';
|
||||
import { CheckboxParser } from './checkboxParser';
|
||||
import { TableParser } from './tableParser';
|
||||
import { JsonParser } from './jsonParser';
|
||||
import { KVParser } from './kvParser';
|
||||
|
||||
export class ParserRegistry {
|
||||
private parsers: Parser[] = [];
|
||||
|
||||
constructor() {
|
||||
// Order matters: most specific first
|
||||
this.parsers = [
|
||||
new JsonParser(),
|
||||
new CheckboxParser(),
|
||||
new TableParser(),
|
||||
new KVParser(),
|
||||
];
|
||||
}
|
||||
|
||||
register(parser: Parser): void {
|
||||
this.parsers.push(parser);
|
||||
}
|
||||
|
||||
findParser(text: string): Parser | null {
|
||||
return this.parsers.find(p => p.canParse(text)) ?? null;
|
||||
}
|
||||
|
||||
getParsers(): Parser[] {
|
||||
return [...this.parsers];
|
||||
}
|
||||
}
|
||||
63
src/parsers/tableParser.ts
Normal file
63
src/parsers/tableParser.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { Parser, ParsedData } from '../types';
|
||||
|
||||
const SEPARATOR_RE = /^\|?[\s|:\-]+\|$/;
|
||||
|
||||
export class TableParser implements Parser {
|
||||
name = 'table';
|
||||
|
||||
canParse(text: string): boolean {
|
||||
const lines = text.trim().split('\n').filter(l => l.trim());
|
||||
if (lines.length < 2) return false;
|
||||
// At least 60% of lines must contain a pipe character
|
||||
const pipeLines = lines.filter(l => l.includes('|'));
|
||||
return pipeLines.length >= 2 && pipeLines.length / lines.length >= 0.6;
|
||||
}
|
||||
|
||||
parse(text: string): ParsedData {
|
||||
const lines = text.trim().split('\n').filter(l => l.trim() && l.includes('|'));
|
||||
|
||||
const headers = this.parseCells(lines[0] ?? '');
|
||||
|
||||
// Skip separator row if present (e.g. |---|---|)
|
||||
const dataStartIndex = lines[1] && SEPARATOR_RE.test(lines[1].trim()) ? 2 : 1;
|
||||
const rows = lines.slice(dataStartIndex).map(l => this.parseCells(l));
|
||||
|
||||
const numericColIndex = this.findNumericColumn(rows, headers);
|
||||
const labelColIndex = numericColIndex === 0 ? 1 : 0;
|
||||
|
||||
const labels = rows.map(r => r[labelColIndex] ?? '');
|
||||
const values = rows.map(r => {
|
||||
const raw = r[numericColIndex] ?? '0';
|
||||
return parseFloat(raw.replace(/[^0-9.-]/g, '')) || 0;
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'table',
|
||||
labels,
|
||||
values,
|
||||
metadata: {
|
||||
totalItems: rows.length,
|
||||
columns: headers,
|
||||
title: headers[numericColIndex] ?? 'Values',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private parseCells(row: string): string[] {
|
||||
return row
|
||||
.split('|')
|
||||
.map(c => c.trim())
|
||||
.filter((_, i, arr) => i > 0 && i < arr.length - 1);
|
||||
}
|
||||
|
||||
private findNumericColumn(rows: string[][], headers: string[]): number {
|
||||
for (let col = 0; col < headers.length; col++) {
|
||||
const isNumeric = rows.every(r => {
|
||||
const val = (r[col] ?? '').replace(/[^0-9.-]/g, '');
|
||||
return val !== '' && !isNaN(parseFloat(val));
|
||||
});
|
||||
if (isNumeric) return col;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
96
src/renderer/chartBlockComponent.ts
Normal file
96
src/renderer/chartBlockComponent.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { App, MarkdownRenderChild, TFile } from 'obsidian';
|
||||
import { Chart } from 'chart.js';
|
||||
import { ChartConfig } from '../types';
|
||||
import { ChartRenderer } from './chartRenderer';
|
||||
import { ParserRegistry } from '../parsers/parserRegistry';
|
||||
import { KPICalculator } from '../kpi/calculator';
|
||||
|
||||
export class ChartBlockComponent extends MarkdownRenderChild {
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
private app: App,
|
||||
private config: ChartConfig,
|
||||
private rawSource: string,
|
||||
container: HTMLElement,
|
||||
private chartInstance: Chart, // already rendered by processChartBlock
|
||||
private renderer: ChartRenderer,
|
||||
private registry: ParserRegistry,
|
||||
private showRefreshBtn: boolean,
|
||||
) {
|
||||
super(container);
|
||||
}
|
||||
|
||||
onload(): void {
|
||||
// Chart is already in the DOM — just add the × and refresh buttons
|
||||
const chartContainer = this.containerEl.querySelector<HTMLElement>('.chartspark-container');
|
||||
if (chartContainer) {
|
||||
const deleteBtn = chartContainer.createEl('button', {
|
||||
cls: 'chartspark-delete-btn',
|
||||
text: '×',
|
||||
});
|
||||
deleteBtn.setAttribute('aria-label', 'Remove chart');
|
||||
deleteBtn.addEventListener('click', () => { void this.deleteBlock(); });
|
||||
}
|
||||
|
||||
if (this.showRefreshBtn) {
|
||||
const controls = this.containerEl.createDiv({ cls: 'chartspark-controls' });
|
||||
const btn = controls.createEl('button', {
|
||||
cls: 'chartspark-refresh-btn',
|
||||
text: '↻ Refresh',
|
||||
});
|
||||
btn.addEventListener('click', () => { void this.refresh(); });
|
||||
}
|
||||
|
||||
const sourcePath = this.config.meta?.source?.file;
|
||||
if (!sourcePath) return;
|
||||
|
||||
this.registerEvent(
|
||||
this.app.vault.on('modify', (file) => {
|
||||
if (!(file instanceof TFile) || file.path !== sourcePath) return;
|
||||
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = null;
|
||||
void this.refresh(file);
|
||||
}, 150);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onunload(): void {
|
||||
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
||||
this.chartInstance.destroy();
|
||||
}
|
||||
|
||||
async refresh(file?: TFile): Promise<void> {
|
||||
const source = this.config.meta?.source;
|
||||
if (!source?.file) return;
|
||||
|
||||
const target = file ?? this.app.vault.getAbstractFileByPath(source.file);
|
||||
if (!(target instanceof TFile)) return;
|
||||
|
||||
const content = await this.app.vault.cachedRead(target);
|
||||
const lines = content.split('\n');
|
||||
const section = lines
|
||||
.slice(source.startLine ?? 0, (source.endLine ?? lines.length - 1) + 1)
|
||||
.join('\n');
|
||||
|
||||
const parser = this.registry.findParser(section);
|
||||
if (!parser) return;
|
||||
|
||||
const parsed = parser.parse(section);
|
||||
const freshConfig = new KPICalculator().buildChartConfig(parsed, this.config.type);
|
||||
this.renderer.update(this.chartInstance, freshConfig);
|
||||
}
|
||||
|
||||
private async deleteBlock(): Promise<void> {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) return;
|
||||
|
||||
const content = await this.app.vault.read(activeFile);
|
||||
const escaped = this.rawSource.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const blockRe = new RegExp(`\\n?\`\`\`chartspark\\n${escaped}\\n\`\`\`\\n?`);
|
||||
const updated = content.replace(blockRe, '\n').replace(/\n{3,}/g, '\n\n');
|
||||
if (updated !== content) await this.app.vault.modify(activeFile, updated);
|
||||
}
|
||||
}
|
||||
47
src/renderer/chartRenderer.ts
Normal file
47
src/renderer/chartRenderer.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Chart, registerables, ChartConfiguration, ChartData } from 'chart.js';
|
||||
import { ChartConfig } from '../types';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
export class ChartRenderer {
|
||||
create(config: ChartConfig, canvas: HTMLCanvasElement): Chart {
|
||||
const chartConfig: ChartConfiguration = {
|
||||
type: config.type,
|
||||
data: config.data as ChartConfiguration['data'],
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
...(config.options ?? {}),
|
||||
} as ChartConfiguration['options'],
|
||||
};
|
||||
return new Chart(canvas, chartConfig);
|
||||
}
|
||||
|
||||
// Patch labels + dataset values in-place — no destroy, no animation
|
||||
update(instance: Chart, freshConfig: ChartConfig): void {
|
||||
const fresh = freshConfig.data as ChartData;
|
||||
instance.data.labels = fresh.labels;
|
||||
const src = fresh.datasets[0];
|
||||
const dst = instance.data.datasets[0];
|
||||
if (src && dst) {
|
||||
dst.data = src.data;
|
||||
if (src.backgroundColor) dst.backgroundColor = src.backgroundColor;
|
||||
if (src.borderColor) dst.borderColor = src.borderColor;
|
||||
}
|
||||
instance.update('none');
|
||||
}
|
||||
|
||||
// Convenience: create a canvas inside container and return the instance
|
||||
render(config: ChartConfig, container: HTMLElement): Chart {
|
||||
container.empty();
|
||||
const wrapper = container.createDiv({ cls: 'chartspark-container' });
|
||||
const sizer = wrapper.createDiv({ cls: 'chartspark-sizer' });
|
||||
const canvas = sizer.createEl('canvas', { cls: 'chartspark-canvas' });
|
||||
return this.create(config, canvas);
|
||||
}
|
||||
|
||||
renderError(message: string, container: HTMLElement): void {
|
||||
container.empty();
|
||||
container.createDiv({ cls: 'chartspark-error', text: `ChartSpark: ${message}` });
|
||||
}
|
||||
}
|
||||
49
src/scanner/dataAggregator.ts
Normal file
49
src/scanner/dataAggregator.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { ParsedData, PatternType } from '../types';
|
||||
import { ScanMatch } from './vaultScanner';
|
||||
|
||||
export class DataAggregator {
|
||||
aggregate(matches: ScanMatch[], targetType?: PatternType): ParsedData {
|
||||
const filtered = targetType
|
||||
? matches.filter(m => m.parsed.type === targetType)
|
||||
: matches;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return { type: 'json', labels: [], values: [], metadata: { totalItems: 0 } };
|
||||
}
|
||||
|
||||
if (filtered[0]?.parsed.type === 'checkbox') {
|
||||
return this.aggregateCheckboxes(filtered);
|
||||
}
|
||||
return this.aggregateNumeric(filtered);
|
||||
}
|
||||
|
||||
private aggregateCheckboxes(matches: ScanMatch[]): ParsedData {
|
||||
let completed = 0;
|
||||
let remaining = 0;
|
||||
for (const m of matches) {
|
||||
completed += m.parsed.values[0] ?? 0;
|
||||
remaining += m.parsed.values[1] ?? 0;
|
||||
}
|
||||
return {
|
||||
type: 'checkbox',
|
||||
labels: ['Completed', 'Remaining'],
|
||||
values: [completed, remaining],
|
||||
metadata: { totalItems: completed + remaining, title: 'Vault task completion' },
|
||||
};
|
||||
}
|
||||
|
||||
private aggregateNumeric(matches: ScanMatch[]): ParsedData {
|
||||
const labelMap = new Map<string, number>();
|
||||
for (const m of matches) {
|
||||
m.parsed.labels.forEach((label, i) => {
|
||||
labelMap.set(label, (labelMap.get(label) ?? 0) + (m.parsed.values[i] ?? 0));
|
||||
});
|
||||
}
|
||||
return {
|
||||
type: matches[0]?.parsed.type ?? 'json',
|
||||
labels: [...labelMap.keys()],
|
||||
values: [...labelMap.values()],
|
||||
metadata: { totalItems: labelMap.size, title: 'Aggregated data' },
|
||||
};
|
||||
}
|
||||
}
|
||||
38
src/scanner/vaultScanner.ts
Normal file
38
src/scanner/vaultScanner.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { App, TFile } from 'obsidian';
|
||||
import { ParsedData } from '../types';
|
||||
import { ParserRegistry } from '../parsers/parserRegistry';
|
||||
|
||||
export interface ScanMatch {
|
||||
file: TFile;
|
||||
parsed: ParsedData;
|
||||
section: string;
|
||||
}
|
||||
|
||||
export class VaultScanner {
|
||||
constructor(private app: App, private registry: ParserRegistry) {}
|
||||
|
||||
async scan(folder?: string): Promise<ScanMatch[]> {
|
||||
const files = this.app.vault.getMarkdownFiles().filter(f => {
|
||||
if (!folder) return true;
|
||||
return f.path.startsWith(folder.replace(/^\//, ''));
|
||||
});
|
||||
|
||||
const matches: ScanMatch[] = [];
|
||||
for (const file of files) {
|
||||
const content = await this.app.vault.read(file);
|
||||
const sections = this.extractSections(content);
|
||||
for (const section of sections) {
|
||||
const parser = this.registry.findParser(section);
|
||||
if (parser) {
|
||||
matches.push({ file, parsed: parser.parse(section), section });
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
private extractSections(content: string): string[] {
|
||||
// Split on blank lines to get logical blocks
|
||||
return content.split(/\n{2,}/).filter(s => s.trim().length > 0);
|
||||
}
|
||||
}
|
||||
12
src/selectionHandler.ts
Normal file
12
src/selectionHandler.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { App } from 'obsidian';
|
||||
|
||||
export class SelectionHandler {
|
||||
constructor(private app: App) {}
|
||||
|
||||
getSelection(): string | null {
|
||||
const editor = this.app.workspace.activeEditor?.editor;
|
||||
if (!editor) return null;
|
||||
const sel = editor.getSelection();
|
||||
return sel.trim() || null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +1,79 @@
|
|||
import {App, PluginSettingTab, Setting} from "obsidian";
|
||||
import MyPlugin from "./main";
|
||||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||
import ChartSparkPlugin from './main';
|
||||
import { ChartType } from './types';
|
||||
|
||||
export interface MyPluginSettings {
|
||||
mySetting: string;
|
||||
export interface ChartSparkSettings {
|
||||
defaultChartType: ChartType;
|
||||
autoRefresh: boolean;
|
||||
chartWidth: number;
|
||||
showRefreshButton: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: MyPluginSettings = {
|
||||
mySetting: 'default'
|
||||
}
|
||||
export const DEFAULT_SETTINGS: ChartSparkSettings = {
|
||||
defaultChartType: 'pie',
|
||||
autoRefresh: true,
|
||||
chartWidth: 600,
|
||||
showRefreshButton: true,
|
||||
};
|
||||
|
||||
export class SampleSettingTab extends PluginSettingTab {
|
||||
plugin: MyPlugin;
|
||||
|
||||
constructor(app: App, plugin: MyPlugin) {
|
||||
export class ChartSparkSettingTab extends PluginSettingTab {
|
||||
constructor(app: App, private plugin: ChartSparkPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const {containerEl} = this;
|
||||
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
containerEl.createEl('h2', { text: 'ChartSpark settings' });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Settings #1')
|
||||
.setDesc('It\'s a secret')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Enter your secret')
|
||||
.setValue(this.plugin.settings.mySetting)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.mySetting = value;
|
||||
.setName('Default chart type')
|
||||
.setDesc('Chart type used when generating from selection.')
|
||||
.addDropdown(d =>
|
||||
d
|
||||
.addOption('pie', 'Pie')
|
||||
.addOption('doughnut', 'Doughnut')
|
||||
.addOption('bar', 'Bar')
|
||||
.addOption('line', 'Line')
|
||||
.setValue(this.plugin.settings.defaultChartType)
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.defaultChartType = v as ChartType;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Auto-refresh charts')
|
||||
.setDesc('Re-render charts when the source note is modified.')
|
||||
.addToggle(t =>
|
||||
t.setValue(this.plugin.settings.autoRefresh).onChange(async v => {
|
||||
this.plugin.settings.autoRefresh = v;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Max chart width (px)')
|
||||
.setDesc('Maximum width of rendered charts.')
|
||||
.addSlider(s =>
|
||||
s
|
||||
.setLimits(200, 1200, 50)
|
||||
.setValue(this.plugin.settings.chartWidth)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async v => {
|
||||
this.plugin.settings.chartWidth = v;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Show refresh button')
|
||||
.setDesc('Display a manual refresh button on each chart.')
|
||||
.addToggle(t =>
|
||||
t.setValue(this.plugin.settings.showRefreshButton).onChange(async v => {
|
||||
this.plugin.settings.showRefreshButton = v;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
src/templates/chartTemplates.ts
Normal file
67
src/templates/chartTemplates.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { ChartConfig } from '../types';
|
||||
|
||||
export interface ChartTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
config: Omit<ChartConfig, 'meta'>;
|
||||
}
|
||||
|
||||
export const CHART_TEMPLATES: ChartTemplate[] = [
|
||||
{
|
||||
id: 'task-completion',
|
||||
name: 'Task completion',
|
||||
description: 'Pie chart showing completed vs remaining tasks',
|
||||
config: {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: ['Completed', 'Remaining'],
|
||||
datasets: [{ label: 'Tasks', data: [0, 0], backgroundColor: ['#4c9be8', '#e86c4c'] }],
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'top' } } },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'weekly-progress',
|
||||
name: 'Weekly progress',
|
||||
description: 'Line chart for tracking values over a week',
|
||||
config: {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
||||
datasets: [{ label: 'Value', data: [0, 0, 0, 0, 0, 0, 0], fill: true, tension: 0.4 }],
|
||||
},
|
||||
options: { responsive: true, scales: { y: { beginAtZero: true } } },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'category-comparison',
|
||||
name: 'Category comparison',
|
||||
description: 'Bar chart comparing categories',
|
||||
config: {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: ['Category A', 'Category B', 'Category C'],
|
||||
datasets: [{ label: 'Value', data: [0, 0, 0] }],
|
||||
},
|
||||
options: { responsive: true, scales: { y: { beginAtZero: true } } },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'distribution',
|
||||
name: 'Distribution',
|
||||
description: 'Doughnut chart for showing proportions',
|
||||
config: {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Part A', 'Part B', 'Part C'],
|
||||
datasets: [{ label: 'Share', data: [33, 33, 34] }],
|
||||
},
|
||||
options: { responsive: true, plugins: { legend: { position: 'right' } } },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function getTemplate(id: string): ChartTemplate | undefined {
|
||||
return CHART_TEMPLATES.find(t => t.id === id);
|
||||
}
|
||||
47
src/types.ts
Normal file
47
src/types.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
export type PatternType = 'checkbox' | 'table' | 'json' | 'keyvalue';
|
||||
export type ChartType = 'pie' | 'bar' | 'line' | 'doughnut';
|
||||
|
||||
export interface ParsedData {
|
||||
type: PatternType;
|
||||
labels: string[];
|
||||
values: number[];
|
||||
metadata: {
|
||||
title?: string;
|
||||
totalItems: number;
|
||||
columns?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChartDataset {
|
||||
label: string;
|
||||
data: number[];
|
||||
backgroundColor?: string[];
|
||||
borderColor?: string[];
|
||||
borderWidth?: number;
|
||||
fill?: boolean;
|
||||
tension?: number;
|
||||
}
|
||||
|
||||
export interface ChartConfig {
|
||||
type: ChartType;
|
||||
data: {
|
||||
labels: string[];
|
||||
datasets: ChartDataset[];
|
||||
};
|
||||
options?: Record<string, unknown>;
|
||||
meta?: {
|
||||
source?: {
|
||||
file?: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
};
|
||||
generated?: string;
|
||||
patternType?: PatternType;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Parser {
|
||||
name: string;
|
||||
canParse(text: string): boolean;
|
||||
parse(text: string): ParsedData;
|
||||
}
|
||||
65
src/ui/chartPreviewModal.ts
Normal file
65
src/ui/chartPreviewModal.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { App, Modal } from 'obsidian';
|
||||
import { ChartConfig, ChartType, ParsedData } from '../types';
|
||||
import { KPICalculator } from '../kpi/calculator';
|
||||
import { ChartRenderer } from '../renderer/chartRenderer';
|
||||
|
||||
const CHART_TYPES: ChartType[] = ['pie', 'doughnut', 'bar', 'line'];
|
||||
|
||||
export class ChartPreviewModal extends Modal {
|
||||
private currentType: ChartType;
|
||||
private previewContainer!: HTMLElement;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private parsed: ParsedData,
|
||||
private defaultType: ChartType,
|
||||
private onInsert: (config: ChartConfig) => void,
|
||||
) {
|
||||
super(app);
|
||||
this.currentType = defaultType;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.createEl('h3', { text: 'Preview chart' });
|
||||
|
||||
// Type selector tabs
|
||||
const tabs = contentEl.createDiv({ cls: 'chartspark-tabs' });
|
||||
for (const type of CHART_TYPES) {
|
||||
const tab = tabs.createEl('button', {
|
||||
cls: 'chartspark-tab' + (type === this.currentType ? ' is-active' : ''),
|
||||
text: type.charAt(0).toUpperCase() + type.slice(1),
|
||||
});
|
||||
tab.addEventListener('click', () => {
|
||||
tabs.querySelectorAll('.chartspark-tab').forEach(t => t.removeClass('is-active'));
|
||||
tab.addClass('is-active');
|
||||
this.currentType = type;
|
||||
this.renderPreview();
|
||||
});
|
||||
}
|
||||
|
||||
this.previewContainer = contentEl.createDiv({ cls: 'chartspark-preview' });
|
||||
this.renderPreview();
|
||||
|
||||
const footer = contentEl.createDiv({ cls: 'chartspark-modal-footer' });
|
||||
const insertBtn = footer.createEl('button', {
|
||||
cls: 'mod-cta',
|
||||
text: 'Insert chart',
|
||||
});
|
||||
insertBtn.addEventListener('click', () => {
|
||||
const config = new KPICalculator().buildChartConfig(this.parsed, this.currentType);
|
||||
this.close();
|
||||
this.onInsert(config);
|
||||
});
|
||||
}
|
||||
|
||||
private renderPreview(): void {
|
||||
const config = new KPICalculator().buildChartConfig(this.parsed, this.currentType);
|
||||
new ChartRenderer().render(config, this.previewContainer);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
40
src/ui/chartTypeModal.ts
Normal file
40
src/ui/chartTypeModal.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { App, Modal } from 'obsidian';
|
||||
import { ChartType } from '../types';
|
||||
|
||||
const CHART_OPTIONS: { type: ChartType; label: string; icon: string }[] = [
|
||||
{ type: 'pie', label: 'Pie', icon: '🥧' },
|
||||
{ type: 'doughnut', label: 'Doughnut', icon: '🍩' },
|
||||
{ type: 'bar', label: 'Bar', icon: '📊' },
|
||||
{ type: 'line', label: 'Line', icon: '📈' },
|
||||
];
|
||||
|
||||
export class ChartTypeModal extends Modal {
|
||||
private selected: ChartType | null = null;
|
||||
|
||||
constructor(app: App, private onSelect: (type: ChartType) => void) {
|
||||
super(app);
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.createEl('h3', { text: 'Select chart type' });
|
||||
|
||||
const grid = contentEl.createDiv({ cls: 'chartspark-chart-type-grid' });
|
||||
|
||||
for (const option of CHART_OPTIONS) {
|
||||
const btn = grid.createEl('button', { cls: 'chartspark-chart-type-btn' });
|
||||
btn.createSpan({ text: option.icon, cls: 'chartspark-chart-icon' });
|
||||
btn.createSpan({ text: option.label, cls: 'chartspark-chart-label' });
|
||||
btn.addEventListener('click', () => {
|
||||
this.selected = option.type;
|
||||
this.close();
|
||||
this.onSelect(option.type);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
15
src/utils/colors.ts
Normal file
15
src/utils/colors.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
const PALETTE = [
|
||||
'#4c9be8', '#e86c4c', '#4ce87a', '#e8c84c', '#a04ce8',
|
||||
'#4ce8d8', '#e84ca0', '#8ce84c', '#e8944c', '#4c74e8',
|
||||
];
|
||||
|
||||
export function getColors(count: number): string[] {
|
||||
return Array.from({ length: count }, (_, i) => PALETTE[i % PALETTE.length] as string);
|
||||
}
|
||||
|
||||
export function withOpacity(hex: string, opacity: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
20
src/watcher/fileWatcher.ts
Normal file
20
src/watcher/fileWatcher.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { TFile } from 'obsidian';
|
||||
import ChartSparkPlugin from '../main';
|
||||
|
||||
export class FileWatcher {
|
||||
constructor(private plugin: ChartSparkPlugin) {}
|
||||
|
||||
register(): void {
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.vault.on('modify', (file) => {
|
||||
if (!(file instanceof TFile) || file.extension !== 'md') return;
|
||||
if (!this.plugin.settings.autoRefresh) return;
|
||||
// Force re-render of all open leaves so chartspark blocks refresh
|
||||
this.plugin.app.workspace.iterateAllLeaves(leaf => {
|
||||
// @ts-expect-error - rerender is not in the public API
|
||||
leaf.view?.previewMode?.rerender?.(true);
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
163
styles.css
163
styles.css
|
|
@ -1,8 +1,161 @@
|
|||
/*
|
||||
/* ChartSpark plugin styles */
|
||||
|
||||
This CSS file will be included with your plugin, and
|
||||
available in the app when your plugin is enabled.
|
||||
.chartspark-delete-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
z-index: 10;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
overflow: visible;
|
||||
transition: opacity 0.15s, background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
If your plugin does not need CSS, delete this file.
|
||||
.chartspark-container:hover .chartspark-delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
*/
|
||||
.chartspark-delete-btn:hover {
|
||||
background: var(--background-modifier-error);
|
||||
color: var(--text-error);
|
||||
border-color: var(--background-modifier-error);
|
||||
}
|
||||
|
||||
.chartspark-container {
|
||||
position: relative;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background: var(--background-secondary);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.chartspark-sizer {
|
||||
position: relative;
|
||||
height: 350px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chartspark-canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chartspark-controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.chartspark-refresh-btn {
|
||||
font-size: 0.8em;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
background: var(--interactive-normal);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chartspark-refresh-btn:hover {
|
||||
background: var(--interactive-hover);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.chartspark-error {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
background: var(--background-modifier-error);
|
||||
color: var(--text-error);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Chart type modal grid */
|
||||
.chartspark-chart-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.chartspark-chart-type-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.2rem 1rem;
|
||||
min-height: 90px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.95em;
|
||||
line-height: 1.2;
|
||||
color: var(--text-normal);
|
||||
overflow: visible;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chartspark-chart-type-btn:hover {
|
||||
background: var(--interactive-hover);
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.chartspark-chart-icon {
|
||||
font-size: 2em;
|
||||
line-height: 1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chartspark-chart-label {
|
||||
display: block;
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Preview modal */
|
||||
.chartspark-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.chartspark-tab {
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chartspark-tab.is-active {
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.chartspark-preview {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.chartspark-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
|
|
|||
6
tests/__mocks__/obsidian.ts
Normal file
6
tests/__mocks__/obsidian.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Minimal obsidian mock for unit tests
|
||||
export class Plugin {}
|
||||
export class Modal { open() {} close() {} }
|
||||
export class Notice { constructor(_msg: string, _timeout?: number) {} hide() {} }
|
||||
export class PluginSettingTab {}
|
||||
export class Setting { setName() { return this; } setDesc() { return this; } addText() { return this; } addDropdown() { return this; } addToggle() { return this; } addSlider() { return this; } }
|
||||
41
tests/kpi/calculator.test.ts
Normal file
41
tests/kpi/calculator.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { KPICalculator } from '../../src/kpi/calculator';
|
||||
import { ParsedData } from '../../src/types';
|
||||
|
||||
describe('KPICalculator', () => {
|
||||
const calc = new KPICalculator();
|
||||
|
||||
const mockData: ParsedData = {
|
||||
type: 'checkbox',
|
||||
labels: ['Completed', 'Remaining'],
|
||||
values: [7, 3],
|
||||
metadata: { totalItems: 10 },
|
||||
};
|
||||
|
||||
it('builds a pie config with correct labels and values', () => {
|
||||
const config = calc.buildChartConfig(mockData, 'pie');
|
||||
expect(config.type).toBe('pie');
|
||||
expect(config.data.labels).toEqual(['Completed', 'Remaining']);
|
||||
expect(config.data.datasets[0]?.data).toEqual([7, 3]);
|
||||
});
|
||||
|
||||
it('builds a bar config', () => {
|
||||
const config = calc.buildChartConfig(mockData, 'bar');
|
||||
expect(config.type).toBe('bar');
|
||||
});
|
||||
|
||||
it('builds a line config with fill and tension', () => {
|
||||
const config = calc.buildChartConfig(mockData, 'line');
|
||||
expect(config.type).toBe('line');
|
||||
expect(config.data.datasets[0]?.fill).toBe(true);
|
||||
});
|
||||
|
||||
it('includes meta.generated timestamp', () => {
|
||||
const config = calc.buildChartConfig(mockData, 'pie');
|
||||
expect(config.meta?.generated).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes correct number of background colors', () => {
|
||||
const config = calc.buildChartConfig(mockData, 'pie');
|
||||
expect(config.data.datasets[0]?.backgroundColor).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
49
tests/parsers/checkboxParser.test.ts
Normal file
49
tests/parsers/checkboxParser.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { CheckboxParser } from '../../src/parsers/checkboxParser';
|
||||
|
||||
describe('CheckboxParser', () => {
|
||||
const parser = new CheckboxParser();
|
||||
|
||||
describe('canParse', () => {
|
||||
it('detects a checkbox list', () => {
|
||||
expect(parser.canParse('- [x] Done\n- [ ] Todo')).toBe(true);
|
||||
});
|
||||
it('detects uppercase X', () => {
|
||||
expect(parser.canParse('- [X] Done')).toBe(true);
|
||||
});
|
||||
it('rejects plain text', () => {
|
||||
expect(parser.canParse('some text')).toBe(false);
|
||||
});
|
||||
it('rejects a markdown table', () => {
|
||||
expect(parser.canParse('| A | B |\n|---|---|\n| 1 | 2 |')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse', () => {
|
||||
it('counts completed and remaining correctly', () => {
|
||||
const text = '- [x] Task 1\n- [x] Task 2\n- [ ] Task 3';
|
||||
const result = parser.parse(text);
|
||||
expect(result.values[0]).toBe(2); // completed
|
||||
expect(result.values[1]).toBe(1); // remaining
|
||||
expect(result.labels).toEqual(['Completed', 'Remaining']);
|
||||
});
|
||||
|
||||
it('handles all checked', () => {
|
||||
const text = '- [x] A\n- [x] B';
|
||||
const result = parser.parse(text);
|
||||
expect(result.values[0]).toBe(2);
|
||||
expect(result.values[1]).toBe(0);
|
||||
});
|
||||
|
||||
it('handles all unchecked', () => {
|
||||
const text = '- [ ] A\n- [ ] B\n- [ ] C';
|
||||
const result = parser.parse(text);
|
||||
expect(result.values[0]).toBe(0);
|
||||
expect(result.values[1]).toBe(3);
|
||||
});
|
||||
|
||||
it('sets metadata.totalItems', () => {
|
||||
const text = '- [x] A\n- [ ] B';
|
||||
expect(parser.parse(text).metadata.totalItems).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
44
tests/parsers/jsonParser.test.ts
Normal file
44
tests/parsers/jsonParser.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { JsonParser } from '../../src/parsers/jsonParser';
|
||||
|
||||
describe('JsonParser', () => {
|
||||
const parser = new JsonParser();
|
||||
|
||||
describe('canParse', () => {
|
||||
it('detects a JSON object', () => {
|
||||
expect(parser.canParse('{"a": 1, "b": 2}')).toBe(true);
|
||||
});
|
||||
it('detects a JSON array', () => {
|
||||
expect(parser.canParse('[1, 2, 3]')).toBe(true);
|
||||
});
|
||||
it('rejects invalid JSON', () => {
|
||||
expect(parser.canParse('{broken json')).toBe(false);
|
||||
});
|
||||
it('rejects plain text', () => {
|
||||
expect(parser.canParse('hello')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse object', () => {
|
||||
it('extracts numeric keys as labels/values', () => {
|
||||
const result = parser.parse('{"Sales": 100, "Returns": 20}');
|
||||
expect(result.labels).toEqual(['Sales', 'Returns']);
|
||||
expect(result.values).toEqual([100, 20]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse array of numbers', () => {
|
||||
it('generates Item labels', () => {
|
||||
const result = parser.parse('[10, 20, 30]');
|
||||
expect(result.labels).toEqual(['Item 1', 'Item 2', 'Item 3']);
|
||||
expect(result.values).toEqual([10, 20, 30]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse array of objects', () => {
|
||||
it('finds label and value keys', () => {
|
||||
const result = parser.parse('[{"name":"A","count":5},{"name":"B","count":8}]');
|
||||
expect(result.labels).toEqual(['A', 'B']);
|
||||
expect(result.values).toEqual([5, 8]);
|
||||
});
|
||||
});
|
||||
});
|
||||
31
tests/parsers/kvParser.test.ts
Normal file
31
tests/parsers/kvParser.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { KVParser } from '../../src/parsers/kvParser';
|
||||
|
||||
describe('KVParser', () => {
|
||||
const parser = new KVParser();
|
||||
|
||||
describe('canParse', () => {
|
||||
it('detects colon-separated key:value list', () => {
|
||||
expect(parser.canParse('Revenue: 500\nCost: 300\nProfit: 200')).toBe(true);
|
||||
});
|
||||
it('detects equals-separated list', () => {
|
||||
expect(parser.canParse('a = 1\nb = 2\nc = 3')).toBe(true);
|
||||
});
|
||||
it('rejects mostly non-numeric lines', () => {
|
||||
expect(parser.canParse('hello world\nfoo bar')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse', () => {
|
||||
it('extracts labels and values', () => {
|
||||
const result = parser.parse('Revenue: 500\nCost: 300');
|
||||
expect(result.labels).toEqual(['Revenue', 'Cost']);
|
||||
expect(result.values).toEqual([500, 300]);
|
||||
});
|
||||
|
||||
it('handles decimal values', () => {
|
||||
const result = parser.parse('Rate: 3.14\nGrowth: 2.71');
|
||||
expect(result.values[0]).toBeCloseTo(3.14);
|
||||
expect(result.values[1]).toBeCloseTo(2.71);
|
||||
});
|
||||
});
|
||||
});
|
||||
30
tests/parsers/parserRegistry.test.ts
Normal file
30
tests/parsers/parserRegistry.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { ParserRegistry } from '../../src/parsers/parserRegistry';
|
||||
|
||||
describe('ParserRegistry', () => {
|
||||
const registry = new ParserRegistry();
|
||||
|
||||
it('finds CheckboxParser for checkbox text', () => {
|
||||
const p = registry.findParser('- [x] Done\n- [ ] Todo');
|
||||
expect(p?.name).toBe('checkbox');
|
||||
});
|
||||
|
||||
it('finds TableParser for markdown table', () => {
|
||||
const p = registry.findParser('| A | B |\n|---|---|\n| 1 | 2 |');
|
||||
expect(p?.name).toBe('table');
|
||||
});
|
||||
|
||||
it('finds JsonParser for JSON', () => {
|
||||
const p = registry.findParser('{"a": 1}');
|
||||
expect(p?.name).toBe('json');
|
||||
});
|
||||
|
||||
it('finds KVParser for key-value text', () => {
|
||||
const p = registry.findParser('Sales: 100\nReturns: 20\nProfit: 80');
|
||||
expect(p?.name).toBe('keyvalue');
|
||||
});
|
||||
|
||||
it('returns null for unrecognized text', () => {
|
||||
const p = registry.findParser('just some random prose that has no pattern');
|
||||
expect(p).toBeNull();
|
||||
});
|
||||
});
|
||||
31
tests/parsers/tableParser.test.ts
Normal file
31
tests/parsers/tableParser.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { TableParser } from '../../src/parsers/tableParser';
|
||||
|
||||
describe('TableParser', () => {
|
||||
const parser = new TableParser();
|
||||
|
||||
const TABLE = '| Item | Sales |\n|------|-------|\n| Apples | 120 |\n| Bananas | 95 |';
|
||||
|
||||
describe('canParse', () => {
|
||||
it('detects a markdown table', () => {
|
||||
expect(parser.canParse(TABLE)).toBe(true);
|
||||
});
|
||||
it('rejects plain text', () => {
|
||||
expect(parser.canParse('hello world')).toBe(false);
|
||||
});
|
||||
it('accepts table without separator row (Live Preview selections omit it)', () => {
|
||||
expect(parser.canParse('| A | B |\n| 1 | 2 |')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse', () => {
|
||||
it('extracts labels and numeric values', () => {
|
||||
const result = parser.parse(TABLE);
|
||||
expect(result.labels).toEqual(['Apples', 'Bananas']);
|
||||
expect(result.values).toEqual([120, 95]);
|
||||
});
|
||||
|
||||
it('sets totalItems', () => {
|
||||
expect(parser.parse(TABLE).metadata.totalItems).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
51
tests/scanner/dataAggregator.test.ts
Normal file
51
tests/scanner/dataAggregator.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { DataAggregator } from '../../src/scanner/dataAggregator';
|
||||
import { ScanMatch } from '../../src/scanner/vaultScanner';
|
||||
import { ParsedData } from '../../src/types';
|
||||
|
||||
function makeMatch(parsed: ParsedData): ScanMatch {
|
||||
return { file: {} as never, parsed, section: '' };
|
||||
}
|
||||
|
||||
describe('DataAggregator', () => {
|
||||
const agg = new DataAggregator();
|
||||
|
||||
it('returns empty result for no matches', () => {
|
||||
const result = agg.aggregate([]);
|
||||
expect(result.labels).toEqual([]);
|
||||
expect(result.values).toEqual([]);
|
||||
});
|
||||
|
||||
it('aggregates checkbox matches by summing completed/remaining', () => {
|
||||
const matches = [
|
||||
makeMatch({ type: 'checkbox', labels: ['Completed', 'Remaining'], values: [3, 2], metadata: { totalItems: 5 } }),
|
||||
makeMatch({ type: 'checkbox', labels: ['Completed', 'Remaining'], values: [1, 4], metadata: { totalItems: 5 } }),
|
||||
];
|
||||
const result = agg.aggregate(matches);
|
||||
expect(result.values[0]).toBe(4); // 3+1 completed
|
||||
expect(result.values[1]).toBe(6); // 2+4 remaining
|
||||
});
|
||||
|
||||
it('aggregates numeric matches by label', () => {
|
||||
const matches = [
|
||||
makeMatch({ type: 'keyvalue', labels: ['A', 'B'], values: [10, 20], metadata: { totalItems: 2 } }),
|
||||
makeMatch({ type: 'keyvalue', labels: ['A', 'C'], values: [5, 15], metadata: { totalItems: 2 } }),
|
||||
];
|
||||
const result = agg.aggregate(matches);
|
||||
const aIdx = result.labels.indexOf('A');
|
||||
const bIdx = result.labels.indexOf('B');
|
||||
const cIdx = result.labels.indexOf('C');
|
||||
expect(result.values[aIdx]).toBe(15); // 10+5
|
||||
expect(result.values[bIdx]).toBe(20);
|
||||
expect(result.values[cIdx]).toBe(15);
|
||||
});
|
||||
|
||||
it('filters by targetType', () => {
|
||||
const matches = [
|
||||
makeMatch({ type: 'checkbox', labels: ['Completed', 'Remaining'], values: [2, 1], metadata: { totalItems: 3 } }),
|
||||
makeMatch({ type: 'keyvalue', labels: ['X'], values: [99], metadata: { totalItems: 1 } }),
|
||||
];
|
||||
const result = agg.aggregate(matches, 'checkbox');
|
||||
expect(result.type).toBe('checkbox');
|
||||
expect(result.values[0]).toBe(2);
|
||||
});
|
||||
});
|
||||
34
tests/templates/chartTemplates.test.ts
Normal file
34
tests/templates/chartTemplates.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { CHART_TEMPLATES, getTemplate } from '../../src/templates/chartTemplates';
|
||||
|
||||
describe('CHART_TEMPLATES', () => {
|
||||
it('contains at least 4 templates', () => {
|
||||
expect(CHART_TEMPLATES.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('every template has required fields', () => {
|
||||
for (const t of CHART_TEMPLATES) {
|
||||
expect(t.id).toBeTruthy();
|
||||
expect(t.name).toBeTruthy();
|
||||
expect(t.description).toBeTruthy();
|
||||
expect(t.config.type).toMatch(/^(pie|bar|line|doughnut)$/);
|
||||
expect(t.config.data.labels.length).toBeGreaterThan(0);
|
||||
expect(t.config.data.datasets.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('all template ids are unique', () => {
|
||||
const ids = CHART_TEMPLATES.map(t => t.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTemplate', () => {
|
||||
it('finds template by id', () => {
|
||||
const t = getTemplate('task-completion');
|
||||
expect(t?.name).toBe('Task completion');
|
||||
});
|
||||
|
||||
it('returns undefined for unknown id', () => {
|
||||
expect(getTemplate('nonexistent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
32
tests/utils/colors.test.ts
Normal file
32
tests/utils/colors.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { getColors, withOpacity } from '../../src/utils/colors';
|
||||
|
||||
describe('getColors', () => {
|
||||
it('returns the requested number of colors', () => {
|
||||
expect(getColors(3)).toHaveLength(3);
|
||||
expect(getColors(10)).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('wraps around palette for counts > palette size', () => {
|
||||
const colors = getColors(12);
|
||||
expect(colors).toHaveLength(12);
|
||||
colors.forEach(c => expect(c).toMatch(/^#[0-9a-f]{6}$/i));
|
||||
});
|
||||
|
||||
it('returns hex strings', () => {
|
||||
getColors(5).forEach(c => expect(c).toMatch(/^#[0-9a-f]{6}$/i));
|
||||
});
|
||||
});
|
||||
|
||||
describe('withOpacity', () => {
|
||||
it('converts hex to rgba with correct opacity', () => {
|
||||
expect(withOpacity('#4c9be8', 0.5)).toMatch(/^rgba\(76, 155, 232, 0\.5\)$/);
|
||||
});
|
||||
|
||||
it('handles opacity 1', () => {
|
||||
expect(withOpacity('#ff0000', 1)).toBe('rgba(255, 0, 0, 1)');
|
||||
});
|
||||
|
||||
it('handles opacity 0', () => {
|
||||
expect(withOpacity('#000000', 0)).toBe('rgba(0, 0, 0, 0)');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue