Merge pull request #19 from h-sphere/feat/plenty-of-improvements
Feat/plenty of improvements
12
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]
|
||||
|
|
|
|||
73
docs/changing-render-method.md
Normal 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
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
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).
|
||||
|
||||

|
||||
|
||||
|
||||
## 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
|
||||
```
|
||||

|
||||
|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
35
docs/csv-viewer.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||

|
||||
|
||||
### 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.
|
||||

|
||||
BIN
docs/csv-viewer_code-gen.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
docs/csv_viewer_add.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
docs/csv_viewer_preview.png
Normal file
|
After Width: | Height: | Size: 425 KiB |
BIN
docs/renderer_grid.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
docs/renderer_grid_advanced.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
docs/renderer_markdown.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/renderer_table.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
84
main.ts
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
38
package.json
|
|
@ -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"
|
||||
|
|
|
|||
3001
pnpm-lock.yaml
|
|
@ -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))))
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
23
src/fileSyncTable/abstractFileSyncTable.ts
Normal 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()
|
||||
}
|
||||
|
||||
}
|
||||
69
src/fileSyncTable/filesTable.ts
Normal 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> {
|
||||
}
|
||||
}
|
||||
43
src/fileSyncTable/tagsTable.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
57
src/fileSyncTable/tasksTable.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,10 @@ export interface Table {
|
|||
fileName: string;
|
||||
}
|
||||
|
||||
export interface TableWithParentPath extends Table {
|
||||
parentPath: string;
|
||||
}
|
||||
|
||||
interface ParsedLanguage {
|
||||
tables: Table[];
|
||||
queryPart: string;
|
||||
|
|
|
|||
41
src/modal/deleteConfirmationModal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
40
src/modal/renameColumnModal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
42
src/modal/showCodeSample.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
139
src/renderer/GridRenderer.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/renderer/MarkdownRenderer.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src/renderer/TableRenderer.ts
Normal 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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
119
src/settings.ts
|
|
@ -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();
|
||||
})
|
||||
}
|
||||
}
|
||||
68
src/settings/SQLSealSettingsTab.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
||||
|
|
|
|||
79
src/ui.ts
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
54
styles.css
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||