adding ability to refresh the data when datasource changes

This commit is contained in:
Kacper Kula 2024-05-06 13:42:14 +01:00
parent 130c5fd16e
commit cd159d0911
6 changed files with 145 additions and 109 deletions

View file

@ -4,65 +4,15 @@ export class SealObserver {
private tables: Map<string, Set<Callback>>;
private verbose: boolean;
private files: Map<string, Set<string>>;
private tags: Map<string, Set<Callback>>;
constructor(verbose = false) {
this.tables = new Map();
this.files = new Map();
this.tags = new Map();
this.verbose = verbose;
}
registerFile(fileName: string) {
if (!this.files.has(fileName)) {
this.files.set(fileName, new Set<string>());
if (this.verbose) {
console.log(`File "${fileName}" registered.`);
}
}
}
registerTableToFile(tableName: string, fileName: string) {
if (!this.files.has(fileName)) {
this.files.set(fileName, new Set<string>());
if (this.verbose) {
console.log(`File "${fileName}" registered.`);
}
}
const tables = this.files.get(fileName);
if (tables !== undefined) {
tables.add(tableName);
if (this.verbose) {
console.log(`Table "${tableName}" registered to file "${fileName}".`);
}
}
}
registerElementToFile(elementName: string, fileName: string) {
if (!this.files.has(fileName)) {
this.files.set(fileName, new Set<string>());
if (this.verbose) {
console.log(`File "${fileName}" registered.`);
}
}
const elements = this.files.get(fileName);
if (elements !== undefined) {
elements.add(elementName);
if (this.verbose) {
console.log(`Element "${elementName}" registered to file "${fileName}".`);
}
}
}
registerTable(tableName: string) {
if (!this.tables.has(tableName)) {
this.tables.set(tableName, new Set<Callback>());
if (this.verbose) {
console.log(`Table "${tableName}" registered.`);
}
}
}
registerObserver(tableNames: string | string[], observer: Callback) {
registerObserver(tableNames: string | string[], observer: Callback, tag?: string) {
if (typeof tableNames === 'string') {
tableNames = [tableNames];
}
@ -80,9 +30,30 @@ export class SealObserver {
console.log(`Observer registered for table "${tableName}".`);
}
}
if (tag) {
if (!this.tags.has(tag)) {
this.tags.set(tag, new Set<Callback>());
}
const tags = this.tags.get(tag);
if (tags !== undefined) {
tags.add(observer);
}
}
});
}
unregisterObserversByTag(tag: string) {
if (this.tags.has(tag)) {
const observers = this.tags.get(tag);
if (observers) {
observers.forEach(observer => {
this.unregisterObserver(observer)
})
this.tags.delete(tag)
}
}
}
unregisterObserver(observer: Callback) {
this.tables.forEach(observers => {
observers.delete(observer);
@ -103,21 +74,4 @@ export class SealObserver {
}
}
}
fireFilenameObservers(fileName: string) {
if (this.files.has(fileName)) {
const tables = this.files.get(fileName);
if (tables) {
tables.forEach(tableName => {
this.fireObservers(tableName);
});
}
}
this.tables.forEach(observers => {
observers.forEach(observer => observer());
});
if (this.verbose) {
console.log(`Observers fired for file "${fileName}".`);
}
}
}

View file

@ -4,7 +4,7 @@ import path from 'path'
import Papa from 'papaparse'
import { prefixedIfNotGlobal } from "./sqlReparseTables"
import fs from 'fs'
import { fetchBlobData } from "./utils"
import { delay, fetchBlobData } from "./utils"
function isNumeric(str: string) {
if (typeof str != "string") return false // we only process strings!
@ -57,8 +57,6 @@ const predictType = (field: string, data: Array<Record<string, string>>) => {
return 'TEXT'
}
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
export class SqlSealDatabase {
private savedDatabases: Record<string, any> = {}
db: typeof Database
@ -96,7 +94,7 @@ export class SqlSealDatabase {
}
console.log('FETCHED')
await delay(1000) // Making sure everything is in order
await delay(1000) // Making sure everything is in order: ;
//@ts-ignore
const defaultDbPath = path.resolve(this.app.vault.adapter.basePath, this.app.vault.configDir, "obsidian.db")
@ -173,17 +171,8 @@ export class SqlSealDatabase {
return types;
}
async defineDatabaseFromUrl(unprefixedName: string, url: string, prefix: string) {
const name = prefixedIfNotGlobal(unprefixedName, [], prefix)
if (this.savedDatabases[name]) {
console.log('Database Exists', name)
return
}
async loadDataForDatabaseFromUrl(name: string, url: string) {
const file = this.app.vault.getFileByPath(url)
if (!file) {
console.log('File not found')
return
}
const data = await this.app.vault.cachedRead(file)
const parsed = Papa.parse(data, {
@ -194,9 +183,6 @@ export class SqlSealDatabase {
const fields = parsed.meta.fields
const { data: parsedData, types } = toTypeStatements(fields, parsed.data)
await this.createTable(name, types)
// this.savedDatabases[name] = url
// Purge the database
await this.db.prepare(`DELETE FROM ${name}`).run()
@ -213,4 +199,36 @@ export class SqlSealDatabase {
await insertMany(parsedData)
}
async defineDatabaseFromUrl(unprefixedName: string, url: string, prefix: string, reloadData: boolean = false) {
const name = prefixedIfNotGlobal(unprefixedName, [], prefix)
if (this.savedDatabases[name]) {
console.log('Database Exists', name)
if (reloadData) {
await this.loadDataForDatabaseFromUrl(name, url)
}
return name
}
const file = this.app.vault.getFileByPath(url)
if (!file) {
console.log('File not found')
return name
}
const data = await this.app.vault.cachedRead(file)
const parsed = Papa.parse(data, {
header: true,
dynamicTyping: false,
skipEmptyLines: true
})
const fields = parsed.meta.fields
const { data: parsedData, types } = toTypeStatements(fields, parsed.data)
await this.createTable(name, types)
// this.savedDatabases[name] = url
await this.loadDataForDatabaseFromUrl(name, url)
return name
}
}

View file

@ -1,5 +1,6 @@
import { BaseFrom, Parser, Select } from "node-sql-parser"
import { generatePrefix } from "./hash"
import { table } from "console"
const isGlobal = (table: string, globalTables: string[]) => {
return globalTables.map(t =>
@ -21,20 +22,29 @@ export const prefixedIfNotGlobal = (tableName: string, globalTables: string[], p
const updateSelect = (selectAst: Select, globalTables: string[], prefix: string) => {
if (!selectAst.from) {
return selectAst
return { selectAst: selectAst, tables: [] }
}
return {
...selectAst,
from: selectAst.from.map(from => {
// FIXME: better typing here.
if(isBaseFrom(from)) {
return {
...from,
table: prefixedIfNotGlobal(from.table, globalTables, prefix)
}
const tables: Array<string> = []
const updatedFrom = selectAst.from.map(from => {
if(isBaseFrom(from)) {
const t = prefixedIfNotGlobal(from.table, globalTables, prefix)
tables.push(t)
return {
...from,
table: t
}
return from
})
}
return from
})
return {
selectAst: {
...selectAst,
from: updatedFrom
},
tables
}
}
@ -42,8 +52,8 @@ export const updateTables = (selectStatement: string, globalTables: string[], pr
const parser = new Parser()
const { ast } = parser.parse(selectStatement)
if (!Array.isArray(ast) && ast.type === 'select') {
const updated = updateSelect(ast!, globalTables, prefix)
return parser.sqlify(updated)
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.')
}

View file

@ -4,11 +4,16 @@ import { displayData, displayError, displayInfo, displayLoader } from "./ui";
import { App, MarkdownPostProcessorContext } from "obsidian";
import { updateTables } from "./sqlReparseTables";
import { hashString } from "./hash";
import { SealObserver } from "./SealObserver";
import { delay } from "./utils";
export class SqlSeal {
public db: SqlSealDatabase
private observer: SealObserver
constructor(private readonly app: App, verbose = false) {
this.db = new SqlSealDatabase(app, verbose)
this.observer = new SealObserver(verbose)
this.observeAllFileChanges()
}
async connect() {
@ -30,12 +35,21 @@ export class SqlSeal {
return frontmatter
}
private observeAllFileChanges() {
// Use fs to observe file changes
this.app.vault.on('modify', async (file) => {
console.log('Firing observers for file:', file.path)
this.observer.fireObservers('file:' + file.path)
})
}
getHandler() {
return async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
displayLoader(el)
await this.connect()
const frontmatter = await this.resolveFrontmatter(ctx)
const prefix = hashString(ctx.sourcePath)
const regex = /TABLE\s+(.+)\s+=\s+file\(([^)]+)\)/g;
@ -43,7 +57,15 @@ export class SqlSeal {
while ((match = regex.exec(source)) !== null) {
const name = match[1];
const url = match[2];
this.db.defineDatabaseFromUrl(name, url, prefix)
const prefixedName = await this.db.defineDatabaseFromUrl(name, url, prefix)
this.observer.registerObserver(`file:${url}`, async () => {
console.log('File was changes and we need to update related table: ', name)
// Update table
await this.db.defineDatabaseFromUrl(name, url, prefix, true)
// Fire observers for the table
this.observer.fireObservers(`table:${prefixedName}`)
}, ctx.docId)
}
const selectRegexp = /SELECT\s+(.*)/g;
@ -51,11 +73,40 @@ export class SqlSeal {
if (selectMatch) {
try {
const selectStatement = selectMatch[0]
const selectUpdated = updateTables(selectStatement, ['files'], prefix)
const stmt = await this.db.db.prepare(selectUpdated)
const columns = await stmt.columns().map(column => column.name);
const data = await stmt.all(frontmatter)
displayData(el, columns, data)
const { statement, tables } = updateTables(selectStatement, ['files'], prefix)
const renderSelect = async () => {
const stmt = await this.db.db.prepare(statement)
const columns = await stmt.columns().map(column => column.name);
const data = await stmt.all(frontmatter)
displayData(el, columns, data)
}
// Register observer for each table
tables.forEach(table => {
const observer = async () => {
console.log('Table was changes and we need to update related select: ', table)
// FIXME: check if element is still in the DOM.
console.log(el, el.isShown(), el.isConnected)
if (!el.isConnected) {
// Unregistering using the context id
this.observer.unregisterObserversByTag(ctx.docId)
}
displayLoader(el)
// await delay(1000)
await renderSelect()
}
this.observer.registerObserver(`table:${table}`, observer, ctx.docId)
})
await renderSelect()
} catch (e) {
if (e instanceof RangeError && Object.keys(ctx.frontmatter).length === 0) {
displayInfo(el, 'Cannot access frontmatter properties in Live Preview Mode. Switch to Reading Mode to see the results.')

View file

@ -33,6 +33,7 @@ export const displayInfo = (el: HTMLElement, message: string) => {
}
export const displayLoader = (el: HTMLElement) => {
el.empty()
const loader = el.createEl("div", { cls: 'callout', text: 'Loading SQLSeal database...' })
loader.dataset.callout = 'info'
}

View file

@ -50,4 +50,6 @@ export const fetchBlobData = async (url: string, filePath: string) => {
reject(`Error writing to ${filePath}: ${err}`)
});
})
};
};
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))