diff --git a/main.ts b/main.ts index 015f0f3..d96b246 100644 --- a/main.ts +++ b/main.ts @@ -13,7 +13,7 @@ import { import { DBService } from "./src/dbService"; import { inspectTableStructure, convertEntriesInNotes } from "./src/commands"; -import { pickTableName, processSqlBlock } from "./src/helpers"; +import { pickTableName, processSqlBlock, processSqlChartBlock } from "./src/helpers"; import { SqliteDBSettings, DEFAULT_SETTINGS } from "./src/types"; export default class SqliteDBPlugin extends Plugin { @@ -62,6 +62,13 @@ export default class SqliteDBPlugin extends Plugin { } ); + this.registerMarkdownCodeBlockProcessor( + "sql-chart", + async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => { + await processSqlChartBlock(this.dbService, source, el); + } + ); + this.addSettingTab(new SqliteDBSettingTab(this.app, this)); } diff --git a/src/helpers/index.ts b/src/helpers/index.ts index 69fadeb..a27cbd4 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -1,4 +1,5 @@ import { pickTableName } from "./pickTableName"; import { processSqlBlock } from "./processSqlBlock"; +import { processSqlChartBlock } from "./processSqlChartBlock/index"; -export { pickTableName, processSqlBlock }; +export { pickTableName, processSqlBlock, processSqlChartBlock }; diff --git a/src/helpers/processSqlChartBlock/charts/BarChart.ts b/src/helpers/processSqlChartBlock/charts/BarChart.ts new file mode 100644 index 0000000..6a8e94d --- /dev/null +++ b/src/helpers/processSqlChartBlock/charts/BarChart.ts @@ -0,0 +1,29 @@ +import { getColorForIndex } from "../helpers/getColorForIndex"; + +interface BarChartOptions { + stacked?: boolean; +} + +export function createBarChart(labels: string[], datasets: any[], options?: BarChartOptions) { + return { + type: 'bar', + data: { + labels, + datasets: datasets.map((dataset, index) => ({ + ...dataset, + backgroundColor: getColorForIndex(index, 0.6), + })) + }, + options: { + scales: { + y: { + beginAtZero: true, + stacked: options?.stacked + }, + x: { + stacked: options?.stacked + } + } + } + }; +} \ No newline at end of file diff --git a/src/helpers/processSqlChartBlock/charts/LineChart.ts b/src/helpers/processSqlChartBlock/charts/LineChart.ts new file mode 100644 index 0000000..5950d25 --- /dev/null +++ b/src/helpers/processSqlChartBlock/charts/LineChart.ts @@ -0,0 +1,124 @@ +interface LineChartOptions { + fill?: boolean; + tension?: number; + pointRadius?: number; + pointHoverRadius?: number; + showLegend?: boolean; + yAxisMin?: number; + yAxisMax?: number; + tooltips?: boolean; + animations?: boolean; +} + +export function createLineChart(labels: string[], datasets: any[], options: LineChartOptions = {}) { + const defaultOptions: LineChartOptions = { + fill: false, + tension: 0.3, + pointRadius: 3, + pointHoverRadius: 5, + showLegend: true, + tooltips: true, + animations: true + }; + + const chartOptions = { ...defaultOptions, ...options }; + + return { + type: 'line', + data: { + labels, + datasets: datasets.map((dataset, index) => ({ + ...dataset, + fill: chartOptions.fill, + tension: chartOptions.tension, + pointRadius: chartOptions.pointRadius, + pointHoverRadius: chartOptions.pointHoverRadius, + borderWidth: 2, + // Point styling + pointBackgroundColor: dataset.borderColor, + pointBorderColor: dataset.borderColor, + pointBorderWidth: 1, + // Hover effects + hoverBorderWidth: 2, + hoverBorderColor: dataset.borderColor, + })) + }, + options: { + responsive: true, + // maintainAspectRatio: false, + interaction: { + intersect: false, + mode: 'index', + }, + animations: chartOptions.animations ? { + tension: { + duration: 1000, + easing: 'linear', + from: 0.4, + to: 0.3, + } + } : false, + plugins: { + legend: { + display: chartOptions.showLegend, + position: 'top' as const, + labels: { + usePointStyle: true, + padding: 15, + font: { + size: 12 + } + } + }, + tooltip: { + enabled: chartOptions.tooltips, + backgroundColor: 'rgba(0, 0, 0, 0.8)', + titleFont: { + size: 13 + }, + bodyFont: { + size: 12 + }, + padding: 10, + cornerRadius: 4, + displayColors: true + } + }, + scales: { + x: { + grid: { + display: true, + drawBorder: true, + drawOnChartArea: true, + drawTicks: true, + color: 'rgba(0, 0, 0, 0.1)' + }, + ticks: { + font: { + size: 11 + }, + maxRotation: 45, + minRotation: 45 + } + }, + y: { + beginAtZero: true, + min: chartOptions.yAxisMin, + max: chartOptions.yAxisMax, + grid: { + display: true, + drawBorder: true, + drawOnChartArea: true, + drawTicks: true, + color: 'rgba(0, 0, 0, 0.1)' + }, + ticks: { + font: { + size: 11 + } + } + } + } + } + }; +} \ No newline at end of file diff --git a/src/helpers/processSqlChartBlock/charts/index.ts b/src/helpers/processSqlChartBlock/charts/index.ts new file mode 100644 index 0000000..0e712d1 --- /dev/null +++ b/src/helpers/processSqlChartBlock/charts/index.ts @@ -0,0 +1,16 @@ +import { createLineChart } from './LineChart'; +import { createBarChart } from './BarChart'; + +export type ChartType = 'line' | 'bar' | 'scatter' | 'pie'; + +export function createChart(type: ChartType, labels: string[], datasets: any[], options?: any) { + switch (type) { + case 'line': + return createLineChart(labels, datasets, options); + case 'bar': + return createBarChart(labels, datasets, options); + //todo add pie and others + default: + return createLineChart(labels, datasets); // fallback to line chart + } +} \ No newline at end of file diff --git a/src/helpers/processSqlChartBlock/helpers/buildSqlQuery.ts b/src/helpers/processSqlChartBlock/helpers/buildSqlQuery.ts new file mode 100644 index 0000000..91aae34 --- /dev/null +++ b/src/helpers/processSqlChartBlock/helpers/buildSqlQuery.ts @@ -0,0 +1,34 @@ +import { ChartConfig } from "./parseChartParams"; + +export function buildSqlQuery(config: ChartConfig) { + let query, params = []; + + if (config.groupBy) { + query = ` + SELECT + ${config.xColumn}, + ${config.groupBy}, + ${config.yColumns.join(', ')} + FROM "${config.table}" + `; + } else { + query = ` + SELECT + ${config.xColumn}, + ${config.yColumns.join(', ')} + FROM "${config.table}" + `; + } + + if (config.startDate && config.endDate) { + query += ` WHERE ${config.xColumn} BETWEEN ? AND ?`; + params.push(config.startDate, config.endDate); + } + + query += ` ORDER BY ${config.xColumn}`; + if (config.groupBy) { + query += `, ${config.groupBy}`; + } + + return { query, params }; +} \ No newline at end of file diff --git a/src/helpers/processSqlChartBlock/helpers/getColorForIndex.ts b/src/helpers/processSqlChartBlock/helpers/getColorForIndex.ts new file mode 100644 index 0000000..d4901d8 --- /dev/null +++ b/src/helpers/processSqlChartBlock/helpers/getColorForIndex.ts @@ -0,0 +1,11 @@ +export function getColorForIndex(index: number, alpha: number = 1): string { + const colors = [ + `rgba(255, 99, 132, ${alpha})`, // red + `rgba(54, 162, 235, ${alpha})`, // blue + `rgba(75, 192, 192, ${alpha})`, // green + `rgba(255, 206, 86, ${alpha})`, // yellow + `rgba(153, 102, 255, ${alpha})`, // purple + `rgba(255, 159, 64, ${alpha})`, // orange + ]; + return colors[index % colors.length]; +} \ No newline at end of file diff --git a/src/helpers/processSqlChartBlock/helpers/index.ts b/src/helpers/processSqlChartBlock/helpers/index.ts new file mode 100644 index 0000000..9c93229 --- /dev/null +++ b/src/helpers/processSqlChartBlock/helpers/index.ts @@ -0,0 +1,6 @@ +import { parseChartParams } from "./parseChartParams"; +import { validateColumns } from "./validateColumns"; +import { buildSqlQuery } from "./buildSqlQuery"; +import { processChartData } from "./processChartData"; + +export { parseChartParams, validateColumns, buildSqlQuery, processChartData }; \ No newline at end of file diff --git a/src/helpers/processSqlChartBlock/helpers/parseChartParams.ts b/src/helpers/processSqlChartBlock/helpers/parseChartParams.ts new file mode 100644 index 0000000..3d66fea --- /dev/null +++ b/src/helpers/processSqlChartBlock/helpers/parseChartParams.ts @@ -0,0 +1,82 @@ +export interface ChartOptions { + fill?: boolean; + tension?: number; + pointRadius?: number; + pointHoverRadius?: number; + showLegend?: boolean; + yAxisMin?: number; + yAxisMax?: number; + tooltips?: boolean; + animations?: boolean; +} + +export interface ChartConfig { + table: string; + xColumn: string; + yColumns: string[]; + groupBy?: string; + startDate?: string; + endDate?: string; + chartType: string; + chartOptions?: ChartOptions; +} + +export function parseChartParams(source: string): ChartConfig | null { + const lines = source.split("\n"); + const params: any = {}; + let chartOptions: any = {}; + let inChartOptions = false; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + // Handle chartOptions block + if (trimmed === 'chartOptions: {') { + inChartOptions = true; + continue; + } + if (trimmed === '}') { + inChartOptions = false; + continue; + } + + if (inChartOptions) { + // Parse chartOptions parameters + const optionMatch = trimmed.match(/(\w+):\s*([^,]+),?$/); + if (optionMatch) { + const [, key, valueStr] = optionMatch; + // Convert string values to appropriate types + let value: any = valueStr.trim(); + if (value === 'true') value = true; + else if (value === 'false') value = false; + else if (!isNaN(Number(value))) value = Number(value); + chartOptions[key] = value; + } + continue; + } + + // Handle regular parameters + const parts = trimmed.split(":").map((p) => p.trim()); + if (parts.length >= 2) { + const key = parts[0]; + const val = parts.slice(1).join(":").trim(); + params[key] = val; + } + } + + if (!params.table || !params.xColumn || !params.yColumns) { + return null; + } + + return { + table: params.table, + xColumn: params.xColumn, + yColumns: params.yColumns.split(',').map((c: string) => c.trim()), + groupBy: params.groupBy?.trim(), + startDate: params.startDate, + endDate: params.endDate, + chartType: params.chartType || 'line', + chartOptions: Object.keys(chartOptions).length > 0 ? chartOptions : undefined + }; +} \ No newline at end of file diff --git a/src/helpers/processSqlChartBlock/helpers/processChartData.ts b/src/helpers/processSqlChartBlock/helpers/processChartData.ts new file mode 100644 index 0000000..32150b5 --- /dev/null +++ b/src/helpers/processSqlChartBlock/helpers/processChartData.ts @@ -0,0 +1,58 @@ +import { getColorForIndex } from './getColorForIndex'; +import { ChartConfig } from './parseChartParams'; + +export function processChartData(rowObj: any, config: ChartConfig) { + let labels: any[]; + let datasets: any[]; + + if (config.groupBy) { + const { labels: groupLabels, datasets: groupDatasets } = processGroupedData(rowObj, config); + labels = groupLabels; + datasets = groupDatasets; + } else { + labels = rowObj.values.map((row: any[]) => row[0]); + datasets = processUngroupedData(rowObj, config); + } + + return { labels, datasets }; +} + +function processGroupedData(rowObj: any, config: ChartConfig) { + const groupedData = new Map(); + + rowObj.values.forEach((row: any[]) => { + const x = row[0]; + const group = String(row[1]); + const y = row[2]; + + if (!groupedData.has(group)) { + groupedData.set(group, { x: [], y: [] }); + } + + groupedData.get(group).x.push(x); + groupedData.get(group).y.push(y); + }); + + const labels = Array.from(groupedData.values())[0].x; + const datasets = Array.from(groupedData.entries()).map(([group, data], index) => ({ + label: group, + data: data.y, + borderColor: getColorForIndex(index), + backgroundColor: getColorForIndex(index, 0.2), + fill: false, + tension: 0.1 + })); + + return { labels, datasets }; +} + +function processUngroupedData(rowObj: any, config: ChartConfig) { + return config.yColumns.map((col, index) => ({ + label: col, + data: rowObj.values.map((row: any[]) => row[index + 1]), + borderColor: getColorForIndex(index), + backgroundColor: getColorForIndex(index, 0.2), + fill: false, + tension: 0.1 + })); +} \ No newline at end of file diff --git a/src/helpers/processSqlChartBlock/helpers/validateColumns.ts b/src/helpers/processSqlChartBlock/helpers/validateColumns.ts new file mode 100644 index 0000000..7461495 --- /dev/null +++ b/src/helpers/processSqlChartBlock/helpers/validateColumns.ts @@ -0,0 +1,21 @@ +import { ChartConfig } from "./parseChartParams"; + +export async function validateColumns(db: any, config: ChartConfig) { + const tableInfo = db.exec(`PRAGMA table_info("${config.table}");`); + const availableColumns = tableInfo[0].values.map((row: any[]) => row[1]); + + const requestedColumns = [config.xColumn, ...config.yColumns]; + if (config.groupBy) { + requestedColumns.push(config.groupBy); + } + + const invalidColumns = requestedColumns.filter(col => !availableColumns.includes(col)); + if (invalidColumns.length > 0) { + return { + message: `The following columns do not exist: ${invalidColumns.join(", ")}`, + availableColumns + }; + } + + return null; +} \ No newline at end of file diff --git a/src/helpers/processSqlChartBlock/index.ts b/src/helpers/processSqlChartBlock/index.ts new file mode 100644 index 0000000..384cbc2 --- /dev/null +++ b/src/helpers/processSqlChartBlock/index.ts @@ -0,0 +1,55 @@ +import { Notice } from "obsidian"; +import { DBService } from "../../dbService"; +import { parseChartParams, validateColumns, buildSqlQuery, processChartData } from "./helpers"; +import { ChartType, createChart } from "./charts"; + +export async function processSqlChartBlock(dbService: DBService, source: string, el: HTMLElement) { + const db = dbService.getDB(); + if (!db) { + new Notice("No DB loaded. Please open the DB first."); + el.createEl("p", { text: "Database not loaded." }); + return; + } + + const config = parseChartParams(source); + if (!config) { + el.createEl("p", { text: "Required parameters are: table, xColumn, yColumns" }); + return; + } + + try { + const validationError = await validateColumns(db, config); + if (validationError) { + el.createEl("p", { text: validationError.message }); + if (validationError.availableColumns) { + el.createEl("p", { text: `Available columns are: ${validationError.availableColumns.join(", ")}` }); + } + return; + } + + const { query, params } = buildSqlQuery(config); + const result = db.exec(query, params); + + if (!result || result.length === 0 || result[0].values.length === 0) { + el.createEl("p", { text: "No data found for the specified parameters." }); + return; + } + + const { labels, datasets } = processChartData(result[0], config); + + const chartData = createChart( + config.chartType as ChartType, + labels, + datasets, + config.chartOptions + ); + + // @ts-ignore + window.renderChart(chartData, el); + + } catch (error) { + el.createEl("p", { text: "An error occurred while processing the chart." }); + el.createEl("p", { text: String(error) }); + console.error(error); + } +} \ No newline at end of file