rendering works

This commit is contained in:
Pooyash1998 2026-04-26 08:43:06 +02:00
parent 8a1e7908ab
commit a5676428f9
40 changed files with 6044 additions and 190 deletions

36
.github/workflows/release.yml vendored Normal file
View 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
View file

@ -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** — 2001200 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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View 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;
},
});
}

View 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);
}

View 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();
},
});
}

View 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
View 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
View 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 } } }
: {}),
};
}
}

View file

@ -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,
));
}
}

View 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
View 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
View 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 },
};
}
}

View 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];
}
}

View 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;
}
}

View 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);
}
}

View 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}` });
}
}

View 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' },
};
}
}

View 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
View 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;
}
}

View file

@ -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();
})
);
}
}

View 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
View 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;
}

View 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
View 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
View 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})`;
}

View 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);
});
})
);
}
}

View file

@ -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;
}

View 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; } }

View 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);
});
});

View 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);
});
});
});

View 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]);
});
});
});

View 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);
});
});
});

View 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();
});
});

View 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);
});
});
});

View 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);
});
});

View 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();
});
});

View 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)');
});
});