Merge pull request #19 from h-sphere/feat/plenty-of-improvements

Feat/plenty of improvements
This commit is contained in:
Kacper Kula 2024-11-24 16:14:49 +00:00 committed by GitHub
commit 3e42a7954f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 2719 additions and 1956 deletions

View file

@ -1,3 +1,13 @@
# 0.11.0
The biggest update yet with plenty of exciting features:
Added CSV Viewer! Now you can see all your CSV files in your vault in the file explorer and open it to preview the data. You can also edit the data in place (remember always to backup your files!)
Added different renderer methods: you can now use GRID (default), HTML (standard table) and MARKDOWN (renders markdown / ASCII text representation of the table).
Added "tasks" table with all tasks from across the vault
Now the files in the queries are resolved relatively to the file they are in. You can also use leading slash (/) to force fetching from the root of the vault or relative paths (./, ../) to traverse the tree down from your location.
Added more lax parser implementation for now
Minor: updated dependencies to the latest versions
# 0.10.1
Many small fixes:
- Changed how parsing is done to simplify code greatly
@ -7,7 +17,7 @@ Many small fixes:
# 0.10.0
SQLSeal is not compatible with mobile! Plugin now uses SQL.JS instead of better-sqlite3 which is written in Web Assembly so no longer native binaries are needed. This makes plugin portable.
SQLSeal is now compatible with mobile! Plugin now uses SQL.JS instead of better-sqlite3 which is written in Web Assembly so no longer native binaries are needed. This makes plugin portable.
Also reworked implementation of the parser from Antlr4TS into Antlr4.
# 0.9.2

View file

@ -17,9 +17,11 @@ export default defineConfig({
text: 'Documentation',
items: [
{ text: 'Quick Start', link: '/quick-start' },
{ text: 'Changing Render Methods', link: '/changing-render-method'},
{ text: 'Using properties', link: '/using-properties' },
{ text: 'Query Vault Content', link: '/query-vault-content' },
{ text: 'Links and Images', link: '/links-and-images' },
{ text: 'CSV Viewer', link: '/csv-viewer'},
{ text: 'Troubleshooting', link: '/troubleshooting' },
{ text: 'Future Plans', link: '/future-plans' },
]

View file

@ -0,0 +1,73 @@
# Changing Render Method
SQLSeal allows for multiple render method to be used. By default it uses the Grid view (which is using AG Grid internally) to provide good looking, feature-rich grid display. You can customise how it is rendered as well as change the renderer all together.
## Table (HTML)
To display results as a regular HTML table you can add `HTML` above your `SELECT` query like following:
```sqlseal
TABLE data = file(./data.csv)
HTML
SELECT * FROM data
LIMIT 10
```
![Table Renderer Example](./renderer_table.png)
HTML render method does not come with any extra options (for now).
## Markdown
You can display table as a text based markdown table. This can be useful if you want to use SQLSeal output as a static table in your document or if you just prefer text look of the table.
To use Markdown renderer, put `MARKDOWN` above your `SELECT` query like:
```sqlseal
TABLE data = file(./data.csv)
MARKDOWN
SELECT * FROM data
LIMIT 10
```
Markdown renderer method does not come with any extra options (for now).
![Markdown Renderer Example](./renderer_markdown.png)
## Grid View Options
The default renderer is grid. You can force this view by putting `GRID` before your `SELECT` query (in the future SQLSeal will allow to change the default renderer globally). You can also use it to provide extra parameters (read more in Advanced Options section below).
```sqlseal
TABLE data = file(./data.csv)
GRID
SELECT * FROM data
LIMIT 10
```
![Grid Renderer Example](./renderer_grid.png)
### Advanced options
Grid renderer uses [AG Grid](https://www.ag-grid.com/) as a grid solution. You can pass options to it directly from your query to customise how it renders. Please note that configuration accepts only simple object and you cannot pass any functions or variables. For more information, see AG Grid documentation and examples below
#### Changing default column behaviour
To change behaviour of all columns, you can pass `defaultColDef` object. For more information, check [Column Definition documentation of AG Grid](https://www.ag-grid.com/javascript-data-grid/column-properties/).
```sqlseal
TABLE data = file(./data.csv)
GRID {
defaultColDef: {
filter: "agTextColumnFilter",
flex: 1
}
}
SELECT * FROM data
LIMIT 100
```
This adds text filtering options for each column and stretches them to the full width.
![Grid Renderer Advanced Example](./renderer_grid_advanced.png)

35
docs/csv-viewer.md Normal file
View file

@ -0,0 +1,35 @@
# CSV Viewer
SQLSeal comes with included CSV Viewer (and editor) starting at version 0.11. The Viewer is enabled by default and can be configured using SQLSeal settings.
![CSV Viewer](./csv_viewer_preview.png)
## Viewing the file
When CSV viewer is enabled, all the CSV files in your vault will be displayed in your Files view. They will also become searchable in the Quick Command (cmd+o).
## Editing CSV files
When editing is enabled you can:
- Add new columns
- Add new rows
- Delete Columns
- Delete Rows
- Edit individual cells / values
> [!CAUTION]
> Editing CSV files might cause data loss! Make sure to always backup your files before proceeding.
### Adding rows and columns
To add and row or a column, click appropriate button above your table.
![CSV Viewer Add](./csv_viewer_add.png)
### Deleting Columns
To delete a column, click the menu next to it and choose Remove Column. You will be asked to confirm your action. This will remove column and all the data assigned to this column from each individual row.
### Deleting Row
To delete a row, right click on a row and choose "Delete Row". You will be asked to confirm your action.
### Editing values
To edit specific value, double click onto the cell and start typing new value.
## Generating SQLSeal code
You can generate SQLSeal code from this view to speed up your process. It will fill in the file location and name the table based on the CSV file name.
![CSV Viewer Add](./csv-viewer_code-gen.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

BIN
docs/csv_viewer_add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
docs/csv_viewer_preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

BIN
docs/renderer_grid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
docs/renderer_markdown.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
docs/renderer_table.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

84
main.ts
View file

@ -1,34 +1,100 @@
import { Plugin } from 'obsidian';
import { Plugin, Tasks } from 'obsidian';
import { FilesFileSyncTable } from 'src/fileSyncTable/filesTable';
import { TagsFileSyncTable } from 'src/fileSyncTable/tagsTable';
import { TasksFileSyncTable } from 'src/fileSyncTable/tasksTable';
import { GridRenderer } from 'src/renderer/GridRenderer';
import { MarkdownRenderer } from 'src/renderer/MarkdownRenderer';
import { TableRenderer } from 'src/renderer/TableRenderer';
import { RendererConfig, RendererRegistry } from 'src/rendererRegistry';
import { SealFileSync } from 'src/SealFileSync';
import { SqlSealSettings } from 'src/settings';
import { DEFAULT_SETTINGS, SQLSealSettings, SQLSealSettingsTab } from 'src/settings/SQLSealSettingsTab';
import { SqlSeal } from 'src/sqlSeal';
import { CSV_VIEW_TYPE, CSVView } from 'src/view/CSVView';
const DEFAULT_SETTINGS = { rows: [] }
const GLOBAL_KEY = 'sqlSealApi'
export default class SqlSealPlugin extends Plugin {
settings: SqlSealSettings;
settings: SQLSealSettings;
fileSync: SealFileSync;
sqlSeal: SqlSeal;
rendererRegistry: RendererRegistry = new RendererRegistry();
async onload() {
await this.loadSettings();
const sqlSeal = new SqlSeal(this.app, true) // FIXME: set verbose based on the env.
this.registerMarkdownCodeBlockProcessor("sqlseal", sqlSeal.getHandler())
const settingsTab = new SQLSealSettingsTab(this.app, this, this.settings)
this.addSettingTab(settingsTab);
settingsTab.onChange(settings => {
this.settings = settings
// FIXME: check how to unregister the view
this.unregisterCSVView()
this.registerCsvView()
})
await this.registerCsvView();
this.rendererRegistry.register('sql-seal-internal-table', new TableRenderer(this.app))
this.rendererRegistry.register('sql-seal-internal-grid', new GridRenderer(this.app))
this.rendererRegistry.register('sql-seal-internal-markdown', new MarkdownRenderer(this.app))
this.registerGlobalApi();
const sqlSeal = new SqlSeal(this.app, false, this.rendererRegistry) // FIXME: set verbose based on the env.
this.registerMarkdownCodeBlockProcessor("sqlseal", sqlSeal.getHandler())
this.sqlSeal = sqlSeal
// start syncing when files are loaded
this.app.workspace.onLayoutReady(() => {
sqlSeal.db.connect().then(() => {
this.fileSync = new SealFileSync(this.app, sqlSeal, this, sqlSeal.tablesManager)
this.fileSync.init()
this.fileSync = new SealFileSync(this.app, this)
this.fileSync.addTablePlugin(new FilesFileSyncTable(sqlSeal.db, this.app, sqlSeal.tablesManager, this))
this.fileSync.addTablePlugin(new TagsFileSyncTable(sqlSeal.db, this.app, sqlSeal.tablesManager))
this.fileSync.addTablePlugin(new TasksFileSyncTable(sqlSeal.db, this.app, sqlSeal.tablesManager))
this.fileSync.init()
})
})
// this.addSettingTab(new SqlSealSettingsTab(this.app, this));
// this.addSettingTab(new SqlSealSettingsTab(this.app, this));
}
async registerCsvView() {
if (this.settings.enableViewer) {
this.registerView(
CSV_VIEW_TYPE,
(leaf) => new CSVView(leaf, this.settings.enableEditing)
);
// Register the view with the workspace for .csv files
this.registerExtensions(['csv'], CSV_VIEW_TYPE);
}
}
registerGlobalApi() {
(window as any)[GLOBAL_KEY] = {
registerRenderer: (uniqueName: string, config: RendererConfig) => {
this.rendererRegistry.register(uniqueName, config)
},
unregisterRenderer: (uniqueName: string) => {
this.rendererRegistry.unregister(uniqueName)
}
}
}
unregisterGlobalApi() {
(window as any)[GLOBAL_KEY] = undefined
}
unregisterCSVView() {
this.app.workspace.detachLeavesOfType(CSV_VIEW_TYPE);
this.app.viewRegistry.unregisterExtensions(['csv'])
this.app.viewRegistry.unregisterView(CSV_VIEW_TYPE)
}
onunload() {
this.sqlSeal.db.disconect();
this.unregisterCSVView();
this.unregisterGlobalApi();
}
async loadSettings() {

View file

@ -1,7 +1,7 @@
{
"id": "sqlseal",
"name": "SQLSeal",
"version": "0.10.1",
"version": "0.11.0",
"minAppVersion": "0.15.0",
"description": "Use SQL in your notes to query your vault files and CSV content.",
"author": "hypersphere",

View file

@ -1,6 +1,6 @@
{
"name": "sqlseal",
"version": "0.10.1",
"version": "0.11.0",
"description": "A plugin for Obsidian that allows you to run SQL queries on your notes.",
"main": "main.js",
"scripts": {
@ -20,33 +20,33 @@
"license": "MIT",
"devDependencies": {
"@jest/globals": "^29.7.0",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.7",
"@types/node": "^22.5.0",
"@types/papaparse": "^5.3.14",
"@types/jest": "^29.5.14",
"@types/lodash": "^4.17.13",
"@types/node": "^22.9.3",
"@types/papaparse": "^5.3.15",
"@types/sql.js": "^1.4.9",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"electron": "^30.0.0",
"@typescript-eslint/eslint-plugin": "8.15.0",
"@typescript-eslint/parser": "8.15.0",
"builtin-modules": "4.0.0",
"electron-rebuild": "^3.2.9",
"esbuild": "0.17.3",
"esbuild": "0.24.0",
"esbuild-plugin-replace": "^1.4.0",
"jest": "^29.7.0",
"obsidian": "^1.6.6",
"prettier": "3.2.5",
"obsidian": "^1.7.2",
"prettier": "3.3.3",
"ts-jest": "^29.2.5",
"tslib": "2.4.0",
"typescript": "4.7.4",
"vitepress": "^1.1.4",
"vue": "^3.4.26"
"tslib": "2.8.1",
"typescript": "5.7.2",
"vitepress": "^1.5.0",
"vue": "^3.5.13"
},
"dependencies": {
"@ag-grid-community/theming": "^32.3.2",
"ag-grid-community": "^32.3.2",
"@ag-grid-community/theming": "^32.3.3",
"ag-grid-community": "^32.3.3",
"json5": "^2.2.3",
"lodash": "^4.17.21",
"node-sql-parser": "^5.0.0",
"markdown-table-ts": "^1.0.3",
"node-sql-parser": "^5.3.4",
"papaparse": "^5.4.1",
"sql.js": "^1.12.0",
"util": "^0.12.5"

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,9 @@
import { App, EventRef, getAllTags, Plugin, TAbstractFile, TFile } from "obsidian";
import { App, Plugin, TAbstractFile, TFile } from "obsidian";
import { SqlSeal } from "./sqlSeal";
import { FieldTypes } from "./utils";
import { TablesManager } from "./dataLoader/collections/tablesManager";
import { sanitise } from "./utils/sanitiseColumn";
import { AFileSyncTable } from "./fileSyncTable/abstractFileSyncTable";
function fileData(file: TAbstractFile, {tags: _tags, ...frontmatter }: Record<string, any>) {
return {
@ -24,11 +25,10 @@ const extractFrontmatterFromFile = async (file: TFile, plugin: Plugin) => {
export class SealFileSync {
private currentSchema: Record<string, FieldTypes> = {}
private tablePlugins: Array<AFileSyncTable> = []
constructor(
public readonly app: App,
private readonly sqlSeal: SqlSeal,
private readonly plugin: Plugin,
private readonly tableManager: TablesManager
) {
plugin.registerEvent(this.app.vault.on('modify', async (file) => {
if (!(file instanceof TFile)) {
@ -40,20 +40,10 @@ export class SealFileSync {
if (this.hasNewColumns(frontmatter)) {
await sleep(1000)
await this.init()
return
}
// we need to update the row
await this.sqlSeal.db.updateData('files', [fileData(file, frontmatter)])
await this.sqlSeal.db.deleteData('tags', [{ fileId: file.path }], 'fileId')
this.tableManager.getTableSignal('files')(Date.now())
// Wait 1 second before updating tags table
await sleep(1000)
await this.sqlSeal.db.insertData('tags', await this.getFileTags(file))
this.tableManager.getTableSignal('tags')(Date.now())
await Promise.all(this.tablePlugins.map(p => p.onFileModify(file)))
}))
@ -70,16 +60,7 @@ export class SealFileSync {
return
}
// we need to update the row
await this.sqlSeal.db.insertData('files', [fileData(file, frontmatter)])
this.tableManager.getTableSignal('files')(Date.now())
// Wait 1 second before updating tags table
await sleep(1000)
await this.sqlSeal.db.insertData('tags', await this.getFileTags(file))
this.tableManager.getTableSignal('tags')(Date.now())
await Promise.all(this.tablePlugins.map(p => p.onFileCreate(file)))
}))
@ -88,12 +69,7 @@ export class SealFileSync {
return
}
await this.sqlSeal.db.deleteData('files', [{ id: file.path }])
this.tableManager.getTableSignal('files')(Date.now())
await this.sqlSeal.db.deleteData('tags', [{ fileId: file.path }], 'fileId')
this.tableManager.getTableSignal('tags')(Date.now())
await Promise.all(this.tablePlugins.map(p => p.onFileDelete(file.path)))
}))
plugin.registerEvent(this.app.vault.on('rename', async (file, oldPath) => {
@ -101,22 +77,10 @@ export class SealFileSync {
return
}
// deleting old one and adding new one
await this.sqlSeal.db.deleteData('files', [{ id: oldPath }])
await Promise.all(this.tablePlugins.map(p => p.onFileDelete(oldPath)))
// delete old tags
await this.sqlSeal.db.deleteData('tags', [{ fileId: oldPath }], 'fileId')
await this.sqlSeal.db.insertData('files', [fileData(file, await extractFrontmatterFromFile(file, this.plugin))])
this.tableManager.getTableSignal('files')(Date.now())
// Wait 1 second before updating tags table
await sleep(1000)
await this.sqlSeal.db.insertData('tags', await this.getFileTags(file))
this.tableManager.getTableSignal('tags')(Date.now())
await Promise.all(this.tablePlugins.map(p => p.onFileCreate(file)))
}))
// add Obsidian command to reload SQLSeal file database
@ -129,45 +93,23 @@ export class SealFileSync {
})
}
async getFileTags(file: TFile) {
const cache = this.app.metadataCache.getFileCache(file)
if (!cache) {
return []
}
const tags = getAllTags(cache)
if (!tags) {
return []
}
return tags.map((t) => ({
tag: t,
fileId: file.path
}))
addTablePlugin(tp: AFileSyncTable) {
this.tablePlugins.push(tp);
}
async init() {
const files = this.app.vault.getMarkdownFiles();
const data = []
const tags: Array<{fileId: string, tag: string }> = []
await Promise.all(this.tablePlugins.map(p => p.onInit()))
for (const file of files) {
const frontmatter = await extractFrontmatterFromFile(file, this.plugin)
tags.push(...await this.getFileTags(file))
data.push(fileData(file, frontmatter))
}
const bulkPlugins = this.tablePlugins.filter(p => p.shouldPerformBulkInsert)
const nonBulkPlugins = this.tablePlugins.filter(p => !p.shouldPerformBulkInsert)
const schema = await this.sqlSeal.db.createTableWithData('files', data)
this.currentSchema = schema
if (tags && tags.length) {
await this.sqlSeal.db.createTableWithData('tags', tags)
} else {
await this.sqlSeal.db.createTable('tags', {
'tag': 'TEXT',
'fileId': 'TEXT'
})
}
this.tableManager.getTableSignal('files')(Date.now())
this.tableManager.getTableSignal('tags')(Date.now())
// Think of adding PLIMIT HERE.
await Promise.all(bulkPlugins.map(p => p.onFileCreateBulk(files)))
await Promise.all(files.map(file =>
Promise.all(nonBulkPlugins.map(p => p.onFileCreate(file))))
)
}

View file

@ -1,5 +1,5 @@
import { App, MarkdownPostProcessorContext } from "obsidian"
import { displayData, displayError, displayNotice } from "./ui"
import { displayError, displayNotice } from "./ui"
import { resolveFrontmatter } from "./frontmatter"
import { hashString } from "./hash"
import { prefixedIfNotGlobal, updateTables } from "./sqlReparseTables"
@ -8,11 +8,12 @@ import { Logger } from "./logger"
import { SyncModel } from "./models/sync"
import { TablesManager } from "./dataLoader/collections/tablesManager"
import { QueryManager } from "./dataLoader/collections/queryManager"
import { parseLanguage, Table } from "./grammar/newParser"
import { parseLanguage, Table, TableWithParentPath } from "./grammar/newParser"
import { RendererRegistry, RenderReturn } from "./rendererRegistry"
export class SqlSealCodeblockHandler {
get globalTables() {
return ['files', 'tags']
return ['files', 'tags', 'tasks'] // Make this come from SealFileSync and plugins.
}
syncModel: SyncModel
constructor(
@ -20,29 +21,27 @@ export class SqlSealCodeblockHandler {
private readonly db: SqlSealDatabase,
private logger: Logger,
private tableManager: TablesManager,
private queryManager: QueryManager
private queryManager: QueryManager,
private rendererRegistry: RendererRegistry
) {
this.syncModel = new SyncModel(db)
}
setupTableSignals(tables: Array<Table>) {
setupTableSignals(tables: Array<TableWithParentPath>) {
tables.forEach(t => {
this.logger.log(`Registering table ${t.tableName} -> ${t.fileName}`)
this.tableManager.registerTable(t.tableName, t.fileName)
this.tableManager.registerTable(t.tableName, t.fileName, t.parentPath)
})
}
setupQuerySignals({ statement, tables }: ReturnType<typeof updateTables>, { api, errorApi }: ReturnType<typeof displayData>, ctx: MarkdownPostProcessorContext) {
setupQuerySignals({ statement, tables }: ReturnType<typeof updateTables>, renderer: RenderReturn, ctx: MarkdownPostProcessorContext) {
const frontmatter = resolveFrontmatter(ctx, this.app)
const renderSelect = async () => {
try {
const { data, columns } = this.db.select(statement, frontmatter ?? {})
api.setGridOption('columnDefs', columns.map((c: any) => ({ field: c })))
api.setGridOption('rowData', data)
api.setGridOption('loading', false)
errorApi.hide()
renderer.render({ data, columns })
} catch (e) {
errorApi.show(e.toString())
renderer.error(e.toString())
}
}
@ -57,17 +56,16 @@ export class SqlSealCodeblockHandler {
return async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
const prefix = hashString(ctx.sourcePath)
let api, errorApi, results;
let results;
let renderer: RenderReturn;
try {
await this.db.connect()
// Before we display data, we need to parse query to see if there is query part.
results = parseLanguage(source)
if (results.queryPart) {
const data = displayData(el, [], [], this.app, prefix)
api = data.api
errorApi = data.errorApi
api.setGridOption('loading', true)
// FIXME: this one probably needs both renderer and error api.
renderer = this.rendererRegistry.prepareRender(results.intermediateContent)(el)
} else {
displayNotice(el, `Creating tables: ${results.tables.map(t => t.tableName).join(', ')}`)
}
@ -75,9 +73,10 @@ export class SqlSealCodeblockHandler {
const prefixedTables = results.tables.map(t => {
return {
...t,
tableName: prefixedIfNotGlobal(t.tableName, this.globalTables, prefix)
tableName: prefixedIfNotGlobal(t.tableName, this.globalTables, prefix),
parentPath: ctx.sourcePath
}
})
}) satisfies TableWithParentPath[]
this.setupTableSignals(prefixedTables)
@ -89,10 +88,10 @@ export class SqlSealCodeblockHandler {
try {
if (results.queryPart) {
const { statement, tables } = updateTables(results.queryPart!, [...this.globalTables], prefix)
this.setupQuerySignals({ statement, tables }, { api, errorApi: errorApi! }, ctx)
this.setupQuerySignals({ statement, tables }, renderer!, ctx)
}
} catch (e) {
errorApi!.show(e.toString())
renderer!.error(e.toString())
}
}

View file

@ -1,14 +1,14 @@
import { Signal, SignalUnsubscriber } from "src/utils/signal";
import { dataTransformer, DataTransformerOut } from "../dataTransformer";
import { csvFileSignal } from "../csvFile";
import { Vault } from "obsidian";
import { App, TFile, Vault } from "obsidian";
import { isNull } from "lodash";
export class FilesManager {
files: Map<string, Signal<DataTransformerOut>> = new Map()
inputFiles: Map<string, Signal<string>> = new Map()
unregisters: Array<SignalUnsubscriber> = []
constructor(private vault: Vault) {
constructor(private vault: Vault, private app: App) {
this.vault.on('modify', async (file) => {
if (this.files.has(file.path)) {
this.inputFiles.get(file.path)!(await this.loadFile(file.path))
@ -16,9 +16,13 @@ export class FilesManager {
})
}
private async loadFile(url: string) {
const file = this.vault.getFileByPath(url)
private async loadFile(url: string, sourcePath?: string) {
let file: TFile | null;
if (sourcePath) {
file = this.app.metadataCache.getFirstLinkpathDest(url, sourcePath)
} else {
file = this.vault.getFileByPath(url)
}
if (!file) {
return ''
}
@ -26,8 +30,12 @@ export class FilesManager {
return data
}
doesFileExist(url: string) {
const file = this.vault.getFileByPath(url)
getFile(url: string, sourcePath: string) {
return this.app.metadataCache.getFirstLinkpathDest(url, sourcePath)
}
doesFileExist(url: string, sourcePath: string) {
const file = this.app.metadataCache.getFirstLinkpathDest(url, sourcePath)
return !isNull(file)
}

View file

@ -3,6 +3,7 @@ import { FilesManager } from "./filesManager";
import { createSignal, Signal, SignalUnsubscriber } from "src/utils/signal";
import { linkTableWithFile } from "../tableSignal";
import { FileManager } from "obsidian";
import { isNull } from "lodash";
interface Definition {
fileName: string;
@ -17,8 +18,9 @@ export class TablesManager {
}
registerTable(tableName: string, fileName: string) {
if (!this.filesManager.doesFileExist(fileName)) {
registerTable(tableName: string, fileName: string, parentPath: string) {
const file = this.filesManager.getFile(fileName, parentPath)
if (isNull(file)) {
throw new Error(`File ${fileName} does not exist`)
}
@ -30,12 +32,13 @@ export class TablesManager {
unlink()
this.tableLinks.delete(tableName)
}
const fileSignal = this.filesManager.getFileSignal(fileName)
const resolvedFileName = file.path
const fileSignal = this.filesManager.getFileSignal(resolvedFileName)
const tableSignal = this.getTableSignal(tableName)
const unlink = linkTableWithFile(fileSignal, tableSignal, tableName, this.db)
this.tableLinks.set(tableName, {
fileName,
fileName: resolvedFileName,
unlink
})
}

View file

@ -5,16 +5,22 @@ import { toTypeStatements } from "../utils"
import { sanitise } from "../utils/sanitiseColumn"
export const parseData = (csvData: string) => {
const parsed = parse<Record<string, string>>(csvData, {
header: true,
dynamicTyping: false,
skipEmptyLines: true,
transformHeader: sanitise
})
return parsed
}
export const dataTransformer = (s: Signal<CSVData>) => {
const sig = derivedSignal([s], (csvData) => {
// FIXME: fix header here.
try {
const parsed = parse<Record<string, string>>(csvData, {
header: true,
dynamicTyping: false,
skipEmptyLines: true,
transformHeader: sanitise
})
const parsed = parseData(csvData)
const typeStatements = toTypeStatements(parsed.meta.fields ?? [], parsed.data)
return typeStatements

View file

@ -232,37 +232,6 @@ export class SqlSealDatabase {
return types;
}
async loadDataForDatabaseFromUrl(name: string, url: string, reloadData: boolean = false) {
const file = this.app.vault.getFileByPath(url)
if (!file) {
return
}
const data = await this.app.vault.cachedRead(file)
const parsed = Papa.parse<Record<string, string>>(data, {
header: true,
dynamicTyping: false,
skipEmptyLines: true
})
const processedData = dataToCamelCase(parsed.data)
const processedWithJsonParsed = predictJson(processedData)
const fields = parsed.meta.fields?.map((f: string) => camelCase(f))!
const { data: parsedData, types } = toTypeStatements(fields, processedWithJsonParsed)
try {
// Purge the database
await this.db.prepare(`DELETE FROM ${name}`).run()
} catch (e) {
// FIXME: check if error is actually that the table does not exist
await this.createTable(name, types)
}
await this.insertData(name, parsedData)
return name
}
select(statement: string, frontmatter: Record<string, unknown>) {
const stmt = this.db.prepare(statement)
stmt.bind(this.recordToBindParams(frontmatter ?? {}))
@ -271,44 +240,4 @@ export class SqlSealDatabase {
columns: stmt.getColumnNames()
}
}
/**
* @deprecated
* @param unprefixedName
* @param url
* @param prefix
* @param reloadData
* @returns
*/
async defineDatabaseFromUrl(unprefixedName: string, url: string, prefix: string, reloadData: boolean = false) {
// FIXME: why do we repeat this code?
const name = prefixedIfNotGlobal(unprefixedName, [], prefix) // FIXME: should we pass global tables here too?
if (this.savedDatabases[name]) {
if (reloadData) {
await this.loadDataForDatabaseFromUrl(name, url)
}
return name
}
const file = this.app.vault.getFileByPath(url)
if (!file) {
return name
}
const data = await this.app.vault.cachedRead(file)
const parsed = Papa.parse<Record<string, string>>(data, {
header: true,
dynamicTyping: false,
skipEmptyLines: true
})
const fields = parsed.meta.fields!
const { data: parsedData, types } = toTypeStatements(fields, parsed.data)
await this.createTable(name, types)
this.savedDatabases[name] = url
await this.loadDataForDatabaseFromUrl(name, url)
return name
}
}

View file

@ -0,0 +1,23 @@
import { App, TFile } from "obsidian";
import { SqlSealDatabase } from "src/database";
import { TablesManager } from "src/dataLoader/collections/tablesManager";
export abstract class AFileSyncTable {
constructor(
protected readonly db: SqlSealDatabase,
protected readonly app: App,
protected tableManager: TablesManager
) {
}
shouldPerformBulkInsert: boolean = false
abstract onFileModify(file: TFile): Promise<void>;
abstract onFileDelete(path: string): Promise<void>;
abstract onFileCreate(file: TFile): Promise<void>;
abstract onInit(): Promise<void>;
onFileCreateBulk(files: TFile[]): Promise<void> {
return Promise.resolve()
}
}

View file

@ -0,0 +1,69 @@
import { App, getAllTags, Plugin, TAbstractFile, TFile } from "obsidian";
import { AFileSyncTable } from "./abstractFileSyncTable";
import { sanitise } from "src/utils/sanitiseColumn";
import { SqlSealDatabase } from "src/database";
import { TablesManager } from "src/dataLoader/collections/tablesManager";
import { predictType } from "src/utils";
const extractFrontmatterFromFile = async (file: TFile, plugin: Plugin) => {
const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter || {}
return Object.fromEntries(
Object.entries(frontmatter)
.map(([v, s]) => ([sanitise(v), s]))
)
}
function fileData(file: TAbstractFile, {tags: _tags, ...frontmatter }: Record<string, any>) {
return {
...frontmatter,
path: file.path,
name: file.name.replace(/\.[^/.]+$/, ""),
id: file.path
}
}
export class FilesFileSyncTable extends AFileSyncTable {
private currentSchema: Record<string, ReturnType<typeof predictType>>
shouldPerformBulkInsert = true;
constructor(db: SqlSealDatabase, app: App, tableManager: TablesManager, private readonly plugin: Plugin) {
super(db, app, tableManager)
}
async onFileModify(file: TFile): Promise<void> {
// we need to update the row
const frontmatter = await extractFrontmatterFromFile(file, this.plugin)
await this.db.updateData('files', [fileData(file, frontmatter)])
this.tableManager.getTableSignal('files')(Date.now())
await sleep(1000) // TO DELAY OTHER PLUGINS, UGLY BUT WORKS.
}
async onFileDelete(path: string): Promise<void> {
await this.db.deleteData('files', [{ id: path }])
this.tableManager.getTableSignal('files')(Date.now())
}
async onFileCreate(file: TFile): Promise<void> {
// we need to update the row
const frontmatter = await extractFrontmatterFromFile(file, this.plugin)
// TODO: Check if there are new columns. If there are, we update the database.
await this.db.insertData('files', [fileData(file, frontmatter)])
this.tableManager.getTableSignal('files')(Date.now())
// FIXME: should we be waiting here?
await sleep(1000)
}
async onFileCreateBulk(files: Array<TFile>) {
// FIXME: implement this one and replace the other implementation.
const data = await Promise.all(files.map(async f => fileData(f, await extractFrontmatterFromFile(f, this.plugin))))
const schema = await this.db.createTableWithData('files', data)
this.currentSchema = schema
this.tableManager.getTableSignal('files')(Date.now())
}
async onInit(): Promise<void> {
}
}

View file

@ -0,0 +1,43 @@
import { getAllTags, TFile } from "obsidian";
import { AFileSyncTable } from "./abstractFileSyncTable";
export class TagsFileSyncTable extends AFileSyncTable {
async onFileModify(file: TFile): Promise<void> {
await this.onFileDelete(file.path)
await this.onFileCreate(file)
}
async onFileDelete(path: string): Promise<void> {
await this.db.deleteData('tags', [{ fileId: path }], 'fileId')
this.tableManager.getTableSignal('tags')(Date.now())
}
async onFileCreate(file: TFile): Promise<void> {
const tags = await this.getFileTags(file)
this.db.insertData('tags', tags)
this.tableManager.getTableSignal('tags')(Date.now())
}
private async getFileTags(file: TFile) {
const cache = this.app.metadataCache.getFileCache(file)
if (!cache) {
return []
}
const tags = getAllTags(cache)
if (!tags) {
return []
}
return tags.map((t) => ({
tag: t,
fileId: file.path
}))
}
async onInit(): Promise<void> {
await this.db.createTable('tags', {
'tag': 'TEXT',
'fileId': 'TEXT'
})
this.tableManager.getTableSignal('tags')(Date.now())
}
}

View file

@ -0,0 +1,57 @@
import { TFile } from "obsidian";
import { AFileSyncTable } from "./abstractFileSyncTable";
export class TasksFileSyncTable extends AFileSyncTable {
async onFileModify(file: TFile): Promise<void> {
await this.onFileDelete(file.path)
await this.onFileCreate(file)
}
async onFileDelete(path: string): Promise<void> {
await this.db.deleteData('tasks', [{ filePath: path }], 'filePath')
this.tableManager.getTableSignal('tasks')(Date.now())
}
async onFileCreate(file: TFile): Promise<void> {
const tasks = await this.getFileTags(file)
this.db.insertData('tasks', tasks)
this.tableManager.getTableSignal('tasks')(Date.now())
}
async getFileTags(file: TFile) {
const cache = this.app.metadataCache.getFileCache(file);
if (!cache || !cache.listItems) return [];
const content = await this.app.vault.read(file);
const lines = content.split('\n');
return cache.listItems.map(listItem => {
// Check if it's a task
if (!listItem.task) return;
const status = listItem.task === ' '
// Get the full line content
const lineContent = lines[listItem.position.start.line];
// Extract task content (removing the checkbox syntax)
const taskContent = lineContent.substring(
lineContent.indexOf(']') + 1
).trim();
return {
filePath: file.path,
task: taskContent,
completed: status ? 1 : 0
}
}).filter(t => !!t)
}
async onInit(): Promise<void> {
await this.db.createTable('tasks', {
'task': 'TEXT',
'completed': 'INTEGER',
'filePath': 'TEXT'
})
this.tableManager.getTableSignal('tasks')(Date.now())
}
}

View file

@ -3,6 +3,10 @@ export interface Table {
fileName: string;
}
export interface TableWithParentPath extends Table {
parentPath: string;
}
interface ParsedLanguage {
tables: Table[];
queryPart: string;

View file

@ -0,0 +1,41 @@
import { App, Modal } from 'obsidian';
export class DeleteConfirmationModal extends Modal {
private onConfirm: () => void;
private itemName: string;
constructor(app: App, itemName: string, onConfirm: () => void) {
super(app);
this.itemName = itemName;
this.onConfirm = onConfirm;
}
onOpen() {
const {contentEl} = this;
contentEl.createEl('h2', {text: `Delete ${this.itemName}?`});
contentEl.createEl('p', {
text: `Are you sure you want to delete this ${this.itemName}? This action cannot be undone.`
});
const buttonContainer = contentEl.createDiv('modal-button-container');
buttonContainer.createEl('button', {
text: 'Cancel',
cls: 'mod-warning'
}).addEventListener('click', () => this.close());
buttonContainer.createEl('button', {
text: 'Delete',
cls: 'mod-cta'
}).addEventListener('click', () => {
this.onConfirm();
this.close();
});
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}

View file

@ -0,0 +1,40 @@
import { App, Modal, Setting } from "obsidian";
export class RenameColumnModal extends Modal {
result: string;
onSubmit: (result: string) => void;
constructor(app: App, onSubmit: (result: string) => void) {
super(app);
this.onSubmit = onSubmit;
}
onOpen() {
const { contentEl } = this;
contentEl.createEl("h2", { text: "Rename Column" });
new Setting(contentEl)
.setName("New column name")
.setDesc("Enter the new name for this column")
.addText((text) =>
text.onChange((value) => {
this.result = value;
}));
new Setting(contentEl)
.addButton((btn) =>
btn
.setButtonText("Submit")
.setCta()
.onClick(() => {
this.close();
this.onSubmit(this.result);
}));
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -0,0 +1,42 @@
import { App, Modal, Notice, Setting, TFile } from "obsidian";
import { sanitise } from "src/utils/sanitiseColumn";
export class CodeSampleModal extends Modal {
constructor(app: App, private file: TFile) {
super(app);
}
onOpen() {
const {contentEl} = this;
contentEl.createEl('h2', {text: 'SQLSeal Code'});
// Add container for code
const textArea = contentEl.createEl('textarea', {
cls: 'sql-seal-modal-code',
attr: {
rows: '10'
}
});
const tableName = sanitise(this.file.basename)
textArea.setText(`TABLE ${tableName} = file(${this.file.path})
SELECT * FROM ${tableName}
LIMIT 100`);
// Add copy button
new Setting(contentEl)
.addButton(button => button
.setButtonText('Copy to Clipboard')
.onClick(async () => {
await navigator.clipboard.writeText(textArea.getText());
new Notice('Copied to clipboard!');
}));
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}

View file

@ -0,0 +1,139 @@
import { createGrid, GridApi, GridOptions, themeQuartz } from "ag-grid-community";
import { merge } from "lodash";
import { App } from "obsidian";
import { RendererConfig } from "src/rendererRegistry";
import { parse } from 'json5'
import { displayError, displayNotice, parseCell } from "src/ui";
const getCurrentTheme = () => {
return document.body.classList.contains('theme-dark') ? 'dark' : 'light';
}
const getAgGridTheme = (theme: 'dark' | 'light') => {
return {
backgroundColor: "var(--color-primary)", //"#1f2836",
browserColorScheme: theme,
chromeBackgroundColor: {
ref: "foregroundColor",
mix: 0.07,
onto: "backgroundColor"
},
foregroundColor: "var(--text-normal)",
headerFontSize: 14
} as const
}
class GridRendererCommunicator {
constructor(private el: HTMLElement, private config: Partial<GridOptions>, private app: App) {
this.initialize()
}
private gridApi: GridApi<any>
private errorEl: HTMLElement
private errorOverlay: HTMLElement
private showError(message: string) {
this.gridApi.setGridOption('loading', false)
this.errorEl.textContent = message //.replace(`TTT${prefix}_`, '');
this.errorOverlay.classList.remove('hidden')
}
private hideError() {
this.errorOverlay.classList.add('hidden')
}
initialize() {
this.el.empty()
const div = this.el.createDiv()
div.classList.add('sqlseal-grid-wrapper')
const grid = div.createDiv()
const errorMessageOverlay = div.createDiv({ cls: ['sqlseal-grid-error-message-overlay', 'hidden'] })
this.errorEl = errorMessageOverlay.createDiv({ cls: ['sqlseal-grid-error-message'] })
this.errorOverlay = errorMessageOverlay
grid.classList.add('ag-theme-quartz')
const myTheme = themeQuartz
.withParams(getAgGridTheme(getCurrentTheme()))
const gridOptions: GridOptions = merge({
theme: myTheme,
defaultColDef: {
resizable: false,
cellRendererSelector: () => {
return {
component: ({ value }: { value: string }) => parseCell(value, this.app)
}
},
autoHeight: true
},
autoSizeStrategy: {
type: 'fitGridWidth',
defaultMinWidth: 150,
},
pagination: true,
suppressMovableColumns: true,
loadThemeGoogleFonts: false,
rowData: [],
columnDefs: [],
domLayout: 'autoHeight',
enableCellTextSelection: true,
ensureDomOrder: true
}, this.config)
this.gridApi = createGrid(
grid,
gridOptions,
);
}
setData(columns: any[], data: any[]) {
if (!this.gridApi) {
throw new Error('Grid has not been initiated')
}
this.gridApi.setGridOption('columnDefs', columns.map((c: any) => ({ field: c })))
this.gridApi.setGridOption('rowData', data)
this.gridApi.setGridOption('loading', false)
}
showInfo(type: 'loading' | 'error', message: string) {
switch (type) {
case 'loading':
this.hideError()
this.gridApi.setGridOption('loading', true)
break;
case 'error':
this.showError(message)
break
}
}
}
export class GridRenderer implements RendererConfig {
constructor(private app: App) { }
get rendererKey() {
return 'grid'
}
isInitialised = false
validateConfig(config: string) {
if (!config || !config.trim()) {
return {}
}
return parse(config)
}
render(config: Partial<GridOptions>, el: HTMLElement) {
const communicator = new GridRendererCommunicator(el, config, this.app)
return {
render: (data: any) => {
// FIXME: we need to update that.
communicator.setData(data.columns, data.data)
},
error: (message: string) => {
communicator.showInfo('error', message)
}
}
}
}

View file

@ -0,0 +1,42 @@
// This is renderer for a very basic Table view.
import { getMarkdownTable } from "markdown-table-ts";
import { App } from "obsidian";
import { RendererConfig } from "src/rendererRegistry";
import { displayError, parseCell } from "src/ui";
const mapDataFromHeaders = (columns: string[], data: Record<string, any>[]) => {
return data.map(d => columns.map(c => String(d[c])))
}
export class MarkdownRenderer implements RendererConfig {
constructor(private readonly app: App) { }
get rendererKey() {
return 'markdown'
}
validateConfig(config: string) {
return {}
}
render(config: ReturnType<typeof this.validateConfig>, el: HTMLElement) {
return {
render: ({ columns, data }: any) => {
const tab = getMarkdownTable({
table: {
head: columns,
body: mapDataFromHeaders(columns, data)
}
})
el.empty()
el.createDiv({ cls: 'sqlseal-markdown-table', text: tab })
},
error: (error: string) => {
displayError(el, error)
}
}
}
}

View file

@ -0,0 +1,47 @@
// This is renderer for a very basic Table view.
import { App } from "obsidian";
import { RendererConfig } from "src/rendererRegistry";
import { displayError, parseCell } from "src/ui";
export class TableRenderer implements RendererConfig {
constructor(private readonly app: App) { }
get rendererKey() {
return 'html'
}
validateConfig(config: string) {
return {}
}
render(config: Record<string, any>, el: HTMLElement) {
return {
render: ({ columns, data }: any) => {
el.empty()
const container = el.createDiv({
cls: 'sqlseal-table-container'
})
const table = container.createEl("table")
// HEADER
const header = table.createEl("thead").createEl("tr")
columns.forEach(c => {
header.createEl("th", { text: c })
})
const body = table.createEl("tbody")
data.forEach((d: any) => {
const row = body.createEl("tr")
columns.forEach((c: any) => {
row.createEl("td", { text: parseCell(d[c], this.app) })
})
})
},
error: (error: string) => {
displayError(el, error)
}
}
}
}

75
src/rendererRegistry.ts Normal file
View file

@ -0,0 +1,75 @@
export interface DataFormat {
data: Record<string, any>[],
columns: string[]
}
export interface RenderReturn {
render: (data: any) => void;
error: (errorMessage: string) => void;
}
export interface RendererConfig<T extends Record<string, any> = Record<string, any>> {
rendererKey: string;
validateConfig: (config: string) => T,
render: (config: T, el: HTMLElement) => RenderReturn
}
export class RendererRegistry {
renderers: Map<string, RendererConfig> = new Map()
renderersByKey: Map<string, RendererConfig> = new Map()
constructor() { }
private default = 'grid'
register(uniqueName: string, config: RendererConfig) {
if (this.renderers.has(uniqueName)) {
throw new Error(`Renderer already registered for ${uniqueName}`)
}
if (this.renderersByKey.has(config.rendererKey)) {
throw new Error(`Renderer already registered for type: ${config.rendererKey}`)
}
this.renderers.set(uniqueName, config)
this.renderersByKey.set(config.rendererKey, config)
}
unregister(uniqueName: string) {
if (!this.renderers.has(uniqueName)) {
throw new Error(`Renderer not registered: ${uniqueName}`)
}
const config = this.renderers.get(uniqueName)!
this.renderersByKey.delete(config.rendererKey)
this.renderers.delete(uniqueName)
}
splitConfig(config: string) {
if (config.length === 0) {
return {
type: this.default,
config: ''
}
}
const firstSpace = config.indexOf(" ")
if (firstSpace < 0) {
return {
type: config.toLowerCase(),
config: ''
}
}
return {
type: config.substring(0, firstSpace).toLowerCase(),
config: config.substring(firstSpace)
}
}
prepareRender(inputConfig: string) {
const { type, config } = this.splitConfig(inputConfig)
if (!this.renderersByKey.has(type)) {
throw new Error(`Renderer does not exist for ${type}`)
}
const rendererConfig = this.renderersByKey.get(type)!
const elConfig = rendererConfig.validateConfig(config)
return (el: HTMLElement) => {
return rendererConfig.render(elConfig, el)
}
}
}

View file

@ -1,119 +0,0 @@
import SqlSealPlugin from "main";
import { App, ButtonComponent, PluginSettingTab, Setting } from "obsidian";
export interface SqlSealSettings {
rows: RowSettings[];
}
interface RowSettings {
name: string;
type: string;
dynamicField: string;
}
export class SqlSealSettingsTab extends PluginSettingTab {
plugin: SqlSealPlugin;
constructor(app: App, plugin: SqlSealPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Rows')
.setDesc('Configure your rows')
.addButton((button: ButtonComponent) => {
button
.setButtonText('Add row')
.onClick(async () => {
this.plugin.settings.rows.push({ name: '', type: 'File', dynamicField: '' });
await this.plugin.saveSettings();
this.display();
});
});
for (let i = 0; i < this.plugin.settings.rows.length; i++) {
const row = this.plugin.settings.rows[i];
this.displayRow(containerEl, row, i);
}
}
displayRow(containerEl: HTMLElement, row: RowSettings, index: number) {
const rowContainer = containerEl.createDiv({ cls: 'setting-item' });
new Setting(rowContainer)
.setName('Name')
.addText(text => text
.setValue(row.name)
.onChange(async (value) => {
this.plugin.settings.rows[index].name = value;
await this.plugin.saveSettings();
}));
new Setting(rowContainer)
.setName('Type')
.addDropdown(dropdown => dropdown
.addOptions({
'File': 'File',
'SQL': 'SQL'
})
.setValue(row.type)
.onChange(async (value) => {
this.plugin.settings.rows[index].type = value;
await this.plugin.saveSettings();
this.display();
}));
if (row.type === 'File') {
new Setting(rowContainer)
.setName('File selector')
.addText(text => {
text
.setPlaceholder('Select file')
.setValue(row.dynamicField)
.onChange(async (value) => {
this.plugin.settings.rows[index].dynamicField = value;
await this.plugin.saveSettings();
})
text.setPlaceholder(`'Start typing to search for a file in your vault.`)
text.inputEl.autocomplete = 'on'
text.inputEl.setAttr('list', `file-list${index}`)
// .setDesc('Start typing to search for a file in your vault.')
// .inputEl.autocomplete = 'on'
// .inputEl.setAttribute('list', `file-list-${index}`)
});
// Create a datalist element for autocomplete
const datalist = rowContainer.createEl('datalist', { attr: { id: `file-list-${index}` } });
for (const file of this.app.vault.getFiles()) {
const option = document.createElement('option');
option.value = file.path;
datalist.appendChild(option);
}
} else if (row.type === 'SQL') {
new Setting(rowContainer)
.setName('SQL Code')
.addTextArea(textarea => textarea
.setValue(row.dynamicField)
.onChange(async (value) => {
this.plugin.settings.rows[index].dynamicField = value;
await this.plugin.saveSettings();
}));
}
rowContainer.createEl('button', {
text: 'Remove',
cls: 'mod-remove',
}).onClickEvent(async () => {
this.plugin.settings.rows.splice(index, 1);
await this.plugin.saveSettings();
this.display();
})
}
}

View file

@ -0,0 +1,68 @@
import { App, PluginSettingTab, Setting, Plugin } from 'obsidian';
export interface SQLSealSettings {
enableViewer: boolean;
enableEditing: boolean;
}
export const DEFAULT_SETTINGS: SQLSealSettings = {
enableViewer: true,
enableEditing: true
};
export class SQLSealSettingsTab extends PluginSettingTab {
plugin: Plugin;
settings: SQLSealSettings;
private onChangeFns: Array<(setting: SQLSealSettings) => void> = []
constructor(app: App, plugin: Plugin, settings: SQLSealSettings) {
super(app, plugin);
this.plugin = plugin;
this.settings = settings;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
// CSV Viewer section
// containerEl.createEl('h3', { text: 'CSV Viewer' });
new Setting(containerEl)
.setName('Enable CSV Viewer')
.setDesc('Enables CSV files in your vault and adds ability to display them in a grid.')
.addToggle(toggle => toggle
.setValue(this.settings.enableViewer)
.onChange(async (value) => {
this.settings.enableViewer = value;
if (!value) {
this.settings.enableEditing = false;
}
await this.plugin.saveData(this.settings);
this.display();
this.callChanges()
}));
new Setting(containerEl)
.setName('Enable CSV Editing')
.setDesc('Enables Editing functions in the CSV Viewer. This will add buttons to add columns, remove individual rows and columns; and edit each entry.')
.setDisabled(!this.settings.enableViewer)
.addToggle(toggle => toggle
.setValue(this.settings.enableEditing)
.onChange(async (value) => {
this.settings.enableEditing = value;
await this.plugin.saveData(this.settings);
this.callChanges()
}));
}
private callChanges() {
this.onChangeFns.forEach(f => f(this.settings))
}
onChange(fn: (settings: SQLSealSettings) => void) {
this.onChangeFns.push(fn)
}
}

View file

@ -5,22 +5,23 @@ import { Logger } from "./logger";
import { TablesManager } from "./dataLoader/collections/tablesManager";
import { QueryManager } from "./dataLoader/collections/queryManager";
import { FilesManager } from "./dataLoader/collections/filesManager";
import { RendererRegistry } from "./rendererRegistry";
export class SqlSeal {
public db: SqlSealDatabase
public codeBlockHandler: SqlSealCodeblockHandler
public tablesManager: TablesManager
constructor(private readonly app: App, verbose = false) {
constructor(private readonly app: App, verbose = false, rendererRegistry: RendererRegistry) {
this.db = new SqlSealDatabase(app, verbose)
const logger = new Logger(verbose)
const fileManager = new FilesManager(this.app.vault)
const fileManager = new FilesManager(this.app.vault, this.app)
this.tablesManager = new TablesManager(fileManager, this.db)
this.tablesManager.getTableSignal('files')
this.tablesManager.getTableSignal('tags')
const queryManager = new QueryManager(this.tablesManager)
this.codeBlockHandler = new SqlSealCodeblockHandler(app, this.db, logger, this.tablesManager, queryManager)
this.codeBlockHandler = new SqlSealCodeblockHandler(app, this.db, logger, this.tablesManager, queryManager, rendererRegistry)
// FIXME: handle here changes to files and tags?
}

View file

@ -2,24 +2,6 @@ import { App } from "obsidian"
import { createGrid, GridOptions } from 'ag-grid-community';
import { themeQuartz } from '@ag-grid-community/theming';
const getCurrentTheme =() => {
return document.body.classList.contains('theme-dark') ? 'dark' : 'light';
}
const getAgGridTheme = (theme: 'dark' | 'light') => {
return {
backgroundColor: "var(--color-primary)", //"#1f2836",
browserColorScheme: theme,
chromeBackgroundColor: {
ref: "foregroundColor",
mix: 0.07,
onto: "backgroundColor"
},
foregroundColor: "var(--text-normal)",
headerFontSize: 14
} as const
}
export const displayNotice = (el: HTMLElement, text: string) => {
el.empty()
el.createDiv({ text: text, cls: 'sqlseal-notice' })
@ -30,66 +12,7 @@ export const displayError= (el: HTMLElement, text: string) => {
el.createDiv({ text: text, cls: 'sqlseal-error' })
}
export const displayData = (el: HTMLElement, columns: string[], data: Array<Record<string, any>>, app: App, prefix: string) => {
el.empty()
const div = el.createDiv()
div.classList.add('sqlseal-grid-wrapper')
const grid = div.createDiv()
const errorMessageOverlay = div.createDiv({ cls: [ 'sqlseal-grid-error-message-overlay', 'hidden' ]})
const errorMessage = errorMessageOverlay.createDiv({ cls: [ 'sqlseal-grid-error-message' ]})
grid.classList.add('ag-theme-quartz')
const myTheme = themeQuartz
.withParams(getAgGridTheme(getCurrentTheme()))
const gridOptions: GridOptions = {
theme: myTheme,
defaultColDef: {
resizable: false,
cellRendererSelector: () => {
return {
component: ({ value }: { value: string }) => parseCell(value, app)
}
},
autoHeight: true
},
autoSizeStrategy: {
type: 'fitGridWidth',
defaultMinWidth: 150,
},
pagination: true,
suppressMovableColumns: true,
loadThemeGoogleFonts: false,
rowData: data,
columnDefs: columns.map(c => ({
field: c,
})),
domLayout: 'autoHeight',
enableCellTextSelection: true,
ensureDomOrder: true
}
// Setup Grid
const gridApi = createGrid(
grid,
gridOptions,
);
const errorApi = {
hide: () => {
errorMessageOverlay.classList.add('hidden')
},
show: (message: string) => {
gridApi.setGridOption('loading', false)
errorMessage.textContent = message.replace(`TTT${prefix}_`, '');
errorMessageOverlay.classList.remove('hidden')
}
}
return { api: gridApi, errorApi: errorApi}
}
const parseCell = (data: string, app: App) => {
export const parseCell = (data: string, app: App) => {
if (data && typeof data === 'string' && data.startsWith('SQLSEALCUSTOM')) {
const parsedData = JSON.parse(data.slice('SQLSEALCUSTOM('.length, -1))
return renderSqlSealCustomElement(parsedData, app)

219
src/view/CSVView.ts Normal file
View file

@ -0,0 +1,219 @@
import { WorkspaceLeaf, TextFileView, Menu } from 'obsidian';
import { parse, unparse } from 'papaparse';
import { DeleteConfirmationModal } from 'src/modal/deleteConfirmationModal';
import { RenameColumnModal } from 'src/modal/renameColumnModal';
import { CodeSampleModal } from 'src/modal/showCodeSample';
import { GridRenderer } from 'src/renderer/GridRenderer';
export const CSV_VIEW_TYPE = "csv-viewer";
export class CSVView extends TextFileView {
private content: string;
private table: HTMLTableElement;
constructor(leaf: WorkspaceLeaf, private enableEditing: boolean) {
super(leaf);
}
getViewType(): string {
return CSV_VIEW_TYPE;
}
getDisplayText(): string {
return this.file?.basename || 'CSV Viewer';
}
async onload(): Promise<void> {
super.onload();
this.contentEl.addClass('csv-viewer-container');
}
async onOpen() {
this.renderCSV();
}
async onClose() {
// Cleanup if needed
}
async setViewData(data: string, clear: boolean): Promise<void> {
this.content = data;
await this.renderCSV();
}
getViewData(): string {
return this.content;
}
clear(): void {
this.content = '';
this.table.empty();
}
private result: any;
updateRow(newRowData: Record<string, any>) {
this.result.data[parseInt(newRowData.__index, 10)] = newRowData;
this.saveData()
}
deleteRow(idx: number) {
this.result.data.splice(idx, 1)
this.saveData()
}
deleteColumn(column: string) {
this.result.fields = this.result.fields.filter((c: string) => c !== column)
this.saveData()
}
addNewColumn(columnName: string) {
if (this.result.fields.indexOf(columnName) > -1) {
throw new Error('Column already exists')
}
this.result.fields.push(columnName)
this.result.data = this.result.data.map((d: any) => ({ [columnName]: '', ...d }))
this.saveData()
}
setIsEditable(newValue: boolean) {
this.enableEditing = newValue
// FIXME: if there already rendered view, use this to rerender it?
}
saveData() {
const output = unparse(this.result)
this.app.vault.modify(this.file!, output)
}
createRow() {
this.result.data.push(this.result.fields.reduce((acc: Record<string, any>, f: string) => ({ ...acc, [f]: '' }), {}))
this.saveData()
}
api: any = null;
loadDataIntoGrid() {
setTimeout(() => {
const result = parse(this.content, {
header: true,
skipEmptyLines: true,
});
const data = result.data.map((d: any, i) => ({
...d,
__index: i.toString()
}))
this.result = {
data: data,
fields: result.meta.fields
}
this.api!.render({
data: data,
columns: result.meta.fields
})
}, 100)
}
private async renderCSV() {
if (this.api) {
this.loadDataIntoGrid()
return
}
this.contentEl.empty()
const csvEditorDiv = this.contentEl.createDiv({ cls: 'sql-seal-csv-editor' })
const buttonsRow = csvEditorDiv.createDiv({ cls: 'sql-seal-csv-viewer-buttons' })
if (this.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 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.app)
const csvView = this;
const api = grid.render({
defaultColDef: {
editable: this.enableEditing,
headerComponentParams: {
enableMenu: this.enableEditing,
showColumnMenu: function (e: any) {
const menu = new Menu()
menu.addItem(item => {
item.setTitle('Delete Column')
item.onClick(() => {
this.column.colId
const modal = new DeleteConfirmationModal(csvView.app, `column ${this.column.colId}`, () => {
csvView.deleteColumn(this.column.colId)
})
modal.open()
})
})
const pos = e.getBoundingClientRect();
menu.showAtPosition({ x: pos.x, y: pos.y + 20 })
}
}
},
enableCellTextSelection: false,
ensureDomOrder: false,
paginationAutoPageSize: true,
domLayout: 'normal',
getRowId: (p) => p.data.__index,
onCellValueChanged: (event) => {
if (event.rowIndex === null) {
return;
}
this.updateRow(event.data)
},
onCellContextMenu: (e) => {
if (!this.enableEditing) {
return
}
const menu = new Menu()
menu.addItem(item => {
item.setTitle('Delete Row')
item.onClick(() => {
const modal = new DeleteConfirmationModal(csvView.app, `row`, () => {
csvView.deleteRow(e.data.__index)
})
modal.open()
})
})
menu.showAtMouseEvent(e.event as any)
}
}, gridEl)
this.api = api;
this.loadDataIntoGrid()
}
}

View file

@ -42,8 +42,10 @@
.sqlseal-grid-error-message-overlay {
content: '';
position: absolute;
top: 0; left: 0;
right: 0; bottom: 0;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
z-index: 5;
}
@ -54,4 +56,52 @@
.edit-block-button {
z-index: 1000;
}
.sqlseal-markdown-table {
font-family: monospace;
white-space: pre-wrap;
}
.sqlseal-markdown-table.no-wrap {
display: inline-block;
overflow-x: scroll;
width: fit-content;
}
/* EDITOR */
.sql-seal-csv-editor {
display: flex;
flex-direction: column;
height: 100%;
gap: var(--size-4-4);
}
.sql-seal-csv-viewer {
flex: 1;
display: flex;
flex-direction: column;
}
.sqlseal-grid-wrapper {
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
}
.ag-theme-quartz {
flex: 1;
}
.sql-seal-csv-viewer-buttons {
display: flex;
gap: var(--size-4-4);
}
.sql-seal-modal-code {
width: 100%;
font-family: monospace;
padding: 8px;
}

View file

@ -12,5 +12,6 @@
"0.9.1": "0.15.0",
"0.9.2": "0.15.0",
"0.10.0": "0.15.0",
"0.10.1": "0.15.0"
"0.10.1": "0.15.0",
"0.11.0": "0.15.0"
}