diff --git a/main.ts b/main.ts index 91f5b3d..18785d2 100644 --- a/main.ts +++ b/main.ts @@ -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. - // /.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; diff --git a/src/commands/convertEntriesInNotes.ts b/src/commands/convertEntriesInNotes.ts new file mode 100644 index 0000000..29a8012 --- /dev/null +++ b/src/commands/convertEntriesInNotes.ts @@ -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 "-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 => { + 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: "-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(); +} diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..a003c08 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,4 @@ +import { inspectTableStructure } from "./inspectTableStructure"; +import { convertEntriesInNotes } from "./convertEntriesInNotes"; + +export { inspectTableStructure, convertEntriesInNotes }; diff --git a/src/commands/inspectTableStructure.ts b/src/commands/inspectTableStructure.ts new file mode 100644 index 0000000..4acf405 --- /dev/null +++ b/src/commands/inspectTableStructure.ts @@ -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(); +} diff --git a/src/components/ColumnPickerModal.ts b/src/components/ColumnPickerModal.ts new file mode 100644 index 0000000..1b26c19 --- /dev/null +++ b/src/components/ColumnPickerModal.ts @@ -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 { + 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 }; \ No newline at end of file diff --git a/src/components/TablePickerModal.ts b/src/components/TablePickerModal.ts new file mode 100644 index 0000000..add9dbe --- /dev/null +++ b/src/components/TablePickerModal.ts @@ -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 { + 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); + } +} diff --git a/src/dbService.ts b/src/dbService.ts new file mode 100644 index 0000000..8dfc33e --- /dev/null +++ b/src/dbService.ts @@ -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; + } +} diff --git a/src/helpers/index.ts b/src/helpers/index.ts new file mode 100644 index 0000000..171109b --- /dev/null +++ b/src/helpers/index.ts @@ -0,0 +1,3 @@ +import { pickTableName } from "./pickTableName"; + +export { pickTableName }; \ No newline at end of file diff --git a/src/helpers/pickTableName.ts b/src/helpers/pickTableName.ts new file mode 100644 index 0000000..a3ad71a --- /dev/null +++ b/src/helpers/pickTableName.ts @@ -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 { + 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(); + }); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..93622ed --- /dev/null +++ b/src/types.ts @@ -0,0 +1,7 @@ +export interface SqliteDBSettings { + dbFilePath: string; +} + +export const DEFAULT_SETTINGS: SqliteDBSettings = { + dbFilePath: "", +};