created project structure and a couple of commands

This commit is contained in:
stfrigerio 2025-01-29 20:35:48 +01:00
parent 9974c6b28c
commit 3706531e70
10 changed files with 418 additions and 87 deletions

152
main.ts
View file

@ -1,103 +1,81 @@
import {
App,
Plugin,
PluginSettingTab,
Setting,
Modal,
import {
App,
Plugin,
PluginSettingTab,
Setting,
Modal,
Notice,
FileSystemAdapter // <-- Import FileSystemAdapter
FileSystemAdapter,
Editor,
MarkdownView,
} from "obsidian";
import { DBService } from "./src/dbService";
import { inspectTableStructure, convertEntriesInNotes } from "./src/commands";
import { pickTableName } from "./src/helpers";
import { SqliteDBSettings, DEFAULT_SETTINGS } from "./src/types";
import initSqlJs, { Database } from "sql.js";
import { readFileSync, writeFileSync } from "fs";
interface MyWasmDBSettings {
dbFilePath: string;
}
const DEFAULT_SETTINGS: MyWasmDBSettings = {
dbFilePath: "",
};
export default class MyWasmDBPlugin extends Plugin {
settings: MyWasmDBSettings;
private db: Database | null = null;
export default class SqliteDBPlugin extends Plugin {
settings: SqliteDBSettings;
private dbService: DBService;
async onload() {
console.log("Loading MyWasmDBPlugin...");
console.log("Loading SqliteDBPlugin...");
await this.loadSettings();
this.dbService = new DBService();
//! i dont think we need this since we do it in each command
// this.addCommand({
// id: "open-db",
// name: "Open Local SQLite DB",
// callback: async () => {
// await this.openDatabase();
// },
// });
this.addCommand({
id: "open-wasm-db",
name: "Open SQLite (WASM) DB and read a table",
callback: async () => {
await this.readDatabase();
id: "inspect-table-structure",
name: "Inspect table structure",
editorCallback: async (editor: Editor, view: MarkdownView) => {
// ensure DB is loaded (if not, load)
await this.openDatabase();
await inspectTableStructure(this.dbService, editor, this.app);
},
});
this.addCommand({
id: "dump-table-to-notes",
name: "Dump Table Rows to Notes",
callback: async () => {
await this.openDatabase(); // ensure DB is loaded
// 1) pick a table
const chosenTable = await pickTableName(this.dbService, this.app);
if (!chosenTable) {
return; // user canceled or no tables
}
// 2) Call the dump function with the chosen table
await convertEntriesInNotes(this.dbService, chosenTable, this.app);
},
});
this.addSettingTab(new MyWasmDBSettingTab(this.app, this));
this.addSettingTab(new SqliteDBSettingTab(this.app, this));
}
onunload() {
console.log("Unloading MyWasmDBPlugin...");
this.db = null;
console.log("Unloading SqliteDBPlugin...");
}
private async readDatabase() {
const { dbFilePath } = this.settings;
if (!dbFilePath) {
new Notice("No DB path set in plugin settings.");
private async openDatabase(forceReload = true) {
const adapter = this.app.vault.adapter;
if (!(adapter instanceof FileSystemAdapter)) {
new Notice("This plugin only works with a local vault (FileSystemAdapter).");
return;
}
try {
// 1) Get the actual base path of the local vault, if we have a FileSystemAdapter
let basePath: string | null = null;
const adapter = this.app.vault.adapter;
if (adapter instanceof FileSystemAdapter) {
basePath = adapter.getBasePath(); // Now we can call getBasePath()
}
if (!basePath) {
new Notice("This plugin only works with a local vault (FileSystemAdapter).");
return;
}
// 2) Load the sql.js WASM module, telling it exactly where to find sql-wasm.wasm
// Suppose we've placed sql-wasm.wasm in the same folder as main.js, i.e.
// <Vault>/.obsidian/plugins/sqliteDB/sql-wasm.wasm
const SQL = await initSqlJs({
locateFile: (file) => {
// "file" will typically be "sql-wasm.wasm"
return `${basePath}/.obsidian/plugins/sqliteDB/${file}`;
},
});
// 3) Read file from disk
const fileBuffer = readFileSync(dbFilePath);
const uint8Array = new Uint8Array(fileBuffer);
// 4) Create the in-memory DB
this.db = new SQL.Database(uint8Array);
// 5) Run a sample query
const result = this.db.exec("SELECT name FROM sqlite_master WHERE type='table' LIMIT 1;");
if (result?.[0]?.values?.[0]?.[0]) {
new Notice(`Found table: ${result[0].values[0][0]}`);
} else {
new Notice("Connected, but found no tables.");
}
/* If you want to write changes back:
this.db.run("CREATE TABLE test (col1 int);");
const data = this.db.export(); // Uint8Array
writeFileSync(dbFilePath, Buffer.from(data));
*/
} catch (err) {
console.error(err);
new Notice("Error reading DB: " + (err as Error).message);
}
const basePath = adapter.getBasePath();
await this.dbService.ensureDBLoaded(this.settings, basePath, forceReload);
}
async loadSettings() {
@ -109,10 +87,10 @@ export default class MyWasmDBPlugin extends Plugin {
}
}
class MyWasmDBSettingTab extends PluginSettingTab {
plugin: MyWasmDBPlugin;
class SqliteDBSettingTab extends PluginSettingTab {
plugin: SqliteDBPlugin;
constructor(app: App, plugin: MyWasmDBPlugin) {
constructor(app: App, plugin: SqliteDBPlugin) {
super(app, plugin);
this.plugin = plugin;
}
@ -121,14 +99,14 @@ class MyWasmDBSettingTab extends PluginSettingTab {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "WASM DB Plugin Settings" });
containerEl.createEl("h2", { text: "SQLite DB Plugin Settings" });
new Setting(containerEl)
.setName("Database File Path")
.setDesc("Absolute path to the .db file on disk.")
.addText((text) =>
text
.setPlaceholder("C:\\path\\to\\your.db")
.setPlaceholder("/home/user/path/to/your.db")
.setValue(this.plugin.settings.dbFilePath)
.onChange(async (value) => {
this.plugin.settings.dbFilePath = value;

View file

@ -0,0 +1,135 @@
import { App, Notice, TFile, TFolder, normalizePath, FileSystemAdapter } from "obsidian";
import { DBService } from "../dbService";
import { ColumnPickerModal } from "../components/ColumnPickerModal";
/**
* Convert every row in the given table into a separate note.
* - Prompts the user to pick a column for filenames.
* - Creates a folder "<tableName>-dump" at the root of the vault.
* - For each row, creates a .md file whose name is the chosen column's value.
* - Inserts frontmatter with key:value pairs for each column.
*
* Usage:
* await convertEntriesInNotes(dbService, "MyTable", this.app);
*/
export async function convertEntriesInNotes(dbService: DBService, tableName: string, app: App) {
const db = dbService.getDB();
if (!db) {
new Notice("No DB loaded. Please open the DB first.");
return;
}
// 1) Get list of columns from the table
// PRAGMA table_info(tableName) gives columns metadata: cid, name, type, etc.
const colResult = db.exec(`PRAGMA table_info('${tableName}');`);
if (!colResult || colResult.length === 0) {
new Notice(`Could not read columns for table '${tableName}'.`);
return;
}
// colResult[0].values => array of rows, where row = [cid, name, type, ...]
const columns: string[] = colResult[0].values.map(row => row[1] as string);
if (!columns.length) {
new Notice(`No columns found in table '${tableName}'.`);
return;
}
// 2) Prompt user to pick which column is used for the note filenames
const pickColumn = async (): Promise<string | null> => {
return new Promise((resolve) => {
const modal = new ColumnPickerModal(app, columns, (col) => {
resolve(col);
});
modal.open();
});
};
const chosenColumn = await pickColumn();
if (!chosenColumn) {
new Notice("No column chosen. Aborting.");
return;
}
// 3) Create or get the folder at the root: "<tableName>-dump"
let basePath = "";
if (app.vault.adapter instanceof FileSystemAdapter) {
basePath = app.vault.adapter.getBasePath();
} else {
new Notice("This plugin only works in a local vault.");
return;
}
const folderName = `${tableName}-dump`;
const targetFolderPath = normalizePath(folderName);
// Check if folder already exists; if not, create it
let folder = app.vault.getAbstractFileByPath(targetFolderPath);
if (!folder) {
folder = await app.vault.createFolder(targetFolderPath);
} else if (!(folder instanceof TFolder)) {
new Notice(`A file named "${folderName}" already exists and is not a folder.`);
return;
}
// 4) Query *all* rows from the table
const allRowsResult = db.exec(`SELECT * FROM "${tableName}";`);
if (!allRowsResult || allRowsResult.length === 0) {
new Notice(`No rows found in table "${tableName}".`);
return;
}
const rowObj = allRowsResult[0]; // { columns: string[], values: any[][] }
const allCols = rowObj.columns; // e.g. ["id", "title", "content", ...]
const rows = rowObj.values; // array of arrays
// 5) Figure out which index corresponds to the "chosenColumn"
const columnIndex = allCols.indexOf(chosenColumn);
if (columnIndex < 0) {
new Notice(`Chosen column '${chosenColumn}' not found in table.`);
return;
}
// 6) Loop each row, create a note
let createdCount = 0;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
// The chosen column's value for the filename
let rawFileName = String(row[columnIndex]) || `Untitled_${i}`;
let safeFileName = sanitizeFilename(rawFileName) || `Untitled_${i}`;
let notePath = `${targetFolderPath}/${safeFileName}.md`;
// If file already exists, append a suffix
let suffix = 1;
while (app.vault.getAbstractFileByPath(notePath)) {
suffix++;
notePath = `${targetFolderPath}/${safeFileName}_${suffix}.md`;
}
// Build frontmatter with all columns
const frontmatterLines = ["---"];
for (let c = 0; c < allCols.length; c++) {
const colName = allCols[c];
const val = row[c] == null ? "" : String(row[c]);
// Escape special newlines or quotes if you want
frontmatterLines.push(`${colName}: ${val.replace(/\n/g, "\\n")}`);
}
frontmatterLines.push("---\n");
const fileContent = frontmatterLines.join("\n");
try {
await app.vault.create(notePath, fileContent);
createdCount++;
} catch (err) {
console.error(`Failed to create file at ${notePath}:`, err);
}
}
new Notice(`Created ${createdCount} notes in folder "${folderName}".`);
}
/**
* A quick utility to remove forbidden filename characters on Windows, etc.
*/
function sanitizeFilename(name: string): string {
// Replace slashes, backslashes, colons, etc.
return name.replace(/[\\\/:*?"<>|]/g, "_").trim();
}

4
src/commands/index.ts Normal file
View file

@ -0,0 +1,4 @@
import { inspectTableStructure } from "./inspectTableStructure";
import { convertEntriesInNotes } from "./convertEntriesInNotes";
export { inspectTableStructure, convertEntriesInNotes };

View file

@ -0,0 +1,49 @@
import { Notice, Editor } from "obsidian";
import { DBService } from "../dbService";
import { TablePickerModal } from "../components/TablePickerModal";
/**
* Show a table picker. On picking a table, query the first 10 rows and insert into the editor.
*/
export async function inspectTableStructure(
dbService: DBService,
editor: Editor,
app: any
) {
const db = dbService.getDB();
if (!db) {
new Notice("No DB loaded. Please open the DB first.");
return;
}
// 1) get all table names
const result = db.exec("SELECT name FROM sqlite_master WHERE type='table';");
if (!result || result.length === 0) {
new Notice("No tables found in the DB.");
return;
}
const tableNames = result[0].values.map((row) => row[0] as string);
// 2) show a modal to pick one
const modal = new TablePickerModal(app, tableNames, (selectedTable) => {
// 3) query first 10 rows from that table
const rowResult = db.exec(`SELECT * FROM "${selectedTable}" LIMIT 10;`);
if (!rowResult || rowResult.length === 0) {
editor.replaceSelection(`\nNo rows found in table ${selectedTable}.\n`);
return;
}
// rowResult[0].columns -> array of column names
// rowResult[0].values -> array of row arrays
const columns = rowResult[0].columns;
const rows = rowResult[0].values;
let output = `\n**Columns in "${selectedTable}"**\n`;
output += columns.join(", ");
output += "\n\n**First row**\n";
output += rows[0].join(", ");
editor.replaceSelection(output + "\n");
});
modal.open();
}

View file

@ -0,0 +1,33 @@
import { App, Notice } from "obsidian";
/**
* A simple fuzzy modal to pick which column to use for filenames.
*/
import { FuzzySuggestModal } from "obsidian";
class ColumnPickerModal extends FuzzySuggestModal<string> {
private columns: string[];
private onChoose: (col: string) => void;
constructor(app: App, columns: string[], onChoose: (col: string) => void) {
super(app);
this.columns = columns;
this.onChoose = onChoose;
this.setPlaceholder("Pick a column to use as filenames...");
}
getItems(): string[] {
return this.columns;
}
getItemText(item: string): string {
return item;
}
onChooseItem(item: string): void {
new Notice(`Selected column: ${item}`);
this.onChoose(item);
}
}
export { ColumnPickerModal };

View file

@ -0,0 +1,32 @@
import { FuzzySuggestModal, Notice } from "obsidian";
/**
* A modal that shows a list of table names. On choose, calls a callback.
*/
export class TablePickerModal extends FuzzySuggestModal<string> {
private tables: string[];
private onChoose: (tableName: string) => void;
constructor(app: any, tables: string[], onChoose: (tableName: string) => void) {
super(app);
this.tables = tables;
this.onChoose = onChoose;
this.setPlaceholder("Pick a table...");
}
// The items to show in the modal
getItems(): string[] {
return this.tables;
}
// How to display each item
getItemText(item: string): string {
return item;
}
// Called when user selects an item
onChooseItem(item: string, evt: MouseEvent | KeyboardEvent) {
new Notice(`Selected table: ${item}`);
this.onChoose(item);
}
}

52
src/dbService.ts Normal file
View file

@ -0,0 +1,52 @@
import { Notice, FileSystemAdapter } from "obsidian";
import initSqlJs, { Database } from "sql.js";
import { readFileSync } from "fs";
import { SqliteDBSettings } from "./types";
/**
* This class manages loading/reloading the SQLite DB using sql.js
*/
export class DBService {
private db: Database | null = null;
/**
* Ensure the DB is loaded. If it's not loaded yet, load it from disk.
* If it is already loaded, do nothing.
*
* If `forceReload` is true, always reload from disk.
*/
async ensureDBLoaded(settings: SqliteDBSettings, basePath: string, forceReload = false) {
if (!settings.dbFilePath) {
new Notice("No DB path set in plugin settings.");
return;
}
if (!this.db || forceReload) {
try {
const SQL = await initSqlJs({
locateFile: (file) => `${basePath}/.obsidian/plugins/sqliteDB/${file}`,
});
const fileBuffer = readFileSync(settings.dbFilePath);
const uint8Array = new Uint8Array(fileBuffer);
this.db = new SQL.Database(uint8Array);
// Check if we have at least one table
const result = this.db.exec("SELECT name FROM sqlite_master WHERE type='table' LIMIT 1;");
if (result?.[0]?.values?.[0]?.[0]) {
new Notice(`DB Loaded.`);
} else {
new Notice("DB Loaded, but found no tables.");
}
} catch (err) {
console.error(err);
new Notice("Error reading DB: " + (err as Error).message);
}
}
}
/**
* Returns the current Database instance, or null if not loaded.
*/
getDB(): Database | null {
return this.db;
}
}

3
src/helpers/index.ts Normal file
View file

@ -0,0 +1,3 @@
import { pickTableName } from "./pickTableName";
export { pickTableName };

View file

@ -0,0 +1,38 @@
import { App, Notice } from "obsidian";
import { DBService } from "../dbService";
import { TablePickerModal } from "../components/TablePickerModal";
/**
* Prompt the user to pick a table from sqlite_master.
* Returns the chosen table name, or null if none.
*/
export async function pickTableName(dbService: DBService, app: App): Promise<string | null> {
const db = dbService.getDB();
if (!db) {
new Notice("No DB loaded. Please open the DB first.");
return null;
}
// 1) Get all tables
const result = db.exec("SELECT name FROM sqlite_master WHERE type='table';");
if (!result || result.length === 0) {
new Notice("No tables found in the DB.");
return null;
}
// each row is [tableName]
const tableNames = result[0].values.map((row) => row[0] as string);
if (!tableNames.length) {
new Notice("No tables found in the DB.");
return null;
}
// 2) Show a fuzzy modal to let the user pick one
return new Promise((resolve) => {
const modal = new TablePickerModal(app, tableNames, (chosen) => {
resolve(chosen);
});
modal.open();
});
}

7
src/types.ts Normal file
View file

@ -0,0 +1,7 @@
export interface SqliteDBSettings {
dbFilePath: string;
}
export const DEFAULT_SETTINGS: SqliteDBSettings = {
dbFilePath: "",
};