From 2bfd2060d7a781f9d67cd126b7ebb381b5551f00 Mon Sep 17 00:00:00 2001 From: Kacper Kula Date: Sun, 10 Aug 2025 11:01:08 +0200 Subject: [PATCH] Feat/global tables new (#173) * chore: reworking plugin internals into modules * feat: settings module is now working, added debug module, enabled new api module * chore: restoring external API * feat: refactoring project to use proper inversion of control * feat: plugins can now be loaded in any order * chore: final file migration, all typescript is in modules now * chore: fixing dependencies of cellParser * feat: csv and json views are only registered when they are not colliding with other plugins * feat: fixed library behaviour on canvas * feat: added ability to hide columns from csv files * feat: adding global tables support (wip) * feat: global tables full implementation * feat: added changeset * chore: fixing typechecks --- .changeset/cool-knives-drop.md | 5 + esbuild.config.mjs | 5 +- src/modules/database/database.ts | 5 + .../codeblockHandler/CodeblockProcessor.ts | 7 +- src/modules/globalTables/FileConfig.ts | 49 +++++ src/modules/globalTables/GlobalTablesView.ts | 206 ++++++++++++++++++ .../globalTables/GlobalTablesView/MenuBar.ts | 39 ++++ .../globalTables/GlobalTablesViewRegister.ts | 46 ++++ src/modules/globalTables/InitFactory.ts | 11 + .../autocompleteElement/AutocompleteInput.ts | 82 +++++++ src/modules/globalTables/cells/ActionCell.ts | 28 +++ src/modules/globalTables/cells/ConfigCell.ts | 47 ++++ .../globalTables/cells/StatsRenderer.ts | 59 +++++ .../globalTables/modal/NewGlobalTableModal.ts | 133 +++++++++++ src/modules/globalTables/module.ts | 20 ++ src/modules/globalTables/sqlseal-bw.svg | 1 + src/modules/main/init.ts | 9 +- src/modules/main/module.ts | 9 +- src/modules/settings/view/CSVView.ts | 88 ++++---- src/modules/settings/view/CSVViewMenuBar.ts | 64 ++++++ src/modules/settings/view/JsonView.ts | 6 +- src/modules/sync/repository/tableAliases.ts | 9 +- src/modules/sync/sync/sync.ts | 39 +++- src/styles/autocomplete.scss | 58 +++++ src/styles/global-tables.scss | 6 + src/styles/main.scss | 4 +- 26 files changed, 975 insertions(+), 60 deletions(-) create mode 100644 .changeset/cool-knives-drop.md create mode 100644 src/modules/globalTables/FileConfig.ts create mode 100644 src/modules/globalTables/GlobalTablesView.ts create mode 100644 src/modules/globalTables/GlobalTablesView/MenuBar.ts create mode 100644 src/modules/globalTables/GlobalTablesViewRegister.ts create mode 100644 src/modules/globalTables/InitFactory.ts create mode 100644 src/modules/globalTables/autocompleteElement/AutocompleteInput.ts create mode 100644 src/modules/globalTables/cells/ActionCell.ts create mode 100644 src/modules/globalTables/cells/ConfigCell.ts create mode 100644 src/modules/globalTables/cells/StatsRenderer.ts create mode 100644 src/modules/globalTables/modal/NewGlobalTableModal.ts create mode 100644 src/modules/globalTables/module.ts create mode 100644 src/modules/globalTables/sqlseal-bw.svg create mode 100644 src/modules/settings/view/CSVViewMenuBar.ts create mode 100644 src/styles/autocomplete.scss create mode 100644 src/styles/global-tables.scss diff --git a/.changeset/cool-knives-drop.md b/.changeset/cool-knives-drop.md new file mode 100644 index 0000000..69eeacf --- /dev/null +++ b/.changeset/cool-knives-drop.md @@ -0,0 +1,5 @@ +--- +"sqlseal": minor +--- + +adding global tables support - you can now define table that will be available in all your files diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 9402e78..bfad87e 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -37,7 +37,7 @@ const wasmPlugin = { loader: 'js', }; }); - }, + } }; // Plugin to inject worker code @@ -108,6 +108,9 @@ const context = await esbuild.context({ sourcemap: prod ? false : "inline", treeShaking: true, outfile: "main.js", + loader: { + '.svg': 'text' + }, plugins: [ wasmPlugin, workerPlugin diff --git a/src/modules/database/database.ts b/src/modules/database/database.ts index 0c6af1b..088827b 100644 --- a/src/modules/database/database.ts +++ b/src/modules/database/database.ts @@ -96,6 +96,11 @@ export class SqlSealDatabase { return this.db?.getColumns(tableName) } + async count(tableName: string) { + const data = await this.db?.select(`SELECT COUNT(*) as count FROM ${tableName}`, {}) + return data?.data[0]['count'] + } + async addColumns(tableName: string, newColumns: string[]) { return this.db?.addColumns(tableName, newColumns) } diff --git a/src/modules/editor/codeblockHandler/CodeblockProcessor.ts b/src/modules/editor/codeblockHandler/CodeblockProcessor.ts index dcbb8f3..c1368ac 100644 --- a/src/modules/editor/codeblockHandler/CodeblockProcessor.ts +++ b/src/modules/editor/codeblockHandler/CodeblockProcessor.ts @@ -182,10 +182,11 @@ export class CodeblockProcessor extends MarkdownRenderChild { const canvasViews = this.app.workspace.getLeavesOfType("canvas"); for (const leaf of canvasViews) { - const nodes = JSON.parse(leaf.view.data).nodes - const node = nodes.filter(n => n.text).find(n => n.text.contains(this.source)) + const canvasView = leaf.view as any; // Canvas view has data and file properties not exposed in base View type + const nodes = JSON.parse(canvasView.data).nodes + const node = nodes.filter((n: any) => n.text).find((n: any) => n.text.contains(this.source)) if (node) { - this.cachedName = leaf.view.file.path + this.cachedName = canvasView.file.path return this.cachedName as string } } diff --git a/src/modules/globalTables/FileConfig.ts b/src/modules/globalTables/FileConfig.ts new file mode 100644 index 0000000..4446659 --- /dev/null +++ b/src/modules/globalTables/FileConfig.ts @@ -0,0 +1,49 @@ +import { TFile, Vault } from "obsidian"; + +export class FileConfig { + constructor(private path: string, private vault: Vault) { + + } + + private data: T[] = [] + private isLoaded: boolean = false + private fileHandler: TFile | null = null + + async load() { + this.fileHandler = this.vault.getFileByPath(this.path) + if (!this.fileHandler) { + return + } + + // FIXME: add proper ZOD parsing here + this.data = JSON.parse(await this.vault.cachedRead(this.fileHandler)) + this.isLoaded = true + } + + async insert(v: T) { + if (!this.isLoaded) { + await this.load() + } + this.data.push(v) + await this.save() + } + + get items() { + return this.data + } + + async save() { + const data = JSON.stringify(this.data) + if (!this.fileHandler) { + // Creating new file + this.fileHandler = await this.vault.create(this.path, data) + return + } + await this.vault.modify(this.fileHandler, data) + } + + async remove(v: T) { + this.data = this.data.filter(d => d !== v) + await this.save() + } +} \ No newline at end of file diff --git a/src/modules/globalTables/GlobalTablesView.ts b/src/modules/globalTables/GlobalTablesView.ts new file mode 100644 index 0000000..2e8d1f6 --- /dev/null +++ b/src/modules/globalTables/GlobalTablesView.ts @@ -0,0 +1,206 @@ +import { IconName, ItemView, Vault, WorkspaceLeaf } from "obsidian"; +import { MenuBar } from "./GlobalTablesView/MenuBar"; +import { FileConfig } from "./FileConfig"; +import { TableConfiguration } from "./modal/NewGlobalTableModal"; +import { GridRenderer } from "../editor/renderer/GridRenderer"; +import { createGrid, GridApi, themeQuartz } from "ag-grid-community"; +import { ConfigCellRenderer } from "./cells/ConfigCell"; +import { StatsRenderer } from "./cells/StatsRenderer"; +import { ActionCellRenderer } from "./cells/ActionCell"; +import { Sync } from "../sync/sync/sync"; +import { ParserTableDefinition } from "../sync/syncStrategy/types"; +import { throttle } from "lodash"; + +export const GLOBAL_TABLES_VIEW_TYPE = "sqlseal-global-tables"; + +const GLOBAL_SOURCE_FILE = "/"; + +export class GlobalTablesView extends ItemView { + config: FileConfig; + constructor( + leaf: WorkspaceLeaf, + vault: Vault, + public sync: Sync, + ) { + super(leaf); + this.config = new FileConfig("__globalviews.sqlseal", vault); + } + + getViewType() { + return GLOBAL_TABLES_VIEW_TYPE; + } + + getDisplayText() { + return "SQLSeal Global Tables"; + } + + getIcon(): IconName { + return "logo-sqlseal"; + } + + api: GridApi | null = null; + + async onOpen() { + const container = this.containerEl.children[1]; + container.empty(); + const c = container.createDiv({ cls: "sqlseal-global-tables-container" }); + const el = c.createEl("div"); + + const menuBar = new MenuBar(el, this.app); + + await this.config.load(); + for (const conf of this.gridData) { + this.sync.registerTable(this.getTableDefinition(conf)); + } + + menuBar.events.on("new-table", (data) => { + this.addElement(data); + }); + + // el.createDiv({ text: 'HELLO FROM SQLSEAL GLOBAL TABLES VIEW' }) + + const gridEl = c.createDiv({ cls: "sql-seal-csv-viewer" }); + const myTheme = themeQuartz.withParams({ + borderRadius: 0, + browserColorScheme: "light", + headerFontSize: 14, + headerRowBorder: false, + headerVerticalPaddingScale: 1, + rowBorder: false, + spacing: 16, + wrapperBorder: false, + wrapperBorderRadius: 0, + }); + + this.api = createGrid(gridEl, { + theme: myTheme, + detailRowAutoHeight: true, + paginationAutoPageSize: true, + rowData: this.gridData, + suppressMoveWhenColumnDragging: true, + suppressRowDrag: true, + suppressCellFocus: true, + suppressMaintainUnsortedOrder: true, + suppressDragLeaveHidesColumns: true, + suppressMoveWhenRowDragging: true, + defaultColDef: { + resizable: false, + sortable: false, + rowDrag: false, + suppressMovable: true, + flex: 1, + }, + autoSizeStrategy: { + type: "fitGridWidth", + }, + columnDefs: [ + { field: "name" }, + { field: "config.type", minWidth: 50 }, + { field: "config", cellRenderer: ConfigCellRenderer }, + { + field: "name", + cellRenderer: StatsRenderer, + headerName: "Stats", + minWidth: 200, + }, + { cellRenderer: ActionCellRenderer, headerName: "Actions" }, + ], + context: this, + }); + + this.setupResizeObserver(gridEl); + } + + // Vibe Coded. + private smartResize() { + if (!this.api) return; + + const api = this.api + const columnApi = this.api + const containerWidth = this.containerEl.clientWidth; + + // First, auto-size all columns based on content + const allColumnIds = columnApi.getColumns()?.map(col => col.getColId()) || []; + api.autoSizeColumns(allColumnIds); + + // Calculate total width needed + const totalContentWidth = columnApi.getColumns() + ?.reduce((sum, col) => sum + col.getActualWidth(), 0) || 0; + + // If content fits in container, optionally expand to fill + if (totalContentWidth < containerWidth * 0.8) { + // Content fits comfortably, you can choose to: + // Option A: Leave as-is (content-based sizing) + // Option B: Expand to fill container + api.sizeColumnsToFit(); + } + // If content is wider than container, keep auto-sized widths + // This will enable horizontal scrolling automatically + } + + get gridData() { + return this.config.items; + } + + async addElement(data: TableConfiguration) { + await this.config.insert(data); + switch (data.config.type) { + case "csv": + case "json": + await this.sync.registerTable(this.getTableDefinition(data)); + break; + case "md-table": + console.log("NOT IMPLEMENTED YET", data); + } + this.refresh(); + } + + getTableDefinition(data: TableConfiguration): ParserTableDefinition { + switch (data.config.type) { + case "csv": + return { + tableAlias: data.name, + sourceFile: GLOBAL_SOURCE_FILE, + type: "file", + arguments: [data.config.filename], + }; + case 'json': + return { + tableAlias: data.name, + sourceFile: GLOBAL_SOURCE_FILE, + type: "file", + arguments: [data.config.filename, data.config.xpath ? data.config.xpath : '$'], + } + case "md-table": + throw new Error("Not implemented"); + } + } + + async deleteElement(e: TableConfiguration) { + await this.config.remove(e); + const def = this.getTableDefinition(e); + await this.sync.unregisterTable(def); + this.refresh(); + } + + refresh() { + this.api?.setGridOption("rowData", this.gridData); + } + + async onClose() { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + if (this.api) { + this.api.destroy(); + } + } + private resizeObserver: ResizeObserver; + + private setupResizeObserver(gridContainer: HTMLElement) { + const fn = throttle(() => this.smartResize(), 100) + this.resizeObserver = new ResizeObserver(fn); + + this.resizeObserver.observe(gridContainer); + } +} diff --git a/src/modules/globalTables/GlobalTablesView/MenuBar.ts b/src/modules/globalTables/GlobalTablesView/MenuBar.ts new file mode 100644 index 0000000..3847091 --- /dev/null +++ b/src/modules/globalTables/GlobalTablesView/MenuBar.ts @@ -0,0 +1,39 @@ +import { args, BusBuilder } from "@hypersphere/omnibus" +import { App, ButtonComponent } from "obsidian" +import { NewGlobalTableModal, TableConfiguration } from "../modal/NewGlobalTableModal" + +export class MenuBar { + + private bus =(new BusBuilder()) + .register('new-table', args()) + .build() + + constructor(private el: HTMLDivElement, private app: App) { + this.show() + } + + get events() { + return this.bus.getRegistrator() + } + + openNewTableModal() { + const modal = new NewGlobalTableModal(this.app) + modal.open() + modal.bus.on('data', d => this.bus.trigger('new-table', d)) + } + + private show() { + this.el.empty() + this.addButton('Create New Table', () => this.openNewTableModal(), true) + } + + private addButton(text: string, onClick: () => void, cta: boolean = false) { + const btn = new ButtonComponent(this.el) + .setButtonText(text) + .onClick(onClick) + + if (cta) { + btn.setCta() + } + } +} \ No newline at end of file diff --git a/src/modules/globalTables/GlobalTablesViewRegister.ts b/src/modules/globalTables/GlobalTablesViewRegister.ts new file mode 100644 index 0000000..5176067 --- /dev/null +++ b/src/modules/globalTables/GlobalTablesViewRegister.ts @@ -0,0 +1,46 @@ +import { makeInjector } from "@hypersphere/dity"; +import { GlobalTablesModule } from "./module"; +import { addIcon, App, Plugin, WorkspaceLeaf } from "obsidian"; +import { GLOBAL_TABLES_VIEW_TYPE, GlobalTablesView } from "./GlobalTablesView"; +// @ts-ignore: Handled by esbuild +import SQLSealIcon from './sqlseal-bw.svg' +import { Sync } from "../sync/sync/sync"; + + +@(makeInjector()( + ['plugin', 'app', 'sync'] +)) +export class GlobalTablesViewRegister { + make(plugin: Plugin, app: App, sync: Sync) { + + const activateView = async () => { + const { workspace } = plugin.app; + + let leaf: WorkspaceLeaf | null = null; + const leaves = workspace.getLeavesOfType(GLOBAL_TABLES_VIEW_TYPE); + + if (leaves.length > 0) { + // A leaf with our view already exists, use that + leaf = leaves[0]; + } else { + leaf = workspace.getLeaf('tab') + if (!leaf) { return } + await leaf.setViewState({ type: GLOBAL_TABLES_VIEW_TYPE, active: true }); + } + + // "Reveal" the leaf in case it is in a collapsed sidebar + workspace.revealLeaf(leaf); + } + + return () => { + plugin.registerView(GLOBAL_TABLES_VIEW_TYPE, leaf => new GlobalTablesView(leaf, app.vault, sync)) + + + // SQLSeal Icon + addIcon('logo-sqlseal', SQLSealIcon) + + plugin.addRibbonIcon('logo-sqlseal', 'SQLSeal Global Tables', activateView) + + } + } +} \ No newline at end of file diff --git a/src/modules/globalTables/InitFactory.ts b/src/modules/globalTables/InitFactory.ts new file mode 100644 index 0000000..12ee2c1 --- /dev/null +++ b/src/modules/globalTables/InitFactory.ts @@ -0,0 +1,11 @@ +import { makeInjector } from "@hypersphere/dity" +import { GlobalTablesModule } from "./module" + +@(makeInjector()(['globalTablesViewRegister'])) +export class InitFactory { + make(register: () => void) { + return () => { + register() + } + } +} \ No newline at end of file diff --git a/src/modules/globalTables/autocompleteElement/AutocompleteInput.ts b/src/modules/globalTables/autocompleteElement/AutocompleteInput.ts new file mode 100644 index 0000000..e87acff --- /dev/null +++ b/src/modules/globalTables/autocompleteElement/AutocompleteInput.ts @@ -0,0 +1,82 @@ +import { args, BusBuilder } from "@hypersphere/omnibus" +import { TFile, Vault } from "obsidian" + +interface DropdownValue { + name: string + path: string + originalFile: TFile +} + +export class AutocompleteInput { + constructor(parent: HTMLElement, private vault: Vault, private extensions: string[]) { + const c = parent.createDiv({ cls: 'sqlseal-autocomplete-container'}) + this.selectedEl = c.createEl('div', { cls: 'selected-el'}) + this.selectedElText = this.selectedEl.createSpan() + this.selectedEl.createEl('button', { text: 'X'}).addEventListener('click', () => { + this.input.show() + this.input.value = '' + this.selectedEl.hide() + this.bus.trigger('change', '') + }) + this.selectedEl.hide() + this.input = c.createEl('input', { type: 'text' }) + this.dropdownContainer = c.createDiv({ cls: 'sqlseal-autocomplete-dropdown'}) + this.render() + } + + bus = new BusBuilder() + .register('item-selected', args()) + .register('change', args()) + .build() + + dropdownContainer: HTMLDivElement + input: HTMLInputElement + dropdownValues: DropdownValue[] = [] + selectedEl: HTMLDivElement + selectedElText: HTMLSpanElement + + render() { + this.input.addEventListener('keydown', e => { + requestAnimationFrame(() => { + this.dropdownValues = this.getFilesForInput((e.target! as any).value) + this.setDropdownValues() + }) + }) + + this.bus.on('item-selected', e => { + this.input.value = e.originalFile.path + this.bus.trigger('change', e.originalFile.path) + this.dropdownValues = [] + this.setDropdownValues() + + this.selectedEl.show() + this.selectedElText.textContent = e.originalFile.path + this.input.hide() + + }) + } + + setDropdownValues() { + this.dropdownContainer.empty() + for (const val of this.dropdownValues) { + this.dropdownContainer.appendChild(this.createDropdownOption(val)) + } + } + + private createDropdownOption(opt: DropdownValue) { + const el = createEl('div', { cls: 'sqlseal-dropdown-el' }) + el.createDiv({ text: opt.name, cls: 'sqlseal-dropdown-el-name' }) + el.createDiv({ text: opt.path, cls: 'sqlseal-dropdown-el-path' }) + el.addEventListener('click', e => { + this.bus.trigger('item-selected', opt) + }) + return el + } + + getFilesForInput(inp: string): DropdownValue[] { + return this.vault.getFiles() + .filter(f => this.extensions.includes(f.extension)) + .filter(f => f.path.includes(inp)) + .map(f => ({ name: f.name, path: f.parent?.path ?? '', originalFile: f })) + } +} \ No newline at end of file diff --git a/src/modules/globalTables/cells/ActionCell.ts b/src/modules/globalTables/cells/ActionCell.ts new file mode 100644 index 0000000..93872a2 --- /dev/null +++ b/src/modules/globalTables/cells/ActionCell.ts @@ -0,0 +1,28 @@ +import type { ICellRendererComp, ICellRendererParams } from "ag-grid-community"; +import { TableConfiguration } from "../modal/NewGlobalTableModal"; +import { ButtonComponent } from "obsidian"; +import { GlobalTablesView } from "../GlobalTablesView"; + +export class ActionCellRenderer implements ICellRendererComp { + private eGui!: HTMLDivElement; + + public init(params: ICellRendererParams): void { + const { value, data, context } = params; + + + + this.eGui = document.createElement("div"); + + new ButtonComponent(this.eGui) + .setIcon('trash-2') + .onClick(() => context.deleteElement(data)) + } + + public getGui(): HTMLElement { + return this.eGui; + } + + public refresh(params: ICellRendererParams): boolean { + return true; + } +} \ No newline at end of file diff --git a/src/modules/globalTables/cells/ConfigCell.ts b/src/modules/globalTables/cells/ConfigCell.ts new file mode 100644 index 0000000..f8698f1 --- /dev/null +++ b/src/modules/globalTables/cells/ConfigCell.ts @@ -0,0 +1,47 @@ +import type { ICellRendererComp, ICellRendererParams } from "ag-grid-community"; +import { TableConfiguration } from "../modal/NewGlobalTableModal"; +import { Component, MarkdownRenderer, TFile } from "obsidian"; +import { GlobalTablesView } from "../GlobalTablesView"; + +export class ConfigCellRenderer implements ICellRendererComp { + private eGui!: HTMLDivElement; + + public init( + params: ICellRendererParams< + TableConfiguration, + TableConfiguration["config"], + GlobalTablesView + >, + ): void { + const { value, data, context } = params; + + this.eGui = document.createElement("div"); + this.eGui.className = "config"; + + const container = this.eGui.createDiv(); + + if (value?.type === "csv") { + const link = container.createEl("a"); + link.href = value.filename; + link.textContent = value.filename; + link.addEventListener("click", (e) => { + e.preventDefault(); + const file = context.app.vault.getAbstractFileByPath(value.filename); + if (file instanceof TFile) { + context.app.workspace.openLinkText(value.filename, "", true); + } + }); + } + if (value?.type === 'json') { + container.textContent = value.filename + '#' + (value.xpath ? value.xpath : '$') + } + } + + public getGui(): HTMLElement { + return this.eGui; + } + + public refresh(params: ICellRendererParams): boolean { + return true; + } +} diff --git a/src/modules/globalTables/cells/StatsRenderer.ts b/src/modules/globalTables/cells/StatsRenderer.ts new file mode 100644 index 0000000..9848baa --- /dev/null +++ b/src/modules/globalTables/cells/StatsRenderer.ts @@ -0,0 +1,59 @@ +import type { ICellRendererComp, ICellRendererParams } from "ag-grid-community"; +import { TableConfiguration } from "../modal/NewGlobalTableModal"; +import { GlobalTablesView } from "../GlobalTablesView"; +import { OmnibusRegistrator } from "@hypersphere/omnibus"; + +export class StatsRenderer implements ICellRendererComp { + private eGui!: HTMLDivElement; + + context: GlobalTablesView + data: TableConfiguration + eventName: string = '' + + reg: OmnibusRegistrator + + syncFn = () => { + this.sync() + } + + public init(params: ICellRendererParams): void { + const { value, data, context } = params; + this.context = context + this.data = data! + + this.eGui = document.createElement("div"); + this.eGui.className = "stats"; + + const stats = this.eGui.createSpan() + stats.textContent = 'Loading' + + requestAnimationFrame(async () => { this.sync() }) + + // Watching for the changes + this.setupWatchersAsync(context, data!) + + } + + async setupWatchersAsync(context: GlobalTablesView, data: TableConfiguration) { + this.reg = context.sync.getRegistrator() + this.eventName = await context.sync.getEventNameForAlias('/', data!.name) + this.reg.on(this.eventName, this.syncFn) + } + + async sync() { + const result = await this.context.sync.getStats('/', this.data!.name) + this.eGui.textContent = `Rows: ${result.rows} / Columns: ${result.columns}` + } + + public getGui(): HTMLElement { + return this.eGui; + } + + public refresh(params: ICellRendererParams): boolean { + return false; + } + + destroy(): void { + this.reg.off(this.eventName, this.syncFn) + } +} \ No newline at end of file diff --git a/src/modules/globalTables/modal/NewGlobalTableModal.ts b/src/modules/globalTables/modal/NewGlobalTableModal.ts new file mode 100644 index 0000000..0ecd383 --- /dev/null +++ b/src/modules/globalTables/modal/NewGlobalTableModal.ts @@ -0,0 +1,133 @@ +import { MenuSeparator, Modal, Setting } from "obsidian"; +import { AutocompleteInput } from "../autocompleteElement/AutocompleteInput"; +import { args, BusBuilder } from "@hypersphere/omnibus"; + +export interface CSVConfig { + type: 'csv' + filename: string +} + +export interface JsonConfig { + type: 'json' + filename: string + xpath: string +} + +export interface MDTable { + type: 'md-table' + filename: string + selector: string +} + +export interface TableConfiguration { + name: string + config: CSVConfig | JsonConfig | MDTable +} + +export type Type = TableConfiguration['config']['type'] + +export class NewGlobalTableModal extends Modal { + + bus = new BusBuilder() + .register('data', args()) + .build() + + data: TableConfiguration = { + name: "", + config: { + type: 'csv', + filename: '' + } + }; + + typeSectionEl: HTMLElement | null = null + + onOpen(): void { + const { contentEl } = this; + contentEl.closest('.modal')?.classList.add('modal-overflow') + contentEl.createEl("h2", { text: "New Table Modal" }); + + const settingsEl = contentEl.createDiv(); + + const name = new Setting(settingsEl).setName("Table Name").addText((txt) => + txt.setPlaceholder("eg. Data").onChange((val) => { + this.data.name = val; + }), + ); + + const type = new Setting(settingsEl) + .setName("Source Type") + .addDropdown((drop) => + drop.addOptions({ + csv: "CSV", + json: "JSON", + // "md-table": "Markdown Table", + }) + .setValue(this.data.config.type) + .onChange(v => { + this.data.config.type = v as Type + this.renderTypeSection() + }) + ); + + this.typeSectionEl = contentEl.createDiv() + this.renderTypeSection() + + new Setting(contentEl) + .addButton(b => b + .setButtonText('Add') + .setCta() + .onClick(() => { + this.bus.trigger('data', this.data) + this.close() + }) + ) + } + + renderTypeSection() { + if (!this.typeSectionEl) { + return + } + const el = this.typeSectionEl + el.empty() + + switch (this.data.config.type) { + case 'csv': + this.renderCsvSection() + break + case 'json': + this.renderJsonSection() + break + case 'md-table': + break + default: + break + } + } + + renderCsvSection() { + const filename = new Setting(this.typeSectionEl!) + .setName('Filename') + const autocomplete = new AutocompleteInput(filename.controlEl, this.app.vault, ['csv']) + autocomplete.bus.on('change', (path) => { + this.data.config.filename = path + }) + } + + renderJsonSection() { + const filename = new Setting(this.typeSectionEl!) + .setName('Filename') + const autocomplete = new AutocompleteInput(filename.controlEl, this.app.vault, ['json', 'json5']) + autocomplete.bus.on('change', (path) => { + this.data.config.filename = path + }) + + const xpath = new Setting(this.typeSectionEl!) + .setName('Selector') + .addText(c => c.setPlaceholder('$.[0]') + .onChange(e => { + (this.data.config as JsonConfig).xpath = e + })) + + } +} diff --git a/src/modules/globalTables/module.ts b/src/modules/globalTables/module.ts new file mode 100644 index 0000000..5b5b73c --- /dev/null +++ b/src/modules/globalTables/module.ts @@ -0,0 +1,20 @@ +import { asFactory, buildContainer } from "@hypersphere/dity"; +import { InitFactory } from "./InitFactory"; +import { App, Plugin } from "obsidian"; +import { GlobalTablesViewRegister } from "./GlobalTablesViewRegister"; +import { Sync } from "../sync/sync/sync"; + +export const globalTables = buildContainer(c => + c.register({ + init: asFactory(InitFactory), + globalTablesViewRegister: asFactory(GlobalTablesViewRegister) + }) + .externals<{ + plugin: Plugin, + app: App, + sync: Sync + }>() + .exports('init') +) + +export type GlobalTablesModule = typeof globalTables diff --git a/src/modules/globalTables/sqlseal-bw.svg b/src/modules/globalTables/sqlseal-bw.svg new file mode 100644 index 0000000..a914bc5 --- /dev/null +++ b/src/modules/globalTables/sqlseal-bw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/modules/main/init.ts b/src/modules/main/init.ts index 65115c0..8cfa8af 100644 --- a/src/modules/main/init.ts +++ b/src/modules/main/init.ts @@ -11,7 +11,8 @@ type InitFn = () => void 'contextMenu.init', 'sync.init', 'debug.init', - 'api.init' + 'api.init', + 'globalTables.init' ])) export class Init { async make( @@ -21,7 +22,8 @@ export class Init { contextMenu: InitFn, syncInit: InitFn, debugInit: InitFn, - apiInit: InitFn + apiInit: InitFn, + globalTablesInit: InitFn ) { return () => { settingsInit() @@ -29,8 +31,9 @@ export class Init { highlighInit() contextMenu() syncInit() - debugInit() + // debugInit() apiInit() + globalTablesInit() } } } \ No newline at end of file diff --git a/src/modules/main/module.ts b/src/modules/main/module.ts index ac21979..7126739 100644 --- a/src/modules/main/module.ts +++ b/src/modules/main/module.ts @@ -10,6 +10,7 @@ import { syntaxHighlight } from '../syntaxHighlight/module' import { contextMenu } from '../contextMenu/module' import { debugModule } from '../debug/module' import { apiModule } from '../api/module' +import { globalTables } from '../globalTables/module' const obsidian = buildContainer(c => c .externals<{ @@ -30,7 +31,8 @@ export const mainModule = buildContainer(c => c syntaxHighlight, contextMenu, debug: debugModule, - api: apiModule + api: apiModule, + globalTables }) .register({ init: asFactory(Init) @@ -77,6 +79,11 @@ export const mainModule = buildContainer(c => c 'api.db': 'db.db', 'api.rendererRegistry': 'editor.rendererRegistry' }) + .resolve({ + 'globalTables.plugin': 'obsidian.plugin', + 'globalTables.app': 'obsidian.app', + 'globalTables.sync': 'sync.syncBus' + }) ) export type MainModule = typeof mainModule diff --git a/src/modules/settings/view/CSVView.ts b/src/modules/settings/view/CSVView.ts index 6b17f5f..2cb02e2 100644 --- a/src/modules/settings/view/CSVView.ts +++ b/src/modules/settings/view/CSVView.ts @@ -1,4 +1,4 @@ -import { WorkspaceLeaf, TextFileView, Menu, MenuItem } from "obsidian"; +import { WorkspaceLeaf, TextFileView, Menu, MenuItem, IconName } from "obsidian"; import { parse, unparse } from "papaparse"; import { GridRenderer, @@ -12,7 +12,8 @@ import { RenameColumnModal } from "../modal/renameColumnModal"; import { CodeSampleModal } from "../modal/showCodeSample"; import { DeleteConfirmationModal } from "../modal/deleteConfirmationModal"; import { CSVColumnContextMenu } from "../menu/csvColumnContextMenu"; -import { AgColumn } from "ag-grid-community"; +import { AgColumn, Column } from "ag-grid-community"; +import { CSVViewMenuBar } from "./CSVViewMenuBar"; const delay = (n: number) => new Promise((resolve) => setTimeout(resolve, n)); @@ -236,6 +237,10 @@ export class CSVView extends TextFileView { } } + getIcon(): IconName { + return 'table' + } + moveColumn(name: string, toIndex: number) { let fields = this.result.fields as Array; fields = fields.filter((f) => f !== name); @@ -307,6 +312,38 @@ export class CSVView extends TextFileView { showHidden: boolean = false; + private menu(container: HTMLElement) { + + const buttonsRow = container.createDiv({ + cls: "sql-seal-csv-viewer-buttons", + }); + + const menuBar = new CSVViewMenuBar(buttonsRow, this.settings, this.app) + + const events = menuBar.events + + events.on('add-column', (columnName: string) => { + this.addNewColumn(columnName) + }) + + events.on('add-row', () => { + this.createRow(); + }) + + events.on('generate-code', () => { + if (!this.file) { + return; + } + const modal = new CodeSampleModal(this.app, this.file); + modal.open(); + }) + + events.on('toggle-hidden', (v) => { + this.showHidden = v + this.refreshTypes(); + }) + } + private async renderCSV() { if (this.isLoading) { if (this.api) { @@ -320,57 +357,12 @@ export class CSVView extends TextFileView { const csvEditorDiv = this.contentEl.createDiv({ cls: "sql-seal-csv-editor", }); - - const buttonsRow = csvEditorDiv.createDiv({ - cls: "sql-seal-csv-viewer-buttons", - }); await this.loadConfig(); - if (this.settings.get("enableEditing")) { - const createColumn = buttonsRow.createEl("button", { - text: "Add Column", - }); - const createRow = buttonsRow.createEl("button", { text: "Add Row" }); - createColumn.addEventListener("click", (e) => { - e.preventDefault(); - const modal = new RenameColumnModal(this.app, (res) => { - this.addNewColumn(res); - }); - modal.open(); - }); - - createRow.addEventListener("click", (e) => { - e.preventDefault(); - this.createRow(); - }); - } - const generateSqlCode = buttonsRow.createEl("button", { - text: "Generate SQLSeal code", - }); - - - - const showHidden = buttonsRow.createEl('button', { - text: 'Show Hidden Columns' - }) - - showHidden.addEventListener('click', () => { - this.showHidden = !this.showHidden - showHidden.textContent = this.showHidden ? 'Hide Hidden Columns' : 'Show Hidden Columns' - this.refreshTypes() - }) + this.menu(csvEditorDiv) const gridEl = csvEditorDiv.createDiv({ cls: "sql-seal-csv-viewer" }); - generateSqlCode.addEventListener("click", (e) => { - e.preventDefault(); - if (!this.file) { - return; - } - const modal = new CodeSampleModal(this.app, this.file); - modal.open(); - }); - const grid = new GridRenderer(this.settings, null, this.app); const csvView = this; const data = this.prepareData(); diff --git a/src/modules/settings/view/CSVViewMenuBar.ts b/src/modules/settings/view/CSVViewMenuBar.ts new file mode 100644 index 0000000..ad9c85d --- /dev/null +++ b/src/modules/settings/view/CSVViewMenuBar.ts @@ -0,0 +1,64 @@ +import { App, ButtonComponent } from "obsidian"; +import { RenameColumnModal } from "../modal/renameColumnModal"; +import { Settings } from "../Settings"; +import { args, BusBuilder } from "@hypersphere/omnibus"; + +const SHOW_HIDDEN_TEXT = "Show Hidden Columns"; +const HIDE_HIDDEN_TEXT = "Hide Hidden Columns"; + +export class CSVViewMenuBar { + private bus = new BusBuilder() + .register("add-row", args<[]>()) + .register("add-column", args()) + .register("generate-code", args<[]>()) + .register("toggle-hidden", args()) + .build(); + + constructor( + private el: HTMLElement, + private settings: Settings, + private app: App, + ) { + this.show(); + } + + private showHidden: boolean = false; + + get events() { + return this.bus.getRegistrator(); + } + + private show() { + const el = this.el; + + el.empty(); + + if (this.settings.get("enableEditing")) { + new ButtonComponent(el) + .setButtonText("Add Row") + .setCta() + .onClick(() => this.bus.trigger("add-row")); + + new ButtonComponent(el).setButtonText("Add Column").onClick(() => { + const modal = new RenameColumnModal(this.app, (res) => { + this.bus.trigger("add-column", res); + }); + modal.open(); + }); + + new ButtonComponent(el) + .setButtonText("Generate SQLSeal Code") + .onClick(() => this.bus.trigger("generate-code")); + + const showHiddenButton = new ButtonComponent(el) + .setButtonText(SHOW_HIDDEN_TEXT) + .onClick(() => { + this.showHidden = !this.showHidden; + showHiddenButton.setButtonText( + this.showHidden ? HIDE_HIDDEN_TEXT : SHOW_HIDDEN_TEXT, + ); + this.bus.trigger("toggle-hidden", this.showHidden); + }); + } + } +} diff --git a/src/modules/settings/view/JsonView.ts b/src/modules/settings/view/JsonView.ts index 0bcb98a..7b67944 100644 --- a/src/modules/settings/view/JsonView.ts +++ b/src/modules/settings/view/JsonView.ts @@ -1,4 +1,4 @@ -import { WorkspaceLeaf, TextFileView, Menu } from 'obsidian'; +import { WorkspaceLeaf, TextFileView, Menu, IconName } from 'obsidian'; import { parse, stringify } from 'json5' export const JSON_VIEW_TYPE = "sqlseal-json-viewer"; @@ -33,6 +33,10 @@ export class JsonView extends TextFileView { // Cleanup if needed } + getIcon(): IconName { + return 'file-json' + } + async setViewData(data: string, clear: boolean): Promise { this.content = data; await this.renderJson(); diff --git a/src/modules/sync/repository/tableAliases.ts b/src/modules/sync/repository/tableAliases.ts index 47ec107..901062b 100644 --- a/src/modules/sync/repository/tableAliases.ts +++ b/src/modules/sync/repository/tableAliases.ts @@ -19,7 +19,14 @@ export class TableAliasesRepository extends Repository { } async deleteMapping(id: string) { - this.db.deleteData(this.TABLE_NAME, [{ id: id }], 'id') + await this.db.deleteData(this.TABLE_NAME, [{ id: id }], 'id') + } + + async deleteMappingByNames(aliasName: string, sourceFileName: string) { + const data = await this.getByAlias(sourceFileName, aliasName) + if (data) { + await this.db.deleteData(this.TABLE_NAME, [{ id: data.id }], 'id') + } } private async createTable() { diff --git a/src/modules/sync/sync/sync.ts b/src/modules/sync/sync/sync.ts index cf7a36d..486089c 100644 --- a/src/modules/sync/sync/sync.ts +++ b/src/modules/sync/sync/sync.ts @@ -63,6 +63,8 @@ export class Sync { // START SYNCING this.startSync() + + await this.refreshGlobalMappings() } async syncFileByName(fileName: string) { @@ -113,6 +115,17 @@ export class Sync { await this.tableDefinitionsRepo.insert(log) } + private globalTables: Record = {} + + async refreshGlobalMappings() { + const globalMappings = await this.tableMapLog.getByContext('/') as { alias_name: string, table_name: string }[] + this.globalTables = Object.fromEntries(globalMappings.map(g => [g.alias_name, g.table_name])) + } + + get globalTablesMapping() { + return this.globalTables + } + async getTablesMappingForContext(sourceFileName: string) { const tables = await this.tableMapLog.getByContext(sourceFileName) as { alias_name: string, table_name: string }[] const map = tables.reduce((acc, t) => ({ @@ -120,11 +133,14 @@ export class Sync { [t.alias_name as string]: t.table_name }), {}) + // FIXME: adding globals here. + return { ...map, + ...this.globalTablesMapping, files: 'files', tasks: 'tasks', - tags: 'tags' + tags: 'tags', } } @@ -171,6 +187,27 @@ export class Sync { }) } } + + if (reg.sourceFile === '/') { + await this.refreshGlobalMappings() + } + } + + unregisterTable(reg: ParserTableDefinition) { + return this.tableMapLog.deleteMappingByNames(reg.tableAlias, reg.sourceFile) + } + + async getStats(sourceFileName: string, table: string) { + const tab = await this.tableMapLog.getByAlias(sourceFileName, table) + if (!tab) { + return { rows: 0, columns: 0 } + } + const columns = await this.db.getColumns(tab.table_name) + const rows = await this.db.count(tab.table_name) + return { + rows, + columns: columns ? columns.length : 0 + } } getRegistrator() { diff --git a/src/styles/autocomplete.scss b/src/styles/autocomplete.scss new file mode 100644 index 0000000..f839cb7 --- /dev/null +++ b/src/styles/autocomplete.scss @@ -0,0 +1,58 @@ +.sqlseal-autocomplete-container { + position: relative; + width: 100%; + + & input[type="text"] { + width: 100%; + } +} + +.sqlseal-autocomplete-dropdown { + position: absolute; + top: 100%; left: 0; + margin-top: 0.5em; + right: 0; + text-align: left; + background: white; + max-height: 200px; + overflow-y: scroll; + border-radius: 5px; + box-shadow: 0 3px 5px #CCC; +} + + +.modal-overflow { + overflow: visible; + // --dialog-width: 1000px; +} + +.sqlseal-dropdown-el { + padding: 4px 16px 4px 8px; + border-top: 1px solid #EEE; + overflow: hidden; + &:first-child { + border-top: 0; + } + + &:hover { + background: #a8c4ef; + } +} + +.sqlseal-dropdown-el-name { + font-weight: bold; + font-size: 0.9em; + text-overflow: ellipsis; + max-width: 100%; + overflow: hidden; + text-wrap: nowrap; +} + +.sqlseal-dropdown-el-path { + opacity: 0.9; + font-size: 0.7em; + text-overflow: ellipsis; + max-width: 100%; + overflow: hidden; + text-wrap: nowrap; +} \ No newline at end of file diff --git a/src/styles/global-tables.scss b/src/styles/global-tables.scss new file mode 100644 index 0000000..098f2b0 --- /dev/null +++ b/src/styles/global-tables.scss @@ -0,0 +1,6 @@ +.sqlseal-global-tables-container { + height: 100%; + display: flex; + flex-direction: column; + gap: 1em; +} \ No newline at end of file diff --git a/src/styles/main.scss b/src/styles/main.scss index 3944e15..3f47e7d 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -6,4 +6,6 @@ @use 'obsidianMinimal'; @use 'agGrid'; @use 'settings'; -@use 'canvas'; \ No newline at end of file +@use 'canvas'; +@use 'autocomplete'; +@use 'global-tables';