Merge pull request #28 from h-sphere/feat/reusing-database-data

Reworking plugin core code
This commit is contained in:
Kacper Kula 2025-01-09 14:48:13 +00:00 committed by GitHub
commit d0a76c1daa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 792 additions and 1212 deletions

View file

@ -1,3 +1,9 @@
# 0.13.0
Huge upgrade to the code codebase. SQLSeal should be now much faster and more reliable thanks to the following:
- Rewritten how files are synched - now each CSV file creates AT MOST one table in the database (synchronisations are being reused accross files)
- Rewritten SQL parser - this enables using more advanced SQLite functionality like recursive CTEs, UNIONS and `json_each`!
- (minor, technical): code got restructured and fixed so it's easier to contribute.
# 0.12.4
- added "Create CSV file" option in context menu in file explorer.

View file

@ -51,7 +51,7 @@ const workerPlugin = {
build.onLoad({ filter: /.*/, namespace: 'worker-code' }, async () => {
// Build worker code
const result = await esbuild.build({
entryPoints: ['src/database-worker.ts'],
entryPoints: ['src/database/worker/database.ts'],
bundle: true,
write: false,
format: 'iife',
@ -83,7 +83,7 @@ const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",

View file

@ -1,7 +1,7 @@
{
"id": "sqlseal",
"name": "SQLSeal",
"version": "0.12.4",
"version": "0.13.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.12.4",
"version": "0.13.0",
"description": "A plugin for Obsidian that allows you to run SQL queries on your notes.",
"main": "main.js",
"scripts": {
@ -42,6 +42,7 @@
},
"dependencies": {
"@ag-grid-community/theming": "^32.3.3",
"@hypersphere/omnibus": "^0.1.6",
"@jlongster/sql.js": "^1.6.7",
"absurd-sql": "^0.0.54",
"ag-grid-community": "^32.3.3",
@ -52,6 +53,7 @@
"markdown-table-ts": "^1.0.3",
"node-sql-parser": "^5.3.4",
"papaparse": "^5.4.1",
"util": "^0.12.5"
"util": "^0.12.5",
"uuid": "^11.0.4"
}
}

View file

@ -11,6 +11,9 @@ importers:
'@ag-grid-community/theming':
specifier: ^32.3.3
version: 32.3.3
'@hypersphere/omnibus':
specifier: ^0.1.6
version: 0.1.6
'@jlongster/sql.js':
specifier: ^1.6.7
version: 1.6.7
@ -44,6 +47,9 @@ importers:
util:
specifier: ^0.12.5
version: 0.12.5
uuid:
specifier: ^11.0.4
version: 11.0.4
devDependencies:
'@jest/globals':
specifier: ^29.7.0
@ -719,6 +725,9 @@ packages:
resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==}
engines: {node: '>=18.18'}
'@hypersphere/omnibus@0.1.6':
resolution: {integrity: sha512-agZuKyhdW0n1JoLYZUuA6Du1QoQn39/LapFgRtbJs7fyRM62C9O2PWISHUCwAKnC1Splshpd8glQgx5pA2zkCg==}
'@iconify-json/simple-icons@1.2.12':
resolution: {integrity: sha512-lRNORrIdeLStShxAjN6FgXE1iMkaAgiAHZdP0P0GZecX91FVYW58uZnRSlXLlSx5cxMoELulkAAixybPA2g52g==}
@ -2974,6 +2983,10 @@ packages:
util@0.12.5:
resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==}
uuid@11.0.4:
resolution: {integrity: sha512-IzL6VtTTYcAhA/oghbFJ1Dkmqev+FpQWnCBaKq/gUluLxliWvO8DPFWfIviRmYbtaavtSQe4WBL++rFjdcGWEg==}
hasBin: true
v8-to-istanbul@9.3.0:
resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==}
engines: {node: '>=10.12.0'}
@ -3643,6 +3656,8 @@ snapshots:
'@humanwhocodes/retry@0.4.1': {}
'@hypersphere/omnibus@0.1.6': {}
'@iconify-json/simple-icons@1.2.12':
dependencies:
'@iconify/types': 2.0.0
@ -6313,6 +6328,8 @@ snapshots:
is-typed-array: 1.1.13
which-typed-array: 1.1.15
uuid@11.0.4: {}
v8-to-istanbul@9.3.0:
dependencies:
'@jridgewell/trace-mapping': 0.3.25

View file

@ -1,101 +0,0 @@
import { App, MarkdownPostProcessorContext } from "obsidian"
import { displayError, displayNotice } from "./ui"
import { resolveFrontmatter } from "./frontmatter"
import { hashString } from "./hash"
import { extractCtes, prefixedIfNotGlobal, updateTables } from "./sqlReparseTables"
import { SqlSealDatabase } from "./database"
import { Logger } from "./logger"
import { TablesManager } from "./dataLoader/collections/tablesManager"
import { QueryManager } from "./dataLoader/collections/queryManager"
import { parseLanguage, Table, TableWithParentPath } from "./grammar/newParser"
import { RendererRegistry, RenderReturn } from "./rendererRegistry"
export class SqlSealCodeblockHandler {
get globalTables() {
return ['files', 'tags', 'tasks', 'xyz'] // Make this come from SealFileSync and plugins.
}
constructor(
private readonly app: App,
private readonly db: SqlSealDatabase,
private logger: Logger,
private tableManager: TablesManager,
private queryManager: QueryManager,
private rendererRegistry: RendererRegistry
) {
}
setupTableSignals(tables: Array<TableWithParentPath>) {
tables.forEach(t => {
this.tableManager.registerTable(t.tableName, t.fileName, t.parentPath)
})
}
setupQuerySignals({ statement, tables }: ReturnType<typeof updateTables>, renderer: RenderReturn, ctx: MarkdownPostProcessorContext, el: Element) {
const frontmatter = resolveFrontmatter(ctx, this.app)
const renderSelect = async () => {
try {
const { data, columns } = await this.db.select(statement, frontmatter ?? {})
renderer.render({ data, columns })
} catch (e) {
renderer.error(e.toString())
}
}
const sig = this.queryManager.registerQuery(ctx.docId, tables)
const unsubscribe = sig(() => {
if (!el.isConnected) {
unsubscribe()
return
}
renderSelect()
})
}
getHandler() {
return async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
const prefix = hashString(ctx.sourcePath)
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) {
// 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(', ')}`)
}
const prefixedTables = results.tables.map(t => {
return {
...t,
tableName: prefixedIfNotGlobal(t.tableName, this.globalTables, prefix),
parentPath: ctx.sourcePath
}
}) satisfies TableWithParentPath[]
this.setupTableSignals(prefixedTables)
} catch (e) {
displayError(el, e.toString())
return
}
try {
if (results.queryPart) {
const { statement, tables } = updateTables(results.queryPart!, [...this.globalTables], prefix)
const ctes = extractCtes(results.queryPart)
const tablesWithoutCtes = tables.filter(t => !ctes.includes(t))
this.setupQuerySignals({ statement, tables: tablesWithoutCtes }, renderer!, ctx, el)
}
} catch (e) {
renderer!.error(e.toString())
}
}
}
}

View file

@ -0,0 +1,97 @@
import { OmnibusRegistrator } from "@hypersphere/omnibus";
import { App, MarkdownPostProcessorContext, MarkdownRenderChild } from "obsidian";
import { SqlSealDatabase } from "src/database/database";
import { Sync } from "src/datamodel/sync";
import { transformQuery } from "src/sql/transformer";
import { parseLanguage, Table } from "src/grammar/newParser";
import { RendererRegistry, RenderReturn } from "src/renderer/rendererRegistry";
import { displayError, displayNotice } from "src/utils/ui";
export class CodeblockProcessor extends MarkdownRenderChild {
registrator: OmnibusRegistrator
renderer: RenderReturn
constructor(
private el: HTMLElement,
private source: string,
private ctx: MarkdownPostProcessorContext,
private rendererRegistry: RendererRegistry,
private db: SqlSealDatabase,
private app: App,
private sync: Sync) {
super(el)
this.registrator = this.sync.getRegistrator()
}
query: string;
async onload() {
try {
const results = parseLanguage(this.source)
if (results.tables) {
await this.registerTables(results.tables)
if (!results.queryPart) {
displayNotice(this.el, `Creating tables: ${results.tables.map(t => t.tableName).join(', ')}`)
return
}
}
this.renderer = this.rendererRegistry.prepareRender(results.intermediateContent)(this.el)
// FIXME: probably should save the one before transform and perform transform every time we execute it.
this.query = results.queryPart
await this.render()
} catch (e) {
displayError(this.el, e.toString())
}
}
onunload() {
this.registrator.offAll()
}
async render() {
try {
const registeredTablesForContext = await this.sync.getTablesMappingForContext(this.ctx.sourcePath)
const tranformedQuery = transformQuery(this.query, registeredTablesForContext)
this.registrator.offAll()
Object.values(registeredTablesForContext).forEach(v => {
this.registrator.on(`change::${v}`, () => {
this.render()
})
this.registrator.on('file::change::'+this.ctx.sourcePath, () => {
sleep(250).then(() => this.render())
})
})
const file = this.app.vault.getFileByPath(this.ctx.sourcePath)
if (!file) {
return
}
const fileCache = this.app.metadataCache.getFileCache(file)
const { data, columns } = await this.db.select(tranformedQuery, fileCache?.frontmatter ?? {})
this.renderer.render({ data, columns })
} catch (e) {
this.renderer.error(e.toString())
}
}
async registerTables(tables: Table[]) {
await Promise.all(tables.map((table) => {
const path = this.app.metadataCache.getFirstLinkpathDest(table.fileName, this.ctx.sourcePath)
if (!path) {
throw new Error(`File does not exist: ${table.fileName} (for ${table.tableName}).`)
}
return this.sync.registerTable({
aliasName: table.tableName,
fileName: path.path,
sourceFile: this.ctx.sourcePath
})
}))
}
}

View file

@ -0,0 +1,31 @@
import { App, MarkdownPostProcessorContext } from "obsidian"
import { SqlSealDatabase } from "../database/database"
import { RendererRegistry } from "../renderer/rendererRegistry"
import { Sync } from "../datamodel/sync"
import { CodeblockProcessor } from "./CodeblockProcessor"
export class SqlSealCodeblockHandler {
constructor(
private readonly app: App,
private readonly db: SqlSealDatabase,
private sync: Sync,
private rendererRegistry: RendererRegistry
) {
}
getHandler() {
return async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
const processor = new CodeblockProcessor(
el,
source,
ctx,
this.rendererRegistry,
this.db,
this.app,
this.sync
)
ctx.addChild(processor)
}
}
}

View file

@ -1,85 +0,0 @@
import { Signal, SignalUnsubscriber } from "src/utils/signal";
import { dataTransformer, DataTransformerOut } from "../dataTransformer";
import { CSVData, csvFileSignal } from "../csvFile";
import { App, TFile, Vault } from "obsidian";
import { isNull } from "lodash";
import { SqlSealDatabase } from "src/database";
import { SyncModel } from "src/models/sync";
export class FilesManager {
files: Map<string, Signal<DataTransformerOut>> = new Map()
inputFiles: Map<string, Signal<CSVData>> = new Map()
unregisters: Array<SignalUnsubscriber> = []
syncModel: SyncModel
constructor(private vault: Vault, private app: App, private db: SqlSealDatabase) {
this.syncModel = new SyncModel(this.db)
this.vault.on('modify', async (file) => {
if (this.files.has(file.path)) {
this.inputFiles.get(file.path)!(await this.loadFile(file.path))
}
})
}
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 ''
}
const data = await this.vault.cachedRead(file)
return data
}
getFile(url: string, sourcePath: string = '/') {
return this.app.metadataCache.getFirstLinkpathDest(url, sourcePath)
}
getFileHandler(url: string, sourcePath?: string) {
let file: TFile | null;
if (sourcePath) {
file = this.app.metadataCache.getFirstLinkpathDest(url, sourcePath)
} else {
file = this.vault.getFileByPath(url)
}
}
doesFileExist(url: string, sourcePath: string) {
const file = this.app.metadataCache.getFirstLinkpathDest(url, sourcePath)
return !isNull(file)
}
destroy() {
this.unregisters.forEach(u => u())
}
private saveManagedRecord(filename: string, checksum: string) {
}
getFileSignal(filename: string): Signal<DataTransformerOut> {
if (this.files.has(filename)) {
return this.files.get(filename)!
}
const fileSignal = csvFileSignal(filename)
const signal = dataTransformer(fileSignal)
// Check if it's already in the database, if so, we can just load it again.
this.db
// FIXME: add here ability to watch changes.
this.files.set(filename, signal)
this.inputFiles.set(filename, fileSignal)
this.loadFile(filename).then(fileSignal)
return signal
}
addUnregister(unreg: SignalUnsubscriber) {
this.unregisters.push(unreg)
}
}

View file

@ -1,21 +0,0 @@
import { createSignal, derivedSignal, SignalUnsubscriber, withSignals } from "src/utils/signal";
import { TablesManager } from "./tablesManager";
export class QueryManager {
private registeredQueries: Map<string, SignalUnsubscriber> = new Map()
constructor(private tablesManager: TablesManager) { }
registerQuery(fileId: string, tables: Array<string>) {
if (this.registeredQueries.has(fileId)) {
this.registeredQueries.get(fileId)!()
this.registeredQueries.delete(fileId)
}
const tableSignals = tables.map(t => this.tablesManager.getTableSignal(t, true))
const quertySignal = createSignal<number>()
const unregister = withSignals(...tableSignals)(() => {
quertySignal(Date.now())
})
this.registeredQueries.set(fileId, unregister)
return quertySignal
}
}

View file

@ -1,55 +0,0 @@
import { SqlSealDatabase } from "src/database";
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;
unlink: SignalUnsubscriber;
}
export class TablesManager {
private tableLinks: Map<string, Definition> = new Map()
private tableSignals: Map<string, Signal<number>> = new Map()
constructor(private filesManager: FilesManager, private db: SqlSealDatabase) {
}
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`)
}
if (this.tableLinks.has(tableName)) {
const { fileName: prevFileName, unlink } = this.tableLinks.get(tableName)!
if (prevFileName === file.path) {
return
}
unlink()
this.tableLinks.delete(tableName)
}
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: resolvedFileName,
unlink
})
}
getTableSignal(tableName: string, failOnUndefined: boolean = false) {
if (!this.tableSignals.has(tableName)) {
if (failOnUndefined) {
throw new Error(`${tableName} does not exist`)
}
this.tableSignals.set(tableName, createSignal<number>())
}
return this.tableSignals.get(tableName)!
}
}

View file

@ -1,9 +0,0 @@
import { TFile } from "obsidian";
import { createSignal, Signal } from "src/utils/signal";
export type CSVData = string
export const csvFileSignal = (_file: string): Signal<CSVData> => {
const sig = createSignal<CSVData>()
return sig
}

View file

@ -1,39 +0,0 @@
import { describe, it, expect, jest } from '@jest/globals'
import { createSignal } from '../utils/signal'
import { dataTransformer } from './dataTransformer'
describe('Data Transformer', () => {
it('should properly transform basic file', () => {
const s = createSignal<string>()
const dt = dataTransformer(s)
s(`a,b,c
1,2,3
4,5,6`)
expect(dt.value.data).toHaveLength(2)
expect(dt.value.data[0]).toEqual({
a: 1,
b: 2,
c: 3
})
expect(dt.value.types).toEqual({
a: 'INTEGER',
b: 'INTEGER',
c: 'INTEGER'
})
})
it('should properly transform incorrect keys', () => {
const s = createSignal<string>()
const dt = dataTransformer(s)
s(`BEGIN,PLAN,QUERY,RAISE,beak size (mm)
1.5,hello world,343.423,22.34,2`)
expect(dt.value.types).toEqual({
'begin_': 'REAL',
'plan_': 'TEXT',
'query_': 'REAL',
'raise_': 'REAL',
'beak_size__mm_': 'INTEGER'
})
})
})

View file

@ -1,38 +0,0 @@
import { derivedSignal, Signal, SignalEventType } from "../utils/signal"
import { CSVData } from "./csvFile"
import { parse } from 'papaparse'
import { toTypeStatements } from "../utils"
import { sanitise } from "../utils/sanitiseColumn"
import { TFile } from "obsidian"
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], (data) => {
try {
const parsed = parseData(data)
const typeStatements = toTypeStatements(parsed.meta.fields ?? [], parsed.data)
return typeStatements
} catch (e) {
console.error(e);
return {
data: [],
types: {}
}
}
})
return sig
}
export type DataTransformerOut = SignalEventType<ReturnType<typeof dataTransformer>>

View file

@ -1,29 +0,0 @@
import { Signal } from "src/utils/signal";
import { DataTransformerOut } from "./dataTransformer";
import { SqlSealDatabase } from "src/database";
import { FieldTypes } from "src/utils";
export const linkTableWithFile = (dataSig: Signal<DataTransformerOut>, tableSignal: Signal<number>, tableName: string, db: SqlSealDatabase) => {
return dataSig(({ data, types }) => {
// Check if the columns are exactly the same. If not, delete and reinstantiate the table.
// For now always remove and reinstantiate the table
db.dropTable(tableName)
// compute the types of the keys
const columns = Object.entries(types).map(([key, value]) => ({
name: key,
type: value as FieldTypes
}));
// create table again
db.createTableClean(tableName, columns)
// Load all the data
db.insertData(tableName, data)
// Here we do the actual update of the table. If it succeeds, we return it with current date to indicate sync time.
tableSignal(Date.now())
})
}

View file

@ -1,12 +1,8 @@
import { App } from "obsidian"
import { FieldTypes, toTypeStatements } from "./utils"
import { sanitise } from "./utils/sanitiseColumn"
import initSqlJs, { BindParams, Database, Statement } from 'sql.js'
import wasmBinary from '../node_modules/sql.js/dist/sql-wasm.wasm'
import { FieldTypes, toTypeStatements } from "../utils/typePredictions"
import * as Comlink from 'comlink'
import workerCode from 'virtual:worker-code'
import { WorkerDatabase } from "./database-worker"
import { WorkerDatabase } from "./worker/database";
export interface FieldDefinition {
name: string;
@ -96,21 +92,6 @@ export class SqlSealDatabase {
return schema
}
// async addNewColumns(name: string, data: Array<Record<string, unknown>>) {
// const schema = await this.getSchema(data)
// const currentSchema = this.toObjectsArray(this.db.prepare(`PRAGMA table_info(${name})`))
// const currentFields = currentSchema.map((f: any) => f.name)
// const newFields = Object.keys(schema).filter(f => !currentFields.includes(f))
// if (newFields.length === 0) {
// return
// }
// const alter = this.db.prepare(`ALTER TABLE ${name} ADD
// COLUMN ${newFields.map(f => `${f} ${schema[f]}`).join(', ')}`)
// alter.run()
// }
async updateData(name: string, data: Array<Record<string, unknown>>) {
return this.db.updateData(name, data)
}
@ -135,8 +116,8 @@ export class SqlSealDatabase {
await this.createTable(name, fieldsToRecord)
}
async createTable(name: string, fields: Record<string, FieldTypes>) {
await this.db.createTable(name, fields)
async createTable(name: string, fields: Record<string, FieldTypes>, noDrop?: boolean) {
await this.db.createTable(name, fields, noDrop)
}
async getSchema(data: Array<Record<string, unknown>>) {
const fields = Object.keys(data.reduce((acc, obj) => ({ ...acc, ...obj }), {}));

View file

@ -1,11 +1,11 @@
import * as Comlink from "comlink"
import initSqlJs from '@jlongster/sql.js';
import wasmBinary from '../node_modules/@jlongster/sql.js/dist/sql-wasm.wasm'
import wasmBinary from '../../../node_modules/@jlongster/sql.js/dist/sql-wasm.wasm'
import { SQLiteFS } from 'absurd-sql';
import IndexedDBBackend from '../node_modules/absurd-sql/dist/indexeddb-backend.js';
import IndexedDBBackend from '../../../node_modules/absurd-sql/dist/indexeddb-backend.js';
import type { BindParams, Database, Statement } from "sql.js";
import { sanitise } from "./utils/sanitiseColumn";
import { FieldTypes } from "./utils";
import { sanitise } from "../../utils/sanitiseColumn";
import { FieldTypes } from "../../utils/typePredictions";
function toObjectArray(stmt: Statement) {
@ -112,20 +112,23 @@ export class WorkerDatabase {
})
}
async createTable(tableName: string, fields: Record<string, FieldTypes>) {
async createTable(tableName: string, fields: Record<string, FieldTypes>, noDrop: boolean = false) {
const transformedFiels = Object.entries(fields).map(([key, type]) => [sanitise(key), type])
const uniqueFields = [...new Map(transformedFiels.map(item =>
[item[0], item])).values()]
const sqlFields = uniqueFields.map(([key, type]) => `${key} ${type}`)
// FIXME: probably use schema generator, for now create with hardcoded fields
await this.dropTable(tableName)
if (!noDrop) {
await this.dropTable(tableName)
}
const createSQL = `CREATE TABLE IF NOT EXISTS ${tableName} (
${sqlFields.join(', ')}
);`
this.db.prepare(createSQL).run()
await this.clearTable(tableName)
if (!noDrop) {
await this.clearTable(tableName)
}
// Dropping data.
}
@ -154,10 +157,10 @@ export class WorkerDatabase {
}
}
async updateData(tableName: string, data: Array<Record<string, unknown>>) {
async updateData(tableName: string, data: Array<Record<string, unknown>>, matchKey: string = 'id') {
const fields = Object.keys(data.reduce((acc, obj) => ({ ...acc, ...obj }), {}));
data.forEach((d: Record<string, unknown>) => {
const stmt = this.db.prepare(`UPDATE ${tableName} SET ${fields.map((key: string) => `${key} = @${key}`).join(', ')} WHERE id = @id`)
const stmt = this.db.prepare(`UPDATE ${tableName} SET ${fields.map((key: string) => `${key} = @${key}`).join(', ')} WHERE ${matchKey} = @${matchKey}`)
stmt.run(recordToBindParams(d))
})
}

View file

@ -0,0 +1,5 @@
import { SqlSealDatabase } from "src/database/database";
export abstract class Repository {
constructor(protected readonly db: SqlSealDatabase) { }
}

View file

@ -0,0 +1,65 @@
import { Repository } from "./abstractRepository";
import { v4 as uuidv4 } from "uuid";
export interface FileLog {
id: string,
table_name: string,
file_name: string,
created_at: string,
updated_at: string,
file_hash: string
}
export class FileLogRepository extends Repository {
async init() {
await this.createTable()
}
private async createTable() {
await this.db.createTable('file_log', {
'id': 'TEXT',
'table_name': 'TEXT',
'file_name': 'TEXT',
'created_at': 'TEXT',
'updated_at': 'TEXT',
'file_hash': 'TEXT'
}, true)
}
async insert(log: Omit<FileLog, 'id' | 'created_at' | 'updated_at'>) {
const now = Date.now()
await this.db.insertData('file_log', [{
id: uuidv4(),
table_name: log.table_name,
file_name: log.file_name,
file_hash: log.file_hash,
created_at: now,
updated_at: now
}])
}
async getByFilename(filename: string) {
const { data } = await this.db.select('SELECT * FROM file_log WHERE file_name = @filename', {
filename: filename
})
if (!data.length) {
return null
}
return data[0] as unknown as FileLog
}
async getAll() {
const { data } = await this.db.select('SELECT * FROM file_log', {})
return data as unknown[] as FileLog[];
}
async updateHash(tableName: string, newHash: string) {
await this.db.db.updateData('file_log', [{
table_name: tableName,
file_hash: newHash
}], 'table_name')
}
}

View file

@ -0,0 +1,86 @@
import { Repository } from "./abstractRepository";
import { v4 as uuid4 } from "uuid";
export interface TableMapLog {
id: string;
table_name: string;
alias_name: string;
source_file_name: string;
created_at: string;
updated_at: string;
}
export class TableMapLogRepository extends Repository {
async init() {
await this.createTable()
}
async deleteMapping(id: string) {
this.db.deleteData('table_map_log', [{ id: id }], 'id')
}
private async createTable() {
// FIXME: add autoincrement index here to make it easier to manage.
await this.db.createTable('table_map_log', {
'id': 'TEXT',
'table_name': 'TEXT',
'alias_name': 'TEXT',
'source_file_name': 'TEXT',
'created_at': 'TEXT',
'updated_at': 'TEXT'
}, true)
}
async insert(log: Omit<TableMapLog, 'id' | 'created_at' | 'updated_at'>) {
const now = Date.now()
await this.db.insertData('table_map_log', [{
id: uuid4(),
table_name: log.table_name,
alias_name: log.alias_name,
source_file_name: log.source_file_name,
created_at: now,
updated_at: now
}])
}
async getAll() {
const { data } = await this.db.select('SELECT * FROM table_map_log', {})
return data as unknown as TableMapLog[]
}
async getByAlias(sourceFileName: string, aliasName: string) {
const { data } = await this.db.select(`SELECT * FROM table_map_log
WHERE source_file_name=@source_file_name
AND alias_name=@alias_name`, {
'source_file_name': sourceFileName,
'alias_name': aliasName
})
if (!data || data.length < 0) {
return null
}
return data[0] as unknown as TableMapLog
}
async getByTableName(tableName: string) {
const { data } = await this.db.select(`SELECT * FROM table_map_log
WHERE table_name = @table_name`, {
table_name: tableName
})
if (!data) {
return []
}
return data as unknown[] as TableMapLog[]
}
async getByContext(sourceFileName: string) {
const { data } = await this.db.select(`SELECT * FROM table_map_log
WHERE source_file_name=@source_file_name
`, { source_file_name: sourceFileName })
if (!data) {
return []
}
return data
}
}

199
src/datamodel/sync.ts Normal file
View file

@ -0,0 +1,199 @@
import { SqlSealDatabase } from "src/database/database";
import { FileLog, FileLogRepository } from "./repository/fileLog";
import { TableMapLogRepository } from "./repository/tableMapLog";
import { TAbstractFile, TFile, Vault } from "obsidian";
import { parse } from "papaparse";
import { sanitise } from "src/utils/sanitiseColumn";
import { FieldTypes, toTypeStatements } from "src/utils/typePredictions";
import { FilepathHasher } from "../utils/hasher";
import { Omnibus } from "@hypersphere/omnibus";
interface TableRegistration {
fileName: string;
aliasName: string;
sourceFile: string;
}
export class Sync {
private fileLog: FileLogRepository;
private tableMapLog: TableMapLogRepository;
private bus = new Omnibus()
private tableMaps = new Map<string, FileLog>()
constructor(
private readonly db: SqlSealDatabase,
private readonly vault: Vault
) {
}
async updateTableMaps() {
const data = await this.fileLog.getAll()
this.tableMaps.clear()
data.forEach(log => {
this.tableMaps.set(log.file_name, log)
})
}
async init() {
await this.db.connect()
this.fileLog = new FileLogRepository(this.db)
await this.fileLog.init()
this.tableMapLog = new TableMapLogRepository(this.db)
await this.tableMapLog.init()
const fileLogs = await this.fileLog.getAll()
for (const log of fileLogs) {
await this.syncFileByName(log.file_name)
}
await this.updateTableMaps()
// START SYNCING
this.startSync()
}
async syncFileByName(fileName: string) {
const file = this.vault.getFileByPath(fileName)
if (!file) {
return
}
await this.syncFile(file)
}
async syncFile(file: TFile) {
if (!this.tableMaps.has(file.path)) {
this.bus.trigger('file::change::'+file.path)
return;
}
const entry = this.tableMaps.get(file.path)!
if (entry.file_hash !== file.stat.mtime.toString()) {
const data = await this.vault.cachedRead(file)
// TODO: PROBABLY SHOULD BE EXTRACTED SOMEWHERE FROM HERE later.
const parsed = parse<Record<string, string>>(data, {
header: true,
dynamicTyping: false,
skipEmptyLines: true,
transformHeader: sanitise
})
const typeStatements = toTypeStatements(parsed.meta.fields ?? [], parsed.data)
const columns = Object.entries(typeStatements.types).map(([key, value]) => ({
name: key,
type: value as FieldTypes
}));
const tableName = entry.table_name
await this.db.createTableClean(tableName, columns)
await this.db.insertData(tableName, typeStatements.data)
await this.fileLog.updateHash(tableName, file.stat.mtime.toString())
this.bus.trigger('change::' + tableName)
}
}
startSync() {
this.vault.on('modify', (file: TAbstractFile) => {
if (file instanceof TFile) {
this.syncFile(file)
}
})
}
async registerFile(log: Omit<FileLog, 'id' | 'created_at' | 'updated_at'>) {
await this.fileLog.insert(log)
await this.updateTableMaps()
}
async getTablesMappingForContext(sourceFileName: string) {
const tables = await this.tableMapLog.getByContext(sourceFileName)
const map = tables.reduce((acc, t) => ({
...acc,
[t.alias_name as string]: t.table_name
}), {})
return {
...map,
files: 'files',
tasks: 'tasks',
tags: 'tags'
}
}
async generateTableName(fileName: string) {
const hash = await FilepathHasher.sha256(fileName)
return `file_${hash}`
}
async registerTable(reg: TableRegistration) {
const existingFileLog = await this.fileLog.getByFilename(reg.fileName)
let fileTableName: string;
if (!existingFileLog) {
// We need to create new one.
const tableName = await this.generateTableName(reg.fileName)
await this.registerFile({
file_name: reg.fileName,
table_name: tableName,
file_hash: ''
})
fileTableName = tableName
// INITIAL SYNC
await this.syncFileByName(reg.fileName)
} else {
fileTableName = existingFileLog.table_name
}
const existingTableLog = await this.tableMapLog.getByAlias(reg.sourceFile, reg.aliasName)
if (!existingTableLog) {
// Create new one
await this.tableMapLog.insert({
alias_name: reg.aliasName,
source_file_name: reg.sourceFile,
table_name: fileTableName,
})
} else {
// Already exists, doing nothing.
// Check if it is the same mapping
if (existingTableLog.table_name != fileTableName) {
await this.tableMapLog.deleteMapping(existingTableLog.id) // FIXME: might need to remove event too
await this.tableMapLog.insert({
alias_name: reg.aliasName,
table_name: fileTableName,
source_file_name: reg.sourceFile
})
}
}
}
getRegistrator() {
return this.bus.getRegistrator()
}
async getEventNameForAlias(sourceFileName: string, aliasName: string) {
const log = await this.tableMapLog.getByAlias(sourceFileName, aliasName)
if (!log) {
throw new Error(`${aliasName} does not exist for ${sourceFileName}`)
}
return `change::${log.table_name}`
}
// async onFileChange(file: TFile) {
// const path = file.path
// const hash = file.stat.mtime
// // Checking if the file is registered
// const reg = await this.fileLog.getByFilename(path)
// if (reg) {
// // Need to syncronise it.
// // Need to save update in the database.
// // Need to trigger all related files.
// const allRelated = this.tableMapLog.getByTableName(reg.tableName)
// for (const related of allRelated) {
// this.bus.bus.trigger('')
// }
// }
// // Checking if the file file is in the map.
// }
}

View file

@ -1,12 +0,0 @@
import { App, MarkdownPostProcessorContext } from "obsidian"
export const resolveFrontmatter = (ctx: Pick<MarkdownPostProcessorContext, 'frontmatter' | 'sourcePath'>, app: App) => {
if (ctx.frontmatter && Object.keys(ctx.frontmatter).length > 0) {
return ctx.frontmatter as Record<string, any>
}
const file = app.vault.getFileByPath(ctx.sourcePath)
if (!file) {
return null
}
return app.metadataCache.getFileCache(file)?.frontmatter
}

View file

@ -1,118 +0,0 @@
export function hashString(inputString: string): string {
// Function to convert a number to alphanumeric character
function toAlphanumeric(num: number): string {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
return chars[num % chars.length];
}
// Function to generate SHA-256 hash
function sha256(input: string): string {
// Simple SHA-256 implementation
function sha256Internal(input: string): string {
function rightRotate(value: number, shift: number): number {
return (value >>> shift) | (value << (32 - shift));
}
function toHexString(num: number): string {
let hex = '';
for (let i = 0; i < 8; i++) {
hex += ((num >>> (24 - i * 4)) & 0xf).toString(16);
}
return hex;
}
const words: number[] = [];
for (let i = 0; i < input.length * 8; i += 8) {
words[i >> 5] |= (input.charCodeAt(i / 8) & 0xff) << (24 - i % 32);
}
words[input.length >> 2] |= 0x80 << (24 - (input.length % 4) * 8);
words[((input.length + 64 >> 9) << 4) + 15] = input.length * 8;
let a = 0x6a09e667;
let b = 0xbb67ae85;
let c = 0x3c6ef372;
let d = 0xa54ff53a;
let e = 0x510e527f;
let f = 0x9b05688c;
let g = 0x1f83d9ab;
let h = 0x5be0cd19;
for (let i = 0; i < words.length; i += 16) {
let aa = a;
let bb = b;
let cc = c;
let dd = d;
let ee = e;
let ff = f;
let gg = g;
let hh = h;
for (let j = 0; j < 64; j++) {
let T1 = hh + ((ee & ff) | (~ee & gg)) + words[i + j] + K[j];
T1 = T1 | 0;
let T2 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25);
T2 = T2 | 0;
let T3 = (e & f) ^ (~e & g);
T3 = T3 | 0;
let T4 = h + T1 + T2 + T3 + H[j];
T4 = T4 | 0;
h = g;
g = f;
f = e;
e = d + T4;
e = e | 0;
d = c;
c = b;
b = a;
a = T4 + T1;
a = a | 0;
}
a = a + aa;
b = b + bb;
c = c + cc;
d = d + dd;
e = e + ee;
f = f + ff;
g = g + gg;
h = h + hh;
}
return toHexString(a) + toHexString(b) + toHexString(c) + toHexString(d) +
toHexString(e) + toHexString(f) + toHexString(g) + toHexString(h);
}
// Convert the SHA-256 hash to alphanumeric characters
function hashToAlphanumeric(hash: string): string {
return hash.replace(/[^\w]/g, '');
}
return hashToAlphanumeric(sha256Internal(input));
}
// Constants for SHA-256
const K = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
];
const H = [
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19
];
return sha256(inputString);
}
export const generatePrefix = (prefix: string, tableName: string) => {
return `TTT${prefix}_${tableName}`
}

View file

@ -1,14 +1,14 @@
import { Menu, MenuItem, Plugin, TAbstractFile, Tasks, TFile } from 'obsidian';
import { FilesFileSyncTable } from 'src/fileSyncTable/filesTable';
import { TagsFileSyncTable } from 'src/fileSyncTable/tagsTable';
import { TasksFileSyncTable } from 'src/fileSyncTable/tasksTable';
import { Menu, Plugin, TAbstractFile, Tasks, TFile } from 'obsidian';
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 { RendererConfig, RendererRegistry } from 'src/renderer/rendererRegistry';
import { DEFAULT_SETTINGS, SQLSealSettings, SQLSealSettingsTab } from 'src/settings/SQLSealSettingsTab';
import { SqlSeal } from 'src/sqlSeal';
import { SealFileSync } from 'src/vaultSync/SealFileSync';
import { FilesFileSyncTable } from 'src/vaultSync/tables/filesTable';
import { TagsFileSyncTable } from 'src/vaultSync/tables/tagsTable';
import { TasksFileSyncTable } from 'src/vaultSync/tables/tasksTable';
import { CSV_VIEW_TYPE, CSVView } from 'src/view/CSVView';
const GLOBAL_KEY = 'sqlSealApi'
@ -37,18 +37,22 @@ export default class SqlSealPlugin extends Plugin {
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
await this.sqlSeal.db.connect()
// start syncing when files are loaded
this.app.workspace.onLayoutReady(() => {
sqlSeal.db.connect().then(() => {
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.addTablePlugin(new FilesFileSyncTable(sqlSeal.db, this.app, this))
this.fileSync.addTablePlugin(new TagsFileSyncTable(sqlSeal.db, this.app))
this.fileSync.addTablePlugin(new TasksFileSyncTable(sqlSeal.db, this.app))
this.fileSync.init()
this.registerMarkdownCodeBlockProcessor("sqlseal", sqlSeal.getHandler())
})
})

View file

@ -1,59 +0,0 @@
import { SqlSealDatabase } from "src/database";
export interface SyncEntry {
filename: string;
url: string;
name: string;
syncedAt: string;
metadata: any;
}
export class SyncModel {
tableName = 'sqlseal_sync'
constructor(private db: SqlSealDatabase) {
this.db.connect().then(() =>
this.createTableIfNotExists()
)
}
createTableIfNotExists() {
this.db.createTable(this.tableName, {
'filename': 'TEXT',
'checksum': 'TEXT',
'syncedAt': 'TEXT',
})
}
registerSync(filename: string, checksum: string) {
this.db.insertData(this.tableName, [{
filename,
checksum,
syncedAt: Date.now(),
}])
}
async getSync(filename: string) {
const { data } = await this.db.select(`SELECT *
FROM sqlseal_sync
WHERE filename = @filename`,
{
filename: filename
})
if (data) {
return data[0] as unknown as SyncEntry
}
return null
}
removeSync(filename: string) {
// FIXME: do not use exposed db, instead implement "exec" method inside the file.
this.db.db.prepare('DELETE FROM sqlseal_sync WHERE filename = :filename AND name = :name').run({
filename: filename,
name: tableName
})
}
}

View file

@ -1,9 +1,9 @@
import { createGrid, GridApi, GridOptions, themeQuartz } from "ag-grid-community";
import { merge } from "lodash";
import { App } from "obsidian";
import { RendererConfig } from "src/rendererRegistry";
import { RendererConfig } from "src/renderer/rendererRegistry";
import { parse } from 'json5'
import { displayError, displayNotice, parseCell } from "src/ui";
import { parseCell } from "src/utils/ui";
const getCurrentTheme = () => {
return document.body.classList.contains('theme-dark') ? 'dark' : 'light';

View file

@ -1,8 +1,8 @@
// 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";
import { RendererConfig } from "src/renderer/rendererRegistry";
import { displayError, parseCell } from "src/utils/ui";
const mapDataFromHeaders = (columns: string[], data: Record<string, any>[]) => {
return data.map(d => columns.map(c => String(d[c])))

View file

@ -1,7 +1,7 @@
// This is renderer for a very basic Table view.
import { App } from "obsidian";
import { RendererConfig } from "src/rendererRegistry";
import { displayError, parseCell } from "src/ui";
import { RendererConfig } from "src/renderer/rendererRegistry";
import { displayError, parseCell } from "src/utils/ui";
export class TableRenderer implements RendererConfig {

View file

@ -26,10 +26,6 @@ export class SQLSealSettingsTab extends PluginSettingTab {
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.')

View file

@ -0,0 +1,23 @@
import { describe, it, expect } from '@jest/globals'
import { transformQuery } from './transformer'
describe('SQL Transformer', () => {
it('should properly transform basic SQL query', () => {
const transformed = transformQuery('SELECT * FROM x', { x: 'file_123' })
expect(transformed).toEqual('SELECT * FROM "file_123"')
})
it('should properly transform SQL with CTE', () => {
const transformed = transformQuery('WITH virtual AS (SELECT * FROM x) SELECT * FROM virtual, x', { x: 'y' })
expect(transformed).toEqual("WITH \"virtual\" AS (SELECT * FROM \"y\") SELECT * FROM \"virtual\", \"y\"")
})
it('should properly persist table functions like json_each', () => {
const transformed = transformQuery(`SELECT y.name, x.value
FROM y, json_each(y.list) as x
WHERE list IS NOT NULL`, { y: 'file_123' })
expect(transformed).toEqual("SELECT \"file_123\".\"name\", \"x\".\"value\" FROM \"file_123\", json_each(\"file_123\".\"list\") AS \"x\" WHERE \"list\" IS NOT NULL")
})
})

143
src/sql/transformer.ts Normal file
View file

@ -0,0 +1,143 @@
import { Parser } from 'node-sql-parser';
export function transformQuery(query: string, tablesAliases: Record<string, string>): string {
const parser = new Parser();
// Parse the query into an AST
const ast = parser.astify(query, {
database: 'Sqlite'
});
// Handle both single queries and multiple queries (like UNION)
const queries = Array.isArray(ast) ? ast : [ast];
// Process each query in the array
queries.forEach(queryAst => {
transformQueryPart(queryAst, tablesAliases);
});
// Convert back to SQL
return parser.sqlify(ast, { database: 'Sqlite' });
}
function transformQueryPart(ast: any, tablesAliases: Record<string, string>): void {
if (!ast || typeof ast !== 'object') return;
// Handle FROM clause
if (ast.from) {
ast.from = ast.from.map((fromItem: any) => {
// Handle regular table references
if (fromItem.table && tablesAliases[fromItem.table]) {
fromItem.table = tablesAliases[fromItem.table];
}
// Handle function expressions in FROM clause (like json_each)
if (fromItem.expr) {
transformExpression(fromItem.expr, tablesAliases);
}
// Handle nested queries in FROM
if (fromItem.stmt) {
transformQueryPart(fromItem.stmt, tablesAliases);
}
return fromItem;
});
}
// Handle WITH clause (CTEs)
if (ast.with && Array.isArray(ast.with)) {
ast.with.forEach((withItem: any) => {
if (withItem.stmt) {
transformQueryPart(withItem.stmt.ast, tablesAliases);
}
});
}
// Handle legacy WITH clause structure
if (ast.with && ast.with.ctes && Array.isArray(ast.with.ctes)) {
ast.with.ctes.forEach((withItem: any) => {
if (withItem.stmt) {
transformQueryPart(withItem.stmt, tablesAliases);
}
});
}
// Handle JOINs
if (ast.join) {
ast.join = ast.join.map((joinItem: any) => {
if (joinItem.table && joinItem.table.table && tablesAliases[joinItem.table.table]) {
joinItem.table.table = tablesAliases[joinItem.table.table];
}
if (joinItem.on) {
transformExpression(joinItem.on, tablesAliases);
}
return joinItem;
});
}
// Handle WHERE clause
if (ast.where) {
transformExpression(ast.where, tablesAliases);
}
// Handle UNION queries
if (ast.union) {
transformQueryPart(ast.union[0], tablesAliases);
transformQueryPart(ast.union[1], tablesAliases);
}
// Handle SELECT columns
if (ast.columns) {
ast.columns.forEach((column: any) => {
transformExpression(column.expr, tablesAliases);
});
}
}
function transformExpression(expr: any, tablesAliases: Record<string, string>): void {
if (!expr || typeof expr !== 'object') return;
// Handle column references
if (expr.type === 'column_ref' && expr.table && tablesAliases[expr.table]) {
expr.table = tablesAliases[expr.table];
}
// Handle function calls
if (expr.type === 'function') {
if (expr.args && Array.isArray(expr.args)) {
expr.args.forEach((arg: any) => {
if (arg.type === 'column_ref' && arg.table && tablesAliases[arg.table]) {
arg.table = tablesAliases[arg.table];
}
transformExpression(arg, tablesAliases);
});
}
}
// Handle binary expressions
if (expr.type === 'binary_expr') {
transformExpression(expr.left, tablesAliases);
transformExpression(expr.right, tablesAliases);
}
// Handle CASE expressions
if (expr.type === 'case') {
if (expr.args && Array.isArray(expr.args)) {
expr.args.forEach((arg: any) => transformExpression(arg, tablesAliases));
}
}
// Recursively process other potential expressions
Object.keys(expr).forEach(key => {
if (Array.isArray(expr[key])) {
expr[key].forEach((item: any) => {
if (item && typeof item === 'object') {
transformExpression(item, tablesAliases);
}
});
} else if (expr[key] && typeof expr[key] === 'object') {
transformExpression(expr[key], tablesAliases);
}
});
}

View file

@ -1,91 +0,0 @@
import { BaseFrom, From, Parser, Select, With } from "node-sql-parser"
import { generatePrefix } from "./hash"
const isGlobal = (table: string, globalTables: string[]) => {
return globalTables.map(t =>
t.toLowerCase()
).includes(table.toLowerCase())
}
const isBaseFrom = (from: From): from is BaseFrom => {
return Object.keys(from).includes('table')
}
export const prefixedIfNotGlobal = (tableName: string, globalTables: string[], prefix: string) => {
if (isGlobal(tableName, globalTables)) {
return tableName
}
return generatePrefix(prefix, tableName)
}
const updateSelect = (selectAst: Select, globalTables: string[], prefix: string): { selectAst: Select, tables: string[] } => {
let cteGlobals: string[] = []
let withs = selectAst.with
let extraTables: string[] = []
if (selectAst.with) {
cteGlobals = selectAst.with.map(w => w.name.value)
const updatedWiths = selectAst.with.map(w => {
const { selectAst, tables } = updateSelect(w.stmt.ast, [...cteGlobals, ...globalTables], prefix)
extraTables.push(...tables)
return {
...w,
stmt: {
...w.stmt,
ast: selectAst
}
} satisfies With
})
withs = updatedWiths
}
if (!selectAst.from) {
return { selectAst: selectAst, tables: [] }
}
const tables: Array<string> = []
const updatedFrom = selectAst.from.map(from => {
if(isBaseFrom(from)) {
const t = prefixedIfNotGlobal(from.table, [...cteGlobals, ...globalTables], prefix)
tables.push(t)
return {
...from,
table: t,
}
}
return from
})
return {
selectAst: {
...selectAst,
from: updatedFrom,
with: withs
},
tables: [...tables, ...extraTables]
}
}
export const updateTables = (selectStatement: string, globalTables: string[], prefix: string) => {
const parser = new Parser()
const { ast } = parser.parse(selectStatement)
if (!Array.isArray(ast) && ast.type === 'select') {
const { selectAst, tables } = updateSelect(ast!, globalTables, prefix)
return { statement: parser.sqlify(selectAst), tables }
} else {
throw new Error('Invalid Statement. Only single SELECTs are accepted at the moment.')
}
}
export const extractCtes = (selectStatement: string) => {
const parser = new Parser()
const { ast } = parser.parse(selectStatement)
if (!Array.isArray(ast) && ast.type === 'select') {
if (ast.with) {
return ast.with.map(w => w.name.value)
}
}
return []
}

View file

@ -1,28 +1,21 @@
import { SqlSealDatabase } from "./database";
import { SqlSealDatabase } from "./database/database";
import { App } from "obsidian";
import { SqlSealCodeblockHandler } from "./SqlSealCodeblockHandler";
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";
import { SqlSealCodeblockHandler } from "./codeblockHandler/SqlSealCodeblockHandler";
import { Logger } from "./utils/logger";
import { RendererRegistry } from "./renderer/rendererRegistry";
import { Sync } from "./datamodel/sync";
export class SqlSeal {
public db: SqlSealDatabase
public codeBlockHandler: SqlSealCodeblockHandler
public tablesManager: TablesManager
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, this.app, this.db)
this.tablesManager = new TablesManager(fileManager, this.db)
this.tablesManager.getTableSignal('files')
this.tablesManager.getTableSignal('tags')
const queryManager = new QueryManager(this.tablesManager)
const sync = new Sync(this.db, this.app.vault)
sync.init()
this.codeBlockHandler = new SqlSealCodeblockHandler(app, this.db, logger, this.tablesManager, queryManager, rendererRegistry)
// FIXME: handle here changes to files and tags?
this.codeBlockHandler = new SqlSealCodeblockHandler(app, this.db, sync, rendererRegistry)
}
getHandler() {

54
src/utils/hasher.ts Normal file
View file

@ -0,0 +1,54 @@
/**
* A utility class for generating consistent hashes from filepaths in browser environments
*/
export class FilepathHasher {
/**
* Creates a SHA-256 hash of a filepath
* @param {string} filepath - The filepath to hash
* @returns {Promise<string>} The hex-encoded hash
*/
static async sha256(filepath: string) {
// Normalize the filepath to ensure consistent hashing across platforms
const normalizedPath = filepath.replace(/\\/g, '/').toLowerCase();
// Convert string to array buffer
const encoder = new TextEncoder();
const data = encoder.encode(normalizedPath);
// Generate hash using browser's crypto API
const hashBuffer = await window.crypto.subtle.digest('SHA-256', data);
// Convert to hex string
return Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Creates a shorter hash (first 8 characters of SHA-256)
* @param {string} filepath - The filepath to hash
* @returns {Promise<string>} The truncated hex-encoded hash
*/
static async shortHash(filepath: string) {
const fullHash = await this.sha256(filepath);
return fullHash.slice(0, 8);
}
/**
* Creates a base64 encoded hash of a filepath
* @param {string} filepath - The filepath to hash
* @returns {Promise<string>} The base64-encoded hash
*/
static async base64Hash(filepath: string) {
const normalizedPath = filepath.replace(/\\/g, '/').toLowerCase();
const encoder = new TextEncoder();
const data = encoder.encode(normalizedPath);
const hashBuffer = await window.crypto.subtle.digest('SHA-256', data);
// Convert ArrayBuffer to Base64
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashString = String.fromCharCode.apply(null, hashArray);
return btoa(hashString);
}
}

View file

@ -1,247 +0,0 @@
import { describe, it, expect, jest } from '@jest/globals'
import { createSignal, derivedSignal, withSignals } from './signal';
describe('createSignal', () => {
it('should create a signal with initial value', () => {
const signal = createSignal(10);
expect(signal.value).toBe(10);
});
it('should create a signal without initial value', () => {
const signal = createSignal<number>();
expect(signal.value).toBeUndefined();
});
it('should notify subscribers when value changes', () => {
const signal = createSignal<number>();
const listener = jest.fn();
signal(listener);
signal(42);
expect(listener).toHaveBeenCalledWith(42);
});
it('should call listener immediately with current value if exists', () => {
const signal = createSignal(10);
const listener = jest.fn();
signal(listener);
expect(listener).toHaveBeenCalledWith(10);
});
it('should allow unsubscribing', () => {
const signal = createSignal<number>();
const listener = jest.fn();
const unsubscribe = signal(listener);
signal(42);
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
signal(43);
expect(listener).toHaveBeenCalledTimes(1);
});
it('should allow multiple subscribers', () => {
const signal = createSignal<number>();
const listener1 = jest.fn();
const listener2 = jest.fn();
signal(listener1);
signal(listener2);
signal(42);
expect(listener1).toHaveBeenCalledWith(42);
expect(listener2).toHaveBeenCalledWith(42);
});
});
describe('withSignals', () => {
it('should combine multiple signals', () => {
const signal1 = createSignal<number>();
const signal2 = createSignal<string>();
const callback = jest.fn();
withSignals(signal1, signal2)(callback);
signal1(42);
expect(callback).not.toHaveBeenCalled();
signal2('hello');
expect(callback).toHaveBeenCalledWith(42, 'hello');
});
it('should work with initial values', () => {
const signal1 = createSignal(42);
const signal2 = createSignal('hello');
const callback = jest.fn();
withSignals(signal1, signal2)(callback);
expect(callback).toHaveBeenCalledWith(42, 'hello');
});
it('should update when any signal changes', () => {
const signal1 = createSignal(42);
const signal2 = createSignal('hello');
const callback = jest.fn();
withSignals(signal1, signal2)(callback);
signal1(43);
expect(callback).toHaveBeenCalledWith(43, 'hello');
});
it('should allow unsubscribing from all signals', () => {
const signal1 = createSignal<number>();
const signal2 = createSignal<string>();
const callback = jest.fn();
const unsubscribe = withSignals(signal1, signal2)(callback);
signal1(42);
signal2('hello');
expect(callback).toHaveBeenCalledTimes(1);
unsubscribe();
signal1(43);
signal2('world');
expect(callback).toHaveBeenCalledTimes(1);
});
});
describe('derivedSignal', () => {
it('should compute derived value', () => {
const count = createSignal(5);
const multiplier = createSignal(2);
const product = derivedSignal(
[count, multiplier],
(c, m) => c * m
);
expect(product.value).toBe(10);
});
it('should update when source signals change', () => {
const count = createSignal(5);
const multiplier = createSignal(2);
const product = derivedSignal(
[count, multiplier],
(c, m) => c * m
);
const listener = jest.fn();
product(listener);
count(10);
expect(product.value).toBe(20);
expect(listener).toHaveBeenCalledWith(20);
multiplier(3);
expect(product.value).toBe(30);
expect(listener).toHaveBeenCalledWith(30);
});
it('should handle undefined initial values', () => {
const signal1 = createSignal<number>();
const signal2 = createSignal<number>();
const sum = derivedSignal(
[signal1, signal2],
(a, b) => a + b
);
expect(sum.value).toBeUndefined();
signal1(5);
expect(sum.value).toBeUndefined();
signal2(3);
expect(sum.value).toBe(8);
});
it('should work with complex derivations', () => {
const firstName = createSignal('John');
const lastName = createSignal('Doe');
const age = createSignal(25);
const person = derivedSignal(
[firstName, lastName, age],
(first, last, a) => ({
fullName: `${first} ${last}`,
age: a,
isAdult: a >= 18
})
);
expect(person.value).toEqual({
fullName: 'John Doe',
age: 25,
isAdult: true
});
firstName('Jane');
expect(person.value).toEqual({
fullName: 'Jane Doe',
age: 25,
isAdult: true
});
});
it('should chain derived signals', () => {
const base = createSignal(5);
const doubled = derivedSignal([base], n => n * 2);
const final = derivedSignal([doubled], n => n + 10);
expect(final.value).toBe(20);
base(10);
expect(doubled.value).toBe(20);
expect(final.value).toBe(30);
});
it('should support array transformations', () => {
const items = createSignal(['apple', 'banana', 'orange']);
const filter = createSignal('an');
const filtered = derivedSignal(
[items, filter],
(list, f) => list.filter(item =>
item.toLowerCase().includes(f.toLowerCase())
)
);
expect(filtered.value).toEqual(['banana', 'orange']);
filter('ap');
expect(filtered.value).toEqual(['apple']);
items(['grape', 'apple', 'mango']);
expect(filtered.value).toEqual(['grape', 'apple']);
});
});
describe('type safety', () => {
it('should maintain proper types in derived signals', () => {
const numberSignal = createSignal<number>(42);
const stringSignal = createSignal<string>('hello');
// This should type check
const derived = derivedSignal(
[numberSignal, stringSignal],
(num, str) => ({
number: num, // Should be typed as number
string: str, // Should be typed as string
combined: `${str}${num}`
})
);
expect(derived.value).toEqual({
number: 42,
string: 'hello',
combined: 'hello42'
});
});
});

View file

@ -1,145 +0,0 @@
/**
* Enhanced signal types to include value access
*/
export interface SignalListener<T> {
(event: T): void;
}
export interface SignalUnsubscriber {
(): void;
}
export interface SignalSubscriber<T> {
(listener: SignalListener<T>): SignalUnsubscriber;
}
export interface SignalDispatcher<T> {
(event: T): void;
}
export interface SignalValue<T> {
readonly value: T;
}
export type Signal<T> = SignalSubscriber<T> & SignalDispatcher<T> & SignalValue<T>;
export type SignalReturn = SignalUnsubscriber & void;
export function createSignal<T>(): Signal<T>;
export function createSignal<T>(initialValue: T): Signal<T>;
export function createSignal<T>(initialValue?: T): Signal<T> {
const subscribers = new Set<SignalListener<T>>();
let currentValue: T | undefined = initialValue;
const signal = ((eventOrListener: T | SignalListener<T>): any => {
if (typeof eventOrListener === 'function') {
subscribers.add(eventOrListener as SignalListener<T>);
// Call the listener immediately with current value if it exists
if (currentValue !== undefined) {
(eventOrListener as SignalListener<T>)(currentValue);
}
return () => { subscribers.delete(eventOrListener as SignalListener<T>) };
} else {
currentValue = eventOrListener;
subscribers.forEach(listener => listener(eventOrListener));
}
}) as Signal<T>;
// Add value getter
Object.defineProperty(signal, 'value', {
get: () => currentValue,
enumerable: true
});
return signal;
}
/**
* Utility types for handling async compute functions
*/
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type ComputeReturnType<Args extends any[], R> =
| ((...args: Args) => R)
| ((...args: Args) => Promise<R>);
/**
* Utility type to extract the event type from a Signal
*/
export type SignalEventType<T> = T extends Signal<infer E> ? E : never;
/**
* Creates a new signal that derives its value from other signals
* Supports both synchronous and asynchronous compute functions
*/
export function derivedSignal<Signals extends Signal<any>[], R>(
signals: [...Signals],
compute: ComputeReturnType<{ [K in keyof Signals]: SignalEventType<Signals[K]> }, R>
): Signal<UnwrapPromise<R>> {
const derivedSignal = createSignal<UnwrapPromise<R>>();
let computeVersion = 0;
let isComputing = false;
const updateValue = async (version: number, values: { [K in keyof Signals]: SignalEventType<Signals[K]> }) => {
if (isComputing) return;
isComputing = true;
try {
const result = compute(...Object.values(values) as { [K in keyof Signals]: SignalEventType<Signals[K]> });
const computedValue = result instanceof Promise ? await result : result;
// Only update if this is still the latest computation
if (version === computeVersion) {
derivedSignal(computedValue as UnwrapPromise<R>);
}
} catch (error) {
console.error('Error in derived signal computation:', error);
} finally {
isComputing = false;
}
};
// Compute initial value if all source signals have values
const initialValues = signals.map(s => s.value);
if (!initialValues.includes(undefined)) {
updateValue(computeVersion, initialValues as { [K in keyof Signals]: SignalEventType<Signals[K]> });
}
// Handle updates
withSignals(...signals)((...args) => {
computeVersion++;
updateValue(computeVersion, args as { [K in keyof Signals]: SignalEventType<Signals[K]> });
});
return derivedSignal;
}
/**
* Takes multiple signals and returns a function that accepts a callback
* which will receive the values from those signals
*/
export function withSignals<Signals extends Signal<any>[]>(
...signals: Signals
): <R>(
callback: (...values: { [K in keyof Signals]: SignalEventType<Signals[K]> }) => R
) => SignalUnsubscriber {
return (callback) => {
const unsubscribers: SignalUnsubscriber[] = [];
const values = new Array(signals.length) as { [K in keyof Signals]: SignalEventType<Signals[K]> };
let initialized = new Array(signals.length).fill(false);
signals.forEach((signal, index) => {
const unsubscribe = signal((value) => {
values[index] = value as { [K in keyof Signals]: SignalEventType<Signals[K]> }[number];
initialized[index] = true;
if (initialized.every(Boolean)) {
callback(...(values as unknown as { [K in keyof Signals]: SignalEventType<Signals[K]> }));
}
});
unsubscribers.push(unsubscribe);
});
return () => {
unsubscribers.forEach(unsubscribe => unsubscribe());
};
};
}

View file

@ -1,8 +1,7 @@
import { describe, it, expect } from '@jest/globals'
import { predictJson, predictType } from './utils'
import { predictJson, predictType } from './typePredictions'
describe('Utils', () => {
it('should properly predict TEXT type', () => {
expect(predictType('xxx', [{ 'xxx': 'dhjkafhkjafhkfas' }])).toEqual('TEXT')
})

View file

@ -1,56 +1,7 @@
import { get } from "https"
import { parse } from 'json5'
import * as fs from 'fs'
import { camelCase } from "lodash";
export const fetchBlobData = async (url: string, filePath: string) => {
return new Promise<void>((resolve, reject) => {
get(url, (response) => {
const statusCode = response.statusCode;
const contentType = response.headers['content-type'];
let error;
if (statusCode === 301 || statusCode === 302) {
// Handle redirect
const redirectUrl = response.headers.location;
if (!redirectUrl) {
reject('Redirect URL not found')
return;
}
fetchBlobData(redirectUrl, filePath).then(resolve).catch(reject);
return;
}
if (statusCode !== 200) {
error = new Error(`Request Failed. Status Code: ${statusCode}`);
} else if (!/^application\/octet-stream/.test(contentType ?? '')) {
error = new Error(`Invalid content-type. Expected application/octet-stream but received ${contentType}`);
}
if (error) {
console.error(error.message);
// Consume response data to free up memory
response.resume();
return;
}
const fileStream = fs.createWriteStream(filePath);
response.pipe(fileStream);
fileStream.on('finish', () => {
resolve()
fileStream.close()
});
fileStream.on('error', (err) => {
console.error(`Error writing to ${filePath}: ${err}`);
reject(`Error writing to ${filePath}: ${err}`)
});
}).on('error', (err) => {
console.error(`Error fetching blob data: ${err.message}`);
reject(`Error writing to ${filePath}: ${err}`)
});
})
};
export const predictType = (field: string, data: Array<Record<string, string | Object>>) => {
if (field === 'id') {

View file

@ -1,6 +1,4 @@
import { App } from "obsidian"
import { createGrid, GridOptions } from 'ag-grid-community';
import { themeQuartz } from '@ag-grid-community/theming';
export const displayNotice = (el: HTMLElement, text: string) => {
el.empty()
@ -51,7 +49,6 @@ const generateLink = (config: SqlSealAnchorElement, app: App) => {
link.addEventListener('click', (event) => {
event.preventDefault();
// Open the file in the active leaf (same tab)
const leaf = app.workspace.getLeaf();
const file = app.vault.getFileByPath(config.href)

View file

@ -1,18 +1,7 @@
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 {
...frontmatter,
path: file.path,
name: file.name.replace(/\.[^/.]+$/, ""),
id: file.path
}
}
import { App, Plugin, TFile } from "obsidian";
import { FieldTypes } from "src/utils/typePredictions";
import { sanitise } from "src/utils/sanitiseColumn";
import { AFileSyncTable } from "./tables/abstractFileSyncTable";
const extractFrontmatterFromFile = async (file: TFile, plugin: Plugin) => {
const frontmatter = plugin.app.metadataCache.getFileCache(file)?.frontmatter || {}

View file

@ -1,12 +1,10 @@
import { App, TFile } from "obsidian";
import { SqlSealDatabase } from "src/database";
import { TablesManager } from "src/dataLoader/collections/tablesManager";
import { SqlSealDatabase } from "src/database/database";
export abstract class AFileSyncTable {
constructor(
protected readonly db: SqlSealDatabase,
protected readonly app: App,
protected tableManager: TablesManager
protected readonly app: App
) {
}

View file

@ -1,9 +1,8 @@
import { App, getAllTags, Plugin, TAbstractFile, TFile } from "obsidian";
import { App, 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";
import { SqlSealDatabase } from "src/database/database";
import { predictType } from "src/utils/typePredictions";
const extractFrontmatterFromFile = async (file: TFile, plugin: Plugin) => {
@ -27,19 +26,17 @@ function fileData(file: TAbstractFile, {tags: _tags, ...frontmatter }: Record<st
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)
constructor(db: SqlSealDatabase, app: App, private readonly plugin: Plugin) {
super(db, app)
}
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> {
@ -49,7 +46,6 @@ export class FilesFileSyncTable extends AFileSyncTable {
// 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)
@ -60,7 +56,6 @@ export class FilesFileSyncTable extends AFileSyncTable {
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())
}

View file

@ -8,13 +8,11 @@ export class TagsFileSyncTable extends AFileSyncTable {
}
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) {
@ -38,6 +36,5 @@ export class TagsFileSyncTable extends AFileSyncTable {
'tag': 'TEXT',
'fileId': 'TEXT'
})
this.tableManager.getTableSignal('tags')(Date.now())
}
}

View file

@ -8,13 +8,11 @@ export class TasksFileSyncTable extends AFileSyncTable {
}
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) {
@ -52,6 +50,5 @@ export class TasksFileSyncTable extends AFileSyncTable {
'completed': 'INTEGER',
'filePath': 'TEXT'
})
this.tableManager.getTableSignal('tasks')(Date.now())
}
}

View file

@ -18,5 +18,6 @@
"0.12.1": "0.15.0",
"0.12.2": "0.15.0",
"0.12.3": "0.15.0",
"0.12.4": "0.15.0"
"0.12.4": "0.15.0",
"0.13.0": "0.15.0"
}