diff --git a/.changeset/sour-lies-do.md b/.changeset/sour-lies-do.md new file mode 100644 index 0000000..aece9c4 --- /dev/null +++ b/.changeset/sour-lies-do.md @@ -0,0 +1,5 @@ +--- +"sqlseal-charts": minor +--- + +adding support for global configurations diff --git a/package.json b/package.json index 21d2e7f..df05eaf 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@types/acorn": "^6.0.4", "@types/estree": "^1.0.6", "@types/jest": "^29.5.14", + "@types/json5": "^2.2.0", "@types/node": "^22.13.10", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/parser": "^8.26.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2ad7db..554e14d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: '@types/jest': specifier: ^29.5.14 version: 29.5.14 + '@types/json5': + specifier: ^2.2.0 + version: 2.2.0 '@types/node': specifier: ^22.13.10 version: 22.13.10 @@ -1062,6 +1065,10 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/json5@2.2.0': + resolution: {integrity: sha512-NrVug5woqbvNZ0WX+Gv4R+L4TGddtmFek2u8RtccAgFZWtS9QXF2xCXY22/M4nzkaKF0q9Fc6M/5rxLDhfwc/A==} + deprecated: This is a stub types definition. json5 provides its own type definitions, so you do not need this installed. + '@types/leaflet@1.9.16': resolution: {integrity: sha512-wzZoyySUxkgMZ0ihJ7IaUIblG8Rdc8AbbZKLneyn+QjYsj5q1QU7TEKYqwTr10BGSzY5LI7tJk9Ifo+mEjdFRw==} @@ -3873,6 +3880,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/json5@2.2.0': + dependencies: + json5: 2.2.3 + '@types/leaflet@1.9.16': dependencies: '@types/geojson': 7946.0.16 diff --git a/src/chartRenderer.ts b/src/chartRenderer.ts index 6f915ee..8b94823 100644 --- a/src/chartRenderer.ts +++ b/src/chartRenderer.ts @@ -7,6 +7,8 @@ import type { RendererConfig } from "@hypersphere/sqlseal"; import { ViewDefinition } from "@hypersphere/sqlseal/dist/src/grammar/parser"; import { parseCodeAdvanced } from "./utils/advancedParser"; import { FullScreenChartModal } from "./fullscreenModal"; +import { SQLSealChartsSettings } from "./settings"; +import * as JSON5 from "json5"; interface Config { config: string @@ -18,7 +20,7 @@ echarts.registerTransform((ecStat as any).transform.histogram); export class ChartRenderer implements RendererConfig { - constructor(private readonly app: App) { + constructor(private readonly app: App, private readonly settings: SQLSealChartsSettings) { } get viewDefinition(): ViewDefinition { @@ -51,6 +53,23 @@ export class ChartRenderer implements RendererConfig { }) } + private prepareGlobalConfigVariables(): Record { + const globalVariables: Record = {}; + + this.settings.globalConfigs.forEach(config => { + try { + const parsedConfig = JSON5.parse(config.config); + globalVariables[config.name] = parsedConfig; + } catch (e) { + console.warn(`Failed to parse global configuration '${config.name}' with JSON5:`, e); + // Still make it available as a string if JSON5 parsing fails + globalVariables[config.name] = config.config; + } + }); + + return globalVariables; + } + render(config: Config, el: HTMLElement) { let isRendered: boolean = false let chart: echarts.ECharts | null = null @@ -64,17 +83,21 @@ export class ChartRenderer implements RendererConfig { throw new Error('To process JavaScript, set ADVANCED MODE flag') } const { functions, variables } = prepareDataVariables({ columns, data }) + + // Add global configurations as variables + const globalVariables = this.prepareGlobalConfigVariables() + const allVariables = { ...variables, ...globalVariables } let parsedConfig: Object = {} if (isAdvancedMode) { try { - parsedConfig = parseCodeAdvanced({ functions, variables }, config.config) + parsedConfig = parseCodeAdvanced({ functions, variables: allVariables }, config.config) } catch (e) { console.error(e) throw e } } else { - parsedConfig = parseCode(config.config, functions, variables) as Object + parsedConfig = parseCode(config.config, functions, allVariables) as Object } if (!parsedConfig || typeof parsedConfig !== 'object') { throw new Error('Issue with parsing config') diff --git a/src/main.ts b/src/main.ts index 486b2a0..5d92239 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,8 @@ import { ChartRenderer } from "./chartRenderer"; import { pluginApi } from "@vanakat/plugin-api"; import type { SQLSealApi, SQLSealRegisterApi } from "@hypersphere/sqlseal"; import { uniqBy } from "lodash"; +import { SQLSealChartsSettings, DEFAULT_SETTINGS } from "./settings"; +import { SQLSealChartsSettingTab } from "./settingsTab"; const SQLSEAL_API_KEY = "___sqlSeal"; const SQLSEAL_QUEUED_PLUGINS = "___sqlSeal_queue"; @@ -38,14 +40,30 @@ const registerApi = (plugin: Plugin, fn: (api: SQLSealApi) => void) => { }; export default class SQLSealCharts extends Plugin { + settings: SQLSealChartsSettings; + async onload() { + await this.loadSettings(); + + // Add settings tab + this.addSettingTab(new SQLSealChartsSettingTab(this.app, this)); + registerApi(this, (api) => this.sqlSealRegistered(api)); } sqlSealRegistered(api: SQLSealApi) { - api.registerView("sqlseal-charts", new ChartRenderer(this.app)); + api.registerView("sqlseal-charts", new ChartRenderer(this.app, this.settings)); if ((api as any).apiVersion >= 2) { api.registerFlag({ key: "isAdvancedMode", name: "ADVANCED MODE" }); } } + + async loadSettings() { + const data = await this.loadData(); + this.settings = Object.assign({}, DEFAULT_SETTINGS, data); + } + + async saveSettings() { + await this.saveData(this.settings); + } } diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..941b332 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,37 @@ +export interface GlobalChartConfig { + name: string; + config: string; // JSON string +} + +export interface SQLSealChartsSettings { + globalConfigs: GlobalChartConfig[]; +} + +export const DEFAULT_SETTINGS: SQLSealChartsSettings = { + globalConfigs: [ + { + name: "defaultTheme", + config: `{ + backgroundColor: "#ffffff", + textStyle: { + color: "#333333", + fontFamily: "Arial, sans-serif" + } +}` + }, + { + name: "pieChart", + config: `{ + type: 'pie', + radius: '70%', + emphasis: { + itemStyle: { + shadowBlur: 10, + shadowOffsetX: 0, + shadowColor: 'rgba(0, 0, 0, 0.5)' + } + } +}` + } + ] +}; \ No newline at end of file diff --git a/src/settingsTab.ts b/src/settingsTab.ts new file mode 100644 index 0000000..fb3c019 --- /dev/null +++ b/src/settingsTab.ts @@ -0,0 +1,343 @@ +import { App, PluginSettingTab, Setting } from "obsidian"; +import SQLSealCharts from "./main"; +import { GlobalChartConfig } from "./settings"; +import * as JSON5 from "json5"; + +export class SQLSealChartsSettingTab extends PluginSettingTab { + plugin: SQLSealCharts; + private currentView: 'list' | 'edit' = 'list'; + private editingIndex: number = -1; + + constructor(app: App, plugin: SQLSealCharts) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + containerEl.createEl('h2', { text: 'SQLSeal Charts Settings' }); + + if (this.currentView === 'list') { + this.displayConfigList(); + } else if (this.currentView === 'edit') { + this.displayConfigEditor(); + } + } + + private displayConfigList(): void { + const { containerEl } = this; + + containerEl.createEl('h3', { text: 'Global Chart Configurations' }); + + const descriptionContainer = containerEl.createDiv('sqlseal-description-container'); + descriptionContainer.createEl('p', { + text: 'Define reusable JSON5 configurations that can be accessed as variables in your charts.', + cls: 'setting-item-description' + }); + + const exampleContainer = descriptionContainer.createDiv('sqlseal-example-container'); + exampleContainer.createEl('strong', { text: 'Example Usage:' }); + exampleContainer.createEl('br'); + exampleContainer.createEl('code', { + text: '1. Create config named "theme" with: {backgroundColor: "#1a1a1a"}', + cls: 'sqlseal-example-code' + }); + exampleContainer.createEl('br'); + exampleContainer.createEl('code', { + text: '2. Use in charts: {...theme, title: {text: "My Chart"}}', + cls: 'sqlseal-example-code' + }); + + const featuresContainer = descriptionContainer.createDiv('sqlseal-features-container'); + featuresContainer.createEl('br'); + featuresContainer.createEl('small', { + text: '✨ Supports JSON5: unquoted keys, single quotes, trailing commas, and comments', + cls: 'sqlseal-feature-text' + }); + + + // Add new configuration button + new Setting(containerEl) + .setName('Add New Configuration') + .setDesc('Create a new global configuration') + .addButton(button => { + button + .setButtonText('Add Configuration') + .setCta() + .onClick(() => { + this.editingIndex = -1; // -1 means new config + this.currentView = 'edit'; + this.display(); + }); + }); + + // Create table + const tableContainer = containerEl.createDiv('sqlseal-config-table-container'); + const table = tableContainer.createEl('table', { cls: 'sqlseal-config-table' }); + + // Table header + const thead = table.createEl('thead'); + const headerRow = thead.createEl('tr'); + headerRow.createEl('th', { text: 'Name' }); + headerRow.createEl('th', { text: 'Preview' }); + headerRow.createEl('th', { text: 'Actions' }); + + // Table body + const tbody = table.createEl('tbody'); + + if (this.plugin.settings.globalConfigs.length === 0) { + const emptyRow = tbody.createEl('tr'); + const emptyCell = emptyRow.createEl('td', { + attr: { colspan: '3' }, + cls: 'sqlseal-empty-state', + text: 'No configurations found. Click "Add Configuration" to create one.' + }); + } else { + this.plugin.settings.globalConfigs.forEach((config, index) => { + this.createConfigRow(tbody, config, index); + }); + } + } + + private createConfigRow(tbody: HTMLTableSectionElement, config: GlobalChartConfig, index: number): void { + const row = tbody.createEl('tr'); + + // Name column + const nameCell = row.createEl('td'); + nameCell.createEl('span', { + text: config.name, + cls: 'sqlseal-config-name' + }); + + // Preview column + const previewCell = row.createEl('td'); + const previewText = this.getConfigPreview(config.config); + previewCell.createEl('code', { + text: previewText, + cls: 'sqlseal-config-preview' + }); + + // Actions column + const actionsCell = row.createEl('td', { cls: 'sqlseal-config-actions' }); + + const editButton = actionsCell.createEl('button', { + text: 'Edit', + cls: 'mod-cta sqlseal-action-button' + }); + editButton.addEventListener('click', () => { + this.editingIndex = index; + this.currentView = 'edit'; + this.display(); + }); + + const deleteButton = actionsCell.createEl('button', { + text: 'Delete', + cls: 'mod-warning sqlseal-action-button' + }); + deleteButton.addEventListener('click', async () => { + if (confirm(`Are you sure you want to delete the configuration "${config.name}"?`)) { + this.plugin.settings.globalConfigs.splice(index, 1); + await this.plugin.saveSettings(); + this.display(); + } + }); + } + + private displayConfigEditor(): void { + const { containerEl } = this; + + const isNewConfig = this.editingIndex === -1; + const originalConfig = isNewConfig + ? { name: `config${this.plugin.settings.globalConfigs.length + 1}`, config: '{\n \n}' } + : this.plugin.settings.globalConfigs[this.editingIndex]; + + // Create a working copy to avoid modifying the original until save + const config = { + name: originalConfig.name, + config: originalConfig.config + }; + + console.log('Editor debug - isNewConfig:', isNewConfig); + console.log('Editor debug - editingIndex:', this.editingIndex); + console.log('Editor debug - originalConfig:', originalConfig); + console.log('Editor debug - working config:', config); + console.log('Editor debug - all configs:', this.plugin.settings.globalConfigs); + + // Header with back button + const headerContainer = containerEl.createDiv('sqlseal-editor-header'); + const backButton = headerContainer.createEl('button', { + text: '← Back to List', + cls: 'sqlseal-back-button' + }); + backButton.addEventListener('click', () => { + this.currentView = 'list'; + this.display(); + }); + + headerContainer.createEl('h3', { + text: isNewConfig ? 'Add New Configuration' : `Edit Configuration: ${config.name}` + }); + + // Name input + new Setting(containerEl) + .setName('Configuration Name') + .setDesc('A unique name to identify this configuration') + .addText(text => { + text.setValue(config.name); + text.onChange(async (value) => { + config.name = value; + // Don't auto-save for existing configs since we're using a working copy + }); + // Save on blur + text.inputEl.addEventListener('blur', async () => { + const newValue = text.getValue(); + config.name = newValue; + // Don't auto-save for existing configs since we're using a working copy + }); + }); + + // JSON editor + const jsonContainer = containerEl.createDiv('sqlseal-json-editor-container'); + const labelContainer = jsonContainer.createDiv('sqlseal-json-label-container'); + labelContainer.createEl('label', { + text: 'JSON5 Configuration:', + cls: 'sqlseal-json-label' + }); + labelContainer.createEl('small', { + text: 'Supports unquoted keys, single quotes, trailing commas, and // comments', + cls: 'sqlseal-json-help' + }); + + const jsonTextarea = jsonContainer.createEl('textarea', { + placeholder: `{ + type: 'pie', + radius: '70%', + emphasis: { + itemStyle: { + shadowBlur: 10 + } + } +}`, + cls: 'sqlseal-json-textarea' + }); + + // Set the value separately to ensure it loads properly + console.log('Setting textarea value to:', config.config); + jsonTextarea.value = config.config || ''; + + // Also set it after a short delay to ensure DOM is ready + requestAnimationFrame(() => { + jsonTextarea.value = config.config || ''; + console.log('Textarea value after setting:', jsonTextarea.value); + }); + + // Auto-save on blur and input with debounce + let saveTimeout: NodeJS.Timeout; + const saveConfig = async () => { + try { + // Use JSON5 for more flexible parsing + JSON5.parse(jsonTextarea.value); + jsonTextarea.classList.remove('error'); + jsonTextarea.title = ''; + config.config = jsonTextarea.value; + // Don't auto-save since we're using a working copy + } catch (e) { + jsonTextarea.classList.add('error'); + jsonTextarea.title = `Invalid JSON5: ${(e as Error).message}`; + } + }; + + jsonTextarea.addEventListener('input', () => { + clearTimeout(saveTimeout); + saveTimeout = setTimeout(saveConfig, 500); // Debounce for 500ms + }); + + jsonTextarea.addEventListener('blur', saveConfig); + + // Helper buttons + const helperContainer = containerEl.createDiv('sqlseal-helper-buttons'); + + const formatButton = helperContainer.createEl('button', { + text: 'Format JSON', + cls: 'mod-cta' + }); + + formatButton.addEventListener('click', () => { + try { + // Parse with JSON5 (allows unquoted keys, trailing commas, etc.) + const parsed = JSON5.parse(jsonTextarea.value); + // Format as standard JSON for consistency + const formatted = JSON.stringify(parsed, null, 2); + jsonTextarea.value = formatted; + config.config = formatted; + jsonTextarea.classList.remove('error'); + jsonTextarea.title = ''; + + // Show success feedback + formatButton.textContent = '✓ Formatted'; + formatButton.style.backgroundColor = '#4caf50'; + setTimeout(() => { + formatButton.textContent = 'Format JSON'; + formatButton.style.backgroundColor = ''; + }, 1500); + } catch (e) { + formatButton.textContent = '✗ Invalid JSON5'; + formatButton.style.backgroundColor = '#ff6b6b'; + setTimeout(() => { + formatButton.textContent = 'Format JSON'; + formatButton.style.backgroundColor = ''; + }, 2000); + } + }); + + // Action buttons + const buttonContainer = containerEl.createDiv('sqlseal-editor-buttons'); + + const saveButton = buttonContainer.createEl('button', { + text: isNewConfig ? 'Create Configuration' : 'Save Changes', + cls: 'mod-cta' + }); + + saveButton.addEventListener('click', async () => { + try { + JSON5.parse(config.config); + + if (isNewConfig) { + this.plugin.settings.globalConfigs.push(config); + } else { + // Update the original config with our working copy + this.plugin.settings.globalConfigs[this.editingIndex].name = config.name; + this.plugin.settings.globalConfigs[this.editingIndex].config = config.config; + } + + await this.plugin.saveSettings(); + this.currentView = 'list'; + this.display(); + } catch (e) { + alert(`Invalid JSON5: ${(e as Error).message}`); + } + }); + + const cancelButton = buttonContainer.createEl('button', { + text: 'Cancel', + cls: 'mod-warning' + }); + + cancelButton.addEventListener('click', () => { + this.currentView = 'list'; + this.display(); + }); + } + + private getConfigPreview(configString: string): string { + try { + const parsed = JSON5.parse(configString); + const preview = JSON.stringify(parsed); + return preview.length > 50 ? preview.substring(0, 47) + '...' : preview; + } catch (e) { + return 'Invalid JSON5'; + } + } +} \ No newline at end of file diff --git a/styles.css b/styles.css index 8db2c18..3a533d8 100644 --- a/styles.css +++ b/styles.css @@ -66,4 +66,218 @@ width: 100%; height: calc(90vh - 40px); min-height: 400px; +} + +/* Settings Panel Styles */ +.sqlseal-config-table-container { + margin: 20px 0; +} + +.sqlseal-config-table { + width: 100%; + border-collapse: collapse; + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + overflow: hidden; +} + +.sqlseal-config-table th, +.sqlseal-config-table td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid var(--background-modifier-border); +} + +.sqlseal-config-table th { + background: var(--background-modifier-hover); + font-weight: 600; + color: var(--text-normal); +} + +.sqlseal-config-table tr:last-child td { + border-bottom: none; +} + +.sqlseal-config-table tr:hover { + background: var(--background-modifier-hover-alt); +} + +.sqlseal-config-name { + font-weight: 500; + color: var(--text-normal); +} + +.sqlseal-config-preview { + font-family: var(--font-monospace); + font-size: 12px; + background: var(--background-modifier-border); + padding: 4px 8px; + border-radius: var(--radius-s); + color: var(--text-muted); + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sqlseal-config-actions { + white-space: nowrap; +} + +.sqlseal-action-button { + padding: 4px 12px; + margin-right: 8px; + border: none; + border-radius: var(--radius-s); + cursor: pointer; + font-size: 12px; + transition: background-color 0.2s ease; +} + +.sqlseal-action-button:last-child { + margin-right: 0; +} + +.sqlseal-empty-state { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: 40px 20px; +} + +/* Editor Styles */ +.sqlseal-editor-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + border-bottom: 1px solid var(--background-modifier-border); + padding-bottom: 16px; +} + +.sqlseal-back-button { + padding: 6px 12px; + background: var(--interactive-normal); + border: none; + border-radius: var(--radius-s); + cursor: pointer; + color: var(--text-normal); + font-size: 14px; + transition: background-color 0.2s ease; +} + +.sqlseal-back-button:hover { + background: var(--interactive-hover); +} + +.sqlseal-json-editor-container { + margin: 20px 0; +} + +.sqlseal-json-label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: var(--text-normal); +} + +.sqlseal-json-textarea { + width: 100%; + min-height: 200px; + padding: 12px; + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background: var(--background-primary); + color: var(--text-normal); + font-family: var(--font-monospace); + font-size: 13px; + line-height: 1.4; + resize: vertical; + transition: border-color 0.2s ease; +} + +.sqlseal-json-textarea:focus { + border-color: var(--interactive-accent); + outline: none; +} + +.sqlseal-json-textarea.error { + border-color: var(--text-error); +} + +.sqlseal-editor-buttons { + display: flex; + gap: 12px; + justify-content: flex-start; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--background-modifier-border); +} + +.sqlseal-editor-buttons button { + padding: 8px 16px; + border: none; + border-radius: var(--radius-s); + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; +} + + +/* Documentation Styles */ +.sqlseal-description-container { + margin-bottom: 20px; + padding: 16px; + background: var(--background-secondary); + border-radius: var(--radius-s); + border-left: 4px solid var(--interactive-accent); +} + +.sqlseal-example-container { + margin-top: 12px; +} + +.sqlseal-example-code { + font-family: var(--font-monospace); + font-size: 12px; + background: var(--background-modifier-border); + padding: 2px 6px; + border-radius: var(--radius-xs); + color: var(--text-muted); +} + +.sqlseal-feature-text { + color: var(--text-accent); + font-style: italic; +} + +.sqlseal-json-label-container { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 8px; +} + +.sqlseal-json-help { + color: var(--text-muted); + font-size: 11px; + font-style: italic; +} + +/* Helper Button Styles */ +.sqlseal-helper-buttons { + display: flex; + gap: 8px; + margin: 12px 0; + justify-content: flex-start; +} + +.sqlseal-helper-buttons button { + padding: 6px 12px; + border: none; + border-radius: var(--radius-s); + cursor: pointer; + font-size: 12px; + transition: all 0.2s ease; } \ No newline at end of file